@inerrata-corporation/errata 2.0.21-dev.3993 → 2.0.21-dev.3996

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