rcf-lite 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/bin/rcf.js +6 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/harness-template.md +11 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +11 -0
- package/package.json +5 -3
- package/rcf/adrs/adr-010.json +30 -0
- package/rcf/code-nodes/cn-058.json +18 -0
- package/rcf/code-nodes/cn-059.json +14 -0
- package/rcf/code-nodes/cn-060.json +14 -0
- package/rcf/code-nodes/cn-061.json +14 -0
- package/rcf/code-nodes/cn-062.json +14 -0
- package/rcf/code-nodes/cn-063.json +15 -0
- package/rcf/code-nodes/cn-064.json +15 -0
- package/rcf/code-nodes/cn-065.json +16 -0
- package/rcf/code-nodes/cn-066.json +14 -0
- package/rcf/code-nodes/cn-067.json +15 -0
- package/rcf/code-nodes/cn-068.json +15 -0
- package/rcf/code-nodes/cn-069.json +16 -0
- package/rcf/fbs/fbs-016.json +39 -0
- package/rcf/fbs/fbs-017.json +40 -0
- package/rcf/fbs/fbs-018.json +34 -0
- package/rcf/fbs/fbs-019.json +33 -0
- package/rcf/requirements/req-010.json +20 -0
- package/rcf/test-suites/ts-026.json +54 -0
- package/rcf/test-suites/ts-027.json +115 -0
- package/rcf/test-suites/ts-028.json +46 -0
- package/rcf/test-suites/ts-029.json +46 -0
- package/rcf/user-stories/us-1001.json +56 -0
- package/rcf/user-stories/us-1002.json +96 -0
- package/rcf/user-stories/us-1003.json +48 -0
- package/rcf/user-stories/us-1004.json +48 -0
- 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/blueprint/apply.js +464 -0
- package/src/blueprint/conflicts.js +351 -0
- package/src/blueprint/diff.js +82 -0
- package/src/blueprint/index.js +12 -0
- package/src/blueprint/list.js +21 -0
- package/src/blueprint/loader.js +163 -0
- package/src/blueprint/manifest-writer.js +49 -0
- package/src/blueprint/namespace.js +145 -0
- package/src/blueprint/remove.js +105 -0
- package/src/blueprint/resolutions.js +83 -0
- package/src/blueprint/standards.js +148 -0
- package/src/blueprint/supersede.js +318 -0
- package/src/browser-verify/invariants.js +33 -6
- package/src/build/bundle.js +34 -11
- package/src/build/standards-selector.js +52 -0
- package/src/cli/blueprint.js +325 -0
- package/src/cli/create.js +49 -1
- package/src/cli/help.js +8 -0
- package/src/cli/init.js +20 -5
- package/src/cli/read.js +7 -1
- package/src/cli/standards.js +127 -0
- package/src/cli/test-suite.js +7 -2
- package/src/core/store/ids.js +168 -18
- package/src/core/store/loader.js +31 -17
- package/src/core/store/walker.js +62 -4
- package/src/core/store/writer.js +41 -11
- package/src/deployment/index.js +13 -0
- package/src/deployment/placeholder-detector.js +113 -0
- 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/formatters/table.js +7 -10
- package/src/query/index.js +4 -0
- package/src/query/refuse-on-admissibility.js +73 -0
- package/src/query/trace.js +45 -4
- 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/ids.js
CHANGED
|
@@ -1,25 +1,175 @@
|
|
|
1
|
-
// Id normalisation.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Id normalisation + family grammar. This module owns THE grammar for
|
|
2
|
+
// RCF document ids: schema-family membership, filename-stem -> canonical-id
|
|
3
|
+
// inversion, and family -> tree location. The walker (walker.js), the
|
|
4
|
+
// document loader (loader.js) and the blueprint namespace helpers
|
|
5
|
+
// (src/blueprint/namespace.js) all consume the primitives defined here so
|
|
6
|
+
// the regexes live in exactly one place.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// allocator. Both sides MUST agree: detection that normalises while
|
|
11
|
-
// allocation does not just moves the collision one step later.
|
|
8
|
+
// Two schema families, per @stravica-ai/rcf-schemas 0.4.4 (see
|
|
9
|
+
// docs/id-conventions.md):
|
|
12
10
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// - Prefix families (REQ, US, PRD, BS, TAD, TS) admit an optional
|
|
12
|
+
// lowercase kebab-slug PREFIX joined by `-` to the family prefix.
|
|
13
|
+
// `REQ-001` under blueprint `spa` becomes `spa-REQ-001`.
|
|
14
|
+
// Schema pattern (common.reqId etc.):
|
|
15
|
+
// ^([a-z][a-z0-9]*(?:-[a-z0-9]+)*-)?<PREFIX>-\d{3,}$
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// - Suffix families (ADR, TAC, FBS, CN) admit an optional lowercase
|
|
18
|
+
// kebab-slug SUFFIX joined by `-` to the numeric tail. `ADR-005`
|
|
19
|
+
// under blueprint `spa` becomes `ADR-005-spa`.
|
|
20
|
+
// Schema pattern (common.adrId etc.):
|
|
21
|
+
// ^<PREFIX>-\d{3,}(-[a-z0-9]+(?:-[a-z0-9]+)*)?$
|
|
20
22
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
+
// - Unnamespaced families (AC, TC) live inline under their parent
|
|
24
|
+
// documents and never appear as top-level files under rcf/.
|
|
25
|
+
//
|
|
26
|
+
// The RCF id patterns admit a variable-width numeric run
|
|
27
|
+
// (`^REQ-\d{3,}$`, `^AC-\d{3,}(-\d+)?$`, ...), so `REQ-001` and
|
|
28
|
+
// `REQ-0001` are BOTH legal and BOTH name requirement number 1.
|
|
29
|
+
// `normaliseId` / `sameId` collapse those spellings for the walker's
|
|
30
|
+
// `globallyUniqueIds` rule and the writer's id allocator; that identity
|
|
31
|
+
// pass runs independently of the family grammar above.
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Family membership (the single source for downstream re-exports)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export const PREFIX_FAMILIES = Object.freeze(['REQ', 'US', 'PRD', 'BS', 'TAD', 'TS']);
|
|
38
|
+
export const SUFFIX_FAMILIES = Object.freeze(['ADR', 'TAC', 'FBS', 'CN']);
|
|
39
|
+
export const UNNAMESPACED_FAMILIES = Object.freeze(['AC', 'TC']);
|
|
40
|
+
|
|
41
|
+
export const PREFIX_FAMILY_SET = new Set(PREFIX_FAMILIES);
|
|
42
|
+
export const SUFFIX_FAMILY_SET = new Set(SUFFIX_FAMILIES);
|
|
43
|
+
export const UNNAMESPACED_FAMILY_SET = new Set(UNNAMESPACED_FAMILIES);
|
|
44
|
+
|
|
45
|
+
export const SLUG_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
46
|
+
|
|
47
|
+
// Precompiled per-family regexes. Prefix families are anchored so the
|
|
48
|
+
// optional slug prefix is captured cleanly; suffix families capture the
|
|
49
|
+
// optional slug suffix. Suffix families are consulted first because they
|
|
50
|
+
// never carry a leading slug -- see parseIdParts.
|
|
51
|
+
const PREFIX_REGEXES = new Map();
|
|
52
|
+
for (const family of PREFIX_FAMILIES) {
|
|
53
|
+
PREFIX_REGEXES.set(family, new RegExp(`^(?:([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-)?${family}-(\\d{3,})$`));
|
|
54
|
+
}
|
|
55
|
+
const SUFFIX_REGEXES = new Map();
|
|
56
|
+
for (const family of SUFFIX_FAMILIES) {
|
|
57
|
+
SUFFIX_REGEXES.set(family, new RegExp(`^${family}-(\\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$`));
|
|
58
|
+
}
|
|
59
|
+
const UNNAMESPACED_REGEXES = new Map([
|
|
60
|
+
['AC', /^AC-(\d{3,})(?:-(\d+))?$/],
|
|
61
|
+
['TC', /^TC-(\d{3,})-([a-z0-9-]+)$/],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Lowercase counterparts for stem-to-canonical inversion. Filenames on
|
|
65
|
+
// disk are lower-case kebab (blueprint apply -> destPathFor -> toLowerCase),
|
|
66
|
+
// and the same lowercase pattern is what the CLI init writer produces.
|
|
67
|
+
const PREFIX_STEM_REGEXES = new Map();
|
|
68
|
+
for (const family of PREFIX_FAMILIES) {
|
|
69
|
+
PREFIX_STEM_REGEXES.set(family, new RegExp(`^(?:([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-)?${family.toLowerCase()}-(\\d{3,})$`));
|
|
70
|
+
}
|
|
71
|
+
const SUFFIX_STEM_REGEXES = new Map();
|
|
72
|
+
for (const family of SUFFIX_FAMILIES) {
|
|
73
|
+
SUFFIX_STEM_REGEXES.set(family, new RegExp(`^${family.toLowerCase()}-(\\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Family -> tree location. Root families (PRD/TAD/BS) live at rcf/ root
|
|
77
|
+
// as single files; child families live one per JSON file under a subdir;
|
|
78
|
+
// AC/TC live inline under their parents and never resolve to a top-level
|
|
79
|
+
// file, so their entry is absent.
|
|
80
|
+
const FAMILY_TO_LOCATION = new Map([
|
|
81
|
+
['REQ', { kind: 'req', subdir: 'requirements', rootFile: null }],
|
|
82
|
+
['US', { kind: 'userStory', subdir: 'user-stories', rootFile: null }],
|
|
83
|
+
['TAC', { kind: 'tac', subdir: 'tacs', rootFile: null }],
|
|
84
|
+
['ADR', { kind: 'adr', subdir: 'adrs', rootFile: null }],
|
|
85
|
+
['FBS', { kind: 'fbs', subdir: 'fbs', rootFile: null }],
|
|
86
|
+
['TS', { kind: 'testSuite', subdir: 'test-suites', rootFile: null }],
|
|
87
|
+
['CN', { kind: 'codeNode', subdir: 'code-nodes', rootFile: null }],
|
|
88
|
+
['PRD', { kind: 'prd', subdir: null, rootFile: 'prd.json' }],
|
|
89
|
+
['TAD', { kind: 'tad', subdir: null, rootFile: 'tad.json' }],
|
|
90
|
+
['BS', { kind: 'buildSequence', subdir: null, rootFile: 'build-sequence.json' }],
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Family grammar
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Split an id into { family, prefixSlug, digits, suffixSlug }. Returns
|
|
99
|
+
* null when the id matches no known family pattern. The public blueprint
|
|
100
|
+
* surface (`src/blueprint/namespace.js`) re-exports this so callers keep
|
|
101
|
+
* a single import path even though the grammar lives here.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} id
|
|
104
|
+
* @returns {{ family: string, prefixSlug: string|null, digits: string, suffixSlug: string|null } | null}
|
|
105
|
+
*/
|
|
106
|
+
export function parseIdParts(id) {
|
|
107
|
+
if (typeof id !== 'string' || id.length === 0) return null;
|
|
108
|
+
for (const [family, regex] of SUFFIX_REGEXES) {
|
|
109
|
+
const match = id.match(regex);
|
|
110
|
+
if (match) return { family, prefixSlug: null, digits: match[1], suffixSlug: match[2] ?? null };
|
|
111
|
+
}
|
|
112
|
+
for (const [family, regex] of PREFIX_REGEXES) {
|
|
113
|
+
const match = id.match(regex);
|
|
114
|
+
if (match) return { family, prefixSlug: match[1] ?? null, digits: match[2], suffixSlug: null };
|
|
115
|
+
}
|
|
116
|
+
for (const [family, regex] of UNNAMESPACED_REGEXES) {
|
|
117
|
+
const match = id.match(regex);
|
|
118
|
+
if (match) return { family, prefixSlug: null, digits: match[1], suffixSlug: null };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Location of a family in the rcf/ tree, or null when the family is not
|
|
125
|
+
* addressable as a top-level file (AC / TC).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} family
|
|
128
|
+
* @returns {{ kind: string, subdir: string|null, rootFile: string|null } | null}
|
|
129
|
+
*/
|
|
130
|
+
export function familyLocation(family) {
|
|
131
|
+
return FAMILY_TO_LOCATION.get(family) ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Canonicalise a lowercase filename stem to its id form: upper-case ONLY
|
|
136
|
+
* the family segment, leaving any slug prefix / suffix verbatim (they are
|
|
137
|
+
* lower-case kebab by schema construction, matching the on-disk stem).
|
|
138
|
+
*
|
|
139
|
+
* Understands both id families:
|
|
140
|
+
* spa-req-001 -> spa-REQ-001 (prefix family, slug 'spa')
|
|
141
|
+
* req-001 -> REQ-001 (prefix family, no slug)
|
|
142
|
+
* adr-005-spa -> ADR-005-spa (suffix family, slug 'spa')
|
|
143
|
+
* adr-005 -> ADR-005 (suffix family, no slug)
|
|
144
|
+
* fbs-004-user-login -> FBS-004-user-login
|
|
145
|
+
*
|
|
146
|
+
* Returns null when the stem matches no known family pattern; the walker
|
|
147
|
+
* folds defensively in that case so an unrecognised stem surfaces via the
|
|
148
|
+
* downstream load error, not a crash.
|
|
149
|
+
*
|
|
150
|
+
* @param {unknown} stem
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
export function canonicaliseStem(stem) {
|
|
154
|
+
if (typeof stem !== 'string' || stem.length === 0) return null;
|
|
155
|
+
// Suffix families first (mirrors parseIdParts: they never carry a
|
|
156
|
+
// leading slug, so their prefix segment is unambiguous).
|
|
157
|
+
for (const [family, regex] of SUFFIX_STEM_REGEXES) {
|
|
158
|
+
const match = stem.match(regex);
|
|
159
|
+
if (match) return match[2] ? `${family}-${match[1]}-${match[2]}` : `${family}-${match[1]}`;
|
|
160
|
+
}
|
|
161
|
+
for (const [family, regex] of PREFIX_STEM_REGEXES) {
|
|
162
|
+
const match = stem.match(regex);
|
|
163
|
+
if (match) return match[1] ? `${match[1]}-${family}-${match[2]}` : `${family}-${match[2]}`;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Numeric identity (leading-zero-tolerant). Independent of the family
|
|
170
|
+
// grammar above -- REQ-001 and REQ-0001 are one id whether or not the
|
|
171
|
+
// blueprint machinery is in play.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
23
173
|
|
|
24
174
|
/**
|
|
25
175
|
* Strip leading zeros from a run of digits without going through Number
|
package/src/core/store/loader.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { rcfError } from '../errors/index.js';
|
|
12
|
+
import { familyLocation, parseIdParts } from './ids.js';
|
|
12
13
|
import { validateDocument } from './validator.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -41,25 +42,35 @@ const ROOT_FILENAMES = {
|
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
|
-
* Resolve an id like "REQ-002"
|
|
45
|
+
* Resolve an id like "REQ-002" (or a blueprint-namespaced id like
|
|
46
|
+
* "spa-REQ-001" / "ADR-005-spa") to a path under rcf/.
|
|
45
47
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
+
* The id grammar lives in `./ids.js` (`parseIdParts` / `familyLocation`);
|
|
49
|
+
* this function is the read-side application of it. Prefix-family ids
|
|
50
|
+
* (REQ / US / PRD / BS / TAD / TS) may carry a leading slug namespace;
|
|
51
|
+
* suffix-family ids (ADR / TAC / FBS / CN) may carry a trailing slug
|
|
52
|
+
* namespace. Either way the filename on disk is the lower-cased id
|
|
53
|
+
* (blueprint apply's `destPathFor` writes it that way; the CLI writer
|
|
54
|
+
* matches).
|
|
55
|
+
*
|
|
56
|
+
* Returns null when the id matches no known family pattern OR when the
|
|
57
|
+
* family has no top-level file (AC and TC live inline under their
|
|
58
|
+
* parent US / TS).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} id - canonical id, e.g. "REQ-002", "spa-REQ-001", "ADR-005-spa"
|
|
61
|
+
* @returns {{ kind: string, relPath: string } | null}
|
|
48
62
|
*/
|
|
49
63
|
export function pathForId(id) {
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
if (id === 'TAD-001' || id.startsWith('TAD-')) return { kind: 'tad', relPath: 'tad.json' };
|
|
61
|
-
if (id === 'BS-001' || id.startsWith('BS-')) return { kind: 'buildSequence', relPath: 'build-sequence.json' };
|
|
62
|
-
return null;
|
|
64
|
+
const parts = parseIdParts(id);
|
|
65
|
+
if (!parts) return null;
|
|
66
|
+
const loc = familyLocation(parts.family);
|
|
67
|
+
if (!loc) return null;
|
|
68
|
+
// Root families (PRD / TAD / BS) resolve to their single root file.
|
|
69
|
+
if (loc.rootFile) return { kind: loc.kind, relPath: loc.rootFile };
|
|
70
|
+
// Child families resolve to <subdir>/<lower(id)>.json. AC / TC have no
|
|
71
|
+
// subdir entry and fall through to null via `!loc` above.
|
|
72
|
+
if (!loc.subdir) return null;
|
|
73
|
+
return { kind: loc.kind, relPath: `${loc.subdir}/${id.toLowerCase()}.json` };
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
/**
|
|
@@ -154,7 +165,10 @@ export async function loadDocument({ projectRoot, id }) {
|
|
|
154
165
|
* discovery mechanism for tree topology (topology comes from parent-id
|
|
155
166
|
* fields); this is just the load-time enumeration required to bring every
|
|
156
167
|
* on-disk file into memory. Callers derive the document id from the
|
|
157
|
-
* filename stem
|
|
168
|
+
* filename stem by upper-casing the PREFIX segment only (0.8.0
|
|
169
|
+
* slug-train, w-2026-07-28-012 landmine 1); slug tails stay verbatim
|
|
170
|
+
* because rcf-schemas 0.4.3 admits lower-case kebab tails on FBS / CN /
|
|
171
|
+
* ADR / TAC and a full-stem fold would silently detach the graph.
|
|
158
172
|
*
|
|
159
173
|
* Returns `{ files: string[] }` on success. Missing subdir returns
|
|
160
174
|
* `{ files: [] }` (an empty children collection is a valid tree state).
|
package/src/core/store/walker.js
CHANGED
|
@@ -16,10 +16,44 @@
|
|
|
16
16
|
// `dependentsByFbsId`, `tsByAcId`, `tcsByAcId`, `usByTacId`.
|
|
17
17
|
|
|
18
18
|
import { rcfError } from '../errors/index.js';
|
|
19
|
-
import { normaliseId } from './ids.js';
|
|
19
|
+
import { canonicaliseStem, normaliseId } from './ids.js';
|
|
20
20
|
import { listSubdirJsonFiles, loadDocument, loadRootDocument, pathForId, subdirFor } from './loader.js';
|
|
21
21
|
import { validateDocument } from './validator.js';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Derive an id from a filename stem. Delegates to the core grammar in
|
|
25
|
+
* `ids.js` (`canonicaliseStem`), which understands BOTH id families
|
|
26
|
+
* (rcf-schemas 0.4.4):
|
|
27
|
+
*
|
|
28
|
+
* - Prefix families (REQ / US / PRD / BS / TAD / TS) admit an optional
|
|
29
|
+
* kebab-slug PREFIX -- `spa-req-001` -> `spa-REQ-001`.
|
|
30
|
+
* - Suffix families (ADR / TAC / FBS / CN) admit an optional kebab-slug
|
|
31
|
+
* SUFFIX -- `fbs-004-user-login` -> `FBS-004-user-login`,
|
|
32
|
+
* `adr-005-spa` -> `ADR-005-spa`.
|
|
33
|
+
*
|
|
34
|
+
* Before this delegation the walker upper-cased only the FIRST dash
|
|
35
|
+
* segment, which was correct for suffix families but yielded
|
|
36
|
+
* `SPA-req-001` for a prefix-family stem `spa-req-001`. That id then
|
|
37
|
+
* failed `pathForId` at load time and every consuming verb refused with
|
|
38
|
+
* "Unrecognised document id" (w-2026-08-19-003).
|
|
39
|
+
*
|
|
40
|
+
* Defensive fallback: for stems that match no family pattern (a
|
|
41
|
+
* genuinely garbage filename), keep the old first-dash upper-case fold
|
|
42
|
+
* so the walker still records SOMETHING addressable and the subsequent
|
|
43
|
+
* load / validation step surfaces the real error. A crash here would
|
|
44
|
+
* hide the file's true problem.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} stem - filename stem, e.g. `spa-req-001` or `fbs-004-user-login`.
|
|
47
|
+
* @returns {string} canonical id.
|
|
48
|
+
*/
|
|
49
|
+
function idFromFilenameStem(stem) {
|
|
50
|
+
const canonical = canonicaliseStem(stem);
|
|
51
|
+
if (canonical) return canonical;
|
|
52
|
+
const dash = stem.indexOf('-');
|
|
53
|
+
if (dash === -1) return stem.toUpperCase();
|
|
54
|
+
return `${stem.slice(0, dash).toUpperCase()}${stem.slice(dash)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
/**
|
|
24
58
|
* @typedef {object} TreeModel
|
|
25
59
|
* @property {object|null} manifest
|
|
@@ -182,7 +216,23 @@ async function loadChildKind(kind, { projectRoot, tree, errors }) {
|
|
|
182
216
|
}
|
|
183
217
|
for (const entry of listing.files) {
|
|
184
218
|
const stem = entry.replace(/\.json$/, '');
|
|
185
|
-
|
|
219
|
+
// 0.8.0 slug-train (w-2026-07-28-012 landmine 1 / d-2026-07-28-011
|
|
220
|
+
// recon): upper-case the PREFIX segment only. Full-stem toUpperCase
|
|
221
|
+
// was lossless while every id was `<PREFIX>-<digits>`; the moment a
|
|
222
|
+
// slug lands (`fbs-004-user-login.json` -> body `FBS-004-user-login`),
|
|
223
|
+
// the whole-stem fold produced `FBS-004-USER-LOGIN` in tree.byId /
|
|
224
|
+
// kindById / parentByChild while every inbound reference
|
|
225
|
+
// (dependsOnFbsIds, contextRequirements.adrIds, us.tacIds,
|
|
226
|
+
// cn.dependencies) used the lower-case form. The graph silently
|
|
227
|
+
// detached. Fixing this here, before any slug-consuming change lands,
|
|
228
|
+
// is the single load-bearing precondition of the whole slug design
|
|
229
|
+
// (see the item's regression test in test/store/walker.test.js:
|
|
230
|
+
// "walkTree preserves case on slug tails when deriving id from
|
|
231
|
+
// filename"). Slugs are lower-case kebab by construction (rcf-schemas
|
|
232
|
+
// 0.4.3 pattern `[a-z0-9]+(?:-[a-z0-9]+)*`), so leaving the tail
|
|
233
|
+
// verbatim is safe. `PRD-001.json` / `req-002.json` / mixed-case
|
|
234
|
+
// filenames continue to normalise their prefix.
|
|
235
|
+
const id = idFromFilenameStem(stem);
|
|
186
236
|
// Filename-derived ids are not injective: on a case-sensitive
|
|
187
237
|
// filesystem `REQ-001.json` and `req-001.json` are two files that
|
|
188
238
|
// both resolve to `REQ-001`. Recording both silently collapsed the
|
|
@@ -848,10 +898,18 @@ function collectBrokenReferences(tree, errors) {
|
|
|
848
898
|
}
|
|
849
899
|
}
|
|
850
900
|
// Inline TC id pattern check: `TC-<TS-suffix>-<slug>`.
|
|
851
|
-
|
|
901
|
+
// 0.8.0 slug-train (w-2026-07-28-012 landmine 3): widened from
|
|
902
|
+
// `\d{3}` (three-digit-exact) to `\d{3,}` in lockstep with
|
|
903
|
+
// rcf-schemas 0.4.3's TS/TC pattern widening. The pre-0.8.0 shape
|
|
904
|
+
// silently skipped every TS above 999: a TS-1000 failed the exact
|
|
905
|
+
// three-digit match, tsSuffix was undefined, and the whole
|
|
906
|
+
// idPrefixMatchesParent rule never fired for that TS's inline TCs.
|
|
907
|
+
// No error, no warning, just an unchecked doc -- exactly the
|
|
908
|
+
// silent-skip class this train is chartered to eliminate.
|
|
909
|
+
const tsSuffix = ts.id?.match(/^TS-(\d{3,})$/)?.[1];
|
|
852
910
|
if (tsSuffix) {
|
|
853
911
|
for (const tc of ts.testCases ?? []) {
|
|
854
|
-
const m = String(tc.id ?? '').match(/^TC-(\d{3})-[a-z0-9-]+$/);
|
|
912
|
+
const m = String(tc.id ?? '').match(/^TC-(\d{3,})-[a-z0-9-]+$/);
|
|
855
913
|
if (m && m[1] !== tsSuffix) {
|
|
856
914
|
errors.push(rcfError({
|
|
857
915
|
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
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Deployment-gate barrel. Shared primitives downstream project probes
|
|
2
|
+
// (TAC-210 external-service-dependency provisioning; TAC-211 core-flow
|
|
3
|
+
// end-to-end) import so the placeholder-detection ruleset lives in ONE
|
|
4
|
+
// place and extends via a rcf-lite minor bump, not a per-project fork.
|
|
5
|
+
//
|
|
6
|
+
// Watchpost first-production defect (w-2026-08-24-005, class cure
|
|
7
|
+
// w-2026-08-24-006) is the reason this module exists: the app shipped
|
|
8
|
+
// with RESEND_API_KEY set to a placeholder, the only login path was
|
|
9
|
+
// inert, the gap was filed as a "quirk" note, and sign-off still said
|
|
10
|
+
// DEPLOYED. The SPA blueprint v1.3.0 contributions bind those class
|
|
11
|
+
// rules; this module is the shared enforcement handle.
|
|
12
|
+
|
|
13
|
+
export { detectPlaceholderCredentialShape, PLACEHOLDER_DETECTOR_VERSION } from './placeholder-detector.js';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Canonical placeholder-credential-shape detector for the deployment-gate
|
|
2
|
+
// class cure (spa-US-1132 / TAC-210, w-2026-08-24-006).
|
|
3
|
+
//
|
|
4
|
+
// The watchpost first production review (w-2026-08-24-005) shipped with
|
|
5
|
+
// RESEND_API_KEY set to a placeholder value; the only login path (magic-link
|
|
6
|
+
// email) was inert; sign-off still said DEPLOYED. The gap was filed as a
|
|
7
|
+
// "quirk" note in status.md rather than blocked at gate time. This module
|
|
8
|
+
// owns the one canonical placeholder shape catalogue rcf-lite ships so
|
|
9
|
+
// downstream projects (via TAC-210 realisations) apply the same detector
|
|
10
|
+
// - extending the ruleset is a rcf-lite minor bump, never a per-project
|
|
11
|
+
// reinvention.
|
|
12
|
+
//
|
|
13
|
+
// The catalogue is deliberately narrow: only credential-shape strings a
|
|
14
|
+
// developer would reasonably type as a stand-in ("YOUR_API_KEY_HERE",
|
|
15
|
+
// "changeme", "xxx", empty). It does NOT try to classify all bad values;
|
|
16
|
+
// entropy checks, provider-specific prefix rules, and format regexes live
|
|
17
|
+
// in project-authored probes. This catalogue is what would have refused
|
|
18
|
+
// the watchpost RESEND_API_KEY at gate time.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} PlaceholderMatch
|
|
22
|
+
* @property {'empty'|'single-dash'|'null-token'|'you-here'|'named-stand-in'|'repeat-char'} pattern
|
|
23
|
+
* The named pattern that matched. Callers surface this on their report
|
|
24
|
+
* so a refusal message can name why.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} PlaceholderVerdict
|
|
29
|
+
* @property {boolean} isPlaceholder true when the value matches a placeholder shape
|
|
30
|
+
* @property {PlaceholderMatch['pattern']} [pattern] the matched pattern name, when isPlaceholder is true
|
|
31
|
+
* @property {string} [reason] a short refusal-message-ready reason string
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// Case-insensitive named stand-ins a developer would reasonably type. The
|
|
35
|
+
// set is intentionally small; adding to it is a rcf-lite minor bump, and
|
|
36
|
+
// the additions land in ONE place downstream projects inherit.
|
|
37
|
+
const NAMED_STAND_INS = new Set([
|
|
38
|
+
'changeme',
|
|
39
|
+
'placeholder',
|
|
40
|
+
'example',
|
|
41
|
+
'test',
|
|
42
|
+
'dummy',
|
|
43
|
+
'fake',
|
|
44
|
+
'todo',
|
|
45
|
+
'tbd',
|
|
46
|
+
'sample',
|
|
47
|
+
'default',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// `YOUR_X_HERE`, `your-key-here`, etc. Case-insensitive.
|
|
51
|
+
const YOU_HERE_RE = /^your[_-].+[_-]here$/i;
|
|
52
|
+
|
|
53
|
+
// Repeated single characters ("xxxx", "----", "0000") of length >= 3, or
|
|
54
|
+
// the literal "xxx+" family common in scaffolds.
|
|
55
|
+
const REPEAT_CHAR_RE = /^(.)\1{2,}$/;
|
|
56
|
+
|
|
57
|
+
// Literal null-token strings that leak in when a resolver hands back a
|
|
58
|
+
// nullish value stringified. `.env` files stringify unset values to the
|
|
59
|
+
// empty string, but a JS resolver that String()s an undefined lands
|
|
60
|
+
// "undefined" in the ship environment - that IS a placeholder, not a
|
|
61
|
+
// live credential.
|
|
62
|
+
const NULL_TOKENS = new Set(['null', 'undefined', 'none']);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect whether `value` matches a placeholder-shape a credential value
|
|
66
|
+
* would never legitimately hold. Deterministic; case-insensitive against
|
|
67
|
+
* the named-stand-in catalogue and the `YOUR_X_HERE` pattern.
|
|
68
|
+
*
|
|
69
|
+
* The intent is a hard refusal filter, not a heuristic: every match is
|
|
70
|
+
* something a developer typed as a stand-in and forgot to replace. False
|
|
71
|
+
* positives are vanishingly unlikely; the shortest name-family entry is
|
|
72
|
+
* six characters ("dummy" being five is the shortest, and no real credential
|
|
73
|
+
* is a five-character English word); the repeat-char rule requires length
|
|
74
|
+
* >= 3 so a two-character hash prefix does not match.
|
|
75
|
+
*
|
|
76
|
+
* @param {unknown} value the raw credential-field value at ship time
|
|
77
|
+
* @returns {PlaceholderVerdict}
|
|
78
|
+
*/
|
|
79
|
+
export function detectPlaceholderCredentialShape(value) {
|
|
80
|
+
// Non-strings are not credentials the shipped runtime carries; skip.
|
|
81
|
+
if (typeof value !== 'string') {
|
|
82
|
+
return { isPlaceholder: false };
|
|
83
|
+
}
|
|
84
|
+
const raw = value;
|
|
85
|
+
const trimmed = raw.trim();
|
|
86
|
+
if (trimmed.length === 0) {
|
|
87
|
+
return { isPlaceholder: true, pattern: 'empty', reason: 'empty or whitespace-only credential value' };
|
|
88
|
+
}
|
|
89
|
+
if (trimmed === '-' || trimmed === '_') {
|
|
90
|
+
return { isPlaceholder: true, pattern: 'single-dash', reason: `credential value is the single-character stand-in "${trimmed}"` };
|
|
91
|
+
}
|
|
92
|
+
const lower = trimmed.toLowerCase();
|
|
93
|
+
if (NULL_TOKENS.has(lower)) {
|
|
94
|
+
return { isPlaceholder: true, pattern: 'null-token', reason: `credential value is the literal null-token string "${trimmed}"` };
|
|
95
|
+
}
|
|
96
|
+
if (YOU_HERE_RE.test(trimmed)) {
|
|
97
|
+
return { isPlaceholder: true, pattern: 'you-here', reason: `credential value matches the YOUR_X_HERE scaffold pattern: "${trimmed}"` };
|
|
98
|
+
}
|
|
99
|
+
if (NAMED_STAND_INS.has(lower)) {
|
|
100
|
+
return { isPlaceholder: true, pattern: 'named-stand-in', reason: `credential value is the named stand-in "${trimmed}"` };
|
|
101
|
+
}
|
|
102
|
+
if (REPEAT_CHAR_RE.test(trimmed)) {
|
|
103
|
+
return { isPlaceholder: true, pattern: 'repeat-char', reason: `credential value is a repeated-single-character stand-in "${trimmed}"` };
|
|
104
|
+
}
|
|
105
|
+
return { isPlaceholder: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The catalogue's stable identifier. Downstream probes surface this on
|
|
110
|
+
* their report so the gate-failure trail names which detector version
|
|
111
|
+
* refused the value. Extending the catalogue bumps this constant.
|
|
112
|
+
*/
|
|
113
|
+
export const PLACEHOLDER_DETECTOR_VERSION = '1.0.0';
|