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
package/dist/lock.js ADDED
@@ -0,0 +1,121 @@
1
+ import { mkdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { NovelCliError } from "./errors.js";
4
+ import { pathExists, readJsonFile, removePath, writeJsonFile } from "./fs-utils.js";
5
+ const STALE_LOCK_MINUTES = 30;
6
+ const STALE_LOCK_MS = STALE_LOCK_MINUTES * 60 * 1000;
7
+ function parseLockInfo(raw) {
8
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
9
+ return {};
10
+ const obj = raw;
11
+ const pid = typeof obj.pid === "number" && Number.isInteger(obj.pid) ? obj.pid : undefined;
12
+ const started = typeof obj.started === "string" ? obj.started : undefined;
13
+ const chapter = typeof obj.chapter === "number" && Number.isInteger(obj.chapter) ? obj.chapter : undefined;
14
+ return { pid, started, chapter };
15
+ }
16
+ function isStale(args) {
17
+ const startedMs = args.startedIso ? Date.parse(args.startedIso) : Number.NaN;
18
+ const baseMs = Number.isFinite(startedMs) ? startedMs : args.fallbackStartedMs;
19
+ if (baseMs === null)
20
+ return false;
21
+ const ageMs = Date.now() - baseMs;
22
+ return ageMs > STALE_LOCK_MS;
23
+ }
24
+ export async function getLockStatus(projectRootDir) {
25
+ const lockDir = join(projectRootDir, ".novel.lock");
26
+ const infoPath = join(lockDir, "info.json");
27
+ const exists = await pathExists(lockDir);
28
+ if (!exists) {
29
+ return { exists: false, stale: false, lockDir, infoPath };
30
+ }
31
+ let fallbackStartedMs = null;
32
+ try {
33
+ const s = await stat(lockDir);
34
+ if (s.isDirectory())
35
+ fallbackStartedMs = s.mtimeMs;
36
+ }
37
+ catch {
38
+ fallbackStartedMs = null;
39
+ }
40
+ let info;
41
+ if (await pathExists(infoPath)) {
42
+ try {
43
+ info = parseLockInfo(await readJsonFile(infoPath));
44
+ }
45
+ catch {
46
+ info = {};
47
+ }
48
+ }
49
+ return {
50
+ exists: true,
51
+ stale: isStale({ startedIso: info?.started, fallbackStartedMs }),
52
+ lockDir,
53
+ infoPath,
54
+ info
55
+ };
56
+ }
57
+ export async function clearStaleLock(projectRootDir) {
58
+ const status = await getLockStatus(projectRootDir);
59
+ if (!status.exists)
60
+ return false;
61
+ if (!(await isDirectory(status.lockDir))) {
62
+ throw new NovelCliError(`Lock path exists but is not a directory: ${status.lockDir}`, 2);
63
+ }
64
+ if (!status.stale) {
65
+ throw new NovelCliError(`Lock is active; refusing to clear. Use after ${STALE_LOCK_MINUTES} minutes or stop the other session.`, 2);
66
+ }
67
+ await removePath(status.lockDir);
68
+ return true;
69
+ }
70
+ async function isDirectory(path) {
71
+ try {
72
+ const s = await stat(path);
73
+ return s.isDirectory();
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ async function acquireLockDir(projectRootDir, lockDir) {
80
+ for (let attempt = 0; attempt < 3; attempt++) {
81
+ try {
82
+ await mkdir(lockDir);
83
+ return;
84
+ }
85
+ catch (err) {
86
+ const code = err.code;
87
+ if (code !== "EEXIST") {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ throw new NovelCliError(`Failed to acquire lock: ${message}`, 2);
90
+ }
91
+ if (!(await isDirectory(lockDir))) {
92
+ throw new NovelCliError(`Lock path exists but is not a directory: ${lockDir}`, 2);
93
+ }
94
+ const status = await getLockStatus(projectRootDir);
95
+ if (!status.stale) {
96
+ throw new NovelCliError(`Another session holds the lock (started=${status.info?.started ?? "unknown"} pid=${status.info?.pid ?? "unknown"}).`, 2);
97
+ }
98
+ await removePath(lockDir);
99
+ continue;
100
+ }
101
+ }
102
+ throw new NovelCliError(`Failed to acquire lock after clearing stale lock; another session likely acquired it.`, 2);
103
+ }
104
+ export async function withWriteLock(projectRootDir, meta = {}, fn) {
105
+ const lockDir = join(projectRootDir, ".novel.lock");
106
+ const infoPath = join(lockDir, "info.json");
107
+ // Try to acquire; if exists, only proceed if stale.
108
+ await acquireLockDir(projectRootDir, lockDir);
109
+ // Best-effort metadata write.
110
+ await writeJsonFile(infoPath, {
111
+ pid: process.pid,
112
+ started: new Date().toISOString(),
113
+ chapter: meta.chapter ?? null
114
+ });
115
+ try {
116
+ return await fn();
117
+ }
118
+ finally {
119
+ await removePath(lockDir);
120
+ }
121
+ }
@@ -0,0 +1,531 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { NovelCliError } from "./errors.js";
4
+ import { fingerprintFile, fingerprintTextFile, fingerprintsMatch } from "./fingerprint.js";
5
+ import { ensureDir, pathExists, readJsonFile, writeJsonFile } from "./fs-utils.js";
6
+ import { pad3 } from "./steps.js";
7
+ import { isPlainObject } from "./type-guards.js";
8
+ function severityRank(sev) {
9
+ if (sev === "warn")
10
+ return 1;
11
+ if (sev === "soft")
12
+ return 2;
13
+ if (sev === "hard")
14
+ return 3;
15
+ return 0;
16
+ }
17
+ function normalizeNameKey(name) {
18
+ return name.trim().replace(/\s+/gu, "").toLowerCase();
19
+ }
20
+ function makePairKey(a, b) {
21
+ return [a, b].sort((x, y) => x.localeCompare(y, "en")).join("||");
22
+ }
23
+ function parseExemptions(raw) {
24
+ const ignore_names = new Set();
25
+ const allow_pairs = new Set();
26
+ if (!isPlainObject(raw))
27
+ return { ignore_names, allow_pairs };
28
+ const obj = raw;
29
+ const ignored = obj.ignore_names;
30
+ if (Array.isArray(ignored)) {
31
+ for (const it of ignored) {
32
+ if (typeof it !== "string")
33
+ continue;
34
+ const key = normalizeNameKey(it);
35
+ if (key.length > 0)
36
+ ignore_names.add(key);
37
+ }
38
+ }
39
+ const pairs = obj.allow_pairs;
40
+ if (Array.isArray(pairs)) {
41
+ for (const it of pairs) {
42
+ if (!Array.isArray(it) || it.length !== 2)
43
+ continue;
44
+ const a = typeof it[0] === "string" ? normalizeNameKey(it[0]) : "";
45
+ const b = typeof it[1] === "string" ? normalizeNameKey(it[1]) : "";
46
+ if (a.length === 0 || b.length === 0)
47
+ continue;
48
+ allow_pairs.add(makePairKey(a, b));
49
+ }
50
+ }
51
+ return { ignore_names, allow_pairs };
52
+ }
53
+ function levenshteinDistance(a, b) {
54
+ if (a === b)
55
+ return 0;
56
+ const aa = Array.from(a);
57
+ const bb = Array.from(b);
58
+ const n = aa.length;
59
+ const m = bb.length;
60
+ if (n === 0)
61
+ return m;
62
+ if (m === 0)
63
+ return n;
64
+ let prev = new Array(m + 1);
65
+ for (let j = 0; j <= m; j += 1)
66
+ prev[j] = j;
67
+ for (let i = 1; i <= n; i += 1) {
68
+ const cur = new Array(m + 1);
69
+ cur[0] = i;
70
+ const ai = aa[i - 1];
71
+ for (let j = 1; j <= m; j += 1) {
72
+ const cost = ai === bb[j - 1] ? 0 : 1;
73
+ const del = prev[j] + 1;
74
+ const ins = cur[j - 1] + 1;
75
+ const sub = prev[j - 1] + cost;
76
+ cur[j] = Math.min(del, ins, sub);
77
+ }
78
+ prev = cur;
79
+ }
80
+ return prev[m];
81
+ }
82
+ function round3(n) {
83
+ return Math.round(n * 1000) / 1000;
84
+ }
85
+ function computeSimilarity(a, b) {
86
+ const left = normalizeNameKey(a);
87
+ const right = normalizeNameKey(b);
88
+ if (left.length === 0 && right.length === 0)
89
+ return 1;
90
+ if (left.length === 0 || right.length === 0)
91
+ return 0;
92
+ const maxLen = Math.max(Array.from(left).length, Array.from(right).length);
93
+ if (maxLen === 0)
94
+ return 1;
95
+ const dist = levenshteinDistance(left, right);
96
+ const ratio = dist / maxLen;
97
+ const sim = maxLen <= 4 ? 1 - Math.pow(ratio, 4) : 1 - ratio;
98
+ if (sim <= 0)
99
+ return 0;
100
+ if (sim >= 1)
101
+ return 1;
102
+ return round3(sim);
103
+ }
104
+ function collectBlockingTypes(profile) {
105
+ const types = profile.naming?.blocking_conflict_types ?? [];
106
+ return new Set(types);
107
+ }
108
+ function severityForConflict(args) {
109
+ return args.blockingTypes.has(args.conflict_type) ? "hard" : "soft";
110
+ }
111
+ async function deriveNameRegistry(rootDir) {
112
+ const dirRel = "characters/active";
113
+ const dirAbs = join(rootDir, dirRel);
114
+ if (!(await pathExists(dirAbs)))
115
+ return { entries: [], issues: [] };
116
+ const entries = [];
117
+ const issues = [];
118
+ let dirents;
119
+ try {
120
+ dirents = await readdir(dirAbs, { withFileTypes: true });
121
+ }
122
+ catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ issues.push({
125
+ id: "naming.registry.read_failed",
126
+ severity: "warn",
127
+ summary: `Failed to list ${dirRel}.`,
128
+ evidence: message,
129
+ suggestion: "Ensure characters/active/ exists and is readable."
130
+ });
131
+ return { entries: [], issues };
132
+ }
133
+ const files = dirents
134
+ .filter((d) => d.isFile() && d.name.endsWith(".json"))
135
+ .map((d) => d.name)
136
+ .sort((a, b) => a.localeCompare(b, "en"));
137
+ for (const filename of files) {
138
+ const slug_id = filename.replace(/\.json$/u, "");
139
+ const rel_path = `${dirRel}/${filename}`;
140
+ let raw;
141
+ try {
142
+ raw = await readJsonFile(join(dirAbs, filename));
143
+ }
144
+ catch (err) {
145
+ const message = err instanceof Error ? err.message : String(err);
146
+ issues.push({
147
+ id: "naming.registry.invalid_profile",
148
+ severity: "warn",
149
+ summary: `Invalid character profile: ${rel_path}.`,
150
+ evidence: message
151
+ });
152
+ continue;
153
+ }
154
+ if (!isPlainObject(raw)) {
155
+ issues.push({
156
+ id: "naming.registry.invalid_profile",
157
+ severity: "warn",
158
+ summary: `Invalid character profile: ${rel_path} must be a JSON object.`,
159
+ suggestion: "Fix the JSON structure (expected {id, display_name, ...})."
160
+ });
161
+ continue;
162
+ }
163
+ const obj = raw;
164
+ const displayRaw = obj.display_name;
165
+ const display_name = typeof displayRaw === "string" ? displayRaw.trim() : "";
166
+ if (display_name.length === 0) {
167
+ issues.push({
168
+ id: "naming.registry.invalid_profile",
169
+ severity: "warn",
170
+ summary: `Invalid character profile: ${rel_path} missing display_name.`,
171
+ suggestion: "Ensure display_name is a non-empty string."
172
+ });
173
+ continue;
174
+ }
175
+ const idRaw = obj.id;
176
+ if (typeof idRaw === "string" && idRaw.trim().length > 0 && idRaw.trim() !== slug_id) {
177
+ issues.push({
178
+ id: "naming.registry.id_mismatch",
179
+ severity: "warn",
180
+ summary: `Character profile id mismatch: ${rel_path} has id='${idRaw.trim()}', expected '${slug_id}'.`,
181
+ suggestion: "Align characters/active/<slug>.json filename with its id field."
182
+ });
183
+ }
184
+ const canonicalKey = normalizeNameKey(display_name);
185
+ const aliasesRaw = obj.aliases;
186
+ const aliases = [];
187
+ if (Array.isArray(aliasesRaw)) {
188
+ for (const it of aliasesRaw) {
189
+ if (typeof it !== "string")
190
+ continue;
191
+ const trimmed = it.trim();
192
+ if (trimmed.length === 0)
193
+ continue;
194
+ if (normalizeNameKey(trimmed) === canonicalKey)
195
+ continue;
196
+ aliases.push(trimmed);
197
+ }
198
+ }
199
+ const uniqueAliases = Array.from(new Set(aliases.map((a) => normalizeNameKey(a))))
200
+ .map((k) => {
201
+ const found = aliases.find((a) => normalizeNameKey(a) === k);
202
+ return found ?? "";
203
+ })
204
+ .filter((a) => a.length > 0);
205
+ entries.push({ slug_id, rel_path, display_name, aliases: uniqueAliases });
206
+ }
207
+ entries.sort((a, b) => a.slug_id.localeCompare(b.slug_id, "en"));
208
+ return { entries, issues };
209
+ }
210
+ function buildNameOccurrences(entries) {
211
+ const out = [];
212
+ for (const e of entries) {
213
+ out.push({
214
+ key: normalizeNameKey(e.display_name),
215
+ occ: { slug_id: e.slug_id, rel_path: e.rel_path, kind: "canonical", value: e.display_name }
216
+ });
217
+ for (const a of e.aliases) {
218
+ out.push({
219
+ key: normalizeNameKey(a),
220
+ occ: { slug_id: e.slug_id, rel_path: e.rel_path, kind: "alias", value: a }
221
+ });
222
+ }
223
+ }
224
+ return out.filter((it) => it.key.length > 0);
225
+ }
226
+ function isPairExempt(exemptions, a, b) {
227
+ const ka = normalizeNameKey(a);
228
+ const kb = normalizeNameKey(b);
229
+ if (ka.length === 0 || kb.length === 0)
230
+ return false;
231
+ return exemptions.allow_pairs.has(makePairKey(ka, kb));
232
+ }
233
+ export async function computeNamingReport(args) {
234
+ const generated_at = new Date().toISOString();
235
+ const policy = args.platformProfile.naming ?? null;
236
+ const policyOut = policy
237
+ ? {
238
+ enabled: policy.enabled,
239
+ near_duplicate_threshold: policy.near_duplicate_threshold,
240
+ blocking_conflict_types: policy.blocking_conflict_types
241
+ }
242
+ : null;
243
+ if (!policy || !policy.enabled) {
244
+ return {
245
+ schema_version: 1,
246
+ generated_at,
247
+ scope: { chapter: args.chapter },
248
+ policy: policyOut,
249
+ registry: { total_characters: 0, total_names: 0 },
250
+ status: "skipped",
251
+ issues: [],
252
+ has_blocking_issues: false
253
+ };
254
+ }
255
+ const exemptions = parseExemptions(policy.exemptions);
256
+ const blockingTypes = collectBlockingTypes(args.platformProfile);
257
+ const threshold = policy.near_duplicate_threshold;
258
+ const registry = await deriveNameRegistry(args.rootDir);
259
+ const entries = registry.entries;
260
+ const issues = registry.issues.map((i) => ({ ...i }));
261
+ const canonicalKeyToEntries = new Map();
262
+ for (const e of entries) {
263
+ const key = normalizeNameKey(e.display_name);
264
+ if (key.length === 0)
265
+ continue;
266
+ if (exemptions.ignore_names.has(key))
267
+ continue;
268
+ const bucket = canonicalKeyToEntries.get(key);
269
+ if (bucket)
270
+ bucket.push(e);
271
+ else
272
+ canonicalKeyToEntries.set(key, [e]);
273
+ }
274
+ const duplicateKeys = Array.from(canonicalKeyToEntries.keys()).sort((a, b) => a.localeCompare(b, "zh"));
275
+ for (const key of duplicateKeys) {
276
+ const bucket = canonicalKeyToEntries.get(key);
277
+ if (!bucket || bucket.length <= 1)
278
+ continue;
279
+ const name = bucket[0]?.display_name ?? key;
280
+ const ev = bucket
281
+ .map((b) => `${b.slug_id} (${b.rel_path})`)
282
+ .slice(0, 5)
283
+ .join(" | ");
284
+ const suffix = bucket.length > 5 ? " …" : "";
285
+ issues.push({
286
+ id: "naming.duplicate_display_name",
287
+ conflict_type: "duplicate",
288
+ severity: severityForConflict({ conflict_type: "duplicate", blockingTypes }),
289
+ summary: `Duplicate character name detected: '${name}' appears in ${bucket.length} profiles.`,
290
+ evidence: `${ev}${suffix}`,
291
+ suggestion: "Rename one character or add an exemption if the overlap is intentional."
292
+ });
293
+ }
294
+ const occurrences = buildNameOccurrences(entries);
295
+ const occByKey = new Map();
296
+ for (const it of occurrences) {
297
+ if (exemptions.ignore_names.has(it.key))
298
+ continue;
299
+ const bucket = occByKey.get(it.key);
300
+ if (bucket)
301
+ bucket.push(it.occ);
302
+ else
303
+ occByKey.set(it.key, [it.occ]);
304
+ }
305
+ const occKeys = Array.from(occByKey.keys()).sort((a, b) => a.localeCompare(b, "zh"));
306
+ for (const key of occKeys) {
307
+ const bucket = occByKey.get(key);
308
+ if (!bucket)
309
+ continue;
310
+ const uniqueSlugs = new Set(bucket.map((b) => b.slug_id));
311
+ if (uniqueSlugs.size <= 1)
312
+ continue;
313
+ const hasAlias = bucket.some((b) => b.kind === "alias");
314
+ if (!hasAlias)
315
+ continue;
316
+ const label = bucket[0]?.value ?? key;
317
+ if (isPairExempt(exemptions, label, label))
318
+ continue;
319
+ const ev = bucket
320
+ .map((b) => `${b.slug_id}:${b.kind} (${b.rel_path})`)
321
+ .slice(0, 6)
322
+ .join(" | ");
323
+ const suffix = bucket.length > 6 ? " …" : "";
324
+ issues.push({
325
+ id: "naming.alias_collision",
326
+ conflict_type: "alias_collision",
327
+ severity: severityForConflict({ conflict_type: "alias_collision", blockingTypes }),
328
+ summary: `Alias collision detected: '${label}' is associated with multiple characters.`,
329
+ evidence: `${ev}${suffix}`,
330
+ suggestion: "Rename the alias or add text disambiguation to avoid confusing readers."
331
+ });
332
+ }
333
+ const canonicalEntries = entries
334
+ .filter((e) => normalizeNameKey(e.display_name).length > 0 && !exemptions.ignore_names.has(normalizeNameKey(e.display_name)))
335
+ .slice()
336
+ .sort((a, b) => a.display_name.localeCompare(b.display_name, "zh") || a.slug_id.localeCompare(b.slug_id, "en"));
337
+ const maxNearIssues = 50;
338
+ let nearCount = 0;
339
+ for (let i = 0; i < canonicalEntries.length && nearCount < maxNearIssues; i += 1) {
340
+ const a = canonicalEntries[i];
341
+ if (!a)
342
+ continue;
343
+ const keyA = normalizeNameKey(a.display_name);
344
+ for (let j = i + 1; j < canonicalEntries.length && nearCount < maxNearIssues; j += 1) {
345
+ const b = canonicalEntries[j];
346
+ if (!b)
347
+ continue;
348
+ const keyB = normalizeNameKey(b.display_name);
349
+ if (keyA === keyB)
350
+ continue;
351
+ if (isPairExempt(exemptions, a.display_name, b.display_name))
352
+ continue;
353
+ const score = computeSimilarity(a.display_name, b.display_name);
354
+ if (score < threshold)
355
+ continue;
356
+ issues.push({
357
+ id: "naming.near_duplicate",
358
+ conflict_type: "near_duplicate",
359
+ severity: severityForConflict({ conflict_type: "near_duplicate", blockingTypes }),
360
+ similarity: score,
361
+ summary: `Near-duplicate names detected: '${a.display_name}' vs '${b.display_name}' (similarity=${score} ≥ ${threshold}).`,
362
+ evidence: `${a.slug_id} (${a.rel_path}) | ${b.slug_id} (${b.rel_path})`,
363
+ suggestion: "Consider renaming or adding strong textual disambiguation when both names must coexist."
364
+ });
365
+ nearCount += 1;
366
+ }
367
+ }
368
+ const nerIndex = args.infoLoadNer?.status === "pass" ? args.infoLoadNer.current_index : null;
369
+ if (nerIndex) {
370
+ const knownNames = [];
371
+ for (const e of entries) {
372
+ const k = normalizeNameKey(e.display_name);
373
+ if (k.length > 0 && !exemptions.ignore_names.has(k)) {
374
+ knownNames.push({ key: k, label: e.display_name, slug_id: e.slug_id, rel_path: e.rel_path, kind: "canonical" });
375
+ }
376
+ for (const a of e.aliases) {
377
+ const ak = normalizeNameKey(a);
378
+ if (ak.length > 0 && !exemptions.ignore_names.has(ak)) {
379
+ knownNames.push({ key: ak, label: a, slug_id: e.slug_id, rel_path: e.rel_path, kind: "alias" });
380
+ }
381
+ }
382
+ }
383
+ const knownKeySet = new Set(knownNames.map((k) => k.key));
384
+ const unknownCandidates = Array.from(nerIndex.entries())
385
+ .filter(([, meta]) => meta.category === "character")
386
+ .map(([text, meta]) => ({ text, meta }))
387
+ .sort((a, b) => a.text.localeCompare(b.text, "zh"));
388
+ for (const it of unknownCandidates) {
389
+ const key = normalizeNameKey(it.text);
390
+ if (key.length === 0)
391
+ continue;
392
+ if (exemptions.ignore_names.has(key))
393
+ continue;
394
+ if (knownKeySet.has(key))
395
+ continue;
396
+ let best = null;
397
+ for (const kn of knownNames) {
398
+ const score = computeSimilarity(key, kn.key);
399
+ if (score < threshold)
400
+ continue;
401
+ if (!best || score > best.score)
402
+ best = { score, match: kn };
403
+ }
404
+ if (!best)
405
+ continue;
406
+ if (isPairExempt(exemptions, it.text, best.match.label))
407
+ continue;
408
+ const ev = it.meta.evidence ? `${it.meta.evidence} ~ ${best.match.label} (${best.match.slug_id})` : `${best.match.label} (${best.match.slug_id})`;
409
+ issues.push({
410
+ id: "naming.unknown_entity_confusion",
411
+ severity: "warn",
412
+ similarity: best.score,
413
+ summary: `Unknown character-like entity '${it.text}' is highly similar to existing '${best.match.label}' (similarity=${best.score} ≥ ${threshold}).`,
414
+ evidence: ev,
415
+ suggestion: "If this is a new character, add a profile under characters/active/. Otherwise, add disambiguation or adjust naming."
416
+ });
417
+ }
418
+ }
419
+ const ordered = issues
420
+ .slice()
421
+ .sort((a, b) => severityRank(b.severity) - severityRank(a.severity) ||
422
+ a.id.localeCompare(b.id, "en") ||
423
+ a.summary.localeCompare(b.summary, "zh"));
424
+ const has_blocking_issues = ordered.some((i) => i.severity === "hard");
425
+ const status = has_blocking_issues ? "violation" : ordered.length > 0 ? "warn" : "pass";
426
+ const total_names = entries.reduce((acc, e) => acc + 1 + e.aliases.length, 0);
427
+ return {
428
+ schema_version: 1,
429
+ generated_at,
430
+ scope: { chapter: args.chapter },
431
+ policy: policyOut,
432
+ registry: { total_characters: entries.length, total_names },
433
+ status,
434
+ issues: ordered,
435
+ has_blocking_issues
436
+ };
437
+ }
438
+ export function summarizeNamingIssues(issues, limit) {
439
+ const ordered = issues
440
+ .slice()
441
+ .sort((a, b) => severityRank(b.severity) - severityRank(a.severity) ||
442
+ a.id.localeCompare(b.id, "en") ||
443
+ a.summary.localeCompare(b.summary, "zh"));
444
+ return ordered
445
+ .slice(0, limit)
446
+ .map((i) => i.summary)
447
+ .join(" | ");
448
+ }
449
+ export async function precomputeNamingReport(args) {
450
+ try {
451
+ const before = await fingerprintTextFile(args.chapterAbsPath);
452
+ const ner = args.infoLoadNer;
453
+ const usableNer = ner && ner.status === "pass" && ner.chapter_fingerprint ? fingerprintsMatch(ner.chapter_fingerprint, before.fingerprint) : false;
454
+ const report = await computeNamingReport({
455
+ rootDir: args.rootDir,
456
+ chapter: args.chapter,
457
+ chapterText: before.text,
458
+ platformProfile: args.platformProfile,
459
+ ...(usableNer ? { infoLoadNer: ner } : {})
460
+ });
461
+ const afterFp = await fingerprintFile(args.chapterAbsPath);
462
+ if (!fingerprintsMatch(before.fingerprint, afterFp)) {
463
+ return {
464
+ status: "skipped",
465
+ error: "Chapter changed while running naming lint; skipping precomputed result.",
466
+ chapter_fingerprint: null,
467
+ report: null
468
+ };
469
+ }
470
+ return { status: "pass", chapter_fingerprint: afterFp, report };
471
+ }
472
+ catch (err) {
473
+ const message = err instanceof Error ? err.message : String(err);
474
+ return { status: "skipped", error: message, chapter_fingerprint: null, report: null };
475
+ }
476
+ }
477
+ export async function writeNamingLintLogs(args) {
478
+ const dirRel = "logs/naming";
479
+ const dirAbs = join(args.rootDir, dirRel);
480
+ await ensureDir(dirAbs);
481
+ const historyRel = `${dirRel}/naming-report-chapter-${pad3(args.chapter)}.json`;
482
+ const latestRel = `${dirRel}/latest.json`;
483
+ await writeJsonFile(join(args.rootDir, historyRel), args.report);
484
+ await writeJsonFile(join(args.rootDir, latestRel), args.report);
485
+ return { latestRel, historyRel };
486
+ }
487
+ export async function attachNamingLintToEval(args) {
488
+ const raw = await readJsonFile(args.evalAbsPath);
489
+ if (!isPlainObject(raw))
490
+ throw new NovelCliError(`Invalid ${args.evalRelPath}: eval JSON must be an object.`, 2);
491
+ const obj = raw;
492
+ const bySeverity = { warn: 0, soft: 0, hard: 0 };
493
+ for (const issue of args.report.issues) {
494
+ if (issue.severity === "warn")
495
+ bySeverity.warn += 1;
496
+ else if (issue.severity === "soft")
497
+ bySeverity.soft += 1;
498
+ else if (issue.severity === "hard")
499
+ bySeverity.hard += 1;
500
+ }
501
+ obj.naming_lint = {
502
+ report_path: args.reportRelPath,
503
+ status: args.report.status,
504
+ ...(args.report.policy
505
+ ? {
506
+ enabled: args.report.policy.enabled,
507
+ near_duplicate_threshold: args.report.policy.near_duplicate_threshold,
508
+ blocking_conflict_types: args.report.policy.blocking_conflict_types
509
+ }
510
+ : { enabled: null, near_duplicate_threshold: null, blocking_conflict_types: null }),
511
+ registry: args.report.registry,
512
+ issues_total: args.report.issues.length,
513
+ issues_by_severity: bySeverity,
514
+ has_blocking_issues: args.report.has_blocking_issues
515
+ };
516
+ await writeJsonFile(args.evalAbsPath, obj);
517
+ }
518
+ export function assertNoDuplicateCanonicalDisplayNames(entries) {
519
+ const seen = new Map();
520
+ for (const e of entries) {
521
+ const key = normalizeNameKey(e.display_name);
522
+ if (key.length === 0)
523
+ continue;
524
+ const prev = seen.get(key);
525
+ if (!prev) {
526
+ seen.set(key, e);
527
+ continue;
528
+ }
529
+ throw new NovelCliError(`Duplicate display_name in registry: '${e.display_name}' in ${prev.rel_path} and ${e.rel_path}.`, 2);
530
+ }
531
+ }