rcf-lite 0.8.0 → 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 +41 -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 +2 -2
- 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/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 +45 -0
- package/src/cli/help.js +8 -0
- package/src/cli/init.js +20 -5
- package/src/cli/standards.js +127 -0
- package/src/core/store/ids.js +168 -18
- package/src/core/store/loader.js +27 -16
- package/src/core/store/walker.js +27 -15
- package/src/deployment/index.js +13 -0
- package/src/deployment/placeholder-detector.js +113 -0
- package/src/query/formatters/table.js +7 -10
- package/src/query/trace.js +45 -4
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
|
/**
|
package/src/core/store/walker.js
CHANGED
|
@@ -16,30 +16,42 @@
|
|
|
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
23
|
/**
|
|
24
|
-
* Derive an id from a filename stem
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
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):
|
|
33
27
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
48
|
*/
|
|
37
49
|
function idFromFilenameStem(stem) {
|
|
50
|
+
const canonical = canonicaliseStem(stem);
|
|
51
|
+
if (canonical) return canonical;
|
|
38
52
|
const dash = stem.indexOf('-');
|
|
39
53
|
if (dash === -1) return stem.toUpperCase();
|
|
40
|
-
|
|
41
|
-
const tail = stem.slice(dash);
|
|
42
|
-
return `${prefix}${tail}`;
|
|
54
|
+
return `${stem.slice(0, dash).toUpperCase()}${stem.slice(dash)}`;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
/**
|
|
@@ -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';
|
|
@@ -122,7 +122,7 @@ function formatTraceTable(result) {
|
|
|
122
122
|
lines.push(`${directionLabel}: (none)`);
|
|
123
123
|
} else {
|
|
124
124
|
for (const n of showList) {
|
|
125
|
-
rows.push([String(n.depth), n.id, n.kind,
|
|
125
|
+
rows.push([String(n.depth), n.id, n.kind, n.title ?? '']);
|
|
126
126
|
}
|
|
127
127
|
lines.push(renderTable(rows));
|
|
128
128
|
}
|
|
@@ -139,7 +139,7 @@ function formatBothTraceTable(result) {
|
|
|
139
139
|
lines.push(' (none)');
|
|
140
140
|
} else {
|
|
141
141
|
const rows = [['Depth', 'Id', 'Kind', 'Title']];
|
|
142
|
-
for (const n of ancestors) rows.push([String(n.depth), n.id, n.kind, '']);
|
|
142
|
+
for (const n of ancestors) rows.push([String(n.depth), n.id, n.kind, n.title ?? '']);
|
|
143
143
|
lines.push(renderTable(rows));
|
|
144
144
|
}
|
|
145
145
|
lines.push('');
|
|
@@ -151,7 +151,7 @@ function formatBothTraceTable(result) {
|
|
|
151
151
|
lines.push(' (none)');
|
|
152
152
|
} else {
|
|
153
153
|
const rows = [['Depth', 'Id', 'Kind', 'Title']];
|
|
154
|
-
for (const n of descendants) rows.push([String(n.depth), n.id, n.kind, '']);
|
|
154
|
+
for (const n of descendants) rows.push([String(n.depth), n.id, n.kind, n.title ?? '']);
|
|
155
155
|
lines.push(renderTable(rows));
|
|
156
156
|
}
|
|
157
157
|
return `${lines.join('\n')}\n`;
|
|
@@ -194,10 +194,7 @@ function renderTable(rows) {
|
|
|
194
194
|
return [out[0], sep, ...out.slice(1)].join('\n');
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
function cellTitle(_id) {
|
|
202
|
-
return '';
|
|
203
|
-
}
|
|
197
|
+
// Trace nodes carry `title` as of the paper-cut batch (previously the
|
|
198
|
+
// Title column was always empty). Doc-kind title source: PRD →
|
|
199
|
+
// productName, REQ/US/TAC/ADR/TAD/TS/FBS/CN → title, inline AC/TC →
|
|
200
|
+
// description. Nodes lacking a title render blank.
|
package/src/query/trace.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* @property {string} id
|
|
33
33
|
* @property {string} kind
|
|
34
34
|
* @property {number} depth - 0 for pivot; positive for descendants; negative for ancestors
|
|
35
|
+
* @property {string} title - display title, or '' when the node has none
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -71,6 +72,41 @@ export function kindOf(tree, id) {
|
|
|
71
72
|
return null;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Return the display title for an id, or '' when the node carries none.
|
|
77
|
+
* Different doc kinds carry the title on different fields (PRD →
|
|
78
|
+
* productName; inline AC/TC → description; everything else → title). A
|
|
79
|
+
* standalone doc that lacks its title field renders blank rather than
|
|
80
|
+
* throwing; that keeps trace output readable on a partially-authored tree.
|
|
81
|
+
*
|
|
82
|
+
* Inline lookup: AC / TC nodes live inside their parent's inline array
|
|
83
|
+
* (userStory.acceptanceCriteria[] / testSuite.testCases[]), not as
|
|
84
|
+
* standalone entries in `tree.byId`. Resolve via parentByChild.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} tree - walker TreeModel
|
|
87
|
+
* @param {string} id
|
|
88
|
+
* @param {string} kind - as returned by `kindOf`
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function titleOf(tree, id, kind) {
|
|
92
|
+
if (kind === 'ac') {
|
|
93
|
+
const usId = tree.parentByChild.get(id);
|
|
94
|
+
const us = usId ? tree.byId.get(usId) : null;
|
|
95
|
+
const ac = us?.acceptanceCriteria?.find((a) => a?.id === id);
|
|
96
|
+
return ac?.description ?? '';
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'tc') {
|
|
99
|
+
const tsId = tree.parentByChild.get(id);
|
|
100
|
+
const ts = tsId ? tree.byId.get(tsId) : null;
|
|
101
|
+
const tc = ts?.testCases?.find((t) => t?.id === id);
|
|
102
|
+
return tc?.description ?? '';
|
|
103
|
+
}
|
|
104
|
+
const doc = tree.byId.get(id);
|
|
105
|
+
if (!doc) return '';
|
|
106
|
+
if (kind === 'prd') return doc.productName ?? '';
|
|
107
|
+
return doc.title ?? '';
|
|
108
|
+
}
|
|
109
|
+
|
|
74
110
|
/**
|
|
75
111
|
* Compute a trace from `id` in the requested direction. Unknown pivot
|
|
76
112
|
* returns `{found: false}`; the handler layer converts this to exit 2.
|
|
@@ -134,7 +170,7 @@ export function computeTrace(tree, {
|
|
|
134
170
|
*/
|
|
135
171
|
function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDependents = false) {
|
|
136
172
|
/** @type {TraceNode[]} */
|
|
137
|
-
const nodes = [{ id: pivot, kind: pivotKind, depth: 0 }];
|
|
173
|
+
const nodes = [{ id: pivot, kind: pivotKind, depth: 0, title: titleOf(tree, pivot, pivotKind) }];
|
|
138
174
|
/** @type {TraceEdge[]} */
|
|
139
175
|
const edges = [];
|
|
140
176
|
const seen = new Set([pivot]);
|
|
@@ -158,7 +194,12 @@ function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDepen
|
|
|
158
194
|
if (!childKind) continue;
|
|
159
195
|
const nextDepth = curDepth + 1;
|
|
160
196
|
depthById.set(child.id, nextDepth);
|
|
161
|
-
nodes.push({
|
|
197
|
+
nodes.push({
|
|
198
|
+
id: child.id,
|
|
199
|
+
kind: childKind,
|
|
200
|
+
depth: nextDepth,
|
|
201
|
+
title: titleOf(tree, child.id, childKind),
|
|
202
|
+
});
|
|
162
203
|
queue.push(child.id);
|
|
163
204
|
}
|
|
164
205
|
}
|
|
@@ -178,7 +219,7 @@ function walkForward(tree, pivot, pivotKind, includeCode = false, expandFbsDepen
|
|
|
178
219
|
*/
|
|
179
220
|
function walkBack(tree, pivot, pivotKind) {
|
|
180
221
|
/** @type {TraceNode[]} */
|
|
181
|
-
const nodes = [{ id: pivot, kind: pivotKind, depth: 0 }];
|
|
222
|
+
const nodes = [{ id: pivot, kind: pivotKind, depth: 0, title: titleOf(tree, pivot, pivotKind) }];
|
|
182
223
|
/** @type {TraceEdge[]} */
|
|
183
224
|
const edges = [];
|
|
184
225
|
const seen = new Set([pivot]);
|
|
@@ -188,7 +229,7 @@ function walkBack(tree, pivot, pivotKind) {
|
|
|
188
229
|
const k = kindOf(tree, toId);
|
|
189
230
|
if (!k) return;
|
|
190
231
|
seen.add(toId);
|
|
191
|
-
nodes.push({ id: toId, kind: k, depth });
|
|
232
|
+
nodes.push({ id: toId, kind: k, depth, title: titleOf(tree, toId, k) });
|
|
192
233
|
edges.push({ from: fromId, to: toId, kind: edgeKind });
|
|
193
234
|
};
|
|
194
235
|
|