forgedock 1.7.1 → 1.8.1

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 (48) hide show
  1. package/bin/engine/admission.mjs +242 -7
  2. package/bin/engine/admission.test.mjs +183 -2
  3. package/bin/engine/phases.mjs +31 -22
  4. package/bin/engine/resolve.mjs +8 -1
  5. package/bin/engine/resolve.test.mjs +5 -0
  6. package/bin/engine.mjs +26 -17
  7. package/bin/forgedock.mjs +15 -2
  8. package/bin/hooks/pre-tool-use.mjs +573 -2
  9. package/bin/journey.mjs +11 -7
  10. package/bin/labels.json +5 -0
  11. package/bin/opencode-adapter.mjs +47 -13
  12. package/bin/orchestrate-preflight.mjs +434 -0
  13. package/bin/runner.mjs +30 -4
  14. package/commands/audit-agents.md +75 -26
  15. package/commands/autopilot.md +4 -4
  16. package/commands/cleanup.md +10 -5
  17. package/commands/issue.md +95 -35
  18. package/commands/orchestrate/phase-1-resolve.md +70 -32
  19. package/commands/orchestrate/phase-3-dependency.md +141 -13
  20. package/commands/orchestrate/phase-4-execution.md +660 -70
  21. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  22. package/commands/orchestrate/phase-6-report.md +27 -1
  23. package/commands/orchestrate/safety.md +1 -1
  24. package/commands/orchestrate.md +39 -1
  25. package/commands/quality-gate.md +30 -10
  26. package/commands/review-pr-agents/protocols.md +13 -0
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +100 -18
  29. package/commands/review-pr.md +78 -24
  30. package/commands/upgrade-deps.md +1 -1
  31. package/commands/work-on/build/validate.md +25 -4
  32. package/commands/work-on/close.md +40 -10
  33. package/commands/work-on/decompose.md +33 -1
  34. package/commands/work-on/investigate.md +33 -0
  35. package/commands/work-on/remediate.md +40 -5
  36. package/commands/work-on/review.md +27 -0
  37. package/commands/work-on.md +48 -5
  38. package/docs/CONFIG.md +45 -10
  39. package/docs/OPENCODE.md +21 -6
  40. package/docs/spec/review-protocol.md +7 -0
  41. package/package.json +1 -1
  42. package/scripts/check-admission-parity.mjs +1 -0
  43. package/scripts/check-command-side-effects.sh +14 -13
  44. package/scripts/check-shell-portability.sh +36 -0
  45. package/scripts/check-spec-markers.sh +21 -11
  46. package/scripts/checkbox-sections.test.sh +67 -0
  47. package/scripts/extract-affected-files.sh +85 -5
  48. package/scripts/extract-affected-files.test.sh +154 -2
@@ -32,24 +32,82 @@
32
32
  * cascade until N tokens spent" — `max_generation` and `token_budget` below
33
33
  * are independent levers precisely so that shape of policy is expressible.
34
34
  *
35
- * Hard invariant (NOT configurable by design): safety exclusions — findings
36
- * whose `## Problem` section indicates security/billing/anti-bot/auth
37
- * concerns — are never batched and never auto-admitted by ANY policy,
38
- * including `all`. That exclusion lives upstream of this module (the P3
39
- * batching eligibility check in `phase-1-resolve.md` / the surface-area
40
- * batching check in `phase-4-execution.md`) and is intentionally absent
41
- * from the levers this module resolves.
35
+ * Billing findings are never batched. Security-relevant P3 findings may only
36
+ * batch with findings in the same coarse class, with a maximum of three
37
+ * members. The prose mirrors use `classifyBatchSafety` as their reference.
42
38
  */
43
39
 
44
40
  /** Sentinel string accepted anywhere an "int | unlimited" lever is read. */
45
41
  export const UNLIMITED = "unlimited";
46
42
 
