@cloud411716/fancy-webnovel 2.2.0 → 3.0.0
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/client.js +201 -15
- package/cordis.patch.yml +5 -0
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +11 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +29 -4
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +143 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* append-block.js —— 扫榜报告块的**校验 + 追加**(写目标文件的唯一入口)
|
|
4
|
+
*
|
|
5
|
+
* ## 为什么把校验搬到这里(2026-10-29)
|
|
6
|
+
*
|
|
7
|
+
* 原来校验写在提示词里(5.1 的一堆 grep + 5.3 的一段 python),靠模型自觉执行。三个问题:
|
|
8
|
+
*
|
|
9
|
+
* 1. **可以跳过** —— 模型不跑那段命令,报告照样交差,用户看到的是"写成功了";
|
|
10
|
+
* 2. **口径会漂** —— 提示词里的正则和插件里的判定各写一遍,改一处就对不上;
|
|
11
|
+
* 3. **失败会污染文件** —— 先 `cat >>` 再校验,校验不过时坏内容已经落盘,
|
|
12
|
+
* 而"追加"是只增不减的,脏块永远留在报告里。
|
|
13
|
+
*
|
|
14
|
+
* 现在:**写目标文件只有这一条路**。
|
|
15
|
+
*
|
|
16
|
+
* 1. **预检** —— 目标可读可写、块标记不存在(不能写重);
|
|
17
|
+
* 2. **先校验正文** —— 不合格**根本不碰目标文件**;
|
|
18
|
+
* 3. **追加** —— 脚本自己写 start/end 标记(模型不用写,也不会写重);
|
|
19
|
+
* 4. **后校验** —— 旧内容前缀哈希没变、块结构对得上;变了就**回滚**(截回原长度)。
|
|
20
|
+
*
|
|
21
|
+
* ## 退出码
|
|
22
|
+
*
|
|
23
|
+
* | 码 | 含义 | 目标文件 |
|
|
24
|
+
* | --- | --- | --- |
|
|
25
|
+
* | 0 | 成功 | 已追加 |
|
|
26
|
+
* | 2 | 预检失败(不可读/不可写/块已存在) | **未改动** |
|
|
27
|
+
* | 3 | 正文不合格 | **未改动** |
|
|
28
|
+
* | 4 | 追加后校验失败 | **已自动回滚** |
|
|
29
|
+
*
|
|
30
|
+
* ## 用法
|
|
31
|
+
*
|
|
32
|
+
* node append-block.js --target <文件> --block-id <块ID> --body <正文文件> \
|
|
33
|
+
* --metric-label <月票> --grouped <0|1>
|
|
34
|
+
*
|
|
35
|
+
* 正文也可以从 stdin 读(不给 --body 时)。
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
existsSync, readFileSync, writeFileSync, statSync, lstatSync, accessSync,
|
|
39
|
+
openSync, readSync, closeSync, ftruncateSync, unlinkSync, constants, realpathSync,
|
|
40
|
+
} from 'fs';
|
|
41
|
+
import { createHash } from 'crypto';
|
|
42
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path';
|
|
43
|
+
import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
|
|
44
|
+
import { validateReportBody } from './report-validate.js';
|
|
45
|
+
|
|
46
|
+
const EXIT_OK = 0;
|
|
47
|
+
const EXIT_PRECHECK = 2;
|
|
48
|
+
const EXIT_BODY = 3;
|
|
49
|
+
const EXIT_POSTCHECK = 4;
|
|
50
|
+
|
|
51
|
+
// 预检模式的参数错误也必须输出 JSON —— 模型只按退出码 + stdout 决策,
|
|
52
|
+
// 如果参数错时只写 stderr,它拿到的就是"退出码 2 + 空 stdout"。
|
|
53
|
+
const IS_PREFLIGHT = process.argv.includes('--preflight');
|
|
54
|
+
|
|
55
|
+
/** 解析 argv */
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
// 三态:undefined = 没传(用 manifest 的值),true/false = 命令行覆盖
|
|
58
|
+
const out = { grouped: undefined, multiList: undefined };
|
|
59
|
+
// `--block-id` → `blockId`(命令行习惯用连字符,内部用驼峰)
|
|
60
|
+
const camel = s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
61
|
+
for (let i = 2; i < argv.length; i++) {
|
|
62
|
+
const a = argv[i];
|
|
63
|
+
if (a === '--grouped') out.grouped = true;
|
|
64
|
+
else if (a === '--ungrouped') out.grouped = false;
|
|
65
|
+
else if (a === '--multi-list') out.multiList = true;
|
|
66
|
+
else if (a === '--single-list') out.multiList = false;
|
|
67
|
+
else if (a === '--preflight') out.preflight = true;
|
|
68
|
+
else if (a === '--data-file') {
|
|
69
|
+
// 可重复:LLM 只需要照抄 3.1 的命令,不用自己拼数组。
|
|
70
|
+
const val = argv[i + 1];
|
|
71
|
+
if (val === undefined || val.startsWith('--')) {
|
|
72
|
+
fail(EXIT_PRECHECK, '--data-file 缺值');
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(out.dataFile)) out.dataFile = [];
|
|
75
|
+
out.dataFile.push(val);
|
|
76
|
+
i++;
|
|
77
|
+
} else if (a.startsWith('--')) {
|
|
78
|
+
const key = camel(a.slice(2));
|
|
79
|
+
const val = argv[i + 1];
|
|
80
|
+
if (val === undefined || val.startsWith('--')) {
|
|
81
|
+
fail(EXIT_PRECHECK, `参数 ${a} 缺值`);
|
|
82
|
+
}
|
|
83
|
+
out[key] = val;
|
|
84
|
+
i++;
|
|
85
|
+
} else {
|
|
86
|
+
fail(EXIT_PRECHECK, `无法识别的参数 ${a}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 失败:往 stderr 写人话,退出;预检模式下改写 JSON 到 stdout */
|
|
93
|
+
function fail(code, message) {
|
|
94
|
+
if (IS_PREFLIGHT) {
|
|
95
|
+
const payload = {
|
|
96
|
+
ok: false,
|
|
97
|
+
mode: 'preflight',
|
|
98
|
+
status: 'blocked',
|
|
99
|
+
data_files: [],
|
|
100
|
+
missing_data_files: [],
|
|
101
|
+
empty_data_files: [],
|
|
102
|
+
unreadable_data_files: [],
|
|
103
|
+
non_utf8_data_files: [],
|
|
104
|
+
warnings: [],
|
|
105
|
+
problems: [String(message).replace(/\n?$/, '')],
|
|
106
|
+
};
|
|
107
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
108
|
+
process.exit(EXIT_PRECHECK);
|
|
109
|
+
}
|
|
110
|
+
process.stderr.write(message.endsWith('\n') ? message : message + '\n');
|
|
111
|
+
process.exit(code);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 读 stdin */
|
|
115
|
+
function readStdin() {
|
|
116
|
+
try {
|
|
117
|
+
return readFileSync(0, 'utf-8');
|
|
118
|
+
} catch {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** child 是否在 parent 目录内 */
|
|
124
|
+
function isInside(parent, child) {
|
|
125
|
+
const p = resolve(parent);
|
|
126
|
+
const c = resolve(child);
|
|
127
|
+
const rel = relative(p, c);
|
|
128
|
+
// 目录相等也算“在目录内”(目标文件所在目录就是 扫榜结果/ 本身)。
|
|
129
|
+
return (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 只删本插件自己创建的临时正文文件。
|
|
134
|
+
*
|
|
135
|
+
* 绝不能无条件 unlink(args.body):调用方传错时(比如把目标文件或用户
|
|
136
|
+
* 手稿当 body)会把真实文件删掉。路径必须是 <工作区目录>/.tmp 下的
|
|
137
|
+
* `fancy_scan_body_*.md`。
|
|
138
|
+
*/
|
|
139
|
+
function cleanupTempBody(bodyFile, projectRoot) {
|
|
140
|
+
if (!bodyFile || !projectRoot) return;
|
|
141
|
+
try {
|
|
142
|
+
const dir = resolve(projectRoot, '.tmp');
|
|
143
|
+
const file = resolve(bodyFile);
|
|
144
|
+
if (!isInside(dir, file)) return;
|
|
145
|
+
if (!/^fancy_scan_body_.*\.md$/i.test(basename(file))) return;
|
|
146
|
+
unlinkSync(file);
|
|
147
|
+
} catch { /* 临时文件删不掉不算失败 */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 文件**前 n 字节**的 sha256。
|
|
152
|
+
*
|
|
153
|
+
* 用来验证"旧内容一个字节都没被动过"——
|
|
154
|
+
* 追加后重新算一遍同样长度的前缀,哈希必须一模一样。
|
|
155
|
+
*/
|
|
156
|
+
function prefixHash(path, nBytes) {
|
|
157
|
+
if (!existsSync(path) || !nBytes) return '';
|
|
158
|
+
const fd = openSync(path, 'r');
|
|
159
|
+
try {
|
|
160
|
+
const h = createHash('sha256');
|
|
161
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
162
|
+
let read = 0;
|
|
163
|
+
while (read < nBytes) {
|
|
164
|
+
const want = Math.min(chunk.length, nBytes - read);
|
|
165
|
+
const got = readSync(fd, chunk, 0, want, read);
|
|
166
|
+
if (got <= 0) break;
|
|
167
|
+
h.update(chunk.subarray(0, got));
|
|
168
|
+
read += got;
|
|
169
|
+
}
|
|
170
|
+
return h.digest('hex');
|
|
171
|
+
} finally {
|
|
172
|
+
closeSync(fd);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 目标文件只读体检 —— 预检与真实写入**共用同一套判断**。
|
|
178
|
+
*
|
|
179
|
+
* 预检(`--preflight`)只把这个结果转成 JSON;真实写入在同一个结果上
|
|
180
|
+
* 再加"正文校验 + 追加 + 回滚"。这样不会出现"预检说能写、真写时才发现
|
|
181
|
+
* 是符号链接"的口径漂移。
|
|
182
|
+
*
|
|
183
|
+
* @param {{target: string, blockId: string, projectRoot?: string}} args
|
|
184
|
+
* @returns {{
|
|
185
|
+
* problems: string[],
|
|
186
|
+
* targetExisted: boolean,
|
|
187
|
+
* blockExists: boolean,
|
|
188
|
+
* size: number,
|
|
189
|
+
* hash: string,
|
|
190
|
+
* tail: string,
|
|
191
|
+
* dir: string,
|
|
192
|
+
* }}
|
|
193
|
+
*/
|
|
194
|
+
function inspectTarget({ target, blockId, projectRoot }) {
|
|
195
|
+
const problems = [];
|
|
196
|
+
const out = {
|
|
197
|
+
problems,
|
|
198
|
+
targetExisted: false,
|
|
199
|
+
blockExists: false,
|
|
200
|
+
size: 0,
|
|
201
|
+
hash: '',
|
|
202
|
+
tail: '',
|
|
203
|
+
dir: target ? dirname(target) : '',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (!target) {
|
|
207
|
+
problems.push('缺少 --target(目标文件路径)');
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
if (!blockId) {
|
|
211
|
+
problems.push('缺少 --block-id(块 ID)');
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
// 块 ID 会被原样插进 `# ...-start` 标记行;换行/井号会伪造出额外标题。
|
|
215
|
+
if (!/^[^\r\n#]{1,120}$/.test(blockId)) {
|
|
216
|
+
problems.push('块 ID 不合法:不能含换行或 #,长度 1-120');
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
if (!isAbsolute(target)) {
|
|
220
|
+
problems.push(`--target 必须是绝对路径:${target}`);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const dir = dirname(target);
|
|
225
|
+
out.dir = dir;
|
|
226
|
+
if (!existsSync(dir)) {
|
|
227
|
+
problems.push(`目标文件所在目录不存在:${dir}`);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 可选:把目标限制在 <projectRoot>/扫榜结果/topic_decision_YYYYMMDD.md ──
|
|
232
|
+
if (projectRoot) {
|
|
233
|
+
const root = resolve(projectRoot);
|
|
234
|
+
if (isSensitiveProjectPath(root) || !isValidProjectPath(root)) {
|
|
235
|
+
problems.push(`工作区目录路径不合法或属于受保护目录,拒绝写入:${root}`);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
let rootReal = '';
|
|
239
|
+
try {
|
|
240
|
+
rootReal = realpathSync(root);
|
|
241
|
+
} catch {
|
|
242
|
+
problems.push(`工作区目录不存在或无法读取:${root}`);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
if (isSensitiveProjectPath(rootReal) || !isValidProjectPath(rootReal)) {
|
|
246
|
+
problems.push(`工作区目录的真实路径属于受保护目录:${rootReal}`);
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
const scanDir = join(root, '扫榜结果');
|
|
250
|
+
let scanReal = scanDir;
|
|
251
|
+
try {
|
|
252
|
+
if (existsSync(scanDir)) {
|
|
253
|
+
const st = lstatSync(scanDir);
|
|
254
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
255
|
+
problems.push(`扫榜结果/ 不是真实目录,拒绝写入:${scanDir}`);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
scanReal = realpathSync(scanDir);
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
problems.push(`检查扫榜结果目录失败:${e.message}`);
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
// 目录本身也必须在工作区目录内 —— 防止通过 symlink 父目录绕过字符串检查。
|
|
265
|
+
let targetDirReal = '';
|
|
266
|
+
try {
|
|
267
|
+
targetDirReal = realpathSync(dir);
|
|
268
|
+
} catch {
|
|
269
|
+
problems.push(`目标文件所在目录不存在或无法读取:${dir}`);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
if (!isInside(rootReal, targetDirReal)) {
|
|
273
|
+
problems.push(`目标文件目录不在工作区目录内:${target}`);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
if (!isInside(scanReal, targetDirReal)) {
|
|
277
|
+
problems.push(`目标文件不在 扫榜结果/ 下:${target}`);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
if (!/^topic_decision_\d{8}\.md$/.test(basename(target))) {
|
|
281
|
+
problems.push(`目标文件名必须是 topic_decision_YYYYMMDD.md:${basename(target)}`);
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── 目录可写 ──────────────────────────────────────────────────
|
|
287
|
+
try {
|
|
288
|
+
accessSync(dir, constants.W_OK);
|
|
289
|
+
} catch {
|
|
290
|
+
problems.push(`目标文件所在目录不可写:${dir}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 先看目录项本身:existsSync 对“悬空符号链接”返回 false,
|
|
294
|
+
// 而后面的 open('a') 会跟着悬空链接把目标文件创建到链接指向的位置。
|
|
295
|
+
let lst = null;
|
|
296
|
+
try { lst = lstatSync(target); } catch { /* 不存在 */ }
|
|
297
|
+
if (lst && lst.isSymbolicLink()) {
|
|
298
|
+
problems.push(`目标文件是符号链接,拒绝追加:${target}`);
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
if (lst && !lst.isFile()) {
|
|
302
|
+
problems.push(`目标路径不是普通文件:${target}`);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (lst) {
|
|
307
|
+
out.targetExisted = true;
|
|
308
|
+
try {
|
|
309
|
+
accessSync(target, constants.R_OK | constants.W_OK);
|
|
310
|
+
const st = statSync(target);
|
|
311
|
+
out.size = st.size;
|
|
312
|
+
out.hash = prefixHash(target, st.size);
|
|
313
|
+
const fd = openSync(target, 'r');
|
|
314
|
+
try {
|
|
315
|
+
const buf = Buffer.alloc(Math.min(4, st.size));
|
|
316
|
+
if (buf.length) {
|
|
317
|
+
readSync(fd, buf, 0, buf.length, Math.max(0, st.size - buf.length));
|
|
318
|
+
out.tail = buf.toString('utf-8');
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
closeSync(fd);
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
problems.push(`目标文件无法读取或不可写:${e.message}`);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── 本次块不能已经存在(不能写重) ──────────────────────────
|
|
329
|
+
try {
|
|
330
|
+
const cur = readFileSync(target, 'utf-8');
|
|
331
|
+
const startMarker = `# ${blockId}-start`;
|
|
332
|
+
const endMarker = `# ${blockId}-end`;
|
|
333
|
+
const nStart = cur.split('\n').filter(l => l.trim() === startMarker).length;
|
|
334
|
+
const nEnd = cur.split('\n').filter(l => l.trim() === endMarker).length;
|
|
335
|
+
if (nStart > 0 || nEnd > 0) {
|
|
336
|
+
out.blockExists = true;
|
|
337
|
+
problems.push(
|
|
338
|
+
`目标文件里已经有本次(${blockId})的标记(start ${nStart} 个 / end ${nEnd} 个)—— 不能重复写入。`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
} catch (e) {
|
|
342
|
+
problems.push(`目标文件无法读取:${e.message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 数据文件只读体检。
|
|
351
|
+
*
|
|
352
|
+
* 结果分四类,方便模型按字段做决定:
|
|
353
|
+
* - ok:可读、非空、UTF-8;
|
|
354
|
+
* - missing:不存在;
|
|
355
|
+
* - empty:存在但 0 字节;
|
|
356
|
+
* - unreadable:不是普通文件 / 读不了 / 不在工作区目录的 扫榜结果/ 下;
|
|
357
|
+
* - nonUtf8:读到了,但不是合法 UTF-8。
|
|
358
|
+
*
|
|
359
|
+
* **部分文件有问题不阻断**(用能读的继续分析);全部都有问题才阻断。
|
|
360
|
+
*
|
|
361
|
+
* @param {string[]} paths
|
|
362
|
+
* @param {string} projectRoot
|
|
363
|
+
*/
|
|
364
|
+
function inspectDataFiles(paths, projectRoot) {
|
|
365
|
+
const ok = [];
|
|
366
|
+
const missing = [];
|
|
367
|
+
const empty = [];
|
|
368
|
+
const unreadable = [];
|
|
369
|
+
const nonUtf8 = [];
|
|
370
|
+
|
|
371
|
+
let scanReal = '';
|
|
372
|
+
if (projectRoot) {
|
|
373
|
+
try {
|
|
374
|
+
const scanDir = join(resolve(projectRoot), '扫榜结果');
|
|
375
|
+
if (existsSync(scanDir)) scanReal = realpathSync(scanDir);
|
|
376
|
+
} catch { /* 目录问题由 inspectTarget 报 */ }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const raw of paths) {
|
|
380
|
+
const p = String(raw || '');
|
|
381
|
+
if (!p || !isAbsolute(p)) {
|
|
382
|
+
unreadable.push({ path: p, reason: '必须是绝对路径' });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
let fileReal = '';
|
|
386
|
+
try {
|
|
387
|
+
fileReal = realpathSync(p);
|
|
388
|
+
} catch {
|
|
389
|
+
missing.push(p);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (scanReal && !isInside(scanReal, fileReal)) {
|
|
393
|
+
unreadable.push({ path: p, reason: '不在工作区目录的 扫榜结果/ 下' });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
let st = null;
|
|
397
|
+
try { st = statSync(fileReal); } catch {
|
|
398
|
+
missing.push(p);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (!st.isFile()) {
|
|
402
|
+
unreadable.push({ path: p, reason: '不是普通文件' });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
let buf = null;
|
|
406
|
+
try {
|
|
407
|
+
buf = readFileSync(fileReal);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
unreadable.push({ path: p, reason: e.message });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (!buf.length) {
|
|
413
|
+
empty.push(p);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
418
|
+
} catch {
|
|
419
|
+
nonUtf8.push(p);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
ok.push({ path: p, bytes: buf.length });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { ok, missing, empty, unreadable, nonUtf8 };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 读取插件生成的运行清单。
|
|
430
|
+
*
|
|
431
|
+
* 清单由 `fancy-scan/index.js` 在投递提示词前写入
|
|
432
|
+
* `<工作区目录>/.fancy-tools/fancy-scan/run-<hash>.json`,里面有目标、工作区目录、
|
|
433
|
+
* 块 ID、数据文件、正文路径、指标与开关。模型只需要照抄两条短命令,
|
|
434
|
+
* **不需要自己拼参数** —— 少一个参数就少一个跑偏的机会。
|
|
435
|
+
*
|
|
436
|
+
* 命令行参数仍然可以覆盖清单(测试与手工调试用)。
|
|
437
|
+
*
|
|
438
|
+
* @param {string|undefined} manifestPath
|
|
439
|
+
* @returns {object}
|
|
440
|
+
*/
|
|
441
|
+
function loadManifest(manifestPath) {
|
|
442
|
+
if (!manifestPath) return {};
|
|
443
|
+
if (!isAbsolute(manifestPath)) {
|
|
444
|
+
fail(EXIT_PRECHECK, '--manifest 必须是绝对路径');
|
|
445
|
+
}
|
|
446
|
+
let raw = '';
|
|
447
|
+
try {
|
|
448
|
+
raw = readFileSync(manifestPath, 'utf-8');
|
|
449
|
+
} catch (e) {
|
|
450
|
+
fail(EXIT_PRECHECK, `无法读取运行清单:${e.message}`);
|
|
451
|
+
}
|
|
452
|
+
let data = null;
|
|
453
|
+
try {
|
|
454
|
+
data = JSON.parse(raw);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
fail(EXIT_PRECHECK, `运行清单不是合法 JSON:${e.message}`);
|
|
457
|
+
}
|
|
458
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
459
|
+
fail(EXIT_PRECHECK, '运行清单顶层必须是 JSON 对象');
|
|
460
|
+
}
|
|
461
|
+
return data;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* `--preflight` 入口:只读,不碰目标文件。
|
|
466
|
+
*
|
|
467
|
+
* stdout **只输出一个 JSON 对象**(模型据此决定是否继续),
|
|
468
|
+
* stderr 留空。退出码:
|
|
469
|
+
* 0 = 可以继续;2 = 有阻断项,禁止继续。
|
|
470
|
+
*
|
|
471
|
+
* 数据文件缺失/部分不可读**不阻断**(放进 missing/empty/unreadable,
|
|
472
|
+
* 由模型在报告里说明);只有"一个能用的都没有"才阻断。
|
|
473
|
+
*/
|
|
474
|
+
function runPreflight(args) {
|
|
475
|
+
const manifest = loadManifest(args.manifest);
|
|
476
|
+
const target = args.target || manifest.target || '';
|
|
477
|
+
const blockId = args.blockId || manifest.block_id || '';
|
|
478
|
+
const projectRoot = args.projectRoot || manifest.project_root || '';
|
|
479
|
+
const dataArgs = Array.isArray(args.dataFile)
|
|
480
|
+
? args.dataFile
|
|
481
|
+
: (Array.isArray(manifest.data_files) ? manifest.data_files : []);
|
|
482
|
+
const warnings = [];
|
|
483
|
+
|
|
484
|
+
const ins = inspectTarget({ target, blockId, projectRoot });
|
|
485
|
+
const problems = [...ins.problems];
|
|
486
|
+
const data = inspectDataFiles(dataArgs, projectRoot);
|
|
487
|
+
|
|
488
|
+
if (!dataArgs.length) {
|
|
489
|
+
warnings.push('未提供 --data-file,数据文件预检被跳过');
|
|
490
|
+
}
|
|
491
|
+
if (data.missing.length) {
|
|
492
|
+
warnings.push(`缺失的数据文件 ${data.missing.length} 个(不阻断,但本次分析不能用它们):${data.missing.join('、')}`);
|
|
493
|
+
}
|
|
494
|
+
if (data.empty.length) {
|
|
495
|
+
warnings.push(`空的数据文件 ${data.empty.length} 个(不阻断,但本次分析不能用它们):${data.empty.join('、')}`);
|
|
496
|
+
}
|
|
497
|
+
if (data.unreadable.length) {
|
|
498
|
+
warnings.push('不可读的数据文件:' + data.unreadable.map(x => `${x.path}(${x.reason})`).join(';'));
|
|
499
|
+
}
|
|
500
|
+
if (data.nonUtf8.length) {
|
|
501
|
+
warnings.push(`不是 UTF-8 的数据文件 ${data.nonUtf8.length} 个(不阻断,但不能拿乱码做统计):${data.nonUtf8.join('、')}`);
|
|
502
|
+
}
|
|
503
|
+
if (dataArgs.length && data.ok.length === 0) {
|
|
504
|
+
problems.push(`所有数据文件都缺失、为空、不可读或不是 UTF-8(${dataArgs.length} 个)—— 没有可分析的数据`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const payload = {
|
|
508
|
+
ok: problems.length === 0,
|
|
509
|
+
mode: 'preflight',
|
|
510
|
+
status: problems.length === 0 ? 'pass' : 'blocked',
|
|
511
|
+
target,
|
|
512
|
+
block_id: blockId,
|
|
513
|
+
project_root: projectRoot || null,
|
|
514
|
+
target_exists: ins.targetExisted,
|
|
515
|
+
target_size: ins.size,
|
|
516
|
+
block_exists: ins.blockExists,
|
|
517
|
+
data_files: data.ok,
|
|
518
|
+
missing_data_files: data.missing,
|
|
519
|
+
empty_data_files: data.empty,
|
|
520
|
+
unreadable_data_files: data.unreadable,
|
|
521
|
+
non_utf8_data_files: data.nonUtf8,
|
|
522
|
+
warnings,
|
|
523
|
+
problems,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
527
|
+
process.exit(problems.length ? EXIT_PRECHECK : EXIT_OK);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function main() {
|
|
531
|
+
const args = parseArgs(process.argv);
|
|
532
|
+
if (args.preflight) return runPreflight(args);
|
|
533
|
+
|
|
534
|
+
const manifest = loadManifest(args.manifest);
|
|
535
|
+
const target = args.target || manifest.target;
|
|
536
|
+
const blockId = args.blockId || manifest.block_id;
|
|
537
|
+
const metricLabel = args.metricLabel || manifest.metric_label || '月票';
|
|
538
|
+
const projectRoot = args.projectRoot || manifest.project_root || '';
|
|
539
|
+
const grouped = args.grouped === undefined ? manifest.grouped === true : args.grouped;
|
|
540
|
+
const multiList = args.multiList === undefined ? manifest.multi_list === true : args.multiList;
|
|
541
|
+
const dataFiles = Array.isArray(manifest.data_files) ? manifest.data_files : [];
|
|
542
|
+
const knownLists = Array.isArray(manifest.rank_labels) ? manifest.rank_labels : [];
|
|
543
|
+
|
|
544
|
+
const ins = inspectTarget({ target, blockId, projectRoot });
|
|
545
|
+
if (ins.problems.length) {
|
|
546
|
+
fail(EXIT_PRECHECK, ins.problems.join('\n') + '\n目标文件没有被动过。');
|
|
547
|
+
}
|
|
548
|
+
const targetExisted = ins.targetExisted;
|
|
549
|
+
const preSize = ins.size;
|
|
550
|
+
const preHash = ins.hash;
|
|
551
|
+
const preTail = ins.tail;
|
|
552
|
+
const startMarker = `# ${blockId}-start`;
|
|
553
|
+
const endMarker = `# ${blockId}-end`;
|
|
554
|
+
|
|
555
|
+
// ── 读正文 ────────────────────────────────────────────────────
|
|
556
|
+
let body = '';
|
|
557
|
+
let bodyFile = '';
|
|
558
|
+
const bodyArg = args.body || manifest.body_file || '';
|
|
559
|
+
if (bodyArg) {
|
|
560
|
+
if (!isAbsolute(bodyArg)) {
|
|
561
|
+
fail(EXIT_BODY, `--body 必须是绝对路径:${bodyArg}\n目标文件没有被动过。`);
|
|
562
|
+
}
|
|
563
|
+
bodyFile = bodyArg;
|
|
564
|
+
if (!existsSync(bodyFile)) {
|
|
565
|
+
fail(EXIT_BODY, `正文文件不存在:${bodyFile}\n目标文件没有被动过。`);
|
|
566
|
+
}
|
|
567
|
+
// body 与 target 相同(或符号链接指向同一文件)时,
|
|
568
|
+
// 追加后再 cleanupTempBody/无条件 unlink 会把目标文件删掉。
|
|
569
|
+
try {
|
|
570
|
+
const bodyReal = realpathSync(bodyFile);
|
|
571
|
+
const targetReal = existsSync(target) ? realpathSync(target) : resolve(target);
|
|
572
|
+
if (bodyReal === targetReal || resolve(bodyFile) === resolve(target)) {
|
|
573
|
+
fail(EXIT_BODY, `--body 不能与 --target 是同一个文件:${bodyFile}\n目标文件没有被动过。`);
|
|
574
|
+
}
|
|
575
|
+
} catch (e) {
|
|
576
|
+
if (e && e.exitCode) throw e;
|
|
577
|
+
// realpath 失败(例如 body 刚被删)交给下面的读取报错
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
body = readFileSync(bodyFile, 'utf-8');
|
|
581
|
+
} catch (e) {
|
|
582
|
+
fail(EXIT_BODY, `无法读取正文文件:${e.message}\n目标文件未被修改。`);
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
body = readStdin();
|
|
586
|
+
}
|
|
587
|
+
body = body.replace(/\r\n/g, '\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
588
|
+
if (!body.trim()) {
|
|
589
|
+
fail(EXIT_BODY, '正文是空的。\n目标文件没有被动过。');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── 校验正文(不合格根本不碰目标文件) ────────────────────────
|
|
593
|
+
const v = validateReportBody(body, {
|
|
594
|
+
metricLabel,
|
|
595
|
+
grouped,
|
|
596
|
+
multiList,
|
|
597
|
+
dataFiles,
|
|
598
|
+
knownLists,
|
|
599
|
+
});
|
|
600
|
+
if (!v.ok) {
|
|
601
|
+
const lines = [
|
|
602
|
+
`正文不合格(${v.errors.length} 项),**目标文件没有被改动**。`,
|
|
603
|
+
'请改正下面这些问题后重跑本脚本(正文写到同一个文件即可):',
|
|
604
|
+
'',
|
|
605
|
+
...v.errors.map((e, i) => ` ${i + 1}. ${e}`),
|
|
606
|
+
];
|
|
607
|
+
if (v.warnings.length) {
|
|
608
|
+
lines.push('', '(另外有几条提醒,不拦你,但最好改):', ...v.warnings.map(w => ` - ${w}`));
|
|
609
|
+
}
|
|
610
|
+
fail(EXIT_BODY, lines.join('\n'));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── 追加 ──────────────────────────────────────────────────────
|
|
614
|
+
// 旧文件末尾没有换行时先补一个,免得和上一段黏成一行
|
|
615
|
+
let prefix = '';
|
|
616
|
+
if (preSize > 0 && preTail && !preTail.endsWith('\n')) {
|
|
617
|
+
prefix = '\n';
|
|
618
|
+
}
|
|
619
|
+
const block = `${prefix}${startMarker}\n${body}\n${endMarker}\n`;
|
|
620
|
+
let writeErr = null;
|
|
621
|
+
try {
|
|
622
|
+
// O_APPEND + O_NOFOLLOW:不跟随符号链接,且始终追加到末尾。
|
|
623
|
+
// 预检已经 lstat 过一次,这里再让内核拒绝一次,缩小竞态窗口。
|
|
624
|
+
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT
|
|
625
|
+
| (constants.O_NOFOLLOW || 0);
|
|
626
|
+
const fd = openSync(target, flags, 0o644);
|
|
627
|
+
try {
|
|
628
|
+
writeFileSync(fd, block, 'utf-8');
|
|
629
|
+
} finally {
|
|
630
|
+
closeSync(fd);
|
|
631
|
+
}
|
|
632
|
+
} catch (e) {
|
|
633
|
+
writeErr = e;
|
|
634
|
+
}
|
|
635
|
+
if (writeErr) {
|
|
636
|
+
// 写失败可能已经落了半截:尽量恢复到写入前,再报告。
|
|
637
|
+
try {
|
|
638
|
+
if (!targetExisted) unlinkSync(target);
|
|
639
|
+
else {
|
|
640
|
+
const fd = openSync(target, 'r+');
|
|
641
|
+
try { ftruncateSync(fd, preSize); } finally { closeSync(fd); }
|
|
642
|
+
}
|
|
643
|
+
} catch { /* 恢复失败在下面的文案里说明 */ }
|
|
644
|
+
fail(EXIT_PRECHECK,
|
|
645
|
+
`追加失败:${writeErr.message}\n本次写入可能只落了一部分,已尝试恢复到写入前;请人工确认目标文件。`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── 后校验:旧内容没被覆盖 + 块结构对得上 ─────────────────────
|
|
649
|
+
const problems = [];
|
|
650
|
+
try {
|
|
651
|
+
const after = readFileSync(target, 'utf-8');
|
|
652
|
+
// 旧内容必须一个字节都没动 —— 重新算一遍同样长度的前缀,哈希要一模一样
|
|
653
|
+
if (preSize > 0) {
|
|
654
|
+
const afterHash = prefixHash(target, preSize);
|
|
655
|
+
if (afterHash !== preHash) {
|
|
656
|
+
problems.push('旧内容的前缀哈希变了 —— 说明旧内容被覆盖了');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const afterLines = after.split('\n');
|
|
660
|
+
const nStart = afterLines.filter(l => l.trim() === startMarker).length;
|
|
661
|
+
const nEnd = afterLines.filter(l => l.trim() === endMarker).length;
|
|
662
|
+
if (nStart !== 1) problems.push(`写入后 start 标记数是 ${nStart},应为 1`);
|
|
663
|
+
if (nEnd !== 1) problems.push(`写入后 end 标记数是 ${nEnd},应为 1`);
|
|
664
|
+
const idxS = afterLines.findIndex(l => l.trim() === startMarker);
|
|
665
|
+
const idxE = afterLines.findIndex(l => l.trim() === endMarker);
|
|
666
|
+
if (idxS >= 0 && idxE >= 0) {
|
|
667
|
+
const blockBody = afterLines.slice(idxS + 1, idxE).join('\n');
|
|
668
|
+
const v2 = validateReportBody(blockBody, {
|
|
669
|
+
metricLabel,
|
|
670
|
+
grouped,
|
|
671
|
+
multiList,
|
|
672
|
+
dataFiles,
|
|
673
|
+
knownLists,
|
|
674
|
+
});
|
|
675
|
+
if (!v2.ok) {
|
|
676
|
+
problems.push(...v2.errors.slice(0, 5).map(e => '写入后复检:' + e));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
} catch (e) {
|
|
680
|
+
problems.push('写入后读不回来:' + e.message);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (problems.length) {
|
|
684
|
+
// 回滚:目标原本不存在就删掉;原本存在就截回原长度。
|
|
685
|
+
try {
|
|
686
|
+
if (!targetExisted) {
|
|
687
|
+
unlinkSync(target);
|
|
688
|
+
} else {
|
|
689
|
+
const fd = openSync(target, 'r+');
|
|
690
|
+
try {
|
|
691
|
+
ftruncateSync(fd, preSize);
|
|
692
|
+
} finally {
|
|
693
|
+
closeSync(fd);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch (e) {
|
|
697
|
+
fail(EXIT_POSTCHECK,
|
|
698
|
+
`写入后校验失败(${problems.length} 项),**并且回滚失败**:${e.message}\n`
|
|
699
|
+
+ problems.map(p => ' - ' + p).join('\n')
|
|
700
|
+
+ '\n目标文件长度已尝试恢复,请人工检查。');
|
|
701
|
+
}
|
|
702
|
+
fail(EXIT_POSTCHECK,
|
|
703
|
+
`写入后校验失败(${problems.length} 项),**已自动回滚**,目标文件恢复到写入前的样子:\n`
|
|
704
|
+
+ problems.map(p => ' - ' + p).join('\n'));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ── 成功 ──────────────────────────────────────────────────────
|
|
708
|
+
// 只清理本插件自己的临时文件;用户传入的真实文件绝不能删。
|
|
709
|
+
cleanupTempBody(bodyFile, projectRoot);
|
|
710
|
+
const s = v.stats;
|
|
711
|
+
const out = [
|
|
712
|
+
`写入成功:${target}`,
|
|
713
|
+
` 块 ID:${blockId}`,
|
|
714
|
+
` 方向 ${s.directions} 个 · 表格 ${s.tables} 张 · 汉字 ${s.totalCjk}`
|
|
715
|
+
+ `(纯正文 ${s.pureCjk})· 引用 ${s.citations} 处 · 推断标记 ${s.inferMarks} 处`,
|
|
716
|
+
...s.tags.map(t => ` 方向 ${t.direction}:标签 ${t.okTags}/${t.total}`),
|
|
717
|
+
];
|
|
718
|
+
if (v.warnings.length) {
|
|
719
|
+
out.push(' 提醒(不拦你):', ...v.warnings.map(w => ` - ${w}`));
|
|
720
|
+
}
|
|
721
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
722
|
+
process.exit(EXIT_OK);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
main();
|