#!/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- ' 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