diff --git a/package.json b/package.json index 42eff8d17..bdfa2f9c7 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:commits --report && yarn check:plan && yarn lint && yarn check:types && yarn typecheck && yarn test", + "ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --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", @@ -66,7 +66,8 @@ "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", - "check:commits": "node refactor/scripts/commit-audit.mjs" + "check:commits": "node refactor/scripts/commit-audit.mjs", + "check:impact": "node refactor/scripts/impact.mjs" }, "browserslist": "last 1 Chrome version", "devDependencies": { @@ -87,13 +88,15 @@ "lerna": "8.2.4", "less": "4.5.1", "prompts": "2.4.2", + "semver": "7.7.4", "servor": "4.0.2", "svgo": "4.1.0", "terser": "5.51.2", "typescript": "5.9.3", "typescript-compat": "npm:typescript@4.3.5", "typescript-runtime-compat": "npm:typescript@5.1.6", - "vite": "7.3.6" + "vite": "7.3.6", + "yaml": "2.8.2" }, "resolutions": { "dts-bundle-generator/typescript": "5.9.3" diff --git a/refactor/README.md b/refactor/README.md index 4599d4d7c..edfe44f89 100644 --- a/refactor/README.md +++ b/refactor/README.md @@ -34,6 +34,7 @@ | [GitHub CI/CD](github-ci-cd.md) | PR/兼容矩阵、构建报告、Pages、npm 发布及远端准入验证 | | [AI 协作流程](ai-workflow.md) | AI 接续工作、任务边界、验证、记录和交接模板 | | [每任务提交审计](commit-audit.md) | 实际Git状态迁移、独立提交、初始例外、分支合并和CI报告 | +| [全包影响映射](impact-analysis.md) | 变更范围、依赖/验证关系、必需CI命令及尚缺的包验证 | | [架构决策](decisions.md) | 已选方向、待验证方案及被拒绝方案 | | [进度与证据](progress.md) | 本次会话结果、阻塞、下一步;不重复维护每个任务状态 | | [变更记录模板](changes/TEMPLATE.md) | 每次行为/类型/结构变化的详细记录 | diff --git a/refactor/baselines/impact-validation.json b/refactor/baselines/impact-validation.json new file mode 100644 index 000000000..4d4311c2b --- /dev/null +++ b/refactor/baselines/impact-validation.json @@ -0,0 +1,116 @@ +{ + "schemaVersion": 1, + "task": "ENG-IMPACT-01", + "status": "done", + "sourceCommit": "e0aacc5d6a75097a50fab2e8fdee46a2277455e6", + "checks": [ + { + "command": "node --test refactor/scripts/impact.test.mjs", + "result": "expected regression reproduced before fix", + "scope": "Shell conditional wrapper bypassed the original workflow matcher", + "log": { + "file": "refactor/.cache/eng-impact-shell-before.log", + "sha256": "5851b82f25d8f5161cdb5a92ca18e30202afb6c7cbe83827228d6caf0f6789f9" + } + }, + { + "command": "node --test refactor/scripts/impact.test.mjs", + "result": "pass after fix", + "tests": 7, + "scope": "Actual 22-package model, isolated dependency/policy negative cases, real Git ranges and workflow shell/condition negatives", + "log": { + "file": "refactor/.cache/eng-impact-shell-after.log", + "sha256": "48dcf18eb1b0d696165d862f2acf4ecc5bdd0c4421972436f5c871c059446a45" + } + }, + { + "command": "node node_modules/eslint/bin/eslint.js refactor/scripts/impact-model.mjs refactor/scripts/impact.mjs refactor/scripts/impact.test.mjs --no-fix", + "result": "pass", + "log": { + "file": "refactor/.cache/eng-impact-lint.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "command": "actionlint 1.7.12", + "result": "pass", + "workflows": 2, + "log": { + "file": "refactor/.cache/eng-impact-actionlint.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 832, + "engineering": 14, + "baseline": 65, + "total": 911 + }, + "log": { + "file": "refactor/.cache/eng-impact-ci-final.log", + "sha256": "04a7f49a111ed565e38e6b6e5e1ebd55487506231f88c9f5db221e62f253cd3b" + } + }, + { + "command": "yarn check:impact --report (inside final ci:check)", + "result": "pass", + "packages": 22, + "affectedPackages": 22, + "installedConsumerGaps": 19, + "unresolvedDynamicImports": 0, + "report": { + "file": "refactor/.cache/eng-impact-candidate-report.json", + "sha256": "7db3db5f7d6642066d17e9eefa506787b00c74fce7b0e2118ad94a829adcde53" + }, + "provenance": "Pre-completion candidate report; metadata/docs changed afterward, implementation inputs recorded below" + } + ], + "inputs": [ + { + "file": "refactor/scripts/impact-model.mjs", + "sha256LF": "d2dc168d2d358694be7e203ab3970deb1f42bff275e89ede88adbe870a962e4c" + }, + { + "file": "refactor/scripts/impact.mjs", + "sha256LF": "26cb7432fad82f3e9f1e454c911e15692610b6ae3052d9e2e2ec4cbc86957c36" + }, + { + "file": "refactor/scripts/impact.test.mjs", + "sha256LF": "3a7c82c0b8d47fba7c39b83e12ceba8b3c02c648689def89d73be44c57b31855" + }, + { + "file": "refactor/impact-policy.json", + "sha256LF": "9e3c0ac40f26d19b9c37783df28ea3adf1ada8ae084401c028867ec45ed47f97" + }, + { + "file": "package.json", + "sha256LF": "367015c0d3caa704eb8e6be179c517d7c1f529e5a956338145e366cff43b1955" + }, + { + "file": "yarn.lock", + "sha256LF": "1a6642a9728e77faf26dce43a849b56ba24573b44f08096a07fa36593eecc8de" + }, + { + "file": ".github/workflows/nodejs.yml", + "sha256LF": "a88eee053a57333c4234b88d93535ee8de1d805bf884ce2ad168e3617a1becc1" + }, + { + "file": "scripts/package-consumer.mjs", + "sha256LF": "3c85b3d664d5cf20e593a938cc756017f55fff5a2df5304054dbaa2f65131044" + }, + { + "file": "scripts/coverage-policy.json", + "sha256LF": "db3f2eacc44d61465031af6e618c8863ac4e9f23ef889865a9202919246ca403" + } + ], + "limitations": [ + "Local validation only; remote GitHub execution remains CI-04", + "Workflow validation accepts a restricted shell structure, not arbitrary shell semantics", + "Impact analysis does not prove package/device/vendor acceptance; 19 library packages lack the unified installed consumer suite", + "Existing generated docs eslint unused-disable warning and Yarn url.parse deprecation remain; no production files changed by this task", + "ENG-COVERAGE-01 and parent ENG-09 remain incomplete" + ] +} diff --git a/refactor/changes/2026-09-12-ENG-IMPACT-01-mapping.md b/refactor/changes/2026-09-12-ENG-IMPACT-01-mapping.md new file mode 100644 index 000000000..7023fd1d0 --- /dev/null +++ b/refactor/changes/2026-09-12-ENG-IMPACT-01-mapping.md @@ -0,0 +1,29 @@ +# ENG-IMPACT-01 全包依赖影响与CI检查映射 + +起点e0aacc5d。核对22包manifest、源码/声明导入、共享构建及示例,新增显式验证 +关系与静态依赖图;核心到全生态、danmuku到mask的共享DOM、库到站点分别说明来源。 +说明与实际命令见 [影响分析](../impact-analysis.md)。 + +新增`yarn check:impact --report`接入ci:check,报告进入已有CI日志收集目录。 +真实YAML校验确保六类现有必需命令不会被路径过滤、job/step条件或允许失败静默 +略过;新增包、错误workspace版本范围和无法解析的源码也会明确失败。 + +7项测试组覆盖真实项目、可控包配置反例及真实Git。Git范围包括暂存/未暂存/未跟踪/ +删除和改名两端;PR/push读取结构化SHA,缺少可用事件base时记录并检查全仓库。 +未知共享路径或计算型import保守扩大范围,没有把未知当作无影响。 + +报告如实标出统一安装消费者只有core/chapter、其余19个库包尚缺完整安装消费。 +这不替代已有Ads/DASH专属类型命令,也不把影响分析当作所有插件或真实设备已通过。 +安装范围从实际consumer脚本提取,后续新增验证包时需同步报告策略。 + +yaml2.8.2/semver7.7.4固定为直接开发依赖,用于可靠解析workflow和版本范围; +初次PowerShell搜索中的引号/目录通配符错误已改为兼容的rg参数,没有改变生产代码。 +检查结果与输入指纹见 [验证记录](../baselines/impact-validation.json)。 + +复查补入shell条件包装及`|| true`反例,先复现校验器漏检,再收紧必需命令首行和shell +格式校验;同时覆盖YAML布尔false条件及checkout条件。问题已修复并保留失败/通过日志, +没有只登记后放行。最终7项影响测试组通过;完整CI结果在验证记录中归档。 + +本任务完成独立本地提交后进行提交审计;无push/tag/publish。回退本步移除影响 +脚本/测试、策略、直接开发依赖及CI命令接入;原有CI任务仍可运行。ENG-COVERAGE-01 +和父任务ENG-09继续,VAST初始化选择仍待用户确认。 diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md index 7512436d5..cf0bcda26 100644 --- a/refactor/ci-setup.md +++ b/refactor/ci-setup.md @@ -17,6 +17,7 @@ | `yarn test:baseline` | 固定发布包完整性及本地 HTTP 基线测试;首次可能下载已固定归档到缓存 | | `yarn ci:check` | 严格 Node/Yarn/锁检查、计划、只读 lint、类型、Node 和基线测试;允许写忽略缓存,不修改源码 | | `yarn ci:build` | 21 库包、i18n、编辑器声明和文档站构建,以及构建后包导入 smoke;会生成 dist 和 docs 内容 | +| `yarn check:impact --report` | 读取实际依赖/验证关系和Git变更,核对workflow必需命令,写CI影响报告;已接入ci:check,见[影响映射](impact-analysis.md) | | `yarn build:all` | 保留旧入口,执行 ci:build 后只读 lint | scripts/build-docs.js 保留原 npm run build 子命令兼容入口,现在传播失败退出码;包管理/锁维护继续使用 Yarn。MOD-02 可再统一旧内部脚本。build-ts.js 仅对刚生成的声明执行 ESLint layout 格式修正,使只读 lint 在构建后仍能通过;不是对生产源码执行自动修复。 diff --git a/refactor/impact-analysis.md b/refactor/impact-analysis.md new file mode 100644 index 000000000..7a456bb91 --- /dev/null +++ b/refactor/impact-analysis.md @@ -0,0 +1,71 @@ +# 全包变更影响映射 + +ENG-IMPACT-01是ENG-09的影响分析子项。`yarn check:impact --report`已接入`ci:check`, +报告写入`refactor/.cache/ci/impact.json`,由现有CI日志artifact收集。它解释哪些包和 +检查受影响,不通过路径过滤减少当前CI任务。 + +## 模型与校验 + +`impact-policy.json`显式登记22个包、核心的全部生态验证消费者、站点消费者、跨插件 +关系、示例及共享文件。包新增/删除/改名必须同步维护映射,不能默默忽略。 +`impact-model.mjs`读取当前manifest并用固定semver解析内部依赖和npm别名的版本 +范围;不匹配当前workspace版本时失败。再用当前TypeScript解析src/types/public的 +静态import/export/require/import-type及相对跨包引用。未知workspace导入或解析 +失败明确报错;计算型动态import使变更保守扩大到全包,并列出需审查的源文件。 + +关系分开记录: + +- manifest及静态导入是实际可观察依赖,沿消费者方向传播。 +- 核心到全部21个生态包是**验证契约**,不是声称所有包都有运行时import。 + 核心API/DOM/类型/媒体行为改变需要全生态检查。 +- 库包到站点是文档、示例及编辑器声明消费关系。 +- danmuku到danmuku-mask来自共享`$danmuku` DOM,源码和组合示例作为证据; + 不能只靠package.json发现它。 + +普通插件源码改动影响自身和站点;修改danmuku还影响mask;核心、共享脚本/类型、 +锁文件和根工具配置影响全包。compiled/uncompiled路径映射回源码所属包。 +示例的初始所有权来自冻结包清单,SITE-01仍负责遗留示例/历史thumbnail来源的完整 +复盘;未分类路径始终扩大到全包,报告`reviewRequired`,不把未知路径当作无影响。 + +## Git范围 + +本地默认包含HEAD之后的暂存、未暂存、删除和未跟踪文件;忽略目录遵循Git规则。 +`yarn check:impact --base --report`另外包含该基准与HEAD的merge-base +之后变化。禁用rename折叠,两端路径都会出现,因此跨包移动不会漏掉原包。 + +GitHub PR读取事件里的base SHA,push读取before SHA;初次push的全零SHA、手动/ +复用入口缺少基准、事件基准对象不可用时检查整个跟踪文件集合,并明确记录fallback。 +显式传错`--base`报错,不自行猜测。事件内容只用作结构化数据,不拼接shell命令。 +报告保存HEAD、实际base/merge-base、是否有工作区变化、文件/策略/manifest/锁指纹。 + +## CI门槛与能力边界 + +六项必需命令不能从策略中漏掉:`ci:check`、`ci:build`、`test:package`、 +`test:browser`、`test:coverage`、`test:performance`。固定yaml2.8.2解析实际workflow, +检查对应job/步骤无条件执行、没有continue-on-error、没有路径过滤,且checkout有 +完整历史。命令被移走、改成echo或增加条件会失败。命令名还必须存在于根scripts。 +必需命令必须是步骤首个非空、非注释行,只接受独立命令或当前显式bash的固定tee日志 +格式;其他shell包装语法需审查后扩展。条件包装、`|| true`、非bash的tee和YAML布尔 +`if: false`均有拒绝反例;checkout本身也不能带条件或允许失败。这是受限结构校验, +不是通用shell解释器,也不证明脚本内部的测试语义。 + +现有CI继续运行全部这些检查;影响报告没有输出允许跳过任务的开关。报告同时区分: + +- `test:package`当前实际只有core/chapter;脚本读取其names数组核对策略,变更范围 + 后必须更新声明。其余19个库包完整安装消费仍缺证据,不能因识别为affected而过关。 +- 覆盖率范围从实际coverage-policy读取;浏览器/性能命令运行已有用例,不代表所有 + 包、真实设备、远程SDK已验收。 +- ENG-COVERAGE-01继续把契约、支持版本、测试ID、候选/报告及任务逐项连接; + 本映射不是发布准入或对测试语义有效性的证明。 + +## 文件与验证 + +- `impact-model.mjs`:包模型、关系传播、策略和workflow约束。 +- `impact.mjs`:Git范围/事件数据、报告及CLI。 +- `impact.test.mjs`:实际22包关系、隔离包配置的反例、真实Git改名/删除/暂存/未跟踪、 + PR/push及范围fallback、workflow漏检反例。 + +新增直接开发依赖yaml2.8.2和semver7.7.4,分别用于解析真实YAML和可靠的版本范围 +校验,均固定为当前工具树已有版本。依赖和锁由Yarn维护;没有新增运行时包依赖。 +运行`yarn test:baseline`或定向Node测试,再执行`yarn ci:check`。远端GitHub实际运行 +仍由CI-04验收,本次本地检查不等于远端成功。 diff --git a/refactor/impact-policy.json b/refactor/impact-policy.json new file mode 100644 index 000000000..6e06fb209 --- /dev/null +++ b/refactor/impact-policy.json @@ -0,0 +1,309 @@ +{ + "schemaVersion": 1, + "packages": [ + { + "name": "artplayer", + "kind": "core" + }, + { + "name": "artplayer-plugin-ads", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-ambilight", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-asr", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-audio-track", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-auto-thumbnail", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-chapter", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-chromecast", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-danmuku", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-danmuku-mask", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-dash-control", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-document-pip", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-hls-control", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-jassub", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-multiple-subtitles", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-vast", + "kind": "plugin" + }, + { + "name": "artplayer-plugin-vtt-thumbnail", + "kind": "plugin" + }, + { + "name": "artplayer-proxy-canvas", + "kind": "proxy" + }, + { + "name": "artplayer-proxy-mediabunny", + "kind": "proxy" + }, + { + "name": "artplayer-tool-iframe", + "kind": "tool" + }, + { + "name": "artplayer-tool-thumbnail", + "kind": "tool" + }, + { + "name": "artplayer-vitepress", + "kind": "docs" + } + ], + "core": "artplayer", + "site": "artplayer-vitepress", + "coreValidationConsumers": [ + "artplayer-plugin-ads", + "artplayer-plugin-ambilight", + "artplayer-plugin-asr", + "artplayer-plugin-audio-track", + "artplayer-plugin-auto-thumbnail", + "artplayer-plugin-chapter", + "artplayer-plugin-chromecast", + "artplayer-plugin-danmuku", + "artplayer-plugin-danmuku-mask", + "artplayer-plugin-dash-control", + "artplayer-plugin-document-pip", + "artplayer-plugin-hls-control", + "artplayer-plugin-jassub", + "artplayer-plugin-multiple-subtitles", + "artplayer-plugin-vast", + "artplayer-plugin-vtt-thumbnail", + "artplayer-proxy-canvas", + "artplayer-proxy-mediabunny", + "artplayer-tool-iframe", + "artplayer-tool-thumbnail", + "artplayer-vitepress" + ], + "coreEvidence": "refactor/compatibility.md", + "siteEvidence": "scripts/build-ts.js", + "edges": [ + { + "dependency": "artplayer-plugin-danmuku", + "consumer": "artplayer-plugin-danmuku-mask", + "reason": "Mask renders into the $danmuku element supplied by the danmuku plugin", + "evidence": [ + "packages/artplayer-plugin-danmuku-mask/src/index.js", + "docs/assets/example/danmuku.mask.js" + ] + } + ], + "examples": [ + { + "file": "docs/assets/example/index.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/mobile.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/setting.test.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/hls.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/dash.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/flv.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/mpegts.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/webtorrent.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/thumbnail.js", + "owner": "artplayer" + }, + { + "file": "docs/assets/example/ads.js", + "owner": "artplayer-plugin-ads" + }, + { + "file": "docs/assets/example/ambilight.js", + "owner": "artplayer-plugin-ambilight" + }, + { + "file": "docs/assets/example/asr.js", + "owner": "artplayer-plugin-asr" + }, + { + "file": "docs/assets/example/audio.track.js", + "owner": "artplayer-plugin-audio-track" + }, + { + "file": "docs/assets/example/auto.thumbnail.js", + "owner": "artplayer-plugin-auto-thumbnail" + }, + { + "file": "docs/assets/example/chapter.js", + "owner": "artplayer-plugin-chapter" + }, + { + "file": "docs/assets/example/chromecast.js", + "owner": "artplayer-plugin-chromecast" + }, + { + "file": "docs/assets/example/danmuku.js", + "owner": "artplayer-plugin-danmuku" + }, + { + "file": "docs/assets/example/danmuku.mask.js", + "owner": "artplayer-plugin-danmuku-mask" + }, + { + "file": "docs/assets/example/dash.control.js", + "owner": "artplayer-plugin-dash-control" + }, + { + "file": "docs/assets/example/document.pip.js", + "owner": "artplayer-plugin-document-pip" + }, + { + "file": "docs/assets/example/hls.control.js", + "owner": "artplayer-plugin-hls-control" + }, + { + "file": "docs/assets/example/jassub.js", + "owner": "artplayer-plugin-jassub" + }, + { + "file": "docs/assets/example/multiple.subtitles.js", + "owner": "artplayer-plugin-multiple-subtitles" + }, + { + "file": "docs/assets/example/vast.js", + "owner": "artplayer-plugin-vast" + }, + { + "file": "docs/assets/example/vtt.thumbnail.js", + "owner": "artplayer-plugin-vtt-thumbnail" + }, + { + "file": "docs/assets/example/canvas.js", + "owner": "artplayer-proxy-canvas" + }, + { + "file": "docs/assets/example/mediabunny.js", + "owner": "artplayer-proxy-mediabunny" + }, + { + "file": "docs/assets/example/iframe.js", + "owner": "artplayer-tool-iframe" + }, + { + "file": "docs/assets/example/tool.thumbnail.js", + "owner": "artplayer-tool-thumbnail" + } + ], + "sharedPrefixes": [ + "scripts/", + "types/", + "test/", + ".github/", + "refactor/scripts/", + "refactor/fixtures/", + "refactor/baselines/" + ], + "sharedFiles": [ + "package.json", + "yarn.lock", + ".node-version", + ".yarnrc", + ".yarnrc.yml", + "tsconfig.json", + "tsconfig.base.json", + "eslint.config.js", + "playwright.config.js", + "playwright.performance.config.js", + "svgo.config.json", + ".editorconfig", + "refactor/impact-policy.json" + ], + "gates": [ + { + "job": "checks", + "command": "yarn ci:check", + "scope": "Toolchain, task/commit/impact contracts, lint, current strict projects and existing Node/baseline suites" + }, + { + "job": "checks", + "command": "yarn ci:build", + "scope": "All library builds, i18n, editor declarations, docs site and existing import suites" + }, + { + "job": "browser-smoke", + "command": "yarn test:package", + "scope": "Installed core/chapter consumers only; other package installation acceptance remains pending" + }, + { + "job": "browser-smoke", + "command": "yarn test:browser", + "scope": "All existing three-engine browser suites; does not imply every package/device has a suite" + }, + { + "job": "browser-smoke", + "command": "yarn test:performance", + "scope": "Existing installed performance and resource-cleanup scenarios" + }, + { + "job": "coverage", + "command": "yarn test:coverage", + "scope": "Current coverage-policy packages and critical module thresholds only" + } + ], + "installedConsumerPackages": [ + "artplayer", + "artplayer-plugin-chapter" + ] +} diff --git a/refactor/plan.md b/refactor/plan.md index 5ea3326e9..d6926531b 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 220 项,范围 22 个包及工作区/示例。 -状态:todo 133 / doing 6 / blocked 0 / done 81 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 132 / doing 6 / blocked 0 / done 82 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -83,7 +83,7 @@ | 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-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-IMPACT-01 | workspace
建立全包依赖与共享变更影响映射 | ENG-07, DOC-04 | 核心/构建/类型/锁文件到受影响包和必需生态检查的映射及CI接入 | 核心变化触发必需生态检查;新增包、未知共享文件或失配依赖不能静默漏检;解释受影响路径和测试命令 | M | done | | 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 | @@ -441,6 +441,7 @@ - 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-IMPACT-01: [记录](impact-analysis.md) [记录](changes/2026-09-12-ENG-IMPACT-01-mapping.md) [记录](baselines/impact-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 17ce09017..ae318185c 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,16 @@ # 进度与证据 +## 最新完成:ENG-IMPACT-01 全包影响与必需检查映射 + +实际22包依赖/验证关系、共享构建/类型/锁文件及示例映射已接入ci:check;Git变更包含 +改名两端及所有本地状态,未知路径保守扩大到全包。六项必需CI命令继续执行,并校验 +真实workflow的条件、失败传播和完整历史;shell漏检已用反例复现并修复。 +7组专项测试和完整CI911项通过,专项lint及actionlint通过。报告明确统一安装消费者 +当前只有core/chapter,其余19个库包不能据此算验收完成。ENG-COVERAGE-01和父任务 +ENG-09继续,VAST初始化选择仍待确认。当前220项:82 done、6 doing、132 todo。 +本任务独立本地提交后审计自身;未推送或发布。见 [实施记录](changes/2026-09-12-ENG-IMPACT-01-mapping.md) +和 [验证证据](baselines/impact-validation.json)。 + ## 最新完成:ENG-AUDIT-01 每任务提交审计 新增实际Git历史审计并接入ci:check,核对状态迁移、独立提交、证据/plan共提交和合并分支。 diff --git a/refactor/scripts/impact-model.mjs b/refactor/scripts/impact-model.mjs new file mode 100644 index 000000000..e1affe429 --- /dev/null +++ b/refactor/scripts/impact-model.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import semver from 'semver' +import ts from 'typescript' +import YAML from 'yaml' + +export function repositoryPath(file) { + assert(typeof file === 'string' && file.length && !file.includes('\\') && !file.includes('\0') && !path.posix.isAbsolute(file) && !/^[A-Z]:/i.test(file), `Invalid repository path: ${file}`) + const normalized = path.posix.normalize(file) + assert(normalized !== '..' && !normalized.startsWith('../'), `Path escapes repository: ${file}`) + return normalized +} + +export function analyzeImpact(model, changedFiles) { + const names = model.packages.map(pkg => pkg.name) + const affected = new Set() + const reasons = [] + const unknownPaths = [] + function include(name, reason) { + if (!affected.has(name)) { + affected.add(name) + reasons.push({ package: name, ...reason }) + } + } + const files = [...new Set(changedFiles.map(repositoryPath))].sort() + const all = (file, rule) => names.forEach(name => include(name, { file, rule })) + for (const file of files) { + if (file.startsWith('packages/')) { + const owner = file.split('/')[1] + assert(names.includes(owner), `Changed package is not mapped: ${owner}`) + include(owner, { file, rule: 'package-owner' }) + } + else if (model.policy.sharedFiles.includes(file) || model.policy.sharedPrefixes.some(prefix => file.startsWith(prefix))) { + all(file, 'shared-engineering') + } + else { + const generated = names.find(name => file.startsWith(`docs/uncompiled/${name}/`) || file === `docs/compiled/${name}.js` || file === `docs/compiled/${name}.legacy.js` || file === `docs/compiled/${name}.mjs`) + const examples = model.policy.examples.filter(example => example.file === file) + if (generated) { + include(generated, { file, rule: 'generated-package-artifact' }) + } + else if (examples.length) { + examples.forEach(example => include(example.owner, { file, rule: 'mapped-example' })) + } + else { + unknownPaths.push(file) + all(file, 'unclassified-conservative-fallback') + } + } + } + if (files.length && model.dynamicImports.length) + all(model.dynamicImports[0], 'unresolved-dynamic-import-conservative-fallback') + let previousSize = -1 + while (affected.size !== previousSize) { + previousSize = affected.size + for (const edge of model.edges) { + if (affected.has(edge.dependency)) + include(edge.consumer, { rule: edge.kind, via: edge.dependency, evidence: edge.evidence, explanation: edge.reason }) + } + } + const affectedPackages = [...affected].sort() + return { + files, + affectedPackages, + reasons, + unknownPaths, + reviewRequired: Boolean(unknownPaths.length || model.dynamicImports.length), + requiredChecks: model.policy.gates, + requiredCIJobs: [...new Set(model.policy.gates.map(gate => gate.job))].sort(), + installedConsumerGaps: affectedPackages.filter(name => name !== model.policy.site && !model.policy.installedConsumerPackages.includes(name)), + limitation: 'CI gates remain unconditional. This dependency report selects no tests for omission and certifies neither missing package suites nor device/vendor acceptance.', + } +} + +function importsIn(file, text) { + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) + assert.equal(source.parseDiagnostics.length, 0, `Cannot reliably scan malformed dependency source: ${file}`) + const imports = [] + const dynamic = [] + const add = (node) => { + if (node && ts.isStringLiteralLike(node)) + imports.push(node.text) + else + dynamic.push(file) + } + function visit(node) { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier) + add(node.moduleSpecifier) + } + else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + add(node.argument.literal) + } + else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) { + add(node.arguments[0]) + } + ts.forEachChild(node, visit) + } + visit(source) + return { imports, dynamic } +} + +export function readImpactModel(directory) { + const read = file => JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8')) + const policy = read('refactor/impact-policy.json') + assert.equal(policy.schemaVersion, 1) + const gateCommands = policy.gates.map(gate => gate.command) + assert.equal(new Set(gateCommands).size, gateCommands.length, 'Duplicate impact gate') + for (const command of ['yarn ci:check', 'yarn ci:build', 'yarn test:package', 'yarn test:browser', 'yarn test:coverage', 'yarn test:performance']) + assert(gateCommands.includes(command), `Missing mandatory ecosystem gate: ${command}`) + const names = policy.packages.map(pkg => pkg.name).sort() + assert.equal(new Set(names).size, names.length, 'Duplicate mapped package') + const actual = fs.readdirSync(path.join(directory, 'packages')).filter(name => fs.existsSync(path.join(directory, 'packages', name, 'package.json'))).sort() + assert.deepEqual(actual, names, 'Workspace package inventory changed; update impact ownership explicitly') + assert(names.includes(policy.core) && names.includes(policy.site), 'Missing core/site mapping') + assert.deepEqual([...policy.coreValidationConsumers].sort(), names.filter(name => name !== policy.core), 'Core changes must trigger every ecosystem validation consumer') + const packages = names.map((name) => { + const manifest = read(`packages/${name}/package.json`) + assert.equal(manifest.name, name, 'Package directory and manifest name disagree') + return { name, version: manifest.version, manifest } + }) + const edges = [] + const dynamicImports = [] + const exists = (file) => { + repositoryPath(file) + assert(fs.existsSync(path.join(directory, file)), `Impact evidence is missing: ${file}`) + } + const edge = (dependency, consumer, kind, evidence, reason) => { + assert(names.includes(dependency) && names.includes(consumer), 'Unknown dependency or consumer in impact graph') + evidence.forEach(exists) + if (dependency !== consumer) + edges.push({ dependency, consumer, kind, evidence, reason }) + } + for (const consumer of policy.coreValidationConsumers) + edge(policy.core, consumer, 'core-validation-contract', [policy.coreEvidence], 'Core API/DOM/type and media contracts require ecosystem validation, even without manifest dependencies') + for (const name of names.filter(name => name !== policy.site)) + edge(name, policy.site, 'site-consumer-contract', [policy.siteEvidence], 'Library examples, generated declarations and docs consume the package') + for (const item of policy.edges) + edge(item.dependency, item.consumer, 'explicit-integration-contract', item.evidence, item.reason) + for (const example of policy.examples) { + assert(names.includes(example.owner), 'Example owner is not mapped') + exists(example.file) + } + for (const pkg of packages) { + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + for (const [declaredName, value] of Object.entries(pkg.manifest[field] || {})) { + assert.equal(typeof value, 'string', `Invalid dependency declaration: ${pkg.name}/${declaredName}`) + const alias = /^npm:(.+)@([^@]+)$/.exec(value) + const name = alias?.[1] || declaredName + const range = alias?.[2] || value + if (!names.includes(name)) { + assert(!/^artplayer(?:-|$)/.test(name), `Unknown workspace dependency: ${name}`) + continue + } + const target = packages.find(candidate => candidate.name === name) + assert(semver.validRange(range) && semver.satisfies(target.version, range), `Workspace dependency range mismatch: ${pkg.name} ${field} ${name}@${range}, current ${target.version}`) + edge(name, pkg.name, `manifest-${field}`, [`packages/${pkg.name}/package.json`], `${declaredName}@${value}`) + } + } + const walk = (relative) => { + const absolute = path.join(directory, relative) + if (!fs.existsSync(absolute)) + return + for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) { + const file = `${relative}/${entry.name}` + assert(!entry.isSymbolicLink(), `Review redirected source before dependency scanning: ${file}`) + if (entry.isDirectory()) { + walk(file) + } + else if (/\.[cm]?[jt]sx?$/.test(file)) { + const found = importsIn(file, fs.readFileSync(path.join(directory, file), 'utf8')) + dynamicImports.push(...found.dynamic) + for (const specifier of found.imports) { + const resolved = specifier.startsWith('.') ? path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)) : specifier + const dependency = names.find(name => resolved === name || resolved.startsWith(`${name}/`) || resolved.startsWith(`packages/${name}/`)) + if (dependency) + edge(dependency, pkg.name, 'static-source-import', [file], specifier) + else + assert(!/^artplayer(?:-|$)/.test(specifier), `Unknown workspace import ${specifier} in ${file}`) + } + } + } + } + for (const folder of ['src', 'types', 'public']) walk(`packages/${pkg.name}/${folder}`) + } + const scripts = read('package.json').scripts + for (const gate of policy.gates) { + assert(/^yarn [a-z][\w:-]*$/.test(gate.command), 'Impact gates require an explicit repository script') + assert(Object.hasOwn(scripts, gate.command.slice(5)), `Missing required command: ${gate.command}`) + } + const consumerSource = ts.createSourceFile('package-consumer.mjs', fs.readFileSync(path.join(directory, 'scripts/package-consumer.mjs'), 'utf8'), ts.ScriptTarget.Latest, true) + const consumerNames = consumerSource.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations]).filter(node => ts.isIdentifier(node.name) && node.name.text === 'names') + assert(consumerNames.length === 1 && consumerNames[0].initializer && ts.isArrayLiteralExpression(consumerNames[0].initializer), 'Review changed installed consumer scope extraction') + const installed = consumerNames[0].initializer.elements.map((element) => { + assert(ts.isStringLiteralLike(element), 'Installed consumer scope must be statically reviewable') + return element.text + }) + assert.deepEqual(installed.sort(), [...policy.installedConsumerPackages].sort(), 'Installed consumer coverage changed; update its reported scope') + const coveragePackages = read('scripts/coverage-policy.json').packages + assert(coveragePackages.every(name => names.includes(name)), 'Unknown coverage package') + return { policy, packages, edges, dynamicImports: [...new Set(dynamicImports)].sort(), coveragePackages } +} + +export function validateImpactWorkflow(text, model) { + const workflow = YAML.parse(text, { uniqueKeys: true }) + assert(workflow.on && Object.hasOwn(workflow.on, 'pull_request') && Object.hasOwn(workflow.on, 'push'), 'Impact checks require both pull_request and push triggers') + for (const event of ['pull_request', 'push']) { + const config = workflow.on[event] + assert(!config?.paths && !config?.['paths-ignore'], 'Path filters may skip required ecosystem gates') + } + for (const gate of model.policy.gates) { + const job = workflow.jobs?.[gate.job] + assert(job && !Object.hasOwn(job, 'if') && !job['continue-on-error'], `Required impact job must not be conditional or allowed to fail: ${gate.job}`) + const steps = job.steps || [] + const command = gate.command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + assert(steps.some((step) => { + if (Object.hasOwn(step, 'if') || step['continue-on-error'] || typeof step.run !== 'string') + return false + const shell = step.shell || job.defaults?.run?.shell || workflow.defaults?.run?.shell + const defaultBash = !shell && /^(?:ubuntu|macos)-/.test(job['runs-on']) + if (shell !== 'bash' && !defaultBash) + return false + const first = step.run.split(/\r?\n/).map(line => line.trim()).find(line => line && !line.startsWith('#')) + return first === gate.command || (shell === 'bash' && new RegExp(`^${command} 2>&1 \\| tee refactor/\\.cache/ci/[a-z-]+\\.log$`).test(first)) + }), `Required unconditional command missing from ${gate.job}: ${gate.command}`) + assert(steps.some(step => step.uses?.startsWith('actions/checkout@') && !Object.hasOwn(step, 'if') && !step['continue-on-error'] && step.with?.['fetch-depth'] === 0), `Impact and compatibility checks require full history: ${gate.job}`) + } +} diff --git a/refactor/scripts/impact.mjs b/refactor/scripts/impact.mjs new file mode 100644 index 000000000..070adcdab --- /dev/null +++ b/refactor/scripts/impact.mjs @@ -0,0 +1,102 @@ +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' +import { analyzeImpact, readImpactModel, validateImpactWorkflow } from './impact-model.mjs' +import { hash } from './releases.mjs' + +const root = fileURLToPath(new URL('../../', import.meta.url)) + +export function changedFiles(directory, options = {}) { + const git = args => execFileSync('git', args, { cwd: directory, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd() + const head = git(['rev-parse', 'HEAD']) + const split = text => text.split('\0').filter(Boolean) + const local = [...split(git(['diff', '--name-only', '--no-renames', '-z', 'HEAD', '--'])), ...split(git(['ls-files', '--others', '--exclude-standard', '-z']))] + let requested = options.base + let origin = requested ? 'explicit-base' : 'worktree' + if (!requested && options.eventName) { + const event = options.eventPath ? JSON.parse(fs.readFileSync(options.eventPath, 'utf8')) : {} + requested = options.eventName === 'pull_request' ? event.pull_request?.base?.sha : options.eventName === 'push' ? event.before : null + if (requested && /^0+$/.test(requested)) + requested = null + if (requested) + assert(/^[a-f\d]{40}$/.test(requested), 'Invalid GitHub event base SHA') + origin = requested ? `github-${options.eventName}` : 'full-repository-event-fallback' + } + let base = null + let mergeBase = null + let committed = [] + if (requested) { + try { + base = git(['rev-parse', '--verify', '--end-of-options', `${requested}^{commit}`]) + mergeBase = git(['merge-base', base, head]) + } + catch (error) { + if (options.base) + throw error + origin = 'full-repository-missing-event-base' + } + if (mergeBase) + committed = split(git(['diff', '--name-only', '--no-renames', '-z', mergeBase, head, '--'])) + } + if (origin.startsWith('full-repository-')) + committed = split(git(['ls-files', '-z'])) + return { head, requestedBase: requested || null, base, mergeBase, origin, worktreeChanged: local.length > 0, files: [...new Set([...committed, ...local])].sort() } +} + +export function createImpactReport(directory = root, options = {}) { + const model = readImpactModel(directory) + validateImpactWorkflow(fs.readFileSync(path.join(directory, '.github/workflows/nodejs.yml'), 'utf8'), model) + const range = changedFiles(directory, options) + const impact = analyzeImpact(model, range.files) + const metadata = ['refactor/impact-policy.json', 'package.json', 'yarn.lock', ...model.packages.map(pkg => `packages/${pkg.name}/package.json`)] + const inputs = [...new Set([...metadata, ...range.files])].map((file) => { + const absolute = path.join(directory, file) + if (!fs.existsSync(absolute)) + return { file, state: 'deleted-or-missing' } + if (fs.lstatSync(absolute).isSymbolicLink()) + return { file, state: 'symlink', sha256: hash(fs.readlinkSync(absolute)) } + return { file, state: 'file', sha256: hash(fs.readFileSync(absolute)) } + }) + return { + schemaVersion: 1, + range, + ...impact, + packages: model.packages.map(pkg => ({ name: pkg.name, version: pkg.version })), + edges: model.edges, + unresolvedDynamicImports: model.dynamicImports, + coveragePackages: model.coveragePackages, + installedConsumerPackages: model.policy.installedConsumerPackages, + inputs, + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + let base + let report = false + const args = process.argv.slice(2) + for (let index = 0; index < args.length; index++) { + if (args[index] === '--report') { + report = true + } + else if (args[index] === '--base') { + base = args[++index] + assert(base && !base.startsWith('--'), 'Missing --base commit/ref') + } + else { + assert.fail('Use --report and/or --base ') + } + } + const result = createImpactReport(root, { base, eventName: process.env.GITHUB_EVENT_NAME, eventPath: process.env.GITHUB_EVENT_PATH }) + if (report) { + const directory = path.join(root, 'refactor/.cache/ci') + fs.mkdirSync(directory, { recursive: true }) + fs.writeFileSync(path.join(directory, 'impact.json'), `${JSON.stringify(result, null, 2)}\n`) + } + console.log(`Impact: ${result.files.length} changed paths, ${result.affectedPackages.length}/${result.packages.length} packages; ${result.range.origin}; required jobs ${result.requiredCIJobs.join(', ')}`) + if (result.reviewRequired) + console.warn(`Impact review: ${result.unknownPaths.length} unclassified paths and ${result.unresolvedDynamicImports.length} dynamic-import files conservatively require the full ecosystem`) + console.log(`Installed-consumer gaps among affected packages: ${result.installedConsumerGaps.length}; impact mapping is not release acceptance`) +} diff --git a/refactor/scripts/impact.test.mjs b/refactor/scripts/impact.test.mjs new file mode 100644 index 000000000..28b4aee3a --- /dev/null +++ b/refactor/scripts/impact.test.mjs @@ -0,0 +1,184 @@ +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 -- Dependency and real Git change-selection contracts use the baseline runner. +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import YAML from 'yaml' +import { analyzeImpact, readImpactModel, validateImpactWorkflow } from './impact-model.mjs' +import { changedFiles } from './impact.mjs' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const actual = readImpactModel(root) +const cache = path.join(root, 'refactor/.cache') + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(cache, 'impact-test-')) + t.after(() => { + const relative = path.relative(cache, directory) + assert(relative.startsWith('impact-test-') && !relative.includes(path.sep)) + fs.rmSync(directory, { recursive: true, force: true }) + }) + const write = (file, value) => { + fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }) + fs.writeFileSync(path.join(directory, file), typeof value === 'string' ? value : JSON.stringify(value)) + } + const names = ['artplayer', 'artplayer-plugin-a', 'artplayer-plugin-b', 'artplayer-vitepress'] + const policy = { ...structuredClone(actual.policy), packages: names.map(name => ({ name })), coreValidationConsumers: names.slice(1), edges: [], examples: [], installedConsumerPackages: ['artplayer'] } + write('refactor/impact-policy.json', policy) + write('refactor/compatibility.md', 'Explicit core validation contract') + write('scripts/build-ts.js', '// Shared docs declaration consumer') + write('scripts/package-consumer.mjs', 'export const names = ["artplayer"]') + write('scripts/coverage-policy.json', { packages: ['artplayer'] }) + write('package.json', { scripts: Object.fromEntries(policy.gates.map(gate => [gate.command.slice(5), 'echo fixture'])) }) + for (const name of names) { + write(`packages/${name}/package.json`, { name, version: '1.0.0' }) + write(`packages/${name}/src/index.ts`, 'export default function example() {}') + } + const git = args => execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_AUTHOR_NAME: 'Impact Fixture', GIT_AUTHOR_EMAIL: 'impact@example.test', GIT_COMMITTER_NAME: 'Impact Fixture', GIT_COMMITTER_EMAIL: 'impact@example.test', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null' } }).trim() + const commit = (message) => { + git(['add', '.']) + git(['-c', 'core.hooksPath=', '-c', 'commit.gpgSign=false', 'commit', '-m', message]) + return git(['rev-parse', 'HEAD']) + } + return { directory, names, policy, write, git, commit, model: () => readImpactModel(directory) } +} + +test('Actual impact graph covers core ecosystem and real plugin/DOM/example consumers without inventing installed coverage', () => { + assert.equal(actual.packages.length, 22) + const core = analyzeImpact(actual, ['packages/artplayer/src/index.ts']) + assert.equal(core.affectedPackages.length, 22) + assert.equal(core.installedConsumerGaps.length, 19) + const ads = analyzeImpact(actual, ['packages/artplayer-plugin-ads/src/session.ts']) + assert.deepEqual(ads.affectedPackages, ['artplayer-plugin-ads', 'artplayer-vitepress']) + const danmuku = analyzeImpact(actual, ['packages/artplayer-plugin-danmuku/src/danmuku.js']) + assert(danmuku.affectedPackages.includes('artplayer-plugin-danmuku-mask')) + assert(danmuku.reasons.some(reason => reason.rule === 'explicit-integration-contract')) + for (const file of ['docs/compiled/artplayer-plugin-ads.legacy.js', 'docs/uncompiled/artplayer-plugin-ads/index.js', 'docs/assets/example/ads.js']) + assert.deepEqual(analyzeImpact(actual, [file]).affectedPackages, ads.affectedPackages) + validateImpactWorkflow(fs.readFileSync(path.join(root, '.github/workflows/nodejs.yml'), 'utf8'), actual) +}) + +test('Static imports and manifest aliases propagate transitively; contradictory ranges and unknown packages fail', (t) => { + const repo = fixture(t) + repo.write('packages/artplayer-plugin-a/src/index.ts', 'export { default } from "../../artplayer-plugin-b/src/index"') + repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0', dependencies: { alias: 'npm:artplayer@^1.0.0' } }) + const model = repo.model() + assert(model.edges.some(edge => edge.consumer === 'artplayer-plugin-b' && edge.dependency === 'artplayer' && edge.kind === 'manifest-dependencies')) + assert.deepEqual(analyzeImpact(model, ['packages/artplayer-plugin-b/src/index.ts']).affectedPackages, ['artplayer-plugin-a', 'artplayer-plugin-b', 'artplayer-vitepress']) + repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0', peerDependencies: { artplayer: '^2.0.0' } }) + assert.throws(repo.model, /range mismatch/) + repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0' }) + repo.write('packages/artplayer-plugin-a/src/index.ts', 'import unknown from "artplayer-plugin-missing"') + assert.throws(repo.model, /Unknown workspace import/) + repo.write('packages/artplayer-plugin-new/package.json', { name: 'artplayer-plugin-new', version: '1.0.0' }) + assert.throws(repo.model, /inventory changed/) +}) + +test('Policy cannot omit core consumers or claim stale installed-package coverage', (t) => { + const missingGate = fixture(t) + missingGate.policy.gates = [] + missingGate.write('refactor/impact-policy.json', missingGate.policy) + assert.throws(missingGate.model, /Missing mandatory ecosystem gate/) + const repo = fixture(t) + repo.policy.coreValidationConsumers.pop() + repo.write('refactor/impact-policy.json', repo.policy) + assert.throws(repo.model, /every ecosystem validation consumer/) + repo.policy.coreValidationConsumers = repo.names.slice(1) + repo.write('refactor/impact-policy.json', repo.policy) + repo.write('scripts/package-consumer.mjs', 'export const names = ["artplayer", "artplayer-plugin-a"]') + assert.throws(repo.model, /consumer coverage changed/) + repo.write('scripts/package-consumer.mjs', 'export const names = computeNames()') + assert.throws(repo.model, /scope extraction/) +}) + +test('Shared/unknown files and computed imports conservatively expand; unsafe or unmapped package paths are rejected', (t) => { + for (const file of ['yarn.lock', 'scripts/new-build-stage.mjs', 'types/new-boundary.d.ts']) + assert.equal(analyzeImpact(actual, [file]).affectedPackages.length, 22) + const unknown = analyzeImpact(actual, ['new-shared-config.toml']) + assert.equal(unknown.affectedPackages.length, 22) + assert.equal(unknown.reviewRequired, true) + for (const file of ['../outside', '/absolute', 'C:/absolute', 'packages\\artplayer\\src\\index.ts']) + assert.throws(() => analyzeImpact(actual, [file]), /path|Path/) + assert.throws(() => analyzeImpact(actual, ['packages/unmapped/src/index.js']), /not mapped/) + const repo = fixture(t) + repo.write('packages/artplayer-plugin-a/src/index.ts', 'export const load = (name: string) => import(name)') + const model = repo.model() + assert.deepEqual(model.dynamicImports, ['packages/artplayer-plugin-a/src/index.ts']) + assert.equal(analyzeImpact(model, ['packages/artplayer-plugin-b/src/index.ts']).affectedPackages.length, 4) + repo.write('packages/artplayer-plugin-a/src/index.ts', 'export { from') + assert.throws(repo.model, /malformed dependency source/) +}) + +test('CI workflow cannot silently skip or soften required impact gates', () => { + const source = fs.readFileSync(path.join(root, '.github/workflows/nodejs.yml'), 'utf8') + for (const edit of [ + (workflow) => { workflow.on.pull_request = { paths: ['packages/artplayer/**'] } }, + (workflow) => { workflow.jobs.coverage.if = 'false' }, + (workflow) => { workflow.jobs.coverage.if = false }, + (workflow) => { workflow.jobs.coverage['continue-on-error'] = true }, + (workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = 'false' }, + (workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'echo yarn ci:check' }, + (workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'if false; then\nyarn ci:check\nfi' }, + (workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'yarn ci:check || true' }, + (workflow) => { workflow.jobs.checks.defaults.run.shell = 'sh' }, + (workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = false }, + (workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).if = false }, + (workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).with['fetch-depth'] = 1 }, + ]) { + const workflow = YAML.parse(source) + edit(workflow) + assert.throws(() => validateImpactWorkflow(YAML.stringify(workflow), actual), /Path filters|Required|full history/) + } +}) + +test('Real Git selection includes both sides of renames, staged/unstaged/untracked paths and deletions', (t) => { + const repo = fixture(t) + repo.git(['init', '-b', 'main']) + repo.write('packages/artplayer-plugin-a/src/old.ts', 'old content') + repo.write('packages/artplayer-plugin-a/src/deleted.ts', 'delete later') + const base = repo.commit('base') + repo.git(['mv', 'packages/artplayer-plugin-a/src/old.ts', 'packages/artplayer-plugin-b/src/renamed.ts']) + repo.commit('rename across packages') + repo.write('packages/artplayer-plugin-a/src/staged.ts', 'staged') + repo.git(['add', 'packages/artplayer-plugin-a/src/staged.ts']) + repo.write('packages/artplayer-plugin-a/src/space name.ts', 'untracked') + repo.write('packages/artplayer-plugin-b/src/index.ts', 'unstaged edit') + fs.unlinkSync(path.join(repo.directory, 'packages/artplayer-plugin-a/src/deleted.ts')) + const range = changedFiles(repo.directory, { base }) + assert.equal(range.origin, 'explicit-base') + assert.equal(range.mergeBase, base) + assert.deepEqual(range.files, [ + 'packages/artplayer-plugin-a/src/deleted.ts', + 'packages/artplayer-plugin-a/src/old.ts', + 'packages/artplayer-plugin-a/src/space name.ts', + 'packages/artplayer-plugin-a/src/staged.ts', + 'packages/artplayer-plugin-b/src/index.ts', + 'packages/artplayer-plugin-b/src/renamed.ts', + ]) + assert.equal(range.worktreeChanged, true) + assert(!changedFiles(repo.directory).files.includes('packages/artplayer-plugin-a/src/old.ts')) + assert.throws(() => changedFiles(repo.directory, { base: 'missing-ref' })) +}) + +test('GitHub event base is read as data; absent, zero and unavailable event bases require full repository checks', (t) => { + const repo = fixture(t) + repo.git(['init', '-b', 'main']) + const base = repo.commit('base') + repo.write('packages/artplayer-plugin-a/src/index.ts', 'changed source') + repo.commit('change') + const eventPath = path.join(repo.directory, '.git/event.json') + const event = data => fs.writeFileSync(eventPath, JSON.stringify(data)) + event({ pull_request: { base: { sha: base } } }) + assert.deepEqual(changedFiles(repo.directory, { eventName: 'pull_request', eventPath }).files, ['packages/artplayer-plugin-a/src/index.ts']) + for (const before of ['0'.repeat(40), 'a'.repeat(40), undefined]) { + event({ before }) + const range = changedFiles(repo.directory, { eventName: 'push', eventPath }) + assert(range.origin.startsWith('full-repository-')) + assert(range.files.includes('packages/artplayer-plugin-b/src/index.ts')) + } + event({ before: '--not-a-sha' }) + assert.throws(() => changedFiles(repo.directory, { eventName: 'push', eventPath }), /Invalid GitHub event base/) +}) diff --git a/refactor/tasks.json b/refactor/tasks.json index 2af3dac18..f864958a3 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -633,14 +633,18 @@ "risk": "M", "id": "ENG-IMPACT-01", "title": "建立全包依赖与共享变更影响映射", - "status": "todo", + "status": "done", "dependsOn": [ "ENG-07", "DOC-04" ], "deliverable": "核心/构建/类型/锁文件到受影响包和必需生态检查的映射及CI接入", "acceptance": "核心变化触发必需生态检查;新增包、未知共享文件或失配依赖不能静默漏检;解释受影响路径和测试命令", - "evidence": [] + "evidence": [ + "impact-analysis.md", + "changes/2026-09-12-ENG-IMPACT-01-mapping.md", + "baselines/impact-validation.json" + ] }, { "phase": "2 工程保障", diff --git a/yarn.lock b/yarn.lock index 2a997d8d2..675485a4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7126,7 +7126,7 @@ seedrandom@^3.0.5: resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^7.0.0, semver@^7.1.1, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3: +semver@7.7.4, semver@^7.0.0, semver@^7.1.1, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3: version "7.7.4" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -8153,7 +8153,7 @@ yaml-eslint-parser@^2.0.0: eslint-visitor-keys "^5.0.0" yaml "^2.0.0" -yaml@^2.0.0, yaml@^2.6.0, yaml@^2.8.2: +yaml@2.8.2, yaml@^2.0.0, yaml@^2.6.0, yaml@^2.8.2: version "2.8.2" resolved "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz" integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==