归档 Spec
归档一个已完成的 Spec,写入 <slug>.md 到解析后的归档根目录,并移除 <resolved-spec-root>/<slug>/,该目录会保留在 Git 中记录在 source_revision 下。源路径是配置的 Spec Root;目标路径是解析后的归档根目录——对于内置的 docs/specs 根目录是 docs/history/specs/,对于外部或非默认根目录是 <spec-root>/_archived/,与 roundfix archive 的目标一致。正常归档意味着 已实现、已验证且自包含——所有任务完成,当 Task Graph 设有 QA 关卡时 QA 通过,并且每个索引引用都由 Spec 拥有。QA 归档覆盖记录明确的例外而不声称已验证。在任一处置后,ls <spec-root>/ 将正在进行的工作与归档分开,归档作为已构建内容及其原因的记录,保持可 grep 搜索。
触发条件是 Spec 完成,而非发布:在 Roundfix Spec Run 结束时,一旦 QA 关卡通过,自动运行此归档;或者当用户要求时运行。合并和发布是单独的、由用户驱动的步骤——归档提交只是随分支一起传播,并在该功能自己的 PR 中发布。
归档重链接
在写入归档记录之前,归档会执行归档重链接。它会自动将实时文档引用的每个非核心文件从所有者 Spec 提升到 docs/references/<basename>,然后将每个实时文档的引用重写为指向提升后的副本;对 Spec 文件夹或核心工件的引用将指向归档记录。该记录在 relinked 下列出已重写的文件(如果有)。源文件只有一个所有者,因此次要 Spec 不需要副本(ADR-0260,ADR-0083)。
Spec 文件夹通过删除而退役。在复制的输出和每个提升的副本中,移除文件夹内的目标指定一个提升的副本,或者成为一个 Git 定位器 `<path>@<commit>`;它永远不会指向记录本身。提交保存路径并可以从默认分支或标签到达。当不存在这样的提交时,定位器指定路径和记录的拉取请求。其他目标保留其仓库目标(ADR-0278)。
当提升目标已存在、两个提升共享一个基本名称、或者要重写的实时文档在 HEAD 处有未提交的更改时,归档拒绝写入。不是实时文档的文件中的引用,或重写后剩余的引用,会发出警告,指明文件、行和目标;归档保持这些文件不变。
Squash 合并
Squash 合并仅在拉取请求的 head 引用中保留 source_revision;记录在 pull_request 中指定该拉取请求。
声明的 QA 拒绝
声明 qa: declined 且带有非空 qa_reason 的 Task Graph,在所有任务完成后即归档,无需 QA 报告且无需覆盖,其归档记录的处置为 no-qa。对于这样的 Spec,拒绝使用 --qa-override。所有其他归档前置条件仍然适用。
QA 结算
相同的结果结算了已创建的 qa Task,并确定归档留下什么:
| 结果 | 结算 | 归档 |
|---|---|---|
pass | 将 QA Task 结算为 completed,并且在报告没有不允许的阻塞行时使 Spec 符合归档条件。 | 归档记录,指定 QA 报告和裁决;Spec、其 QA 报告和证据保留在 Git 中记录在 source_revision 下。 |
符合条件的声明 partial | 当没有行失败、被跳过或因发现受阻,每个声明阻塞的行都被匹配的 ## Unreachable Acceptance 声明覆盖,并且每个环境阻塞的行是 PR 前的 Pull Request 行,记录为 blocked (environment: no open Pull Request),并在其来源中指定 Pull Request 行,或者是 Run 沙箱无法访问的外部证据行,记录为 blocked (environment: network denied: <host>),并在其来源中指定外部证据行时,将 QA Task 结算为 completed。这两行都不需要 Unreachable Acceptance 声明,并且仅由这些行不满足的 partial 符合条件。 | 归档记录,指定 QA 报告和裁决,并将声明的 satisfied-by 记录作为 unproven 携带;Spec、其 QA 报告和证据保留在 Git 中记录在 source_revision 下。 |
environment-blocked | 使该行保持阻塞;当等效证据满足环境策略时,报告仍可以结算为 pass。 | 本身不归档任何内容;符合条件的报告可以归档 Spec。 |
failed | 使 QA Task 保持未解决,并且除非有授权的覆盖,否则拒绝归档。 | 不归档任何内容。 |
missing | 使 QA Task 保持未解决,并且除非有授权的覆盖,否则拒绝归档。 | 不归档任何内容。 |
override | 不更改 QA Task 状态或报告裁决;在 QA 失败或缺失的情况下,将归档结算为明确授权。 | 归档记录包含 qa_override、qa_override_approval、qa_override_reason、qa_override_qa_outcome、当 QA Task 未完成时的 qa_override_qa_task_status、以及 qa_override_revision;QA Task 和报告在 Git 中记录在 source_revision 下保持不变。 |
Spec 授权保持离线的行
上述符合条件的声明 partial 也接受外部证据行,由于 Spec 自身的授权禁止查找,该行未被查找,记录为 blocked (environment: <subject> prohibited by Spec authorization),并在其来源中指定外部证据行。subject 命名授权禁止的内容,使用其措辞:network 用于网络查找,或 another repository;长度为 1 到 80 个字符,不包含括号。与网络拒绝行一样,它永远不会决定符合条件的 partial,也不需要 Unreachable Acceptance 声明,一行最多计入一种豁免类型,并且结算、roundfix qa-report accept 和 roundfix archive 通过相同的策略读取它(ADR-0276,ADR-0335)。
取消 Spec
不会交付的 Spec 使用 roundfix cancel <slug> --reason <text> 取消,切勿手动删除。该命令写入一个处置为 cancelled 的归档记录,将每个采用的 Backlog Entry 返回到 docs/backlog/ 并设置 status: open,将每个采用的 Finding 返回到 docs/findings/ 并设置 status: pending,然后移除 Spec 文件夹,同时其字节保留在 Git 中的 source_revision 下。已取消的记录永远不会满足前提条件,永远不会取代另一个 Spec,并且 Delivery Retry 会拒绝它。
忽略的文件和失败的删除
当 Git 忽略 Spec 文件夹下的任何文件时,Archive 命令和 Cancel 命令拒绝执行。每个命令列出每个被忽略的文件并且不写入任何内容。操作员必须删除每个文件或使用 git add -f 提交它,然后重试。
如果 Spec 文件夹的最终删除失败,两个命令都保留每次写入,退出 1,并打印完成删除的命令:
chmod -R u+w '<folder>' && rm -rf '<folder>'。
前置条件——验证,不要轻信
在接触任何内容之前,使用新鲜的命令证据检查所有三个条件:
-
所需任务完成。 对于正常归档,读取
_tasks.md中列出的每个task_NN.md;每个status必须为completed。对于 QA 归档覆盖,每个非 QA 任务必须为completed;QA 任务可以保持pending或failed。如果 QA 任务也是completed,则最新的报告必须仍然不符合条件,否则覆盖是不必要的且会被拒绝。命令: 运行
grep -n '^status:' <each-task-file-listed-in-_tasks.md>并保留其输出。任何status: completed以外的值都会阻止正常归档。在覆盖期间,只有具有其他状态的非 QA 任务才会阻止归档;指定该任务文件。 -
QA 通过。
qa/中最新的报告必须通过仓库的 QA 验证器。不要用行 grep 替代结构化验证。在 Roundfix 中,使用与 Archive 命令相同的internal/spec.QAVerdict契约:选择符合qa-report-YYYY-MM-DD[-NN].md文件名契约的最新报告,解析其 YAML 前置元数据,要求支持verdict,要求两个阻塞行字段在存在时是非负整数,并在rows_blocked_finding非零时拒绝verdict: pass。保留验证器的报告路径和结果作为证据。缺失的qa/目录、格式错误的最新报告或非通过的裁决会阻止正常归档。仅当通过roundfix archive <slug> --qa-override --approval <source> --reason <text>执行明确的 QA 归档覆盖时,才继续进行。切勿通过编辑归档记录来记录覆盖。符合条件的最新报告不会使失败或待处理的 QA 任务完成;仅当报告符合条件且所有任务都完成时,才会拒绝覆盖。 -
Spec 是自包含的。 当
docs/specs/<slug>/references/_index.md存在或是符号链接时应用此前置条件;符号链接(包括损坏的链接)是无效的索引状态,而不是遗留 Spec。只有该路径既不存在也不是符号链接的 Spec 早于本契约,无需改造历史工件即可通过。对于索引 Spec,每个索引的path必须相对于_index.md存在,每个从未更新的source路径必须不存在,并且 Spec 内的 Markdown 链接目标不得指向docs/_inbox/或docs/findings/。命令: 首先运行此链接目标检查;其语法刻意匹配内联或引用样式的 Markdown 链接,而不是仅命名这些树的散文:
spec_dir=docs/specs/<slug> link_hits=$(grep --include='*.md' -RInE '(\](\([^)]*(docs/)?(_inbox|findings)/[^)]*\)|^[[:space:]]*\[[^]]+\]:[[:space:]]*<?[^[:space:]>]*(docs/)?(_inbox|findings)/)' "$spec_dir") link_status=$? if test "$link_status" -eq 0; then printf '%s\n' "$link_hits" echo "self-containment failed: rewrite each listed link at adoption step 8" exit 1 fi test "$link_status" -eq 1 || exit "$link_status"然后解析并验证每个数据行。索引属于当前 Spec:
owner必须等于其四位数字前缀,type必须是inbox、finding或backlog,并且每个source和path必须只出现一次。path必须是相对于_index.md的一个基本名称;拒绝绝对路径、.、..、路径分隔符和符号链接,而不是允许遍历或references/之外的链接。从仓库根目录运行以下命令,并保留规范化行以及任何诊断信息作为证据:slug=<slug> index="docs/specs/$slug/references/_index.md" expected_owner=${slug%%-*} parsed_index=$(mktemp) || exit 1 trap 'rm -f "$parsed_index"' EXIT HUP INT TERM if test -L "$(dirname "$index")"; then printf 'self-containment failed: references/ must not be a symbolic link\n' >&2 exit 1 fi if test -L "$index" || test ! -f "$index"; then printf 'self-containment failed: references/_index.md must be a regular, non-symbolic-link file\n' >&2 exit 1 fi awk -F '|' -v expected_owner="$expected_owner" ' function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) return value } function reject(message) { print "self-containment failed: " message > "/dev/stderr" invalid = 1 } function separator(value) { value = trim(value) return value ~ /^:?-{3,}:?$/ } /^[[:space:]]*$/ || /^[[:space:]]*#/ { next } !header { if (NF != 7 || trim($2) != "source" || trim($3) != "type" || trim($4) != "owner" || trim($5) != "adopted date" || trim($6) != "path") { reject("_index.md must use the fixed source | type | owner | adopted date | path header") } else { header = 1 } next } !divider { if (NF != 7 || !separator($2) || !separator($3) || !separator($4) || !separator($5) || !separator($6)) { reject("_index.md has an invalid table separator") } else { divider = 1 } next } { source = trim($2) type = trim($3) owner = trim($4) adopted = trim($5) path = trim($6) if (NF != 7 || source == "" || type == "" || owner == "" || adopted == "" || path == "") { reject("invalid index row at line " NR ": " $0) next } if (type != "inbox" && type != "finding" && type != "backlog") { reject("type must be `inbox`, `finding`, or `backlog` at line " NR ": " type) } if (owner != expected_owner) { reject("owner must be " expected_owner " at line " NR ": " owner) } if (seen_source[source]++) { reject("duplicate source at line " NR ": " source) } if (seen_path[path]++) { reject("duplicate path at line " NR ": " path) } if (adopted !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { reject("adopted date must be YYYY-MM-DD at line " NR ": " adopted) } if ((type == "inbox" && source !~ /^docs\/_inbox\/[^\/]+\.md$/) || (type == "finding" && source !~ /^docs\/findings\/[^\/]+\.md$/) || (type == "backlog" && source !~ /^docs\/backlog\/[^\/]+\.md$/)) { reject("source does not match type at line " NR ": " source) } print source "|" type "|" owner "|" adopted "|" path } END { if (!header || !divider) { reject("_index.md is missing its fixed table header") } if (invalid) { exit 1 } } ' "$index" > "$parsed_index" || exit $? while IFS='|' read -r source type owner adopted path; do case "$path" in ""|.|..|/*|*/*|*\\*) printf 'self-containment failed: path must be one basename relative to `_index.md`: %s\n' "$path" >&2 exit 1 ;; esac source_basename=${source##*/} if test "$path" != "$source_basename" # ... [为简洁而截断]