@codedology/code-forge 0.15.4 → 0.16.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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/native/bin/darwin-arm64/code-forge-native +0 -0
  4. package/native/bin/darwin-x64/code-forge-native +0 -0
  5. package/native/bin/linux-arm64/code-forge-native +0 -0
  6. package/native/bin/linux-x64/code-forge-native +0 -0
  7. package/package.json +4 -2
  8. package/schema/facts.schema.json +1 -1
  9. package/skill/SKILL.md +1 -1
  10. package/skill/references/adapters/solo.md +1 -1
  11. package/skill/references/decisions.md +1 -1
  12. package/skill/references/plan.md +14 -5
  13. package/skill/references/review.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/facts-sheet.md +9 -3
  16. package/src/cli/review-file.mjs +5 -0
  17. package/src/cli/spawn.mjs +26 -22
  18. package/src/cli/status.mjs +37 -5
  19. package/src/decide/escalation.mjs +5 -5
  20. package/src/gates/dependents.mjs +200 -0
  21. package/src/gates/expand.mjs +83 -26
  22. package/src/gates/run.mjs +76 -14
  23. package/src/ledger/tail.mjs +89 -0
  24. package/src/review/definitions.mjs +170 -0
  25. package/src/review/fixloop.mjs +4 -4
  26. package/src/review/gate-check.mjs +20 -4
  27. package/src/review/packet.mjs +58 -20
  28. package/src/session/facts.mjs +234 -64
  29. package/src/session/spawn.mjs +70 -19
  30. package/src/state/block-end.mjs +3 -1
  31. package/src/state/block.mjs +17 -5
  32. package/src/state/run.mjs +12 -5
  33. package/src/util/native.mjs +2 -0
  34. package/src/worker/engine.mjs +11 -26
  35. package/src/worker/loop.mjs +6 -4
  36. package/src/worker/queue.mjs +2 -1
  37. package/src/worker/review-state.mjs +15 -3
  38. package/src/worker/watch.mjs +92 -89
  39. package/src/worker/worker-warning.mjs +35 -0
