rcf-lite 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +124 -51
  2. package/README.md +8 -4
  3. package/bin/rcf.js +147 -53
  4. package/fixtures/canary-manifest.json +9 -9
  5. package/guidance/README.md +1 -1
  6. package/guidance/build-cycle-playbook.md +51 -51
  7. package/guidance/build-cycle.md +7 -7
  8. package/guidance/document-model.md +1 -1
  9. package/guidance/elicitation-playbook.md +29 -29
  10. package/guidance/harness-template.md +21 -10
  11. package/guidance/managed/README.md +1 -1
  12. package/guidance/managed/agent-instructions-block.hash +1 -1
  13. package/guidance/managed/agent-instructions-block.md +20 -9
  14. package/guidance/manifest.json +1 -1
  15. package/guidance/overview.md +4 -4
  16. package/package.json +5 -7
  17. package/rcf/adrs/adr-008.json +1 -1
  18. package/rcf/adrs/adr-009.json +4 -4
  19. package/rcf/adrs/adr-010.json +30 -0
  20. package/rcf/code-nodes/cn-016.json +1 -1
  21. package/rcf/code-nodes/cn-019.json +1 -1
  22. package/rcf/code-nodes/cn-020.json +1 -1
  23. package/rcf/code-nodes/cn-021.json +1 -1
  24. package/rcf/code-nodes/cn-022.json +1 -1
  25. package/rcf/code-nodes/cn-049.json +1 -1
  26. package/rcf/code-nodes/cn-055.json +1 -1
  27. package/rcf/code-nodes/cn-057.json +5 -5
  28. package/rcf/code-nodes/cn-058.json +18 -0
  29. package/rcf/code-nodes/cn-059.json +14 -0
  30. package/rcf/code-nodes/cn-060.json +14 -0
  31. package/rcf/code-nodes/cn-061.json +14 -0
  32. package/rcf/code-nodes/cn-062.json +14 -0
  33. package/rcf/code-nodes/cn-063.json +15 -0
  34. package/rcf/code-nodes/cn-064.json +15 -0
  35. package/rcf/code-nodes/cn-065.json +16 -0
  36. package/rcf/code-nodes/cn-066.json +14 -0
  37. package/rcf/code-nodes/cn-067.json +15 -0
  38. package/rcf/code-nodes/cn-068.json +15 -0
  39. package/rcf/code-nodes/cn-069.json +16 -0
  40. package/rcf/fbs/fbs-005.json +1 -1
  41. package/rcf/fbs/fbs-006.json +2 -2
  42. package/rcf/fbs/fbs-007.json +1 -1
  43. package/rcf/fbs/fbs-014.json +1 -1
  44. package/rcf/fbs/fbs-015.json +9 -8
  45. package/rcf/fbs/fbs-016.json +39 -0
  46. package/rcf/fbs/fbs-017.json +40 -0
  47. package/rcf/fbs/fbs-018.json +34 -0
  48. package/rcf/fbs/fbs-019.json +33 -0
  49. package/rcf/requirements/req-008.json +1 -1
  50. package/rcf/requirements/req-009.json +2 -2
  51. package/rcf/requirements/req-010.json +20 -0
  52. package/rcf/test-suites/PENDING.md +2 -2
  53. package/rcf/test-suites/ts-004.json +1 -1
  54. package/rcf/test-suites/ts-006.json +3 -3
  55. package/rcf/test-suites/ts-008.json +2 -2
  56. package/rcf/test-suites/ts-009.json +2 -2
  57. package/rcf/test-suites/ts-017.json +1 -1
  58. package/rcf/test-suites/ts-024.json +1 -1
  59. package/rcf/test-suites/ts-025.json +32 -18
  60. package/rcf/test-suites/ts-026.json +54 -0
  61. package/rcf/test-suites/ts-027.json +115 -0
  62. package/rcf/test-suites/ts-028.json +46 -0
  63. package/rcf/test-suites/ts-029.json +46 -0
  64. package/rcf/user-stories/us-1001.json +56 -0
  65. package/rcf/user-stories/us-1002.json +96 -0
  66. package/rcf/user-stories/us-1003.json +48 -0
  67. package/rcf/user-stories/us-1004.json +48 -0
  68. package/rcf/user-stories/us-805.json +2 -2
  69. package/rcf/user-stories/us-901.json +13 -13
  70. package/src/blueprint/apply.js +464 -0
  71. package/src/blueprint/conflicts.js +351 -0
  72. package/src/blueprint/diff.js +82 -0
  73. package/src/blueprint/index.js +12 -0
  74. package/src/blueprint/list.js +21 -0
  75. package/src/blueprint/loader.js +163 -0
  76. package/src/blueprint/manifest-writer.js +49 -0
  77. package/src/blueprint/namespace.js +145 -0
  78. package/src/blueprint/remove.js +105 -0
  79. package/src/blueprint/resolutions.js +83 -0
  80. package/src/blueprint/standards.js +148 -0
  81. package/src/blueprint/supersede.js +318 -0
  82. package/src/browser-verify/invariants.js +33 -6
  83. package/src/build/bundle.js +37 -14
  84. package/src/build/formatters/markdown.js +9 -9
  85. package/src/build/mark.js +3 -3
  86. package/src/build/queue.js +1 -1
  87. package/src/build/standards-selector.js +52 -0
  88. package/src/cli/blueprint.js +325 -0
  89. package/src/cli/browser-verify.js +1 -1
  90. package/src/cli/build.js +139 -69
  91. package/src/cli/coverage.js +1 -1
  92. package/src/cli/create.js +48 -3
  93. package/src/cli/delete.js +2 -2
  94. package/src/cli/design.js +10 -10
  95. package/src/cli/fbs.js +1 -1
  96. package/src/cli/finalise.js +22 -20
  97. package/src/cli/help.js +269 -89
  98. package/src/cli/impact.js +1 -1
  99. package/src/cli/init.js +20 -5
  100. package/src/cli/intake.js +3 -3
  101. package/src/cli/link.js +3 -3
  102. package/src/cli/preflight.js +2 -2
  103. package/src/cli/read.js +1 -1
  104. package/src/cli/req-baseline.js +2 -2
  105. package/src/cli/req-classify.js +3 -3
  106. package/src/cli/review.js +1 -1
  107. package/src/cli/standards.js +127 -0
  108. package/src/cli/test-suite.js +1 -1
  109. package/src/cli/trace.js +1 -1
  110. package/src/cli/ui-baseline.js +3 -3
  111. package/src/cli/ui-classify.js +4 -4
  112. package/src/cli/update.js +2 -2
  113. package/src/cli/validate.js +2 -2
  114. package/src/cli/view.js +12 -10
  115. package/src/core/store/ids.js +168 -18
  116. package/src/core/store/loader.js +27 -16
  117. package/src/core/store/walker.js +27 -15
  118. package/src/core/store/writer.js +1 -1
  119. package/src/deployment/index.js +13 -0
  120. package/src/deployment/placeholder-detector.js +113 -0
  121. package/src/design/writer.js +3 -3
  122. package/src/finalise/detect.js +32 -38
  123. package/src/finalise/index.js +0 -1
  124. package/src/finalise/install.js +9 -8
  125. package/src/finalise/spawn.js +14 -10
  126. package/src/mcp/tools.js +1 -1
  127. package/src/query/formatters/table.js +7 -10
  128. package/src/query/trace.js +45 -4
  129. package/src/req-baseline/gate.js +1 -1
  130. package/src/ui-baseline/manifest-writer.js +2 -2
  131. package/src/verify/cli/cleanup.js +1 -1
  132. package/src/verify/cli/mcp.js +1 -1
  133. package/src/verify/cli/provision.js +1 -1
  134. package/src/verify/cli/report.js +1 -1
  135. package/src/verify/cli/run.js +1 -1
  136. package/src/view-supervisor/manifest-writer.js +2 -2
  137. package/bin/rcf-verify.js +0 -122
  138. package/src/verify/cli/help.js +0 -56
