modwright 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -15,8 +15,9 @@ The core is generic. Each game is a **surface**: one module that declares
15
15
  where things live and what the rules are, so every tool works for every
16
16
  supported game.
17
17
 
18
- **Status:** 0.x. The tools are exercised against real installs and real mod
19
- projects, but the API is not stable yet.
18
+ **Status:** 0.x, and the API is not stable yet. How far each game is tested
19
+ differs a lot; "Supported games" says which surfaces have been used against
20
+ a real install and which are built from documentation alone.
20
21
 
21
22
  ## Why
22
23
 
@@ -65,16 +66,26 @@ pointed at once.
65
66
 
66
67
  ## Supported games
67
68
 
68
- | Surface | Game | Mod systems modeled |
69
- |---|---|---|
70
- | `cyberpunk2077` | Cyberpunk 2077 | legacy archives, REDmod, redscript, TweakXL, ArchiveXL, RED4ext, CET; the TweakDB vanilla index; garment, vehicle, tweak and packaging validators; the CET bridge |
71
- | `baldursgate3` | Baldur's Gate 3 | `.pak` mods, loose overrides, Script Extender, the native mod loader; the stats, GUID and handle vanilla index; stats, story, progression and visual-bank validators; the Script Extender bridge |
72
- | `skyrimse` | Skyrim Special Edition | plugins, BSA archives, SKSE plugins, loose files |
73
- | `eldenring` | Elden Ring | me3 profiles, Mod Engine 2 overlay and DLLs, UXM loose files; paramdef-typed param exports, the `.me3` profile, package-layout and DCX rules |
74
- | `valheim` | Valheim | BepInEx plugins, patchers, config, MonoMod, the Doorstop loader; Thunderstore packaging, plugin-source and config rules; the vanilla index from Jötunn's dumps |
75
- | `stardewvalley` | Stardew Valley | SMAPI mods and Content Patcher packs, the load-order override lists, SMAPI's log; the manifest and `content.json` rules; the wiki schema index, built from the wiki's data pages, which `check_toolchain` fetches for you |
76
- | `subnautica2` | Subnautica 2 | `~mods` paks, LogicMods, UE4SS mods, DLC-style plugins; triplet, mount-point, TOC and UE4SS-shape rules |
77
- | `nivalisnights` | Nivalis Nights | BepInEx 6 IL2CPP plugins, patchers, config, generated interop, MelonLoader mods; the vanilla index (items, recipes, vendors, venues, NPCs, by GUID) built from your install with Cpp2IL and UnityPy |
69
+ | Surface | Game | Tested | Mod systems modeled |
70
+ |---|---|---|---|
71
+ | `cyberpunk2077` | Cyberpunk 2077 | field-tested | legacy archives, REDmod, redscript, TweakXL, ArchiveXL, RED4ext, CET; the TweakDB vanilla index; garment, vehicle, tweak and packaging validators; the CET bridge |
72
+ | `baldursgate3` | Baldur's Gate 3 | field-tested | `.pak` mods, loose overrides, Script Extender, the native mod loader; the stats, GUID and handle vanilla index; stats, story, progression and visual-bank validators; the Script Extender bridge |
73
+ | `skyrimse` | Skyrim Special Edition | research only | plugins, BSA archives, SKSE plugins, loose files |
74
+ | `eldenring` | Elden Ring | research only | me3 profiles, Mod Engine 2 overlay and DLLs, UXM loose files; paramdef-typed param exports, the `.me3` profile, package-layout and DCX rules |
75
+ | `valheim` | Valheim | research only | BepInEx plugins, patchers, config, MonoMod, the Doorstop loader; Thunderstore packaging, plugin-source and config rules; the vanilla index from Jötunn's dumps |
76
+ | `stardewvalley` | Stardew Valley | research only | SMAPI mods and Content Patcher packs, the load-order override lists, SMAPI's log; the manifest and `content.json` rules; the wiki schema index, built from the wiki's data pages, which `check_toolchain` fetches for you |
77
+ | `subnautica2` | Subnautica 2 | research only | `~mods` paks, LogicMods, UE4SS mods, DLC-style plugins; triplet, mount-point, TOC and UE4SS-shape rules |
78
+ | `nivalisnights` | Nivalis Nights | checked on an install | BepInEx 6 IL2CPP plugins, patchers, config, generated interop, MelonLoader mods; the vanilla index (items, recipes, vendors, venues, NPCs, by GUID) built from your install with Cpp2IL and UnityPy |
79
+
80
+ - **Field-tested:** used against real installs, real mod projects and the
81
+ running game, through the in-game bridge, over many field runs.
82
+ - **Checked on an install:** detection, logs, compatibility and the vanilla
83
+ index were checked against an installed copy of the game. No mod project
84
+ has been built or deployed through it yet, and it has no bridge.
85
+ - **Research only:** written from the game's modding documentation, its
86
+ community tools' sources and their published file formats, and tested
87
+ against fixtures. No install was behind it. Expect gaps, and please report
88
+ them.
78
89
 
