@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -2
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +14 -5
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/review-file.mjs +5 -0
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +37 -5
- package/src/decide/escalation.mjs +5 -5
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +20 -4
- package/src/review/packet.mjs +58 -20
- package/src/session/facts.mjs +234 -64
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +17 -5
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +2 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +6 -4
- package/src/worker/queue.mjs +2 -1
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +92 -89
- 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
|
+
}
|
package/src/gates/expand.mjs
CHANGED
|
@@ -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
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
221
|
-
return
|
|
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`
|
|
227
|
-
*
|
|
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
|
-
|
|
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
|
|
259
|
-
|
|
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 = [
|
|
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
|
|
318
|
+
stdout,
|
|
267
319
|
stderr: diagnostic
|
|
268
|
-
? `${
|
|
269
|
-
:
|
|
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
|
|
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;
|