Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Record a merge disposition for every generator, and gate the ones nobody judged by claude[bot] · Pull Request #13876 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
207 changes: 207 additions & 0 deletions scripts/git-merge-regen.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'));
Expand DownExpand Up@@ -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(),
Expand Down
Loading
Loading