@henols/c64-re-tools 2.0.0-rc.9 → 2.0.0

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 (117) hide show
  1. package/README.md +97 -116
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +22 -6
  4. package/skills/c64-assembler/scripts/assemble.ts +42 -14
  5. package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
  6. package/skills/c64-basic/SKILL.md +10 -1
  7. package/skills/c64-basic/scripts/basic.ts +3 -3
  8. package/skills/c64-disk/SKILL.md +13 -4
  9. package/skills/c64-disk/scripts/disk.ts +7 -7
  10. package/skills/c64-disk/scripts/{containers.ts → lib/containers.ts} +2 -2
  11. package/skills/c64-docs/SKILL.md +138 -0
  12. package/skills/c64-docs/references/page-types.md +141 -0
  13. package/skills/c64-docs/references/style.md +43 -0
  14. package/skills/c64-docs/scripts/generate.ts +109 -0
  15. package/skills/c64-docs/scripts/lib/links.ts +167 -0
  16. package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
  17. package/skills/c64-docs/scripts/lib/reference.ts +406 -0
  18. package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
  19. package/skills/c64-docs/scripts/link.ts +239 -0
  20. package/skills/c64-emulator/SKILL.md +16 -5
  21. package/skills/c64-knowledge/SKILL.md +140 -18
  22. package/skills/c64-knowledge/scripts/knowledge.ts +737 -112
  23. package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
  24. package/skills/c64-memory-map/SKILL.md +11 -2
  25. package/skills/c64-memory-map/scripts/memmap.ts +2 -2
  26. package/skills/c64-provenance/SKILL.md +66 -11
  27. package/skills/c64-provenance/scripts/{compare.ts → lib/compare.ts} +3 -3
  28. package/skills/c64-provenance/scripts/lib/library.ts +584 -0
  29. package/skills/c64-provenance/scripts/{runtime.ts → lib/runtime.ts} +2 -2
  30. package/skills/c64-provenance/scripts/{separate.ts → lib/separate.ts} +3 -3
  31. package/skills/c64-provenance/scripts/provenance.ts +242 -40
  32. package/skills/c64-reverse-engineering/SKILL.md +51 -26
  33. package/skills/c64-source/SKILL.md +76 -28
  34. package/skills/c64-source/scripts/generate.ts +425 -127
  35. package/skills/c64-source/scripts/{loader.ts → lib/loader.ts} +84 -25
  36. package/skills/c64-source/scripts/lib/source.ts +1121 -0
  37. package/skills/c64-static-analysis/SKILL.md +35 -18
  38. package/skills/c64-static-analysis/scripts/analyze.ts +184 -55
  39. package/skills/c64-static-analysis/scripts/{coverage.ts → lib/coverage.ts} +19 -25
  40. package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +6 -2
  41. package/skills/c64-static-analysis/scripts/{idioms.ts → lib/idioms.ts} +21 -12
  42. package/skills/c64-static-analysis/scripts/{instructions.ts → lib/instructions.ts} +1 -1
  43. package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
  44. package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
  45. package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
  46. package/skills/c64-testing/SKILL.md +36 -7
  47. package/skills/c64-testing/references/scenario-format.md +12 -8
  48. package/skills/c64-testing/scripts/checklist.ts +3 -3
  49. package/skills/c64-testing/scripts/diff.ts +167 -0
  50. package/skills/c64-testing/scripts/lib/diff.ts +70 -0
  51. package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +45 -34
  52. package/skills/c64-testing/scripts/run.ts +73 -21
  53. package/skills/c64-testing/scripts/test.ts +5 -5
  54. package/skills/c64-unpacker/SKILL.md +23 -9
  55. package/skills/c64-unpacker/scripts/{evidence.ts → lib/evidence.ts} +1 -1
  56. package/skills/c64-unpacker/scripts/{loads.ts → lib/loads.ts} +4 -4
  57. package/skills/c64-unpacker/scripts/{machine.ts → lib/machine.ts} +4 -4
  58. package/skills/c64-unpacker/scripts/{stages.ts → lib/stages.ts} +17 -5
  59. package/skills/c64-unpacker/scripts/unpack.ts +41 -24
  60. package/src/c64.ts +103 -5
  61. package/src/cli/cleanup.ts +1 -1
  62. package/src/cli/main.ts +7 -4
  63. package/src/cli/status.ts +70 -3
  64. package/src/graphics.ts +158 -0
  65. package/src/host/main.ts +18 -4
  66. package/src/host/server.ts +10 -3
  67. package/src/host/tools/petcat.ts +1 -1
  68. package/src/host/tools/status.ts +1 -1
  69. package/src/host/vice/adapter.ts +4 -3
  70. package/src/host/vice/process.ts +2 -2
  71. package/src/host/vice/screen.ts +1 -42
  72. package/src/host/vice/session.ts +196 -23
  73. package/src/host-client/autostart.ts +1 -1
  74. package/src/host-client/connect.ts +1 -1
  75. package/src/host-client/coverage.ts +2 -29
  76. package/src/host-client/image.ts +66 -0
  77. package/src/host-client/steps.ts +9 -4
  78. package/src/host-client/tools.ts +1 -0
  79. package/src/host-client/transfer.ts +39 -11
  80. package/src/host-client/vice-session.ts +33 -16
  81. package/src/knowledge/conflicts.ts +161 -0
  82. package/src/knowledge/database.ts +150 -38
  83. package/src/knowledge/expression.ts +209 -0
  84. package/src/knowledge/graph.ts +104 -0
  85. package/src/knowledge/history.ts +70 -22
  86. package/src/knowledge/import.ts +64 -55
  87. package/src/knowledge/join.ts +211 -0
  88. package/src/knowledge/program.ts +266 -0
  89. package/src/knowledge/read.ts +258 -27
  90. package/src/knowledge/schema.ts +199 -75
  91. package/src/knowledge/text.ts +415 -0
  92. package/src/knowledge/write.ts +740 -57
  93. package/src/mcp/main.ts +1 -1
  94. package/src/mcp/output.ts +15 -8
  95. package/src/mcp/server.ts +3 -1
  96. package/src/mcp/tools/debug.ts +97 -16
  97. package/src/mcp/tools/execution.ts +38 -5
  98. package/src/mcp/tools/machine.ts +11 -2
  99. package/src/mcp/tools/media.ts +18 -13
  100. package/src/mcp/tools/memory.ts +17 -8
  101. package/src/mcp/tools/video.ts +9 -9
  102. package/src/native/discover.ts +2 -2
  103. package/src/native/ghidra/analyze.ts +2 -2
  104. package/src/native/ghidra/index.ts +1 -1
  105. package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
  106. package/src/native/ghidra/scripts/C64Export.java +1 -1
  107. package/src/native/ghidra/scripts/C64Prepare.java +7 -1
  108. package/src/native/types.ts +14 -8
  109. package/{skills/c64-source/scripts → src}/opcodes.ts +75 -3
  110. package/src/platform.ts +70 -7
  111. package/src/project.ts +180 -3
  112. package/src/protocol/messages.ts +1 -1
  113. package/src/protocol/tools.ts +2 -16
  114. package/src/protocol/vice.ts +124 -15
  115. package/src/script.ts +46 -7
  116. package/skills/c64-assembler/scripts/compare.ts +0 -84
  117. package/skills/c64-source/scripts/source.ts +0 -595
