@inerrata-corporation/errata 2.0.21 → 2.0.22

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,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
+ }