forgedock 1.30.0 → 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.
Files changed (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -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
- 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 });
169
- return w.observe(ret);
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]);
@@ -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 creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
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.status === "implemented") {
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" && row.status !== "implemented") {
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
- /** After a conforming merge: close as completed, linking every tracking issue. */
566
- export function closePlan(contract) {
567
- const tracked = [...new Set((contract.requirements ?? []).filter((r) => TRACKED.includes(r.disposition)).map((r) => r.tracked).filter(Boolean))];
568
- return { reason: "completed", tracked };
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
  /**