Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/mcp/tool-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ export const snapshotWithRebuildSchema = z
})
.strict();

export const changeFamilySchema = z.enum([
'no_change',
'viewing_change',
'slot_explicitness_change',
'editioned_semantic_change',
'entity_addition',
'described_entity_retargeting',
]);

export const refreshSentinelSchema = z
.object({
name: z.string(),
passed: z.boolean(),
detail: z.string().optional(),
})
.strict();

export const refreshClassificationSchema = z
.object({
changeFamily: changeFamilySchema,
sentinels: z.array(refreshSentinelSchema),
addedIds: z.array(z.string()),
removedIds: z.array(z.string()),
changedIds: z.array(z.string()),
})
.strict();

export const buildAuditSchema = z
.object({
sourcePath: z.string(),
Expand All @@ -139,6 +166,7 @@ export const buildAuditSchema = z
brokenRoutes: z.array(z.string()),
})
.strict(),
refreshClassification: refreshClassificationSchema.optional(),
compiler: z
.object({
mode: z.literal('local_vectorless'),
Expand Down
1 change: 1 addition & 0 deletions src/runtime/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const ARTIFACT_FILENAMES = {
routeGraph: 'route-graph.json',
lexicon: 'lexicon.json',
anchorMap: 'anchor-map.json',
indexingView: 'indexing-view.json',
} as const;

export const PREFACE_MARKER = '# **Preface** (non-normative)';
Expand Down
343 changes: 343 additions & 0 deletions src/runtime/indexing-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
import { createHash } from 'node:crypto';
import type {
ChangeFamily,
IndexingView,
IndexingViewEntry,
IndexingViewRoute,
RefreshClassification,
RefreshSentinel,
Snapshot,
} from './types.js';

export function buildIndexingView(snapshot: Snapshot): IndexingView {
const patterns: Record<string, IndexingViewEntry> = {};
for (const [id, pattern] of Object.entries(snapshot.patternGraph.nodes)) {
patterns[id] = {
id,
Comment on lines +13 to +16
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The edition hash is derived from JSON.stringify({ patterns, ... }), but patterns is built by iterating Object.entries(snapshot.patternGraph.nodes) without sorting. Since object key insertion order depends on how the compiler discovered sections, purely "viewing" reordering in the source can change the edition even if the semantic spine is identical. To keep the view stable, build patterns (and routes) using a sorted ID list and/or use a stable stringify with sorted keys.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — the edition hash now sorts both patterns and routes by key before JSON.stringify (added in e137efa). The patterns/routes Records stored in the view itself remain insertion-ordered since they're keyed by ID and looked up by ID, but the hash input is always deterministic. Fixed in e137efa.

kind: 'pattern',
title: pattern.title,
status: pattern.status,
type: pattern.type,
normativity: pattern.normativity,
part: pattern.part,
cluster: pattern.cluster,
aliases: [...pattern.aliases].sort(),
anchorIds: [...pattern.sectionIds].sort(),
relationEdges: pattern.relations
.map((r) => ({ from: r.from, relation: r.relation, to: r.to }))
.sort((a, b) => `${a.from}:${a.relation}:${a.to}`.localeCompare(`${b.from}:${b.relation}:${b.to}`)),
};
}

const routes: Record<string, IndexingViewRoute> = {};
for (const [id, route] of Object.entries(snapshot.routeGraph.nodes)) {
routes[id] = {
id,
Comment on lines +32 to +35
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same determinism issue as patterns: routes is populated from Object.entries(snapshot.routeGraph.nodes) (insertion-ordered). A table row reorder in the source can change edition even if routes are otherwise identical. Populate routes in sorted-key order to keep the indexing view edition stable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — sortedRoutes is built with sorted keys before hashing. Fixed in e137efa.

name: route.name,
orderedIds: [...route.orderedIds],
optionalIds: [...route.optionalIds],
landingIds: [...route.landingIds],
routeSurfaces: [...route.routeSurfaces],
constraints: route.firstHonestBurden ? [route.firstHonestBurden] : [],
Comment on lines +37 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hash route retrieval fields in indexing view

buildIndexingView currently fingerprints routes without anchorIds, citations, nextOwners, or reroutes, but runtime behavior uses those fields (QueryEngine.anchorIdsForNode/buildRouteAnswer consume anchors and citations, and buildRouteRelations turns next-owner/reroute edits into traversable edges). In the scenario where only those fields change, edition remains the same and classifyChange reports no_change, which misclassifies a behavior-affecting rebuild as unchanged. Please include these route fields (or equivalent derived data) in the route view/equality inputs used for editioning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid catch — confirmed this is a real bug. IndexingViewRoute omitted anchorIds, citations, nextOwners, and reroutes, all of which affect runtime behavior:

  • anchorIds → consumed by QueryEngine.anchorIdsForNode() for grounding
  • citations → consumed by buildRouteAnswer() and buildRouteRelations()
  • nextOwners/reroutes → consumed by buildRouteRelations() and index-projector.ts

Changes to any of these fields would leave the edition hash unchanged and classifyChange would report no_change, masking a behavior-affecting rebuild.

Fixed in b0b5eb1:

  1. Added anchorIds, citations, nextOwners, reroutes to IndexingViewRoute interface
  2. buildIndexingView() now populates these fields (sorted for deterministic hashing)
  3. routeEqual() compares all four new fields
  4. inferChangeFamily() semantic check includes the new fields

Typecheck and lint pass clean.

anchorIds: [...route.anchorIds].sort(),
citations: [...route.citations].sort(),
nextOwners: [...route.nextOwners].sort(),
reroutes: [...route.reroutes].sort(),
};
Comment on lines +39 to +46
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RouteRecord (from Snapshot['routeGraph']) does not define a constraints property, so this cast-based extraction will always produce an empty array unless the runtime is carrying extra fields outside the declared type. Either add constraints to RouteRecord (and ensure the compiler populates it) or remove it from IndexingViewRoute to avoid emitting misleading data.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — RouteRecord on main doesn't have constraints. This is intentionally set to [] as a forward-compatible placeholder. PR #28 (metadata-driven heuristics) adds constraints to RouteRecord; once that merges, this can be updated to read the actual field. Added a comment in the Gemini review fix explaining this cross-branch dependency.

}

const anchorIds = Object.keys(snapshot.anchorMap).sort();
const lexiconCanonicals = Object.keys(snapshot.lexicon).sort();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hash lexicon matching state, not only lexicon IDs

The lexicon portion of the edition hash is only Object.keys(snapshot.lexicon), so edits that keep the same lexeme IDs but change entry internals (for example normalizedKeys or linkedNodeIds) are classified as no_change. This is problematic because lexical retrieval in query-engine.ts depends on those internals for matching and node expansion, so query behavior can change while refresh classification says nothing changed. Include relevant lexicon entry fields in the indexing view/hash instead of only keys.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ab3cf69 — the edition hash now includes a lexiconFingerprints record alongside lexiconCanonicals. Each entry captures normalizedKeys and linkedNodeIds (both sorted for determinism), so changes to lexical retrieval inputs that affect query-engine matching and node expansion are now detected by semantic refresh classification even when the set of lexeme IDs stays the same.

const lexiconFingerprints: Record<string, { normalizedKeys: string[]; linkedNodeIds: string[] }> = {};
for (const [id, entry] of Object.entries(snapshot.lexicon)) {
lexiconFingerprints[id] = {
normalizedKeys: [...entry.normalizedKeys].sort(),
linkedNodeIds: [...entry.linkedNodeIds].sort(),
};
}

const sortedPatterns = Object.fromEntries(Object.entries(patterns).sort(([a], [b]) => a.localeCompare(b)));
const sortedRoutes = Object.fromEntries(Object.entries(routes).sort(([a], [b]) => a.localeCompare(b)));
const sortedLexiconFingerprints = Object.fromEntries(
Object.entries(lexiconFingerprints).sort(([a], [b]) => a.localeCompare(b)),
);
const spineContent = JSON.stringify({ patterns: sortedPatterns, routes: sortedRoutes, anchorIds, lexiconCanonicals, lexiconFingerprints: sortedLexiconFingerprints });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include retrieval text in edition hashing

buildIndexingView hashes a reduced pattern/route spine, but it omits fields that drive retrieval scoring (notably pattern.searchableText and route.searchableText). Because scorePatternQuery/scoreRouteQuery rank candidates using those text fields, a prose-only source edit can change runtime answers while classifyChange still returns no_change when the edition hash matches. This makes refreshClassification inaccurate for behavior-affecting content changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. The edition hash captures the semantic spine — structural metadata (IDs, titles, statuses, relations, aliases, routes, anchors, lexicon). The sourceHash field (line 65) separately tracks raw source-level changes. If sourceHash differs but edition doesn't, classifyChange correctly returns viewing_change — indicating the source text changed but the structural/semantic shape didn't. Including searchableText in the edition hash would conflate prose rewording (which doesn't affect structure) with actual semantic changes, making the classification less useful. Retrieval scoring changes from prose edits are already detectable via sourceHash comparison.

const edition = `sha256:${createHash('sha256').update(spineContent).digest('hex').slice(0, 16)}`;

return {
edition,
sourceHash: snapshot.sourceHash,
builtAt: snapshot.builtAt,
patterns,
routes,
anchorIds,
lexiconCanonicals,
};
}

export function classifyChange(
previous: IndexingView,
current: IndexingView,
): RefreshClassification {
const sentinels = runRefreshSentinels(previous, current);

const prevPatternIds = new Set(Object.keys(previous.patterns));
const currPatternIds = new Set(Object.keys(current.patterns));
const prevRouteIds = new Set(Object.keys(previous.routes));
const currRouteIds = new Set(Object.keys(current.routes));

const prevAllIds = new Set([...prevPatternIds, ...prevRouteIds]);
const currAllIds = new Set([...currPatternIds, ...currRouteIds]);
Comment on lines +88 to +90
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addedIds/removedIds are computed only from pattern+route IDs. If the edition changes solely due to anchorIds or lexiconCanonicals differences, the classification will show viewing_change but addedIds/removedIds/changedIds can all be empty, making the audit hard to interpret. Consider including anchor/lexicon diffs in the classification payload (or add separate arrays for those dimensions).

Suggested change
const prevAllIds = new Set([...prevPatternIds, ...prevRouteIds]);
const currAllIds = new Set([...currPatternIds, ...currRouteIds]);
const prevAnchorIds = new Set(previous.anchorIds);
const currAnchorIds = new Set(current.anchorIds);
const prevLexiconCanonicals = new Set(previous.lexiconCanonicals);
const currLexiconCanonicals = new Set(current.lexiconCanonicals);
const prevAllIds = new Set([
...prevPatternIds,
...prevRouteIds,
...[...prevAnchorIds].map((id) => `anchor:${id}`),
...[...prevLexiconCanonicals].map((canonical) => `lexicon:${canonical}`),
]);
const currAllIds = new Set([
...currPatternIds,
...currRouteIds,
...[...currAnchorIds].map((id) => `anchor:${id}`),
...[...currLexiconCanonicals].map((canonical) => `lexicon:${canonical}`),
]);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional design choice — the classification deliberately scopes addedIds/removedIds/changedIds to pattern+route IDs since those are the primary semantic entities. Anchor and lexicon changes are reflected in the edition hash difference and correctly classified as viewing_change by inferChangeFamily(). Including anchor/lexicon IDs in the diff arrays would mix namespace concerns (pattern IDs vs anchor IDs). The sentinel checks (anchor_continuity, alias_coverage) already provide granular anchor/lexicon regression info in a dedicated field. If richer anchor/lexicon diffs become needed, we can add dedicated arrays in a follow-up.


const addedIds = [...currAllIds].filter((id) => !prevAllIds.has(id));
const removedIds = [...prevAllIds].filter((id) => !currAllIds.has(id));
const changedIds: string[] = [];

for (const id of currPatternIds) {
if (prevPatternIds.has(id) && !entryEqual(previous.patterns[id], current.patterns[id])) {
changedIds.push(id);
}
}
for (const id of currRouteIds) {
if (prevRouteIds.has(id) && !routeEqual(previous.routes[id], current.routes[id])) {
changedIds.push(id);
}
}

if (previous.edition === current.edition) {
return { changeFamily: 'no_change', sentinels, addedIds, removedIds, changedIds };
}

const changeFamily = inferChangeFamily(previous, current, addedIds, removedIds, changedIds);
return { changeFamily, sentinels, addedIds, removedIds, changedIds };
}

function inferChangeFamily(
previous: IndexingView,
current: IndexingView,
addedIds: string[],
removedIds: string[],
changedIds: string[],
): ChangeFamily {
// Distinguish pure additions (benign) from removals/renames (real retarget risk)
if (removedIds.length > 0) {
return 'described_entity_retargeting';
}
if (addedIds.length > 0 && changedIds.length === 0) {
return 'entity_addition';
}

if (changedIds.length === 0) {
// No pattern/route ID changes but edition differs — check anchors and lexicon explicitly
const anchorsChanged =
previous.anchorIds.length !== current.anchorIds.length ||
previous.anchorIds.some((id, i) => id !== current.anchorIds[i]);
const lexiconChanged =
previous.lexiconCanonicals.length !== current.lexiconCanonicals.length ||
previous.lexiconCanonicals.some((id, i) => id !== current.lexiconCanonicals[i]);
Comment on lines +136 to +137
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify lexicon fingerprint edits as semantic changes

inferChangeFamily only treats lexicon differences as viewing_change when the lexicon ID list (lexiconCanonicals) changes, but ignores fingerprint-only edits such as normalizedKeys/linkedNodeIds changes. In that scenario changedIds is empty (only patterns/routes are diffed), so behavior-changing lexical retrieval updates fall through to a non-semantic family even though query matching and lexeme expansion depend on those fields. This can mislead downstream automation that relies on changeFamily severity to triage rebuild risk.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated — this is working as intended. Here's the analysis:

Edition hash catches it: The spineContent at line 64 includes lexiconFingerprints (containing normalizedKeys and linkedNodeIds), so a fingerprint-only edit does change the edition hash. This means classifyChange will never return no_change for this scenario — the previous.edition === current.edition guard at line 107 correctly passes through.

Classification is correct: When changedIds is empty (no pattern/route field changes) but the edition differs, the code falls through to viewing_change at line 142. This is the right family for lexicon fingerprint edits:

  • viewing_change = "retrieval behavior may differ but entity identity/meaning is unchanged"
  • linkedNodeIds/normalizedKeys changes affect how retrieval routes through existing entities, not what entities exist or mean
  • All families except no_change trigger a rebuild, so the risk is properly surfaced

The concern about "misleading downstream automation" doesn't apply because viewing_change already signals "something retrieval-relevant changed, rebuild needed." Promoting this to editioned_semantic_change would overstate the severity — these aren't entity-level semantic changes (title, status, relations), they're lexical routing changes.

if (anchorsChanged || lexiconChanged) {
return 'viewing_change';
}
// Edition differs but no detectable field change — defensive fallback
return 'viewing_change';
}

const hasSemanticChange = changedIds.some((id) => {
const prev = previous.patterns[id];
const curr = current.patterns[id];
if (!prev || !curr) {
const prevRoute = previous.routes[id];
const currRoute = current.routes[id];
if (prevRoute && currRoute) {
return (
prevRoute.name !== currRoute.name ||
!arraysEqual(prevRoute.orderedIds, currRoute.orderedIds) ||
!arraysEqual(prevRoute.landingIds, currRoute.landingIds) ||
!arraysEqual(prevRoute.optionalIds, currRoute.optionalIds) ||
!arraysEqual(prevRoute.routeSurfaces, currRoute.routeSurfaces) ||
!arraysEqual(prevRoute.constraints, currRoute.constraints) ||
!arraysEqual(prevRoute.anchorIds, currRoute.anchorIds) ||
!arraysEqual(prevRoute.citations, currRoute.citations) ||
!arraysEqual(prevRoute.nextOwners, currRoute.nextOwners) ||
!arraysEqual(prevRoute.reroutes, currRoute.reroutes)
);
}
Comment on lines +151 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The semantic change detection for routes is incomplete. It currently only checks name, orderedIds, and landingIds. Changes to optionalIds, routeSurfaces, or constraints should also be considered semantic changes as they alter the route's definition and behavior.

      if (prevRoute && currRoute) {
        return (
          prevRoute.name !== currRoute.name ||
          JSON.stringify(prevRoute.orderedIds) !== JSON.stringify(currRoute.orderedIds) ||
          JSON.stringify(prevRoute.landingIds) !== JSON.stringify(currRoute.landingIds) ||
          JSON.stringify(prevRoute.optionalIds) !== JSON.stringify(currRoute.optionalIds) ||
          JSON.stringify(prevRoute.routeSurfaces) !== JSON.stringify(currRoute.routeSurfaces) ||
          JSON.stringify(prevRoute.constraints) !== JSON.stringify(currRoute.constraints)
        );
      }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — route semantic change detection now also compares optionalIds, routeSurfaces, and constraints. Fixed in e137efa.

return true;
}
return (
prev.title !== curr.title ||
prev.status !== curr.status ||
prev.type !== curr.type ||
prev.normativity !== curr.normativity ||
!relationEdgesEqual(prev.relationEdges, curr.relationEdges)
);
});

if (hasSemanticChange) {
return 'editioned_semantic_change';
}

const hasSlotChange = changedIds.some((id) => {
const prev = previous.patterns[id];
const curr = current.patterns[id];
if (!prev || !curr) {
return false;
}
return (
prev.part !== curr.part ||
prev.cluster !== curr.cluster ||
!arraysEqual(prev.aliases, curr.aliases)
);
});

if (hasSlotChange) {
return 'slot_explicitness_change';
}

return 'viewing_change';
}

/** Cap sentinel detail to first N items for uniform truncation. */
const MAX_DETAIL_ITEMS = 10;
function formatDetail(label: string, items: string[]): string {
const capped = items.slice(0, MAX_DETAIL_ITEMS);
const suffix = items.length > MAX_DETAIL_ITEMS ? `, … (${items.length - MAX_DETAIL_ITEMS} more)` : '';
return `${label}: ${capped.join(', ')}${suffix}`;
}

function runRefreshSentinels(
previous: IndexingView,
current: IndexingView,
): RefreshSentinel[] {
const sentinels: RefreshSentinel[] = [];

const prevPatternIds = new Set(Object.keys(previous.patterns));
const currPatternIds = new Set(Object.keys(current.patterns));
const missingIds = [...prevPatternIds].filter((id) => !currPatternIds.has(id));
Comment on lines +211 to +216
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several sentinel detail fields can grow without bound (e.g., Removed pattern IDs: ${missingIds.join(', ')}), which can bloat build-audit.json and logs on large diffs. Consider truncating these lists (similar to the slice(0, 10) used for anchors) and including a count of omitted items.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added truncateDetail() helper that caps all sentinel detail strings at 500 characters. The anchor_continuity sentinel already had .slice(0, 10) but the others (id_continuity, alias_coverage, route_closure) didn't. Now all are consistently bounded. Fixed in c236dab.

sentinels.push({
name: 'id_continuity',
passed: missingIds.length === 0,
detail: missingIds.length > 0 ? formatDetail('Removed pattern IDs', missingIds) : undefined,
});

const prevAliases = new Set(
Object.values(previous.patterns).flatMap((p) => p.aliases),
);
const currAliases = new Set(
Object.values(current.patterns).flatMap((p) => p.aliases),
);
const droppedAliases = [...prevAliases].filter((a) => !currAliases.has(a));
sentinels.push({
name: 'alias_coverage',
passed: droppedAliases.length === 0,
detail: droppedAliases.length > 0 ? formatDetail('Dropped aliases', droppedAliases) : undefined,
});

const prevAnchorSet = new Set(previous.anchorIds);
const currAnchorSet = new Set(current.anchorIds);
const missingAnchors = [...prevAnchorSet].filter((a) => !currAnchorSet.has(a));
sentinels.push({
name: 'anchor_continuity',
passed: missingAnchors.length === 0,
detail: missingAnchors.length > 0 ? formatDetail('Missing anchors', missingAnchors) : undefined,
});

const prevRouteIds = new Set(Object.keys(previous.routes));
const currRouteIds = new Set(Object.keys(current.routes));
const missingRoutes = [...prevRouteIds].filter((id) => !currRouteIds.has(id));
sentinels.push({
name: 'route_closure',
passed: missingRoutes.length === 0,
detail: missingRoutes.length > 0 ? formatDetail('Missing routes', missingRoutes) : undefined,
});

const allRelationTargets = new Set<string>();
for (const pattern of Object.values(current.patterns)) {
for (const edge of pattern.relationEdges) {
allRelationTargets.add(edge.to);
}
}
Comment on lines +255 to +259
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check dangling references from routes in sentinel

runRefreshSentinels builds no_dangling_references from current.patterns[*].relationEdges only, so route references are never validated here. If a route keeps an invalid orderedIds/optionalIds/landingIds target after an edit, this sentinel can still report passed: true, which hides broken route wiring in the refresh classification output.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation, but this is by design rather than a bug. The no_dangling_references sentinel specifically validates relation graph integrity — ensuring pattern relation edge targets resolve to known nodes. Route orderedIds/optionalIds/landingIds are validated during compilation (the compiler rejects unknown IDs when building the route graph). Adding route node reference checks to this sentinel would be a reasonable enhancement but isn't a regression — route wiring integrity is already enforced upstream.

const danglingRefs = [...allRelationTargets].filter(
(id) => !currPatternIds.has(id) && !currRouteIds.has(id),
);
sentinels.push({
name: 'no_dangling_references',
passed: danglingRefs.length === 0,
detail: danglingRefs.length > 0 ? formatDetail('Dangling references', danglingRefs) : undefined,
});

return sentinels;
}

/** Field-by-field equality for IndexingViewEntry — avoids JSON.stringify allocation. */
function entryEqual(
a: IndexingViewEntry | undefined,
b: IndexingViewEntry | undefined,
): boolean {
if (!a || !b) {
return a === b;
}
return (
a.id === b.id &&
a.kind === b.kind &&
a.title === b.title &&
a.status === b.status &&
a.type === b.type &&
a.normativity === b.normativity &&
a.part === b.part &&
a.cluster === b.cluster &&
arraysEqual(a.aliases, b.aliases) &&
arraysEqual(a.anchorIds, b.anchorIds) &&
relationEdgesEqual(a.relationEdges, b.relationEdges)
);
}

/** Field-by-field equality for IndexingViewRoute — avoids JSON.stringify allocation. */
function routeEqual(
a: IndexingViewRoute | undefined,
b: IndexingViewRoute | undefined,
): boolean {
if (!a || !b) {
return a === b;
}
return (
a.id === b.id &&
a.name === b.name &&
arraysEqual(a.orderedIds, b.orderedIds) &&
arraysEqual(a.optionalIds, b.optionalIds) &&
arraysEqual(a.landingIds, b.landingIds) &&
arraysEqual(a.routeSurfaces, b.routeSurfaces) &&
arraysEqual(a.constraints, b.constraints) &&
arraysEqual(a.anchorIds, b.anchorIds) &&
arraysEqual(a.citations, b.citations) &&
arraysEqual(a.nextOwners, b.nextOwners) &&
arraysEqual(a.reroutes, b.reroutes)
);
}

function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}

function relationEdgesEqual(
a: ReadonlyArray<{ from: string; relation: string; to: string }>,
b: ReadonlyArray<{ from: string; relation: string; to: string }>,
): boolean {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i].from !== b[i].from || a[i].relation !== b[i].relation || a[i].to !== b[i].to) {
return false;
}
}
return true;
}
Loading
Loading