|
| 1 | +import {displayMarkdown} from "./roadmap-markdown-display.mjs"; |
| 2 | + |
| 3 | +/* Classification is a display projection; it never changes the source or tracking model. */ |
| 4 | +export const sectionRules = [ |
| 5 | + {category: "supplement", claim: "historical", pattern: /histor(?:y|ical)|timeline|(?:delivery|release|status|progress)\s+(?:snapshot|update)|live\s+pr\s+status|delivery\s+and\s+next\s+steps|(?:执行|交付|发布)顺序|历史|时间线|快照|交付进展|当前\s*pr\s*状态/i}, |
| 6 | + {category: "scope", pattern: /non[ -]?goals?|out[ -]of[ -]scope|scope|非目标|不(?:在|包括|包含).*范围|范围|边界/i}, |
| 7 | + {category: "goals", pattern: /(?:^|\b)(?:goals?|objectives?|expected\s+outcomes?)(?:\b|$)|目标|预期成果|期望结果/i}, |
| 8 | + {category: "risks", pattern: /risks?|open\s+(?:questions?|issues?)|unresolved|feedback\s+period|风险|未决|开放问题|反馈期/i}, |
| 9 | + {category: "references", pattern: /related\s+work|references?|resources?|bibliography|参考|相关工作|相关资料/i}, |
| 10 | + {category: "acceptance", pattern: /acceptance|success\s+criteria|validation\s+criteria|验收|成功标准/i}, |
| 11 | + {category: "design", claim: "source", pattern: /performance|benchmark|measurement|estimat(?:e|ion)|measured|experiment\s+plan|性能|测量|估算|基准|实验计划/i}, |
| 12 | + {category: "design", pattern: /design|architectur|alternatives?|models?|execution\s+semantics|(?:proposed\s+)?solution|approach|implementation\s+details|pr\s+review\s+contract|事实与依据|备选|方案|设计|模型|架构|执行语义|评审契约/i}, |
| 13 | + {category: "overview", pattern: /(?:^|\b)(?:topic|summary|overview|motivation|background|problem)(?:\b|$)|概述|摘要|背景|动机|问题/i}, |
| 14 | + {category: "work", pattern: /feature\s+roadmap|(?:implementation|work)\s+plan|工作计划|工作拆分|工作轨道|实现计划|功能路线图/i}, |
| 15 | +]; |
| 16 | + |
| 17 | +const categories = ["overview", "goals", "scope", "design", "risks", "references", "supplement", "acceptance"]; |
| 18 | + |
| 19 | +function classification(title, parent, featureHeading = true) { |
| 20 | + if (parent?.featureId) return {category: "work", claim: parent.claim, featureId: parent.featureId}; |
| 21 | + const feature = featureHeading && title.match(/^([A-Za-z][A-Za-z0-9_-]*)[.、:]\s+(.+)/); |
| 22 | + if (feature) return {category: "work", claim: "source", featureId: feature[1]}; |
| 23 | + // A historical section stays historical even when a child is named "Goals". |
| 24 | + if (parent?.claim === "historical") return {category: "supplement", claim: "historical", featureId: null}; |
| 25 | + // Domain headings inside goals or acceptance must not become model/design claims. |
| 26 | + if (["goals", "scope", "risks", "references", "acceptance"].includes(parent?.category)) { |
| 27 | + return {category: parent.category, claim: parent.claim, featureId: null}; |
| 28 | + } |
| 29 | + const rule = sectionRules.find(rule => rule.pattern.test(title)); |
| 30 | + return {category: rule?.category || parent?.category || "supplement", claim: rule?.claim || parent?.claim || null, featureId: null}; |
| 31 | +} |
| 32 | + |
| 33 | +function commentLines(lines, tokens) { |
| 34 | + const fenced = new Set(); |
| 35 | + for (const token of tokens) if (["fence", "code_block"].includes(token.type) && token.map) { |
| 36 | + for (let line = token.map[0]; line < token.map[1]; line += 1) fenced.add(line); |
| 37 | + } |
| 38 | + const hidden = new Set(); |
| 39 | + for (let index = 0; index < lines.length; index += 1) { |
| 40 | + if (fenced.has(index) || !/^ {0,3}(?:\\)?<!--/.test(lines[index])) continue; |
| 41 | + let end = index; |
| 42 | + while (end < lines.length && !lines[end].includes("-->")) end += 1; |
| 43 | + if (end < lines.length && !lines[end].slice(lines[end].indexOf("-->") + 3).trim()) { |
| 44 | + for (let line = index; line <= end; line += 1) hidden.add(line); |
| 45 | + index = end; |
| 46 | + } |
| 47 | + } |
| 48 | + return hidden; |
| 49 | +} |
| 50 | + |
| 51 | +/** Project original Markdown into exclusive fragments with 1-based source spans. */ |
| 52 | +export function projectRFC(body, markdownParser) { |
| 53 | + const source = String(body || ""), lines = source.split(/\r?\n/); |
| 54 | + const tokens = markdownParser.parse(source, {}); |
| 55 | + const comments = commentLines(lines, tokens); |
| 56 | + const suppressed = new Set(comments); |
| 57 | + // Match the graph module's source grammar; other diagrams remain readable code. |
| 58 | + for (const token of tokens) if (token.type === "fence" && token.markup[0] === "`" && /^mermaid\s*$/.test(token.info) && |
| 59 | + /^\s*(?:flowchart|graph)\s+(?:LR|RL|TD|TB|BT)\b/.test(token.content) && token.map) { |
| 60 | + for (let line = token.map[0]; line < token.map[1]; line += 1) suppressed.add(line); |
| 61 | + } |
| 62 | + const headings = []; |
| 63 | + for (let index = 0; index < tokens.length; index += 1) { |
| 64 | + const token = tokens[index]; |
| 65 | + if (token.type !== "heading_open" || token.level !== 0 || !token.map || comments.has(token.map[0])) continue; |
| 66 | + const inline = tokens[index + 1]; |
| 67 | + const title = (inline.children || []).map(child => child.type === "image" ? child.content : child.content || "").join("").trim(); |
| 68 | + headings.push({title, level: Number(token.tag.slice(1)), start: token.map[0], contentStart: token.map[1]}); |
| 69 | + } |
| 70 | + const result = Object.fromEntries(categories.map(category => [category, []])); |
| 71 | + result.tree = []; |
| 72 | + result.featureSections = Object.create(null); |
| 73 | + const stack = []; |
| 74 | + const clean = (start, end) => displayMarkdown(lines.slice(start, end).map((line, offset) => suppressed.has(start + offset) ? "" : line).join("\n")).trim(); |
| 75 | + const preambleEnd = headings[0]?.start ?? lines.length; |
| 76 | + const preamble = clean(0, preambleEnd); |
| 77 | + if (preamble) { |
| 78 | + const fragment = {id: "section-preamble", title: "概述", level: 0, startLine: 1, endLine: preambleEnd, markdown: preamble, category: "overview", claim: null, featureId: null, parentId: null, children: []}; |
| 79 | + result.overview.push(fragment); result.tree.push(fragment); |
| 80 | + } |
| 81 | + headings.forEach((heading, index) => { |
| 82 | + while (stack.length && stack.at(-1).level >= heading.level) stack.pop(); |
| 83 | + const parent = stack.at(-1), next = headings[index + 1]; |
| 84 | + const assignment = classification(heading.title, parent, heading.level >= 3); |
| 85 | + const fragment = {id: `section-${heading.start + 1}`, title: heading.title, level: heading.level, |
| 86 | + startLine: heading.start + 1, endLine: next?.start ?? lines.length, |
| 87 | + markdown: clean(heading.contentStart, next?.start ?? lines.length), |
| 88 | + ...assignment, parentId: parent?.id || null, children: []}; |
| 89 | + if (parent) parent.children.push(fragment); else result.tree.push(fragment); |
| 90 | + stack.push(fragment); |
| 91 | + // Heading containers have no duplicated body; empty containers remain in the tree. |
| 92 | + if (!fragment.markdown) return; |
| 93 | + if (fragment.featureId) { |
| 94 | + (result.featureSections[fragment.featureId] ||= []).push(fragment); |
| 95 | + } else if (fragment.category === "work") { |
| 96 | + result.supplement.push({...fragment, category: "supplement"}); |
| 97 | + } else { |
| 98 | + result[fragment.category].push(fragment); |
| 99 | + } |
| 100 | + }); |
| 101 | + // Tree spans include descendants while fragment spans remain exclusive. |
| 102 | + const finish = nodes => nodes.forEach(node => { |
| 103 | + finish(node.children); |
| 104 | + node.subtreeEndLine = node.children.at(-1)?.subtreeEndLine ?? node.endLine; |
| 105 | + }); |
| 106 | + finish(result.tree); |
| 107 | + return result; |
| 108 | +} |
| 109 | + |
| 110 | +function timestamp(value) { |
| 111 | + if (value === null || value === undefined || value === "") return null; |
| 112 | + const numeric = typeof value === "number" || (typeof value === "string" && /^[+-]?\d+(?:\.\d+)?$/.test(value)); |
| 113 | + const parsed = numeric ? Number(value) * (Math.abs(Number(value)) < 1e12 ? 1000 : 1) : Date.parse(value); |
| 114 | + return Number.isFinite(parsed) ? parsed : null; |
| 115 | +} |
| 116 | + |
| 117 | +const evidenceTimestamp = evidence => timestamp(evidence.recorded_at ?? evidence.verified_at ?? evidence.at); |
| 118 | + |
| 119 | +function currentEvidence(criterion) { |
| 120 | + return (criterion.evidence || []).filter(evidence => evidence && typeof evidence === "object" && !evidence.stale && evidence.revision && evidence.environment) |
| 121 | + .sort((a, b) => (evidenceTimestamp(b) ?? 0) - (evidenceTimestamp(a) ?? 0)); |
| 122 | +} |
| 123 | + |
| 124 | +/** Outcomes are authorized tracking facts, never performance claims in the prose. */ |
| 125 | +export function outcomeModel(rfc, projection) { |
| 126 | + const features = (rfc.features || []).filter(feature => !feature.dropped); |
| 127 | + const criteria = rfc.criteria || []; |
| 128 | + const implementation = {total: features.length, implemented: 0, partial: 0, inProgress: 0, blocked: 0, planned: 0}; |
| 129 | + for (const feature of features) { |
| 130 | + const state = feature.implementation || feature.state || "planned"; |
| 131 | + const key = state === "in_progress" ? "inProgress" : ["implemented", "partial", "blocked"].includes(state) ? state : "planned"; |
| 132 | + implementation[key] += 1; |
| 133 | + } |
| 134 | + const acceptance = {total: criteria.length, passed: 0, failed: 0, unverified: 0, waived: 0}; |
| 135 | + const verified = []; |
| 136 | + for (const criterion of criteria) { |
| 137 | + const evidence = currentEvidence(criterion); |
| 138 | + const verdict = criterion.verdict; |
| 139 | + if (verdict === "passing" && evidence.length) { |
| 140 | + acceptance.passed += 1; |
| 141 | + verified.push({criterionId: criterion.id, featureId: criterion.feature_ids?.find(id => features.some(feature => feature.id === id)) || null, |
| 142 | + title: criterion.title, evidence: evidence[0], verifiedAt: evidenceTimestamp(evidence[0]) === null ? null : new Date(evidenceTimestamp(evidence[0])).toISOString()}); |
| 143 | + } else if (verdict === "failing") acceptance.failed += 1; |
| 144 | + else if (verdict === "waived") acceptance.waived += 1; |
| 145 | + else acceptance.unverified += 1; |
| 146 | + } |
| 147 | + verified.sort((a, b) => (timestamp(b.verifiedAt) ?? 0) - (timestamp(a.verifiedAt) ?? 0)); |
| 148 | + const next = [], seen = new Set(); |
| 149 | + const add = item => { const key = `${item.kind}:${item.id}`; if (!seen.has(key)) { seen.add(key); next.push(item); } }; |
| 150 | + for (const feature of features) if (feature.blockers?.length || (feature.implementation || feature.state) === "blocked") { |
| 151 | + add({kind: "feature", id: feature.id, featureId: feature.id, criterionId: null, title: feature.title, |
| 152 | + reason: feature.blockers?.length ? `等待:${feature.blockers.join("、")}` : "工作被阻塞"}); |
| 153 | + } |
| 154 | + for (const criterion of [...criteria].sort((a, b) => Number(b.verdict === "failing") - Number(a.verdict === "failing"))) { |
| 155 | + if (criterion.verdict === "waived" || (criterion.verdict === "passing" && currentEvidence(criterion).length)) continue; |
| 156 | + add({kind: "criterion", id: criterion.id, featureId: criterion.feature_ids?.find(id => features.some(feature => feature.id === id)) || null, |
| 157 | + criterionId: criterion.id, title: criterion.title, reason: criterion.verdict === "failing" ? "验收未通过,需修复并重新验证" : "补充当前版本与环境的验收证据"}); |
| 158 | + } |
| 159 | + for (const feature of features) if ((feature.implementation || feature.state) !== "implemented") { |
| 160 | + add({kind: "feature", id: feature.id, featureId: feature.id, criterionId: null, title: feature.title, reason: "推进剩余实现工作"}); |
| 161 | + } |
| 162 | + if (!features.length && !criteria.length) add({kind: "work", id: "work", featureId: null, criterionId: null, title: "补充工作拆分与验收标准", reason: "当前 RFC 尚未建立可验证的工作计划"}); |
| 163 | + return {goals: projection.goals, implementation, acceptance, verified: verified.slice(0, 3), next: next.slice(0, 3)}; |
| 164 | +} |
0 commit comments