@davidorex/pi-context 0.31.0 → 0.32.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 +58 -0
- package/README.md +12 -11
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +27 -2
- package/dist/block-api.js.map +1 -1
- package/dist/context-sdk.d.ts +71 -33
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +547 -149
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +153 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +75 -5
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +71 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +405 -94
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts +0 -5
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +43 -1
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +20 -12
- package/dist/migration-registry-loader.d.ts.map +1 -1
- package/dist/migration-registry-loader.js +46 -17
- package/dist/migration-registry-loader.js.map +1 -1
- package/dist/migrations-store.d.ts +45 -18
- package/dist/migrations-store.d.ts.map +1 -1
- package/dist/migrations-store.js +56 -22
- package/dist/migrations-store.js.map +1 -1
- package/dist/ops-registry.d.ts.map +1 -1
- package/dist/ops-registry.js +91 -118
- package/dist/ops-registry.js.map +1 -1
- package/dist/promote-item.d.ts.map +1 -1
- package/dist/promote-item.js +41 -12
- package/dist/promote-item.js.map +1 -1
- package/dist/roadmap-plan.d.ts +121 -99
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +281 -345
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/status-vocab.d.ts +12 -2
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +14 -1
- package/dist/status-vocab.js.map +1 -1
- package/package.json +1 -1
- package/samples/blocks/milestone.json +3 -0
- package/samples/blocks/session-notes.json +1 -0
- package/samples/conception.json +308 -15
- package/samples/migrations.json +8 -0
- package/samples/schemas/context-contracts.schema.json +4 -0
- package/samples/schemas/conventions.schema.json +4 -0
- package/samples/schemas/decisions.schema.json +4 -0
- package/samples/schemas/features.schema.json +4 -0
- package/samples/schemas/framework-gaps.schema.json +4 -0
- package/samples/schemas/issues.schema.json +28 -0
- package/samples/schemas/layer-plans.schema.json +4 -0
- package/samples/schemas/milestone.schema.json +79 -0
- package/samples/schemas/phase.schema.json +4 -0
- package/samples/schemas/rationale.schema.json +4 -0
- package/samples/schemas/requirements.schema.json +4 -0
- package/samples/schemas/research.schema.json +4 -0
- package/samples/schemas/session-notes.schema.json +89 -0
- package/samples/schemas/spec-reviews.schema.json +4 -0
- package/samples/schemas/story.schema.json +8 -0
- package/samples/schemas/tasks.schema.json +4 -0
- package/samples/schemas/verification.schema.json +4 -0
- package/samples/schemas/work-orders.schema.json +4 -0
- package/schemas/config.schema.json +77 -3
- package/schemas/migrations.schema.json +25 -0
- package/skill-narrative.md +7 -5
- package/skills/pi-context/SKILL.md +44 -49
- package/skills/pi-context/references/bundled-resources.md +5 -1
package/dist/context-sdk.js
CHANGED
|
@@ -8,19 +8,20 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { readBlock, readBlockForDir, updateItemInBlock } from "./block-api.js";
|
|
11
|
-
import { appendRelation, appendRelations, endpointIdentity, endpointKey, findUnmaterializedAssets, isSkeletonConfig, loadConfig, loadRelations, removeRelation, validateRelations, writeRelations, } from "./context.js";
|
|
11
|
+
import { appendRelation, appendRelations, counterEndpoint, endpointIdentity, endpointKey, findUnmaterializedAssets, isSkeletonConfig, loadConfig, loadConfigForDir, loadRelations, primaryEndpoint, removeRelation, validateRelations, writeRelations, } from "./context.js";
|
|
12
12
|
import { resolveContextDir, SCHEMAS_DIR, schemaPath, schemasDir, tryResolveContextDir } from "./context-dir.js";
|
|
13
13
|
import { loadRegistry, resolveAlias, resolveSubstrateDir } from "./context-registry.js";
|
|
14
14
|
import { cleanGitEnv } from "./git-env.js";
|
|
15
15
|
import { getLensValidators } from "./lens-validator.js";
|
|
16
16
|
import { findReferencesInRepo } from "./lens-view.js";
|
|
17
|
+
import { getProjectMigrationRegistryForDir } from "./migration-registry-loader.js";
|
|
17
18
|
import { addressInto, discoverArrayKey, pageArray } from "./read-element.js";
|
|
18
|
-
import { validateFromFile } from "./schema-validator.js";
|
|
19
|
+
import { ValidationError, validateBlockWithMigrationForDir, validateFromFile } from "./schema-validator.js";
|
|
19
20
|
import { findNestedIdBearingArrays } from "./schema-write.js";
|
|
20
|
-
import { resolveStatusVocabulary } from "./status-vocab.js";
|
|
21
|
+
import { resolveStateDerivation, resolveStatusVocabulary } from "./status-vocab.js";
|
|
21
22
|
import { topoSort } from "./topo.js";
|
|
22
23
|
// Re-export substrate SDK so consumers can keep importing through context-sdk.
|
|
23
|
-
export { displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
|
|
24
|
+
export { counterEndpoint, displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, primaryEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
|
|
24
25
|
export function availableBlocks(cwd) {
|
|
25
26
|
const workflowDir = tryResolveContextDir(cwd);
|
|
26
27
|
if (workflowDir === null)
|
|
@@ -527,14 +528,51 @@ export function contextState(cwd) {
|
|
|
527
528
|
* `.project` substrate. No writes; tolerant of absent optional blocks (every
|
|
528
529
|
* branch defaults to empty rather than throwing).
|
|
529
530
|
*
|
|
530
|
-
* Edge-direction contract for blocked/ready derivation (
|
|
531
|
-
* roadmap-plan
|
|
532
|
-
*
|
|
531
|
+
* Edge-direction contract for blocked/ready derivation (the same convention as
|
|
532
|
+
* roadmap-plan's topoSort-preds mapping for milestone_precedes_milestone —
|
|
533
|
+
* preds are parents of edges whose child is the node): a
|
|
534
|
+
* `task_depends_on_task` edge `{parent: D, child: T}`
|
|
533
535
|
* means task T DEPENDS ON task D, so D must reach status "completed" before T is
|
|
534
536
|
* unblocked. (relation name source_verb_target = task_depends_on_task ⇒ child is
|
|
535
537
|
* the source/dependent, parent is the target/prerequisite; config display_name
|
|
536
538
|
* "depends on task".)
|
|
539
|
+
*
|
|
540
|
+
* Readiness ALSO honors `task_gated_by_item` gates (FGAP-061 NOW slice): a
|
|
541
|
+
* `task_gated_by_item` edge `{parent: T, child: G}` means task T is GATED BY
|
|
542
|
+
* item G (the gate target, of any kind — gap/decision/feature/task/…), so G must
|
|
543
|
+
* reach the "complete" bucket before T's gate releases. A planned task with any
|
|
544
|
+
* gate target NOT in the "complete" bucket is reported in `blocked` (the target
|
|
545
|
+
* id present in `blockedBy`, unioned with unsatisfied dep-parents) and excluded
|
|
546
|
+
* from `nextActions`; a gate target reaching "complete" releases the gate. Gate
|
|
547
|
+
* satisfaction is `bucket(target) === "complete"` via the same status-vocabulary
|
|
548
|
+
* the status-consistency invariant engine uses — kind-general, no per-kind
|
|
549
|
+
* special-casing (gap→closed, decision→enacted, feature→complete, task→completed
|
|
550
|
+
* all bucket to "complete"). A dangling gate target (id resolves to no item) is
|
|
551
|
+
* treated as satisfied/non-blocking, mirroring the dangling-dep guard.
|
|
552
|
+
*
|
|
553
|
+
* Scope is strictly the literal relation_type `task_gated_by_item`:
|
|
554
|
+
* `decision_gated_by_item` and other non-task `*_gated_by_item` edges are inert
|
|
555
|
+
* here (currentState buckets only tasks). A gate target in a terminal-abandoned
|
|
556
|
+
* status (wontfix/superseded/cancelled — buckets to "unknown", NOT "complete")
|
|
557
|
+
* keeps the gated task blocked under the `=== "complete"` rule; promoting such
|
|
558
|
+
* states to gate-releasing, and config-driven generalization to all gate
|
|
559
|
+
* relation kinds, is the FEAT-004 refinement boundary, out of scope here.
|
|
560
|
+
*/
|
|
561
|
+
/**
|
|
562
|
+
* Render a `next_ranked` entry's `reason_template`, substituting `{token}`
|
|
563
|
+
* occurrences from `tokens` (every value stringified). Tokens absent from the
|
|
564
|
+
* map are left literal. An undefined template yields the empty string — the
|
|
565
|
+
* deriver carries no kind-coupled reason default; the stock registry declares
|
|
566
|
+
* the templates that reproduce the canonical reason strings.
|
|
537
567
|
*/
|
|
568
|
+
function renderReasonTemplate(template, tokens) {
|
|
569
|
+
if (template === undefined)
|
|
570
|
+
return "";
|
|
571
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
572
|
+
const v = tokens[key];
|
|
573
|
+
return v !== undefined ? v : match;
|
|
574
|
+
});
|
|
575
|
+
}
|
|
538
576
|
export function currentState(cwd) {
|
|
539
577
|
// Tolerate any substrate-read failure (no .project, malformed config, etc.)
|
|
540
578
|
// by collapsing to the empty state — this is a pure read surface.
|
|
@@ -559,12 +597,22 @@ export function currentState(cwd) {
|
|
|
559
597
|
// a raw item.status string to its StatusBucket, defaulting to "unknown".
|
|
560
598
|
const vocab = resolveStatusVocabulary(cwd);
|
|
561
599
|
const bucket = (item) => vocab[String(item.status)] ?? "unknown";
|
|
562
|
-
//
|
|
600
|
+
// Resolve the config-declared derivation registry (TASK-020 / FGAP-017). When
|
|
601
|
+
// it is ABSENT, every coupling below is unconfigured, so the function returns
|
|
602
|
+
// the truthful "state-derivation not configured" signal — a state distinct
|
|
603
|
+
// from a configured-but-empty substrate (which derives normally to
|
|
604
|
+
// `focus: "no active focus."` + empty arrays). All 16 couplings below read
|
|
605
|
+
// `sd.*`; no kind / relation / rank / status / head-size literal remains.
|
|
606
|
+
const sd = resolveStateDerivation(cwd);
|
|
607
|
+
if (sd === null) {
|
|
608
|
+
return { focus: "state-derivation not configured", inFlight: [], nextActions: [], blocked: [], milestones: [] };
|
|
609
|
+
}
|
|
610
|
+
// ── inFlight: items of an `in_flight.kinds` block bucketing to in_flight.bucket ─
|
|
563
611
|
const inFlight = [];
|
|
564
612
|
for (const loc of index.byRefname.values()) {
|
|
565
|
-
if (loc.block
|
|
613
|
+
if (!sd.in_flight.kinds.includes(loc.block))
|
|
566
614
|
continue;
|
|
567
|
-
if (bucket(loc.item) !==
|
|
615
|
+
if (bucket(loc.item) !== sd.in_flight.bucket)
|
|
568
616
|
continue;
|
|
569
617
|
inFlight.push({
|
|
570
618
|
id: loc.id,
|
|
@@ -572,108 +620,207 @@ export function currentState(cwd) {
|
|
|
572
620
|
description: typeof loc.item.description === "string" ? loc.item.description : "",
|
|
573
621
|
});
|
|
574
622
|
}
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const loc = index.byRefname.get(taskId);
|
|
623
|
+
// A target is complete when it resolves to a known item whose status buckets to
|
|
624
|
+
// "complete" — the SHARED status-vocab completeness notion (as TASK-065 left it,
|
|
625
|
+
// the same check the status-consistency invariant engine uses), kind-general and
|
|
626
|
+
// not a per-derivation literal.
|
|
627
|
+
const isCompleted = (itemId) => {
|
|
628
|
+
const loc = index.byRefname.get(itemId);
|
|
582
629
|
return loc !== undefined && bucket(loc.item) === "complete";
|
|
583
630
|
};
|
|
584
|
-
|
|
585
|
-
|
|
631
|
+
// Blocking-relation adjacency, driven by `sd.blocked_by.relation_types`. The
|
|
632
|
+
// two stock relations use OPPOSITE endpoint directions, preserved exactly:
|
|
633
|
+
// • task_depends_on_task — DEPENDENCY direction: parents of edges whose CHILD
|
|
634
|
+
// is the item (the prerequisites of the item).
|
|
635
|
+
// • task_gated_by_item — GATE direction: children of edges whose PARENT is
|
|
636
|
+
// the item (the gate targets the item waits on).
|
|
637
|
+
// A relation present in the set with no known direction rule defaults to the
|
|
638
|
+
// DEPENDENCY direction (parent-of-edge-with-child=item). Which relations
|
|
639
|
+
// participate is gated on membership in `sd.blocked_by.relation_types`, so an
|
|
640
|
+
// empty/absent relation is simply not consulted.
|
|
641
|
+
// Partition the configured blocking relations into GATE-direction vs
|
|
642
|
+
// DEPENDENCY-direction by each relation's declared `role_direction` (FGAP-113),
|
|
643
|
+
// replacing the former single `task_gated_by_item` string literal:
|
|
644
|
+
// • role_direction === "as_child" → GATE direction: the relation's PRIMARY
|
|
645
|
+
// role (the gate) sits at edge.child and the waiting item at edge.parent, so
|
|
646
|
+
// the item's gates are the CHILDREN of edges whose PARENT is the item.
|
|
647
|
+
// • otherwise (as_parent OR unset) → DEPENDENCY direction: the item's
|
|
648
|
+
// prerequisites are the PARENTS of edges whose CHILD is the item.
|
|
649
|
+
// For the stock set {task_depends_on_task (as_parent), task_gated_by_item
|
|
650
|
+
// (as_child)} this partition is identical to the pre-change literal split, so
|
|
651
|
+
// blocked / nextActions / blockedBy stay byte-identical; any *_gated_by_item
|
|
652
|
+
// sibling later added to blocked_by routes to the gate direction by
|
|
653
|
+
// construction (no literal to extend). A blocked_by relation the config does
|
|
654
|
+
// NOT register with a role_direction reads as the DEPENDENCY default.
|
|
655
|
+
const blockedByRels = new Set(sd.blocked_by.relation_types);
|
|
656
|
+
const roleDirection = new Map();
|
|
657
|
+
for (const rt of loadConfig(cwd)?.relation_types ?? []) {
|
|
658
|
+
if (rt.role_direction !== undefined)
|
|
659
|
+
roleDirection.set(rt.canonical_id, rt.role_direction);
|
|
660
|
+
}
|
|
661
|
+
const gateDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) === "as_child"));
|
|
662
|
+
const depDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) !== "as_child"));
|
|
663
|
+
const dependencyPredsOf = (itemId) => edges
|
|
664
|
+
.filter((e) => depDirRels.has(e.relation_type) && endpointKey(e.child) === itemId)
|
|
586
665
|
.map((e) => endpointKey(e.parent));
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
//
|
|
666
|
+
const gatePredsOf = (itemId) => edges
|
|
667
|
+
.filter((e) => gateDirRels.has(e.relation_type) && endpointKey(e.parent) === itemId)
|
|
668
|
+
.map((e) => endpointKey(e.child));
|
|
669
|
+
// All preds (deps ∪ gates) in discovery order — used by the topo ordering below.
|
|
670
|
+
const allPredsOf = (itemId) => [...dependencyPredsOf(itemId), ...gatePredsOf(itemId)];
|
|
671
|
+
const incompletePreds = (itemId) => dependencyPredsOf(itemId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
|
|
672
|
+
const unsatisfiedGates = (itemId) => gatePredsOf(itemId).filter((target) => index.byRefname.has(target) && !isCompleted(target));
|
|
673
|
+
// The "planned tasks" set is the next_ranked entry that has NO rank_field (the
|
|
674
|
+
// stock tasks entry, topo-ordered). It drives both blocked + ready derivations.
|
|
675
|
+
const topoEntry = sd.next_ranked.find((e) => e.rank_field === undefined);
|
|
591
676
|
const plannedTasks = [];
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
677
|
+
if (topoEntry !== undefined) {
|
|
678
|
+
for (const loc of index.byRefname.values()) {
|
|
679
|
+
if (loc.block === topoEntry.kind && bucket(loc.item) === topoEntry.bucket) {
|
|
680
|
+
plannedTasks.push({ id: loc.id, loc });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
595
683
|
}
|
|
596
|
-
//
|
|
684
|
+
// blockedBy(T) = UNION of T's unsatisfied dependency-direction preds and
|
|
685
|
+
// unsatisfied gate-direction targets, de-duplicated while preserving discovery
|
|
686
|
+
// order (deps first, then gates). With no gate relation configured / no gate
|
|
687
|
+
// edges present this collapses to the dependency-only set.
|
|
688
|
+
const blockersOf = (taskId) => {
|
|
689
|
+
const result = [];
|
|
690
|
+
const seen = new Set();
|
|
691
|
+
for (const blocker of [...incompletePreds(taskId), ...unsatisfiedGates(taskId)]) {
|
|
692
|
+
if (seen.has(blocker))
|
|
693
|
+
continue;
|
|
694
|
+
seen.add(blocker);
|
|
695
|
+
result.push(blocker);
|
|
696
|
+
}
|
|
697
|
+
return result;
|
|
698
|
+
};
|
|
699
|
+
// ── blocked: planned tasks with at least one unsatisfied dep or gate ─────────
|
|
597
700
|
const blocked = [];
|
|
598
701
|
const blockedIds = new Set();
|
|
599
702
|
for (const { id, loc } of plannedTasks) {
|
|
600
|
-
const blockedBy =
|
|
703
|
+
const blockedBy = blockersOf(id);
|
|
601
704
|
if (blockedBy.length > 0) {
|
|
602
705
|
blocked.push({ id, block: loc.block, blockedBy });
|
|
603
706
|
blockedIds.add(id);
|
|
604
707
|
}
|
|
605
708
|
}
|
|
606
709
|
// ── nextActions (atomic-next, ranked) ──────────────────────────────────────
|
|
710
|
+
// Iterate `sd.next_ranked` IN ARRAY ORDER — array order IS the cross-kind push
|
|
711
|
+
// order (stock: topo-ordered tasks, then priority-ranked issues, then
|
|
712
|
+
// priority-ranked gaps).
|
|
607
713
|
const nextActions = [];
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
714
|
+
for (const entry of sd.next_ranked) {
|
|
715
|
+
if (entry.rank_field !== undefined) {
|
|
716
|
+
// Field-ranked entry (stock: framework-gaps by `priority`). Select items of
|
|
717
|
+
// `entry.kind` at `entry.bucket`, rank by index in `entry.rank_order` (value
|
|
718
|
+
// not listed → large sentinel 99) then by id.
|
|
719
|
+
const rankField = entry.rank_field;
|
|
720
|
+
const rankIndex = {};
|
|
721
|
+
(entry.rank_order ?? []).forEach((v, i) => {
|
|
722
|
+
rankIndex[v] = i;
|
|
723
|
+
});
|
|
724
|
+
const selected = [];
|
|
725
|
+
for (const loc of index.byRefname.values()) {
|
|
726
|
+
if (loc.block !== entry.kind)
|
|
727
|
+
continue;
|
|
728
|
+
if (bucket(loc.item) !== entry.bucket)
|
|
729
|
+
continue;
|
|
730
|
+
const raw = loc.item[rankField];
|
|
731
|
+
selected.push({ id: loc.id, value: typeof raw === "string" ? raw : undefined });
|
|
732
|
+
}
|
|
733
|
+
selected.sort((a, b) => {
|
|
734
|
+
const ra = a.value !== undefined ? (rankIndex[a.value] ?? 99) : 99;
|
|
735
|
+
const rb = b.value !== undefined ? (rankIndex[b.value] ?? 99) : 99;
|
|
736
|
+
if (ra !== rb)
|
|
737
|
+
return ra - rb;
|
|
738
|
+
return a.id.localeCompare(b.id);
|
|
739
|
+
});
|
|
740
|
+
for (const s of selected) {
|
|
741
|
+
nextActions.push({
|
|
742
|
+
id: s.id,
|
|
743
|
+
kind: entry.label,
|
|
744
|
+
...(s.value !== undefined ? { [rankField]: s.value } : {}),
|
|
745
|
+
reason: renderReasonTemplate(entry.reason_template, { rank_value: s.value ?? "unset", id: s.id }),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
// Topo-ordered entry (stock: tasks). Ready = planned items NOT in `blocked`,
|
|
751
|
+
// ordered via topoSort over the planned nodes with preds = dependency preds
|
|
752
|
+
// ∪ gate targets. topoSort only counts edges between graph nodes, so preds
|
|
753
|
+
// outside the planned set (completed / non-task) don't gate the ordering.
|
|
754
|
+
const { order } = topoSort(plannedTasks, (t) => t.id, (t) => allPredsOf(t.id));
|
|
755
|
+
for (const id of order) {
|
|
756
|
+
if (blockedIds.has(id))
|
|
757
|
+
continue;
|
|
758
|
+
nextActions.push({ id, kind: entry.label, reason: renderReasonTemplate(entry.reason_template, { id }) });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
634
761
|
}
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
762
|
+
// Cap nextActions at the config-declared scannable head — derivation can
|
|
763
|
+
// surface a long backlog; the head is the actionable slice for "what's next".
|
|
764
|
+
const cappedNextActions = nextActions.slice(0, sd.head_size);
|
|
765
|
+
// ── milestones: config-declared membership rollups ──────────────────────────
|
|
766
|
+
// For each `sd.rollups` entry, orientation is read from the membership
|
|
767
|
+
// relation's declared `role_direction` (FGAP-113): the CONTAINER (the rollup
|
|
768
|
+
// item itself) sits at the PRIMARY endpoint, its MEMBERS at the COUNTER
|
|
769
|
+
// endpoint. `phase_positioned_in_milestone` is `as_child` (container=milestone
|
|
770
|
+
// at edge.child, member=phase at edge.parent), so `primaryEndpoint`===child /
|
|
771
|
+
// `counterEndpoint`===parent reproduces the prior filter-child / map-parent
|
|
772
|
+
// selection exactly. A membership relation the config does not register with a
|
|
773
|
+
// `role_direction` defaults to `as_child` (the pre-FGAP-113 container=child
|
|
774
|
+
// convention). The rollup emits `complete_status` when ≥1 member exists and
|
|
775
|
+
// every member id resolves to a known item bucketing to complete; else
|
|
776
|
+
// `incomplete_status` (covering no-members + any-incomplete). Every comparison
|
|
777
|
+
// routes through bucket() — no raw status literal.
|
|
778
|
+
const milestones = [];
|
|
779
|
+
for (const entry of sd.rollups) {
|
|
780
|
+
const dir = roleDirection.get(entry.membership_relation) ?? "as_child";
|
|
781
|
+
for (const loc of index.byRefname.values()) {
|
|
782
|
+
if (loc.block !== entry.kind)
|
|
783
|
+
continue;
|
|
784
|
+
const memberIds = edges
|
|
785
|
+
.filter((e) => e.relation_type === entry.membership_relation && endpointKey(primaryEndpoint(e, dir)) === loc.id)
|
|
786
|
+
.map((e) => endpointKey(counterEndpoint(e, dir)));
|
|
787
|
+
const phaseCount = memberIds.length;
|
|
788
|
+
const allComplete = memberIds.every((memberId) => isCompleted(memberId));
|
|
789
|
+
const reached = phaseCount >= 1 && allComplete;
|
|
790
|
+
milestones.push({
|
|
791
|
+
id: loc.id,
|
|
792
|
+
status: reached ? entry.complete_status : entry.incomplete_status,
|
|
793
|
+
phaseCount,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
646
796
|
}
|
|
647
|
-
|
|
648
|
-
// long backlog; the head is the actionable slice for "what's next".
|
|
649
|
-
const NEXT_ACTIONS_CAP = 15;
|
|
650
|
-
const cappedNextActions = nextActions.slice(0, NEXT_ACTIONS_CAP);
|
|
797
|
+
milestones.sort((a, b) => a.id.localeCompare(b.id));
|
|
651
798
|
// ── focus: single derived string ───────────────────────────────────────────
|
|
652
799
|
let focus;
|
|
653
800
|
if (inFlight.length > 0) {
|
|
654
801
|
focus = `in-flight: ${inFlight.map((t) => t.id).join(", ")}`;
|
|
655
802
|
}
|
|
656
803
|
else {
|
|
657
|
-
// Fall back to
|
|
658
|
-
//
|
|
659
|
-
let
|
|
804
|
+
// Fall back to the first item of `focus_fallback.kind` bucketing to
|
|
805
|
+
// `focus_fallback.bucket` (stock: an in-progress phase).
|
|
806
|
+
let fallbackItem = null;
|
|
660
807
|
for (const loc of index.byRefname.values()) {
|
|
661
|
-
if (loc.block !==
|
|
808
|
+
if (loc.block !== sd.focus_fallback.kind)
|
|
662
809
|
continue;
|
|
663
|
-
if (bucket(loc.item) !==
|
|
810
|
+
if (bucket(loc.item) !== sd.focus_fallback.bucket)
|
|
664
811
|
continue;
|
|
665
|
-
|
|
812
|
+
fallbackItem = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
|
|
666
813
|
break;
|
|
667
814
|
}
|
|
668
|
-
if (
|
|
669
|
-
const label =
|
|
670
|
-
focus =
|
|
815
|
+
if (fallbackItem !== null) {
|
|
816
|
+
const label = fallbackItem.name ? `${fallbackItem.id} (${fallbackItem.name})` : fallbackItem.id;
|
|
817
|
+
focus = `${sd.focus_fallback.kind}: ${label}`;
|
|
671
818
|
}
|
|
672
819
|
else {
|
|
673
820
|
focus = "no active focus.";
|
|
674
821
|
}
|
|
675
822
|
}
|
|
676
|
-
return { focus, inFlight, nextActions: cappedNextActions, blocked };
|
|
823
|
+
return { focus, inFlight, nextActions: cappedNextActions, blocked, milestones };
|
|
677
824
|
}
|
|
678
825
|
// discoverArrayKey lives in read-element.ts (the lowest pure layer) and is
|
|
679
826
|
// imported above — ONE copy of the single-top-level-array heuristic shared by
|
|
@@ -1034,22 +1181,31 @@ export function resolveItemsByIds(cwd, ids) {
|
|
|
1034
1181
|
}
|
|
1035
1182
|
// ── Relation porcelain (selector → structured EdgeEndpoint → raw append) ─────
|
|
1036
1183
|
/**
|
|
1037
|
-
* Load
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1184
|
+
* Load a foreign substrate dir's config.json PREFERRING the migration-aware
|
|
1185
|
+
* loader (`loadConfigForDir`: migrate-then-validate) so this reader sees the
|
|
1186
|
+
* SAME config shape `loadConfig` sees once a shape-changing config migration
|
|
1187
|
+
* exists (no split-brain read of the same file). Best-effort in two tiers:
|
|
1188
|
+
* when the migration-aware load fails (unresolvable version, invalid), fall
|
|
1189
|
+
* back to the prior raw parse — the consumer is `expectedBlockForId`'s prefix
|
|
1190
|
+
* invariant, which is better fed an unvalidated `block_kinds` than none (a
|
|
1191
|
+
* null config would silently disable the invariant and let a mis-filed
|
|
1192
|
+
* foreign id resolve instead of degrading). Absent or unparsable → null,
|
|
1193
|
+
* exactly the prior contract.
|
|
1043
1194
|
*/
|
|
1044
1195
|
function loadConfigForDirBestEffort(substrateDir) {
|
|
1045
1196
|
const p = path.join(substrateDir, "config.json");
|
|
1046
1197
|
if (!fs.existsSync(p))
|
|
1047
1198
|
return null;
|
|
1048
1199
|
try {
|
|
1049
|
-
return
|
|
1200
|
+
return loadConfigForDir(substrateDir);
|
|
1050
1201
|
}
|
|
1051
1202
|
catch {
|
|
1052
|
-
|
|
1203
|
+
try {
|
|
1204
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
1205
|
+
}
|
|
1206
|
+
catch {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1053
1209
|
}
|
|
1054
1210
|
}
|
|
1055
1211
|
/**
|
|
@@ -1134,24 +1290,180 @@ function edgeIdentityKey(edge) {
|
|
|
1134
1290
|
return `${endpointIdentity(edge.parent)} ${endpointIdentity(edge.child)} ${edge.relation_type}`;
|
|
1135
1291
|
}
|
|
1136
1292
|
/**
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
* `
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1293
|
+
* Shared edge-registry validator (TASK-062). The single source of edge
|
|
1294
|
+
* registration + endpoint-kind semantics, invoked BOTH at write time (the
|
|
1295
|
+
* `appendRelationByRef` / `appendRelationsByRef` porcelain, so a bad edge throws
|
|
1296
|
+
* before the raw write) AND post-hoc in {@link validateContext} (so write-time
|
|
1297
|
+
* and validate-time verdicts are guaranteed identical).
|
|
1298
|
+
*
|
|
1299
|
+
* Returns an ARRAY of human-readable error messages — empty when the edge is
|
|
1300
|
+
* acceptable. Each message is byte-identical to the wording `validateContext`
|
|
1301
|
+
* historically emitted inline, so the validate-time issue stream is unchanged;
|
|
1302
|
+
* the write path joins the array into the thrown Error message.
|
|
1303
|
+
*
|
|
1304
|
+
* Checks (registration + source/target-kind ONLY — NO `category` check; the
|
|
1305
|
+
* relation_type `category` attribute has no per-edge referent anywhere in code):
|
|
1306
|
+
* - (a) `edge.relation_type` MUST be registered in `config.relation_types[]`
|
|
1307
|
+
* (matched by `canonical_id`). Unregistered → the registration message;
|
|
1308
|
+
* when unregistered the kind check is short-circuited (no rt to read).
|
|
1309
|
+
* - (b) PRESENCE-GATED source/target-kind membership: a relation_type with
|
|
1310
|
+
* NEITHER `source_kinds` NOR `target_kinds` is unchecked (mirrors the
|
|
1311
|
+
* `if (!rt.source_kinds && !rt.target_kinds) continue;` gate). When a set
|
|
1312
|
+
* is present, the resolved endpoint block (via `resolve(...).loc.block`)
|
|
1313
|
+
* MUST be in it, honoring the `"*"` wildcard. A lens_bin / dangling /
|
|
1314
|
+
* unregistered endpoint carries no `loc` and is skipped for the kind
|
|
1315
|
+
* check (endpoint-resolution failures are validateContext's own surface,
|
|
1316
|
+
* not this helper's).
|
|
1317
|
+
*
|
|
1318
|
+
* `resolve` is the caller-supplied endpoint resolver (the same pass-bound
|
|
1319
|
+
* `resolveRef` closure validateContext builds; the write path builds a fresh one
|
|
1320
|
+
* over a freshly-built active index).
|
|
1321
|
+
*/
|
|
1322
|
+
export function validateEdgeAgainstRegistry(edge, config, resolve) {
|
|
1323
|
+
const errors = [];
|
|
1324
|
+
const parentKey = endpointKey(edge.parent);
|
|
1325
|
+
const childKey = endpointKey(edge.child);
|
|
1326
|
+
const rt = (config.relation_types ?? []).find((r) => r.canonical_id === edge.relation_type);
|
|
1327
|
+
if (!rt) {
|
|
1328
|
+
errors.push(`Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`);
|
|
1329
|
+
// Short-circuit: with no registered relation_type there is no source/target
|
|
1330
|
+
// metadata to gate on (mirrors validateContext's `if (!rt) continue;`).
|
|
1331
|
+
return errors;
|
|
1332
|
+
}
|
|
1333
|
+
// Presence gate — neither set declared → endpoint kinds unchecked.
|
|
1334
|
+
if (!rt.source_kinds && !rt.target_kinds)
|
|
1335
|
+
return errors;
|
|
1336
|
+
const parentLoc = resolve(edge.parent).loc;
|
|
1337
|
+
const childLoc = resolve(edge.child).loc;
|
|
1338
|
+
if (parentLoc && rt.source_kinds && !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
|
|
1339
|
+
errors.push(`Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`);
|
|
1340
|
+
}
|
|
1341
|
+
if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
|
|
1342
|
+
errors.push(`Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`);
|
|
1343
|
+
}
|
|
1344
|
+
return errors;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Build the per-call edge-validation resolver for the WRITE-TIME porcelain — a
|
|
1348
|
+
* `resolveRef` closure bound to a freshly-built active index + a fresh foreign
|
|
1349
|
+
* cache for this write, plus the loaded config. Mirrors the pass-bound resolver
|
|
1350
|
+
* validateContext constructs, so the two paths resolve endpoints identically.
|
|
1351
|
+
* Returns `null` when no config is present (a pre-bootstrap substrate has no
|
|
1352
|
+
* relation_types registry to validate against → write-time check is a no-op,
|
|
1353
|
+
* matching validateContext's `if (config)` gate).
|
|
1354
|
+
*/
|
|
1355
|
+
function buildWriteTimeEdgeValidator(cwd) {
|
|
1356
|
+
const config = loadConfig(cwd);
|
|
1357
|
+
if (!config)
|
|
1358
|
+
return null;
|
|
1359
|
+
const activeIndex = buildIdIndex(cwd);
|
|
1360
|
+
const foreignCache = new Map();
|
|
1361
|
+
const resolve = (ref) => resolveRef(cwd, ref, { activeIndex, foreignCache });
|
|
1362
|
+
return { config, resolve };
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Single-edge write-time gate (TASK-062): build the validator for `cwd` and
|
|
1366
|
+
* THROW if `edge` fails the shared registry check. A no-op when no config is
|
|
1367
|
+
* present (pre-bootstrap substrate). The thrown message names the offending
|
|
1368
|
+
* endpoint kind / relation_type / expected kinds (the helper's wording).
|
|
1369
|
+
*/
|
|
1370
|
+
function assertEdgeValidForWrite(cwd, edge) {
|
|
1371
|
+
const validator = buildWriteTimeEdgeValidator(cwd);
|
|
1372
|
+
if (!validator)
|
|
1373
|
+
return;
|
|
1374
|
+
const edgeErrors = validateEdgeAgainstRegistry(edge, validator.config, validator.resolve);
|
|
1375
|
+
if (edgeErrors.length > 0) {
|
|
1376
|
+
throw new Error(`Edge rejected at write time (invalid relation_type / endpoint kind): ${edgeErrors.join("; ")}`);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Whether a role-bearing relation is orientation-AMBIGUOUS: its `source_kinds`
|
|
1381
|
+
* and `target_kinds` overlap (a shared kind, or either side unconstrained / the
|
|
1382
|
+
* `"*"` wildcard), so a bare `{parent, child}` append cannot be reliably oriented
|
|
1383
|
+
* from the endpoint kinds alone. Disjoint-kind relations are self-orienting — the
|
|
1384
|
+
* `validateEdgeAgainstRegistry` source/target-kind gate already rejects an
|
|
1385
|
+
* inversion — so a bare append of them stays allowed.
|
|
1386
|
+
*/
|
|
1387
|
+
function relationKindsOverlap(rt) {
|
|
1388
|
+
const s = rt?.source_kinds;
|
|
1389
|
+
const t = rt?.target_kinds;
|
|
1390
|
+
if (!s || !t)
|
|
1391
|
+
return true; // an unconstrained endpoint is universal → overlaps everything
|
|
1392
|
+
if (s.includes("*") || t.includes("*"))
|
|
1393
|
+
return true;
|
|
1394
|
+
return s.some((k) => t.includes(k));
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Resolve a {@link RelationAppendInput} (raw `{parent,child}` OR role-typed
|
|
1398
|
+
* `{primary,counter}`) to the canonical `{parent, child}` STRING selectors the raw
|
|
1399
|
+
* plumbing consumes, applying the FGAP-113 write-orientation rules:
|
|
1400
|
+
* - the raw and role-typed pairs are mutually exclusive; exactly one complete
|
|
1401
|
+
* pair must be supplied.
|
|
1402
|
+
* - the role-typed form maps `primary`/`counter` → `parent`/`child` via the
|
|
1403
|
+
* relation's declared `role_direction`; it throws when the relation declares no
|
|
1404
|
+
* `role_direction` (there is no primary/counter role to map).
|
|
1405
|
+
* - a bare `{parent,child}` append of a role-BEARING relation that is
|
|
1406
|
+
* orientation-ambiguous (same-kind / wildcard endpoints) is REJECTED, directing
|
|
1407
|
+
* the author to `--primary`/`--counter`. The porcelain never guesses or swaps.
|
|
1408
|
+
* - a bare append of a role-less relation, or of a role-bearing DISJOINT-kind
|
|
1409
|
+
* relation (self-orienting via the kind gate), passes through unchanged.
|
|
1410
|
+
*/
|
|
1411
|
+
function orientAppendInput(config, rel) {
|
|
1412
|
+
const hasRole = rel.primary !== undefined || rel.counter !== undefined;
|
|
1413
|
+
const hasRaw = rel.parent !== undefined || rel.child !== undefined;
|
|
1414
|
+
if (hasRole && hasRaw) {
|
|
1415
|
+
throw new Error(`Relation append for '${rel.relation_type}': --primary/--counter and --parent/--child are mutually exclusive; supply exactly one orientation pair.`);
|
|
1416
|
+
}
|
|
1417
|
+
const rt = (config?.relation_types ?? []).find((r) => r.canonical_id === rel.relation_type);
|
|
1418
|
+
const roleDir = rt?.role_direction;
|
|
1419
|
+
const ordinalPart = rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {};
|
|
1420
|
+
if (hasRole) {
|
|
1421
|
+
if (rel.primary === undefined || rel.counter === undefined) {
|
|
1422
|
+
throw new Error(`Relation append for '${rel.relation_type}': the role-typed form needs BOTH --primary and --counter.`);
|
|
1423
|
+
}
|
|
1424
|
+
if (roleDir === undefined) {
|
|
1425
|
+
throw new Error(`Relation '${rel.relation_type}' declares no role_direction, so it has no primary/counter role to map — author it with --parent/--child.`);
|
|
1426
|
+
}
|
|
1427
|
+
const parent = roleDir === "as_parent" ? rel.primary : rel.counter;
|
|
1428
|
+
const child = roleDir === "as_parent" ? rel.counter : rel.primary;
|
|
1429
|
+
return { parent, child, relation_type: rel.relation_type, ...ordinalPart };
|
|
1430
|
+
}
|
|
1431
|
+
if (rel.parent === undefined || rel.child === undefined) {
|
|
1432
|
+
throw new Error(`Relation append for '${rel.relation_type}': supply either --parent and --child, or --primary and --counter.`);
|
|
1433
|
+
}
|
|
1434
|
+
if (roleDir !== undefined && relationKindsOverlap(rt)) {
|
|
1435
|
+
throw new Error(`Relation '${rel.relation_type}' carries a declared role_direction and is orientation-ambiguous (its ` +
|
|
1436
|
+
`source and target kinds overlap), so a bare --parent/--child append cannot be reliably oriented. Re-issue ` +
|
|
1437
|
+
`with --primary/--counter (primary = the endpoint holding the relation's semantic role, stored at edge.${roleDir === "as_parent" ? "parent" : "child"}).`);
|
|
1438
|
+
}
|
|
1439
|
+
return { parent: rel.parent, child: rel.child, relation_type: rel.relation_type, ...ordinalPart };
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Friendly-selector relation append (Cycle 5 porcelain). Accepts EITHER raw
|
|
1443
|
+
* `{parent, child}` selectors OR the role-typed `{primary, counter}` form
|
|
1444
|
+
* (FGAP-113), resolves the (possibly role-mapped) STRING selectors to structured
|
|
1445
|
+
* `EdgeEndpoint`s via `resolveRelationSelector`, then delegates to the raw
|
|
1446
|
+
* `appendRelation` plumbing (atomic, AJV-validated, exact-duplicate no-op — same
|
|
1447
|
+
* deferred-integrity semantics). Keeps the string param surface its callers (the
|
|
1448
|
+
* append-relation Pi tool + the orchestrator CLI) already expose.
|
|
1143
1449
|
*
|
|
1144
1450
|
* Returns `{ appended, edge }` where `edge` is the RESOLVED structured edge
|
|
1145
1451
|
* actually written (so callers can report / dry-run-validate the structured
|
|
1146
1452
|
* form).
|
|
1147
1453
|
*/
|
|
1148
1454
|
export function appendRelationByRef(cwd, rel, ctx, opts) {
|
|
1455
|
+
const oriented = orientAppendInput(loadConfig(cwd), rel);
|
|
1149
1456
|
const edge = {
|
|
1150
|
-
parent: resolveRelationSelector(cwd,
|
|
1151
|
-
child: resolveRelationSelector(cwd,
|
|
1152
|
-
relation_type:
|
|
1153
|
-
...(
|
|
1457
|
+
parent: resolveRelationSelector(cwd, oriented.parent),
|
|
1458
|
+
child: resolveRelationSelector(cwd, oriented.child),
|
|
1459
|
+
relation_type: oriented.relation_type,
|
|
1460
|
+
...(oriented.ordinal !== undefined ? { ordinal: oriented.ordinal } : {}),
|
|
1154
1461
|
};
|
|
1462
|
+
// Write-time edge-registry gate (TASK-062): reject an unregistered
|
|
1463
|
+
// relation_type or a source/target-kind-violating endpoint BEFORE any write
|
|
1464
|
+
// (dryRun included — preview must surface the same rejection). The shared
|
|
1465
|
+
// helper guarantees this verdict is identical to validateContext's.
|
|
1466
|
+
assertEdgeValidForWrite(cwd, edge);
|
|
1155
1467
|
if (opts?.dryRun) {
|
|
1156
1468
|
// Preview parity: run the SAME validation the write path applies (the
|
|
1157
1469
|
// prospective Edge[] against the whole relations schema — what
|
|
@@ -1262,12 +1574,33 @@ export function replaceRelationByRef(cwd, rels, ctx, opts) {
|
|
|
1262
1574
|
* `validateContext`).
|
|
1263
1575
|
*/
|
|
1264
1576
|
export function appendRelationsByRef(cwd, edges, ctx, opts) {
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1577
|
+
// Orient every input once against the loaded config (FGAP-113): a role-typed
|
|
1578
|
+
// {primary,counter} edge maps to parent/child via role_direction; a bare
|
|
1579
|
+
// {parent,child} append of an orientation-ambiguous role-bearing relation is
|
|
1580
|
+
// rejected here (before any write), directing the author to --primary/--counter.
|
|
1581
|
+
const config = loadConfig(cwd);
|
|
1582
|
+
const resolved = edges.map((rel) => {
|
|
1583
|
+
const oriented = orientAppendInput(config, rel);
|
|
1584
|
+
return {
|
|
1585
|
+
parent: resolveRelationSelector(cwd, oriented.parent),
|
|
1586
|
+
child: resolveRelationSelector(cwd, oriented.child),
|
|
1587
|
+
relation_type: oriented.relation_type,
|
|
1588
|
+
...(oriented.ordinal !== undefined ? { ordinal: oriented.ordinal } : {}),
|
|
1589
|
+
};
|
|
1590
|
+
});
|
|
1591
|
+
// Write-time edge-registry gate (TASK-062): every resolved edge in the batch
|
|
1592
|
+
// is checked BEFORE any write (dryRun included). Build the validator once for
|
|
1593
|
+
// the batch (config + active index built once) and reject if any edge fails —
|
|
1594
|
+
// an all-or-nothing batch (no partial write past a bad edge).
|
|
1595
|
+
const validator = buildWriteTimeEdgeValidator(cwd);
|
|
1596
|
+
if (validator) {
|
|
1597
|
+
for (const edge of resolved) {
|
|
1598
|
+
const edgeErrors = validateEdgeAgainstRegistry(edge, validator.config, validator.resolve);
|
|
1599
|
+
if (edgeErrors.length > 0) {
|
|
1600
|
+
throw new Error(`Edge rejected at write time (invalid relation_type / endpoint kind): ${edgeErrors.join("; ")}`);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1271
1604
|
if (opts?.dryRun) {
|
|
1272
1605
|
// Preview parity: replay the bulk dedup the raw appendRelations applies —
|
|
1273
1606
|
// skip an edge whose identity is already on disk OR earlier in THIS batch
|
|
@@ -1553,7 +1886,6 @@ export function validateContext(cwd) {
|
|
|
1553
1886
|
// false-pass a zero-edge substrate. The edge-integrity loop below is a
|
|
1554
1887
|
// no-op on empty relations, so it needs no separate guard.
|
|
1555
1888
|
if (config) {
|
|
1556
|
-
const registeredRelTypes = new Set((config.relation_types ?? []).map((rt) => rt.canonical_id));
|
|
1557
1889
|
// Per-pass foreign-index cache (Constraint 3): N foreign edges into the same
|
|
1558
1890
|
// registered substrate build that substrate's index ONCE within this pass.
|
|
1559
1891
|
const foreignCache = new Map();
|
|
@@ -1607,60 +1939,48 @@ export function validateContext(cwd) {
|
|
|
1607
1939
|
code: "edge_endpoint_dangling",
|
|
1608
1940
|
});
|
|
1609
1941
|
}
|
|
1610
|
-
if (!registeredRelTypes.has(edge.relation_type)) {
|
|
1611
|
-
issues.push({
|
|
1612
|
-
severity: "error",
|
|
1613
|
-
message: `Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`,
|
|
1614
|
-
block: "relations",
|
|
1615
|
-
field: `${parentKey}->${childKey}`,
|
|
1616
|
-
});
|
|
1617
|
-
}
|
|
1618
1942
|
}
|
|
1619
|
-
// ── Edge endpoint-kind check (FGAP-086, DEC-0037
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1943
|
+
// ── Edge registration + endpoint-kind check (FGAP-086, DEC-0037; TASK-062
|
|
1944
|
+
// factored into the shared validateEdgeAgainstRegistry helper so the
|
|
1945
|
+
// write-time porcelain and this validate-time loop reach an IDENTICAL
|
|
1946
|
+
// verdict). The helper performs (a) relation_type-registration and
|
|
1947
|
+
// (b) PRESENCE-GATED source/target-kind membership ("*" wildcard honored);
|
|
1948
|
+
// a relation_type with neither kind set is unchecked (the frozen .project
|
|
1949
|
+
// substrate, whose relation_types carry no endpoint metadata, is not
|
|
1950
|
+
// retroactively failed). Its returned messages are byte-identical to the
|
|
1951
|
+
// wording this loop historically emitted inline (registration message +
|
|
1952
|
+
// source/target kind messages), each mapped to the same issue shape
|
|
1953
|
+
// (severity error, block "relations", field parent->child, no code).
|
|
1954
|
+
// Order discipline (TASK-062): context-validate prints issues[], so the
|
|
1955
|
+
// emission order is a UX surface and must match the pre-refactor two-pass
|
|
1956
|
+
// shape — ALL relation_type-registration issues across every edge first,
|
|
1957
|
+
// THEN ALL source/target-kind issues across every edge (class-grouped, not
|
|
1958
|
+
// interleaved per edge). The shared helper returns both classes per edge;
|
|
1959
|
+
// we collect once then partition by message text (registration messages
|
|
1960
|
+
// carry "is not registered"; kind messages carry "source kind"/"target
|
|
1961
|
+
// kind"). Issue set/count/wording/severity and the registration
|
|
1962
|
+
// short-circuit are unchanged — this is order-only.
|
|
1963
|
+
const registrationIssues = [];
|
|
1964
|
+
const kindIssues = [];
|
|
1629
1965
|
for (const edge of relations) {
|
|
1630
|
-
const rt = config.relation_types?.find((r) => r.canonical_id === edge.relation_type);
|
|
1631
|
-
if (!rt)
|
|
1632
|
-
continue; // unregistered relation_type already reported above
|
|
1633
|
-
if (!rt.source_kinds && !rt.target_kinds)
|
|
1634
|
-
continue; // metadata absent → unchecked
|
|
1635
1966
|
const parentKey = endpointKey(edge.parent);
|
|
1636
1967
|
const childKey = endpointKey(edge.child);
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
// lens_bin endpoint (endpointKind:"lens_bin") carries no loc and is skipped
|
|
1640
|
-
// — it never routes through item-block resolution.
|
|
1641
|
-
const parentRes = resolve(edge.parent);
|
|
1642
|
-
const childRes = resolve(edge.child);
|
|
1643
|
-
const parentLoc = parentRes.loc;
|
|
1644
|
-
const childLoc = childRes.loc;
|
|
1645
|
-
if (parentLoc &&
|
|
1646
|
-
rt.source_kinds &&
|
|
1647
|
-
!(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
|
|
1648
|
-
issues.push({
|
|
1968
|
+
for (const message of validateEdgeAgainstRegistry(edge, config, resolve)) {
|
|
1969
|
+
const issue = {
|
|
1649
1970
|
severity: "error",
|
|
1650
|
-
message
|
|
1971
|
+
message,
|
|
1651
1972
|
block: "relations",
|
|
1652
1973
|
field: `${parentKey}->${childKey}`,
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
field: `${parentKey}->${childKey}`,
|
|
1661
|
-
});
|
|
1974
|
+
};
|
|
1975
|
+
if (message.includes("is not registered")) {
|
|
1976
|
+
registrationIssues.push(issue);
|
|
1977
|
+
}
|
|
1978
|
+
else {
|
|
1979
|
+
kindIssues.push(issue);
|
|
1980
|
+
}
|
|
1662
1981
|
}
|
|
1663
1982
|
}
|
|
1983
|
+
issues.push(...registrationIssues, ...kindIssues);
|
|
1664
1984
|
// Cycle detection — delegate to validateRelations. It performs its own
|
|
1665
1985
|
// lens/hierarchy/relation_type resolution and emits several edge codes;
|
|
1666
1986
|
// only its cycle diagnostics are merged here (the parent/child/relation_type
|
|
@@ -1864,6 +2184,59 @@ export function validateContext(cwd) {
|
|
|
1864
2184
|
});
|
|
1865
2185
|
}
|
|
1866
2186
|
}
|
|
2187
|
+
// ── Substrate-wide block schema-validity sweep ────────────────────────────
|
|
2188
|
+
// The composed verdict must cover schema validity, not only edge/invariant
|
|
2189
|
+
// integrity: invalidity introduced WITHOUT a write to the block (a schema
|
|
2190
|
+
// resync, a hand edit, catalog drift) previously sat behind a clean verdict
|
|
2191
|
+
// until the next write to that block failed. For every block that has BOTH a
|
|
2192
|
+
// schema in <substrateDir>/schemas/ AND a block file, validate the whole
|
|
2193
|
+
// block against its installed schema — migration-aware when the envelope is
|
|
2194
|
+
// stamped (validateBlockWithMigrationForDir walks a lagging version through
|
|
2195
|
+
// the registered chain), raw otherwise (the same helper: absent version ⇒
|
|
2196
|
+
// straight validate). Every failure surfaces as an ERROR naming the block,
|
|
2197
|
+
// the failing item id (resolved from the AJV instancePath), and the
|
|
2198
|
+
// field/keyword. Guarded per block so one unreadable file cannot mask the
|
|
2199
|
+
// rest of the sweep; pointer-less projects degrade gracefully (no sweep —
|
|
2200
|
+
// there is no substrate to validate), matching validateContext's class rule.
|
|
2201
|
+
{
|
|
2202
|
+
const substrateDir = tryResolveContextDir(cwd);
|
|
2203
|
+
const schemasDir = substrateDir ? path.join(substrateDir, "schemas") : null;
|
|
2204
|
+
if (substrateDir && schemasDir && fs.existsSync(schemasDir)) {
|
|
2205
|
+
const registry = getProjectMigrationRegistryForDir(substrateDir);
|
|
2206
|
+
for (const schemaFile of fs.readdirSync(schemasDir).filter((f) => f.endsWith(".schema.json"))) {
|
|
2207
|
+
const blockName = schemaFile.slice(0, -".schema.json".length);
|
|
2208
|
+
const blockFile = path.join(substrateDir, `${blockName}.json`);
|
|
2209
|
+
if (!fs.existsSync(blockFile))
|
|
2210
|
+
continue;
|
|
2211
|
+
try {
|
|
2212
|
+
const blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
|
|
2213
|
+
validateBlockWithMigrationForDir(substrateDir, blockName, blockData, registry);
|
|
2214
|
+
}
|
|
2215
|
+
catch (err) {
|
|
2216
|
+
if (err instanceof ValidationError) {
|
|
2217
|
+
for (const e of err.errors) {
|
|
2218
|
+
const itemId = blockItemIdForInstancePath(cwd, blockName, e.instancePath);
|
|
2219
|
+
issues.push({
|
|
2220
|
+
severity: "error",
|
|
2221
|
+
message: `Block '${blockName}' fails its installed schema at ${e.instancePath || "(envelope)"}${itemId ? ` (item '${itemId}')` : ""}: ${e.keyword} ${e.message ?? ""}`.trimEnd(),
|
|
2222
|
+
block: blockName,
|
|
2223
|
+
...(itemId !== undefined ? { field: itemId } : {}),
|
|
2224
|
+
code: "block_schema_invalid",
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
else {
|
|
2229
|
+
issues.push({
|
|
2230
|
+
severity: "error",
|
|
2231
|
+
message: `Block '${blockName}' failed schema-validity sweep: ${err instanceof Error ? err.message : String(err)}`,
|
|
2232
|
+
block: blockName,
|
|
2233
|
+
code: "block_schema_invalid",
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
1867
2240
|
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
1868
2241
|
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
1869
2242
|
return {
|
|
@@ -1871,6 +2244,31 @@ export function validateContext(cwd) {
|
|
|
1871
2244
|
issues,
|
|
1872
2245
|
};
|
|
1873
2246
|
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Resolve the failing block item's `id` from an AJV `instancePath` of the form
|
|
2249
|
+
* `/<arrayKey>/<index>[/...]` by re-reading the block file. Returns undefined
|
|
2250
|
+
* for envelope-level paths or when the item carries no string id.
|
|
2251
|
+
*/
|
|
2252
|
+
function blockItemIdForInstancePath(cwd, blockName, instancePath) {
|
|
2253
|
+
const m = /^\/([^/]+)\/(\d+)/.exec(instancePath);
|
|
2254
|
+
if (!m)
|
|
2255
|
+
return undefined;
|
|
2256
|
+
try {
|
|
2257
|
+
const substrateDir = resolveContextDir(cwd);
|
|
2258
|
+
const data = JSON.parse(fs.readFileSync(path.join(substrateDir, `${blockName}.json`), "utf-8"));
|
|
2259
|
+
const arr = data[m[1]];
|
|
2260
|
+
if (!Array.isArray(arr))
|
|
2261
|
+
return undefined;
|
|
2262
|
+
const item = arr[Number(m[2])];
|
|
2263
|
+
if (!item || typeof item !== "object")
|
|
2264
|
+
return undefined;
|
|
2265
|
+
const id = item.id;
|
|
2266
|
+
return typeof id === "string" ? id : undefined;
|
|
2267
|
+
}
|
|
2268
|
+
catch {
|
|
2269
|
+
return undefined;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
1874
2272
|
/**
|
|
1875
2273
|
* Gate task completion on verification. Reads the verification block to confirm
|
|
1876
2274
|
* a passing verification entry exists, asserts a `verification_verifies_item`
|