@davidorex/pi-context 0.26.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 (126) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +195 -0
  3. package/dist/block-api.d.ts +328 -0
  4. package/dist/block-api.d.ts.map +1 -0
  5. package/dist/block-api.js +1167 -0
  6. package/dist/block-api.js.map +1 -0
  7. package/dist/block-validation.d.ts +33 -0
  8. package/dist/block-validation.d.ts.map +1 -0
  9. package/dist/block-validation.js +213 -0
  10. package/dist/block-validation.js.map +1 -0
  11. package/dist/context-dir.d.ts +93 -0
  12. package/dist/context-dir.d.ts.map +1 -0
  13. package/dist/context-dir.js +224 -0
  14. package/dist/context-dir.js.map +1 -0
  15. package/dist/context-sdk.d.ts +428 -0
  16. package/dist/context-sdk.d.ts.map +1 -0
  17. package/dist/context-sdk.js +1327 -0
  18. package/dist/context-sdk.js.map +1 -0
  19. package/dist/context.d.ts +497 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +1060 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/dispatch-context.d.ts +112 -0
  24. package/dist/dispatch-context.d.ts.map +1 -0
  25. package/dist/dispatch-context.js +119 -0
  26. package/dist/dispatch-context.js.map +1 -0
  27. package/dist/execution-context.d.ts +138 -0
  28. package/dist/execution-context.d.ts.map +1 -0
  29. package/dist/execution-context.js +151 -0
  30. package/dist/execution-context.js.map +1 -0
  31. package/dist/index.d.ts +34 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1655 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lens-validator.d.ts +50 -0
  36. package/dist/lens-validator.d.ts.map +1 -0
  37. package/dist/lens-validator.js +46 -0
  38. package/dist/lens-validator.js.map +1 -0
  39. package/dist/lens-view.d.ts +75 -0
  40. package/dist/lens-view.d.ts.map +1 -0
  41. package/dist/lens-view.js +253 -0
  42. package/dist/lens-view.js.map +1 -0
  43. package/dist/rename-canonical-id.d.ts +64 -0
  44. package/dist/rename-canonical-id.d.ts.map +1 -0
  45. package/dist/rename-canonical-id.js +378 -0
  46. package/dist/rename-canonical-id.js.map +1 -0
  47. package/dist/roadmap-plan.d.ts +186 -0
  48. package/dist/roadmap-plan.d.ts.map +1 -0
  49. package/dist/roadmap-plan.js +534 -0
  50. package/dist/roadmap-plan.js.map +1 -0
  51. package/dist/samples-catalog.d.ts +48 -0
  52. package/dist/samples-catalog.d.ts.map +1 -0
  53. package/dist/samples-catalog.js +128 -0
  54. package/dist/samples-catalog.js.map +1 -0
  55. package/dist/schema-migrations.d.ts +82 -0
  56. package/dist/schema-migrations.d.ts.map +1 -0
  57. package/dist/schema-migrations.js +106 -0
  58. package/dist/schema-migrations.js.map +1 -0
  59. package/dist/schema-validator.d.ts +76 -0
  60. package/dist/schema-validator.d.ts.map +1 -0
  61. package/dist/schema-validator.js +219 -0
  62. package/dist/schema-validator.js.map +1 -0
  63. package/dist/schema-write.d.ts +122 -0
  64. package/dist/schema-write.d.ts.map +1 -0
  65. package/dist/schema-write.js +210 -0
  66. package/dist/schema-write.js.map +1 -0
  67. package/dist/status-vocab.d.ts +60 -0
  68. package/dist/status-vocab.d.ts.map +1 -0
  69. package/dist/status-vocab.js +109 -0
  70. package/dist/status-vocab.js.map +1 -0
  71. package/dist/test-helpers.d.ts +12 -0
  72. package/dist/test-helpers.d.ts.map +1 -0
  73. package/dist/test-helpers.js +28 -0
  74. package/dist/test-helpers.js.map +1 -0
  75. package/dist/topo.d.ts +19 -0
  76. package/dist/topo.d.ts.map +1 -0
  77. package/dist/topo.js +99 -0
  78. package/dist/topo.js.map +1 -0
  79. package/dist/update-check.d.ts +6 -0
  80. package/dist/update-check.d.ts.map +1 -0
  81. package/dist/update-check.js +82 -0
  82. package/dist/update-check.js.map +1 -0
  83. package/package.json +115 -0
  84. package/samples/blocks/context-contracts.json +3 -0
  85. package/samples/blocks/conventions.json +3 -0
  86. package/samples/blocks/decisions.json +3 -0
  87. package/samples/blocks/features.json +3 -0
  88. package/samples/blocks/framework-gaps.json +3 -0
  89. package/samples/blocks/issues.json +3 -0
  90. package/samples/blocks/layer-plans.json +3 -0
  91. package/samples/blocks/phase.json +3 -0
  92. package/samples/blocks/rationale.json +3 -0
  93. package/samples/blocks/requirements.json +3 -0
  94. package/samples/blocks/research.json +3 -0
  95. package/samples/blocks/spec-reviews.json +3 -0
  96. package/samples/blocks/story.json +3 -0
  97. package/samples/blocks/tasks.json +3 -0
  98. package/samples/blocks/verification.json +3 -0
  99. package/samples/conception.json +467 -0
  100. package/samples/schemas/context-contracts.schema.json +89 -0
  101. package/samples/schemas/conventions.schema.json +34 -0
  102. package/samples/schemas/decisions.schema.json +87 -0
  103. package/samples/schemas/features.schema.json +53 -0
  104. package/samples/schemas/framework-gaps.schema.json +64 -0
  105. package/samples/schemas/issues.schema.json +39 -0
  106. package/samples/schemas/layer-plans.schema.json +92 -0
  107. package/samples/schemas/phase.schema.json +118 -0
  108. package/samples/schemas/rationale.schema.json +24 -0
  109. package/samples/schemas/requirements.schema.json +34 -0
  110. package/samples/schemas/research.schema.json +134 -0
  111. package/samples/schemas/spec-reviews.schema.json +62 -0
  112. package/samples/schemas/story.schema.json +73 -0
  113. package/samples/schemas/tasks.schema.json +34 -0
  114. package/samples/schemas/verification.schema.json +37 -0
  115. package/schemas/bootstrap.schema.json +24 -0
  116. package/schemas/config.schema.json +193 -0
  117. package/schemas/layer.schema.json +9 -0
  118. package/schemas/priority.schema.json +9 -0
  119. package/schemas/relations.schema.json +31 -0
  120. package/schemas/severity.schema.json +9 -0
  121. package/schemas/source.schema.json +9 -0
  122. package/schemas/status.schema.json +9 -0
  123. package/schemas/verification-method.schema.json +9 -0
  124. package/skill-narrative.md +130 -0
  125. package/skills/pi-context/SKILL.md +684 -0
  126. package/skills/pi-context/references/bundled-resources.md +47 -0