@@ -0,0 +1,200 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Script } from 'node:vm';
4
+
5
+ /** @typedef {{value: string, string?: boolean, start: number, end: number}} Token */
6
+
7
+ /** Tokenize without treating comments, quoted text or regular expressions as imports.
8
+ * @param {string} source @returns {Token[]}
9
+ */
10
+ function tokens(source) {
11
+ const out = /** @type {Token[]} */ ([]);
12
+ const templates = /** @type {{expression: boolean, depth: number}[]} */ ([]);
13
+ let i = 0;
14
+ while (i < source.length) {
15
+ const start = i, char = source[i];
16
+ const template = templates.at(-1);
17
+ if (template && !template.expression) {
18
+ if (char === '\\') { i += 2; continue; }
19
+ if (char === '`') { templates.pop(); i++; continue; }
20
+ if (source.startsWith('${', i)) {
21
+ template.expression = true;
22
+ i += 2;
23
+ out.push({ value: '{', start, end: i });
24
+ } else i++;
25
+ continue;
26
+ }
27
+ if (char === '`') { templates.push({ expression: false, depth: 0 }); i++; continue; }
28
+ if (template && char === '{') template.depth++;
29
+ if (template && char === '}') {
30
+ if (template.depth === 0) template.expression = false;
31
+ else template.depth--;
32
+ }
33
+ if (/\s/.test(char)) { i++; continue; }
34
+ if (source.startsWith('//', i) || (i === 0 && source.startsWith('#!'))) {
35
+ i = source.indexOf('\n', i);
36
+ if (i < 0) break;
37
+ continue;
38
+ }
39
+ if (source.startsWith('/*', i)) {
40
+ i = source.indexOf('*/', i + 2);
41
+ if (i < 0) throw new SyntaxError('unclosed comment');
42
+ i += 2;
43
+ continue;
44
+ }
45
+ if (char === '"' || char === "'") {
46
+ i++;
47
+ while (i < source.length && source[i] !== char) {
48
+ if (source[i] === '\\') i++;
49
+ i++;
50
+ }
51
+ if (i === source.length) throw new SyntaxError('unclosed string');
52
+ out.push({ value: source.slice(start + 1, i), string: true, start, end: ++i });
53
+ continue;
54
+ }
55
+ // A slash after an expression-start token is a regexp, not a division operator.
56
+ if (char === '/' && (!out.length || /^(?:[=(:,;!&|?{}\[]|return|throw|case|=>)$/.test(out.at(-1).value))) {
57
+ let bracket = false;
58
+ i++;
59
+ while (i < source.length) {
60
+ if (source[i] === '\\') { i += 2; continue; }
61
+ if (source[i] === '[') bracket = true;
62
+ if (source[i] === ']') bracket = false;
63
+ if (source[i++] === '/' && !bracket) break;
64
+ }
65
+ while (/[a-z]/i.test(source[i] ?? '') && i < source.length) i++;
66
+ out.push({ value: 'regexp', start, end: i });
67
+ continue;
68
+ }
69
+ const word = /^[\w$]+/.exec(source.slice(i));
70
+ i += word ? word[0].length : 1;
71
+ out.push({ value: source.slice(start, i), start, end: i });
72
+ }
73
+ if (templates.length) throw new SyntaxError('unclosed template');
74
+ return out;
75
+ }
76
+
77
+ /** Extract module edges and syntax-check a script equivalent without executing it.
78
+ * Module declarations are removed only for the syntax check; dynamic imports stay intact.
79
+ * @param {string} source @returns {string[]}
80
+ */
81
+ function specifiers(source) {
82
+ const list = tokens(source);
83
+ const specs = [];
84
+ const edits = /** @type {{start: number, end: number, text: string}[]} */ ([]);
85
+ let depth = 0;
86
+ for (let i = 0; i < list.length; i++) {
87
+ const token = list[i], next = list[i + 1];
88
+ if (token.string) continue;
89
+ if (['{', '[', '('].includes(token.value)) depth++;
90
+ if (['}', ']', ')'].includes(token.value)) depth--;
91
+ if (!next || list[i - 1]?.value === '.') continue;
92
+ if (token.value === 'import' && next.value === '(') {
93
+ if (list[i + 2]?.string && [')', ','].includes(list[i + 3]?.value)) specs.push(list[i + 2].value);
94
+ continue;
95
+ }
96
+ if (token.value === 'import' && next.value === '.') {
97
+ edits.push({ start: token.start, end: list[i + 2].end, text: '({})' });
98
+ i += 2;
99
+ continue;
100
+ }
101
+ if (depth !== 0 || (token.value !== 'import' && token.value !== 'export')) continue;
102
+ if (token.value === 'export' && !['{', '*'].includes(next.value)) {
103
+ const isDefault = next.value === 'default';
104
+ edits.push({ start: token.start, end: isDefault ? next.end : token.end, text: isDefault ? 'const __default_export__ =' : '' });
105
+ if (isDefault) i++;
106
+ continue;
107
+ }
108
+ let end = i + 1;
109
+ if (!next.string) {
110
+ if (next.value === '{') {
111
+ while (end < list.length && list[end].value !== '}') end++;
112
+ if (list[end + 1]?.value === 'from') end += 2;
113
+ } else {
114
+ while (end < list.length && !(list[end].value === 'from' && list[end + 1]?.string)) end++;
115
+ end++;
116
+ }
117
+ }
118
+ if (!list[end]) throw new SyntaxError('incomplete module declaration');
119
+ if (list[end].string) specs.push(list[end].value);
120
+ // JSON import attributes are part of the declaration being removed.
121
+ if (['with', 'assert'].includes(list[end + 1]?.value) && list[end + 2]?.value === '{') {
122
+ end += 3;
123
+ while (end < list.length && list[end].value !== '}') end++;
124
+ if (!list[end]) throw new SyntaxError('incomplete import attributes');
125
+ }
126
+ edits.push({ start: token.start, end: list[end].end, text: '' });
127
+ i = end;
128
+ }
129
+ // Keep source positions stable when stripping a hashbang.
130
+ let script = source.replace(/^#![^\n]*/, (line) => ' '.repeat(line.length));
131
+ for (const edit of edits.reverse()) script = script.slice(0, edit.start) + edit.text + script.slice(edit.end);
132
+ new Script(`(async function () { "use strict";\n${script}\n})`);
133
+ return specs.filter((specifier) => specifier.startsWith('./') || specifier.startsWith('../'));
134
+ }
135
+
136
+ /** @param {string} file */
137
+ const testPath = (file) => /(?:^|\/)(?:test|tests|__tests__|spec)\//.test(file) || /\.(?:test|spec)\.[^/]+$/.test(file);
138
+
139
+ /** Build a reverse transitive graph, shared by the async selector and the synchronous scope.
140
+ * Every module is read and parsed at most once. Missing/deleted targets remain graph edges.
141
+ * @param {{cwd: string, testFiles: string[]}} opts
142
+ * @returns {Promise<Record<string, string[]>>}
143
+ */
144
+ export async function testDependents({ cwd, testFiles }) {
145
+ const root = path.resolve(cwd);
146
+ const reads = new Map();
147
+ const stats = new Map();
148
+ const graph = /** @type {Record<string, string[]>} */ (Object.create(null));
149
+ /** @param {string} file @returns {Promise<boolean>} */
150
+ function exists(file) {
151
+ if (!stats.has(file)) stats.set(file, stat(path.join(root, file)).then((s) => s.isFile()).catch(() => false));
152
+ return stats.get(file);
153
+ }
154
+ /** @param {string} file @returns {Promise<string[] | null>} */
155
+ function imports(file) {
156
+ if (!reads.has(file)) reads.set(file, (async () => {
157
+ try {
158
+ const specs = specifiers(await readFile(path.join(root, file), 'utf8'));
159
+ const edges = [];
160
+ for (const spec of specs) {
161
+ const relative = path.relative(root, path.resolve(root, path.dirname(file), spec)).split(path.sep).join('/');
162
+ if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) continue;
163
+ const choices = path.extname(relative) ? [relative] : [relative + '.mjs', relative + '.js', relative + '/index.mjs'];
164
+ let resolved = choices[0];
165
+ for (const choice of choices) if (await exists(choice)) { resolved = choice; break; }
166
+ edges.push(resolved);
167
+ }
168
+ return edges;
169
+ } catch { return null; }
170
+ })());
171
+ return reads.get(file);
172
+ }
173
+ for (const test of [...new Set(testFiles)].sort()) {
174
+ if (!(await imports(test))) continue;
175
+ const seen = new Set([test]);
176
+ const pending = [test];
177
+ while (pending.length) {
178
+ for (const dependency of (await imports(pending.pop())) ?? []) {
179
+ if (seen.has(dependency)) continue;
180
+ seen.add(dependency);
181
+ (graph[dependency] ??= []).push(test);
182
+ pending.push(dependency);
183
+ }
184
+ }
185
+ if (/^test\/(?:config|doctor)\//.test(test)) (graph['schema/code-forge.schema.json'] ??= []).push(test);
186
+ }
187
+ for (const file of Object.keys(graph)) graph[file] = [...new Set(graph[file])].sort();
188
+ return graph;
189
+ }
190
+
191
+ /** Tests importing any changed non-test source, directly or through repository modules.
192
+ * @param {{cwd: string, changed: string[], testFiles: string[]}} opts
193
+ * @returns {Promise<string[]>}
194
+ */
195
+ export async function dependentTests({ cwd, changed, testFiles }) {
196
+ const sources = changed.filter((file) => !testPath(file) && !testFiles.includes(file));
197
+ if (!sources.length) return [];
198
+ const graph = await testDependents({ cwd, testFiles });
199
+ return [...new Set(sources.flatMap((file) => graph[file] ?? []))].sort();
200
+ }
@@ -28,6 +28,7 @@ import path from 'node:path';
28
28
  import { isExactEntry, ownsFile } from '../state/registry.mjs';
29
29
  import { pairsFor } from '../review/paired.mjs';
30
30
  import { computeFileSet } from './scope.mjs';
31
+ import { dependentTests, testDependents } from './dependents.mjs';
31
32
  import { exec } from '../util/exec.mjs';
32
33
  import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
33
34
 
@@ -73,14 +74,38 @@ export function isChangedTest(file) {
73
74
  }
74
75
 
75
76
  /**
76
- * Existing block tests added or changed since its base, independent of the last green scope
77
- * and its threshold. Deleted tests and unchanged source-paired tests are not repeat candidates.
77
+ * Keep test files supported by the executable's runner family. Wrappers in the JS family
78
+ * still accept JS tests; unknown executables retain the caller's complete file list.
79
+ * @param {ReadonlyArray<string>} argv @param {ReadonlyArray<string>} files
80
+ * @returns {string[]}
81
+ */
82
+ export function testsForRunner(argv, files) {
83
+ let extensions;
84
+ switch (path.basename(argv[0] ?? '')) {
85
+ case 'node': case 'bun': case 'deno': case 'npm': case 'npx': case 'pnpm':
86
+ case 'yarn': case 'bunx': case 'vitest': case 'jest': case 'mocha':
87
+ extensions = /\.(?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$/;
88
+ break;
89
+ case 'go': extensions = /\.go$/; break;
90
+ case 'php': case 'pest': case 'phpunit': extensions = /\.php$/; break;
91
+ case 'pytest': case 'python': case 'python3': extensions = /\.py$/; break;
92
+ case 'cargo': extensions = /\.rs$/; break;
93
+ case 'swift': case 'xcodebuild': extensions = /\.swift$/; break;
94
+ default: return [...files];
95
+ }
96
+ return files.filter((file) => extensions.test(file));
97
+ }
98
+
99
+ /**
100
+ * Existing owned changed tests plus every test importing an owned changed source since base.
101
+ * Deleted tests are omitted; ownership limits changed inputs, not their test callers.
78
102
  * @param {{cwd: string, base: string, owned: string[], kinds?: Record<string, string>}} opts
79
103
  * @returns {Promise<string[]>}
80
104
  */
81
105
  export async function changedTestFiles({ cwd, base, owned, kinds }) {
82
106
  const { all } = await computeFileSet({ cwd, base });
83
- const files = all.filter((file) => ownsFile(owned, file, kinds) && isChangedTest(file));
107
+ const changed = all.filter((file) => ownsFile(owned, file, kinds));
108
+ const files = changed.filter(isChangedTest);
84
109
  const existing = await Promise.all(files.map(async (file) => {
85
110
  const stat = await lstat(path.join(cwd, file)).catch((error) => {
86
111
  if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
@@ -88,7 +113,9 @@ export async function changedTestFiles({ cwd, base, owned, kinds }) {
88
113
  });
89
114
  return stat?.isFile() === true;
90
115
  }));
91
- return files.filter((_, i) => existing[i]);
116
+ const testFiles = (await repositoryFiles(cwd) ?? []).filter(isChangedTest);
117
+ const dependents = await dependentTests({ cwd, changed: changed.filter((file) => !isChangedTest(file)), testFiles });
118
+ return [...new Set([...files.filter((_, i) => existing[i]), ...dependents])].sort();
92
119
  }
93
120
 
94
121
  /**
@@ -98,8 +125,8 @@ export async function changedTestFiles({ cwd, base, owned, kinds }) {
98
125
  const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEntry(p) && !p.endsWith('/');
99
126
 
100
127
  /**
101
- * @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
102
- * @typedef {{scope: 'changed', files: string[], changedFiles: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'}} ChangedScope
128
+ * @typedef {{scope: 'related', files: string[]} | {scope: 'full', files?: string[], reason: 'threshold' | 'no_tests' | 'runner-filter'}} TestScope
129
+ * @typedef {{scope: 'changed', files: string[], changedFiles: string[], dependents?: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'}} ChangedScope
103
130
  */
104
131
 
105
132
  /** A gate run with no block: no test file is known. */
@@ -151,21 +178,28 @@ function safeFile(file) {
151
178
  return file.length > 0 && !file.startsWith('-') && !path.isAbsolute(file) && !/[\\\x00-\x1f]/.test(file) && !file.split('/').some((s) => s === '' || s === '.' || s === '..');
152
179
  }
153
180
 
181
+ /** Tracked and new, non-ignored repo files in an isolated git environment.
182
+ * @param {string} cwd @returns {Promise<string[] | null>}
183
+ */
184
+ async function repositoryFiles(cwd) {
185
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
186
+ env.GIT_CONFIG_NOSYSTEM = '1';
187
+ env.GIT_CONFIG_GLOBAL = os.devNull;
188
+ const listed = await exec(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, env, timeoutMs: 30000 });
189
+ return listed.result === 'ok' ? [...new Set(listed.stdout.split('\0').filter(Boolean))].sort() : null;
190
+ }
191
+
154
192
  /**
155
193
  * Bounded content baseline. Missing listed files are omitted from hashes and candidates; an
156
194
  * owned entry with no current file has no hash. More than 200 owned files, a symlink, a
157
195
  * non-regular owned file, a path outside the root or another read error means no record;
158
196
  * callers retain the old scope. Git sees only argv and an isolated git environment.
159
197
  * @param {string} cwd @param {string[]} owned @param {Record<string, string>} [kinds]
160
- * @returns {Promise<{files: Record<string, string>, candidates: string[]} | null>}
198
+ * @returns {Promise<{files: Record<string, string>, candidates: string[], dependents?: Record<string, string[]>} | null>}
161
199
  */
162
200
  export async function fileBaseline(cwd, owned, kinds) {
163
- const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
164
- env.GIT_CONFIG_NOSYSTEM = '1';
165
- env.GIT_CONFIG_GLOBAL = os.devNull;
166
- const listed = await exec(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, env, timeoutMs: 30000 });
167
- if (listed.result !== 'ok') return null;
168
- const listedFiles = [...new Set(listed.stdout.split('\0').filter(Boolean))].sort();
201
+ const listedFiles = await repositoryFiles(cwd);
202
+ if (!listedFiles) return null;
169
203
  const candidates = [];
170
204
  const files = /** @type {Record<string, string>} */ (Object.create(null));
171
205
  try {
@@ -192,11 +226,13 @@ export async function fileBaseline(cwd, owned, kinds) {
192
226
  files[file] = createHash('sha256').update(await readFile(absolute)).digest('hex');
193
227
  }
194
228
  } catch { return null; }
195
- return { files, candidates };
229
+ const dependents = await testDependents({ cwd, testFiles: candidates.filter(isChangedTest) });
230
+ // The graph is transient: keep the baseline's public/serialized hash-and-candidate shape.
231
+ return Object.defineProperty({ files, candidates }, 'dependents', { value: dependents });
196
232
  }
197
233
 
198
234
  /**
199
- * Compare the last green record. A source without a stem pair cannot narrow the test gate.
235
+ * Compare the last green record, adding importers and retaining stem pairs as a fallback.
200
236
  * @param {{baseline: unknown, current: Awaited<ReturnType<typeof fileBaseline>>, fallback: TestScope, threshold?: number, owned?: string[], kinds?: Record<string, string>}} opts
201
237
  * @returns {ChangedScope}
202
238
  */
@@ -204,29 +240,46 @@ export function changedScope({ baseline, current, fallback, threshold = DEFAULT_
204
240
  const old = /** @type {Record<string, string> | null} */ (baseline);
205
241
  const candidates = new Set(current?.candidates);
206
242
  const fallbackFiles = fallback.scope === 'related' ? fallback.files.filter((file) => !current || candidates.has(file)) : [];
243
+ /** Merge callers while preserving the legacy fallback for sources with no known callers.
244
+ * @param {string[]} files @param {string[]} sources @param {string[]} changedFiles
245
+ * @param {ChangedScope['reason']} [reason] @returns {ChangedScope}
246
+ */
247
+ function withDependents(files, sources, changedFiles, reason) {
248
+ const dependents = [...new Set(sources.filter((file) => !isChangedTest(file)).flatMap((file) => current?.dependents?.[file] ?? []))]
249
+ .filter((file) => !files.includes(file)).sort();
250
+ // An empty full fallback means every test; adding callers would narrow that run.
251
+ const fullFallback = fallback.scope === 'full' && (reason === 'unpaired' || reason === 'no_baseline');
252
+ const union = fullFallback ? [] : [...new Set([...files, ...dependents])].sort();
253
+ const overThreshold = union.length > threshold || reason === 'threshold';
254
+ return { scope: 'changed', files: overThreshold ? [] : union, changedFiles,
255
+ ...(current?.dependents || current === null ? { dependents } : {}),
256
+ ...(overThreshold ? { reason: /** @type {const} */ ('threshold') } : reason ? { reason } : {}),
257
+ };
258
+ }
207
259
  if (!current || !old || typeof old !== 'object' || Array.isArray(old) || Object.keys(old).length > 200 ||
208
260
  Object.entries(old).some(([file, hash]) => !safeFile(file) || (owned && !ownsFile(owned, file, kinds)) || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))) {
209
- return { scope: 'changed', files: fallbackFiles, changedFiles: [], reason: 'no_baseline' };
261
+ return withDependents(fallbackFiles, Object.keys(current?.files ?? {}), [], fallback.scope === 'full' && fallback.reason === 'threshold' ? 'threshold' : 'no_baseline');
210
262
  }
211
263
  const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
212
- if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
264
+ if (changes.length > threshold) return withDependents([], changes, [], 'threshold');
213
265
  const changedFiles = changes.filter((file) => candidates.has(file));
214
266
  const tests = new Set(changedFiles.filter(isChangedTest));
215
267
  for (const file of changes.filter((f) => !isChangedTest(f))) {
268
+ if (current.dependents?.[file]?.length) continue;
216
269
  const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
217
- if (pairs.length === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'unpaired' };
270
+ if (pairs.length === 0) return withDependents(fallbackFiles, changes, changedFiles, 'unpaired');
218
271
  for (const paired of pairs) tests.add(paired);
219
272
  }
220
- if (tests.size === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'no_changes' };
221
- return { scope: 'changed', files: [...tests].sort(), changedFiles };
273
+ if (tests.size === 0 && !changes.some((file) => current.dependents?.[file]?.length)) return withDependents(fallbackFiles, changes, changedFiles, 'no_changes');
274
+ return withDependents([...tests].sort(), changes, changedFiles);
222
275
  }
223
276
 
224
277
  /**
225
278
  * One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
226
- * test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
227
- * unchanged) when the gate has no such item.
279
+ * test files, or dropped for a full run. Always a new array; `scoped` records whether a scope
280
+ * placeholder was handled, including full runs. `full` signals a runner-filter fallback.
228
281
  * @param {ReadonlyArray<string>} argv @param {TestScope | ChangedScope} scope
229
- * @returns {{argv: string[], scoped: boolean}}
282
+ * @returns {{argv: string[], scoped: boolean, full?: true, reason?: 'runner-filter'}}
230
283
  * @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
231
284
  * open --tests` refuses one too).
232
285
  */
@@ -237,17 +290,21 @@ export function expandArgv(argv, scope) {
237
290
  if (changed.some((file) => !safeFile(file))) throw new TypeError('expandArgv: unsafe changed path');
238
291
  const dashed = files.find((f) => f.startsWith('-'));
239
292
  if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
240
- return { argv: argv.flatMap((item) => item === TEST_FILES_TOKEN ? files : item === CHANGED_FILES_TOKEN ? changed : [item]), scoped: true };
293
+ const runnerFiles = testsForRunner(argv, files);
294
+ const full = argv.includes(TEST_FILES_TOKEN) && files.length > 0 && runnerFiles.length === 0;
295
+ return { argv: argv.flatMap((item) => item === TEST_FILES_TOKEN ? runnerFiles : item === CHANGED_FILES_TOKEN ? changed : [item]), scoped: true,
296
+ ...(full ? { full: /** @type {const} */ (true), reason: /** @type {const} */ ('runner-filter') } : {}),
297
+ };
241
298
  }
242
299
 
243
300
  /**
244
301
  * The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
245
302
  * or `reason` when full.
246
303
  * @param {TestScope | ChangedScope} scope
247
- * @returns {{scope: 'changed', test_count: number, reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'} | {scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'|'no_tests'}}
304
+ * @returns {{scope: 'changed', test_count: number, dependents?: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'} | {scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'|'no_tests'|'runner-filter'}}
248
305
  */
249
306
  export function scopeFields(scope) {
250
- if (scope.scope === 'changed') return { scope: 'changed', test_count: scope.files.length, ...(scope.reason ? { reason: scope.reason } : {}) };
307
+ if (scope.scope === 'changed') return { scope: 'changed', test_count: scope.files.length, ...(scope.dependents ? { dependents: scope.dependents } : {}), ...(scope.reason ? { reason: scope.reason } : {}) };
251
308
  return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
252
309
  }
253
310
 
package/src/gates/run.mjs CHANGED
@@ -14,7 +14,7 @@ import { customGateMessages } from '../config/validate.mjs';
14
14
  import { exec } from '../util/exec.mjs';
15
15
  import { registerSecret } from '../util/redact.mjs';
16
16
  import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
17
- import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
17
+ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv, maskableEnvValues, scopeFields, testsForRunner } from './expand.mjs';
18
18
 
19
19
  /** @typedef {import('./detect.mjs').Gate} Gate */
20
20
  /** @typedef {import('./detect.mjs').GateCommand} GateCommand */
@@ -42,12 +42,13 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
42
42
  * @property {boolean} ok - true when skipped, or when the command exited with code 0 (and, for a
43
43
  * `failOnStdout` gate, its stdout was empty).
44
44
  * @property {number | null} code
45
+ * @property {true} [timed_out] - the command exceeded its gate deadline, not a test failure.
45
46
  * @property {string} stdout
46
47
  * @property {string} stderr
47
48
  * @property {string} [message] - termination diagnostic with the last lines of both streams.
48
49
  * @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
49
50
  * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
50
- * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'|'timeout'|'prompt'|'spawn-error'|'terminated'|NodeJS.Signals|`not in gates.kinds.${string}`} [reason] - fallback, exclusion or gate failure.
51
+ * @property {'threshold'|'no_tests'|'runner-filter'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'|'timeout'|'prompt'|'spawn-error'|'terminated'|NodeJS.Signals|`not in gates.kinds.${string}`} [reason] - fallback, exclusion or gate failure.
51
52
  * @property {Repeats} [repeats]
52
53
  * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
53
54
  * NAMES, sorted (the values are never in a result).
@@ -239,11 +240,57 @@ function normalizeCommand(command) {
239
240
  return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
240
241
  }
241
242
 
243
+ /** Node reports cancelled tests as failures after a timeout. Keep their diagnostics, but
244
+ * remove the failure markers consumed by TAP lists and shown by the spec reporter.
245
+ * @param {string} output @returns {string}
246
+ */
247
+ function unfinishedOutput(output) {
248
+ const lines = output.split('\n');
249
+ /** @type {Map<number, {indent: number, tap: boolean, diagnostic: string}>} */
250
+ const failures = new Map();
251
+ for (let i = 0; i < lines.length; i++) {
252
+ const tap = /^([ \t]*)not ok\s+\d+\s+- /.exec(lines[i]);
253
+ const spec = /^([ \t]*)✖ /.exec(lines[i]);
254
+ if (!tap && !spec) continue;
255
+ let diagnostic = lines[i];
256
+ const indent = (tap ?? spec)[1].length;
257
+ for (let j = i + 1; j < lines.length; j++) {
258
+ if (lines[j].trim() && lines[j].search(/\S/) <= indent) break;
259
+ if (/^\s*(?:not ok\s+\d+\s+- |✖ |# Subtest: |▶ )/.test(lines[j])) break;
260
+ diagnostic += `\n${lines[j]}`;
261
+ if (tap && lines[j].trim() === '...') break;
262
+ }
263
+ failures.set(i, { indent, tap: !!tap, diagnostic });
264
+ }
265
+ /** @type {Set<number>} */
266
+ const unfinished = new Set();
267
+ // Reporters normally close a suite after its children; also accept a parent-first list.
268
+ // Visit deeper entries first so unfinished status propagates through every ancestor.
269
+ for (const [i, { indent, diagnostic }] of [...failures].sort((a, b) => b[1].indent - a[1].indent)) {
270
+ const children = [];
271
+ for (const direction of [-1, 1]) {
272
+ for (let j = i + direction; j >= 0 && j < lines.length; j += direction) {
273
+ if (lines[j].trim() && lines[j].search(/\S/) <= indent) break;
274
+ if (failures.has(j)) children.push(j);
275
+ }
276
+ }
277
+ const pending = diagnostic.includes('Promise resolution is still pending but the event loop has already resolved');
278
+ const parent = /subtestsFailed|\b\d+ subtests? failed\b/.test(diagnostic) || diagnostic.trimEnd() === lines[i].trimEnd();
279
+ const allUnfinished = children.length > 0 && children.every((j) => unfinished.has(j));
280
+ if (children.length ? !allUnfinished || !(pending || parent) : !pending) continue;
281
+ unfinished.add(i);
282
+ }
283
+ for (const i of unfinished) {
284
+ lines[i] = failures.get(i).tap ? lines[i].replace('not ok', '# unfinished') : lines[i].replace('✖', 'ℹ unfinished:');
285
+ }
286
+ return lines.join('\n');
287
+ }
288
+
242
289
  /** Interpret timeouts and unanswered prompts for every gate, including repeats.
243
290
  * @param {import('../util/exec.mjs').ExecResult} res
244
291
  * @param {number} timeoutMs
245
292
  * @param {boolean} [failOnStdout]
246
- * @returns {Pick<GateResult, 'ok'|'code'|'stdout'|'stderr'|'reason'|'message'>}
293
+ * @returns {Pick<GateResult, 'ok'|'code'|'stdout'|'stderr'|'reason'|'message'|'timed_out'>}
247
294
  */
248
295
  function gateOutcome(res, timeoutMs, failOnStdout = false) {
249
296
  const prompt = res.result === 'ok' && [res.stdout, res.stderr].some((stream) =>
@@ -255,18 +302,24 @@ function gateOutcome(res, timeoutMs, failOnStdout = false) {
255
302
  ? `gate timeout after ${timeoutMs / 1000} seconds; raise gates.timeout_s (or --timeout-ms)`
256
303
  : res.code === null ? `gate terminated: ${res.signal ?? res.error ?? 'no exit status (no signal reported)'}`
257
304
  : prompt ? 'gate ended on an interactive prompt; install the tool or make the command non-interactive' : '';
258
- const tails = ['stdout', 'stderr'].flatMap((stream) => {
259
- const tail = res[stream].trimEnd().split(/\r?\n/).slice(-10).join('\n');
305
+ const stdout = res.timedOut ? unfinishedOutput(res.stdout) : res.stdout;
306
+ const stderr = res.timedOut ? unfinishedOutput(res.stderr) : res.stderr;
307
+ const tails = [stdout, stderr].flatMap((output, index) => {
308
+ const stream = index === 0 ? 'stdout' : 'stderr';
309
+ const tail = output.trimEnd().split(/\r?\n/).slice(-10).join('\n');
260
310
  return tail ? [`${stream} tail:\n${tail}`] : [];
261
311
  });
262
- const message = [diagnostic, ...tails].join('\n');
312
+ const message = [res.timedOut
313
+ ? `TIMEOUT after ${timeoutMs / 1000} s (not a test failure); rerun with --timeout-ms ${timeoutMs * 2} or raise gates.timeout_s`
314
+ : diagnostic, ...tails].join('\n');
263
315
  return {
264
316
  ok: res.result === 'ok' && !reason && (!failOnStdout || res.stdout.trim().length === 0),
265
317
  code: res.code,
266
- stdout: res.stdout,
318
+ stdout,
267
319
  stderr: diagnostic
268
- ? `${res.stderr}${res.stderr && !res.stderr.endsWith('\n') ? '\n' : ''}${diagnostic}\n`
269
- : res.stderr,
320
+ ? `${stderr}${stderr && !stderr.endsWith('\n') ? '\n' : ''}${diagnostic}\n`
321
+ : stderr,
322
+ ...(res.timedOut ? { timed_out: /** @type {const} */ (true) } : {}),
270
323
  ...(reason ? { reason } : {}),
271
324
  ...(diagnostic ? { message } : {}),
272
325
  };
@@ -312,7 +365,10 @@ export async function runGates(gates, opts) {
312
365
  if (command === null || excluded(gate)) { planned.push({ gate, command: null }); continue; }
313
366
  const normalized = normalizeCommand(command);
314
367
  if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
315
- let { argv, scoped } = expandArgv(normalized.argv, scope);
368
+ let { argv, scoped, full, reason } = expandArgv(normalized.argv, scope);
369
+ const commandScope = full ? { scope: /** @type {const} */ ('full'), files: [], reason }
370
+ : scope.scope !== 'full' && normalized.argv.includes(TEST_FILES_TOKEN)
371
+ ? { ...scope, files: testsForRunner(normalized.argv, scope.files) } : scope;
316
372
  if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
317
373
  const formatter = gate === 'format' && !Array.isArray(command) ? command.formatter : undefined;
318
374
  let batches;
@@ -322,7 +378,7 @@ export async function runGates(gates, opts) {
322
378
  batches = formatBatches(argv, files, opts.formatArgvBudgetBytes ?? FORMAT_ARGV_BUDGET_BYTES);
323
379
  argv = argv.concat(files);
324
380
  }
325
- planned.push({ gate, command: { argv, batches, originalArgv: normalized.argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } });
381
+ planned.push({ gate, command: { argv, batches, originalArgv: normalized.argv, scoped, full, scope: commandScope, failOnStdout: normalized.failOnStdout, env: normalized.env } });
326
382
  }
327
383
  /** @type {GateResult[]} */
328
384
  const results = [];
@@ -349,7 +405,7 @@ export async function runGates(gates, opts) {
349
405
  }
350
406
  // Retain the first failure's status and diagnostic even if later batches pass.
351
407
  const outcome = {
352
- ...(outcomes.find((entry) => !entry.ok) ?? outcomes[0]),
408
+ ...(outcomes.find((entry) => entry.timed_out) ?? outcomes.find((entry) => !entry.ok) ?? outcomes[0]),
353
409
  stdout: outcomes.map((entry) => entry.stdout).join(''),
354
410
  stderr: outcomes.map((entry) => entry.stderr).join(''),
355
411
  };
@@ -358,7 +414,7 @@ export async function runGates(gates, opts) {
358
414
  duration_ms: 0,
359
415
  argv,
360
416
  skipped: false,
361
- ...(scoped ? scopeFields(scope) : {}),
417
+ ...(scoped ? scopeFields(command.scope) : {}),
362
418
  ...outcome,
363
419
  ...(env ? { env_keys: Object.keys(env).sort() } : {}),
364
420
  });
@@ -366,8 +422,14 @@ export async function runGates(gates, opts) {
366
422
  if (gate === 'test' && outcome.ok && repeat) {
367
423
  if (!command.originalArgv.includes(TEST_FILES_TOKEN)) {
368
424
  repeat.onSkip?.('repeat_new_tests: skipped (gates.test has no {test_files})');
425
+ } else if (command.full) {
426
+ repeat.onSkip?.('repeat_new_tests: skipped (no changed tests for this runner)');
369
427
  } else {
370
- const files = (await changedTestFiles({ cwd, ...repeat })).filter((file) => !gates.lightTestFiles || gates.lightTestFiles.includes(file));
428
+ const changedFiles = (await changedTestFiles({ cwd, ...repeat })).filter((file) => !gates.lightTestFiles || gates.lightTestFiles.includes(file));
429
+ const files = testsForRunner(command.originalArgv, changedFiles);
430
+ if (changedFiles.length > 0 && files.length === 0) {
431
+ repeat.onSkip?.('repeat_new_tests: skipped (no changed tests for this runner)');
432
+ }
371
433
  if (files.length > 0) {
372
434
  result.repeats = { n: repeat.n, files, failed: false };
373
435
  const repeatArgv = expandArgv(command.originalArgv, { scope: 'related', files }).argv;