novel-writer-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/agents/chapter-writer.md +142 -0
- package/agents/character-weaver.md +117 -0
- package/agents/consistency-auditor.md +85 -0
- package/agents/plot-architect.md +128 -0
- package/agents/quality-judge.md +232 -0
- package/agents/style-analyzer.md +109 -0
- package/agents/style-refiner.md +97 -0
- package/agents/summarizer.md +128 -0
- package/agents/world-builder.md +161 -0
- package/dist/__tests__/character-voice.test.js +445 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
- package/dist/__tests__/engagement.test.js +382 -0
- package/dist/__tests__/foreshadow-visibility.test.js +131 -0
- package/dist/__tests__/hook-ledger.test.js +1028 -0
- package/dist/__tests__/naming-lint.test.js +132 -0
- package/dist/__tests__/narrative-health-injection.test.js +359 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
- package/dist/__tests__/next-step-title-fix.test.js +153 -0
- package/dist/__tests__/platform-profile.test.js +274 -0
- package/dist/__tests__/promise-ledger.test.js +189 -0
- package/dist/__tests__/readability-lint.test.js +209 -0
- package/dist/__tests__/text-utils.test.js +39 -0
- package/dist/__tests__/title-policy.test.js +147 -0
- package/dist/advance.js +75 -0
- package/dist/character-voice.js +805 -0
- package/dist/checkpoint.js +126 -0
- package/dist/cli.js +563 -0
- package/dist/cliche-lint.js +515 -0
- package/dist/commit.js +1460 -0
- package/dist/consistency-auditor.js +684 -0
- package/dist/engagement.js +687 -0
- package/dist/errors.js +7 -0
- package/dist/fingerprint.js +16 -0
- package/dist/foreshadow-visibility.js +214 -0
- package/dist/fs-utils.js +68 -0
- package/dist/hook-ledger.js +721 -0
- package/dist/hook-policy.js +107 -0
- package/dist/instruction-gates.js +51 -0
- package/dist/instructions.js +406 -0
- package/dist/latest-summary-loader.js +29 -0
- package/dist/lock.js +121 -0
- package/dist/naming-lint.js +531 -0
- package/dist/ner.js +73 -0
- package/dist/next-step.js +408 -0
- package/dist/novel-ask.js +270 -0
- package/dist/output.js +9 -0
- package/dist/platform-constraints.js +518 -0
- package/dist/platform-profile.js +325 -0
- package/dist/prejudge-guardrails.js +370 -0
- package/dist/project.js +40 -0
- package/dist/promise-ledger.js +723 -0
- package/dist/readability-lint.js +555 -0
- package/dist/safe-parse.js +36 -0
- package/dist/safe-path.js +29 -0
- package/dist/scoring-weights.js +290 -0
- package/dist/steps.js +60 -0
- package/dist/text-utils.js +18 -0
- package/dist/title-policy.js +251 -0
- package/dist/type-guards.js +6 -0
- package/dist/validate.js +131 -0
- package/docs/user/README.md +17 -0
- package/docs/user/guardrails.md +179 -0
- package/docs/user/interactive-gates.md +124 -0
- package/docs/user/novel-cli.md +289 -0
- package/docs/user/ops.md +123 -0
- package/docs/user/quick-start.md +97 -0
- package/docs/user/spec-system.md +166 -0
- package/docs/user/storylines.md +144 -0
- package/package.json +48 -0
- package/schemas/README.md +18 -0
- package/schemas/character-voice-drift.schema.json +135 -0
- package/schemas/character-voice-profiles.schema.json +141 -0
- package/schemas/engagement-metrics.schema.json +38 -0
- package/schemas/hook-ledger.schema.json +108 -0
- package/schemas/platform-profile.schema.json +235 -0
- package/schemas/promise-ledger.schema.json +97 -0
- package/scripts/calibrate-quality-judge.sh +91 -0
- package/scripts/compare-regression-runs.sh +86 -0
- package/scripts/lib/_common.py +131 -0
- package/scripts/lib/calibrate_quality_judge.py +312 -0
- package/scripts/lib/compare_regression_runs.py +142 -0
- package/scripts/lib/run_regression.py +621 -0
- package/scripts/lint-blacklist.sh +201 -0
- package/scripts/lint-cliche.sh +370 -0
- package/scripts/lint-readability.sh +404 -0
- package/scripts/query-foreshadow.sh +252 -0
- package/scripts/run-ner.sh +669 -0
- package/scripts/run-regression.sh +122 -0
- package/skills/cli-step/SKILL.md +158 -0
- package/skills/continue/SKILL.md +348 -0
- package/skills/continue/references/context-contracts.md +169 -0
- package/skills/continue/references/continuity-checks.md +187 -0
- package/skills/continue/references/file-protocols.md +64 -0
- package/skills/continue/references/foreshadowing.md +130 -0
- package/skills/continue/references/gate-decision.md +53 -0
- package/skills/continue/references/periodic-maintenance.md +46 -0
- package/skills/novel-writing/SKILL.md +77 -0
- package/skills/novel-writing/references/quality-rubric.md +140 -0
- package/skills/novel-writing/references/style-guide.md +145 -0
- package/skills/start/SKILL.md +458 -0
- package/skills/start/references/quality-review.md +86 -0
- package/skills/start/references/setting-update.md +44 -0
- package/skills/start/references/vol-planning.md +61 -0
- package/skills/start/references/vol-review.md +58 -0
- package/skills/status/SKILL.md +116 -0
- package/skills/status/references/sample-output.md +60 -0
- package/templates/ai-blacklist.json +79 -0
- package/templates/brief-template.md +46 -0
- package/templates/genre-weight-profiles.json +90 -0
- package/templates/novel-ask/example.answer.json +12 -0
- package/templates/novel-ask/example.question.json +51 -0
- package/templates/platform-profile.json +148 -0
- package/templates/style-profile-template.json +58 -0
- package/templates/web-novel-cliche-lint.json +41 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { parsePlatformProfile } from "../platform-profile.js";
|
|
7
|
+
import { computeReadabilityReport, writeReadabilityLogs } from "../readability-lint.js";
|
|
8
|
+
function makeProfileRaw(extra) {
|
|
9
|
+
return {
|
|
10
|
+
schema_version: 1,
|
|
11
|
+
platform: "qidian",
|
|
12
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
13
|
+
word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
|
|
14
|
+
hook_policy: { required: false, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
|
|
15
|
+
info_load: { max_new_entities_per_chapter: 0, max_unknown_entities_per_chapter: 0, max_new_terms_per_1k_words: 0 },
|
|
16
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn" },
|
|
17
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
18
|
+
...extra
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function makeReadabilityPolicy(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
mobile: {
|
|
24
|
+
enabled: true,
|
|
25
|
+
max_paragraph_chars: 10,
|
|
26
|
+
max_consecutive_exposition_paragraphs: 2,
|
|
27
|
+
blocking_severity: "hard_only",
|
|
28
|
+
...overrides
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
test("computeReadabilityReport skips when readability.mobile is missing/disabled", async () => {
|
|
33
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-skip-test-"));
|
|
34
|
+
const profile = parsePlatformProfile(makeProfileRaw({ readability: null }), "platform-profile.json");
|
|
35
|
+
const report = await computeReadabilityReport({
|
|
36
|
+
rootDir,
|
|
37
|
+
chapter: 1,
|
|
38
|
+
chapterAbsPath: join(rootDir, "chapter.md"),
|
|
39
|
+
chapterText: "# T\n正文",
|
|
40
|
+
platformProfile: profile
|
|
41
|
+
});
|
|
42
|
+
assert.equal(report.status, "skipped");
|
|
43
|
+
assert.equal(report.issues.length, 0);
|
|
44
|
+
});
|
|
45
|
+
test("computeReadabilityReport fallback flags overlong paragraphs and mixed punctuation (warn-only)", async () => {
|
|
46
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-fallback-test-"));
|
|
47
|
+
const profile = parsePlatformProfile(makeProfileRaw({ readability: makeReadabilityPolicy({ max_paragraph_chars: 5 }) }), "platform-profile.json");
|
|
48
|
+
const chapterText = '第一段太长太长太长, A. 但是用了全角,。\n\n第二段。';
|
|
49
|
+
const report = await computeReadabilityReport({
|
|
50
|
+
rootDir,
|
|
51
|
+
chapter: 1,
|
|
52
|
+
chapterAbsPath: join(rootDir, "chapter.md"),
|
|
53
|
+
chapterText,
|
|
54
|
+
platformProfile: profile
|
|
55
|
+
});
|
|
56
|
+
assert.equal(report.mode, "fallback");
|
|
57
|
+
assert.equal(report.has_blocking_issues, false);
|
|
58
|
+
assert.equal(report.status, "warn");
|
|
59
|
+
assert.ok(report.issues.some((i) => i.id === "readability.mobile.overlong_paragraph"));
|
|
60
|
+
assert.ok(report.issues.some((i) => i.id === "readability.mobile.mixed_comma_styles"));
|
|
61
|
+
assert.ok(report.issues.some((i) => i.id === "readability.mobile.mixed_period_styles"));
|
|
62
|
+
});
|
|
63
|
+
test("computeReadabilityReport uses deterministic script output and derives blocking status from policy", async () => {
|
|
64
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-script-test-"));
|
|
65
|
+
await mkdir(join(rootDir, "scripts"), { recursive: true });
|
|
66
|
+
const profile = parsePlatformProfile(makeProfileRaw({
|
|
67
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn", script_paths: { lint_readability: "scripts/lint-readability.sh" } },
|
|
68
|
+
readability: makeReadabilityPolicy({ blocking_severity: "soft_and_hard" })
|
|
69
|
+
}), "platform-profile.json");
|
|
70
|
+
await writeFile(join(rootDir, "platform-profile.json"), `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
71
|
+
const chapterAbsPath = join(rootDir, "chapter.md");
|
|
72
|
+
await writeFile(chapterAbsPath, "# T\n正文\n", "utf8");
|
|
73
|
+
const scriptAbs = join(rootDir, "scripts", "lint-readability.sh");
|
|
74
|
+
const stubJson = '{"schema_version":1,"generated_at":"2026-01-01T00:00:00.000Z","scope":{"chapter":1},"policy":{"enabled":true,"max_paragraph_chars":10,"max_consecutive_exposition_paragraphs":2,"blocking_severity":"soft_and_hard"},"issues":[{"id":"readability.mobile.overlong_paragraph","severity":"soft","summary":"Soft but blocking when configured."}]}';
|
|
75
|
+
const stubScript = `#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' '${stubJson}'\n`;
|
|
76
|
+
await writeFile(scriptAbs, stubScript, "utf8");
|
|
77
|
+
const report = await computeReadabilityReport({
|
|
78
|
+
rootDir,
|
|
79
|
+
chapter: 1,
|
|
80
|
+
chapterAbsPath,
|
|
81
|
+
chapterText: "# T\n正文\n",
|
|
82
|
+
platformProfile: profile
|
|
83
|
+
});
|
|
84
|
+
assert.equal(report.mode, "script");
|
|
85
|
+
assert.equal(report.has_blocking_issues, true);
|
|
86
|
+
assert.equal(report.status, "violation");
|
|
87
|
+
});
|
|
88
|
+
test("computeReadabilityReport trusts platform profile policy over script policy", async () => {
|
|
89
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-policy-wins-test-"));
|
|
90
|
+
await mkdir(join(rootDir, "scripts"), { recursive: true });
|
|
91
|
+
const profile = parsePlatformProfile(makeProfileRaw({
|
|
92
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn", script_paths: { lint_readability: "scripts/lint-readability.sh" } },
|
|
93
|
+
readability: makeReadabilityPolicy({ blocking_severity: "hard_only" })
|
|
94
|
+
}), "platform-profile.json");
|
|
95
|
+
await writeFile(join(rootDir, "platform-profile.json"), `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
96
|
+
const chapterAbsPath = join(rootDir, "chapter.md");
|
|
97
|
+
await writeFile(chapterAbsPath, "# T\n正文\n", "utf8");
|
|
98
|
+
const scriptAbs = join(rootDir, "scripts", "lint-readability.sh");
|
|
99
|
+
const stubJson = '{"schema_version":1,"generated_at":"2026-01-01T00:00:00.000Z","scope":{"chapter":1},"policy":{"enabled":true,"max_paragraph_chars":10,"max_consecutive_exposition_paragraphs":2,"blocking_severity":"soft_and_hard"},"issues":[{"id":"readability.mobile.overlong_paragraph","severity":"soft","summary":"Soft issue should not block when profile is hard_only."}]}';
|
|
100
|
+
const stubScript = `#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' '${stubJson}'\n`;
|
|
101
|
+
await writeFile(scriptAbs, stubScript, "utf8");
|
|
102
|
+
const report = await computeReadabilityReport({
|
|
103
|
+
rootDir,
|
|
104
|
+
chapter: 1,
|
|
105
|
+
chapterAbsPath,
|
|
106
|
+
chapterText: "# T\n正文\n",
|
|
107
|
+
platformProfile: profile
|
|
108
|
+
});
|
|
109
|
+
assert.equal(report.mode, "script");
|
|
110
|
+
assert.equal(report.policy?.blocking_severity, "hard_only");
|
|
111
|
+
assert.equal(report.has_blocking_issues, false);
|
|
112
|
+
assert.equal(report.status, "warn");
|
|
113
|
+
});
|
|
114
|
+
test("computeReadabilityReport falls back when lint_readability script path contains traversal segments", async () => {
|
|
115
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-traversal-test-"));
|
|
116
|
+
const profile = parsePlatformProfile(makeProfileRaw({
|
|
117
|
+
compliance: { banned_words: [], duplicate_name_policy: "warn", script_paths: { lint_readability: "../evil.sh" } },
|
|
118
|
+
readability: makeReadabilityPolicy()
|
|
119
|
+
}), "platform-profile.json");
|
|
120
|
+
const chapterAbsPath = join(rootDir, "chapter.md");
|
|
121
|
+
await writeFile(chapterAbsPath, "# T\n正文\n", "utf8");
|
|
122
|
+
const report = await computeReadabilityReport({
|
|
123
|
+
rootDir,
|
|
124
|
+
chapter: 1,
|
|
125
|
+
chapterAbsPath,
|
|
126
|
+
chapterText: "# T\n正文\n",
|
|
127
|
+
platformProfile: profile
|
|
128
|
+
});
|
|
129
|
+
assert.equal(report.mode, "fallback");
|
|
130
|
+
assert.ok(typeof report.script_error === "string" && report.script_error.includes("../evil.sh"));
|
|
131
|
+
assert.ok(typeof report.script_error === "string" && report.script_error.includes("must not contain '..'"));
|
|
132
|
+
});
|
|
133
|
+
test("computeReadabilityReport falls back when lint_readability script resolves outside project root (symlink)", async () => {
|
|
134
|
+
if (process.platform === "win32")
|
|
135
|
+
return;
|
|
136
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-symlink-test-"));
|
|
137
|
+
await mkdir(join(rootDir, "scripts"), { recursive: true });
|
|
138
|
+
const outsideDir = await mkdtemp(join(tmpdir(), "novel-readability-symlink-outside-"));
|
|
139
|
+
const outsideAbs = join(outsideDir, "lint-readability.sh");
|
|
140
|
+
await writeFile(outsideAbs, "#!/usr/bin/env bash\nset -euo pipefail\necho 'not-used'\n", "utf8");
|
|
141
|
+
await symlink(outsideAbs, join(rootDir, "scripts", "lint-readability.sh"));
|
|
142
|
+
const profile = parsePlatformProfile(makeProfileRaw({ readability: makeReadabilityPolicy() }), "platform-profile.json");
|
|
143
|
+
const chapterAbsPath = join(rootDir, "chapter.md");
|
|
144
|
+
await writeFile(chapterAbsPath, "# T\n正文\n", "utf8");
|
|
145
|
+
const report = await computeReadabilityReport({
|
|
146
|
+
rootDir,
|
|
147
|
+
chapter: 1,
|
|
148
|
+
chapterAbsPath,
|
|
149
|
+
chapterText: "# T\n正文\n",
|
|
150
|
+
platformProfile: profile
|
|
151
|
+
});
|
|
152
|
+
assert.equal(report.mode, "fallback");
|
|
153
|
+
assert.ok(typeof report.script_error === "string" && report.script_error.includes("Unsafe path outside project root"));
|
|
154
|
+
});
|
|
155
|
+
test("computeReadabilityReport falls back when deterministic script output is invalid JSON", async () => {
|
|
156
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-bad-json-test-"));
|
|
157
|
+
await mkdir(join(rootDir, "scripts"), { recursive: true });
|
|
158
|
+
const profile = parsePlatformProfile(makeProfileRaw({ readability: makeReadabilityPolicy() }), "platform-profile.json");
|
|
159
|
+
await writeFile(join(rootDir, "platform-profile.json"), `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
160
|
+
const chapterAbsPath = join(rootDir, "chapter.md");
|
|
161
|
+
await writeFile(chapterAbsPath, "# T\n正文\n", "utf8");
|
|
162
|
+
await writeFile(join(rootDir, "scripts", "lint-readability.sh"), "#!/usr/bin/env bash\nset -euo pipefail\necho 'not-json'\n", "utf8");
|
|
163
|
+
const report = await computeReadabilityReport({
|
|
164
|
+
rootDir,
|
|
165
|
+
chapter: 1,
|
|
166
|
+
chapterAbsPath,
|
|
167
|
+
chapterText: "# T\n正文\n",
|
|
168
|
+
platformProfile: profile
|
|
169
|
+
});
|
|
170
|
+
assert.equal(report.mode, "fallback");
|
|
171
|
+
assert.ok(typeof report.script_error === "string" && report.script_error.includes("scripts/lint-readability.sh"));
|
|
172
|
+
assert.equal(report.has_blocking_issues, false);
|
|
173
|
+
});
|
|
174
|
+
test("computeReadabilityReport falls back when deterministic script returns wrong scope.chapter", async () => {
|
|
175
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-wrong-chapter-test-"));
|
|
176
|
+
await mkdir(join(rootDir, "scripts"), { recursive: true });
|
|
177
|
+
const profile = parsePlatformProfile(makeProfileRaw({ readability: makeReadabilityPolicy() }), "platform-profile.json");
|
|
178
|
+
await writeFile(join(rootDir, "platform-profile.json"), `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
179
|
+
const chapterAbsPath = join(rootDir, "chapter.md");
|
|
180
|
+
await writeFile(chapterAbsPath, "# T\n正文\n", "utf8");
|
|
181
|
+
const wrongScopeJson = '{"schema_version":1,"generated_at":"2026-01-01T00:00:00.000Z","scope":{"chapter":2},"policy":{"enabled":true,"max_paragraph_chars":10,"max_consecutive_exposition_paragraphs":2,"blocking_severity":"hard_only"},"issues":[]}';
|
|
182
|
+
await writeFile(join(rootDir, "scripts", "lint-readability.sh"), `#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' '${wrongScopeJson}'\n`, "utf8");
|
|
183
|
+
const report = await computeReadabilityReport({
|
|
184
|
+
rootDir,
|
|
185
|
+
chapter: 1,
|
|
186
|
+
chapterAbsPath,
|
|
187
|
+
chapterText: "# T\n正文\n",
|
|
188
|
+
platformProfile: profile
|
|
189
|
+
});
|
|
190
|
+
assert.equal(report.mode, "fallback");
|
|
191
|
+
assert.ok(typeof report.script_error === "string" && report.script_error.includes("scope.chapter=2"));
|
|
192
|
+
assert.equal(report.has_blocking_issues, false);
|
|
193
|
+
});
|
|
194
|
+
test("writeReadabilityLogs writes history under readability-report-chapter-*.json", async () => {
|
|
195
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-readability-logs-test-"));
|
|
196
|
+
const report = {
|
|
197
|
+
schema_version: 1,
|
|
198
|
+
generated_at: "2026-01-01T00:00:00.000Z",
|
|
199
|
+
scope: { chapter: 1 },
|
|
200
|
+
policy: null,
|
|
201
|
+
mode: "fallback",
|
|
202
|
+
status: "pass",
|
|
203
|
+
issues: [],
|
|
204
|
+
has_blocking_issues: false
|
|
205
|
+
};
|
|
206
|
+
const out = await writeReadabilityLogs({ rootDir, chapter: 1, report });
|
|
207
|
+
assert.equal(out.latestRel, "logs/readability/latest.json");
|
|
208
|
+
assert.equal(out.historyRel, "logs/readability/readability-report-chapter-001.json");
|
|
209
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { truncateWithEllipsis } from "../text-utils.js";
|
|
4
|
+
test("truncateWithEllipsis returns original when within limit", () => {
|
|
5
|
+
assert.equal(truncateWithEllipsis("abc", 3), "abc");
|
|
6
|
+
assert.equal(truncateWithEllipsis("abc", 10), "abc");
|
|
7
|
+
});
|
|
8
|
+
test("truncateWithEllipsis returns empty string for maxLen 0", () => {
|
|
9
|
+
assert.equal(truncateWithEllipsis("abc", 0), "");
|
|
10
|
+
});
|
|
11
|
+
test("truncateWithEllipsis returns ellipsis only for maxLen 1", () => {
|
|
12
|
+
assert.equal(truncateWithEllipsis("abc", 1), "\u2026");
|
|
13
|
+
});
|
|
14
|
+
test("truncateWithEllipsis truncates and appends ellipsis", () => {
|
|
15
|
+
assert.equal(truncateWithEllipsis("abcdef", 4), "abc\u2026");
|
|
16
|
+
});
|
|
17
|
+
test("truncateWithEllipsis does not split surrogate pairs", () => {
|
|
18
|
+
// "\uD83D\uDE00" is U+1F600 encoded as surrogate pair \uD83D\uDE00
|
|
19
|
+
const text = "ab\uD83D\uDE00cd";
|
|
20
|
+
// maxLen=4: would cut at index 3 which is inside the surrogate pair
|
|
21
|
+
const result = truncateWithEllipsis(text, 4);
|
|
22
|
+
// Should back up to avoid splitting: "ab" + "\u2026"
|
|
23
|
+
assert.equal(result, "ab\u2026");
|
|
24
|
+
// Verify no lone surrogate in output
|
|
25
|
+
for (let i = 0; i < result.length; i++) {
|
|
26
|
+
const code = result.charCodeAt(i);
|
|
27
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
28
|
+
const next = result.charCodeAt(i + 1);
|
|
29
|
+
assert.ok(next >= 0xdc00 && next <= 0xdfff, "high surrogate must be followed by low surrogate");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
test("truncateWithEllipsis handles empty string", () => {
|
|
34
|
+
assert.equal(truncateWithEllipsis("", 5), "");
|
|
35
|
+
});
|
|
36
|
+
test("truncateWithEllipsis handles string exactly at limit boundary", () => {
|
|
37
|
+
assert.equal(truncateWithEllipsis("abcd", 4), "abcd");
|
|
38
|
+
assert.equal(truncateWithEllipsis("abcde", 4), "abc\u2026");
|
|
39
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parsePlatformProfile } from "../platform-profile.js";
|
|
4
|
+
import { assertTitleFixOnlyChangedTitleLine, computeTitlePolicyReport, extractChapterTitleFromMarkdown, stripFirstAtxHeadingLine } from "../title-policy.js";
|
|
5
|
+
function makeProfileRaw(extra) {
|
|
6
|
+
return {
|
|
7
|
+
schema_version: 1,
|
|
8
|
+
platform: "qidian",
|
|
9
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
10
|
+
word_count: { target_min: 1, target_max: 2, hard_min: 1, hard_max: 2 },
|
|
11
|
+
hook_policy: { required: false, min_strength: 3, allowed_types: ["question"], fix_strategy: "hook-fix" },
|
|
12
|
+
info_load: { max_new_entities_per_chapter: 0, max_unknown_entities_per_chapter: 0, max_new_terms_per_1k_words: 0 },
|
|
13
|
+
compliance: { banned_words: ["禁词"], duplicate_name_policy: "warn" },
|
|
14
|
+
scoring: { genre_drive_type: "plot", weight_profile_id: "plot:v1" },
|
|
15
|
+
...extra
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function makeTitlePolicy(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
enabled: true,
|
|
21
|
+
min_chars: 2,
|
|
22
|
+
max_chars: 10,
|
|
23
|
+
forbidden_patterns: ["剧透"],
|
|
24
|
+
required_patterns: ["^第\\d+章"],
|
|
25
|
+
auto_fix: true,
|
|
26
|
+
...overrides
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function makeRetention(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
title_policy: makeTitlePolicy(),
|
|
32
|
+
hook_ledger: {
|
|
33
|
+
enabled: false,
|
|
34
|
+
fulfillment_window_chapters: 10,
|
|
35
|
+
diversity_window_chapters: 5,
|
|
36
|
+
max_same_type_streak: 2,
|
|
37
|
+
min_distinct_types_in_window: 2,
|
|
38
|
+
overdue_policy: "warn"
|
|
39
|
+
},
|
|
40
|
+
...overrides
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
test("extractChapterTitleFromMarkdown returns the first non-empty H1 title", () => {
|
|
44
|
+
const out = extractChapterTitleFromMarkdown("\n\n# 第1章 开场\n正文");
|
|
45
|
+
assert.equal(out.has_h1, true);
|
|
46
|
+
assert.equal(out.title_text, "第1章 开场");
|
|
47
|
+
assert.equal(out.line_no, 3);
|
|
48
|
+
});
|
|
49
|
+
test("extractChapterTitleFromMarkdown reports missing H1 when first non-empty line is not H1", () => {
|
|
50
|
+
const out = extractChapterTitleFromMarkdown("正文\n# 标题");
|
|
51
|
+
assert.equal(out.has_h1, false);
|
|
52
|
+
assert.equal(out.line_no, 1);
|
|
53
|
+
assert.equal(out.raw_line, "正文");
|
|
54
|
+
});
|
|
55
|
+
test("computeTitlePolicyReport skips when title_policy is missing/disabled", () => {
|
|
56
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: null }), "platform-profile.json");
|
|
57
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 标题\n正文", platformProfile: profile });
|
|
58
|
+
assert.equal(report.status, "skipped");
|
|
59
|
+
});
|
|
60
|
+
test("computeTitlePolicyReport detects missing H1 title as hard violation when enabled", () => {
|
|
61
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention() }), "platform-profile.json");
|
|
62
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "正文", platformProfile: profile });
|
|
63
|
+
assert.equal(report.status, "violation");
|
|
64
|
+
assert.equal(report.has_hard_violations, true);
|
|
65
|
+
assert.ok(report.issues.some((i) => i.id.includes("missing_h1")));
|
|
66
|
+
});
|
|
67
|
+
test("computeTitlePolicyReport returns pass when title complies", () => {
|
|
68
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention({ title_policy: makeTitlePolicy({ forbidden_patterns: [], required_patterns: [] }) }) }), "platform-profile.json");
|
|
69
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 合规标题\n正文", platformProfile: profile });
|
|
70
|
+
assert.equal(report.status, "pass");
|
|
71
|
+
assert.equal(report.issues.length, 0);
|
|
72
|
+
});
|
|
73
|
+
test("computeTitlePolicyReport flags empty title as hard violation", () => {
|
|
74
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention({ title_policy: makeTitlePolicy({ forbidden_patterns: [], required_patterns: [] }) }) }), "platform-profile.json");
|
|
75
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# \n正文", platformProfile: profile });
|
|
76
|
+
assert.equal(report.status, "violation");
|
|
77
|
+
assert.equal(report.has_hard_violations, true);
|
|
78
|
+
assert.ok(report.issues.some((i) => i.id.includes("empty_title")));
|
|
79
|
+
});
|
|
80
|
+
test("computeTitlePolicyReport flags too-short title as warn (soft)", () => {
|
|
81
|
+
const profile = parsePlatformProfile(makeProfileRaw({
|
|
82
|
+
retention: makeRetention({ title_policy: makeTitlePolicy({ min_chars: 5, forbidden_patterns: [], required_patterns: [] }) })
|
|
83
|
+
}), "platform-profile.json");
|
|
84
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 短\n正文", platformProfile: profile });
|
|
85
|
+
assert.equal(report.status, "warn");
|
|
86
|
+
assert.equal(report.has_hard_violations, false);
|
|
87
|
+
assert.ok(report.issues.some((i) => i.id.includes("too_short")));
|
|
88
|
+
});
|
|
89
|
+
test("computeTitlePolicyReport flags too-long title as warn (soft)", () => {
|
|
90
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention({ title_policy: makeTitlePolicy({ max_chars: 3, required_patterns: [] }) }) }), "platform-profile.json");
|
|
91
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 太长的标题\n正文", platformProfile: profile });
|
|
92
|
+
assert.equal(report.status, "warn");
|
|
93
|
+
assert.equal(report.has_hard_violations, false);
|
|
94
|
+
assert.ok(report.issues.some((i) => i.id.includes("too_long")));
|
|
95
|
+
});
|
|
96
|
+
test("computeTitlePolicyReport flags required-pattern mismatch as warn (soft)", () => {
|
|
97
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention() }), "platform-profile.json");
|
|
98
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 开场\n正文", platformProfile: profile });
|
|
99
|
+
assert.equal(report.status, "warn");
|
|
100
|
+
assert.ok(report.issues.some((i) => i.id.includes("required_pattern_missing")));
|
|
101
|
+
});
|
|
102
|
+
test("computeTitlePolicyReport flags forbidden pattern matches as warn (soft)", () => {
|
|
103
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention({ title_policy: makeTitlePolicy({ required_patterns: [] }) }) }), "platform-profile.json");
|
|
104
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 这是剧透\n正文", platformProfile: profile });
|
|
105
|
+
assert.equal(report.status, "warn");
|
|
106
|
+
assert.ok(report.issues.some((i) => i.id.includes("forbidden_pattern")));
|
|
107
|
+
});
|
|
108
|
+
test("computeTitlePolicyReport flags banned words in title as hard violation", () => {
|
|
109
|
+
const profile = parsePlatformProfile(makeProfileRaw({ retention: makeRetention({ title_policy: makeTitlePolicy({ forbidden_patterns: [], required_patterns: [] }) }) }), "platform-profile.json");
|
|
110
|
+
const report = computeTitlePolicyReport({ chapter: 1, chapterText: "# 禁词来了\n正文", platformProfile: profile });
|
|
111
|
+
assert.equal(report.status, "violation");
|
|
112
|
+
assert.equal(report.has_hard_violations, true);
|
|
113
|
+
assert.ok(report.issues.some((i) => i.id.includes("banned_words")));
|
|
114
|
+
});
|
|
115
|
+
test("stripFirstAtxHeadingLine strips the first ATX heading line (keeps bytes elsewhere)", () => {
|
|
116
|
+
const before = "\n## 旧标题\r\n正文\n";
|
|
117
|
+
const out = stripFirstAtxHeadingLine(before);
|
|
118
|
+
assert.equal(out.removed_line, "## 旧标题");
|
|
119
|
+
assert.equal(out.stripped, "\n正文\n");
|
|
120
|
+
});
|
|
121
|
+
test("stripFirstAtxHeadingLine returns unchanged when first non-empty line is not a heading", () => {
|
|
122
|
+
const before = "\n正文\n# 标题\n";
|
|
123
|
+
const out = stripFirstAtxHeadingLine(before);
|
|
124
|
+
assert.equal(out.removed_line, null);
|
|
125
|
+
assert.equal(out.stripped, before);
|
|
126
|
+
});
|
|
127
|
+
test("stripFirstAtxHeadingLine handles heading-only files without trailing newline", () => {
|
|
128
|
+
const before = "# 标题";
|
|
129
|
+
const out = stripFirstAtxHeadingLine(before);
|
|
130
|
+
assert.equal(out.removed_line, "# 标题");
|
|
131
|
+
assert.equal(out.stripped, "");
|
|
132
|
+
});
|
|
133
|
+
test("assertTitleFixOnlyChangedTitleLine allows inserting a new title line", () => {
|
|
134
|
+
const before = "\n正文第一段\n第二段\n";
|
|
135
|
+
const after = "\n# 新标题\n正文第一段\n第二段\n";
|
|
136
|
+
assert.doesNotThrow(() => assertTitleFixOnlyChangedTitleLine({ before, after, file: "chapters/chapter-001.md" }));
|
|
137
|
+
});
|
|
138
|
+
test("assertTitleFixOnlyChangedTitleLine allows promoting an existing ATX heading to H1", () => {
|
|
139
|
+
const before = "## 旧标题\n正文\n";
|
|
140
|
+
const after = "# 旧标题\n正文\n";
|
|
141
|
+
assert.doesNotThrow(() => assertTitleFixOnlyChangedTitleLine({ before, after, file: "chapters/chapter-001.md" }));
|
|
142
|
+
});
|
|
143
|
+
test("assertTitleFixOnlyChangedTitleLine rejects body changes", () => {
|
|
144
|
+
const before = "# 标题\n正文\n";
|
|
145
|
+
const after = "# 新标题\n正文改了\n";
|
|
146
|
+
assert.throws(() => assertTitleFixOnlyChangedTitleLine({ before, after, file: "chapters/chapter-001.md" }), /only change.*title line/i);
|
|
147
|
+
});
|
package/dist/advance.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
|
|
3
|
+
import { NovelCliError } from "./errors.js";
|
|
4
|
+
import { removePath } from "./fs-utils.js";
|
|
5
|
+
import { withWriteLock } from "./lock.js";
|
|
6
|
+
import { chapterRelPaths, titleFixSnapshotRel } from "./steps.js";
|
|
7
|
+
import { validateStep } from "./validate.js";
|
|
8
|
+
function stageForStep(step) {
|
|
9
|
+
if (step.kind !== "chapter")
|
|
10
|
+
throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
|
|
11
|
+
switch (step.stage) {
|
|
12
|
+
case "draft":
|
|
13
|
+
return "drafting";
|
|
14
|
+
case "summarize":
|
|
15
|
+
return "drafted";
|
|
16
|
+
case "refine":
|
|
17
|
+
return "refined";
|
|
18
|
+
case "judge":
|
|
19
|
+
return "judged";
|
|
20
|
+
case "title-fix":
|
|
21
|
+
return "refined";
|
|
22
|
+
case "hook-fix":
|
|
23
|
+
return "refined";
|
|
24
|
+
default:
|
|
25
|
+
throw new NovelCliError(`Unsupported step stage: ${step.stage}`, 2);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function advanceCheckpointForStep(args) {
|
|
29
|
+
if (args.step.kind !== "chapter")
|
|
30
|
+
throw new NovelCliError(`Unsupported step kind: ${args.step.kind}`, 2);
|
|
31
|
+
if (args.step.stage === "commit")
|
|
32
|
+
throw new NovelCliError(`Use 'novel commit' for commit.`, 2);
|
|
33
|
+
if (args.step.stage === "review")
|
|
34
|
+
throw new NovelCliError(`Review is a manual step; do not advance it.`, 2);
|
|
35
|
+
return await withWriteLock(args.rootDir, { chapter: args.step.chapter }, async () => {
|
|
36
|
+
const checkpoint = await readCheckpoint(args.rootDir);
|
|
37
|
+
// Enforce validate-before-advance to keep deterministic semantics.
|
|
38
|
+
await validateStep({ rootDir: args.rootDir, checkpoint, step: args.step });
|
|
39
|
+
const updated = { ...checkpoint };
|
|
40
|
+
const nextStage = stageForStep(args.step);
|
|
41
|
+
updated.pipeline_stage = nextStage;
|
|
42
|
+
updated.inflight_chapter = args.step.chapter;
|
|
43
|
+
// Ensure revision counter is initialized when starting from draft (revision loops may preserve it).
|
|
44
|
+
if (args.step.stage === "draft") {
|
|
45
|
+
if (typeof updated.revision_count !== "number")
|
|
46
|
+
updated.revision_count = 0;
|
|
47
|
+
updated.hook_fix_count = 0;
|
|
48
|
+
updated.title_fix_count = 0;
|
|
49
|
+
await removePath(join(args.rootDir, titleFixSnapshotRel(args.step.chapter)));
|
|
50
|
+
}
|
|
51
|
+
// Title-fix counts as a bounded micro-revision and invalidates the current eval.
|
|
52
|
+
if (args.step.stage === "title-fix") {
|
|
53
|
+
const prev = typeof updated.title_fix_count === "number" ? updated.title_fix_count : 0;
|
|
54
|
+
if (prev >= 1) {
|
|
55
|
+
throw new NovelCliError(`Title-fix already attempted for chapter ${args.step.chapter}; manual review required.`, 2);
|
|
56
|
+
}
|
|
57
|
+
updated.title_fix_count = prev + 1;
|
|
58
|
+
const rel = chapterRelPaths(args.step.chapter);
|
|
59
|
+
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
60
|
+
}
|
|
61
|
+
// Hook-fix counts as a bounded micro-revision and invalidates the current eval.
|
|
62
|
+
if (args.step.stage === "hook-fix") {
|
|
63
|
+
const prev = typeof updated.hook_fix_count === "number" ? updated.hook_fix_count : 0;
|
|
64
|
+
if (prev >= 1) {
|
|
65
|
+
throw new NovelCliError(`Hook-fix already attempted for chapter ${args.step.chapter}; manual review required.`, 2);
|
|
66
|
+
}
|
|
67
|
+
updated.hook_fix_count = prev + 1;
|
|
68
|
+
const rel = chapterRelPaths(args.step.chapter);
|
|
69
|
+
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
70
|
+
}
|
|
71
|
+
updated.last_checkpoint_time = new Date().toISOString();
|
|
72
|
+
await writeCheckpoint(args.rootDir, updated);
|
|
73
|
+
return updated;
|
|
74
|
+
});
|
|
75
|
+
}
|