79
90
  Installs are auto-detected from Steam (every library folder), plus GOG and
80
91
  Epic paths where they apply. Override with `installPath` on any tool, or set
@@ -94,7 +105,7 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
94
105
  | `read_log` | Tail a mod-related log (redscript, RED4ext, CET, SKSE, BG3SE, BepInEx, SMAPI and more). |
95
106
  | `triage_logs` | Read every mod log for the current run and classify lines against known failure signatures. It returns one verdict: which systems loaded, what failed first, and the usual fix. |
96
107
  | `check_compat` | Detect the game build, loaders, frameworks and tools, and check them against cited compatibility floors. |
97
- | `check_toolchain` | Locate the external tools (LSLib divine, WolvenKit CLI, Blender, Cpp2IL and others), report each one's path and version (never guessed), and say what each feature needs. It also remembers a tool or data folder you point it at, and installs the tools ModWright can install. |
108
+ | `check_toolchain` | Locate the external tools (LSLib divine, WolvenKit CLI, Blender, Cpp2IL and others), report each one's path and version (never guessed), and say what each feature needs. It also remembers a tool or data folder you point it at, installs the tools ModWright can install, and trusts a project to run its own build commands. |
98
109
  | `project_info` | Load a `modwright.json` mod project: sources, build, deploy targets, dependencies and publish targets, with paths resolved and structural warnings. |
99
110
  | `lookup` | Search the knowledge base of game and toolchain facts (each with a status and a source), the validator catalogue, or the game's vanilla index you built locally. |
100
111
  | `build_index` | Extract and index the game's own data into a local SQLite file in ModWright's cache. Never redistributed. |
@@ -114,6 +125,23 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
114
125
  Every tool that writes is **dry-run by default**: it describes the plan and
115
126
  writes nothing until you apply it.
116
127
 
128
+ ## How much to trust an answer
129
+
130
+ Every fact `lookup` returns carries a status and a source:
131
+
132
+ | Status | Means |
133
+ |---|---|
134
+ | `verified` | Someone observed it in the game; the fact says when. |
135
+ | `community` | A wiki, README, issue or forum post states it, cited by URL, and it has not been re-checked here. |
136
+ | `inferred` | Derived from reading code or from other facts. |
137
+ | `unverified` | Written from documentation or reasoning, and never observed. |
138
+ | `contradicted` | Once believed and later shown wrong. It is kept, pointing at what replaced it, so the mistake is not made again. |
139
+
140
+ A validator that cannot run is reported as **skipped**, with the reason,
141
+ never as passed. That happens when the vanilla index is not built, a tool is
142
+ missing, or the project has nothing of the kind it checks. An in-game test
143
+ whose probe is `unverified` can pass, but its result stays below `verified`.
144
+
117
145
  ## Workflow prompts
118
146
 
119
147
  Seven workflow prompts walk the authoring loop in any MCP client
@@ -173,6 +201,12 @@ project:
173
201
  `check_toolchain action=install tool=cpp2il` shows exactly what it would
174
202
  fetch (URL, size, sha256, destination); `mode=apply` installs it. Downloads
175
203
  are pinned and checked before anything is written.
204
+ - **Trusted projects** are listed in `trusted-projects.json`, beside that
205
+ config. A project's `exec` build steps and `toolchain` overrides run only
206
+ after you trust it once: `check_toolchain action=trust projectPath=<mod>`
207
+ lists what it would allow, and `mode=apply` records it. Until then an
208
+ applied build, convert, template extract or index build that would run
209
+ them is refused. `MODWRIGHT_TRUST_ALL=1` trusts every project, for CI.
176
210
  - **Overrides:** `MODWRIGHT_CACHE_DIR` moves the cache; `MODWRIGHT_HOME`
177
211
  moves everything.
178
212
  - **Project state** stays in the project's own `.modwright/`.
@@ -205,6 +239,50 @@ produce confident nonsense:
205
239
  - **Redistributing game data.** Indexes and templates are built from your
