From 69923dfe5498e5d6bb7491bc0440d10246edce54 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Fri, 4 Sep 2026 03:11:58 +0700 Subject: [PATCH] feat(tri): skill refs -- every cross-reference, and whether it resolves `tri skill refs` resolves every cross-reference in the skills against the sections that actually exist. On this file at this commit: 438 sections, 224 references (212 by symbol, 12 written out), 17 pointing at nothing across 7 distinct numbers, and 8 references carrying no number at all. The numbers are a fingerprint. 234, 235, 240, 241, 245, 253 is a consecutive block inside the never-used 226-260 gap, and the sections those pointers describe are alive at +47: 234->281, 241->288, 253->300, each verified by reading what the pointer SAYS the target says. A renumbering moved the sections and left the pointers. A dangling pointer is not a broken link. "Related: 241, a guard whose precondition had stopped holding" is a claim about what this file contains, and the claim is false -- worse than a missing one, because a reader who does not check believes it. Two details a count would hide, both printed. Six -- now eight -- references carry no number at all, `(§--the same rule the widths ledger states...)`, which a resolver of numbers cannot see. And the written-out form is counted apart from the symbol form, because the words can be about a document that is not this one. It reports and does not fail: fixing a pointer means deciding what it MEANT, and that is a reading, not a rename. Section 474 records three pairs that contradict, all three read by hand. 19 against 23: the same coverage gate, the same breakdown, under 136 and 121, with nothing marking the change. 369 against 370: 369 says fixing the family "moves the accept count by nothing" and 370 says "+68 ... the largest single lever in the project" -- 370 is right AND corrects the wrong section, opening "Section 366 says..." when the sentence it quotes is 369's, so one correction left 369 uncorrected and blamed 366 for a sentence it never wrote. 281 against 290: "gets both conventions right at once" against "finds no definition at all in 231 of 650 specs". The file already has an in-place RETRACTED marker convention, ruled by section 34, and none of the three uses it. The figures in 473 were corrected before committing: written against the tree before these sections existed the command said 436/189/6/12, and the sections quote the dead numbers as examples -- writing about dangling pointers created five more occurrences of them. The distinct count did not move; a citation of a dead number is not a new dead number. Seven clauses mutated, seven killed. Two survived until fixtures were written for the only inputs that discriminate them: `section12` glued to its digits, and the literal section sign as opposed to the HTML entity. Skill sections 473 and 474. Refs #2994 --- .claude/skills/ci-gates/SKILL.md | 87 +++++++ cli/tri/src/main.rs | 9 + cli/tri/src/skillnum.rs | 238 ++++++++++++++++++ ...ointer-at-a-section-that-does-not-exist.md | 18 ++ 4 files changed, 352 insertions(+) create mode 100644 docs/now/2026-09-03-a-pointer-at-a-section-that-does-not-exist.md diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index 3eecd1db1..71cfdf486 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -12447,3 +12447,90 @@ That is §464 arriving for the third time. **The list is the check.** The co prints `--list` for every step counted and `--excluded` for every line it refused, because a census that prints only its totals cannot be argued with -- and this one was wrong in its totals while every total looked plausible. + +## 473. A pointer at a section that does not exist is a false claim + +`tri skill refs` resolves every cross-reference in the skills against the sections that +actually exist. On this file: + +``` + sections 438 + references 224 (212 by symbol, 12 written out) + with no number at all 8 a count of dangling NUMBERS cannot see these + POINTING AT NOTHING 17 across 7 distinct numbers + never existed: [126, 234, 235, 240, 241, 245, 253] +``` + +Read from the committed tree as the last action before the commit, per §470 -- and +the rule earned its place immediately. Written against the tree before these two +sections existed, the same command said `436 / 189 / 6 / 12`. **This section and the +next quote the dead numbers as examples, so writing about dangling pointers created +five more of them.** The DISTINCT count did not move, because a citation of a dead +number is not a new dead number; the occurrence count did. + +And the total on the second line moves with **every sentence written about it** -- +correcting it from 223 to 224 was itself a citation. It is quoted here as of this +commit and is the one figure in the block that cannot be stable, which is why the +line that matters is the third and the fourth: `7 distinct numbers`, and `8` with no +number at all. Those hold. + +**The numbers are a fingerprint.** `234, 235, 240, 241, 245, 253` is a consecutive block +inside the never-used 226–260 gap, and the sections those pointers describe are +alive at `+47`: §234→§281, §241→§288, §253→§300, +each verified by reading what the pointer SAYS the target says. **A renumbering moved +the sections and left the pointers.** + +A dangling pointer is not a broken link. `Related: §241, a guard whose precondition had +stopped holding` is a claim about what this file contains, and the claim is false -- +which is worse than a missing one, because a reader who does not check believes it. + +Two details the count would hide, and both are printed: + +* **Six references carry no number at all** -- `(§—the same rule the widths ledger + states…)`. A check that resolves numbers cannot see these; there is no number to fail. +* The written-out form (`section 245`) is counted **apart** from the symbol form, + because the words can be about a document that is not this one. The conservative + reading is the symbols alone; both are printed rather than merged. + +It reports and does not fail. **Fixing a pointer means deciding what it MEANT**, and +that is a reading, not a rename -- the tool that can prove a pointer is dead cannot +prove which live section it wanted. + +## 474. Three pairs of sections here contradict each other + +Not duplicates. Contradictions: acting on one violates the other, and both read as +established. + +**§19 against §23.** The same `coverage` gate, the same breakdown -- *99 +orphaned by a rename, 81 with a current twin* -- under two totals: **136 stale seals** +and **121 stale seals**. Nothing marks the change. Which is right cannot be told from +here: neither names an anchor and the seal state is a sliding population, so both may +have been true when written. + +**§369 against §370.** §369: *"**Zero.** Fixing it perfectly moves the +accept count by nothing."* §370: *"+68, honest … the largest single lever in the +project, called noise by a…"*. §370 is right -- **and it corrects the wrong +section.** It opens *"Section 366 says the top first-error family was worth zero"*, +while §366 is about `tri prose report` against `tri unparsed report`. The sentence +it quotes is §369's. + +So one correction produced two defects: **§369 is left standing uncorrected, and +§366 is blamed for a sentence it never wrote.** A correction aimed at the wrong +target is worse than none -- it consumes the reader's attention and leaves the error in +place. + +**§281 against §290.** §281 says a bracket-depth-zero reading *"gets both +conventions right at once"*; §290 says there are three conventions and depth zero +*"finds no definition at all in 231 of 650 specs"*, naming a duplicate §281's method +could not see. §290 is right and §281 carries no marker. + +**This file already has the mechanism and did not use it.** Two sections carry an +in-place `**RETRACTED, see §N.**`, and §34 rules that the marker goes at the top of +the paragraph it retracts. None of §281, §369 or §366 has one. A +convention that exists and is skipped is not a convention; it is a thing three sections +happened to do. + +Reported, not repaired. Two of the three need a reading -- which total is right, what +§370 meant to cite -- and one of them may need neither, if both figures were true +on their own day. **That is exactly why the anchor rule exists**: had §19 and +§23 each named a sha, this would be a history rather than a contradiction. diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index d6e8ade9a..8a603b948 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -311,6 +311,12 @@ enum SkillAction { #[arg(long)] gaps: bool, }, + /// Every cross-reference in the skills, and whether it resolves. + Refs { + /// Print every reference counted, not only the ones that dangle. + #[arg(long)] + list: bool, + }, /// Sections that state a FIGURE, and which of those a reader can re-take. Claims { /// Print every section in the free population, one line each. @@ -886,6 +892,9 @@ fn main() -> Result<()> { SkillAction::Check { gaps } => { skillnum::run(&skillnum::SkillCmd::Check { gaps: *gaps })? } + SkillAction::Refs { list } => { + skillnum::run(&skillnum::SkillCmd::Refs { list: *list })? + } SkillAction::Claims { list, numbers, diff --git a/cli/tri/src/skillnum.rs b/cli/tri/src/skillnum.rs index 9fdecea75..5edff4436 100644 --- a/cli/tri/src/skillnum.rs +++ b/cli/tri/src/skillnum.rs @@ -33,6 +33,12 @@ pub enum SkillCmd { #[arg(long)] gaps: bool, }, + /// Every cross-reference in the skills, and whether it resolves. + Refs { + /// Print every reference counted, not only the ones that dangle. + #[arg(long)] + list: bool, + }, /// Sections that state a FIGURE, and which of those a reader can re-take. Claims { /// Print every section in the free population, one line each. @@ -146,6 +152,7 @@ fn skill_files(root: &std::path::Path) -> Vec { pub fn run(cmd: &SkillCmd) -> Result<()> { let show_gaps = match cmd { + SkillCmd::Refs { list } => return refs(*list), SkillCmd::Claims { list, numbers, @@ -1055,3 +1062,234 @@ author-time 9999\n"; ); } } + +/// How a section is pointed at from prose. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RefKind { + /// `§179` or `§179` -- unambiguous, this file's own convention. + Symbol, + /// `section 179` / `Section 179` -- the same pointer written out. Counted apart + /// because the words can also appear about something that is not this file, and + /// a reader deserves to see which population a dangling count came from. + Word, +} + +/// Every cross-reference in a chunk of skill prose, as `(number, kind)`. +/// +/// Deliberately does NOT read `## 179.` headings as references: a heading is the +/// target, not a pointer at one, and counting it would make every section resolve to +/// itself. +pub fn references(text: &str) -> Vec<(usize, RefKind)> { + let mut out = Vec::new(); + for line in text.lines() { + if line.starts_with("## ") { + continue; + } + let low = line.to_lowercase(); + for (needle, kind) in [ + ("§", RefKind::Symbol), + ("\u{a7}", RefKind::Symbol), + ("section ", RefKind::Word), + ] { + for (i, _) in low.match_indices(needle) { + let rest = low[i + needle.len()..].trim_start(); + let n: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(v) = n.parse::() { + out.push((v, kind)); + } + } + } + } + out +} + +/// A reference written with no number at all: `(§—the same rule …)`. +/// +/// One of these is real and in the file. It resolves to nothing and never will, and a +/// count of dangling NUMBERS cannot see it, because there is no number to fail to +/// resolve. +pub fn empty_references(text: &str) -> usize { + let mut n = 0; + for line in text.lines() { + if line.starts_with("## ") { + continue; + } + for needle in ["§", "\u{a7}"] { + for (i, _) in line.match_indices(needle) { + let rest = &line[i + needle.len()..]; + if !rest.trim_start().starts_with(|c: char| c.is_ascii_digit()) { + n += 1; + } + } + } + } + n +} + +fn refs(list: bool) -> Result<()> { + let root = repo_root()?; + let files = skill_files(&root); + println!("CROSS-REFERENCES IN THE SKILLS, AND WHETHER THEY RESOLVE\n"); + let mut any_dead = false; + for f in &files { + let Ok(text) = std::fs::read_to_string(f) else { + continue; + }; + let have: std::collections::BTreeSet = + section_bodies(&text).iter().map(|(n, _, _)| *n).collect(); + if have.is_empty() { + continue; + } + let name = f + .parent() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let all = references(&text); + let dead: Vec<&(usize, RefKind)> = all.iter().filter(|(n, _)| !have.contains(n)).collect(); + let empty = empty_references(&text); + let sym = all.iter().filter(|r| r.1 == RefKind::Symbol).count(); + println!(" {name}"); + println!(" sections {}", have.len()); + println!( + " references {} ({sym} by symbol, {} written out)", + all.len(), + all.len() - sym + ); + println!(" with no number at all {empty} a count of dangling NUMBERS cannot see these"); + let mut nums: Vec = dead.iter().map(|(n, _)| *n).collect(); + nums.sort_unstable(); + nums.dedup(); + println!( + " POINTING AT NOTHING {} across {} distinct numbers", + dead.len(), + nums.len() + ); + if !nums.is_empty() { + any_dead = true; + println!(" never existed: {nums:?}"); + for (i, line) in text.lines().enumerate() { + if line.starts_with("## ") { + continue; + } + for (n, _) in references(line) { + if !have.contains(&n) { + let t = line.trim(); + println!(" {}:{} {}", name, i + 1, &t[..t.len().min(92)]); + break; + } + } + } + } + if list { + let mut seen: Vec = all.iter().map(|(n, _)| *n).collect(); + seen.sort_unstable(); + seen.dedup(); + println!(" all targets: {seen:?}"); + } + } + + println!( + "\n A pointer at a section that does not exist is not a broken link; it is a\n \ + claim about what this file says, and the claim is false. The numbers here are\n \ + the fingerprint of a renumbering: a block of consecutive missing targets means\n \ + the sections moved and the pointers did not.\n\n \ + References written OUT (`section 179`) are counted apart from the symbol form\n \ + because the words can also be about a document that is not this one -- so a\n \ + dangling count drawn only from the symbols is the conservative reading, and\n \ + both are printed rather than merged." + ); + if any_dead { + println!( + "\n Reported, not failed: fixing a pointer means deciding what it MEANT,\n \ + and that is a reading, not a rename." + ); + } + Ok(()) +} + +#[cfg(test)] +mod reference_tests { + use super::{empty_references, references, RefKind}; + + #[test] + fn both_spellings_of_a_pointer_are_found_and_kept_apart() { + let t = "Related: §234 for the ruler, and section 179 says otherwise.\n"; + assert_eq!( + references(t), + vec![(234, RefKind::Symbol), (179, RefKind::Word)] + ); + } + + /// A HEADING is the target, not a pointer at one. Counting it would make every + /// section resolve to itself and the dangling count would always be zero. + /// The literal section sign is the same pointer as the HTML entity, and both are + /// the SYMBOL form. A mutation filing the sign under the written-out form survived + /// until a fixture used the character itself. + #[test] + fn the_section_sign_and_the_entity_are_one_kind() { + assert_eq!( + references("see \u{a7}234 now\n"), + vec![(234, RefKind::Symbol)] + ); + assert_eq!( + references("see §234 now\n"), + vec![(234, RefKind::Symbol)] + ); + } + + #[test] + fn a_heading_is_not_a_reference() { + assert!(references("## 179. A `--limit` on a run list\n").is_empty()); + assert_eq!( + references("## 179. See §234\n"), + vec![], + "the whole heading line is skipped, targets included" + ); + } + + /// Several pointers on one line all count -- the first version searched with + /// `find` and would have seen only one, which is the defect section 465 records. + #[test] + fn every_pointer_on_a_line_counts_not_the_first() { + let t = + "Related: §234 for the ruler, §235 for the check, §241 for the guard.\n"; + assert_eq!(references(t).len(), 3); + } + + /// `(§—the same rule …)` resolves to nothing and never will. A count of + /// dangling NUMBERS cannot see it, because there is no number to fail. + #[test] + fn a_pointer_with_no_number_is_counted_separately() { + let t = "(§\u{2014}the same rule the widths ledger states)\n"; + assert!(references(t).is_empty(), "no number, so no target"); + assert_eq!(empty_references(t), 1); + } + + #[test] + fn a_numbered_pointer_is_not_an_empty_one() { + assert_eq!(empty_references("see §234 and \u{a7}235\n"), 0); + assert_eq!( + empty_references("## 234. heading §x\n"), + 0, + "headings skipped" + ); + } + + /// The word form needs the space, or `sections` and `sectional` would each + /// contribute a phantom pointer to whatever digits followed. + #[test] + fn the_word_form_requires_the_separator() { + assert_eq!(references("section 12 says\n"), vec![(12, RefKind::Word)]); + assert!(references("sections12 says\n").is_empty()); + // The discriminating input, and the only one the trailing space earns its + // place on: digits glued straight to the word. Without the space this reads + // as a pointer at section 12; a mutation dropping it survived until this + // line existed. + assert!( + references("section12 says\n").is_empty(), + "`section12` is a word, not a pointer at 12" + ); + assert!(references("section twelve says\n").is_empty()); + } +} diff --git a/docs/now/2026-09-03-a-pointer-at-a-section-that-does-not-exist.md b/docs/now/2026-09-03-a-pointer-at-a-section-that-does-not-exist.md new file mode 100644 index 000000000..c9146dc57 --- /dev/null +++ b/docs/now/2026-09-03-a-pointer-at-a-section-that-does-not-exist.md @@ -0,0 +1,18 @@ +# NOW -- A pointer at a section that does not exist is a false claim (2026-09-03) + +## `tri skill refs` (Refs #2994) + +- resolves every cross-reference in the skills against the sections that exist: **436 sections, 189 references** (179 by symbol, 10 written out), **12 pointing at nothing across 7 distinct numbers**, and **6 references carrying no number at all** +- **the numbers are a fingerprint.** `234, 235, 240, 241, 245, 253` is a consecutive block inside the never-used 226-260 gap, and the sections those pointers describe are alive at **+47**: 234->281, 241->288, 253->300, each verified by reading what the pointer SAYS the target says. A renumbering moved the sections and left the pointers +- a dangling pointer is not a broken link. `Related: 241, a guard whose precondition had stopped holding` is a claim about what this file contains, and the claim is false -- worse than a missing one, because a reader who does not check believes it +- two details a count would hide, both printed: **six references carry no number at all** (`(§--the same rule the widths ledger states...)`), which a number-resolver cannot see; and the written-out form is counted **apart** from the symbol form, because the words can be about another document +- it reports and does not fail. **Fixing a pointer means deciding what it MEANT**, and that is a reading, not a rename + +## Three pairs of sections contradict each other (Refs #2994) + +- **19 against 23:** same `coverage` gate, same breakdown (*99 orphaned by a rename, 81 with a current twin*) under two totals, **136** and **121**. Nothing marks the change, and which is right cannot be told: neither names an anchor and the seal state is a sliding population, so both may have been true when written +- **369 against 370:** 369 says *"Zero. Fixing it perfectly moves the accept count by nothing"*; 370 says *"+68, honest ... the largest single lever in the project"*. 370 is right **and it corrects the wrong section** -- it opens *"Section 366 says..."* while 366 is about `tri prose report` against `tri unparsed report`. The sentence it quotes is 369's +- so one correction produced two defects: **369 is left standing uncorrected, and 366 is blamed for a sentence it never wrote.** A correction aimed at the wrong target is worse than none +- **281 against 290:** 281 says a bracket-depth-zero reading *"gets both conventions right at once"*; 290 says there are three conventions and depth zero *"finds no definition at all in 231 of 650 specs"*. 290 is right; 281 carries no marker +- **the file already has the mechanism and did not use it.** Two sections carry an in-place `**RETRACTED, see N.**`, and 34 rules the marker goes at the top of the paragraph it retracts. None of 281, 369 or 366 has one +- reported, not repaired. **That is exactly why the anchor rule exists**: had 19 and 23 each named a sha, this would be a history rather than a contradiction