43
+ const BATCH_SAFETY_CLASSES = [
44
+ ["billing", /\bbilling\b/i],
45
+ ["auth", /\b(?:[A-Za-z0-9]*[A-Z][A-Za-z0-9]*[Aa][Uu][Tt][Hh][A-Za-z0-9]*|[Aa][Uu][Tt][Hh](?:[NnZz]?_|[A-Z]))\b|\b(?:authentication|authorization|authn|authz)\b/i],
46
+ ["injection", /\b(?:inject|injection|xss|csrf|ssrf|deserializ|rce)\w*/i],
47
+ ["access-control", /\b(?:bypass|escalat|privilege)\w*/i],
48
+ ["credential", /\b(?:credential|secret|token|password|pgpassword|htpasswd)\w*/i],
49
+ ["redaction", /\b(?:redact|sanitiz)\w*/i],
50
+ ["scheme", /\bscheme\b/i],
51
+ ["traversal", /\btraversal\b/i],
52
+ ["security", /\b(?:security|anti-bot)\b/i],
53
+ ];
54
+
55
+ /**
56
+ * Return the batching safety class for title/Problem text. `null` is routine
57
+ * work; `billing` is an absolute exclusion; every other value is eligible only
58
+ * for a same-class, maximum-three-member security batch.
59
+ *
60
+ * `auth` deliberately matches camelCase/PascalCase and underscore identifiers
61
+ * (MaintenanceAuth, AdminAuth, authz_check) without matching authority_source.
62
+ * @param {string} text
63
+ * @returns {string|null}
64
+ */
65
+ export function classifyBatchSafety(text) {
66
+ const findingText = text.replace(/^\*\*Agent\*\*:.*$/gim, "");
67
+ const explicitClass = findingText.match(/<!--\s*FORGE:CLASS:\s*([a-z0-9-]+)\s*-->/i)?.[1];
68
+ if (explicitClass) return explicitClass.toLowerCase();
69
+ return BATCH_SAFETY_CLASSES.find(([, pattern]) => pattern.test(findingText))?.[0] ?? null;
70
+ }
71
+
72
+ /**
73
+ * True only for the narrow automated-alert duplicate case. Human-authored
74
+ * reports and alerts without a stable generator/trigger identity always stay
75
+ * for investigation.
76
+ *
77
+ * @param {Object} canonical
78
+ * @param {Object} candidate
79
+ * @returns {boolean}
80
+ */
81
+ export function canDeduplicateAutomatedAlert(canonical, candidate) {
82
+ const isAutomated = (issue) =>
83
+ issue?.authorType === "Bot" || /\[bot\]$/i.test(issue?.authorLogin || "");
84
+ const normalizeTitle = (title) => String(title || "").trim().replace(/\s+/g, " ").toLowerCase();
85
+ const hasIdentity = (value) => typeof value === "string" && value.trim() !== "";
86
+
87
+ return (
88
+ isAutomated(canonical) &&
89
+ isAutomated(candidate) &&
90
+ normalizeTitle(canonical.title) !== "" &&
91
+ normalizeTitle(canonical.title) === normalizeTitle(candidate.title) &&
92
+ hasIdentity(canonical.generator) &&
93
+ hasIdentity(candidate.generator) &&
94
+ canonical.generator === candidate.generator &&
95
+ hasIdentity(canonical.trigger) &&
96
+ hasIdentity(candidate.trigger) &&
97
+ canonical.trigger === candidate.trigger
98
+ );
99
+ }
100
+
47
101
  /**
48
102
  * @typedef {Object} CascadePolicy
49
103
  * @property {number|typeof UNLIMITED} maxGeneration - Max cascade generation depth
50
104
  * admitted. 1 = only original (non-review-finding-spawned) issues; a
51
105
  * review-finding whose source is itself a review-finding is generation 2,
52
106
  * and so on up the chain. `unlimited` removes the cap entirely.
107
+ * @property {number} batchMaxGeneration - Deepest review-finding generation that
108
+ * may be absorbed into a P3 batch. This is deliberately always finite: batching
109
+ * is a bounded aggregation exception to autonomous cascade admission, not an
110
+ * alternate path to unlimited recursion.
53
111
  * @property {number|typeof UNLIMITED} tokenBudget - Per-batch token ceiling for
54
112
  * Step 4C's review-finding cascade dispatch (mirrors, and by default reads
55
113
  * through to, `pipeline.token_budget_per_batch`). `unlimited` removes the cap.
@@ -59,6 +117,10 @@ export const UNLIMITED = "unlimited";
59
117
  * heuristic defers P3-and-below findings.
60
118
  * @property {boolean} p3SameFileDefer - Whether a P3 finding sharing a file with
61
119
  * the active batch is deferred.
120
+ * @property {number|null} maxAmplification - Maximum findings spawned per merged
121
+ * unit before same-lineage refinements are deferred. `null` disables the bound.
122
+ * @property {number} convergenceWindow - Merged-unit window used to warn when
123
+ * amplification remains at or above 1.0.
62
124
  */
63
125
 
