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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
156 changes: 156 additions & 0 deletions .claude/skills/housekeeping/chapters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# frozen_string_literal: true

# SPDX-License-Identifier: MIT
# .claude/skills/housekeeping/chapters.rb
#
# The probe's ninth check, `chapters` (phase 10's plan, Task 7; design R10 and addendum A10).
#
# A document asserting that a requirement ID is stated in a named `docs/product-spec/` chapter,
# where that chapter does not carry the ID. The unit is a CLAUSE, not a line: "A.md for X, Y;
# B.md for Z" pairs every ID with every chapter under a same-line rule, which is why a naive rule
# fires 23 times for 4 real defects (phase 10's Fact 6). So a line is split at `;`, each ID is
# associated with the NEAREST PRECEDING chapter reference in its clause -- or, for an ID run
# followed by "appears in", with the chapter that phrase introduces -- a range is expanded
# whether or not its endpoints are backticked, and a line whose two-line window carries a negation
# -- prose whose own subject is that the ID is NOT in that chapter -- is skipped. Appendix C
# carries every ID and can never be wrong about one, so it is exempt as a target.
#
# Two stated blind spots, printed in the check's own `GAPS` so a reader is never told the check
# saw something it did not: `continued_clause` (a chapter reference on the PRECEDING line is
# invisible to a line-oriented scanner -- 8c's design :70, `SEAM-15`, was caught by hand), and
# `dynamic_chapter_path` (a chapter named by an ellipsis such as `13-…md`, or by a variable,
# resolves to no file and is skipped).

