modwright 0.1.4 → 0.1.5

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.
@@ -5,6 +5,7 @@ import { normalizeBuildSteps } from "../project/build-steps.js";
5
5
  import { outsideRootPaths } from "../project/outside-root.js";
6
6
  import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
7
7
  import { describeNeeds, isProjectTrusted, trustRefusal } from "../trust.js";
8
+ import { pruneDated, STAGE_RETENTION } from "../safety/retention.js";
8
9
  import { buildContext, runValidators } from "../validate/index.js";
9
10
  import { runAssertAbsentStep, runAssertPresentStep, runExecStep, runZipStep } from "./steps/core.js";
10
11
  import { runBlenderExportStep, runVerifyArchiveListingStep, runWolvenkitImportStep, runWolvenkitPackStep, } from "./steps/cyberpunk.js";
@@ -187,6 +188,7 @@ export async function runBuild(project, surface, options) {
187
188
  ? path.resolve(project.root, project.build.staging)
188
189
  : path.join(project.root, ".modwright", "cache", "build");
189
190
  const stageRoot = path.join(stagingDir, buildStamp());
191
+ const stagesPruned = options.mode === "apply" ? (await pruneDated(stagingDir, "stage", STAGE_RETENTION, { mode: "apply" })).expired : [];
190
192
  const outDirAbs = project.build.absOutDir;
191
193
  const locateTool = options.locateTool ?? defaultLocateTool(project, options.mode);
192
194
  const projectExclude = project.build.exclude ?? [];
@@ -310,6 +312,7 @@ export async function runBuild(project, surface, options) {
310
312
  failureReason,
311
313
  ...(failedStep ? { failedStep } : {}),
312
314
  skippedOptional,
315
+ ...(stagesPruned.length > 0 ? { stagesPruned } : {}),
313
316
  ...(options.mode === "dry-run" ? { skippedVariant } : {}),
314
317
  outDirAbs,
315
318
  };
@@ -323,6 +326,7 @@ export async function runBuild(project, surface, options) {
323
326
  plan: dryRunPlan,
324
327
  failed: false,
325
328
  skippedOptional,
329
+ ...(stagesPruned.length > 0 ? { stagesPruned } : {}),
326
330
  skippedVariant,
327
331
  outDirAbs,
328
332
  };
@@ -351,6 +355,7 @@ export async function runBuild(project, surface, options) {
351
355
  manifestPath,
352
356
  failed: false,
353
357
  skippedOptional,
358
+ ...(stagesPruned.length > 0 ? { stagesPruned } : {}),
354
359
  outDirAbs,
355
360
  };
356
361
  }
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { isDirectory, pathExists, walk } from "../fsutil.js";
4
4
  import { globToRegExp, isExcludedByGlobs } from "../validate/index.js";
5
5
  import { outsideRootPaths } from "../project/outside-root.js";
6
+ import { BACKUP_RETENTION, countBackupRuns } from "../safety/retention.js";
6
7
  import { findRunningProcesses } from "../process.js";
7
8
  import { executePlan, isIdenticalFile, planCopy, planDelete, planJunction, planMkdir, planUnlink, sha256File } from "../safety/index.js";
8
9
  import { classifyReload } from "./reload.js";