@@ -6,53 +6,72 @@ import type { DatabaseSync } from "node:sqlite";
6
6
  import { KnowledgeError } from "./database.ts";
7
7
  import {
8
8
  COMMENT_COLUMNS,
9
+ MACRO_COLUMNS,
10
+ MACRO_USE_COLUMNS,
9
11
  MODULE_COLUMNS,
12
+ OPERAND_COLUMNS,
13
+ PATCH_COLUMNS,
10
14
  REFERENCE_COLUMNS,
11
15
  REGION_COLUMNS,
16
+ resolveRevision,
12
17
  SUPPRESSION_COLUMNS,
13
18
  SYMBOL_COLUMNS,
14
19
  toComment,
20
+ toMacro,
21
+ toMacroUse,
15
22
  toModule,
23
+ toOperand,
24
+ toPatch,
16
25
  toReference,
17
26
  toRegion,
18
27
  toSuppression,
19
28
  toSymbol,
20
29
  type CommentRow,
30
+ type MacroRow,
31
+ type MacroUseRow,
21
32
  type ModuleRow,
33
+ type OperandRow,
34
+ type PatchRow,
22
35
  type ReferenceRow,
23
36
  type RegionRow,
24
37
  type SuppressionRow,
25
38
  type SymbolRow,
26
39
  } from "./read.ts";
27
40
  import { DEFAULT_SPACE, type EvidenceKind, type Origin, type Space } from "./schema.ts";
41
+ import { compareText } from "./text.ts";
28
42
  import type { Evidence } from "./write.ts";
29
43
 
30
44
  export interface Revision {
31
- id: number;
45
+ id: string;
32
46
  createdAt: string;
33
47
  origin: Origin;
34
48
  operation: string;
35
49
  description?: string;
36
50
  inputHash?: string;
37
51
  toolVersion?: string;
38
- /** What the change rests on (D42). */
52
+ /** What the change rests on. */
39
53
  evidence?: Evidence[];
40
54
  }
41
55
 
42
56
  /** One knowledge row with its validity: from its first revision until the revision that closed it. */
43
57
  export type HistoryEntry =
44
- | { entity: "symbol"; row: SymbolRow; from: number; to?: number }
45
- | { entity: "region"; row: RegionRow; from: number; to?: number }
46
- | { entity: "comment"; row: CommentRow; from: number; to?: number }
47
- | { entity: "reference"; row: ReferenceRow; from: number; to?: number }
48
- | { entity: "suppression"; row: SuppressionRow; from: number; to?: number }
49
- | { entity: "module"; row: ModuleRow; from: number; to?: number };
58
+ | { entity: "symbol"; row: SymbolRow; from: string; to?: string }
59
+ | { entity: "analyzer-name"; row: SymbolRow; from: string; to?: string }
60
+ | { entity: "region"; row: RegionRow; from: string; to?: string }
61
+ | { entity: "comment"; row: CommentRow; from: string; to?: string }
62
+ | { entity: "reference"; row: ReferenceRow; from: string; to?: string }
63
+ | { entity: "suppression"; row: SuppressionRow; from: string; to?: string }
64
+ | { entity: "module"; row: ModuleRow; from: string; to?: string }
65
+ | { entity: "operand"; row: OperandRow; from: string; to?: string }
66
+ | { entity: "patch"; row: PatchRow; from: string; to?: string }
67
+ | { entity: "macro"; row: MacroRow; from: string; to?: string }
68
+ | { entity: "macro-use"; row: MacroUseRow; from: string; to?: string };
50
69
 
51
70
  type Raw = Record<string, unknown>;
52
71
 
