@smartmemory/compose 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/compose/SKILL.md +12 -3
- package/.compose-deps.json +51 -25
- package/README.md +79 -7
- package/bin/compose.js +495 -360
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/deps.js +164 -7
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
package/lib/completion-writer.js
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* <cwd>/.compose/data/locks/feature-<feature_code>.lock
|
|
15
15
|
* (Decision 10; mirrors acquireLock pattern from lib/idempotency.js:42).
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
17
|
+
* The completion gate is lazy-imported inside the writer to avoid circular load
|
|
18
|
+
* (gate → writer → gate). The lazy import is intentional and must stay inside
|
|
19
|
+
* the function. Since COMP-COMPLETION-GATE slice 3 this writer never flips
|
|
20
|
+
* status itself: the completing path (set_status ≠ false) delegates to the
|
|
21
|
+
* gate, and the gate calls back with set_status:false for the record write.
|
|
20
22
|
*
|
|
21
23
|
* No HTTP, no transport awareness.
|
|
22
24
|
*/
|
|
@@ -113,10 +115,16 @@ function notFoundError(code) {
|
|
|
113
115
|
return e;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
e
|
|
119
|
-
|
|
118
|
+
function completionRefusedError(gated) {
|
|
119
|
+
const reasons = (gated.reasons || []).join('; ') || 'refused';
|
|
120
|
+
const e = new Error(
|
|
121
|
+
`completion-writer: completion of "${gated.featureCode}" refused at ${gated.refusedAt} — ${reasons}. ` +
|
|
122
|
+
`Nothing was written.`,
|
|
123
|
+
);
|
|
124
|
+
e.code = 'COMPLETION_REFUSED';
|
|
125
|
+
e.refusedAt = gated.refusedAt;
|
|
126
|
+
e.reasons = gated.reasons || [];
|
|
127
|
+
if (gated.error) e.cause = gated.error;
|
|
120
128
|
return e;
|
|
121
129
|
}
|
|
122
130
|
|
|
@@ -287,6 +295,34 @@ export async function recordCompletion(cwd, args) {
|
|
|
287
295
|
// 1. Validate
|
|
288
296
|
validate(args);
|
|
289
297
|
|
|
298
|
+
// COMP-COMPLETION-GATE slice 3 (AC-7, Decision 2): a completion that moves
|
|
299
|
+
// the feature goes THROUGH the gate — evidence, guarded transition, then the
|
|
300
|
+
// gate performs every write (record, status, ROADMAP, vision, events) in
|
|
301
|
+
// §2.3a order. This writer no longer flips status itself: `setFeatureStatus`
|
|
302
|
+
// refuses COMPLETE unconditionally (AC-9), and the only module authorized to
|
|
303
|
+
// raw-write COMPLETE is the gate. `set_status:false` is the record-only path
|
|
304
|
+
// the gate itself calls back into (step 1 of §2.3a) and that evidence-only
|
|
305
|
+
// callers use; it never touches status. No recursion: the gate always passes
|
|
306
|
+
// `set_status:false`.
|
|
307
|
+
if (args.set_status !== false) {
|
|
308
|
+
const { completionGate } = await import('./completion-gate.js');
|
|
309
|
+
const gated = await completionGate({
|
|
310
|
+
featureCode: args.feature_code,
|
|
311
|
+
...(args.commit_sha === undefined || args.commit_sha === null ? {} : { commitSha: args.commit_sha }),
|
|
312
|
+
testsPass: args.tests_pass,
|
|
313
|
+
filesChanged: args.files_changed,
|
|
314
|
+
notes: args.notes,
|
|
315
|
+
force: args.force,
|
|
316
|
+
builtVia: args.built_via,
|
|
317
|
+
idempotencyKey: args.idempotency_key,
|
|
318
|
+
workspaceRoot: cwd,
|
|
319
|
+
...(args.evidence_root ? { evidenceRoot: args.evidence_root } : {}),
|
|
320
|
+
...(args.mode ? { mode: args.mode } : {}),
|
|
321
|
+
});
|
|
322
|
+
if (!gated.ok) throw completionRefusedError({ featureCode: args.feature_code, ...gated });
|
|
323
|
+
return gated.result;
|
|
324
|
+
}
|
|
325
|
+
|
|
290
326
|
// 2. Normalize SHA (omitted/null ⇒ NULL_SHA sentinel — commit-less completion)
|
|
291
327
|
const commit_sha = (args.commit_sha === undefined || args.commit_sha === null)
|
|
292
328
|
? NULL_SHA
|
|
@@ -366,46 +402,10 @@ export async function recordCompletion(cwd, args) {
|
|
|
366
402
|
...(args.built_via ? { built_via: args.built_via } : {}),
|
|
367
403
|
});
|
|
368
404
|
|
|
369
|
-
// 5i.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (set_status && feature.status !== 'COMPLETE') {
|
|
374
|
-
const fromStatus = feature.status;
|
|
375
|
-
// Terminal states (KILLED, SUPERSEDED) have no valid outgoing transitions.
|
|
376
|
-
// We deliberately do NOT force for terminal states so that the transition
|
|
377
|
-
// policy enforcement fires and produces the STATUS_FLIP_AFTER_COMPLETION_RECORDED
|
|
378
|
-
// error (test #11 / Decision 4). For non-terminal states we pass force: true
|
|
379
|
-
// so that intermediate-state features (e.g. PLANNED → COMPLETE) succeed without
|
|
380
|
-
// requiring callers to manually walk through IN_PROGRESS first.
|
|
381
|
-
const TERMINAL_STATES = new Set(['KILLED', 'SUPERSEDED']);
|
|
382
|
-
const flipForce = !TERMINAL_STATES.has(fromStatus);
|
|
383
|
-
try {
|
|
384
|
-
// Lazy-import setFeatureStatus to avoid circular load
|
|
385
|
-
// (completion-writer.js must not be statically imported by feature-writer.js)
|
|
386
|
-
const { setFeatureStatus } = await import('./feature-writer.js');
|
|
387
|
-
await setFeatureStatus(cwd, {
|
|
388
|
-
code: feature_code,
|
|
389
|
-
status: 'COMPLETE',
|
|
390
|
-
commit_sha,
|
|
391
|
-
reason: 'record_completion',
|
|
392
|
-
force: flipForce,
|
|
393
|
-
});
|
|
394
|
-
status_changed = { from: fromStatus, to: 'COMPLETE' };
|
|
395
|
-
} catch (flipErr) {
|
|
396
|
-
// Both failure sub-cases (transition rejected AND ROADMAP_PARTIAL_WRITE) rethrow
|
|
397
|
-
// as STATUS_FLIP_AFTER_COMPLETION_RECORDED. The completion record IS persisted (step h).
|
|
398
|
-
throw statusFlipError(
|
|
399
|
-
flipErr.code === 'ROADMAP_PARTIAL_WRITE'
|
|
400
|
-
? `completion-writer: completion recorded for "${feature_code}" but ROADMAP regen failed after status flip. ` +
|
|
401
|
-
`This is the ROADMAP_PARTIAL_WRITE subcase (Decision 4). ` +
|
|
402
|
-
`Recover with \`compose roadmap generate\`.`
|
|
403
|
-
: `completion-writer: completion recorded for "${feature_code}" but status flip to COMPLETE failed. ` +
|
|
404
|
-
`err.cause carries the underlying transition error.`,
|
|
405
|
-
flipErr,
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
405
|
+
// 5i. No status flip here. The gate owns it (§2.3a step 2) — see the
|
|
406
|
+
// delegation at the top of this function. This branch is record-only.
|
|
407
|
+
const set_status = false;
|
|
408
|
+
const status_changed = null;
|
|
409
409
|
|
|
410
410
|
// 5j. Audit event (not appended for idempotent no-ops — they return early at 5e)
|
|
411
411
|
const auditEvent = {
|
package/lib/consumer-fanout.js
CHANGED
|
@@ -143,19 +143,80 @@ function cumulativeDiff(cwd, baseCommit) {
|
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Apply one lane's cumulative diff to a temporary index.
|
|
148
|
+
*
|
|
149
|
+
* `--3way`: every lane diffs against the SAME baseline, so once lane 1 has
|
|
150
|
+
* landed, lane 2's hunks still carry baseline context. A plain `git apply`
|
|
151
|
+
* rejects any hunk whose context line was touched by an earlier lane — even
|
|
152
|
+
* when the edits themselves do not overlap (two lanes appending tests to the
|
|
153
|
+
* same file, 2026-08-30: `patch failed: test/version-check.test.js:5`, four
|
|
154
|
+
* paid revise rounds). Three-way falls back to merging against the recorded
|
|
155
|
+
* preimage blobs (`index` lines in the diff), so only a genuine overlap fails.
|
|
156
|
+
* A genuine conflict still throws: `git apply` exits non-zero and the temporary
|
|
157
|
+
* index (with its conflict entries) is discarded by withTemporaryIndex.
|
|
158
|
+
*/
|
|
159
|
+
function applyDiffToIndex(cwd, diff, env) {
|
|
160
|
+
git(cwd, ['apply', '--cached', '--3way', '--binary', '-'], {
|
|
161
|
+
env,
|
|
162
|
+
input: diff,
|
|
163
|
+
timeout: 60_000,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Working tree + one lane diff, merged in a temporary index; returns the tree id. */
|
|
168
|
+
function mergeDiffIntoWorkingTree(cwd, diff) {
|
|
169
|
+
return withTemporaryIndex(cwd, (env) => {
|
|
170
|
+
git(cwd, ['read-tree', 'HEAD'], { env });
|
|
171
|
+
git(cwd, ['add', '-A'], { env });
|
|
172
|
+
applyDiffToIndex(cwd, diff, env);
|
|
173
|
+
return git(cwd, ['write-tree'], { env });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Move the working tree from one snapshot tree to another WITHOUT `git clean`.
|
|
179
|
+
*
|
|
180
|
+
* A two-tree `read-tree -m -u` writes exactly the paths that differ between
|
|
181
|
+
* the trees and leaves untracked and ignored content alone. restoreWorkingTree
|
|
182
|
+
* (baseline restore) cleans first, which is right for a full reset but wrong
|
|
183
|
+
* mid-merge: a lane that edits .gitignore makes previously ignored files
|
|
184
|
+
* visible, and a clean under the new rules would delete files no snapshot ever
|
|
185
|
+
* captured (Codex review of e98ea87, 2026-08-30). The real index is untouched.
|
|
186
|
+
*/
|
|
187
|
+
function checkoutTreeDelta(cwd, fromTree, toTree) {
|
|
188
|
+
withTemporaryIndex(cwd, (env) => {
|
|
189
|
+
git(cwd, ['read-tree', fromTree], { env });
|
|
190
|
+
// A freshly read index has no stat data, so every entry looks modified and
|
|
191
|
+
// the two-tree merge refuses ("not uptodate"). Refresh against the working
|
|
192
|
+
// tree, which matches fromTree by the witness check that precedes this.
|
|
193
|
+
git(cwd, ['update-index', '-q', '--refresh'], { env });
|
|
194
|
+
git(cwd, ['read-tree', '-m', '-u', fromTree, toTree], { env });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Rollback variant of checkoutTreeDelta for a working tree that may be
|
|
200
|
+
* PARTIALLY written (a forward checkout that threw mid-way). `--reset -u`
|
|
201
|
+
* rewrites every path listed in fromTree or toTree to its toTree state without
|
|
202
|
+
* demanding an up-to-date worktree, and — unlike restoreWorkingTree — never
|
|
203
|
+
* runs `git clean`, so untracked and ignored content is untouched (Codex r3).
|
|
204
|
+
* fromTree must be a SUPERSET of anything the forward pass may have written.
|
|
205
|
+
*/
|
|
206
|
+
function rollbackTreeDelta(cwd, fromTree, toTree) {
|
|
207
|
+
withTemporaryIndex(cwd, (env) => {
|
|
208
|
+
git(cwd, ['read-tree', fromTree], { env });
|
|
209
|
+
git(cwd, ['read-tree', '--reset', '-u', toTree], { env });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
146
213
|
function computeWitnessChain(cwd, orderedEntries) {
|
|
147
214
|
return withTemporaryIndex(cwd, (env) => {
|
|
148
215
|
git(cwd, ['read-tree', 'HEAD'], { env });
|
|
149
216
|
git(cwd, ['add', '-A'], { env });
|
|
150
217
|
const chain = [git(cwd, ['write-tree'], { env })];
|
|
151
218
|
for (const entry of orderedEntries) {
|
|
152
|
-
if (entry.diff.length > 0)
|
|
153
|
-
git(cwd, ['apply', '--cached', '--binary', '-'], {
|
|
154
|
-
env,
|
|
155
|
-
input: entry.diff,
|
|
156
|
-
timeout: 60_000,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
219
|
+
if (entry.diff.length > 0) applyDiffToIndex(cwd, entry.diff, env);
|
|
159
220
|
chain.push(git(cwd, ['write-tree'], { env }));
|
|
160
221
|
}
|
|
161
222
|
return chain;
|
|
@@ -808,7 +869,20 @@ export class ConsumerFanoutArtifacts {
|
|
|
808
869
|
// evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
|
|
809
870
|
const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
|
|
810
871
|
const baselineTree = snapshotWorkingTree(this.targetCwd);
|
|
811
|
-
|
|
872
|
+
// A worker that changed NOTHING owes an empty diff, not a missing one, and it
|
|
873
|
+
// must not join the merge: applying nothing leaves the tree identical, so its
|
|
874
|
+
// witness entry repeats its predecessor and the uniqueness check below fires
|
|
875
|
+
// — blocking a merge whose only fault is that one worker had nothing to do.
|
|
876
|
+
// That is the normal outcome whenever a task is fanned out more ways than it
|
|
877
|
+
// divides (observed: 2 workers on a 2-edit single-file doc change, one
|
|
878
|
+
// no-op). computeWitnessChain already skips `apply` for a zero-length diff;
|
|
879
|
+
// it just pushed a tree anyway, so the intent was there and the chain wasn't.
|
|
880
|
+
//
|
|
881
|
+
// Filtered AFTER the completeness accounting below reads `worktreeAccepted`,
|
|
882
|
+
// so a NULL diff (never captured — a real fault) still fails there. Empty and
|
|
883
|
+
// absent are different things and stay that way.
|
|
884
|
+
const mergeParticipants = worktreeAccepted.filter((entry) => (entry.diff?.length ?? 0) > 0);
|
|
885
|
+
const orderedDiffs = mergeParticipants.map((entry) => ({
|
|
812
886
|
dispatchToken: entry.dispatchToken,
|
|
813
887
|
scopedId: entry.scopedId,
|
|
814
888
|
itemIndex: entry.itemIndex,
|
|
@@ -849,7 +923,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
849
923
|
|
|
850
924
|
let witnessChain;
|
|
851
925
|
try {
|
|
852
|
-
witnessChain = computeWitnessChain(this.targetCwd,
|
|
926
|
+
witnessChain = computeWitnessChain(this.targetCwd, mergeParticipants);
|
|
853
927
|
} catch (error) {
|
|
854
928
|
recordBlocked(
|
|
855
929
|
'MERGE_WITNESS_PRECOMPUTE_FAILED',
|
|
@@ -983,6 +1057,8 @@ export class ConsumerFanoutArtifacts {
|
|
|
983
1057
|
|
|
984
1058
|
for (let orderedIndex = prefix; orderedIndex < orderedDiffs.length; orderedIndex += 1) {
|
|
985
1059
|
const ordered = orderedDiffs[orderedIndex];
|
|
1060
|
+
// Tree the working tree's TRACKED content currently equals (by witness).
|
|
1061
|
+
let trackedTree = witnessChain[orderedIndex];
|
|
986
1062
|
try {
|
|
987
1063
|
if (typeof this.hooks.insideDiffApply === 'function') {
|
|
988
1064
|
await this.hooks.insideDiffApply({
|
|
@@ -1003,7 +1079,17 @@ export class ConsumerFanoutArtifacts {
|
|
|
1003
1079
|
// not a lockfile in this slice.
|
|
1004
1080
|
const live = this.#currentTransaction(gateToken);
|
|
1005
1081
|
if (live) this.#assertMergeNotDecided(live, gateToken);
|
|
1006
|
-
|
|
1082
|
+
// Same merge algorithm as the witness precompute (temporary index,
|
|
1083
|
+
// three-way), then the merged tree is checked out. Applying straight to
|
|
1084
|
+
// the working tree cannot use --3way (it requires the real index to match).
|
|
1085
|
+
const mergedTree = mergeDiffIntoWorkingTree(this.targetCwd, ordered.diff);
|
|
1086
|
+
// Mark the merged tree as written BEFORE the checkout starts: if the
|
|
1087
|
+
// checkout throws part-way, the rollback still knows every path it may
|
|
1088
|
+
// have touched. A rollback must transition from that tree, never from a
|
|
1089
|
+
// fresh snapshot (which would reclassify newly un-ignored files as
|
|
1090
|
+
// tracked and delete them on the way back to baseline — Codex r2/r3).
|
|
1091
|
+
trackedTree = mergedTree;
|
|
1092
|
+
checkoutTreeDelta(this.targetCwd, witnessChain[orderedIndex], mergedTree);
|
|
1007
1093
|
const landedTree = snapshotWorkingTree(this.targetCwd);
|
|
1008
1094
|
const expectedTree = witnessChain[orderedIndex + 1];
|
|
1009
1095
|
if (landedTree !== expectedTree) {
|
|
@@ -1020,7 +1106,15 @@ export class ConsumerFanoutArtifacts {
|
|
|
1020
1106
|
// as-is (no baseline restore / block) rather than mislabeling it as an
|
|
1021
1107
|
// apply failure over a round another party already owns.
|
|
1022
1108
|
if (error instanceof ConsumerMergeDecisionError) throw error;
|
|
1023
|
-
|
|
1109
|
+
// Roll back by tree delta from the last tree we may have WRITTEN, never
|
|
1110
|
+
// by clean and never from a fresh snapshot: the failing lane may have
|
|
1111
|
+
// just un-ignored files that no snapshot holds. Reset-style so a
|
|
1112
|
+
// partially written checkout rolls back too.
|
|
1113
|
+
rollbackTreeDelta(this.targetCwd, trackedTree, baselineTree);
|
|
1114
|
+
const restored = snapshotWorkingTree(this.targetCwd);
|
|
1115
|
+
if (restored !== baselineTree) {
|
|
1116
|
+
throw new Error(`consumer merge rollback verification failed: expected ${baselineTree}, got ${restored}`);
|
|
1117
|
+
}
|
|
1024
1118
|
this.#mutate(() => {
|
|
1025
1119
|
const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
|
|
1026
1120
|
// Never overwrite a concurrent decision, even on the error path.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coverage-gate.js — COMP-COVERAGE-GATE slice 2.
|
|
3
|
+
*
|
|
4
|
+
* The check half of the feature. Slice 1 made the mutating surface DECLARED
|
|
5
|
+
* (`effect`/`writes` on every tool definition, derived by lib/tool-inventory.js);
|
|
6
|
+
* this module cross-checks that declaration against the two lists that are
|
|
7
|
+
* supposed to govern it — lib/canon-registry.js entries and
|
|
8
|
+
* server/mcp-tool-policy.js profile policy — and reports what neither accounts
|
|
9
|
+
* for.
|
|
10
|
+
*
|
|
11
|
+
* Pure, no I/O. Callers supply the three inputs (shape template:
|
|
12
|
+
* server/mcp-tool-policy.js).
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS ANSWERS. "Could this tool ever be enforced?" — not "was this call
|
|
15
|
+
* allowed?". It is a static check over declarations and adds NO enforcement
|
|
16
|
+
* point (design Decision 3); the runtime points (`hook`, `ship`, the MCP
|
|
17
|
+
* CallTool dispatch) are untouched, and the blast radius is validate_project's
|
|
18
|
+
* output.
|
|
19
|
+
*
|
|
20
|
+
* ADVISORY, WITH ONE HARD TIER (design Decision 2). Only MISSING_EFFECT is an
|
|
21
|
+
* error: it is unambiguous and one-line-fixable. C2–C4 encode conventions that
|
|
22
|
+
* can have legitimate exceptions, and a gate that blocks on a judgment call
|
|
23
|
+
* gets disabled.
|
|
24
|
+
*
|
|
25
|
+
* EVERY FINDING CARRIES A REMEDIATION, NOT A VERDICT (design Decision 4) — a
|
|
26
|
+
* specific instruction naming the file and the list to edit. That is the one
|
|
27
|
+
* PolicyGuide (arXiv:2608.19861 §3.3) result that transfers at zero cost:
|
|
28
|
+
* returning the required NEXT ACTION changes agent behavior where a bare
|
|
29
|
+
* allow/deny does not.
|
|
30
|
+
*
|
|
31
|
+
* HONEST LIMIT. This checks declarations, not behavior. A tool declaring
|
|
32
|
+
* `writes: []` while writing feature.json through a helper passes every check
|
|
33
|
+
* here. Closing that needs runtime write-path attribution — the `ship` point's
|
|
34
|
+
* correlation job, not this one.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* C4 exceptions — mutating tools an implementer-profile session is INTENDED to
|
|
39
|
+
* call, so their absence from IMPLEMENTER_DENY is a decision, not an omission.
|
|
40
|
+
*
|
|
41
|
+
* This list lives next to the check rather than in a doc on purpose: a doc
|
|
42
|
+
* nobody loads is how the three lists drifted in the first place (design, Open
|
|
43
|
+
* Questions). Adding a tool here is a recorded ruling and needs a reason.
|
|
44
|
+
*
|
|
45
|
+
* The bar: does an implementer doing ordinary feature work need it? Recording
|
|
46
|
+
* its own artifacts, notes and iteration state — yes. Granting itself
|
|
47
|
+
* authority, or writing the decision record — no.
|
|
48
|
+
*/
|
|
49
|
+
export const C4_EXCEPTIONS = {
|
|
50
|
+
scaffold_feature: 'implementer creates the feature folder templates it then fills in',
|
|
51
|
+
link_artifact: 'implementer records the artifacts it produced',
|
|
52
|
+
link_features: 'implementer records dependencies it discovered while building',
|
|
53
|
+
write_journal_entry: 'implementer progress notes — non-canon, append-only',
|
|
54
|
+
write_checkpoint: 'implementer checkpoints its own run — non-canon',
|
|
55
|
+
start_iteration_loop: 'the iteration loop is driven BY the implementer',
|
|
56
|
+
report_iteration_result: 'the iteration loop is driven BY the implementer',
|
|
57
|
+
abort_iteration_loop: 'the implementer must be able to stop its own loop',
|
|
58
|
+
add_changelog_entry: 'CHANGELOG entry ships in the same commit as the code (documentation standard)',
|
|
59
|
+
|
|
60
|
+
// The eight judgment writers. NOT an omission — COMP-JUDGMENT-WRITER
|
|
61
|
+
// design.md:137 rules that the write tools "stay implementer/orchestrator-only",
|
|
62
|
+
// with the provenance argument aimed squarely at REVIEWERS (a reviewer writing
|
|
63
|
+
// canon directly would be a second unattributed author). C4 raised all eight
|
|
64
|
+
// on its first run because the ruling lived only in prose; recording it here
|
|
65
|
+
// is the closure. Reversing it is a deliberate revisit of that design, not a
|
|
66
|
+
// coverage fix — and `test/judgment-writer-mcp.test.js` asserts the current
|
|
67
|
+
// behavior directly, so a reversal has one obvious place to start.
|
|
68
|
+
judgment_position_create: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
69
|
+
judgment_position_amend: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
70
|
+
judgment_joint_add: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
71
|
+
judgment_transition: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
72
|
+
judgment_ledger_append: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
73
|
+
judgment_person_write: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
74
|
+
judgment_situation_write: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
75
|
+
judgment_goal_write: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Findings are reported in this order — most actionable first. */
|
|
79
|
+
const CODE_RANK = ['MISSING_EFFECT', 'UNGATED_MUTATION', 'ORPHAN_REGISTRY_TOOL', 'UNCOVERED_WRITE'];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {object} args
|
|
83
|
+
* @param {{read:string[], mutating:string[], setup:string[],
|
|
84
|
+
* undeclared:Array<{name:string,reason:string}>,
|
|
85
|
+
* writesByTool:Record<string,string[]>}} args.inventory
|
|
86
|
+
* — lib/tool-inventory.js loadToolInventory() result.
|
|
87
|
+
* @param {Array<{id:string, display?:string, tools:string[]}>} args.registry
|
|
88
|
+
* — lib/canon-registry.js canonEntries().
|
|
89
|
+
* @param {{PROFILE_POLICY:object, PHASE_REFINEMENT?:object}} args.policy
|
|
90
|
+
* — server/mcp-tool-policy.js.
|
|
91
|
+
* @returns {{ findings: Array<{code:string, tool:string, path?:string,
|
|
92
|
+
* severity:'error'|'warning'|'info', remediation:string}> }}
|
|
93
|
+
*/
|
|
94
|
+
export function checkAuthorizationCoverage({ inventory, registry, policy } = {}) {
|
|
95
|
+
const findings = [];
|
|
96
|
+
|
|
97
|
+
const inv = inventory || {};
|
|
98
|
+
const undeclared = Array.isArray(inv.undeclared) ? inv.undeclared : [];
|
|
99
|
+
const mutating = Array.isArray(inv.mutating) ? inv.mutating : [];
|
|
100
|
+
const writesByTool = inv.writesByTool || {};
|
|
101
|
+
const entries = Array.isArray(registry) ? registry : [];
|
|
102
|
+
|
|
103
|
+
// Every name the inventory has SEEN, declared or not — an undeclared tool
|
|
104
|
+
// still exists, so it must not also be reported as an orphan (C3).
|
|
105
|
+
const known = new Set([
|
|
106
|
+
...(inv.read || []), ...mutating, ...(inv.setup || []),
|
|
107
|
+
...undeclared.map((u) => u.name),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// ── C1 MISSING_EFFECT (hard) ───────────────────────────────────────────────
|
|
111
|
+
// Fail-closed on the declaration. This is the only check that can fail a
|
|
112
|
+
// validate run, because the fix is a single field with no judgment in it.
|
|
113
|
+
for (const { name, reason } of undeclared) {
|
|
114
|
+
findings.push({
|
|
115
|
+
code: 'MISSING_EFFECT',
|
|
116
|
+
tool: name,
|
|
117
|
+
severity: 'error',
|
|
118
|
+
remediation: `add \`effect: 'read' | 'mutating' | 'setup'\` to the '${name}' definition in server/mcp-tool-defs.js (${reason}); a mutating tool also needs \`writes: []\``,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── C4 UNGATED_MUTATION (the headline) ─────────────────────────────────────
|
|
123
|
+
// PROFILE_POLICY is the only one of the three lists that is actually
|
|
124
|
+
// ENFORCED — at the server/compose-mcp.js CallTool dispatch. A mutating tool
|
|
125
|
+
// named by none of its lists is callable by an implementer-profile session
|
|
126
|
+
// and nothing anywhere records whether that was intended.
|
|
127
|
+
const profilePolicy = (policy && policy.PROFILE_POLICY) || {};
|
|
128
|
+
const denyTools = _toolSet(profilePolicy.implementer);
|
|
129
|
+
const allowTools = _toolSet(profilePolicy.reviewer);
|
|
130
|
+
const refinementTools = new Set();
|
|
131
|
+
for (const set of Object.values((policy && policy.PHASE_REFINEMENT) || {})) {
|
|
132
|
+
for (const t of _iter(set)) refinementTools.add(t);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const tool of mutating) {
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(C4_EXCEPTIONS, tool)) continue;
|
|
137
|
+
if (denyTools.has(tool) || refinementTools.has(tool) || allowTools.has(tool)) continue;
|
|
138
|
+
findings.push({
|
|
139
|
+
code: 'UNGATED_MUTATION',
|
|
140
|
+
tool,
|
|
141
|
+
severity: 'warning',
|
|
142
|
+
remediation: `'${tool}' mutates state but is named by no profile list — add it to IMPLEMENTER_DENY in server/mcp-tool-policy.js, or record why an implementer may call it in C4_EXCEPTIONS (lib/coverage-gate.js)`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── C3 ORPHAN_REGISTRY_TOOL ────────────────────────────────────────────────
|
|
147
|
+
// Reverse drift: the registry names a tool that no longer exists. Catches
|
|
148
|
+
// renames, which are otherwise silent — a renamed tool leaves its old name
|
|
149
|
+
// in a deny message that will never match anything again.
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
for (const tool of entry.tools || []) {
|
|
152
|
+
if (known.has(tool)) continue;
|
|
153
|
+
findings.push({
|
|
154
|
+
code: 'ORPHAN_REGISTRY_TOOL',
|
|
155
|
+
tool,
|
|
156
|
+
path: entry.id,
|
|
157
|
+
severity: 'warning',
|
|
158
|
+
remediation: `canon entry '${entry.id}' names tool '${tool}', which is not in the tool inventory — remove it from lib/canon-registry.js, or restore the tool definition in server/mcp-tool-defs.js if it was renamed`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── C2 UNCOVERED_WRITE (minor) ─────────────────────────────────────────────
|
|
164
|
+
// `entry.tools` has exactly ONE consumer — lib/canon-guard.js joins it into
|
|
165
|
+
// the deny message ("use one of these tools instead"). It is never an
|
|
166
|
+
// allow/deny input. So this keeps the REMEDIATION MESSAGE honest; it does not
|
|
167
|
+
// close a hole. Ranked last for that reason.
|
|
168
|
+
const entryById = new Map(entries.map((e) => [e.id, e]));
|
|
169
|
+
for (const tool of mutating) {
|
|
170
|
+
for (const canonId of writesByTool[tool] || []) {
|
|
171
|
+
const entry = entryById.get(canonId);
|
|
172
|
+
if (!entry) continue; // an unknown canon id is tool-inventory's `undeclared` job
|
|
173
|
+
if ((entry.tools || []).includes(tool)) continue;
|
|
174
|
+
findings.push({
|
|
175
|
+
code: 'UNCOVERED_WRITE',
|
|
176
|
+
tool,
|
|
177
|
+
path: canonId,
|
|
178
|
+
severity: 'info',
|
|
179
|
+
remediation: `'${tool}' declares it writes '${entry.display || canonId}' but is absent from that entry's tool list — add it in lib/canon-registry.js so a denied raw edit is offered it as an alternative`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
findings.sort((a, b) => {
|
|
185
|
+
const r = CODE_RANK.indexOf(a.code) - CODE_RANK.indexOf(b.code);
|
|
186
|
+
return r !== 0 ? r : a.tool.localeCompare(b.tool);
|
|
187
|
+
});
|
|
188
|
+
return { findings };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** PROFILE_POLICY entries carry `tools` as a Set; tolerate an array or absence. */
|
|
192
|
+
function _toolSet(profile) {
|
|
193
|
+
return new Set(_iter(profile && profile.tools));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _iter(v) {
|
|
197
|
+
if (v instanceof Set) return v;
|
|
198
|
+
if (Array.isArray(v)) return v;
|
|
199
|
+
return [];
|
|
200
|
+
}
|