@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.
@@ -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 }