modwright 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +123 -15
  2. package/bridges/bg3-se/README.md +15 -0
  3. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  4. package/bridges/cp2077-cet/README.md +17 -1
  5. package/bridges/cp2077-cet/bridge.json +1 -1
  6. package/dist/core/bridge/pending.js +8 -1
  7. package/dist/core/bridge/stage.js +2 -2
  8. package/dist/core/build/run.js +14 -0
  9. package/dist/core/build/stage.js +3 -2
  10. package/dist/core/build/steps/core.js +4 -4
  11. package/dist/core/deploy/plan.js +68 -1
  12. package/dist/core/deploy/reload.js +5 -3
  13. package/dist/core/ini.js +26 -0
  14. package/dist/core/knowledge/facts.js +0 -12
  15. package/dist/core/ledger/hooks.js +22 -5
  16. package/dist/core/logs/registry.js +19 -0
  17. package/dist/core/logs/scan.js +65 -10
  18. package/dist/core/logs/triage.js +79 -2
  19. package/dist/core/managers/mo2.js +121 -0
  20. package/dist/core/managers/vortex.js +52 -0
  21. package/dist/core/ownership/index.js +364 -0
  22. package/dist/core/project/index.js +2 -1
  23. package/dist/core/project/load.js +7 -25
  24. package/dist/core/project/outside-root.js +36 -0
  25. package/dist/core/project/schema.js +38 -1
  26. package/dist/core/safety/backup.js +2 -2
  27. package/dist/core/safety/execute.js +12 -4
  28. package/dist/core/safety/restore.js +62 -0
  29. package/dist/core/testplan/registry.js +51 -2
  30. package/dist/core/text/converters/index.js +1 -1
  31. package/dist/core/text/toolchain.js +2 -2
  32. package/dist/core/toolchain/locate.js +13 -1
  33. package/dist/core/toolchain/run.js +6 -4
  34. package/dist/core/toolchain/types.js +1 -1
  35. package/dist/core/trust.js +105 -0
  36. package/dist/core/writes/index.js +206 -0
  37. package/dist/index.js +33 -9
  38. package/dist/server.js +252 -34
  39. package/dist/surfaces/baldursgate3/surface.js +3 -0
  40. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  41. package/dist/surfaces/baldursgate3/writes.js +152 -0
  42. package/dist/surfaces/cyberpunk2077/compat.js +8 -4
  43. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  44. package/dist/surfaces/cyberpunk2077/logs.js +86 -6
  45. package/dist/surfaces/cyberpunk2077/surface.js +13 -1
  46. package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
  47. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  48. package/dist/surfaces/eldenring/loaders.js +7 -1
  49. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  50. package/dist/surfaces/skyrimse/surface.js +1 -0
  51. package/dist/surfaces/stardewvalley/logs.js +41 -0
  52. package/dist/surfaces/stardewvalley/surface.js +2 -0
  53. package/dist/surfaces/stardewvalley/writes.js +136 -0
  54. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  55. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  56. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  57. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  58. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  59. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  60. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  61. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  62. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  63. package/package.json +1 -1
  64. package/scaffolds/baldursgate3/new-project.yaml +4 -1
  65. package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
@@ -2,6 +2,13 @@ import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { copyFilePreservingMtime, readManifest, sha256File } from "./backup.js";
4
4
  import { isDirectory } from "../fsutil.js";
