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,23 +1,32 @@
1
1
  // rcf-verify install detection (spec §8.3, amendment 5 - install-together
2
- // posture). build-lite's finalise gate MUST detect whether `rcf-verify` is
3
- // resolvable and, when it is absent, prompt to install it - NEVER silently
4
- // skip the ship gate (the one behaviour §8.3 explicitly forbids).
2
+ // posture). The finalise gate MUST detect whether `rcf-verify` is resolvable
3
+ // and, when it is absent, prompt to install it - NEVER silently skip the ship
4
+ // gate (the one behaviour §8.3 explicitly forbids).
5
5
  //
6
- // Two detection routes, in order, matching how the two packages are actually
7
- // installed:
8
- // 1. The `rcf-verify` bin on PATH - the install-together default is two
9
- // global bins (`npm i -g @stravica-ai/rcf-build-lite @stravica-ai/rcf-verify-lite`).
6
+ // Two detection routes, in order, matching how the CLI is actually installed:
7
+ // 1. The `rcf-verify` bin on PATH - the umbrella install exposes both `rcf`
8
+ // and `rcf-verify` as global bins (`npm i -g rcf-lite`).
10
9
  // 2. Package resolution from the project dir - the local-project install
11
- // (`npm i @stravica-ai/rcf-verify-lite` in a repo's node_modules).
12
- // Either hit yields a concrete invocation the finalise spawn (spawn.js) uses
13
- // verbatim. A miss returns { installed:false } and the caller enters the
14
- // prompt-or-explicit-flag path.
10
+ // (`npm i rcf-lite` in a repo's node_modules). Two package specifiers
11
+ // are tried in order: the current umbrella `rcf-lite`, then the legacy
12
+ // `@stravica-ai/rcf-verify-lite` name (deprecated at 0.7.1 but still
13
+ // resolvable in lockfiles that pinned it). Either resolves to the same
14
+ // `bin/rcf-verify.js` entry point.
15
+ // Either route yields a concrete invocation the finalise spawn (spawn.js)
16
+ // uses verbatim. A miss returns { installed:false } and the caller enters
17
+ // the prompt-or-explicit-flag path.
15
18
 
16
19
  import { access, constants } from 'node:fs/promises';
17
20
  import { createRequire } from 'node:module';
18
21
  import { delimiter, dirname, join, resolve } from 'node:path';
19
22
 
20
- const VERIFY_PACKAGE = '@stravica-ai/rcf-verify-lite';
23
+ // VERIFY_PACKAGE is the current install target and the name shown to
24
+ // operators in absent-verify messaging. VERIFY_PACKAGE_LEGACY is only used
25
+ // as a resolution fallback for lockfiles still pinning the pre-0.7.1 scoped
26
+ // name; it is never displayed as an install target.
27
+ const VERIFY_PACKAGE = 'rcf-lite';
28
+ const VERIFY_PACKAGE_LEGACY = '@stravica-ai/rcf-verify-lite';
29
+ const VERIFY_PACKAGE_CANDIDATES = [VERIFY_PACKAGE, VERIFY_PACKAGE_LEGACY];
21
30
  const VERIFY_BIN = 'rcf-verify';
22
31
 
23
32
  /**
@@ -69,31 +78,44 @@ export async function findOnPath(name, { env = process.env } = {}) {
69
78
  }
70
79
 
71
80
  /**
72
- * Resolve the rcf-verify package's bin entry point from a starting directory,
73
- * following the normal node_modules resolution the caller's project sees.
74
- * Returns the absolute path to `bin/rcf-verify.js`, or null if the package is
75
- * not installed / not resolvable from there.
81
+ * Resolve the rcf-verify bin entry point from a starting directory, following
82
+ * the normal node_modules resolution the caller's project sees. Returns the
83
+ * absolute path to `bin/rcf-verify.js`, or null if no candidate package is
84
+ * installed / resolvable from there.
85
+ *
86
+ * Candidates are tried in the order defined by VERIFY_PACKAGE_CANDIDATES: the
87
+ * current umbrella (`rcf-lite`) first, then the deprecated scoped name
88
+ * (`@stravica-ai/rcf-verify-lite`) as a fallback for lockfiles pinned to it.
89
+ * The first candidate whose bin resolves and exists on disk wins.
76
90
  *
77
91
  * @param {string} fromDir - directory to resolve from (the project root / cwd)
78
92
  * @returns {Promise<string|null>}
79
93
  */
