@agentskit/doc-bridge 1.7.44 → 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 +471 -0
- package/CONTRIBUTING.md +29 -4
- 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 +15888 -6061
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +91 -9
- package/dist/config/index.js.map +1 -1
- package/dist/index-Beor6Yhi.d.ts +792 -0
- package/dist/index.d.ts +9979 -3257
- package/dist/index.js +15954 -5774
- 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/RELEASE.md +22 -8
- 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 +14 -3
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +7 -3
- package/docs/agent-corpus/cli.md +18 -2
- package/docs/agent-corpus/conformance.md +14 -2
- package/docs/agent-corpus/doc-bridge.md +48 -1
- package/docs/agent-corpus/doctor.md +10 -2
- package/docs/agent-corpus/gates.md +6 -2
- package/docs/agent-corpus/mcp.md +15 -2
- package/docs/agent-corpus/memory.md +6 -2
- package/docs/agent-corpus/query.md +35 -2
- 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 +51 -4
- 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 +39 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +192 -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 +33 -0
- package/docs/verification-harness.md +15 -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 +89 -72
- package/scripts/check-ecosystem-upstream.mjs +36 -7
- package/scripts/report-visual-check.mjs +20 -3
- 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/demo.ts +2 -2
- package/src/cli/program.ts +699 -79
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/load-config.ts +7 -1
- package/src/config/schema.ts +121 -4
- package/src/conformance/documentation-standard-v1.ts +22 -14
- package/src/discovery/areas.ts +182 -0
- package/src/discovery/documentation.ts +255 -23
- 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 +557 -125
- package/src/doctor/run-doctor.ts +246 -27
- 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 +74 -24
- package/src/findings/report.ts +103 -0
- package/src/fixes/proposals.ts +4 -3
- 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/human-adapters/fumadocs.ts +1 -1
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index-builder/watch-index.ts +1 -1
- package/src/index.ts +630 -2
- package/src/lib/bounded-text.ts +15 -10
- 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 +148 -15
- 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 +23 -17
- 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 +45 -12
- package/src/safety/repository.ts +1 -1
- 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 +19 -3
- 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 +8 -5
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/dist/index-C2PCQSrB.d.ts +0 -2251
- package/scripts/verification-harness.mjs +0 -483
|
@@ -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,13 +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(?:\/|$)/.test(path)) return 'human'
|
|
106
|
-
if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
|
|
107
|
-
return 'unclassified'
|
|
108
|
-
}
|
|
109
|
-
|
|
110
135
|
const packageName = (manifest: JsonRecord, fallback: string): string | undefined =>
|
|
111
136
|
typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : fallback || undefined
|
|
112
137
|
|
|
@@ -207,88 +232,75 @@ const readCompilerOptions = (root: string): { readonly options: ts.CompilerOptio
|
|
|
207
232
|
return { options: config.options }
|
|
208
233
|
}
|
|
209
234
|
|
|
210
|
-
const scriptKind = (path: string): ts.ScriptKind => {
|
|
211
|
-
switch (extname(path)) {
|
|
212
|
-
case '.js': return ts.ScriptKind.JS
|
|
213
|
-
case '.jsx': return ts.ScriptKind.JSX
|
|
214
|
-
case '.mjs': return ts.ScriptKind.JS
|
|
215
|
-
case '.cjs': return ts.ScriptKind.JS
|
|
216
|
-
case '.ts': return ts.ScriptKind.TS
|
|
217
|
-
case '.tsx': return ts.ScriptKind.TSX
|
|
218
|
-
case '.mts': return ts.ScriptKind.TS
|
|
219
|
-
case '.cts': return ts.ScriptKind.TS
|
|
220
|
-
default: return ts.ScriptKind.Unknown
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
235
|
const nodeEvidence = (root: string, path: string, sourceFile: ts.SourceFile, node: ts.Node): Evidence => {
|
|
225
236
|
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
|
|
226
237
|
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1
|
|
227
238
|
return lineEvidence('code', root, path, start, end)
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
const isExported = (node: ts.Node): boolean => {
|
|
231
|
-
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
232
|
-
return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const exportedNames = (sourceFile: ts.SourceFile): string[] => {
|
|
236
|
-
const names = new Set<string>()
|
|
237
|
-
const addDeclarationName = (node: ts.Declaration): void => {
|
|
238
|
-
if (!isExported(node)) return
|
|
239
|
-
const name = ts.getNameOfDeclaration(node)
|
|
240
|
-
if (name && ts.isIdentifier(name)) names.add(name.text)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const visit = (node: ts.Node): void => {
|
|
244
|
-
if (ts.isExportDeclaration(node)) {
|
|
245
|
-
if (!node.exportClause) names.add('*')
|
|
246
|
-
else if (ts.isNamedExports(node.exportClause)) {
|
|
247
|
-
for (const element of node.exportClause.elements) names.add(element.name.text)
|
|
248
|
-
}
|
|
249
|
-
} else if (ts.isExportAssignment(node)) {
|
|
250
|
-
names.add('default')
|
|
251
|
-
} else if (
|
|
252
|
-
ts.isClassDeclaration(node) ||
|
|
253
|
-
ts.isFunctionDeclaration(node) ||
|
|
254
|
-
ts.isInterfaceDeclaration(node) ||
|
|
255
|
-
ts.isTypeAliasDeclaration(node) ||
|
|
256
|
-
ts.isEnumDeclaration(node) ||
|
|
257
|
-
ts.isModuleDeclaration(node)
|
|
258
|
-
) {
|
|
259
|
-
addDeclarationName(node)
|
|
260
|
-
} else if (ts.isVariableStatement(node) && isExported(node)) {
|
|
261
|
-
for (const declaration of node.declarationList.declarations) {
|
|
262
|
-
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
ts.forEachChild(node, visit)
|
|
266
|
-
}
|
|
267
|
-
visit(sourceFile)
|
|
268
|
-
return [...names].sort()
|
|
269
|
-
}
|
|
270
|
-
|
|
271
241
|
const moduleReferences = (
|
|
272
242
|
root: string,
|
|
273
243
|
path: string,
|
|
274
244
|
sourceFile: ts.SourceFile,
|
|
275
245
|
runtimeWiringMethods: ReadonlySet<string>,
|
|
276
|
-
): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly hasDynamic: boolean; readonly hasLiteralDynamic: boolean; readonly hasRuntimeWiring: boolean; readonly hasUnresolvedRuntimeWiring: boolean } => {
|
|
246
|
+
): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly dynamicEvidence: readonly Evidence[]; readonly hasDynamic: boolean; readonly hasLiteralDynamic: boolean; readonly hasRuntimeWiring: boolean; readonly hasUnresolvedRuntimeWiring: boolean } => {
|
|
277
247
|
const references: ImportReference[] = []
|
|
248
|
+
const dynamicEvidence: Evidence[] = []
|
|
278
249
|
let hasDynamic = false
|
|
279
250
|
let hasLiteralDynamic = false
|
|
280
251
|
let hasRuntimeWiring = false
|
|
281
252
|
let hasUnresolvedRuntimeWiring = false
|
|
282
253
|
const importedBindings = new Map<string, string>()
|
|
283
254
|
const staticStringBindings = new Map<string, string | undefined>()
|
|
255
|
+
const localBindings = new Set<string>()
|
|
256
|
+
const resolveStaticString = (expression: ts.Expression): string | undefined => {
|
|
257
|
+
if (ts.isStringLiteralLike(expression)) return expression.text
|
|
258
|
+
if (ts.isIdentifier(expression)) return staticStringBindings.get(expression.text)
|
|
259
|
+
if (ts.isParenthesizedExpression(expression)) return resolveStaticString(expression.expression)
|
|
260
|
+
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
261
|
+
const left = resolveStaticString(expression.left)
|
|
262
|
+
const right = resolveStaticString(expression.right)
|
|
263
|
+
return left !== undefined && right !== undefined ? left + right : undefined
|
|
264
|
+
}
|
|
265
|
+
return undefined
|
|
266
|
+
}
|
|
284
267
|
const collectStaticStringBindings = (node: ts.Node): void => {
|
|
285
|
-
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.
|
|
268
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isVariableDeclarationList(node.parent) && (node.parent.flags & ts.NodeFlags.Const) !== 0) {
|
|
269
|
+
const value = resolveStaticString(node.initializer)
|
|
286
270
|
const previous = staticStringBindings.get(node.name.text)
|
|
287
|
-
staticStringBindings.set(node.name.text, !staticStringBindings.has(node.name.text) || previous ===
|
|
271
|
+
staticStringBindings.set(node.name.text, !staticStringBindings.has(node.name.text) || previous === value ? value : undefined)
|
|
288
272
|
}
|
|
273
|
+
if (
|
|
274
|
+
(ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) &&
|
|
275
|
+
ts.isIdentifier(node.name)
|
|
276
|
+
) localBindings.add(node.name.text)
|
|
277
|
+
if (
|
|
278
|
+
(ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
|
|
279
|
+
node.name
|
|
280
|
+
) localBindings.add(node.name.text)
|
|
289
281
|
ts.forEachChild(node, collectStaticStringBindings)
|
|
290
282
|
}
|
|
291
283
|
collectStaticStringBindings(sourceFile)
|
|
284
|
+
const addImportedBindingReference = (expression: ts.Expression, node: ts.Node): boolean => {
|
|
285
|
+
if (ts.isIdentifier(expression)) {
|
|
286
|
+
const specifier = importedBindings.get(expression.text)
|
|
287
|
+
if (specifier) {
|
|
288
|
+
addReference({ text: specifier } as ts.StringLiteralLike, 'runtime-wiring', node, 'runtime-wiring-static')
|
|
289
|
+
return true
|
|
290
|
+
}
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
293
|
+
if (ts.isPropertyAccessExpression(expression)) return addImportedBindingReference(expression.expression, node)
|
|
294
|
+
if (ts.isCallExpression(expression)) return addImportedBindingReference(expression.expression, node)
|
|
295
|
+
return false
|
|
296
|
+
}
|
|
297
|
+
const isKnownLocal = (expression: ts.Expression): boolean => {
|
|
298
|
+
if (ts.isIdentifier(expression)) return localBindings.has(expression.text) || importedBindings.has(expression.text)
|
|
299
|
+
if (expression.kind === ts.SyntaxKind.ThisKeyword) return true
|
|
300
|
+
if (ts.isPropertyAccessExpression(expression)) return isKnownLocal(expression.expression)
|
|
301
|
+
if (ts.isCallExpression(expression)) return isKnownLocal(expression.expression)
|
|
302
|
+
return ts.isStringLiteralLike(expression)
|
|
303
|
+
}
|
|
292
304
|
const addReference = (specifier: ts.StringLiteralLike, kind: ImportReference['kind'], node: ts.Node, detection?: ImportReference['detection']): void => {
|
|
293
305
|
references.push({ specifier: specifier.text, kind, evidence: nodeEvidence(root, path, sourceFile, node), ...(detection ? { detection } : {}) })
|
|
294
306
|
}
|
|
@@ -309,32 +321,43 @@ const moduleReferences = (
|
|
|
309
321
|
importedBindings.set(node.name.text, node.moduleReference.expression.text)
|
|
310
322
|
} else if (ts.isCallExpression(node)) {
|
|
311
323
|
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
312
|
-
|
|
324
|
+
const specifier = node.arguments[0] ? resolveStaticString(node.arguments[0]) : undefined
|
|
325
|
+
if (specifier !== undefined) {
|
|
313
326
|
hasLiteralDynamic = true
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
327
|
+
dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
|
|
328
|
+
addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node, 'dynamic-literal')
|
|
329
|
+
} else {
|
|
330
|
+
hasDynamic = true
|
|
331
|
+
dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
|
|
332
|
+
}
|
|
319
333
|
} else if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
320
334
|
const argument = node.arguments[0]
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
335
|
+
const specifier = argument ? resolveStaticString(argument) : undefined
|
|
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
|
|
345
|
+
dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
|
|
346
|
+
addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node)
|
|
347
|
+
} else {
|
|
348
|
+
hasDynamic = true
|
|
349
|
+
dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
|
|
350
|
+
}
|
|
324
351
|
} else if (ts.isPropertyAccessExpression(node.expression) && runtimeWiringMethods.has(node.expression.name.text)) {
|
|
325
352
|
hasRuntimeWiring = true
|
|
326
|
-
|
|
327
|
-
let hasPotentialTargetArgument = false
|
|
353
|
+
let hasUnresolvedTarget = false
|
|
328
354
|
for (const argument of node.arguments) {
|
|
329
|
-
if (ts.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (specifier) addReference({ text: specifier } as ts.StringLiteralLike, 'runtime-wiring', node, 'runtime-wiring-static')
|
|
333
|
-
} else if (ts.isPropertyAccessExpression(argument) || ts.isCallExpression(argument)) {
|
|
334
|
-
hasPotentialTargetArgument = true
|
|
335
|
-
}
|
|
355
|
+
if (ts.isStringLiteralLike(argument)) continue
|
|
356
|
+
if (addImportedBindingReference(argument, node)) continue
|
|
357
|
+
if (!isKnownLocal(argument)) hasUnresolvedTarget = true
|
|
336
358
|
}
|
|
337
|
-
|
|
359
|
+
const receiver = node.expression.expression
|
|
360
|
+
if (hasUnresolvedTarget && !isKnownLocal(receiver)) hasUnresolvedRuntimeWiring = true
|
|
338
361
|
}
|
|
339
362
|
}
|
|
340
363
|
ts.forEachChild(node, visit)
|
|
@@ -343,6 +366,7 @@ const moduleReferences = (
|
|
|
343
366
|
return {
|
|
344
367
|
references,
|
|
345
368
|
exports: exportedNames(sourceFile),
|
|
369
|
+
dynamicEvidence,
|
|
346
370
|
hasDynamic,
|
|
347
371
|
hasLiteralDynamic,
|
|
348
372
|
hasRuntimeWiring,
|
|
@@ -434,6 +458,10 @@ const hasPackageManagerMetadata = (root: string, rootManifest: JsonRecord | unde
|
|
|
434
458
|
existsSync(join(root, 'package-lock.json')),
|
|
435
459
|
)
|
|
436
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
|
+
|
|
437
465
|
const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: readonly string[], entities: readonly KnowledgeEntity[], relations: readonly KnowledgeRelation[], coverage: DiscoverySnapshotV1['coverage']): DiscoverySnapshotV1 => {
|
|
438
466
|
const revision = sourceRevision(root, files)
|
|
439
467
|
const base = {
|
|
@@ -444,34 +472,28 @@ const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: re
|
|
|
444
472
|
project: { name: (entities.find((entity) => entity.kind === 'package' && entity.path === '.')?.name ?? basename(root)), root: '.' },
|
|
445
473
|
sourceRevision: revision.value,
|
|
446
474
|
sourceRevisionKind: revision.kind,
|
|
447
|
-
configurationHash:
|
|
448
|
-
pipelineVersion:
|
|
449
|
-
analyzerVersions:
|
|
475
|
+
configurationHash: configurationHashOf(config),
|
|
476
|
+
pipelineVersion: PIPELINE_VERSION,
|
|
477
|
+
analyzerVersions: ANALYZER_VERSIONS,
|
|
450
478
|
entities: [...entities].sort((a, b) => a.id.localeCompare(b.id)),
|
|
451
479
|
relations: [...relations].sort((a, b) => a.id.localeCompare(b.id)),
|
|
452
|
-
coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? (
|
|
480
|
+
coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? (ANALYZER_VERSIONS[entry.analyzer] ?? '1.0.0') })),
|
|
453
481
|
}
|
|
454
482
|
return DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
|
|
455
483
|
}
|
|
456
484
|
|
|
457
485
|
export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapshotV1 => {
|
|
458
486
|
const root = resolve(opts.root ?? process.cwd())
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
|
|
464
|
-
maxFiles,
|
|
465
|
-
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
466
|
-
...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
|
|
467
|
-
...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
|
|
468
|
-
}
|
|
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
|
+
})
|
|
469
491
|
const rootManifestPath = join(root, 'package.json')
|
|
470
492
|
const rootManifest = readJson(rootManifestPath).value
|
|
471
493
|
const packageResult = discoverPackages(root, rootManifest, opts.config)
|
|
472
494
|
const sourceWalk = safeWalkFiles(root, { extensions: SOURCE_EXTENSIONS, ...safeOptions })
|
|
473
495
|
const documentWalk = safeWalkFiles(root, { extensions: DOCUMENT_EXTENSIONS, ...safeOptions })
|
|
474
|
-
const configWalk = safeWalkFiles(root, { extensions:
|
|
496
|
+
const configWalk = safeWalkFiles(root, { extensions: CONFIG_EXTENSIONS, ...safeOptions })
|
|
475
497
|
const sourcePaths = sourceWalk.files
|
|
476
498
|
const documentPaths = documentWalk.files
|
|
477
499
|
const configPaths = configWalk.files
|
|
@@ -502,28 +524,280 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
502
524
|
|
|
503
525
|
for (const pkg of packageResult.packages) {
|
|
504
526
|
const text = readFileSync(pkg.manifestPath, 'utf8')
|
|
505
|
-
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')
|
|
506
573
|
}
|
|
507
574
|
|
|
508
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
|
+
|
|
509
591
|
for (const absPath of sourcePaths) {
|
|
510
592
|
const path = relativePath(root, absPath)
|
|
511
593
|
const pkg = packageForModule(packageResult.packages, absPath)
|
|
512
594
|
const id = entityId('module', path)
|
|
513
595
|
const text = readFileSync(absPath, 'utf8')
|
|
514
|
-
const
|
|
515
|
-
const exports = exportedNames(sourceFile)
|
|
596
|
+
const contentHash = fileContentHash(text)
|
|
516
597
|
modules.set(resolve(absPath), { absPath, path, entityId: id, ...(pkg ? { packageId: pkg.id } : {}) })
|
|
517
|
-
|
|
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
|
+
}
|
|
518
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)] })
|
|
519
636
|
}
|
|
520
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[] = []
|
|
521
713
|
for (const absPath of documentPaths) {
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
}
|
|
524
799
|
}
|
|
525
800
|
|
|
526
|
-
const compiler = readCompilerOptions(root)
|
|
527
801
|
const coverage: DiscoverySnapshotV1['coverage'] = [
|
|
528
802
|
...[sourceWalk, documentWalk, configWalk].flatMap((walk, index) => walk.incomplete ? [{ analyzer: 'repository', scope: `limits:${['source', 'documentation', 'configuration'][index]}`, status: 'partial' as const, reason: walk.reason }] : []),
|
|
529
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)}.` } : {}) },
|
|
@@ -534,6 +808,113 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
534
808
|
{ analyzer: 'js-ts', scope: 'generated-code', status: 'not-analyzed', reason: 'Generated code is not interpreted as source architecture.' },
|
|
535
809
|
]
|
|
536
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
|
+
|
|
537
918
|
for (const pkg of packageResult.packages) {
|
|
538
919
|
const text = readFileSync(pkg.manifestPath, 'utf8')
|
|
539
920
|
for (const dependency of dependencyEntries(pkg.manifest)) {
|
|
@@ -552,15 +933,40 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
552
933
|
const includeTestRuntimeWiring = opts.config?.analysis?.jsTs?.includeTestRuntimeWiring ?? false
|
|
553
934
|
let observedLiteralDynamic = false
|
|
554
935
|
let observedUnresolvedDynamic = false
|
|
936
|
+
const observedDynamicEvidence: Evidence[] = []
|
|
555
937
|
let observedRuntimeWiring = false
|
|
556
938
|
let observedUnresolvedRuntimeWiring = false
|
|
557
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
|
+
|
|
558
962
|
const text = readFileSync(module.absPath, 'utf8')
|
|
559
963
|
const sourceFile = ts.createSourceFile(module.absPath, text, ts.ScriptTarget.Latest, true, scriptKind(module.absPath))
|
|
560
964
|
const runtimeWiringMethods = includeTestRuntimeWiring || !TEST_MODULE_PATTERN.test(module.path) ? configuredRuntimeWiringMethods : new Set<string>()
|
|
561
965
|
const references = moduleReferences(root, module.absPath, sourceFile, runtimeWiringMethods)
|
|
966
|
+
ledger.parsedFiles.push(module.path)
|
|
562
967
|
observedLiteralDynamic ||= references.hasLiteralDynamic
|
|
563
968
|
observedUnresolvedDynamic ||= references.hasDynamic
|
|
969
|
+
observedDynamicEvidence.push(...references.dynamicEvidence)
|
|
564
970
|
observedRuntimeWiring ||= references.hasRuntimeWiring
|
|
565
971
|
observedUnresolvedRuntimeWiring ||= references.hasUnresolvedRuntimeWiring
|
|
566
972
|
for (const reference of references.references) {
|
|
@@ -572,14 +978,19 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
572
978
|
}
|
|
573
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 } } : {}) })
|
|
574
980
|
}
|
|
575
|
-
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: [
|
|
576
|
-
|
|
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)] })
|
|
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)] })
|
|
577
988
|
}
|
|
578
989
|
|
|
579
990
|
if (dynamicCoverageIndex >= 0) coverage[dynamicCoverageIndex] = observedUnresolvedDynamic
|
|
580
|
-
? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'partial', reason: 'Literal dynamic imports are resolved; non-literal import expressions and require calls remain unresolved.' }
|
|
991
|
+
? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'partial', reason: 'Literal dynamic imports are resolved; non-literal import expressions and require calls remain unresolved. Evidence lists representative dynamic loading sites.', evidence: [...observedDynamicEvidence.slice(0, 32)] }
|
|
581
992
|
: observedLiteralDynamic
|
|
582
|
-
? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'complete', reason: 'All observed dynamic imports used literal targets and were resolved.' }
|
|
993
|
+
? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'complete', reason: 'All observed dynamic imports used literal targets and were resolved.', evidence: [...observedDynamicEvidence.slice(0, 32)] }
|
|
583
994
|
: { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'not-applicable', reason: 'No dynamic loading expression was observed.' }
|
|
584
995
|
const runtimeCoverageIndex = coverage.findIndex((entry) => entry.scope === 'runtime-wiring')
|
|
585
996
|
if (runtimeCoverageIndex >= 0) coverage[runtimeCoverageIndex] = observedUnresolvedRuntimeWiring
|
|
@@ -588,6 +999,27 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
|
|
|
588
999
|
? { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'complete', reason: 'All observed configured runtime-wiring calls resolved to static bindings.' }
|
|
589
1000
|
: { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-applicable', reason: 'No configured runtime-wiring call was observed.' }
|
|
590
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
|
+
|
|
591
1023
|
return artifact(root, opts.config, allFiles, [...entities.values()], [...relations.values()], coverage)
|
|
592
1024
|
}
|
|
593
1025
|
|