@agentskit/doc-bridge 1.6.4 → 1.7.45

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/CONTRIBUTING.md +6 -4
  3. package/action.yml +1 -1
  4. package/dist/cli/program.js +1139 -294
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/config/index.d.ts +1 -1
  7. package/dist/config/index.js +43 -5
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/index-BUL0q7s8.d.ts +660 -0
  10. package/dist/index.d.ts +817 -2134
  11. package/dist/index.js +1154 -244
  12. package/dist/index.js.map +1 -1
  13. package/docs/PRD-enterprise-hardening.md +288 -0
  14. package/docs/RELEASE.md +22 -8
  15. package/docs/adr/0001-enterprise-verification-contract.md +35 -0
  16. package/docs/agent-corpus/INDEX.md +2 -2
  17. package/docs/agent-corpus/chat.md +2 -2
  18. package/docs/agent-corpus/cli.md +2 -2
  19. package/docs/agent-corpus/conformance.md +2 -2
  20. package/docs/agent-corpus/doc-bridge.md +1 -1
  21. package/docs/agent-corpus/doctor.md +2 -2
  22. package/docs/agent-corpus/gates.md +2 -2
  23. package/docs/agent-corpus/mcp.md +2 -2
  24. package/docs/agent-corpus/memory.md +2 -2
  25. package/docs/agent-corpus/query.md +2 -2
  26. package/docs/knowledge-engine-runbook.md +30 -2
  27. package/docs/spec/analyzer-plugin-v1.md +24 -0
  28. package/docs/spec/benchmark-v1.md +36 -0
  29. package/docs/spec/config-v1.md +156 -0
  30. package/docs/validation-cycle-plan.md +255 -0
  31. package/docs/verification-harness.md +37 -4
  32. package/mcpb/manifest.json +1 -1
  33. package/package.json +68 -70
  34. package/scripts/check-ecosystem-upstream.mjs +3 -2
  35. package/scripts/report-visual-check.mjs +64 -12
  36. package/scripts/verification-harness.mjs +216 -14
  37. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  38. package/src/agents/registry-adapter.ts +31 -7
  39. package/src/cli/demo.ts +2 -2
  40. package/src/cli/program.ts +59 -16
  41. package/src/config/index.ts +2 -0
  42. package/src/config/load-config.ts +7 -1
  43. package/src/config/schema.ts +60 -2
  44. package/src/conformance/documentation-standard-v1.ts +14 -8
  45. package/src/discovery/documentation.ts +90 -23
  46. package/src/discovery/repository.ts +147 -19
  47. package/src/doctor/run-doctor.ts +2 -15
  48. package/src/federation/llms.ts +72 -20
  49. package/src/fixes/proposals.ts +4 -3
  50. package/src/index-builder/human-adapters/fumadocs.ts +1 -1
  51. package/src/index-builder/watch-index.ts +1 -1
  52. package/src/index.ts +29 -0
  53. package/src/lib/bounded-text.ts +15 -10
  54. package/src/metrics/benchmark.ts +176 -0
  55. package/src/plugins/contract.ts +89 -0
  56. package/src/reconciliation/reconcile.ts +181 -5
  57. package/src/report/html.ts +318 -88
  58. package/src/rules/engine.ts +15 -2
  59. package/src/safety/repository.ts +1 -1
  60. package/src/schemas/knowledge.ts +21 -3
  61. package/src/validate.ts +7 -1
  62. package/src/version.ts +1 -1
  63. package/src/workflow/engine.ts +65 -9
  64. package/dist/index-DudNuwI5.d.ts +0 -2060
@@ -171,18 +171,60 @@ export const RuleSeveritySchema = z.enum(['off', 'info', 'warn', 'error'])
171
171
  export const RulesConfigSchema = z
172
172
  .object({
173
173
  mode: z.enum(['default', 'recommended', 'strict']).optional(),
174
- severity: z.record(RuleIdSchema, RuleSeveritySchema).optional(),
174
+ severity: z.partialRecord(RuleIdSchema, RuleSeveritySchema).optional(),
175
175
  ignore: z.array(RuleIdSchema).max(128).optional(),
176
176
  criticalEntities: z.array(z.string().min(1).max(256)).max(128).optional(),
177
177
  criticalPaths: z.array(z.string().min(1).max(512)).max(128).optional(),
178
- warningThresholds: z.record(RuleIdSchema, z.number().int().min(1).max(100_000)).optional(),
178
+ warningThresholds: z.partialRecord(RuleIdSchema, z.number().int().min(1).max(100_000)).optional(),
179
179
  })
180
180
  .strict()
181
181
 
182
182
  export const ReconciliationConfigSchema = z
