@henols/c64-re-tools 1.1.0 → 2.0.0-rc.3

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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +106 -0
  39. package/src/host/server.ts +376 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +238 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +106 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +243 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,344 @@
1
+ // Analyzer import: reconciles one analyzer's normalized findings with
2
+ // current knowledge as one revision in one transaction.
3
+ //
4
+ // An import is a snapshot of what the analyzer asserts inside its coverage.
5
+ // In an authoritative category, the analyzer's own current rows in coverage
6
+ // that the snapshot no longer holds are closed. Rows of the user, the LLM or
7
+ // another analyzer are never replaced: a finding that contradicts one is
8
+ // reported as a conflict. Code against data is a contradiction; a different
9
+ // name or a finer data type for the same kind of memory is not.
10
+
11
+ import { createHash } from "node:crypto";
12
+ import type { DatabaseSync } from "node:sqlite";
13
+
14
+ import { KnowledgeError, transaction } from "./database.ts";
15
+ import {
16
+ REFERENCE_COLUMNS,
17
+ regionsOverlapping,
18
+ SYMBOL_COLUMNS,
19
+ symbolAt,
20
+ symbolNamed,
21
+ toReference,
22
+ toSymbol,
23
+ type ReferenceRow,
24
+ type RegionRow,
25
+ type SymbolRow,
26
+ } from "./read.ts";
27
+ import { REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type Origin, type ReferenceKind, type RegionType, type SymbolKind } from "./schema.ts";
28
+ import { Change, checkAddress, hex, SYMBOL_NAME } from "./write.ts";
29
+
30
+ export const ANALYZERS = ["dxa", "ghidra"] as const satisfies readonly Origin[];
31
+ export type Analyzer = (typeof ANALYZERS)[number];
32
+
33
+ export interface AddressRange {
34
+ start: number;
35
+ end: number;
36
+ }
37
+
38
+ export interface NormalizedFindings {
39
+ analyzer: Analyzer;
40
+ /** The address ranges the analyzer examined; it makes claims only there. */
41
+ coverage: AddressRange[];
42
+ /** A category is authoritative when its findings are complete in coverage, so absence means "no longer asserted". */
43
+ authoritative: { symbols: boolean; regions: boolean; references: boolean };
44
+ symbols: Array<{ address: number; name: string; kind: SymbolKind }>;
45
+ regions: Array<{ start: number; end: number; type: RegionType }>;
46
+ /** Owned and covered by their source address. */
47
+ references: Array<{ from: number; to: number; kind: ReferenceKind }>;
48
+ }
49
+
50
+ export interface ImportContext {
51
+ /** The revision the caller read; the import is refused if knowledge changed since. */
52
+ expectedRevision?: number;
53
+ description?: string;
54
+ /** The SHA-256 of the analyzed image, as lowercase hex. */
55
+ inputHash?: string;
56
+ /** The analyzer's own version line. */
57
+ toolVersion?: string;
58
+ }
59
+
60
+ /** What a revision records about one analyzer run: the hash of the image bytes and, when the analyzer tells it, its version. */
61
+ export function runProvenance(image: Uint8Array, toolVersion: string | undefined): Pick<ImportContext, "inputHash" | "toolVersion"> {
62
+ const inputHash = createHash("sha256").update(image).digest("hex");
63
+ return toolVersion === undefined ? { inputHash } : { inputHash, toolVersion };
64
+ }
65
+
66
+ export type ImportConflict =
67
+ | {
68
+ category: "symbol";
69
+ address: number;
70
+ reason: "kind" | "name-taken";
71
+ current: { address: number; name: string; kind: SymbolKind; origin: Origin };
72
+ finding: { name: string; kind: SymbolKind };
73
+ }
74
+ | { category: "region"; start: number; end: number; current: { type: RegionType; origin: Origin }; finding: { type: RegionType } };
75
+
76
+ export interface ImportCounts {
77
+ added: number;
78
+ changed: number;
79
+ retired: number;
80
+ unchanged: number;
81
+ }
82
+
83
+ export interface ImportResult {
84
+ /** The new revision, or null when nothing changed. */
85
+ revision: number | null;
86
+ symbols: ImportCounts;
87
+ regions: ImportCounts;
88
+ references: ImportCounts;
89
+ conflicts: ImportConflict[];
90
+ }
91
+
92
+ const isCode = (value: SymbolKind | RegionType) => value === "label" || value === "routine" || value === "code";
93
+
94
+ const counts = (): ImportCounts => ({ added: 0, changed: 0, retired: 0, unchanged: 0 });
95
+
96
+ function invalid(message: string): never {
97
+ throw new KnowledgeError("invalid-input", `The analyzer result is not usable: ${message} Nothing was imported.`);
98
+ }
99
+
100
+ /** Sorted, merged coverage ranges. */
101
+ function mergeCoverage(coverage: AddressRange[]): AddressRange[] {
102
+ const sorted = [...coverage].sort((a, b) => a.start - b.start);
103
+ const merged: AddressRange[] = [];
104
+ for (const range of sorted) {
105
+ const last = merged.at(-1);
106
+ if (last !== undefined && range.start <= last.end + 1) last.end = Math.max(last.end, range.end);
107
+ else merged.push({ ...range });
108
+ }
109
+ return merged;
110
+ }
111
+
112
+ /** Parts of `range` that no range in `holes` covers. */
113
+ function subtract(range: AddressRange, holes: AddressRange[]): AddressRange[] {
114
+ let pieces = [range];
115
+ for (const hole of holes) {
116
+ pieces = pieces.flatMap((piece) => {
117
+ if (hole.end < piece.start || hole.start > piece.end) return [piece];
118
+ const left = hole.start > piece.start ? [{ start: piece.start, end: hole.start - 1 }] : [];
119
+ const right = hole.end < piece.end ? [{ start: hole.end + 1, end: piece.end }] : [];
120
+ return [...left, ...right];
121
+ });
122
+ }
123
+ return pieces;
124
+ }
125
+
126
+ /**
127
+ * Checks complete findings before anything is written. Every finding
128
+ * lies in coverage, names are valid and unique, regions do not overlap.
129
+ * Returns the merged coverage.
130
+ */
131
+ export function validateFindings(findings: NormalizedFindings): AddressRange[] {
132
+ if (!(ANALYZERS as readonly string[]).includes(findings.analyzer)) invalid(`the analyzer must be one of ${ANALYZERS.join(", ")}.`);
133
+ if (!Array.isArray(findings.coverage) || findings.coverage.length === 0) invalid("the result has no coverage.");
134
+ for (const range of findings.coverage) {
135
+ checkAddress(range.start, "coverage start");
136
+ checkAddress(range.end, "coverage end");
137
+ if (range.end < range.start) invalid(`coverage ${hex(range.start)}-${hex(range.end)} ends before it starts.`);
138
+ }
139
+ const coverage = mergeCoverage(findings.coverage);
140
+ const covering = (start: number, end: number) => coverage.some((range) => range.start <= start && end <= range.end);
141
+
142
+ const addresses = new Set<number>();
143
+ const names = new Set<string>();
144
+ for (const symbol of findings.symbols) {
145
+ checkAddress(symbol.address);
146
+ if (!covering(symbol.address, symbol.address)) invalid(`the symbol at ${hex(symbol.address)} is outside the coverage.`);
147
+ if (!SYMBOL_NAME.test(symbol.name)) invalid(`the symbol name ${JSON.stringify(symbol.name)} is not valid.`);
148
+ if (!(SYMBOL_KINDS as readonly string[]).includes(symbol.kind)) invalid(`the symbol kind ${symbol.kind} is not known.`);
149
+ if (addresses.has(symbol.address)) invalid(`two symbols are at ${hex(symbol.address)}.`);
150
+ if (names.has(symbol.name)) invalid(`the name ${symbol.name} is at two addresses.`);
151
+ addresses.add(symbol.address);
152
+ names.add(symbol.name);
153
+ }
154
+ const regions = [...findings.regions].sort((a, b) => a.start - b.start);
155
+ regions.forEach((region, index) => {
156
+ checkAddress(region.start, "region start");
157
+ checkAddress(region.end, "region end");
158
+ if (region.end < region.start) invalid(`the region ${hex(region.start)}-${hex(region.end)} ends before it starts.`);
159
+ if (!covering(region.start, region.end)) invalid(`the region ${hex(region.start)}-${hex(region.end)} is not inside the coverage.`);
160
+ if (!(REGION_TYPES as readonly string[]).includes(region.type)) invalid(`the region type ${region.type} is not known.`);
161
+ const previous = regions[index - 1];
162
+ if (previous !== undefined && region.start <= previous.end) invalid(`the regions at ${hex(previous.start)} and ${hex(region.start)} overlap.`);
163
+ });
164
+ for (const reference of findings.references) {
165
+ checkAddress(reference.from, "reference source");
166
+ checkAddress(reference.to, "reference target");
167
+ if (!covering(reference.from, reference.from)) invalid(`the reference from ${hex(reference.from)} is outside the coverage.`);
168
+ if (!(REFERENCE_KINDS as readonly string[]).includes(reference.kind)) invalid(`the reference kind ${reference.kind} is not known.`);
169
+ }
170
+ return coverage;
171
+ }
172
+
173
+ function ownSymbols(db: DatabaseSync, analyzer: Analyzer, range: AddressRange): SymbolRow[] {
174
+ return db
175
+ .prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE origin = ? AND address BETWEEN ? AND ? ORDER BY address`)
176
+ .all(analyzer, range.start, range.end)
177
+ .map((row) => toSymbol(row as Record<string, unknown>));
178
+ }
179
+
180
+ function ownReferences(db: DatabaseSync, analyzer: Analyzer, range: AddressRange): ReferenceRow[] {
181
+ return db
182
+ .prepare(`SELECT ${REFERENCE_COLUMNS} FROM current_references WHERE origin = ? AND from_address BETWEEN ? AND ? ORDER BY from_address`)
183
+ .all(analyzer, range.start, range.end)
184
+ .map((row) => toReference(row as Record<string, unknown>));
185
+ }
186
+
187
+ function importSymbols(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
188
+ const { analyzer } = findings;
189
+ const wanted = new Map(findings.symbols.map((symbol) => [symbol.address, symbol]));
190
+ const settled = new Set<number>();
191
+ const replaced = new Set<number>();
192
+ // Close first, so a name can move between addresses within one import.
193
+ for (const range of coverage) {
194
+ for (const own of ownSymbols(db, analyzer, range)) {
195
+ const finding = wanted.get(own.address);
196
+ if (finding === undefined) {
197
+ if (!findings.authoritative.symbols) continue;
198
+ change.close("symbols", "address = ?", own.address);
199
+ result.symbols.retired++;
200
+ } else if (finding.name === own.name && finding.kind === own.kind) {
201
+ settled.add(own.address);
202
+ result.symbols.unchanged++;
203
+ } else {
204
+ change.close("symbols", "address = ?", own.address);
205
+ replaced.add(own.address);
206
+ }
207
+ }
208
+ }
209
+ for (const finding of findings.symbols) {
210
+ if (settled.has(finding.address)) continue;
211
+ const current = symbolAt(db, finding.address);
212
+ if (current !== undefined) {
213
+ // A semantic or other-analyzer row stays; only a code/data disagreement matters.
214
+ if (isCode(current.kind) !== isCode(finding.kind)) {
215
+ result.conflicts.push({ category: "symbol", address: finding.address, reason: "kind", current, finding: { name: finding.name, kind: finding.kind } });
216
+ }
217
+ continue;
218
+ }
219
+ const owner = symbolNamed(db, finding.name);
220
+ if (owner?.origin === analyzer) {
221
+ // The analyzer moved its own name: its newer finding replaces its own older row.
222
+ change.close("symbols", "address = ?", owner.address);
223
+ result.symbols.retired++;
224
+ } else if (owner !== undefined) {
225
+ result.conflicts.push({ category: "symbol", address: finding.address, reason: "name-taken", current: owner, finding: { name: finding.name, kind: finding.kind } });
226
+ if (replaced.has(finding.address)) result.symbols.retired++;
227
+ continue;
228
+ }
229
+ change.insertSymbol({ address: finding.address, name: finding.name, kind: finding.kind, origin: analyzer });
230
+ if (replaced.has(finding.address)) result.symbols.changed++;
231
+ else result.symbols.added++;
232
+ }
233
+ }
234
+
235
+ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
236
+ const { analyzer } = findings;
237
+ const conflictWith = (row: RegionRow, region: { start: number; end: number; type: RegionType }) => {
238
+ if (isCode(row.type) === isCode(region.type)) return;
239
+ result.conflicts.push({
240
+ category: "region",
241
+ start: Math.max(row.start, region.start),
242
+ end: Math.min(row.end, region.end),
243
+ current: { type: row.type, origin: row.origin },
244
+ finding: { type: region.type },
245
+ });
246
+ };
247
+
248
+ if (!findings.authoritative.regions) {
249
+ // Retire nothing the snapshot leaves out. Fill addresses that no region covers yet, and
250
+ // give the analyzer's own regions under a finding the type that the analyzer finds now.
251
+ for (const region of findings.regions) {
252
+ const current = regionsOverlapping(db, region.start, region.end);
253
+ const protectedRows = current.filter((row) => row.origin !== analyzer);
254
+ for (const row of protectedRows) conflictWith(row, region);
255
+ const own = current.filter((row) => row.origin === analyzer);
256
+ const kept = own.filter((row) => row.type === region.type);
257
+ result.regions.unchanged += kept.length;
258
+ for (const row of own.filter((candidate) => candidate.type !== region.type)) {
259
+ // Close the row and keep its parts outside the finding.
260
+ change.close("regions", "start_address = ? AND end_address = ? AND origin = ?", row.start, row.end, analyzer);
261
+ if (row.start < region.start) change.insertRegion({ start: row.start, end: region.start - 1, type: row.type, origin: analyzer });
262
+ if (row.end > region.end) change.insertRegion({ start: region.end + 1, end: row.end, type: row.type, origin: analyzer });
263
+ result.regions.changed++;
264
+ }
265
+ const empty = subtract(region, current);
266
+ for (const piece of subtract(region, [...protectedRows, ...kept])) {
267
+ change.insertRegion({ ...piece, type: region.type, origin: analyzer });
268
+ }
269
+ result.regions.added += empty.length;
270
+ }
271
+ return;
272
+ }
273
+
274
+ for (const range of coverage) {
275
+ const current = regionsOverlapping(db, range.start, range.end);
276
+ const protectedRows = current.filter((row) => row.origin !== analyzer);
277
+ const own = current.filter((row) => row.origin === analyzer);
278
+ // What the analyzer may own here: its regions minus protected rows.
279
+ const desired = new Map<string, { start: number; end: number; type: RegionType }>();
280
+ for (const region of findings.regions.filter((candidate) => candidate.start >= range.start && candidate.end <= range.end)) {
281
+ for (const row of protectedRows) if (row.start <= region.end && row.end >= region.start) conflictWith(row, region);
282
+ for (const piece of subtract(region, protectedRows)) desired.set(`${piece.start}-${piece.end}`, { ...piece, type: region.type });
283
+ }
284
+ const retiredRanges = new Set<string>();
285
+ for (const row of own) {
286
+ const inside = { start: Math.max(row.start, range.start), end: Math.min(row.end, range.end) };
287
+ const key = `${inside.start}-${inside.end}`;
288
+ if (desired.get(key)?.type === row.type) {
289
+ desired.delete(key);
290
+ result.regions.unchanged++;
291
+ continue;
292
+ }
293
+ // Close the row and keep its parts outside the coverage.
294
+ change.close("regions", "start_address = ? AND end_address = ? AND origin = ?", row.start, row.end, analyzer);
295
+ if (row.start < range.start) change.insertRegion({ start: row.start, end: range.start - 1, type: row.type, origin: analyzer });
296
+ if (row.end > range.end) change.insertRegion({ start: range.end + 1, end: row.end, type: row.type, origin: analyzer });
297
+ retiredRanges.add(key);
298
+ }
299
+ for (const [key, piece] of desired) {
300
+ change.insertRegion({ ...piece, origin: analyzer });
301
+ if (retiredRanges.delete(key)) result.regions.changed++;
302
+ else result.regions.added++;
303
+ }
304
+ result.regions.retired += retiredRanges.size;
305
+ }
306
+ }
307
+
308
+ function importReferences(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
309
+ const { analyzer } = findings;
310
+ const key = (reference: { from: number; to: number; kind: ReferenceKind }) => `${reference.from}|${reference.to}|${reference.kind}`;
311
+ const wanted = new Map(findings.references.map((reference) => [key(reference), reference]));
312
+ for (const range of coverage) {
313
+ for (const own of ownReferences(db, analyzer, range)) {
314
+ if (wanted.has(key(own))) continue;
315
+ if (!findings.authoritative.references) continue;
316
+ change.close('"references"', "from_address = ? AND to_address = ? AND kind = ? AND origin = ?", own.from, own.to, own.kind, analyzer);
317
+ result.references.retired++;
318
+ }
319
+ }
320
+ const existing = db.prepare('SELECT origin FROM current_references WHERE from_address = ? AND to_address = ? AND kind = ?');
321
+ for (const reference of wanted.values()) {
322
+ const rows = existing.all(reference.from, reference.to, reference.kind) as Array<{ origin: Origin }>;
323
+ if (rows.some((row) => row.origin === analyzer)) result.references.unchanged++;
324
+ // The same fact from the user, the LLM or another analyzer already holds.
325
+ else if (rows.length === 0) {
326
+ change.insertReference({ ...reference, origin: analyzer });
327
+ result.references.added++;
328
+ }
329
+ }
330
+ }
331
+
332
+ /** Imports one analyzer result as one revision. Nothing is written when validation fails. */
333
+ export function importFindings(db: DatabaseSync, findings: NormalizedFindings, context: ImportContext = {}): ImportResult {
334
+ const coverage = validateFindings(findings);
335
+ return transaction(db, () => {
336
+ const change = new Change(db, { origin: findings.analyzer, ...context }, `import-${findings.analyzer}`);
337
+ const result: ImportResult = { revision: null, symbols: counts(), regions: counts(), references: counts(), conflicts: [] };
338
+ importSymbols(db, change, findings, coverage, result);
339
+ importRegions(db, change, findings, coverage, result);
340
+ importReferences(db, change, findings, coverage, result);
341
+ result.revision = change.revision;
342
+ return result;
343
+ });
344
+ }
@@ -0,0 +1,191 @@
1
+ // Current-state knowledge queries. A project without a database
2
+ // reads as empty knowledge.
3
+
4
+ import type { DatabaseSync } from "node:sqlite";
5
+
6
+ import type { CommentPlacement, Origin, ReferenceKind, RegionType, SymbolKind } from "./schema.ts";
7
+
8
+ export interface SymbolRow {
9
+ address: number;
10
+ name: string;
11
+ kind: SymbolKind;
12
+ origin: Origin;
13
+ revision: number;
14
+ }
15
+
16
+ export interface RegionRow {
17
+ start: number;
18
+ end: number;
19
+ type: RegionType;
20
+ origin: Origin;
21
+ revision: number;
22
+ }
23
+
24
+ export interface CommentRow {
25
+ address: number;
26
+ placement: CommentPlacement;
27
+ text: string;
28
+ origin: Origin;
29
+ revision: number;
30
+ }
31
+
32
+ export interface ReferenceRow {
33
+ from: number;
34
+ to: number;
35
+ kind: ReferenceKind;
36
+ origin: Origin;
37
+ revision: number;
38
+ }
39
+
40
+ type Raw = Record<string, unknown>;
41
+
42
+ export const SYMBOL_COLUMNS = "address, name, kind, origin, valid_from_revision";
43
+ export const REGION_COLUMNS = "start_address, end_address, type, origin, valid_from_revision";
44
+ export const COMMENT_COLUMNS = "address, placement, text, origin, valid_from_revision";
45
+ export const REFERENCE_COLUMNS = "from_address, to_address, kind, origin, valid_from_revision";
46
+
47
+ export function toSymbol(row: Raw): SymbolRow {
48
+ return { address: row.address as number, name: row.name as string, kind: row.kind as SymbolKind, origin: row.origin as Origin, revision: row.valid_from_revision as number };
49
+ }
50
+
51
+ export function toRegion(row: Raw): RegionRow {
52
+ return { start: row.start_address as number, end: row.end_address as number, type: row.type as RegionType, origin: row.origin as Origin, revision: row.valid_from_revision as number };
53
+ }
54
+
55
+ export function toComment(row: Raw): CommentRow {
56
+ return { address: row.address as number, placement: row.placement as CommentPlacement, text: row.text as string, origin: row.origin as Origin, revision: row.valid_from_revision as number };
57
+ }
58
+
59
+ export function toReference(row: Raw): ReferenceRow {
60
+ return { from: row.from_address as number, to: row.to_address as number, kind: row.kind as ReferenceKind, origin: row.origin as Origin, revision: row.valid_from_revision as number };
61
+ }
62
+
63
+ export function currentRevision(db: DatabaseSync | undefined): number {
64
+ if (db === undefined) return 0;
65
+ return Number((db.prepare("SELECT value FROM meta WHERE key = 'current_revision'").get() as { value: string }).value);
66
+ }
67
+
68
+ export function symbolAt(db: DatabaseSync | undefined, address: number): SymbolRow | undefined {
69
+ const row = db?.prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE address = ?`).get(address) as Raw | undefined;
70
+ return row === undefined ? undefined : toSymbol(row);
71
+ }
72
+
73
+ export function symbolNamed(db: DatabaseSync | undefined, name: string): SymbolRow | undefined {
74
+ const row = db?.prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE name = ?`).get(name) as Raw | undefined;
75
+ return row === undefined ? undefined : toSymbol(row);
76
+ }
77
+
78
+ /** Current regions that share at least one address with [start, end], in address order. */
79
+ export function regionsOverlapping(db: DatabaseSync | undefined, start: number, end: number): RegionRow[] {
80
+ if (db === undefined) return [];
81
+ return db
82
+ .prepare(`SELECT ${REGION_COLUMNS} FROM current_regions WHERE start_address <= ? AND end_address >= ? ORDER BY start_address`)
83
+ .all(end, start)
84
+ .map((row) => toRegion(row as Raw));
85
+ }
86
+
87
+ export function commentsAt(db: DatabaseSync | undefined, address: number): CommentRow[] {
88
+ if (db === undefined) return [];
89
+ return db
90
+ .prepare(`SELECT ${COMMENT_COLUMNS} FROM current_comments WHERE address = ? ORDER BY placement`)
91
+ .all(address)
92
+ .map((row) => toComment(row as Raw));
93
+ }
94
+
95
+ export function referencesFrom(db: DatabaseSync | undefined, address: number): ReferenceRow[] {
96
+ if (db === undefined) return [];
97
+ return db
98
+ .prepare(`SELECT ${REFERENCE_COLUMNS} FROM current_references WHERE from_address = ? ORDER BY to_address, kind`)
99
+ .all(address)
100
+ .map((row) => toReference(row as Raw));
101
+ }
102
+
103
+ export function referencesTo(db: DatabaseSync | undefined, address: number): ReferenceRow[] {
104
+ if (db === undefined) return [];
105
+ return db
106
+ .prepare(`SELECT ${REFERENCE_COLUMNS} FROM current_references WHERE to_address = ? ORDER BY from_address, kind`)
107
+ .all(address)
108
+ .map((row) => toReference(row as Raw));
109
+ }
110
+
111
+ /** Everything currently known at one address: the main lookup for skills. */
112
+ export interface AddressKnowledge {
113
+ address: number;
114
+ symbol?: SymbolRow;
115
+ /** The current region that contains the address. */
116
+ region?: RegionRow;
117
+ comments: CommentRow[];
118
+ referencesFrom: ReferenceRow[];
119
+ referencesTo: ReferenceRow[];
120
+ revision: number;
121
+ }
122
+
123
+ export function at(db: DatabaseSync | undefined, address: number): AddressKnowledge {
124
+ const result: AddressKnowledge = {
125
+ address,
126
+ comments: commentsAt(db, address),
127
+ referencesFrom: referencesFrom(db, address),
128
+ referencesTo: referencesTo(db, address),
129
+ revision: currentRevision(db),
130
+ };
131
+ const symbol = symbolAt(db, address);
132
+ if (symbol !== undefined) result.symbol = symbol;
133
+ const [region] = regionsOverlapping(db, address, address);
134
+ if (region !== undefined) result.region = region;
135
+ return result;
136
+ }
137
+
138
+ export interface Range {
139
+ start: number;
140
+ end: number;
141
+ }
142
+
143
+ const ALL: Range = { start: 0x0000, end: 0xffff };
144
+
145
+ export function listSymbols(db: DatabaseSync | undefined, range: Range = ALL, kind?: SymbolKind): SymbolRow[] {
146
+ if (db === undefined) return [];
147
+ const rows =
148
+ kind === undefined
149
+ ? db.prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE address BETWEEN ? AND ? ORDER BY address`).all(range.start, range.end)
150
+ : db.prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE address BETWEEN ? AND ? AND kind = ? ORDER BY address`).all(range.start, range.end, kind);
151
+ return rows.map((row) => toSymbol(row as Raw));
152
+ }
153
+
154
+ export function listRegions(db: DatabaseSync | undefined, range: Range = ALL, type?: RegionType): RegionRow[] {
155
+ return regionsOverlapping(db, range.start, range.end).filter((region) => type === undefined || region.type === type);
156
+ }
157
+
158
+ export function listComments(db: DatabaseSync | undefined, range: Range = ALL): CommentRow[] {
159
+ if (db === undefined) return [];
160
+ return db
161
+ .prepare(`SELECT ${COMMENT_COLUMNS} FROM current_comments WHERE address BETWEEN ? AND ? ORDER BY address, placement`)
162
+ .all(range.start, range.end)
163
+ .map((row) => toComment(row as Raw));
164
+ }
165
+
166
+ /** Current references whose source or target lies in the range. */
167
+ export function listReferences(db: DatabaseSync | undefined, range: Range = ALL, kind?: ReferenceKind): ReferenceRow[] {
168
+ if (db === undefined) return [];
169
+ return db
170
+ .prepare(
171
+ `SELECT ${REFERENCE_COLUMNS} FROM current_references WHERE (from_address BETWEEN ? AND ? OR to_address BETWEEN ? AND ?) ORDER BY from_address, to_address, kind`,
172
+ )
173
+ .all(range.start, range.end, range.start, range.end)
174
+ .map((row) => toReference(row as Raw))
175
+ .filter((reference) => kind === undefined || reference.kind === kind);
176
+ }
177
+
178
+ /** Case-insensitive text search in current symbol names and comment texts. */
179
+ export function search(db: DatabaseSync | undefined, text: string, limit = 50): { symbols: SymbolRow[]; comments: CommentRow[] } {
180
+ if (db === undefined || text.length === 0) return { symbols: [], comments: [] };
181
+ const pattern = `%${text.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
182
+ const symbols = db
183
+ .prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE name LIKE ? ESCAPE '\\' ORDER BY address LIMIT ?`)
184
+ .all(pattern, limit)
185
+ .map((row) => toSymbol(row as Raw));
186
+ const comments = db
187
+ .prepare(`SELECT ${COMMENT_COLUMNS} FROM current_comments WHERE text LIKE ? ESCAPE '\\' ORDER BY address, placement LIMIT ?`)
188
+ .all(pattern, limit)
189
+ .map((row) => toComment(row as Raw));
190
+ return { symbols, comments };
191
+ }
@@ -0,0 +1,140 @@
1
+ // The knowledge.db schema: one version, created in an empty database.
2
+
3
+ import type { DatabaseSync } from "node:sqlite";
4
+
5
+ import { REFERENCE_KINDS, type ReferenceKind } from "../c64.ts";
6
+ import { KnowledgeError } from "./database.ts";
7
+
8
+ export const ORIGINS = ["user", "llm", "dxa", "ghidra"] as const;
9
+ export type Origin = (typeof ORIGINS)[number];
10
+ export const SYMBOL_KINDS = ["label", "routine", "variable", "data", "vector"] as const;
11
+ export type SymbolKind = (typeof SYMBOL_KINDS)[number];
12
+ export const REGION_TYPES = ["code", "bytes", "words", "addresses", "addresses-split", "petscii", "screen", "bitmap", "sprite"] as const;
13
+ export type RegionType = (typeof REGION_TYPES)[number];
14
+ export const COMMENT_PLACEMENTS = ["line", "side"] as const;
15
+ export type CommentPlacement = (typeof COMMENT_PLACEMENTS)[number];
16
+ export { REFERENCE_KINDS, type ReferenceKind };
17
+
18
+ const list = (values: readonly string[]) => values.map((value) => `'${value}'`).join(", ");
19
+
20
+ /** The schema version this build reads and writes. */
21
+ export const SCHEMA_VERSION = 1;
22
+
23
+ /**
24
+ * Creates the schema in an empty database. Knowledge rows are temporal: a
25
+ * change closes the current row (valid_to_revision) and inserts a new one in
26
+ * the same revision.
27
+ */
28
+ function createSchema(db: DatabaseSync): void {
29
+ db.exec(`
30
+ CREATE TABLE meta (
31
+ key TEXT PRIMARY KEY,
32
+ value TEXT NOT NULL
33
+ );
34
+ CREATE TABLE revisions (
35
+ id INTEGER PRIMARY KEY,
36
+ created_at TEXT NOT NULL,
37
+ origin TEXT NOT NULL CHECK (origin IN (${list(ORIGINS)})),
38
+ operation TEXT NOT NULL,
39
+ input_hash TEXT,
40
+ tool_version TEXT,
41
+ description TEXT
42
+ );
43
+ CREATE TABLE symbols (
44
+ id INTEGER PRIMARY KEY,
45
+ address INTEGER NOT NULL CHECK (address BETWEEN 0 AND 65535),
46
+ name TEXT NOT NULL CHECK (length(name) > 0),
47
+ kind TEXT NOT NULL CHECK (kind IN (${list(SYMBOL_KINDS)})),
48
+ origin TEXT NOT NULL CHECK (origin IN (${list(ORIGINS)})),
49
+ valid_from_revision INTEGER NOT NULL REFERENCES revisions (id),
50
+ valid_to_revision INTEGER REFERENCES revisions (id)
51
+ );
52
+ CREATE UNIQUE INDEX symbols_current_address ON symbols (address) WHERE valid_to_revision IS NULL;
53
+ CREATE UNIQUE INDEX symbols_current_name ON symbols (name) WHERE valid_to_revision IS NULL;
54
+ CREATE INDEX symbols_address ON symbols (address);
55
+ CREATE TABLE regions (
56
+ id INTEGER PRIMARY KEY,
57
+ start_address INTEGER NOT NULL CHECK (start_address BETWEEN 0 AND 65535),
58
+ end_address INTEGER NOT NULL CHECK (end_address BETWEEN start_address AND 65535),
59
+ type TEXT NOT NULL CHECK (type IN (${list(REGION_TYPES)})),
60
+ origin TEXT NOT NULL CHECK (origin IN (${list(ORIGINS)})),
61
+ valid_from_revision INTEGER NOT NULL REFERENCES revisions (id),
62
+ valid_to_revision INTEGER REFERENCES revisions (id)
63
+ );
64
+ CREATE INDEX regions_start ON regions (start_address);
65
+ CREATE TABLE comments (
66
+ id INTEGER PRIMARY KEY,
67
+ address INTEGER NOT NULL CHECK (address BETWEEN 0 AND 65535),
68
+ placement TEXT NOT NULL CHECK (placement IN (${list(COMMENT_PLACEMENTS)})),
69
+ text TEXT NOT NULL CHECK (length(text) > 0),
70
+ origin TEXT NOT NULL CHECK (origin IN (${list(ORIGINS)})),
71
+ valid_from_revision INTEGER NOT NULL REFERENCES revisions (id),
72
+ valid_to_revision INTEGER REFERENCES revisions (id)
73
+ );
74
+ CREATE UNIQUE INDEX comments_current ON comments (address, placement) WHERE valid_to_revision IS NULL;
75
+ CREATE INDEX comments_address ON comments (address);
76
+ CREATE TABLE "references" (
77
+ id INTEGER PRIMARY KEY,
78
+ from_address INTEGER NOT NULL CHECK (from_address BETWEEN 0 AND 65535),
79
+ to_address INTEGER NOT NULL CHECK (to_address BETWEEN 0 AND 65535),
80
+ kind TEXT NOT NULL CHECK (kind IN (${list(REFERENCE_KINDS)})),
81
+ origin TEXT NOT NULL CHECK (origin IN (${list(ORIGINS)})),
82
+ valid_from_revision INTEGER NOT NULL REFERENCES revisions (id),
83
+ valid_to_revision INTEGER REFERENCES revisions (id)
84
+ );
85
+ CREATE UNIQUE INDEX references_current ON "references" (from_address, to_address, kind) WHERE valid_to_revision IS NULL;
86
+ CREATE INDEX references_from ON "references" (from_address);
87
+ CREATE INDEX references_to ON "references" (to_address);
88
+ CREATE VIEW current_symbols AS SELECT * FROM symbols WHERE valid_to_revision IS NULL;
89
+ CREATE VIEW current_regions AS SELECT * FROM regions WHERE valid_to_revision IS NULL;
90
+ CREATE VIEW current_comments AS SELECT * FROM comments WHERE valid_to_revision IS NULL;
91
+ CREATE VIEW current_references AS SELECT * FROM "references" WHERE valid_to_revision IS NULL;
92
+ INSERT INTO meta (key, value) VALUES ('current_revision', '0');
93
+ `);
94
+ }
95
+
96
+ export function schemaVersion(db: DatabaseSync): number {
97
+ const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
98
+ if (hasMeta === undefined) {
99
+ const tables = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type = 'table'").get() as { n: number };
100
+ if (tables.n > 0) throw new KnowledgeError("invalid-database", "The knowledge database has tables but no meta table; it is not a c64-re-tools database.");
101
+ return 0;
102
+ }
103
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as { value: unknown } | undefined;
104
+ if (row === undefined) throw new KnowledgeError("invalid-database", "The knowledge database has no schema version.");
105
+ const value = String(row.value);
106
+ if (!/^(0|[1-9][0-9]{0,8})$/.test(value)) throw new KnowledgeError("invalid-database", `The schema version of the knowledge database is not a whole number: ${JSON.stringify(value)}.`);
107
+ return Number(value);
108
+ }
109
+
110
+ /** Refuses a database with any schema version other than the one this build reads and writes. */
111
+ export function checkVersion(version: number): void {
112
+ if (version !== SCHEMA_VERSION) {
113
+ throw new KnowledgeError(
114
+ "unsupported-schema",
115
+ `The knowledge database has schema version ${version}. This c64-re-tools reads and writes only version ${SCHEMA_VERSION}. Use the c64-re-tools release that wrote it.`,
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Creates the schema in an empty database and refuses any other version. The
122
+ * version is read again under the write lock, so two processes that open a
123
+ * new database at the same time create the schema once.
124
+ */
125
+ export function ensureSchema(db: DatabaseSync): void {
126
+ if (schemaVersion(db) === 0) {
127
+ db.exec("BEGIN IMMEDIATE");
128
+ try {
129
+ if (schemaVersion(db) === 0) {
130
+ createSchema(db);
131
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
132
+ }
133
+ db.exec("COMMIT");
134
+ } catch (error) {
135
+ db.exec("ROLLBACK");
136
+ throw error;
137
+ }
138
+ }
139
+ checkVersion(schemaVersion(db));
140
+ }