From 8105059e8e8ffc59d93c9de83bf66c1b9491f81b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:57:03 +0000 Subject: [PATCH 1/3] A named path pattern may render a page of its matches (#373) `patterns.X.output` (and `title`) renders one row per match, one column per named node, each a link with its title; sorted, "No matches" when empty. Any pattern renders, zero hops and across schemes included. The lowering shows `pattern_match(P, (X, ...))`; logic reads clingo tuples. The page is excluded from the reference scan, and an output another view renders to is refused at load. ADR-tmpgicpu, an `unfinished` page in argument-map, and a devlog entry on the nucleation trial. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xS6nL8ZZpTh1jMSvaTRks --- docs/project-memory.md | 4 + examples/argument-map/README.md | 16 +- examples/argument-map/luria.yaml | 8 + .../argument-map/record/claims.d/CLAIM-005.md | 2 +- .../record/inferences.d/INF-004.md | 3 +- luria/adr_index.py | 6 +- luria/config.py | 95 +++++++++- luria/logic/__init__.py | 4 + luria/pattern_reports.py | 84 +++++++++ luria/pattern_rules.py | 32 +++- luria/reports.py | 16 +- record/changelog.d/pattern-pages-373.md | 19 ++ record/decisions.d/ADR-tmpgicpu.md | 132 +++++++++++++ record/devlog.d/2026/10/13/075154.md | 55 ++++++ tests/test_examples.py | 11 ++ tests/test_pattern_reports.py | 178 ++++++++++++++++++ 16 files changed, 639 insertions(+), 26 deletions(-) create mode 100644 luria/pattern_reports.py create mode 100644 record/changelog.d/pattern-pages-373.md create mode 100644 record/decisions.d/ADR-tmpgicpu.md create mode 100644 record/devlog.d/2026/10/13/075154.md create mode 100644 tests/test_pattern_reports.py diff --git a/docs/project-memory.md b/docs/project-memory.md index ad6b8fea..59e02db4 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -354,6 +354,10 @@ Labels are schemes and edge types are reference fields, checked when the config loads, and the page names what each step went *through* ([ADR-129](../record/decisions.d/ADR-129.md), `examples/argument-map`). +A pattern that is not a line — a thesis nothing objects to, an inference +with no conclusion yet — takes an `output:` instead, and renders as a page +of its matches: one row per match, one column per named node ([ADR-tmpgicpu](../record/decisions.d/ADR-tmpgicpu.md)). + **Tag rules.** A vocabulary says what a value *means*; a group says which of them may appear together, because some fields are an axis rather than a pile. The group is declared under the field it constrains: diff --git a/examples/argument-map/README.md b/examples/argument-map/README.md index d9e177cc..f4f4f434 100644 --- a/examples/argument-map/README.md +++ b/examples/argument-map/README.md @@ -28,17 +28,19 @@ same pattern with `WHERE NOT (i)<-[:challenges]-(:OBJ)`. It drops the step from the 2025 inspection to "the repair can wait", because [OBJ-001](record/objections.d/OBJ-001.md) challenges it. -**An unfinished step draws nothing.** [INF-004](record/inferences.d/INF-004.md) has a premise and no -conclusion yet, so it is not a step on either page. What counts as -unfinished depends on what this record requires, so luria does not guess. -The record can say it as a pattern of its own: +**An unfinished step is a page of its own.** [INF-004](record/inferences.d/INF-004.md) has a premise and no +conclusion yet, so it is not a step on either chain. What counts as +unfinished depends on what this record requires, so luria does not guess; +the record says it as a pattern, and gives the pattern an `output`: ```yaml -unfinished: (i:INF)<-[:premise_of]-(:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) +unfinished: + match: (i:INF)<-[:premise_of]-(p:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) + output: docs/unfinished.md ``` -Rendering such a pattern as a list of its matches is luria's -[issue 373](https://github.com/dmarx/luria/issues/373). +[Unfinished steps](docs/unfinished.md) lists each match, one column per +named node: the inference and the premise it has. ``` LURIA_ROOT=$PWD luria index diff --git a/examples/argument-map/luria.yaml b/examples/argument-map/luria.yaml index 5d198203..2ed8929d 100644 --- a/examples/argument-map/luria.yaml +++ b/examples/argument-map/luria.yaml @@ -64,6 +64,14 @@ patterns: (b:CLAIM)<-[:concludes]-(i:INF)<-[:premise_of]-(a:CLAIM) WHERE NOT (i)<-[:challenges]-(:OBJ) blurb: A step no objection challenges. + # What this record calls unfinished: an inference with premises and no + # conclusion. It renders as a page of its matches rather than a chain, + # since it relates an inference to a claim, not a claim to a claim. + unfinished: + match: (i:INF)<-[:premise_of]-(p:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM) + blurb: Inferences with premises in place and no conclusion yet. + output: docs/unfinished.md + title: Unfinished steps chains: argument: scheme: CLAIM diff --git a/examples/argument-map/record/claims.d/CLAIM-005.md b/examples/argument-map/record/claims.d/CLAIM-005.md index c2c6f9e2..0c8274e5 100644 --- a/examples/argument-map/record/claims.d/CLAIM-005.md +++ b/examples/argument-map/record/claims.d/CLAIM-005.md @@ -11,4 +11,4 @@ premise_of: The conclusion this record exists to support. [INF-004](../inferences.d/INF-004.md), which would price the repair, has premises and no conclusion yet, so it draws no step on -either page. +either chain and is listed with the unfinished steps. diff --git a/examples/argument-map/record/inferences.d/INF-004.md b/examples/argument-map/record/inferences.d/INF-004.md index dde43574..8038f318 100644 --- a/examples/argument-map/record/inferences.d/INF-004.md +++ b/examples/argument-map/record/inferences.d/INF-004.md @@ -10,4 +10,5 @@ premises: # INF-004: What the repair should cost Unfinished: the premises are in and the conclusion is not. A partial match -is no step, so neither page draws one until it names its conclusion. +is no step, so neither chain draws one until it names its conclusion; the +`unfinished` pattern lists it on a page of its own. diff --git a/luria/adr_index.py b/luria/adr_index.py index d11e9c42..94eb712a 100644 --- a/luria/adr_index.py +++ b/luria/adr_index.py @@ -524,7 +524,8 @@ def outputs(nested: bool = True) -> dict[Path, str]: pure function of the tree, so they run as parallel units. `pmap` returns in input order, which is what keeps the merged dict — and therefore the staleness diff — deterministic.""" - from . import chains, config_doc, journal, record_doc, reports + from . import (chains, config_doc, journal, pattern_reports, record_doc, + reports) from .parallel import pmap cfg = current() units = [lambda s=s: _render_scheme(s) for s in cfg.schemes.values()] @@ -535,6 +536,9 @@ def outputs(nested: bool = True) -> dict[Path, str]: # Sequences walked from a relation the schemes declare (#171). Empty for # a project that declares no chains, which is every project by default. units += [chains.outputs] + # A pattern's page of matches (#373). Empty unless a pattern declares an + # `output`, so a record without one renders exactly what it did. + units += [pattern_reports.outputs] # The record description is a projection of *this* config's values, so # naming a new scheme rewrites it on the next build. units += [record_doc.outputs] diff --git a/luria/config.py b/luria/config.py index 87938970..0779b13e 100644 --- a/luria/config.py +++ b/luria/config.py @@ -1996,10 +1996,35 @@ class NamedPattern: like a record with nothing to say (DP-15). The value is the pattern itself, or a table with `match:` and a `blurb:` - when the pattern wants a sentence of explanation.""" + when the pattern wants a sentence of explanation. + + **A page of its matches (#373).** Give the table an `output:` and the + pattern renders there: one row per match, one column per named node in + the order the pattern writes them, each cell a link to its document. Any + pattern in the subset renders — across schemes, a single node with + `WHERE NOT`, a closure — which is what a chain cannot show: + + patterns: + unopposed: + match: >- + (c:CLAIM {role: 'thesis'}) + WHERE NOT (c)<-[:objects_to]-(:CLAIM) + output: docs/unopposed.md + title: Unopposed theses + + A property map puts `: ` inside the pattern, which YAML reads as a key + unless the value is quoted or folded — hence `>-` above. + + A report, never a failure: what a pattern matches needs judgement, so + nothing on the page fails the lint. An empty result says "No matches" + rather than rendering a table with no rows.""" name: str match: str blurb: str = "" + # Where the page of matches renders; unset renders none. + output: Path | None = None + # The page's heading; defaults to the name. + title: str = "" # The parse, done once at load so a malformed pattern is a config error # rather than a page that fails to render. _parsed: object = dcfield(default=None, compare=False, repr=False) @@ -2356,6 +2381,10 @@ def is_generated(self, path: Path) -> bool: # the reader must not edit, in a file the next build overwrites. if any(path == c.output for c in self.chains.values()): return True + # A pattern's page of matches (#373), for the same reason: it lists + # retired documents as readily as live ones, by design. + if any(path == p.output for p in self.patterns.values()): + return True for s in self.schemes.values(): # The index, and a page per value of every field it groups by — # the axis's among them, on the same template and in the same @@ -2541,7 +2570,8 @@ def load(root: Path | None = None, text: str | None = None, for name, spec in raw.get("journals", {}).items() }, patterns=(patterns := _patterns(raw.get("patterns") or {}, - schemes)), + schemes, root, + root / paths["reports"])), chains=_chains(raw.get("chains", {}), schemes, root, patterns), stale_days=int(raw.get("stale_days", 90)), user_agent=str(raw.get("user_agent") @@ -2571,32 +2601,73 @@ def nameable(scheme) -> set[str]: | set(scheme.requires) | {"status"}) -def _patterns(raw: dict, schemes: dict) -> dict[str, NamedPattern]: +def _patterns(raw: dict, schemes: dict, root: Path | None = None, + reports: Path | None = None) -> dict[str, NamedPattern]: """Every declared pattern, parsed and checked against the schemes. Parsing refuses what is outside the subset (`patterns.parse`); the checks against the schemes — labels, hops, properties — are - `consistency.check_patterns`, beside the other cross-scheme refusals.""" + `consistency.check_patterns`, beside the other cross-scheme refusals. + A pattern's page is checked here: it needs a column to show, and a path + no other view renders to (#373).""" from .patterns import parse + from .pattern_rules import columns + root = root or Path(".") out: dict[str, NamedPattern] = {} for name, spec in raw.items(): name = str(name) + where = f"luria.yaml: patterns.{name}: " table = spec if isinstance(spec, dict) else {"match": spec} - if unknown := set(table) - {"match", "blurb"}: + if unknown := set(table) - {"match", "blurb", "output", "title"}: raise ValueError( - f"luria.yaml: patterns.{name}: {', '.join(sorted(unknown))} " - f"is not a key — a pattern is its `match:` and an optional " - f"`blurb:`") + where + f"{', '.join(sorted(unknown))} is not a key — a " + f"pattern is its `match:`, with an optional `blurb:`, and an " + f"`output:` and `title:` for a page of its matches") source = str(table.get("match") or "") + parsed = parse(name, source) + output = root / str(table["output"]) if table.get("output") else None + title = str(table.get("title", "") or "").strip() + if title and output is None: + raise ValueError(where + "`title` names a page, and there is no " + "`output` for it to head") + if output is not None and not columns(parsed): + raise ValueError(where + "names no node, so a page of its matches " + "would have no columns — name the nodes to show, " + "such as `(c:CLAIM)`") out[name] = NamedPattern(name=name, match=source.strip(), blurb=str(table.get("blurb", "")).strip(), - _parsed=parse(name, source)) + output=output, title=title, _parsed=parsed) if out: from . import consistency consistency.check_patterns(out, schemes) + _check_outputs(out, schemes, reports) return out +def _check_outputs(patterns: dict, schemes: dict, reports: Path | None) -> None: + """Refuse a pattern page whose path another view already renders to. + Two views at one path means one silently overwrites the other on every + build, and whichever wins looks exactly like a correct page (DP-15).""" + from . import reports as status_reports + taken: dict[Path, str] = {} + for prefix, scheme in schemes.items(): + if scheme.output is not None: + taken[scheme.output] = f"where {prefix} renders" + if scheme.render == "index": + taken[scheme.index_path] = f"where {prefix} renders" + if reports is not None: + for name in status_reports.NAMES: + taken[reports / name] = "where a status report renders" + for name, pattern in patterns.items(): + if pattern.output is None: + continue + if pattern.output in taken: + raise ValueError( + f"luria.yaml: patterns.{name}: `output` " + f"{pattern.output.name} is already {taken[pattern.output]}") + taken[pattern.output] = f"where pattern {name!r} renders" + + def _chains(raw: dict, schemes: dict, root: Path, patterns: dict | None = None) -> dict[str, Chain]: """Every declared chain, checked against the schemes it walks. @@ -2618,6 +2689,12 @@ def listed(value) -> tuple[str, ...]: for name, spec in raw.items()} from . import consistency consistency.check_chains(specs, schemes, patterns or {}) + pages = {p.output: p.name for p in (patterns or {}).values() if p.output} + for name, s in specs.items(): + if s["output"] and (owner := pages.get(root / str(s["output"]))): + raise ValueError( + f"luria.yaml: chains.{name}: `output` {s['output']} is " + f"already where pattern {owner!r} renders") return {name: Chain(name=name, scheme=s["scheme"], relation=s["relation"], sibling=s["sibling"], diff --git a/luria/logic/__init__.py b/luria/logic/__init__.py index 5cb1629e..76b9ee08 100644 --- a/luria/logic/__init__.py +++ b/luria/logic/__init__.py @@ -57,6 +57,10 @@ def _python(symbol: clingo.Symbol): return symbol.number if symbol.type == clingo.SymbolType.Function and not symbol.arguments: return symbol.name + # A tuple (`("C1", "I1")`) is a function with no name: a row of values, + # which a caller reads as one, not as the text clingo would print. + if symbol.type == clingo.SymbolType.Function and not symbol.name: + return tuple(_python(a) for a in symbol.arguments) return str(symbol) diff --git a/luria/pattern_reports.py b/luria/pattern_reports.py new file mode 100644 index 00000000..6ea295e4 --- /dev/null +++ b/luria/pattern_reports.py @@ -0,0 +1,84 @@ +# luria/pattern_reports.py +"""A named path pattern rendered as a page of its matches (#373). + + patterns: + unopposed: + match: >- + (c:CLAIM {role: 'thesis'}) WHERE NOT (c)<-[:objects_to]-(:CLAIM) + output: docs/unopposed.md + +A chain shows a pattern only as steps inside its lines, and only a pattern +that runs from the chain's scheme back to it. Most of what a record wants to +ask has neither shape — a thesis nothing objects to, an argument something +undercuts, a step the record calls unfinished — and the answer is a list. +This is that list: one row per match, one column per named node, in the +order the pattern writes them. + +**What a page promises.** Every cell links to its document from the page's +own directory and carries its title. Rows are sorted by their codes, column +by column, and nothing on the page reads the clock, so `luria index --check` +can hold it fresh like any other view. A pattern that matches nothing says +"No matches" rather than rendering a table with no rows, because an empty +table reads like a page that failed to build (DP-15). + +**A report, never a failure.** Whether a match matters is a judgement, so no +lint class reads this page; and it is excluded from the reference scan, since +it lists retired documents as readily as live ones (`Config.is_generated`). +""" +from __future__ import annotations + +from .adr_index import Adr, escape_cell, load_scheme, prefix_for +from .config import current +from .contract import local_scheme +from . import pattern_rules + +MARKER = "" + + +def _documents(codes: set[str]) -> dict[str, Adr]: + """Every document a page cites, by code, loading each scheme once.""" + out: dict[str, Adr] = {} + for prefix in {local_scheme(c) for c in codes} - {None}: + out.update((d.code, d) for d in load_scheme(current().schemes[prefix])) + return out + + +def _cell(code: str, docs: dict[str, Adr], base) -> str: + doc, prefix = docs.get(code), local_scheme(code) + if doc is None or prefix is None: + return escape_cell(code) + scheme = current().schemes[prefix] + link = prefix_for(scheme, base) + doc.path.name + return f"[{code}]({link}) — {escape_cell(doc.title)}" + + +def _count(n: int) -> str: + return f"{n} match" + ("" if n == 1 else "es") + + +def render(spec) -> str: + """One pattern's page.""" + cols = pattern_rules.columns(spec.parsed) + rows = pattern_rules.matches(spec.name) + title = spec.title or spec.name.replace("_", " ").capitalize() + out = [MARKER, "", f"# {title}", ""] + if spec.blurb: + out += [spec.blurb, ""] + out += [f"`{spec.match}` · {_count(len(rows))}", ""] + if not rows: + return "\n".join(out + ["No matches."]) + "\n" + docs = _documents({code for row in rows for code in row}) + base = spec.output.parent + out.append("| " + " | ".join(f"{var} ({label})" for var, label in cols) + + " |") + out.append("|" + "---|" * len(cols)) + out += ["| " + " | ".join(_cell(code, docs, base) for code in row) + " |" + for row in rows] + return "\n".join(out) + "\n" + + +def outputs() -> dict: + """Every pattern page, for `luria index` to write and `--check` to + compare — none for a record whose patterns declare no `output`.""" + return {spec.output: render(spec) + for spec in current().patterns.values() if spec.output} diff --git a/luria/pattern_rules.py b/luria/pattern_rules.py index 78e0dac8..7a0c570c 100644 --- a/luria/pattern_rules.py +++ b/luria/pattern_rules.py @@ -17,10 +17,14 @@ match — non-recursive, so the program stays stratified and has one answer set. -**What is shown.** Two predicates, each keyed by the pattern's name: +**What is shown.** Three predicates, each keyed by the pattern's name: pattern_edge(P, First, Last) a whole match, by its two ends pattern_via(P, First, Last, Var, X) a named inner node of that match + pattern_match(P, (X, …)) a whole match, as the tuple of its + named nodes in pattern order — + what a pattern's own page lists + (#373), zero hops included Nothing about partial matches. Whether a path that stops partway is unfinished depends on what the record's schema requires, which a pattern @@ -42,10 +46,22 @@ def program(patterns: dict[str, Pattern]) -> str: out = ["% Generated from luria.yaml `patterns:` — see pattern_rules.py."] for k, pattern in enumerate(patterns.values()): out += _Lowering(f"pat{k}", pattern).rules() - out += ["#show pattern_edge/3.", "#show pattern_via/5."] + out += ["#show pattern_edge/3.", "#show pattern_via/5.", + "#show pattern_match/2."] return "\n".join(out) + "\n" +def columns(pattern: Pattern) -> list[tuple[str, str]]: + """(variable, label) for each named node of the match, first occurrence + only, in the order the pattern writes them. Anonymous nodes are matched + and not shown; a node named only under `WHERE NOT` never binds.""" + seen: dict[str, str] = {} + for node in pattern.path.nodes: + if node.var and node.var not in seen: + seen[node.var] = node.label + return list(seen.items()) + + def needs_values(patterns: dict[str, Pattern]) -> bool: """Whether any property map reads a field other than `status`, which is the only one the `documents` facts carry.""" @@ -148,6 +164,11 @@ def rules(self) -> list[str]: body.append(f"not {self._negation(j, neg)}") args = ", ".join(self.main) self.lines.append(f"{self.p}_m({args}) :- {', '.join(body)}.") + shown = [self.bound[var] for var, _ in columns(self.pattern)] + if shown: + row = ", ".join(shown) + ("," if len(shown) == 1 else "") + self.lines.append(f"pattern_match({self.name}, ({row})) :- " + f"{self.p}_m({args}).") if n == 0: return self.lines first, last = self.main[0], self.main[n] @@ -216,3 +237,10 @@ def through(name: str) -> dict[tuple[str, str], list[str]]: if p == name: out.setdefault((first, last), set()).add(code) return {k: sorted(v) for k, v in out.items()} + + +def matches(name: str) -> list[tuple[str, ...]]: + """Every match of a pattern as the codes of its named nodes, in column + order, sorted and without repeats.""" + return sorted({row for p, row in derived().get("pattern_match", ()) + if p == name}) diff --git a/luria/reports.py b/luria/reports.py index 16c0b8d7..832b888a 100644 --- a/luria/reports.py +++ b/luria/reports.py @@ -325,16 +325,22 @@ def unbound_lineage(base: Path | None = None) -> str: return "\n".join(out) +# Each report's file name and renderer — one table, so `outputs` and the +# config's check that nothing else claims these paths (#373) read the same +# names. +NAMES = { + "reference-status.md": reference_status, + "pending-decisions.md": pending_decisions, + "unbound-lineage.md": unbound_lineage, +} + + def outputs(out_dir: Path | None = None) -> dict[Path, str]: """Every report, keyed by where it lands — the unit `luria index` renders, so a stale report fails the same staleness check as every other view (#35).""" out_dir = current().reports if out_dir is None else out_dir - return { - out_dir / "reference-status.md": reference_status(out_dir), - out_dir / "pending-decisions.md": pending_decisions(out_dir), - out_dir / "unbound-lineage.md": unbound_lineage(out_dir), - } + return {out_dir / name: render(out_dir) for name, render in NAMES.items()} def write(out_dir: Path | None = None) -> list[Path]: diff --git a/record/changelog.d/pattern-pages-373.md b/record/changelog.d/pattern-pages-373.md new file mode 100644 index 00000000..efe2c6f7 --- /dev/null +++ b/record/changelog.d/pattern-pages-373.md @@ -0,0 +1,19 @@ +### Added + +- **A path pattern can render a page of its matches** ([#373](https://github.com/dmarx/luria/issues/373)). Give a pattern + an `output:`, and optionally a `title:`, and `luria index` writes one row + per match there, one column per named node, each a link to its document. + - Any pattern renders, including ones a chain cannot walk: a single node + with `WHERE NOT`, such as theses nothing objects to, or a path across + schemes, such as arguments an Active claim undercuts. + - Rows are sorted. A pattern with no matches says "No matches". + - The page is a report: it never fails the lint and is not scanned for + citations. + - An `output` that another view already renders to is refused at load. +- `examples/argument-map` lists its unfinished steps on such a page. + +### Changed + +- A pattern with a property map must be folded (`>-`) or quoted in YAML, + since `{status: 'Active'}` puts `: ` inside the value. The configuration + reference now says so. diff --git a/record/decisions.d/ADR-tmpgicpu.md b/record/decisions.d/ADR-tmpgicpu.md new file mode 100644 index 00000000..d9309f05 --- /dev/null +++ b/record/decisions.d/ADR-tmpgicpu.md @@ -0,0 +1,132 @@ +--- +status: Proposed +title: 'A named path pattern may render a page of its matches' +version: 1 +tags: +- record +- mechanism +date: '2026-10-13' +issue: '#373' +summary: >- + A pattern table may take an `output:` (and a `title:`), and the pattern + then renders there: one row per match, one column per named node in the + order the pattern writes them, each cell a link to its document with its + title, rows sorted, and "No matches" when empty. Any pattern in the subset + renders, across schemes and with zero hops, which is what a chain cannot + show. The page is a report, never a lint failure, and is excluded from the + reference scan. An output another view already renders to is refused at + load. Rejected: a `reports:` family, fixed placement under + `docs/reports/`, grouping rows, acknowledgement directives, and a lint + class. +influenced_by: +- ADR-129 +- ADR-035 +--- + +# ADR-tmpgicpu: A named path pattern may render a page of its matches + +## Context + +[ADR-129](ADR-129.md) gave patterns one consumer, a chain. A chain needs a pattern that +starts and ends on its own scheme and has a hop, and it shows matches only +as steps inside its lines. On dmarx/nucleation, `argued_from` draws three +steps among twelve lines, and nothing lists them on their own. + +Everything else a record wants to ask has no shape a chain can draw: a +thesis nothing objects to (a single node with `WHERE NOT`), an argument an +Active claim undercuts (across schemes), a step the record calls unfinished. +That last is where [ADR-129](ADR-129.md)'s built-in *Open* list went. It named six complete +arguments on nucleation as unfinished, because whether a partial match is +unfinished depends on the schema. The record states it as a pattern instead, +and the pattern needs somewhere to render. + +## Decision + +**A pattern table may take `output:` and `title:`.** + +```yaml +patterns: + unopposed: + match: >- + (c:CLAIM {role: 'thesis'}) WHERE NOT (c)<-[:objects_to]-(:CLAIM) + blurb: Theses nothing in the record has objected to yet. + output: docs/unopposed.md + title: Unopposed theses +``` + +- **The page.** It has a heading (the title, else the name), the blurb, the + pattern in a code span with a count of matches, then a table. + - The table has one column per named node, headed `var (LABEL)`, and one + row per match. + - Each cell is a link to the document from the page's own directory, + followed by its title, with `|` escaped. + - Rows are sorted by their codes, column by column, and repeats collapse. + - A pattern with no matches says "No matches." instead of a table. +- **Columns are the named nodes of the match**, first occurrence only, in + pattern order. Anonymous nodes are matched and not shown, as in a chain's + "through". A node named only under `WHERE NOT` never binds, so it is not a + column. A pattern that names no node is refused if it asks for a page. +- **Any pattern in the subset renders**: zero hops, across schemes, closures + and `WHERE NOT`. The lowering shows `pattern_match(P, (X, …))`, one atom per + match carrying the tuple of its named nodes. `logic` turns a clingo tuple + into a Python tuple. +- **The page is generated in the strict sense.** + - `Config.is_generated` excludes it from the reference scan, as it does + chain pages. A page that lists retired documents would otherwise fill the + reference report with citations nobody can annotate. + - An `output` that a scheme, a status report, a chain or another pattern + already renders to is refused at load. Otherwise two views at one path + overwrite each other on every build, and whichever wins looks correct. + - The status report names now live in one table (`reports.NAMES`), which + both the renderer and this check read. +- **A report, never a failure.** No lint class reads the page. What a pattern + matches needs judgement ([ADR-035](ADR-035.md)), and CLAUDE.md admits a check to the lint + only when it is always wrong and mechanically fixable. +- **Navigation is the record's.** A page under `docs/` must be listed in the + docs front page, as every other page there must; `check_docs_index` + enforces that already. So the record links its pattern pages where it + chooses, and luria adds no navigation of its own. + +## Alternatives considered + +- **A `reports:` family that names patterns.** It would separate the query + from the view, and one pattern could feed several pages. But it is a + seventh family for what one key does, and every page would need a second + name. A chain already says "this renders a page here" with `output:`, so + the same key on a pattern reads the same way. +- **Fixed placement under `docs/reports/patterns/.md`.** No path to + choose, but no control over where the page lands, and `reports/` holds + luria's own status reports. A record's questions belong in its own docs. +- **Grouping rows under their first column.** It is easier to scan when one + claim has many matches. Deferred: a flat table sorts the same way, and + nothing has needed grouping yet. +- **Acknowledgement directives on rows**, so a judged match stops showing. + Deferred: a pattern that matches too much is fixed by narrowing the + pattern, which keeps the reason in the line where a reader sees it. +- **A lint class for non-empty pages.** Rejected for the reasons in the + Decision. + +## Consequences + +- `NamedPattern` gains `output` and `title`, and the configuration reference + shows them. A pattern without `output` renders nothing. Parity holds: on + dmarx/nucleation, which declares `argued_from` with no output, every file + `luria index` writes and every line of `luria lint` (network off) is + identical before and after this change. +- **A property map must be folded or quoted in YAML.** + `(c:CLAIM {role: 'thesis'})` puts `: ` inside a plain scalar, which YAML + reads as a key. The configuration reference says so and uses `>-`. The + configuration-reference test that every YAML example parses caught this in + the first docstring. +- **Tried on nucleation** in a scratch copy before trusting it: + - `unopposed` listed 115 of 175 theses, and `undercut` listed 8 arguments + with the Active claims that undercut them. Both counts match an + independent read of the frontmatter. The ninth undercutting claim is + Proposed, and the property map left it out. + - The reference report was unchanged. + - The lint failed until `docs/README.md` linked the two new pages, which is + the navigation rule above, working. +- `examples/argument-map` declares `unfinished`, and its one row is INF-004 + with the premise it has. +- Not decided here: grouping, acknowledgements, a count badge, and the + outline view that walks a pattern from a root across schemes. diff --git a/record/devlog.d/2026/10/13/075154.md b/record/devlog.d/2026/10/13/075154.md new file mode 100644 index 00000000..2622b707 --- /dev/null +++ b/record/devlog.d/2026/10/13/075154.md @@ -0,0 +1,55 @@ +--- +title: 'Pattern pages, and what nucleation said about them' +created: '2026-10-13T07:51:54' +tags: [] +--- + +[#373](https://github.com/dmarx/luria/issues/373) gives a named pattern a page of its matches ([ADR-tmpgicpu](../../record/decisions.d/ADR-tmpgicpu.md)). The code is +small: the lowering already derived every match as `pat_m(…)`, so +showing `pattern_match(P, (X, …))` and teaching `logic._python` that a +nameless clingo function is a tuple was the whole of the rules side. The +renderer is `luria/pattern_reports.py`, beside `chains.outputs` in +`adr_index.outputs`. + +Three things worth not rediscovering: + +- **YAML eats property maps.** `match: (c:CLAIM {role: 'thesis'}) …` is not + YAML: `role: ` inside a plain scalar reads as a key. The issue's own + examples were written that way. `test_no_example_is_still_written_in_toml`, + which parses every YAML block in the configuration reference, caught it in + the first docstring. Fold (`>-`) or quote any pattern with a property map. + The failure arrives as a YAML parser error before luria sees the config, + so luria cannot word it better without reading the file itself. +- **Navigation was already decided.** On nucleation the lint failed with + "missing index entry for unopposed.md": `check_docs_index` wants every + page directly under `docs/` listed in its README, and chain pages have + always been linked there by hand. So the issue's question of whether the + site should list pattern pages answered itself: the record links them, and + the lint holds it to that. Fired once on that real case before writing + that down. +- **Collisions were unchecked for every view, not only these.** Nothing + refused two views rendering to one path. The check added here covers a + pattern's `output` against schemes, status reports, chains and other + patterns. A chain colliding with a scheme is still unchecked. Out of scope, + but worth an issue if it ever bites. + +One trap of my own making: `luria new changelog --name ` hands back +the path of a fragment that already exists, which on a branch reused after a +merge is the previous PR's uncollected entry. Writing to that path with a +shell redirect overwrote it, and `git status` showing the fragment as +modified rather than new was the only sign. This PR's entry is +`pattern-pages-373.md`. Whether `luria new` should refuse an existing +fragment is a separate question. + +Tried on a scratch copy of nucleation's current `main`. It already declares +`argued_from` with no output, so the parity run doubled as the "no `output`, +no change" check: index and lint (network off) were identical against luria +`main`. With `unopposed` and `undercut` added: + +- 115 of 175 theses are unopposed; +- 8 arguments are undercut by an Active claim. + +A separate script reading the frontmatter got the same numbers. Its ninth +undercutting claim, CLAIM-004 against ARG-004, is Proposed, and the property +map left it out as it should. The reference report did not change, so the +`is_generated` exclusion holds. diff --git a/tests/test_examples.py b/tests/test_examples.py index 98f5a69b..932a72d8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -655,3 +655,14 @@ def test_a_line_of_argument_runs_through_its_inferences(example): "[INF-002](../record/inferences.d/INF-002.md)" in argument) assert "through [INF-003]" in argument assert "INF-003" not in uncontested + + +def test_what_the_record_calls_unfinished_is_a_page(example): + """A pattern with an `output` renders its matches (#373): INF-004 has a + premise and no conclusion, and is the one row.""" + root = example("argument-map") + page = (root / "docs/unfinished.md").read_text() + assert "· 1 match\n" in page + assert ("| [INF-004](../record/inferences.d/INF-004.md) — What the repair " + "should cost | [CLAIM-005](../record/claims.d/CLAIM-005.md) — The " + "bridge should be repaired before winter |") in page diff --git a/tests/test_pattern_reports.py b/tests/test_pattern_reports.py new file mode 100644 index 00000000..e5a08727 --- /dev/null +++ b/tests/test_pattern_reports.py @@ -0,0 +1,178 @@ +# tests/test_pattern_reports.py +"""A named path pattern rendered as a page of its matches (#373). + +A chain shows a pattern only as steps inside its lines, and only a pattern +that runs from its scheme back to it. Everything else a record wants to ask — +a claim nothing objects to, an argument something undercuts, a step the +record calls unfinished — is a list of matches, and this is that list. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from _config import merged +from luria import config, pattern_reports, ref_status +from luria.adr_index import outputs + +BASE = """ +issue_url: https://example.test/issues/{n} +schemes: + CLAIM: + dir: record/claims.d + output: docs/claims + references: + premise_of: {scheme: INF, many: true, required: false, converse: premises} + objects_to: {scheme: CLAIM, many: true, required: false} + INF: + dir: record/inferences.d + output: docs/inferences + references: + premises: {scheme: CLAIM, many: true, required: false, converse: premise_of} + concludes: {scheme: CLAIM, required: false} +""" + +UNFINISHED = {"patterns": {"unfinished": { + "match": "(i:INF)<-[:premise_of]-(p:CLAIM) " + "WHERE NOT (i)-[:concludes]->(:CLAIM)", + "blurb": "Inferences with premises and no conclusion yet.", + "output": "docs/unfinished.md", + "title": "Unfinished steps"}}} + + +def project(tmp_path: Path, monkeypatch, *extra) -> Path: + (tmp_path / "luria.yaml").write_text(merged(BASE, *extra)) + monkeypatch.setenv("LURIA_ROOT", str(tmp_path)) + config.reset() + return tmp_path + + +def doc(root: Path, scheme: str, number: int, title: str, **fields) -> None: + code = f"{scheme}-{number:03d}" + folder = {"CLAIM": "claims.d", "INF": "inferences.d"}[scheme] + front = ["---", "status: Active", f"title: {title!r}", "date: '2026-01-01'"] + for key, value in fields.items(): + if isinstance(value, (list, tuple)): + front.append(f"{key}:") + front += [f"- {v}" for v in value] + else: + front.append(f"{key}: {value}") + front += ["---", "", f"# {code}: {title}", "", "Body."] + path = root / "record" / folder / f"{code}.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(front) + "\n") + + +def argument(root: Path) -> None: + doc(root, "CLAIM", 1, "First premise", premise_of=["INF-001", "INF-002"]) + doc(root, "CLAIM", 2, "Second premise", premise_of=["INF-002"]) + doc(root, "CLAIM", 3, "Conclusion") + doc(root, "INF", 1, "Finished", concludes="CLAIM-003") + doc(root, "INF", 2, "Unfinished | with a pipe") + + +def page(root: Path) -> str: + return pattern_reports.outputs()[root / "docs/unfinished.md"] + + +def test_a_page_lists_each_match_with_a_column_per_named_node( + tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, UNFINISHED) + argument(root) + text = page(root) + assert text.startswith("" - - -def _documents(codes: set[str]) -> dict[str, Adr]: - """Every document a page cites, by code, loading each scheme once.""" - out: dict[str, Adr] = {} - for prefix in {local_scheme(c) for c in codes} - {None}: - out.update((d.code, d) for d in load_scheme(current().schemes[prefix])) - return out - - -def _cell(code: str, docs: dict[str, Adr], base) -> str: - doc, prefix = docs.get(code), local_scheme(code) - if doc is None or prefix is None: - return escape_cell(code) - scheme = current().schemes[prefix] - link = prefix_for(scheme, base) + doc.path.name - return f"[{code}]({link}) — {escape_cell(doc.title)}" - - -def _count(n: int) -> str: - return f"{n} match" + ("" if n == 1 else "es") - - -def render(spec) -> str: - """One pattern's page.""" - cols = pattern_rules.columns(spec.parsed) - rows = pattern_rules.matches(spec.name) - title = spec.title or spec.name.replace("_", " ").capitalize() - out = [MARKER, "", f"# {title}", ""] - if spec.blurb: - out += [spec.blurb, ""] - out += [f"`{spec.match}` · {_count(len(rows))}", ""] - if not rows: - return "\n".join(out + ["No matches."]) + "\n" - docs = _documents({code for row in rows for code in row}) - base = spec.output.parent - out.append("| " + " | ".join(f"{var} ({label})" for var, label in cols) - + " |") - out.append("|" + "---|" * len(cols)) - out += ["| " + " | ".join(_cell(code, docs, base) for code in row) + " |" - for row in rows] - return "\n".join(out) + "\n" - - -def outputs() -> dict: - """Every pattern page, for `luria index` to write and `--check` to - compare — none for a record whose patterns declare no `output`.""" - return {spec.output: render(spec) - for spec in current().patterns.values() if spec.output} diff --git a/luria/pattern_rules.py b/luria/pattern_rules.py index 8f411151..d1b7fc4e 100644 --- a/luria/pattern_rules.py +++ b/luria/pattern_rules.py @@ -42,7 +42,8 @@ Nothing about partial matches. Whether a path that stops partway is unfinished depends on what the record's schema requires, which a pattern cannot know; a record that wants that list writes it as a pattern of its own, -`WHERE NOT` and all (#373). +`WHERE NOT` and all, and gives it an `output:` for a page of its matches +(#373), which is a view over `pattern_row` like any other. Every pattern's internal predicates are prefixed `pat_` by position, and its name only ever appears as a quoted string, so no name a record chooses diff --git a/luria/patterns.py b/luria/patterns.py index 0d219bac..57cdcd8b 100644 --- a/luria/patterns.py +++ b/luria/patterns.py @@ -97,6 +97,15 @@ def first(self) -> Node: def last(self) -> Node: return self.path.nodes[-1] + @property + def named(self) -> tuple[tuple[str, str], ...]: + """(variable, label) for each named node of the match, first + occurrence only, in the order the pattern writes them. An anonymous + node is matched and not named; one named only under `WHERE NOT` + never binds.""" + return tuple(dict.fromkeys( + (n.var, n.label) for n in self.path.nodes if n.var and not n.bound)) + @functools.cache def _parser() -> lark.Lark: diff --git a/luria/view_reads.py b/luria/view_reads.py index 7101d471..18d98220 100644 --- a/luria/view_reads.py +++ b/luria/view_reads.py @@ -72,7 +72,8 @@ def retired_rows(rendered: dict | None = None) -> list[str]: if site.kind != "transcluded" or _acknowledged( site.template, site.line, read.code): continue - rows.append(f"views.{name}: {site.template}:{site.line} " + rows.append(f"{current().views[name].where}: " + f"{site.template}:{site.line} " f"transcludes {read.code}, which is " f"{read.status}{after}") return rows diff --git a/luria/view_templates/pattern.md.j2 b/luria/view_templates/pattern.md.j2 new file mode 100644 index 00000000..23c5fc9d --- /dev/null +++ b/luria/view_templates/pattern.md.j2 @@ -0,0 +1,25 @@ +{#- A pattern's page of matches (#373): the view luria supplies for a + pattern with an `output:`. Its values come from `config._views`; + `match` is the same function every template calls. Codes are written + bare, so the link fixer makes the links in the page's own frame. -#} +{% set rows = match(pattern) %} +# {{ title }} + +{% if blurb %} +{{ blurb }} + +{% endif %} +`{{ source }}` · {{ rows | length }} match{{ "" if rows | length == 1 else "es" }} + +{% if not rows %} +No matches. +{% else %} +|{% for var, label in columns %} {{ var }} ({{ label }}) |{% endfor %} + +|{% for _ in columns %}---|{% endfor %} + +{% for row in rows %} +|{% for var, _ in columns %} {{ row[var].code }} — {{ row[var].title | cell }} |{% endfor %} + +{% endfor %} +{% endif %} diff --git a/luria/views.py b/luria/views.py index 9ac00571..d7875e3a 100644 --- a/luria/views.py +++ b/luria/views.py @@ -32,6 +32,13 @@ Composition is Jinja's own: `include`, `import` and `extends` resolve from the project root, so a mostly hand-written file is a template that is mostly prose. + +**Some views luria supplies.** A template named `luria:NAME` is one of +luria's own, shipped in `luria/view_templates/`; a view that names one gets +its values from `context`, not from a file the project wrote. A pattern's +`output:` is such a view (`luria:pattern.md.j2`), and so it is rendered, +held fresh by `--check`, kept out of the reference scan and located on error +exactly as a view the project wrote. """ from __future__ import annotations @@ -52,6 +59,13 @@ STAMP = ("\n\n") +# A view luria supplies has no template the project can edit, so its stamp +# names the declaration that asked for it instead. +SUPPLIED_STAMP = ("\n\n") +# The reserved prefix of a template luria ships, and where they are. +PACKAGE = "luria" +PACKAGE_TEMPLATES = Path(__file__).parent / "view_templates" class ViewError(Exception): """A view that cannot render, said once, with the template's line.""" @@ -203,8 +217,7 @@ def match(self, pattern: str) -> list[dict[str, Document]]: written here in the same syntax and checked the same way.""" from . import logic, pattern_rules parsed = _pattern(self.cfg, pattern) - named = list(dict.fromkeys( - n.var for n in parsed.path.nodes if n.var)) + named = [var for var, _ in parsed.named] if not named: raise _Refused(f"`{pattern}` names no node, so a match has " "nothing to show — name one, as in `(c:CLAIM)`") @@ -217,10 +230,12 @@ def match(self, pattern: str) -> list[dict[str, Document]]: "relations", extra=text, families=families).get("pattern_row", ()): found.setdefault(key, {})[var] = code - ordered = sorted(found.values(), - key=lambda row: [row.get(v, "") for v in named]) - return [{v: self.resolve(row[v]) for v in named if v in row} - for row in ordered] + # A row shows only the named nodes, so two matches that differ in an + # anonymous one are the same row to a template, and listed once. + rows = {tuple(row.get(v, "") for v in named): row + for row in found.values()} + return [{v: self.resolve(rows[k][v]) for v in named if v in rows[k]} + for k in sorted(rows)] def vocabulary(self, name: str) -> list[Term]: values = self.cfg.vocabularies.get(name) @@ -288,17 +303,30 @@ def _holds(contract, meta: dict, field: str, wanted) -> bool: def _environment() -> SandboxedEnvironment: """One environment per render: the loader reads the tree as it is now, and nothing survives from one view to the next.""" - return SandboxedEnvironment( - loader=jinja2.FileSystemLoader(str(current().root)), + env = SandboxedEnvironment( + # luria's own templates first, so the reserved prefix cannot be + # shadowed by a file in the project. + loader=jinja2.ChoiceLoader([ + jinja2.PrefixLoader( + {PACKAGE: jinja2.FileSystemLoader(str(PACKAGE_TEMPLATES))}, + delimiter=":"), + jinja2.FileSystemLoader(str(current().root)), + ]), undefined=_Undefined, autoescape=False, keep_trailing_newline=True, trim_blocks=True, lstrip_blocks=True, ) + # One markdown table cell, however many `|` the text holds. + from .adr_index import escape_cell + env.filters["cell"] = escape_cell + return env def _name(view) -> str: + if isinstance(view.template, str): # `luria:NAME`, one of ours + return view.template return current().rel(view.template).replace(os.sep, "/") @@ -308,9 +336,16 @@ def _located(view, exc: BaseException) -> ViewError: where = f"{exc.name or _name(view)}:{exc.lineno}" return ViewError(f"{where}: {exc.message}") root = str(current().root) + ours = str(PACKAGE_TEMPLATES) where = _name(view) for frame in reversed(traceback.extract_tb(exc.__traceback__)): - if frame.filename.startswith(root) and not frame.filename.endswith(".py"): + if frame.filename.endswith(".py"): + continue + if frame.filename.startswith(ours): + where = (f"{PACKAGE}:{Path(frame.filename).relative_to(ours).as_posix()}" + f":{frame.lineno}") + break + if frame.filename.startswith(root): where = (f"{Path(frame.filename).relative_to(root).as_posix()}" f":{frame.lineno}") break @@ -347,13 +382,14 @@ def render(view) -> str: journals=record.journals, chains=record.chains, views=record.views, paths=record.paths) try: - text = env.get_template(_name(view)).render() + text = env.get_template(_name(view)).render(view.context) except (jinja2.TemplateError, _Refused, TypeError, ValueError, AttributeError, KeyError, LookupError) as exc: raise _located(view, exc) from None text, _ = doc_refs.linkify(text, view.output) if view.output.suffix == ".md": - text = STAMP.format(template=_name(view)) + text + text = (SUPPLIED_STAMP.format(origin=view.where) if view.origin + else STAMP.format(template=_name(view))) + text _RENDERED[view.output] = Rendering(text, record.reads()) return text @@ -389,7 +425,7 @@ def errors(rendered: dict[Path, str] | None = None) -> list[str]: try: render(view) except ViewError as exc: - found.append(f"views.{view.name}: {exc}") + found.append(f"{view.where}: {exc}") return found diff --git a/record/changelog.d/pattern-pages-373.md b/record/changelog.d/pattern-pages-373.md index efe2c6f7..844f5e62 100644 --- a/record/changelog.d/pattern-pages-373.md +++ b/record/changelog.d/pattern-pages-373.md @@ -2,18 +2,23 @@ - **A path pattern can render a page of its matches** ([#373](https://github.com/dmarx/luria/issues/373)). Give a pattern an `output:`, and optionally a `title:`, and `luria index` writes one row - per match there, one column per named node, each a link to its document. + per match there, one column per named node, each its document's code and + title. - Any pattern renders, including ones a chain cannot walk: a single node with `WHERE NOT`, such as theses nothing objects to, or a path across schemes, such as arguments an Active claim undercuts. - - Rows are sorted. A pattern with no matches says "No matches". - - The page is a report: it never fails the lint and is not scanned for - citations. - - An `output` that another view already renders to is refused at load. + - The page is a view luria supplies, so `luria index --check` holds it + fresh and the reference scan skips it, like any other view. + - It is a list, never a lint finding. `report: true` still makes matches + a warning class, a pattern may have both, and an acknowledged match + stays on the page. - `examples/argument-map` lists its unfinished steps on such a page. +- A view template may use the `cell` filter to escape `|` in a table cell. ### Changed -- A pattern with a property map must be folded (`>-`) or quoted in YAML, - since `{status: 'Active'}` puts `: ` inside the value. The configuration - reference now says so. +- Two renderers writing one path are refused at load, naming both: schemes, + status reports, chains, views and pattern pages alike. Before, a chain or + a view could silently overwrite another page. +- `match` in a view template lists two matches that differ only in an + anonymous node once, since its rows cannot tell them apart. diff --git a/record/decisions.d/ADR-129.md b/record/decisions.d/ADR-129.md index 272b7017..fc297f77 100644 --- a/record/decisions.d/ADR-129.md +++ b/record/decisions.d/ADR-129.md @@ -191,7 +191,8 @@ set, and the views it feeds are deterministic, as [ADR-127](ADR-127.md) requires schema requires, and a pattern cannot know that. A record that wants the list writes it as a pattern of its own, such as `(i:INF)<-[:premise_of]-(:CLAIM) WHERE NOT (i)-[:concludes]->(:CLAIM)`, - and rendering such a pattern is [#373](https://github.com/dmarx/luria/issues/373). + and gives it an `output:` to render it as a page of its matches + ([ADR-tmpgicpu](ADR-tmpgicpu.md)). - **A chain's `invariant` covers pattern steps.** It is checked between the two ends, as for any edge the chain walks. - **Cycles through a pattern are the chain's existing cycle finding.** @@ -297,9 +298,9 @@ converse, and is never completed by `luria link --fix`. - **Not decided here, and made possible by it:** - an outline view that walks a pattern from a root across schemes, which is where `OPTIONAL MATCH` would earn its place; - - transcluding document bodies, so views can compose into a manuscript; - - patterns rendered as reports, such as claims no objection challenges - or steps a record calls unfinished ([#373](https://github.com/dmarx/luria/issues/373)). + - transcluding document bodies, so views can compose into a manuscript. Each is its own decision. This one keeps named intermediates available - to them. + to them. A third, patterns rendered as a page of their matches (claims + no objection challenges, steps a record calls unfinished), has since been + decided in [ADR-tmpgicpu](ADR-tmpgicpu.md). diff --git a/record/decisions.d/ADR-tmpgicpu.md b/record/decisions.d/ADR-tmpgicpu.md index d9309f05..3c1b533c 100644 --- a/record/decisions.d/ADR-tmpgicpu.md +++ b/record/decisions.d/ADR-tmpgicpu.md @@ -9,17 +9,22 @@ date: '2026-10-13' issue: '#373' summary: >- A pattern table may take an `output:` (and a `title:`), and the pattern - then renders there: one row per match, one column per named node in the - order the pattern writes them, each cell a link to its document with its - title, rows sorted, and "No matches" when empty. Any pattern in the subset - renders, across schemes and with zero hops, which is what a chain cannot - show. The page is a report, never a lint failure, and is excluded from the - reference scan. An output another view already renders to is refused at - load. Rejected: a `reports:` family, fixed placement under - `docs/reports/`, grouping rows, acknowledgement directives, and a lint - class. + then renders there as a view luria supplies: one template shipped in the + package, `luria:pattern.md.j2`, reading the matches through the `match` + every view template calls. One row per match, one column per named node + in pattern order, each cell the document's code and title, rows sorted, + and "No matches." when empty. Freshness, the reference-scan exclusion and + render errors come from the view machinery. The page is a list, never a + finding: `report: true` is the separate consumer that makes matches a lint + class, a pattern may have both, and an acknowledged match stays on the + page. Every path luria renders is now checked at load against every + other. Rejected: a renderer of its own, closing the issue as served by + views, a `reports:` family, fixed placement under `docs/reports/`, and + grouping rows. influenced_by: - ADR-129 +- ADR-130 +- ADR-131 - ADR-035 --- @@ -29,16 +34,19 @@ influenced_by: [ADR-129](ADR-129.md) gave patterns one consumer, a chain. A chain needs a pattern that starts and ends on its own scheme and has a hop, and it shows matches only -as steps inside its lines. On dmarx/nucleation, `argued_from` draws three -steps among twelve lines, and nothing lists them on their own. +as steps inside its lines. Everything else a record wants to ask has no +shape a chain can draw: a thesis nothing objects to (a single node with +`WHERE NOT`), an argument an Active claim undercuts (across schemes), a step +the record calls unfinished. That last is where [ADR-129](ADR-129.md)'s built-in *Open* +list went: whether a partial match is unfinished depends on the schema, so +the record states it as a pattern, and the pattern needs somewhere to +render. -Everything else a record wants to ask has no shape a chain can draw: a -thesis nothing objects to (a single node with `WHERE NOT`), an argument an -Active claim undercuts (across schemes), a step the record calls unfinished. -That last is where [ADR-129](ADR-129.md)'s built-in *Open* list went. It named six complete -arguments on nucleation as unfinished, because whether a partial match is -unfinished depends on the schema. The record states it as a pattern instead, -and the pattern needs somewhere to render. +Two consumers already read patterns besides chains. A view template +([ADR-130](ADR-130.md)) can call `match(name)` and get every match as rows of documents. +And `report: true` ([ADR-131](ADR-131.md)) makes every match a row of a lint class. So the +list a record wants is already one template away. The question is who +writes that template. ## Decision @@ -54,79 +62,101 @@ patterns: title: Unopposed theses ``` -- **The page.** It has a heading (the title, else the name), the blurb, the - pattern in a code span with a count of matches, then a table. - - The table has one column per named node, headed `var (LABEL)`, and one - row per match. - - Each cell is a link to the document from the page's own directory, - followed by its title, with `|` escaped. - - Rows are sorted by their codes, column by column, and repeats collapse. - - A pattern with no matches says "No matches." instead of a table. -- **Columns are the named nodes of the match**, first occurrence only, in - pattern order. Anonymous nodes are matched and not shown, as in a chain's - "through". A node named only under `WHERE NOT` never binds, so it is not a - column. A pattern that names no node is refused if it asks for a page. -- **Any pattern in the subset renders**: zero hops, across schemes, closures - and `WHERE NOT`. The lowering shows `pattern_match(P, (X, …))`, one atom per - match carrying the tuple of its named nodes. `logic` turns a clingo tuple - into a Python tuple. -- **The page is generated in the strict sense.** - - `Config.is_generated` excludes it from the reference scan, as it does - chain pages. A page that lists retired documents would otherwise fill the - reference report with citations nobody can annotate. - - An `output` that a scheme, a status report, a chain or another pattern - already renders to is refused at load. Otherwise two views at one path - overwrite each other on every build, and whichever wins looks correct. - - The status report names now live in one table (`reports.NAMES`), which - both the renderer and this check read. -- **A report, never a failure.** No lint class reads the page. What a pattern - matches needs judgement ([ADR-035](ADR-035.md)), and CLAUDE.md admits a check to the lint - only when it is always wrong and mechanically fixable. +- **The page is a view luria supplies.** At load, a pattern with an + `output` contributes a view whose template is `luria:pattern.md.j2`, one + of luria's own (`luria/view_templates/`), with the pattern's name, source, + blurb, title and named nodes as its values. The view environment loads a + `luria:` name from the package and everything else from the project root, + inside the same sandbox. The template reads rows through `match`, writes + codes bare, and the link fixer makes the links in the page's own frame, as + for any view. +- **What the page shows.** A heading (the title, else the name), the blurb, + the pattern in a code span with a count of matches, then a table: + - one column per named node, headed `var (LABEL)`, first occurrence only, + in pattern order. An anonymous node is matched and not shown. A node + named only under `WHERE NOT` never binds, so it is not a column, and a + pattern that names no node is refused if it asks for a page; + - one row per match, sorted by its codes column by column. Two matches + that differ only in an anonymous node are one row, since nothing on the + row tells them apart; `match` now collapses them for every template; + - each cell is the code, linked, then " — " and the title, with `|` + escaped by a `cell` filter any template may use; + - "No matches." instead of a table when the pattern matches nothing. +- **What being a view gives it, with nothing written for it.** `luria index` + writes it and `--check` calls it stale; `Config.is_generated` keeps it out + of the reference scan, so a page listing retired documents does not fill + the reference report; a render error names the pattern and the template + line (`patterns.unopposed: luria:pattern.md.j2:5: …`). The stamp names + `patterns.` in `luria.yaml` rather than a template, because that is + what a reader would edit. +- **A list, never a finding.** No lint class reads the page. Whether a match + matters is a judgement ([ADR-035](ADR-035.md)). A record that wants its matches as + findings says `report: true` ([ADR-131](ADR-131.md)), a separate consumer of the same + pattern, and may say both. Then the page and the class list the same + matches, and a `pattern-ok:` acknowledgement silences the finding without + removing the row: the page is the list of what matches, not of what is + outstanding. +- **One check for every output.** At load, every path luria renders is + gathered: each scheme's output and index, the status reports + (`reports.NAMES`, one table that both the renderer and this check read), + the record description and configuration reference, chains, views and + pattern pages. Any two that coincide are refused, naming both. Two views + at one path overwrite each other on every build, and whichever wins looks + correct. Before this, nothing checked any two of them. - **Navigation is the record's.** A page under `docs/` must be listed in the - docs front page, as every other page there must; `check_docs_index` - enforces that already. So the record links its pattern pages where it - chooses, and luria adds no navigation of its own. + docs front page, as every other page there must, and `check_docs_index` + enforces that already. ## Alternatives considered +- **A renderer of its own**, beside `chains.outputs`, with its own clingo + predicate (`pattern_match/2`), a tuple reader in the logic core and a + module writing the table. This was the first version of this decision. + It was a second implementation of what `match` already answers for every + template, and then a third path to keep fresh, excluded and located on + error. As a view it gets all three from code that exists. +- **Close [#373](https://github.com/dmarx/luria/issues/373) as served by views**, each record writing its own template + over `match`. Nothing new in luria, but every record rewrites the same + table of about twenty lines, and the copies drift: one escapes `|`, + another forgets the empty case. Supplying the template keeps the page one + key in the config. - **A `reports:` family that names patterns.** It would separate the query - from the view, and one pattern could feed several pages. But it is a - seventh family for what one key does, and every page would need a second - name. A chain already says "this renders a page here" with `output:`, so - the same key on a pattern reads the same way. + from the page, and one pattern could feed several pages. But it is + another family for what one key does. A chain already says "this renders + a page here" with `output:`, so the same key on a pattern reads the same + way. A record that wants a second page writes a view. - **Fixed placement under `docs/reports/patterns/.md`.** No path to choose, but no control over where the page lands, and `reports/` holds luria's own status reports. A record's questions belong in its own docs. -- **Grouping rows under their first column.** It is easier to scan when one - claim has many matches. Deferred: a flat table sorts the same way, and +- **Grouping rows under their first column.** Easier to scan when one + document has many matches. Deferred: a flat table sorts the same way, and nothing has needed grouping yet. -- **Acknowledgement directives on rows**, so a judged match stops showing. - Deferred: a pattern that matches too much is fixed by narrowing the - pattern, which keeps the reason in the line where a reader sees it. -- **A lint class for non-empty pages.** Rejected for the reasons in the - Decision. ## Consequences -- `NamedPattern` gains `output` and `title`, and the configuration reference - shows them. A pattern without `output` renders nothing. Parity holds: on - dmarx/nucleation, which declares `argued_from` with no output, every file - `luria index` writes and every line of `luria lint` (network off) is - identical before and after this change. +- `NamedPattern` gains `output` and `title`, and a pattern's keys are + `match`, `blurb`, `report`, `output` and `title`. A pattern without + `output` renders nothing. `View` gains a context and an origin for the + views luria supplies; `luria views` lists a pattern page as + `patterns.`. - **A property map must be folded or quoted in YAML.** `(c:CLAIM {role: 'thesis'})` puts `: ` inside a plain scalar, which YAML - reads as a key. The configuration reference says so and uses `>-`. The - configuration-reference test that every YAML example parses caught this in - the first docstring. -- **Tried on nucleation** in a scratch copy before trusting it: - - `unopposed` listed 115 of 175 theses, and `undercut` listed 8 arguments - with the Active claims that undercut them. Both counts match an - independent read of the frontmatter. The ninth undercutting claim is - Proposed, and the property map left it out. - - The reference report was unchanged. - - The lint failed until `docs/README.md` linked the two new pages, which is - the navigation rule above, working. + reads as a key. The configuration reference says so. +- **Tried on nucleation** in a scratch copy of its `main` (cf318d525), with + `lint.network: never`: + - With no pattern `output`, every file `luria index` writes and every line + of `luria lint` is identical between luria `main` and this change. + nucleation declares four `report: true` patterns and the `argued_from` + chain pattern, so the parity covers both other consumers. + - With `unopposed` and `undercut` (`(g:ARG)<-[:undercuts]-(c:CLAIM + {status: 'Active'})`) added, `unopposed` lists 286 of 361 theses and + `undercut` 8 rows across 6 arguments. A separate script reading the + frontmatter got the same sets. The ninth undercutting claim, CLAIM-004 + against ARG-004, is Proposed, and the property map left it out. + - The status reports were unchanged, so the `is_generated` exclusion + holds. The lint added exactly two findings, the docs index missing the + two new pages, which is the navigation rule above. - `examples/argument-map` declares `unfinished`, and its one row is INF-004 with the premise it has. -- Not decided here: grouping, acknowledgements, a count badge, and the - outline view that walks a pattern from a root across schemes. +- Not decided here: grouping, a count badge, and the outline view that walks + a pattern from a root across schemes. diff --git a/record/devlog.d/2026/10/13/075154.md b/record/devlog.d/2026/10/13/075154.md index 2622b707..4c770d0d 100644 --- a/record/devlog.d/2026/10/13/075154.md +++ b/record/devlog.d/2026/10/13/075154.md @@ -4,52 +4,58 @@ created: '2026-10-13T07:51:54' tags: [] --- -[#373](https://github.com/dmarx/luria/issues/373) gives a named pattern a page of its matches ([ADR-tmpgicpu](../../record/decisions.d/ADR-tmpgicpu.md)). The code is -small: the lowering already derived every match as `pat_m(…)`, so -showing `pattern_match(P, (X, …))` and teaching `logic._python` that a -nameless clingo function is a tuple was the whole of the rules side. The -renderer is `luria/pattern_reports.py`, beside `chains.outputs` in -`adr_index.outputs`. +[#373](https://github.com/dmarx/luria/issues/373) gives a named pattern a page of its matches ([ADR-tmpgicpu](../../record/decisions.d/ADR-tmpgicpu.md)). The +page is a view luria supplies: a pattern with an `output:` adds a view at +load whose template is `luria:pattern.md.j2`, shipped in +`luria/view_templates/`, and the view environment's loader is now a +`ChoiceLoader` that tries luria's own `luria:` prefix before the project +root. The template reads rows through the `match` every view template +calls and writes codes bare, so `linkify` makes the links. -Three things worth not rediscovering: +**The first version was a renderer of its own, and the rework deleted it.** +It added `pattern_match/2` to the lowering, taught `logic._python` to read a +clingo tuple, and wrote the table in `luria/pattern_reports.py`. All of +that duplicated `views._Record.match`, which merged to `main` with [ADR-130](../../record/decisions.d/ADR-130.md) +while the branch was open, and then needed its own freshness, exclusion and +error-locating. As a view the page gets all three from existing code, and +the tests that held the first version's output pass unchanged against the +second, link targets included. +Things worth not rediscovering: + +- **`match` returned a row per whole match, anonymous nodes included.** Two + matches differing only in an unnamed node came back as two identical + rows, which a template cannot tell apart. It now collapses them; the old + renderer had done the same in its own `sorted(set(...))`. +- **A collision check belongs to the set of renderers, not to the newest + one.** The first version checked a pattern page against schemes, reports + and chains, and noted that a chain colliding with a scheme was still + unchecked. `main` checked no pair at all. `config._check_rendered` now + gathers every path a renderer claims and refuses any two that coincide, + and the tests cover each pairing that can occur. - **YAML eats property maps.** `match: (c:CLAIM {role: 'thesis'}) …` is not - YAML: `role: ` inside a plain scalar reads as a key. The issue's own - examples were written that way. `test_no_example_is_still_written_in_toml`, - which parses every YAML block in the configuration reference, caught it in - the first docstring. Fold (`>-`) or quote any pattern with a property map. - The failure arrives as a YAML parser error before luria sees the config, - so luria cannot word it better without reading the file itself. + YAML: `role: ` inside a plain scalar reads as a key. Fold (`>-`) or quote + any pattern with a property map. The failure arrives as a YAML parser + error before luria sees the config. - **Navigation was already decided.** On nucleation the lint failed with "missing index entry for unopposed.md": `check_docs_index` wants every - page directly under `docs/` listed in its README, and chain pages have - always been linked there by hand. So the issue's question of whether the - site should list pattern pages answered itself: the record links them, and - the lint holds it to that. Fired once on that real case before writing - that down. -- **Collisions were unchecked for every view, not only these.** Nothing - refused two views rendering to one path. The check added here covers a - pattern's `output` against schemes, status reports, chains and other - patterns. A chain colliding with a scheme is still unchecked. Out of scope, - but worth an issue if it ever bites. - -One trap of my own making: `luria new changelog --name ` hands back -the path of a fragment that already exists, which on a branch reused after a -merge is the previous PR's uncollected entry. Writing to that path with a -shell redirect overwrote it, and `git status` showing the fragment as -modified rather than new was the only sign. This PR's entry is -`pattern-pages-373.md`. Whether `luria new` should refuse an existing -fragment is a separate question. - -Tried on a scratch copy of nucleation's current `main`. It already declares -`argued_from` with no output, so the parity run doubled as the "no `output`, -no change" check: index and lint (network off) were identical against luria -`main`. With `unopposed` and `undercut` added: + page directly under `docs/` listed in its README. So the record links its + pattern pages, and the lint holds it to that. +- **`luria new changelog --name ` hands back an existing path.** On + a branch reused after a merge, that is the previous PR's uncollected + fragment, and a shell redirect overwrites it. This PR's fragment is + `pattern-pages-373.md`. -- 115 of 175 theses are unopposed; -- 8 arguments are undercut by an Active claim. +Tried on a scratch copy of nucleation's `main` (cf318d525, network off). +With no pattern `output`, `luria index` wrote the same files and `luria +lint` printed the same lines under luria `main` and this branch. With +`unopposed` and `undercut` added, 286 of 361 theses are unopposed and 8 +(argument, Active claim) rows cover 6 arguments; a script reading the +frontmatter found the same sets. The counts moved from the first trial's +115 of 175 because nucleation's record grew in between. -A separate script reading the frontmatter got the same numbers. Its ninth -undercutting claim, CLAIM-004 against ARG-004, is Proposed, and the property -map left it out as it should. The reference report did not change, so the -`is_generated` exclusion holds. +**Follow-up, not done here:** `pattern_row/4` (what views read) and +`pattern_node/4` (what the `report: true` classes read) are two encodings of +one match, named and positional. The page and the class agree on the test +that pins it, but one shown predicate both could read would remove the +chance that they ever stop agreeing. diff --git a/tests/test_pattern_pages.py b/tests/test_pattern_pages.py new file mode 100644 index 00000000..5537f7cc --- /dev/null +++ b/tests/test_pattern_pages.py @@ -0,0 +1,323 @@ +# tests/test_pattern_pages.py +"""A named path pattern rendered as a page of its matches (#373). + +A chain shows a pattern only as steps inside its lines, and only a pattern +that runs from its scheme back to it. Everything else a record wants to ask — +a claim nothing objects to, an argument something undercuts, a step the +record calls unfinished — is a list of matches, and this is that list. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from _config import merged +from luria import adr_index, config, pattern_report, pattern_rules, views +from luria.adr_index import outputs +from luria.view_document import _Refused + +BASE = """ +issue_url: https://example.test/issues/{n} +schemes: + CLAIM: + dir: record/claims.d + output: docs/claims + references: + premise_of: {scheme: INF, many: true, required: false, converse: premises} + objects_to: {scheme: CLAIM, many: true, required: false} + INF: + dir: record/inferences.d + output: docs/inferences + references: + premises: {scheme: CLAIM, many: true, required: false, converse: premise_of} + concludes: {scheme: CLAIM, required: false} +""" + +UNFINISHED = {"patterns": {"unfinished": { + "match": "(i:INF)<-[:premise_of]-(p:CLAIM) " + "WHERE NOT (i)-[:concludes]->(:CLAIM)", + "blurb": "Inferences with premises and no conclusion yet.", + "output": "docs/unfinished.md", + "title": "Unfinished steps"}}} + + +def project(tmp_path: Path, monkeypatch, *extra) -> Path: + (tmp_path / "luria.yaml").write_text(merged(BASE, *extra)) + monkeypatch.setenv("LURIA_ROOT", str(tmp_path)) + config.reset() + return tmp_path + + +def doc(root: Path, scheme: str, number: int, title: str, **fields) -> None: + code = f"{scheme}-{number:03d}" + folder = {"CLAIM": "claims.d", "INF": "inferences.d"}[scheme] + front = ["---", "status: Active", f"title: {title!r}", "date: '2026-01-01'"] + for key, value in fields.items(): + if isinstance(value, (list, tuple)): + front.append(f"{key}:") + front += [f"- {v}" for v in value] + else: + front.append(f"{key}: {value}") + front += ["---", "", f"# {code}: {title}", "", "Body."] + path = root / "record" / folder / f"{code}.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(front) + "\n") + + +def argument(root: Path) -> None: + doc(root, "CLAIM", 1, "First premise", premise_of=["INF-001", "INF-002"]) + doc(root, "CLAIM", 2, "Second premise", premise_of=["INF-002"]) + doc(root, "CLAIM", 3, "Conclusion") + doc(root, "INF", 1, "Finished", concludes="CLAIM-003") + doc(root, "INF", 2, "Unfinished | with a pipe") + + +def page(root: Path) -> str: + return views.outputs()[root / "docs/unfinished.md"] + + +def test_a_page_lists_each_match_with_a_column_per_named_node( + tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, UNFINISHED) + argument(root) + text = page(root) + assert text.startswith("") + + +# ── a page and a lint class over one pattern (#382) ─────────────────────── + +BOTH = {"patterns": {"unfinished": {**UNFINISHED["patterns"]["unfinished"], + "report": True}}} + + +def test_a_pattern_may_have_a_page_and_a_class_and_they_agree( + tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, BOTH) + argument(root) + lint_rows = pattern_rules.reported()["unfinished"] + page_rows = [(row["i"].code, row["p"].code) for row in + views._Record(root / "docs/unfinished.md").match("unfinished")] + assert lint_rows == page_rows == [("INF-002", "CLAIM-001"), + ("INF-002", "CLAIM-002")] + assert "· 2 matches" in page(root) + + +def test_an_acknowledged_match_is_still_on_the_page(tmp_path, monkeypatch): + """The page is a list; `pattern-ok:` silences the finding, not the row.""" + root = project(tmp_path, monkeypatch, BOTH) + argument(root) + path = root / "record/inferences.d/INF-002.md" + path.write_text(path.read_text() + "\n\n") + found = pattern_report.findings() + assert "unfinished" not in found.rows and found.acknowledged == 2 + text = page(root) + assert "· 2 matches" in text + assert text.count("| [INF-002](") == 2 diff --git a/tests/test_pattern_reports.py b/tests/test_pattern_reports.py deleted file mode 100644 index e5a08727..00000000 --- a/tests/test_pattern_reports.py +++ /dev/null @@ -1,178 +0,0 @@ -# tests/test_pattern_reports.py -"""A named path pattern rendered as a page of its matches (#373). - -A chain shows a pattern only as steps inside its lines, and only a pattern -that runs from its scheme back to it. Everything else a record wants to ask — -a claim nothing objects to, an argument something undercuts, a step the -record calls unfinished — is a list of matches, and this is that list. -""" -from __future__ import annotations - -from pathlib import Path - -import pytest - -from _config import merged -from luria import config, pattern_reports, ref_status -from luria.adr_index import outputs - -BASE = """ -issue_url: https://example.test/issues/{n} -schemes: - CLAIM: - dir: record/claims.d - output: docs/claims - references: - premise_of: {scheme: INF, many: true, required: false, converse: premises} - objects_to: {scheme: CLAIM, many: true, required: false} - INF: - dir: record/inferences.d - output: docs/inferences - references: - premises: {scheme: CLAIM, many: true, required: false, converse: premise_of} - concludes: {scheme: CLAIM, required: false} -""" - -UNFINISHED = {"patterns": {"unfinished": { - "match": "(i:INF)<-[:premise_of]-(p:CLAIM) " - "WHERE NOT (i)-[:concludes]->(:CLAIM)", - "blurb": "Inferences with premises and no conclusion yet.", - "output": "docs/unfinished.md", - "title": "Unfinished steps"}}} - - -def project(tmp_path: Path, monkeypatch, *extra) -> Path: - (tmp_path / "luria.yaml").write_text(merged(BASE, *extra)) - monkeypatch.setenv("LURIA_ROOT", str(tmp_path)) - config.reset() - return tmp_path - - -def doc(root: Path, scheme: str, number: int, title: str, **fields) -> None: - code = f"{scheme}-{number:03d}" - folder = {"CLAIM": "claims.d", "INF": "inferences.d"}[scheme] - front = ["---", "status: Active", f"title: {title!r}", "date: '2026-01-01'"] - for key, value in fields.items(): - if isinstance(value, (list, tuple)): - front.append(f"{key}:") - front += [f"- {v}" for v in value] - else: - front.append(f"{key}: {value}") - front += ["---", "", f"# {code}: {title}", "", "Body."] - path = root / "record" / folder / f"{code}.md" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(front) + "\n") - - -def argument(root: Path) -> None: - doc(root, "CLAIM", 1, "First premise", premise_of=["INF-001", "INF-002"]) - doc(root, "CLAIM", 2, "Second premise", premise_of=["INF-002"]) - doc(root, "CLAIM", 3, "Conclusion") - doc(root, "INF", 1, "Finished", concludes="CLAIM-003") - doc(root, "INF", 2, "Unfinished | with a pipe") - - -def page(root: Path) -> str: - return pattern_reports.outputs()[root / "docs/unfinished.md"] - - -def test_a_page_lists_each_match_with_a_column_per_named_node( - tmp_path, monkeypatch): - root = project(tmp_path, monkeypatch, UNFINISHED) - argument(root) - text = page(root) - assert text.startswith("\n\n") -# A view luria supplies has no template the project can edit, so its stamp -# names the declaration that asked for it instead. +# A supplied view has no template to edit; its stamp names the config key. SUPPLIED_STAMP = ("\n\n") -# The reserved prefix of a template luria ships, and where they are. -PACKAGE = "luria" PACKAGE_TEMPLATES = Path(__file__).parent / "view_templates" class ViewError(Exception): @@ -230,8 +223,7 @@ def match(self, pattern: str) -> list[dict[str, Document]]: "relations", extra=text, families=families).get("pattern_row", ()): found.setdefault(key, {})[var] = code - # A row shows only the named nodes, so two matches that differ in an - # anonymous one are the same row to a template, and listed once. + # Matches differing only in an anonymous node are one row. rows = {tuple(row.get(v, "") for v in named): row for row in found.values()} return [{v: self.resolve(rows[k][v]) for v in named if v in rows[k]} @@ -304,11 +296,10 @@ def _environment() -> SandboxedEnvironment: """One environment per render: the loader reads the tree as it is now, and nothing survives from one view to the next.""" env = SandboxedEnvironment( - # luria's own templates first, so the reserved prefix cannot be - # shadowed by a file in the project. + # luria's own `luria:` templates first, so a project cannot shadow one. loader=jinja2.ChoiceLoader([ jinja2.PrefixLoader( - {PACKAGE: jinja2.FileSystemLoader(str(PACKAGE_TEMPLATES))}, + {"luria": jinja2.FileSystemLoader(str(PACKAGE_TEMPLATES))}, delimiter=":"), jinja2.FileSystemLoader(str(current().root)), ]), @@ -318,14 +309,13 @@ def _environment() -> SandboxedEnvironment: trim_blocks=True, lstrip_blocks=True, ) - # One markdown table cell, however many `|` the text holds. from .adr_index import escape_cell env.filters["cell"] = escape_cell return env def _name(view) -> str: - if isinstance(view.template, str): # `luria:NAME`, one of ours + if isinstance(view.template, str): # `luria:NAME` return view.template return current().rel(view.template).replace(os.sep, "/") @@ -342,7 +332,7 @@ def _located(view, exc: BaseException) -> ViewError: if frame.filename.endswith(".py"): continue if frame.filename.startswith(ours): - where = (f"{PACKAGE}:{Path(frame.filename).relative_to(ours).as_posix()}" + where = (f"luria:{Path(frame.filename).relative_to(ours).as_posix()}" f":{frame.lineno}") break if frame.filename.startswith(root): diff --git a/record/changelog.d/pattern-pages-373.md b/record/changelog.d/pattern-pages-373.md index 844f5e62..a89239b5 100644 --- a/record/changelog.d/pattern-pages-373.md +++ b/record/changelog.d/pattern-pages-373.md @@ -1,24 +1,13 @@ ### Added -- **A path pattern can render a page of its matches** ([#373](https://github.com/dmarx/luria/issues/373)). Give a pattern - an `output:`, and optionally a `title:`, and `luria index` writes one row - per match there, one column per named node, each its document's code and - title. - - Any pattern renders, including ones a chain cannot walk: a single node - with `WHERE NOT`, such as theses nothing objects to, or a path across - schemes, such as arguments an Active claim undercuts. - - The page is a view luria supplies, so `luria index --check` holds it - fresh and the reference scan skips it, like any other view. - - It is a list, never a lint finding. `report: true` still makes matches - a warning class, a pattern may have both, and an acknowledged match - stays on the page. +- **A path pattern can render a page of its matches** ([#373](https://github.com/dmarx/luria/issues/373)). Give it an + `output:`, and optionally a `title:`, and `luria index` writes one row per + match there, one column per named node, including patterns no chain can + walk. The page is a view luria supplies and a list, never a lint finding; + `report: true` still makes a lint class, and a pattern may have both. - `examples/argument-map` lists its unfinished steps on such a page. -- A view template may use the `cell` filter to escape `|` in a table cell. ### Changed -- Two renderers writing one path are refused at load, naming both: schemes, - status reports, chains, views and pattern pages alike. Before, a chain or - a view could silently overwrite another page. -- `match` in a view template lists two matches that differ only in an - anonymous node once, since its rows cannot tell them apart. +- Two renderers writing one path are refused at load, naming both. +- A view's `match` lists matches differing only in an anonymous node once. diff --git a/record/decisions.d/ADR-129.md b/record/decisions.d/ADR-129.md index fc297f77..b2791a19 100644 --- a/record/decisions.d/ADR-129.md +++ b/record/decisions.d/ADR-129.md @@ -301,6 +301,4 @@ converse, and is never completed by `luria link --fix`. - transcluding document bodies, so views can compose into a manuscript. Each is its own decision. This one keeps named intermediates available - to them. A third, patterns rendered as a page of their matches (claims - no objection challenges, steps a record calls unfinished), has since been - decided in [ADR-tmpgicpu](ADR-tmpgicpu.md). + to them. diff --git a/record/decisions.d/ADR-tmpgicpu.md b/record/decisions.d/ADR-tmpgicpu.md index 3c1b533c..aa8182c7 100644 --- a/record/decisions.d/ADR-tmpgicpu.md +++ b/record/decisions.d/ADR-tmpgicpu.md @@ -8,19 +8,13 @@ tags: date: '2026-10-13' issue: '#373' summary: >- - A pattern table may take an `output:` (and a `title:`), and the pattern - then renders there as a view luria supplies: one template shipped in the - package, `luria:pattern.md.j2`, reading the matches through the `match` - every view template calls. One row per match, one column per named node - in pattern order, each cell the document's code and title, rows sorted, - and "No matches." when empty. Freshness, the reference-scan exclusion and - render errors come from the view machinery. The page is a list, never a - finding: `report: true` is the separate consumer that makes matches a lint - class, a pattern may have both, and an acknowledged match stays on the - page. Every path luria renders is now checked at load against every - other. Rejected: a renderer of its own, closing the issue as served by - views, a `reports:` family, fixed placement under `docs/reports/`, and - grouping rows. + A pattern with an `output:` (and optional `title:`) renders there as a + view luria supplies, `luria:pattern.md.j2`: one row per match, one column + per named node. It is a list, never a finding; `report: true` is the + separate lint consumer, a pattern may have both, and acknowledged matches + stay listed. Every path luria renders is checked against every other at + load. Rejected: a renderer of its own, closing the issue as served by + views, a `reports:` family, fixed placement, and grouping rows. influenced_by: - ADR-129 - ADR-130 @@ -32,25 +26,18 @@ influenced_by: ## Context -[ADR-129](ADR-129.md) gave patterns one consumer, a chain. A chain needs a pattern that -starts and ends on its own scheme and has a hop, and it shows matches only -as steps inside its lines. Everything else a record wants to ask has no -shape a chain can draw: a thesis nothing objects to (a single node with +[ADR-129](ADR-129.md) gave patterns one consumer, a chain, which needs a pattern from its +own scheme back to it and shows matches only as steps. Most of what a record +asks has no such shape: a thesis nothing objects to (one node with `WHERE NOT`), an argument an Active claim undercuts (across schemes), a step -the record calls unfinished. That last is where [ADR-129](ADR-129.md)'s built-in *Open* -list went: whether a partial match is unfinished depends on the schema, so -the record states it as a pattern, and the pattern needs somewhere to -render. - -Two consumers already read patterns besides chains. A view template -([ADR-130](ADR-130.md)) can call `match(name)` and get every match as rows of documents. -And `report: true` ([ADR-131](ADR-131.md)) makes every match a row of a lint class. So the -list a record wants is already one template away. The question is who -writes that template. +the record calls unfinished. A view template ([ADR-130](ADR-130.md)) can already list +them with `match(name)`, and `report: true` ([ADR-131](ADR-131.md)) makes them findings. The +question is who writes the list's template. ## Decision -**A pattern table may take `output:` and `title:`.** +**A pattern table may take `output:` and `title:`, and the page is a view +luria supplies.** ```yaml patterns: @@ -59,104 +46,64 @@ patterns: (c:CLAIM {role: 'thesis'}) WHERE NOT (c)<-[:objects_to]-(:CLAIM) blurb: Theses nothing in the record has objected to yet. output: docs/unopposed.md - title: Unopposed theses ``` -- **The page is a view luria supplies.** At load, a pattern with an - `output` contributes a view whose template is `luria:pattern.md.j2`, one - of luria's own (`luria/view_templates/`), with the pattern's name, source, - blurb, title and named nodes as its values. The view environment loads a - `luria:` name from the package and everything else from the project root, - inside the same sandbox. The template reads rows through `match`, writes - codes bare, and the link fixer makes the links in the page's own frame, as - for any view. -- **What the page shows.** A heading (the title, else the name), the blurb, - the pattern in a code span with a count of matches, then a table: - - one column per named node, headed `var (LABEL)`, first occurrence only, - in pattern order. An anonymous node is matched and not shown. A node - named only under `WHERE NOT` never binds, so it is not a column, and a - pattern that names no node is refused if it asks for a page; - - one row per match, sorted by its codes column by column. Two matches - that differ only in an anonymous node are one row, since nothing on the - row tells them apart; `match` now collapses them for every template; - - each cell is the code, linked, then " — " and the title, with `|` - escaped by a `cell` filter any template may use; - - "No matches." instead of a table when the pattern matches nothing. -- **What being a view gives it, with nothing written for it.** `luria index` - writes it and `--check` calls it stale; `Config.is_generated` keeps it out - of the reference scan, so a page listing retired documents does not fill - the reference report; a render error names the pattern and the template - line (`patterns.unopposed: luria:pattern.md.j2:5: …`). The stamp names - `patterns.` in `luria.yaml` rather than a template, because that is - what a reader would edit. -- **A list, never a finding.** No lint class reads the page. Whether a match - matters is a judgement ([ADR-035](ADR-035.md)). A record that wants its matches as - findings says `report: true` ([ADR-131](ADR-131.md)), a separate consumer of the same - pattern, and may say both. Then the page and the class list the same - matches, and a `pattern-ok:` acknowledgement silences the finding without - removing the row: the page is the list of what matches, not of what is - outstanding. -- **One check for every output.** At load, every path luria renders is - gathered: each scheme's output and index, the status reports - (`reports.NAMES`, one table that both the renderer and this check read), - the record description and configuration reference, chains, views and - pattern pages. Any two that coincide are refused, naming both. Two views - at one path overwrite each other on every build, and whichever wins looks - correct. Before this, nothing checked any two of them. -- **Navigation is the record's.** A page under `docs/` must be listed in the - docs front page, as every other page there must, and `check_docs_index` - enforces that already. +- **One template, shipped.** At load, a pattern with an `output` adds a + view on `luria:pattern.md.j2` (`luria/view_templates/`), with the + pattern's name, source, blurb, title and named nodes as its values. The + view loader tries the `luria:` prefix in the package before the project + root, inside the same sandbox. The template writes codes bare, and the + link fixer links them in the page's frame. +- **The page.** A heading (the title, else the name), the blurb, the + pattern in a code span with its count, and a table: one column per named + node, headed `var (LABEL)` in pattern order; one row per match, sorted by + its codes; each cell the code and " — " title, `|` escaped by a `cell` + filter. "No matches." replaces an empty table. An anonymous node is not a + column, so two matches differing only in one are a single row; `match` + now collapses them for every template. A pattern that names no node, or a + `title` without an `output`, is refused at load. +- **Being a view**, it is written by `luria index`, held fresh by + `--check`, kept out of the reference scan, and located on error as + `patterns.` with the template line, which its stamp names too. +- **A list, never a finding.** No lint class reads the page; whether a + match matters is a judgement ([ADR-035](ADR-035.md)). `report: true` is the consumer that + makes matches findings, and a pattern may have both. A `pattern-ok:` + acknowledgement silences the finding and leaves the row: the page lists + what matches, not what is outstanding. +- **One collision check for every renderer.** At load, every path luria + renders is gathered: scheme outputs and indexes, the status reports + (`reports.NAMES`, which the renderer reads too), the record description, + the configuration reference, chains, views and pattern pages. Two that + coincide are refused, naming both. Before, no pair was checked, and + whichever view wins looks correct. ## Alternatives considered -- **A renderer of its own**, beside `chains.outputs`, with its own clingo - predicate (`pattern_match/2`), a tuple reader in the logic core and a - module writing the table. This was the first version of this decision. - It was a second implementation of what `match` already answers for every - template, and then a third path to keep fresh, excluded and located on - error. As a view it gets all three from code that exists. -- **Close [#373](https://github.com/dmarx/luria/issues/373) as served by views**, each record writing its own template - over `match`. Nothing new in luria, but every record rewrites the same - table of about twenty lines, and the copies drift: one escapes `|`, - another forgets the empty case. Supplying the template keeps the page one - key in the config. -- **A `reports:` family that names patterns.** It would separate the query - from the page, and one pattern could feed several pages. But it is - another family for what one key does. A chain already says "this renders - a page here" with `output:`, so the same key on a pattern reads the same - way. A record that wants a second page writes a view. -- **Fixed placement under `docs/reports/patterns/.md`.** No path to - choose, but no control over where the page lands, and `reports/` holds - luria's own status reports. A record's questions belong in its own docs. -- **Grouping rows under their first column.** Easier to scan when one - document has many matches. Deferred: a flat table sorts the same way, and - nothing has needed grouping yet. +- **A renderer of its own**, with a `pattern_match/2` predicate, a tuple + reader in the logic core and a module writing the table. This was the + first version of this decision. It duplicated `match` and needed its own + freshness, exclusion and error locating, which a view already has. +- **Close [#373](https://github.com/dmarx/luria/issues/373) as served by views**, each record writing its own template. + Nothing new in luria, but every record rewrites the same twenty-line table + and the copies drift: one escapes `|`, another forgets the empty case. +- **A `reports:` family that names patterns**: another family for what one + key does, where `output:` already means "renders a page here" on a chain. +- **Fixed placement under `docs/reports/`**, which holds luria's own + reports; a record's questions belong in its own docs. +- **Grouping rows under their first column.** Deferred until needed. ## Consequences -- `NamedPattern` gains `output` and `title`, and a pattern's keys are - `match`, `blurb`, `report`, `output` and `title`. A pattern without - `output` renders nothing. `View` gains a context and an origin for the - views luria supplies; `luria views` lists a pattern page as - `patterns.`. -- **A property map must be folded or quoted in YAML.** - `(c:CLAIM {role: 'thesis'})` puts `: ` inside a plain scalar, which YAML - reads as a key. The configuration reference says so. -- **Tried on nucleation** in a scratch copy of its `main` (cf318d525), with - `lint.network: never`: - - With no pattern `output`, every file `luria index` writes and every line - of `luria lint` is identical between luria `main` and this change. - nucleation declares four `report: true` patterns and the `argued_from` - chain pattern, so the parity covers both other consumers. - - With `unopposed` and `undercut` (`(g:ARG)<-[:undercuts]-(c:CLAIM - {status: 'Active'})`) added, `unopposed` lists 286 of 361 theses and - `undercut` 8 rows across 6 arguments. A separate script reading the - frontmatter got the same sets. The ninth undercutting claim, CLAIM-004 - against ARG-004, is Proposed, and the property map left it out. - - The status reports were unchanged, so the `is_generated` exclusion - holds. The lint added exactly two findings, the docs index missing the - two new pages, which is the navigation rule above. -- `examples/argument-map` declares `unfinished`, and its one row is INF-004 - with the premise it has. -- Not decided here: grouping, a count badge, and the outline view that walks - a pattern from a root across schemes. +- `View` gains a context and an origin; `luria views` lists a pattern page + as `patterns.`. A page must be listed in `docs/README.md` like any + other page there (`check_docs_index`). +- **Tried on nucleation** (scratch copy of cf318d525, network off). With no + `output`, `luria index` writes the same files and `luria lint` prints the + same lines as luria `main`, its `report: true` and chain patterns + included. With `unopposed` and + `undercut: (g:ARG)<-[:undercuts]-(c:CLAIM {status: 'Active'})` added, 286 + of 361 theses are unopposed and 8 rows cover 6 undercut arguments, the + same sets a script reading the frontmatter found. The status reports did + not change, and the lint added only the docs-index entries for the two + pages. +- `examples/argument-map` declares `unfinished`; its one row is INF-004. diff --git a/record/devlog.d/2026/10/13/075154.md b/record/devlog.d/2026/10/13/075154.md index 4c770d0d..c8d8ef40 100644 --- a/record/devlog.d/2026/10/13/075154.md +++ b/record/devlog.d/2026/10/13/075154.md @@ -4,58 +4,25 @@ created: '2026-10-13T07:51:54' tags: [] --- -[#373](https://github.com/dmarx/luria/issues/373) gives a named pattern a page of its matches ([ADR-tmpgicpu](../../record/decisions.d/ADR-tmpgicpu.md)). The -page is a view luria supplies: a pattern with an `output:` adds a view at -load whose template is `luria:pattern.md.j2`, shipped in -`luria/view_templates/`, and the view environment's loader is now a -`ChoiceLoader` that tries luria's own `luria:` prefix before the project -root. The template reads rows through the `match` every view template -calls and writes codes bare, so `linkify` makes the links. +[#373](https://github.com/dmarx/luria/issues/373) gives a named pattern a page of its matches ([ADR-tmpgicpu](../../record/decisions.d/ADR-tmpgicpu.md)), as a view +luria supplies on its own template, `luria:pattern.md.j2`. **The first version was a renderer of its own, and the rework deleted it.** -It added `pattern_match/2` to the lowering, taught `logic._python` to read a -clingo tuple, and wrote the table in `luria/pattern_reports.py`. All of -that duplicated `views._Record.match`, which merged to `main` with [ADR-130](../../record/decisions.d/ADR-130.md) -while the branch was open, and then needed its own freshness, exclusion and -error-locating. As a view the page gets all three from existing code, and -the tests that held the first version's output pass unchanged against the -second, link targets included. +`pattern_match/2`, a clingo-tuple reader in `logic` and +`pattern_reports.py` duplicated `views._Record.match`, which reached `main` +while the branch was open. As a view, the page's tests from the first +version pass with the same output, link targets included. -Things worth not rediscovering: +- **`match` returned one row per whole match**, so two matches differing + only in an anonymous node were two identical rows. It now collapses them. +- **No pair of outputs was checked for collisions**; now all are, in one place. +- **YAML eats property maps**: fold (`>-`) or quote a pattern holding one. +- **`luria new changelog --name ` returns an existing path** on a + reused branch, and a shell redirect overwrites the previous fragment. -- **`match` returned a row per whole match, anonymous nodes included.** Two - matches differing only in an unnamed node came back as two identical - rows, which a template cannot tell apart. It now collapses them; the old - renderer had done the same in its own `sorted(set(...))`. -- **A collision check belongs to the set of renderers, not to the newest - one.** The first version checked a pattern page against schemes, reports - and chains, and noted that a chain colliding with a scheme was still - unchecked. `main` checked no pair at all. `config._check_rendered` now - gathers every path a renderer claims and refuses any two that coincide, - and the tests cover each pairing that can occur. -- **YAML eats property maps.** `match: (c:CLAIM {role: 'thesis'}) …` is not - YAML: `role: ` inside a plain scalar reads as a key. Fold (`>-`) or quote - any pattern with a property map. The failure arrives as a YAML parser - error before luria sees the config. -- **Navigation was already decided.** On nucleation the lint failed with - "missing index entry for unopposed.md": `check_docs_index` wants every - page directly under `docs/` listed in its README. So the record links its - pattern pages, and the lint holds it to that. -- **`luria new changelog --name ` hands back an existing path.** On - a branch reused after a merge, that is the previous PR's uncollected - fragment, and a shell redirect overwrites it. This PR's fragment is - `pattern-pages-373.md`. +On nucleation the unopposed count moved from 115 of 175 theses to 286 of +361 because the record grew; undercut stayed at 8 rows. -Tried on a scratch copy of nucleation's `main` (cf318d525, network off). -With no pattern `output`, `luria index` wrote the same files and `luria -lint` printed the same lines under luria `main` and this branch. With -`unopposed` and `undercut` added, 286 of 361 theses are unopposed and 8 -(argument, Active claim) rows cover 6 arguments; a script reading the -frontmatter found the same sets. The counts moved from the first trial's -115 of 175 because nucleation's record grew in between. - -**Follow-up, not done here:** `pattern_row/4` (what views read) and -`pattern_node/4` (what the `report: true` classes read) are two encodings of -one match, named and positional. The page and the class agree on the test -that pins it, but one shown predicate both could read would remove the -chance that they ever stop agreeing. +**Follow-up:** `pattern_row/4` (views) and `pattern_node/4` (the `report: +true` classes) are two encodings of one match. A test pins that the page and +the class agree; one predicate for both would remove the need. diff --git a/tests/test_pattern_pages.py b/tests/test_pattern_pages.py index 5537f7cc..97ca60e1 100644 --- a/tests/test_pattern_pages.py +++ b/tests/test_pattern_pages.py @@ -1,14 +1,8 @@ # tests/test_pattern_pages.py -"""A named path pattern rendered as a page of its matches (#373). - -A chain shows a pattern only as steps inside its lines, and only a pattern -that runs from its scheme back to it. Everything else a record wants to ask — -a claim nothing objects to, an argument something undercuts, a step the -record calls unfinished — is a list of matches, and this is that list. -""" +"""A named path pattern rendered as a page of its matches (#373): a view +luria supplies, beside the lint class `report: true` makes (#382).""" from __future__ import annotations -import re from pathlib import Path import pytest @@ -54,12 +48,7 @@ def doc(root: Path, scheme: str, number: int, title: str, **fields) -> None: code = f"{scheme}-{number:03d}" folder = {"CLAIM": "claims.d", "INF": "inferences.d"}[scheme] front = ["---", "status: Active", f"title: {title!r}", "date: '2026-01-01'"] - for key, value in fields.items(): - if isinstance(value, (list, tuple)): - front.append(f"{key}:") - front += [f"- {v}" for v in value] - else: - front.append(f"{key}: {value}") + front += [f"{k}: {v}" for k, v in fields.items()] front += ["---", "", f"# {code}: {title}", "", "Body."] path = root / "record" / folder / f"{code}.md" path.parent.mkdir(parents=True, exist_ok=True) @@ -83,7 +72,9 @@ def test_a_page_lists_each_match_with_a_column_per_named_node( root = project(tmp_path, monkeypatch, UNFINISHED) argument(root) text = page(root) - assert text.startswith("") assert "# Unfinished steps" in text assert "Inferences with premises and no conclusion yet." in text assert ("`(i:INF)<-[:premise_of]-(p:CLAIM) WHERE NOT " @@ -104,9 +95,7 @@ def test_an_empty_result_says_so(tmp_path, monkeypatch): doc(root, "CLAIM", 2, "Conclusion") doc(root, "INF", 1, "Finished", concludes="CLAIM-002") text = page(root) - assert "· 0 matches" in text - assert "No matches." in text - assert "|---|" not in text + assert "· 0 matches\n\nNo matches.\n" in text and "|---|" not in text def test_a_zero_hop_pattern_renders(tmp_path, monkeypatch): @@ -134,16 +123,14 @@ def test_anonymous_and_repeated_nodes_are_not_columns(tmp_path, monkeypatch): def test_matches_differing_only_in_an_anonymous_node_are_one_row( tmp_path, monkeypatch): - """INF-002 has two premises; with the premise anonymous, nothing on a - row tells the two matches apart, so the page lists INF-002 once.""" + """INF-002 has two premises; with the premise anonymous, one row.""" root = project(tmp_path, monkeypatch, {"patterns": {"open": { "match": "(i:INF)<-[:premise_of]-(:CLAIM) " "WHERE NOT (i)-[:concludes]->(:CLAIM)", "output": "docs/open.md"}}}) argument(root) text = views.outputs()[root / "docs/open.md"] - assert "· 1 match\n" in text - assert text.count("| [INF-002](") == 1 + assert "· 1 match\n" in text and text.count("| [INF-002](") == 1 def test_a_pattern_without_output_renders_nothing(tmp_path, monkeypatch): @@ -160,8 +147,7 @@ def test_the_page_is_a_view_luria_index_writes(tmp_path, monkeypatch): def test_the_page_is_not_scanned_for_citations(tmp_path, monkeypatch): - """A report lists retired documents as readily as live ones; scanned, it - would fill the reference report with citations nobody can annotate.""" + """It lists retired documents by design, at sites nobody can annotate.""" root = project(tmp_path, monkeypatch, UNFINISHED) argument(root) assert config.current().is_generated(root / "docs/unfinished.md") @@ -169,8 +155,7 @@ def test_the_page_is_not_scanned_for_citations(tmp_path, monkeypatch): @pytest.mark.parametrize("extra, says", [ ({"patterns": {"p": {"match": "(:CLAIM) WHERE NOT (:INF)", - "output": "docs/p.md"}}}, - "patterns.p: names no node"), + "output": "docs/p.md"}}}, "patterns.p: names no node"), ({"patterns": {"p": {"match": "(c:CLAIM)", "title": "No page"}}}, "patterns.p: `title` names a page, and there is no `output`"), ]) @@ -182,57 +167,44 @@ def test_a_page_that_cannot_render_is_refused(tmp_path, monkeypatch, extra, assert says in str(caught.value) -# One check covers every renderer whose path the config chooses, so each -# pairing that can occur is refused the same way, naming both. -P = {"match": "(c:CLAIM)"} -CHAIN = {"scheme": "CLAIM", "relation": "objects_to"} -VIEW = {"template": "record/views.d/v.md.j2"} +SPEC = {"patterns": {"match": "(c:CLAIM)"}, "views": {"template": "t.j2"}, + "chains": {"scheme": "CLAIM", "relation": "objects_to"}} +def family(name, **out): + return {name: {n: {**SPEC[name], "output": o} for n, o in out.items()}} + + +def pats(**out): return family("patterns", **out) +def chains(**out): return family("chains", **out) +def views_(**out): return family("views", **out) + + +# One check covers every renderer whose path the config chooses, so each +# pairing that can occur is refused the same way, naming both. @pytest.mark.parametrize("extra, later, earlier", [ - # a pattern page against each other renderer - ({"patterns": {"p": {**P, "output": "docs/claims"}}}, - "patterns.p", "schemes.CLAIM"), - ({"patterns": {"p": {**P, "output": "docs/claims/README.md"}}}, - "patterns.p", "schemes.CLAIM"), - ({"patterns": {"p": {**P, "output": "docs/x.md"}, - "q": {**P, "output": "docs/x.md"}}}, - "patterns.q", "patterns.p"), - ({"patterns": {"p": {**P, "output": "docs/reports/unbound-lineage.md"}}}, - "patterns.p", "the status report unbound-lineage.md"), - ({"patterns": {"p": {**P, "output": "docs/x.md"}}, - "chains": {"c": {**CHAIN, "output": "docs/x.md"}}}, - "patterns.p", "chains.c"), - ({"patterns": {"p": {**P, "output": "docs/x.md"}}, - "views": {"v": {**VIEW, "output": "docs/x.md"}}}, - "patterns.p", "views.v"), - ({"patterns": {"p": {**P, "output": "docs/record.md"}}}, - "patterns.p", "the record description"), - ({"patterns": {"p": {**P, "output": "docs/./x.md"}, - "q": {**P, "output": "docs/x.md"}}}, - "patterns.q", "patterns.p"), - # the pairings no check covered before (#373) - ({"chains": {"c": {**CHAIN, "output": "docs/claims/README.md"}}}, - "chains.c", "schemes.CLAIM"), - ({"views": {"v": {**VIEW, "output": "docs/inferences/README.md"}}}, - "views.v", "schemes.INF"), - ({"schemes": {"NOTE": {"dir": "record/notes.d", - "output": "docs/claims"}}}, + (pats(p="docs/claims"), "patterns.p", "schemes.CLAIM"), + (pats(p="docs/claims/README.md"), "patterns.p", "schemes.CLAIM"), + (pats(p="docs/./x.md", q="docs/x.md"), "patterns.q", "patterns.p"), + (pats(p="docs/reports/unbound-lineage.md"), "patterns.p", + "the status report unbound-lineage.md"), + (pats(p="docs/record.md"), "patterns.p", "the record description"), + ({**pats(p="docs/x.md"), **chains(c="docs/x.md")}, "patterns.p", + "chains.c"), + ({**pats(p="docs/x.md"), **views_(v="docs/x.md")}, "patterns.p", + "views.v"), + (chains(c="docs/claims/README.md"), "chains.c", "schemes.CLAIM"), + (chains(c="docs/x.md", d="docs/x.md"), "chains.d", "chains.c"), + (chains(c="docs/reports/pending-decisions.md"), "chains.c", + "the status report pending-decisions.md"), + (views_(v="docs/inferences/README.md"), "views.v", "schemes.INF"), + (views_(v="docs/x.md", w="docs/x.md"), "views.w", "views.v"), + ({**chains(c="docs/x.md"), **views_(v="docs/x.md")}, "views.v", + "chains.c"), + (views_(v="docs/reports/reference-status.md"), "views.v", + "the status report reference-status.md"), + ({"schemes": {"NOTE": {"dir": "record/notes.d", "output": "docs/claims"}}}, "schemes.NOTE", "schemes.CLAIM"), - ({"chains": {"c": {**CHAIN, "output": "docs/x.md"}, - "d": {**CHAIN, "output": "docs/x.md"}}}, - "chains.d", "chains.c"), - ({"chains": {"c": {**CHAIN, "output": "docs/reports/pending-decisions.md"}}}, - "chains.c", "the status report pending-decisions.md"), - ({"chains": {"c": {**CHAIN, "output": "docs/x.md"}}, - "views": {"v": {**VIEW, "output": "docs/x.md"}}}, - "views.v", "chains.c"), - ({"views": {"v": {**VIEW, "output": "docs/x.md"}, - "w": {"template": "record/views.d/w.md.j2", - "output": "docs/x.md"}}}, - "views.w", "views.v"), - ({"views": {"v": {**VIEW, "output": "docs/reports/reference-status.md"}}}, - "views.v", "the status report reference-status.md"), ]) def test_two_renderers_at_one_path_are_refused(tmp_path, monkeypatch, extra, later, earlier): @@ -245,23 +217,15 @@ def test_two_renderers_at_one_path_are_refused(tmp_path, monkeypatch, extra, def test_distinct_outputs_load(tmp_path, monkeypatch): - """The control: the same renderers at different paths are no collision.""" - project(tmp_path, monkeypatch, { - "patterns": {"p": {**P, "output": "docs/p.md"}}, - "chains": {"c": {**CHAIN, "output": "docs/c.md"}}, - "views": {"v": {**VIEW, "output": "docs/v.md"}}}) - cfg = config.load(tmp_path) - assert {v.output for v in cfg.views.values()} == { - tmp_path / "docs/p.md", tmp_path / "docs/v.md"} + project(tmp_path, monkeypatch, pats(p="docs/p.md"), chains(c="docs/c.md"), + views_(v="docs/v.md")) + assert len(config.load(tmp_path).views) == 2 -# ── the page is a view: what the view machinery gives it ────────────────── - def test_index_check_holds_the_page_fresh(tmp_path, monkeypatch): root = project(tmp_path, monkeypatch, UNFINISHED) argument(root) adr_index.run() - assert "INF-002" in (root / "docs/unfinished.md").read_text() assert root / "docs/unfinished.md" not in adr_index.staleness().stale doc(root, "INF", 3, "Another unfinished step") doc(root, "CLAIM", 4, "Its premise", premise_of=["INF-003"]) @@ -270,43 +234,27 @@ def test_index_check_holds_the_page_fresh(tmp_path, monkeypatch): def test_a_render_error_names_the_pattern_and_the_template_line( tmp_path, monkeypatch): - root = project(tmp_path, monkeypatch, UNFINISHED) - argument(root) + argument(project(tmp_path, monkeypatch, UNFINISHED)) def refused(self, pattern): - raise _Refused("the solver is out to lunch") + raise _Refused("out to lunch") monkeypatch.setattr(views._Record, "match", refused) - found = views.errors() - assert len(found) == 1 - assert re.fullmatch(r"patterns\.unfinished: luria:pattern\.md\.j2:\d+: " - r"the solver is out to lunch", found[0]), found + assert views.errors() == [ + "patterns.unfinished: luria:pattern.md.j2:3: out to lunch"] -def test_the_stamp_names_the_pattern_not_a_template_to_edit( - tmp_path, monkeypatch): - root = project(tmp_path, monkeypatch, UNFINISHED) - argument(root) - assert page(root).splitlines()[0] == ( - "") - - -# ── a page and a lint class over one pattern (#382) ─────────────────────── - BOTH = {"patterns": {"unfinished": {**UNFINISHED["patterns"]["unfinished"], "report": True}}} -def test_a_pattern_may_have_a_page_and_a_class_and_they_agree( - tmp_path, monkeypatch): +def test_a_page_and_a_lint_class_list_the_same_matches(tmp_path, monkeypatch): + """A pattern may have both (#382); the page lists what the class finds.""" root = project(tmp_path, monkeypatch, BOTH) argument(root) - lint_rows = pattern_rules.reported()["unfinished"] - page_rows = [(row["i"].code, row["p"].code) for row in + page_rows = [(r["i"].code, r["p"].code) for r in views._Record(root / "docs/unfinished.md").match("unfinished")] - assert lint_rows == page_rows == [("INF-002", "CLAIM-001"), - ("INF-002", "CLAIM-002")] - assert "· 2 matches" in page(root) + assert pattern_rules.reported()["unfinished"] == page_rows == [ + ("INF-002", "CLAIM-001"), ("INF-002", "CLAIM-002")] def test_an_acknowledged_match_is_still_on_the_page(tmp_path, monkeypatch): @@ -316,8 +264,5 @@ def test_an_acknowledged_match_is_still_on_the_page(tmp_path, monkeypatch): path = root / "record/inferences.d/INF-002.md" path.write_text(path.read_text() + "\n\n") - found = pattern_report.findings() - assert "unfinished" not in found.rows and found.acknowledged == 2 - text = page(root) - assert "· 2 matches" in text - assert text.count("| [INF-002](") == 2 + assert pattern_report.findings().acknowledged == 2 + assert page(root).count("| [INF-002](") == 2