rcf-lite 0.8.0 → 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 (60) hide show
  1. package/CHANGELOG.md +41 -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 +2 -2
  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/blueprint/apply.js +464 -0
  35. package/src/blueprint/conflicts.js +351 -0
  36. package/src/blueprint/diff.js +82 -0
  37. package/src/blueprint/index.js +12 -0
  38. package/src/blueprint/list.js +21 -0
  39. package/src/blueprint/loader.js +163 -0
  40. package/src/blueprint/manifest-writer.js +49 -0
  41. package/src/blueprint/namespace.js +145 -0
  42. package/src/blueprint/remove.js +105 -0
  43. package/src/blueprint/resolutions.js +83 -0
  44. package/src/blueprint/standards.js +148 -0
  45. package/src/blueprint/supersede.js +318 -0
  46. package/src/browser-verify/invariants.js +33 -6
  47. package/src/build/bundle.js +34 -11
  48. package/src/build/standards-selector.js +52 -0
  49. package/src/cli/blueprint.js +325 -0
  50. package/src/cli/create.js +45 -0
  51. package/src/cli/help.js +8 -0
  52. package/src/cli/init.js +20 -5
  53. package/src/cli/standards.js +127 -0
  54. package/src/core/store/ids.js +168 -18
  55. package/src/core/store/loader.js +27 -16
  56. package/src/core/store/walker.js +27 -15
  57. package/src/deployment/index.js +13 -0
  58. package/src/deployment/placeholder-detector.js +113 -0
  59. package/src/query/formatters/table.js +7 -10
  60. package/src/query/trace.js +45 -4
@@ -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
  /**
@@ -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';
@@ -122,7 +122,7 @@ function formatTraceTable(result) {
122
122
  lines.push(`${directionLabel}: (none)`);
123
123
  } else {
124
124
  for (const n of showList) {
125
- rows.push([String(n.depth), n.id, n.kind, cellTitle(n.id)]);
125
+ rows.push([String(n.depth), n.id, n.kind, n.title ?? '']);
126
126
  }
127
127
  lines.push(renderTable(rows));
128
128
  }
@@ -139,7 +139,7 @@ function formatBothTraceTable(result) {
139
139
  lines.push(' (none)');
140
140
  } else {
141
141
  const rows = [['Depth', 'Id', 'Kind', 'Title']];
142
- for (const n of ancestors) rows.push([String(n.depth), n.id, n.kind, '']);
142
+ for (const n of ancestors) rows.push([String(n.depth), n.id, n.kind, n.title ?? '']);
143
143
  lines.push(renderTable(rows));
144
144
  }
145
145
  lines.push('');
@@ -151,7 +151,7 @@ function formatBothTraceTable(result) {
151
151
  lines.push(' (none)');
152
152
  } else {
153
153
  const rows = [['Depth', 'Id', 'Kind', 'Title']];
154
- for (const n of descendants) rows.push([String(n.depth), n.id, n.kind, '']);
154
+ for (const n of descendants) rows.push([String(n.depth), n.id, n.kind, n.title ?? '']);
155
155
  lines.push(renderTable(rows));
156
156
  }
157
157
  return `${lines.join('\n')}\n`;
@@ -194,10 +194,7 @@ function renderTable(rows) {
194
194
  return [out[0], sep, ...out.slice(1)].join('\n');
195
195
  }
196
196
 
197
- // Placeholder for future title lookup - the pure trace result does not
198
- // carry doc bodies, so title columns render blank unless the caller
199
- // passes a title source. Left here for the trace `Title` column so the
200
- // column stays if we ever wire in a title map without a schema change.
201
- function cellTitle(_id) {
202
- return '';
203
- }
197
+ // Trace nodes carry `title` as of the paper-cut batch (previously the
198
+ // Title column was always empty). Doc-kind title source: PRD
199
+ // productName, REQ/US/TAC/ADR/TAD/TS/FBS/CN title, inline AC/TC
200
+ // description. Nodes lacking a title render blank.
@@ -32,6 +32,7 @@
32
32
  * @property {string} id
33
33
  * @property {string} kind
34
34
  * @property {number} depth - 0 for pivot; positive for descendants; negative for ancestors
35
+ * @property {string} title - display title, or '' when the node has none
35
36
  */
