* chore(#2896): convert CONTEXT.md prose defect registry into enforced gates Squashes the prior 4-commit sequence and fixes defects found while resuming this branch: 5 orphaned/corrupted DEFECT fragment lines left by an earlier botched edit, 17 "Source of truth: Memtrace `find_symbol`" placeholders that had destroyed real file-path citations, and 3 DEFECT.GENERATIVE-* entries merged into one RULESET.GENERATIVE-FIX predicate (policy, not an unenforced defect) to satisfy the zero DEFECT.<NAME>.<field>= acceptance criterion. Six mechanizable defects get real gates: DEFECT.UNBOUNDED-SUBPROCESS (eslint-rules/require-subprocess-timeout.cjs), DEFECT.CANARY-VERSION-LEAK (scripts/lint-canary-version-leak.cjs + version-gate.yml), DEFECT.CHANGESET-PR-FIELD-DRIFT (findPrFieldDrift in changeset/lint.cjs), DEFECT.FRONTMATTER-SCALAR-BROAD-GREP, DEFECT.REMOVED-BUT-NEEDED, and DEFECT.DEFAULT-FLIP-DOCUMENTATION (new lint scripts, wired into lint:ci). Already-enforced and unenforceable prose entries are deleted; the gate is the record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#2896): route the new lint tests' subprocess calls through the bounded process-seam helper The 4 new test files for this PR's lint checks called cp.spawnSync/ execFileSync directly with no timeout, tripping this repo's own existing local/no-unbounded-spawn ESLint rule. Route every one through runNode/gitOrThrow (tests/helpers/process-seam.cjs, tests/helpers/git-fixture.cjs) instead, matching the pattern already used elsewhere in the suite (e.g. tests/changeset-lint.test.cjs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: register claude-orchestration.cjs and regenerate stale generated indexes Pre-existing drift on next, unrelated to this PR's own change, surfaced by running lint:ci as part of verifying #2896: two cli_modules (claude-orchestration.cjs, write-set.cjs) landed without a manifest regen, and CONTEXT.md's own edits in this PR staled its two generated indexes. Adds the missing docs/INVENTORY.md row for claude-orchestration.cjs (write-set.cjs already had one — only its manifest entry was stale) and regenerates docs/INVENTORY-MANIFEST.json, docs/CONTEXT-INDEX.json, and examples/dynamic-context-management/CONTEXT-INDEX.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): default-flip-documentation lint's local fallback base was main, not next Found in review: every other base-ref fallback in this repo (see scripts/changeset/lint.cjs's DEFAULT_BASE, #2988) defaults to `next`, the integration branch every PR actually targets — `main` is the release branch. This script's local fallback (used only when GITHUB_BASE_REF is unset, i.e. never in CI, but potentially on a local or direct invocation) diffed against the wrong ref. No test exercised the unset-env-var path, so it shipped unnoticed; every e2e test sets GITHUB_BASE_REF explicitly and is unaffected by this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): stale eslint comment, overclaiming CONTEXT.md wording, and an incompletely-regenerated manifest Found by the isolated Standards code-review pass: - eslint.config.mjs's require-subprocess-timeout comment said "'warn' for now... flip to 'error' once migrated" while the rule already shipped as 'error' with all 8 sites migrated in the same commit — described a state that never existed. - The CONTEXT.md pointer block claimed the rule's bounded call sites "never throw", but roadmap-upgrade.cts's pre-mutation clean-tree check correctly still throws on failure (it gates a destructive real-run migration; degrading to "assume clean" would risk clobbering uncommitted work) — softened the claim to describe both shapes accurately instead of overclaiming one. - docs/INVENTORY-MANIFEST.json's claude-orchestration.cjs/write-set.cjs entries from the prior "fix: register claude-orchestration.cjs..." commit didn't actually land — re-running the generator now includes them; lint:generated-sync is green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#2896): backfill changeset pr field with the real PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): normalize buildCorpus file paths to POSIX in lint-removed-but-needed Windows CI caught it: path.relative(root, abs) returns backslash- separated paths on Windows, but findSurvivingReferences's package-lock special case does file.startsWith('.github/workflows') — a forward- slash literal. On Windows the check silently never matched, so tests/removed-but-needed-lint.test.cjs's real-defect-shape fixture got exit 0 instead of the expected exit 1. Normalize at the production source (RULESET.CONTENT-PATH-NORMALIZATION) rather than the test side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
194 lines
7.3 KiB
JavaScript
194 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* lint-default-flip-documentation.cjs — DEFECT.DEFAULT-FLIP-DOCUMENTATION
|
|
* (CONTEXT.md).
|
|
*
|
|
* ## Why
|
|
*
|
|
* A PR flips a config default but doesn't call out the migration semantics
|
|
* (when the new default takes effect; existing configs vs new configs; what
|
|
* the opt-back-in looks like — #3309, the v2 default flip from mid-flight to
|
|
* end-of-phase).
|
|
*
|
|
* ## Scope (deliberately narrower than the full DEFECT.detect clause)
|
|
*
|
|
* The DEFECT text names two surfaces: `CONFIG_DEFAULTS` and
|
|
* `buildNewProjectConfig`. This check covers ONLY the single-source-of-truth
|
|
* defaults manifest, `gsd-core/bin/shared/config-defaults.manifest.json`
|
|
* (what `CONFIG_DEFAULTS` in `src/configuration.cts` / `src/config.cts`
|
|
* actually loads at runtime) — because it is pure JSON, a resolved
|
|
* key→value-map diff between base and head is trivially reliable: no line
|
|
* movement, reordering, or refactor can ever produce a false "value changed"
|
|
* verdict, only an actual value change can.
|
|
*
|
|
* `buildNewProjectConfig`'s `hardcoded` object literal in `src/config.cts`
|
|
* is DELIBERATELY OUT OF SCOPE here. It mixes literal values with
|
|
* environment-derived branches (`hasBraveSearch`, etc.) and spreads of
|
|
* `CONFIG_DEFAULTS.*` — there is no reliable way to compute its *resolved*
|
|
* value map from source text alone without executing the compiled module at
|
|
* both refs, and a line/AST-level diff of that literal would inherit exactly
|
|
* the false-positive risk (a harmless refactor that moves or restructures
|
|
* the literal reads as a "flip") this check exists to avoid. Per the audit's
|
|
* own risk callout, a noisy check here is worse than no check — the
|
|
* `buildNewProjectConfig` half of the DEFECT stays prose-only.
|
|
*
|
|
* ## What this checks
|
|
*
|
|
* If any *value* differs between the base and head resolved manifest
|
|
* key→value maps (additions/removals alone don't count as a "flip" — the
|
|
* symptom is specifically about an EXISTING default changing), fail unless
|
|
* the PR body contains a `## Breaking Changes` (or `# Breaking Changes`)
|
|
* heading.
|
|
*
|
|
* Needs a PR event payload (`GITHUB_EVENT_PATH`) to read the PR body — this
|
|
* is a dedicated-workflow check (like `lint-canary-version-leak.cjs`), not a
|
|
* `lint:ci` member, since a local/push run has no PR body to check against.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const cp = require('node:child_process');
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const MANIFEST_PATH = path.join('gsd-core', 'bin', 'shared', 'config-defaults.manifest.json');
|
|
const BREAKING_CHANGES_RE = /^#{1,6}\s*Breaking Changes\b/im;
|
|
|
|
/**
|
|
* Pure: flatten a nested plain-object JSON value into dot-path
|
|
* `{ "a.b.c": value }` leaves. Arrays and primitives are leaves (compared by
|
|
* JSON.stringify equality, never recursed into) so array reordering reads as
|
|
* one value change, not N.
|
|
* @param {unknown} value
|
|
* @param {string} prefix
|
|
* @param {Record<string, unknown>} out
|
|
* @returns {Record<string, unknown>}
|
|
*/
|
|
function flatten(value, prefix = '', out = {}) {
|
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
for (const [key, v] of Object.entries(value)) {
|
|
flatten(v, prefix ? `${prefix}.${key}` : key, out);
|
|
}
|
|
} else {
|
|
out[prefix] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Pure: given two resolved (already-flattened) key→value maps, return the
|
|
* keys present in BOTH whose value differs. Additions/removals are NOT
|
|
* "flips" — a brand-new default has no prior behavior to contradict.
|
|
* @param {Record<string, unknown>} baseMap
|
|
* @param {Record<string, unknown>} headMap
|
|
* @returns {{ key: string, from: unknown, to: unknown }[]}
|
|
*/
|
|
function findDefaultValueChanges(baseMap, headMap) {
|
|
const changes = [];
|
|
for (const key of Object.keys(baseMap)) {
|
|
if (!Object.prototype.hasOwnProperty.call(headMap, key)) continue;
|
|
if (JSON.stringify(baseMap[key]) !== JSON.stringify(headMap[key])) {
|
|
changes.push({ key, from: baseMap[key], to: headMap[key] });
|
|
}
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
/**
|
|
* Pure verdict: given the detected default-value changes and the PR body,
|
|
* decide pass/fail.
|
|
* @param {{ key: string, from: unknown, to: unknown }[]} changes
|
|
* @param {string} prBody
|
|
* @returns {{ ok: boolean, changes: object[] }}
|
|
*/
|
|
function evaluateDefaultFlipDoc(changes, prBody) {
|
|
if (changes.length === 0) return { ok: true, changes: [] };
|
|
if (BREAKING_CHANGES_RE.test(prBody || '')) return { ok: true, changes };
|
|
return { ok: false, changes };
|
|
}
|
|
|
|
/**
|
|
* Read and JSON.parse the manifest at a given git ref. Returns `{}` when the
|
|
* file doesn't exist at that ref (new file, or ref predates it) — that is
|
|
* not a "flip", it's an addition, and is silently excluded by
|
|
* findDefaultValueChanges's both-sides-present requirement anyway.
|
|
* @param {string} root
|
|
* @param {string} ref
|
|
* @returns {Record<string, unknown>}
|
|
*/
|
|
function readManifestAtRef(root, ref) {
|
|
let raw;
|
|
try {
|
|
raw = cp.execFileSync('git', ['show', `${ref}:${MANIFEST_PATH.split(path.sep).join('/')}`], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
} catch {
|
|
return {};
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (e) {
|
|
throw new ExitError(2, `lint-default-flip-documentation: ${ref}:${MANIFEST_PATH} is not valid JSON: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function readPrBody() {
|
|
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
if (!eventPath || !fs.existsSync(eventPath)) return null;
|
|
try {
|
|
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
|
return typeof event.pull_request?.body === 'string' ? event.pull_request.body : '';
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const prBody = readPrBody();
|
|
if (prBody === null) {
|
|
console.log('lint-default-flip-documentation: no PR event payload (not a pull_request run), skipping');
|
|
return;
|
|
}
|
|
|
|
const baseRef = `origin/${process.env.GITHUB_BASE_REF || 'next'}`; // #2988
|
|
const baseMap = flatten(readManifestAtRef(ROOT, baseRef));
|
|
const headMap = flatten(readManifestAtRef(ROOT, 'HEAD'));
|
|
const changes = findDefaultValueChanges(baseMap, headMap);
|
|
const verdict = evaluateDefaultFlipDoc(changes, prBody);
|
|
|
|
if (!verdict.ok) {
|
|
const detail = verdict.changes
|
|
.map((c) => ` ${c.key}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`)
|
|
.join('\n');
|
|
throw new ExitError(
|
|
1,
|
|
'lint-default-flip-documentation: this PR changes an existing default value in\n'
|
|
+ 'config-defaults.manifest.json (DEFECT.DEFAULT-FLIP-DOCUMENTATION) but the PR body has no\n'
|
|
+ '`## Breaking Changes` section. Add one covering: (a) when the new default takes effect\n'
|
|
+ '(config-set, fresh project, regenerated config), (b) the opt-back-in command\n'
|
|
+ '(`gsd config-set <key> <old-value>`), (c) effect on in-flight artifacts. Changed default(s):\n'
|
|
+ detail,
|
|
);
|
|
}
|
|
console.log(
|
|
changes.length === 0
|
|
? 'ok lint-default-flip-documentation: no default value changed'
|
|
: `ok lint-default-flip-documentation: ${changes.length} default value change(s), PR body documents Breaking Changes`,
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
flatten,
|
|
findDefaultValueChanges,
|
|
evaluateDefaultFlipDoc,
|
|
readManifestAtRef,
|
|
MANIFEST_PATH,
|
|
BREAKING_CHANGES_RE,
|
|
};
|
|
|
|
if (require.main === module) runMain(main);
|