hook-ledger-validator.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /**
  2. * Phase 9-3: hard gate that a chapter draft actually acts on the hook ledger
  3. * the planner declared in the memo's "## 本章 hook 账" / "## Hook ledger for
  4. * this chapter" section.
  5. *
  6. * The planner commits, per chapter, to:
  7. * - advance: <hook_id> "name" → state-change
  8. * - resolve: <hook_id> "name" → action
  9. *
  10. * This validator parses those two lists and checks that each committed
  11. * hook_id is echoed somewhere in the draft body. A silent failure would
  12. * let the planner promise advancement the writer never delivered — exactly
  13. * the "debt keeps piling up" failure mode Phase 9 is meant to prevent.
  14. *
  15. * A missing echo is a "critical" severity violation so the review cycle
  16. * forces a reviser pass instead of shipping.
  17. */
  18. export interface HookLedgerViolation {
  19. readonly severity: "critical";
  20. readonly category: string;
  21. readonly description: string;
  22. readonly suggestion: string;
  23. }
  24. export interface HookLedger {
  25. readonly open: ReadonlyArray<string>;
  26. readonly advance: ReadonlyArray<string>;
  27. readonly resolve: ReadonlyArray<string>;
  28. readonly defer: ReadonlyArray<string>;
  29. }
  30. const LEDGER_HEADING_PATTERNS = [
  31. /^#{2,3}\s*本章\s*hook\s*账\s*$/im,
  32. /^#{2,3}\s*Hook\s+ledger\s+for\s+this\s+chapter\s*$/im,
  33. ];
  34. const SUBSECTION_KEYS: ReadonlyArray<keyof HookLedger> = ["open", "advance", "resolve", "defer"];
  35. /**
  36. * Extract the hook ledger's four sub-lists from a memo body. Hook IDs follow
  37. * the pending_hooks convention — typically H###, S###, or an alphanumeric
  38. * token. We accept any token matching [A-Za-z][A-Za-z0-9_-]{0,15} so the
  39. * validator is robust to ID style differences between books.
  40. */
  41. export function parseHookLedger(memoBody: string): HookLedger {
  42. const section = extractLedgerSection(memoBody);
  43. if (!section) {
  44. return { open: [], advance: [], resolve: [], defer: [] };
  45. }
  46. const result: Record<keyof HookLedger, string[]> = {
  47. open: [],
  48. advance: [],
  49. resolve: [],
  50. defer: [],
  51. };
  52. let current: keyof HookLedger | null = null;
  53. for (const rawLine of section.split(/\r?\n/)) {
  54. const line = rawLine.trim();
  55. if (line.length === 0) continue;
  56. const subHeadingMatch = line.match(/^(open|advance|resolve|defer)\s*[::]?\s*$/i);
  57. if (subHeadingMatch) {
  58. current = subHeadingMatch[1]!.toLowerCase() as keyof HookLedger;
  59. continue;
  60. }
  61. if (!current) continue;
  62. if (!line.startsWith("-")) continue;
  63. const id = extractHookIdFromLine(line);
  64. if (id) result[current].push(id);
  65. }
  66. return result;
  67. }
  68. /**
  69. * Enforce: every hook_id declared under advance / resolve must appear in the
  70. * draft text. We do NOT validate `open` (new hooks by definition don't have
  71. * a pre-existing ID to echo) or `defer` (deferred = deliberately not touched).
  72. */
  73. export function validateHookLedger(
  74. memoBody: string,
  75. draftContent: string,
  76. ): ReadonlyArray<HookLedgerViolation> {
  77. const ledger = parseHookLedger(memoBody);
  78. const committedIds = dedupe([...ledger.advance, ...ledger.resolve]);
  79. if (committedIds.length === 0) return [];
  80. const violations: HookLedgerViolation[] = [];
  81. for (const id of committedIds) {
  82. if (!draftEchoesHookId(draftContent, id)) {
  83. violations.push({
  84. severity: "critical",
  85. category: "hook 账未兑现",
  86. description: `memo 在 advance/resolve 里声明要处理 ${id},但正文没有对应的落地动作`,
  87. suggestion: `在正文中加入对 ${id} 的具体情节推进(动作、对话、环境变化),或把它从 hook 账里移到 defer 并给出理由`,
  88. });
  89. }
  90. }
  91. return violations;
  92. }
  93. function extractLedgerSection(memoBody: string): string | undefined {
  94. for (const pattern of LEDGER_HEADING_PATTERNS) {
  95. const match = memoBody.match(pattern);
  96. if (!match || match.index === undefined) continue;
  97. const start = match.index + match[0].length;
  98. const rest = memoBody.slice(start);
  99. // Stop at the next H2/H3 heading.
  100. const nextHeading = rest.match(/\n#{2,3}\s/);
  101. const end = nextHeading ? nextHeading.index ?? rest.length : rest.length;
  102. return rest.slice(0, end);
  103. }
  104. return undefined;
  105. }
  106. function extractHookIdFromLine(line: string): string | undefined {
  107. // Strip leading "-" bullet and any trailing punctuation before checking.
  108. const cleaned = line.replace(/^-+\s*/, "").trim();
  109. if (cleaned.startsWith("[new]") || cleaned.startsWith("[NEW]")) return undefined;
  110. // Hook IDs: H007, S004, hook_12, 主线-01, etc. Match a token that starts
  111. // with a letter/CJK and contains [A-Za-z0-9_-]. We stop at whitespace or
  112. // quote so "H007 \"xxx\"" cleanly yields "H007".
  113. const idMatch = cleaned.match(/^([A-Za-z\u4e00-\u9fff][A-Za-z0-9_\-\u4e00-\u9fff]{0,19})/);
  114. if (!idMatch) return undefined;
  115. const candidate = idMatch[1]!;
  116. // Reject obvious non-IDs like "open", "advance" that slipped through.
  117. if (/^(open|advance|resolve|defer|new)$/i.test(candidate)) return undefined;
  118. return candidate;
  119. }
  120. function draftEchoesHookId(draftContent: string, id: string): boolean {
  121. // Exact token match — word boundary for ASCII, simple substring for CJK.
  122. if (/^[A-Za-z0-9_-]+$/.test(id)) {
  123. const pattern = new RegExp(`\\b${escapeRegex(id)}\\b`);
  124. if (pattern.test(draftContent)) return true;
  125. } else {
  126. if (draftContent.includes(id)) return true;
  127. }
  128. // Also accept the hook's name quoted in the ledger line — but we only have
  129. // the ID here. Name-based matching is the responsibility of the caller if
  130. // it wants softer gating. This validator stays strict on IDs.
  131. return false;
  132. }
  133. function dedupe(values: ReadonlyArray<string>): string[] {
  134. return [...new Set(values)];
  135. }
  136. function escapeRegex(value: string): string {
  137. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  138. }
  139. export const INTERNAL = {
  140. SUBSECTION_KEYS,
  141. extractLedgerSection,
  142. extractHookIdFromLine,
  143. };