183
183
  .object({
184
+ /** Semantic comparison level. Raw discovery always keeps file-level relations. */
185
+ scope: z.enum(['file', 'module', 'package']).optional(),
184
186
  /** Relation kinds that must have documentation declarations. Omit to require all observed kinds; [] disables this signal. */
185
187
  requiredRelationKinds: z.array(z.string().min(1).max(128)).max(128).optional(),
188
+ /** Limit missing-declaration findings to relations whose endpoints are internal project entities. */
189
+ requiredRelationTargets: z.enum(['all', 'internal']).optional(),
190
+ includeOrphanedDocuments: z.boolean().optional(),
191
+ })
192
+ .strict()
193
+
194
+ export const AnalysisConfigSchema = z
195
+ .object({
196
+ plugins: z
197
+ .array(
198
+ z
199
+ .object({
200
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/).max(128),
201
+ enabled: z.boolean().optional(),
202
+ order: z.number().int().nonnegative().optional(),
203
+ options: z.record(z.string(), z.unknown()).optional(),
204
+ reason: z.string().min(1).max(1_024).optional(),
205
+ })
206
+ .strict(),
207
+ )
208
+ .max(128)
209
+ .optional(),
210
+ jsTs: z
211
+ .object({
212
+ runtimeWiringMethods: z.array(z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/).max(64)).max(64).optional(),
213
+ runtimeWiringAdapters: z
214
+ .array(
215
+ z
216
+ .object({
217
+ id: z.string().min(1).max(128),
218
+ methods: z.array(z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/).max(64)).min(1).max(64),
219
+ })
220
+ .strict(),
221
+ )
222
+ .max(32)
223
+ .optional(),
224
+ includeTestRuntimeWiring: z.boolean().optional(),
225
+ })
226
+ .strict()
227
+ .optional(),
186
228
  })
187
229
  .strict()
188
230
 
@@ -203,6 +245,13 @@ export const RepositorySafetyConfigSchema = z
203
245
  })
204
246
  .strict()
205
247
 
248
+ export const ReportConfigSchema = z
249
+ .object({
250
+ /** Public report privacy mode. Private is the default; anonymized preserves topology without project identity. */
251
+ privacy: z.enum(['private', 'anonymized']).optional(),
252
+ })
253
+ .strict()
254
+
206
255
  export const SurfacesConfigSchema = z
207
256
  .object({
208
257
  cli: z
@@ -310,6 +359,11 @@ export const IntelligenceConfigSchema = z
310
359
  agentId: z.string().min(1).max(256).optional(),
311
360
  agentRoot: z.string().min(1).max(512).optional(),
312
361
  runnerModule: z.string().min(1).max(512).optional(),
362
+ deterministic: z.boolean().optional(),
363
+ timeoutMs: z.number().int().positive().max(600_000).optional(),
364
+ maxTokens: z.number().int().positive().max(1_000_000).optional(),
365
+ maxResponseBytes: z.number().int().positive().max(10_000_000).optional(),
366
+ maxConcurrency: z.number().int().positive().max(64).optional(),
313
367
  })
314
368
  .strict()
315
369
  .optional(),
@@ -442,9 +496,11 @@ export const DocBridgeConfigV1Schema = z
442
496
  routing: RoutingConfigSchema.optional(),
443
497
  gates: GatesConfigSchema.optional(),
444
498
  reconciliation: ReconciliationConfigSchema.optional(),
499
+ analysis: AnalysisConfigSchema.optional(),
445
500
  rules: RulesConfigSchema.optional(),
446
501
  workflow: WorkflowConfigSchema.optional(),
447
502
  safety: RepositorySafetyConfigSchema.optional(),
503
+ report: ReportConfigSchema.optional(),
448
504
  surfaces: SurfacesConfigSchema.optional(),
449
505
  intelligence: IntelligenceConfigSchema.optional(),
450
506
  federation: FederationConfigSchema.optional(),
@@ -458,8 +514,10 @@ export type HumanCorpusConfig = z.infer<typeof HumanCorpusConfigSchema>
458
514
  export type DocumentationStandardV1Config = z.infer<typeof DocumentationStandardV1ConfigSchema>
459
515
  export type DocumentationStandardRuleId = z.infer<typeof DocumentationStandardRuleIdSchema>
460
516
  export type ReconciliationConfig = z.infer<typeof ReconciliationConfigSchema>
517
+ export type AnalysisConfig = z.infer<typeof AnalysisConfigSchema>
461
518
  export type RuleId = z.infer<typeof RuleIdSchema>
462
519
  export type RuleSeverity = z.infer<typeof RuleSeveritySchema>
463
520
  export type RulesConfig = z.infer<typeof RulesConfigSchema>
464
521
  export type WorkflowConfig = z.infer<typeof WorkflowConfigSchema>
465
522
  export type RepositorySafetyConfig = z.infer<typeof RepositorySafetyConfigSchema>