80
94
  export async function resolvePackageBin(fromDir) {
95
+ // Resolve from a synthetic module living in fromDir so node walks that
96
+ // project's node_modules chain, not this package's own.
97
+ let req;
81
98
  try {
82
- // Resolve from a synthetic module living in fromDir so node walks that
83
- // project's node_modules chain, not build-lite's own.
84
- const req = createRequire(join(fromDir, 'noop.js'));
85
- const pkgJsonPath = req.resolve(`${VERIFY_PACKAGE}/package.json`);
86
- const req2 = createRequire(pkgJsonPath);
87
- const pkg = req2(`${VERIFY_PACKAGE}/package.json`);
88
- const binField = pkg.bin;
89
- const rel = typeof binField === 'string' ? binField : binField?.[VERIFY_BIN];
90
- if (!rel) return null;
91
- const abs = resolve(dirname(pkgJsonPath), rel);
92
- await access(abs, constants.F_OK);
93
- return abs;
99
+ req = createRequire(join(fromDir, 'noop.js'));
94
100
  } catch {
95
101
  return null;
96
102
  }
103
+ for (const candidate of VERIFY_PACKAGE_CANDIDATES) {
104
+ try {
105
+ const pkgJsonPath = req.resolve(`${candidate}/package.json`);
106
+ const req2 = createRequire(pkgJsonPath);
107
+ const pkg = req2(`${candidate}/package.json`);
108
+ const binField = pkg.bin;
109
+ const rel = typeof binField === 'string' ? binField : binField?.[VERIFY_BIN];
110
+ if (!rel) continue;
111
+ const abs = resolve(dirname(pkgJsonPath), rel);
112
+ await access(abs, constants.F_OK);
113
+ return abs;
114
+ } catch {
115
+ // Candidate not resolvable from here; try the next one.
116
+ }
117
+ }
118
+ return null;
97
119
  }
98
120
 
