rcf-lite 0.7.1 → 0.9.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 (77) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/rcf.js +6 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/harness-template.md +11 -0
  5. package/guidance/managed/agent-instructions-block.hash +1 -1
  6. package/guidance/managed/agent-instructions-block.md +11 -0
  7. package/package.json +5 -3
  8. package/rcf/adrs/adr-010.json +30 -0
  9. package/rcf/code-nodes/cn-058.json +18 -0
  10. package/rcf/code-nodes/cn-059.json +14 -0
  11. package/rcf/code-nodes/cn-060.json +14 -0
  12. package/rcf/code-nodes/cn-061.json +14 -0
  13. package/rcf/code-nodes/cn-062.json +14 -0
  14. package/rcf/code-nodes/cn-063.json +15 -0
  15. package/rcf/code-nodes/cn-064.json +15 -0
  16. package/rcf/code-nodes/cn-065.json +16 -0
  17. package/rcf/code-nodes/cn-066.json +14 -0
  18. package/rcf/code-nodes/cn-067.json +15 -0
  19. package/rcf/code-nodes/cn-068.json +15 -0
  20. package/rcf/code-nodes/cn-069.json +16 -0
  21. package/rcf/fbs/fbs-016.json +39 -0
  22. package/rcf/fbs/fbs-017.json +40 -0
  23. package/rcf/fbs/fbs-018.json +34 -0
  24. package/rcf/fbs/fbs-019.json +33 -0
  25. package/rcf/requirements/req-010.json +20 -0
  26. package/rcf/test-suites/ts-026.json +54 -0
  27. package/rcf/test-suites/ts-027.json +115 -0
  28. package/rcf/test-suites/ts-028.json +46 -0
  29. package/rcf/test-suites/ts-029.json +46 -0
  30. package/rcf/user-stories/us-1001.json +56 -0
  31. package/rcf/user-stories/us-1002.json +96 -0
  32. package/rcf/user-stories/us-1003.json +48 -0
  33. package/rcf/user-stories/us-1004.json +48 -0
  34. package/src/admissibility/enforce.js +142 -0
  35. package/src/admissibility/index.js +8 -0
  36. package/src/admissibility/markers.js +104 -0
  37. package/src/admissibility/scope-lint.js +163 -0
  38. package/src/blueprint/apply.js +464 -0
  39. package/src/blueprint/conflicts.js +351 -0
  40. package/src/blueprint/diff.js +82 -0
  41. package/src/blueprint/index.js +12 -0
  42. package/src/blueprint/list.js +21 -0
  43. package/src/blueprint/loader.js +163 -0
  44. package/src/blueprint/manifest-writer.js +49 -0
  45. package/src/blueprint/namespace.js +145 -0
  46. package/src/blueprint/remove.js +105 -0
  47. package/src/blueprint/resolutions.js +83 -0
  48. package/src/blueprint/standards.js +148 -0
  49. package/src/blueprint/supersede.js +318 -0
  50. package/src/browser-verify/invariants.js +33 -6
  51. package/src/build/bundle.js +34 -11
  52. package/src/build/standards-selector.js +52 -0
  53. package/src/cli/blueprint.js +325 -0
  54. package/src/cli/create.js +49 -1
  55. package/src/cli/help.js +8 -0
  56. package/src/cli/init.js +20 -5
  57. package/src/cli/read.js +7 -1
  58. package/src/cli/standards.js +127 -0
  59. package/src/cli/test-suite.js +7 -2
  60. package/src/core/store/ids.js +168 -18
  61. package/src/core/store/loader.js +31 -17
  62. package/src/core/store/walker.js +62 -4
  63. package/src/core/store/writer.js +41 -11
  64. package/src/deployment/index.js +13 -0
  65. package/src/deployment/placeholder-detector.js +113 -0
  66. package/src/finalise/detect.js +51 -29
  67. package/src/finalise/index.js +16 -2
  68. package/src/finalise/ingest.js +41 -0
  69. package/src/mcp/tools.js +10 -2
  70. package/src/query/formatters/table.js +7 -10
  71. package/src/query/index.js +4 -0
  72. package/src/query/refuse-on-admissibility.js +73 -0
  73. package/src/query/trace.js +45 -4
  74. package/src/ruleset/index.js +140 -0
  75. package/src/ruleset/ruleset.json +146 -0
  76. package/src/verify/chain/index.js +31 -0
  77. package/src/verify/verdict/index.js +67 -0
