diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1edd87d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,120 @@ +name: lint + +on: + pull_request: + branches: [main] + paths: + - 'PRISM.md' + - 'scripts/lint/**' + - 'lint_rules.md' + - '.github/workflows/lint.yml' + +permissions: + contents: read + pull-requests: write + +jobs: + lint-required: + name: Required lint (error + warning) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install check-jsonschema pyyaml + + - name: Run named-refs lint (error + warning) + id: named_refs + run: | + python scripts/lint/lint_named_refs.py PRISM.md \ + --severities error,warning > ndjson.required.txt + # Translate NDJSON to GitHub annotations + python - <<'PY' + import json + with open("ndjson.required.txt") as f: + for line in f: + line = line.strip() + if not line: + continue + o = json.loads(line) + level = "error" if o["severity"] == "error" else "warning" + msg = f'{o["rule"]} ({o["alias"]}): {o["message"]}' + print(f'::{level} file={o["file"]},line={o["line"]}::{msg}') + PY + + - name: Validate frontmatter against schema (if schema available) + run: | + # Schema lives in the workshop repo per `design/tooling_conventions_micro_dds_rev1.md` A5. + SCHEMA_URL="https://raw.githubusercontent.com/Ronkupper/PRISM-workshop/main/schemas/prism_frontmatter.schema.json" + # Best-effort: workshop repo is private, so the URL may 404 in PRs + # from forks. Skip gracefully when unavailable; PRISM-LINT-03 is + # gated on Pattern A and the schema being publicly reachable. + if curl -fsSL "$SCHEMA_URL" -o /tmp/schema.json 2>/dev/null; then + # Extract YAML frontmatter (between the first two `---`) to JSON + python - <<'PY' + import json, yaml + with open("PRISM.md") as f: + src = f.read() + parts = src.split("---", 2) + fm = yaml.safe_load(parts[1]) + with open("/tmp/frontmatter.json", "w") as f: + json.dump(fm, f, default=str) + PY + check-jsonschema --schemafile /tmp/schema.json /tmp/frontmatter.json + else + echo "::notice ::frontmatter schema not reachable from CI; PRISM-LINT-03 skipped" + fi + + lint-info: + name: Informational lint (non-blocking) + runs-on: ubuntu-latest + needs: [lint-required] + if: always() + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run named-refs lint (info) + id: info + run: | + python scripts/lint/lint_named_refs.py PRISM.md \ + --severities info > ndjson.info.txt + python - <<'PY' > info_summary.md + import json + rows = [] + with open("ndjson.info.txt") as f: + for line in f: + line = line.strip() + if not line: + continue + rows.append(json.loads(line)) + print("## Informational lint findings\n") + if not rows: + print("_No info-level findings._") + else: + print(f"_{len(rows)} info-level findings (non-blocking)._\n") + print("| Rule | File:Line | Message |") + print("|---|---|---|") + for o in rows[:50]: + msg = o["message"].replace("|", "\\|") + print(f'| `{o["rule"]}` | `{o["file"]}:{o["line"]}` | {msg} |') + if len(rows) > 50: + print(f"\n_…and {len(rows) - 50} more._") + PY + + - name: Post info summary as PR comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: prism-lint-info + path: info_summary.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3789f4..a6cd62a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,10 @@ Typos, broken links, formatting bugs, obvious grammar issues — open a PR direc - Proposals that hardcode specific LLM vendor names into `PRISM.md` — vendor capabilities drift too fast for that to age well (see declined item "Named-vendor recommendations" in the backlog) - Large rewrites without prior discussion — they're expensive to review and rarely merge clean +## Reviewing PRs — rendered-diff convention + +When reviewing PRs that change `PRISM.md` or `lens/` content, default to the GitHub **Rendered** diff view rather than the raw Markdown diff. Raw diffs are hard to read for table cells, heading-level shifts, fenced-block boundaries, and decision-tag tokens; rendered diffs show what an operator actually sees on a phone or in a Claude chat. The convention applies to maintainers and contributors alike. Lint rules in [`lint_rules.md`](./lint_rules.md) catch what tooling can verify mechanically (named-references resolution, frontmatter shape, version-string consistency); the rendered diff covers the judgment surface that lint does not. + ## Code of conduct Participation is governed by the [Code of Conduct](./CODE_OF_CONDUCT.md). By contributing, you agree to uphold it. diff --git a/lint_rules.md b/lint_rules.md new file mode 100644 index 0000000..1e06a32 --- /dev/null +++ b/lint_rules.md @@ -0,0 +1,130 @@ +# PRISM lint catalog + +**Catalog version:** 1 + +This file is the contributor-facing reference catalog of lint rules +enforced against `PRISM.md` by the workflow at `.github/workflows/lint.yml`. +Each rule has a stable ID (`PRISM-LINT-NN`), a stable alias, a severity, +and a one-paragraph description. + +## Preamble — when a constraint lives here vs. in `PRISM.md` + +The PRISM lint catalog enforces what tooling can verify mechanically. +Where a constraint requires operator judgment, it lives in `PRISM.md` +prose; where it can be checked deterministically, the catalog is the +canonical statement. + +Lint rules and prose injunctions are mutually exclusive expressions of +the same intent: the catalog grows when prose injunctions become +mechanically checkable, and prose loses redundant restatements when +their lint rule lands. This placement principle sits one level below +PRISM's earn-your-seat filter — it is a *placement* rule, not a *content* +rule. The catalog is intentionally small and slow-growing. + +## Severity tiers + +- **error** — blocks merge in CI. Structural integrity is broken if this + fires (refs don't resolve; schema invalid; versions mismatch). +- **warning** — surfaces in CI annotations; does not block merge. Drift + signal that needs attention but is not necessarily wrong. +- **info** — surfaces only when explicitly requested (e.g., a separate + workflow job); does not annotate PRs by default. Hygiene observations. + +## Catalog + +### `PRISM-LINT-01` / `named-refs-resolve` — error + +Every `§{namespace.slug}` cross-reference in `PRISM.md` resolves to a +defined anchor. Implementation also surfaces three error-class structural +issues under this rule because they are all forms of named-reference +resolution failure: unresolved refs (broken-ref), duplicate anchor slugs +in the same namespace (slug-collision), and bare numeric refs outside the +`DD.` namespace (mixed-ref-style, post-migration residue). + +Implemented by `scripts/lint/lint_named_refs.py`. + +### `PRISM-LINT-02` / `named-refs-orphan-anchor` — info + +A `` anchor is defined in `PRISM.md` but no +`§{ns.slug}` reference resolves to it. Many appendix subsection anchors +are deliberately defined as index entries without inbound prose refs; +that pattern is acceptable and produces info-level findings rather than +warnings. + +Implemented by `scripts/lint/lint_named_refs.py`. + +### `PRISM-LINT-03` / `frontmatter-schema-valid` — error *(reserved — gated on Pattern A)* + +`PRISM.md`'s YAML frontmatter validates against +`PRISM-workshop/schemas/prism_frontmatter.schema.json` via the Python +`check-jsonschema` validator. Pattern A's frontmatter shape (machine- +readable framework metadata: `version`, `released`, `supersedes`, +`lens_library_embedded`, `substrate_target`, `normativity`, +`lint_catalog_version`) is the validation target. + +**Status at catalog v1:** reserved slot. Activates when Pattern A ships. + +### `PRISM-LINT-04` / `version-title-match` — error *(reserved — gated on Pattern A)* + +The version string in the title block (`# PRISM v{X.Y.Z} — Framework +operating document`) matches the frontmatter `version` field. Mismatch is +a CI-blocking error because the title-block is the human-readable mirror +of the canonical machine-readable `version`. + +**Status at catalog v1:** reserved slot. Activates when Pattern A ships. + +### `PRISM-LINT-05` / `lens-library-version-match` — warning *(reserved — gated on Pattern A)* + +Frontmatter `lens_library_embedded` matches the embedded Lens Library's +own `Version:` line inside Appendix G. Warning rather than error because +in-between Lens-Library bumps and PRISM-MINOR bumps create transient +drift that the framework handles via M2 (Version Drift) at runtime +rather than at lint time. + +**Status at catalog v1:** reserved slot. Activates when Pattern A ships. + +### `PRISM-LINT-06` / `element-marking-completeness` — info *(reserved — gated on Pattern B Phase B1)* + +Every Standing Principle, Monitor, Gate, and Probe heading in `PRISM.md` +carries a parseable decision-tag block (`[durability | review-trigger]` +plus optional strength and polarity tokens). Info-severity initially +because Pattern B's Phase B1 ships frontmatter and new-element shape +only; legacy elements remain unmarked until Phase B2 completes the +sweep. Severity may promote to warning after Phase B2 ships. + +**Status at catalog v1:** reserved slot. Activates when Pattern B Phase +B1 ships. + +### `PRISM-LINT-07` / `description-version-match` — error *(reserved — gated on Pattern A)* + +The substring `Currently v{X.Y.Z}` inside frontmatter `description` +matches frontmatter `version`. Closes the third location in `PRISM.md` +where a version string lives (the others: frontmatter `version` and the +title-block heading — both checked by `PRISM-LINT-04`). Skill-loader +description copy is operator-facing and version-bound. + +**Status at catalog v1:** reserved slot. Activates when Pattern A ships. + +## Output format + +All lint scripts emit NDJSON (newline-delimited JSON), one violation per +line: + +```json +{"rule": "PRISM-LINT-01", "alias": "named-refs-resolve", "severity": "error", "file": "PRISM.md", "line": 1234, "message": "Reference §{section.foo} does not resolve", "context": ""} +``` + +The workflow at `.github/workflows/lint.yml` translates NDJSON into +GitHub's `::error file=...,line=...::message` and `::warning ...::` +annotations. + +## Catalog versioning + +`lint_catalog_version` in `PRISM.md` frontmatter is a monotonic integer +(1, 2, 3, …); not semver. The catalog isn't a public API with consumers +beyond CI. The integer bumps when the catalog's surface changes +meaningfully (rule added; severity changed; rule removed). + +| Catalog version | Active rules | Reserved rules | +|---|---|---| +| 1 | `LINT-01`, `LINT-02` | `LINT-03`, `LINT-04`, `LINT-05`, `LINT-06`, `LINT-07` | diff --git a/scripts/lint/lint_named_refs.py b/scripts/lint/lint_named_refs.py new file mode 100755 index 0000000..b27c94a --- /dev/null +++ b/scripts/lint/lint_named_refs.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +PRISM named-references linter. + +Emits NDJSON findings (one violation per line) under the public lint +catalog rule IDs: + + PRISM-LINT-01 named-refs-resolve (error) + PRISM-LINT-02 named-refs-orphan-anchor (info) + +PRISM-LINT-01 consolidates three error-class structural failure modes +under a single rule per the catalog at `lint_rules.md`: + + - broken-ref — `§{ns.slug}` does not resolve to a defined anchor + - slug-collision — duplicate anchor slugs in the same namespace + - mixed-ref-style — bare numeric ref `§X.Y` outside the `DD.` namespace + +The internal sub-mode is preserved in the `detail` field of each finding +for diagnosability; the outward rule ID is the catalog ID. + +Usage: + python lint_named_refs.py [--severities error,warning,info] + [--text] + +Default output is NDJSON. `--text` switches to a human-readable line +format. Exit code 0 if no error-severity findings, 1 otherwise. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from typing import Optional + + +# --------------------------------------------------------------------------- +# Heading / anchor / ref regexes +# --------------------------------------------------------------------------- + +RE_SECTION_HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+){0,2})\.?\s+(.+?)\s*$") +RE_APPENDIX_TOP = re.compile(r"^(##)\s+Appendix\s+([A-Z])\s+[—–-]\s+(.+?)\s*$") +RE_APPENDIX_SUB = re.compile(r"^(#{3,4})\s+([A-Z])\.(\d+(?:\.\d+)*)\s+(.+?)\s*$") +RE_APPENDIX_F_SP = re.compile(r"^(#{3,4})\s+(SP-\d+)\b\s*(.*)$") +RE_ANCHOR = re.compile(r'^\s*$') + +RE_NAMED_REF = re.compile(r"§\{(?P[a-z]+)\.(?P[A-Za-z0-9.-]+)\}") +RE_NUMERIC_REF_OUTSIDE_DD = re.compile(r"(? str: + s = RE_EMPHASIS.sub(r"\1", s) + s = s.replace("'", "").replace("\u2019", "") + s = s.lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def derive_slug(raw: str) -> str: + no_tag = RE_STABILITY_TAG.sub("", raw) + slug = _slugify(no_tag) + if slug: + return slug + return _slugify(raw) + + +# --------------------------------------------------------------------------- +# Finding / Index data structures +# --------------------------------------------------------------------------- + +@dataclass +class Finding: + rule: str # "PRISM-LINT-01" | "PRISM-LINT-02" + alias: str # "named-refs-resolve" | "named-refs-orphan-anchor" + severity: str # "error" | "warning" | "info" + file: str + line: int + message: str + context: str = "" + + +@dataclass +class Index: + anchors: dict[str, set[str]] = field(default_factory=dict) + section_keys: dict[str, str] = field(default_factory=dict) + appendix_keys: dict[str, str] = field(default_factory=dict) + id_inventory: dict[str, set[str]] = field(default_factory=dict) + definitions: dict[tuple[str, str], int] = field(default_factory=dict) + references: dict[tuple[str, str], list[int]] = field(default_factory=dict) + collisions: list[Finding] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Index builder +# --------------------------------------------------------------------------- + +def build_index(lines: list[str], path: str) -> Index: + idx = Index() + idx.id_inventory = { + "principle": set(), + "monitor": set(), + "probe": set(), + "gate": set(), + "lens": set(), + } + + in_fence = False + in_appendix_f = False + + for i, line in enumerate(lines): + line_no = i + 1 + if re.match(r"^```", line): + in_fence = not in_fence + continue + if in_fence: + ym = re.match(r"^\s*-?\s*id:\s*(LL-[UD]-\d{3})\s*$", line) + if ym: + idx.id_inventory["lens"].add(ym.group(1)) + continue + + am = RE_APPENDIX_TOP.match(line) + if am: + letter = am.group(2) + title = am.group(3).strip() + slug = derive_slug(title) + if letter in idx.appendix_keys: + idx.collisions.append(Finding( + rule="PRISM-LINT-01", alias="named-refs-resolve", + severity="error", file=path, line=line_no, + message=f"duplicate appendix letter {letter}", + context="slug-collision", + )) + idx.appendix_keys[letter] = slug + idx.definitions.setdefault(("appendix", slug), line_no) + in_appendix_f = (letter == "F") + continue + + asub = RE_APPENDIX_SUB.match(line) + if asub: + letter = asub.group(2) + num = asub.group(3) + title = asub.group(4).strip() + slug = derive_slug(title) + key = f"{letter}.{num}" + idx.appendix_keys[key] = slug + idx.definitions.setdefault(("appendix", slug), line_no) + continue + + if in_appendix_f: + fm = RE_APPENDIX_F_SP.match(line) + if fm: + sp_id = fm.group(2) + idx.id_inventory["principle"].add(sp_id) + idx.definitions.setdefault(("principle", sp_id), line_no) + continue + + sm = RE_SECTION_HEADING.match(line) + if sm: + num = sm.group(2) + title = sm.group(3).strip() + slug = derive_slug(title) + idx.section_keys[num] = slug + idx.definitions.setdefault(("section", slug), line_no) + in_appendix_f = False + for m in re.finditer(r"\b(M\d+|GATE-\d+|P\d+(?:\.\d+)?|SP-\d+)\b", title): + tok = m.group(1) + if tok.startswith("SP-"): + idx.id_inventory["principle"].add(tok) + elif tok.startswith("GATE-"): + idx.id_inventory["gate"].add(tok) + elif tok.startswith("M"): + idx.id_inventory["monitor"].add(tok) + elif tok.startswith("P"): + idx.id_inventory["probe"].add(tok) + for m in re.finditer(r"\bProbe\s+(\d+)\b", title): + idx.id_inventory["probe"].add(f"P{m.group(1)}") + continue + + an = RE_ANCHOR.match(line) + if an: + ns, slug = an.group(1), an.group(2) + idx.anchors.setdefault(ns, set()).add(slug) + continue + + return idx + + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + +def check_named_refs(lines: list[str], idx: Index, path: str) -> list[Finding]: + findings: list[Finding] = [] + in_fence = False + for i, line in enumerate(lines): + if re.match(r"^```", line): + in_fence = not in_fence + continue + if in_fence: + continue + if re.match(r"^#{1,6}\s", line): + continue + for m in RE_NAMED_REF.finditer(line): + ns = m.group("ns") + slug = m.group("slug") + if ns not in VALID_NAMESPACES: + findings.append(Finding( + rule="PRISM-LINT-01", alias="named-refs-resolve", + severity="error", file=path, line=i + 1, + message=f"unknown namespace '{ns}' in §{{{ns}.{slug}}}", + context="broken-ref", + )) + continue + if ns in ("section", "appendix"): + anchors = idx.anchors.get(ns, set()) + if slug not in anchors: + findings.append(Finding( + rule="PRISM-LINT-01", alias="named-refs-resolve", + severity="error", file=path, line=i + 1, + message=f"unresolved §{{{ns}.{slug}}}", + context="broken-ref", + )) + else: + idx.references.setdefault((ns, slug), []).append(i + 1) + else: + inv = idx.id_inventory.get(ns, set()) + resolved = slug in inv + if not resolved and ns == "probe" and "." in slug: + parent = slug.split(".", 1)[0] + if parent in inv: + resolved = True + if not resolved: + findings.append(Finding( + rule="PRISM-LINT-01", alias="named-refs-resolve", + severity="error", file=path, line=i + 1, + message=f"unresolved §{{{ns}.{slug}}}", + context="broken-ref", + )) + else: + idx.references.setdefault((ns, slug), []).append(i + 1) + return findings + + +def check_mixed_style(lines: list[str], path: str) -> list[Finding]: + findings: list[Finding] = [] + in_fence = False + for i, line in enumerate(lines): + if re.match(r"^```", line): + in_fence = not in_fence + continue + if in_fence: + continue + if re.match(r"^#{1,6}\s", line): + continue + for m in RE_NUMERIC_REF_OUTSIDE_DD.finditer(line): + findings.append(Finding( + rule="PRISM-LINT-01", alias="named-refs-resolve", + severity="error", file=path, line=i + 1, + message=f"bare numeric ref §{m.group(1)} (use named form)", + context="mixed-ref-style", + )) + return findings + + +def check_collisions(idx: Index, path: str) -> list[Finding]: + findings: list[Finding] = list(idx.collisions) + by_ns: dict[str, dict[str, list[int]]] = {} + for (ns, slug), line in idx.definitions.items(): + by_ns.setdefault(ns, {}).setdefault(slug, []).append(line) + for ns, slugs in by_ns.items(): + for slug, lns in slugs.items(): + if len(lns) > 1: + findings.append(Finding( + rule="PRISM-LINT-01", alias="named-refs-resolve", + severity="error", file=path, line=lns[0], + message=f"slug collision ns={ns} slug={slug} lines={lns}", + context="slug-collision", + )) + return findings + + +def check_orphans(idx: Index, path: str) -> list[Finding]: + findings: list[Finding] = [] + for (ns, slug), line in idx.definitions.items(): + if ns not in ("section", "appendix"): + continue + if (ns, slug) in idx.references: + continue + findings.append(Finding( + rule="PRISM-LINT-02", alias="named-refs-orphan-anchor", + severity="info", file=path, line=line, + message=f"orphan {ns} '{slug}' (never referenced)", + context="orphan-anchor", + )) + return findings + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Lint PRISM.md named cross-references.") + ap.add_argument("path", help="Path to PRISM.md") + ap.add_argument( + "--severities", + default="error,warning,info", + help="Comma-separated severities to emit (default: all).", + ) + ap.add_argument( + "--text", + action="store_true", + help="Emit human-readable text rather than NDJSON.", + ) + args = ap.parse_args(argv) + + with open(args.path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + + idx = build_index(lines, args.path) + findings: list[Finding] = [] + findings.extend(check_named_refs(lines, idx, args.path)) + findings.extend(check_mixed_style(lines, args.path)) + findings.extend(check_collisions(idx, args.path)) + findings.extend(check_orphans(idx, args.path)) + + findings.sort(key=lambda f_: (f_.rule, f_.line)) + + sev_filter = {s.strip() for s in args.severities.split(",") if s.strip()} + emitted = [f_ for f_ in findings if f_.severity in sev_filter] + + if args.text: + for f_ in emitted: + print(f"{f_.rule} {f_.severity} {f_.file}:{f_.line} {f_.message}" + + (f" [{f_.context}]" if f_.context else "")) + else: + for f_ in emitted: + print(json.dumps(asdict(f_), ensure_ascii=False)) + + err = sum(1 for f_ in findings if f_.severity == "error") + info = sum(1 for f_ in findings if f_.severity == "info") + print(f"Summary: {err} error, {info} info", file=sys.stderr) + + return 1 if err else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))