@henols/vice-mcp 1.0.0 → 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.
- package/THIRD-PARTY-NOTICES.md +6 -4
- package/anno-cli.ts +1 -1
- package/anno-store.ts +1 -1
- package/backend-detect.mts +143 -19
- package/build.ts +42 -1
- package/evid-ingest.ts +7 -3
- package/host-tool-client.ts +7 -3
- package/package.json +3 -1
- package/prerequisites.json +416 -0
- package/prg-image.ts +1 -1
- package/repo-root.ts +19 -8
- package/resources/backend-detect.mjs +90 -18
- package/resources/broker-launch.mjs +11 -2
- package/resources/ghidra-project.mjs +2 -2
- package/resources/host-tool.mjs +306 -82
- package/resources/prerequisites.json +416 -0
- package/resources/tool-location.mjs +750 -0
- package/resources/vice-broker.mjs +85 -12
- package/stock-checkpoints.ts +2 -2
- package/stock-connect.ts +12 -10
- package/stock-execution.ts +4 -2
- package/stock-memory-search.ts +3 -2
- package/stock-protocol.ts +60 -28
- package/stock-registers.ts +3 -1
- package/text-protocol.ts +6 -3
- package/textmon-profile.ts +8 -3
- package/version.ts +34 -246
- package/vice-proxy.ts +23 -23
|
@@ -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
|
+
}
|