Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion luria/ack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))")
Expand Down
2 changes: 1 addition & 1 deletion luria/adr_pending.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
135 changes: 135 additions & 0 deletions luria/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2072,6 +2078,59 @@ def parsed(self):
return self._parsed


# The edge an `inactive_ok` pattern walks. Reserved rather than declared:
# it is the citation the lint already reads out of a document's text, which
# no reference field holds, so it is never a `patterns.X` hop.
CITES = "cites"


@dataclass(frozen=True)
class InactiveOk:
"""A citation of a retired document that needs no acknowledgement:
the config-level form of the `inactive-ok` directive.

lint:
inactive_ok:
- match: "(:QUESTION {status: 'Open'})-[:cites]->(:QUESTION {status: 'Answered'})"
reason: a split parent cites the Answered child split off it

Quote the pattern when it has a property map: YAML reads an unquoted
`{` as the start of a mapping and refuses the line.

The pattern is one hop over `cites`, the reserved edge meaning "this
document's text cites that one"; the arrow points from the citer to the
cited, either way round. A citation of a document that is not in force
is sanctioned when the document it sits in matches the citer's node and
the cited document matches the other: labels are schemes, and a
property is literal equality on any frontmatter field, as in
`patterns.X`. A citation from anywhere else — another scheme, another
status, a file that is no document of the record — is judged as before.

A directive says why at the site; a rule says why once, for every site
it matches, so `reason` is mandatory for the same purpose. An
`inactive-ok` that a rule has made redundant is reported stale, and a
rule that sanctions no citation is reported too: a dead rule otherwise
reads exactly like a working one (DP-015)."""
index: int
match: str
reason: str
_parsed: object = dcfield(default=None, repr=False, compare=False)

@property
def key(self) -> str:
return f"lint.inactive_ok[{self.index}]"

@property
def citer(self):
"""The node the `cites` arrow leaves."""
return self._parsed.path.near(0)

@property
def cited(self):
"""The node the `cites` arrow lands on."""
return self._parsed.path.far(0)


@dataclass(frozen=True)
class Chain:
"""A relation walked transitively and rendered as sequences (#171).
Expand Down Expand Up @@ -2292,6 +2351,9 @@ class Config:
# key that `index` has to reach into the site table to read is DP-16's
# awkwardness — a distinction the layout had stopped expressing.
include_records: tuple[str, ...] = ()
# Citations of retired documents that need no acknowledgement
# (`lint.inactive_ok`, see `InactiveOk`).
inactive_ok: tuple[InactiveOk, ...] = ()
site: Site = None # type: ignore[assignment]
_raw: dict = dcfield(default_factory=dict, repr=False)

Expand Down Expand Up @@ -2622,6 +2684,7 @@ def load(root: Path | None = None, text: str | None = None,
mute=tuple(raw["lint"]["mute"]),
narrow_terms=tuple(raw["lint"].get("narrow_terms", [])),
network=str(raw["lint"].get("network", "auto")),
inactive_ok=_inactive_ok(raw["lint"].get("inactive_ok"), schemes),
include_records=tuple(raw.get("include_records", ())),
site=_site(raw, root),
_raw=raw,
Expand Down Expand Up @@ -2668,6 +2731,78 @@ def _patterns(raw: dict, schemes: dict) -> dict[str, NamedPattern]:
return out


def _inactive_ok(raw, schemes: dict) -> tuple[InactiveOk, ...]:
"""Every `lint.inactive_ok` rule, refused by name when it cannot mean
what it says.