53
72
  function toRevision(row: Raw): Revision {
54
73
  const revision: Revision = {
55
- id: row.id as number,
74
+ id: row.id as string,
56
75
  createdAt: row.created_at as string,
57
76
  origin: row.origin as Origin,
58
77
  operation: row.operation as string,
@@ -64,13 +83,14 @@ function toRevision(row: Raw): Revision {
64
83
  }
65
84
 
66
85
  function entry<E extends HistoryEntry["entity"]>(entity: E, row: unknown, raw: Raw): HistoryEntry {
67
- const result = { entity, row, from: raw.valid_from_revision as number } as HistoryEntry;
68
- if (raw.valid_to_revision !== null) result.to = raw.valid_to_revision as number;
86
+ const result = { entity, row, from: raw.valid_from_revision as string } as HistoryEntry;
87
+ if (raw.valid_to_revision !== null) result.to = raw.valid_to_revision as string;
69
88
  return result;
70
89
  }
71
90
 
91
+ /** Revision ids sort in time order as text; a current row sorts after every closed one. */
72
92
  function bySequence(a: HistoryEntry, b: HistoryEntry): number {
73
- return a.from - b.from || (a.to ?? Infinity) - (b.to ?? Infinity);
93
+ return compareText(a.from, b.from) || compareText(a.to ?? "~", b.to ?? "~");
74
94
  }
75
95
 
76
96
  /** Every row, current or closed, that concerns one address of a space, oldest first. */
@@ -79,6 +99,7 @@ export function historyAt(db: DatabaseSync | undefined, address: number, space:
79
99
  const validity = ", valid_to_revision";
80
100
  const entries: HistoryEntry[] = [];
81
101
  for (const raw of db.prepare(`SELECT ${SYMBOL_COLUMNS}${validity} FROM symbols WHERE space = ? AND address = ?`).all(space, address) as Raw[]) entries.push(entry("symbol", toSymbol(raw), raw));
102
+ for (const raw of db.prepare(`SELECT ${SYMBOL_COLUMNS}${validity} FROM analyzer_names WHERE space = ? AND address = ?`).all(space, address) as Raw[]) entries.push(entry("analyzer-name", toSymbol(raw), raw));
82
103
  for (const raw of db.prepare(`SELECT ${REGION_COLUMNS}${validity} FROM regions WHERE space = ? AND start_address <= ? AND end_address >= ?`).all(space, address, address) as Raw[]) {
83
104
  entries.push(entry("region", toRegion(raw), raw));
84
105
  }
@@ -92,9 +113,24 @@ export function historyAt(db: DatabaseSync | undefined, address: number, space:
92
113
  entries.push(entry("suppression", toSuppression(raw), raw));
93
114
  }
94
115
  for (const raw of db.prepare(`SELECT ${MODULE_COLUMNS}${validity} FROM modules WHERE space = ? AND start_address = ?`).all(space, address) as Raw[]) entries.push(entry("module", toModule(raw), raw));
116
+ for (const raw of db.prepare(`SELECT ${OPERAND_COLUMNS}${validity} FROM operands WHERE space = ? AND address = ?`).all(space, address) as Raw[]) entries.push(entry("operand", toOperand(raw), raw));
117
+ for (const raw of db.prepare(`SELECT ${PATCH_COLUMNS}${validity} FROM patches WHERE space = ? AND start_address <= ? AND end_address >= ?`).all(space, address, address) as Raw[]) {
118
+ entries.push(entry("patch", toPatch(raw), raw));
119
+ }
120
+ for (const raw of db.prepare(`SELECT ${MACRO_USE_COLUMNS}${validity} FROM macro_uses WHERE space = ? AND start_address <= ? AND end_address >= ?`).all(space, address, address) as Raw[]) {
121
+ entries.push(entry("macro-use", toMacroUse(raw), raw));
122
+ }
95
123
  return entries.sort(bySequence);
96
124
  }
97
125
 
126
+ /** Every row of a macro with this name, and every use of it, oldest first. */
127
+ export function historyOfMacro(db: DatabaseSync | undefined, name: string, space: Space = DEFAULT_SPACE): HistoryEntry[] {
128
+ if (db === undefined) return [];
129
+ const macros = (db.prepare(`SELECT ${MACRO_COLUMNS}, valid_to_revision FROM macros WHERE space = ? AND name = ?`).all(space, name) as Raw[]).map((raw) => entry("macro", toMacro(raw), raw));
130
+ const uses = (db.prepare(`SELECT ${MACRO_USE_COLUMNS}, valid_to_revision FROM macro_uses WHERE space = ? AND macro = ?`).all(space, name) as Raw[]).map((raw) => entry("macro-use", toMacroUse(raw), raw));
131
+ return [...macros, ...uses].sort(bySequence);
132
+ }
133
+
98
134
  /** Every symbol row of a space that ever had this name, oldest first: a name can move between addresses. */
99
135
  export function historyNamed(db: DatabaseSync | undefined, name: string, space: Space = DEFAULT_SPACE): HistoryEntry[] {
100
136
  if (db === undefined) return [];
@@ -104,7 +140,7 @@ export function historyNamed(db: DatabaseSync | undefined, name: string, space:
104
140
  }
105
141
 
106
142
  /** Why a revision was written and what it rests on: its description and its evidence, when it has them. */
107
- export function revisionNote(db: DatabaseSync | undefined, id: number): { reason?: string; evidence?: Evidence[] } {
143
+ export function revisionNote(db: DatabaseSync | undefined, id: string): { reason?: string; evidence?: Evidence[] } {
108
144
  const raw = db?.prepare("SELECT * FROM revisions WHERE id = ?").get(id) as Raw | undefined;
109
145
  if (db === undefined || raw === undefined) return {};
110
146
  const { description, evidence } = withEvidence(db, toRevision(raw));
@@ -113,20 +149,25 @@ export function revisionNote(db: DatabaseSync | undefined, id: number): { reason
113
149
 
114
150
  /** The revision with its evidence, when it has any. */
115
151
  function withEvidence(db: DatabaseSync, revision: Revision): Revision {
116
- const rows = db.prepare("SELECT kind, ref, sha256 FROM evidence WHERE revision = ? ORDER BY id").all(revision.id) as Raw[];
152
+ const rows = db.prepare("SELECT kind, ref, sha256, space FROM evidence WHERE revision = ? ORDER BY id").all(revision.id) as Raw[];
117
153
  if (rows.length === 0) return revision;
118
- const evidence = rows.map((row): Evidence => ({ kind: row.kind as EvidenceKind, ref: row.ref as string, ...(row.sha256 === null ? {} : { sha256: row.sha256 as string }) }));
154
+ const evidence = rows.map((row): Evidence => ({
155
+ kind: row.kind as EvidenceKind,
156
+ ref: row.ref as string,
157
+ ...(row.sha256 === null ? {} : { sha256: row.sha256 as string }),
158
+ ...(row.space === null ? {} : { space: row.space as Space }),
159
+ }));
119
160
  return { ...revision, evidence };
120
161
  }
121
162
 
122
163
  /** Revisions, newest first; with `before`, only revisions older than it. */
123
- export function revisions(db: DatabaseSync | undefined, options: { limit?: number; before?: number } = {}): Revision[] {
164
+ export function revisions(db: DatabaseSync | undefined, options: { limit?: number; before?: string } = {}): Revision[] {
124
165
  if (db === undefined) return [];
125
- return (
126
- db
127
- .prepare("SELECT * FROM revisions WHERE id < ? ORDER BY id DESC LIMIT ?")
128
- .all(options.before ?? Number.MAX_SAFE_INTEGER, options.limit ?? 50) as Raw[]
129
- ).map((row) => withEvidence(db, toRevision(row)));
166
+ const rows =
167
+ options.before === undefined
168
+ ? db.prepare("SELECT * FROM revisions ORDER BY id DESC LIMIT ?").all(options.limit ?? 50)
169
+ : db.prepare("SELECT * FROM revisions WHERE id < ? ORDER BY id DESC LIMIT ?").all(resolveRevision(db, options.before), options.limit ?? 50);
170
+ return (rows as Raw[]).map((row) => withEvidence(db, toRevision(row)));
130
171
  }
131
172
 
132
173
  export interface RevisionChanges {
@@ -138,16 +179,22 @@ export interface RevisionChanges {
138
179
  }
139
180
 
140
181
  /** One revision and exactly what it changed. */
141
- export function revision(db: DatabaseSync | undefined, id: number): RevisionChanges {
182
+ export function revision(db: DatabaseSync | undefined, text: string): RevisionChanges {
183
+ const id = resolveRevision(db, text);
142
184
  const raw = db?.prepare("SELECT * FROM revisions WHERE id = ?").get(id) as Raw | undefined;
143
185
  if (db === undefined || raw === undefined) throw new KnowledgeError("not-found", `There is no revision ${id}.`);
144
186
  const tables = [
145
187
  ["symbol", "symbols", SYMBOL_COLUMNS, toSymbol],
188
+ ["analyzer-name", "analyzer_names", SYMBOL_COLUMNS, toSymbol],
146
189
  ["region", "regions", REGION_COLUMNS, toRegion],
147
190
  ["comment", "comments", COMMENT_COLUMNS, toComment],
148
191
  ["reference", '"references"', REFERENCE_COLUMNS, toReference],
149
192
  ["suppression", "suppressions", SUPPRESSION_COLUMNS, toSuppression],
150
193
  ["module", "modules", MODULE_COLUMNS, toModule],
194
+ ["operand", "operands", OPERAND_COLUMNS, toOperand],
195
+ ["patch", "patches", PATCH_COLUMNS, toPatch],
196
+ ["macro", "macros", MACRO_COLUMNS, toMacro],
197
+ ["macro-use", "macro_uses", MACRO_USE_COLUMNS, toMacroUse],
151
198
  ] as const;
152
199
  const added: HistoryEntry[] = [];
153
200
  const closed: HistoryEntry[] = [];
@@ -161,3 +208,4 @@ export function revision(db: DatabaseSync | undefined, id: number): RevisionChan
161
208
  }
162
209
  return { revision: withEvidence(db, toRevision(raw)), added, closed };
163
210
  }
211
+
@@ -7,7 +7,7 @@
7
7
  // another analyzer are never replaced: a finding that contradicts one is
8
8
  // reported as a conflict. Code against data is a contradiction; a different
9
9
  // name or a finer data type for the same kind of memory is not. One exception
10
- // (D58): where a run executed an instruction start, a code finding replaces
10
+ //: where a run executed an instruction start, a code finding replaces
11
11
  // the data of another analyzer, never of the user, the LLM or a heuristic.
12
12
 
13
13
  import { createHash } from "node:crypto";
@@ -15,21 +15,20 @@ import type { DatabaseSync } from "node:sqlite";
15
15
 
16
16
  import { KnowledgeError, transaction } from "./database.ts";
17
17
  import {
18
+ analyzerNamed,
18
19
  REFERENCE_COLUMNS,
19
20
  referenceSuppressed,
20
21
  regionsOverlapping,
21
22
  suppressionsOverlapping,
22
23
  SYMBOL_COLUMNS,
23
24
  symbolAt,
24
- symbolNamed,
25
- symbolSuppressed,
26
25
  toReference,
27
26
  toSymbol,
28
27
  type ReferenceRow,
29
28
  type RegionRow,
30
29
  type SymbolRow,
31
30
  } from "./read.ts";
32
- import { CODE_REGION_TYPES, DEFAULT_SPACE, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
31
+ import { CODE_REGION_TYPES, DEFAULT_SPACE, POINTER_KIND, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
33
32
  import { Change, checkAddress, checkSpace, hex, subtract, SYMBOL_NAME, type AddressRange, type Evidence } from "./write.ts";
34
33
 
35
34
  export type { AddressRange };
@@ -39,7 +38,7 @@ export type Analyzer = (typeof ANALYZERS)[number];
39
38
 
40
39
  export interface NormalizedFindings {
41
40
  analyzer: Analyzer;
42
- /** The address space that the analyzed image belongs to (D53); the C64 when absent. */
41
+ /** The address space that the analyzed image belongs to; the C64 when absent. */
43
42
  space?: Space;
44
43
  /** The address ranges the analyzer examined; it makes claims only there. */
45
44
  coverage: AddressRange[];
@@ -49,13 +48,13 @@ export interface NormalizedFindings {
49
48
  regions: Array<{ start: number; end: number; type: RegionType }>;
50
49
  /** Owned and covered by their source address. */
51
50
  references: Array<{ from: number; to: number; kind: ReferenceKind }>;
52
- /** True for an address where a coverage run executed an instruction start (D58). */
51
+ /** True for an address where a coverage run executed an instruction start. */
53
52
  executed?: (address: number) => boolean;
54
53
  }
55
54
 
56
55
  export interface ImportContext {
57
56
  /** The revision the caller read; the import is refused if knowledge changed since. */
58
- expectedRevision?: number;
57
+ expectedRevision?: string;
59
58
  description?: string;
60
59
  /** The SHA-256 of the analyzed image, as lowercase hex. */
61
60
  inputHash?: string;
@@ -86,13 +85,16 @@ export interface ImportCounts {
86
85
  changed: number;
87
86
  retired: number;
88
87
  unchanged: number;
89
- /** Findings at a place that a user/LLM removal blocks (D36); not written. */
88
+ /** Findings at a place that a user/LLM removal blocks; not written. */
90
89
  suppressed: number;
91
90
  }
92
91
 
93
92
  export interface ImportResult {
94
93
  /** The new revision, or null when nothing changed. */
95
- revision: number | null;
94
+ revision: string | null;
95
+ /** The analyzer names. */
96
+ names: ImportCounts;
97
+ /** The analyzer's own rows from an older version that the run closed in symbols: only `retired` counts. */
96
98
  symbols: ImportCounts;
97
99
  regions: ImportCounts;
98
100
  references: ImportCounts;
@@ -163,6 +165,7 @@ export function validateFindings(findings: NormalizedFindings): AddressRange[] {
163
165
  checkAddress(reference.to, "reference target");
164
166
  if (!covering(reference.from, reference.from)) invalid(`the reference from ${hex(reference.from)} is outside the coverage.`);
165
167
  if (!(REFERENCE_KINDS as readonly string[]).includes(reference.kind)) invalid(`the reference kind ${reference.kind} is not known.`);
168
+ if (reference.kind === POINTER_KIND) invalid(`only an operand expression writes a pointer reference, not an analyzer.`);
166
169
  }
167
170
  return coverage;
168
171
  }
@@ -181,68 +184,73 @@ function ownReferences(db: DatabaseSync, analyzer: Analyzer, range: AddressRange
181
184
  .map((row) => toReference(row as Record<string, unknown>));
182
185
  }
183
186
 
184
- function importSymbols(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
187
+ function ownNames(db: DatabaseSync, analyzer: Analyzer, range: AddressRange, space: Space): SymbolRow[] {
188
+ return db
189
+ .prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_analyzer_names WHERE space = ? AND origin = ? AND address BETWEEN ? AND ? ORDER BY address`)
190
+ .all(space, analyzer, range.start, range.end)
191
+ .map((row) => toSymbol(row as Record<string, unknown>));
192
+ }
193
+
194
+ /**
195
+ * The names of an analyzer are second names: they go to analyzer_names,
196
+ * one for each analyzer at an address, and never to symbols. A run replaces
197
+ * the analyzer's own names in its coverage, and closes the analyzer's old rows
198
+ * in symbols there (knowledge from an older version). A name that the user or the LLM
199
+ * gave the address already is not kept twice. A code or data kind that
200
+ * disagrees with the name of the address is a conflict.
201
+ */
202
+ function importNames(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
185
203
  const { analyzer } = findings;
186
204
  const space = findings.space ?? DEFAULT_SPACE;
205
+ for (const range of coverage) {
206
+ for (const own of ownSymbols(db, analyzer, range, space)) {
207
+ change.close("symbols", "space = ? AND address = ? AND origin = ?", space, own.address, analyzer);
208
+ result.symbols.retired++;
209
+ }
210
+ }
187
211
  const wanted = new Map(findings.symbols.map((symbol) => [symbol.address, symbol]));
188
212
  const settled = new Set<number>();
189
213
  const replaced = new Set<number>();
190
- // Close first, so a name can move between addresses within one import.
191
214
  for (const range of coverage) {
192
- for (const own of ownSymbols(db, analyzer, range, space)) {
215
+ for (const own of ownNames(db, analyzer, range, space)) {
193
216
  const finding = wanted.get(own.address);
194
- if (finding === undefined) {
195
- if (!findings.authoritative.symbols) continue;
196
- change.close("symbols", "space = ? AND address = ?", space, own.address);
197
- result.symbols.retired++;
198
- } else if (finding.name === own.name && finding.kind === own.kind) {
217
+ // A seeded run gives back the name of the user or the LLM: the analyzer's own name there stays.
218
+ const echo = finding !== undefined && symbolAt(db, own.address, space)?.name === finding.name;
219
+ if (echo || (finding !== undefined && finding.name === own.name && finding.kind === own.kind)) {
199
220
  settled.add(own.address);
200
- result.symbols.unchanged++;
201
- } else {
202
- change.close("symbols", "space = ? AND address = ?", space, own.address);
203
- replaced.add(own.address);
221
+ result.names.unchanged++;
222
+ continue;
204
223
  }
224
+ change.close("analyzer_names", "space = ? AND address = ? AND origin = ?", space, own.address, analyzer);
225
+ if (finding === undefined) result.names.retired++;
226
+ else replaced.add(own.address);
205
227
  }
206
228
  }
207
229
  for (const finding of findings.symbols) {
208
- if (settled.has(finding.address)) continue;
209
- if (symbolSuppressed(db, finding.address, space)) {
210
- result.symbols.suppressed++;
211
- continue;
230
+ const current = symbolAt(db, finding.address, space);
231
+ if (current !== undefined && isCode(current.kind) !== isCode(finding.kind)) {
232
+ result.conflicts.push({ category: "symbol", address: finding.address, reason: "kind", current, finding: { name: finding.name, kind: finding.kind } });
212
233
  }
213
- let current = symbolAt(db, finding.address, space);
214
- if (current !== undefined && isCode(finding.kind) && !isCode(current.kind) && YIELDS_TO_EXECUTED_CODE.has(current.origin) && findings.executed?.(finding.address) === true) {
215
- // A run executed this address: the data symbol of another analyzer gives way (D58).
216
- change.close("symbols", "space = ? AND address = ?", space, current.address);
217
- result.symbols.retired++;
218
- current = undefined;
219
- }
220
- if (current !== undefined) {
221
- // A semantic or other-analyzer row stays; only a code/data disagreement matters.
222
- if (isCode(current.kind) !== isCode(finding.kind)) {
223
- result.conflicts.push({ category: "symbol", address: finding.address, reason: "kind", current, finding: { name: finding.name, kind: finding.kind } });
224
- }
234
+ if (settled.has(finding.address)) continue;
235
+ if (current?.name === finding.name) {
236
+ // The analyzer only gives back the name of the address.
237
+ if (replaced.has(finding.address)) result.names.retired++;
225
238
  continue;
226
239
  }
227
- const owner = symbolNamed(db, finding.name, space);
228
- if (owner?.origin === analyzer) {
229
- // The analyzer moved its own name: its newer finding replaces its own older row.
230
- change.close("symbols", "space = ? AND address = ?", space, owner.address);
231
- result.symbols.retired++;
232
- } else if (owner !== undefined) {
233
- result.conflicts.push({ category: "symbol", address: finding.address, reason: "name-taken", current: owner, finding: { name: finding.name, kind: finding.kind } });
234
- if (replaced.has(finding.address)) result.symbols.retired++;
235
- continue;
240
+ // The analyzer moved its own name from an address outside the coverage.
241
+ for (const moved of analyzerNamed(db, finding.name, space).filter((row) => row.origin === analyzer)) {
242
+ change.close("analyzer_names", "space = ? AND address = ? AND origin = ?", space, moved.address, analyzer);
243
+ result.names.retired++;
236
244
  }
237
- change.insertSymbol({ space, address: finding.address, name: finding.name, kind: finding.kind, origin: analyzer });
238
- if (replaced.has(finding.address)) result.symbols.changed++;
239
- else result.symbols.added++;
245
+ change.insertAnalyzerName({ space, address: finding.address, name: finding.name, kind: finding.kind, origin: analyzer });
246
+ if (replaced.has(finding.address)) result.names.changed++;
247
+ else result.names.added++;
240
248
  }
241
249
  }
242
250
 
243
- /** Analyzers whose regions a heuristic finding replaces where it lies (D44): user/LLM > heuristic > DXA/Ghidra. */
251
+ /** Analyzers whose regions a heuristic finding replaces where it lies: user/LLM > heuristic > DXA/Ghidra. */
244
252
  const YIELDS_TO_HEURISTIC = new Set<Origin>(["dxa", "ghidra"]);
245
- /** Analyzers whose data gives way to a code finding over an executed instruction start (D58). */
253
+ /** Analyzers whose data gives way to a code finding over an executed instruction start. */
246
254
  const YIELDS_TO_EXECUTED_CODE = new Set<Origin>(["dxa", "ghidra"]);
247
255
 
248
256
  /** True when an address in the range is an executed instruction start. */
@@ -264,7 +272,7 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
264
272
  }
265
273
  }
266
274
  }
267
- // Executed code is evidence about its bytes (D58): another analyzer's data region under a code finding gives way where a run executed an instruction start.
275
+ // Executed code is evidence about its bytes: another analyzer's data region under a code finding gives way where a run executed an instruction start.
268
276
  for (const region of findings.regions.filter((candidate) => isCode(candidate.type))) {
269
277
  for (const row of regionsOverlapping(db, region.start, region.end, space)) {
270
278
  if (row.origin === analyzer || !YIELDS_TO_EXECUTED_CODE.has(row.origin) || isCode(row.type) || row.type === "dual-use") continue;
@@ -275,7 +283,7 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
275
283
  }
276
284
  }
277
285
  const conflictWith = (row: RegionRow, region: { start: number; end: number; type: RegionType }) => {
278
- // dual-use bytes are code at one time and data at another: neither finding contradicts them (D42).
286
+ // dual-use bytes are code at one time and data at another: neither finding contradicts them.
279
287
  if (isCode(row.type) === isCode(region.type) || row.type === "dual-use") return;
280
288
  result.conflicts.push({
281
289
  category: "region",
@@ -381,10 +389,11 @@ export function importFindings(db: DatabaseSync, findings: NormalizedFindings, c
381
389
  const coverage = validateFindings(findings);
382
390
  return transaction(db, () => {
383
391
  const change = new Change(db, { origin: findings.analyzer, ...context }, `import-${findings.analyzer}`);
384
- const result: ImportResult = { revision: null, symbols: counts(), regions: counts(), references: counts(), conflicts: [] };
385
- importSymbols(db, change, findings, coverage, result);
392
+ const result: ImportResult = { revision: null, names: counts(), symbols: counts(), regions: counts(), references: counts(), conflicts: [] };
393
+ importNames(db, change, findings, coverage, result);
386
394
  importRegions(db, change, findings, coverage, result);
387
395
  importReferences(db, change, findings, coverage, result);
396
+ change.check();
388
397
  result.revision = change.revision;
389
398
  return result;
390
399
  });
@@ -0,0 +1,211 @@
1
+ // Joins the batches of agents that worked in parallel, each on its own range
2
+ // of addresses, into one batch for apply. The owner of an address is
3
+ // the batch whose range holds it: its name, comment, operand and module win
4
+ // there; at an address that no batch owns, the first batch in the order given
5
+ // wins. A name that a batch gave to an address, and that the join replaced
6
+ // with the name of another batch, is replaced in that batch's operand
7
+ // expressions and macro args too. The join writes no knowledge.
8
+
9
+ import { replaceIdentifiers } from "./expression.ts";
10
+ import type { BatchOperation } from "./write.ts";
11
+
12
+ export interface Batch {
13
+ /** The file the batch came from, for the report. */
14
+ file: string;
15
+ /** The range of addresses that the batch owns. */
16
+ owner?: { start: number; end: number };
17
+ ops: BatchOperation[];
18
+ }
19
+
20
+ /** One place where batches gave different values: the value that stays, and the batches whose value the join left out. */
21
+ export interface JoinChoice {
22
+ what: "name" | "comment" | "operand" | "module" | "macro";
23
+ space: string;
24
+ /** The address, or for a macro its name. */
25
+ at: number | string;
26
+ kept: { file: string; value: string };
27
+ left: Array<{ file: string; value: string }>;
28
+ }
29
+
30
+ /** A clash that the join cannot decide: one name, or one module file, at two addresses. */
31
+ export interface JoinClash {
32
+ what: "name" | "file";
33
+ value: string;
34
+ space: string;
35
+ places: Array<{ address: number; file: string }>;
36
+ }
37
+
38
+ export interface JoinResult {
39
+ ops: BatchOperation[];
40
+ choices: JoinChoice[];
41
+ clashes: JoinClash[];
42
+ /** Writes that were the same in more than one batch, and that the join wrote once. */
43
+ repeated: number;
44
+ }
45
+
46
+ type Op<K extends BatchOperation["op"]> = Extract<BatchOperation, { op: K }>;
47
+ const spaceOf = (op: { space?: string }) => op.space ?? "c64";
48
+
49
+ /** The value of a keyed write as text, to compare and report it. */
50
+ function valueOf(op: BatchOperation): string {
51
+ switch (op.op) {
52
+ case "rename":
53
+ return op.kind === undefined ? op.name : `${op.name} (${op.kind})`;
54
+ case "comment":
55
+ return op.text;
56
+ case "operand":
57
+ return op.expression ?? `${op.select} ${op.symbol ?? ""}${op.offset === undefined || op.offset === 0 ? "" : op.offset > 0 ? `+${op.offset}` : op.offset}`;
58
+ case "module":
59
+ return op.title === undefined ? op.file : `${op.file} (${op.title})`;
60
+ case "macro":
61
+ return `${op.name}(${(op.params ?? []).join(", ")})`;
62
+ default:
63
+ return JSON.stringify(op);
64
+ }
65
+ }
66
+
67
+ export function joinBatches(batches: readonly Batch[]): JoinResult {
68
+ const owns = (batch: Batch, address: number) => batch.owner !== undefined && address >= batch.owner.start && address <= batch.owner.end;
69
+ const choices: JoinChoice[] = [];
70
+ const clashes: JoinClash[] = [];
71
+
72
+ /** For each key, the write that stays: the owner's last write, else the first batch's last write. */
73
+ function choose<K extends "rename" | "comment" | "operand" | "module" | "macro">(op: K, what: JoinChoice["what"], keyOf: (write: Op<K>) => string, at: (write: Op<K>) => number | string) {
74
+ const all = new Map<string, Array<{ batch: Batch; write: Op<K> }>>();
75
+ for (const batch of batches) {
76
+ for (const write of batch.ops) {
77
+ if (write.op !== op) continue;
78
+ const key = keyOf(write as Op<K>);
79
+ all.set(key, [...(all.get(key) ?? []), { batch, write: write as Op<K> }]);
80
+ }
81
+ }
82
+ const kept = new Map<string, { batch: Batch; write: Op<K> }>();
83
+ for (const [key, candidates] of all) {
84
+ const place = at(candidates[0]!.write);
85
+ const owner = typeof place === "number" ? candidates.filter((candidate) => owns(candidate.batch, place)) : [];
86
+ // A batch can write a place twice; its last write counts, as in apply.
87
+ const pick = (owner.length > 0 ? owner : candidates.filter((candidate) => candidate.batch === candidates[0]!.batch)).at(-1)!;
88
+ kept.set(key, pick);
89
+ const left = candidates.filter((candidate) => candidate.batch !== pick.batch && valueOf(candidate.write) !== valueOf(pick.write));
90
+ const distinct = [...new Map(left.map((candidate) => [`${candidate.batch.file}|${valueOf(candidate.write)}`, candidate])).values()];
91
+ if (distinct.length > 0) {
92
+ choices.push({
93
+ what,
94
+ space: spaceOf(pick.write),
95
+ at: place,
96
+ kept: { file: pick.batch.file, value: valueOf(pick.write) },
97
+ left: distinct.map((candidate) => ({ file: candidate.batch.file, value: valueOf(candidate.write) })),
98
+ });
99
+ }
100
+ }
101
+ return kept;
102
+ }
103
+
104
+ const names = choose("rename", "name", (write) => `${spaceOf(write)}|${write.address}`, (write) => write.address);
105
+ // Name clashes: one name for two addresses after the choice.
106
+ const byName = new Map<string, Array<{ address: number; file: string }>>();
107
+ for (const { batch, write } of names.values()) {
108
+ const key = `${spaceOf(write)}|${write.name}`;
109
+ byName.set(key, [...(byName.get(key) ?? []), { address: write.address, file: batch.file }]);
110
+ }
111
+ for (const [key, places] of byName) {
112
+ if (places.length > 1) clashes.push({ what: "name", value: key.split("|")[1]!, space: key.split("|")[0]!, places: places.sort((a, b) => a.address - b.address) });
113
+ }
114
+ // A name that a batch gave and the join replaced: each batch's names follow the join's names.
115
+ const finalName = new Map<string, string>();
116
+ for (const write of names.values()) finalName.set(`${spaceOf(write.write)}|${write.write.address}`, write.write.name);
117
+ const renamedIn = new Map<Batch, Map<string, string>>();
118
+ for (const batch of batches) {
119
+ const map = new Map<string, string>();
120
+ for (const write of batch.ops) {
121
+ if (write.op !== "rename") continue;
122
+ const now = finalName.get(`${spaceOf(write)}|${write.address}`);
123
+ if (now !== undefined && now !== write.name) map.set(`${spaceOf(write)}|${write.name}`, now);
124
+ }
125
+ renamedIn.set(batch, map);
126
+ }
127
+ const follow = (batch: Batch, space: string, text: string) => {
128
+ const map = renamedIn.get(batch)!;
129
+ if (map.size === 0) return text;
130
+ try {
131
+ return replaceIdentifiers(text, (name) => map.get(`${space}|${name}`) ?? name);
132
+ } catch {
133
+ // Text that is no expression stays as the batch wrote it; apply refuses it then.
134
+ return text;
135
+ }
136
+ };
137
+
138
+ const comments = choose("comment", "comment", (write) => `${spaceOf(write)}|${write.address}|${write.placement}`, (write) => write.address);
139
+ const operands = choose("operand", "operand", (write) => `${spaceOf(write)}|${write.address}`, (write) => write.address);
140
+ const modules = choose("module", "module", (write) => `${spaceOf(write)}|${write.start}`, (write) => write.start);
141
+ const macros = choose("macro", "macro", (write) => `${spaceOf(write)}|${write.name}`, (write) => write.name);
142
+ const byFile = new Map<string, Array<{ address: number; file: string }>>();
143
+ for (const { batch, write } of modules.values()) {
144
+ const key = `${spaceOf(write)}|${write.file}`;
145
+ byFile.set(key, [...(byFile.get(key) ?? []), { address: write.start, file: batch.file }]);
146
+ }
147
+ for (const [key, places] of byFile) {
148
+ if (places.length > 1) clashes.push({ what: "file", value: key.split("|")[1]!, space: key.split("|")[0]!, places: places.sort((a, b) => a.address - b.address) });
149
+ }
150
+
151
+ // The order of the joined batch: removals, the regions of other batches and then the owner's (a later classify wins
152
+ // where they overlap), the names, the comments, the modules, the references, the macros, then the writes that use names.
153
+ let repeated = 0;
154
+ const seen = new Set<string>();
155
+ const once = (write: BatchOperation) => {
156
+ const key = JSON.stringify(write);
157
+ if (seen.has(key)) {
158
+ repeated++;
159
+ return false;
160
+ }
161
+ seen.add(key);
162
+ return true;
163
+ };
164
+ const named = new Set([...names.values()].map(({ write }) => `${spaceOf(write)}|${write.address}`));
165
+ const removals: BatchOperation[] = [];
166
+ const foreignRegions: BatchOperation[] = [];
167
+ const ownRegions: BatchOperation[] = [];
168
+ const references: BatchOperation[] = [];
169
+ const rest: BatchOperation[] = [];
170
+ for (const batch of batches) {
171
+ for (const write of batch.ops) {
172
+ switch (write.op) {
173
+ case "remove-symbol":
174
+ // A name from any batch wins over a removal at the same address.
175
+ if (!named.has(`${spaceOf(write)}|${write.address}`) && once(write)) removals.push(write);
176
+ break;
177
+ case "classify":
178
+ case "unclassify":
179
+ if (once(write)) (owns(batch, write.start) ? ownRegions : foreignRegions).push(write);
180
+ break;
181
+ case "reference":
182
+ case "unreference":
183
+ if (once(write)) references.push(write);
184
+ break;
185
+ case "rename":
186
+ case "comment":
187
+ case "operand":
188
+ case "module":
189
+ case "macro":
190
+ break;
191
+ case "use":
192
+ if (once(write)) rest.push({ ...write, args: (write.args ?? []).map((arg) => follow(batch, spaceOf(write), arg)) });
193
+ break;
194
+ default:
195
+ if (once(write)) rest.push(write);
196
+ }
197
+ }
198
+ }
199
+ const kept = <T extends { batch: Batch; write: BatchOperation }>(map: Map<string, T>) => [...map.values()].map(({ write }) => write);
200
+ const operandWrites = [...operands.values()].map(({ batch, write }) => ({
201
+ ...write,
202
+ ...(write.symbol === undefined ? {} : { symbol: follow(batch, spaceOf(write), write.symbol) }),
203
+ ...(write.expression === undefined ? {} : { expression: follow(batch, spaceOf(write), write.expression) }),
204
+ }));
205
+ return {
206
+ ops: [...removals, ...foreignRegions, ...ownRegions, ...kept(names), ...kept(comments), ...kept(modules), ...references, ...kept(macros), ...operandWrites, ...rest],
207
+ choices,
208
+ clashes,
209
+ repeated,
210
+ };
211
+ }