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