From b70fef1bba6466465f80c465c08ebe9a0e7e3ba7 Mon Sep 17 00:00:00 2001
From: Ron Kuper <33256475+Ronkupper@users.noreply.github.com>
Date: Fri, 22 May 2026 17:51:53 +0000
Subject: [PATCH] lint-v1: ship Pattern C lint catalog (off-cycle reference
artifacts)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
No PRISM.md content change. Closes Pattern C per
PRISM-workshop/design/tooling_conventions_micro_dds_rev1.md decisions
C1-C8.
Artifacts:
- lint_rules.md (catalog v1) — contributor-facing catalog with seven
entries: two active (PRISM-LINT-01 named-refs-resolve error;
PRISM-LINT-02 named-refs-orphan-anchor info), five reserved
(PRISM-LINT-03/04/05/07 gated on Pattern A; PRISM-LINT-06 gated on
Pattern B Phase B1 — both now active as of v2.1.0).
- scripts/lint/lint_named_refs.py — Python lint script. NDJSON output
by default; --text flag for human-readable; --severities flag for
selective emission. Consolidates broken-ref / slug-collision /
mixed-ref-style under PRISM-LINT-01; emits PRISM-LINT-02 for orphan
anchors. Self-contained (slug-derivation inlined; no workshop-script
dependency).
- .github/workflows/lint.yml — PR-only workflow with two jobs:
lint-required (error + warning; translates NDJSON to GitHub
annotations; best-effort schema validation against workshop-hosted
prism_frontmatter.schema.json) and lint-info (info; posts NDJSON
summary as sticky PR comment).
- CONTRIBUTING.md — new section 'Reviewing PRs — rendered-diff
convention' per C7.
Catalog rename in NDJSON output: the four workshop PRISM-REF-NN IDs
(REF-01/02/03 error; REF-04 info) consolidate to PRISM-LINT-01 (all
three error-class structural failures) and PRISM-LINT-02 (orphan).
Internal sub-mode preserved in NDJSON 'context' field for diagnosability.
Workshop's scripts/lint_named_refs.py retires immediately per C5
(handled in a separate workshop-side commit). Migration scripts in
workshop stay (one-shot, different maintenance cadence).
Tagging: lint-v1 after merge.
---
.github/workflows/lint.yml | 120 +++++++++++
CONTRIBUTING.md | 4 +
lint_rules.md | 130 ++++++++++++
scripts/lint/lint_named_refs.py | 362 ++++++++++++++++++++++++++++++++
4 files changed, 616 insertions(+)
create mode 100644 .github/workflows/lint.yml
create mode 100644 lint_rules.md
create mode 100755 scripts/lint/lint_named_refs.py
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:]))