You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
218 lines
9.1 KiB
218 lines
9.1 KiB
#!/usr/bin/env tclsh |
|
# goals_lint.tcl - lint the two-tier goals documentation |
|
# (root GOALS.md / GOALS-archive.md indexes + goals/ / goals/archive/ detail files) |
|
# |
|
# Validates the contracts declared in goals/AGENTS.md and root AGENTS.md: |
|
# - detail-file header grammar: '# G-<id> <title>' then a blank line, then |
|
# Status:/Scope:/Goal:/Acceptance: exactly once each, in order, one line each, |
|
# before any '## section'; no prose riding a header field |
|
# - Status: values are exactly one of proposed|active|abandoned|superseded or |
|
# 'achieved YYYY-MM-DD' |
|
# - active-tree detail files are not 'achieved'; archive detail files are |
|
# - no orphans: every goals/*.md detail file has a GOALS.md index entry and vice |
|
# versa; every goals/archive/*.md file has a GOALS-archive.md record and vice versa |
|
# - Status:/Scope: mirror lines in detail files match the owning index entry |
|
# |
|
# Plain tclsh (8.6+ or 9), no package dependencies - runnable from any checkout: |
|
# tclsh scriptlib/developer/goals_lint.tcl [repo-root] |
|
# Exit 0 when clean; exit 1 with one 'path: problem' line per finding. |
|
|
|
set root [expr {$argc >= 1 ? [lindex $argv 0] : [file dirname [file dirname [file dirname [file normalize [info script]]]]]}] |
|
if {![file isfile [file join $root GOALS.md]] || ![file isdir [file join $root goals]]} { |
|
puts stderr "goals_lint: '$root' does not look like the punkshell repo root (need GOALS.md and goals/)" |
|
exit 2 |
|
} |
|
|
|
set findings {} |
|
proc flag {path msg} { |
|
global findings |
|
lappend findings "$path: $msg" |
|
} |
|
|
|
set status_re {^(proposed|active|abandoned|superseded|achieved \d{4}-\d{2}-\d{2})$} |
|
|
|
proc read_lines {path} { |
|
set f [open $path r] |
|
fconfigure $f -encoding utf-8 |
|
set lines [split [read $f] \n] |
|
close $f |
|
return $lines |
|
} |
|
|
|
# --- parse an index file (GOALS.md or GOALS-archive.md) --------------------- |
|
# returns dict: id -> {status <s> title <t> scope <scope-or-empty>} |
|
proc parse_index {path} { |
|
global status_re |
|
set entries [dict create] |
|
set relpath [file tail $path] |
|
set curid "" |
|
foreach line [read_lines $path] { |
|
if {[regexp {^### G-(\d+) \[([^\]]*)\] (.*)$} $line -> id status title]} { |
|
set curid $id |
|
if {[dict exists $entries $id]} { |
|
flag $relpath "duplicate index entry G-$id" |
|
} |
|
if {![regexp $status_re $status]} { |
|
flag $relpath "G-$id: bad status '\[$status\]' (want proposed|active|abandoned|superseded|achieved YYYY-MM-DD)" |
|
} |
|
dict set entries $id [dict create status $status title $title scope ""] |
|
} elseif {[regexp {^### } $line]} { |
|
set curid "" |
|
} elseif {$curid ne "" && [regexp {^Scope: (.*)$} $line -> scope]} { |
|
dict set entries $curid scope $scope |
|
} |
|
} |
|
return $entries |
|
} |
|
|
|
# --- lint one detail file ---------------------------------------------------- |
|
# returns dict {id <id> status <s> scope <s>} (empty strings when unparseable) |
|
proc lint_detail {path relpath} { |
|
global status_re |
|
set lines [read_lines $path] |
|
set fname [file tail $path] |
|
set result [dict create id "" status "" scope ""] |
|
|
|
if {![regexp {^G-(\d+)-[a-z0-9-]+\.md$} $fname -> fid]} { |
|
flag $relpath "filename does not match G-<id>-<slug>.md" |
|
set fid "" |
|
} |
|
dict set result id $fid |
|
|
|
if {![regexp {^# G-(\d+) .+$} [lindex $lines 0] -> hid]} { |
|
flag $relpath "line 1 is not '# G-<id> <title>'" |
|
set hid "" |
|
} |
|
if {$fid ne "" && $hid ne "" && $hid ne $fid} { |
|
flag $relpath "title-line id G-$hid does not match filename id G-$fid" |
|
} |
|
if {[lindex $lines 1] ne ""} { |
|
flag $relpath "line 2 is not blank" |
|
} |
|
|
|
# header fields: Status/Scope/Goal/Acceptance exactly once each, in order, |
|
# before the first '## ' section |
|
set want {Status Scope Goal Acceptance} |
|
set found [dict create] |
|
set order {} |
|
set i 2 |
|
foreach line [lrange $lines 2 end] { |
|
incr i |
|
if {[regexp {^## } $line]} break |
|
if {[regexp {^(Status|Scope|Goal|Acceptance): ?(.*)$} $line -> field value]} { |
|
if {[dict exists $found $field]} { |
|
flag $relpath "duplicate header field '$field:' (line $i)" |
|
} else { |
|
dict set found $field $value |
|
lappend order $field |
|
} |
|
if {$value eq ""} { |
|
flag $relpath "empty header field '$field:' (line $i)" |
|
} |
|
} |
|
} |
|
foreach field $want { |
|
if {![dict exists $found $field]} { |
|
flag $relpath "missing header field '$field:'" |
|
} |
|
} |
|
if {[llength $order] == 4 && $order ne $want} { |
|
flag $relpath "header fields out of order ([join $order /] - want [join $want /])" |
|
} |
|
|
|
if {[dict exists $found Status]} { |
|
set status [dict get $found Status] |
|
dict set result status $status |
|
if {![regexp $status_re $status]} { |
|
flag $relpath "bad Status line 'Status: $status' - must be exactly proposed|active|abandoned|superseded or 'achieved YYYY-MM-DD' with no additional prose (verification evidence belongs in ## Progress / ## Notes)" |
|
} |
|
} |
|
if {[dict exists $found Scope]} { |
|
dict set result scope [dict get $found Scope] |
|
} |
|
return $result |
|
} |
|
|
|
# --- run ---------------------------------------------------------------------- |
|
set active_index [parse_index [file join $root GOALS.md]] |
|
set archive_index [parse_index [file join $root GOALS-archive.md]] |
|
|
|
foreach {dir index indexname achieved_expected} [list \ |
|
goals $active_index GOALS.md 0 \ |
|
[file join goals archive] $archive_index GOALS-archive.md 1] { |
|
set seen [dict create] |
|
foreach path [lsort [glob -nocomplain -directory [file join $root $dir] G-*.md]] { |
|
set relpath [string map {\\ /} [file join $dir [file tail $path]]] |
|
set info [lint_detail $path $relpath] |
|
set id [dict get $info id] |
|
if {$id eq ""} continue |
|
dict set seen $id 1 |
|
set achieved [string match "achieved *" [dict get $info status]] |
|
if {$achieved_expected && !$achieved && [dict get $info status] ne ""} { |
|
flag $relpath "archived detail file Status is '[dict get $info status]' - archive/ holds achieved goals only" |
|
} elseif {!$achieved_expected && $achieved} { |
|
flag $relpath "achieved goal still in goals/ - the achieved flip includes archiving (move to goals/archive/, record in GOALS-archive.md)" |
|
} |
|
if {![dict exists $index $id]} { |
|
flag $relpath "orphan detail file - no G-$id entry in $indexname" |
|
continue |
|
} |
|
set entry [dict get $index $id] |
|
if {[dict get $info status] ne "" && [dict get $info status] ne [dict get $entry status]} { |
|
flag $relpath "Status mirror '[dict get $info status]' disagrees with $indexname '\[[dict get $entry status]\]' (index wins)" |
|
} |
|
if {[dict get $info scope] ne "" && [dict get $entry scope] ne "" \ |
|
&& [dict get $info scope] ne [dict get $entry scope]} { |
|
flag $relpath "Scope mirror disagrees with $indexname (index wins)" |
|
} |
|
} |
|
foreach id [dict keys $index] { |
|
if {![dict exists $seen $id]} { |
|
flag $indexname "G-$id has no detail file in $dir/" |
|
} |
|
} |
|
} |
|
|
|
# --- informational: candidate stale references to archived goals --------------- |
|
# An active detail file mentioning an archived goal with no achieved/archive/landed |
|
# marker on ANY of the mentioning lines has likely missed the archive-time reference |
|
# sweep (goals/AGENTS.md Archive rules) - a reader cannot tell the referenced goal is |
|
# done. One marked mention per file suffices; pure-history mentions on other lines |
|
# are fine. Heuristic - reported as warnings, never fatal. |
|
set warnings {} |
|
foreach path [lsort [glob -nocomplain -directory [file join $root goals] G-*.md]] { |
|
set relpath goals/[file tail $path] |
|
set mentioned [dict create] ;# id -> 1 when any mentioning line carries a marker |
|
set lines [read_lines $path] |
|
set n [llength $lines] |
|
for {set i 0} {$i < $n} {incr i} { |
|
set line [lindex $lines $i] |
|
# marker may sit on the mention line or a wrapped neighbour |
|
set window "$line [lindex $lines [expr {$i-1}]] [lindex $lines [expr {$i+1}]]" |
|
set marked [regexp -nocase {achieved|archive|landed} $window] |
|
foreach id [regexp -all -inline {G-\d+} $line] { |
|
set num [string range $id 2 end] |
|
if {![dict exists $archive_index $num]} continue |
|
if {$marked || ![dict exists $mentioned $num]} { |
|
dict set mentioned $num $marked |
|
} |
|
} |
|
} |
|
dict for {num marked} $mentioned { |
|
if {!$marked} { |
|
lappend warnings "$relpath: references archived goal G-$num with no achieved/archive marker anywhere - reference sweep candidate (goals/AGENTS.md Archive rules)" |
|
} |
|
} |
|
} |
|
|
|
if {[llength $warnings]} { |
|
puts stderr "goals_lint: [llength $warnings] informational warning(s):" |
|
puts stderr [join $warnings \n] |
|
} |
|
if {[llength $findings]} { |
|
puts [join $findings \n] |
|
puts stderr "goals_lint: [llength $findings] finding(s)" |
|
exit 1 |
|
} |
|
puts "goals_lint: clean ([dict size $active_index] active-index goals, [dict size $archive_index] archived)" |
|
exit 0
|
|
|