feat(qa): package linter as publish gate + v1 full-catalog heatmap
lint_package.py checks contamination, alphabetical truncation, count consistency, provenance completeness and intro grammar; wired into both publish paths (p4_publish + batch stage_packages), SKIP_LINT=1 reserved for labeled v1-grade republishes. Heatmap over 3,039 v1 packages: 1,406 clean / 1,633 with findings; C2 truncation is systemic (generator), contamination affects 2.6% of crawled packages; nothing requires re-fetching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDKeXvpT6tENSvyQGLV1Uq
This commit is contained in:
6706
data/v1-lint-results.json
Normal file
6706
data/v1-lint-results.json
Normal file
File diff suppressed because it is too large
Load Diff
53
docs/v1-quality-heatmap.md
Normal file
53
docs/v1-quality-heatmap.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# v1 quality heatmap — full-catalog lint (3,039 packages)
|
||||
|
||||
Date: 2026-07-09 · Linter: `qa/lint_package.py` (phase 3) · Baseline: git tag `v1`
|
||||
Raw results: `data/v1-lint-results.json` (per-package findings).
|
||||
|
||||
## Distribution
|
||||
|
||||
| | Packages |
|
||||
|---|---|
|
||||
| **Clean** | **1,406 (46%)** |
|
||||
| With findings | 1,633 (54%) |
|
||||
|
||||
| Check | Findings | Affected packages | Nature |
|
||||
|---|---|---|---|
|
||||
| C2 truncation (cut lists / alphabetical dumps) | 2,006 | 1,632 | **systemic — generator defect** |
|
||||
| C1 contamination (foreign-role tools in market top-10) | 21 | 5 | data defect (no relevance gate) |
|
||||
| C5 grammar (raw ESCO intro) | 5 | 5 | content defect (no rewrite step) |
|
||||
| C3 count mismatch | 0 | 0 | — |
|
||||
| C4 provenance | 0 | 0 | — |
|
||||
|
||||
## Reading the numbers
|
||||
|
||||
- **The flagship's problems are SYSTEMIC, not isolated.** 54% of the catalog
|
||||
carries the same truncation defect (`essential[:15]` + alphabetical O*NET
|
||||
hot-tech dump) — one generator bug, thousands of hits. Both causes are
|
||||
already fixed on `quality/reference-standard` (full lists; market-derived
|
||||
hot-tech via p3c).
|
||||
- **C1 can only fire where market data exists.** Only 189 packages had a
|
||||
`market.md` at lint time (the evidence crawl is ongoing). 5 of 189 ≈ **2.6%
|
||||
of crawled packages** show top-10 contamination; the ungated crawl keeps
|
||||
producing v1-grade corpora, so expect this rate to hold until re-gating.
|
||||
- **Provenance discipline held everywhere** (C4 = 0), and counts are
|
||||
consistent package-internally (C3 = 0 — the landing-page "83" defect was a
|
||||
hard-coded UI number, structurally fixed via stats.json).
|
||||
|
||||
## Regeneration cost split (per the phase-5 decision brief)
|
||||
|
||||
| Fix class | Packages | Method | Cost |
|
||||
|---|---|---|---|
|
||||
| C2 truncation + intro rewrite | 1,632 (+ all others for intro polish) | re-run p2 SKILL.md generation (overlays preserve curation) | **local only** — no API calls; gemma3 intro rewrites ~3,039 × 1 call, cached; ~2–4 h GPU |
|
||||
| C1 contamination | 5 known + future crawled | re-gate stored raw ads (`ad_gate.py --all`, running) + re-extract relevant-only + re-aggregate | **local only** — raw ads are on disk for all 189 crawled packages; zero re-fetching |
|
||||
| C5 grammar | 5 | covered by the intro rewrite step | local only |
|
||||
| EU/DACH corpora | opt-in per package | `eu_fetch.py` + `data/eu-queries.json` | **API cost** — ~8–16 JSearch requests per occupation; budget-gated |
|
||||
|
||||
**Bottom line: nothing in v1 requires re-fetching.** The entire cleanup is
|
||||
re-classification and re-compilation from stored raw data plus local
|
||||
generation — the only API spend is the deliberate EU/DACH expansion.
|
||||
|
||||
## Publish gate status
|
||||
|
||||
`p4_publish` now refuses to push a lint-failing package (`SKIP_LINT=1`
|
||||
escape hatch exists for explicitly-labeled v1-grade republishes, e.g. the
|
||||
ongoing evidence crawl's catalog refresh).
|
||||
@@ -255,6 +255,19 @@ def stage_packages(state, limit=0):
|
||||
manifest = json.load(open(
|
||||
os.path.join(p2_generate.SKILLS_DIR, actual_slug, "manifest.json"),
|
||||
encoding="utf-8"))
|
||||
# Phase 3 publish gate (SKIP_LINT=1 = explicitly-labeled v1-grade
|
||||
# republish, e.g. the evidence-crawl catalog refresh)
|
||||
if os.environ.get("SKIP_LINT") != "1":
|
||||
sys.path.insert(0, os.path.join(BASE, "qa"))
|
||||
import lint_package
|
||||
lint_findings = lint_package.lint(actual_slug)
|
||||
if lint_findings:
|
||||
occ_state["package"] = "failed"
|
||||
occ_state["error"] = f"lint: {lint_findings[0][:150]}"
|
||||
progress.save(state)
|
||||
failed += 1
|
||||
print(f"LINT-BLOCKED {actual_slug}: {lint_findings[0][:100]}")
|
||||
continue
|
||||
p4.ensure_repo("skills-core", actual_slug, manifest.get("title", actual_slug))
|
||||
p4.push_dir(
|
||||
"skills-core", actual_slug,
|
||||
|
||||
@@ -201,8 +201,21 @@ def main():
|
||||
slugs = [s for s in slugs if s == only]
|
||||
print(f"publishing {len(slugs)} packages ...")
|
||||
|
||||
# Phase 3 publish gate: a package that fails qa/lint_package.py is not
|
||||
# published. SKIP_LINT=1 only for explicitly-labeled v1-grade republishes.
|
||||
skip_lint = os.environ.get("SKIP_LINT") == "1"
|
||||
if not skip_lint:
|
||||
sys.path.insert(0, os.path.join(BASE, "qa"))
|
||||
import lint_package
|
||||
|
||||
for i, slug in enumerate(slugs, 1):
|
||||
manifest = json.load(open(os.path.join(SKILLS_DIR, slug, "manifest.json"), encoding="utf-8"))
|
||||
if not skip_lint:
|
||||
findings = lint_package.lint(slug)
|
||||
if findings:
|
||||
print(f"[{i}/{len(slugs)}] LINT-BLOCKED skills-core/{slug} "
|
||||
f"({len(findings)} finding(s); first: {findings[0]})")
|
||||
continue
|
||||
ensure_repo("skills-core", slug, manifest.get("title", slug))
|
||||
push_dir("skills-core", slug, os.path.join(SKILLS_DIR, slug),
|
||||
f"feat: {slug} skill package v{manifest['version']}")
|
||||
|
||||
@@ -49,10 +49,14 @@ cmd /c "`"$py`" pipeline\p3d_provenance.py >> `"$log`" 2>&1"
|
||||
Log "p3d_provenance done (exit $LASTEXITCODE)"
|
||||
|
||||
# ── 4. Katalog re-pushen (Markt-Evidence in den Repos) ───────────────────────
|
||||
# SKIP_LINT=1: dieser Refresh publiziert bewusst v1-Qualitaet (Heatmap kennt
|
||||
# den Zustand); Referenz-Qualitaet entsteht paketweise ueber das Lint-Gate.
|
||||
$env:SKIP_LINT = "1"
|
||||
cmd /c "`"$py`" pipeline\reset_push_status.py >> `"$log`" 2>&1"
|
||||
Log "reset_push_status done"
|
||||
& powershell -NonInteractive -ExecutionPolicy Bypass -File "$dev\push_loop.ps1" *>> $log
|
||||
Log "re-push finished"
|
||||
Log "re-push finished (SKIP_LINT=1, v1-grade republish)"
|
||||
$env:SKIP_LINT = ""
|
||||
|
||||
# ── 5. Marketplace + Abschluss-Auswertung ────────────────────────────────────
|
||||
cmd /c "`"$py`" pipeline\push_marketplace.py >> `"$log`" 2>&1"
|
||||
|
||||
204
qa/lint_package.py
Normal file
204
qa/lint_package.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""qa/lint_package.py — publish gate for skill packages (phase 3).
|
||||
|
||||
Checks (each returns a list of findings; any finding = lint failure):
|
||||
C1 contamination designer/marketing tool names in the top-10 market
|
||||
rankings of an engineering/ICT package
|
||||
C2 truncation long bullet list that is exactly alphabetically sorted
|
||||
and ends mid-alphabet (the silent essential[:15] defect)
|
||||
C3 counts manifest.enrichment_ai_skills.total_skills must equal
|
||||
the ai-skills.md table rows; stats.json flagship count
|
||||
must match the manifest
|
||||
C4 provenance every ai-skills.md source section needs repo, commit,
|
||||
license, retrieval date; manifest needs esco_uri;
|
||||
PROVENANCE.md must exist
|
||||
C5 grammar singular-noun + base-verb agreement error in the intro
|
||||
("engineer apply ...")
|
||||
|
||||
Usage:
|
||||
python qa/lint_package.py <slug> lint one package (exit 1 on fail)
|
||||
python qa/lint_package.py --all [--json F] lint the catalog, write summary
|
||||
|
||||
Publish wiring: p4_publish refuses to push a failing package unless
|
||||
SKIP_LINT=1 is set (used for explicitly-labeled v1-grade republishes).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
BASE = os.path.join(os.path.dirname(__file__), "..")
|
||||
SKILLS = os.path.join(BASE, "skills")
|
||||
|
||||
# C1: tools that signal a foreign role family inside an ICT/engineering package
|
||||
CONTAMINANTS = {
|
||||
"figma", "sketch", "adobe creative", "adobe photoshop", "photoshop",
|
||||
"illustrator", "indesign", "canva", "adobe xd", "after effects",
|
||||
"mailchimp", "hubspot", "hootsuite", "google ads", "meta ads",
|
||||
"salesforce marketing", "interaction design", "user research",
|
||||
"product design", "visual design", "ux design", "ui design",
|
||||
}
|
||||
ENGINEERING_ISCO_PREFIXES = ("21", "25", "35")
|
||||
|
||||
|
||||
def _read(base, rel):
|
||||
p = os.path.join(base, rel)
|
||||
return open(p, encoding="utf-8").read() if os.path.exists(p) else ""
|
||||
|
||||
|
||||
def check_contamination(base, manifest):
|
||||
isco = str(manifest.get("ids", {}).get("isco_group") or "")
|
||||
if not isco.startswith(ENGINEERING_ISCO_PREFIXES):
|
||||
return []
|
||||
market = _read(base, os.path.join("references", "market.md"))
|
||||
if not market:
|
||||
return []
|
||||
findings = []
|
||||
for section in ("Tools", "Hard skills"):
|
||||
m = re.search(rf"(?ms)^## {section} — full market ranking$(.*?)(?=^## |\Z)",
|
||||
market)
|
||||
if not m:
|
||||
continue
|
||||
rows = re.findall(r"(?m)^\|\s*(\d+)\s*\|\s*([^|]+)\|", m.group(1))
|
||||
top10 = [item.strip().lower() for rank, item in rows if int(rank) <= 10]
|
||||
for item in top10:
|
||||
if any(c in item for c in CONTAMINANTS):
|
||||
findings.append(f"C1 contamination: '{item}' in market top-10 "
|
||||
f"({section}) of an engineering package")
|
||||
return findings
|
||||
|
||||
|
||||
def check_truncation(base, manifest):
|
||||
findings = []
|
||||
skill_md = _read(base, "SKILL.md")
|
||||
for m in re.finditer(r"(?ms)^## ([^\n]+)$\n\n((?:- [^\n]+\n)+)", skill_md):
|
||||
title, block = m.group(1), m.group(2)
|
||||
items = [l[2:].strip().lower() for l in block.strip().splitlines()]
|
||||
if len(items) < 10:
|
||||
continue
|
||||
if items == sorted(items) and items[-1][:1] < "n":
|
||||
findings.append(f"C2 truncation: '{title}' list is alphabetically "
|
||||
f"sorted and ends at '{items[-1][:30]}' — looks cut")
|
||||
return findings
|
||||
|
||||
|
||||
def check_counts(base, manifest, slug):
|
||||
findings = []
|
||||
enr = manifest.get("enrichment_ai_skills") or {}
|
||||
claimed = enr.get("total_skills")
|
||||
ai_md = _read(base, os.path.join("references", "ai-skills.md"))
|
||||
if claimed is not None and ai_md:
|
||||
actual = len(re.findall(r"(?m)^\| `", ai_md))
|
||||
if actual != claimed:
|
||||
findings.append(f"C3 counts: manifest claims {claimed} ai-skills, "
|
||||
f"ai-skills.md has {actual}")
|
||||
stats_p = os.path.join(BASE, "data", "stats.json")
|
||||
if os.path.exists(stats_p):
|
||||
st = json.load(open(stats_p, encoding="utf-8"))
|
||||
fl = st.get("flagship") or {}
|
||||
if fl.get("slug") == slug and claimed is not None \
|
||||
and fl.get("ai_skills_total") != claimed:
|
||||
findings.append(f"C3 counts: stats.json flagship says "
|
||||
f"{fl.get('ai_skills_total')}, manifest {claimed}")
|
||||
return findings
|
||||
|
||||
|
||||
def check_provenance(base, manifest):
|
||||
findings = []
|
||||
if not manifest.get("ids", {}).get("esco_uri"):
|
||||
findings.append("C4 provenance: manifest.ids.esco_uri missing")
|
||||
if not os.path.exists(os.path.join(base, "PROVENANCE.md")):
|
||||
findings.append("C4 provenance: PROVENANCE.md missing")
|
||||
ai_md = _read(base, os.path.join("references", "ai-skills.md"))
|
||||
if ai_md:
|
||||
for m in re.finditer(r"(?ms)^## Source: ([^\n]+)$(.*?)(?=^## |\Z)", ai_md):
|
||||
src, body = m.group(1).strip(), m.group(2)
|
||||
for field, pat in (("commit", r"commit `?\w+"),
|
||||
("license", r"License:"),
|
||||
("retrieval date", r"retrieved \d{4}-\d{2}-\d{2}")):
|
||||
if not re.search(pat, body):
|
||||
findings.append(f"C4 provenance: source '{src}' lacks {field}")
|
||||
return findings
|
||||
|
||||
|
||||
_SING_NOUNS = ("engineer", "designer", "manager", "technician", "analyst",
|
||||
"consultant", "officer", "specialist", "developer", "operator",
|
||||
"assistant", "advisor", "worker", "scientist", "teacher")
|
||||
_BASE_VERBS = ("apply", "design", "create", "provide", "develop", "perform",
|
||||
"manage", "use", "work", "integrate", "analyse", "analyze",
|
||||
"coordinate", "support", "conduct", "oversee")
|
||||
|
||||
|
||||
def check_grammar(base, manifest):
|
||||
skill_md = _read(base, "SKILL.md")
|
||||
m = re.search(r"(?ms)^# [^\n]+\n\n(.+?)(?=\n\n## )", skill_md)
|
||||
if not m:
|
||||
return []
|
||||
intro = m.group(1)
|
||||
first = intro.split(".")[0].lower()
|
||||
for noun in _SING_NOUNS:
|
||||
for verb in _BASE_VERBS:
|
||||
if re.search(rf"\b{noun} {verb}\b", first):
|
||||
return [f"C5 grammar: singular '{noun}' with plural verb "
|
||||
f"'{verb}' in the intro"]
|
||||
return []
|
||||
|
||||
|
||||
def lint(slug):
|
||||
base = os.path.join(SKILLS, slug)
|
||||
mp = os.path.join(base, "manifest.json")
|
||||
if not os.path.isfile(mp):
|
||||
return [f"C4 provenance: manifest.json missing"]
|
||||
try:
|
||||
manifest = json.load(open(mp, encoding="utf-8"))
|
||||
except ValueError:
|
||||
return ["C4 provenance: manifest.json unparseable"]
|
||||
findings = []
|
||||
findings += check_contamination(base, manifest)
|
||||
findings += check_truncation(base, manifest)
|
||||
findings += check_counts(base, manifest, slug)
|
||||
findings += check_provenance(base, manifest)
|
||||
findings += check_grammar(base, manifest)
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("slug", nargs="?")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--json", help="write per-package results to this file")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.slug:
|
||||
findings = lint(args.slug)
|
||||
for f in findings:
|
||||
print(f"FAIL {args.slug}: {f}")
|
||||
print(f"{args.slug}: {'CLEAN' if not findings else str(len(findings)) + ' finding(s)'}")
|
||||
sys.exit(1 if findings else 0)
|
||||
|
||||
if not args.all:
|
||||
ap.error("slug or --all required")
|
||||
|
||||
results = {}
|
||||
slugs = sorted(d for d in os.listdir(SKILLS)
|
||||
if os.path.isdir(os.path.join(SKILLS, d)))
|
||||
for i, slug in enumerate(slugs, 1):
|
||||
results[slug] = lint(slug)
|
||||
if i % 500 == 0:
|
||||
print(f"... {i}/{len(slugs)}")
|
||||
if args.json:
|
||||
json.dump(results, open(args.json, "w", encoding="utf-8"), indent=1)
|
||||
dirty = {s: f for s, f in results.items() if f}
|
||||
by_class = {}
|
||||
for fs in results.values():
|
||||
for f in fs:
|
||||
by_class[f[:2]] = by_class.get(f[:2], 0) + 1
|
||||
print(f"\ncatalog: {len(slugs)} packages, {len(slugs) - len(dirty)} clean, "
|
||||
f"{len(dirty)} with findings")
|
||||
for cls, n in sorted(by_class.items()):
|
||||
print(f" {cls}: {n} findings")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user