523
+ export type ReportConfig = z.infer<typeof ReportConfigSchema>
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, realpathSync } from 'node:fs'
2
2
  import { isAbsolute, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  import type {
@@ -95,11 +95,10 @@ const fileEvidence = (
95
95
  evidence: { path, detail: 'Path escapes the project root.' },
96
96
  }
97
97
  }
98
- if (!existsSync(abs)) {
99
- return { exists: false, content: '', evidence: { path, detail: 'File does not exist.' } }
100
- }
98
+ let fd: number | undefined
101
99
  try {
102
- const stat = statSync(abs)
100
+ fd = openSync(abs, 'r')
101
+ const stat = fstatSync(fd)
103
102
  if (!stat.isFile()) {
104
103
  return { exists: false, content: '', evidence: { path, detail: 'Path is not a regular file.' } }
105
104
  }
@@ -120,7 +119,7 @@ const fileEvidence = (
120
119
  evidence: { path, detail: `Text evidence exceeds ${MAX_TEXT_EVIDENCE_BYTES} bytes.` },
121
120
  }
122
121
  }
123
- const content = readFileSync(abs, 'utf8')
122
+ const content = readFileSync(fd, 'utf8')
124
123
  return {
125
124
  exists: content.trim().length > 0,
126
125
  content,
@@ -129,8 +128,15 @@ const fileEvidence = (
129
128
  detail: content.trim().length > 0 ? 'File exists and is non-empty.' : 'File is empty.',
130
129
  },
131
130
  }
132
- } catch {
133
- return { exists: false, content: '', evidence: { path, detail: 'File is not readable text.' } }
131
+ } catch (error) {
132
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined
133
+ return {
134
+ exists: false,
135
+ content: '',
136
+ evidence: { path, detail: code === 'ENOENT' ? 'File does not exist.' : 'File is not readable text.' },
137
+ }
138
+ } finally {
139
+ if (fd !== undefined) closeSync(fd)
134
140
  }
135
141
  }
136
142
 
