@3fn/core 13.0.0 → 14.0.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/.kiro/agents/ada-prompt.md +80 -132
- package/.kiro/agents/ada-prompt.md.attribution.json +45 -0
- package/.kiro/agents/ada.json +44 -59
- package/.kiro/agents/ada.json.attribution.json +13 -0
- package/.kiro/agents/data-prompt.md +83 -74
- package/.kiro/agents/data-prompt.md.attribution.json +53 -0
- package/.kiro/agents/data.json +31 -38
- package/.kiro/agents/data.json.attribution.json +13 -0
- package/.kiro/agents/kenya-prompt.md +83 -72
- package/.kiro/agents/kenya-prompt.md.attribution.json +53 -0
- package/.kiro/agents/kenya.json +27 -35
- package/.kiro/agents/kenya.json.attribution.json +13 -0
- package/.kiro/agents/leonardo-prompt.md +176 -234
- package/.kiro/agents/leonardo-prompt.md.attribution.json +45 -0
- package/.kiro/agents/leonardo.json +28 -36
- package/.kiro/agents/leonardo.json.attribution.json +13 -0
- package/.kiro/agents/lina-prompt.md +110 -151
- package/.kiro/agents/lina-prompt.md.attribution.json +53 -0
- package/.kiro/agents/lina.json +46 -59
- package/.kiro/agents/lina.json.attribution.json +13 -0
- package/.kiro/agents/sparky-prompt.md +89 -72
- package/.kiro/agents/sparky-prompt.md.attribution.json +53 -0
- package/.kiro/agents/sparky.json +37 -37
- package/.kiro/agents/sparky.json.attribution.json +13 -0
- package/.kiro/agents/stacy-prompt.md +74 -48
- package/.kiro/agents/stacy-prompt.md.attribution.json +45 -0
- package/.kiro/agents/stacy.json +28 -30
- package/.kiro/agents/stacy.json.attribution.json +13 -0
- package/.kiro/agents/thurgood-prompt.md +94 -138
- package/.kiro/agents/thurgood-prompt.md.attribution.json +45 -0
- package/.kiro/agents/thurgood.json +31 -35
- package/.kiro/agents/thurgood.json.attribution.json +13 -0
- package/.kiro/steering/AI-Collaboration-Principles.md +3 -3
- package/.kiro/steering/Civitas-System-Overview.md +5 -5
- package/.kiro/steering/DesignerPunk-Systems-Overview.md +6 -6
- package/.kiro/steering/Task-Completion-Protocol.md +98 -17
- package/.kiro/steering/core-goals.md +3 -3
- package/.kiro/steering/personal-note.md +1 -1
- package/.kiro/steering/start-up-tasks.md +14 -3
- package/application-mcp-server/src/index.ts +26 -0
- package/dist/ComponentTokens.android.kt +1 -1
- package/dist/ComponentTokens.ios.swift +1 -1
- package/dist/ComponentTokens.web.css +1 -1
- package/dist/DesignTokens.android.kt +1 -1
- package/dist/DesignTokens.dtcg.json +8 -5
- package/dist/DesignTokens.figma.json +2 -2
- package/dist/DesignTokens.ios.swift +1 -1
- package/dist/DesignTokens.web.css +1 -1
- package/dist/android/DesignTokens.android.kt +1 -1
- package/dist/blend/OklchBlendCalculator.js +1 -0
- package/dist/blend/ThemeAwareBlendUtilities.web.d.ts +13 -2
- package/dist/blend/ThemeAwareBlendUtilities.web.js +6 -1
- package/dist/browser/designerpunk.esm.js +22 -81
- package/dist/browser/designerpunk.esm.min.js +29 -32
- package/dist/browser/designerpunk.umd.js +22 -81
- package/dist/browser/designerpunk.umd.min.js +42 -45
- package/dist/browser/tokens.css +1 -1
- package/dist/components/core/Avatar-Base/platforms/web/Avatar.web.js +24 -5
- package/dist/components/core/Button-CTA/examples/BasicUsage.d.ts +16 -28
- package/dist/components/core/Button-CTA/examples/BasicUsage.js +18 -43
- package/dist/components/core/Button-CTA/platforms/web/ButtonCTA.web.d.ts +3 -15
- package/dist/components/core/Button-CTA/platforms/web/ButtonCTA.web.js +9 -58
- package/dist/components/core/Button-CTA/types.d.ts +0 -24
- package/dist/components/core/Button-CTA/types.js +6 -0
- package/dist/components/core/Input-Text-Base/types.d.ts +13 -1
- package/dist/components/core/Input-Text-Password/platforms/web/InputTextPassword.web.js +11 -2
- package/dist/generators/DTCGFormatGenerator.js +8 -0
- package/dist/integration/BuildErrorHandler.js +2 -2
- package/dist/ios/DesignTokens.ios.swift +1 -1
- package/dist/mcp/application-mcp.js +24 -0
- package/dist/mcp/docs-mcp.js +130 -15
- package/dist/mcp/product-mcp.js +25 -0
- package/dist/tokens/OpacityTokens.js +1 -1
- package/dist/tokens/semantic/BlendTokens.d.ts +10 -3
- package/dist/tokens/semantic/BlendTokens.js +17 -5
- package/dist/tokens/semantic/OpacityTokens.d.ts +4 -4
- package/dist/tokens/semantic/OpacityTokens.js +4 -4
- package/dist/types/ComponentTypes.d.ts +1 -1
- package/dist/types/generated/TokenTypes.d.ts +1 -1
- package/dist/types/generated/TokenTypes.js +1 -1
- package/dist/validators/StemmaTokenUsageValidator.js +3 -2
- package/dist/web/DesignTokens.web.css +1 -1
- package/governance/Component-Development-Guide.md +22 -12
- package/governance/Component-Development-Standards.md +2 -2
- package/governance/Component-Family-Avatar.md +0 -1
- package/governance/Component-Family-Badge.md +0 -1
- package/governance/Component-Family-Button.md +4 -17
- package/governance/Component-Family-Chip.md +0 -1
- package/governance/Component-Family-Container.md +0 -1
- package/governance/Component-Family-Data-Display.md +1 -2
- package/governance/Component-Family-Divider.md +1 -2
- package/governance/Component-Family-Form-Inputs.md +5 -5
- package/governance/Component-Family-Icon.md +1 -2
- package/governance/Component-Family-Loading.md +1 -2
- package/governance/Component-Family-Modal.md +1 -2
- package/governance/Component-Family-Navigation.md +0 -1
- package/governance/Component-Family-Progress.md +0 -1
- package/governance/Component-Inheritance-Structures.md +195 -76
- package/governance/Component-MCP-Document-Template.md +6 -5
- package/governance/Component-Primitive-vs-Semantic-Philosophy.md +1 -1
- package/governance/Component-Quick-Reference.md +33 -33
- package/governance/Component-Readiness-Status.md +54 -38
- package/governance/Component-Templates.md +32 -36
- package/governance/Contract-System-Reference.md +6 -6
- package/governance/MCP-Integration-Guide.md +1 -1
- package/governance/Process-Cross-Reference-Standards.md +31 -13
- package/governance/Process-Development-Workflow.md +49 -59
- package/governance/Process-File-Organization.md +24 -24
- package/governance/Process-Hook-Operations.md +19 -7
- package/governance/Process-Orchestration-Model-Selection.md +92 -0
- package/governance/Process-Spec-Planning.md +91 -39
- package/governance/Process-Task-Type-Definitions.md +80 -4
- package/governance/Product-Handoff-Protocol.md +2 -0
- package/governance/Rosetta-System-Architecture.md +6 -6
- package/governance/Test-Behavioral-Contract-Validation.md +38 -31
- package/governance/Test-Failure-Audit-Methodology.md +1 -1
- package/governance/Token-Family-Accessibility.md +1 -2
- package/governance/Token-Family-Blend.md +18 -16
- package/governance/Token-Family-Blur.md +0 -1
- package/governance/Token-Family-Border.md +1 -2
- package/governance/Token-Family-Color.md +0 -1
- package/governance/Token-Family-Glow.md +1 -2
- package/governance/Token-Family-Layering.md +0 -1
- package/governance/Token-Family-Motion.md +1 -2
- package/governance/Token-Family-Opacity.md +0 -1
- package/governance/Token-Family-Radius.md +1 -2
- package/governance/Token-Family-Responsive.md +1 -2
- package/governance/Token-Family-Shadow.md +1 -2
- package/governance/Token-Family-Sizing.md +0 -1
- package/governance/Token-Family-Spacing.md +1 -2
- package/governance/Token-Family-Typography.md +1 -2
- package/governance/Token-Governance.md +8 -8
- package/governance/Token-Quick-Reference.md +21 -21
- package/governance/Token-Resolution-Patterns.md +1 -1
- package/governance/Token-Semantic-Structure.md +1 -1
- package/governance/Web-Authoring-Standards.md +5 -5
- package/governance/browser-distribution-guide.md +1 -4
- package/governance/classification-map.md +368 -0
- package/governance/completion-documentation-guide.md +11 -8
- package/governance/component-meta-authoring-guide.md +1 -1
- package/governance/cross-platform-vs-platform-specific-decision-framework.md +1 -1
- package/governance/platform-implementation-guidelines.md +1 -1
- package/governance/release-management-system.md +2 -2
- package/governance/rosetta-system-principles.md +8 -6
- package/governance/stemma-system-principles.md +18 -17
- package/mcp-server/src/index.ts +24 -6
- package/mcp-server/src/indexer/DocumentIndexer.ts +119 -9
- package/mcp-server/src/indexer/__tests__/bare-id-crossrefs.test.ts +250 -0
- package/mcp-server/src/indexer/cross-ref-parser.ts +29 -1
- package/mcp-server/src/indexer/index-health.ts +27 -2
- package/mcp-server/src/query/__tests__/find-docs-calibration.test.ts +11 -26
- package/mcp-server/src/relocation-integrity-gate/__tests__/relocation-integrity-gate.test.ts +72 -5
- package/mcp-server/src/relocation-integrity-gate/relocation-integrity-gate.ts +81 -24
- package/mcp-server/src/tools/list-cross-references.ts +2 -2
- package/package.json +23 -21
- package/src/__tests__/browser-distribution/css-bundling.test.ts +6 -4
- package/src/__tests__/console-allowlist.json +14 -0
- package/src/__tests__/console-fail-setup.ts +169 -0
- package/src/__tests__/integration/Spec107-DesignLanguageContext.test.ts +16 -0
- package/src/__tests__/stemma-system/behavioral-contract-validation.test.ts +70 -17
- package/src/__tests__/stemma-system/contract-catalog-name-validation.test.ts +28 -0
- package/src/__tests__/stemma-system/form-inputs-contracts.test.ts +223 -16
- package/src/__tests__/stemma-system/input-text-native-base-call-alignment.test.ts +298 -0
- package/src/blend/OklchBlendCalculator.ts +3 -0
- package/src/blend/ThemeAwareBlendUtilities.android.kt +3 -0
- package/src/blend/ThemeAwareBlendUtilities.ios.swift +3 -0
- package/src/blend/ThemeAwareBlendUtilities.web.ts +9 -1
- package/src/blend/__tests__/InteractionStateAudit.test.ts +12 -9
- package/src/build/errors/__tests__/ErrorHandler.integration.test.ts +8 -0
- package/src/build/errors/__tests__/ErrorHandler.test.ts +5 -0
- package/src/build/workflow/__tests__/CICDIntegration.test.ts +12 -1
- package/src/cli/__tests__/init.test.ts +45 -11
- package/src/components/core/Avatar-Base/Avatar-Base.schema.yaml +1 -1
- package/src/components/core/Avatar-Base/__tests__/Avatar.accessibility.test.ts +121 -7
- package/src/components/core/Avatar-Base/__tests__/Avatar.image.test.ts +3 -0
- package/src/components/core/Avatar-Base/__tests__/Avatar.test.ts +15 -6
- package/src/components/core/Avatar-Base/contracts.yaml +11 -1
- package/src/components/core/Avatar-Base/platforms/web/Avatar.web.ts +24 -5
- package/src/components/core/Badge-Count-Base/contracts.yaml +1 -1
- package/src/components/core/Badge-Label-Base/contracts.yaml +1 -1
- package/src/components/core/Button-CTA/Button-CTA.schema.yaml +2 -12
- package/src/components/core/Button-CTA/README.md +3 -6
- package/src/components/core/Button-CTA/__tests__/ButtonCTA.test.ts +35 -89
- package/src/components/core/Button-CTA/__tests__/setup.test.ts +0 -2
- package/src/components/core/Button-CTA/__tests__/test-utils.ts +0 -2
- package/src/components/core/Button-CTA/contracts.yaml +6 -29
- package/src/components/core/Button-CTA/examples/BasicUsage.html +2 -14
- package/src/components/core/Button-CTA/examples/BasicUsage.tsx +17 -44
- package/src/components/core/Button-CTA/platforms/android/ButtonCTA.android.kt +12 -20
- package/src/components/core/Button-CTA/platforms/ios/ButtonCTA.ios.swift +12 -51
- package/src/components/core/Button-CTA/platforms/web/ButtonCTA.web.css +2 -26
- package/src/components/core/Button-CTA/platforms/web/ButtonCTA.web.ts +18 -71
- package/src/components/core/Button-CTA/types.ts +10 -28
- package/src/components/core/Chip-Base/__tests__/ChipBase.test.ts +13 -0
- package/src/components/core/Chip-Filter/__tests__/ChipFilter.test.ts +13 -0
- package/src/components/core/Chip-Input/__tests__/ChipInput.test.ts +13 -0
- package/src/components/core/Input-Text-Base/Input-Text-Base.schema.yaml +30 -2
- package/src/components/core/Input-Text-Base/README.md +25 -2
- package/src/components/core/Input-Text-Base/__tests__/focusIndicators.test.ts +16 -15
- package/src/components/core/Input-Text-Base/contracts.yaml +90 -0
- package/src/components/core/Input-Text-Base/platforms/android/InputTextBase.android.kt +26 -12
- package/src/components/core/Input-Text-Base/platforms/ios/InputTextBase.ios.swift +195 -59
- package/src/components/core/Input-Text-Base/types.ts +13 -1
- package/src/components/core/Input-Text-Email/Input-Text-Email.schema.yaml +5 -1
- package/src/components/core/Input-Text-Email/README.md +8 -7
- package/src/components/core/Input-Text-Email/platforms/android/InputTextEmail.android.kt +1 -4
- package/src/components/core/Input-Text-Email/platforms/ios/InputTextEmail.ios.swift +2 -16
- package/src/components/core/Input-Text-Password/Input-Text-Password.schema.yaml +10 -3
- package/src/components/core/Input-Text-Password/README.md +9 -8
- package/src/components/core/Input-Text-Password/contracts.yaml +5 -0
- package/src/components/core/Input-Text-Password/platforms/android/InputTextPassword.android.kt +17 -7
- package/src/components/core/Input-Text-Password/platforms/ios/InputTextPassword.ios.swift +22 -20
- package/src/components/core/Input-Text-Password/platforms/web/InputTextPassword.web.ts +11 -2
- package/src/components/core/Input-Text-PhoneNumber/Input-Text-PhoneNumber.schema.yaml +5 -1
- package/src/components/core/Input-Text-PhoneNumber/README.md +9 -8
- package/src/components/core/Input-Text-PhoneNumber/platforms/android/InputTextPhoneNumber.android.kt +2 -5
- package/src/components/core/Input-Text-PhoneNumber/platforms/ios/InputTextPhoneNumber.ios.swift +3 -17
- package/src/components/core/Nav-Header-App/contracts.yaml +1 -1
- package/src/components/core/Nav-SegmentedChoice-Base/contracts.yaml +1 -1
- package/src/components/core/Progress-Indicator-Connector-Base/contracts.yaml +1 -1
- package/src/components/core/Progress-Indicator-Label-Base/contracts.yaml +1 -1
- package/src/components/core/Progress-Indicator-Node-Base/contracts.yaml +1 -1
- package/src/components/core/Progress-Stepper-Base/__tests__/StepperBase.test.ts +5 -2
- package/src/components/core/Progress-Stepper-Detailed/__tests__/StepperDetailed.test.ts +5 -2
- package/src/generators/DTCGFormatGenerator.ts +6 -0
- package/src/generators/__tests__/DTCGConfigOptions.test.ts +14 -5
- package/src/integration/BuildErrorHandler.ts +2 -2
- package/src/tokens/OpacityTokens.ts +1 -1
- package/src/tokens/__tests__/OpacityTokens.test.ts +3 -1
- package/src/tokens/semantic/BlendTokens.ts +26 -5
- package/src/tokens/semantic/OpacityTokens.ts +4 -4
- package/src/tools/release/__tests__/ReleasePipeline.test.ts +1 -1
- package/src/types/ComponentTypes.ts +1 -1
- package/src/types/generated/TokenTypes.ts +1 -1
- package/src/validators/StemmaTokenUsageValidator.ts +3 -2
- package/token-index/semantics.yaml +1 -2
package/mcp-server/src/index.ts
CHANGED
|
@@ -346,7 +346,15 @@ class MCPDocumentationServer {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
/**
|
|
349
|
-
* Set up graceful shutdown handlers
|
|
349
|
+
* Set up graceful shutdown handlers.
|
|
350
|
+
*
|
|
351
|
+
* stdin EOF ('end'/'close') is a first-class shutdown trigger: a stdio MCP server
|
|
352
|
+
* whose parent client died (or gracefully closed the pipe) has no one to serve, but
|
|
353
|
+
* the file watcher keeps the event loop alive forever — found live at Spec 122 U3
|
|
354
|
+
* (~230 orphaned servers accumulated across harness runs, wedging later boots).
|
|
355
|
+
* Self-exiting on EOF also makes graceful client closes immediate: the MCP SDK's
|
|
356
|
+
* StdioClientTransport.close() ends stdin and waits up to 2s for exactly this exit
|
|
357
|
+
* before escalating to SIGTERM.
|
|
350
358
|
*/
|
|
351
359
|
private setupShutdownHandlers(): void {
|
|
352
360
|
const shutdown = async () => {
|
|
@@ -356,6 +364,8 @@ class MCPDocumentationServer {
|
|
|
356
364
|
|
|
357
365
|
process.on('SIGINT', shutdown);
|
|
358
366
|
process.on('SIGTERM', shutdown);
|
|
367
|
+
process.stdin.on('end', shutdown);
|
|
368
|
+
process.stdin.on('close', shutdown);
|
|
359
369
|
}
|
|
360
370
|
}
|
|
361
371
|
|
|
@@ -417,11 +427,19 @@ async function main(): Promise<void> {
|
|
|
417
427
|
await server.start();
|
|
418
428
|
}
|
|
419
429
|
|
|
420
|
-
// Run the server
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
430
|
+
// Run the server ONLY when this module is the process entry point (executed
|
|
431
|
+
// directly — `node mcp-server/dist/index.js`, or the esbuild `dist/mcp/docs-mcp.js`
|
|
432
|
+
// bundle a consumer launches). Importing this module as a LIBRARY (Spec 122 imports
|
|
433
|
+
// the re-exported WORKFLOW_RULES from the package entry) must NOT start the server;
|
|
434
|
+
// the previous unconditional call started an MCP server as an import side effect.
|
|
435
|
+
// This module is CommonJS (tsconfig `module: commonjs`), so `require.main === module`
|
|
436
|
+
// is the correct entry check — verified to still fire under the esbuild CJS bundle.
|
|
437
|
+
if (require.main === module) {
|
|
438
|
+
main().catch((error) => {
|
|
439
|
+
console.error('[MCP Server] Fatal error:', error);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
425
443
|
|
|
426
444
|
// Export for testing
|
|
427
445
|
export { MCPDocumentationServer };
|
|
@@ -4,7 +4,7 @@ import { extractMetadata } from './metadata-parser';
|
|
|
4
4
|
import { extractFrontmatterInfo } from './frontmatter-parser';
|
|
5
5
|
import { extractHeadingStructure } from './heading-parser';
|
|
6
6
|
import { resolveSection, SectionLookup } from './section-parser';
|
|
7
|
-
import { extractCrossReferences } from './cross-ref-parser';
|
|
7
|
+
import { extractCrossReferences, CrossReference as ParsedCrossReference } from './cross-ref-parser';
|
|
8
8
|
import { estimateTokenCount } from '../utils/token-estimator';
|
|
9
9
|
import { determineIndexHealth } from './index-health';
|
|
10
10
|
import { seedLegacyPathsFromFrozenManifest } from '../legacy-path';
|
|
@@ -65,6 +65,25 @@ export class DocumentIndexer {
|
|
|
65
65
|
private idIndex: Map<string, string> = new Map(); // id → indexedKey
|
|
66
66
|
private legacyPathIndex: Map<string, string> = new Map(); // normalizedLegacyPath → indexedKey
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Validated cross-references per indexed key (Spec 119-B OB-1, Decision 1:
|
|
70
|
+
* extract-then-validate at INDEX time, one validation point). indexFile
|
|
71
|
+
* stores the raw parser output (bare-id candidates still tagged); the
|
|
72
|
+
* post-index validation pass (or reindexFile's inline pass) replaces it with
|
|
73
|
+
* the validated, TAG-STRIPPED set — the candidate tag never escapes the
|
|
74
|
+
* indexer. All read surfaces (list_cross_references, getDocumentSummary,
|
|
75
|
+
* index-health metrics) consume THIS map, never re-extract, so the
|
|
76
|
+
* crossReferences count is a stable index property.
|
|
77
|
+
*/
|
|
78
|
+
private crossRefsByKey: Map<string, ParsedCrossReference[]> = new Map();
|
|
79
|
+
/**
|
|
80
|
+
* Bare-id candidates dropped by validation (target not in idIndex) — the
|
|
81
|
+
* typo-suspicious class. Surfaced on two channels (design Component 6):
|
|
82
|
+
* scan-cross-references.sh lists individually; index-health emits ONE
|
|
83
|
+
* aggregate warning when count > 0.
|
|
84
|
+
*/
|
|
85
|
+
private droppedIdCandidates: Array<{ sourceKey: string; target: string; section: string; lineNumber: number }> = [];
|
|
86
|
+
|
|
68
87
|
private lastIndexTime: string | undefined;
|
|
69
88
|
private directoryPath: string | undefined;
|
|
70
89
|
private logsDirectory: string;
|
|
@@ -104,6 +123,8 @@ export class DocumentIndexer {
|
|
|
104
123
|
this.documentContent.clear();
|
|
105
124
|
this.idIndex.clear();
|
|
106
125
|
this.legacyPathIndex.clear();
|
|
126
|
+
this.crossRefsByKey.clear();
|
|
127
|
+
this.droppedIdCandidates = [];
|
|
107
128
|
// RE-SEED OBLIGATION (Task 3.2): legacyPathIndex is seeded out-of-band from a
|
|
108
129
|
// build-time manifest via loadLegacyPathManifest — it is NOT derived from
|
|
109
130
|
// on-disk scanning (the original `.kiro/steering/…` paths no longer exist
|
|
@@ -131,6 +152,13 @@ export class DocumentIndexer {
|
|
|
131
152
|
// no legacy fallback (correct degraded behavior).
|
|
132
153
|
this.seedLegacyPaths();
|
|
133
154
|
|
|
155
|
+
// CROSS-REF VALIDATION PASS (Spec 119-B OB-1): joins the same post-index
|
|
156
|
+
// hook as the legacy re-seed — idIndex is complete here, so every bare-id
|
|
157
|
+
// candidate can be validated in ONE place (Decision 1: index-time, never
|
|
158
|
+
// query-time). Hits become real cross-references (target already the doc
|
|
159
|
+
// id); misses are dropped and recorded for the two surfacing channels.
|
|
160
|
+
this.validateAllCrossReferences();
|
|
161
|
+
|
|
134
162
|
// Update last index time
|
|
135
163
|
this.lastIndexTime = new Date().toISOString();
|
|
136
164
|
|
|
@@ -157,12 +185,20 @@ export class DocumentIndexer {
|
|
|
157
185
|
this.pruneAddressingEntriesForKey(filePath);
|
|
158
186
|
this.documentMap.delete(filePath);
|
|
159
187
|
this.documentContent.delete(filePath);
|
|
188
|
+
this.crossRefsByKey.delete(filePath);
|
|
189
|
+
this.droppedIdCandidates = this.droppedIdCandidates.filter(d => d.sourceKey !== filePath);
|
|
160
190
|
this.lastIndexTime = new Date().toISOString();
|
|
161
191
|
return;
|
|
162
192
|
}
|
|
163
193
|
|
|
164
194
|
// Re-index the file (the re-add branch repopulates idIndex via indexFile).
|
|
165
195
|
await this.indexFile(filePath);
|
|
196
|
+
// Inline cross-ref validation against the STANDING idIndex (Spec 119-B
|
|
197
|
+
// OB-1) — same validation function as the full-rebuild pass. ACCEPTED EDGE
|
|
198
|
+
// (pinned by test): a ref to a NEW doc B not yet in the standing idIndex is
|
|
199
|
+
// dropped until the next full rebuild — covered operationally by the
|
|
200
|
+
// write-side rebuild protocol.
|
|
201
|
+
this.validateCrossReferencesForKey(filePath);
|
|
166
202
|
this.lastIndexTime = new Date().toISOString();
|
|
167
203
|
}
|
|
168
204
|
|
|
@@ -206,14 +242,16 @@ export class DocumentIndexer {
|
|
|
206
242
|
* @param filePath - Path to document
|
|
207
243
|
*/
|
|
208
244
|
getDocumentSummary(filePath: string): DocumentSummary {
|
|
209
|
-
const
|
|
245
|
+
const { indexedKey } = this.resolveRef(filePath);
|
|
246
|
+
const content = this.documentContent.get(indexedKey)!;
|
|
210
247
|
const metadata = extractMetadata(content);
|
|
211
248
|
|
|
212
249
|
// Extract heading structure
|
|
213
250
|
const outline = extractHeadingStructure(content);
|
|
214
251
|
|
|
215
|
-
//
|
|
216
|
-
|
|
252
|
+
// Cross-references: the VALIDATED index-time set (Spec 119-B OB-1) — same
|
|
253
|
+
// single source list_cross_references serves; dropped candidates excluded.
|
|
254
|
+
const crossReferences = this.crossRefsByKey.get(indexedKey) ?? [];
|
|
217
255
|
|
|
218
256
|
// Convert CrossReference[] to CrossReferenceInfo[]
|
|
219
257
|
const crossReferenceInfo = crossReferences.map(ref => ({
|
|
@@ -350,12 +388,71 @@ export class DocumentIndexer {
|
|
|
350
388
|
/**
|
|
351
389
|
* List cross-references in a document
|
|
352
390
|
* Returns links without following them
|
|
353
|
-
*
|
|
354
|
-
*
|
|
391
|
+
*
|
|
392
|
+
* Spec 119-B OB-1: the incoming ref routes through the SAME resolver chain
|
|
393
|
+
* as every other document-addressed tool (id → indexed key → legacy path,
|
|
394
|
+
* D5), and the result is the VALIDATED index-time set — `.md` path refs
|
|
395
|
+
* unchanged, bare-id refs enumerated with target = the doc id; dropped
|
|
396
|
+
* candidates never appear. Public shape unchanged (no kind tag).
|
|
397
|
+
*
|
|
398
|
+
* @param filePath - Document ref: id, indexed relative path, or legacy path
|
|
355
399
|
*/
|
|
356
400
|
listCrossReferences(filePath: string): CrossReference[] {
|
|
357
|
-
const
|
|
358
|
-
return
|
|
401
|
+
const { indexedKey } = this.resolveRef(filePath);
|
|
402
|
+
return this.crossRefsByKey.get(indexedKey) ?? [];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Bare-id candidates dropped by validation (Spec 119-B OB-1) — the
|
|
407
|
+
* typo-suspicious class surfaced via index-health's aggregate warning and
|
|
408
|
+
* scan-cross-references.sh's individual listing.
|
|
409
|
+
*/
|
|
410
|
+
getDroppedIdCandidates(): ReadonlyArray<{ sourceKey: string; target: string; section: string; lineNumber: number }> {
|
|
411
|
+
return this.droppedIdCandidates;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Validate ALL stored cross-reference sets against the completed idIndex
|
|
416
|
+
* (the post-index hook — Spec 119-B OB-1, Decision 1's single validation
|
|
417
|
+
* point). Resets the dropped-candidate record.
|
|
418
|
+
*/
|
|
419
|
+
private validateAllCrossReferences(): void {
|
|
420
|
+
this.droppedIdCandidates = [];
|
|
421
|
+
for (const key of this.crossRefsByKey.keys()) {
|
|
422
|
+
this.validateCrossReferencesForKey(key);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Validate one document's stored cross-references against the CURRENT
|
|
428
|
+
* idIndex. `.md` path refs pass through untouched; bare-id candidates are
|
|
429
|
+
* kept iff their target is a known doc id (tag stripped — it never escapes
|
|
430
|
+
* the indexer) and dropped-with-record otherwise.
|
|
431
|
+
*/
|
|
432
|
+
private validateCrossReferencesForKey(key: string): void {
|
|
433
|
+
const raw = this.crossRefsByKey.get(key);
|
|
434
|
+
if (!raw) return;
|
|
435
|
+
|
|
436
|
+
// Replace any prior dropped entries for this key (reindex path).
|
|
437
|
+
this.droppedIdCandidates = this.droppedIdCandidates.filter(d => d.sourceKey !== key);
|
|
438
|
+
|
|
439
|
+
const validated: ParsedCrossReference[] = [];
|
|
440
|
+
for (const ref of raw) {
|
|
441
|
+
if (ref.kind !== 'id-candidate') {
|
|
442
|
+
validated.push(ref);
|
|
443
|
+
} else if (this.idIndex.has(ref.target)) {
|
|
444
|
+
const { kind: _kind, ...publicRef } = ref;
|
|
445
|
+
validated.push(publicRef);
|
|
446
|
+
} else {
|
|
447
|
+
this.droppedIdCandidates.push({
|
|
448
|
+
sourceKey: key,
|
|
449
|
+
target: ref.target,
|
|
450
|
+
section: ref.section,
|
|
451
|
+
lineNumber: ref.lineNumber,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
this.crossRefsByKey.set(key, validated);
|
|
359
456
|
}
|
|
360
457
|
|
|
361
458
|
/**
|
|
@@ -483,6 +580,13 @@ export class DocumentIndexer {
|
|
|
483
580
|
|
|
484
581
|
this.documentMap.set(filePath, documentMetadata);
|
|
485
582
|
|
|
583
|
+
// Store the RAW parser output (bare-id candidates still tagged) for the
|
|
584
|
+
// post-index validation pass (Spec 119-B OB-1). Mid-indexing this map may
|
|
585
|
+
// hold unvalidated candidates — accepted and explicit (design Component 6):
|
|
586
|
+
// no consumer reads cross-refs during indexing; indexing is atomic from the
|
|
587
|
+
// API's view.
|
|
588
|
+
this.crossRefsByKey.set(filePath, extractCrossReferences(content, filePath));
|
|
589
|
+
|
|
486
590
|
// Build the id index (id → indexed key). First drop any PRIOR id forward-entry
|
|
487
591
|
// that still points at this same key — covers an in-place id rewrite (reindexFile
|
|
488
592
|
// re-add branch with NO delete event), which would otherwise leave a stale
|
|
@@ -690,10 +794,16 @@ export class DocumentIndexer {
|
|
|
690
794
|
}
|
|
691
795
|
|
|
692
796
|
// Use determineIndexHealth for comprehensive health check
|
|
797
|
+
let validatedCount = 0;
|
|
798
|
+
for (const refs of this.crossRefsByKey.values()) validatedCount += refs.length;
|
|
693
799
|
const health = determineIndexHealth({
|
|
694
800
|
indexedDocuments: this.documentContent,
|
|
695
801
|
directoryPath: this.directoryPath,
|
|
696
|
-
lastIndexTime: this.lastIndexTime
|
|
802
|
+
lastIndexTime: this.lastIndexTime,
|
|
803
|
+
crossRefTotals: {
|
|
804
|
+
validatedCount,
|
|
805
|
+
droppedBareIdCount: this.droppedIdCandidates.length
|
|
806
|
+
}
|
|
697
807
|
});
|
|
698
808
|
|
|
699
809
|
this.logIndexStateChange('validation_completed', {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare-id cross-reference enumeration — unit tests (Spec 119-B OB-1, R9,
|
|
3
|
+
* design Component 6).
|
|
4
|
+
*
|
|
5
|
+
* Covers: the parser's bare-id candidate grammar (positives + false-positive
|
|
6
|
+
* guards), `.md` extraction unchanged (no tag on path refs), the indexer's
|
|
7
|
+
* post-index validation pass (hits kept tag-stripped, misses dropped with
|
|
8
|
+
* record), the migrated-doc enumeration case (token-governance-pattern
|
|
9
|
+
* fixture), the reindexFile ACCEPTED EDGE (new-doc ref dropped until full
|
|
10
|
+
* rebuild), the D5 addressing contract on listCrossReferences, and the
|
|
11
|
+
* index-health aggregate warning + stable validated count.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { extractCrossReferences, BARE_ID_GRAMMAR } from '../cross-ref-parser';
|
|
17
|
+
import { DocumentIndexer } from '../DocumentIndexer';
|
|
18
|
+
|
|
19
|
+
const TEST_FIXTURES_DIR = path.join(__dirname, 'fixtures');
|
|
20
|
+
|
|
21
|
+
function docWithId(id: string, h1: string, body: string): string {
|
|
22
|
+
return `---
|
|
23
|
+
name: ${h1}
|
|
24
|
+
id: ${id}
|
|
25
|
+
description: fixture doc
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# ${h1}
|
|
29
|
+
|
|
30
|
+
**Date**: 2026-08-02
|
|
31
|
+
**Purpose**: OB-1 fixture
|
|
32
|
+
**Organization**: test-org
|
|
33
|
+
**Scope**: test
|
|
34
|
+
**Layer**: 2
|
|
35
|
+
**Relevant Tasks**: testing
|
|
36
|
+
**Last Reviewed**: 2026-08-02
|
|
37
|
+
|
|
38
|
+
## Body
|
|
39
|
+
|
|
40
|
+
${body}
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Parser: grammar + guards (7.2)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('cross-ref parser — bare-id candidate extraction (Spec 119-B OB-1)', () => {
|
|
49
|
+
it('grammar positives: extracts bare-id candidates with the internal tag', () => {
|
|
50
|
+
const content = '## S\n\nsee [Token Governance](token-governance) and [x](a1) and [y](x-y-z2).';
|
|
51
|
+
const refs = extractCrossReferences(content, 'test.md');
|
|
52
|
+
expect(refs).toHaveLength(3);
|
|
53
|
+
expect(refs.map(r => r.target)).toEqual(['token-governance', 'a1', 'x-y-z2']);
|
|
54
|
+
for (const r of refs) expect(r.kind).toBe('id-candidate');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('false-positive guards: anchors, URLs, paths, dotted names, uppercase, underscores, code-ish targets are NOT candidates', () => {
|
|
58
|
+
const content = [
|
|
59
|
+
'## S',
|
|
60
|
+
'[anchor](#section)',
|
|
61
|
+
'[url](https://example.com/page)',
|
|
62
|
+
'[url2](mailto:x@y.z)',
|
|
63
|
+
'[relpath](./sub/thing)',
|
|
64
|
+
'[abspath](/root/thing)',
|
|
65
|
+
'[dotted](file.txt)',
|
|
66
|
+
'[upper](Not-An-Id)',
|
|
67
|
+
'[underscore](not_an_id)',
|
|
68
|
+
'[hyphen-start](-bad)',
|
|
69
|
+
'[empty]()',
|
|
70
|
+
].join('\n');
|
|
71
|
+
const refs = extractCrossReferences(content, 'test.md');
|
|
72
|
+
expect(refs).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('.md extraction is unchanged: path refs carry NO kind tag and keep their exact shape', () => {
|
|
76
|
+
const content = '## Section One\n\nsee [Guide](./guide.md) and [Other](docs/other.md#anchor).';
|
|
77
|
+
const refs = extractCrossReferences(content, 'test.md');
|
|
78
|
+
expect(refs).toEqual([
|
|
79
|
+
{ target: './guide.md', context: 'Guide', section: 'Section One', lineNumber: 3 },
|
|
80
|
+
{ target: 'docs/other.md#anchor', context: 'Other', section: 'Section One', lineNumber: 3 },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('mixed line: .md ref and bare-id candidate extracted side by side', () => {
|
|
85
|
+
const content = '## S\n\n[A](a.md) then [B](token-governance) then [C](#x).';
|
|
86
|
+
const refs = extractCrossReferences(content, 'test.md');
|
|
87
|
+
expect(refs.map(r => [r.target, r.kind ?? 'path'])).toEqual([
|
|
88
|
+
['a.md', 'path'],
|
|
89
|
+
['token-governance', 'id-candidate'],
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('grammar constant rejects / . : # by construction', () => {
|
|
94
|
+
for (const bad of ['a/b', 'a.b', 'a:b', 'a#b', 'A', '-a', '']) {
|
|
95
|
+
expect(BARE_ID_GRAMMAR.test(bad) && !/[/.:#]/.test(bad)).toBe(false);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Indexer: validation pass + surfacing + D5 (7.3 / 7.4)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe('DocumentIndexer — bare-id validation on the post-index hook (Spec 119-B OB-1)', () => {
|
|
105
|
+
let indexer: DocumentIndexer;
|
|
106
|
+
let testDir: string;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
indexer = new DocumentIndexer();
|
|
110
|
+
testDir = path.join(TEST_FIXTURES_DIR, `bareid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
111
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('validates candidates against idIndex: hits kept (tag stripped), misses dropped with record', async () => {
|
|
119
|
+
fs.writeFileSync(
|
|
120
|
+
path.join(testDir, 'doc-a.md'),
|
|
121
|
+
docWithId('doc-a', 'Doc A', 'see [B](doc-b), [bogus](no-such-id), and [file](other.md).')
|
|
122
|
+
);
|
|
123
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'plain body.'));
|
|
124
|
+
await indexer.indexDirectory(testDir);
|
|
125
|
+
|
|
126
|
+
const refs = indexer.listCrossReferences('doc-a');
|
|
127
|
+
expect(refs.map(r => r.target).sort()).toEqual(['doc-b', 'other.md']);
|
|
128
|
+
// The candidate tag never escapes the indexer (public shape unchanged)
|
|
129
|
+
for (const r of refs) expect('kind' in r).toBe(false);
|
|
130
|
+
|
|
131
|
+
const dropped = indexer.getDroppedIdCandidates();
|
|
132
|
+
expect(dropped).toHaveLength(1);
|
|
133
|
+
expect(dropped[0].target).toBe('no-such-id');
|
|
134
|
+
expect(dropped[0].sourceKey).toContain('doc-a.md');
|
|
135
|
+
expect(dropped[0].lineNumber).toBeGreaterThan(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('migrated-doc enumeration: a token-governance-pattern doc has its bare-id refs enumerated', async () => {
|
|
139
|
+
// Mirrors the real post-U3 pattern: prose linking sibling docs by bare id
|
|
140
|
+
const body = [
|
|
141
|
+
'Token selection: see [Token Quick Reference](token-quick-reference) for patterns,',
|
|
142
|
+
'[Rosetta System Architecture](rosetta-system-architecture) for the pipeline,',
|
|
143
|
+
'and the [DTCG guide](dtcg-integration-guide) for exports.',
|
|
144
|
+
].join('\n');
|
|
145
|
+
fs.writeFileSync(path.join(testDir, 'token-governance.md'), docWithId('token-governance', 'Token Governance', body));
|
|
146
|
+
fs.writeFileSync(path.join(testDir, 'tqr.md'), docWithId('token-quick-reference', 'Token Quick Reference', 'x'));
|
|
147
|
+
fs.writeFileSync(path.join(testDir, 'rsa.md'), docWithId('rosetta-system-architecture', 'Rosetta System Architecture', 'x'));
|
|
148
|
+
fs.writeFileSync(path.join(testDir, 'dtcg.md'), docWithId('dtcg-integration-guide', 'DTCG Integration Guide', 'x'));
|
|
149
|
+
await indexer.indexDirectory(testDir);
|
|
150
|
+
|
|
151
|
+
const refs = indexer.listCrossReferences('token-governance');
|
|
152
|
+
expect(refs.map(r => r.target).sort()).toEqual([
|
|
153
|
+
'dtcg-integration-guide',
|
|
154
|
+
'rosetta-system-architecture',
|
|
155
|
+
'token-quick-reference',
|
|
156
|
+
]);
|
|
157
|
+
expect(indexer.getDroppedIdCandidates()).toHaveLength(0);
|
|
158
|
+
// Context + section + line survive for bare-id refs (same fields as .md refs)
|
|
159
|
+
expect(refs[0].section).toBe('Body');
|
|
160
|
+
expect(refs[0].context.length).toBeGreaterThan(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('ACCEPTED EDGE (pinned): reindexFile drops a ref to a NEW doc not yet in the standing idIndex; the next full rebuild restores it', async () => {
|
|
164
|
+
const aPath = path.join(testDir, 'doc-a.md');
|
|
165
|
+
fs.writeFileSync(aPath, docWithId('doc-a', 'Doc A', 'no links yet.'));
|
|
166
|
+
await indexer.indexDirectory(testDir);
|
|
167
|
+
|
|
168
|
+
// NEW doc B lands on disk; doc A is edited to reference it; ONLY A is reindexed.
|
|
169
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'new doc.'));
|
|
170
|
+
fs.writeFileSync(aPath, docWithId('doc-a', 'Doc A', 'now see [B](doc-b).'));
|
|
171
|
+
await indexer.reindexFile(aPath);
|
|
172
|
+
|
|
173
|
+
// B is not in the standing idIndex (it was never indexed) → ref dropped
|
|
174
|
+
expect(indexer.listCrossReferences('doc-a').map(r => r.target)).toEqual([]);
|
|
175
|
+
expect(indexer.getDroppedIdCandidates().map(d => d.target)).toEqual(['doc-b']);
|
|
176
|
+
|
|
177
|
+
// Full rebuild: idIndex complete → the ref validates
|
|
178
|
+
await indexer.indexDirectory(testDir);
|
|
179
|
+
expect(indexer.listCrossReferences('doc-a').map(r => r.target)).toEqual(['doc-b']);
|
|
180
|
+
expect(indexer.getDroppedIdCandidates()).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('reindexFile validates inline when the target IS in the standing idIndex', async () => {
|
|
184
|
+
const aPath = path.join(testDir, 'doc-a.md');
|
|
185
|
+
fs.writeFileSync(aPath, docWithId('doc-a', 'Doc A', 'no links yet.'));
|
|
186
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'x'));
|
|
187
|
+
await indexer.indexDirectory(testDir);
|
|
188
|
+
|
|
189
|
+
fs.writeFileSync(aPath, docWithId('doc-a', 'Doc A', 'now see [B](doc-b).'));
|
|
190
|
+
await indexer.reindexFile(aPath);
|
|
191
|
+
expect(indexer.listCrossReferences('doc-a').map(r => r.target)).toEqual(['doc-b']);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('D5: listCrossReferences resolves via id, indexed key, and ./-prefixed key through the shared resolver chain', async () => {
|
|
195
|
+
const aPath = path.join(testDir, 'doc-a.md');
|
|
196
|
+
fs.writeFileSync(aPath, docWithId('doc-a', 'Doc A', 'see [B](doc-b) and [G](./guide.md).'));
|
|
197
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'x'));
|
|
198
|
+
await indexer.indexDirectory(testDir);
|
|
199
|
+
|
|
200
|
+
const byId = indexer.listCrossReferences('doc-a');
|
|
201
|
+
const byKey = indexer.listCrossReferences(aPath);
|
|
202
|
+
const byDotSlash = indexer.listCrossReferences(`./${aPath}`);
|
|
203
|
+
expect(byKey).toEqual(byId);
|
|
204
|
+
expect(byDotSlash).toEqual(byId);
|
|
205
|
+
expect(byId.map(r => r.target).sort()).toEqual(['./guide.md', 'doc-b']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('D5 miss: an unresolvable ref throws DocumentNotResolved (same contract as the other document tools)', async () => {
|
|
209
|
+
fs.writeFileSync(path.join(testDir, 'doc-a.md'), docWithId('doc-a', 'Doc A', 'x'));
|
|
210
|
+
await indexer.indexDirectory(testDir);
|
|
211
|
+
expect(() => indexer.listCrossReferences('nope-never')).toThrow(/Document not found/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('getDocumentSummary serves the same validated set (dropped candidates never appear)', async () => {
|
|
215
|
+
fs.writeFileSync(
|
|
216
|
+
path.join(testDir, 'doc-a.md'),
|
|
217
|
+
docWithId('doc-a', 'Doc A', 'see [B](doc-b) and [bogus](no-such-id).')
|
|
218
|
+
);
|
|
219
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'x'));
|
|
220
|
+
await indexer.indexDirectory(testDir);
|
|
221
|
+
|
|
222
|
+
const summary = indexer.getDocumentSummary('doc-a');
|
|
223
|
+
expect(summary.crossReferences.map(r => r.target)).toEqual(['doc-b']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('index-health: totalCrossReferences is the validated count and drops emit ONE aggregate warning', async () => {
|
|
227
|
+
fs.writeFileSync(
|
|
228
|
+
path.join(testDir, 'doc-a.md'),
|
|
229
|
+
docWithId('doc-a', 'Doc A', 'see [B](doc-b), [bogus](no-such-id), and [file](guide.md).')
|
|
230
|
+
);
|
|
231
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'x'));
|
|
232
|
+
await indexer.indexDirectory(testDir);
|
|
233
|
+
|
|
234
|
+
const health = indexer.getIndexHealth();
|
|
235
|
+
// validated = doc-b (bare-id hit) + guide.md (path ref) = 2; bogus dropped
|
|
236
|
+
expect(health.metrics.totalCrossReferences).toBe(2);
|
|
237
|
+
const warning = health.warnings.filter(w => w.includes('unresolved bare-id'));
|
|
238
|
+
expect(warning).toHaveLength(1);
|
|
239
|
+
expect(warning[0]).toBe('1 unresolved bare-id link targets — run scan-cross-references.sh for the list');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('index-health: zero drops emits NO bare-id warning', async () => {
|
|
243
|
+
fs.writeFileSync(path.join(testDir, 'doc-a.md'), docWithId('doc-a', 'Doc A', 'see [B](doc-b).'));
|
|
244
|
+
fs.writeFileSync(path.join(testDir, 'doc-b.md'), docWithId('doc-b', 'Doc B', 'x'));
|
|
245
|
+
await indexer.indexDirectory(testDir);
|
|
246
|
+
|
|
247
|
+
const health = indexer.getIndexHealth();
|
|
248
|
+
expect(health.warnings.filter(w => w.includes('unresolved bare-id'))).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -8,12 +8,28 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export interface CrossReference {
|
|
11
|
-
target: string; // Referenced document path
|
|
11
|
+
target: string; // Referenced document path (or doc id, for bare-id refs)
|
|
12
12
|
context: string; // Context description from link text
|
|
13
13
|
section: string; // Source section containing reference
|
|
14
14
|
lineNumber: number; // Line number in source file
|
|
15
|
+
/**
|
|
16
|
+
* INTERNAL-ONLY candidate tag (Spec 119-B OB-1). Present ONLY on bare-id
|
|
17
|
+
* candidates awaiting idIndex validation; `.md` path refs never carry it
|
|
18
|
+
* (their extracted shape is unchanged). The tag never escapes the indexer:
|
|
19
|
+
* validation strips it before refs reach any public surface.
|
|
20
|
+
*/
|
|
21
|
+
kind?: 'id-candidate';
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Bare-id candidate grammar (Spec 119-B OB-1 / design Component 6): a link
|
|
26
|
+
* target is an id candidate IF it matches this AND contains none of `/ . : #`
|
|
27
|
+
* (the character class already excludes them; the explicit guard in the
|
|
28
|
+
* extractor documents the contract). Validation against idIndex happens in the
|
|
29
|
+
* indexer's post-index pass — the parser stays a dumb extractor.
|
|
30
|
+
*/
|
|
31
|
+
export const BARE_ID_GRAMMAR = /^[a-z0-9][a-z0-9-]*$/;
|
|
32
|
+
|
|
17
33
|
/**
|
|
18
34
|
* Extract cross-references from markdown content
|
|
19
35
|
*
|
|
@@ -57,6 +73,18 @@ export function extractCrossReferences(content: string, _filePath: string): Cros
|
|
|
57
73
|
section: currentSection,
|
|
58
74
|
lineNumber: i + 1
|
|
59
75
|
});
|
|
76
|
+
} else if (BARE_ID_GRAMMAR.test(target) && !/[/.:#]/.test(target)) {
|
|
77
|
+
// Bare-id candidate (Spec 119-B OB-1): tagged, NOT validated here —
|
|
78
|
+
// anchors (#…), URLs (contain :/), and paths (contain / or .) never
|
|
79
|
+
// reach this branch. The indexer's post-index pass validates against
|
|
80
|
+
// idIndex and drops misses.
|
|
81
|
+
references.push({
|
|
82
|
+
target,
|
|
83
|
+
context,
|
|
84
|
+
section: currentSection,
|
|
85
|
+
lineNumber: i + 1,
|
|
86
|
+
kind: 'id-candidate'
|
|
87
|
+
});
|
|
60
88
|
}
|
|
61
89
|
}
|
|
62
90
|
}
|
|
@@ -24,6 +24,17 @@ export interface HealthCheckOptions {
|
|
|
24
24
|
directoryPath: string;
|
|
25
25
|
/** Last index time (ISO string) */
|
|
26
26
|
lastIndexTime?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Validated cross-reference totals from the indexer (Spec 119-B OB-1).
|
|
29
|
+
* When provided, `totalCrossReferences` reports the VALIDATED index-time
|
|
30
|
+
* count (a stable index property — Decision 1) instead of a re-extraction,
|
|
31
|
+
* and `droppedBareIdCount > 0` emits ONE aggregate warning pointing at
|
|
32
|
+
* scan-cross-references.sh for the individual listing.
|
|
33
|
+
*/
|
|
34
|
+
crossRefTotals?: {
|
|
35
|
+
validatedCount: number;
|
|
36
|
+
droppedBareIdCount: number;
|
|
37
|
+
};
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
/**
|
|
@@ -38,7 +49,7 @@ export interface HealthCheckOptions {
|
|
|
38
49
|
* @returns IndexHealth with status, errors, warnings, and metrics
|
|
39
50
|
*/
|
|
40
51
|
export function determineIndexHealth(options: HealthCheckOptions): IndexHealth {
|
|
41
|
-
const { indexedDocuments, directoryPath, lastIndexTime } = options;
|
|
52
|
+
const { indexedDocuments, directoryPath, lastIndexTime, crossRefTotals } = options;
|
|
42
53
|
|
|
43
54
|
const errors: string[] = [];
|
|
44
55
|
const warnings: string[] = [];
|
|
@@ -68,9 +79,23 @@ export function determineIndexHealth(options: HealthCheckOptions): IndexHealth {
|
|
|
68
79
|
warnings.push(`Malformed metadata: ${malformedDocs.join(', ')}`);
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
// Dropped bare-id candidates: ONE aggregate warning on the daily-consumer
|
|
83
|
+
// channel (Spec 119-B OB-1, design Component 6) — per-item detail lives in
|
|
84
|
+
// the scanner, not here.
|
|
85
|
+
if (crossRefTotals && crossRefTotals.droppedBareIdCount > 0) {
|
|
86
|
+
warnings.push(
|
|
87
|
+
`${crossRefTotals.droppedBareIdCount} unresolved bare-id link targets — run scan-cross-references.sh for the list`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
// Calculate metrics
|
|
72
92
|
const metrics = calculateIndexMetrics(indexedDocuments);
|
|
73
|
-
|
|
93
|
+
// Spec 119-B OB-1: prefer the indexer's validated count (stable index
|
|
94
|
+
// property) over re-extraction when supplied.
|
|
95
|
+
if (crossRefTotals) {
|
|
96
|
+
metrics.totalCrossReferences = crossRefTotals.validatedCount;
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
// Determine status
|
|
75
100
|
let status: IndexHealthStatus;
|
|
76
101
|
if (errors.length > 0) {
|