@jenga-ai/agent 1.1.0 → 1.1.1
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 +7 -3
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +140 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/package.json +1 -1
- package/scripts/check-permission-level.sh +107 -0
- package/scripts/check-publicignore-match.sh +122 -0
- package/scripts/check-worktree-liveness.sh +193 -0
- package/scripts/generate-rapport-manifest.sh +43 -0
- package/scripts/idea_manager.sh +47 -0
- package/scripts/install-worktree-commit-guard.sh +134 -0
- package/scripts/jenga-permission-level-switch.sh +109 -0
- package/scripts/smoke-harness.sh +139 -0
- package/scripts/validate-board.sh +62 -0
- package/scripts/with-lock.sh +158 -0
- package/scripts/worktree-remove-guard.sh +204 -0
- package/skills/clearify/SKILL.md +52 -0
- package/skills/commit/SKILL.md +13 -4
- package/skills/distribute/CONFIG_SCHEMA.md +60 -2
- package/skills/do/SKILL.md +48 -11
- package/skills/doc-sync/SKILL.md +16 -0
- package/skills/doc-sync/assets/doc_targets.md +11 -0
- package/skills/idea/SKILL.md +56 -0
- package/skills/idea/assets/idea_handoff_template.md +26 -0
- package/skills/idea/assets/idea_template.md +3 -0
- package/skills/init/SKILL.md +100 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/workflow_template.json +1 -1
- package/skills/init/scripts/apply-project-visibility.sh +176 -0
- package/skills/init/scripts/detect-existing-codebase.sh +166 -0
- package/skills/init/scripts/init.sh +30 -1
- package/skills/jenga/SKILL.md +160 -17
- package/skills/jenga/scripts/board-scan.sh +238 -0
- package/skills/jenga/scripts/cascade-resolve.sh +297 -0
- package/skills/jenga/scripts/render-confirmation.sh +679 -0
- package/skills/jenga/scripts/render-picker.sh +439 -0
- package/skills/jenga/scripts/resolve-id.sh +367 -0
- package/skills/jenga-permission-level/SKILL.md +81 -0
- package/skills/proceed/SKILL.md +1 -1
- package/skills/publish/SKILL.md +8 -5
- package/skills/publish/assets/ci-contract.md +2 -2
- package/skills/publish/assets/ownership-matrix.md +1 -1
- package/skills/publish/scripts/finalize_changelog.sh +115 -0
- package/skills/publish/scripts/generate_release_notes.sh +475 -28
- package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
- package/skills/publish/scripts/publish_deploy.sh +38 -8
- package/skills/publish/scripts/run_gates.sh +2 -2
- package/skills/reconcile/SKILL.md +117 -5
- package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
- package/skills/spinoff/SKILL.md +12 -7
- package/skills/todo/SKILL.md +2 -0
- package/skills/uncharted/SKILL.md +711 -0
- package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
- package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
- package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
- package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
- package/skills/uncharted/scripts/detect-tests.sh +553 -0
- package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
- package/skills/uncharted/scripts/enumerate-target.sh +470 -0
- package/skills/uncharted/scripts/import-source.sh +517 -0
- package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
- package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
- package/skills/uncharted/scripts/run-engine.sh +655 -0
- package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
- package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
- package/skills/wtf/SKILL.md +20 -0
- package/templates/CHANGELOG_TEMPLATE.md +13 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +157 -10
- package/templates/permission-levels/README.md +73 -0
- package/templates/permission-levels/level-1-locked.json +71 -0
- package/templates/permission-levels/level-2-guarded.json +64 -0
- package/templates/permission-levels/level-3-standard.json +62 -0
- package/templates/permission-levels/level-4-elevated.json +60 -0
- package/templates/permission-levels/level-5-unrestricted.json +58 -0
- package/skills/convert/SKILL.md +0 -124
- package/skills/convert/convert_cli.py +0 -235
- package/skills/convert/tests/sample.csv +0 -4
- package/skills/convert/tests/sample.json +0 -5
- package/skills/convert/tests/sample.jsonl +0 -3
- package/skills/convert/tests/sample.yaml +0 -18
- package/skills/convert/tests/sample_obj.csv +0 -2
- package/skills/convert/tests/sample_obj.json +0 -9
- package/skills/mirror-public/SKILL.md +0 -237
- package/skills/mirror-public/assets/config.json +0 -5
- package/skills/mirror-public/scripts/mirror.sh +0 -374
- package/skills/self-sync/SKILL.md +0 -73
- package/skills/self-sync/scripts/run.js +0 -136
- package/skills/strategy/SKILL.md +0 -312
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# detect-tests.sh — test-coverage detection for the /uncharted investigative engine
|
|
3
|
+
#
|
|
4
|
+
# Usage: detect-tests.sh <target-path>
|
|
5
|
+
# detect-tests.sh --help
|
|
6
|
+
#
|
|
7
|
+
# Reports, as JSON on stdout, whether test coverage exists for <target-path>, using
|
|
8
|
+
# conventional signals only (no test execution, no coverage instrumentation):
|
|
9
|
+
#
|
|
10
|
+
# - test directories test/ tests/ spec/ specs/ __tests__/ e2e/ testing/
|
|
11
|
+
# - test filenames *_test.* *.test.* test_*.* *_spec.* *.spec.*
|
|
12
|
+
# *Test.java *Tests.kt *.bats conftest.py
|
|
13
|
+
# - test-runner config pytest.ini, tox.ini, jest.config.*, vitest.config.*,
|
|
14
|
+
# .mocharc*, phpunit.xml, playwright.config.*,
|
|
15
|
+
# cypress.config.*, .rspec, Cargo.toml, go.mod,
|
|
16
|
+
# package.json with a scripts.test entry, and this
|
|
17
|
+
# framework's own project/configs/test-config.json
|
|
18
|
+
#
|
|
19
|
+
# The distinguishing feature of this script is that it separates two very different
|
|
20
|
+
# situations that a naive "does the repo have tests?" grep conflates:
|
|
21
|
+
#
|
|
22
|
+
# "covered" — tests exist FOR THIS TARGET (test files live inside the
|
|
23
|
+
# target, or test files elsewhere reference it by name/path)
|
|
24
|
+
# "repo_tests_only" — the repo HAS tests, but none of them touch this target
|
|
25
|
+
# "no_tests_in_repo" — no test files were found anywhere in the repo
|
|
26
|
+
#
|
|
27
|
+
# This script is STRICTLY READ-ONLY. It creates no files, no temp files, and mutates
|
|
28
|
+
# no git state. Its only outputs are JSON on stdout and diagnostics on stderr.
|
|
29
|
+
#
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Output contract (stdout, JSON) — consumed by skills/uncharted/scripts/run-engine.sh
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# {
|
|
34
|
+
# "script": "detect-tests.sh",
|
|
35
|
+
# "version": 1,
|
|
36
|
+
# "target": "<repo-relative path to the target>",
|
|
37
|
+
# "target_absolute": "<absolute path to the target>",
|
|
38
|
+
# "target_type": "file" | "directory",
|
|
39
|
+
# "repo_root": "<absolute path to the repository root>",
|
|
40
|
+
#
|
|
41
|
+
# "coverage_status": "covered" | "repo_tests_only" | "no_tests_in_repo",
|
|
42
|
+
# "coverage_summary": "<one-sentence human-readable verdict>",
|
|
43
|
+
# "repo_has_tests": <bool>,
|
|
44
|
+
#
|
|
45
|
+
# "target_test_files": [ "<repo-relative test file located inside the target>" ],
|
|
46
|
+
# "referencing_test_files": [
|
|
47
|
+
# {
|
|
48
|
+
# "path": "<repo-relative test file outside the target>",
|
|
49
|
+
# "matched": [ "<reference name(s) that test file mentions>" ]
|
|
50
|
+
# }
|
|
51
|
+
# ],
|
|
52
|
+
# "test_directories": [ "<repo-relative directory>" ],
|
|
53
|
+
# "test_runner_configs": [
|
|
54
|
+
# {
|
|
55
|
+
# "name": "package.json",
|
|
56
|
+
# "path": "<repo-relative path>",
|
|
57
|
+
# "runner": "<inferred runner, e.g. npm test / pytest / jest>",
|
|
58
|
+
# "detail": "<extra context, or null>"
|
|
59
|
+
# }
|
|
60
|
+
# ],
|
|
61
|
+
#
|
|
62
|
+
# "reference_names": [ "<name/path used to match test files against the target>" ],
|
|
63
|
+
# "repo_test_file_count": <int>,
|
|
64
|
+
# "repo_test_files_read": <int>, // capped; see notices when the cap is hit
|
|
65
|
+
# "target_files_considered": <int>,
|
|
66
|
+
# "files_walked": <int>,
|
|
67
|
+
# "walk_truncated": <bool>,
|
|
68
|
+
# "notices": [ "<non-fatal diagnostic>", ... ]
|
|
69
|
+
# }
|
|
70
|
+
#
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Behaviour notes
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# - Reference-name matching drops generic/short tokens (index, main, utils, run,
|
|
75
|
+
# config, __init__, anything under 4 characters) to keep "covered" meaningful
|
|
76
|
+
# rather than generic. Every referencing entry reports WHICH names it matched so
|
|
77
|
+
# the calling agent can judge the strength of the signal itself.
|
|
78
|
+
# - Unrecognised language / no test conventions present: NOT an error. The script
|
|
79
|
+
# exits 0 with "coverage_status": "no_tests_in_repo" and an explanatory notice.
|
|
80
|
+
# - Target outside a git repository: NOT an error. repo_root falls back to the
|
|
81
|
+
# target's own directory and a notice is emitted.
|
|
82
|
+
# - .claude/worktrees is never descended into: it holds full copies of the repo and
|
|
83
|
+
# would multiply every count.
|
|
84
|
+
#
|
|
85
|
+
# Exit codes:
|
|
86
|
+
# 0 — success (including every graceful-degradation path above)
|
|
87
|
+
# 1 — usage error (wrong number of arguments)
|
|
88
|
+
# 2 — target path does not exist
|
|
89
|
+
|
|
90
|
+
set -euo pipefail
|
|
91
|
+
|
|
92
|
+
usage() {
|
|
93
|
+
cat <<EOF
|
|
94
|
+
Usage: $(basename "$0") <target-path>
|
|
95
|
+
|
|
96
|
+
Prints a JSON report describing whether test coverage exists for a file or
|
|
97
|
+
directory, distinguishing "tests exist for this target" from "the repo has
|
|
98
|
+
tests, but none reference this target".
|
|
99
|
+
|
|
100
|
+
Arguments:
|
|
101
|
+
<target-path> File or directory to analyse.
|
|
102
|
+
|
|
103
|
+
Options:
|
|
104
|
+
-h, --help Show this help and exit.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
$(basename "$0") skills/reconcile/
|
|
108
|
+
$(basename "$0") scripts/board_resolver.sh
|
|
109
|
+
EOF
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Argument validation
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
case "${1:-}" in
|
|
117
|
+
-h|--help)
|
|
118
|
+
usage
|
|
119
|
+
exit 0
|
|
120
|
+
;;
|
|
121
|
+
esac
|
|
122
|
+
|
|
123
|
+
if [[ $# -ne 1 ]]; then
|
|
124
|
+
echo "Error: expected exactly 1 argument, got $#" >&2
|
|
125
|
+
usage >&2
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
TARGET="$1"
|
|
130
|
+
|
|
131
|
+
if [[ ! -e "$TARGET" ]]; then
|
|
132
|
+
echo "Error: target path does not exist: $TARGET" >&2
|
|
133
|
+
exit 2
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Resolve absolute target path and repository root
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
if [[ -d "$TARGET" ]]; then
|
|
141
|
+
TARGET_ABS=$(cd "$TARGET" && pwd)
|
|
142
|
+
TARGET_DIR="$TARGET_ABS"
|
|
143
|
+
else
|
|
144
|
+
TARGET_DIR=$(cd "$(dirname "$TARGET")" && pwd)
|
|
145
|
+
TARGET_ABS="$TARGET_DIR/$(basename "$TARGET")"
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
REPO_ROOT=$(git -C "$TARGET_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Scan and emit
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
python3 - "$TARGET_ABS" "$REPO_ROOT" <<'PY'
|
|
155
|
+
import json
|
|
156
|
+
import os
|
|
157
|
+
import re
|
|
158
|
+
import sys
|
|
159
|
+
|
|
160
|
+
TARGET = sys.argv[1]
|
|
161
|
+
REPO_ROOT = sys.argv[2]
|
|
162
|
+
|
|
163
|
+
notices = []
|
|
164
|
+
|
|
165
|
+
if not REPO_ROOT:
|
|
166
|
+
REPO_ROOT = TARGET if os.path.isdir(TARGET) else os.path.dirname(TARGET)
|
|
167
|
+
notices.append(
|
|
168
|
+
"Target is not inside a git repository; repo root fell back to '%s'. "
|
|
169
|
+
"The repo-wide test sweep is limited to that directory." % REPO_ROOT
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
MAX_WALK_FILES = 20000
|
|
173
|
+
MAX_TEST_FILES_READ = 500
|
|
174
|
+
MAX_REFERENCE_NAMES = 300
|
|
175
|
+
MAX_BYTES = 512 * 1024
|
|
176
|
+
MAX_MATCHES_PER_FILE = 8
|
|
177
|
+
|
|
178
|
+
SKIP_DIRS = {
|
|
179
|
+
".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build",
|
|
180
|
+
".next", "coverage", "vendor", "target", ".mypy_cache", ".pytest_cache",
|
|
181
|
+
".tox", ".gradle", ".idea", ".svn", ".hg", "bower_components", ".terraform",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
TEST_DIR_NAMES = {"test", "tests", "spec", "specs", "__tests__", "e2e", "testing"}
|
|
185
|
+
|
|
186
|
+
TEST_FILE_PATTERNS = [
|
|
187
|
+
re.compile(r".+_test\.[A-Za-z0-9]+$"),
|
|
188
|
+
re.compile(r".+\.test\.[A-Za-z0-9]+$"),
|
|
189
|
+
re.compile(r"^test_.+\.[A-Za-z0-9]+$"),
|
|
190
|
+
re.compile(r".+_spec\.[A-Za-z0-9]+$"),
|
|
191
|
+
re.compile(r".+\.spec\.[A-Za-z0-9]+$"),
|
|
192
|
+
re.compile(r".+Tests?\.(?:java|kt|kts|cs|scala)$"),
|
|
193
|
+
re.compile(r".+\.bats$"),
|
|
194
|
+
re.compile(r"^conftest\.py$"),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
# A file inside a test directory only counts as a test file when it looks like source.
|
|
198
|
+
SOURCE_EXTS = {
|
|
199
|
+
".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".py", ".go", ".rs", ".rb",
|
|
200
|
+
".sh", ".bash", ".bats", ".c", ".h", ".cc", ".cpp", ".java", ".kt", ".kts",
|
|
201
|
+
".php", ".scala", ".cs", ".swift", ".exs",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
RUNNER_EXACT = {
|
|
205
|
+
"pytest.ini": "pytest",
|
|
206
|
+
"conftest.py": "pytest",
|
|
207
|
+
"tox.ini": "tox",
|
|
208
|
+
"noxfile.py": "nox",
|
|
209
|
+
"phpunit.xml": "phpunit",
|
|
210
|
+
"phpunit.xml.dist": "phpunit",
|
|
211
|
+
".rspec": "rspec",
|
|
212
|
+
"Rakefile": "rake",
|
|
213
|
+
"Cargo.toml": "cargo test",
|
|
214
|
+
"go.mod": "go test",
|
|
215
|
+
"karma.conf.js": "karma",
|
|
216
|
+
"vitest.workspace.ts": "vitest",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
RUNNER_PATTERNS = [
|
|
220
|
+
(re.compile(r"^jest\.config\.[A-Za-z]+$"), "jest"),
|
|
221
|
+
(re.compile(r"^vitest\.config\.[A-Za-z]+$"), "vitest"),
|
|
222
|
+
(re.compile(r"^\.mocharc(\..+)?$"), "mocha"),
|
|
223
|
+
(re.compile(r"^playwright\.config\.[A-Za-z]+$"), "playwright"),
|
|
224
|
+
(re.compile(r"^cypress\.config\.[A-Za-z]+$"), "cypress"),
|
|
225
|
+
(re.compile(r"^karma\.conf\.[A-Za-z]+$"), "karma"),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
MAX_RUNNER_CONFIGS = 30
|
|
229
|
+
|
|
230
|
+
# Tokens too generic to prove a test references THIS target.
|
|
231
|
+
GENERIC_NAMES = {
|
|
232
|
+
"index", "main", "util", "utils", "run", "test", "tests", "spec", "setup",
|
|
233
|
+
"config", "configs", "types", "const", "consts", "constants", "helper",
|
|
234
|
+
"helpers", "common", "core", "app", "cli", "api", "data", "init", "__init__",
|
|
235
|
+
"package", "readme", "license", "makefile", "base", "lib", "src", "docs",
|
|
236
|
+
"script", "scripts", "assets", "template", "templates", "schema", "server",
|
|
237
|
+
"client", "model", "models", "view", "views", "style", "styles", "build",
|
|
238
|
+
}
|
|
239
|
+
MIN_NAME_LENGTH = 4
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def rel(path):
|
|
243
|
+
"""Repo-relative POSIX path, falling back to the absolute path when outside the repo."""
|
|
244
|
+
try:
|
|
245
|
+
r = os.path.relpath(path, REPO_ROOT)
|
|
246
|
+
except ValueError:
|
|
247
|
+
return path
|
|
248
|
+
if r.startswith(".."):
|
|
249
|
+
return path
|
|
250
|
+
return r.replace(os.sep, "/")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def prune(dirpath, dirnames):
|
|
254
|
+
"""In-place SKIP_DIRS pruning; .claude/worktrees holds full repo copies."""
|
|
255
|
+
keep = []
|
|
256
|
+
for d in sorted(dirnames):
|
|
257
|
+
if d in SKIP_DIRS:
|
|
258
|
+
continue
|
|
259
|
+
if d == "worktrees" and os.path.basename(dirpath) == ".claude":
|
|
260
|
+
continue
|
|
261
|
+
keep.append(d)
|
|
262
|
+
dirnames[:] = keep
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def read_text(path):
|
|
266
|
+
try:
|
|
267
|
+
if os.path.getsize(path) > MAX_BYTES:
|
|
268
|
+
return None
|
|
269
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
270
|
+
return fh.read()
|
|
271
|
+
except OSError:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def looks_like_test_file(filename, in_test_dir):
|
|
276
|
+
for pattern in TEST_FILE_PATTERNS:
|
|
277
|
+
if pattern.match(filename):
|
|
278
|
+
return True
|
|
279
|
+
if in_test_dir and os.path.splitext(filename)[1].lower() in SOURCE_EXTS:
|
|
280
|
+
return True
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def classify_runner(filename):
|
|
285
|
+
if filename in RUNNER_EXACT:
|
|
286
|
+
return RUNNER_EXACT[filename]
|
|
287
|
+
for pattern, runner in RUNNER_PATTERNS:
|
|
288
|
+
if pattern.match(filename):
|
|
289
|
+
return runner
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Step 1 — collect the target's own files and derive reference names
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
is_dir = os.path.isdir(TARGET)
|
|
298
|
+
target_rel = rel(TARGET)
|
|
299
|
+
|
|
300
|
+
target_files = []
|
|
301
|
+
if is_dir:
|
|
302
|
+
for dirpath, dirnames, filenames in os.walk(TARGET):
|
|
303
|
+
prune(dirpath, dirnames)
|
|
304
|
+
for fn in sorted(filenames):
|
|
305
|
+
target_files.append(os.path.join(dirpath, fn))
|
|
306
|
+
if len(target_files) >= MAX_WALK_FILES:
|
|
307
|
+
break
|
|
308
|
+
if len(target_files) >= MAX_WALK_FILES:
|
|
309
|
+
notices.append("Target file enumeration stopped at the %d-file cap." % MAX_WALK_FILES)
|
|
310
|
+
break
|
|
311
|
+
else:
|
|
312
|
+
target_files.append(TARGET)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def keep_name(name):
|
|
317
|
+
"""Path-like names are always specific enough; bare names must clear the generic filter."""
|
|
318
|
+
if "/" in name:
|
|
319
|
+
return True
|
|
320
|
+
return len(name) >= MIN_NAME_LENGTH and name.lower() not in GENERIC_NAMES
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
reference_names = set()
|
|
324
|
+
if keep_name(target_rel):
|
|
325
|
+
reference_names.add(target_rel)
|
|
326
|
+
if is_dir:
|
|
327
|
+
base = os.path.basename(TARGET.rstrip(os.sep))
|
|
328
|
+
if keep_name(base):
|
|
329
|
+
reference_names.add(base)
|
|
330
|
+
|
|
331
|
+
for file_path in target_files:
|
|
332
|
+
file_rel = rel(file_path)
|
|
333
|
+
reference_names.add(file_rel)
|
|
334
|
+
basename = os.path.basename(file_path)
|
|
335
|
+
stem = os.path.splitext(basename)[0]
|
|
336
|
+
for candidate in (basename, stem):
|
|
337
|
+
if keep_name(candidate):
|
|
338
|
+
reference_names.add(candidate)
|
|
339
|
+
|
|
340
|
+
reference_names = sorted(reference_names)
|
|
341
|
+
if len(reference_names) > MAX_REFERENCE_NAMES:
|
|
342
|
+
notices.append(
|
|
343
|
+
"Target yielded %d reference names; truncated to the first %d for matching."
|
|
344
|
+
% (len(reference_names), MAX_REFERENCE_NAMES)
|
|
345
|
+
)
|
|
346
|
+
reference_names = reference_names[:MAX_REFERENCE_NAMES]
|
|
347
|
+
|
|
348
|
+
path_names = [n for n in reference_names if "/" in n]
|
|
349
|
+
word_names = [n for n in reference_names if "/" not in n]
|
|
350
|
+
word_re = None
|
|
351
|
+
if word_names:
|
|
352
|
+
alternation = "|".join(re.escape(n) for n in sorted(word_names, key=len, reverse=True))
|
|
353
|
+
word_re = re.compile(r"(?<![\w-])(" + alternation + r")(?![\w-])")
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Step 2 — sweep the repository for test signals
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
target_prefix = TARGET.rstrip(os.sep) + os.sep
|
|
360
|
+
|
|
361
|
+
hidden_pruned = []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def on_target_path(path):
|
|
365
|
+
"""True when path is the target, contains it, or lives inside it."""
|
|
366
|
+
return path == TARGET or TARGET.startswith(path + os.sep) or path.startswith(target_prefix)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def prune_repo(dirpath, dirnames):
|
|
370
|
+
"""Repo-sweep pruning: SKIP_DIRS plus hidden directories.
|
|
371
|
+
|
|
372
|
+
Hidden directories are skipped because they overwhelmingly hold tooling and
|
|
373
|
+
generated mirrors rather than a project's real tests -- in this framework,
|
|
374
|
+
.agents/ and .claude/ are build outputs that duplicate every skill under
|
|
375
|
+
skills/, which would triple every count here. A hidden directory is still
|
|
376
|
+
descended into when the target itself lives on that path, and anything pruned
|
|
377
|
+
is reported in "notices" rather than dropped silently.
|
|
378
|
+
"""
|
|
379
|
+
prune(dirpath, dirnames)
|
|
380
|
+
keep = []
|
|
381
|
+
for d in dirnames:
|
|
382
|
+
full = os.path.join(dirpath, d)
|
|
383
|
+
if d.startswith(".") and not on_target_path(full):
|
|
384
|
+
hidden_pruned.append(rel(full))
|
|
385
|
+
continue
|
|
386
|
+
keep.append(d)
|
|
387
|
+
dirnames[:] = keep
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
test_directories = []
|
|
391
|
+
repo_test_files = []
|
|
392
|
+
runner_configs = []
|
|
393
|
+
files_walked = 0
|
|
394
|
+
walk_truncated = False
|
|
395
|
+
|
|
396
|
+
for dirpath, dirnames, filenames in os.walk(REPO_ROOT):
|
|
397
|
+
prune_repo(dirpath, dirnames)
|
|
398
|
+
for d in dirnames:
|
|
399
|
+
if d.lower() in TEST_DIR_NAMES:
|
|
400
|
+
test_directories.append(rel(os.path.join(dirpath, d)))
|
|
401
|
+
in_test_dir = any(part.lower() in TEST_DIR_NAMES for part in rel(dirpath).split("/"))
|
|
402
|
+
for fn in sorted(filenames):
|
|
403
|
+
files_walked += 1
|
|
404
|
+
if files_walked > MAX_WALK_FILES:
|
|
405
|
+
walk_truncated = True
|
|
406
|
+
break
|
|
407
|
+
full = os.path.join(dirpath, fn)
|
|
408
|
+
if looks_like_test_file(fn, in_test_dir):
|
|
409
|
+
repo_test_files.append(full)
|
|
410
|
+
if len(runner_configs) < MAX_RUNNER_CONFIGS:
|
|
411
|
+
runner = classify_runner(fn)
|
|
412
|
+
detail = None
|
|
413
|
+
if fn == "package.json":
|
|
414
|
+
text = read_text(full)
|
|
415
|
+
try:
|
|
416
|
+
scripts = (json.loads(text) or {}).get("scripts") or {}
|
|
417
|
+
except Exception:
|
|
418
|
+
scripts = {}
|
|
419
|
+
if isinstance(scripts, dict) and scripts.get("test"):
|
|
420
|
+
runner = "npm test"
|
|
421
|
+
detail = str(scripts["test"])[:200]
|
|
422
|
+
elif fn == "test-config.json" and rel(dirpath).endswith("project/configs"):
|
|
423
|
+
runner = "jenga test-config"
|
|
424
|
+
text = read_text(full)
|
|
425
|
+
try:
|
|
426
|
+
tools = (json.loads(text) or {}).get("tools") or []
|
|
427
|
+
named = [t.get("tool_name") for t in tools if isinstance(t, dict)]
|
|
428
|
+
detail = "declares: " + ", ".join(n for n in named if n and n != "-")
|
|
429
|
+
except Exception:
|
|
430
|
+
detail = None
|
|
431
|
+
if runner:
|
|
432
|
+
runner_configs.append({
|
|
433
|
+
"name": fn,
|
|
434
|
+
"path": rel(full),
|
|
435
|
+
"runner": runner,
|
|
436
|
+
"detail": detail,
|
|
437
|
+
})
|
|
438
|
+
if walk_truncated:
|
|
439
|
+
break
|
|
440
|
+
|
|
441
|
+
if walk_truncated:
|
|
442
|
+
notices.append(
|
|
443
|
+
"Repository sweep stopped at the %d-file cap; test discovery is partial rather than "
|
|
444
|
+
"silently complete." % MAX_WALK_FILES
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
if hidden_pruned:
|
|
448
|
+
shown = sorted(set(hidden_pruned))
|
|
449
|
+
notices.append(
|
|
450
|
+
"Skipped %d hidden director%s during the repository sweep (generated mirrors and tooling "
|
|
451
|
+
"directories duplicate real source trees): %s%s"
|
|
452
|
+
% (
|
|
453
|
+
len(shown),
|
|
454
|
+
"y" if len(shown) == 1 else "ies",
|
|
455
|
+
", ".join(shown[:10]),
|
|
456
|
+
"" if len(shown) <= 10 else ", ...",
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
test_directories = sorted(set(test_directories))
|
|
461
|
+
repo_test_files = sorted(set(repo_test_files))
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# Step 3 — split target-local tests from tests elsewhere that reference the target
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
target_test_files = []
|
|
468
|
+
other_test_files = []
|
|
469
|
+
for path in repo_test_files:
|
|
470
|
+
if path == TARGET or path.startswith(target_prefix):
|
|
471
|
+
target_test_files.append(path)
|
|
472
|
+
else:
|
|
473
|
+
other_test_files.append(path)
|
|
474
|
+
|
|
475
|
+
referencing = []
|
|
476
|
+
read_count = 0
|
|
477
|
+
for path in other_test_files:
|
|
478
|
+
if read_count >= MAX_TEST_FILES_READ:
|
|
479
|
+
notices.append(
|
|
480
|
+
"Stopped reading test files at the %d-file cap; %d test files were left unexamined "
|
|
481
|
+
"for references to the target." % (MAX_TEST_FILES_READ, len(other_test_files) - read_count)
|
|
482
|
+
)
|
|
483
|
+
break
|
|
484
|
+
text = read_text(path)
|
|
485
|
+
if text is None:
|
|
486
|
+
continue
|
|
487
|
+
read_count += 1
|
|
488
|
+
matched = [n for n in path_names if n in text]
|
|
489
|
+
if word_re:
|
|
490
|
+
matched.extend(word_re.findall(text))
|
|
491
|
+
if matched:
|
|
492
|
+
ordered = sorted(set(matched), key=lambda n: (-len(n), n))[:MAX_MATCHES_PER_FILE]
|
|
493
|
+
referencing.append({"path": rel(path), "matched": ordered})
|
|
494
|
+
|
|
495
|
+
referencing.sort(key=lambda e: e["path"])
|
|
496
|
+
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
# Step 4 — derive the coverage verdict
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
repo_has_tests = bool(repo_test_files)
|
|
502
|
+
|
|
503
|
+
if target_test_files or referencing:
|
|
504
|
+
coverage_status = "covered"
|
|
505
|
+
parts = []
|
|
506
|
+
if target_test_files:
|
|
507
|
+
parts.append("%d test file(s) inside the target" % len(target_test_files))
|
|
508
|
+
if referencing:
|
|
509
|
+
parts.append("%d test file(s) elsewhere referencing it" % len(referencing))
|
|
510
|
+
coverage_summary = "Tests were found for this target: " + " and ".join(parts) + "."
|
|
511
|
+
elif repo_has_tests:
|
|
512
|
+
coverage_status = "repo_tests_only"
|
|
513
|
+
coverage_summary = (
|
|
514
|
+
"The repository has %d test file(s), but none of them are inside '%s' or reference it. "
|
|
515
|
+
"This target appears untested." % (len(repo_test_files), target_rel)
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
coverage_status = "no_tests_in_repo"
|
|
519
|
+
coverage_summary = (
|
|
520
|
+
"No test files were found anywhere in the repository, so no coverage exists for '%s'."
|
|
521
|
+
% target_rel
|
|
522
|
+
)
|
|
523
|
+
notices.append(
|
|
524
|
+
"No files matched any test-file or test-directory convention in this repository. "
|
|
525
|
+
"This is reported as an explicit empty result, not an error — the project may use an "
|
|
526
|
+
"unrecognised testing convention, or may have no tests at all."
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
result = {
|
|
530
|
+
"script": "detect-tests.sh",
|
|
531
|
+
"version": 1,
|
|
532
|
+
"target": target_rel,
|
|
533
|
+
"target_absolute": TARGET,
|
|
534
|
+
"target_type": "directory" if is_dir else "file",
|
|
535
|
+
"repo_root": REPO_ROOT,
|
|
536
|
+
"coverage_status": coverage_status,
|
|
537
|
+
"coverage_summary": coverage_summary,
|
|
538
|
+
"repo_has_tests": repo_has_tests,
|
|
539
|
+
"target_test_files": [rel(p) for p in target_test_files],
|
|
540
|
+
"referencing_test_files": referencing,
|
|
541
|
+
"test_directories": test_directories,
|
|
542
|
+
"test_runner_configs": runner_configs,
|
|
543
|
+
"reference_names": reference_names,
|
|
544
|
+
"repo_test_file_count": len(repo_test_files),
|
|
545
|
+
"repo_test_files_read": read_count,
|
|
546
|
+
"target_files_considered": len(target_files),
|
|
547
|
+
"files_walked": files_walked,
|
|
548
|
+
"walk_truncated": walk_truncated,
|
|
549
|
+
"notices": notices,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
print(json.dumps(result, indent=2))
|
|
553
|
+
PY
|