36
37
 
37
38
  /**
@@ -71,6 +72,41 @@ export function kindOf(tree, id) {
71
72
  return null;
72
73
  }
73
74
 
75
+ /**
76
+ * Return the display title for an id, or '' when the node carries none.
77
+ * Different doc kinds carry the title on different fields (PRD →
78
+ * productName; inline AC/TC → description; everything else → title). A
79
+ * standalone doc that lacks its title field renders blank rather than
80
+ * throwing; that keeps trace output readable on a partially-authored tree.
81
+ *
82
+ * Inline lookup: AC / TC nodes live inside their parent's inline array
83
+ * (userStory.acceptanceCriteria[] / testSuite.testCases[]), not as
84
+ * standalone entries in `tree.byId`. Resolve via parentByChild.
85
+ *
86
+ * @param {object} tree - walker TreeModel
87
+ * @param {string} id
88
+ * @param {string} kind - as returned by `kindOf`
89
+ * @returns {string}
90
+ */
91
+ export function titleOf(tree, id, kind) {
92
+ if (kind === 'ac') {
93
+ const usId = tree.parentByChild.get(id);
94
+ const us = usId ? tree.byId.get(usId) : null;
95
+ const ac = us?.acceptanceCriteria?.find((a) => a?.id === id);
96
+ return ac?.description ?? '';
97
+ }
98
+ if (kind === 'tc') {
99
+ const tsId = tree.parentByChild.get(id);
100
+ const ts = tsId ? tree.byId.get(tsId) : null;
101
+ const tc = ts?.testCases?.find((t) => t?.id === id);
102
+ return tc?.description ?? '';
103
+ }
104
+ const doc = tree.byId.get(id);
105
+ if (!doc) return '';
106
+ if (kind === 'prd') return doc.productName ?? '';
107
+ return doc.title ?? '';
108
+ }
109
+
74
110
  /**
75
111
  * Compute a trace from `id` in the requested direction. Unknown pivot
76
112
  * returns `{found: false}`; the handler layer converts this to exit 2.
@@ -134,7 +170,7 @@ export function computeTrace(tree, {
134
170
  */
135
171
  function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDependents = false) {
136
172
  /** @type {TraceNode[]} */
137
- const nodes = [{ id: pivot, kind: pivotKind, depth: 0 }];
173
+ const nodes = [{ id: pivot, kind: pivotKind, depth: 0, title: titleOf(tree, pivot, pivotKind) }];
138
174
  /** @type {TraceEdge[]} */
139
175
  const edges = [];
140
176
  const seen = new Set([pivot]);
@@ -158,7 +194,12 @@ function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDepen
158
194
  if (!childKind) continue;
159
195
  const nextDepth = curDepth + 1;
160
196
  depthById.set(child.id, nextDepth);
161
- nodes.push({ id: child.id, kind: childKind, depth: nextDepth });
197
+ nodes.push({
198
+ id: child.id,
199
+ kind: childKind,
200
+ depth: nextDepth,
201
+ title: titleOf(tree, child.id, childKind),
202
+ });
162
203
  queue.push(child.id);
163
204
  }
164
205
  }
@@ -178,7 +219,7 @@ function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDepen
178
219
  */
179
220
  function walkBack(tree, pivot, pivotKind) {
180
221
  /** @type {TraceNode[]} */
181
- const nodes = [{ id: pivot, kind: pivotKind, depth: 0 }];
222
+ const nodes = [{ id: pivot, kind: pivotKind, depth: 0, title: titleOf(tree, pivot, pivotKind) }];
182
223
  /** @type {TraceEdge[]} */
183
224
  const edges = [];
184
225
  const seen = new Set([pivot]);
@@ -188,7 +229,7 @@ function walkBack(tree, pivot, pivotKind) {
188
229
  const k = kindOf(tree, toId);
189
230
  if (!k) return;
190
231
  seen.add(toId);
191
- nodes.push({ id: toId, kind: k, depth });
232
+ nodes.push({ id: toId, kind: k, depth, title: titleOf(tree, toId, k) });
192
233
  edges.push({ from: fromId, to: toId, kind: edgeKind });
193
234
  };
194
235