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
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