@@ -22,6 +22,8 @@ export type DocumentationDeclarationInput = {
22
22
  export type DocumentationDeclarationOptions = {
23
23
  readonly snapshot: Pick<DiscoverySnapshotV1, 'entities'>
24
24
  readonly documentId?: string
25
+ /** Agent corpus root used for conservative package/app path inference. */
26
+ readonly agentRoot?: string
25
27
  }
26
28
 
27
29
  export type DocumentationDeclarationResult = {
@@ -71,6 +73,41 @@ const scalar = (value: string): string => {
71
73
  return trimmed
72
74
  }
73
75
 
76
+ const isFieldName = (value: string): boolean => {
77
+ if (!/^[A-Za-z]/.test(value)) return false
78
+ for (const character of value.slice(1)) {
79
+ if (!/[A-Za-z0-9_-]/.test(character)) return false
80
+ }
81
+ return true
82
+ }
83
+
84
+ const parseIndentedField = (raw: string, indentation: number): { readonly key: string; readonly value: string } | undefined => {
85
+ const prefix = ' '.repeat(indentation)
86
+ if (!raw.startsWith(prefix) || raw[indentation] === ' ') return undefined
87
+ const body = raw.slice(indentation)
88
+ const separator = body.indexOf(':')
89
+ if (separator <= 0) return undefined
90
+ const key = body.slice(0, separator).trim()
91
+ return isFieldName(key) ? { key, value: body.slice(separator + 1).trim() } : undefined
92
+ }
93
+
94
+ const parseListItem = (raw: string, indentation: number): string | undefined => {
95
+ const prefix = `${' '.repeat(indentation)}-`
96
+ if (!raw.startsWith(prefix)) return undefined
97
+ const rest = raw.slice(prefix.length)
98
+ if (rest && !/\s/.test(rest[0] ?? '')) return undefined
99
+ return rest.trim()
100
+ }
101
+
102
+ const conventionalPackageReference = (path: string, agentRoot: string): string | undefined => {
103
+ const prefix = `${agentRoot.replace(/\/$/, '')}/`
104
+ if (!path.startsWith(prefix)) return undefined
105
+ const relative = path.slice(prefix.length)
106
+ const [scope, file] = relative.split('/')
107
+ if ((scope !== 'packages' && scope !== 'apps') || !file) return undefined
108
+ return file.replace(/\.mdx?$/, '')
109
+ }
110
+
74
111
  const list = (value: string): string[] | undefined => {
75
112
  const trimmed = value.trim()
76
113
  if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return undefined
@@ -103,7 +140,15 @@ const resolveEntity = (
103
140
  lineStart: number,
104
141
  unresolved: Map<string, KnowledgeEntity>,
105
142
  ): KnowledgeEntity => {
106
- const resolved = entities.find((entity) => entity.id === reference || entity.aliases?.includes(reference))
143
+ const direct = entities.find((entity) => entity.id === reference || entity.aliases?.includes(reference))
144
+ if (direct) return direct
145
+ const packageReference = reference.replace(/^package:/, '')
146
+ const packageCandidates = entities.filter((entity) => {
147
+ if (entity.kind !== 'package') return false
148
+ const pathName = entity.path?.split('/').pop()
149
+ return entity.name === reference || entity.path === reference || pathName === packageReference || entity.name.endsWith(`/${packageReference}`)
150
+ })
151
+ const resolved = packageCandidates.length === 1 ? packageCandidates[0] : undefined
107
152
  if (resolved) return resolved
108
153
  const id = `unresolved:${reference}`
109
154
  const existing = unresolved.get(id)
@@ -123,9 +168,14 @@ const relationKey = (from: string, to: string, kind: string): string => `${from}
123
168
 
124
169
  const parseBlock = (
125
170
  input: DocumentationDeclarationInput,
171
+ options: Pick<DocumentationDeclarationOptions, 'agentRoot'> = {},
126
172
  ): { readonly covers: readonly { value: string; line: number }[]; readonly relations: readonly RelationFields[]; readonly diagnostics: readonly DocumentationDiagnostic[]; readonly hasDocbridge: boolean } => {
173
+ const conventionalPath = conventionalPackageReference(input.path, options.agentRoot ?? 'docs/for-agents')
127
174
  const frontmatter = findFrontmatter(input.content)
128
175
  if (!frontmatter) {
176
+ if (conventionalPath && !input.content.replace(/^\uFEFF/, '').startsWith('---')) {
177
+ return { covers: [{ value: conventionalPath, line: 1 }], relations: [], diagnostics: [], hasDocbridge: true }
178
+ }
129
179
  if (input.content.replace(/^\uFEFF/, '').startsWith('---')) {
130
180
  return {
131
181
  covers: [],
@@ -138,8 +188,24 @@ const parseBlock = (
138
188
  }
139
189
 
140
190
  const { lines, end } = frontmatter
141
- const docbridgeLine = lines.findIndex((line, index) => index > 0 && index < end && /^docbridge\s*:/.test(line))
142
- if (docbridgeLine < 0) return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
191
+ let docbridgeLine = -1
192
+ let typeLine = -1
193
+ let packageLine = -1
194
+ let humanDocLine = -1
195
+ for (let index = 1; index < end; index += 1) {
196
+ const line = lines[index] ?? ''
197
+ if (docbridgeLine < 0 && /^docbridge\s*:/.test(line)) docbridgeLine = index
198
+ if (typeLine < 0 && /^type\s*:/.test(line)) typeLine = index
199
+ if (packageLine < 0 && /^package\s*:/.test(line)) packageLine = index
200
+ if (humanDocLine < 0 && /^humanDoc\s*:/.test(line)) humanDocLine = index
201
+ }
202
+ if (docbridgeLine < 0) {
203
+ const type = typeLine >= 0 ? scalar(lines[typeLine]?.slice('type:'.length) ?? '') : ''
204
+ const packageReference = packageLine >= 0 ? scalar(lines[packageLine]?.slice('package:'.length) ?? '') : conventionalPath ?? ''
205
+ if (type === 'package' && packageReference) return { covers: [{ value: packageReference, line: packageLine >= 0 ? packageLine + 1 : typeLine + 1 }], relations: [], diagnostics: [], hasDocbridge: true }
206
+ if (conventionalPath) return { covers: [{ value: packageReference, line: humanDocLine >= 0 ? humanDocLine + 1 : 1 }], relations: [], diagnostics: [], hasDocbridge: true }
207
+ return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
208
+ }
143
209
 
144
210
  const diagnostics: DocumentationDiagnostic[] = []
145
211
  const covers: { value: string; line: number }[] = []
@@ -168,11 +234,10 @@ const parseBlock = (
168
234
  section = undefined
169
235
  continue
170
236
  }
171
- if (/^ {2}[A-Za-z][A-Za-z0-9_-]*\s*:/.test(raw)) {
237
+ const sectionField = parseIndentedField(raw, 2)
238
+ if (sectionField) {
172
239
  finishRelation()
173
- const match = /^ {2}([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
174
- const key = match?.[1]
175
- const value = match?.[2] ?? ''
240
+ const { key, value } = sectionField
176
241
  if (key !== 'covers' && key !== 'relations') {
177
242
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown docbridge field: ${key ?? '(missing)'}.`, line)
178
243
  section = undefined
@@ -189,35 +254,36 @@ const parseBlock = (
189
254
  }
190
255
  continue
191
256
  }
192
- if (section === 'covers' && /^ {4}-\s*/.test(raw)) {
193
- const value = scalar(raw.replace(/^ {4}-\s*/, ''))
257
+ const listValue = parseListItem(raw, 4)
258
+ if (section === 'covers' && listValue !== undefined) {
259
+ const value = scalar(listValue)
194
260
  if (!value) addDiagnostic(diagnostics, input, 'DOCBRIDGE_REFERENCE_MISSING', 'covers entries must not be empty.', line)
195
261
  else covers.push({ value, line })
196
262
  continue
197
263
  }
198
- if (section === 'relations' && /^ {4}-\s*/.test(raw)) {
264
+ if (section === 'relations' && listValue !== undefined) {
199
265
  finishRelation()
200
- const firstField = /^ {4}-\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
266
+ const firstField = parseIndentedField(` ${listValue}`, 4)
201
267
  current = { startLine: line, endLine: line, fields: new Set() }
202
- if (firstField?.[1]) {
203
- current.fields.add(firstField[1])
204
- current[firstField[1] as 'from' | 'to' | 'kind' | 'detection'] = scalar(firstField[2] ?? '')
205
- } else if (raw.replace(/^ {4}-\s*/, '').trim()) {
268
+ if (firstField?.key) {
269
+ current.fields.add(firstField.key)
270
+ current[firstField.key as 'from' | 'to' | 'kind' | 'detection'] = scalar(firstField.value)
271
+ } else if (listValue) {
206
272
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_RELATION_INVALID', 'Relation entries must be field mappings.', line)
207
273
  }
208
274
  continue
209
275
  }
210
- if (section === 'relations' && current && /^ {6}[A-Za-z][A-Za-z0-9_-]*\s*:/.test(raw)) {
211
- const field = /^ {6}([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
212
- const key = field?.[1]
276
+ const field = parseIndentedField(raw, 6)
277
+ if (section === 'relations' && current && field) {
278
+ const { key, value } = field
213
279
  current.endLine = line
214
- if (!key || !['from', 'to', 'kind', 'detection'].includes(key)) {
215
- addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown relation field: ${key ?? '(missing)'}.`, line)
280
+ if (!['from', 'to', 'kind', 'detection'].includes(key)) {
281
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown relation field: ${key}.`, line)
216
282
  } else if (current.fields.has(key)) {
217
283
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_DUPLICATE', `Duplicate relation field: ${key}.`, line)
218
284
  } else {
219
285
  current.fields.add(key)
220
- current[key as 'from' | 'to' | 'kind' | 'detection'] = scalar(field?.[2] ?? '')
286
+ current[key as 'from' | 'to' | 'kind' | 'detection'] = scalar(value)
221
287
  }
222
288
  continue
223
289
  }
@@ -231,7 +297,7 @@ export const parseDocumentationDeclarations = (
231
297
  input: DocumentationDeclarationInput,
232
298
  options: DocumentationDeclarationOptions,
233
299
  ): DocumentationDeclarationResult => {
234
- const parsed = parseBlock(input)
300
+ const parsed = parseBlock(input, options)
235
301
  if (!parsed.hasDocbridge) return { hasDocbridge: false, entities: [], relations: [], diagnostics: [] }
236
302
  const diagnostics = [...parsed.diagnostics]
237
303
  const unresolved = new Map<string, KnowledgeEntity>()
@@ -300,13 +366,14 @@ export const parseDocumentationDeclarations = (
300
366
  export const applyDocumentationDeclarations = (
301
367
  snapshot: DiscoverySnapshotV1,
302
368
  documents: readonly DocumentationDeclarationInput[],
369
+ options: Pick<DocumentationDeclarationOptions, 'agentRoot'> = {},
303
370
  ): DocumentationAnalysisResult => {
304
371
  const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
305
372
  const relations = new Map(snapshot.relations.map((relation) => [relation.id, relation]))
306
373
  const diagnostics: DocumentationDiagnostic[] = []
307
374
 
308
375
  for (const document of documents) {
309
- const result = parseDocumentationDeclarations(document, { snapshot })
376
+ const result = parseDocumentationDeclarations(document, { snapshot, ...options })
310
377
  diagnostics.push(...result.diagnostics)
311
378
  for (const entity of result.entities) entities.set(entity.id, entity)
312
379
  for (const relation of result.relations) relations.set(relation.id, relation)
@@ -21,6 +21,8 @@ const SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts',
21
21
  const DOCUMENT_EXTENSIONS = ['.md', '.mdx'] as const
22
22
  const DEFAULT_MAX_FILES = 10_000
23
23
  const EMPTY_HASH = '0'.repeat(64)
24
+ const DEFAULT_RUNTIME_WIRING_METHODS = ['register', 'use', 'mount', 'attach'] as const
25
+ const TEST_MODULE_PATTERN = /(?:\.test|\.spec|__tests__)/
24
26
 
25
27
  type JsonRecord = Record<string, unknown>
26
28
 
@@ -42,8 +44,9 @@ type ModuleInfo = {
42
44
 
43
45
  type ImportReference = {
44
46
  readonly specifier: string
45
- readonly kind: 'imports' | 're-exports'
47
+ readonly kind: 'imports' | 're-exports' | 'runtime-wiring'
46
48
  readonly evidence: Evidence
49
+ readonly detection?: 'dynamic-literal' | 'runtime-wiring-static'
47
50
  }
48
51
 
49
52
  type DiscoveryOptions = {
@@ -97,6 +100,14 @@ const firstLineContaining = (text: string, pattern: string): number | undefined
97
100
  return line >= 0 ? line + 1 : undefined
98
101
  }
99
102
 
103
+ const documentClassification = (path: string): string => {
104
+ if (/(^|\/)docs\/for-agents(?:\/|$)/.test(path)) return 'agent'
105
+ if (/(^|\/)docs-archive(?:\/|$)/.test(path)) return 'archive'
106
+ if (/(^|\/)docs(?:\/|$)/.test(path)) return 'human'
107
+ if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
108
+ return 'unclassified'
109
+ }
110
+
100
111
  const packageName = (manifest: JsonRecord, fallback: string): string | undefined =>
101
112
  typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : fallback || undefined
102
113
 
@@ -262,30 +273,114 @@ const moduleReferences = (
262
273
  root: string,
263
274
  path: string,
264
275
  sourceFile: ts.SourceFile,
265
- ): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly hasDynamic: boolean; readonly hasRuntimeWiring: boolean } => {
276
+ runtimeWiringMethods: ReadonlySet<string>,
277
+ ): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly dynamicEvidence: readonly Evidence[]; readonly hasDynamic: boolean; readonly hasLiteralDynamic: boolean; readonly hasRuntimeWiring: boolean; readonly hasUnresolvedRuntimeWiring: boolean } => {
266
278
  const references: ImportReference[] = []
279
+ const dynamicEvidence: Evidence[] = []
267
280
  let hasDynamic = false
281
+ let hasLiteralDynamic = false
268
282
  let hasRuntimeWiring = false
269
- const addReference = (specifier: ts.StringLiteralLike, kind: ImportReference['kind'], node: ts.Node): void => {
270
- references.push({ specifier: specifier.text, kind, evidence: nodeEvidence(root, path, sourceFile, node) })
283
+ let hasUnresolvedRuntimeWiring = false
284
+ const importedBindings = new Map<string, string>()
285
+ const staticStringBindings = new Map<string, string | undefined>()
286
+ const localBindings = new Set<string>()
287
+ const resolveStaticString = (expression: ts.Expression): string | undefined => {
288
+ if (ts.isStringLiteralLike(expression)) return expression.text
289
+ if (ts.isIdentifier(expression)) return staticStringBindings.get(expression.text)
290
+ if (ts.isParenthesizedExpression(expression)) return resolveStaticString(expression.expression)
291
+ if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
292
+ const left = resolveStaticString(expression.left)
293
+ const right = resolveStaticString(expression.right)
294
+ return left !== undefined && right !== undefined ? left + right : undefined
295
+ }
296
+ return undefined
297
+ }
298
+ const collectStaticStringBindings = (node: ts.Node): void => {
299
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isVariableDeclarationList(node.parent) && (node.parent.flags & ts.NodeFlags.Const) !== 0) {
300
+ const value = resolveStaticString(node.initializer)
301
+ const previous = staticStringBindings.get(node.name.text)
302
+ staticStringBindings.set(node.name.text, !staticStringBindings.has(node.name.text) || previous === value ? value : undefined)
303
+ }
304
+ if (
305
+ (ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) &&
306
+ ts.isIdentifier(node.name)
307
+ ) localBindings.add(node.name.text)
308
+ if (
309
+ (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
310
+ node.name
311
+ ) localBindings.add(node.name.text)
312
+ ts.forEachChild(node, collectStaticStringBindings)
313
+ }
314
+ collectStaticStringBindings(sourceFile)
315
+ const addImportedBindingReference = (expression: ts.Expression, node: ts.Node): boolean => {
316
+ if (ts.isIdentifier(expression)) {
317
+ const specifier = importedBindings.get(expression.text)
318
+ if (specifier) {
319
+ addReference({ text: specifier } as ts.StringLiteralLike, 'runtime-wiring', node, 'runtime-wiring-static')
320
+ return true
321
+ }
322
+ return false
323
+ }
324
+ if (ts.isPropertyAccessExpression(expression)) return addImportedBindingReference(expression.expression, node)
325
+ if (ts.isCallExpression(expression)) return addImportedBindingReference(expression.expression, node)
326
+ return false
327
+ }
328
+ const isKnownLocal = (expression: ts.Expression): boolean => {
329
+ if (ts.isIdentifier(expression)) return localBindings.has(expression.text) || importedBindings.has(expression.text)
330
+ if (expression.kind === ts.SyntaxKind.ThisKeyword) return true
331
+ if (ts.isPropertyAccessExpression(expression)) return isKnownLocal(expression.expression)
332
+ if (ts.isCallExpression(expression)) return isKnownLocal(expression.expression)
333
+ return ts.isStringLiteralLike(expression)
334
+ }
335
+ const addReference = (specifier: ts.StringLiteralLike, kind: ImportReference['kind'], node: ts.Node, detection?: ImportReference['detection']): void => {
336
+ references.push({ specifier: specifier.text, kind, evidence: nodeEvidence(root, path, sourceFile, node), ...(detection ? { detection } : {}) })
271
337
  }
272
338
 
273
339
  const visit = (node: ts.Node): void => {
274
340
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
275
341
  addReference(node.moduleSpecifier, 'imports', node)
342
+ const clause = node.importClause
343
+ if (clause?.name) importedBindings.set(clause.name.text, node.moduleSpecifier.text)
344
+ if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) importedBindings.set(clause.namedBindings.name.text, node.moduleSpecifier.text)
345
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
346
+ for (const element of clause.namedBindings.elements) importedBindings.set((element.name ?? element.propertyName)?.text ?? '', node.moduleSpecifier.text)
347
+ }
276
348
  } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
277
349
  addReference(node.moduleSpecifier, 're-exports', node)
278
350
  } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) {
279
351
  addReference(node.moduleReference.expression, 'imports', node)
352
+ importedBindings.set(node.name.text, node.moduleReference.expression.text)
280
353
  } else if (ts.isCallExpression(node)) {
281
354
  if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
282
- if (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0])) hasDynamic = true
355
+ const specifier = node.arguments[0] ? resolveStaticString(node.arguments[0]) : undefined
356
+ if (specifier !== undefined) {
357
+ hasLiteralDynamic = true
358
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
359
+ addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node, 'dynamic-literal')
360
+ } else {
361
+ hasDynamic = true
362
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
363
+ }
283
364
  } else if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
284
365
  const argument = node.arguments[0]
285
- if (argument && ts.isStringLiteralLike(argument)) addReference(argument, 'imports', node)
286
- else hasDynamic = true
287
- } else if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') {
366
+ const specifier = argument ? resolveStaticString(argument) : undefined
367
+ if (specifier !== undefined) {
368
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
369
+ addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node)
370
+ } else {
371
+ hasDynamic = true
372
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
373
+ }
374
+ } else if (ts.isPropertyAccessExpression(node.expression) && runtimeWiringMethods.has(node.expression.name.text)) {
288
375
  hasRuntimeWiring = true
376
+ let hasUnresolvedTarget = false
377
+ for (const argument of node.arguments) {
378
+ if (ts.isStringLiteralLike(argument)) continue
379
+ if (addImportedBindingReference(argument, node)) continue
380
+ if (!isKnownLocal(argument)) hasUnresolvedTarget = true
381
+ }
382
+ const receiver = node.expression.expression
383
+ if (hasUnresolvedTarget && !isKnownLocal(receiver)) hasUnresolvedRuntimeWiring = true
289
384
  }
290
385
  }
