|
|
@@ -0,0 +1,195 @@
|
|
|
+// 第三轮 review 修复后探针:与 07-05 probe-m1-m7.mjs / probe-followup.mjs 同场景,
|
|
|
+// 断言修复后语义(守卫拒绝/修复接受/角色可见/别名命中/ok 不改写/重号硬错)。
|
|
|
+// 库级直调真实路径(persistCreateBook 建书)。跑完自删临时目录,全 PASS 即收口留档。
|
|
|
+import { promises as fs } from 'node:fs'
|
|
|
+import os from 'node:os'
|
|
|
+import path from 'node:path'
|
|
|
+import { fileURLToPath } from 'node:url'
|
|
|
+
|
|
|
+const V7 = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'v7')
|
|
|
+const m = (p) => import(`file:///${path.join(V7, p).replace(/\\/g, '/')}`)
|
|
|
+
|
|
|
+const { persistCreateBook, persistRepair } = await m('src/state-machine/persist.js')
|
|
|
+const { finalizeChapter } = await m('src/finalize/index.js')
|
|
|
+const { stageChapter, readBatch } = await m('src/staging/index.js')
|
|
|
+const { gotoChapter } = await m('src/state-machine/flows/goto-chapter.js')
|
|
|
+const { determineNextState } = await m('src/state-machine/index.js')
|
|
|
+const { CacheManager } = await m('src/cache/index.js')
|
|
|
+const { EntityReader } = await m('src/storage/adapters/EntityReader.js')
|
|
|
+const { migrateV6 } = await m('src/migrate/index.js')
|
|
|
+const { run: listCharacters } = await m('src/commands/list-characters.js')
|
|
|
+const { run: finalizeCmd } = await m('src/commands/finalize.js')
|
|
|
+const { createGit } = await m('src/finalize/git.js')
|
|
|
+
|
|
|
+const results = []
|
|
|
+const P = (id, pass, detail) => {
|
|
|
+ results.push({ id, pass })
|
|
|
+ console.log(`${pass ? '【PASS】' : '【FAIL】'} ${id}: ${detail}`)
|
|
|
+}
|
|
|
+
|
|
|
+const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-fix-probe-'))
|
|
|
+let n = 0
|
|
|
+
|
|
|
+async function makeBook(extraYaml = '') {
|
|
|
+ const repo = path.join(tmpRoot, `book-${++n}`)
|
|
|
+ await fs.mkdir(repo, { recursive: true })
|
|
|
+ const r = await persistCreateBook({ repoPath: repo, cache: null }, {
|
|
|
+ book: { spec_version: '7.0', 书名: `修复探针${n}`, 类型: '玄幻', 每章目标字数: 3000, 卷规模: 40 },
|
|
|
+ 总纲: '# 总纲\n\n## 结局\n主角胜。\n',
|
|
|
+ 卷纲: '# 第1卷\n\n第一卷纲要。\n',
|
|
|
+ })
|
|
|
+ if (!r.ok) throw new Error(`建书失败: ${r.error}`)
|
|
|
+ if (extraYaml) {
|
|
|
+ const p = path.join(repo, 'book.yaml')
|
|
|
+ await fs.writeFile(p, (await fs.readFile(p, 'utf8')) + extraYaml, 'utf8')
|
|
|
+ }
|
|
|
+ const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
|
|
|
+ await cache.ensureReady(repo)
|
|
|
+ return { repoPath: repo, cache }
|
|
|
+}
|
|
|
+
|
|
|
+const payloadOf = (num, extra = {}) => ({
|
|
|
+ chapterNum: num,
|
|
|
+ frontMatter: {
|
|
|
+ 章号: num, 标题: `第${num}章题`, 卷: 1, 书内时间: `夏月初${num}`, 字数: 100,
|
|
|
+ 章定位: '推进', 钩子: '危机钩-强', 情绪定位: '铺垫',
|
|
|
+ },
|
|
|
+ body: `第${num}章正文内容,情节推进一格。`,
|
|
|
+ summary: `第${num}章摘要。`,
|
|
|
+ commitLines: {},
|
|
|
+ workspaceFiles: [],
|
|
|
+ ...extra,
|
|
|
+})
|
|
|
+
|
|
|
+async function stage(ctx, num, extra = {}) {
|
|
|
+ await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(ctx.repoPath, '工作区', '审稿.md'), `# 第 ${num} 章审稿单\n\n> 共 0 个问题:0 阻断。\n`, 'utf8')
|
|
|
+ const r = await stageChapter(ctx, { chapterNum: num, payload: payloadOf(num, extra) })
|
|
|
+ if (!r.ok) throw new Error(`stage ${num} 失败: ${r.error}`)
|
|
|
+}
|
|
|
+
|
|
|
+// P-1 (R1):批次在场 goto → 人话拒绝,批次原样;无批次时 goto 照常可用
|
|
|
+try {
|
|
|
+ const ctx = await makeBook()
|
|
|
+ for (const c of [1, 2, 3]) {
|
|
|
+ const r = await finalizeChapter(ctx, payloadOf(c))
|
|
|
+ if (!r.ok) throw new Error(`定稿${c}: ${r.error}`)
|
|
|
+ }
|
|
|
+ await stage(ctx, 4)
|
|
|
+ const g = await gotoChapter(ctx, { chapterNum: 2, confirm: true })
|
|
|
+ const batch = await readBatch(ctx.repoPath)
|
|
|
+ const humane = /finalize-batch|batch-discard/.test(g.error || '')
|
|
|
+ P('P-1 goto×批次守卫(R1)', !g.ok && humane && batch.exists,
|
|
|
+ `goto被拒=${!g.ok} 指引人话=${humane} 批次完好=${batch.exists} err=${(g.error || '').slice(0, 80)}`)
|
|
|
+ await ctx.cache.close()
|
|
|
+} catch (e) { P('P-1 goto×批次守卫(R1)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-2 (R2):批次在场手动 finalize(命令层)→ 双文案人话拒绝;finalizeChapter 本体不带守卫(finalizeBatch 内部要用)
|
|
|
+try {
|
|
|
+ const ctx = await makeBook()
|
|
|
+ await finalizeChapter(ctx, payloadOf(1))
|
|
|
+ await stage(ctx, 2)
|
|
|
+ const inBatch = await finalizeCmd(['2'], { payload: '不会被读到.json' }, ctx)
|
|
|
+ const outBatch = await finalizeCmd(['9'], { payload: '不会被读到.json' }, ctx)
|
|
|
+ const okIn = !inBatch.ok && /finalize-batch/.test(inBatch.error || '')
|
|
|
+ const okOut = !outBatch.ok && /章号错位|finalize-batch/.test(outBatch.error || '')
|
|
|
+ P('P-2 手动finalize守卫(R2)', okIn && okOut,
|
|
|
+ `批内被拒=${okIn} 批外被拒=${okOut} errIn=${(inBatch.error || '').slice(0, 60)}`)
|
|
|
+ await ctx.cache.close()
|
|
|
+} catch (e) { P('P-2 手动finalize守卫(R2)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-3 (R3):book.yaml 坏 → 序0;合法修复被接受;修复后序离开 0
|
|
|
+try {
|
|
|
+ const ctx = await makeBook('卷规模: 40\n') // 追加重复键 → YAML 解析失败
|
|
|
+ const next = await determineNextState(ctx)
|
|
|
+ const failures = next.dto?.failures?.map((f) => f.file) || []
|
|
|
+ const good = 'spec_version: "7.0"\n书名: 探针修复\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n'
|
|
|
+ const rep = await persistRepair(ctx, { repairs: [{ file: 'book.yaml', content: good }] }, { allowedFiles: failures })
|
|
|
+ const after = await determineNextState(ctx)
|
|
|
+ P('P-3 修复分派校验(R3)', next.序 === 0 && failures.includes('book.yaml') && rep.ok && after.序 !== 0,
|
|
|
+ `序0=${next.序 === 0} 修复接受=${rep.ok} 修复后序=${after.序} err=${(rep.error || '').slice(0, 60)}`)
|
|
|
+ await ctx.cache.close()
|
|
|
+} catch (e) { P('P-3 修复分派校验(R3)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-4 (B1):坏 front matter 章 → rebuild warning 不再静默;主链由序0 拦(上轮 REFUTED 主链保持)
|
|
|
+try {
|
|
|
+ const ctx = await makeBook()
|
|
|
+ for (const c of [1, 2, 3]) await finalizeChapter(ctx, payloadOf(c))
|
|
|
+ const chDir = path.join(ctx.repoPath, '定稿', '正文')
|
|
|
+ const f3 = (await fs.readdir(chDir)).find((f) => f.startsWith('0003-'))
|
|
|
+ await fs.writeFile(path.join(chDir, f3), '---\n章号: [broken\n---\n正文', 'utf8')
|
|
|
+ const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
|
|
|
+ const next = await determineNextState(ctx)
|
|
|
+ P('P-4 解析失败有warning(B1)', (rb.warnings || []).length > 0 && next.序 === 0,
|
|
|
+ `rebuild.ok=${rb.ok} warnings=${(rb.warnings || []).length} 条 next序=${next.序}(序0 修复确认拦住重抄)`)
|
|
|
+ await ctx.cache.close()
|
|
|
+} catch (e) { P('P-4 解析失败有warning(B1)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-4b (B2):同章号两个合法文件 → UNIQUE 硬错冒泡人话报错,不再静默丢章
|
|
|
+try {
|
|
|
+ const ctx = await makeBook()
|
|
|
+ for (const c of [1, 2, 3]) await finalizeChapter(ctx, payloadOf(c))
|
|
|
+ const chDir = path.join(ctx.repoPath, '定稿', '正文')
|
|
|
+ const f3 = (await fs.readdir(chDir)).find((f) => f.startsWith('0003-'))
|
|
|
+ await fs.copyFile(path.join(chDir, f3), path.join(chDir, '0003-旧稿备份.md'))
|
|
|
+ const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
|
|
|
+ const errText = (rb.errors || []).join(';') // rebuild 返回 errors 数组(非 error 单数)
|
|
|
+ P('P-4b 重号硬错冒泡(B2)', rb.ok === false && /重复档案|同章号/.test(errText),
|
|
|
+ `rebuild.ok=${rb.ok} err=${errText.slice(0, 80)}`)
|
|
|
+ await ctx.cache.close()
|
|
|
+} catch (e) { P('P-4b 重号硬错冒泡(B2)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-5 (R5):sqlite v6 迁移书 → list-characters 可见、entities.type 为英文 machine 值
|
|
|
+try {
|
|
|
+ const workdir = path.join(tmpRoot, 'workdir-g2')
|
|
|
+ await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
|
|
|
+ const { tempV6Sqlite } = await m('test/migrate/_v6.js')
|
|
|
+ const v6 = await tempV6Sqlite()
|
|
|
+ const mig = await migrateV6({ workdir }, v6.v6Path)
|
|
|
+ const repo = path.join(workdir, '潮汐之下')
|
|
|
+ const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
|
|
|
+ await cache.ensureReady(repo)
|
|
|
+ const lc = await listCharacters([], {}, { repoPath: repo, cache })
|
|
|
+ const rows = await cache.query('SELECT type FROM entities', [])
|
|
|
+ const machineTypes = rows.every((r) => /^[a-z]+$/.test(r.type))
|
|
|
+ P('P-5 迁移书角色可见(R5)', mig.ok && lc.ok && (lc.output || '').includes('江遥') && machineTypes,
|
|
|
+ `migrate.ok=${mig.ok} 含江遥=${(lc.output || '').includes('江遥')} type全machine值=${machineTypes} [${rows.map((r) => r.type).join(',')}]`)
|
|
|
+ await cache.close()
|
|
|
+ await v6.cleanup()
|
|
|
+} catch (e) { P('P-5 迁移书角色可见(R5)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-6 (R6):名册全角/顿号混合分隔别名 → resolveAlias 全命中(英文类型恒等收纳兼验)
|
|
|
+try {
|
|
|
+ const ctx = await makeBook()
|
|
|
+ await fs.mkdir(path.join(ctx.repoPath, '定稿', '设定'), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(ctx.repoPath, '定稿', '设定', '名册.md'),
|
|
|
+ '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 阿晚,晚儿、小晚 | character | 1 |\n', 'utf8')
|
|
|
+ await ctx.cache.rebuildFromSource(ctx.repoPath)
|
|
|
+ const er = new EntityReader(ctx.repoPath, ctx.cache)
|
|
|
+ const hits = []
|
|
|
+ for (const a of ['阿晚', '晚儿', '小晚']) hits.push((await er.resolveAlias(a)).ok)
|
|
|
+ P('P-6 别名中文分隔全命中(R6)', hits.every(Boolean), `resolveAlias(阿晚/晚儿/小晚)=[${hits}]`)
|
|
|
+ await ctx.cache.close()
|
|
|
+} catch (e) { P('P-6 别名中文分隔全命中(R6)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+// P-7/P-7b (R4):workspaceFiles 含目录(带/不带 工作区/ 前缀)→ ok:true + commit 在 + 目录清掉
|
|
|
+try {
|
|
|
+ for (const [id, rel] of [['P-7 目录带前缀', '工作区/评审报告'], ['P-7b 目录裸名', '评审报告']]) {
|
|
|
+ const ctx = await makeBook()
|
|
|
+ await fs.mkdir(path.join(ctx.repoPath, '工作区', '评审报告'), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(ctx.repoPath, '工作区', '评审报告', 'a.md'), 'x', 'utf8')
|
|
|
+ const r = await finalizeChapter(ctx, payloadOf(1, { workspaceFiles: [rel] }))
|
|
|
+ const committed = await createGit(ctx.repoPath).findChapterCommit(1)
|
|
|
+ const dirLeft = await fs.access(path.join(ctx.repoPath, '工作区', '评审报告')).then(() => true, () => false)
|
|
|
+ P(`${id}(R4)`, r.ok && !!committed && !dirLeft,
|
|
|
+ `finalize.ok=${r.ok} commit=${committed ? '在' : '无'} 目录残留=${dirLeft} warnings=${(r.warnings || []).length}`)
|
|
|
+ await ctx.cache.close()
|
|
|
+ }
|
|
|
+} catch (e) { P('P-7 finalize清理(R4)', false, `探针异常: ${e.message}`) }
|
|
|
+
|
|
|
+console.log('\n===== 修复后探针汇总 =====')
|
|
|
+for (const r of results) console.log(`${r.pass ? 'PASS' : 'FAIL'} | ${r.id}`)
|
|
|
+const failed = results.filter((r) => !r.pass).length
|
|
|
+console.log(failed ? `\n${failed} 项未过` : '\n全部通过')
|
|
|
+await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
|
+process.exitCode = failed ? 1 : 0
|