@agentskit/doc-bridge 1.7.45 → 1.9.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 +465 -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 +15861 -6223
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +86 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
- package/dist/index.d.ts +9871 -1062
- package/dist/index.js +16126 -6125
- 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 +147 -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 +25 -6
- 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 +117 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +182 -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 +484 -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
|
@@ -8,7 +8,43 @@ import { expandWorkspaceGlobs } from '../lib/glob-expand.js'
|
|
|
8
8
|
import { detectPackageManager } from '../lib/package-manager.js'
|
|
9
9
|
import { toPosix } from '../lib/paths.js'
|
|
10
10
|
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
11
|
-
import {
|
|
11
|
+
import { safeWalkFiles } from '../safety/repository.js'
|
|
12
|
+
import { GRAPH_ANALYZER_VERSION, areaSuggestionCoverage } from '../graph/build.js'
|
|
13
|
+
import { deriveAreas, type AreaModule } from './areas.js'
|
|
14
|
+
import { entityId } from './identity.js'
|
|
15
|
+
import {
|
|
16
|
+
emptyLedger,
|
|
17
|
+
exportsOf,
|
|
18
|
+
declaredExportsOf,
|
|
19
|
+
fileContentHash,
|
|
20
|
+
indexPriorSnapshot,
|
|
21
|
+
moduleUniverseFingerprint,
|
|
22
|
+
replayableRelations,
|
|
23
|
+
resolutionFingerprint,
|
|
24
|
+
reuseCoverage,
|
|
25
|
+
reuseRefusal,
|
|
26
|
+
type PreviousSnapshot,
|
|
27
|
+
type PriorFile,
|
|
28
|
+
} from './incremental.js'
|
|
29
|
+
import {
|
|
30
|
+
MARKDOWN_ANALYZER_VERSION,
|
|
31
|
+
analyzeMarkdownDocument,
|
|
32
|
+
markdownPathCandidateIndex,
|
|
33
|
+
declaredAudience,
|
|
34
|
+
markdownContentHash,
|
|
35
|
+
parseMarkdownDocument,
|
|
36
|
+
type MarkdownDocumentV1,
|
|
37
|
+
} from './markdown.js'
|
|
38
|
+
import {
|
|
39
|
+
CONFIG_EXTENSIONS,
|
|
40
|
+
DEFAULT_MAX_FILES,
|
|
41
|
+
DOCUMENT_EXTENSIONS,
|
|
42
|
+
SOURCE_EXTENSIONS,
|
|
43
|
+
documentClassification,
|
|
44
|
+
exportedNames,
|
|
45
|
+
safeWalkOptions,
|
|
46
|
+
scriptKind,
|
|
47
|
+
} from './inputs.js'
|
|
12
48
|
import {
|
|
13
49
|
DiscoverySnapshotV1Schema,
|
|
14
50
|
type DiscoverySnapshotV1,
|
|
@@ -17,12 +53,11 @@ import {
|
|
|
17
53
|
type KnowledgeRelation,
|
|
18
54
|
} from '../schemas/knowledge.js'
|
|
19
55
|
|
|
20
|
-
const SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'] as const
|
|
21
|
-
const DOCUMENT_EXTENSIONS = ['.md', '.mdx'] as const
|
|
22
|
-
const DEFAULT_MAX_FILES = 10_000
|
|
23
56
|
const EMPTY_HASH = '0'.repeat(64)
|
|
24
57
|
const DEFAULT_RUNTIME_WIRING_METHODS = ['register', 'use', 'mount', 'attach'] as const
|
|
25
58
|
const TEST_MODULE_PATTERN = /(?:\.test|\.spec|__tests__)/
|
|
59
|
+
/** Coverage is evidence, not a log: past this many notes the list stops informing anyone. */
|
|
60
|
+
const MAX_MARKDOWN_NOTES = 32
|
|
26
61
|
|
|
27
62
|
type JsonRecord = Record<string, unknown>
|
|
28
63
|
|
|
@@ -54,6 +89,14 @@ type DiscoveryOptions = {
|
|
|
54
89
|
readonly config?: DocBridgeConfigV1
|
|
55
90
|
readonly maxFiles?: number
|
|
56
91
|
readonly maxBytes?: number
|
|
92
|
+
/**
|
|
93
|
+
* A snapshot from a previous scan.
|
|
94
|
+
*
|
|
95
|
+
* Entities whose file hash is unchanged are taken from it instead of parsed again. Supplying one
|
|
96
|
+
* cannot change the result: a reused run either produces the same snapshot a cold run would, or
|
|
97
|
+
* the reuse is refused. Omit it to scan from scratch.
|
|
98
|
+
*/
|
|
99
|
+
readonly previous?: PreviousSnapshot
|
|
57
100
|
}
|
|
58
101
|
|
|
59
102
|
const isRecord = (value: unknown): value is JsonRecord =>
|
|
@@ -71,17 +114,6 @@ const readJson = (path: string): { readonly value?: JsonRecord; readonly error?:
|
|
|
71
114
|
const relativePath = (root: string, path: string): string =>
|
|
72
115
|
toPosix(relative(root, path)) || '.'
|
|
73
116
|
|
|
74
|
-
const MAX_ID_LENGTH = 256
|
|
75
|
-
const ID_HASH_LENGTH = 32
|
|
76
|
-
|
|
77
|
-
const entityId = (kind: string, value: string): string => {
|
|
78
|
-
const fullId = `${kind}:${value}`
|
|
79
|
-
if (fullId.length <= MAX_ID_LENGTH) return fullId
|
|
80
|
-
|
|
81
|
-
const suffix = `:${sha256NormalizedV1(fullId).slice(0, ID_HASH_LENGTH)}`
|
|
82
|
-
return `${fullId.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`
|
|
83
|
-
}
|
|
84
|
-
|
|
85
117
|
const lineEvidence = (
|
|
86
118
|
source: 'code' | 'configuration' | 'documentation',
|
|
87
119
|
root: string,
|
|
@@ -100,14 +132,6 @@ const firstLineContaining = (text: string, pattern: string): number | undefined
|
|
|
100
132
|
return line >= 0 ? line + 1 : undefined
|
|
101
133
|
}
|
|
102
134
|
|
|
103
|
-
const documentClassification = (path: string): string => {
|
|
104
|
-
if (/(^|\/)docs\/for-agents(?:\/|$)/.test(path)) return 'agent'
|
|
105
|
-
if (/(^|\/)docs-archive(?:\/|$)/.test(path)) return 'archive'
|
|
106
|
-
if (/(^|\/)docs(?:\/|$)/.test(path)) return 'human'
|
|
107
|
-
if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
|
|
108
|
-
return 'unclassified'
|
|
109
|
-
}
|
|
110
|
-
|
|
111
135
|
const packageName = (manifest: JsonRecord, fallback: string): string | undefined =>
|
|
112
136
|
typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : fallback || undefined
|
|
113
137
|
|
|
@@ -208,67 +232,12 @@ const readCompilerOptions = (root: string): { readonly options: ts.CompilerOptio
|
|
|
208
232
|
return { options: config.options }
|
|
209
233
|
}
|
|
210
234
|
|
|
211
|
-
const scriptKind = (path: string): ts.ScriptKind => {
|
|
212
|
-
switch (extname(path)) {
|
|
213
|
-
case '.js': return ts.ScriptKind.JS
|
|
214
|
-
case '.jsx': return ts.ScriptKind.JSX
|
|
215
|
-
case '.mjs': return ts.ScriptKind.JS
|
|
216
|
-
case '.cjs': return ts.ScriptKind.JS
|
|
217
|
-
case '.ts': return ts.ScriptKind.TS
|
|
218
|
-
case '.tsx': return ts.ScriptKind.TSX
|
|
219
|
-
case '.mts': return ts.ScriptKind.TS
|
|
220
|
-
case '.cts': return ts.ScriptKind.TS
|
|
221
|
-
default: return ts.ScriptKind.Unknown
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
235
|
const nodeEvidence = (root: string, path: string, sourceFile: ts.SourceFile, node: ts.Node): Evidence => {
|
|
226
236
|
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
|
|
227
237
|
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1
|
|
228
238
|
return lineEvidence('code', root, path, start, end)
|
|
229
239
|
}
|
|
230
240
|
|
|
231
|
-
const isExported = (node: ts.Node): boolean => {
|
|
232
|
-
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
233
|
-
return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const exportedNames = (sourceFile: ts.SourceFile): string[] => {
|
|
237
|
-
const names = new Set<string>()
|
|
238
|
-
const addDeclarationName = (node: ts.Declaration): void => {
|
|
239
|
-
if (!isExported(node)) return
|
|
240
|
-
const name = ts.getNameOfDeclaration(node)
|
|
241
|
-
if (name && ts.isIdentifier(name)) names.add(name.text)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const visit = (node: ts.Node): void => {
|
|
245
|
-
if (ts.isExportDeclaration(node)) {
|
|
246
|
-
if (!node.exportClause) names.add('*')
|
|
247
|
-
else if (ts.isNamedExports(node.exportClause)) {
|
|
248
|
-
for (const element of node.exportClause.elements) names.add(element.name.text)
|
|
249
|
-
}
|
|
250
|
-
} else if (ts.isExportAssignment(node)) {
|
|
251
|
-
names.add('default')
|
|
252
|
-
} else if (
|
|
253
|
-
ts.isClassDeclaration(node) ||
|
|
254
|
-
ts.isFunctionDeclaration(node) ||
|
|
255
|
-
ts.isInterfaceDeclaration(node) ||
|
|
256
|
-
ts.isTypeAliasDeclaration(node) ||
|
|
257
|
-
ts.isEnumDeclaration(node) ||
|
|
258
|
-
ts.isModuleDeclaration(node)
|
|
259
|
-
) {
|
|
260
|
-
addDeclarationName(node)
|
|
261
|
-
} else if (ts.isVariableStatement(node) && isExported(node)) {
|
|
262
|
-
for (const declaration of node.declarationList.declarations) {
|
|
263
|
-
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
ts.forEachChild(node, visit)
|
|
267
|
-
}
|
|
268
|
-
visit(sourceFile)
|
|
269
|
-
return [...names].sort()
|
|
270
|
-
}
|
|
271
|
-
|
|
272
241
|
const moduleReferences = (
|
|
273
242
|
root: string,
|
|
274
243
|
path: string,
|
|
@@ -365,6 +334,14 @@ const moduleReferences = (
|
|
|
365
334
|
const argument = node.arguments[0]
|
|
366
335
|
const specifier = argument ? resolveStaticString(argument) : undefined
|
|
367
336
|
if (specifier !== undefined) {
|
|
337
|
+
/*
|
|
338
|
+
* A literal `require` is a dynamic load that resolved, so it counts as one.
|
|
339
|
+
*
|
|
340
|
+
* The evidence was always recorded here and the flag was not, which left the aggregate
|
|
341
|
+
* entry sampling a file that had no per-file entry of its own — and made the aggregate
|
|
342
|
+
* unreproducible from the per-file facts, which is exactly what a reused scan replays.
|
|
343
|
+
*/
|
|
344
|
+
hasLiteralDynamic = true
|
|
368
345
|
dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
|
|
369
346
|
addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node)
|
|
370
347
|
} else {
|
|
@@ -481,6 +458,10 @@ const hasPackageManagerMetadata = (root: string, rootManifest: JsonRecord | unde
|
|
|
481
458
|
existsSync(join(root, 'package-lock.json')),
|
|
482
459
|
)
|
|
483
460
|
|
|
461
|
+
const PIPELINE_VERSION = '1.5.0'
|
|
462
|
+
const ANALYZER_VERSIONS: Readonly<Record<string, string>> = { repository: '1.3.0', 'js-ts': '1.3.5', markdown: MARKDOWN_ANALYZER_VERSION, graph: GRAPH_ANALYZER_VERSION }
|
|
463
|
+
const configurationHashOf = (config: DocBridgeConfigV1 | undefined): string => sha256NormalizedV1(config ?? {})
|
|
464
|
+
|
|
484
465
|
const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: readonly string[], entities: readonly KnowledgeEntity[], relations: readonly KnowledgeRelation[], coverage: DiscoverySnapshotV1['coverage']): DiscoverySnapshotV1 => {
|
|
485
466
|
const revision = sourceRevision(root, files)
|
|
486
467
|
const base = {
|
|
@@ -491,34 +472,28 @@ const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: re
|
|
|
491
472
|
project: { name: (entities.find((entity) => entity.kind === 'package' && entity.path === '.')?.name ?? basename(root)), root: '.' },
|
|
492
473
|
sourceRevision: revision.value,
|
|
493
474
|
sourceRevisionKind: revision.kind,
|
|
494
|
-
configurationHash:
|
|
495
|
-
pipelineVersion:
|
|
496
|
-
analyzerVersions:
|
|
475
|
+
configurationHash: configurationHashOf(config),
|
|
476
|
+
pipelineVersion: PIPELINE_VERSION,
|
|
477
|
+
analyzerVersions: ANALYZER_VERSIONS,
|
|
497
478
|
entities: [...entities].sort((a, b) => a.id.localeCompare(b.id)),
|
|
498
479
|
relations: [...relations].sort((a, b) => a.id.localeCompare(b.id)),
|
|
499
|
-
coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? (
|
|
480
|
+
coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? (ANALYZER_VERSIONS[entry.analyzer] ?? '1.0.0') })),
|
|
500
481
|
}
|
|
501
482
|
return DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
|
|
502
483
|
}
|
|
503
484
|
|
|
504
485
|
export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapshotV1 => {
|
|
505
486
|
const root = resolve(opts.root ?? process.cwd())
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
|
|
511
|
-
maxFiles,
|
|
512
|
-
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
513
|
-
...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
|
|
514
|
-
...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
|
|
515
|
-
}
|
|
487
|
+
const safeOptions = safeWalkOptions(opts.config, {
|
|
488
|
+
maxFiles: opts.maxFiles ?? opts.config?.safety?.maxFiles ?? DEFAULT_MAX_FILES,
|
|
489
|
+
...(opts.maxBytes !== undefined ? { maxBytes: opts.maxBytes } : {}),
|
|
490
|
+
})
|
|
516
491
|
const rootManifestPath = join(root, 'package.json')
|
|
517
492
|
const rootManifest = readJson(rootManifestPath).value
|
|
518
493
|
const packageResult = discoverPackages(root, rootManifest, opts.config)
|
|
519
494
|
const sourceWalk = safeWalkFiles(root, { extensions: SOURCE_EXTENSIONS, ...safeOptions })
|
|
520
495
|
const documentWalk = safeWalkFiles(root, { extensions: DOCUMENT_EXTENSIONS, ...safeOptions })
|
|
521
|
-
const configWalk = safeWalkFiles(root, { extensions:
|
|
496
|
+
const configWalk = safeWalkFiles(root, { extensions: CONFIG_EXTENSIONS, ...safeOptions })
|
|
522
497
|
const sourcePaths = sourceWalk.files
|
|
523
498
|
const documentPaths = documentWalk.files
|
|
524
499
|
const configPaths = configWalk.files
|
|
@@ -549,28 +524,280 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
549
524
|
|
|
550
525
|
for (const pkg of packageResult.packages) {
|
|
551
526
|
const text = readFileSync(pkg.manifestPath, 'utf8')
|
|
552
|
-
addEntity({
|
|
527
|
+
addEntity({
|
|
528
|
+
id: pkg.id,
|
|
529
|
+
kind: 'package',
|
|
530
|
+
name: pkg.name ?? pkg.path,
|
|
531
|
+
path: pkg.path,
|
|
532
|
+
provenance: 'observed',
|
|
533
|
+
evidence: [
|
|
534
|
+
{
|
|
535
|
+
...lineEvidence('configuration', root, pkg.manifestPath, firstLineContaining(text, '"name"')),
|
|
536
|
+
contentHash: fileContentHash(text),
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const compiler = readCompilerOptions(root)
|
|
543
|
+
/*
|
|
544
|
+
* What the previous scan already knows.
|
|
545
|
+
*
|
|
546
|
+
* Indexed before anything is parsed, because the decision to parse a file at all depends on
|
|
547
|
+
* whether its hash matches what that scan recorded.
|
|
548
|
+
*/
|
|
549
|
+
const ledger = emptyLedger()
|
|
550
|
+
const refusal = opts.previous
|
|
551
|
+
? reuseRefusal(opts.previous, { pipelineVersion: PIPELINE_VERSION, analyzerVersions: ANALYZER_VERSIONS, configurationHash: configurationHashOf(opts.config) })
|
|
552
|
+
: undefined
|
|
553
|
+
if (refusal) ledger.invalidated.push(refusal)
|
|
554
|
+
const prior = opts.previous && !refusal ? indexPriorSnapshot(opts.previous, compiler.options) : undefined
|
|
555
|
+
|
|
556
|
+
/*
|
|
557
|
+
* Whether a reference can resolve differently than it did last time.
|
|
558
|
+
*
|
|
559
|
+
* A module's own bytes decide its entity; what it resolves *to* depends on which modules and
|
|
560
|
+
* packages exist and on the compiler options that turn a specifier into a path. If any of that
|
|
561
|
+
* moved, nothing is reused however unchanged a file is — an import of `./new.js` resolved to
|
|
562
|
+
* nothing yesterday and resolves to a module today. Reusing the entity alone would save no
|
|
563
|
+
* parse, because the pass that reads references would have to build the tree regardless.
|
|
564
|
+
*/
|
|
565
|
+
const moduleUniverse = moduleUniverseFingerprint({
|
|
566
|
+
modulePaths: sourcePaths.map((absPath) => relativePath(root, absPath)),
|
|
567
|
+
packages: packageResult.packages.map((pkg) => ({ id: pkg.id, path: pkg.path, ...(pkg.name ? { name: pkg.name } : {}) })),
|
|
568
|
+
compilerOptions: compiler.options,
|
|
569
|
+
})
|
|
570
|
+
const reuseModuleRelations = Boolean(prior) && prior?.moduleUniverse === moduleUniverse
|
|
571
|
+
if (prior && !reuseModuleRelations) {
|
|
572
|
+
ledger.invalidated.push('the set of modules, packages or compiler options changed')
|
|
553
573
|
}
|
|
554
574
|
|
|
555
575
|
const modules = new Map<string, ModuleInfo>()
|
|
576
|
+
const modulesByPath = new Map<string, string>()
|
|
577
|
+
const reusedModules = new Set<string>()
|
|
578
|
+
const areaModules: AreaModule[] = []
|
|
579
|
+
const declaringModules = new Map<string, string[]>()
|
|
580
|
+
const exportingModules = new Map<string, string[]>()
|
|
581
|
+
const registerSymbols = (id: string, exports: readonly string[], declared: ReadonlySet<string>): void => {
|
|
582
|
+
for (const name of exports) {
|
|
583
|
+
if (name === '*' || name === 'default') continue
|
|
584
|
+
const owners = declared.has(name) ? declaringModules : exportingModules
|
|
585
|
+
const existing = owners.get(name)
|
|
586
|
+
if (existing) existing.push(id)
|
|
587
|
+
else owners.set(name, [id])
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
556
591
|
for (const absPath of sourcePaths) {
|
|
557
592
|
const path = relativePath(root, absPath)
|
|
558
593
|
const pkg = packageForModule(packageResult.packages, absPath)
|
|
559
594
|
const id = entityId('module', path)
|
|
560
595
|
const text = readFileSync(absPath, 'utf8')
|
|
561
|
-
const
|
|
562
|
-
const exports = exportedNames(sourceFile)
|
|
596
|
+
const contentHash = fileContentHash(text)
|
|
563
597
|
modules.set(resolve(absPath), { absPath, path, entityId: id, ...(pkg ? { packageId: pkg.id } : {}) })
|
|
564
|
-
|
|
598
|
+
modulesByPath.set(path, id)
|
|
599
|
+
if (pkg) areaModules.push({ moduleId: id, path, packageId: pkg.id, packagePath: pkg.path })
|
|
600
|
+
|
|
601
|
+
const priorModule = reuseModuleRelations ? prior?.modules.get(path) : undefined
|
|
602
|
+
if (priorModule && priorModule.contentHash === contentHash) {
|
|
603
|
+
/*
|
|
604
|
+
* The file is byte-identical to the one that produced this entity, so the entity is the
|
|
605
|
+
* answer — no syntax tree needed. Which names it declares as opposed to forwards is read
|
|
606
|
+
* back from `reexports`, because that distinction only exists in the tree.
|
|
607
|
+
*/
|
|
608
|
+
addEntity(priorModule.entity)
|
|
609
|
+
reusedModules.add(path)
|
|
610
|
+
ledger.reusedEntities += 1
|
|
611
|
+
registerSymbols(id, exportsOf(priorModule.entity), new Set(declaredExportsOf(priorModule.entity)))
|
|
612
|
+
} else {
|
|
613
|
+
const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true, scriptKind(absPath))
|
|
614
|
+
const exports = exportedNames(sourceFile)
|
|
615
|
+
const declared = new Set(exportedNames(sourceFile, { declaredOnly: true }))
|
|
616
|
+
const reexports = exports.filter((name) => !declared.has(name))
|
|
617
|
+
registerSymbols(id, exports, declared)
|
|
618
|
+
addEntity({
|
|
619
|
+
id,
|
|
620
|
+
kind: 'module',
|
|
621
|
+
name: basename(absPath),
|
|
622
|
+
path,
|
|
623
|
+
provenance: 'observed',
|
|
624
|
+
evidence: [
|
|
625
|
+
{
|
|
626
|
+
...lineEvidence('code', root, absPath, 1, sourceFile.getLineAndCharacterOfPosition(sourceFile.getEnd()).line + 1),
|
|
627
|
+
contentHash,
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
...(exports.length
|
|
631
|
+
? { metadata: { exports, ...(reexports.length ? { reexports } : {}), test: TEST_MODULE_PATTERN.test(path) } }
|
|
632
|
+
: {}),
|
|
633
|
+
})
|
|
634
|
+
}
|
|
565
635
|
if (pkg) addRelation({ id: entityId('relation', `${pkg.id}:contains:${id}`), kind: 'contains', from: pkg.id, to: id, provenance: 'observed', evidence: [lineEvidence('code', root, absPath, 1)] })
|
|
566
636
|
}
|
|
567
637
|
|
|
638
|
+
/*
|
|
639
|
+
* Areas: the directory level between a package and a file.
|
|
640
|
+
*
|
|
641
|
+
* Derived from convention and from what an ownership record already names, then attached to the
|
|
642
|
+
* graph with `contains` — package to area, area to its nested areas, area to module. Each
|
|
643
|
+
* module belongs to exactly one area, the most specific one, so an aggregation at area scope
|
|
644
|
+
* has one answer per module.
|
|
645
|
+
*/
|
|
646
|
+
const areas = deriveAreas({
|
|
647
|
+
modules: areaModules,
|
|
648
|
+
ownership: Object.entries(opts.config?.routing?.options?.ownership ?? {}).map(([id, record]) => ({ id, path: record.path })),
|
|
649
|
+
...(opts.config?.analysis?.areas?.depth !== undefined ? { depth: opts.config.analysis.areas.depth } : {}),
|
|
650
|
+
...(opts.config?.analysis?.areas?.roots !== undefined ? { roots: opts.config.analysis.areas.roots } : {}),
|
|
651
|
+
})
|
|
652
|
+
const areasById = new Map(areas.map((area) => [area.id, area]))
|
|
653
|
+
const areasByPath = new Map(areas.map((area) => [area.path, area.id]))
|
|
654
|
+
|
|
655
|
+
for (const area of areas) {
|
|
656
|
+
addEntity({
|
|
657
|
+
id: area.id,
|
|
658
|
+
kind: 'area',
|
|
659
|
+
name: area.name,
|
|
660
|
+
path: area.path,
|
|
661
|
+
provenance: 'observed',
|
|
662
|
+
evidence: [
|
|
663
|
+
{
|
|
664
|
+
source: 'derived',
|
|
665
|
+
path: area.path,
|
|
666
|
+
context: `Directory groups ${area.moduleIds.length} module(s).`,
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
metadata: {
|
|
670
|
+
moduleCount: area.moduleIds.length,
|
|
671
|
+
...(area.ownershipId ? { ownershipId: area.ownershipId } : {}),
|
|
672
|
+
},
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
const parent = area.parentId && areasById.has(area.parentId) ? area.parentId : area.packageId
|
|
676
|
+
addRelation({
|
|
677
|
+
id: entityId('relation', `${parent}:contains:${area.id}`),
|
|
678
|
+
kind: 'contains',
|
|
679
|
+
from: parent,
|
|
680
|
+
to: area.id,
|
|
681
|
+
provenance: 'observed',
|
|
682
|
+
evidence: [{ source: 'derived', path: area.path }],
|
|
683
|
+
})
|
|
684
|
+
for (const moduleId of area.moduleIds) {
|
|
685
|
+
addRelation({
|
|
686
|
+
id: entityId('relation', `${area.id}:contains:${moduleId}`),
|
|
687
|
+
kind: 'contains',
|
|
688
|
+
from: area.id,
|
|
689
|
+
to: moduleId,
|
|
690
|
+
provenance: 'observed',
|
|
691
|
+
evidence: [{ source: 'derived', path: area.path }],
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/*
|
|
697
|
+
* A symbol resolves to the module that declares it. Only when nothing declares it — a type
|
|
698
|
+
* forwarded through a barrel, say — do the re-exporting modules stand in, and then only if
|
|
699
|
+
* there is exactly one of them.
|
|
700
|
+
*/
|
|
701
|
+
const symbolModules = new Map<string, readonly string[]>()
|
|
702
|
+
for (const [name, owners] of declaringModules) symbolModules.set(name, owners)
|
|
703
|
+
for (const [name, owners] of exportingModules) if (!symbolModules.has(name)) symbolModules.set(name, owners)
|
|
704
|
+
|
|
705
|
+
/*
|
|
706
|
+
* Documents are parsed first and added as entities after their relations are known, because
|
|
707
|
+
* whether a document's references were truncated is part of what the entity has to say.
|
|
708
|
+
*/
|
|
709
|
+
const markdownDocuments: MarkdownDocumentV1[] = []
|
|
710
|
+
const documentsByPath = new Map<string, string>()
|
|
711
|
+
const documentFiles = new Map<string, string>()
|
|
712
|
+
const unreadableDocuments: string[] = []
|
|
568
713
|
for (const absPath of documentPaths) {
|
|
569
|
-
|
|
570
|
-
|
|
714
|
+
documentFiles.set(relativePath(root, absPath), absPath)
|
|
715
|
+
}
|
|
716
|
+
for (const path of documentFiles.keys()) documentsByPath.set(path, entityId('document', path))
|
|
717
|
+
|
|
718
|
+
/*
|
|
719
|
+
* Whether an id will be in this snapshot.
|
|
720
|
+
*
|
|
721
|
+
* A replayed relation is checked against what the scan is going to produce, not against what it
|
|
722
|
+
* has produced so far: documents and modules get their entities late, and an edge to a file that
|
|
723
|
+
* plainly exists must not be dropped for arriving early.
|
|
724
|
+
*/
|
|
725
|
+
const plannedIds = new Set([...modulesByPath.values(), ...documentsByPath.values()])
|
|
726
|
+
const willExist = (id: string): boolean => entities.has(id) || plannedIds.has(id)
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Put a reused entity's edges back.
|
|
730
|
+
*
|
|
731
|
+
* An edge whose internal target is gone is dropped — the file it pointed at was renamed or
|
|
732
|
+
* deleted, and a graph that keeps the edge is lying about the repository. An external or
|
|
733
|
+
* unresolved endpoint is re-created instead, because such an entity is in the snapshot only
|
|
734
|
+
* because something referenced it, and that something is exactly what was reused.
|
|
735
|
+
*/
|
|
736
|
+
const replayRelations = (prior: PriorFile): readonly KnowledgeRelation[] => {
|
|
737
|
+
const replay = replayableRelations(prior.outgoing, willExist)
|
|
738
|
+
for (const id of replay.missingEndpoints) {
|
|
739
|
+
if (entities.has(id)) continue
|
|
740
|
+
const relation = prior.outgoing.find((item) => item.to === id)
|
|
741
|
+
addEntity({
|
|
742
|
+
id,
|
|
743
|
+
kind: id.startsWith('external:') ? 'external' : 'unresolved-reference',
|
|
744
|
+
name: id.replace(/^(?:external|unresolved):/, ''),
|
|
745
|
+
provenance: 'observed',
|
|
746
|
+
evidence: relation?.evidence[0] ? [relation.evidence[0]] : [],
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
for (const relation of replay.relations) addRelation(relation)
|
|
750
|
+
return replay.relations
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/*
|
|
754
|
+
* Whether a document's references can resolve differently than they did last time.
|
|
755
|
+
*
|
|
756
|
+
* A document resolves against more than a module does: it can name another document, an area,
|
|
757
|
+
* a package or an exported symbol. So document reuse is refused unless all of that is identical
|
|
758
|
+
* — a symbol that moved from one module to another changes where a mention points without
|
|
759
|
+
* changing a single byte of the document that mentions it.
|
|
760
|
+
*/
|
|
761
|
+
const resolution = resolutionFingerprint({
|
|
762
|
+
moduleUniverse,
|
|
763
|
+
documentPaths: [...documentFiles.keys()],
|
|
764
|
+
areaPaths: areas.map((area) => area.path),
|
|
765
|
+
symbols: symbolModules,
|
|
766
|
+
})
|
|
767
|
+
const reuseDocumentRelations = Boolean(prior) && prior?.resolution === resolution
|
|
768
|
+
if (prior && reuseModuleRelations && !reuseDocumentRelations) {
|
|
769
|
+
ledger.invalidated.push('the set of documents, areas or exported symbols changed')
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const reusedDocuments = new Map<string, PriorFile>()
|
|
773
|
+
for (const [path, absPath] of documentFiles) {
|
|
774
|
+
let text: string
|
|
775
|
+
try {
|
|
776
|
+
text = readFileSync(absPath, 'utf8')
|
|
777
|
+
} catch {
|
|
778
|
+
unreadableDocuments.push(path)
|
|
779
|
+
ledger.parsedFiles.push(path)
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
782
|
+
const priorDocument = prior?.documents.get(path)
|
|
783
|
+
if (reuseDocumentRelations && priorDocument && priorDocument.contentHash === markdownContentHash(text)) {
|
|
784
|
+
/*
|
|
785
|
+
* Byte-identical, resolving against an identical universe: last scan's answer is this
|
|
786
|
+
* scan's answer, and the Markdown tree is never built.
|
|
787
|
+
*/
|
|
788
|
+
reusedDocuments.set(path, priorDocument)
|
|
789
|
+
ledger.reusedEntities += 1
|
|
790
|
+
ledger.skippedFiles.push(path)
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
ledger.parsedFiles.push(path)
|
|
794
|
+
try {
|
|
795
|
+
markdownDocuments.push(parseMarkdownDocument(path, text))
|
|
796
|
+
} catch {
|
|
797
|
+
unreadableDocuments.push(path)
|
|
798
|
+
}
|
|
571
799
|
}
|
|
572
800
|
|
|
573
|
-
const compiler = readCompilerOptions(root)
|
|
574
801
|
const coverage: DiscoverySnapshotV1['coverage'] = [
|
|
575
802
|
...[sourceWalk, documentWalk, configWalk].flatMap((walk, index) => walk.incomplete ? [{ analyzer: 'repository', scope: `limits:${['source', 'documentation', 'configuration'][index]}`, status: 'partial' as const, reason: walk.reason }] : []),
|
|
576
803
|
{ analyzer: 'repository', scope: 'package-manager', status: hasPackageManagerMetadata(root, rootManifest) ? 'complete' : 'partial', ...(!hasPackageManagerMetadata(root, rootManifest) ? { reason: `No package manager metadata found; default helper would fall back to ${detectPackageManager(root)}.` } : {}) },
|
|
@@ -581,6 +808,113 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
581
808
|
{ analyzer: 'js-ts', scope: 'generated-code', status: 'not-analyzed', reason: 'Generated code is not interpreted as source architecture.' },
|
|
582
809
|
]
|
|
583
810
|
|
|
811
|
+
/*
|
|
812
|
+
* What the documentation says, as edges.
|
|
813
|
+
*
|
|
814
|
+
* A link to another document, a path in inline code, an exported name in backticks: each is a
|
|
815
|
+
* claim the repository makes about itself, with a line number to check it against. Package
|
|
816
|
+
* names resolve by their manifest name and, when unambiguous, by their directory name.
|
|
817
|
+
*/
|
|
818
|
+
const packageNames = new Map<string, string>()
|
|
819
|
+
const shortNames = new Map<string, string[]>()
|
|
820
|
+
for (const pkg of packageResult.packages) {
|
|
821
|
+
if (pkg.name) packageNames.set(pkg.name, pkg.id)
|
|
822
|
+
const short = pkg.name?.split('/').pop() ?? pkg.path.split('/').pop()
|
|
823
|
+
if (short) {
|
|
824
|
+
const owners = shortNames.get(short)
|
|
825
|
+
if (owners) owners.push(pkg.id)
|
|
826
|
+
else shortNames.set(short, [pkg.id])
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
for (const [short, owners] of shortNames) {
|
|
830
|
+
if (owners.length === 1 && owners[0] && !packageNames.has(short)) packageNames.set(short, owners[0])
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const markdownResolution = {
|
|
834
|
+
documents: documentsByPath,
|
|
835
|
+
modules: modulesByPath,
|
|
836
|
+
// Areas exist now, so a document naming a directory resolves to the unit, not to nothing.
|
|
837
|
+
areas: areasByPath,
|
|
838
|
+
packages: packageNames,
|
|
839
|
+
symbols: symbolModules,
|
|
840
|
+
// One index for the whole run: the analyzer used to rebuild this per document.
|
|
841
|
+
pathIndex: markdownPathCandidateIndex({ documents: documentsByPath, modules: modulesByPath, areas: areasByPath }),
|
|
842
|
+
}
|
|
843
|
+
type MarkdownNote = { readonly scope: string; readonly reason: string; readonly evidence: readonly Evidence[] }
|
|
844
|
+
const notesByDocument = new Map<string, readonly MarkdownNote[]>()
|
|
845
|
+
const truncatedDocuments = new Set<string>()
|
|
846
|
+
for (const document of markdownDocuments) {
|
|
847
|
+
const analysis = analyzeMarkdownDocument(document, entityId('document', document.path), markdownResolution)
|
|
848
|
+
for (const relation of analysis.relations) addRelation(relation)
|
|
849
|
+
notesByDocument.set(document.path, analysis.notes)
|
|
850
|
+
if (analysis.truncated) truncatedDocuments.add(document.path)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
for (const [path, priorDocument] of reusedDocuments) {
|
|
854
|
+
// The resolution universe is identical, so every edge this document recorded still resolves the same way.
|
|
855
|
+
replayRelations(priorDocument)
|
|
856
|
+
notesByDocument.set(
|
|
857
|
+
path,
|
|
858
|
+
priorDocument.coverage.map((entry) => ({ scope: entry.scope, reason: entry.reason ?? '', evidence: entry.evidence ?? [] })),
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Notes follow the walk, not the order documents happened to be parsed in, so reuse cannot move them.
|
|
863
|
+
const markdownNotes: readonly MarkdownNote[] = [...documentFiles.keys()].flatMap((path) => [...(notesByDocument.get(path) ?? [])])
|
|
864
|
+
|
|
865
|
+
const parsedDocuments = new Map(markdownDocuments.map((document) => [document.path, document]))
|
|
866
|
+
for (const [path, absPath] of documentFiles) {
|
|
867
|
+
const reused = reusedDocuments.get(path)
|
|
868
|
+
if (reused) {
|
|
869
|
+
addEntity(reused.entity)
|
|
870
|
+
continue
|
|
871
|
+
}
|
|
872
|
+
const parsed = parsedDocuments.get(path)
|
|
873
|
+
addEntity({
|
|
874
|
+
id: entityId('document', path),
|
|
875
|
+
kind: 'document',
|
|
876
|
+
name: basename(absPath),
|
|
877
|
+
path,
|
|
878
|
+
provenance: 'observed',
|
|
879
|
+
evidence: [
|
|
880
|
+
{
|
|
881
|
+
...lineEvidence('documentation', root, absPath, 1),
|
|
882
|
+
...(parsed ? { contentHash: parsed.contentHash } : {}),
|
|
883
|
+
},
|
|
884
|
+
],
|
|
885
|
+
metadata: {
|
|
886
|
+
classification: (parsed && declaredAudience(parsed.frontmatter)) ?? documentClassification(path),
|
|
887
|
+
...(parsed?.title ? { title: parsed.title } : {}),
|
|
888
|
+
...(parsed?.headings.length ? { headings: parsed.headings } : {}),
|
|
889
|
+
...(parsed?.summary ? { summary: parsed.summary } : {}),
|
|
890
|
+
...(parsed ? { wordCount: parsed.wordCount } : {}),
|
|
891
|
+
...(parsed && Object.keys(parsed.frontmatter).length ? { frontmatter: parsed.frontmatter } : {}),
|
|
892
|
+
...(parsed?.generatedRegions.length ? { generatedRegions: parsed.generatedRegions } : {}),
|
|
893
|
+
...(truncatedDocuments.has(path) ? { evidenceTruncated: true } : {}),
|
|
894
|
+
},
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
coverage.push({
|
|
899
|
+
analyzer: 'markdown',
|
|
900
|
+
scope: 'documentation-relations',
|
|
901
|
+
status: unreadableDocuments.length ? 'partial' : 'complete',
|
|
902
|
+
reason: unreadableDocuments.length
|
|
903
|
+
? `${unreadableDocuments.length} document(s) could not be read: ${unreadableDocuments.slice(0, 4).join(', ')}.`
|
|
904
|
+
: `Analyzed ${markdownDocuments.length + reusedDocuments.size} document(s) for links, mentions and exported-symbol references.`,
|
|
905
|
+
})
|
|
906
|
+
for (const note of markdownNotes.slice(0, MAX_MARKDOWN_NOTES)) {
|
|
907
|
+
coverage.push({ analyzer: 'markdown', scope: note.scope, status: 'partial', reason: note.reason, evidence: [...note.evidence.slice(0, 32)] })
|
|
908
|
+
}
|
|
909
|
+
if (markdownNotes.length > MAX_MARKDOWN_NOTES) {
|
|
910
|
+
coverage.push({
|
|
911
|
+
analyzer: 'markdown',
|
|
912
|
+
scope: 'documentation-relations:notes',
|
|
913
|
+
status: 'partial',
|
|
914
|
+
reason: `${markdownNotes.length - MAX_MARKDOWN_NOTES} further ambiguous or truncated reference(s) were not listed.`,
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
|
|
584
918
|
for (const pkg of packageResult.packages) {
|
|
585
919
|
const text = readFileSync(pkg.manifestPath, 'utf8')
|
|
586
920
|
for (const dependency of dependencyEntries(pkg.manifest)) {
|
|
@@ -603,10 +937,33 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
603
937
|
let observedRuntimeWiring = false
|
|
604
938
|
let observedUnresolvedRuntimeWiring = false
|
|
605
939
|
for (const module of modules.values()) {
|
|
940
|
+
const priorModule = prior?.modules.get(module.path)
|
|
941
|
+
if (reuseModuleRelations && priorModule && reusedModules.has(module.path)) {
|
|
942
|
+
// Replay what this module said last time, then the facts the aggregate entries are built from.
|
|
943
|
+
replayRelations(priorModule)
|
|
944
|
+
|
|
945
|
+
const dynamicEntry = priorModule.coverage.find((entry) => entry.scope === `dynamic-imports:${module.path}`)
|
|
946
|
+
const wiringEntry = priorModule.coverage.find((entry) => entry.scope === `runtime-wiring:${module.path}`)
|
|
947
|
+
if (dynamicEntry) {
|
|
948
|
+
coverage.push(dynamicEntry)
|
|
949
|
+
observedUnresolvedDynamic ||= dynamicEntry.status === 'not-analyzed'
|
|
950
|
+
observedLiteralDynamic ||= dynamicEntry.status === 'complete'
|
|
951
|
+
observedDynamicEvidence.push(...(dynamicEntry.evidence ?? []))
|
|
952
|
+
}
|
|
953
|
+
if (wiringEntry) {
|
|
954
|
+
coverage.push(wiringEntry)
|
|
955
|
+
observedRuntimeWiring = true
|
|
956
|
+
observedUnresolvedRuntimeWiring ||= wiringEntry.status === 'not-analyzed'
|
|
957
|
+
}
|
|
958
|
+
ledger.skippedFiles.push(module.path)
|
|
959
|
+
continue
|
|
960
|
+
}
|
|
961
|
+
|
|
606
962
|
const text = readFileSync(module.absPath, 'utf8')
|
|
607
963
|
const sourceFile = ts.createSourceFile(module.absPath, text, ts.ScriptTarget.Latest, true, scriptKind(module.absPath))
|
|
608
964
|
const runtimeWiringMethods = includeTestRuntimeWiring || !TEST_MODULE_PATTERN.test(module.path) ? configuredRuntimeWiringMethods : new Set<string>()
|
|
609
965
|
const references = moduleReferences(root, module.absPath, sourceFile, runtimeWiringMethods)
|
|
966
|
+
ledger.parsedFiles.push(module.path)
|
|
610
967
|
observedLiteralDynamic ||= references.hasLiteralDynamic
|
|
611
968
|
observedUnresolvedDynamic ||= references.hasDynamic
|
|
612
969
|
observedDynamicEvidence.push(...references.dynamicEvidence)
|
|
@@ -622,7 +979,12 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
622
979
|
addRelation({ id: entityId('relation', `${module.entityId}:${reference.kind}:${target.targetId}`), kind: reference.kind, from: module.entityId, to: target.targetId, provenance: 'observed', evidence: [reference.evidence], ...(reference.detection ? { metadata: { detection: reference.detection } } : {}) })
|
|
623
980
|
}
|
|
624
981
|
if (references.hasLiteralDynamic || references.hasDynamic) coverage.push({ analyzer: 'js-ts', scope: `dynamic-imports:${module.path}`, status: references.hasDynamic ? 'not-analyzed' : 'complete', reason: references.hasDynamic ? 'A non-literal dynamic import was found; the target is unresolved.' : 'Literal dynamic imports were resolved.', evidence: [...references.dynamicEvidence.slice(0, 32)] })
|
|
625
|
-
|
|
982
|
+
/*
|
|
983
|
+
* Every observed wiring call leaves a per-file record, resolved or not — the aggregate entry
|
|
984
|
+
* below is derived from these, and a fact that exists only in a local variable cannot be
|
|
985
|
+
* replayed by a scan that skipped the parse.
|
|
986
|
+
*/
|
|
987
|
+
if (references.hasRuntimeWiring) coverage.push({ analyzer: 'js-ts', scope: `runtime-wiring:${module.path}`, status: references.hasUnresolvedRuntimeWiring ? 'not-analyzed' : 'complete', reason: references.hasUnresolvedRuntimeWiring ? 'A runtime registration/wiring call was found without a statically imported target.' : 'Configured runtime-wiring call(s) were found with statically known targets.', evidence: [lineEvidence('code', root, module.absPath)] })
|
|
626
988
|
}
|
|
627
989
|
|
|
628
990
|
if (dynamicCoverageIndex >= 0) coverage[dynamicCoverageIndex] = observedUnresolvedDynamic
|
|
@@ -637,6 +999,27 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
637
999
|
? { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'complete', reason: 'All observed configured runtime-wiring calls resolved to static bindings.' }
|
|
638
1000
|
: { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-applicable', reason: 'No configured runtime-wiring call was observed.' }
|
|
639
1001
|
|
|
1002
|
+
/*
|
|
1003
|
+
* Community suggestions, last, because they need the finished import graph.
|
|
1004
|
+
*
|
|
1005
|
+
* A cluster of modules that move together is a hypothesis about where an area boundary might
|
|
1006
|
+
* be. It is reported as coverage — status `not-analyzed`, because whether the cluster is an area
|
|
1007
|
+
* is a question nobody has answered — and never as an area entity. A clustering algorithm does
|
|
1008
|
+
* not get to name the architecture.
|
|
1009
|
+
*/
|
|
1010
|
+
coverage.push(
|
|
1011
|
+
...areaSuggestionCoverage({ entities: [...entities.values()], relations: [...relations.values()] }),
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
/*
|
|
1015
|
+
* What this run reused, last, because only now is it known.
|
|
1016
|
+
*
|
|
1017
|
+
* A run that finishes in a tenth of the time has to be able to say why. This entry is the one
|
|
1018
|
+
* part of the snapshot that describes the run rather than the repository — the entities, the
|
|
1019
|
+
* relations and every other coverage entry are identical to what a cold scan would produce.
|
|
1020
|
+
*/
|
|
1021
|
+
coverage.push(reuseCoverage(ledger))
|
|
1022
|
+
|
|
640
1023
|
return artifact(root, opts.config, allFiles, [...entities.values()], [...relations.values()], coverage)
|
|
641
1024
|
}
|
|
642
1025
|
|