99
121
  /**
@@ -126,4 +148,4 @@ export async function detectVerify(deps = {}) {
126
148
  return { installed: false, invocation: null };
127
149
  }
128
150
 
129
- export { VERIFY_PACKAGE, VERIFY_BIN };
151
+ export { VERIFY_PACKAGE, VERIFY_PACKAGE_LEGACY, VERIFY_PACKAGE_CANDIDATES, VERIFY_BIN };
@@ -6,10 +6,24 @@
6
6
  // absent - prompts to install rather than silently skipping the gate (detect.js
7
7
  // + install.js).
8
8
 
9
- export { detectVerify, findOnPath, resolvePackageBin, VERIFY_PACKAGE, VERIFY_BIN } from './detect.js';
9
+ export {
10
+ detectVerify,
11
+ findOnPath,
12
+ resolvePackageBin,
13
+ VERIFY_PACKAGE,
14
+ VERIFY_PACKAGE_LEGACY,
15
+ VERIFY_PACKAGE_CANDIDATES,
16
+ VERIFY_BIN,
17
+ } from './detect.js';
10
18
  export { buildVerifyArgs, spawnVerify } from './spawn.js';
11
19
  export { promptYesNo, installVerify, resolveAbsentVerify } from './install.js';
12
- export { loadReport, summariseReport, findMockOnlyDeclaredAcs, reportHasMockOnlyDeclared } from './ingest.js';
20
+ export {
21
+ loadReport, summariseReport,
22
+ findMockOnlyDeclaredAcs, reportHasMockOnlyDeclared,
23
+ // 0.8.0 slug-train car 4: NV-BL-GATE-01 pull-in of the profile-vs-AC
24
+ // scope-mismatch check into REVIEW.
25
+ findScopeMismatchAcs, reportHasScopeMismatch,
26
+ } from './ingest.js';
13
27
  export {
14
28
  composeShipWithoutVerifiedRecord,
15
29
  nextShipWithoutVerifiedId,
@@ -82,6 +82,17 @@ export function summariseReport(report) {
82
82
  lines.push(` - ${d.acId ?? '?'} (${d.verdict}): ${d.reason ?? 'declaredMockOnly at pre-flight; verify emitted the honest verdict rather than a false PASS.'}`);
83
83
  }
84
84
  }
85
+ // 0.8.0 slug-train car 4: NV-BL-GATE-01 pulls verify's profile-vs-AC
86
+ // scope-mismatch check into REVIEW. When verify emits SCOPE-MISMATCH
87
+ // on perAcVerdicts[] the summary surfaces it so REVIEW / finalise see
88
+ // it. Zero-mismatch reports render nothing (no false-flag noise).
89
+ const scopeMismatches = findScopeMismatchAcs(report);
90
+ if (scopeMismatches.length > 0) {
91
+ lines.push(`scope mismatches (${scopeMismatches.length}):`);
92
+ for (const s of scopeMismatches) {
93
+ lines.push(` - ${s.acId ?? '?'} (${s.verdict}): ${s.reason ?? 'a bound TC is narrower than the AC scope; NV-BL-ADM-03 / NV-BL-GATE-01.'}`);
94
+ }
95
+ }
85
96
  if (report.launchFailure?.message) {
86
97
  lines.push(`launch failure: ${report.launchFailure.message}`);
87
98
  }
@@ -117,3 +128,33 @@ export function findMockOnlyDeclaredAcs(report) {
117
128
  export function reportHasMockOnlyDeclared(report) {
118
129
  return findMockOnlyDeclaredAcs(report).length > 0;
119
130
  }
131
+
132
+ /**
133
+ * Extract SCOPE-MISMATCH per-AC verdicts from a verify report. Introduced
134
+ * in the 0.8.0 slug-train (car 4) so REVIEW consumes the same shape via
135
+ * NV-BL-GATE-01. Earlier reports carry no such entries; this returns an
136
+ * empty array on those.
137
+ *
138
+ * @param {object} report
139
+ * @returns {Array<{ acId: string, verdict: string, reason?: string }>}
140
+ */
141
+ export function findScopeMismatchAcs(report) {
142
+ const perAc = Array.isArray(report?.perAcVerdicts) ? report.perAcVerdicts : [];
143
+ return perAc
144
+ .filter((e) => e && e.verdict === 'SCOPE-MISMATCH')
145
+ .map((e) => ({ acId: e.acId, verdict: e.verdict, reason: e.reason }));
146
+ }
147
+
148
+ /**
149
+ * True when a verify report carries at least one SCOPE-MISMATCH per-AC
150
+ * verdict. NV-BL-GATE-01 (0.8.0 slug-train car 4): the REVIEW gate
151
+ * consumes this so a scope mismatch caught at REVIEW-time fails the
152
+ * gate and returns the FBS to BUILD; the finalise gate reads the same
153
+ * shape as a last-mile refusal.
154
+ *
155
+ * @param {object} report
156
+ * @returns {boolean}
157
+ */
158
+ export function reportHasScopeMismatch(report) {
159
+ return findScopeMismatchAcs(report).length > 0;
160
+ }
package/src/mcp/tools.js CHANGED
@@ -762,7 +762,12 @@ function resolveTarget(tree, id) {
762
762
  const entry = (us.acceptanceCriteria ?? []).find((ac) => ac.id === id);
763
763
  return entry ? { doc: entry } : null;
764
764
  }
