forgedock 1.19.0 → 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.
- package/README.md +2 -0
- package/bin/engine/phases.mjs +4 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +40 -1
- package/bin/engine.mjs +48 -3
- package/bin/forgedock.mjs +1 -1
- package/bin/lean/effects.mjs +51 -0
- package/bin/lean/lean.mjs +352 -0
- package/bin/lean/lean.test.mjs +525 -0
- package/bin/lean/prompts.mjs +62 -0
- package/bin/lean/risk.mjs +49 -0
- package/bin/runner.mjs +99 -6
- package/commands/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr.md +7 -6
- package/commands/work-on/build/architect.md +1 -1
- package/commands/work-on/build/implement.md +9 -1
- package/commands/work-on/build/validate.md +14 -7
- package/commands/work-on/build.md +118 -8
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/remediate.md +11 -4
- package/commands/work-on/review.md +12 -4
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +8 -1
- package/docs/RELEASE-NOTES.md +21 -0
- package/docs/TELEMETRY.md +2 -2
- package/docs/WORK-ON-RUNTIME.md +11 -3
- package/package.json +2 -2
- package/scripts/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/ci-local/lib.mjs +11 -3
- package/scripts/ci-local/run.mjs +155 -49
- package/scripts/ci-local/scoping.mjs +27 -4
- package/scripts/ci-local.sh +3 -2
- package/scripts/ci-local.test.sh +189 -0
- package/scripts/classify-finding.sh +1 -1
- package/scripts/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- package/scripts/lib/telemetry.sh +2 -0
- package/scripts/review-finalize.sh +101 -12
- package/scripts/review-finalize.test.sh +136 -5
- package/scripts/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +8 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/verify-phase-trail.test.sh +6 -0
|
@@ -173,12 +173,14 @@ function readForgeYaml(repoPath) {
|
|
|
173
173
|
const yamlPath = join(repoPath, 'forge.yaml');
|
|
174
174
|
if (!existsSync(yamlPath)) return null;
|
|
175
175
|
try {
|
|
176
|
-
const result = spawnSync('yq', ['-r', '.project.owner
|
|
176
|
+
const result = spawnSync('yq', ['-r', '.project.owner, .project.repo', yamlPath], {
|
|
177
177
|
encoding: 'utf8',
|
|
178
178
|
timeout: 5000,
|
|
179
179
|
});
|
|
180
|
-
if (result.status === 0
|
|
181
|
-
|
|
180
|
+
if (result.status === 0) {
|
|
181
|
+
const [owner, name] = result.stdout.split('\n').map(l => l.trim());
|
|
182
|
+
// Both halves must be real values: a partial forge.yaml must not yield "/" or "null/x"
|
|
183
|
+
if (owner && name && owner !== 'null' && name !== 'null') return `${owner}/${name}`;
|
|
182
184
|
}
|
|
183
185
|
} catch (_) {
|
|
184
186
|
// ignore
|
|
@@ -1576,7 +1578,7 @@ async function main() {
|
|
|
1576
1578
|
repo = readForgeYaml(repoPath);
|
|
1577
1579
|
}
|
|
1578
1580
|
if (!repo) {
|
|
1579
|
-
process.stderr.write('[ledger] ERROR: Cannot determine GitHub repo. Pass --repo owner/repo or ensure forge.yaml
|
|
1581
|
+
process.stderr.write('[ledger] ERROR: Cannot determine GitHub repo. Pass --repo owner/repo or ensure forge.yaml has project.owner and project.repo (yq required).\n');
|
|
1580
1582
|
process.exit(1);
|
|
1581
1583
|
}
|
|
1582
1584
|
|
|
@@ -1760,6 +1762,7 @@ async function main() {
|
|
|
1760
1762
|
}
|
|
1761
1763
|
|
|
1762
1764
|
main().catch(e => {
|
|
1763
|
-
process.
|
|
1765
|
+
const showStack = process.argv.includes('--verbose') || !!process.env.FORGE_DEBUG;
|
|
1766
|
+
process.stderr.write(`[ledger] FATAL: ${e && e.message ? e.message : e}\n${showStack && e && e.stack ? e.stack + '\n' : ''}`);
|
|
1764
1767
|
process.exit(1);
|
|
1765
1768
|
});
|
|
@@ -14,7 +14,7 @@ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
|
|
|
14
14
|
bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
|
|
15
15
|
eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
|
|
16
16
|
|
|
17
|
-
SELECT_JQ='[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("
|
|
17
|
+
SELECT_JQ='[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("\\n[ \\t]*<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]'
|
|
18
18
|
DELETE_JQ='[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end'
|
|
19
19
|
|
|
20
20
|
# c <id> <assoc> <type> <body>
|
|
@@ -27,7 +27,7 @@ PLAN=$'<!-- FORGE:BUILDER -->\n## Implementation\n\n<!-- FORGE:BUILDER:COMPLETE
|
|
|
27
27
|
PARTIAL=$'<!-- FORGE:BUILDER -->\n## Implementation (in progress)'
|
|
28
28
|
INVEST=$'<!-- FORGE:INVESTIGATOR -->\nThe build phase later posts FORGE:BUILDER and FORGE:BUILDER:COMPLETE.'
|
|
29
29
|
CTX=$'<!-- FORGE:CONTEXT -->\nmentions <!-- FORGE:BUILDER -->'
|
|
30
|
-
MIXED=$'<!-- FORGE:BUILDER -->\nquoted
|
|
30
|
+
MIXED=$'<!-- FORGE:BUILDER -->\nquoted:\n<!-- FORGE:INVESTIGATOR -->'
|
|
31
31
|
|
|
32
32
|
eq "INVESTIGATOR mentioning FORGE:BUILDER is not selected" "$(arch "$(c 1 NONE Bot "$INVEST")" | jq length)" 0
|
|
33
33
|
eq "INVESTIGATOR mentioning FORGE:BUILDER is never deleted" "$(del_id "$(c 1 NONE Bot "$INVEST")")" ""
|
|
@@ -38,7 +38,7 @@ eq "single partial comment -> delete its id" "$(del_id "$(c 4 NONE Bot "$PARTIAL
|
|
|
38
38
|
eq "two partial comments -> delete nothing" "$(del_id "$(c 4 NONE Bot "$PARTIAL")"$'\n'"$(c 5 NONE Bot "$PARTIAL")")" ""
|
|
39
39
|
eq "untrusted author partial ignored" "$(del_id "$(c 6 NONE User "$PARTIAL")")" ""
|
|
40
40
|
eq "untrusted author complete ignored" "$(n_complete "$(c 6 NONE User "$PLAN")")" 0
|
|
41
|
-
eq "partial
|
|
41
|
+
eq "partial with another FORGE marker at a line start is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
|
|
42
42
|
eq "investigator + partial comment -> only the comment id" "$(del_id "$(c 1 NONE Bot "$INVEST")"$'\n'"$(c 8 NONE Bot "$PARTIAL")")" 8
|
|
43
43
|
eq "paginated concatenated arrays" "$(printf '[%s]\n[%s]' "$(c 1 NONE Bot "$INVEST")" "$(c 9 NONE Bot "$PLAN")" | jq -s "$SELECT_JQ" | jq '[.[] | select(.complete)] | length')" 1
|
|
44
44
|
|
|
@@ -46,6 +46,15 @@ ARCHC=$'<!-- FORGE:ARCHITECT -->\nplan; the builder will post <!-- FORGE:BUILDER
|
|
|
46
46
|
eq "ARCHITECT mentioning the marker is not selected" "$(arch "$(c 10 NONE Bot "$ARCHC")" | jq length)" 0
|
|
47
47
|
eq "unparsable input fails closed" "$(printf 'not json' | jq -s "$SELECT_JQ" 2>/dev/null | jq -r "$DELETE_JQ" 2>/dev/null)" ""
|
|
48
48
|
|
|
49
|
+
INLINE=$'<!-- FORGE:BUILDER -->\n## Approach\nFollowed the plan in `<!-- FORGE:ARCHITECT -->` and <!-- FORGE:INVESTIGATOR --> notes.'
|
|
50
|
+
CRLF=$'<!-- FORGE:BUILDER -->\r\n<!-- FORGE:ARCHITECT -->\r\nbody'
|
|
51
|
+
eq "partial quoting a marker inline is picked and deleted e1_inline_picked" "$(del_id "$(c 11 NONE Bot "$INLINE")")" 11
|
|
52
|
+
eq "inline quoting partial is selected as non-other e1_inline_not_other" "$(arch "$(c 11 NONE Bot "$INLINE")" | jq -r '.[0].other')" false
|
|
53
|
+
eq "marker at the start of a later line is refused e2_line_start_refused" "$(arch "$(c 12 NONE Bot "$MIXED")" | jq -r '.[0].other')" true
|
|
54
|
+
eq "own first-line marker never counts as other e4_own_marker_not_other" "$(arch "$(c 13 NONE Bot "$PARTIAL")" | jq -r '.[0].other')" false
|
|
55
|
+
eq "two inline-quoting partials still delete nothing e3_two_partials_none" "$(del_id "$(c 11 NONE Bot "$INLINE")"$'\n'"$(c 14 NONE Bot "$INLINE")")" ""
|
|
56
|
+
eq "CRLF body with a marker line is still refused e5_crlf_refused" "$(del_id "$(c 15 NONE Bot "$CRLF")")" ""
|
|
57
|
+
|
|
49
58
|
# Spec drift guard: every mutating site carries this exact predicate and no unanchored selector.
|
|
50
59
|
for f in commands/work-on/build/implement.md commands/work-on/build.md commands/work-on/build/validate.md; do
|
|
51
60
|
SPEC="$ROOT/$f"
|
package/scripts/calibration.mjs
CHANGED
|
@@ -143,10 +143,13 @@ export function readForgeYaml(repoPath) {
|
|
|
143
143
|
const configPath = join(repoPath, 'forge.yaml');
|
|
144
144
|
if (!existsSync(configPath)) return null;
|
|
145
145
|
try {
|
|
146
|
-
const result = spawnSync('yq', ['-r', '.project.owner
|
|
146
|
+
const result = spawnSync('yq', ['-r', '.project.owner, .project.repo', configPath], {
|
|
147
147
|
encoding: 'utf8', timeout: 5000,
|
|
148
148
|
});
|
|
149
|
-
if (result.status === 0
|
|
149
|
+
if (result.status === 0) {
|
|
150
|
+
const [owner, name] = result.stdout.split('\n').map(l => l.trim());
|
|
151
|
+
if (owner && name && owner !== 'null' && name !== 'null') return `${owner}/${name}`;
|
|
152
|
+
}
|
|
150
153
|
} catch (_) { /* ignore */ }
|
|
151
154
|
return null;
|
|
152
155
|
}
|
package/scripts/ci-local/lib.mjs
CHANGED
|
@@ -29,10 +29,18 @@ export function commandExists(name) {
|
|
|
29
29
|
return r.code === 0;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Memoized per process: stepKey runs for every step (twice: cost ordering and execution), and an unmemoized
|
|
33
|
+
// `<tool> --version` is a synchronous spawn that blocks the scheduler and burns the budget (#4068 review CONC-1).
|
|
34
|
+
const TOOL_VERSIONS = new Map();
|
|
32
35
|
export function toolVersion(name) {
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
if (TOOL_VERSIONS.has(name)) return TOOL_VERSIONS.get(name);
|
|
37
|
+
let v = '';
|
|
38
|
+
if (/^[A-Za-z0-9._+-]+$/.test(name) && commandExists(name)) {
|
|
39
|
+
const r = run(name, ['--version'], { timeout: 5000 });
|
|
40
|
+
v = (r.out || r.err).split('\n')[0].slice(0, 120);
|
|
41
|
+
}
|
|
42
|
+
TOOL_VERSIONS.set(name, v);
|
|
43
|
+
return v;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
export function hostClass() {
|
package/scripts/ci-local/run.mjs
CHANGED
|
@@ -29,30 +29,57 @@ function slotCount() {
|
|
|
29
29
|
}
|
|
30
30
|
function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
let ticketSeq = 0;
|
|
33
|
+
// Is this slot dir free to take (absent, or held by a dead pid / for over 3 h)? A stale one is reaped.
|
|
34
|
+
function slotFree(slot) {
|
|
35
|
+
if (!fs.existsSync(slot)) return true;
|
|
36
|
+
let pid = 0; let ts = 0;
|
|
37
|
+
try { const [p, t] = fs.readFileSync(path.join(slot, 'owner'), 'utf8').split(' '); pid = Number(p); ts = Number(t); } catch { /* owner file not written yet */ }
|
|
38
|
+
const stale = pid ? !alive(pid) : (Date.now() - (fs.statSync(slot, { throwIfNoEntry: false })?.mtimeMs || 0) > 30000);
|
|
39
|
+
if (stale || (ts && Date.now() - ts > 3 * 3600 * 1000)) { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* raced */ } return !fs.existsSync(slot); }
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// forge#4057: FIFO-fair slot acquisition. Every waiter files a ticket (`<ms>-<seq>-<pid>-<rand>`) under ci-local.lock/queue and
|
|
44
|
+
// only a ticket among the first N (N = number of free slots) may claim one, so an invocation can never be starved by polling
|
|
45
|
+
// races. A ticket whose pid is dead is reaped; the ticket is removed on acquire, on a deadline miss and on every exit path.
|
|
46
|
+
export async function acquireSlot(owner, deadline) {
|
|
33
47
|
const dir = path.join(homeForgeDir(), 'ci-local.lock');
|
|
34
|
-
|
|
48
|
+
const qdir = path.join(dir, 'queue');
|
|
49
|
+
fs.mkdirSync(qdir, { recursive: true });
|
|
35
50
|
const n = slotCount();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const ticket = `${String(Date.now()).padStart(14, '0')}-${String(++ticketSeq).padStart(6, '0')}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
52
|
+
const tpath = path.join(qdir, ticket);
|
|
53
|
+
fs.writeFileSync(tpath, `${process.pid} ${owner}\n`, { flag: 'wx' });
|
|
54
|
+
const dropTicket = () => { try { fs.rmSync(tpath, { force: true }); } catch { /* gone */ } cleanups.delete(dropTicket); };
|
|
55
|
+
cleanups.add(dropTicket);
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const free = [];
|
|
59
|
+
for (let i = 0; i < n; i++) if (slotFree(path.join(dir, `slot-${i}`))) free.push(i);
|
|
60
|
+
const live = [];
|
|
61
|
+
for (const name of fs.readdirSync(qdir).sort()) {
|
|
62
|
+
if (name !== ticket && !alive(Number(name.split('-')[2]))) { try { fs.rmSync(path.join(qdir, name), { force: true }); } catch { /* raced */ } continue; }
|
|
63
|
+
live.push(name);
|
|
64
|
+
}
|
|
65
|
+
const pos = live.indexOf(ticket);
|
|
66
|
+
if (pos === -1) { try { fs.writeFileSync(tpath, `${process.pid} ${owner}\n`, { flag: 'wx' }); } catch { /* re-filed by a racing reaper */ } }
|
|
67
|
+
else if (pos < free.length) {
|
|
68
|
+
for (const i of free) {
|
|
69
|
+
const slot = path.join(dir, `slot-${i}`);
|
|
70
|
+
try {
|
|
71
|
+
fs.mkdirSync(slot);
|
|
72
|
+
fs.writeFileSync(path.join(slot, 'owner'), `${process.pid} ${Date.now()} ${owner}\n`);
|
|
73
|
+
const release = () => { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* gone */ } cleanups.delete(release); };
|
|
74
|
+
cleanups.add(release);
|
|
75
|
+
return release;
|
|
76
|
+
} catch (e) { if (e.code !== 'EEXIST') throw e; }
|
|
77
|
+
}
|
|
51
78
|
}
|
|
79
|
+
if (Date.now() > deadline) return null;
|
|
80
|
+
await sleep(250);
|
|
52
81
|
}
|
|
53
|
-
|
|
54
|
-
await sleep(250);
|
|
55
|
-
}
|
|
82
|
+
} finally { dropTicket(); }
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
// ---- process execution ----
|
|
@@ -153,6 +180,16 @@ function changeHash(ctx) {
|
|
|
153
180
|
if (!ctx.changeHash) ctx.changeHash = sha256(JSON.stringify([ctx.plan.merge_base, ctx.plan.changed_files.map((f) => `${f}:${fileHash(ctx.repo, f)}`)]));
|
|
154
181
|
return ctx.changeHash;
|
|
155
182
|
}
|
|
183
|
+
// forge#4060: service containers are an execution-environment input; they must enter every key derived from stepKey
|
|
184
|
+
// (single-flight, persisted result cache, cost probe). Order-insensitive; absent, undefined and [] fingerprint identically.
|
|
185
|
+
function servicesFp(job) {
|
|
186
|
+
const svcs = Array.isArray(job.services) ? job.services : [];
|
|
187
|
+
return JSON.stringify(svcs.map((s) => {
|
|
188
|
+
const v = s || {};
|
|
189
|
+
const env = v.env || {};
|
|
190
|
+
return [v.name, v.image, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), (v.ports || []).map(String), v.options || ''];
|
|
191
|
+
}).sort((a, b) => JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
|
|
192
|
+
}
|
|
156
193
|
function stepKey(step, job, ctx) {
|
|
157
194
|
const sel = step.scope || {};
|
|
158
195
|
const first = (shellSplit(step.command) || [])[0] || '';
|
|
@@ -161,7 +198,28 @@ function stepKey(step, job, ctx) {
|
|
|
161
198
|
? { files: sel.files.map((f) => `${f}:${fileHash(ctx.repo, f)}`), config: configFilesHash(ctx) }
|
|
162
199
|
: { change: changeHash(ctx), selected: sel.files || sel.selector || '' };
|
|
163
200
|
const env = { ...job.env, ...step.env };
|
|
164
|
-
return sha256(JSON.stringify([step.command, step.cwd, step.shell, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), toolVersion(path.basename(first)), process.version, inputs]));
|
|
201
|
+
return sha256(JSON.stringify([step.command, step.cwd, step.shell, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), toolVersion(path.basename(first)), process.version, inputs, servicesFp(job)]));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function durKey(step) { return sha256(`${step.command}\0${step.cwd || ''}`).slice(0, 40); }
|
|
205
|
+
|
|
206
|
+
// forge#4057: cost hint for ordering. wide = some step runs unscoped or widened; est = expected execution ms (a cached step
|
|
207
|
+
// costs 0, otherwise the last recorded duration of that command, else a default). Cheap and narrow jobs go first so a budget
|
|
208
|
+
// hit leaves the most coverage proven.
|
|
209
|
+
function jobCost(job, ctx) {
|
|
210
|
+
let wide = 0; let est = 0;
|
|
211
|
+
for (const step of job.steps) {
|
|
212
|
+
if (step.noop || step.skip) continue;
|
|
213
|
+
if (step.builtin) continue;
|
|
214
|
+
if (!step.scope || step.scope.mode === 'full' || step.scope.widen_reason) wide = 1;
|
|
215
|
+
try {
|
|
216
|
+
if (step.kind !== 'install' && !ctx.noResultCache && fs.existsSync(path.join(cacheDir(ctx.o), stepKey(step, job, ctx) + '.json'))) continue;
|
|
217
|
+
} catch { /* unknown cost */ }
|
|
218
|
+
let d = 60000;
|
|
219
|
+
try { const v = Number(fs.readFileSync(path.join(cacheDir(ctx.o), 'dur', durKey(step)), 'utf8')); if (v >= 0) d = v; } catch { /* no history */ }
|
|
220
|
+
est += d;
|
|
221
|
+
}
|
|
222
|
+
return [wide, est];
|
|
165
223
|
}
|
|
166
224
|
|
|
167
225
|
// ---- one job ----
|
|
@@ -184,24 +242,30 @@ function absorbGhFiles(ghFiles, env) {
|
|
|
184
242
|
}
|
|
185
243
|
|
|
186
244
|
async function runJob(job, ctx) {
|
|
187
|
-
const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0 };
|
|
245
|
+
const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0, wait_ms: 0 };
|
|
188
246
|
const jobTimeout = (job.timeout_s || ctx.jobTimeoutS) * 1000;
|
|
189
247
|
const log = (m) => { res.notes = (res.notes || []).concat(m); };
|
|
190
248
|
const runnableSteps = job.steps.filter((s) => !s.noop);
|
|
191
249
|
if (!runnableSteps.length) { res.status = 'pass'; res.reason = 'nothing to run'; res.duration_ms = Date.now() - t0; return res; }
|
|
192
|
-
const needExec = runnableSteps.some((s) => !s.skip);
|
|
193
250
|
let svc = null; let release = null;
|
|
194
|
-
|
|
195
|
-
|
|
251
|
+
// forge#4040 (REG-5): the job timeout clock starts when the job starts executing, never while it waits for a slot.
|
|
252
|
+
// forge#4057: the slot is taken lazily, right before the first step that really executes, so a job made only of skipped,
|
|
253
|
+
// cached or deduplicated steps never queues for one. Time spent here is wait_ms (not duration_ms) and shifts tExec.
|
|
254
|
+
let tExec = Date.now();
|
|
255
|
+
const takeSlot = async () => {
|
|
256
|
+
const tw = Date.now();
|
|
257
|
+
try {
|
|
196
258
|
release = await acquireSlot(job.id, ctx.deadline);
|
|
197
|
-
|
|
259
|
+
res.wait_ms += Date.now() - tw;
|
|
260
|
+
if (!release) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted waiting for a machine slot'; return false; }
|
|
198
261
|
if (job.services && job.services.length) {
|
|
199
262
|
svc = await startServices(job, log);
|
|
200
|
-
if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return
|
|
263
|
+
if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return false; }
|
|
201
264
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
265
|
+
return true;
|
|
266
|
+
} finally { tExec += Date.now() - tw; }
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
205
269
|
const ghFiles = { env: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.env`), path: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.path`), output: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.out`), summary: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.sum`) };
|
|
206
270
|
for (const f of Object.values(ghFiles)) fs.writeFileSync(f, '');
|
|
207
271
|
const carried = {}; let failedGroup = null;
|
|
@@ -212,17 +276,23 @@ async function runJob(job, ctx) {
|
|
|
212
276
|
if (step.skip) { sr.status = 'skipped'; sr.reason = step.skip; continue; }
|
|
213
277
|
if (failedGroup && step.group !== failedGroup) break;
|
|
214
278
|
if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted'; return res; }
|
|
215
|
-
|
|
279
|
+
let s0 = Date.now();
|
|
216
280
|
if (step.builtin === 'portability') {
|
|
217
281
|
const f = checkPortability({ repo: ctx.repo, from: ctx.plan.merge_base, changed: ctx.plan.changed_files, tracked: ctx.tracked });
|
|
218
282
|
sr.duration_ms = Date.now() - s0;
|
|
219
283
|
if (f.length) { sr.status = 'fail'; res.tail = f.slice(0, 40); res.status = 'fail'; res.failed_step = step.name; failedGroup = step.group || null; if (!failedGroup) break; }
|
|
220
284
|
continue;
|
|
221
285
|
}
|
|
222
|
-
let key = '';
|
|
286
|
+
let key = ''; let fkey = '';
|
|
223
287
|
const cacheable = step.kind !== 'install' && !ctx.noResultCache;
|
|
288
|
+
if (step.kind !== 'install') {
|
|
289
|
+
const base = stepKey(step, job, ctx);
|
|
290
|
+
// forge#4057: identical checks (command, cwd, shell, env, input hash, carried env) run once per invocation. A step that
|
|
291
|
+
// reads or writes the GitHub Actions side files has effects beyond its exit code, so it is never shared.
|
|
292
|
+
if (!(job.source === 'github-actions' && /GITHUB_(ENV|PATH|OUTPUT|STEP_SUMMARY)/.test(step.command))) fkey = sha256(base + JSON.stringify(carried));
|
|
293
|
+
if (cacheable) key = base;
|
|
294
|
+
}
|
|
224
295
|
if (cacheable) {
|
|
225
|
-
key = stepKey(step, job, ctx);
|
|
226
296
|
const f = path.join(cacheDir(ctx.o), key + '.json');
|
|
227
297
|
if (fs.existsSync(f)) { sr.cache = 'hit'; sr.duration_ms = 0; try { fs.utimesSync(f, new Date(), new Date()); } catch { /* ignore */ } continue; }
|
|
228
298
|
sr.cache = 'miss';
|
|
@@ -233,14 +303,35 @@ async function runJob(job, ctx) {
|
|
|
233
303
|
const env = jobEnv(job, step, ctx, ghFiles); Object.assign(env, carried);
|
|
234
304
|
const cwd = path.resolve(ctx.repo, step.cwd || '.');
|
|
235
305
|
if (!insideRepo(ctx.repo, cwd)) { res.status = 'deferred'; res.reason = 'step working-directory escapes the repository'; return res; }
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
306
|
+
let prior = fkey ? ctx.flight.get(fkey) : null;
|
|
307
|
+
if (!prior && !release) {
|
|
308
|
+
if (!(await takeSlot())) return res;
|
|
309
|
+
s0 = Date.now();
|
|
310
|
+
prior = fkey ? ctx.flight.get(fkey) : null; // another job may have started the same check while this one queued
|
|
311
|
+
}
|
|
312
|
+
let r; let text; let left;
|
|
313
|
+
if (prior) {
|
|
314
|
+
// single-flight: wait for the first run of this exact check and reuse its result (a failure is shared, never re-run)
|
|
315
|
+
({ r, text, left } = await prior);
|
|
316
|
+
sr.cache = 'dedup'; sr.duration_ms = 0;
|
|
317
|
+
} else {
|
|
318
|
+
const tail = new Tail();
|
|
319
|
+
left = Math.max(1000, Math.min((step.timeout_s || ctx.jobTimeoutS) * 1000, jobTimeout - (Date.now() - tExec), ctx.deadline - Date.now()));
|
|
320
|
+
const l0 = left;
|
|
321
|
+
const flight = (async () => { const r0 = await exec([...argv, scriptFile], { cwd, env, timeoutMs: l0, onData: (b) => tail.push(b) }); if (r0.error) tail.push(Buffer.from(`${r0.error}\n`)); return { r: r0, text: tail.text(60), left: l0 }; })();
|
|
322
|
+
if (fkey) ctx.flight.set(fkey, flight);
|
|
323
|
+
({ r, text } = await flight);
|
|
324
|
+
sr.duration_ms = Date.now() - s0;
|
|
325
|
+
if (job.source === 'github-actions') { const e2 = { ...env }; absorbGhFiles(ghFiles, e2); for (const k of Object.keys(e2)) if (e2[k] !== env[k]) carried[k] = e2[k]; }
|
|
326
|
+
if (r.code === 0) {
|
|
327
|
+
try { // duration_ms in the entry and a per-command duration hint feed the scheduler's cost ordering
|
|
328
|
+
fs.mkdirSync(path.join(cacheDir(ctx.o), 'dur'), { recursive: true });
|
|
329
|
+
if (key) fs.writeFileSync(path.join(cacheDir(ctx.o), key + '.json'), JSON.stringify({ cmd: step.command.slice(0, 300), ts: Date.now(), duration_ms: sr.duration_ms }));
|
|
330
|
+
fs.writeFileSync(path.join(cacheDir(ctx.o), 'dur', durKey(step)), String(sr.duration_ms));
|
|
331
|
+
} catch { /* cache is best effort */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (r.code === 0) continue;
|
|
244
335
|
if (r.timedOut) { if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted (job killed)'; return res; } sr.status = 'timeout'; res.status = 'degraded'; res.reason = `timed out after ${Math.round(left / 1000)}s (a timeout proves nothing about the code)`; res.tail = text; res.failed_step = step.name; return res; }
|
|
245
336
|
if (step.kind === 'install') { res.status = 'deferred'; res.reason = `dependency install failed (offline?): ${step.command.slice(0, 60)}`; return res; }
|
|
246
337
|
if (r.code === 127 && text.some((l) => /command not found|not found$|No such file or directory/.test(l)) && !step.continue_on_error) {
|
|
@@ -257,7 +348,7 @@ async function runJob(job, ctx) {
|
|
|
257
348
|
} finally {
|
|
258
349
|
if (svc && svc.finish) svc.finish();
|
|
259
350
|
if (release) release();
|
|
260
|
-
res.duration_ms = Date.now() - t0;
|
|
351
|
+
res.duration_ms = Math.max(0, Date.now() - t0 - res.wait_ms);
|
|
261
352
|
}
|
|
262
353
|
}
|
|
263
354
|
|
|
@@ -277,23 +368,34 @@ export async function runPlan(plan, o = {}) {
|
|
|
277
368
|
plan, repo, o, tmp: tmpRoot, deadline: t0 + budgetS * 1000, jobTimeoutS: Number(process.env.FORGE_CI_LOCAL_JOB_TIMEOUT) || Math.min(900, budgetS),
|
|
278
369
|
tracked: runSync('git', ['-C', repo, 'ls-files', '--cached', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean),
|
|
279
370
|
noResultCache: process.env.FORGE_CI_LOCAL_NO_CACHE === '1',
|
|
371
|
+
flight: new Map(),
|
|
280
372
|
};
|
|
281
373
|
const runnable = plan.jobs.filter((j) => !j.not_applicable && !j.deferred_reason);
|
|
282
374
|
const ids = new Set(runnable.map((j) => j.id));
|
|
283
375
|
const results = new Map(); const running = new Map();
|
|
284
376
|
const pending = new Set(runnable.map((j) => j.id));
|
|
285
377
|
const byId = new Map(runnable.map((j) => [j.id, j]));
|
|
378
|
+
const costs = new Map();
|
|
379
|
+
const cost = (id) => { if (!costs.has(id)) { let c = [1, 0]; try { c = jobCost(byId.get(id), ctx); } catch { /* ordering is a hint */ } costs.set(id, c); } return costs.get(id); };
|
|
380
|
+
const order = new Map([...pending].map((id, i) => [id, i]));
|
|
286
381
|
while (pending.size || running.size) {
|
|
382
|
+
const ready = [];
|
|
287
383
|
for (const id of [...pending]) {
|
|
288
384
|
const j = byId.get(id);
|
|
289
385
|
const needs = (j.needs || []).filter((n) => ids.has(n));
|
|
290
386
|
const blocked = needs.find((n) => results.has(n) && results.get(n).status !== 'pass');
|
|
291
|
-
if (blocked) { pending.delete(id); const b = results.get(blocked); const r = { id, name: j.name, source: j.source, status: b.status === 'fail' ? 'skipped' : 'deferred', reason: b.status === 'fail' ? `needs ${blocked}, which failed` : `needs ${blocked}, which is not proven locally`, steps: [], duration_ms: 0 }; results.set(id, r); printJob(r); continue; }
|
|
387
|
+
if (blocked) { pending.delete(id); const b = results.get(blocked); const r = { id, name: j.name, source: j.source, status: b.status === 'fail' ? 'skipped' : 'deferred', reason: b.status === 'fail' ? `needs ${blocked}, which failed` : `needs ${blocked}, which is not proven locally`, steps: [], duration_ms: 0, wait_ms: 0 }; results.set(id, r); printJob(r); continue; }
|
|
292
388
|
if (needs.some((n) => !results.has(n))) continue;
|
|
389
|
+
ready.push(id);
|
|
390
|
+
}
|
|
391
|
+
// narrow/scoped jobs first, then the cheapest; the widest or longest last (ties keep plan order). `needs` is honoured above.
|
|
392
|
+
ready.sort((x, y) => cost(x)[0] - cost(y)[0] || cost(x)[1] - cost(y)[1] || order.get(x) - order.get(y));
|
|
393
|
+
for (const id of ready) {
|
|
293
394
|
if (running.size >= maxParallel) break;
|
|
294
|
-
|
|
395
|
+
const j = byId.get(id);
|
|
396
|
+
if (Date.now() > ctx.deadline) { pending.delete(id); results.set(id, { id, name: j.name, source: j.source, status: 'deferred', budget: true, reason: 'budget exhausted before the job started', steps: [], duration_ms: 0, wait_ms: 0 }); continue; }
|
|
295
397
|
pending.delete(id);
|
|
296
|
-
const p = runJob(j, ctx).catch((e) => ({ id, name: j.name, source: j.source, status: 'deferred', reason: `runner error: ${e.message}`, steps: [], duration_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
|
|
398
|
+
const p = runJob(j, ctx).catch((e) => ({ id, name: j.name, source: j.source, status: 'deferred', reason: `runner error: ${e.message}`, steps: [], duration_ms: 0, wait_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
|
|
297
399
|
running.set(id, p);
|
|
298
400
|
}
|
|
299
401
|
if (running.size) await Promise.race(running.values()); else if (pending.size) await sleep(10);
|
|
@@ -315,7 +417,7 @@ export async function runPlan(plan, o = {}) {
|
|
|
315
417
|
if (s.scope.mode === 'scoped' || s.scope.mode === 'skip') narrowed++;
|
|
316
418
|
if (s.scope.widen_reason) widened++;
|
|
317
419
|
if (typeof s.scope.selected === 'number' && typeof s.scope.total === 'number') { selSel += s.scope.selected; selTotal += s.scope.total; }
|
|
318
|
-
if (checks++ < 40) console.log(`CI_LOCAL_CHECK: job=${r.id} step=${n + 1} selector=${s.scope.selector || 'none'} selected=${s.scope.selected ?? ''} total=${s.scope.total ?? ''} cache=${s.cache} duration_ms=${s.duration_ms} widen=${(s.scope.widen_reason || '').replace(/\s+/g, '_') || 'none'}`);
|
|
420
|
+
if (checks++ < 40) console.log(`CI_LOCAL_CHECK: job=${r.id} step=${n + 1} selector=${s.scope.selector || 'none'} selected=${s.scope.selected ?? ''} total=${s.scope.total ?? ''} cache=${s.cache} duration_ms=${s.duration_ms} widen=${(s.scope.widen_reason || '').replace(/\s+/g, '_') || 'none'} wait_ms=${r.wait_ms || 0}`);
|
|
319
421
|
}
|
|
320
422
|
const degraded = all.filter((r) => r.status === 'degraded');
|
|
321
423
|
const budgetHit = all.some((r) => r.budget) || (all.some((r) => r.status === 'deferred' && /budget exhausted/.test(r.reason || '')));
|
|
@@ -325,18 +427,22 @@ export async function runPlan(plan, o = {}) {
|
|
|
325
427
|
const degReason = degraded.length ? 'job_timeout' : budgetHit ? (capped ? 'budget_capped_at_invocation_limit' : 'budget_exhausted') : '';
|
|
326
428
|
const state = failed.length ? 'FAIL' : degReason ? 'DEGRADED' : 'PASS';
|
|
327
429
|
const dur = Date.now() - t0;
|
|
430
|
+
const waitTotal = all.reduce((a, r) => a + (r.wait_ms || 0), 0);
|
|
431
|
+
const dedups = all.reduce((a, r) => a + r.steps.filter((x) => x.cache === 'dedup').length, 0);
|
|
328
432
|
if (plan.no_applicable_jobs && !all.length) console.log('CI_LOCAL_NOTE: no_applicable_jobs (CI definitions exist but no job applies to this change)');
|
|
329
|
-
console.log(`CI_LOCAL_RESULT: ${state} jobs_run=${run_.length} jobs_deferred=${defd.length} jobs_failed=${failed.length} jobs_degraded=${degraded.length} jobs_skipped=${skipped.length} checks=${checks} cache_hits=${hits} narrowed=${narrowed} widened=${widened} selected=${selSel} total=${selTotal} duration_ms=${dur}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
|
|
330
|
-
if (o.resultFile) { try { fs.writeFileSync(o.resultFile, JSON.stringify({ state, mode: plan.config && plan.config.mode, jobs: all, plan_key: plan.plan_key, duration_ms: dur }, null, 2)); } catch { /* ignore */ } }
|
|
433
|
+
console.log(`CI_LOCAL_RESULT: ${state} jobs_run=${run_.length} jobs_deferred=${defd.length} jobs_failed=${failed.length} jobs_degraded=${degraded.length} jobs_skipped=${skipped.length} checks=${checks} cache_hits=${hits} narrowed=${narrowed} widened=${widened} selected=${selSel} total=${selTotal} duration_ms=${dur} wait_ms=${waitTotal} dedup=${dedups}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
|
|
434
|
+
if (o.resultFile) { try { fs.writeFileSync(o.resultFile, JSON.stringify({ state, mode: plan.config && plan.config.mode, jobs: all, plan_key: plan.plan_key, duration_ms: dur, wait_ms: waitTotal }, null, 2)); } catch { /* ignore */ } }
|
|
331
435
|
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
332
436
|
return failed.length ? 1 : state === 'DEGRADED' ? 2 : 0;
|
|
333
437
|
}
|
|
334
438
|
|
|
335
439
|
function printJob(r) {
|
|
336
440
|
const secs = (r.duration_ms / 1000).toFixed(1);
|
|
441
|
+
const waitS = ((r.wait_ms || 0) / 1000).toFixed(1);
|
|
442
|
+
const dedup = r.steps.filter((s) => s.cache === 'dedup').length;
|
|
337
443
|
const stepsRun = r.steps.filter((s) => s.status !== 'skipped').length;
|
|
338
444
|
const hit = r.steps.filter((s) => s.cache === 'hit').length;
|
|
339
|
-
console.log(`CI_LOCAL_JOB: ${r.status === 'pass' ? 'pass' : r.status} ${r.id} | ${r.name} | ${secs}s | steps=${stepsRun}/${r.steps.length}${hit ? ` cached=${hit}` : ''}${r.reason ? ` | ${r.reason}` : ''}`);
|
|
445
|
+
console.log(`CI_LOCAL_JOB: ${r.status === 'pass' ? 'pass' : r.status} ${r.id} | ${r.name} | ${secs}s | steps=${stepsRun}/${r.steps.length}${hit ? ` cached=${hit}` : ''}${dedup ? ` dedup=${dedup}` : ''} wait=${waitS}s${r.reason ? ` | ${r.reason}` : ''}`);
|
|
340
446
|
if (r.status === 'fail' || r.status === 'degraded') {
|
|
341
447
|
console.log(`${r.status === 'fail' ? 'CI_LOCAL_FAIL' : 'CI_LOCAL_DEGRADED_STEP'}: ${r.id} | step: ${r.failed_step || '?'}${r.exit !== undefined ? ` | exit ${r.exit}` : ''}`);
|
|
342
448
|
if (r.failed_command) console.log(`CI_LOCAL_FAIL_COMMAND: ${r.failed_command.split('\n')[0].slice(0, 300)}`);
|
|
@@ -242,8 +242,30 @@ function reachFile(sctx, f) {
|
|
|
242
242
|
return out;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// Every tracked, present test file in the repo: the universe the SEC-3 "reaches at least one test" check runs against.
|
|
246
|
+
function allTests(sctx) {
|
|
247
|
+
if (!sctx.cache.allTests) sctx.cache.allTests = sctx.tracked.filter((f) => isTestFile(f) && sctx.present.has(f));
|
|
248
|
+
return sctx.cache.allTests;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The SEC-3 universe is limited to the step's own ecosystem: a test in another language (tests/utils.test.ts) never
|
|
252
|
+
// counts as covering a changed source of this one (pkg/utils.py), or a pytest step would skip instead of widen.
|
|
253
|
+
const ECO = { js: 'js', mjs: 'js', cjs: 'js', jsx: 'js', ts: 'js', tsx: 'js', mts: 'js', cts: 'js', py: 'py', pyi: 'py', sh: 'sh', bash: 'sh', bats: 'sh', rb: 'rb', php: 'php', go: 'go', java: 'jvm', kt: 'jvm', scala: 'jvm', rs: 'rs', cs: 'cs' };
|
|
254
|
+
const ecoOf = (f) => { const e = extOf(f); return ECO[e] || e; };
|
|
255
|
+
// #4068 review DRR-1: the per-ecosystem universe applies only to a step whose ecosystem has a changed source file. A step
|
|
256
|
+
// in another ecosystem (a node step on a Python-only change) keeps the repo-wide universe it had at a0d45ae, so it skips or
|
|
257
|
+
// narrows instead of widening to a full run on every single-language change.
|
|
258
|
+
function ecosystemTests(sctx, tests) {
|
|
259
|
+
const ecos = new Set(tests.map(ecoOf));
|
|
260
|
+
const touched = sctx.changed.some((f) => !isTestFile(f) && ecos.has(ecoOf(f)) && (CODE_EXT.has(extOf(f)) || /^(sh|bash)$/.test(extOf(f))));
|
|
261
|
+
if (!touched) return allTests(sctx);
|
|
262
|
+
return allTests(sctx).filter((t) => ecos.has(ecoOf(t)));
|
|
263
|
+
}
|
|
264
|
+
|
|
245
265
|
// Which of testFiles can this change reach? { selected, total, widen } ; widen is a reason string or ''.
|
|
246
|
-
|
|
266
|
+
// `universe` is the repo-wide test set used only for the SEC-3 reach check: a step that runs ONE test must not widen just
|
|
267
|
+
// because the change does not reach that test, only because it reaches no test anywhere. Selection stays within testFiles.
|
|
268
|
+
export function impactedTests(sctx, testFiles, universe = testFiles) {
|
|
247
269
|
const r = reach(sctx);
|
|
248
270
|
if (r.widen) return { selected: [...testFiles], total: testFiles.length, widen: r.widen };
|
|
249
271
|
const selected = new Set();
|
|
@@ -254,10 +276,11 @@ export function impactedTests(sctx, testFiles) {
|
|
|
254
276
|
// never hide it. The reason names the file.
|
|
255
277
|
const srcFiles = sctx.changed.filter((f) => !isTestFile(f) && (CODE_EXT.has(extOf(f)) || /^(sh|bash)$/.test(extOf(f))));
|
|
256
278
|
const unreached = [];
|
|
279
|
+
if (universe !== testFiles) universe = [...new Set([...universe, ...testFiles])];
|
|
257
280
|
for (const f of srcFiles) {
|
|
258
281
|
const rf = reachFile(sctx, f);
|
|
259
282
|
if (rf.widen) return { selected: [...testFiles], total: testFiles.length, widen: rf.widen };
|
|
260
|
-
const hit =
|
|
283
|
+
const hit = universe.some((t) => rf.files.has(t) && sctx.present.has(t)) || siblingTests(f, universe).length > 0;
|
|
261
284
|
if (!hit) unreached.push(f);
|
|
262
285
|
}
|
|
263
286
|
if (unreached.length) return { selected: [...testFiles], total: testFiles.length, widen: `empty-selection: no test reaches ${unreached.slice(0, 5).join(', ')}${unreached.length > 5 ? ` (+${unreached.length - 5} more)` : ''}` };
|
|
@@ -398,7 +421,7 @@ function scopeRunner(name, args, head, cmd, cwdRel, sctx, join) {
|
|
|
398
421
|
if (sctx.widen) return full(cmd, sctx.widen, { selector: spec.sel, selected: candidates.length, total: candidates.length });
|
|
399
422
|
const un = unmappedFile(sctx.changed.filter((f) => !isTestFile(f)), new Set([...spec.ext, ...SRC_EXT]));
|
|
400
423
|
if (un) return full(cmd, `unknown-language: ${un}`, { selector: spec.sel, selected: candidates.length, total: candidates.length });
|
|
401
|
-
const imp = impactedTests(sctx, candidates);
|
|
424
|
+
const imp = impactedTests(sctx, candidates, ecosystemTests(sctx, universe));
|
|
402
425
|
if (imp.widen) return full(cmd, imp.widen, { selector: spec.sel, selected: candidates.length, total: candidates.length });
|
|
403
426
|
if (!imp.selected.length) return skipNo(0, candidates.length, spec.sel, 'no test reaches the changed files');
|
|
404
427
|
const sel = imp.selected.map((f) => fromCwd(cwdRel, f));
|
|
@@ -414,7 +437,7 @@ function scopeSingleTest(rest, cmd, cwdRel, sctx) {
|
|
|
414
437
|
if (!isTestFile(r) || !sctx.present.has(r)) return null;
|
|
415
438
|
if (rest.slice(rest.indexOf(fileTok) + 1).length) return full(cmd, '', { selector: 'unscoped:test-args' });
|
|
416
439
|
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'single-test', selected: 1, total: 1 });
|
|
417
|
-
const imp = impactedTests(sctx, [r]);
|
|
440
|
+
const imp = impactedTests(sctx, [r], ecosystemTests(sctx, [r]));
|
|
418
441
|
if (imp.widen) return full(cmd, imp.widen, { selector: 'single-test', selected: 1, total: 1 });
|
|
419
442
|
if (!imp.selected.length) return skipNo(0, 1, 'single-test', `${r} not reached by the change`);
|
|
420
443
|
return scoped(cmd, 1, 1, 'single-test');
|
package/scripts/ci-local.sh
CHANGED
|
@@ -76,7 +76,7 @@ field() { printf '%s\n' "$RES" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n 1; }
|
|
|
76
76
|
STATE=$(printf '%s\n' "$RES" | awk '{print $2}')
|
|
77
77
|
JR=$(field jobs_run); JD=$(field jobs_deferred); JF=$(field jobs_failed); DUR=$(field duration_ms)
|
|
78
78
|
forge_emit ci_local result="${STATE:-DEGRADED}" jobs_run="${JR:-0}" jobs_deferred="${JD:-0}" jobs_failed="${JF:-0}" \
|
|
79
|
-
duration_ms="${DUR:-0}" fix_iterations="$ITER" checks="$(field checks)" cache_hits="$(field cache_hits)" \
|
|
79
|
+
duration_ms="${DUR:-0}" wait_ms="$(field wait_ms)" fix_iterations="$ITER" checks="$(field checks)" cache_hits="$(field cache_hits)" \
|
|
80
80
|
narrowed="$(field narrowed)" widened="$(field widened)" selected="$(field selected)" total="$(field total)" >/dev/null 2>&1 || true
|
|
81
81
|
|
|
82
82
|
# One event per scoped/widened/executed check, capped so a large plan never floods the telemetry comment.
|
|
@@ -90,7 +90,8 @@ while IFS= read -r line; do
|
|
|
90
90
|
ch=$(printf '%s\n' "$line" | sed -n 's/.* cache=\([^ ]*\).*/\1/p')
|
|
91
91
|
dm=$(printf '%s\n' "$line" | sed -n 's/.* duration_ms=\([^ ]*\).*/\1/p')
|
|
92
92
|
wd=$(printf '%s\n' "$line" | sed -n 's/.* widen=\([^ ]*\).*/\1/p')
|
|
93
|
-
|
|
93
|
+
wm=$(printf '%s\n' "$line" | sed -n 's/.* wait_ms=\([^ ]*\).*/\1/p')
|
|
94
|
+
forge_emit ci_local_check job="$job" selector="$sel" selected="${sc:-0}" total="${tt:-0}" cache="$ch" duration_ms="${dm:-0}" wait_ms="${wm:-0}" widen="$wd" >/dev/null 2>&1 || true
|
|
94
95
|
EMITTED=$((EMITTED + 1))
|
|
95
96
|
done < <(grep '^CI_LOCAL_CHECK:' "$OUT")
|
|
96
97
|
|