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 CHANGED
@@ -4,6 +4,62 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-1.0, breaking changes are signalled by a minor version bump.
6
6
 
7
+ ## [0.8.0] - 2026-08-12 (on `slug-train/0.8.0`, PR pending)
8
+
9
+ ### Added (Car 4, verify updates per requirements doc)
10
+
11
+ - **`src/verify/chain/index.js` (`readChain`) surfaces AC `scope` and per-TC `boundTcs`.** Every flattened AC now carries the schema-declared scope tag (or `undefined` for bootstrap-era ACs) plus a `boundTcs[]` list of `{ tsId, tcId, scope }` for every bound TC. Verify's downstream verdict layer runs the scope check off this shape, matching the shape the admissibility lint's `scanTcScopeVsAc` consumes at build stage.
12
+ - **`src/verify/verdict/index.js` (`scopePerAcVerdict`, extended `derivePerAcVerdicts`).** New `SCOPE-MISMATCH` per-AC verdict (NV-BL-GATE-01 + NV-BL-ADM-03). Fires when an AC declares a scope tag AND every bound TC's scope is narrower than the AC's scope AND at least one bound TC declares a scope tag (bootstrap-era TCs without a tag are silent -- the admissibility lint handles that class). At least one TC at or wider than the AC scope clears the mismatch (a wider TC is always admissible). Emitted alongside the four 0.7.0 per-AC verdict classes on `report.perAcVerdicts[]`. Existing report validator (`src/verify/report/index.js`) admits the new class via the widened `PER_AC_VERDICTS` enum.
13
+ - **`src/finalise/ingest.js` (`findScopeMismatchAcs`, `reportHasScopeMismatch`; extended `summariseReport`).** NV-BL-GATE-01 REVIEW-stage consumers (`rcf review <fbs-id>`, `rcf finalise`) read the same shape via new predicates. `summariseReport` renders a dedicated `scope mismatches (N)` section when the report carries any SCOPE-MISMATCH entries (silent when none, so pre-0.8.0 reports remain byte-identical in the summary output).
14
+ - **`src/finalise/index.js` re-exports** `findScopeMismatchAcs` and `reportHasScopeMismatch` alongside the pre-existing mock-only-declared predicates.
15
+ - **`test/verify/chain/scope-derivation-0-8-0.test.js` (2 tests):** chain reader hands through AC.scope + per-TC scope on boundTcs; leaves AC.scope undefined when the AC has no tag.
16
+ - **`test/verify/verdict/scope-mismatch-0-8-0.test.js` (7 tests):** narrower TC surfaces SCOPE-MISMATCH; wider or equal TC clears; deployed TC covering runtime AC is fine; AC without scope is silent; TC without scope is silent; AC with no bound TCs is silent; `derivePerAcVerdicts` emits SCOPE-MISMATCH alongside MOCK-ONLY-DECLARED on the same AC when both apply.
17
+ - **`test/finalise/scope-mismatch-0-8-0.test.js` (5 tests):** `findScopeMismatchAcs` extracts SCOPE-MISMATCH only; graceful with absent perAcVerdicts; `reportHasScopeMismatch` truthiness; `summariseReport` renders a dedicated section; silent when no mismatches present.
18
+ - **`test/verify/verdict/per-ac-verdicts-0-7-0.test.js` PER_AC_VERDICTS assertion updated** to include SCOPE-MISMATCH (0.8.0 slug-train car 4).
19
+
20
+ ### Version bump
21
+
22
+ - **`package.json` version 0.7.1 -> 0.8.0.** Consumes `@stravica-ai/rcf-schemas@0.4.3` (exact pin per the umbrella exact-pin doctrine ratified alongside item 18; the 0.4.3 diff is additive-only and preserved by the existing back-compat fixtures on the schemas side). The `rcf-lite@0.8.0` tag + publish is out of this train's scope and Dave will cut it post-merge.
23
+
24
+
25
+
26
+ The `0.8.0` admissibility-and-ruleset release, cut against the ratified next-version plan (`projects/rcf-build-lite/docs/2026-08-06_next-version-plan-amendment.md`) and requirements (`projects/rcf-build-lite/docs/2026-08-06_build-lite-nextver-requirements.md`). Ships the shared standards ruleset bundled inside this umbrella package, wires build-lite's fitness tracks around it (admissibility lint refuse-by-default, build-stage drift refusal, gate tightenings), and pulls verify's per-AC scope check into REVIEW. Car 1 of the slug-train, `@stravica-ai/rcf-schemas@0.4.3`, published to npm on 2026-08-12 and is EXACT-PINNED here (no caret, no range) per the umbrella exact-pin doctrine ratified alongside item 18. The 0.4.3 diff is additive-only: widened id patterns to accept an optional kebab-case slug tail on FBS / CN / ADR / TAC ids, widened TS/TC to drop the 999 cap, added the shared `scopeTag` vocabulary in `common.schema.json`, and added optional `scope` fields on AC and TC. Every existing chain continues to validate; no retro-slugging is performed.
27
+
28
+ ### Added (Car 3, admissibility lint + drift refusal + refuse-by-default in query)
29
+
30
+ - **`src/admissibility/` module (`#admissibility`).** Chain-admissibility gate wiring per NV-BL-ADM-02, -03, -04, -05, -06. Refuse-first per ratified ruling-sheet item 1.
31
+ - `scanAcScopeCoverage(tree, { tolerateUnclassified })` (NV-BL-ADM-02): every AC must carry a known scope tag from the shared vocabulary. `unclassified` is tolerated during the migration window (default; `tolerateUnclassified: false` fails those too after the window closes).
32
+ - `scanTcScopeVsAc(tree, opts)` (NV-BL-ADM-03): every bound TC's scope must be equal to or wider than the covered AC's scope. `library` < `runtime` < `deployed`; a wider TC on a narrower AC is admissible (opposite trips a finding).
33
+ - `scanSourceStringForMarkers(source, { filePath, markers })` and `scanFilesForMarkers(filePaths)` (NV-BL-ADM-04): scan source for the ratified marker vocabulary (`TODO`, `FIXME`, `XXX`, `HACK`, `placeholder`, `v1 refinement`, `deferred`, `stub`; case-insensitive). Line + column offsets on each finding. Vocabulary is read from the ruleset at call time so a future ruleset revision picks up without recompiling this module.
34
+ - `enforceAdmissibility({ tree, chainRulesetVersion, overrides, opts })` (NV-BL-ADM-05 + -06): the top-level refuse-first gate. Combines the scope scans with `detectRulesetDrift`; applies recorded overrides per rule id + optional documentId; emits `{ verdict: 'pass' | 'refuse' | 'passWithOverrides', findings, unresolved, appliedOverrides, drift }`. Guardrail: NV-BL-ADM-04 findings can NEVER be masked through the generic override channel (ruling-sheet item 16 narrowed override to ADR-only for source markers).
35
+ - `getRulesetToolScope()`: exposes the ruleset's `toolScope` block so consumers decide whether to apply the traceability-and-query-tools refusal per NV-BL-SR-03 addendum.
36
+ - **`src/query/refuse-on-admissibility.js` (`runWithAdmissibilityGate`).** NV-BL-SR-03 addendum wiring (ruling-sheet item 1): traceability and query tools share the refuse-first posture. Wraps a query producer such that a REFUSE verdict short-circuits the query and returns a refusal envelope naming the unresolved rule ids; PASS lets the producer run and returns its payload. Re-exported from `src/query/index.js`.
37
+ - **`#admissibility` package.json import specifier.** New subpath alias mapping to `./src/admissibility/index.js`.
38
+ - **`test/admissibility/scope-lint.test.js` (5 tests)** covering NV-BL-ADM-02 absent / unclassified / tolerance, NV-BL-ADM-03 narrower-fails / wider-passes.
39
+ - **`test/admissibility/markers.test.js` (4 tests)** covering the case-insensitive vocabulary scan, line/column offsets, empty input, and multi-file scan + ioFailure surfacing.
40
+ - **`test/admissibility/enforce.test.js` (7 tests)** covering NV-BL-ADM-06 missing-version + behavioural drift refusal, NV-BL-ADM-05 override application, the NV-BL-ADM-04 override guardrail, `getRulesetToolScope`, and `runWithAdmissibilityGate` short-circuit + pass paths.
41
+
42
+ ### Changed (Car 3, landmines + schemas pin)
43
+
44
+ - **`@stravica-ai/rcf-schemas` bumped to EXACT `0.4.3`** (no caret, no range) per the ratified umbrella exact-pin doctrine (item 18 alongside the 0.4.3 release). The 0.4.3 diff is additive-only: widened `fbsId` / `cnId` / `adrId` / `tacId` to accept an optional kebab-case slug tail, widened `tsId` / `tcId` from `\d{3}` to `\d{3,}` (drops the 999 cap), added `common.$defs.scopeTag` (`library | runtime | deployed | unclassified`), added OPTIONAL `scope` on the AC subschema (`user-story.schema.json`) and TC subschema (`test-suite.schema.json`). Every pre-0.4.3 chain continues to validate; no retro-slugging is performed (the design is preferred-with-numeric-fallback per w-2026-07-28-012). Pin review: the additive-only nature is asserted by the schemas repo's `manifest-004-pre-040-backcompat.json` fixture round-tripping cleanly under 0.4.3, and by the existing writer / walker tests continuing to pass with the bumped pin.
45
+
46
+ ### Fixed (Car 3, w-2026-07-28-012 landmines)
47
+
48
+ Four landmine fixes lifted from `w-2026-07-28-012` (design notes + d-2026-07-28-011 recon), each landing WITH its regression test. Every fix is a precondition for the slug-consuming changes that follow it in the same car; the walker prefix-only-uppercase fix lands first, per the ratified sequencing.
49
+
50
+ - **Landmine 1: `packages/rcf-lite/src/core/store/walker.js` (loadChildKind).** The walker used to derive tree ids by upper-casing the whole filename stem (`stem.toUpperCase()`). Lossless while every id was `<PREFIX>-<digits>`; the moment a slug lands (`fbs-004-user-login.json` -> body `fbsId: "FBS-004-user-login"`), the whole-stem fold produced `FBS-004-USER-LOGIN` in `tree.byId` / `kindById` / `parentByChild` while every inbound reference used the lower-case form. The graph silently detached. The fix (`idFromFilenameStem`) upper-cases the prefix segment only and leaves the slug tail verbatim. Regression: `test/store/walker.test.js` `walkTree preserves case on slug tails when deriving id from filename`; also asserts a valid inbound `dependsOnFbsIds` reference resolves to the byId entry.
51
+ - **Landmine 2: `packages/rcf-lite/src/core/store/writer.js` (nextFlatId).** The allocator's high-water mark used a slug-blind regex `^${prefix}-(\d+)$`; a slugged id was invisible to the max and the allocator reset to 001, re-issuing taken numbers. The fix replaces the local regex with the shared `idNumber(id, prefix)` helper (`ids.js:72-78`, pattern `^${prefix}-(\d+)(?:-|$)`) which parses both numeric-only and slugged shapes into the same numeric key. Regression: `test/core/store/writer.test.js` `nextIdForKind sees slugged ids in the occupancy set` and `nextIdForKind never re-issues a slugged FBS number even without a companion numeric-only FBS at the same slot`.
52
+ - **Landmine 3: hardcoded three-digit id patterns across seven walker/writer sites AND three consumer-path stragglers.** The moment a TS crosses 999 (rcf-schemas 0.4.3 admits `TS-1000`), the walker's `idPrefixMatchesParent` rule silently skipped every inline TC under that TS, and the writer's `nextIdForKind tc` refused with "unrecognised TS id" even though the schema admitted the parent. Widened all sites to `\d{3,}` in lockstep with the schema. Walker + writer sites (7 total): `packages/rcf-lite/src/core/store/walker.js:851/854` and `writer.js:252/908/1163` (four regex sites), plus the corresponding comments and the resolveInlineId TC branch on writer.js widened for consistency. Consumer-path stragglers (3 sites, folded in as a rework commit after the initial car 3.1 landed): `packages/rcf-lite/src/cli/test-suite.js:85` (hard-refused any TS >= 1000 in `rcf test-suite <id> {provenance|approve}` with an error text citing "TS-015"; widened + error text updated to cite both "TS-015" and "TS-1000"), `packages/rcf-lite/src/cli/read.js:122` (`rcf read TC-1000-x` silently fell through to null even when the TC existed under a widened TS; widened the inline-TC guard), and `packages/rcf-lite/src/mcp/tools.js:765` (same silent-null pattern via the MCP read tool). Regressions: `test/core/store/writer.test.js` `nextIdForKind tc admits a TS beyond 999` + `walker inline-TC prefix-match rule fires for TS beyond 999`; `test/cli/test-suite-cli.test.js` `rcf test-suite TS-1000 provenance accepts a four-digit TS id` + `rcf test-suite TS-1000 approve accepts a four-digit TS id` + `rcf test-suite refuses a non-TS positional and cites both three-digit and four-digit shapes`; `test/cli/read.test.js` `rcf read TC-1000-first-case resolves the inline TC on a widened (four-digit) TS`; `test/mcp/tools.test.js` `rcf_read: resolves an inline TC on a widened (four-digit) TS`.
53
+ - **Landmine 4: `packages/rcf-lite/src/core/store/writer.js` (`deriveSlug`).** Returned the literal `'tc'` on empty derivation. Every non-TC caller (FBS, CN, ADR, TAC create paths) would silently produce an id ending in `-tc` when the source description derived to empty -- wrong kind label baked in as a slug. The fix returns `''` at the source and moves the `|| 'tc'` fallback to the TC-specific callers (`writer.js` `createInlineTc`, `mcp/tools.js`, `cli/create.js`). Regression: `deriveSlug lowercases and squashes non-alphanumeric runs` updated (`!!!` now maps to `''`, not `'tc'`) and a new `0.8.0 slug-train (landmine 4): deriveSlug returns empty string, not the "tc" literal` test.
54
+
55
+ ### Added (Car 2, shared standards ruleset)
56
+
57
+ - **`src/ruleset/ruleset.json` (NV-BL-SR-01).** Single machine-readable artefact bundled inside the umbrella package, camelCase per estate convention. Names the admissibility rule identifiers (`NV-BL-ADM-01`..`NV-BL-ADM-06`), the gate rule identifiers (`NV-BL-GATE-01`..`NV-BL-GATE-04`), the scope-tag vocabulary reference (points at `@stravica-ai/rcf-schemas` `common.schema.json#/$defs/scopeTag` rather than owning the enum, ratified 2026-08-11 item 11), the source-comment marker vocabulary (`TODO`, `FIXME`, `XXX`, `HACK`, `placeholder`, `v1 refinement`, `deferred`, `stub` — all case-insensitive per NV-BL-ADM-04), the canonical TC template family identifiers (`TCT-SERVER-BOOT`, `TCT-CLI-INVOKE`, `TCT-CONTAINER-RUN` — NV-BL-GATE-03), and the light-mechanical ruling-consistency check family (`RCC-EXTERNAL-RESOURCE-CONTRADICTION`, `RCC-TIER-CAPABILITY-MISMATCH` — NV-BL-GATE-04; probabilistic/suitability checks live in rcf-define-lite under DL-REQ-VALIDATE-04). `toolScope` covers chain admissibility AND traceability/query tools per the ratified addendum on item 1.
58
+ - **`src/ruleset/index.js` (`#ruleset`).** Loader for the artefact. `getRuleset()` returns the frozen artefact with `rulesetVersion` stamped at read time from the umbrella `package.json` (NV-BL-SR-02: the ruleset has no separate semver; its version IS the umbrella version). `detectRulesetDrift({ chainRulesetVersion })` classifies chain-vs-shipping version mismatch as `none | additive | behavioural | missing`, feeding `NV-BL-ADM-06` (build-stage refusal on behavioural drift) and `DL-REQ-VALIDATE-03` (define-stage warning). `getUmbrellaVersion()` exposes the same version so callers do not re-parse `package.json`. `resetRulesetCache()` for tests.
59
+ - **`#ruleset` package.json import specifier.** New subpath alias mapping to `./src/ruleset/index.js` so consumers reach the loader without hard-coding a relative path.
60
+ - **`docs/ruleset.md`.** Ruleset reference doc — artefact location, version policy (NV-BL-SR-02), content at v1 (NV-BL-SR-03), consumers, migration state.
61
+ - **`test/ruleset/ruleset.test.js`.** 14 tests covering NV-BL-SR-01 shape, NV-BL-SR-02 version stamping (umbrella-authoritative; baked-in `rulesetVersion` on the JSON refused), NV-BL-SR-03 content (every ADM/GATE id present, every ADM rule `refuseByDefault: true`, scope-tag vocabulary points at schemas, marker list matches NV-BL-ADM-04 vocabulary, TC template family matches NV-BL-GATE-03 surfaces, ruling-consistency checks are light-mechanical), and `detectRulesetDrift` classes.
62
+
7
63
  ## [0.7.0] - 2026-07-31