765
- if (/^TC-\d{3}-[a-z0-9-]+$/.test(id)) {
765
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3, consumer-path
766
+ // straggler): widened `\d{3}` -> `\d{3,}` in lockstep with rcf-schemas
767
+ // 0.4.3's TC pattern. Under the previous shape a `read TC-1000-x` MCP
768
+ // call silently returned null even when the TC existed -- the same
769
+ // silent-skip class as the CLI `rcf read` path (src/cli/read.js).
770
+ if (/^TC-\d{3,}-[a-z0-9-]+$/.test(id)) {
766
771
  const parentId = tree.parentByChild.get(id);
767
772
  if (!parentId) return null;
768
773
  const ts = tree.byId.get(parentId);
@@ -1005,7 +1010,10 @@ export function createToolRegistry({ projectRoot, log }) {
1005
1010
  if (kind === 'tc') {
1006
1011
  if (!args.acId) return usageErrorResult('create tc: acId is required');
1007
1012
  body.acId = args.acId;
1008
- options.slug = args.slug ?? deriveSlug(body.description);
1013
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 4): deriveSlug returns
1014
+ // '' on empty derivation; TC keeps its historical 'tc' fallback
1015
+ // locally rather than letting deriveSlug bake it in.
1016
+ options.slug = args.slug ?? (deriveSlug(body.description) || 'tc');
1009
1017
  if (args.testPointer !== undefined) options.testPointer = args.testPointer;
1010
1018
  }
1011
1019
 
@@ -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.
@@ -7,3 +7,7 @@ export { computeImpact, labelFor } from './impact.js';
7
7
  export { formatTable } from './formatters/table.js';
8
8
  export { formatJson } from './formatters/json.js';
9
9
  export { formatMermaid } from './formatters/mermaid.js';
10
+ // 0.8.0 slug-train car 3: NV-BL-SR-03 addendum (ruling-sheet item 1)
11
+ // -- traceability / query tools share the refuse-first posture that
12
+ // gates rcf build. Callers wrap their query producer with this.
13
+ export { runWithAdmissibilityGate } from './refuse-on-admissibility.js';
@@ -0,0 +1,73 @@
1
+ // Traceability / query tool refuse-first wrapper (NV-BL-SR-03
2
+ // addendum on ruling-sheet item 1, ratified 2026-08-11).
3
+ //
4
+ // The ruleset's `toolScope` block declares:
5
+ // { chainAdmissibility: true, traceabilityAndQueryTools: true }
6
+ //
7
+ // meaning the same refusal semantics that gate `rcf build` also gate
8
+ // the traceability and query tools. A tool that hides an admissibility
9
+ // failure is the same class of defect as a build that hides one.
10
+ //
11
+ // This module wraps a query result so a REFUSE verdict from
12
+ // `enforceAdmissibility` short-circuits the tool's output. Callers
13
+ // pass the walker tree and the chain's declared ruleset version; on
14
+ // REFUSE the wrapper returns a refusal envelope naming the unresolved
15
+ // findings. On PASS or PASS-WITH-OVERRIDES the query's own result flows
16
+ // through unchanged.
17
+
18
+ import { enforceAdmissibility, getRulesetToolScope } from '#admissibility';
19
+
20
+ /**
21
+ * @typedef {import('../admissibility/enforce.js').AdmissibilityVerdict} AdmissibilityVerdict
22
+ * @typedef {import('../admissibility/enforce.js').AdmissibilityOverride} AdmissibilityOverride
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} QueryResult
27
+ * @property {'ok' | 'refused-admissibility'} status
28
+ * @property {AdmissibilityVerdict} [admissibility] - always present so callers can log.
29
+ * @property {*} [payload] - the underlying query result on status 'ok'.
30
+ * @property {string} [refusal] - human-readable summary on 'refused-admissibility'.
31
+ */
32
+
33
+ /**
34
+ * Wrap a query producer with the refuse-first posture. The producer is
35
+ * only called when admissibility passes (or passes-with-overrides);
36
+ * on refusal, its produce function does NOT run and the wrapper
37
+ * returns a refusal envelope naming the unresolved rules.
38
+ *
39
+ * @param {object} args
40
+ * @param {object} args.tree
41
+ * @param {string|null} [args.chainRulesetVersion]
42
+ * @param {AdmissibilityOverride[]} [args.overrides]
43
+ * @param {() => (Promise<*> | *)} args.produce - the underlying query
44
+ * @param {object} [args.opts] - passed through to enforceAdmissibility
45
+ * @returns {Promise<QueryResult>}
46
+ */
47
+ export async function runWithAdmissibilityGate({
48
+ tree,
49
+ chainRulesetVersion = null,
50
+ overrides = [],
51
+ produce,
52
+ opts = {},
53
+ } = {}) {
54
+ const toolScope = await getRulesetToolScope();
55
+ if (!toolScope.traceabilityAndQueryTools) {
56
+ // Ruleset opted out of tool-scope gating (currently the artefact
57
+ // ships with this on -- item 1 addendum -- but the switch is
58
+ // read at runtime so a future ruleset revision can amend it).
59
+ const payload = await Promise.resolve(produce());
60
+ return { status: 'ok', payload };
61
+ }
62
+ const verdict = await enforceAdmissibility({ tree, chainRulesetVersion, overrides, opts });
63
+ if (verdict.verdict === 'refuse') {
64
+ const ruleIds = [...new Set(verdict.unresolved.map((f) => f.rule).filter(Boolean))].sort();
65
+ return {
66
+ status: 'refused-admissibility',
67
+ admissibility: verdict,
68
+ refusal: `traceability/query tool refused (NV-BL-SR-03 addendum): unresolved admissibility rules [${ruleIds.join(', ')}]. Fix or record a NV-BL-ADM-05 override before re-querying.`,
69
+ };
70
+ }
71
+ const payload = await Promise.resolve(produce());
72
+ return { status: 'ok', admissibility: verdict, payload };
73
+ }
@@ -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
 
@@ -0,0 +1,140 @@
1
+ // Shared standards ruleset loader (NV-BL-SR-01, NV-BL-SR-02, NV-BL-SR-03).
2
+ //
3
+ // The ruleset is a single machine-readable artefact bundled inside the
4
+ // rcf-lite umbrella package (JSON, camelCase per estate convention). Both
5
+ // build-lite (as a gate) and rcf-define-lite (as an elicitation checklist,
6
+ // from the umbrella release that adds the define payload) consume the
7
+ // identical ruleset from the identical umbrella version.
8
+ //
9
+ // Per NV-BL-SR-02 (ratified 2026-08-11, ruling-sheet items 2 and 6): the
10
+ // ruleset carries no separate semver. Its version IS the rcf-lite umbrella
11
+ // package version. This loader stamps `rulesetVersion` at read time from
12
+ // the umbrella's package.json so a redeploy of the same JSON on a bumped
13
+ // umbrella version reports the new version without a data edit.
14
+ //
15
+ // Ruleset scope covers chain admissibility AND the estate's traceability
16
+ // and query tooling (ratified 2026-08-11, ruling-sheet item 1 addendum).
17
+ // See toolScope on the artefact.
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const rulesetPath = join(here, 'ruleset.json');
25
+ const packageJsonPath = resolve(here, '..', '..', 'package.json');
26
+
27
+ /**
28
+ * @typedef {object} RulesetRule
29
+ * @property {string} id
30
+ * @property {string} title
31
+ * @property {boolean} [refuseByDefault]
32
+ * @property {string} [overrideChannel]
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} Ruleset
37
+ * @property {string} id
38
+ * @property {string} rulesetVersion - the rcf-lite umbrella package version at load time.
39
+ * @property {RulesetRule[]} admissibilityRules
40
+ * @property {RulesetRule[]} gateRules
41
+ * @property {object} scopeTagVocabulary
42
+ * @property {Array<{ marker: string, caseInsensitive: boolean }>} sourceCommentMarkers
43
+ * @property {Array<{ id: string, title: string, surface: string }>} tcTemplateFamily
44
+ * @property {Array<{ id: string, title: string, kind: string }>} rulingConsistencyChecks
45
+ * @property {{ chainAdmissibility: boolean, traceabilityAndQueryTools: boolean }} toolScope
46
+ */
47
+
48
+ let cachedRuleset = null;
49
+ let cachedUmbrellaVersion = null;
50
+
51
+ async function readJson(path) {
52
+ const raw = await readFile(path, 'utf8');
53
+ return JSON.parse(raw);
54
+ }
55
+
56
+ /**
57
+ * The rcf-lite umbrella package version at load time. Used by
58
+ * `getRuleset()` to stamp `rulesetVersion` per NV-BL-SR-02; also exported
59
+ * so consumers can read the umbrella version without opening
60
+ * `package.json` themselves.
61
+ *
62
+ * @returns {Promise<string>}
63
+ */
64
+ export async function getUmbrellaVersion() {
65
+ if (cachedUmbrellaVersion) return cachedUmbrellaVersion;
66
+ const pkg = await readJson(packageJsonPath);
67
+ if (typeof pkg?.version !== 'string' || pkg.version.length === 0) {
68
+ throw new Error('rcf-lite umbrella package.json is missing a version string');
69
+ }
70
+ cachedUmbrellaVersion = pkg.version;
71
+ return cachedUmbrellaVersion;
72
+ }
73
+
74
+ /**
75
+ * Load the shared standards ruleset artefact and stamp its `rulesetVersion`
76
+ * from the umbrella package.json. The artefact itself carries no version
77
+ * field (NV-BL-SR-02); read-time stamping is the single source of truth.
78
+ *
79
+ * @param {object} [opts]
80
+ * @param {boolean} [opts.fresh] - bypass the module-scope cache and re-read
81
+ * @returns {Promise<Ruleset>}
82
+ */
83
+ export async function getRuleset({ fresh = false } = {}) {
84
+ if (!fresh && cachedRuleset) return cachedRuleset;
85
+ const [artefact, umbrellaVersion] = await Promise.all([
86
+ readJson(rulesetPath),
87
+ getUmbrellaVersion(),
88
+ ]);
89
+ // Defensive: strip any accidental rulesetVersion baked into the JSON so
90
+ // the umbrella version is authoritative. NV-BL-SR-02 is emphatic about
91
+ // this: a divergence here is a spec drift, not a data field.
92
+ delete artefact.rulesetVersion;
93
+ cachedRuleset = Object.freeze({ ...artefact, rulesetVersion: umbrellaVersion });
94
+ return cachedRuleset;
95
+ }
96
+
97
+ /**
98
+ * Detect whether the ruleset version on a chain differs from the shipping
99
+ * ruleset version, and classify the drift for NV-BL-ADM-06 (build-stage
100
+ * refusal) and DL-REQ-VALIDATE-03 (define-stage warning).
101
+ *
102
+ * Additive-only drift (only new rule ids appear on the shipping side) is
103
+ * classified `additive` and warns rather than refuses. Any other version
104
+ * mismatch is classified `behavioural` and refuses at build stage.
105
+ *
106
+ * Same-version comparisons return `{ drift: 'none' }`.
107
+ *
108
+ * @param {object} args
109
+ * @param {string|null|undefined} args.chainRulesetVersion - version the chain declared it was authored against.
110
+ * @param {Ruleset} [args.ruleset] - shipping ruleset; defaults to the loaded artefact.
111
+ * @returns {Promise<{ drift: 'none' | 'additive' | 'behavioural' | 'missing', shippingVersion: string, chainVersion: string | null }>}
112
+ */
113
+ export async function detectRulesetDrift({ chainRulesetVersion, ruleset } = {}) {
114
+ const shipping = ruleset ?? (await getRuleset());
115
+ const shippingVersion = shipping.rulesetVersion;
116
+ const chainVersion = typeof chainRulesetVersion === 'string' && chainRulesetVersion.length > 0
117
+ ? chainRulesetVersion
118
+ : null;
119
+ if (chainVersion === null) {
120
+ return { drift: 'missing', shippingVersion, chainVersion };
121
+ }
122
+ if (chainVersion === shippingVersion) {
123
+ return { drift: 'none', shippingVersion, chainVersion };
124
+ }
125
+ // v1 policy: any version mismatch is treated as behavioural drift for
126
+ // the build stage refusal path (NV-BL-ADM-06). Additive-only drift
127
+ // becomes distinguishable once the umbrella starts landing patch bumps
128
+ // that only add rules; the classifier lives here so define-stage
129
+ // warning (DL-REQ-VALIDATE-03) can consume it without duplicating logic.
130
+ return { drift: 'behavioural', shippingVersion, chainVersion };
131
+ }
132
+
133
+ /**
134
+ * Reset the module-scope cache. For tests that mutate the on-disk artefact
135
+ * or the package version.
136
+ */
137
+ export function resetRulesetCache() {
138
+ cachedRuleset = null;
139
+ cachedUmbrellaVersion = null;
140
+ }