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,370 @@
|
|
|
1
|
+
import { lstat, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fingerprintTextFile, hashText } from "./fingerprint.js";
|
|
4
|
+
import { ensureDir, pathExists, readJsonFile } from "./fs-utils.js";
|
|
5
|
+
import { computeNamingReport } from "./naming-lint.js";
|
|
6
|
+
import { computeReadabilityReport } from "./readability-lint.js";
|
|
7
|
+
import { assertInsideProjectRoot, resolveProjectRelativePath } from "./safe-path.js";
|
|
8
|
+
import { pad3 } from "./steps.js";
|
|
9
|
+
import { computeTitlePolicyReport } from "./title-policy.js";
|
|
10
|
+
import { isPlainObject } from "./type-guards.js";
|
|
11
|
+
const PREJUDGE_GUARDRAILS_STATUSES = ["pass", "warn", "violation", "skipped"];
|
|
12
|
+
const SEVERITY_POLICIES = ["warn", "soft", "hard"];
|
|
13
|
+
const MAX_PREJUDGE_GUARDRAILS_CACHE_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
const MAX_READABILITY_SCRIPT_FINGERPRINT_BYTES = 1024 * 1024;
|
|
15
|
+
function resolveReadabilityScriptRelPath(profile) {
|
|
16
|
+
const fromProfile = profile.compliance.script_paths?.lint_readability;
|
|
17
|
+
if (typeof fromProfile === "string" && fromProfile.trim().length > 0)
|
|
18
|
+
return fromProfile.trim();
|
|
19
|
+
return "scripts/lint-readability.sh";
|
|
20
|
+
}
|
|
21
|
+
async function fingerprintReadabilityScript(args) {
|
|
22
|
+
const rel_path = args.scriptRelPath.trim();
|
|
23
|
+
if (rel_path.length === 0) {
|
|
24
|
+
return { rel_path: args.scriptRelPath, fingerprint: hashText(JSON.stringify({ status: "invalid_path" })) };
|
|
25
|
+
}
|
|
26
|
+
const label = "platform-profile.json.compliance.script_paths.lint_readability";
|
|
27
|
+
let scriptAbs;
|
|
28
|
+
try {
|
|
29
|
+
scriptAbs = resolveProjectRelativePath(args.rootDir, rel_path, label);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "invalid_path" })) };
|
|
33
|
+
}
|
|
34
|
+
if (!(await pathExists(scriptAbs)))
|
|
35
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "missing" })) };
|
|
36
|
+
try {
|
|
37
|
+
const rootReal = await realpath(args.rootDir);
|
|
38
|
+
const execAbs = await realpath(scriptAbs);
|
|
39
|
+
assertInsideProjectRoot(rootReal, execAbs);
|
|
40
|
+
const st = await stat(execAbs);
|
|
41
|
+
if (!st.isFile())
|
|
42
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "not_file" })) };
|
|
43
|
+
if (st.size > MAX_READABILITY_SCRIPT_FINGERPRINT_BYTES) {
|
|
44
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "too_large", size: st.size })) };
|
|
45
|
+
}
|
|
46
|
+
const { fingerprint } = await fingerprintTextFile(execAbs);
|
|
47
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "ok", fingerprint })) };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "unreadable" })) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function fingerprintCharactersActive(rootDir) {
|
|
54
|
+
const rel_path = "characters/active";
|
|
55
|
+
const abs = join(rootDir, rel_path);
|
|
56
|
+
if (!(await pathExists(abs)))
|
|
57
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "missing" })) };
|
|
58
|
+
let rootReal;
|
|
59
|
+
let dirReal;
|
|
60
|
+
let resolvedFromSymlink = false;
|
|
61
|
+
try {
|
|
62
|
+
rootReal = await realpath(rootDir);
|
|
63
|
+
dirReal = await realpath(abs);
|
|
64
|
+
assertInsideProjectRoot(rootReal, dirReal);
|
|
65
|
+
const st = await lstat(abs);
|
|
66
|
+
resolvedFromSymlink = st.isSymbolicLink();
|
|
67
|
+
const resolved = await stat(dirReal);
|
|
68
|
+
if (!resolved.isDirectory())
|
|
69
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "not_dir" })) };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "unreadable" })) };
|
|
73
|
+
}
|
|
74
|
+
let dirents;
|
|
75
|
+
try {
|
|
76
|
+
dirents = await readdir(dirReal, { withFileTypes: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status: "unreadable" })) };
|
|
80
|
+
}
|
|
81
|
+
const files = dirents
|
|
82
|
+
.map((d) => d.name)
|
|
83
|
+
.filter((name) => name.endsWith(".json"))
|
|
84
|
+
.sort((a, b) => a.localeCompare(b, "en"));
|
|
85
|
+
const entries = [];
|
|
86
|
+
for (const name of files) {
|
|
87
|
+
try {
|
|
88
|
+
const st = await lstat(join(dirReal, name));
|
|
89
|
+
const base = { name, size: st.size, mtime_ms: st.mtimeMs };
|
|
90
|
+
if (st.isSymbolicLink())
|
|
91
|
+
entries.push({ ...base, kind: "symlink" });
|
|
92
|
+
else if (st.isFile())
|
|
93
|
+
entries.push({ ...base, kind: "file" });
|
|
94
|
+
else
|
|
95
|
+
entries.push({ ...base, kind: "other" });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
entries.push({ name, kind: "error" });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const status = entries.some((e) => e.kind !== "file") ? "unreadable" : "ok";
|
|
102
|
+
return { rel_path, fingerprint: hashText(JSON.stringify({ status, resolvedFromSymlink, entries })) };
|
|
103
|
+
}
|
|
104
|
+
function isBlockingTitlePolicy(report) {
|
|
105
|
+
if (report.status === "pass" || report.status === "skipped")
|
|
106
|
+
return false;
|
|
107
|
+
if (report.has_hard_violations)
|
|
108
|
+
return true;
|
|
109
|
+
// When title_policy.auto_fix is enabled, warn-only issues are treated as blocking to trigger title-fix.
|
|
110
|
+
return Boolean(report.policy?.auto_fix);
|
|
111
|
+
}
|
|
112
|
+
function isBlockingReadability(report) {
|
|
113
|
+
return report.has_blocking_issues;
|
|
114
|
+
}
|
|
115
|
+
function isBlockingNaming(report) {
|
|
116
|
+
return report.has_blocking_issues;
|
|
117
|
+
}
|
|
118
|
+
export function prejudgeGuardrailsRelPath(chapter) {
|
|
119
|
+
return `staging/guardrails/prejudge-guardrails-chapter-${pad3(chapter)}.json`;
|
|
120
|
+
}
|
|
121
|
+
function fingerprintPlatformProfile(profile) {
|
|
122
|
+
// Note: PlatformProfile is fully derived from platform-profile.json via parsePlatformProfile.
|
|
123
|
+
// JSON.stringify output is deterministic here because we control key insertion order in the parser.
|
|
124
|
+
return hashText(JSON.stringify(profile));
|
|
125
|
+
}
|
|
126
|
+
export async function computePrejudgeGuardrailsReport(args) {
|
|
127
|
+
const generated_at = new Date().toISOString();
|
|
128
|
+
const rootReal = await realpath(args.rootDir);
|
|
129
|
+
const chapterReal = await realpath(args.chapterAbsPath);
|
|
130
|
+
assertInsideProjectRoot(rootReal, chapterReal);
|
|
131
|
+
const { fingerprint: chapter_fingerprint, text: chapterText } = await fingerprintTextFile(chapterReal);
|
|
132
|
+
const [characters_active, readability_script] = await Promise.all([
|
|
133
|
+
fingerprintCharactersActive(args.rootDir),
|
|
134
|
+
fingerprintReadabilityScript({ rootDir: args.rootDir, scriptRelPath: resolveReadabilityScriptRelPath(args.platformProfile) })
|
|
135
|
+
]);
|
|
136
|
+
const title_policy = computeTitlePolicyReport({ chapter: args.chapter, chapterText, platformProfile: args.platformProfile });
|
|
137
|
+
const readability_lint = await computeReadabilityReport({
|
|
138
|
+
rootDir: args.rootDir,
|
|
139
|
+
chapter: args.chapter,
|
|
140
|
+
chapterAbsPath: chapterReal,
|
|
141
|
+
chapterText,
|
|
142
|
+
platformProfile: args.platformProfile,
|
|
143
|
+
preferDeterministicScript: true
|
|
144
|
+
});
|
|
145
|
+
const naming_lint = await computeNamingReport({
|
|
146
|
+
rootDir: args.rootDir,
|
|
147
|
+
chapter: args.chapter,
|
|
148
|
+
chapterText,
|
|
149
|
+
platformProfile: args.platformProfile
|
|
150
|
+
});
|
|
151
|
+
const blocking_reasons = [];
|
|
152
|
+
if (isBlockingTitlePolicy(title_policy))
|
|
153
|
+
blocking_reasons.push("title_policy");
|
|
154
|
+
if (isBlockingReadability(readability_lint))
|
|
155
|
+
blocking_reasons.push("readability_lint");
|
|
156
|
+
if (isBlockingNaming(naming_lint))
|
|
157
|
+
blocking_reasons.push("naming_lint");
|
|
158
|
+
const has_blocking_issues = blocking_reasons.length > 0;
|
|
159
|
+
const hasAnyIssues = title_policy.issues.length + readability_lint.issues.length + naming_lint.issues.length > 0;
|
|
160
|
+
const status = has_blocking_issues ? "violation" : hasAnyIssues ? "warn" : "pass";
|
|
161
|
+
return {
|
|
162
|
+
schema_version: 2,
|
|
163
|
+
generated_at,
|
|
164
|
+
scope: { chapter: args.chapter },
|
|
165
|
+
platform_profile: { rel_path: args.platformProfileRelPath, fingerprint: fingerprintPlatformProfile(args.platformProfile) },
|
|
166
|
+
dependencies: { characters_active, readability_script },
|
|
167
|
+
chapter_fingerprint,
|
|
168
|
+
title_policy,
|
|
169
|
+
readability_lint,
|
|
170
|
+
naming_lint,
|
|
171
|
+
status,
|
|
172
|
+
has_blocking_issues,
|
|
173
|
+
blocking_reasons
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export async function writePrejudgeGuardrailsReport(args) {
|
|
177
|
+
const relPath = prejudgeGuardrailsRelPath(args.chapter);
|
|
178
|
+
const absPath = join(args.rootDir, relPath);
|
|
179
|
+
const rootReal = await realpath(args.rootDir);
|
|
180
|
+
const stagingAbs = join(args.rootDir, "staging");
|
|
181
|
+
const guardrailsAbs = join(args.rootDir, "staging/guardrails");
|
|
182
|
+
if (await pathExists(stagingAbs))
|
|
183
|
+
assertInsideProjectRoot(rootReal, await realpath(stagingAbs));
|
|
184
|
+
if (await pathExists(guardrailsAbs))
|
|
185
|
+
assertInsideProjectRoot(rootReal, await realpath(guardrailsAbs));
|
|
186
|
+
await ensureDir(guardrailsAbs);
|
|
187
|
+
assertInsideProjectRoot(rootReal, await realpath(guardrailsAbs));
|
|
188
|
+
// Atomic write (rename) prevents following a symlink at the destination path.
|
|
189
|
+
const tmpPath = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
190
|
+
await writeFile(tmpPath, `${JSON.stringify(args.report, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
191
|
+
try {
|
|
192
|
+
await rename(tmpPath, absPath);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
await rm(tmpPath, { force: true });
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
return { relPath };
|
|
199
|
+
}
|
|
200
|
+
export async function loadPrejudgeGuardrailsReportIfFresh(args) {
|
|
201
|
+
const rel = prejudgeGuardrailsRelPath(args.chapter);
|
|
202
|
+
const abs = join(args.rootDir, rel);
|
|
203
|
+
if (!(await pathExists(abs)))
|
|
204
|
+
return null;
|
|
205
|
+
let cacheAbs = abs;
|
|
206
|
+
try {
|
|
207
|
+
const rootReal = await realpath(args.rootDir);
|
|
208
|
+
cacheAbs = await realpath(abs);
|
|
209
|
+
assertInsideProjectRoot(rootReal, cacheAbs);
|
|
210
|
+
const st = await stat(cacheAbs);
|
|
211
|
+
if (!st.isFile())
|
|
212
|
+
return null;
|
|
213
|
+
if (st.size > MAX_PREJUDGE_GUARDRAILS_CACHE_BYTES)
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
let raw;
|
|
220
|
+
try {
|
|
221
|
+
raw = await readJsonFile(cacheAbs);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
if (!isPlainObject(raw))
|
|
227
|
+
return null;
|
|
228
|
+
const obj = raw;
|
|
229
|
+
if (obj.schema_version !== 2)
|
|
230
|
+
return null;
|
|
231
|
+
const scopeRaw = obj.scope;
|
|
232
|
+
if (!isPlainObject(scopeRaw))
|
|
233
|
+
return null;
|
|
234
|
+
const scopeObj = scopeRaw;
|
|
235
|
+
if (typeof scopeObj.chapter !== "number" || !Number.isInteger(scopeObj.chapter) || scopeObj.chapter !== args.chapter)
|
|
236
|
+
return null;
|
|
237
|
+
const statusRaw = obj.status;
|
|
238
|
+
if (typeof statusRaw !== "string")
|
|
239
|
+
return null;
|
|
240
|
+
if (!PREJUDGE_GUARDRAILS_STATUSES.includes(statusRaw))
|
|
241
|
+
return null;
|
|
242
|
+
const profileRaw = obj.platform_profile;
|
|
243
|
+
if (!isPlainObject(profileRaw))
|
|
244
|
+
return null;
|
|
245
|
+
const profileObj = profileRaw;
|
|
246
|
+
if (typeof profileObj.rel_path !== "string" || profileObj.rel_path.trim().length === 0)
|
|
247
|
+
return null;
|
|
248
|
+
if (typeof profileObj.fingerprint !== "string" || profileObj.fingerprint.trim().length === 0)
|
|
249
|
+
return null;
|
|
250
|
+
if (profileObj.rel_path !== args.platformProfileRelPath)
|
|
251
|
+
return null;
|
|
252
|
+
if (profileObj.fingerprint !== fingerprintPlatformProfile(args.platformProfile))
|
|
253
|
+
return null;
|
|
254
|
+
const depsRaw = obj.dependencies;
|
|
255
|
+
if (!isPlainObject(depsRaw))
|
|
256
|
+
return null;
|
|
257
|
+
const depsObj = depsRaw;
|
|
258
|
+
const charactersRaw = depsObj.characters_active;
|
|
259
|
+
if (!isPlainObject(charactersRaw))
|
|
260
|
+
return null;
|
|
261
|
+
const charactersObj = charactersRaw;
|
|
262
|
+
if (typeof charactersObj.rel_path !== "string" || charactersObj.rel_path !== "characters/active")
|
|
263
|
+
return null;
|
|
264
|
+
if (typeof charactersObj.fingerprint !== "string" || charactersObj.fingerprint.trim().length === 0)
|
|
265
|
+
return null;
|
|
266
|
+
const scriptRaw = depsObj.readability_script;
|
|
267
|
+
if (!isPlainObject(scriptRaw))
|
|
268
|
+
return null;
|
|
269
|
+
const scriptObj = scriptRaw;
|
|
270
|
+
if (typeof scriptObj.rel_path !== "string" || scriptObj.rel_path.trim().length === 0)
|
|
271
|
+
return null;
|
|
272
|
+
if (typeof scriptObj.fingerprint !== "string" || scriptObj.fingerprint.trim().length === 0)
|
|
273
|
+
return null;
|
|
274
|
+
const fpRaw = obj.chapter_fingerprint;
|
|
275
|
+
if (!isPlainObject(fpRaw))
|
|
276
|
+
return null;
|
|
277
|
+
const fp = fpRaw;
|
|
278
|
+
if (typeof fp.size !== "number" || typeof fp.mtime_ms !== "number" || typeof fp.content_hash !== "string")
|
|
279
|
+
return null;
|
|
280
|
+
if (typeof obj.has_blocking_issues !== "boolean")
|
|
281
|
+
return null;
|
|
282
|
+
if (!Array.isArray(obj.blocking_reasons) || !obj.blocking_reasons.every((v) => typeof v === "string"))
|
|
283
|
+
return null;
|
|
284
|
+
const titleRaw = obj.title_policy;
|
|
285
|
+
if (!isPlainObject(titleRaw))
|
|
286
|
+
return null;
|
|
287
|
+
const titleObj = titleRaw;
|
|
288
|
+
if (titleObj.schema_version !== 1)
|
|
289
|
+
return null;
|
|
290
|
+
if (typeof titleObj.status !== "string")
|
|
291
|
+
return null;
|
|
292
|
+
if (!Array.isArray(titleObj.issues))
|
|
293
|
+
return null;
|
|
294
|
+
if (typeof titleObj.has_hard_violations !== "boolean")
|
|
295
|
+
return null;
|
|
296
|
+
const readability = obj.readability_lint;
|
|
297
|
+
if (!isPlainObject(readability))
|
|
298
|
+
return null;
|
|
299
|
+
const readabilityObj = readability;
|
|
300
|
+
if (readabilityObj.schema_version !== 1)
|
|
301
|
+
return null;
|
|
302
|
+
if (typeof readabilityObj.has_blocking_issues !== "boolean")
|
|
303
|
+
return null;
|
|
304
|
+
if (!Array.isArray(readabilityObj.issues))
|
|
305
|
+
return null;
|
|
306
|
+
if (!readabilityObj.issues.every((it) => {
|
|
307
|
+
if (!isPlainObject(it))
|
|
308
|
+
return false;
|
|
309
|
+
const rec = it;
|
|
310
|
+
if (typeof rec.id !== "string" || rec.id.trim().length === 0)
|
|
311
|
+
return false;
|
|
312
|
+
if (typeof rec.summary !== "string" || rec.summary.trim().length === 0)
|
|
313
|
+
return false;
|
|
314
|
+
if (typeof rec.severity !== "string" || !SEVERITY_POLICIES.includes(rec.severity))
|
|
315
|
+
return false;
|
|
316
|
+
return true;
|
|
317
|
+
}))
|
|
318
|
+
return null;
|
|
319
|
+
if (typeof readabilityObj.status !== "string")
|
|
320
|
+
return null;
|
|
321
|
+
const naming = obj.naming_lint;
|
|
322
|
+
if (!isPlainObject(naming))
|
|
323
|
+
return null;
|
|
324
|
+
const namingObj = naming;
|
|
325
|
+
if (namingObj.schema_version !== 1)
|
|
326
|
+
return null;
|
|
327
|
+
if (typeof namingObj.has_blocking_issues !== "boolean")
|
|
328
|
+
return null;
|
|
329
|
+
if (!Array.isArray(namingObj.issues))
|
|
330
|
+
return null;
|
|
331
|
+
if (!namingObj.issues.every((it) => {
|
|
332
|
+
if (!isPlainObject(it))
|
|
333
|
+
return false;
|
|
334
|
+
const rec = it;
|
|
335
|
+
if (typeof rec.id !== "string" || rec.id.trim().length === 0)
|
|
336
|
+
return false;
|
|
337
|
+
if (typeof rec.summary !== "string" || rec.summary.trim().length === 0)
|
|
338
|
+
return false;
|
|
339
|
+
if (typeof rec.severity !== "string" || !SEVERITY_POLICIES.includes(rec.severity))
|
|
340
|
+
return false;
|
|
341
|
+
return true;
|
|
342
|
+
}))
|
|
343
|
+
return null;
|
|
344
|
+
if (typeof namingObj.status !== "string")
|
|
345
|
+
return null;
|
|
346
|
+
const [currentCharacters, currentScript] = await Promise.all([
|
|
347
|
+
fingerprintCharactersActive(args.rootDir),
|
|
348
|
+
fingerprintReadabilityScript({ rootDir: args.rootDir, scriptRelPath: resolveReadabilityScriptRelPath(args.platformProfile) })
|
|
349
|
+
]);
|
|
350
|
+
if (charactersObj.fingerprint !== currentCharacters.fingerprint)
|
|
351
|
+
return null;
|
|
352
|
+
if (scriptObj.rel_path !== currentScript.rel_path)
|
|
353
|
+
return null;
|
|
354
|
+
if (scriptObj.fingerprint !== currentScript.fingerprint)
|
|
355
|
+
return null;
|
|
356
|
+
let now;
|
|
357
|
+
try {
|
|
358
|
+
const rootReal = await realpath(args.rootDir);
|
|
359
|
+
const chapterReal = await realpath(args.chapterAbsPath);
|
|
360
|
+
assertInsideProjectRoot(rootReal, chapterReal);
|
|
361
|
+
({ fingerprint: now } = await fingerprintTextFile(chapterReal));
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const fresh = now.size === fp.size && now.mtime_ms === fp.mtime_ms && now.content_hash === fp.content_hash;
|
|
367
|
+
if (!fresh)
|
|
368
|
+
return null;
|
|
369
|
+
return raw;
|
|
370
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { NovelCliError } from "./errors.js";
|
|
4
|
+
import { pathExists } from "./fs-utils.js";
|
|
5
|
+
import { rejectPathTraversalInput } from "./safe-path.js";
|
|
6
|
+
async function isDirectory(path) {
|
|
7
|
+
try {
|
|
8
|
+
const s = await stat(path);
|
|
9
|
+
return s.isDirectory();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function resolveProjectRoot(args) {
|
|
16
|
+
const cwdAbs = resolve(args.cwd);
|
|
17
|
+
if (args.projectOverride) {
|
|
18
|
+
rejectPathTraversalInput(args.projectOverride, "--project");
|
|
19
|
+
const candidate = resolve(cwdAbs, args.projectOverride);
|
|
20
|
+
if (!(await isDirectory(candidate))) {
|
|
21
|
+
throw new NovelCliError(`Project root is not a directory: ${candidate}`, 2);
|
|
22
|
+
}
|
|
23
|
+
const checkpoint = join(candidate, ".checkpoint.json");
|
|
24
|
+
if (!(await pathExists(checkpoint))) {
|
|
25
|
+
throw new NovelCliError(`No .checkpoint.json found under --project: ${candidate}`, 2);
|
|
26
|
+
}
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
let dir = cwdAbs;
|
|
30
|
+
while (true) {
|
|
31
|
+
const checkpoint = join(dir, ".checkpoint.json");
|
|
32
|
+
if (await pathExists(checkpoint))
|
|
33
|
+
return dir;
|
|
34
|
+
const parent = dirname(dir);
|
|
35
|
+
if (parent === dir)
|
|
36
|
+
break;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
throw new NovelCliError(`No project root found (missing .checkpoint.json). Use --project <dir>.`, 2);
|
|
40
|
+
}
|