From dc492c701326d03c746c57a1688b8ee325ebfefb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:27:04 +0000 Subject: [PATCH] feat(devx): record a merge disposition for every generator, and gate it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generator-owned artifact now has a recorded disposition — routed to merge=os-regen, or NOT_DRIVER_MANAGED with a per-path reason — and check:merge-driver reconciles the generators themselves, so the next one is met by a red gate instead of by a merge conflict. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- .gitattributes | 27 +++++ scripts/git-merge-regen.mjs | 207 +++++++++++++++++++++++++++++++++++ scripts/regen-artifacts.mjs | 210 ++++++++++++++++++++++++++++++++++-- 3 files changed, 435 insertions(+), 9 deletions(-) diff --git a/.gitattributes b/.gitattributes index 09fb0ff8ed..58540b99ed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -93,6 +93,30 @@ # 18 anchors stale, 5 marked). The driver removes hand-merge rounds; it is never # the only signal. +# +# #13731 enumerated this file's blind spot and closed it: `check:merge-driver` now +# also reconciles the GENERATORS. Every `gen:*` script in every workspace manifest +# (and the root) must be accounted for — named as a row's `gen` below, or carrying a +# recorded disposition in NOT_DRIVER_MANAGED. Until then the gate was green over an +# artifact that was in NEITHER list, which is how #13646 and #13335 were each found +# by hand, during a merge. Eleven generators were unaccounted; each now has one. +# +# The three rows added here are the ones whose answer was "route": the per-skill +# reference indexes (#13335) and both halves of the react-blocks contract. Their +# neighbours got the other answer for reasons recorded per path — the skill docs and +# the AI skills guide are MIXED (a spliced block in hand-written prose, so a deferral +# would launder the prose), the two per-package test-typecheck ledgers are shrink-only +# ratchets, the sdui lockstep record cannot be regenerated without an objectui +# checkout, and the openapi/sbom outputs are gitignored so git never merges them. +# +# ⚠️ The same LOCAL-facility bound applies to all three: routing removes hand-merge +# rounds, it does NOT protect them in the merge queue. What protects them is +# server-side — `check:skill-refs` and `check:react-blocks` run in `lint.yml` on +# `pull_request` and `merge_group` with no `paths:` filter, and both RE-DERIVE their +# artifact from source rather than reading it back, so they also catch the silent +# case where two branches' rows do not overlap and the text merge exits 0 describing +# neither side. + packages/spec/spec-changes.json merge=os-regen packages/spec/liveness/state-counts.md merge=os-regen packages/spec/authorable-surface/** merge=os-regen @@ -107,3 +131,6 @@ docs/protocol-upgrade-guide.md merge=os-regen docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen content/docs/references/** merge=os-regen content/docs/permissions/system-context.mdx merge=os-regen +skills/*/references/_index.md merge=os-regen +skills/objectstack-ui/contracts/react-blocks.contract.json merge=os-regen +skills/objectstack-ui/references/react-blocks.md merge=os-regen diff --git a/scripts/git-merge-regen.mjs b/scripts/git-merge-regen.mjs index cee3b7a0be..5d4c7d7401 100755 --- a/scripts/git-merge-regen.mjs +++ b/scripts/git-merge-regen.mjs @@ -255,6 +255,210 @@ function reconcileScripts() { * manifest" — passes every other assertion here and fails that one, which is the * whole difference between a resolution and a search. */ +/** + * ⭐ The third reconciliation (#13731): every GENERATOR must have a recorded + * disposition, not merely every declared path. + * + * ## What was invisible, and why the two reconciliations above could not see it + * + * `reconcileAttributes` holds `.gitattributes` equal to `REGEN_ARTIFACTS`, and + * `reconcileScripts` holds every declared name to its owner's manifest. Both are + * exact, both were green — and both are closed over the paths somebody already + * declared. An artifact in NEITHER ledger is not a disagreement between them; it + * is absent from both, so the gate returned green while saying nothing about it. + * That is this repo's recurring shape (an instrument reporting green where it + * cannot see) and the price was paid twice by hand: #13646 and #13335 were each + * discovered by hitting a merge conflict, on unrelated PRs. + * + * ## The population, and the one thing it cannot see + * + * A generator-ish script is a manifest `scripts` key spelled `gen:*`, or one whose + * command carries a `--fix` / `--update` mode — #13731's definition, reproduced + * here so the count is the card's count. It is enumerated from the manifests + * themselves (78 workspace members plus the root), never from a hand-kept list, so + * generator number 12 enters this population by existing. + * + * ⚠️ Its bound, stated because a bound nobody wrote down is a bound nobody checks: + * a generator that NO manifest script names is invisible here. `scripts/*.mjs` + * invoked directly by a workflow is the shape this misses; that population belongs + * to `check:ratchet-remedy-authority`, which builds its own from `readdirSync`. + * This gate answers "is every generator the manifests declare accounted for", and + * that is the question the two ledgers are keyed to. + * + * The `--fix`/`--update` limb currently adds ZERO members beyond the `gen:*` keys + * (measured on this tree: all 21 members carry a `gen:` key). It is kept because it + * fails CLOSED — a future generator spelled `fix:foo` still lands here — and its + * false-positive class is bounded and cheap: a transform that rewrites hand-written + * source (`eslint --fix` and friends) would be caught and costs one ledger line + * saying it writes nothing generated. A gate that asks for one line is not a noisy + * gate; a silent gap that costs a merge conflict is the alternative being priced. + * + * ## Accounting is per (owner, script), never per bare name + * + * `gen:test-typecheck-debt` is defined in THREE manifests and writes three separate + * ledgers. Keyed by name alone, declaring the `packages/spec` copy would have + * accounted for the `client` and `rest` copies too — and those two were 2 of the 11 + * gaps this gate exists to find, so a name-keyed version of this check would have + * been born unable to see its own motivating case. + */ +function reconcileGenerators() { + const workspace = workspacePackages(REPO_ROOT); + const manifests = [ + { dir: '.', manifest: JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) }, + ...workspace, + ]; + + const key = (owner, script) => `${owner} :: ${script}`; + + // Everything the two ledgers account for, with the owner as part of the key. + const accounted = new Map(); + for (const e of REGEN_ARTIFACTS) { + for (const name of [e.gen, ...(e.alsoWrittenBy ?? [])]) { + accounted.set(key(ownerOf(e), name), `driver-managed: ${e.path}`); + } + } + for (const e of NOT_DRIVER_MANAGED) { + if (!e.gen) continue; + accounted.set(key(ownerOf(e), e.gen), `NOT_DRIVER_MANAGED: ${e.path}`); + } + + const population = []; + for (const p of manifests) { + const owner = p?.manifest?.name; + if (!owner) continue; + for (const [name, cmd] of Object.entries(p.manifest.scripts ?? {})) { + const generatorish = name.startsWith('gen:') || /(^|\s)--(fix|update)(\s|$|=)/.test(String(cmd)); + if (generatorish) population.push({ owner, name }); + } + } + + const unaccounted = population.filter((g) => !accounted.has(key(g.owner, g.name))); + // Two-way, for the same reason `reconcileScripts` is: a disposition naming a + // generator that no longer exists is a reason nobody can act on, and it makes the + // ledger read as covering a case the tree dropped. + const live = new Set(population.map((g) => key(g.owner, g.name))); + const dead = [...accounted.keys()].filter((k) => !live.has(k)); + + let ok = true; + if (unaccounted.length) { + ok = fail(`generator(s) with NO recorded merge disposition:\n ${unaccounted + .map((g) => `${g.name} [${g.owner}]`).join('\n ')}\n` + + ' Every generator must be in ONE of the two ledgers in scripts/regen-artifacts.mjs.\n' + + ' ⛔ Routing it is NOT the default answer. Ask the question this file exists to ask:\n' + + ' would "discard both sides and re-run the generator" ever lose a decision a human\n' + + ' made? If yes — a hand-written region, a shrink-only ratchet, a vendored record —\n' + + ' add a NOT_DRIVER_MANAGED entry with `gen` and a per-path `why`. If no, add a\n' + + ' REGEN_ARTIFACTS row AND the matching .gitattributes line (both, in one commit).\n' + + ' ⚠️ And routing is LOCAL: it never protects a path in the merge queue. If the real\n' + + ' problem is queue eviction, say so in the reason — sharding is the precedent.'); + } + if (dead.length) { + ok = fail(`disposition(s) naming a generator that no manifest defines:\n ${dead.join('\n ')}\n` + + ' The script was renamed or removed; the recorded reason now covers nothing.'); + } + if (ok) { + console.log(`✓ all ${population.length} generator(s) across ${manifests.length} manifest(s)` + + ' have a recorded disposition'); + } + return ok; +} + +/** + * The `untracked: true` dispositions, held against git rather than against their own + * prose (#13731). + * + * "git never merges it" is a legitimate answer to this ledger's question and an + * expiring one: the day somebody commits `sbom.json`, the recorded reason becomes + * false and the path silently rejoins the population with a disposition that reads + * as settled. Asserting it here means that day reddens a gate instead of surfacing, + * later, as the merge conflict this whole file exists to pre-empt. + */ +function reconcileUntrackedDispositions() { + const claims = NOT_DRIVER_MANAGED.filter((e) => e.untracked); + const wrong = []; + for (const e of claims) { + const spec = e.path.endsWith('/**') ? e.path.slice(0, -3) : e.path; + const tracked = execFileSync('git', ['ls-files', '--', spec], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); + if (tracked) wrong.push(`${e.path} — declared untracked, but git tracks ${tracked.split('\n').length} file(s)`); + } + if (wrong.length) { + return fail(`untracked disposition(s) no longer true:\n ${wrong.join('\n ')}\n` + + ' The reason recorded for these paths was "git never merges it". It does now.\n' + + ' Replace the entry with a real disposition: route it, or say why a text merge is right.'); + } + console.log(`✓ ${claims.length} untracked disposition(s) still hold — git tracks none of those paths`); + return true; +} + +/** + * `entryForPath` and GIT must read a declared path the same way (#13731). + * + * The table path and the `.gitattributes` pattern are the same string, so a + * divergence in what that string MEANS is invisible to `reconcileAttributes` — it + * compares bytes, and the bytes agree. The failure it lets through is specific and + * bad: git routes a real file to the driver, `entryForPath` fails to resolve it, and + * the driver REFUSES with a message blaming a missing table row that is right there. + * Discovered at merge time, on a path whose whole purpose was to make merges cheaper. + * + * Measured against `git check-attr` — git's own answer, not a second implementation + * of it — over the tracked files the table claims. A row matching NOTHING is a + * failure too: it is either a typo or an artifact that left the tree, and both read + * as "covered" until someone looks. + */ +function reconcileAttributeSemantics() { + const tracked = execFileSync('git', ['ls-files', '-z'], { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }).split('\u0000').filter(Boolean); + + const attrs = execFileSync('git', ['check-attr', '-z', 'merge', '--stdin'], { + cwd: REPO_ROOT, + encoding: 'utf8', + input: tracked.map((t) => t + '\u0000').join(''), + maxBuffer: 64 * 1024 * 1024, + }).split('\u0000'); + + // `-z` output is a flat stream of (path, attr, value) triples. + const gitSays = new Set(); + for (let i = 0; i + 2 < attrs.length; i += 3) { + if (attrs[i + 1] === 'merge' && attrs[i + 2] === DRIVER_NAME) gitSays.add(attrs[i]); + } + + const tableSays = new Set(tracked.filter((p) => entryForPath(p))); + const gitOnly = [...gitSays].filter((p) => !tableSays.has(p)); + const tableOnly = [...tableSays].filter((p) => !gitSays.has(p)); + + let ok = true; + if (gitOnly.length) { + ok = fail(`git routes these to merge=${DRIVER_NAME} but entryForPath does not resolve them:\n ` + + `${gitOnly.slice(0, 20).join('\n ')}\n` + + ' The driver would REFUSE them mid-merge, blaming an absent table row that is present.\n' + + ' entryForPath understands `a/b/**` and one `*` segment — teach it the form, or\n' + + ' respell the path in BOTH files.'); + } + if (tableOnly.length) { + ok = fail(`entryForPath claims these but git does not route them:\n ` + + `${tableOnly.slice(0, 20).join('\n ')}\n` + + ' Those files text-merge today while the table reads as covering them.'); + } + // A row that matches nothing is not "covered", it is unmeasured. + const empty = REGEN_ARTIFACTS + .filter((e) => !tracked.some((p) => entryForPath(p)?.path === e.path)) + .map((e) => e.path); + if (empty.length) { + ok = fail(`declared path(s) matching no tracked file: ${empty.join(', ')}\n` + + ' A typo, or the artifact left the tree. Either way nothing here is being protected.'); + } + if (ok) { + console.log(`✓ entryForPath agrees with git check-attr on all ${gitSays.size} routed file(s)`); + } + return ok; +} + function reconcileOwnership() { const workspace = workspacePackages(REPO_ROOT); const rootScripts = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); @@ -486,7 +690,10 @@ if (process.argv.includes('--self-test')) { console.log('git-merge-regen --self-test\n'); const results = [ reconcileAttributes(), + reconcileAttributeSemantics(), reconcileScripts(), + reconcileGenerators(), + reconcileUntrackedDispositions(), reconcileOwnership(), hookIsExecutable(), registeredDriverResolves(), diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 156b00dbe1..f5ff421a1a 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -96,7 +96,18 @@ export const REGEN_ARTIFACTS = Object.freeze([ // explicit `--update-base` writes it, so it was never on the churn path that // made the other three the queue's serialization point — and its `baseRev` is // one commit for the whole surface, which a per-shard copy would let drift. - { path: 'packages/spec/authorable-surface.base.json', gen: 'gen:schema', check: 'check:authorable-surface' }, + // + // `alsoWrittenBy` records the generator the paragraph above deliberately refuses + // to name as `gen`, so the #13731 accounting can see that + // `gen:authorable-surface-base` HAS a recorded disposition rather than reporting + // it as a generator nobody has judged. It is a declaration for that reconciliation + // only — no consumer runs it, which is the whole point of it not being `gen`. + { + path: 'packages/spec/authorable-surface.base.json', + gen: 'gen:schema', + check: 'check:authorable-surface', + alsoWrittenBy: ['gen:authorable-surface-base'], + }, { path: 'packages/spec/json-schema.manifest/**', gen: 'gen:schema', check: 'check:authorable-surface' }, // The #4666 default-value ratchet — what an author gets when they OMIT a key. // Same producer, same gate and the same sorted-array-per-category shape as its @@ -218,13 +229,81 @@ export const REGEN_ARTIFACTS = Object.freeze([ check: 'check:system-context-census', owner: ROOT_OWNER, }, + // #13335 / #13731. The per-skill reference index — one row per `packages/spec` + // source module, rendered whole by `gen:skill-refs`. Nine tracked files today, + // matched as a SEGMENT glob so a tenth skill arrives routed instead of arriving + // unrouted and silent (`entryForPath` learned the form for this row). + // + // The measured case is #13335's, and it is the textbook shape: PR #13262 and the + // #13263 branch each regenerated `skills/objectstack-ui/references/_index.md`, + // and `git merge origin/main` conflicted on adjacent rows — + // + // - node_modules/@objectstack/spec/src/ui/report.zod.ts — Exports: ReportType, ... + // ======= + // - node_modules/@objectstack/spec/src/ui/report.zod.ts — Report Type Enum + // + // Both sides are correct-for-themselves projections of their own tree, and the + // merged tree's index equals NEITHER — the same "no text merge can reach the + // answer" property that put the elevation census page above here. The correct + // resolution #13335 records by hand (take either side, commit the merge, + // regenerate, let `check:skill-refs` prove it) is exactly what this row plus + // `os-regen-merge.sh` step 4 do mechanically. + // + // ⚠️ Routing is the CHEAP half and never the protection — the driver is LOCAL, so + // it does nothing for a merge-queue rebuild (the header's standing warning). The + // load-bearing half is server-side and already wired: `check:skill-refs` runs in + // `lint.yml` (`typecheck-source-gates`) on `pull_request` AND `merge_group` with + // no `paths:` filter, and it RE-DERIVES the index from the spec sources rather + // than reading the file back — so it catches the silent case too, which is the + // one that matters here (two branches whose rows do not overlap merge to exit 0 + // and a file describing neither side). + // + // No `readsDist`/`readsSchemaTree`: the generator walks `src/` directly. + { path: 'skills/*/references/_index.md', gen: 'gen:skill-refs', check: 'check:skill-refs' }, + // #13731. The ADR-0081 react-tier contract, both halves. Generated WHOLE — the + // markdown's frontmatter and its "do not edit by hand" banner are emitted by the + // generator too, so there is no hand-written region for a deferral to launder, + // which is the question this table exists to ask. A projection of the + // `REACT_BLOCKS` definition in `@objectstack/spec/ui`, one section per block: two + // PRs adding different blocks are a set union that git reports as a conflict. + // + // Same cheap-half caveat as its neighbour above, same answer: `check:react-blocks` + // runs in `lint.yml` on `pull_request` and `merge_group`, re-deriving from the + // definition, so the driver removes hand-merge rounds and is never the only signal. + { + path: 'skills/objectstack-ui/contracts/react-blocks.contract.json', + gen: 'gen:react-blocks', + check: 'check:react-blocks', + }, + { + path: 'skills/objectstack-ui/references/react-blocks.md', + gen: 'gen:react-blocks', + check: 'check:react-blocks', + }, ]); /** - * Tracked files that LOOK generator-owned and deliberately are not. Recorded - * rather than omitted: the dangerous mistake here is adding a path to - * `.gitattributes` because a generator writes it, without asking whether - * recomputing it can *lose* a decision a human made. + * Files that LOOK generator-owned and deliberately are not. Recorded rather than + * omitted: the dangerous mistake here is adding a path to `.gitattributes` because + * a generator writes it, without asking whether recomputing it can *lose* a + * decision a human made. + * + * Two optional fields, both added by #13731 and both read only by + * `reconcileGenerators` in `git-merge-regen.mjs`: + * + * `gen` / `owner` — the generator whose output this path is. Present where the + * accounting needs it, i.e. where the generator appears in no `REGEN_ARTIFACTS` + * row's `gen` and this entry is therefore the only record of its disposition. + * `owner` defaults to `DEFAULT_OWNER` exactly as a row's does, and it is part + * of the key: `gen:test-typecheck-debt` exists in THREE manifests writing three + * different ledgers, and declaring one of them must not silently account for + * the other two — that was 2 of the 11 unaccounted generators #13731 found. + * + * `untracked` — this path is not in git at all (gitignored build output). The + * disposition is still recorded, because "git never merges it" is an answer to + * the question and its absence is not. Asserted rather than asserted-once: the + * self-test refuses if such a path becomes tracked, which is the moment the + * reason expires and a real disposition is owed. */ export const NOT_DRIVER_MANAGED = Object.freeze([ { @@ -244,6 +323,7 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ }, { path: 'packages/spec/test-typecheck-debt.json', + gen: 'gen:test-typecheck-debt', why: 'a SHRINK-ONLY ratchet, same trade as docs-import-surface.baseline.json above (#5286). ' + '`gen:test-typecheck-debt` writes it, and on a merge it is exactly the file two branches ' @@ -257,6 +337,7 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ }, { path: 'packages/spec/src/migrations/registry.ts', + gen: 'gen:migration-registry', why: 'a MIXED file since #7297, and the mix is exactly why the driver must not own it. Its three ' + 'append tables are now generated into marked regions from `src/migrations/entries/` (one file ' @@ -288,6 +369,87 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ + 'edits silently. Route the generated FILE; leave the neighbours to text-merge, which is ' + 'correct for prose and always was.', }, + // ── #13731: the remaining generators, one recorded disposition each ────────── + { + path: 'packages/client/test-typecheck-debt.json', + gen: 'gen:test-typecheck-debt', + owner: '@objectstack/client', + why: + 'a SHRINK-ONLY ratchet — the same file, the same generator and the same trade as ' + + '`packages/spec/test-typecheck-debt.json` above, one package over. It is listed ' + + 'SEPARATELY rather than covered by its sibling on purpose: three manifests define ' + + '`gen:test-typecheck-debt` and each writes its own ledger, so one entry standing for ' + + 'all three would be a disposition nobody actually made for this file. Recomputing it ' + + 'mid-merge records whatever the half-merged tree compiles to, and a file that GAINED ' + + 'errors enters the ledger as merge noise instead of as red.', + }, + { + path: 'packages/rest/test-typecheck-debt.json', + gen: 'gen:test-typecheck-debt', + owner: '@objectstack/rest', + why: + 'a SHRINK-ONLY ratchet — see `packages/client/test-typecheck-debt.json` directly above; ' + + 'same generator, same per-package ledger, same reason a merge must never recompute it.', + }, + { + path: 'packages/sdui-parser/objectui-lockstep.json', + gen: 'gen:sdui-lockstep', + owner: ROOT_OWNER, + why: + 'a VENDORED RECORD OF ANOTHER REPOSITORY, and the only entry here that a merge could not ' + + "regenerate even if regenerating were right. `--update` re-records objectui's side of the " + + 'sdui-parser lockstep and needs an objectui CHECKOUT to do it; a merge driver has no ' + + 'network, no build and no sibling checkout, so "recompute from the merged sources" names ' + + 'sources that are not in this tree at all. And the file is a human decision twice over — ' + + 'the pinned `.objectui-sha` records WHICH objectui revision someone ported to, which is an ' + + 'act of porting and not a projection of this repo. Regenerating it during a merge would ' + + 'either fail or silently re-point the lockstep at whatever checkout happened to be on disk, ' + + 'and a wrong answer here means the save gate and the renderer accept different grammars ' + + 'while every gate stays green.', + }, + { + path: 'skills/README.md', + gen: 'gen:skill-docs', + why: + 'MIXED, and the mix is the whole reason — the same trade `packages/spec/src/migrations/' + + 'registry.ts` is kept out of the table for. `gen:skill-docs` rewrites ONLY the region ' + + 'between `BEGIN/END GENERATED: skills`: 17 of the file\'s 114 lines. The other 97 are ' + + 'hand-written prose about the bundle layout, and the driver defers the WHOLE file to OURS ' + + "— so routing it would let a regeneration launder away a sibling's prose edit, trading a " + + 'merge conflict for a silent loss. `check:skill-docs` guards the generated region instead, ' + + 'and a prose conflict here is a human\'s, as it always was.', + }, + { + path: 'content/docs/ai/skills-reference.mdx', + gen: 'gen:skill-docs', + why: + 'MIXED, exactly as `skills/README.md` above — one spliced `BEGIN/END GENERATED: skills` ' + + 'block inside 267 lines of hand-written guide prose. Same generator, same deferral hazard, ' + + 'same answer: guard the block with `check:skill-docs`, leave the prose to text-merge.', + }, + { + path: 'packages/spec/json-schema/**', + gen: 'gen:openapi', + untracked: true, + why: + 'GITIGNORED build output (`.gitignore:61`) — git never merges it, so it has no merge ' + + 'semantics to decide. Recorded rather than omitted because the tree LOOKS like the routed ' + + '`json-schema.manifest/**` next to it and invites the symmetry. It is also the tree ' + + '`readsSchemaTree` already warns about: a merge never delivers it, and whatever sits on ' + + 'disk describes one side. If it is ever committed, this entry expires and a real ' + + 'disposition is owed — the self-test refuses at that moment rather than after the merge ' + + 'that needed it.', + }, + { + path: 'sbom.json', + gen: 'gen:sbom', + untracked: true, + why: + 'GITIGNORED build output (`.gitignore:73`), produced at release time from the manifests. ' + + 'Nothing merges it and no `check:` proves it current, so it has no place in either ' + + 'ledger — recorded so that "no disposition" is not confused with "not yet decided". Same ' + + 'expiry clause as the entry above: committing it turns this entry red.', + }, { path: 'docs/audits/**', why: @@ -389,9 +551,39 @@ export function ownerRunCommand(owner, script, { silent = false } = {}) { return owner === ROOT_OWNER ? `pnpm${s} ${script}` : `pnpm${s} --filter ${owner} ${script}`; } -/** Resolve the entry that owns a path, or undefined. Handles the one `**` entry. */ +/** + * Does `p` match a table path, read the way **git** reads the same string in + * `.gitattributes`? + * + * The table path and the `.gitattributes` pattern are literally the same string — + * `reconcileAttributes` holds them equal — so the two readers have to agree on what + * it MEANS, not merely on its bytes. Until #13731 they did not: the matcher below + * understood a trailing `/**` and exact equality and nothing else, so a pattern git + * matches happily (`skills/*` + a segment) would reconcile green and then be REFUSED + * by the driver at merge time, with the refusal blaming an absent table row. That is + * this card's own failure mode one level down — a gate green over a case it cannot + * see — and it surfaces during a merge, which is the worst moment to learn it. + * + * Two forms, both matching git's gitattributes semantics for a pattern containing a + * slash (anchored at the repo root): + * + * `a/b/**` — the subtree under `a/b/` + * a slash-star-slash form — one path SEGMENT; the star never crosses a `/`, as git has it + * + * `git-merge-regen.mjs --self-test` pins the agreement against `git check-attr` + * itself rather than against this comment, so a future divergence is measured. + */ +function pathMatches(pattern, p) { + if (pattern.endsWith('/**')) return p.startsWith(pattern.slice(0, -2)); + if (!pattern.includes('*')) return pattern === p; + const rx = pattern + .split('*') + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[^/]*'); + return new RegExp(`^${rx}$`).test(p); +} + +/** Resolve the entry that owns a path, or undefined. Handles the `**` and `*` forms. */ export function entryForPath(p) { - return REGEN_ARTIFACTS.find((e) => - e.path.endsWith('/**') ? p.startsWith(e.path.slice(0, -2)) : e.path === p, - ); + return REGEN_ARTIFACTS.find((e) => pathMatches(e.path, p)); }