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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/agents/chapter-writer.md +142 -0
  4. package/agents/character-weaver.md +117 -0
  5. package/agents/consistency-auditor.md +85 -0
  6. package/agents/plot-architect.md +128 -0
  7. package/agents/quality-judge.md +232 -0
  8. package/agents/style-analyzer.md +109 -0
  9. package/agents/style-refiner.md +97 -0
  10. package/agents/summarizer.md +128 -0
  11. package/agents/world-builder.md +161 -0
  12. package/dist/__tests__/character-voice.test.js +445 -0
  13. package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
  14. package/dist/__tests__/engagement.test.js +382 -0
  15. package/dist/__tests__/foreshadow-visibility.test.js +131 -0
  16. package/dist/__tests__/hook-ledger.test.js +1028 -0
  17. package/dist/__tests__/naming-lint.test.js +132 -0
  18. package/dist/__tests__/narrative-health-injection.test.js +359 -0
  19. package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
  20. package/dist/__tests__/next-step-title-fix.test.js +153 -0
  21. package/dist/__tests__/platform-profile.test.js +274 -0
  22. package/dist/__tests__/promise-ledger.test.js +189 -0
  23. package/dist/__tests__/readability-lint.test.js +209 -0
  24. package/dist/__tests__/text-utils.test.js +39 -0
  25. package/dist/__tests__/title-policy.test.js +147 -0
  26. package/dist/advance.js +75 -0
  27. package/dist/character-voice.js +805 -0
  28. package/dist/checkpoint.js +126 -0
  29. package/dist/cli.js +563 -0
  30. package/dist/cliche-lint.js +515 -0
  31. package/dist/commit.js +1460 -0
  32. package/dist/consistency-auditor.js +684 -0
  33. package/dist/engagement.js +687 -0
  34. package/dist/errors.js +7 -0
  35. package/dist/fingerprint.js +16 -0
  36. package/dist/foreshadow-visibility.js +214 -0
  37. package/dist/fs-utils.js +68 -0
  38. package/dist/hook-ledger.js +721 -0
  39. package/dist/hook-policy.js +107 -0
  40. package/dist/instruction-gates.js +51 -0
  41. package/dist/instructions.js +406 -0
  42. package/dist/latest-summary-loader.js +29 -0
  43. package/dist/lock.js +121 -0
  44. package/dist/naming-lint.js +531 -0
  45. package/dist/ner.js +73 -0
  46. package/dist/next-step.js +408 -0
  47. package/dist/novel-ask.js +270 -0
  48. package/dist/output.js +9 -0
  49. package/dist/platform-constraints.js +518 -0
  50. package/dist/platform-profile.js +325 -0
  51. package/dist/prejudge-guardrails.js +370 -0
  52. package/dist/project.js +40 -0
  53. package/dist/promise-ledger.js +723 -0
  54. package/dist/readability-lint.js +555 -0
  55. package/dist/safe-parse.js +36 -0
  56. package/dist/safe-path.js +29 -0
  57. package/dist/scoring-weights.js +290 -0
  58. package/dist/steps.js +60 -0
  59. package/dist/text-utils.js +18 -0
  60. package/dist/title-policy.js +251 -0
  61. package/dist/type-guards.js +6 -0
  62. package/dist/validate.js +131 -0
  63. package/docs/user/README.md +17 -0
  64. package/docs/user/guardrails.md +179 -0
  65. package/docs/user/interactive-gates.md +124 -0
  66. package/docs/user/novel-cli.md +289 -0
  67. package/docs/user/ops.md +123 -0
  68. package/docs/user/quick-start.md +97 -0
  69. package/docs/user/spec-system.md +166 -0
  70. package/docs/user/storylines.md +144 -0
  71. package/package.json +48 -0
  72. package/schemas/README.md +18 -0
  73. package/schemas/character-voice-drift.schema.json +135 -0
  74. package/schemas/character-voice-profiles.schema.json +141 -0
  75. package/schemas/engagement-metrics.schema.json +38 -0
  76. package/schemas/hook-ledger.schema.json +108 -0
  77. package/schemas/platform-profile.schema.json +235 -0
  78. package/schemas/promise-ledger.schema.json +97 -0
  79. package/scripts/calibrate-quality-judge.sh +91 -0
  80. package/scripts/compare-regression-runs.sh +86 -0
  81. package/scripts/lib/_common.py +131 -0
  82. package/scripts/lib/calibrate_quality_judge.py +312 -0
  83. package/scripts/lib/compare_regression_runs.py +142 -0
  84. package/scripts/lib/run_regression.py +621 -0
  85. package/scripts/lint-blacklist.sh +201 -0
  86. package/scripts/lint-cliche.sh +370 -0
  87. package/scripts/lint-readability.sh +404 -0
  88. package/scripts/query-foreshadow.sh +252 -0
  89. package/scripts/run-ner.sh +669 -0
  90. package/scripts/run-regression.sh +122 -0
  91. package/skills/cli-step/SKILL.md +158 -0
  92. package/skills/continue/SKILL.md +348 -0
  93. package/skills/continue/references/context-contracts.md +169 -0
  94. package/skills/continue/references/continuity-checks.md +187 -0
  95. package/skills/continue/references/file-protocols.md +64 -0
  96. package/skills/continue/references/foreshadowing.md +130 -0
  97. package/skills/continue/references/gate-decision.md +53 -0
  98. package/skills/continue/references/periodic-maintenance.md +46 -0
  99. package/skills/novel-writing/SKILL.md +77 -0
  100. package/skills/novel-writing/references/quality-rubric.md +140 -0
  101. package/skills/novel-writing/references/style-guide.md +145 -0
  102. package/skills/start/SKILL.md +458 -0
  103. package/skills/start/references/quality-review.md +86 -0
  104. package/skills/start/references/setting-update.md +44 -0
  105. package/skills/start/references/vol-planning.md +61 -0
  106. package/skills/start/references/vol-review.md +58 -0
  107. package/skills/status/SKILL.md +116 -0
  108. package/skills/status/references/sample-output.md +60 -0
  109. package/templates/ai-blacklist.json +79 -0
  110. package/templates/brief-template.md +46 -0
  111. package/templates/genre-weight-profiles.json +90 -0
  112. package/templates/novel-ask/example.answer.json +12 -0
  113. package/templates/novel-ask/example.question.json +51 -0
  114. package/templates/platform-profile.json +148 -0
  115. package/templates/style-profile-template.json +58 -0
  116. package/templates/web-novel-cliche-lint.json +41 -0
