@henols/vice-mcp 0.2.5 → 1.1.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.
@@ -0,0 +1,750 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from tool-location.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
+ // tool-location.mts
8
+ //
9
+ // THIS IS THE ONE AUTHORITATIVE PLACE that knows the precedence order for a
10
+ // declared tool id -- a key under `tools` in the committed
11
+ // prerequisites.json. For an id, this module walks three layers in a fixed
12
+ // order (an environment variable, then `.c64-re-tools/tools.json`, then a
13
+ // `$PATH` walk or a probe) and reports the resolved path plus which layer
14
+ // and which exact mechanism answered. Nothing calls this module today; a
15
+ // later phase rewires a live callsite to ask it, and that absence here is
16
+ // correct, not an oversight -- wiring this in before the module itself is
17
+ // proven would risk shipping an unproven answer to a real caller.
18
+ //
19
+ // Three separate `$PATH`-walk implementations coexist in this tree right
20
+ // now: `defaultResolveBinPath()` in backend-detect.mts, the fallback loop
21
+ // inside `findSiblingBinary()` in host-tool.mts, and this module's own
22
+ // exported `resolveOnPath()`. A later phase collapses all three into one --
23
+ // naming that here so a future reader finds scheduled work, not
24
+ // rediscovered duplication this file quietly left behind.
25
+ //
26
+ // This module deliberately holds no memo, unlike the two probes named
27
+ // above. Two reasons. First, this project's own architecture record
28
+ // enumerates a small, fixed set of modules that are allowed to hold
29
+ // process-lifetime global state, and growing that set is an architecture
30
+ // change, not a performance optimisation -- this file is not that change.
31
+ // Second, a future doctor built on top of this seam must never report a
32
+ // tool's location as something it no longer is; caching here would make a
33
+ // stale answer structurally possible, and no result this module returns is
34
+ // worth that risk.
35
+ //
36
+ // Two of the eight declared ids may never be located through any layer this
37
+ // module walks, and each has exactly one legitimate route instead --
38
+ // documented here as well as refused by name (`resolveTool()`) and reported
39
+ // by name (`validateToolsFile()`), because criterion 4 of this phase asks for
40
+ // both: refused in code, and documented "where a reader would look for it".
41
+ // - `node`: `vice-launcher.sh` is bash and reads `VICE_BROKER_NODE` before
42
+ // any working Node interpreter exists to parse a `tools.json` file with,
43
+ // so the environment variable is the only route and this file has no say
44
+ // at all.
45
+ // - `dxa`: it is vendored and built by this project against a pinned
46
+ // source, so an override here could only ever select a binary this
47
+ // project did not build and did not pin.
48
+ // `toolsFileTemplate()` ships a reserved `_viceBrokerNode` key and a reserved
49
+ // `_dxa` key so a reader editing the file by hand finds both exclusions and
50
+ // their reasons without opening this module -- the reason text itself is
51
+ // read from `prerequisites.json` at call time, never re-authored here, for
52
+ // the same reason `resolveTool()`'s own refusal sentences are.
53
+ //
54
+ // WHAT NOT TO DO, each because of something already recorded above:
55
+ // - Do not call the emulator-identity resolver in backend-detect.mts, and
56
+ // do not import backend-detect.mjs or host-tool.mjs. A location query
57
+ // stays read-only: it must not write either sibling's on-disk cache and
58
+ // must not inherit either sibling's process-lifetime memo.
59
+ // - Do not add a module-level memo, a caller-supplied cache, or a
60
+ // reset-for-tests hatch of any kind. See the no-memo paragraph above.
61
+ // - Do not statically import this project's repo-root resolver. The
62
+ // caller resolves `toolsDir` and `projectRoot` and passes both in as
63
+ // plain strings; re-deriving either locally here would be the same
64
+ // mistake this tree has already warned against elsewhere for a
65
+ // different directory.
66
+ // - Do not interpolate a resolved path into a shell string, and do not
67
+ // import node:child_process anywhere in this file. This seam exists so
68
+ // a future caller inherits an already-safe string, not a string this
69
+ // file itself made unsafe.
70
+ // - Do not hand-edit the compiled artifact under resources/. This file is
71
+ // the source; the compiled copy is generated and committed, and a
72
+ // hand-edit there is silently overwritten by the next build.
73
+ //
74
+ // Phase 60 gap closure (LOC-03, PD-13/PD-14/PD-16, corrected by plan 60-08):
75
+ // the environment layer (Layer 1) is AMENDED from Phase 59's D-08
76
+ // existence-only posture. Before this phase, a declared variable's value
77
+ // that failed to resolve simply fell through, silently, to a `$PATH` probe
78
+ // for the DECLARED ID -- and for a value that already named a specific
79
+ // location (an absolute path, or any value containing a `/`), that
80
+ // fall-through produced an honest operating-system failure at the eventual
81
+ // `spawn()` call, because `$PATH` was never going to answer for a literal
82
+ // path in the first place. Plan 60-01's rewiring of `resolvedBackend()`
83
+ // through this seam added an UNCONDITIONAL declared-id `$PATH` probe
84
+ // reachable after any unresolved value, of any shape -- which turned that
85
+ // honest failure into a same-named binary silently starting under a name
86
+ // the developer had pinned. Plan 60-06 closed this for a separator-free
87
+ // value only, on the belief that the separator-containing shape's
88
+ // fall-through was itself pre-phase-60 behaviour worth preserving;
89
+ // `60-VERIFICATION.md` read the pre-phase source directly (`git show
90
+ // d54d98a1:src/mcp/vice/backend-detect.mts`) and found that a
91
+ // separator-containing value which resolved nowhere returned `null` with NO
92
+ // `$PATH` walk for the bare id at all -- so the fall-through plan 60-06 kept
93
+ // was itself a regression plan 60-01 introduced earlier in this same phase,
94
+ // not a pre-phase truth. Plan 60-08 corrects this: the environment layer is
95
+ // now terminal for a declared variable's non-empty value WHATEVER its
96
+ // shape.
97
+ //
98
+ // The surviving distinction is between two different questions. The `$PATH`
99
+ // WALK still asks only whether `$PATH` could plausibly answer for the raw
100
+ // value itself -- true only for a bare, separator-free candidate on an
101
+ // `executable`-kind record, since a `$PATH` search of an absolute path or of
102
+ // a directory-kind value is meaningless. The REFUSAL asks a different,
103
+ // shape-independent question: was the variable set to something non-empty
104
+ // that resolved through neither check. A slash-free value on an
105
+ // `executable`-kind record is additionally walked on `$PATH`, reusing this
106
+ // module's own exported `resolveOnPath()`. When a declared variable was set
107
+ // to a non-empty value that resolves through NEITHER check, resolution
108
+ // refuses by name -- naming the variable, its value, and every candidate
109
+ // tried -- immediately before the declared-id `$PATH` probe, so a same-named
110
+ // binary sitting on `$PATH` is never silently substituted for the one the
111
+ // developer wrote, whatever the value's shape. `.c64-re-tools/tools.json` is
112
+ // still consulted first (PD-14): an entry a developer wrote down is a
113
+ // statement of intent, not a guess, so the ONE fall-through this removes is
114
+ // the declared-id `$PATH` probe for a bare candidate, not the file layer.
115
+ // The executable-bit check stays the FILE LAYER's alone -- D-08's untouched
116
+ // half -- and this layer gains no memo, no cache, and no reset hatch, here
117
+ // or anywhere else in this module.
118
+ //
119
+ // `remedyTextsFor()` is the FIRST runtime reader of the declaration's
120
+ // `remedies` arrays -- every one of the eight records' `remedies` blocks has
121
+ // existed as data only, read by no shipped code path, since Phase 58 wrote
122
+ // them. It exists because of `DECL-03`: a live refusal and a future doctor
123
+ // must never be able to name different remedies for the same tool, which is
124
+ // only true if both read the same declaration through the same reader. A
125
+ // caller composes its own sentence around the strings this returns and never
126
+ // re-types one, and nothing in this tree may execute a remedy string -- the
127
+ // never-auto-install constraint made structural, not merely documented.
128
+ import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs";
129
+ import { dirname, join, resolve as resolvePath } from "node:path";
130
+ import { fileURLToPath } from "node:url";
131
+ /** This module's own directory. Computed once, at module load, purely as
132
+ * the DEFAULT for `deps.here` below -- never assigned to again, and never
133
+ * read as a substitute for a caller-supplied `toolsDir`/`projectRoot`. */
134
+ const HERE = dirname(fileURLToPath(import.meta.url));
135
+ /** Reads `prerequisites.json` through a two-candidate list -- beside `here`,
136
+ * and one directory up from it -- taking the first that exists. This
137
+ * module ships two ways: as unbuilt source (`src/mcp/vice/tool-location.mts`,
138
+ * where `here` is `src/mcp/vice/`) and as the compiled artifact this
139
+ * project actually runs (`src/mcp/vice/resources/tool-location.mjs`, where
140
+ * `here` is `src/mcp/vice/resources/`). So "the declaration relative to
141
+ * `here`" means two different real locations depending on which form of
142
+ * this module is executing, and the two-candidate join is what makes both
143
+ * forms find the same file. Always reads the real filesystem directly
144
+ * (never `deps.exists`/`deps.readFile`, which govern the environment and
145
+ * `tools.json` layers below, not this declaration) -- the one thing a test
146
+ * can vary here is `here` itself. */
147
+ function readDeclaration(here) {
148
+ const candidates = [join(here, "prerequisites.json"), join(here, "..", "prerequisites.json")];
149
+ for (const candidate of candidates) {
150
+ if (existsSync(candidate)) {
151
+ return JSON.parse(readFileSync(candidate, "utf8"));
152
+ }
153
+ }
154
+ throw new Error(`tool-location: prerequisites.json not found at any of: ${candidates.join(", ")}`);
155
+ }
156
+ /** Classifies a path as a statable file, a statable directory, or neither.
157
+ * The real default behind `deps.statKind` above -- called by
158
+ * `resolveTool()`'s own kind-aware existence check for every layer it
159
+ * reaches. */
160
+ function defaultStatKind(p) {
161
+ try {
162
+ const st = statSync(p);
163
+ if (st.isDirectory())
164
+ return "directory";
165
+ if (st.isFile())
166
+ return "file";
167
+ return null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /** The `$PATH` walk, exported as its own named function so it is one thing
174
+ * this seam owns rather than a third private copy of an algorithm that
175
+ * already exists twice elsewhere in this tree. Mirrors the existing
176
+ * algorithm exactly: a name containing a separator is resolved directly
177
+ * rather than walked, and `PATH` is split on `:`. Unlike the two existing
178
+ * private copies, this one returns every candidate it inspected, not only
179
+ * the winner -- and it holds no memo of its own. */
180
+ export function resolveOnPath(bin, env) {
181
+ const tried = [];
182
+ if (bin.includes("/")) {
183
+ const abs = resolvePath(bin);
184
+ tried.push(abs);
185
+ return { path: existsSync(abs) ? abs : null, tried };
186
+ }
187
+ const pathEnv = env.PATH ?? "";
188
+ for (const dir of pathEnv.split(":")) {
189
+ if (!dir)
190
+ continue;
191
+ const candidate = join(dir, bin);
192
+ tried.push(candidate);
193
+ if (existsSync(candidate))
194
+ return { path: candidate, tried };
195
+ }
196
+ return { path: null, tried };
197
+ }
198
+ /** Normalises a raw `tools.json` value into an absolute path. Applied ONLY
199
+ * to a value that came out of `tools.json` (D-08) -- the environment and
200
+ * probe layers keep today's `existsSync`-only behaviour and see none of
201
+ * this. Exactly two steps, in order:
202
+ * 1. A value beginning `~/` has the `~` replaced by the injected
203
+ * environment's `HOME`. A bare `~` with no following separator is
204
+ * left alone -- it is a legal relative path name, and guessing what a
205
+ * user meant by it is worse than not.
206
+ * 2. The result is handed to `node:path`'s own `resolve`, seeded with
207
+ * `projectRoot` -- which both resolves a still-relative value against
208
+ * `projectRoot` (never against `toolsDir` and never against the
209
+ * process working directory) and is the ONLY normalisation applied:
210
+ * no case folding, no Unicode normalisation, no comparison against a
211
+ * normalised form. A non-ASCII segment survives byte-identically, and
212
+ * a trailing separator resolves to the same path as the same value
213
+ * without one. */
214
+ function normalizeFileLayerValue(rawValue, env, projectRoot) {
215
+ const expanded = rawValue.startsWith("~/") ? join(env.HOME ?? "", rawValue.slice(2)) : rawValue;
216
+ return resolvePath(projectRoot, expanded);
217
+ }
218
+ /** Describes a value's shape for a refusal message -- `null`, `false` and
219
+ * a number render with their own literal, an array or a plain object
220
+ * renders as its JSON shape name, and a string renders quoted (or as "an
221
+ * empty string"). Deliberately local rather than importing `host-tool.mts`'s
222
+ * own `describe()`: this module's header forbids importing that sibling. */
223
+ function describeValueShape(value) {
224
+ if (value === null)
225
+ return "null";
226
+ if (Array.isArray(value))
227
+ return "an array";
228
+ if (typeof value === "object")
229
+ return "an object";
230
+ if (typeof value === "string")
231
+ return value === "" ? "an empty string" : `a string (${JSON.stringify(value)})`;
232
+ return `a ${typeof value} (${JSON.stringify(value)})`;
233
+ }
234
+ /** Judges `.c64-re-tools/tools.json` alone and returns every file-level
235
+ * problem it finds -- unparseable JSON, a top level that is not a plain
236
+ * object, a key that names no declared tool, a value that is not a
237
+ * non-empty string, and an entry naming a tool this declaration says the
238
+ * file may never name -- WITHOUT resolving anything (D-10). This function
239
+ * does not walk `$PATH`, does not read the environment for a location,
240
+ * and does not stat a declared path; that is `resolveTool()`'s job. An
241
+ * absent file, a zero-byte file and a bare `{}` are not problems -- they
242
+ * are the default state of an installation that has not written one yet
243
+ * -- so each returns an empty array. Problems are returned in file key
244
+ * order, so the same file always produces the same output, and each
245
+ * problem is independent: one bad key changes nothing about a report on
246
+ * any other key in the same file. */
247
+ export function validateToolsFile(deps) {
248
+ const exists = deps.exists ?? existsSync;
249
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, "utf8"));
250
+ const here = deps.here ?? HERE;
251
+ const filePath = join(deps.toolsDir, "tools.json");
252
+ let text;
253
+ if (deps.raw !== undefined) {
254
+ text = deps.raw;
255
+ }
256
+ else {
257
+ if (!exists(filePath))
258
+ return [];
259
+ text = readFile(filePath);
260
+ }
261
+ if (text.trim() === "")
262
+ return [];
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(text);
266
+ }
267
+ catch {
268
+ return [{ toolId: null, key: "", message: `${filePath} could not be parsed: its bytes are not valid JSON` }];
269
+ }
270
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
271
+ return [
272
+ {
273
+ toolId: null,
274
+ key: "",
275
+ message: `${filePath} must be a plain object mapping a declared tool id to a path string; found ${describeValueShape(parsed)}`,
276
+ },
277
+ ];
278
+ }
279
+ const doc = parsed;
280
+ const declaration = readDeclaration(here);
281
+ const acceptedIds = Object.keys(declaration.tools);
282
+ const problems = [];
283
+ for (const key of Object.keys(doc)) {
284
+ // D-11: a key beginning with a SINGLE leading underscore (its second
285
+ // character is anything other than another underscore) is reserved for
286
+ // prose and is skipped before the unknown-key check runs -- never
287
+ // reported, whatever its value. The single-underscore qualifier is
288
+ // deliberate: it is what the template's own "_readme"/"_viceBrokerNode"
289
+ // keys look like, and it is what keeps a double-underscore JavaScript
290
+ // dunder name -- "__proto__" foremost -- OUT of the exemption, so that
291
+ // key falls through to the ordinary array-membership check below and is
292
+ // reported as unknown like any other unrecognised value, with no
293
+ // separate branch naming it.
294
+ if (key[0] === "_" && key[1] !== "_")
295
+ continue;
296
+ // Exact, case-sensitive ARRAY membership against the declaration's own
297
+ // key set -- never an object-property lookup keyed by the raw string --
298
+ // so a prototype-shaped key (e.g. "__proto__") refuses exactly like any
299
+ // other unrecognised value, with no separate branch (T-59-09).
300
+ if (!acceptedIds.includes(key)) {
301
+ problems.push({
302
+ toolId: null,
303
+ key,
304
+ message: `"${key}" is not a declared tool id; ${filePath} may only name one of ${acceptedIds.join(", ")}`,
305
+ });
306
+ continue;
307
+ }
308
+ const record = declaration.tools[key];
309
+ // A record declared `fileOverridable: false` may not be named in
310
+ // tools.json at all (LOC-05, LOC-07): the refusal quotes the
311
+ // declaration's own `reason` field verbatim, read at call time, never
312
+ // duplicated in this module.
313
+ if (record.location?.fileOverridable === false) {
314
+ problems.push({
315
+ toolId: key,
316
+ key,
317
+ message: `"${key}" may not be named in tools.json: ${record.location.reason ?? ""}`,
318
+ });
319
+ continue;
320
+ }
321
+ const value = doc[key];
322
+ if (typeof value !== "string" || value === "") {
323
+ problems.push({
324
+ toolId: key,
325
+ key,
326
+ message: `"${key}"'s tools.json entry must be a non-empty string naming a path; found ${describeValueShape(value)}`,
327
+ });
328
+ }
329
+ }
330
+ return problems;
331
+ }
332
+ /** Resolves one declared tool id through, in order: an environment
333
+ * variable (using the env-var name the declaration's own `location.envVar`
334
+ * names -- never a name hardcoded in this file), `.c64-re-tools/tools.json`,
335
+ * then a `$PATH` walk for the tool's own id (executable-kind ids only -- a
336
+ * `$PATH` walk for a directory-kind id is meaningless, so that id's probe
337
+ * layer answers `null` rather than recording a candidate nobody meant).
338
+ * The first layer that yields an existing path wins. An id this
339
+ * declaration does not know returns a refusal naming it and reads
340
+ * `tools.json` never. Every call re-resolves from scratch: this function
341
+ * holds no memo of its own and reads no memo of anyone else's, so a binary
342
+ * that appears on disk between two calls is found by the very next one. */
343
+ export function resolveTool(id, deps) {
344
+ const env = deps.env ?? process.env;
345
+ const exists = deps.exists ?? existsSync;
346
+ const statKind = deps.statKind ?? defaultStatKind;
347
+ const access = deps.access ?? ((p, mode) => accessSync(p, mode));
348
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, "utf8"));
349
+ const here = deps.here ?? HERE;
350
+ const tried = [];
351
+ const declaration = readDeclaration(here);
352
+ // Exact, case-sensitive ARRAY membership against the declaration's own
353
+ // key set -- never a bracket property lookup keyed by the raw string --
354
+ // so an id shaped like an inherited Object.prototype member (constructor,
355
+ // toString, valueOf, hasOwnProperty, ...) refuses exactly like any other
356
+ // undeclared id, with no separate branch. Mirrors `validateToolsFile()`'s
357
+ // own unknown-key check one function over: that function already carries
358
+ // this exact defence for the same hazard, and this lookup previously did
359
+ // not, which let such an id slip past this guard and reach the
360
+ // tools.json layer below for an id nobody declared.
361
+ const declaredIds = Object.keys(declaration.tools);
362
+ if (!declaredIds.includes(id)) {
363
+ return {
364
+ id,
365
+ path: null,
366
+ tried,
367
+ layer: null,
368
+ mechanism: null,
369
+ refusal: `"${id}" is not a declared tool id`,
370
+ envCandidate: null,
371
+ };
372
+ }
373
+ const record = declaration.tools[id];
374
+ // A record declared `fileOverridable: false` has exactly one legitimate
375
+ // location, and that location is not any of the three layers this
376
+ // function walks (D-16). No layer is consulted at all -- not the
377
+ // environment, not `tools.json`, not `$PATH` -- because walking any of
378
+ // them is precisely the substitution the exclusion exists to refuse.
379
+ // The refusal quotes the declaration's own `reason` field verbatim
380
+ // (LOC-05, LOC-07): this module never re-authors that sentence.
381
+ if (record.location?.fileOverridable === false) {
382
+ return {
383
+ id,
384
+ path: null,
385
+ tried: [],
386
+ layer: null,
387
+ mechanism: null,
388
+ refusal: `"${id}" may not be located through an environment variable, tools.json, or $PATH: ${record.location.reason ?? ""}`,
389
+ envCandidate: null,
390
+ };
391
+ }
392
+ /** Whether `candidate` matches this record's declared `kind` -- a
393
+ * statable file for an `executable` record, or a statable directory
394
+ * containing the declared `marker` for a `directory` record. This is an
395
+ * EXISTENCE test widened to be kind-aware (D-07), not the executable-bit
396
+ * check below, which lives in `passesFileLayerCheck` and applies to the
397
+ * file layer alone (D-08). */
398
+ const matchesDeclaredKind = (candidate) => {
399
+ if (record.kind === "directory") {
400
+ return statKind(candidate) === "directory" && typeof record.marker === "string" && exists(join(candidate, record.marker));
401
+ }
402
+ return statKind(candidate) === "file";
403
+ };
404
+ /** Whether `candidate` satisfies this record's declared `kind` on the
405
+ * FILE LAYER specifically (D-08, LOC-06's amended triad): everything
406
+ * `matchesDeclaredKind` already tests, PLUS -- for an `executable`-kind
407
+ * record only -- a real executable-bit check via `accessSync(candidate,
408
+ * fsConstants.X_OK)` inside a `try`/`catch`, never mode-bit arithmetic.
409
+ * A `directory`-kind candidate is never subjected to this additional
410
+ * check at all: emptiness or permission bits on a directory are not this
411
+ * criterion's concern, only its marker is. */
412
+ const passesFileLayerCheck = (candidate) => {
413
+ if (!matchesDeclaredKind(candidate))
414
+ return false;
415
+ if (record.kind === "directory")
416
+ return true;
417
+ try {
418
+ access(candidate, fsConstants.X_OK);
419
+ return true;
420
+ }
421
+ catch {
422
+ return false;
423
+ }
424
+ };
425
+ /** Builds the file layer's refusal sentence for a `candidate` that
426
+ * failed `passesFileLayerCheck` -- naming the tool id, quoting the
427
+ * offending path, saying `tools.json` supplied it (the half of LOC-06
428
+ * that tells a user which of the three layers to go fix), and naming
429
+ * which condition failed: absent, wrong kind, a missing marker, or a
430
+ * missing executable bit. */
431
+ const buildFileLayerRefusal = (candidate) => {
432
+ const onDiskKind = statKind(candidate);
433
+ if (onDiskKind === null) {
434
+ return `"${id}"'s tools.json entry (${candidate}) does not exist on disk; tools.json supplied this path`;
435
+ }
436
+ if (record.kind === "directory") {
437
+ if (onDiskKind !== "directory") {
438
+ return `"${id}"'s tools.json entry (${candidate}) is a ${onDiskKind}, but the declaration requires a directory; tools.json supplied this path`;
439
+ }
440
+ return `"${id}"'s tools.json entry (${candidate}) is a directory but is missing its required marker (${record.marker ?? ""}); tools.json supplied this path`;
441
+ }
442
+ if (onDiskKind !== "file") {
443
+ return `"${id}"'s tools.json entry (${candidate}) is a ${onDiskKind}, but the declaration requires an executable file; tools.json supplied this path`;
444
+ }
445
+ return `"${id}"'s tools.json entry (${candidate}) exists but is not executable (missing the executable bit); tools.json supplied this path`;
446
+ };
447
+ // Layer 1: the environment (PD-13/PD-14/PD-16/PD-20, LOC-03 gap closure --
448
+ // amends Phase 59's D-08 existence-only posture for this layer alone, and
449
+ // corrected by plan 60-08 -- see this module's own header for the
450
+ // incident and the correction). Two ordered steps for a declared,
451
+ // non-empty `envVarName` value:
452
+ // 1. Today's behaviour, unchanged: the raw value IS the candidate. If it
453
+ // matches the record's declared kind, it wins outright.
454
+ // 2. Only for an `executable`-kind record whose value contains no `/`:
455
+ // the value names a bare command a user expects `$PATH` (and, before
456
+ // this phase, `spawn()`'s own search) to resolve -- so this layer
457
+ // walks `$PATH` for exactly that value, through this module's own
458
+ // exported `resolveOnPath()` (never a second, private copy of that
459
+ // walk). A `directory`-kind value is never widened this way
460
+ // (PD-14/D-08's untouched half): a `$PATH` search for a directory is
461
+ // meaningless. This is the ONE separator test in this block, and it
462
+ // guards this walk alone.
463
+ //
464
+ // If neither step found a match, the variable WAS set non-empty and
465
+ // nothing answered it (`envUnresolved`) -- WHATEVER the value's shape.
466
+ // This is recorded but not yet returned -- `.c64-re-tools/tools.json`
467
+ // still gets its say (PD-14: an entry a developer wrote down is a
468
+ // statement of intent, not a guess), and only once THAT layer also has
469
+ // nothing to say for this id does resolution refuse, immediately before
470
+ // the declared-id `$PATH` probe (Layer 3) -- see the refusal below,
471
+ // right before that probe. The one fall-through this removes is exactly
472
+ // that probe: a `$PATH` search for the DECLARED ID once a variable
473
+ // named a candidate that resolved nothing, which could silently start a
474
+ // different binary than the one the developer named.
475
+ //
476
+ // PD-20 (plan 60-08): before this fix, a value CONTAINING a `/` that
477
+ // failed `matchesDeclaredKind()` was deliberately NOT recorded as
478
+ // `envUnresolved`, on the belief that this matched pre-phase-60
479
+ // behaviour. `60-VERIFICATION.md` read the pre-phase source directly and
480
+ // found that belief was wrong -- see this module's own header. The
481
+ // separator test above still gates the `$PATH` WALK (that surviving
482
+ // distinction is real and unchanged: a `$PATH` search of an absolute
483
+ // path is still meaningless), but it no longer gates the
484
+ // `envUnresolved` ASSIGNMENT -- those are two distinct expressions in
485
+ // this file and must stay that way.
486
+ //
487
+ // The executable-bit check stays the FILE LAYER's alone (D-08's untouched
488
+ // half): `matchesDeclaredKind()` is an existence/kind check only, and the
489
+ // walk above gains no `passesFileLayerCheck()` `accessSync` call. This
490
+ // layer gains no memo, no cache, and no reset hatch.
491
+ const envVarName = record.location?.envVar;
492
+ let envCandidate = null;
493
+ let envUnresolved = false;
494
+ if (envVarName) {
495
+ const envValue = env[envVarName];
496
+ if (typeof envValue === "string" && envValue !== "") {
497
+ envCandidate = envValue;
498
+ tried.push(envValue);
499
+ if (matchesDeclaredKind(envValue)) {
500
+ return { id, path: envValue, tried, layer: "env", mechanism: envVarName, refusal: null, envCandidate };
501
+ }
502
+ if (!envValue.includes("/") && record.kind === "executable") {
503
+ const envProbe = resolveOnPath(envValue, env);
504
+ tried.push(...envProbe.tried);
505
+ if (envProbe.path && matchesDeclaredKind(envProbe.path)) {
506
+ return { id, path: envProbe.path, tried, layer: "env", mechanism: envVarName, refusal: null, envCandidate };
507
+ }
508
+ }
509
+ envUnresolved = true;
510
+ }
511
+ }
512
+ /** Builds the environment layer's terminal refusal sentence (PD-13/PD-21):
513
+ * composed only when `envUnresolved` fired above AND `.c64-re-tools/tools.json`
514
+ * had nothing to say for this id either. Names the tool id, the declared
515
+ * variable, the value it held, what was looked for, and every candidate
516
+ * tried so far -- mirroring `buildFileLayerRefusal()`'s own prose idiom
517
+ * one section below. The trailing justification clause is branched on
518
+ * `record.kind` (PD-21, plan 60-08, fixing WR-03): an `executable`-kind
519
+ * record keeps the `$PATH`-shadowing warning byte-for-byte, because
520
+ * Layer 3's declared-id probe below is real for it (gated on the same
521
+ * `record.kind === "executable"` test) and IS what this refusal declines
522
+ * to run. A `directory`-kind record gets no such warning: Layer 3 is
523
+ * gated to executable records only (D-15), so there is no `$PATH`
524
+ * fallback to decline for a directory-kind id, and asserting one would
525
+ * name a protection mechanism that structurally cannot apply. */
526
+ const buildEnvLayerRefusal = (varName, value) => {
527
+ const wants = record.kind === "directory" ? `a directory carrying its required marker (${record.marker ?? ""})` : "an executable file";
528
+ const base = `"${id}"'s ${varName} environment variable is set to "${value}", which did not resolve to ${wants} ` +
529
+ `(tried: ${tried.join(", ") || "nothing"})`;
530
+ if (record.kind === "executable") {
531
+ return (`${base}; the seam will not fall back to searching $PATH for "${id}" itself, ` +
532
+ `because that could start a different binary than the one ${varName} named`);
533
+ }
534
+ return `${base}; resolution is terminal for ${varName}, and .c64-re-tools/tools.json was consulted and had nothing to say for "${id}" either`;
535
+ };
536
+ // Layer 2: `.c64-re-tools/tools.json`. This is the one layer D-08 scopes
537
+ // validation to, and the amended LOC-06 triad applies in full here: a
538
+ // named entry is refused by name when the path is absent, is not what
539
+ // its record's `kind` declares, or -- for a `directory` kind -- does
540
+ // not contain its declared marker, or -- for an `executable` kind --
541
+ // exists but carries no executable bit. Once a non-empty entry names a
542
+ // candidate for this id, resolution is TERMINAL for this call: it either
543
+ // accepts the candidate or refuses it, and never falls through to
544
+ // `$PATH` afterward (D-09) -- silently continuing would resolve a
545
+ // different binary than the one the file named and report success,
546
+ // which is exactly the failure LOC-06 exists to replace. An id that
547
+ // tools.json does not mention at all is simply absent from the file,
548
+ // which is unaffected by any of this and falls through as before.
549
+ //
550
+ // A malformed FILE is refused too, not silently treated as "nothing to
551
+ // say" -- mirroring the exact three problem shapes `validateToolsFile()`
552
+ // already detects and reports, so a JSON typo never silently downgrades
553
+ // an intended override into a $PATH search with no signal at all. An
554
+ // absent file, a zero-byte one, and a bare `{}` one are NOT malformed --
555
+ // each is the default state of an installation that has not written a
556
+ // file yet -- and each keeps falling through silently exactly as before.
557
+ // Unparseable JSON and a non-object top level affect every id in the same
558
+ // way `validateToolsFile()`'s own file-level problems do (no per-id entry
559
+ // can be read from either shape at all); a present-but-not-a-non-empty-
560
+ // string value affects only the id it names, so a sibling id's own
561
+ // well-formed entry in the same file still resolves.
562
+ const toolsJsonPath = join(deps.toolsDir, "tools.json");
563
+ if (exists(toolsJsonPath)) {
564
+ const rawText = readFile(toolsJsonPath);
565
+ if (rawText.trim() !== "") {
566
+ let parsed;
567
+ let parseFailed = false;
568
+ try {
569
+ parsed = JSON.parse(rawText);
570
+ }
571
+ catch {
572
+ parseFailed = true;
573
+ }
574
+ if (parseFailed) {
575
+ return {
576
+ id,
577
+ path: null,
578
+ tried,
579
+ layer: null,
580
+ mechanism: null,
581
+ refusal: `${toolsJsonPath} could not be parsed: its bytes are not valid JSON`,
582
+ envCandidate,
583
+ };
584
+ }
585
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
586
+ return {
587
+ id,
588
+ path: null,
589
+ tried,
590
+ layer: null,
591
+ mechanism: null,
592
+ refusal: `${toolsJsonPath} must be a plain object mapping a declared tool id to a path string; found ${describeValueShape(parsed)}`,
593
+ envCandidate,
594
+ };
595
+ }
596
+ const doc = parsed;
597
+ // Same defence as the declaration lookup above, applied symmetrically:
598
+ // exact array membership against this file's own keys, never a bare
599
+ // bracket lookup keyed by `id`, so an inherited Object.prototype
600
+ // member never answers for a key the file itself did not write.
601
+ if (Object.keys(doc).includes(id)) {
602
+ const rawValue = doc[id];
603
+ if (typeof rawValue !== "string" || rawValue === "") {
604
+ return {
605
+ id,
606
+ path: null,
607
+ tried,
608
+ layer: null,
609
+ mechanism: null,
610
+ refusal: `"${id}"'s tools.json entry must be a non-empty string naming a path; found ${describeValueShape(rawValue)}`,
611
+ envCandidate,
612
+ };
613
+ }
614
+ const resolvedPath = normalizeFileLayerValue(rawValue, env, deps.projectRoot);
615
+ tried.push(resolvedPath);
616
+ if (passesFileLayerCheck(resolvedPath)) {
617
+ return { id, path: resolvedPath, tried, layer: "file", mechanism: "tools.json", refusal: null, envCandidate };
618
+ }
619
+ return {
620
+ id,
621
+ path: null,
622
+ tried,
623
+ layer: null,
624
+ mechanism: null,
625
+ refusal: buildFileLayerRefusal(resolvedPath),
626
+ envCandidate,
627
+ };
628
+ }
629
+ }
630
+ }
631
+ // PD-13/PD-14/PD-20 (LOC-03 gap closure, plan 60-08): the environment
632
+ // layer's TERMINAL refusal. Reached only when a declared variable was set
633
+ // to a non-empty value that resolved through neither Layer 1 step
634
+ // (`envUnresolved`, above -- now set WHATEVER the value's shape), AND
635
+ // `.c64-re-tools/tools.json` had nothing to say for this id either (a
636
+ // present, well-formed, resolving entry already returned above; an
637
+ // ABSENT entry falls through to here, same as before this phase). This is
638
+ // what makes the declared-id `$PATH` probe below UNREACHABLE once a
639
+ // variable was set non-empty and unresolved -- the one fall-through this
640
+ // gap-closure plan removes, for both value shapes.
641
+ if (envUnresolved) {
642
+ return {
643
+ id,
644
+ path: null,
645
+ tried,
646
+ layer: null,
647
+ mechanism: null,
648
+ refusal: buildEnvLayerRefusal(envVarName, envCandidate),
649
+ envCandidate,
650
+ };
651
+ }
652
+ // Layer 3: `$PATH`, executable-kind ids only (a directory has no
653
+ // meaningful `$PATH` candidate, and recording one nobody meant would be
654
+ // misleading in a later doctor's output -- D-15).
655
+ if (record.kind === "executable") {
656
+ const probe = resolveOnPath(id, env);
657
+ tried.push(...probe.tried);
658
+ if (probe.path) {
659
+ return { id, path: probe.path, tried, layer: "probe", mechanism: "$PATH", refusal: null, envCandidate };
660
+ }
661
+ }
662
+ return { id, path: null, tried, layer: null, mechanism: null, refusal: null, envCandidate };
663
+ }
664
+ /** Builds the text a doctor (Phase 61's `DOCTOR-08`) writes as
665
+ * `.c64-re-tools/tools.json` -- an EXPORT of this seam, never a committed
666
+ * static example, so the doctor fills in paths it itself resolved through
667
+ * `resolveTool()` and this project never carries two templates that can
668
+ * disagree (D-12's locked bare-string shape rides along: every emitted tool
669
+ * entry is the caller's path unchanged, never wrapped in an object).
670
+ *
671
+ * `resolved` is a read-only map from declared tool id to an absolute path
672
+ * the CALLER resolved -- this function invents no path and supplies no
673
+ * default for an id `resolved` does not name; `DOCTOR-08` requires the
674
+ * emitted template contain no path the doctor did not itself resolve, and a
675
+ * plausible-looking default is exactly the invented-remedy failure this
676
+ * milestone exists to remove.
677
+ *
678
+ * The emitted object's keys, in order: `_readme` (what the file is, the
679
+ * precedence order in words, and the underscore-prose rule that makes the
680
+ * next two keys legal), `_viceBrokerNode` and `_dxa` (D-11's reserved keys,
681
+ * quoting the `node`/`dxa` records' own declared `reason` fields verbatim,
682
+ * read from the declaration at call time -- never duplicated as a literal
683
+ * in this module, for the same reason `resolveTool()`'s own exclusion
684
+ * refusals read them), then one key per entry in `resolved` whose id both
685
+ * the declaration knows and marks `fileOverridable`, in declaration order,
686
+ * with the caller's path as a bare string. An id `resolved` names that the
687
+ * declaration does not know, or that the declaration says the file may
688
+ * never name, is silently omitted -- this builder emits a template, it does
689
+ * not judge its caller's map; `validateToolsFile()` is what judges a file. */
690
+ export function toolsFileTemplate(resolved, deps = {}) {
691
+ const here = deps.here ?? HERE;
692
+ const declaration = readDeclaration(here);
693
+ const nodeReason = declaration.tools.node?.location?.reason ?? "";
694
+ const dxaReason = declaration.tools.dxa?.location?.reason ?? "";
695
+ const out = {
696
+ _readme: "This file overrides where c64-re-tools looks for an external tool. " +
697
+ "Precedence order, highest first: an environment variable, then this file, then a $PATH search or probe. " +
698
+ "Any key beginning with an underscore is prose for a human reader and is ignored.",
699
+ _viceBrokerNode: `${nodeReason} Set the VICE_BROKER_NODE environment variable instead -- this file has no say over it.`,
700
+ _dxa: `${dxaReason} There is no environment-variable or tools.json override for it.`,
701
+ };
702
+ for (const id of Object.keys(declaration.tools)) {
703
+ if (!(id in resolved))
704
+ continue;
705
+ const record = declaration.tools[id];
706
+ if (record.location?.fileOverridable === false)
707
+ continue;
708
+ out[id] = resolved[id];
709
+ }
710
+ return JSON.stringify(out, null, 2) + "\n";
711
+ }
712
+ /** Reads a declared tool id's remedy prose out of `prerequisites.json`,
713
+ * ordered and byte-identical, for a caller to compose its own refusal
714
+ * sentence around -- `DECL-03`'s FIRST runtime reader of the `remedies`
715
+ * arrays (see this module's header). Collects, in order, every entry's
716
+ * `text` under the key matching `platform` (one of `linux`, `darwin`,
717
+ * `win32`), then every entry's `text` under `universal`, preserving
718
+ * declaration order within each key. Returns the strings exactly as parsed
719
+ * -- no trimming, no case change, no Unicode normalisation, no joining.
720
+ *
721
+ * An id the declaration does not carry, and a record with no `remedies`
722
+ * block, both return `[]` rather than throwing: this module returns
723
+ * structured results, and `vice-errors.ts` is not reached from here. The id
724
+ * lookup is exact ARRAY membership against the declaration's own key set,
725
+ * never a bracket property lookup on an unchecked string (T-60-02) -- the
726
+ * same defence `resolveTool()` and `validateToolsFile()` already carry for
727
+ * the identical hazard (an id shaped like an inherited Object.prototype
728
+ * member must refuse like any other undeclared id, with no separate
729
+ * branch). Reads through the existing private `readDeclaration(here)`; adds
730
+ * no second reader, no memo (Phase 59 `D-03`), and no reset-for-tests
731
+ * hatch. */
732
+ export function remedyTextsFor(id, deps = {}) {
733
+ const platform = deps.platform ?? process.platform;
734
+ const here = deps.here ?? HERE;
735
+ const declaration = readDeclaration(here);
736
+ // Exact, case-sensitive ARRAY membership against the declaration's own key
737
+ // set -- never a bracket property lookup on the raw string -- mirroring
738
+ // resolveTool()'s and validateToolsFile()'s own defence against an id
739
+ // shaped like an inherited Object.prototype member (T-60-02).
740
+ const declaredIds = Object.keys(declaration.tools);
741
+ if (!declaredIds.includes(id))
742
+ return [];
743
+ const record = declaration.tools[id];
744
+ const remedies = record.remedies;
745
+ if (remedies === undefined || remedies === null || typeof remedies !== "object")
746
+ return [];
747
+ const platformEntries = remedies[platform] ?? [];
748
+ const universalEntries = remedies.universal ?? [];
749
+ return [...platformEntries, ...universalEntries].map((entry) => entry.text);
750
+ }