@davidorex/pi-context 0.26.0 → 0.28.1
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 +71 -15
- package/README.md +71 -18
- package/dist/block-api.d.ts +165 -18
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +800 -57
- package/dist/block-api.js.map +1 -1
- package/dist/citation-rot-scanner.d.ts +80 -0
- package/dist/citation-rot-scanner.d.ts.map +1 -0
- package/dist/citation-rot-scanner.js +421 -0
- package/dist/citation-rot-scanner.js.map +1 -0
- package/dist/content-hash.d.ts +25 -0
- package/dist/content-hash.d.ts.map +1 -0
- package/dist/content-hash.js +62 -0
- package/dist/content-hash.js.map +1 -0
- package/dist/context-dir.d.ts +126 -1
- package/dist/context-dir.d.ts.map +1 -1
- package/dist/context-dir.js +208 -4
- package/dist/context-dir.js.map +1 -1
- package/dist/context-registry.d.ts +122 -0
- package/dist/context-registry.d.ts.map +1 -0
- package/dist/context-registry.js +189 -0
- package/dist/context-registry.js.map +1 -0
- package/dist/context-sdk.d.ts +179 -12
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +536 -101
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +222 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +244 -56
- package/dist/context.js.map +1 -1
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.d.ts.map +1 -0
- package/dist/git-env.js +29 -0
- package/dist/git-env.js.map +1 -0
- package/dist/index.d.ts +124 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +440 -1125
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +5 -3
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +121 -0
- package/dist/migration-registry-loader.d.ts.map +1 -0
- package/dist/migration-registry-loader.js +309 -0
- package/dist/migration-registry-loader.js.map +1 -0
- package/dist/migrations-store.d.ts +141 -0
- package/dist/migrations-store.d.ts.map +1 -0
- package/dist/migrations-store.js +206 -0
- package/dist/migrations-store.js.map +1 -0
- package/dist/object-store.d.ts +26 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +104 -0
- package/dist/object-store.js.map +1 -0
- package/dist/ops-registry.d.ts +44 -0
- package/dist/ops-registry.d.ts.map +1 -0
- package/dist/ops-registry.js +1223 -0
- package/dist/ops-registry.js.map +1 -0
- package/dist/orientation.d.ts +16 -0
- package/dist/orientation.d.ts.map +1 -0
- package/dist/orientation.js +49 -0
- package/dist/orientation.js.map +1 -0
- package/dist/promote-item.d.ts +28 -0
- package/dist/promote-item.d.ts.map +1 -0
- package/dist/promote-item.js +257 -0
- package/dist/promote-item.js.map +1 -0
- package/dist/read-element.d.ts +129 -0
- package/dist/read-element.d.ts.map +1 -0
- package/dist/read-element.js +296 -0
- package/dist/read-element.js.map +1 -0
- package/dist/rename-canonical-id.d.ts.map +1 -1
- package/dist/rename-canonical-id.js +22 -13
- package/dist/rename-canonical-id.js.map +1 -1
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +23 -13
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/schema-validator.d.ts +1 -0
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +7 -3
- package/dist/schema-validator.js.map +1 -1
- package/dist/schema-write.d.ts +78 -4
- package/dist/schema-write.d.ts.map +1 -1
- package/dist/schema-write.js +297 -34
- package/dist/schema-write.js.map +1 -1
- package/dist/status-vocab.d.ts +1 -0
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +4 -0
- package/dist/status-vocab.js.map +1 -1
- package/dist/truncate.d.ts +34 -0
- package/dist/truncate.d.ts.map +1 -0
- package/dist/truncate.js +85 -0
- package/dist/truncate.js.map +1 -0
- package/dist/write-schema-migration-tool.d.ts +46 -0
- package/dist/write-schema-migration-tool.d.ts.map +1 -0
- package/dist/write-schema-migration-tool.js +115 -0
- package/dist/write-schema-migration-tool.js.map +1 -0
- package/package.json +35 -2
- package/samples/blocks/work-orders.json +3 -0
- package/samples/conception.json +22 -3
- package/samples/migrations.json +133 -0
- package/samples/schemas/context-contracts.schema.json +19 -4
- package/samples/schemas/conventions.schema.json +68 -12
- package/samples/schemas/decisions.schema.json +125 -28
- package/samples/schemas/features.schema.json +140 -26
- package/samples/schemas/framework-gaps.schema.json +171 -26
- package/samples/schemas/issues.schema.json +86 -13
- package/samples/schemas/layer-plans.schema.json +195 -35
- package/samples/schemas/phase.schema.json +18 -3
- package/samples/schemas/rationale.schema.json +37 -7
- package/samples/schemas/requirements.schema.json +77 -10
- package/samples/schemas/research.schema.json +169 -36
- package/samples/schemas/spec-reviews.schema.json +88 -18
- package/samples/schemas/story.schema.json +18 -3
- package/samples/schemas/tasks.schema.json +66 -10
- package/samples/schemas/verification.schema.json +70 -12
- package/samples/schemas/work-orders.schema.json +195 -0
- package/schemas/bootstrap.schema.json +18 -6
- package/schemas/config.schema.json +58 -26
- package/schemas/context-registry.schema.json +46 -0
- package/schemas/migrations.schema.json +123 -0
- package/schemas/relations.schema.json +41 -10
- package/skill-narrative.md +42 -21
- package/skills/pi-context/SKILL.md +159 -65
- package/skills/pi-context/references/bundled-resources.md +7 -2
package/dist/context-sdk.js
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { readBlock, updateItemInBlock } from "./block-api.js";
|
|
10
|
-
import { findUnmaterializedAssets, loadConfig, loadRelations, validateRelations, } from "./context.js";
|
|
9
|
+
import { readBlock, readBlockForDir, updateItemInBlock } from "./block-api.js";
|
|
10
|
+
import { appendRelation, endpointKey, findUnmaterializedAssets, loadConfig, loadRelations, validateRelations, } from "./context.js";
|
|
11
11
|
import { resolveContextDir, SCHEMAS_DIR, schemaPath, schemasDir, tryResolveContextDir } from "./context-dir.js";
|
|
12
|
+
import { loadRegistry, resolveAlias, resolveSubstrateDir } from "./context-registry.js";
|
|
13
|
+
import { cleanGitEnv } from "./git-env.js";
|
|
12
14
|
import { getLensValidators } from "./lens-validator.js";
|
|
15
|
+
import { addressInto, discoverArrayKey, pageArray } from "./read-element.js";
|
|
16
|
+
import { findNestedIdBearingArrays } from "./schema-write.js";
|
|
13
17
|
import { resolveStatusVocabulary } from "./status-vocab.js";
|
|
14
18
|
import { topoSort } from "./topo.js";
|
|
15
19
|
// Re-export substrate SDK so consumers can keep importing through context-sdk.
|
|
16
|
-
export { displayName, edgesForLens, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
|
|
20
|
+
export { displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
|
|
17
21
|
export function availableBlocks(cwd) {
|
|
18
22
|
const workflowDir = tryResolveContextDir(cwd);
|
|
19
23
|
if (workflowDir === null)
|
|
@@ -265,8 +269,8 @@ export function contextState(cwd) {
|
|
|
265
269
|
let lastCommit = "unknown";
|
|
266
270
|
let lastCommitMessage = "";
|
|
267
271
|
try {
|
|
268
|
-
lastCommit = execSync("git log -1 --format=%h", { cwd, encoding: "utf-8" }).trim();
|
|
269
|
-
lastCommitMessage = execSync("git log -1 --format=%s", { cwd, encoding: "utf-8" }).trim();
|
|
272
|
+
lastCommit = execSync("git log -1 --format=%h", { cwd, encoding: "utf-8", env: cleanGitEnv() }).trim();
|
|
273
|
+
lastCommitMessage = execSync("git log -1 --format=%s", { cwd, encoding: "utf-8", env: cleanGitEnv() }).trim();
|
|
270
274
|
}
|
|
271
275
|
catch {
|
|
272
276
|
/* not a git repo or no commits */
|
|
@@ -274,7 +278,7 @@ export function contextState(cwd) {
|
|
|
274
278
|
// Recent commits
|
|
275
279
|
let recentCommits = [];
|
|
276
280
|
try {
|
|
277
|
-
const log = execSync("git log --oneline -5", { cwd, encoding: "utf-8" }).trim();
|
|
281
|
+
const log = execSync("git log --oneline -5", { cwd, encoding: "utf-8", env: cleanGitEnv() }).trim();
|
|
278
282
|
if (log)
|
|
279
283
|
recentCommits = log.split("\n");
|
|
280
284
|
}
|
|
@@ -525,12 +529,12 @@ export function contextState(cwd) {
|
|
|
525
529
|
export function currentState(cwd) {
|
|
526
530
|
// Tolerate any substrate-read failure (no .project, malformed config, etc.)
|
|
527
531
|
// by collapsing to the empty state — this is a pure read surface.
|
|
528
|
-
let
|
|
532
|
+
let index;
|
|
529
533
|
try {
|
|
530
|
-
|
|
534
|
+
index = buildIdIndex(cwd);
|
|
531
535
|
}
|
|
532
536
|
catch {
|
|
533
|
-
|
|
537
|
+
index = { dir: cwd, byRefname: new Map(), byOid: new Map(), items: [] };
|
|
534
538
|
}
|
|
535
539
|
let edges;
|
|
536
540
|
try {
|
|
@@ -548,13 +552,13 @@ export function currentState(cwd) {
|
|
|
548
552
|
const bucket = (item) => vocab[String(item.status)] ?? "unknown";
|
|
549
553
|
// ── inFlight: tasks-block items bucketing to in_progress ───────────────────
|
|
550
554
|
const inFlight = [];
|
|
551
|
-
for (const
|
|
555
|
+
for (const loc of index.byRefname.values()) {
|
|
552
556
|
if (loc.block !== "tasks")
|
|
553
557
|
continue;
|
|
554
558
|
if (bucket(loc.item) !== "in_progress")
|
|
555
559
|
continue;
|
|
556
560
|
inFlight.push({
|
|
557
|
-
id,
|
|
561
|
+
id: loc.id,
|
|
558
562
|
block: loc.block,
|
|
559
563
|
description: typeof loc.item.description === "string" ? loc.item.description : "",
|
|
560
564
|
});
|
|
@@ -565,18 +569,20 @@ export function currentState(cwd) {
|
|
|
565
569
|
// at unknown ids are treated as satisfied — a dangling edge is a relations
|
|
566
570
|
// integrity concern surfaced by validateRelations, not a blocker here).
|
|
567
571
|
const isCompleted = (taskId) => {
|
|
568
|
-
const loc =
|
|
572
|
+
const loc = index.byRefname.get(taskId);
|
|
569
573
|
return loc !== undefined && bucket(loc.item) === "complete";
|
|
570
574
|
};
|
|
571
|
-
const depParentsOf = (taskId) => edges
|
|
572
|
-
|
|
575
|
+
const depParentsOf = (taskId) => edges
|
|
576
|
+
.filter((e) => e.relation_type === "task_depends_on_task" && endpointKey(e.child) === taskId)
|
|
577
|
+
.map((e) => endpointKey(e.parent));
|
|
578
|
+
const incompleteDeps = (taskId) => depParentsOf(taskId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
|
|
573
579
|
// Collect all to-do (ready/queued) tasks once — drives both blocked + ready
|
|
574
580
|
// derivations. "todo" bucket = planned/queued work (raw status "planned"
|
|
575
581
|
// buckets to todo under STATUS_VOCABULARY_DEFAULTS).
|
|
576
582
|
const plannedTasks = [];
|
|
577
|
-
for (const
|
|
583
|
+
for (const loc of index.byRefname.values()) {
|
|
578
584
|
if (loc.block === "tasks" && bucket(loc.item) === "todo")
|
|
579
|
-
plannedTasks.push({ id, loc });
|
|
585
|
+
plannedTasks.push({ id: loc.id, loc });
|
|
580
586
|
}
|
|
581
587
|
// ── blocked: planned tasks with at least one incomplete dep parent ─────────
|
|
582
588
|
const blocked = [];
|
|
@@ -595,12 +601,12 @@ export function currentState(cwd) {
|
|
|
595
601
|
// P0 > P1 > P2 > P3 (missing priority sorts last) then by id.
|
|
596
602
|
const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
597
603
|
const openGaps = [];
|
|
598
|
-
for (const
|
|
604
|
+
for (const loc of index.byRefname.values()) {
|
|
599
605
|
if (loc.block !== "framework-gaps")
|
|
600
606
|
continue;
|
|
601
607
|
if (bucket(loc.item) !== "todo")
|
|
602
608
|
continue;
|
|
603
|
-
openGaps.push({ id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
|
|
609
|
+
openGaps.push({ id: loc.id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
|
|
604
610
|
}
|
|
605
611
|
openGaps.sort((a, b) => {
|
|
606
612
|
const ra = a.priority !== undefined ? (priorityRank[a.priority] ?? 99) : 99;
|
|
@@ -642,12 +648,12 @@ export function currentState(cwd) {
|
|
|
642
648
|
// Fall back to a phase bucketing to in_progress (phase.json phases[]
|
|
643
649
|
// array-block).
|
|
644
650
|
let inProgressPhase = null;
|
|
645
|
-
for (const
|
|
651
|
+
for (const loc of index.byRefname.values()) {
|
|
646
652
|
if (loc.block !== "phase")
|
|
647
653
|
continue;
|
|
648
654
|
if (bucket(loc.item) !== "in_progress")
|
|
649
655
|
continue;
|
|
650
|
-
inProgressPhase = { id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
|
|
656
|
+
inProgressPhase = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
|
|
651
657
|
break;
|
|
652
658
|
}
|
|
653
659
|
if (inProgressPhase !== null) {
|
|
@@ -660,24 +666,10 @@ export function currentState(cwd) {
|
|
|
660
666
|
}
|
|
661
667
|
return { focus, inFlight, nextActions: cappedNextActions, blocked };
|
|
662
668
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
* disambiguation policy. Mirrors the heuristic in
|
|
668
|
-
* scripts/orchestrator/inject-context-items.ts:85-95 — both consumers share
|
|
669
|
-
* the same single-array-key assumption used across .project/ block writes.
|
|
670
|
-
*/
|
|
671
|
-
function discoverArrayKey(blockData) {
|
|
672
|
-
const arrayKeys = Object.entries(blockData).filter(([, v]) => Array.isArray(v));
|
|
673
|
-
if (arrayKeys.length === 0)
|
|
674
|
-
return null;
|
|
675
|
-
if (arrayKeys.length === 1)
|
|
676
|
-
return arrayKeys[0][0];
|
|
677
|
-
throw new Error(`filterBlockItems: block has multiple top-level array properties (${arrayKeys
|
|
678
|
-
.map(([k]) => k)
|
|
679
|
-
.join(", ")}); array_key per block is not declared in any registry — single-array assumption violated`);
|
|
680
|
-
}
|
|
669
|
+
// discoverArrayKey lives in read-element.ts (the lowest pure layer) and is
|
|
670
|
+
// imported above — ONE copy of the single-top-level-array heuristic shared by
|
|
671
|
+
// filterBlockItems / readBlockPage / serializeForRead. Mirrors the assumption
|
|
672
|
+
// in scripts/orchestrator/inject-context-items.ts used across .project/ writes.
|
|
681
673
|
/**
|
|
682
674
|
* Filter the array items of a block by a predicate. Reads the block via the
|
|
683
675
|
* canonical block-api `readBlock`, discovers the single top-level array key,
|
|
@@ -728,28 +720,34 @@ export function filterBlockItems(cwd, blockName, predicate) {
|
|
|
728
720
|
/**
|
|
729
721
|
* Read a single item from ONE named block by its id, or null if absent.
|
|
730
722
|
* Block-scoped (no cross-substrate idIndex, no prefix-vs-block invariant — that is resolveItemById).
|
|
731
|
-
*
|
|
732
|
-
* (
|
|
723
|
+
* Reads the block then routes id-resolution through the shared addressInto primitive
|
|
724
|
+
* (id matches `.id` or `.canonical_id`), so block-item lookup uses the same element
|
|
725
|
+
* addressing as every other read surface. Missing block / multiple top-level arrays
|
|
726
|
+
* THROW (readBlock + discoverArrayKey via addressInto); no-array block or id-not-found → null. FGAP-045.
|
|
733
727
|
*/
|
|
734
728
|
export function readBlockItem(cwd, blockName, id) {
|
|
735
|
-
const
|
|
736
|
-
|
|
729
|
+
const data = readBlock(cwd, blockName);
|
|
730
|
+
// A block with multiple top-level arrays must throw (single-array assumption);
|
|
731
|
+
// addressInto tolerates ambiguity, so probe discoverArrayKey directly first to
|
|
732
|
+
// preserve the documented throw, then address into the resolved array.
|
|
733
|
+
discoverArrayKey(data);
|
|
734
|
+
const hit = addressInto(data, { id });
|
|
735
|
+
return hit.found ? hit.value : null;
|
|
737
736
|
}
|
|
738
737
|
/**
|
|
739
|
-
* Paginate a block's items.
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
738
|
+
* Paginate a block's items. Reads the block, discovers its single top-level array,
|
|
739
|
+
* then routes the slice/total/hasMore math through the shared `pageArray` primitive
|
|
740
|
+
* (ONE pagination implementation — serializeForRead uses the same). Returns the FULL
|
|
741
|
+
* count as `total` (not the page length) and `hasMore = offset + limit < total`.
|
|
742
|
+
* No-array block → {items:[],total:0,hasMore:false}; offset ≥ total → empty items
|
|
743
|
+
* with correct total. Missing block / multiple top-level arrays propagate the throw
|
|
744
|
+
* (consistent with filterBlockItems). FGAP-045.
|
|
743
745
|
*/
|
|
744
746
|
export function readBlockPage(cwd, blockName, opts = {}) {
|
|
745
|
-
const offset = opts.offset ?? 0;
|
|
746
|
-
const limit = opts.limit ?? 50;
|
|
747
747
|
const data = readBlock(cwd, blockName);
|
|
748
748
|
const arrayKey = discoverArrayKey(data);
|
|
749
749
|
const arr = arrayKey ? data[arrayKey] : [];
|
|
750
|
-
|
|
751
|
-
const items = arr.slice(offset, offset + limit);
|
|
752
|
-
return { items, total, hasMore: offset + limit < total };
|
|
750
|
+
return pageArray(arr, opts);
|
|
753
751
|
}
|
|
754
752
|
/**
|
|
755
753
|
* Read a block and return its single top-level array of items, or [] when the
|
|
@@ -768,9 +766,11 @@ function readBlockArray(cwd, blockName) {
|
|
|
768
766
|
/**
|
|
769
767
|
* Cross-block join (FGAP-043, HYBRID). Mode = exactly one of relationType (edge) XOR
|
|
770
768
|
* leftField+rightField (field). Returns one JoinResult per left item (after leftPredicate),
|
|
771
|
-
* right always an array. Edge mode is DEC-0013-native (relations.json); field mode
|
|
772
|
-
*
|
|
773
|
-
*
|
|
769
|
+
* right always an array. Edge mode is DEC-0013-native (relations.json); field mode is a
|
|
770
|
+
* DEPRECATED backward-compat path that joins on a shared field value (formerly inline-FK,
|
|
771
|
+
* now arbitrary shared fields). NO validation uses field mode — cross-block validation is
|
|
772
|
+
* edge-only since DEC-0036; field mode here is a query convenience, not a reference surface.
|
|
773
|
+
* Reuses filterBlockItems / readBlock / discoverArrayKey / loadRelations / buildIdIndex.
|
|
774
774
|
*/
|
|
775
775
|
export function joinBlocks(cwd, spec) {
|
|
776
776
|
const isEdge = spec.relationType !== undefined;
|
|
@@ -803,17 +803,17 @@ export function joinBlocks(cwd, spec) {
|
|
|
803
803
|
const relationType = spec.relationType;
|
|
804
804
|
const leftEndpoint = spec.leftEndpoint ?? "parent";
|
|
805
805
|
const edges = loadRelations(cwd).filter((e) => e.relation_type === relationType);
|
|
806
|
-
const
|
|
806
|
+
const index = buildIdIndex(cwd);
|
|
807
807
|
return leftItems.map((left) => {
|
|
808
808
|
const leftId = left.id;
|
|
809
809
|
const right = [];
|
|
810
810
|
if (typeof leftId === "string") {
|
|
811
811
|
for (const e of edges) {
|
|
812
|
-
const here = leftEndpoint === "parent" ? e.parent : e.child;
|
|
812
|
+
const here = leftEndpoint === "parent" ? endpointKey(e.parent) : endpointKey(e.child);
|
|
813
813
|
if (here !== leftId)
|
|
814
814
|
continue;
|
|
815
|
-
const otherId = leftEndpoint === "parent" ? e.child : e.parent;
|
|
816
|
-
const loc =
|
|
815
|
+
const otherId = leftEndpoint === "parent" ? endpointKey(e.child) : endpointKey(e.parent);
|
|
816
|
+
const loc = index.byRefname.get(otherId);
|
|
817
817
|
if (loc && loc.block === spec.rightBlock)
|
|
818
818
|
right.push(loc.item);
|
|
819
819
|
}
|
|
@@ -882,29 +882,54 @@ export function expectedBlockForId(id, cfg) {
|
|
|
882
882
|
* the resolver deterministic without allocating warning channels here.
|
|
883
883
|
*/
|
|
884
884
|
export function buildIdIndex(cwd) {
|
|
885
|
-
const index = new Map();
|
|
886
885
|
const blockDir = tryResolveContextDir(cwd);
|
|
887
|
-
if (blockDir === null)
|
|
886
|
+
if (blockDir === null) {
|
|
887
|
+
// No active pointer — return an empty SubstrateIndex (dir set to cwd so
|
|
888
|
+
// the surface is still well-formed; substrate_id undefined). Mirrors the
|
|
889
|
+
// prior empty-Map return.
|
|
890
|
+
return { dir: cwd, byRefname: new Map(), byOid: new Map(), items: [] };
|
|
891
|
+
}
|
|
892
|
+
return buildIdIndexForDir(blockDir, cwd, loadConfig(cwd));
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Build the item-id → location index for an ARBITRARY substrate directory
|
|
896
|
+
* (the dir-targeted twin of `buildIdIndex`, which resolves the active pointer
|
|
897
|
+
* dir). Used by the relation porcelain to index a FOREIGN substrate (resolved
|
|
898
|
+
* via the registry from a `<alias>:` selector) as well as the active substrate.
|
|
899
|
+
*
|
|
900
|
+
* `substrateDir` is the absolute substrate directory to scan; `cfg` is that
|
|
901
|
+
* dir's config (drives the prefix-vs-block invariant via `expectedBlockForId`),
|
|
902
|
+
* passed by the caller so this function performs no pointer resolution. Reads
|
|
903
|
+
* each block file via `readBlockForDir` so the version-aware validation hook
|
|
904
|
+
* fires identically to the active-dir path. Same first-writer-wins collision
|
|
905
|
+
* semantics + prefix-invariant throw as `buildIdIndex`.
|
|
906
|
+
*/
|
|
907
|
+
export function buildIdIndexForDir(substrateDir, _cwd, cfg) {
|
|
908
|
+
// `_cwd` is part of the locked F1 signature (the active-dir caller threads its
|
|
909
|
+
// cwd; the foreign-substrate caller threads the foreign dir) so F2 can resolve
|
|
910
|
+
// registry-relative concerns from it. F1's body reads config explicitly via
|
|
911
|
+
// `cfg`, so `_cwd` is currently unused — retained for the forward-compatible
|
|
912
|
+
// surface rather than dropped and re-added next cycle.
|
|
913
|
+
const byRefname = new Map();
|
|
914
|
+
const byOid = new Map();
|
|
915
|
+
const items = [];
|
|
916
|
+
const index = {
|
|
917
|
+
substrate_id: cfg?.substrate_id,
|
|
918
|
+
dir: substrateDir,
|
|
919
|
+
byRefname,
|
|
920
|
+
byOid,
|
|
921
|
+
items,
|
|
922
|
+
};
|
|
923
|
+
if (!fs.existsSync(substrateDir))
|
|
888
924
|
return index;
|
|
889
|
-
const cfg = loadConfig(cwd);
|
|
890
|
-
// Phases are an ordinary array-block since DEC-0028: each phase carries a
|
|
891
|
-
// PHASE-NNN top-level `id` and lives in `phase.json` under the plural
|
|
892
|
-
// `phases` array key (singular file basename matches phase.schema.json +
|
|
893
|
-
// the verification.json precedent). The generic block-file scan below
|
|
894
|
-
// indexes them by id like any other block. When `PHASE-` is registered in
|
|
895
|
-
// config.block_kinds (canonical_id "phase"), expectedBlockForId resolves
|
|
896
|
-
// PHASE-NNN ids to block "phase" — matching the file they are found in, so
|
|
897
|
-
// the prefix-vs-block invariant passes without a dedicated branch.
|
|
898
925
|
// Top-level block files — scan every array property for items with `id`.
|
|
899
|
-
|
|
900
|
-
return index;
|
|
901
|
-
for (const file of fs.readdirSync(blockDir)) {
|
|
926
|
+
for (const file of fs.readdirSync(substrateDir)) {
|
|
902
927
|
if (!file.endsWith(".json"))
|
|
903
928
|
continue;
|
|
904
929
|
const blockName = file.replace(".json", "");
|
|
905
930
|
let data;
|
|
906
931
|
try {
|
|
907
|
-
data =
|
|
932
|
+
data = readBlockForDir(substrateDir, blockName);
|
|
908
933
|
}
|
|
909
934
|
catch {
|
|
910
935
|
continue; // unreadable / malformed block — skip
|
|
@@ -924,8 +949,23 @@ export function buildIdIndex(cwd) {
|
|
|
924
949
|
throw new Error(`buildIdIndex: ID '${idVal}' found in block '${blockName}' but its prefix maps to block '${expected}'. ` +
|
|
925
950
|
`Prefix-vs-block-kind invariant violated — this indicates a direct-fs write that bypassed schema validation, or a prefix collision needing explicit resolution.`);
|
|
926
951
|
}
|
|
927
|
-
|
|
928
|
-
|
|
952
|
+
// `items` carries ONE entry per id-bearing item in scan order (the
|
|
953
|
+
// iteration surface). `byRefname` is first-writer-wins on refname
|
|
954
|
+
// collision (one entry per distinct refname) — exactly the prior
|
|
955
|
+
// single-Map semantics. The locator that lands in `items` is the
|
|
956
|
+
// same object reference stored under the maps when this is the first
|
|
957
|
+
// writer for its refname, so iteration and lookup share identity.
|
|
958
|
+
const loc = { id: idVal, block: blockName, arrayKey, item };
|
|
959
|
+
items.push(loc);
|
|
960
|
+
if (!byRefname.has(idVal)) {
|
|
961
|
+
byRefname.set(idVal, loc);
|
|
962
|
+
}
|
|
963
|
+
// `byOid` — populated for items carrying a string `oid` (DORMANT this
|
|
964
|
+
// cycle: no F1 consumer reads it). First-writer-wins on oid collision,
|
|
965
|
+
// mirroring `byRefname`.
|
|
966
|
+
const oidVal = item.oid;
|
|
967
|
+
if (typeof oidVal === "string" && oidVal.length > 0 && !byOid.has(oidVal)) {
|
|
968
|
+
byOid.set(oidVal, loc);
|
|
929
969
|
}
|
|
930
970
|
}
|
|
931
971
|
}
|
|
@@ -938,7 +978,7 @@ export function buildIdIndex(cwd) {
|
|
|
938
978
|
* `buildIdIndex` once and reuse the returned map.
|
|
939
979
|
*/
|
|
940
980
|
export function resolveItemById(cwd, id) {
|
|
941
|
-
return buildIdIndex(cwd).get(id) ?? null;
|
|
981
|
+
return buildIdIndex(cwd).byRefname.get(id) ?? null;
|
|
942
982
|
}
|
|
943
983
|
/**
|
|
944
984
|
* Bulk variant of `resolveItemById` — resolve N ids against a single
|
|
@@ -979,10 +1019,275 @@ export function resolveItemsByIds(cwd, ids) {
|
|
|
979
1019
|
for (const id of ids) {
|
|
980
1020
|
if (out.has(id))
|
|
981
1021
|
continue; // duplicate input — Map dedup semantics
|
|
982
|
-
out.set(id, index.get(id) ?? null);
|
|
1022
|
+
out.set(id, index.byRefname.get(id) ?? null);
|
|
983
1023
|
}
|
|
984
1024
|
return out;
|
|
985
1025
|
}
|
|
1026
|
+
// ── Relation porcelain (selector → structured EdgeEndpoint → raw append) ─────
|
|
1027
|
+
/**
|
|
1028
|
+
* Load + JSON-parse a foreign substrate dir's config.json WITHOUT pointer
|
|
1029
|
+
* resolution or AJV validation — best-effort, returns null on absence/parse
|
|
1030
|
+
* failure. Used only to feed `expectedBlockForId`'s prefix invariant when
|
|
1031
|
+
* indexing a foreign substrate in the porcelain; the foreign substrate's own
|
|
1032
|
+
* write path already AJV-validated its config, so a re-validate here would only
|
|
1033
|
+
* add a failure mode to a read.
|
|
1034
|
+
*/
|
|
1035
|
+
function loadConfigForDirBestEffort(substrateDir) {
|
|
1036
|
+
const p = path.join(substrateDir, "config.json");
|
|
1037
|
+
if (!fs.existsSync(p))
|
|
1038
|
+
return null;
|
|
1039
|
+
try {
|
|
1040
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Is `selector` a declared lens bin in the active config? Scans every
|
|
1048
|
+
* `config.lenses[].bins[]`. Disambiguates a bare selector that names a bin
|
|
1049
|
+
* (→ `{kind:"lens_bin"}`) from one that names an item refname (→ `{kind:"item"}`).
|
|
1050
|
+
*/
|
|
1051
|
+
function selectorIsLensBin(cwd, selector) {
|
|
1052
|
+
const cfg = loadConfig(cwd);
|
|
1053
|
+
if (!cfg)
|
|
1054
|
+
return false;
|
|
1055
|
+
for (const lens of cfg.lenses ?? []) {
|
|
1056
|
+
if (lens.bins.includes(selector))
|
|
1057
|
+
return true;
|
|
1058
|
+
}
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Resolve one friendly relation selector to a structured `EdgeEndpoint`:
|
|
1063
|
+
* - `<alias>:<refname>` (alias is a registered substrate alias) → FOREIGN item
|
|
1064
|
+
* `{kind:"item", substrate_id, oid, refname}` (oid from the foreign index;
|
|
1065
|
+
* when the foreign refname does not resolve, oid is left as the refname so the
|
|
1066
|
+
* endpoint round-trips — Cycle 8 resolves foreign endpoints, this cycle only
|
|
1067
|
+
* forms them; an unresolved foreign endpoint validates as a sentinel).
|
|
1068
|
+
* - a selector matching a declared lens bin → `{kind:"lens_bin", bin}`.
|
|
1069
|
+
* - a bare `refname` → SAME-substrate item `{kind:"item", oid, refname}` (oid
|
|
1070
|
+
* from the active index; falls back to refname when unresolved so an
|
|
1071
|
+
* edge to a not-yet-filed item is still expressible).
|
|
1072
|
+
*
|
|
1073
|
+
* NOTE: the `<alias>:` branch is tried first so an alias-prefixed selector is
|
|
1074
|
+
* never misread as a bare refname containing a colon.
|
|
1075
|
+
*/
|
|
1076
|
+
export function resolveRelationSelector(cwd, selector) {
|
|
1077
|
+
// `<alias>:<refname>` — only when the prefix is a REGISTERED alias (a bare
|
|
1078
|
+
// refname that happens to contain a colon is not an alias selector).
|
|
1079
|
+
const colon = selector.indexOf(":");
|
|
1080
|
+
if (colon > 0) {
|
|
1081
|
+
const alias = selector.slice(0, colon);
|
|
1082
|
+
const refname = selector.slice(colon + 1);
|
|
1083
|
+
const substrate_id = resolveAlias(cwd, alias);
|
|
1084
|
+
if (substrate_id !== null) {
|
|
1085
|
+
const dir = resolveSubstrateDir(cwd, substrate_id);
|
|
1086
|
+
let oid = refname;
|
|
1087
|
+
if (dir !== null) {
|
|
1088
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
1089
|
+
const foreignIndex = buildIdIndexForDir(abs, abs, loadConfigForDirBestEffort(abs));
|
|
1090
|
+
const loc = foreignIndex.byRefname.get(refname);
|
|
1091
|
+
if (loc && typeof loc.item.oid === "string")
|
|
1092
|
+
oid = loc.item.oid;
|
|
1093
|
+
}
|
|
1094
|
+
return { kind: "item", substrate_id, oid, refname };
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// A declared lens bin → lens_bin endpoint (never an item).
|
|
1098
|
+
if (selectorIsLensBin(cwd, selector)) {
|
|
1099
|
+
return { kind: "lens_bin", bin: selector };
|
|
1100
|
+
}
|
|
1101
|
+
// Bare refname → same-substrate item. oid from the active index; falls back
|
|
1102
|
+
// to the refname itself when the item is not yet filed.
|
|
1103
|
+
const index = buildIdIndex(cwd);
|
|
1104
|
+
const loc = index.byRefname.get(selector);
|
|
1105
|
+
const oid = loc && typeof loc.item.oid === "string" ? loc.item.oid : selector;
|
|
1106
|
+
return { kind: "item", oid, refname: selector };
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Friendly-selector relation append (Cycle 5 porcelain). Resolves `parent` /
|
|
1110
|
+
* `child` STRING selectors to structured `EdgeEndpoint`s via
|
|
1111
|
+
* `resolveRelationSelector`, then delegates to the raw `appendRelation` plumbing
|
|
1112
|
+
* (atomic, AJV-validated, exact-duplicate no-op — same deferred-integrity
|
|
1113
|
+
* semantics). Keeps the string param surface its callers (the append-relation
|
|
1114
|
+
* Pi tool + the orchestrator CLI) already expose.
|
|
1115
|
+
*
|
|
1116
|
+
* Returns `{ appended, edge }` where `edge` is the RESOLVED structured edge
|
|
1117
|
+
* actually written (so callers can report / dry-run-validate the structured
|
|
1118
|
+
* form).
|
|
1119
|
+
*/
|
|
1120
|
+
export function appendRelationByRef(cwd, rel, ctx) {
|
|
1121
|
+
const edge = {
|
|
1122
|
+
parent: resolveRelationSelector(cwd, rel.parent),
|
|
1123
|
+
child: resolveRelationSelector(cwd, rel.child),
|
|
1124
|
+
relation_type: rel.relation_type,
|
|
1125
|
+
...(rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {}),
|
|
1126
|
+
};
|
|
1127
|
+
const { appended } = appendRelation(cwd, edge, ctx);
|
|
1128
|
+
return { appended, edge };
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Build (or fetch from the per-pass cache) the {@link SubstrateIndex} for a
|
|
1132
|
+
* REGISTERED foreign substrate. Resolves the substrate dir from the registry,
|
|
1133
|
+
* absolutizes it against `cwd`, and builds the index once per substrate_id within
|
|
1134
|
+
* a validation pass (the `foreignCache` is keyed by substrate_id). A build that
|
|
1135
|
+
* THROWS (malformed foreign block / prefix-invariant violation) is caught and
|
|
1136
|
+
* returns null — the caller resolves the ref `dangling` rather than crashing the
|
|
1137
|
+
* whole validation pass on one bad foreign substrate.
|
|
1138
|
+
*
|
|
1139
|
+
* Returns null when the substrate_id is not registered (→ caller `unregistered`)
|
|
1140
|
+
* or when the foreign-index build throws (→ caller `dangling`). A registered
|
|
1141
|
+
* substrate whose dir is missing on disk builds an empty index (not null) via
|
|
1142
|
+
* `buildIdIndexForDir`'s existsSync guard, so its endpoints resolve `dangling`.
|
|
1143
|
+
*/
|
|
1144
|
+
function foreignIndexFor(cwd, substrate_id, foreignCache) {
|
|
1145
|
+
const cached = foreignCache.get(substrate_id);
|
|
1146
|
+
if (cached)
|
|
1147
|
+
return cached;
|
|
1148
|
+
const dir = resolveSubstrateDir(cwd, substrate_id);
|
|
1149
|
+
if (dir === null)
|
|
1150
|
+
return null; // not registered → unregistered (caller)
|
|
1151
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
1152
|
+
try {
|
|
1153
|
+
const index = buildIdIndexForDir(abs, abs, loadConfigForDirBestEffort(abs));
|
|
1154
|
+
foreignCache.set(substrate_id, index);
|
|
1155
|
+
return index;
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
// Malformed foreign block / prefix-invariant throw — degrade to dangling.
|
|
1159
|
+
// Do NOT cache the failure (a transient read could differ); the per-pass
|
|
1160
|
+
// cost of a re-throw is bounded by the edge count into this substrate.
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Classify a single edge endpoint (legacy string OR structured) into one of the
|
|
1166
|
+
* four {@link ResolveStatus} values — the load-bearing F2 resolver wired into
|
|
1167
|
+
* `validateContext`'s edge loop + the `validateRelations` `resolve?` hook.
|
|
1168
|
+
*
|
|
1169
|
+
* Algorithm (the locked Cycle-8 design):
|
|
1170
|
+
* 1. A structured `{kind:"lens_bin"}` endpoint → `{status:"active",
|
|
1171
|
+
* endpointKind:"lens_bin"}` with NO item lookup (a lens_bin never routes
|
|
1172
|
+
* through item resolution — the corruption-risk surface, Constraint 4).
|
|
1173
|
+
* 2. An item endpoint WITH A LOCATOR — a structured `substrate_id`, OR a STRING
|
|
1174
|
+
* of the form `<alias>:<refname>` whose `<alias>` prefix is a REGISTERED
|
|
1175
|
+
* alias — resolves against the named FOREIGN substrate: substrate_id/alias
|
|
1176
|
+
* NOT in the registry → `unregistered`; registered → build (cached) the
|
|
1177
|
+
* foreign index → look up by `oid` (structured locator carrying an oid) else
|
|
1178
|
+
* by `refname` in `byOid`/`byRefname` → found `foreign` / absent `dangling`.
|
|
1179
|
+
* A foreign-index build that throws → `dangling` (never a crash).
|
|
1180
|
+
* 3. An item endpoint with NO locator — a bare oid or a bare refname (a string
|
|
1181
|
+
* with no `:` alias-prefix) — resolves against the ACTIVE index ONLY → found
|
|
1182
|
+
* `active` / absent `dangling`.
|
|
1183
|
+
*
|
|
1184
|
+
* The alias-string parse is ATTEMPTED FIRST on any string containing a `:`
|
|
1185
|
+
* (mirroring `resolveRelationSelector`): the `<x>` in `<x>:<y>` is treated as an
|
|
1186
|
+
* alias candidate, so such a string is an aliased-item locator (step 2), NOT a
|
|
1187
|
+
* bare refname (step 3). If `<x>` is NOT a registered alias → `unregistered`. So
|
|
1188
|
+
* today's `project:FGAP-153` (the `project` alias is not registered until Phase H)
|
|
1189
|
+
* → `unregistered`. The real 30 are therefore `unregistered` pre-H and flip to
|
|
1190
|
+
* `foreign` once Phase H registers the `project` alias (the count/total stay
|
|
1191
|
+
* unchanged at reclassification — see the note in `validateContext`).
|
|
1192
|
+
*
|
|
1193
|
+
* `opts.activeIndex` lets the caller pass a pre-built active index (built once per
|
|
1194
|
+
* validation pass); `opts.foreignCache` memoizes foreign indices per substrate_id
|
|
1195
|
+
* within the pass (so N edges into one foreign substrate build its index ONCE).
|
|
1196
|
+
*/
|
|
1197
|
+
export function resolveRef(cwd, ref, opts) {
|
|
1198
|
+
const foreignCache = opts?.foreignCache ?? new Map();
|
|
1199
|
+
const activeIndex = opts?.activeIndex ?? buildIdIndex(cwd);
|
|
1200
|
+
// (1) lens_bin endpoint — no item lookup.
|
|
1201
|
+
if (typeof ref !== "string" && ref.kind === "lens_bin") {
|
|
1202
|
+
return { status: "active", endpointKind: "lens_bin" };
|
|
1203
|
+
}
|
|
1204
|
+
// Derive the locator + lookup keys.
|
|
1205
|
+
// - structured item: substrate_id (locator), oid, refname.
|
|
1206
|
+
// - string: attempt `<alias>:<refname>` parse — a `:` whose prefix is a
|
|
1207
|
+
// REGISTERED alias yields a foreign locator; otherwise no locator (step 3
|
|
1208
|
+
// looks up the whole string in the active index).
|
|
1209
|
+
let substrate_id;
|
|
1210
|
+
let oid;
|
|
1211
|
+
let refname;
|
|
1212
|
+
// A string carrying a `:` is treated as a `<alias>:<refname>` LOCATOR
|
|
1213
|
+
// candidate (the alias parse is ATTEMPTED): the prefix is an alias that
|
|
1214
|
+
// either resolves (→ foreign locator) or does NOT (→ `unregistered` — the
|
|
1215
|
+
// pre-Phase-H state of the 30 `project:` strings, whose `project` alias is
|
|
1216
|
+
// not yet registered). A registered alias yields a foreign `substrate_id`
|
|
1217
|
+
// locator + the post-colon refname; an UNregistered alias is flagged with the
|
|
1218
|
+
// `aliasUnregistered` sentinel so step (2)/(3) below routes it to
|
|
1219
|
+
// `unregistered` rather than the active index. A string with NO `:` is a bare
|
|
1220
|
+
// active refname (step 3).
|
|
1221
|
+
let aliasUnregistered = false;
|
|
1222
|
+
if (typeof ref === "string") {
|
|
1223
|
+
const colon = ref.indexOf(":");
|
|
1224
|
+
if (colon > 0) {
|
|
1225
|
+
const alias = ref.slice(0, colon);
|
|
1226
|
+
const aliasSubId = resolveAlias(cwd, alias);
|
|
1227
|
+
if (aliasSubId !== null) {
|
|
1228
|
+
// Registered alias → foreign locator; refname is the post-colon part.
|
|
1229
|
+
substrate_id = aliasSubId;
|
|
1230
|
+
refname = ref.slice(colon + 1);
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
// `:`-prefix is an alias CANDIDATE but the alias is NOT registered →
|
|
1234
|
+
// `unregistered` (NOT active-dangling). The whole string is retained as
|
|
1235
|
+
// the refname for diagnostics; no active-index lookup is performed.
|
|
1236
|
+
aliasUnregistered = true;
|
|
1237
|
+
refname = ref;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
// No colon — bare active refname.
|
|
1242
|
+
refname = ref;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
// Structured item endpoint.
|
|
1247
|
+
substrate_id = ref.substrate_id;
|
|
1248
|
+
oid = ref.oid;
|
|
1249
|
+
refname = ref.refname;
|
|
1250
|
+
}
|
|
1251
|
+
// A `<alias>:<refname>` string whose alias is NOT registered → unregistered
|
|
1252
|
+
// (locked decision 1: the alias parse is attempted; a missing alias is the
|
|
1253
|
+
// pre-Phase-H state of the 30, NOT an active-substrate dangling lookup).
|
|
1254
|
+
if (aliasUnregistered) {
|
|
1255
|
+
return { status: "unregistered", endpointKind: "item", refname };
|
|
1256
|
+
}
|
|
1257
|
+
// (2) item endpoint WITH a foreign locator (structured substrate_id OR an
|
|
1258
|
+
// alias that resolved to one).
|
|
1259
|
+
if (substrate_id !== undefined) {
|
|
1260
|
+
const index = foreignIndexFor(cwd, substrate_id, foreignCache);
|
|
1261
|
+
if (index === null) {
|
|
1262
|
+
// substrate_id not registered → unregistered (a build-throw also returns
|
|
1263
|
+
// null, but foreignIndexFor only returns null on UNREGISTERED when the
|
|
1264
|
+
// registry lacks the id; the throw path returns null too — disambiguate
|
|
1265
|
+
// by re-checking the registry to keep the two outcomes distinct).
|
|
1266
|
+
const dir = resolveSubstrateDir(cwd, substrate_id);
|
|
1267
|
+
return dir === null
|
|
1268
|
+
? { status: "unregistered", endpointKind: "item", substrate_id, oid, refname }
|
|
1269
|
+
: { status: "dangling", endpointKind: "item", substrate_id, oid, refname };
|
|
1270
|
+
}
|
|
1271
|
+
// Look up by oid first (structured locator carrying an oid), else by refname.
|
|
1272
|
+
let loc;
|
|
1273
|
+
if (typeof oid === "string" && oid.length > 0)
|
|
1274
|
+
loc = index.byOid.get(oid);
|
|
1275
|
+
if (!loc && typeof refname === "string" && refname.length > 0)
|
|
1276
|
+
loc = index.byRefname.get(refname);
|
|
1277
|
+
return loc
|
|
1278
|
+
? { status: "foreign", endpointKind: "item", substrate_id, oid, refname, loc }
|
|
1279
|
+
: { status: "dangling", endpointKind: "item", substrate_id, oid, refname };
|
|
1280
|
+
}
|
|
1281
|
+
// (3) item endpoint with NO locator → ACTIVE index only.
|
|
1282
|
+
let loc;
|
|
1283
|
+
if (typeof oid === "string" && oid.length > 0)
|
|
1284
|
+
loc = activeIndex.byOid.get(oid);
|
|
1285
|
+
if (!loc && typeof refname === "string" && refname.length > 0)
|
|
1286
|
+
loc = activeIndex.byRefname.get(refname);
|
|
1287
|
+
return loc
|
|
1288
|
+
? { status: "active", endpointKind: "item", oid, refname, loc }
|
|
1289
|
+
: { status: "dangling", endpointKind: "item", oid, refname };
|
|
1290
|
+
}
|
|
986
1291
|
/**
|
|
987
1292
|
* Field-equality predicate for config-declared invariants. Mirrors the
|
|
988
1293
|
* composition-lens `where` semantics (context.ts:773-778): the item
|
|
@@ -1023,13 +1328,55 @@ export function validateContext(cwd) {
|
|
|
1023
1328
|
// Note: buildIdIndex enforces the prefix-vs-block invariant and may throw
|
|
1024
1329
|
// on corrupted state; that surfaces as a hard failure to validateContext
|
|
1025
1330
|
// callers (intended — corrupted IDs are not recoverable cross-ref issues).
|
|
1026
|
-
const
|
|
1331
|
+
const index = buildIdIndex(cwd);
|
|
1027
1332
|
// ── Edge integrity (DEC-0013 closure-table reference surface) ─────────────
|
|
1028
1333
|
// Load config + relations; both absent in a pre-bootstrap project, in which
|
|
1029
1334
|
// case edge checks (and the relocated invariants, which depend on edges)
|
|
1030
1335
|
// are skipped gracefully — there is no edge model to validate yet.
|
|
1031
1336
|
const config = loadConfig(cwd);
|
|
1032
1337
|
const relations = config ? loadRelations(cwd) : [];
|
|
1338
|
+
// ── SoT-drift invariant (content-addressed substrate identity, Cycle 4) ───
|
|
1339
|
+
// When the active config declares a `substrate_id`, the project-root
|
|
1340
|
+
// registry (.pi-context-registry.json) MUST carry an entry for that id whose
|
|
1341
|
+
// `dir` resolves to the active substrate dir. A missing entry or a dir
|
|
1342
|
+
// mismatch means the registry has drifted from the active substrate's sole
|
|
1343
|
+
// SoT (config.substrate_id) and is an ERROR. When `config.substrate_id` is
|
|
1344
|
+
// ABSENT (a pre-identity / pre-Phase-H substrate), the check SKIPS — read
|
|
1345
|
+
// the field directly off the config rather than via substrateIdFor (which
|
|
1346
|
+
// THROWS on absence) so an un-migrated substrate still validates cleanly.
|
|
1347
|
+
if (config) {
|
|
1348
|
+
const substrateId = config.substrate_id;
|
|
1349
|
+
if (typeof substrateId === "string" && substrateId.length > 0) {
|
|
1350
|
+
const registry = loadRegistry(cwd);
|
|
1351
|
+
const entry = registry?.substrates?.[substrateId];
|
|
1352
|
+
if (!entry) {
|
|
1353
|
+
issues.push({
|
|
1354
|
+
severity: "error",
|
|
1355
|
+
message: `config.substrate_id '${substrateId}' is not registered in the project-root .pi-context-registry.json — register the active substrate (registerSubstrate) so foreign-locator resolution can find it`,
|
|
1356
|
+
block: "config",
|
|
1357
|
+
field: "substrate_id",
|
|
1358
|
+
code: "substrate_id_unregistered",
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
const registeredAbs = path.resolve(cwd, entry.dir);
|
|
1363
|
+
// resolveContextDir returns path.join(cwd, contextDir), which is
|
|
1364
|
+
// RELATIVE when cwd is relative (e.g. '.'). Absolutize it so the
|
|
1365
|
+
// comparison is absolute-vs-absolute and a relative cwd can't
|
|
1366
|
+
// produce a false-positive drift error.
|
|
1367
|
+
const activeAbs = path.resolve(resolveContextDir(cwd));
|
|
1368
|
+
if (registeredAbs !== activeAbs) {
|
|
1369
|
+
issues.push({
|
|
1370
|
+
severity: "error",
|
|
1371
|
+
message: `config.substrate_id '${substrateId}' registry entry dir '${entry.dir}' (resolved ${registeredAbs}) does not match the active substrate dir ${activeAbs} — the registry has drifted from the active substrate's SoT`,
|
|
1372
|
+
block: "config",
|
|
1373
|
+
field: "substrate_id",
|
|
1374
|
+
code: "substrate_id_registry_mismatch",
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1033
1380
|
// `config` present → run edge-integrity + the relocated invariants. The
|
|
1034
1381
|
// invariants detect MISSING edges (completed task without a verification
|
|
1035
1382
|
// edge; decision without a forcing-artifact edge), so they must run even
|
|
@@ -1038,21 +1385,57 @@ export function validateContext(cwd) {
|
|
|
1038
1385
|
// no-op on empty relations, so it needs no separate guard.
|
|
1039
1386
|
if (config) {
|
|
1040
1387
|
const registeredRelTypes = new Set((config.relation_types ?? []).map((rt) => rt.canonical_id));
|
|
1388
|
+
// Per-pass foreign-index cache (Constraint 3): N foreign edges into the same
|
|
1389
|
+
// registered substrate build that substrate's index ONCE within this pass.
|
|
1390
|
+
const foreignCache = new Map();
|
|
1391
|
+
// Resolver bound to this pass's cwd + active index + foreign cache; supplied
|
|
1392
|
+
// to validateRelations so its lens/hierarchy resolution can see foreign items.
|
|
1393
|
+
const resolve = (ref) => resolveRef(cwd, ref, { activeIndex: index, foreignCache });
|
|
1041
1394
|
for (const edge of relations) {
|
|
1042
|
-
|
|
1395
|
+
// F2 severity split (DEC §F2): every endpoint is classified by resolveRef
|
|
1396
|
+
// into active | foreign | dangling | unregistered. active/foreign/lens_bin
|
|
1397
|
+
// → no issue; unregistered → ERROR (`edge_endpoint_unregistered`); dangling
|
|
1398
|
+
// → ERROR (`edge_endpoint_dangling`). The two new codes REPLACE the prior
|
|
1399
|
+
// inline "does not resolve" message — the intended reclassification of the
|
|
1400
|
+
// cross-substrate (`<alias>:`) strings (their alias is unregistered pre-H).
|
|
1401
|
+
const parentKey = endpointKey(edge.parent);
|
|
1402
|
+
const childKey = endpointKey(edge.child);
|
|
1403
|
+
const parentRes = resolve(edge.parent);
|
|
1404
|
+
const childRes = resolve(edge.child);
|
|
1405
|
+
if (parentRes.status === "unregistered") {
|
|
1043
1406
|
issues.push({
|
|
1044
1407
|
severity: "error",
|
|
1045
|
-
message: `Edge parent '${
|
|
1408
|
+
message: `Edge parent '${parentKey}' (relation_type '${edge.relation_type}') names an unregistered substrate alias/id`,
|
|
1046
1409
|
block: "relations",
|
|
1047
|
-
field: `${
|
|
1410
|
+
field: `${parentKey}->${childKey}`,
|
|
1411
|
+
code: "edge_endpoint_unregistered",
|
|
1048
1412
|
});
|
|
1049
1413
|
}
|
|
1050
|
-
if (
|
|
1414
|
+
else if (parentRes.status === "dangling") {
|
|
1051
1415
|
issues.push({
|
|
1052
1416
|
severity: "error",
|
|
1053
|
-
message: `Edge
|
|
1417
|
+
message: `Edge parent '${parentKey}' (relation_type '${edge.relation_type}') does not resolve to any item`,
|
|
1054
1418
|
block: "relations",
|
|
1055
|
-
field: `${
|
|
1419
|
+
field: `${parentKey}->${childKey}`,
|
|
1420
|
+
code: "edge_endpoint_dangling",
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
if (childRes.status === "unregistered") {
|
|
1424
|
+
issues.push({
|
|
1425
|
+
severity: "error",
|
|
1426
|
+
message: `Edge child '${childKey}' (relation_type '${edge.relation_type}') names an unregistered substrate alias/id`,
|
|
1427
|
+
block: "relations",
|
|
1428
|
+
field: `${parentKey}->${childKey}`,
|
|
1429
|
+
code: "edge_endpoint_unregistered",
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
else if (childRes.status === "dangling") {
|
|
1433
|
+
issues.push({
|
|
1434
|
+
severity: "error",
|
|
1435
|
+
message: `Edge child '${childKey}' (relation_type '${edge.relation_type}') does not resolve to any item`,
|
|
1436
|
+
block: "relations",
|
|
1437
|
+
field: `${parentKey}->${childKey}`,
|
|
1438
|
+
code: "edge_endpoint_dangling",
|
|
1056
1439
|
});
|
|
1057
1440
|
}
|
|
1058
1441
|
if (!registeredRelTypes.has(edge.relation_type)) {
|
|
@@ -1060,7 +1443,7 @@ export function validateContext(cwd) {
|
|
|
1060
1443
|
severity: "error",
|
|
1061
1444
|
message: `Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`,
|
|
1062
1445
|
block: "relations",
|
|
1063
|
-
field: `${
|
|
1446
|
+
field: `${parentKey}->${childKey}`,
|
|
1064
1447
|
});
|
|
1065
1448
|
}
|
|
1066
1449
|
}
|
|
@@ -1080,24 +1463,32 @@ export function validateContext(cwd) {
|
|
|
1080
1463
|
continue; // unregistered relation_type already reported above
|
|
1081
1464
|
if (!rt.source_kinds && !rt.target_kinds)
|
|
1082
1465
|
continue; // metadata absent → unchecked
|
|
1083
|
-
const
|
|
1084
|
-
const
|
|
1466
|
+
const parentKey = endpointKey(edge.parent);
|
|
1467
|
+
const childKey = endpointKey(edge.child);
|
|
1468
|
+
// Use the resolved location for item endpoints (active OR foreign), so a
|
|
1469
|
+
// foreign item's block is kind-checked against source/target_kinds too. A
|
|
1470
|
+
// lens_bin endpoint (endpointKind:"lens_bin") carries no loc and is skipped
|
|
1471
|
+
// — it never routes through item-block resolution.
|
|
1472
|
+
const parentRes = resolve(edge.parent);
|
|
1473
|
+
const childRes = resolve(edge.child);
|
|
1474
|
+
const parentLoc = parentRes.loc;
|
|
1475
|
+
const childLoc = childRes.loc;
|
|
1085
1476
|
if (parentLoc &&
|
|
1086
1477
|
rt.source_kinds &&
|
|
1087
1478
|
!(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
|
|
1088
1479
|
issues.push({
|
|
1089
1480
|
severity: "error",
|
|
1090
|
-
message: `Edge ${
|
|
1481
|
+
message: `Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
|
|
1091
1482
|
block: "relations",
|
|
1092
|
-
field: `${
|
|
1483
|
+
field: `${parentKey}->${childKey}`,
|
|
1093
1484
|
});
|
|
1094
1485
|
}
|
|
1095
1486
|
if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
|
|
1096
1487
|
issues.push({
|
|
1097
1488
|
severity: "error",
|
|
1098
|
-
message: `Edge ${
|
|
1489
|
+
message: `Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
|
|
1099
1490
|
block: "relations",
|
|
1100
|
-
field: `${
|
|
1491
|
+
field: `${parentKey}->${childKey}`,
|
|
1101
1492
|
});
|
|
1102
1493
|
}
|
|
1103
1494
|
}
|
|
@@ -1107,11 +1498,11 @@ export function validateContext(cwd) {
|
|
|
1107
1498
|
// resolution above is the authoritative reference-integrity surface, so
|
|
1108
1499
|
// merging validateRelations' resolution codes too would double-report).
|
|
1109
1500
|
const itemsByBlock = {};
|
|
1110
|
-
for (const
|
|
1111
|
-
(itemsByBlock[loc.block] ??= []).push({ id, ...loc.item });
|
|
1501
|
+
for (const loc of index.byRefname.values()) {
|
|
1502
|
+
(itemsByBlock[loc.block] ??= []).push({ id: loc.id, ...loc.item });
|
|
1112
1503
|
}
|
|
1113
1504
|
try {
|
|
1114
|
-
const relResult = validateRelations(config, relations, itemsByBlock);
|
|
1505
|
+
const relResult = validateRelations(config, relations, itemsByBlock, resolve);
|
|
1115
1506
|
for (const ri of relResult.issues) {
|
|
1116
1507
|
if (ri.code !== "edge_cycle_detected")
|
|
1117
1508
|
continue;
|
|
@@ -1142,9 +1533,10 @@ export function validateContext(cwd) {
|
|
|
1142
1533
|
for (const edge of relations) {
|
|
1143
1534
|
if (!relTypeSet.has(edge.relation_type))
|
|
1144
1535
|
continue;
|
|
1145
|
-
satisfied.add(inv.direction === "as_parent" ? edge.parent : edge.child);
|
|
1536
|
+
satisfied.add(inv.direction === "as_parent" ? endpointKey(edge.parent) : endpointKey(edge.child));
|
|
1146
1537
|
}
|
|
1147
|
-
for (const
|
|
1538
|
+
for (const loc of index.byRefname.values()) {
|
|
1539
|
+
const id = loc.id;
|
|
1148
1540
|
if (loc.block !== inv.block)
|
|
1149
1541
|
continue;
|
|
1150
1542
|
if (!matchesWhere(loc.item, inv.where))
|
|
@@ -1177,7 +1569,8 @@ export function validateContext(cwd) {
|
|
|
1177
1569
|
if (inv.class !== "status-consistency")
|
|
1178
1570
|
continue;
|
|
1179
1571
|
const relSet = new Set(inv.relation_types);
|
|
1180
|
-
for (const
|
|
1572
|
+
for (const loc of index.byRefname.values()) {
|
|
1573
|
+
const id = loc.id;
|
|
1181
1574
|
if (loc.block !== inv.block)
|
|
1182
1575
|
continue;
|
|
1183
1576
|
if (inv.when_bucket && bucketOf(loc.item) !== inv.when_bucket)
|
|
@@ -1186,10 +1579,10 @@ export function validateContext(cwd) {
|
|
|
1186
1579
|
if (!relSet.has(edge.relation_type))
|
|
1187
1580
|
continue;
|
|
1188
1581
|
const selfIsParent = inv.direction === "as_parent";
|
|
1189
|
-
if ((selfIsParent ? edge.parent : edge.child) !== id)
|
|
1582
|
+
if ((selfIsParent ? endpointKey(edge.parent) : endpointKey(edge.child)) !== id)
|
|
1190
1583
|
continue;
|
|
1191
|
-
const otherId = selfIsParent ? edge.child : edge.parent;
|
|
1192
|
-
const otherLoc =
|
|
1584
|
+
const otherId = selfIsParent ? endpointKey(edge.child) : endpointKey(edge.parent);
|
|
1585
|
+
const otherLoc = index.byRefname.get(otherId);
|
|
1193
1586
|
if (!otherLoc)
|
|
1194
1587
|
continue; // dangling endpoint handled by edge-integrity above
|
|
1195
1588
|
const otherBucket = bucketOf(otherLoc.item);
|
|
@@ -1218,7 +1611,8 @@ export function validateContext(cwd) {
|
|
|
1218
1611
|
// key (recognized) and is NOT flagged; only a value with NO key is. Warning-only.
|
|
1219
1612
|
{
|
|
1220
1613
|
const statusVocab = resolveStatusVocabulary(cwd);
|
|
1221
|
-
for (const
|
|
1614
|
+
for (const sloc of index.byRefname.values()) {
|
|
1615
|
+
const sid = sloc.id;
|
|
1222
1616
|
const sval = sloc.item.status;
|
|
1223
1617
|
if (sval === undefined || sval === null)
|
|
1224
1618
|
continue;
|
|
@@ -1233,6 +1627,47 @@ export function validateContext(cwd) {
|
|
|
1233
1627
|
}
|
|
1234
1628
|
}
|
|
1235
1629
|
}
|
|
1630
|
+
// ── Nested id-bearing array warning (content-addressed substrate identity,
|
|
1631
|
+
// Cycle 9.2) ─────────────────────────────────────────────────────────────
|
|
1632
|
+
// Schema-level, independent of block DATA + config: enumerate the active
|
|
1633
|
+
// substrate's installed schemas and flag every array property at nesting
|
|
1634
|
+
// depth ≥ 1 whose item shape carries an `id` — a relationship-as-embedding
|
|
1635
|
+
// that should be promoted to a top-level entity + membership edge (Phase H).
|
|
1636
|
+
// Non-fatal (warning), so it raises the warning count only and never flips
|
|
1637
|
+
// status to "invalid" by itself. Runs whether or not config is present;
|
|
1638
|
+
// skips cleanly when the schemas dir is absent (pre-bootstrap substrate) and
|
|
1639
|
+
// is best-effort per-file (an unparseable / unreadable schema is skipped
|
|
1640
|
+
// rather than failing the whole project validate).
|
|
1641
|
+
{
|
|
1642
|
+
// Resolve the substrate dir via tryResolveContextDir (returns null when no
|
|
1643
|
+
// pointer exists) rather than schemasDir, whose resolveContextDir THROWS on a
|
|
1644
|
+
// pointer-less cwd — validateContext must degrade cleanly there, not throw.
|
|
1645
|
+
const ctxRoot = tryResolveContextDir(cwd);
|
|
1646
|
+
const schemasDirPath = ctxRoot === null ? null : path.join(ctxRoot, SCHEMAS_DIR);
|
|
1647
|
+
if (schemasDirPath !== null && fs.existsSync(schemasDirPath)) {
|
|
1648
|
+
for (const file of fs.readdirSync(schemasDirPath).sort()) {
|
|
1649
|
+
if (!file.endsWith(".schema.json"))
|
|
1650
|
+
continue;
|
|
1651
|
+
const schemaName = file.slice(0, -".schema.json".length);
|
|
1652
|
+
let parsed;
|
|
1653
|
+
try {
|
|
1654
|
+
parsed = JSON.parse(fs.readFileSync(path.join(schemasDirPath, file), "utf-8"));
|
|
1655
|
+
}
|
|
1656
|
+
catch {
|
|
1657
|
+
continue; // unreadable / non-JSON schema — not this pass's concern
|
|
1658
|
+
}
|
|
1659
|
+
for (const fieldPath of findNestedIdBearingArrays(parsed)) {
|
|
1660
|
+
issues.push({
|
|
1661
|
+
severity: "warning",
|
|
1662
|
+
message: `nested id-bearing array '${fieldPath}' — promote to a top-level entity + membership edge (Phase H)`,
|
|
1663
|
+
block: schemaName,
|
|
1664
|
+
field: fieldPath,
|
|
1665
|
+
code: "nested_id_bearing_array",
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1236
1671
|
// Lens-validator dispatch (Step 7 / pi-context Divergence 3): iterate every
|
|
1237
1672
|
// validator registered via registerLensValidator and merge its issues into
|
|
1238
1673
|
// the project-validation result. Validators are guarded individually so a
|