@@ -0,0 +1,555 @@
1
+ import { execFile } from "node:child_process";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { NovelCliError } from "./errors.js";
6
+ import { fingerprintFile, fingerprintTextFile, fingerprintsMatch } from "./fingerprint.js";
7
+ import { ensureDir, pathExists, readJsonFile, writeJsonFile } from "./fs-utils.js";
8
+ import { assertInsideProjectRoot, resolveProjectRelativePath } from "./safe-path.js";
9
+ import { pad3 } from "./steps.js";
10
+ import { isPlainObject } from "./type-guards.js";
11
+ const execFileAsync = promisify(execFile);
12
+ function countNonWhitespaceChars(text) {
13
+ return Array.from(text.replace(/\s+/gu, "")).length;
14
+ }
15
+ function isAtxHeadingLine(line) {
16
+ return /^(?:\uFEFF)? {0,3}#{1,6}(?!#)\s+.*$/u.test(line);
17
+ }
18
+ function stripCodeFences(text) {
19
+ // Best-effort removal of fenced code blocks to avoid counting them as prose paragraphs.
20
+ // This is a deterministic heuristic, not a Markdown parser.
21
+ return text.replace(/(^|\n)```[\s\S]*?\n```[ \t]*(?=\n|$)/gu, "\n");
22
+ }
23
+ function extractParagraphs(text) {
24
+ const cleaned = stripCodeFences(text).replace(/\r\n?/gu, "\n");
25
+ const lines = cleaned.split("\n");
26
+ const out = [];
27
+ let buf = [];
28
+ const flush = () => {
29
+ if (buf.length === 0)
30
+ return;
31
+ const raw = buf.join("\n").trimEnd();
32
+ buf = [];
33
+ if (raw.trim().length === 0)
34
+ return;
35
+ const firstLine = raw.split("\n").find((l) => l.trim().length > 0) ?? "";
36
+ const isHeading = isAtxHeadingLine(firstLine);
37
+ const chars = countNonWhitespaceChars(raw);
38
+ const hasDialogue = /["“”]/u.test(raw);
39
+ out.push({ index: out.length + 1, raw, chars, isHeading, hasDialogue });
40
+ };
41
+ for (const line of lines) {
42
+ if (line.trim().length === 0) {
43
+ flush();
44
+ continue;
45
+ }
46
+ buf.push(line);
47
+ }
48
+ flush();
49
+ return out;
50
+ }
51
+ function severityRank(sev) {
52
+ if (sev === "warn")
53
+ return 1;
54
+ if (sev === "soft")
55
+ return 2;
56
+ if (sev === "hard")
57
+ return 3;
58
+ return 0;
59
+ }
60
+ function isBlockingSeverity(blocking, severity) {
61
+ if (severity === "warn")
62
+ return false;
63
+ if (blocking === "hard_only")
64
+ return severity === "hard";
65
+ return severity === "soft" || severity === "hard";
66
+ }
67
+ function computeStatus(args) {
68
+ if (args.issues.length === 0)
69
+ return "pass";
70
+ return args.issues.some((i) => isBlockingSeverity(args.blocking, i.severity)) ? "violation" : "warn";
71
+ }
72
+ function computeStatusWarnOnly(issues) {
73
+ if (issues.length === 0)
74
+ return "pass";
75
+ return "warn";
76
+ }
77
+ function computeHasBlockingIssues(args) {
78
+ return args.issues.some((i) => isBlockingSeverity(args.blocking, i.severity));
79
+ }
80
+ function snippet(text, maxLen) {
81
+ const s = text.trim().replace(/\s+/gu, " ");
82
+ if (s.length <= maxLen)
83
+ return s;
84
+ return `${s.slice(0, Math.max(0, maxLen - 1))}…`;
85
+ }
86
+ function computeFallbackReport(args) {
87
+ const generated_at = new Date().toISOString();
88
+ const policy = args.platformProfile.readability?.mobile ?? null;
89
+ if (!policy || !policy.enabled) {
90
+ return {
91
+ schema_version: 1,
92
+ generated_at,
93
+ scope: { chapter: args.chapter },
94
+ policy: policy
95
+ ? {
96
+ enabled: policy.enabled,
97
+ max_paragraph_chars: policy.max_paragraph_chars,
98
+ max_consecutive_exposition_paragraphs: policy.max_consecutive_exposition_paragraphs,
99
+ blocking_severity: policy.blocking_severity
100
+ }
101
+ : null,
102
+ mode: "fallback",
103
+ ...(args.scriptRelPath ? { script: { rel_path: args.scriptRelPath } } : {}),
104
+ ...(args.scriptError ? { script_error: args.scriptError } : {}),
105
+ status: "skipped",
106
+ issues: [],
107
+ has_blocking_issues: false
108
+ };
109
+ }
110
+ const paragraphs = extractParagraphs(args.chapterText);
111
+ const issues = [];
112
+ // Chapter-level quote consistency.
113
+ const hasAsciiQuotes = args.chapterText.includes("\"");
114
+ const hasCurlyQuotes = /[“”]/u.test(args.chapterText);
115
+ if (hasAsciiQuotes && hasCurlyQuotes) {
116
+ issues.push({
117
+ id: "readability.mobile.mixed_quote_styles",
118
+ severity: "warn",
119
+ summary: "Mixed quote styles detected (ASCII '\"' and curly quotes “”).",
120
+ suggestion: "Use a single quote style consistently to improve mobile readability."
121
+ });
122
+ }
123
+ // Chapter-level ellipsis consistency.
124
+ const hasAsciiDots = args.chapterText.includes("...");
125
+ const hasCjkEllipsis = args.chapterText.includes("……");
126
+ if (hasAsciiDots && hasCjkEllipsis) {
127
+ issues.push({
128
+ id: "readability.mobile.mixed_ellipsis_styles",
129
+ severity: "warn",
130
+ summary: "Mixed ellipsis styles detected ('...' and '……').",
131
+ suggestion: "Use a single ellipsis style consistently."
132
+ });
133
+ }
134
+ // Chapter-level fullwidth punctuation consistency (best-effort).
135
+ const punctuationPairs = [
136
+ { ascii: ",", full: ",", id: "readability.mobile.mixed_comma_styles", summary: "Mixed comma styles detected (',' and ',')." },
137
+ { ascii: ".", full: "。", id: "readability.mobile.mixed_period_styles", summary: "Mixed period styles detected ('.' and '。')." },
138
+ { ascii: "?", full: "?", id: "readability.mobile.mixed_question_mark_styles", summary: "Mixed question mark styles detected ('?' and '?')." },
139
+ { ascii: "!", full: "!", id: "readability.mobile.mixed_exclamation_styles", summary: "Mixed exclamation mark styles detected ('!' and '!')." }
140
+ ];
141
+ for (const pair of punctuationPairs) {
142
+ if (!args.chapterText.includes(pair.ascii))
143
+ continue;
144
+ if (!args.chapterText.includes(pair.full))
145
+ continue;
146
+ issues.push({
147
+ id: pair.id,
148
+ severity: "warn",
149
+ summary: pair.summary,
150
+ suggestion: "Use a single punctuation width style consistently (prefer fullwidth for Chinese prose)."
151
+ });
152
+ }
153
+ // Per-paragraph checks.
154
+ for (const p of paragraphs) {
155
+ if (p.isHeading)
156
+ continue;
157
+ if (p.chars > policy.max_paragraph_chars) {
158
+ issues.push({
159
+ id: "readability.mobile.overlong_paragraph",
160
+ severity: "warn",
161
+ summary: `Overlong paragraph (${p.chars} chars > max ${policy.max_paragraph_chars}).`,
162
+ evidence: snippet(p.raw, 140),
163
+ suggestion: "Split the paragraph into 2–3 shorter paragraphs around actions/dialogue beats.",
164
+ paragraph_index: p.index,
165
+ paragraph_chars: p.chars
166
+ });
167
+ }
168
+ // Dialogue dense paragraph: many quotes packed into a single paragraph.
169
+ if (p.hasDialogue) {
170
+ const quoteCount = (p.raw.match(/["“”]/gu) ?? []).length;
171
+ const isSingleLine = !p.raw.includes("\n");
172
+ if (isSingleLine && quoteCount >= 6) {
173
+ issues.push({
174
+ id: "readability.mobile.dialogue_dense_paragraph",
175
+ severity: "warn",
176
+ summary: "Dialogue-heavy paragraph may hurt mobile readability (many quotes in one paragraph).",
177
+ evidence: snippet(p.raw, 140),
178
+ suggestion: "Split dialogue into separate paragraphs per speaker and keep each paragraph short.",
179
+ paragraph_index: p.index,
180
+ paragraph_chars: p.chars
181
+ });
182
+ }
183
+ }
184
+ }
185
+ // Consecutive exposition blocks: heuristic = consecutive non-heading paragraphs with no dialogue quotes.
186
+ let runStart = 0;
187
+ let runLen = 0;
188
+ const flushRun = () => {
189
+ if (runLen <= policy.max_consecutive_exposition_paragraphs) {
190
+ runStart = 0;
191
+ runLen = 0;
192
+ return;
193
+ }
194
+ const startIdx = runStart;
195
+ const endIdx = runStart + runLen - 1;
196
+ issues.push({
197
+ id: "readability.mobile.exposition_run_too_long",
198
+ severity: "warn",
199
+ summary: `Too many consecutive exposition paragraphs (${runLen} > max ${policy.max_consecutive_exposition_paragraphs}).`,
200
+ evidence: `paragraphs ${startIdx}-${endIdx}`,
201
+ suggestion: "Break up exposition with dialogue/action beats, and add whitespace for mobile scanning."
202
+ });
203
+ runStart = 0;
204
+ runLen = 0;
205
+ };
206
+ for (const p of paragraphs) {
207
+ if (p.isHeading) {
208
+ flushRun();
209
+ continue;
210
+ }
211
+ const isExposition = !p.hasDialogue;
212
+ if (!isExposition) {
213
+ flushRun();
214
+ continue;
215
+ }
216
+ if (runLen === 0)
217
+ runStart = p.index;
218
+ runLen += 1;
219
+ }
220
+ flushRun();
221
+ // Fallback is warn-only and must not block.
222
+ return {
223
+ schema_version: 1,
224
+ generated_at,
225
+ scope: { chapter: args.chapter },
226
+ policy: {
227
+ enabled: policy.enabled,
228
+ max_paragraph_chars: policy.max_paragraph_chars,
229
+ max_consecutive_exposition_paragraphs: policy.max_consecutive_exposition_paragraphs,
230
+ blocking_severity: policy.blocking_severity
231
+ },
232
+ mode: "fallback",
233
+ ...(args.scriptRelPath ? { script: { rel_path: args.scriptRelPath } } : {}),
234
+ ...(args.scriptError ? { script_error: args.scriptError } : {}),
235
+ status: computeStatusWarnOnly(issues),
236
+ issues,
237
+ has_blocking_issues: false
238
+ };
239
+ }
240
+ function parseSeverity(value, ctx) {
241
+ if (value === "warn" || value === "soft" || value === "hard")
242
+ return value;
243
+ throw new NovelCliError(`Invalid readability lint output: ${ctx} must be one of: warn, soft, hard.`, 2);
244
+ }
245
+ function parseBlockingSeverity(value) {
246
+ if (value === "hard_only" || value === "soft_and_hard")
247
+ return value;
248
+ throw new NovelCliError(`Invalid readability lint output: policy.blocking_severity must be 'hard_only' or 'soft_and_hard'.`, 2);
249
+ }
250
+ function requireInt(value, ctx) {
251
+ if (typeof value !== "number" || !Number.isInteger(value)) {
252
+ throw new NovelCliError(`Invalid readability lint output: ${ctx} must be an int.`, 2);
253
+ }
254
+ return value;
255
+ }
256
+ function requireBool(value, ctx) {
257
+ if (typeof value !== "boolean") {
258
+ throw new NovelCliError(`Invalid readability lint output: ${ctx} must be a boolean.`, 2);
259
+ }
260
+ return value;
261
+ }
262
+ function requireNonEmptyString(value, ctx) {
263
+ if (typeof value !== "string" || value.trim().length === 0) {
264
+ throw new NovelCliError(`Invalid readability lint output: ${ctx} must be a non-empty string.`, 2);
265
+ }
266
+ return value.trim();
267
+ }
268
+ function parseScriptReport(raw) {
269
+ if (!isPlainObject(raw))
270
+ throw new NovelCliError("Invalid readability lint output: expected JSON object.", 2);
271
+ const obj = raw;
272
+ const schema = obj.schema_version;
273
+ if (schema !== 1)
274
+ throw new NovelCliError(`Invalid readability lint output: schema_version must be 1.`, 2);
275
+ const generated_at = typeof obj.generated_at === "string" ? obj.generated_at : null;
276
+ if (!generated_at)
277
+ throw new NovelCliError("Invalid readability lint output: generated_at must be a string.", 2);
278
+ const scopeRaw = obj.scope;
279
+ if (!isPlainObject(scopeRaw))
280
+ throw new NovelCliError("Invalid readability lint output: scope must be an object.", 2);
281
+ const scopeObj = scopeRaw;
282
+ const chapter = requireInt(scopeObj.chapter, "scope.chapter");
283
+ if (chapter <= 0)
284
+ throw new NovelCliError("Invalid readability lint output: scope.chapter must be an int >= 1.", 2);
285
+ const policyRaw = obj.policy;
286
+ if (!isPlainObject(policyRaw))
287
+ throw new NovelCliError("Invalid readability lint output: policy must be an object.", 2);
288
+ const policyObj = policyRaw;
289
+ const enabled = requireBool(policyObj.enabled, "policy.enabled");
290
+ const maxParagraph = requireInt(policyObj.max_paragraph_chars, "policy.max_paragraph_chars");
291
+ if (maxParagraph < 1)
292
+ throw new NovelCliError("Invalid readability lint output: policy.max_paragraph_chars must be an int >= 1.", 2);
293
+ const maxExpo = requireInt(policyObj.max_consecutive_exposition_paragraphs, "policy.max_consecutive_exposition_paragraphs");
294
+ if (maxExpo < 1) {
295
+ throw new NovelCliError("Invalid readability lint output: policy.max_consecutive_exposition_paragraphs must be an int >= 1.", 2);
296
+ }
297
+ const blocking = parseBlockingSeverity(policyObj.blocking_severity);
298
+ const issuesRaw = obj.issues;
299
+ if (!Array.isArray(issuesRaw))
300
+ throw new NovelCliError("Invalid readability lint output: issues must be an array.", 2);
301
+ const issues = [];
302
+ for (const [idx, it] of issuesRaw.entries()) {
303
+ if (!isPlainObject(it))
304
+ throw new NovelCliError(`Invalid readability lint output: issues[${idx}] must be an object.`, 2);
305
+ const rec = it;
306
+ const id = requireNonEmptyString(rec.id, `issues[${idx}].id`);
307
+ const severity = parseSeverity(rec.severity, `issues[${idx}].severity`);
308
+ const summary = requireNonEmptyString(rec.summary, `issues[${idx}].summary`);
309
+ const issue = { id, severity, summary };
310
+ if (rec.evidence !== undefined && rec.evidence !== null) {
311
+ const evidence = requireNonEmptyString(rec.evidence, `issues[${idx}].evidence`);
312
+ issue.evidence = evidence;
313
+ }
314
+ if (rec.suggestion !== undefined && rec.suggestion !== null) {
315
+ const suggestion = requireNonEmptyString(rec.suggestion, `issues[${idx}].suggestion`);
316
+ issue.suggestion = suggestion;
317
+ }
318
+ if (rec.paragraph_index !== undefined && rec.paragraph_index !== null) {
319
+ const paragraph_index = requireInt(rec.paragraph_index, `issues[${idx}].paragraph_index`);
320
+ if (paragraph_index <= 0)
321
+ throw new NovelCliError(`Invalid readability lint output: issues[${idx}].paragraph_index must be >= 1.`, 2);
322
+ issue.paragraph_index = paragraph_index;
323
+ }
324
+ if (rec.paragraph_chars !== undefined && rec.paragraph_chars !== null) {
325
+ const paragraph_chars = requireInt(rec.paragraph_chars, `issues[${idx}].paragraph_chars`);
326
+ if (paragraph_chars < 0)
327
+ throw new NovelCliError(`Invalid readability lint output: issues[${idx}].paragraph_chars must be >= 0.`, 2);
328
+ issue.paragraph_chars = paragraph_chars;
329
+ }
330
+ issues.push(issue);
331
+ }
332
+ const has_blocking_issues = computeHasBlockingIssues({ blocking, issues });
333
+ const status = computeStatus({ blocking, issues });
334
+ return {
335
+ schema_version: 1,
336
+ generated_at,
337
+ scope: { chapter },
338
+ policy: {
339
+ enabled,
340
+ max_paragraph_chars: maxParagraph,
341
+ max_consecutive_exposition_paragraphs: maxExpo,
342
+ blocking_severity: blocking
343
+ },
344
+ mode: "script",
345
+ status,
346
+ issues,
347
+ has_blocking_issues
348
+ };
349
+ }
350
+ async function tryRunDeterministicScript(args) {
351
+ const scriptRel = args.scriptRelPath.trim();
352
+ if (scriptRel.length === 0)
353
+ return { status: "error", error: "Invalid lint_readability script path: empty string." };
354
+ const label = "platform-profile.json.compliance.script_paths.lint_readability";
355
+ let scriptAbs;
356
+ try {
357
+ scriptAbs = resolveProjectRelativePath(args.rootDir, scriptRel, label);
358
+ }
359
+ catch (err) {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ return { status: "error", error: message };
362
+ }
363
+ if (!(await pathExists(scriptAbs)))
364
+ return { status: "missing" };
365
+ // Resolve symlinks to prevent unexpected execution outside project root.
366
+ let execAbs = scriptAbs;
367
+ try {
368
+ const rootReal = await realpath(args.rootDir);
369
+ execAbs = await realpath(scriptAbs);
370
+ assertInsideProjectRoot(rootReal, execAbs);
371
+ const st = await stat(execAbs);
372
+ if (!st.isFile()) {
373
+ return { status: "error", error: `Invalid lint_readability script path: must point to a file. (${label})` };
374
+ }
375
+ }
376
+ catch (err) {
377
+ const message = err instanceof Error ? err.message : String(err);
378
+ return { status: "error", error: message };
379
+ }
380
+ try {
381
+ const { stdout } = await execFileAsync("bash", [execAbs, args.chapterAbsPath, args.platformProfileAbsPath, String(args.chapter)], {
382
+ maxBuffer: 10 * 1024 * 1024,
383
+ timeout: 10_000,
384
+ killSignal: "SIGKILL"
385
+ });
386
+ const trimmed = stdout.trim();
387
+ const parsed = JSON.parse(trimmed);
388
+ const report = parseScriptReport(parsed);
389
+ if (report.scope.chapter !== args.chapter) {
390
+ return { status: "error", error: `Invalid readability lint output: scope.chapter=${report.scope.chapter}, expected ${args.chapter}.` };
391
+ }
392
+ return { status: "ok", report: { ...report, mode: "script", script: { rel_path: scriptRel } } };
393
+ }
394
+ catch (err) {
395
+ const message = err instanceof Error ? err.message : String(err);
396
+ const stderr = err.stderr;
397
+ const stderrText = typeof stderr === "string" && stderr.trim().length > 0 ? stderr.trim() : null;
398
+ const detail = stderrText ? ` stderr=${stderrText.slice(0, 200)}` : "";
399
+ return { status: "error", error: `${message}${detail}` };
400
+ }
401
+ }
402
+ function resolveReadabilityLintScriptRelPath(profile) {
403
+ const fromProfile = profile.compliance.script_paths?.lint_readability;
404
+ if (typeof fromProfile === "string" && fromProfile.trim().length > 0)
405
+ return fromProfile.trim();
406
+ return "scripts/lint-readability.sh";
407
+ }
408
+ export async function computeReadabilityReport(args) {
409
+ const policy = args.platformProfile.readability?.mobile ?? null;
410
+ const generated_at = new Date().toISOString();
411
+ if (!policy || !policy.enabled) {
412
+ return {
413
+ schema_version: 1,
414
+ generated_at,
415
+ scope: { chapter: args.chapter },
416
+ policy: policy
417
+ ? {
418
+ enabled: policy.enabled,
419
+ max_paragraph_chars: policy.max_paragraph_chars,
420
+ max_consecutive_exposition_paragraphs: policy.max_consecutive_exposition_paragraphs,
421
+ blocking_severity: policy.blocking_severity
422
+ }
423
+ : null,
424
+ mode: "fallback",
425
+ status: "skipped",
426
+ issues: [],
427
+ has_blocking_issues: false
428
+ };
429
+ }
430
+ const preferScript = args.preferDeterministicScript ?? true;
431
+ const scriptRelPath = resolveReadabilityLintScriptRelPath(args.platformProfile);
432
+ if (preferScript) {
433
+ const attempted = await tryRunDeterministicScript({
434
+ rootDir: args.rootDir,
435
+ chapterAbsPath: args.chapterAbsPath,
436
+ platformProfileAbsPath: join(args.rootDir, "platform-profile.json"),
437
+ scriptRelPath,
438
+ chapter: args.chapter
439
+ });
440
+ if (attempted.status === "ok") {
441
+ const expectedPolicy = {
442
+ enabled: policy.enabled,
443
+ max_paragraph_chars: policy.max_paragraph_chars,
444
+ max_consecutive_exposition_paragraphs: policy.max_consecutive_exposition_paragraphs,
445
+ blocking_severity: policy.blocking_severity
446
+ };
447
+ const issues = attempted.report.issues;
448
+ const blocking = policy.blocking_severity;
449
+ return {
450
+ ...attempted.report,
451
+ policy: expectedPolicy,
452
+ status: computeStatus({ blocking, issues }),
453
+ has_blocking_issues: computeHasBlockingIssues({ blocking, issues })
454
+ };
455
+ }
456
+ if (attempted.status === "error") {
457
+ const scriptPrefix = scriptRelPath.trim().length > 0 ? `${scriptRelPath.trim()}: ` : "";
458
+ return computeFallbackReport({
459
+ chapter: args.chapter,
460
+ chapterText: args.chapterText,
461
+ platformProfile: args.platformProfile,
462
+ scriptError: `${scriptPrefix}${attempted.error}`,
463
+ scriptRelPath: scriptRelPath.trim()
464
+ });
465
+ }
466
+ if (attempted.status === "missing") {
467
+ const rel = scriptRelPath.trim();
468
+ return computeFallbackReport({
469
+ chapter: args.chapter,
470
+ chapterText: args.chapterText,
471
+ platformProfile: args.platformProfile,
472
+ ...(rel.length > 0 ? { scriptError: `${rel}: missing`, scriptRelPath: rel } : {})
473
+ });
474
+ }
475
+ }
476
+ // Missing script or preferDeterministicScript disabled: warn-only fallback (non-blocking).
477
+ return computeFallbackReport({
478
+ chapter: args.chapter,
479
+ chapterText: args.chapterText,
480
+ platformProfile: args.platformProfile,
481
+ ...(preferScript ? { scriptRelPath: scriptRelPath.trim() } : {})
482
+ });
483
+ }
484
+ export function summarizeReadabilityIssues(issues, limit) {
485
+ const ordered = issues
486
+ .slice()
487
+ .sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.id.localeCompare(b.id, "en"));
488
+ return ordered
489
+ .slice(0, limit)
490
+ .map((i) => i.summary)
491
+ .join(" | ");
492
+ }
493
+ export async function writeReadabilityLogs(args) {
494
+ const dirRel = "logs/readability";
495
+ const dirAbs = join(args.rootDir, dirRel);
496
+ await ensureDir(dirAbs);
497
+ const historyRel = `${dirRel}/readability-report-chapter-${pad3(args.chapter)}.json`;
498
+ const latestRel = `${dirRel}/latest.json`;
499
+ await writeJsonFile(join(args.rootDir, historyRel), args.report);
500
+ await writeJsonFile(join(args.rootDir, latestRel), args.report);
501
+ return { latestRel, historyRel };
502
+ }
503
+ export async function precomputeReadabilityReport(args) {
504
+ try {
505
+ const before = await fingerprintTextFile(args.chapterAbsPath);
506
+ const report = await computeReadabilityReport({
507
+ rootDir: args.rootDir,
508
+ chapter: args.chapter,
509
+ chapterAbsPath: args.chapterAbsPath,
510
+ chapterText: before.text,
511
+ platformProfile: args.platformProfile,
512
+ preferDeterministicScript: true
513
+ });
514
+ const afterFp = await fingerprintFile(args.chapterAbsPath);
515
+ if (!fingerprintsMatch(before.fingerprint, afterFp)) {
516
+ return {
517
+ status: "skipped",
518
+ error: "Chapter changed while running readability lint; skipping precomputed result.",
519
+ chapter_fingerprint: null,
520
+ report: null
521
+ };
522
+ }
523
+ const error = report.script_error ? `Deterministic readability lint script failed; used fallback. ${report.script_error}` : undefined;
524
+ return { status: "pass", ...(error ? { error } : {}), chapter_fingerprint: afterFp, report };
525
+ }
526
+ catch (err) {
527
+ const message = err instanceof Error ? err.message : String(err);
528
+ return { status: "skipped", error: message, chapter_fingerprint: null, report: null };
529
+ }
530
+ }
531
+ export async function attachReadabilityLintToEval(args) {
532
+ const raw = await readJsonFile(args.evalAbsPath);
533
+ if (!isPlainObject(raw))
534
+ throw new NovelCliError(`Invalid ${args.evalRelPath}: eval JSON must be an object.`, 2);
535
+ const obj = raw;
536
+ const bySeverity = { warn: 0, soft: 0, hard: 0 };
537
+ for (const issue of args.report.issues) {
538
+ if (issue.severity === "warn")
539
+ bySeverity.warn += 1;
540
+ else if (issue.severity === "soft")
541
+ bySeverity.soft += 1;
542
+ else if (issue.severity === "hard")
543
+ bySeverity.hard += 1;
544
+ }
545
+ obj.readability_lint = {
546
+ report_path: args.reportRelPath,
547
+ status: args.report.status,
548
+ mode: args.report.mode,
549
+ ...(args.report.policy ? { blocking_severity: args.report.policy.blocking_severity } : { blocking_severity: null }),
550
+ issues_total: args.report.issues.length,
551
+ issues_by_severity: bySeverity,
552
+ has_blocking_issues: args.report.has_blocking_issues
553
+ };
554
+ await writeJsonFile(args.evalAbsPath, obj);
555
+ }
@@ -0,0 +1,36 @@
1
+ import { truncateWithEllipsis } from "./text-utils.js";
2
+ import { isPlainObject } from "./type-guards.js";
3
+ export function safePositiveIntOrNull(v) {
4
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : null;
5
+ }
6
+ export function safeNonNegativeIntOrNull(v) {
7
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : null;
8
+ }
9
+ export function safeNonNegativeFiniteOrNull(v) {
10
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
11
+ }
12
+ export function safeStringOrNull(v, maxLen) {
13
+ if (typeof v !== "string")
14
+ return null;
15
+ const t = v.trim();
16
+ if (t.length === 0)
17
+ return null;
18
+ return truncateWithEllipsis(t, maxLen);
19
+ }
20
+ export function parseSummaryIssues(issuesRaw, maxCount = 5) {
21
+ const issues = [];
22
+ for (const it of issuesRaw) {
23
+ if (!isPlainObject(it))
24
+ continue;
25
+ const issue = it;
26
+ issues.push({
27
+ id: safeStringOrNull(issue.id, 240),
28
+ severity: safeStringOrNull(issue.severity, 32),
29
+ summary: safeStringOrNull(issue.summary, 240),
30
+ suggestion: safeStringOrNull(issue.suggestion, 200)
31
+ });
32
+ if (issues.length >= maxCount)
33
+ break;
34
+ }
35
+ return issues;
36
+ }
@@ -0,0 +1,29 @@
1
+ import { isAbsolute, join, sep } from "node:path";
2
+ import { NovelCliError } from "./errors.js";
3
+ export function rejectPathTraversalInput(inputPath, label) {
4
+ const normalized = inputPath.replaceAll("\\", "/");
5
+ const parts = normalized.split("/").filter(Boolean);
6
+ if (parts.includes("..")) {
7
+ throw new NovelCliError(`${label} must not contain '..' path traversal segments.`, 2);
8
+ }
9
+ }
10
+ export function assertInsideProjectRoot(projectRootAbs, absolutePath) {
11
+ const root = projectRootAbs.endsWith(sep) ? projectRootAbs : `${projectRootAbs}${sep}`;
12
+ if (absolutePath === projectRootAbs)
13
+ return;
14
+ if (!absolutePath.startsWith(root)) {
15
+ throw new NovelCliError(`Unsafe path outside project root: ${absolutePath}`, 2);
16
+ }
17
+ }
18
+ export function resolveProjectRelativePath(projectRootAbs, relPath, label) {
19
+ if (typeof relPath !== "string" || relPath.trim().length === 0) {
20
+ throw new NovelCliError(`Invalid ${label}: must be a non-empty string.`, 2);
21
+ }
22
+ if (isAbsolute(relPath)) {
23
+ throw new NovelCliError(`Invalid ${label}: must be a project-relative path.`, 2);
24
+ }
25
+ rejectPathTraversalInput(relPath, label);
26
+ const abs = join(projectRootAbs, relPath);
27
+ assertInsideProjectRoot(projectRootAbs, abs);
28
+ return abs;
29
+ }