module Housekeeping
module Checks
# 9. A requirement ID attributed to a spec chapter that does not carry it.
class Chapters < Check
NAME = 'chapters'
PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG
TRANSPORT ASYNC XCUT NFR].freeze
ID = /\b(#{PREFIXES.join('|')})-(\d+)\b/
# `SEAM-11`–`SEAM-15`, SEAM-11–SEAM-15, SEAM-11–15: backticks tolerated on either end,
# which is what hid 8a's `SEAM-13` from a range pattern written for bare text.
RANGE = /`?\b(#{PREFIXES.join('|')})-(\d+)`?\s*[–—]\s*`?(?:\1-)?(\d+)\b`?/
CHAPTER = %r{docs/product-spec/(\d\d-[a-z0-9-]+\.md)}
BOUNDARY = ';'
# Prose whose subject is that the ID is NOT in the named chapter, over the line and the
# next one, because such a sentence routinely crosses a line break.
# A union of plain phrases, never an /x pattern: under /x the spaces inside a phrase are
# stripped and "appears nowhere" silently becomes "appearsnowhere" (found writing this).
NEGATION = Regexp.union(
'appear nowhere', 'appears nowhere', 'harvested nowhere', 'appear in no', 'appears in no',
'appendix C is their only', 'appendix C is its only', 'appendix C alone',
'does not carry', 'do not carry', 'carries neither', 'carries none', 'carries no ',
'carry none', 'no prose chapter', 'appendix-C row', 'unfollowable', 'from appendix C',
'read out of appendix C', 'read out of **appendix C', 'neither ID appears', 'is not in',
'are not in', 'not stated in', 'only normative statement', 'only prose home',
'not appear in', 'does not appear', 'do not appear', 'never appear', "n't appear"
).then { |union| Regexp.new(union.source, Regexp::IGNORECASE) }
# "X appears in <chapter>": the one verb that puts the chapter AFTER the IDs it is about.
# An ID run followed by it binds FORWARD, to the chapter the verb introduces, instead of to
# the nearest preceding one. Phase 10's review round 0 (R0-8) found the phrase listed as a
# negation, which silenced this -- the usual positive attribution -- to hide one line of the
# phase-5 segmentation design (:538-539) whose second run bound backward to the first
# run's chapter; binding forward is what that line needed.
FORWARD = /\bappears?\s+in\b/i
EXEMPT_TARGET = 'appendix-c-consolidated-normative-requirement-index.md'
# Documents that quote the pre-correction attributions ON PURPOSE, as the record of the
# defect this check exists for: phase 10's own design, plan and checklist.
EXEMPT_DOCUMENTS = %r{\Adocs/work/mvp/phase10/}
SCANNED = ['docs'].freeze
SKIPPED = %r{\Adocs/(?:product-spec|knowledge/harvested)/}
GAPS = %i[continued_clause dynamic_chapter_path].freeze

def run(repo)
carried = chapter_ids(repo)
documents(repo).flat_map { |path| scan(path, repo.read(path).lines, carried) }
end

# The pairs a single line asserts, [chapter, id], in clause order. Public so a test can
# read the clause scoping directly.
def self.pairs(line)
line.split(BOUNDARY).flat_map { |clause| clause_pairs(clause) }
end

# A run of consecutive IDs binds to the nearest preceding chapter, unless the run is
# followed by FORWARD, when it binds to the chapter immediately after the verb (or to
# nothing, when the chapter is on the next line: the continued-clause gap).
def self.clause_pairs(clause)
list = tokens(clause)
chapter = nil
pairs = []
index = 0
while index < list.length
kind, value = list[index]
if kind == :chapter
chapter = value
index += 1
elsif kind == :forward
index += 1
else
run_end = index
run_end += 1 while run_end < list.length && list[run_end][0] == :id
target = run_target(list, run_end, chapter)
list[index...run_end].each { |_kind, id| pairs << [target, id] } if target
index = run_end
end
end
pairs
end

def self.run_target(list, after, chapter)
return chapter unless list.dig(after, 0) == :forward

list.dig(after + 1, 0) == :chapter ? list.dig(after + 1, 1) : nil
end

# Chapters and IDs in textual order, every range expanded in place.
def self.tokens(clause)
found = []
clause.scan(CHAPTER) { found << [Regexp.last_match.begin(0), :chapter, Regexp.last_match[1]] }
clause.scan(FORWARD) { found << [Regexp.last_match.begin(0), :forward, nil] }
covered = []
clause.scan(RANGE) do
match = Regexp.last_match
covered << (match.begin(0)...match.end(0))
(match[2].to_i..match[3].to_i).each { |n| found << [match.begin(0), :id, "#{match[1]}-#{n}"] }
end
clause.scan(ID) do
match = Regexp.last_match
next if covered.any? { |span| span.cover?(match.begin(0)) }

found << [match.begin(0), :id, "#{match[1]}-#{match[2]}"]
end
found.sort_by.with_index { |(position, _kind, _value), index| [position, index] }
.map { |_position, kind, value| [kind, value] }
end

private

def chapter_ids(repo)
repo.tracked('docs/product-spec/*.md').to_h do |path|
[File.basename(path), repo.read(path).scan(ID).map { |prefix, n| "#{prefix}-#{n}" }.uniq]
end
end

def documents(repo)
repo.tracked(*SCANNED).select { |path| path.end_with?('.md') }
.reject { |path| SKIPPED.match?(path) || EXEMPT_DOCUMENTS.match?(path) }
end

def scan(path, lines, carried)
lines.each_with_index.flat_map do |line, index|
next [] if NEGATION.match?(line + lines.fetch(index + 1, ''))

self.class.pairs(line).filter_map do |chapter, id|
next if chapter == EXEMPT_TARGET || !carried.key?(chapter) || carried[chapter].include?(id)

act(path, index + 1, "attributes #{id} to docs/product-spec/#{chapter}, which does not " \
"carry it (gaps: #{GAPS.join(', ')})")
end
end
end
end
end
end
8 changes: 6 additions & 2 deletions .claude/skills/housekeeping/probe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ def strip_inline_code(text)
end
end

# The eight read-only checks. Each is a class with `name` and `run(repo) -> [Finding]`.
# The nine read-only checks. Each is a class with `name` and `run(repo) -> [Finding]`. The
# ninth, `chapters`, lives in chapters.rb (phase 10): one concern per file, as guard.rb.
module Checks
# Base class: supplies `name` from the subclass's `NAME` and a `Finding` factory.
class Check
Expand Down Expand Up @@ -732,11 +733,14 @@ def symlinks(repo)
end
end

require_relative 'chapters'

# Runs the checks and collects their findings.
class Probe
ALL = [
Checks::Inbox, Checks::Root, Checks::Claims, Checks::Readmes,
Checks::Links, Checks::Registers, Checks::Citations, Checks::GuardCheck
Checks::Links, Checks::Registers, Checks::Citations, Checks::GuardCheck,
Checks::Chapters
].freeze
NAMES = ALL.map { |check| check::NAME }.freeze

Expand Down
131 changes: 131 additions & 0 deletions .claude/skills/housekeeping/test/chapters_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# frozen_string_literal: true

# SPDX-License-Identifier: MIT
# .claude/skills/housekeeping/test/chapters_test.rb
#
# The probe's ninth check, `chapters` (phase 10's plan, Task 7). Measured over docs/ on
# 2026-09-25: the clause-scoped form fires on exactly the true positives, and the two blind spots
# are asserted here so the check never claims to have seen what it did not. The two
# phase-8 governing-documents lines it was designed against were corrected on 2026-09-13, so they
# live on as committed regression fixtures -- a check whose only evidence is a live defect loses
# its evidence the moment the defect is fixed.

require 'minitest/autorun'

require_relative '../probe'
require_relative 'fixture'

class ChaptersTest < Minitest::Test
Fixture = Housekeeping::Fixture
Chapters = Housekeeping::Checks::Chapters

FIXTURES = File.expand_path('fixtures/chapters', __dir__)
# Chapter 03 as the spec carries it, trimmed to the SEAM IDs that matter: 13, 15 and 22 are not
# in it (02 carries SEAM-13; appendix C alone carries 15 and 22).
CHAPTER_03 = "# 3\n\nSEAM-11 SEAM-12 SEAM-14 SEAM-16 SEAM-17 SEAM-24 SEAM-25 SEAM-29 SEAM-30\n"
CHAPTER_02 = "# 2\n\nSEAM-13 SEAM-29\n"

def run_over(document)
overrides = {
'docs/product-spec/03-pluggable-seams-and-extension-model.md' => CHAPTER_03,
'docs/product-spec/02-architectural-principles.md' => CHAPTER_02,
'docs/work/mvp/x-design.md' => document
}
Fixture.with(overrides: overrides) do |root|
Housekeeping::Probe.new(Housekeeping::Repo.new(root), only: ['chapters']).run
end
end

def ids(findings) = findings.map { |finding| finding.message[/SEAM-\d+/] }.sort

def test_a_governing_documents_list_naming_an_appendix_c_only_id_is_a_finding
findings = run_over("`docs/product-spec/03-pluggable-seams-and-extension-model.md` for `SEAM-11`, `SEAM-15`.\n")

assert_equal %w[SEAM-15], ids(findings)
end

def test_prose_whose_subject_is_the_absence_is_not_a_finding
assert_empty run_over(
"`docs/product-spec/03-pluggable-seams-and-extension-model.md` carries 22 of the 30 IDs; " \
"`SEAM-15` and `SEAM-22` appear nowhere in the specification's prose.\n"
)
end

def test_a_clause_boundary_reassigns_the_chapter
findings = run_over("`docs/product-spec/03-pluggable-seams-and-extension-model.md` for `SEAM-11`; " \
"`docs/product-spec/02-architectural-principles.md` for `SEAM-13`.\n")

assert_empty findings
end

# 8a's design :67, verbatim: the backticked range is what hid SEAM-13 from a pattern written for
# bare text, and with the range read all three wrong IDs are found.
def test_the_phase8a_pre_correction_line_fires_on_all_three_wrong_ids
findings = run_over(File.read(File.join(FIXTURES, 'phase8a_design_67_pre_correction.md')))

assert_equal %w[SEAM-13 SEAM-15 SEAM-22], ids(findings)
end

# 8c's design :69-70, verbatim: SEAM-13 is on the chapter's own line and is found; SEAM-15 is
# too. A clause continued onto the NEXT line would be the stated blind spot -- see below.
def test_the_phase8c_pre_correction_line_fires_on_both_wrong_ids
findings = run_over(File.read(File.join(FIXTURES, 'phase8c_design_69_pre_correction.md')))

assert_equal %w[SEAM-13 SEAM-15], ids(findings)
end

def test_a_clause_continued_onto_the_next_line_is_a_stated_blind_spot
findings = run_over("- `docs/product-spec/03-pluggable-seams-and-extension-model.md` for\n `SEAM-15`.\n")

assert_empty findings
assert_includes Chapters::GAPS, :continued_clause
end

def test_the_range_vocabulary_tolerates_backticks_and_a_bare_upper_bound
assert_equal [%w[03-x.md SEAM-11], %w[03-x.md SEAM-12], %w[03-x.md SEAM-13]],
Chapters.pairs('docs/product-spec/03-x.md for `SEAM-11`–`SEAM-13`').first(3)
assert_equal 3, Chapters.pairs('docs/product-spec/03-x.md for SEAM-11–13').size
end

# Phase 10's review round 0 (R0-8): "appears in" was listed as a negation, which silenced the
# usual positive attribution. It is a FORWARD binding now: the IDs before it belong to the
# chapter after it, so a wrong one fires and a right one does not.
def test_an_appears_in_attribution_binds_forward_and_fires_when_wrong
wrong = "`SEAM-15` appears in `docs/product-spec/03-pluggable-seams-and-extension-model.md`.\n"
right = "`SEAM-11` appears in `docs/product-spec/03-pluggable-seams-and-extension-model.md`.\n"

assert_equal %w[SEAM-15], ids(run_over(wrong))
assert_empty run_over(right)
end

# The phase-5 segmentation design's :538-539 shape, the line the negation entry was hiding: a
# second run followed by "appears in" and a chapter on the NEXT line must not bind backward to
# the first run's chapter (40 false fires under the backward rule).
def test_a_second_appears_in_run_does_not_bind_backward
document = "every one of `SEAM-11`–`SEAM-12` appears in\n" \
"`docs/product-spec/03-pluggable-seams-and-extension-model.md` and every one of `SEAM-13` appears in\n" \
"`docs/product-spec/02-architectural-principles.md`.\n"

assert_empty run_over(document)
assert_equal [%w[03-x.md SEAM-11]], Chapters.pairs('`SEAM-11` appears in docs/product-spec/03-x.md')
end

# Phase 10's review round 1 (R1-4): the forward binding matched "appear in" inside a negative
# sentence, so a correct statement that an ID is ABSENT from a chapter fired as a wrong
# attribution. Each spelling of the negated verb is a negation, not a binding. Round 2 (R2-2)
# added the two it still bound forward: 'never appears in' and "doesn't appear in".
def test_a_negated_appears_in_is_not_an_attribution
['does not appear in', 'do not appear in', 'did not appear in', 'never appears in',
"doesn't appear in", "don't appear in", 'appears nowhere in'].each do |verb|
document = "`SEAM-15` #{verb} `docs/product-spec/03-pluggable-seams-and-extension-model.md`.\n"

assert_empty run_over(document), verb
end
end

def test_appendix_c_is_never_a_target
document = "`docs/product-spec/appendix-c-consolidated-normative-requirement-index.md` for `SEAM-99`.\n"

assert_empty run_over(document)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- `docs/product-spec/03-pluggable-seams-and-extension-model.md` for `SEAM-11`–`SEAM-15`, `SEAM-22`, `SEAM-29`
-- the governing-documents line phase 8a's design carried at :67 before phase 10's planning
corrected it on 2026-09-13, kept verbatim as a regression fixture.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- `docs/product-spec/03-pluggable-seams-and-extension-model.md` — `SEAM-11`, `SEAM-13`, `SEAM-14`, `SEAM-15`, `SEAM-16`,
`SEAM-17`, `SEAM-24`, `SEAM-25`, `SEAM-30`
4 changes: 2 additions & 2 deletions .claude/skills/housekeeping/test/probe_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ def test_a_clean_fixture_reports_nothing_on_every_check
assert_empty on_fixture
end

def test_check_names_are_the_eight_the_documentation_states
assert_equal %w[inbox root claims readmes links registers citations guard], Probe::NAMES
def test_check_names_are_the_nine_the_documentation_states
assert_equal %w[inbox root claims readmes links registers citations guard chapters], Probe::NAMES
end

# --- numerals --------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- run: bundle exec rake gates:cause_walk
- run: bundle exec rake gates:bounded_map
- run: bundle exec rake gates:seam_names
- run: bundle exec rake gates:ledger_audit
- run: bundle exec rake gates:spdx_rbs
- run: bundle exec rake gates:sole_parse
- run: bundle exec rake gates:rbs_surface
- run: bundle exec rake gates:sig_diff
- run: bundle exec rake gates:surface_snapshot
Expand Down
3 changes: 3 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ DEFAULT_GATES = %w[
gates:cause_walk
gates:bounded_map
gates:seam_names
gates:ledger_audit
gates:spdx_rbs
gates:sole_parse
gates:clean_bundle
gates:rbs_surface
gates:sig_diff
Expand Down
Loading
Loading