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.
- package/CHANGELOG.md +56 -0
- package/fixtures/canary-manifest.json +6 -6
- package/package.json +5 -3
- package/src/admissibility/enforce.js +142 -0
- package/src/admissibility/index.js +8 -0
- package/src/admissibility/markers.js +104 -0
- package/src/admissibility/scope-lint.js +163 -0
- package/src/cli/create.js +4 -1
- package/src/cli/read.js +7 -1
- package/src/cli/test-suite.js +7 -2
- package/src/core/store/loader.js +4 -1
- package/src/core/store/walker.js +49 -3
- package/src/core/store/writer.js +41 -11
- package/src/finalise/detect.js +51 -29
- package/src/finalise/index.js +16 -2
- package/src/finalise/ingest.js +41 -0
- package/src/mcp/tools.js +10 -2
- package/src/query/index.js +4 -0
- package/src/query/refuse-on-admissibility.js +73 -0
- package/src/ruleset/index.js +140 -0
- package/src/ruleset/ruleset.json +146 -0
- package/src/verify/chain/index.js +31 -0
- package/src/verify/verdict/index.js +67 -0
package/src/core/store/walker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
package/src/core/store/writer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
271
|
-
if (
|
|
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
|
-
|
|
908
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/finalise/detect.js
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
// rcf-verify install detection (spec §8.3, amendment 5 - install-together
|
|
2
|
-
// posture).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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 };
|
package/src/finalise/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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,
|
package/src/finalise/ingest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/query/index.js
CHANGED
|
@@ -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
|
+
}
|