@@ -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
  /**
@@ -154,7 +165,10 @@ export async function loadDocument({ projectRoot, id }) {
154
165
  * discovery mechanism for tree topology (topology comes from parent-id
155
166
  * fields); this is just the load-time enumeration required to bring every
156
167
  * on-disk file into memory. Callers derive the document id from the
157
- * filename stem in upper case (per the layout convention).
168
+ * filename stem by upper-casing the PREFIX segment only (0.8.0
169
+ * slug-train, w-2026-07-28-012 landmine 1); slug tails stay verbatim
170
+ * because rcf-schemas 0.4.3 admits lower-case kebab tails on FBS / CN /
171
+ * ADR / TAC and a full-stem fold would silently detach the graph.
158
172
  *
159
173
  * Returns `{ files: string[] }` on success. Missing subdir returns
160
174
  * `{ files: [] }` (an empty children collection is a valid tree state).
@@ -16,10 +16,44 @@
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
+ /**
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):
27
+ *
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.
48
+ */
49
+ function idFromFilenameStem(stem) {
50
+ const canonical = canonicaliseStem(stem);
51
+ if (canonical) return canonical;
52
+ const dash = stem.indexOf('-');
53
+ if (dash === -1) return stem.toUpperCase();
54
+ return `${stem.slice(0, dash).toUpperCase()}${stem.slice(dash)}`;
55
+ }
56
+
23
57
  /**
24
58
  * @typedef {object} TreeModel
25
59
  * @property {object|null} manifest
@@ -182,7 +216,23 @@ async function loadChildKind(kind, { projectRoot, tree, errors }) {
182
216
  }
183
217
  for (const entry of listing.files) {
184
218
  const stem = entry.replace(/\.json$/, '');
185
- const id = stem.toUpperCase();
219
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 1 / d-2026-07-28-011
220
+ // recon): upper-case the PREFIX segment only. Full-stem toUpperCase
221
+ // was lossless while every id was `<PREFIX>-<digits>`; the moment a
222
+ // slug lands (`fbs-004-user-login.json` -> body `FBS-004-user-login`),
223
+ // the whole-stem fold produced `FBS-004-USER-LOGIN` in tree.byId /
224
+ // kindById / parentByChild while every inbound reference
225
+ // (dependsOnFbsIds, contextRequirements.adrIds, us.tacIds,
226
+ // cn.dependencies) used the lower-case form. The graph silently
227
+ // detached. Fixing this here, before any slug-consuming change lands,
228
+ // is the single load-bearing precondition of the whole slug design
229
+ // (see the item's regression test in test/store/walker.test.js:
230
+ // "walkTree preserves case on slug tails when deriving id from
231
+ // filename"). Slugs are lower-case kebab by construction (rcf-schemas
232
+ // 0.4.3 pattern `[a-z0-9]+(?:-[a-z0-9]+)*`), so leaving the tail
233
+ // verbatim is safe. `PRD-001.json` / `req-002.json` / mixed-case
234
+ // filenames continue to normalise their prefix.
235
+ const id = idFromFilenameStem(stem);
186
236
  // Filename-derived ids are not injective: on a case-sensitive
187
237
  // filesystem `REQ-001.json` and `req-001.json` are two files that
188
238
  // both resolve to `REQ-001`. Recording both silently collapsed the
@@ -848,10 +898,18 @@ function collectBrokenReferences(tree, errors) {
848
898
  }
849
899
  }
850
900
  // Inline TC id pattern check: `TC-<TS-suffix>-<slug>`.
851
- const tsSuffix = ts.id?.match(/^TS-(\d{3})$/)?.[1];
901
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3): widened from
902
+ // `\d{3}` (three-digit-exact) to `\d{3,}` in lockstep with
903
+ // rcf-schemas 0.4.3's TS/TC pattern widening. The pre-0.8.0 shape
904
+ // silently skipped every TS above 999: a TS-1000 failed the exact
905
+ // three-digit match, tsSuffix was undefined, and the whole
906
+ // idPrefixMatchesParent rule never fired for that TS's inline TCs.
907
+ // No error, no warning, just an unchecked doc -- exactly the
908
+ // silent-skip class this train is chartered to eliminate.
909
+ const tsSuffix = ts.id?.match(/^TS-(\d{3,})$/)?.[1];
852
910
  if (tsSuffix) {
853
911
  for (const tc of ts.testCases ?? []) {
854
- const m = String(tc.id ?? '').match(/^TC-(\d{3})-[a-z0-9-]+$/);
912
+ const m = String(tc.id ?? '').match(/^TC-(\d{3,})-[a-z0-9-]+$/);
855
913
  if (m && m[1] !== tsSuffix) {
856
914
  errors.push(rcfError({
857
915
  kind: 'brokenReference',
@@ -249,7 +249,9 @@ export function nextIdForKind(tree, kind, opts = {}) {
249
249
  if (typeof slug !== 'string' || slug.length === 0) {
250
250
  throw new TypeError('nextIdForKind tc requires opts.slug');
251
251
  }
252
- const mTs = /^TS-(\d{3})$/.exec(tsId);
252
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3): widened to
253
+ // `\d{3,}` in lockstep with rcf-schemas 0.4.3.
254
+ const mTs = /^TS-(\d{3,})$/.exec(tsId);
253
255
  if (!mTs) throw new TypeError('nextIdForKind tc: unrecognised TS id');
254
256
  return `TC-${mTs[1]}-${slug}`;
255
257
  }
@@ -263,15 +265,24 @@ export function nextIdForKind(tree, kind, opts = {}) {
263
265
  // set fed in (see `occupiedIdsOfKind`). Comparison stays numeric and the
264
266
  // emitted id is always the canonical three-digit-minimum spelling, so a
265
267
  // freshly allocated id is never a leading-zero variant of a taken one.
268
+ //
269
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 2): the previous shape used
270
+ // `^<PREFIX>-(\\d+)$` and slug-blindly ignored any id whose number was
271
+ // followed by a slug tail. Verified empirically: /^FBS-(\d+)$/.test(
272
+ // 'FBS-003-user-login') === false, so a slugged id was invisible to the
273
+ // high-water mark and the allocator reset to 001 and re-issued taken
274
+ // numbers. Use the shared `idNumber(id, prefix)` helper (ids.js:72-78,
275
+ // pattern `^${prefix}-(\d+)(?:-|$)`) instead of writing a second parser
276
+ // -- that helper's whole job is to parse a number out of any id shape the
277
+ // schemas admit (numeric-only OR slug-suffixed), so numeric-only and
278
+ // slugged ids feed into the SAME high-water mark and cannot collide by
279
+ // spelling. w-2026-07-28-017's `occupiedIdsOfKind` already feeds the
280
+ // right ids in; landmine 2 is that this reader could not parse them.
266
281
  function nextFlatId(prefix, ids) {
267
282
  let max = 0;
268
- const re = new RegExp(`^${prefix}-(\\d+)$`);
269
283
  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
- }
284
+ const n = idNumber(id, prefix);
285
+ if (n !== null && n > max) max = n;
275
286
  }
276
287
  return `${prefix}-${String(max + 1).padStart(3, '0')}`;
277
288
  }
@@ -904,8 +915,14 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
904
915
  message: 'create tc: --test-pointer is required (format filePath::testName; coverage counts a TC only when its pointer resolves to a real test)',
905
916
  });
906
917
  }
907
- const slug = options.slug ?? deriveSlug(description);
908
- const tsSuffix = /^TS-(\d{3})$/.exec(parentTsId)?.[1];
918
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 4): deriveSlug now returns
919
+ // '' on empty derivation so the TC-specific `|| 'tc'` fallback is applied
920
+ // here rather than leaking the literal 'tc' into non-TC callers. See
921
+ // deriveSlug for the full argument.
922
+ const slug = options.slug ?? (deriveSlug(description) || 'tc');
923
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3): widened `\d{3}` ->
924
+ // `\d{3,}` in lockstep with rcf-schemas 0.4.3.
925
+ const tsSuffix = /^TS-(\d{3,})$/.exec(parentTsId)?.[1];
909
926
  if (!tsSuffix) {
910
927
  return rcfError({ kind: 'usage', message: `create tc: parent ${parentTsId} has an unrecognised id shape` });
911
928
  }
@@ -959,7 +976,17 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
959
976
  * partial word is dropped (B2 fix, E2E matrix 2026-07-06-003 - ids
960
977
  * like "...-saved-whil" chopped mid-word). A single unbroken word
961
978
  * longer than the limit keeps its 40-char prefix (no boundary exists).
979
+ *
980
+ * 0.8.0 slug-train (w-2026-07-28-012 landmine 4): returns '' when no
981
+ * slug can be derived (empty / non-word / whitespace input). The previous
982
+ * shape returned the literal 'tc' as a fallback, which leaked the TC
983
+ * kind-specific default into every slug caller -- an FBS whose title
984
+ * derived to empty would land as `FBS-004-tc`, entirely wrong for the
985
+ * kind. TC callers now apply `deriveSlug(description) || 'tc'` locally
986
+ * (createInlineTc); every non-TC caller decides what its own empty
987
+ * fallback should be, or refuses.
962
988
  * @param {string} description
989
+ * @returns {string} slug, or '' when no slug can be derived
963
990
  */
