rcf-lite 0.7.1 → 0.8.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.
@@ -20,6 +20,28 @@ import { 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, 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.
33
+ *
34
+ * @param {string} stem - filename stem, e.g. `fbs-004-user-login`.
35
+ * @returns {string} canonical id, e.g. `FBS-004-user-login`.
36
+ */
37
+ function idFromFilenameStem(stem) {
38
+ const dash = stem.indexOf('-');
39
+ if (dash === -1) return stem.toUpperCase();
40
+ const prefix = stem.slice(0, dash).toUpperCase();
41
+ const tail = stem.slice(dash);
42
+ return `${prefix}${tail}`;
43
+ }
44
+
23
45
  /**
24
46
  * @typedef {object} TreeModel
25
47
  * @property {object|null} manifest
@@ -182,7 +204,23 @@ async function loadChildKind(kind, { projectRoot, tree, errors }) {
182
204
  }
183
205
  for (const entry of listing.files) {
184
206
  const stem = entry.replace(/\.json$/, '');
185
- const id = stem.toUpperCase();
207
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 1 / d-2026-07-28-011
208
+ // recon): upper-case the PREFIX segment only. Full-stem toUpperCase
209
+ // was lossless while every id was `<PREFIX>-<digits>`; the moment a
210
+ // slug lands (`fbs-004-user-login.json` -> body `FBS-004-user-login`),
211
+ // the whole-stem fold produced `FBS-004-USER-LOGIN` in tree.byId /
212
+ // kindById / parentByChild while every inbound reference
213
+ // (dependsOnFbsIds, contextRequirements.adrIds, us.tacIds,
214
+ // cn.dependencies) used the lower-case form. The graph silently
215
+ // detached. Fixing this here, before any slug-consuming change lands,
216
+ // is the single load-bearing precondition of the whole slug design
217
+ // (see the item's regression test in test/store/walker.test.js:
218
+ // "walkTree preserves case on slug tails when deriving id from
219
+ // filename"). Slugs are lower-case kebab by construction (rcf-schemas
220
+ // 0.4.3 pattern `[a-z0-9]+(?:-[a-z0-9]+)*`), so leaving the tail
221
+ // verbatim is safe. `PRD-001.json` / `req-002.json` / mixed-case
222
+ // filenames continue to normalise their prefix.
223
+ const id = idFromFilenameStem(stem);
186
224
  // Filename-derived ids are not injective: on a case-sensitive
187
225
  // filesystem `REQ-001.json` and `req-001.json` are two files that
188
226
  // both resolve to `REQ-001`. Recording both silently collapsed the
@@ -848,10 +886,18 @@ function collectBrokenReferences(tree, errors) {
848
886
  }
849
887
  }
850
888
  // Inline TC id pattern check: `TC-<TS-suffix>-<slug>`.
851
- const tsSuffix = ts.id?.match(/^TS-(\d{3})$/)?.[1];
889
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3): widened from
890
+ // `\d{3}` (three-digit-exact) to `\d{3,}` in lockstep with
891
+ // rcf-schemas 0.4.3's TS/TC pattern widening. The pre-0.8.0 shape
892
+ // silently skipped every TS above 999: a TS-1000 failed the exact
893
+ // three-digit match, tsSuffix was undefined, and the whole
894
+ // idPrefixMatchesParent rule never fired for that TS's inline TCs.
895
+ // No error, no warning, just an unchecked doc -- exactly the
896
+ // silent-skip class this train is chartered to eliminate.
897
+ const tsSuffix = ts.id?.match(/^TS-(\d{3,})$/)?.[1];
852
898
  if (tsSuffix) {
853
899
  for (const tc of ts.testCases ?? []) {
854
- const m = String(tc.id ?? '').match(/^TC-(\d{3})-[a-z0-9-]+$/);
900
+ const m = String(tc.id ?? '').match(/^TC-(\d{3,})-[a-z0-9-]+$/);
855
901
  if (m && m[1] !== tsSuffix) {
856
902
  errors.push(rcfError({
857
903
  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
 
@@ -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
 
@@ -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
+ }
@@ -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
+ }