sillyspec 3.28.7 → 3.28.9
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/.claude/skills/sillyspec-quick/SKILL.md +2 -0
- package/.husky/pre-push +3 -1
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/change-risk-profile.js +20 -0
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +32 -0
- package/src/constants.js +3 -0
- package/src/db.js +23 -6
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +108 -6
- package/src/doctor-diagnostics.js +243 -9
- package/src/friction-ledger.js +171 -0
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +72 -30
- package/src/index.js +356 -81
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +224 -43
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/shared.js +3 -2
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +123 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +199 -32
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +124 -15
- package/src/run/complete-handlers.js +514 -24
- package/src/run/complete.js +82 -25
- package/src/run/concurrent-detect.js +99 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +85 -14
- package/src/run/prompt.js +143 -6
- package/src/run/quick-audit.js +136 -15
- package/src/run/shared.js +174 -20
- package/src/run/stage.js +6 -0
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +365 -35
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +29 -12
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +91 -4
- package/src/stages/knowledge.js +52 -3
- package/src/stages/plan-postcheck.js +57 -12
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +114 -6
- package/src/sync.js +201 -11
- package/src/task-review.js +333 -27
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +41 -6
- package/src/verify-probes.js +348 -7
- package/src/worktree-apply.js +594 -62
- package/src/worktree-cross.js +12 -18
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +255 -30
- package/src/wt-commit.js +128 -0
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/sa-rename-probe/src/new.js +0 -4
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-classify.js — knowledge classify 子命令核心(2026-09-14-knowledge-loop-close task-02)
|
|
3
|
+
*
|
|
4
|
+
* 职责:把 knowledge/uncategorized.md 中的条目迁移归类到目标知识文件,四步动作:
|
|
5
|
+
* ①解析条目 → ②追加目标知识文件(`## <标题>` + 原文,先追加后删除保内容可 revert)
|
|
6
|
+
* → ③INDEX.md 对应分类段补路由行(keywords 显式传或标题分词兜底,anchor=条目标题
|
|
7
|
+
* 机械生成,先例 knowledge/INDEX.md `#平台审核占位`)→ ④从 uncategorized.md 删除该段。
|
|
8
|
+
*
|
|
9
|
+
* 条目寻址三通道(X-001 双格式 + --title 兜底,按优先级):
|
|
10
|
+
* 1. 标题行前缀 `## <qlId> | <标题>`(knowledge.js validate 契约格式)
|
|
11
|
+
* 2. 正文尾注 `(<qlId>)`(中文括号,quick.js 收尾指引格式;半角括号容忍)
|
|
12
|
+
* 3. titleFallback 模糊匹配条目标题(无 ql 标注历史条目兜底)
|
|
13
|
+
*
|
|
14
|
+
* 幂等:目标文件已含同标题条目 → 跳过追加(skippedReason='duplicate-title')只做迁移
|
|
15
|
+
* 收尾(INDEX 路由 + 删除原条目);INDEX 已含同 file#anchor 路由 → 不重复加行。
|
|
16
|
+
* --dry-run 只渲染将要发生的变更(返回 plan),不落盘(含审计零写盘)。
|
|
17
|
+
*
|
|
18
|
+
* 审计:归类动作经 task-01 的 appendKnowledgeHit(KnowledgeHitsAPI)落一行 type:classify
|
|
19
|
+
* 进 .runtime/knowledge-hits.jsonl;runtimeDir 缺省 = knowledgeDir/../.runtime。
|
|
20
|
+
*
|
|
21
|
+
* CRLF/LF 双容忍:读侧 \r\n 归一解析,写侧按各文件检测到的既有 EOL 回写/追加
|
|
22
|
+
* (uncategorized.md 可能 CRLF,主仓知识文件为 LF)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { appendFileSync, existsSync, readFileSync } from 'fs'
|
|
26
|
+
import { basename, dirname, join } from 'path'
|
|
27
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
28
|
+
import { appendKnowledgeHit } from './knowledge-hits.js'
|
|
29
|
+
|
|
30
|
+
// ── 常量与内部工具 ──
|
|
31
|
+
|
|
32
|
+
// 目标文件(INDEX 相对 POSIX 路径)→ INDEX.md 分类段标题。
|
|
33
|
+
// decisions/ 目录按前缀判定为 Decisions;四类之外的目标文件无处落路由行 → unknown_category。
|
|
34
|
+
const CATEGORY_SECTIONS = {
|
|
35
|
+
'known-issues.md': 'Known Issues',
|
|
36
|
+
'patterns.md': 'Patterns',
|
|
37
|
+
'conventions.md': 'Conventions',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 标题行 qlId 标注形态:`## ql-20260604-001-7a4c | <标题>` 的首 token
|
|
41
|
+
const QL_TOKEN_RE = /^(?:ql|quick)-[a-z0-9][a-z0-9-]*$/i
|
|
42
|
+
|
|
43
|
+
function escapeRegExp(s) {
|
|
44
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function detectEol(content) {
|
|
48
|
+
return content.includes('\r\n') ? '\r\n' : '\n'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 目标文件路径归一:反斜杠转 POSIX、剥 './' 与冗余 'knowledge/' 前缀(--file 容错)。
|
|
53
|
+
* 'knowledge\known-issues.md' / './known-issues.md' → 'known-issues.md'
|
|
54
|
+
*/
|
|
55
|
+
function normalizeTargetFile(targetFile) {
|
|
56
|
+
let f = String(targetFile || '').trim().replace(/\\/g, '/')
|
|
57
|
+
if (!f) return ''
|
|
58
|
+
f = f.replace(/^\.\//, '')
|
|
59
|
+
if (f.startsWith('knowledge/')) f = f.slice('knowledge/'.length)
|
|
60
|
+
return f
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 目标文件 → INDEX.md 分类段标题(known-issues/patterns/conventions/decisions 四类,其余 null) */
|
|
64
|
+
function categoryForTarget(file) {
|
|
65
|
+
if (/^decisions\//.test(file)) return 'Decisions'
|
|
66
|
+
return CATEGORY_SECTIONS[basename(file)] || null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 标题分词兜底(--keywords 未显式传时的 keywords 来源,X-005):
|
|
71
|
+
* 按空白 + Unicode 标点/符号切段('|' 与 '→' 均 \p{S}/\p{P},不会泄进关键词),
|
|
72
|
+
* 滤 <2 字符噪音 token,去重封顶 6 个;中文长短语保持整段
|
|
73
|
+
* (keywordMatchesContext 对非 ASCII 关键词用子串匹配,短语可命中)。
|
|
74
|
+
*/
|
|
75
|
+
function tokenizeTitle(title) {
|
|
76
|
+
return [...new Set(
|
|
77
|
+
String(title || '')
|
|
78
|
+
.split(/[\s\p{P}\p{S}]+/u)
|
|
79
|
+
.map(t => t.trim())
|
|
80
|
+
.filter(t => t.length >= 2)
|
|
81
|
+
)].slice(0, 6)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** keywords 解析:显式传优先(滤空与含 '→'/换行的废值);缺省/为空 → 标题分词兜底;仍空 → 标题本身 */
|
|
85
|
+
function resolveKeywords(keywords, title) {
|
|
86
|
+
const explicit = (Array.isArray(keywords) ? keywords : [keywords])
|
|
87
|
+
.map(k => String(k || '').trim())
|
|
88
|
+
.filter(k => k && !k.includes('→') && !k.includes('\n'))
|
|
89
|
+
if (explicit.length > 0) return explicit
|
|
90
|
+
const tokens = tokenizeTitle(title)
|
|
91
|
+
return tokens.length > 0 ? tokens : [String(title || 'uncategorized')]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── uncategorized.md 条目解析(双格式寻址底座) ──
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析 uncategorized.md(\r\n 已归一的正文)为条目列表。
|
|
98
|
+
* 条目边界:`^#{2,3}\s+\S` 标题行——与 knowledge.js validate 的计数正则
|
|
99
|
+
* `/^#{2,3}\s+\S/gm` 同口径(h3 形态一并兼容);h1 行终止当前条目但自身不是条目;
|
|
100
|
+
* h4 归入正文。start/end 为行号区间 [start, end),end 止于下一标题边界行。
|
|
101
|
+
* @returns {{ start: number, end: number, headingText: string, qlIdPrefix: string|null,
|
|
102
|
+
* title: string, body: string, text: string }[]}
|
|
103
|
+
*/
|
|
104
|
+
function parseUncategorizedEntries(normalizedContent) {
|
|
105
|
+
const lines = normalizedContent.split('\n')
|
|
106
|
+
const boundaryRe = /^#{1,3}\s+\S/
|
|
107
|
+
const entryHeadingRe = /^#{2,3}\s+\S/
|
|
108
|
+
const entries = []
|
|
109
|
+
let cur = null
|
|
110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
111
|
+
if (!boundaryRe.test(lines[i])) continue
|
|
112
|
+
if (cur) { cur.end = i; entries.push(cur); cur = null }
|
|
113
|
+
if (entryHeadingRe.test(lines[i])) {
|
|
114
|
+
cur = { start: i, end: lines.length, headingText: lines[i].replace(/^#{2,3}\s+/, '').trim() }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (cur) entries.push(cur)
|
|
118
|
+
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
// 标题行 qlId 前缀剥离:`## ql-xxx | <标题>` → title=`<标题>`;非 ql 形态首 token 不剥
|
|
121
|
+
const m = e.headingText.match(/^(\S+)\s*\|\s*(.*)$/)
|
|
122
|
+
if (m && QL_TOKEN_RE.test(m[1])) {
|
|
123
|
+
e.qlIdPrefix = m[1]
|
|
124
|
+
e.title = m[2].trim() || e.headingText
|
|
125
|
+
} else {
|
|
126
|
+
e.qlIdPrefix = null
|
|
127
|
+
e.title = e.headingText
|
|
128
|
+
}
|
|
129
|
+
e.body = lines.slice(e.start + 1, e.end).join('\n').trim()
|
|
130
|
+
e.text = e.headingText + '\n' + e.body
|
|
131
|
+
}
|
|
132
|
+
return entries
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 三通道寻址:qlId 标题行前缀 → qlId 正文尾注(中文/半角括号)→ titleFallback 模糊(首个命中) */
|
|
136
|
+
function locateEntry(entries, qlId, titleFallback) {
|
|
137
|
+
if (qlId) {
|
|
138
|
+
const headingRe = new RegExp(`^${escapeRegExp(qlId)}\\s*\\|`)
|
|
139
|
+
const byHeading = entries.find(e => headingRe.test(e.headingText))
|
|
140
|
+
if (byHeading) return { entry: byHeading, channel: 'ql-heading' }
|
|
141
|
+
const full = `(${qlId})`
|
|
142
|
+
const half = `(${qlId})`
|
|
143
|
+
const bySuffix = entries.find(e => e.text.includes(full) || e.text.includes(half))
|
|
144
|
+
if (bySuffix) return { entry: bySuffix, channel: 'ql-suffix' }
|
|
145
|
+
}
|
|
146
|
+
if (titleFallback) {
|
|
147
|
+
const needle = String(titleFallback).toLowerCase()
|
|
148
|
+
const byTitle = entries.find(e =>
|
|
149
|
+
e.title.toLowerCase().includes(needle) || e.headingText.toLowerCase().includes(needle))
|
|
150
|
+
if (byTitle) return { entry: byTitle, channel: 'title-fallback' }
|
|
151
|
+
}
|
|
152
|
+
return { entry: null, channel: null }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* INDEX.md 分类段内(或文件末尾新段)插入路由行,返回新的归一正文('\n' 行尾)。
|
|
157
|
+
* 只增不改既有行;插入点 = 分类段内最后一个非空行之后(段缺失时 EOF 追加 `## <段>` + 行)。
|
|
158
|
+
*/
|
|
159
|
+
function insertRouteLine(indexNorm, category, routeLine) {
|
|
160
|
+
const lines = indexNorm.split('\n')
|
|
161
|
+
const sectionRe = new RegExp(`^##\\s+${escapeRegExp(category)}\\s*$`, 'i')
|
|
162
|
+
let sectStart = -1
|
|
163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
164
|
+
if (sectionRe.test(lines[i])) { sectStart = i; break }
|
|
165
|
+
}
|
|
166
|
+
if (sectStart < 0) {
|
|
167
|
+
return indexNorm.replace(/\n+$/, '') + `\n\n## ${category}\n${routeLine}\n`
|
|
168
|
+
}
|
|
169
|
+
let lastContent = sectStart
|
|
170
|
+
for (let i = sectStart + 1; i < lines.length; i++) {
|
|
171
|
+
if (/^##\s+\S/.test(lines[i])) break
|
|
172
|
+
if (lines[i].trim() !== '') lastContent = i
|
|
173
|
+
}
|
|
174
|
+
lines.splice(lastContent + 1, 0, routeLine)
|
|
175
|
+
return lines.join('\n')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 核心 ──
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 归类单个 uncategorized 条目到目标知识文件(四步迁移)。
|
|
182
|
+
*
|
|
183
|
+
* @param {object} params
|
|
184
|
+
* @param {string} params.knowledgeDir - knowledge 目录路径
|
|
185
|
+
* @param {string} [params.qlId] - quicklog 条目 ID(标题行前缀 ∪ 正文尾注双格式寻址)
|
|
186
|
+
* @param {string} params.targetFile - 目标知识文件(INDEX 相对,如 'known-issues.md')
|
|
187
|
+
* @param {string} [params.sectionTitle] - 覆盖落盘条目标题(--section;anchor 随实际标题)
|
|
188
|
+
* @param {string[]} [params.keywords] - INDEX 路由行关键词(缺省按条目标题分词兜底)
|
|
189
|
+
* @param {string} [params.titleFallback] - 无 ql 标注历史条目的模糊匹配兜底(--title)
|
|
190
|
+
* @param {boolean} [params.dryRun] - 只渲染将要发生的变更,不落盘
|
|
191
|
+
* @param {string} [params.runtimeDir] - 审计目录(缺省 knowledgeDir/../.runtime)
|
|
192
|
+
* @returns {{ ok: boolean, moved: boolean, appendedTo: string|null, indexUpdated: boolean,
|
|
193
|
+
* anchor: string, skippedReason?: string, error?: object,
|
|
194
|
+
* dryRun?: boolean, title?: string, channel?: string, keywords?: string[], plan?: object }}
|
|
195
|
+
* moved:真实迁移发生(条目已从 uncategorized 删除;dry-run 恒 false);
|
|
196
|
+
* skippedReason='duplicate-title':目标已含同标题条目,跳过追加只做迁移收尾。
|
|
197
|
+
*/
|
|
198
|
+
export function classifyUncategorizedEntry({ knowledgeDir, qlId, targetFile, sectionTitle, keywords, titleFallback, dryRun, runtimeDir } = {}) {
|
|
199
|
+
const fail = (code, extra = {}) => ({
|
|
200
|
+
ok: false, moved: false, appendedTo: null, indexUpdated: false, anchor: '',
|
|
201
|
+
error: { code, ...extra },
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ① 前置校验
|
|
205
|
+
const file = normalizeTargetFile(targetFile)
|
|
206
|
+
if (!knowledgeDir || !existsSync(knowledgeDir)) return fail('knowledge_dir_missing', { path: knowledgeDir || null })
|
|
207
|
+
if (!file) return fail('target_file_required')
|
|
208
|
+
if (!qlId && !titleFallback) return fail('address_required', { message: 'qlId 与 titleFallback 至少传一个' })
|
|
209
|
+
|
|
210
|
+
const uncatPath = join(knowledgeDir, 'uncategorized.md')
|
|
211
|
+
if (!existsSync(uncatPath)) return fail('uncategorized_missing', { path: 'knowledge/uncategorized.md' })
|
|
212
|
+
const uncatRaw = readFileSync(uncatPath, 'utf8')
|
|
213
|
+
const uncatEol = detectEol(uncatRaw)
|
|
214
|
+
const uncatNorm = uncatRaw.replace(/\r\n/g, '\n')
|
|
215
|
+
const entries = parseUncategorizedEntries(uncatNorm)
|
|
216
|
+
|
|
217
|
+
const { entry, channel } = locateEntry(entries, qlId, titleFallback)
|
|
218
|
+
if (!entry) return fail('entry_not_found', { qlId: qlId || null, titleFallback: titleFallback || null })
|
|
219
|
+
|
|
220
|
+
const entryTitle = entry.title || entry.headingText
|
|
221
|
+
const finalTitle = String(sectionTitle || '').trim() || entryTitle
|
|
222
|
+
const anchor = finalTitle // anchor=条目标题机械生成(X-005 / R-03)
|
|
223
|
+
|
|
224
|
+
// ② 追加目标知识文件(幂等:同标题条目已存在 → 跳过追加只做迁移收尾)
|
|
225
|
+
const targetPath = join(knowledgeDir, file)
|
|
226
|
+
if (!existsSync(targetPath)) return fail('target_file_missing', { targetFile: file, path: targetPath })
|
|
227
|
+
const targetRaw = readFileSync(targetPath, 'utf8')
|
|
228
|
+
const targetEol = detectEol(targetRaw)
|
|
229
|
+
const dupRe = new RegExp(`^##\\s+${escapeRegExp(finalTitle)}\\s*$`, 'm')
|
|
230
|
+
const duplicate = dupRe.test(targetRaw.replace(/\r\n/g, '\n'))
|
|
231
|
+
let appendBlock = null
|
|
232
|
+
let skippedReason
|
|
233
|
+
if (!duplicate) {
|
|
234
|
+
// 追加块:空行 + `## <标题>` + 空行 + 原文 + 收尾换行(按目标文件既有 EOL)
|
|
235
|
+
const body = entry.body ? entry.body.replace(/\n/g, targetEol) : ''
|
|
236
|
+
appendBlock = ['', `## ${finalTitle}`, '', ...(body ? [body] : []), ''].join(targetEol)
|
|
237
|
+
if (targetRaw && !targetRaw.endsWith('\n')) appendBlock = targetEol + appendBlock
|
|
238
|
+
} else {
|
|
239
|
+
skippedReason = 'duplicate-title'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ③ INDEX.md 对应分类段补路由行(不改既有行;同 file#anchor 已路由则不重复加)
|
|
243
|
+
const category = categoryForTarget(file)
|
|
244
|
+
if (!category) return fail('unknown_category', { targetFile: file })
|
|
245
|
+
const indexPath = join(knowledgeDir, 'INDEX.md')
|
|
246
|
+
if (!existsSync(indexPath)) return fail('index_missing', { path: 'knowledge/INDEX.md' })
|
|
247
|
+
const indexRaw = readFileSync(indexPath, 'utf8')
|
|
248
|
+
const indexEol = detectEol(indexRaw)
|
|
249
|
+
const indexNorm = indexRaw.replace(/\r\n/g, '\n')
|
|
250
|
+
const kws = resolveKeywords(keywords, finalTitle)
|
|
251
|
+
const display = `${file}#${anchor}`
|
|
252
|
+
const routeLine = `- ${kws.join('|')} → [${display}](${display})`
|
|
253
|
+
const routeExists = indexNorm.split('\n').some(l => l.includes(`](${display})`))
|
|
254
|
+
const newIndexNorm = routeExists ? null : insertRouteLine(indexNorm, category, routeLine)
|
|
255
|
+
|
|
256
|
+
// --dry-run:只渲染将要发生的变更,不落盘(三个知识文件与 hits 审计均零写盘)
|
|
257
|
+
if (dryRun) {
|
|
258
|
+
return {
|
|
259
|
+
ok: true, dryRun: true, moved: false, appendedTo: file, indexUpdated: false,
|
|
260
|
+
anchor, title: finalTitle, channel, keywords: kws,
|
|
261
|
+
skippedReason: skippedReason || undefined,
|
|
262
|
+
plan: {
|
|
263
|
+
appendToTarget: !duplicate,
|
|
264
|
+
targetSectionHeading: `## ${finalTitle}`,
|
|
265
|
+
indexRouteLine: routeExists ? null : routeLine,
|
|
266
|
+
removeFromUncategorized: true,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 落盘顺序(约束:先追加后删除,条目不丢内容、可手工搬运 revert):
|
|
272
|
+
// 目标文件追加(append-only,既有字节不动)→ INDEX 原子写 → uncategorized 删除段原子写
|
|
273
|
+
if (appendBlock !== null) appendFileSync(targetPath, appendBlock, 'utf8')
|
|
274
|
+
if (newIndexNorm !== null) writeAtomicSync(indexPath, newIndexNorm.split('\n').join(indexEol))
|
|
275
|
+
const uncatLines = uncatNorm.split('\n')
|
|
276
|
+
uncatLines.splice(entry.start, entry.end - entry.start)
|
|
277
|
+
writeAtomicSync(uncatPath, uncatLines.join(uncatEol))
|
|
278
|
+
|
|
279
|
+
// 归类审计(task-01 KnowledgeHitsAPI;幂等重入同样留痕,skippedReason 随行记录)
|
|
280
|
+
const rt = runtimeDir || join(dirname(knowledgeDir), '.runtime')
|
|
281
|
+
appendKnowledgeHit(rt, {
|
|
282
|
+
type: 'classify',
|
|
283
|
+
qlId: qlId || entry.qlIdPrefix || undefined,
|
|
284
|
+
targetFile: file,
|
|
285
|
+
title: finalTitle,
|
|
286
|
+
channel,
|
|
287
|
+
skippedReason: skippedReason || undefined,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
ok: true, moved: true, appendedTo: file,
|
|
292
|
+
indexUpdated: newIndexNorm !== null,
|
|
293
|
+
anchor, title: finalTitle, channel, keywords: kws,
|
|
294
|
+
skippedReason: skippedReason || undefined,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── CLI 入口(形态对齐 src/stages/knowledge.js 的 cmdSearch:JSON 输出 + args.indexOf 解析) ──
|
|
299
|
+
|
|
300
|
+
function output(ok, data, error) {
|
|
301
|
+
const result = { ok, ...data }
|
|
302
|
+
if (error) result.error = error
|
|
303
|
+
console.log(JSON.stringify(result, null, 2))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `sillyspec knowledge classify` 子命令入口(stages/knowledge.js 二级路由动态 import 转发)。
|
|
308
|
+
*
|
|
309
|
+
* 用法:classify (--ql <id> | --title <模糊>) --file <目标文件>
|
|
310
|
+
* [--section <标题>] [--keywords kw1,kw2] [--dry-run]
|
|
311
|
+
* @param {string} dir - 项目根目录
|
|
312
|
+
* @param {string[]} args - 二级路由切掉 'classify' 后的参数
|
|
313
|
+
* @param {object} [opts] - { specDir, runtimeDir }
|
|
314
|
+
*/
|
|
315
|
+
export async function cmdKnowledgeClassify(dir, args, opts = {}) {
|
|
316
|
+
const base = opts.specDir || join(dir, '.sillyspec')
|
|
317
|
+
const knowledgeDir = join(base, 'knowledge')
|
|
318
|
+
|
|
319
|
+
const pick = (name) => {
|
|
320
|
+
const i = args.indexOf(name)
|
|
321
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : ''
|
|
322
|
+
}
|
|
323
|
+
const qlId = pick('--ql')
|
|
324
|
+
const targetFile = pick('--file')
|
|
325
|
+
const sectionTitle = pick('--section')
|
|
326
|
+
const keywordsRaw = pick('--keywords')
|
|
327
|
+
const titleFallback = pick('--title')
|
|
328
|
+
const dryRun = args.includes('--dry-run')
|
|
329
|
+
|
|
330
|
+
if (!qlId && !titleFallback) {
|
|
331
|
+
output(false, {}, '--ql or --title is required')
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
if (!targetFile) {
|
|
335
|
+
output(false, {}, '--file is required')
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
const keywords = keywordsRaw
|
|
339
|
+
? keywordsRaw.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
|
340
|
+
: undefined
|
|
341
|
+
|
|
342
|
+
const result = classifyUncategorizedEntry({
|
|
343
|
+
knowledgeDir,
|
|
344
|
+
qlId,
|
|
345
|
+
targetFile,
|
|
346
|
+
sectionTitle,
|
|
347
|
+
keywords,
|
|
348
|
+
titleFallback,
|
|
349
|
+
dryRun,
|
|
350
|
+
runtimeDir: opts.runtimeDir || join(base, '.runtime'),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
if (result.ok) {
|
|
354
|
+
output(true, result)
|
|
355
|
+
} else {
|
|
356
|
+
output(false, {
|
|
357
|
+
moved: result.moved,
|
|
358
|
+
appendedTo: result.appendedTo,
|
|
359
|
+
indexUpdated: result.indexUpdated,
|
|
360
|
+
anchor: result.anchor,
|
|
361
|
+
}, result.error)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-hits.js — 知识闭环遥测事件流(.runtime/knowledge-hits.jsonl)
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-14-knowledge-loop-close task-01 交付的共享底座(KnowledgeHitsAPI):
|
|
5
|
+
* - 写:appendKnowledgeHit —— 每次恰好一行 JSON + '\n'(Node fs 不做换行翻译,Windows 下同为 LF)
|
|
6
|
+
* - 读:readKnowledgeHits —— 逐行 JSON.parse,坏行/残行跳过不抛(R-04:并发 append 交错容忍)
|
|
7
|
+
* - 记录类型:inject(机械注入命中,task-04 写入)/ classify(归类审计,task-02 写入);
|
|
8
|
+
* 聚合侧 stats(task-05)统一经 readKnowledgeHits 读取
|
|
9
|
+
*
|
|
10
|
+
* 数据模型按 design.md:{"type":"inject"|"classify","change":…,"query":…,"matchedFiles":[…],"at":"ISO-8601"}
|
|
11
|
+
* classify 记录可携带 qlId/targetFile(字段原样序列化,不裁剪)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
|
|
15
|
+
import { join } from 'path'
|
|
16
|
+
|
|
17
|
+
const HITS_FILENAME = 'knowledge-hits.jsonl'
|
|
18
|
+
|
|
19
|
+
function hitsFilePath(runtimeDir) {
|
|
20
|
+
return join(runtimeDir, HITS_FILENAME)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 追加一条遥测记录(目录/文件缺失自动创建)。
|
|
25
|
+
*
|
|
26
|
+
* @param {string} runtimeDir - .runtime 目录
|
|
27
|
+
* @param {object} hit - 记录对象,常用字段 { type:'inject'|'classify', change, query, matchedFiles, at }
|
|
28
|
+
* (at 缺省时补当前 ISO 时间;其余字段原样序列化,classify 的 qlId/targetFile 等照写)
|
|
29
|
+
* @returns {object} 实际落盘的记录对象
|
|
30
|
+
*/
|
|
31
|
+
export function appendKnowledgeHit(runtimeDir, hit) {
|
|
32
|
+
const record = { ...hit }
|
|
33
|
+
if (record.at === undefined || record.at === null) {
|
|
34
|
+
record.at = new Date().toISOString()
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(runtimeDir, { recursive: true })
|
|
37
|
+
appendFileSync(hitsFilePath(runtimeDir), JSON.stringify(record) + '\n', 'utf8')
|
|
38
|
+
return record
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读取遥测记录(坏行/残行容忍,解析失败不抛)。
|
|
43
|
+
*
|
|
44
|
+
* @param {string} runtimeDir - .runtime 目录
|
|
45
|
+
* @param {object} [opts] - { sinceDays }:仅保留 at 在近 N 天内的记录
|
|
46
|
+
* (过滤启用时 at 缺失/不可解析的记录一并跳过——无法证明在窗口内)
|
|
47
|
+
* @returns {object[]} 解析成功的记录数组(文件缺失/为空 → [])
|
|
48
|
+
*/
|
|
49
|
+
export function readKnowledgeHits(runtimeDir, opts = {}) {
|
|
50
|
+
const file = hitsFilePath(runtimeDir)
|
|
51
|
+
if (!existsSync(file)) return []
|
|
52
|
+
|
|
53
|
+
const records = []
|
|
54
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
55
|
+
const trimmed = line.trim()
|
|
56
|
+
if (!trimmed) continue
|
|
57
|
+
let record
|
|
58
|
+
try {
|
|
59
|
+
record = JSON.parse(trimmed)
|
|
60
|
+
} catch {
|
|
61
|
+
continue // 坏行/残行:跳过不抛(R-04)
|
|
62
|
+
}
|
|
63
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) continue
|
|
64
|
+
records.push(record)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sinceDays = opts ? Number(opts.sinceDays) : NaN
|
|
68
|
+
if (!Number.isFinite(sinceDays) || sinceDays < 0) return records
|
|
69
|
+
const cutoff = Date.now() - sinceDays * 24 * 60 * 60 * 1000
|
|
70
|
+
return records.filter((record) => {
|
|
71
|
+
const at = Date.parse(record.at)
|
|
72
|
+
return Number.isFinite(at) && at >= cutoff
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-stats.js — knowledge stats 子命令核心(2026-09-14-knowledge-loop-close task-05,FR-05 / D-002@v1)
|
|
3
|
+
*
|
|
4
|
+
* 职责:聚合 .runtime/knowledge-hits.jsonl 遥测事件流(task-01 KnowledgeHitsAPI 写入:
|
|
5
|
+
* task-04 注入点写 type:inject、task-02 classify 写 type:classify),输出近 N 天命中矩阵
|
|
6
|
+
* (文件 × 次数 × 最近命中时间)与从未命中清单(对照 INDEX.md 全集标注疑似死重)。
|
|
7
|
+
* 纯只读:不写 hits/INDEX/知识文件任何输入;死重清单只报告,不自动删除/合并(非目标)。
|
|
8
|
+
*
|
|
9
|
+
* 计数口径:
|
|
10
|
+
* - inject 记录:matchedFiles 每个条目(`file#anchor` 形态,prompt.js 注入侧逐条落盘)计 1 次
|
|
11
|
+
* 命中,归属剥 # 后的 file;classify 记录:targetFile 计 1 次(归类审计同样视作一次消费命中);
|
|
12
|
+
* - lastHitAt = 引用该文件的记录 at 最大值(ISO 原样保留;sinceDays 窗口启时时幸存记录
|
|
13
|
+
* 必有可解析 at——readKnowledgeHits 契约滤掉 at 缺失/坏值,'' 兜底仅防御性);
|
|
14
|
+
* - totalInjects / totalClassifies = 窗口内两类记录条数(按记录计,与 matrix 按条目计独立);
|
|
15
|
+
* - 未知 type 不进矩阵不进计数(前向兼容:新事件类型不炸聚合)。
|
|
16
|
+
*
|
|
17
|
+
* neverHit:parseKnowledgeIndex(src/knowledge-match.js 同款解析)得 INDEX.md 唯一 file 全集
|
|
18
|
+
* (保持 INDEX 行序)减去 matrix 中出现过的 file。不在 INDEX 但被命中过的 file 照进 matrix
|
|
19
|
+
* (informative),不影响 neverHit。INDEX 缺失 → neverHit=[](无对照面)。
|
|
20
|
+
*
|
|
21
|
+
* 空数据:hits.jsonl 缺失/无有效记录 → 空矩阵 + totals 0 + neverHit=INDEX 全集;CLI 人类可读
|
|
22
|
+
* 模式此时输出「暂无遥测数据」提示(--json 输出 hasTelemetry:false 供机读区分「无遥测」与
|
|
23
|
+
* 「窗口内无命中」两种空矩阵——后者死重对照无意义,人类可读模式同样略过 neverHit 段)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync } from 'fs'
|
|
27
|
+
import { join } from 'path'
|
|
28
|
+
import { readKnowledgeHits } from './knowledge-hits.js'
|
|
29
|
+
import { parseKnowledgeIndex } from './knowledge-match.js'
|
|
30
|
+
|
|
31
|
+
// ── 聚合核心 ──
|
|
32
|
+
|
|
33
|
+
/** `file#anchor` 引用 → file 部分(反斜杠归一 POSIX、剥首个 # 后段;空引用 → '') */
|
|
34
|
+
function fileOfRef(ref) {
|
|
35
|
+
const s = String(ref || '').trim().replace(/\\/g, '/')
|
|
36
|
+
if (!s) return ''
|
|
37
|
+
const hash = s.indexOf('#')
|
|
38
|
+
return (hash >= 0 ? s.slice(0, hash) : s).trim()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 聚合命中矩阵与死重对照。
|
|
43
|
+
*
|
|
44
|
+
* @param {string} knowledgeDir - knowledge 目录(INDEX.md 全集来源)
|
|
45
|
+
* @param {string} runtimeDir - .runtime 目录(knowledge-hits.jsonl 落点)
|
|
46
|
+
* @param {object} [opts] - { sinceDays = 30 }:仅统计近 N 天记录(at 过滤沿 readKnowledgeHits 契约,
|
|
47
|
+
* 坏行/残行读取侧已跳过)
|
|
48
|
+
* @returns {{ matrix: { file: string, hits: number, lastHitAt: string }[],
|
|
49
|
+
* neverHit: string[], totalInjects: number, totalClassifies: number }}
|
|
50
|
+
* matrix 按 hits 降序(同数按 file 字典序稳定 tie-break);neverHit 保持 INDEX 行序。
|
|
51
|
+
*/
|
|
52
|
+
export function buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays = 30 } = {}) {
|
|
53
|
+
const records = readKnowledgeHits(runtimeDir, { sinceDays })
|
|
54
|
+
|
|
55
|
+
const byFile = new Map()
|
|
56
|
+
const touch = (file, at) => {
|
|
57
|
+
if (!file) return
|
|
58
|
+
const row = byFile.get(file) || { file, hits: 0, lastHitAt: '', lastTs: -Infinity }
|
|
59
|
+
row.hits += 1
|
|
60
|
+
const ts = Date.parse(at)
|
|
61
|
+
if (Number.isFinite(ts) && ts >= row.lastTs) {
|
|
62
|
+
row.lastTs = ts
|
|
63
|
+
row.lastHitAt = String(at)
|
|
64
|
+
}
|
|
65
|
+
byFile.set(file, row)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let totalInjects = 0
|
|
69
|
+
let totalClassifies = 0
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
if (record.type === 'inject') {
|
|
72
|
+
totalInjects += 1
|
|
73
|
+
const refs = Array.isArray(record.matchedFiles) ? record.matchedFiles : []
|
|
74
|
+
for (const ref of refs) touch(fileOfRef(ref), record.at)
|
|
75
|
+
} else if (record.type === 'classify') {
|
|
76
|
+
totalClassifies += 1
|
|
77
|
+
touch(fileOfRef(record.targetFile), record.at)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const matrix = [...byFile.values()]
|
|
82
|
+
.map(({ lastTs, ...row }) => row) // 内部时间比较键不下发
|
|
83
|
+
.sort((a, b) => b.hits - a.hits || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))
|
|
84
|
+
|
|
85
|
+
const hitFiles = new Set(matrix.map((row) => row.file))
|
|
86
|
+
const seen = new Set()
|
|
87
|
+
const neverHit = []
|
|
88
|
+
for (const entry of parseKnowledgeIndex(knowledgeDir)) {
|
|
89
|
+
const file = fileOfRef(entry.file)
|
|
90
|
+
if (!file || seen.has(file) || hitFiles.has(file)) continue
|
|
91
|
+
seen.add(file)
|
|
92
|
+
neverHit.push(file)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { matrix, neverHit, totalInjects, totalClassifies }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── CLI 入口(形态对齐 src/stages/knowledge.js cmdSearch / src/knowledge-classify.js cmdKnowledgeClassify)──
|
|
99
|
+
|
|
100
|
+
function outputJson(ok, data, error) {
|
|
101
|
+
const result = { ok, ...data }
|
|
102
|
+
if (error) result.error = error
|
|
103
|
+
console.log(JSON.stringify(result, null, 2))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `sillyspec knowledge stats` 子命令入口(stages/knowledge.js 二级路由动态 import 转发)。
|
|
108
|
+
*
|
|
109
|
+
* 用法:stats [--since-days N] [--json]
|
|
110
|
+
* 默认人类可读输出(命中矩阵表 + 从未命中清单 + 遥测计数);--json 输出结构化结果
|
|
111
|
+
* ({ ok, sinceDays, hasTelemetry, matrix, neverHit, totalInjects, totalClassifies })。
|
|
112
|
+
* 纯只读。
|
|
113
|
+
*
|
|
114
|
+
* @param {string} dir - 项目根目录
|
|
115
|
+
* @param {string[]} args - 二级路由切掉 'stats' 后的参数
|
|
116
|
+
* @param {object} [opts] - { specDir, runtimeDir }
|
|
117
|
+
*/
|
|
118
|
+
export async function cmdKnowledgeStats(dir, args, opts = {}) {
|
|
119
|
+
const base = opts.specDir || join(dir, '.sillyspec')
|
|
120
|
+
const knowledgeDir = join(base, 'knowledge')
|
|
121
|
+
const runtimeDir = opts.runtimeDir || join(base, '.runtime')
|
|
122
|
+
|
|
123
|
+
const pick = (name) => {
|
|
124
|
+
const i = args.indexOf(name)
|
|
125
|
+
return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : ''
|
|
126
|
+
}
|
|
127
|
+
let sinceDays = 30
|
|
128
|
+
const sinceRaw = pick('--since-days')
|
|
129
|
+
if (sinceRaw !== '') {
|
|
130
|
+
const n = Number.parseInt(sinceRaw, 10)
|
|
131
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
132
|
+
outputJson(false, {}, '--since-days must be a non-negative integer')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
sinceDays = n
|
|
136
|
+
}
|
|
137
|
+
const asJson = args.includes('--json')
|
|
138
|
+
|
|
139
|
+
// 遥测在场判定用全量读取(不加窗口):文件缺失/只有坏行 → 无遥测
|
|
140
|
+
const hasTelemetry = readKnowledgeHits(runtimeDir).length > 0
|
|
141
|
+
const result = buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays })
|
|
142
|
+
|
|
143
|
+
if (asJson) {
|
|
144
|
+
outputJson(true, { sinceDays, hasTelemetry, ...result })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const lines = []
|
|
149
|
+
lines.push(`📚 knowledge stats(近 ${sinceDays} 天,数据源 .runtime/knowledge-hits.jsonl)`)
|
|
150
|
+
lines.push('')
|
|
151
|
+
|
|
152
|
+
if (!hasTelemetry) {
|
|
153
|
+
lines.push('暂无遥测数据(hits.jsonl 缺失或无有效记录)——机械注入(execute/quick prompt)与 knowledge classify 产生命中后此处可查')
|
|
154
|
+
lines.push('')
|
|
155
|
+
} else if (result.matrix.length === 0) {
|
|
156
|
+
lines.push(`近 ${sinceDays} 天无命中记录(可用 --since-days 扩大窗口;窗口内死重对照无意义,已略过)`)
|
|
157
|
+
lines.push('')
|
|
158
|
+
} else {
|
|
159
|
+
const fileCol = Math.max(2, ...result.matrix.map((row) => row.file.length))
|
|
160
|
+
lines.push('命中矩阵(按次数降序):')
|
|
161
|
+
lines.push(` ${'文件'.padEnd(fileCol)} 命中 最近命中`)
|
|
162
|
+
for (const row of result.matrix) {
|
|
163
|
+
const last = row.lastHitAt ? row.lastHitAt.slice(0, 10) : '—'
|
|
164
|
+
lines.push(` ${row.file.padEnd(fileCol)} ${String(row.hits).padStart(4)} ${last}`)
|
|
165
|
+
}
|
|
166
|
+
lines.push('')
|
|
167
|
+
if (!existsSync(join(knowledgeDir, 'INDEX.md'))) {
|
|
168
|
+
lines.push('(knowledge/INDEX.md 缺失——从未命中清单无从对照)')
|
|
169
|
+
} else if (result.neverHit.length > 0) {
|
|
170
|
+
lines.push('从未命中(对照 INDEX 全集,疑似死重——仅报告,不自动删除/合并):')
|
|
171
|
+
for (const file of result.neverHit) lines.push(` - ${file}`)
|
|
172
|
+
} else {
|
|
173
|
+
lines.push('INDEX 路由的全部文件在窗口内均有过命中(无死重)')
|
|
174
|
+
}
|
|
175
|
+
lines.push('')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
lines.push(`遥测计数(窗口内):注入 ${result.totalInjects} 次 | 归类 ${result.totalClassifies} 次`)
|
|
179
|
+
console.log(lines.join('\n'))
|
|
180
|
+
}
|
package/src/local-detect.js
CHANGED
|
@@ -15,26 +15,41 @@ import { join } from 'path'
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
20
|
-
* -
|
|
18
|
+
* 支持的写法(按 make 语义):
|
|
19
|
+
* - tab/空格 缩行配方:`test:\n\tpytest\n` → 'pytest'
|
|
20
|
+
* - `;` 同行配方:`test: ; pytest` → 'pytest'
|
|
21
|
+
* - 行内其他非空内容是 prerequisite(`test: unit integration`),不是命令——配方照常
|
|
22
|
+
* 向下找缩行;无配方回退 'make test'(make 会先跑依赖目标,语义正确)
|
|
21
23
|
* 边界:
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
+
* - 配方行以「下一个非缩进非空行」为界——空目标后跳进后续目标的配方是旧版缺陷
|
|
25
|
+
* (ql-20260911-029 复现:`test:\nbuild:\n\tnpm run build` 旧正则返回 'npm run build')
|
|
26
|
+
* - `test := x` 变量赋值不匹配 test 目标;命令行内 `#` 注释截断
|
|
24
27
|
* @param {string} makefileText
|
|
25
28
|
* @returns {string}
|
|
26
29
|
*/
|
|
27
|
-
function parseMakefileTestCommand(makefileText) {
|
|
30
|
+
export function parseMakefileTestCommand(makefileText) {
|
|
28
31
|
if (!makefileText) return 'make test'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
const lines = String(makefileText).split(/\r?\n/)
|
|
33
|
+
// (?!=) 排除 `test :=` 变量赋值形态
|
|
34
|
+
const testIdx = lines.findIndex(l => /^test:(?!=)/.test(l))
|
|
35
|
+
if (testIdx === -1) return 'make test'
|
|
36
|
+
const inline = lines[testIdx].slice(lines[testIdx].indexOf('test:') + 'test:'.length).trim()
|
|
37
|
+
const semicolonRecipe = inline.match(/^;\s*(.+)$/)
|
|
38
|
+
if (semicolonRecipe) {
|
|
39
|
+
const cmd = semicolonRecipe[1].replace(/\s+#.*$/, '').trim()
|
|
40
|
+
if (cmd) return cmd
|
|
41
|
+
}
|
|
42
|
+
// 向下扫配方:第一条缩进行即 test 的配方(tab 或空格缩进);遇非缩进非空行(下一
|
|
43
|
+
// 目标/变量/指令)即边界——空行与列 0 注释不构成边界
|
|
44
|
+
for (let i = testIdx + 1; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i]
|
|
46
|
+
if (line.trim() === '' || line.startsWith('#')) continue
|
|
47
|
+
if (/^[ \t]+\S/.test(line)) {
|
|
48
|
+
const cmd = line.replace(/^[ \t]+/, '').replace(/\s+#.*$/, '').trim()
|
|
49
|
+
return cmd || 'make test'
|
|
50
|
+
}
|
|
51
|
+
return 'make test'
|
|
52
|
+
}
|
|
38
53
|
return 'make test'
|
|
39
54
|
}
|
|
40
55
|
|