@@ -116,8 +117,14 @@ export async function planDeploy(inputs) {
116
117
  const mode = inputs.mode ?? "dry-run";
117
118
  const deployMode = inputs.deployMode ?? "pak";
118
119
  const backupRoot = path.join(project.root, ".modwright", "backups");
120
+ const backupRuns = await countBackupRuns(backupRoot);
121
+ const backupWarning = backupRuns > BACKUP_RETENTION.keepRuns
122
+ ? `${backupRuns} backup runs under ${backupRoot}; rollback action=prune keeps the newest ${BACKUP_RETENTION.keepRuns} and anything under ${BACKUP_RETENTION.maxAgeDays} days old.`
123
+ : undefined;
119
124
  const blocked = [];
120
125
  const warnings = [];
126
+ if (backupWarning)
127
+ warnings.push(backupWarning);
121
128
  const assumptions = [];
122
129
  const verifiedBy = [];
123
130
  const targets = [];
@@ -535,6 +542,17 @@ export async function planDeploy(inputs) {
535
542
  ...toWrite.filter((t) => !t.locked).flatMap((t) => t.copies.map((c) => planCopy(c.from, c.to))),
536
543
  ...toWrite.filter((t) => t.locked).flatMap((t) => t.copies.map((c) => planCopy(c.from, c.to))),
537
544
  ];
545
+ const throughLinks = await writesThroughLinks(operations, roots, junctions.map((j) => j.linkPath));
546
+ if (throughLinks.size > 0) {
547
+ blocked.push({
548
+ code: "write-through-link",
549
+ message: `${[...throughLinks.keys()].join(", ")} ${throughLinks.size === 1 ? "is a link" : "are links"} inside a mod root, and this deploy would write through ` +
550
+ `${throughLinks.size === 1 ? "it" : "them"} into wherever ${throughLinks.size === 1 ? "it points" : "they point"} ` +
551
+ `(${[...throughLinks.values()].flat().slice(0, 3).join(", ")}${[...throughLinks.values()].flat().length > 3 ? ", ..." : ""}). ` +
552
+ `Remove the link, or declare it under deploy.junctions if the project owns it.`,
553
+ detail: { links: Object.fromEntries([...throughLinks].map(([link, paths]) => [link, paths.slice(0, 20)])) },
554
+ });
555
+ }
538
556
  const confidence = {
539
557
  level: assumptions.length > 0 ? "medium" : "high",
540
558
  assumptions,
@@ -562,6 +580,49 @@ export async function planDeploy(inputs) {
562
580
  backupRoot,
563
581
  };
564
582
  }
583
+ async function writesThroughLinks(operations, roots, allowed) {
584
+ const isLink = new Map();
585
+ const linkAt = async (dir) => {
586
+ const key = normalizeForCompare(dir);
587
+ let known = isLink.get(key);
588
+ if (known === undefined) {
589
+ try {
590
+ known = (await fs.lstat(dir)).isSymbolicLink();
591
+ }
592
+ catch {
593
+ known = false;
594
+ }
595
+ isLink.set(key, known);
596
+ }
597
+ return known;
598
+ };
599
+ const allowedKeys = new Set(allowed.map(normalizeForCompare));
600
+ const rootPaths = roots.map((r) => path.resolve(r.path));
601
+ const found = new Map();
602
+ for (const op of operations) {
603
+ const dest = op.kind === "copy" ? op.to : "path" in op ? op.path : undefined;
604
+ if (!dest || op.kind === "junction" || op.kind === "unlink")
605
+ continue;
606
+ const abs = path.resolve(dest);
607
+ const root = rootPaths.find((r) => {
608
+ const rel = path.relative(r, abs);
609
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
610
+ });
611
+ if (!root)
612
+ continue;
613
+ for (let dir = path.dirname(abs); dir !== root && dir.length > root.length; dir = path.dirname(dir)) {
614
+ if (allowedKeys.has(normalizeForCompare(dir)))
615
+ break;
616
+ if (await linkAt(dir)) {
617
+ const list = found.get(dir) ?? [];
618
+ list.push(abs);
619
+ found.set(dir, list);
620
+ break;
621
+ }
622
+ }
623
+ }
624
+ return found;
625
+ }
565
626
  function copyKey(from, to) {
566
627
  return `${normalizeForCompare(from)}\0${normalizeForCompare(to)}`;
567
628
  }
@@ -0,0 +1,76 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ const BACKUP_NAME = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?$/;
4
+ const STAGE_NAME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-\d{3}Z$/;
5
+ export const BACKUP_RETENTION = { keepRuns: 20, maxAgeDays: 14 };
6
+ export const STAGE_RETENTION = { keepRuns: 3, maxAgeDays: 0 };
7
+ function runOf(name, kind) {
8
+ const m = (kind === "backup" ? BACKUP_NAME : STAGE_NAME).exec(name);
9
+ if (!m)
10
+ return undefined;
11
+ const time = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
12
+ return { run: kind === "backup" ? name.slice(0, 15) : name, time };
13
+ }
14
+ export function selectExpired(names, kind, policy, now = new Date()) {
15
+ const byRun = new Map();
16
+ const ignored = [];
17
+ for (const name of names) {
18
+ const r = runOf(name, kind);
19
+ if (!r) {
20
+ ignored.push(name);
21
+ continue;
22
+ }
23
+ const entry = byRun.get(r.run) ?? { time: r.time, names: [] };
24
+ entry.names.push(name);
25
+ byRun.set(r.run, entry);
26
+ }
27
+ const runs = [...byRun.values()].sort((a, b) => b.time - a.time);
28
+ const cutoff = now.getTime() - policy.maxAgeDays * 24 * 60 * 60 * 1000;
29
+ const expired = [];
30
+ const kept = [];
31
+ runs.forEach((run, i) => {
32
+ const keep = i < policy.keepRuns || (policy.maxAgeDays > 0 && run.time >= cutoff);
33
+ (keep ? kept : expired).push(...run.names);
34
+ });
35
+ return { expired: expired.sort(), kept: kept.sort(), ignored: ignored.sort(), runs: runs.length };
36
+ }
37
+ async function folderNames(root) {
38
+ try {
39
+ return (await fs.readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ export async function pruneDated(root, kind, policy, options = {}) {
46
+ const mode = options.mode ?? "dry-run";
47
+ const selection = selectExpired(await folderNames(root), kind, policy, options.now);
48
+ const report = {
49
+ mode,
50
+ root,
51
+ applied: false,
52
+ policy,
53
+ runs: selection.runs,
54
+ expired: selection.expired,
55
+ keptCount: selection.kept.length,
56
+ ignored: selection.ignored,
57
+ };
58
+ if (mode !== "apply")
59
+ return report;
60
+ const failed = [];
61
+ for (const name of selection.expired) {
62
+ try {
63
+ await fs.rm(path.join(root, name), { recursive: true, force: true });
64
+ }
65
+ catch (error) {
66
+ failed.push({ name, error: error.message });
67
+ }
68
+ }
69
+ report.applied = true;
70
+ if (failed.length > 0)
71
+ report.failed = failed;
72
+ return report;
73
+ }
74
+ export async function countBackupRuns(root) {
75
+ return selectExpired(await folderNames(root), "backup", { keepRuns: Infinity, maxAgeDays: 0 }).runs;
76
+ }
@@ -47,6 +47,15 @@ export async function collectWrites(surface, install, options = {}) {
47
47
  }
48
48
  for (const err of extraction.errors)
49
49
  collection.coverage.errors.push({ writerId: mod.id, ...err });
50
+ for (const r of extraction.repeatedKeys ?? []) {
51
+ (collection.repeatedKeys ??= []).push({
52
+ writerId: r.unit ? `${mod.id}/${r.unit}` : mod.id,
53
+ name: r.unit ?? mod.name,
54
+ file: r.file,
55
+ key: r.key,
56
+ lines: r.lines,
57
+ });
58
+ }
50
59
  for (const write of extraction.writes) {
51
60
  const writerId = write.unit ? `${mod.id}/${write.unit}` : mod.id;
52
61
  scanned.add(writerId);
package/dist/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { promises as fs, watchFile, unwatchFile } from "node:fs";
3
+ import { appendFileSync, mkdirSync, promises as fs, watchFile, unwatchFile } from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { userDataDir } from "./core/home.js";
7
8
  const here = path.dirname(fileURLToPath(import.meta.url));
8
9
  const SERVER_ENTRY = process.env.MODWRIGHT_SERVER_ENTRY ?? path.join(here, "server.js");
9
10
  const STAMP_FILE = process.env.MODWRIGHT_BUILD_STAMP ?? path.join(here, ".build-stamp");
10
11
  const STAMP_POLL_MS = Number(process.env.MODWRIGHT_STAMP_POLL_MS ?? 2000);
11
12
  const RETIRE_GRACE_MS = Number(process.env.MODWRIGHT_RETIRE_GRACE_MS ?? 15 * 60_000);
13
+ const SHUTDOWN_GRACE_MS = Number(process.env.MODWRIGHT_SHUTDOWN_GRACE_MS ?? 5000);
12
14
  const INIT_TIMEOUT_MS = 20_000;
13
15
  const RESTART_BASE_MS = Number(process.env.MODWRIGHT_RESTART_BASE_MS ?? 500);
14
16
  const RESTART_MAX_DELAY_MS = 30_000;
@@ -17,6 +19,16 @@ const SUPERVISOR_INIT_ID = "modwright-supervisor-initialize";
17
19
  function log(msg) {
18
20
  process.stderr.write(`[modwright supervisor] ${msg}\n`);
19
21
  }
22
+ function incident(msg) {
23
+ log(msg);
24
+ try {
25
+ const dir = userDataDir();
26
+ mkdirSync(dir, { recursive: true });
27
+ appendFileSync(path.join(dir, "supervisor-incidents.log"), `${new Date().toISOString()} ${msg}\n`);
28
+ }
29
+ catch {
30
+ }
31
+ }
20
32
  function idKey(id) {
21
33
  return `${typeof id}:${String(id)}`;
22
34
  }
@@ -49,6 +61,10 @@ class ServerProcess {
49
61
  child;
50
62
  generation;
51
63
  pending = new Set();
64
+ pendingMethod = new Map();
65
+ pendingMethods() {
66
+ return [...this.pending].map((k) => this.pendingMethod.get(k) ?? "?");
67
+ }
52
68
  retired = false;
53
69
  exited = false;
54
70
  splitter = new LineSplitter();
@@ -115,6 +131,7 @@ class Supervisor {
115
131
  swapping = false;
116
132
  swapQueued = false;
117
133
  closing = false;
134
+ retiredServers = new Set();
118
135
  initializeRaw;
119
136
  initializedSeen = false;
120
137
  routes = new Map();
@@ -194,11 +211,13 @@ class Supervisor {
194
211
  if (key !== undefined) {
195
212
  this.routes.set(key, active);
196
213
  active.pending.add(key);
214
+ active.pendingMethod.set(key, msg.method);
197
215
  }
198
216
  if (!active.send(raw)) {
199
217
  if (key !== undefined) {
200
218
  this.routes.delete(key);
201
219
  active.pending.delete(key);
220
+ active.pendingMethod.delete(key);
202
221
  }
203
222
  answerNotRunning();
204
223
  }
@@ -212,6 +231,7 @@ class Supervisor {
212
231
  const key = idKey(msg.id);
213
232
  this.routes.delete(key);
214
233
  server.pending.delete(key);
234
+ server.pendingMethod.delete(key);
215
235
  process.stdout.write(`${raw}\n`);
216
236
  if (server.retired && server.pending.size === 0)
217
237
  server.kill();
@@ -239,6 +259,7 @@ class Supervisor {
239
259
  process.stdout.write(`${JSON.stringify(response)}\n`);
240
260
  }
241
261
  server.pending.clear();
262
+ server.pendingMethod.clear();
242
263
  if (this.closing || server.retired || server !== this.active)
243
264
  return;
244
265
  log(`server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}); restarting`);
@@ -301,6 +322,7 @@ class Supervisor {
301
322
  log(`swapped to server generation ${next.generation} (${reason})`);
302
323
  if (previous && previous !== next) {
303
324
  previous.retired = true;
325
+ this.retiredServers.add(previous);
304
326
  if (previous.pending.size === 0)
305
327
  previous.kill();
306
328
  else
@@ -356,7 +378,25 @@ class Supervisor {
356
378
  clearTimeout(this.restartTimer);
357
379
  unwatchFile(STAMP_FILE);
358
380
  this.active?.kill();
359
- setTimeout(() => process.exit(0), 2500).unref();
381
+ const busy = [...this.retiredServers].filter((s) => !s.exited && s.pending.size > 0);
382
+ for (const s of this.retiredServers)
383
+ if (!busy.includes(s))
384
+ s.kill();
385
+ for (const s of busy) {
386
+ incident(`shutdown: retired server generation ${s.generation} still answering ${s.pending.size} request(s) (${s.pendingMethods().join(", ")}); ` +
387
+ `killing it in ${SHUTDOWN_GRACE_MS} ms`);
388
+ }
389
+ if (busy.length > 0) {
390
+ setTimeout(() => {
391
+ for (const s of busy) {
392
+ if (s.exited)
393
+ continue;
394
+ incident(`shutdown: retired server generation ${s.generation} killed with ${s.pending.size} request(s) unanswered`);
395
+ s.kill();
396
+ }
397
+ }, SHUTDOWN_GRACE_MS);
398
+ }
399
+ setTimeout(() => process.exit(0), Math.max(2500, busy.length > 0 ? SHUTDOWN_GRACE_MS + 500 : 0)).unref();
360
400
  }
361
401
  }
362
402
  async function readStamp() {
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { pathExists, tail } from "./core/fsutil.js";
6
6
  import { describeProject, findProjectFile, loadProject, locateProjectFile } from "./core/project/index.js";
7
- import { userDataDir } from "./core/home.js";
7
+ import { packageRoot, userDataDir } from "./core/home.js";
8
8
  import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
9
9
  import { loadUserConfig } from "./core/userconfig.js";
10
10
  import { describeNeeds, isProjectTrusted, planTrust, projectTrustNeeds, trustRefusal } from "./core/trust.js";
@@ -15,6 +15,7 @@ import { runBuild } from "./core/build/index.js";
15
15
  import { classifyReload, detectRedHotTools, runDeploy } from "./core/deploy/index.js";
16
16
  import { armBridge, bridgeCandidateDirs, bridgeStatus, cetLogCandidates, listPendingArms, PROTOCOL_VERSION as BRIDGE_PROTOCOL_VERSION, pollCyberpunk, pollForResult, probeSchema as bridgeProbeSchema, readPendingArm, stageBridge } from "./core/bridge/index.js";
17
17
  import { removeBridge } from "./core/bridge/remove.js";
18
+ import { BACKUP_RETENTION, pruneDated } from "./core/safety/retention.js";
18
19
  import { runTestPlan } from "./core/testplan/index.js";
19
20
  import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
20
21
  import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
@@ -30,7 +31,27 @@ import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds }
30
31
  import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
31
32
  import { bootstrapProject, describe as describeScaffold, findScaffold, findSequence, generateClaudeAssets, loadAllScaffolds, loadAllSequences, planSequence, runScaffold, summarize, summarizeSequences } from "./core/scaffold/index.js";
32
33
  import { loadSkillPromptCatalog, renderSkillPrompt } from "./core/prompts.js";
33
- const server = new McpServer({ name: "modwright", version: "0.1.0" });
34
+ const server = new McpServer({ name: "modwright", version: await serverVersion() });
35
+ async function serverVersion() {
36
+ try {
37
+ const pkg = JSON.parse(await fs.readFile(path.join(packageRoot(), "package.json"), "utf8"));
38
+ return pkg.version ?? "0.0.0";
39
+ }
40
+ catch {
41
+ return "0.0.0";
42
+ }
43
+ }
44
+ {
45
+ const register = server.registerTool.bind(server);
46
+ server.registerTool = (name, config, handler) => register(name, config, async (...args) => {
47
+ try {
48
+ return await handler(...args);
49
+ }
50
+ catch (error) {
51
+ return fail(`${String(name)} failed: ${error.message}`);
52
+ }
53
+ });
54
+ }
34
55
  const registry = new SignatureRegistry();
35
56
  for (const surface of SURFACES) {
36
57
  if (surface.logSignatures?.length)
@@ -313,6 +334,7 @@ server.registerTool("who_touches", {
313
334
  const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
314
335
  const { errors, ...coverage } = collection.coverage;
315
336
  const copies = duplicateCopies(collection);
337
+ const repeats = (collection.repeatedKeys ?? []).filter((r) => key ? r.key.toLowerCase().startsWith(key.toLowerCase()) : mod ? r.writerId.toLowerCase().includes(mod.toLowerCase()) || r.name.toLowerCase().includes(mod.toLowerCase()) : true);
316
338
  const result = key
317
339
  ? { mode: "key", ...whoTouches(collection, key, limit) }
318
340
  : mod
@@ -328,6 +350,16 @@ server.registerTool("who_touches", {
328
350
  errors: errors.slice(0, 25),
329
351
  },
330
352
  ...(copies.length > 0 ? { duplicateCopies: copies } : {}),
353
+ ...(repeats.length > 0
354
+ ? {
355
+ repeatedKeys: {
356
+ total: repeats.length,
357
+ items: repeats.slice(0, 50),
358
+ note: "Each key is repeated within one mapping in that file: invalid YAML that TweakXL reads without an error. Which " +
359
+ "occurrence takes effect is not established, so one of the edits may be silently lost.",
360
+ },
361
+ }
362
+ : {}),
331
363
  ...result,
332
364
  note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
333
365
  "key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
@@ -1300,19 +1332,59 @@ server.registerTool("rollback", {
1300
1332
  "allowOutsideRoots is set.",
1301
1333
  inputSchema: {
1302
1334
  path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
1335
+ action: z
1336
+ .enum(["list", "restore", "prune"])
1337
+ .optional()
1338
+ .describe("list (the default without backupId), restore (the default with one), or prune: delete old backup runs, keeping the newest 20 and anything under 14 days old. Only ModWright's own dated folders are ever pruned."),
1303
1339
  backupId: z.string().optional().describe("The stamp naming one backup directory under .modwright/backups (from this tool's own listing). Omit to list."),
1304
- mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run reports what would be restored without writing a byte."),
1340
+ backupRoot: z
1341
+ .string()
1342
+ .optional()
1343
+ .describe("prune only: another backups folder to prune instead of the project's, e.g. one an older version left under a working directory. Must be a .modwright/backups folder or ModWright's per-user backups."),
1344
+ mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run reports what would be restored or pruned without changing anything."),
1305
1345
  installPath: installPathArg,
1306
1346
  allowOutsideRoots: z
1307
1347
  .boolean()
1308
1348
  .default(false)
1309
1349
  .describe("Restore entries outside the project and the game's mod roots too. Off by default: a backup that names any other path is refused whole."),
1310
1350
  },
1311
- }, async ({ path: start, backupId, mode, installPath, allowOutsideRoots }) => {
1351
+ }, async ({ path: start, action, backupId, backupRoot: otherRoot, mode, installPath, allowOutsideRoots }) => {
1352
+ if (action === "prune") {
1353
+ let root;
1354
+ if (otherRoot) {
1355
+ const abs = path.resolve(otherRoot);
1356
+ const posix = abs.split(path.sep).join("/").toLowerCase();
1357
+ const perUser = path.join(userDataDir(), "backups").split(path.sep).join("/").toLowerCase();
1358
+ if (!posix.endsWith("/.modwright/backups") && posix !== perUser) {
1359
+ return fail(`rollback prune: ${abs} is not a .modwright/backups folder or ModWright's per-user backups (${path.join(userDataDir(), "backups")}).`);
1360
+ }
1361
+ root = abs;
1362
+ }
1363
+ else {
1364
+ const project = await projectOrFail(start);
1365
+ if ("isError" in project)
1366
+ return project;
1367
+ root = path.join(project.root, ".modwright", "backups");
1368
+ }
1369
+ const report = await pruneDated(root, "backup", BACKUP_RETENTION, { mode });
1370
+ return json({
1371
+ action,
1372
+ ...report,
1373
+ expired: report.expired.slice(0, 100),
1374
+ expiredCount: report.expired.length,
1375
+ note: report.expired.length === 0
1376
+ ? `Nothing to prune: ${report.runs} backup run(s), all within the newest ${BACKUP_RETENTION.keepRuns} or under ${BACKUP_RETENTION.maxAgeDays} days old.`
1377
+ : report.applied
1378
+ ? `Pruned ${report.expired.length - (report.failed?.length ?? 0)} folder(s); ${report.keptCount} kept.`
1379
+ : `Dry run: ${report.expired.length} folder(s) would be deleted outright (they are the backups); ${report.keptCount} kept. Repeat with mode 'apply'.`,
1380
+ });
1381
+ }
1312
1382
  const resolved = await projectOrFail(start);
1313
1383
  if ("isError" in resolved)
1314
1384
  return resolved;
1315
1385
  const backupRoot = path.join(resolved.root, ".modwright", "backups");
1386
+ if (action === "restore" && !backupId)
1387
+ return fail("rollback restore needs backupId (from this tool's listing).");
1316
1388
  if (!backupId) {
1317
1389
  const backups = await listBackups(backupRoot);
1318
1390
  return json({
@@ -1686,7 +1758,7 @@ server.registerTool("ledger", {
1686
1758
  scopeFiles: z.array(z.string()).optional().describe("'record' kind:\"claim\": files this claim is about, for a new claim."),
1687
1759
  scopeEntries: z.array(z.string()).optional().describe("'record' kind:\"claim\": named entries (stat/tweak ids) this claim is about, for a new claim."),
1688
1760
  toStatus: z.enum(["authored", "checker-clean", "built", "verified", "contradicted"]).optional().describe("'record' kind:\"claim\": the status to move this claim to."),
1689
- tool: z.enum(["validate", "build", "run_tests", "ledger"]).optional().describe("'record' kind:\"claim\": which tool this write claims to be — defaults to \"ledger\" (a manual record/correction). Passing another tool's name does not skip that tool's own gate; it only labels the write."),
1761
+ tool: z.enum(["validate", "build", "run_tests", "ledger"]).optional().describe("'record' kind:\"claim\": whose transition rule applies. \"ledger\" (the default) is a manual record or correction: any move is allowed, with a note when it skips a stage. Naming validate, build or run_tests applies that tool's rule instead: only the status that tool writes, and only forward. A name never widens what is allowed and never stands in for the tool's own checks."),
1690
1762
  by: z.string().optional().describe("'record': who or what made this write. Default \"ledger\"."),
1691
1763
  note: z.string().optional().describe("'record': why, for the history entry."),
1692
1764
  evidence: z
@@ -2,6 +2,6 @@ import { bridgeNeverShipValidator } from "../../../core/bridge/index.js";
2
2
  export const bridgeNeverShipsValidator = bridgeNeverShipValidator({
3
3
  id: "bg3.history.bridge-never-ships",
4
4
  game: "baldursgate3",
5
- evidence: ["ModWright research"],
5
+ evidence: ["ModWright's own rule, decided 2026-09-08: the in-game verification bridge is an authoring aid and never ships in a release build."],
6
6
  });
7
7
  export const BRIDGE_VALIDATORS = [bridgeNeverShipsValidator];
@@ -2,6 +2,6 @@ import { vanillaExtractNeverShipValidator } from "../../../core/text/validate.js
2
2
  export const vanillaExtractNeverShipsValidator = vanillaExtractNeverShipValidator({
3
3
  id: "bg3.history.vanilla-extract-never-ships",
4
4
  game: "baldursgate3",
5
- evidence: ["ModWright research"],
5
+ evidence: ["ModWright's own rule, decided 2026-09-08: a template extracted from the user's own install is for authoring only, and a release never ships one verbatim (redistributing game data)."],
6
6
  });
7
7
  export const TEMPLATE_VALIDATORS = [vanillaExtractNeverShipsValidator];
@@ -181,3 +181,32 @@ export function parseTweakXLYaml(text, file, options = {}) {
181
181
  }
182
182
  return ctx.records;
183
183
  }
184
+ export function findRepeatedKeys(text) {
185
+ const lineCounter = new YAML.LineCounter();
186
+ const doc = YAML.parseDocument(text, { lineCounter, uniqueKeys: false });
187
+ if (doc.errors.length > 0)
188
+ return [];
189
+ const out = [];
190
+ const visit = (node, prefix) => {
191
+ if (YAML.isMap(node)) {
192
+ const seen = new Map();
193
+ for (const pair of node.items) {
194
+ if (!YAML.isScalar(pair.key))
195
+ continue;
196
+ const name = String(pair.key.value);
197
+ const line = pair.key.range ? lineCounter.linePos(pair.key.range[0]).line : 0;
198
+ seen.set(name, [...(seen.get(name) ?? []), line]);
199
+ visit(pair.value, prefix ? `${prefix}.${name}` : name);
200
+ }
201
+ for (const [name, lines] of seen)
202
+ if (lines.length > 1)
203
+ out.push({ key: prefix ? `${prefix}.${name}` : name, lines });
204
+ }
205
+ else if (YAML.isSeq(node)) {
206
+ for (const item of node.items)
207
+ visit(item, prefix);
208
+ }
209
+ };
210
+ visit(doc.contents, "");
211
+ return out.sort((a, b) => a.lines[0] - b.lines[0]);
212
+ }
@@ -2,6 +2,6 @@ import { bridgeNeverShipValidator } from "../../../core/bridge/index.js";
2
2
  export const bridgeNeverShipsValidator = bridgeNeverShipValidator({
3
3
  id: "cp2077.history.bridge-never-ships",
4
4
  game: "cyberpunk2077",
5
- evidence: ["ModWright research"],
5
+ evidence: ["ModWright's own rule, decided 2026-09-08: the in-game verification bridge is an authoring aid and never ships in a release build."],
6
6
  });
7
7
  export const BRIDGE_VALIDATORS = [bridgeNeverShipsValidator];
@@ -264,7 +264,7 @@ function checkResourcePathShape(root, file) {
264
264
  const GAMEVERSION_EVIDENCE = [
265
265
  "cp2077.toolchain.gameversion-header",
266
266
  "cp2077.packaging.game-version-compatibility",
267
- "observed: a WolvenKit 8.20.0 en-us.source.json header carries GameVersion 2310",
267
+ "observed by 2026-09-07: a WolvenKit 8.20.0 en-us.source.json header carries GameVersion 2310",
268
268
  ];
269
269
  function expectedGameVersion(marketingVersion) {
270
270
  const m = /^(\d+)\.(\d{1,2})$/.exec(marketingVersion.trim());
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  const ID = "cp2077.history.modsettings-annotation-keys";
3
- const EVIDENCE = ["ModWright research"];
3
+ const EVIDENCE = ["cp2077.ui.modsettings.universal-annotation-keys", "cp2077.ui.modsettings.type-specific-annotation-keys", "cp2077.ui.modsettings.unannotated-property-is-invisible"];
4
4
  const REQUIRED_KEYS = ["mod", "category", "displayName"];
5
5
  function isWithin(parent, child) {
6
6
  const rel = path.relative(parent, child);
@@ -2,6 +2,6 @@ import { vanillaExtractNeverShipValidator } from "../../../core/text/validate.js
2
2
  export const vanillaExtractNeverShipsValidator = vanillaExtractNeverShipValidator({
3
3
  id: "cp2077.history.vanilla-extract-never-ships",
4
4
  game: "cyberpunk2077",
5
- evidence: ["ModWright research"],
5
+ evidence: ["ModWright's own rule, decided 2026-09-08: a template extracted from the user's own install is for authoring only, and a release never ships one verbatim (redistributing game data)."],
6
6
  });
7
7
  export const TEMPLATE_VALIDATORS = [vanillaExtractNeverShipsValidator];
@@ -85,7 +85,11 @@ function finding(validatorId, severity, file, message, evidence, extra = {}) {
85
85
  const YAML_LINT_ID = "cp2077.tweaks.yaml-lint";
86
86
  const YAML_LINT_EVIDENCE = [
87
87
  "cp2077.tweakdb.melee.tweakxl-log-required-after-yaml-changes",
88
+ "cp2077.tweakxl.yaml-syntax.duplicate-keys-do-not-fail-the-parse",
88
89
  ];
90
+ const DUPLICATE_KEY_NOTE = "That is invalid YAML. TweakXL reads the file without an error, but which occurrence takes effect is not established, " +
91
+ "so one of the two edits can be silently lost.";
92
+ const DUPLICATE_KEY_FIX = "Write everything this key sets in one block, and delete the repeat.";
89
93
  export const yamlLintValidator = {
90
94
  id: YAML_LINT_ID,
91
95
  game: GAME,
@@ -113,9 +117,12 @@ export const yamlLintValidator = {
113
117
  const doc = YAML.parseDocument(text, { lineCounter });
114
118
  return doc.errors.map((err) => {
115
119
  const pos = err.pos ? lineCounter.linePos(err.pos[0]) : undefined;
116
- return finding(YAML_LINT_ID, "block", file, err.message.split("\n")[0] ?? err.message, YAML_LINT_EVIDENCE, {
120
+ const first = err.message.split("\n")[0] ?? err.message;
121
+ const duplicate = /map keys must be unique/i.test(first);
122
+ return finding(YAML_LINT_ID, "block", file, duplicate ? `${first} ${DUPLICATE_KEY_NOTE}` : first, YAML_LINT_EVIDENCE, {
117
123
  line: pos?.line,
118
124
  column: pos?.col,
125
+ ...(duplicate ? { fix: DUPLICATE_KEY_FIX } : {}),
119
126
  });
120
127
  });
121
128
  },
@@ -164,7 +171,7 @@ export const baseExistsValidator = {
164
171
  },
165
172
  };
166
173
  const TYPE_KNOWN_ID = "cp2077.tweaks.type-known";
167
- const TYPE_KNOWN_EVIDENCE = ["ModWright research"];
174
+ const TYPE_KNOWN_EVIDENCE = ["cp2077.tweakxl.yaml-syntax.record-names-must-be-unique", "https://github.com/psiberx/cp2077-tweak-xl/wiki/YAML-Tweaks"];
168
175
  export const typeKnownValidator = {
169
176
  id: TYPE_KNOWN_ID,
170
177
  game: GAME,
@@ -252,7 +259,7 @@ export const appendOnListFlatValidator = {
252
259
  },
253
260
  };
254
261
  const INSTANCES_ID = "cp2077.tweaks.instances-resolve";
255
- const INSTANCES_EVIDENCE = ["ModWright research"];
262
+ const INSTANCES_EVIDENCE = ["https://github.com/psiberx/cp2077-tweak-xl/wiki/YAML-Tweaks"];
256
263
  const VAR_RE = /\$\(([^)]+)\)/g;
257
264
  function collectVarNamesFromText(text, out) {
258
265
  for (const m of text.matchAll(VAR_RE))
@@ -553,7 +560,7 @@ const REPLACER_BASE = "Character.Player_Replacer_Puppet_Base";
553
560
  const REPLACER_TEMPLATE_EVIDENCE = [
554
561
  "cp2077.player.control.replacers-are-other-player-bodies",
555
562
  "cp2077.player.control.android-replacer-is-v-in-the-android-app",
556
- "cyberpunk2077.tweakxl.missing-resource (triage pattern: `<record>.entityTemplatePath refers to a non-existent resource.`)",
563
+ "observed 2026-10-04 in a TweakXL 1.11.4 log: `<record>.entityTemplatePath refers to a non-existent resource.` is logged as a warning naming the record",
557
564
  ];
558
565
  async function derivesFromReplacerBase(ctx, start, projectBases) {
559
566
  let current = start;
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { TweakParseError } from "./index/model.js";
4
4
  import { parseTweakFile } from "./index/parsers/tweak.js";
5
- import { parseTweakXLYaml } from "./index/parsers/tweakxl-yaml.js";
5
+ import { findRepeatedKeys, parseTweakXLYaml } from "./index/parsers/tweakxl-yaml.js";
6
6
  const TWEAK_EXTENSIONS = new Set([".yaml", ".yml", ".tweak"]);
7
7
  const MAX_TWEAK_BYTES = 8 * 1024 * 1024;
8
8
  export const TWEAKS_UNIT_NOTE = "r6/tweaks is one shared folder and TweakXL has no notion of mods, so each top-level folder there " +
@@ -38,7 +38,9 @@ async function parseFile(abs) {
38
38
  if (stat.size > MAX_TWEAK_BYTES)
39
39
  throw new Error(`larger than ${MAX_TWEAK_BYTES} bytes; skipped`);
40
40
  const text = await fs.readFile(abs, "utf8");
41
- return path.extname(abs).toLowerCase() === ".tweak" ? parseTweakFile(text, abs) : parseTweakXLYaml(text, abs, { allowDuplicateKeys: true });
41
+ if (path.extname(abs).toLowerCase() === ".tweak")
42
+ return { records: parseTweakFile(text, abs), repeats: [] };
43
+ return { records: parseTweakXLYaml(text, abs, { allowDuplicateKeys: true }), repeats: findRepeatedKeys(text) };
42
44
  }
43
45
  export async function extractCyberpunkWrites(mod, root) {
44
46
  let files;
@@ -56,7 +58,10 @@ export async function extractCyberpunkWrites(mod, root) {
56
58
  const abs = path.join(mod.path, rel);
57
59
  const unit = root.id === "tweaks" ? rel.split("/")[0] : undefined;
58
60
  try {
59
- extraction.writes.push(...recordWrites(await parseFile(abs), abs, unit));
61
+ const { records, repeats } = await parseFile(abs);
62
+ extraction.writes.push(...recordWrites(records, abs, unit));
63
+ for (const r of repeats)
64
+ (extraction.repeatedKeys ??= []).push({ file: abs, ...(unit ? { unit } : {}), key: r.key, lines: r.lines });
60
65
  }
61
66
  catch (error) {
62
67
  const message = error instanceof TweakParseError ? error.message : `${abs}: ${error.message}`;
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { BSA_VERSION_OBLIVION, BSA_VERSION_SKYRIM_LE, BSA_VERSION_SKYRIM_SE, BsaHeaderError, readBsaHeader } from "../archives.js";
3
- const EVIDENCE = ["ModWright research"];
3
+ const HEADER_EVIDENCE = ["sse.archives.bsa.version-105"];
4
4
  const APPLIES = "every .bsa file under a project source";
5
5
  function isWithin(parent, child) {
6
6
  const rel = path.relative(parent, child);
@@ -23,11 +23,12 @@ async function headerOrFinding(id, file) {
23
23
  file,
24
24
  message: `${path.basename(file)} has no readable BSA header (${reason}): ${err.message}`,
25
25
  fix: "Repack the archive with BSArch or the Creation Kit's archive tool.",
26
- evidence: EVIDENCE,
26
+ evidence: HEADER_EVIDENCE,
27
27
  };
28
28
  }
29
29
  }
30
30
  const isFinding = (v) => v.validatorId !== undefined;
31
+ const ARCHIVEVERSION_EVIDENCE = ["sse.archives.bsa.version-105", "sse.archives.bsa.v105-uses-lz4", "sse.archives.bsa.folder-record-is-24-bytes-in-v105"];
31
32
  const archiveVersion = {
32
33
  id: "sse.archives.version",
33
34
  game: "skyrimse",
@@ -39,7 +40,7 @@ const archiveVersion = {
39
40
  needs: { project: true },
40
41
  cost: "edit",
41
42
  severity: "block",
42
- evidence: EVIDENCE,
43
+ evidence: ARCHIVEVERSION_EVIDENCE,
43
44
  async run(ctx) {
44
45
  if (ctx.target.kind !== "file")
45
46
  return [];
@@ -59,11 +60,12 @@ const archiveVersion = {
59
60
  ? `${name} is a version ${header.version} archive, ${known}; Skyrim SE reads version 105.`
60
61
  : `${name} has BSA version ${header.version}; Skyrim SE reads version 105.`,
61
62
  fix: "Extract and repack the archive for Skyrim SE (BSArch with the SSE format, or the Creation Kit).",
62
- evidence: EVIDENCE,
63
+ evidence: ARCHIVEVERSION_EVIDENCE,
63
64
  },
64
65
  ];
65
66
  },
66
67
  };
68
+ const ARCHIVENAMEFLAGS_EVIDENCE = ["sse.archives.bsa.directory-and-file-name-flags-are-required"];
67
69
  const archiveNameFlags = {
68
70
  id: "sse.archives.name-flags",
69
71
  game: "skyrimse",
@@ -75,7 +77,7 @@ const archiveNameFlags = {
75
77
  needs: { project: true },
76
78
  cost: "edit",
77
79
  severity: "warn",
78
- evidence: EVIDENCE,
80
+ evidence: ARCHIVENAMEFLAGS_EVIDENCE,
79
81
  async run(ctx) {
80
82
  if (ctx.target.kind !== "file")
81
83
  return [];
@@ -91,7 +93,7 @@ const archiveNameFlags = {
91
93
  file: ctx.target.path,
92
94
  message: `${path.basename(ctx.target.path)} lacks ${missing.join(" and ")} in its archive flags; UESP notes the game may not load a BSA without them, and every official archive sets both.`,
93
95
  fix: "Repack with names included (BSArch's default).",
94
- evidence: EVIDENCE,
96
+ evidence: ARCHIVENAMEFLAGS_EVIDENCE,
95
97
  });
96
98
  }
97
99
  if (header.xbox360) {
@@ -101,7 +103,7 @@ const archiveNameFlags = {
101
103
  file: ctx.target.path,
102
104
  message: `${path.basename(ctx.target.path)} has the Xbox 360 flag (0x40): big-endian numbers and hashes, which the PC game does not read.`,
103
105
  fix: "Repack for PC.",
104
- evidence: EVIDENCE,
106
+ evidence: ARCHIVENAMEFLAGS_EVIDENCE,
105
107
  });
106
108
  }
107
109
  return findings;
@@ -3,7 +3,7 @@ import { readPeFileVersion } from "../../../core/compat/pe.js";
3
3
  import { compareVersions, parseVersion } from "../../../core/compat/version.js";
4
4
  import { FORM_VERSION_LE, FORM_VERSION_SSE, HEADER_VERSION_EXPANDED_LIGHT_RANGE, MASTER_LIMIT_SSE, PluginHeaderError, VALID_HEADER_VERSIONS_SSE, pluginExtension, readPluginHeader, } from "../plugins.js";
5
5
  import { RUNTIME_EXPANDED_LIGHT_RANGE } from "../compat.js";
6
- const EVIDENCE = ["ModWright research"];
6
+ const HEADER_EVIDENCE = ["sse.plugins.format.record-header-is-24-bytes", "sse.plugins.format.hedr-is-twelve-bytes"];
7
7
  function isPluginUnderSource(target, project) {
8
8
  if (target.kind !== "file" || pluginExtension(target.path) === undefined)
9
9
  return false;
@@ -26,7 +26,7 @@ async function headerOrFinding(id, file) {
26
26
  file,
27
27
  message: `${path.basename(file)} has no readable TES4 header (${reason}): ${err.message}`,
28
28
  fix: "A plugin must begin with a TES4 record; re-save it from the Creation Kit or xEdit, or check the file is not truncated.",
29
- evidence: EVIDENCE,
29
+ evidence: HEADER_EVIDENCE,
30
30
  };
31
31
  }
32
32
  }
@@ -42,6 +42,7 @@ function findEntry(order, name) {
42
42
  const key = name.toLowerCase();
43
43
  return order.find((e) => e.name.toLowerCase() === key);
44
44
  }
45
+ const MASTERSPRESENT_EVIDENCE = ["sse.plugins.format.mast-data-pairs-are-the-master-list", "sse.loadorder.files.five-masters-are-implicit-and-absent"];
45
46
  const mastersPresent = {
46
47
  id: "sse.plugins.masters-present",
47
48
  game: "skyrimse",
@@ -53,7 +54,7 @@ const mastersPresent = {
53
54
  needs: { project: true, install: true },
54
55
  cost: "edit",
55
56
  severity: "block",
56
- evidence: EVIDENCE,
57
+ evidence: MASTERSPRESENT_EVIDENCE,
57
58
  async run(ctx) {
58
59
  if (ctx.target.kind !== "file")
59
60
  return [];
@@ -79,12 +80,13 @@ const mastersPresent = {
79
80
  ? `Master "${master}" is under Data but not in the load order at all; ${path.basename(ctx.target.path)} depends on it.`
80
81
  : `Master "${master}" is neither in the load order nor under Data; ${path.basename(ctx.target.path)} cannot load without it.`,
81
82
  fix: entry ? `Activate ${master} (a * line in Plugins.txt).` : `Install ${master} and activate it ahead of ${path.basename(ctx.target.path)}.`,
82
- evidence: EVIDENCE,
83
+ evidence: MASTERSPRESENT_EVIDENCE,
83
84
  });
84
85
  }
85
86
  return findings;
86
87
  },
87
88
  };
89
+ const MASTERORDER_EVIDENCE = ["sse.plugins.format.mast-data-pairs-are-the-master-list", "sse.loadorder.files.master-hoisting"];
88
90
  const masterOrder = {
89
91
  id: "sse.plugins.master-order",
90
92
  game: "skyrimse",
@@ -96,7 +98,7 @@ const masterOrder = {
96
98
  needs: { project: true, install: true },
97
99
  cost: "edit",
98
100
  severity: "block",
99
- evidence: [...EVIDENCE],
101
+ evidence: [...MASTERORDER_EVIDENCE],
100
102
  async run(ctx) {
101
103
  if (ctx.target.kind !== "file")
102
104
  return [];
@@ -114,7 +116,7 @@ const masterOrder = {
114
116
  severity: "info",
115
117
  file: ctx.target.path,
116
118
  message: `${path.basename(ctx.target.path)} is not in the load order, so its masters' positions relative to it cannot be checked yet.`,
117
- evidence: EVIDENCE,
119
+ evidence: MASTERORDER_EVIDENCE,
118
120
  },
119
121
  ];
120
122
  }
@@ -129,12 +131,13 @@ const masterOrder = {
129
131
  file: ctx.target.path,
130
132
  message: `Master "${master}" loads at position ${entry.index}, after ${path.basename(ctx.target.path)} at ${self.index}; a plugin must load after every master it depends on.`,
131
133
  fix: `Move ${master} above ${path.basename(ctx.target.path)} in Plugins.txt (LOOT does this).`,
132
- evidence: [...EVIDENCE],
134
+ evidence: [...MASTERORDER_EVIDENCE],
133
135
  });
134
136
  }
135
137
  return findings;
136
138
  },
137
139
  };
140
+ const ESLHEADERVERSION_EVIDENCE = ["sse.plugins.esl.hedr-1-71-unlocks-the-expanded-range", "sse.plugins.esl.masters-at-1-71-force-the-plugin-to-1-71", "sse.plugins.esl.1-71-plugins-crash-old-game-builds"];
138
141
  const eslHeaderVersion = {
139
142
  id: "sse.plugins.esl-header-version",
140
143
  game: "skyrimse",
@@ -146,7 +149,7 @@ const eslHeaderVersion = {
146
149
  needs: { project: true },
147
150
  cost: "edit",
148
151
  severity: "block",
149
- evidence: EVIDENCE,
152
+ evidence: ESLHEADERVERSION_EVIDENCE,
150
153
  async run(ctx) {
151
154
  if (ctx.target.kind !== "file")
152
155
  return [];
@@ -161,7 +164,7 @@ const eslHeaderVersion = {
161
164
  severity: "block",
162
165
  file: ctx.target.path,
163
166
  message: `${path.basename(ctx.target.path)} has no HEDR subrecord; every plugin's TES4 record carries one.`,
164
- evidence: EVIDENCE,
167
+ evidence: ESLHEADERVERSION_EVIDENCE,
165
168
  });
166
169
  return findings;
167
170
  }
@@ -172,7 +175,7 @@ const eslHeaderVersion = {
172
175
  file: ctx.target.path,
173
176
  message: `HEDR version ${version.toFixed(2)} is not one Skyrim SE accepts (${VALID_HEADER_VERSIONS_SSE.map((v) => v.toFixed(2)).join(", ")}).`,
174
177
  fix: "Re-save the plugin with the Skyrim SE Creation Kit or xEdit in SSE mode.",
175
- evidence: EVIDENCE,
178
+ evidence: ESLHEADERVERSION_EVIDENCE,
176
179
  });
177
180
  return findings;
178
181
  }
@@ -185,7 +188,7 @@ const eslHeaderVersion = {
185
188
  file: ctx.target.path,
186
189
  message: `${path.basename(ctx.target.path)} has HEDR version ${version.toFixed(2)}, which needs game runtime ${RUNTIME_EXPANDED_LIGHT_RANGE} or later; this install is ${runtime}.`,
187
190
  fix: "Update the game, or re-save the plugin with a 1.70 header if it uses no FormID below 0x800.",
188
- evidence: EVIDENCE,
191
+ evidence: ESLHEADERVERSION_EVIDENCE,
189
192
  });
190
193
  }
191
194
  }
@@ -204,7 +207,7 @@ const eslHeaderVersion = {
204
207
  file: ctx.target.path,
205
208
  message: `Master "${master}" is a ${mh.headerVersion.toFixed(2)} plugin while ${path.basename(ctx.target.path)} is ${version.toFixed(2)}; referencing or overriding that master's records in the extended FormID range (001-7FF) will error.`,
206
209
  fix: "Re-save this plugin with a 1.71 header (xEdit 4.1.5b or later, or the current Creation Kit).",
207
- evidence: EVIDENCE,
210
+ evidence: ESLHEADERVERSION_EVIDENCE,
208
211
  });
209
212
  }
210
213
  }
@@ -217,6 +220,7 @@ const eslHeaderVersion = {
217
220
  return findings;
218
221
  },
219
222
  };
223
+ const FLAGEXTENSIONMISMATCH_EVIDENCE = ["sse.plugins.esl.extension-does-not-determine-lightness", "sse.plugins.format.tes4-flag-bits"];
220
224
  const flagExtensionMismatch = {
221
225
  id: "sse.plugins.flag-extension-mismatch",
222
226
  game: "skyrimse",
@@ -228,7 +232,7 @@ const flagExtensionMismatch = {
228
232
  needs: { project: true },
229
233
  cost: "edit",
230
234
  severity: "warn",
231
- evidence: EVIDENCE,
235
+ evidence: FLAGEXTENSIONMISMATCH_EVIDENCE,
232
236
  async run(ctx) {
233
237
  if (ctx.target.kind !== "file")
234
238
  return [];
@@ -245,7 +249,7 @@ const flagExtensionMismatch = {
245
249
  file: ctx.target.path,
246
250
  message: `${name} has the ESM flag set: the game treats it as a master regardless of the .esp extension, so it loads among the masters and every header-reading tool disagrees with its name.`,
247
251
  fix: "Clear the ESM flag in xEdit, or rename the file .esm so the extension says what the flag does.",
248
- evidence: EVIDENCE,
252
+ evidence: FLAGEXTENSIONMISMATCH_EVIDENCE,
249
253
  },
250
254
  ];
251
255
  }
@@ -257,13 +261,14 @@ const flagExtensionMismatch = {
257
261
  file: ctx.target.path,
258
262
  message: `${name} has no ESM flag: the .esm extension makes the game load it as a master anyway, but tools reading the header will not.`,
259
263
  fix: "Set the ESM flag in xEdit so the header and the extension agree.",
260
- evidence: EVIDENCE,
264
+ evidence: FLAGEXTENSIONMISMATCH_EVIDENCE,
261
265
  },
262
266
  ];
263
267
  }
264
268
  return [];
265
269
  },
266
270
  };
271
+ const ESLEXTENSIONIMPLIESFLAG_EVIDENCE = ["sse.plugins.esl.flag-is-bit-9-of-the-tes4-header", "sse.plugins.esl.extension-does-not-determine-lightness"];
267
272
  const eslExtensionImpliesFlag = {
268
273
  id: "sse.plugins.esl-extension-implies-flag",
269
274
  game: "skyrimse",
@@ -275,7 +280,7 @@ const eslExtensionImpliesFlag = {
275
280
  needs: { project: true },
276
281
  cost: "edit",
277
282
  severity: "warn",
278
- evidence: EVIDENCE,
283
+ evidence: ESLEXTENSIONIMPLIESFLAG_EVIDENCE,
279
284
  async run(ctx) {
280
285
  if (ctx.target.kind !== "file")
281
286
  return [];
@@ -291,11 +296,12 @@ const eslExtensionImpliesFlag = {
291
296
  file: ctx.target.path,
292
297
  message: `${path.basename(ctx.target.path)} is an .esl without the light flag (0x200) in its header; the game treats it as light because of the extension, header-reading tools do not.`,
293
298
  fix: "Set the ESL flag in xEdit (it checks the plugin's new records fit the light range first).",
294
- evidence: EVIDENCE,
299
+ evidence: ESLEXTENSIONIMPLIESFLAG_EVIDENCE,
295
300
  },
296
301
  ];
297
302
  },
298
303
  };
304
+ const FORMVERSION_EVIDENCE = ["sse.plugins.format.form-version-44-for-sse"];
299
305
  const formVersion = {
300
306
  id: "sse.plugins.form-version",
301
307
  game: "skyrimse",
@@ -307,7 +313,7 @@ const formVersion = {
307
313
  needs: { project: true },
308
314
  cost: "edit",
309
315
  severity: "block",
310
- evidence: EVIDENCE,
316
+ evidence: FORMVERSION_EVIDENCE,
311
317
  async run(ctx) {
312
318
  if (ctx.target.kind !== "file")
313
319
  return [];
@@ -326,11 +332,12 @@ const formVersion = {
326
332
  ? `${path.basename(ctx.target.path)} has form version 43: a Skyrim Legendary Edition plugin, not converted for Special Edition.`
327
333
  : `${path.basename(ctx.target.path)} has form version ${header.formVersion}; Skyrim SE plugins carry 44.`,
328
334
  fix: le ? "Open and re-save the plugin in the Skyrim SE Creation Kit to convert it (and re-pack any LE .bsa)." : "Re-save the plugin with the Skyrim SE Creation Kit or xEdit.",
329
- evidence: EVIDENCE,
335
+ evidence: FORMVERSION_EVIDENCE,
330
336
  },
331
337
  ];
332
338
  },
333
339
  };
340
+ const MASTERCOUNT_EVIDENCE = ["sse.plugins.esl.master-list-limit-is-253"];
334
341
  const masterCount = {
335
342
  id: "sse.plugins.master-count",
336
343
  game: "skyrimse",
@@ -342,7 +349,7 @@ const masterCount = {
342
349
  needs: { project: true },
343
350
  cost: "edit",
344
351
  severity: "block",
345
- evidence: EVIDENCE,
352
+ evidence: MASTERCOUNT_EVIDENCE,
346
353
  async run(ctx) {
347
354
  if (ctx.target.kind !== "file")
348
355
  return [];
@@ -358,7 +365,7 @@ const masterCount = {
358
365
  file: ctx.target.path,
359
366
  message: `${path.basename(ctx.target.path)} names ${header.masters.length} masters; Skyrim SE allows ${MASTER_LIMIT_SSE}.`,
360
367
  fix: "Clean unused masters in xEdit, or split the plugin.",
361
- evidence: EVIDENCE,
368
+ evidence: MASTERCOUNT_EVIDENCE,
362
369
  },
363
370
  ];
364
371
  },
@@ -60,7 +60,7 @@ facts:
60
60
  - id: cp2077.tweakxl.yaml-syntax.duplicate-keys-break-file
61
61
  claim: A duplicate key at the same nesting level in a TweakXL yaml file breaks the whole file's
62
62
  parse; the community-recommended check is to run the file through yamllint.com before packing.
63
- status: community
63
+ status: contradicted
64
64
  source: CDPR-Modding-Documentation/Cyberpunk-Modding-Docs
65
65
  for-mod-creators-theory/core-mods-explained/tweakxl/tweakxl-changing-game-records/how-to-yaml-tweak-modding-basics.md,
66
66
  section "Key Uniqueness"
@@ -69,6 +69,26 @@ facts:
69
69
  - yaml
70
70
  - duplicate-key
71
71
  - validator
72
+ detail: "Contradicted for TweakXL 1.11.4: on 2026-10-04 it read seven installed files that repeat a
73
+ top-level key and logged no error for any of them, while it does log `[error] yaml-cpp: ...`
74
+ for a file that fails to parse
75
+ (cp2077.tweakxl.hotreload.parse-error-still-ends-in-import-completed). See
76
+ cp2077.tweakxl.yaml-syntax.duplicate-keys-do-not-fail-the-parse."
77
+ - id: cp2077.tweakxl.yaml-syntax.duplicate-keys-do-not-fail-the-parse
78
+ claim: "TweakXL 1.11.4 reads a yaml file that repeats a top-level key without a parse error: its log
79
+ shows the file being read and no error line, and the rest of the import goes on. Which
80
+ occurrence wins, or whether both apply, is not established."
81
+ status: verified
82
+ verified_on: "2.31"
83
+ source: ModWright field verification
84
+ supersedes: cp2077.tweakxl.yaml-syntax.duplicate-keys-break-file
85
+ tags:
86
+ - tweakxl
87
+ - yaml
88
+ - duplicate-key
89
+ detail: ModWright's write census (who_touches) reads such a file with every occurrence, in file
90
+ order. The yaml-lint rule still reports a repeated key in a project's own file, where it is
91
+ usually a mistake.
72
92
  - id: cp2077.tweakxl.yaml-syntax.record-names-must-be-unique
73
93
  claim: Every TweakXL record (a named container grouping properties, which may themselves be other
74
94
  records or flats) must have a globally unique name; two records sharing a name overwrite each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modwright",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A game-agnostic MCP server for game modding, with per-game surfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",