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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Namespacing rules for blueprint-contributed doc ids.
|
|
2
|
+
//
|
|
3
|
+
// THE id grammar (family membership, regex shapes, filename inversion)
|
|
4
|
+
// lives in `../core/store/ids.js` -- one implementation shared by the
|
|
5
|
+
// blueprint stamper, the walker (idFromFilenameStem) and the loader
|
|
6
|
+
// (pathForId). This module carries the blueprint-facing helpers on top
|
|
7
|
+
// of that grammar (stampId / isNamespacedFor / namespaceStyleFor) and
|
|
8
|
+
// re-exports parseIdParts so the public blueprint surface stays stable.
|
|
9
|
+
//
|
|
10
|
+
// Two families, per the 0.4.4 schemas grammar (see docs/id-conventions.md
|
|
11
|
+
// in @stravica-ai/rcf-schemas):
|
|
12
|
+
//
|
|
13
|
+
// - Prefix families (REQ, US, PRD, BS, TAD, TS): the blueprint slug is
|
|
14
|
+
// attached as a lowercase kebab-slug PREFIX joined by `-` to the
|
|
15
|
+
// family prefix. `REQ-001` under blueprint `spa` becomes `spa-REQ-001`.
|
|
16
|
+
//
|
|
17
|
+
// - Suffix families (ADR, TAC, FBS, CN): the blueprint slug is attached
|
|
18
|
+
// as a lowercase kebab-slug SUFFIX joined by `-` to the numeric tail.
|
|
19
|
+
// `ADR-005` under blueprint `spa` becomes `ADR-005-spa`; a longer slug
|
|
20
|
+
// segment (`spa-theme`) becomes `ADR-005-spa-theme`.
|
|
21
|
+
//
|
|
22
|
+
// - AC and TC: not namespaced. AC ids are anchored to their parent US
|
|
23
|
+
// (whose id is prefix-namespaced) and TC ids are anchored to their
|
|
24
|
+
// parent TS (ditto). The 0.4.4 schemas intentionally left AC/TC
|
|
25
|
+
// patterns unchanged.
|
|
26
|
+
//
|
|
27
|
+
// Ownership determination is NOT a grammar concern. String grammar is
|
|
28
|
+
// only used here to STAMP a bare id at first apply; ownership of an
|
|
29
|
+
// already-written contribution is answered by the manifest's
|
|
30
|
+
// appliedBlueprintRecord.contributions[] list -- the authoritative
|
|
31
|
+
// record of exactly which ids a given applied blueprint owns. See
|
|
32
|
+
// `apply.js` (overwrite guard, cross-claim check) and `remove.js`
|
|
33
|
+
// (referring-doc scan) for the record-consulting call-sites.
|
|
34
|
+
//
|
|
35
|
+
// This module is pure: no I/O, no wall clock.
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
PREFIX_FAMILY_SET,
|
|
39
|
+
SLUG_PATTERN,
|
|
40
|
+
SUFFIX_FAMILY_SET,
|
|
41
|
+
UNNAMESPACED_FAMILY_SET,
|
|
42
|
+
parseIdParts,
|
|
43
|
+
} from '../core/store/ids.js';
|
|
44
|
+
|
|
45
|
+
// Re-exported so `import { parseIdParts } from '../blueprint/namespace.js'`
|
|
46
|
+
// (and the transitive `blueprint/index.js` re-export) keep working. The
|
|
47
|
+
// implementation lives in core.
|
|
48
|
+
export { parseIdParts };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which namespacing style applies to a given id.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} id
|
|
54
|
+
* @returns {'prefix' | 'suffix' | 'none' | null}
|
|
55
|
+
*/
|
|
56
|
+
export function namespaceStyleFor(id) {
|
|
57
|
+
const parts = parseIdParts(id);
|
|
58
|
+
if (!parts) return null;
|
|
59
|
+
if (PREFIX_FAMILY_SET.has(parts.family)) return 'prefix';
|
|
60
|
+
if (SUFFIX_FAMILY_SET.has(parts.family)) return 'suffix';
|
|
61
|
+
return 'none';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stamp an id with a blueprint namespace at FIRST APPLY.
|
|
66
|
+
*
|
|
67
|
+
* Contract:
|
|
68
|
+
* - Bare family+digits ids (`REQ-001`, `ADR-005`) get the slug written
|
|
69
|
+
* as prefix (prefix families) or suffix (suffix families).
|
|
70
|
+
* - Ids that already carry a slug segment are accepted VERBATIM: the
|
|
71
|
+
* blueprint author's declared contribution list is the truth for
|
|
72
|
+
* what that blueprint owns. String grammar does not veto (`stampId`
|
|
73
|
+
* used to refuse `ADR-201-spa-theme` under slug `spa` on the basis
|
|
74
|
+
* that `spa-theme` != `spa`, which broke every blueprint whose
|
|
75
|
+
* suffix-family ids carried a semantic tail after the slug).
|
|
76
|
+
* - AC and TC pass through unchanged (no namespacing family).
|
|
77
|
+
* - An id that matches no family pattern, or a slug that is not a
|
|
78
|
+
* valid kebab, is refused.
|
|
79
|
+
*
|
|
80
|
+
* Cross-blueprint claims (`spa-theme-REQ-001` declared under blueprint
|
|
81
|
+
* `spa` where `spa-theme` is also applied) are caught by the manifest-
|
|
82
|
+
* record consulted at write time (`apply.js` overwrite guard +
|
|
83
|
+
* cross-claim detector). String parsing here is deliberately not a
|
|
84
|
+
* trust surface.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} id - canonical id (either bare `REQ-001` or already
|
|
87
|
+
* namespaced `spa-REQ-001` / `ADR-201-spa-theme`)
|
|
88
|
+
* @param {string} slug - blueprint slug (must be well-formed kebab)
|
|
89
|
+
* @returns {{ id: string } | { error: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function stampId(id, slug) {
|
|
92
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
93
|
+
return { error: `stampId: slug '${slug}' is not a valid kebab slug` };
|
|
94
|
+
}
|
|
95
|
+
const parts = parseIdParts(id);
|
|
96
|
+
if (!parts) return { error: `stampId: id '${id}' does not match any known family pattern` };
|
|
97
|
+
const style = namespaceStyleFor(id);
|
|
98
|
+
if (style === 'none') return { id };
|
|
99
|
+
if (style === 'prefix') {
|
|
100
|
+
if (parts.prefixSlug === null) return { id: `${slug}-${parts.family}-${parts.digits}` };
|
|
101
|
+
// Already carries a prefix segment. Trust the declaration -- see
|
|
102
|
+
// module doc block. Any resulting cross-claim will surface at the
|
|
103
|
+
// manifest-record check in apply.js, not here.
|
|
104
|
+
return { id };
|
|
105
|
+
}
|
|
106
|
+
// suffix
|
|
107
|
+
if (parts.suffixSlug === null) return { id: `${parts.family}-${parts.digits}-${slug}` };
|
|
108
|
+
// Already carries a suffix segment. Trust the declaration (same
|
|
109
|
+
// reasoning as the prefix branch above).
|
|
110
|
+
return { id };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* GRAMMAR predicate: does the id's parsed slug segment equal `slug`?
|
|
115
|
+
*
|
|
116
|
+
* This is NOT an ownership check. Ownership of an applied contribution
|
|
117
|
+
* is answered by the manifest's appliedBlueprintRecord.contributions[]
|
|
118
|
+
* list (see `apply.js` overwrite guard and `remove.js` referring-doc
|
|
119
|
+
* scan). `isNamespacedFor` retains exact-slug grammar semantics for
|
|
120
|
+
* external consumers (docs generators, id-audit tooling) but must NOT
|
|
121
|
+
* be used to authorise a write or a delete -- for slug+tail ids like
|
|
122
|
+
* `ADR-201-spa-theme` the parsed suffix is `spa-theme`, which returns
|
|
123
|
+
* false for a legitimate `spa`-owned contribution whose author put a
|
|
124
|
+
* semantic tail after the slug. That misread is the exact reason the
|
|
125
|
+
* ownership call-sites were routed off this predicate.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} id
|
|
128
|
+
* @param {string} slug
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export function isNamespacedFor(id, slug) {
|
|
132
|
+
const parts = parseIdParts(id);
|
|
133
|
+
if (!parts) return false;
|
|
134
|
+
const style = namespaceStyleFor(id);
|
|
135
|
+
if (style === 'prefix') return parts.prefixSlug === slug;
|
|
136
|
+
if (style === 'suffix') return parts.suffixSlug === slug;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const _internal = {
|
|
141
|
+
PREFIX_FAMILIES: PREFIX_FAMILY_SET,
|
|
142
|
+
SUFFIX_FAMILIES: SUFFIX_FAMILY_SET,
|
|
143
|
+
UNNAMESPACED_FAMILIES: UNNAMESPACED_FAMILY_SET,
|
|
144
|
+
SLUG_PATTERN,
|
|
145
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Blueprint remove. Referring-doc scan + manifest patch + contribution
|
|
2
|
+
// file unlink.
|
|
3
|
+
//
|
|
4
|
+
// Refuses when any project-authored doc references any of the removed
|
|
5
|
+
// blueprint's contribution ids. `--force` is a Phase 3 concern (see
|
|
6
|
+
// design brief §Verbs); Phase 1 declines the override and returns the
|
|
7
|
+
// referring-doc list so the operator can see what needs unbinding.
|
|
8
|
+
|
|
9
|
+
import { unlink } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { rcfError } from '../core/errors/index.js';
|
|
13
|
+
import { updateManifest } from './manifest-writer.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} args
|
|
17
|
+
* @param {string} args.projectRoot
|
|
18
|
+
* @param {import('#core/store/walker.js').TreeModel} args.tree
|
|
19
|
+
* @param {string} args.slug
|
|
20
|
+
* @param {boolean} [args.dryRun]
|
|
21
|
+
* @returns {Promise<{
|
|
22
|
+
* removed: boolean,
|
|
23
|
+
* slug: string,
|
|
24
|
+
* deletedPaths?: string[],
|
|
25
|
+
* referringDocs?: Array<{ docId: string, matchedId: string }>
|
|
26
|
+
* } | import('../core/errors/index.js').RcfError>}
|
|
27
|
+
*/
|
|
28
|
+
export async function removeBlueprint({ projectRoot, tree, slug, dryRun = false }) {
|
|
29
|
+
const applied = tree.manifest?.blueprints ?? [];
|
|
30
|
+
const entry = applied.find((b) => b.slug === slug);
|
|
31
|
+
if (!entry) {
|
|
32
|
+
return rcfError({ kind: 'usage', message: `blueprint remove: no blueprint '${slug}' is applied on this project.` });
|
|
33
|
+
}
|
|
34
|
+
const contributionIds = new Set((entry.contributions ?? []).map((c) => c.id));
|
|
35
|
+
const referring = scanReferringDocs(tree, contributionIds, slug);
|
|
36
|
+
if (referring.length > 0) {
|
|
37
|
+
return { removed: false, slug, referringDocs: referring };
|
|
38
|
+
}
|
|
39
|
+
const deletedPaths = [];
|
|
40
|
+
for (const c of entry.contributions ?? []) {
|
|
41
|
+
if (dryRun) { deletedPaths.push(c.path); continue; }
|
|
42
|
+
try {
|
|
43
|
+
await unlink(join(projectRoot, c.path));
|
|
44
|
+
deletedPaths.push(c.path);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code !== 'ENOENT') {
|
|
47
|
+
return rcfError({ kind: 'ioFailure', message: `blueprint remove: unlink failed for ${c.path}: ${err.message}`, filePath: c.path });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const result = await updateManifest({
|
|
52
|
+
projectRoot,
|
|
53
|
+
manifest: tree.manifest,
|
|
54
|
+
mutate: (next) => {
|
|
55
|
+
const list = Array.isArray(next.blueprints) ? next.blueprints : [];
|
|
56
|
+
next.blueprints = list.filter((b) => b.slug !== slug);
|
|
57
|
+
if (next.blueprints.length === 0) delete next.blueprints;
|
|
58
|
+
},
|
|
59
|
+
dryRun,
|
|
60
|
+
});
|
|
61
|
+
if (result.kind) return result;
|
|
62
|
+
return { removed: true, slug, deletedPaths };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk every non-blueprint doc's string-shaped fields (including nested
|
|
67
|
+
* arrays / objects) and report any occurrence of a contribution id.
|
|
68
|
+
* A referring-doc match INCLUDES the blueprint's own contributions when
|
|
69
|
+
* one contribution refers to another — we filter those out by checking
|
|
70
|
+
* the referring doc id itself against `contributionIds`.
|
|
71
|
+
*/
|
|
72
|
+
function scanReferringDocs(tree, contributionIds, slug) {
|
|
73
|
+
const hits = [];
|
|
74
|
+
const contributionPaths = new Set();
|
|
75
|
+
for (const entry of tree.manifest?.blueprints ?? []) {
|
|
76
|
+
if (entry.slug !== slug) continue;
|
|
77
|
+
for (const c of entry.contributions ?? []) contributionPaths.add(c.path);
|
|
78
|
+
}
|
|
79
|
+
for (const [id, doc] of tree.byId ?? new Map()) {
|
|
80
|
+
if (contributionIds.has(id)) continue; // the blueprint's own contributions cross-refer each other
|
|
81
|
+
for (const matchedId of findIdReferences(doc, contributionIds)) {
|
|
82
|
+
hits.push({ docId: id, matchedId });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return hits;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findIdReferences(node, contributionIds, seen = new WeakSet()) {
|
|
89
|
+
const out = [];
|
|
90
|
+
if (typeof node === 'string') {
|
|
91
|
+
if (contributionIds.has(node)) out.push(node);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(node)) {
|
|
95
|
+
for (const item of node) out.push(...findIdReferences(item, contributionIds, seen));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
if (typeof node === 'object' && node !== null && !seen.has(node)) {
|
|
99
|
+
seen.add(node);
|
|
100
|
+
for (const value of Object.values(node)) {
|
|
101
|
+
out.push(...findIdReferences(value, contributionIds, seen));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Blueprint conflict-resolution helpers.
|
|
2
|
+
//
|
|
3
|
+
// A resolution record (schema $defs/blueprintConflictResolution, added
|
|
4
|
+
// in @stravica-ai/rcf-schemas 0.4.5) captures an operator ruling that
|
|
5
|
+
// resolves a globalAdrTopic conflict: two applied blueprints each
|
|
6
|
+
// contributing a scope:global ADR on the same topic, resolved by a
|
|
7
|
+
// project-level ADR that supersedes both.
|
|
8
|
+
//
|
|
9
|
+
// This module owns:
|
|
10
|
+
// - `nextResolutionId(manifest, now)`: monotonic `res-YYYY-MM-DD-NNN`
|
|
11
|
+
// id-mint, mirroring the pattern used for pfc / boo / ic / rc ids
|
|
12
|
+
// elsewhere in the tree.
|
|
13
|
+
// - `matchingResolution(manifest, { topic, incoming, existing })`: the
|
|
14
|
+
// lookup the conflict detector calls to see whether a would-be
|
|
15
|
+
// globalAdrTopic conflict has already been resolved. Match rule:
|
|
16
|
+
// kind = globalAdrTopic, topic matches, AND supersedes[] lists both
|
|
17
|
+
// the incoming and the existing { slug, adrId } pairs. Superset OK
|
|
18
|
+
// (a resolution recorded for three blueprints on the same topic still
|
|
19
|
+
// resolves the two-blueprint conflict in front of us); subset not (a
|
|
20
|
+
// resolution that only lists one of the two would leave the other's
|
|
21
|
+
// ADR unaccounted for).
|
|
22
|
+
//
|
|
23
|
+
// Pure functions. No I/O, no wall clock beyond the `now` a caller hands
|
|
24
|
+
// in.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute the next resolution id for today: monotonic
|
|
28
|
+
* `res-YYYY-MM-DD-NNN`. Reads `manifest.resolutions[]` for the highest
|
|
29
|
+
* NNN under today's date prefix and returns that + 1, zero-padded.
|
|
30
|
+
*
|
|
31
|
+
* @param {object|null} manifest
|
|
32
|
+
* @param {Date} now
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function nextResolutionId(manifest, now) {
|
|
36
|
+
const y = now.getUTCFullYear().toString().padStart(4, '0');
|
|
37
|
+
const m = (now.getUTCMonth() + 1).toString().padStart(2, '0');
|
|
38
|
+
const d = now.getUTCDate().toString().padStart(2, '0');
|
|
39
|
+
const prefix = `res-${y}-${m}-${d}-`;
|
|
40
|
+
const existing = Array.isArray(manifest?.resolutions) ? manifest.resolutions : [];
|
|
41
|
+
let maxN = 0;
|
|
42
|
+
for (const rec of existing) {
|
|
43
|
+
if (typeof rec?.id !== 'string' || !rec.id.startsWith(prefix)) continue;
|
|
44
|
+
const n = Number.parseInt(rec.id.slice(prefix.length), 10);
|
|
45
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
46
|
+
}
|
|
47
|
+
return `${prefix}${(maxN + 1).toString().padStart(3, '0')}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find a resolution on the manifest that resolves a specific
|
|
52
|
+
* globalAdrTopic conflict pair. Returns the resolution record if a
|
|
53
|
+
* match exists, `null` otherwise.
|
|
54
|
+
*
|
|
55
|
+
* A match requires:
|
|
56
|
+
* - `kind === 'globalAdrTopic'` on the resolution
|
|
57
|
+
* - `topic` matches
|
|
58
|
+
* - `supersedes[]` contains an entry matching the incoming
|
|
59
|
+
* `{ slug, adrId }` AND an entry matching the existing
|
|
60
|
+
* `{ slug, adrId }`
|
|
61
|
+
*
|
|
62
|
+
* The `adrId` comparison is exact. The `slug` comparison is exact. A
|
|
63
|
+
* resolution that lists a superset of blueprints on the same topic still
|
|
64
|
+
* resolves the pair-conflict in front of us (blueprint 3 landing later
|
|
65
|
+
* on the same topic just needs to be listed too, which is a fresh
|
|
66
|
+
* resolution write, not a re-resolve).
|
|
67
|
+
*
|
|
68
|
+
* @param {object|null} manifest
|
|
69
|
+
* @param {{ topic: string, incoming: { slug: string, adrId: string }, existing: { slug: string, adrId: string } }} conflict
|
|
70
|
+
* @returns {object|null}
|
|
71
|
+
*/
|
|
72
|
+
export function matchingResolution(manifest, { topic, incoming, existing }) {
|
|
73
|
+
const list = Array.isArray(manifest?.resolutions) ? manifest.resolutions : [];
|
|
74
|
+
for (const rec of list) {
|
|
75
|
+
if (rec?.kind !== 'globalAdrTopic') continue;
|
|
76
|
+
if (rec.topic !== topic) continue;
|
|
77
|
+
const supers = Array.isArray(rec.supersedes) ? rec.supersedes : [];
|
|
78
|
+
const hasIncoming = supers.some((s) => s?.slug === incoming.slug && s?.adrId === incoming.adrId);
|
|
79
|
+
const hasExisting = supers.some((s) => s?.slug === existing.slug && s?.adrId === existing.adrId);
|
|
80
|
+
if (hasIncoming && hasExisting) return rec;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Standards-pack registration. Reference-by-default when the source
|
|
2
|
+
// lives inside the project root; copy into rcf/standards/<slug>/ only
|
|
3
|
+
// when the source lives outside.
|
|
4
|
+
//
|
|
5
|
+
// Manifest registration is authoritative (design-brief.md v2 §Corporate
|
|
6
|
+
// standards and personal patterns).
|
|
7
|
+
|
|
8
|
+
import { cp, stat } from 'node:fs/promises';
|
|
9
|
+
import { join, relative, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { rcfError } from '../core/errors/index.js';
|
|
12
|
+
import { updateManifest } from './manifest-writer.js';
|
|
13
|
+
|
|
14
|
+
const SLUG_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} args
|
|
18
|
+
* @param {string} args.projectRoot
|
|
19
|
+
* @param {import('#core/store/walker.js').TreeModel} args.tree
|
|
20
|
+
* @param {string} args.sourcePath - the standards folder path (absolute or repo-relative)
|
|
21
|
+
* @param {string} args.slug
|
|
22
|
+
* @param {string[]} args.tags
|
|
23
|
+
* @param {'standard' | 'agent' | 'none'} args.testsProvidedBy
|
|
24
|
+
* @param {'personal' | 'corporate'} args.provenance
|
|
25
|
+
* @param {string} [args.summary]
|
|
26
|
+
* @param {boolean} [args.dryRun]
|
|
27
|
+
* @returns {Promise<{
|
|
28
|
+
* registered: boolean,
|
|
29
|
+
* entry: object,
|
|
30
|
+
* copied: boolean,
|
|
31
|
+
* copyPath?: string,
|
|
32
|
+
* alreadyRegistered?: boolean
|
|
33
|
+
* } | import('../core/errors/index.js').RcfError>}
|
|
34
|
+
*/
|
|
35
|
+
export async function registerStandardsPack({
|
|
36
|
+
projectRoot, tree, sourcePath, slug, tags, testsProvidedBy, provenance, summary, dryRun = false,
|
|
37
|
+
}) {
|
|
38
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
39
|
+
return rcfError({ kind: 'usage', message: `standards add: slug '${slug}' is not a valid kebab slug` });
|
|
40
|
+
}
|
|
41
|
+
if (!['standard', 'agent', 'none'].includes(testsProvidedBy)) {
|
|
42
|
+
return rcfError({ kind: 'usage', message: `standards add: testsProvidedBy must be one of standard|agent|none` });
|
|
43
|
+
}
|
|
44
|
+
if (!['personal', 'corporate'].includes(provenance)) {
|
|
45
|
+
return rcfError({ kind: 'usage', message: `standards add: provenance must be one of personal|corporate` });
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
48
|
+
return rcfError({ kind: 'usage', message: `standards add: at least one tag is required` });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const abs = resolve(sourcePath);
|
|
52
|
+
try { await stat(abs); }
|
|
53
|
+
catch (err) {
|
|
54
|
+
return rcfError({ kind: 'missingFile', message: `standards add: source path does not exist: ${abs}`, filePath: abs });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const projectAbs = resolve(projectRoot);
|
|
58
|
+
const rel = relative(projectAbs, abs);
|
|
59
|
+
const isInsideRoot = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
60
|
+
|
|
61
|
+
let entry;
|
|
62
|
+
let copied = false;
|
|
63
|
+
let copyPath;
|
|
64
|
+
if (isInsideRoot) {
|
|
65
|
+
entry = {
|
|
66
|
+
id: `std-${slug}`,
|
|
67
|
+
slug,
|
|
68
|
+
sourcePath: rel.split('\\').join('/'),
|
|
69
|
+
tags,
|
|
70
|
+
...(summary ? { summary } : {}),
|
|
71
|
+
testsProvidedBy,
|
|
72
|
+
provenance,
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
copyPath = `rcf/standards/${slug}`;
|
|
76
|
+
const absCopy = join(projectRoot, copyPath);
|
|
77
|
+
if (!dryRun) {
|
|
78
|
+
try {
|
|
79
|
+
await cp(abs, absCopy, { recursive: true, force: true });
|
|
80
|
+
copied = true;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return rcfError({ kind: 'ioFailure', message: `standards add: copy failed: ${err.message}`, filePath: absCopy });
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
copied = true; // reported to caller so dry-run output is coherent
|
|
86
|
+
}
|
|
87
|
+
entry = {
|
|
88
|
+
id: `std-${slug}`,
|
|
89
|
+
slug,
|
|
90
|
+
sourcePath: abs,
|
|
91
|
+
copyPath,
|
|
92
|
+
tags,
|
|
93
|
+
...(summary ? { summary } : {}),
|
|
94
|
+
testsProvidedBy,
|
|
95
|
+
provenance,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const existing = (tree.manifest?.standards ?? []).find((s) => s.slug === slug);
|
|
100
|
+
const alreadyRegistered = existing && JSON.stringify(existing) === JSON.stringify(entry);
|
|
101
|
+
if (alreadyRegistered) {
|
|
102
|
+
// Short-circuit before rewriting the manifest -- mirrors
|
|
103
|
+
// applyBlueprint's `alreadyApplied` fast-path (apply.js). Re-writing
|
|
104
|
+
// the same bytes churns the manifest's mtime and forces every
|
|
105
|
+
// consumer that watches it to re-load, so a truly idempotent call
|
|
106
|
+
// stays a no-op end-to-end.
|
|
107
|
+
return {
|
|
108
|
+
registered: false,
|
|
109
|
+
alreadyRegistered: true,
|
|
110
|
+
entry,
|
|
111
|
+
copied,
|
|
112
|
+
...(copyPath ? { copyPath } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const manifestResult = await updateManifest({
|
|
116
|
+
projectRoot,
|
|
117
|
+
manifest: tree.manifest,
|
|
118
|
+
mutate: (next) => {
|
|
119
|
+
const list = Array.isArray(next.standards) ? next.standards : [];
|
|
120
|
+
const filtered = list.filter((s) => s.slug !== slug);
|
|
121
|
+
filtered.push(entry);
|
|
122
|
+
next.standards = filtered;
|
|
123
|
+
},
|
|
124
|
+
dryRun,
|
|
125
|
+
});
|
|
126
|
+
if (manifestResult.kind) return manifestResult;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
registered: true,
|
|
130
|
+
alreadyRegistered: false,
|
|
131
|
+
entry,
|
|
132
|
+
copied,
|
|
133
|
+
...(copyPath ? { copyPath } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
139
|
+
* @returns {object[]}
|
|
140
|
+
*/
|
|
141
|
+
export function listStandards(tree) {
|
|
142
|
+
const list = tree.manifest?.standards ?? [];
|
|
143
|
+
return [...list];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isAbsolute(p) {
|
|
147
|
+
return p.startsWith('/') || /^[A-Za-z]:/.test(p);
|
|
148
|
+
}
|