self-evolve-framework 1.0.7 → 1.1.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/README.md +12 -8
- package/bin/cli.js +63 -35
- package/package.json +1 -1
- package/template/rules/CodeGraph.mdc +23 -0
- package/template/rules/Svelte_5.mdc +167 -0
- package/template/rules/Svelte_Flow.mdc +176 -0
- package/template/rules/Tailwind_CSS_v4.mdc +187 -0
- package/template/rules/Tauri.mdc +145 -0
- package/template/rules/app-error-pattern.mdc +65 -0
- package/template/rules/invoke-safe-pattern.mdc +53 -0
- package/template/rules/js.mdc +10 -0
- package/template/rules/powershell.mdc +9 -0
- package/template/rules/self-evolve.mdc +4 -4
- package/template/rules//346/227/245/345/277/227.mdc +15 -0
- package/template/rules//350/257/267/346/261/202.mdc +49 -0
- package/template/skills/caveman/SKILL.md +49 -0
- package/template/skills/check/SKILL.md +393 -0
- package/template/skills/check/agents/reviewer-architecture.md +39 -0
- package/template/skills/check/agents/reviewer-security.md +39 -0
- package/template/skills/check/references/persona-catalog.md +56 -0
- package/template/skills/check/references/project-context.md +120 -0
- package/template/skills/check/references/public-reply.md +14 -0
- package/template/skills/check/scripts/audit_signals.py +666 -0
- package/template/skills/check/scripts/run-tests.sh +19 -0
- package/template/skills/design/SKILL.md +173 -0
- package/template/skills/design/references/design-aesthetic-quality.md +67 -0
- package/template/skills/design/references/design-data-viz.md +34 -0
- package/template/skills/design/references/design-reference.md +295 -0
- package/template/skills/design/references/design-tokens.md +45 -0
- package/template/skills/design/references/design-traps.md +43 -0
- package/template/skills/design-an-interface/SKILL.md +94 -0
- package/template/skills/diagnose/SKILL.md +117 -0
- package/template/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/template/skills/edit-article/SKILL.md +14 -0
- package/template/skills/git-guardrails-claude-code/SKILL.md +95 -0
- package/template/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh +25 -0
- package/template/skills/grill-me/SKILL.md +10 -0
- package/template/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/template/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/template/skills/grill-with-docs/SKILL.md +88 -0
- package/template/skills/handoff/SKILL.md +15 -0
- package/template/skills/health/SKILL.md +260 -0
- package/template/skills/health/agents/inspector-context.md +119 -0
- package/template/skills/health/agents/inspector-control.md +84 -0
- package/template/skills/health/agents/inspector-maintainability.md +55 -0
- package/template/skills/health/scripts/check-agent-context.sh +5 -0
- package/template/skills/health/scripts/check-doc-refs.sh +8 -0
- package/template/skills/health/scripts/check-maintainability.sh +8 -0
- package/template/skills/health/scripts/check-verifier-output.sh +5 -0
- package/template/skills/health/scripts/check_agent_context.py +444 -0
- package/template/skills/health/scripts/check_doc_refs.py +110 -0
- package/template/skills/health/scripts/check_maintainability.py +635 -0
- package/template/skills/health/scripts/check_verifier_output.py +116 -0
- package/template/skills/health/scripts/collect-data.sh +751 -0
- package/template/skills/hunt/SKILL.md +232 -0
- package/template/skills/hunt/references/failure-patterns.md +138 -0
- package/template/skills/hunt/references/ime-unicode.md +58 -0
- package/template/skills/hunt/references/logging-techniques.md +72 -0
- package/template/skills/hunt/references/rendering-debug.md +34 -0
- package/template/skills/impeccable/SKILL.md +47 -0
- package/template/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/template/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/template/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/template/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/template/skills/improve-codebase-architecture/SKILL.md +81 -0
- package/template/skills/learn/SKILL.md +140 -0
- package/template/skills/migrate-to-shoehorn/SKILL.md +118 -0
- package/template/skills/obsidian-vault/SKILL.md +59 -0
- package/template/skills/prototype/LOGIC.md +79 -0
- package/template/skills/prototype/SKILL.md +30 -0
- package/template/skills/prototype/UI.md +112 -0
- package/template/skills/qa/SKILL.md +130 -0
- package/template/skills/read/SKILL.md +141 -0
- package/template/skills/read/references/read-methods.md +129 -0
- package/template/skills/read/scripts/fetch.sh +106 -0
- package/template/skills/read/scripts/fetch_feishu.py +251 -0
- package/template/skills/read/scripts/fetch_local.py +218 -0
- package/template/skills/read/scripts/fetch_weixin.py +107 -0
- package/template/skills/request-refactor-plan/SKILL.md +68 -0
- package/template/skills/review/SKILL.md +78 -0
- package/template/skills/rust-auto-fix/SKILL.md +94 -0
- package/template/skills/scaffold-exercises/SKILL.md +106 -0
- package/template/skills/sdd-dev/SKILL.md +114 -0
- package/template/skills/setup-matt-pocock-skills/SKILL.md +121 -0
- package/template/skills/setup-matt-pocock-skills/domain.md +51 -0
- package/template/skills/setup-matt-pocock-skills/issue-tracker-github.md +22 -0
- package/template/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md +23 -0
- package/template/skills/setup-matt-pocock-skills/issue-tracker-local.md +19 -0
- package/template/skills/setup-matt-pocock-skills/triage-labels.md +15 -0
- package/template/skills/setup-pre-commit/SKILL.md +91 -0
- package/template/skills/skillopt-sleep/SKILL.md +1 -1
- package/template/skills/svelte-warnings-fix/SKILL.md +94 -0
- package/template/skills/tauri-nsis-installer-icon/SKILL.md +92 -0
- package/template/skills/tauri-nsis-installer-icon/references/tauri-nsis-schema.md +71 -0
- package/template/skills/tb/SKILL.md +62 -0
- package/template/skills/tdd/SKILL.md +109 -0
- package/template/skills/tdd/deep-modules.md +33 -0
- package/template/skills/tdd/interface-design.md +31 -0
- package/template/skills/tdd/mocking.md +59 -0
- package/template/skills/tdd/refactoring.md +10 -0
- package/template/skills/tdd/tests.md +61 -0
- package/template/skills/teach/GLOSSARY-FORMAT.md +35 -0
- package/template/skills/teach/LEARNING-RECORD-FORMAT.md +46 -0
- package/template/skills/teach/MISSION-FORMAT.md +31 -0
- package/template/skills/teach/RESOURCES-FORMAT.md +32 -0
- package/template/skills/teach/SKILL.md +91 -0
- package/template/skills/think/SKILL.md +184 -0
- package/template/skills/to-issues/SKILL.md +83 -0
- package/template/skills/to-prd/SKILL.md +74 -0
- package/template/skills/triage/AGENT-BRIEF.md +168 -0
- package/template/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/template/skills/triage/SKILL.md +103 -0
- package/template/skills/ubiquitous-language/SKILL.md +93 -0
- package/template/skills/ver/SKILL.md +62 -0
- package/template/skills/write/SKILL.md +209 -0
- package/template/skills/write/references/write-en.md +199 -0
- package/template/skills/write/references/write-product-localization.md +43 -0
- package/template/skills/write/references/write-zh-bilingual.md +59 -0
- package/template/skills/write/references/write-zh-prose.md +50 -0
- package/template/skills/write/references/write-zh-release-notes.md +40 -0
- package/template/skills/write/references/write-zh.md +721 -0
- package/template/skills/write-a-skill/SKILL.md +117 -0
- package/template/skills/writing-beats/SKILL.md +52 -0
- package/template/skills/writing-fragments/SKILL.md +75 -0
- package/template/skills/writing-shape/SKILL.md +64 -0
- package/template/skills/zoom-out/SKILL.md +7 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Project audit signals (Phase 1) for /check audit mode.
|
|
3
|
+
|
|
4
|
+
Walks a project root and emits structured signal blocks to stdout.
|
|
5
|
+
Each block ends with `status: PASS|WARN|FAIL|N/A` so the LLM driving the
|
|
6
|
+
4-axis Linus-style scorecard can skim quickly.
|
|
7
|
+
|
|
8
|
+
Pure stdlib. Read-only. Exits 0 even on WARN/FAIL so the harness does
|
|
9
|
+
not confuse "finding surfaced" with "script broken".
|
|
10
|
+
|
|
11
|
+
Run as: python3 skills/check/scripts/audit_signals.py --root <path>
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
EXCLUDED_DIRS = {
|
|
26
|
+
".git", ".hg", ".svn", "node_modules", "dist", "build", ".next",
|
|
27
|
+
"__pycache__", ".turbo", "target", ".venv", "venv", "vendor",
|
|
28
|
+
"coverage", ".cache", ".parcel-cache", ".pytest_cache", ".mypy_cache",
|
|
29
|
+
".ruff_cache", "Pods", "Carthage", ".swiftpm", ".gradle",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
SOURCE_EXTS = {
|
|
33
|
+
".py", ".swift", ".rs", ".go", ".ts", ".tsx", ".js", ".jsx", ".sh",
|
|
34
|
+
".bash", ".zsh", ".rb", ".java", ".kt", ".m", ".mm", ".vue", ".c",
|
|
35
|
+
".cc", ".cpp", ".h", ".hpp", ".cs",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
HOTSPOT_LINES = 500
|
|
39
|
+
HOTSPOT_FAIL = 1500
|
|
40
|
+
HEREDOC_LINES = 100
|
|
41
|
+
DRIFT_WARN = 50
|
|
42
|
+
DRIFT_FAIL = 150
|
|
43
|
+
DUP_JACCARD = 0.70
|
|
44
|
+
|
|
45
|
+
MARKER_RE = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b")
|
|
46
|
+
HEREDOC_OPEN_RE = re.compile(
|
|
47
|
+
r"(python3?|node|ruby|perl|php)\b[^|\n]*?<<-?\s*['\"]?(\w+)['\"]?"
|
|
48
|
+
)
|
|
49
|
+
INSTALL_URL_RE = re.compile(
|
|
50
|
+
r"raw\.githubusercontent\.com/[^/\s]+/[^/\s]+/([^/\s]+)/"
|
|
51
|
+
)
|
|
52
|
+
# --exclude requires = or trailing value to avoid matching git's --exclude-standard
|
|
53
|
+
DENYLIST_HINT_RE = re.compile(
|
|
54
|
+
r"(^\s*(skip|exclude)\s*=|\s--exclude=|!\*\.\w+|grep\s+-v\b|--ignore=)",
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
MINIFIED_RE = re.compile(r"\.min\.[a-z]+$", re.IGNORECASE)
|
|
58
|
+
CLI_CONTRACT_BUCKETS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
59
|
+
("help_or_usage", re.compile(r"(--help|\busage\b|\bhelp output\b)", re.IGNORECASE)),
|
|
60
|
+
("version", re.compile(r"(--version|\bversion output\b)", re.IGNORECASE)),
|
|
61
|
+
("exit_code", re.compile(r"\b(exit code|exit status|return code|exit_code|\$\?)\b", re.IGNORECASE)),
|
|
62
|
+
("stdout", re.compile(r"\b(stdout|standard output)\b|>\s*\"\$?[A-Za-z0-9_./-]*stdout", re.IGNORECASE)),
|
|
63
|
+
("stderr", re.compile(r"\b(stderr|standard error)\b|2>\s*\"\$?[A-Za-z0-9_./-]*stderr", re.IGNORECASE)),
|
|
64
|
+
("non_interactive_or_tty", re.compile(r"\b(non-interactive|noninteractive|tty|isatty|/dev/null|CI=1)\b", re.IGNORECASE)),
|
|
65
|
+
(
|
|
66
|
+
"install_run",
|
|
67
|
+
re.compile(
|
|
68
|
+
r"(\binstall\s+-m\b|\binstalled command\b|\binstalled-runtime\b|"
|
|
69
|
+
r"\binstall/run\b|\binstall run\b|\btemp prefix\b|\bPATH shim\b|"
|
|
70
|
+
r"\bpackage-manager path\b|\bnpm link\b|\bpipx install\b|"
|
|
71
|
+
r"\bcargo install\b|\bbrew install\b|\bmake install\b)",
|
|
72
|
+
re.IGNORECASE,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
("json_or_schema", re.compile(r"\b(json|schema)\b", re.IGNORECASE)),
|
|
76
|
+
("completion", re.compile(r"\bcompletion\b", re.IGNORECASE)),
|
|
77
|
+
)
|
|
78
|
+
CLI_CORE_BUCKETS = (
|
|
79
|
+
"help_or_usage",
|
|
80
|
+
"version",
|
|
81
|
+
"exit_code",
|
|
82
|
+
"stdout",
|
|
83
|
+
"stderr",
|
|
84
|
+
"install_run",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# The file-walk helpers below are deliberately duplicated in
|
|
89
|
+
# skills/health/scripts/check_maintainability.py. Both scripts ship
|
|
90
|
+
# standalone (see packaging.allowlist) and run inside an arbitrary target
|
|
91
|
+
# project, so they import only stdlib. Do not hoist them into a shared
|
|
92
|
+
# scripts/ module: it is dev-only, not on the ship allowlist, and would
|
|
93
|
+
# couple a standalone tool to the install layout.
|
|
94
|
+
def is_excluded(path: Path, root: Path) -> bool:
|
|
95
|
+
try:
|
|
96
|
+
parts = path.relative_to(root).parts
|
|
97
|
+
except ValueError:
|
|
98
|
+
parts = path.parts
|
|
99
|
+
if any(p in EXCLUDED_DIRS for p in parts):
|
|
100
|
+
return True
|
|
101
|
+
return bool(MINIFIED_RE.search(path.name))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def iter_files(root: Path) -> list[Path]:
|
|
105
|
+
try:
|
|
106
|
+
proc = subprocess.run(
|
|
107
|
+
["git", "-C", str(root), "ls-files",
|
|
108
|
+
"--cached", "--others", "--exclude-standard"],
|
|
109
|
+
text=True, stdout=subprocess.PIPE,
|
|
110
|
+
stderr=subprocess.DEVNULL, check=False,
|
|
111
|
+
)
|
|
112
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
113
|
+
out = []
|
|
114
|
+
for line in proc.stdout.splitlines():
|
|
115
|
+
p = root / line
|
|
116
|
+
if p.is_file() and not is_excluded(p, root):
|
|
117
|
+
out.append(p)
|
|
118
|
+
return out
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
out = []
|
|
122
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
123
|
+
current = Path(dirpath)
|
|
124
|
+
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS]
|
|
125
|
+
if is_excluded(current, root):
|
|
126
|
+
continue
|
|
127
|
+
for fname in filenames:
|
|
128
|
+
p = current / fname
|
|
129
|
+
if p.is_file() and not is_excluded(p, root):
|
|
130
|
+
out.append(p)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def line_count(path: Path) -> int:
|
|
135
|
+
try:
|
|
136
|
+
with path.open("rb") as fh:
|
|
137
|
+
return sum(1 for _ in fh)
|
|
138
|
+
except OSError:
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_text(path: Path, limit: int = 0) -> str:
|
|
143
|
+
try:
|
|
144
|
+
data = path.read_text(encoding="utf-8", errors="replace")
|
|
145
|
+
except OSError:
|
|
146
|
+
return ""
|
|
147
|
+
return data[:limit] if limit else data
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def rel(path: Path, root: Path) -> str:
|
|
151
|
+
try:
|
|
152
|
+
return path.relative_to(root).as_posix()
|
|
153
|
+
except ValueError:
|
|
154
|
+
return path.as_posix()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def header(name: str) -> None:
|
|
158
|
+
print(f"=== {name} ===")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def status(label: str) -> None:
|
|
162
|
+
print(f"status: {label}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def block_hotspots(files: list[Path], root: Path) -> None:
|
|
166
|
+
header("FILE SIZE HOTSPOTS")
|
|
167
|
+
sized = ((p, line_count(p)) for p in files if p.suffix in SOURCE_EXTS)
|
|
168
|
+
big = sorted(
|
|
169
|
+
(item for item in sized if item[1] >= HOTSPOT_LINES),
|
|
170
|
+
key=lambda x: -x[1],
|
|
171
|
+
)[:10]
|
|
172
|
+
if not big:
|
|
173
|
+
print(f"(no source files >= {HOTSPOT_LINES} lines)")
|
|
174
|
+
status("PASS")
|
|
175
|
+
return
|
|
176
|
+
for path, n in big:
|
|
177
|
+
print(f" {n:>5} {rel(path, root)}")
|
|
178
|
+
status("FAIL" if any(n >= HOTSPOT_FAIL for _, n in big) else "WARN")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def block_heredoc(files: list[Path], root: Path) -> None:
|
|
182
|
+
header("HEREDOC BLOAT")
|
|
183
|
+
hits: list[tuple[str, int, str, int]] = []
|
|
184
|
+
for path in files:
|
|
185
|
+
if path.suffix not in {".sh", ".bash", ".zsh"}:
|
|
186
|
+
continue
|
|
187
|
+
text = read_text(path)
|
|
188
|
+
if not text:
|
|
189
|
+
continue
|
|
190
|
+
lines = text.splitlines()
|
|
191
|
+
i = 0
|
|
192
|
+
while i < len(lines):
|
|
193
|
+
m = HEREDOC_OPEN_RE.search(lines[i])
|
|
194
|
+
if not m:
|
|
195
|
+
i += 1
|
|
196
|
+
continue
|
|
197
|
+
lang, marker = m.group(1), m.group(2)
|
|
198
|
+
j = i + 1
|
|
199
|
+
close = re.compile(r"^\s*" + re.escape(marker) + r"\s*$")
|
|
200
|
+
while j < len(lines) and not close.match(lines[j]):
|
|
201
|
+
j += 1
|
|
202
|
+
size = j - i
|
|
203
|
+
if size >= HEREDOC_LINES:
|
|
204
|
+
hits.append((rel(path, root), i + 1, lang, size))
|
|
205
|
+
i = j + 1
|
|
206
|
+
if not hits:
|
|
207
|
+
print("(no python/node/ruby/perl/php heredocs >= 100 lines)")
|
|
208
|
+
status("PASS")
|
|
209
|
+
return
|
|
210
|
+
for f, ln, lang, sz in hits:
|
|
211
|
+
print(f" {f}:{ln} lang={lang} block_lines={sz}")
|
|
212
|
+
status("WARN")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def block_test_ci(files: list[Path], root: Path) -> None:
|
|
216
|
+
header("TEST AND CI SURFACE")
|
|
217
|
+
test_files = [
|
|
218
|
+
p for p in files
|
|
219
|
+
if p.suffix in SOURCE_EXTS
|
|
220
|
+
and (("test" in p.name.lower()) or ("spec" in p.name.lower()))
|
|
221
|
+
]
|
|
222
|
+
src_files = [p for p in files if p.suffix in SOURCE_EXTS]
|
|
223
|
+
wf_dir = root / ".github" / "workflows"
|
|
224
|
+
workflows = []
|
|
225
|
+
if wf_dir.is_dir():
|
|
226
|
+
workflows = sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml")))
|
|
227
|
+
job_names: list[str] = []
|
|
228
|
+
for wf in workflows:
|
|
229
|
+
text = read_text(wf, 50_000)
|
|
230
|
+
for m in re.finditer(r"^name:\s*(.+?)\s*$", text, re.MULTILINE):
|
|
231
|
+
job_names.append(f"{wf.name}: {m.group(1)[:60]}")
|
|
232
|
+
break
|
|
233
|
+
ratio = len(test_files) / max(len(src_files), 1)
|
|
234
|
+
print(f"tests_count={len(test_files)} source_count={len(src_files)} "
|
|
235
|
+
f"ratio={ratio:.1%}")
|
|
236
|
+
print(f"ci_workflow_files={len(workflows)}")
|
|
237
|
+
for j in job_names[:10]:
|
|
238
|
+
print(f" workflow: {j}")
|
|
239
|
+
if not test_files and not workflows:
|
|
240
|
+
status("FAIL")
|
|
241
|
+
elif not test_files or not workflows:
|
|
242
|
+
status("WARN")
|
|
243
|
+
else:
|
|
244
|
+
status("PASS")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _package_bin_entrypoints(root: Path) -> list[str]:
|
|
248
|
+
path = root / "package.json"
|
|
249
|
+
if not path.is_file():
|
|
250
|
+
return []
|
|
251
|
+
text = read_text(path, 200_000)
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(text)
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
return []
|
|
256
|
+
bin_field = data.get("bin")
|
|
257
|
+
name = str(data.get("name") or "package")
|
|
258
|
+
if isinstance(bin_field, str):
|
|
259
|
+
return [f"package.json bin:{name} -> {bin_field}"]
|
|
260
|
+
if isinstance(bin_field, dict):
|
|
261
|
+
return [
|
|
262
|
+
f"package.json bin:{cmd} -> {target}"
|
|
263
|
+
for cmd, target in sorted(bin_field.items())
|
|
264
|
+
if isinstance(cmd, str) and isinstance(target, str)
|
|
265
|
+
]
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _pyproject_script_entrypoints(root: Path) -> list[str]:
|
|
270
|
+
path = root / "pyproject.toml"
|
|
271
|
+
if not path.is_file():
|
|
272
|
+
return []
|
|
273
|
+
text = read_text(path, 200_000)
|
|
274
|
+
entries: list[str] = []
|
|
275
|
+
in_scripts = False
|
|
276
|
+
for line in text.splitlines():
|
|
277
|
+
stripped = line.strip()
|
|
278
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
279
|
+
in_scripts = stripped in {
|
|
280
|
+
"[project.scripts]",
|
|
281
|
+
"[tool.poetry.scripts]",
|
|
282
|
+
}
|
|
283
|
+
continue
|
|
284
|
+
if not in_scripts or not stripped or stripped.startswith("#"):
|
|
285
|
+
continue
|
|
286
|
+
m = re.match(r'([A-Za-z0-9_.-]+)\s*=\s*["\']([^"\']+)["\']', stripped)
|
|
287
|
+
if m:
|
|
288
|
+
entries.append(f"pyproject.toml script:{m.group(1)} -> {m.group(2)}")
|
|
289
|
+
return entries
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cargo_entrypoints(root: Path) -> list[str]:
|
|
293
|
+
entries: list[str] = []
|
|
294
|
+
cargo = root / "Cargo.toml"
|
|
295
|
+
if cargo.is_file():
|
|
296
|
+
text = read_text(cargo, 200_000)
|
|
297
|
+
if "[[bin]]" in text:
|
|
298
|
+
names = re.findall(r'(?m)^\s*name\s*=\s*["\']([^"\']+)["\']', text)
|
|
299
|
+
if names:
|
|
300
|
+
entries.extend(f"Cargo.toml bin:{name}" for name in sorted(set(names)))
|
|
301
|
+
else:
|
|
302
|
+
entries.append("Cargo.toml [[bin]]")
|
|
303
|
+
if (root / "src" / "main.rs").is_file():
|
|
304
|
+
entries.append("src/main.rs")
|
|
305
|
+
return entries
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cli_entrypoints(files: list[Path], root: Path) -> list[str]:
|
|
309
|
+
entries: set[str] = set()
|
|
310
|
+
entries.update(_package_bin_entrypoints(root))
|
|
311
|
+
entries.update(_pyproject_script_entrypoints(root))
|
|
312
|
+
entries.update(_cargo_entrypoints(root))
|
|
313
|
+
|
|
314
|
+
for path in files:
|
|
315
|
+
try:
|
|
316
|
+
parts = path.relative_to(root).parts
|
|
317
|
+
except ValueError:
|
|
318
|
+
continue
|
|
319
|
+
if not parts:
|
|
320
|
+
continue
|
|
321
|
+
if parts[0] == "bin" and len(parts) >= 2:
|
|
322
|
+
entries.add("/".join(parts[:2]))
|
|
323
|
+
if parts[0] == "cmd" and len(parts) >= 3 and path.suffix == ".go":
|
|
324
|
+
entries.add(f"cmd/{parts[1]}")
|
|
325
|
+
return sorted(entries)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _is_cli_contract_candidate(path: Path, root: Path) -> bool:
|
|
329
|
+
try:
|
|
330
|
+
parts = path.relative_to(root).parts
|
|
331
|
+
except ValueError:
|
|
332
|
+
return False
|
|
333
|
+
if not parts:
|
|
334
|
+
return False
|
|
335
|
+
lower_parts = tuple(p.lower() for p in parts)
|
|
336
|
+
name = lower_parts[-1]
|
|
337
|
+
if name in {"readme.md", "readme.txt", "agents.md", "claude.md"}:
|
|
338
|
+
return True
|
|
339
|
+
if lower_parts[0] in {"tests", "test", "spec", "scripts"}:
|
|
340
|
+
return True
|
|
341
|
+
if "test" in name or "spec" in name:
|
|
342
|
+
return True
|
|
343
|
+
if len(lower_parts) >= 3 and lower_parts[:2] == (".github", "workflows"):
|
|
344
|
+
return True
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cli_contract_evidence(files: list[Path], root: Path) -> dict[str, list[tuple[str, str]]]:
|
|
349
|
+
hits: dict[str, list[tuple[str, str]]] = {}
|
|
350
|
+
for path in files:
|
|
351
|
+
if not _is_cli_contract_candidate(path, root):
|
|
352
|
+
continue
|
|
353
|
+
text = read_text(path, 200_000)
|
|
354
|
+
if not text:
|
|
355
|
+
continue
|
|
356
|
+
for bucket, pattern in CLI_CONTRACT_BUCKETS:
|
|
357
|
+
m = pattern.search(text)
|
|
358
|
+
if m:
|
|
359
|
+
hits.setdefault(bucket, []).append((rel(path, root), m.group(0)))
|
|
360
|
+
return {bucket: sorted(values) for bucket, values in sorted(hits.items())}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def block_cli_contract_surface(files: list[Path], root: Path) -> None:
|
|
364
|
+
header("CLI CONTRACT SURFACE")
|
|
365
|
+
entries = cli_entrypoints(files, root)
|
|
366
|
+
if not entries:
|
|
367
|
+
print("(no CLI entrypoints detected)")
|
|
368
|
+
status("N/A")
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
print(f"entrypoints={len(entries)}")
|
|
372
|
+
for entry in entries[:12]:
|
|
373
|
+
print(f" entry: {entry}")
|
|
374
|
+
if len(entries) > 12:
|
|
375
|
+
print(f" ... {len(entries) - 12} more")
|
|
376
|
+
|
|
377
|
+
evidence = cli_contract_evidence(files, root)
|
|
378
|
+
covered = tuple(bucket for bucket, _ in CLI_CONTRACT_BUCKETS if bucket in evidence)
|
|
379
|
+
missing = tuple(bucket for bucket in CLI_CORE_BUCKETS if bucket not in evidence)
|
|
380
|
+
print(f"covered={','.join(covered) if covered else 'none'}")
|
|
381
|
+
print(f"missing={','.join(missing) if missing else 'none'}")
|
|
382
|
+
printed = 0
|
|
383
|
+
for bucket in covered:
|
|
384
|
+
for path, signal in evidence[bucket][:3]:
|
|
385
|
+
print(f" evidence: {bucket} {path} signal={signal}")
|
|
386
|
+
printed += 1
|
|
387
|
+
if printed >= 12:
|
|
388
|
+
break
|
|
389
|
+
if printed >= 12:
|
|
390
|
+
break
|
|
391
|
+
if not missing:
|
|
392
|
+
status("PASS")
|
|
393
|
+
else:
|
|
394
|
+
status("WARN")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _grep_version(path: Path, pattern: str) -> str | None:
|
|
398
|
+
text = read_text(path, 20_000)
|
|
399
|
+
if not text:
|
|
400
|
+
return None
|
|
401
|
+
m = re.search(pattern, text, re.MULTILINE)
|
|
402
|
+
return m.group(1).strip() if m else None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def block_version_sources(root: Path) -> None:
|
|
406
|
+
header("VERSION SOURCE COUNT")
|
|
407
|
+
found: list[tuple[str, str]] = []
|
|
408
|
+
v = root / "VERSION"
|
|
409
|
+
if v.is_file():
|
|
410
|
+
first = read_text(v).strip().splitlines()
|
|
411
|
+
if first:
|
|
412
|
+
found.append(("VERSION", first[0]))
|
|
413
|
+
probes = [
|
|
414
|
+
("package.json", r'"version"\s*:\s*"([^"]+)"'),
|
|
415
|
+
("Cargo.toml", r'^\s*version\s*=\s*"([^"]+)"'),
|
|
416
|
+
("pyproject.toml", r'^\s*version\s*=\s*"([^"]+)"'),
|
|
417
|
+
("setup.py", r"version\s*=\s*['\"]([^'\"]+)['\"]"),
|
|
418
|
+
]
|
|
419
|
+
for fname, pat in probes:
|
|
420
|
+
p = root / fname
|
|
421
|
+
if p.is_file():
|
|
422
|
+
v_str = _grep_version(p, pat)
|
|
423
|
+
if v_str:
|
|
424
|
+
found.append((fname, v_str))
|
|
425
|
+
for pat in ("*.podspec", "*.csproj"):
|
|
426
|
+
for path in root.glob(pat):
|
|
427
|
+
v_str = _grep_version(
|
|
428
|
+
path, r'(?i)version\s*[:=]\s*["\']?(\d+\.\d+\.\d+[\w.-]*)'
|
|
429
|
+
)
|
|
430
|
+
if v_str:
|
|
431
|
+
found.append((path.name, v_str))
|
|
432
|
+
for path in list(root.glob("build.gradle*")):
|
|
433
|
+
v_str = _grep_version(
|
|
434
|
+
path, r'(?i)version\s*[:=]\s*["\']?(\d+\.\d+\.\d+[\w.-]*)'
|
|
435
|
+
)
|
|
436
|
+
if v_str:
|
|
437
|
+
found.append((path.name, v_str))
|
|
438
|
+
if not found:
|
|
439
|
+
print("(no declared version source found)")
|
|
440
|
+
status("PASS")
|
|
441
|
+
return
|
|
442
|
+
for f, val in found:
|
|
443
|
+
print(f" {f}: {val}")
|
|
444
|
+
distinct = {val for _, val in found if val}
|
|
445
|
+
print(f"sources={len(found)} distinct_values={len(distinct)}")
|
|
446
|
+
if len(found) > 1 and len(distinct) > 1:
|
|
447
|
+
status("WARN")
|
|
448
|
+
else:
|
|
449
|
+
status("PASS")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def block_packaging_posture(root: Path) -> None:
|
|
453
|
+
header("PACKAGING FILTER POSTURE")
|
|
454
|
+
allowlist_files = list(root.glob("*.allowlist")) + list(root.glob("MANIFEST.in"))
|
|
455
|
+
pkg_scripts = (list(root.glob("scripts/package*.sh"))
|
|
456
|
+
+ list(root.glob("scripts/release*.sh")))
|
|
457
|
+
denylist_hits = 0
|
|
458
|
+
for sp in pkg_scripts:
|
|
459
|
+
for line in read_text(sp).splitlines():
|
|
460
|
+
if DENYLIST_HINT_RE.search(line):
|
|
461
|
+
denylist_hits += 1
|
|
462
|
+
if allowlist_files:
|
|
463
|
+
for f in allowlist_files:
|
|
464
|
+
print(f" allowlist: {rel(f, root)}")
|
|
465
|
+
print(f"posture=allowlist denylist_hits_in_scripts={denylist_hits}")
|
|
466
|
+
status("PASS")
|
|
467
|
+
return
|
|
468
|
+
if denylist_hits:
|
|
469
|
+
for sp in pkg_scripts:
|
|
470
|
+
print(f" script: {rel(sp, root)}")
|
|
471
|
+
print(f"posture=denylist denylist_hits_in_scripts={denylist_hits}")
|
|
472
|
+
status("WARN")
|
|
473
|
+
return
|
|
474
|
+
print("posture=none (no packaging scripts)")
|
|
475
|
+
status("N/A")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def block_install_url(root: Path) -> None:
|
|
479
|
+
header("INSTALL URL PINNING")
|
|
480
|
+
targets: list[Path] = [root / "README.md"]
|
|
481
|
+
targets += list(root.glob("scripts/setup*.sh"))
|
|
482
|
+
targets += list(root.glob("scripts/install*.sh"))
|
|
483
|
+
findings: list[tuple[str, int, str]] = []
|
|
484
|
+
for path in targets:
|
|
485
|
+
if not path.is_file():
|
|
486
|
+
continue
|
|
487
|
+
text = read_text(path, 200_000)
|
|
488
|
+
for i, line in enumerate(text.splitlines(), start=1):
|
|
489
|
+
for m in INSTALL_URL_RE.finditer(line):
|
|
490
|
+
findings.append((rel(path, root), i, m.group(1)))
|
|
491
|
+
if not findings:
|
|
492
|
+
print("(no raw.githubusercontent.com refs found)")
|
|
493
|
+
status("PASS")
|
|
494
|
+
return
|
|
495
|
+
moving = [f for f in findings if f[2] in ("main", "master", "HEAD")]
|
|
496
|
+
for f, ln, ref in findings[:20]:
|
|
497
|
+
marker = " [MOVING]" if ref in ("main", "master", "HEAD") else ""
|
|
498
|
+
print(f" {f}:{ln} ref={ref}{marker}")
|
|
499
|
+
print(f"total={len(findings)} moving={len(moving)}")
|
|
500
|
+
if moving:
|
|
501
|
+
status("WARN")
|
|
502
|
+
else:
|
|
503
|
+
status("PASS")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def block_agent_doc_dedup(root: Path) -> None:
|
|
507
|
+
header("AGENT DOC DEDUP")
|
|
508
|
+
claude = root / "CLAUDE.md"
|
|
509
|
+
agents = root / "AGENTS.md"
|
|
510
|
+
have_c = claude.exists() or claude.is_symlink()
|
|
511
|
+
have_a = agents.exists() or agents.is_symlink()
|
|
512
|
+
if not have_c and not have_a:
|
|
513
|
+
print("posture=none")
|
|
514
|
+
status("PASS")
|
|
515
|
+
return
|
|
516
|
+
if not (have_c and have_a):
|
|
517
|
+
print(f"posture=single-file ({'CLAUDE.md' if have_c else 'AGENTS.md'} only)")
|
|
518
|
+
status("PASS")
|
|
519
|
+
return
|
|
520
|
+
if claude.is_symlink() and claude.resolve(strict=False).name == "AGENTS.md":
|
|
521
|
+
print("posture=symlink (CLAUDE.md -> AGENTS.md)")
|
|
522
|
+
status("PASS")
|
|
523
|
+
return
|
|
524
|
+
if agents.is_symlink() and agents.resolve(strict=False).name == "CLAUDE.md":
|
|
525
|
+
print("posture=symlink (AGENTS.md -> CLAUDE.md)")
|
|
526
|
+
status("PASS")
|
|
527
|
+
return
|
|
528
|
+
a = read_text(claude)
|
|
529
|
+
b = read_text(agents)
|
|
530
|
+
if a and a == b:
|
|
531
|
+
print("posture=identical (consider symlink to dedup)")
|
|
532
|
+
status("WARN")
|
|
533
|
+
return
|
|
534
|
+
cross = ("AGENTS.md" in a) or ("CLAUDE.md" in b)
|
|
535
|
+
a_set = {ln.strip() for ln in a.splitlines()
|
|
536
|
+
if ln.strip() and not ln.strip().startswith("#")}
|
|
537
|
+
b_set = {ln.strip() for ln in b.splitlines()
|
|
538
|
+
if ln.strip() and not ln.strip().startswith("#")}
|
|
539
|
+
union = a_set | b_set
|
|
540
|
+
jaccard = len(a_set & b_set) / len(union) if union else 0.0
|
|
541
|
+
print(f"jaccard={jaccard:.2f} cross_refs={cross}")
|
|
542
|
+
if jaccard >= 0.20:
|
|
543
|
+
print("posture=divergent-overlap (drift risk; consider symlink)")
|
|
544
|
+
status("WARN")
|
|
545
|
+
return
|
|
546
|
+
if cross:
|
|
547
|
+
print("posture=cross-ref (one references the other)")
|
|
548
|
+
status("WARN")
|
|
549
|
+
return
|
|
550
|
+
print("posture=independent")
|
|
551
|
+
status("PASS")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def block_drift_markers(files: list[Path], root: Path) -> None:
|
|
555
|
+
header("DRIFT MARKERS")
|
|
556
|
+
counts: list[tuple[str, int]] = []
|
|
557
|
+
total = 0
|
|
558
|
+
for path in files:
|
|
559
|
+
if path.suffix not in SOURCE_EXTS:
|
|
560
|
+
continue
|
|
561
|
+
text = read_text(path, 200_000)
|
|
562
|
+
if not text:
|
|
563
|
+
continue
|
|
564
|
+
n = len(MARKER_RE.findall(text))
|
|
565
|
+
if n:
|
|
566
|
+
counts.append((rel(path, root), n))
|
|
567
|
+
total += n
|
|
568
|
+
counts.sort(key=lambda x: -x[1])
|
|
569
|
+
for f, n in counts[:5]:
|
|
570
|
+
print(f" {n:>4} {f}")
|
|
571
|
+
print(f"total={total}")
|
|
572
|
+
if total >= DRIFT_FAIL:
|
|
573
|
+
status("FAIL")
|
|
574
|
+
elif total >= DRIFT_WARN:
|
|
575
|
+
status("WARN")
|
|
576
|
+
else:
|
|
577
|
+
status("PASS")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def block_duplicate_setup(root: Path) -> None:
|
|
581
|
+
header("DUPLICATE SETUP SCRIPTS")
|
|
582
|
+
scripts = (list(root.glob("scripts/setup-*.sh"))
|
|
583
|
+
+ list(root.glob("scripts/install-*.sh")))
|
|
584
|
+
if len(scripts) < 2:
|
|
585
|
+
print("(fewer than 2 setup-* scripts to compare)")
|
|
586
|
+
status("N/A")
|
|
587
|
+
return
|
|
588
|
+
sets: dict[Path, set[str]] = {}
|
|
589
|
+
for sp in scripts:
|
|
590
|
+
sets[sp] = {ln.strip() for ln in read_text(sp).splitlines()
|
|
591
|
+
if ln.strip() and not ln.strip().startswith("#")}
|
|
592
|
+
pairs: list[tuple[str, str, float]] = []
|
|
593
|
+
names = list(sets.keys())
|
|
594
|
+
for i, a in enumerate(names):
|
|
595
|
+
for b in names[i + 1:]:
|
|
596
|
+
union = sets[a] | sets[b]
|
|
597
|
+
if not union:
|
|
598
|
+
continue
|
|
599
|
+
j = len(sets[a] & sets[b]) / len(union)
|
|
600
|
+
if j >= DUP_JACCARD:
|
|
601
|
+
pairs.append((rel(a, root), rel(b, root), j))
|
|
602
|
+
if not pairs:
|
|
603
|
+
print("(no setup pairs with jaccard >= 0.70)")
|
|
604
|
+
status("PASS")
|
|
605
|
+
return
|
|
606
|
+
for a, b, j in pairs:
|
|
607
|
+
print(f" {a} vs {b} jaccard={j:.2f}")
|
|
608
|
+
status("WARN")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def block_denylist_in_build(root: Path) -> None:
|
|
612
|
+
header("DENYLIST IN BUILD")
|
|
613
|
+
targets = (list(root.glob("scripts/package*.sh"))
|
|
614
|
+
+ list(root.glob("scripts/release*.sh"))
|
|
615
|
+
+ [root / "Makefile", root / "Justfile"])
|
|
616
|
+
real_targets = [p for p in targets if p.is_file()]
|
|
617
|
+
if not real_targets:
|
|
618
|
+
print("(no build scripts present)")
|
|
619
|
+
status("N/A")
|
|
620
|
+
return
|
|
621
|
+
hits: list[tuple[str, int, str]] = []
|
|
622
|
+
for path in real_targets:
|
|
623
|
+
text = read_text(path, 100_000)
|
|
624
|
+
for i, line in enumerate(text.splitlines(), start=1):
|
|
625
|
+
if DENYLIST_HINT_RE.search(line):
|
|
626
|
+
hits.append((rel(path, root), i, line.strip()[:80]))
|
|
627
|
+
if not hits:
|
|
628
|
+
print("(no denylist patterns found in build scripts)")
|
|
629
|
+
status("PASS")
|
|
630
|
+
return
|
|
631
|
+
for f, ln, s in hits[:20]:
|
|
632
|
+
print(f" {f}:{ln} {s}")
|
|
633
|
+
status("WARN")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def main() -> int:
|
|
637
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
638
|
+
parser.add_argument(
|
|
639
|
+
"--root", type=Path, default=Path.cwd(),
|
|
640
|
+
help="Project root to audit (default: current working directory)",
|
|
641
|
+
)
|
|
642
|
+
args = parser.parse_args()
|
|
643
|
+
root = args.root.resolve()
|
|
644
|
+
if not root.is_dir():
|
|
645
|
+
print(f"audit_signals: not a directory: {root}", file=sys.stderr)
|
|
646
|
+
return 2
|
|
647
|
+
files = iter_files(root)
|
|
648
|
+
print(f"project_root: {root}")
|
|
649
|
+
print(f"files_scanned: {len(files)}")
|
|
650
|
+
print()
|
|
651
|
+
block_hotspots(files, root); print()
|
|
652
|
+
block_heredoc(files, root); print()
|
|
653
|
+
block_test_ci(files, root); print()
|
|
654
|
+
block_cli_contract_surface(files, root); print()
|
|
655
|
+
block_version_sources(root); print()
|
|
656
|
+
block_packaging_posture(root); print()
|
|
657
|
+
block_install_url(root); print()
|
|
658
|
+
block_agent_doc_dedup(root); print()
|
|
659
|
+
block_drift_markers(files, root); print()
|
|
660
|
+
block_duplicate_setup(root); print()
|
|
661
|
+
block_denylist_in_build(root)
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
if __name__ == "__main__":
|
|
666
|
+
sys.exit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Auto-detect and run project verification (lint + typecheck + tests).
|
|
3
|
+
# Run from the project root. Exits non-zero on failure.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
if [ -f Cargo.toml ]; then
|
|
7
|
+
cargo check && cargo test
|
|
8
|
+
elif [ -f tsconfig.json ]; then
|
|
9
|
+
npx tsc --noEmit && npm test
|
|
10
|
+
elif [ -f package.json ] && grep -q '"test"' package.json; then
|
|
11
|
+
npm test
|
|
12
|
+
elif [ -f Makefile ] && grep -q '^test:' Makefile; then
|
|
13
|
+
make test
|
|
14
|
+
elif [ -f pytest.ini ] || [ -f pyproject.toml ] || find . -maxdepth 2 -name "test_*.py" | grep -q .; then
|
|
15
|
+
pytest
|
|
16
|
+
else
|
|
17
|
+
echo "(no test command detected - ask the user for the verification command)"
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|