forgedock 1.30.0 → 1.32.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 +3 -2
- 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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -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 +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -15
- 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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- 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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- 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 +72 -10
- package/bin/lean/verify.test.mjs +68 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- 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 +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -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/trusted-comments.test.sh +1 -1
package/bin/lean/checks.test.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
-
import { discoverChecks, scopedChecks, cacheEntry, hydrate, publish, withCacheFlags, LOCAL_CACHE } from "./checks.mjs";
|
|
10
|
+
import { discoverChecks, scopedChecks, configuredLint, cacheEntry, hydrate, publish, withCacheFlags, LOCAL_CACHE } from "./checks.mjs";
|
|
11
|
+
import { yqAvailable } from "../../scripts/ci-local/lib.mjs";
|
|
11
12
|
|
|
12
13
|
const TMP = tmpDir("checks-4218-");
|
|
13
14
|
process.env.FORGE_CI_LOCAL_HOME = path.join(TMP, "forge-home");
|
|
@@ -181,3 +182,79 @@ test("discovery: declared workspace packages' scripts win over the root; undecla
|
|
|
181
182
|
});
|
|
182
183
|
assert.deepEqual(discoverChecks(r).map((c) => `${c.kind}:${c.command}:${c.cwd}`), ["typecheck:pnpm run typecheck:apps/web", "test:pnpm run test:."]);
|
|
183
184
|
});
|
|
185
|
+
|
|
186
|
+
// #4381: lint is scoped to the changed packages, never their dependency closure; a configured typescript.lint wins.
|
|
187
|
+
const HAS_YQ = yqAvailable();
|
|
188
|
+
const turboMonorepo = (extra = {}) => repo({
|
|
189
|
+
".gitignore": "node_modules/\nforge.yaml\n",
|
|
190
|
+
"package.json": JSON.stringify({ name: "root", private: true, workspaces: ["apps/*", "packages/*"] }),
|
|
191
|
+
"pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
|
|
192
|
+
"turbo.json": JSON.stringify({ tasks: { lint: {}, typecheck: {}, test: {} } }),
|
|
193
|
+
"packages/db/package.json": JSON.stringify({ name: "db", scripts: { lint: "eslint ." } }),
|
|
194
|
+
"packages/db/src/index.ts": "export const db = 1;\n",
|
|
195
|
+
"apps/api/package.json": JSON.stringify({ name: "api", dependencies: { db: "workspace:*" } }),
|
|
196
|
+
"apps/api/src/index.ts": "export const api = 1;\n",
|
|
197
|
+
...extra,
|
|
198
|
+
});
|
|
199
|
+
const TS_LINT_YAML = "verification:\n commands:\n typescript:\n lint: \"pnpm exec eslint --cache packages/db/src\"\n";
|
|
200
|
+
|
|
201
|
+
test("turbo monorepo: an upstream package change lints only the changed packages; typecheck/test keep dependents", () => {
|
|
202
|
+
const r = turboMonorepo();
|
|
203
|
+
touch(r, "packages/db/src/index.ts");
|
|
204
|
+
const c = byKind(scopedChecks({ repo: r, base: "main", lint: null }));
|
|
205
|
+
assert.equal(c.lint.selector, "turbo-changed");
|
|
206
|
+
assert.match(c.lint.command, /^npx turbo run lint '--filter=\[[0-9a-f]{40}\]'$/);
|
|
207
|
+
assert.doesNotMatch(c.lint.command, /\.\.\.\[/);
|
|
208
|
+
assert.match(c.typecheck.command, /^npx turbo run typecheck '--filter=\.\.\.\[[0-9a-f]{40}\]'$/);
|
|
209
|
+
assert.match(c.test.command, /^npx turbo run test '--filter=\.\.\.\[[0-9a-f]{40}\]'$/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("turbo: lint-only task lists (lint:*, *:lint) scope to changed packages; mixed lists keep the closure", () => {
|
|
213
|
+
const r = turboMonorepo();
|
|
214
|
+
touch(r, "packages/db/src/index.ts");
|
|
215
|
+
const run = (command) => scopedChecks({ repo: r, base: "main", checks: [{ kind: "lint", command, cwd: "." }], lint: null })[0];
|
|
216
|
+
assert.equal(run("turbo run lint:types web:lint --concurrency 2").selector, "turbo-changed");
|
|
217
|
+
assert.equal(run("turbo run lint build").selector, "turbo-affected");
|
|
218
|
+
assert.equal(run("turbo run lint --filter=web").mode, "full");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("configured typescript.lint in the main checkout's forge.yaml replaces the discovered lint", { skip: !HAS_YQ && "yq not installed" }, () => {
|
|
222
|
+
const r = turboMonorepo();
|
|
223
|
+
fs.writeFileSync(path.join(r, "forge.yaml"), TS_LINT_YAML);
|
|
224
|
+
const wt = path.join(TMP, `wt${n++}`);
|
|
225
|
+
git(r, "worktree", "add", "-q", "-b", "lane", wt, "main");
|
|
226
|
+
touch(wt, "packages/db/src/index.ts");
|
|
227
|
+
const checks = scopedChecks({ repo: wt, base: "main" });
|
|
228
|
+
const lints = checks.filter((c) => c.kind === "lint");
|
|
229
|
+
assert.equal(lints.length, 1);
|
|
230
|
+
assert.equal(lints[0].configured, true);
|
|
231
|
+
assert.doesNotMatch(lints[0].command, /turbo/);
|
|
232
|
+
assert.match(lints[0].command, /eslint .*packages\/db\/src/);
|
|
233
|
+
assert.deepEqual(checks.map((c) => c.kind), ["typecheck", "lint", "test"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a PR-tracked forge.yaml is not trusted; without the slot the output equals discovery-only", { skip: !HAS_YQ && "yq not installed" }, () => {
|
|
237
|
+
const r = turboMonorepo();
|
|
238
|
+
touch(r, "packages/db/src/index.ts");
|
|
239
|
+
const before = scopedChecks({ repo: r, base: "main", lint: null });
|
|
240
|
+
assert.deepEqual(scopedChecks({ repo: r, base: "main" }), before);
|
|
241
|
+
fs.writeFileSync(path.join(r, "forge.yaml"), "verification:\n commands:\n python:\n lint: \"ruff check .\"\n");
|
|
242
|
+
assert.deepEqual(scopedChecks({ repo: r, base: "main" }), before);
|
|
243
|
+
fs.writeFileSync(path.join(r, "forge.yaml"), TS_LINT_YAML);
|
|
244
|
+
git(r, "add", "-f", "forge.yaml");
|
|
245
|
+
git(r, "commit", "-qm", "pr config");
|
|
246
|
+
assert.equal(configuredLint(r, "main"), null);
|
|
247
|
+
const lint = scopedChecks({ repo: r, base: "main" }).find((c) => c.kind === "lint");
|
|
248
|
+
assert.equal(lint.configured, undefined);
|
|
249
|
+
assert.match(lint.command, /^npx turbo run lint/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("Python: a configured typescript.lint or python.lint never replaces ruff", { skip: !HAS_YQ && "yq not installed" }, () => {
|
|
253
|
+
const r = pyRepo("uv.lock", "version = 1\n");
|
|
254
|
+
fs.appendFileSync(path.join(r, ".gitignore"), "forge.yaml\n");
|
|
255
|
+
git(r, "commit", "-qam", "ignore forge.yaml");
|
|
256
|
+
fs.writeFileSync(path.join(r, "forge.yaml"), "verification:\n commands:\n python:\n lint: \"ruff check src\"\n");
|
|
257
|
+
fs.appendFileSync(path.join(r, "pkg/alpha.py"), "# change\n");
|
|
258
|
+
assert.equal(configuredLint(r, "main"), null);
|
|
259
|
+
assert.equal(byKind(scopedChecks({ repo: r, base: "main" })).lint.command, "uv run ruff check pkg/alpha.py");
|
|
260
|
+
});
|
|
@@ -14,6 +14,9 @@ import path from "node:path";
|
|
|
14
14
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
15
15
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
16
16
|
import { runCore } from "./lean.mjs";
|
|
17
|
+
import { readLog } from "../engine/runlog.mjs";
|
|
18
|
+
import { runCheckCommand } from "./effects.mjs";
|
|
19
|
+
import { checkFromCli } from "../engine-cli.mjs";
|
|
17
20
|
|
|
18
21
|
const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
|
|
19
22
|
const git = (args, cwd) => { try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: GIT_ENV }); } catch (e) { throw Object.assign(new Error(`git ${args.join(" ")}: ${e.stderr || e.message}`), { stderr: String(e.stderr ?? "") }); } };
|
|
@@ -134,7 +137,7 @@ function builderScript(cs) {
|
|
|
134
137
|
const finish = (t) => { res.validation = [{ id: "V1", command: V1, exit: t.run(V1) }]; t.lean("implementation.md", `### Approach\nrelease in a catch\n\n### Changes\n${t.changed().map((f) => `- ${f}`).join("\n")}\n\n### Acceptance Criteria Status\n- see result\n\n### Verification\n- ${V1}\n\n### Notes\nnone`); t.lean("result.json", res); };
|
|
135
138
|
return async (n, c, t) => {
|
|
136
139
|
if (n === 1) { t.lean("investigation.md", "**Verdict**: CONFIRMED\n**Confidence**: high\n**Severity**: high\n**Task Type**: bug\n\n### Root Cause\ncheck() throws after reserve() with no release"); t.lean("contract.json", cs.contract()); return; }
|
|
137
|
-
if (n === 2) { cs.impl(t); t.commit("fix: release on 429"); res = { status: "fixed", summary: "release on 429", requirements: (cs.claims ?? claimAll)(cs.contract()), amendments: [] }; finish(t); return; }
|
|
140
|
+
if (n === 2) { cs.impl(t); t.commit("fix: release on 429"); if (cs.selfCheck) await cs.selfCheck(c.cwd); res = { status: "fixed", summary: "release on 429", requirements: (cs.claims ?? claimAll)(cs.contract()), amendments: [] }; finish(t); return; }
|
|
138
141
|
if (cs.resume) { cs.resume(t, res); t.commit("fix: conformance"); }
|
|
139
142
|
finish(t);
|
|
140
143
|
};
|
|
@@ -162,11 +165,12 @@ function oracle(c) {
|
|
|
162
165
|
}
|
|
163
166
|
const generic = () => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "looks good" }) + "\n```";
|
|
164
167
|
|
|
165
|
-
async function runCase(cs, conformance = "enforce") {
|
|
168
|
+
async function runCase(cs, conformance = "enforce", extra = {}) {
|
|
166
169
|
const dir = tmpDir("conf-e2e-");
|
|
167
170
|
const w = makeWorld(dir, { builder: builderScript(cs), reviewer: (n, c) => (cs.generic ? generic() : oracle(c)) });
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
if (extra.runCommand) w.io.runCommand = extra.runCommand;
|
|
172
|
+
const ret = await runCore({ issue: 101, repo: "o/r", lane: "staging", cwd: w.repo, worktreeRoot: path.join(dir, "wt"), dir: path.join(dir, "runs"), io: w.io, random: () => 0.5, stableMs: 0, livePollMs: 20, conformance, ...(extra.opts ?? {}) });
|
|
173
|
+
return { ...w.observe(ret), log: readLog(path.join(dir, "runs"), 101, { repo: "o/r" }) }; // #4432: keyed by repo
|
|
170
174
|
}
|
|
171
175
|
const blocked = (o, code) => {
|
|
172
176
|
assert.equal(o.ret.terminalReason, "needs-human"); assert.equal(o.ret.code, code);
|
|
@@ -185,7 +189,7 @@ describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAV
|
|
|
185
189
|
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search", "export"]) });
|
|
186
190
|
merged(o); assert.ok(o.staging.search && o.staging.export && !o.staging.billingChanged);
|
|
187
191
|
assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 1]);
|
|
188
|
-
assert.deepEqual(o.markers, ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:CONTRACT", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
|
|
192
|
+
assert.deepEqual(o.markers, ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:CONTRACT", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:FACTS", "FORGE:TRAJECTORY"]);
|
|
189
193
|
});
|
|
190
194
|
it("2 omission: the contract silently drops requirement 3; review catches it; issue stays open, nothing merged", async () => {
|
|
191
195
|
const o = await runCase({ contract: OMIT, impl: (t) => fix(t, ["scrape", "search"]) });
|
|
@@ -225,6 +229,24 @@ describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAV
|
|
|
225
229
|
assert.match(o.issue.comments.find((c) => c.includes("FORGE:REVIEW")), /would BLOCK \(nonconforming\)[\s\S]*conformance\.omission/);
|
|
226
230
|
assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 1]);
|
|
227
231
|
});
|
|
232
|
+
it("#4364 forgedock check in the lane worktree runs the engine's commands and reaches its verdict", async () => {
|
|
233
|
+
let cli = null;
|
|
234
|
+
const learned = [V1, "exit 3"];
|
|
235
|
+
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search", "export"]),
|
|
236
|
+
selfCheck: async (wt) => { cli = await checkFromCli([], { cwd: wt, git: async (a, cwd) => git(a, cwd), out: () => {} }); } },
|
|
237
|
+
"enforce", { runCommand: runCheckCommand, opts: { precheck: { commands: learned }, verify: { maxRounds: 0 } } });
|
|
238
|
+
merged(o);
|
|
239
|
+
const [engine] = o.log.filter((e) => e.event === "PRECHECK_COMMANDS");
|
|
240
|
+
assert.deepEqual(cli.commands.map(({ command, rc }) => ({ command, rc })), engine.results.map(({ command, rc }) => ({ command, rc })));
|
|
241
|
+
assert.deepEqual(engine.results.map((r) => r.rc), [0, 3], "one passing and one failing pre-PR command");
|
|
242
|
+
// validate: the CLI's V1 run agrees with the engine's conformance precheck (no validate failure)
|
|
243
|
+
assert.deepEqual(cli.validate.map(({ id, rc }) => ({ id, rc })), [{ id: "V1", rc: 0 }]);
|
|
244
|
+
assert.ok(!o.log.some((e) => e.event === "PRECHECK" && e.failures.length), "the engine's validate verdict passes too");
|
|
245
|
+
assert.equal(cli.ok, false); assert.equal(cli.exitCode, 1);
|
|
246
|
+
// the run was on the final tree, so the engine records it as red (not skipped/stale) on both events
|
|
247
|
+
assert.equal(engine.self_check, "red");
|
|
248
|
+
assert.deepEqual(o.log.filter((e) => e.event === "SELF_CHECK_MISSING").map((e) => e.state), ["red"]);
|
|
249
|
+
});
|
|
228
250
|
it("6 a generic PASS over an incomplete implementation never merges (review.incomplete)", async () => {
|
|
229
251
|
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "export"]), generic: true });
|
|
230
252
|
blocked(o, "review.incomplete"); assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 2]);
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -112,6 +112,74 @@ 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
|
+
|
|
115
183
|
/** Option names whose value a tool writes (output, report, cache or temp dirs), not reads (#4325). */
|
|
116
184
|
const WRITE_OPT = /^-(?:o|O|-?[\w-]*(?:out|dir|temp|tmp|cache|report|junit|xml|log)[\w-]*)$/i;
|
|
117
185
|
const TMP_PATH = /\$(?:\{TMPDIR(?::-[^}]*)?\}|TMPDIR)\/([^\s"'`;|&<>()=]+)/g;
|
|
@@ -162,6 +230,9 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
162
230
|
if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
|
|
163
231
|
if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
|
|
164
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)`);
|
|
165
236
|
}
|
|
166
237
|
const dels = Array.isArray(c.deliverables) ? c.deliverables : [];
|
|
167
238
|
if (reqs.some((r) => r?.disposition === "deliver") && !dels.length) problems.push("deliver requirements but no deliverables");
|
|
@@ -213,6 +284,7 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
213
284
|
const r = { disposition: "deliver", ...a.requirement, amended: true };
|
|
214
285
|
if (!DISPOSITIONS.includes(r.disposition)) { problems.push(`add-requirement ${r.id}: bad disposition ${r.disposition}`); continue; }
|
|
215
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
|
|
216
288
|
c.requirements.push(r);
|
|
217
289
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
218
290
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
@@ -261,17 +333,22 @@ export function repoTracks(paths) {
|
|
|
261
333
|
|
|
262
334
|
/**
|
|
263
335
|
* Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
|
|
264
|
-
* requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[]}; each failure carries a
|
|
336
|
+
* requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[], superseded[]}; each failure carries a
|
|
265
337
|
* `kind` (scope_creep, validate_missing, validate_failed, validate_env, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green, repro_env) so callers can
|
|
266
338
|
* tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
|
|
267
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.
|
|
268
340
|
* `validateRuns` (#4323): the engine's rerun {[id]: {rc, output}} of validate commands recorded as failed. A rerun that passes
|
|
269
341
|
* counts as passed; one that could not find a host tool or module (missingTool, with `repoHas`) is kind validate_env, code
|
|
270
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 [].
|
|
271
346
|
*/
|
|
272
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false }) {
|
|
273
|
-
const failures = [];
|
|
274
|
-
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);
|
|
275
352
|
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
276
353
|
const runs = Array.isArray(result?.validation) ? result.validation : [];
|
|
277
354
|
for (const v of contract.validate ?? []) {
|
|
@@ -286,6 +363,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
286
363
|
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
287
364
|
if (miss && ciVerified) continue;
|
|
288
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 });
|
|
289
367
|
else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
290
368
|
}
|
|
291
369
|
}
|
|
@@ -293,14 +371,31 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
293
371
|
for (const r of contract.requirements ?? []) {
|
|
294
372
|
const s = st.find((x) => x?.id === r.id)?.status;
|
|
295
373
|
if (TRACKED.includes(r.disposition)) continue; // typed deferrals are judged by the reviewer, then filed before the PR
|
|
296
|
-
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") {
|
|
297
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` });
|
|
298
380
|
else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} reported "${s ?? "no status"}"` });
|
|
299
381
|
}
|
|
300
382
|
}
|
|
301
383
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
302
384
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
303
|
-
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;
|
|
304
399
|
}
|
|
305
400
|
|
|
306
401
|
/**
|
|
@@ -432,7 +527,7 @@ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = []
|
|
|
432
527
|
out.preserve = pres;
|
|
433
528
|
if (Array.isArray(rv.requirements)) {
|
|
434
529
|
out.requirements = rv.requirements.map((r) => {
|
|
435
|
-
if (r?.status !== "implemented") return r;
|
|
530
|
+
if (r?.status !== "implemented" && r?.instrumented !== true) return r;
|
|
436
531
|
const e = evidenceOf(r.evidence, { changedFiles, repoFiles });
|
|
437
532
|
return e ? { ...r, evidence: `${e.path}:${e.line}` } : r;
|
|
438
533
|
});
|
|
@@ -460,7 +555,8 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
|
|
|
460
555
|
const row = rows[0];
|
|
461
556
|
if (!REQ_STATUS.includes(row.status)) { miss(r.id, `${r.id}: status invalid: ${row.status}`); continue; }
|
|
462
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?)`);
|
|
463
|
-
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)) {
|
|
464
560
|
const e = evidenceOf(row.evidence, { changedFiles, repoFiles });
|
|
465
561
|
if (!e) miss(r.id, `${r.id}: implemented needs path:line evidence`);
|
|
466
562
|
else if (!changedFiles.includes(e.path) && !pathExists(e.path)) miss(r.id, `${r.id}: evidence path ${e.path} does not exist`);
|
|
@@ -496,7 +592,10 @@ export function decide(raw, ctx) {
|
|
|
496
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})` : ""}` });
|
|
497
593
|
for (const r of ctx.contract.requirements) {
|
|
498
594
|
const row = rv.requirements.find((x) => x.id === r.id);
|
|
499
|
-
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") {
|
|
500
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})` : ""}` });
|
|
501
600
|
}
|
|
502
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` });
|
|
@@ -552,7 +651,7 @@ export function reviewSkeleton(contract, changedFiles = [], ignore = []) {
|
|
|
552
651
|
const reqs = contract?.requirements ?? [];
|
|
553
652
|
return {
|
|
554
653
|
issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
|
|
555
|
-
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: "" })),
|
|
556
655
|
scope: reviewedFiles(changedFiles, ignore).map((f) => ({ file: f, authorized_by: authorizationOf(contract ?? {}, f) ?? "none", note: "" })),
|
|
557
656
|
preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "unverified", evidence: "" })),
|
|
558
657
|
regressions: [], verdict: "", summary: "",
|
|
@@ -562,16 +661,52 @@ export function reviewSkeleton(contract, changedFiles = [], ignore = []) {
|
|
|
562
661
|
/** Requirements the reviewer accepted as deferred or post-deploy that still need a tracking issue. */
|
|
563
662
|
export const pendingTracking = (contract) => (contract.requirements ?? []).filter((r) => TRACKED.includes(r.disposition) && !r.tracked);
|
|
564
663
|
|
|
565
|
-
/**
|
|
566
|
-
export
|
|
567
|
-
|
|
568
|
-
|
|
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}`;
|
|
569
697
|
}
|
|
570
698
|
|
|
571
699
|
/** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
|
|
572
700
|
export function followupItems(reqs) {
|
|
573
701
|
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
574
|
-
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())]));
|
|
575
710
|
}
|
|
576
711
|
|
|
577
712
|
/**
|