@@ -0,0 +1,127 @@
1
+ // `rcf standards <verb>` — Phase 1 landing (add | list).
2
+
3
+ import { parseArgs } from 'node:util';
4
+
5
+ import { isRcfError } from '#core/errors';
6
+ import { walkTree } from '#core/store';
7
+ import { findProjectRoot } from '../view/index.js';
8
+ import { listStandards, registerStandardsPack } from '../blueprint/index.js';
9
+
10
+ export const HELP = `Usage: rcf define standards <verb> [options]
11
+
12
+ Verbs:
13
+ add <source> Register a standards pack against the project.
14
+ Reference-by-default: if <source> lives inside
15
+ the project root, the pack is referenced in place
16
+ and no copy is written. If <source> lives OUTSIDE
17
+ the project root, the pack is copied into
18
+ rcf/standards/<slug>/ so the tree stays portable.
19
+ list List every registered standards pack.
20
+
21
+ Options (for add):
22
+ --slug <slug> Required. Kebab slug for this pack.
23
+ --tags <t1,t2,...> Required. Comma-separated tag vocabulary.
24
+ --summary <string> Optional short summary read by the selective-
25
+ retrieval step alongside the tags.
26
+ --tests-provided-by <val> Required. One of: standard | agent | none.
27
+ --provenance <val> Required. One of: personal | corporate.
28
+ --dry-run Print intended writes without executing.
29
+ --quiet Suppress non-error stdout.
30
+ --help Print this help.
31
+ `;
32
+
33
+ const OPTION_SPEC = {
34
+ slug: { type: 'string' },
35
+ tags: { type: 'string' },
36
+ summary: { type: 'string' },
37
+ 'tests-provided-by': { type: 'string' },
38
+ provenance: { type: 'string' },
39
+ 'dry-run': { type: 'boolean' },
40
+ quiet: { type: 'boolean' },
41
+ help: { type: 'boolean' },
42
+ };
43
+
44
+ /**
45
+ * @param {string[]} argv
46
+ * @param {object} [deps]
47
+ * @returns {Promise<number>}
48
+ */
49
+ export async function main(argv, deps = {}) {
50
+ const stdout = deps.stdout ?? process.stdout;
51
+ const stderr = deps.stderr ?? process.stderr;
52
+ const cwd = deps.cwd ?? process.cwd();
53
+
54
+ let parsed;
55
+ try {
56
+ parsed = parseArgs({ args: argv, options: OPTION_SPEC, allowPositionals: true, strict: true });
57
+ } catch (err) {
58
+ stderr.write(`[error] ${err.message}\n`);
59
+ stderr.write(HELP);
60
+ return 2;
61
+ }
62
+ if (parsed.values.help || parsed.positionals.length === 0) {
63
+ stdout.write(HELP);
64
+ return 0;
65
+ }
66
+ const verb = parsed.positionals[0];
67
+ const rest = parsed.positionals.slice(1);
68
+
69
+ const projectRoot = await findProjectRoot(cwd);
70
+ if (!projectRoot) {
71
+ stderr.write('[error] no rcf/ tree found in this directory or any ancestor.\n');
72
+ return 2;
73
+ }
74
+ const { tree, errors } = await walkTree({ projectRoot });
75
+ if (errors.length > 0 && verb !== 'list') {
76
+ for (const e of errors) stderr.write(`[tree] ${e.kind}: ${e.message}\n`);
77
+ return 2;
78
+ }
79
+
80
+ if (verb === 'add') {
81
+ if (rest.length === 0) {
82
+ stderr.write('[error] standards add: missing <source>\n');
83
+ return 2;
84
+ }
85
+ if (!parsed.values.slug || !parsed.values.tags || !parsed.values['tests-provided-by'] || !parsed.values.provenance) {
86
+ stderr.write('[error] standards add: --slug, --tags, --tests-provided-by and --provenance are required\n');
87
+ return 2;
88
+ }
89
+ const result = await registerStandardsPack({
90
+ projectRoot, tree,
91
+ sourcePath: rest[0],
92
+ slug: parsed.values.slug,
93
+ tags: parsed.values.tags.split(',').map((t) => t.trim()).filter(Boolean),
94
+ summary: parsed.values.summary,
95
+ testsProvidedBy: parsed.values['tests-provided-by'],
96
+ provenance: parsed.values.provenance,
97
+ dryRun: parsed.values['dry-run'] === true,
98
+ });
99
+ if (isRcfError(result)) {
100
+ stderr.write(`[error] standards add: ${result.message}\n`);
101
+ return 2;
102
+ }
103
+ if (!parsed.values.quiet) {
104
+ const shape = result.copyPath ? `copied to ${result.copyPath}` : `referenced in place at ${result.entry.sourcePath}`;
105
+ const state = result.alreadyRegistered ? 'already registered (no change)' : 'registered';
106
+ stdout.write(`[standards] '${result.entry.slug}' ${state} (${shape}).\n`);
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ if (verb === 'list') {
112
+ const rows = listStandards(tree);
113
+ if (rows.length === 0) {
114
+ if (!parsed.values.quiet) stdout.write('[standards] no standards packs registered on this project.\n');
115
+ return 0;
116
+ }
117
+ for (const row of rows) {
118
+ const shape = row.copyPath ? 'copied' : 'referenced';
119
+ stdout.write(`${row.slug}\t${row.testsProvidedBy}\t${row.provenance}\t${shape}\t${(row.tags ?? []).join(',')}\n`);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ stderr.write(`[error] standards: unknown verb '${verb}'\n`);
125
+ stderr.write(HELP);
126
+ return 2;
127
+ }
@@ -34,7 +34,7 @@ const APPROVE_OPTION_SPEC = {
34
34
 
35
35
  const VALID_PROFILES = new Set(['mock', 'stub', 'fixture', 'live', 'mixed']);
36
36
 
37
- export const HELP = `Usage: rcf test-suite <ts-id> <verb> [options]
37
+ export const HELP = `Usage: rcf build test-suite <ts-id> <verb> [options]
38
38
 
39
39
  Verbs:
40
40
  provenance Author runtimeProvenance on the TS or a specific TC
package/src/cli/trace.js CHANGED
@@ -29,7 +29,7 @@ const OPTION_SPEC = {
29
29
  'to-code': { type: 'boolean' },
30
30
  };
31
31
 
32
- export const HELP = `Usage: rcf trace <id|path> [options]
32
+ export const HELP = `Usage: rcf audit trace <id|path> [options]
33
33
 
34
34
  Walk the graph from <id> forward (descendants), backward (ancestors),
35
35
  or both. Default is --forward. When <id> is not a known document id, it
@@ -48,7 +48,7 @@ const OPTION_SPEC = {
48
48
  help: { type: 'boolean' },
49
49
  };
50
50
 
51
- export const HELP = `Usage: rcf ui-baseline <verb> [options]
51
+ export const HELP = `Usage: rcf discover ui-baseline <verb> [options]
52
52
 
53
53
  Manage the project's ruled UI defaults (theme, layout, contrast,
54
54
  components, auth flow). Written once per project as a uiBaseline record
@@ -136,7 +136,7 @@ export async function main(argv, deps = {}) {
136
136
  function runShow({ tree, stdout, flags }) {
137
137
  const record = tree.manifest?.uiBaseline;
138
138
  if (!record) {
139
- stdout.write('ui-baseline: no baseline recorded. Run \'rcf ui-baseline init\'.\n');
139
+ stdout.write('ui-baseline: no baseline recorded. Run \'rcf discover ui-baseline init\'.\n');
140
140
  return 0;
141
141
  }
142
142
  if (flags.json) {
@@ -171,7 +171,7 @@ async function runOptOut({ tree, projectRoot, stdout, stderr, flags, now }) {
171
171
  }
172
172
  const normalisedField = flags.field.startsWith('defaults.') ? flags.field.slice('defaults.'.length) : flags.field;
173
173
  if (!isKnownBaselinePath(normalisedField)) {
174
- stderr.write(`[error] usage ui-baseline opt-out: unknown baseline field '${normalisedField}' (see 'rcf ui-baseline show' for the field list)\n`);
174
+ stderr.write(`[error] usage ui-baseline opt-out: unknown baseline field '${normalisedField}' (see 'rcf discover ui-baseline show' for the field list)\n`);
175
175
  return 2;
176
176
  }
177
177
  const result = await writeUiBaselineOptOut({
@@ -19,11 +19,11 @@ const OPTION_SPEC = {
19
19
  help: { type: 'boolean' },
20
20
  };
21
21
 
22
- export const HELP = `Usage: rcf ui-classify <fbs-id> [options]
22
+ export const HELP = `Usage: rcf discover ui-classify <fbs-id> [options]
23
23
 
24
24
  Run the UI-bearing classifier on one FBS and print the verdict. Does
25
25
  not write to the FBS document; ratify with:
26
- rcf update <fbs-id> --set uiBearing=true
26
+ rcf define update <fbs-id> --set uiBearing=true
27
27
 
28
28
  Options:
29
29
  --json Emit the uiClassification block as JSON.
@@ -98,9 +98,9 @@ export async function main(argv, deps = {}) {
98
98
  }
99
99
  }
100
100
  if (block.verdict === 'ui') {
101
- stdout.write(' Ratify with: rcf update ' + fbsId + ' --set uiBearing=true\n');
101
+ stdout.write(' Ratify with: rcf define update ' + fbsId + ' --set uiBearing=true\n');
102
102
  } else if (block.verdict === 'notUi' && signals.length === 0) {
103
- stdout.write(' No UI signals detected. Override with: rcf update ' + fbsId + ' --set uiBearing=true\n');
103
+ stdout.write(' No UI signals detected. Override with: rcf define update ' + fbsId + ' --set uiBearing=true\n');
104
104
  } else if (block.verdict === 'operatorOverride') {
105
105
  stdout.write(' Operator ruling recorded on FBS.uiBearing wins over the classifier.\n');
106
106
  }
package/src/cli/update.js CHANGED
@@ -26,7 +26,7 @@ const OPTION_SPEC = {
26
26
  'derive-deps': { type: 'boolean' },
27
27
  };
28
28
 
29
- export const HELP = `Usage: rcf update <id> [options]
29
+ export const HELP = `Usage: rcf define update <id> [options]
30
30
 
31
31
  Options:
32
32
  --set <dotPath>=<value> Set a field; repeatable
@@ -80,7 +80,7 @@ export async function main(argv, deps = {}) {
80
80
  // update is gated on the POST-write tree state inside the writer, so
81
81
  // repairing a broken doc is exactly what this verb is now for.
82
82
  if (walkResult.errors.length > 0) {
83
- stderr.write(`[warn] tree has ${walkResult.errors.length} pre-existing issue(s); proceeding - writes are validated against the post-write state (run 'rcf validate' for details)\n`);
83
+ stderr.write(`[warn] tree has ${walkResult.errors.length} pre-existing issue(s); proceeding - writes are validated against the post-write state (run 'rcf define validate' for details)\n`);
84
84
  }
85
85
 
86
86
  const sets = [];
@@ -18,7 +18,7 @@ const OPTION_SPEC = {
18
18
  'no-code': { type: 'boolean' },
19
19
  };
20
20
 
21
- export const HELP = `Usage: rcf validate [options]
21
+ export const HELP = `Usage: rcf define validate [options]
22
22
 
23
23
  Walk the rcf/ tree and report schema-validation and broken-reference
24
24
  issues. Exits 0 when clean, 3 on issues.
@@ -140,7 +140,7 @@ export async function main(argv, deps = {}) {
140
140
  }
141
141
  if (errors.length === 0) {
142
142
  if (!flags.quiet) {
143
- stdout.write('rcf validate: tree is clean.\n');
143
+ stdout.write('rcf define validate: tree is clean.\n');
144
144
  // B4 (E2E matrix 2026-07-06-003): scaffold TODO placeholders were
145
145
  // surviving to otherwise-valid trees unflagged. Non-blocking notice
146
146
  // only - the exit code is unchanged.
package/src/cli/view.js CHANGED
@@ -24,7 +24,7 @@ import { parsePersistUntil } from '../view-supervisor/persist-until.js';
24
24
  export const DEFAULT_PORT = 4373;
25
25
  export const SHUTDOWN_BUDGET_MS = 2000;
26
26
 
27
- export const HELP = `Usage: rcf view [subverb] [options]
27
+ export const HELP = `Usage: rcf audit view [subverb] [options]
28
28
 
29
29
  Serve the on-disk RCF tree as a live HTML review surface. Runs a
30
30
  long-running HTTP + SSE server on 127.0.0.1 that watches rcf/ and pushes
@@ -32,10 +32,10 @@ tree updates to the connected browser tab. No on-disk output; no static
32
32
  files are written.
33
33
 
34
34
  Subverbs (spec §9.2):
35
- rcf view Foreground server (default; lifetime tied to
35
+ rcf audit view Foreground server (default; lifetime tied to
36
36
  the invoking session). Backward-compatible
37
37
  with pre-0.7.0 callers.
38
- rcf view start [--detach|--foreground] [--persist-until <duration|iso>]
38
+ rcf audit view start [--detach|--foreground] [--persist-until <duration|iso>]
39
39
  Start the server. --detach forks a supervised
40
40
  background process that persists across the
41
41
  parent session's death; the manifest carries
@@ -50,12 +50,14 @@ Subverbs (spec §9.2):
50
50
  --detach; non-interactive callers keep
51
51
  --foreground so a script does not orphan a
52
52
  process.
53
- rcf view status [--json] Print the supervisor state:
53
+ rcf audit view status [--json]
54
+ Print the supervisor state:
54
55
  running | stale | not-started.
55
- rcf view stop Send SIGTERM to the supervised process, wait
56
+ rcf audit view stop Send SIGTERM to the supervised process, wait
56
57
  for a clean shutdown, and clear the manifest
57
58
  record.
58
- rcf view logs [--tail <n>] Print the supervisor log tail (default 200).
59
+ rcf audit view logs [--tail <n>]
60
+ Print the supervisor log tail (default 200).
59
61
 
60
62
  Options:
61
63
  --port <n> Bind the HTTP server on the given port.
@@ -83,7 +85,7 @@ Security posture:
83
85
  Shutdown:
84
86
  Ctrl-C (SIGINT) or SIGTERM triggers a clean shutdown: watcher
85
87
  closed, SSE connections drained with a shutdown event, port
86
- released, 2s force-exit budget. Detached: rcf view stop sends
88
+ released, 2s force-exit budget. Detached: rcf audit view stop sends
87
89
  SIGTERM to the supervisor and waits for a clean unwind.
88
90
 
89
91
  Exit codes:
@@ -213,7 +215,7 @@ export function maybeAutoOpen({ target, noOpen, stream, env, stderr, spawnFn = s
213
215
  }
214
216
 
215
217
  /**
216
- * Main entry for the `rcf view` subcommand. Mirrors the shape of the
218
+ * Main entry for the `rcf audit view` subcommand. Mirrors the shape of the
217
219
  * old bin/rcf-view.js main().
218
220
  *
219
221
  * @param {string[]} argv - the argv slice *after* the "view" positional
@@ -278,7 +280,7 @@ export async function main(argv, deps = {}) {
278
280
  });
279
281
  } catch (err) {
280
282
  if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EADDRINUSE') {
281
- stderr.write(`[error] usage port ${resolvedPort} is in use (another rcf view process, or a different service).\n`);
283
+ stderr.write(`[error] usage port ${resolvedPort} is in use (another rcf audit view process, or a different service).\n`);
282
284
  stderr.write('Pass --port <n> or set RCF_VIEW_PORT to pick a free port.\n');
283
285
  return 2;
284
286
  }
@@ -286,7 +288,7 @@ export async function main(argv, deps = {}) {
286
288
  return 1;
287
289
  }
288
290
 
289
- stdout.write(`rcf view server listening at ${server.url}\n`);
291
+ stdout.write(`rcf audit view server listening at ${server.url}\n`);
290
292
  stdout.write('watching rcf/ - Ctrl-C to shut down\n');
291
293
 
292
294
  maybeAutoOpen({
@@ -1,25 +1,175 @@
1
- // Id normalisation. The RCF id patterns in `@stravica-ai/rcf-schemas`
2
- // admit a variable-width numeric run (`^REQ-\d{3,}$`, `^US-\d{3,}$`,
3
- // `^AC-\d{3,}(-\d+)?$`, ...), so `REQ-001` and `REQ-0001` are BOTH legal
4
- // and BOTH name requirement number 1. The schema is right to permit the
5
- // widths -- an id space that outgrows three digits has to be expressible
6
- // -- but two spellings of one number are one identity, not two.
1
+ // Id normalisation + family grammar. This module owns THE grammar for
2
+ // RCF document ids: schema-family membership, filename-stem -> canonical-id
3
+ // inversion, and family -> tree location. The walker (walker.js), the
4
+ // document loader (loader.js) and the blueprint namespace helpers
5
+ // (src/blueprint/namespace.js) all consume the primitives defined here so
6
+ // the regexes live in exactly one place.
7
7
  //
8
- // This module owns the single definition of "the same id" used by the
9
- // walker's uniqueness rule (`globallyUniqueIds`) and by the writer's id
10
- // allocator. Both sides MUST agree: detection that normalises while
11
- // allocation does not just moves the collision one step later.
8
+ // Two schema families, per @stravica-ai/rcf-schemas 0.4.4 (see
9
+ // docs/id-conventions.md):
12
10
  //
13
- // Normalisation is per hyphen-delimited segment and only touches
14
- // segments that are entirely digits:
11
+ // - Prefix families (REQ, US, PRD, BS, TAD, TS) admit an optional
12
+ // lowercase kebab-slug PREFIX joined by `-` to the family prefix.
13
+ // `REQ-001` under blueprint `spa` becomes `spa-REQ-001`.
14
+ // Schema pattern (common.reqId etc.):
15
+ // ^([a-z][a-z0-9]*(?:-[a-z0-9]+)*-)?<PREFIX>-\d{3,}$
15
16
  //
16
- // REQ-001 -> REQ-1
17
- // REQ-0001 -> REQ-1 (collides with REQ-001, correctly)
18
- // AC-101-01 -> AC-101-1 (collides with AC-101-1, correctly)
19
- // TC-001-step2 -> TC-1-step2 (the slug segment is left alone)
17
+ // - Suffix families (ADR, TAC, FBS, CN) admit an optional lowercase
18
+ // kebab-slug SUFFIX joined by `-` to the numeric tail. `ADR-005`
19
+ // under blueprint `spa` becomes `ADR-005-spa`.
20
+ // Schema pattern (common.adrId etc.):
21
+ // ^<PREFIX>-\d{3,}(-[a-z0-9]+(?:-[a-z0-9]+)*)?$
20
22
  //
21
- // Leaving non-numeric segments untouched is deliberate: a TC slug like
22
- // `step02` is a word, not a number, and must not be folded into `step2`.
23
+ // - Unnamespaced families (AC, TC) live inline under their parent
24
+ // documents and never appear as top-level files under rcf/.
25
+ //
26
+ // The RCF id patterns admit a variable-width numeric run
27
+ // (`^REQ-\d{3,}$`, `^AC-\d{3,}(-\d+)?$`, ...), so `REQ-001` and
28
+ // `REQ-0001` are BOTH legal and BOTH name requirement number 1.
29
+ // `normaliseId` / `sameId` collapse those spellings for the walker's
30
+ // `globallyUniqueIds` rule and the writer's id allocator; that identity
31
+ // pass runs independently of the family grammar above.
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Family membership (the single source for downstream re-exports)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export const PREFIX_FAMILIES = Object.freeze(['REQ', 'US', 'PRD', 'BS', 'TAD', 'TS']);
38
+ export const SUFFIX_FAMILIES = Object.freeze(['ADR', 'TAC', 'FBS', 'CN']);
39
+ export const UNNAMESPACED_FAMILIES = Object.freeze(['AC', 'TC']);
40
+
41
+ export const PREFIX_FAMILY_SET = new Set(PREFIX_FAMILIES);
42
+ export const SUFFIX_FAMILY_SET = new Set(SUFFIX_FAMILIES);
43
+ export const UNNAMESPACED_FAMILY_SET = new Set(UNNAMESPACED_FAMILIES);
44
+
45
+ export const SLUG_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
46
+
47
+ // Precompiled per-family regexes. Prefix families are anchored so the
48
+ // optional slug prefix is captured cleanly; suffix families capture the
49
+ // optional slug suffix. Suffix families are consulted first because they
50
+ // never carry a leading slug -- see parseIdParts.
51
+ const PREFIX_REGEXES = new Map();
52
+ for (const family of PREFIX_FAMILIES) {
53
+ PREFIX_REGEXES.set(family, new RegExp(`^(?:([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-)?${family}-(\\d{3,})$`));
54
+ }
55
+ const SUFFIX_REGEXES = new Map();
56
+ for (const family of SUFFIX_FAMILIES) {
57
+ SUFFIX_REGEXES.set(family, new RegExp(`^${family}-(\\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$`));
58
+ }
59
+ const UNNAMESPACED_REGEXES = new Map([
60
+ ['AC', /^AC-(\d{3,})(?:-(\d+))?$/],
61
+ ['TC', /^TC-(\d{3,})-([a-z0-9-]+)$/],
62
+ ]);
63
+
64
+ // Lowercase counterparts for stem-to-canonical inversion. Filenames on
65
+ // disk are lower-case kebab (blueprint apply -> destPathFor -> toLowerCase),
66
+ // and the same lowercase pattern is what the CLI init writer produces.
67
+ const PREFIX_STEM_REGEXES = new Map();
68
+ for (const family of PREFIX_FAMILIES) {
69
+ PREFIX_STEM_REGEXES.set(family, new RegExp(`^(?:([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-)?${family.toLowerCase()}-(\\d{3,})$`));
70
+ }
71
+ const SUFFIX_STEM_REGEXES = new Map();
72
+ for (const family of SUFFIX_FAMILIES) {
73
+ SUFFIX_STEM_REGEXES.set(family, new RegExp(`^${family.toLowerCase()}-(\\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$`));
74
+ }
75
+
76
+ // Family -> tree location. Root families (PRD/TAD/BS) live at rcf/ root
77
+ // as single files; child families live one per JSON file under a subdir;
78
+ // AC/TC live inline under their parents and never resolve to a top-level
79
+ // file, so their entry is absent.
80
+ const FAMILY_TO_LOCATION = new Map([
81
+ ['REQ', { kind: 'req', subdir: 'requirements', rootFile: null }],
82
+ ['US', { kind: 'userStory', subdir: 'user-stories', rootFile: null }],
83
+ ['TAC', { kind: 'tac', subdir: 'tacs', rootFile: null }],
84
+ ['ADR', { kind: 'adr', subdir: 'adrs', rootFile: null }],
85
+ ['FBS', { kind: 'fbs', subdir: 'fbs', rootFile: null }],
86
+ ['TS', { kind: 'testSuite', subdir: 'test-suites', rootFile: null }],
87
+ ['CN', { kind: 'codeNode', subdir: 'code-nodes', rootFile: null }],
88
+ ['PRD', { kind: 'prd', subdir: null, rootFile: 'prd.json' }],
89
+ ['TAD', { kind: 'tad', subdir: null, rootFile: 'tad.json' }],
90
+ ['BS', { kind: 'buildSequence', subdir: null, rootFile: 'build-sequence.json' }],
91
+ ]);
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Family grammar
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Split an id into { family, prefixSlug, digits, suffixSlug }. Returns
99
+ * null when the id matches no known family pattern. The public blueprint
100
+ * surface (`src/blueprint/namespace.js`) re-exports this so callers keep
101
+ * a single import path even though the grammar lives here.
102
+ *
103
+ * @param {unknown} id
104
+ * @returns {{ family: string, prefixSlug: string|null, digits: string, suffixSlug: string|null } | null}
105
+ */
106
+ export function parseIdParts(id) {
107
+ if (typeof id !== 'string' || id.length === 0) return null;
108
+ for (const [family, regex] of SUFFIX_REGEXES) {
109
+ const match = id.match(regex);
110
+ if (match) return { family, prefixSlug: null, digits: match[1], suffixSlug: match[2] ?? null };
111
+ }
112
+ for (const [family, regex] of PREFIX_REGEXES) {
113
+ const match = id.match(regex);
114
+ if (match) return { family, prefixSlug: match[1] ?? null, digits: match[2], suffixSlug: null };
115
+ }
116
+ for (const [family, regex] of UNNAMESPACED_REGEXES) {
117
+ const match = id.match(regex);
118
+ if (match) return { family, prefixSlug: null, digits: match[1], suffixSlug: null };
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Location of a family in the rcf/ tree, or null when the family is not
125
+ * addressable as a top-level file (AC / TC).
126
+ *
127
+ * @param {string} family
128
+ * @returns {{ kind: string, subdir: string|null, rootFile: string|null } | null}
129
+ */
130
+ export function familyLocation(family) {
131
+ return FAMILY_TO_LOCATION.get(family) ?? null;
132
+ }
133
+
134
+ /**
135
+ * Canonicalise a lowercase filename stem to its id form: upper-case ONLY
136
+ * the family segment, leaving any slug prefix / suffix verbatim (they are
137
+ * lower-case kebab by schema construction, matching the on-disk stem).
138
+ *
139
+ * Understands both id families:
140
+ * spa-req-001 -> spa-REQ-001 (prefix family, slug 'spa')
141
+ * req-001 -> REQ-001 (prefix family, no slug)
142
+ * adr-005-spa -> ADR-005-spa (suffix family, slug 'spa')
143
+ * adr-005 -> ADR-005 (suffix family, no slug)
144
+ * fbs-004-user-login -> FBS-004-user-login
145
+ *
146
+ * Returns null when the stem matches no known family pattern; the walker
147
+ * folds defensively in that case so an unrecognised stem surfaces via the
148
+ * downstream load error, not a crash.
149
+ *
150
+ * @param {unknown} stem
151
+ * @returns {string|null}
152
+ */
153
+ export function canonicaliseStem(stem) {
154
+ if (typeof stem !== 'string' || stem.length === 0) return null;
155
+ // Suffix families first (mirrors parseIdParts: they never carry a
156
+ // leading slug, so their prefix segment is unambiguous).
157
+ for (const [family, regex] of SUFFIX_STEM_REGEXES) {
158
+ const match = stem.match(regex);
159
+ if (match) return match[2] ? `${family}-${match[1]}-${match[2]}` : `${family}-${match[1]}`;
160
+ }
161
+ for (const [family, regex] of PREFIX_STEM_REGEXES) {
162
+ const match = stem.match(regex);
163
+ if (match) return match[1] ? `${match[1]}-${family}-${match[2]}` : `${family}-${match[2]}`;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Numeric identity (leading-zero-tolerant). Independent of the family
170
+ // grammar above -- REQ-001 and REQ-0001 are one id whether or not the
171
+ // blueprint machinery is in play.
172
+ // ---------------------------------------------------------------------------
23
173
 
24
174
  /**
25
175
  * Strip leading zeros from a run of digits without going through Number
@@ -9,6 +9,7 @@ import { readdir, readFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  import { rcfError } from '../errors/index.js';
12
+ import { familyLocation, parseIdParts } from './ids.js';
12
13
  import { validateDocument } from './validator.js';
13
14
 
14
15
  /**
@@ -41,25 +42,35 @@ const ROOT_FILENAMES = {
41
42
  };
42
43
 
43
44
  /**
44
- * Resolve an id like "REQ-002" to a path under rcf/.
45
+ * Resolve an id like "REQ-002" (or a blueprint-namespaced id like
46
+ * "spa-REQ-001" / "ADR-005-spa") to a path under rcf/.
45
47
  *
46
- * @param {string} id - canonical id, e.g. "REQ-002", "US-201", "FBS-003"
47
- * @returns {{ kind: string, relPath: string } | null} null if the id pattern is unknown
48
+ * The id grammar lives in `./ids.js` (`parseIdParts` / `familyLocation`);
49
+ * this function is the read-side application of it. Prefix-family ids
50
+ * (REQ / US / PRD / BS / TAD / TS) may carry a leading slug namespace;
51
+ * suffix-family ids (ADR / TAC / FBS / CN) may carry a trailing slug
52
+ * namespace. Either way the filename on disk is the lower-cased id
53
+ * (blueprint apply's `destPathFor` writes it that way; the CLI writer
54
+ * matches).
55
+ *
56
+ * Returns null when the id matches no known family pattern OR when the
57
+ * family has no top-level file (AC and TC live inline under their
58
+ * parent US / TS).
59
+ *
60
+ * @param {string} id - canonical id, e.g. "REQ-002", "spa-REQ-001", "ADR-005-spa"
61
+ * @returns {{ kind: string, relPath: string } | null}
48
62
  */
49
63
  export function pathForId(id) {
50
- if (typeof id !== 'string') return null;
51
- if (id.startsWith('REQ-')) return { kind: 'req', relPath: `requirements/${id.toLowerCase()}.json` };
52
- if (id.startsWith('US-')) return { kind: 'userStory', relPath: `user-stories/${id.toLowerCase()}.json` };
53
- if (id.startsWith('TAC-')) return { kind: 'tac', relPath: `tacs/${id.toLowerCase()}.json` };
54
- if (id.startsWith('ADR-')) return { kind: 'adr', relPath: `adrs/${id.toLowerCase()}.json` };
55
- if (id.startsWith('FBS-')) return { kind: 'fbs', relPath: `fbs/${id.toLowerCase()}.json` };
56
- if (id.startsWith('TS-')) return { kind: 'testSuite', relPath: `test-suites/${id.toLowerCase()}.json` };
57
- // Phase 10 (X2 CodeNode bridge): Code Node document type.
58
- if (id.startsWith('CN-')) return { kind: 'codeNode', relPath: `code-nodes/${id.toLowerCase()}.json` };
59
- if (id === 'PRD-001' || id.startsWith('PRD-')) return { kind: 'prd', relPath: 'prd.json' };
60
- if (id === 'TAD-001' || id.startsWith('TAD-')) return { kind: 'tad', relPath: 'tad.json' };
61
- if (id === 'BS-001' || id.startsWith('BS-')) return { kind: 'buildSequence', relPath: 'build-sequence.json' };
62
- return null;
64
+ const parts = parseIdParts(id);
65
+ if (!parts) return null;
66
+ const loc = familyLocation(parts.family);
67
+ if (!loc) return null;
68
+ // Root families (PRD / TAD / BS) resolve to their single root file.
69
+ if (loc.rootFile) return { kind: loc.kind, relPath: loc.rootFile };
70
+ // Child families resolve to <subdir>/<lower(id)>.json. AC / TC have no
71
+ // subdir entry and fall through to null via `!loc` above.
72
+ if (!loc.subdir) return null;
73
+ return { kind: loc.kind, relPath: `${loc.subdir}/${id.toLowerCase()}.json` };
63
74
  }
64
75
 
65
76
  /**
@@ -16,30 +16,42 @@
16
16
  // `dependentsByFbsId`, `tsByAcId`, `tcsByAcId`, `usByTacId`.
17
17
 
18
18
  import { rcfError } from '../errors/index.js';
19
- import { normaliseId } from './ids.js';
19
+ import { canonicaliseStem, normaliseId } from './ids.js';
20
20
  import { listSubdirJsonFiles, loadDocument, loadRootDocument, pathForId, subdirFor } from './loader.js';
21
21
  import { validateDocument } from './validator.js';
22
22
 
23
23
  /**
24
- * Derive an id from a filename stem, upper-casing the prefix segment only
25
- * and leaving any slug tail verbatim (0.8.0 slug-train; w-2026-07-28-012
26
- * landmine 1). Every id shape rcf-schemas 0.4.3 admits is
27
- * `<PREFIX>-<digits>[-<lower-kebab>]`, so the split is always on the
28
- * FIRST hyphen. Segments after the first hyphen are lower-case by
29
- * construction (schema pattern `[a-z0-9]+(?:-[a-z0-9]+)*`); we still
30
- * leave them verbatim rather than round-tripping through
31
- * .toLowerCase() so a slug that fails the pattern surfaces as-is at the
32
- * schema-validation step instead of being silently masked here.
24
+ * Derive an id from a filename stem. Delegates to the core grammar in
25
+ * `ids.js` (`canonicaliseStem`), which understands BOTH id families
26
+ * (rcf-schemas 0.4.4):
33
27
  *
34
- * @param {string} stem - filename stem, e.g. `fbs-004-user-login`.
35
- * @returns {string} canonical id, e.g. `FBS-004-user-login`.
28
+ * - Prefix families (REQ / US / PRD / BS / TAD / TS) admit an optional
29
+ * kebab-slug PREFIX -- `spa-req-001` -> `spa-REQ-001`.
30
+ * - Suffix families (ADR / TAC / FBS / CN) admit an optional kebab-slug
31
+ * SUFFIX -- `fbs-004-user-login` -> `FBS-004-user-login`,
32
+ * `adr-005-spa` -> `ADR-005-spa`.
33
+ *
34
+ * Before this delegation the walker upper-cased only the FIRST dash
35
+ * segment, which was correct for suffix families but yielded
36
+ * `SPA-req-001` for a prefix-family stem `spa-req-001`. That id then
37
+ * failed `pathForId` at load time and every consuming verb refused with
38
+ * "Unrecognised document id" (w-2026-08-19-003).
39
+ *
40
+ * Defensive fallback: for stems that match no family pattern (a
41
+ * genuinely garbage filename), keep the old first-dash upper-case fold
42
+ * so the walker still records SOMETHING addressable and the subsequent
43
+ * load / validation step surfaces the real error. A crash here would
44
+ * hide the file's true problem.
45
+ *
46
+ * @param {string} stem - filename stem, e.g. `spa-req-001` or `fbs-004-user-login`.
47
+ * @returns {string} canonical id.
36
48
  */
37
49
  function idFromFilenameStem(stem) {
50
+ const canonical = canonicaliseStem(stem);
51
+ if (canonical) return canonical;
38
52
  const dash = stem.indexOf('-');
39
53
  if (dash === -1) return stem.toUpperCase();
40
- const prefix = stem.slice(0, dash).toUpperCase();
41
- const tail = stem.slice(dash);
42
- return `${prefix}${tail}`;
54
+ return `${stem.slice(0, dash).toUpperCase()}${stem.slice(dash)}`;
43
55
  }
44
56
 
45
57
  /**
@@ -1286,7 +1286,7 @@ export async function deleteDocument({ projectRoot, tree, id, options = {}, walk
1286
1286
  if (rootKinds.has(kind)) {
1287
1287
  return rcfError({
1288
1288
  kind: 'usage',
1289
- message: `delete: root singleton ${id} cannot be deleted via rcf delete`,
1289
+ message: `delete: root singleton ${id} cannot be deleted via rcf define delete`,
1290
1290
  documentId: id,
1291
1291
  });
1292
1292
  }
@@ -0,0 +1,13 @@
1
+ // Deployment-gate barrel. Shared primitives downstream project probes
2
+ // (TAC-210 external-service-dependency provisioning; TAC-211 core-flow
3
+ // end-to-end) import so the placeholder-detection ruleset lives in ONE
4
+ // place and extends via a rcf-lite minor bump, not a per-project fork.
5
+ //
6
+ // Watchpost first-production defect (w-2026-08-24-005, class cure
7
+ // w-2026-08-24-006) is the reason this module exists: the app shipped
8
+ // with RESEND_API_KEY set to a placeholder, the only login path was
9
+ // inert, the gap was filed as a "quirk" note, and sign-off still said
10
+ // DEPLOYED. The SPA blueprint v1.3.0 contributions bind those class
11
+ // rules; this module is the shared enforcement handle.
12
+
13
+ export { detectPlaceholderCredentialShape, PLACEHOLDER_DETECTOR_VERSION } from './placeholder-detector.js';