206
240
  own install, on your machine, and never shipped.
207
241
 
242
+ ## Security
243
+
244
+ ModWright runs programs on your machine and writes into your game folders.
245
+ What it runs, and where it writes:
246
+
247
+ - **Tools it finds.** It runs the external tools it locates (LSLib divine,
248
+ WolvenKit CLI, Blender and the others `check_toolchain` lists). They come
249
+ from a `MODWRIGHT_TOOL_*` variable, your per-user config, its own managed
250
+ installs, PATH or common install locations. Managed installs are pinned
251
+ and their sha256 is checked before anything is written.
252
+ - **A project's own commands.** A mod project's `exec` build steps and its
253
+ `toolchain` overrides are programs the project's author chose. They run
254
+ only after you trust that project on this machine:
255
+ `check_toolchain action=trust projectPath=<mod>` lists what it would allow,
256
+ and `mode=apply` records it. Read the project's `modwright.json` first,
257
+ above all for a mod you cloned from someone else. Trust belongs to the
258
+ folder, not to the file's content, so a trusted project that later gains
259
+ a step (after a `git pull`) is not asked again.
260
+ - **Where it writes.** Every writer is dry-run until you apply it, and it
261
+ backs up what it replaces. A build writes under the project's
262
+ `build.outDir`, and a deploy writes under the game's mod roots. A project
263
+ path that is absolute or climbs out with `..` is refused unless that entry
264
+ sets `"allowOutsideRoot": true`. `rollback` restores only under the
265
+ project and the game's mod roots, unless you pass `allowOutsideRoots`.
266
+ - **The in-game bridges.** While a verification bridge is deployed and the
267
+ game runs, anything that can write the bridge's request folder can run Lua
268
+ in the game. There is no token. Deploy a bridge only while you verify,
269
+ remove it afterwards, and never ship it (the release validators block a
270
+ build that contains it). The bridge READMEs under `bridges/` describe each
271
+ one's trust model.
272
+
273
+ To report a security problem, use **Report a vulnerability** on
274
+ [modwright-community's Security tab](https://github.com/jtrachtenberg/modwright-community/security),
275
+ not a public issue.
276
+
277
+ ## Reporting a problem
278
+
279
+ Bug reports, game and feature requests and questions go to
280
+ [modwright-community](https://github.com/jtrachtenberg/modwright-community):
281
+ [open an issue](https://github.com/jtrachtenberg/modwright-community/issues/new/choose),
282
+ or ask in [Discussions](https://github.com/jtrachtenberg/modwright-community/discussions).
283
+ Include `npx modwright --version`, the game, the tool call and what it
284
+ returned. Release notes are in that repository's CHANGELOG.
285
+
208
286
  ## License
209
287
 
210
288
  MIT; see LICENSE. Files ModWright generates into your own projects are
@@ -82,6 +82,21 @@ No Lua parser is vendored. Syntax is checked with `luac -p` under Lua 5.1 and
82
82
  dispatch-table keys equal the catalogue's probe ids, that the `PROTOCOL_VERSION` literals
83
83
  agree, that `bridge.json` lists every shipped file, and that every `.lua` carries the banner.
84
84
 
85
+ ## Trust model
86
+
87
+ While this bridge is deployed and the game is running, anything that can write a file into
88
+ its request folder (`%LOCALAPPDATA%\Larian Studios\Baldur's Gate 3\Script Extender`) can run
89
+ Lua inside the game. A request names its own probes and its own grants, and `bg3.lua.eval`
90
+ runs whatever code a request carries once the request grants the `session` tier. There is no
91
+ token. The nonce and the request's `armedUntil` expiry only stop a consumed or stale request
92
+ from running again; they do not say who wrote it. So:
93
+
94
+ - deploy the bridge only to a machine and a profile you control, and only while you are
95
+ verifying a mod;
96
+ - remove `Mods/ModWrightBridge` from the profile when you are done, before playing normally
97
+ or sharing the profile;
98
+ - never ship it. `bg3.history.bridge-never-ships` blocks a release build that contains it.
99
+
85
100
  ## What this bridge never does
86
101
 
87
102
  - No Osiris **action**, ever, and no forced cast: `Osi.UseSpell` was observed to re-cast,
@@ -155,6 +155,20 @@ The Lua is kept inside the 5.1-compatible subset shipped CET mods use, and check
155
155
  against both dialects — CET embeds a 5.4-era sol2, but nothing on disk pins which dialect
156
156
  features are available, so the intersection is the safe target.
157
157
 
158
+ ## Trust model
159
+
160
+ While this bridge is deployed and the game is running, anything that can write a file into
161
+ its write root (the folders listed under "The `io` write-root discovery") can run Lua inside
162
+ the game, and so can anything that can type into the CET console. A request names its own
163
+ probes and its own grants, and `cp2077.lua.eval` runs whatever code a request carries once
164
+ the request grants the `session` tier. There is no token. The nonce and the request's expiry
165
+ only stop a consumed or stale request from running again; they do not say who wrote it. So:
166
+
167
+ - deploy the bridge only to an install you control, and only while you are verifying a mod;
168
+ - remove `bin/x64/plugins/cyber_engine_tweaks/mods/ModWrightBridge` when you are done,
169
+ before playing normally;
170
+ - never ship it. `cp2077.history.bridge-never-ships` blocks a release build that contains it.
171
+
158
172
  ## What this bridge never does
159
173
 
160
174
  * It never observes: no CET listener API is attested, so `effect: "observe"` is `unsupported`
@@ -51,7 +51,14 @@ export async function readPendingArm(projectRoot, id) {
51
51
  catch {
52
52
  return undefined;
53
53
  }
54
- const parsed = pendingArmSchema.safeParse(JSON.parse(raw));
54
+ let json;
55
+ try {
56
+ json = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ const parsed = pendingArmSchema.safeParse(json);
55
62
  return parsed.success ? parsed.data : undefined;
56
63
  }
57
64
  export async function listPendingArms(projectRoot, game, opts) {
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { isDirectory, pathExists } from "../fsutil.js";
5
5
  import { parseProject } from "../project/schema.js";
6
6
  import { PROJECT_FILE } from "../project/types.js";
7
- import { executePlan, planCopy, planFile, planMkdir } from "../safety/index.js";
7
+ import { executePlan, planCopy, planDelete, planFile, planMkdir } from "../safety/index.js";
8
8
  const BRIDGE_DIR_BY_GAME = {
9
9
  baldursgate3: "bg3-se",
10
10
  cyberpunk2077: "cp2077-cet",
@@ -77,7 +77,7 @@ export async function planStageBridge(projectRoot, game) {
77
77
  }
78
78
  const to = stagedBridgeDir(projectRoot, game);
79
79
  const registration = await planDeployTargetRegistration(projectRoot, game);
80
- const operations = [planMkdir(path.dirname(to)), planCopy(from, to)];
80
+ const operations = [planMkdir(path.dirname(to)), ...(await pathExists(to) ? [planDelete(to)] : []), planCopy(from, to)];
81
81
  if (registration.op)
82
82
  operations.push(registration.op);
83
83
  return {
@@ -2,7 +2,9 @@ import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { pathExists, walk } from "../fsutil.js";
4
4
  import { normalizeBuildSteps } from "../project/build-steps.js";
5
+ import { outsideRootPaths } from "../project/outside-root.js";
5
6
  import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
7
+ import { describeNeeds, isProjectTrusted, trustRefusal } from "../trust.js";
6
8
  import { buildContext, runValidators } from "../validate/index.js";
7
9
  import { runAssertAbsentStep, runAssertPresentStep, runExecStep, runZipStep } from "./steps/core.js";
8
10
  import { runBlenderExportStep, runVerifyArchiveListingStep, runWolvenkitImportStep, runWolvenkitPackStep, } from "./steps/cyberpunk.js";
@@ -168,6 +170,18 @@ export async function runBuild(project, surface, options) {
168
170
  throw new Error(`--only named no matching step id(s): ${options.only.join(", ")}. Known ids: ${active.map(({ step }) => step.id).filter(Boolean).join(", ") || "(none declared)"}`);
169
171
  }
170
172
  const skippedVariant = gatedOut.map(stepName);
173
+ const outside = outsideRootPaths(project.project, selected.map(({ step }) => step)).filter((e) => e.kind === "build-step" && !e.allowed);
174
+ if (outside.length > 0) {
175
+ throw new Error(`${project.project.name}: ${outside.map((e) => e.message).join("; ")}. A build writes only under build.outDir and runs only inside the project; ` +
176
+ `set "allowOutsideRoot": true on the step if this is intended.`);
177
+ }
178
+ if (options.mode === "apply") {
179
+ const exec = selected.flatMap(({ step, stepIndex }) => (step.step === "exec" ? [`"${stepName({ step, stepIndex })}" (${step.command})`] : []));
180
+ const what = describeNeeds({ toolchain: Object.keys(project.project.toolchain ?? {}), exec });
181
+ if (what && !(await (options.isTrusted ?? ((root) => isProjectTrusted(root)))(project.root))) {
182
+ throw new Error(trustRefusal(project.root, what));
183
+ }
184
+ }
171
185
  const version = options.version ?? project.project.version ?? "0.0.0";
172
186
  const stagingDir = project.build.staging
173
187
  ? path.resolve(project.root, project.build.staging)
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { isDirectory, walk } from "../../fsutil.js";
4
4
  import { globToRegExp } from "../../validate/context.js";
5
5
  import { runTool } from "../../toolchain/run.js";
6
+ import { DEFAULT_TOOL_TIMEOUT_MS } from "../../toolchain/types.js";
6
7
  import { TOOL_SPECS } from "../../toolchain/specs.js";
7
8
  import { directoryEntry, pathEntries } from "../manifest.js";
8
9
  import { resolveOutDirPath, resolveSourceOrStagePath, resolveStageId } from "../paths.js";
@@ -86,9 +87,8 @@ export async function runZipStep(step, ctx) {
86
87
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
87
88
  await fs.mkdir(path.dirname(out), { recursive: true });
88
89
  await writeZipFile(out, entries.map((entry) => ({ name: entry.name, read: () => fs.readFile(entry.abs) })));
89
- const stat = await fs.stat(out);
90
- if (stat.size === 0)
91
- throw new BuildStepError(`zip: wrote an empty archive: ${out}`, step.id, step.step);
90
+ if (entries.length === 0)
91
+ throw new BuildStepError(`zip: nothing to archive under ${from}`, step.id, step.step);
92
92
  const outputs = await pathEntries([out]);
93
93
  return {
94
94
  inputs: [await directoryEntry(from)],
@@ -128,7 +128,7 @@ async function resolveCommandLocation(step, ctx) {
128
128
  }
129
129
  return { id: "exec", label, found: true, path: resolveSourceOrStagePath(step.command, ctx), usedBy: [] };
130
130
  }
131
- export const EXEC_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
131
+ export const EXEC_DEFAULT_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS;
132
132
  export async function runExecStep(step, ctx) {
133
133
  const location = await resolveCommandLocation(step, ctx);
134
134
  const command = location.path;
@@ -1,7 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
- import { pathExists, walk } from "../fsutil.js";
3
+ import { isDirectory, pathExists, walk } from "../fsutil.js";
4
4
  import { globToRegExp, isExcludedByGlobs } from "../validate/index.js";
5
+ import { outsideRootPaths } from "../project/outside-root.js";
5
6
  import { findRunningProcesses } from "../process.js";
6
7
  import { executePlan, isIdenticalFile, planCopy, planDelete, planJunction, planMkdir, planUnlink, sha256File } from "../safety/index.js";
7
8
  import { classifyReload } from "./reload.js";
@@ -158,6 +159,60 @@ export async function planDeploy(inputs) {
158
159
  backupRoot,
159
160
  };
160
161
  }
162
+ if (inputs.variant !== undefined) {
163
+ const named = new Set([
164
+ ...deploy.targets.flatMap((t) => (t.variant ? [t.variant] : [])),
165
+ ...Object.keys(project.project.build?.variants ?? {}),
166
+ ]);
167
+ if (!named.has(inputs.variant)) {
168
+ const known = [...named].sort();
169
+ return {
170
+ blocked: [
171
+ {
172
+ code: "unknown-variant",
173
+ message: `${project.project.name}: variant "${inputs.variant}" is named by no deploy target and no build.variants entry` +
174
+ (known.length > 0 ? ` (known: ${known.join(", ")})` : " (this project declares none)") +
175
+ "; nothing was deployed rather than silently deploying only the ungated targets.",
176
+ detail: { variant: inputs.variant, known },
177
+ },
178
+ ],
179
+ confidence: { level: "high", assumptions: [], verifiedBy: ["modwright.json"] },
180
+ targets: [],
181
+ skipped: [],
182
+ deferred: [],
183
+ junctions: [],
184
+ pruned: [],
185
+ skippedDestDirs: [],
186
+ excluded: [],
187
+ warnings,
188
+ writtenPaths: [],
189
+ processCheck: emptyProcessCheck,
190
+ backupRoot,
191
+ };
192
+ }
193
+ }
194
+ const outside = outsideRootPaths(project.project).filter((e) => (e.kind === "deploy-target" || e.kind === "junction") && !e.allowed);
195
+ if (outside.length > 0) {
196
+ return {
197
+ blocked: outside.map((e) => ({
198
+ code: "outside-root",
199
+ message: `${e.message}; a deploy writes only under the game's mod roots. Set "allowOutsideRoot": true on that entry if this is intended.`,
200
+ detail: { kind: e.kind, value: e.value },
201
+ })),
202
+ confidence: { level: "high", assumptions: [], verifiedBy: ["modwright.json"] },
203
+ targets: [],
204
+ skipped: [],
205
+ deferred: [],
206
+ junctions: [],
207
+ pruned: [],
208
+ skippedDestDirs: [],
209
+ excluded: [],
210
+ warnings,
211
+ writtenPaths: [],
212
+ processCheck: emptyProcessCheck,
213
+ backupRoot,
214
+ };
215
+ }
161
216
  const roots = inputs.roots ?? (inputs.install ? surface.modRoots(inputs.install) : undefined);
162
217
  if (!roots) {
163
218
  return {
@@ -202,6 +257,7 @@ export async function planDeploy(inputs) {
202
257
  }
203
258
  if (target.mode && target.mode !== deployMode) {
204
259
  skipped.push({ rootId: target.rootId, from: target.from, reason: `target is mode "${target.mode}", this deploy is mode "${deployMode}"` });
260
+ variantExcludedTargets.add(target);
205
261
  const skippedRoot = roots.find((r) => r.id === target.rootId);
206
262
  if (skippedRoot && (target.locked ?? defaultLocked(target.rootId))) {
207
263
  notDeployedLocked.push({ target, rootPath: skippedRoot.path });
@@ -365,7 +421,18 @@ export async function planDeploy(inputs) {
365
421
  }
366
422
  }
367
423
  else {
424
+ const fold = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
368
425
  for (const j of junctions) {
426
+ if (!(await isDirectory(j.target))) {
427
+ blocked.push({
428
+ code: "junction-target-missing",
429
+ message: `deploy.junctions: ${j.linkPath} would point at ${j.target}, which is not a directory. Build or create it first, or fix the junction's target.`,
430
+ detail: { linkPath: j.linkPath, target: j.target },
431
+ });
432
+ continue;
433
+ }
434
+ if (j.before === "link" && j.linkTargetBefore !== undefined && fold(j.linkTargetBefore) === fold(j.target))
435
+ continue;
369
436
  if (j.before === "directory" || j.before === "file") {
370
437
  warnings.push(`${j.linkPath} is a real ${j.before}, not a link. It will be backed up and replaced by a link to ${j.target}.`);
371
438
  }
@@ -331,9 +331,11 @@ export function classifyReload(surfaceId, project, writtenPaths, options = {}) {
331
331
  .map((c) => RANK[c.action]);
332
332
  if (unclassified.length > 0)
333
333
  ranked.push(RANK.restart);
334
- const overall = ranked.length === 0
335
- ? "unknown"
336
- : Object.keys(RANK).find((a) => RANK[a] === Math.max(...ranked));
334
+ const overall = writtenPaths.length === 0
335
+ ? "none"
336
+ : ranked.length === 0
337
+ ? "unknown"
338
+ : Object.keys(RANK).find((a) => RANK[a] === Math.max(...ranked));
337
339
  const notes = [];
338
340
  if (writtenPaths.length === 0) {
339
341
  notes.push("nothing was written, so there is nothing to reload");
@@ -5,16 +5,33 @@ const NONE = { skipped: true, applied: [], refused: [] };
5
5
  function toPosix(p) {
6
6
  return p.split(path.sep).join("/");
7
7
  }
8
+ function relPosix(projectRoot, f) {
9
+ return toPosix(path.isAbsolute(f) ? path.relative(projectRoot, f) : f);
10
+ }
8
11
  function claimFileSet(claim, projectRoot) {
9
12
  const files = claim.scope?.files ?? [];
10
- return new Set(files.map((f) => toPosix(path.isAbsolute(f) ? path.relative(projectRoot, f) : f)));
13
+ return new Set(files.map((f) => relPosix(projectRoot, f)));
14
+ }
15
+ function sameFile(a, b) {
16
+ if (a === b)
17
+ return true;
18
+ const lower = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
19
+ const x = lower(a);
20
+ const y = lower(b);
21
+ return x.endsWith(`/${y}`) || y.endsWith(`/${x}`);
22
+ }
23
+ function anyMatch(files, f) {
24
+ for (const other of files)
25
+ if (sameFile(other, f))
26
+ return true;
27
+ return false;
11
28
  }
12
29
  export async function applyValidateLedgerHook(projectRoot, blockingFindingFiles, ranFiles, tool = "validate", options = {}) {
13
30
  const loaded = await loadClaims(projectRoot);
14
31
  if (!loaded.exists)
15
32
  return NONE;
16
- const blocked = new Set(blockingFindingFiles.map(toPosix));
17
- const ran = ranFiles ? new Set(ranFiles.map(toPosix)) : undefined;
33
+ const blocked = blockingFindingFiles.map((f) => relPosix(projectRoot, f));
34
+ const ran = ranFiles ? ranFiles.map((f) => relPosix(projectRoot, f)) : undefined;
18
35
  const by = options.by ?? "validate";
19
36
  let claims = loaded.claims;
20
37
  const applied = [];
@@ -25,10 +42,10 @@ export async function applyValidateLedgerHook(projectRoot, blockingFindingFiles,
25
42
  const files = claimFileSet(claim, projectRoot);
26
43
  if (files.size === 0)
27
44
  continue;
28
- const allRan = !ran || [...files].every((f) => ran.has(f));
45
+ const allRan = !ran || [...files].every((f) => anyMatch(ran, f));
29
46
  if (!allRan)
30
47
  continue;
31
- const anyBlocked = [...files].some((f) => blocked.has(f));
48
+ const anyBlocked = [...files].some((f) => anyMatch(blocked, f));
32
49
  if (anyBlocked)
33
50
  continue;
34
51
  const result = applyClaimTransition({ claims, id: claim.id, to: "checker-clean", tool, by, now: options.now });
@@ -102,14 +102,30 @@ export async function scanSource(source, signatures, scope, ctx) {
102
102
  let text = "";
103
103
  const length = sizeBytes - startOffset;
104
104
  if (length > 0) {
105
- const handle = await fs.open(source.path, "r");
106
105
  try {
107
- const buffer = Buffer.alloc(length);
108
- await handle.read(buffer, 0, length, startOffset);
109
- text = buffer.toString("utf8");
106
+ const handle = await fs.open(source.path, "r");
107
+ try {
108
+ const buffer = Buffer.alloc(length);
109
+ await handle.read(buffer, 0, length, startOffset);
110
+ text = buffer.toString("utf8");
111
+ }
112
+ finally {
113
+ await handle.close();
114
+ }
110
115
  }
111
- finally {
112
- await handle.close();
116
+ catch (err) {
117
+ const status = {
118
+ source,
119
+ sizeBytes,
120
+ modifiedAt: modifiedAt.toISOString(),
121
+ zeroByte,
122
+ linesScanned: 0,
123
+ findingCount: 0,
124
+ unreadable: err.message,
125
+ };
126
+ if (stale !== undefined)
127
+ status.stale = stale;
128
+ return { status, findings: [] };
113
129
  }
114
130
  }
115
131
  if (startOffset === 0 && text.charCodeAt(0) === 0xfeff)
@@ -1,5 +1,6 @@
1
1
  export * from "./types.js";
2
2
  export { modProjectSchema, parseProject } from "./schema.js";
3
3
  export { findProjectFile, loadProject } from "./load.js";
4
+ export { escapesRoot, outsideRootPaths } from "./outside-root.js";
4
5
  export { describeProject } from "./describe.js";
5
6
  export { defaultArtifactName, normalizeBuildSteps } from "./build-steps.js";
@@ -4,6 +4,7 @@ import { isDirectory, pathExists } from "../fsutil.js";
4
4
  import { getSurface, surfaceIds } from "../../surfaces/index.js";
5
5
  import { parseProject } from "./schema.js";
6
6
  import { PROJECT_FILE } from "./types.js";
7
+ import { looksAbsolute, outsideRootPaths } from "./outside-root.js";
7
8
  export async function findProjectFile(startDir) {
8
9
  let dir = path.resolve(startDir);
9
10
  for (;;) {
@@ -16,9 +17,6 @@ export async function findProjectFile(startDir) {
16
17
  dir = parent;
17
18
  }
18
19
  }
19
- function looksAbsolute(p) {
20
- return path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
21
- }
22
20
  function isWithin(parent, child) {
23
21
  const rel = path.relative(parent, child);
24
22
  return rel === "" || (!(rel === ".." || rel.startsWith(`..${path.sep}`)) && !path.isAbsolute(rel));
@@ -115,29 +113,8 @@ export async function loadProject(fileOrDir) {
115
113
  }
116
114
  }
117
115
  }
118
- const escapes = (p) => p !== undefined && (looksAbsolute(p) || p.split(/[\\/]/).includes(".."));
119
- for (const target of project.deploy?.targets ?? []) {
120
- if (escapes(target.into)) {
121
- warnings.push(`deploy target "${target.from}": into "${target.into}" leaves the "${target.rootId}" root (absolute or "..")`);
122
- }
123
- }
124
- for (const junction of project.deploy?.junctions ?? []) {
125
- if (escapes(junction.linkPath)) {
126
- warnings.push(`junction linkPath "${junction.linkPath}" leaves the "${junction.rootId}" root (absolute or "..")`);
127
- }
128
- }
129
- for (const step of project.build?.steps ?? []) {
130
- if (typeof step !== "object" || step === null)
131
- continue;
132
- const s = step;
133
- if ((s.step === "wolvenkit-pack" || s.step === "divine-create-package") && escapes(s.out)) {
134
- warnings.push(`build step ${s.step}: out "${s.out}" leaves build.outDir (absolute or "..")`);
135
- }
136
- if (s.step === "zip" && escapes(s.name))
137
- warnings.push(`build step zip: name "${s.name}" leaves build.outDir (absolute or "..")`);
138
- if (s.step === "exec" && s.cwd !== undefined && !s.cwd.includes("${stage}") && escapes(s.cwd)) {
139
- warnings.push(`build step exec: cwd "${s.cwd}" leaves the project (absolute or "..")`);
140
- }
116
+ for (const entry of outsideRootPaths(project)) {
117
+ warnings.push(entry.allowed ? `${entry.message} — allowed by allowOutsideRoot` : `${entry.message}; build and deploy refuse it unless the entry sets "allowOutsideRoot": true`);
141
118
  }
142
119
  const toolchain = project.toolchain
143
120
  ? Object.fromEntries(Object.entries(project.toolchain).map(([id, p]) => [id, looksAbsolute(p) ? p : resolveFromRoot(p)]))
@@ -0,0 +1,36 @@
1
+ import * as path from "node:path";
2
+ export function looksAbsolute(p) {
3
+ return path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
4
+ }
5
+ export function escapesRoot(p) {
6
+ return p !== undefined && (looksAbsolute(p) || p.split(/[\\/]/).includes(".."));
7
+ }
8
+ export function outsideRootPaths(project, steps = project.build?.steps) {
9
+ const found = [];
10
+ for (const target of project.deploy?.targets ?? []) {
11
+ if (escapesRoot(target.into)) {
12
+ found.push({ kind: "deploy-target", value: target.into, allowed: target.allowOutsideRoot === true, message: `deploy target "${target.from}": into "${target.into}" leaves the "${target.rootId}" root (absolute or "..")` });
13
+ }
14
+ }
15
+ for (const junction of project.deploy?.junctions ?? []) {
16
+ if (escapesRoot(junction.linkPath)) {
17
+ found.push({ kind: "junction", value: junction.linkPath, allowed: junction.allowOutsideRoot === true, message: `junction linkPath "${junction.linkPath}" leaves the "${junction.rootId}" root (absolute or "..")` });
18
+ }
19
+ }
20
+ for (const step of steps ?? []) {
21
+ if (typeof step !== "object" || step === null)
22
+ continue;
23
+ const s = step;
24
+ const label = `build step ${s.id ? `"${s.id}" (${s.step})` : s.step}`;
25
+ const allowed = s.allowOutsideRoot === true;
26
+ if ((s.step === "wolvenkit-pack" || s.step === "divine-create-package") && escapesRoot(s.out)) {
27
+ found.push({ kind: "build-step", value: s.out, allowed, message: `${label}: out "${s.out}" leaves build.outDir (absolute or "..")` });
28
+ }
29
+ if (s.step === "zip" && escapesRoot(s.name))
30
+ found.push({ kind: "build-step", value: s.name, allowed, message: `${label}: name "${s.name}" leaves build.outDir (absolute or "..")` });
31
+ if (s.step === "exec" && s.cwd !== undefined && !s.cwd.includes("${stage}") && escapesRoot(s.cwd)) {
32
+ found.push({ kind: "build-step", value: s.cwd, allowed, message: `${label}: cwd "${s.cwd}" leaves the project (absolute or "..")` });
33
+ }
34
+ }
35
+ return found;
36
+ }