8
64
 
9
65
  The cold-run response train. Four tracks land together in one release, each closing a systemic gap the 0.5.2 watchpost cold run exposed. Ships alongside `@stravica-ai/rcf-lite-core@0.3.0` (shared pattern sets, baseline catalog, canary fixtures) and `@stravica-ai/rcf-verify-lite@0.2.0` (per-AC verdict classes, chain-reader derivation); `@stravica-ai/rcf-schemas@0.4.2` already carries the full 0.7.0 surface additively so every new chain field is optional at the schema layer. Track A (verification integrity) ships the primitives that keep a mocked integration test from ever again looking identical to a live one: the chain gains an honest record of what the tests actually verified against. Track B (UI design gate + quality baseline) ships the Design substage (Stage 1.5), the ruled UI baseline, and the browser-verification gate for UI-bearing FBSes. Tracks C and D (elicitation upstream + playbook hardening) ship the REQ-shape classifier and baseline-AC catalog with silence-refuses-build enforcement (Track C), the variable-fidelity intake stage (Track C), and the release-time register canary, never-skip-RCF invariant, review-surface persistence and persona tail-interview template (Track D). Every field is optional at schema; the ENFORCEMENT (elicitation, `coverage --strict`, the review-stage audit, the finalise gate, `--mark complete` refusals, the Stage-1 sweep gate) is what makes them de-facto required for new chains from 0.7.0 onward.
