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.
Files changed (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -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
- /** The first path:line anywhere in the evidence text (backticks, quotes and brackets around it are ignored). */
22
- const EVIDENCE_RE = /([^\s:`'"()[\]<>,;]+):(\d+)/;
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 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);
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) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
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 ?? "UNRUNNABLE"}` }];
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 = EVIDENCE_RE.exec(String(text ?? ""));
294
- return m ? { path: normalizeEvidencePath(m[1], ctx), line: m[2] } : null;
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.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)) {
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" && 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") {
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
- /** After a conforming merge: close as completed, linking every tracking issue. */
445
- export function closePlan(contract) {
446
- const tracked = [...new Set((contract.requirements ?? []).filter((r) => TRACKED.includes(r.disposition)).map((r) => r.tracked).filter(Boolean))];
447
- 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}`;
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
  /**