rcf-lite 0.0.1 → 0.7.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.
- package/CHANGELOG.md +288 -0
- package/LICENSE +202 -0
- package/README.md +92 -4
- package/bin/rcf-verify.js +122 -0
- package/bin/rcf.js +174 -0
- package/bin/view-supervisor-child.mjs +14 -0
- package/fixtures/canary-manifest.json +103 -0
- package/guidance/README.md +25 -0
- package/guidance/build-cycle-playbook.md +492 -0
- package/guidance/build-cycle.md +40 -0
- package/guidance/document-model.md +45 -0
- package/guidance/elicitation-playbook.md +442 -0
- package/guidance/harness-template.md +246 -0
- package/guidance/managed/README.md +63 -0
- package/guidance/managed/agent-instructions-block.hash +1 -0
- package/guidance/managed/agent-instructions-block.md +220 -0
- package/guidance/managed/legacy-fragment-hashes.json +22 -0
- package/guidance/manifest.json +21 -0
- package/guidance/overview.md +48 -0
- package/guidance/persona-programme.md +44 -0
- package/package.json +69 -8
- package/rcf/adrs/adr-001.json +25 -0
- package/rcf/adrs/adr-002.json +25 -0
- package/rcf/adrs/adr-003.json +25 -0
- package/rcf/adrs/adr-004.json +20 -0
- package/rcf/adrs/adr-005.json +20 -0
- package/rcf/adrs/adr-006.json +25 -0
- package/rcf/adrs/adr-007.json +25 -0
- package/rcf/adrs/adr-008.json +25 -0
- package/rcf/adrs/adr-009.json +25 -0
- package/rcf/build-sequence.json +11 -0
- package/rcf/code-nodes/cn-001.json +14 -0
- package/rcf/code-nodes/cn-002.json +14 -0
- package/rcf/code-nodes/cn-003.json +15 -0
- package/rcf/code-nodes/cn-004.json +17 -0
- package/rcf/code-nodes/cn-005.json +19 -0
- package/rcf/code-nodes/cn-006.json +17 -0
- package/rcf/code-nodes/cn-007.json +16 -0
- package/rcf/code-nodes/cn-008.json +19 -0
- package/rcf/code-nodes/cn-009.json +16 -0
- package/rcf/code-nodes/cn-010.json +18 -0
- package/rcf/code-nodes/cn-011.json +18 -0
- package/rcf/code-nodes/cn-012.json +18 -0
- package/rcf/code-nodes/cn-013.json +19 -0
- package/rcf/code-nodes/cn-014.json +16 -0
- package/rcf/code-nodes/cn-015.json +16 -0
- package/rcf/code-nodes/cn-016.json +16 -0
- package/rcf/code-nodes/cn-017.json +16 -0
- package/rcf/code-nodes/cn-018.json +15 -0
- package/rcf/code-nodes/cn-019.json +16 -0
- package/rcf/code-nodes/cn-020.json +16 -0
- package/rcf/code-nodes/cn-021.json +16 -0
- package/rcf/code-nodes/cn-022.json +16 -0
- package/rcf/code-nodes/cn-023.json +16 -0
- package/rcf/code-nodes/cn-024.json +16 -0
- package/rcf/code-nodes/cn-025.json +16 -0
- package/rcf/code-nodes/cn-026.json +16 -0
- package/rcf/code-nodes/cn-027.json +16 -0
- package/rcf/code-nodes/cn-028.json +16 -0
- package/rcf/code-nodes/cn-029.json +19 -0
- package/rcf/code-nodes/cn-030.json +14 -0
- package/rcf/code-nodes/cn-031.json +14 -0
- package/rcf/code-nodes/cn-032.json +14 -0
- package/rcf/code-nodes/cn-033.json +14 -0
- package/rcf/code-nodes/cn-034.json +14 -0
- package/rcf/code-nodes/cn-035.json +14 -0
- package/rcf/code-nodes/cn-036.json +14 -0
- package/rcf/code-nodes/cn-037.json +14 -0
- package/rcf/code-nodes/cn-038.json +14 -0
- package/rcf/code-nodes/cn-039.json +14 -0
- package/rcf/code-nodes/cn-040.json +14 -0
- package/rcf/code-nodes/cn-041.json +14 -0
- package/rcf/code-nodes/cn-042.json +14 -0
- package/rcf/code-nodes/cn-043.json +14 -0
- package/rcf/code-nodes/cn-044.json +14 -0
- package/rcf/code-nodes/cn-045.json +14 -0
- package/rcf/code-nodes/cn-046.json +14 -0
- package/rcf/code-nodes/cn-047.json +14 -0
- package/rcf/code-nodes/cn-048.json +14 -0
- package/rcf/code-nodes/cn-049.json +14 -0
- package/rcf/code-nodes/cn-050.json +14 -0
- package/rcf/code-nodes/cn-051.json +14 -0
- package/rcf/code-nodes/cn-052.json +14 -0
- package/rcf/code-nodes/cn-053.json +14 -0
- package/rcf/code-nodes/cn-054.json +14 -0
- package/rcf/code-nodes/cn-055.json +14 -0
- package/rcf/code-nodes/cn-056.json +14 -0
- package/rcf/code-nodes/cn-057.json +14 -0
- package/rcf/fbs/fbs-001.json +49 -0
- package/rcf/fbs/fbs-002.json +42 -0
- package/rcf/fbs/fbs-003.json +37 -0
- package/rcf/fbs/fbs-004.json +39 -0
- package/rcf/fbs/fbs-005.json +38 -0
- package/rcf/fbs/fbs-006.json +48 -0
- package/rcf/fbs/fbs-007.json +39 -0
- package/rcf/fbs/fbs-008.json +40 -0
- package/rcf/fbs/fbs-009.json +36 -0
- package/rcf/fbs/fbs-010.json +41 -0
- package/rcf/fbs/fbs-011.json +36 -0
- package/rcf/fbs/fbs-012.json +46 -0
- package/rcf/fbs/fbs-013.json +42 -0
- package/rcf/fbs/fbs-014.json +49 -0
- package/rcf/fbs/fbs-015.json +32 -0
- package/rcf/manifest.json +17 -0
- package/rcf/prd.json +47 -0
- package/rcf/requirements/req-001.json +19 -0
- package/rcf/requirements/req-002.json +19 -0
- package/rcf/requirements/req-003.json +19 -0
- package/rcf/requirements/req-004.json +19 -0
- package/rcf/requirements/req-005.json +19 -0
- package/rcf/requirements/req-006.json +19 -0
- package/rcf/requirements/req-007.json +19 -0
- package/rcf/requirements/req-008.json +19 -0
- package/rcf/requirements/req-009.json +19 -0
- package/rcf/tacs/tac-001.json +45 -0
- package/rcf/tacs/tac-002.json +109 -0
- package/rcf/tacs/tac-003.json +40 -0
- package/rcf/tacs/tac-004.json +51 -0
- package/rcf/tacs/tac-005.json +52 -0
- package/rcf/tacs/tac-006.json +104 -0
- package/rcf/tacs/tac-007.json +38 -0
- package/rcf/tacs/tac-008.json +51 -0
- package/rcf/tad.json +51 -0
- package/rcf/test-suites/PENDING.md +23 -0
- package/rcf/test-suites/ts-001.json +38 -0
- package/rcf/test-suites/ts-002.json +38 -0
- package/rcf/test-suites/ts-003.json +43 -0
- package/rcf/test-suites/ts-004.json +44 -0
- package/rcf/test-suites/ts-005.json +30 -0
- package/rcf/test-suites/ts-006.json +36 -0
- package/rcf/test-suites/ts-007.json +43 -0
- package/rcf/test-suites/ts-008.json +37 -0
- package/rcf/test-suites/ts-009.json +38 -0
- package/rcf/test-suites/ts-010.json +38 -0
- package/rcf/test-suites/ts-011.json +44 -0
- package/rcf/test-suites/ts-012.json +36 -0
- package/rcf/test-suites/ts-013.json +38 -0
- package/rcf/test-suites/ts-014.json +38 -0
- package/rcf/test-suites/ts-015.json +38 -0
- package/rcf/test-suites/ts-016.json +37 -0
- package/rcf/test-suites/ts-017.json +38 -0
- package/rcf/test-suites/ts-018.json +38 -0
- package/rcf/test-suites/ts-019.json +37 -0
- package/rcf/test-suites/ts-020.json +46 -0
- package/rcf/test-suites/ts-021.json +46 -0
- package/rcf/test-suites/ts-022.json +46 -0
- package/rcf/test-suites/ts-023.json +46 -0
- package/rcf/test-suites/ts-024.json +46 -0
- package/rcf/test-suites/ts-025.json +52 -0
- package/rcf/user-stories/us-101.json +40 -0
- package/rcf/user-stories/us-102.json +40 -0
- package/rcf/user-stories/us-201.json +40 -0
- package/rcf/user-stories/us-202.json +40 -0
- package/rcf/user-stories/us-203.json +32 -0
- package/rcf/user-stories/us-301.json +40 -0
- package/rcf/user-stories/us-302.json +40 -0
- package/rcf/user-stories/us-303.json +40 -0
- package/rcf/user-stories/us-304.json +40 -0
- package/rcf/user-stories/us-401.json +40 -0
- package/rcf/user-stories/us-402.json +40 -0
- package/rcf/user-stories/us-403.json +40 -0
- package/rcf/user-stories/us-501.json +40 -0
- package/rcf/user-stories/us-502.json +40 -0
- package/rcf/user-stories/us-503.json +40 -0
- package/rcf/user-stories/us-601.json +40 -0
- package/rcf/user-stories/us-602.json +40 -0
- package/rcf/user-stories/us-701.json +40 -0
- package/rcf/user-stories/us-702.json +40 -0
- package/rcf/user-stories/us-801.json +49 -0
- package/rcf/user-stories/us-802.json +49 -0
- package/rcf/user-stories/us-803.json +49 -0
- package/rcf/user-stories/us-804.json +49 -0
- package/rcf/user-stories/us-805.json +49 -0
- package/rcf/user-stories/us-901.json +40 -0
- package/src/.gitkeep +0 -0
- package/src/browser-verify/auth-smoke.js +109 -0
- package/src/browser-verify/index.js +29 -0
- package/src/browser-verify/invariants.js +336 -0
- package/src/browser-verify/manifest-writer.js +189 -0
- package/src/browser-verify/runner.js +171 -0
- package/src/build/bundle.js +198 -0
- package/src/build/formatters/json.js +18 -0
- package/src/build/formatters/markdown.js +406 -0
- package/src/build/index.js +14 -0
- package/src/build/mark.js +177 -0
- package/src/build/queue.js +285 -0
- package/src/cli/browser-verify.js +231 -0
- package/src/cli/build.js +584 -0
- package/src/cli/coverage.js +219 -0
- package/src/cli/create.js +355 -0
- package/src/cli/delete.js +127 -0
- package/src/cli/design.js +353 -0
- package/src/cli/doctor.js +548 -0
- package/src/cli/fbs.js +171 -0
- package/src/cli/finalise.js +377 -0
- package/src/cli/guidance.js +206 -0
- package/src/cli/help.js +156 -0
- package/src/cli/impact.js +119 -0
- package/src/cli/init.js +282 -0
- package/src/cli/intake.js +153 -0
- package/src/cli/link.js +128 -0
- package/src/cli/mcp.js +160 -0
- package/src/cli/preflight.js +220 -0
- package/src/cli/read.js +162 -0
- package/src/cli/req-baseline.js +269 -0
- package/src/cli/req-classify.js +135 -0
- package/src/cli/review.js +295 -0
- package/src/cli/test-suite.js +216 -0
- package/src/cli/trace.js +172 -0
- package/src/cli/ui-baseline.js +292 -0
- package/src/cli/ui-classify.js +108 -0
- package/src/cli/update.js +197 -0
- package/src/cli/validate.js +168 -0
- package/src/cli/view.js +452 -0
- package/src/core/baseline-catalog/data/auth.json +42 -0
- package/src/core/baseline-catalog/data/http-api.json +42 -0
- package/src/core/baseline-catalog/data/notifications.json +33 -0
- package/src/core/baseline-catalog/data/persistence.json +33 -0
- package/src/core/baseline-catalog/data/web-ui.json +60 -0
- package/src/core/baseline-catalog/index.js +121 -0
- package/src/core/errors/index.js +167 -0
- package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
- package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
- package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
- package/src/core/isolation/index.js +60 -0
- package/src/core/mcp/framing.js +103 -0
- package/src/core/mcp/index.js +8 -0
- package/src/core/mcp/server.js +228 -0
- package/src/core/patterns/register-canary.js +209 -0
- package/src/core/patterns/req-shapes.js +158 -0
- package/src/core/patterns/services.js +358 -0
- package/src/core/patterns/ui-shapes.js +166 -0
- package/src/core/store/cn-resolve.js +134 -0
- package/src/core/store/derive-deps.js +93 -0
- package/src/core/store/ids.js +78 -0
- package/src/core/store/index.js +20 -0
- package/src/core/store/init.js +255 -0
- package/src/core/store/loader.js +211 -0
- package/src/core/store/tp-resolve.js +176 -0
- package/src/core/store/validator.js +191 -0
- package/src/core/store/walker.js +898 -0
- package/src/core/store/writer.js +1849 -0
- package/src/design/index.js +11 -0
- package/src/design/writer.js +271 -0
- package/src/finalise/detect.js +129 -0
- package/src/finalise/index.js +17 -0
- package/src/finalise/ingest.js +119 -0
- package/src/finalise/install.js +119 -0
- package/src/finalise/ship-without-verified.js +131 -0
- package/src/finalise/spawn.js +84 -0
- package/src/intake/fidelity.js +105 -0
- package/src/intake/index.js +6 -0
- package/src/intake/manifest-writer.js +100 -0
- package/src/intake/orchestrator.js +138 -0
- package/src/intake/validate.js +80 -0
- package/src/mcp/map-errors.js +131 -0
- package/src/mcp/prompts.js +49 -0
- package/src/mcp/resources.js +244 -0
- package/src/mcp/tools.js +1204 -0
- package/src/preflight/design-shapes.js +185 -0
- package/src/preflight/index.js +90 -0
- package/src/preflight/manifest-writer.js +264 -0
- package/src/preflight/scanner.js +206 -0
- package/src/preflight/secrets.js +134 -0
- package/src/preflight/session.js +246 -0
- package/src/query/attestation.js +285 -0
- package/src/query/coverage.js +308 -0
- package/src/query/formatters/json.js +21 -0
- package/src/query/formatters/mermaid.js +209 -0
- package/src/query/formatters/table.js +203 -0
- package/src/query/impact.js +173 -0
- package/src/query/index.js +9 -0
- package/src/query/trace.js +345 -0
- package/src/register-canary/fixture-loader.js +87 -0
- package/src/register-canary/index.js +10 -0
- package/src/register-canary/record-writer.js +132 -0
- package/src/register-canary/runner.js +156 -0
- package/src/req-baseline/gate.js +86 -0
- package/src/req-baseline/index.js +27 -0
- package/src/req-baseline/open-candidates.js +143 -0
- package/src/req-baseline/opt-out.js +195 -0
- package/src/req-baseline/sweep.js +230 -0
- package/src/req-detection/classifier.js +181 -0
- package/src/req-detection/index.js +9 -0
- package/src/req-detection/persist.js +55 -0
- package/src/review/index.js +325 -0
- package/src/review/mutation.js +117 -0
- package/src/review/ui-baseline-drift.js +138 -0
- package/src/server/index.js +178 -0
- package/src/server/routes.js +110 -0
- package/src/server/sse.js +118 -0
- package/src/setup/agent-setup.js +362 -0
- package/src/setup/identity-seed.js +104 -0
- package/src/setup/knowledge-seed.js +123 -0
- package/src/setup/managed-block.js +193 -0
- package/src/setup/managed-gitignore.js +166 -0
- package/src/setup/managed-markers.js +49 -0
- package/src/ui-baseline/defaults.js +119 -0
- package/src/ui-baseline/index.js +25 -0
- package/src/ui-baseline/manifest-writer.js +282 -0
- package/src/ui-baseline/session.js +178 -0
- package/src/ui-detection/classifier.js +192 -0
- package/src/verify/chain/index.js +190 -0
- package/src/verify/cli/cleanup.js +61 -0
- package/src/verify/cli/help.js +56 -0
- package/src/verify/cli/mcp.js +98 -0
- package/src/verify/cli/provision.js +71 -0
- package/src/verify/cli/report.js +71 -0
- package/src/verify/cli/run.js +155 -0
- package/src/verify/engine/brief.js +87 -0
- package/src/verify/engine/index.js +177 -0
- package/src/verify/engine/launcher.js +307 -0
- package/src/verify/mcp/tools.js +107 -0
- package/src/verify/profile/index.js +146 -0
- package/src/verify/provision/index.js +256 -0
- package/src/verify/report/index.js +139 -0
- package/src/verify/report/renderer.js +118 -0
- package/src/verify/verdict/index.js +246 -0
- package/src/view/doc-renderers/adr.js +44 -0
- package/src/view/doc-renderers/build-sequence.js +40 -0
- package/src/view/doc-renderers/fbs.js +128 -0
- package/src/view/doc-renderers/helpers.js +159 -0
- package/src/view/doc-renderers/index.js +12 -0
- package/src/view/doc-renderers/prd.js +45 -0
- package/src/view/doc-renderers/req.js +43 -0
- package/src/view/doc-renderers/tac.js +38 -0
- package/src/view/doc-renderers/tad.js +74 -0
- package/src/view/doc-renderers/test-suite.js +45 -0
- package/src/view/doc-renderers/user-story.js +63 -0
- package/src/view/html-page.js +462 -0
- package/src/view/index.js +63 -0
- package/src/view/live-client.js +338 -0
- package/src/view/mermaid-diagram.js +178 -0
- package/src/view/style.css +735 -0
- package/src/view/tree-model.js +152 -0
- package/src/view/vendored/mermaid.min.js +2607 -0
- package/src/view-supervisor/index.js +26 -0
- package/src/view-supervisor/logs.js +32 -0
- package/src/view-supervisor/manifest-writer.js +178 -0
- package/src/view-supervisor/persist-until.js +85 -0
- package/src/view-supervisor/supervisor.js +276 -0
- package/src/watch/index.js +152 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// Pure trace compute. Given a walker-produced TreeModel + a pivot id,
|
|
2
|
+
// walk the graph in one or both directions.
|
|
3
|
+
//
|
|
4
|
+
// Phase 5 §D8 trace-back: cross-links are NOT traversed by --back.
|
|
5
|
+
// From an AC, back walks the parent-child edge to US (not the
|
|
6
|
+
// `fbsByAcId` cross-link). This keeps --back unambiguous and
|
|
7
|
+
// terminating; cross-link fan-out is what `impact` is for.
|
|
8
|
+
//
|
|
9
|
+
// Phase 5 §D9 --both output shape: {pivot, ancestors, descendants} -
|
|
10
|
+
// two arrays around a single pivot id; not one merged graph. The
|
|
11
|
+
// pivot appears in neither array (it's the anchor).
|
|
12
|
+
//
|
|
13
|
+
// Phase 5 §4.2 test surface documents the expected node/edge shape:
|
|
14
|
+
// - Forward from PRD returns full descendant tree down to TCs.
|
|
15
|
+
// - Forward from AC follows all cross-links (tsByAcId, tcsByAcId,
|
|
16
|
+
// fbsByAcId) - but a delivering FBS is a cross-link LEAF: trace
|
|
17
|
+
// never traverses through the FBS layer (AC-402-3). The FBS
|
|
18
|
+
// dependency fan-out is impact's business (expandFbsDependents).
|
|
19
|
+
// - Back from TC walks TC -> TS -> AC -> US -> REQ -> PRD (AC noted
|
|
20
|
+
// via testCases[].acId; back-walk continues through parentByChild).
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {import('#core/store/walker.js').TreeModel} TreeModel
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {'forward' | 'back' | 'both'} TraceDirection
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} TraceNode
|
|
32
|
+
* @property {string} id
|
|
33
|
+
* @property {string} kind
|
|
34
|
+
* @property {number} depth - 0 for pivot; positive for descendants; negative for ancestors
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} TraceEdge
|
|
39
|
+
* @property {string} from
|
|
40
|
+
* @property {string} to
|
|
41
|
+
* @property {'parentChild' | 'crossLink'} kind
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} TraceResult
|
|
46
|
+
* @property {string} pivot - the id queried
|
|
47
|
+
* @property {TraceDirection} direction
|
|
48
|
+
* @property {boolean} found - false when the pivot id is unknown
|
|
49
|
+
* @property {TraceNode[]} [nodes] - direction forward | back
|
|
50
|
+
* @property {TraceEdge[]} [edges] - direction forward | back
|
|
51
|
+
* @property {TraceNode[]} [ancestors] - direction both
|
|
52
|
+
* @property {TraceNode[]} [descendants] - direction both
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const INLINE_AC_RE = /^AC-/;
|
|
56
|
+
const INLINE_TC_RE = /^TC-/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Return the effective kind of an id. `tree.kindById` covers every
|
|
60
|
+
* standalone doc; inline AC / TC ids are inferred from the id prefix.
|
|
61
|
+
*
|
|
62
|
+
* @param {TreeModel} tree
|
|
63
|
+
* @param {string} id
|
|
64
|
+
* @returns {string | null}
|
|
65
|
+
*/
|
|
66
|
+
export function kindOf(tree, id) {
|
|
67
|
+
const k = tree.kindById.get(id);
|
|
68
|
+
if (k) return k;
|
|
69
|
+
if (INLINE_AC_RE.test(id)) return tree.parentByChild.has(id) ? 'ac' : null;
|
|
70
|
+
if (INLINE_TC_RE.test(id)) return tree.parentByChild.has(id) ? 'tc' : null;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute a trace from `id` in the requested direction. Unknown pivot
|
|
76
|
+
* returns `{found: false}`; the handler layer converts this to exit 2.
|
|
77
|
+
*
|
|
78
|
+
* @param {TreeModel} tree
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {string} opts.id
|
|
81
|
+
* @param {TraceDirection} [opts.direction]
|
|
82
|
+
* @param {boolean} [opts.includeCode] - Phase 10: extend the forward fan-out
|
|
83
|
+
* into Code Nodes (AC -> implementing CN -> transitive dependents). Opt-in
|
|
84
|
+
* so spec-only queries stay byte-identical (spec D9).
|
|
85
|
+
* @param {boolean} [opts.expandFbsDependents] - opt-in (impact only): expand
|
|
86
|
+
* an FBS node's dependency fan-out (dependentsByFbsId). Default OFF: trace
|
|
87
|
+
* is the intent-chain walk, and per AC-402-3 a delivering FBS appears as a
|
|
88
|
+
* cross-link LEAF - trace never traverses through the FBS layer. Impact is
|
|
89
|
+
* the blast-radius question, so `computeImpact` opts in (D7: a dependent of
|
|
90
|
+
* an affected FBS needs re-execution).
|
|
91
|
+
* @returns {TraceResult}
|
|
92
|
+
*/
|
|
93
|
+
export function computeTrace(tree, {
|
|
94
|
+
id, direction = 'forward', includeCode = false, expandFbsDependents = false,
|
|
95
|
+
}) {
|
|
96
|
+
const kind = kindOf(tree, id);
|
|
97
|
+
if (!kind) {
|
|
98
|
+
return { pivot: id, direction, found: false };
|
|
99
|
+
}
|
|
100
|
+
if (direction === 'forward') {
|
|
101
|
+
const { nodes, edges } = walkForward(tree, id, kind, includeCode, expandFbsDependents);
|
|
102
|
+
return { pivot: id, direction, found: true, nodes, edges };
|
|
103
|
+
}
|
|
104
|
+
if (direction === 'back') {
|
|
105
|
+
const { nodes, edges } = walkBack(tree, id, kind);
|
|
106
|
+
return { pivot: id, direction, found: true, nodes, edges };
|
|
107
|
+
}
|
|
108
|
+
// both
|
|
109
|
+
const back = walkBack(tree, id, kind);
|
|
110
|
+
const fwd = walkForward(tree, id, kind, includeCode, expandFbsDependents);
|
|
111
|
+
// Both arrays exclude the pivot; pivot is the anchor between them
|
|
112
|
+
// (spec §D9: two arrays around a single pivot id).
|
|
113
|
+
return {
|
|
114
|
+
pivot: id,
|
|
115
|
+
direction: 'both',
|
|
116
|
+
found: true,
|
|
117
|
+
ancestors: back.nodes.filter((n) => n.id !== id),
|
|
118
|
+
descendants: fwd.nodes.filter((n) => n.id !== id),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Forward walk from pivot. BFS over structural children + cross-link
|
|
124
|
+
* children. Each visited id becomes a node; each traversal step
|
|
125
|
+
* becomes an edge (parentChild or crossLink).
|
|
126
|
+
*
|
|
127
|
+
* @param {TreeModel} tree
|
|
128
|
+
* @param {string} pivot
|
|
129
|
+
* @param {string} pivotKind
|
|
130
|
+
* @param {boolean} [includeCode] - Phase 10 opt-in for the code layer.
|
|
131
|
+
* @param {boolean} [expandFbsDependents] - impact-only opt-in; see
|
|
132
|
+
* `computeTrace`. When off, FBS nodes are leaves (AC-402-3).
|
|
133
|
+
* @returns {{nodes: TraceNode[], edges: TraceEdge[]}}
|
|
134
|
+
*/
|
|
135
|
+
function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDependents = false) {
|
|
136
|
+
/** @type {TraceNode[]} */
|
|
137
|
+
const nodes = [{ id: pivot, kind: pivotKind, depth: 0 }];
|
|
138
|
+
/** @type {TraceEdge[]} */
|
|
139
|
+
const edges = [];
|
|
140
|
+
const seen = new Set([pivot]);
|
|
141
|
+
const depthById = new Map([[pivot, 0]]);
|
|
142
|
+
/** @type {string[]} */
|
|
143
|
+
const queue = [pivot];
|
|
144
|
+
|
|
145
|
+
while (queue.length > 0) {
|
|
146
|
+
const cur = queue.shift();
|
|
147
|
+
if (!cur) break;
|
|
148
|
+
const curDepth = depthById.get(cur) ?? 0;
|
|
149
|
+
const children = forwardChildrenOf(tree, cur, includeCode, expandFbsDependents);
|
|
150
|
+
for (const child of children) {
|
|
151
|
+
// Always emit the edge (even if child already visited via a
|
|
152
|
+
// different parent) so the graph is complete; only enqueue and
|
|
153
|
+
// add-as-node the first time.
|
|
154
|
+
edges.push({ from: cur, to: child.id, kind: child.edgeKind });
|
|
155
|
+
if (seen.has(child.id)) continue;
|
|
156
|
+
seen.add(child.id);
|
|
157
|
+
const childKind = kindOf(tree, child.id);
|
|
158
|
+
if (!childKind) continue;
|
|
159
|
+
const nextDepth = curDepth + 1;
|
|
160
|
+
depthById.set(child.id, nextDepth);
|
|
161
|
+
nodes.push({ id: child.id, kind: childKind, depth: nextDepth });
|
|
162
|
+
queue.push(child.id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { nodes, edges };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Back walk from pivot. Follows `parentByChild` up to the root.
|
|
170
|
+
* D8: cross-links are NOT traversed. Special case for TC: also
|
|
171
|
+
* include the referenced AC (from tc.acId) between TS and US so the
|
|
172
|
+
* back-trace surfaces the AC the TC exercises (§4.2 test surface).
|
|
173
|
+
*
|
|
174
|
+
* @param {TreeModel} tree
|
|
175
|
+
* @param {string} pivot
|
|
176
|
+
* @param {string} pivotKind
|
|
177
|
+
* @returns {{nodes: TraceNode[], edges: TraceEdge[]}}
|
|
178
|
+
*/
|
|
179
|
+
function walkBack(tree, pivot, pivotKind) {
|
|
180
|
+
/** @type {TraceNode[]} */
|
|
181
|
+
const nodes = [{ id: pivot, kind: pivotKind, depth: 0 }];
|
|
182
|
+
/** @type {TraceEdge[]} */
|
|
183
|
+
const edges = [];
|
|
184
|
+
const seen = new Set([pivot]);
|
|
185
|
+
|
|
186
|
+
const addStep = (fromId, toId, edgeKind, depth) => {
|
|
187
|
+
if (!toId || seen.has(toId)) return;
|
|
188
|
+
const k = kindOf(tree, toId);
|
|
189
|
+
if (!k) return;
|
|
190
|
+
seen.add(toId);
|
|
191
|
+
nodes.push({ id: toId, kind: k, depth });
|
|
192
|
+
edges.push({ from: fromId, to: toId, kind: edgeKind });
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// TC special-case: insert AC (via testCases[].acId cross-link) between
|
|
196
|
+
// TS and US in the back trace. Every other segment follows parentByChild.
|
|
197
|
+
if (pivotKind === 'tc') {
|
|
198
|
+
const tsId = tree.parentByChild.get(pivot);
|
|
199
|
+
if (tsId) {
|
|
200
|
+
addStep(pivot, tsId, 'parentChild', -1);
|
|
201
|
+
const ts = tree.byId.get(tsId);
|
|
202
|
+
const tc = ts?.testCases?.find((t) => t.id === pivot);
|
|
203
|
+
const acId = tc?.acId;
|
|
204
|
+
if (acId && !seen.has(acId)) {
|
|
205
|
+
addStep(tsId, acId, 'crossLink', -2);
|
|
206
|
+
// Continue from AC upward via parentByChild.
|
|
207
|
+
let cur = acId;
|
|
208
|
+
let depth = -3;
|
|
209
|
+
while (cur) {
|
|
210
|
+
const parent = tree.parentByChild.get(cur);
|
|
211
|
+
if (!parent) break;
|
|
212
|
+
addStep(cur, parent, 'parentChild', depth);
|
|
213
|
+
cur = parent;
|
|
214
|
+
depth -= 1;
|
|
215
|
+
}
|
|
216
|
+
return { nodes, edges };
|
|
217
|
+
}
|
|
218
|
+
// Fall through to plain TS -> US -> REQ -> PRD walk.
|
|
219
|
+
let cur = tsId;
|
|
220
|
+
let depth = -2;
|
|
221
|
+
while (cur) {
|
|
222
|
+
const parent = tree.parentByChild.get(cur);
|
|
223
|
+
if (!parent) break;
|
|
224
|
+
addStep(cur, parent, 'parentChild', depth);
|
|
225
|
+
cur = parent;
|
|
226
|
+
depth -= 1;
|
|
227
|
+
}
|
|
228
|
+
return { nodes, edges };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Phase 10 (X2 CodeNode bridge): CN special-case. A code node's parents
|
|
233
|
+
// are the ACs it implements (a cross-link, not a parent-child edge).
|
|
234
|
+
// Insert each AC below its US, then continue up via parentByChild
|
|
235
|
+
// (AC -> US -> REQ -> PRD). This is exactly the `rcf trace <path>`
|
|
236
|
+
// backward query: from a source location up to every requirement that
|
|
237
|
+
// location serves.
|
|
238
|
+
if (pivotKind === 'codeNode') {
|
|
239
|
+
const cn = tree.byId.get(pivot);
|
|
240
|
+
let depth = -1;
|
|
241
|
+
for (const acId of cn?.implementsAcIds ?? []) {
|
|
242
|
+
if (seen.has(acId)) continue;
|
|
243
|
+
addStep(pivot, acId, 'crossLink', depth);
|
|
244
|
+
let cur = acId;
|
|
245
|
+
let up = depth - 1;
|
|
246
|
+
while (cur) {
|
|
247
|
+
const parent = tree.parentByChild.get(cur);
|
|
248
|
+
if (!parent || seen.has(parent)) break;
|
|
249
|
+
addStep(cur, parent, 'parentChild', up);
|
|
250
|
+
cur = parent;
|
|
251
|
+
up -= 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { nodes, edges };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let cur = pivot;
|
|
258
|
+
let depth = -1;
|
|
259
|
+
while (cur) {
|
|
260
|
+
const parent = tree.parentByChild.get(cur);
|
|
261
|
+
if (!parent) break;
|
|
262
|
+
addStep(cur, parent, 'parentChild', depth);
|
|
263
|
+
cur = parent;
|
|
264
|
+
depth -= 1;
|
|
265
|
+
}
|
|
266
|
+
return { nodes, edges };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Enumerate forward children of `id`. Combines:
|
|
271
|
+
* - parent-child edges via `childrenByParent`
|
|
272
|
+
* - inline AC ids for US pivots
|
|
273
|
+
* - inline TC ids for TS pivots
|
|
274
|
+
* - cross-links: tsByAcId + fbsByAcId + tcsByAcId for AC pivots
|
|
275
|
+
* - cross-links: usByTacId + FBS-context-refs for TAC pivots
|
|
276
|
+
* - cross-links: FBS-context-refs for ADR pivots
|
|
277
|
+
* - cross-links: dependentsByFbsId for FBS pivots
|
|
278
|
+
*
|
|
279
|
+
* @param {TreeModel} tree
|
|
280
|
+
* @param {string} id
|
|
281
|
+
* @param {boolean} [includeCode] - Phase 10 opt-in for the code layer.
|
|
282
|
+
* @param {boolean} [expandFbsDependents] - impact-only opt-in; see
|
|
283
|
+
* `computeTrace`. When off (trace), an FBS enumerates no children:
|
|
284
|
+
* it is a cross-link leaf per AC-402-3.
|
|
285
|
+
* @returns {{id: string, edgeKind: 'parentChild' | 'crossLink'}[]}
|
|
286
|
+
*/
|
|
287
|
+
function forwardChildrenOf(tree, id, includeCode = false, expandFbsDependents = false) {
|
|
288
|
+
const kind = kindOf(tree, id);
|
|
289
|
+
const doc = tree.byId.get(id);
|
|
290
|
+
/** @type {{id: string, edgeKind: 'parentChild' | 'crossLink'}[]} */
|
|
291
|
+
const out = [];
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
const push = (childId, edgeKind) => {
|
|
294
|
+
if (!childId || seen.has(childId)) return;
|
|
295
|
+
seen.add(childId);
|
|
296
|
+
out.push({ id: childId, edgeKind });
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Parent-child edges (standalone kids).
|
|
300
|
+
for (const c of tree.childrenByParent.get(id) ?? []) push(c, 'parentChild');
|
|
301
|
+
|
|
302
|
+
// Inline children.
|
|
303
|
+
if (kind === 'userStory' && doc) {
|
|
304
|
+
for (const ac of doc.acceptanceCriteria ?? []) push(ac?.id, 'parentChild');
|
|
305
|
+
}
|
|
306
|
+
if (kind === 'testSuite' && doc) {
|
|
307
|
+
for (const tc of doc.testCases ?? []) push(tc?.id, 'parentChild');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Cross-links.
|
|
311
|
+
if (kind === 'ac') {
|
|
312
|
+
for (const tsId of tree.tsByAcId.get(id) ?? []) push(tsId, 'crossLink');
|
|
313
|
+
for (const entry of tree.tcsByAcId.get(id) ?? []) push(entry.tcId, 'crossLink');
|
|
314
|
+
for (const fbsId of tree.fbsByAcId.get(id) ?? []) push(fbsId, 'crossLink');
|
|
315
|
+
// Phase 10 (X2 CodeNode bridge): AC -> implementing Code Nodes. This is
|
|
316
|
+
// the seam that carries a forward trace/impact from spec into source.
|
|
317
|
+
// Opt-in (includeCode) so spec-only queries stay byte-identical.
|
|
318
|
+
if (includeCode) {
|
|
319
|
+
for (const cnId of tree.cnByAcId?.get(id) ?? []) push(cnId, 'crossLink');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Phase 10: CN -> dependent Code Nodes. Forward from a code node is the
|
|
323
|
+
// blast radius: everything that declares this node in its dependencies[].
|
|
324
|
+
if (kind === 'codeNode' && includeCode) {
|
|
325
|
+
for (const depId of tree.dependentsByCnId?.get(id) ?? []) push(depId, 'crossLink');
|
|
326
|
+
}
|
|
327
|
+
if (kind === 'tac') {
|
|
328
|
+
for (const usId of tree.usByTacId.get(id) ?? []) push(usId, 'crossLink');
|
|
329
|
+
for (const fbs of tree.fbsItems) {
|
|
330
|
+
if ((fbs.contextRequirements?.tacIds ?? []).includes(id)) push(fbs.fbsId, 'crossLink');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (kind === 'adr') {
|
|
334
|
+
for (const fbs of tree.fbsItems) {
|
|
335
|
+
if ((fbs.contextRequirements?.adrIds ?? []).includes(id)) push(fbs.fbsId, 'crossLink');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// AC-402-3: in a trace, the FBS layer is never traversed through - a
|
|
339
|
+
// delivering FBS is a cross-link leaf. Impact opts in to the dependency
|
|
340
|
+
// fan-out (D7: dependents of an affected FBS need re-execution).
|
|
341
|
+
if (kind === 'fbs' && expandFbsDependents) {
|
|
342
|
+
for (const depId of tree.dependentsByFbsId.get(id) ?? []) push(depId, 'crossLink');
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Fixture-pack loader for the register canary (spec §7.3).
|
|
2
|
+
//
|
|
3
|
+
// The fixture pack ships in `#core/fixtures/register-canary`.
|
|
4
|
+
// This loader reads each file by id, verifies the shape the runner
|
|
5
|
+
// consumes, and returns the pack in canonical order. Operators can
|
|
6
|
+
// extend the pack via a local `fixtures/register-canary/*.json` in
|
|
7
|
+
// their own project; the loader accepts an override directory for that.
|
|
8
|
+
|
|
9
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
/** Canonical fixture ids that ship in the core pack (spec §7.3). */
|
|
16
|
+
export const DEFAULT_FIXTURE_IDS = Object.freeze([
|
|
17
|
+
'canary-prompt-01',
|
|
18
|
+
'canary-prompt-02',
|
|
19
|
+
'canary-prompt-03',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Locate the core fixture directory. Post-0.7.1 consolidation the core src
|
|
24
|
+
* lives inline under `src/core/`, so the loader (which sits at
|
|
25
|
+
* `src/register-canary/`) can reach the fixture pack via a single relative
|
|
26
|
+
* walk. `RCF_CANARY_FIXTURE_DIR` still overrides for tests and operators.
|
|
27
|
+
*
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function defaultFixtureDir() {
|
|
31
|
+
// src/register-canary/ -> up one -> src/ -> core/fixtures/register-canary.
|
|
32
|
+
// Same relative shape in dev and inside the published tarball because the
|
|
33
|
+
// umbrella ships src/ verbatim (no build step). See package.json `files`.
|
|
34
|
+
const fixtureCandidate = join(here, '..', 'core', 'fixtures', 'register-canary');
|
|
35
|
+
return process.env.RCF_CANARY_FIXTURE_DIR ?? fixtureCandidate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} CanaryFixture
|
|
40
|
+
* @property {string} id
|
|
41
|
+
* @property {string} operatorPrompt
|
|
42
|
+
* @property {Array<{ path: string, content: string }>} [supportingArtefacts]
|
|
43
|
+
* @property {string[]} grantedPermissions
|
|
44
|
+
* @property {number} wordCountBudget
|
|
45
|
+
* @property {string} [notes]
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load a fixture pack from a directory. Reads every *.json file in
|
|
50
|
+
* canonical order; the ids must be unique. Overrides via env
|
|
51
|
+
* `RCF_CANARY_FIXTURE_DIR` (used by tests).
|
|
52
|
+
*
|
|
53
|
+
* @param {string} [dir]
|
|
54
|
+
* @returns {Promise<CanaryFixture[]>}
|
|
55
|
+
*/
|
|
56
|
+
export async function loadFixturePack(dir = defaultFixtureDir()) {
|
|
57
|
+
const files = (await readdir(dir))
|
|
58
|
+
.filter((n) => n.endsWith('.json'))
|
|
59
|
+
.sort();
|
|
60
|
+
const fixtures = [];
|
|
61
|
+
const ids = new Set();
|
|
62
|
+
for (const name of files) {
|
|
63
|
+
const abs = join(dir, name);
|
|
64
|
+
const raw = await readFile(abs, 'utf8');
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(raw);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new Error(`register-canary fixture ${name}: JSON parse failed: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof parsed?.id !== 'string' || parsed.id.length === 0) {
|
|
72
|
+
throw new Error(`register-canary fixture ${name}: missing id`);
|
|
73
|
+
}
|
|
74
|
+
if (ids.has(parsed.id)) {
|
|
75
|
+
throw new Error(`register-canary fixture ${name}: duplicate id ${parsed.id}`);
|
|
76
|
+
}
|
|
77
|
+
ids.add(parsed.id);
|
|
78
|
+
if (typeof parsed?.operatorPrompt !== 'string' || parsed.operatorPrompt.length === 0) {
|
|
79
|
+
throw new Error(`register-canary fixture ${parsed.id}: missing operatorPrompt`);
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(parsed?.grantedPermissions)) {
|
|
82
|
+
throw new Error(`register-canary fixture ${parsed.id}: grantedPermissions must be an array`);
|
|
83
|
+
}
|
|
84
|
+
fixtures.push(parsed);
|
|
85
|
+
}
|
|
86
|
+
return fixtures;
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Public surface for the register-canary runner (spec §7).
|
|
2
|
+
|
|
3
|
+
export { loadFixturePack, DEFAULT_FIXTURE_IDS } from './fixture-loader.js';
|
|
4
|
+
export {
|
|
5
|
+
composeCanaryRecord,
|
|
6
|
+
writeCanaryManifest,
|
|
7
|
+
readCanaryManifest,
|
|
8
|
+
DEFAULT_CANARY_MANIFEST_PATH,
|
|
9
|
+
} from './record-writer.js';
|
|
10
|
+
export { runCanaryAgainstFixture, runCanaryPack, MOCK_SUBAGENT } from './runner.js';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Canary record composer + fixture-manifest writer (spec §3.5, §7.4).
|
|
2
|
+
//
|
|
3
|
+
// Records land on a fixture manifest (not a real chain manifest) at
|
|
4
|
+
// packages/rcf-lite/fixtures/canary-manifest.json. Consumers grep the top
|
|
5
|
+
// record's verdict to decide whether to gate a release; a
|
|
6
|
+
// ship-despite-fail record carries `shipDespiteFailReason` so the
|
|
7
|
+
// operator's ruling is durable and greppable.
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
/** Location of the fixture manifest that carries the canary history. */
|
|
16
|
+
export const DEFAULT_CANARY_MANIFEST_PATH = resolve(here, '..', '..', 'fixtures', 'canary-manifest.json');
|
|
17
|
+
|
|
18
|
+
const ID_RE = /^rc-\d{4}-\d{2}-\d{2}-(\d{3})$/;
|
|
19
|
+
|
|
20
|
+
function nextCanaryId(records, now = new Date()) {
|
|
21
|
+
const yyyy = now.getUTCFullYear().toString().padStart(4, '0');
|
|
22
|
+
const mm = (now.getUTCMonth() + 1).toString().padStart(2, '0');
|
|
23
|
+
const dd = now.getUTCDate().toString().padStart(2, '0');
|
|
24
|
+
const prefix = `rc-${yyyy}-${mm}-${dd}-`;
|
|
25
|
+
let maxN = 0;
|
|
26
|
+
for (const r of records) {
|
|
27
|
+
if (typeof r?.id !== 'string' || !r.id.startsWith(prefix)) continue;
|
|
28
|
+
const m = r.id.match(ID_RE);
|
|
29
|
+
if (m) {
|
|
30
|
+
const n = Number.parseInt(m[1], 10);
|
|
31
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return `${prefix}${(maxN + 1).toString().padStart(3, '0')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compose a registerCanary[] record for one fixture run.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} args
|
|
41
|
+
* @param {object[]} args.existingRecords
|
|
42
|
+
* @param {string} args.buildVersion
|
|
43
|
+
* @param {string} args.fixturePromptId
|
|
44
|
+
* @param {string} args.responseBody
|
|
45
|
+
* @param {{ verdict: 'pass'|'fail', grades: object }} args.grade
|
|
46
|
+
* @param {string} [args.shipDespiteFailReason]
|
|
47
|
+
* @param {Date} [args.now]
|
|
48
|
+
* @returns {object}
|
|
49
|
+
*/
|
|
50
|
+
export function composeCanaryRecord({ existingRecords, buildVersion, fixturePromptId, responseBody, grade, shipDespiteFailReason, now = new Date() }) {
|
|
51
|
+
const isoNow = now.toISOString();
|
|
52
|
+
const record = {
|
|
53
|
+
id: nextCanaryId(existingRecords, now),
|
|
54
|
+
createdAt: isoNow,
|
|
55
|
+
buildVersion,
|
|
56
|
+
fixturePromptId,
|
|
57
|
+
responseWordCount: countWords(responseBody),
|
|
58
|
+
grades: grade.grades,
|
|
59
|
+
verdict: grade.verdict,
|
|
60
|
+
};
|
|
61
|
+
if (typeof shipDespiteFailReason === 'string' && shipDespiteFailReason.length > 0) {
|
|
62
|
+
record.shipDespiteFailReason = shipDespiteFailReason;
|
|
63
|
+
}
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countWords(text) {
|
|
68
|
+
const stripped = String(text ?? '')
|
|
69
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
70
|
+
.replace(/`[^`\n]*`/g, ' ')
|
|
71
|
+
.trim();
|
|
72
|
+
if (stripped.length === 0) return 0;
|
|
73
|
+
return stripped.split(/\s+/).length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the fixture manifest (or return an empty seed when missing).
|
|
78
|
+
*
|
|
79
|
+
* @param {string} [path]
|
|
80
|
+
* @returns {Promise<{ registerCanary: object[] }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function readCanaryManifest(path = DEFAULT_CANARY_MANIFEST_PATH) {
|
|
83
|
+
try {
|
|
84
|
+
const raw = await readFile(path, 'utf8');
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (!Array.isArray(parsed?.registerCanary)) {
|
|
87
|
+
return { ...parsed, registerCanary: [] };
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err.code === 'ENOENT') return { registerCanary: [] };
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Write the fixture manifest atomically. `records` is the full array
|
|
98
|
+
* (append-only usage: read, append the new record, write the result).
|
|
99
|
+
*
|
|
100
|
+
* @param {object} args
|
|
101
|
+
* @param {object[]} args.records
|
|
102
|
+
* @param {string} [args.path]
|
|
103
|
+
* @returns {Promise<void>}
|
|
104
|
+
*/
|
|
105
|
+
export async function writeCanaryManifest({ records, path = DEFAULT_CANARY_MANIFEST_PATH }) {
|
|
106
|
+
const abs = path;
|
|
107
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
108
|
+
const tmp = `${abs}.tmp`;
|
|
109
|
+
const body = { registerCanary: records };
|
|
110
|
+
await writeFile(tmp, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
|
|
111
|
+
try {
|
|
112
|
+
await rename(tmp, abs);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
try { await unlink(tmp); } catch { /* ignore */ }
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Convenience: append one record to the fixture manifest and write back.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} args
|
|
123
|
+
* @param {object} args.record
|
|
124
|
+
* @param {string} [args.path]
|
|
125
|
+
* @returns {Promise<{ records: object[] }>}
|
|
126
|
+
*/
|
|
127
|
+
export async function appendCanaryRecord({ record, path = DEFAULT_CANARY_MANIFEST_PATH }) {
|
|
128
|
+
const manifest = await readCanaryManifest(path);
|
|
129
|
+
const records = [...(manifest.registerCanary ?? []), record];
|
|
130
|
+
await writeCanaryManifest({ records, path });
|
|
131
|
+
return { records };
|
|
132
|
+
}
|