@ccoalm/ccl-skills 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/figure-and-table-craft.md +41 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/doc-lint-repo.py +203 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/figure-lint.py +111 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_doc_lint_repo.py +420 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_figure_and_doc_lint.sh +9 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/foreign-ns-title.svg +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/self-contained-ok.svg +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-css-import.svg +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-css-url.svg +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-event-handler.svg +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-external-ref.svg +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-noxmlns.svg +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-script.svg +11 -0
- package/dist/assets/release.json +57 -7
- package/package.json +1 -1
|
@@ -340,3 +340,6 @@ and inverted the sense (production, not product), and the coordinator now shares
|
|
|
340
340
|
| 图表判据里唯一有生理机制的一支是色觉障碍:判据不是「避开某些颜色」而是「别让语义只落在同一条混淆线上的色对」,且实算优于模式匹配 | `tighten-doc` | `tighten-doc` 补此前完全缺失的色觉与主题面:`tighten-doc/references/figure-and-table-craft.md` §4c 记混淆线机制与三条边界、§4d 记图值不值得画与退化形态;`tighten-doc/scripts/figure-lint.py` 新增 CVD-DISTANCE / THEME-CONTRAST / THEME-PURE-BLACK / FIGURE-IS-A-LIST / FLOW-DIRECTION-MIXED / VALUE-SHOULD-BE-TOKEN / FIGURE-A11Y-STRUCTURE 七条谓词,契约新增 themes 维度,并按独立评审的七条发现修正后落地; result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/tighten-doc/scripts/test_figure_and_doc_lint.sh | `updated` | owner key `tighten-doc/SKILL.md`(本轮未改其正文,改的是 reference 与 script)。**观察到的失败**:上一轮只读外部仓 README 表格即下结论,正文里有四类 README 看不见的判据——digest-masks-corpus 实例。**实算 vs 模式匹配的差分是实测的**:按外部技能给的「红橙/蓝橙/低饱和」色对模式,在本仓真实 token 集上只命中 warn/critical 一对;改为 Viénot 1999 投影实算后,primary/muted(protan 距 14)与 primary/ok(tritan 距 19)两对也出——模式匹配漏三分之二。参照点:Okabe-Ito 八色最小距离 38、随机八色 10、本仓原 token 集 14。**主题维度**实测:`#111827` 压纯黑仅 1.18:1,说明写死十六进制的 token 表在深色主题下整体失效。**RED-baseline**:`mutation_probe.sh` 37/37,新谓词均由探针的 DERIVATION-GAP 自动纳入并验出覆盖;fixture 全数改用 Okabe-Ito 配色——原配色在 tritan 下仅 19,会让每个 fixture 连带触发 CVD-DISTANCE、谓词隔离不开。**过程瑕疵如实记**:charter 写于前三个源已读之后;对照清单时我对「深色模式」打过一次假勾(实际只落了 CVD、契约主题维度未做),自查时撤回补齐。**独立评审七条全部成立、全部已修**(5×P1):①`themes.*.surface` 只判真值 → `"black"` 过校验后被静默跳过=声明了主题却一次没测;②主题对比度拿全局底色判压在局部卡片上的文字 → 误报,改为只判压在整幅底板上的文字(覆盖≥95% 画布算页面底色);③CVD 比的是全部渲染色(含装饰、边框)并拿 25 当触发条件,而同文件写着「只报数不设阈」——**声明与实现相反**,改为只测契约 `semantic_colors` 显式声明的语义色且无条件报出距离与参照点;④`VALUE-SHOULD-BE-TOKEN` 规则写 >2 次即报、实现要求≥3 个颜色 → 单色重复永不报,已对齐并补一色/多色两个用例;⑤流向判据在几何不可信时仍跑、且忽略 marker-start 与双向边 → 加前提并按 marker 语义定向;⑥主题与 token 两组独立断言用 `\|\| true` 吞掉退出码 → 补退出码断言;⑦发布档要求候选绑定的机器可验证证据 → 产出 `eval/evidence/figure-lint-predicates-2026-08-25/REPLAY.md`(**tracked,在冻结包内**): 列出三条命令与期望退出码/关键行,前两条完全由包内文件决定、评审方可自行复算;第三条依赖维护者私有 alias, 如实标为**不可从包内独立复核**而非当成已验证。此前那版证据放在 gitignore 的 `.work/` 下、根本不在包里, 且第一版把套件退出码取成空(PIPESTATUS 落在子 shell)、又把 gates 写成跑在提交后的 tree 上——正是本条所指的缺陷形态本身。**第二轮 dual-track(review+challenge 同绑候选 1f1e5f06)再报 9 条,两条 lane 独立收敛到同一批**,全部已修: ⑧`CVD-DISTANCE` 走 `WARN` 档 → 退出码非零,「只报数不设阈」这句声明被退出码当场推翻;新增 `INFO` 档,不影响退出码; ⑨`semantic_colors` 未受契约校验 → 数组/字符串在 `.values()` 处直接崩,非法颜色被静默丢成空集,「没报 CVD」分不清是「距离没问题」还是「压根没测」;现校验类型、`#RRGGBB` 语法与 `color_tokens` 子集三项; ⑩`fill="none"` 的描边框被当成卡片底板 → 框住低对比文字的边框把它豁免掉;卡片须为实际填充色; ⑪底色解析不可信时主题检查仍执行 → 圆形/路径/渐变卡片不进 `rects`,「查不到卡片」被当成「文字压在主题底上」,判出假红;改为整条跳过并显式报新谓词 `THEME-UNASSESSED`(未判定必须说成未判定,不能沉默成绿); ⑫两端都没箭头的边按 `d` 的书写顺序被计入流向 → 三条视觉上无向的线仅因端点顺序不同即可触发混合流向;无向边跳过。 **修 ⑧ 时当场炸出探针自身的第二个分母缺陷**:`mutation_probe.sh` 的派生正则只枚举 `ERROR\|WARN` 档, 把一条谓词改成 `INFO` 就让它**静默退出分母**——总数看着没变、覆盖少了一条。 档位改为 `[A-Z]+` 不枚举后为 37/37。**覆盖率的分母若由被测代码的某个属性决定,改那个属性就能无声地让分数变好看**。 **修正后复测**:套件全绿、突变探针 differential_sensitivity=37/37 且 DERIVATION-GAP 无缺口、`make test-repo-gates`=0(含 alias_audit_ok / r0_status=private-ok)。**真实语料复测 55 张 SVG**(非本仓,无契约):`FIGURE-IS-A-LIST` 17、`VALUE-SHOULD-BE-TOKEN` 34、`CVD-*` 0(无契约声明语义色=不测,不是「测过没问题」)、`FLOW-DIRECTION-MIXED` 0——较修正前的 5 条下降,因为 55 张里 45 张含 gradient/非矩形底,几何不可信,那 5 条原本就是拿不可信坐标判出来的。 |
|
|
341
341
|
| 自检触发点漏掉了 gap-list 形态:「列举外部源有而我们没有的」就是 findings 回合,但它读起来像在答覆盖问题,charter-before-findings 从不觉得被触发 | `skill-extraction-workflow` | 自检触发点扩到 gap-list 形态并写明它为何最易滑过;自审纪律补「验证锚点本身可能是错的」——锚点的预期方向若来自你对一手源的解读,该解读是 hypothesis-grade,锚点不过应先质疑锚点而非判实现有 bug; result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: file:skills/skill-extraction-workflow/SKILL.md#enumerates what an external source has that we lack | `updated` | owner key `skill-extraction-workflow/SKILL.md`。两条均为 **merge 非 append**(并进既有的自检触发点与自审纪律)。**观察到的失败**:①本轮产出四条「外部有我们没有」的缺口清单在先、invoke 提炼工作流在后,而规则明写 charter 要在第一个 findings 回合之前——规则在、没触发,故补触发点而非补规则。②用「红色模拟后应变暗」验 CVD 实现,实测亮度上升;追一手后确认是模拟把红投影到 575nm 黄色不变轴、亮度上升为正确行为,而「红色看起来暗」说的是红与黑难分、是另一个量。**危险在于其余两个锚点通过**——错的尺子量对的实现,比实现出错更难发现。**RED-baseline 差分**:改前的触发点只列举「沉淀 / 提炼 / 复盘 / distil」四种自述措辞,本轮实际产出的是「缺口清单」措辞,逐字不匹配任何一项,故规则在而未触发(本轮即为该差分的观测实例);改后触发点显式含 gap-list 形态。可核对方式:在改前文本上检索本轮的触发措辞得零命中,改后命中。 |
|
|
342
342
|
| 确定性闸对历史形态有前提,而**递给它哪个 ref 是 harness 的选择**:闸按分支自己的一级父链切轮,CI 默认检出的 `refs/pull/N/merge` 的一级父是**目标分支**,于是分支上所有轮塌成一轮,凡「验证依赖本轮很窄」的台账行都会在自己一个字没改的情况下翻红——闸没坏,喂它的历史形态不对 | `skill-extraction-workflow` | 五个跑闸 lane 的 job 全部改为检出分支 head(`ref: pull_request.head.sha \|\| github.sha`,非 PR 事件回落 `github.sha`);新增 `test_ci_checkout_ref_binding.sh` 逐 job 断言该绑定并注册进 fast lane,绑定丢失即闭式失败; result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh | `updated` | owner key `skill-extraction-workflow/SKILL.md`(本轮未改其正文,改的是 scripts 与 `.github/workflows/ci.yml`)。**观察到的失败是 CI 实测**:PR #53(dev→main,053–056 四轮 + npm 0.5.0)`repository-gates` 报 `impact_chain_firing_path_missing`(incomplete 点名的是文档技能那个 owner 的入口),`regression-heavy` 两个套件连带红;同一棵树在线性 dev 上闸全绿。055 那轮只改 tighten-doc 的 description、用 `#description` 锚点,该锚点合法的前提是「该 owner 本轮全部 diff 就是 description」;四轮塌成一轮后 tighten-doc 在同一轮里还改了 `references/` 与 `scripts/`,前提失效。**先试过改闸、证否了**:把 round 派生改成「一级父已被 base 包含时走第二个父」,在 merge-ref 树上确实转绿,但打破了 `test_check_ccl_impact_chain_refscripts.sh` 的 round scoping 8——「台账行写在工作提交之前的 worktree 轮必须塌成一个边界」。两种形态在拓扑上**完全相同**(都是 merge X into Y 且 Y==base),git 里没有可区分它们的不变量,而该 fixture 明写 `--first-parent` 是承重的。按仓里对 form 3 的既定裁决(同类复现以删除收束、不再补代理),改的不是闸而是喂给它的 ref。**RED-baseline 是双向差分**:新测试在修好的 workflow 上绿,把 `.github/workflows/ci.yml` 换回 `origin/dev` 版即报三个 job 未绑定。该测试第一次跑就抓到我只改了三个 job、漏了两条 regression lane——**这正是它存在的理由**。**本轮暴露的验证面缺陷**:053–056 四轮我只跑 `make test-repo-gates`、基线取 `origin/dev`;而 CI 的基线是 `origin/main`、检出是 merge ref、还有 fast/heavy 两条 lane——三处都不同,四轮的绿从未覆盖这个形态 |
|
|
343
|
+
| 能力落在技能包里、只在被点名时才跑,就只覆盖被想起来的那些输入;本仓自己的文档语料从来没有被这套检查器扫过一遍——**判据存在不等于判据生效** | `tighten-doc` | `scripts/check-doc-structure.py` 枚举全部 tracked Markdown 交给 `doc-lint` 判,接进 `make test-repo-gates`;只有 ERROR 一档阻断,WARN 只打印计数; result-class: stable-success; behavioral-evidence: RED-baseline; observed-failure: no; firing-path: file:skills/tighten-doc/references/figure-and-table-craft.md#DOC-STRUCTURE-GATE-TIER | `updated` | owner key `tighten-doc/SKILL.md`(本轮未改其正文,改的是 reference 与被闸调用的 script)。**先量后接,不是先接后量**:481 篇 tracked 非 fixture 文档实测 0 ERROR / 75 WARN,0.15s;fixture 目录(13 篇)按构造带 2 个 ERROR,故必须排除,否则这道闸对「证明检查器有效」的输入永久红。**分档依据是本包自己的 §9b**——判不开缺陷与判断题的代理不得设闸:WARN 那批是 `[工]` 代理,设成阻断等于落地当天就用一堆判断题把仓库判红;ERROR 那半是客观的(表格无真表头、图引用悬空、文件读不出)。**RED-baseline 是突变差分 15/15**(含 dual-track 后新增的六个维度):排除放宽成子串 / 放宽成全排 / 完全失效、有 ERROR 仍返回 0、去掉 linter 退出码契约检查、去掉空作用域守卫、去掉合计行解析守卫、ok 行不报数量、缺 linter 也放行,十五种突变逐一施加后套件均转红,施加后一律还原并复验 baseline 为绿。**dual-track 两条 lane 独立收敛到同一条 P1**:只校验 linter 退出码属于 0/1/2、却不与它自己的汇总数交叉核对——退出 1(契约含义「有 ERROR」)却打印「0 ERROR」时,包装器会打印 ok 并返回 0;崩溃与不可解析两条腿都够不到它,因为这份输出解析得很好、只是在说谎。已加双向交叉核对与四个 stub 组合(含一个自洽 stub 作控制)。review lane 另报大小写扩展名漏扫:`git ls-files '*.md'` 在大小写敏感文件系统上会漏掉 tracked 的 `NOTES.MD`,对一个以覆盖率为全部价值的闸而言是致命的;改为枚举全部再按 casefold 后缀过滤。**三条本来会静默通过的假断言**,形状相同——断言写在输出里恒存在的东西上,而不是写在会随缺陷变化的量上:①「排除不会误吞真文档」那条腿把缺陷放在了不含 `tests/` 的路径上,放宽成子串匹配后掉的是一篇干净文档、判定不变、腿照样绿——注释里声称双向、断言实际单向。缺陷必须落在被放宽的排除会吞掉的那条路径上,已改并复验;②WARN 那条腿断言的是成功行里恒存在的 `WARN` 与 `non-blocking` 字样,加上计数断言后当场暴露那个 fixture **一条 WARN 都不产生**、此前什么也没测(challenge lane 发现,已换成真会触发 TABLE-NO-UNIT 的 fixture);③非 git 目录那条腿只断言通用 token,分不开「git 失败」与「空作用域守卫接住了空清单」,已点名 `git ls-files exited`。**候选绑定证据**:`eval/evidence/doc-structure-gate-2026-08-26/REPLAY.md`(tracked,在冻结包内),列出三条命令与期望、落地前的语料实测表、十五条突变清单;前两条完全由包内文件决定、可自行复算,依赖私有 alias 的第三条如实标为不可从包内独立复核。**为什么记 stable-success 而不是 failure**:本轮不是修缺陷,是把 053–057 已建成的能力接到一个每次落地都会跑的面上;机制是全量枚举 + 分档阻断,非侥幸证据是上述实测与突变差分,复用条件是「代理不设闸」这条判据,firing point 在 `test-repo-gates`。**仍未闭合的那件事**:`figure-lint` 不接闸——它至今只在同一作者产出的图上量过;而这整套判据到今天为止,仍然没有被一个不带本会话上下文的 agent 用过 |
|
|
344
|
+
| 能力建好了不等于交付得出去:判据、检查器、闸都齐了,而枚举器放在了**不进发布闭包的目录**里,于是发出去的只有描述它的十七行说明、闸本身一行都没发——**放错位置的能力,在消费者那里等于不存在,且比不存在更糟:说明还在指着它** | `tighten-doc` | 枚举器与其测试从仓根 `scripts/` 移进技能包(`skills/tighten-doc/scripts/doc-lint-repo.py`、`test_doc_lint_repo.py`,与它调用的 `doc-lint.py` 同级、随包发布);同时去掉两处「假定被扫仓库里有这个技能」的写死:linter 改取自己的同级文件、fixture 排除改由 linter 位置派生且仅在它确实位于被扫仓库内时生效; result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/tighten-doc/scripts/test_doc_lint_repo.py | `updated` | owner key `tighten-doc/SKILL.md`(本轮未改其正文,改的是 reference 与随包发布的 scripts)。**观察到的失败是用户指出的**:0.5.0 发版后被问「skill 正好有修改,不需要发布?」,核出 tag 之后进发布物的改动**只有 18 行**(判据文档 17 + 台账 1),而 058 真正建的东西——180 行枚举器 + 338 行测试——在仓根 `scripts/`,打包 `roots` 不含它(只含 `scripts/owner-dispatch` 一个子目录)。`doc-lint.py` / `figure-lint.py` 本来就在技能包内、一直在发;枚举器是**位置放错**,不是能力不该发。**两处写死是同一个不变量的两半**:脚本不再假定被扫仓库里有这个技能。写死路径在消费仓里是反向错误——会去吞掉人家碰巧同名的真文档;派生的前缀在本仓命中、在消费仓解析为空,两边都对。**RED-baseline 是突变差分 17/17**:058 的十五条全部保留并复验,另加「排除写死成本仓路径」与「linter 改回在被扫仓库里查找」两条新维度;新增测试腿 `test_consuming_repo_excludes_nothing_and_still_works` 在被扫仓库里放一篇路径为 `skills/tighten-doc/scripts/tests/their-own-doc.md` 的**真文档**——写死排除会吞掉它使判定翻绿,派生排除不吞、判定保持 rc=1。**外部仓实测**:三个 `git init` 现造、完全不含本技能的仓库上,干净仓 rc=0(含一篇 `.MD` 也计入)、含 ERROR rc=1、缺陷落在大写扩展名文件上 rc=1,三种都对。**058 的台账行与 REPLAY 按 append-only 不改写**,只在后者顶部加一条迁移说明指向本轮。候选绑定证据:`eval/evidence/doc-lint-repo-ship-2026-08-26/REPLAY.md`(tracked,在冻结包内)。**该记的教训不是「记得改路径」**:058 那一轮我完整跑了闸、突变、dual-track、两条 lane,没有任何一道检查会问「这个能力发得出去吗」——**验证面覆盖了正确性,没覆盖可交付性**。**而这条教训在本轮身上又复现了一次,由 dual-track 两条 lane 独立指出**:我最初是手工跑构建、把「dist 里有这两个文件」贴进证据文件的——**不可重跑**,打包明天再漏装,全部测试与闸照样绿。已补 `packages/ccl-skills-npm/test/shipped-doc-lint-repo.test.mjs`:跑在 `npm test` 里(该命令第一步即 `npm run build`,`dist/` 已就位),由 CI 的 `npm-packages` job 消费;五条断言=两个必需文件在 dist 中 + **用 dist 里的那份脚本**跑干净仓 / 含 ERROR 仓 / 消费者自己的 `tests/` 目录不得被吞 / 大写扩展名。**反向差分**:删掉 `dist/.../doc-lint-repo.py` 后 5 条全部转红,还原后回绿。另修 review lane 的 P2:§10 里写的 `../scripts/doc-lint-repo.py` 是相对该 reference 文件的,读者在仓根照抄会指向不存在的路径,已改成带 `<技能安装路径>` 的可直接复制形态 |
|
|
345
|
+
| 提炼完成度的自查不能用一次 grep 定论:按关键词查「这条落了没」,会把「按内容写、没写编号」的已落条目判成缺口,也会把「解法用另一套词写着」的判成答案缺失——**空的搜索结果是搜索方式的性质,不是仓库的性质** | `tighten-doc` | 补上外部技能提炼里唯一真正漏掉的一条:内联 SVG 的自包含约束落成 `SVG-NOT-SELF-CONTAINED`(`<script>`/`<foreignObject>` 与 `image`/`use`/`feImage`/`pattern` 的外部资源引用判 ERROR);§5 补 `currentColor` 作为写死十六进制的 SVG 原生解;§4b 对 §1 的概括收紧; result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/tighten-doc/scripts/test_figure_and_doc_lint.sh | `updated` | owner key `tighten-doc/SKILL.md`(本轮未改其正文,改的是 reference、script 与 fixture)。**观察到的失败是我自己在自查里判错**:用户问「研究的那些一手源和三方技能都落了么」,我逐条 grep 后报了三处缺口,复核下来**只有一处成立**——①「WCAG 1.4.8 行宽没落」是错的:§1 的 `[禁]` 档已完整记着「CJK 40 是拉丁 80 折半推导、原文要求的是提供机制让用户改」,我 grep `1.4.8` 没命中只是因为文里按内容写、没写 SC 编号,**我据此还断言仓库有一句错话——那句话不错,错的是我的检查方法**;②「`currentColor` 的解法缺失」被缩小:`THEME-CONTRAST` 的诊断文案已写着「应按主题 swap token 值」,缺的只是 SVG 原生那条机制(手写内联 SVG 里比建 token 表更省事),是补充不是缺失;③自包含约束确认真缺——散文与谓词都没有,且 `<script>` 在内联 SVG 里是**注入面**不只是整洁问题。**边界是刻意划的**:邻近的 artifact-diagramming 还禁 `<style>`,本轮**不照抄**——那是它那条 lane 的约束(页面已有 CSS 层),本仓的图正当地用内联 `<style>` 定义字号与语义色;`<a>` 同样不禁,超链接不在渲染时拉取任何东西。按仓规外部包借理论不借实现,**照抄邻居的约束会连它的前提一起引进来**。**RED-baseline**:`mutation_probe.sh` 38/38,新谓词由探针 DERIVATION-GAP 自动纳入;两条 fixture(`<script>` 与外部 `image` 引用)各自孤立触发,控制组仍 0 ERROR(证明没误伤内联 `<style>`)。**跨作者语料实测 55 张 SVG:0 命中**——诚实读法是「没测到误报,也没测到真阳性,这批语料不触发这个类」,不是「谓词有效」。**dual-track 两条 lane 各指出一条真实漏报面,都已修**:①review:只匹配带命名空间的 QName,则无默认 `xmlns` 的内联 SVG(在 HTML 里完全合法——HTML 解析器补命名空间、ElementTree 不补)持续假绿;**顺着查发现这是全文件的假设**,`C4-TITLE` 与 `GROUPING` 反方向误报「缺标题」「零分组」,已把全部 9 处标签比较改走 `lname()`/`iter_local()`;②challenge:外部资源不只从 `href` 进来——`fill="url(https://…)"`、`filter=`、`<style>` 里的 `@font-face src` 同样在渲染时拉取,已改为扫描所有属性值与 `<style>` 文本的 `url(...)`,只放行 `url(#id)` 与 `data:`;③challenge P2 要求的**反方向断言**已补:`self-contained-ok.svg` 把内联 `<style>` + `<a>` + `use` fragment + `data:` 全放一张图里断言 0 ERROR——没有它,未来收紧 href 分类会在全部测试仍绿的情况下打坏合法输入。五条 fixture 全部进逐谓词差分表,突变 38/38。**local-name 修正在语料上零回归**:`C4-TITLE` 55→55、`GROUPING` 20→20(那 55 张都带 xmlns)。候选绑定证据 `eval/evidence/svg-self-contained-2026-08-26/REPLAY.md`(tracked,在冻结包内)。**第二轮 dual-track 又报四条,其中两条是我上一轮修正的过度修正**,均已修:①`lname()` 无条件丢命名空间(两条 lane 从相反方向指出)——`<dc:title>` 冒充图标题使假绿、`<ext:script>` 被误判成脚本错误阻断;**支持无 xmlns 不要求接受任意外来命名空间**,现只接受 SVG 命名空间或无命名空间;②整图标题被我从「直接子元素」放宽成任意层级,嵌套在 `<g>` 里本是子元素提示的 `<title>` 也能满足 C4,已改回;③`@import "https://…"` 是合法 CSS 且不走 `url()` 语法,只认 `url()` 即绕过口,已单列正则;④事件处理器与 `javascript:` URL——谓词声称管注入面却只查 `<script>`,**又一次声明与实现相反**,已覆盖任意 `on*` 属性与 `javascript:` URL。附带修了 `parse_css` 不认 at-rule(`@import`/`@media` 会污染紧随其后的真规则,让文字退回默认样式、冒出无关契约报错)。fixture 增至八条含一条 0 ERROR 边界组;语料 55 张零回归(`C4-TITLE` 55→55、`GROUPING` 20→20、`CONTRACT-FONT-SCALE` 0→0)。**该记的教训**:完整性自查若只用关键词检索,它报的是「我用的词在不在」而不是「那件事落没落」;判缺口前必须按**语义**再查一遍并读原文 |
|
|
@@ -93,7 +93,7 @@ almost certain`)并说明什么指标会改变不确定性水平;**第 7 条
|
|
|
93
93
|
|
|
94
94
|
## §4b 图的布局:唯一有实证的那一支 `[外]`
|
|
95
95
|
|
|
96
|
-
§1
|
|
96
|
+
§1 判定「图表密度」与「一行 N 汉字」这类**具体数字**无可靠来源是对的(WCAG 1.4.8 本身是来源,但它要的是可调机制、不是固定宽度),而**图的布局属性另有一支真正做过实验的文献**,
|
|
97
97
|
本仓此前完全没有覆盖——它是少数几个既有实证依据、又能在 SVG 里精确计算的属性。
|
|
98
98
|
|
|
99
99
|
**Purchase, "Which aesthetic has the greatest effect on human understanding?"(Graph Drawing 1997)**
|
|
@@ -210,6 +210,10 @@ consistency in the terms used is critical)。**但「冻结成哪几档」是
|
|
|
210
210
|
**分组单元**(每个卡片 = 形状 + 其文字包一个组)
|
|
211
211
|
- `semantic_colors` 是 `color_tokens` 的**子集声明**:哪些 token 承载语义区分(§4c 只测这些)。
|
|
212
212
|
不声明 = 不测,不是"测出来没问题"
|
|
213
|
+
- **不承载语义的笔画与文字用 `currentColor`,别写死十六进制**。它继承宿主文档的前景色,
|
|
214
|
+
深浅两底自动跟随,连 token 表都不必建——手写内联 SVG 里这是比 swap token 更省事的解。
|
|
215
|
+
只有**承载含义**的那几个色(状态、被强调的那条边)才值得进 `semantic_colors` 写死并受 §4c 测量。
|
|
216
|
+
`THEME-CONTRAST` 报的是「哪里失效」,这条是「怎么改对」
|
|
213
217
|
- `themes.<名>.surface` 必须是 `#RRGGBB`。写成 `"black"` 或非字符串,早先只判真值会让它通过校验、
|
|
214
218
|
随后因不是 `#` 开头被静默跳过——**声明了主题却一次都没测,报出来的是绿**。现在按语法拒绝
|
|
215
219
|
- **只有直接压在主题底色上的文字才按主题判**(`THEME-CONTRAST`)。压在局部卡片上的文字,
|
|
@@ -314,5 +318,41 @@ python3 skills/tighten-doc/scripts/figure-lint.py <svg 目录或文件>
|
|
|
314
318
|
python3 skills/tighten-doc/scripts/doc-lint.py <文档.md>
|
|
315
319
|
```
|
|
316
320
|
|
|
321
|
+
**整仓文档也能一次扫完**,且**对任何仓库都能跑**——脚本取的 linter 是自己的同级文件,
|
|
322
|
+
排除项也由此派生,不假定被扫仓库里有这个技能:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
python3 <技能安装路径>/skills/tighten-doc/scripts/doc-lint-repo.py <要扫的仓库路径>
|
|
326
|
+
# 在本仓内即:
|
|
327
|
+
python3 skills/tighten-doc/scripts/doc-lint-repo.py .
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
(实测 483 篇 / 0.15s。本仓把它接在 `make test-repo-gates` 里。)三条规则:
|
|
331
|
+
|
|
332
|
+
- **DOC-STRUCTURE-GATE-TIER:只有 ERROR 一档得阻断,WARN 不得设成阻断。** 这不是保守,
|
|
333
|
+
是 §9b 那条自己的规矩——判不开缺陷与判断题的代理不得设闸。落地时实测 0 ERROR / 75 WARN,
|
|
334
|
+
把 WARN 也设成阻断等于当天就用一堆判断题把仓库判红。ERROR 是客观的那一半:
|
|
335
|
+
表格没有真表头、图引用指向不存在的图、文件读不出来。
|
|
336
|
+
- **DOC-STRUCTURE-GATE-SCOPE:检查器自己的 fixture 目录必须排除,排除只能是一条前缀,
|
|
337
|
+
且必须从 linter 自身位置派生、不得写死。** 那批文档按构造就带 2 个 ERROR,扫它等于让这道闸对
|
|
338
|
+
"证明检查器有效"的输入永久红。但写死成某个仓库里的路径就换了个方向错——消费仓里这个技能
|
|
339
|
+
装在别处,写死的前缀反而会去吞掉人家碰巧同名的真文档。派生的前缀在本仓命中、在消费仓解析为
|
|
340
|
+
空(什么都不排除),两边都对。排除写成模式同样会悄悄长大——它是整个扫描器里唯一能让它
|
|
341
|
+
一边打印通过、一边覆盖得比声称的少的部件。
|
|
342
|
+
- **DOC-STRUCTURE-GATE-BIDIRECTIONAL:排除必须双向断言,且缺陷必须落在会被吞掉的那条路径上。**
|
|
343
|
+
fixture 要被丢掉,长得像 fixture 的真文档要留下。后一条最初写错了:带缺陷的文档放在了不含
|
|
344
|
+
`tests/` 的路径上,于是把前缀放宽成子串匹配时掉的只是一篇干净文档、判定不变、那条腿照样绿,
|
|
345
|
+
而它的注释里明写着"双向"。**是突变探针发现的,不是我读出来的**——
|
|
346
|
+
单向的排除测试在排除已经长到覆盖半个仓库时,一样绿。
|
|
347
|
+
|
|
348
|
+
- **DOC-STRUCTURE-GATE-SELF-CONTAINED:内联 SVG 不得带 `<script>`、`<foreignObject>` 或外部资源引用。**
|
|
349
|
+
内联 SVG 与图片文件不同——它进入宿主文档的 DOM,所以 `<script>` 是**注入面**不是整洁问题;
|
|
350
|
+
`<foreignObject>` 把任意 HTML 带进来、逃出 SVG 语义且多数导出/打印路径不渲染;
|
|
351
|
+
`image`/`use`/`feImage`/`pattern` 指向 `http(s)` 或相对文件时,图换个位置渲染就是断链
|
|
352
|
+
(图随文档走,被引的东西不会)。用 `href="#id"` 或 `data:` 内嵌。`SVG-NOT-SELF-CONTAINED` 查这条。
|
|
353
|
+
**刻意不禁 `<style>` 与 `<a>`**:邻近技能禁 `<style>` 是它那条 lane 的约束(页面已有 CSS 层),
|
|
354
|
+
不是普遍判据,本仓的图正当地用内联 `<style>`;超链接不在渲染时拉取任何东西,与自包含无关。
|
|
355
|
+
**外部包借理论不借实现——照抄邻居的约束会引入它的前提。**
|
|
356
|
+
|
|
317
357
|
**不可机械判定的仍归人**:图种选得对不对、图注写的是不是主张、独立性验收、
|
|
318
358
|
调研族图上实体能否回指证据。检查器只挡机械项,不替代 §5 的验收。
|
package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/doc-lint-repo.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail when a tracked Markdown doc in a repository carries an ERROR-class defect.
|
|
3
|
+
|
|
4
|
+
`doc-lint.py` (this script's sibling) decides every predicate for ONE document.
|
|
5
|
+
This is the repository-wide enumerator: it lists the tracked Markdown of a repo
|
|
6
|
+
you point it at, runs the linter over all of it in one pass, and turns the ERROR
|
|
7
|
+
tier into a landing gate. Nothing here judges a document; it decides only WHICH
|
|
8
|
+
files are in scope and WHICH tier blocks.
|
|
9
|
+
|
|
10
|
+
python3 doc-lint-repo.py /path/to/your/repo
|
|
11
|
+
|
|
12
|
+
It is written to run against ANY repository, not only the one that ships it. That
|
|
13
|
+
is the point: a repo-local scanner would have made this skill's own documents the
|
|
14
|
+
only ones ever checked. Both paths it depends on are derived, not assumed — the
|
|
15
|
+
linter is the sibling file next to this script, and the fixture exclusion is that
|
|
16
|
+
linter's own `tests/` directory, resolved against whatever repo you scanned.
|
|
17
|
+
|
|
18
|
+
WHY ERROR ONLY. Measured over the shipping repository's 481 tracked non-fixture
|
|
19
|
+
Markdown files: 0 ERROR, 75 WARN. Blocking on the WARN tier would fail that repo
|
|
20
|
+
on its own docs the day the gate lands, and the right conclusion is not a waiver
|
|
21
|
+
list — the WARN predicates are labelled `[工]` engineering proxies in
|
|
22
|
+
`../references/figure-and-table-craft.md` (§9b), and that file's own rule is that
|
|
23
|
+
a proxy which cannot separate a defect from a judgement call must not gate. So
|
|
24
|
+
the WARN count is printed for visibility and does not block. The ERROR tier is
|
|
25
|
+
the objective half: an empty or absent table header, a dangling figure reference,
|
|
26
|
+
an unreadable file.
|
|
27
|
+
|
|
28
|
+
WHY A FIXTURE DIRECTORY IS EXCLUDED, and why that is not convenience. The
|
|
29
|
+
linter's own corpus under its `tests/` directory contains documents built to
|
|
30
|
+
violate each predicate; measured, it reports 2 ERROR by construction. Scanning it
|
|
31
|
+
would make this gate permanently red for the exact inputs that prove the linter
|
|
32
|
+
works. The exclusion is ONE derived prefix and applies only when that directory
|
|
33
|
+
actually sits inside the scanned repo — in a consuming repo the skill is
|
|
34
|
+
installed elsewhere, so nothing is excluded and nothing needs to be. It is
|
|
35
|
+
asserted in both directions: a real doc must not be dropped by it, and a fixture
|
|
36
|
+
must not be scanned. A wider exclusion is the failure mode to guard against — it
|
|
37
|
+
hides real documents while still printing a pass.
|
|
38
|
+
|
|
39
|
+
Scope is TRACKED files only: an untracked scratch file is not part of the
|
|
40
|
+
delivered repo, and including it would make the verdict depend on the working
|
|
41
|
+
tree rather than on what lands.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
HERE = Path(__file__).resolve().parent
|
|
53
|
+
# The linter that owns every predicate — the sibling file, NOT a path guessed
|
|
54
|
+
# inside the scanned repo. Deriving it is what lets this run against a repo that
|
|
55
|
+
# has never heard of this skill.
|
|
56
|
+
LINTER = HERE / "doc-lint.py"
|
|
57
|
+
# Documents built to violate the predicates so the linter can prove it detects
|
|
58
|
+
# them. Derived from the linter's own location, and applied only if that location
|
|
59
|
+
# is inside the repo being scanned.
|
|
60
|
+
FIXTURE_DIR = HERE / "tests"
|
|
61
|
+
|
|
62
|
+
TOTAL_RE = re.compile(r"^合计: (\d+) ERROR, (\d+) WARN", re.M)
|
|
63
|
+
ERROR_ROW_RE = re.compile(r"^ ERROR +([A-Z][A-Z0-9-]+)", re.M)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fixture_prefix(root: Path) -> str | None:
|
|
67
|
+
"""The fixture directory as a repo-relative prefix, or None if outside it."""
|
|
68
|
+
try:
|
|
69
|
+
rel = FIXTURE_DIR.resolve().relative_to(root)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return f"{rel.as_posix()}/"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tracked_markdown(root: Path) -> list[str]:
|
|
76
|
+
"""Tracked Markdown paths, fixtures removed. Fails closed on a git error.
|
|
77
|
+
|
|
78
|
+
Enumerate EVERYTHING and filter on a case-folded suffix rather than asking
|
|
79
|
+
git for `*.md`. That pathspec is case-sensitive on a case-sensitive
|
|
80
|
+
filesystem, so a tracked `NOTES.MD` is silently outside a gate that reports
|
|
81
|
+
repository-wide Markdown coverage — a hole in exactly the property this
|
|
82
|
+
script exists to provide.
|
|
83
|
+
"""
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
["git", "-C", str(root), "ls-files", "-z"],
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
)
|
|
89
|
+
if proc.returncode != 0:
|
|
90
|
+
raise RuntimeError(f"git ls-files exited {proc.returncode}: {proc.stderr.strip()}")
|
|
91
|
+
paths = [p for p in proc.stdout.split("\0") if p]
|
|
92
|
+
md = [p for p in paths if Path(p).suffix.casefold() == ".md"]
|
|
93
|
+
prefix = fixture_prefix(root)
|
|
94
|
+
if prefix is None:
|
|
95
|
+
return md
|
|
96
|
+
return [p for p in md if not p.startswith(prefix)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: list[str]) -> int:
|
|
100
|
+
root = Path(argv[0] if argv else ".").resolve()
|
|
101
|
+
linter = LINTER
|
|
102
|
+
if not linter.is_file():
|
|
103
|
+
# Fail closed: a missing linter must not read as "nothing to report".
|
|
104
|
+
print(
|
|
105
|
+
f"doc_structure_check_failed: linter not found at {linter}; "
|
|
106
|
+
"the gate cannot certify a corpus it never scanned",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
files = tracked_markdown(root)
|
|
113
|
+
except RuntimeError as exc:
|
|
114
|
+
print(f"doc_structure_check_failed: {exc}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
if not files:
|
|
118
|
+
# An empty scope is a scoping bug, not a clean corpus. A repository worth
|
|
119
|
+
# gating has tracked Markdown; zero means the enumeration broke or the
|
|
120
|
+
# path is not the repo you meant.
|
|
121
|
+
print(
|
|
122
|
+
"doc_structure_check_failed: no tracked Markdown in scope — the "
|
|
123
|
+
"enumeration is broken, not the corpus clean",
|
|
124
|
+
file=sys.stderr,
|
|
125
|
+
)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
proc = subprocess.run(
|
|
129
|
+
[sys.executable, str(linter), *files],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
cwd=str(root),
|
|
133
|
+
)
|
|
134
|
+
# doc-lint's contract: 0 clean / 2 WARN-only / 1 has ERROR. Anything else is
|
|
135
|
+
# the linter itself failing, which this gate must not read as a clean corpus.
|
|
136
|
+
if proc.returncode not in (0, 1, 2):
|
|
137
|
+
print(
|
|
138
|
+
f"doc_structure_check_failed: linter exited {proc.returncode} "
|
|
139
|
+
f"(expected 0/1/2); its output cannot be trusted as a verdict\n"
|
|
140
|
+
f"{proc.stderr.strip()[:2000]}",
|
|
141
|
+
file=sys.stderr,
|
|
142
|
+
)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
total = TOTAL_RE.search(proc.stdout)
|
|
146
|
+
if not total:
|
|
147
|
+
print(
|
|
148
|
+
"doc_structure_check_failed: linter produced no 合计 line, so the "
|
|
149
|
+
"count it reports cannot be read; treat as unscanned",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
return 1
|
|
153
|
+
n_err, n_warn = int(total.group(1)), int(total.group(2))
|
|
154
|
+
|
|
155
|
+
# CROSS-CHECK the exit code against the counts. Accepting the code as merely
|
|
156
|
+
# "in range" and then trusting the totals alone leaves the contradictory case
|
|
157
|
+
# open: a linter that exits 1 — its own signal for "there are ERRORs" — while
|
|
158
|
+
# printing `合计: 0 ERROR` is read here as a clean corpus and returns 0. That
|
|
159
|
+
# is the shape a partial failure after summary emission takes, and it is the
|
|
160
|
+
# one combination where every other guard in this file is satisfied. Both
|
|
161
|
+
# review lanes found it independently; the crash and unparseable-output tests
|
|
162
|
+
# do not reach it, because this output is perfectly parseable and merely lying.
|
|
163
|
+
expected_rc = 1 if n_err else (2 if n_warn else 0)
|
|
164
|
+
if proc.returncode != expected_rc:
|
|
165
|
+
print(
|
|
166
|
+
f"doc_structure_check_failed: linter exit code {proc.returncode} "
|
|
167
|
+
f"contradicts its own summary ({n_err} ERROR, {n_warn} WARN, which "
|
|
168
|
+
f"its contract maps to {expected_rc}). One of the two is wrong, so "
|
|
169
|
+
f"neither can be trusted as a verdict; treat as unscanned.",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
if n_err:
|
|
175
|
+
# Print the ERROR rows only. The WARN tier is visibility, and dumping 75
|
|
176
|
+
# advisory lines into a failure message buries the blocking ones.
|
|
177
|
+
for line in proc.stdout.splitlines():
|
|
178
|
+
if line.startswith(" ERROR") or (
|
|
179
|
+
line.strip() and not line.startswith(" ") and "ERROR" in line
|
|
180
|
+
):
|
|
181
|
+
print(line, file=sys.stderr)
|
|
182
|
+
codes = sorted(set(ERROR_ROW_RE.findall(proc.stdout)))
|
|
183
|
+
print(
|
|
184
|
+
f"doc_structure_check_failed: {n_err} ERROR-class structure defect(s) "
|
|
185
|
+
f"across {len(files)} tracked doc(s): {', '.join(codes)}. "
|
|
186
|
+
f"Fix the document; these are objective defects (a table with no real "
|
|
187
|
+
f"header, a figure reference with no such figure, an unreadable file), "
|
|
188
|
+
f"not style preferences. Run "
|
|
189
|
+
f"`python3 {linter} <file>` for the per-file detail.",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
print(
|
|
195
|
+
f"doc_structure_check_ok: {len(files)} tracked doc(s), 0 ERROR, "
|
|
196
|
+
f"{n_warn} WARN (advisory, non-blocking — the WARN predicates are `[工]` "
|
|
197
|
+
f"proxies and do not gate)"
|
|
198
|
+
)
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main(sys.argv[1:]))
|
package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/figure-lint.py
CHANGED
|
@@ -13,7 +13,35 @@
|
|
|
13
13
|
import sys, re, os, glob, json, math, collections
|
|
14
14
|
import xml.etree.ElementTree as ET
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
17
|
+
SVG = "{" + SVG_NS + "}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def lname(tag):
|
|
21
|
+
"""SVG 或无命名空间元素的 local-name;外来命名空间返回 None。
|
|
22
|
+
|
|
23
|
+
没有默认 xmlns 的内联 SVG 在 HTML 里完全合法——HTML 解析器会补命名空间,
|
|
24
|
+
ElementTree 不会,于是它产出**裸标签**。只按 `{ns}tag` 比较,这一整类图就
|
|
25
|
+
持续误判。但反过来无条件丢掉命名空间同样错,而且两个方向都错:
|
|
26
|
+
Inkscape/Illustrator 导出里极常见的 `<dc:title>`(RDF 元数据)会被当成 SVG
|
|
27
|
+
`<title>` 让缺标题的图假绿,而 `<ext:script>` 会被误判成可执行脚本错误阻断。
|
|
28
|
+
支持无 xmlns **不要求**接受任意外来命名空间——两条 lane 各从一个方向指出了这点。
|
|
29
|
+
"""
|
|
30
|
+
if not isinstance(tag, str):
|
|
31
|
+
return None
|
|
32
|
+
if tag.startswith('{'):
|
|
33
|
+
ns, _, local = tag[1:].partition('}')
|
|
34
|
+
return local if ns == SVG_NS else None
|
|
35
|
+
return tag
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def iter_local(root, *names):
|
|
39
|
+
"""按 local-name 遍历,替代 root.iter(f'{SVG}tag')。"""
|
|
40
|
+
want = set(names)
|
|
41
|
+
for el in root.iter():
|
|
42
|
+
if lname(el.tag) in want:
|
|
43
|
+
yield el
|
|
44
|
+
|
|
17
45
|
|
|
18
46
|
# ---------- 颜色与对比度(WCAG 2.2 SC 1.4.3) ----------
|
|
19
47
|
|
|
@@ -51,6 +79,11 @@ def parse_css(svg_text):
|
|
|
51
79
|
"""返回 {class_name: {prop: value}}"""
|
|
52
80
|
out = {}
|
|
53
81
|
for block in re.findall(r'<style[^>]*>(.*?)</style>', svg_text, re.S):
|
|
82
|
+
# at-rule 先剥掉:`@import "…";` 会被下面的选择器正则当成选择器的一部分,
|
|
83
|
+
# 把它后面那条真规则的 class 名污染掉,于是文字取不到样式、退回默认值,
|
|
84
|
+
# 冒出一串与本缺陷无关的契约报错。`@media{…}` 同理(只剥外层,保留内部规则)。
|
|
85
|
+
block = re.sub(r'@[a-zA-Z-]+[^;{]*;', ' ', block)
|
|
86
|
+
block = re.sub(r'@[a-zA-Z-]+[^{]*\{', ' ', block)
|
|
54
87
|
for m in re.finditer(r'([^{}]+)\{([^}]*)\}', block):
|
|
55
88
|
sels, body = m.group(1), m.group(2)
|
|
56
89
|
props = {}
|
|
@@ -144,12 +177,12 @@ def referenced_defs_colors(root, css):
|
|
|
144
177
|
m = re.match(r'url\(#([^)]+)\)', v)
|
|
145
178
|
if m:
|
|
146
179
|
used.add(m.group(1))
|
|
147
|
-
if el.tag ==
|
|
180
|
+
if lname(el.tag) == 'use':
|
|
148
181
|
href = el.get('href') or el.get('{http://www.w3.org/1999/xlink}href') or ''
|
|
149
182
|
if href.startswith('#'):
|
|
150
183
|
used.add(href[1:])
|
|
151
184
|
seen = set()
|
|
152
|
-
for defs in root
|
|
185
|
+
for defs in iter_local(root, 'defs'):
|
|
153
186
|
for node in defs:
|
|
154
187
|
if node.get('id') not in used:
|
|
155
188
|
continue
|
|
@@ -198,7 +231,7 @@ def group_path_map(root):
|
|
|
198
231
|
m = {}
|
|
199
232
|
def walk(node, chain):
|
|
200
233
|
for el in node:
|
|
201
|
-
c = chain + [id(el)] if el.tag ==
|
|
234
|
+
c = chain + [id(el)] if lname(el.tag) == 'g' else chain
|
|
202
235
|
m[id(el)] = c
|
|
203
236
|
walk(el, c)
|
|
204
237
|
walk(root, [])
|
|
@@ -622,8 +655,76 @@ def lint(path, contract_hint=None):
|
|
|
622
655
|
# 渐变、圆/多边形底、rgb()/CSS 变量同样会让「按白底算」得出假红或假绿。
|
|
623
656
|
geometry_unsafe = bool(paint_gaps)
|
|
624
657
|
|
|
658
|
+
# --- 自包含:图不得带脚本、外部资源或 HTML 逃逸口 ---
|
|
659
|
+
# 内联 SVG 与图片文件不同:它进入宿主文档的 DOM,所以 <script> 是**注入面**,
|
|
660
|
+
# 不只是整洁问题。<foreignObject> 把任意 HTML 带进来,同样逃出 SVG 的语义。
|
|
661
|
+
# 外部资源引用(image/use/feImage 指向 http(s) 或相对文件)让图在别处渲染时
|
|
662
|
+
# 变成断链——图随文档走,被引的东西不会。
|
|
663
|
+
#
|
|
664
|
+
# 刻意**不禁** <style>:邻近的 artifact-diagramming 技能禁它,但那是它那条
|
|
665
|
+
# lane 的自包含约束(页面已有 CSS 层),不是普遍判据;本仓自己的图正当地用
|
|
666
|
+
# 内联 <style> 定义字号与语义色。按仓规,外部包借理论不借实现。
|
|
667
|
+
# 同样不禁 <a>:超链接不在渲染时拉取任何东西,与"自包含"无关。
|
|
668
|
+
# 标签一律按 local-name 判:没有默认 xmlns 的 SVG 会让 ElementTree 产出裸标签,
|
|
669
|
+
# 只匹配带命名空间的 QName 就对那一类**持续假绿**。(评审实测:新增 fixture 全带
|
|
670
|
+
# xmlns,所以这个洞在自己的测试下看不见。)
|
|
671
|
+
RESOURCE_TAGS = ('image', 'use', 'feImage', 'pattern')
|
|
672
|
+
# 外部资源不只从 href 进来:`fill="url(https://…)"`、`filter=`、以及 <style> 里的
|
|
673
|
+
# @font-face src / background 同样在渲染时拉取。只查四个标签的 href 会漏掉这一整面。
|
|
674
|
+
EXTERNAL_URL_RE = re.compile(r'url\(\s*[\'"]?\s*(?!#|data:)([^)\'"\s]+)', re.I)
|
|
675
|
+
# `@import "https://…"` 是合法 CSS 且**不走 url() 语法**,只认 url() 就是一个绕过口。
|
|
676
|
+
CSS_IMPORT_RE = re.compile(r'@import\s+(?:url\(\s*)?[\'"]?\s*(?!#|data:)([^;)\'"\s]+)', re.I)
|
|
677
|
+
|
|
678
|
+
def _flag_external(where, value, css=False):
|
|
679
|
+
pats = (EXTERNAL_URL_RE, CSS_IMPORT_RE) if css else (EXTERNAL_URL_RE,)
|
|
680
|
+
for m in [m for pat in pats for m in pat.finditer(value or '')]:
|
|
681
|
+
add('ERROR', 'SVG-NOT-SELF-CONTAINED',
|
|
682
|
+
f'{where} 通过 url() 引用了外部资源 {m.group(1)[:60]}:'
|
|
683
|
+
f'渲染时会去拉取,图换个位置就是断链。只允许同文档 fragment(url(#id))或 data:')
|
|
684
|
+
|
|
685
|
+
for el in root.iter():
|
|
686
|
+
name = lname(el.tag)
|
|
687
|
+
if name == 'script':
|
|
688
|
+
add('ERROR', 'SVG-NOT-SELF-CONTAINED',
|
|
689
|
+
'<script> 出现在内联 SVG 里:它进入宿主文档的 DOM,是注入面而非整洁问题。'
|
|
690
|
+
'图应当是纯标记,交互归页面')
|
|
691
|
+
continue
|
|
692
|
+
if name == 'foreignObject':
|
|
693
|
+
add('ERROR', 'SVG-NOT-SELF-CONTAINED',
|
|
694
|
+
'<foreignObject> 把任意 HTML 带进图里:逃出 SVG 语义,且在多数导出/'
|
|
695
|
+
'打印路径下不渲染。要文本就用 <text>')
|
|
696
|
+
continue
|
|
697
|
+
if name == 'style':
|
|
698
|
+
_flag_external('<style>', el.text or '', css=True)
|
|
699
|
+
continue
|
|
700
|
+
if name in RESOURCE_TAGS:
|
|
701
|
+
href = (el.get('href') or el.get('{http://www.w3.org/1999/xlink}href') or '').strip()
|
|
702
|
+
if href and not href.startswith('#') and not href.startswith('data:'):
|
|
703
|
+
add('ERROR', 'SVG-NOT-SELF-CONTAINED',
|
|
704
|
+
f'{name} 引用了外部资源 {href[:60]}:'
|
|
705
|
+
f'图随文档走、被引的东西不会,换个位置渲染就是断链。'
|
|
706
|
+
f'用 fragment 引用(href="#id")或 data: 内嵌')
|
|
707
|
+
# 任何元素的任何属性里的 url()——fill/stroke/filter/mask/clip-path/style 都算
|
|
708
|
+
for attr, val in el.attrib.items():
|
|
709
|
+
a = attr.split('}')[-1]
|
|
710
|
+
# 事件处理器与 javascript: URL 与 <script> 是同一注入面。谓词声称管注入面,
|
|
711
|
+
# 只查 <script> 就是**声明与实现相反**——评审从这个方向打进来过。
|
|
712
|
+
if a.lower().startswith('on'):
|
|
713
|
+
add('ERROR', 'SVG-NOT-SELF-CONTAINED',
|
|
714
|
+
f'{name} 带事件处理器 {a}:内联 SVG 进入宿主文档的 DOM,'
|
|
715
|
+
f'它和 <script> 是同一个注入面。交互归页面,图是纯标记')
|
|
716
|
+
continue
|
|
717
|
+
v = (val or '').strip()
|
|
718
|
+
if a in ('href', 'xlink:href') and v.lower().replace(' ', '').startswith('javascript:'):
|
|
719
|
+
add('ERROR', 'SVG-NOT-SELF-CONTAINED',
|
|
720
|
+
f'{name} 的 {a} 是 javascript: URL:同上,属注入面')
|
|
721
|
+
continue
|
|
722
|
+
if 'url(' in (val or ''):
|
|
723
|
+
_flag_external(f'{name} 的 {a}', val)
|
|
724
|
+
|
|
625
725
|
# --- C4: 标题 ---
|
|
626
|
-
|
|
726
|
+
# 直接子元素才是整图标题;嵌套在 <g> 里的 <title> 是那个子元素的提示。
|
|
727
|
+
title_el = next((c for c in root if lname(c.tag) == 'title'), None)
|
|
627
728
|
title = (title_el.text or '').strip() if title_el is not None else ''
|
|
628
729
|
if not title:
|
|
629
730
|
add('ERROR', 'C4-TITLE', '缺 <title>:C4 要求每张图有标题,说清图的类型与范围')
|
|
@@ -633,10 +734,10 @@ def lint(path, contract_hint=None):
|
|
|
633
734
|
def walk_text(node, inh):
|
|
634
735
|
for el in node:
|
|
635
736
|
p = resolve(el, css, inh)
|
|
636
|
-
if el.tag ==
|
|
737
|
+
if lname(el.tag) == 'text':
|
|
637
738
|
content = ''.join(el.itertext()).strip()
|
|
638
739
|
content = ' '.join(content.split())
|
|
639
|
-
tspans = el.
|
|
740
|
+
tspans = [c for c in el if lname(c.tag) == 'tspan']
|
|
640
741
|
try:
|
|
641
742
|
tx = float(el.get('x', 0)); ty = float(el.get('y', 0))
|
|
642
743
|
except ValueError:
|
|
@@ -818,8 +919,8 @@ def lint(path, contract_hint=None):
|
|
|
818
919
|
if w <= 0 or h <= 0 or w >= page_w * 0.95:
|
|
819
920
|
return False
|
|
820
921
|
return any(x <= t['x'] <= x + w and y <= t['y'] <= y + h for t in texts)
|
|
821
|
-
card_rects = [e for e in root
|
|
822
|
-
for el in root
|
|
922
|
+
card_rects = [e for e in iter_local(root, 'rect') if _is_card_rect(e)]
|
|
923
|
+
for el in iter_local(root, 'rect'):
|
|
823
924
|
try:
|
|
824
925
|
rx = float(el.get('x', 0)); ry = float(el.get('y', 0))
|
|
825
926
|
rw = float(el.get('width', 0)); rh = float(el.get('height', 0))
|
|
@@ -859,7 +960,7 @@ def lint(path, contract_hint=None):
|
|
|
859
960
|
add('ERROR', 'GROUPING',
|
|
860
961
|
f'{ungrouped_cards}/{card_total} 张卡片的形状与其文字不在同一个 <g> 内:'
|
|
861
962
|
f'推送到会重排 z 序的目标端时,矩形可能盖住文字')
|
|
862
|
-
elif card_total == 0 and len(list(root
|
|
963
|
+
elif card_total == 0 and len(list(iter_local(root, 'g'))) == 0:
|
|
863
964
|
add('ERROR', 'GROUPING', '全图零 <g> 分组:目标端重排 z 序时矩形可能盖住文字')
|
|
864
965
|
|
|
865
966
|
# --- 主题:语义色需在每个声明的底色上都达标 ---
|
package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_doc_lint_repo.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Assertions for `doc-lint-repo.py`.
|
|
3
|
+
|
|
4
|
+
Every leg states what it pins. The two that matter most are the exclusion pair:
|
|
5
|
+
an exclusion is the only part of a scanner that can make it print a pass while
|
|
6
|
+
covering less than it claims, so it is asserted in BOTH directions — a fixture
|
|
7
|
+
must be dropped, and a real document under a similar path must not be.
|
|
8
|
+
|
|
9
|
+
The fail-closed legs matter for the same reason: a scanner that returns 0 when
|
|
10
|
+
it could not scan is worse than no scanner, because it certifies a corpus it
|
|
11
|
+
never read.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
HERE = Path(__file__).resolve().parent # skills/tighten-doc/scripts
|
|
25
|
+
REPO = HERE.parents[2] # repo root
|
|
26
|
+
SCRIPT = HERE / "doc-lint-repo.py"
|
|
27
|
+
LINTER_REL = "skills/tighten-doc/scripts/doc-lint.py"
|
|
28
|
+
# The scanner derives its fixture exclusion from the linter's own location, so a
|
|
29
|
+
# synthetic repo needs the skill laid out at the same relative depth for the
|
|
30
|
+
# exclusion legs to exercise the real code path.
|
|
31
|
+
FIXTURE_PREFIX = "skills/tighten-doc/scripts/tests/"
|
|
32
|
+
|
|
33
|
+
CLEAN_DOC = """# Title
|
|
34
|
+
|
|
35
|
+
A paragraph with nothing structurally wrong.
|
|
36
|
+
|
|
37
|
+
| Name | Count (n) |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| alpha | 1 |
|
|
40
|
+
| beta | 2 |
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# A table whose header row is empty. This is WCAG 2.2 SC 1.3.1 territory and the
|
|
44
|
+
# linter's ERROR tier, not a style opinion.
|
|
45
|
+
HEADERLESS_DOC = """# Title
|
|
46
|
+
|
|
47
|
+
| | |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| alpha | 1 |
|
|
50
|
+
| beta | 2 |
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(args, cwd=None, scanner=None):
|
|
55
|
+
"""Invoke the scanner. `scanner` overrides which copy of it runs.
|
|
56
|
+
|
|
57
|
+
The scanner resolves its linter as its OWN sibling, not as a path inside the
|
|
58
|
+
scanned repo — that is what lets it run against a repo which has never heard
|
|
59
|
+
of this skill. So a leg that needs a broken or absent linter installs a COPY
|
|
60
|
+
of the scanner into a temp directory and controls the sibling there, rather
|
|
61
|
+
than editing a file inside the fixture repo (which the scanner never reads).
|
|
62
|
+
"""
|
|
63
|
+
return subprocess.run(
|
|
64
|
+
[sys.executable, str(scanner or SCRIPT), *args],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
cwd=cwd,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def install_scanner(tmp: Path, linter_body: str | None) -> Path:
|
|
72
|
+
"""A temp 'installed skill' dir: the scanner plus the linter beside it.
|
|
73
|
+
|
|
74
|
+
`linter_body=None` installs no linter at all, which is the missing-linter
|
|
75
|
+
case. Anything else is written verbatim as the sibling `doc-lint.py`.
|
|
76
|
+
"""
|
|
77
|
+
inst = tmp / "installed" / "scripts"
|
|
78
|
+
inst.mkdir(parents=True)
|
|
79
|
+
dest = inst / SCRIPT.name
|
|
80
|
+
dest.write_text(SCRIPT.read_text(encoding="utf8"), encoding="utf8")
|
|
81
|
+
if linter_body is not None:
|
|
82
|
+
(inst / "doc-lint.py").write_text(linter_body, encoding="utf8")
|
|
83
|
+
return dest
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
REAL_LINTER = (HERE / "doc-lint.py").read_text(encoding="utf8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def install_scanner_inside(root: Path) -> Path:
|
|
90
|
+
"""Install the scanner INSIDE the scanned repo, at the shipping layout.
|
|
91
|
+
|
|
92
|
+
The exclusion only fires when the linter's `tests/` directory actually sits
|
|
93
|
+
inside the repo being scanned — which is true for the repo that ships this
|
|
94
|
+
skill and false for every consuming repo. So the exclusion legs must
|
|
95
|
+
reproduce the shipping layout; pointing the real scanner at a synthetic repo
|
|
96
|
+
would exercise the consumer path instead and silently test nothing.
|
|
97
|
+
"""
|
|
98
|
+
inst = root / "skills" / "tighten-doc" / "scripts"
|
|
99
|
+
inst.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
dest = inst / SCRIPT.name
|
|
101
|
+
dest.write_text(SCRIPT.read_text(encoding="utf8"), encoding="utf8")
|
|
102
|
+
(inst / "doc-lint.py").write_text(REAL_LINTER, encoding="utf8")
|
|
103
|
+
return dest
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def make_repo(tmp: Path, docs: dict[str, str], *, with_linter=True) -> Path:
|
|
107
|
+
"""A git repo carrying just the given docs.
|
|
108
|
+
|
|
109
|
+
`with_linter` also drops a copy of the linter at the conventional in-repo
|
|
110
|
+
path. That copy is NOT what the scanner runs — it resolves its own sibling —
|
|
111
|
+
but the exclusion legs need the fixture directory to exist under the scanned
|
|
112
|
+
root at the real relative depth, and this is what puts it there.
|
|
113
|
+
"""
|
|
114
|
+
root = tmp / "repo"
|
|
115
|
+
root.mkdir()
|
|
116
|
+
subprocess.run(["git", "init", "-q", "-b", "trunk", str(root)], check=True)
|
|
117
|
+
subprocess.run(["git", "-C", str(root), "config", "user.email", "t@example.invalid"], check=True)
|
|
118
|
+
subprocess.run(["git", "-C", str(root), "config", "user.name", "T"], check=True)
|
|
119
|
+
subprocess.run(["git", "-C", str(root), "config", "commit.gpgsign", "false"], check=True)
|
|
120
|
+
if with_linter:
|
|
121
|
+
dest = root / LINTER_REL
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
dest.write_text((REPO / LINTER_REL).read_text(encoding="utf8"), encoding="utf8")
|
|
124
|
+
for rel, body in docs.items():
|
|
125
|
+
p = root / rel
|
|
126
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
p.write_text(body, encoding="utf8")
|
|
128
|
+
subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
|
|
129
|
+
subprocess.run(["git", "-C", str(root), "commit", "-qm", "seed"], check=True)
|
|
130
|
+
return root
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RealRepo(unittest.TestCase):
|
|
134
|
+
def test_gate_is_green_and_states_its_coverage(self):
|
|
135
|
+
"""The gate is green on the current checkout, and says how many docs it read.
|
|
136
|
+
|
|
137
|
+
A pass with no count is unfalsifiable: it reads identically whether the
|
|
138
|
+
scanner covered 481 documents or zero.
|
|
139
|
+
"""
|
|
140
|
+
proc = run([str(REPO)], cwd=str(REPO))
|
|
141
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
142
|
+
self.assertIn("doc_structure_check_ok:", proc.stdout)
|
|
143
|
+
count = int(proc.stdout.split("doc_structure_check_ok:")[1].split()[0])
|
|
144
|
+
self.assertGreater(count, 100, "scope collapsed; the pass covers almost nothing")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Exclusion(unittest.TestCase):
|
|
148
|
+
def test_fixture_corpus_is_excluded(self):
|
|
149
|
+
"""Without the exclusion this gate is permanently red on its own fixtures."""
|
|
150
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
151
|
+
root = make_repo(
|
|
152
|
+
Path(tmp),
|
|
153
|
+
{
|
|
154
|
+
"docs/real.md": CLEAN_DOC,
|
|
155
|
+
FIXTURE_PREFIX + "doc/broken.md": HEADERLESS_DOC,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
scanner = install_scanner_inside(root)
|
|
159
|
+
subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
|
|
160
|
+
subprocess.run(
|
|
161
|
+
["git", "-C", str(root), "commit", "-qm", "install"], check=True
|
|
162
|
+
)
|
|
163
|
+
proc = run([str(root)], cwd=str(root), scanner=scanner)
|
|
164
|
+
self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout)
|
|
165
|
+
# docs/real.md plus the installed skill's own two .md-free scripts:
|
|
166
|
+
# only the real doc and nothing from tests/ may be counted.
|
|
167
|
+
self.assertIn("1 tracked doc(s)", proc.stdout)
|
|
168
|
+
|
|
169
|
+
def test_exclusion_does_not_swallow_a_real_doc(self):
|
|
170
|
+
"""The other direction: a path that merely LOOKS fixture-ish stays in scope.
|
|
171
|
+
|
|
172
|
+
This is the leg that catches an exclusion widened into a pattern. A
|
|
173
|
+
one-directional exclusion test passes just as well when the exclusion
|
|
174
|
+
has quietly grown to cover half the repo.
|
|
175
|
+
|
|
176
|
+
THE DEFECT MUST SIT ON THE AT-RISK PATH, and that is not a detail. The
|
|
177
|
+
first version of this leg put the headerless table on a path with no
|
|
178
|
+
`tests/` segment and a clean doc on `docs/tests/guide.md`; a mutation
|
|
179
|
+
that widened the prefix to a `"tests/" in p` substring then dropped only
|
|
180
|
+
the CLEAN document, the verdict did not move, and the leg stayed green
|
|
181
|
+
while its own docstring claimed it tested both directions. Applying that
|
|
182
|
+
mutation is what found it. Keep the ERROR on the path the widened
|
|
183
|
+
exclusion would swallow, or this leg proves nothing again.
|
|
184
|
+
"""
|
|
185
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
186
|
+
root = make_repo(
|
|
187
|
+
Path(tmp),
|
|
188
|
+
{
|
|
189
|
+
# Contains `tests/`, is NOT under the fixture prefix, and is
|
|
190
|
+
# the doc carrying the defect.
|
|
191
|
+
"docs/tests/guide.md": HEADERLESS_DOC,
|
|
192
|
+
"skills/tighten-doc/scripts/testing-notes.md": CLEAN_DOC,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
scanner = install_scanner_inside(root)
|
|
196
|
+
subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
|
|
197
|
+
subprocess.run(
|
|
198
|
+
["git", "-C", str(root), "commit", "-qm", "install"], check=True
|
|
199
|
+
)
|
|
200
|
+
proc = run([str(root)], cwd=str(root), scanner=scanner)
|
|
201
|
+
self.assertEqual(
|
|
202
|
+
proc.returncode,
|
|
203
|
+
1,
|
|
204
|
+
"a real doc on a fixture-looking path was dropped by the exclusion",
|
|
205
|
+
)
|
|
206
|
+
self.assertIn("doc_structure_check_failed:", proc.stderr)
|
|
207
|
+
self.assertIn("WCAG-131-TABLE", proc.stderr)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_consuming_repo_excludes_nothing_and_still_works(self):
|
|
211
|
+
"""A repo that has never heard of this skill: nothing is excluded.
|
|
212
|
+
|
|
213
|
+
This is what moving the scanner into the skill package is FOR. The
|
|
214
|
+
exclusion is derived from the linter's own location; in a consuming repo
|
|
215
|
+
that location is outside the scanned tree, so the prefix resolves to None
|
|
216
|
+
and every tracked document is in scope. A repo-local hardcoded prefix
|
|
217
|
+
would have silently dropped any consumer path that happened to match.
|
|
218
|
+
"""
|
|
219
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
220
|
+
root = make_repo(
|
|
221
|
+
Path(tmp),
|
|
222
|
+
{
|
|
223
|
+
"README.md": CLEAN_DOC,
|
|
224
|
+
# A consumer path that a hardcoded prefix would have eaten.
|
|
225
|
+
"skills/tighten-doc/scripts/tests/their-own-doc.md": HEADERLESS_DOC,
|
|
226
|
+
},
|
|
227
|
+
with_linter=False,
|
|
228
|
+
)
|
|
229
|
+
proc = run([str(root)], cwd=str(root))
|
|
230
|
+
self.assertEqual(
|
|
231
|
+
proc.returncode,
|
|
232
|
+
1,
|
|
233
|
+
"a consuming repo's document was dropped by an exclusion that "
|
|
234
|
+
"should not apply outside the skill's own checkout",
|
|
235
|
+
)
|
|
236
|
+
self.assertIn("2 tracked doc(s)", proc.stderr + proc.stdout)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Blocking(unittest.TestCase):
|
|
240
|
+
def test_error_blocks_and_names_the_predicate(self):
|
|
241
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
242
|
+
root = make_repo(Path(tmp), {"docs/bad.md": HEADERLESS_DOC})
|
|
243
|
+
proc = run([str(root)], cwd=str(root))
|
|
244
|
+
self.assertEqual(proc.returncode, 1)
|
|
245
|
+
self.assertIn("doc_structure_check_failed:", proc.stderr)
|
|
246
|
+
# rc alone is a weak oracle: name WHICH defect, or an unrelated
|
|
247
|
+
# failure reads as the gate working.
|
|
248
|
+
self.assertIn("WCAG-131-TABLE", proc.stderr)
|
|
249
|
+
|
|
250
|
+
def test_warn_only_corpus_does_not_block(self):
|
|
251
|
+
"""The WARN tier is advisory by decision, not by accident.
|
|
252
|
+
|
|
253
|
+
`figure-and-table-craft.md` §9b: a proxy that cannot separate a defect
|
|
254
|
+
from a judgement call must not gate. If this leg ever flips, that
|
|
255
|
+
decision was reversed silently.
|
|
256
|
+
|
|
257
|
+
ASSERT ON THE COUNTS, NOT ON THE WORDS. The first version searched the
|
|
258
|
+
success line for the literal strings "WARN" and "non-blocking" — both of
|
|
259
|
+
which that line always contains, whatever the counts are. The leg would
|
|
260
|
+
have stayed green if the parser started reporting 0 WARN, or if this
|
|
261
|
+
fixture stopped producing any WARN at all. The challenge lane found it.
|
|
262
|
+
So: parse the numbers, and independently confirm the fixture really is a
|
|
263
|
+
WARN-only document by checking the linter's own exit code is 2.
|
|
264
|
+
"""
|
|
265
|
+
# A numeric column with no unit, over enough rows to trip TABLE-NO-UNIT.
|
|
266
|
+
# The single-row version used first produced ZERO findings — which is how
|
|
267
|
+
# the old string-matching assertion passed on a fixture that tested nothing.
|
|
268
|
+
warn_doc = (
|
|
269
|
+
"# Title\n\n| 指标 | 值 |\n| --- | --- |\n"
|
|
270
|
+
"| 延迟 | 120 |\n| 吞吐 | 4500 |\n| 错误率 | 3 |\n| 并发 | 64 |\n"
|
|
271
|
+
)
|
|
272
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
273
|
+
root = make_repo(Path(tmp), {"docs/warny.md": warn_doc})
|
|
274
|
+
|
|
275
|
+
# Precondition: the fixture is WARN-only per the linter itself. If
|
|
276
|
+
# this ever stops holding, the leg below is testing nothing.
|
|
277
|
+
direct = subprocess.run(
|
|
278
|
+
[sys.executable, str(HERE / "doc-lint.py"), str(root / "docs/warny.md")],
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
)
|
|
282
|
+
self.assertEqual(
|
|
283
|
+
direct.returncode, 2, "fixture is no longer WARN-only: " + direct.stdout
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
proc = run([str(root)], cwd=str(root))
|
|
287
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
288
|
+
m = re.search(r"(\d+) tracked doc\(s\), (\d+) ERROR, (\d+) WARN", proc.stdout)
|
|
289
|
+
self.assertIsNotNone(m, proc.stdout)
|
|
290
|
+
n_docs, n_err, n_warn = (int(g) for g in m.groups())
|
|
291
|
+
self.assertEqual(n_docs, 1)
|
|
292
|
+
self.assertEqual(n_err, 0)
|
|
293
|
+
self.assertGreater(n_warn, 0, "WARN tier reported nothing; leg proves nothing")
|
|
294
|
+
self.assertIn("non-blocking", proc.stdout)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Coverage(unittest.TestCase):
|
|
298
|
+
def test_uppercase_extension_is_in_scope(self):
|
|
299
|
+
"""A tracked `.MD` is Markdown, and a coverage gate that misses it lies.
|
|
300
|
+
|
|
301
|
+
`git ls-files '*.md'` is case-sensitive on a case-sensitive filesystem,
|
|
302
|
+
so a document named `NOTES.MD` sat outside a gate whose whole claim is
|
|
303
|
+
repository-wide tracked-Markdown coverage. The review lane found it. The
|
|
304
|
+
defect must sit on the uppercase file, or the leg passes on the lowercase
|
|
305
|
+
one and proves nothing.
|
|
306
|
+
"""
|
|
307
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
308
|
+
root = make_repo(
|
|
309
|
+
Path(tmp),
|
|
310
|
+
{"docs/CLEAN.md": CLEAN_DOC, "docs/BYPASS.MD": HEADERLESS_DOC},
|
|
311
|
+
)
|
|
312
|
+
proc = run([str(root)], cwd=str(root))
|
|
313
|
+
self.assertEqual(proc.returncode, 1, "an uppercase-extension doc was skipped")
|
|
314
|
+
self.assertIn("WCAG-131-TABLE", proc.stderr)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class ContradictoryLinter(unittest.TestCase):
|
|
318
|
+
"""The exit code and the summary must agree, or neither is a verdict.
|
|
319
|
+
|
|
320
|
+
Both review lanes found this independently: accepting the code as merely
|
|
321
|
+
"in range" and then trusting the totals leaves open the one combination
|
|
322
|
+
where every other guard here is satisfied — a linter that exits 1 (its
|
|
323
|
+
signal for "there are ERRORs") while printing `合计: 0 ERROR`. The crash and
|
|
324
|
+
unparseable-output legs do not reach it: this output parses perfectly and is
|
|
325
|
+
simply lying.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def _stub(self, code: str, rc: int):
|
|
329
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
330
|
+
root = make_repo(Path(tmp), {"docs/real.md": CLEAN_DOC})
|
|
331
|
+
scanner = install_scanner(
|
|
332
|
+
Path(tmp), f"import sys\nprint({code!r})\nsys.exit({rc})\n"
|
|
333
|
+
)
|
|
334
|
+
return run([str(root)], cwd=str(root), scanner=scanner)
|
|
335
|
+
|
|
336
|
+
def test_exit_1_with_zero_errors_is_not_read_as_clean(self):
|
|
337
|
+
proc = self._stub("合计: 0 ERROR, 0 WARN", 1)
|
|
338
|
+
self.assertEqual(proc.returncode, 1)
|
|
339
|
+
self.assertIn("contradicts its own summary", proc.stderr)
|
|
340
|
+
|
|
341
|
+
def test_exit_0_with_errors_reported_is_not_read_as_clean(self):
|
|
342
|
+
proc = self._stub("合计: 3 ERROR, 0 WARN", 0)
|
|
343
|
+
self.assertEqual(proc.returncode, 1)
|
|
344
|
+
self.assertIn("contradicts its own summary", proc.stderr)
|
|
345
|
+
|
|
346
|
+
def test_exit_2_with_zero_findings_is_not_read_as_clean(self):
|
|
347
|
+
proc = self._stub("合计: 0 ERROR, 0 WARN", 2)
|
|
348
|
+
self.assertEqual(proc.returncode, 1)
|
|
349
|
+
self.assertIn("contradicts its own summary", proc.stderr)
|
|
350
|
+
|
|
351
|
+
def test_consistent_warn_only_stub_is_accepted(self):
|
|
352
|
+
"""The control: a stub that AGREES with itself must pass.
|
|
353
|
+
|
|
354
|
+
Without it, a cross-check that rejected everything would look identical
|
|
355
|
+
to one that works.
|
|
356
|
+
"""
|
|
357
|
+
proc = self._stub("合计: 0 ERROR, 4 WARN", 2)
|
|
358
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class FailsClosed(unittest.TestCase):
|
|
362
|
+
def test_missing_linter_blocks(self):
|
|
363
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
364
|
+
root = make_repo(Path(tmp), {"docs/real.md": CLEAN_DOC})
|
|
365
|
+
scanner = install_scanner(Path(tmp), None)
|
|
366
|
+
proc = run([str(root)], cwd=str(root), scanner=scanner)
|
|
367
|
+
self.assertEqual(proc.returncode, 1)
|
|
368
|
+
self.assertIn("linter not found", proc.stderr)
|
|
369
|
+
|
|
370
|
+
def test_empty_scope_blocks(self):
|
|
371
|
+
"""No tracked Markdown is a broken enumeration, never a clean corpus."""
|
|
372
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
373
|
+
root = Path(tmp) / "empty"
|
|
374
|
+
root.mkdir()
|
|
375
|
+
subprocess.run(["git", "init", "-q", "-b", "trunk", str(root)], check=True)
|
|
376
|
+
proc = run([str(root)], cwd=str(root))
|
|
377
|
+
self.assertEqual(proc.returncode, 1)
|
|
378
|
+
self.assertIn("enumeration is broken", proc.stderr)
|
|
379
|
+
|
|
380
|
+
def test_non_git_directory_blocks(self):
|
|
381
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
382
|
+
root = Path(tmp) / "plain"
|
|
383
|
+
root.mkdir()
|
|
384
|
+
proc = run([str(root)], cwd=str(root))
|
|
385
|
+
self.assertEqual(proc.returncode, 1)
|
|
386
|
+
# Name WHICH refusal. Asserting only the generic token cannot tell a
|
|
387
|
+
# git failure apart from the empty-scope guard catching the empty
|
|
388
|
+
# list a swallowed git error would return — a mutation that replaced
|
|
389
|
+
# the raise with a fall-through stayed green here for that reason.
|
|
390
|
+
self.assertIn("git ls-files exited", proc.stderr)
|
|
391
|
+
|
|
392
|
+
def test_linter_crash_is_not_read_as_clean(self):
|
|
393
|
+
"""A linter that dies must block, not certify.
|
|
394
|
+
|
|
395
|
+
Substituting a stub that exits 3 pins the exit-code contract check: 0/1/2
|
|
396
|
+
are verdicts, anything else is the linter failing.
|
|
397
|
+
"""
|
|
398
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
399
|
+
root = make_repo(Path(tmp), {"docs/real.md": CLEAN_DOC})
|
|
400
|
+
scanner = install_scanner(Path(tmp), "import sys\nsys.exit(3)\n")
|
|
401
|
+
proc = run([str(root)], cwd=str(root), scanner=scanner)
|
|
402
|
+
self.assertEqual(proc.returncode, 1)
|
|
403
|
+
self.assertIn("expected 0/1/2", proc.stderr)
|
|
404
|
+
|
|
405
|
+
def test_missing_total_line_is_not_read_as_clean(self):
|
|
406
|
+
"""A linter that prints nothing parseable must block.
|
|
407
|
+
|
|
408
|
+
Exit 0 plus unreadable output is the exact shape that makes a scanner
|
|
409
|
+
certify a corpus it never counted.
|
|
410
|
+
"""
|
|
411
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
412
|
+
root = make_repo(Path(tmp), {"docs/real.md": CLEAN_DOC})
|
|
413
|
+
scanner = install_scanner(Path(tmp), "print('nothing parseable here')\n")
|
|
414
|
+
proc = run([str(root)], cwd=str(root), scanner=scanner)
|
|
415
|
+
self.assertEqual(proc.returncode, 1)
|
|
416
|
+
self.assertIn("treat as unscanned", proc.stderr)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
unittest.main(verbosity=2)
|
|
@@ -83,7 +83,15 @@ run_expect figure-lint.py "${here}/tests/svg" svg \
|
|
|
83
83
|
'no-aria.svg=FIGURE-A11Y-STRUCTURE' \
|
|
84
84
|
'cvd-confusable.svg=' \
|
|
85
85
|
'decorative-line.svg=' \
|
|
86
|
-
'blackmarker.svg=CONTRACT-COLOR-TOKEN'
|
|
86
|
+
'blackmarker.svg=CONTRACT-COLOR-TOKEN' \
|
|
87
|
+
'unsafe-script.svg=SVG-NOT-SELF-CONTAINED' \
|
|
88
|
+
'unsafe-external-ref.svg=SVG-NOT-SELF-CONTAINED' \
|
|
89
|
+
'unsafe-noxmlns.svg=SVG-NOT-SELF-CONTAINED' \
|
|
90
|
+
'unsafe-css-url.svg=SVG-NOT-SELF-CONTAINED' \
|
|
91
|
+
'unsafe-css-import.svg=SVG-NOT-SELF-CONTAINED' \
|
|
92
|
+
'unsafe-event-handler.svg=SVG-NOT-SELF-CONTAINED' \
|
|
93
|
+
'foreign-ns-title.svg=C4-TITLE' \
|
|
94
|
+
'self-contained-ok.svg='
|
|
87
95
|
|
|
88
96
|
note "== doc-lint 逐谓词差分 =="
|
|
89
97
|
run_expect doc-lint.py "${here}/tests/doc" md \
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 400 200" role="img" aria-label="RDF metadata title must not satisfy C4">
|
|
2
|
+
<dc:title>Metadata title, not the figure title</dc:title>
|
|
3
|
+
<defs><style>.ok{font-size:14px;fill:#111827}</style></defs>
|
|
4
|
+
<g>
|
|
5
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
6
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
7
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
8
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
9
|
+
</g>
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 200" role="img" aria-label="every allowed self-contained form">
|
|
2
|
+
<title>Every allowed self-contained form</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<style>.ok{font-size:14px;fill:#111827}.flow{fill:none;stroke:#0072B2;marker-end:url(#a)}</style>
|
|
5
|
+
<marker id="a" refX="10" refY="6"><path d="M1 1L11 6L1 11Z" fill="#0072B2"/></marker>
|
|
6
|
+
<rect id="chip" width="14" height="14" fill="#009E73"/>
|
|
7
|
+
</defs>
|
|
8
|
+
<g>
|
|
9
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
10
|
+
<a href="https://example.invalid/docs"><text x="200" y="30" class="ok">超链接不拉取资源</text></a>
|
|
11
|
+
<image x="300" y="20" width="8" height="8" href="data:image/gif;base64,R0lGODlhAQABAAAAACw="/>
|
|
12
|
+
<use xlink:href="#chip" x="10" y="95"/>
|
|
13
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
14
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
15
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
16
|
+
</g>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200" role="img" aria-label="css import pulls an external stylesheet">
|
|
2
|
+
<title>CSS import pulls an external stylesheet</title>
|
|
3
|
+
<defs><style>@import "https://example.invalid/theme.css";
|
|
4
|
+
.ok{font-size:14px;fill:#111827}</style></defs>
|
|
5
|
+
<g>
|
|
6
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
7
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
8
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
9
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200" role="img" aria-label="external font in inline style">
|
|
2
|
+
<title>External font in inline style</title>
|
|
3
|
+
<defs><style>@font-face{font-family:X;src:url(https://example.invalid/f.woff2)}.ok{font-size:14px;fill:#111827}</style></defs>
|
|
4
|
+
<g>
|
|
5
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
6
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
7
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
8
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
9
|
+
</g>
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200" role="img" aria-label="inline event handler is the same injection surface">
|
|
2
|
+
<title>Inline event handler is the same injection surface</title>
|
|
3
|
+
<defs><style>.ok{font-size:14px;fill:#111827}</style></defs>
|
|
4
|
+
<g>
|
|
5
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF" onload="void 0"/>
|
|
6
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
7
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
8
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
9
|
+
</g>
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200" role="img" aria-label="external image reference">
|
|
2
|
+
<title>External image reference</title>
|
|
3
|
+
<defs><style>.ok{font-size:14px;fill:#111827}</style></defs>
|
|
4
|
+
<g>
|
|
5
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
6
|
+
<image x="200" y="20" width="40" height="40" href="https://example.invalid/logo.png"/>
|
|
7
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
8
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
9
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg viewBox="0 0 400 200" role="img" aria-label="external use without a default namespace">
|
|
2
|
+
<title>External use without a default namespace</title>
|
|
3
|
+
<defs><style>.ok{font-size:14px;fill:#111827}</style></defs>
|
|
4
|
+
<g>
|
|
5
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
6
|
+
<use href="https://example.invalid/sprite.svg#icon" x="200" y="20"/>
|
|
7
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
8
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
9
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200" role="img" aria-label="script inside an inline svg">
|
|
2
|
+
<title>Script inside an inline SVG</title>
|
|
3
|
+
<defs><style>.ok{font-size:14px;fill:#111827}</style></defs>
|
|
4
|
+
<script>void 0;</script>
|
|
5
|
+
<g>
|
|
6
|
+
<rect x="0" y="0" width="400" height="200" fill="#FFFFFF"/>
|
|
7
|
+
<rect x="10" y="120" width="14" height="14" fill="#0072B2"/><text x="32" y="132" class="ok">图例一</text>
|
|
8
|
+
<rect x="10" y="145" width="14" height="14" fill="#D55E00"/><text x="32" y="157" class="ok">图例二</text>
|
|
9
|
+
<rect x="10" y="170" width="14" height="14" fill="#009E73"/><text x="32" y="182" class="ok">图例三</text>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
package/dist/assets/release.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"npmPackage": "@ccoalm/ccl-skills",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"sourceCommit": "
|
|
4
|
+
"version": "0.6.1",
|
|
5
|
+
"sourceCommit": "1fe64817eecf48e4225a6d03f574afc81871ac28",
|
|
6
6
|
"sourceState": "clean",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
@@ -2017,7 +2017,7 @@
|
|
|
2017
2017
|
},
|
|
2018
2018
|
{
|
|
2019
2019
|
"path": "marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md",
|
|
2020
|
-
"sha256": "
|
|
2020
|
+
"sha256": "3826b9678567561ad5823fb22f76b1546d1cc886f25ecb988a4557add3443311",
|
|
2021
2021
|
"mode": 420
|
|
2022
2022
|
},
|
|
2023
2023
|
{
|
|
@@ -2632,7 +2632,7 @@
|
|
|
2632
2632
|
},
|
|
2633
2633
|
{
|
|
2634
2634
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/references/figure-and-table-craft.md",
|
|
2635
|
-
"sha256": "
|
|
2635
|
+
"sha256": "15fb3f231595b49730315a9e1f12918e0f268f9d7831523a62d92c5831d0d92d",
|
|
2636
2636
|
"mode": 420
|
|
2637
2637
|
},
|
|
2638
2638
|
{
|
|
@@ -2645,6 +2645,11 @@
|
|
|
2645
2645
|
"sha256": "f8e211e223305ad9535531fd3abede8884d859a0fda01e103e6ef1700909c478",
|
|
2646
2646
|
"mode": 420
|
|
2647
2647
|
},
|
|
2648
|
+
{
|
|
2649
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/doc-lint-repo.py",
|
|
2650
|
+
"sha256": "40923750c1d7058b902bb8c55b7d4324fbfb43ada4a1454bba412b68c7eaa48c",
|
|
2651
|
+
"mode": 493
|
|
2652
|
+
},
|
|
2648
2653
|
{
|
|
2649
2654
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/doc-lint.py",
|
|
2650
2655
|
"sha256": "f3b98094fea915864c8581d81c2615c1df7b5863539274af7b1877a851036258",
|
|
@@ -2652,7 +2657,7 @@
|
|
|
2652
2657
|
},
|
|
2653
2658
|
{
|
|
2654
2659
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/figure-lint.py",
|
|
2655
|
-
"sha256": "
|
|
2660
|
+
"sha256": "688a30beb54b29a070127097dc5716985dc5cb951e5131bb378c9d75776fc22b",
|
|
2656
2661
|
"mode": 493
|
|
2657
2662
|
},
|
|
2658
2663
|
{
|
|
@@ -2660,9 +2665,14 @@
|
|
|
2660
2665
|
"sha256": "114f73b6340e9272ef297ff79f764ef77439a69f0ec2dfec36f7ee8a516df78b",
|
|
2661
2666
|
"mode": 493
|
|
2662
2667
|
},
|
|
2668
|
+
{
|
|
2669
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_doc_lint_repo.py",
|
|
2670
|
+
"sha256": "87cbd9ff6a0a329b753ed1f08cd75eb2dcc433e34a10883819d6b299b1349ce0",
|
|
2671
|
+
"mode": 493
|
|
2672
|
+
},
|
|
2663
2673
|
{
|
|
2664
2674
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_figure_and_doc_lint.sh",
|
|
2665
|
-
"sha256": "
|
|
2675
|
+
"sha256": "f759f974b7c4de72dd7b0599ae14c1a2e4ced7b1610a80dcc96e562cd1bbb11f",
|
|
2666
2676
|
"mode": 493
|
|
2667
2677
|
},
|
|
2668
2678
|
{
|
|
@@ -2790,6 +2800,11 @@
|
|
|
2790
2800
|
"sha256": "8acc43af08efda556400b556f9625b110f505a7698d222705cf74a3ffa78fe8c",
|
|
2791
2801
|
"mode": 420
|
|
2792
2802
|
},
|
|
2803
|
+
{
|
|
2804
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/foreign-ns-title.svg",
|
|
2805
|
+
"sha256": "90a33a506bc6112a51bb66d14b550611b74f81588f1141c9e34f8729a94b1469",
|
|
2806
|
+
"mode": 420
|
|
2807
|
+
},
|
|
2793
2808
|
{
|
|
2794
2809
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/low-contrast.svg",
|
|
2795
2810
|
"sha256": "64d3406a0c7bf36a5e4ee6961e2aa850e4fb2972e3aeb1c5f24fc7b78b7e4efb",
|
|
@@ -2835,6 +2850,11 @@
|
|
|
2835
2850
|
"sha256": "4366d067bd1a2f15b883e1ba3cf8d1de446f31194c4782b2a9c6b35095e94460",
|
|
2836
2851
|
"mode": 420
|
|
2837
2852
|
},
|
|
2853
|
+
{
|
|
2854
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/self-contained-ok.svg",
|
|
2855
|
+
"sha256": "97b54f836a32cfd40c802eddcc56a83e3a2ca2bc2abeb8baa173ea678ea6e478",
|
|
2856
|
+
"mode": 420
|
|
2857
|
+
},
|
|
2838
2858
|
{
|
|
2839
2859
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/transformed.svg",
|
|
2840
2860
|
"sha256": "2fe513502d519230109876b8c09988e23cef06127f266d6a075f407b13570e82",
|
|
@@ -2850,6 +2870,36 @@
|
|
|
2850
2870
|
"sha256": "97574a14daeb43df353d436cbfc4b7809b992e0bda3c93374dfe0496dc7c275c",
|
|
2851
2871
|
"mode": 420
|
|
2852
2872
|
},
|
|
2873
|
+
{
|
|
2874
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-css-import.svg",
|
|
2875
|
+
"sha256": "933ca0f36b00ad670e937a8fe34ecff2bfc43bfa9206149eb53ad0967a50d41b",
|
|
2876
|
+
"mode": 420
|
|
2877
|
+
},
|
|
2878
|
+
{
|
|
2879
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-css-url.svg",
|
|
2880
|
+
"sha256": "06b72d5a57e65aa24a0838cabf2b28ccb72ffa9f473efd54a554533e2bdad87b",
|
|
2881
|
+
"mode": 420
|
|
2882
|
+
},
|
|
2883
|
+
{
|
|
2884
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-event-handler.svg",
|
|
2885
|
+
"sha256": "7645dac5995e3567912babd78bff3e6917b23fb08e43dd8683045c36816e56c2",
|
|
2886
|
+
"mode": 420
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-external-ref.svg",
|
|
2890
|
+
"sha256": "d4a23f94d0451d80d3cf7b8abd9a7dd1de5e36917d75a03461c1faf2d2c966c7",
|
|
2891
|
+
"mode": 420
|
|
2892
|
+
},
|
|
2893
|
+
{
|
|
2894
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-noxmlns.svg",
|
|
2895
|
+
"sha256": "b213dc68e627ca197a26a0a5ba63ebd6a05a2271650cc6aac8fea89775e4e851",
|
|
2896
|
+
"mode": 420
|
|
2897
|
+
},
|
|
2898
|
+
{
|
|
2899
|
+
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unsafe-script.svg",
|
|
2900
|
+
"sha256": "a5f9b7aee40935cb0b494fc62104d1b811bb6945a410cdb04581c682d7ca3e99",
|
|
2901
|
+
"mode": 420
|
|
2902
|
+
},
|
|
2853
2903
|
{
|
|
2854
2904
|
"path": "marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md",
|
|
2855
2905
|
"sha256": "4647f69fd7cb1dd9beeeefcbffd3614ddc8e76beaa7a4215fced3adc843e1ef0",
|
|
@@ -3088,5 +3138,5 @@
|
|
|
3088
3138
|
"mode": 420
|
|
3089
3139
|
}
|
|
3090
3140
|
],
|
|
3091
|
-
"snapshotHash": "
|
|
3141
|
+
"snapshotHash": "8ac035597e272582c843f961d753dadd5c9ff576bd7e96d8ce9d486a09fe583d"
|
|
3092
3142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccoalm/ccl-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Self-contained CCL Skills installer for Claude Code, Codex, and OpenCode",
|
|
5
5
|
"keywords": ["agent-skills", "claude-code", "codex", "opencode", "developer-tools"],
|
|
6
6
|
"type": "module",
|