novel-writer-cli 0.0.1
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/LICENSE +21 -0
- package/README.md +103 -0
- package/agents/chapter-writer.md +142 -0
- package/agents/character-weaver.md +117 -0
- package/agents/consistency-auditor.md +85 -0
- package/agents/plot-architect.md +128 -0
- package/agents/quality-judge.md +232 -0
- package/agents/style-analyzer.md +109 -0
- package/agents/style-refiner.md +97 -0
- package/agents/summarizer.md +128 -0
- package/agents/world-builder.md +161 -0
- package/dist/__tests__/character-voice.test.js +445 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
- package/dist/__tests__/engagement.test.js +382 -0
- package/dist/__tests__/foreshadow-visibility.test.js +131 -0
- package/dist/__tests__/hook-ledger.test.js +1028 -0
- package/dist/__tests__/naming-lint.test.js +132 -0
- package/dist/__tests__/narrative-health-injection.test.js +359 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
- package/dist/__tests__/next-step-title-fix.test.js +153 -0
- package/dist/__tests__/platform-profile.test.js +274 -0
- package/dist/__tests__/promise-ledger.test.js +189 -0
- package/dist/__tests__/readability-lint.test.js +209 -0
- package/dist/__tests__/text-utils.test.js +39 -0
- package/dist/__tests__/title-policy.test.js +147 -0
- package/dist/advance.js +75 -0
- package/dist/character-voice.js +805 -0
- package/dist/checkpoint.js +126 -0
- package/dist/cli.js +563 -0
- package/dist/cliche-lint.js +515 -0
- package/dist/commit.js +1460 -0
- package/dist/consistency-auditor.js +684 -0
- package/dist/engagement.js +687 -0
- package/dist/errors.js +7 -0
- package/dist/fingerprint.js +16 -0
- package/dist/foreshadow-visibility.js +214 -0
- package/dist/fs-utils.js +68 -0
- package/dist/hook-ledger.js +721 -0
- package/dist/hook-policy.js +107 -0
- package/dist/instruction-gates.js +51 -0
- package/dist/instructions.js +406 -0
- package/dist/latest-summary-loader.js +29 -0
- package/dist/lock.js +121 -0
- package/dist/naming-lint.js +531 -0
- package/dist/ner.js +73 -0
- package/dist/next-step.js +408 -0
- package/dist/novel-ask.js +270 -0
- package/dist/output.js +9 -0
- package/dist/platform-constraints.js +518 -0
- package/dist/platform-profile.js +325 -0
- package/dist/prejudge-guardrails.js +370 -0
- package/dist/project.js +40 -0
- package/dist/promise-ledger.js +723 -0
- package/dist/readability-lint.js +555 -0
- package/dist/safe-parse.js +36 -0
- package/dist/safe-path.js +29 -0
- package/dist/scoring-weights.js +290 -0
- package/dist/steps.js +60 -0
- package/dist/text-utils.js +18 -0
- package/dist/title-policy.js +251 -0
- package/dist/type-guards.js +6 -0
- package/dist/validate.js +131 -0
- package/docs/user/README.md +17 -0
- package/docs/user/guardrails.md +179 -0
- package/docs/user/interactive-gates.md +124 -0
- package/docs/user/novel-cli.md +289 -0
- package/docs/user/ops.md +123 -0
- package/docs/user/quick-start.md +97 -0
- package/docs/user/spec-system.md +166 -0
- package/docs/user/storylines.md +144 -0
- package/package.json +48 -0
- package/schemas/README.md +18 -0
- package/schemas/character-voice-drift.schema.json +135 -0
- package/schemas/character-voice-profiles.schema.json +141 -0
- package/schemas/engagement-metrics.schema.json +38 -0
- package/schemas/hook-ledger.schema.json +108 -0
- package/schemas/platform-profile.schema.json +235 -0
- package/schemas/promise-ledger.schema.json +97 -0
- package/scripts/calibrate-quality-judge.sh +91 -0
- package/scripts/compare-regression-runs.sh +86 -0
- package/scripts/lib/_common.py +131 -0
- package/scripts/lib/calibrate_quality_judge.py +312 -0
- package/scripts/lib/compare_regression_runs.py +142 -0
- package/scripts/lib/run_regression.py +621 -0
- package/scripts/lint-blacklist.sh +201 -0
- package/scripts/lint-cliche.sh +370 -0
- package/scripts/lint-readability.sh +404 -0
- package/scripts/query-foreshadow.sh +252 -0
- package/scripts/run-ner.sh +669 -0
- package/scripts/run-regression.sh +122 -0
- package/skills/cli-step/SKILL.md +158 -0
- package/skills/continue/SKILL.md +348 -0
- package/skills/continue/references/context-contracts.md +169 -0
- package/skills/continue/references/continuity-checks.md +187 -0
- package/skills/continue/references/file-protocols.md +64 -0
- package/skills/continue/references/foreshadowing.md +130 -0
- package/skills/continue/references/gate-decision.md +53 -0
- package/skills/continue/references/periodic-maintenance.md +46 -0
- package/skills/novel-writing/SKILL.md +77 -0
- package/skills/novel-writing/references/quality-rubric.md +140 -0
- package/skills/novel-writing/references/style-guide.md +145 -0
- package/skills/start/SKILL.md +458 -0
- package/skills/start/references/quality-review.md +86 -0
- package/skills/start/references/setting-update.md +44 -0
- package/skills/start/references/vol-planning.md +61 -0
- package/skills/start/references/vol-review.md +58 -0
- package/skills/status/SKILL.md +116 -0
- package/skills/status/references/sample-output.md +60 -0
- package/templates/ai-blacklist.json +79 -0
- package/templates/brief-template.md +46 -0
- package/templates/genre-weight-profiles.json +90 -0
- package/templates/novel-ask/example.answer.json +12 -0
- package/templates/novel-ask/example.question.json +51 -0
- package/templates/platform-profile.json +148 -0
- package/templates/style-profile-template.json +58 -0
- package/templates/web-novel-cliche-lint.json +41 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { NovelCliError } from "./errors.js";
|
|
5
|
+
import { fingerprintFile, fingerprintTextFile, fingerprintsMatch } from "./fingerprint.js";
|
|
6
|
+
import { ensureDir, pathExists, readJsonFile, writeJsonFile } from "./fs-utils.js";
|
|
7
|
+
import { rejectPathTraversalInput } from "./safe-path.js";
|
|
8
|
+
import { pad3 } from "./steps.js";
|
|
9
|
+
import { isPlainObject } from "./type-guards.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
function requireStringArray(value, file, field) {
|
|
12
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
13
|
+
throw new NovelCliError(`Invalid ${file}: '${field}' must be a string array.`, 2);
|
|
14
|
+
}
|
|
15
|
+
return value.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
16
|
+
}
|
|
17
|
+
function parseOptionalStringArray(value) {
|
|
18
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
|
|
19
|
+
return [];
|
|
20
|
+
return value.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
21
|
+
}
|
|
22
|
+
function requireSeverity(value, file, field) {
|
|
23
|
+
if (value === "warn" || value === "soft" || value === "hard")
|
|
24
|
+
return value;
|
|
25
|
+
throw new NovelCliError(`Invalid ${file}: '${field}' must be one of: warn, soft, hard.`, 2);
|
|
26
|
+
}
|
|
27
|
+
function uniquePreserveOrder(items) {
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const it of items) {
|
|
31
|
+
if (seen.has(it))
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(it);
|
|
34
|
+
out.push(it);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function severityRank(s) {
|
|
39
|
+
switch (s) {
|
|
40
|
+
case "warn":
|
|
41
|
+
return 1;
|
|
42
|
+
case "soft":
|
|
43
|
+
return 2;
|
|
44
|
+
case "hard":
|
|
45
|
+
return 3;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function maxSeverity(a, b) {
|
|
49
|
+
return severityRank(a) >= severityRank(b) ? a : b;
|
|
50
|
+
}
|
|
51
|
+
function parseConfig(raw, file) {
|
|
52
|
+
if (!isPlainObject(raw))
|
|
53
|
+
throw new NovelCliError(`Invalid ${file}: expected a JSON object.`, 2);
|
|
54
|
+
const obj = raw;
|
|
55
|
+
const schema_version = typeof obj.schema_version === "number" && Number.isInteger(obj.schema_version) ? obj.schema_version : 0;
|
|
56
|
+
const last_updated = typeof obj.last_updated === "string" && obj.last_updated.trim().length > 0 ? obj.last_updated.trim() : null;
|
|
57
|
+
const words = obj.words === undefined ? [] : requireStringArray(obj.words, file, "words");
|
|
58
|
+
const categories = {};
|
|
59
|
+
if (obj.categories !== undefined) {
|
|
60
|
+
if (!isPlainObject(obj.categories))
|
|
61
|
+
throw new NovelCliError(`Invalid ${file}: 'categories' must be an object.`, 2);
|
|
62
|
+
for (const [cat, list] of Object.entries(obj.categories)) {
|
|
63
|
+
categories[cat] = uniquePreserveOrder(parseOptionalStringArray(list));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const severityDefault = "warn";
|
|
67
|
+
let severity_default = severityDefault;
|
|
68
|
+
const per_category = {};
|
|
69
|
+
const per_word = {};
|
|
70
|
+
if (obj.severity !== undefined) {
|
|
71
|
+
if (!isPlainObject(obj.severity))
|
|
72
|
+
throw new NovelCliError(`Invalid ${file}: 'severity' must be an object.`, 2);
|
|
73
|
+
const sev = obj.severity;
|
|
74
|
+
const def = sev.default;
|
|
75
|
+
severity_default = def === undefined ? severityDefault : requireSeverity(def, file, "severity.default");
|
|
76
|
+
const pc = sev.per_category;
|
|
77
|
+
if (pc !== undefined) {
|
|
78
|
+
if (!isPlainObject(pc))
|
|
79
|
+
throw new NovelCliError(`Invalid ${file}: 'severity.per_category' must be an object.`, 2);
|
|
80
|
+
for (const [k, v] of Object.entries(pc)) {
|
|
81
|
+
per_category[k] = requireSeverity(v, file, `severity.per_category.${k}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const pw = sev.per_word;
|
|
85
|
+
if (pw !== undefined) {
|
|
86
|
+
if (!isPlainObject(pw))
|
|
87
|
+
throw new NovelCliError(`Invalid ${file}: 'severity.per_word' must be an object.`, 2);
|
|
88
|
+
for (const [k, v] of Object.entries(pw)) {
|
|
89
|
+
per_word[k] = requireSeverity(v, file, `severity.per_word.${k}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const whitelistRaw = obj.whitelist;
|
|
94
|
+
const whitelist = Array.isArray(whitelistRaw)
|
|
95
|
+
? parseOptionalStringArray(whitelistRaw)
|
|
96
|
+
: isPlainObject(whitelistRaw)
|
|
97
|
+
? parseOptionalStringArray(whitelistRaw.words)
|
|
98
|
+
: [];
|
|
99
|
+
const exemptionsObj = isPlainObject(obj.exemptions) ? obj.exemptions : {};
|
|
100
|
+
const exemptionsExact = parseOptionalStringArray(exemptionsObj.exact);
|
|
101
|
+
const exemptionsRegex = parseOptionalStringArray(exemptionsObj.regex);
|
|
102
|
+
return {
|
|
103
|
+
schema_version,
|
|
104
|
+
last_updated,
|
|
105
|
+
words: uniquePreserveOrder(words),
|
|
106
|
+
categories,
|
|
107
|
+
severity: { default: severity_default, per_category, per_word },
|
|
108
|
+
whitelist: uniquePreserveOrder(whitelist),
|
|
109
|
+
exemptions: { exact: uniquePreserveOrder(exemptionsExact), regex: uniquePreserveOrder(exemptionsRegex) }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function loadWebNovelClicheLintConfig(rootDir) {
|
|
113
|
+
const relPath = "web-novel-cliche-lint.json";
|
|
114
|
+
const absPath = join(rootDir, relPath);
|
|
115
|
+
if (!(await pathExists(absPath)))
|
|
116
|
+
return null;
|
|
117
|
+
const raw = await readJsonFile(absPath);
|
|
118
|
+
return { relPath, config: parseConfig(raw, relPath) };
|
|
119
|
+
}
|
|
120
|
+
function codepointCompare(a, b) {
|
|
121
|
+
if (a === b)
|
|
122
|
+
return 0;
|
|
123
|
+
return a < b ? -1 : 1;
|
|
124
|
+
}
|
|
125
|
+
function countNonWhitespaceChars(text) {
|
|
126
|
+
const compact = text.replace(/\s+/gu, "");
|
|
127
|
+
return Array.from(compact).length;
|
|
128
|
+
}
|
|
129
|
+
function collectLineEvidence(text, phrase) {
|
|
130
|
+
const lines = [];
|
|
131
|
+
const snippets = [];
|
|
132
|
+
for (const [i, line] of text.split(/\r?\n/gu).entries()) {
|
|
133
|
+
if (!line.includes(phrase))
|
|
134
|
+
continue;
|
|
135
|
+
const lineNo = i + 1;
|
|
136
|
+
lines.push(lineNo);
|
|
137
|
+
if (snippets.length < 5) {
|
|
138
|
+
const snippet = line.trim();
|
|
139
|
+
snippets.push(snippet.length > 160 ? `${snippet.slice(0, 160)}…` : snippet);
|
|
140
|
+
}
|
|
141
|
+
if (lines.length >= 20)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
return { lines, snippets };
|
|
145
|
+
}
|
|
146
|
+
function maskLiteral(text, phrase) {
|
|
147
|
+
if (!phrase)
|
|
148
|
+
return text;
|
|
149
|
+
return text.replaceAll(phrase, "\x00".repeat(phrase.length));
|
|
150
|
+
}
|
|
151
|
+
function maskExemptions(text, exemptions) {
|
|
152
|
+
let masked = text;
|
|
153
|
+
for (const phrase of exemptions.exact) {
|
|
154
|
+
masked = maskLiteral(masked, phrase);
|
|
155
|
+
}
|
|
156
|
+
for (const pattern of exemptions.regex) {
|
|
157
|
+
try {
|
|
158
|
+
const re = new RegExp(pattern, "gu");
|
|
159
|
+
masked = masked.replace(re, (m) => "\x00".repeat(m.length));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// ignore invalid regex patterns
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return masked;
|
|
166
|
+
}
|
|
167
|
+
function buildWordIndex(cfg) {
|
|
168
|
+
const index = new Map();
|
|
169
|
+
const whitelist = new Set(cfg.whitelist);
|
|
170
|
+
const exemptionsExact = new Set(cfg.exemptions.exact);
|
|
171
|
+
const addWord = (word, category) => {
|
|
172
|
+
const trimmed = word.trim();
|
|
173
|
+
if (trimmed.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
if (whitelist.has(trimmed))
|
|
176
|
+
return;
|
|
177
|
+
if (exemptionsExact.has(trimmed))
|
|
178
|
+
return;
|
|
179
|
+
const existing = index.get(trimmed) ?? { categories: new Set(), severity: cfg.severity.default };
|
|
180
|
+
if (category)
|
|
181
|
+
existing.categories.add(category);
|
|
182
|
+
index.set(trimmed, existing);
|
|
183
|
+
};
|
|
184
|
+
for (const w of cfg.words)
|
|
185
|
+
addWord(w, null);
|
|
186
|
+
for (const [cat, list] of Object.entries(cfg.categories)) {
|
|
187
|
+
for (const w of list)
|
|
188
|
+
addWord(w, cat);
|
|
189
|
+
}
|
|
190
|
+
const out = new Map();
|
|
191
|
+
for (const [word, meta] of index.entries()) {
|
|
192
|
+
let severity = cfg.severity.default;
|
|
193
|
+
const perWord = cfg.severity.per_word[word];
|
|
194
|
+
if (perWord) {
|
|
195
|
+
severity = perWord;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
for (const cat of meta.categories) {
|
|
199
|
+
const catSev = cfg.severity.per_category[cat];
|
|
200
|
+
if (catSev)
|
|
201
|
+
severity = maxSeverity(severity, catSev);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
out.set(word, { categories: Array.from(meta.categories).sort(), severity });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function computeFallbackReport(args) {
|
|
209
|
+
const generated_at = new Date().toISOString();
|
|
210
|
+
const chars = countNonWhitespaceChars(args.chapterText);
|
|
211
|
+
const index = buildWordIndex(args.config);
|
|
212
|
+
const words = Array.from(index.keys()).sort((a, b) => b.length - a.length || codepointCompare(a, b));
|
|
213
|
+
let masked = maskExemptions(args.chapterText, args.config.exemptions);
|
|
214
|
+
const hits = [];
|
|
215
|
+
let totalHits = 0;
|
|
216
|
+
const severityCounts = { warn: 0, soft: 0, hard: 0 };
|
|
217
|
+
const categoryCounts = new Map();
|
|
218
|
+
for (const word of words) {
|
|
219
|
+
if (!word)
|
|
220
|
+
continue;
|
|
221
|
+
const count = masked.split(word).length - 1;
|
|
222
|
+
if (count <= 0)
|
|
223
|
+
continue;
|
|
224
|
+
masked = maskLiteral(masked, word);
|
|
225
|
+
const meta = index.get(word);
|
|
226
|
+
const severity = meta?.severity ?? args.config.severity.default;
|
|
227
|
+
const categories = meta?.categories ?? [];
|
|
228
|
+
const category = categories[0] ?? null;
|
|
229
|
+
totalHits += count;
|
|
230
|
+
severityCounts[severity] += count;
|
|
231
|
+
if (category)
|
|
232
|
+
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + count);
|
|
233
|
+
const evidence = collectLineEvidence(args.chapterText, word);
|
|
234
|
+
hits.push({ word, count, severity, category, categories, lines: evidence.lines, snippets: evidence.snippets });
|
|
235
|
+
}
|
|
236
|
+
hits.sort((a, b) => b.count - a.count || severityRank(b.severity) - severityRank(a.severity) || codepointCompare(a.word, b.word));
|
|
237
|
+
const hits_per_kchars = chars > 0 ? Math.round((totalHits / (chars / 1000.0)) * 1000) / 1000 : 0;
|
|
238
|
+
const perK = (n) => (chars > 0 ? Math.round((n / (chars / 1000.0)) * 1000) / 1000 : 0);
|
|
239
|
+
const by_category = {};
|
|
240
|
+
for (const [cat, n] of categoryCounts.entries()) {
|
|
241
|
+
by_category[cat] = { hits: n, hits_per_kchars: perK(n) };
|
|
242
|
+
}
|
|
243
|
+
const top_hits = hits.slice(0, 10).map((h) => ({ word: h.word, count: h.count, severity: h.severity, category: h.category }));
|
|
244
|
+
const has_hard_hits = severityCounts.hard > 0;
|
|
245
|
+
return {
|
|
246
|
+
schema_version: 1,
|
|
247
|
+
generated_at,
|
|
248
|
+
scope: { chapter: args.chapter },
|
|
249
|
+
config: { schema_version: args.config.schema_version, last_updated: args.config.last_updated },
|
|
250
|
+
mode: "fallback",
|
|
251
|
+
chars,
|
|
252
|
+
total_hits: totalHits,
|
|
253
|
+
hits_per_kchars,
|
|
254
|
+
by_severity: {
|
|
255
|
+
warn: { hits: severityCounts.warn, hits_per_kchars: perK(severityCounts.warn) },
|
|
256
|
+
soft: { hits: severityCounts.soft, hits_per_kchars: perK(severityCounts.soft) },
|
|
257
|
+
hard: { hits: severityCounts.hard, hits_per_kchars: perK(severityCounts.hard) }
|
|
258
|
+
},
|
|
259
|
+
by_category,
|
|
260
|
+
hits,
|
|
261
|
+
top_hits,
|
|
262
|
+
has_hard_hits
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function parseScriptReport(raw) {
|
|
266
|
+
if (!isPlainObject(raw))
|
|
267
|
+
throw new NovelCliError(`Invalid cliche lint script output: expected an object.`, 2);
|
|
268
|
+
const obj = raw;
|
|
269
|
+
if (obj.schema_version !== 1)
|
|
270
|
+
throw new NovelCliError(`Invalid cliche lint script output: schema_version must be 1.`, 2);
|
|
271
|
+
if (!isPlainObject(obj.scope) || typeof obj.scope.chapter !== "number") {
|
|
272
|
+
throw new NovelCliError(`Invalid cliche lint script output: missing scope.chapter.`, 2);
|
|
273
|
+
}
|
|
274
|
+
const requireNumber = (v, field) => {
|
|
275
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
276
|
+
throw new NovelCliError(`Invalid cliche lint script output: '${field}' must be a number.`, 2);
|
|
277
|
+
return v;
|
|
278
|
+
};
|
|
279
|
+
const requireInt = (v, field) => {
|
|
280
|
+
const n = requireNumber(v, field);
|
|
281
|
+
if (!Number.isInteger(n))
|
|
282
|
+
throw new NovelCliError(`Invalid cliche lint script output: '${field}' must be an int.`, 2);
|
|
283
|
+
return n;
|
|
284
|
+
};
|
|
285
|
+
const requireString = (v, field) => {
|
|
286
|
+
if (typeof v !== "string" || v.trim().length === 0)
|
|
287
|
+
throw new NovelCliError(`Invalid cliche lint script output: '${field}' must be a non-empty string.`, 2);
|
|
288
|
+
return v;
|
|
289
|
+
};
|
|
290
|
+
const requireBool = (v, field) => {
|
|
291
|
+
if (typeof v !== "boolean")
|
|
292
|
+
throw new NovelCliError(`Invalid cliche lint script output: '${field}' must be a boolean.`, 2);
|
|
293
|
+
return v;
|
|
294
|
+
};
|
|
295
|
+
const scopeObj = obj.scope;
|
|
296
|
+
const scope = { chapter: requireInt(scopeObj.chapter, "scope.chapter") };
|
|
297
|
+
const configObj = isPlainObject(obj.config) ? obj.config : {};
|
|
298
|
+
const configSchemaVersionRaw = configObj.schema_version;
|
|
299
|
+
const configSchemaVersion = typeof configSchemaVersionRaw === "number" && Number.isInteger(configSchemaVersionRaw) ? configSchemaVersionRaw : 0;
|
|
300
|
+
const configLastUpdatedRaw = configObj.last_updated;
|
|
301
|
+
const configLastUpdated = typeof configLastUpdatedRaw === "string" && configLastUpdatedRaw.trim().length > 0 ? configLastUpdatedRaw.trim() : null;
|
|
302
|
+
const bySeverityRaw = obj.by_severity;
|
|
303
|
+
if (!isPlainObject(bySeverityRaw))
|
|
304
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'by_severity' must be an object.`, 2);
|
|
305
|
+
const bySev = bySeverityRaw;
|
|
306
|
+
const parseSevBucket = (key) => {
|
|
307
|
+
const rawBucket = bySev[key];
|
|
308
|
+
if (!isPlainObject(rawBucket))
|
|
309
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'by_severity.${key}' must be an object.`, 2);
|
|
310
|
+
const bucket = rawBucket;
|
|
311
|
+
return { hits: requireInt(bucket.hits, `by_severity.${key}.hits`), hits_per_kchars: requireNumber(bucket.hits_per_kchars, `by_severity.${key}.hits_per_kchars`) };
|
|
312
|
+
};
|
|
313
|
+
const by_severity = {
|
|
314
|
+
warn: parseSevBucket("warn"),
|
|
315
|
+
soft: parseSevBucket("soft"),
|
|
316
|
+
hard: parseSevBucket("hard")
|
|
317
|
+
};
|
|
318
|
+
const by_category = {};
|
|
319
|
+
if (obj.by_category !== undefined) {
|
|
320
|
+
if (!isPlainObject(obj.by_category))
|
|
321
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'by_category' must be an object.`, 2);
|
|
322
|
+
for (const [cat, rawBucket] of Object.entries(obj.by_category)) {
|
|
323
|
+
if (!isPlainObject(rawBucket))
|
|
324
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'by_category.${cat}' must be an object.`, 2);
|
|
325
|
+
const bucket = rawBucket;
|
|
326
|
+
by_category[cat] = {
|
|
327
|
+
hits: requireInt(bucket.hits, `by_category.${cat}.hits`),
|
|
328
|
+
hits_per_kchars: requireNumber(bucket.hits_per_kchars, `by_category.${cat}.hits_per_kchars`)
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const hitsRaw = obj.hits;
|
|
333
|
+
if (!Array.isArray(hitsRaw))
|
|
334
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'hits' must be an array.`, 2);
|
|
335
|
+
const hits = hitsRaw.map((h, idx) => {
|
|
336
|
+
if (!isPlainObject(h))
|
|
337
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'hits[${idx}]' must be an object.`, 2);
|
|
338
|
+
const ho = h;
|
|
339
|
+
const word = requireString(ho.word, `hits[${idx}].word`);
|
|
340
|
+
const count = requireInt(ho.count, `hits[${idx}].count`);
|
|
341
|
+
const severity = requireSeverity(ho.severity, `cliche lint script output`, `hits[${idx}].severity`);
|
|
342
|
+
const catRaw = ho.category;
|
|
343
|
+
const category = catRaw === null ? null : typeof catRaw === "string" && catRaw.trim().length > 0 ? catRaw.trim() : null;
|
|
344
|
+
const categories = Array.isArray(ho.categories) ? parseOptionalStringArray(ho.categories) : [];
|
|
345
|
+
const linesRaw = ho.lines;
|
|
346
|
+
const lines = linesRaw === undefined || linesRaw === null
|
|
347
|
+
? []
|
|
348
|
+
: Array.isArray(linesRaw) && linesRaw.every((n) => typeof n === "number" && Number.isInteger(n))
|
|
349
|
+
? linesRaw
|
|
350
|
+
: (() => {
|
|
351
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'hits[${idx}].lines' must be an int array.`, 2);
|
|
352
|
+
})();
|
|
353
|
+
const snippetsRaw = ho.snippets;
|
|
354
|
+
const snippets = snippetsRaw === undefined || snippetsRaw === null
|
|
355
|
+
? []
|
|
356
|
+
: Array.isArray(snippetsRaw) && snippetsRaw.every((s) => typeof s === "string")
|
|
357
|
+
? snippetsRaw
|
|
358
|
+
: (() => {
|
|
359
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'hits[${idx}].snippets' must be a string array.`, 2);
|
|
360
|
+
})();
|
|
361
|
+
return { word, count, severity, category, categories, lines, snippets };
|
|
362
|
+
});
|
|
363
|
+
const topHitsRaw = obj.top_hits;
|
|
364
|
+
if (!Array.isArray(topHitsRaw))
|
|
365
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'top_hits' must be an array.`, 2);
|
|
366
|
+
const top_hits = topHitsRaw.map((h, idx) => {
|
|
367
|
+
if (!isPlainObject(h))
|
|
368
|
+
throw new NovelCliError(`Invalid cliche lint script output: 'top_hits[${idx}]' must be an object.`, 2);
|
|
369
|
+
const ho = h;
|
|
370
|
+
const word = requireString(ho.word, `top_hits[${idx}].word`);
|
|
371
|
+
const count = requireInt(ho.count, `top_hits[${idx}].count`);
|
|
372
|
+
const severity = requireSeverity(ho.severity, `cliche lint script output`, `top_hits[${idx}].severity`);
|
|
373
|
+
const catRaw = ho.category;
|
|
374
|
+
const category = catRaw === null ? null : typeof catRaw === "string" && catRaw.trim().length > 0 ? catRaw.trim() : null;
|
|
375
|
+
return { word, count, severity, category };
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
schema_version: 1,
|
|
379
|
+
generated_at: requireString(obj.generated_at, "generated_at"),
|
|
380
|
+
scope,
|
|
381
|
+
config: { schema_version: configSchemaVersion, last_updated: configLastUpdated },
|
|
382
|
+
mode: "script",
|
|
383
|
+
chars: requireInt(obj.chars, "chars"),
|
|
384
|
+
total_hits: requireInt(obj.total_hits, "total_hits"),
|
|
385
|
+
hits_per_kchars: requireNumber(obj.hits_per_kchars, "hits_per_kchars"),
|
|
386
|
+
by_severity,
|
|
387
|
+
by_category,
|
|
388
|
+
hits,
|
|
389
|
+
top_hits,
|
|
390
|
+
has_hard_hits: requireBool(obj.has_hard_hits, "has_hard_hits")
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async function tryRunDeterministicScript(args) {
|
|
394
|
+
const scriptRel = args.scriptRelPath.trim();
|
|
395
|
+
if (scriptRel.length === 0) {
|
|
396
|
+
return { status: "error", error: "Invalid lint_cliche script path: empty string." };
|
|
397
|
+
}
|
|
398
|
+
if (isAbsolute(scriptRel)) {
|
|
399
|
+
return { status: "error", error: "Invalid lint_cliche script path: must be project-relative (not absolute)." };
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
rejectPathTraversalInput(scriptRel, "platform-profile.json.compliance.script_paths.lint_cliche");
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
406
|
+
return { status: "error", error: message };
|
|
407
|
+
}
|
|
408
|
+
const scriptAbs = join(args.rootDir, scriptRel);
|
|
409
|
+
if (!(await pathExists(scriptAbs)))
|
|
410
|
+
return { status: "missing" };
|
|
411
|
+
try {
|
|
412
|
+
const { stdout } = await execFileAsync("bash", [scriptAbs, args.chapterAbsPath, args.configAbsPath], { maxBuffer: 10 * 1024 * 1024 });
|
|
413
|
+
const trimmed = stdout.trim();
|
|
414
|
+
const parsed = JSON.parse(trimmed);
|
|
415
|
+
const report = parseScriptReport(parsed);
|
|
416
|
+
return { status: "ok", report: { ...report, mode: "script", script: { rel_path: scriptRel } } };
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
420
|
+
const stderr = err.stderr;
|
|
421
|
+
const stderrText = typeof stderr === "string" && stderr.trim().length > 0 ? stderr.trim() : null;
|
|
422
|
+
const detail = stderrText ? ` stderr=${stderrText.slice(0, 200)}` : "";
|
|
423
|
+
return { status: "error", error: `${message}${detail}` };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function resolveClicheLintScriptRelPath(profile) {
|
|
427
|
+
const fromProfile = profile?.compliance.script_paths?.lint_cliche;
|
|
428
|
+
if (fromProfile && typeof fromProfile === "string" && fromProfile.trim().length > 0)
|
|
429
|
+
return fromProfile.trim();
|
|
430
|
+
return "scripts/lint-cliche.sh";
|
|
431
|
+
}
|
|
432
|
+
export async function computeClicheLintReport(args) {
|
|
433
|
+
const preferScript = args.preferDeterministicScript ?? true;
|
|
434
|
+
if (preferScript) {
|
|
435
|
+
const scriptRelPath = resolveClicheLintScriptRelPath(args.platformProfile);
|
|
436
|
+
const attempted = await tryRunDeterministicScript({
|
|
437
|
+
rootDir: args.rootDir,
|
|
438
|
+
chapterAbsPath: args.chapterAbsPath,
|
|
439
|
+
configAbsPath: join(args.rootDir, args.configRelPath),
|
|
440
|
+
scriptRelPath
|
|
441
|
+
});
|
|
442
|
+
if (attempted.status === "ok")
|
|
443
|
+
return attempted.report;
|
|
444
|
+
if (attempted.status === "error") {
|
|
445
|
+
const report = computeFallbackReport({ chapter: args.chapter, chapterText: args.chapterText, config: args.config });
|
|
446
|
+
const scriptRel = scriptRelPath.trim();
|
|
447
|
+
const scriptPrefix = scriptRel.length > 0 ? `${scriptRel}: ` : "";
|
|
448
|
+
return { ...report, script_error: `${scriptPrefix}${attempted.error}` };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return computeFallbackReport({ chapter: args.chapter, chapterText: args.chapterText, config: args.config });
|
|
452
|
+
}
|
|
453
|
+
export async function precomputeClicheLintReport(args) {
|
|
454
|
+
try {
|
|
455
|
+
const before = await fingerprintTextFile(args.chapterAbsPath);
|
|
456
|
+
const scriptRelPath = resolveClicheLintScriptRelPath(args.platformProfile);
|
|
457
|
+
const attempted = await tryRunDeterministicScript({
|
|
458
|
+
rootDir: args.rootDir,
|
|
459
|
+
chapterAbsPath: args.chapterAbsPath,
|
|
460
|
+
configAbsPath: join(args.rootDir, args.configRelPath),
|
|
461
|
+
scriptRelPath
|
|
462
|
+
});
|
|
463
|
+
const scriptRel = scriptRelPath.trim();
|
|
464
|
+
const scriptPrefix = scriptRel.length > 0 ? `${scriptRel}: ` : "";
|
|
465
|
+
const scriptError = attempted.status === "error" ? `${scriptPrefix}${attempted.error}` : undefined;
|
|
466
|
+
const report = attempted.status === "ok"
|
|
467
|
+
? attempted.report
|
|
468
|
+
: {
|
|
469
|
+
...computeFallbackReport({ chapter: args.chapter, chapterText: before.text, config: args.config }),
|
|
470
|
+
...(scriptError ? { script_error: scriptError } : {})
|
|
471
|
+
};
|
|
472
|
+
const afterFp = await fingerprintFile(args.chapterAbsPath);
|
|
473
|
+
if (!fingerprintsMatch(before.fingerprint, afterFp)) {
|
|
474
|
+
return {
|
|
475
|
+
status: "skipped",
|
|
476
|
+
error: "Chapter changed while running cliché lint; skipping precomputed result.",
|
|
477
|
+
chapter_fingerprint: null,
|
|
478
|
+
report: null
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const error = scriptError ? `Deterministic cliché lint script failed; used fallback. ${scriptError}` : undefined;
|
|
482
|
+
return { status: "pass", ...(error ? { error } : {}), chapter_fingerprint: afterFp, report };
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
486
|
+
return { status: "skipped", error: message, chapter_fingerprint: null, report: null };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
export async function writeClicheLintLogs(args) {
|
|
490
|
+
const dirRel = "logs/cliche-lint";
|
|
491
|
+
const dirAbs = join(args.rootDir, dirRel);
|
|
492
|
+
await ensureDir(dirAbs);
|
|
493
|
+
const historyRel = `${dirRel}/cliche-lint-chapter-${pad3(args.chapter)}.json`;
|
|
494
|
+
const latestRel = `${dirRel}/latest.json`;
|
|
495
|
+
await writeJsonFile(join(args.rootDir, historyRel), args.report);
|
|
496
|
+
await writeJsonFile(join(args.rootDir, latestRel), args.report);
|
|
497
|
+
return { latestRel, historyRel };
|
|
498
|
+
}
|
|
499
|
+
export async function attachClicheLintToEval(args) {
|
|
500
|
+
const raw = await readJsonFile(args.evalAbsPath);
|
|
501
|
+
if (!isPlainObject(raw))
|
|
502
|
+
throw new NovelCliError(`Invalid ${args.evalRelPath}: eval JSON must be an object.`, 2);
|
|
503
|
+
const obj = raw;
|
|
504
|
+
obj.cliche_lint = {
|
|
505
|
+
report_path: args.reportRelPath,
|
|
506
|
+
chars: args.report.chars,
|
|
507
|
+
total_hits: args.report.total_hits,
|
|
508
|
+
hits_per_kchars: args.report.hits_per_kchars,
|
|
509
|
+
by_severity: args.report.by_severity,
|
|
510
|
+
by_category: args.report.by_category,
|
|
511
|
+
top_hits: args.report.top_hits,
|
|
512
|
+
has_hard_hits: args.report.has_hard_hits
|
|
513
|
+
};
|
|
514
|
+
await writeJsonFile(args.evalAbsPath, obj);
|
|
515
|
+
}
|