forgedock 1.29.1 → 1.31.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/bin/engine/outcome-codes.mjs +5 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +356 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +799 -127
- package/bin/lean/lean.test.mjs +1749 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +77 -10
- package/bin/lean/verify.test.mjs +74 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/conformance.mjs
CHANGED
|
@@ -18,8 +18,8 @@ export const DECISION_SOURCES = Object.freeze(["doc", "lint", "precedent", "spec
|
|
|
18
18
|
const REQ_STATUS = ["implemented", "partial", "missing", "deferred", "post-deploy", "not-applicable"];
|
|
19
19
|
const COVER_STATUS = ["covered", "narrowed", "missing"];
|
|
20
20
|
const PRESERVE_STATUS = ["intact", "violated", "unverified"];
|
|
21
|
-
/**
|
|
22
|
-
const EVIDENCE_RE = /([
|
|
21
|
+
/** A path:line inside one evidence token; the path may hold ( ) [ ] segments (route groups, [slug], [...slug]) (#4327). */
|
|
22
|
+
const EVIDENCE_RE = /([^:]+):(\d+)/;
|
|
23
23
|
/** Test files in any common ecosystem: test dirs, *.test.* / *.spec.*, test_*.*, *_test.*, *Test(s).java|kt|cs|php|scala, *_spec.rb, tests.rs, conftest.py, snapshots. */
|
|
24
24
|
const TEST_RE = /(^|\/)(test|tests|__tests__|spec|specs|testdata|__snapshots__)\/|\.(test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.[a-z0-9]+$|_test\.[a-z0-9]+$|Tests?\.(java|kt|kts|cs|php|scala|swift)$|_spec\.rb$|(^|\/)tests\.rs$|(^|\/)conftest\.py$|\.bats$|\.snap$/;
|
|
25
25
|
/** Files any change may touch without being a deliverable: changelogs and dependency lockfiles. */
|
|
@@ -112,6 +112,110 @@ export function movingBaseDiff(command) {
|
|
|
112
112
|
return null;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* #4350: true when a command lists the change's file names: a `git diff|log|show` segment with --name-only, --name-status,
|
|
117
|
+
* --stat or --numstat, or `git status --porcelain|--short`. Such a validate is a hand-written scope check; the engine's
|
|
118
|
+
* scope gate (deliverables plus typed amendments) is the authority over it.
|
|
119
|
+
*/
|
|
120
|
+
export function isScopeCheck(command) {
|
|
121
|
+
return String(command ?? "").split(/&&|\|\||;|\||\$\(|\)|`/).some((seg) =>
|
|
122
|
+
/\bgit\b(?:\s+-C\s+\S+)?(?:\s+-c\s+\S+)*\s+(?:diff|log|show)\b[^]*?\s--(?:name-only|name-status|stat|numstat)\b/.test(seg)
|
|
123
|
+
|| /\bgit\b(?:\s+-C\s+\S+)?(?:\s+-c\s+\S+)*\s+status\b[^]*?\s(?:--porcelain|--short|-s)\b/.test(seg));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const SCOPE_UTILS = new Set(["grep", "egrep", "fgrep", "sort", "uniq", "wc", "test", "[", "[[", "]", "]]", "echo", "printf", "awk", "sed", "head", "tail", "tr", "cut", "comm", "true", "false", "exit"]);
|
|
127
|
+
const SCOPE_GIT = new Set(["diff", "log", "show", "status", "merge-base", "rev-parse"]);
|
|
128
|
+
/**
|
|
129
|
+
* #4353: true when a command is only a changed-files allowlist or denylist: isScopeCheck holds and every segment's command
|
|
130
|
+
* is git diff|log|show|status|merge-base|rev-parse or a filter (grep, sort, wc, test, awk, sed…). A command that also runs
|
|
131
|
+
* anything else (`node --test x && git diff --stat`) is not pure, so it stays a real validate.
|
|
132
|
+
*/
|
|
133
|
+
export function isPureScopeCheck(command) {
|
|
134
|
+
if (!isScopeCheck(command)) return false;
|
|
135
|
+
// text after a `)` belongs to the outer command (its arguments), so `)` is dropped instead of starting a segment
|
|
136
|
+
return String(command ?? "").replace(/\)/g, " ").split(/&&|\|\||;|\||\$\(|`|\n/).every((seg) => {
|
|
137
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']+|["']+$/g, ""));
|
|
138
|
+
let i = 0;
|
|
139
|
+
while (i < toks.length && (toks[i] === "!" || toks[i] === "" || /^[A-Za-z_]\w*=/.test(toks[i]))) i++;
|
|
140
|
+
if (i >= toks.length) return true;
|
|
141
|
+
const cmd = toks[i];
|
|
142
|
+
if (cmd !== "git") return SCOPE_UTILS.has(cmd);
|
|
143
|
+
for (i++; i < toks.length && (toks[i] === "-C" || toks[i] === "-c"); i += 2);
|
|
144
|
+
return SCOPE_GIT.has(toks[i]);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* #4353: the contract without its pure scope-check validates (isPureScopeCheck): the engine's scope gate (deliverables plus
|
|
150
|
+
* typed amendments) is the only scope authority, so they are dropped at freeze, not rejected. Returns {contract, dropped:
|
|
151
|
+
* [{id, command}]}; a contract that is not an object or has no validate array comes back unchanged.
|
|
152
|
+
*/
|
|
153
|
+
export function dropScopeValidates(contract) {
|
|
154
|
+
if (!contract || typeof contract !== "object" || Array.isArray(contract) || !Array.isArray(contract.validate)) return { contract, dropped: [] };
|
|
155
|
+
const dropped = contract.validate.filter((v) => isPureScopeCheck(v?.command)).map((v) => ({ id: v.id, command: v.command }));
|
|
156
|
+
if (!dropped.length) return { contract, dropped };
|
|
157
|
+
return { contract: { ...contract, validate: contract.validate.filter((v) => !isPureScopeCheck(v?.command)) }, dropped };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** #4404: how a deliver requirement is verified: in the lane (default) or by a job outside it (`evidence` names the job). */
|
|
161
|
+
export const VERIFY_MODES = Object.freeze(["in_lane", "out_of_band"]);
|
|
162
|
+
/**
|
|
163
|
+
* #4404: a requirement quote whose result is only measured outside the lane: Lighthouse, web vitals, field perf or a field
|
|
164
|
+
* or throttled device profile, a nightly or report-only suite, and the perf metric acronyms (matched case-sensitively below).
|
|
165
|
+
*/
|
|
166
|
+
export const OUT_OF_BAND_RE = /\blighthouse\b|\bweb[- ]vitals\b|\bfield[- ](?:perf\w*|profiles?|data|metrics?)\b|\bthrottled\b|\b(?:nightly|report[- ]only)\b[^.;\n]{0,40}?\b(?:suites?|jobs?|runs?|bench\w*|workflows?)\b/i;
|
|
167
|
+
const PERF_METRIC_RE = /\b(?:LCP|INP|TTI|FCP|TTFB)\b/;
|
|
168
|
+
export const isOutOfBand = (r) => r?.verify === "out_of_band";
|
|
169
|
+
const tagsOutOfBand = (r) => r?.disposition === "deliver" && r?.verify == null && (OUT_OF_BAND_RE.test(String(r?.quote ?? "")) || PERF_METRIC_RE.test(String(r?.quote ?? "")));
|
|
170
|
+
/**
|
|
171
|
+
* #4404: at freeze, every deliver requirement without a `verify` whose quote names an out-of-lane measurement becomes
|
|
172
|
+
* verify "out_of_band"; an explicit verify (in_lane too) is never overwritten. Returns {contract, tagged: [ids]}.
|
|
173
|
+
*/
|
|
174
|
+
export function tagOutOfBand(contract) {
|
|
175
|
+
if (!contract || typeof contract !== "object" || Array.isArray(contract) || !Array.isArray(contract.requirements)) return { contract, tagged: [] };
|
|
176
|
+
const tagged = contract.requirements.filter(tagsOutOfBand).map((r) => r.id);
|
|
177
|
+
if (!tagged.length) return { contract, tagged };
|
|
178
|
+
return { contract: { ...contract, requirements: contract.requirements.map((r) => (tagsOutOfBand(r) ? { ...r, verify: "out_of_band" } : r)) }, tagged };
|
|
179
|
+
}
|
|
180
|
+
/** #4404: the out-of-band requirements as [{id, quote, job}] (job = the requirement's evidence), for the PR body. */
|
|
181
|
+
export const outOfBandRequirements = (contract) => (contract?.requirements ?? []).filter(isOutOfBand).map((r) => ({ id: r.id, quote: r.quote, job: String(r.evidence ?? "").trim() }));
|
|
182
|
+
|
|
183
|
+
/** Option names whose value a tool writes (output, report, cache or temp dirs), not reads (#4325). */
|
|
184
|
+
const WRITE_OPT = /^-(?:o|O|-?[\w-]*(?:out|dir|temp|tmp|cache|report|junit|xml|log)[\w-]*)$/i;
|
|
185
|
+
const TMP_PATH = /\$(?:\{TMPDIR(?::-[^}]*)?\}|TMPDIR)\/([^\s"'`;|&<>()=]+)/g;
|
|
186
|
+
/**
|
|
187
|
+
* #4325: the `$TMPDIR/<name>` paths a frozen command reads without creating them itself. The run TMPDIR is per run, so such
|
|
188
|
+
* a file (written by the builder before) is gone for the engine's red-green check and for any rerun. A path counts as
|
|
189
|
+
* created when it, or a directory above it, was earlier the target of `>`/`>>`, `tee`, `touch|mkdir|mktemp`, the last
|
|
190
|
+
* argument of `cp|mv|ln|install`, an output-like option (`-o`, `--output`, `--basetemp`, `--junitxml`, `--cache-dir`…) or an
|
|
191
|
+
* env assignment (`X=$TMPDIR/y`). Returns the read paths in first-use order (deduped), e.g. ["$TMPDIR/req.txt"].
|
|
192
|
+
*/
|
|
193
|
+
export function tmpdirInputs(command) {
|
|
194
|
+
const made = [], reads = [];
|
|
195
|
+
const createdAbove = (p) => made.some((m) => p === m || p.startsWith(`${m}/`));
|
|
196
|
+
for (const seg of String(command ?? "").split(/&&|\|\||;|\||\n/)) {
|
|
197
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']+|["']+$/g, ""));
|
|
198
|
+
const words = toks.filter((t) => !/^[A-Za-z_]\w*=/.test(t));
|
|
199
|
+
const cmd = (words[0] ?? "").replace(/^.*\//, "");
|
|
200
|
+
toks.forEach((tok, i) => {
|
|
201
|
+
for (const m of tok.matchAll(TMP_PATH)) {
|
|
202
|
+
const path = m[1].replace(/\/+$/, "");
|
|
203
|
+
const before = tok.slice(0, m.index), prev = toks[i - 1] ?? "";
|
|
204
|
+
const opt = /^(-[\w-]+)=["']?$/.exec(before)?.[1] ?? (before === "" && /^-/.test(prev) ? prev : null);
|
|
205
|
+
const write = /\d*>>?$|&>$/.test(before) || (before === "" && /^(?:\d*>>?|&>)$/.test(prev))
|
|
206
|
+
|| /^[A-Za-z_]\w*=["']?$/.test(before) || (opt !== null && WRITE_OPT.test(opt))
|
|
207
|
+
|| cmd === "tee" || ["touch", "mkdir", "mktemp"].includes(cmd)
|
|
208
|
+
|| (["cp", "mv", "ln", "install"].includes(cmd) && i === toks.length - 1);
|
|
209
|
+
if (write) made.push(path);
|
|
210
|
+
else if (!createdAbove(path) && !reads.includes(`$TMPDIR/${path}`)) reads.push(`$TMPDIR/${path}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return reads;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
|
|
218
|
+
|
|
115
219
|
export function validateContract(c, issueText, { repro = false } = {}) {
|
|
116
220
|
const problems = [];
|
|
117
221
|
if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
|
|
@@ -126,6 +230,9 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
126
230
|
if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
|
|
127
231
|
if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
|
|
128
232
|
if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
|
|
233
|
+
if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
|
|
234
|
+
if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
|
|
235
|
+
else if (isOutOfBand(r) && blank(r?.evidence)) problems.push(`${id}: verify out_of_band needs evidence naming the job or workflow that measures it (or set verify "in_lane" if this change can check it)`);
|
|
129
236
|
}
|
|
130
237
|
const dels = Array.isArray(c.deliverables) ? c.deliverables : [];
|
|
131
238
|
if (reqs.some((r) => r?.disposition === "deliver") && !dels.length) problems.push("deliver requirements but no deliverables");
|
|
@@ -142,11 +249,13 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
142
249
|
if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
|
|
143
250
|
const ref = movingBaseDiff(v.command);
|
|
144
251
|
if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
|
|
252
|
+
for (const p of tmpdirInputs(v.command)) problems.push(tmpInputProblem(v.id, p));
|
|
145
253
|
}
|
|
146
254
|
if (repro) {
|
|
147
255
|
const t = c.repro_test;
|
|
148
256
|
if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
|
|
149
257
|
else if (!t || typeof t !== "object" || Array.isArray(t) || blank(t.path) || blank(t.command)) problems.push(`this is a bug: repro_test must be {"path","id","command"} (a test that fails on the base and passes after the fix) or "none" with a repro_test_reason`);
|
|
258
|
+
else for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
|
|
150
259
|
}
|
|
151
260
|
if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
|
|
152
261
|
return { ok: problems.length === 0, problems };
|
|
@@ -175,6 +284,7 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
175
284
|
const r = { disposition: "deliver", ...a.requirement, amended: true };
|
|
176
285
|
if (!DISPOSITIONS.includes(r.disposition)) { problems.push(`add-requirement ${r.id}: bad disposition ${r.disposition}`); continue; }
|
|
177
286
|
if (TRACKED.includes(r.disposition) && blank(r.why)) r.why = a.why;
|
|
287
|
+
if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
|
|
178
288
|
c.requirements.push(r);
|
|
179
289
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
180
290
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
@@ -187,16 +297,58 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
187
297
|
return { contract: c, applied, toTrack, problems };
|
|
188
298
|
}
|
|
189
299
|
|
|
300
|
+
const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
|
|
301
|
+
const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
|
|
302
|
+
const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
|
|
303
|
+
const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
|
|
304
|
+
/**
|
|
305
|
+
* forge#4323: the host tool or module a failed command could not find, from its {rc, output}: bash/sh "X: command not found"
|
|
306
|
+
* (or rc 127), python "No module named x" (top-level package), node "Cannot find module|package 'x'" (bare specifiers only).
|
|
307
|
+
* Returns {tool, kind: command|python-module|node-module} or null. A path, a relative specifier or a name the repo tracks
|
|
308
|
+
* (`repoHas(name)`) is null: a module the change forgot to create is a code defect, not the environment.
|
|
309
|
+
*/
|
|
310
|
+
export function missingTool({ rc, output } = {}, { repoHas = () => false, command = "" } = {}) {
|
|
311
|
+
const out = String(output ?? "");
|
|
312
|
+
const local = (name) => !name || name.startsWith(".") || name.startsWith("/") || name.startsWith("file:") || repoHas(name);
|
|
313
|
+
const py = PY_MISSING_RE.exec(out);
|
|
314
|
+
if (py) { const tool = String(py[1] ?? py[2]).split(".")[0]; return local(tool) ? null : { tool, kind: "python-module" }; }
|
|
315
|
+
const js = NODE_MISSING_RE.exec(out);
|
|
316
|
+
if (js) {
|
|
317
|
+
const spec = js[1], scoped = spec.startsWith("@");
|
|
318
|
+
if (!scoped && spec.includes("/")) return null;
|
|
319
|
+
const tool = scoped ? spec.split("/").slice(0, 2).join("/") : spec;
|
|
320
|
+
return local(tool) || (scoped && !tool.includes("/")) ? null : { tool, kind: "node-module" };
|
|
321
|
+
}
|
|
322
|
+
const cmd = CMD_NOT_FOUND_RE.exec(out)?.[1] ?? (rc === 127 ? SH_NOT_FOUND_RE.exec(out)?.[1] ?? String(command).trim().split(/\s+/)[0] : null);
|
|
323
|
+
if (!cmd || cmd.includes("/") || local(cmd)) return null;
|
|
324
|
+
return { tool: cmd, kind: "command" };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** `repoHas` for missingTool: true when any tracked path has a segment equal to `name` or `name.py`. */
|
|
328
|
+
export function repoTracks(paths) {
|
|
329
|
+
const segs = new Set();
|
|
330
|
+
for (const p of paths ?? []) for (const x of String(p).split("/")) segs.add(x);
|
|
331
|
+
return (name) => segs.has(name) || segs.has(`${name}.py`);
|
|
332
|
+
}
|
|
333
|
+
|
|
190
334
|
/**
|
|
191
335
|
* Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
|
|
192
|
-
* requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[]}; each failure carries a
|
|
193
|
-
* `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green) so callers can
|
|
336
|
+
* requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[], superseded[]}; each failure carries a
|
|
337
|
+
* `kind` (scope_creep, validate_missing, validate_failed, validate_env, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green, repro_env) so callers can
|
|
194
338
|
* tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
|
|
195
339
|
* engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails. `repro` (#4271): the repro_test red-green words, checked when given.
|
|
340
|
+
* `validateRuns` (#4323): the engine's rerun {[id]: {rc, output}} of validate commands recorded as failed. A rerun that passes
|
|
341
|
+
* counts as passed; one that could not find a host tool or module (missingTool, with `repoHas`) is kind validate_env, code
|
|
342
|
+
* env.tool_missing (skipped on a CI-verified head), not a conformance failure.
|
|
343
|
+
* `frozen` (#4350): the contract before typed amendments. A failed scope-check validate (isScopeCheck) whose only files
|
|
344
|
+
* outside `frozen` are covered by deliverables an amendment added, with no scope creep left, is superseded by the engine's
|
|
345
|
+
* scope gate: listed in `superseded` [{id, files}], not a failure. Without `frozen`, `superseded` is always [].
|
|
196
346
|
*/
|
|
197
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null }) {
|
|
198
|
-
const failures = [];
|
|
199
|
-
const
|
|
347
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
|
|
348
|
+
const failures = [], superseded = [];
|
|
349
|
+
const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
|
|
350
|
+
const creep = outside(contract);
|
|
351
|
+
const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
|
|
200
352
|
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
201
353
|
const runs = Array.isArray(result?.validation) ? result.validation : [];
|
|
202
354
|
for (const v of contract.validate ?? []) {
|
|
@@ -205,32 +357,64 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
205
357
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
206
358
|
if (ciVerified && leftToCi) continue;
|
|
207
359
|
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
208
|
-
else if (r.exit !== 0)
|
|
360
|
+
else if (r.exit !== 0) {
|
|
361
|
+
const rerun = validateRuns?.[v.id];
|
|
362
|
+
if (rerun?.rc === 0) continue;
|
|
363
|
+
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
364
|
+
if (miss && ciVerified) continue;
|
|
365
|
+
if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
|
|
366
|
+
else if (amendedOnly && isScopeCheck(v.command)) superseded.push({ id: v.id, files: amendedOnly });
|
|
367
|
+
else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
368
|
+
}
|
|
209
369
|
}
|
|
210
370
|
const st = Array.isArray(result?.requirements) ? result.requirements : [];
|
|
211
371
|
for (const r of contract.requirements ?? []) {
|
|
212
372
|
const s = st.find((x) => x?.id === r.id)?.status;
|
|
213
373
|
if (TRACKED.includes(r.disposition)) continue; // typed deferrals are judged by the reviewer, then filed before the PR
|
|
214
|
-
if (r.disposition === "deliver") {
|
|
374
|
+
if (r.disposition === "deliver" && isOutOfBand(r)) {
|
|
375
|
+
// #4404: measured outside the lane; the builder delivers its instrumentation (budget test or bench committed, job configured)
|
|
376
|
+
const row = st.find((x) => x?.id === r.id);
|
|
377
|
+
if (row?.instrumented !== true && s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} is verified out of band${r.evidence ? ` by ${r.evidence}` : ""} but reported "${s ?? "no status"}" without instrumented: true` });
|
|
378
|
+
} else if (r.disposition === "deliver") {
|
|
215
379
|
if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, kind: "untracked_deferral", detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
|
|
216
380
|
else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} reported "${s ?? "no status"}"` });
|
|
217
381
|
}
|
|
218
382
|
}
|
|
219
383
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
220
384
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
221
|
-
return { ok: failures.length === 0, failures };
|
|
385
|
+
return { ok: failures.length === 0, failures, superseded };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* #4350: the changed files outside the frozen contract when every one of them is covered by a deliverable an amendment
|
|
390
|
+
* added (a path the frozen contract does not list) and the amended contract leaves no scope creep; otherwise null.
|
|
391
|
+
*/
|
|
392
|
+
function frozenAmendedFiles(frozen, contract, outside, creep) {
|
|
393
|
+
if (!frozen || creep.length) return null;
|
|
394
|
+
const before = outside(frozen);
|
|
395
|
+
if (!before.length) return null;
|
|
396
|
+
const had = new Set((frozen.deliverables ?? []).map((d) => cleanPath(d.path)));
|
|
397
|
+
const added = (contract.deliverables ?? []).filter((d) => !had.has(cleanPath(d.path)));
|
|
398
|
+
return before.every((f) => added.some((d) => pathCovers(d.path, f))) ? before : null;
|
|
222
399
|
}
|
|
223
400
|
|
|
224
401
|
/**
|
|
225
402
|
* #4271: the frozen repro_test's red-green proof ({base, head} words from scripts/acceptance-red-green.sh). Anything but
|
|
226
403
|
* HEAD=RAN BASE=FAILED fails; code conformance.unimplemented, so it blocks in shadow too and goes back to the builder.
|
|
404
|
+
* #4334: HEAD=UNRUNNABLE or no words (the harness could not run the test) is env.repro_unrunnable, kind repro_env: an
|
|
405
|
+
* environment error, never a builder resume.
|
|
227
406
|
*/
|
|
228
407
|
export function reproFailures(contract, { base, head } = {}) {
|
|
229
408
|
if (head === "RAN" && base === "FAILED") return [];
|
|
230
409
|
const t = contract?.repro_test ?? {};
|
|
231
410
|
const name = [t.path, t.id].filter((x) => !blank(x)).join(" ") || "repro_test";
|
|
411
|
+
if (!head || head === "UNRUNNABLE") {
|
|
412
|
+
return [{ code: C.ENV_REPRO_UNRUNNABLE, kind: "repro_env",
|
|
413
|
+
detail: `repro test ${name} (\`${t.command ?? ""}\`): the harness could not run the test on head (environment, not the change); got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
|
|
414
|
+
}
|
|
415
|
+
const skipped = base === "SKIPPED" ? " (base not run: head did not pass)" : "";
|
|
232
416
|
return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
|
|
233
|
-
detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head
|
|
417
|
+
detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head}${skipped}` }];
|
|
234
418
|
}
|
|
235
419
|
|
|
236
420
|
const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
|
|
@@ -288,10 +472,42 @@ export function resolveEvidencePath(p, changedFiles = []) {
|
|
|
288
472
|
const n = normalizeEvidencePath(p, { changedFiles });
|
|
289
473
|
return n && changedFiles.includes(n) ? n : null;
|
|
290
474
|
}
|
|
475
|
+
const count = (s, ch) => s.split(ch).length - 1;
|
|
476
|
+
/** Drops a leading or trailing bracket that has no partner inside the path: `(src/a.js` -> `src/a.js`, `(g)/a.tsx` stays. */
|
|
477
|
+
function trimUnbalanced(p) {
|
|
478
|
+
for (let again = true; again && p;) {
|
|
479
|
+
again = false;
|
|
480
|
+
for (const [o, c] of [["(", ")"], ["[", "]"]]) {
|
|
481
|
+
if (p.startsWith(o) && count(p, o) > count(p, c)) { p = p.slice(1); again = true; }
|
|
482
|
+
if (p.endsWith(c) && count(p, c) > count(p, o)) { p = p.slice(0, -1); again = true; }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return p;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The first path:line anywhere in the evidence text, verbatim (#4327: `(group)`, `[slug]`, `[...slug]` segments kept).
|
|
489
|
+
* Backticks and quotes are ignored, a markdown link `[label](path:line)` yields its target, and only brackets with no
|
|
490
|
+
* partner inside the path (prose like `(src/a.js:3)`) are dropped.
|
|
491
|
+
*/
|
|
492
|
+
export function evidencePathLine(text) {
|
|
493
|
+
for (let tok of String(text ?? "").replace(/[`'"]/g, "").split(/[\s,;<>]+/)) {
|
|
494
|
+
const link = tok.lastIndexOf("](");
|
|
495
|
+
if (link >= 0) tok = tok.slice(link + 2);
|
|
496
|
+
const m = EVIDENCE_RE.exec(tok);
|
|
497
|
+
const path = m && trimUnbalanced(m[1]);
|
|
498
|
+
if (path) return { path, line: m[2] };
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
291
502
|
/** The path:line in an evidence string, with the path normalized; null when there is none. */
|
|
292
503
|
function evidenceOf(text, ctx) {
|
|
293
|
-
const m =
|
|
294
|
-
return m ? { path: normalizeEvidencePath(m
|
|
504
|
+
const m = evidencePathLine(text);
|
|
505
|
+
return m ? { path: normalizeEvidencePath(m.path, ctx), line: m.line } : null;
|
|
506
|
+
}
|
|
507
|
+
const MISSING_PATH_RE = /^[^:]+: evidence path .+ does not exist$/;
|
|
508
|
+
/** #4327: true when every problem of an incomplete review is an evidence path that did not resolve (a re-ask can fix it). */
|
|
509
|
+
export function evidencePathOnly(problems = []) {
|
|
510
|
+
return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)));
|
|
295
511
|
}
|
|
296
512
|
const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
|
|
297
513
|
|
|
@@ -311,7 +527,7 @@ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = []
|
|
|
311
527
|
out.preserve = pres;
|
|
312
528
|
if (Array.isArray(rv.requirements)) {
|
|
313
529
|
out.requirements = rv.requirements.map((r) => {
|
|
314
|
-
if (r?.status !== "implemented") return r;
|
|
530
|
+
if (r?.status !== "implemented" && r?.instrumented !== true) return r;
|
|
315
531
|
const e = evidenceOf(r.evidence, { changedFiles, repoFiles });
|
|
316
532
|
return e ? { ...r, evidence: `${e.path}:${e.line}` } : r;
|
|
317
533
|
});
|
|
@@ -339,7 +555,8 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
|
|
|
339
555
|
const row = rows[0];
|
|
340
556
|
if (!REQ_STATUS.includes(row.status)) { miss(r.id, `${r.id}: status invalid: ${row.status}`); continue; }
|
|
341
557
|
if (TRACKED.includes(r.disposition) && typeof row.deferral_ok !== "boolean") miss(r.id, `${r.id}: a ${r.disposition} requirement needs deferral_ok true|false (is deferring it justified?)`);
|
|
342
|
-
if (row.
|
|
558
|
+
if (isOutOfBand(r) && typeof row.instrumented !== "boolean") miss(r.id, `${r.id}: a requirement verified out of band needs instrumented true|false (is its budget test or bench committed and its job configured?)`);
|
|
559
|
+
if (row.status === "implemented" || (isOutOfBand(r) && row.instrumented === true)) {
|
|
343
560
|
const e = evidenceOf(row.evidence, { changedFiles, repoFiles });
|
|
344
561
|
if (!e) miss(r.id, `${r.id}: implemented needs path:line evidence`);
|
|
345
562
|
else if (!changedFiles.includes(e.path) && !pathExists(e.path)) miss(r.id, `${r.id}: evidence path ${e.path} does not exist`);
|
|
@@ -375,7 +592,10 @@ export function decide(raw, ctx) {
|
|
|
375
592
|
for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
|
|
376
593
|
for (const r of ctx.contract.requirements) {
|
|
377
594
|
const row = rv.requirements.find((x) => x.id === r.id);
|
|
378
|
-
if (r.disposition === "deliver" &&
|
|
595
|
+
if (r.disposition === "deliver" && isOutOfBand(r)) {
|
|
596
|
+
// #4404: the measured result lives in the named job; only the instrumentation is judged here
|
|
597
|
+
if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
598
|
+
} else if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
379
599
|
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} ${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
380
600
|
}
|
|
381
601
|
if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
|
|
@@ -431,7 +651,7 @@ export function reviewSkeleton(contract, changedFiles = [], ignore = []) {
|
|
|
431
651
|
const reqs = contract?.requirements ?? [];
|
|
432
652
|
return {
|
|
433
653
|
issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
|
|
434
|
-
requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), note: "" })),
|
|
654
|
+
requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), ...(isOutOfBand(r) ? { verify: "out_of_band", job: r.evidence ?? "", instrumented: null } : {}), note: "" })),
|
|
435
655
|
scope: reviewedFiles(changedFiles, ignore).map((f) => ({ file: f, authorized_by: authorizationOf(contract ?? {}, f) ?? "none", note: "" })),
|
|
436
656
|
preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "unverified", evidence: "" })),
|
|
437
657
|
regressions: [], verdict: "", summary: "",
|
|
@@ -441,16 +661,52 @@ export function reviewSkeleton(contract, changedFiles = [], ignore = []) {
|
|
|
441
661
|
/** Requirements the reviewer accepted as deferred or post-deploy that still need a tracking issue. */
|
|
442
662
|
export const pendingTracking = (contract) => (contract.requirements ?? []).filter((r) => TRACKED.includes(r.disposition) && !r.tracked);
|
|
443
663
|
|
|
444
|
-
/**
|
|
445
|
-
export
|
|
446
|
-
|
|
447
|
-
|
|
664
|
+
/** #4398: an `investigate(` (or `investigate:`) issue, whose stated question must be answered before it closes as completed. */
|
|
665
|
+
export const isInvestigation = (title) => /^\s*investigate[(:]/i.test(String(title ?? ""));
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* After a conforming merge: close as completed, linking every tracking issue. #4398: an investigation with a deferred or
|
|
669
|
+
* post-deploy requirement, or a deliver requirement whose review row is not implemented, closes "partial" instead; `open`
|
|
670
|
+
* holds those requirements (the open question, carried forward). A null review (reviewer skipped) judges dispositions only.
|
|
671
|
+
*/
|
|
672
|
+
export function closePlan(contract, { title = null, review = null } = {}) {
|
|
673
|
+
const reqs = contract.requirements ?? [];
|
|
674
|
+
const tracked = [...new Set(reqs.filter((r) => TRACKED.includes(r.disposition)).map((r) => r.tracked).filter(Boolean))];
|
|
675
|
+
if (!isInvestigation(title)) return { reason: "completed", tracked };
|
|
676
|
+
const rows = Array.isArray(review?.requirements) ? review.requirements : null;
|
|
677
|
+
const open = reqs.filter((r) => TRACKED.includes(r.disposition)
|
|
678
|
+
|| (r.disposition === "deliver" && rows && rows.find((x) => x?.id === r.id)?.status !== "implemented"));
|
|
679
|
+
return open.length ? { reason: "partial", tracked, open } : { reason: "completed", tracked };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const UNUSABLE_REVIEW = ["review.incomplete", "review.inconsistent", "review.unparseable"];
|
|
683
|
+
/**
|
|
684
|
+
* #4398 batch-report line from the lane rows' `conformance` ({mode, would, risk}): shadow lanes, how many the enforced
|
|
685
|
+
* gate would have blocked, the false-block share (a would-block on an unusable review, the scripts/calibration.mjs
|
|
686
|
+
* recommendEnforce proxy) and the lanes enforced by a declared risk path. Null when no row carries conformance.
|
|
687
|
+
*/
|
|
688
|
+
export function formatShadowSummary(rows) {
|
|
689
|
+
const cs = (rows ?? []).map((r) => r?.conformance).filter((c) => c && typeof c === "object");
|
|
690
|
+
if (!cs.length) return null;
|
|
691
|
+
const sh = cs.filter((c) => c.mode === "shadow");
|
|
692
|
+
const would = sh.filter((c) => typeof c.would === "string" && c.would !== "conforms").length;
|
|
693
|
+
const falseB = sh.filter((c) => UNUSABLE_REVIEW.includes(c.would)).length;
|
|
694
|
+
const pct = (k) => (sh.length ? `${Math.round((k / sh.length) * 100)}%` : "n/a");
|
|
695
|
+
const risk = cs.filter((c) => c.risk === true).length;
|
|
696
|
+
return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}`;
|
|
448
697
|
}
|
|
449
698
|
|
|
450
699
|
/** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
|
|
451
700
|
export function followupItems(reqs) {
|
|
452
701
|
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
453
|
-
return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400) }));
|
|
702
|
+
return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400).trim() }));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
|
|
706
|
+
export function followupMap(out, items) {
|
|
707
|
+
const echoed = new Map();
|
|
708
|
+
for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(k.trim(), Number(n)); }
|
|
709
|
+
return Object.fromEntries(items.filter((i) => echoed.has(String(i.key).trim())).map((i) => [i.key, echoed.get(String(i.key).trim())]));
|
|
454
710
|
}
|
|
455
711
|
|
|
456
712
|
/**
|