@ccoalm/ccl-skills 0.4.0 → 0.6.0
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/multi-perspective-research/references/public-data-acquisition.md +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-disclosure-channels.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +4 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +20 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +12 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +85 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +123 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +4 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/deliverable-doc-genre-skeletons.md +133 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/doc-charter-first.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/figure-and-table-craft.md +345 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/AGENTS.md +46 -0
- 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/doc-lint.py +246 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/figure-lint.py +1092 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/mutation_probe.sh +100 -0
- 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 +375 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/control.md +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/empty-header.md +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fake-header.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fenced-noise.md +14 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-dangling.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-orphan-captioned.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-orphan.md +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig.png +0 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/imbalance.md +41 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/no-unit.md +8 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/should-be-chart.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/tables-only-clean.md +35 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/unfilled.md +7 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/wide-table.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/bad-viewbox.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/blackmarker.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/control.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/crossings.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/cvd-confusable.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/decorative-line.svg +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/edge-no-arrow.svg +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/edge-vague.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/figure-contract.json +21 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/figure-is-a-list.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/flow-mixed.svg +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/low-contrast.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/malformed.svg +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-aria.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-group.svg +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-legend.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-title.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-viewbox.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/offcontract-shape.svg +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/overflow.svg +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/transformed.svg +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/ungrouped-card.svg +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unlabeled-edge.svg +13 -0
- package/dist/assets/release.json +254 -14
- package/package.json +1 -1
package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/mutation_probe.sh
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 差分敏感度实测:逐谓词把其 code 字面量替换掉使其在结果中消失,
|
|
3
|
+
# 确认自测套件转红。绿的谓词 = 该谓词没有覆盖。
|
|
4
|
+
#
|
|
5
|
+
# 为什么必须"施加"而不是"断言":本目录实测踩过两种无效突变——
|
|
6
|
+
# ① 改的是 add() 的 severity 参数,而断言比较 code 集合,
|
|
7
|
+
# 改了 oracle 不观测的维度,全绿是假通过;
|
|
8
|
+
# ② 脚本崩溃时也没有 FAIL 行,只看 FAIL 行会把崩溃读成绿。
|
|
9
|
+
# 所以判据同时看退出码与 FAIL 行。
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
12
|
+
work="$(mktemp -d)"; trap 'rm -rf "${work}"' EXIT
|
|
13
|
+
cp -R "${here}" "${work}/scripts"
|
|
14
|
+
cd "${work}/scripts" || exit 1
|
|
15
|
+
|
|
16
|
+
bash test_figure_and_doc_lint.sh >/dev/null 2>&1 || {
|
|
17
|
+
echo "BASELINE-RED: 未突变时套件就是红的,实验无效"; exit 1; }
|
|
18
|
+
echo "baseline: GREEN"
|
|
19
|
+
|
|
20
|
+
# 谓词清单从**源码派生**,不手写:手写清单会漏掉新增谓词,
|
|
21
|
+
# 而漏掉的那条就没有任何覆盖。独立评审实测抓到过——手写的 24 条里
|
|
22
|
+
# 漏了 PARSE / C4-VIEWBOX / C4-EDGE-VAGUE 三条。
|
|
23
|
+
extract_codes() { # <linter-file>
|
|
24
|
+
# 必须覆盖**所有发射路径与所有档位**。踩过两次分母偏小:
|
|
25
|
+
# ① 早先只认 add(...) 与错误元组,漏掉走 findings.append({'code': ...}) 的
|
|
26
|
+
# CONTRACT-MISSING / CONTRACT-INVALID;
|
|
27
|
+
# ② 档位只写了 ERROR|WARN,于是把一条谓词改成 INFO 就让它**静默退出分母**,
|
|
28
|
+
# 总数看起来不变、覆盖却少了一条。档位一律用 [A-Z]+ 匹配,不枚举。
|
|
29
|
+
python3 - "$1" <<'PYEOF'
|
|
30
|
+
import re, sys
|
|
31
|
+
src = open(sys.argv[1], encoding="utf8").read()
|
|
32
|
+
pats = [
|
|
33
|
+
r"""add\(\s*['"][A-Z]+['"]\s*,\s*['"]([A-Z0-9][A-Z0-9-]*)['"]""", # add('<档位>', 'CODE'
|
|
34
|
+
r"""\(\s*['"][A-Z]+['"]\s*,\s*['"]([A-Z0-9][A-Z0-9-]*)['"]""", # ('<档位>', 'CODE'
|
|
35
|
+
r"""['"]code['"]\s*:\s*['"]([A-Z0-9][A-Z0-9-]*)['"]""", # {'code': 'CODE'
|
|
36
|
+
]
|
|
37
|
+
codes = set()
|
|
38
|
+
for pat in pats:
|
|
39
|
+
codes |= set(re.findall(pat, src))
|
|
40
|
+
codes.discard("MUTANT-GONE")
|
|
41
|
+
for c in sorted(codes):
|
|
42
|
+
print(c)
|
|
43
|
+
PYEOF
|
|
44
|
+
}
|
|
45
|
+
codes_fig="$(extract_codes figure-lint.py)"
|
|
46
|
+
codes_doc="$(extract_codes doc-lint.py)"
|
|
47
|
+
echo "codes_from_source: figure=$(echo "${codes_fig}" | wc -w | tr -d ' ') doc=$(echo "${codes_doc}" | wc -w | tr -d ' ')"
|
|
48
|
+
|
|
49
|
+
pass=0; total=0; missed=""
|
|
50
|
+
probe() {
|
|
51
|
+
local file="$1" code="$2"
|
|
52
|
+
total=$((total + 1))
|
|
53
|
+
cp "${file}" "${file}.bak"
|
|
54
|
+
MUT_FILE="${file}" MUT_CODE="${code}" python3 -c '
|
|
55
|
+
import os
|
|
56
|
+
p = os.environ["MUT_FILE"]
|
|
57
|
+
s = open(p, encoding="utf8").read()
|
|
58
|
+
open(p, "w").write(s.replace("'"'"'" + os.environ["MUT_CODE"] + "'"'"'", "'"'"'MUTANT-GONE'"'"'"))
|
|
59
|
+
'
|
|
60
|
+
local out rc
|
|
61
|
+
out="$(bash test_figure_and_doc_lint.sh 2>&1)"; rc=$?
|
|
62
|
+
mv "${file}.bak" "${file}"
|
|
63
|
+
if [ "${rc}" -ne 0 ] && printf '%s' "${out}" | grep -q FAIL; then
|
|
64
|
+
pass=$((pass + 1)); printf ' %-28s RED ok\n' "${code}"
|
|
65
|
+
else
|
|
66
|
+
missed="${missed} ${code}"; printf ' %-28s GREEN NO-COVERAGE\n' "${code}"
|
|
67
|
+
fi
|
|
68
|
+
}
|
|
69
|
+
for c in ${codes_fig}; do probe figure-lint.py "${c}"; done
|
|
70
|
+
for c in ${codes_doc}; do probe doc-lint.py "${c}"; done
|
|
71
|
+
# 派生集合必须覆盖 linter 在自测语料上实际输出过的每一个 code。
|
|
72
|
+
# 不断言的话,一条新增谓词只要用了未被派生正则识别的发射路径,就会静默无覆盖。
|
|
73
|
+
emitted="$(
|
|
74
|
+
{ python3 figure-lint.py tests/svg/*.svg --json 2>/dev/null || true
|
|
75
|
+
python3 doc-lint.py tests/doc/*.md --json 2>/dev/null || true
|
|
76
|
+
} | python3 -c '
|
|
77
|
+
import json, sys
|
|
78
|
+
codes = set()
|
|
79
|
+
for chunk in sys.stdin.read().split("\n{"):
|
|
80
|
+
text = chunk if chunk.startswith("{") else "{" + chunk
|
|
81
|
+
try:
|
|
82
|
+
d = json.loads(text)
|
|
83
|
+
except Exception:
|
|
84
|
+
continue
|
|
85
|
+
for v in d.get("files", d).values(): # doc-lint 的 JSON 顶层就是文件映射,没有 files 键
|
|
86
|
+
codes |= {x["code"] for x in v["findings"]}
|
|
87
|
+
print(" ".join(sorted(codes)))
|
|
88
|
+
')"
|
|
89
|
+
# codes_* 是换行分隔的,必须先规范成单空格分隔再做包含匹配,
|
|
90
|
+
# 否则 case 模式永远匹配不上、把每一条都误报成缺口(本脚本实测踩过)。
|
|
91
|
+
derived=" $(printf '%s %s' "${codes_fig}" "${codes_doc}" | tr '\n' ' ' | tr -s ' ') "
|
|
92
|
+
for c in ${emitted}; do
|
|
93
|
+
case "${derived}" in
|
|
94
|
+
*" ${c} "*) ;;
|
|
95
|
+
*) echo "DERIVATION-GAP: ${c} 实际会被输出,却不在派生清单里——该谓词无突变覆盖"; missed="${missed} ${c}";;
|
|
96
|
+
esac
|
|
97
|
+
done
|
|
98
|
+
echo "differential_sensitivity=${pass}/${total}"
|
|
99
|
+
[ -n "${missed}" ] && { echo "uncovered:${missed}"; exit 1; }
|
|
100
|
+
echo "mutation_probe_ok"
|
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)
|