diff --git a/luria/ack.py b/luria/ack.py index c468d6b2..b7b47f5b 100644 --- a/luria/ack.py +++ b/luria/ack.py @@ -95,7 +95,7 @@ def plan(code: str, docs=None, result=None) -> tuple[str, list[ref_status.Citati return "", [], (f"{code} is not cited anywhere this record scans — " f"nothing to acknowledge") - loud = [c for c in sites if c.excused_by is None] + loud = [c for c in sites if c.loud] if not loud: return "", [], (f"every citation of {code} is already acknowledged " f"({len(sites)} site(s))") diff --git a/luria/adr_pending.py b/luria/adr_pending.py index def9e1d0..2aede143 100644 --- a/luria/adr_pending.py +++ b/luria/adr_pending.py @@ -96,7 +96,7 @@ def pending() -> list[Pending]: sites = cited.get(doc.code, []) rows.append(Pending(doc.code, doc.number, status, doc.title, _date(doc.meta), len(sites), - sum(1 for c in sites if c.excused_by is None), + sum(1 for c in sites if c.loud), doc.path)) return sorted(rows, key=lambda r: (r.date is None, r.date, -r.cites, r.code)) diff --git a/luria/config.py b/luria/config.py index 11910d54..928bd6e3 100644 --- a/luria/config.py +++ b/luria/config.py @@ -207,6 +207,12 @@ def _own_version() -> str: # is what CI wants: a green build then means the references were # verified rather than merely remembered. "network": "auto", + # The config-level form of the `inactive-ok` directive: citations of + # a retired document that need no acknowledgement, named by a + # one-hop pattern over the reserved `cites` edge, each with its + # reason. Empty means every such citation is judged at its site, + # which is what every project had before the key. + "inactive_ok": [], }, # Whole records nested inside this one (ADR-077, ADR-078). Empty for the # ordinary project, which contains no others. @@ -2072,6 +2078,59 @@ def parsed(self): return self._parsed +# The edge an `inactive_ok` pattern walks. Reserved rather than declared: +# it is the citation the lint already reads out of a document's text, which +# no reference field holds, so it is never a `patterns.X` hop. +CITES = "cites" + + +@dataclass(frozen=True) +class InactiveOk: + """A citation of a retired document that needs no acknowledgement: + the config-level form of the `inactive-ok` directive. + + lint: + inactive_ok: + - match: "(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION {status: 'Answered'})" + reason: a split parent cites the Answered child split off it + + Quote the pattern when it has a property map: YAML reads an unquoted + `{` as the start of a mapping and refuses the line. + + The pattern is one hop over `cites`, the reserved edge meaning "this + document's text cites that one"; the arrow points from the citer to the + cited, either way round. A citation of a document that is not in force + is sanctioned when the document it sits in matches the citer's node and + the cited document matches the other: labels are schemes, and a + property is literal equality on any frontmatter field, as in + `patterns.X`. A citation from anywhere else — another scheme, another + status, a file that is no document of the record — is judged as before. + + A directive says why at the site; a rule says why once, for every site + it matches, so `reason` is mandatory for the same purpose. An + `inactive-ok` that a rule has made redundant is reported stale, and a + rule that sanctions no citation is reported too: a dead rule otherwise + reads exactly like a working one (DP-015).""" + index: int + match: str + reason: str + _parsed: object = dcfield(default=None, repr=False, compare=False) + + @property + def key(self) -> str: + return f"lint.inactive_ok[{self.index}]" + + @property + def citer(self): + """The node the `cites` arrow leaves.""" + return self._parsed.path.near(0) + + @property + def cited(self): + """The node the `cites` arrow lands on.""" + return self._parsed.path.far(0) + + @dataclass(frozen=True) class Chain: """A relation walked transitively and rendered as sequences (#171). @@ -2292,6 +2351,9 @@ class Config: # key that `index` has to reach into the site table to read is DP-16's # awkwardness — a distinction the layout had stopped expressing. include_records: tuple[str, ...] = () + # Citations of retired documents that need no acknowledgement + # (`lint.inactive_ok`, see `InactiveOk`). + inactive_ok: tuple[InactiveOk, ...] = () site: Site = None # type: ignore[assignment] _raw: dict = dcfield(default_factory=dict, repr=False) @@ -2622,6 +2684,7 @@ def load(root: Path | None = None, text: str | None = None, mute=tuple(raw["lint"]["mute"]), narrow_terms=tuple(raw["lint"].get("narrow_terms", [])), network=str(raw["lint"].get("network", "auto")), + inactive_ok=_inactive_ok(raw["lint"].get("inactive_ok"), schemes), include_records=tuple(raw.get("include_records", ())), site=_site(raw, root), _raw=raw, @@ -2668,6 +2731,78 @@ def _patterns(raw: dict, schemes: dict) -> dict[str, NamedPattern]: return out +def _inactive_ok(raw, schemes: dict) -> tuple[InactiveOk, ...]: + """Every `lint.inactive_ok` rule, refused by name when it cannot mean + what it says. + + The pattern is parsed by the one grammar every pattern uses, then held + to what a citation is: one hop, typed `cites` alone, fixed length, no + `WHERE NOT` (a condition on a citation is a widening for when a record + needs one). Labels and properties are checked as `patterns.X` checks + them, with the `cites` hop left out — it is no reference field.""" + from types import SimpleNamespace + + from . import consistency + from .patterns import Path as PatternPath + from .patterns import Pattern, parse + if not raw: + return () + if not isinstance(raw, list): + raise ValueError( + "luria.yaml: lint.inactive_ok: has to be a list of entries, each " + "a `match:` pattern and a `reason:`, not " + f"{type(raw).__name__}") + out: list[InactiveOk] = [] + for i, entry in enumerate(raw): + key = f"lint.inactive_ok[{i}]" + where = f"luria.yaml: {key}: " + if not isinstance(entry, dict): + raise ValueError( + where + f"has to be a mapping with `match:` and `reason:`, " + f"not {entry!r}") + if unknown := set(entry) - {"match", "reason"}: + raise ValueError( + where + f"{', '.join(sorted(map(str, unknown)))} is not a key " + f"— an entry is its `match:` and its `reason:`") + reason = str(entry.get("reason") or "").strip() + if not reason: + raise ValueError( + where + "needs a `reason` — a rule excuses every citation it " + "matches, and silence that carries no argument is " + "indistinguishable from an oversight") + source = str(entry.get("match") or "") + pattern = parse(key, source, where=where) + if pattern.negations: + raise ValueError( + where + "`WHERE NOT` is not taken here — a rule names the " + "citer and the cited document, and nothing else") + hops = pattern.path.hops + if len(hops) != 1: + raise ValueError( + where + f"has {len(hops)} hops; a rule takes exactly one hop " + f"— the citation, `-[:{CITES}]->`") + (hop,) = hops + if hop.fields != (CITES,): + raise ValueError( + where + f"`[:{'|'.join(hop.fields)}]` — the hop is `[:{CITES}]` " + f"and only that: it is the citation in a document's text, " + f"which no reference field holds") + if hop.variable: + raise ValueError( + where + "a variable-length hop is not taken here — a citation " + "is one document's text naming another") + out.append(InactiveOk(i, source.strip(), reason, _parsed=pattern)) + # Labels and properties, by the checks `patterns.X` gets; the hop is left + # out because `cites` is no reference field. + stripped = { + str(r.index): SimpleNamespace(parsed=Pattern( + r.key, r.match, PatternPath(r._parsed.path.nodes, ()), ())) + for r in out} + consistency.check_patterns(stripped, schemes, + declared_at="lint.inactive_ok[{}]") + return tuple(out) + + def _chains(raw: dict, schemes: dict, root: Path, patterns: dict | None = None) -> dict[str, Chain]: """Every declared chain, checked against the schemes it walks. diff --git a/luria/config_doc.py b/luria/config_doc.py index 88c0adc8..a54b714f 100644 --- a/luria/config_doc.py +++ b/luria/config_doc.py @@ -68,8 +68,8 @@ from . import config as config_mod from .adr_index import escape_cell -from .config import (FAMILIES, Chain, FieldGroup, Fragment, Journal, - NamedPattern, PlainField, Reference, Remote, +from .config import (FAMILIES, Chain, FieldGroup, Fragment, InactiveOk, + Journal, NamedPattern, PlainField, Reference, Remote, RemoteScheme, RequiredWhen, Scheme, Site, TagGroup, View, Vocabulary, VocabularyTable, current) @@ -259,6 +259,11 @@ "field, which is how a line passes through a document of another " "kind — a claim to the claims it follows from, through the inference " "that concludes it."), + InactiveOk: ("Citation rules — `lint.inactive_ok[N]`", + "One entry per kind of citation of a retired document that needs no " + "acknowledgement: a one-hop pattern over the reserved `cites` edge, and " + "the reason. The `inactive-ok` directive says this at one site; an " + "entry says it once, for every site it matches."), View: ("Views — `views.X`", "One entry per Jinja template rendered over the record into a file. " "Every other view is a generator luria ships; a view is one the project " @@ -293,7 +298,7 @@ #: of the page instead of nowhere. ORDER = [Scheme, Vocabulary, PlainField, RequiredWhen, Reference, TagGroup, FieldGroup, VocabularyTable, Fragment, Journal, Chain, - NamedPattern, View, Remote, + NamedPattern, InactiveOk, View, Remote, RemoteScheme, Site] @@ -428,6 +433,21 @@ def sections() -> list[tuple[str, type, str]]: "lockfile alone, for the hermetic build; `require` makes " "an unreachable remote a failure, so a green run means the " "references were verified rather than remembered.", + "inactive_ok": "The config-level form of the `inactive-ok` " + "directive: a list of entries, each a `match:` " + "pattern and a mandatory `reason:`. The pattern is " + "one hop over the reserved `cites` edge — " + "`(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION " + "{status: 'Answered'})` — and a citation of a " + "retired document needs no acknowledgement when the " + "document it sits in matches the citing node and the " + "cited document the other. Labels are schemes and " + "properties are literal equality on any frontmatter " + "field, as in `patterns.X`; more than one hop, " + "another edge type, variable length and `WHERE NOT` " + "are refused. A directive the rule made redundant, " + "and a rule that sanctions no citation, are both " + "`stale-directives` findings.", }), ] @@ -474,6 +494,7 @@ def swap(m: re.Match) -> str: Scheme: {"prefix"}, # `schemes.ADR` → prefix "ADR" Journal: {"name"}, # `journals.devlog` → name "devlog" NamedPattern: {"name"}, # `patterns.follows_from` → its name + InactiveOk: {"index"}, # `lint.inactive_ok[0]` → its position View: {"name"}, # `views.bootloader` → its name Remote: {"prefix"}, # …but `Remote.name` IS a key, deliberately RemoteScheme: {"prefix"}, diff --git a/luria/consistency.py b/luria/consistency.py index f544da12..164aaa24 100644 --- a/luria/consistency.py +++ b/luria/consistency.py @@ -354,10 +354,14 @@ def _hop_text(hop) -> str: return f"-{inner}->" if hop.right else f"<-{inner}-" -def check_patterns(patterns: dict, schemes: dict) -> None: +def check_patterns(patterns: dict, schemes: dict, + declared_at: str = "patterns.{}") -> None: """Refuse the first pattern that cannot match what it says: per pattern in order, a name a reference already uses, then each path (the match, - then each `WHERE NOT`) node by node and hop by hop.""" + then each `WHERE NOT`) node by node and hop by hop. + + `declared_at` spells where each pattern was declared, for the patterns written + somewhere other than `patterns.X` (`lint.inactive_ok[N]`).""" from .config import nameable, spelled derived = _derived(schemes, {}, patterns=patterns) order = list(patterns) @@ -368,7 +372,7 @@ def path(name, w): return (p.path, *p.negations)[w] def where(name): - return f"luria.yaml: patterns.{name}: " + return f"luria.yaml: {declared_at.format(name)}: " for name, prefix in derived.get("pattern_shadows", ()): found.append(((order.index(name), -1, 0, 0, 0), where(name) + ( diff --git a/luria/logic/acknowledgements.lp b/luria/logic/acknowledgements.lp index f56266b7..1a2729e8 100644 --- a/luria/logic/acknowledgements.lp +++ b/luria/logic/acknowledgements.lp @@ -8,7 +8,8 @@ % file P; resolves(C) and active(C) for local documents; ann(A, P, K), the % A-th annotation found, of kind K, in file P; ann_code(A, C); ann_line(A, L) % for each line it governs, or ann_file(A) when it governs the whole file; -% ann_problem(A) when it is malformed. +% ann_problem(A) when it is malformed; sanctioned(I) when a `lint.inactive_ok` +% rule matches the document the site sits in and the document it cites. covers(A, L) :- ann(A, P, _), ann_file(A), site(_, P, L, _). covers(A, L) :- ann_line(A, L). @@ -20,11 +21,12 @@ can(I, A, K) :- site(I, P, L, C), ann(A, P, K), usable(A), ann_code(A, C), covers(A, L). % What a site needs excusing for. A code naming nothing here wants an -% `unresolved-ok`; a retired document wants an `inactive-ok`; a document in -% force is no finding, so only a mention is recorded against it — which is -% what keeps that mention alive when its document goes Active (ADR-105). +% `unresolved-ok`; a retired document wants an `inactive-ok`, unless it is +% cited where a `lint.inactive_ok` rule allows; a document in force is no +% finding, so only a mention is recorded against it — which is what keeps +% that mention alive when its document goes Active (ADR-105). dangling(I) :- site(I, _, _, C), not resolves(C). -retired(I) :- site(I, _, _, C), resolves(C), not active(C). +retired(I) :- site(I, _, _, C), resolves(C), not active(C), not sanctioned(I). wants(I, "unresolved-ok") :- dangling(I). wants(I, "inactive-ok") :- retired(I). diff --git a/luria/patterns.py b/luria/patterns.py index 0d219bac..02ccd444 100644 --- a/luria/patterns.py +++ b/luria/patterns.py @@ -105,10 +105,11 @@ def _parser() -> lark.Lark: return lark.Lark(grammar, parser="lalr", maybe_placeholders=True) -def parse(name: str, source: str) -> Pattern: +def parse(name: str, source: str, where: str = "") -> Pattern: """`source` as a pattern, or a `PatternError` saying what is outside the - subset.""" - where = f"luria.yaml: patterns.{name}: " + subset. `where` names the key it was written at, for a pattern declared + somewhere other than `patterns.X` (`lint.inactive_ok`).""" + where = where or f"luria.yaml: patterns.{name}: " text = str(source or "") if not text.strip(): raise PatternError(where + "is empty — a pattern is a path, such as " diff --git a/luria/ref_status.py b/luria/ref_status.py index f00c5627..120bbe93 100644 --- a/luria/ref_status.py +++ b/luria/ref_status.py @@ -100,6 +100,9 @@ class Doc: title: str path: Path active: bool + scheme: str = "" # the prefix of the scheme it belongs to + # Its frontmatter, for a `lint.inactive_ok` property to read. + meta: dict = field(default_factory=dict, compare=False, repr=False) def _load_scheme(scheme) -> dict[str, Doc]: @@ -118,7 +121,8 @@ def _load_scheme(scheme) -> dict[str, Doc]: for doc in builder.load_scheme(scheme): status = doc.status_value docs[doc.code] = Doc(doc.code, status, doc.title, doc.path, - status == scheme.active) + status == scheme.active, scheme.prefix, + doc.meta) return docs @@ -245,10 +249,23 @@ class Citation: line: int code: str excused_by: Annotation | None = field(default=None, compare=False) + # The `lint.inactive_ok` rule this citation matches, as its key: nothing + # to acknowledge, and so nothing for an `inactive-ok` to answer for. + sanctioned_by: str = field(default="", compare=False) def __str__(self) -> str: return f"{_rel(self.path)}:{self.line}" + @property + def sanctioned(self) -> bool: + return bool(self.sanctioned_by) + + @property + def loud(self) -> bool: + """Whether this site still wants a reader's attention: nothing + acknowledges it and no `lint.inactive_ok` rule sanctions it.""" + return self.excused_by is None and not self.sanctioned + @dataclass class Scan: @@ -267,6 +284,8 @@ class Scan: # annotation's position in `annotations`: whether it answers for any # site, and what a malformed one left unanswered (code → sites). unused: frozenset[int] = frozenset() + # The `lint.inactive_ok` rules, by index, that sanctioned some site. + sanctioning: frozenset[int] = frozenset() lost: dict[int, dict[str, int]] = field(default_factory=dict) def _index(self, ann: Annotation) -> int: @@ -429,15 +448,49 @@ def _scan(files: list[Path] | None = None, docs: dict[str, Doc] | None = None) - for m in scheme.temp_pattern.finditer(bare)} sites += [(path, line_no, code) for code in sorted(codes) if own.get(path) != code] - _acknowledge(result, sites, docs) + _acknowledge(result, sites, docs, own) for pool in (result.cited, result.dangling): for sites in pool.values(): sites.sort(key=lambda c: (str(c.path), c.line)) return result +def _matches(node, doc: Doc) -> bool: + """Whether a pattern node holds of a document: its label is the + document's scheme and each property is literal equality on a frontmatter + field, a list field holding the value among its members — the reading + `pattern_rules` gives `status/2` and `value/4`.""" + from .facts import field_rows + if node.label != doc.scheme: + return False + for key, value in node.props: + if key == "status": + if doc.status != value: + return False + elif not any(f == key and v == value + for _, f, _, v in field_rows(doc.code, doc.meta)): + return False + return True + + +def _sanctioned(path: Path, code: str, docs: dict[str, Doc], + own: dict[Path, str]) -> int | None: + """The first `lint.inactive_ok` rule admitting this citation, by index: + the file the site sits in is one of the record's documents and matches + the rule's citing node, and the cited document, not in force, matches + the other. A file that is no document here is never a sanctioned + citer.""" + cited, citer = docs.get(code), docs.get(own.get(path, "")) + if cited is None or citer is None or cited.active: + return None + return next((rule.index for rule in current().inactive_ok + if _matches(rule.citer, citer) + and _matches(rule.cited, cited)), None) + + def _acknowledge(result: Scan, sites: list[tuple[Path, int, str]], - docs: dict[str, Doc]) -> None: + docs: dict[str, Doc], + own: dict[Path, str] | None = None) -> None: """File every site under `cited` or `dangling`, with the annotation that answers for it, and record which annotations answer for nothing. @@ -448,12 +501,23 @@ def _acknowledge(result: Scan, sites: list[tuple[Path, int, str]], the first `mention-ok`, which claims nothing and so answers either. A document in force is no finding, so only a mention is recorded against it, which is what keeps that mention alive when its document goes - Active (ADR-105).""" + Active (ADR-105). Nor is a retired one cited where a `lint.inactive_ok` + rule allows: that site is `sanctioned`, and an `inactive-ok` over it + answers for nothing.""" from . import logic from .facts import Fact + own = own or {} found: list[Fact] = [] + sanctioned: dict[int, int] = {} + if current().inactive_ok: + for i, (path, _, code) in enumerate(sites): + rule = _sanctioned(path, code, docs, own) + if rule is not None: + sanctioned[i] = rule for i, (path, line, code) in enumerate(sites): found.append(Fact("site", (i, str(path), line, code))) + if i in sanctioned: + found.append(Fact("sanctioned", (i,))) for code in {c for _, _, c in sites} & set(docs): found.append(Fact("resolves", (code,))) if docs[code].active: @@ -473,7 +537,11 @@ def _acknowledge(result: Scan, sites: list[tuple[Path, int, str]], for i, (path, line, code) in enumerate(sites): ann = result.annotations[excused[i]] if i in excused else None pool = result.cited if code in docs else result.dangling - pool.setdefault(code, []).append(Citation(path, line, code, ann)) + rule = (f"lint.inactive_ok[{sanctioned[i]}]" if i in sanctioned + else "") + pool.setdefault(code, []).append( + Citation(path, line, code, ann, rule)) + result.sanctioning = frozenset(sanctioned.values()) result.unused = frozenset(a for (a,) in derived.get("unused", ())) for a, code, _ in derived.get("lost", ()): lost = result.lost.setdefault(a, {}) @@ -509,7 +577,7 @@ def flagged(result: Scan | None = None, docs: dict[str, Doc] | None = None): doc = docs[code] if doc.active: continue - loud = [c for c in sites if c.excused_by is None] + loud = [c for c in sites if c.loud] if loud: rows.append((doc, loud, len(sites) - len(loud))) return sorted(rows, key=lambda r: (-len(r[1]), r[0].code)) @@ -649,9 +717,28 @@ def stale_annotations(result: Scan | None = None, else: active = sorted(c for c in ann.codes if c in docs and docs[c].active) + # Cited in scope, but where a `lint.inactive_ok` rule + # allows: the site needs nothing, so say which rule took + # over rather than that the citation is gone. + allowed = { + c: site.sanctioned_by for c in sorted(ann.codes) + for site in result.cited.get(c, ()) + if site.sanctioned and site.path == ann.directive.path + and ann.covers(site.line)} why = (f"{', '.join(active)} is Active now" if active + else "; ".join( + f"{c} is cited here where `{rule}` allows it" + for c, rule in allowed.items()) + if allowed else f"nothing in scope cites {named}") out.append(f"{ann}: annotation no longer applies — {why}") + # A rule is a directive written once in the config, and goes stale the + # same way: one that sanctions nothing reads exactly like one that works + # unless something says so (DP-015). + for rule in current().inactive_ok: + if rule.index not in result.sanctioning: + out.append(f"luria.yaml: {rule.key}: rule sanctions no citation " + f"— `{rule.match}` ({rule.reason})") return sorted(out) diff --git a/record/changelog.d/claude-laughing-brahmagupta-17c985.md b/record/changelog.d/claude-laughing-brahmagupta-17c985.md new file mode 100644 index 00000000..91d92117 --- /dev/null +++ b/record/changelog.d/claude-laughing-brahmagupta-17c985.md @@ -0,0 +1,14 @@ +### Added + +- `lint.inactive_ok`, the config-level form of the `inactive-ok` directive: + a list of entries, each a one-hop path pattern over the reserved `cites` + edge and a mandatory reason. + `match: "(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION {status: 'Answered'})"` + lets an open question cite an answered one without warning. A citation + from any other scheme, any other status, or a file outside the record + still warns. Properties are literal equality on any frontmatter field, as + in `patterns.X`. An `inactive-ok` made redundant by a rule is reported + stale, naming the rule, and so is a rule that sanctions no citation. The + loader refuses, by `lint.inactive_ok[N]`, anything but one `cites` hop, + variable length, `WHERE NOT`, an unknown scheme or field, and a missing + reason ([ADR-tmpfu1s6](record/decisions.d/ADR-tmpfu1s6.md)). diff --git a/record/decisions.d/ADR-tmpfu1s6.md b/record/decisions.d/ADR-tmpfu1s6.md new file mode 100644 index 00000000..1b069635 --- /dev/null +++ b/record/decisions.d/ADR-tmpfu1s6.md @@ -0,0 +1,144 @@ +--- +status: Active +title: 'A citation that needs no acknowledgement may be declared once, as a pattern' +version: 1 +tags: +- config +- mechanism +date: '2026-10-21' +summary: >- + `lint.inactive_ok` is the config-level form of the `inactive-ok` + directive: a list of one-hop path patterns over the reserved `cites` edge, + each with a mandatory reason. A citation of a retired document is + sanctioned when the document it sits in matches the citing node and the + cited document matches the other, by scheme and by literal equality on + any frontmatter field. A citation from anywhere else is judged as before. + An `inactive-ok` the rule made redundant, and a rule that sanctions + nothing, are both reported stale. The pattern is read by the grammar + `patterns.X` uses, and anything beyond one `cites` hop is refused by + name. Rejected: `active` as a list, per-site annotations only, a global + flag counting a status as in force, and a per-scheme `citable_from` + keyed by status. +influenced_by: +- ADR-035 +- ADR-085 +- ADR-105 +- DP-010 +- DP-015 +--- + +# ADR-tmpfu1s6: A citation that needs no acknowledgement may be declared once, as a pattern + +## Context + +Whether a cited document is in force is one word per scheme, `active`. Every +citation of a document at any other status is a `retired-citations` warning +until an `inactive-ok` acknowledges it ([ADR-035](ADR-035.md)). For decisions that is +right: a `Superseded` ADR is cited deliberately or by mistake, and only the +author knows which. + +A question record breaks the assumption that every citer is in the same +position. nucleation's `QUESTION` scheme has `active: Open` and the statuses +`Answered`, `Dissolved`, `Deferred` and `Superseded`. An open question that is +too broad is split, and the child gets answered first. The parent still +cites it, because the answered child is part of the parent's argument. That +citation is the record working. Every other citation of an answered +question is still worth a warning: it should cite the claim or theory that +answered it, or it still treats the question as open. + +Before this decision the only remedy was an `inactive-ok` at each parent's +site. nucleation carried thirteen of them, one per answered question, each +saying the same thing. + +## Decision + +**A record may state the acknowledgement once, in its lint settings.** + +```yaml +lint: + inactive_ok: + - match: "(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION {status: 'Answered'})" + reason: a split parent cites the Answered child split off it +``` + +The entry is the config-level form of the `inactive-ok` directive, and is +named for it. A directive says why at one site; a rule says why once, for +every site it matches. + +**The pattern is a path pattern, over one reserved edge.** `cites` means +"this document's text cites that one", the citation the lint already reads +out of a document. It is not a reference field, which is why a rule is not +a `patterns.X` entry. The arrow points from the citer to the cited, either +way round (`<-[:cites]-` reverses the roles). A node's label is a scheme, +and a property is literal equality on any frontmatter field (`status`, +`role`, `warrant`, …), exactly as in `patterns.X`. + +**What it sanctions.** A citation of a document that is not in force is +sanctioned when the document the site sits in matches the citing node and +the cited document matches the other. A citation from another scheme, +another status, or a file that is no document of the record is judged as +before. `luria/ref_status.py` states `sanctioned(I)`, and +`luria/logic/acknowledgements.lp` reads a sanctioned site as not retired. +So the site wants no `inactive-ok`, and one written over it answers for +nothing. It is reported stale with the rule that took over ("cited here +where `lint.inactive_ok[0]` allows it"), which is how a record finds the +annotations to delete. + +**A rule that sanctions nothing is reported** in the same +`stale-directives` class, with its pattern and reason. A dead rule +otherwise reads exactly like a working one ([DP-015](../principles.d/DP-015.md)). + +`Citation.loud`, not acknowledged and not sanctioned, is the one test of +whether a site still wants attention. `flagged`, `luria ack` and the +pending report read it. The export's `excused` column still means "a +directive acknowledges it", as its schema says. + +**One grammar, held to what a citation is.** The pattern is parsed by the +grammar every pattern uses (`luria/patterns.lark`), and its labels and +properties are checked by the checks `patterns.X` gets. The loader then +refuses, naming `lint.inactive_ok[N]`: + +- anything but exactly one hop; +- a hop typed anything but `cites`, alone; +- a variable-length hop; +- `WHERE NOT`, which could be widened later if a record needs a condition; +- a label that is not a scheme, a property that scheme cannot hold, and an + unlabelled node; +- a missing or empty `reason`, for the reason a directive's reason is + mandatory; +- an entry that is not a mapping of `match` and `reason`. + +## Alternatives considered + +- **`active` as a list.** Counting `Answered` as in force silences the + parent's citation, and also every citation that should warn: a theory + still treating the question as open, a note that should cite the answer. + Those are the warnings the scheme exists for. +- **Per-site annotations only.** This is the status quo, and it works. But + each annotation restates a rule that holds for every parent and every + answered child. The record pays for one rule thirteen times, and the + fourteenth is a warning until someone writes the same sentence again. +- **A global flag that counts a status as in force for some purpose.** It + has the same defect as the list: it cannot say who the citer is. +- **A per-scheme `citable_from`, keyed by status:** `Answered: + [QUESTION:Open]` on the cited scheme. It was built first. It could only + express status: status words are scheme-specific, so a rule over a + claim's `role` or `warrant` had no spelling, and the `PREFIX:Status` + string was a second, ad hoc notation for what a pattern node already + says. The pattern needs no new syntax, reads like every other pattern in + the config, and takes any field. + +## Consequences + +- A record states a citation rule once, in its config, and the redundant + annotations surface as stale findings to delete. +- A rule applies only to record documents. A citation from code or from a + page outside every scheme cannot match a node, so it is judged as before. +- A pattern with a property map must be quoted in YAML, which reads an + unquoted `{` as a mapping. The configuration reference's example is + quoted, and a test parses it. +- A sanctioned site is not counted in the "acknowledged" totals. It is not + a suppression: the rule and its reason are written in `luria.yaml`. +- Fired on a copy of nucleation with the rule above: the thirteen parent + annotations went stale, each naming `lint.inactive_ok[0]`, and no other + finding changed. The tests are in `tests/test_inactive_ok_rules.py`. diff --git a/record/devlog.d/2026/10/21/173451.md b/record/devlog.d/2026/10/21/173451.md new file mode 100644 index 00000000..e0d4e874 --- /dev/null +++ b/record/devlog.d/2026/10/21/173451.md @@ -0,0 +1,64 @@ +--- +title: 'lint.inactive_ok, fired on a question record' +created: '2026-10-21T17:34:51' +tags: [] +--- + +**The first shape was too narrow.** nucleation's `QUESTION` scheme warned +at every citation of an `Answered` question. The citations from the open +parent that the answered child was split off are correct, and the rest are +worth the warning. The first version of this branch added a per-scheme +`citable_from: {Answered: [QUESTION:Open]}`. Review found it hardcoded +status: status words belong to one scheme, so a rule over a claim's `role` +or `warrant` had no spelling, and `PREFIX:Status` was a second notation for +what a pattern node already says. It became `lint.inactive_ok` +([ADR-tmpfu1s6](../../record/decisions.d/ADR-tmpfu1s6.md)): a one-hop pattern over a reserved `cites` edge, plus a +reason. + +**Reusing the pattern machinery without forking it.** `patterns.parse` and +`consistency.check_patterns` each gained one optional argument that says +where the pattern was declared, so their refusals name +`lint.inactive_ok[N]` rather than `patterns.X`. The label and property +checks are reused by passing the pattern with its hop removed, because +`cites` is no reference field and the hop checks would refuse it. The +restrictions on a citation (one hop, `cites` alone, fixed length, no +`WHERE NOT`) are checked in `config._inactive_ok`. Matching happens in +Python, in `ref_status._matches`. It reads a property as `pattern_rules` +does, `status` directly and any other field through `field_rows`, and states +`sanctioned(I)` for the logic program as before. + +**Two traps.** +- In `check_patterns`, the new argument was first called `key`, which a + loop variable in the property check shadows. The refusal then named the + field instead of the rule. A test caught it. +- A pattern with a property map has to be quoted in YAML. Unquoted, the + `{` starts a flow mapping and the line fails to parse. The config doc's + example test caught this in the docstring. + +**The trap from the first version still holds: three readers decided +"loud" for themselves.** `flagged`, `luria ack` and the pending report each +tested `excused_by is None`. `Citation.loud` is now the one test. + +**Fired on a real case ([DP-006](../../record/principles.d/DP-006.md)).** I compared the reference-status report +(`python -m luria.ref_status --all`) on a copy of nucleation with and +without: + +```yaml +lint: + inactive_ok: + - match: "(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION {status: 'Answered'})" + reason: a split parent cites the Answered child split off it +``` + +- 13 more stale directives, one per `Answered` question, each in the open + parent and each reading "QUESTION-249 is cited here where + `lint.inactive_ok[0]` allows it". +- The unacknowledged count was unchanged at 480 rows, so no warning outside + the rule was silenced. +- The acknowledged count fell from 10265 to 10231. Those 34 sites are now + sanctioned rather than acknowledged. +- The rule sanctioned citations, so no dead-rule row appeared. A dead rule + is pinned by `tests/test_inactive_ok_rules.py`. + +A full `luria lint` on the copy was stalled by throttled arXiv lookups, so +the report above is the whole of what this rule can change. diff --git a/tests/test_inactive_ok_rules.py b/tests/test_inactive_ok_rules.py new file mode 100644 index 00000000..309d0559 --- /dev/null +++ b/tests/test_inactive_ok_rules.py @@ -0,0 +1,247 @@ +# tests/test_inactive_ok_rules.py +"""`lint.inactive_ok`: the config-level form of the `inactive-ok` directive. + +An entry is a one-hop path pattern over the reserved `cites` edge and a +mandatory reason: + + lint: + inactive_ok: + - match: "(:QN {status: 'Open'})-[:cites]->(:QN {status: 'Answered'})" + reason: a split parent cites the answered child split off it + +A citation of a retired document is then sanctioned when the document it +sits in matches the citing node and the cited document matches the other. +The case it was built for is a question record: the open parent cites the +answered child split off it, which is the record working; every OTHER +citation of an answered question still warns. + +`QN` and `CM` rather than live prefixes, per the fixture-code rule. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from _config import merged + +from luria import ack, config, ref_status + +RULE = {"match": "(:QN {status: 'Open'})-[:cites]->(:QN {status: 'Answered'})", + "reason": "a split parent cites the answered child split off it"} + +BASE = { + "issue_url": "https://example.test/issues/{n}", + "vocabularies": { + "qstatuses": {"Open": {"blurb": "being asked"}, + "Answered": {"blurb": "settled"}, + "Deferred": {"blurb": "parked"}}, + "cstatuses": {"Active": {"blurb": "held"}, + "Retracted": {"blurb": "withdrawn"}}, + }, + "schemes": { + "QN": {"dir": "record/questions.d", "output": "docs/questions", + "active": "Open", + "fields": {"status": {"vocabulary": "qstatuses"}, + "kind": {"many": True}}}, + "CM": {"dir": "record/claims.d", "output": "docs/claims", + "active": "Active", + "fields": {"status": {"vocabulary": "cstatuses"}}}, + }, +} + + +def project(root: Path, monkeypatch, rules=(RULE,)) -> Path: + (root / "luria.yaml").write_text( + merged(BASE, {"lint": {"inactive_ok": list(rules)}})) + for d in ("record/questions.d", "record/claims.d", "docs"): + (root / d).mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("LURIA_ROOT", str(root)) + config.reset() + return root + + +def doc(root: Path, code: str, status: str, body: str = "", + kind: str = "") -> Path: + folder = "questions.d" if code.startswith("QN") else "claims.d" + path = root / "record" / folder / f"{code}.md" + extra = f"kind: {kind}\n" if kind else "" + path.write_text(f"---\nstatus: {status}\n{extra}title: 'Entry {code}'\n" + f"date: '2026-01-01'\n---\n\n# {code}: Entry\n\n{body}\n") + return path + + +def corpus(root: Path, parent_body: str = "Split off: QN-001.") -> list[Path]: + """An answered question, the open parent citing it, and every other kind + of citer: a question at another status, another scheme, a plain file.""" + return [ + doc(root, "QN-001", "Answered", "The answered child."), + doc(root, "QN-002", "Open", parent_body), + doc(root, "QN-003", "Deferred", "Waits on QN-001."), + doc(root, "CM-001", "Active", "Settles QN-001."), + _write(root / "notes.md", "Still asking QN-001.\n"), + ] + + +def _write(path: Path, text: str) -> Path: + path.write_text(text) + return path + + +def _scan(files): + docs = ref_status.load_docs() + return docs, ref_status.scan(files, docs) + + +def _loud_files(result, docs, root): + rows = {d.code: loud for d, loud, _ in ref_status.flagged(result, docs)} + return sorted(c.path.relative_to(root).as_posix() + for c in rows.get("QN-001", [])) + + +# ── the sanctioned citation ────────────────────────────────────────────── + +def test_the_matched_citer_needs_no_acknowledgement(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + docs, result = _scan(corpus(root)) + parent = [c for c in result.cited["QN-001"] + if c.path.name == "QN-002.md"] + assert len(parent) == 1 and parent[0].sanctioned + assert not parent[0].loud + assert "record/questions.d/QN-002.md" not in _loud_files(result, docs, root) + + +@pytest.mark.parametrize("citer", [ + "record/questions.d/QN-003.md", # the right scheme, another status + "record/claims.d/CM-001.md", # another scheme + "notes.md", # not a record document at all +]) +def test_every_other_citer_still_warns(tmp_path, monkeypatch, citer): + root = project(tmp_path, monkeypatch) + docs, result = _scan(corpus(root)) + assert citer in _loud_files(result, docs, root) + + +def test_the_arrow_may_point_either_way(tmp_path, monkeypatch): + """`<-[:cites]-` reverses the roles: the node it leaves is the citer.""" + root = project(tmp_path, monkeypatch, rules=[{ + "match": "(:QN {status: 'Answered'})<-[:cites]-(:QN {status: 'Open'})", + "reason": RULE["reason"]}]) + docs, result = _scan(corpus(root)) + assert "record/questions.d/QN-002.md" not in _loud_files(result, docs, root) + assert "record/questions.d/QN-003.md" in _loud_files(result, docs, root) + + +def test_a_property_may_name_any_field(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, rules=[{ + "match": "(:QN {kind: 'parent'})-[:cites]->(:QN {status: 'Answered'})", + "reason": RULE["reason"]}]) + files = corpus(root) + doc(root, "QN-003", "Deferred", "Waits on QN-001.", kind="parent") + docs, result = _scan(files) + loud = _loud_files(result, docs, root) + assert "record/questions.d/QN-003.md" not in loud + assert "record/questions.d/QN-002.md" in loud + + +def test_no_rule_judges_exactly_as_before(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch, rules=()) + docs, result = _scan(corpus(root)) + assert "record/questions.d/QN-002.md" in _loud_files(result, docs, root) + + +def test_an_acknowledgement_over_a_sanctioned_site_goes_stale(tmp_path, + monkeypatch): + """The migration signal: an `inactive-ok` written before the rule existed + now answers for nothing, and says which rule took over.""" + root = project(tmp_path, monkeypatch) + files = corpus(root, parent_body=( + "\n" + "Split off: QN-001.")) + docs, result = _scan(files) + stale = ref_status.stale_annotations(result, docs) + assert any("QN-002.md" in s and "no longer applies" in s + and "QN-001 is cited here where `lint.inactive_ok[0]` " + "allows it" in s + for s in stale), stale + + +def test_a_rule_that_sanctions_nothing_is_reported(tmp_path, monkeypatch): + """A dead rule reads exactly like a working one unless something says so + (DP-015); the row carries the rule's reason.""" + root = project(tmp_path, monkeypatch, rules=[RULE, { + "match": "(:CM {status: 'Retracted'})-[:cites]->(:QN)", + "reason": "a retraction names the question it gave up on"}]) + docs, result = _scan(corpus(root)) + stale = ref_status.stale_annotations(result, docs) + dead = [s for s in stale if "lint.inactive_ok" in s] + assert len(dead) == 1, stale + assert "lint.inactive_ok[1]" in dead[0] + assert "a retraction names the question it gave up on" in dead[0] + + +def test_ack_offers_only_the_loud_sites(tmp_path, monkeypatch): + root = project(tmp_path, monkeypatch) + docs, result = _scan(corpus(root)) + _, sites, refusal = ack.plan("QN-001", docs, result) + assert refusal == "" + assert "QN-002.md" not in {c.path.name for c in sites} + assert len(sites) == 3 + + +# ── the declaration ────────────────────────────────────────────────────── + +def test_the_rule_is_parsed(tmp_path, monkeypatch): + project(tmp_path, monkeypatch) + (rule,) = config.current().inactive_ok + assert rule.reason == RULE["reason"] + assert (rule.citer.label, rule.cited.label) == ("QN", "QN") + assert rule.citer.props == (("status", "Open"),) + assert rule.cited.props == (("status", "Answered"),) + + +@pytest.mark.parametrize("rules, message", [ + ("(:QN)-[:cites]->(:QN)", + r"lint\.inactive_ok: has to be a list"), + (["(:QN)-[:cites]->(:QN)"], + r"lint\.inactive_ok\[0\]: has to be a mapping"), + ([{"match": "(:QN)-[:cites]->(:QN)"}], + r"lint\.inactive_ok\[0\]: needs a `reason`"), + ([{"match": "(:QN)-[:cites]->(:QN)", "reason": " "}], + r"lint\.inactive_ok\[0\]: needs a `reason`"), + ([{"match": "(:QN)-[:cites]->(:QN)", "reason": "r", "blurb": "b"}], + r"lint\.inactive_ok\[0\]: blurb is not a key"), + ([{"match": "(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: .*exactly one hop"), + ([{"match": "(:QN)-[:cites]->(:QN)-[:cites]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: .*exactly one hop"), + ([{"match": "(:QN)-[:refines]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: .*the hop is `\[:cites\]` and only that"), + ([{"match": "(:QN)-[:cites|refines]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: .*the hop is `\[:cites\]` and only that"), + ([{"match": "(:QN)-[:cites*]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: .*variable-length"), + ([{"match": "(a:QN)-[:cites]->(b:QN) WHERE NOT (b)-[:cites]->(a)", + "reason": "r"}], + r"lint\.inactive_ok\[0\]: .*`WHERE NOT`"), + ([{"match": "(:XX)-[:cites]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: `\(:XX\)`: XX is not a declared scheme"), + ([{"match": "(:CM {kind: 'x'})-[:cites]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: `\(:CM\)`: `kind` is not a field CM declares"), + ([{"match": "()-[:cites]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: `\(\)` needs a label"), + ([{"match": "MATCH (:QN)-[:cites]->(:QN)", "reason": "r"}], + r"lint\.inactive_ok\[0\]: write the pattern without `MATCH`"), + ([RULE, {"match": "(:QN)-[:cites]->(:QN)"}], + r"lint\.inactive_ok\[1\]: needs a `reason`"), +]) +def test_a_rule_that_cannot_mean_what_it_says_is_refused( + tmp_path, monkeypatch, rules, message): + (tmp_path / "luria.yaml").write_text( + merged(BASE, {"lint": {"inactive_ok": rules}})) + for d in ("record/questions.d", "record/claims.d", "docs"): + (tmp_path / d).mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("LURIA_ROOT", str(tmp_path)) + config.reset() + with pytest.raises(ValueError, match=message): + config.current()