@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.
- package/CHANGELOG.md +23 -0
- package/README.md +195 -0
- package/dist/block-api.d.ts +328 -0
- package/dist/block-api.d.ts.map +1 -0
- package/dist/block-api.js +1167 -0
- package/dist/block-api.js.map +1 -0
- package/dist/block-validation.d.ts +33 -0
- package/dist/block-validation.d.ts.map +1 -0
- package/dist/block-validation.js +213 -0
- package/dist/block-validation.js.map +1 -0
- package/dist/context-dir.d.ts +93 -0
- package/dist/context-dir.d.ts.map +1 -0
- package/dist/context-dir.js +224 -0
- package/dist/context-dir.js.map +1 -0
- package/dist/context-sdk.d.ts +428 -0
- package/dist/context-sdk.d.ts.map +1 -0
- package/dist/context-sdk.js +1327 -0
- package/dist/context-sdk.js.map +1 -0
- package/dist/context.d.ts +497 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +1060 -0
- package/dist/context.js.map +1 -0
- package/dist/dispatch-context.d.ts +112 -0
- package/dist/dispatch-context.d.ts.map +1 -0
- package/dist/dispatch-context.js +119 -0
- package/dist/dispatch-context.js.map +1 -0
- package/dist/execution-context.d.ts +138 -0
- package/dist/execution-context.d.ts.map +1 -0
- package/dist/execution-context.js +151 -0
- package/dist/execution-context.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1655 -0
- package/dist/index.js.map +1 -0
- package/dist/lens-validator.d.ts +50 -0
- package/dist/lens-validator.d.ts.map +1 -0
- package/dist/lens-validator.js +46 -0
- package/dist/lens-validator.js.map +1 -0
- package/dist/lens-view.d.ts +75 -0
- package/dist/lens-view.d.ts.map +1 -0
- package/dist/lens-view.js +253 -0
- package/dist/lens-view.js.map +1 -0
- package/dist/rename-canonical-id.d.ts +64 -0
- package/dist/rename-canonical-id.d.ts.map +1 -0
- package/dist/rename-canonical-id.js +378 -0
- package/dist/rename-canonical-id.js.map +1 -0
- package/dist/roadmap-plan.d.ts +186 -0
- package/dist/roadmap-plan.d.ts.map +1 -0
- package/dist/roadmap-plan.js +534 -0
- package/dist/roadmap-plan.js.map +1 -0
- package/dist/samples-catalog.d.ts +48 -0
- package/dist/samples-catalog.d.ts.map +1 -0
- package/dist/samples-catalog.js +128 -0
- package/dist/samples-catalog.js.map +1 -0
- package/dist/schema-migrations.d.ts +82 -0
- package/dist/schema-migrations.d.ts.map +1 -0
- package/dist/schema-migrations.js +106 -0
- package/dist/schema-migrations.js.map +1 -0
- package/dist/schema-validator.d.ts +76 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +219 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/schema-write.d.ts +122 -0
- package/dist/schema-write.d.ts.map +1 -0
- package/dist/schema-write.js +210 -0
- package/dist/schema-write.js.map +1 -0
- package/dist/status-vocab.d.ts +60 -0
- package/dist/status-vocab.d.ts.map +1 -0
- package/dist/status-vocab.js +109 -0
- package/dist/status-vocab.js.map +1 -0
- package/dist/test-helpers.d.ts +12 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +28 -0
- package/dist/test-helpers.js.map +1 -0
- package/dist/topo.d.ts +19 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +99 -0
- package/dist/topo.js.map +1 -0
- package/dist/update-check.d.ts +6 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +82 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +115 -0
- package/samples/blocks/context-contracts.json +3 -0
- package/samples/blocks/conventions.json +3 -0
- package/samples/blocks/decisions.json +3 -0
- package/samples/blocks/features.json +3 -0
- package/samples/blocks/framework-gaps.json +3 -0
- package/samples/blocks/issues.json +3 -0
- package/samples/blocks/layer-plans.json +3 -0
- package/samples/blocks/phase.json +3 -0
- package/samples/blocks/rationale.json +3 -0
- package/samples/blocks/requirements.json +3 -0
- package/samples/blocks/research.json +3 -0
- package/samples/blocks/spec-reviews.json +3 -0
- package/samples/blocks/story.json +3 -0
- package/samples/blocks/tasks.json +3 -0
- package/samples/blocks/verification.json +3 -0
- package/samples/conception.json +467 -0
- package/samples/schemas/context-contracts.schema.json +89 -0
- package/samples/schemas/conventions.schema.json +34 -0
- package/samples/schemas/decisions.schema.json +87 -0
- package/samples/schemas/features.schema.json +53 -0
- package/samples/schemas/framework-gaps.schema.json +64 -0
- package/samples/schemas/issues.schema.json +39 -0
- package/samples/schemas/layer-plans.schema.json +92 -0
- package/samples/schemas/phase.schema.json +118 -0
- package/samples/schemas/rationale.schema.json +24 -0
- package/samples/schemas/requirements.schema.json +34 -0
- package/samples/schemas/research.schema.json +134 -0
- package/samples/schemas/spec-reviews.schema.json +62 -0
- package/samples/schemas/story.schema.json +73 -0
- package/samples/schemas/tasks.schema.json +34 -0
- package/samples/schemas/verification.schema.json +37 -0
- package/schemas/bootstrap.schema.json +24 -0
- package/schemas/config.schema.json +193 -0
- package/schemas/layer.schema.json +9 -0
- package/schemas/priority.schema.json +9 -0
- package/schemas/relations.schema.json +31 -0
- package/schemas/severity.schema.json +9 -0
- package/schemas/source.schema.json +9 -0
- package/schemas/status.schema.json +9 -0
- package/schemas/verification-method.schema.json +9 -0
- package/skill-narrative.md +130 -0
- package/skills/pi-context/SKILL.md +684 -0
- package/skills/pi-context/references/bundled-resources.md +47 -0
package/dist/context.js
ADDED
|
@@ -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
|