@theglitchking/babel-fish 2.0.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/project-map/generate.py +206 -21
- package/.claude/project-map/grader.py +77 -1
- package/.claude/project-map/mine-sessions.py +141 -23
- package/.claude/project-map/test_generate.py +203 -0
- package/.claude/project-map/test_grader.py +164 -0
- package/.claude/project-map/test_mine_sessions.py +206 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +197 -0
- package/README.md +9 -2
- package/checksums.json +2 -2
- package/hooks/session-start.js +34 -1
- package/package.json +13 -4
- package/.claude/project-map/PROJECT_MAP.md +0 -61
- package/.claude/project-map/__pycache__/generate.cpython-312.pyc +0 -0
- package/.claude/project-map/checksums.json +0 -6
- package/.claude/project-map/learned-vocabulary.json +0 -1
- package/.claude/project-map/reports/install-report.md +0 -54
- package/.claude/project-map/reports/iteration-01-report.md +0 -54
- package/.claude/project-map/reports/iteration-01-score.json +0 -7
- package/.claude/project-map/sections/01-vocabulary.md +0 -8
- package/.claude/project-map/sections/02-service-topology.md +0 -6
- package/.claude/project-map/sections/03-environment.md +0 -6
- package/.claude/project-map/sections/04-api-routes.md +0 -6
- package/.claude/project-map/sections/05-data-models.md +0 -4
- package/.claude/project-map/sections/06-schemas.md +0 -4
- package/.claude/project-map/sections/07-services.md +0 -6
- package/.claude/project-map/sections/08-background-jobs.md +0 -5
- package/.claude/project-map/sections/09-frontend-features.md +0 -4
- package/.claude/project-map/sections/10-tools-commands.md +0 -8
- package/.claude/project-map/sections/11-migrations.md +0 -4
- package/.claude/project-map/sections/12-import-chains.md +0 -7
- package/.claude/project-map/sections/13-frontend-backend-map.md +0 -8
- package/.claude/project-map/sections/14-reverse-proxy.md +0 -4
- package/.claude/project-map/sections/15-auth-config.md +0 -6
- package/.claude/project-map/sections/16-infra-profile.md +0 -13
- package/.claude/project-map/sections/17-learned-vocabulary.md +0 -7
- package/.claude/project-map/sections/18-dead-code.md +0 -9
- package/.claude/project-map/sections/19-doc-pointers.md +0 -5
- package/.claude/project-map/stack.json +0 -12
- package/.claude/rules/operational-runbook.md +0 -40
- package/.claude/rules/project-vocabulary.md +0 -25
- package/.claude/settings.json +0 -6
- package/.claude/settings.local.json +0 -6
- package/.claude/skills/babel-fish-developer-skill/SKILL.md +0 -56
|
@@ -13,6 +13,7 @@ import ast
|
|
|
13
13
|
import argparse
|
|
14
14
|
import hashlib
|
|
15
15
|
import json
|
|
16
|
+
from fnmatch import fnmatch
|
|
16
17
|
import re
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
@@ -53,13 +54,41 @@ def redact_secrets(text: str) -> str:
|
|
|
53
54
|
# ── Checksum logic ───────────────────────────────────────────────────────────
|
|
54
55
|
WATCHED_EXTENSIONS = {
|
|
55
56
|
'.py', '.ts', '.tsx', '.js', '.jsx', '.go', '.java', '.kt',
|
|
56
|
-
'.yaml', '.yml', '.toml', '.json', '.prisma', '.sql', '.env',
|
|
57
|
+
'.yaml', '.yml', '.toml', '.json', '.prisma', '.sql', '.env', '.sh',
|
|
57
58
|
}
|
|
58
59
|
WATCHED_NAMES = {
|
|
59
60
|
'docker-compose.yml', 'docker-compose.yaml', 'docker-compose.dev.yml',
|
|
60
61
|
'package.json', 'requirements.txt', 'pyproject.toml', 'go.mod',
|
|
61
62
|
'Cargo.toml', 'pom.xml', 'Gemfile', 'Makefile',
|
|
62
63
|
}
|
|
64
|
+
# Skill/command manifests — matched by path shape, not by extension, so
|
|
65
|
+
# documentation .md files stay out of the watch set. fnmatch runs against the
|
|
66
|
+
# repo-relative posix path, which anchors the glob at the root:
|
|
67
|
+
# ".documentation/reference/commands/cli.md" does not match "commands/*.md".
|
|
68
|
+
WATCHED_GLOBS = (
|
|
69
|
+
'skills/*/SKILL.md',
|
|
70
|
+
'commands/*.md',
|
|
71
|
+
# Dead until '.claude' leaves IGNORE_DIRS; kept as the anchor for when it does.
|
|
72
|
+
'.claude/skills/*/SKILL.md',
|
|
73
|
+
'.claude/commands/*.md',
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Directories build_doc_pointers_section() walks. Watched by PATH ONLY (no
|
|
77
|
+
# mtime) so adding or removing a doc refreshes section 19 while editing one
|
|
78
|
+
# does not churn the whole map.
|
|
79
|
+
DOC_DIRS = ['docs', 'doc', 'documentation', 'wiki', '.docs', '.documentation']
|
|
80
|
+
DOC_EXTS = {'.md', '.rst', '.txt', '.adoc'}
|
|
81
|
+
# Auto-generated navigation, not documentation. A hit-em-with-the-docs tree
|
|
82
|
+
# carries one INDEX.md + REGISTRY.md per domain (32 files for 15 domains), which
|
|
83
|
+
# would otherwise crowd every real doc out of section 19's 30-entry cap.
|
|
84
|
+
DOC_SKIP_NAMES = {'INDEX.md', 'REGISTRY.md'}
|
|
85
|
+
# hewtd excludes archive/ from all of its own scans; deprecated docs are not
|
|
86
|
+
# pointers worth handing an agent. 'reports' holds generated, timestamped audit
|
|
87
|
+
# output — listing it is noise, and because every run writes a NEW filename it
|
|
88
|
+
# would change the doc path set and force a full regeneration each time, which
|
|
89
|
+
# is the churn the path-only doc hash exists to prevent.
|
|
90
|
+
DOC_SKIP_DIRS = {'archive', 'reports'}
|
|
91
|
+
|
|
63
92
|
IGNORE_DIRS = {
|
|
64
93
|
'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env',
|
|
65
94
|
'dist', 'build', '.next', '.nuxt', 'target', 'vendor', '.cache',
|
|
@@ -71,11 +100,34 @@ def collect_watched_files() -> list[Path]:
|
|
|
71
100
|
for path in sorted(PROJECT_ROOT.rglob('*')):
|
|
72
101
|
if any(p in IGNORE_DIRS for p in path.parts):
|
|
73
102
|
continue
|
|
74
|
-
if path.is_file()
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
continue
|
|
105
|
+
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
106
|
+
if (path.suffix in WATCHED_EXTENSIONS
|
|
107
|
+
or path.name in WATCHED_NAMES
|
|
108
|
+
or any(fnmatch(rel, g) for g in WATCHED_GLOBS)):
|
|
75
109
|
result.append(path)
|
|
76
110
|
return result
|
|
77
111
|
|
|
78
|
-
|
|
112
|
+
|
|
113
|
+
def collect_doc_files() -> list[Path]:
|
|
114
|
+
"""Docs section 19 points at. Hashed by path only — see compute_checksum."""
|
|
115
|
+
docs = []
|
|
116
|
+
for doc_dir in DOC_DIRS:
|
|
117
|
+
d = PROJECT_ROOT / doc_dir
|
|
118
|
+
if d.is_dir():
|
|
119
|
+
docs.extend(
|
|
120
|
+
f for f in d.rglob('*')
|
|
121
|
+
if f.is_file()
|
|
122
|
+
and f.suffix in DOC_EXTS
|
|
123
|
+
and f.name not in DOC_SKIP_NAMES
|
|
124
|
+
and not (DOC_SKIP_DIRS & set(f.relative_to(PROJECT_ROOT).parts))
|
|
125
|
+
)
|
|
126
|
+
# Root-level docs, matching build_doc_pointers_section()'s own glob.
|
|
127
|
+
docs.extend(f for f in PROJECT_ROOT.glob('*.md') if f.is_file())
|
|
128
|
+
return sorted(set(docs))
|
|
129
|
+
|
|
130
|
+
def compute_checksum(files: list[Path], doc_files: list[Path] | None = None) -> str:
|
|
79
131
|
h = hashlib.sha256()
|
|
80
132
|
for f in files:
|
|
81
133
|
h.update(str(f).encode())
|
|
@@ -83,6 +135,11 @@ def compute_checksum(files: list[Path]) -> str:
|
|
|
83
135
|
h.update(str(f.stat().st_mtime_ns).encode())
|
|
84
136
|
except OSError:
|
|
85
137
|
pass
|
|
138
|
+
# ponytail: path only, no mtime — a new/renamed/deleted doc regenerates the
|
|
139
|
+
# section 19 pointer list, an edited one doesn't. Hashing doc mtimes would
|
|
140
|
+
# force a full regeneration on every prose edit.
|
|
141
|
+
for f in doc_files or []:
|
|
142
|
+
h.update(str(f).encode())
|
|
86
143
|
return h.hexdigest()
|
|
87
144
|
|
|
88
145
|
def load_checksums() -> dict:
|
|
@@ -710,9 +767,20 @@ class VocabularyBuilder:
|
|
|
710
767
|
schemas: list[dict],
|
|
711
768
|
features: list[dict],
|
|
712
769
|
stack: dict,
|
|
770
|
+
skills: list[dict] | None = None,
|
|
713
771
|
) -> list[dict]:
|
|
714
772
|
vocab: dict[str, dict] = {}
|
|
715
773
|
|
|
774
|
+
# From skills and slash commands. In a plugin repo this is the only
|
|
775
|
+
# source that fires — there are no routes or models to mine.
|
|
776
|
+
for sk in skills or []:
|
|
777
|
+
note = sk['description'][:120] if sk['description'] else f"{sk['kind']} manifest"
|
|
778
|
+
for alias in self._name_to_aliases(sk['name']):
|
|
779
|
+
self._add(vocab, alias, sk['kind'], sk['file'], note)
|
|
780
|
+
if sk['kind'] == 'command':
|
|
781
|
+
# humans say "/status" as often as "status"
|
|
782
|
+
self._add(vocab, f"/{sk['name']}", 'command', sk['file'], note)
|
|
783
|
+
|
|
716
784
|
# From features
|
|
717
785
|
for feat in features:
|
|
718
786
|
name = feat['name']
|
|
@@ -781,6 +849,98 @@ class VocabularyBuilder:
|
|
|
781
849
|
return {}
|
|
782
850
|
|
|
783
851
|
|
|
852
|
+
# ── Skill / Command Manifest Parser ───────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
class SkillParser:
|
|
855
|
+
"""Claude skill and slash-command manifests.
|
|
856
|
+
|
|
857
|
+
In a plugin/skill repo these ARE the source: there are no routes or models
|
|
858
|
+
to extract, but a skill's frontmatter name + description is literally an
|
|
859
|
+
alias -> location pair, which is what section 01 wants.
|
|
860
|
+
|
|
861
|
+
The .claude/* patterns are globbed directly, the same way ToolsScanner
|
|
862
|
+
reaches .claude/skills, so a consuming project's installed skills are
|
|
863
|
+
picked up even though '.claude' is in IGNORE_DIRS. Those files are parsed
|
|
864
|
+
but not watched, so they refresh on the next regeneration rather than
|
|
865
|
+
immediately — see WATCHED_GLOBS.
|
|
866
|
+
"""
|
|
867
|
+
|
|
868
|
+
PATTERNS = (
|
|
869
|
+
('skills/*/SKILL.md', 'skill'),
|
|
870
|
+
('.claude/skills/*/SKILL.md', 'skill'),
|
|
871
|
+
('commands/*.md', 'command'),
|
|
872
|
+
('.claude/commands/*.md', 'command'),
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def parse(self) -> list[dict]:
|
|
876
|
+
found: dict[str, dict] = {}
|
|
877
|
+
for pattern, kind in self.PATTERNS:
|
|
878
|
+
for f in sorted(PROJECT_ROOT.glob(pattern)):
|
|
879
|
+
meta = self._frontmatter(f)
|
|
880
|
+
if meta is None:
|
|
881
|
+
continue
|
|
882
|
+
# commands carry no 'name:' — the filename is the command.
|
|
883
|
+
name = str(meta.get('name') or '').strip()
|
|
884
|
+
if not name:
|
|
885
|
+
name = f.parent.name if f.name == 'SKILL.md' else f.stem
|
|
886
|
+
desc = ' '.join(str(meta.get('description') or '').split())
|
|
887
|
+
rel = str(f.relative_to(PROJECT_ROOT))
|
|
888
|
+
found.setdefault(rel, {
|
|
889
|
+
'name': name,
|
|
890
|
+
'kind': kind,
|
|
891
|
+
'file': rel,
|
|
892
|
+
'description': desc,
|
|
893
|
+
})
|
|
894
|
+
return list(found.values())
|
|
895
|
+
|
|
896
|
+
def _frontmatter(self, f: Path) -> dict | None:
|
|
897
|
+
"""Leading --- ... --- YAML block, or None when absent/unparseable."""
|
|
898
|
+
try:
|
|
899
|
+
text = f.read_text(encoding='utf-8', errors='replace')
|
|
900
|
+
except OSError:
|
|
901
|
+
return None
|
|
902
|
+
if not text.startswith('---'):
|
|
903
|
+
return None
|
|
904
|
+
end = text.find('\n---', 3)
|
|
905
|
+
if end == -1:
|
|
906
|
+
return None
|
|
907
|
+
block = text[3:end]
|
|
908
|
+
if HAS_YAML:
|
|
909
|
+
try:
|
|
910
|
+
data = yaml.safe_load(block)
|
|
911
|
+
if isinstance(data, dict):
|
|
912
|
+
return data
|
|
913
|
+
except Exception:
|
|
914
|
+
pass
|
|
915
|
+
return self._frontmatter_regex(block)
|
|
916
|
+
|
|
917
|
+
def _frontmatter_regex(self, block: str) -> dict:
|
|
918
|
+
"""pyyaml-free fallback. Handles 'key: value' and 'key: |' blocks —
|
|
919
|
+
enough for name/description, which is all this parser reads."""
|
|
920
|
+
out: dict[str, str] = {}
|
|
921
|
+
lines = block.splitlines()
|
|
922
|
+
i = 0
|
|
923
|
+
while i < len(lines):
|
|
924
|
+
m = re.match(r'^([A-Za-z_][\w-]*):\s*(.*)$', lines[i])
|
|
925
|
+
if not m:
|
|
926
|
+
i += 1
|
|
927
|
+
continue
|
|
928
|
+
key, val = m.group(1), m.group(2).strip()
|
|
929
|
+
if val in ('|', '>', '|-', '>-', ''):
|
|
930
|
+
# block scalar: consume the indented run beneath it
|
|
931
|
+
body, i = [], i + 1
|
|
932
|
+
while i < len(lines) and (not lines[i].strip() or lines[i][:1] in (' ', '\t')):
|
|
933
|
+
body.append(lines[i].strip())
|
|
934
|
+
i += 1
|
|
935
|
+
joined = ' '.join(x for x in body if x)
|
|
936
|
+
if joined:
|
|
937
|
+
out[key] = joined
|
|
938
|
+
continue
|
|
939
|
+
out[key] = val.strip('"\'')
|
|
940
|
+
i += 1
|
|
941
|
+
return out
|
|
942
|
+
|
|
943
|
+
|
|
784
944
|
# ── Tools & Commands Scanner ──────────────────────────────────────────────────
|
|
785
945
|
|
|
786
946
|
class ToolsScanner:
|
|
@@ -827,6 +987,22 @@ class ToolsScanner:
|
|
|
827
987
|
if (skill_dir / 'SKILL.md').exists():
|
|
828
988
|
tools.append({'name': f'/{skill_dir.name}', 'command': f'/{skill_dir.name}', 'description': 'Claude skill', 'source': 'skills'})
|
|
829
989
|
|
|
990
|
+
# Slash commands — commands/*.md and .claude/commands/*.md
|
|
991
|
+
seen = {t['name'] for t in tools}
|
|
992
|
+
for manifest in SkillParser().parse():
|
|
993
|
+
if manifest['kind'] != 'command':
|
|
994
|
+
continue
|
|
995
|
+
name = f"/{manifest['name']}"
|
|
996
|
+
if name in seen:
|
|
997
|
+
continue
|
|
998
|
+
seen.add(name)
|
|
999
|
+
tools.append({
|
|
1000
|
+
'name': name,
|
|
1001
|
+
'command': name,
|
|
1002
|
+
'description': manifest['description'] or 'slash command',
|
|
1003
|
+
'source': 'commands',
|
|
1004
|
+
})
|
|
1005
|
+
|
|
830
1006
|
return tools
|
|
831
1007
|
|
|
832
1008
|
|
|
@@ -958,7 +1134,11 @@ def build_vocabulary_section(vocab: list[dict]) -> str:
|
|
|
958
1134
|
notes = v.get('notes', '').replace('|', '\\|')
|
|
959
1135
|
lines.append(f"| {alias} | {type_} | {location} | {notes} |")
|
|
960
1136
|
if not vocab:
|
|
961
|
-
|
|
1137
|
+
# Prose, not a table row: a placeholder row parses as a valid entry with
|
|
1138
|
+
# an empty (=neutral) location, which scored an empty vocabulary at 100%
|
|
1139
|
+
# and left grader.py's greenfield branch permanently unreachable.
|
|
1140
|
+
lines.append("")
|
|
1141
|
+
lines.append("_No vocabulary generated yet — add source code to populate._")
|
|
962
1142
|
return '\n'.join(lines) + '\n'
|
|
963
1143
|
|
|
964
1144
|
|
|
@@ -1260,20 +1440,9 @@ def build_dead_code_section(candidates: list[dict]) -> str:
|
|
|
1260
1440
|
|
|
1261
1441
|
def build_doc_pointers_section() -> str:
|
|
1262
1442
|
lines = ["# Section 19 — Documentation Pointers\n\n"]
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
for doc_dir in doc_dirs:
|
|
1268
|
-
d = PROJECT_ROOT / doc_dir
|
|
1269
|
-
if d.is_dir():
|
|
1270
|
-
for f in sorted(d.rglob('*')):
|
|
1271
|
-
if f.is_file() and f.suffix in doc_exts:
|
|
1272
|
-
docs.append(str(f.relative_to(PROJECT_ROOT)))
|
|
1273
|
-
|
|
1274
|
-
# Root-level docs
|
|
1275
|
-
for f in PROJECT_ROOT.glob('*.md'):
|
|
1276
|
-
docs.append(str(f.relative_to(PROJECT_ROOT)))
|
|
1443
|
+
# Same walk the checksum watches (DOC_DIRS/DOC_EXTS), so the pointer list and
|
|
1444
|
+
# the watch set cannot drift apart.
|
|
1445
|
+
docs = [str(f.relative_to(PROJECT_ROOT)) for f in collect_doc_files()]
|
|
1277
1446
|
|
|
1278
1447
|
if not docs:
|
|
1279
1448
|
lines.append("_No documentation files found._\n")
|
|
@@ -1297,6 +1466,7 @@ def build_project_map(
|
|
|
1297
1466
|
services: list[dict],
|
|
1298
1467
|
vocab: list[dict],
|
|
1299
1468
|
section_files: list[tuple[str, Path]],
|
|
1469
|
+
scanned: dict[str, int] | None = None,
|
|
1300
1470
|
) -> str:
|
|
1301
1471
|
now = datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
1302
1472
|
name = stack.get('name', PROJECT_ROOT.name)
|
|
@@ -1315,6 +1485,16 @@ def build_project_map(
|
|
|
1315
1485
|
f"| Docker Services | {len(services)} |",
|
|
1316
1486
|
f"| Vocabulary Entries | {len(vocab)} |",
|
|
1317
1487
|
f"| Stack | {stack.get('language','?')} / {stack.get('framework','?')} |",
|
|
1488
|
+
"",
|
|
1489
|
+
# Inputs beside outputs. "0 routes" alone cannot be judged: from 47
|
|
1490
|
+
# Python files it means an extractor is broken, from 0 it is correct.
|
|
1491
|
+
# grader.py reads these to tell those two cases apart.
|
|
1492
|
+
"### Source files scanned\n",
|
|
1493
|
+
"| Language | Files |",
|
|
1494
|
+
"|----------|-------|",
|
|
1495
|
+
] + [
|
|
1496
|
+
f"| {lang} | {count} |" for lang, count in sorted((scanned or {}).items())
|
|
1497
|
+
] + [
|
|
1318
1498
|
"",
|
|
1319
1499
|
"## Section Index\n",
|
|
1320
1500
|
"| # | Section | Size | When to Read |",
|
|
@@ -1392,7 +1572,7 @@ def main() -> None:
|
|
|
1392
1572
|
|
|
1393
1573
|
# 1. Checksum check
|
|
1394
1574
|
watched = collect_watched_files()
|
|
1395
|
-
checksum = compute_checksum(watched)
|
|
1575
|
+
checksum = compute_checksum(watched, collect_doc_files())
|
|
1396
1576
|
|
|
1397
1577
|
if not args.force and is_unchanged(checksum):
|
|
1398
1578
|
print("[generate] ✓ No changes detected — skipping regeneration (use --force to override)")
|
|
@@ -1434,12 +1614,13 @@ def main() -> None:
|
|
|
1434
1614
|
env_entries = EnvParser().parse()
|
|
1435
1615
|
migrations = MigrationParser().parse()
|
|
1436
1616
|
features = FrontendScanner().scan()
|
|
1617
|
+
skills = SkillParser().parse()
|
|
1437
1618
|
tools = ToolsScanner().scan()
|
|
1438
1619
|
auth_info = AuthScanner().scan()
|
|
1439
1620
|
proxy_rules = ReverseProxyScanner().scan()
|
|
1440
1621
|
|
|
1441
1622
|
print("[generate] Building vocabulary...")
|
|
1442
|
-
vocab = VocabularyBuilder().build(routes, models, schemas, features, stack)
|
|
1623
|
+
vocab = VocabularyBuilder().build(routes, models, schemas, features, stack, skills)
|
|
1443
1624
|
|
|
1444
1625
|
print("[generate] Tracing import chains...")
|
|
1445
1626
|
chains = ImportChainTracer().trace(routes)
|
|
@@ -1473,7 +1654,11 @@ def main() -> None:
|
|
|
1473
1654
|
|
|
1474
1655
|
# 6. Write PROJECT_MAP.md
|
|
1475
1656
|
print("[generate] Writing PROJECT_MAP.md...")
|
|
1476
|
-
project_map = build_project_map(
|
|
1657
|
+
project_map = build_project_map(
|
|
1658
|
+
stack, routes, models, schemas, features, migrations, services, vocab, section_files,
|
|
1659
|
+
scanned={'python': len(py_files), 'typescript/javascript': len(ts_files),
|
|
1660
|
+
'go': len(go_files)},
|
|
1661
|
+
)
|
|
1477
1662
|
(MAP_DIR / 'PROJECT_MAP.md').write_text(project_map, encoding='utf-8')
|
|
1478
1663
|
|
|
1479
1664
|
# 7. Update checksums
|
|
@@ -173,7 +173,7 @@ def grade_import_chains() -> GradeResult:
|
|
|
173
173
|
|
|
174
174
|
content = chains_file.read_text(encoding='utf-8', errors='replace')
|
|
175
175
|
|
|
176
|
-
if '_No import chains traced' in content
|
|
176
|
+
if '_No import chains traced' in content:
|
|
177
177
|
# For greenfield or non-Python: acceptable
|
|
178
178
|
details = "No chains traced (greenfield or non-Python stack — ok)"
|
|
179
179
|
return GradeResult('import_chain_validity', WEIGHTS['import_chain_validity'],
|
|
@@ -497,6 +497,17 @@ def print_terminal_summary(results: list[GradeResult], total_score: float, passe
|
|
|
497
497
|
print(f" {'TOTAL SCORE':<30} {score_color}{total_score:5.1f}%{RESET} → {verdict}")
|
|
498
498
|
print(f"{CYAN}{'─' * 55}{RESET}\n")
|
|
499
499
|
|
|
500
|
+
pop, total_sections = populated_sections()
|
|
501
|
+
if total_sections:
|
|
502
|
+
print(f" {'Sections populated':<30} {pop}/{total_sections}"
|
|
503
|
+
f" (diagnostic — not scored)")
|
|
504
|
+
print()
|
|
505
|
+
|
|
506
|
+
for w in usefulness_warnings():
|
|
507
|
+
print(f"{YELLOW} ⚠ {w}{RESET}")
|
|
508
|
+
if usefulness_warnings():
|
|
509
|
+
print()
|
|
510
|
+
|
|
500
511
|
all_issues = [(r.display_name, i) for r in results for i in r.issues]
|
|
501
512
|
if all_issues:
|
|
502
513
|
print(f"{YELLOW} Issues:{RESET}")
|
|
@@ -507,6 +518,71 @@ def print_terminal_summary(results: list[GradeResult], total_score: float, passe
|
|
|
507
518
|
print()
|
|
508
519
|
|
|
509
520
|
|
|
521
|
+
# ── Usefulness diagnostics (reported, never scored) ──────────────────────────
|
|
522
|
+
# The seven graded categories all measure FORM, and generate.py always emits
|
|
523
|
+
# well-formed output — so an empty map and a populated one score identically
|
|
524
|
+
# (both 97.0% before this was added). The information that separates them is
|
|
525
|
+
# not in the map, so it is surfaced as a warning rather than folded into the
|
|
526
|
+
# score: making it scored would fail legitimately sparse repos, which is a
|
|
527
|
+
# worse failure than the one it fixes.
|
|
528
|
+
|
|
529
|
+
def _stat(content: str, label: str) -> int | None:
|
|
530
|
+
m = re.search(rf'^\|\s*{re.escape(label)}\s*\|\s*(\d+)\s*\|', content, re.MULTILINE)
|
|
531
|
+
return int(m.group(1)) if m else None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def usefulness_warnings() -> list[str]:
|
|
535
|
+
map_file = MAP_DIR / 'PROJECT_MAP.md'
|
|
536
|
+
if not map_file.exists():
|
|
537
|
+
return []
|
|
538
|
+
content = map_file.read_text(encoding='utf-8', errors='replace')
|
|
539
|
+
warnings = []
|
|
540
|
+
|
|
541
|
+
# Vocabulary is the product. Zero entries means the map gave the user
|
|
542
|
+
# nothing, whatever the seven form categories say. This is the signal that
|
|
543
|
+
# would have surfaced issue #6 at install time.
|
|
544
|
+
vocab = _stat(content, 'Vocabulary Entries')
|
|
545
|
+
if vocab == 0:
|
|
546
|
+
warnings.append(
|
|
547
|
+
"Vocabulary is empty — the map's primary output produced nothing. "
|
|
548
|
+
"Expected on a repo with no source code; otherwise an extractor "
|
|
549
|
+
"does not understand this project's shape."
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Source files present but nothing extracted from them: a parser that does
|
|
553
|
+
# not fit the stack, rather than a repo with nothing to find.
|
|
554
|
+
scanned = 0
|
|
555
|
+
block = re.search(r'### Source files scanned(.*?)(?:\n## |\Z)', content, re.DOTALL)
|
|
556
|
+
if block:
|
|
557
|
+
scanned = sum(int(n) for n in re.findall(r'^\|[^|]+\|\s*(\d+)\s*\|',
|
|
558
|
+
block.group(1), re.MULTILINE))
|
|
559
|
+
extracted = sum(v or 0 for v in (
|
|
560
|
+
_stat(content, 'API Routes'), _stat(content, 'Data Models'),
|
|
561
|
+
_stat(content, 'Schemas/DTOs'), _stat(content, 'Frontend Features')))
|
|
562
|
+
if scanned >= 10 and extracted == 0:
|
|
563
|
+
warnings.append(
|
|
564
|
+
f"Scanned {scanned} source file(s) but extracted no routes, models, "
|
|
565
|
+
f"schemas or features — likely an unsupported framework."
|
|
566
|
+
)
|
|
567
|
+
return warnings
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def populated_sections() -> tuple[int, int]:
|
|
571
|
+
"""Sections carrying real content. Diagnostic only — 19 is aspirational,
|
|
572
|
+
not a target: a plugin repo can never populate routes or migrations."""
|
|
573
|
+
files = sorted(SECTIONS_DIR.glob('*.md')) if SECTIONS_DIR.exists() else []
|
|
574
|
+
pop = 0
|
|
575
|
+
for f in files:
|
|
576
|
+
body = f.read_text(encoding='utf-8', errors='replace')
|
|
577
|
+
meat = [ln for ln in body.splitlines()
|
|
578
|
+
if ln.strip() and not ln.startswith(('#', '>'))
|
|
579
|
+
and not re.fullmatch(r'\|[\s|:-]*\|', ln.strip())
|
|
580
|
+
and not re.match(r'^_\(?(no|none|not)\b', ln.strip(), re.I)]
|
|
581
|
+
if meat:
|
|
582
|
+
pop += 1
|
|
583
|
+
return pop, len(files)
|
|
584
|
+
|
|
585
|
+
|
|
510
586
|
# ╔══════════════════════════════════════════════════════════════════════════╗
|
|
511
587
|
# ║ MAIN ║
|
|
512
588
|
# ╚══════════════════════════════════════════════════════════════════════════╝
|