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:
skillfactor-pipeline
2026-07-09 06:54:09 +02:00
parent 5dfef33f0e
commit 69a7ba6ea7
6 changed files with 6994 additions and 1 deletions

6706
data/v1-lint-results.json Normal file

File diff suppressed because it is too large Load Diff

View 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; ~24 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** — ~816 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).

View File

@@ -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,

View File

@@ -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']}")

View File

@@ -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
View 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()