@tw93/waza 3.25.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/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +35 -0
- package/rules/anti-patterns.md +38 -0
- package/rules/chinese.md +18 -0
- package/rules/durable-context.md +27 -0
- package/rules/english.md +14 -0
- package/scripts/build_metadata.py +360 -0
- package/scripts/check_routing_drift.py +82 -0
- package/scripts/dispatcher-template.md +43 -0
- package/scripts/dispatcher.md +53 -0
- package/scripts/package-skill.sh +71 -0
- package/scripts/packaging_filter.py +55 -0
- package/scripts/setup-rule.sh +109 -0
- package/scripts/setup-statusline.sh +127 -0
- package/scripts/skill_checks.py +483 -0
- package/scripts/skill_frontmatter.py +110 -0
- package/scripts/statusline.sh +321 -0
- package/scripts/validate_package.py +66 -0
- package/scripts/verify_skills.py +100 -0
- package/skills/RESOLVER.md +91 -0
- package/skills/check/SKILL.md +338 -0
- package/skills/check/agents/reviewer-architecture.md +39 -0
- package/skills/check/agents/reviewer-security.md +39 -0
- package/skills/check/references/persona-catalog.md +56 -0
- package/skills/check/references/project-context.md +107 -0
- package/skills/check/references/public-reply.md +14 -0
- package/skills/check/scripts/audit_signals.py +485 -0
- package/skills/check/scripts/run-tests.sh +19 -0
- package/skills/design/SKILL.md +134 -0
- package/skills/design/references/design-aesthetic-quality.md +67 -0
- package/skills/design/references/design-data-viz.md +34 -0
- package/skills/design/references/design-reference.md +278 -0
- package/skills/design/references/design-tokens.md +53 -0
- package/skills/design/references/design-traps.md +43 -0
- package/skills/health/SKILL.md +231 -0
- package/skills/health/agents/inspector-context.md +119 -0
- package/skills/health/agents/inspector-control.md +84 -0
- package/skills/health/agents/inspector-maintainability.md +55 -0
- package/skills/health/scripts/check-agent-context.sh +5 -0
- package/skills/health/scripts/check-doc-refs.sh +8 -0
- package/skills/health/scripts/check-maintainability.sh +8 -0
- package/skills/health/scripts/check-verifier-output.sh +5 -0
- package/skills/health/scripts/check_agent_context.py +407 -0
- package/skills/health/scripts/check_doc_refs.py +110 -0
- package/skills/health/scripts/check_maintainability.py +629 -0
- package/skills/health/scripts/check_verifier_output.py +116 -0
- package/skills/health/scripts/collect-data.sh +760 -0
- package/skills/hunt/SKILL.md +197 -0
- package/skills/hunt/references/failure-patterns.md +75 -0
- package/skills/hunt/references/ime-unicode.md +58 -0
- package/skills/hunt/references/logging-techniques.md +72 -0
- package/skills/hunt/references/rendering-debug.md +34 -0
- package/skills/learn/SKILL.md +128 -0
- package/skills/read/SKILL.md +108 -0
- package/skills/read/references/read-methods.md +110 -0
- package/skills/read/references/save-paths.md +33 -0
- package/skills/read/scripts/fetch.sh +105 -0
- package/skills/read/scripts/fetch_feishu.py +246 -0
- package/skills/read/scripts/fetch_local.py +218 -0
- package/skills/read/scripts/fetch_weixin.py +107 -0
- package/skills/think/SKILL.md +155 -0
- package/skills/write/SKILL.md +129 -0
- package/skills/write/references/write-en.md +197 -0
- package/skills/write/references/write-zh-bilingual.md +60 -0
- package/skills/write/references/write-zh-prose.md +48 -0
- package/skills/write/references/write-zh-release-notes.md +38 -0
- package/skills/write/references/write-zh.md +645 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AI maintainability audit: project shape, context surface, verification surface,
|
|
3
|
+
decision artifacts, drift markers, hotspot ownership, markdown links.
|
|
4
|
+
|
|
5
|
+
Run as: python3 check_maintainability.py [ROOT] [summary|deep]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EXCLUDED_DIRS = {
|
|
22
|
+
".git",
|
|
23
|
+
".hg",
|
|
24
|
+
".svn",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
".next",
|
|
29
|
+
"__pycache__",
|
|
30
|
+
".turbo",
|
|
31
|
+
"target",
|
|
32
|
+
".venv",
|
|
33
|
+
"venv",
|
|
34
|
+
"vendor",
|
|
35
|
+
"coverage",
|
|
36
|
+
".cache",
|
|
37
|
+
".parcel-cache",
|
|
38
|
+
".pytest_cache",
|
|
39
|
+
".mypy_cache",
|
|
40
|
+
".ruff_cache",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
SOURCE_EXTS = {
|
|
44
|
+
".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html",
|
|
45
|
+
".java", ".js", ".jsx", ".kt", ".lua", ".m", ".mm", ".md", ".mjs",
|
|
46
|
+
".php", ".py", ".rb", ".rs", ".scss", ".sh", ".swift", ".ts", ".tsx",
|
|
47
|
+
".vue", ".yaml", ".yml",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
MARKER_RE = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b", re.IGNORECASE)
|
|
51
|
+
MAKE_RE = re.compile(r"^([A-Za-z0-9_.-]+)\s*:(?![=])")
|
|
52
|
+
MAKE_CMD_RE = re.compile(r"\bmake\s+([A-Za-z0-9_.-]+)\b")
|
|
53
|
+
NPM_CMD_RE = re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+run\s+([A-Za-z0-9:_-]+)\b")
|
|
54
|
+
COMMAND_LINE_RE = re.compile(r"^(?:make|npm|pnpm|yarn|bun)\s+")
|
|
55
|
+
MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
|
56
|
+
URL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
57
|
+
HOTSPOT_WORD_RE = re.compile(
|
|
58
|
+
r"(hotspot|large file|ownership|owner|boundary|module|owned|owns|"
|
|
59
|
+
r"负责|边界|模块|热点|大文件)",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
VERIFICATION_WORD_RE = re.compile(
|
|
63
|
+
r"(verification|verify|test|check|make\s+\w+|pytest|go test|cargo test|"
|
|
64
|
+
r"npm test|npm run|pnpm|yarn|swift test|xcodebuild|验证|测试)",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def rel(path: Path, root: Path) -> str:
|
|
70
|
+
try:
|
|
71
|
+
return path.resolve().relative_to(root).as_posix()
|
|
72
|
+
except ValueError:
|
|
73
|
+
return path.as_posix()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_excluded(path: Path, root: Path) -> bool:
|
|
77
|
+
parts = path.relative_to(root).parts if path.is_absolute() else path.parts
|
|
78
|
+
return any(part in EXCLUDED_DIRS for part in parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_text(path: Path, limit: int | None = None) -> str:
|
|
82
|
+
try:
|
|
83
|
+
data = path.read_text(encoding="utf-8", errors="replace")
|
|
84
|
+
except OSError:
|
|
85
|
+
return ""
|
|
86
|
+
return data[:limit] if limit else data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def iter_files(root: Path) -> list[Path]:
|
|
90
|
+
try:
|
|
91
|
+
proc = subprocess.run(
|
|
92
|
+
["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
93
|
+
text=True,
|
|
94
|
+
stdout=subprocess.PIPE,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
99
|
+
files = []
|
|
100
|
+
for line in proc.stdout.splitlines():
|
|
101
|
+
path = root / line
|
|
102
|
+
if path.is_file() and not is_excluded(path, root):
|
|
103
|
+
files.append(path)
|
|
104
|
+
return files
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
files = []
|
|
109
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
110
|
+
current = Path(dirpath)
|
|
111
|
+
dirnames[:] = [name for name in dirnames if name not in EXCLUDED_DIRS]
|
|
112
|
+
if is_excluded(current, root):
|
|
113
|
+
continue
|
|
114
|
+
for filename in filenames:
|
|
115
|
+
path = current / filename
|
|
116
|
+
if path.is_file() and not is_excluded(path, root):
|
|
117
|
+
files.append(path)
|
|
118
|
+
return files
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def line_count(path: Path) -> int:
|
|
122
|
+
try:
|
|
123
|
+
with path.open("rb") as handle:
|
|
124
|
+
return sum(1 for _ in handle)
|
|
125
|
+
except OSError:
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def print_list(items: list[str], empty: str = "(none)", limit: int | None = None) -> None:
|
|
130
|
+
shown = items if limit is None else items[:limit]
|
|
131
|
+
if not shown:
|
|
132
|
+
print(f" {empty}")
|
|
133
|
+
return
|
|
134
|
+
for item in shown:
|
|
135
|
+
print(f" {item}")
|
|
136
|
+
if limit is not None and len(items) > limit:
|
|
137
|
+
print(f" ... {len(items) - limit} more")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def instruction_paths(root: Path) -> list[Path]:
|
|
141
|
+
candidates = [
|
|
142
|
+
root / "AGENTS.md",
|
|
143
|
+
root / "CLAUDE.md",
|
|
144
|
+
root / ".github" / "copilot-instructions.md",
|
|
145
|
+
root / "GEMINI.md",
|
|
146
|
+
]
|
|
147
|
+
instructions_dir = root / ".github" / "instructions"
|
|
148
|
+
if instructions_dir.is_dir():
|
|
149
|
+
candidates.extend(sorted(instructions_dir.glob("*.md")))
|
|
150
|
+
return [path for path in candidates if path.is_file() and not is_excluded(path, root)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_text_signal(paths: list[Path], patterns: list[str]) -> bool:
|
|
154
|
+
regexes = [re.compile(pattern, re.IGNORECASE) for pattern in patterns]
|
|
155
|
+
for path in paths:
|
|
156
|
+
text = read_text(path, 200_000)
|
|
157
|
+
if any(regex.search(text) for regex in regexes):
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_makefile(root: Path) -> tuple[set[str], list[str]]:
|
|
163
|
+
makefile = root / "Makefile"
|
|
164
|
+
targets: set[str] = set()
|
|
165
|
+
commands: list[str] = []
|
|
166
|
+
if not makefile.is_file():
|
|
167
|
+
return targets, commands
|
|
168
|
+
for line in read_text(makefile).splitlines():
|
|
169
|
+
match = MAKE_RE.match(line)
|
|
170
|
+
if not match:
|
|
171
|
+
continue
|
|
172
|
+
target = match.group(1)
|
|
173
|
+
if target.startswith("."):
|
|
174
|
+
continue
|
|
175
|
+
targets.add(target)
|
|
176
|
+
if re.search(r"(test|check|lint|type|build|package|verify|smoke)", target, re.IGNORECASE):
|
|
177
|
+
commands.append(f"make {target}")
|
|
178
|
+
return targets, commands
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_package_json(root: Path) -> tuple[set[str], list[str]]:
|
|
182
|
+
package = root / "package.json"
|
|
183
|
+
script_names: set[str] = set()
|
|
184
|
+
commands: list[str] = []
|
|
185
|
+
if not package.is_file():
|
|
186
|
+
return script_names, commands
|
|
187
|
+
try:
|
|
188
|
+
data = json.loads(read_text(package))
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
return script_names, commands
|
|
191
|
+
scripts = data.get("scripts", {})
|
|
192
|
+
if not isinstance(scripts, dict):
|
|
193
|
+
return script_names, commands
|
|
194
|
+
for name in sorted(scripts):
|
|
195
|
+
script_names.add(name)
|
|
196
|
+
if re.search(r"(test|check|lint|type|build|verify)", name, re.IGNORECASE):
|
|
197
|
+
commands.append(f"npm run {name}")
|
|
198
|
+
return script_names, commands
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_ci_commands(root: Path) -> list[str]:
|
|
202
|
+
workflows_dir = root / ".github" / "workflows"
|
|
203
|
+
workflows = sorted(workflows_dir.glob("*.yml")) if workflows_dir.is_dir() else []
|
|
204
|
+
workflows += sorted(workflows_dir.glob("*.yaml")) if workflows_dir.is_dir() else []
|
|
205
|
+
commands: list[str] = []
|
|
206
|
+
for workflow in workflows:
|
|
207
|
+
for raw in read_text(workflow).splitlines():
|
|
208
|
+
line = raw.strip()
|
|
209
|
+
if line.startswith("- run:"):
|
|
210
|
+
command = line.split("- run:", 1)[1].strip().strip("'\"")
|
|
211
|
+
elif line.startswith("run:"):
|
|
212
|
+
command = line.split("run:", 1)[1].strip().strip("'\"")
|
|
213
|
+
else:
|
|
214
|
+
continue
|
|
215
|
+
if command and command != "|":
|
|
216
|
+
commands.append(f"{rel(workflow, root)}: {command}")
|
|
217
|
+
return commands
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def scan_markdown_links(files: list[Path], root: Path) -> list[str]:
|
|
221
|
+
missing: list[str] = []
|
|
222
|
+
markdown_files = [path for path in files if path.suffix.lower() == ".md"]
|
|
223
|
+
for path in markdown_files:
|
|
224
|
+
for lineno, line in enumerate(read_text(path).splitlines(), 1):
|
|
225
|
+
for raw in MARKDOWN_LINK_RE.findall(line):
|
|
226
|
+
target = raw.strip().split()[0].strip("<>")
|
|
227
|
+
if not target or target.startswith("#") or URL_RE.match(target):
|
|
228
|
+
continue
|
|
229
|
+
target = urllib.parse.unquote(target.split("#", 1)[0])
|
|
230
|
+
if not target:
|
|
231
|
+
continue
|
|
232
|
+
full = (path.parent / target).resolve()
|
|
233
|
+
if not full.exists():
|
|
234
|
+
missing.append(f"{rel(path, root)}:{lineno} -> {target}")
|
|
235
|
+
return missing
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def verification_surface(
|
|
239
|
+
root: Path, instruction_files: list[Path]
|
|
240
|
+
) -> tuple[list[str], list[str], set[str], set[str]]:
|
|
241
|
+
make_targets, make_commands = parse_makefile(root)
|
|
242
|
+
package_scripts, package_commands = parse_package_json(root)
|
|
243
|
+
commands = make_commands + package_commands + parse_ci_commands(root)
|
|
244
|
+
|
|
245
|
+
if (root / "Cargo.toml").is_file():
|
|
246
|
+
commands.extend(["cargo test", "cargo check"])
|
|
247
|
+
if (root / "go.mod").is_file():
|
|
248
|
+
commands.append("go test ./...")
|
|
249
|
+
if (root / "pyproject.toml").is_file() or (root / "pytest.ini").is_file():
|
|
250
|
+
commands.append("pytest")
|
|
251
|
+
if (root / "pom.xml").is_file():
|
|
252
|
+
commands.append("mvn test")
|
|
253
|
+
if (root / "deno.json").is_file() or (root / "deno.jsonc").is_file():
|
|
254
|
+
commands.append("deno test")
|
|
255
|
+
|
|
256
|
+
missing: list[str] = []
|
|
257
|
+
for path in instruction_files:
|
|
258
|
+
text = read_text(path, 200_000)
|
|
259
|
+
snippets: list[str] = []
|
|
260
|
+
for raw_line in text.splitlines():
|
|
261
|
+
snippets.extend(re.findall(r"`([^`]+)`", raw_line))
|
|
262
|
+
stripped = raw_line.strip().strip("`")
|
|
263
|
+
if COMMAND_LINE_RE.match(stripped):
|
|
264
|
+
snippets.append(stripped)
|
|
265
|
+
for snippet in snippets:
|
|
266
|
+
for target in MAKE_CMD_RE.findall(snippet):
|
|
267
|
+
if target not in make_targets:
|
|
268
|
+
missing.append(f"{rel(path, root)} references missing make target: {target}")
|
|
269
|
+
for script in NPM_CMD_RE.findall(snippet):
|
|
270
|
+
if script not in package_scripts:
|
|
271
|
+
missing.append(f"{rel(path, root)} references missing package script: {script}")
|
|
272
|
+
|
|
273
|
+
unique_commands = list(dict.fromkeys(commands))
|
|
274
|
+
unique_missing = list(dict.fromkeys(missing))
|
|
275
|
+
return unique_commands, unique_missing, make_targets, package_scripts
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def hotspot_ownership_surface(
|
|
279
|
+
records: list[tuple[int, int, Path]],
|
|
280
|
+
instruction_files: list[Path],
|
|
281
|
+
mode: str,
|
|
282
|
+
root: Path,
|
|
283
|
+
) -> tuple[str, list[str], list[str], list[str]]:
|
|
284
|
+
if mode != "deep":
|
|
285
|
+
return "SKIPPED", [], [], []
|
|
286
|
+
if not records:
|
|
287
|
+
return "PASS", [], [], []
|
|
288
|
+
|
|
289
|
+
snippets: list[str] = []
|
|
290
|
+
for path in instruction_files:
|
|
291
|
+
snippets.append(f"\n# {rel(path, root)}\n{read_text(path, 200_000)}")
|
|
292
|
+
instruction_text = "\n".join(snippets)
|
|
293
|
+
lower_text = instruction_text.lower()
|
|
294
|
+
instruction_lines = instruction_text.splitlines()
|
|
295
|
+
|
|
296
|
+
documented: list[str] = []
|
|
297
|
+
missing: list[str] = []
|
|
298
|
+
for lines, _size, path in records:
|
|
299
|
+
relative = rel(path, root)
|
|
300
|
+
relative_lower = relative.lower()
|
|
301
|
+
indices: list[int] = []
|
|
302
|
+
start = 0
|
|
303
|
+
while True:
|
|
304
|
+
index = lower_text.find(relative_lower, start)
|
|
305
|
+
if index < 0:
|
|
306
|
+
break
|
|
307
|
+
indices.append(index)
|
|
308
|
+
start = index + len(relative_lower)
|
|
309
|
+
|
|
310
|
+
if not indices:
|
|
311
|
+
missing.append(f"{relative} lines={lines} reason=not mentioned in agent instructions")
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
saw_hotspot_context = False
|
|
315
|
+
saw_verification_context = False
|
|
316
|
+
has_documented_entry = False
|
|
317
|
+
for index in indices:
|
|
318
|
+
window = lower_text[max(0, index - 700): index + len(relative_lower) + 700]
|
|
319
|
+
line_no = lower_text[:index].count("\n")
|
|
320
|
+
local_lines = instruction_lines[max(0, line_no - 1): line_no + 4]
|
|
321
|
+
local_context = "\n".join(local_lines)
|
|
322
|
+
has_hotspot_context = bool(HOTSPOT_WORD_RE.search(window))
|
|
323
|
+
has_verification_context = bool(VERIFICATION_WORD_RE.search(local_context))
|
|
324
|
+
saw_hotspot_context = saw_hotspot_context or has_hotspot_context
|
|
325
|
+
saw_verification_context = saw_verification_context or has_verification_context
|
|
326
|
+
if has_hotspot_context and has_verification_context:
|
|
327
|
+
has_documented_entry = True
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
if has_documented_entry:
|
|
331
|
+
documented.append(f"{relative} lines={lines}")
|
|
332
|
+
else:
|
|
333
|
+
reasons = []
|
|
334
|
+
if not saw_hotspot_context:
|
|
335
|
+
reasons.append("missing ownership/boundary context")
|
|
336
|
+
if not saw_verification_context:
|
|
337
|
+
reasons.append("missing verification context")
|
|
338
|
+
if saw_hotspot_context and saw_verification_context:
|
|
339
|
+
reasons.append("ownership and verification are not in the same hotspot entry")
|
|
340
|
+
missing.append(f"{relative} lines={lines} reason={'; '.join(reasons)}")
|
|
341
|
+
|
|
342
|
+
findings: list[str] = []
|
|
343
|
+
if missing:
|
|
344
|
+
findings.append("large source files lack hotspot ownership or verification map")
|
|
345
|
+
return ("WARN" if missing else "PASS"), documented, missing, findings
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main() -> int:
|
|
349
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
350
|
+
parser.add_argument("root", nargs="?", default=".", help="Repo root (default: cwd)")
|
|
351
|
+
parser.add_argument(
|
|
352
|
+
"mode", nargs="?", default="summary", choices=("summary", "deep"),
|
|
353
|
+
help="Output detail level",
|
|
354
|
+
)
|
|
355
|
+
args = parser.parse_args()
|
|
356
|
+
root = Path(args.root).resolve()
|
|
357
|
+
mode = args.mode
|
|
358
|
+
|
|
359
|
+
if not root.is_dir():
|
|
360
|
+
print(f"Repo root not found: {root}", file=sys.stderr)
|
|
361
|
+
return 2
|
|
362
|
+
|
|
363
|
+
files = iter_files(root)
|
|
364
|
+
tracked_count = len(files)
|
|
365
|
+
extensions = Counter(path.suffix.lower() or "(none)" for path in files)
|
|
366
|
+
detected_manifests = [
|
|
367
|
+
name
|
|
368
|
+
for name in [
|
|
369
|
+
"Makefile", "package.json", "Cargo.toml", "go.mod", "pyproject.toml",
|
|
370
|
+
"pytest.ini", "pom.xml", "deno.json", "deno.jsonc",
|
|
371
|
+
]
|
|
372
|
+
if (root / name).is_file()
|
|
373
|
+
]
|
|
374
|
+
workflows_dir = root / ".github" / "workflows"
|
|
375
|
+
workflow_count = 0
|
|
376
|
+
if workflows_dir.is_dir():
|
|
377
|
+
workflow_count = len(list(workflows_dir.glob("*.yml"))) + len(list(workflows_dir.glob("*.yaml")))
|
|
378
|
+
if workflow_count:
|
|
379
|
+
detected_manifests.append(f".github/workflows ({workflow_count})")
|
|
380
|
+
|
|
381
|
+
source_files = [path for path in files if path.suffix.lower() in SOURCE_EXTS]
|
|
382
|
+
source_stats: list[tuple[int, int, Path]] = []
|
|
383
|
+
for path in source_files:
|
|
384
|
+
try:
|
|
385
|
+
size = path.stat().st_size
|
|
386
|
+
except OSError:
|
|
387
|
+
size = 0
|
|
388
|
+
source_stats.append((line_count(path), size, path))
|
|
389
|
+
source_stats.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
|
390
|
+
|
|
391
|
+
dir_counts: Counter[str] = Counter()
|
|
392
|
+
for path in files:
|
|
393
|
+
relative_parts = Path(rel(path, root)).parts
|
|
394
|
+
top = relative_parts[0] if len(relative_parts) > 1 else "."
|
|
395
|
+
dir_counts[top] += 1
|
|
396
|
+
|
|
397
|
+
instruction_files = instruction_paths(root)
|
|
398
|
+
project_map = find_text_signal(
|
|
399
|
+
instruction_files,
|
|
400
|
+
[r"repository map", r"project map", r"repo map", r"\bproject\b", r"目录", r"仓库", r"结构"],
|
|
401
|
+
)
|
|
402
|
+
instruction_verification = find_text_signal(
|
|
403
|
+
instruction_files,
|
|
404
|
+
[r"verification", r"test plan", r"make test", r"npm test", r"pytest", r"cargo test", r"验证", r"测试"],
|
|
405
|
+
)
|
|
406
|
+
boundaries = find_text_signal(
|
|
407
|
+
instruction_files,
|
|
408
|
+
[r"not for", r"do not", r"non-?goals?", r"scope", r"boundar", r"never", r"avoid", r"边界", r"非目标", r"不要"],
|
|
409
|
+
)
|
|
410
|
+
commands, missing_references, make_targets, _package_scripts = verification_surface(root, instruction_files)
|
|
411
|
+
stable_make_targets = sorted(make_targets & {"check", "test", "verify"})
|
|
412
|
+
wrapper_warnings: list[str] = []
|
|
413
|
+
if len(commands) >= 2 and (root / "Makefile").is_file() and not stable_make_targets:
|
|
414
|
+
wrapper_warnings.append(
|
|
415
|
+
"multiple verification commands discovered but Makefile lacks check/test/verify wrapper"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
decision_artifacts = {
|
|
419
|
+
"docs_dir": (root / "docs").is_dir(),
|
|
420
|
+
"specs_dir": (root / "specs").is_dir(),
|
|
421
|
+
"specify_dir": (root / ".specify").is_dir(),
|
|
422
|
+
"handoff_md": any(path.name.upper() == "HANDOFF.MD" for path in root.glob("*.md")),
|
|
423
|
+
"changelog": any(path.name.upper().startswith("CHANGELOG") for path in root.glob("*")),
|
|
424
|
+
"issue_templates": (root / ".github" / "ISSUE_TEMPLATE").exists(),
|
|
425
|
+
"pr_template": any(
|
|
426
|
+
path.is_file()
|
|
427
|
+
for path in [
|
|
428
|
+
root / ".github" / "pull_request_template.md",
|
|
429
|
+
root / ".github" / "PULL_REQUEST_TEMPLATE.md",
|
|
430
|
+
]
|
|
431
|
+
),
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
todo_counts: Counter[str] = Counter()
|
|
435
|
+
todo_total = 0
|
|
436
|
+
for path in source_files:
|
|
437
|
+
text = read_text(path, 200_000)
|
|
438
|
+
# Count marker-bearing lines, not marker words. Documentation often names
|
|
439
|
+
# the full marker family in one rule line; treating that as four issues
|
|
440
|
+
# makes the checker flag itself instead of real TODO piles.
|
|
441
|
+
count = sum(1 for line in text.splitlines() if MARKER_RE.search(line))
|
|
442
|
+
if count:
|
|
443
|
+
todo_counts[rel(path, root)] += count
|
|
444
|
+
todo_total += count
|
|
445
|
+
|
|
446
|
+
large_line_limit = 1200 if mode == "summary" else 800
|
|
447
|
+
large_file_records = [
|
|
448
|
+
(lines, size, path) for lines, size, path in source_stats if lines >= large_line_limit
|
|
449
|
+
]
|
|
450
|
+
large_files = [f"{rel(path, root)} lines={lines} bytes={size}" for lines, size, path in large_file_records]
|
|
451
|
+
todo_hotspots = [
|
|
452
|
+
f"{path} markers={count}" for path, count in todo_counts.most_common(8 if mode == "deep" else 5)
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
doc_ref_status = "unavailable"
|
|
456
|
+
doc_ref_detail = ""
|
|
457
|
+
checker = os.environ.get("DOC_REF_CHECKER")
|
|
458
|
+
if checker and Path(checker).is_file():
|
|
459
|
+
proc = subprocess.run(
|
|
460
|
+
["bash", checker, str(root)],
|
|
461
|
+
text=True,
|
|
462
|
+
stdout=subprocess.PIPE,
|
|
463
|
+
stderr=subprocess.STDOUT,
|
|
464
|
+
check=False,
|
|
465
|
+
)
|
|
466
|
+
doc_ref_status = "pass" if proc.returncode == 0 else "fail"
|
|
467
|
+
if proc.stdout.strip():
|
|
468
|
+
first_lines = proc.stdout.strip().splitlines()[:8]
|
|
469
|
+
doc_ref_detail = " | ".join(first_lines)
|
|
470
|
+
|
|
471
|
+
has_instruction_surface = bool(instruction_files)
|
|
472
|
+
has_command_surface = bool(commands)
|
|
473
|
+
context_warnings: list[str] = []
|
|
474
|
+
verification_warnings: list[str] = []
|
|
475
|
+
drift_warnings: list[str] = []
|
|
476
|
+
|
|
477
|
+
if not has_instruction_surface:
|
|
478
|
+
context_warnings.append("no agent instruction surface")
|
|
479
|
+
if has_instruction_surface and not project_map:
|
|
480
|
+
context_warnings.append("instructions lack project map")
|
|
481
|
+
if has_instruction_surface and not instruction_verification:
|
|
482
|
+
context_warnings.append("instructions lack verification guidance")
|
|
483
|
+
if has_instruction_surface and not boundaries:
|
|
484
|
+
context_warnings.append("instructions lack scope/boundary language")
|
|
485
|
+
if not has_command_surface:
|
|
486
|
+
verification_warnings.append("no executable verification command discovered")
|
|
487
|
+
if missing_references:
|
|
488
|
+
verification_warnings.append("instruction references missing commands")
|
|
489
|
+
if todo_total >= (50 if mode == "summary" else 25):
|
|
490
|
+
drift_warnings.append("TODO/FIXME/HACK/XXX markers are concentrated")
|
|
491
|
+
hotspot_status, documented_hotspots, missing_hotspot_ownership, hotspot_findings = (
|
|
492
|
+
hotspot_ownership_surface(large_file_records, instruction_files, mode, root)
|
|
493
|
+
)
|
|
494
|
+
if large_files and mode != "deep":
|
|
495
|
+
drift_warnings.append("large source files need ownership or module boundaries")
|
|
496
|
+
if hotspot_status == "WARN":
|
|
497
|
+
drift_warnings.extend(hotspot_findings)
|
|
498
|
+
if doc_ref_status == "fail":
|
|
499
|
+
drift_warnings.append("broken documentation references")
|
|
500
|
+
|
|
501
|
+
markdown_missing: list[str] = []
|
|
502
|
+
markdown_link_status = "SKIPPED"
|
|
503
|
+
if mode == "deep":
|
|
504
|
+
markdown_missing = scan_markdown_links(files, root)
|
|
505
|
+
markdown_link_status = "WARN" if markdown_missing else "PASS"
|
|
506
|
+
if markdown_missing:
|
|
507
|
+
drift_warnings.append("broken Markdown links")
|
|
508
|
+
|
|
509
|
+
context_status = "FAIL" if not has_instruction_surface else ("WARN" if context_warnings else "PASS")
|
|
510
|
+
verification_status = "FAIL" if not has_command_surface else ("WARN" if verification_warnings else "PASS")
|
|
511
|
+
decision_status = "PASS"
|
|
512
|
+
wrapper_status = "WARN" if wrapper_warnings else "PASS"
|
|
513
|
+
drift_status = "WARN" if drift_warnings else "PASS"
|
|
514
|
+
|
|
515
|
+
if context_status == "FAIL" or verification_status == "FAIL" or doc_ref_status == "fail":
|
|
516
|
+
overall = "FAIL"
|
|
517
|
+
elif "WARN" in {
|
|
518
|
+
context_status, verification_status, decision_status, wrapper_status,
|
|
519
|
+
drift_status, markdown_link_status, hotspot_status,
|
|
520
|
+
}:
|
|
521
|
+
overall = "WARN"
|
|
522
|
+
else:
|
|
523
|
+
overall = "PASS"
|
|
524
|
+
|
|
525
|
+
top_ext = [f"{ext} files={count}" for ext, count in extensions.most_common(10)]
|
|
526
|
+
largest_sources = [
|
|
527
|
+
f"{rel(path, root)} lines={lines} bytes={size}"
|
|
528
|
+
for lines, size, path in source_stats[: (10 if mode == "deep" else 5)]
|
|
529
|
+
]
|
|
530
|
+
largest_dirs = [f"{directory} files={count}" for directory, count in dir_counts.most_common(8)]
|
|
531
|
+
|
|
532
|
+
print("=== PROJECT SHAPE ===")
|
|
533
|
+
print(f"maintainability_status: {overall}")
|
|
534
|
+
print(f"mode: {mode}")
|
|
535
|
+
print(f"tracked_files: {tracked_count}")
|
|
536
|
+
print("top_extensions:")
|
|
537
|
+
print_list(top_ext)
|
|
538
|
+
print("largest_source_files:")
|
|
539
|
+
print_list(largest_sources)
|
|
540
|
+
print("largest_directories:")
|
|
541
|
+
print_list(largest_dirs)
|
|
542
|
+
|
|
543
|
+
print("=== AI CONTEXT SURFACE ===")
|
|
544
|
+
print(f"context_status: {context_status}")
|
|
545
|
+
print(f"AGENTS.md: {'yes' if (root / 'AGENTS.md').is_file() else 'no'}")
|
|
546
|
+
print(f"CLAUDE.md: {'yes' if (root / 'CLAUDE.md').is_file() else 'no'}")
|
|
547
|
+
print(f".github/copilot-instructions.md: {'yes' if (root / '.github' / 'copilot-instructions.md').is_file() else 'no'}")
|
|
548
|
+
github_instruction_count = (
|
|
549
|
+
len(list((root / ".github" / "instructions").glob("*.md")))
|
|
550
|
+
if (root / ".github" / "instructions").is_dir() else 0
|
|
551
|
+
)
|
|
552
|
+
print(f".github/instructions/*.md: {github_instruction_count}")
|
|
553
|
+
print(f"GEMINI.md: {'yes' if (root / 'GEMINI.md').is_file() else 'no'}")
|
|
554
|
+
print(f"project_map: {'yes' if project_map else 'no'}")
|
|
555
|
+
print(f"verification_guidance: {'yes' if instruction_verification else 'no'}")
|
|
556
|
+
print(f"boundary_guidance: {'yes' if boundaries else 'no'}")
|
|
557
|
+
print("context_findings:")
|
|
558
|
+
print_list(context_warnings)
|
|
559
|
+
print("instruction_files:")
|
|
560
|
+
print_list([rel(path, root) for path in instruction_files])
|
|
561
|
+
|
|
562
|
+
print("=== VERIFICATION SURFACE ===")
|
|
563
|
+
print(f"verification_status: {verification_status}")
|
|
564
|
+
print("detected_manifests:")
|
|
565
|
+
print_list(detected_manifests)
|
|
566
|
+
print("commands:")
|
|
567
|
+
print_list(commands, limit=12 if mode == "summary" else None)
|
|
568
|
+
print("missing_referenced_commands:")
|
|
569
|
+
print_list(missing_references, limit=10 if mode == "summary" else None)
|
|
570
|
+
print("verification_findings:")
|
|
571
|
+
print_list(verification_warnings)
|
|
572
|
+
|
|
573
|
+
print("=== VERIFICATION WRAPPER SURFACE ===")
|
|
574
|
+
print(f"wrapper_status: {wrapper_status}")
|
|
575
|
+
print(f"makefile_present: {'yes' if (root / 'Makefile').is_file() else 'no'}")
|
|
576
|
+
print("stable_make_targets:")
|
|
577
|
+
print_list([f"make {target}" for target in stable_make_targets])
|
|
578
|
+
print("wrapper_findings:")
|
|
579
|
+
print_list(wrapper_warnings)
|
|
580
|
+
|
|
581
|
+
print("=== DECISION ARTIFACTS ===")
|
|
582
|
+
print(f"decision_artifacts_status: {decision_status}")
|
|
583
|
+
for key, value in decision_artifacts.items():
|
|
584
|
+
print(f"{key}: {'yes' if value else 'no'}")
|
|
585
|
+
|
|
586
|
+
print("=== DRIFT MARKERS ===")
|
|
587
|
+
print(f"drift_status: {drift_status}")
|
|
588
|
+
print(f"todo_markers: {todo_total}")
|
|
589
|
+
print("todo_hotspots:")
|
|
590
|
+
print_list(todo_hotspots)
|
|
591
|
+
print("large_source_files:")
|
|
592
|
+
print_list(large_files[: (10 if mode == "deep" else 5)])
|
|
593
|
+
print(f"broken_doc_references: {doc_ref_status}")
|
|
594
|
+
if doc_ref_detail and (mode == "deep" or doc_ref_status == "fail"):
|
|
595
|
+
print(f"broken_doc_reference_detail: {doc_ref_detail}")
|
|
596
|
+
print("drift_findings:")
|
|
597
|
+
print_list(drift_warnings)
|
|
598
|
+
|
|
599
|
+
print("=== HOTSPOT OWNERSHIP SURFACE ===")
|
|
600
|
+
print(f"hotspot_ownership_status: {hotspot_status}")
|
|
601
|
+
print(f"large_hotspot_threshold_lines: {large_line_limit if mode == 'deep' else '(deep mode only)'}")
|
|
602
|
+
print("documented_hotspots:")
|
|
603
|
+
if mode == "deep":
|
|
604
|
+
print_list(documented_hotspots)
|
|
605
|
+
else:
|
|
606
|
+
print(" (skipped: deep mode only)")
|
|
607
|
+
print("missing_hotspot_ownership:")
|
|
608
|
+
if mode == "deep":
|
|
609
|
+
print_list(missing_hotspot_ownership)
|
|
610
|
+
else:
|
|
611
|
+
print(" (skipped: deep mode only)")
|
|
612
|
+
print("hotspot_ownership_findings:")
|
|
613
|
+
if mode == "deep":
|
|
614
|
+
print_list(hotspot_findings)
|
|
615
|
+
else:
|
|
616
|
+
print(" (skipped: deep mode only)")
|
|
617
|
+
|
|
618
|
+
print("=== MARKDOWN LINK SURFACE ===")
|
|
619
|
+
print(f"markdown_link_status: {markdown_link_status}")
|
|
620
|
+
print("missing_markdown_links:")
|
|
621
|
+
if mode == "deep":
|
|
622
|
+
print_list(markdown_missing, limit=20)
|
|
623
|
+
else:
|
|
624
|
+
print(" (skipped: deep mode only)")
|
|
625
|
+
return 0
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
if __name__ == "__main__":
|
|
629
|
+
sys.exit(main())
|