@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.
- package/CHANGELOG.md +12 -0
- package/action.yml +1 -1
- package/dist/cli/program.js +2413 -268
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +74 -3
- package/dist/config/index.js.map +1 -1
- package/dist/{index-DhoAG9Ar.d.ts → index-Di7PkJuf.d.ts} +195 -8
- package/dist/index.d.ts +1942 -4
- package/dist/index.js +2144 -189
- package/dist/index.js.map +1 -1
- package/docs/PRD-doc-bridge-knowledge-engine.md +338 -0
- package/docs/knowledge-engine-runbook.md +44 -0
- package/ecosystem-claims.json +16 -16
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +84 -127
- package/mcpb/manifest.json +25 -1
- package/package.json +2 -2
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +97 -0
- package/src/cli/program.ts +285 -3
- package/src/config/defaults.ts +14 -1
- package/src/config/schema.ts +67 -0
- package/src/discovery/documentation.ts +320 -0
- package/src/discovery/repository.ts +515 -0
- package/src/fixes/proposals.ts +165 -0
- package/src/index-builder/content-hash.ts +9 -2
- package/src/index.ts +99 -2
- package/src/mcp/server.ts +178 -8
- package/src/reconciliation/reconcile.ts +227 -0
- package/src/report/html.ts +74 -0
- package/src/rules/engine.ts +180 -0
- package/src/safety/repository.ts +84 -0
- package/src/schemas/knowledge.ts +315 -0
- package/src/validate.ts +24 -0
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +238 -0
- package/tsup.config.ts +2 -1
|
@@ -0,0 +1,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('&', '&')
|
|
15
|
+
.replaceAll('<', '<')
|
|
16
|
+
.replaceAll('>', '>')
|
|
17
|
+
.replaceAll('"', '"')
|
|
18
|
+
.replaceAll("'", ''')
|
|
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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');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
|