Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **hat**:20-task-audit(书面审查 · R1 · 独立上下文 · 非起草者)
> **日期**:2026-09-18
> **被审对象**:[`docs/tasks/active/task_3_0_1_w1_sticky_table_source.md`](../../tasks/active/task_3_0_1_w1_sticky_table_source.md)(slug `3-0-1-w1-sticky-table-source` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **被审对象**:[`docs/tasks/done/task_3_0_1_w1_sticky_table_source.md`](../../tasks/done/task_3_0_1_w1_sticky_table_source.md)(slug `3-0-1-w1-sticky-table-source` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **对照基准**:[`docs/roadmap/PLAN_3_0_1_patch_v1_zh.md`](../../roadmap/PLAN_3_0_1_patch_v1_zh.md)(HG-NEXT-PLAN=approved · **W1 节** + 硬约束 **3/4/5**)· [`docs/harness/reviews/acceptance_3_0_0_specwave_20260918.md`](./acceptance_3_0_0_specwave_20260918.md) **§6.1 P1-1** · 10 invoke `docs/harness/invokes/by-task/3-0-1-w1-sticky-table-source/invoke_20260918_10_3-0-1-w1-sticky-table-source.md` · 先例形态 [`task_2_4_2_patch_audit_R1_20260915.md`](./task_2_4_2_patch_audit_R1_20260915.md)
> **审查方式**:只读通读 task + PLAN W1/硬约束 + §6.1 P1-1;**独立再钉** `sticky.ts` / `cmd.ts` / `table.ts` 行号现值;可选跑 `node bin/specgate.js task lint`(**LINT: PASS**);**未改被审 task 任何字节 · 未改 `src/`**。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **hat**:20-task-audit(书面审查 · R1 · 独立上下文 · 非起草者)
> **日期**:2026-09-18
> **被审对象**:[`docs/tasks/active/task_3_0_1_w3_pins_io_failclosed.md`](../../tasks/active/task_3_0_1_w3_pins_io_failclosed.md)(slug `3-0-1-w3-pins-io-failclosed` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **被审对象**:[`docs/tasks/done/task_3_0_1_w3_pins_io_failclosed.md`](../../tasks/done/task_3_0_1_w3_pins_io_failclosed.md)(slug `3-0-1-w3-pins-io-failclosed` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **对照基准**:[`docs/roadmap/PLAN_3_0_1_patch_v1_zh.md`](../../roadmap/PLAN_3_0_1_patch_v1_zh.md)(HG-NEXT-PLAN=approved · **W3 节** + 硬约束 **3/5**)· [`acceptance_3_0_0_specwave_20260918.md`](./acceptance_3_0_0_specwave_20260918.md) **§6.3 P3-8** · 10 invoke `docs/harness/invokes/by-task/3-0-1-w3-pins-io-failclosed/invoke_20260918_10_3-0-1-w3-pins-io-failclosed.md` · 先例形态 [`task_3_0_1_w2_gate_table_contract_audit_R1_20260918.md`](./task_3_0_1_w2_gate_table_contract_audit_R1_20260918.md)
> **审查方式**:只读通读 task + PLAN W3/硬约束 3·5 + §6.3 P3-8;**独立再钉** `src/cli-pins.ts` `loadPins` / `readTruthVersion` 行号;`node bin/specgate.js task lint` → **LINT: PASS**;**未改被审 task 任何字节 · 未改 `src/`**。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **hat**:20-task-audit(书面审查 · R1 · 独立上下文 · 非起草者)
> **日期**:2026-09-18
> **被审对象**:[`docs/tasks/active/task_3_0_1_w5_pin_hook_version.md`](../../tasks/active/task_3_0_1_w5_pin_hook_version.md)(slug `3-0-1-w5-pin-hook-version` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **被审对象**:[`docs/tasks/done/task_3_0_1_w5_pin_hook_version.md`](../../tasks/done/task_3_0_1_w5_pin_hook_version.md)(slug `3-0-1-w5-pin-hook-version` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **对照基准**:[`docs/roadmap/PLAN_3_0_1_patch_v1_zh.md`](../../roadmap/PLAN_3_0_1_patch_v1_zh.md)(HG-NEXT-PLAN=approved · **W5 节** + 硬约束 **3/5** · 及 7/8/10)· [`acceptance_3_0_0_specwave_20260918.md`](./acceptance_3_0_0_specwave_20260918.md) **§6.2 P2-2**(`:367-372`)· 10 invoke `docs/harness/invokes/by-task/3-0-1-w5-pin-hook-version/invoke_20260918_10_3-0-1-w5-pin-hook-version.md` · 先例形态 [`task_3_0_1_w4_docs_precision_audit_R1_20260918.md`](./task_3_0_1_w4_docs_precision_audit_R1_20260918.md)
> **审查方式**:只读通读 task + PLAN W5/硬约束 3·5(及 7/8/10)+ 验收 §6.2 P2-2;**独立再钉** `hooks.ts` / `materialize.ts` / `cmd.ts` / 手册 §7.3 / `package.json#version` / `RELEASING.md`;`node bin/specgate.js task lint` → **LINT: PASS**;**未改被审 task 任何字节 · 未改 `src/`**。

Expand Down
1 change: 1 addition & 0 deletions docs/tasks/active/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# 空目录占位。git 不跟踪空目录;task 全部进 done/ 后 active/ 仍须存在。
11 changes: 7 additions & 4 deletions scripts/check-doc-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
// 3.0 W7 CI hotfix(task_3_0_w7_ci_hotfix · 验收 #1/#2 · 硬约束 6/10):(i) 判据改「入库状态而非文件系统状态」——
// 本机 `.workbuddy/`(.gitignore 忽略)除 **9 件显式 tracked**(判据:`git ls-files .workbuddy/`)外均忽略;未入库实体曾使
// 11 处冻结坏链被 existsSync 掩盖(本地 23 假绿 / CI 干净 clone 34 真红)。
// inRepo 目标 = tracked 文件 ∪ tracked 目录前缀(git 不跟踪目录 · 目录链须命中);仓外目标维持 existsSync。
// inRepo 目标 = (tracked 文件 ∪ tracked 目录前缀)且工作区仍在。仅索引(rename 后未刷新)不算可解析;
// 仅磁盘(.workbuddy 未入库)也不算。仓外目标维持 existsSync。
// tracked 集合以 `-z` 原样读取(避免非 ASCII 路径 quotePath 八进制转义)。冻结基线按修后本地/干净 clone 双跑实测重建 23 → 34。
//
// 两级判据(SPEC 08 §5.2):
// (i) 可解析:docs/**/*.md 的 Markdown 相对链接(inline + reference-definition + <a href>)解析后目标须**已入库**(仓外目标须存在)。
// (i) 可解析:docs/**/*.md 的 Markdown 相对链接(inline + reference-definition + <a href>)解析后,仓内目标须已入库且工作区仍在(仓外目标须存在)。
// (ii) 目标已入库:docs/ 内指向 `.workbuddy/…` 的链接目标须 `git ls-files` 命中(可解析但未入库 = 坏链)。
// S2 域(docs/tasks/ · docs/harness/reviews/ · docs/harness/invokes/by-task/)整体豁免 + 冻结基线
// (永不覆写 · 历史 stale 链接不可修 · 硬约束 1):S2 (i) 处数须 == 冻结基线(参数化排除 current task 路径)
Expand Down Expand Up @@ -112,8 +113,10 @@ export function checkDocLinks({ root, gitTracked, s2Baseline = S2_FROZEN_BASELIN
const targetRel = path.relative(root, targetAbs).split(path.sep).join('/')
const inRepo = !targetRel.startsWith('..') && !path.isAbsolute(targetRel)
const isWorkbuddy = /(^|\/)\.workbuddy\//.test(targetRel) || clean.includes('.workbuddy/')
// (i) 可解析 = 入库状态:inRepo 须 tracked 文件或 tracked 目录前缀;仓外才看文件系统(F-HOT2-02)
const exists = inRepo ? gitTracked.has(targetRel) || trackedDirs.has(targetRel) : existsSync(targetAbs)
// (i) 可解析 = 入库且工作区仍在。仅索引(task close 的 renameSync 不更新 index)不算;
// 仅磁盘不算(.workbuddy 假绿 · F-HOT2-02)。目录链仍用 tracked 祖先前缀。
const trackedHit = gitTracked.has(targetRel) || trackedDirs.has(targetRel)
const exists = inRepo ? trackedHit && existsSync(targetAbs) : existsSync(targetAbs)
const entry = { file: rel, line, target: clean, resolve: targetRel }
const bucket = isS2(rel) ? broken.s2 : broken.nonS2
if (!exists) bucket.i.push(entry)
Expand Down
139 changes: 139 additions & 0 deletions src/cli/close-retarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { toRel } from '../cli-shared.ts'

/**
* task close 真归档后,把 docs 里「解析后正好指向刚搬走的那个文件」的 Markdown 链接
* 改到新路径。只改链接目标,以及该链接标签里的仓内旧路径。
* 围栏、行内代码、指向其它文件的链接不动。
* 与 scripts/check-doc-links.mjs extractLinks 同口径:跳过 ``` 围栏与行内代码。
*/
const INLINE_RE = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
const REF_RE = /^\s*!?\[[^\]]+\]:\s*(\S+)/
const HTML_RE = /<a\s[^>]*href=["']?([^"'\s>]+)/gi

function maskInlineCode(line: string): string {
return line.replace(/`[^`]*`/g, (m) => ' '.repeat(m.length))
}

function isExternal(target: string): boolean {
return /^(#|https?:\/\/|mailto:|tel:|data:|\/\/)/i.test(target)
}

function pointsAt(sourceAbs: string, url: string, fromAbs: string): boolean {
const clean = url.split('#')[0]
if (!clean || isExternal(url)) return false
return path.resolve(path.dirname(sourceAbs), clean) === path.resolve(fromAbs)
}

function linkHref(fromFileAbs: string, toAbs: string, hash: string): string {
let rel = path.relative(path.dirname(fromFileAbs), toAbs).split(path.sep).join('/')
if (!rel.startsWith('.')) rel = `./${rel}`
return hash ? `${rel}${hash}` : rel
}

type Hit = { start: number; end: number; text: string }

function consider(
hits: Hit[],
line: string,
urlStart: number,
url: string,
sourceAbs: string,
fromAbs: string,
toAbs: string,
root: string,
labelStart: number,
labelEnd: number,
): boolean {
if (!pointsAt(sourceAbs, url, fromAbs)) return false
const hash = url.includes('#') ? url.slice(url.indexOf('#')) : ''
hits.push({ start: urlStart, end: urlStart + url.length, text: linkHref(sourceAbs, toAbs, hash) })
if (labelStart >= 0 && labelEnd > labelStart) {
const fromRel = toRel(root, fromAbs)
const toRelPath = toRel(root, toAbs)
const label = line.slice(labelStart, labelEnd)
if (fromRel && fromRel !== '.' && label.includes(fromRel)) {
hits.push({ start: labelStart, end: labelEnd, text: label.split(fromRel).join(toRelPath) })
}
}
return true
}

function applyHits(line: string, hits: Hit[]): string {
const sorted = [...hits].sort((a, b) => b.start - a.start)
let out = line
for (const h of sorted) out = out.slice(0, h.start) + h.text + out.slice(h.end)
return out
}

/** 改一份 Markdown。count 只计被改写的链接目标,不计标签。 */
export function retargetMarkdown(
content: string,
sourceAbs: string,
fromAbs: string,
toAbs: string,
root: string,
): { text: string; count: number } {
const lines = content.split('\n')
let inFence = false
let count = 0
const out = lines.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
const masked = maskInlineCode(line)
const hits: Hit[] = []
INLINE_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = INLINE_RE.exec(masked)) !== null) {
const url = m[1]
if (!url) continue
const urlStart = m.index + m[0].indexOf('(') + 1
const labelStart = m.index + (m[0].startsWith('!') ? 2 : 1)
const labelEnd = m.index + m[0].indexOf('](')
if (consider(hits, line, urlStart, url, sourceAbs, fromAbs, toAbs, root, labelStart, labelEnd)) count += 1
}
HTML_RE.lastIndex = 0
while ((m = HTML_RE.exec(masked)) !== null) {
const url = m[1]
if (!url) continue
const urlStart = m.index + m[0].lastIndexOf(url)
if (consider(hits, line, urlStart, url, sourceAbs, fromAbs, toAbs, root, -1, -1)) count += 1
}
const ref = REF_RE.exec(masked)
if (ref?.[1]) {
const urlStart = ref.index + ref[0].lastIndexOf(ref[1])
if (consider(hits, line, urlStart, ref[1], sourceAbs, fromAbs, toAbs, root, -1, -1)) count += 1
}
return hits.length === 0 ? line : applyHits(line, hits)
})
return { text: out.join('\n'), count }
}

function walkMd(dir: string, out: string[]): void {
if (!existsSync(dir)) return
for (const ent of readdirSync(dir, { withFileTypes: true })) {
if (ent.name.startsWith('.')) continue
const p = path.join(dir, ent.name)
if (ent.isDirectory()) walkMd(p, out)
else if (/\.md$/i.test(ent.name)) out.push(p)
}
}

/** 扫描 root/docs 下全部 Markdown,改写指向 fromAbs 的链接。返回改写条数。 */
export function retargetClosedTaskLinks(root: string, fromAbs: string, toAbs: string): number {
const files: string[] = []
walkMd(path.join(root, 'docs'), files)
let total = 0
for (const file of files) {
const before = readFileSync(file, 'utf8')
const { text, count } = retargetMarkdown(before, file, fromAbs, toAbs, root)
if (count === 0 || text === before) continue
writeFileSync(file, text)
total += count
}
return total
}
6 changes: 6 additions & 0 deletions src/cli/task-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { appendAuditEvent, resolveAuditFile, stampAuditEvent } from '../audit/lo
import { buildDoneSnapshot, extractTaskSlug, fail, findGitRoot, parseHarnessMeta, printJson, takeOption, toRel } from '../cli-shared.ts'
import { evalCloseExecEvidence, evalCloseGuard, lintTaskFile } from '../cli-checks.ts'
import { cmdTaskCheck, cmdTaskLintDone, cmdTaskLintWikiDelta } from '../cli-task-extra.ts'
import { retargetClosedTaskLinks } from './close-retarget.ts'
import { CLOSE_GUARD_ORDER, TASK_USAGE } from './usage.ts'

async function cmdTaskLint(args: string[]): Promise<void> {
Expand Down Expand Up @@ -169,6 +170,10 @@ async function cmdTaskClose(args: string[]): Promise<void> {
if (!dest) fail('无法解析归档目标', 2)
mkdirSync(path.dirname(dest), { recursive: true })
renameSync(abs, dest)
// 归档使 active/ 路径失效。同一次 --yes 改写 docs 里正好指向该文件的 Markdown 链接,
// 不依赖 30 事后手改。dry-run 不执行。历史指向其它文件的坏链不动。
const retargeted = retargetClosedTaskLinks(closeJsonBase, abs, dest)
traces.push(`close: retarget · ${retargeted}`)
// K5:真归档(renameSync 执行)后构建 done 片段快照 —— 摘录归档文件内
// HARNESS_META_HEADING 节原文(extractSection · 归档真值防模板漂移);快照存在性
// 唯绑归档事件,与豁免旗标无关(20 审 R2 口径裁决:豁免 + --yes → 快照照打)
Expand All @@ -191,6 +196,7 @@ async function cmdTaskClose(args: string[]): Promise<void> {
// 2.4-W3(D-24-OUTPUT-REL-EXIT):人类输出路径值同口径相对化(冻结文案 CLOSE: PASS 不动)
console.log(`moved: ${toRel(process.cwd(), abs)} → ${toRel(process.cwd(), dest)}`)
console.log(`CLOSE: PASS · ${slug}`)
console.log(`retarget: ${retargeted}`)
// K5(W1):PASS 分支 stdout 追加快照块 —— 归档后路径 + 元信息节摘录 + 禁手写提示;
// 取不到元信息节(异常态)打 canonical 模板占位 + WARN 行;不改 CLOSE: PASS 冻结文案,仅追加
console.log(`done_snapshot · path: ${toRel(process.cwd(), snapshot.path)}`)
Expand Down
13 changes: 13 additions & 0 deletions test/check-doc-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,17 @@ describe('3.0-W7 S7.7 · check-doc-links(两级机检 failClosed)', { concur
assert.equal(miss.status, 2, miss.combined)
})
})

it('索引仍有、工作区已搬走 → (i) 坏链(rename 不刷新 index 不再假绿)', async () => {
await withTemp(async (dir) => {
await writeRel(dir, 'docs/roadmap/a.md', '# a\n\n[t](./b.md)\n')
await writeRel(dir, 'docs/roadmap/b.md', '# b\n')
execGit(dir, ['init', '-q'])
execGit(dir, ['add', 'docs/roadmap/a.md', 'docs/roadmap/b.md'])
await rm(path.join(dir, 'docs', 'roadmap', 'b.md'))
const gone = run(dir, ['--s2-baseline', '0'])
assert.equal(gone.status, 2, gone.combined)
assert.match(gone.combined, /docs\/roadmap\/a\.md/)
})
})
})
121 changes: 121 additions & 0 deletions test/close-retarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { describe, it } from 'node:test'
import { retargetMarkdown } from '../src/cli/close-retarget.ts'
import { runCore } from './_helpers/core-harness.ts'

const ROOT = '/repo'
const SOURCE = path.join(ROOT, 'docs/harness/reviews/r.md')
const FROM = path.join(ROOT, 'docs/tasks/active/t.md')
const TO = path.join(ROOT, 'docs/tasks/done/t.md')

describe('task close · 归档后改写指向该文件的链接', () => {
it('只改指向本文件的链接;围栏、行内代码、其它文件、旁白不动', () => {
const input = [
'[`docs/tasks/active/t.md`](../../tasks/active/t.md) 审时为 active/',
'',
'```',
'[x](../../tasks/active/t.md)',
'```',
'',
'行内 `[nope](../../tasks/active/t.md)` 不动',
'',
'[other](../../tasks/active/other.md)',
'',
'[hash](../../tasks/active/t.md#s)',
'',
'[ref]: ../../tasks/active/t.md',
'',
'<a href="../../tasks/active/t.md">x</a>',
].join('\n')
const { text, count } = retargetMarkdown(input, SOURCE, FROM, TO, ROOT)
assert.equal(count, 4)
assert.match(text, /\[`docs\/tasks\/done\/t\.md`\]\(\.\.\/\.\.\/tasks\/done\/t\.md\) 审时为 active\//)
assert.match(text, /```\n\[x\]\(\.\.\/\.\.\/tasks\/active\/t\.md\)\n```/)
assert.match(text, /行内 `\[nope\]\(\.\.\/\.\.\/tasks\/active\/t\.md\)` 不动/)
assert.match(text, /\[other\]\(\.\.\/\.\.\/tasks\/active\/other\.md\)/)
assert.match(text, /\[hash\]\(\.\.\/\.\.\/tasks\/done\/t\.md#s\)/)
assert.match(text, /\[ref\]: \.\.\/\.\.\/tasks\/done\/t\.md/)
assert.match(text, /<a href="\.\.\/\.\.\/tasks\/done\/t\.md">/)
})

it('task close --yes 改审查文链接;dry-run 不改', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-ck-retarget-'))
await mkdir(path.join(dir, '.git'), { recursive: true })
try {
const taskRel = 'docs/tasks/active/task_retarget.md'
const taskAbs = path.join(dir, taskRel)
await mkdir(path.dirname(taskAbs), { recursive: true })
await writeFile(
taskAbs,
[
'# Task retarget',
'',
'> **状态**:`done`',
'',
'## Harness 元信息',
'',
'| 字段 | 值 |',
'|------|-----|',
'| **task_slug** | `retarget` |',
'| **test_strategy** | `recommended` |',
'| **wiki_delta** | `none` |',
'',
'### 人工闸',
'',
'| human_gate_id | status | blocks_hats | 说明 |',
'|---------------|--------|-------------|------|',
'| HG-TASK-DRAFT | approved | 20,30 | fixture |',
'| HG-AUDIT-R1 | approved | 30 | fixture |',
'',
'## 验收标准',
'',
'- [x] fixture item',
'',
'## 失败路径',
'',
'| F | Scenario |',
'|---|----------|',
'| F1 | fixture |',
'',
'### 自检结论(执行者)',
'',
'自检已回填:fixture retarget。',
'',
].join('\n'),
'utf8',
)
const reviewRel = 'docs/harness/reviews/r.md'
const reviewAbs = path.join(dir, reviewRel)
await mkdir(path.dirname(reviewAbs), { recursive: true })
const review = '[`docs/tasks/active/task_retarget.md`](../../tasks/active/task_retarget.md)\n'
await writeFile(reviewAbs, review, 'utf8')
const allow = [
'--allow-invoke-gap', '--allow-no-review', '--allow-kpi-gap', '--allow-wiki-gap',
'--allow-no-pr-merge', '--allow-no-hub', '--allow-experience-gap',
]

const dry = await runCore(['task', 'close', '--file', taskAbs, ...allow])
assert.equal(dry.status, 0, dry.combined)
assert.match(dry.stdout, /CLOSE: READY/)
assert.equal(await readFile(reviewAbs, 'utf8'), review)
assert.equal(existsSync(taskAbs), true)

const pass = await runCore(['task', 'close', '--file', taskAbs, '--yes', ...allow])
assert.equal(pass.status, 0, pass.combined)
assert.match(pass.stdout, /CLOSE: PASS · retarget/)
assert.match(pass.stdout, /retarget: 1/)
assert.equal(existsSync(taskAbs), false)
assert.equal(existsSync(path.join(dir, 'docs/tasks/done/task_retarget.md')), true)
const after = await readFile(reviewAbs, 'utf8')
assert.match(after, /docs\/tasks\/done\/task_retarget\.md/)
assert.match(after, /\.\.\/\.\.\/tasks\/done\/task_retarget\.md/)
assert.equal(after.includes('tasks/active/'), false)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
Loading