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/README.md +2 -2
- package/bridges/bg3-se/README.md +3 -2
- package/bridges/bg3-se/bridge.json +1 -1
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +115 -1
- package/bridges/cp2077-cet/README.md +3 -2
- package/bridges/cp2077-cet/bridge.json +2 -2
- package/dist/core/bridge/remove.js +48 -0
- package/dist/core/build/run.js +5 -0
- package/dist/core/deploy/plan.js +61 -0
- package/dist/core/safety/retention.js +76 -0
- package/dist/core/testplan/registry.js +33 -0
- package/dist/core/writes/index.js +9 -0
- package/dist/index.js +42 -2
- package/dist/server.js +115 -7
- package/dist/surfaces/baldursgate3/validators/bridge.js +1 -1
- package/dist/surfaces/baldursgate3/validators/templates.js +1 -1
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +29 -0
- package/dist/surfaces/cyberpunk2077/validators/bridge.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/modsettings.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/templates.js +1 -1
- package/dist/surfaces/cyberpunk2077/validators/tweaks.js +11 -4
- package/dist/surfaces/cyberpunk2077/writes.js +8 -3
- package/dist/surfaces/skyrimse/validators/archives.js +9 -7
- package/dist/surfaces/skyrimse/validators/plugins.js +28 -21
- package/knowledge/cyberpunk2077/cet.natives.yaml +48 -0
- package/knowledge/cyberpunk2077/tweakxl.yaml-syntax.yaml +21 -1
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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.
|
|
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\":
|
|
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
|
|
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
|
|
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
|
|
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 = ["
|
|
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
|
|
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
|
-
|
|
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 = ["
|
|
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 = ["
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
106
|
+
evidence: ARCHIVENAMEFLAGS_EVIDENCE,
|
|
105
107
|
});
|
|
106
108
|
}
|
|
107
109
|
return findings;
|