@opengsd/gsd-core 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Generates docs/CLI-SURFACE-MANIFEST.json from the CLI surface registry
6
+ * (`src/cli-surface.cts`, compiled to `gsd-core/bin/lib/cli-surface.cjs`) and the
7
+ * command aliases (`gsd-core/bin/lib/command-aliases.cjs`). ADR-4909 D2, #6087.
8
+ *
9
+ * Usage:
10
+ * node scripts/gen-cli-surface-manifest.cjs # print the manifest to stdout
11
+ * node scripts/gen-cli-surface-manifest.cjs --write # rewrite the manifest file
12
+ * node scripts/gen-cli-surface-manifest.cjs --check # exit 1 if the manifest is missing/stale
13
+ * node scripts/gen-cli-surface-manifest.cjs --check --json # machine-readable report
14
+ * node scripts/gen-cli-surface-manifest.cjs --manifest-path <p> # test-only: target another file
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
21
+
22
+ const ROOT = path.resolve(__dirname, '..');
23
+ const MANIFEST_REL = 'docs/CLI-SURFACE-MANIFEST.json';
24
+ const MANIFEST_PATH = path.join(ROOT, MANIFEST_REL);
25
+ const SURFACE_REL = 'gsd-core/bin/lib/cli-surface.cjs';
26
+ const SURFACE_PATH = path.join(ROOT, SURFACE_REL);
27
+ const ALIASES_PATH = path.join(ROOT, 'gsd-core/bin/lib/command-aliases.cjs');
28
+
29
+ const USAGE =
30
+ 'Usage: node scripts/gen-cli-surface-manifest.cjs [--write | --check] [--json] [--manifest-path <path>]';
31
+
32
+ // Each compiled module the manifest reads, beside the source it is built from. The registry
33
+ // is read through its compiled form, so a stale compile would write or check a stale manifest.
34
+ const COMPILED_PAIRS = [
35
+ { source: path.join(ROOT, 'src/cli-surface.cts'), compiled: SURFACE_PATH },
36
+ {
37
+ source: path.join(ROOT, 'src/command-arg-projection.cts'),
38
+ compiled: path.join(ROOT, 'gsd-core/bin/lib/command-arg-projection.cjs'),
39
+ },
40
+ ];
41
+
42
+ /**
43
+ * Whether the compiled file is exactly what the build would emit for the source: true or false,
44
+ * or null when typescript or the build options cannot be loaded (the caller falls back to mtime).
45
+ */
46
+ function compiledMatchesSource(source, compiled, loadTypescript) {
47
+ try {
48
+ const ts = loadTypescript();
49
+ const config = ts.readConfigFile(path.join(ROOT, 'tsconfig.build.json'), ts.sys.readFile);
50
+ if (config.error !== undefined) return null;
51
+ // parseJsonConfigFileContent resolves `extends`; 18003 (no input files) is about the include globs, not the options.
52
+ const { options, errors } = ts.parseJsonConfigFileContent(config.config, ts.sys, ROOT);
53
+ if (errors.some((e) => e.code !== 18003)) return null;
54
+ const emitted = ts.transpileModule(fs.readFileSync(source, 'utf8'), { compilerOptions: options, fileName: source });
55
+ return emitted.outputText === fs.readFileSync(compiled, 'utf8');
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Why the compiled registry cannot be trusted, or null when it is fresh: a compiled file that
63
+ * is missing, or older than the source beside it, means `npm run build:lib` has not run since
64
+ * the source changed. A source that is not on disk (an installed package) is not checked.
65
+ * Pairs are compared one to one: tsc leaves an unchanged module's output alone, so a source that
66
+ * is newer by mtime only (a checkout, a touch) is judged by content: the source is transpiled
67
+ * with the repo's own typescript and the build options of tsconfig.build.json, and equal to the
68
+ * compiled text means fresh. When typescript cannot be loaded the mtime comparison decides.
69
+ *
70
+ * @param {Array<{source: string, compiled: string}>} [pairs]
71
+ * @param {() => object} [loadTypescript]
72
+ * @returns {string | null}
73
+ */
74
+ function staleCompileProblem(pairs = COMPILED_PAIRS, loadTypescript = () => require('typescript')) {
75
+ for (const { source, compiled } of pairs) {
76
+ const rel = (p) => path.relative(ROOT, p) || p;
77
+ if (!fs.existsSync(compiled)) {
78
+ return `compiled artifact not found at ${rel(compiled)}. Run \`npm run build:lib\` first.`;
79
+ }
80
+ if (!fs.existsSync(source)) continue;
81
+ if (fs.statSync(source).mtimeMs > fs.statSync(compiled).mtimeMs) {
82
+ if (compiledMatchesSource(source, compiled, loadTypescript) === true) continue;
83
+ return (
84
+ `${rel(compiled)} is older than ${rel(source)}. Run \`npm run build:lib\` first ` +
85
+ '(if it emits nothing, delete tsconfig.build.tsbuildinfo and run it again).'
86
+ );
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function loadSurface() {
93
+ const problem = staleCompileProblem();
94
+ if (problem !== null) throw new ExitError(2, `gen-cli-surface-manifest: ${problem}`);
95
+ return require(SURFACE_PATH);
96
+ }
97
+
98
+ /** canonical -> sorted, unique aliases, across every `*_COMMAND_ALIASES` array. */
99
+ function aliasMap() {
100
+ const mod = require(ALIASES_PATH);
101
+ const collected = new Map();
102
+ for (const [key, value] of Object.entries(mod)) {
103
+ if (!key.endsWith('_COMMAND_ALIASES') || !Array.isArray(value)) continue;
104
+ for (const entry of value) {
105
+ if (!entry || typeof entry.canonical !== 'string') continue;
106
+ const set = collected.get(entry.canonical) ?? new Set();
107
+ for (const alias of entry.aliases ?? []) set.add(alias);
108
+ collected.set(entry.canonical, set);
109
+ }
110
+ }
111
+ return new Map([...collected].map(([canonical, set]) => [canonical, [...set].sort()]));
112
+ }
113
+
114
+ const sorted = (list) => [...(list ?? [])].sort();
115
+
116
+ function flagKinds(spec) {
117
+ const repeatable = new Set(spec.repeatableFlags ?? []);
118
+ const bare = new Set(spec.bareEmptyFlags ?? []);
119
+ const kinds = {};
120
+ for (const f of spec.valueFlags ?? []) {
121
+ kinds[f] = repeatable.has(f) ? 'repeatable' : bare.has(f) ? 'value-or-bare' : 'value';
122
+ }
123
+ for (const f of spec.booleanFlags ?? []) kinds[f] = 'boolean';
124
+ for (const f of spec.optionalValueFlags ?? []) kinds[f] = 'optional-value';
125
+ for (const f of spec.listFlags ?? []) kinds[f] = 'list';
126
+ for (const f of spec.multiwordFlags ?? []) kinds[f] = 'multiword';
127
+ return sortedObject(kinds);
128
+ }
129
+
130
+ function sortedObject(obj) {
131
+ const out = {};
132
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k];
133
+ return out;
134
+ }
135
+
136
+ function buildVerbEntry(verb, spec, aliases) {
137
+ const entry = {
138
+ verb,
139
+ aliases: sorted(aliases.get(verb)),
140
+ positionals: spec.positionals,
141
+ flags: flagKinds(spec),
142
+ required: sorted(spec.requiredFlags),
143
+ workstreamScoped: !!spec.workstreamScoped,
144
+ };
145
+ if (spec.freePositionals) entry.freePositionals = true;
146
+ if (spec.rejectShortFlags) entry.rejectShortFlags = true;
147
+ if ((spec.eqFlags ?? []).length > 0) entry.eq = sorted(spec.eqFlags);
148
+ if ((spec.nonEmptyFlags ?? []).length > 0) entry.nonEmpty = sorted(spec.nonEmptyFlags);
149
+ if ((spec.emptyValueFlags ?? []).length > 0) entry.emptyValue = sorted(spec.emptyValueFlags);
150
+ if ((spec.emptyEqFlags ?? []).length > 0) entry.emptyEq = sorted(spec.emptyEqFlags);
151
+ if (spec.passthroughAfter) entry.passthroughAfter = spec.passthroughAfter;
152
+ if (spec.thirdParty) entry.thirdParty = true;
153
+ return entry;
154
+ }
155
+
156
+ function buildManifest() {
157
+ const { CLI_SURFACE, GLOBAL_FLAGS, UNDECLARED_VERBS } = loadSurface();
158
+ const aliases = aliasMap();
159
+ const flags = {};
160
+ for (const f of GLOBAL_FLAGS.valueFlags ?? []) flags[f] = 'value';
161
+ for (const f of GLOBAL_FLAGS.booleanFlags ?? []) flags[f] = 'boolean';
162
+ const verbs = Object.keys(CLI_SURFACE)
163
+ .sort()
164
+ .map((verb) => buildVerbEntry(verb, CLI_SURFACE[verb], aliases));
165
+ return {
166
+ schema: 1,
167
+ global: {
168
+ flags: sortedObject(flags),
169
+ eq: sorted(GLOBAL_FLAGS.eqFlags),
170
+ help: [...(GLOBAL_FLAGS.helpTokens ?? [])],
171
+ rejected: [...(GLOBAL_FLAGS.rejectedTokens ?? [])],
172
+ },
173
+ verbs,
174
+ undeclared: sorted(UNDECLARED_VERBS),
175
+ };
176
+ }
177
+
178
+ function serialize(manifest) {
179
+ return JSON.stringify(manifest, null, 2) + '\n';
180
+ }
181
+
182
+ function diffVerbs(expected, actual) {
183
+ const exp = new Map(expected.verbs.map((v) => [v.verb, JSON.stringify(v)]));
184
+ const act = new Map((actual.verbs ?? []).map((v) => [v.verb, JSON.stringify(v)]));
185
+ const added = [...exp.keys()].filter((k) => !act.has(k));
186
+ const removed = [...act.keys()].filter((k) => !exp.has(k));
187
+ const changed = [...exp.keys()].filter((k) => act.has(k) && act.get(k) !== exp.get(k));
188
+ return { added, removed, changed };
189
+ }
190
+
191
+ /**
192
+ * @param {{manifestPath?: string}} [opts]
193
+ * @returns {{ok: boolean, problems: Array<{kind: 'missing'|'stale'|'invalid-json', subject: string, message: string}>}}
194
+ */
195
+ function checkReport({ manifestPath = MANIFEST_PATH } = {}) {
196
+ const problems = [];
197
+ if (!fs.existsSync(manifestPath)) {
198
+ problems.push({
199
+ kind: 'missing',
200
+ subject: manifestPath,
201
+ message: `${manifestPath} not found. Run: node scripts/gen-cli-surface-manifest.cjs --write`,
202
+ });
203
+ return { ok: false, problems };
204
+ }
205
+ const text = fs.readFileSync(manifestPath, 'utf8');
206
+ let actual;
207
+ try {
208
+ actual = JSON.parse(text);
209
+ } catch (err) {
210
+ problems.push({
211
+ kind: 'invalid-json',
212
+ subject: manifestPath,
213
+ message: `${manifestPath} is not valid JSON (${err.message}). Run: node scripts/gen-cli-surface-manifest.cjs --write`,
214
+ });
215
+ return { ok: false, problems };
216
+ }
217
+ const expected = buildManifest();
218
+ if (serialize(expected) !== text) {
219
+ const { added, removed, changed } = diffVerbs(expected, actual);
220
+ const parts = [];
221
+ if (added.length) parts.push(`missing verbs: ${added.join(', ')}`);
222
+ if (removed.length) parts.push(`removed verbs: ${removed.join(', ')}`);
223
+ if (changed.length) parts.push(`changed verbs: ${changed.join(', ')}`);
224
+ if (!parts.length) parts.push('global/undeclared sections or formatting differ');
225
+ const subject = [...added, ...removed, ...changed][0] ?? 'global';
226
+ problems.push({
227
+ kind: 'stale',
228
+ subject,
229
+ message: `${manifestPath} is stale (${parts.join('; ')}). Run: node scripts/gen-cli-surface-manifest.cjs --write`,
230
+ });
231
+ }
232
+ return { ok: problems.length === 0, problems };
233
+ }
234
+
235
+ function parseArgs(argv) {
236
+ const opts = { write: false, check: false, json: false, manifestPath: MANIFEST_PATH };
237
+ for (let i = 0; i < argv.length; i++) {
238
+ const arg = argv[i];
239
+ if (arg === '--write') opts.write = true;
240
+ else if (arg === '--check') opts.check = true;
241
+ else if (arg === '--json') opts.json = true;
242
+ else if (arg === '--manifest-path') {
243
+ const value = argv[i + 1];
244
+ if (value === undefined) throw new ExitError(2, `--manifest-path requires a path argument.\n${USAGE}`);
245
+ opts.manifestPath = path.resolve(value);
246
+ i++;
247
+ } else {
248
+ throw new ExitError(2, `unknown argument: ${arg}\n${USAGE}`);
249
+ }
250
+ }
251
+ if (opts.write && opts.check) throw new ExitError(2, `--write and --check are mutually exclusive.\n${USAGE}`);
252
+ return opts;
253
+ }
254
+
255
+ function main() {
256
+ const opts = parseArgs(process.argv.slice(2));
257
+
258
+ if (opts.write) {
259
+ const manifest = buildManifest();
260
+ fs.mkdirSync(path.dirname(opts.manifestPath), { recursive: true });
261
+ const tmp = `${opts.manifestPath}.${process.pid}.tmp`;
262
+ fs.writeFileSync(tmp, serialize(manifest), 'utf8');
263
+ fs.renameSync(tmp, opts.manifestPath);
264
+ process.stdout.write(`Wrote ${opts.manifestPath} - ${manifest.verbs.length} verb(s).\n`);
265
+ return 0;
266
+ }
267
+
268
+ if (opts.check) {
269
+ const report = checkReport({ manifestPath: opts.manifestPath });
270
+ if (opts.json) {
271
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
272
+ } else if (report.ok) {
273
+ process.stdout.write(`${opts.manifestPath} is up to date.\n`);
274
+ } else {
275
+ for (const p of report.problems) process.stderr.write(`${p.kind}: ${p.subject}: ${p.message}\n`);
276
+ }
277
+ if (!report.ok) throw new ExitError(1);
278
+ return 0;
279
+ }
280
+
281
+ process.stdout.write(serialize(buildManifest()));
282
+ return 0;
283
+ }
284
+
285
+ if (require.main === module) runMain(main);
286
+
287
+ module.exports = { buildManifest, checkReport, serialize, staleCompileProblem, aliasMap, MANIFEST_PATH };
@@ -139,11 +139,62 @@ const MACOS_GENERATED_HEADER =
139
139
  '// Windows-oriented tier in platform-conformance-tier.generated.cjs — see\n' +
140
140
  '// docs/adr/4593-macos-conformance-tier-architecture.md for the full rationale.\n';
141
141
 
142
+ // #6002 child_process call-site shapes. First arguments that are node itself:
143
+ // `process.execPath` or the literal 'node' / "node".
144
+ const NODE_FIRST_ARG = "(?:process\\.execPath\\s*[,)]|(['\"])node\\1\\s*[,)])";
145
+ const NOT_NODE_FIRST_ARG = '(?!' + NODE_FIRST_ARG + ')';
146
+ // One source of truth for the call names every shape below matches.
147
+ const CHILD_SYNC_NAMES = 'spawnSync|execSync|execFileSync';
148
+ const CHILD_ASYNC_NAMES = 'spawn|execFile|exec';
149
+ const CHILD_CALL_NAMES = CHILD_SYNC_NAMES + '|' + CHILD_ASYNC_NAMES;
150
+ const CHILD_NON_NODE_CALL = new RegExp(
151
+ '(?:\\b(?:' + CHILD_SYNC_NAMES + ')|(?<![.\\w$])(?:' + CHILD_ASYNC_NAMES + '))\\(\\s*' + NOT_NODE_FIRST_ARG,
152
+ );
153
+ // Member form `<ident>.spawn(...)` (e.g. `cp.spawn('bash')`, `child_process.execFile('git')`).
154
+ // Heuristic: any `<ident>.(spawn|spawnSync|execFile|execFileSync|exec|execSync)(` whose first
155
+ // argument is not node counts as a non-node call, EXCEPT a regex `.exec(` — the receiver is a
156
+ // regex literal (preceded by `/`) or an identifier ending in re/Re/RE/regex/Regex/REGEX.
157
+ const CHILD_MEMBER_CALL = new RegExp(
158
+ '([\\w$]+)\\.(?:' + CHILD_CALL_NAMES + ')\\(\\s*' + NOT_NODE_FIRST_ARG,
159
+ 'g',
160
+ );
161
+ const REGEX_RECEIVER_NAME = /(?:re|Re|RE|regex|Regex|REGEX)$/;
162
+ function hasMemberNonNodeCall(content) {
163
+ CHILD_MEMBER_CALL.lastIndex = 0;
164
+ let m;
165
+ while ((m = CHILD_MEMBER_CALL.exec(content)) !== null) {
166
+ if (content[m.index - 1] === '/') continue; // regex-literal receiver: /x/g.exec(
167
+ if (/\.exec\(/.test(m[0]) && REGEX_RECEIVER_NAME.test(m[1])) continue;
168
+ return true;
169
+ }
170
+ return false;
171
+ }
172
+ // `promisify(cp.execFile)` hides the call sites from the call-shape scan: an unidentifiable,
173
+ // counted site.
174
+ const CHILD_PROMISIFIED = /\bpromisify\(\s*(?:[\w$]+\.)?(?:execFile|exec|spawn)\b/;
175
+ const CHILD_NODE_ONLY_CALL = new RegExp(
176
+ '\\b(?:' + CHILD_CALL_NAMES + ')\\(\\s*' + NODE_FIRST_ARG + '|(?<![.\\w$])fork\\(',
177
+ );
178
+ // Code constructs only: a bare `lock` word matches prose, so lock-ness is a lockfile/lock-helper
179
+ // identifier, a `.lock` path suffix, a rename/unlink call, EBUSY/EPERM, the installer or --config-dir.
180
+ const FS_LOCK_OR_INSTALL =
181
+ /\b(?:lockfile|acquireLock|releaseLock|withLock|lockSync|proper-lockfile)\b|\.lock\b|\b(?:rename|unlink)(?:Sync)?\(|\bE(?:BUSY|PERM)\b|install\.js|--config-dir/;
182
+ const CHILD_SHELL_TRUE = /\bshell\s*:\s*true\b/;
183
+ // Pre-#6002 raw-child-process test, kept for source-file reachability
184
+ // (scripts/ci-test-scope.cjs), whose behavior must not change.
185
+ function preIssue6002RawChildProcess(content) {
186
+ if (/require\((['"])(?:node:)?child_process\1\)/.test(content)) return true;
187
+ if (!content.includes('child_process')) return false;
188
+ return /\bspawnSync\(|\bexecSync\(|\bexecFileSync\(/.test(content);
189
+ }
190
+
142
191
  /**
143
192
  * Detection categories (#4591 design doc). Each entry's `test` receives the
144
193
  * raw file content string and returns true when that category's signal is
145
194
  * present. Order matches the design doc's enumeration; `signals` in
146
- * `classifyContent`'s return value preserves this order.
195
+ * `classifyContent`'s return value preserves this order. Optional fields
196
+ * (the pre-#6002 view used by `classifyUnstrippedContent`): `raw: <fn>`
197
+ * replaces `test` there; `rawSkip: true` omits the category there.
147
198
  */
148
199
  const CATEGORIES = [
149
200
  {
@@ -172,16 +223,43 @@ const CATEGORIES = [
172
223
  },
173
224
  {
174
225
  name: 'raw-child-process',
175
- // Requires BOTH the child_process import/reference token AND one of the
176
- // three call names in the same file — this is what keeps a same-named
177
- // local identifier (e.g. a variable called `spawnResult`) from false-
178
- // positiving: `spawnResult` never forms the substring `spawnSync(`.
226
+ raw: preIssue6002RawChildProcess, // pre-#6002 test used by classifyUnstrippedContent
227
+ // #6002: matches a call that can spawn a platform-dependent binary, not
228
+ // the mere presence of the module. Requires the child_process token AND a
229
+ // sync or async spawn call (spawnSync/execSync/execFileSync/spawn/execFile/
230
+ // exec) whose first argument is NOT node itself (`process.execPath` or the
231
+ // literal 'node' / "node"): a child that only re-runs node behaves
232
+ // identically on every OS and is no real-OS signal. `fork(` always runs
233
+ // node, so it is node-only by construction and never a signal on its own.
234
+ // Any other first argument (another literal, an identifier, a template
235
+ // literal) matches (over-inclusion, the safe direction), as does
236
+ // `shell: true` anywhere in a child_process-using file. A file that
237
+ // requires child_process but whose call sites cannot be identified at all
238
+ // (aliased or destructured-and-renamed usage) also counts. A same-named
239
+ // local identifier (e.g. `spawnResult`) never forms the substring
240
+ // `spawnSync(`; bare `.exec(` member calls (e.g. `re.exec(`) are not
241
+ // spawn call sites.
179
242
  test: (content) => {
180
- if (/require\((['"])(?:node:)?child_process\1\)/.test(content)) return true;
181
243
  if (!content.includes('child_process')) return false;
182
- return /\bspawnSync\(|\bexecSync\(|\bexecFileSync\(/.test(content);
244
+ if (CHILD_SHELL_TRUE.test(content)) return true;
245
+ if (CHILD_NON_NODE_CALL.test(content)) return true;
246
+ if (hasMemberNonNodeCall(content)) return true;
247
+ if (CHILD_PROMISIFIED.test(content)) return true;
248
+ return !CHILD_NODE_ONLY_CALL.test(content);
183
249
  },
184
250
  },
251
+ {
252
+ name: 'fs-lock-or-install-child',
253
+ rawSkip: true, // did not exist pre-#6002; classifyUnstrippedContent omits it
254
+ // #6002 decision: a node-only child still counts when the file also
255
+ // exercises Windows-sensitive filesystem behavior (locking, rename/unlink
256
+ // semantics, EBUSY/EPERM, the installer, --config-dir), because the child
257
+ // is then driving that behavior in a real process.
258
+ test: (content) =>
259
+ content.includes('child_process') &&
260
+ CHILD_NODE_ONLY_CALL.test(content) &&
261
+ FS_LOCK_OR_INSTALL.test(content),
262
+ },
185
263
  {
186
264
  name: 'shell-interpreter-spawn',
187
265
  // Added after an adversarial review (#4641) caught a REAL false negative
@@ -259,15 +337,8 @@ const NOISY_FOR_SOURCE_REACHABILITY = new Set(['symlink-keyword']);
259
337
  * expressible as content; reach for this map only when it structurally is
260
338
  * not.
261
339
  *
262
- * Current entries:
263
- * - tests/external-descriptor-confinement.test.cjs: exercises `isPathConfined`
264
- * (src/external-descriptor-trust.cts:41-49), which calls the AMBIENT
265
- * `path` module directly — `path.resolve(root, target)` and `path.sep` —
266
- * with no platform/path injection seam. Its win32 semantics (drive
267
- * letters, UNC paths, `\` separator) are therefore only reachable by
268
- * actually running on Windows; the win32 branch is unreachable on Linux.
269
- * This is a security-relevant write-confinement gate, so a silent gap
270
- * here is a security regression, not a coverage nit (#4641).
340
+ * Entries and their reasons live in the Map below (the single source); each
341
+ * reason is a required value, so none can be added without one.
271
342
  */
272
343
  const ALWAYS_REAL_OS = new Map([
273
344
  [
@@ -276,6 +347,13 @@ const ALWAYS_REAL_OS = new Map([
276
347
  'path module (path.resolve/path.sep) with no platform injection; its win32 branch (drive ' +
277
348
  'letters, UNC paths, \\ separator) is unreachable on Linux. Security-relevant write-confinement gate.',
278
349
  ],
350
+ [
351
+ 'tests/state-todos-render.test.cjs',
352
+ 'Exercises renderPendingTodosMarkdown (src/init.cts:2646-2650), which uses the ambient path module ' +
353
+ '(path.isAbsolute/path.relative) with no platform injection to build repo-relative todo links ' +
354
+ '(#4384/#4421); its win32 branch (drive letters, \\ separator) is unreachable on Linux. The test ' +
355
+ "file's only content signal was a node-only spawnSync, which #6002 deliberately ignores.",
356
+ ],
279
357
  ]);
280
358
 
281
359
  /**
@@ -301,18 +379,157 @@ const MACOS_CATEGORIES = [
301
379
  { name: 'case-sensitivity', test: (content) => /case.?insensitiv|case.?sensitiv/i.test(content) },
302
380
  { name: 'chmod-mode-bit', test: (content) => /chmodSync|chmod\(/.test(content) || /0o[0-7]{3,4}\b/.test(content) },
303
381
  { name: 'symlink-keyword', test: (content) => /\bsymlink/i.test(content) },
382
+ // #6002: realpath calls normalize the macOS /var -> /private/var tmpdir symlink.
383
+ { name: 'realpath', test: (content) => /\brealpath(Sync)?\b/.test(content) },
304
384
  ];
305
385
 
386
+ /** Keywords after which a `/` begins a regex literal rather than a division. */
387
+ const REGEX_AFTER_KEYWORD = new Set([
388
+ 'return', 'typeof', 'case', 'in', 'of', 'delete', 'void', 'throw', 'new', 'else', 'do', 'instanceof', 'yield', 'await',
389
+ ]);
390
+ const REGEX_AFTER_CHAR = '(,=:[!&|?{};+-*%<>~^';
391
+
392
+ function regexAllowedAfter(out) {
393
+ let j = out.length - 1;
394
+ while (j >= 0 && /\s/.test(out[j])) j--;
395
+ if (j < 0) return true;
396
+ if (REGEX_AFTER_CHAR.includes(out[j])) return true;
397
+ if (/[A-Za-z0-9_$]/.test(out[j])) {
398
+ let k = j;
399
+ while (k >= 0 && /[A-Za-z0-9_$]/.test(out[k])) k--;
400
+ return REGEX_AFTER_KEYWORD.has(out.slice(k + 1, j + 1));
401
+ }
402
+ return false;
403
+ }
404
+
405
+ /**
406
+ * #6002 — remove `//` line comments and block comments from source text so
407
+ * the CATEGORIES / MACOS_CATEGORIES tests match platform BEHAVIOR in code, not
408
+ * words in prose. A single-pass scanner tracks whether it is inside a '…', "…"
409
+ * or `…` string literal (honoring backslash escapes), so a `//` inside a
410
+ * string (e.g. a URL) does not start a comment. A `/` is read as the start of
411
+ * a regex literal (scanned to its closing unescaped `/`, honoring `[...]`
412
+ * classes and escapes) when the previous significant character is one of
413
+ * `( , = : [ ! & | ? { } ; + - * % < > ~ ^`, the start of input, or a keyword
414
+ * such as return/typeof; the regex body is kept verbatim so quotes or `//`
415
+ * inside it cannot corrupt string or comment tracking. An unterminated regex
416
+ * (newline first) falls back to a plain `/`. '…' and "…" strings end at a
417
+ * newline so an unbalanced quote cannot swallow the rest of the file. A line
418
+ * comment is dropped up to (not including) its newline; a block comment
419
+ * becomes one space (its newlines are kept) so adjacent tokens never fuse.
420
+ *
421
+ * @param {string} content
422
+ * @returns {string}
423
+ */
424
+ function stripComments(content) {
425
+ const src = typeof content === 'string' ? content : '';
426
+ const n = src.length;
427
+ let out = '';
428
+ let i = 0;
429
+
430
+ // Scans code from `i`. When `inExpr` (inside a template `${ … }`), returns at the
431
+ // matching unnested `}` (not consumed); otherwise runs to end of input.
432
+ function scanCode(inExpr) {
433
+ let depth = 0;
434
+ while (i < n) {
435
+ const ch = src[i];
436
+ const next = src[i + 1];
437
+ if (inExpr && ch === '}' && depth === 0) return;
438
+ if (inExpr && ch === '{') depth++;
439
+ else if (inExpr && ch === '}') depth--;
440
+ if (ch === '/' && next === '/') {
441
+ while (i < n && src[i] !== '\n') i++;
442
+ } else if (ch === '/' && next === '*') {
443
+ i += 2;
444
+ let newlines = '';
445
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) {
446
+ if (src[i] === '\n') newlines += '\n';
447
+ i++;
448
+ }
449
+ i += 2;
450
+ out += ' ' + newlines;
451
+ } else if (ch === '/' && regexAllowedAfter(out)) {
452
+ let j = i + 1;
453
+ let inClass = false;
454
+ let closed = false;
455
+ while (j < n && src[j] !== '\n') {
456
+ const c = src[j];
457
+ if (c === '\\') {
458
+ j += 2;
459
+ continue;
460
+ }
461
+ if (c === '[') inClass = true;
462
+ else if (c === ']') inClass = false;
463
+ else if (c === '/' && !inClass) {
464
+ closed = true;
465
+ break;
466
+ }
467
+ j++;
468
+ }
469
+ if (closed) {
470
+ out += src.slice(i, j + 1);
471
+ i = j + 1;
472
+ } else {
473
+ out += ch;
474
+ i++;
475
+ }
476
+ } else if (ch === '`') {
477
+ out += ch;
478
+ i++;
479
+ while (i < n) {
480
+ const c = src[i];
481
+ if (c === '\\' && i + 1 < n) {
482
+ out += c + src[i + 1];
483
+ i += 2;
484
+ continue;
485
+ }
486
+ if (c === '$' && src[i + 1] === '{') {
487
+ out += '${';
488
+ i += 2;
489
+ scanCode(true);
490
+ continue;
491
+ }
492
+ out += c;
493
+ i++;
494
+ if (c === '`') break;
495
+ }
496
+ } else if (ch === "'" || ch === '"') {
497
+ out += ch;
498
+ i++;
499
+ while (i < n) {
500
+ const c = src[i];
501
+ if (c === '\\' && i + 1 < n) {
502
+ out += c + src[i + 1];
503
+ i += 2;
504
+ continue;
505
+ }
506
+ if (c === '\n') break;
507
+ out += c;
508
+ i++;
509
+ if (c === ch) break;
510
+ }
511
+ } else {
512
+ out += ch;
513
+ i++;
514
+ }
515
+ }
516
+ }
517
+
518
+ scanCode(false);
519
+ return out;
520
+ }
521
+
306
522
  /**
307
523
  * Pure classifier: given a test file's raw string content, returns which
308
524
  * platform-conformance categories matched and whether the file needs real-OS
309
- * coverage (true iff at least one category matched).
525
+ * coverage (true iff at least one category matched). Comments are stripped
526
+ * first (#6002).
310
527
  *
311
528
  * @param {string} content
312
529
  * @returns {{needsRealOs: boolean, signals: string[]}}
313
530
  */
314
531
  function classifyContent(content) {
315
- const text = typeof content === 'string' ? content : '';
532
+ const text = stripComments(content);
316
533
  const signals = [];
317
534
  for (const category of CATEGORIES) {
318
535
  if (category.test(text)) signals.push(category.name);
@@ -320,6 +537,26 @@ function classifyContent(content) {
320
537
  return { needsRealOs: signals.length > 0, signals };
321
538
  }
322
539
 
540
+ /**
541
+ * Pre-#6002 classification: no comment stripping and the legacy
542
+ * raw-child-process test (any child_process require counts); the
543
+ * fs-lock-or-install-child category does not exist here. Used by
544
+ * scripts/ci-test-scope.cjs for src/ reachability, whose behavior #6002 must
545
+ * not change.
546
+ *
547
+ * @param {string} content
548
+ * @returns {{needsRealOs: boolean, signals: string[]}}
549
+ */
550
+ function classifyUnstrippedContent(content) {
551
+ const text = typeof content === 'string' ? content : '';
552
+ const signals = [];
553
+ for (const category of CATEGORIES) {
554
+ if (category.rawSkip) continue;
555
+ if ((category.raw || category.test)(text)) signals.push(category.name);
556
+ }
557
+ return { needsRealOs: signals.length > 0, signals };
558
+ }
559
+
323
560
  /**
324
561
  * Pure classifier, macOS-specific signal set (#4593). Same shape as
325
562
  * classifyContent, against MACOS_CATEGORIES instead of CATEGORIES.
@@ -328,7 +565,7 @@ function classifyContent(content) {
328
565
  * @returns {{needsRealOs: boolean, signals: string[]}}
329
566
  */
330
567
  function classifyMacosContent(content) {
331
- const text = typeof content === 'string' ? content : '';
568
+ const text = stripComments(content); // #6002
332
569
  const signals = [];
333
570
  for (const category of MACOS_CATEGORIES) {
334
571
  if (category.test(text)) signals.push(category.name);
@@ -728,6 +965,8 @@ if (require.main === module) {
728
965
 
729
966
  module.exports = {
730
967
  classifyContent,
968
+ classifyUnstrippedContent,
969
+ stripComments,
731
970
  CATEGORIES,
732
971
  NOISY_FOR_SOURCE_REACHABILITY,
733
972
  ALWAYS_REAL_OS,