diff --git a/docs/cli.md b/docs/cli.md index 31638fc5..b86fe6a7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -637,8 +637,10 @@ scheme, number, title, status, version, date, path, the whole frontmatter as JSON, and the body), a row per field value with list fields exploded into positioned rows, the typed edges (`superseded_by:`, `influenced_by:`, every declared reference field), every citation the lint's scanner counts -— where it sits, whether it resolves, whether a directive excuses it — and -every journal entry with its tags. Rebuilt from scratch on each run, so a +— where it sits, whether it resolves, its `standing` (`in-force`, +`acknowledged`, `sanctioned`, `unacknowledged` or `dangling`, as the lint +reads it), whether a directive or a `lint.inactive_ok` rule excuses it and +`excused_by` naming which — and every journal entry with its tags. Rebuilt from scratch on each run, so a row never outlives its source; refuses to overwrite a file it did not write. It is a generated view for asking the record questions with `sqlite3`, Datasette or pandas — never a source, and not committed diff --git a/luria/export.py b/luria/export.py index e1d9b291..7890dc20 100644 --- a/luria/export.py +++ b/luria/export.py @@ -26,9 +26,12 @@ edges the typed graph `luria/edges.py` derives — a reference field, `superseded_by:`, `influenced_by:` citations every code the lint's scanner counts as a citation, with - the file and line it sits on, whether it resolves, and - whether a directive excuses it — a suppression is - counted, never hidden (DP-1) + the file and line it sits on, whether it resolves, its + `standing` — `in-force`, `acknowledged`, `sanctioned`, + `unacknowledged` or `dangling`, read off the same scan + the lint reads — and what clears it, a directive or a + `lint.inactive_ok` rule: a suppression is counted, + never hidden (DP-1) journal_entries one row per entry, keyed on the timestamp that is its identity; `journal_tags` holds their tags meta which version of luria wrote it, when, and from where @@ -89,7 +92,11 @@ line INTEGER NOT NULL, code TEXT NOT NULL, resolves INTEGER NOT NULL, -- 0 when the code names no document here - excused INTEGER NOT NULL -- 1 when a directive acknowledges it + standing TEXT NOT NULL, -- in-force | acknowledged | sanctioned + -- | unacknowledged | dangling + excused INTEGER NOT NULL, -- 1 when a directive or a rule clears it + excused_by TEXT -- `inactive-ok @ path:line`, or + -- `lint.inactive_ok[N]`; NULL if nothing ); CREATE INDEX citations_by_code ON citations(code); CREATE TABLE journal_entries ( @@ -137,8 +144,9 @@ def _citation_rows(cfg) -> list[tuple]: rows: list[tuple] = [] for pool, resolves in ((scan.cited, 1), (scan.dangling, 0)): for code, sites in pool.items(): - rows += [(cfg.rel(c.path), c.line, code, resolves, - int(c.excused_by is not None)) for c in sites] + rows += [(cfg.rel(c.path), c.line, code, resolves, c.standing, + int(c.cleared_by is not None), c.cleared_by) + for c in sites] return sorted(rows) @@ -191,7 +199,7 @@ def write(out: Path, cfg=None) -> Path: conn.executemany("INSERT INTO edges VALUES (?,?,?,?)", [(e.source, e.relation, e.target, e.because) for e in graph.edges]) - conn.executemany("INSERT INTO citations VALUES (?,?,?,?,?)", + conn.executemany("INSERT INTO citations VALUES (?,?,?,?,?,?,?)", _citation_rows(cfg)) conn.executemany("INSERT INTO journal_entries VALUES (?,?,?,?,?)", entries) diff --git a/luria/ref_status.py b/luria/ref_status.py index 120bbe93..3dd7a02c 100644 --- a/luria/ref_status.py +++ b/luria/ref_status.py @@ -243,6 +243,17 @@ def annotations(path: Path, text: str, known: set[str], # ── Scanning ───────────────────────────────────────────────────────────── +# What a site needs, in one word each — the lint's reading of a citation, +# named once so the export's `standing` column and any report that asks +# read it from here rather than re-deriving it (DP-004). +IN_FORCE = "in-force" # the cited document is in force +ACKNOWLEDGED = "acknowledged" # a directive answers for the finding +SANCTIONED = "sanctioned" # a `lint.inactive_ok` rule does +UNACKNOWLEDGED = "unacknowledged" # retired, and nothing answers: loud +DANGLING = "dangling" # names no document, and nothing answers +STANDINGS = (IN_FORCE, ACKNOWLEDGED, SANCTIONED, UNACKNOWLEDGED, DANGLING) + + @dataclass(frozen=True) class Citation: path: Path @@ -252,6 +263,10 @@ class Citation: # 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) + # Whether the code names a document here, and whether it is in force: + # what the site would need with nothing answering for it. + resolves: bool = field(default=True, compare=False) + in_force: bool = field(default=False, compare=False) def __str__(self) -> str: return f"{_rel(self.path)}:{self.line}" @@ -266,6 +281,38 @@ def loud(self) -> bool: acknowledges it and no `lint.inactive_ok` rule sanctions it.""" return self.excused_by is None and not self.sanctioned + @property + def standing(self) -> str: + """What this site needs, as one of `STANDINGS`. + + A document in force needs nothing, whatever answers for it — a + `mention-ok` over it is only keeping itself alive (ADR-105). Any + other site stands on what clears it: a rule before a directive, + since a `mention-ok` is the only directive that can sit over a + sanctioned site and it claims nothing. A `mention-ok` over a retired + or dangling target answers its finding, so the site is + `acknowledged`.""" + if self.resolves and self.in_force: + return IN_FORCE + if self.sanctioned: + return SANCTIONED + if self.excused_by is not None: + return ACKNOWLEDGED + return UNACKNOWLEDGED if self.resolves else DANGLING + + @property + def cleared_by(self) -> str | None: + """What answers for this site, or None: the rule's key + (`lint.inactive_ok[0]`), else the directive's kind and where it + sits (`inactive-ok @ docs/x.md:12`).""" + if self.sanctioned: + return self.sanctioned_by + if self.excused_by is not None: + ann = self.excused_by + suffix = "-file" if ann.scope == directives.FILE else "" + return f"{ann.kind}{suffix} @ {ann}" + return None + @dataclass class Scan: @@ -540,7 +587,8 @@ def _acknowledge(result: Scan, sites: list[tuple[Path, int, str]], rule = (f"lint.inactive_ok[{sanctioned[i]}]" if i in sanctioned else "") pool.setdefault(code, []).append( - Citation(path, line, code, ann, rule)) + Citation(path, line, code, ann, rule, resolves=code in docs, + in_force=code in docs and docs[code].active)) result.sanctioning = frozenset(sanctioned.values()) result.unused = frozenset(a for (a,) in derived.get("unused", ())) for a, code, _ in derived.get("lost", ()): diff --git a/record/changelog.d/claude-laughing-brahmagupta-17c985-export-standing.md b/record/changelog.d/claude-laughing-brahmagupta-17c985-export-standing.md new file mode 100644 index 00000000..3aa0177d --- /dev/null +++ b/record/changelog.d/claude-laughing-brahmagupta-17c985-export-standing.md @@ -0,0 +1,13 @@ +### Added + +- `luria export`'s `citations` table has a `standing` column, which is one + of `in-force`, `acknowledged`, `sanctioned`, `unacknowledged` or + `dangling`. It also has an `excused_by` column, which names the directive + (`inactive-ok @ path:line`) or the `lint.inactive_ok[N]` rule that clears + the site ([ADR-tmptj3j3](record/decisions.d/ADR-tmptj3j3.md)). + +### Changed + +- The export's `excused` column is 1 when a directive or a + `lint.inactive_ok` rule clears the site. Before, it counted directives + only, so a sanctioned citation exported 0 and looked like a warning. diff --git a/record/decisions.d/ADR-tmptj3j3.md b/record/decisions.d/ADR-tmptj3j3.md new file mode 100644 index 00000000..27c3bd88 --- /dev/null +++ b/record/decisions.d/ADR-tmptj3j3.md @@ -0,0 +1,105 @@ +--- +status: Active +title: 'An exported citation states its standing and what clears it' +version: 1 +tags: +- mechanism +date: '2026-10-21' +summary: >- + The export's `citations` table gains `standing`, which is one of + `in-force`, `acknowledged`, `sanctioned`, `unacknowledged` or `dangling`, + and `excused_by`, which names the directive (`inactive-ok @ path:line`) or + the rule (`lint.inactive_ok[N]`) that clears a site. `excused` widens to 1 + when a directive or a rule clears the site. The standing is + `Citation.standing` in `ref_status`, the reading the lint makes, so the + export does not derive it a second time. Rejected: keeping `excused` as + it was and adding only `standing`, and dropping `excused`. +influenced_by: +- ADR-116 +- ADR-105 +- ADR-tmpfu1s6 +- DP-004 +--- + +# ADR-tmptj3j3: An exported citation states its standing and what clears it + +## Context + +`luria export` writes every citation the lint's scanner counts. Each row +carried `resolves` and `excused`, "1 when a directive acknowledges it" +([ADR-116](ADR-116.md)). Five different situations ended up in those two columns: + +- the cited document is in force; +- a directive acknowledges the site; +- a `lint.inactive_ok` rule sanctions it ([ADR-tmpfu1s6](ADR-tmpfu1s6.md)); +- the cited document is retired and nothing answers for it, which is the + case the lint warns about; +- the code names no document here. + +An in-force site and an unacknowledged one both exported `excused = 0`. +To tell them apart, a query had to join `documents` and know the scheme's +`active` word. That reconstructs the lint's reading by hand, and it went +wrong once a rule could clear a site without any directive in the text. A +sanctioned site exported `excused = 0` and looked like a warning. + +## Decision + +**Each citation row states its standing**, as one of these words: + +| standing | the site | +|---|---| +| `in-force` | cites a document in force, and needs nothing | +| `acknowledged` | is a finding that a directive answers for | +| `sanctioned` | cites a retired document where a `lint.inactive_ok` rule allows it | +| `unacknowledged` | cites a retired document and nothing answers for it, so the lint warns | +| `dangling` | names no document and nothing answers for it, so the lint warns | + +A dangling site under `unresolved-ok` is `acknowledged`, because the +directive answers its finding. + +**A `mention-ok` site stands where its target puts it.** The directive +claims nothing about state ([ADR-105](ADR-105.md)). Over a document in force it is +`in-force`: the mention only keeps itself alive. Over a retired or dangling +target it is `acknowledged`, because the mention answered the finding the +site would otherwise be. In both cases `excused_by` names the mention. + +**`excused_by` names what clears the site.** For a directive this is its +kind and where it sits, as the stale-directive report already prints it: +`inactive-ok @ docs/x.md:12`, or `inactive-ok-file @ …` for a file-scoped +directive. For a rule it is the rule's key, `lint.inactive_ok[0]`. It is +NULL when nothing clears the site. A rule takes precedence over a +directive. The only directive that can sit over a sanctioned site is a +`mention-ok`, and it claims nothing. + +**`excused` is 1 when a directive or a rule clears the site.** This changes +the column's meaning: before, a sanctioned site exported 0. + +**There is one reading.** `Citation.standing` and `Citation.cleared_by` in +`luria/ref_status.py` derive both values from the scan the lint reads, and +the export only copies them out ([DP-004](../principles.d/DP-004.md)). The words are constants +(`ref_status.STANDINGS`), so a report that wants them reads the same +values. + +## Alternatives considered + +- **Keep `excused` as it was and add only `standing`.** Nothing breaks, but + `excused` would mean "a directive, not a rule". A query that already + filters on `excused = 0` to find what needs attention would keep finding + every sanctioned site. A rule is an acknowledgement written once, and + the column should count it. +- **Drop `excused`.** `standing IN ('acknowledged', 'sanctioned')` says the + same thing, but removing a column breaks every saved query to tidy a + redundancy. The flag costs one integer per row. +- **Derive the standing in `export.py`.** That would be a second + implementation of the lint's reading of a site. It is the drift [DP-004](../principles.d/DP-004.md) + names, and it is what produced the wrong `excused = 0`. + +## Consequences + +- A query asks `WHERE standing = 'unacknowledged'` for what the lint warns + about, with no join. +- A saved query reading `excused = 0` as "not acknowledged" now also + excludes sanctioned sites, which is what it meant. +- `tests/test_export.py` pins each of the five standings, `excused_by` for + a directive and for a rule, and the `mention-ok` cases. + `tests/test_ref_status.py` pins `Citation.standing` directly. diff --git a/record/devlog.d/2026/10/21/194454.md b/record/devlog.d/2026/10/21/194454.md new file mode 100644 index 00000000..8f6b6279 --- /dev/null +++ b/record/devlog.d/2026/10/21/194454.md @@ -0,0 +1,32 @@ +--- +title: 'Export standing: one reading of a citation' +created: '2026-10-21T19:44:54' +tags: [] +--- + +**The export had been reading a site with half the facts.** Its +`excused` column was `excused_by is not None`, which is true only for a +directive. Once `lint.inactive_ok` could clear a site with no directive in +the text, a sanctioned citation exported `excused = 0`, exactly like a loud +one. An in-force citation did too, so no query could find "what the lint +warns about" without joining `documents` and knowing each scheme's +`active` word. + +**The fix puts the reading on `Citation`, not in `export.py`.** A +`Citation` did not know whether its target resolved or was in force: +`_acknowledge` used both facts to choose a pool and then dropped them. It +now carries both, as `resolves` and `in_force`. With them, +`Citation.standing` and `Citation.cleared_by` are properties, and the export +copies them out ([ADR-tmptj3j3](../../record/decisions.d/ADR-tmptj3j3.md)). + +**One thing that looks like a bug and is not.** A `mention-ok` can sit +over an in-force site and over a sanctioned one, because it answers any +site nothing else claimed. So an in-force row can carry `excused = 1`, with +`excused_by` naming the mention. The standing stays `in-force`, because a +mention claims nothing about state ([ADR-105](../../record/decisions.d/ADR-105.md)). Over a sanctioned site the +rule wins, both in the standing and in `excused_by`. + +**A sanctioned citation in frontmatter is a real case.** In the export +test, the citer's own `summary:` names the decision it replaced, and the +row lands on the summary's line, not in the body. That is the lint's +reading, since `summary:` is prose and a code in it is a citation. diff --git a/tests/test_export.py b/tests/test_export.py index 5ae3d09c..e99327bf 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -18,7 +18,7 @@ from _scheme import decision -# unresolved-ok-file: ADR-918, ADR-919 — fixture codes, deliberately not +# unresolved-ok-file: ADR-917, ADR-918, ADR-919 — fixture codes, deliberately not # real: the citations test checks that they land in the table as unresolved. @@ -94,6 +94,81 @@ def test_citations_come_from_the_record_s_own_scanner(project): ("docs/notes.md", 3, "ADR-919", 0, 0)] +def _with_rule(project: Path) -> None: + """Add a `lint.inactive_ok` rule letting an Active decision cite a + Superseded one.""" + import yaml + path = project / "luria.yaml" + raw = yaml.safe_load(path.read_text()) + raw.setdefault("lint", {})["inactive_ok"] = [{ + "match": "(:ADR {status: 'Active'})-[:cites]->" + "(:ADR {status: 'Superseded'})", + "reason": "a successor names what it replaced"}] + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + config.reset() + + +def test_every_citation_carries_its_standing(project): + """`standing` says what a site needs, in the lint's own reading: nothing + (in force), nothing because a directive or a rule answers for it, a + reader's attention (unacknowledged), or a target (dangling). `excused` + is 1 whenever a directive OR a rule clears the site, and `excused_by` + names which.""" + _with_rule(project) + decision(project, 1, "Active") + decision(project, 2, "Superseded", superseded_by="ADR-001") + decision(project, 3, "Active", "The successor", + summary="Replaces ADR-002.") + (project / "docs" / "notes.md").write_text( + "# Notes\n" + "\n" + "In force: ADR-001.\n" + "\n" + "Acknowledged: ADR-002.\n" + "\n" + "\n" + "Loud: ADR-002 again.\n" + "Dangling: ADR-919.\n" + "\n" + "Acknowledged dangling: ADR-918.\n") + out = export.write(project / "record.sqlite") + got = rows(out, "SELECT path, line, code, standing, excused, excused_by " + "FROM citations WHERE path IN ('docs/notes.md', " + "'record/decisions.d/ADR-003.md') ORDER BY path, line") + assert got == [ + ("docs/notes.md", 3, "ADR-001", "in-force", 0, None), + ("docs/notes.md", 5, "ADR-002", "acknowledged", 1, + "inactive-ok @ docs/notes.md:4"), + ("docs/notes.md", 8, "ADR-002", "unacknowledged", 0, None), + ("docs/notes.md", 9, "ADR-919", "dangling", 0, None), + ("docs/notes.md", 11, "ADR-918", "acknowledged", 1, + "unresolved-ok @ docs/notes.md:10"), + ("record/decisions.d/ADR-003.md", 7, "ADR-002", "sanctioned", 1, + "lint.inactive_ok[0]"), + ] + + +def test_a_mention_counts_by_what_the_site_would_need(project): + """`mention-ok` claims nothing about state, so a site under one stands + where its target puts it: in force stays `in-force`, and a retired or + dangling target is `acknowledged` — the mention answered the finding. + Either way `excused_by` names it.""" + decision(project, 1, "Active") + decision(project, 2, "Superseded", superseded_by="ADR-001") + (project / "docs" / "notes.md").write_text( + "# Notes\n" + "\n" + "\n" + "ADR-001, ADR-002 and ADR-917 as spellings.\n") + out = export.write(project / "record.sqlite") + got = rows(out, "SELECT code, standing, excused, excused_by " + "FROM citations WHERE path = 'docs/notes.md' ORDER BY code") + by = "mention-ok @ docs/notes.md:3" + assert got == [("ADR-001", "in-force", 1, by), + ("ADR-002", "acknowledged", 1, by), + ("ADR-917", "acknowledged", 1, by)] + + def test_journal_entries_are_exported(tmp_path, monkeypatch): (tmp_path / "luria.yaml").write_text( "issue_url: https://example.test/{n}\n" diff --git a/tests/test_ref_status.py b/tests/test_ref_status.py index 64ee562d..77885321 100644 --- a/tests/test_ref_status.py +++ b/tests/test_ref_status.py @@ -498,3 +498,24 @@ def test_the_acknowledged_counts_are_partitioned_too(project): "ADR-777 and ADR-tmpab12c\n") assert ref_status.dangling_acknowledged_count(result, docs) == 1 assert ref_status.dangling_acknowledged_count(result, docs, temps=True) == 1 + + +def test_each_site_has_one_standing(project): + """`Citation.standing` is the one reading of what a site needs — the + export's column and anything else that asks read it here (DP-004).""" + docs, result = scan(project, "in force: ADR-001\n" + "\n" + "retired: ADR-012\n" + "\n" + "loud: ADR-012\n" + "nothing: ADR-920\n") + got = sorted((c.line, c.code, c.standing, c.cleared_by) + for pool in (result.cited, result.dangling) + for sites in pool.values() for c in sites) + assert got == [ + (1, "ADR-001", ref_status.IN_FORCE, None), + (3, "ADR-012", ref_status.ACKNOWLEDGED, + "inactive-ok @ notes.md:2"), + (5, "ADR-012", ref_status.UNACKNOWLEDGED, None), + (6, "ADR-920", ref_status.DANGLING, None), + ]