From 4263ff3f4db1e84b38aecd2ff0f194930589e3a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 04:17:38 +0000 Subject: [PATCH] feat(devx): pin the ported guard-main-checkout self-test, and stop --resync rewriting governed files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/upstream-port-pin.json` covered only `scripts/`, so the verbatim cross-repo ports under `.claude/hooks/**` — this repo's densest concentration of copies from the same upstream, and the one directory whose drift history is four separate human catches (#5459, #5712, #5789, #6042) — had no drift gate at all. Two changes, in the order they have to happen: 1. `--resync` now REFUSES to rewrite a ported path on the governed surface unless `--rewrite-governed-file` is passed, naming the path, the surface it matched and the flag. The governed set is read from `check-governed-queue-guard.mjs` rather than re-listed. The CHECK path is untouched: a drifted governed port reds with no flag and no exemption. 2. `.claude/hooks/guard-main-checkout.selftest.sh` is registered against the upstream blob the pin already names, with 14 declared divergences and a one-sentence `why` each. The wiring test's `pinned ⊆ patrol.paths` assertion silently encoded "the ledger only pins the sweeper's unit". It is re-scoped to the patrol's own unit, and the claim that matters for entries outside it is asserted directly: no pinned file falls inside `lint.yml`'s ignore set, so the PR that drifts one is never the PR on which the gate does not run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013uAaxiwgYDybsTNV9xwa1M --- .../upstream-port-parity-wiring.test.ts | 71 ++++++++++- scripts/check-upstream-port-parity.mjs | 118 +++++++++++++++++- scripts/upstream-port-pin.json | 91 ++++++++++++++ 3 files changed, 273 insertions(+), 7 deletions(-) diff --git a/scripts/__tests__/upstream-port-parity-wiring.test.ts b/scripts/__tests__/upstream-port-parity-wiring.test.ts index 5589c44ae0..ca3a824e78 100644 --- a/scripts/__tests__/upstream-port-parity-wiring.test.ts +++ b/scripts/__tests__/upstream-port-parity-wiring.test.ts @@ -86,17 +86,78 @@ describe('check-upstream-port-parity is wired, not merely present', () => { expect(pinned).toContain('scripts/invoked-as.mjs'); }); - it('the pinned files are the ones the patrol workflow watches', () => { - // Both directions of the same claim: a file added to the patrol's paths - // filter but not to the pin drifts unwatched, and a file in the pin that - // the patrol no longer uses is a stale obligation. + it("the patrol's own ported unit is pinned, in both directions", () => { + // ⚠️ Re-scoped by objectui#7263, which registered the first entry OUTSIDE + // `scripts/`. This assertion used to read `pinned ⊆ watched` — every pinned + // file must appear in the patrol's paths filter — which silently encoded + // "the ledger only ever pins the sweeper's unit". That was true of the two + // entries that existed and is not a property of the ledger: the patrol runs + // ONE ported program, and a hook self-test has no business in its filter. + // + // Both directions are kept, each scoped to the thing it is actually about: + // a ported file the patrol watches but nothing pins drifts unwatched, and a + // pinned file from the patrol's own unit that the patrol dropped is a stale + // obligation. The reach of the pin BEYOND that unit is the next test's job. const patrol = parseYaml( fs.readFileSync(path.join(ROOT, '.github/workflows/half-state-patrol.yml'), 'utf8'), ); const watched: string[] = patrol.on.pull_request.paths; const pin = JSON.parse(fs.readFileSync(path.join(ROOT, PIN), 'utf8')); const pinned: string[] = pin.files.map((f: { ported: string }) => f.ported); - for (const p of pinned) expect(watched).toContain(p); + // forward: everything the patrol watches, other than the workflow file that + // declares the watch, is a ported program and must be pinned. + for (const p of watched.filter((w) => !w.startsWith('.github/'))) expect(pinned).toContain(p); + // back: the patrol's own unit, identified by where the sweeper lives, must + // still be in that filter. + for (const p of pinned.filter((f) => f.startsWith('scripts/pm/'))) expect(watched).toContain(p); + }); + + it('every pinned file is one the gate actually runs on when it drifts', () => { + // The wiring claim that had to exist once the ledger reached past + // `scripts/` (objectui#7263). `lint.yml` runs this gate, and its `relevant` + // step skips every step below it when a pull request touches ONLY the paths + // it ignores. A ported file inside that ignore set would be pinned and + // unwatched at the same time: the PR that drifts it is exactly the PR on + // which the gate does not run, and the drift lands green — the failure this + // whole mechanism exists to make impossible, one level up. + const lintYml = fs.readFileSync(path.join(ROOT, '.github/workflows/lint.yml'), 'utf8'); + const relevant = lintYml.slice(lintYml.indexOf('id: relevant')); + const ignored = [...relevant.matchAll(/':\(exclude,glob\)([^']+)'/g)].map((m) => m[1]); + // Tokenised, not chained replaces: a chain rewrites the `*` it has already + // emitted into a substitution, and the resulting pattern matches nothing — + // an assertion that passes because it recognises nothing, which is the + // shape this whole file is about. + const matches = (glob: string, file: string) => + new RegExp( + `^${glob + .split(/(\*\*\/|\*\*|\*|\?)/) + .map((tok) => + tok === '**/' + ? '(?:.*/)?' + : tok === '**' + ? '.*' + : tok === '*' + ? '[^/]*' + : tok === '?' + ? '[^/]' + : tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + ) + .join('')}$`, + ).test(file); + + // The control leg: every glob in that list must actually recognise a file + // it is there to ignore. Without this the loop below is green on a matcher + // that never matches. + expect(ignored).toEqual(expect.arrayContaining(['**/*.md', 'content/**', 'docs/**', '.changeset/**'])); + expect(ignored.filter((g) => matches(g, 'docs/adr/0001-example.md'))).toEqual(['**/*.md', 'docs/**']); + expect(ignored.filter((g) => matches(g, 'content/docs/guide/a.md'))).toEqual(['**/*.md', 'content/**']); + expect(ignored.filter((g) => matches(g, '.changeset/lucky-pans-smile.md'))).toEqual(['**/*.md', '.changeset/**']); + + const pin = JSON.parse(fs.readFileSync(path.join(ROOT, PIN), 'utf8')); + const pinned: string[] = pin.files.map((f: { ported: string }) => f.ported); + for (const file of pinned) { + expect({ file, ignoredBy: ignored.filter((g) => matches(g, file)) }).toEqual({ file, ignoredBy: [] }); + } }); it('its self-test passes — the half that makes a green comparison mean something', () => { diff --git a/scripts/check-upstream-port-parity.mjs b/scripts/check-upstream-port-parity.mjs index 53837ad000..f7da7f8b4e 100644 --- a/scripts/check-upstream-port-parity.mjs +++ b/scripts/check-upstream-port-parity.mjs @@ -9,6 +9,9 @@ * node scripts/check-upstream-port-parity.mjs --self-test # verify the checker itself * node scripts/check-upstream-port-parity.mjs --resync --ref * # the deliberate re-sync act + * …the same, plus --rewrite-governed-file # …when the ported file is + * # GOVERNED SURFACE and you are + * # the human merging it * * ## What this gate is for * @@ -87,6 +90,27 @@ * (zero occurrences), but a divergence that still applies and no longer makes * sense is only visible to those tests. * + * ## `--resync` REFUSES to rewrite governed surface unless told, by name + * + * The ledger REGISTERS a file; `--resync` REWRITES one. Those are different + * powers and only the second one is dangerous here. `.claude/**`, `skills/**`, + * `docs/adr/**`, `AGENTS.md` and `CLAUDE.md` are this repository's governed + * surface — the operating rules every later session reads — and they carry a + * human-merge rule precisely because no green check can say whether a rule + * SHOULD be the rule. A tool that silently rewrites one of them in place, from + * another repository's bytes, is a governed-surface edit nobody chose. + * + * So the write path asks `check-governed-queue-guard.mjs` — the repository's own + * definition of that surface, reused rather than re-listed, so the two can never + * disagree — and refuses, naming the path, its surface and the flag that + * proceeds anyway. `--rewrite-governed-file` is spelled long on purpose: it is + * for the human doing the merge, and it reads as what it does at the call site. + * + * ⛔ The CHECK path is NOT governed by any of this. A drifted governed port reds + * exactly like any other, with no flag and no exemption — the refusal is about + * WRITING, and a gate that went quiet on the surface with the worst drift + * history would be the whole card inverted. + * * ## The divergences are a checklist, not a licence * * Every entry carries a `why`. An adaptation nobody can justify in one sentence @@ -104,6 +128,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { GOVERNED_SURFACES, governedPathsIn } from './check-governed-queue-guard.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -297,6 +322,54 @@ function resyncCommand(pin, entry) { ); } +/** + * The flag that lets `--resync` write a GOVERNED file. Long on purpose: it is + * typed by the human doing the merge, and it has to read as what it does. + */ +export const RESYNC_GOVERNED_FLAG = '--rewrite-governed-file'; + +/** + * May `--resync` write this ported path? Pure, so the self-test drives the real + * decision rather than a restatement of it, and so the answer costs nothing. + * + * The governed set is not restated here: `governedPathsIn` is the repository's + * own definition (`scripts/check-governed-queue-guard.mjs`), and a second copy + * of it would be one more thing to keep honest — the disease this whole file is + * about, one level up. + * + * @returns {{ write: boolean, governed: boolean, surface: object|null, reasons: string[] }} + */ +export function resyncWriteVerdict(portedPath, { allowGoverned = false } = {}) { + const [surface = null] = governedPathsIn([portedPath]); + if (!surface) return { write: true, governed: false, surface: null, reasons: [] }; + if (allowGoverned) { + return { + write: true, + governed: true, + surface, + reasons: [ + `${portedPath} is GOVERNED SURFACE (${surface.glob} — ${surface.what}) and ` + + `${RESYNC_GOVERNED_FLAG} was passed: rewriting it in place.`, + ], + }; + } + return { + write: false, + governed: true, + surface, + reasons: [ + `⛔ refusing to rewrite ${portedPath}: it is GOVERNED SURFACE ` + + `(${surface.glob} — ${surface.what}), which this repository merges by human review.`, + `A re-sync would replace it with another repository's bytes, which is a governed-surface`, + `edit nobody chose. The pin is unchanged and nothing was written.`, + ` • To see what a re-sync WOULD do, diff the upstream file against this one by hand.`, + ` • To do it anyway — as the human doing that merge — pass ${RESYNC_GOVERNED_FLAG}.`, + ` • Registering this file in the pin is NOT affected: checking is not writing, and a`, + ` drifted governed port reds this gate with no flag and no exemption.`, + ], + }; +} + function main(root = ROOT) { let pin; try { @@ -422,6 +495,11 @@ function resync(argv, root = ROOT) { ); return 1; } + const write = resyncWriteVerdict(entry.ported, { + allowGoverned: argv.includes(RESYNC_GOVERNED_FLAG), + }); + for (const r of write.reasons) (write.write ? console.log : console.error)(r); + if (!write.write) return 2; writeFileSync(path.join(root, entry.ported), text, 'utf8'); entry.upstreamSha256 = digest(upstreamText); pin.upstream.ref = ref; @@ -530,7 +608,42 @@ function selfTest() { t('a divergence whose upstream anchor vanished fails the re-sync loudly', lost.problems.length === 1); t('…naming the divergence that no longer applies', lost.problems.join(' ').includes('`extra-guard`')); - // ── row 5: a malformed pin is REFUSED, never read as clean ───────────────── + // ── row 5: --resync REFUSES to rewrite governed surface ──────────────────── + // The write path only. Every row drives `resyncWriteVerdict`, which is the + // decision `resync()` makes, so a green row means the refusal is reachable + // rather than merely written down. + const GOV = '.claude/hooks/guard-main-checkout.selftest.sh'; + const refused = resyncWriteVerdict(GOV); + t('a governed ported path is REFUSED by --resync', refused.write === false); + t('…and the refusal names the path', refused.reasons.join(' ').includes(GOV)); + t('…and names the flag that proceeds anyway', refused.reasons.join(' ').includes(RESYNC_GOVERNED_FLAG)); + t('…and names the surface it matched, not just "governed"', refused.reasons.join(' ').includes('.claude/**')); + // The refusal must not read as "this file cannot be pinned": registering is + // the whole point of the card that added this, and checking is not writing. + t('…and says registering/checking is unaffected', refused.reasons.join(' ').includes('drifted governed port reds this gate')); + const allowed = resyncWriteVerdict(GOV, { allowGoverned: true }); + t('…and the named flag lets it proceed', allowed.write === true && allowed.governed === true); + t('…saying out loud that it is rewriting a governed file', allowed.reasons.join(' ').includes('GOVERNED SURFACE')); + // Every surface the REGISTER declares, derived from it rather than re-listed: + // a second copy of that list here would drift from the guard's own. + t( + 'every surface in GOVERNED_SURFACES refuses, and the list is not copied here', + GOVERNED_SURFACES.every((sf) => resyncWriteVerdict(sf.exact ?? `${sf.prefix}specimen.txt`).write === false), + ); + // The other direction, which is the one that would make the gate useless in + // the ordinary case: a plain script is written exactly as before. + const plain = resyncWriteVerdict('scripts/pm/check-half-states.mjs'); + t('a NON-governed ported path is unchanged: it writes, and says nothing', plain.write === true && plain.governed === false && plain.reasons.length === 0); + t('…and the flag does not change it either', JSON.stringify(resyncWriteVerdict('scripts/pm/check-half-states.mjs', { allowGoverned: true })) === JSON.stringify(plain)); + // ⛔ The CHECK path is not governed by any of this. Drift in a governed port + // reds with no flag and no exemption — the refusal is about WRITING, and a + // gate that went quiet on the surface with the worst drift history would be + // this whole mechanism inverted. + const govEntry = { ...ENTRY, ported: GOV }; + t('checking a governed ported file is NOT gated: parity still holds', verifyFile(govEntry, PORTED).ok); + t('…and drift in one still REDS, with no flag involved', !verifyFile(govEntry, driftedOutside).ok); + + // ── row 6: a malformed pin is REFUSED, never read as clean ───────────────── const good = { upstream: { repo: 'o/r', ref: 'a'.repeat(40) }, files: [ENTRY] }; t('the fixture pin is well-formed', validatePin(good).length === 0); const broken = [ @@ -582,7 +695,8 @@ function selfTest() { `✓ check-upstream-port-parity self-test: ${cases.length} cases pass — parity holds on an undrifted copy, ` + 'drift outside the declared regions reds as a digest mismatch, drift inside one names its divergence, ' + 'an ambiguous anchor is refused rather than applied, the pin-bump procedure round-trips (and a vanished ' + - 'anchor fails it loudly), and every malformed-pin shape is refused instead of read as clean.', + 'anchor fails it loudly), `--resync` refuses to rewrite governed surface unless the named flag is passed ' + + 'while the CHECK path stays ungated, and every malformed-pin shape is refused instead of read as clean.', ); return 0; } diff --git a/scripts/upstream-port-pin.json b/scripts/upstream-port-pin.json index b24a4ded4a..b57ffd030b 100644 --- a/scripts/upstream-port-pin.json +++ b/scripts/upstream-port-pin.json @@ -137,6 +137,97 @@ "ported": " // A neighbour that must really be there. The ported spelling of this case\n // named `js-comment-mask.mjs`, which exists in objectstack and NOT here -- so\n // it silently became a second copy of the case below it, and both passed. The\n // existence assertion is what stops that from happening again the next time\n // the named file moves.\n const neighbour = resolve(SELF, '..', 'check-control-bytes.mjs');\n t('the neighbour fixture still exists (or the next case tests nothing)', existsSync(neighbour), neighbour);\n t('an unrelated existing file is not this module', !invokedAs(neighbour, SELF));\n" } ] + }, + { + "ported": ".claude/hooks/guard-main-checkout.selftest.sh", + "upstreamPath": ".claude/hooks/guard-main-checkout.selftest.sh", + "upstreamSha256": "fb413bdb1410c6333bfc24d924c0d1175daa8a3ee9ba43e1f5b1bd554a1887b6", + "divergences": [ + { + "id": "routed-key-header-sentence", + "why": "objectui#7686 taught the hook to pick its path key from the routed tool, so the header sentence describing what it reads was rewritten; the pinned upstream copy still says it reads .tool_input.file_path.", + "upstream": "# all — it reads .tool_input.file_path and makes a PATH-AND-WORKTREE decision — so these\n# cases are derived from what this hook actually decides, not ported from the sibling.\n", + "ported": "# all — it picks the payload's path key from the tool that sent it and makes a\n# PATH-AND-WORKTREE decision — so these cases are derived from what this hook actually\n# decides, not ported from the sibling.\n" + }, + { + "id": "ported-from-provenance-header", + "why": "The port added its own provenance paragraph (objectui#6451): upstream has no reason to record where its file was ported to, and this repo needs the two copies' convergence rule stated in the file.", + "upstream": "#\n# Fail-open by default, on purpose: the process cwd AND CLAUDE_PROJECT_DIR both default to a\n", + "ported": "#\n# PORTED from objectstack's copy of this matrix (objectstack-ai/objectstack, .claude/hooks/\n# guard-main-checkout.selftest.sh @ d63c8a2) under objectui#6451, and the port is VERBATIM:\n# the two repos' guard-main-checkout.sh differ by 9 diff lines that are all inside one\n# comment block, no executable line differs, and both settings.json route the identical\n# Edit|Write|NotebookEdit matcher — so the sibling file was first run here BYTE-FOR-BYTE\n# unmodified (via the two env vars below) and returned 87 passed, 0 failed. Not one case\n# needed adapting. The only edit below the header is the one remaining KNOWN HOLE section,\n# whose issue reference is re-pointed at this repo's own card for the same defect.\n# ⛔ Keep the two copies converged: a case that has to differ is evidence the HOOKS have\n# drifted, and that drift is the finding — not something to paper over here.\n#\n# Fail-open by default, on purpose: the process cwd AND CLAUDE_PROJECT_DIR both default to a\n" + }, + { + "id": "nbpay-payload-helper", + "why": "objectui#7686 promoted the NotebookEdit payload builder to a first-class helper used across the matrix; the pinned upstream copy defines one only inside the KNOWN HOLE section it still carries.", + "upstream": "\nexpect() { # expect [env…] — the common case\n", + "ported": "\nnbpay() { # nbpay [tool_name] -> the NotebookEdit payload shape\n # Matches the tool's documented input schema: notebook_path (absolute, required) and\n # new_source, with no file_path key anywhere in the payload.\n jq -nc --arg f \"$1\" --arg t \"${2:-NotebookEdit}\" \\\n '{session_id:\"selftest\",cwd:\"/payload-cwd-must-be-ignored\",tool_name:$t,\n tool_input:{notebook_path:$f,new_source:\"x\",edit_mode:\"replace\"}}'\n}\n\nexpect() { # expect [env…] — the common case\n" + }, + { + "id": "tool-name-selects-path-key-section", + "why": "objectui#7686 made tool_name select which key of tool_input holds the path, so the section that pinned 'tool_name is never consulted' became one row per routed tool.", + "upstream": "echo \"== tool_name is never consulted — scoping lives in the settings.json matcher ==\"\n# The hook decides on the path alone. Which tools reach it is the matcher's job, asserted\n# in the wiring section below; these cases pin that the hook itself does not second-guess it.\nfor t in Edit Write NotebookEdit MultiEdit AnythingElse; do\n check block \"tool_name=$t into \\$MAIN\" \"$(payload \"$MAIN/pkg/x.ts\" \"$t\")\"\n check allow \"tool_name=$t into \\$WT\" \"$(payload \"$WT/pkg/x.ts\" \"$t\")\"\ndone\n", + "ported": "echo \"== tool_name selects the path key — one row per tool, and the matcher is its pair ==\"\n# tool_name is consulted for exactly one thing: which key of tool_input holds the path. The\n# verdict itself still comes from the path alone. Tools the table names are read for their\n# own key; a tool it does not name is not routed here by the matcher, and keeps the\n# permissive read — any known key it happens to carry, else the session's dir.\nfor t in Edit Write MultiEdit; do\n check block \"tool_name=$t (file_path) into \\$MAIN\" \"$(payload \"$MAIN/pkg/x.ts\" \"$t\")\"\n check allow \"tool_name=$t (file_path) into \\$WT\" \"$(payload \"$WT/pkg/x.ts\" \"$t\")\"\ndone\ncheck block 'tool_name=NotebookEdit (notebook_path) into $MAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\"\ncheck allow 'tool_name=NotebookEdit (notebook_path) into $WT' \"$(nbpay \"$WT/pkg/x.ipynb\")\"\ncheck block 'unrouted tool carrying file_path, into $MAIN' \"$(payload \"$MAIN/pkg/x.ts\" AnythingElse)\"\ncheck allow 'unrouted tool carrying file_path, into $WT' \"$(payload \"$WT/pkg/x.ts\" AnythingElse)\"\ncheck block 'unrouted tool carrying notebook_path, into $MAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\" AnythingElse)\"\ncheck allow 'unrouted tool carrying notebook_path, into $WT' \"$(nbpay \"$WT/pkg/x.ipynb\" AnythingElse)\"\n" + }, + { + "id": "routed-tool-missing-key-is-drift-section", + "why": "objectui#7686 added the drift arm — a tool this guard routes, arriving with no path key, blocks instead of falling back to the session dir — and this section is what pins it.", + "upstream": "echo \"== a payload carrying no usable path is judged by CLAUDE_PROJECT_DIR — fails CLOSED ==\"\n# This is the branch the hook takes when it learns nothing from the payload. It is the\n# opposite posture from the Bash sibling (which fails open on an unparseable command): here\n# an unreadable payload on the shared checkout still BLOCKS. Safe direction, and deliberate\n# — it is an explicit `else` in the hook, not a fall-through.\nfor probe in '{\"tool_name\":\"Edit\",\"tool_input\":{}}' '{}' 'not json at all' '' '{\"tool_input\":{\"file_path\":\"\"}}'; do\n", + "ported": "echo \"== a ROUTED tool whose payload lacks its own path key is drift — it blocks, never guesses ==\"\n# The dangerous branch is the one that turns an unreadable payload into a confident verdict.\n# For a tool this guard is routed, an absent path key is not \"no path given\", it is the\n# tool's schema moving under the guard — and judging the session's dir instead would answer\n# the same way all session long, right or wrong by where that session is rooted. Every row\n# here is run with CLAUDE_PROJECT_DIR pointed somewhere that would ALLOW under a fallback,\n# except the first of each trio, so a passing `block` can only have come from the drift arm.\nfor probe in \\\n '{\"tool_name\":\"Edit\",\"tool_input\":{}}' \\\n '{\"tool_name\":\"Write\",\"tool_input\":{\"content\":\"x\"}}' \\\n '{\"tool_name\":\"NotebookEdit\",\"tool_input\":{\"new_source\":\"x\",\"edit_mode\":\"replace\"}}'\ndo\n PROJ=\"$MAIN\"; check block \"routed tool, no path key, CLAUDE_PROJECT_DIR=\\$MAIN [$probe]\" \"$probe\"\n PROJ=\"$WT\"; check block \"routed tool, no path key, CLAUDE_PROJECT_DIR=\\$WT [$probe]\" \"$probe\"\n PROJ=\"$PLAIN\"; check block \"routed tool, no path key, CLAUDE_PROJECT_DIR=\\$PLAIN [$probe]\" \"$probe\"\ndone\nPROJ=\"$PLAIN\"\n# the cross-key pair: the right tool, the other tool's key. Drift in both directions.\ncheck block 'NotebookEdit carrying file_path (the wrong key), into $WT' \"$(payload \"$WT/pkg/x.ipynb\" NotebookEdit)\"\ncheck block 'Edit carrying notebook_path (the wrong key), into $WT' \"$(nbpay \"$WT/pkg/x.ipynb\" Edit)\"\n\necho \"== an UNROUTED payload carrying no usable path is judged by CLAUDE_PROJECT_DIR — fails CLOSED ==\"\n# The remaining no-path branch: nothing in the payload names a tool this guard knows, so\n# there is no contract to have drifted. It is the opposite posture from the Bash sibling\n# (which fails open on an unparseable command): here an unreadable payload on the shared\n# checkout still BLOCKS. Safe direction, and deliberate — an explicit `else`, not a\n# fall-through.\nfor probe in '{}' 'not json at all' '' '{\"tool_input\":{\"file_path\":\"\"}}' '{\"tool_name\":\"AnythingElse\",\"tool_input\":{}}'; do\n" + }, + { + "id": "nopath-probe-uses-an-unrouted-tool", + "why": "The CLAUDE_PROJECT_DIR fallback row must use a tool the hook does NOT route, because after objectui#7686 a routed tool with no path key takes the drift arm instead of that branch.", + "upstream": "nopath='{\"tool_name\":\"Edit\",\"tool_input\":{}}'\n", + "ported": "nopath='{\"tool_name\":\"AnythingElse\",\"tool_input\":{}}'\n" + }, + { + "id": "nojq-notebook-and-tool-name-decoy-rows", + "why": "objectui#7686 extended the jq-less text-scan fallback to tool_name and notebook_path, so the fallback section gained the rows that pin both, decoys included.", + "upstream": "check block 'with jq: same decoy payload' \"$decoy\"\n\n", + "ported": "check block 'with jq: same decoy payload' \"$decoy\"\n# the fallback mirrors the whole table, not just one key: it must read tool_name and the\n# notebook key too, or notebook edits silently rejoin the no-path branch whenever jq is away\ncheck block 'no jq: NotebookEdit into $MAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\" PATH=\"$nojq\"\ncheck allow 'no jq: NotebookEdit into $WT' \"$(nbpay \"$WT/pkg/x.ipynb\")\" PATH=\"$nojq\"\ncheck allow 'no jq: NotebookEdit into $PLAIN' \"$(nbpay \"$PLAIN/x.ipynb\")\" PATH=\"$nojq\"\ncheck block 'no jq: NotebookEdit with no notebook_path is drift' \\\n '{\"tool_name\":\"NotebookEdit\",\"tool_input\":{\"new_source\":\"x\"}}' PATH=\"$nojq\"\n# tool_name gets the same decoy treatment as the path keys: quoted inside a string value its\n# quotes are escaped, so prose about a tool cannot re-key the scan\ndecoy2=\"$(jq -nc --arg f \"$MAIN/pkg/x.ts\" --arg c 'prose mentioning \"tool_name\": \"NotebookEdit\" verbatim' \\\n '{tool_name:\"Write\",tool_input:{content:$c,file_path:$f}}')\"\ncheck block 'no jq: escaped decoy tool_name in content loses to the real one' \"$decoy2\" PATH=\"$nojq\"\ncheck block 'with jq: same tool_name decoy payload' \"$decoy2\"\n\n" + }, + { + "id": "wiring-section-pins-the-matcher-pairing", + "why": "objectui#7686 made the settings.json matcher and the hook's known_path_keys table a checkable pair, so this section's heading and rationale say so rather than pinning the matcher alone.", + "upstream": "echo \"== wiring: settings.json must route Edit, Write and NotebookEdit to this hook ==\"\n# The hook is deliberately tool-agnostic, so the matcher is the ONLY thing that decides which\n# tools it sees. Nothing else in the repo checks that. Additions to the matcher are fine;\n# a removal is what this pins.\n", + "ported": "echo \"== wiring: the matcher routes Edit, Write and NotebookEdit, and every routed tool has a row ==\"\n# The matcher is the ONLY thing that decides which tools this hook sees, and the hook's\n# known_path_keys table is the only thing that decides what it reads out of each one.\n# Nothing else in the repo checks either half, so both are pinned here: a removal from the\n# matcher, and a tool routed with no row to read it by. Additions to the matcher are fine\n# — provided they bring their row.\n" + }, + { + "id": "matcher-to-table-pairing-block", + "why": "The pairing assertion itself, added by objectui#7686: every tool the matcher routes must have a row in the hook's table, a relation nothing in the pinned upstream copy checks.", + "upstream": " done\nelse\n", + "ported": " done\n\n # ── the pairing ───────────────────────────────────────────────────────────────────────\n # \"a tool the matcher routes here\" and \"a path key this hook knows\" must be a CHECKABLE\n # relation, not a convention: a tool routed here with no row is read for a key its payload\n # never carries, and the guard silently goes back to judging the session. So: every name\n # in the matcher must have a row in the hook's table. A row with no matcher entry is the\n # harmless direction — a tool the hook is ready for that nothing routes yet — so it prints\n # a note, not a failure.\n table=\"$(sed -n \"s/^known_path_keys='\\(.*\\)'\\$/\\1/p\" \"$hook\" | head -1)\"\n if [ -z \"$table\" ]; then\n fail=$((fail + 1)); printf ' FAIL the hook has no known_path_keys table for the matcher to pair with\\n'\n else\n routed=\"$(printf '%s' \"$matcher\" | tr '|' ' ')\"\n for tool in $routed; do\n row=\"\"\n for r in $table; do case \"$r\" in \"$tool=\"*) row=\"${r#*=}\" ;; esac; done\n if [ -n \"$row\" ]; then\n pass=$((pass + 1)); printf ' ok pair %-13s -> .tool_input.%s\\n' \"$tool\" \"$row\"\n else\n fail=$((fail + 1)); printf ' FAIL %s is routed to this hook but has no row in known_path_keys (%s)\\n' \"$tool\" \"$table\"\n fi\n done\n for r in $table; do\n t=\"${r%%=*}\"\n case \" $routed \" in\n *\" $t \"*) ;;\n *) printf ' note %s has a row in known_path_keys; the matcher does not route it\\n' \"$t\" ;;\n esac\n done\n fi\nelse\n" + }, + { + "id": "notebook-verdict-by-its-own-path", + "why": "objectui#7686 fixed notebooks to be judged by the notebook's own path, so this copy pins the FIXED behaviour where the pinned upstream copy pins the hole.", + "upstream": "\n# ── KNOWN HOLES ─────────────────────────────────────────────────────────────────────────\n", + "ported": "\necho \"== a notebook is judged by the NOTEBOOK's own path, exactly as a file edit is ==\"\n# The verdict must depend on where the notebook lives, never on where the session happens to\n# be rooted, so CLAUDE_PROJECT_DIR points at the WRONG place in every row here: a guard that\n# judged the session would answer the same way down each column instead of following the\n# path. The three `expect` rows are the same three notebooks through the Edit payload shape\n# — the two shapes must reach the same verdict, or the guard has one rule per tool.\nPROJ=\"$MAIN\"\ncheck block 'NotebookEdit into $MAIN, CLAUDE_PROJECT_DIR=$MAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\"\ncheck allow 'NotebookEdit into $WT, CLAUDE_PROJECT_DIR=$MAIN' \"$(nbpay \"$WT/pkg/x.ipynb\")\"\ncheck allow 'NotebookEdit into $PLAIN, CLAUDE_PROJECT_DIR=$MAIN' \"$(nbpay \"$PLAIN/x.ipynb\")\"\nPROJ=\"$WT\"\ncheck block 'NotebookEdit into $MAIN, CLAUDE_PROJECT_DIR=$WT' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\"\ncheck allow 'NotebookEdit into $WT, CLAUDE_PROJECT_DIR=$WT' \"$(nbpay \"$WT/pkg/x.ipynb\")\"\nPROJ=\"$PLAIN\"\ncheck block 'NotebookEdit into $MAIN, CLAUDE_PROJECT_DIR=$PLAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\"\ncheck allow 'NotebookEdit into $WT, CLAUDE_PROJECT_DIR=$PLAIN' \"$(nbpay \"$WT/pkg/x.ipynb\")\"\nPROJ=\"$WT\"; expect block \"$MAIN/pkg/x.ipynb\"\nPROJ=\"$MAIN\"; expect allow \"$WT/pkg/x.ipynb\"\nPROJ=\"$MAIN\"; expect allow \"$PLAIN/x.ipynb\"\nPROJ=\"$PLAIN\"\n# and the ancestor walk reaches notebooks too: a new notebook in a not-yet-created directory\ncheck block 'NotebookEdit, new file in a new dir under $MAIN' \"$(nbpay \"$MAIN/brand/new/nb.ipynb\")\"\ncheck allow 'NotebookEdit, new file in a new dir under $WT' \"$(nbpay \"$WT/brand/new/nb.ipynb\")\"\n\n# ── KNOWN HOLES ─────────────────────────────────────────────────────────────────────────\n" + }, + { + "id": "hole-heading-repointed", + "why": "The KNOWN HOLE heading names this repository's own card (objectui#7259) instead of upstream's number for the same defect, so a reader here can find the card that will flip these rows.", + "upstream": "echo \"== KNOWN HOLE #11809: any git-dir path containing /worktrees/ reads as a linked worktree ==\"\n", + "ported": "echo \"== KNOWN HOLE #7259: any git-dir path containing /worktrees/ reads as a linked worktree ==\"\n" + }, + { + "id": "hole-crossref-repointed", + "why": "The same re-pointing in the body, keeping upstream's number as an explicit cross-reference because the two hooks share these lines and must be fixed together.", + "upstream": "# When #11809 is fixed both of these become `block`.\n", + "ported": "# When #7259 is fixed both of these become `block`. (Same defect as the sibling repo's\n# objectstack-ai/objectstack#11809 — the hooks share these lines; fix them together.)\n" + }, + { + "id": "known-hole-notebook-section-removed", + "why": "The notebook-path hole is FIXED here (objectui#7260, by objectui#7686), so the section pinning it is gone; the pinned upstream copy still carries it as its own #11810.", + "upstream": "expect allow \"$ODD/pkg/brand/new/f.ts\" # ⛔ WRONG — same hole, reached through the ancestor walk\n\necho \"== KNOWN HOLE #11810: NotebookEdit's path key is notebook_path, which this hook never reads ==\"\n# The matcher routes NotebookEdit here, but the hook extracts only .tool_input.file_path, so\n# every notebook edit takes the no-path branch and is judged by CLAUDE_PROJECT_DIR instead of\n# by the file. The verdict below is a constant per session and is wrong in both directions.\n# When #11810 is fixed, these become block / allow / allow by the notebook's own path.\nnbpay() { jq -nc --arg f \"$1\" '{tool_name:\"NotebookEdit\",tool_input:{notebook_path:$f,new_source:\"x\",edit_mode:\"replace\"}}'; }\nPROJ=\"$MAIN\"\ncheck block 'NotebookEdit into $MAIN, CLAUDE_PROJECT_DIR=$MAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\"\ncheck block 'NotebookEdit into $WT, CLAUDE_PROJECT_DIR=$MAIN' \"$(nbpay \"$WT/pkg/x.ipynb\")\" # ⛔ WRONG — refuses the mandated location\ncheck block 'NotebookEdit into $PLAIN, CLAUDE_PROJECT_DIR=$MAIN' \"$(nbpay \"$PLAIN/x.ipynb\")\" # ⛔ WRONG — refuses a file in no repo\nPROJ=\"$WT\"\ncheck allow 'NotebookEdit into $MAIN, CLAUDE_PROJECT_DIR=$WT' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\" # ⛔ WRONG — unguarded edit into the shared checkout\nPROJ=\"$PLAIN\"\ncheck allow 'NotebookEdit into $MAIN, CLAUDE_PROJECT_DIR=$PLAIN' \"$(nbpay \"$MAIN/pkg/x.ipynb\")\" # ⛔ WRONG — same, from a session rooted outside any repo\n\necho \"== BOUNDARY: the jq-less fallback is a text scan, not a JSON parser ==\"\n", + "ported": "expect allow \"$ODD/pkg/brand/new/f.ts\" # ⛔ WRONG — same hole, reached through the ancestor walk\n\necho \"== BOUNDARY: the jq-less fallback is a text scan, not a JSON parser ==\"\n" + }, + { + "id": "mutation-recipe-classes", + "why": "The mutation recipe lists one mutation per behaviour class, and objectui#7686 added three classes (the notebook row, the wiring pair, the schema-drift arm) the pinned upstream copy has no lines for.", + "upstream": "# in the grep fallback (the jq-less class) · change the final exit 2 to exit 0 (every block).\n", + "ported": "# in the grep fallback (the jq-less class) · change the final exit 2 to exit 0 (every block) ·\n# point NotebookEdit's row at file_path (the notebook class) · delete NotebookEdit's row\n# altogether (the wiring pair, which reds on the matcher relation and not on a verdict) ·\n# replace the drift block with the project-dir fallback (the schema-drift class).\n" + } + ] } ] }