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.
Files changed (60) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/rcf.js +6 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/harness-template.md +11 -0
  5. package/guidance/managed/agent-instructions-block.hash +1 -1
  6. package/guidance/managed/agent-instructions-block.md +11 -0
  7. package/package.json +2 -2
  8. package/rcf/adrs/adr-010.json +30 -0
  9. package/rcf/code-nodes/cn-058.json +18 -0
  10. package/rcf/code-nodes/cn-059.json +14 -0
  11. package/rcf/code-nodes/cn-060.json +14 -0
  12. package/rcf/code-nodes/cn-061.json +14 -0
  13. package/rcf/code-nodes/cn-062.json +14 -0
  14. package/rcf/code-nodes/cn-063.json +15 -0
  15. package/rcf/code-nodes/cn-064.json +15 -0
  16. package/rcf/code-nodes/cn-065.json +16 -0
  17. package/rcf/code-nodes/cn-066.json +14 -0
  18. package/rcf/code-nodes/cn-067.json +15 -0
  19. package/rcf/code-nodes/cn-068.json +15 -0
  20. package/rcf/code-nodes/cn-069.json +16 -0
  21. package/rcf/fbs/fbs-016.json +39 -0
  22. package/rcf/fbs/fbs-017.json +40 -0
  23. package/rcf/fbs/fbs-018.json +34 -0
  24. package/rcf/fbs/fbs-019.json +33 -0
  25. package/rcf/requirements/req-010.json +20 -0
  26. package/rcf/test-suites/ts-026.json +54 -0
  27. package/rcf/test-suites/ts-027.json +115 -0
  28. package/rcf/test-suites/ts-028.json +46 -0
  29. package/rcf/test-suites/ts-029.json +46 -0
  30. package/rcf/user-stories/us-1001.json +56 -0
  31. package/rcf/user-stories/us-1002.json +96 -0
  32. package/rcf/user-stories/us-1003.json +48 -0
  33. package/rcf/user-stories/us-1004.json +48 -0
  34. package/src/blueprint/apply.js +464 -0
  35. package/src/blueprint/conflicts.js +351 -0
  36. package/src/blueprint/diff.js +82 -0
  37. package/src/blueprint/index.js +12 -0
  38. package/src/blueprint/list.js +21 -0
  39. package/src/blueprint/loader.js +163 -0
  40. package/src/blueprint/manifest-writer.js +49 -0
  41. package/src/blueprint/namespace.js +145 -0
  42. package/src/blueprint/remove.js +105 -0
  43. package/src/blueprint/resolutions.js +83 -0
  44. package/src/blueprint/standards.js +148 -0
  45. package/src/blueprint/supersede.js +318 -0
  46. package/src/browser-verify/invariants.js +33 -6
  47. package/src/build/bundle.js +34 -11
  48. package/src/build/standards-selector.js +52 -0
  49. package/src/cli/blueprint.js +325 -0
  50. package/src/cli/create.js +45 -0
  51. package/src/cli/help.js +8 -0
  52. package/src/cli/init.js +20 -5
  53. package/src/cli/standards.js +127 -0
  54. package/src/core/store/ids.js +168 -18
  55. package/src/core/store/loader.js +27 -16
  56. package/src/core/store/walker.js +27 -15
  57. package/src/deployment/index.js +13 -0
  58. package/src/deployment/placeholder-detector.js +113 -0
  59. package/src/query/formatters/table.js +7 -10
  60. 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
+ }