#!/usr/bin/env tclsh # architecture_lint.tcl - lint the root ARCHITECTURE.md descriptive map # # Validates the front-matter contract declared in ARCHITECTURE.md: # - every backtick-quoted repo-relative path reference exists # (anchored tokens only - see $path_anchors / $root_files below; # tokens with glob/placeholder characters are patterns, not paths) # - every G- reference resolves to a goal in GOALS.md or # GOALS-archive.md # - size budget: the file stays a cheap full read (<= $max_lines lines) # - line 1 is a '# ' title # # Plain tclsh (8.6+ or 9), no package dependencies - runnable from any checkout: # tclsh scriptlib/developer/architecture_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]]]]]}] set archfile [file join $root ARCHITECTURE.md] if {![file isfile $archfile]} { puts stderr "architecture_lint: '$root' does not look like the punkshell repo root (need ARCHITECTURE.md)" exit 2 } set findings {} proc flag {path msg} { global findings lappend findings "$path: $msg" } proc read_lines {path} { set f [open $path r] fconfigure $f -encoding utf-8 set lines [split [read $f] \n] close $f return $lines } set max_lines 400 set path_anchors {src/ bin/ goals/ scriptlib/ .fossil-settings/} set root_files {AGENTS.md ARCHITECTURE.md GOALS.md GOALS-archive.md CHANGELOG.md README.md LICENSE.txt punkproject.toml CLAUDE.md tclint.toml} # --- collect goal ids from both indexes -------------------------------------- set goalids [dict create] foreach indexname {GOALS.md GOALS-archive.md} { set indexpath [file join $root $indexname] if {![file isfile $indexpath]} { flag $indexname "goal index missing - cannot resolve G- references" continue } foreach line [read_lines $indexpath] { if {[regexp {^### G-(\d+) \[} $line -> id]} { dict set goalids $id 1 } } } # --- classify one backtick token ---------------------------------------------- # returns: skip | file | dir proc classify_token {tok} { global path_anchors root_files # patterns and placeholders are not literal paths if {[regexp {[*<>$%\\ ()`;:'",]} $tok]} {return skip} if {[string index $tok 0] eq "-"} {return skip} set anchored 0 foreach anchor $path_anchors { if {[string match "$anchor*" $tok]} { set anchored 1 break } } if {!$anchored && $tok ni $root_files} {return skip} if {[string index $tok end] eq "/"} {return dir} return file } # --- run ---------------------------------------------------------------------- set lines [read_lines $archfile] set nlines [llength $lines] if {$nlines > $max_lines} { flag ARCHITECTURE.md "$nlines lines exceeds the $max_lines-line budget - the file must stay a cheap full read; move detail into owning docs and point at it" } if {![regexp {^# } [lindex $lines 0]]} { flag ARCHITECTURE.md "line 1 is not a '# ' title" } # path references (unique; first-mention line reported) set seen_paths [dict create] # goal references (unique; first-mention line reported) set seen_goals [dict create] set lineno 0 foreach line $lines { incr lineno foreach {- tok} [regexp -all -inline {`([^`\n]+)`} $line] { set kind [classify_token $tok] if {$kind eq "skip"} continue if {![dict exists $seen_paths $tok]} { dict set seen_paths $tok [list $kind $lineno] } } foreach gid [regexp -all -inline {G-\d+} $line] { if {![dict exists $seen_goals $gid]} { dict set seen_goals $gid $lineno } } } dict for {tok info} $seen_paths { lassign $info kind lineno if {$kind eq "dir"} { set tok [string range $tok 0 end-1] } set full [file join $root $tok] if {$kind eq "dir"} { if {![file isdirectory $full]} { flag ARCHITECTURE.md "line $lineno: referenced directory '$tok/' does not exist" } } else { if {![file exists $full]} { flag ARCHITECTURE.md "line $lineno: referenced path '$tok' does not exist" } } } dict for {gid lineno} $seen_goals { set num [string range $gid 2 end] if {![dict exists $goalids $num]} { flag ARCHITECTURE.md "line $lineno: $gid does not resolve to a goal in GOALS.md or GOALS-archive.md" } } if {[llength $findings]} { puts [join $findings \n] puts stderr "architecture_lint: [llength $findings] finding(s)" exit 1 } puts "architecture_lint: clean ([dict size $seen_paths] path refs, [dict size $seen_goals] goal refs, $nlines lines)" exit 0