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.
 
 
 
 
 
 

137 lines
4.7 KiB

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