@henols/vice-mcp 0.2.2 → 0.2.4

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 (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -0,0 +1,790 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from ghidra-project.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // ghidra-project.mts
8
+ //
9
+ // Phase 34, plan 34-03 (SEAM-04): the dot-segment refusal and the per-run
10
+ // Ghidra project location, enforced in THIS PROJECT'S OWN CODE before
11
+ // `analyzeHeadless` is ever reached. Motivated by 34-RESEARCH.md Finding 2:
12
+ // this session ran real Ghidra 12.1.3 twice and found the refusal walks the
13
+ // ENTIRE absolute project-location path, not just the leaf --
14
+ // `ghidra.util.NamingUtilities.checkName()` -> `GhidraURL.checkValidProjectPath()`
15
+ // -> `ghidra.framework.model.ProjectLocator`'s constructor -- firing after
16
+ // 12-16 seconds of JVM startup with a hard `exit 1` and the literal message
17
+ // "Path element starting with '.' is not permitted". A client-side
18
+ // per-segment check is not merely tidier here; it is the ONLY way to
19
+ // satisfy "refused before analyzeHeadless is ever reached" literally,
20
+ // because Ghidra's own refusal necessarily happens INSIDE a running
21
+ // `analyzeHeadless` process.
22
+ //
23
+ // THIS IS THE ONE AUTHORITATIVE PLACE for three things, none of which may
24
+ // be re-derived anywhere else -- in particular not copied into
25
+ // host-tool.mts, which reaches these by a VALUE import of this module's
26
+ // compiled `.mjs` sibling instead:
27
+ // - the dot-segment rule (hasDotPrefixedSegment());
28
+ // - the per-run project location (resolveGhidraProject()) -- a fresh,
29
+ // never-reused directory per run id, which is what makes Ghidra's
30
+ // single-writer project lock UNREACHABLE rather than merely guarded;
31
+ // - analyzeHeadless argv construction (buildAnalyzeHeadlessArgv()), which
32
+ // re-runs the dot-segment check independently so the rule holds even
33
+ // for a caller that skipped the resolver entirely, AND (34-07, CR-02)
34
+ // independently refuses a preScript/postScript carrying a
35
+ // parent-directory path segment, for the same reason.
36
+ //
37
+ // WHAT NOT TO DO, each naming the prohibition it guards (34-03-PLAN.md's
38
+ // own `must_haves.prohibitions`):
39
+ // - Never implement the refusal by parsing Ghidra's own stderr for the
40
+ // `IllegalArgumentException` string -- that would mean paying the
41
+ // ~12-16s JVM startup cost to learn a fact a string comparison already
42
+ // knows.
43
+ // - Never reuse a project directory across run ids -- reuse is exactly
44
+ // what makes the single-writer lock reachable again.
45
+ // - Never hardcode an absolute runs-root path -- it is always derived
46
+ // from a caller-supplied repoRoot via ghidraRunsRoot().
47
+ // - No second copy of the dot-segment rule anywhere outside this file.
48
+ // - No child-process call, no reference to an `analyzeHeadless`
49
+ // executable path, anywhere in this module -- it is pure string,
50
+ // filesystem-EXISTENCE-check, and filesystem-CREATE logic, provable
51
+ // with no Ghidra installation present. (`existsSync`/`mkdirSync` below
52
+ // only ever check or create a directory this project itself is about
53
+ // to hand to a FUTURE `analyzeHeadless` invocation -- neither ever
54
+ // invokes anything.)
55
+ //
56
+ // LIVE FINDING, this plan's own Task 3 (not stated by 34-RESEARCH.md
57
+ // Finding 2, which only ever exercised the REFUSAL path): a real Ghidra
58
+ // 12.1.3 run against a CLEAN, non-dotted, well-formed project location that
59
+ // does not yet exist on disk fails with `java.io.FileNotFoundException:
60
+ // Directory not found` at `DefaultProjectManager.createProject()` --
61
+ // `analyzeHeadless` does not create the leaf project directory itself, on
62
+ // EITHER path (refusal or success). resolveGhidraProject() therefore
63
+ // CREATES the directory as the last step of a successful resolution (never
64
+ // on a refusal) -- this is what makes it a genuine RESERVATION, not just a
65
+ // path computation: the moment a caller receives `ok: true`, the directory
66
+ // exists and a second call under the same run id is refused, with no
67
+ // window where two callers could observe an absent directory and both
68
+ // proceed. See `evidence/34-ghidra-dotpath.md` for the full transcript.
69
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync } from "node:fs";
70
+ import { join, sep } from "node:path";
71
+ /** The refusal-message fragment naming Ghidra's own literal error text, in
72
+ * ONE place, so every refusal in this module (and any caller reading a
73
+ * refusal message) can quote the same words Ghidra itself would have used
74
+ * ~12-16 seconds later. */
75
+ export const DOT_SEGMENT_REFUSAL = "path element starting with '.' is not permitted";
76
+ /** Gap G-40-1 (2026-09-08): the Ghidra runs root moved UNDER the single
77
+ * tool-written root (`.c64-re-tools/`, D-33) via a broker-minted symlink
78
+ * HANDLE, not by relocating the physical bytes anywhere else. This corrects
79
+ * an earlier, unmeasured claim recorded right here -- see "What this
80
+ * corrects" below.
81
+ *
82
+ * MEASURED against real Ghidra 12.1.3
83
+ * (`.planning/notes/ghidra-dot-path-check-semantics.md`: 5 live
84
+ * `analyzeHeadless` runs plus a `javap` read of `ProjectLocator.class`): the
85
+ * dot-segment refusal binds the ABSOLUTIZED project-location path argument
86
+ * -- `ProjectLocator` calls `java.io.File.getAbsolutePath()` and never
87
+ * `getCanonicalPath()`. So:
88
+ * - a RELATIVE location is absolutized against the process cwd and then
89
+ * refused if that absolutized path carries a dotted segment;
90
+ * - a bare `.` segment survives absolutization and is itself caught;
91
+ * - a SYMLINK is NOT resolved -- Ghidra never canonicalises the path, so a
92
+ * non-dotted symlink into a dotted directory is ACCEPTED. Proven by a
93
+ * FULL import plus analysis run through such a link
94
+ * (`REPORT: Analysis succeeded`, 2.1M of program database physically
95
+ * under `.c64-re-tools/`), not merely by project creation -- an earlier,
96
+ * less complete run had mistaken creation alone for proof.
97
+ *
98
+ * The chosen location therefore satisfies three conditions SIMULTANEOUSLY,
99
+ * all three load-bearing and none of them optional:
100
+ * 1. No dotted or bare-dot segment anywhere in the ABSOLUTIZED path handed
101
+ * to `analyzeHeadless` (this file's own `hasDotPrefixedSegment()`).
102
+ * 2. The path stays INSIDE the bind-mounted workspace tree --
103
+ * `containerPath()` THROWS on a host path matching no known host root
104
+ * (`containerpath.ts`), so an out-of-workspace handle (a `/tmp` or
105
+ * XDG-cache location) breaks the container route outright.
106
+ * 3. The link TARGET is RELATIVE, not absolute -- the CONTAINER itself
107
+ * traverses this handle to read the per-run log (`ghidra-run.ts:241`),
108
+ * and an absolute HOST-side target names a path that does not exist
109
+ * inside the container, so every run-log read would ENOENT.
110
+ *
111
+ * What this corrects: an earlier version of this comment stated re-pointing
112
+ * the runs root under `.c64-re-tools/` would make `resolveGhidraProject()`
113
+ * "refuse EVERY call, unconditionally," and called that "a hard
114
+ * external-tool constraint, not a preference." Both claims were inferred
115
+ * from running THIS MODULE'S OWN `hasDotPrefixedSegment()` against a
116
+ * synthetic `.c64-re-tools/runs/ghidra/<runId>` string -- a self-referential
117
+ * check that observes this project's own predicate, never Ghidra itself.
118
+ * That inference is superseded by the measurement above; do not
119
+ * reconstruct it from the same self-referential method a second time.
120
+ *
121
+ * Fragility this design knowingly accepts: the symlink route depends on
122
+ * Ghidra continuing to call `getAbsolutePath()` rather than
123
+ * `getCanonicalPath()` -- a one-word upstream change would silently break
124
+ * every run with the same misleading dot-segment error, ~12-16s late. Plan
125
+ * 40-09 adds a live guard for this; this module still must NEVER implement
126
+ * that guard by string-matching Ghidra's own stderr (this file's header,
127
+ * above, already prohibits paying the JVM-startup cost to learn a fact a
128
+ * string comparison already knows). */
129
+ export const GHIDRA_RUNS_HANDLE_NAME = "c64-re-tools";
130
+ /** The symlink TARGET `ensureGhidraRunsHandle()` mints at
131
+ * `<repoRoot>/GHIDRA_RUNS_HANDLE_NAME` -- the RELATIVE string `.c64-re-tools`
132
+ * (condition 3 above), never an absolute path. Relative so the CONTAINER
133
+ * resolves it against the symlink's own directory rather than a host-only
134
+ * absolute path. */
135
+ export const GHIDRA_RUNS_HANDLE_TARGET = ".c64-re-tools";
136
+ /** THE one place the path HANDED TO GHIDRA is computed: the repo root, the
137
+ * non-dotted handle segment, then `runs`, then `ghidra`. Every consumer
138
+ * (`resolveGhidraProject()` below, `host-tool.mts`'s run-log path, any
139
+ * future caller) derives the runs root from this function rather than
140
+ * re-joining the segments itself. */
141
+ export function ghidraRunsRoot(repoRoot) {
142
+ return join(repoRoot, GHIDRA_RUNS_HANDLE_NAME, "runs", "ghidra");
143
+ }
144
+ /** The PHYSICAL location the same runs live at, reached through the handle
145
+ * symlink `ghidraRunsRoot()` points at -- the repo root, the dotted target
146
+ * segment, then `runs`, then `ghidra`. Exported so a test (or a filesystem
147
+ * audit) can assert the D-33 "physically under `.c64-re-tools/`" truth
148
+ * without re-deriving this shape itself. */
149
+ export function ghidraRunsRealRoot(repoRoot) {
150
+ return join(repoRoot, GHIDRA_RUNS_HANDLE_TARGET, "runs", "ghidra");
151
+ }
152
+ /**
153
+ * Mints (or verifies) the broker-owned symlink HANDLE that lets a Ghidra
154
+ * project location satisfy the dot-segment refusal while the bytes
155
+ * genuinely live under `.c64-re-tools/` (D-33). Never throws; idempotent;
156
+ * NEVER repairs a wrong or foreign handle -- refuses BY NAME instead. This
157
+ * is the precondition that makes the reservation `mkdirSync` in
158
+ * `resolveGhidraProject()` below safe: without it, a missing handle would
159
+ * let recursive `mkdir` silently materialise a REAL directory tree at the
160
+ * handle path, and D-33 would break invisibly (the gap this whole function
161
+ * closes).
162
+ *
163
+ * Order, so a dangling link is never observable:
164
+ * 1. Narrow `repoRoot` the same way `resolveGhidraProject()` does.
165
+ * 2. Create the PHYSICAL runs tree first, recursively, at
166
+ * `ghidraRunsRealRoot()`. On failure, refuse naming that path and the
167
+ * underlying error.
168
+ * 3. `lstatSync()` the handle path. On ENOENT, `symlinkSync()` the
169
+ * RELATIVE target at the handle path. `EEXIST` is caught specifically
170
+ * and falls through to step 4 rather than being treated as a failure --
171
+ * two host-side callers (the broker at startup, a concurrent
172
+ * brokerless spawn) can race here, and the loser of that race must
173
+ * observe success, not a spurious error. No lock is taken and none is
174
+ * needed: both callers write the identical link.
175
+ * 4. VERIFY unconditionally, including immediately after minting -- one
176
+ * verification path, no trust placed in this function's own write.
177
+ * `lstatSync()` must report a symbolic link, and `readlinkSync()` must
178
+ * equal `GHIDRA_RUNS_HANDLE_TARGET` by EXACT string comparison.
179
+ * Anything else refuses.
180
+ *
181
+ * A refusal NEVER repairs: no unlink, no replace, no rename. Whatever
182
+ * already sits at the handle path may be a user's own deliberate directory,
183
+ * a stale artifact, or a redirect -- silently replacing it is exactly the
184
+ * invisible-breakage shape this function exists to close.
185
+ */
186
+ export function ensureGhidraRunsHandle(repoRoot) {
187
+ if (typeof repoRoot !== "string" || repoRoot === "") {
188
+ return { ok: false, message: `ensureGhidraRunsHandle requires a non-empty string "repoRoot"; got ${describe(repoRoot)}` };
189
+ }
190
+ const realRoot = ghidraRunsRealRoot(repoRoot);
191
+ try {
192
+ mkdirSync(realRoot, { recursive: true });
193
+ }
194
+ catch (e) {
195
+ return { ok: false, message: `ensureGhidraRunsHandle failed to create the physical runs tree (${realRoot}): ${e instanceof Error ? e.message : String(e)}` };
196
+ }
197
+ const handlePath = join(repoRoot, GHIDRA_RUNS_HANDLE_NAME);
198
+ let handleAlreadyExists = true;
199
+ try {
200
+ lstatSync(handlePath);
201
+ }
202
+ catch {
203
+ handleAlreadyExists = false;
204
+ }
205
+ if (!handleAlreadyExists) {
206
+ try {
207
+ symlinkSync(GHIDRA_RUNS_HANDLE_TARGET, handlePath);
208
+ }
209
+ catch (e) {
210
+ const code = e instanceof Error && "code" in e ? e.code : undefined;
211
+ if (code !== "EEXIST") {
212
+ return {
213
+ ok: false,
214
+ message: `ensureGhidraRunsHandle failed to create the handle symlink (${handlePath} -> ${GHIDRA_RUNS_HANDLE_TARGET}): ${e instanceof Error ? e.message : String(e)}`,
215
+ };
216
+ }
217
+ // EEXIST: a concurrent caller won the race and created the identical
218
+ // link between our lstatSync() above and this symlinkSync() call --
219
+ // fall through to unconditional verification rather than treating
220
+ // this as a failure.
221
+ }
222
+ }
223
+ // VERIFY unconditionally -- never trust the write above, whichever branch
224
+ // took it.
225
+ let stat;
226
+ try {
227
+ stat = lstatSync(handlePath);
228
+ }
229
+ catch (e) {
230
+ return {
231
+ ok: false,
232
+ message: `ensureGhidraRunsHandle: the handle (${handlePath}) does not exist after a creation attempt: ${e instanceof Error ? e.message : String(e)}`,
233
+ };
234
+ }
235
+ if (!stat.isSymbolicLink()) {
236
+ const kind = stat.isDirectory() ? "a directory" : stat.isFile() ? "a file" : "neither a directory, a file, nor a symbolic link";
237
+ return {
238
+ ok: false,
239
+ message: `ensureGhidraRunsHandle refuses: ${handlePath} already exists and is ${kind}, not a symbolic link. ` +
240
+ `This handle must remain a broker-minted alias pointing at the relative target "${GHIDRA_RUNS_HANDLE_TARGET}" -- ` +
241
+ `it is never deleted, replaced, or repaired automatically. Remove it by hand if it is safe to do so, then retry.`,
242
+ };
243
+ }
244
+ // Guarded for the same reason the two lstatSync() calls above are: this
245
+ // function documents "Never throws", and its broker call site
246
+ // (vice-broker.mts run(), requirement R2) sits deliberately OUTSIDE any
247
+ // try/catch so that a refusal is reported without stopping startup. An
248
+ // unguarded throw here would instead reach main()'s outer catch and abort
249
+ // the whole broker -- the exact opposite of R2's "a broker that cannot mint
250
+ // the handle still starts and says so". The window is narrow but real: the
251
+ // handle can be deleted or replaced between the verifying lstatSync() above
252
+ // and this readlinkSync() (TOCTOU). Found by code review, plan 40-09.
253
+ let target;
254
+ try {
255
+ target = readlinkSync(handlePath);
256
+ }
257
+ catch (e) {
258
+ return {
259
+ ok: false,
260
+ message: `ensureGhidraRunsHandle: ${handlePath} verified as a symbolic link but its target could not be read ` +
261
+ `(it was most likely removed or replaced concurrently): ${e instanceof Error ? e.message : String(e)}`,
262
+ };
263
+ }
264
+ if (target !== GHIDRA_RUNS_HANDLE_TARGET) {
265
+ return {
266
+ ok: false,
267
+ message: `ensureGhidraRunsHandle refuses: ${handlePath} is a symbolic link but points to "${target}", not the expected ` +
268
+ `relative target "${GHIDRA_RUNS_HANDLE_TARGET}" -- an absolute or otherwise-wrong target breaks the container ` +
269
+ `route, which traverses this link to read the per-run log. It is never repaired automatically. Remove it by ` +
270
+ `hand if it is safe to do so, then retry.`,
271
+ };
272
+ }
273
+ return { ok: true, handle: handlePath, target };
274
+ }
275
+ /** Anchored, narrow run-id shape, in `vice-broker-client.ts`'s own
276
+ * `REQUEST_ID_PATTERN` spirit for a validated opaque id: begins
277
+ * alphanumeric, then a small explicitly-listed printable set (alphanumeric,
278
+ * dash, underscore), no path separator, no dot ANYWHERE (a leading OR
279
+ * embedded dot is refused, since either would make the corresponding path
280
+ * segment dot-PREFIXED at its own start once joined -- e.g. a hypothetical
281
+ * "a.b" run id would be fine as a segment name, but this pattern refuses it
282
+ * anyway to keep the accepted run-id alphabet simple and auditable), and a
283
+ * stated length cap so a run id can never grow into something implausible. */
284
+ export const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
285
+ // ---------------------------------------------------------------------------
286
+ // Phase 36, plan 36-01 (OPC-01, OPC-04, T-36-02, T-36-03). The SLEIGH
287
+ // language-id shape and the extension-module file set, in the SAME
288
+ // "one authoritative place" discipline this module's own header states for
289
+ // the dot-segment rule and the per-run project location.
290
+ // ---------------------------------------------------------------------------
291
+ /** Anchored Ghidra language-id shape, in `RUN_ID_PATTERN`'s own spirit: a
292
+ * colon-separated sequence of alphanumeric-and-underscore segments (e.g.
293
+ * `6502:LE:16:nmos`), no path separator, no dot ANYWHERE, and a stated
294
+ * length cap so a caller-supplied `processor` string can never grow into
295
+ * something implausible. Deliberately NOT routed through
296
+ * `resolveWorkspacePath()` -- it is a language id, not a path (T-36-02).
297
+ * Compared byte-exactly and case-sensitively wherever it is used --
298
+ * `6502:le:16:nmos` does not match this pattern's own literal segment
299
+ * alphabet in a case-insensitive sense; the RegExp itself carries no `i`
300
+ * flag, so a caller cannot silently loosen the comparison by construction. */
301
+ export const LANGUAGE_ID_PATTERN = /^[A-Za-z0-9_]{1,64}(:[A-Za-z0-9_]{1,64}){1,7}$/;
302
+ // ---------------------------------------------------------------------------
303
+ // Phase 36, plan 36-02 (GHID-01, D-36-07). The loader-base-address shape and
304
+ // the import-route enum, in the SAME "one authoritative place" discipline
305
+ // this module's header states.
306
+ // ---------------------------------------------------------------------------
307
+ /** Anchored loader-base-address shape: a `0x` prefix followed by one to four
308
+ * LOWERCASE hex digits, anchored at both ends -- `"0X0"` (uppercase X) and
309
+ * `"0x0; rm -rf /"` both fail this pattern by construction, since the
310
+ * comparison is over the WHOLE string, not a prefix match. It is a raw argv
311
+ * token (never a path), so it is validated here rather than routed through
312
+ * `resolveWorkspacePath()` (T-36-09). */
313
+ export const LOADER_BASE_ADDR_PATTERN = /^0x[0-9a-f]{1,4}$/;
314
+ export const GHIDRA_IMPORT_ROUTES = Object.freeze(["prg", "flat64k"]);
315
+ // ---------------------------------------------------------------------------
316
+ // Phase 37, plan 37-08 (AUTO-07): the data-range pre-script's own fixed
317
+ // script name. `dataRangesPath` (below) is a wire field naming a RANGE FILE
318
+ // only -- never a script name -- because the script that reads it always
319
+ // lives beside `VolatileCarve.java`/`GhidraStructExport.java` in the SAME
320
+ // `scriptPath` directory every caller already supplies (see
321
+ // `ghidra-live.test.ts`'s own `scriptPath: "vendor/ghidra-scripts"`
322
+ // convention), exactly like `GHIDRA_STOCK_6502_LANGUAGE_FILES` above names a
323
+ // fixed file set rather than accepting one on the wire. This keeps
324
+ // `dataRangesPath` the ONLY new field this plan adds (D-37-33), with no
325
+ // second "which script" field to keep in sync.
326
+ // ---------------------------------------------------------------------------
327
+ /** The fixed name of plan 37-08's new pre-script, `DataRangeSeed.java` --
328
+ * resolved by Ghidra against the SAME `-scriptPath` directory every other
329
+ * script name here already resolves against. Never a wire field. */
330
+ export const DATA_RANGE_SEED_SCRIPT_NAME = "DataRangeSeed.java";
331
+ /** The route's own fixed loader base address, as a lowercase-hex string
332
+ * matching `LOADER_BASE_ADDR_PATTERN`. The flat-64K route bases at zero --
333
+ * the whole 64K address space IS the image; the `.prg` route bases at
334
+ * `0x801`, the C64 BASIC program start address (MEASURED, carried from
335
+ * `36-RESEARCH.md`'s own recorded `analyzeHeadless` invocations) -- a
336
+ * `.prg`'s load address is a property of the IMAGE, not of the route, which
337
+ * is why `loaderBaseAddr` stays a separately overridable field on the `prg`
338
+ * route (D-36-07) rather than being folded into this function entirely. */
339
+ export function importRouteBaseAddr(route) {
340
+ return route === "flat64k" ? "0x0" : "0x801";
341
+ }
342
+ /** The three stock 6502-processor language files `6502_nmos.slaspec` and
343
+ * `6502_nmos.ldefs` depend on (`@include "6502.slaspec"`, and `.ldefs`
344
+ * `processorspec="6502.pspec"` / `compiler spec="6502.cspec"`) -- per
345
+ * D-36-02, copied at INSTALL time from the host's own Ghidra installation,
346
+ * never committed into this repository (committing them would silently pin
347
+ * a Ghidra version inside this repo, the same objection already recorded
348
+ * against committing the compiled `.sla`). `ghidra.installExtension`
349
+ * (host-tool.mts) and `sleigh-compile-gate.test.ts`'s COMPILE half both copy
350
+ * exactly this list from `<GHIDRA_HOME>/Ghidra/Processors/6502/data/languages/`
351
+ * -- one shared list, never re-typed at either call site. */
352
+ export const GHIDRA_STOCK_6502_LANGUAGE_FILES = Object.freeze(["6502.slaspec", "6502.pspec", "6502.cspec"]);
353
+ /** Matches one `<language ...>` opening tag at a time. Attributes may span
354
+ * multiple lines (MEASURED against the real stock `6502.ldefs`), so this
355
+ * deliberately does not anchor to a single line -- it stops at the first
356
+ * unescaped `>`, which is always the tag's own close, since no attribute
357
+ * value in a `.ldefs` file contains a literal `>`. */
358
+ const LANGUAGE_ELEMENT_PATTERN = /<language\b[^>]*>/g;
359
+ const ID_ATTR_PATTERN = /\bid\s*=\s*"([^"]*)"/;
360
+ const SLAFILE_ATTR_PATTERN = /\bslafile\s*=\s*"([^"]*)"/;
361
+ /**
362
+ * Walks every module directory under `<ghidraHome>/Ghidra/Extensions/` and
363
+ * `<ghidraHome>/Ghidra/Processors/`, reading each one's own
364
+ * `data/languages/` directory for `.ldefs` files, and returns
365
+ * every declared `<language>` element's own `id`/`slafile` pair, plus
366
+ * whether that `slafile` exists on disk beside its own `.ldefs` -- a SORTED
367
+ * list (by `id`) so any assertion over the result is order-independent.
368
+ *
369
+ * `ghidraHome` is an explicit parameter, never read from
370
+ * `process.env.GHIDRA_HOME` internally, so the whole function is drivable
371
+ * against a synthetic directory tree with no real Ghidra installation
372
+ * present.
373
+ *
374
+ * A missing root (no `Extensions/` directory at all, the ordinary state of
375
+ * a fresh Ghidra install before any extension is ever installed) is
376
+ * treated as "no entries there" -- neither a refusal nor a throw. Any other
377
+ * filesystem error on a candidate module/language-file is likewise skipped
378
+ * rather than thrown: this function's contract is a plain data return, not
379
+ * a discriminated result.
380
+ *
381
+ * Read via a narrow anchored attribute match, never a general XML parse --
382
+ * this module has no parser dependency today and must not gain one for a
383
+ * single filesystem-read helper.
384
+ *
385
+ * This function performs no child-process call and touches nothing outside
386
+ * the directory it is given -- it never creates, copies, or compiles
387
+ * anything, preserving this module's own header invariant.
388
+ */
389
+ export function installedLanguageIds(ghidraHome) {
390
+ const results = [];
391
+ const moduleRoots = [join(ghidraHome, "Ghidra", "Extensions"), join(ghidraHome, "Ghidra", "Processors")];
392
+ for (const moduleRoot of moduleRoots) {
393
+ let moduleNames;
394
+ try {
395
+ moduleNames = readdirSync(moduleRoot, { withFileTypes: true })
396
+ .filter((entry) => entry.isDirectory())
397
+ .map((entry) => entry.name);
398
+ }
399
+ catch {
400
+ continue;
401
+ }
402
+ for (const moduleName of moduleNames) {
403
+ const languagesDir = join(moduleRoot, moduleName, "data", "languages");
404
+ let fileNames;
405
+ try {
406
+ fileNames = readdirSync(languagesDir).filter((name) => name.endsWith(".ldefs"));
407
+ }
408
+ catch {
409
+ continue;
410
+ }
411
+ for (const fileName of fileNames) {
412
+ const ldefsPath = join(languagesDir, fileName);
413
+ let text;
414
+ try {
415
+ text = readFileSync(ldefsPath, "utf8");
416
+ }
417
+ catch {
418
+ continue;
419
+ }
420
+ for (const tagMatch of text.matchAll(LANGUAGE_ELEMENT_PATTERN)) {
421
+ const tagText = tagMatch[0];
422
+ const idMatch = ID_ATTR_PATTERN.exec(tagText);
423
+ const slafileMatch = SLAFILE_ATTR_PATTERN.exec(tagText);
424
+ if (!idMatch || !slafileMatch)
425
+ continue;
426
+ const id = idMatch[1];
427
+ const slafile = slafileMatch[1];
428
+ const slafileExists = existsSync(join(languagesDir, slafile));
429
+ results.push({ id, ldefsPath, slafile, slafileExists });
430
+ }
431
+ }
432
+ }
433
+ }
434
+ results.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
435
+ return results;
436
+ }
437
+ /** Splits `absolutePath` on the platform separator and reports the FIRST
438
+ * segment beginning with `.` -- checking EVERY segment, not just the leaf,
439
+ * per Finding 2's own discovery. Returning the offending SEGMENT (never a
440
+ * bare boolean) is what lets every caller's refusal message name the
441
+ * offender rather than merely saying "refused".
442
+ *
443
+ * The empty leading segment produced by splitting an absolute path (e.g.
444
+ * `"/a/b".split(sep)` -> `["", "a", "b"]`) is ignored -- it is not a real
445
+ * path element and does not begin with `.` in any user-meaningful sense.
446
+ * A doubled separator (`"a//b"`) produces an empty INTERNAL segment, which
447
+ * is likewise skipped: there is no element there to judge.
448
+ *
449
+ * A literal `".."` segment (a parent-directory reference) STARTS with `.`
450
+ * exactly like any other dot-prefixed name, so it is refused by the same
451
+ * check with no special-casing -- this function never resolves or
452
+ * normalises the path, precisely so a `".."` segment stays visible to it
453
+ * (path.resolve()/path.join() would silently normalise `".."` away before
454
+ * this function ever saw it).
455
+ *
456
+ * Never throws: `/` and `""` both resolve to no dotted segment. */
457
+ export function hasDotPrefixedSegment(absolutePath) {
458
+ if (typeof absolutePath !== "string" || absolutePath === "")
459
+ return { dotted: false };
460
+ const rawSegments = absolutePath.split(sep);
461
+ for (let i = 0; i < rawSegments.length; i++) {
462
+ const segment = rawSegments[i];
463
+ if (segment === "")
464
+ continue; // the absolute-path leading empty segment, or a doubled separator
465
+ if (segment.startsWith("."))
466
+ return { dotted: true, segment };
467
+ }
468
+ return { dotted: false };
469
+ }
470
+ function isPlainObject(value) {
471
+ return typeof value === "object" && value !== null && !Array.isArray(value);
472
+ }
473
+ function describe(value) {
474
+ try {
475
+ const json = JSON.stringify(value);
476
+ return json === undefined ? String(value) : json;
477
+ }
478
+ catch {
479
+ return String(value);
480
+ }
481
+ }
482
+ // ---------------------------------------------------------------------------
483
+ // resolveGhidraProject -- the per-run project location (SEAM-04). Mirrors
484
+ // broker-control.mts's normaliseLaunchProfile()/host-tool.mts's
485
+ // normaliseHostToolRequest() discipline: narrow at one site, refuse unknown
486
+ // keys BY NAME, never coerce.
487
+ // ---------------------------------------------------------------------------
488
+ const RESOLVE_GHIDRA_PROJECT_KEYS = Object.freeze(["repoRoot", "runId"]);
489
+ const RESOLVE_GHIDRA_PROJECT_SHAPE = `an object with exactly the keys ${RESOLVE_GHIDRA_PROJECT_KEYS.join("/")}, both non-empty strings`;
490
+ /** THIS IS THE ONE PLACE a Ghidra run's project location is computed. Never
491
+ * throws; answers a discriminated result naming the offending value, key,
492
+ * or path segment. Refusal order: shape/keys, then `repoRoot` type, then
493
+ * `runId` type and pattern, then the dot-segment check over the computed
494
+ * ABSOLUTE `projectLocation` (which, since it is built by joining `repoRoot`
495
+ * onto itself, also catches a dot-prefixed `repoRoot` -- e.g. a caller that
496
+ * mistakenly passed `.vice-supervisor/` or `.planning/` as `repoRoot`),
497
+ * then `ensureGhidraRunsHandle()` as an idempotent precondition (a refusal
498
+ * here propagates straight out, before any reuse check and before the
499
+ * reservation `mkdirSync` below ever runs -- this order is what keeps a
500
+ * missing/wrong handle from silently materialising a second root), then the
501
+ * already-exists (no-reuse) idempotency check. */
502
+ export function resolveGhidraProject(input) {
503
+ if (!isPlainObject(input)) {
504
+ return { ok: false, message: `resolveGhidraProject input must be ${RESOLVE_GHIDRA_PROJECT_SHAPE}; got ${describe(input)}` };
505
+ }
506
+ const unknownKeys = Object.keys(input).filter((key) => !RESOLVE_GHIDRA_PROJECT_KEYS.includes(key));
507
+ if (unknownKeys.length > 0) {
508
+ return {
509
+ ok: false,
510
+ message: `resolveGhidraProject input has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${RESOLVE_GHIDRA_PROJECT_SHAPE}`,
511
+ };
512
+ }
513
+ const repoRoot = input.repoRoot;
514
+ if (typeof repoRoot !== "string" || repoRoot === "") {
515
+ return { ok: false, message: `resolveGhidraProject requires a non-empty string "repoRoot"; got ${describe(repoRoot)}` };
516
+ }
517
+ const runId = input.runId;
518
+ if (typeof runId !== "string" || runId === "") {
519
+ return { ok: false, message: `resolveGhidraProject requires a non-empty string "runId"; got ${describe(runId)}` };
520
+ }
521
+ if (!RUN_ID_PATTERN.test(runId)) {
522
+ return {
523
+ ok: false,
524
+ message: `resolveGhidraProject "runId" must match ${RUN_ID_PATTERN.source} (alphanumeric-first, alphanumeric/dash/underscore only, no separator, no dot, length-capped); got ${describe(runId)}`,
525
+ };
526
+ }
527
+ const runsRoot = ghidraRunsRoot(repoRoot);
528
+ const projectLocation = join(runsRoot, runId);
529
+ const projectName = runId;
530
+ const dotted = hasDotPrefixedSegment(projectLocation);
531
+ if (dotted.dotted) {
532
+ return {
533
+ ok: false,
534
+ message: `resolveGhidraProject refuses a project location containing a dot-prefixed path element ("${dotted.segment}"): ${DOT_SEGMENT_REFUSAL}; computed location was ${projectLocation}`,
535
+ };
536
+ }
537
+ const handleResult = ensureGhidraRunsHandle(repoRoot);
538
+ if (!handleResult.ok) {
539
+ return { ok: false, message: handleResult.message };
540
+ }
541
+ if (existsSync(projectLocation)) {
542
+ return {
543
+ ok: false,
544
+ message: `resolveGhidraProject refuses to reuse an existing run directory (${projectLocation}): ` +
545
+ `a Ghidra project directory is never reused across runs, because reuse is exactly what makes ` +
546
+ `Ghidra's single-writer project lock reachable again -- choose a different runId`,
547
+ };
548
+ }
549
+ // CREATE the directory here, as the LAST step of a successful resolution
550
+ // (never on a refusal above). Measured live this plan (Task 3): real
551
+ // Ghidra 12.1.3 does NOT create the leaf project directory itself --
552
+ // `analyzeHeadless` against a clean, well-formed, not-yet-existing
553
+ // location fails with `java.io.FileNotFoundException: Directory not
554
+ // found` at `DefaultProjectManager.createProject()`. Creating it HERE
555
+ // (rather than leaving it to host-tool.mts or to a caller) is what makes
556
+ // this function a genuine RESERVATION: the directory exists the instant
557
+ // `ok: true` is returned, so the existsSync() check above is what a
558
+ // second call under the SAME run id will see -- there is no window where
559
+ // two concurrent callers could both observe an absent directory.
560
+ try {
561
+ mkdirSync(projectLocation, { recursive: true });
562
+ }
563
+ catch (e) {
564
+ return {
565
+ ok: false,
566
+ message: `resolveGhidraProject failed to create the run directory (${projectLocation}): ${e instanceof Error ? e.message : String(e)}`,
567
+ };
568
+ }
569
+ return { ok: true, runsRoot, projectLocation, projectName };
570
+ }
571
+ // ---------------------------------------------------------------------------
572
+ // buildAnalyzeHeadlessArgv -- server-side argv construction (mirrors
573
+ // host-tool.mts's own buildHostToolArgv() discipline: typed fields only,
574
+ // re-checks the dot-segment rule independently so it holds even for a
575
+ // caller that bypassed resolveGhidraProject()).
576
+ // ---------------------------------------------------------------------------
577
+ const BUILD_ANALYZE_HEADLESS_ARGV_KEYS = Object.freeze([
578
+ "projectLocation",
579
+ "projectName",
580
+ "importPath",
581
+ "processor",
582
+ "loaderBaseAddr",
583
+ "noanalysis",
584
+ "scriptPath",
585
+ "preScript",
586
+ "entrypointsPath",
587
+ "postScript",
588
+ "exportPath",
589
+ "expectedClassificationLines",
590
+ // Phase 37, plan 37-08 (AUTO-07, D-37-33): the ONE new key this plan adds.
591
+ "dataRangesPath",
592
+ ]);
593
+ const BUILD_ANALYZE_HEADLESS_ARGV_SHAPE = `an object with keys ${BUILD_ANALYZE_HEADLESS_ARGV_KEYS.join("/")} ` +
594
+ `("projectLocation"/"projectName"/"importPath"/"processor"/"loaderBaseAddr" required non-empty strings, ` +
595
+ `"noanalysis" an optional boolean, "scriptPath"/"preScript"/"entrypointsPath"/"postScript"/"exportPath"/` +
596
+ `"dataRangesPath" optional non-empty strings, "expectedClassificationLines" an optional non-negative integer)`;
597
+ /** Emits `[projectLocation, projectName, "-import", importPath, "-processor",
598
+ * processor, "-deleteProject", ...optional pre/post script flags]`.
599
+ *
600
+ * `-deleteProject` only applies on the `-import` path (never on `-process`)
601
+ * -- the local `analyzeHeadlessREADME.md` (Ghidra 12.1.3) states: "the
602
+ * Ghidra project will be deleted after scripts and/or analysis have
603
+ * completed (only applies if the project has been created in the current
604
+ * session with `-import`; existing projects are never deleted)." This is
605
+ * exactly why the per-run directory (resolveGhidraProject()'s own refusal
606
+ * to reuse an existing run directory) is the PRIMARY mechanism that makes
607
+ * the single-writer lock unreachable, and `-deleteProject` is a SECONDARY
608
+ * cleanup on top of it, never a substitute for it. */
609
+ export function buildAnalyzeHeadlessArgv(input) {
610
+ if (!isPlainObject(input)) {
611
+ return { ok: false, message: `buildAnalyzeHeadlessArgv input must be ${BUILD_ANALYZE_HEADLESS_ARGV_SHAPE}; got ${describe(input)}` };
612
+ }
613
+ const unknownKeys = Object.keys(input).filter((key) => !BUILD_ANALYZE_HEADLESS_ARGV_KEYS.includes(key));
614
+ if (unknownKeys.length > 0) {
615
+ return {
616
+ ok: false,
617
+ message: `buildAnalyzeHeadlessArgv input has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${BUILD_ANALYZE_HEADLESS_ARGV_SHAPE}`,
618
+ };
619
+ }
620
+ const { projectLocation, projectName, importPath, processor, loaderBaseAddr, noanalysis, scriptPath, preScript, entrypointsPath, postScript, exportPath, expectedClassificationLines, dataRangesPath } = input;
621
+ for (const [key, value] of [
622
+ ["projectLocation", projectLocation],
623
+ ["projectName", projectName],
624
+ ["importPath", importPath],
625
+ ["processor", processor],
626
+ ["loaderBaseAddr", loaderBaseAddr],
627
+ ]) {
628
+ if (typeof value !== "string" || value === "") {
629
+ return { ok: false, message: `buildAnalyzeHeadlessArgv requires a non-empty string "${key}"; got ${describe(value)}` };
630
+ }
631
+ }
632
+ // Phase 36, plan 36-01 (T-36-02): an INDEPENDENT second-layer check --
633
+ // re-validated against LANGUAGE_ID_PATTERN here, so the rule holds even
634
+ // for a caller that constructed this field itself and bypassed
635
+ // host-tool.mts's own normaliseHostToolRequest() entirely. Mirrors the
636
+ // dot-segment re-check below and the parent-segment re-check just after
637
+ // it -- same second-layer discipline, third field.
638
+ if (!LANGUAGE_ID_PATTERN.test(processor)) {
639
+ return {
640
+ ok: false,
641
+ message: `buildAnalyzeHeadlessArgv "processor" must match ${LANGUAGE_ID_PATTERN.source}; got ${describe(processor)}`,
642
+ };
643
+ }
644
+ // Phase 36, plan 36-02 (D-36-07, T-36-09): the SAME independent
645
+ // second-layer discipline, fourth field -- so the rule holds even for a
646
+ // caller that constructed `loaderBaseAddr` itself and bypassed
647
+ // host-tool.mts's own route-conflict check entirely.
648
+ if (!LOADER_BASE_ADDR_PATTERN.test(loaderBaseAddr)) {
649
+ return {
650
+ ok: false,
651
+ message: `buildAnalyzeHeadlessArgv "loaderBaseAddr" must match ${LOADER_BASE_ADDR_PATTERN.source}; got ${describe(loaderBaseAddr)}`,
652
+ };
653
+ }
654
+ if (noanalysis !== undefined && typeof noanalysis !== "boolean") {
655
+ return { ok: false, message: `buildAnalyzeHeadlessArgv "noanalysis" must be a boolean or absent; got ${describe(noanalysis)}` };
656
+ }
657
+ if (expectedClassificationLines !== undefined && (typeof expectedClassificationLines !== "number" || !Number.isInteger(expectedClassificationLines) || expectedClassificationLines < 0)) {
658
+ return {
659
+ ok: false,
660
+ message: `buildAnalyzeHeadlessArgv "expectedClassificationLines" must be a non-negative integer or absent; got ${describe(expectedClassificationLines)}`,
661
+ };
662
+ }
663
+ for (const [key, value] of [
664
+ ["scriptPath", scriptPath],
665
+ ["preScript", preScript],
666
+ ["entrypointsPath", entrypointsPath],
667
+ ["postScript", postScript],
668
+ ["exportPath", exportPath],
669
+ // Phase 37, plan 37-08 (AUTO-07): the SAME "optional non-empty string"
670
+ // validation every other script-adjacent path field already gets.
671
+ ["dataRangesPath", dataRangesPath],
672
+ ]) {
673
+ if (value !== undefined && (typeof value !== "string" || value === "")) {
674
+ return { ok: false, message: `buildAnalyzeHeadlessArgv "${key}" must be a non-empty string or absent; got ${describe(value)}` };
675
+ }
676
+ }
677
+ // 34-07 (CR-02) / 36-02: an INDEPENDENT second-layer check -- refuse a
678
+ // preScript/postScript/scriptPath/entrypointsPath/exportPath containing a
679
+ // parent-directory path segment, even for a caller that constructed
680
+ // these fields itself and bypassed host-tool.mts's own
681
+ // resolveWorkspacePath() entirely. Mirrors the dot-segment re-check just
682
+ // below: same per-SEGMENT splitting approach (never a substring test), so
683
+ // a name that merely CONTAINS two dots (e.g. "..foo.java") is not
684
+ // misjudged -- only an exact ".." segment is a parent-directory
685
+ // reference. Does NOT require absoluteness: a bare Ghidra script name
686
+ // ("Pre.java") is Ghidra's own documented form for these flags and must
687
+ // stay accepted (ghidra-project.test.ts's own pre-existing case).
688
+ for (const [key, value] of [
689
+ ["preScript", preScript],
690
+ ["postScript", postScript],
691
+ ["scriptPath", scriptPath],
692
+ ["entrypointsPath", entrypointsPath],
693
+ ["exportPath", exportPath],
694
+ // Phase 37, plan 37-08 (AUTO-07): the SAME independent parent-segment
695
+ // re-check every other script-adjacent path field already gets.
696
+ ["dataRangesPath", dataRangesPath],
697
+ ]) {
698
+ if (typeof value !== "string")
699
+ continue;
700
+ const hasParentSegment = value.split(sep).some((segment) => segment === "..");
701
+ if (hasParentSegment) {
702
+ return {
703
+ ok: false,
704
+ message: `buildAnalyzeHeadlessArgv refuses a "${key}" containing a parent-directory path segment: a script ` +
705
+ `argument is either a bare Ghidra script name or a path already bounded inside the workspace; got ${describe(value)}`,
706
+ };
707
+ }
708
+ }
709
+ // Phase 36, plan 36-02: "a script argument with no script" is refused BY
710
+ // NAME rather than silently dropped -- a dropped argument is how a run
711
+ // reports success having asserted nothing (must_haves.prohibitions).
712
+ // Independent second-layer check, mirroring every other rule above.
713
+ if (typeof entrypointsPath === "string" && typeof preScript !== "string") {
714
+ return {
715
+ ok: false,
716
+ message: `buildAnalyzeHeadlessArgv refuses "entrypointsPath" without "preScript": a script argument with no script to receive it`,
717
+ };
718
+ }
719
+ if (typeof postScript !== "string" && (typeof exportPath === "string" || expectedClassificationLines !== undefined)) {
720
+ return {
721
+ ok: false,
722
+ message: `buildAnalyzeHeadlessArgv refuses "exportPath"/"expectedClassificationLines" without "postScript": a script argument with no script to receive it`,
723
+ };
724
+ }
725
+ if (expectedClassificationLines !== undefined && typeof exportPath !== "string") {
726
+ return {
727
+ ok: false,
728
+ message: `buildAnalyzeHeadlessArgv refuses "expectedClassificationLines" without "exportPath": it is the export script's own second argument, positioned after the export path`,
729
+ };
730
+ }
731
+ // Re-run the dot-segment check independently of resolveGhidraProject() --
732
+ // so the rule holds even for a caller that constructed `projectLocation`
733
+ // itself and skipped the resolver entirely (T-34-14).
734
+ const dotted = hasDotPrefixedSegment(projectLocation);
735
+ if (dotted.dotted) {
736
+ return {
737
+ ok: false,
738
+ message: `buildAnalyzeHeadlessArgv refuses a dot-prefixed project location ("${dotted.segment}"): ${DOT_SEGMENT_REFUSAL}; got ${describe(projectLocation)}`,
739
+ };
740
+ }
741
+ // Phase 36, plan 36-01/36-02 (OPC-04, D-36-07): fixed-order argv, each
742
+ // flag and its value as SEPARATE array entries, never string-concatenated.
743
+ // "-loader BinaryLoader" is a fixed literal (never a wire field, D-36-07);
744
+ // "-loader-baseAddr" is ALWAYS emitted, since host-tool.mts always
745
+ // supplies a value (either the caller's own or the route's own default).
746
+ const argv = [
747
+ projectLocation,
748
+ projectName,
749
+ "-import",
750
+ importPath,
751
+ "-processor",
752
+ processor,
753
+ "-loader",
754
+ "BinaryLoader",
755
+ "-loader-baseAddr",
756
+ loaderBaseAddr,
757
+ ];
758
+ if (noanalysis === true)
759
+ argv.push("-noanalysis");
760
+ if (typeof scriptPath === "string")
761
+ argv.push("-scriptPath", scriptPath);
762
+ // Phase 37, plan 37-08 (AUTO-07, D-37-33): DataRangeSeed.java's own
763
+ // `-preScript` pair is emitted FIRST, before the caller's own `preScript`
764
+ // (VolatileCarve.java) -- `analyzeHeadless` runs `-preScript` entries in
765
+ // argv order (analyzeHeadlessREADME.md: "Using Multiple Scripts"), and
766
+ // VolatileCarve.java's own `run()` calls `analyzeAll()` itself at the end
767
+ // of ITS run -- so the data ranges must already be marked as data before
768
+ // that call happens, or the code-discovery analysis this whole feedback
769
+ // exists to suppress would already have run over them. Independent of
770
+ // whether a `preScript` is present at all: `dataRangesPath` needs no
771
+ // OTHER script to be useful (unlike `entrypointsPath`, which is
772
+ // VolatileCarve.java's own positional argument).
773
+ if (typeof dataRangesPath === "string")
774
+ argv.push("-preScript", DATA_RANGE_SEED_SCRIPT_NAME, dataRangesPath);
775
+ if (typeof preScript === "string") {
776
+ argv.push("-preScript", preScript);
777
+ if (typeof entrypointsPath === "string")
778
+ argv.push(entrypointsPath);
779
+ }
780
+ if (typeof postScript === "string") {
781
+ argv.push("-postScript", postScript);
782
+ if (typeof exportPath === "string") {
783
+ argv.push(exportPath);
784
+ if (typeof expectedClassificationLines === "number")
785
+ argv.push(String(expectedClassificationLines));
786
+ }
787
+ }
788
+ argv.push("-deleteProject");
789
+ return { ok: true, argv };
790
+ }