From 2a934236bf708bb53c890fab879484f003639502 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 20 Oct 2026 18:48:50 -0700 Subject: [PATCH 1/4] Quote the bullet a citation comment names (#761 follow-up) Fourteen comments named a rule's Except or Accepted bullet by description and quoted nothing, so test_doc_citations could not check them. Each now quotes the bullet (an Except bullet by its whole bold lead, which bullet_lead_findings holds it to) beside its rule ID, says which clause the code implements or pins, and points at the decisions entry. Two named the wrong bullet: test_parser.py's 'M2 Accepted row' is M2's Except bullet on post-comma text, and test_pieces.py's 'no-comma peel's Accepted limit' is S7's. Co-Authored-By: Claude Opus 5.5 --- nameparser/_parser.py | 2 +- nameparser/_pipeline/_comma.py | 12 +++++++----- nameparser/_pipeline/_extract.py | 7 ++++--- nameparser/_pipeline/_pieces.py | 7 ++++--- nameparser/_pipeline/_vocab.py | 3 ++- nameparser/_render.py | 8 +++++--- tests/v2/cases.py | 6 ++++-- tests/v2/pipeline/test_pieces.py | 6 ++++-- tests/v2/pipeline/test_script_segment.py | 4 +++- tests/v2/test_parser.py | 6 ++++-- tests/v2/test_properties.py | 12 +++++++----- tests/v2/test_render.py | 5 +++-- 12 files changed, 48 insertions(+), 30 deletions(-) diff --git a/nameparser/_parser.py b/nameparser/_parser.py index 0f9c4b46..4b46981c 100644 --- a/nameparser/_parser.py +++ b/nameparser/_parser.py @@ -41,7 +41,7 @@ class Parser: vocabulary DECLINES, so a locale pack's surnames always win where they match; returning None declines in turn and the token stays whole. Two promises narrow when one is supplied (the first is - rules/A1's Accepted clause): + rules/A1's Accepted bullet "The one exception to totality"): parse-totality gains its one exception -- an exception raised by the segmenter propagates, because a user-supplied callable's own error is a user-code error, not a content error -- and this Parser diff --git a/nameparser/_pipeline/_comma.py b/nameparser/_pipeline/_comma.py index 33f6d161..bd517f7c 100644 --- a/nameparser/_pipeline/_comma.py +++ b/nameparser/_pipeline/_comma.py @@ -483,11 +483,13 @@ def decide(state: ParseState) -> ParseState: tokens[i] = copy_with(tokens[i], role=role, tags=tags) # rules/C11: "A decision either way at the comma C1 reads is reported; # for a run of words the decision is the flip to the credential - # run, reported once over the whole part" -- "A flip in which no - # listed word of the ambiguous credential class (C4) takes part is - # the exception and is made in silence" -- "unless what said so is - # nothing but other paired initials". This implements its Then, - # the bullet on runs S7 reads, and its Except bullet + # run, reported once over the whole part" -- its Then, which this + # implements, with rules/C11's Then bullet "A run the count leaves + # in the listing form reports as S7 reads the words in it" and + # rules/C11's Except bullet "A flip in which no listed word of the + # ambiguous credential class (C4) takes part is the exception and + # is made in silence" -- "unless what said so is nothing but other + # paired initials", the clause that bullet ends on # (decisions/C11 (#544)). A run whose every class word is listed and settled by # its capitals is read on that evidence, not flipped by the count. # A caps word bound above carries the class tags now, so membership diff --git a/nameparser/_pipeline/_extract.py b/nameparser/_pipeline/_extract.py index 18011938..17577e77 100644 --- a/nameparser/_pipeline/_extract.py +++ b/nameparser/_pipeline/_extract.py @@ -70,9 +70,10 @@ def _maiden_marked(content: str, lexicon: Lexicon) -> bool: clause's word (it may be the surname Nee). A PHRASE marker is one marker, so the word after is the word after the whole run -- '(z domu Nowak)' has one, '(z domu)' has none. The word after is - not tested for anything -- M3's Accepted line, and the reason a - bracketed '(née V)' reads maiden 'V' where the bare 'née V' gives - M2 a suffix. + not tested for anything, which is + rules/M3's Accepted bullet "The word taken after the marker is not tested" + (decisions/M3) and the reason a bracketed '(née V)' reads maiden + 'V' where the bare 'née V' gives M2 a suffix. This stage runs before tokenize, so it calls maiden_marker_run itself rather than reading the tags classify will set. The two diff --git a/nameparser/_pipeline/_pieces.py b/nameparser/_pipeline/_pieces.py index a6d4a5ba..389525a0 100644 --- a/nameparser/_pipeline/_pieces.py +++ b/nameparser/_pipeline/_pieces.py @@ -805,9 +805,10 @@ def segment_suffix_reading(pieces: Sequence[Sequence[int]], -- such a word being a suffix word written as an initial. Under strict the numeral gets no suffix reading behind a suffix word, so a part no credential opened is no run and the reading is None - ('Smith, MD I'); a part a credential opened (C13, C14's Except) - still takes the word in through `opened` below and hands it to - `absorbed` ('Smith, PSM I.'). The walk reads the token's `initial` + ('Smith, MD I'); a part a credential opened (C13) still takes the + word in through `opened` below and hands it to `absorbed` + ('Smith, PSM I.'), which implements + rules/C14's Except bullet "A credential opening the part". The walk reads the token's `initial` tag, so a one-case lowercase letter is vetoed as its capital is (rules/T4). Reading no policy here silently overrode the one knob a caller sets to prevent exactly this (#430). History: diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index 923d3a49..fb640e0f 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -186,7 +186,8 @@ def in_initialless_script(text: str) -> bool: # it at the z. `_MARKS` is the general combining blocks -- what NFD of # a Latin, Greek or Cyrillic letter produces -- and not the marks that # only some scripts write (Indic vowel signs, Arabic and Hebrew -# points); rules/H2's Accepted clause owns that limit. +# points); rules/H2's Accepted bullet "The shape is only recognizable +# as an unbroken run of letters" owns that limit (decisions/H2). _MARKS = '[\u0300-\u036f\u1ab0-\u1aff\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]' _LETTER = r'[^\W\d_]' + _MARKS + '*' _PERIOD_ABBREV = re.compile(r'^(?:' + _LETTER + r'){2,}\.$') diff --git a/nameparser/_render.py b/nameparser/_render.py index a6c7c708..6de33b24 100644 --- a/nameparser/_render.py +++ b/nameparser/_render.py @@ -228,8 +228,8 @@ def initials(name: ParsedName, spec: str, delimiter: str, separator: str) -> str This view takes NO lexicon, so it has none to fall back to for that text: `replace(family='de la vega')` initials every word of that field where the same name parsed gives 'j. v.' - (rules/R3's Accepted - clause, and decisions/R4 for why the fallback was tried + (rules/R3's Accepted bullet "This rule reads a part the parser read", + the limit this view keeps; decisions/R4 for why the fallback was tried and dropped -- #464 is the crossing that would make it answerable). Valid spec keys: given, middle, family.""" if not isinstance(delimiter, str): @@ -649,7 +649,9 @@ def capitalized(name: ParsedName, lexicon: Lexicon | None, *, 'de la' where the same words parsed give 'De La'; one set to 'de y' keeps the 'y' lowercase, as the parse does and as 1.4.0 did. Parser.revise() is the edit that classifies the value, and - gives 'De La' (rules/R4's Accepted boundary). + gives 'De La': the boundary of + rules/R4's Accepted bullet "The all-particle clause reaches a part" + (decisions/R4). Idempotent: every _cap_word rule, and the hyphen rule in _cap_text, is a fixpoint on its own output, so a repaired name comes back unchanged whether or not the gate admits it again diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 47340d7b..18d010d3 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -277,8 +277,10 @@ def _check_cjk_shape_purity(self) -> None: f"composed forms belong under tolerated=True, not a " f"shape tag") # U+00B7 (间隔号) marks a name transcription, which keeps the - # DECLARED order (W4's interpunct Except) -- shape 7's ground, - # not shape 6's family-first one. The fullwidth nakaguro U+30FB + # DECLARED order: rules/W4's Except bullet "A name the + # interpunct divides", the clause shape 7 is checked against + # here, where shape 6 is checked against W4's family-first Then + # (decisions/W4). The fullwidth nakaguro U+30FB # is NOT a transcription marker on its own: decisions/T3 scopes that reading # to the codepoint, so U+30FB on non-katakana text is an # ordinary Han/Hangul separator and the text reads family- diff --git a/tests/v2/pipeline/test_pieces.py b/tests/v2/pipeline/test_pieces.py index 9aa9193c..752ed0e4 100644 --- a/tests/v2/pipeline/test_pieces.py +++ b/tests/v2/pipeline/test_pieces.py @@ -542,8 +542,10 @@ def test_anchor_in_reach_is_false_only_where_the_pass_is() -> None: skip={rest[2]}) # a merged split credential is ONE suffix piece of two tokens, and # it anchors as the unsplit spelling does wherever it is in the - # walk -- after a comma ('John Smith Ph. D. MEng' is the no-comma - # peel's Accepted limit, the merged piece standing outside its walk) + # walk -- after a comma ('John Smith Ph. D. MEng' is rules/S7's + # Accepted shape "a credential written split across two words with + # no comma after the name", the merged piece standing outside its + # walk; decisions/S7) assert parse("Doe, Jane Ph. D. MEng").suffix == "Ph. D. MEng" assert parse("Smith, Ph. D. MEng").suffix == "Ph. D. MEng" # and the member it speaks for reports as the pick it is diff --git a/tests/v2/pipeline/test_script_segment.py b/tests/v2/pipeline/test_script_segment.py index 9fcdb62b..3241df38 100644 --- a/tests/v2/pipeline/test_script_segment.py +++ b/tests/v2/pipeline/test_script_segment.py @@ -984,7 +984,9 @@ def test_interpunct_divided_name_never_segments() -> None: # 马丁·路德·金 (Martin Luther King): 马 is a listed surname, but a # 间隔号-divided name is a transcription -- its pieces are syllable # groups, not surname+given, so the dot gates the stage off - # entirely, vocabulary AND segmenter (rule W1's interpunct Except). Built + # entirely, vocabulary AND segmenter: rules/W1's Except bullet "A + # name the interpunct divides", the clause this test pins + # (decisions/W1, decisions/T3). Built # through the real tokenize so interpunct_offsets is the field's # own producer's, not hand-set. state = segment(tokenize(ParseState( diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index 4d9f7dc1..2851194e 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -1703,8 +1703,10 @@ def test_stacked_activation_warns_only_for_uncovered_scripts() -> None: #: The corpus names a maiden clause can be appended to without the #: clause itself being the variable: Latin script, no comma (a clause -#: behind a comma is post-comma text, rules/M2's Accepted row), and -#: no marker already present. +#: behind a comma is post-comma text: rules/M2's Except bullet "In the +#: given part after a family comma, in a part after a suffix comma, and +#: behind a title, a suffix word or a connective, the marker is an +#: ordinary word", decisions/M2), and no marker already present. _LATIN = re.compile(r"^[\x00-\u024f]*$") diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index a071e0c7..d1ccb377 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -829,9 +829,10 @@ def test_a_trailing_title_is_transparent_to_the_maiden_clause() -> None: the title up, 'Doe nee Smith Prof. ba' cannot; and where something in or ahead of the clause would take the title they differ too ('Jane van der Berg nee Smith PhD Prof.' against '... Prof. PhD', - 'Jane Doe nee Smith do MA Prof.' against '... do Prof. MA') -- - rules/M2's Accepted pairs, which are examples rather than a - complete list. No head or run below has a particle, a bound given + 'Jane Doe nee Smith do MA Prof.' against '... do Prof. MA'), the + shape rules/M2's Accepted bullet "The run is what the trailing rules read" + accepts (decisions/M2). Both lists are examples rather than + complete. No head or run below has a particle, a bound given name or an empty name in or ahead of the clause. An invariant over two INPUTS (docs/design/AGENTS.md axis 11), so it @@ -2306,8 +2307,9 @@ def _off_switch_findings() -> dict[str, list[str]]: # is decisions/P3's own -- a one-case name reads the # marked letter as an initial and so reads as its ALL-CAPS # twin already did -- and a class letter the caller's lexicon - # leaves UNMARKED is the shape P8's Accepted block records - # instead, where a bare Latin capital and its lowercase + # leaves UNMARKED is the shape rules/P8's Accepted bullet + # "Two 1.4.0 parity breaks, one in each direction" records + # (decisions/P8) instead, where a bare Latin capital and its lowercase # spelling genuinely part. The `conj+v` variant is exactly # that caller: it adds 'v' to the connectives and not to the # marked subset, so 'carod y rovira, v' and its ALL-CAPS twin diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index cd6310a2..eb2d6f22 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -338,8 +338,9 @@ def test_repair_keeps_a_plain_connective_the_suffix_field_holds_lower( v1 = HumanName(text) v1.capitalize(force=True) assert str(v1) == forced, text - # A SPLICED field is the other way in, and R4's Accepted - # paragraph is explicit about it: text nobody read gets the + # A SPLICED field is the other way in, and rules/R4's Accepted + # bullet "The boundary is drawn per question rather than per field" + # is explicit about it (decisions/R4): text nobody read gets the # vocabulary's answer, so a suffix set to 'de y' keeps its 'y' # exactly as a family set to 'de y' does. 1.4.0 and 2.3.0 both # give 'John Smith De, y'. From a5ae1864b33e757a99188db5a7c388f510342113 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 20 Oct 2026 18:49:16 -0700 Subject: [PATCH 2/4] Add the Except-fence check to the design review's axis 3 (#761 follow-up) Co-Authored-By: Claude Opus 5.5 --- docs/design/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/AGENTS.md b/docs/design/AGENTS.md index 5a9255aa..047eb3a8 100644 --- a/docs/design/AGENTS.md +++ b/docs/design/AGENTS.md @@ -26,7 +26,7 @@ Skip what the suite already enforces. `tests/v2/test_rules_doc.py`, `tests/v2/te 1. **Recompute every number.** Any count, ratio or "N names" in the diff gets recomputed by a script written now, never read. (A filter described as returning one name returned three; a rule's stated blast radius listed two names its own scope excluded.) 2. **Your detector is a second, unreviewed implementation.** It must read the same vocabulary and boundaries the rule reads. (A particle-run detector that walked only the never-given half — the rule chains through ANY particle — reported 50 false movers where the true count was 1, printing cleanly both times.) -3. **Rule vs. its own examples.** (P1 said the fold takes "the particle and the one name word"; its example `de la Vega` is two particles onto one word.) +3. **Rule vs. its own examples.** (P1 said the fold takes "the particle and the one name word"; its example `de la Vega` is two particles onto one word.) An Except bullet's fence is examples of that bullet, not of the rule: a row there must be one where Then does NOT hold, so read each row in an Except fence against its own bullet's bold lead and move a row Then still decides to the main fence. Nothing checks this — the runner asserts a row's output, never which fence it sits in — and the #761 migrations, which sorted every rule's examples into fences by hand, put a row where Then held into an Except fence three times: in the H section (PR #779), in S's strict-mode switch row (PR #783), and in a T1 row (PR #782). 4. **Any claim about a document vs. what that document says about itself.** Two directions, both bitten. Inward: follow every `history:` and read the entry (P6's rationale asserted what decisions/vocabulary-collisions denies, and the rule's own guard exists because the assertion is false). Outward: when prose anywhere — this file, a code comment, another design doc — describes what one of these files contains or does, read that file's preamble or entry conventions first (an AGENTS.md bullet claimed the rules name the pipeline stages; their preamble forbids exactly that — "Statements are implementation-free: no stage names, no function names, no regexes"). Those declarations sit at the top of files people cite without reading. Mind their scope before calling a violation: the rules' ban binds STATEMENTS, which `_statements()` in test_doc_citations.py defines — a rule's fields, When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets, without fences or footer — so Backgrounds and Not-in-scope may name functions and do. 5. **Does the change move another rule's OUTPUT?** A marker lands on the rule whose STATEMENT changed; a rule can move another's output without touching its statement, and the runner cannot see it. Walk the rules it names: the IDs in its When, Then, Except and Precedence. (family_base moved initials(); R3 had no marker.) 6. **Contested inputs: is precedence stated?** Naming a rule pins nothing: an ID in Then or Except does not say which rule wins. A rule states precedence in a `**Precedence:**` bullet — the input, the winning rule, why. Walk the same IDs as axis 5 and ask, for each, whether an input both rules claim has its winner written down. (P6 and S2 both claimed `Berg, Jan vd`.) From 46ecbdbd94b6ba87005efcee6e675dd9064473a2 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 20 Oct 2026 18:50:49 -0700 Subject: [PATCH 3/4] Refuse a rule file that reuses a retired rule's ID (#761 follow-up) PR #786 replaced the tombstone with 'A retired rule's decisions entry records the retirement', which left 'never reused' unenforced. A retired rule's decisions file now carries a line opening 'Retired:', and test_no_rule_reuses_a_retired_id refuses a rule file with that ID. The reader lives in tests/v2/design_docs.py. A misspelled Retired line and one in a file keyed by no rule ID are findings too, since either would switch the refusal off silently. No rule is retired yet, so the real-tree test refuses nothing; test_a_rule_file_reusing_a_retired_id_is_refused builds a synthetic decisions file and a rule file reusing its ID and is the negative control (it fails with the ID check disabled, measured). Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/README.md | 1 + docs/design/rules/README.md | 2 +- tests/v2/design_docs.py | 46 +++++++++++++++++++ tests/v2/test_design_docs.py | 78 ++++++++++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index 4d5b2b6f..85f0a415 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -12,6 +12,7 @@ Entry conventions: 3. **Compaction, on a stated trigger.** A superseded entry is cut to a tombstone: its date, issue, verdict line and `SUPERSEDED by`, plus whatever it recorded that the superseding entry did not replace — a gap it found standing before its change, or an option it declined that the reversal does not answer, stays beneath the tombstone (decisions/T4's #731); a superseded passage inside a live entry is cut to its decision and what replaced it. A measurement paragraph (what moves, cost, blast radius) is cut to one line naming its population, counts, baseline and PR once its change has shipped in a release. The full text stays in git and in the PR the entry cites. #728 is the worked compaction. A harvested entry (below) keeps its framing line through compaction, a tombstone included, and its measured-vs-remembered markings wherever its measurements survive. A trap #759's review found: a one-line replacement carries each count with the classification the old text gave that count and no other (decisions/S2's #614 line had given a whole grid the classification measured on one slice of it). 4. **A per-word fact that moves into the vocabulary's TOML is REPLACED by a query that finds it** (`uv run python tools/vocab/query.py --issue 661`, `--excluded generation`), not left beside a dated "moved" note, which would keep two copies to drift. Only the per-word sentences go; the decision, its criterion and its measurement stay as written. Sanctioned since 2026-10-18 (decisions/vocabulary-model). - **`## Standing`**, at the head of a file: the one part edited freely. One line per decision in force, in the present tense, ending with the entry that decided it by its exact date and the first issue its head names (`— 2026-10-06 #613`), or by its date alone where the head names none and no other entry shares the date, plus a `Not standing:` line for entries whose plan was carried out or whose schedule events overtook — a decision reversed by a later DECISION is marked under 1 instead and an `Open:` line for each open question. It indexes the log and is never a decision itself: a Standing line with no entry behind it, or only a superseded one, fails `tests/v2/test_design_docs.py::test_every_standing_line_points_at_a_live_entry`, and changing a decision is still a new entry, which updates Standing in the same diff. The entries follow under `## Log`. Every decisions file created after C1's block landed (2026-10-19) opens with one, the cheap moment to write it being the file's first entry; the older files are listed in `tests/v2/decisions_without_standing.json`, decisions/C12 and decisions/vocabulary-model among them though they too were created that day, before the rule. `test_a_decisions_file_off_the_list_opens_with_standing` fails on a file off that list with no block (the heading on a line of its own; the words quoted in prose do not count), on a listed file that has gained one, on a listed key no file carries, and on a listed key the list did not hold when the rule landed (a fixed set in `tests/v2/design_docs.py`, its size asserted), so the list never holds a key it did not start with: a new file can join it neither alone nor in exchange for one that leaves. The block stands ahead of every `## Log` heading; a `## Log` before it is a finding, and so is a heading that misspells `## Standing` (a trailing space, a different level), which would otherwise switch the pointer checks off. A decision of another rule's that this log is the record of stays a bullet naming its rule (decisions/C13's line for rules/C2, decisions/P9's for rules/S5); one recorded under another key is an `Elsewhere:` line pointing at that entry, not a bullet. Whether the older files get one is #762. Five files split out on 2026-10-19 (C13, C14, P8, P9, T5) showed the rule was needed: none had a block, though C1's had landed the same day. +- **`Retired:`**, for a rule taken out of rules/: a line of its own in the rule's decisions file, opening `Retired:` and naming the date, the issue and what replaced the rule (`Retired: 2026-11-02 #800 — folded into C1.`), in the Standing block where the file has one, beside the dated entry that decided it. The ID is then never reused: `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying it, a `Retired:` line in a file keyed by no rule ID, and a line that misspells `Retired:` (bold, a list marker, lowercase), which would switch the refusal off for its ID. No rule had been retired when the check landed (2026-10-20), so it guarded nothing on the tree until the first one is; its synthetic case is what shows it bites. - **Harvested entries** (content landed from a session's report) keep their provenance: a "measurements are that session's, spot-checked at landing" framing line, the contributor's measured-vs-remembered markings where they matter, and — the hard-won one — a flagged uncertainty is resolved by reading the source artifact, never by inference from the neighboring argument. - **`Declined:`** — proposals rejected WITH the evidence that killed them. Resolved-as-no is a decision; without a home for it, the next person re-derives the rejected proposal and its measurement. - **`Excluded:`** — standing prohibitions with indefinite lifetime, keyed by vocabulary set: entries that must stay OUT of a wordlist, each with its reason. Distinct from Declined because the failure mode differs — nobody re-derives a rejected proposal, but someone sweeping a wordlist ships the excluded entry as a bug. **A word-level refusal is better as data**: an `excluded` key on the word's TOML entry makes adding the refused reading a build error that quotes the reason, which prose cannot do. So these blocks narrow to CLASS-level prohibitions (a criterion that keeps a whole kind of word out) as their words move, under the exception above; a block whose words have not moved yet stays as it is. diff --git a/docs/design/rules/README.md b/docs/design/rules/README.md index 0ddbd66f..693e26b4 100644 --- a/docs/design/rules/README.md +++ b/docs/design/rules/README.md @@ -4,7 +4,7 @@ This document is NORMATIVE, not descriptive: the rules state how names are writt Authority, scoped: `tests/v2/cases.py` pins CURRENT behavior; this document states INTENDED behavior. A mismatch between them must be classified, never defaulted: either the rule is wrong (fix it here) or the parser is wrong (the example takes a `deviates:` marker and an issue). Where this document is silent, the behavior is pinned-but-undocumented — an extraction gap to close, not a specification, and not license to change the behavior. -Rule IDs are stable forever: never renumbered, never reused. A retired rule's decisions entry records the retirement. Cross-references use the form `decisions/P2` / `mechanisms.md#SPANS`; a bare ID is never a citation. The citation-integrity test checks that every rule ID written in a rule's quotable prose (every field, below) exists — not that the two rules really meet, and not which of them wins, which a Precedence bullet states. `implemented:` and `tracked:` are checked too: every rule carries exactly one. `implemented:` names the modules honoring the rule, checked against the modules that cite it; `tracked:` names the issues that would ship a rule nothing implements yet, so a wholly-aspirational rule cannot sit here untracked, and a shipped rule cannot keep a stale tracking pointer. +Rule IDs are stable forever: never renumbered, never reused. A retired rule's file leaves this folder, and its decisions file records the retirement on a line of its own opening `Retired:` (decisions/README.md); `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying an ID a decisions file records that way. Cross-references use the form `decisions/P2` / `mechanisms.md#SPANS`; a bare ID is never a citation. The citation-integrity test checks that every rule ID written in a rule's quotable prose (every field, below) exists — not that the two rules really meet, and not which of them wins, which a Precedence bullet states. `implemented:` and `tracked:` are checked too: every rule carries exactly one. `implemented:` names the modules honoring the rule, checked against the modules that cite it; `tracked:` names the issues that would ship a rule nothing implements yet, so a wholly-aspirational rule cannot sit here untracked, and a shipped rule cannot keep a stale tracking pointer. ## The skeleton diff --git a/tests/v2/design_docs.py b/tests/v2/design_docs.py index 0479b76b..712546b5 100644 --- a/tests/v2/design_docs.py +++ b/tests/v2/design_docs.py @@ -108,6 +108,52 @@ def resolve(doc: str, cid: str, folder: Path | None = None) -> Path | None: return hits[0] if len(hits) == 1 else None +# A rule ID is never reused (rules/README.md). A retired rule's file +# leaves rules/, and its decisions file records the retirement on a line +# of its own opening `Retired:`; a rule file carrying that ID again is +# refused. A line that means `Retired:` and misspells it (bold, a list +# marker, lowercase) would switch the refusal off for its ID, so it is a +# finding too. +_RETIRED_RE = re.compile(r"^Retired:", re.M) +_NEAR_RETIRED = re.compile(r"^[ \t>*_+-]*retired[ \t*_]*:.*$", re.M | re.I) + + +def rule_file_ids(folder: Path = RULES_DIR) -> set[str]: + """The ID of every rule and section file in the folder, read from + the file names rather than the index, so a file the index misses is + still counted.""" + return {p.stem.split("-", 1)[0] for p in folder.glob("*.md") + if p.name != "README.md" + and _RULE_KEY_RE.fullmatch(p.stem.split("-", 1)[0])} + + +def retired_ids(decisions: dict[str, str]) -> set[str]: + """The keys of the decisions files that record a retirement.""" + return {key for key, text in decisions.items() + if _RETIRED_RE.search(text)} + + +def reused_id_findings(rule_ids: set[str], + decisions: dict[str, str]) -> list[str]: + """What breaks never-reused: a rule file whose ID a decisions file + records as retired, a `Retired:` line in a decisions file keyed by + no rule ID (it retires nothing), and a line misspelling `Retired:`.""" + findings = [] + for key, text in sorted(decisions.items()): + for m in _NEAR_RETIRED.finditer(text): + if not m.group(0).startswith("Retired:"): + findings.append(f"decisions/{key}: {m.group(0)!r} is not " + "a line opening 'Retired:'") + for key in sorted(retired_ids(decisions)): + if not _RULE_KEY_RE.fullmatch(key): + findings.append(f"decisions/{key} carries a Retired: line but " + "is keyed by no rule ID, so it retires nothing") + elif key in rule_ids: + findings.append(f"rules/{key} reuses an ID decisions/{key} " + "records as retired: a new rule takes a new ID") + return findings + + # A decisions file carries `## Standing`, an index of the decisions in # force, ahead of its entries under `## Log` -- every file but those # listed in decisions_without_standing.json (below). Each Standing diff --git a/tests/v2/test_design_docs.py b/tests/v2/test_design_docs.py index d8c28be8..f5219d59 100644 --- a/tests/v2/test_design_docs.py +++ b/tests/v2/test_design_docs.py @@ -9,8 +9,9 @@ from tests.v2.design_docs import ( DECISIONS_DIR, MENTION_RE, RULES_DIR, read_decision_files, read_index, STANDING_EXEMPT_ORIGINAL, has_standing, read_rules_text, - read_standing_exempt, resolve, slugify, standing_findings, - standing_roster_findings, stem_for) + read_standing_exempt, resolve, retired_ids, reused_id_findings, + rule_file_ids, slugify, standing_findings, standing_roster_findings, + stem_for) from tests.v2.rules_doc import parse_rules_doc _FIXTURE_FILES = ("S-suffixes.md", "S1-x.md", @@ -269,3 +270,76 @@ def test_the_original_exempt_set_is_never_edited() -> None: # C1's block. A key added here would let a new file skip its block, # so the size is asserted rather than trusted to the comment. assert len(STANDING_EXEMPT_ORIGINAL) == 88 + + +_RETIRED_ENTRY = ("# X9 — a retired rule\n\n## Standing\n\n" + "Retired: 2026-10-20 #0 — folded into X1.\n\n" + "## Log\n\n- 2026-10-20 #0 — RETIRED.\n") + + +def _retirement_layout(tmp_path: Path, entry: str) -> tuple[Path, Path]: + """A rules folder holding X1 and X9, and a decisions folder whose + one indexed file, X9's, holds `entry`.""" + rules, decisions = tmp_path / "rules", tmp_path / "decisions" + rules.mkdir(parents=True) + decisions.mkdir() + (rules / "README.md").write_text("# Rules\n", encoding="utf-8") + for name in ("X-section.md", "X1-kept.md", "X9-reused.md"): + (rules / name).write_text(f"# {name}\n", encoding="utf-8") + (decisions / "README.md").write_text( + "# Decisions\n\n- [X9 — a retired rule](X9-retired-rule.md)\n", + encoding="utf-8") + (decisions / "X9-retired-rule.md").write_text(entry, encoding="utf-8") + return rules, decisions + + +def test_a_rule_file_reusing_a_retired_id_is_refused(tmp_path: Path) -> None: + # The negative control the real tree cannot give (no rule is + # retired yet): a decisions file recording X9's retirement and a + # rule file carrying X9 again, read through the same readers the + # real-tree test uses. + rules, decisions = _retirement_layout(tmp_path, _RETIRED_ENTRY) + assert rule_file_ids(rules) == {"X", "X1", "X9"} + found = reused_id_findings(rule_file_ids(rules), + read_decision_files(decisions)) + assert found == ["rules/X9 reuses an ID decisions/X9 records as " + "retired: a new rule takes a new ID"] + # and the Retired: line is what bites: the same files without it + # pass + rules, decisions = _retirement_layout( + tmp_path / "control", _RETIRED_ENTRY.replace("Retired:", "Folded:")) + assert reused_id_findings(rule_file_ids(rules), + read_decision_files(decisions)) == [] + + +@pytest.mark.parametrize("line", [ + "**Retired:** 2026-10-20 #0", "- Retired: 2026-10-20 #0", + "retired: 2026-10-20 #0", "Retired : 2026-10-20 #0", + " Retired: 2026-10-20 #0", +]) +def test_a_misspelled_retired_line_is_a_finding(line: str) -> None: + # each spelling would leave X9 out of retired_ids and the refusal + # off for it + entry = _RETIRED_ENTRY.replace("Retired: 2026-10-20 #0", line) + assert retired_ids({"X9": entry}) == set() + found = reused_id_findings({"X9"}, {"X9": entry}) + assert len(found) == 1 and "is not a line opening 'Retired:'" in found[0] + + +def test_a_retired_line_needs_a_rule_key() -> None: + found = reused_id_findings(set(), {"some-slug": _RETIRED_ENTRY}) + assert found == ["decisions/some-slug carries a Retired: line but is " + "keyed by no rule ID, so it retires nothing"] + + +def test_no_rule_reuses_a_retired_id() -> None: + """rules/README.md: a rule ID is never reused. No rule is retired + on 2026-10-20, so on the real tree this passes with nothing to + refuse; test_a_rule_file_reusing_a_retired_id_is_refused is what + shows it bites. The readers must still reach both folders, or the + pass would be vacuous for a second reason.""" + ids = rule_file_ids() + assert {"C1", "S2", "H"} <= ids + decisions = read_decision_files() + assert "C1" in decisions + assert reused_id_findings(ids, decisions) == [] From 9355e315d679730b321309886f769c34fd87b932 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 20 Oct 2026 18:58:10 -0700 Subject: [PATCH 4/4] Address the design-docs review (#761 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_properties: drop the two maiden-clause pairs said to differ; measured, both spellings of each read alike since #601, and M2's bullet the new citation quoted accepts no such split. - _extract: the M3 contrast is a suffix word ('née PhD'), not V, which M2 also reads as the maiden name. - Retired: a numbered '1. Retired:' line is a misspelling too (the new row failed against the old pattern first). - docs/design/AGENTS.md axis 3: two incidents on the record (H1 in #779, S3's Msc.Ed. row in #783), not three; a row Then decides may belong under Accepted. The skip list names the new guard. - cases.py: shape 7's U+00B7 half is what the W4 Except decides. Co-Authored-By: Claude Opus 5.5 --- docs/design/AGENTS.md | 4 ++-- docs/design/decisions/README.md | 2 +- nameparser/_pipeline/_extract.py | 4 ++-- tests/v2/cases.py | 4 ++-- tests/v2/design_docs.py | 5 +++-- tests/v2/test_design_docs.py | 2 +- tests/v2/test_properties.py | 9 ++------- 7 files changed, 13 insertions(+), 17 deletions(-) diff --git a/docs/design/AGENTS.md b/docs/design/AGENTS.md index 047eb3a8..cbba0fcc 100644 --- a/docs/design/AGENTS.md +++ b/docs/design/AGENTS.md @@ -22,11 +22,11 @@ Two shapes, needing different instruments. CROSS-SESSION distillation is what th **Reviewing a docs/design change: the axes.** Run these before merging anything that touches rules/, decisions/, mechanisms.md — or THIS FILE, which carries the conventions those three rest on and has the same failure modes. Axes 3, 5, 6 and 12 are rules-specific (they need rule statements, examples and the rules a rule names); the other eight apply to any of the four — axis 11 included, since an invariant over two inputs is prompted by what a rule implies but is RUN without reading one, which is the whole of its value. Say which axes you ran and which you skipped as inapplicable — a checklist that asks for something meaningless teaches the reader to skim it. Axes 1–9 were each earned by a defect that survived every other check on #386, where the review passes surfaced ten issues in total — nine of them by these axes, one by the spec self-review above — and unaided prose reading surfaced none. Axes 10 and 11 were earned the same way on other arcs — #291's, which PREDATES this list, and #461/#466's, which follows it. Axis 12 was earned by no defect at all, which is its point: it covers the growth every other axis passes. The parenthetical is the case that earned the axis. -Skip what the suite already enforces. `tests/v2/test_rules_doc.py`, `tests/v2/test_doc_citations.py` and `tests/v2/test_design_docs.py` machine-check example lines and their `deviates:` today-values, boundaries, the one-of `implemented:`/`tracked:` rule, `implemented:` against the modules that actually cite the rule, the rule IDs a rule names anywhere in its prose, cross-doc anchor IDs, citation excerpts quoted verbatim, legacy citation forms (the pre-#713 file-anchor form among them), that every rule and decisions file is indexed once, under a title matching its heading and file name, each decisions file's `## Standing` block against its live entries and against the roster of files exempt from one, the README's copy of rules/D2 against the file and its split-candidate grep against the rules' Signal lines, that the NFD-typed examples stay decomposed, a denylist of spellings that shipped wrong (`tests/v2/test_doc_spellings.py`, over nameparser/ and docs/), each rule's `Signal:` line (its presence, spelling and place) and the README's list of the tags those lines use, a hub's rules against the bullets under its Then, both ways, a comment naming an Except or Precedence bullet against that bullet's bold lead, the skeleton grammar in `tests/v2/test_rules_skeleton.py` (labels, field order, fences, the footer, one paragraph each for When, Tolerated and Then, the two sentence limits — Then one sentence, Because three at most — and an example under every Except bullet), the refusal of a rule in the retired block format, and — in `tests/v2/test_rules_budget.py` — each rule's recorded size. Assume they pass; a finding one of them would catch is a false positive. Read those files rather than trusting this sentence — it named five of the nine guards when written, and goes stale as guards are added. +Skip what the suite already enforces. `tests/v2/test_rules_doc.py`, `tests/v2/test_doc_citations.py` and `tests/v2/test_design_docs.py` machine-check example lines and their `deviates:` today-values, boundaries, the one-of `implemented:`/`tracked:` rule, `implemented:` against the modules that actually cite the rule, the rule IDs a rule names anywhere in its prose, cross-doc anchor IDs, citation excerpts quoted verbatim, legacy citation forms (the pre-#713 file-anchor form among them), that every rule and decisions file is indexed once, under a title matching its heading and file name, each decisions file's `## Standing` block against its live entries and against the roster of files exempt from one, a rule file reusing an ID a decisions file records `Retired:` and a misspelled `Retired:` line, the README's copy of rules/D2 against the file and its split-candidate grep against the rules' Signal lines, that the NFD-typed examples stay decomposed, a denylist of spellings that shipped wrong (`tests/v2/test_doc_spellings.py`, over nameparser/ and docs/), each rule's `Signal:` line (its presence, spelling and place) and the README's list of the tags those lines use, a hub's rules against the bullets under its Then, both ways, a comment naming an Except or Precedence bullet against that bullet's bold lead, the skeleton grammar in `tests/v2/test_rules_skeleton.py` (labels, field order, fences, the footer, one paragraph each for When, Tolerated and Then, the two sentence limits — Then one sentence, Because three at most — and an example under every Except bullet), the refusal of a rule in the retired block format, and — in `tests/v2/test_rules_budget.py` — each rule's recorded size. Assume they pass; a finding one of them would catch is a false positive. Read those files rather than trusting this sentence — it named five of the nine guards when written, and goes stale as guards are added. 1. **Recompute every number.** Any count, ratio or "N names" in the diff gets recomputed by a script written now, never read. (A filter described as returning one name returned three; a rule's stated blast radius listed two names its own scope excluded.) 2. **Your detector is a second, unreviewed implementation.** It must read the same vocabulary and boundaries the rule reads. (A particle-run detector that walked only the never-given half — the rule chains through ANY particle — reported 50 false movers where the true count was 1, printing cleanly both times.) -3. **Rule vs. its own examples.** (P1 said the fold takes "the particle and the one name word"; its example `de la Vega` is two particles onto one word.) An Except bullet's fence is examples of that bullet, not of the rule: a row there must be one where Then does NOT hold, so read each row in an Except fence against its own bullet's bold lead and move a row Then still decides to the main fence. Nothing checks this — the runner asserts a row's output, never which fence it sits in — and the #761 migrations, which sorted every rule's examples into fences by hand, put a row where Then held into an Except fence three times: in the H section (PR #779), in S's strict-mode switch row (PR #783), and in a T1 row (PR #782). +3. **Rule vs. its own examples.** (P1 said the fold takes "the particle and the one name word"; its example `de la Vega` is two particles onto one word.) An Except bullet's fence is examples of that bullet, not of the rule: a row there must be one where Then does NOT hold, so read each row in an Except fence against its own bullet's bold lead and move a row Then still decides out of it — to the main fence, or to the Accepted bullet that records it as a cost. Nothing checks this — the runner asserts a row's output, never which fence it sits in — and the #761 migrations, which sorted every rule's examples into fences by hand, put rows where Then held into an Except fence twice that review caught: H1's given-name rows (PR #779, back to the listing-record Accepted bullet) and S3's switched-off `Msc.Ed.` row (PR #783, to the main fence). 4. **Any claim about a document vs. what that document says about itself.** Two directions, both bitten. Inward: follow every `history:` and read the entry (P6's rationale asserted what decisions/vocabulary-collisions denies, and the rule's own guard exists because the assertion is false). Outward: when prose anywhere — this file, a code comment, another design doc — describes what one of these files contains or does, read that file's preamble or entry conventions first (an AGENTS.md bullet claimed the rules name the pipeline stages; their preamble forbids exactly that — "Statements are implementation-free: no stage names, no function names, no regexes"). Those declarations sit at the top of files people cite without reading. Mind their scope before calling a violation: the rules' ban binds STATEMENTS, which `_statements()` in test_doc_citations.py defines — a rule's fields, When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets, without fences or footer — so Backgrounds and Not-in-scope may name functions and do. 5. **Does the change move another rule's OUTPUT?** A marker lands on the rule whose STATEMENT changed; a rule can move another's output without touching its statement, and the runner cannot see it. Walk the rules it names: the IDs in its When, Then, Except and Precedence. (family_base moved initials(); R3 had no marker.) 6. **Contested inputs: is precedence stated?** Naming a rule pins nothing: an ID in Then or Except does not say which rule wins. A rule states precedence in a `**Precedence:**` bullet — the input, the winning rule, why. Walk the same IDs as axis 5 and ask, for each, whether an input both rules claim has its winner written down. (P6 and S2 both claimed `Berg, Jan vd`.) diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index 85f0a415..b5dd3d31 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -12,7 +12,7 @@ Entry conventions: 3. **Compaction, on a stated trigger.** A superseded entry is cut to a tombstone: its date, issue, verdict line and `SUPERSEDED by`, plus whatever it recorded that the superseding entry did not replace — a gap it found standing before its change, or an option it declined that the reversal does not answer, stays beneath the tombstone (decisions/T4's #731); a superseded passage inside a live entry is cut to its decision and what replaced it. A measurement paragraph (what moves, cost, blast radius) is cut to one line naming its population, counts, baseline and PR once its change has shipped in a release. The full text stays in git and in the PR the entry cites. #728 is the worked compaction. A harvested entry (below) keeps its framing line through compaction, a tombstone included, and its measured-vs-remembered markings wherever its measurements survive. A trap #759's review found: a one-line replacement carries each count with the classification the old text gave that count and no other (decisions/S2's #614 line had given a whole grid the classification measured on one slice of it). 4. **A per-word fact that moves into the vocabulary's TOML is REPLACED by a query that finds it** (`uv run python tools/vocab/query.py --issue 661`, `--excluded generation`), not left beside a dated "moved" note, which would keep two copies to drift. Only the per-word sentences go; the decision, its criterion and its measurement stay as written. Sanctioned since 2026-10-18 (decisions/vocabulary-model). - **`## Standing`**, at the head of a file: the one part edited freely. One line per decision in force, in the present tense, ending with the entry that decided it by its exact date and the first issue its head names (`— 2026-10-06 #613`), or by its date alone where the head names none and no other entry shares the date, plus a `Not standing:` line for entries whose plan was carried out or whose schedule events overtook — a decision reversed by a later DECISION is marked under 1 instead and an `Open:` line for each open question. It indexes the log and is never a decision itself: a Standing line with no entry behind it, or only a superseded one, fails `tests/v2/test_design_docs.py::test_every_standing_line_points_at_a_live_entry`, and changing a decision is still a new entry, which updates Standing in the same diff. The entries follow under `## Log`. Every decisions file created after C1's block landed (2026-10-19) opens with one, the cheap moment to write it being the file's first entry; the older files are listed in `tests/v2/decisions_without_standing.json`, decisions/C12 and decisions/vocabulary-model among them though they too were created that day, before the rule. `test_a_decisions_file_off_the_list_opens_with_standing` fails on a file off that list with no block (the heading on a line of its own; the words quoted in prose do not count), on a listed file that has gained one, on a listed key no file carries, and on a listed key the list did not hold when the rule landed (a fixed set in `tests/v2/design_docs.py`, its size asserted), so the list never holds a key it did not start with: a new file can join it neither alone nor in exchange for one that leaves. The block stands ahead of every `## Log` heading; a `## Log` before it is a finding, and so is a heading that misspells `## Standing` (a trailing space, a different level), which would otherwise switch the pointer checks off. A decision of another rule's that this log is the record of stays a bullet naming its rule (decisions/C13's line for rules/C2, decisions/P9's for rules/S5); one recorded under another key is an `Elsewhere:` line pointing at that entry, not a bullet. Whether the older files get one is #762. Five files split out on 2026-10-19 (C13, C14, P8, P9, T5) showed the rule was needed: none had a block, though C1's had landed the same day. -- **`Retired:`**, for a rule taken out of rules/: a line of its own in the rule's decisions file, opening `Retired:` and naming the date, the issue and what replaced the rule (`Retired: 2026-11-02 #800 — folded into C1.`), in the Standing block where the file has one, beside the dated entry that decided it. The ID is then never reused: `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying it, a `Retired:` line in a file keyed by no rule ID, and a line that misspells `Retired:` (bold, a list marker, lowercase), which would switch the refusal off for its ID. No rule had been retired when the check landed (2026-10-20), so it guarded nothing on the tree until the first one is; its synthetic case is what shows it bites. +- **`Retired:`**, for a rule taken out of rules/: a line of its own in the rule's decisions file, opening `Retired:` and naming the date, the issue and what replaced the rule (`Retired: 2026-11-02 #800 — folded into C1.`), in the Standing block where the file has one, beside the dated entry that decided it. The ID is then never reused: `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying it, a `Retired:` line in a file keyed by no rule ID, and a line that misspells `Retired:` (bold, a list marker or number, lowercase), which would switch the refusal off for its ID. No rule had been retired when the check landed (2026-10-20), so it guarded nothing on the tree until the first one is; its synthetic case is what shows it bites. - **Harvested entries** (content landed from a session's report) keep their provenance: a "measurements are that session's, spot-checked at landing" framing line, the contributor's measured-vs-remembered markings where they matter, and — the hard-won one — a flagged uncertainty is resolved by reading the source artifact, never by inference from the neighboring argument. - **`Declined:`** — proposals rejected WITH the evidence that killed them. Resolved-as-no is a decision; without a home for it, the next person re-derives the rejected proposal and its measurement. - **`Excluded:`** — standing prohibitions with indefinite lifetime, keyed by vocabulary set: entries that must stay OUT of a wordlist, each with its reason. Distinct from Declined because the failure mode differs — nobody re-derives a rejected proposal, but someone sweeping a wordlist ships the excluded entry as a bug. **A word-level refusal is better as data**: an `excluded` key on the word's TOML entry makes adding the refused reading a build error that quotes the reason, which prose cannot do. So these blocks narrow to CLASS-level prohibitions (a criterion that keeps a whole kind of word out) as their words move, under the exception above; a block whose words have not moved yet stays as it is. diff --git a/nameparser/_pipeline/_extract.py b/nameparser/_pipeline/_extract.py index 17577e77..f5943ac0 100644 --- a/nameparser/_pipeline/_extract.py +++ b/nameparser/_pipeline/_extract.py @@ -72,8 +72,8 @@ def _maiden_marked(content: str, lexicon: Lexicon) -> bool: '(z domu Nowak)' has one, '(z domu)' has none. The word after is not tested for anything, which is rules/M3's Accepted bullet "The word taken after the marker is not tested" - (decisions/M3) and the reason a bracketed '(née V)' reads maiden - 'V' where the bare 'née V' gives M2 a suffix. + (decisions/M3) and the reason a bracketed '(née PhD)' reads maiden + 'PhD' where the bare 'née PhD' gives M2 a suffix. This stage runs before tokenize, so it calls maiden_marker_run itself rather than reading the tags classify will set. The two diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 18d010d3..643e73e3 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -278,8 +278,8 @@ def _check_cjk_shape_purity(self) -> None: f"shape tag") # U+00B7 (间隔号) marks a name transcription, which keeps the # DECLARED order: rules/W4's Except bullet "A name the - # interpunct divides", the clause shape 7 is checked against - # here, where shape 6 is checked against W4's family-first Then + # interpunct divides", the clause shape 7's U+00B7 half is + # checked against here, where shape 6 is checked against W4's family-first Then # (decisions/W4). The fullwidth nakaguro U+30FB # is NOT a transcription marker on its own: decisions/T3 scopes that reading # to the codepoint, so U+30FB on non-katakana text is an diff --git a/tests/v2/design_docs.py b/tests/v2/design_docs.py index 712546b5..d540ace8 100644 --- a/tests/v2/design_docs.py +++ b/tests/v2/design_docs.py @@ -112,10 +112,11 @@ def resolve(doc: str, cid: str, folder: Path | None = None) -> Path | None: # leaves rules/, and its decisions file records the retirement on a line # of its own opening `Retired:`; a rule file carrying that ID again is # refused. A line that means `Retired:` and misspells it (bold, a list -# marker, lowercase) would switch the refusal off for its ID, so it is a +# marker or number, lowercase) would switch the refusal off for its ID, so it is a # finding too. _RETIRED_RE = re.compile(r"^Retired:", re.M) -_NEAR_RETIRED = re.compile(r"^[ \t>*_+-]*retired[ \t*_]*:.*$", re.M | re.I) +_NEAR_RETIRED = re.compile( + r"^[ \t>*_+-]*(?:\d+[.)][ \t]*)?retired[ \t*_]*:.*$", re.M | re.I) def rule_file_ids(folder: Path = RULES_DIR) -> set[str]: diff --git a/tests/v2/test_design_docs.py b/tests/v2/test_design_docs.py index f5219d59..25dcf2b5 100644 --- a/tests/v2/test_design_docs.py +++ b/tests/v2/test_design_docs.py @@ -315,7 +315,7 @@ def test_a_rule_file_reusing_a_retired_id_is_refused(tmp_path: Path) -> None: @pytest.mark.parametrize("line", [ "**Retired:** 2026-10-20 #0", "- Retired: 2026-10-20 #0", "retired: 2026-10-20 #0", "Retired : 2026-10-20 #0", - " Retired: 2026-10-20 #0", + " Retired: 2026-10-20 #0", "1. Retired: 2026-10-20 #0", ]) def test_a_misspelled_retired_line_is_a_finding(line: str) -> None: # each spelling would leave X9 out of retired_ids and the refusal diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index d1ccb377..71c97a04 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -826,13 +826,8 @@ def test_a_trailing_title_is_transparent_to_the_maiden_clause() -> None: empty). The invariant is claimed over the forms below and no wider. Where the clause KEEPS the credential the spellings differ, a clause being one contiguous run: 'Doe nee Smith ba Prof.' gives - the title up, 'Doe nee Smith Prof. ba' cannot; and where something - in or ahead of the clause would take the title they differ too - ('Jane van der Berg nee Smith PhD Prof.' against '... Prof. PhD', - 'Jane Doe nee Smith do MA Prof.' against '... do Prof. MA'), the - shape rules/M2's Accepted bullet "The run is what the trailing rules read" - accepts (decisions/M2). Both lists are examples rather than - complete. No head or run below has a particle, a bound given + the title up, 'Doe nee Smith Prof. ba' cannot -- an example + rather than a complete list. No head or run below has a particle, a bound given name or an empty name in or ahead of the clause. An invariant over two INPUTS (docs/design/AGENTS.md axis 11), so it