Преглед изворни кода

feat(v7): M1 重建 R3——P1 接口真实现 + 测试(机检与全书近况)

5 个 P1 命令,全部真实现(非空壳)+ test/commands 逐条测试:
- list-chapters --章定位 [--卷]:按章定位/卷查 chapters 表
- list-secrets:未揭晓信息差 + 查询时算蓄积章数(派生值不物化)
- grep-story <关键词>:全文检索 定稿/正文,返回章号/匹配行/上下文
- report-book-stats:总章数/总字数/条目数/角色数
- report-weak-hook-streak:末尾连续弱钩计数(含弱钩/-弱 判定)
测试覆盖正常路径 + 边界(空结果、缺参)+ 弱钩正例(临时仓库构造)。
lingfengQAQ пре 1 дан
родитељ
комит
1a1185ccb9

+ 38 - 0
v7/src/commands/grep-story.js

@@ -0,0 +1,38 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * grep-story <关键词>(R4 补 --regex=<pattern>)→ JSON 数组(章号、匹配行、上下文)
+ * 全文检索 定稿/正文/*.md。契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const keyword = args[0]
+  if (!keyword) {
+    return { ok: false, error: '请指定检索关键词' }
+  }
+
+  const dir = path.join(ctx.repoPath, '定稿', '正文')
+  let files = []
+  try {
+    files = (await fs.readdir(dir)).filter((f) => f.endsWith('.md')).sort()
+  } catch {
+    return { ok: true, output: JSON.stringify([], null, 2) }
+  }
+
+  const hits = []
+  for (const file of files) {
+    const num = parseInt(file.match(/\d+/)?.[0] || '0', 10)
+    const content = await fs.readFile(path.join(dir, file), 'utf8')
+    const lines = content.split('\n')
+    for (let i = 0; i < lines.length; i++) {
+      if (lines[i].includes(keyword)) {
+        hits.push({
+          章号: num,
+          匹配行: lines[i].trim(),
+          上下文: lines.slice(Math.max(0, i - 1), i + 2).join(' ').trim(),
+        })
+      }
+    }
+  }
+  return { ok: true, output: JSON.stringify(hits, null, 2) }
+}

+ 21 - 0
v7/src/commands/list-chapters.js

@@ -0,0 +1,21 @@
+/**
+ * list-chapters --章定位=<推进|过渡|日常> [--卷=N] → JSON 数组(章号、标题)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const position = options['章定位']
+  if (!position) {
+    return { ok: false, error: '请用 --章定位=<推进|过渡|日常> 指定筛选' }
+  }
+
+  let sql = 'SELECT chapter_num, title, volume_num FROM chapters WHERE chapter_position = ?'
+  const params = [position]
+  if (options['卷'] !== undefined && options['卷'] !== true) {
+    sql += ' AND volume_num = ?'
+    params.push(parseInt(options['卷'], 10))
+  }
+  sql += ' ORDER BY chapter_num'
+
+  const rows = await ctx.cache.query(sql, params)
+  return { ok: true, output: JSON.stringify(rows, null, 2) }
+}

+ 21 - 0
v7/src/commands/list-secrets.js

@@ -0,0 +1,21 @@
+import { SecretReader } from '../storage/adapters/SecretReader.js'
+
+/**
+ * list-secrets [--reader-knows=false] → 未揭晓信息差(含查询时算的蓄积章数)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const reader = new SecretReader(ctx.repoPath, ctx.cache)
+  const rows = await reader.listUnrevealed()
+
+  // 蓄积章数 = 当前最大章号 − 登记章(派生值,不物化,查询时算)
+  const maxRows = await ctx.cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
+  const max = maxRows[0]?.m || 0
+
+  const out = rows.map((s) => ({
+    id: s.id,
+    short_title: s.short_title,
+    蓄积章数: max - s.registered_chapter,
+  }))
+  return { ok: true, output: JSON.stringify(out, null, 2) }
+}

+ 19 - 0
v7/src/commands/report-book-stats.js

@@ -0,0 +1,19 @@
+/**
+ * report-book-stats → 总章数 / 总字数 / 条目数 / 角色数
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const ch = await ctx.cache.query(
+    'SELECT COUNT(*) AS c, COALESCE(SUM(word_count), 0) AS w FROM chapters'
+  )
+  const th = await ctx.cache.query('SELECT COUNT(*) AS c FROM threads')
+  const en = await ctx.cache.query("SELECT COUNT(*) AS c FROM entities WHERE type = 'character'")
+
+  const stats = {
+    总章数: ch[0].c,
+    总字数: ch[0].w,
+    条目数: th[0].c,
+    角色数: en[0].c,
+  }
+  return { ok: true, output: JSON.stringify(stats, null, 2) }
+}

+ 21 - 0
v7/src/commands/report-weak-hook-streak.js

@@ -0,0 +1,21 @@
+/**
+ * report-weak-hook-streak → 末尾连续弱钩章数(全书近况 / 机检"连续弱钩上限"用)
+ * 钩子值形如"危机钩-强"、"情绪钩-弱",弱钩判定:含"弱钩"或以"-弱"结尾(design §6.3)。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const rows = await ctx.cache.query(
+    'SELECT chapter_num, hook_type FROM chapters ORDER BY chapter_num DESC LIMIT 20'
+  )
+
+  let streak = 0
+  for (const ch of rows) {
+    const h = ch.hook_type || ''
+    if (h.includes('弱钩') || h.endsWith('-弱')) {
+      streak++
+    } else {
+      break
+    }
+  }
+  return { ok: true, output: JSON.stringify({ streak }, null, 2) }
+}

+ 32 - 0
v7/test/commands/grep-story.test.js

@@ -0,0 +1,32 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/grep-story.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('grep-story 关键词命中正文,返回章号与匹配行', async () => {
+  const r = await run(['玉佩'], {}, ctx)
+  assert.equal(r.ok, true)
+  const hits = JSON.parse(r.output)
+  assert.ok(hits.length >= 1)
+  assert.ok(hits.every((h) => h.匹配行.includes('玉佩')))
+  assert.ok(hits.some((h) => h.章号 === 1))
+})
+
+test('grep-story 无命中 → ok 且空数组', async () => {
+  const r = await run(['绝不存在的词xyz'], {}, ctx)
+  assert.equal(r.ok, true)
+  assert.deepEqual(JSON.parse(r.output), [])
+})
+
+test('grep-story 缺关键词 → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})

+ 37 - 0
v7/test/commands/list-chapters.test.js

@@ -0,0 +1,37 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/list-chapters.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('list-chapters --章定位=推进 返回该定位的章节', async () => {
+  const r = await run([], { 章定位: '推进' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  const nums = rows.map((c) => c.chapter_num).sort()
+  assert.deepEqual(nums, [1, 2])
+})
+
+test('list-chapters --章定位=推进 --卷=1 受卷过滤', async () => {
+  const r = await run([], { 章定位: '推进', 卷: '1' }, ctx)
+  assert.equal(r.ok, true)
+  assert.equal(JSON.parse(r.output).length, 2)
+})
+
+test('list-chapters 空结果(无日常章)→ ok 且空数组', async () => {
+  const r = await run([], { 章定位: '日常' }, ctx)
+  assert.equal(r.ok, true)
+  assert.deepEqual(JSON.parse(r.output), [])
+})
+
+test('list-chapters 缺 --章定位 → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})

+ 22 - 0
v7/test/commands/list-secrets.test.js

@@ -0,0 +1,22 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/list-secrets.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('list-secrets 列出未揭晓信息差,含蓄积章数', async () => {
+  const r = await run([], { 'reader-knows': 'false' }, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  const one = rows.find((s) => s.id === '信息差-001')
+  assert.ok(one, `应含 信息差-001,实际:${r.output}`)
+  // 当前最大章 2 − 登记章 1 = 1
+  assert.equal(one.蓄积章数, 1)
+})

+ 22 - 0
v7/test/commands/report-book-stats.test.js

@@ -0,0 +1,22 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/report-book-stats.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('report-book-stats 返回总章数/总字数/条目数/角色数', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, true)
+  const s = JSON.parse(r.output)
+  assert.equal(s.总章数, 2)
+  assert.equal(s.总字数, 5400) // 2800 + 2600
+  assert.equal(s.条目数, 3) // 伏笔-001 + 悬念-001 + 感情线-001
+  assert.equal(s.角色数, 2) // 林晚 + 神秘老者
+})

+ 32 - 0
v7/test/commands/report-weak-hook-streak.test.js

@@ -0,0 +1,32 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/report-weak-hook-streak.js'
+import { fixtureCtx, repoCtx } from './_helper.js'
+
+test('report-weak-hook-streak fixture 末尾无弱钩 → streak 0', async () => {
+  const { ctx, cleanup } = await fixtureCtx()
+  try {
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, true)
+    assert.equal(JSON.parse(r.output).streak, 0)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('report-weak-hook-streak 统计末尾连续弱钩(design §6.3)', async () => {
+  const { ctx, cleanup } = await repoCtx(null, {
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/正文/0001-起.md': '---\n章号: 1\n标题: 起\n卷: 1\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n---\n正文。',
+    '定稿/正文/0002-承.md': '---\n章号: 2\n标题: 承\n卷: 1\n字数: 100\n章定位: 推进\n钩子: 情绪钩-弱\n---\n正文。',
+    '定稿/正文/0003-转.md': '---\n章号: 3\n标题: 转\n卷: 1\n字数: 100\n章定位: 推进\n钩子: 悬念钩-弱\n---\n正文。',
+  })
+  try {
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, true)
+    // 第3、2章弱钩,第1章强钩 → 末尾连续 2
+    assert.equal(JSON.parse(r.output).streak, 2)
+  } finally {
+    await cleanup()
+  }
+})