From 985f8b650438b7b7339ec991e9714f793f967726 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:48:55 +0000 Subject: [PATCH 1/2] Add per-scheme citable_from: retired statuses citable from named citers A scheme may now declare which citers may cite one of its retired statuses without an acknowledgement: citable_from: Answered: [QUESTION:Open] A site whose citer document matches a declared (status, prefix, status) triple is `sanctioned`: acknowledgements.lp no longer reads it as retired, so it needs no inactive-ok and one written over it is reported stale, with the reason. Citation.loud is the single test the flagged report, `luria ack` and the pending report now use. The loader refuses an unknown cited status, the scheme's own `active` word, an undeclared citer scheme and an unknown citer status (consistency.lp), and malformed shapes (config.py). Fired on a copy of nucleation: its 13 parent-question inactive-ok annotations go stale and no other finding changes. Record: ADR-tmpfu1s6, changelog fragment, devlog entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KC8NN4NtUf8sd5unC6dpFV --- luria/ack.py | 2 +- luria/adr_pending.py | 2 +- luria/config.py | 50 +++++ luria/consistency.py | 52 ++++- luria/logic/acknowledgements.lp | 12 +- luria/logic/consistency.lp | 19 +- luria/ref_status.py | 58 +++++- .../claude-laughing-brahmagupta-17c985.md | 11 ++ record/decisions.d/ADR-tmpfu1s6.md | 117 +++++++++++ record/devlog.d/2026/10/21/173451.md | 46 +++++ tests/test_citable_from.py | 182 ++++++++++++++++++ 11 files changed, 534 insertions(+), 17 deletions(-) create mode 100644 record/changelog.d/claude-laughing-brahmagupta-17c985.md create mode 100644 record/decisions.d/ADR-tmpfu1s6.md create mode 100644 record/devlog.d/2026/10/21/173451.md create mode 100644 tests/test_citable_from.py 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..bfbaeedd 100644 --- a/luria/config.py +++ b/luria/config.py @@ -900,6 +900,23 @@ class Scheme: # Lowering this asks the sharper question — is the vocabulary *exercised* # — and is opt-in because the answer is a matter of what a scheme is for. uniform_share: float = 1.0 + # Retired statuses that particular citers may cite without an + # acknowledgement, as (cited status, citer prefix, citer status): + # + # citable_from: + # Answered: [QUESTION:Open] + # + # `active` is one word because it answers one question — is this document + # in force — and every citation of a document that is not draws a + # warning. Some retired documents have a citer for whom that warning is + # always wrong: an `Open` question naming the child question split off it + # is the record working, though the child is `Answered`. Widening `active` + # would silence every OTHER citation of an answered question too, and + # those are the ones that should cite whatever answered it instead. So + # the exemption names its citer exactly; anything else, a non-record file + # included, is judged as it always was. Empty for every scheme that sets + # nothing, which is the behaviour every project had before the key. + citable_from: tuple[tuple[str, str, str], ...] = () @property def view(self) -> Path: @@ -2902,6 +2919,38 @@ def _check_target_fields(where: str, rule, ref, schemes, many: bool = False) -> _check_cardinality(where, rule, plural_fields(target), many, prefix) +def _citable_from(prefix: str, raw) -> tuple[tuple[str, str, str], ...]: + """A scheme's `citable_from`, as (cited status, citer prefix, citer + status) triples, refused here when it is not the shape it has to be. + + Whether the words it names exist is a cross-scheme question, decided + once every scheme is known (`consistency.check_references`).""" + if not raw: + return () + where = f"luria.yaml: schemes.{prefix}.citable_from" + if not isinstance(raw, dict): + raise ValueError( + f"{where}: has to be a mapping of this scheme's status words to " + f"lists of `PREFIX:Status` citers, not {type(raw).__name__} " + f"(e.g. `Answered: [{prefix}:Open]`)") + out: list[tuple[str, str, str]] = [] + for status, citers in raw.items(): + if not isinstance(citers, list): + raise ValueError( + f"{where}.{status}: has to be a list of `PREFIX:Status` " + f"citers, not {citers!r} — write `[{citers}]` for one") + for citer in citers: + parts = str(citer).split(":") + if (not isinstance(citer, str) or len(parts) != 2 + or not all(p.strip() for p in parts)): + raise ValueError( + f"{where}.{status}: {citer!r} is not a `PREFIX:Status` " + f"citer — name the citing scheme and the status it has " + f"to be at (e.g. `{prefix}:Open`)") + out.append((str(status), parts[0].strip(), parts[1].strip())) + return tuple(out) + + def _alias_template(prefix: str, raw) -> str: """A scheme's `alias` template, validated for shape where it is read. @@ -3037,6 +3086,7 @@ def _schemes(raw: dict, root: Path, scaffolding: bool = False, field_groups=_field_groups(prefix, spec.get("field_groups", {})), uniform_ok=(spec.get("uniform_ok") or None), uniform_share=float(spec.get("uniform_share", 1.0)), + citable_from=_citable_from(prefix, spec.get("citable_from")), ) # Which cross-scheme declarations are wrong is decided by rules # (`luria/consistency.py`); the order they are raised in is this loop's. diff --git a/luria/consistency.py b/luria/consistency.py index f544da12..db5199d7 100644 --- a/luria/consistency.py +++ b/luria/consistency.py @@ -31,6 +31,12 @@ says where the assertion belongs instead. A chain walking a pattern (#371) holds the same line at the pattern's two ends, wherever its middle goes. +**An exemption names a citer that can exist.** `citable_from` lets a named +citer cite a retired status without acknowledgement. Its status has to be one +the scheme holds and not its `active` word (which needs no exemption), and +each citer a declared scheme at a word that scheme holds — otherwise the entry +exempts nothing, and looks exactly like one that works. + **A pattern names schemes and fields.** Every label a declared scheme, every hop a reference held by the node the arrow leaves and able to land on the node it reaches, every property a field the node can hold. A pattern that @@ -53,6 +59,9 @@ def _scheme_facts(schemes: dict, remotes=()) -> list[Fact]: out.append(Fact("role_retires_on", (prefix, scheme.retires_on))) if scheme.influence: out.append(Fact("role_influence", (prefix, scheme.influence))) + out.append(Fact("active_word", (prefix, scheme.active))) + out += [Fact("citable_from", (prefix, cited, by, at)) + for cited, by, at in scheme.citable_from] out += [Fact("status_word", (prefix, w)) for w in vocabulary(scheme)] out += [Fact("nameable", (prefix, f)) for f in nameable(scheme)] for ref in scheme.references: @@ -163,9 +172,11 @@ def _ref(schemes: dict, prefix: str, field: str): def check_references(schemes: dict, remotes=()) -> None: - """Refuse the first converse or relation invariant that cannot mean what - it says: per scheme in declaration order, its converses before its - invariants, each reference in order and each scheme it names in order.""" + """Refuse the first converse, relation invariant, role or + `citable_from` entry that cannot mean what it says: per scheme in + declaration order, its converses before its invariants, each reference + in order and each scheme it names in order, then its roles, then its + `citable_from` entries in the order they are written.""" from .config import nameable, spelled derived = _derived(schemes, {}, remotes) order = list(schemes) @@ -243,10 +254,45 @@ def where(prefix, field, part): f"{prefix}'s status vocabulary does not hold — the role says " f"which status means replaced; it does not add one (have: " f"{', '.join(vocabulary(schemes[prefix]))})"))) + for prefix, word in derived.get("citable_unknown", ()): + found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, word), + 0, 0, 0), ( + f"luria.yaml: schemes.{prefix}.citable_from: names {word!r}, " + f"which {prefix}'s status vocabulary does not hold — the key " + f"says which retired status a citer may cite; it does not add " + f"one (have: {', '.join(vocabulary(schemes[prefix]))})"))) + for prefix, word in derived.get("citable_active", ()): + found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, word), + 0, 0, 0), ( + f"luria.yaml: schemes.{prefix}.citable_from: names {word!r}, " + f"{prefix}'s `active` word — a document in force can be cited " + f"from anywhere already, so the entry exempts nothing"))) + for prefix, cited, by, at in derived.get("citable_by_unknown", ()): + found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, cited), + _citer_at(schemes, prefix, cited, by, at), 0, 0), ( + f"luria.yaml: schemes.{prefix}.citable_from.{cited}: names " + f"scheme {by!r}, which is not declared — a citer is a scheme " + f"and a status of it (have: {', '.join(sorted(schemes))})"))) + for prefix, cited, by, at in derived.get("citable_at_unknown", ()): + found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, cited), + _citer_at(schemes, prefix, cited, by, at), 0, 0), ( + f"luria.yaml: schemes.{prefix}.citable_from.{cited}: names " + f"'{by}:{at}', but {by}'s status vocabulary does not hold " + f"{at!r} — no {by} document could ever be that citer (have: " + f"{', '.join(vocabulary(schemes[by]))})"))) if found: raise ValueError(min(found)[1]) +def _cited_at(schemes: dict, prefix: str, word: str) -> int: + """Where a `citable_from` status sits in its declaration, for order.""" + return [c for c, _, _ in schemes[prefix].citable_from].index(word) + + +def _citer_at(schemes: dict, prefix: str, cited: str, by: str, at: str) -> int: + return schemes[prefix].citable_from.index((cited, by, at)) + + # ── the chains ──────────────────────────────────────────────────────────── def check_chains(chains: dict[str, dict], schemes: dict, diff --git a/luria/logic/acknowledgements.lp b/luria/logic/acknowledgements.lp index f56266b7..f1b172c8 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 the cited scheme's +% `citable_from` admits the document the site sits in as its citer. 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 from where its scheme says it may be; 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/logic/consistency.lp b/luria/logic/consistency.lp index 73031bd7..00c5cb42 100644 --- a/luria/logic/consistency.lp +++ b/luria/logic/consistency.lp @@ -5,7 +5,9 @@ % % Facts: scheme/1, remote/1, ref/2, ref_target/3, ref_converse/3, % ref_many/2, ref_invariant/3, nameable/2 (every field a scheme can hold), -% role_successor/2, role_retires_on/2, role_influence/2, status_word/2, and +% role_successor/2, role_retires_on/2, role_influence/2, status_word/2, +% active_word/2, citable_from/4 (scheme, cited status, citer scheme, citer +% status), and % per raw % chain: chain_scheme/2, chain_facet/2, chain_walks/3 (relation or sibling), % chain_output/1, chain_invariant/2; and per pattern: pattern/1, @@ -44,6 +46,17 @@ successor_undeclared(S, F) :- role_successor(S, F), not ref(S, F). retires_unknown(S, W) :- role_retires_on(S, W), not status_word(S, W). influence_undeclared(S, F) :- role_influence(S, F), not ref(S, F). +% ── citable_from ──────────────────────────────────────────────────────── +% A retired status a named citer may cite without acknowledgement. The +% status is one this scheme holds and is not its `active` word, which needs +% no exemption; the citer is a declared scheme at a word its vocabulary holds. +citable_unknown(S, W) :- citable_from(S, W, _, _), not status_word(S, W). +citable_active(S, W) :- citable_from(S, W, _, _), status_word(S, W), + active_word(S, W). +citable_by_unknown(S, W, B, A) :- citable_from(S, W, B, A), not scheme(B). +citable_at_unknown(S, W, B, A) :- citable_from(S, W, B, A), scheme(B), + not status_word(B, A). + % ── chains ────────────────────────────────────────────────────────────── chain_unknown(N) :- chain_scheme(N, S), not scheme(S). facet_unheld(N, X) :- chain_facet(N, X), chain_scheme(N, S), scheme(S), @@ -92,6 +105,10 @@ pattern_shadows(P, S) :- pattern(P), ref(S, P). #show successor_undeclared/2. #show retires_unknown/2. #show influence_undeclared/2. +#show citable_unknown/2. +#show citable_active/2. +#show citable_by_unknown/4. +#show citable_at_unknown/4. #show chain_unknown/1. #show facet_unheld/2. #show walk_undeclared/3. diff --git a/luria/ref_status.py b/luria/ref_status.py index f00c5627..157f5341 100644 --- a/luria/ref_status.py +++ b/luria/ref_status.py @@ -100,6 +100,7 @@ class Doc: title: str path: Path active: bool + scheme: str = "" # the prefix of the scheme it belongs to def _load_scheme(scheme) -> dict[str, Doc]: @@ -118,7 +119,7 @@ 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) return docs @@ -245,10 +246,20 @@ class Citation: line: int code: str excused_by: Annotation | None = field(default=None, compare=False) + # Cited from where the cited scheme's `citable_from` says a document at + # this status may be: nothing to acknowledge, and so nothing for an + # `inactive-ok` to answer for either. + sanctioned: bool = field(default=False, compare=False) def __str__(self) -> str: return f"{_rel(self.path)}:{self.line}" + @property + def loud(self) -> bool: + """Whether this site still wants a reader's attention: nothing + acknowledges it and no `citable_from` sanctions it.""" + return self.excused_by is None and not self.sanctioned + @dataclass class Scan: @@ -429,15 +440,30 @@ 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 _sanctioned(path: Path, code: str, docs: dict[str, Doc], + own: dict[Path, str]) -> bool: + """Whether the cited scheme's `citable_from` admits this citer: the + citing file is one of the record's documents, and its scheme and status, + with the cited document's status, are a declared triple. 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 False + scheme = schemes().get(cited.scheme) + return scheme is not None and ( + (cited.status, citer.scheme, citer.status) in scheme.citable_from) + + 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 +474,19 @@ 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 from where its scheme's + `citable_from` 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: set[int] = set() for i, (path, line, code) in enumerate(sites): found.append(Fact("site", (i, str(path), line, code))) + if _sanctioned(path, code, docs, own): + sanctioned.add(i) + 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 +506,8 @@ 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)) + pool.setdefault(code, []).append( + Citation(path, line, code, ann, i in sanctioned)) result.unused = frozenset(a for (a,) in derived.get("unused", ())) for a, code, _ in derived.get("lost", ()): lost = result.lost.setdefault(a, {}) @@ -509,7 +543,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,7 +683,19 @@ 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 from where the cited scheme's + # `citable_from` allows: the site needs nothing, so say + # why rather than that the citation is gone. + allowed = sorted( + c for c in 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 (f"{', '.join(dict.fromkeys(allowed))} is cited " + f"here from where its scheme's `citable_from` " + f"allows, so needs no acknowledgement") + if allowed else f"nothing in scope cites {named}") out.append(f"{ann}: annotation no longer applies — {why}") 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..309057f4 --- /dev/null +++ b/record/changelog.d/claude-laughing-brahmagupta-17c985.md @@ -0,0 +1,11 @@ +### Added + +- `schemes.X.citable_from` lets a scheme name the citers that may cite one of + its retired statuses with no acknowledgement, as `PREFIX:Status` lists + keyed by status: `Answered: [QUESTION:Open]` 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. An `inactive-ok` that + covered only such a site is now reported stale, with the reason. The + loader refuses an entry naming an unknown status, the scheme's own + `active` word, an undeclared citer scheme, or a status the citer's + vocabulary lacks ([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..530f07af --- /dev/null +++ b/record/decisions.d/ADR-tmpfu1s6.md @@ -0,0 +1,117 @@ +--- +status: Active +title: 'A retired status may name the citers that need not acknowledge it' +version: 1 +tags: +- config +- mechanism +date: '2026-10-21' +summary: >- + A scheme may declare `citable_from`, which maps a status of its own to the + citers that may cite a document at that status with no acknowledgement: + `Answered: [QUESTION:Open]`. A citer is a scheme and a status of it, and + a citation from anywhere else is judged exactly as before. Such a site is + `sanctioned`, so an `inactive-ok` over it answers for nothing and is + reported stale, which is the migration signal. The loader refuses an + entry that could exempt nothing. Rejected: `active` as a list, per-site + annotations only, and a global flag that counts a status as in force. +influenced_by: +- ADR-035 +- ADR-085 +- ADR-105 +- DP-010 +--- + +# ADR-tmpfu1s6: A retired status may name the citers that need not acknowledge it + +## 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 + +**The cited scheme names the citers its retired statuses are citable from.** + +```yaml +schemes: + QUESTION: + active: Open + citable_from: + Answered: [QUESTION:Open] +``` + +Each entry maps a status word of this scheme to a list of `PREFIX:Status` +citers. A citation of an `Answered` question from a document of scheme +`QUESTION` whose status is `Open` needs no acknowledgement. A citation from +any other scheme, any other status, or a file that is no document of the +record is judged exactly as before. + +**A sanctioned site is not retired.** `luria/ref_status.py` works out which +document each site sits in, and states `sanctioned(I)` when the cited +document's status and the citer's scheme and status are a declared triple. +`luria/logic/acknowledgements.lp` reads a retired site as one that is not +sanctioned. So a sanctioned site wants no `inactive-ok`, and one written +over it answers for nothing. It is reported stale, with the reason ("cited +here from where its scheme's `citable_from` allows"). That stale report is +how a record finds the annotations the key has made redundant. + +`Citation.loud` is the one test of whether a site still wants attention: +not acknowledged and not sanctioned. `flagged`, `luria ack` and the pending +report read it. The export's `excused` column still means "a directive +acknowledges it", because that is what its schema says. + +**A declaration that could exempt nothing is refused when the config +loads**, as `retires_on` is: a status the scheme's vocabulary lacks, the +scheme's own `active` word (a document in force needs no exemption), a +citer scheme that is not declared, and a citer status that scheme's +vocabulary lacks. A malformed shape is refused too, naming the key. + +## 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. This was the first idea + and the reason a narrower key is needed. +- **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, so the record pays for one rule thirteen times, and the + fourteenth is a warning until someone writes the same sentence again. + A rule stated once belongs in the config. +- **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. Naming + the citer by scheme and status is what keeps the other warnings. +- **Declaring the exemption on the citing scheme.** The cited scheme is + the one whose status vocabulary gives the words their meaning, and it is + where a reader looks to find out what `Answered` permits. + +## Consequences + +- A record states a citation rule once, in its config, and the redundant + annotations surface as stale findings to delete. +- The exemption applies only to record documents. A citation from code or + from a page outside every scheme cannot match a citer, so it is judged as + before. A rule for those would need another shape. +- A sanctioned site is not counted in the "acknowledged" totals. It is not + a suppression: the rule is written in `luria.yaml` and the configuration + reference lists the key. +- Fired on a copy of nucleation with the key above: the thirteen parent + annotations were reported stale, each naming the answered question, and + no other finding changed. The tests are in `tests/test_citable_from.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..5b155947 --- /dev/null +++ b/record/devlog.d/2026/10/21/173451.md @@ -0,0 +1,46 @@ +--- +title: 'citable_from, fired on a question record' +created: '2026-10-21T17:34:51' +tags: [] +--- + +**Why the key is on the cited scheme and names a citer.** 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. Widening `active` would have +silenced both. `citable_from` ([ADR-tmpfu1s6](../../record/decisions.d/ADR-tmpfu1s6.md)) names the citer by scheme and +status. Anything else, a file outside the record included, is judged as +before. + +**Where the citer comes from.** `_scan` already had `own`, the map from a +document's path to its code, for skipping self-citations. `Doc` now carries +its scheme's prefix, so the citer's scheme and status are one lookup. The +decision is a Python test that states `sanctioned(I)`. The logic program +only reads a sanctioned site as not retired, so the acknowledgement rules +did not change. + +**The trap: three readers decided "loud" for themselves.** `flagged`, `luria +ack` and the pending report each tested `excused_by is None`. Changing only +one would have left `luria ack` offering to write a directive over a site +that needs none, and that directive is a stale finding as soon as it is +written. `Citation.loud` is now the one test. The export's `excused` column +still means "a directive acknowledges it", as its schema says. + +**Fired on a real case ([DP-006](../../record/principles.d/DP-006.md)).** I ran the checkout's `luria lint` on a +copy of nucleation, then added `citable_from: {Answered: [QUESTION:Open]}` +to its `QUESTION` scheme and ran it again. The difference, setting aside +upstream identifier checks that depend on the network: + +- 13 more stale directives, one for each of the 13 `Answered` questions, + each in the open parent that cites it. Each was an `inactive-ok` that + nucleation had written to silence exactly this warning. +- No retired-citation row appeared or disappeared, so no warning outside + the declared citer was silenced. + +The first firing showed a wording bug. The stale message said "nothing in +scope cites QUESTION-249", which was false, because the parent does cite it. +`stale_annotations` now says the code "is cited here from where its scheme's +`citable_from` allows, so needs no acknowledgement". +`tests/test_citable_from.py` pins that wording, and it pins the +non-sanctioned citers (another status, another scheme, a plain file) and +each config refusal. diff --git a/tests/test_citable_from.py b/tests/test_citable_from.py new file mode 100644 index 00000000..a857fea7 --- /dev/null +++ b/tests/test_citable_from.py @@ -0,0 +1,182 @@ +# tests/test_citable_from.py +"""`citable_from`: a retired status one scheme's documents may be cited at, +from a named scheme at a named status, with no acknowledgement. + +The case it was built for is a question record: a parent question that is +still `Open` cites the child split off it, and the child is now `Answered`. +That citation is the record working, not drifting, so it should not warn — +while every OTHER citation of an answered question still should, because it +either belongs on whatever answered it or still treats it as open. + +`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 + +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"}}, + "citable_from": {"Answered": ["QN:Open"]}}, + "CM": {"dir": "record/claims.d", "output": "docs/claims", + "active": "Active", + "fields": {"status": {"vocabulary": "cstatuses"}}}, + }, +} + + +def project(root: Path, monkeypatch, *extra) -> Path: + (root / "luria.yaml").write_text(merged(BASE, *extra)) + 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 = "") -> Path: + folder = "questions.d" if code.startswith("QN") else "claims.d" + path = root / "record" / folder / f"{code}.md" + path.write_text(f"---\nstatus: {status}\ntitle: '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_declared_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_undeclared_scheme_judges_exactly_as_before(tmp_path, monkeypatch): + """Without the key, the open parent's citation warns like any other.""" + root = project(tmp_path, monkeypatch, + {"schemes": {"QN": {"citable_from": None}}}) + 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 key existed + now answers for nothing, and says so.""" + 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 from where its scheme's " + "`citable_from` allows" in s + for s in stale), stale + + +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_declaration_is_parsed_into_triples(tmp_path, monkeypatch): + project(tmp_path, monkeypatch) + qn = config.current().schemes["QN"] + assert qn.citable_from == (("Answered", "QN", "Open"),) + assert config.current().schemes["CM"].citable_from == () + + +@pytest.mark.parametrize("value, message", [ + (["Answered"], r"QN\.citable_from: .*mapping"), + ({"Answered": "QN:Open"}, r"QN\.citable_from\.Answered: .*list"), + ({"Answered": ["QN"]}, r"QN\.citable_from\.Answered: .*'QN'"), + ({"Answered": ["QN:"]}, r"QN\.citable_from\.Answered: .*'QN:'"), +]) +def test_a_malformed_declaration_is_refused(tmp_path, monkeypatch, value, + message): + project(tmp_path, monkeypatch, {"schemes": {"QN": {"citable_from": value}}}) + with pytest.raises(ValueError, match=message): + config.current() + + +@pytest.mark.parametrize("value, message", [ + ({"Gone": ["QN:Open"]}, + r"QN\.citable_from: names 'Gone', which QN's status vocabulary"), + ({"Open": ["QN:Open"]}, + r"QN\.citable_from: names 'Open', QN's `active` word"), + ({"Answered": ["XX:Open"]}, + r"QN\.citable_from\.Answered: names scheme 'XX', which is not declared"), + ({"Answered": ["CM:Gone"]}, + r"QN\.citable_from\.Answered: names 'CM:Gone', but CM's status " + r"vocabulary does not hold 'Gone'"), +]) +def test_a_declaration_that_cannot_mean_anything_is_refused( + tmp_path, monkeypatch, value, message): + project(tmp_path, monkeypatch, {"schemes": {"QN": {"citable_from": value}}}) + with pytest.raises(ValueError, match=message): + config.current() From 26be275c57aac53766f726aa01837643b946b9c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:20:29 +0000 Subject: [PATCH 2/2] Replace citable_from with lint.inactive_ok pattern rules Review asked for a generic shape: the per-scheme `citable_from` could only express status, and status words are scheme-specific. A citation that needs no acknowledgement is now declared once, as 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 The pattern is read by the existing pattern grammar over a reserved `cites` edge; labels and properties go through check_patterns, which (with patterns.parse) now takes where the pattern was declared. The loader refuses, by lint.inactive_ok[N], anything but one `cites` hop, variable length, WHERE NOT, unknown schemes or fields, unlabelled nodes, and a missing reason. Matching sites still emit sanctioned(I); a directive made redundant names the rule, and a rule that sanctions no citation is a stale-directives row with its reason (DP-015). Scheme.citable_from and its four consistency refusals are removed. Fired on a copy of nucleation: the 13 parent annotations go stale, naming lint.inactive_ok[0]; unacknowledged rows unchanged (480). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KC8NN4NtUf8sd5unC6dpFV --- luria/config.py | 185 +++++++++---- luria/config_doc.py | 27 +- luria/consistency.py | 62 +---- luria/logic/acknowledgements.lp | 6 +- luria/logic/consistency.lp | 19 +- luria/patterns.py | 7 +- luria/ref_status.py | 103 +++++--- .../claude-laughing-brahmagupta-17c985.md | 21 +- record/decisions.d/ADR-tmpfu1s6.md | 143 ++++++---- record/devlog.d/2026/10/21/173451.md | 100 ++++--- tests/test_citable_from.py | 182 ------------- tests/test_inactive_ok_rules.py | 247 ++++++++++++++++++ 12 files changed, 652 insertions(+), 450 deletions(-) delete mode 100644 tests/test_citable_from.py create mode 100644 tests/test_inactive_ok_rules.py diff --git a/luria/config.py b/luria/config.py index bfbaeedd..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. @@ -900,23 +906,6 @@ class Scheme: # Lowering this asks the sharper question — is the vocabulary *exercised* # — and is opt-in because the answer is a matter of what a scheme is for. uniform_share: float = 1.0 - # Retired statuses that particular citers may cite without an - # acknowledgement, as (cited status, citer prefix, citer status): - # - # citable_from: - # Answered: [QUESTION:Open] - # - # `active` is one word because it answers one question — is this document - # in force — and every citation of a document that is not draws a - # warning. Some retired documents have a citer for whom that warning is - # always wrong: an `Open` question naming the child question split off it - # is the record working, though the child is `Answered`. Widening `active` - # would silence every OTHER citation of an answered question too, and - # those are the ones that should cite whatever answered it instead. So - # the exemption names its citer exactly; anything else, a non-record file - # included, is judged as it always was. Empty for every scheme that sets - # nothing, which is the behaviour every project had before the key. - citable_from: tuple[tuple[str, str, str], ...] = () @property def view(self) -> Path: @@ -2089,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). @@ -2309,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) @@ -2639,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, @@ -2685,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. @@ -2919,38 +3037,6 @@ def _check_target_fields(where: str, rule, ref, schemes, many: bool = False) -> _check_cardinality(where, rule, plural_fields(target), many, prefix) -def _citable_from(prefix: str, raw) -> tuple[tuple[str, str, str], ...]: - """A scheme's `citable_from`, as (cited status, citer prefix, citer - status) triples, refused here when it is not the shape it has to be. - - Whether the words it names exist is a cross-scheme question, decided - once every scheme is known (`consistency.check_references`).""" - if not raw: - return () - where = f"luria.yaml: schemes.{prefix}.citable_from" - if not isinstance(raw, dict): - raise ValueError( - f"{where}: has to be a mapping of this scheme's status words to " - f"lists of `PREFIX:Status` citers, not {type(raw).__name__} " - f"(e.g. `Answered: [{prefix}:Open]`)") - out: list[tuple[str, str, str]] = [] - for status, citers in raw.items(): - if not isinstance(citers, list): - raise ValueError( - f"{where}.{status}: has to be a list of `PREFIX:Status` " - f"citers, not {citers!r} — write `[{citers}]` for one") - for citer in citers: - parts = str(citer).split(":") - if (not isinstance(citer, str) or len(parts) != 2 - or not all(p.strip() for p in parts)): - raise ValueError( - f"{where}.{status}: {citer!r} is not a `PREFIX:Status` " - f"citer — name the citing scheme and the status it has " - f"to be at (e.g. `{prefix}:Open`)") - out.append((str(status), parts[0].strip(), parts[1].strip())) - return tuple(out) - - def _alias_template(prefix: str, raw) -> str: """A scheme's `alias` template, validated for shape where it is read. @@ -3086,7 +3172,6 @@ def _schemes(raw: dict, root: Path, scaffolding: bool = False, field_groups=_field_groups(prefix, spec.get("field_groups", {})), uniform_ok=(spec.get("uniform_ok") or None), uniform_share=float(spec.get("uniform_share", 1.0)), - citable_from=_citable_from(prefix, spec.get("citable_from")), ) # Which cross-scheme declarations are wrong is decided by rules # (`luria/consistency.py`); the order they are raised in is this loop's. 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 db5199d7..164aaa24 100644 --- a/luria/consistency.py +++ b/luria/consistency.py @@ -31,12 +31,6 @@ says where the assertion belongs instead. A chain walking a pattern (#371) holds the same line at the pattern's two ends, wherever its middle goes. -**An exemption names a citer that can exist.** `citable_from` lets a named -citer cite a retired status without acknowledgement. Its status has to be one -the scheme holds and not its `active` word (which needs no exemption), and -each citer a declared scheme at a word that scheme holds — otherwise the entry -exempts nothing, and looks exactly like one that works. - **A pattern names schemes and fields.** Every label a declared scheme, every hop a reference held by the node the arrow leaves and able to land on the node it reaches, every property a field the node can hold. A pattern that @@ -59,9 +53,6 @@ def _scheme_facts(schemes: dict, remotes=()) -> list[Fact]: out.append(Fact("role_retires_on", (prefix, scheme.retires_on))) if scheme.influence: out.append(Fact("role_influence", (prefix, scheme.influence))) - out.append(Fact("active_word", (prefix, scheme.active))) - out += [Fact("citable_from", (prefix, cited, by, at)) - for cited, by, at in scheme.citable_from] out += [Fact("status_word", (prefix, w)) for w in vocabulary(scheme)] out += [Fact("nameable", (prefix, f)) for f in nameable(scheme)] for ref in scheme.references: @@ -172,11 +163,9 @@ def _ref(schemes: dict, prefix: str, field: str): def check_references(schemes: dict, remotes=()) -> None: - """Refuse the first converse, relation invariant, role or - `citable_from` entry that cannot mean what it says: per scheme in - declaration order, its converses before its invariants, each reference - in order and each scheme it names in order, then its roles, then its - `citable_from` entries in the order they are written.""" + """Refuse the first converse or relation invariant that cannot mean what + it says: per scheme in declaration order, its converses before its + invariants, each reference in order and each scheme it names in order.""" from .config import nameable, spelled derived = _derived(schemes, {}, remotes) order = list(schemes) @@ -254,45 +243,10 @@ def where(prefix, field, part): f"{prefix}'s status vocabulary does not hold — the role says " f"which status means replaced; it does not add one (have: " f"{', '.join(vocabulary(schemes[prefix]))})"))) - for prefix, word in derived.get("citable_unknown", ()): - found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, word), - 0, 0, 0), ( - f"luria.yaml: schemes.{prefix}.citable_from: names {word!r}, " - f"which {prefix}'s status vocabulary does not hold — the key " - f"says which retired status a citer may cite; it does not add " - f"one (have: {', '.join(vocabulary(schemes[prefix]))})"))) - for prefix, word in derived.get("citable_active", ()): - found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, word), - 0, 0, 0), ( - f"luria.yaml: schemes.{prefix}.citable_from: names {word!r}, " - f"{prefix}'s `active` word — a document in force can be cited " - f"from anywhere already, so the entry exempts nothing"))) - for prefix, cited, by, at in derived.get("citable_by_unknown", ()): - found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, cited), - _citer_at(schemes, prefix, cited, by, at), 0, 0), ( - f"luria.yaml: schemes.{prefix}.citable_from.{cited}: names " - f"scheme {by!r}, which is not declared — a citer is a scheme " - f"and a status of it (have: {', '.join(sorted(schemes))})"))) - for prefix, cited, by, at in derived.get("citable_at_unknown", ()): - found.append(((order.index(prefix), 3, _cited_at(schemes, prefix, cited), - _citer_at(schemes, prefix, cited, by, at), 0, 0), ( - f"luria.yaml: schemes.{prefix}.citable_from.{cited}: names " - f"'{by}:{at}', but {by}'s status vocabulary does not hold " - f"{at!r} — no {by} document could ever be that citer (have: " - f"{', '.join(vocabulary(schemes[by]))})"))) if found: raise ValueError(min(found)[1]) -def _cited_at(schemes: dict, prefix: str, word: str) -> int: - """Where a `citable_from` status sits in its declaration, for order.""" - return [c for c, _, _ in schemes[prefix].citable_from].index(word) - - -def _citer_at(schemes: dict, prefix: str, cited: str, by: str, at: str) -> int: - return schemes[prefix].citable_from.index((cited, by, at)) - - # ── the chains ──────────────────────────────────────────────────────────── def check_chains(chains: dict[str, dict], schemes: dict, @@ -400,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) @@ -414,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 f1b172c8..1a2729e8 100644 --- a/luria/logic/acknowledgements.lp +++ b/luria/logic/acknowledgements.lp @@ -8,8 +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; sanctioned(I) when the cited scheme's -% `citable_from` admits the document the site sits in as its citer. +% 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). @@ -22,7 +22,7 @@ can(I, A, K) :- site(I, P, L, C), ann(A, P, K), usable(A), ann_code(A, C), % What a site needs excusing for. A code naming nothing here wants an % `unresolved-ok`; a retired document wants an `inactive-ok`, unless it is -% cited from where its scheme says it may be; a document in force is no +% 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). diff --git a/luria/logic/consistency.lp b/luria/logic/consistency.lp index 00c5cb42..73031bd7 100644 --- a/luria/logic/consistency.lp +++ b/luria/logic/consistency.lp @@ -5,9 +5,7 @@ % % Facts: scheme/1, remote/1, ref/2, ref_target/3, ref_converse/3, % ref_many/2, ref_invariant/3, nameable/2 (every field a scheme can hold), -% role_successor/2, role_retires_on/2, role_influence/2, status_word/2, -% active_word/2, citable_from/4 (scheme, cited status, citer scheme, citer -% status), and +% role_successor/2, role_retires_on/2, role_influence/2, status_word/2, and % per raw % chain: chain_scheme/2, chain_facet/2, chain_walks/3 (relation or sibling), % chain_output/1, chain_invariant/2; and per pattern: pattern/1, @@ -46,17 +44,6 @@ successor_undeclared(S, F) :- role_successor(S, F), not ref(S, F). retires_unknown(S, W) :- role_retires_on(S, W), not status_word(S, W). influence_undeclared(S, F) :- role_influence(S, F), not ref(S, F). -% ── citable_from ──────────────────────────────────────────────────────── -% A retired status a named citer may cite without acknowledgement. The -% status is one this scheme holds and is not its `active` word, which needs -% no exemption; the citer is a declared scheme at a word its vocabulary holds. -citable_unknown(S, W) :- citable_from(S, W, _, _), not status_word(S, W). -citable_active(S, W) :- citable_from(S, W, _, _), status_word(S, W), - active_word(S, W). -citable_by_unknown(S, W, B, A) :- citable_from(S, W, B, A), not scheme(B). -citable_at_unknown(S, W, B, A) :- citable_from(S, W, B, A), scheme(B), - not status_word(B, A). - % ── chains ────────────────────────────────────────────────────────────── chain_unknown(N) :- chain_scheme(N, S), not scheme(S). facet_unheld(N, X) :- chain_facet(N, X), chain_scheme(N, S), scheme(S), @@ -105,10 +92,6 @@ pattern_shadows(P, S) :- pattern(P), ref(S, P). #show successor_undeclared/2. #show retires_unknown/2. #show influence_undeclared/2. -#show citable_unknown/2. -#show citable_active/2. -#show citable_by_unknown/4. -#show citable_at_unknown/4. #show chain_unknown/1. #show facet_unheld/2. #show walk_undeclared/3. 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 157f5341..120bbe93 100644 --- a/luria/ref_status.py +++ b/luria/ref_status.py @@ -101,6 +101,8 @@ class Doc: 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]: @@ -119,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, scheme.prefix) + status == scheme.active, scheme.prefix, + doc.meta) return docs @@ -246,18 +249,21 @@ class Citation: line: int code: str excused_by: Annotation | None = field(default=None, compare=False) - # Cited from where the cited scheme's `citable_from` says a document at - # this status may be: nothing to acknowledge, and so nothing for an - # `inactive-ok` to answer for either. - sanctioned: bool = field(default=False, 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 `citable_from` sanctions it.""" + acknowledges it and no `lint.inactive_ok` rule sanctions it.""" return self.excused_by is None and not self.sanctioned @@ -278,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: @@ -447,18 +455,37 @@ def _scan(files: list[Path] | None = None, docs: dict[str, Doc] | None = None) - 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]) -> bool: - """Whether the cited scheme's `citable_from` admits this citer: the - citing file is one of the record's documents, and its scheme and status, - with the cited document's status, are a declared triple. A file that is - no document here is never a sanctioned citer.""" + 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 False - scheme = schemes().get(cited.scheme) - return scheme is not None and ( - (cited.status, citer.scheme, citer.status) in scheme.citable_from) + 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]], @@ -474,18 +501,22 @@ 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). Nor is a retired one cited from where its scheme's - `citable_from` allows: that site is `sanctioned`, and an `inactive-ok` - over it answers for nothing.""" + 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: set[int] = set() + 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 _sanctioned(path, code, docs, own): - sanctioned.add(i) + if i in sanctioned: found.append(Fact("sanctioned", (i,))) for code in {c for _, _, c in sites} & set(docs): found.append(Fact("resolves", (code,))) @@ -506,8 +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 + rule = (f"lint.inactive_ok[{sanctioned[i]}]" if i in sanctioned + else "") pool.setdefault(code, []).append( - Citation(path, line, code, ann, i in sanctioned)) + 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, {}) @@ -683,21 +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 from where the cited scheme's - # `citable_from` allows: the site needs nothing, so say - # why rather than that the citation is gone. - allowed = sorted( - c for c in ann.codes + # 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)) + and ann.covers(site.line)} why = (f"{', '.join(active)} is Active now" if active - else (f"{', '.join(dict.fromkeys(allowed))} is cited " - f"here from where its scheme's `citable_from` " - f"allows, so needs no acknowledgement") + 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 index 309057f4..91d92117 100644 --- a/record/changelog.d/claude-laughing-brahmagupta-17c985.md +++ b/record/changelog.d/claude-laughing-brahmagupta-17c985.md @@ -1,11 +1,14 @@ ### Added -- `schemes.X.citable_from` lets a scheme name the citers that may cite one of - its retired statuses with no acknowledgement, as `PREFIX:Status` lists - keyed by status: `Answered: [QUESTION:Open]` 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. An `inactive-ok` that - covered only such a site is now reported stale, with the reason. The - loader refuses an entry naming an unknown status, the scheme's own - `active` word, an undeclared citer scheme, or a status the citer's - vocabulary lacks ([ADR-tmpfu1s6](record/decisions.d/ADR-tmpfu1s6.md)). +- `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 index 530f07af..1b069635 100644 --- a/record/decisions.d/ADR-tmpfu1s6.md +++ b/record/decisions.d/ADR-tmpfu1s6.md @@ -1,28 +1,33 @@ --- status: Active -title: 'A retired status may name the citers that need not acknowledge it' +title: 'A citation that needs no acknowledgement may be declared once, as a pattern' version: 1 tags: - config - mechanism date: '2026-10-21' summary: >- - A scheme may declare `citable_from`, which maps a status of its own to the - citers that may cite a document at that status with no acknowledgement: - `Answered: [QUESTION:Open]`. A citer is a scheme and a status of it, and - a citation from anywhere else is judged exactly as before. Such a site is - `sanctioned`, so an `inactive-ok` over it answers for nothing and is - reported stale, which is the migration signal. The loader refuses an - entry that could exempt nothing. Rejected: `active` as a list, per-site - annotations only, and a global flag that counts a status as in force. + `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 retired status may name the citers that need not acknowledge it +# ADR-tmpfu1s6: A citation that needs no acknowledgement may be declared once, as a pattern ## Context @@ -47,71 +52,93 @@ saying the same thing. ## Decision -**The cited scheme names the citers its retired statuses are citable from.** +**A record may state the acknowledgement once, in its lint settings.** ```yaml -schemes: - QUESTION: - active: Open - citable_from: - Answered: [QUESTION:Open] +lint: + inactive_ok: + - match: "(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION {status: 'Answered'})" + reason: a split parent cites the Answered child split off it ``` -Each entry maps a status word of this scheme to a list of `PREFIX:Status` -citers. A citation of an `Answered` question from a document of scheme -`QUESTION` whose status is `Open` needs no acknowledgement. A citation from -any other scheme, any other status, or a file that is no document of the -record is judged exactly as before. - -**A sanctioned site is not retired.** `luria/ref_status.py` works out which -document each site sits in, and states `sanctioned(I)` when the cited -document's status and the citer's scheme and status are a declared triple. -`luria/logic/acknowledgements.lp` reads a retired site as one that is not -sanctioned. So a sanctioned site wants no `inactive-ok`, and one written -over it answers for nothing. It is reported stale, with the reason ("cited -here from where its scheme's `citable_from` allows"). That stale report is -how a record finds the annotations the key has made redundant. - -`Citation.loud` is the one test of whether a site still wants attention: -not acknowledged and not sanctioned. `flagged`, `luria ack` and the pending -report read it. The export's `excused` column still means "a directive -acknowledges it", because that is what its schema says. - -**A declaration that could exempt nothing is refused when the config -loads**, as `retires_on` is: a status the scheme's vocabulary lacks, the -scheme's own `active` word (a document in force needs no exemption), a -citer scheme that is not declared, and a citer status that scheme's -vocabulary lacks. A malformed shape is refused too, naming the key. +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. This was the first idea - and the reason a narrower key is needed. + 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, so the record pays for one rule thirteen times, and the + answered child. The record pays for one rule thirteen times, and the fourteenth is a warning until someone writes the same sentence again. - A rule stated once belongs in the config. - **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. Naming - the citer by scheme and status is what keeps the other warnings. -- **Declaring the exemption on the citing scheme.** The cited scheme is - the one whose status vocabulary gives the words their meaning, and it is - where a reader looks to find out what `Answered` permits. + 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. -- The exemption applies only to record documents. A citation from code or - from a page outside every scheme cannot match a citer, so it is judged as - before. A rule for those would need another shape. +- 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 is written in `luria.yaml` and the configuration - reference lists the key. -- Fired on a copy of nucleation with the key above: the thirteen parent - annotations were reported stale, each naming the answered question, and - no other finding changed. The tests are in `tests/test_citable_from.py`. + 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 index 5b155947..e0d4e874 100644 --- a/record/devlog.d/2026/10/21/173451.md +++ b/record/devlog.d/2026/10/21/173451.md @@ -1,46 +1,64 @@ --- -title: 'citable_from, fired on a question record' +title: 'lint.inactive_ok, fired on a question record' created: '2026-10-21T17:34:51' tags: [] --- -**Why the key is on the cited scheme and names a citer.** 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. Widening `active` would have -silenced both. `citable_from` ([ADR-tmpfu1s6](../../record/decisions.d/ADR-tmpfu1s6.md)) names the citer by scheme and -status. Anything else, a file outside the record included, is judged as -before. - -**Where the citer comes from.** `_scan` already had `own`, the map from a -document's path to its code, for skipping self-citations. `Doc` now carries -its scheme's prefix, so the citer's scheme and status are one lookup. The -decision is a Python test that states `sanctioned(I)`. The logic program -only reads a sanctioned site as not retired, so the acknowledgement rules -did not change. - -**The trap: three readers decided "loud" for themselves.** `flagged`, `luria -ack` and the pending report each tested `excused_by is None`. Changing only -one would have left `luria ack` offering to write a directive over a site -that needs none, and that directive is a stale finding as soon as it is -written. `Citation.loud` is now the one test. The export's `excused` column -still means "a directive acknowledges it", as its schema says. - -**Fired on a real case ([DP-006](../../record/principles.d/DP-006.md)).** I ran the checkout's `luria lint` on a -copy of nucleation, then added `citable_from: {Answered: [QUESTION:Open]}` -to its `QUESTION` scheme and ran it again. The difference, setting aside -upstream identifier checks that depend on the network: - -- 13 more stale directives, one for each of the 13 `Answered` questions, - each in the open parent that cites it. Each was an `inactive-ok` that - nucleation had written to silence exactly this warning. -- No retired-citation row appeared or disappeared, so no warning outside - the declared citer was silenced. - -The first firing showed a wording bug. The stale message said "nothing in -scope cites QUESTION-249", which was false, because the parent does cite it. -`stale_annotations` now says the code "is cited here from where its scheme's -`citable_from` allows, so needs no acknowledgement". -`tests/test_citable_from.py` pins that wording, and it pins the -non-sanctioned citers (another status, another scheme, a plain file) and -each config refusal. +**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_citable_from.py b/tests/test_citable_from.py deleted file mode 100644 index a857fea7..00000000 --- a/tests/test_citable_from.py +++ /dev/null @@ -1,182 +0,0 @@ -# tests/test_citable_from.py -"""`citable_from`: a retired status one scheme's documents may be cited at, -from a named scheme at a named status, with no acknowledgement. - -The case it was built for is a question record: a parent question that is -still `Open` cites the child split off it, and the child is now `Answered`. -That citation is the record working, not drifting, so it should not warn — -while every OTHER citation of an answered question still should, because it -either belongs on whatever answered it or still treats it as open. - -`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 - -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"}}, - "citable_from": {"Answered": ["QN:Open"]}}, - "CM": {"dir": "record/claims.d", "output": "docs/claims", - "active": "Active", - "fields": {"status": {"vocabulary": "cstatuses"}}}, - }, -} - - -def project(root: Path, monkeypatch, *extra) -> Path: - (root / "luria.yaml").write_text(merged(BASE, *extra)) - 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 = "") -> Path: - folder = "questions.d" if code.startswith("QN") else "claims.d" - path = root / "record" / folder / f"{code}.md" - path.write_text(f"---\nstatus: {status}\ntitle: '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_declared_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_undeclared_scheme_judges_exactly_as_before(tmp_path, monkeypatch): - """Without the key, the open parent's citation warns like any other.""" - root = project(tmp_path, monkeypatch, - {"schemes": {"QN": {"citable_from": None}}}) - 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 key existed - now answers for nothing, and says so.""" - 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 from where its scheme's " - "`citable_from` allows" in s - for s in stale), stale - - -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_declaration_is_parsed_into_triples(tmp_path, monkeypatch): - project(tmp_path, monkeypatch) - qn = config.current().schemes["QN"] - assert qn.citable_from == (("Answered", "QN", "Open"),) - assert config.current().schemes["CM"].citable_from == () - - -@pytest.mark.parametrize("value, message", [ - (["Answered"], r"QN\.citable_from: .*mapping"), - ({"Answered": "QN:Open"}, r"QN\.citable_from\.Answered: .*list"), - ({"Answered": ["QN"]}, r"QN\.citable_from\.Answered: .*'QN'"), - ({"Answered": ["QN:"]}, r"QN\.citable_from\.Answered: .*'QN:'"), -]) -def test_a_malformed_declaration_is_refused(tmp_path, monkeypatch, value, - message): - project(tmp_path, monkeypatch, {"schemes": {"QN": {"citable_from": value}}}) - with pytest.raises(ValueError, match=message): - config.current() - - -@pytest.mark.parametrize("value, message", [ - ({"Gone": ["QN:Open"]}, - r"QN\.citable_from: names 'Gone', which QN's status vocabulary"), - ({"Open": ["QN:Open"]}, - r"QN\.citable_from: names 'Open', QN's `active` word"), - ({"Answered": ["XX:Open"]}, - r"QN\.citable_from\.Answered: names scheme 'XX', which is not declared"), - ({"Answered": ["CM:Gone"]}, - r"QN\.citable_from\.Answered: names 'CM:Gone', but CM's status " - r"vocabulary does not hold 'Gone'"), -]) -def test_a_declaration_that_cannot_mean_anything_is_refused( - tmp_path, monkeypatch, value, message): - project(tmp_path, monkeypatch, {"schemes": {"QN": {"citable_from": value}}}) - with pytest.raises(ValueError, match=message): - config.current() 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()