# Validate a tech-stack-evaluator comparison markdown report: house rules from SKILL.md.
# lint_comparison.sh <comparison.md>
# scripts/lint_comparison.sh ./comparison.md
# This skill has no fixed output filename convention (report_generator.py returns
# markdown as a string or writes wherever the caller names), so the path is a
# required argument; we default to ./comparison.md only as a convenience guess.
# The heavy lifting is a small inline python3 stdlib script (regex + markdown-table
# row parsing) — no external CLI/package required, so this runs in a bare CI shell.
# Exit code is non-zero if any check fails.
COMPARISON_FILE="${1:-./comparison.md}"
if [[ -z "$COMPARISON_FILE" || ! -f "$COMPARISON_FILE" ]]; then
echo "Usage: $0 <comparison.md>" >&2
echo " (no file given and default './comparison.md' was not found)" >&2
warn() { printf '⚠️ %s\n' "$1"; }
error() { printf '❌ %s\n' "$1"; fail=1; }
ok() { printf '✅ %s\n' "$1"; }
echo "== markdown table: consistent columns, no blank cells =="
table_report="$(python3 - "$COMPARISON_FILE" <<'PYEOF'
with open(path, encoding="utf-8") as f:
# A markdown table row: a line starting (after optional leading whitespace) with '|'.
# The separator row (e.g. "|---|---|" or "| :--- | ---: |") marks the header/body split.
def is_table_row(line: str) -> bool:
return s.startswith("|") and s.endswith("|") and len(s) > 1
def is_separator_row(line: str) -> bool:
s = line.strip().strip("|")
cells = [c.strip() for c in s.split("|")]
return len(cells) > 0 and all(re.fullmatch(r":?-+:?", c) for c in cells if c != "")
def split_cells(line: str):
# Strip one leading and one trailing pipe, then split on unescaped pipes.
return [c.strip() for c in re.split(r"(?<!\\)\|", s)]
lopsided = [] # (line_no, expected, actual, row_text)
blank_cells = [] # (line_no, col_index, row_text)
if is_table_row(lines[i]) and i + 1 < n and is_separator_row(lines[i + 1]):
header_cells = split_cells(lines[i])
expected = len(header_cells)
while j < n and is_table_row(lines[j]):
row_cells = split_cells(lines[j])
lopsided.append((j + 1, expected, actual, lines[j].strip()))
for col_idx, cell in enumerate(row_cells):
blank_cells.append((j + 1, col_idx + 1, header_cells[col_idx] if col_idx < len(header_cells) else "?"))
print(f"TABLES_FOUND={tables_found}")
for line_no, expected, actual, text in lopsided:
print(f"LOPSIDED\t{line_no}\t{expected}\t{actual}\t{text}")
for line_no, col_idx, col_name in blank_cells:
print(f"BLANK\t{line_no}\t{col_idx}\t{col_name}")
tables_found=$(printf '%s\n' "$table_report" | grep -m1 '^TABLES_FOUND=' | cut -d= -f2)
tables_found="${tables_found:-0}"
if [[ "$tables_found" -eq 0 ]]; then
error "no markdown table found in $COMPARISON_FILE — the comparison deliverable requires a criteria table"
lopsided_lines=$(printf '%s\n' "$table_report" | grep -c '^LOPSIDED' || true)
blank_lines=$(printf '%s\n' "$table_report" | grep -c '^BLANK' || true)
if [[ "$lopsided_lines" -gt 0 ]]; then
error "$lopsided_lines row(s) have a different cell count than the header (lopsided comparison):"
printf '%s\n' "$table_report" | awk -F'\t' '$1=="LOPSIDED" {printf " line %s: expected %s cells, found %s -> %s\n", $2, $3, $4, $5}'
ok "all data rows match the header's cell count ($tables_found table(s) checked)"
if [[ "$blank_lines" -gt 0 ]]; then
error "$blank_lines truly blank cell(s) found (use '-' per the technical-writing convention, never leave blank):"
printf '%s\n' "$table_report" | awk -F'\t' '$1=="BLANK" {printf " line %s, column %s (%s)\n", $2, $3, $4}'
ok "no blank cells — placeholders use '-' or are filled"
echo "== house rule: recommendation/conclusion section present =="
if grep -qiE '^#{1,6}[[:space:]]*(Recommendation|Conclusion|Verdict)\b' "$COMPARISON_FILE"; then
ok "found a Recommendation/Conclusion/Verdict heading"
error "no heading matching Recommendation/Conclusion/Verdict found — the report must end in a clear call"
echo "== house rule: at least one named tradeoff =="
# Heuristic, text-based check (documented here, not a substitute for human judgment):
# 1. A "Tradeoff"/"Trade-off" heading anywhere in the doc, OR
# 2. Con-signaling prose inside/near the recommendation section, e.g.
# "however,", "the cost is", "trade-off:", "downside:", "at the expense of".
# This will not catch every valid phrasing of a named con — it is a pragmatic
# floor, not a substitute for a human read of the recommendation.
if grep -qiE '^#{1,6}[[:space:]]*Trade-?off' "$COMPARISON_FILE"; then
ok "found a Tradeoff/Trade-off heading"
elif grep -qiE '(however,|the cost is|trade-?off:|downside:|at the expense of)' "$COMPARISON_FILE"; then
ok "found con-signaling prose naming a tradeoff (however/cost is/trade-off:/downside:/at the expense of)"
error "no named tradeoff found — add a Trade-offs heading or a con-signaling phrase (however, the cost is, trade-off:, downside:, at the expense of) near the recommendation"
if [[ "$fail" -eq 0 ]]; then
echo "All checks passed."
echo "One or more checks failed — see ❌ lines above."