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,1849 @@
|
|
|
1
|
+
// Persistence module for CRUD verbs. Wraps every disk write with
|
|
2
|
+
// schema validation + referential-integrity checks, gated on the
|
|
3
|
+
// POST-WRITE tree state (B5 amendment, E2E matrix 2026-07-06-003): a
|
|
4
|
+
// verb never INTRODUCES schema or reference breakage, but pre-existing
|
|
5
|
+
// breakage does not wedge the tree - repairing or deleting a broken doc
|
|
6
|
+
// is always possible in-tool.
|
|
7
|
+
//
|
|
8
|
+
// Four public functions per spec Phase 4 §D11:
|
|
9
|
+
// nextIdForKind(tree, kind, opts) -> string
|
|
10
|
+
// createDocument({...}) -> { id, filePath } | RcfError
|
|
11
|
+
// updateDocument({...}) -> { id, filePath } | RcfError
|
|
12
|
+
// deleteDocument({...}) -> { deleted, mutated } | RcfError
|
|
13
|
+
//
|
|
14
|
+
// No internal `updateParentLinkage()` helper: parents do not carry
|
|
15
|
+
// children lists (Phase 3.7 §D2). Create paths write exactly one file
|
|
16
|
+
// (or mutate one file for inline AC/TC kinds). Delete paths delete
|
|
17
|
+
// 1..N files and mutate 0..N cross-link-carrying files, never touching
|
|
18
|
+
// parents.
|
|
19
|
+
|
|
20
|
+
import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { rcfError } from '../errors/index.js';
|
|
24
|
+
import { idNumber, normaliseId } from './ids.js';
|
|
25
|
+
import { pathForId, subdirFor } from './loader.js';
|
|
26
|
+
import { validateDocument } from './validator.js';
|
|
27
|
+
import { netNewErrors, simulateWriteErrors } from './walker.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {import('./walker.js').TreeModel} TreeModel
|
|
31
|
+
* @typedef {import('../errors/index.js').RcfError} RcfError
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// Mapping between CLI-facing kind aliases and the canonical validator kind.
|
|
35
|
+
const KIND_ALIASES = {
|
|
36
|
+
req: 'req',
|
|
37
|
+
us: 'userStory',
|
|
38
|
+
userStory: 'userStory',
|
|
39
|
+
ac: 'ac',
|
|
40
|
+
tac: 'tac',
|
|
41
|
+
adr: 'adr',
|
|
42
|
+
fbs: 'fbs',
|
|
43
|
+
ts: 'testSuite',
|
|
44
|
+
testSuite: 'testSuite',
|
|
45
|
+
tc: 'tc',
|
|
46
|
+
// Phase 10 (X2 CodeNode bridge): Code Node.
|
|
47
|
+
cn: 'codeNode',
|
|
48
|
+
codeNode: 'codeNode',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function canonicalKind(kind) {
|
|
52
|
+
return KIND_ALIASES[kind] ?? kind;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// B5: post-write validation gate
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
//
|
|
59
|
+
// Write verbs validate the POST-WRITE tree state, not the pre-existing
|
|
60
|
+
// state (operator-approved amendment to the Phase-4 refusal semantics,
|
|
61
|
+
// E2E matrix 2026-07-06-003 finding B5). A tree that is already broken
|
|
62
|
+
// no longer wedges every write: repairing a broken doc is allowed,
|
|
63
|
+
// deleting the offending doc is allowed. What stays refused is any
|
|
64
|
+
// operation that would introduce NET-NEW breakage - on a valid tree or
|
|
65
|
+
// a broken one.
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run the operation's change-set through the walker's in-memory
|
|
69
|
+
* simulation and refuse if any error appears post-write that was not
|
|
70
|
+
* present pre-write.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} args
|
|
73
|
+
* @param {TreeModel} args.tree
|
|
74
|
+
* @param {RcfError[]} [args.walkErrors] - pre-write walk errors
|
|
75
|
+
* @param {Array<{ kind: string, id: string, doc: object }>} [args.upserts]
|
|
76
|
+
* @param {string[]} [args.deletes]
|
|
77
|
+
* @param {string} args.verb - for the refusal message
|
|
78
|
+
* @returns {RcfError | null}
|
|
79
|
+
*/
|
|
80
|
+
function postWriteGate({ tree, walkErrors = [], upserts = [], deletes = [], verb }) {
|
|
81
|
+
const postErrors = simulateWriteErrors({ tree, preErrors: walkErrors, upserts, deletes });
|
|
82
|
+
const netNew = netNewErrors(walkErrors, postErrors);
|
|
83
|
+
if (netNew.length === 0) return null;
|
|
84
|
+
return rcfError({
|
|
85
|
+
kind: 'validation',
|
|
86
|
+
message: `${verb}: refused - the post-write tree would carry new breakage: ${netNew.map((e) => e.message).join('; ')}`,
|
|
87
|
+
rule: 'postWriteValidation',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ids of schema-invalid (unloadable) docs of a given canonical kind.
|
|
93
|
+
* Considered occupied for id allocation so a repair-pending id is never
|
|
94
|
+
* silently reallocated (and its file never overwritten).
|
|
95
|
+
*
|
|
96
|
+
* @param {TreeModel} tree
|
|
97
|
+
* @param {string} kind - canonical kind
|
|
98
|
+
* @returns {string[]}
|
|
99
|
+
*/
|
|
100
|
+
function invalidIdsOfKind(tree, kind) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const [id, entry] of tree.invalidDocs ?? []) {
|
|
103
|
+
if (entry.kind === kind) out.push(id);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Own-id field per canonical kind, mirroring the walker. */
|
|
109
|
+
const OWN_ID_FIELD = {
|
|
110
|
+
req: 'reqId',
|
|
111
|
+
userStory: 'usId',
|
|
112
|
+
tac: 'tacId',
|
|
113
|
+
adr: 'adrId',
|
|
114
|
+
fbs: 'fbsId',
|
|
115
|
+
testSuite: 'id',
|
|
116
|
+
codeNode: 'cnId',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every id of a given kind that is occupied, from every source the
|
|
121
|
+
* allocator must respect (w-2026-07-28-017):
|
|
122
|
+
*
|
|
123
|
+
* - the id each loaded doc DECLARES (`d.reqId`), and
|
|
124
|
+
* - the id it is FILED under (its `byId` key, derived from the
|
|
125
|
+
* filename), which can diverge from the declared id, and
|
|
126
|
+
* - ids of schema-invalid docs, whose files still sit on disk.
|
|
127
|
+
*
|
|
128
|
+
* Reading only the declared ids was the allocation half of the
|
|
129
|
+
* duplicate-id bug: with `req-002.json` declaring `"reqId": "REQ-001"`,
|
|
130
|
+
* `nextFlatId` saw only REQ-001 and handed out REQ-002, whose file
|
|
131
|
+
* already existed.
|
|
132
|
+
*
|
|
133
|
+
* @param {TreeModel} tree
|
|
134
|
+
* @param {string} kind - canonical kind
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
137
|
+
function occupiedIdsOfKind(tree, kind) {
|
|
138
|
+
const field = OWN_ID_FIELD[kind];
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const [id, doc] of tree.byId) {
|
|
141
|
+
if (tree.kindById.get(id) !== kind) continue;
|
|
142
|
+
out.push(id);
|
|
143
|
+
const declared = field ? doc?.[field] : null;
|
|
144
|
+
if (typeof declared === 'string') out.push(declared);
|
|
145
|
+
}
|
|
146
|
+
out.push(...invalidIdsOfKind(tree, kind));
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function fileExistsOnDisk(absPath) {
|
|
151
|
+
try {
|
|
152
|
+
await stat(absPath);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compute the next free id for a given kind. Never reuses freed ids:
|
|
161
|
+
* `max+1` reads the current `tree.byId`; a deletion does not lower
|
|
162
|
+
* `max`, so a fresh allocation always exceeds the historical high-water
|
|
163
|
+
* mark for that kind (Phase 4 §D10 amendment).
|
|
164
|
+
*
|
|
165
|
+
* @param {TreeModel} tree
|
|
166
|
+
* @param {string} kind - one of req | us | ac | tac | adr | fbs | ts | tc
|
|
167
|
+
* @param {object} [opts]
|
|
168
|
+
* @param {string} [opts.parentId] - required for us / ac / tc
|
|
169
|
+
* @param {string} [opts.slug] - required for tc
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function nextIdForKind(tree, kind, opts = {}) {
|
|
173
|
+
const k = canonicalKind(kind);
|
|
174
|
+
// B5: unloadable (schema-invalid) docs are absent from the doc arrays
|
|
175
|
+
// but their ids are still occupied on disk - include them so an
|
|
176
|
+
// allocation never collides with a repair-pending file.
|
|
177
|
+
switch (k) {
|
|
178
|
+
case 'req':
|
|
179
|
+
return nextFlatId('REQ', occupiedIdsOfKind(tree, 'req'));
|
|
180
|
+
case 'tac':
|
|
181
|
+
return nextFlatId('TAC', occupiedIdsOfKind(tree, 'tac'));
|
|
182
|
+
case 'adr':
|
|
183
|
+
return nextFlatId('ADR', occupiedIdsOfKind(tree, 'adr'));
|
|
184
|
+
case 'fbs':
|
|
185
|
+
return nextFlatId('FBS', occupiedIdsOfKind(tree, 'fbs'));
|
|
186
|
+
case 'testSuite':
|
|
187
|
+
return nextFlatId('TS', occupiedIdsOfKind(tree, 'testSuite'));
|
|
188
|
+
// Phase 10 (X2 CodeNode bridge): Code Node. Flat namespace like
|
|
189
|
+
// REQ/TAC/ADR/FBS/TS - no parent required (D13).
|
|
190
|
+
case 'codeNode':
|
|
191
|
+
return nextFlatId('CN', occupiedIdsOfKind(tree, 'codeNode'));
|
|
192
|
+
case 'userStory': {
|
|
193
|
+
const reqId = opts.parentId;
|
|
194
|
+
const match = /^REQ-(\d+)$/.exec(reqId ?? '');
|
|
195
|
+
if (!match) {
|
|
196
|
+
throw new TypeError('nextIdForKind us requires opts.parentId matching REQ-XXX');
|
|
197
|
+
}
|
|
198
|
+
// w-2026-07-28-017: the group is a NUMBER, not a string. `REQ-001`
|
|
199
|
+
// and `REQ-0001` are one requirement, so their user stories share
|
|
200
|
+
// one numbering group.
|
|
201
|
+
const groupNum = Number(normaliseId(match[1]));
|
|
202
|
+
const groupLabel = String(groupNum);
|
|
203
|
+
// Occupancy is every US id that numerically falls in this group,
|
|
204
|
+
// whatever `reqId` string it names. Filtering on an exact reqId
|
|
205
|
+
// string match was the bug: a US filed under `REQ-0001` was
|
|
206
|
+
// invisible when allocating for `REQ-001`, so US-101 got re-issued
|
|
207
|
+
// on top of the existing US-101.
|
|
208
|
+
const usIds = occupiedIdsOfKind(tree, 'userStory');
|
|
209
|
+
let maxLocal = 0;
|
|
210
|
+
for (const usId of usIds) {
|
|
211
|
+
const num = idNumber(usId, 'US');
|
|
212
|
+
if (num === null) continue;
|
|
213
|
+
const local = num - groupNum * 100;
|
|
214
|
+
if (local >= 1 && local <= 99 && local > maxLocal) maxLocal = local;
|
|
215
|
+
}
|
|
216
|
+
const nextLocal = maxLocal + 1;
|
|
217
|
+
return `US-${groupLabel}${String(nextLocal).padStart(2, '0')}`;
|
|
218
|
+
}
|
|
219
|
+
case 'ac': {
|
|
220
|
+
const usId = opts.parentId;
|
|
221
|
+
const us = tree.byId.get(usId ?? '');
|
|
222
|
+
if (!us || tree.kindById.get(usId) !== 'userStory') {
|
|
223
|
+
throw new TypeError('nextIdForKind ac requires opts.parentId=US-XXX (existing)');
|
|
224
|
+
}
|
|
225
|
+
const mUs = /^US-(\d+)$/.exec(usId);
|
|
226
|
+
if (!mUs) throw new TypeError('nextIdForKind ac: unrecognised US id');
|
|
227
|
+
const usSuffix = mUs[1];
|
|
228
|
+
// w-2026-07-28-017: match the AC's group and local number
|
|
229
|
+
// numerically, so `AC-101-01` and `AC-0101-1` both count against
|
|
230
|
+
// `US-101` instead of being read as unrelated ids.
|
|
231
|
+
const normalisedSuffix = normaliseId(usSuffix);
|
|
232
|
+
let maxLocal = 0;
|
|
233
|
+
for (const ac of us.acceptanceCriteria ?? []) {
|
|
234
|
+
const mm = /^AC-(\d+)-(\d+)$/.exec(ac?.id ?? '');
|
|
235
|
+
if (!mm) continue;
|
|
236
|
+
if (normaliseId(mm[1]) !== normalisedSuffix) continue;
|
|
237
|
+
const n = Number(mm[2]);
|
|
238
|
+
if (n > maxLocal) maxLocal = n;
|
|
239
|
+
}
|
|
240
|
+
return `AC-${usSuffix}-${maxLocal + 1}`;
|
|
241
|
+
}
|
|
242
|
+
case 'tc': {
|
|
243
|
+
const tsId = opts.parentId;
|
|
244
|
+
const slug = opts.slug;
|
|
245
|
+
const ts = tree.byId.get(tsId ?? '');
|
|
246
|
+
if (!ts || tree.kindById.get(tsId) !== 'testSuite') {
|
|
247
|
+
throw new TypeError('nextIdForKind tc requires opts.parentId=TS-XXX (existing)');
|
|
248
|
+
}
|
|
249
|
+
if (typeof slug !== 'string' || slug.length === 0) {
|
|
250
|
+
throw new TypeError('nextIdForKind tc requires opts.slug');
|
|
251
|
+
}
|
|
252
|
+
const mTs = /^TS-(\d{3})$/.exec(tsId);
|
|
253
|
+
if (!mTs) throw new TypeError('nextIdForKind tc: unrecognised TS id');
|
|
254
|
+
return `TC-${mTs[1]}-${slug}`;
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
throw new TypeError(`nextIdForKind: unsupported kind ${kind}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// w-2026-07-28-017: `Number()` already folded leading zeros here, so the
|
|
262
|
+
// high-water mark was never fooled by spelling. What WAS wrong is the id
|
|
263
|
+
// set fed in (see `occupiedIdsOfKind`). Comparison stays numeric and the
|
|
264
|
+
// emitted id is always the canonical three-digit-minimum spelling, so a
|
|
265
|
+
// freshly allocated id is never a leading-zero variant of a taken one.
|
|
266
|
+
function nextFlatId(prefix, ids) {
|
|
267
|
+
let max = 0;
|
|
268
|
+
const re = new RegExp(`^${prefix}-(\\d+)$`);
|
|
269
|
+
for (const id of ids) {
|
|
270
|
+
const m = re.exec(id ?? '');
|
|
271
|
+
if (m) {
|
|
272
|
+
const n = Number(m[1]);
|
|
273
|
+
if (n > max) max = n;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return `${prefix}-${String(max + 1).padStart(3, '0')}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parent id field name required on each child kind.
|
|
281
|
+
*/
|
|
282
|
+
const PARENT_FIELD_FOR = {
|
|
283
|
+
req: 'prdId',
|
|
284
|
+
userStory: 'reqId',
|
|
285
|
+
tac: 'tadId',
|
|
286
|
+
adr: 'tadId',
|
|
287
|
+
fbs: 'bsId',
|
|
288
|
+
testSuite: 'usId',
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const EXPECTED_PARENT_KIND_FOR = {
|
|
292
|
+
req: 'prd',
|
|
293
|
+
userStory: 'req',
|
|
294
|
+
tac: 'tad',
|
|
295
|
+
adr: 'tad',
|
|
296
|
+
fbs: 'buildSequence',
|
|
297
|
+
testSuite: 'userStory',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @returns {string} ISO timestamp for createdAt/updatedAt fields.
|
|
302
|
+
*/
|
|
303
|
+
function nowIso() {
|
|
304
|
+
return new Date().toISOString();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Write a JSON file atomically (write to .tmp, rename). Ensures the
|
|
309
|
+
* parent directory exists.
|
|
310
|
+
* @param {string} absPath
|
|
311
|
+
* @param {object} body
|
|
312
|
+
*/
|
|
313
|
+
async function writeJsonAtomic(absPath, body) {
|
|
314
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
315
|
+
const tmp = `${absPath}.tmp`;
|
|
316
|
+
await writeFile(tmp, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
|
|
317
|
+
try {
|
|
318
|
+
await rename(tmp, absPath);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
try { await unlink(tmp); } catch { /* ignore */ }
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function pathForKindFile(projectRoot, kind, id) {
|
|
326
|
+
const sub = subdirFor(kind);
|
|
327
|
+
if (!sub) throw new TypeError(`pathForKindFile: not a child kind: ${kind}`);
|
|
328
|
+
return join(projectRoot, 'rcf', sub, `${id.toLowerCase()}.json`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function pathForRootDoc(projectRoot, kind) {
|
|
332
|
+
switch (kind) {
|
|
333
|
+
case 'manifest': return join(projectRoot, 'rcf', 'manifest.json');
|
|
334
|
+
case 'prd': return join(projectRoot, 'rcf', 'prd.json');
|
|
335
|
+
case 'tad': return join(projectRoot, 'rcf', 'tad.json');
|
|
336
|
+
case 'buildSequence': return join(projectRoot, 'rcf', 'build-sequence.json');
|
|
337
|
+
default: throw new TypeError(`pathForRootDoc: not a root kind: ${kind}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create a new document. Returns `{ id, filePath }` on success or an
|
|
343
|
+
* RcfError. Handles the inline kinds `ac` (mutates parent US) and `tc`
|
|
344
|
+
* (mutates parent TS). For root-child kinds writes exactly one file.
|
|
345
|
+
*
|
|
346
|
+
* `body` supplies the caller-provided fields; parent linkage +
|
|
347
|
+
* timestamps + `--from-file` merging happen inside this function so
|
|
348
|
+
* every path routes through a single validation pass.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} args
|
|
351
|
+
* @param {string} args.projectRoot
|
|
352
|
+
* @param {TreeModel} args.tree
|
|
353
|
+
* @param {string} args.kind - CLI-facing kind alias (req / us / ac / ...)
|
|
354
|
+
* @param {object} args.body - caller-supplied fields
|
|
355
|
+
* @param {object} [args.options]
|
|
356
|
+
* @param {string} [args.options.id] - override auto-id
|
|
357
|
+
* @param {string} [args.options.parentId] - required for all child kinds
|
|
358
|
+
* @param {number} [args.options.buildOrder] - for fbs
|
|
359
|
+
* @param {string} [args.options.slug] - for tc
|
|
360
|
+
* @param {boolean}[args.options.dryRun]
|
|
361
|
+
* @param {RcfError[]} [args.walkErrors] - pre-write walk errors (B5 gate)
|
|
362
|
+
* @returns {Promise<{ id: string, filePath: string, body: object } | RcfError>}
|
|
363
|
+
*/
|
|
364
|
+
export async function createDocument({ projectRoot, tree, kind, body, options = {}, walkErrors = [] }) {
|
|
365
|
+
const canonical = canonicalKind(kind);
|
|
366
|
+
const parentId = options.parentId;
|
|
367
|
+
const parentField = PARENT_FIELD_FOR[canonical];
|
|
368
|
+
const expectedParentKind = EXPECTED_PARENT_KIND_FOR[canonical];
|
|
369
|
+
|
|
370
|
+
// Inline kinds route through mutateInline().
|
|
371
|
+
if (canonical === 'ac') {
|
|
372
|
+
return await createInlineAc({ projectRoot, tree, options, body, walkErrors });
|
|
373
|
+
}
|
|
374
|
+
if (canonical === 'tc') {
|
|
375
|
+
return await createInlineTc({ projectRoot, tree, options, body, walkErrors });
|
|
376
|
+
}
|
|
377
|
+
// Phase 10 (X2 CodeNode bridge): CN has no parent field (D13) - its
|
|
378
|
+
// identity is `path`, not a position in the PRD/REQ/US tree.
|
|
379
|
+
if (canonical === 'codeNode') {
|
|
380
|
+
return await createCn({ projectRoot, tree, options, body, walkErrors });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!parentField) {
|
|
384
|
+
return rcfError({
|
|
385
|
+
kind: 'usage',
|
|
386
|
+
message: `createDocument: unsupported kind ${kind}`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (typeof parentId !== 'string' || parentId.length === 0) {
|
|
390
|
+
return rcfError({
|
|
391
|
+
kind: 'usage',
|
|
392
|
+
message: `create ${kind}: --parent is required`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// Referential-integrity pre-check.
|
|
396
|
+
const parentDoc = tree.byId.get(parentId);
|
|
397
|
+
const parentKind = tree.kindById.get(parentId);
|
|
398
|
+
if (!parentDoc || parentKind !== expectedParentKind) {
|
|
399
|
+
return rcfError({
|
|
400
|
+
kind: 'brokenReference',
|
|
401
|
+
message: `create ${kind}: parent ${parentId} not found or not a ${expectedParentKind}`,
|
|
402
|
+
documentId: parentId,
|
|
403
|
+
field: parentField,
|
|
404
|
+
rule: `resolveTo:${expectedParentKind}`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Allocate id (or take the override).
|
|
409
|
+
let id = options.id;
|
|
410
|
+
if (id) {
|
|
411
|
+
// B5: an unloadable (schema-invalid) doc still occupies its id.
|
|
412
|
+
if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
|
|
413
|
+
return rcfError({
|
|
414
|
+
kind: 'usage',
|
|
415
|
+
message: `create ${kind}: id ${id} is already taken`,
|
|
416
|
+
documentId: id,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
try {
|
|
421
|
+
id = nextIdForKind(tree, canonical, { parentId });
|
|
422
|
+
} catch (err) {
|
|
423
|
+
return rcfError({ kind: 'usage', message: err.message });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// FBS-only sibling collision pre-check on buildOrder (§D6 step 4).
|
|
428
|
+
if (canonical === 'fbs') {
|
|
429
|
+
const siblings = childrenOfParent(tree, parentId, 'fbs');
|
|
430
|
+
let providedOrder = options.buildOrder;
|
|
431
|
+
if (providedOrder === undefined) {
|
|
432
|
+
let maxOrder = 0;
|
|
433
|
+
for (const sib of siblings) {
|
|
434
|
+
if (typeof sib.buildOrder === 'number' && sib.buildOrder > maxOrder) {
|
|
435
|
+
maxOrder = sib.buildOrder;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
providedOrder = maxOrder + 1;
|
|
439
|
+
} else if (Number.isInteger(providedOrder)) {
|
|
440
|
+
const collision = siblings.find((sib) => sib.buildOrder === providedOrder);
|
|
441
|
+
if (collision) {
|
|
442
|
+
return rcfError({
|
|
443
|
+
kind: 'usage',
|
|
444
|
+
message: `create fbs: --build-order ${providedOrder} collides with ${collision.fbsId} (existing buildOrder=${collision.buildOrder}) under ${parentId}`,
|
|
445
|
+
documentId: collision.fbsId,
|
|
446
|
+
field: 'buildOrder',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
options = { ...options, buildOrder: providedOrder };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Assemble body with parent linkage + defaults + timestamps.
|
|
454
|
+
const finalBody = assembleBody({ tree, canonical, id, parentId, body, options });
|
|
455
|
+
|
|
456
|
+
// Validate assembled body.
|
|
457
|
+
const relPath = relativePathForChild(canonical, id);
|
|
458
|
+
const validation = validateDocument({
|
|
459
|
+
doc: finalBody,
|
|
460
|
+
kind: canonical,
|
|
461
|
+
filePath: relPath,
|
|
462
|
+
});
|
|
463
|
+
if (validation) return { ...validation, documentId: id };
|
|
464
|
+
|
|
465
|
+
// Cross-link ACs must resolve to existing ACs for fbs / ts.
|
|
466
|
+
if (canonical === 'fbs' || canonical === 'testSuite') {
|
|
467
|
+
const allAcIds = collectAllAcIds(tree);
|
|
468
|
+
for (const acId of finalBody.acIds ?? []) {
|
|
469
|
+
if (!allAcIds.has(acId)) {
|
|
470
|
+
return rcfError({
|
|
471
|
+
kind: 'brokenReference',
|
|
472
|
+
message: `create ${kind}: acId ${acId} does not resolve to a known AC`,
|
|
473
|
+
documentId: acId,
|
|
474
|
+
field: 'acIds',
|
|
475
|
+
rule: 'resolveTo:ac',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (canonical === 'fbs') {
|
|
481
|
+
for (const depId of finalBody.dependsOnFbsIds ?? []) {
|
|
482
|
+
if (tree.kindById.get(depId) !== 'fbs') {
|
|
483
|
+
return rcfError({
|
|
484
|
+
kind: 'brokenReference',
|
|
485
|
+
message: `create fbs: dependsOnFbsIds entry ${depId} is not an existing FBS`,
|
|
486
|
+
documentId: depId,
|
|
487
|
+
field: 'dependsOnFbsIds',
|
|
488
|
+
rule: 'resolveTo:fbs',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const absPath = pathForKindFile(projectRoot, canonical, id);
|
|
495
|
+
// B5: never silently overwrite an on-disk file that failed to load
|
|
496
|
+
// entirely (e.g. parse-broken) - such files are absent from both byId
|
|
497
|
+
// and invalidDocs, so the id checks above cannot see them.
|
|
498
|
+
if (await fileExistsOnDisk(absPath)) {
|
|
499
|
+
return rcfError({
|
|
500
|
+
kind: 'usage',
|
|
501
|
+
message: `create ${kind}: ${relPath} already exists on disk but did not load; repair or delete it first`,
|
|
502
|
+
documentId: id,
|
|
503
|
+
filePath: relPath,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const gateErr = postWriteGate({
|
|
507
|
+
tree,
|
|
508
|
+
walkErrors,
|
|
509
|
+
upserts: [{ kind: canonical, id, doc: finalBody }],
|
|
510
|
+
verb: `create ${kind}`,
|
|
511
|
+
});
|
|
512
|
+
if (gateErr) return { ...gateErr, documentId: id };
|
|
513
|
+
if (options.dryRun) {
|
|
514
|
+
return { id, filePath: relPath, body: finalBody, dryRun: true };
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
await writeJsonAtomic(absPath, finalBody);
|
|
518
|
+
} catch (err) {
|
|
519
|
+
return rcfError({
|
|
520
|
+
kind: 'ioFailure',
|
|
521
|
+
message: `create ${kind}: write failed: ${err.message}`,
|
|
522
|
+
filePath: relPath,
|
|
523
|
+
stack: err.stack,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return { id, filePath: relPath, body: finalBody };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Create a Code Node (Phase 10, X2 CodeNode bridge, D13). CN has no
|
|
531
|
+
* parent field - identity is `path`, optionally `#symbol`-suffixed
|
|
532
|
+
* (D2). `implementsAcIds` MAY be empty (an orphan CN is legitimate,
|
|
533
|
+
* D3); every non-empty entry must resolve to a known AC.
|
|
534
|
+
* `dependencies` MAY be empty; every entry must resolve to an existing
|
|
535
|
+
* CN and may not self-reference.
|
|
536
|
+
*
|
|
537
|
+
* @param {object} args
|
|
538
|
+
* @param {string} args.projectRoot
|
|
539
|
+
* @param {TreeModel} args.tree
|
|
540
|
+
* @param {object} args.options
|
|
541
|
+
* @param {string} [args.options.id]
|
|
542
|
+
* @param {boolean} [args.options.dryRun]
|
|
543
|
+
* @param {object} args.body
|
|
544
|
+
* @param {string} args.body.path - required
|
|
545
|
+
* @param {string[]} [args.body.implementsAcIds]
|
|
546
|
+
* @param {string[]} [args.body.dependencies]
|
|
547
|
+
* @param {RcfError[]} [args.walkErrors]
|
|
548
|
+
* @returns {Promise<{ id: string, filePath: string, body: object } | RcfError>}
|
|
549
|
+
*/
|
|
550
|
+
async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
|
|
551
|
+
const path = body?.path;
|
|
552
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
553
|
+
return rcfError({ kind: 'usage', message: 'create cn: --path is required' });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let id = options.id;
|
|
557
|
+
if (id) {
|
|
558
|
+
if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
|
|
559
|
+
return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
id = nextIdForKind(tree, 'codeNode');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const implementsAcIds = body.implementsAcIds ?? [];
|
|
566
|
+
const allAcIds = collectAllAcIds(tree);
|
|
567
|
+
for (const acId of implementsAcIds) {
|
|
568
|
+
if (!allAcIds.has(acId)) {
|
|
569
|
+
return rcfError({
|
|
570
|
+
kind: 'brokenReference',
|
|
571
|
+
message: `create cn: implementsAcIds entry ${acId} does not resolve to a known AC`,
|
|
572
|
+
documentId: acId,
|
|
573
|
+
field: 'implementsAcIds',
|
|
574
|
+
rule: 'resolveTo:ac',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const dependencies = body.dependencies ?? [];
|
|
580
|
+
for (const depId of dependencies) {
|
|
581
|
+
if (depId === id) {
|
|
582
|
+
return rcfError({
|
|
583
|
+
kind: 'usage',
|
|
584
|
+
message: `create cn: dependencies entry ${depId} cannot be the node's own id`,
|
|
585
|
+
documentId: id,
|
|
586
|
+
field: 'dependencies',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (tree.kindById.get(depId) !== 'codeNode') {
|
|
590
|
+
return rcfError({
|
|
591
|
+
kind: 'brokenReference',
|
|
592
|
+
message: `create cn: dependencies entry ${depId} does not resolve to a known code node`,
|
|
593
|
+
documentId: depId,
|
|
594
|
+
field: 'dependencies',
|
|
595
|
+
rule: 'resolveTo:codeNode',
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const now = nowIso();
|
|
601
|
+
const finalBody = {
|
|
602
|
+
cnId: id,
|
|
603
|
+
path,
|
|
604
|
+
...(body.title !== undefined ? { title: body.title } : {}),
|
|
605
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
606
|
+
implementsAcIds,
|
|
607
|
+
dependencies,
|
|
608
|
+
version: body.version ?? '0.1.0',
|
|
609
|
+
status: body.status ?? 'draft',
|
|
610
|
+
createdAt: now,
|
|
611
|
+
updatedAt: now,
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const relPath = relativePathForChild('codeNode', id);
|
|
615
|
+
const validation = validateDocument({ doc: finalBody, kind: 'codeNode', filePath: relPath });
|
|
616
|
+
if (validation) return { ...validation, documentId: id };
|
|
617
|
+
|
|
618
|
+
const absPath = pathForKindFile(projectRoot, 'codeNode', id);
|
|
619
|
+
if (await fileExistsOnDisk(absPath)) {
|
|
620
|
+
return rcfError({
|
|
621
|
+
kind: 'usage',
|
|
622
|
+
message: `create cn: ${relPath} already exists on disk but did not load; repair or delete it first`,
|
|
623
|
+
documentId: id,
|
|
624
|
+
filePath: relPath,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const gateErr = postWriteGate({
|
|
628
|
+
tree,
|
|
629
|
+
walkErrors,
|
|
630
|
+
upserts: [{ kind: 'codeNode', id, doc: finalBody }],
|
|
631
|
+
verb: 'create cn',
|
|
632
|
+
});
|
|
633
|
+
if (gateErr) return { ...gateErr, documentId: id };
|
|
634
|
+
if (options.dryRun) {
|
|
635
|
+
return { id, filePath: relPath, body: finalBody, dryRun: true };
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
await writeJsonAtomic(absPath, finalBody);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
return rcfError({
|
|
641
|
+
kind: 'ioFailure',
|
|
642
|
+
message: `create cn: write failed: ${err.message}`,
|
|
643
|
+
filePath: relPath,
|
|
644
|
+
stack: err.stack,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return { id, filePath: relPath, body: finalBody };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function relativePathForChild(canonical, id) {
|
|
651
|
+
const sub = subdirFor(canonical);
|
|
652
|
+
if (!sub) throw new TypeError(`relativePathForChild: bad kind ${canonical}`);
|
|
653
|
+
return `rcf/${sub}/${id.toLowerCase()}.json`;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function childrenOfParent(tree, parentId, expectedKind) {
|
|
657
|
+
const ids = tree.childrenByParent.get(parentId) ?? [];
|
|
658
|
+
const out = [];
|
|
659
|
+
for (const id of ids) {
|
|
660
|
+
if (tree.kindById.get(id) === expectedKind) out.push(tree.byId.get(id));
|
|
661
|
+
}
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function collectAllAcIds(tree) {
|
|
666
|
+
const set = new Set();
|
|
667
|
+
for (const us of tree.userStories) {
|
|
668
|
+
for (const ac of us.acceptanceCriteria ?? []) {
|
|
669
|
+
if (ac?.id) set.add(ac.id);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return set;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function assembleBody({ tree, canonical, id, parentId, body, options }) {
|
|
676
|
+
const now = nowIso();
|
|
677
|
+
const prdId = tree.prd?.prdId ?? 'PRD-001';
|
|
678
|
+
const base = { ...(body ?? {}) };
|
|
679
|
+
// BUG-002/BUG-003 fix: `--title` is a CLI convenience seed. The writer's
|
|
680
|
+
// job is to place it in the field the kind's schema wants — `title` for
|
|
681
|
+
// req/us/adr/fbs/ts (schema-required) or `name` for tac (which lacks
|
|
682
|
+
// `title` and forbids additional properties). Lift the seed out of the
|
|
683
|
+
// base body so it never leaks via the spread below, then place it
|
|
684
|
+
// explicitly per kind. This also stops the previous title→description /
|
|
685
|
+
// title→summary / title→purpose cross-fallbacks that produced doc bodies
|
|
686
|
+
// where two semantic fields carried the same value.
|
|
687
|
+
const titleSeed = typeof base.title === 'string' ? base.title : undefined;
|
|
688
|
+
delete base.title;
|
|
689
|
+
// B1 fix (E2E matrix 2026-07-06-003): timestamps are writer-owned. The
|
|
690
|
+
// `...base` spread below previously let a caller-supplied createdAt /
|
|
691
|
+
// updatedAt (via --from-file or the MCP body object) override the
|
|
692
|
+
// writer clock - a date-only "today" value serialised as midnight UTC
|
|
693
|
+
// produced updatedAt EARLIER than the same doc's createdAt. Strip both
|
|
694
|
+
// so create mirrors update, which already refuses createdAt and forces
|
|
695
|
+
// updatedAt = nowIso().
|
|
696
|
+
delete base.createdAt;
|
|
697
|
+
delete base.updatedAt;
|
|
698
|
+
const withTimestamps = {
|
|
699
|
+
createdAt: now,
|
|
700
|
+
updatedAt: now,
|
|
701
|
+
...base,
|
|
702
|
+
};
|
|
703
|
+
// Force id, parent linkage, and prdId on every child that needs them.
|
|
704
|
+
switch (canonical) {
|
|
705
|
+
case 'req':
|
|
706
|
+
return {
|
|
707
|
+
...withTimestamps,
|
|
708
|
+
reqId: id,
|
|
709
|
+
prdId: parentId,
|
|
710
|
+
title: titleSeed ?? 'TODO: name this requirement',
|
|
711
|
+
description: base.description ?? 'TODO: describe this requirement.',
|
|
712
|
+
category: base.category ?? 'functional',
|
|
713
|
+
domain: base.domain ?? 'todo',
|
|
714
|
+
priority: base.priority ?? 'must',
|
|
715
|
+
version: base.version ?? '0.1.0',
|
|
716
|
+
status: base.status ?? 'draft',
|
|
717
|
+
};
|
|
718
|
+
case 'userStory':
|
|
719
|
+
return {
|
|
720
|
+
...withTimestamps,
|
|
721
|
+
usId: id,
|
|
722
|
+
prdId,
|
|
723
|
+
reqId: parentId,
|
|
724
|
+
version: base.version ?? '0.1.0',
|
|
725
|
+
status: base.status ?? 'draft',
|
|
726
|
+
title: titleSeed ?? 'TODO: name this user story',
|
|
727
|
+
asA: base.asA ?? 'TODO: name the user',
|
|
728
|
+
iWant: base.iWant ?? 'TODO: state the want',
|
|
729
|
+
soThat: base.soThat ?? 'TODO: state the value',
|
|
730
|
+
acceptanceCriteria: base.acceptanceCriteria ?? [{
|
|
731
|
+
id: `AC-${id.slice(3)}-1`,
|
|
732
|
+
description: 'TODO: first acceptance criterion',
|
|
733
|
+
testable: true,
|
|
734
|
+
}],
|
|
735
|
+
};
|
|
736
|
+
case 'tac':
|
|
737
|
+
return {
|
|
738
|
+
...withTimestamps,
|
|
739
|
+
tacId: id,
|
|
740
|
+
prdId,
|
|
741
|
+
tadId: parentId,
|
|
742
|
+
version: base.version ?? '0.1.0',
|
|
743
|
+
status: base.status ?? 'draft',
|
|
744
|
+
purpose: base.purpose ?? 'TODO: state the purpose',
|
|
745
|
+
responsibilities: base.responsibilities ?? ['TODO: list at least one responsibility'],
|
|
746
|
+
name: base.name ?? titleSeed ?? 'TODO: name this component',
|
|
747
|
+
};
|
|
748
|
+
case 'adr':
|
|
749
|
+
return {
|
|
750
|
+
...withTimestamps,
|
|
751
|
+
adrId: id,
|
|
752
|
+
prdId,
|
|
753
|
+
tadId: parentId,
|
|
754
|
+
version: base.version ?? '0.1.0',
|
|
755
|
+
status: base.status ?? 'proposed',
|
|
756
|
+
title: titleSeed ?? 'TODO: name this ADR',
|
|
757
|
+
context: base.context ?? 'TODO: describe the context',
|
|
758
|
+
decision: base.decision ?? 'TODO: describe the decision',
|
|
759
|
+
consequences: base.consequences ?? 'TODO: describe the consequences',
|
|
760
|
+
};
|
|
761
|
+
case 'fbs': {
|
|
762
|
+
const buildOrder = typeof options.buildOrder === 'number' ? options.buildOrder : 1;
|
|
763
|
+
return {
|
|
764
|
+
...withTimestamps,
|
|
765
|
+
fbsId: id,
|
|
766
|
+
prdId,
|
|
767
|
+
bsId: parentId,
|
|
768
|
+
buildOrder,
|
|
769
|
+
executionStatus: base.executionStatus ?? 'notStarted',
|
|
770
|
+
title: titleSeed ?? 'TODO: name this build session',
|
|
771
|
+
summary: base.summary ?? 'TODO: describe the build session',
|
|
772
|
+
acIds: base.acIds ?? [],
|
|
773
|
+
dependsOnFbsIds: base.dependsOnFbsIds ?? [],
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
case 'testSuite': {
|
|
777
|
+
// Parent US -> prdId inherited from tree root; testSuite schema doesn't
|
|
778
|
+
// require prdId, so we omit it unless caller supplied.
|
|
779
|
+
const out = {
|
|
780
|
+
...withTimestamps,
|
|
781
|
+
id,
|
|
782
|
+
usId: parentId,
|
|
783
|
+
title: titleSeed ?? 'TODO: name this test suite',
|
|
784
|
+
purpose: base.purpose ?? 'TODO: state the purpose',
|
|
785
|
+
testLevel: base.testLevel ?? 'unit',
|
|
786
|
+
acIds: base.acIds ?? [],
|
|
787
|
+
testCases: base.testCases ?? [],
|
|
788
|
+
status: base.status ?? 'draft',
|
|
789
|
+
};
|
|
790
|
+
// testSuite schema forbids additional properties - drop any prdId hint.
|
|
791
|
+
delete out.prdId;
|
|
792
|
+
return out;
|
|
793
|
+
}
|
|
794
|
+
default:
|
|
795
|
+
throw new TypeError(`assembleBody: bad kind ${canonical}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Create an inline AC under a parent US.
|
|
801
|
+
*/
|
|
802
|
+
async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [] }) {
|
|
803
|
+
const parentUsId = options.parentId;
|
|
804
|
+
if (typeof parentUsId !== 'string' || parentUsId.length === 0) {
|
|
805
|
+
return rcfError({ kind: 'usage', message: 'create ac: --parent US-XXX is required' });
|
|
806
|
+
}
|
|
807
|
+
const us = tree.byId.get(parentUsId);
|
|
808
|
+
if (!us || tree.kindById.get(parentUsId) !== 'userStory') {
|
|
809
|
+
return rcfError({
|
|
810
|
+
kind: 'brokenReference',
|
|
811
|
+
message: `create ac: parent ${parentUsId} is not an existing US`,
|
|
812
|
+
documentId: parentUsId,
|
|
813
|
+
field: 'parent',
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
let acId = options.id;
|
|
817
|
+
if (acId) {
|
|
818
|
+
const existing = (us.acceptanceCriteria ?? []).some((ac) => ac.id === acId);
|
|
819
|
+
if (existing) {
|
|
820
|
+
return rcfError({ kind: 'usage', message: `create ac: id ${acId} already exists on ${parentUsId}`, documentId: acId });
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
try {
|
|
824
|
+
acId = nextIdForKind(tree, 'ac', { parentId: parentUsId });
|
|
825
|
+
} catch (err) {
|
|
826
|
+
return rcfError({ kind: 'usage', message: err.message });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const acEntry = {
|
|
830
|
+
id: acId,
|
|
831
|
+
description: body?.description ?? 'TODO: describe the acceptance criterion',
|
|
832
|
+
testable: body?.testable ?? true,
|
|
833
|
+
...(body?.given !== undefined ? { given: body.given } : {}),
|
|
834
|
+
...(body?.when !== undefined ? { when: body.when } : {}),
|
|
835
|
+
...(body?.then !== undefined ? { then: body.then } : {}),
|
|
836
|
+
};
|
|
837
|
+
const nextUs = {
|
|
838
|
+
...us,
|
|
839
|
+
acceptanceCriteria: [...(us.acceptanceCriteria ?? []), acEntry],
|
|
840
|
+
updatedAt: nowIso(),
|
|
841
|
+
};
|
|
842
|
+
const relPath = `rcf/user-stories/${parentUsId.toLowerCase()}.json`;
|
|
843
|
+
const validation = validateDocument({ doc: nextUs, kind: 'userStory', filePath: relPath });
|
|
844
|
+
if (validation) return { ...validation, documentId: parentUsId };
|
|
845
|
+
const gateErr = postWriteGate({
|
|
846
|
+
tree,
|
|
847
|
+
walkErrors,
|
|
848
|
+
upserts: [{ kind: 'userStory', id: parentUsId, doc: nextUs }],
|
|
849
|
+
verb: 'create ac',
|
|
850
|
+
});
|
|
851
|
+
if (gateErr) return { ...gateErr, documentId: parentUsId };
|
|
852
|
+
if (options.dryRun) {
|
|
853
|
+
return { id: acId, filePath: relPath, parentId: parentUsId, dryRun: true, body: acEntry };
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
await writeJsonAtomic(pathForKindFile(projectRoot, 'userStory', parentUsId), nextUs);
|
|
857
|
+
} catch (err) {
|
|
858
|
+
return rcfError({ kind: 'ioFailure', message: `create ac: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
859
|
+
}
|
|
860
|
+
return { id: acId, filePath: relPath, parentId: parentUsId, body: acEntry };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Create an inline TC under a parent TS.
|
|
865
|
+
*/
|
|
866
|
+
async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [] }) {
|
|
867
|
+
const parentTsId = options.parentId;
|
|
868
|
+
if (typeof parentTsId !== 'string' || parentTsId.length === 0) {
|
|
869
|
+
return rcfError({ kind: 'usage', message: 'create tc: --parent TS-XXX is required' });
|
|
870
|
+
}
|
|
871
|
+
const ts = tree.byId.get(parentTsId);
|
|
872
|
+
if (!ts || tree.kindById.get(parentTsId) !== 'testSuite') {
|
|
873
|
+
return rcfError({
|
|
874
|
+
kind: 'brokenReference',
|
|
875
|
+
message: `create tc: parent ${parentTsId} is not an existing TS`,
|
|
876
|
+
documentId: parentTsId,
|
|
877
|
+
field: 'parent',
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
const acId = body?.acId ?? options.acId;
|
|
881
|
+
if (typeof acId !== 'string' || acId.length === 0) {
|
|
882
|
+
return rcfError({ kind: 'usage', message: 'create tc: --ac AC-XXX is required' });
|
|
883
|
+
}
|
|
884
|
+
const allAcIds = collectAllAcIds(tree);
|
|
885
|
+
if (!allAcIds.has(acId)) {
|
|
886
|
+
return rcfError({
|
|
887
|
+
kind: 'brokenReference',
|
|
888
|
+
message: `create tc: acId ${acId} does not resolve to a known AC`,
|
|
889
|
+
documentId: acId,
|
|
890
|
+
field: 'acId',
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
const description = body?.description;
|
|
894
|
+
if (typeof description !== 'string' || description.length === 0) {
|
|
895
|
+
return rcfError({ kind: 'usage', message: 'create tc: --description is required' });
|
|
896
|
+
}
|
|
897
|
+
// w-2026-07-28-005: a TC without a test pointer is a coverage claim with
|
|
898
|
+
// nothing behind it; the schema overlay refuses it anyway, but refuse
|
|
899
|
+
// here first with a usable message.
|
|
900
|
+
const testPointer = options.testPointer ?? body?.testPointer;
|
|
901
|
+
if (typeof testPointer !== 'string' || testPointer.length === 0) {
|
|
902
|
+
return rcfError({
|
|
903
|
+
kind: 'usage',
|
|
904
|
+
message: 'create tc: --test-pointer is required (format filePath::testName; coverage counts a TC only when its pointer resolves to a real test)',
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
const slug = options.slug ?? deriveSlug(description);
|
|
908
|
+
const tsSuffix = /^TS-(\d{3})$/.exec(parentTsId)?.[1];
|
|
909
|
+
if (!tsSuffix) {
|
|
910
|
+
return rcfError({ kind: 'usage', message: `create tc: parent ${parentTsId} has an unrecognised id shape` });
|
|
911
|
+
}
|
|
912
|
+
const tcId = options.id ?? `TC-${tsSuffix}-${slug}`;
|
|
913
|
+
// Slug collision fails explicitly (§D10 OQ-P4-R-1).
|
|
914
|
+
const existing = (ts.testCases ?? []).find((tc) => tc.id === tcId);
|
|
915
|
+
if (existing) {
|
|
916
|
+
return rcfError({
|
|
917
|
+
kind: 'usage',
|
|
918
|
+
message: `create tc: slug collision on ${tcId}, supply --slug explicitly`,
|
|
919
|
+
documentId: tcId,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
const tcEntry = {
|
|
923
|
+
id: tcId,
|
|
924
|
+
acId,
|
|
925
|
+
description,
|
|
926
|
+
status: body?.status ?? 'pending',
|
|
927
|
+
testPointer,
|
|
928
|
+
};
|
|
929
|
+
const nextTs = {
|
|
930
|
+
...ts,
|
|
931
|
+
testCases: [...(ts.testCases ?? []), tcEntry],
|
|
932
|
+
updatedAt: nowIso(),
|
|
933
|
+
};
|
|
934
|
+
const relPath = `rcf/test-suites/${parentTsId.toLowerCase()}.json`;
|
|
935
|
+
const validation = validateDocument({ doc: nextTs, kind: 'testSuite', filePath: relPath });
|
|
936
|
+
if (validation) return { ...validation, documentId: parentTsId };
|
|
937
|
+
const gateErr = postWriteGate({
|
|
938
|
+
tree,
|
|
939
|
+
walkErrors,
|
|
940
|
+
upserts: [{ kind: 'testSuite', id: parentTsId, doc: nextTs }],
|
|
941
|
+
verb: 'create tc',
|
|
942
|
+
});
|
|
943
|
+
if (gateErr) return { ...gateErr, documentId: parentTsId };
|
|
944
|
+
if (options.dryRun) {
|
|
945
|
+
return { id: tcId, filePath: relPath, parentId: parentTsId, dryRun: true, body: tcEntry };
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
await writeJsonAtomic(pathForKindFile(projectRoot, 'testSuite', parentTsId), nextTs);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
return rcfError({ kind: 'ioFailure', message: `create tc: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
951
|
+
}
|
|
952
|
+
return { id: tcId, filePath: relPath, parentId: parentTsId, body: tcEntry };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Derive a slug from a description string. Lowercase, alphanumerics
|
|
957
|
+
* plus hyphens, first 40 chars max, single hyphen runs. Truncation
|
|
958
|
+
* lands on a word boundary: if the 40-char cut falls mid-word the
|
|
959
|
+
* partial word is dropped (B2 fix, E2E matrix 2026-07-06-003 - ids
|
|
960
|
+
* like "...-saved-whil" chopped mid-word). A single unbroken word
|
|
961
|
+
* longer than the limit keeps its 40-char prefix (no boundary exists).
|
|
962
|
+
* @param {string} description
|
|
963
|
+
*/
|
|
964
|
+
export function deriveSlug(description) {
|
|
965
|
+
const full = String(description)
|
|
966
|
+
.toLowerCase()
|
|
967
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
968
|
+
.replace(/^-+|-+$/g, '');
|
|
969
|
+
let slug = full.slice(0, 40);
|
|
970
|
+
if (full.length > 40 && full[40] !== '-') {
|
|
971
|
+
// The cut landed inside a word - back off to the last hyphen.
|
|
972
|
+
const boundary = slug.lastIndexOf('-');
|
|
973
|
+
if (boundary > 0) slug = slug.slice(0, boundary);
|
|
974
|
+
}
|
|
975
|
+
slug = slug.replace(/-+$/g, '');
|
|
976
|
+
return slug.length > 0 ? slug : 'tc';
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Apply a set of dot-path assignments and/or a body merge to an
|
|
981
|
+
* existing document. Refuses to touch `id`, `createdAt`, or
|
|
982
|
+
* `schemaVersion`. Returns `{ id, filePath }` or an RcfError.
|
|
983
|
+
*
|
|
984
|
+
* @param {object} args
|
|
985
|
+
* @param {string} args.projectRoot
|
|
986
|
+
* @param {TreeModel} args.tree
|
|
987
|
+
* @param {string} args.id - target document id (supports inline AC / TC ids)
|
|
988
|
+
* @param {object} [args.patch] - deep-merge target (from --from-file)
|
|
989
|
+
* @param {Array<{ path: string, value: unknown }>} [args.sets] - dot-path assignments
|
|
990
|
+
* @param {object} [args.options]
|
|
991
|
+
* @param {boolean} [args.options.dryRun]
|
|
992
|
+
* @param {RcfError[]} [args.walkErrors] - pre-write walk errors (B5 gate)
|
|
993
|
+
* @returns {Promise<{ id: string, filePath: string, body: object } | RcfError>}
|
|
994
|
+
*/
|
|
995
|
+
export async function updateDocument({ projectRoot, tree, id, patch, sets = [], options = {}, walkErrors = [] }) {
|
|
996
|
+
// Inline id resolution.
|
|
997
|
+
const inline = resolveInlineId(id);
|
|
998
|
+
if (inline) {
|
|
999
|
+
return await updateInline({
|
|
1000
|
+
projectRoot, tree, inline, id, patch, sets, options, walkErrors,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
// Root or child document.
|
|
1004
|
+
const resolved = pathForId(id);
|
|
1005
|
+
if (!resolved) {
|
|
1006
|
+
return rcfError({ kind: 'usage', message: `update: unrecognised id ${id}`, documentId: id });
|
|
1007
|
+
}
|
|
1008
|
+
const kind = resolved.kind;
|
|
1009
|
+
const rootKinds = new Set(['prd', 'tad', 'buildSequence']);
|
|
1010
|
+
// B5: schema-invalid docs are absent from byId but stay addressable
|
|
1011
|
+
// via invalidDocs - updating one is exactly how a wedged tree gets
|
|
1012
|
+
// repaired (the post-write gate ensures the repair actually repairs).
|
|
1013
|
+
let doc;
|
|
1014
|
+
if (rootKinds.has(kind)) {
|
|
1015
|
+
doc = tree.byId.get(id) ?? tree[rootKindTreeKey(kind)] ?? tree.invalidDocs?.get(id)?.doc;
|
|
1016
|
+
} else if (kind === 'manifest') {
|
|
1017
|
+
doc = tree.manifest;
|
|
1018
|
+
} else {
|
|
1019
|
+
doc = tree.byId.get(id) ?? tree.invalidDocs?.get(id)?.doc;
|
|
1020
|
+
}
|
|
1021
|
+
if (!doc) {
|
|
1022
|
+
return rcfError({ kind: 'usage', message: `update: id ${id} not found`, documentId: id });
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Build patched body: start from doc, apply --from-file deep merge,
|
|
1026
|
+
// then apply each --set dot-path assignment.
|
|
1027
|
+
let next = deepClone(doc);
|
|
1028
|
+
if (patch && typeof patch === 'object') {
|
|
1029
|
+
next = deepMergeReplaceArrays(next, patch);
|
|
1030
|
+
}
|
|
1031
|
+
for (const { path, value } of sets) {
|
|
1032
|
+
const err = applyDotPath(next, path, value);
|
|
1033
|
+
if (err) return rcfError({ kind: 'usage', message: err, documentId: id });
|
|
1034
|
+
}
|
|
1035
|
+
// Refuse to update immutable fields.
|
|
1036
|
+
const immutableTouched = ['id', 'createdAt', 'schemaVersion'].filter((f) => {
|
|
1037
|
+
// Fields at top level only.
|
|
1038
|
+
return (patch && Object.prototype.hasOwnProperty.call(patch, f))
|
|
1039
|
+
|| sets.some((s) => s.path === f);
|
|
1040
|
+
});
|
|
1041
|
+
if (immutableTouched.length > 0) {
|
|
1042
|
+
return rcfError({
|
|
1043
|
+
kind: 'usage',
|
|
1044
|
+
message: `update: refusing to modify immutable field(s): ${immutableTouched.join(', ')}`,
|
|
1045
|
+
documentId: id,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
next.updatedAt = nowIso();
|
|
1049
|
+
const relPath = kind === 'manifest'
|
|
1050
|
+
? 'rcf/manifest.json'
|
|
1051
|
+
: rootKinds.has(kind)
|
|
1052
|
+
? `rcf/${resolved.relPath}`
|
|
1053
|
+
: `rcf/${resolved.relPath}`;
|
|
1054
|
+
const validation = validateDocument({ doc: next, kind, filePath: relPath });
|
|
1055
|
+
if (validation) return { ...validation, documentId: id };
|
|
1056
|
+
|
|
1057
|
+
// Cross-link resolution for update-time reference-integrity:
|
|
1058
|
+
// if the caller touched acIds / tacIds / dependsOnFbsIds, verify each
|
|
1059
|
+
// referenced id is loaded.
|
|
1060
|
+
const refCheck = checkCrossLinks(tree, kind, next, id);
|
|
1061
|
+
if (refCheck) return refCheck;
|
|
1062
|
+
|
|
1063
|
+
// B5 gate: the updated doc must not introduce net-new tree breakage
|
|
1064
|
+
// (e.g. dropping an AC that a surviving FBS / TS still references).
|
|
1065
|
+
if (kind !== 'manifest') {
|
|
1066
|
+
const gateErr = postWriteGate({
|
|
1067
|
+
tree,
|
|
1068
|
+
walkErrors,
|
|
1069
|
+
upserts: [{ kind, id, doc: next }],
|
|
1070
|
+
verb: 'update',
|
|
1071
|
+
});
|
|
1072
|
+
if (gateErr) return { ...gateErr, documentId: id };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const absPath = kind === 'manifest'
|
|
1076
|
+
? pathForRootDoc(projectRoot, 'manifest')
|
|
1077
|
+
: rootKinds.has(kind)
|
|
1078
|
+
? pathForRootDoc(projectRoot, kind)
|
|
1079
|
+
: pathForKindFile(projectRoot, kind, id);
|
|
1080
|
+
if (options.dryRun) {
|
|
1081
|
+
return { id, filePath: relPath, body: next, dryRun: true };
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
await writeJsonAtomic(absPath, next);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
return rcfError({ kind: 'ioFailure', message: `update: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
1087
|
+
}
|
|
1088
|
+
return { id, filePath: relPath, body: next };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function rootKindTreeKey(kind) {
|
|
1092
|
+
if (kind === 'prd') return 'prd';
|
|
1093
|
+
if (kind === 'tad') return 'tad';
|
|
1094
|
+
if (kind === 'buildSequence') return 'bs';
|
|
1095
|
+
return kind;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function checkCrossLinks(tree, kind, doc, docId) {
|
|
1099
|
+
const allAcIds = collectAllAcIds(tree);
|
|
1100
|
+
if (kind === 'fbs') {
|
|
1101
|
+
for (const acId of doc.acIds ?? []) {
|
|
1102
|
+
if (!allAcIds.has(acId)) {
|
|
1103
|
+
return rcfError({
|
|
1104
|
+
kind: 'brokenReference', message: `update: acId ${acId} does not resolve to a known AC`,
|
|
1105
|
+
documentId: docId, field: 'acIds', rule: 'resolveTo:ac',
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
for (const depId of doc.dependsOnFbsIds ?? []) {
|
|
1110
|
+
if (tree.kindById.get(depId) !== 'fbs' || depId === docId) {
|
|
1111
|
+
return rcfError({
|
|
1112
|
+
kind: 'brokenReference', message: `update: dependsOnFbsIds entry ${depId} is invalid`,
|
|
1113
|
+
documentId: docId, field: 'dependsOnFbsIds', rule: 'resolveTo:fbs',
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (kind === 'testSuite') {
|
|
1119
|
+
for (const acId of doc.acIds ?? []) {
|
|
1120
|
+
if (!allAcIds.has(acId)) {
|
|
1121
|
+
return rcfError({
|
|
1122
|
+
kind: 'brokenReference', message: `update: acId ${acId} does not resolve to a known AC`,
|
|
1123
|
+
documentId: docId, field: 'acIds', rule: 'resolveTo:ac',
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (kind === 'userStory') {
|
|
1129
|
+
for (const tacId of doc.tacIds ?? []) {
|
|
1130
|
+
if (tree.kindById.get(tacId) !== 'tac') {
|
|
1131
|
+
return rcfError({
|
|
1132
|
+
kind: 'brokenReference', message: `update: tacId ${tacId} does not resolve to a known TAC`,
|
|
1133
|
+
documentId: docId, field: 'tacIds', rule: 'resolveTo:tac',
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Phase 10 (X2 CodeNode bridge): CN cross-link resolution on update.
|
|
1139
|
+
if (kind === 'codeNode') {
|
|
1140
|
+
for (const acId of doc.implementsAcIds ?? []) {
|
|
1141
|
+
if (!allAcIds.has(acId)) {
|
|
1142
|
+
return rcfError({
|
|
1143
|
+
kind: 'brokenReference', message: `update: implementsAcIds entry ${acId} does not resolve to a known AC`,
|
|
1144
|
+
documentId: docId, field: 'implementsAcIds', rule: 'resolveTo:ac',
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
for (const depId of doc.dependencies ?? []) {
|
|
1149
|
+
if (depId === docId || tree.kindById.get(depId) !== 'codeNode') {
|
|
1150
|
+
return rcfError({
|
|
1151
|
+
kind: 'brokenReference', message: `update: dependencies entry ${depId} is invalid`,
|
|
1152
|
+
documentId: docId, field: 'dependencies', rule: 'resolveTo:codeNode',
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function resolveInlineId(id) {
|
|
1161
|
+
if (typeof id !== 'string') return null;
|
|
1162
|
+
if (/^AC-\d+(-\d+)?$/.test(id)) return { kind: 'ac' };
|
|
1163
|
+
if (/^TC-\d{3}-[a-z0-9-]+$/.test(id)) return { kind: 'tc' };
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Update an inline AC or TC by mutating its parent document.
|
|
1169
|
+
*/
|
|
1170
|
+
async function updateInline({ projectRoot, tree, inline, id, patch, sets, options, walkErrors = [] }) {
|
|
1171
|
+
const parentId = tree.parentByChild.get(id);
|
|
1172
|
+
if (!parentId) {
|
|
1173
|
+
return rcfError({ kind: 'usage', message: `update: inline id ${id} has no resolvable parent`, documentId: id });
|
|
1174
|
+
}
|
|
1175
|
+
if (inline.kind === 'ac' && tree.kindById.get(parentId) !== 'userStory') {
|
|
1176
|
+
return rcfError({ kind: 'usage', message: `update: inline ${id} parent ${parentId} is not a US`, documentId: id });
|
|
1177
|
+
}
|
|
1178
|
+
if (inline.kind === 'tc' && tree.kindById.get(parentId) !== 'testSuite') {
|
|
1179
|
+
return rcfError({ kind: 'usage', message: `update: inline ${id} parent ${parentId} is not a TS`, documentId: id });
|
|
1180
|
+
}
|
|
1181
|
+
const parent = tree.byId.get(parentId);
|
|
1182
|
+
const arrayField = inline.kind === 'ac' ? 'acceptanceCriteria' : 'testCases';
|
|
1183
|
+
const entries = parent[arrayField] ?? [];
|
|
1184
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
1185
|
+
if (idx < 0) {
|
|
1186
|
+
return rcfError({ kind: 'usage', message: `update: inline ${id} not found under ${parentId}`, documentId: id });
|
|
1187
|
+
}
|
|
1188
|
+
let entry = deepClone(entries[idx]);
|
|
1189
|
+
if (patch && typeof patch === 'object') {
|
|
1190
|
+
entry = deepMergeReplaceArrays(entry, patch);
|
|
1191
|
+
}
|
|
1192
|
+
for (const { path, value } of sets) {
|
|
1193
|
+
const err = applyDotPath(entry, path, value);
|
|
1194
|
+
if (err) return rcfError({ kind: 'usage', message: err, documentId: id });
|
|
1195
|
+
}
|
|
1196
|
+
if (Object.prototype.hasOwnProperty.call(entry, 'id') && entry.id !== id) {
|
|
1197
|
+
return rcfError({ kind: 'usage', message: 'update: refusing to modify immutable field(s): id', documentId: id });
|
|
1198
|
+
}
|
|
1199
|
+
const nextParent = { ...parent, [arrayField]: [...entries.slice(0, idx), entry, ...entries.slice(idx + 1)], updatedAt: nowIso() };
|
|
1200
|
+
const kind = inline.kind === 'ac' ? 'userStory' : 'testSuite';
|
|
1201
|
+
const relPath = `rcf/${subdirFor(kind)}/${parentId.toLowerCase()}.json`;
|
|
1202
|
+
const validation = validateDocument({ doc: nextParent, kind, filePath: relPath });
|
|
1203
|
+
if (validation) return { ...validation, documentId: parentId };
|
|
1204
|
+
const gateErr = postWriteGate({
|
|
1205
|
+
tree,
|
|
1206
|
+
walkErrors,
|
|
1207
|
+
upserts: [{ kind, id: parentId, doc: nextParent }],
|
|
1208
|
+
verb: 'update',
|
|
1209
|
+
});
|
|
1210
|
+
if (gateErr) return { ...gateErr, documentId: parentId };
|
|
1211
|
+
if (options.dryRun) {
|
|
1212
|
+
return { id, filePath: relPath, parentId, body: entry, dryRun: true };
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
await writeJsonAtomic(pathForKindFile(projectRoot, kind, parentId), nextParent);
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
return rcfError({ kind: 'ioFailure', message: `update: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
1218
|
+
}
|
|
1219
|
+
return { id, filePath: relPath, parentId, body: entry };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ---------------------------------------------------------------------------
|
|
1223
|
+
// Delete
|
|
1224
|
+
// ---------------------------------------------------------------------------
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Delete a document. Refuses by default when the doc has dependents;
|
|
1228
|
+
* `options.cascade` opts in. `options.dryRun` prints the plan without
|
|
1229
|
+
* touching disk. Returns:
|
|
1230
|
+
* { deleted: string[], mutated: Array<{ id, filePath }>, plan: string[] }
|
|
1231
|
+
* or an RcfError. Exit-code 4 refusals are surfaced as `usage` errors
|
|
1232
|
+
* with a `rule` field the CLI maps to exit 4 (`dependents` /
|
|
1233
|
+
* `wouldOrphan`).
|
|
1234
|
+
*
|
|
1235
|
+
* @param {object} args
|
|
1236
|
+
* @param {string} args.projectRoot
|
|
1237
|
+
* @param {TreeModel} args.tree
|
|
1238
|
+
* @param {string} args.id
|
|
1239
|
+
* @param {object} [args.options]
|
|
1240
|
+
* @param {boolean}[args.options.cascade]
|
|
1241
|
+
* @param {boolean}[args.options.dryRun]
|
|
1242
|
+
* @param {RcfError[]} [args.walkErrors] - pre-write walk errors (B5 gate)
|
|
1243
|
+
* @returns {Promise<{ deleted: string[], mutated: Array<{ id: string, filePath: string }>, plan: string[] } | RcfError>}
|
|
1244
|
+
*/
|
|
1245
|
+
export async function deleteDocument({ projectRoot, tree, id, options = {}, walkErrors = [] }) {
|
|
1246
|
+
const inline = resolveInlineId(id);
|
|
1247
|
+
if (inline) {
|
|
1248
|
+
return await deleteInline({ projectRoot, tree, inline, id, options, walkErrors });
|
|
1249
|
+
}
|
|
1250
|
+
const resolved = pathForId(id);
|
|
1251
|
+
if (!resolved) {
|
|
1252
|
+
return rcfError({ kind: 'usage', message: `delete: unrecognised id ${id}`, documentId: id });
|
|
1253
|
+
}
|
|
1254
|
+
const kind = resolved.kind;
|
|
1255
|
+
const rootKinds = new Set(['prd', 'tad', 'buildSequence', 'manifest']);
|
|
1256
|
+
if (rootKinds.has(kind)) {
|
|
1257
|
+
return rcfError({
|
|
1258
|
+
kind: 'usage',
|
|
1259
|
+
message: `delete: root singleton ${id} cannot be deleted via rcf delete`,
|
|
1260
|
+
documentId: id,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const doc = tree.byId.get(id);
|
|
1264
|
+
if (!doc) {
|
|
1265
|
+
// B5: the doc may exist on disk but have failed to load (schema-
|
|
1266
|
+
// invalid or parse-broken). Deleting it is the canonical escape from
|
|
1267
|
+
// a wedged tree; a delete is never blocked by validation errors
|
|
1268
|
+
// attributable solely to the doc being deleted. The post-write gate
|
|
1269
|
+
// still refuses if removing the file would introduce NET-NEW
|
|
1270
|
+
// breakage elsewhere.
|
|
1271
|
+
const invalid = tree.invalidDocs?.get(id);
|
|
1272
|
+
const absPath = pathForKindFile(projectRoot, kind, id);
|
|
1273
|
+
if (invalid || await fileExistsOnDisk(absPath)) {
|
|
1274
|
+
const gateErr = postWriteGate({ tree, walkErrors, deletes: [id], verb: 'delete' });
|
|
1275
|
+
if (gateErr) return gateErr;
|
|
1276
|
+
const relPath = relativePathForChild(kind, id);
|
|
1277
|
+
if (!options.dryRun) {
|
|
1278
|
+
try { await unlink(absPath); } catch (err) {
|
|
1279
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return { deleted: [id], mutated: [], plan: [`delete ${relPath}`] };
|
|
1283
|
+
}
|
|
1284
|
+
return rcfError({ kind: 'usage', message: `delete: id ${id} not found`, documentId: id });
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const cascade = Boolean(options.cascade);
|
|
1288
|
+
|
|
1289
|
+
switch (kind) {
|
|
1290
|
+
case 'req': return await deleteReq({ projectRoot, tree, id, cascade, options, walkErrors });
|
|
1291
|
+
case 'userStory': return await deleteUs({ projectRoot, tree, id, cascade, options, walkErrors });
|
|
1292
|
+
case 'tac': return await deleteTac({ projectRoot, tree, id, cascade, options, walkErrors });
|
|
1293
|
+
case 'adr': return await deleteAdr({ projectRoot, tree, id, options, walkErrors });
|
|
1294
|
+
case 'fbs': return await deleteFbs({ projectRoot, tree, id, cascade, options, walkErrors });
|
|
1295
|
+
case 'testSuite': return await deleteTs({ projectRoot, tree, id, options, walkErrors });
|
|
1296
|
+
// Phase 10 (X2 CodeNode bridge, D13): refused while depended-on,
|
|
1297
|
+
// mirroring the FBS dependsOnFbsIds pattern.
|
|
1298
|
+
case 'codeNode': return await deleteCn({ projectRoot, tree, id, cascade, options, walkErrors });
|
|
1299
|
+
default:
|
|
1300
|
+
return rcfError({ kind: 'usage', message: `delete: unsupported kind ${kind}`, documentId: id });
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function deleteReq({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
|
|
1305
|
+
// Discover: child US ids via childrenByParent.
|
|
1306
|
+
const childUsIds = (tree.childrenByParent.get(id) ?? []).filter((cid) => tree.kindById.get(cid) === 'userStory');
|
|
1307
|
+
// Discover: TS ids under each child US.
|
|
1308
|
+
const tsIds = [];
|
|
1309
|
+
const collectedAcIds = new Set();
|
|
1310
|
+
for (const usId of childUsIds) {
|
|
1311
|
+
const us = tree.byId.get(usId);
|
|
1312
|
+
for (const ac of us.acceptanceCriteria ?? []) collectedAcIds.add(ac.id);
|
|
1313
|
+
for (const cid of tree.childrenByParent.get(usId) ?? []) {
|
|
1314
|
+
if (tree.kindById.get(cid) === 'testSuite') tsIds.push(cid);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const dependents = childUsIds.length + tsIds.length + collectedAcIds.size;
|
|
1318
|
+
if (!cascade && dependents > 0) {
|
|
1319
|
+
return refuseWithDependents(id, {
|
|
1320
|
+
childUs: childUsIds,
|
|
1321
|
+
childTs: tsIds,
|
|
1322
|
+
collectedAcs: [...collectedAcIds],
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
return await executeCascade({
|
|
1326
|
+
projectRoot,
|
|
1327
|
+
tree,
|
|
1328
|
+
toDelete: [id, ...childUsIds, ...tsIds],
|
|
1329
|
+
collectedAcIds,
|
|
1330
|
+
options,
|
|
1331
|
+
walkErrors,
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function deleteUs({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
|
|
1336
|
+
const us = tree.byId.get(id);
|
|
1337
|
+
const collectedAcIds = new Set((us.acceptanceCriteria ?? []).map((ac) => ac.id));
|
|
1338
|
+
const childTsIds = (tree.childrenByParent.get(id) ?? []).filter((cid) => tree.kindById.get(cid) === 'testSuite');
|
|
1339
|
+
const dependents = childTsIds.length + collectedAcIds.size;
|
|
1340
|
+
if (!cascade && dependents > 0) {
|
|
1341
|
+
return refuseWithDependents(id, { childTs: childTsIds, collectedAcs: [...collectedAcIds] });
|
|
1342
|
+
}
|
|
1343
|
+
return await executeCascade({
|
|
1344
|
+
projectRoot,
|
|
1345
|
+
tree,
|
|
1346
|
+
toDelete: [id, ...childTsIds],
|
|
1347
|
+
collectedAcIds,
|
|
1348
|
+
options,
|
|
1349
|
+
walkErrors,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
async function deleteTac({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
|
|
1354
|
+
const dependents = (tree.usByTacId.get(id) ?? []).slice();
|
|
1355
|
+
if (!cascade && dependents.length > 0) {
|
|
1356
|
+
return refuseWithDependents(id, { usDependents: dependents });
|
|
1357
|
+
}
|
|
1358
|
+
// Two-phase (B5): compute every mutation first, gate the whole
|
|
1359
|
+
// change-set on the post-write tree state, then flush.
|
|
1360
|
+
const pending = [];
|
|
1361
|
+
if (cascade) {
|
|
1362
|
+
for (const usId of dependents) {
|
|
1363
|
+
const us = tree.byId.get(usId);
|
|
1364
|
+
const next = { ...us, tacIds: (us.tacIds ?? []).filter((t) => t !== id), updatedAt: nowIso() };
|
|
1365
|
+
if (next.tacIds.length === 0) delete next.tacIds; // schema allows omission (minItems:0 with optional presence)
|
|
1366
|
+
const relPath = `rcf/user-stories/${usId.toLowerCase()}.json`;
|
|
1367
|
+
const validation = validateDocument({ doc: next, kind: 'userStory', filePath: relPath });
|
|
1368
|
+
if (validation) return { ...validation, documentId: usId };
|
|
1369
|
+
pending.push({ kind: 'userStory', id: usId, doc: next, relPath });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const gateErr = postWriteGate({
|
|
1373
|
+
tree,
|
|
1374
|
+
walkErrors,
|
|
1375
|
+
upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
|
|
1376
|
+
deletes: [id],
|
|
1377
|
+
verb: 'delete',
|
|
1378
|
+
});
|
|
1379
|
+
if (gateErr) return gateErr;
|
|
1380
|
+
const mutated = [];
|
|
1381
|
+
for (const p of pending) {
|
|
1382
|
+
if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
|
|
1383
|
+
mutated.push({ id: p.id, filePath: p.relPath });
|
|
1384
|
+
}
|
|
1385
|
+
// Delete the TAC file.
|
|
1386
|
+
const tacRel = `rcf/tacs/${id.toLowerCase()}.json`;
|
|
1387
|
+
if (!options.dryRun) {
|
|
1388
|
+
try { await unlink(pathForKindFile(projectRoot, 'tac', id)); } catch (err) {
|
|
1389
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: tacRel, stack: err.stack });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return { deleted: [id], mutated, plan: buildPlanLines([id], mutated) };
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
async function deleteAdr({ projectRoot, tree, id, options, walkErrors = [] }) {
|
|
1396
|
+
const gateErr = postWriteGate({ tree, walkErrors, deletes: [id], verb: 'delete' });
|
|
1397
|
+
if (gateErr) return gateErr;
|
|
1398
|
+
const adrRel = `rcf/adrs/${id.toLowerCase()}.json`;
|
|
1399
|
+
if (!options.dryRun) {
|
|
1400
|
+
try { await unlink(pathForKindFile(projectRoot, 'adr', id)); } catch (err) {
|
|
1401
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: adrRel, stack: err.stack });
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return { deleted: [id], mutated: [], plan: [`delete ${adrRel}`] };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function deleteFbs({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
|
|
1408
|
+
const dependents = (tree.dependentsByFbsId.get(id) ?? []).slice();
|
|
1409
|
+
if (!cascade && dependents.length > 0) {
|
|
1410
|
+
return refuseWithDependents(id, { fbsDependents: dependents });
|
|
1411
|
+
}
|
|
1412
|
+
const pending = [];
|
|
1413
|
+
if (cascade) {
|
|
1414
|
+
for (const depFbsId of dependents) {
|
|
1415
|
+
const dep = tree.byId.get(depFbsId);
|
|
1416
|
+
const next = {
|
|
1417
|
+
...dep,
|
|
1418
|
+
dependsOnFbsIds: (dep.dependsOnFbsIds ?? []).filter((d) => d !== id),
|
|
1419
|
+
updatedAt: nowIso(),
|
|
1420
|
+
};
|
|
1421
|
+
const relPath = `rcf/fbs/${depFbsId.toLowerCase()}.json`;
|
|
1422
|
+
const validation = validateDocument({ doc: next, kind: 'fbs', filePath: relPath });
|
|
1423
|
+
if (validation) return { ...validation, documentId: depFbsId };
|
|
1424
|
+
pending.push({ kind: 'fbs', id: depFbsId, doc: next, relPath });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const gateErr = postWriteGate({
|
|
1428
|
+
tree,
|
|
1429
|
+
walkErrors,
|
|
1430
|
+
upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
|
|
1431
|
+
deletes: [id],
|
|
1432
|
+
verb: 'delete',
|
|
1433
|
+
});
|
|
1434
|
+
if (gateErr) return gateErr;
|
|
1435
|
+
const mutated = [];
|
|
1436
|
+
for (const p of pending) {
|
|
1437
|
+
if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
|
|
1438
|
+
mutated.push({ id: p.id, filePath: p.relPath });
|
|
1439
|
+
}
|
|
1440
|
+
const fbsRel = `rcf/fbs/${id.toLowerCase()}.json`;
|
|
1441
|
+
if (!options.dryRun) {
|
|
1442
|
+
try { await unlink(pathForKindFile(projectRoot, 'fbs', id)); } catch (err) {
|
|
1443
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: fbsRel, stack: err.stack });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return { deleted: [id], mutated, plan: buildPlanLines([id], mutated) };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Delete a Code Node (Phase 10, D13). Refused by default while another
|
|
1451
|
+
* CN depends on it (dependentsByCnId); --cascade drops the dependency
|
|
1452
|
+
* edge from every dependent CN's `dependencies[]` before removing the
|
|
1453
|
+
* file, mirroring `deleteFbs`.
|
|
1454
|
+
*/
|
|
1455
|
+
async function deleteCn({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
|
|
1456
|
+
const dependents = (tree.dependentsByCnId.get(id) ?? []).slice();
|
|
1457
|
+
if (!cascade && dependents.length > 0) {
|
|
1458
|
+
return refuseWithDependents(id, { cnDependents: dependents });
|
|
1459
|
+
}
|
|
1460
|
+
const pending = [];
|
|
1461
|
+
if (cascade) {
|
|
1462
|
+
for (const depCnId of dependents) {
|
|
1463
|
+
const dep = tree.byId.get(depCnId);
|
|
1464
|
+
const next = {
|
|
1465
|
+
...dep,
|
|
1466
|
+
dependencies: (dep.dependencies ?? []).filter((d) => d !== id),
|
|
1467
|
+
updatedAt: nowIso(),
|
|
1468
|
+
};
|
|
1469
|
+
const relPath = `rcf/code-nodes/${depCnId.toLowerCase()}.json`;
|
|
1470
|
+
const validation = validateDocument({ doc: next, kind: 'codeNode', filePath: relPath });
|
|
1471
|
+
if (validation) return { ...validation, documentId: depCnId };
|
|
1472
|
+
pending.push({ kind: 'codeNode', id: depCnId, doc: next, relPath });
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
const gateErr = postWriteGate({
|
|
1476
|
+
tree,
|
|
1477
|
+
walkErrors,
|
|
1478
|
+
upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
|
|
1479
|
+
deletes: [id],
|
|
1480
|
+
verb: 'delete',
|
|
1481
|
+
});
|
|
1482
|
+
if (gateErr) return gateErr;
|
|
1483
|
+
const mutated = [];
|
|
1484
|
+
for (const p of pending) {
|
|
1485
|
+
if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
|
|
1486
|
+
mutated.push({ id: p.id, filePath: p.relPath });
|
|
1487
|
+
}
|
|
1488
|
+
const cnRel = `rcf/code-nodes/${id.toLowerCase()}.json`;
|
|
1489
|
+
if (!options.dryRun) {
|
|
1490
|
+
try { await unlink(pathForKindFile(projectRoot, 'codeNode', id)); } catch (err) {
|
|
1491
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: cnRel, stack: err.stack });
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return { deleted: [id], mutated, plan: buildPlanLines([id], mutated) };
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function deleteTs({ projectRoot, tree, id, options, walkErrors = [] }) {
|
|
1498
|
+
const gateErr = postWriteGate({ tree, walkErrors, deletes: [id], verb: 'delete' });
|
|
1499
|
+
if (gateErr) return gateErr;
|
|
1500
|
+
const tsRel = `rcf/test-suites/${id.toLowerCase()}.json`;
|
|
1501
|
+
if (!options.dryRun) {
|
|
1502
|
+
try { await unlink(pathForKindFile(projectRoot, 'testSuite', id)); } catch (err) {
|
|
1503
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: tsRel, stack: err.stack });
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return { deleted: [id], mutated: [], plan: [`delete ${tsRel}`] };
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Delete an inline AC / TC entry. Refuses with exit 4 if the AC has
|
|
1511
|
+
* cross-link dependents; --cascade opts in and mutates FBS/TS acIds
|
|
1512
|
+
* accordingly (with an orphan-refuse pre-plan check per §D9).
|
|
1513
|
+
*/
|
|
1514
|
+
async function deleteInline({ projectRoot, tree, inline, id, options, walkErrors = [] }) {
|
|
1515
|
+
const parentId = tree.parentByChild.get(id);
|
|
1516
|
+
if (!parentId) {
|
|
1517
|
+
return rcfError({ kind: 'usage', message: `delete: inline ${id} has no resolvable parent`, documentId: id });
|
|
1518
|
+
}
|
|
1519
|
+
if (inline.kind === 'ac' && tree.kindById.get(parentId) !== 'userStory') {
|
|
1520
|
+
return rcfError({ kind: 'usage', message: `delete: parent ${parentId} for ${id} is not a US`, documentId: id });
|
|
1521
|
+
}
|
|
1522
|
+
if (inline.kind === 'tc' && tree.kindById.get(parentId) !== 'testSuite') {
|
|
1523
|
+
return rcfError({ kind: 'usage', message: `delete: parent ${parentId} for ${id} is not a TS`, documentId: id });
|
|
1524
|
+
}
|
|
1525
|
+
const parent = tree.byId.get(parentId);
|
|
1526
|
+
const arrayField = inline.kind === 'ac' ? 'acceptanceCriteria' : 'testCases';
|
|
1527
|
+
const entries = parent[arrayField] ?? [];
|
|
1528
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
1529
|
+
if (idx < 0) {
|
|
1530
|
+
return rcfError({ kind: 'usage', message: `delete: inline ${id} not found under ${parentId}`, documentId: id });
|
|
1531
|
+
}
|
|
1532
|
+
if (inline.kind === 'tc') {
|
|
1533
|
+
// No cross-refs to worry about (§D9 TC clause). Just drop the entry.
|
|
1534
|
+
const nextEntries = [...entries.slice(0, idx), ...entries.slice(idx + 1)];
|
|
1535
|
+
return await writeInlineParent({
|
|
1536
|
+
projectRoot, tree, walkErrors, parent, parentId, arrayField, nextEntries, options, deletedInlineIds: [id],
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
// AC path: check cross-refs and orphan-refuse.
|
|
1540
|
+
const dependentFbs = tree.fbsByAcId.get(id) ?? [];
|
|
1541
|
+
const dependentTs = tree.tsByAcId.get(id) ?? [];
|
|
1542
|
+
const hasDeps = dependentFbs.length + dependentTs.length > 0;
|
|
1543
|
+
if (!options.cascade && hasDeps) {
|
|
1544
|
+
return refuseWithDependents(id, { fbsAcDependents: dependentFbs, tsAcDependents: dependentTs });
|
|
1545
|
+
}
|
|
1546
|
+
// Would the parent US drop below minItems:1 on acceptanceCriteria?
|
|
1547
|
+
const nextEntries = [...entries.slice(0, idx), ...entries.slice(idx + 1)];
|
|
1548
|
+
if (nextEntries.length === 0) {
|
|
1549
|
+
return rcfError({
|
|
1550
|
+
kind: 'usage',
|
|
1551
|
+
message: `delete: would leave US ${parentId} with no acceptance criteria`,
|
|
1552
|
+
documentId: parentId,
|
|
1553
|
+
rule: 'wouldOrphan',
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
// Cascade path: compute FBS/TS acIds mutations (no writes yet - the
|
|
1557
|
+
// whole change-set is gated in writeInlineParent), orphan-refuse.
|
|
1558
|
+
const pending = [];
|
|
1559
|
+
if (options.cascade) {
|
|
1560
|
+
const orphanCheck = checkAcOrphans(tree, [id]);
|
|
1561
|
+
if (orphanCheck) return orphanCheck;
|
|
1562
|
+
for (const fbsId of dependentFbs) {
|
|
1563
|
+
const fbs = tree.byId.get(fbsId);
|
|
1564
|
+
const nextAcIds = (fbs.acIds ?? []).filter((a) => a !== id);
|
|
1565
|
+
const next = { ...fbs, acIds: nextAcIds, updatedAt: nowIso() };
|
|
1566
|
+
const relPath = `rcf/fbs/${fbsId.toLowerCase()}.json`;
|
|
1567
|
+
const validation = validateDocument({ doc: next, kind: 'fbs', filePath: relPath });
|
|
1568
|
+
if (validation) return { ...validation, documentId: fbsId };
|
|
1569
|
+
pending.push({ kind: 'fbs', id: fbsId, doc: next, relPath });
|
|
1570
|
+
}
|
|
1571
|
+
for (const tsId of dependentTs) {
|
|
1572
|
+
const ts = tree.byId.get(tsId);
|
|
1573
|
+
const nextAcIds = (ts.acIds ?? []).filter((a) => a !== id);
|
|
1574
|
+
const next = { ...ts, acIds: nextAcIds, updatedAt: nowIso() };
|
|
1575
|
+
const relPath = `rcf/test-suites/${tsId.toLowerCase()}.json`;
|
|
1576
|
+
const validation = validateDocument({ doc: next, kind: 'testSuite', filePath: relPath });
|
|
1577
|
+
if (validation) return { ...validation, documentId: tsId };
|
|
1578
|
+
pending.push({ kind: 'testSuite', id: tsId, doc: next, relPath });
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return await writeInlineParent({
|
|
1582
|
+
projectRoot, tree, walkErrors, parent, parentId, arrayField, nextEntries, options, pendingMutations: pending, deletedInlineIds: [id],
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
async function writeInlineParent({
|
|
1587
|
+
projectRoot, tree, walkErrors = [], parent, parentId, arrayField, nextEntries, options,
|
|
1588
|
+
pendingMutations = [], deletedInlineIds = [],
|
|
1589
|
+
}) {
|
|
1590
|
+
const parentKind = tree.kindById.get(parentId);
|
|
1591
|
+
const next = { ...parent, [arrayField]: nextEntries, updatedAt: nowIso() };
|
|
1592
|
+
const relPath = `rcf/${subdirFor(parentKind)}/${parentId.toLowerCase()}.json`;
|
|
1593
|
+
const validation = validateDocument({ doc: next, kind: parentKind, filePath: relPath });
|
|
1594
|
+
if (validation) return { ...validation, documentId: parentId };
|
|
1595
|
+
// B5 gate over the whole change-set (cascade mutations + parent edit).
|
|
1596
|
+
const gateErr = postWriteGate({
|
|
1597
|
+
tree,
|
|
1598
|
+
walkErrors,
|
|
1599
|
+
upserts: [
|
|
1600
|
+
...pendingMutations.map((m) => ({ kind: m.kind, id: m.id, doc: m.doc })),
|
|
1601
|
+
{ kind: parentKind, id: parentId, doc: next },
|
|
1602
|
+
],
|
|
1603
|
+
verb: 'delete',
|
|
1604
|
+
});
|
|
1605
|
+
if (gateErr) return gateErr;
|
|
1606
|
+
if (!options.dryRun) {
|
|
1607
|
+
try {
|
|
1608
|
+
for (const m of pendingMutations) {
|
|
1609
|
+
await writeJsonAtomic(pathForKindFile(projectRoot, m.kind, m.id), m.doc);
|
|
1610
|
+
}
|
|
1611
|
+
await writeJsonAtomic(pathForKindFile(projectRoot, parentKind, parentId), next);
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
return rcfError({ kind: 'ioFailure', message: `delete: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
const mutated = [
|
|
1617
|
+
...pendingMutations.map((m) => ({ id: m.id, filePath: m.relPath })),
|
|
1618
|
+
{ id: parentId, filePath: relPath },
|
|
1619
|
+
];
|
|
1620
|
+
const plan = [
|
|
1621
|
+
...pendingMutations.map((m) => `mutate ${m.relPath}`),
|
|
1622
|
+
`mutate ${relPath} (drop inline entries)`,
|
|
1623
|
+
];
|
|
1624
|
+
return { deleted: [...deletedInlineIds], mutated, plan };
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Execute a cascade delete plan (REQ/US cascade). Runs the orphan-refuse
|
|
1629
|
+
* pre-plan check first; simulates every FBS/TS `acIds` mutation and
|
|
1630
|
+
* refuses the whole cascade if any surviving FBS/TS would fall below
|
|
1631
|
+
* minItems:1.
|
|
1632
|
+
*/
|
|
1633
|
+
async function executeCascade({ projectRoot, tree, toDelete, collectedAcIds, options, walkErrors = [] }) {
|
|
1634
|
+
const deletingSet = new Set(toDelete);
|
|
1635
|
+
const orphanCheck = checkAcOrphans(tree, [...collectedAcIds], deletingSet);
|
|
1636
|
+
if (orphanCheck) return orphanCheck;
|
|
1637
|
+
|
|
1638
|
+
// Two-phase (B5): compute every surviving-doc mutation, gate the whole
|
|
1639
|
+
// change-set on the post-write tree state, then flush writes + unlinks.
|
|
1640
|
+
const affectedFbs = new Map();
|
|
1641
|
+
const affectedTs = new Map();
|
|
1642
|
+
for (const acId of collectedAcIds) {
|
|
1643
|
+
for (const fbsId of tree.fbsByAcId.get(acId) ?? []) {
|
|
1644
|
+
if (deletingSet.has(fbsId)) continue;
|
|
1645
|
+
const fbs = affectedFbs.get(fbsId) ?? { ...tree.byId.get(fbsId) };
|
|
1646
|
+
fbs.acIds = (fbs.acIds ?? []).filter((a) => a !== acId);
|
|
1647
|
+
affectedFbs.set(fbsId, fbs);
|
|
1648
|
+
}
|
|
1649
|
+
for (const tsId of tree.tsByAcId.get(acId) ?? []) {
|
|
1650
|
+
if (deletingSet.has(tsId)) continue;
|
|
1651
|
+
const ts = affectedTs.get(tsId) ?? { ...tree.byId.get(tsId) };
|
|
1652
|
+
ts.acIds = (ts.acIds ?? []).filter((a) => a !== acId);
|
|
1653
|
+
affectedTs.set(tsId, ts);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const pending = [];
|
|
1657
|
+
for (const [fbsId, fbs] of affectedFbs) {
|
|
1658
|
+
fbs.updatedAt = nowIso();
|
|
1659
|
+
const relPath = `rcf/fbs/${fbsId.toLowerCase()}.json`;
|
|
1660
|
+
const validation = validateDocument({ doc: fbs, kind: 'fbs', filePath: relPath });
|
|
1661
|
+
if (validation) return { ...validation, documentId: fbsId };
|
|
1662
|
+
pending.push({ kind: 'fbs', id: fbsId, doc: fbs, relPath });
|
|
1663
|
+
}
|
|
1664
|
+
for (const [tsId, ts] of affectedTs) {
|
|
1665
|
+
ts.updatedAt = nowIso();
|
|
1666
|
+
const relPath = `rcf/test-suites/${tsId.toLowerCase()}.json`;
|
|
1667
|
+
const validation = validateDocument({ doc: ts, kind: 'testSuite', filePath: relPath });
|
|
1668
|
+
if (validation) return { ...validation, documentId: tsId };
|
|
1669
|
+
pending.push({ kind: 'testSuite', id: tsId, doc: ts, relPath });
|
|
1670
|
+
}
|
|
1671
|
+
const gateErr = postWriteGate({
|
|
1672
|
+
tree,
|
|
1673
|
+
walkErrors,
|
|
1674
|
+
upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
|
|
1675
|
+
deletes: toDelete,
|
|
1676
|
+
verb: 'delete',
|
|
1677
|
+
});
|
|
1678
|
+
if (gateErr) return gateErr;
|
|
1679
|
+
const mutated = [];
|
|
1680
|
+
for (const p of pending) {
|
|
1681
|
+
if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
|
|
1682
|
+
mutated.push({ id: p.id, filePath: p.relPath });
|
|
1683
|
+
}
|
|
1684
|
+
// Then unlink every file in the toDelete list (leaves first: reverse order).
|
|
1685
|
+
const deleted = [];
|
|
1686
|
+
for (const delId of [...toDelete].reverse()) {
|
|
1687
|
+
const kind = tree.kindById.get(delId);
|
|
1688
|
+
if (!kind) continue;
|
|
1689
|
+
const relPath = relativePathForChild(kind, delId);
|
|
1690
|
+
if (!options.dryRun) {
|
|
1691
|
+
try { await unlink(pathForKindFile(projectRoot, kind, delId)); } catch (err) {
|
|
1692
|
+
if (err.code !== 'ENOENT') {
|
|
1693
|
+
return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: relPath, stack: err.stack });
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
deleted.push(delId);
|
|
1698
|
+
}
|
|
1699
|
+
return { deleted, mutated, plan: buildPlanLines(deleted, mutated) };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Check if deleting the given AC ids would leave any surviving FBS/TS
|
|
1704
|
+
* with an empty `acIds[]` (schema mandates `minItems: 1`). Optionally
|
|
1705
|
+
* skips FBS/TS in `deletingSet` because those are being removed anyway.
|
|
1706
|
+
* Returns an RcfError on refusal or null.
|
|
1707
|
+
*/
|
|
1708
|
+
function checkAcOrphans(tree, acIds, deletingSet = new Set()) {
|
|
1709
|
+
const deletingAcSet = new Set(acIds);
|
|
1710
|
+
const orphans = [];
|
|
1711
|
+
for (const [acId, fbsList] of tree.fbsByAcId) {
|
|
1712
|
+
if (!deletingAcSet.has(acId)) continue;
|
|
1713
|
+
for (const fbsId of fbsList) {
|
|
1714
|
+
if (deletingSet.has(fbsId)) continue;
|
|
1715
|
+
const fbs = tree.byId.get(fbsId);
|
|
1716
|
+
const survivorAcIds = (fbs.acIds ?? []).filter((a) => !deletingAcSet.has(a));
|
|
1717
|
+
if (survivorAcIds.length === 0) {
|
|
1718
|
+
orphans.push({ id: fbsId, kind: 'fbs', drivers: (fbs.acIds ?? []).filter((a) => deletingAcSet.has(a)) });
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
for (const [acId, tsList] of tree.tsByAcId) {
|
|
1723
|
+
if (!deletingAcSet.has(acId)) continue;
|
|
1724
|
+
for (const tsId of tsList) {
|
|
1725
|
+
if (deletingSet.has(tsId)) continue;
|
|
1726
|
+
const ts = tree.byId.get(tsId);
|
|
1727
|
+
const survivorAcIds = (ts.acIds ?? []).filter((a) => !deletingAcSet.has(a));
|
|
1728
|
+
if (survivorAcIds.length === 0) {
|
|
1729
|
+
orphans.push({ id: tsId, kind: 'testSuite', drivers: (ts.acIds ?? []).filter((a) => deletingAcSet.has(a)) });
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
// Deduplicate by id.
|
|
1734
|
+
const dedup = new Map();
|
|
1735
|
+
for (const o of orphans) if (!dedup.has(o.id)) dedup.set(o.id, o);
|
|
1736
|
+
if (dedup.size === 0) return null;
|
|
1737
|
+
const lines = [...dedup.values()].map((o) => `${o.id} (${o.kind}) would be orphaned by deletion of ${o.drivers.join(', ')}`);
|
|
1738
|
+
return rcfError({
|
|
1739
|
+
kind: 'usage',
|
|
1740
|
+
message: `delete: cascade would orphan downstream ac lists: ${lines.join('; ')}`,
|
|
1741
|
+
rule: 'wouldOrphan',
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function refuseWithDependents(id, dependents) {
|
|
1746
|
+
const parts = [];
|
|
1747
|
+
for (const [key, list] of Object.entries(dependents)) {
|
|
1748
|
+
if (Array.isArray(list) && list.length > 0) parts.push(`${key}=${list.join(',')}`);
|
|
1749
|
+
}
|
|
1750
|
+
return rcfError({
|
|
1751
|
+
kind: 'usage',
|
|
1752
|
+
message: `delete: ${id} has dependents (${parts.join(' ')}); pass --cascade to opt in`,
|
|
1753
|
+
documentId: id,
|
|
1754
|
+
rule: 'dependents',
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function buildPlanLines(deleted, mutated) {
|
|
1759
|
+
return [
|
|
1760
|
+
...mutated.map((m) => `mutate ${m.filePath}`),
|
|
1761
|
+
...deleted.map((id) => {
|
|
1762
|
+
// BUG-006 fix: resolve the real kind-directory from the id prefix
|
|
1763
|
+
// instead of emitting the `rcf/.../` placeholder. `pathForId`
|
|
1764
|
+
// returns `{ kind, relPath }` where `relPath` already includes the
|
|
1765
|
+
// kind subdirectory (e.g. `requirements/req-002.json`); prepend
|
|
1766
|
+
// the `rcf/` root to line up with the other plan lines.
|
|
1767
|
+
const resolved = pathForId(id);
|
|
1768
|
+
const rel = resolved ? `rcf/${resolved.relPath}` : `rcf/${id.toLowerCase()}.json`;
|
|
1769
|
+
return `delete ${rel}`;
|
|
1770
|
+
}),
|
|
1771
|
+
];
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ---------------------------------------------------------------------------
|
|
1775
|
+
// Utilities: deep-merge and dot-path patch
|
|
1776
|
+
// ---------------------------------------------------------------------------
|
|
1777
|
+
|
|
1778
|
+
function deepClone(value) {
|
|
1779
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1780
|
+
if (Array.isArray(value)) return value.map((v) => deepClone(v));
|
|
1781
|
+
const out = {};
|
|
1782
|
+
for (const k of Object.keys(value)) out[k] = deepClone(value[k]);
|
|
1783
|
+
return out;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function deepMergeReplaceArrays(target, source) {
|
|
1787
|
+
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
|
|
1788
|
+
return deepClone(source);
|
|
1789
|
+
}
|
|
1790
|
+
const out = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
|
|
1791
|
+
for (const k of Object.keys(source)) {
|
|
1792
|
+
const sv = source[k];
|
|
1793
|
+
if (Array.isArray(sv)) {
|
|
1794
|
+
out[k] = deepClone(sv);
|
|
1795
|
+
} else if (sv && typeof sv === 'object') {
|
|
1796
|
+
out[k] = deepMergeReplaceArrays(out[k], sv);
|
|
1797
|
+
} else {
|
|
1798
|
+
out[k] = sv;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return out;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Apply `target.<dotPath> = value`. Supports `a.b.c` and `a.b[0].c`.
|
|
1806
|
+
* Returns null on success or a string describing the failure.
|
|
1807
|
+
*/
|
|
1808
|
+
function applyDotPath(target, path, value) {
|
|
1809
|
+
const parts = parseDotPath(path);
|
|
1810
|
+
if (!parts) return `bad path: ${path}`;
|
|
1811
|
+
let cur = target;
|
|
1812
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
1813
|
+
const seg = parts[i];
|
|
1814
|
+
if (seg.kind === 'index') {
|
|
1815
|
+
if (!Array.isArray(cur)) return `path ${path}: expected array at ${seg.value}`;
|
|
1816
|
+
if (cur[seg.value] === undefined) cur[seg.value] = {};
|
|
1817
|
+
cur = cur[seg.value];
|
|
1818
|
+
} else {
|
|
1819
|
+
if (cur[seg.value] === undefined || cur[seg.value] === null) cur[seg.value] = {};
|
|
1820
|
+
cur = cur[seg.value];
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
const last = parts[parts.length - 1];
|
|
1824
|
+
if (last.kind === 'index') {
|
|
1825
|
+
if (!Array.isArray(cur)) return `path ${path}: expected array at ${last.value}`;
|
|
1826
|
+
cur[last.value] = value;
|
|
1827
|
+
} else {
|
|
1828
|
+
cur[last.value] = value;
|
|
1829
|
+
}
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function parseDotPath(path) {
|
|
1834
|
+
if (typeof path !== 'string' || path.length === 0) return null;
|
|
1835
|
+
const parts = [];
|
|
1836
|
+
const segments = path.split('.');
|
|
1837
|
+
for (const seg of segments) {
|
|
1838
|
+
const re = /^([^\[\]]+)((?:\[\d+\])*)$/;
|
|
1839
|
+
const m = re.exec(seg);
|
|
1840
|
+
if (!m) return null;
|
|
1841
|
+
parts.push({ kind: 'prop', value: m[1] });
|
|
1842
|
+
const bracketed = m[2];
|
|
1843
|
+
if (bracketed) {
|
|
1844
|
+
const indices = bracketed.match(/\d+/g) ?? [];
|
|
1845
|
+
for (const n of indices) parts.push({ kind: 'index', value: Number(n) });
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return parts;
|
|
1849
|
+
}
|