5
+ function outsideConfinement(entries, roots) {
6
+ const fold = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
7
+ const folded = roots.map(fold);
8
+ return entries
9
+ .map((e) => e.originalPath)
10
+ .filter((p) => !folded.some((root) => isUnder(root, fold(p))));
11
+ }
5
12
  export async function listBackups(backupRoot) {
6
13
  let entries;
7
14
  try {
@@ -86,6 +93,14 @@ export async function restoreBackup(backupDir, options = {}) {
86
93
  if (!("kind" in entry))
87
94
  backupFileFor(backupDir, entry.backupPath);
88
95
  }
96
+ if (options.confineTo) {
97
+ const outside = outsideConfinement(manifest.entries, options.confineTo);
98
+ if (outside.length > 0) {
99
+ const shown = outside.slice(0, 5).join(", ") + (outside.length > 5 ? ` and ${outside.length - 5} more` : "");
100
+ throw new Error(`safety: this backup would write outside the project and the game's mod roots (${shown}); nothing was restored. ` +
101
+ `Allowed: ${options.confineTo.join(", ")}. If the manifest is yours and the paths are right, pass allowOutsideRoots: true.`);
102
+ }
103
+ }
89
104
  if (mode === "dry-run") {
90
105
  const operations = [];
91
106
  for (const entry of manifest.entries) {
@@ -110,6 +125,7 @@ export async function restoreBackup(backupDir, options = {}) {
110
125
  const results = [];
111
126
  const locked = [];
112
127
  let stopped = false;
128
+ const commonRoot = commonAncestor(manifest.entries.filter((e) => !("kind" in e)).map((e) => path.dirname(e.originalPath)));
113
129
  for (const entry of manifest.entries) {
114
130
  if ("kind" in entry) {
115
131
  const op = { kind: "junction", path: entry.originalPath, target: entry.linkTarget };
@@ -141,6 +157,16 @@ export async function restoreBackup(backupDir, options = {}) {
141
157
  if (sha256 !== entry.sha256) {
142
158
  throw new Error(`backup of ${entry.originalPath} is corrupted: manifest says sha256 ${entry.sha256}, file on disk hashes to ${sha256}`);
143
159
  }
160
+ const link = await linkAncestor(entry.originalPath, commonRoot);
161
+ if (link) {
162
+ results.push({
163
+ op,
164
+ status: "failed",
165
+ error: `${link} is now a junction/symlink, so restoring ${entry.originalPath} would write through it into ${await fs.realpath(link).catch(() => "its target")}. ` +
166
+ `Remove the link first (deploy --deployMode pak --unlink, or by hand) and run the restore again.`,
167
+ });
168
+ continue;
169
+ }
144
170
  await copyFilePreservingMtime(src, entry.originalPath);
145
171
  results.push({ op, status: "applied" });
146
172
  }
@@ -170,3 +196,39 @@ export async function restoreBackup(backupDir, options = {}) {
170
196
  : "An integrity check failed before that file was restored; the backup itself is untouched, so fix the problem and retry."),
171
197
  };
172
198
  }
199
+ function commonAncestor(dirs) {
200
+ if (dirs.length === 0)
201
+ return undefined;
202
+ let common = path.resolve(dirs[0]);
203
+ for (const dir of dirs.slice(1)) {
204
+ let candidate = path.resolve(dir);
205
+ while (candidate !== common && !isUnder(common, candidate)) {
206
+ const parent = path.dirname(common);
207
+ if (parent === common)
208
+ return undefined;
209
+ common = parent;
210
+ }
211
+ }
212
+ return common;
213
+ }
214
+ function isUnder(parent, child) {
215
+ const rel = path.relative(parent, child);
216
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
217
+ }
218
+ async function linkAncestor(file, stopAt) {
219
+ let dir = path.dirname(path.resolve(file));
220
+ for (;;) {
221
+ try {
222
+ if ((await fs.lstat(dir)).isSymbolicLink())
223
+ return dir;
224
+ }
225
+ catch {
226
+ }
227
+ if (stopAt === undefined || dir === stopAt || !isUnder(stopAt, dir))
228
+ return undefined;
229
+ const parent = path.dirname(dir);
230
+ if (parent === dir)
231
+ return undefined;
232
+ dir = parent;
233
+ }
234
+ }
@@ -262,7 +262,11 @@ const CP2077_PROBES = [
262
262
  summary: "Where an entity is. Attested for item objects; unverified for a sector-placed prop.",
263
263
  citation: "cp2077.entity.transform",
264
264
  paramsSchema: z.object({ entityRef: str() }).strict(),
265
- evidence: "Observed in game: a test mod's own init.lua comment says its tag \"removes legacy dynamic cases from older experiments\" and the real case is a static sector prop, so GetTaggedIDs on it returned 0 twice at two locations — not a location miss. That tag marks legacy dev spawns only and is the wrong entry point for a sector-placed prop; a sector-placed prop needs a different probe mechanism, not just a re-run of this one. Stays unverified.",
265
+ evidence: "Observed in game: a test mod's own init.lua comment says its tag \"removes legacy dynamic cases from older experiments\" and the real case is a static sector prop, so GetTaggedIDs on it returned 0 twice at two locations — not a location miss. That tag marks legacy dev spawns only and is the wrong entry point for a sector-placed prop; a sector-placed prop needs a different probe mechanism, not just a re-run of this one. Stays unverified. " +
266
+ "RTTI (rayshader/cp2077-nativedb b5d29af, game 2.31): both calls are declared on entEntity itself — " +
267
+ "GetWorldPosition() -> Vector4, GetWorldOrientation() -> Quaternion — and a loot-container prop's entity class " +
268
+ "(LootContainerObjectAnimatedByTransform) reaches entEntity through gameLootContainerBase/gameObject/entGameEntity, " +
269
+ "so such a prop has both methods by inheritance. The methods were never in doubt; getting a handle to the prop is.",
266
270
  },
267
271
  {
268
272
  kind: "cp2077.entity.appearance",
@@ -273,6 +277,12 @@ const CP2077_PROBES = [
273
277
  summary: "Acceptance regression, differential: did the sector node's appearanceName resolve?",
274
278
  citation: "cp2077.entity.appearance",
275
279
  paramsSchema: z.object({ tag: str(), baselineId: str() }).strict(),
280
+ evidence: "Composite of cp2077.entity.tagged + .components + .transform against a baseline; blocked the same way " +
281
+ "cp2077.entity.tagged is for a sector-placed prop (its first step). RTTI (rayshader/cp2077-nativedb " +
282
+ "b5d29af, game 2.31): entEntity::GetCurrentAppearanceName() -> CName exists, inherited by loot-container " +
283
+ "props, and entIComponent::GetAppearanceName() -> CName exists per component. That is a direct read of the " +
284
+ "question this row asks (did appearanceName resolve?) and a smaller call than the composite, but no CET mod " +
285
+ "or wiki page calls it from Lua, so it is a candidate for the next live run, not a redesign of this row.",
276
286
  },
277
287
  {
278
288
  kind: "cp2077.tweakdb.record",
@@ -302,9 +312,48 @@ const CP2077_PROBES = [
302
312
  context: "cet",
303
313
  effect: "read",
304
314
  status: "unverified",
305
- summary: "An interaction component is on the entity. The component name is not attested anywhere on disk.",
315
+ summary: "An interaction component is on the entity. The component name is per-entity: a loot-container template " +
316
+ "names it \"interactions\" (class gameinteractionsComponent); nothing calls it from Lua yet.",
306
317
  citation: "cp2077.interaction.present",
307
318
  paramsSchema: z.object({ entityRef: str() }).strict(),
319
+ evidence: "A loot-container entity template's components list { $type: gameinteractionsComponent, name: \"interactions\" } " +
320
+ "alongside inventory, scanning, workspotMapper, root, audioSound, vision, UI_Slots, GameplayRole, state_fxes and " +
321
+ "targeting. RTTI (rayshader/cp2077-nativedb b5d29af): gameinteractionsComponent -> entIPlacedComponent -> " +
322
+ "entIComponent; entEntity::FindComponentByName(componentName: CName) -> ref<entIComponent>; no generic " +
323
+ "enumerate-by-class call exists on entEntity/gameObject, so exact names stay the only path. A design-time " +
324
+ "template field is not an in-engine observation that the component resolves on the streamed instance: stays unverified.",
325
+ },
326
+ {
327
+ kind: "cp2077.entity.nearby",
328
+ game: "cyberpunk2077",
329
+ context: "cet",
330
+ effect: "read",
331
+ status: "unverified",
332
+ summary: "Entities within a radius of an anchor entity (the player by default). filter \"npc\" (default) asks " +
333
+ "GetNPCsAroundObject; filter \"objects\" asks GetEntitiesAroundObject with an Obj_*-wide target search " +
334
+ "filter, the shape that could find a placed static prop.",
335
+ citation: "cp2077.entity.nearby",
336
+ paramsSchema: z
337
+ .object({
338
+ radius: z.number().positive(),
339
+ filter: z.enum(["npc", "objects"]).optional(),
340
+ entityRef: str().optional(),
341
+ tag: str().optional(),
342
+ index: z.number().int().positive().optional(),
343
+ })
344
+ .strict(),
345
+ evidence: "rayshader/cp2077-nativedb at b5d29af (v2.1.0, game 2.31; RTTI dumped by WopsS/RED4.RTTIDumper) classes.json: " +
346
+ "gameObject (parent entGameEntity) carries GetNPCsAroundObject(range: Float) -> array<ref<NPCPuppet>> and " +
347
+ "GetEntitiesAroundObject(range: Float, searchFilter: gameTargetSearchFilter) -> array<ref<entEntity>>; " +
348
+ "globals.json lists the filter constructors TSF_All/TSF_Any/TSF_Not/TSF_And/TSF_Or/TSF_NPC/TSF_EnemyNPC/" +
349
+ "TSF_Quickhackable and enums.json the mask members (TSFMV). No shipped CET mod or wiki page calls any of it, " +
350
+ "but CET's own source (yamashi/CyberEngineTweaks 9a8522f; knowledge facts cp2077.cet.*) pins the Lua side: " +
351
+ "globals are exposed bare and via Game.*, a returned struct passes back as a parameter, an undeclared enum value " +
352
+ "is coerced to 0 silently (so the handler builds the filter from declared members via Enum.new and TSF_Or, never " +
353
+ "an ORed mask), and array returns are 1-based tables with nil holes for null handles. What no source can supply " +
354
+ "is the live result: TSF_Any/TSF_Or semantics, whether a sector-placed prop is in the targeting set (a " +
355
+ "loot-container template does carry a gameTargetingComponent), and whether the game marks TSF_* isExec. Never " +
356
+ "run in game; stays unverified until a live run exercises it.",
308
357
  },
309
358
  ];
310
359
  export const PROBE_CATALOG = [...BG3_PROBES, ...CP2077_PROBES];
@@ -221,7 +221,7 @@ export async function convert(id, sourceAbs, destAbs, options = {}) {
221
221
  const plan = {
222
222
  purpose: `text convert ${entry.id}: ${path.basename(sourceAbs)} -> ${path.basename(destAbs)}`,
223
223
  operations: [planFile(destAbs, content)],
224
- backupRoot: path.join(path.dirname(destAbs), ".modwright", "backups"),
224
+ ...(options.backupRoot ? { backupRoot: options.backupRoot } : {}),
225
225
  };
226
226
  const writeReport = await executePlan(plan, {
227
227
  mode: "apply",
@@ -1,5 +1,5 @@
1
1
  import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
2
- export function defaultLocateTool(projectOverrides, probe = "all") {
2
+ export function defaultLocateTool(projectOverrides, probe = "all", projectTrust) {
3
3
  return async (id) => {
4
4
  const spec = TOOL_SPECS.find((s) => s.id === id);
5
5
  if (!spec) {
@@ -11,6 +11,6 @@ export function defaultLocateTool(projectOverrides, probe = "all") {
11
11
  hint: `"${id}" is not a known tool id in ModWright's toolchain catalog (see core/toolchain/specs.ts).`,
12
12
  };
13
13
  }
14
- return coreLocateTool(spec, { projectOverrides, probe });
14
+ return coreLocateTool(spec, { projectOverrides, probe, ...(projectTrust ? { projectTrust } : {}) });
15
15
  };
16
16
  }
@@ -1,9 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { killTree } from "./run.js";
2
3
  import { promises as fs } from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { StringDecoder } from "node:string_decoder";
5
6
  import { pathExists } from "../fsutil.js";
6
7
  import { userDataDir } from "../home.js";
8
+ import { trustRefusal } from "../trust.js";
7
9
  import { loadUserConfig } from "../userconfig.js";
8
10
  import { toolEnvironment } from "./env.js";
9
11
  import { TOOL_SPECS } from "./specs.js";
@@ -145,7 +147,7 @@ async function runVersionProbe(execPath, versionArgs, versionPattern) {
145
147
  resolve(result);
146
148
  };
147
149
  const timer = setTimeout(() => {
148
- child.kill();
150
+ killTree(child);
149
151
  finish(out);
150
152
  }, VERSION_PROBE_TIMEOUT_MS);
151
153
  const outDecoder = new StringDecoder("utf8");
@@ -227,6 +229,16 @@ export async function locateTool(spec, options = {}) {
227
229
  return found(fromEnv, "env");
228
230
  const override = options.projectOverrides?.[spec.id];
229
231
  const fromProject = override ? await resolveOverride(override, spec) : undefined;
232
+ if (fromProject && options.projectTrust && !options.projectTrust.trusted) {
233
+ return {
234
+ ...base,
235
+ found: false,
236
+ path: fromProject,
237
+ source: "project",
238
+ untrusted: true,
239
+ hint: trustRefusal(options.projectTrust.root, `its modwright.json points ${spec.id} at ${fromProject}`),
240
+ };
241
+ }
230
242
  if (fromProject)
231
243
  return found(fromProject, "project");
232
244
  const userTools = options.userTools === undefined ? (await loadUserConfig()).config.tools : options.userTools;
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  import { toolEnvironment } from "./env.js";
6
+ import { DEFAULT_TOOL_TIMEOUT_MS } from "./types.js";
6
7
  const MAX_CAPTURE_BYTES = 1_000_000;
7
8
  const TRUNCATION_NOTE = "\n...[truncated]";
8
9
  export function assertAbsolute(paths) {
@@ -86,7 +87,7 @@ export function batchFileArgv(file, args, env = process.env) {
86
87
  export function isBatchFile(p) {
87
88
  return /\.(bat|cmd)$/i.test(p);
88
89
  }
89
- function killTree(child) {
90
+ export function killTree(child) {
90
91
  if (process.platform === "win32" && child.pid !== undefined) {
91
92
  try {
92
93
  spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => child.kill());
@@ -151,7 +152,8 @@ export async function runTool(location, options) {
151
152
  resolve(spawnFailed(location, command, args, started, err));
152
153
  return;
153
154
  }
154
- const timer = options.timeoutMs
155
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
156
+ const timer = timeoutMs
155
157
  ? setTimeout(() => {
156
158
  if (settled)
157
159
  return;
@@ -171,10 +173,10 @@ export async function runTool(location, options) {
171
173
  stderrTruncated: stderrTruncated.value,
172
174
  error: {
173
175
  code: "timeout",
174
- message: `${location.label} did not finish within ${options.timeoutMs}ms.`,
176
+ message: `${location.label} did not finish within ${timeoutMs}ms.`,
175
177
  },
176
178
  });
177
- }, options.timeoutMs)
179
+ }, timeoutMs)
178
180
  : undefined;
179
181
  const cap = options.maxCaptureBytes ?? MAX_CAPTURE_BYTES;
180
182
  const stdoutDecoder = new StringDecoder("utf8");
@@ -1 +1 @@
1
- export {};
1
+ export const DEFAULT_TOOL_TIMEOUT_MS = 30 * 60 * 1000;
@@ -0,0 +1,105 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { z } from "zod";
4
+ import { userConfigDir, userDataDir } from "./home.js";
5
+ import { planFile, planMkdir } from "./safety/index.js";
6
+ export const TRUST_SCHEMA_VERSION = 1;
7
+ export const TRUST_ALL_ENV = "MODWRIGHT_TRUST_ALL";
8
+ const trustEntrySchema = z.object({
9
+ root: z.string().min(1),
10
+ name: z.string().optional(),
11
+ trustedAt: z.string().optional(),
12
+ });
13
+ const trustFileSchema = z.object({
14
+ schemaVersion: z.literal(TRUST_SCHEMA_VERSION),
15
+ projects: z.record(trustEntrySchema).default({}),
16
+ });
17
+ export function trustFilePath() {
18
+ return path.join(userConfigDir(), "trusted-projects.json");
19
+ }
20
+ export function trustKey(root, platform = process.platform) {
21
+ const resolved = path.resolve(root).replace(/[\\/]+$/, "");
22
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
23
+ }
24
+ export async function loadTrust(file = trustFilePath()) {
25
+ const empty = { schemaVersion: TRUST_SCHEMA_VERSION, projects: {} };
26
+ let text;
27
+ try {
28
+ text = await fs.readFile(file, "utf8");
29
+ }
30
+ catch (error) {
31
+ const missing = error.code === "ENOENT";
32
+ return { trust: empty, file, exists: !missing, problems: missing ? [] : [`${file} could not be read: ${error.message}`] };
33
+ }
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(text.replace(/^/, ""));
37
+ }
38
+ catch (error) {
39
+ return { trust: empty, file, exists: true, problems: [`${file} is not valid JSON (${error.message}); no project is trusted until it is fixed.`] };
40
+ }
41
+ const parsed = trustFileSchema.safeParse(raw);
42
+ if (!parsed.success) {
43
+ const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
44
+ return { trust: empty, file, exists: true, problems: [`${file} does not match schemaVersion ${TRUST_SCHEMA_VERSION} (${issues}); no project is trusted until it is fixed.`] };
45
+ }
46
+ return { trust: parsed.data, file, exists: true, problems: [] };
47
+ }
48
+ export async function isProjectTrusted(root, options = {}) {
49
+ if ((options.env ?? process.env)[TRUST_ALL_ENV] === "1")
50
+ return true;
51
+ const loaded = await loadTrust(options.file);
52
+ return trustKey(root) in loaded.trust.projects;
53
+ }
54
+ export function projectTrustNeeds(project) {
55
+ const toolchain = Object.keys(project.toolchain ?? {});
56
+ const exec = [];
57
+ (project.build?.steps ?? []).forEach((step, i) => {
58
+ if (typeof step === "string" || step.step !== "exec")
59
+ return;
60
+ exec.push(step.id ? `"${step.id}" (${step.command})` : `#${i} (${step.command})`);
61
+ });
62
+ return { toolchain, exec };
63
+ }
64
+ export function trustRefusal(root, what) {
65
+ return (`${root} is not a trusted project, and ${what}. ModWright runs a project's own executables and commands only after ` +
66
+ `you trust it once on this machine. Read its modwright.json first, then: check_toolchain action=trust projectPath=${JSON.stringify(root)} mode=apply. ` +
67
+ `(${TRUST_ALL_ENV}=1 trusts every project, for CI.)`);
68
+ }
69
+ export function describeNeeds(needs) {
70
+ const parts = [];
71
+ if (needs.exec.length > 0)
72
+ parts.push(`its build runs exec step(s) ${needs.exec.join(", ")}`);
73
+ if (needs.toolchain.length > 0)
74
+ parts.push(`its modwright.json overrides the tool path(s) for ${needs.toolchain.join(", ")}`);
75
+ return parts.length > 0 ? parts.join("; ") : undefined;
76
+ }
77
+ export async function planTrust(action, root, name, options = {}) {
78
+ const file = options.file ?? trustFilePath();
79
+ const loaded = await loadTrust(file);
80
+ if (loaded.problems.length > 0)
81
+ return { ok: false, error: `${loaded.problems.join(" ")} Fix or remove ${file} before changing it through ModWright.` };
82
+ const key = trustKey(root);
83
+ const previous = loaded.trust.projects[key];
84
+ const write = (trust, purpose) => ({
85
+ purpose,
86
+ operations: [planMkdir(path.dirname(file)), planFile(file, JSON.stringify(trust, null, 2) + "\n")],
87
+ backupRoot: path.join(userDataDir(), "backups"),
88
+ });
89
+ if (action === "untrust") {
90
+ if (!previous)
91
+ return { ok: false, error: `${root} is not in ${file}; nothing to untrust.` };
92
+ const { [key]: _dropped, ...rest } = loaded.trust.projects;
93
+ return { ok: true, action, root: path.resolve(root), file, plan: write({ ...loaded.trust, projects: rest }, `stop trusting ${root}`), previous };
94
+ }
95
+ const entry = { root: path.resolve(root), ...(name ? { name } : {}), trustedAt: (options.now ?? (() => new Date()))().toISOString() };
96
+ return {
97
+ ok: true,
98
+ action,
99
+ root: entry.root,
100
+ file,
101
+ plan: write({ ...loaded.trust, projects: { ...loaded.trust.projects, [key]: entry } }, `trust ${root} to run its toolchain overrides and exec steps`),
102
+ entry,
103
+ ...(previous ? { previous } : {}),
104
+ };
105
+ }
@@ -0,0 +1,206 @@
1
+ import { listMods } from "../mods.js";
2
+ const VALUE_CHARS = 200;
3
+ export function shortValue(value) {
4
+ if (value === undefined)
5
+ return undefined;
6
+ const text = typeof value === "string" ? value : JSON.stringify(value);
7
+ if (text === undefined)
8
+ return undefined;
9
+ if (text.length <= VALUE_CHARS)
10
+ return value;
11
+ return `${text.slice(0, VALUE_CHARS)}…`;
12
+ }
13
+ export async function collectWrites(surface, install, options = {}) {
14
+ const collection = {
15
+ writers: [],
16
+ coverage: { modsScanned: 0, writesFound: 0, rootsScanned: [], errors: [], unitNotes: [] },
17
+ };
18
+ if (!surface.extractWrites)
19
+ return collection;
20
+ const listOptions = { fileLimit: 20000 };
21
+ if (options.rootIds)
22
+ listOptions.rootIds = options.rootIds;
23
+ const { mods, roots } = await listMods(surface, install, listOptions);
24
+ const rootById = new Map(roots.map((r) => [r.id, r]));
25
+ const scannedRoots = new Set();
26
+ const byWriter = new Map();
27
+ const scanned = new Set();
28
+ for (const mod of mods) {
29
+ const root = rootById.get(mod.rootId);
30
+ if (!root)
31
+ continue;
32
+ let extraction;
33
+ try {
34
+ extraction = await surface.extractWrites(mod, root, install);
35
+ }
36
+ catch (error) {
37
+ collection.coverage.errors.push({ writerId: mod.id, file: mod.path, error: error.message });
38
+ continue;
39
+ }
40
+ if (!extraction)
41
+ continue;
42
+ scannedRoots.add(root.id);
43
+ if (extraction.writes.length === 0)
44
+ scanned.add(mod.id);
45
+ if (extraction.unitNote && !collection.coverage.unitNotes.includes(extraction.unitNote)) {
46
+ collection.coverage.unitNotes.push(extraction.unitNote);
47
+ }
48
+ for (const err of extraction.errors)
49
+ collection.coverage.errors.push({ writerId: mod.id, ...err });
50
+ for (const write of extraction.writes) {
51
+ const writerId = write.unit ? `${mod.id}/${write.unit}` : mod.id;
52
+ scanned.add(writerId);
53
+ let writer = byWriter.get(writerId);
54
+ if (!writer) {
55
+ writer = { writerId, name: write.unit ?? mod.name, rootId: mod.rootId, writes: [] };
56
+ const state = write.unit ? extraction.unitStates?.[write.unit] : undefined;
57
+ const inactive = state?.inactive ?? extraction.inactive;
58
+ const identity = state?.identity ?? extraction.identity;
59
+ if (inactive)
60
+ writer.inactive = inactive;
61
+ if (identity)
62
+ writer.identity = identity;
63
+ byWriter.set(writerId, writer);
64
+ }
65
+ writer.writes.push(write);
66
+ collection.coverage.writesFound++;
67
+ }
68
+ }
69
+ collection.writers = [...byWriter.values()];
70
+ collection.coverage.modsScanned = scanned.size;
71
+ const inactive = collection.writers.filter((w) => w.inactive).length;
72
+ if (inactive > 0)
73
+ collection.coverage.inactiveWriters = inactive;
74
+ collection.coverage.rootsScanned = [...scannedRoots];
75
+ return collection;
76
+ }
77
+ function groupByKey(collection, keep) {
78
+ const byKey = new Map();
79
+ for (const writer of collection.writers) {
80
+ for (const write of writer.writes) {
81
+ if (!keep(write))
82
+ continue;
83
+ const k = write.key.toLowerCase();
84
+ let entry = byKey.get(k);
85
+ if (!entry) {
86
+ entry = { key: write.key, keyKind: write.keyKind, contested: false, writers: [] };
87
+ byKey.set(k, entry);
88
+ }
89
+ const row = {
90
+ writerId: writer.writerId,
91
+ name: writer.name,
92
+ op: write.op,
93
+ file: write.file,
94
+ certainty: write.certainty,
95
+ };
96
+ const value = shortValue(write.value);
97
+ if (value !== undefined)
98
+ row.value = value;
99
+ if (write.line !== undefined)
100
+ row.line = write.line;
101
+ if (write.condition !== undefined)
102
+ row.condition = write.condition;
103
+ if (writer.inactive)
104
+ row.inactive = writer.inactive;
105
+ if (writer.identity)
106
+ row.identity = writer.identity;
107
+ entry.writers.push(row);
108
+ }
109
+ }
110
+ for (const entry of byKey.values()) {
111
+ const live = entry.writers.filter((w) => !w.inactive);
112
+ const writers = new Set(live.map((w) => w.identity ?? w.writerId));
113
+ if (writers.size < 2)
114
+ continue;
115
+ if (new Set(live.map((w) => JSON.stringify([w.op, w.value]))).size === 1) {
116
+ entry.agree = true;
117
+ continue;
118
+ }
119
+ if (live.every((w) => w.op === "append" || w.op === "remove")) {
120
+ const items = (op) => new Set(live.filter((w) => w.op === op).flatMap((w) => (Array.isArray(w.value) ? w.value : [w.value])).map((v) => JSON.stringify(v)));
121
+ const appended = items("append");
122
+ entry.contested = [...items("remove")].some((v) => appended.has(v));
123
+ continue;
124
+ }
125
+ entry.contested = true;
126
+ }
127
+ return byKey;
128
+ }
129
+ export function whoTouches(collection, query, limit = 50) {
130
+ const q = query.trim().toLowerCase();
131
+ const all = groupByKey(collection, () => true);
132
+ const pick = (matchedBy, keys) => {
133
+ keys.sort((a, b) => Number(b.contested) - Number(a.contested) || a.key.localeCompare(b.key));
134
+ const result = { query, matchedBy, keys: keys.slice(0, limit) };
135
+ if (keys.length > limit)
136
+ result.omittedKeys = keys.length - limit;
137
+ return result;
138
+ };
139
+ const exact = all.get(q);
140
+ if (exact) {
141
+ const under = [...all.entries()].filter(([k]) => k !== q && isUnder(k, q)).map(([, v]) => v);
142
+ return pick("exact", [exact, ...under]);
143
+ }
144
+ const prefixed = [...all.entries()].filter(([k]) => isUnder(k, q)).map(([, v]) => v);
145
+ if (prefixed.length > 0)
146
+ return pick("prefix", prefixed);
147
+ const containing = [...all.entries()].filter(([k]) => k.includes(q)).map(([, v]) => v);
148
+ if (containing.length > 0)
149
+ return pick("substring", containing);
150
+ return { query, matchedBy: "none", keys: [] };
151
+ }
152
+ function isUnder(key, parent) {
153
+ if (!key.startsWith(parent) || key.length === parent.length)
154
+ return false;
155
+ const next = key[parent.length];
156
+ return next === "." || next === "#" || next === "/";
157
+ }
158
+ export function whatTouches(collection, modQuery, limit = 100) {
159
+ const q = modQuery.trim().toLowerCase();
160
+ const exact = collection.writers.filter((w) => w.writerId.toLowerCase() === q || w.name.toLowerCase() === q);
161
+ const matches = exact.length > 0 ? exact : collection.writers.filter((w) => w.writerId.toLowerCase().includes(q) || w.name.toLowerCase().includes(q));
162
+ const byKey = groupByKey(collection, () => true);
163
+ return matches.map((writer) => {
164
+ const sharedWith = [];
165
+ const seen = new Set();
166
+ for (const write of writer.writes) {
167
+ const k = write.key.toLowerCase();
168
+ if (seen.has(k))
169
+ continue;
170
+ seen.add(k);
171
+ const entry = byKey.get(k);
172
+ if (!entry?.contested)
173
+ continue;
174
+ const others = [...new Set(entry.writers.filter((w) => w.writerId !== writer.writerId).map((w) => w.name))];
175
+ if (others.length > 0)
176
+ sharedWith.push({ key: entry.key, others });
177
+ }
178
+ const summary = {
179
+ writerId: writer.writerId,
180
+ name: writer.name,
181
+ rootId: writer.rootId,
182
+ writeCount: writer.writes.length,
183
+ sharedWith,
184
+ writes: writer.writes.slice(0, limit).map((w) => ({ ...w, value: shortValue(w.value) })),
185
+ };
186
+ if (writer.writes.length > limit)
187
+ summary.omittedWrites = writer.writes.length - limit;
188
+ return summary;
189
+ });
190
+ }
191
+ export function duplicateCopies(collection) {
192
+ const byIdentity = new Map();
193
+ for (const writer of collection.writers) {
194
+ if (!writer.identity || writer.inactive)
195
+ continue;
196
+ const names = byIdentity.get(writer.identity) ?? [];
197
+ names.push(writer.name);
198
+ byIdentity.set(writer.identity, names);
199
+ }
200
+ return [...byIdentity].filter(([, names]) => names.length > 1).map(([identity, writers]) => ({ identity, writers: writers.sort() }));
201
+ }
202
+ export function contestedKeys(collection, limit = 100) {
203
+ const keys = [...groupByKey(collection, () => true).values()].filter((k) => k.contested);
204
+ keys.sort((a, b) => new Set(b.writers.map((w) => w.writerId)).size - new Set(a.writers.map((w) => w.writerId)).size || a.key.localeCompare(b.key));
205
+ return { total: keys.length, keys: keys.slice(0, limit) };
206
+ }