The pattern is parsed by the one grammar every pattern uses, then held
to what a citation is: one hop, typed `cites` alone, fixed length, no
`WHERE NOT` (a condition on a citation is a widening for when a record
needs one). Labels and properties are checked as `patterns.X` checks
them, with the `cites` hop left out — it is no reference field."""
from types import SimpleNamespace

from . import consistency
from .patterns import Path as PatternPath
from .patterns import Pattern, parse
if not raw:
return ()
if not isinstance(raw, list):
raise ValueError(
"luria.yaml: lint.inactive_ok: has to be a list of entries, each "
"a `match:` pattern and a `reason:`, not "
f"{type(raw).__name__}")
out: list[InactiveOk] = []
for i, entry in enumerate(raw):
key = f"lint.inactive_ok[{i}]"
where = f"luria.yaml: {key}: "
if not isinstance(entry, dict):
raise ValueError(
where + f"has to be a mapping with `match:` and `reason:`, "
f"not {entry!r}")
if unknown := set(entry) - {"match", "reason"}:
raise ValueError(
where + f"{', '.join(sorted(map(str, unknown)))} is not a key "
f"— an entry is its `match:` and its `reason:`")
reason = str(entry.get("reason") or "").strip()
if not reason:
raise ValueError(
where + "needs a `reason` — a rule excuses every citation it "
"matches, and silence that carries no argument is "
"indistinguishable from an oversight")
source = str(entry.get("match") or "")
pattern = parse(key, source, where=where)
if pattern.negations:
raise ValueError(
where + "`WHERE NOT` is not taken here — a rule names the "
"citer and the cited document, and nothing else")
hops = pattern.path.hops
if len(hops) != 1:
raise ValueError(
where + f"has {len(hops)} hops; a rule takes exactly one hop "
f"— the citation, `-[:{CITES}]->`")
(hop,) = hops
if hop.fields != (CITES,):
raise ValueError(
where + f"`[:{'|'.join(hop.fields)}]` — the hop is `[:{CITES}]` "
f"and only that: it is the citation in a document's text, "
f"which no reference field holds")
if hop.variable:
raise ValueError(
where + "a variable-length hop is not taken here — a citation "
"is one document's text naming another")
out.append(InactiveOk(i, source.strip(), reason, _parsed=pattern))
# Labels and properties, by the checks `patterns.X` gets; the hop is left
# out because `cites` is no reference field.
stripped = {
str(r.index): SimpleNamespace(parsed=Pattern(
r.key, r.match, PatternPath(r._parsed.path.nodes, ()), ()))
for r in out}
consistency.check_patterns(stripped, schemes,
declared_at="lint.inactive_ok[{}]")
return tuple(out)


def _chains(raw: dict, schemes: dict, root: Path,
patterns: dict | None = None) -> dict[str, Chain]:
"""Every declared chain, checked against the schemes it walks.
Expand Down
27 changes: 24 additions & 3 deletions luria/config_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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.",
}),
]

Expand Down Expand Up @@ -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"},
Expand Down
10 changes: 7 additions & 3 deletions luria/consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,14 @@ def _hop_text(hop) -> str:
return f"-{inner}->" if hop.right else f"<-{inner}-"


def check_patterns(patterns: dict, schemes: dict) -> None:
def check_patterns(patterns: dict, schemes: dict,
declared_at: str = "patterns.{}") -> None:
"""Refuse the first pattern that cannot match what it says: per pattern
in order, a name a reference already uses, then each path (the match,
then each `WHERE NOT`) node by node and hop by hop."""
then each `WHERE NOT`) node by node and hop by hop.

`declared_at` spells where each pattern was declared, for the patterns written
somewhere other than `patterns.X` (`lint.inactive_ok[N]`)."""
from .config import nameable, spelled
derived = _derived(schemes, {}, patterns=patterns)
order = list(patterns)
Expand All @@ -368,7 +372,7 @@ def path(name, w):
return (p.path, *p.negations)[w]

def where(name):
return f"luria.yaml: patterns.{name}: "
return f"luria.yaml: {declared_at.format(name)}: "

for name, prefix in derived.get("pattern_shadows", ()):
found.append(((order.index(name), -1, 0, 0, 0), where(name) + (
Expand Down
12 changes: 7 additions & 5 deletions luria/logic/acknowledgements.lp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
% file P; resolves(C) and active(C) for local documents; ann(A, P, K), the
% A-th annotation found, of kind K, in file P; ann_code(A, C); ann_line(A, L)
% for each line it governs, or ann_file(A) when it governs the whole file;
% ann_problem(A) when it is malformed.
% ann_problem(A) when it is malformed; sanctioned(I) when a `lint.inactive_ok`
% rule matches the document the site sits in and the document it cites.

covers(A, L) :- ann(A, P, _), ann_file(A), site(_, P, L, _).
covers(A, L) :- ann_line(A, L).
Expand All @@ -20,11 +21,12 @@ can(I, A, K) :- site(I, P, L, C), ann(A, P, K), usable(A), ann_code(A, C),
covers(A, L).

% What a site needs excusing for. A code naming nothing here wants an
% `unresolved-ok`; a retired document wants an `inactive-ok`; a document in
% force is no finding, so only a mention is recorded against it — which is
% what keeps that mention alive when its document goes Active (ADR-105).
% `unresolved-ok`; a retired document wants an `inactive-ok`, unless it is
% cited where a `lint.inactive_ok` rule allows; a document in force is no
% finding, so only a mention is recorded against it — which is what keeps
% that mention alive when its document goes Active (ADR-105).
dangling(I) :- site(I, _, _, C), not resolves(C).
retired(I) :- site(I, _, _, C), resolves(C), not active(C).
retired(I) :- site(I, _, _, C), resolves(C), not active(C), not sanctioned(I).
wants(I, "unresolved-ok") :- dangling(I).
wants(I, "inactive-ok") :- retired(I).

Expand Down
7 changes: 4 additions & 3 deletions luria/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
Loading
Loading