@agentskit/doc-bridge 1.4.3 → 1.5.1

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,227 @@
1
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
2
+ import {
3
+ ReconciliationReportV1Schema,
4
+ type DiscoverySnapshotV1,
5
+ type Evidence,
6
+ type KnowledgeEntity,
7
+ type KnowledgeRelation,
8
+ type ReconciliationReportV1,
9
+ } from '../schemas/knowledge.js'
10
+
11
+ type EntityResolver = (reference: string) => string
12
+
13
+ const ignoredDocumentationRelations = new Set(['covers'])
14
+
15
+ const metadataDetection = (relation: KnowledgeRelation): string | undefined => {
16
+ const detection = relation.metadata?.detection
17
+ return typeof detection === 'string' ? detection : undefined
18
+ }
19
+
20
+ const relationDetection = (relation: KnowledgeRelation): string =>
21
+ relation.discriminator ?? metadataDetection(relation) ?? 'static'
22
+
23
+ const entityResolver = (snapshots: readonly DiscoverySnapshotV1[]): EntityResolver => {
24
+ const references = new Map<string, string>()
25
+ const entities = snapshots.flatMap((snapshot) => snapshot.entities).sort((a, b) => a.id.localeCompare(b.id))
26
+ for (const entity of entities) {
27
+ references.set(entity.id, entity.id)
28
+ for (const alias of entity.aliases ?? []) {
29
+ if (!references.has(alias)) references.set(alias, entity.id)
30
+ }
31
+ }
32
+ return (reference) => references.get(reference) ?? reference
33
+ }
34
+
35
+ const relationBase = (relation: KnowledgeRelation, resolveEntity: EntityResolver): string =>
36
+ `${resolveEntity(relation.from)}\u0000${resolveEntity(relation.to)}\u0000${relation.kind}`
37
+
38
+ const evidenceKey = (item: Evidence): string =>
39
+ `${item.source}:${item.path}:${item.lineStart ?? ''}:${item.lineEnd ?? ''}:${item.context ?? ''}`
40
+
41
+ const mergeEvidence = (...relations: readonly KnowledgeRelation[]): Evidence[] => {
42
+ const merged = new Map<string, Evidence>()
43
+ for (const relation of relations) {
44
+ for (const item of relation.evidence) merged.set(evidenceKey(item), item)
45
+ }
46
+ return [...merged.values()].sort((a, b) => evidenceKey(a).localeCompare(evidenceKey(b)))
47
+ }
48
+
49
+ const diagnosticId = (code: string, value: unknown): string =>
50
+ `reconciliation:${code}:${sha256NormalizedV1(value).slice(0, 32)}`
51
+
52
+ const coverageAvailable = (snapshot: DiscoverySnapshotV1, relation: KnowledgeRelation): boolean => {
53
+ const status = snapshot.coverage.find((entry) =>
54
+ entry.scope === 'static-imports-and-exports' && (relation.kind === 'imports' || relation.kind === 're-exports'),
55
+ )?.status
56
+ return status === undefined || status === 'complete'
57
+ }
58
+
59
+ const entityById = (snapshots: readonly DiscoverySnapshotV1[]): ReadonlyMap<string, KnowledgeEntity> => {
60
+ const entities = new Map<string, KnowledgeEntity>()
61
+ for (const snapshot of snapshots) {
62
+ for (const entity of snapshot.entities) if (!entities.has(entity.id)) entities.set(entity.id, entity)
63
+ }
64
+ return entities
65
+ }
66
+
67
+ const isUnresolved = (id: string, entities: ReadonlyMap<string, KnowledgeEntity>): boolean =>
68
+ id.startsWith('unresolved:') || entities.get(id)?.kind === 'unresolved-reference'
69
+
70
+ const relationMatches = (observed: KnowledgeRelation, declared: KnowledgeRelation, resolveEntity: EntityResolver): boolean => {
71
+ if (relationBase(observed, resolveEntity) !== relationBase(declared, resolveEntity)) return false
72
+ return relationDetection(declared) === relationDetection(observed)
73
+ }
74
+
75
+ const reportDiagnostic = (
76
+ code: string,
77
+ status: ReconciliationReportV1['diagnostics'][number]['status'],
78
+ severity: ReconciliationReportV1['diagnostics'][number]['severity'],
79
+ message: string,
80
+ evidence: readonly Evidence[],
81
+ value: unknown,
82
+ entityIds?: readonly string[],
83
+ relationIds?: readonly string[],
84
+ remediation?: string,
85
+ ): ReconciliationReportV1['diagnostics'][number] => ({
86
+ id: diagnosticId(code, value),
87
+ code,
88
+ status,
89
+ severity,
90
+ message,
91
+ evidence: [...evidence],
92
+ ...(entityIds?.length ? { entityIds: [...entityIds] } : {}),
93
+ ...(relationIds?.length ? { relationIds: [...relationIds] } : {}),
94
+ ...(remediation ? { remediation } : {}),
95
+ })
96
+
97
+ export const reconcileKnowledge = (
98
+ observed: DiscoverySnapshotV1,
99
+ declared: DiscoverySnapshotV1,
100
+ ): ReconciliationReportV1 => {
101
+ const resolveEntity = entityResolver([observed, declared])
102
+ const entities = entityById([observed, declared])
103
+ const observedRelations = observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind))
104
+ const declaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind))
105
+ const diagnostics: ReconciliationReportV1['diagnostics'][number][] = []
106
+
107
+ for (const entity of declared.entities.filter((item) => isUnresolved(item.id, entities)).sort((a, b) => a.id.localeCompare(b.id))) {
108
+ diagnostics.push(reportDiagnostic(
109
+ 'UNRESOLVED_ENTITY_REFERENCE',
110
+ 'unresolved',
111
+ 'error',
112
+ `Declared reference could not be resolved: ${entity.name}.`,
113
+ entity.evidence,
114
+ entity.id,
115
+ [entity.id],
116
+ undefined,
117
+ 'Resolve the reference to an observed entity ID or configured alias.',
118
+ ))
119
+ }
120
+
121
+ const declaredByBase = new Map<string, KnowledgeRelation[]>()
122
+ for (const relation of declaredRelations) {
123
+ const base = relationBase(relation, resolveEntity)
124
+ const group = declaredByBase.get(base) ?? []
125
+ group.push(relation)
126
+ declaredByBase.set(base, group)
127
+ }
128
+
129
+ for (const group of declaredByBase.values()) {
130
+ const detections = new Set(group.map(relationDetection))
131
+ if (detections.size < 2) continue
132
+ diagnostics.push(reportDiagnostic(
133
+ 'CONFLICTING_DECLARATIONS',
134
+ 'conflict',
135
+ 'error',
136
+ 'Declarations for the same semantic relation disagree on detection.',
137
+ mergeEvidence(...group),
138
+ [relationBase(group[0] as KnowledgeRelation, resolveEntity), [...detections].sort()],
139
+ undefined,
140
+ group.map((relation) => relation.id),
141
+ 'Keep one detection value for this relation or document the intended distinction with a different relation kind.',
142
+ ))
143
+ }
144
+
145
+ for (const relation of observedRelations) {
146
+ const candidates = declaredByBase.get(relationBase(relation, resolveEntity)) ?? []
147
+ const match = candidates.find((candidate) => relationMatches(relation, candidate, resolveEntity))
148
+ if (match) {
149
+ diagnostics.push(reportDiagnostic(
150
+ 'RELATION_CONFIRMED',
151
+ 'confirmed',
152
+ 'info',
153
+ 'Observed relation is covered by a matching declaration.',
154
+ mergeEvidence(relation, match),
155
+ relation.id,
156
+ undefined,
157
+ [relation.id, match.id],
158
+ ))
159
+ } else if (coverageAvailable(observed, relation)) {
160
+ diagnostics.push(reportDiagnostic(
161
+ 'RELATION_UNDOCUMENTED',
162
+ 'undocumented',
163
+ 'warn',
164
+ 'Observed relation has no matching documentation declaration.',
165
+ relation.evidence,
166
+ relation.id,
167
+ undefined,
168
+ [relation.id],
169
+ 'Add a matching relation declaration or configure this relation kind as intentionally undocumented.',
170
+ ))
171
+ }
172
+ }
173
+
174
+ for (const relation of declaredRelations) {
175
+ const candidates = observedRelations.filter((candidate) => relationBase(candidate, resolveEntity) === relationBase(relation, resolveEntity))
176
+ const detection = relationDetection(relation)
177
+ if (candidates.some((candidate) => relationMatches(candidate, relation, resolveEntity))) continue
178
+ if (isUnresolved(resolveEntity(relation.from), entities) || isUnresolved(resolveEntity(relation.to), entities)) continue
179
+ if (detection === 'dynamic' || detection === 'external') {
180
+ diagnostics.push(reportDiagnostic(
181
+ 'RELATION_NOT_ANALYZED',
182
+ 'not-analyzed',
183
+ 'info',
184
+ `Declared ${detection} relation cannot be verified by the current static analyzer.`,
185
+ relation.evidence,
186
+ relation.id,
187
+ undefined,
188
+ [relation.id],
189
+ 'Enable a compatible analyzer or provide explicit observed evidence before treating this relation as confirmed.',
190
+ ))
191
+ } else if (coverageAvailable(observed, relation)) {
192
+ diagnostics.push(reportDiagnostic(
193
+ 'DECLARED_RELATION_STALE',
194
+ 'stale-or-unverified',
195
+ 'warn',
196
+ candidates.length ? 'Declared relation has incompatible observed evidence.' : 'Declared static relation was not observed.',
197
+ candidates.length ? mergeEvidence(relation, ...candidates) : relation.evidence,
198
+ relation.id,
199
+ undefined,
200
+ [relation.id, ...candidates.map((candidate) => candidate.id)],
201
+ 'Update the declaration or the implementation so both graphs describe the same relation.',
202
+ ))
203
+ }
204
+ }
205
+
206
+ const sortedDiagnostics = [...diagnostics].sort((a, b) => a.id.localeCompare(b.id))
207
+ const base = {
208
+ type: 'reconciliation-report' as const,
209
+ schemaVersion: 1 as const,
210
+ contentHash: '0'.repeat(64),
211
+ contentHashAlgo: observed.contentHashAlgo,
212
+ project: observed.project,
213
+ sourceRevision: observed.sourceRevision,
214
+ sourceRevisionKind: observed.sourceRevisionKind,
215
+ configurationHash: observed.configurationHash,
216
+ pipelineVersion: observed.pipelineVersion,
217
+ analyzerVersions: observed.analyzerVersions,
218
+ snapshotHash: observed.contentHash,
219
+ diagnostics: sortedDiagnostics,
220
+ summary: {
221
+ entityCount: observed.entities.length,
222
+ relationCount: observedRelations.length,
223
+ diagnosticCount: sortedDiagnostics.length,
224
+ },
225
+ }
226
+ return ReconciliationReportV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
227
+ }
@@ -0,0 +1,74 @@
1
+ import { DiscoverySnapshotV1Schema, ReconciliationReportV1Schema, type DiscoverySnapshotV1, type ReconciliationReportV1 } from '../schemas/knowledge.js'
2
+ import { redactSecrets } from '../safety/repository.js'
3
+
4
+ export type OfflineReportInput = {
5
+ readonly snapshot: DiscoverySnapshotV1
6
+ readonly report: ReconciliationReportV1
7
+ }
8
+
9
+ export type OfflineReportOptions = {
10
+ readonly includeSnippets?: boolean
11
+ }
12
+
13
+ const escapeHtml = (value: unknown): string => String(value)
14
+ .replaceAll('&', '&amp;')
15
+ .replaceAll('<', '&lt;')
16
+ .replaceAll('>', '&gt;')
17
+ .replaceAll('"', '&quot;')
18
+ .replaceAll("'", '&#39;')
19
+
20
+ const anchor = (prefix: string, value: string): string => `${prefix}-${value.replace(/[^A-Za-z0-9_-]+/g, '-')}`
21
+
22
+ const evidenceText = (evidence: ReconciliationReportV1['diagnostics'][number]['evidence'][number], includeSnippets: boolean): string => {
23
+ const location = `${evidence.path}${evidence.lineStart ? `:${evidence.lineStart}${evidence.lineEnd && evidence.lineEnd !== evidence.lineStart ? `-${evidence.lineEnd}` : ''}` : ''}`
24
+ return `${location}${includeSnippets && evidence.context ? ` — ${redactSecrets(evidence.context)}` : ''}`
25
+ }
26
+
27
+ const errorPage = (message: string): string => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Doc Bridge report error</title><style>body{font:16px system-ui;margin:3rem;color:#311}main{max-width:60rem;margin:auto;border:1px solid #d99;padding:2rem;border-radius:8px;background:#fff8f8}code{white-space:pre-wrap}</style></head><body><main><h1>Doc Bridge report unavailable</h1><p>The saved snapshot/report could not be rendered.</p><code>${escapeHtml(message)}</code><p>Run <code>ak-docs check</code> to regenerate valid artifacts.</p></main></body></html>`
28
+
29
+ const embeddedJson = (value: unknown): string => JSON.stringify(value).replaceAll('<', '\\u003c')
30
+
31
+ const controls = (statusOptions: string[]): string => `<section id="controls"><label>Search <input id="search" type="search" placeholder="entity, relation, diagnostic"></label><label>Severity <select id="severity"><option value="">Any</option><option>error</option><option>warn</option><option>info</option></select></label><label>Provenance <select id="provenance"><option value="">Any</option><option>observed</option><option>declared</option><option>proposed</option></select></label><label>Status <select id="status"><option value="">Any</option>${statusOptions.map((value) => `<option>${value}</option>`).join('')}</select></label><label>Analyzer <input id="analyzer" type="search" placeholder="js-ts, report"></label><label>Entity <input id="entity" type="search" placeholder="entity id"></label><label>Relation <input id="relation" type="search" placeholder="relation id"></label><button id="reset">Reset filters</button></section>`
32
+
33
+ const renderCompact = (snapshot: DiscoverySnapshotV1, report: ReconciliationReportV1, entityAnchors: ReadonlyMap<string, string>, includeSnippets: boolean): string => {
34
+ const entities = snapshot.entities.map((entity) => ({ id: entity.id, name: entity.name, kind: entity.kind, anchor: entityAnchors.get(entity.id) ?? anchor('entity', entity.id) }))
35
+ const entityIndexes = new Map(entities.map((entity, index) => [entity.id, index]))
36
+ const relations = snapshot.relations.map((relation) => ({ id: relation.id, from: entityIndexes.get(relation.from), to: entityIndexes.get(relation.to), kind: relation.kind, provenance: relation.provenance }))
37
+ const diagnostics = report.diagnostics.map((diagnostic) => ({
38
+ ...diagnostic,
39
+ evidence: diagnostic.evidence.map(({ context, ...evidence }) => includeSnippets && context ? { ...evidence, context: redactSecrets(context) } : evidence),
40
+ }))
41
+ const data = embeddedJson({ entities, relations, diagnostics, coverage: snapshot.coverage })
42
+ const script = `const data=${data};const esc=(v)=>String(v).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;');const entityById=new Map(data.entities.map((e)=>[e.id,e]));const entityLink=(id)=>{const e=entityById.get(id);return '<a href="#'+esc(e?.anchor??'entity-'+id)+'">'+esc(e?.name??id)+'</a>'};const evidenceText=(e)=>esc(e.path+(e.lineStart?':'+e.lineStart+(e.lineEnd&&e.lineEnd!==e.lineStart?'-'+e.lineEnd:''):'')+(e.context?' — '+e.context:''));const render=()=>{document.querySelector('#map').innerHTML=data.entities.map((e)=>'<div class="node" id="'+esc(e.anchor)+'" data-node="'+esc(e.anchor)+'" data-search="'+esc([e.id,e.name,e.kind].join(' '))+'"><a href="#'+esc(e.anchor)+'">'+esc(e.name)+'</a><br><span class="muted">'+esc(e.kind)+'</span></div>').join('');document.querySelector('#relations').innerHTML=data.relations.map((r)=>{const from=data.entities[r.from],to=data.entities[r.to];return '<tr id="relation-'+esc(r.id)+'" data-relation="'+esc(r.id)+'" data-from="'+esc(from?.anchor??'')+'" data-to="'+esc(to?.anchor??'')+'" data-kind="'+esc(r.kind)+'" data-provenance="'+esc(r.provenance)+'" data-analyzer="snapshot" data-search="'+esc([r.id,from?.id,to?.id,r.kind,r.provenance].join(' '))+'"><td>'+esc(r.kind)+'</td><td>'+entityLink(from?.id??'')+'</td><td>'+entityLink(to?.id??'')+'</td><td>'+esc(r.provenance)+'</td></tr>'}).join('');document.querySelector('#diagnostics').innerHTML=data.diagnostics.length?data.diagnostics.map((d)=>'<article id="diagnostic-'+esc(d.id)+'" class="diagnostic" data-status="'+esc(d.status)+'" data-severity="'+esc(d.severity)+'" data-analyzer="report" data-entity="'+esc((d.entityIds??[]).join(' '))+'" data-relation="'+esc((d.relationIds??[]).join(' '))+'" data-search="'+esc([d.id,d.code,d.message,...(d.entityIds??[]),...(d.relationIds??[])].join(' '))+'"><h3><a href="#diagnostic-'+esc(d.id)+'">'+esc(d.code)+'</a> <span class="badge '+esc(d.severity)+'">'+esc(d.severity)+'</span></h3><p>'+esc(d.message)+'</p><p>Status: <b>'+esc(d.status)+'</b></p><ul>'+d.evidence.map((e)=>'<li>'+evidenceText(e)+'</li>').join('')+'</ul>'+(d.entityIds?.length?'<p>Entities: '+d.entityIds.map(entityLink).join(', ')+'</p>':'')+'</article>').join(''):'<p class="muted">No diagnostics.</p>';document.querySelector('#coverage').innerHTML=data.coverage.length?data.coverage.map((c)=>'<li data-status="'+esc(c.status)+'" data-analyzer="'+esc(c.analyzer)+'" data-search="'+esc([c.analyzer,c.scope,c.status,c.reason??''].join(' '))+'"><b>'+esc(c.analyzer)+'</b> / '+esc(c.scope)+': '+esc(c.status)+(c.reason?' — '+esc(c.reason):'')+'</li>').join(''):'<li class="muted">No coverage metadata.</li>';document.querySelectorAll('[data-node]').forEach((node)=>node.onclick=()=>{const id=node.dataset.node;document.querySelectorAll('[data-from],[data-to]').forEach((edge)=>edge.classList.toggle('hidden',edge.dataset.from!==id&&edge.dataset.to!==id))});};const apply=()=>{const q=document.querySelector('#search').value.toLowerCase(),severity=document.querySelector('#severity').value,provenance=document.querySelector('#provenance').value,status=document.querySelector('#status').value,analyzer=document.querySelector('#analyzer').value.toLowerCase(),entity=document.querySelector('#entity').value.toLowerCase(),relation=document.querySelector('#relation').value.toLowerCase();document.querySelectorAll('[data-search]').forEach((el)=>el.classList.toggle('hidden',Boolean(q&&!el.dataset.search.toLowerCase().includes(q)||severity&&el.dataset.severity!==severity||provenance&&el.dataset.provenance!==provenance||status&&el.dataset.status!==status||analyzer&&!el.dataset.analyzer?.toLowerCase().includes(analyzer)||entity&&!el.dataset.entity?.toLowerCase().includes(entity)||relation&&!el.dataset.relation?.toLowerCase().includes(relation))));};render();for(const id of ['search','analyzer','entity','relation'])document.querySelector('#'+id).oninput=apply;for(const id of ['severity','provenance','status'])document.querySelector('#'+id).onchange=apply;document.querySelector('#reset').onclick=()=>{for(const id of ['search','analyzer','entity','relation'])document.querySelector('#'+id).value='';for(const id of ['severity','provenance','status'])document.querySelector('#'+id).value='';apply()};`
43
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Doc Bridge — ${escapeHtml(snapshot.project.name)}</title><style>body{font:14px system-ui;margin:0;color:#18202a;background:#f5f7fa}header,main{max-width:1200px;margin:auto;padding:1.25rem}header{background:#18202a;color:white;max-width:none;padding-left:calc((100% - 1200px)/2);padding-right:calc((100% - 1200px)/2)}main{background:white}section{margin:1.5rem 0;border-top:1px solid #d7dde5;padding-top:1rem}table{border-collapse:collapse;width:100%;margin-top:.75rem}td,th{border-bottom:1px solid #e5e9ef;text-align:left;padding:.45rem;vertical-align:top}input,select,button{padding:.45rem;margin:.15rem;border:1px solid #b9c3d0;border-radius:4px;background:white}.badge{border-radius:1rem;padding:.15rem .5rem;background:#dce4ee}.error{background:#ffd9d9}.warn{background:#fff0c2}.info{background:#dcecff}.diagnostic{border:1px solid #d7dde5;border-left:4px solid #9aa7b5;padding:.75rem;margin:.75rem 0}.diagnostic:target,tr:target{background:#fff8cf}.muted{color:#5d6a78}a{color:#0b5cad}#map{display:flex;gap:1rem;flex-wrap:wrap}.node{border:1px solid #9aa7b5;padding:.5rem;border-radius:4px}.hidden{display:none}</style></head><body><header><h1>Doc Bridge: ${escapeHtml(snapshot.project.name)}</h1><p>Offline architecture and documentation reconciliation report</p><p class="muted">Snapshot ${escapeHtml(snapshot.contentHash)} · Report ${escapeHtml(report.contentHash)} · Revision ${escapeHtml(snapshot.sourceRevision)}</p></header><main>${controls(['confirmed', 'undocumented', 'stale-or-unverified', 'conflict', 'unresolved', 'not-analyzed'])}<section id="architecture"><h2>Architecture map</h2><p>Large snapshots are rendered from compact canonical data after load to keep the offline report bounded.</p><div id="map"></div><h3>Relations</h3><table><thead><tr><th>Kind</th><th>From</th><th>To</th><th>Provenance</th></tr></thead><tbody id="relations"></tbody></table></section><section id="diagnostic-lens"><h2>Diagnostic lens</h2><p>Findings: ${report.diagnostics.length}</p><div id="diagnostics"></div></section><section id="coverage"><h2>Coverage and unsupported areas</h2><ul></ul></section><section id="metadata"><h2>Run metadata</h2><dl><dt>Source revision</dt><dd>${escapeHtml(snapshot.sourceRevision)} (${escapeHtml(snapshot.sourceRevisionKind)})</dd><dt>Configuration hash</dt><dd>${escapeHtml(snapshot.configurationHash)}</dd><dt>Pipeline</dt><dd>${escapeHtml(snapshot.pipelineVersion)}</dd></dl></section></main><script>${script}</script></body></html>`
44
+ }
45
+
46
+ const render = (input: OfflineReportInput, options: OfflineReportOptions): string => {
47
+ const { snapshot, report } = input
48
+ const includeSnippets = options.includeSnippets === true
49
+ const entityAnchors = new Map(snapshot.entities.map((entity, index) => [entity.id, entity.id.length > 64 ? `entity-n${index}` : anchor('entity', entity.id)]))
50
+ if (snapshot.entities.length > 500 || report.diagnostics.length > 1_000) return renderCompact(snapshot, report, entityAnchors, includeSnippets)
51
+ const entityNames = new Map(snapshot.entities.map((entity) => [entity.id, entity.name]))
52
+ const entityAnchor = (id: string): string => entityAnchors.get(id) ?? anchor('entity', id)
53
+ const entityLabel = (id: string): string => entityNames.get(id) ?? id
54
+ const entityLink = (id: string): string => `<a href="#${entityAnchor(id)}">${escapeHtml(entityLabel(id))}</a>`
55
+ const relations = snapshot.relations.map((relation) => `<tr id="${anchor('relation', relation.id)}" data-relation="${escapeHtml(relation.id)}" data-from="${entityAnchor(relation.from)}" data-to="${entityAnchor(relation.to)}" data-kind="${escapeHtml(relation.kind)}" data-provenance="${escapeHtml(relation.provenance)}" data-analyzer="snapshot" data-search="${escapeHtml(`${relation.id} ${relation.from} ${relation.to} ${relation.kind} ${relation.provenance}`)}"><td>${escapeHtml(relation.kind)}</td><td>${entityLink(relation.from)}</td><td>${entityLink(relation.to)}</td><td>${escapeHtml(relation.provenance)}</td></tr>`).join('')
56
+ const diagnostics = report.diagnostics.map((diagnostic) => `<article id="${anchor('diagnostic', diagnostic.id)}" class="diagnostic" data-status="${escapeHtml(diagnostic.status)}" data-severity="${escapeHtml(diagnostic.severity)}" data-analyzer="report" data-entity="${escapeHtml(diagnostic.entityIds?.join(' ') ?? '')}" data-relation="${escapeHtml(diagnostic.relationIds?.join(' ') ?? '')}" data-search="${escapeHtml(`${diagnostic.id} ${diagnostic.code} ${diagnostic.message} ${diagnostic.entityIds?.join(' ') ?? ''} ${diagnostic.relationIds?.join(' ') ?? ''}`)}"><h3><a href="#${anchor('diagnostic', diagnostic.id)}">${escapeHtml(diagnostic.code)}</a> <span class="badge ${escapeHtml(diagnostic.severity)}">${escapeHtml(diagnostic.severity)}</span></h3><p>${escapeHtml(diagnostic.message)}</p><p>Status: <b>${escapeHtml(diagnostic.status)}</b></p><ul>${diagnostic.evidence.map((item) => `<li>${escapeHtml(evidenceText(item, includeSnippets))}</li>`).join('')}</ul>${diagnostic.entityIds?.length ? `<p>Entities: ${diagnostic.entityIds.map((id) => entityLink(id)).join(', ')}</p>` : ''}</article>`).join('')
57
+ const coverage = snapshot.coverage.map((entry) => `<li data-status="${escapeHtml(entry.status)}" data-analyzer="${escapeHtml(entry.analyzer)}" data-search="${escapeHtml(`${entry.analyzer} ${entry.scope} ${entry.status} ${entry.reason ?? ''}`)}"><b>${escapeHtml(entry.analyzer)}</b> / ${escapeHtml(entry.scope)}: ${escapeHtml(entry.status)}${entry.reason ? ` — ${escapeHtml(entry.reason)}` : ''}</li>`).join('')
58
+ const nodes = snapshot.entities.map((entity) => `<div class="node" id="${entityAnchor(entity.id)}" data-node="${entityAnchor(entity.id)}"><a href="#${entityAnchor(entity.id)}">${escapeHtml(entity.name)}</a><br><span class="muted">${escapeHtml(entity.kind)}</span></div>`).join('')
59
+ const script = `const q=document.querySelector('#search'),severity=document.querySelector('#severity'),provenance=document.querySelector('#provenance'),status=document.querySelector('#status'),analyzer=document.querySelector('#analyzer'),entity=document.querySelector('#entity'),relation=document.querySelector('#relation');function apply(){const term=q.value.toLowerCase(),entityTerm=entity.value.toLowerCase(),relationTerm=relation.value.toLowerCase();document.querySelectorAll('[data-search]').forEach((el)=>{const match=(!term||el.dataset.search.toLowerCase().includes(term))&&(!severity.value||el.dataset.severity===severity.value)&&(!provenance.value||el.dataset.provenance===provenance.value)&&(!status.value||el.dataset.status===status.value)&&(!analyzer.value||el.dataset.analyzer?.toLowerCase().includes(analyzer.value.toLowerCase()))&&(!entityTerm||el.dataset.entity?.toLowerCase().includes(entityTerm))&&(!relationTerm||el.dataset.relation?.toLowerCase().includes(relationTerm));el.classList.toggle('hidden',!match)});}q.oninput=apply;severity.onchange=apply;provenance.onchange=apply;status.onchange=apply;analyzer.oninput=apply;entity.oninput=apply;relation.oninput=apply;document.querySelector('#reset').onclick=()=>{q.value='';severity.value='';provenance.value='';status.value='';analyzer.value='';entity.value='';relation.value='';apply()};document.querySelectorAll('[data-node]').forEach((node)=>node.onclick=()=>{const id=node.dataset.node;document.querySelectorAll('[data-from],[data-to]').forEach((edge)=>edge.classList.toggle('hidden',edge.dataset.from!==id&&edge.dataset.to!==id));});`
60
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Doc Bridge — ${escapeHtml(snapshot.project.name)}</title><style>body{font:14px system-ui;margin:0;color:#18202a;background:#f5f7fa}header,main{max-width:1200px;margin:auto;padding:1.25rem}header{background:#18202a;color:white;max-width:none;padding-left:calc((100% - 1200px)/2);padding-right:calc((100% - 1200px)/2)}main{background:white}section{margin:1.5rem 0;border-top:1px solid #d7dde5;padding-top:1rem}table{border-collapse:collapse;width:100%;margin-top:.75rem}td,th{border-bottom:1px solid #e5e9ef;text-align:left;padding:.45rem;vertical-align:top}input,select,button{padding:.45rem;margin:.15rem;border:1px solid #b9c3d0;border-radius:4px;background:white}.badge{border-radius:1rem;padding:.15rem .5rem;background:#dce4ee}.error{background:#ffd9d9}.warn{background:#fff0c2}.info{background:#dcecff}.diagnostic{border:1px solid #d7dde5;border-left:4px solid #9aa7b5;padding:.75rem;margin:.75rem 0}.diagnostic:target,tr:target{background:#fff8cf}.muted{color:#5d6a78}a{color:#0b5cad}#map{display:flex;gap:1rem;flex-wrap:wrap}.node{border:1px solid #9aa7b5;padding:.5rem;border-radius:4px}.hidden{display:none}</style></head><body><header><h1>Doc Bridge: ${escapeHtml(snapshot.project.name)}</h1><p>Offline architecture and documentation reconciliation report</p><p class="muted">Snapshot ${escapeHtml(snapshot.contentHash)} · Report ${escapeHtml(report.contentHash)} · Revision ${escapeHtml(snapshot.sourceRevision)}</p></header><main><section id="controls"><label>Search <input id="search" type="search" placeholder="entity, relation, diagnostic"></label><label>Severity <select id="severity"><option value="">Any</option><option>error</option><option>warn</option><option>info</option></select></label><label>Provenance <select id="provenance"><option value="">Any</option><option>observed</option><option>declared</option><option>proposed</option></select></label><label>Status <select id="status"><option value="">Any</option>${['confirmed', 'undocumented', 'stale-or-unverified', 'conflict', 'unresolved', 'not-analyzed'].map((value) => `<option>${value}</option>`).join('')}</select></label><label>Analyzer <input id="analyzer" type="search" placeholder="js-ts, report"></label><label>Entity <input id="entity" type="search" placeholder="entity id"></label><label>Relation <input id="relation" type="search" placeholder="relation id"></label><button id="reset">Reset filters</button></section><section id="architecture"><h2>Architecture map</h2><p>Observed and declared relations are rendered from the canonical snapshot; browser code does not infer edges.</p><div id="map">${nodes}</div><h3>Relations</h3><table><thead><tr><th>Kind</th><th>From</th><th>To</th><th>Provenance</th></tr></thead><tbody id="relations">${relations}</tbody></table></section><section id="diagnostic-lens"><h2>Diagnostic lens</h2><p>Findings: ${report.diagnostics.length}</p><div id="diagnostics">${diagnostics || '<p class="muted">No diagnostics.</p>'}</div></section><section id="coverage"><h2>Coverage and unsupported areas</h2><ul>${coverage || '<li>No coverage metadata.</li>'}</ul></section><section id="metadata"><h2>Run metadata</h2><dl><dt>Source revision</dt><dd>${escapeHtml(snapshot.sourceRevision)} (${escapeHtml(snapshot.sourceRevisionKind)})</dd><dt>Configuration hash</dt><dd>${escapeHtml(snapshot.configurationHash)}</dd><dt>Pipeline</dt><dd>${escapeHtml(snapshot.pipelineVersion)}</dd></dl></section></main><script>${script}</script></body></html>`
61
+ }
62
+
63
+ export const renderOfflineReport = (input: unknown, options: OfflineReportOptions = {}): string => {
64
+ try {
65
+ if (!input || typeof input !== 'object') throw new Error('Input must contain snapshot and report artifacts.')
66
+ const value = input as Partial<OfflineReportInput>
67
+ const snapshot = DiscoverySnapshotV1Schema.parse(value.snapshot)
68
+ const report = ReconciliationReportV1Schema.parse(value.report)
69
+ if (report.snapshotHash !== snapshot.contentHash) throw new Error('Report snapshotHash does not match snapshot contentHash.')
70
+ return render({ snapshot, report }, options)
71
+ } catch (error) {
72
+ return errorPage(error instanceof Error ? error.message : String(error))
73
+ }
74
+ }
@@ -0,0 +1,180 @@
1
+ import { minimatch } from 'minimatch'
2
+
3
+ import {
4
+ RuleIdSchema,
5
+ RuleSeveritySchema,
6
+ RulesConfigSchema,
7
+ type RuleId,
8
+ type RuleSeverity,
9
+ type RulesConfig,
10
+ } from '../config/schema.js'
11
+ import type { ReconciliationReportV1 } from '../schemas/knowledge.js'
12
+
13
+ export type RuleMode = 'default' | 'recommended' | 'strict'
14
+
15
+ export type RuleEngineOptions = {
16
+ readonly config?: RulesConfig
17
+ readonly preset?: RuleMode
18
+ readonly severity?: Partial<Record<RuleId, RuleSeverity>>
19
+ readonly ignore?: readonly RuleId[]
20
+ readonly criticalEntities?: readonly string[]
21
+ readonly criticalPaths?: readonly string[]
22
+ readonly warningThresholds?: Partial<Record<RuleId, number>>
23
+ }
24
+
25
+ export type RuleFinding = {
26
+ readonly id: string
27
+ readonly ruleId: RuleId
28
+ readonly code: string
29
+ readonly status: ReconciliationReportV1['diagnostics'][number]['status']
30
+ readonly severity: RuleSeverity
31
+ readonly message: string
32
+ readonly evidence: ReconciliationReportV1['diagnostics'][number]['evidence']
33
+ readonly entityIds?: readonly string[]
34
+ readonly relationIds?: readonly string[]
35
+ readonly remediation?: string
36
+ readonly sourceDiagnosticCode?: string
37
+ }
38
+
39
+ export type RuleEvaluationResult = {
40
+ readonly mode: RuleMode
41
+ readonly findings: readonly RuleFinding[]
42
+ readonly exitCode: 0 | 1
43
+ }
44
+
45
+ const diagnosticRules: Readonly<Record<string, RuleId>> = {
46
+ DOCUMENTATION_QUALITY: 'documentation-quality',
47
+ RELATION_UNDOCUMENTED: 'graph-undocumented-relation',
48
+ DECLARED_RELATION_STALE: 'declared-unobserved-relation',
49
+ UNRESOLVED_ENTITY_REFERENCE: 'unresolved-reference',
50
+ CONFLICTING_DECLARATIONS: 'conflicting-declaration',
51
+ RELATION_NOT_ANALYZED: 'not-analyzed-coverage',
52
+ STALE_DOCUMENTATION: 'stale-documentation',
53
+ FRESHNESS_FAILURE: 'freshness',
54
+ OWNERSHIP_GAP: 'ownership',
55
+ CENTRALITY_RISK: 'centrality-risk',
56
+ CRITICAL_PATH_RISK: 'critical-path-risk',
57
+ }
58
+
59
+ const defaultSeverity = (mode: RuleMode, ruleId: RuleId): RuleSeverity => {
60
+ if (mode === 'default') return 'info'
61
+ if (mode === 'strict') return ruleId === 'not-analyzed-coverage' ? 'warn' : 'error'
62
+ return ruleId === 'not-analyzed-coverage' ? 'info' : 'warn'
63
+ }
64
+
65
+ const resolvedOptions = (options: RuleEngineOptions): {
66
+ readonly mode: RuleMode
67
+ readonly severity: Partial<Record<RuleId, RuleSeverity>>
68
+ readonly ignore: ReadonlySet<RuleId>
69
+ readonly criticalEntities: readonly string[]
70
+ readonly criticalPaths: readonly string[]
71
+ readonly warningThresholds: Partial<Record<RuleId, number>>
72
+ } => {
73
+ const config = RulesConfigSchema.parse(options.config ?? {})
74
+ const mode = options.preset ?? config.mode ?? 'default'
75
+ const severity = { ...config.severity, ...options.severity }
76
+ const ignore = new Set<RuleId>([...(config.ignore ?? []), ...(options.ignore ?? [])])
77
+ return {
78
+ mode,
79
+ severity,
80
+ ignore,
81
+ criticalEntities: options.criticalEntities ?? config.criticalEntities ?? [],
82
+ criticalPaths: options.criticalPaths ?? config.criticalPaths ?? [],
83
+ warningThresholds: { ...config.warningThresholds, ...options.warningThresholds },
84
+ }
85
+ }
86
+
87
+ const severityFor = (
88
+ ruleId: RuleId,
89
+ mode: RuleMode,
90
+ overrides: Partial<Record<RuleId, RuleSeverity>>,
91
+ ): RuleSeverity => overrides[ruleId] ?? defaultSeverity(mode, ruleId)
92
+
93
+ const findingFromDiagnostic = (
94
+ diagnostic: ReconciliationReportV1['diagnostics'][number],
95
+ ruleId: RuleId,
96
+ severity: RuleSeverity,
97
+ ): RuleFinding => ({
98
+ id: `${diagnostic.id}:${ruleId}`,
99
+ ruleId,
100
+ code: ruleId,
101
+ status: diagnostic.status,
102
+ severity,
103
+ message: diagnostic.message,
104
+ evidence: diagnostic.evidence,
105
+ ...(diagnostic.entityIds ? { entityIds: diagnostic.entityIds } : {}),
106
+ ...(diagnostic.relationIds ? { relationIds: diagnostic.relationIds } : {}),
107
+ ...(diagnostic.remediation ? { remediation: diagnostic.remediation } : {}),
108
+ sourceDiagnosticCode: diagnostic.code,
109
+ })
110
+
111
+ const criticalFinding = (
112
+ finding: RuleFinding,
113
+ severity: RuleSeverity,
114
+ target: string,
115
+ ): RuleFinding => ({
116
+ ...finding,
117
+ id: `${finding.id}:critical:${target}`,
118
+ ruleId: 'critical-path-risk',
119
+ code: 'critical-path-risk',
120
+ severity,
121
+ message: `Critical path or entity is affected: ${target}. ${finding.message}`,
122
+ })
123
+
124
+ export const evaluateRules = (
125
+ report: ReconciliationReportV1,
126
+ options: RuleEngineOptions = {},
127
+ ): RuleEvaluationResult => {
128
+ const resolved = resolvedOptions(options)
129
+ const findings: RuleFinding[] = []
130
+
131
+ for (const diagnostic of [...report.diagnostics].sort((a, b) => a.id.localeCompare(b.id))) {
132
+ const ruleId = diagnosticRules[diagnostic.code]
133
+ if (!ruleId || resolved.ignore.has(ruleId)) continue
134
+ const severity = severityFor(ruleId, resolved.mode, resolved.severity)
135
+ if (severity !== 'off') findings.push(findingFromDiagnostic(diagnostic, ruleId, severity))
136
+ }
137
+
138
+ const criticalSeverity = severityFor('critical-path-risk', resolved.mode, resolved.severity)
139
+ const criticalEntitySet = new Set(resolved.criticalEntities)
140
+ for (const finding of [...findings]) {
141
+ const matchingEntity = (finding.entityIds ?? []).find((id) => criticalEntitySet.has(id))
142
+ if (matchingEntity && !resolved.ignore.has('critical-path-risk') && criticalSeverity !== 'off') {
143
+ findings.push(criticalFinding(finding, criticalSeverity, matchingEntity))
144
+ }
145
+ for (const path of resolved.criticalPaths) {
146
+ if (finding.evidence.some((item) => minimatch(item.path, path, { dot: true })) && !resolved.ignore.has('critical-path-risk') && criticalSeverity !== 'off') {
147
+ findings.push(criticalFinding(finding, criticalSeverity, path))
148
+ }
149
+ }
150
+ }
151
+
152
+ const centralityThreshold = resolved.warningThresholds['centrality-risk'] ?? 3
153
+ const centralitySeverity = severityFor('centrality-risk', resolved.mode, resolved.severity)
154
+ if (!resolved.ignore.has('centrality-risk') && centralitySeverity !== 'off') {
155
+ const counts = new Map<string, number>()
156
+ for (const finding of findings.filter((item) => item.ruleId === 'graph-undocumented-relation')) {
157
+ for (const entityId of finding.entityIds ?? []) counts.set(entityId, (counts.get(entityId) ?? 0) + 1)
158
+ }
159
+ for (const [entityId, count] of [...counts.entries()].sort(([a], [b]) => a.localeCompare(b))) {
160
+ if (count < centralityThreshold || !criticalEntitySet.has(entityId)) continue
161
+ findings.push({
162
+ id: `centrality-risk:${entityId}`,
163
+ ruleId: 'centrality-risk',
164
+ code: 'centrality-risk',
165
+ status: 'unresolved',
166
+ severity: centralitySeverity,
167
+ message: `Critical entity has ${count} undocumented relation finding(s); static centrality is a review signal, not a runtime availability claim.`,
168
+ evidence: findings.filter((item) => item.ruleId === 'graph-undocumented-relation' && item.entityIds?.includes(entityId)).flatMap((item) => item.evidence),
169
+ entityIds: [entityId],
170
+ remediation: 'Review ownership, dependency boundaries, and runtime availability before declaring an SPOF.',
171
+ })
172
+ }
173
+ }
174
+
175
+ const sortedFindings = [...findings].sort((a, b) => a.id.localeCompare(b.id))
176
+ return { mode: resolved.mode, findings: sortedFindings, exitCode: sortedFindings.some((finding) => finding.severity === 'error') ? 1 : 0 }
177
+ }
178
+
179
+ export const parseRuleId = (value: string): RuleId => RuleIdSchema.parse(value)
180
+ export const parseRuleSeverity = (value: string): RuleSeverity => RuleSeveritySchema.parse(value)
@@ -0,0 +1,84 @@
1
+ import { lstatSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
3
+
4
+ import { minimatch } from 'minimatch'
5
+
6
+ export const DEFAULT_SAFETY_EXCLUDES = ['**/.git/**', '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.doc-bridge/**', '**/.env', '**/.env.*', '**/*secret*', '**/*credential*', '**/*.pem', '**/*.key'] as const
7
+
8
+ export type SafeWalkOptions = {
9
+ readonly extensions?: readonly string[]
10
+ readonly exclude?: readonly string[]
11
+ readonly maxFiles?: number
12
+ readonly maxBytes?: number
13
+ readonly maxTimeMs?: number
14
+ readonly maxMemoryMb?: number
15
+ }
16
+
17
+ export type SafeWalkResult = {
18
+ readonly files: readonly string[]
19
+ readonly incomplete: boolean
20
+ readonly reason?: string
21
+ }
22
+
23
+ export const containedPath = (root: string, candidate: string): string | undefined => {
24
+ const projectRoot = realpathSync.native(resolve(root))
25
+ const unresolved = resolve(projectRoot, candidate)
26
+ const unresolvedRelative = relative(projectRoot, unresolved)
27
+ if (isAbsolute(unresolvedRelative) || unresolvedRelative === '..' || unresolvedRelative.startsWith(`..${sep}`)) return undefined
28
+ try {
29
+ const canonical = realpathSync.native(unresolved)
30
+ const canonicalRelative = relative(projectRoot, canonical)
31
+ return isAbsolute(canonicalRelative) || canonicalRelative === '..' || canonicalRelative.startsWith(`..${sep}`) ? undefined : canonical
32
+ } catch {
33
+ return unresolved
34
+ }
35
+ }
36
+
37
+ export const safeWalkFiles = (root: string, options: SafeWalkOptions = {}): SafeWalkResult => {
38
+ const projectRoot = resolve(root)
39
+ const extensions = options.extensions ?? []
40
+ const excludes = options.exclude ?? DEFAULT_SAFETY_EXCLUDES
41
+ const files: string[] = []
42
+ let bytes = 0
43
+ let reason: string | undefined
44
+ const started = Date.now()
45
+ const matchesExclude = (path: string): boolean => excludes.some((pattern) => minimatch(path, pattern, { dot: true }))
46
+ const visit = (directory: string): void => {
47
+ if (reason) return
48
+ if (options.maxTimeMs !== undefined && Date.now() - started >= options.maxTimeMs) { reason = `Repository scan exceeded the ${options.maxTimeMs} ms time limit.`; return }
49
+ if (options.maxMemoryMb !== undefined && process.memoryUsage().heapUsed > options.maxMemoryMb * 1024 * 1024) { reason = `Repository scan exceeded the ${options.maxMemoryMb} MiB memory limit.`; return }
50
+ let entries: string[]
51
+ try { entries = readdirSync(directory) } catch { return }
52
+ for (const name of entries.sort()) {
53
+ const absolute = resolve(directory, name)
54
+ const relativePath = relative(projectRoot, absolute).split(sep).join('/')
55
+ if (matchesExclude(relativePath) || name === '.git') continue
56
+ let stats
57
+ try { stats = lstatSync(absolute) } catch { continue }
58
+ if (stats.isSymbolicLink()) continue
59
+ if (stats.isDirectory()) { visit(absolute); if (reason) return; continue }
60
+ if (!stats.isFile() || (extensions.length > 0 && !extensions.some((extension) => name.endsWith(extension)))) continue
61
+ if (files.length >= (options.maxFiles ?? 10_000)) { reason = `Repository scan exceeded the ${options.maxFiles ?? 10_000} file limit.`; return }
62
+ bytes += statSync(absolute).size
63
+ if (options.maxBytes !== undefined && bytes > options.maxBytes) { reason = `Repository scan exceeded the ${options.maxBytes} byte limit.`; return }
64
+ files.push(absolute)
65
+ }
66
+ }
67
+ visit(projectRoot)
68
+ return { files: files.sort(), incomplete: reason !== undefined, ...(reason ? { reason } : {}) }
69
+ }
70
+
71
+ const SECRET_PATTERNS = [
72
+ /\b(?:sk|pk)[_-](?:live|test)[_-][A-Za-z0-9_-]{12,}\b/g,
73
+ /\b(?:ghp|github_pat|xox[baprs])_[A-Za-z0-9_-]{12,}\b/g,
74
+ /\bAKIA[0-9A-Z]{16}\b/g,
75
+ /(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[^\s,"']+/gi,
76
+ ]
77
+
78
+ export const redactSecrets = (value: string): string => SECRET_PATTERNS.reduce((result, pattern) => result.replace(pattern, '[REDACTED]'), value)
79
+
80
+ export const redactValue = (value: unknown): unknown => Array.isArray(value)
81
+ ? value.map(redactValue)
82
+ : value && typeof value === 'object'
83
+ ? Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, redactValue(item)]))
84
+ : typeof value === 'string' ? redactSecrets(value) : value