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
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
|
+
}
|