@agentskit/doc-bridge 1.4.3 → 1.5.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 +6 -0
- package/action.yml +1 -1
- package/dist/cli/program.js +2412 -268
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +74 -3
- package/dist/config/index.js.map +1 -1
- package/dist/{index-DhoAG9Ar.d.ts → index-Di7PkJuf.d.ts} +195 -8
- package/dist/index.d.ts +1942 -4
- package/dist/index.js +2143 -189
- package/dist/index.js.map +1 -1
- package/docs/PRD-doc-bridge-knowledge-engine.md +338 -0
- package/docs/knowledge-engine-runbook.md +44 -0
- package/ecosystem-claims.json +16 -16
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +84 -127
- package/mcpb/manifest.json +25 -1
- package/package.json +2 -2
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +97 -0
- package/src/cli/program.ts +285 -3
- package/src/config/defaults.ts +14 -1
- package/src/config/schema.ts +67 -0
- package/src/discovery/documentation.ts +320 -0
- package/src/discovery/repository.ts +514 -0
- package/src/fixes/proposals.ts +165 -0
- package/src/index-builder/content-hash.ts +9 -2
- package/src/index.ts +99 -2
- package/src/mcp/server.ts +178 -8
- package/src/reconciliation/reconcile.ts +227 -0
- package/src/report/html.ts +74 -0
- package/src/rules/engine.ts +180 -0
- package/src/safety/repository.ts +84 -0
- package/src/schemas/knowledge.ts +315 -0
- package/src/validate.ts +24 -0
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +238 -0
- package/tsup.config.ts +2 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import * as ts from 'typescript'
|
|
5
|
+
|
|
6
|
+
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
7
|
+
import { expandWorkspaceGlobs } from '../lib/glob-expand.js'
|
|
8
|
+
import { detectPackageManager } from '../lib/package-manager.js'
|
|
9
|
+
import { toPosix } from '../lib/paths.js'
|
|
10
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
11
|
+
import { DEFAULT_SAFETY_EXCLUDES, safeWalkFiles } from '../safety/repository.js'
|
|
12
|
+
import {
|
|
13
|
+
DiscoverySnapshotV1Schema,
|
|
14
|
+
type DiscoverySnapshotV1,
|
|
15
|
+
type Evidence,
|
|
16
|
+
type KnowledgeEntity,
|
|
17
|
+
type KnowledgeRelation,
|
|
18
|
+
} from '../schemas/knowledge.js'
|
|
19
|
+
|
|
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
|
+
const EMPTY_HASH = '0'.repeat(64)
|
|
24
|
+
|
|
25
|
+
type JsonRecord = Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
type PackageInfo = {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly name?: string
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly absPath: string
|
|
32
|
+
readonly manifestPath: string
|
|
33
|
+
readonly manifest: JsonRecord
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ModuleInfo = {
|
|
37
|
+
readonly absPath: string
|
|
38
|
+
readonly path: string
|
|
39
|
+
readonly entityId: string
|
|
40
|
+
readonly packageId?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type ImportReference = {
|
|
44
|
+
readonly specifier: string
|
|
45
|
+
readonly kind: 'imports' | 're-exports'
|
|
46
|
+
readonly evidence: Evidence
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type DiscoveryOptions = {
|
|
50
|
+
readonly root?: string
|
|
51
|
+
readonly config?: DocBridgeConfigV1
|
|
52
|
+
readonly maxFiles?: number
|
|
53
|
+
readonly maxBytes?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isRecord = (value: unknown): value is JsonRecord =>
|
|
57
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
58
|
+
|
|
59
|
+
const readJson = (path: string): { readonly value?: JsonRecord; readonly error?: string } => {
|
|
60
|
+
try {
|
|
61
|
+
const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
62
|
+
return isRecord(value) ? { value } : { error: 'JSON root is not an object' }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const relativePath = (root: string, path: string): string =>
|
|
69
|
+
toPosix(relative(root, path)) || '.'
|
|
70
|
+
|
|
71
|
+
const MAX_ID_LENGTH = 256
|
|
72
|
+
const ID_HASH_LENGTH = 32
|
|
73
|
+
|
|
74
|
+
const entityId = (kind: string, value: string): string => {
|
|
75
|
+
const fullId = `${kind}:${value}`
|
|
76
|
+
if (fullId.length <= MAX_ID_LENGTH) return fullId
|
|
77
|
+
|
|
78
|
+
const suffix = `:${sha256NormalizedV1(fullId).slice(0, ID_HASH_LENGTH)}`
|
|
79
|
+
return `${fullId.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lineEvidence = (
|
|
83
|
+
source: 'code' | 'configuration' | 'documentation',
|
|
84
|
+
root: string,
|
|
85
|
+
path: string,
|
|
86
|
+
lineStart?: number,
|
|
87
|
+
lineEnd?: number,
|
|
88
|
+
): Evidence => ({
|
|
89
|
+
source,
|
|
90
|
+
path: relativePath(root, path),
|
|
91
|
+
...(lineStart !== undefined ? { lineStart } : {}),
|
|
92
|
+
...(lineEnd !== undefined ? { lineEnd } : {}),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const firstLineContaining = (text: string, pattern: string): number | undefined => {
|
|
96
|
+
const line = text.split(/\r?\n/).findIndex((value) => value.includes(pattern))
|
|
97
|
+
return line >= 0 ? line + 1 : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const packageName = (manifest: JsonRecord, fallback: string): string | undefined =>
|
|
101
|
+
typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : fallback || undefined
|
|
102
|
+
|
|
103
|
+
const workspacePatterns = (root: string, rootManifest: JsonRecord | undefined): string[] => {
|
|
104
|
+
const fromPackageJson = rootManifest?.workspaces
|
|
105
|
+
if (Array.isArray(fromPackageJson)) return fromPackageJson.filter((value): value is string => typeof value === 'string')
|
|
106
|
+
if (isRecord(fromPackageJson) && Array.isArray(fromPackageJson.packages)) {
|
|
107
|
+
return fromPackageJson.packages.filter((value): value is string => typeof value === 'string')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const workspacePath = join(root, 'pnpm-workspace.yaml')
|
|
111
|
+
if (!existsSync(workspacePath)) return []
|
|
112
|
+
const patterns: string[] = []
|
|
113
|
+
let inPackages = false
|
|
114
|
+
for (const line of readFileSync(workspacePath, 'utf8').split(/\r?\n/)) {
|
|
115
|
+
const trimmed = line.trim()
|
|
116
|
+
if (trimmed === 'packages:') {
|
|
117
|
+
inPackages = true
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (!inPackages) continue
|
|
121
|
+
if (trimmed.startsWith('- ')) {
|
|
122
|
+
patterns.push(trimmed.slice(2).trim().replace(/^['"]|['"]$/g, ''))
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (trimmed && !trimmed.startsWith('#')) inPackages = false
|
|
126
|
+
}
|
|
127
|
+
return patterns
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const discoverPackages = (
|
|
131
|
+
root: string,
|
|
132
|
+
rootManifest: JsonRecord | undefined,
|
|
133
|
+
config: DocBridgeConfigV1 | undefined,
|
|
134
|
+
): { readonly packages: readonly PackageInfo[]; readonly coverage: readonly { status: 'complete' | 'partial'; reason?: string }[] } => {
|
|
135
|
+
const packages: PackageInfo[] = []
|
|
136
|
+
const coverage: { status: 'complete' | 'partial'; reason?: string }[] = []
|
|
137
|
+
const rootManifestPath = join(root, 'package.json')
|
|
138
|
+
|
|
139
|
+
if (rootManifest) {
|
|
140
|
+
const name = packageName(rootManifest, '')
|
|
141
|
+
packages.push({
|
|
142
|
+
id: entityId('package', packageName(rootManifest, 'root') ?? 'root'),
|
|
143
|
+
...(name ? { name } : {}),
|
|
144
|
+
path: '.',
|
|
145
|
+
absPath: root,
|
|
146
|
+
manifestPath: rootManifestPath,
|
|
147
|
+
manifest: rootManifest,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const configuredPatterns = config?.routing?.options?.packages
|
|
152
|
+
const patterns = configuredPatterns?.length ? [...configuredPatterns] : workspacePatterns(root, rootManifest)
|
|
153
|
+
if (!patterns.length) {
|
|
154
|
+
coverage.push({ status: 'complete' })
|
|
155
|
+
return { packages, coverage }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const dirs = expandWorkspaceGlobs(root, patterns)
|
|
159
|
+
for (const absPath of dirs) {
|
|
160
|
+
const manifestPath = join(absPath, 'package.json')
|
|
161
|
+
const parsed = readJson(manifestPath)
|
|
162
|
+
if (!parsed.value) {
|
|
163
|
+
coverage.push({ status: 'partial', reason: `${relativePath(root, manifestPath)}: ${parsed.error ?? 'invalid package.json'}` })
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const path = relativePath(root, absPath)
|
|
167
|
+
const name = packageName(parsed.value, path)
|
|
168
|
+
const id = entityId('package', name ?? path)
|
|
169
|
+
const duplicate = packages.find((pkg) => pkg.id === id)
|
|
170
|
+
if (duplicate && duplicate.absPath !== absPath) {
|
|
171
|
+
throw new Error(`Package identity collision for "${id}": "${duplicate.path}" and "${path}".`)
|
|
172
|
+
}
|
|
173
|
+
if (!duplicate) packages.push({ id, ...(name ? { name } : {}), path, absPath, manifestPath, manifest: parsed.value })
|
|
174
|
+
}
|
|
175
|
+
coverage.push({ status: 'complete' })
|
|
176
|
+
return { packages: packages.sort((a, b) => a.id.localeCompare(b.id)), coverage }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const packageForModule = (packages: readonly PackageInfo[], absPath: string): PackageInfo | undefined =>
|
|
180
|
+
[...packages]
|
|
181
|
+
.filter((pkg) => absPath === pkg.absPath || absPath.startsWith(`${pkg.absPath}${sep}`))
|
|
182
|
+
.sort((a, b) => b.absPath.length - a.absPath.length)[0]
|
|
183
|
+
|
|
184
|
+
const readCompilerOptions = (root: string): { readonly options: ts.CompilerOptions; readonly error?: string } => {
|
|
185
|
+
const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json')
|
|
186
|
+
if (!configPath) return { options: {} }
|
|
187
|
+
const parsed = ts.readConfigFile(configPath, ts.sys.readFile)
|
|
188
|
+
if (parsed.error) return { options: {}, error: ts.flattenDiagnosticMessageText(parsed.error.messageText, '\n') }
|
|
189
|
+
const config = ts.parseJsonConfigFileContent(parsed.config, ts.sys, dirname(configPath))
|
|
190
|
+
if (config.errors.length) {
|
|
191
|
+
return {
|
|
192
|
+
options: config.options,
|
|
193
|
+
error: ts.flattenDiagnosticMessageText(config.errors[0]?.messageText ?? 'Invalid tsconfig', '\n'),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { options: config.options }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const scriptKind = (path: string): ts.ScriptKind => {
|
|
200
|
+
switch (extname(path)) {
|
|
201
|
+
case '.js': return ts.ScriptKind.JS
|
|
202
|
+
case '.jsx': return ts.ScriptKind.JSX
|
|
203
|
+
case '.mjs': return ts.ScriptKind.JS
|
|
204
|
+
case '.cjs': return ts.ScriptKind.JS
|
|
205
|
+
case '.ts': return ts.ScriptKind.TS
|
|
206
|
+
case '.tsx': return ts.ScriptKind.TSX
|
|
207
|
+
case '.mts': return ts.ScriptKind.TS
|
|
208
|
+
case '.cts': return ts.ScriptKind.TS
|
|
209
|
+
default: return ts.ScriptKind.Unknown
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nodeEvidence = (root: string, path: string, sourceFile: ts.SourceFile, node: ts.Node): Evidence => {
|
|
214
|
+
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
|
|
215
|
+
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1
|
|
216
|
+
return lineEvidence('code', root, path, start, end)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isExported = (node: ts.Node): boolean => {
|
|
220
|
+
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
221
|
+
return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const exportedNames = (sourceFile: ts.SourceFile): string[] => {
|
|
225
|
+
const names = new Set<string>()
|
|
226
|
+
const addDeclarationName = (node: ts.Declaration): void => {
|
|
227
|
+
if (!isExported(node)) return
|
|
228
|
+
const name = ts.getNameOfDeclaration(node)
|
|
229
|
+
if (name && ts.isIdentifier(name)) names.add(name.text)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const visit = (node: ts.Node): void => {
|
|
233
|
+
if (ts.isExportDeclaration(node)) {
|
|
234
|
+
if (!node.exportClause) names.add('*')
|
|
235
|
+
else if (ts.isNamedExports(node.exportClause)) {
|
|
236
|
+
for (const element of node.exportClause.elements) names.add(element.name.text)
|
|
237
|
+
}
|
|
238
|
+
} else if (ts.isExportAssignment(node)) {
|
|
239
|
+
names.add('default')
|
|
240
|
+
} else if (
|
|
241
|
+
ts.isClassDeclaration(node) ||
|
|
242
|
+
ts.isFunctionDeclaration(node) ||
|
|
243
|
+
ts.isInterfaceDeclaration(node) ||
|
|
244
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
245
|
+
ts.isEnumDeclaration(node) ||
|
|
246
|
+
ts.isModuleDeclaration(node)
|
|
247
|
+
) {
|
|
248
|
+
addDeclarationName(node)
|
|
249
|
+
} else if (ts.isVariableStatement(node) && isExported(node)) {
|
|
250
|
+
for (const declaration of node.declarationList.declarations) {
|
|
251
|
+
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
ts.forEachChild(node, visit)
|
|
255
|
+
}
|
|
256
|
+
visit(sourceFile)
|
|
257
|
+
return [...names].sort()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const moduleReferences = (
|
|
261
|
+
root: string,
|
|
262
|
+
path: string,
|
|
263
|
+
sourceFile: ts.SourceFile,
|
|
264
|
+
): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly hasDynamic: boolean; readonly hasRuntimeWiring: boolean } => {
|
|
265
|
+
const references: ImportReference[] = []
|
|
266
|
+
let hasDynamic = false
|
|
267
|
+
let hasRuntimeWiring = false
|
|
268
|
+
const addReference = (specifier: ts.StringLiteralLike, kind: ImportReference['kind'], node: ts.Node): void => {
|
|
269
|
+
references.push({ specifier: specifier.text, kind, evidence: nodeEvidence(root, path, sourceFile, node) })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const visit = (node: ts.Node): void => {
|
|
273
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
274
|
+
addReference(node.moduleSpecifier, 'imports', node)
|
|
275
|
+
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
276
|
+
addReference(node.moduleSpecifier, 're-exports', node)
|
|
277
|
+
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) {
|
|
278
|
+
addReference(node.moduleReference.expression, 'imports', node)
|
|
279
|
+
} else if (ts.isCallExpression(node)) {
|
|
280
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
281
|
+
if (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0])) hasDynamic = true
|
|
282
|
+
} else if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
283
|
+
const argument = node.arguments[0]
|
|
284
|
+
if (argument && ts.isStringLiteralLike(argument)) addReference(argument, 'imports', node)
|
|
285
|
+
else hasDynamic = true
|
|
286
|
+
} else if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') {
|
|
287
|
+
hasRuntimeWiring = true
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
ts.forEachChild(node, visit)
|
|
291
|
+
}
|
|
292
|
+
visit(sourceFile)
|
|
293
|
+
return {
|
|
294
|
+
references,
|
|
295
|
+
exports: exportedNames(sourceFile),
|
|
296
|
+
hasDynamic,
|
|
297
|
+
hasRuntimeWiring,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const resolveRelativeModule = (specifier: string, containingFile: string, modulePaths: ReadonlyMap<string, ModuleInfo>): ModuleInfo | undefined => {
|
|
302
|
+
const base = resolve(dirname(containingFile), specifier)
|
|
303
|
+
const extension = extname(base)
|
|
304
|
+
const extensionlessBase = extension ? base.slice(0, -extension.length) : base
|
|
305
|
+
const candidates = [
|
|
306
|
+
base,
|
|
307
|
+
...SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`),
|
|
308
|
+
...SOURCE_EXTENSIONS.map((extension) => join(base, `index${extension}`)),
|
|
309
|
+
...SOURCE_EXTENSIONS.map((extension) => `${extensionlessBase}${extension}`),
|
|
310
|
+
...SOURCE_EXTENSIONS.map((extension) => join(extensionlessBase, `index${extension}`)),
|
|
311
|
+
]
|
|
312
|
+
return candidates.map((candidate) => modulePaths.get(resolve(candidate))).find(Boolean)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const resolveReference = (
|
|
316
|
+
reference: ImportReference,
|
|
317
|
+
containingFile: string,
|
|
318
|
+
modules: ReadonlyMap<string, ModuleInfo>,
|
|
319
|
+
packages: readonly PackageInfo[],
|
|
320
|
+
compilerOptions: ts.CompilerOptions,
|
|
321
|
+
): { readonly targetId: string; readonly targetEvidence?: Evidence } | undefined => {
|
|
322
|
+
if (reference.specifier.startsWith('.') || reference.specifier.startsWith('/')) {
|
|
323
|
+
const relativeTarget = resolveRelativeModule(reference.specifier, containingFile, modules)
|
|
324
|
+
return relativeTarget ? { targetId: relativeTarget.entityId } : undefined
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const packageTarget = [...packages]
|
|
328
|
+
.filter((pkg) => pkg.name && (reference.specifier === pkg.name || reference.specifier.startsWith(`${pkg.name}/`)))
|
|
329
|
+
.sort((a, b) => (b.name?.length ?? 0) - (a.name?.length ?? 0))[0]
|
|
330
|
+
if (packageTarget) return { targetId: packageTarget.id }
|
|
331
|
+
|
|
332
|
+
const resolved = ts.resolveModuleName(reference.specifier, containingFile, compilerOptions, ts.sys).resolvedModule?.resolvedFileName
|
|
333
|
+
const resolvedTarget = resolved ? modules.get(resolve(resolved)) : undefined
|
|
334
|
+
if (resolvedTarget) return { targetId: resolvedTarget.entityId }
|
|
335
|
+
|
|
336
|
+
return { targetId: entityId('external', reference.specifier) }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const dependencyEntries = (manifest: JsonRecord): readonly { readonly name: string; readonly type: string }[] => {
|
|
340
|
+
const sections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
341
|
+
return sections.flatMap((type) => {
|
|
342
|
+
const value = manifest[type]
|
|
343
|
+
if (!isRecord(value)) return []
|
|
344
|
+
return Object.keys(value).sort().map((name) => ({ name, type }))
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const sourceRevision = (root: string, files: readonly string[]): { readonly value: string; readonly kind: 'git' | 'content' } => {
|
|
349
|
+
const contentRevision = (): { readonly value: string; readonly kind: 'content' } => ({
|
|
350
|
+
value: sha256NormalizedV1(
|
|
351
|
+
files.map((path) => ({
|
|
352
|
+
path: relativePath(root, path),
|
|
353
|
+
contentHash: sha256NormalizedV1(readFileSync(path, 'utf8')),
|
|
354
|
+
})),
|
|
355
|
+
),
|
|
356
|
+
kind: 'content',
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const status = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], {
|
|
361
|
+
cwd: root,
|
|
362
|
+
encoding: 'utf8',
|
|
363
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
364
|
+
}).trim()
|
|
365
|
+
if (status) return contentRevision()
|
|
366
|
+
const value = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
367
|
+
if (value) return { value, kind: 'git' }
|
|
368
|
+
} catch {
|
|
369
|
+
// Not a Git checkout.
|
|
370
|
+
}
|
|
371
|
+
return contentRevision()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const hasPackageManagerMetadata = (root: string, rootManifest: JsonRecord | undefined): boolean =>
|
|
375
|
+
Boolean(
|
|
376
|
+
rootManifest?.packageManager ||
|
|
377
|
+
existsSync(join(root, 'pnpm-lock.yaml')) ||
|
|
378
|
+
existsSync(join(root, 'pnpm-workspace.yaml')) ||
|
|
379
|
+
existsSync(join(root, 'yarn.lock')) ||
|
|
380
|
+
existsSync(join(root, 'bun.lock')) ||
|
|
381
|
+
existsSync(join(root, 'bun.lockb')) ||
|
|
382
|
+
existsSync(join(root, 'package-lock.json')),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: readonly string[], entities: readonly KnowledgeEntity[], relations: readonly KnowledgeRelation[], coverage: DiscoverySnapshotV1['coverage']): DiscoverySnapshotV1 => {
|
|
386
|
+
const revision = sourceRevision(root, files)
|
|
387
|
+
const base = {
|
|
388
|
+
type: 'discovery-snapshot' as const,
|
|
389
|
+
schemaVersion: 1 as const,
|
|
390
|
+
contentHash: EMPTY_HASH,
|
|
391
|
+
contentHashAlgo: 'sha256-normalized-v1' as const,
|
|
392
|
+
project: { name: (entities.find((entity) => entity.kind === 'package' && entity.path === '.')?.name ?? basename(root)), root: '.' },
|
|
393
|
+
sourceRevision: revision.value,
|
|
394
|
+
sourceRevisionKind: revision.kind,
|
|
395
|
+
configurationHash: sha256NormalizedV1(config ?? {}),
|
|
396
|
+
pipelineVersion: '1.0.0',
|
|
397
|
+
analyzerVersions: { repository: '1.0.0', 'js-ts': '1.0.0' },
|
|
398
|
+
entities: [...entities].sort((a, b) => a.id.localeCompare(b.id)),
|
|
399
|
+
relations: [...relations].sort((a, b) => a.id.localeCompare(b.id)),
|
|
400
|
+
coverage: [...coverage],
|
|
401
|
+
}
|
|
402
|
+
return DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapshotV1 => {
|
|
406
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
407
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES
|
|
408
|
+
const safety = opts.config?.safety
|
|
409
|
+
const maxBytes = opts.maxBytes ?? safety?.maxBytes
|
|
410
|
+
const safeOptions = {
|
|
411
|
+
exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
|
|
412
|
+
maxFiles,
|
|
413
|
+
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
414
|
+
...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
|
|
415
|
+
...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
|
|
416
|
+
}
|
|
417
|
+
const rootManifestPath = join(root, 'package.json')
|
|
418
|
+
const rootManifest = readJson(rootManifestPath).value
|
|
419
|
+
const packageResult = discoverPackages(root, rootManifest, opts.config)
|
|
420
|
+
const sourceWalk = safeWalkFiles(root, { extensions: SOURCE_EXTENSIONS, ...safeOptions })
|
|
421
|
+
const documentWalk = safeWalkFiles(root, { extensions: DOCUMENT_EXTENSIONS, ...safeOptions })
|
|
422
|
+
const configWalk = safeWalkFiles(root, { extensions: ['.json', '.yaml', '.yml', '.js', '.ts'], ...safeOptions })
|
|
423
|
+
const sourcePaths = sourceWalk.files
|
|
424
|
+
const documentPaths = documentWalk.files
|
|
425
|
+
const configPaths = configWalk.files
|
|
426
|
+
.filter((path) => /(?:^|\/)(?:tsconfig|jsconfig|vite\.config|webpack\.config|rollup\.config|next\.config|jest\.config|eslint\.config|vitest\.config)/.test(relativePath(root, path)))
|
|
427
|
+
const allFiles = [...new Set([rootManifestPath, ...sourcePaths, ...documentPaths, ...configPaths].filter(existsSync))].sort()
|
|
428
|
+
|
|
429
|
+
const entities = new Map<string, KnowledgeEntity>()
|
|
430
|
+
const relations = new Map<string, KnowledgeRelation>()
|
|
431
|
+
const addEntity = (entity: KnowledgeEntity): void => {
|
|
432
|
+
const existing = entities.get(entity.id)
|
|
433
|
+
if (existing && (existing.kind !== entity.kind || existing.path !== entity.path)) throw new Error(`Entity identity collision for "${entity.id}".`)
|
|
434
|
+
entities.set(entity.id, existing ?? entity)
|
|
435
|
+
}
|
|
436
|
+
const addRelation = (relation: KnowledgeRelation): void => {
|
|
437
|
+
const existing = relations.get(relation.id)
|
|
438
|
+
if (!existing) {
|
|
439
|
+
relations.set(relation.id, relation)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const evidence = new Map(
|
|
443
|
+
[...existing.evidence, ...relation.evidence].map((item) => [
|
|
444
|
+
`${item.path}:${item.lineStart ?? ''}:${item.lineEnd ?? ''}:${item.source}`,
|
|
445
|
+
item,
|
|
446
|
+
]),
|
|
447
|
+
)
|
|
448
|
+
relations.set(relation.id, { ...existing, evidence: [...evidence.values()] })
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const pkg of packageResult.packages) {
|
|
452
|
+
const text = readFileSync(pkg.manifestPath, 'utf8')
|
|
453
|
+
addEntity({ id: pkg.id, kind: 'package', name: pkg.name ?? pkg.path, path: pkg.path, provenance: 'observed', evidence: [lineEvidence('configuration', root, pkg.manifestPath, firstLineContaining(text, '"name"'))] })
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const modules = new Map<string, ModuleInfo>()
|
|
457
|
+
for (const absPath of sourcePaths) {
|
|
458
|
+
const path = relativePath(root, absPath)
|
|
459
|
+
const pkg = packageForModule(packageResult.packages, absPath)
|
|
460
|
+
const id = entityId('module', path)
|
|
461
|
+
const text = readFileSync(absPath, 'utf8')
|
|
462
|
+
const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true, scriptKind(absPath))
|
|
463
|
+
const exports = exportedNames(sourceFile)
|
|
464
|
+
modules.set(resolve(absPath), { absPath, path, entityId: id, ...(pkg ? { packageId: pkg.id } : {}) })
|
|
465
|
+
addEntity({ id, kind: 'module', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('code', root, absPath, 1, sourceFile.getLineAndCharacterOfPosition(sourceFile.getEnd()).line + 1)], ...(exports.length ? { metadata: { exports, test: /(?:\.test|\.spec|__tests__)/.test(path) } } : {}) })
|
|
466
|
+
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)] })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const absPath of documentPaths) {
|
|
470
|
+
const path = relativePath(root, absPath)
|
|
471
|
+
addEntity({ id: entityId('document', path), kind: 'document', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('documentation', root, absPath, 1)] })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const compiler = readCompilerOptions(root)
|
|
475
|
+
const coverage: DiscoverySnapshotV1['coverage'] = [
|
|
476
|
+
...[sourceWalk, documentWalk, configWalk].flatMap((walk, index) => walk.incomplete ? [{ analyzer: 'repository', scope: `limits:${['source', 'documentation', 'configuration'][index]}`, status: 'partial' as const, reason: walk.reason }] : []),
|
|
477
|
+
{ 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)}.` } : {}) },
|
|
478
|
+
{ analyzer: 'repository', scope: 'workspace-packages', status: packageResult.coverage.some((item) => item.status === 'partial') ? 'partial' : 'complete', ...(packageResult.coverage.find((item) => item.reason)?.reason ? { reason: packageResult.coverage.find((item) => item.reason)?.reason } : {}) },
|
|
479
|
+
{ analyzer: 'js-ts', scope: 'static-imports-and-exports', status: compiler.error ? 'partial' : 'complete', ...(compiler.error ? { reason: compiler.error } : {}) },
|
|
480
|
+
{ analyzer: 'js-ts', scope: 'dynamic-imports', status: 'not-analyzed', reason: 'Dynamic import expressions and non-literal require calls are not resolved.' },
|
|
481
|
+
{ analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-analyzed', reason: 'Reflection, dependency injection and runtime wiring are not inferred.' },
|
|
482
|
+
{ analyzer: 'js-ts', scope: 'generated-code', status: 'not-analyzed', reason: 'Generated code is not interpreted as source architecture.' },
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
for (const pkg of packageResult.packages) {
|
|
486
|
+
const text = readFileSync(pkg.manifestPath, 'utf8')
|
|
487
|
+
for (const dependency of dependencyEntries(pkg.manifest)) {
|
|
488
|
+
const target = packageResult.packages.find((candidate) => candidate.name === dependency.name)?.id ?? entityId('external', dependency.name)
|
|
489
|
+
if (!entities.has(target)) addEntity({ id: target, kind: 'external', name: dependency.name, provenance: 'observed', evidence: [lineEvidence('configuration', root, pkg.manifestPath, firstLineContaining(text, `"${dependency.name}"`))] })
|
|
490
|
+
addRelation({ id: entityId('relation', `${pkg.id}:depends-on:${target}:${dependency.type}`), kind: 'depends-on', from: pkg.id, to: target, provenance: 'observed', evidence: [lineEvidence('configuration', root, pkg.manifestPath, firstLineContaining(text, `"${dependency.name}"`))], metadata: { dependencyType: dependency.type } })
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (const module of modules.values()) {
|
|
495
|
+
const text = readFileSync(module.absPath, 'utf8')
|
|
496
|
+
const sourceFile = ts.createSourceFile(module.absPath, text, ts.ScriptTarget.Latest, true, scriptKind(module.absPath))
|
|
497
|
+
const references = moduleReferences(root, module.absPath, sourceFile)
|
|
498
|
+
for (const reference of references.references) {
|
|
499
|
+
const target = resolveReference(reference, module.absPath, modules, packageResult.packages, compiler.options)
|
|
500
|
+
if (!target) continue
|
|
501
|
+
if (!entities.has(target.targetId)) {
|
|
502
|
+
const externalName = target.targetId.replace(/^external:/, '')
|
|
503
|
+
addEntity({ id: target.targetId, kind: 'external', name: externalName, provenance: 'observed', evidence: [reference.evidence] })
|
|
504
|
+
}
|
|
505
|
+
addRelation({ id: entityId('relation', `${module.entityId}:${reference.kind}:${target.targetId}`), kind: reference.kind, from: module.entityId, to: target.targetId, provenance: 'observed', evidence: [reference.evidence] })
|
|
506
|
+
}
|
|
507
|
+
if (references.hasDynamic) coverage.push({ analyzer: 'js-ts', scope: `dynamic-imports:${module.path}`, status: 'not-analyzed', reason: 'A dynamic import or non-literal require was found.', evidence: [lineEvidence('code', root, module.absPath)] })
|
|
508
|
+
if (references.hasRuntimeWiring) coverage.push({ analyzer: 'js-ts', scope: `runtime-wiring:${module.path}`, status: 'not-analyzed', reason: 'A possible runtime registration/wiring call was found.', evidence: [lineEvidence('code', root, module.absPath)] })
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return artifact(root, opts.config, allFiles, [...entities.values()], [...relations.values()], coverage)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export type { DiscoveryOptions }
|