diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 70066ee91..53129aeb8 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -37,6 +37,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version @@ -69,6 +70,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version @@ -116,6 +118,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version diff --git a/package.json b/package.json index c0c503e46..42eff8d17 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "check:plan": "node refactor/scripts/plan.mjs --check", "test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js", "test:baseline": "node --test refactor/scripts/*.test.mjs", - "ci:check": "yarn check:toolchain --strict && yarn check:plan && yarn lint && yarn check:types && yarn typecheck && yarn test", + "ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:plan && yarn lint && yarn check:types && yarn typecheck && yarn test", "ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js", "typecheck": "node scripts/typecheck.mjs", @@ -65,7 +65,8 @@ "test:dash-types-package": "node refactor/scripts/dash-package-types.mjs", "test:ads-types-package": "node refactor/scripts/ads-package-types.mjs", "test:ads-native-visibility": "node refactor/scripts/ads-native-visibility.mjs", - "test:vast": "node --test test/vast.test.js test/vast-lifecycle.test.js" + "test:vast": "node --test test/vast.test.js test/vast-lifecycle.test.js", + "check:commits": "node refactor/scripts/commit-audit.mjs" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/README.md b/refactor/README.md index b07c8bbe6..4599d4d7c 100644 --- a/refactor/README.md +++ b/refactor/README.md @@ -33,6 +33,7 @@ | [已实现的 CI 入口](ci-setup.md) | Yarn 检查/构建、只读 lint、Pages 隔离及远端待验收状态 | | [GitHub CI/CD](github-ci-cd.md) | PR/兼容矩阵、构建报告、Pages、npm 发布及远端准入验证 | | [AI 协作流程](ai-workflow.md) | AI 接续工作、任务边界、验证、记录和交接模板 | +| [每任务提交审计](commit-audit.md) | 实际Git状态迁移、独立提交、初始例外、分支合并和CI报告 | | [架构决策](decisions.md) | 已选方向、待验证方案及被拒绝方案 | | [进度与证据](progress.md) | 本次会话结果、阻塞、下一步;不重复维护每个任务状态 | | [变更记录模板](changes/TEMPLATE.md) | 每次行为/类型/结构变化的详细记录 | diff --git a/refactor/ai-workflow.md b/refactor/ai-workflow.md index 67f866abc..d9441330b 100644 --- a/refactor/ai-workflow.md +++ b/refactor/ai-workflow.md @@ -46,6 +46,10 @@ AI 用于源码分析、任务拆分、实现、测试和差异审查。现代 - 本地 commit 已获授权;push、merge、tag 和公开发布仍按用户明确授权执行。 - 新规则生效前已完成但未提交的 DOC-01 至 DOC-04 文档,作为 DOC-05 的初始文档基线一次入库,记录真实情况,不伪造之前存在的提交。 +执行`yarn check:commits --report`核对真实Git完成历史,详见[提交审计](commit-audit.md)。 +任务仍doing时跑适用检查;标done后立即独立提交,再运行审计验证自身。未提交的done +状态会被拒绝,这不是可跳过的CI错误。审计与现有历史源码测试要求完整Git历史。 + ## 后续任务提示模板 重构实现完成后继续执行 [多轮复盘与 npm 准入](release-reviews.md)。用户计划分多次任务复盘,后续 AI 从 REVIEW 任务与持久报告接续;不以迁移完成或 Chrome 已连接宣称发布就绪。每项复盘发现的修复仍独立建任务、验证和提交。 diff --git a/refactor/baselines/commit-audit-validation.json b/refactor/baselines/commit-audit-validation.json new file mode 100644 index 000000000..d6d251bd2 --- /dev/null +++ b/refactor/baselines/commit-audit-validation.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 1, + "task": "ENG-AUDIT-01", + "status": "done", + "sourceCommit": "4f324a84", + "checks": [ + { + "command": "node --test refactor/scripts/commit-audit.test.mjs", + "result": "pass", + "tests": 4, + "scope": "Real isolated Git repositories, negative cases and a real merge", + "log": { + "file": "refactor/.cache/eng-audit-tests-first.log", + "sha256": "95a8c9fa5388f828a37a35c97667bb641107ddd027073ce027a6126e986c2761" + } + }, + { + "command": "yarn check:commits --report", + "result": "pass before task completion", + "doneTasks": 80, + "dedicatedCommits": 76, + "bootstrapBundledTasks": 4, + "head": "4f324a84389186be7acd099ab074c4b21011ee81", + "report": { + "file": "refactor/.cache/eng-audit-before.json", + "sha256": "8dd298e4efd1b8ee21c06f245e3c73fe24d21fcfa77bbd83e2beea0d447c2a22" + } + }, + { + "command": "actionlint 1.7.12", + "result": "pass", + "workflows": 2 + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 832, + "engineering": 14, + "baseline": 58, + "total": 904 + }, + "log": { + "file": "refactor/.cache/eng-audit-ci.log", + "sha256": "73d92161c74c921a6755298efd18b49b1b68caf693c0c31f8aba040e050ebf92" + } + } + ], + "limitations": [ + "Local Git and workflow static validation only; no remote GitHub run", + "Commit/evidence provenance is not semantic test or implementation acceptance", + "ENG-09 impact and coverage subtasks remain open" + ], + "inputs": [ + { + "file": "refactor/scripts/commit-audit.mjs", + "sha256LF": "3b2076e428042017df2eb12b02c827a81059704f7435d86279e20f3e986f3d57" + }, + { + "file": "refactor/scripts/commit-audit.test.mjs", + "sha256LF": "74d807947473ccafb783a9127a8fac480c3051b422a5e2edb9e1509ac02cd49c" + }, + { + "file": "package.json", + "sha256LF": "94f3162e4ed876f5d4dcfbb4a93948a87b46314cf09d67ceaf9c3a8fd8960624" + }, + { + "file": ".github/workflows/nodejs.yml", + "sha256LF": "a88eee053a57333c4234b88d93535ee8de1d805bf884ce2ad168e3617a1becc1" + } + ], + "postCommitVerification": "Run yarn check:commits --report immediately after this task commit; its actual HEAD and completion must appear in the generated report. The report is not embedded in its own commit." +} diff --git a/refactor/changes/2026-09-12-ENG-AUDIT-01-commits.md b/refactor/changes/2026-09-12-ENG-AUDIT-01-commits.md new file mode 100644 index 000000000..df67259af --- /dev/null +++ b/refactor/changes/2026-09-12-ENG-AUDIT-01-commits.md @@ -0,0 +1,26 @@ +# ENG-AUDIT-01 独立完成提交的Git审计 + +起点4f324a84。把ENG-09拆出可独立验收的Git审计、依赖影响和契约覆盖三个子任务; +本步实现Git审计,其他两项及父任务保持todo。VAST初始化选择仍待用户确认,没有 +更改其默认行为或将VAST-03标完成。 + +`commit-audit.mjs`核对真实任务状态迁移、父分支/merge、准确ID、plan和当时的本地 +证据文件,不从done标志或提交标题推断工作已提交。初始80个done任务对应76个 +独立完成提交,DOC-01~04只在固定DOC-05提交中合并。历史标题符号差异保留, +任务ID仍准确可追溯;没有重写、amend或伪造过去的提交。 + +新命令`yarn check:commits --report`接入`ci:check`,报告由既有CI日志artifact收集。 +三个GitHub checkout补`fetch-depth: 0`;除审计外,已有测试也会读取历史源码Git对象。 +没有新增依赖或更改Yarn锁文件。详细行为与本地提交前后顺序见 +[维护说明](../commit-audit.md)。 + +使用隔离的真实Git仓库验证反例和分支合并,不能用mock成功输出替代历史验证。 +实际仓库审计、测试、lint与工作流静态检查结果见 +[验证记录](../baselines/commit-audit-validation.json)。远端GitHub运行仍由CI验收任务 +处理,本次没有push,不能把本地通过称为远端通过。 + +完成后独立本地提交`ENG-AUDIT-01`,提交后重新运行审计验证本任务自身。回退本步 +撤销脚本/测试/命令/checkout设置及文档台账,生产包API和已有历史均不变。 + +最终本地CI904项通过(832单元、14工程、58基线);脚本lint、plan及两个工作流的 +actionlint检查通过。提交后审计将验证新增的第81个done任务及第77个独立完成提交。 diff --git a/refactor/commit-audit.md b/refactor/commit-audit.md new file mode 100644 index 000000000..aa22e198f --- /dev/null +++ b/refactor/commit-audit.md @@ -0,0 +1,46 @@ +# 每任务提交审计 + +`ENG-AUDIT-01`负责ENG-09的Git审计子项。运行`yarn check:commits`,或增加`--report` +将本次HEAD及完成记录写入`refactor/.cache/ci/commit-audit.json`。检查已接入`ci:check`, +GitHub日志artifact收集该报告。CI三个checkout使用完整历史:审计以及既有历史源码 +兼容测试均依赖旧Git对象,浅克隆不能提供它们。 + +## 实际核对内容 + +脚本从可达Git历史读取每次tasks.json变化及其父提交,而不是从提交标题推断完成。 +一项从非done转为done,必须在同一个提交包含:准确任务ID、tasks.json、生成plan.md、 +存在于该提交树中的本地证据,以及至少一份该任务证据文件的实际变更。 + +普通完成提交只能完成一个任务。历史中的checkpoint不会被当作完成提交。合并时, +父分支已经done的任务沿用该分支的原始完成提交,不要求merge标题冒充任务完成。 +当前工作区新增done但尚未提交会失败,已完成历史任务被从台账删除也会失败。 +脚本核实起点与bootstrap提交可达;浅仓库明确报错,不悄悄跳过历史验证。 + +唯一合并完成例外固定为完整SHA `570600d2f6ffd580a890e0b87dfa9843bf8dd8d0`: +DOC-05携带先前已完成的DOC-01~04;要求该提交的新增done集合精确匹配这五项。 +见 [原始说明](changes/2026-09-10-DOC-05-task-commits.md)。不能把例外按ID无限复用。 + +早期历史标题存在`test(BASE-04): ...`和`... ENG-04 ...`等格式。审计接受准确的任务 +编号token,拒绝把TEST-010冒充TEST-01;这保留真实的独立提交,不是额外合并例外。 +新增提交仍遵循AI工作流的`type(scope): [TASK-ID] description`格式。 + +## 本地实施顺序 + +1. 任务保持doing时运行适用测试、`ci:check`与审计;已提交的其他done任务必须可追溯。 +2. 验收完成后将本任务标done,生成plan并暂存具体成果,立即独立提交。 +3. 提交后再次运行`yarn check:commits --report`确认本任务的真实完成记录,并检查Git状态。 + +第2步尚未提交时审计会报告未提交完成,这是预期保护。没有“临时豁免”参数,也不能 +在CI中通过环境变量绕过。审计不代替源码、测试结果或发布审查:证据文件共存不证明 +其中断言有效,因此报告明确限制。它也不凭历史标题判断某个实现是否有足够测试。 + +## 验证和维护 + +`refactor/scripts/commit-audit.test.mjs`建立临时真实Git仓库,覆盖独立完成、固定 +bootstrap例外、多个任务混在一提交、错误ID、缺plan/证据、复用未变化旧证据、路径 +逃逸、未提交done、删除历史任务、浅仓库以及实际feature分支合并。临时仓库使用 +专用作者,不修改用户Git配置;清理只针对已验证位于测试cache内的目录。 + +更改任务状态或提交政策时同时维护这些反例;禁止仅更新计数或忽略失败使审计变绿。 +`ENG-IMPACT-01`继续处理影响范围,`ENG-COVERAGE-01`继续契约覆盖索引;本子项完成 +不代表ENG-09整体、远端GitHub执行或npm发布门槛完成。 diff --git a/refactor/plan.md b/refactor/plan.md index 0c5447b19..5ea3326e9 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -2,9 +2,9 @@ > 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。 -基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 217 项,范围 22 个包及工作区/示例。 +基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 220 项,范围 22 个包及工作区/示例。 -状态:todo 131 / doing 6 / blocked 0 / done 80 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 133 / doing 6 / blocked 0 / done 81 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -82,7 +82,10 @@ | ENG-06 | workspace
支持按包非交互与 JS/TS 构建 | ENG-02, BASE-05 | build/dev 入口解析、指定包参数、原交互保留 | 三种产物、Less/SVG/worker 和本地 8082 demo 正常;保持 BASE-05 的 AMD 同时写入全局行为及 i18n/legacy 入口 | H | done | | ENG-07 | workspace
建立 tarball 消费与产物检查 | ENG-04, ENG-06 | 隔离 npm 消费 fixtures、API/声明/入口差分 | 不借 workspace 源码通过,能识别缺文件与默认导出变化 | H | done | | ENG-08 | workspace
增加覆盖率、资源与性能报告 | ENG-03, ENG-05, BASE-06 | 覆盖率基线、资源清理断言、性能报告与阈值 | 关键生命周期分支有门槛,报告不靠无意义断言堆数量;将 BASE-06 的同环境多组配对、计时/压缩审查阈值与资源异常分开接入候选,不把历史现象冻结成正常要求 | M | done | -| ENG-09 | workspace
建立全包依赖影响和文档检查 | ENG-07, DOC-04 | 共享核心/构建影响映射、文档及每任务完成提交的 Git 审计接入;契约-支持版本-测试 ID-命令-候选/报告-任务的覆盖索引 | 核心变化触发必需生态检查;原有 DOC-01 至 04 基线例外明确,后续 done 任务不能缺失独立 commit;计划/已执行/缺证据分开,识别缺少验证归属的公开契约 | M | todo | +| ENG-AUDIT-01 | workspace
核对每任务完成提交的真实Git历史 | ENG-07, DOC-04 | 独立完成提交、初始DOC例外、父分支合并和证据共提交审计;CI完整历史与报告 | 真实仓库及隔离Git反例通过,done任务不允许缺独立commit,固定DOC-01~04例外可追溯;提交后验证自身 | M | done | +| ENG-IMPACT-01 | workspace
建立全包依赖与共享变更影响映射 | ENG-07, DOC-04 | 核心/构建/类型/锁文件到受影响包和必需生态检查的映射及CI接入 | 核心变化触发必需生态检查;新增包、未知共享文件或失配依赖不能静默漏检;解释受影响路径和测试命令 | M | todo | +| ENG-COVERAGE-01 | workspace
建立公开契约与版本测试证据覆盖索引 | BASE-08, CORE-22, ENG-07 | 契约-包-支持版本-固定测试ID-命令-候选/报告-责任任务索引和文档检查 | 计划、已执行和缺证据状态分开,所有公开契约都有验证归属;无效路径、版本依据或报告对应不明确会被识别 | M | todo | +| ENG-09 | workspace
建立全包依赖影响和文档检查 | ENG-07, DOC-04, ENG-AUDIT-01, ENG-IMPACT-01, ENG-COVERAGE-01 | 共享核心/构建影响映射、文档及每任务完成提交的 Git 审计接入;契约-支持版本-测试 ID-命令-候选/报告-任务的覆盖索引 | 核心变化触发必需生态检查;原有 DOC-01 至 04 基线例外明确,后续 done 任务不能缺失独立 commit;计划/已执行/缺证据分开,识别缺少验证归属的公开契约 | M | todo | | ENG-10 | workspace
建立历史失败分级和测试可靠性规则 | ENG-03, ENG-04, ENG-05, ENG-07, BASE-07 | 历史失败 ID/环境/旧版复现/负责修复任务、逐模块门槛、受控等待与 trace/retry 规则 | 不靠全局忽略或无理由 skip 隐藏问题,新增回归阻止交付,设备缺口和偶发失败单独可见;以 risks.json 为统一差异索引;关闭必须有 resolutionEvidence/rationale,已复现、源码事实、未验证分开,登记不等于豁免 | M | done | | ENG-11 | workspace
增加不改变产物的模块构建分析 | ENG-03, ENG-08 | 正式构建的可选模块归因报告及源码/产物指纹 | 相同构建开关前后三格式字节一致,报告不进入分发包;区分 Rollup 渲染字节与压缩体积,并保留真实工作区/发布来源 | L | done | @@ -437,6 +440,7 @@ - ENG-06: [记录](changes/2026-09-10-ENG-06-build-development.md) [记录](build-development.md) [记录](baselines/build-validation.json) - ENG-07: [记录](changes/2026-09-10-ENG-07-package-consumers.md) [记录](baselines/package-validation.json) - ENG-08: [记录](coverage-performance.md) [记录](baselines/quality-validation.json) [记录](changes/2026-09-11-ENG-08-quality-reports.md) +- ENG-AUDIT-01: [记录](commit-audit.md) [记录](changes/2026-09-12-ENG-AUDIT-01-commits.md) [记录](baselines/commit-audit-validation.json) - ENG-10: [记录](changes/2026-09-10-ENG-10-test-reliability.md) [记录](test-reliability.md) - ENG-11: [记录](build-analysis.md) [记录](baselines/bundle-attribution.json) [记录](changes/2026-09-11-ENG-11-build-analysis.md) - PILOT-01: [记录](changes/2026-09-10-PILOT-01-chapter.md) [记录](baselines/pilot-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index 9541d1e37..17ce09017 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,15 @@ # 进度与证据 +## 最新完成:ENG-AUDIT-01 每任务提交审计 + +新增实际Git历史审计并接入ci:check,核对状态迁移、独立提交、证据/plan共提交和合并分支。 +原80个done对应76个独立完成提交,另4项为固定DOC-05初始例外;保留真实历史标题格式。 +真实Git反例及完整本地CI904项通过,GitHub三个checkout改为完整历史,actionlint通过。 +提交后再审计本任务自身。ENG-09拆成Git审计、影响映射、契约覆盖三个子项,后二者 +和父任务仍未完成;没有远端运行或推送。当前220项:81 done、6 doing、133 todo。 +VAST初始化选择仍待确认;其余实施继续。见 [本步记录](changes/2026-09-12-ENG-AUDIT-01-commits.md) +和 [验证证据](baselines/commit-audit-validation.json)。 + ## 当前实施:VAST-03 TS拆分与生命周期修复 原入口拆成5个严格TS模块,处理核心终止、显式广告重建、SDK/DOM回滚与晚到/重入回调。 diff --git a/refactor/scripts/commit-audit.mjs b/refactor/scripts/commit-audit.mjs new file mode 100644 index 000000000..aefe42d26 --- /dev/null +++ b/refactor/scripts/commit-audit.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +export const commitPolicy = { + bootstrapCommit: '570600d2f6ffd580a890e0b87dfa9843bf8dd8d0', + bootstrapTask: 'DOC-05', + bundledTasks: ['DOC-01', 'DOC-02', 'DOC-03', 'DOC-04'], +} + +export function auditCommits(directory = root, policy = commitPolicy) { + const git = args => execFileSync('git', args, { cwd: directory, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd() + assert.equal(git(['rev-parse', '--is-shallow-repository']), 'false', 'Commit audit requires full Git history; use checkout fetch-depth: 0') + const head = git(['rev-parse', 'HEAD']) + const current = JSON.parse(fs.readFileSync(path.join(directory, 'refactor/tasks.json'), 'utf8')) + git(['merge-base', '--is-ancestor', current.baselineCommit, head]) + git(['merge-base', '--is-ancestor', policy.bootstrapCommit, head]) + const snapshots = new Map() + function snapshot(commit) { + if (!snapshots.has(commit)) { + const entry = git(['ls-tree', commit, '--', 'refactor/tasks.json']) + const tasks = entry ? JSON.parse(git(['show', `${commit}:refactor/tasks.json`])).tasks : [] + snapshots.set(commit, new Map(tasks.map(task => [task.id, task]))) + } + return snapshots.get(commit) + } + const history = git(['log', '--full-history', '--reverse', '--format=%H%x00%P%x00%s', head, '--', 'refactor/tasks.json']).split('\n').filter(Boolean) + const completions = [] + for (const line of history) { + const [commit, parentText, subject] = line.split('\0') + const parents = parentText.split(' ').filter(Boolean).map(snapshot) + const newlyDone = [...snapshot(commit).values()].filter(task => task.status === 'done' && parents.every(parent => parent.get(task.id)?.status !== 'done')) + if (!newlyDone.length) + continue + const bootstrap = commit === policy.bootstrapCommit + const exceptions = bootstrap ? policy.bundledTasks : [] + const dedicated = newlyDone.filter(task => !exceptions.includes(task.id)) + assert.equal(dedicated.length, 1, `${commit}: one completion commit must complete exactly one task; found ${dedicated.map(task => task.id).join(', ')}`) + if (bootstrap) { + assert.equal(dedicated[0].id, policy.bootstrapTask, 'Bootstrap exception is restricted to DOC-05') + assert.deepEqual(newlyDone.map(task => task.id).sort(), [...policy.bundledTasks, policy.bootstrapTask].sort(), 'Bootstrap exception task set changed') + } + const id = dedicated[0].id + assert(/^[A-Z]+(?:-[A-Z]+)*-\d{2}$/.test(id), 'Invalid task ID') + // Older subjects use parentheses or plain tokens; punctuation does not erase a real task commit. + assert(new RegExp(`(?:^|[^A-Z0-9-])${id}(?:$|[^A-Z0-9-])`).test(subject), `${commit}: completion subject does not identify exact task ${id}`) + const changes = git(['diff-tree', '--root', '-m', '--no-commit-id', '--name-only', '-r', '-z', commit]).split('\0').filter(Boolean) + assert(changes.includes('refactor/tasks.json'), `${commit}: missing task-state change`) + assert(changes.includes('refactor/plan.md'), `${commit}: missing generated plan change`) + const tree = new Set(git(['ls-tree', '-r', '--name-only', '-z', commit, '--', 'refactor']).split('\0').filter(Boolean)) + for (const task of newlyDone) { + assert(task.evidence?.length, `${commit}: ${task.id} has no evidence`) + const local = task.evidence.filter(file => !/^https?:\/\//.test(file)).map((file) => { + const name = file.split('#')[0] + const normalized = path.posix.normalize(name) + assert(name && !name.includes('\\') && !path.posix.isAbsolute(name) && !/^[A-Z]:/i.test(name) && normalized !== '..' && !normalized.startsWith('../'), `${commit}: evidence escapes refactor`) + return `refactor/${normalized}` + }) + assert(local.length, `${commit}: ${task.id} needs durable local completion evidence`) + for (const file of local) assert(tree.has(file), `${commit}: ${task.id} evidence missing at completion: ${file}`) + if (!exceptions.includes(task.id)) + assert(local.some(file => changes.includes(file)), `${commit}: ${task.id} has no evidence updated in its completion commit`) + completions.push({ task: task.id, commit, subject, bundledWith: exceptions.includes(task.id) ? policy.bootstrapTask : null, evidence: local, changedEvidence: local.filter(file => changes.includes(file)) }) + } + } + const currentTasks = new Map(current.tasks.map(task => [task.id, task])) + const committed = snapshot(head) + for (const completion of completions) + assert(currentTasks.has(completion.task), `Historical completed task was removed: ${completion.task}`) + for (const task of current.tasks.filter(task => task.status === 'done')) { + assert.equal(committed.get(task.id)?.status, 'done', `Uncommitted completion: ${task.id}; commit this task before the final audit`) + assert(completions.some(completion => completion.task === task.id), `No dedicated completion found for ${task.id}`) + } + return { + schemaVersion: 1, + head, + baselineCommit: current.baselineCommit, + policy, + doneTasks: current.tasks.filter(task => task.status === 'done').length, + dedicatedCommits: new Set(completions.filter(item => !item.bundledWith).map(item => item.commit)).size, + completions, + limitation: 'Verifies actual completion transitions, task identity and durable evidence co-committed with the plan; does not prove the semantic quality of implementation or tests.', + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + assert(process.argv.slice(2).every(arg => arg === '--report'), 'Use --report or no arguments') + const report = auditCommits() + if (process.argv.includes('--report')) { + const directory = path.join(root, 'refactor/.cache/ci') + fs.mkdirSync(directory, { recursive: true }) + fs.writeFileSync(path.join(directory, 'commit-audit.json'), `${JSON.stringify(report, null, 2)}\n`) + } + console.log(`Commit audit: ${report.doneTasks} done tasks, ${report.dedicatedCommits} dedicated completion commits, ${report.policy.bundledTasks.length} documented bootstrap tasks; HEAD ${report.head}`) +} diff --git a/refactor/scripts/commit-audit.test.mjs b/refactor/scripts/commit-audit.test.mjs new file mode 100644 index 000000000..515ff3638 --- /dev/null +++ b/refactor/scripts/commit-audit.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +// eslint-disable-next-line test/no-import-node-test -- Exercise real isolated Git histories, not mocked command output. +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { auditCommits } from './commit-audit.mjs' + +const cache = fileURLToPath(new URL('../.cache/', import.meta.url)) +function repository(t) { + fs.mkdirSync(cache, { recursive: true }) + const directory = fs.mkdtempSync(path.join(cache, 'commit-audit-test-')) + t.after(() => { + const relative = path.relative(cache, directory) + assert(relative.startsWith('commit-audit-test-') && !relative.includes(path.sep)) + fs.rmSync(directory, { recursive: true, force: true }) + }) + const git = args => execFileSync('git', args, { + cwd: directory, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_AUTHOR_NAME: 'Audit Fixture', GIT_AUTHOR_EMAIL: 'audit@example.test', GIT_COMMITTER_NAME: 'Audit Fixture', GIT_COMMITTER_EMAIL: 'audit@example.test', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null' }, + }).trim() + const write = (file, text) => { + fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }) + fs.writeFileSync(path.join(directory, file), text) + } + const commit = (subject) => { + git(['add', '.']) + git(['-c', 'core.hooksPath=', '-c', 'commit.gpgSign=false', 'commit', '-m', subject]) + return git(['rev-parse', 'HEAD']) + } + git(['init', '-b', 'main']) + write('README.md', 'Test-only Git repository\n') + const baselineCommit = commit('Baseline') + const bundledTasks = ['DOC-01', 'DOC-02', 'DOC-03', 'DOC-04'] + const data = { baselineCommit, tasks: [...bundledTasks, 'DOC-05'].map(id => ({ id, status: 'done', evidence: ['changes/bootstrap.md'] })) } + for (const id of ['TEST-01', 'TEST-02']) data.tasks.push({ id, status: 'doing', evidence: [] }) + const save = (plan = true) => { + write('refactor/tasks.json', JSON.stringify(data)) + if (plan) + write('refactor/plan.md', data.tasks.map(task => `${task.id}: ${task.status}`).join('\n')) + } + write('refactor/changes/bootstrap.md', 'Initial documentation and the per-task rule\n') + save() + const bootstrapCommit = commit('docs: [DOC-05] establish initial documentation') + const policy = { bootstrapCommit, bootstrapTask: 'DOC-05', bundledTasks } + const finish = (ids = ['TEST-01'], options = {}) => { + for (const id of ids) { + const task = data.tasks.find(task => task.id === id) + task.status = 'done' + task.evidence = options.evidence || [`changes/${id}.md`] + if (options.writeEvidence !== false) + write(`refactor/changes/${id}.md`, `${id}: tested completion\n`) + } + save(options.plan !== false) + if (options.commit !== false) + return commit(options.subject || `test: [${ids[0]}] completed work`) + } + return { directory, git, write, commit, save, finish, data, policy, audit: () => auditCommits(directory, policy) } +} + +test('Commit audit proves dedicated transitions and only the pinned bootstrap bundling', (t) => { + const repo = repository(t) + const first = repo.finish() + const second = repo.finish(['TEST-02'], { subject: 'test(TEST-02): historical punctuation remains traceable' }) + const report = repo.audit() + assert.equal(report.doneTasks, 7) + assert.equal(report.dedicatedCommits, 3) + assert.deepEqual(report.completions.filter(item => item.bundledWith).map(item => item.task), repo.policy.bundledTasks) + assert.equal(report.completions.find(item => item.task === 'TEST-01').commit, first) + assert.equal(report.completions.find(item => item.task === 'TEST-02').commit, second) + assert.throws(() => auditCommits(repo.directory, { ...repo.policy, bundledTasks: [...repo.policy.bundledTasks, 'TEST-01'] }), /Bootstrap exception task set changed/) +}) + +test('Commit audit rejects combined task completion, misleading subjects and missing co-committed evidence', (t) => { + for (const [ids, options, expected] of [ + [['TEST-01', 'TEST-02'], {}, /exactly one task/], + [['TEST-01'], { subject: 'test: [TEST-010] not the completed task' }, /exact task TEST-01/], + [['TEST-01'], { plan: false }, /missing generated plan change/], + [['TEST-01'], { writeEvidence: false }, /evidence missing at completion/], + [['TEST-01'], { evidence: ['changes/bootstrap.md'], writeEvidence: false }, /no evidence updated/], + [['TEST-01'], { evidence: ['../README.md'] }, /evidence escapes refactor/], + ]) { + const repo = repository(t) + repo.finish(ids, options) + assert.throws(repo.audit, expected) + } +}) + +test('Commit audit rejects uncommitted done state and shallow or removed completion history', (t) => { + const repo = repository(t) + repo.finish(['TEST-01'], { commit: false }) + assert.throws(repo.audit, /Uncommitted completion: TEST-01/) + repo.commit('test: [TEST-01] finish with proof') + repo.data.tasks = repo.data.tasks.filter(task => task.id !== 'TEST-01') + repo.save() + assert.throws(repo.audit, /Historical completed task was removed/) + repo.write('.git/shallow', `${repo.git(['rev-parse', 'HEAD'])}\n`) + assert.throws(repo.audit, /requires full Git history/) +}) + +test('Commit audit finds feature-branch completion through a real merge without calling the merge a second completion', (t) => { + const repo = repository(t) + repo.git(['checkout', '-b', 'feature']) + const completed = repo.finish() + repo.git(['checkout', 'main']) + repo.write('notes.md', 'Unrelated main branch work\n') + repo.commit('docs: independent work') + repo.git(['-c', 'core.hooksPath=', '-c', 'commit.gpgSign=false', 'merge', '--no-ff', 'feature', '-m', 'Merge feature branch']) + const report = repo.audit() + assert.equal(report.doneTasks, 6) + assert.equal(report.dedicatedCommits, 2) + assert.equal(report.completions.filter(item => item.task === 'TEST-01').length, 1) + assert.equal(report.completions.find(item => item.task === 'TEST-01').commit, completed) +}) diff --git a/refactor/tasks.json b/refactor/tasks.json index 5bd9bed14..2af3dac18 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -604,6 +604,62 @@ "changes/2026-09-11-ENG-08-quality-reports.md" ] }, + { + "phase": "2 工程保障", + "scope": [ + "workspace" + ], + "risk": "M", + "id": "ENG-AUDIT-01", + "title": "核对每任务完成提交的真实Git历史", + "status": "done", + "dependsOn": [ + "ENG-07", + "DOC-04" + ], + "deliverable": "独立完成提交、初始DOC例外、父分支合并和证据共提交审计;CI完整历史与报告", + "acceptance": "真实仓库及隔离Git反例通过,done任务不允许缺独立commit,固定DOC-01~04例外可追溯;提交后验证自身", + "evidence": [ + "commit-audit.md", + "changes/2026-09-12-ENG-AUDIT-01-commits.md", + "baselines/commit-audit-validation.json" + ] + }, + { + "phase": "2 工程保障", + "scope": [ + "workspace" + ], + "risk": "M", + "id": "ENG-IMPACT-01", + "title": "建立全包依赖与共享变更影响映射", + "status": "todo", + "dependsOn": [ + "ENG-07", + "DOC-04" + ], + "deliverable": "核心/构建/类型/锁文件到受影响包和必需生态检查的映射及CI接入", + "acceptance": "核心变化触发必需生态检查;新增包、未知共享文件或失配依赖不能静默漏检;解释受影响路径和测试命令", + "evidence": [] + }, + { + "phase": "2 工程保障", + "scope": [ + "workspace" + ], + "risk": "M", + "id": "ENG-COVERAGE-01", + "title": "建立公开契约与版本测试证据覆盖索引", + "status": "todo", + "dependsOn": [ + "BASE-08", + "CORE-22", + "ENG-07" + ], + "deliverable": "契约-包-支持版本-固定测试ID-命令-候选/报告-责任任务索引和文档检查", + "acceptance": "计划、已执行和缺证据状态分开,所有公开契约都有验证归属;无效路径、版本依据或报告对应不明确会被识别", + "evidence": [] + }, { "id": "ENG-09", "phase": "2 工程保障", @@ -613,7 +669,10 @@ ], "dependsOn": [ "ENG-07", - "DOC-04" + "DOC-04", + "ENG-AUDIT-01", + "ENG-IMPACT-01", + "ENG-COVERAGE-01" ], "status": "todo", "risk": "M",