@inerrata-corporation/errata 2.0.21 → 2.0.22-dev.4020
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/consolidate-worker.mjs +443 -187
- package/errata.mjs +5929 -4474
- package/generalize-worker.mjs +17430 -0
- package/install.ps1 +1 -1
- package/package.json +2 -1
- package/pass-worker.mjs +3101 -2653
- package/resources/claude-mods/errata-minimap/.claude-plugin/plugin.json +2 -2
- package/resources/claude-mods/errata-minimap/hooks/bookcase.ts +450 -0
- package/resources/claude-mods/errata-minimap/hooks/chains.ts +427 -0
- package/resources/claude-mods/errata-minimap/hooks/graph.ts +349 -0
- package/resources/claude-mods/errata-minimap/hooks/minimap.ts +39 -351
- package/resources/claude-mods/errata-minimap/hooks/register.tsx +440 -148
- package/resources/claude-mods/errata-minimap/hooks/tags.ts +129 -0
- package/resources/claude-mods/errata-minimap/types/index.d.ts +42 -8
- package/resources/context-plugin/.codex-plugin/plugin.json +1 -1
- package/resources/context-plugin/SHA256SUMS +1 -1
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import type { ChainFacts, FarNode, MapGraph, MapNode } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The burst bound into BOOKS: a book is one causal chain — RootCause → Problem
|
|
5
|
+
* → Solution, any subset — the way the splash's lesson stacks read a node's
|
|
6
|
+
* neighbourhood (apps/splash/src/network/components/library/lesson-model.ts).
|
|
7
|
+
* Books stand in BOOKCASES by technology and on SHELVES by community.
|
|
8
|
+
*
|
|
9
|
+
* Two sources, merged: the burst rows themselves (their `via` edge says how
|
|
10
|
+
* each was reached, so a cause or fix walked off a problem joins its book at
|
|
11
|
+
* once), and `errata_neighbors` answers fetched after the first draw
|
|
12
|
+
* (`ChainFacts`), which complete a chain beyond the burst and place it — its
|
|
13
|
+
* package, its local community, its patterns, the file it is anchored at.
|
|
14
|
+
*/
|
|
15
|
+
export type Role = 'cause' | 'problem' | 'fix'
|
|
16
|
+
|
|
17
|
+
export type Part = {
|
|
18
|
+
id: string
|
|
19
|
+
label: string
|
|
20
|
+
name: string
|
|
21
|
+
/** Entered the agent's context with this burst; false = completed from the graph afterwards. */
|
|
22
|
+
isDelivered: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Book = {
|
|
26
|
+
/** The problem's id, or the lone cause/fix's when no problem is known. */
|
|
27
|
+
key: string
|
|
28
|
+
cause?: Part
|
|
29
|
+
problem?: Part
|
|
30
|
+
fix?: Part
|
|
31
|
+
/** Causes / fixes the chain holds beyond the one on its card. */
|
|
32
|
+
moreCauses: number
|
|
33
|
+
moreFixes: number
|
|
34
|
+
/** The strongest member's burst score. */
|
|
35
|
+
score: number
|
|
36
|
+
isSeed: boolean
|
|
37
|
+
memberIds: string[]
|
|
38
|
+
tech?: string
|
|
39
|
+
community?: string
|
|
40
|
+
patterns: string[]
|
|
41
|
+
anchor?: string
|
|
42
|
+
/** Its `errata_neighbors` answer has arrived. */
|
|
43
|
+
isCatalogued: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Shelf = {
|
|
47
|
+
key: string
|
|
48
|
+
name: string
|
|
49
|
+
/** 1-based, top shelf first: the middle term of a call number. */
|
|
50
|
+
number: number
|
|
51
|
+
books: Book[]
|
|
52
|
+
isMiscellany: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Bookcase = {
|
|
56
|
+
tech: string
|
|
57
|
+
/** Three letters of the tech, the call number's class mark (CHO for chokidar). */
|
|
58
|
+
code: string
|
|
59
|
+
shelves: Shelf[]
|
|
60
|
+
isGeneral: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Catalog = { cases: Bookcase[]; books: Book[] }
|
|
64
|
+
|
|
65
|
+
const ROLE_OF: Record<string, Role> = {
|
|
66
|
+
Problem: 'problem',
|
|
67
|
+
Vulnerability: 'problem',
|
|
68
|
+
AntiPattern: 'problem',
|
|
69
|
+
Claim: 'problem',
|
|
70
|
+
RootCause: 'cause',
|
|
71
|
+
Constraint: 'cause',
|
|
72
|
+
Solution: 'fix',
|
|
73
|
+
Technique: 'fix',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A node's place in a chain; a seed not yet named reads its role off its id prefix. */
|
|
77
|
+
export function roleOf(label: string, id = ''): Role | undefined {
|
|
78
|
+
if (ROLE_OF[label]) return ROLE_OF[label]
|
|
79
|
+
if (label !== 'Seed') return undefined
|
|
80
|
+
if (/^dprob_/.test(id)) return 'problem'
|
|
81
|
+
if (/^dcause_/.test(id)) return 'cause'
|
|
82
|
+
if (/^(dfix|dsol)_/.test(id)) return 'fix'
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Edge = { from: string; to: string; type: string }
|
|
87
|
+
|
|
88
|
+
const CAUSE_EDGES = new Set(['CAUSED_BY'])
|
|
89
|
+
const CAUSES_EDGES = new Set(['MANIFESTS_AS', 'CAUSES'])
|
|
90
|
+
const FIX_EDGES = new Set(['SOLVED_BY', 'FIXED_BY', 'RESOLVED_BY'])
|
|
91
|
+
const FIXES_EDGES = new Set(['SOLVES', 'FIXES', 'RESOLVES'])
|
|
92
|
+
const TECH_EDGES = new Set(['DEPENDS_ON', 'CONCERNS', 'OCCURS_IN'])
|
|
93
|
+
const TECH_LABELS = new Set(['Package', 'Tool', 'Component', 'Language'])
|
|
94
|
+
|
|
95
|
+
/** Each walked row's edge, oriented: `forward` ⇒ parent —type→ node. */
|
|
96
|
+
function burstEdges(graph: MapGraph): Edge[] {
|
|
97
|
+
return graph.nodes.flatMap(n => {
|
|
98
|
+
const parent = graph.nodes[n.parent]
|
|
99
|
+
if (n.isSeed || !parent || !n.edge) return []
|
|
100
|
+
return [n.forward === false ? { from: n.id, to: parent.id, type: n.edge } : { from: parent.id, to: n.id, type: n.edge }]
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What a cause or fix node belongs to, by the walk's own edges: problem ids, and (for a fix) cause ids. */
|
|
105
|
+
function ownersOf(id: string, role: Role, edges: Edge[]): string[] {
|
|
106
|
+
const out: string[] = []
|
|
107
|
+
for (const e of edges) {
|
|
108
|
+
if (role === 'cause') {
|
|
109
|
+
if (e.to === id && CAUSE_EDGES.has(e.type)) out.push(e.from)
|
|
110
|
+
if (e.from === id && CAUSES_EDGES.has(e.type)) out.push(e.to)
|
|
111
|
+
} else if (role === 'fix') {
|
|
112
|
+
if (e.to === id && FIX_EDGES.has(e.type)) out.push(e.from)
|
|
113
|
+
if (e.from === id && FIXES_EDGES.has(e.type)) out.push(e.to)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const part = (n: { id: string; label: string; name: string }, isDelivered: boolean): Part => ({ id: n.id, label: n.label, name: n.name, isDelivered })
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `vitest@3.2.6` → `vitest`, `@hono/zod-validator@0.7.6` → `@hono/zod-validator`:
|
|
123
|
+
* a bookcase holds the technology, not one release of it.
|
|
124
|
+
*/
|
|
125
|
+
export function techName(name: string): string {
|
|
126
|
+
return name.replace(/^(@?[^@\s]+)@[^@\s]*$/, '$1')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How strongly a tech anchor names a chain's bookcase, lower first. A versioned
|
|
131
|
+
* package is a dependency the problem is ABOUT; an unversioned one is nearly
|
|
132
|
+
* always the workspace's own package (`@inerrata-corporation/cloud-client`),
|
|
133
|
+
* which says where the code lives, not what it fights. Language last: on the
|
|
134
|
+
* live store `typescript` anchors 398 of 428 anchored chains — it separates
|
|
135
|
+
* nothing, so it only names a case when nothing else does.
|
|
136
|
+
*/
|
|
137
|
+
function techTier(n: FarNode): number {
|
|
138
|
+
if (n.label === 'Package') return /^@?[^@\s]+@\S+$/.test(n.name) ? 0 : 1
|
|
139
|
+
if (n.label === 'Tool' || n.label === 'Component') return 2
|
|
140
|
+
return 3
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Each book's bookcase: within the best tier a chain has, the tech the most
|
|
145
|
+
* books in view share (greedy set cover), so a burst consolidates into a few
|
|
146
|
+
* cases instead of one per package a problem happens to touch (a problem has
|
|
147
|
+
* ~1.6 DEPENDS_ON edges on the live store).
|
|
148
|
+
*/
|
|
149
|
+
function assignTech(books: Book[], candidates: Map<string, FarNode[]>): void {
|
|
150
|
+
let open = books.filter(b => (candidates.get(b.key) ?? []).length > 0)
|
|
151
|
+
for (let tier = 0; tier <= 3 && open.length > 0; tier++) {
|
|
152
|
+
const inTier = (b: Book) => (candidates.get(b.key) ?? []).filter(c => techTier(c) === tier).map(c => techName(c.name))
|
|
153
|
+
for (;;) {
|
|
154
|
+
const count = new Map<string, number>()
|
|
155
|
+
for (const b of open) for (const t of new Set(inTier(b))) count.set(t, (count.get(t) ?? 0) + 1)
|
|
156
|
+
if (count.size === 0) break
|
|
157
|
+
// Ties go to the tech met first, in book order (strongest first).
|
|
158
|
+
const best = [...count].reduce((a, b) => (b[1] > a[1] ? b : a))[0]
|
|
159
|
+
for (const b of open) if (inTier(b).includes(best)) b.tech = best
|
|
160
|
+
open = open.filter(b => b.tech === undefined)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A path-like anchor (a file, or a symbol), or the `@ path:line` a problem title carries. */
|
|
166
|
+
function anchorOf(facts: ChainFacts | null | undefined, name: string | undefined): string | undefined {
|
|
167
|
+
const fromTitle = name ? /\s@\s+(\S+:\d+)\s*$/.exec(name)?.[1] : undefined
|
|
168
|
+
if (fromTitle) return fromTitle
|
|
169
|
+
const anchors = facts?.anchors ?? []
|
|
170
|
+
return anchors.find(a => a.includes('/')) ?? anchors[0]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The burst bound into chains and placed: books strongest first, in bookcases and on shelves. */
|
|
174
|
+
export function catalogue(graph: MapGraph, facts: Record<string, ChainFacts | null> = {}): Catalog {
|
|
175
|
+
const edges = burstEdges(graph)
|
|
176
|
+
const ranked = graph.nodes
|
|
177
|
+
.map((n, i) => ({ n, i, role: roleOf(n.label, n.id) }))
|
|
178
|
+
.filter((x): x is { n: MapNode; i: number; role: Role } => x.role !== undefined)
|
|
179
|
+
.sort((a, b) => Number(b.n.isSeed) - Number(a.n.isSeed) || b.n.score - a.n.score || a.i - b.i)
|
|
180
|
+
const books = new Map<string, Book>()
|
|
181
|
+
const bookOf = new Map<string, Book>()
|
|
182
|
+
const open = (key: string, score: number, isSeed: boolean): Book => {
|
|
183
|
+
const book: Book = { key, moreCauses: 0, moreFixes: 0, score, isSeed, memberIds: [], patterns: [], isCatalogued: false }
|
|
184
|
+
books.set(key, book)
|
|
185
|
+
return book
|
|
186
|
+
}
|
|
187
|
+
const join = (book: Book, role: Role, p: Part, score = 0) => {
|
|
188
|
+
if (book.memberIds.includes(p.id)) return
|
|
189
|
+
book.memberIds.push(p.id)
|
|
190
|
+
bookOf.set(p.id, book)
|
|
191
|
+
book.score = Math.max(book.score, score)
|
|
192
|
+
if (role === 'problem') book.problem ??= p
|
|
193
|
+
else if (role === 'cause') book.cause ? book.moreCauses++ : (book.cause = p)
|
|
194
|
+
else book.fix ? book.moreFixes++ : (book.fix = p)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const { n, role } of ranked) if (role === 'problem') join(open(n.id, n.score, n.isSeed), 'problem', part(n, true), n.score)
|
|
198
|
+
// Causes before fixes: a fix walked off a cause joins the cause's book.
|
|
199
|
+
for (const want of ['cause', 'fix'] as const) {
|
|
200
|
+
for (const { n, role } of ranked) {
|
|
201
|
+
if (role !== want || bookOf.has(n.id)) continue
|
|
202
|
+
const owner = ownersOf(n.id, role, edges)
|
|
203
|
+
.map(id => bookOf.get(id))
|
|
204
|
+
.find(b => b !== undefined)
|
|
205
|
+
const factProblem = facts[n.id]?.problems[0]
|
|
206
|
+
const book = owner ?? (factProblem ? books.get(factProblem.id) : undefined) ?? open(factProblem?.id ?? n.id, n.score, n.isSeed)
|
|
207
|
+
if (!owner && factProblem && !book.problem) join(book, 'problem', part(factProblem, false))
|
|
208
|
+
join(book, role, part(n, true), n.score)
|
|
209
|
+
if (n.isSeed) book.isSeed = true
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const candidates = new Map<string, FarNode[]>()
|
|
214
|
+
const byId = new Map(graph.nodes.map(n => [n.id, n]))
|
|
215
|
+
for (const book of books.values()) {
|
|
216
|
+
const head = book.problem?.id ?? book.key
|
|
217
|
+
const f = facts[head]
|
|
218
|
+
const techs: FarNode[] = []
|
|
219
|
+
for (const e of edges) {
|
|
220
|
+
const far = byId.get(e.to)
|
|
221
|
+
if (!far || !book.memberIds.includes(e.from)) continue
|
|
222
|
+
if (TECH_EDGES.has(e.type) && TECH_LABELS.has(far.label)) techs.push({ id: far.id, label: far.label, name: far.name })
|
|
223
|
+
if (e.type === 'INSTANCE_OF' && far.label === 'Pattern') book.patterns.push(far.name)
|
|
224
|
+
}
|
|
225
|
+
if (f) {
|
|
226
|
+
book.isCatalogued = true
|
|
227
|
+
for (const [role, links] of [['cause', f.causes], ['fix', f.fixes]] as const) {
|
|
228
|
+
for (const link of links) {
|
|
229
|
+
const other = bookOf.get(link.id)
|
|
230
|
+
// A cause/fix the burst shelved alone turns out to be this chain's: fold its book into this one.
|
|
231
|
+
if (other && other !== book) {
|
|
232
|
+
if (other.problem) continue
|
|
233
|
+
books.delete(other.key)
|
|
234
|
+
for (const [r, p] of [['cause', other.cause], ['fix', other.fix]] as const) if (p) join(book, r, p, other.score)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
const delivered = byId.get(link.id)
|
|
238
|
+
join(book, role, delivered ? part(delivered, true) : part(link, false), delivered?.score ?? 0)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
techs.push(...f.techs)
|
|
242
|
+
for (const p of f.patterns) if (!book.patterns.includes(p.name)) book.patterns.push(p.name)
|
|
243
|
+
if (f.community) book.community = f.community
|
|
244
|
+
} else if (f === null) {
|
|
245
|
+
book.isCatalogued = true
|
|
246
|
+
}
|
|
247
|
+
// Only cloud-merged rows carry a community; theirs is the cloud partition's, kept apart from the local one.
|
|
248
|
+
const cloud = book.community ? undefined : graph.nodes.find(n => book.memberIds.includes(n.id) && n.community)?.community
|
|
249
|
+
if (cloud) book.community = `cloud_${cloud.id}`
|
|
250
|
+
book.anchor = anchorOf(f, book.problem?.name)
|
|
251
|
+
candidates.set(book.key, techs.filter((t, i) => techs.findIndex(u => u.name === t.name) === i))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const list = [...books.values()].sort((a, b) => Number(b.isSeed) - Number(a.isSeed) || b.score - a.score)
|
|
255
|
+
assignTech(list, candidates)
|
|
256
|
+
return { cases: bookcases(list), books: list }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const GENERAL = 'general'
|
|
260
|
+
/** A shelf holds a community, or a shared Pattern, only when two of its books are in view. */
|
|
261
|
+
const MIN_SHELF = 2
|
|
262
|
+
/** The shelf for books that share neither with another book in view. */
|
|
263
|
+
export const LOOSE = 'loose volumes'
|
|
264
|
+
|
|
265
|
+
function bookcases(books: Book[]): Bookcase[] {
|
|
266
|
+
const byTech = new Map<string, Book[]>()
|
|
267
|
+
for (const b of books) byTech.set(b.tech ?? GENERAL, [...(byTech.get(b.tech ?? GENERAL) ?? []), b])
|
|
268
|
+
return [...byTech]
|
|
269
|
+
.sort((a, b) => Number(a[0] === GENERAL) - Number(b[0] === GENERAL))
|
|
270
|
+
.map(([tech, held]) => ({ tech, code: classMark(tech), shelves: shelves(held), isGeneral: tech === GENERAL }))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Shelves in three tiers: a community with two books in view; then, among the
|
|
275
|
+
* books left, a Pattern two of them instance (agent-named shapes are often the
|
|
276
|
+
* truest grouping there is); then the loose volumes. A burst of ten chains
|
|
277
|
+
* rarely holds two from one community — they are small — so community alone
|
|
278
|
+
* put nearly every book on one catch-all shelf.
|
|
279
|
+
*/
|
|
280
|
+
function shelves(books: Book[]): Shelf[] {
|
|
281
|
+
const out: Shelf[] = []
|
|
282
|
+
const byCommunity = new Map<string, Book[]>()
|
|
283
|
+
for (const b of books) if (b.community) byCommunity.set(b.community, [...(byCommunity.get(b.community) ?? []), b])
|
|
284
|
+
for (const [key, held] of byCommunity) {
|
|
285
|
+
if (held.length >= MIN_SHELF) out.push({ key, name: shelfName(held), number: 0, books: held, isMiscellany: false })
|
|
286
|
+
}
|
|
287
|
+
let rest = books.filter(b => !out.some(s => s.books.includes(b)))
|
|
288
|
+
// The most-shared Pattern first, so a book that instances several joins the biggest group.
|
|
289
|
+
for (;;) {
|
|
290
|
+
const count = new Map<string, Book[]>()
|
|
291
|
+
for (const b of rest) for (const p of new Set(b.patterns)) count.set(p, [...(count.get(p) ?? []), b])
|
|
292
|
+
const best = [...count].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0]
|
|
293
|
+
if (!best || best[1].length < MIN_SHELF) break
|
|
294
|
+
out.push({ key: `pattern:${best[0]}`, name: patternTitle(best[0]), number: 0, books: best[1], isMiscellany: false })
|
|
295
|
+
rest = rest.filter(b => !best[1].includes(b))
|
|
296
|
+
}
|
|
297
|
+
if (rest.length > 0) out.push({ key: 'loose', name: LOOSE, number: 0, books: rest, isMiscellany: true })
|
|
298
|
+
out.forEach((s, i) => (s.number = i + 1))
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A community has no name of its own (the local partition's label is null), so
|
|
304
|
+
* the shelf is named from what its books share: the Pattern most of its books
|
|
305
|
+
* are INSTANCE_OF — the graph's own generalisation of what those problems have
|
|
306
|
+
* in common — else its strongest problem, the shelf's exemplar. Never the cloud
|
|
307
|
+
* community's theme label: those can describe other members entirely (#43
|
|
308
|
+
* "Canvas zoom/scroll…" held SQLite busy_timeout problems).
|
|
309
|
+
*/
|
|
310
|
+
export function shelfName(books: Book[]): string {
|
|
311
|
+
const count = new Map<string, number>()
|
|
312
|
+
for (const b of books) for (const p of new Set(b.patterns)) count.set(p, (count.get(p) ?? 0) + 1)
|
|
313
|
+
if (count.size > 0) return patternTitle([...count].reduce((a, b) => (b[1] > a[1] ? b : a))[0])
|
|
314
|
+
const exemplar = books.find(b => b.problem) ?? books[0]!
|
|
315
|
+
return (exemplar.problem ?? exemplar.fix ?? exemplar.cause)!.name
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `pattern ×64 (exemplar): harvestInlineTags is … @ apps/daemon/src/engine.ts:1036` → the exemplar's words. */
|
|
319
|
+
export function patternTitle(name: string): string {
|
|
320
|
+
return name
|
|
321
|
+
.replace(/^pattern\s*[×x]\d+\s*\(exemplar\):\s*/i, '')
|
|
322
|
+
.replace(/\s+@\s+\S+$/, '')
|
|
323
|
+
.trim()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Three letters of the tech, scope dropped: chokidar → CHO, @hono/zod-validator → ZOD. */
|
|
327
|
+
export function classMark(tech: string): string {
|
|
328
|
+
const bare = tech.replace(/^@[^/]+\//, '').replace(/[^A-Za-z]/g, '')
|
|
329
|
+
return (bare.slice(0, 3) || 'GEN').toUpperCase()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A book's call number: class mark, shelf, place on the shelf (CHO 1.2). */
|
|
333
|
+
export function callNumber(catalog: Catalog, book: Book): string {
|
|
334
|
+
for (const c of catalog.cases)
|
|
335
|
+
for (const s of c.shelves) {
|
|
336
|
+
const at = s.books.indexOf(book)
|
|
337
|
+
if (at >= 0) return `${c.code} ${s.number}.${at + 1}`
|
|
338
|
+
}
|
|
339
|
+
return ''
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function placeOf(catalog: Catalog, book: Book): { bookcase: Bookcase; shelf: Shelf } | undefined {
|
|
343
|
+
for (const bookcase of catalog.cases) for (const shelf of bookcase.shelves) if (shelf.books.includes(book)) return { bookcase, shelf }
|
|
344
|
+
return undefined
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The book holding a node, if any. */
|
|
348
|
+
export function bookHolding(catalog: Catalog, id: string | undefined): Book | undefined {
|
|
349
|
+
return id ? catalog.books.find(b => b.key === id || b.memberIds.includes(id)) : undefined
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The ids worth an `errata_neighbors` call, strongest chain first: each book's
|
|
354
|
+
* problem (or its lone cause/fix) whose facts have not been asked yet.
|
|
355
|
+
*/
|
|
356
|
+
export function enrichTargets(catalog: Catalog, facts: Record<string, ChainFacts | null>, max: number): string[] {
|
|
357
|
+
const out: string[] = []
|
|
358
|
+
for (const b of catalog.books) {
|
|
359
|
+
const head = b.problem?.id ?? b.key
|
|
360
|
+
if (!(head in facts) && !out.includes(head)) out.push(head)
|
|
361
|
+
if (out.length >= max) break
|
|
362
|
+
}
|
|
363
|
+
return out
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
type NeighborsJson = {
|
|
367
|
+
found?: boolean
|
|
368
|
+
node?: { id?: string; label?: string; name?: string; community?: unknown }
|
|
369
|
+
out?: { type?: string; to?: Partial<FarNode> }[]
|
|
370
|
+
in?: { type?: string; from?: Partial<FarNode> }[]
|
|
371
|
+
nearby?: { id?: string; label?: string; name?: string; via?: { edgeType?: string; parentId?: string; forward?: boolean } }[]
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const far = (n: Partial<FarNode> | undefined): FarNode | undefined =>
|
|
375
|
+
n?.id && n.label ? { id: n.id, label: n.label, name: n.name ?? n.id, ...(typeof n.community === 'string' ? { community: n.community } : {}) } : undefined
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* An `errata_neighbors` answer → the facts that bind and place a chain. The
|
|
379
|
+
* collective fallback (a cloud id not in the local store) answers with
|
|
380
|
+
* `nearby` rows instead of typed out/in lists; their `via` is read the same way.
|
|
381
|
+
* `null` = the node was not found (asked; nothing to add).
|
|
382
|
+
*/
|
|
383
|
+
export function parseNeighbors(texts: string[]): ChainFacts | null | undefined {
|
|
384
|
+
for (const text of texts) {
|
|
385
|
+
let json: NeighborsJson
|
|
386
|
+
try {
|
|
387
|
+
json = JSON.parse(text) as NeighborsJson
|
|
388
|
+
} catch {
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
if (json.found === false) return null
|
|
392
|
+
if (!json.node?.id) continue
|
|
393
|
+
const id = json.node.id
|
|
394
|
+
const out: { type: string; to: FarNode }[] = []
|
|
395
|
+
const inn: { type: string; from: FarNode }[] = []
|
|
396
|
+
for (const e of json.out ?? []) {
|
|
397
|
+
const to = far(e.to)
|
|
398
|
+
if (e.type && to) out.push({ type: e.type, to })
|
|
399
|
+
}
|
|
400
|
+
for (const e of json.in ?? []) {
|
|
401
|
+
const from = far(e.from)
|
|
402
|
+
if (e.type && from) inn.push({ type: e.type, from })
|
|
403
|
+
}
|
|
404
|
+
for (const n of json.nearby ?? []) {
|
|
405
|
+
const node = far(n)
|
|
406
|
+
if (!node || !n.via?.edgeType || n.via.parentId !== id) continue
|
|
407
|
+
if (n.via.forward === false) inn.push({ type: n.via.edgeType, from: node })
|
|
408
|
+
else out.push({ type: n.via.edgeType, to: node })
|
|
409
|
+
}
|
|
410
|
+
const outOf = (types: Set<string>, labels?: Set<string>) => out.filter(e => types.has(e.type) && (!labels || labels.has(e.to.label))).map(e => e.to)
|
|
411
|
+
const inOf = (types: Set<string>) => inn.filter(e => types.has(e.type)).map(e => e.from)
|
|
412
|
+
const problemsOf = [...inOf(CAUSE_EDGES), ...inOf(FIX_EDGES), ...outOf(CAUSES_EDGES), ...outOf(FIXES_EDGES)].filter(n => roleOf(n.label) === 'problem')
|
|
413
|
+
return {
|
|
414
|
+
id,
|
|
415
|
+
label: json.node.label ?? 'Node',
|
|
416
|
+
name: json.node.name ?? id,
|
|
417
|
+
community: typeof json.node.community === 'string' ? json.node.community : null,
|
|
418
|
+
causes: [...outOf(CAUSE_EDGES), ...inOf(CAUSES_EDGES)].filter(n => roleOf(n.label) === 'cause'),
|
|
419
|
+
fixes: [...outOf(FIX_EDGES), ...inOf(FIXES_EDGES)].filter(n => roleOf(n.label) === 'fix'),
|
|
420
|
+
problems: problemsOf,
|
|
421
|
+
techs: outOf(TECH_EDGES, TECH_LABELS),
|
|
422
|
+
patterns: outOf(new Set(['INSTANCE_OF']), new Set(['Pattern'])),
|
|
423
|
+
anchors: outOf(new Set(['ANCHORED_AT'])).map(n => n.name),
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return undefined
|
|
427
|
+
}
|