964
991
  export function deriveSlug(description) {
965
992
  const full = String(description)
@@ -973,7 +1000,7 @@ export function deriveSlug(description) {
973
1000
  if (boundary > 0) slug = slug.slice(0, boundary);
974
1001
  }
975
1002
  slug = slug.replace(/-+$/g, '');
976
- return slug.length > 0 ? slug : 'tc';
1003
+ return slug;
977
1004
  }
978
1005
 
979
1006
  /**
@@ -1160,7 +1187,10 @@ function checkCrossLinks(tree, kind, doc, docId) {
1160
1187
  function resolveInlineId(id) {
1161
1188
  if (typeof id !== 'string') return null;
1162
1189
  if (/^AC-\d+(-\d+)?$/.test(id)) return { kind: 'ac' };
1163
- if (/^TC-\d{3}-[a-z0-9-]+$/.test(id)) return { kind: 'tc' };
1190
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3): widened `\d{3}` -> `\d{3,}`
1191
+ // in lockstep with rcf-schemas 0.4.3. A TC-1000-... would otherwise fail
1192
+ // the inline resolver even though the schema admits it.
1193
+ if (/^TC-\d{3,}-[a-z0-9-]+$/.test(id)) return { kind: 'tc' };
1164
1194
  return null;
1165
1195
  }
1166
1196
 
@@ -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';
@@ -0,0 +1,113 @@
1
+ // Canonical placeholder-credential-shape detector for the deployment-gate
2
+ // class cure (spa-US-1132 / TAC-210, w-2026-08-24-006).
3
+ //
4
+ // The watchpost first production review (w-2026-08-24-005) shipped with
5
+ // RESEND_API_KEY set to a placeholder value; the only login path (magic-link
6
+ // email) was inert; sign-off still said DEPLOYED. The gap was filed as a
7
+ // "quirk" note in status.md rather than blocked at gate time. This module
8
+ // owns the one canonical placeholder shape catalogue rcf-lite ships so
9
+ // downstream projects (via TAC-210 realisations) apply the same detector
10
+ // - extending the ruleset is a rcf-lite minor bump, never a per-project
11
+ // reinvention.
12
+ //
13
+ // The catalogue is deliberately narrow: only credential-shape strings a
14
+ // developer would reasonably type as a stand-in ("YOUR_API_KEY_HERE",
15
+ // "changeme", "xxx", empty). It does NOT try to classify all bad values;
16
+ // entropy checks, provider-specific prefix rules, and format regexes live
17
+ // in project-authored probes. This catalogue is what would have refused
18
+ // the watchpost RESEND_API_KEY at gate time.
19
+
20
+ /**
21
+ * @typedef {object} PlaceholderMatch
22
+ * @property {'empty'|'single-dash'|'null-token'|'you-here'|'named-stand-in'|'repeat-char'} pattern
23
+ * The named pattern that matched. Callers surface this on their report
24
+ * so a refusal message can name why.
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} PlaceholderVerdict
29
+ * @property {boolean} isPlaceholder true when the value matches a placeholder shape
30
+ * @property {PlaceholderMatch['pattern']} [pattern] the matched pattern name, when isPlaceholder is true
31
+ * @property {string} [reason] a short refusal-message-ready reason string
32
+ */
33
+
34
+ // Case-insensitive named stand-ins a developer would reasonably type. The
35
+ // set is intentionally small; adding to it is a rcf-lite minor bump, and
36
+ // the additions land in ONE place downstream projects inherit.
37
+ const NAMED_STAND_INS = new Set([
38
+ 'changeme',
39
+ 'placeholder',
40
+ 'example',
41
+ 'test',
42
+ 'dummy',
43
+ 'fake',
44
+ 'todo',
45
+ 'tbd',
46
+ 'sample',
47
+ 'default',
48
+ ]);
49
+
50
+ // `YOUR_X_HERE`, `your-key-here`, etc. Case-insensitive.
51
+ const YOU_HERE_RE = /^your[_-].+[_-]here$/i;
52
+
53
+ // Repeated single characters ("xxxx", "----", "0000") of length >= 3, or
54
+ // the literal "xxx+" family common in scaffolds.
55
+ const REPEAT_CHAR_RE = /^(.)\1{2,}$/;
56
+
57
+ // Literal null-token strings that leak in when a resolver hands back a
58
+ // nullish value stringified. `.env` files stringify unset values to the
59
+ // empty string, but a JS resolver that String()s an undefined lands
60
+ // "undefined" in the ship environment - that IS a placeholder, not a
61
+ // live credential.
62
+ const NULL_TOKENS = new Set(['null', 'undefined', 'none']);
63
+
64
+ /**
65
+ * Detect whether `value` matches a placeholder-shape a credential value
66
+ * would never legitimately hold. Deterministic; case-insensitive against
67
+ * the named-stand-in catalogue and the `YOUR_X_HERE` pattern.
68
+ *
69
+ * The intent is a hard refusal filter, not a heuristic: every match is
70
+ * something a developer typed as a stand-in and forgot to replace. False
71
+ * positives are vanishingly unlikely; the shortest name-family entry is
72
+ * six characters ("dummy" being five is the shortest, and no real credential
73
+ * is a five-character English word); the repeat-char rule requires length
74
+ * >= 3 so a two-character hash prefix does not match.
75
+ *
76
+ * @param {unknown} value the raw credential-field value at ship time
77
+ * @returns {PlaceholderVerdict}
78
+ */
79
+ export function detectPlaceholderCredentialShape(value) {
80
+ // Non-strings are not credentials the shipped runtime carries; skip.
81
+ if (typeof value !== 'string') {
82
+ return { isPlaceholder: false };
83
+ }
84
+ const raw = value;
85
+ const trimmed = raw.trim();
86
+ if (trimmed.length === 0) {
87
+ return { isPlaceholder: true, pattern: 'empty', reason: 'empty or whitespace-only credential value' };
88
+ }
89
+ if (trimmed === '-' || trimmed === '_') {
90
+ return { isPlaceholder: true, pattern: 'single-dash', reason: `credential value is the single-character stand-in "${trimmed}"` };
91
+ }
92
+ const lower = trimmed.toLowerCase();
93
+ if (NULL_TOKENS.has(lower)) {
94
+ return { isPlaceholder: true, pattern: 'null-token', reason: `credential value is the literal null-token string "${trimmed}"` };
95
+ }
96
+ if (YOU_HERE_RE.test(trimmed)) {
97
+ return { isPlaceholder: true, pattern: 'you-here', reason: `credential value matches the YOUR_X_HERE scaffold pattern: "${trimmed}"` };
98
+ }
99
+ if (NAMED_STAND_INS.has(lower)) {
100
+ return { isPlaceholder: true, pattern: 'named-stand-in', reason: `credential value is the named stand-in "${trimmed}"` };
101
+ }
102
+ if (REPEAT_CHAR_RE.test(trimmed)) {
103
+ return { isPlaceholder: true, pattern: 'repeat-char', reason: `credential value is a repeated-single-character stand-in "${trimmed}"` };
104
+ }
105
+ return { isPlaceholder: false };
106
+ }
107
+
108
+ /**
109
+ * The catalogue's stable identifier. Downstream probes surface this on
110
+ * their report so the gate-failure trail names which detector version
111
+ * refused the value. Extending the catalogue bumps this constant.
112
+ */
113
+ export const PLACEHOLDER_DETECTOR_VERSION = '1.0.0';