fix(gate): close the gaps in the plan-code guard, the button lists and the tooling
- planCodes: the quote and slash lookarounds let the commonest form through (a docstring or test title opening with a code, a slash pair); the two real exceptions (an episode titled "E1", ruff's E4/E7/E9) are now named and cut first, with cases that fail on the old pattern. audit_metrics counts by the same definition. - button-primitive: both file lists live in the rule body, out of sight of the snapshot and the register; each is now pinned to its exact set of ui/ files (red when either is widened). - check.sh --brief shows a short log whole and keeps the line before each hit, so ruff's rule and eslint's file path survive the excerpt. - lint-changed runs prettier over every changed frontend file, as the gate does, not only code. - CLAUDE.md and gates.md no longer claim the basedpyright baseline was found harmless: it was never triaged, and the reportOptional* entries are the likeliest real bugs.
This commit is contained in:
@@ -21,7 +21,7 @@ green **before** any merge, release, or handing work to review.
|
||||
- Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's `siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate.
|
||||
- **There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it.
|
||||
- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every scope and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
|
||||
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` (no burn-down mandate: E34 found none of them a real bug). A run that fixed some rewrites it — commit that.
|
||||
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` (no burn-down mandate, but none was ever triaged: a `reportOptional*` entry in code you touch may be a real bug). A run that fixed some rewrites it — commit that.
|
||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||
|
||||
## Workflow
|
||||
|
||||
+2
-2
@@ -94,14 +94,14 @@ project checks needs a row.
|
||||
| Scope | Narrowed | Why | Measured |
|
||||
| --- | --- | --- | --- |
|
||||
| backend/app + alembic | `typeCheckingMode` — `standard` | Not `strict`/`all`: those report on every untyped third-party boundary (SQLAlchemy rows, yt-dlp dicts) — thousands of findings, near-zero real ones. `standard` was measured at 7 findings, all fixed (E32 S1). | — |
|
||||
| backend/app + alembic | `baseline.json` — pre-existing errors absorbed | The lane fails on NEW errors only; the baseline only shrinks (the lane fails when a run tightens it and it is not committed). Matching is per (file, rule, column span) + count. No burn-down mandate (E35): E34 read the 7 standard-mode findings and none was a real bug, so the entries go when their code is touched, not as work of their own. | 195 entries (E32 S1); 176 (E35) |
|
||||
| backend/app + alembic | `baseline.json` — pre-existing errors absorbed | The lane fails on NEW errors only; the baseline only shrinks (the lane fails when a run tightens it and it is not committed). Matching is per (file, rule, column span) + count. No burn-down mandate (E35): the entries go when their code is touched, not as work of their own. None of them was ever triaged one by one (the 7 findings E34 read were the `standard` ones above, fixed and not in the baseline), so an entry is not known to be harmless — the `reportOptional*` ones (24 in E35) are the likeliest real bugs; read one before re-baselining code around it. | 195 entries (E32 S1); 176 (E35) |
|
||||
| tests/ | not type-checked | A test's job is to fail loudly when wrong — the opposite of the silent runtime failure this lane catches — and annotating fixtures for a checker is churn. | — |
|
||||
|
||||
## pytest (backend)
|
||||
|
||||
| Scope | Narrowed | Why | Measured |
|
||||
| --- | --- | --- | --- |
|
||||
| a hand run | `SIFTLODE_TEST_REQUIRE_DB=0` — the DB-backed and ffmpeg tests skip | The explicit "pure-logic only" run for a machine without Postgres. Off by default since E32: without it a missing DB is a hard error; the gate and CI never set it. | — |
|
||||
| a hand run | `SIFTLODE_TEST_REQUIRE_DB=0` — the DB-backed and ffmpeg tests skip | The explicit "pure-logic only" run for a machine without Postgres. Off by default since E32: without it a missing DB is a hard error; the gate never sets it. | — |
|
||||
|
||||
## Lanes and pipeline
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eslint } from "./lintProbe.testkit";
|
||||
import { eslint, uiFiles } from "./lintProbe.testkit";
|
||||
|
||||
// `local/button-primitive` (eslint.config.js): one primitive per button family (docs/DESIGN.md).
|
||||
// 268 hand-rolled buttons were swept onto ui/Button, IconButton, Pressable, Chip and
|
||||
@@ -43,6 +43,37 @@ describe("a hand-rolled button", () => {
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
// The primitive list lives in the rule body, where neither the gate snapshot nor docs/gates.md
|
||||
// sees it, so this holds it to exactly these files of ui/.
|
||||
it("is allowed in exactly the control primitives of ui/", { timeout: 60_000 }, async () => {
|
||||
const allowed: string[] = [];
|
||||
for (const file of uiFiles())
|
||||
if (
|
||||
(await hits(
|
||||
'export const R = () => <button type="button">x</button>;\n',
|
||||
`src/components/ui/${file}`,
|
||||
)) === 0
|
||||
)
|
||||
allowed.push(file);
|
||||
expect(allowed).toEqual([
|
||||
"AnchoredPopover.tsx",
|
||||
"Button.tsx",
|
||||
"Chip.tsx",
|
||||
"Combobox.tsx",
|
||||
"DragHandle.tsx",
|
||||
"FilterSelect.tsx",
|
||||
"FocusSafeButton.tsx",
|
||||
"IconButton.tsx",
|
||||
"Pressable.tsx",
|
||||
"PriorityStepper.tsx",
|
||||
"SegmentedToggle.tsx",
|
||||
"SelectTrigger.tsx",
|
||||
"SortControl.tsx",
|
||||
"Tabs.tsx",
|
||||
"form.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is refused spelled as an element with the button role; a component's role is its own", async () => {
|
||||
expect(await hits('export const F = () => <div role="button" tabIndex={0}>x</div>;\n')).toBe(1);
|
||||
expect(await hits('export const G = () => <span role="button">x</span>;\n')).toBe(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eslint } from "./lintProbe.testkit";
|
||||
import { eslint, uiFiles } from "./lintProbe.testkit";
|
||||
|
||||
// `local/button-primitive`'s disabled check (eslint.config.js): a raw `<button disabled>` throws a
|
||||
// focused keyboard user to <body> the moment it turns disabled. ~60 were swept onto ui/FocusSafeButton;
|
||||
@@ -34,4 +34,13 @@ describe("a raw button that can be disabled", () => {
|
||||
expect(await hits(DISABLED, "src/components/ui/FocusSafeButton.tsx")).toBe(0);
|
||||
expect(await hits(DISABLED, "src/components/ui/AnchoredPopover.tsx")).toBe(0);
|
||||
});
|
||||
|
||||
// The exemption lives in the rule body, where neither the gate snapshot nor docs/gates.md sees it,
|
||||
// so this holds it to exactly those two: every other file in ui/ is refused.
|
||||
it("is exempt in exactly those two of the ui/ files", { timeout: 60_000 }, async () => {
|
||||
const exempt: string[] = [];
|
||||
for (const file of uiFiles())
|
||||
if ((await hits(DISABLED, `src/components/ui/${file}`)) === 0) exempt.push(file);
|
||||
expect(exempt).toEqual(["AnchoredPopover.tsx", "FocusSafeButton.tsx"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1323,10 +1323,15 @@
|
||||
" rm -f \"$out\"",
|
||||
"}",
|
||||
"brief_excerpt() {",
|
||||
" local hits",
|
||||
" hits=\"$(grep -v -E '^testdb-[0-9]+ ' \"$1\" |",
|
||||
" grep -E -i -A 2 -m 20 'error|fail|✗|×|warn|would reformat|unused|vulnerab|found [0-9]')\"",
|
||||
" if [ -n \"$hits\" ]; then printf '%s\\n' \"$hits\"; else tail -n 20 \"$1\"; fi",
|
||||
" local log hits",
|
||||
" log=\"$(grep -v -E '^testdb-[0-9]+ ' \"$1\")\"",
|
||||
" if [ \"$(printf '%s\\n' \"$log\" | wc -l)\" -le 60 ]; then",
|
||||
" printf '%s\\n' \"$log\"",
|
||||
" return",
|
||||
" fi",
|
||||
" hits=\"$(printf '%s\\n' \"$log\" |",
|
||||
" grep -E -i -B 1 -A 2 -m 20 'error|fail|✗|×|warn|would reformat|unused|vulnerab|found [0-9]')\"",
|
||||
" if [ -n \"$hits\" ]; then printf '%s\\n' \"$hits\"; else printf '%s\\n' \"$log\" | tail -n 20; fi",
|
||||
"}",
|
||||
"ruff_pinned() {",
|
||||
" local pin host",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { ESLint } from "eslint";
|
||||
@@ -35,6 +36,12 @@ export const eslint = new ESLint({
|
||||
},
|
||||
});
|
||||
|
||||
/** Every component file in ui/, by name — for a probe that pins which of them a rule exempts. */
|
||||
export const uiFiles = (): string[] =>
|
||||
readdirSync(new URL("../components/ui/", import.meta.url))
|
||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
||||
.sort();
|
||||
|
||||
beforeAll(async () => {
|
||||
await eslint.lintText("", { filePath: "src/components/__probe.tsx" });
|
||||
}, 60_000);
|
||||
|
||||
@@ -19,15 +19,24 @@ const ALLOWED = new Set([
|
||||
"frontend/src/lib/planCodes.contract.test.ts",
|
||||
"scripts/audit_metrics.py",
|
||||
]);
|
||||
// Named exceptions, cut from a line before it is matched: a test show's episode titled "E1" (the
|
||||
// whole string, quotes included) and ruff's default rule families. Anything else that looks like a
|
||||
// code is one — a docstring or a test title opening with `"""E30:` / `it("E36:` included.
|
||||
const NOT_CODES = /"E\d{1,2}"|\bE4\/E7\/E9\b/g;
|
||||
const PLAN_CODES = [
|
||||
// an epic (`E28`, `E9.5`) or an older roadmap item (`R14`) — not `E2E`, not a ruff rule family
|
||||
// (`E4/E7/E9`), not a TV episode (`S2 · E5`, a test show's episode titled "E1")
|
||||
/(?<!S\d{1,2}\s?·?\s?)(?<![/"])\b(?:E\d{1,2}(?:\.\d+)?|R\d{1,2})\b(?![/"E])/,
|
||||
// an epic (`E28`, `E9.5`, `E28/E30`) or an older roadmap item (`R14`) — not `E2E`, not a TV
|
||||
// episode (`S2 · E5`)
|
||||
/(?<!S\d{1,2}\s?·?\s?)\b(?:E\d{1,2}(?:\.\d+)?|R\d{1,2})\b(?!E)/,
|
||||
// a sprint cited on its own (`(S6)`, `S3e2`) or a review round (`round 4`, `deep-review`) — not
|
||||
// `S3h`, the media-card decision docs/DESIGN.md is headed by and the design lab labels
|
||||
/\(S\d{1,2}[a-z]?\d?\)|\bS(?!3h\b)\d{1,2}[a-z]\d?\b|\b[Rr]ound[ -]\d|deep-review/,
|
||||
];
|
||||
|
||||
const isPlanCode = (line: string) => {
|
||||
const rest = line.replace(NOT_CODES, "");
|
||||
return PLAN_CODES.some((re) => re.test(rest));
|
||||
};
|
||||
|
||||
function tracked(): string[] {
|
||||
const out = execFileSync(
|
||||
"git",
|
||||
@@ -44,15 +53,34 @@ function tracked(): string[] {
|
||||
}
|
||||
|
||||
describe("plan codes", () => {
|
||||
it("are recognised in every form a citation takes", () => {
|
||||
const cited = [
|
||||
'"""E30: the new sink.',
|
||||
'it("E36: keeps x", () => {',
|
||||
"// both sides (E28/E30)",
|
||||
"# the roadmap's R6/R7 pair",
|
||||
"// held since E9.5",
|
||||
"// (S6) moved it",
|
||||
"// deep-review round 4 found it",
|
||||
];
|
||||
const clean = [
|
||||
'title="E1",',
|
||||
"# ruff's default rule set (E4/E7/E9 + F)",
|
||||
"// an E2E run",
|
||||
"// S2 · E5 is the second episode",
|
||||
"// the S3h media card",
|
||||
];
|
||||
expect(cited.filter((l) => !isPlanCode(l))).toEqual([]);
|
||||
expect(clean.filter(isPlanCode)).toEqual([]);
|
||||
});
|
||||
|
||||
it("are not cited in the code", () => {
|
||||
const files = tracked();
|
||||
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
|
||||
const hits = files.flatMap((f) =>
|
||||
readFileSync(new URL(f, REPO), "utf-8")
|
||||
.split("\n")
|
||||
.flatMap((line, i) =>
|
||||
PLAN_CODES.some((re) => re.test(line)) ? [`${f}:${i + 1}: ${line.trim()}`] : [],
|
||||
),
|
||||
.flatMap((line, i) => (isPlanCode(line) ? [`${f}:${i + 1}: ${line.trim()}`] : [])),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -28,9 +28,11 @@ BE_COMPLEXITY = 10 # ruff's C901 default
|
||||
FE_COMPLEXITY = 20 # the frontend's line
|
||||
|
||||
# what planCodes.contract.test.ts holds the code to: an epic (`E28`, `E9.5`) or a roadmap item (`R14`),
|
||||
# not `E2E`, not a ruff rule family (`E4/E7/E9`), not a TV episode (`S2 · E5`, an episode titled "E1")
|
||||
EPIC_CODE = re.compile(r'(?<![/"])\b(E\d{1,2}(\.\d+)?|R\d{1,2})\b(?![/"E])')
|
||||
# not `E2E`, not a TV episode (`S2 · E5`); NOT_CODES are its named exceptions, cut first (an episode
|
||||
# titled "E1", ruff's default `E4/E7/E9`)
|
||||
EPIC_CODE = re.compile(r"\b(E\d{1,2}(\.\d+)?|R\d{1,2})\b(?!E)")
|
||||
EPISODE = re.compile(r"S\d{1,2}\s?·?\s?E\d+")
|
||||
NOT_CODES = re.compile(r'"E\d{1,2}"|\bE4/E7/E9\b')
|
||||
NARRATION = re.compile(r"\b(used to|no longer)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -114,7 +116,12 @@ def _complexity() -> dict[str, object]:
|
||||
|
||||
def _noise(be: list[tuple[Path, list[str]]], fe: list[tuple[Path, list[str]]]) -> dict[str, object]:
|
||||
every = be + fe
|
||||
epic = [(p, ln) for p, ls in every for ln in ls if EPIC_CODE.search(EPISODE.sub("", ln))]
|
||||
epic = [
|
||||
(p, ln)
|
||||
for p, ls in every
|
||||
for ln in ls
|
||||
if EPIC_CODE.search(NOT_CODES.sub("", EPISODE.sub("", ln)))
|
||||
]
|
||||
be_c = sum(_comment_lines(ls, True) for _, ls in be)
|
||||
fe_c = sum(_comment_lines(ls, False) for _, ls in fe)
|
||||
return {
|
||||
|
||||
+16
-9
@@ -48,16 +48,23 @@ lane() { # lane <name> <command…> — run it, remember a failure, never stop
|
||||
fi
|
||||
rm -f "$out"
|
||||
}
|
||||
# The lines that say what broke: the first 20 error-looking ones, each with the two lines after it
|
||||
# (knip names the file on the line after "Unused files"); a log with none of them (a tool that
|
||||
# words its failures some other way) shows its tail instead, which is where a summary sits. The test
|
||||
# database's own log is left out: the DB lane provokes constraint errors on purpose, and its
|
||||
# `ERROR:` lines filled the excerpt ahead of the failing test.
|
||||
# What broke, in a log's own words. A short log is shown whole: a keyword filter keeps ruff's
|
||||
# summary line and drops the rule and file above it, and eslint's file path sits on a line of its
|
||||
# own above its errors. A long one shows its first 20 error-looking lines, each with the line before
|
||||
# it and the two after (knip names the file on the line after "Unused files"); a long log with none
|
||||
# of them shows its tail, which is where a summary sits. The test database's own log is left out:
|
||||
# the DB lane provokes constraint errors on purpose, and its `ERROR:` lines filled the excerpt ahead
|
||||
# of the failing test.
|
||||
brief_excerpt() {
|
||||
local hits
|
||||
hits="$(grep -v -E '^testdb-[0-9]+ ' "$1" |
|
||||
grep -E -i -A 2 -m 20 'error|fail|✗|×|warn|would reformat|unused|vulnerab|found [0-9]')"
|
||||
if [ -n "$hits" ]; then printf '%s\n' "$hits"; else tail -n 20 "$1"; fi
|
||||
local log hits
|
||||
log="$(grep -v -E '^testdb-[0-9]+ ' "$1")"
|
||||
if [ "$(printf '%s\n' "$log" | wc -l)" -le 60 ]; then
|
||||
printf '%s\n' "$log"
|
||||
return
|
||||
fi
|
||||
hits="$(printf '%s\n' "$log" |
|
||||
grep -E -i -B 1 -A 2 -m 20 'error|fail|✗|×|warn|would reformat|unused|vulnerab|found [0-9]')"
|
||||
if [ -n "$hits" ]; then printf '%s\n' "$hits"; else printf '%s\n' "$log" | tail -n 20; fi
|
||||
}
|
||||
|
||||
# --- static lanes ------------------------------------------------------------------------------------
|
||||
|
||||
+16
-6
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# The fast lint lanes on the uncommitted files only — ruff (check + format) on Python, eslint +
|
||||
# prettier on the frontend — for a check after every edit instead of a full gate run. It is a
|
||||
# convenience, never the gate: scripts/check.sh stays what a merge or a release requires.
|
||||
# The fast lint lanes on the uncommitted files only — ruff (check + format) on Python, eslint on the
|
||||
# frontend's code and prettier on all of the frontend — for a check after every edit instead of a
|
||||
# full gate run. It is a convenience, never the gate: scripts/check.sh stays what a merge or a
|
||||
# release requires.
|
||||
#
|
||||
# bash scripts/lint-changed.sh lint what `git status` shows as changed or new
|
||||
# bash scripts/lint-changed.sh --hook the same, as a Claude Code Stop hook: silent and exit 0 when
|
||||
@@ -23,12 +24,19 @@ fi
|
||||
|
||||
mapfile -t changed < <(git status --porcelain --untracked-files=all | sed -E 's/^.. //; s/.* -> //' |
|
||||
while read -r f; do [ -f "$f" ] && printf '%s\n' "$f"; done)
|
||||
py=() fe=()
|
||||
# eslint reads code; prettier reads every file the gate's `prettier --check .` does (a locale JSON,
|
||||
# a stylesheet), and `--ignore-unknown` passes over the ones it has no parser for.
|
||||
py=() fe=() pretty=()
|
||||
for f in "${changed[@]}"; do
|
||||
case "$f" in
|
||||
backend/*.py | scripts/*.py) py+=("$f") ;;
|
||||
esac
|
||||
case "$f" in
|
||||
frontend/*.ts | frontend/*.tsx | frontend/*.js | frontend/*.mjs) fe+=("${f#frontend/}") ;;
|
||||
esac
|
||||
case "$f" in
|
||||
frontend/*) pretty+=("${f#frontend/}") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
report="$(
|
||||
@@ -38,12 +46,14 @@ report="$(
|
||||
fi
|
||||
if [ ${#fe[@]} -gt 0 ]; then
|
||||
(cd frontend && npx eslint --max-warnings 0 "${fe[@]}" 2>&1)
|
||||
(cd frontend && npx prettier --check --log-level warn "${fe[@]}" 2>&1)
|
||||
fi
|
||||
if [ ${#pretty[@]} -gt 0 ]; then
|
||||
(cd frontend && npx prettier --check --ignore-unknown --log-level warn "${pretty[@]}" 2>&1)
|
||||
fi
|
||||
)"
|
||||
|
||||
if [ -z "$report" ]; then
|
||||
[ "$hook" = 1 ] || echo "lint-changed: clean (${#py[@]} py, ${#fe[@]} frontend)"
|
||||
[ "$hook" = 1 ] || echo "lint-changed: clean (${#py[@]} py, ${#pretty[@]} frontend)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$hook" = 1 ]; then
|
||||
|
||||
Reference in New Issue
Block a user