@agentskit/doc-bridge 1.7.45 → 1.10.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 +488 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15871 -6218
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +95 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
- package/dist/index.d.ts +9880 -1062
- package/dist/index.js +16143 -6127
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +157 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +34 -15
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +126 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +197 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +485 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/scripts/verification-harness.mjs +0 -482
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { parseDocument, type Node as YamlNode } from 'yaml'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
1
4
|
import { contentHashForArtifactV1 } from '../index-builder/content-hash.js'
|
|
2
5
|
import {
|
|
3
6
|
DiscoverySnapshotV1Schema,
|
|
@@ -24,6 +27,7 @@ export type DocumentationDeclarationOptions = {
|
|
|
24
27
|
readonly documentId?: string
|
|
25
28
|
/** Agent corpus root used for conservative package/app path inference. */
|
|
26
29
|
readonly agentRoot?: string
|
|
30
|
+
readonly entityLookup?: EntityLookup
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export type DocumentationDeclarationResult = {
|
|
@@ -48,6 +52,8 @@ type RelationFields = {
|
|
|
48
52
|
fields: Set<string>
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
type EntityLookup = ReadonlyMap<string, KnowledgeEntity>
|
|
56
|
+
|
|
51
57
|
const detectionValues = new Set(['static', 'dynamic', 'external'])
|
|
52
58
|
|
|
53
59
|
const evidence = (path: string, lineStart: number, lineEnd = lineStart): Evidence => ({
|
|
@@ -65,6 +71,15 @@ const diagnostic = (
|
|
|
65
71
|
lineEnd = lineStart,
|
|
66
72
|
): DocumentationDiagnostic => ({ code, message, path: 'docbridge', evidence: evidence(path, lineStart, lineEnd) })
|
|
67
73
|
|
|
74
|
+
const entityLookup = (entities: readonly KnowledgeEntity[]): EntityLookup => {
|
|
75
|
+
const lookup = new Map<string, KnowledgeEntity>()
|
|
76
|
+
for (const entity of entities) {
|
|
77
|
+
if (!lookup.has(entity.id)) lookup.set(entity.id, entity)
|
|
78
|
+
for (const alias of entity.aliases ?? []) if (!lookup.has(alias)) lookup.set(alias, entity)
|
|
79
|
+
}
|
|
80
|
+
return lookup
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
const scalar = (value: string): string => {
|
|
69
84
|
const trimmed = value.trim()
|
|
70
85
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
@@ -99,6 +114,71 @@ const parseListItem = (raw: string, indentation: number): string | undefined =>
|
|
|
99
114
|
return rest.trim()
|
|
100
115
|
}
|
|
101
116
|
|
|
117
|
+
/*
|
|
118
|
+
* The `docbridge` block is real YAML validated by Zod.
|
|
119
|
+
*
|
|
120
|
+
* It used to be read by a hand-written parser for a YAML subset, which meant a quoted list, a
|
|
121
|
+
* multi-line string or an anchor was a syntax error in a file every other tool considered valid.
|
|
122
|
+
* The schema is deliberately structural rather than semantic: it says what shape the block has,
|
|
123
|
+
* while the checks that a relation names all four of its fields and a known detection value stay
|
|
124
|
+
* downstream, where they already produce the aggregated messages this contract promises.
|
|
125
|
+
*/
|
|
126
|
+
const DocbridgeRelationSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
from: z.string().min(1).optional(),
|
|
129
|
+
to: z.string().min(1).optional(),
|
|
130
|
+
kind: z.string().min(1).optional(),
|
|
131
|
+
detection: z.string().min(1).optional(),
|
|
132
|
+
})
|
|
133
|
+
.strict()
|
|
134
|
+
|
|
135
|
+
const DocbridgeBlockSchema = z
|
|
136
|
+
.object({
|
|
137
|
+
covers: z.array(z.string().min(1)).optional(),
|
|
138
|
+
relations: z.array(DocbridgeRelationSchema).optional(),
|
|
139
|
+
})
|
|
140
|
+
.strict()
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Map a schema issue onto the diagnostic code it has always had.
|
|
144
|
+
*
|
|
145
|
+
* The codes are a published contract — a repository may fail its build on one — so adopting a
|
|
146
|
+
* schema must not rename them. Zod says precisely what is wrong and where; this decides which
|
|
147
|
+
* existing code that corresponds to.
|
|
148
|
+
*/
|
|
149
|
+
const issueCode = (issue: z.core.$ZodIssue): string => {
|
|
150
|
+
const [first, second, third] = issue.path
|
|
151
|
+
if (issue.code === 'unrecognized_keys') return 'DOCBRIDGE_FIELD_UNKNOWN'
|
|
152
|
+
if (first === undefined) return 'DOCBRIDGE_BLOCK_MALFORMED'
|
|
153
|
+
if (first === 'covers') return second === undefined ? 'DOCBRIDGE_COVERS_INVALID' : 'DOCBRIDGE_REFERENCE_MISSING'
|
|
154
|
+
if (first === 'relations') {
|
|
155
|
+
if (second === undefined) return 'DOCBRIDGE_RELATIONS_INVALID'
|
|
156
|
+
return third === undefined ? 'DOCBRIDGE_RELATION_INVALID' : 'DOCBRIDGE_FIELD_UNKNOWN'
|
|
157
|
+
}
|
|
158
|
+
return 'DOCBRIDGE_STRUCTURE_INVALID'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const issueMessage = (issue: z.core.$ZodIssue): string => {
|
|
162
|
+
const field = issue.path.length ? `docbridge.${issue.path.join('.')}` : 'docbridge'
|
|
163
|
+
const keys = issue.code === 'unrecognized_keys' ? `: ${issue.keys.join(', ')}` : ''
|
|
164
|
+
return `${field}: ${issue.message}${keys}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Absolute file line of an offset inside the frontmatter body. */
|
|
168
|
+
const lineResolver = (body: string, frontmatterLine: number) => (offset: number): number => {
|
|
169
|
+
const safe = Math.max(0, Math.min(offset, body.length))
|
|
170
|
+
let line = frontmatterLine + 1
|
|
171
|
+
for (let index = 0; index < safe; index += 1) if (body[index] === '\n') line += 1
|
|
172
|
+
return line
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type YamlRanged = { readonly range?: readonly [number, number, number] | null }
|
|
176
|
+
|
|
177
|
+
const nodeStart = (node: unknown): number | undefined => {
|
|
178
|
+
const range = (node as YamlRanged | null | undefined)?.range
|
|
179
|
+
return range ? range[0] : undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
102
182
|
const conventionalPackageReference = (path: string, agentRoot: string): string | undefined => {
|
|
103
183
|
const prefix = `${agentRoot.replace(/\/$/, '')}/`
|
|
104
184
|
if (!path.startsWith(prefix)) return undefined
|
|
@@ -139,8 +219,9 @@ const resolveEntity = (
|
|
|
139
219
|
input: DocumentationDeclarationInput,
|
|
140
220
|
lineStart: number,
|
|
141
221
|
unresolved: Map<string, KnowledgeEntity>,
|
|
222
|
+
lookup: EntityLookup,
|
|
142
223
|
): KnowledgeEntity => {
|
|
143
|
-
const direct =
|
|
224
|
+
const direct = lookup.get(reference)
|
|
144
225
|
if (direct) return direct
|
|
145
226
|
const packageReference = reference.replace(/^package:/, '')
|
|
146
227
|
const packageCandidates = entities.filter((entity) => {
|
|
@@ -150,6 +231,23 @@ const resolveEntity = (
|
|
|
150
231
|
})
|
|
151
232
|
const resolved = packageCandidates.length === 1 ? packageCandidates[0] : undefined
|
|
152
233
|
if (resolved) return resolved
|
|
234
|
+
|
|
235
|
+
/*
|
|
236
|
+
* An ownership id resolves to the area that carries it.
|
|
237
|
+
*
|
|
238
|
+
* Agent documents declare coverage by ownership id (`editRoot: src/query` becomes ownership
|
|
239
|
+
* `doc-bridge-query`), and until areas existed there was nothing in the graph with that
|
|
240
|
+
* identity — every such declaration became an unresolved reference, and the audit reported a
|
|
241
|
+
* documented area as a gap.
|
|
242
|
+
*/
|
|
243
|
+
const ownedAreas = entities.filter((entity) => entity.kind === 'area' && entity.metadata?.ownershipId === reference)
|
|
244
|
+
if (ownedAreas.length === 1 && ownedAreas[0]) return ownedAreas[0]
|
|
245
|
+
|
|
246
|
+
// A path the repository knows an area, a module or a document by names that thing.
|
|
247
|
+
const path = reference.replace(/^\.\//, '').replace(/\/$/, '')
|
|
248
|
+
const byPath = entities.filter((entity) => (entity.kind === 'area' || entity.kind === 'module' || entity.kind === 'document') && entity.path === path)
|
|
249
|
+
if (byPath.length === 1 && byPath[0]) return byPath[0]
|
|
250
|
+
|
|
153
251
|
const id = `unresolved:${reference}`
|
|
154
252
|
const existing = unresolved.get(id)
|
|
155
253
|
if (existing) return existing
|
|
@@ -166,6 +264,91 @@ const resolveEntity = (
|
|
|
166
264
|
|
|
167
265
|
const relationKey = (from: string, to: string, kind: string): string => `${from}\u0000${to}\u0000${kind}`
|
|
168
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Read the `docbridge` block as YAML.
|
|
269
|
+
*
|
|
270
|
+
* Returns `undefined` when YAML cannot read the block at all — a tab in the indentation, a
|
|
271
|
+
* duplicate key, a scalar where a mapping was opened. The lenient line scanner below then takes
|
|
272
|
+
* over, because on a mangled block a per-line diagnostic is more use to the author than a single
|
|
273
|
+
* parser error, and because those diagnostics are the published contract.
|
|
274
|
+
*/
|
|
275
|
+
const parseDocbridgeYaml = (
|
|
276
|
+
input: DocumentationDeclarationInput,
|
|
277
|
+
lines: readonly string[],
|
|
278
|
+
end: number,
|
|
279
|
+
):
|
|
280
|
+
| {
|
|
281
|
+
readonly covers: readonly { value: string; line: number }[]
|
|
282
|
+
readonly relations: readonly RelationFields[]
|
|
283
|
+
readonly diagnostics: readonly DocumentationDiagnostic[]
|
|
284
|
+
}
|
|
285
|
+
| undefined => {
|
|
286
|
+
const body = lines.slice(1, end).join('\n')
|
|
287
|
+
const document = parseDocument(body, { prettyErrors: false })
|
|
288
|
+
if (document.errors.length) return undefined
|
|
289
|
+
|
|
290
|
+
let data: unknown
|
|
291
|
+
try {
|
|
292
|
+
data = document.toJS()
|
|
293
|
+
} catch {
|
|
294
|
+
return undefined
|
|
295
|
+
}
|
|
296
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
|
|
297
|
+
const raw = (data as Record<string, unknown>).docbridge
|
|
298
|
+
const lineAt = lineResolver(body, 1)
|
|
299
|
+
const blockNode = document.get('docbridge', true) as YamlNode | undefined
|
|
300
|
+
|
|
301
|
+
const diagnostics: DocumentationDiagnostic[] = []
|
|
302
|
+
// `docbridge:` with nothing under it is empty, not malformed: DOCBRIDGE_CONTENT_MISSING says so
|
|
303
|
+
// downstream, and two diagnostics for one mistake is one too many.
|
|
304
|
+
if (raw === null || raw === undefined) return { covers: [], relations: [], diagnostics }
|
|
305
|
+
|
|
306
|
+
const parsed = DocbridgeBlockSchema.safeParse(raw)
|
|
307
|
+
if (!parsed.success) {
|
|
308
|
+
for (const issue of parsed.error.issues) {
|
|
309
|
+
const node = issue.path.length ? (document.getIn(['docbridge', ...issue.path], true) as YamlNode | undefined) : blockNode
|
|
310
|
+
diagnostics.push(diagnostic(input.path, issueCode(issue), issueMessage(issue), lineAt(nodeStart(node) ?? nodeStart(blockNode) ?? 0)))
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/*
|
|
315
|
+
* Keep what is individually valid. One unknown key should not discard the declarations around
|
|
316
|
+
* it: the author gets the precise error and the graph still gets the edges they did declare.
|
|
317
|
+
*/
|
|
318
|
+
const covers: { value: string; line: number }[] = []
|
|
319
|
+
const relations: RelationFields[] = []
|
|
320
|
+
// `raw` is neither null nor undefined here: the early return above settled that.
|
|
321
|
+
const record = typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined
|
|
322
|
+
|
|
323
|
+
if (Array.isArray(record?.covers)) {
|
|
324
|
+
for (const [index, entry] of record.covers.entries()) {
|
|
325
|
+
if (typeof entry !== 'string' || !entry.trim()) continue
|
|
326
|
+
const node = document.getIn(['docbridge', 'covers', index], true) as YamlNode | undefined
|
|
327
|
+
covers.push({ value: entry.trim(), line: lineAt(nodeStart(node) ?? nodeStart(blockNode) ?? 0) })
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (Array.isArray(record?.relations)) {
|
|
332
|
+
for (const [index, entry] of record.relations.entries()) {
|
|
333
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue
|
|
334
|
+
const node = document.getIn(['docbridge', 'relations', index], true) as YamlNode | undefined
|
|
335
|
+
const startLine = lineAt(nodeStart(node) ?? nodeStart(blockNode) ?? 0)
|
|
336
|
+
const relation: RelationFields = { startLine, endLine: startLine, fields: new Set() }
|
|
337
|
+
for (const field of ['from', 'to', 'kind', 'detection'] as const) {
|
|
338
|
+
const value = (entry as Record<string, unknown>)[field]
|
|
339
|
+
if (typeof value !== 'string' || !value.trim()) continue
|
|
340
|
+
relation.fields.add(field)
|
|
341
|
+
relation[field] = value.trim()
|
|
342
|
+
const valueNode = document.getIn(['docbridge', 'relations', index, field], true) as YamlNode | undefined
|
|
343
|
+
relation.endLine = Math.max(relation.endLine, lineAt(nodeStart(valueNode) ?? nodeStart(node) ?? 0))
|
|
344
|
+
}
|
|
345
|
+
relations.push(relation)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { covers, relations, diagnostics }
|
|
350
|
+
}
|
|
351
|
+
|
|
169
352
|
const parseBlock = (
|
|
170
353
|
input: DocumentationDeclarationInput,
|
|
171
354
|
options: Pick<DocumentationDeclarationOptions, 'agentRoot'> = {},
|
|
@@ -192,21 +375,42 @@ const parseBlock = (
|
|
|
192
375
|
let typeLine = -1
|
|
193
376
|
let packageLine = -1
|
|
194
377
|
let humanDocLine = -1
|
|
378
|
+
let editRootLine = -1
|
|
379
|
+
let idLine = -1
|
|
195
380
|
for (let index = 1; index < end; index += 1) {
|
|
196
381
|
const line = lines[index] ?? ''
|
|
197
382
|
if (docbridgeLine < 0 && /^docbridge\s*:/.test(line)) docbridgeLine = index
|
|
198
383
|
if (typeLine < 0 && /^type\s*:/.test(line)) typeLine = index
|
|
199
384
|
if (packageLine < 0 && /^package\s*:/.test(line)) packageLine = index
|
|
200
385
|
if (humanDocLine < 0 && /^humanDoc\s*:/.test(line)) humanDocLine = index
|
|
386
|
+
if (editRootLine < 0 && /^editRoot\s*:/.test(line)) editRootLine = index
|
|
387
|
+
if (idLine < 0 && /^id\s*:/.test(line)) idLine = index
|
|
201
388
|
}
|
|
202
389
|
if (docbridgeLine < 0) {
|
|
203
390
|
const type = typeLine >= 0 ? scalar(lines[typeLine]?.slice('type:'.length) ?? '') : ''
|
|
204
391
|
const packageReference = packageLine >= 0 ? scalar(lines[packageLine]?.slice('package:'.length) ?? '') : conventionalPath ?? ''
|
|
205
392
|
if (type === 'package' && packageReference) return { covers: [{ value: packageReference, line: packageLine >= 0 ? packageLine + 1 : typeLine + 1 }], relations: [], diagnostics: [], hasDocbridge: true }
|
|
206
393
|
if (conventionalPath) return { covers: [{ value: packageReference, line: humanDocLine >= 0 ? humanDocLine + 1 : 1 }], relations: [], diagnostics: [], hasDocbridge: true }
|
|
394
|
+
|
|
395
|
+
/*
|
|
396
|
+
* `id` plus `editRoot` is a coverage declaration.
|
|
397
|
+
*
|
|
398
|
+
* The corpus already uses this pair to own a directory — it is what fills the ownership map
|
|
399
|
+
* and the handoff — but discovery never read it, so the graph had no edge from the sidecar to
|
|
400
|
+
* the unit it owns. With areas in the graph the reference resolves, and a documented area
|
|
401
|
+
* stops being reported as a gap.
|
|
402
|
+
*/
|
|
403
|
+
const editRoot = editRootLine >= 0 ? scalar(lines[editRootLine]?.slice('editRoot:'.length) ?? '') : ''
|
|
404
|
+
const identifier = idLine >= 0 ? scalar(lines[idLine]?.slice('id:'.length) ?? '') : ''
|
|
405
|
+
if (editRoot && identifier) {
|
|
406
|
+
return { covers: [{ value: identifier, line: editRootLine + 1 }], relations: [], diagnostics: [], hasDocbridge: true }
|
|
407
|
+
}
|
|
207
408
|
return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
|
|
208
409
|
}
|
|
209
410
|
|
|
411
|
+
const fromYaml = parseDocbridgeYaml(input, lines, end)
|
|
412
|
+
if (fromYaml) return { ...fromYaml, hasDocbridge: true }
|
|
413
|
+
|
|
210
414
|
const diagnostics: DocumentationDiagnostic[] = []
|
|
211
415
|
const covers: { value: string; line: number }[] = []
|
|
212
416
|
const relations: RelationFields[] = []
|
|
@@ -304,13 +508,14 @@ export const parseDocumentationDeclarations = (
|
|
|
304
508
|
const relations: KnowledgeRelation[] = []
|
|
305
509
|
const documentId = options.documentId ?? `document:${input.path}`
|
|
306
510
|
const relationClaims = new Map<string, string>()
|
|
511
|
+
const lookup = options.entityLookup ?? entityLookup(options.snapshot.entities)
|
|
307
512
|
|
|
308
513
|
if (!parsed.covers.length && !parsed.relations.length) {
|
|
309
514
|
addDiagnostic(diagnostics, input, 'DOCBRIDGE_CONTENT_MISSING', 'docbridge must declare covers or relations.', 1)
|
|
310
515
|
}
|
|
311
516
|
|
|
312
517
|
for (const [index, cover] of parsed.covers.entries()) {
|
|
313
|
-
const target = resolveEntity(cover.value, options.snapshot.entities, input, cover.line, unresolved)
|
|
518
|
+
const target = resolveEntity(cover.value, options.snapshot.entities, input, cover.line, unresolved, lookup)
|
|
314
519
|
relations.push({
|
|
315
520
|
id: `relation:declared:${input.path}:covers:${index}`,
|
|
316
521
|
kind: 'covers',
|
|
@@ -336,8 +541,8 @@ export const parseDocumentationDeclarations = (
|
|
|
336
541
|
addDiagnostic(diagnostics, input, 'DOCBRIDGE_DETECTION_INVALID', `Invalid relation detection: ${detection}.`, declaration.startLine, declaration.endLine)
|
|
337
542
|
continue
|
|
338
543
|
}
|
|
339
|
-
const fromEntity = resolveEntity(from, options.snapshot.entities, input, declaration.startLine, unresolved)
|
|
340
|
-
const toEntity = resolveEntity(to, options.snapshot.entities, input, declaration.startLine, unresolved)
|
|
544
|
+
const fromEntity = resolveEntity(from, options.snapshot.entities, input, declaration.startLine, unresolved, lookup)
|
|
545
|
+
const toEntity = resolveEntity(to, options.snapshot.entities, input, declaration.startLine, unresolved, lookup)
|
|
341
546
|
const key = relationKey(fromEntity.id, toEntity.id, kind)
|
|
342
547
|
const previousDetection = relationClaims.get(key)
|
|
343
548
|
if (previousDetection === detection) addDiagnostic(diagnostics, input, 'DOCBRIDGE_DECLARATION_DUPLICATE', 'Duplicate relation declaration.', declaration.startLine, declaration.endLine)
|
|
@@ -371,9 +576,10 @@ export const applyDocumentationDeclarations = (
|
|
|
371
576
|
const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
|
|
372
577
|
const relations = new Map(snapshot.relations.map((relation) => [relation.id, relation]))
|
|
373
578
|
const diagnostics: DocumentationDiagnostic[] = []
|
|
579
|
+
const lookup = entityLookup(snapshot.entities)
|
|
374
580
|
|
|
375
581
|
for (const document of documents) {
|
|
376
|
-
const result = parseDocumentationDeclarations(document, { snapshot, ...options })
|
|
582
|
+
const result = parseDocumentationDeclarations(document, { snapshot, ...options, entityLookup: lookup })
|
|
377
583
|
diagnostics.push(...result.diagnostics)
|
|
378
584
|
for (const entity of result.entities) entities.set(entity.id, entity)
|
|
379
585
|
for (const relation of result.relations) relations.set(relation.id, relation)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Entity and relation identity, in one place.
|
|
5
|
+
*
|
|
6
|
+
* Every analyzer and every projection has to agree on what a module or a document is called, or
|
|
7
|
+
* the graph silently grows two nodes for one file and nothing joins. The hash suffix keeps a very
|
|
8
|
+
* long path addressable without exceeding the schema's identifier bound.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_ID_LENGTH = 256
|
|
12
|
+
const ID_HASH_LENGTH = 32
|
|
13
|
+
|
|
14
|
+
export const entityId = (kind: string, value: string): string => {
|
|
15
|
+
const fullId = `${kind}:${value}`
|
|
16
|
+
if (fullId.length <= MAX_ID_LENGTH) return fullId
|
|
17
|
+
|
|
18
|
+
const suffix = `:${sha256NormalizedV1(fullId).slice(0, ID_HASH_LENGTH)}`
|
|
19
|
+
return `${fullId.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Identity of an observed relation: its endpoints and kind, plus an optional discriminator. */
|
|
23
|
+
export const relationId = (from: string, kind: string, to: string, discriminator?: string): string =>
|
|
24
|
+
entityId('relation', discriminator ? `${from}:${kind}:${to}:${discriminator}` : `${from}:${kind}:${to}`)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
2
|
+
import type { DiscoverySnapshotV1, Coverage, KnowledgeEntity, KnowledgeRelation } from '../schemas/knowledge.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reusing what has not changed.
|
|
6
|
+
*
|
|
7
|
+
* `EvidenceSchema.contentHash` has always existed and discovery never filled it, so every cache
|
|
8
|
+
* and every overlay could only be keyed on "the whole repository changed" — true between any two
|
|
9
|
+
* commits, and therefore useless. With a hash per file-backed entity, a second scan can tell which
|
|
10
|
+
* files it has already read and skip the expensive part: the TypeScript parse and the Markdown
|
|
11
|
+
* parse, which is where nearly all of discovery's time goes.
|
|
12
|
+
*
|
|
13
|
+
* Reuse is only sound when the answer cannot have changed, and two different things can change it:
|
|
14
|
+
*
|
|
15
|
+
* - An entity's own fields depend on its own bytes alone, so a hash match is enough to reuse it.
|
|
16
|
+
* - A relation depends on what else exists. A module importing `./new.js` resolved to nothing
|
|
17
|
+
* before that file was added and resolves to a module after; a document mentioning `src/new.ts`
|
|
18
|
+
* gains an edge the moment the module appears. So relation reuse also requires that the universe
|
|
19
|
+
* the references resolve against is identical, which is what the fingerprints below capture.
|
|
20
|
+
*
|
|
21
|
+
* The consequence is that a fast run and a cold run produce the same snapshot, byte for byte, or
|
|
22
|
+
* the fast run does not happen. A cache that is only usually right is worse than no cache.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const fileContentHash = (text: string): string => sha256NormalizedV1(text)
|
|
26
|
+
|
|
27
|
+
/** Kinds whose entity is one file, and therefore hashable. */
|
|
28
|
+
export const FILE_BACKED_KINDS = ['module', 'document', 'package'] as const
|
|
29
|
+
|
|
30
|
+
export type PriorFile = {
|
|
31
|
+
readonly entity: KnowledgeEntity
|
|
32
|
+
readonly contentHash: string
|
|
33
|
+
/** Relations this entity is the source of. */
|
|
34
|
+
readonly outgoing: readonly KnowledgeRelation[]
|
|
35
|
+
/** Coverage entries scoped to this file. */
|
|
36
|
+
readonly coverage: readonly Coverage[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a caller may hand back from a previous scan. */
|
|
40
|
+
export type PreviousSnapshot = Pick<DiscoverySnapshotV1, 'entities' | 'relations' | 'coverage'> &
|
|
41
|
+
Partial<Pick<DiscoverySnapshotV1, 'pipelineVersion' | 'analyzerVersions' | 'configurationHash'>>
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a previous snapshot may be reused at all, and why not when it may not.
|
|
45
|
+
*
|
|
46
|
+
* A hash says a file has not changed; it says nothing about whether *this* code would still read
|
|
47
|
+
* it the same way. An analyzer that learned to record a document's headings produces different
|
|
48
|
+
* entities from identical bytes, and a configuration change moves area boundaries and
|
|
49
|
+
* runtime-wiring detection. So the snapshot has to have been produced by this pipeline, these
|
|
50
|
+
* analyzers and this configuration — and a snapshot that does not say which is not trusted, since
|
|
51
|
+
* the alternative is trusting a caller's hand-assembled input with a repository scan.
|
|
52
|
+
*/
|
|
53
|
+
export const reuseRefusal = (
|
|
54
|
+
previous: PreviousSnapshot,
|
|
55
|
+
current: Pick<DiscoverySnapshotV1, 'pipelineVersion' | 'analyzerVersions' | 'configurationHash'>,
|
|
56
|
+
): string | undefined => {
|
|
57
|
+
if (!previous.pipelineVersion || !previous.analyzerVersions || !previous.configurationHash) {
|
|
58
|
+
return 'the previous snapshot does not declare the pipeline, analyzers and configuration it was produced by'
|
|
59
|
+
}
|
|
60
|
+
if (previous.pipelineVersion !== current.pipelineVersion) {
|
|
61
|
+
return `the previous snapshot was produced by pipeline ${previous.pipelineVersion}, not ${current.pipelineVersion}`
|
|
62
|
+
}
|
|
63
|
+
if (sha256NormalizedV1(previous.analyzerVersions) !== sha256NormalizedV1(current.analyzerVersions)) {
|
|
64
|
+
return 'an analyzer version changed, so identical bytes would not produce identical entities'
|
|
65
|
+
}
|
|
66
|
+
if (previous.configurationHash !== current.configurationHash) return 'the configuration changed'
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type PriorSnapshot = {
|
|
71
|
+
readonly modules: ReadonlyMap<string, PriorFile>
|
|
72
|
+
readonly documents: ReadonlyMap<string, PriorFile>
|
|
73
|
+
readonly moduleUniverse: string
|
|
74
|
+
readonly resolution: string
|
|
75
|
+
/** Every entity by id, for re-adding an endpoint a reused relation still points at. */
|
|
76
|
+
readonly entities: ReadonlyMap<string, KnowledgeEntity>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hashOf = (entity: KnowledgeEntity): string | undefined => entity.evidence[0]?.contentHash
|
|
80
|
+
|
|
81
|
+
/** Coverage scopes that belong to a single module, by the topic they start with. */
|
|
82
|
+
const MODULE_COVERAGE_PREFIXES = ['dynamic-imports:', 'runtime-wiring:'] as const
|
|
83
|
+
/** Coverage scopes that belong to a single document. */
|
|
84
|
+
const DOCUMENT_COVERAGE_PREFIXES = ['relations:', 'mentions-symbol:'] as const
|
|
85
|
+
|
|
86
|
+
const stringList = (value: unknown): readonly string[] =>
|
|
87
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Which names a module declares itself, as opposed to forwarding from somewhere else.
|
|
91
|
+
*
|
|
92
|
+
* The distinction decides where a documented symbol resolves to, and it is only visible in the
|
|
93
|
+
* syntax tree — so a module that skipped its parse has to read it back from what the previous scan
|
|
94
|
+
* recorded. `exports` minus `reexports` is that record.
|
|
95
|
+
*/
|
|
96
|
+
export const declaredExportsOf = (entity: KnowledgeEntity): readonly string[] => {
|
|
97
|
+
const exports = stringList(entity.metadata?.exports)
|
|
98
|
+
const reexports = new Set(stringList(entity.metadata?.reexports))
|
|
99
|
+
return exports.filter((name) => !reexports.has(name))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const exportsOf = (entity: KnowledgeEntity): readonly string[] => stringList(entity.metadata?.exports)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What a module's references resolve against: the module paths, the packages, and the compiler
|
|
106
|
+
* options that decide how a specifier becomes a path. A change to any of them can turn an
|
|
107
|
+
* unresolved import into a relation, so it invalidates relation reuse for every module.
|
|
108
|
+
*/
|
|
109
|
+
export const moduleUniverseFingerprint = (input: {
|
|
110
|
+
readonly modulePaths: readonly string[]
|
|
111
|
+
readonly packages: readonly { readonly id: string; readonly path: string; readonly name?: string }[]
|
|
112
|
+
readonly compilerOptions: unknown
|
|
113
|
+
}): string =>
|
|
114
|
+
sha256NormalizedV1({
|
|
115
|
+
modules: [...input.modulePaths].sort(),
|
|
116
|
+
packages: [...input.packages].map(({ id, path, name }) => ({ id, path, ...(name ? { name } : {}) })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
117
|
+
compilerOptions: input.compilerOptions,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What a document's references resolve against: everything a module resolves against, plus the
|
|
122
|
+
* documents, the areas, the package names and which module declares each exported symbol.
|
|
123
|
+
*/
|
|
124
|
+
export const resolutionFingerprint = (input: {
|
|
125
|
+
readonly moduleUniverse: string
|
|
126
|
+
readonly documentPaths: readonly string[]
|
|
127
|
+
readonly areaPaths: readonly string[]
|
|
128
|
+
readonly symbols: ReadonlyMap<string, readonly string[]>
|
|
129
|
+
}): string =>
|
|
130
|
+
sha256NormalizedV1({
|
|
131
|
+
moduleUniverse: input.moduleUniverse,
|
|
132
|
+
documents: [...input.documentPaths].sort(),
|
|
133
|
+
areas: [...input.areaPaths].sort(),
|
|
134
|
+
symbols: [...input.symbols.entries()].map(([name, owners]) => [name, [...owners].sort()]).sort(([a], [b]) => String(a).localeCompare(String(b))),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Index a previous snapshot for reuse, and recompute the fingerprints it was built under.
|
|
139
|
+
*
|
|
140
|
+
* The fingerprints are derived rather than stored: everything they cover is in the snapshot
|
|
141
|
+
* already, and a stored fingerprint is one more thing that can be stale or forged.
|
|
142
|
+
*/
|
|
143
|
+
export const indexPriorSnapshot = (previous: PreviousSnapshot, compilerOptions: unknown): PriorSnapshot => {
|
|
144
|
+
const outgoing = new Map<string, KnowledgeRelation[]>()
|
|
145
|
+
for (const relation of previous.relations) {
|
|
146
|
+
const list = outgoing.get(relation.from)
|
|
147
|
+
if (list) list.push(relation)
|
|
148
|
+
else outgoing.set(relation.from, [relation])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const collect = (kind: string, prefixes: readonly string[]): Map<string, PriorFile> => {
|
|
152
|
+
const paths = new Map<string, KnowledgeEntity>()
|
|
153
|
+
for (const entity of previous.entities) {
|
|
154
|
+
if (entity.kind !== kind || !entity.path) continue
|
|
155
|
+
if (hashOf(entity)) paths.set(entity.path, entity)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* Per-file coverage, matched back to its file.
|
|
160
|
+
*
|
|
161
|
+
* The scope is `<topic>:<path>` or `<topic>:<path>:<detail>`, and a detail can itself contain
|
|
162
|
+
* a colon, so the path is recovered by trimming from the right until a file is recognised
|
|
163
|
+
* rather than by splitting on the first one.
|
|
164
|
+
*/
|
|
165
|
+
const coverage = new Map<string, Coverage[]>()
|
|
166
|
+
for (const entry of previous.coverage) {
|
|
167
|
+
const prefix = prefixes.find((candidate) => entry.scope.startsWith(candidate))
|
|
168
|
+
if (!prefix) continue
|
|
169
|
+
let remainder = entry.scope.slice(prefix.length)
|
|
170
|
+
while (remainder && !paths.has(remainder)) {
|
|
171
|
+
const cut = remainder.lastIndexOf(':')
|
|
172
|
+
remainder = cut < 0 ? '' : remainder.slice(0, cut)
|
|
173
|
+
}
|
|
174
|
+
if (!remainder) continue
|
|
175
|
+
const list = coverage.get(remainder)
|
|
176
|
+
if (list) list.push(entry)
|
|
177
|
+
else coverage.set(remainder, [entry])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result = new Map<string, PriorFile>()
|
|
181
|
+
for (const [path, entity] of paths) {
|
|
182
|
+
result.set(path, {
|
|
183
|
+
entity,
|
|
184
|
+
contentHash: hashOf(entity) as string,
|
|
185
|
+
outgoing: outgoing.get(entity.id) ?? [],
|
|
186
|
+
coverage: coverage.get(path) ?? [],
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return result
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const modules = collect('module', MODULE_COVERAGE_PREFIXES)
|
|
193
|
+
const documents = collect('document', DOCUMENT_COVERAGE_PREFIXES)
|
|
194
|
+
const packages = previous.entities
|
|
195
|
+
.filter((entity) => entity.kind === 'package')
|
|
196
|
+
.map((entity) => ({ id: entity.id, path: entity.path ?? '.', ...(entity.name ? { name: entity.name } : {}) }))
|
|
197
|
+
|
|
198
|
+
const symbols = new Map<string, readonly string[]>()
|
|
199
|
+
const declaring = new Map<string, string[]>()
|
|
200
|
+
const forwarding = new Map<string, string[]>()
|
|
201
|
+
for (const [, file] of modules) {
|
|
202
|
+
const declared = new Set(declaredExportsOf(file.entity))
|
|
203
|
+
for (const name of exportsOf(file.entity)) {
|
|
204
|
+
if (name === '*' || name === 'default') continue
|
|
205
|
+
const target = declared.has(name) ? declaring : forwarding
|
|
206
|
+
const list = target.get(name)
|
|
207
|
+
if (list) list.push(file.entity.id)
|
|
208
|
+
else target.set(name, [file.entity.id])
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const [name, owners] of declaring) symbols.set(name, owners)
|
|
212
|
+
for (const [name, owners] of forwarding) if (!symbols.has(name)) symbols.set(name, owners)
|
|
213
|
+
|
|
214
|
+
const moduleUniverse = moduleUniverseFingerprint({
|
|
215
|
+
modulePaths: [...modules.keys()],
|
|
216
|
+
packages,
|
|
217
|
+
compilerOptions,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
modules,
|
|
222
|
+
documents,
|
|
223
|
+
moduleUniverse,
|
|
224
|
+
resolution: resolutionFingerprint({
|
|
225
|
+
moduleUniverse,
|
|
226
|
+
documentPaths: [...documents.keys()],
|
|
227
|
+
areaPaths: previous.entities.filter((entity) => entity.kind === 'area').map((entity) => entity.path ?? ''),
|
|
228
|
+
symbols,
|
|
229
|
+
}),
|
|
230
|
+
entities: new Map(previous.entities.map((entity) => [entity.id, entity])),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type ReuseLedger = {
|
|
235
|
+
/** Entities taken from the previous snapshot instead of recomputed. */
|
|
236
|
+
reusedEntities: number
|
|
237
|
+
/** Files whose syntax tree or Markdown tree had to be built. */
|
|
238
|
+
parsedFiles: string[]
|
|
239
|
+
/** Files whose parse was skipped entirely. */
|
|
240
|
+
skippedFiles: string[]
|
|
241
|
+
/** Why relation reuse was refused, when it was. */
|
|
242
|
+
invalidated: string[]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const emptyLedger = (): ReuseLedger => ({ reusedEntities: 0, parsedFiles: [], skippedFiles: [], invalidated: [] })
|
|
246
|
+
|
|
247
|
+
/** How many file paths a coverage reason lists before it stops being readable. */
|
|
248
|
+
const LISTED_PATHS = 8
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The reuse, as coverage.
|
|
252
|
+
*
|
|
253
|
+
* A run that is ten times faster than the last one has to be able to say why, or nobody can tell
|
|
254
|
+
* a working cache from a broken scan.
|
|
255
|
+
*/
|
|
256
|
+
export const reuseCoverage = (ledger: ReuseLedger): Coverage => {
|
|
257
|
+
const total = ledger.skippedFiles.length + ledger.parsedFiles.length
|
|
258
|
+
if (!ledger.skippedFiles.length && !ledger.invalidated.length) {
|
|
259
|
+
return {
|
|
260
|
+
analyzer: 'repository',
|
|
261
|
+
scope: 'reused-entities',
|
|
262
|
+
status: 'not-applicable',
|
|
263
|
+
reason: `No previous snapshot was reused; parsed ${total} file(s).`,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const listed = [...ledger.parsedFiles].sort().slice(0, LISTED_PATHS)
|
|
267
|
+
const remainder = ledger.parsedFiles.length - listed.length
|
|
268
|
+
return {
|
|
269
|
+
analyzer: 'repository',
|
|
270
|
+
scope: 'reused-entities',
|
|
271
|
+
status: ledger.invalidated.length ? 'partial' : 'complete',
|
|
272
|
+
reason: [
|
|
273
|
+
`Reused ${ledger.reusedEntities} entit${ledger.reusedEntities === 1 ? 'y' : 'ies'} and skipped ${ledger.skippedFiles.length} of ${total} parse(s).`,
|
|
274
|
+
ledger.parsedFiles.length
|
|
275
|
+
? `Re-parsed: ${listed.join(', ')}${remainder > 0 ? ` and ${remainder} more` : ''}.`
|
|
276
|
+
: 'Nothing needed re-parsing.',
|
|
277
|
+
...ledger.invalidated.map((reason) => `Reuse refused: ${reason}.`),
|
|
278
|
+
].join(' '),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Replay a reused entity's outgoing relations against the entity set that exists now.
|
|
284
|
+
*
|
|
285
|
+
* A relation whose internal target is gone is dropped rather than carried: the file it pointed at
|
|
286
|
+
* was renamed or deleted, and a graph that keeps the edge is lying about the repository. An
|
|
287
|
+
* external endpoint is re-added instead, because an external entity only exists in the snapshot
|
|
288
|
+
* because something referenced it, and the thing that referenced it is exactly what was reused.
|
|
289
|
+
*/
|
|
290
|
+
export const replayableRelations = (
|
|
291
|
+
outgoing: readonly KnowledgeRelation[],
|
|
292
|
+
existing: (id: string) => boolean,
|
|
293
|
+
): {
|
|
294
|
+
readonly relations: readonly KnowledgeRelation[]
|
|
295
|
+
readonly missingEndpoints: readonly string[]
|
|
296
|
+
readonly dropped: readonly KnowledgeRelation[]
|
|
297
|
+
} => {
|
|
298
|
+
const relations: KnowledgeRelation[] = []
|
|
299
|
+
const missingEndpoints: string[] = []
|
|
300
|
+
const dropped: KnowledgeRelation[] = []
|
|
301
|
+
for (const relation of outgoing) {
|
|
302
|
+
if (existing(relation.to)) {
|
|
303
|
+
relations.push(relation)
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (relation.to.startsWith('external:') || relation.to.startsWith('unresolved:')) {
|
|
307
|
+
missingEndpoints.push(relation.to)
|
|
308
|
+
relations.push(relation)
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
dropped.push(relation)
|
|
312
|
+
}
|
|
313
|
+
return { relations, missingEndpoints, dropped }
|
|
314
|
+
}
|