modwright 0.1.3 → 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.
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";
@@ -14,6 +14,8 @@ import { buildContext, createValidatorRegistry, runValidators } from "./core/val
14
14
  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
+ import { removeBridge } from "./core/bridge/remove.js";
18
+ import { BACKUP_RETENTION, pruneDated } from "./core/safety/retention.js";
17
19
  import { runTestPlan } from "./core/testplan/index.js";
18
20
  import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
19
21
  import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
@@ -29,7 +31,27 @@ import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds }
29
31
  import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
30
32
  import { bootstrapProject, describe as describeScaffold, findScaffold, findSequence, generateClaudeAssets, loadAllScaffolds, loadAllSequences, planSequence, runScaffold, summarize, summarizeSequences } from "./core/scaffold/index.js";
31
33
  import { loadSkillPromptCatalog, renderSkillPrompt } from "./core/prompts.js";
32
- 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
+ }
33
55
  const registry = new SignatureRegistry();
34
56
  for (const surface of SURFACES) {
35
57
  if (surface.logSignatures?.length)
@@ -312,6 +334,7 @@ server.registerTool("who_touches", {
312
334
  const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
313
335
  const { errors, ...coverage } = collection.coverage;
314
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);
315
338
  const result = key
316
339
  ? { mode: "key", ...whoTouches(collection, key, limit) }
317
340
  : mod
@@ -327,6 +350,16 @@ server.registerTool("who_touches", {
327
350
  errors: errors.slice(0, 25),
328
351
  },
329
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
+ : {}),
330
363
  ...result,
331
364
  note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
332
365
  "key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
@@ -1299,19 +1332,59 @@ server.registerTool("rollback", {
1299
1332
  "allowOutsideRoots is set.",
1300
1333
  inputSchema: {
1301
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."),
1302
1339
  backupId: z.string().optional().describe("The stamp naming one backup directory under .modwright/backups (from this tool's own listing). Omit to list."),
1303
- 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."),
1304
1345
  installPath: installPathArg,
1305
1346
  allowOutsideRoots: z
1306
1347
  .boolean()
1307
1348
  .default(false)
1308
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."),
1309
1350
  },
1310
- }, 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
+ }
1311
1382
  const resolved = await projectOrFail(start);
1312
1383
  if ("isError" in resolved)
1313
1384
  return resolved;
1314
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).");
1315
1388
  if (!backupId) {
1316
1389
  const backups = await listBackups(backupRoot);
1317
1390
  return json({
@@ -1401,10 +1474,13 @@ server.registerTool("bridge", {
1401
1474
  "(dry-run by default for both; refuses on a protocolVersion mismatch rather than guessing). 'poll': wait for " +
1402
1475
  "that request's result up to a budget of max(15s, 3s x probe count) + window, capped at 300s, " +
1403
1476
  "reporting one of four named timeout outcomes rather than a bare 'timed out' — plus, on the CET log transport, " +
1404
- "'truncated' when the result line was cut mid-write. Test-plan execution is run_tests; claims are the " +
1477
+ "'truncated' when the result line was cut mid-write. 'remove': take a deployed bridge back out of the game " +
1478
+ "(every folder the project's withBridge deploy targets name), backed up under the project; dry-run by default. " +
1479
+ "A deploy without the withBridge variant leaves an installed bridge in place. " +
1480
+ "Test-plan execution is run_tests; claims are the " +
1405
1481
  "ledger tool.",
1406
1482
  inputSchema: {
1407
- action: z.enum(["status", "arm", "poll", "stage"]),
1483
+ action: z.enum(["status", "arm", "poll", "stage", "remove"]),
1408
1484
  game: z
1409
1485
  .enum(BRIDGE_GAMES)
1410
1486
  .optional()
@@ -1475,6 +1551,38 @@ server.registerTool("bridge", {
1475
1551
  return fail(`bridge stage failed: ${error.message}`);
1476
1552
  }
1477
1553
  }
1554
+ if (action === "remove") {
1555
+ if (!start)
1556
+ return fail('bridge remove requires "path" (the project whose deployed bridge to remove).');
1557
+ const surfaceId = game ?? projectGame;
1558
+ const removeSurface = surfaceId ? getSurface(surfaceId) : undefined;
1559
+ if (!removeSurface)
1560
+ return fail(`Could not determine the game for ${start}; pass game explicitly.`);
1561
+ const install = await resolveInstall(removeSurface, installPath);
1562
+ if (!install)
1563
+ return fail(`No install detected for ${removeSurface.name}.`);
1564
+ try {
1565
+ const resolved = await loadProject(start);
1566
+ const report = await removeBridge(resolved, removeSurface.modRoots(install), { mode });
1567
+ const toRemove = report.removals.filter((r) => r.action === "remove").length;
1568
+ return json({
1569
+ action,
1570
+ game: removeSurface.id,
1571
+ project: resolved.project.name,
1572
+ ...report,
1573
+ note: report.removals.length === 0
1574
+ ? "The project has no withBridge deploy target: nothing to remove."
1575
+ : toRemove === 0
1576
+ ? "No deployed bridge folder was found: nothing to remove."
1577
+ : report.applied
1578
+ ? `Removed ${toRemove} bridge folder(s); the backup is under the project's .modwright/backups (rollback restores it).`
1579
+ : `Dry run: ${toRemove} bridge folder(s) would be removed, with a backup. Repeat with mode 'apply'.`,
1580
+ });
1581
+ }
1582
+ catch (error) {
1583
+ return fail(`bridge remove failed: ${error.message}`);
1584
+ }
1585
+ }
1478
1586
  const gameId = game ?? projectGame;
1479
1587
  if (!gameId)
1480
1588
  return fail('bridge status/arm/poll requires "game" (or a "path" to a project that names one).');
@@ -1650,7 +1758,7 @@ server.registerTool("ledger", {
1650
1758
  scopeFiles: z.array(z.string()).optional().describe("'record' kind:\"claim\": files this claim is about, for a new claim."),
1651
1759
  scopeEntries: z.array(z.string()).optional().describe("'record' kind:\"claim\": named entries (stat/tweak ids) this claim is about, for a new claim."),
1652
1760
  toStatus: z.enum(["authored", "checker-clean", "built", "verified", "contradicted"]).optional().describe("'record' kind:\"claim\": the status to move this claim to."),
1653
- 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."),
1654
1762
  by: z.string().optional().describe("'record': who or what made this write. Default \"ledger\"."),
1655
1763
  note: z.string().optional().describe("'record': why, for the history entry."),
1656
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;