64
126
  /**
@@ -78,6 +140,7 @@ export const UNLIMITED = "unlimited";
78
140
  export const CASCADE_PRESETS = Object.freeze({
79
141
  all: Object.freeze({
80
142
  maxGeneration: UNLIMITED,
143
+ batchMaxGeneration: 2,
81
144
  tokenBudget: UNLIMITED,
82
145
  deferOnBatchGated: false,
83
146
  keywordHeuristic: false,
@@ -85,6 +148,7 @@ export const CASCADE_PRESETS = Object.freeze({
85
148
  }),
86
149
  balanced: Object.freeze({
87
150
  maxGeneration: 1,
151
+ batchMaxGeneration: 2,
88
152
  tokenBudget: 900000,
89
153
  deferOnBatchGated: true,
90
154
  keywordHeuristic: true,
@@ -92,6 +156,7 @@ export const CASCADE_PRESETS = Object.freeze({
92
156
  }),
93
157
  conservative: Object.freeze({
94
158
  maxGeneration: 1,
159
+ batchMaxGeneration: 2,
95
160
  tokenBudget: 450000,
96
161
  deferOnBatchGated: true,
97
162
  keywordHeuristic: true,
@@ -101,6 +166,19 @@ export const CASCADE_PRESETS = Object.freeze({
101
166
 
102
167
  export const DEFAULT_CASCADE_POLICY_NAME = "balanced";
103
168
 
169
+ /** Parse an optional positive decimal used for an opt-in ratio ceiling. */
170
+ export function parseOptionalPositiveNumber(raw) {
171
+ if (raw === undefined || raw === null || raw === "null" || raw === "" || raw === "off") {
172
+ return { value: null, warning: null };
173
+ }
174
+ const n = typeof raw === "number" ? raw : Number(String(raw).trim());
175
+ if (Number.isFinite(n) && n > 0) return { value: n, warning: null };
176
+ return {
177
+ value: null,
178
+ warning: `not a positive number or "off" ("${raw}") — disabling the bound`,
179
+ };
180
+ }
181
+
104
182
  /**
105
183
  * Parse a raw config value that may be a positive integer, the literal
106
184
  * string "unlimited" (case-insensitive), or absent/invalid. Mirrors the
@@ -144,10 +222,13 @@ export function parseIntOrUnlimited(raw, fallback) {
144
222
  * that resolves to `balanced` exactly like today's hardcoded behavior).
145
223
  * @param {string} [config.policy]
146
224
  * @param {number|string} [config.max_generation]
225
+ * @param {number|string} [config.batch_max_generation]
147
226
  * @param {number|string} [config.token_budget]
148
227
  * @param {boolean} [config.defer_on_batch_gated]
149
228
  * @param {boolean} [config.keyword_heuristic]
150
229
  * @param {boolean} [config.p3_same_file_defer]
230
+ * @param {number|string} [config.max_amplification]
231
+ * @param {number|string} [config.convergence_window]
151
232
  * @param {number|string} [legacyTokenBudgetPerBatch] - Deprecated-alias fallback:
152
233
  * `pipeline.token_budget_per_batch`, read when `config.token_budget` is absent
153
234
  * so existing configs keep working unchanged (see forge#1858).
@@ -173,6 +254,27 @@ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
173
254
  const maxGen = parseIntOrUnlimited(config.max_generation, preset.maxGeneration);
174
255
  if (maxGen.warning) warnings.push(`orchestration.cascade.max_generation ${maxGen.warning}`);
175
256
 
257
+ // Unlike explicit Phase 1 admission, automated batching must retain a finite
258
+ // recursion bound even under policy: all. Do not accept the unlimited sentinel.
259
+ const rawBatchMaxGeneration = config.batch_max_generation;
260
+ const parsedBatchMaxGeneration = Number(rawBatchMaxGeneration);
261
+ const batchMaxGeneration =
262
+ rawBatchMaxGeneration === undefined || rawBatchMaxGeneration === null || rawBatchMaxGeneration === ""
263
+ ? preset.batchMaxGeneration
264
+ : Number.isInteger(parsedBatchMaxGeneration) && parsedBatchMaxGeneration > 0
265
+ ? parsedBatchMaxGeneration
266
+ : preset.batchMaxGeneration;
267
+ if (
268
+ rawBatchMaxGeneration !== undefined &&
269
+ rawBatchMaxGeneration !== null &&
270
+ rawBatchMaxGeneration !== "" &&
271
+ !(Number.isInteger(parsedBatchMaxGeneration) && parsedBatchMaxGeneration > 0)
272
+ ) {
273
+ warnings.push(
274
+ `orchestration.cascade.batch_max_generation is not a positive integer ("${rawBatchMaxGeneration}") — falling back to default ${preset.batchMaxGeneration}`,
275
+ );
276
+ }
277
+
176
278
  // token_budget precedence: orchestration.cascade.token_budget (new home) >
177
279
  // pipeline.token_budget_per_batch (deprecated alias, forge#1858) > preset default.
178
280
  // The legacy fallback is validated through parseIntOrUnlimited itself before use —
@@ -195,6 +297,16 @@ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
195
297
  typeof config.keyword_heuristic === "boolean" ? config.keyword_heuristic : preset.keywordHeuristic;
196
298
  const p3SameFileDefer =
197
299
  typeof config.p3_same_file_defer === "boolean" ? config.p3_same_file_defer : preset.p3SameFileDefer;
300
+ const maxAmplification = parseOptionalPositiveNumber(config.max_amplification);
301
+ if (maxAmplification.warning) warnings.push(`orchestration.cascade.max_amplification ${maxAmplification.warning}`);
302
+ const convergenceWindow = parseIntOrUnlimited(config.convergence_window, 3);
303
+ if (convergenceWindow.warning || convergenceWindow.value === UNLIMITED) {
304
+ warnings.push(
305
+ convergenceWindow.warning
306
+ ? `orchestration.cascade.convergence_window ${convergenceWindow.warning}`
307
+ : 'orchestration.cascade.convergence_window cannot be "unlimited" — falling back to default 3',
308
+ );
309
+ }
198
310
 
199
311
  // Both-uncapped notice: neither generation depth nor token spend is bounded this
200
312
  // run. This is never a preset default (no preset in CASCADE_PRESETS sets both to
@@ -213,10 +325,13 @@ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
213
325
  return {
214
326
  policy: {
215
327
  maxGeneration: maxGen.value,
328
+ batchMaxGeneration,
216
329
  tokenBudget: tokenBudget.value,
217
330
  deferOnBatchGated,
218
331
  keywordHeuristic,
219
332
  p3SameFileDefer,
333
+ maxAmplification: maxAmplification.value,
334
+ convergenceWindow: convergenceWindow.value === UNLIMITED ? 3 : convergenceWindow.value,
220
335
  },
221
336
  policyName,
222
337
  bothUncapped,
@@ -237,6 +352,18 @@ export function admitsGeneration(generation, policy) {
237
352
  return generation <= policy.maxGeneration;
238
353
  }
239
354
 
355
+ /**
356
+ * P3 batches may aggregate deferred findings only through this finite ceiling.
357
+ * The resulting batch must record its maximum member generation for auditability.
358
+ *
359
+ * @param {number} generation
360
+ * @param {CascadePolicy} policy
361
+ * @returns {boolean}
362
+ */
363
+ export function admitsBatchGeneration(generation, policy) {
364
+ return generation <= policy.batchMaxGeneration;
365
+ }
366
+
240
367
  /**
241
368
  * @param {number} projectedSpend - BATCH_TOKEN_SPEND if this unit were admitted.
242
369
  * @param {CascadePolicy} policy
@@ -247,6 +374,19 @@ export function admitsTokenSpend(projectedSpend, policy) {
247
374
  return projectedSpend <= policy.tokenBudget;
248
375
  }
249
376
 
377
+ /**
378
+ * Compute the observable cascade amplification signal and opt-in bound state.
379
+ * The bound is deliberately not an admission decision: callers only apply it
380
+ * to same-lineage refinements, never to unrelated or high-value findings.
381
+ */
382
+ export function evaluateAmplification(mergedUnits, findingsSpawned, policy) {
383
+ const ratio = mergedUnits > 0 ? findingsSpawned / mergedUnits : 0;
384
+ return {
385
+ ratio,
386
+ exceedsBound: policy.maxAmplification !== null && ratio > policy.maxAmplification,
387
+ };
388
+ }
389
+
250
390
  /**
251
391
  * Evaluate the Step 4C rule chain for a single cascade-spawned finding.
252
392
  * Mirrors `commands/orchestrate/phase-4-execution.md` Step 4C's "Evaluation
@@ -292,3 +432,98 @@ export function evaluateCascadeFinding(finding, policy) {
292
432
  }
293
433
  return { admit: true, reason: null };
294
434
  }
435
+
436
+ /**
437
+ * Deterministic P3 batching rules mirrored by the orchestration specs.
438
+ * Grouping order is deliberately strongest-context first: a finding can only
439
+ * belong to one group, so earlier rules claim it before broader rules run.
440
+ */
441
+ export const P3_BATCHING_RULES = Object.freeze({
442
+ maxMembers: 8,
443
+ sameFileMinimum: 2,
444
+ sourcePrMinimum: 2,
445
+ defectClassMinimum: 2,
446
+ leafDirectoryMinimum: 3,
447
+ });
448
+
449
+ const HIGH_BLAST_RADIUS = /(?:^|\/)(?:\.env\.example|docker-compose[^/]*|compose[^/]*|index\.[^/]+|main\.[^/]+)$/i;
450
+
451
+ function priorityOf(finding) {
452
+ const labels = (finding.labels || []).map((label) => typeof label === "string" ? label : label?.name);
453
+ return labels.find((label) => /^priority:P[0-3]$/.test(label))?.slice(-2) || labels.find((label) => /^P[0-3]$/.test(label)) || "P3";
454
+ }
455
+
456
+ /** Priority is urgency, not risk: only P0/P1 stay individual for latency. */
457
+ export function batchExclusionReason(finding, dangerZones = []) {
458
+ if (["P0", "P1"].includes(priorityOf(finding))) return "urgency";
459
+ const file = finding.affectedFile || "";
460
+ if (/^infra\/migrations\/.*credit_balance/i.test(file) || /^services\/api\/app\/billing\//i.test(file)) return "domain";
461
+ if (dangerZones.some((zone) => file === zone || file.startsWith(`${zone}/`))) return "domain";
462
+ if (/\/migrations?\//i.test(file) || HIGH_BLAST_RADIUS.test(file)) return "high-blast-radius";
463
+ return classifyBatchSafety(`${finding.title || ""}\n${finding.body || ""}`) === "billing" ? "domain" : null;
464
+ }
465
+
466
+ function sourcePr(body = "") {
467
+ return body.match(/^\*\*Source\*\*: PR #(\d+)\b/m)?.[1] || null;
468
+ }
469
+
470
+ function defectClass(body = "") {
471
+ return body.match(/<!-- FORGE:CLASS: ([a-z0-9]+(?:-[a-z0-9]+)*) -->/)?.[1] || null;
472
+ }
473
+
474
+ function leafDirectory(file = "") {
475
+ const slash = file.lastIndexOf("/");
476
+ return slash > 0 ? file.slice(0, slash) : null;
477
+ }
478
+
479
+ /**
480
+ * Plan P3 batches from already safety-filtered, open, unbatched, undispatched
481
+ * findings. The caller executes these groups and preserves unclaimed findings
482
+ * for a later full-queue sweep.
483
+ */
484
+ export function planP3BatchGroups(findings, rules = P3_BATCHING_RULES, { openBatches = [], dangerZones = [] } = {}) {
485
+ const remaining = new Map(
486
+ findings
487
+ .filter((finding) => finding?.number && finding.affectedFile && !batchExclusionReason(finding, dangerZones))
488
+ .map((finding) => [finding.number, finding]),
489
+ );
490
+ const groups = [];
491
+ const extensions = [];
492
+
493
+ for (const batch of openBatches) {
494
+ const key = batch.affectedFile || batch.key;
495
+ const headroom = rules.maxMembers - (batch.memberCount ?? batch.members?.length ?? 0);
496
+ if (!key || headroom <= 0) continue;
497
+ for (const member of batch.members || []) remaining.delete(typeof member === "object" ? member.number : member);
498
+ const members = [...remaining.values()].filter((finding) => finding.affectedFile === key).slice(0, headroom);
499
+ if (members.length) {
500
+ extensions.push({ batch: batch.number, key, members: members.map((finding) => finding.number) });
501
+ members.forEach((finding) => remaining.delete(finding.number));
502
+ }
503
+ }
504
+
505
+ const claim = (kind, key, minimum) => {
506
+ const byKey = new Map();
507
+ for (const finding of remaining.values()) {
508
+ const groupKey = key(finding);
509
+ if (!groupKey) continue;
510
+ const members = byKey.get(groupKey) || [];
511
+ members.push(finding);
512
+ byKey.set(groupKey, members);
513
+ }
514
+ for (const [groupKey, members] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
515
+ while (members.length >= minimum) {
516
+ const chunk = members.splice(0, rules.maxMembers);
517
+ groups.push({ kind, key: groupKey, members: chunk.map((finding) => finding.number) });
518
+ chunk.forEach((finding) => remaining.delete(finding.number));
519
+ }
520
+ }
521
+ };
522
+
523
+ claim("same-file", (finding) => finding.affectedFile, rules.sameFileMinimum);
524
+ claim("source-pr", (finding) => sourcePr(finding.body), rules.sourcePrMinimum);
525
+ claim("defect-class", (finding) => defectClass(finding.body), rules.defectClassMinimum);
526
+ claim("leaf-directory", (finding) => leafDirectory(finding.affectedFile), rules.leafDirectoryMinimum);
527
+
528
+ return { groups, extensions, ungrouped: [...remaining.keys()] };
529
+ }
@@ -5,12 +5,73 @@ import {
5
5
  CASCADE_PRESETS,
6
6
  DEFAULT_CASCADE_POLICY_NAME,
7
7
  parseIntOrUnlimited,
8
+ parseOptionalPositiveNumber,
8
9
  resolveCascadePolicy,
9
10
  admitsGeneration,
11
+ admitsBatchGeneration,
10
12
  admitsTokenSpend,
11
13
  evaluateCascadeFinding,
14
+ classifyBatchSafety,
15
+ evaluateAmplification,
16
+ batchExclusionReason,
17
+ planP3BatchGroups,
18
+ canDeduplicateAutomatedAlert,
12
19
  } from "./admission.mjs";
13
20
 
21
+ describe("classifyBatchSafety", () => {
22
+ it("classifies the six security findings that previously evaded batching exclusion", () => {
23
+ const fixtures = [
24
+ ["MaintenanceAuth alias bypasses write rate limit", "auth"],
25
+ ["nested bash -c double substitution permits command injection", "injection"],
26
+ ["CTA href needs scheme validation to reject javascript URIs", "scheme"],
27
+ ["Discord markdown code-fence injection from log samples", "injection"],
28
+ ["redact raw psql DETAIL and CONTEXT on migration failure", "redaction"],
29
+ ["PGPASSWORD interpolated into a SQL literal", "credential"],
30
+ ];
31
+ for (const [text, expected] of fixtures) {
32
+ assert.equal(classifyBatchSafety(text), expected, text);
33
+ }
34
+ });
35
+
36
+ it("keeps the documented false positives batchable while matching identifier auth", () => {
37
+ assert.equal(classifyBatchSafety("authority_source docstring fix"), null);
38
+ assert.equal(classifyBatchSafety("**Agent**: Security\n## Problem\nstale docstring count"), null);
39
+ assert.equal(classifyBatchSafety("AdminAuth and authz_check must reject bypasses"), "auth");
40
+ assert.equal(classifyBatchSafety("injection\n<!-- FORGE:CLASS: shell-hardening -->"), "shell-hardening");
41
+ });
42
+ });
43
+
44
+ describe("canDeduplicateAutomatedAlert", () => {
45
+ const canonical = {
46
+ authorType: "Bot",
47
+ authorLogin: "github-actions[bot]",
48
+ title: "Backup restore drill failed",
49
+ generator: "backup-restore-drill.yml",
50
+ trigger: "corrupt-backup-fixture-v1",
51
+ };
52
+
53
+ it("permits only byte-for-byte equivalent machine alerts after title normalization", () => {
54
+ assert.equal(
55
+ canDeduplicateAutomatedAlert(canonical, {
56
+ ...canonical,
57
+ authorLogin: "app[bot]",
58
+ title: " backup restore drill FAILED ",
59
+ }),
60
+ true,
61
+ );
62
+ });
63
+
64
+ it("rejects human reports and differing generators or triggers", () => {
65
+ assert.equal(
66
+ canDeduplicateAutomatedAlert(canonical, { ...canonical, authorType: "User", authorLogin: "person" }),
67
+ false,
68
+ );
69
+ assert.equal(canDeduplicateAutomatedAlert(canonical, { ...canonical, generator: "other.yml" }), false);
70
+ assert.equal(canDeduplicateAutomatedAlert(canonical, { ...canonical, trigger: "other-fixture" }), false);
71
+ assert.equal(canDeduplicateAutomatedAlert(canonical, { ...canonical, generator: undefined }), false);
72
+ });
73
+ });
74
+
14
75
  describe("parseIntOrUnlimited", () => {
15
76
  it("parses a positive integer", () => {
16
77
  assert.deepEqual(parseIntOrUnlimited("3", 1), { value: 3, warning: null });
@@ -39,17 +100,36 @@ describe("parseIntOrUnlimited", () => {
39
100
  });
40
101
  });
41
102
 
103
+ describe("parseOptionalPositiveNumber", () => {
104
+ it("defaults to disabled and accepts a positive decimal", () => {
105
+ assert.deepEqual(parseOptionalPositiveNumber(undefined), { value: null, warning: null });
106
+ assert.deepEqual(parseOptionalPositiveNumber("off"), { value: null, warning: null });
107
+ assert.deepEqual(parseOptionalPositiveNumber("1.5"), { value: 1.5, warning: null });
108
+ });
109
+
110
+ it("disables invalid values with a warning", () => {
111
+ const result = parseOptionalPositiveNumber("0");
112
+ assert.equal(result.value, null);
113
+ assert.match(result.warning, /disabling the bound/);
114
+ });
115
+ });
116
+
42
117
  describe("resolveCascadePolicy — presets", () => {
43
118
  it("defaults to balanced when no config is given (no-op, matches pre-#2234 hardcoded behavior)", () => {
44
119
  const { policy, policyName, warnings } = resolveCascadePolicy();
45
120
  assert.equal(policyName, DEFAULT_CASCADE_POLICY_NAME);
46
- assert.deepEqual(policy, CASCADE_PRESETS.balanced);
121
+ assert.deepEqual(policy, {
122
+ ...CASCADE_PRESETS.balanced,
123
+ maxAmplification: null,
124
+ convergenceWindow: 3,
125
+ });
47
126
  assert.deepEqual(warnings, []);
48
127
  });
49
128
 
50
129
  it("policy: all removes both caps and disables every heuristic", () => {
51
130
  const { policy, warnings } = resolveCascadePolicy({ policy: "all" });
52
131
  assert.equal(policy.maxGeneration, UNLIMITED);
132
+ assert.equal(policy.batchMaxGeneration, 2);
53
133
  assert.equal(policy.tokenBudget, UNLIMITED);
54
134
  assert.equal(policy.deferOnBatchGated, false);
55
135
  assert.equal(policy.keywordHeuristic, false);
@@ -65,6 +145,7 @@ describe("resolveCascadePolicy — presets", () => {
65
145
  it("policy: conservative keeps balanced's shape but lowers the token budget", () => {
66
146
  const { policy } = resolveCascadePolicy({ policy: "conservative" });
67
147
  assert.equal(policy.maxGeneration, 1);
148
+ assert.equal(policy.batchMaxGeneration, 2);
68
149
  assert.equal(policy.tokenBudget, 450000);
69
150
  assert.equal(policy.deferOnBatchGated, true);
70
151
  });
@@ -72,7 +153,11 @@ describe("resolveCascadePolicy — presets", () => {
72
153
  it("unrecognized policy name falls back to balanced with a warning", () => {
73
154
  const { policy, policyName, warnings } = resolveCascadePolicy({ policy: "yolo" });
74
155
  assert.equal(policyName, "balanced");
75
- assert.deepEqual(policy, CASCADE_PRESETS.balanced);
156
+ assert.deepEqual(policy, {
157
+ ...CASCADE_PRESETS.balanced,
158
+ maxAmplification: null,
159
+ convergenceWindow: 3,
160
+ });
76
161
  assert.equal(warnings.length, 1);
77
162
  assert.match(warnings[0], /not one of/);
78
163
  });
@@ -87,6 +172,8 @@ describe("resolveCascadePolicy — granular overrides compose with a preset", ()
87
172
  assert.equal(policy.deferOnBatchGated, true);
88
173
  assert.equal(policy.keywordHeuristic, true);
89
174
  assert.equal(policy.p3SameFileDefer, true);
175
+ assert.equal(policy.maxAmplification, null);
176
+ assert.equal(policy.convergenceWindow, 3);
90
177
  });
91
178
 
92
179
  it("granular boolean overrides on top of the all preset", () => {
@@ -104,6 +191,32 @@ describe("resolveCascadePolicy — granular overrides compose with a preset", ()
104
191
  assert.equal(admitsGeneration(3, policy), true);
105
192
  assert.equal(admitsGeneration(4, policy), false);
106
193
  });
194
+
195
+ it("keeps batching finite even when explicit cascade admission is unlimited", () => {
196
+ const { policy } = resolveCascadePolicy({ policy: "all" });
197
+ assert.equal(admitsBatchGeneration(2, policy), true);
198
+ assert.equal(admitsBatchGeneration(3, policy), false);
199
+ });
200
+
201
+ it("allows a repository to set a different finite batching ceiling", () => {
202
+ const { policy } = resolveCascadePolicy({ batch_max_generation: 3 });
203
+ assert.equal(policy.batchMaxGeneration, 3);
204
+ assert.equal(admitsBatchGeneration(3, policy), true);
205
+ assert.equal(admitsBatchGeneration(4, policy), false);
206
+ });
207
+ });
208
+
209
+ describe("evaluateAmplification", () => {
210
+ it("reports the running ratio without bounding the default policy", () => {
211
+ const { policy } = resolveCascadePolicy();
212
+ assert.deepEqual(evaluateAmplification(2, 3, policy), { ratio: 1.5, exceedsBound: false });
213
+ });
214
+
215
+ it("flags an opt-in bound only after the ratio exceeds it", () => {
216
+ const { policy } = resolveCascadePolicy({ max_amplification: 1 });
217
+ assert.equal(evaluateAmplification(2, 2, policy).exceedsBound, false);
218
+ assert.equal(evaluateAmplification(2, 3, policy).exceedsBound, true);
219
+ });
107
220
  });
108
221
 
109
222
  describe("resolveCascadePolicy — token_budget deprecated-alias fallback", () => {
@@ -263,3 +376,71 @@ describe("evaluateCascadeFinding — Step 4C rule-chain parity", () => {
263
376
  assert.match(result.reason, /token budget exhausted/);
264
377
  });
265
378
  });
379
+
380
+ describe("planP3BatchGroups — concern-level P3 batching", () => {
381
+ const finding = (number, affectedFile, body = "") => ({ number, affectedFile, body });
382
+
383
+ it("keeps same-file grouping ahead of every broader key", () => {
384
+ const plan = planP3BatchGroups([
385
+ finding(1, "infra/monitoring/a.yml", "**Source**: PR #42"),
386
+ finding(2, "infra/monitoring/a.yml", "**Source**: PR #42"),
387
+ finding(3, "infra/monitoring/b.yml", "**Source**: PR #42"),
388
+ ]);
389
+ assert.deepEqual(plan.groups, [
390
+ { kind: "same-file", key: "infra/monitoring/a.yml", members: [1, 2] },
391
+ ]);
392
+ assert.deepEqual(plan.ungrouped, [3]);
393
+ });
394
+
395
+ it("groups all remaining findings from one source PR cohort", () => {
396
+ const plan = planP3BatchGroups([
397
+ finding(1, "infra/monitoring/a.yml", "**Source**: PR #42"),
398
+ finding(2, "infra/monitoring/b.yml", "**Source**: PR #42"),
399
+ finding(3, "scripts/a.sh", "**Source**: PR #42"),
400
+ finding(4, "README.md", "**Source**: PR #42"),
401
+ ]);
402
+ assert.deepEqual(plan.groups, [
403
+ { kind: "source-pr", key: "42", members: [1, 2, 3, 4] },
404
+ ]);
405
+ assert.deepEqual(plan.ungrouped, []);
406
+ });
407
+
408
+ it("groups explicit defect classes across files", () => {
409
+ const plan = planP3BatchGroups([
410
+ finding(1, "infra/monitoring/a.yml", "<!-- FORGE:CLASS: fail-loud-check -->"),
411
+ finding(2, "scripts/check.sh", "<!-- FORGE:CLASS: fail-loud-check -->"),
412
+ ]);
413
+ assert.deepEqual(plan.groups, [
414
+ { kind: "defect-class", key: "fail-loud-check", members: [1, 2] },
415
+ ]);
416
+ });
417
+
418
+ it("lowers leaf-directory grouping to three and caps batches at eight", () => {
419
+ const three = planP3BatchGroups([
420
+ finding(1, "scripts/a.sh"), finding(2, "scripts/b.sh"), finding(3, "scripts/c.sh"),
421
+ ]);
422
+ assert.deepEqual(three.groups, [{ kind: "leaf-directory", key: "scripts", members: [1, 2, 3] }]);
423
+
424
+ const nine = planP3BatchGroups(Array.from({ length: 9 }, (_, index) => finding(index + 1, `scripts/${index}.sh`)));
425
+ assert.deepEqual(nine.groups, [{ kind: "leaf-directory", key: "scripts", members: [1, 2, 3, 4, 5, 6, 7, 8] }]);
426
+ assert.deepEqual(nine.ungrouped, [9]);
427
+ });
428
+
429
+ it("extends compatible open batches before creating another one", () => {
430
+ const plan = planP3BatchGroups(
431
+ [finding(1, "scripts/a.sh"), finding(2, "scripts/a.sh")],
432
+ undefined,
433
+ { openBatches: [{ number: 99, affectedFile: "scripts/a.sh", members: [1], memberCount: 7 }] },
434
+ );
435
+ assert.deepEqual(plan.extensions, [{ batch: 99, key: "scripts/a.sh", members: [2] }]);
436
+ assert.deepEqual(plan.groups, []);
437
+ });
438
+
439
+ it("uses urgency and path risk instead of excluding every P2 finding", () => {
440
+ assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: "scripts/a.sh" }), null);
441
+ assert.equal(batchExclusionReason({ labels: ["priority:P1"], affectedFile: "scripts/a.sh" }), "urgency");
442
+ assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: "infra/migrations/0333_credit_balance.sql" }), "domain");
443
+ assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: "services/api/app/billing/charge.py" }), "domain");
444
+ assert.equal(batchExclusionReason({ labels: ["priority:P2"], affectedFile: ".env.example" }), "high-blast-radius");
445
+ });
446
+ });