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,274 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { parsePlatformProfile } from "../platform-profile.js";
|
|
5
|
+
function makeBaseRaw() {
|
|
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: true, 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
|
+
};
|
|
16
|
+
}
|
|
17
|
+
test("parsePlatformProfile loads legacy profile without retention/readability/naming", () => {
|
|
18
|
+
const raw = makeBaseRaw();
|
|
19
|
+
const profile = parsePlatformProfile(raw, "platform-profile.json");
|
|
20
|
+
assert.equal(Object.prototype.hasOwnProperty.call(profile, "retention"), false);
|
|
21
|
+
assert.equal(Object.prototype.hasOwnProperty.call(profile, "readability"), false);
|
|
22
|
+
assert.equal(Object.prototype.hasOwnProperty.call(profile, "naming"), false);
|
|
23
|
+
});
|
|
24
|
+
test("parsePlatformProfile accepts explicit null retention/readability/naming", () => {
|
|
25
|
+
const raw = {
|
|
26
|
+
...makeBaseRaw(),
|
|
27
|
+
retention: null,
|
|
28
|
+
readability: null,
|
|
29
|
+
naming: null
|
|
30
|
+
};
|
|
31
|
+
const profile = parsePlatformProfile(raw, "platform-profile.json");
|
|
32
|
+
assert.equal(profile.retention, null);
|
|
33
|
+
assert.equal(profile.readability, null);
|
|
34
|
+
assert.equal(profile.naming, null);
|
|
35
|
+
});
|
|
36
|
+
test("parsePlatformProfile loads extended profile with retention/readability/naming", () => {
|
|
37
|
+
const raw = {
|
|
38
|
+
...makeBaseRaw(),
|
|
39
|
+
retention: {
|
|
40
|
+
title_policy: { enabled: true, min_chars: 2, max_chars: 30, forbidden_patterns: [], auto_fix: false },
|
|
41
|
+
hook_ledger: {
|
|
42
|
+
enabled: true,
|
|
43
|
+
fulfillment_window_chapters: 12,
|
|
44
|
+
diversity_window_chapters: 5,
|
|
45
|
+
max_same_type_streak: 2,
|
|
46
|
+
min_distinct_types_in_window: 2,
|
|
47
|
+
overdue_policy: "warn"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
readability: { mobile: { enabled: true, max_paragraph_chars: 320, max_consecutive_exposition_paragraphs: 3, blocking_severity: "hard_only" } },
|
|
51
|
+
naming: { enabled: true, near_duplicate_threshold: 0.88, blocking_conflict_types: ["duplicate"], exemptions: {} }
|
|
52
|
+
};
|
|
53
|
+
const profile = parsePlatformProfile(raw, "platform-profile.json");
|
|
54
|
+
assert.ok(profile.retention);
|
|
55
|
+
assert.equal(profile.retention?.hook_ledger.overdue_policy, "warn");
|
|
56
|
+
assert.equal(profile.readability?.mobile.blocking_severity, "hard_only");
|
|
57
|
+
assert.deepEqual(profile.naming?.blocking_conflict_types, ["duplicate"]);
|
|
58
|
+
});
|
|
59
|
+
test("parsePlatformProfile rejects unknown naming conflict types", () => {
|
|
60
|
+
const raw = {
|
|
61
|
+
...makeBaseRaw(),
|
|
62
|
+
naming: { enabled: true, near_duplicate_threshold: 0.5, blocking_conflict_types: ["typo"] }
|
|
63
|
+
};
|
|
64
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /blocking_conflict_types.*unknown type/i);
|
|
65
|
+
});
|
|
66
|
+
test("parsePlatformProfile rejects naming.near_duplicate_threshold > 1", () => {
|
|
67
|
+
const raw = {
|
|
68
|
+
...makeBaseRaw(),
|
|
69
|
+
naming: { enabled: true, near_duplicate_threshold: 1.01, blocking_conflict_types: ["near_duplicate"] }
|
|
70
|
+
};
|
|
71
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /near_duplicate_threshold.*<= 1/i);
|
|
72
|
+
});
|
|
73
|
+
test("parsePlatformProfile rejects naming.near_duplicate_threshold when negative", () => {
|
|
74
|
+
const raw = {
|
|
75
|
+
...makeBaseRaw(),
|
|
76
|
+
naming: { enabled: true, near_duplicate_threshold: -0.1, blocking_conflict_types: ["near_duplicate"] }
|
|
77
|
+
};
|
|
78
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /near_duplicate_threshold.*finite number/i);
|
|
79
|
+
});
|
|
80
|
+
test("parsePlatformProfile rejects naming.near_duplicate_threshold when non-finite", () => {
|
|
81
|
+
const raw = {
|
|
82
|
+
...makeBaseRaw(),
|
|
83
|
+
naming: { enabled: true, near_duplicate_threshold: Number.POSITIVE_INFINITY, blocking_conflict_types: ["near_duplicate"] }
|
|
84
|
+
};
|
|
85
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /near_duplicate_threshold.*finite number/i);
|
|
86
|
+
});
|
|
87
|
+
test("parsePlatformProfile rejects naming.blocking_conflict_types when contains non-string items", () => {
|
|
88
|
+
const raw = {
|
|
89
|
+
...makeBaseRaw(),
|
|
90
|
+
naming: { enabled: true, near_duplicate_threshold: 0.5, blocking_conflict_types: [123] }
|
|
91
|
+
};
|
|
92
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /blocking_conflict_types.*string array/i);
|
|
93
|
+
});
|
|
94
|
+
test("parsePlatformProfile rejects naming.exemptions when non-object", () => {
|
|
95
|
+
const raw = {
|
|
96
|
+
...makeBaseRaw(),
|
|
97
|
+
naming: { enabled: true, near_duplicate_threshold: 0.5, blocking_conflict_types: ["duplicate"], exemptions: "foo" }
|
|
98
|
+
};
|
|
99
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /exemptions.*object/i);
|
|
100
|
+
});
|
|
101
|
+
test("parsePlatformProfile rejects invalid retention.title_policy regex patterns", () => {
|
|
102
|
+
const raw = {
|
|
103
|
+
...makeBaseRaw(),
|
|
104
|
+
retention: {
|
|
105
|
+
title_policy: { enabled: true, min_chars: 2, max_chars: 30, forbidden_patterns: ["("], auto_fix: false },
|
|
106
|
+
hook_ledger: {
|
|
107
|
+
enabled: true,
|
|
108
|
+
fulfillment_window_chapters: 12,
|
|
109
|
+
diversity_window_chapters: 5,
|
|
110
|
+
max_same_type_streak: 2,
|
|
111
|
+
min_distinct_types_in_window: 2,
|
|
112
|
+
overdue_policy: "warn"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /forbidden_patterns\[0\].*regex/i);
|
|
117
|
+
});
|
|
118
|
+
test("parsePlatformProfile rejects invalid retention.title_policy required_patterns regex patterns", () => {
|
|
119
|
+
const raw = {
|
|
120
|
+
...makeBaseRaw(),
|
|
121
|
+
retention: {
|
|
122
|
+
title_policy: {
|
|
123
|
+
enabled: true,
|
|
124
|
+
min_chars: 2,
|
|
125
|
+
max_chars: 30,
|
|
126
|
+
forbidden_patterns: [],
|
|
127
|
+
required_patterns: ["("],
|
|
128
|
+
auto_fix: false
|
|
129
|
+
},
|
|
130
|
+
hook_ledger: {
|
|
131
|
+
enabled: true,
|
|
132
|
+
fulfillment_window_chapters: 12,
|
|
133
|
+
diversity_window_chapters: 5,
|
|
134
|
+
max_same_type_streak: 2,
|
|
135
|
+
min_distinct_types_in_window: 2,
|
|
136
|
+
overdue_policy: "warn"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /required_patterns\[0\].*regex/i);
|
|
141
|
+
});
|
|
142
|
+
test("parsePlatformProfile rejects retention.title_policy min_chars > max_chars", () => {
|
|
143
|
+
const raw = {
|
|
144
|
+
...makeBaseRaw(),
|
|
145
|
+
retention: {
|
|
146
|
+
title_policy: { enabled: true, min_chars: 10, max_chars: 5, forbidden_patterns: [], auto_fix: false },
|
|
147
|
+
hook_ledger: {
|
|
148
|
+
enabled: true,
|
|
149
|
+
fulfillment_window_chapters: 12,
|
|
150
|
+
diversity_window_chapters: 5,
|
|
151
|
+
max_same_type_streak: 2,
|
|
152
|
+
min_distinct_types_in_window: 2,
|
|
153
|
+
overdue_policy: "warn"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /min_chars.*<=.*max_chars/i);
|
|
158
|
+
});
|
|
159
|
+
test("parsePlatformProfile rejects retention.title_policy min_chars when float", () => {
|
|
160
|
+
const raw = {
|
|
161
|
+
...makeBaseRaw(),
|
|
162
|
+
retention: {
|
|
163
|
+
title_policy: { enabled: true, min_chars: 2.5, max_chars: 30, forbidden_patterns: [], auto_fix: false },
|
|
164
|
+
hook_ledger: {
|
|
165
|
+
enabled: true,
|
|
166
|
+
fulfillment_window_chapters: 12,
|
|
167
|
+
diversity_window_chapters: 5,
|
|
168
|
+
max_same_type_streak: 2,
|
|
169
|
+
min_distinct_types_in_window: 2,
|
|
170
|
+
overdue_policy: "warn"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /min_chars.*int/i);
|
|
175
|
+
});
|
|
176
|
+
test("parsePlatformProfile rejects retention when non-object", () => {
|
|
177
|
+
const raw = { ...makeBaseRaw(), retention: 42 };
|
|
178
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /'retention'.*object/i);
|
|
179
|
+
});
|
|
180
|
+
test("parsePlatformProfile rejects retention.hook_ledger overdue_policy when unknown", () => {
|
|
181
|
+
const raw = {
|
|
182
|
+
...makeBaseRaw(),
|
|
183
|
+
retention: {
|
|
184
|
+
title_policy: { enabled: true, min_chars: 2, max_chars: 30, forbidden_patterns: [], auto_fix: false },
|
|
185
|
+
hook_ledger: {
|
|
186
|
+
enabled: true,
|
|
187
|
+
fulfillment_window_chapters: 12,
|
|
188
|
+
diversity_window_chapters: 5,
|
|
189
|
+
max_same_type_streak: 2,
|
|
190
|
+
min_distinct_types_in_window: 2,
|
|
191
|
+
overdue_policy: "block"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /overdue_policy.*warn, soft, hard/i);
|
|
196
|
+
});
|
|
197
|
+
test("parsePlatformProfile rejects invalid readability.mobile blocking_severity", () => {
|
|
198
|
+
const raw = {
|
|
199
|
+
...makeBaseRaw(),
|
|
200
|
+
readability: {
|
|
201
|
+
mobile: {
|
|
202
|
+
enabled: true,
|
|
203
|
+
max_paragraph_chars: 320,
|
|
204
|
+
max_consecutive_exposition_paragraphs: 3,
|
|
205
|
+
blocking_severity: "warn"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /blocking_severity.*hard_only/i);
|
|
210
|
+
});
|
|
211
|
+
test("parsePlatformProfile rejects readability.mobile max_paragraph_chars = 0", () => {
|
|
212
|
+
const raw = {
|
|
213
|
+
...makeBaseRaw(),
|
|
214
|
+
readability: {
|
|
215
|
+
mobile: {
|
|
216
|
+
enabled: true,
|
|
217
|
+
max_paragraph_chars: 0,
|
|
218
|
+
max_consecutive_exposition_paragraphs: 3,
|
|
219
|
+
blocking_severity: "hard_only"
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /max_paragraph_chars.*>= 1/i);
|
|
224
|
+
});
|
|
225
|
+
test("parsePlatformProfile rejects readability.mobile max_consecutive_exposition_paragraphs = 0", () => {
|
|
226
|
+
const raw = {
|
|
227
|
+
...makeBaseRaw(),
|
|
228
|
+
readability: {
|
|
229
|
+
mobile: {
|
|
230
|
+
enabled: true,
|
|
231
|
+
max_paragraph_chars: 320,
|
|
232
|
+
max_consecutive_exposition_paragraphs: 0,
|
|
233
|
+
blocking_severity: "hard_only"
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /max_consecutive_exposition_paragraphs.*>= 1/i);
|
|
238
|
+
});
|
|
239
|
+
test("parsePlatformProfile rejects non-boolean values for enabled fields", () => {
|
|
240
|
+
const raw = {
|
|
241
|
+
...makeBaseRaw(),
|
|
242
|
+
naming: { enabled: "true", near_duplicate_threshold: 0.5, blocking_conflict_types: ["duplicate"] }
|
|
243
|
+
};
|
|
244
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /'naming\.enabled'.*boolean/i);
|
|
245
|
+
});
|
|
246
|
+
test("parsePlatformProfile rejects readability when non-object", () => {
|
|
247
|
+
const raw = { ...makeBaseRaw(), readability: 42 };
|
|
248
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /'readability'.*object/i);
|
|
249
|
+
});
|
|
250
|
+
test("parsePlatformProfile rejects naming when non-object", () => {
|
|
251
|
+
const raw = { ...makeBaseRaw(), naming: 42 };
|
|
252
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /'naming'.*object/i);
|
|
253
|
+
});
|
|
254
|
+
test("parsePlatformProfile rejects hook_policy when non-object", () => {
|
|
255
|
+
const raw = { ...makeBaseRaw(), hook_policy: 42 };
|
|
256
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /'hook_policy'.*object/i);
|
|
257
|
+
});
|
|
258
|
+
test("parsePlatformProfile rejects scoring when non-object", () => {
|
|
259
|
+
const raw = { ...makeBaseRaw(), scoring: 42 };
|
|
260
|
+
assert.throws(() => parsePlatformProfile(raw, "platform-profile.json"), /'scoring'.*object/i);
|
|
261
|
+
});
|
|
262
|
+
test("parsePlatformProfile rejects non-object raw input", () => {
|
|
263
|
+
assert.throws(() => parsePlatformProfile(null, "platform-profile.json"), /expected a JSON object/i);
|
|
264
|
+
});
|
|
265
|
+
test("templates/platform-profile.json defaults parse as valid platform profiles", async () => {
|
|
266
|
+
const raw = JSON.parse(await readFile("templates/platform-profile.json", "utf8"));
|
|
267
|
+
assert.ok(raw.defaults, "expected templates/platform-profile.json to have defaults");
|
|
268
|
+
for (const [platform, profileRaw] of Object.entries(raw.defaults)) {
|
|
269
|
+
const profile = parsePlatformProfile(profileRaw, `templates/platform-profile.json#defaults.${platform}`);
|
|
270
|
+
assert.ok(profile.retention, `expected defaults.${platform}.retention to be present`);
|
|
271
|
+
assert.ok(profile.readability, `expected defaults.${platform}.readability to be present`);
|
|
272
|
+
assert.ok(profile.naming, `expected defaults.${platform}.naming to be present`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { buildPromiseLedgerSeed, computePromiseLedgerReport, loadPromiseLedger, summarizePromiseLedgerReport, writePromiseLedgerLogs } from "../promise-ledger.js";
|
|
7
|
+
async function writeText(absPath, contents) {
|
|
8
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
9
|
+
await writeFile(absPath, contents, "utf8");
|
|
10
|
+
}
|
|
11
|
+
test("loadPromiseLedger returns an empty ledger when promise-ledger.json is missing", async () => {
|
|
12
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-promise-ledger-"));
|
|
13
|
+
const loaded = await loadPromiseLedger(rootDir);
|
|
14
|
+
assert.equal(loaded.ledger.schema_version, 1);
|
|
15
|
+
assert.equal(loaded.ledger.entries.length, 0);
|
|
16
|
+
assert.equal(loaded.ledger.policy?.dormancy_threshold_chapters, 12);
|
|
17
|
+
});
|
|
18
|
+
test("buildPromiseLedgerSeed extracts candidates from brief/outline/summaries", async () => {
|
|
19
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-promise-ledger-seed-"));
|
|
20
|
+
await writeText(join(rootDir, "brief.md"), `# Brief\n\n## 卖点\n- 主角每次突破都会引来异象\n\n## 核心谜团\n- 主角身世之谜\n\n## 关系弧\n- 主角与师姐从敌对到信任\n`);
|
|
21
|
+
await writeText(join(rootDir, "volumes/vol-01/outline.md"), `# 卷大纲\n\n## 机制\n- 系统有代价:使用会消耗寿命\n`);
|
|
22
|
+
await writeText(join(rootDir, "summaries/chapter-001-summary.md"), `## 第 1 章摘要\n\n### 关键事件\n- 主角触发系统提示,发现寿命消耗\n- 与师姐首次冲突\n`);
|
|
23
|
+
const seeded = await buildPromiseLedgerSeed({ rootDir, volume: 1, maxRecentSummaries: 5 });
|
|
24
|
+
assert.ok(seeded.ledger.entries.length >= 4);
|
|
25
|
+
const texts = seeded.ledger.entries.map((e) => e.promise_text);
|
|
26
|
+
assert.ok(texts.includes("主角身世之谜"));
|
|
27
|
+
assert.ok(texts.includes("系统有代价:使用会消耗寿命"));
|
|
28
|
+
const mystery = seeded.ledger.entries.find((e) => e.promise_text === "主角身世之谜");
|
|
29
|
+
assert.equal(mystery?.type, "core_mystery");
|
|
30
|
+
const mechanism = seeded.ledger.entries.find((e) => e.promise_text === "系统有代价:使用会消耗寿命");
|
|
31
|
+
assert.equal(mechanism?.type, "mechanism");
|
|
32
|
+
});
|
|
33
|
+
test("buildPromiseLedgerSeed avoids overmatching headings and supports uppercase CP", async () => {
|
|
34
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-promise-ledger-seed-headings-"));
|
|
35
|
+
await writeText(join(rootDir, "brief.md"), `# Brief\n\n## 关键点\n- 主角身世之谜\n\n## CP线\n- 主角与师姐从敌对到信任\n`);
|
|
36
|
+
const seeded = await buildPromiseLedgerSeed({ rootDir, volume: 1, maxRecentSummaries: 0 });
|
|
37
|
+
const mystery = seeded.ledger.entries.find((e) => e.promise_text === "主角身世之谜");
|
|
38
|
+
assert.equal(mystery?.type, "core_mystery");
|
|
39
|
+
const rel = seeded.ledger.entries.find((e) => e.promise_text === "主角与师姐从敌对到信任");
|
|
40
|
+
assert.equal(rel?.type, "relationship_arc");
|
|
41
|
+
});
|
|
42
|
+
test("loadPromiseLedger warns and defaults invalid status to promised", async () => {
|
|
43
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-promise-ledger-bad-status-"));
|
|
44
|
+
await writeText(join(rootDir, "promise-ledger.json"), `${JSON.stringify({
|
|
45
|
+
schema_version: 1,
|
|
46
|
+
entries: [
|
|
47
|
+
{
|
|
48
|
+
id: "promise:p001",
|
|
49
|
+
type: "core_mystery",
|
|
50
|
+
promise_text: "主角身世之谜",
|
|
51
|
+
status: "oops",
|
|
52
|
+
introduced_chapter: 1,
|
|
53
|
+
last_touched_chapter: 1
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}, null, 2)}\n`);
|
|
57
|
+
const loaded = await loadPromiseLedger(rootDir);
|
|
58
|
+
assert.equal(loaded.ledger.entries[0]?.status, "promised");
|
|
59
|
+
assert.ok(loaded.warnings.some((w) => w.includes("invalid 'status'")));
|
|
60
|
+
});
|
|
61
|
+
test("computePromiseLedgerReport surfaces dormant promises and suggestions", async () => {
|
|
62
|
+
const ledger = {
|
|
63
|
+
$schema: "schemas/promise-ledger.schema.json",
|
|
64
|
+
schema_version: 1,
|
|
65
|
+
policy: { dormancy_threshold_chapters: 3 },
|
|
66
|
+
entries: [
|
|
67
|
+
{
|
|
68
|
+
id: "promise:p001",
|
|
69
|
+
type: "core_mystery",
|
|
70
|
+
promise_text: "主角身世之谜",
|
|
71
|
+
status: "promised",
|
|
72
|
+
introduced_chapter: 1,
|
|
73
|
+
last_touched_chapter: 1
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "promise:p002",
|
|
77
|
+
type: "selling_point",
|
|
78
|
+
promise_text: "修炼异象机制",
|
|
79
|
+
status: "advanced",
|
|
80
|
+
introduced_chapter: 1,
|
|
81
|
+
last_touched_chapter: 9
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "promise:p003",
|
|
85
|
+
type: "relationship_arc",
|
|
86
|
+
promise_text: "主角与师姐关系弧",
|
|
87
|
+
status: "delivered",
|
|
88
|
+
introduced_chapter: 1,
|
|
89
|
+
last_touched_chapter: 2,
|
|
90
|
+
delivered_chapter: 2
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
};
|
|
94
|
+
const report = computePromiseLedgerReport({ ledger, asOfChapter: 6, volume: 1, chapterRange: { start: 1, end: 6 } });
|
|
95
|
+
assert.equal(report.schema_version, 1);
|
|
96
|
+
assert.equal(report.stats.total_promises, 3);
|
|
97
|
+
assert.equal(report.stats.dormant_total, 1);
|
|
98
|
+
assert.equal(report.dormant_promises[0]?.id, "promise:p001");
|
|
99
|
+
assert.ok(report.dormant_promises[0]?.suggestion.length > 0);
|
|
100
|
+
});
|
|
101
|
+
test("writePromiseLedgerLogs keeps latest.json monotonic by chapter (and generated_at tie-break)", async () => {
|
|
102
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-promise-ledger-log-mono-"));
|
|
103
|
+
const mkReport = (chapter, generated_at) => ({
|
|
104
|
+
schema_version: 1,
|
|
105
|
+
generated_at,
|
|
106
|
+
as_of: { chapter, volume: 1 },
|
|
107
|
+
scope: { volume: 1, chapter_start: Math.max(1, chapter - 9), chapter_end: chapter },
|
|
108
|
+
ledger_path: "promise-ledger.json",
|
|
109
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
110
|
+
stats: {
|
|
111
|
+
total_promises: 0,
|
|
112
|
+
promised_total: 0,
|
|
113
|
+
advanced_total: 0,
|
|
114
|
+
delivered_total: 0,
|
|
115
|
+
open_total: 0,
|
|
116
|
+
dormant_total: 0
|
|
117
|
+
},
|
|
118
|
+
dormant_promises: [],
|
|
119
|
+
issues: [],
|
|
120
|
+
has_blocking_issues: false
|
|
121
|
+
});
|
|
122
|
+
await writePromiseLedgerLogs({ rootDir, report: mkReport(5, "2026-01-01T00:00:00.000Z"), historyRange: null });
|
|
123
|
+
await writePromiseLedgerLogs({ rootDir, report: mkReport(4, "2026-01-02T00:00:00.000Z"), historyRange: null });
|
|
124
|
+
const latestAbs = join(rootDir, "logs", "promises", "latest.json");
|
|
125
|
+
const raw = JSON.parse(await readFile(latestAbs, "utf8"));
|
|
126
|
+
assert.equal(raw.as_of.chapter, 5);
|
|
127
|
+
await writePromiseLedgerLogs({ rootDir, report: mkReport(5, "2025-01-01T00:00:00.000Z"), historyRange: null });
|
|
128
|
+
const raw2 = JSON.parse(await readFile(latestAbs, "utf8"));
|
|
129
|
+
assert.equal(raw2.generated_at, "2026-01-01T00:00:00.000Z");
|
|
130
|
+
await writePromiseLedgerLogs({ rootDir, report: mkReport(5, "2027-01-01T00:00:00.000Z"), historyRange: null });
|
|
131
|
+
const raw3 = JSON.parse(await readFile(latestAbs, "utf8"));
|
|
132
|
+
assert.equal(raw3.generated_at, "2027-01-01T00:00:00.000Z");
|
|
133
|
+
});
|
|
134
|
+
test("writePromiseLedgerLogs writes latest + history when requested", async () => {
|
|
135
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-promise-ledger-logs-"));
|
|
136
|
+
const ledger = { $schema: "schemas/promise-ledger.schema.json", schema_version: 1, entries: [] };
|
|
137
|
+
const report = computePromiseLedgerReport({ ledger, asOfChapter: 10, volume: 2, chapterRange: { start: 1, end: 10 } });
|
|
138
|
+
const written = await writePromiseLedgerLogs({ rootDir, report, historyRange: { start: 1, end: 10 } });
|
|
139
|
+
assert.equal(written.latestRel, "logs/promises/latest.json");
|
|
140
|
+
assert.equal(written.historyRel, "logs/promises/promise-ledger-report-vol-02-ch001-ch010.json");
|
|
141
|
+
const latestRaw = JSON.parse(await readFile(join(rootDir, written.latestRel), "utf8"));
|
|
142
|
+
assert.equal(latestRaw.schema_version, 1);
|
|
143
|
+
const historyRaw = JSON.parse(await readFile(join(rootDir, written.historyRel), "utf8"));
|
|
144
|
+
assert.equal(historyRaw.schema_version, 1);
|
|
145
|
+
});
|
|
146
|
+
test("summarizePromiseLedgerReport caps lists and truncates surrogate-pair-safe", () => {
|
|
147
|
+
const longPromiseText = `${"x".repeat(158)}😀${"y".repeat(30)}`;
|
|
148
|
+
const raw = {
|
|
149
|
+
schema_version: 1,
|
|
150
|
+
as_of: { chapter: 10, volume: 1 },
|
|
151
|
+
scope: { volume: 1, chapter_start: 1, chapter_end: 10 },
|
|
152
|
+
ledger_path: "promise-ledger.json",
|
|
153
|
+
policy: { dormancy_threshold_chapters: 12 },
|
|
154
|
+
stats: {
|
|
155
|
+
total_promises: 2,
|
|
156
|
+
promised_total: 2,
|
|
157
|
+
advanced_total: 0,
|
|
158
|
+
delivered_total: 0,
|
|
159
|
+
open_total: 2,
|
|
160
|
+
dormant_total: 1
|
|
161
|
+
},
|
|
162
|
+
dormant_promises: Array.from({ length: 7 }).map((_, i) => ({
|
|
163
|
+
id: `promise.${i + 1}`,
|
|
164
|
+
type: "core_mystery",
|
|
165
|
+
promise_text: i === 0 ? ` ${longPromiseText} ` : `承诺 ${i + 1}`,
|
|
166
|
+
status: "promised",
|
|
167
|
+
chapters_since_last_touch: i,
|
|
168
|
+
suggestion: "轻触谜团:加入一个微小线索(不要揭示答案)。"
|
|
169
|
+
})),
|
|
170
|
+
issues: Array.from({ length: 7 }).map((_, i) => ({
|
|
171
|
+
id: `promise_ledger.issue.${i + 1}`,
|
|
172
|
+
severity: "warn",
|
|
173
|
+
summary: `Issue ${i + 1}`,
|
|
174
|
+
suggestion: "Keep promises alive with light-touch reminders."
|
|
175
|
+
})),
|
|
176
|
+
has_blocking_issues: false
|
|
177
|
+
};
|
|
178
|
+
const summary = summarizePromiseLedgerReport(raw);
|
|
179
|
+
assert.equal(summary.as_of.chapter, 10);
|
|
180
|
+
assert.equal(summary.stats.total_promises, 2);
|
|
181
|
+
assert.equal(summary.has_blocking_issues, false);
|
|
182
|
+
assert.equal(summary.dormant_promises.length, 5);
|
|
183
|
+
assert.equal(summary.issues.length, 5);
|
|
184
|
+
const truncated = String(summary.dormant_promises[0]?.promise_text ?? "");
|
|
185
|
+
assert.ok(truncated.endsWith("…"));
|
|
186
|
+
const lastBeforeEllipsis = truncated.charCodeAt(Math.max(0, truncated.length - 2));
|
|
187
|
+
assert.ok(lastBeforeEllipsis < 0xd800 || lastBeforeEllipsis > 0xdbff);
|
|
188
|
+
assert.equal(summarizePromiseLedgerReport({ schema_version: 2 }), null);
|
|
189
|
+
});
|