@@ -2,8 +2,8 @@
2
2
  "registerCanary": [
3
3
  {
4
4
  "id": "rc-2026-08-12-001",
5
- "createdAt": "2026-08-12T10:45:12.561Z",
6
- "buildVersion": "0.7.1-mockdriver",
5
+ "createdAt": "2026-08-12T16:39:41.040Z",
6
+ "buildVersion": "0.8.0-mockdriver",
7
7
  "fixturePromptId": "canary-prompt-01",
8
8
  "responseWordCount": 55,
9
9
  "grades": {
@@ -35,8 +35,8 @@
35
35
  },
36
36
  {
37
37
  "id": "rc-2026-08-12-002",
38
- "createdAt": "2026-08-12T10:45:12.562Z",
39
- "buildVersion": "0.7.1-mockdriver",
38
+ "createdAt": "2026-08-12T16:39:41.041Z",
39
+ "buildVersion": "0.8.0-mockdriver",
40
40
  "fixturePromptId": "canary-prompt-02",
41
41
  "responseWordCount": 55,
42
42
  "grades": {
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "id": "rc-2026-08-12-003",
71
- "createdAt": "2026-08-12T10:45:12.562Z",
72
- "buildVersion": "0.7.1-mockdriver",
71
+ "createdAt": "2026-08-12T16:39:41.041Z",
72
+ "buildVersion": "0.8.0-mockdriver",
73
73
  "fixturePromptId": "canary-prompt-03",
74
74
  "responseWordCount": 55,
75
75
  "grades": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rcf-lite",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "One-install tooling for the Requirements Confidence Framework (RCF): the unified `rcf` CLI (init, view, validate, build, verify, finalise and 25+ more verbs), an MCP server, the live tree viewer and the fresh-context adversarial ship-gate verifier. Consumes @stravica-ai/rcf-schemas.",
6
6
  "license": "Apache-2.0",
@@ -53,10 +53,12 @@
53
53
  "#core/patterns/services": "./src/core/patterns/services.js",
54
54
  "#core/patterns/register-canary": "./src/core/patterns/register-canary.js",
55
55
  "#core/baseline-catalog": "./src/core/baseline-catalog/index.js",
56
- "#core/fixtures/register-canary/*": "./src/core/fixtures/register-canary/*"
56
+ "#core/fixtures/register-canary/*": "./src/core/fixtures/register-canary/*",
57
+ "#ruleset": "./src/ruleset/index.js",
58
+ "#admissibility": "./src/admissibility/index.js"
57
59
  },
58
60
  "dependencies": {
59
- "@stravica-ai/rcf-schemas": "0.4.2",
61
+ "@stravica-ai/rcf-schemas": "0.4.3",
60
62
  "ajv": "^8.20.0",
61
63
  "ajv-formats": "^3.0.1"
62
64
  },
@@ -0,0 +1,142 @@
1
+ // Chain-admissibility gate (NV-BL-ADM-05, NV-BL-ADM-06).
2
+ //
3
+ // Refuse-first per ratified ruling-sheet item 1 (2026-08-11): a chain
4
+ // that fails any admissibility check is refused before build starts.
5
+ // Explicit overrides are permitted for non-source-marker checks,
6
+ // recorded in the chain with rule id, reason, and authoring identity,
7
+ // and surfaced in the finalise summary. Source-comment markers
8
+ // (NV-BL-ADM-04) fall outside this generic override channel and are
9
+ // governed by the narrower ADR-only mechanism per ruling-sheet
10
+ // item 16 -- callers apply that check separately.
11
+ //
12
+ // NV-BL-ADM-06 (build-stage refusal on ruleset-version drift):
13
+ // composes with the scope-tag scans below so a build-time entry point
14
+ // gets one call that returns a single verdict.
15
+
16
+ import { detectRulesetDrift, getRuleset } from '#ruleset';
17
+
18
+ import { scanAcScopeCoverage, scanTcScopeVsAc } from './scope-lint.js';
19
+
20
+ /**
21
+ * @typedef {object} AdmissibilityOverride
22
+ * @property {string} rule - rule id being overridden (e.g. "NV-BL-ADM-02").
23
+ * @property {string} reason - operator-provided reason recorded on the chain.
24
+ * @property {string} [authoredBy] - author identity string.
25
+ * @property {string} [documentId] - optional pin to a specific doc id.
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} AdmissibilityVerdict
30
+ * @property {'pass' | 'refuse' | 'passWithOverrides'} verdict
31
+ * @property {import('#core/errors').RcfError[]} findings - all findings before override application.
32
+ * @property {import('#core/errors').RcfError[]} unresolved - findings not covered by a supplied override.
33
+ * @property {AdmissibilityOverride[]} appliedOverrides
34
+ * @property {object} drift - shape from detectRulesetDrift.
35
+ */
36
+
37
+ /**
38
+ * Should this finding be masked by the given override? Match on rule id
39
+ * and, when the override pins a documentId, on documentId too.
40
+ */
41
+ function overrideCovers(finding, override) {
42
+ if (finding.rule !== override.rule) return false;
43
+ if (override.documentId && finding.documentId !== override.documentId) return false;
44
+ return true;
45
+ }
46
+
47
+ /**
48
+ * Enforce admissibility across the whole chain (NV-BL-ADM-05 gate) plus
49
+ * ruleset-version drift (NV-BL-ADM-06). Callers pass the walker tree,
50
+ * the chain's declared ruleset version (per DL-REQ-VALIDATE-03), and an
51
+ * optional list of recorded overrides.
52
+ *
53
+ * A pure function on top of the ruleset + tree. Callers decide what to
54
+ * do with a `refuse` verdict (`rcf build` refuses with exit 4; a query
55
+ * or traceability tool refuses to surface the chain per the item 1
56
+ * addendum -- see `#query/refuse-on-admissibility`).
57
+ *
58
+ * @param {object} args
59
+ * @param {object} args.tree - walker output
60
+ * @param {string|null} [args.chainRulesetVersion]
61
+ * @param {AdmissibilityOverride[]} [args.overrides]
62
+ * @param {object} [args.opts]
63
+ * @param {boolean} [args.opts.tolerateUnclassified] - default true.
64
+ * @returns {Promise<AdmissibilityVerdict>}
65
+ */
66
+ export async function enforceAdmissibility({
67
+ tree,
68
+ chainRulesetVersion = null,
69
+ overrides = [],
70
+ opts = {},
71
+ } = {}) {
72
+ const ruleset = await getRuleset();
73
+ const drift = await detectRulesetDrift({ chainRulesetVersion, ruleset });
74
+
75
+ const findings = [];
76
+
77
+ // NV-BL-ADM-06: build-stage refusal on behavioural drift. Missing
78
+ // chain-ruleset-version is a separate class -- the chain never
79
+ // declared a version, so we cannot classify drift; the finding
80
+ // asks the operator to run the define-stage warning path
81
+ // (DL-REQ-VALIDATE-03) or acknowledge the omission via override.
82
+ if (drift.drift === 'behavioural') {
83
+ findings.push({
84
+ kind: 'validation',
85
+ message: `NV-BL-ADM-06: chain was authored against ruleset version ${drift.chainVersion}; shipping version is ${drift.shippingVersion}. Behaviour-changing drift refuses at build stage.`,
86
+ rule: 'NV-BL-ADM-06',
87
+ });
88
+ } else if (drift.drift === 'missing') {
89
+ findings.push({
90
+ kind: 'validation',
91
+ message: `NV-BL-ADM-06: chain does not declare a ruleset version. Run the define-stage adequacy check against ruleset ${drift.shippingVersion} (DL-REQ-VALIDATE-03) or record an override.`,
92
+ rule: 'NV-BL-ADM-06',
93
+ });
94
+ }
95
+
96
+ // NV-BL-ADM-02 / -03: scope-tag coverage + TC-scope-vs-AC-scope.
97
+ findings.push(...scanAcScopeCoverage(tree, opts));
98
+ findings.push(...scanTcScopeVsAc(tree, opts));
99
+
100
+ // NV-BL-ADM-05: refuse-first, override-recorded. Overrides are
101
+ // recorded on the chain; we apply them here to produce the
102
+ // unresolved-findings set. Source-comment markers (NV-BL-ADM-04) do
103
+ // NOT flow through this override channel; callers running that scan
104
+ // filter its findings only through the ADR-only channel per
105
+ // ruling-sheet item 16.
106
+ const appliedOverrides = [];
107
+ const unresolved = [];
108
+ for (const finding of findings) {
109
+ if (finding.rule === 'NV-BL-ADM-04') {
110
+ // Guardrail: source-marker findings must never be masked through
111
+ // the generic override channel. If a caller mistakenly threaded
112
+ // them into this function, leave them unresolved.
113
+ unresolved.push(finding);
114
+ continue;
115
+ }
116
+ const hit = overrides.find((o) => overrideCovers(finding, o));
117
+ if (hit) {
118
+ appliedOverrides.push(hit);
119
+ continue;
120
+ }
121
+ unresolved.push(finding);
122
+ }
123
+
124
+ let verdict;
125
+ if (unresolved.length === 0 && appliedOverrides.length === 0) verdict = 'pass';
126
+ else if (unresolved.length === 0) verdict = 'passWithOverrides';
127
+ else verdict = 'refuse';
128
+
129
+ return { verdict, findings, unresolved, appliedOverrides, drift };
130
+ }
131
+
132
+ /**
133
+ * Convenience: the ruleset's toolScope block. Query / traceability tools
134
+ * read this to decide whether to apply the refuse-first posture when
135
+ * surfacing chain data (NV-BL-SR-03 addendum on ruling-sheet item 1).
136
+ *
137
+ * @returns {Promise<{ chainAdmissibility: boolean, traceabilityAndQueryTools: boolean }>}
138
+ */
139
+ export async function getRulesetToolScope() {
140
+ const ruleset = await getRuleset();
141
+ return ruleset.toolScope;
142
+ }
@@ -0,0 +1,8 @@
1
+ // Public surface for the chain-admissibility gate (0.8.0 slug-train,
2
+ // car 3). Rules consumed from the shared standards ruleset bundled
3
+ // inside this umbrella package (`#ruleset`) per the ratified
4
+ // requirements doc (2026-08-06 canonical since 2026-08-12).
5
+
6
+ export { enforceAdmissibility, getRulesetToolScope } from './enforce.js';
7
+ export { scanAcScopeCoverage, scanTcScopeVsAc } from './scope-lint.js';
8
+ export { scanFilesForMarkers, scanSourceStringForMarkers } from './markers.js';
@@ -0,0 +1,104 @@
1
+ // Source-comment marker scanner (NV-BL-ADM-04).
2
+ //
3
+ // Scans a set of source files for admission markers from the ratified
4
+ // vocabulary carried on the shared standards ruleset
5
+ // (`sourceCommentMarkers[]`). Any match is a finding. Per ratified
6
+ // ruling-sheet item 16, the only permitted override channel is an ADR
7
+ // recording the deferral for a genuine external-boundary blocker; the
8
+ // generic recorded-override channel (NV-BL-ADM-05) does not cover
9
+ // source markers.
10
+ //
11
+ // This module produces findings only. Wiring the gate into
12
+ // `rcf build --mark complete` is a build-lite verb change that reads
13
+ // the FBS's build-sequence files and calls `scanSourceForMarkers` for
14
+ // each; the CLI change belongs in that verb's PR, not here.
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+
18
+ import { rcfError } from '#core/errors';
19
+ import { getRuleset } from '#ruleset';
20
+
21
+ /**
22
+ * Compile the ruleset's marker vocabulary into a single case-insensitive
23
+ * pattern. Markers are ratified as case-insensitive per NV-BL-ADM-04, so
24
+ * we build the pattern with the `i` flag. Longest markers first so
25
+ * "v1 refinement" wins over "v1" if a shorter marker is ever added to
26
+ * the vocabulary in future releases.
27
+ *
28
+ * @param {Array<{ marker: string, caseInsensitive: boolean }>} markers
29
+ * @returns {RegExp}
30
+ */
31
+ function compileMarkerPattern(markers) {
32
+ const sorted = [...markers].sort((a, b) => b.marker.length - a.marker.length);
33
+ const alternation = sorted
34
+ .map((m) => m.marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
35
+ .join('|');
36
+ // Boundary-free by design: matches a marker anywhere in a comment
37
+ // ("PLACEHOLDER function" matches; "// placeholder" matches; a random
38
+ // word like "todos" also matches, which is the trade-off named in the
39
+ // ruleset's NV-BL-ADM-04 notes -- false positives on unrelated tokens
40
+ // are cheaper than false negatives on real deferrals.
41
+ return new RegExp(`(${alternation})`, 'ig');
42
+ }
43
+
44
+ /**
45
+ * Scan a single source string for the ratified marker vocabulary.
46
+ * Returns one finding per match, with line + column offsets. Empty
47
+ * input returns an empty findings array.
48
+ *
49
+ * @param {string} source
50
+ * @param {object} [opts]
51
+ * @param {string} [opts.filePath] - decorates each finding for callers
52
+ * @param {Array<{ marker: string, caseInsensitive: boolean }>} [opts.markers]
53
+ * @returns {Promise<import('#core/errors').RcfError[]>}
54
+ */
55
+ export async function scanSourceStringForMarkers(source, { filePath, markers } = {}) {
56
+ if (typeof source !== 'string' || source.length === 0) return [];
57
+ const vocab = markers ?? (await getRuleset()).sourceCommentMarkers;
58
+ const pattern = compileMarkerPattern(vocab);
59
+ const findings = [];
60
+ let match;
61
+ while ((match = pattern.exec(source)) !== null) {
62
+ const before = source.slice(0, match.index);
63
+ const line = before.split('\n').length;
64
+ const lastNewline = before.lastIndexOf('\n');
65
+ const column = match.index - (lastNewline === -1 ? -1 : lastNewline);
66
+ findings.push(rcfError({
67
+ kind: 'validation',
68
+ message: `NV-BL-ADM-04: source-comment admission marker "${match[1]}" at ${filePath ?? '<source>'}:${line}:${column}`,
69
+ filePath: filePath ?? null,
70
+ rule: 'NV-BL-ADM-04',
71
+ }));
72
+ }
73
+ return findings;
74
+ }
75
+
76
+ /**
77
+ * Scan a set of files by path for the ratified marker vocabulary. IO
78
+ * failures on a single file are captured as `ioFailure` findings; other
79
+ * files continue to scan. Order is deterministic in the input file list.
80
+ *
81
+ * @param {string[]} filePaths - absolute paths to scan
82
+ * @returns {Promise<import('#core/errors').RcfError[]>}
83
+ */
84
+ export async function scanFilesForMarkers(filePaths) {
85
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return [];
86
+ const ruleset = await getRuleset();
87
+ const vocab = ruleset.sourceCommentMarkers;
88
+ const findings = [];
89
+ for (const filePath of filePaths) {
90
+ try {
91
+ const source = await readFile(filePath, 'utf8');
92
+ const perFile = await scanSourceStringForMarkers(source, { filePath, markers: vocab });
93
+ findings.push(...perFile);
94
+ } catch (err) {
95
+ findings.push(rcfError({
96
+ kind: 'ioFailure',
97
+ message: `NV-BL-ADM-04: failed to read source for marker scan: ${err.message}`,
98
+ filePath,
99
+ rule: 'NV-BL-ADM-04',
100
+ }));
101
+ }
102
+ }
103
+ return findings;
104
+ }
@@ -0,0 +1,163 @@
1
+ // Chain-admissibility scope checks (NV-BL-ADM-02, NV-BL-ADM-03).
2
+ //
3
+ // The scope-tag vocabulary itself lives on rcf-schemas 0.4.3
4
+ // (`common.$defs.scopeTag`). This module consumes the tags off ACs
5
+ // and TCs and enforces the admissibility rules the shared standards
6
+ // ruleset references (`NV-BL-ADM-02` for AC scope classification and
7
+ // `NV-BL-ADM-03` for TC scope >= AC scope).
8
+ //
9
+ // Refuse-first, override-recorded per NV-BL-ADM-05; source-comment
10
+ // markers are governed separately by NV-BL-ADM-04 and its ADR-only
11
+ // override channel.
12
+
13
+ import { rcfError } from '#core/errors';
14
+
15
+ /**
16
+ * Rank each scope so we can compare "TC scope >= AC scope" numerically.
17
+ * `unclassified` is the migration state; ranked -1 so a TC-scoped
18
+ * unclassified against a runtime-scope AC surfaces as a mismatch.
19
+ * @type {Record<string, number>}
20
+ */
21
+ const SCOPE_RANK = Object.freeze({
22
+ library: 1,
23
+ runtime: 2,
24
+ deployed: 3,
25
+ unclassified: -1,
26
+ });
27
+
28
+ /**
29
+ * True when the tag is a known scope value per the shared vocabulary.
30
+ * @param {unknown} tag
31
+ * @returns {boolean}
32
+ */
33
+ function isKnownScope(tag) {
34
+ return typeof tag === 'string' && Object.prototype.hasOwnProperty.call(SCOPE_RANK, tag);
35
+ }
36
+
37
+ /**
38
+ * NV-BL-ADM-02: every AC must carry a known scope tag. An AC without a
39
+ * scope, or with a non-vocabulary value, produces an admissibility
40
+ * finding. Per the ruleset's `unclassifiedMigrationTolerance` block,
41
+ * an AC scoped `unclassified` is currently tolerated (findings not
42
+ * emitted); callers who want to enforce full migration pass
43
+ * `tolerateUnclassified: false`.
44
+ *
45
+ * @param {object} tree - walkTree output
46
+ * @param {object} [opts]
47
+ * @param {boolean} [opts.tolerateUnclassified] - default true (migration state)
48
+ * @returns {import('#core/errors').RcfError[]}
49
+ */
50
+ export function scanAcScopeCoverage(tree, { tolerateUnclassified = true } = {}) {
51
+ const findings = [];
52
+ for (const us of tree.userStories ?? []) {
53
+ for (const ac of us.acceptanceCriteria ?? []) {
54
+ const scope = ac?.scope;
55
+ if (scope === undefined) {
56
+ findings.push(rcfError({
57
+ kind: 'validation',
58
+ message: `NV-BL-ADM-02: AC ${ac.id} on US ${us.usId} carries no scope tag`,
59
+ documentId: ac.id,
60
+ field: 'scope',
61
+ rule: 'NV-BL-ADM-02',
62
+ }));
63
+ continue;
64
+ }
65
+ if (!isKnownScope(scope)) {
66
+ findings.push(rcfError({
67
+ kind: 'validation',
68
+ message: `NV-BL-ADM-02: AC ${ac.id} on US ${us.usId} carries an unknown scope tag "${scope}"`,
69
+ documentId: ac.id,
70
+ field: 'scope',
71
+ rule: 'NV-BL-ADM-02',
72
+ }));
73
+ continue;
74
+ }
75
+ if (scope === 'unclassified' && !tolerateUnclassified) {
76
+ findings.push(rcfError({
77
+ kind: 'validation',
78
+ message: `NV-BL-ADM-02: AC ${ac.id} on US ${us.usId} still scoped "unclassified" after the migration window`,
79
+ documentId: ac.id,
80
+ field: 'scope',
81
+ rule: 'NV-BL-ADM-02',
82
+ }));
83
+ }
84
+ }
85
+ }
86
+ return findings;
87
+ }
88
+
89
+ /**
90
+ * NV-BL-ADM-03: for each AC, every bound TC's scope must be equal to
91
+ * or wider than the AC's scope. A library-scope TC bound to a
92
+ * runtime-scope AC surfaces as a mismatch. TCs with no scope tag are
93
+ * flagged the same way ACs are in NV-BL-ADM-02: absent = finding
94
+ * (bootstrap: unclassified tolerated).
95
+ *
96
+ * @param {object} tree - walkTree output
97
+ * @param {object} [opts]
98
+ * @param {boolean} [opts.tolerateUnclassified] - default true
99
+ * @returns {import('#core/errors').RcfError[]}
100
+ */
101
+ export function scanTcScopeVsAc(tree, { tolerateUnclassified = true } = {}) {
102
+ const findings = [];
103
+ const acScope = new Map();
104
+ for (const us of tree.userStories ?? []) {
105
+ for (const ac of us.acceptanceCriteria ?? []) {
106
+ if (ac?.id) acScope.set(ac.id, ac.scope);
107
+ }
108
+ }
109
+ for (const ts of tree.testSuites ?? []) {
110
+ for (const tc of ts.testCases ?? []) {
111
+ if (!tc?.id || !tc?.acId) continue;
112
+ const tcScope = tc?.scope;
113
+ const acTag = acScope.get(tc.acId);
114
+ if (acTag === undefined || !isKnownScope(acTag) || acTag === 'unclassified') {
115
+ // The AC's own scope problem surfaces via scanAcScopeCoverage;
116
+ // this scan is silent for ACs the classifier could not read.
117
+ continue;
118
+ }
119
+ if (tcScope === undefined) {
120
+ findings.push(rcfError({
121
+ kind: 'validation',
122
+ message: `NV-BL-ADM-03: TC ${tc.id} on TS ${ts.id} carries no scope tag (bound AC ${tc.acId} is scope=${acTag})`,
123
+ documentId: tc.id,
124
+ field: 'scope',
125
+ rule: 'NV-BL-ADM-03',
126
+ }));
127
+ continue;
128
+ }
129
+ if (!isKnownScope(tcScope)) {
130
+ findings.push(rcfError({
131
+ kind: 'validation',
132
+ message: `NV-BL-ADM-03: TC ${tc.id} on TS ${ts.id} carries an unknown scope tag "${tcScope}"`,
133
+ documentId: tc.id,
134
+ field: 'scope',
135
+ rule: 'NV-BL-ADM-03',
136
+ }));
137
+ continue;
138
+ }
139
+ if (tcScope === 'unclassified') {
140
+ if (!tolerateUnclassified) {
141
+ findings.push(rcfError({
142
+ kind: 'validation',
143
+ message: `NV-BL-ADM-03: TC ${tc.id} on TS ${ts.id} still scoped "unclassified" after the migration window`,
144
+ documentId: tc.id,
145
+ field: 'scope',
146
+ rule: 'NV-BL-ADM-03',
147
+ }));
148
+ }
149
+ continue;
150
+ }
151
+ if (SCOPE_RANK[tcScope] < SCOPE_RANK[acTag]) {
152
+ findings.push(rcfError({
153
+ kind: 'validation',
154
+ message: `NV-BL-ADM-03: TC ${tc.id} scope "${tcScope}" is narrower than the AC ${tc.acId} scope "${acTag}"; a bound TC must be equal to or wider than the AC scope`,
155
+ documentId: tc.id,
156
+ field: 'scope',
157
+ rule: 'NV-BL-ADM-03',
158
+ }));
159
+ }
160
+ }
161
+ }
162
+ return findings;
163
+ }
package/src/cli/create.js CHANGED
@@ -261,7 +261,10 @@ export async function main(argv, deps = {}) {
261
261
  return 2;
262
262
  }
263
263
  body.acId = flags.ac;
264
- options.slug = flags.slug ?? deriveSlug(body.description);
264
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 4): deriveSlug returns ''
265
+ // on empty derivation; TC keeps its historical 'tc' fallback locally
266
+ // rather than letting deriveSlug bake it in.
267
+ options.slug = flags.slug ?? (deriveSlug(body.description) || 'tc');
265
268
  options.testPointer = flags['test-pointer'];
266
269
  }
267
270
 
package/src/cli/read.js CHANGED
@@ -119,7 +119,13 @@ function resolveTarget(tree, id) {
119
119
  const entry = (us.acceptanceCriteria ?? []).find((ac) => ac.id === id);
120
120
  return entry ? { doc: entry, containerId: parentId, invalid: false } : null;
121
121
  }
122
- if (/^TC-\d{3}-[a-z0-9-]+$/.test(id)) {
122
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3, consumer-path
123
+ // straggler): widened `\d{3}` -> `\d{3,}` in lockstep with rcf-schemas
124
+ // 0.4.3's TC pattern. Under the previous shape `rcf read TC-1000-x`
125
+ // fell through to the `return null` below (silent skip) even when the
126
+ // TC existed under a widened TS -- exactly the class the landmine
127
+ // charter names.
128
+ if (/^TC-\d{3,}-[a-z0-9-]+$/.test(id)) {
123
129
  const parentId = tree.parentByChild.get(id);
124
130
  if (!parentId) return null;
125
131
  const ts = tree.byId.get(parentId);
@@ -82,8 +82,13 @@ export async function main(argv, deps = {}) {
82
82
  stderr.write('[error] usage test-suite: verb required (provenance | approve)\n');
83
83
  return 2;
84
84
  }
85
- if (!/^TS-\d{3}$/.test(tsId)) {
86
- stderr.write(`[error] usage test-suite: expected a TS id like TS-015, got '${tsId}'\n`);
85
+ // 0.8.0 slug-train (w-2026-07-28-012 landmine 3, consumer-path
86
+ // straggler): widened `\d{3}` -> `\d{3,}` in lockstep with rcf-schemas
87
+ // 0.4.3's TS pattern. The previous shape hard-refused any TS >= 1000 --
88
+ // silent from the operator's perspective (the tsId matched the schema
89
+ // but the CLI verb refused with a usage error citing "TS-015").
90
+ if (!/^TS-\d{3,}$/.test(tsId)) {
91
+ stderr.write(`[error] usage test-suite: expected a TS id like TS-015 or TS-1000, got '${tsId}'\n`);
87
92
  return 2;
88
93
  }
89
94
 
@@ -154,7 +154,10 @@ export async function loadDocument({ projectRoot, id }) {
154
154
  * discovery mechanism for tree topology (topology comes from parent-id
155
155
  * fields); this is just the load-time enumeration required to bring every
156
156
  * on-disk file into memory. Callers derive the document id from the
157
- * filename stem in upper case (per the layout convention).
157
+ * filename stem by upper-casing the PREFIX segment only (0.8.0
158
+ * slug-train, w-2026-07-28-012 landmine 1); slug tails stay verbatim
159
+ * because rcf-schemas 0.4.3 admits lower-case kebab tails on FBS / CN /
160
+ * ADR / TAC and a full-stem fold would silently detach the graph.
158
161
  *
159
162
  * Returns `{ files: string[] }` on success. Missing subdir returns
160
163
  * `{ files: [] }` (an empty children collection is a valid tree state).