@@ -0,0 +1,1060 @@
1
+ /**
2
+ * pi-context substrate SDK — config-driven vocabulary registries from line 1.
3
+ *
4
+ * This module owns the substrate primitives ported from
5
+ * `analysis/poc-degree-zero-lens/render.ts` and extended with the
6
+ * config-as-canonical-registry shape per the step-2 plan.
7
+ *
8
+ * Design constraint: imports only from `./context-dir` (path constants),
9
+ * `./schema-validator` (AJV bridge), and node builtins. This module resolves
10
+ * the substrate dir via `resolveContextDir` from `./context-dir` without
11
+ * forming a cycle through context-sdk; it must therefore stay at a strictly
12
+ * lower layer than context-sdk.
13
+ *
14
+ * Closes structurally:
15
+ * - FGAP-001 (hierarchical block storage via closure-table per DEC-0009)
16
+ * - FGAP-013 (status vocabulary registry — `config.status_buckets`)
17
+ * - issue-089 class (PLAN- prefix collision — `config.block_kinds[].prefix`
18
+ * makes prefix conflicts a config-registration-time concern, not a
19
+ * fixture-write-time crash)
20
+ */
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { appendManyToTypedFileIfAbsent, writeTypedFile } from "./block-api.js";
25
+ import { assertSubstrateName, resolveContextDir, SCHEMAS_DIR, tryResolveContextDir } from "./context-dir.js";
26
+ import { ValidationError, validateFromFile } from "./schema-validator.js";
27
+ // ── Schema paths (bundled with the package) ──────────────────────────────────
28
+ /**
29
+ * Resolve the bundled config / relations schema files. Resolved relative to
30
+ * this module so it works from both `src/` (via tsx --test) and `dist/`
31
+ * (after `tsc` compile) — schemas live one directory up in either case.
32
+ */
33
+ function bundledSchemaPath(name) {
34
+ const here = path.dirname(fileURLToPath(import.meta.url));
35
+ return path.resolve(here, "..", "schemas", `${name}.schema.json`);
36
+ }
37
+ // ── Substrate-dir-relative file paths ────────────────────────────────────────
38
+ /** `<resolveContextDir(cwd)>/config.json` — substrate-dir-relative; bootstrap
39
+ * pointer at `<cwd>/.pi-context.json` declares the substrate dir per DEC-0015.
40
+ * Previous `.project/`-fixed exemption removed — initProject writes the
41
+ * bootstrap pointer FIRST so the resolver finds the dir before any path-builder
42
+ * runs. */
43
+ function configPath(cwd) {
44
+ return path.join(resolveContextDir(cwd), "config.json");
45
+ }
46
+ /** `<resolveContextDir(cwd)>/relations.json` — same substrate-dir-relative
47
+ * resolution as configPath; previous `.project/`-fixed exemption removed for
48
+ * DEC-0015 compliance. */
49
+ function relationsPath(cwd) {
50
+ return path.join(resolveContextDir(cwd), "relations.json");
51
+ }
52
+ // ── Loaders ─────────────────────────────────────────────────────────────────
53
+ /**
54
+ * Resolve the substrate root for `cwd` — pointer-canonical (DEC-0045 / FGAP-079).
55
+ *
56
+ * Returns `resolveContextDir(cwd)` (the `.pi-context.json` pointer dir) for ALL
57
+ * substrate path resolution. `config.root` is NOT a path-resolution input:
58
+ * config.json and relations.json are pinned to the pointer dir by necessity
59
+ * (`projectRoot` must read config.json to learn `config.root`, so config.json
60
+ * cannot itself relocate), so honoring `config.root` for blocks/schemas would
61
+ * split the substrate across two dirs (config/relations at the pointer; blocks/
62
+ * schemas at config.root) — incoherent. Substrate relocation is properly done
63
+ * by flipping the pointer (future `/context migrate`, DEC-0036 step-5), which
64
+ * moves the whole substrate coherently.
65
+ *
66
+ * `config.root` is retained as optional config DATA (DEC-0041 — config carries
67
+ * the substrate dir name for display/round-trip) but is unused for resolution.
68
+ * In practice `adoptConception` sets `config.root` == the pointer dir, so this
69
+ * is behavior-preserving wherever the two coincide; it removes the latent
70
+ * divergence (writes honoring config.root while reads/validation use the
71
+ * pointer) that surfaced under FGAP-077.
72
+ */
73
+ /**
74
+ * Load and AJV-validate `<cwd>/.project/config.json` against the bundled
75
+ * config.schema.json. Returns null when the file is absent. Throws
76
+ * ValidationError on schema failure; throws Error with file context on
77
+ * read/parse failure.
78
+ */
79
+ export function loadConfig(cwd) {
80
+ const root = tryResolveContextDir(cwd);
81
+ if (root === null)
82
+ return null;
83
+ const p = path.join(root, "config.json");
84
+ if (!fs.existsSync(p))
85
+ return null;
86
+ let raw;
87
+ try {
88
+ raw = fs.readFileSync(p, "utf-8");
89
+ }
90
+ catch (err) {
91
+ throw new Error(`loadConfig: failed to read ${p}: ${err instanceof Error ? err.message : String(err)}`);
92
+ }
93
+ let data;
94
+ try {
95
+ data = JSON.parse(raw);
96
+ }
97
+ catch (err) {
98
+ throw new Error(`loadConfig: invalid JSON in ${p}: ${err instanceof Error ? err.message : String(err)}`);
99
+ }
100
+ validateFromFile(bundledSchemaPath("config"), data, `config.json (${p})`);
101
+ return data;
102
+ }
103
+ // ── Installed-asset materialization (shared with installContext; DEC-0042 / FGAP-095) ──
104
+ /**
105
+ * Destination path of an installed SCHEMA asset — `<root>/<SCHEMAS_DIR>/<name>.schema.json`.
106
+ * `root` is the already-resolved substrate root (`projectRoot(cwd)`). This is the
107
+ * single source of the schema-dest derivation; `installContext` and
108
+ * `findUnmaterializedAssets` both route through it so installer and detector
109
+ * cannot drift.
110
+ */
111
+ export function installedSchemaDestPath(root, name) {
112
+ assertSubstrateName(name);
113
+ return path.join(root, SCHEMAS_DIR, `${name}.schema.json`);
114
+ }
115
+ /** Destination path of an installed BLOCK asset — `<root>/<name>.json`. `root` is `projectRoot(cwd)`. */
116
+ export function installedBlockDestPath(root, name) {
117
+ assertSubstrateName(name);
118
+ return path.join(root, `${name}.json`);
119
+ }
120
+ /**
121
+ * The declared-but-not-materialized installed assets for `config`: the subset of
122
+ * `config.installed_schemas` / `installed_blocks` whose destination file is
123
+ * absent on disk. Empty arrays when everything declared is present (or nothing
124
+ * is declared — vacuously materialized). Pure read, no copy — answers "is the
125
+ * substrate fully installed?" via the SAME path derivation `installContext`
126
+ * writes to, so the question and the act cannot diverge.
127
+ */
128
+ export function findUnmaterializedAssets(cwd, config) {
129
+ const root = tryResolveContextDir(cwd);
130
+ if (root === null)
131
+ return { schemas: [], blocks: [] };
132
+ const schemas = (config.installed_schemas ?? []).filter((name) => !fs.existsSync(installedSchemaDestPath(root, name)));
133
+ const blocks = (config.installed_blocks ?? []).filter((name) => !fs.existsSync(installedBlockDestPath(root, name)));
134
+ return { schemas, blocks };
135
+ }
136
+ /**
137
+ * Load and AJV-validate `<cwd>/.project/relations.json`. Returns [] when the
138
+ * file is absent. Schema shape is `Edge[]` at the top level (array, not
139
+ * `{edges: [...]}`); the validator enforces this.
140
+ */
141
+ export function loadRelations(cwd) {
142
+ const root = tryResolveContextDir(cwd);
143
+ if (root === null)
144
+ return [];
145
+ const p = path.join(root, "relations.json");
146
+ if (!fs.existsSync(p))
147
+ return [];
148
+ let raw;
149
+ try {
150
+ raw = fs.readFileSync(p, "utf-8");
151
+ }
152
+ catch (err) {
153
+ throw new Error(`loadRelations: failed to read ${p}: ${err instanceof Error ? err.message : String(err)}`);
154
+ }
155
+ let data;
156
+ try {
157
+ data = JSON.parse(raw);
158
+ }
159
+ catch (err) {
160
+ throw new Error(`loadRelations: invalid JSON in ${p}: ${err instanceof Error ? err.message : String(err)}`);
161
+ }
162
+ validateFromFile(bundledSchemaPath("relations"), data, `relations.json (${p})`);
163
+ return data;
164
+ }
165
+ // ── Writers (atomic, AJV-validated against bundled schemas) ──────────────────
166
+ /**
167
+ * Atomic, AJV-validated write of `<resolveContextDir(cwd)>/relations.json`
168
+ * (top-level `Edge[]` array). Delegates to block-api's `writeTypedFile` against
169
+ * the bundled relations schema. Importing `writeTypedFile` here is cycle-safe:
170
+ * block-api imports only `resolveContextDir`-class path constants and does NOT
171
+ * import context, so this module remains at a strictly lower layer in the read
172
+ * direction while reaching up to block-api for the shared atomic-write surface.
173
+ *
174
+ * `ctx` is threaded through for attestation parity with the rest of the write
175
+ * surface; the relations schema is a flat array and declares no envelope author
176
+ * fields, so stamping is a structural no-op today (consistent with the
177
+ * top-level-array stamping semantics documented in block-api).
178
+ */
179
+ export function writeRelations(cwd, edges, ctx) {
180
+ writeTypedFile(relationsPath(cwd), bundledSchemaPath("relations"), edges, ctx, "relations.json");
181
+ }
182
+ /**
183
+ * Composite identity of an edge for append-if-absent dedup: the
184
+ * (parent, child, relation_type) triple. `ordinal` is intentionally NOT part
185
+ * of identity — two edges differing only in `ordinal` are the same relationship
186
+ * for dedup purposes (re-filing with a different sibling-order is a no-op, not a
187
+ * second edge). The space separator is safe because none of the three id
188
+ * components contains a space in any registered substrate vocabulary.
189
+ */
190
+ const identityKey = (e) => {
191
+ const r = e;
192
+ return `${r.parent} ${r.child} ${r.relation_type}`;
193
+ };
194
+ /**
195
+ * Append closure-table edges to `<resolveContextDir(cwd)>/relations.json`,
196
+ * skipping any whose (parent, child, relation_type) triple already exists
197
+ * on-disk OR appears earlier in this same batch. The write-twin of
198
+ * `writeRelations` (whole-file replace) for the additive case. Creates
199
+ * relations.json (flat `Edge[]` array) when absent.
200
+ *
201
+ * Guards are DEFERRED to `validateContext` by design: this surface performs
202
+ * only AJV-shape validation (whole-array against the relations schema) and the
203
+ * exact-duplicate-no-op above. Reference integrity — endpoints resolve,
204
+ * relation_type is registered, no cycle under hierarchy relation_types — is NOT
205
+ * checked here. This is forced by the layer graph: `appendRelations` lives in
206
+ * context, which imports only block-api; endpoint resolution needs
207
+ * `buildIdIndex` from context-sdk, and importing context-sdk here would invert
208
+ * the dependency direction. Registration / endpoint / cycle violations are
209
+ * caught later by `validateContext`.
210
+ *
211
+ * `ctx` is threaded for attestation parity with `writeRelations`; the relations
212
+ * schema is a flat array with no envelope author fields, so stamping is a
213
+ * structural no-op today (consistent with the top-level-array stamping
214
+ * semantics documented in block-api).
215
+ */
216
+ export function appendRelations(cwd, edges, ctx) {
217
+ return appendManyToTypedFileIfAbsent(relationsPath(cwd), bundledSchemaPath("relations"), null, edges, identityKey, ctx, "relations.json");
218
+ }
219
+ /**
220
+ * Append a single closure-table edge. Convenience over `appendRelations`:
221
+ * returns `{ appended }` reflecting whether the edge was new (true) or an
222
+ * exact (parent, child, relation_type) duplicate of an existing edge (false →
223
+ * no-op). Same deferred-guard semantics as `appendRelations`.
224
+ */
225
+ export function appendRelation(cwd, edge, ctx) {
226
+ const r = appendRelations(cwd, [edge], ctx);
227
+ return { appended: r.appended > 0 };
228
+ }
229
+ /**
230
+ * Atomic, AJV-validated write of `<resolveContextDir(cwd)>/config.json`.
231
+ * Delegates to block-api's `writeTypedFile` against the bundled config schema.
232
+ * Same cycle-safety reasoning as `writeRelations`.
233
+ */
234
+ export function writeConfig(cwd, config, ctx) {
235
+ writeTypedFile(configPath(cwd), bundledSchemaPath("config"), config, ctx, "config.json");
236
+ }
237
+ /**
238
+ * accept-all: adopt the package's canonical packaged conception
239
+ * (samples/conception.json) as this substrate's config.json (DEC-0037 / DEC-0038
240
+ * accept-all mode). Writes config ONLY (does not install assets — run
241
+ * installContext after). Idempotent: never clobbers an existing config
242
+ * (DEC-0011/0038 offer-don't-impose). The conception ships NO root (it is a
243
+ * template, not an instance — DEC-0041/FGAP-094); this function SETS root to the
244
+ * ACTUAL substrate dir name (resolved from the .pi-context.json pointer) on the
245
+ * adopted config. Validated via writeConfig (whole-config AJV).
246
+ */
247
+ export function adoptConception(cwd) {
248
+ const contextDirAbs = resolveContextDir(cwd); // throws BootstrapNotFoundError if no pointer
249
+ const root = path.relative(cwd, contextDirAbs);
250
+ const cfgPath = configPath(cwd);
251
+ const existing = loadConfig(cwd);
252
+ if (existing) {
253
+ return {
254
+ adopted: false,
255
+ configPath: path.relative(cwd, cfgPath),
256
+ root,
257
+ schemaCount: (existing.installed_schemas ?? []).length,
258
+ blockCount: (existing.installed_blocks ?? []).length,
259
+ };
260
+ }
261
+ const here = path.dirname(fileURLToPath(import.meta.url));
262
+ const samplesRoot = path.resolve(here, "..", "samples");
263
+ const conception = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
264
+ conception.root = root; // SET root from the resolved substrate dir — the conception template ships none (DEC-0041)
265
+ writeConfig(cwd, conception);
266
+ return {
267
+ adopted: true,
268
+ configPath: path.relative(cwd, cfgPath),
269
+ root,
270
+ schemaCount: (conception.installed_schemas ?? []).length,
271
+ blockCount: (conception.installed_blocks ?? []).length,
272
+ };
273
+ }
274
+ /**
275
+ * Per-registry storage descriptors. Drives the kind-dispatch in
276
+ * `amendConfigEntry`; the keys enumerate every addressable `AmendRegistry`.
277
+ */
278
+ const REGISTRY_DESCRIPTORS = {
279
+ block_kinds: { kind: "keyed-array", idField: "canonical_id" },
280
+ relation_types: { kind: "keyed-array", idField: "canonical_id" },
281
+ lenses: { kind: "keyed-array", idField: "id" },
282
+ layers: { kind: "keyed-array", idField: "id" },
283
+ invariants: { kind: "keyed-array", idField: "id" },
284
+ status_buckets: { kind: "map" },
285
+ display_strings: { kind: "map" },
286
+ naming: { kind: "map" },
287
+ installed_schemas: { kind: "string-array" },
288
+ installed_blocks: { kind: "string-array" },
289
+ hierarchy: { kind: "value-array" },
290
+ };
291
+ /** Canonical identity join for a hierarchy triple (the `value-array` kind). */
292
+ function hierarchyKey(h) {
293
+ return `${h.parent_block} ${h.child_block} ${h.relation_type}`;
294
+ }
295
+ /**
296
+ * Scoped add / replace / remove of ONE entry in ONE config registry.
297
+ *
298
+ * Two guard tiers, both decidable here:
299
+ * 1. OP-CORRECTNESS (local, in-config invariants — the analog of
300
+ * rename-canonical-id's existence/collision guards): `add` requires the key
301
+ * ABSENT (collision → throw); `replace` / `remove` require it PRESENT
302
+ * (missing → throw). Every such throw fires BEFORE any write, so a guard
303
+ * failure leaves config.json byte-untouched.
304
+ * 2. SHAPE: automatic + free via `writeConfig`'s whole-config AJV validation —
305
+ * a malformed entry (e.g. a relation_type missing its required `category`,
306
+ * or a status_bucket value outside the enum) fails the write and throws
307
+ * ValidationError, again leaving the file unchanged (atomic tmp+rename).
308
+ *
309
+ * Cross-registry referential integrity (removing a relation_type / lens / layer
310
+ * / block_kind that is still referenced by an edge or another registry) is
311
+ * DEFERRED to `validateContext` by design — the same layer-graph constraint that
312
+ * defers `appendRelation`'s integrity checks (this module imports only block-api;
313
+ * referential checks need buildIdIndex from context-sdk, which would invert the
314
+ * dependency). `remove` therefore emits NO write-time warning.
315
+ *
316
+ * Mutation works on a deep clone of the loaded config (the
317
+ * load → JSON.parse(JSON.stringify(config)) → mutate → writeConfig precedent
318
+ * from rename-canonical-id); the original is never touched until the single
319
+ * `writeConfig` at the end (skipped under `dryRun`).
320
+ *
321
+ * `ctx` is forwarded to `writeConfig` for attestation parity with the rest of
322
+ * the write surface; the config schema declares no envelope author fields, so
323
+ * stamping is a structural no-op today (consistent with `writeConfig`).
324
+ *
325
+ * @param registry one of the eleven `AmendRegistry` names (validated; scalars
326
+ * `schema_version` / `root` are rejected as non-registries).
327
+ * @param operation `add` | `replace` | `remove` (validated).
328
+ * @param key the entry key — id for keyed-array, map key for map, the
329
+ * string value for string-array, a JSON-encoded triple for the
330
+ * hierarchy value-array.
331
+ * @param entry the entry payload: object for keyed-array / value-array, the
332
+ * value for map; required for `add` / `replace`, omitted for
333
+ * `remove`. For string-array, when provided it must equal `key`.
334
+ * @throws Error on unknown registry / operation, missing entry for add/replace,
335
+ * absent config, OP-CORRECTNESS violation, or key/entry divergence.
336
+ * @throws ValidationError (from `writeConfig`) on a SHAPE violation.
337
+ */
338
+ export function amendConfigEntry(cwd, registry, operation, key, entry, ctx, opts) {
339
+ // (1) Discriminator validation.
340
+ const descriptor = REGISTRY_DESCRIPTORS[registry];
341
+ if (!descriptor) {
342
+ const addressable = Object.keys(REGISTRY_DESCRIPTORS).join(" | ");
343
+ throw new Error(`amendConfigEntry: unknown registry '${registry}' — addressable registries: ${addressable} (scalars schema_version / root are not registries)`);
344
+ }
345
+ if (operation !== "add" && operation !== "replace" && operation !== "remove") {
346
+ throw new Error(`amendConfigEntry: unknown operation '${operation}' — expected add | replace | remove`);
347
+ }
348
+ const op = operation;
349
+ const reg = registry;
350
+ // string-array registries (installed_schemas/installed_blocks) carry the value
351
+ // AS the key, so add needs no separate entry payload; every other kind does.
352
+ if ((op === "add" || op === "replace") && entry === undefined && descriptor.kind !== "string-array") {
353
+ throw new Error(`amendConfigEntry: operation '${op}' requires an entry payload`);
354
+ }
355
+ // (2) Load + (3) deep clone (mutate the clone, write once at the end).
356
+ const config = loadConfig(cwd);
357
+ if (!config) {
358
+ throw new Error("amendConfigEntry: no config.json");
359
+ }
360
+ const nextConfig = JSON.parse(JSON.stringify(config));
361
+ // `nc` is the untyped view used for dynamic-registry indexing below.
362
+ const nc = nextConfig;
363
+ let modified = false;
364
+ let previousValue;
365
+ // (4) Locate + OP-CORRECTNESS-guard + mutate the clone, per descriptor kind.
366
+ if (descriptor.kind === "keyed-array") {
367
+ const idField = descriptor.idField;
368
+ const arr = nc[reg] ?? [];
369
+ const idx = arr.findIndex((e) => e[idField] === key);
370
+ const present = idx !== -1;
371
+ if (op === "add" || op === "replace") {
372
+ const obj = entry;
373
+ if (!obj || typeof obj !== "object" || obj[idField] !== key) {
374
+ throw new Error(`amendConfigEntry: entry.${idField} (${obj && typeof obj === "object" ? String(obj[idField]) : "missing"}) must equal key (${key}) for registry '${reg}'`);
375
+ }
376
+ }
377
+ if (op === "add") {
378
+ if (present)
379
+ throw new Error(`amendConfigEntry: add collision — ${reg}[${idField}=${key}] already exists`);
380
+ if (!Array.isArray(nc[reg]))
381
+ nc[reg] = [];
382
+ nc[reg].push(entry);
383
+ modified = true;
384
+ }
385
+ else if (op === "replace") {
386
+ if (!present)
387
+ throw new Error(`amendConfigEntry: replace target missing — ${reg}[${idField}=${key}] not found`);
388
+ previousValue = arr[idx];
389
+ arr.splice(idx, 1, entry);
390
+ modified = true;
391
+ }
392
+ else {
393
+ if (!present)
394
+ throw new Error(`amendConfigEntry: remove target missing — ${reg}[${idField}=${key}] not found`);
395
+ previousValue = arr[idx];
396
+ arr.splice(idx, 1);
397
+ modified = true;
398
+ }
399
+ }
400
+ else if (descriptor.kind === "map") {
401
+ const m = nc[reg] ?? {};
402
+ const present = Object.hasOwn(m, key);
403
+ if (op === "add" || op === "replace") {
404
+ if (op === "add" && present)
405
+ throw new Error(`amendConfigEntry: add collision — ${reg}[${key}] already exists`);
406
+ if (op === "replace" && !present) {
407
+ throw new Error(`amendConfigEntry: replace target missing — ${reg}[${key}] not found`);
408
+ }
409
+ previousValue = present ? m[key] : undefined;
410
+ if (!nc[reg])
411
+ nc[reg] = {};
412
+ nc[reg][key] = entry;
413
+ modified = true;
414
+ }
415
+ else {
416
+ if (!present)
417
+ throw new Error(`amendConfigEntry: remove target missing — ${reg}[${key}] not found`);
418
+ previousValue = m[key];
419
+ delete nc[reg][key];
420
+ modified = true;
421
+ }
422
+ }
423
+ else if (descriptor.kind === "string-array") {
424
+ const arr = nc[reg] ?? [];
425
+ if (entry !== undefined && entry !== key) {
426
+ throw new Error(`amendConfigEntry: entry (${String(entry)}) must equal key (${key}) for string-array '${reg}'`);
427
+ }
428
+ const present = arr.includes(key);
429
+ if (op === "add") {
430
+ if (present)
431
+ throw new Error(`amendConfigEntry: add collision — ${reg} already contains '${key}'`);
432
+ if (!Array.isArray(nc[reg]))
433
+ nc[reg] = [];
434
+ nc[reg].push(key);
435
+ modified = true;
436
+ }
437
+ else if (op === "replace") {
438
+ throw new Error(`amendConfigEntry: replace is meaningless for string-array '${reg}' (value IS the key) — use remove + add`);
439
+ }
440
+ else {
441
+ if (!present)
442
+ throw new Error(`amendConfigEntry: remove target missing — ${reg} does not contain '${key}'`);
443
+ previousValue = key;
444
+ nc[reg] = arr.filter((v) => v !== key);
445
+ modified = true;
446
+ }
447
+ }
448
+ else {
449
+ // value-array (hierarchy): identity is the canonical (parent,child,relation_type) join.
450
+ let target;
451
+ try {
452
+ target = JSON.parse(key);
453
+ }
454
+ catch (err) {
455
+ throw new Error(`amendConfigEntry: key for value-array '${reg}' must be a JSON {parent_block, child_block, relation_type}: ${err instanceof Error ? err.message : String(err)}`);
456
+ }
457
+ const arr = nc[reg] ?? [];
458
+ if (op === "add" || op === "replace") {
459
+ const obj = entry;
460
+ if (!obj || typeof obj !== "object" || hierarchyKey(obj) !== hierarchyKey(target)) {
461
+ throw new Error(`amendConfigEntry: entry must hk-match key (${key}) for value-array '${reg}'`);
462
+ }
463
+ }
464
+ const idx = arr.findIndex((e) => hierarchyKey(e) === hierarchyKey(target));
465
+ const present = idx !== -1;
466
+ if (op === "add") {
467
+ if (present)
468
+ throw new Error(`amendConfigEntry: add collision — ${reg} already contains ${key}`);
469
+ if (!Array.isArray(nc[reg]))
470
+ nc[reg] = [];
471
+ nc[reg].push(entry);
472
+ modified = true;
473
+ }
474
+ else if (op === "replace") {
475
+ if (!present)
476
+ throw new Error(`amendConfigEntry: replace target missing — ${reg} has no ${key}`);
477
+ previousValue = arr[idx];
478
+ arr.splice(idx, 1, entry);
479
+ modified = true;
480
+ }
481
+ else {
482
+ if (!present)
483
+ throw new Error(`amendConfigEntry: remove target missing — ${reg} has no ${key}`);
484
+ previousValue = arr[idx];
485
+ arr.splice(idx, 1);
486
+ modified = true;
487
+ }
488
+ }
489
+ // (5) SHAPE validation + single config write. Under dryRun we still validate
490
+ // the would-be-written config against the SAME schema writeConfig uses (so a
491
+ // dry-run surfaces shape errors), but write nothing — keeping ONE validation
492
+ // path (no parallel re-implementation of the op for dry-run previews).
493
+ if (modified) {
494
+ if (opts?.dryRun) {
495
+ validateFromFile(bundledSchemaPath("config"), nextConfig, "config.json (dry-run)");
496
+ }
497
+ else {
498
+ writeConfig(cwd, nextConfig, ctx);
499
+ }
500
+ }
501
+ // (6) Outcome.
502
+ return {
503
+ modified,
504
+ operation: op,
505
+ registry: reg,
506
+ key,
507
+ ...(previousValue !== undefined ? { previousValue } : {}),
508
+ };
509
+ }
510
+ const contextCache = new Map();
511
+ /** Return mtime of file in ms, or 0 when absent. Catches ENOENT to keep
512
+ * "missing" indistinguishable from "never modified" without throwing. */
513
+ function safeMtimeMs(p) {
514
+ try {
515
+ return fs.statSync(p).mtimeMs;
516
+ }
517
+ catch {
518
+ return 0;
519
+ }
520
+ }
521
+ /**
522
+ * Cached `(loadConfig, loadRelations)` pair, keyed by absolute cwd. Cache
523
+ * invalidates when either `config.json` or `relations.json` mtime changes —
524
+ * deleting the file (mtime → 0) also invalidates so a config-removal is
525
+ * picked up. Direct, intentional cache flush is not exposed; tests that
526
+ * need to bypass call `loadConfig`/`loadRelations` directly.
527
+ */
528
+ export function loadContext(cwd) {
529
+ const key = path.resolve(cwd);
530
+ const bMtime = safeMtimeMs(path.join(cwd, ".pi-context.json"));
531
+ // Chokepoint guard (FGAP-074 C3): when no `.pi-context.json` bootstrap
532
+ // pointer resolves, `configPath`/`relationsPath` would throw
533
+ // BootstrapNotFoundError. Degrade to an empty context instead so READ /
534
+ // VALIDATE / SNAPSHOT callers reaching here indirectly
535
+ // (validateContext → resolveStatusVocabulary → loadContext, currentState,
536
+ // etc.) survive pointer-less. Cached by the absent bootstrap mtime so the
537
+ // degraded value invalidates the instant a pointer lands.
538
+ const root = tryResolveContextDir(cwd);
539
+ if (root === null) {
540
+ const hit = contextCache.get(key);
541
+ if (hit && hit.bootstrapMtimeMs === bMtime && hit.configMtimeMs === 0 && hit.relationsMtimeMs === 0) {
542
+ return hit.value;
543
+ }
544
+ const value = { config: null, relations: [] };
545
+ contextCache.set(key, {
546
+ bootstrapMtimeMs: bMtime,
547
+ configMtimeMs: 0,
548
+ relationsMtimeMs: 0,
549
+ value,
550
+ });
551
+ return value;
552
+ }
553
+ const cMtime = safeMtimeMs(configPath(cwd));
554
+ const rMtime = safeMtimeMs(relationsPath(cwd));
555
+ const hit = contextCache.get(key);
556
+ if (hit && hit.bootstrapMtimeMs === bMtime && hit.configMtimeMs === cMtime && hit.relationsMtimeMs === rMtime) {
557
+ return hit.value;
558
+ }
559
+ const value = {
560
+ config: loadConfig(cwd),
561
+ relations: loadRelations(cwd),
562
+ };
563
+ contextCache.set(key, {
564
+ bootstrapMtimeMs: bMtime,
565
+ configMtimeMs: cMtime,
566
+ relationsMtimeMs: rMtime,
567
+ value,
568
+ });
569
+ return value;
570
+ }
571
+ // ── Edge synthesis + lens projection ─────────────────────────────────────────
572
+ /**
573
+ * Synthesize edges from a per-item field for a derived lens. Returns [] when
574
+ * `lens.derived_from_field` is null / undefined (caller should source edges
575
+ * from authored relations instead).
576
+ */
577
+ export function synthesizeFromField(lens, items) {
578
+ if (lens.derived_from_field === null || lens.derived_from_field === undefined)
579
+ return [];
580
+ const field = lens.derived_from_field;
581
+ const relationType = lens.relation_type ?? lens.id;
582
+ const out = [];
583
+ for (const item of items) {
584
+ const v = item[field];
585
+ if (typeof v === "string") {
586
+ out.push({ parent: v, child: item.id, relation_type: relationType });
587
+ }
588
+ }
589
+ return out;
590
+ }
591
+ /**
592
+ * Edges visible to traversal/projection for a given lens. Auto-derived lens →
593
+ * edges synthesized from items at read-time. Hand-curated lens → filter
594
+ * authored edges by relation_type.
595
+ */
596
+ export function edgesForLens(lens, items, authoredEdges) {
597
+ if (lens.derived_from_field !== null && lens.derived_from_field !== undefined) {
598
+ return synthesizeFromField(lens, items);
599
+ }
600
+ const relationType = lens.relation_type ?? lens.id;
601
+ return authoredEdges.filter((e) => e.relation_type === relationType);
602
+ }
603
+ /**
604
+ * Walk descendants of `parentId` along edges of a given relation_type. Cycle-
605
+ * safe via a visited-set: revisit short-circuits, so a back-edge does not
606
+ * loop, but `validateRelations` is the surface that flags the cycle.
607
+ */
608
+ export function walkDescendants(parentId, relationType, edges) {
609
+ const out = [];
610
+ const visited = new Set();
611
+ const stack = [parentId];
612
+ while (stack.length > 0) {
613
+ const node = stack.pop();
614
+ if (node === undefined || visited.has(node))
615
+ continue;
616
+ visited.add(node);
617
+ for (const e of edges) {
618
+ if (e.parent === node && e.relation_type === relationType) {
619
+ out.push(e.child);
620
+ stack.push(e.child);
621
+ }
622
+ }
623
+ }
624
+ return out;
625
+ }
626
+ /**
627
+ * Walk ancestors of `itemId` along edges of a given relation_type — the
628
+ * reverse-direction traversal of walkDescendants. Iterates edges where
629
+ * `e.child === node && e.relation_type === relationType` and recurses on
630
+ * `e.parent`. Cycle-safe via a visited-set mirroring walkDescendants:
631
+ * revisit short-circuits, so a back-edge does not loop, but
632
+ * `validateRelations` is the surface that flags the cycle.
633
+ *
634
+ * Returns the ancestor id list (may include multiple distinct parents
635
+ * when the closure-table edge set is a DAG with merges). Order is
636
+ * traversal-order (closest ancestors first) matching walkDescendants'
637
+ * BFS-like semantic; callers MUST treat the result as a set or sort if
638
+ * deterministic order is required.
639
+ *
640
+ * Pure function — operates on the Edge[] argument; does NOT read substrate.
641
+ */
642
+ export function walkAncestors(itemId, relationType, edges) {
643
+ const out = [];
644
+ const visited = new Set();
645
+ const stack = [itemId];
646
+ while (stack.length > 0) {
647
+ const node = stack.pop();
648
+ if (node === undefined || visited.has(node))
649
+ continue;
650
+ visited.add(node);
651
+ for (const e of edges) {
652
+ if (e.child === node && e.relation_type === relationType) {
653
+ out.push(e.parent);
654
+ stack.push(e.parent);
655
+ }
656
+ }
657
+ }
658
+ return out;
659
+ }
660
+ /**
661
+ * Find all closure-table edges incident on `itemId`, returning the full
662
+ * Edge[] records — NOT id arrays. Distinguishing semantic vs walkDescendants
663
+ * and walkAncestors which return string[] id chains for traversal; this
664
+ * primitive surfaces edge-level inspection (relation_type + ordinal preserved
665
+ * per record) for callers that need the relationship-typed view rather than
666
+ * the projected id set.
667
+ *
668
+ * Direction semantics:
669
+ * "inbound" — edges where `e.child === itemId` (edges pointing AT itemId)
670
+ * "outbound" — edges where `e.parent === itemId` (edges FROM itemId)
671
+ * "both" — union of inbound + outbound (default).
672
+ *
673
+ * Self-loop handling: an edge with `parent === child === itemId` is a
674
+ * self-loop. Under "both" direction such an edge matches BOTH the inbound
675
+ * and outbound filters. The implementation iterates edges once with a
676
+ * single inclusion predicate per direction, so a self-loop is returned
677
+ * EXACTLY ONCE under "both" — never duplicated. This is the cleaner
678
+ * option: callers reasoning about reference-uniqueness do not need to
679
+ * dedup, and an edge identity in relations.json maps to a single result
680
+ * record.
681
+ *
682
+ * Multiple relation_types between the same (parent, child) pair preserve
683
+ * as DISTINCT result entries — each edge is a separate relationship
684
+ * record, queries on it must not collapse.
685
+ *
686
+ * Pure function — operates on the Edge[] argument; does NOT read substrate.
687
+ */
688
+ export function findReferences(itemId, edges, direction = "both") {
689
+ if (direction === "inbound") {
690
+ return edges.filter((e) => e.child === itemId);
691
+ }
692
+ if (direction === "outbound") {
693
+ return edges.filter((e) => e.parent === itemId);
694
+ }
695
+ // "both": single-pass predicate; self-loop matches once via the OR.
696
+ return edges.filter((e) => e.child === itemId || e.parent === itemId);
697
+ }
698
+ /**
699
+ * Project items into bins under a lens. Items reachable through `lensEdges`
700
+ * with parent ∈ `lens.bins` go to that bin; remaining items go to
701
+ * "(uncategorized)". Caller picks whether to include the uncategorized
702
+ * bucket via `lens.render_uncategorized`.
703
+ */
704
+ export function groupByLens(items, lens, lensEdges) {
705
+ const grouped = new Map();
706
+ for (const bin of lens.bins)
707
+ grouped.set(bin, []);
708
+ grouped.set("(uncategorized)", []);
709
+ const itemById = new Map(items.map((i) => [i.id, i]));
710
+ const placedIds = new Set();
711
+ for (const e of lensEdges) {
712
+ const item = itemById.get(e.child);
713
+ if (item && lens.bins.includes(e.parent)) {
714
+ grouped.get(e.parent)?.push(item);
715
+ placedIds.add(item.id);
716
+ }
717
+ }
718
+ for (const item of items) {
719
+ if (!placedIds.has(item.id))
720
+ grouped.get("(uncategorized)")?.push(item);
721
+ }
722
+ return grouped;
723
+ }
724
+ // ── Display name resolution ──────────────────────────────────────────────────
725
+ /**
726
+ * Universal display-name lookup. Resolution order:
727
+ * 1. `cfg.naming[canonicalId]` if present (explicit alias)
728
+ * 2. matching `block_kinds[].display_name` whose `canonical_id` matches
729
+ * 3. canonical id itself (POC A identity-vs-display decoupling)
730
+ */
731
+ export function displayName(cfg, canonicalId) {
732
+ if (!cfg)
733
+ return canonicalId;
734
+ if (cfg.naming && Object.hasOwn(cfg.naming, canonicalId)) {
735
+ return cfg.naming[canonicalId];
736
+ }
737
+ for (const bk of cfg.block_kinds) {
738
+ if (bk.canonical_id === canonicalId)
739
+ return bk.display_name;
740
+ }
741
+ return canonicalId;
742
+ }
743
+ // ── Curation surface (uncategorized listing) ─────────────────────────────────
744
+ /**
745
+ * List items that fell to the (uncategorized) bucket plus a template for
746
+ * emitting append-block-item payloads. Suggestion intentionally carries the
747
+ * edge payload only — the calling ceremony decides which bin to file under.
748
+ */
749
+ export function listUncategorized(lens, grouped) {
750
+ const uncategorized = grouped.get("(uncategorized)") ?? [];
751
+ const relationType = lens.relation_type ?? lens.id;
752
+ const suggestionTemplate = (binName, item) => ({
753
+ payload: { parent: binName, child: item.id, relation_type: relationType },
754
+ reason: `item '${item.id}' falls outside lens '${lens.id}' bins; suggested placement '${binName}'`,
755
+ });
756
+ return { uncategorized, suggestionTemplate };
757
+ }
758
+ // ── validateRelations ────────────────────────────────────────────────────────
759
+ /**
760
+ * Validate authored edges against `config` registries (lenses + hierarchy +
761
+ * relation_types) and the cross-block id index supplied by the caller.
762
+ * Emits seven structured issue codes:
763
+ * - edge_unknown_relation_type
764
+ * - edge_parent_not_in_bins (lens edges)
765
+ * - edge_unresolved_parent / edge_parent_wrong_block (hierarchy edges)
766
+ * - edge_unresolved_child / edge_child_wrong_block (lens or hierarchy)
767
+ * - edge_cycle_detected (DFS recursion-stack on hierarchy relation_types)
768
+ *
769
+ * `itemsByBlock` is indexed by `BlockKindDecl.canonical_id` for hierarchy
770
+ * checks (parent / child must reside in the declared block) and by
771
+ * `LensSpec.target` for lens checks. Callers supply the index — this
772
+ * module does not read blocks itself, keeping it independent of block-api.
773
+ */
774
+ export function validateRelations(config, relations, itemsByBlock) {
775
+ const issues = [];
776
+ const lensesByRelType = new Map();
777
+ for (const l of config.lenses ?? []) {
778
+ const rt = l.relation_type ?? l.id;
779
+ lensesByRelType.set(rt, l);
780
+ }
781
+ const hierarchyByRelType = new Map();
782
+ for (const h of config.hierarchy ?? [])
783
+ hierarchyByRelType.set(h.relation_type, h);
784
+ const declaredRelTypes = new Set();
785
+ for (const rt of lensesByRelType.keys())
786
+ declaredRelTypes.add(rt);
787
+ for (const rt of hierarchyByRelType.keys())
788
+ declaredRelTypes.add(rt);
789
+ for (const rt of config.relation_types ?? [])
790
+ declaredRelTypes.add(rt.canonical_id);
791
+ const idIndex = new Map();
792
+ for (const [block, items] of Object.entries(itemsByBlock)) {
793
+ for (const i of items)
794
+ idIndex.set(i.id, block);
795
+ }
796
+ for (const edge of relations) {
797
+ const lens = lensesByRelType.get(edge.relation_type);
798
+ const hier = hierarchyByRelType.get(edge.relation_type);
799
+ if (!declaredRelTypes.has(edge.relation_type)) {
800
+ issues.push({
801
+ code: "edge_unknown_relation_type",
802
+ message: `relation_type '${edge.relation_type}' matches no lens, hierarchy, or relation_types declaration`,
803
+ edge,
804
+ relation_type: edge.relation_type,
805
+ });
806
+ continue;
807
+ }
808
+ if (lens) {
809
+ if (!lens.bins.includes(edge.parent)) {
810
+ issues.push({
811
+ code: "edge_parent_not_in_bins",
812
+ message: `lens-edge parent '${edge.parent}' is not in lens '${lens.id}' bins`,
813
+ edge,
814
+ });
815
+ }
816
+ const childBlock = idIndex.get(edge.child);
817
+ if (!childBlock) {
818
+ issues.push({
819
+ code: "edge_unresolved_child",
820
+ message: `lens-edge child '${edge.child}' not found in any loaded block`,
821
+ edge,
822
+ });
823
+ }
824
+ else if (lens.target && childBlock !== lens.target) {
825
+ issues.push({
826
+ code: "edge_child_wrong_block",
827
+ message: `lens-edge child '${edge.child}' in block '${childBlock}', expected lens.target '${lens.target}'`,
828
+ edge,
829
+ });
830
+ }
831
+ }
832
+ if (hier) {
833
+ const parentBlock = idIndex.get(edge.parent);
834
+ if (!parentBlock) {
835
+ issues.push({
836
+ code: "edge_unresolved_parent",
837
+ message: `hierarchy-edge parent '${edge.parent}' not found in any loaded block`,
838
+ edge,
839
+ });
840
+ }
841
+ else if (parentBlock !== hier.parent_block) {
842
+ issues.push({
843
+ code: "edge_parent_wrong_block",
844
+ message: `hierarchy-edge parent '${edge.parent}' in block '${parentBlock}', expected '${hier.parent_block}'`,
845
+ edge,
846
+ });
847
+ }
848
+ const childBlock = idIndex.get(edge.child);
849
+ if (!childBlock) {
850
+ issues.push({
851
+ code: "edge_unresolved_child",
852
+ message: `hierarchy-edge child '${edge.child}' not found in any loaded block`,
853
+ edge,
854
+ });
855
+ }
856
+ else if (childBlock !== hier.child_block) {
857
+ issues.push({
858
+ code: "edge_child_wrong_block",
859
+ message: `hierarchy-edge child '${edge.child}' in block '${childBlock}', expected '${hier.child_block}'`,
860
+ edge,
861
+ });
862
+ }
863
+ }
864
+ }
865
+ // ── Cycle detection ──────────────────────────────────────────────────
866
+ // Per-relation_type DFS with explicit recursion stack. Only relation_types
867
+ // that have a hierarchy or relation_types declaration with cycle_allowed≠true
868
+ // are checked. Lens-only relation_types do not participate in cycle checks
869
+ // (parents are bin labels, not item ids).
870
+ const cycleAllowed = new Map();
871
+ for (const rt of config.relation_types ?? [])
872
+ cycleAllowed.set(rt.canonical_id, rt.cycle_allowed === true);
873
+ const cycleCandidates = new Set();
874
+ for (const rt of hierarchyByRelType.keys()) {
875
+ if (!cycleAllowed.get(rt))
876
+ cycleCandidates.add(rt);
877
+ }
878
+ for (const rt of config.relation_types ?? []) {
879
+ if (!rt.cycle_allowed && !lensesByRelType.has(rt.canonical_id))
880
+ cycleCandidates.add(rt.canonical_id);
881
+ }
882
+ for (const rt of cycleCandidates) {
883
+ const adj = new Map();
884
+ for (const e of relations) {
885
+ if (e.relation_type !== rt)
886
+ continue;
887
+ const arr = adj.get(e.parent) ?? [];
888
+ arr.push(e.child);
889
+ adj.set(e.parent, arr);
890
+ }
891
+ const visited = new Set();
892
+ const onStack = new Set();
893
+ const reportedCycles = new Set();
894
+ function dfs(node, stack) {
895
+ if (onStack.has(node)) {
896
+ const idx = stack.indexOf(node);
897
+ const cycle = idx >= 0 ? stack.slice(idx).concat(node) : [node, node];
898
+ const key = `${rt}:${cycle.join("→")}`;
899
+ if (!reportedCycles.has(key)) {
900
+ reportedCycles.add(key);
901
+ issues.push({
902
+ code: "edge_cycle_detected",
903
+ message: `cycle detected under relation_type '${rt}': ${cycle.join(" → ")}`,
904
+ relation_type: rt,
905
+ cycle,
906
+ });
907
+ }
908
+ return;
909
+ }
910
+ if (visited.has(node))
911
+ return;
912
+ visited.add(node);
913
+ onStack.add(node);
914
+ stack.push(node);
915
+ for (const child of adj.get(node) ?? []) {
916
+ dfs(child, stack);
917
+ }
918
+ stack.pop();
919
+ onStack.delete(node);
920
+ }
921
+ for (const start of adj.keys()) {
922
+ if (!visited.has(start))
923
+ dfs(start, []);
924
+ }
925
+ }
926
+ const errorCodes = new Set([
927
+ "edge_parent_not_in_bins",
928
+ "edge_unresolved_parent",
929
+ "edge_unresolved_child",
930
+ "edge_unknown_relation_type",
931
+ "edge_parent_wrong_block",
932
+ "edge_child_wrong_block",
933
+ "edge_cycle_detected",
934
+ ]);
935
+ const hasErrors = issues.some((i) => errorCodes.has(i.code));
936
+ const status = hasErrors ? "invalid" : issues.length > 0 ? "warnings" : "clean";
937
+ return { status, issues };
938
+ }
939
+ /**
940
+ * Resolve a composition lens by walking each member declaration:
941
+ * - { lens: <id> }: lookup the named sub-lens in config.lenses; if it's
942
+ * also composition, recurse via resolveCompositionInternal carrying
943
+ * the visited-set; if it's target, load its target block items.
944
+ * - { from: <block>, where: <field-equality> }: read the block items and
945
+ * filter by field-equality predicate.
946
+ *
947
+ * Cycle detection: if a sub-lens reference forms a cycle in the composition
948
+ * graph (lens A → lens B → lens A), throws an Error with message
949
+ * "composition_cycle_detected: <cycle path>".
950
+ *
951
+ * Throws when:
952
+ * - lens is not kind="composition"
953
+ * - composition members reference a non-existent sub-lens id
954
+ * - composition members reference a sub-lens that throws on resolution
955
+ * - cycle detected
956
+ *
957
+ * Caller (loadLensView) catches Error and returns { error: <message> }.
958
+ */
959
+ export function resolveComposition(cwd, lens) {
960
+ if (lens.kind !== "composition") {
961
+ throw new Error(`resolveComposition: lens '${lens.id}' is not kind=composition`);
962
+ }
963
+ const ctx = loadContext(cwd);
964
+ if (!ctx.config) {
965
+ throw new Error("resolveComposition: no .project/config.json");
966
+ }
967
+ return resolveCompositionInternal(cwd, lens, ctx.config, new Set());
968
+ }
969
+ function resolveCompositionInternal(cwd, lens, config, visited) {
970
+ if (visited.has(lens.id)) {
971
+ const cyclePath = [...visited, lens.id].join(" → ");
972
+ throw new Error(`composition_cycle_detected: ${cyclePath}`);
973
+ }
974
+ visited.add(lens.id);
975
+ const members = [];
976
+ const perItemOrigin = new Map();
977
+ const unionedById = new Map();
978
+ const allLenses = config.lenses ?? [];
979
+ for (const member of lens.members ?? []) {
980
+ if (member.lens) {
981
+ const subLens = allLenses.find((l) => l.id === member.lens);
982
+ if (!subLens) {
983
+ throw new Error(`resolveComposition: member references unknown lens '${member.lens}'`);
984
+ }
985
+ let memberItems = [];
986
+ if (subLens.kind === "composition") {
987
+ const subResult = resolveCompositionInternal(cwd, subLens, config, new Set(visited));
988
+ memberItems = subResult.unionedItems;
989
+ for (const [id, origin] of subResult.perItemOrigin) {
990
+ if (!perItemOrigin.has(id))
991
+ perItemOrigin.set(id, origin);
992
+ }
993
+ }
994
+ else {
995
+ // Target lens: read its target block items directly. Don't
996
+ // invoke loadLensView here (avoids importing block-api;
997
+ // context.ts is at a lower layer than lens-view).
998
+ if (!subLens.target) {
999
+ throw new Error(`resolveComposition: sub-lens '${subLens.id}' is kind=target but missing target field`);
1000
+ }
1001
+ memberItems = readBlockItems(cwd, subLens.target);
1002
+ for (const item of memberItems) {
1003
+ if (typeof item.id === "string" && !perItemOrigin.has(item.id)) {
1004
+ perItemOrigin.set(item.id, subLens.target);
1005
+ }
1006
+ }
1007
+ }
1008
+ members.push({ source: { lens: member.lens }, items: memberItems });
1009
+ for (const item of memberItems) {
1010
+ if (typeof item.id === "string" && !unionedById.has(item.id)) {
1011
+ unionedById.set(item.id, item);
1012
+ }
1013
+ }
1014
+ }
1015
+ else if (member.from) {
1016
+ const blockName = member.from;
1017
+ const blockItems = readBlockItems(cwd, blockName);
1018
+ const where = member.where ?? {};
1019
+ const filtered = blockItems.filter((item) => {
1020
+ for (const [k, v] of Object.entries(where)) {
1021
+ if (item[k] !== v)
1022
+ return false;
1023
+ }
1024
+ return true;
1025
+ });
1026
+ members.push({ source: { from: blockName, where: member.where }, items: filtered });
1027
+ for (const item of filtered) {
1028
+ if (typeof item.id === "string") {
1029
+ if (!unionedById.has(item.id))
1030
+ unionedById.set(item.id, item);
1031
+ if (!perItemOrigin.has(item.id))
1032
+ perItemOrigin.set(item.id, blockName);
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+ return { members, unionedItems: [...unionedById.values()], perItemOrigin };
1038
+ }
1039
+ /**
1040
+ * Inline minimal block read used by resolveComposition. Avoids importing
1041
+ * block-api at this layer (context.ts must remain free of
1042
+ * block-api dependencies — block-api imports resolveContextDir from context-dir).
1043
+ */
1044
+ function readBlockItems(cwd, blockName) {
1045
+ assertSubstrateName(blockName);
1046
+ const root = tryResolveContextDir(cwd);
1047
+ if (root === null)
1048
+ return [];
1049
+ const filePath = path.join(root, `${blockName}.json`);
1050
+ if (!fs.existsSync(filePath))
1051
+ return [];
1052
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1053
+ const arrayKey = Object.keys(raw).find((k) => Array.isArray(raw[k]));
1054
+ if (!arrayKey)
1055
+ return [];
1056
+ return raw[arrayKey];
1057
+ }
1058
+ // Re-export ValidationError so consumers don't have to dual-import.
1059
+ export { ValidationError };
1060
+ //# sourceMappingURL=context.js.map