291
386
  ts.forEachChild(node, visit)
@@ -294,8 +389,11 @@ const moduleReferences = (
294
389
  return {
295
390
  references,
296
391
  exports: exportedNames(sourceFile),
392
+ dynamicEvidence,
297
393
  hasDynamic,
394
+ hasLiteralDynamic,
298
395
  hasRuntimeWiring,
396
+ hasUnresolvedRuntimeWiring,
299
397
  }
300
398
  }
301
399
 
@@ -394,11 +492,11 @@ const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: re
394
492
  sourceRevision: revision.value,
395
493
  sourceRevisionKind: revision.kind,
396
494
  configurationHash: sha256NormalizedV1(config ?? {}),
397
- pipelineVersion: '1.0.0',
398
- analyzerVersions: { repository: '1.0.0', 'js-ts': '1.0.0' },
495
+ pipelineVersion: '1.1.8',
496
+ analyzerVersions: { repository: '1.1.1', 'js-ts': '1.3.4' },
399
497
  entities: [...entities].sort((a, b) => a.id.localeCompare(b.id)),
400
498
  relations: [...relations].sort((a, b) => a.id.localeCompare(b.id)),
401
- coverage: [...coverage],
499
+ coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? ({ repository: '1.1.1', 'js-ts': '1.3.4' }[entry.analyzer] ?? '1.0.0') })),
402
500
  }
403
501
  return DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
404
502
  }
@@ -463,13 +561,13 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
463
561
  const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true, scriptKind(absPath))
464
562
  const exports = exportedNames(sourceFile)
465
563
  modules.set(resolve(absPath), { absPath, path, entityId: id, ...(pkg ? { packageId: pkg.id } : {}) })
466
- 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) } } : {}) })
564
+ 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_MODULE_PATTERN.test(path) } } : {}) })
467
565
  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)] })
468
566
  }
469
567
 
470
568
  for (const absPath of documentPaths) {
471
569
  const path = relativePath(root, absPath)
472
- addEntity({ id: entityId('document', path), kind: 'document', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('documentation', root, absPath, 1)] })
570
+ addEntity({ id: entityId('document', path), kind: 'document', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('documentation', root, absPath, 1)], metadata: { classification: documentClassification(path) } })
473
571
  }
474
572
 
475
573
  const compiler = readCompilerOptions(root)
@@ -478,8 +576,8 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
478
576
  { 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)}.` } : {}) },
479
577
  { 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 } : {}) },
480
578
  { analyzer: 'js-ts', scope: 'static-imports-and-exports', status: compiler.error ? 'partial' : 'complete', ...(compiler.error ? { reason: compiler.error } : {}) },
481
- { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'not-analyzed', reason: 'Dynamic import expressions and non-literal require calls are not resolved.' },
482
- { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-analyzed', reason: 'Reflection, dependency injection and runtime wiring are not inferred.' },
579
+ { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'not-applicable', reason: 'No dynamic loading expression was observed.' },
580
+ { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-applicable', reason: 'No configured runtime-wiring call was observed.' },
483
581
  { analyzer: 'js-ts', scope: 'generated-code', status: 'not-analyzed', reason: 'Generated code is not interpreted as source architecture.' },
484
582
  ]
485
583
 
@@ -492,10 +590,28 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
492
590
  }
493
591
  }
494
592
 
593
+ const dynamicCoverageIndex = coverage.findIndex((entry) => entry.scope === 'dynamic-imports')
594
+ const configuredRuntimeWiringMethods = new Set([
595
+ ...(opts.config?.analysis?.jsTs?.runtimeWiringMethods ?? []),
596
+ ...(opts.config?.analysis?.jsTs?.runtimeWiringAdapters?.flatMap((adapter) => adapter.methods) ?? []),
597
+ ])
598
+ if (!configuredRuntimeWiringMethods.size) for (const method of DEFAULT_RUNTIME_WIRING_METHODS) configuredRuntimeWiringMethods.add(method)
599
+ const includeTestRuntimeWiring = opts.config?.analysis?.jsTs?.includeTestRuntimeWiring ?? false
600
+ let observedLiteralDynamic = false
601
+ let observedUnresolvedDynamic = false
602
+ const observedDynamicEvidence: Evidence[] = []
603
+ let observedRuntimeWiring = false
604
+ let observedUnresolvedRuntimeWiring = false
495
605
  for (const module of modules.values()) {
496
606
  const text = readFileSync(module.absPath, 'utf8')
497
607
  const sourceFile = ts.createSourceFile(module.absPath, text, ts.ScriptTarget.Latest, true, scriptKind(module.absPath))
498
- const references = moduleReferences(root, module.absPath, sourceFile)
608
+ const runtimeWiringMethods = includeTestRuntimeWiring || !TEST_MODULE_PATTERN.test(module.path) ? configuredRuntimeWiringMethods : new Set<string>()
609
+ const references = moduleReferences(root, module.absPath, sourceFile, runtimeWiringMethods)
610
+ observedLiteralDynamic ||= references.hasLiteralDynamic
611
+ observedUnresolvedDynamic ||= references.hasDynamic
612
+ observedDynamicEvidence.push(...references.dynamicEvidence)
613
+ observedRuntimeWiring ||= references.hasRuntimeWiring
614
+ observedUnresolvedRuntimeWiring ||= references.hasUnresolvedRuntimeWiring
499
615
  for (const reference of references.references) {
500
616
  const target = resolveReference(reference, module.absPath, modules, packageResult.packages, compiler.options)
501
617
  if (!target) continue
@@ -503,12 +619,24 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
503
619
  const externalName = target.targetId.replace(/^external:/, '')
504
620
  addEntity({ id: target.targetId, kind: 'external', name: externalName, provenance: 'observed', evidence: [reference.evidence] })
505
621
  }
506
- addRelation({ id: entityId('relation', `${module.entityId}:${reference.kind}:${target.targetId}`), kind: reference.kind, from: module.entityId, to: target.targetId, provenance: 'observed', evidence: [reference.evidence] })
622
+ 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 } } : {}) })
507
623
  }
508
- 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)] })
509
- 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)] })
624
+ if (references.hasLiteralDynamic || references.hasDynamic) coverage.push({ analyzer: 'js-ts', scope: `dynamic-imports:${module.path}`, status: references.hasDynamic ? 'not-analyzed' : 'complete', reason: references.hasDynamic ? 'A non-literal dynamic import was found; the target is unresolved.' : 'Literal dynamic imports were resolved.', evidence: [...references.dynamicEvidence.slice(0, 32)] })
625
+ if (references.hasUnresolvedRuntimeWiring) coverage.push({ analyzer: 'js-ts', scope: `runtime-wiring:${module.path}`, status: 'not-analyzed', reason: 'A runtime registration/wiring call was found without a statically imported target.', evidence: [lineEvidence('code', root, module.absPath)] })
510
626
  }
511
627
 
628
+ if (dynamicCoverageIndex >= 0) coverage[dynamicCoverageIndex] = observedUnresolvedDynamic
629
+ ? { 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)] }
630
+ : observedLiteralDynamic
631
+ ? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'complete', reason: 'All observed dynamic imports used literal targets and were resolved.', evidence: [...observedDynamicEvidence.slice(0, 32)] }
632
+ : { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'not-applicable', reason: 'No dynamic loading expression was observed.' }
633
+ const runtimeCoverageIndex = coverage.findIndex((entry) => entry.scope === 'runtime-wiring')
634
+ if (runtimeCoverageIndex >= 0) coverage[runtimeCoverageIndex] = observedUnresolvedRuntimeWiring
635
+ ? { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'partial', reason: 'Some configured runtime-wiring calls remain unresolved after static binding analysis.' }
636
+ : observedRuntimeWiring
637
+ ? { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'complete', reason: 'All observed configured runtime-wiring calls resolved to static bindings.' }
638
+ : { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-applicable', reason: 'No configured runtime-wiring call was observed.' }
639
+
512
640
  return artifact(root, opts.config, allFiles, [...entities.values()], [...relations.values()], coverage)
513
641
  }
514
642