@jenga-ai/agent 1.0.1 → 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.
Files changed (117) hide show
  1. package/README.md +10 -7
  2. package/agents/developer.md +82 -2
  3. package/agents/scrum-master.md +215 -21
  4. package/agents/tester.md +90 -8
  5. package/hooks/on_session_end.sh +171 -20
  6. package/mcp/router/embedder.js +1 -1
  7. package/mcp/training_runner/index.js +239 -0
  8. package/mcp/training_runner/package-lock.json +1065 -0
  9. package/mcp/training_runner/package.json +15 -0
  10. package/package.json +14 -16
  11. package/scripts/check-permission-level.sh +107 -0
  12. package/scripts/check-publicignore-match.sh +122 -0
  13. package/scripts/check-worktree-liveness.sh +193 -0
  14. package/scripts/generate-rapport-manifest.sh +43 -0
  15. package/scripts/idea_manager.sh +47 -0
  16. package/scripts/install-worktree-commit-guard.sh +134 -0
  17. package/scripts/jenga-permission-level-switch.sh +109 -0
  18. package/scripts/smoke-harness.sh +139 -0
  19. package/scripts/validate-board.sh +62 -0
  20. package/scripts/with-lock.sh +158 -0
  21. package/scripts/worktree-remove-guard.sh +204 -0
  22. package/skills/clearify/SKILL.md +52 -0
  23. package/skills/close-story/SKILL.md +203 -0
  24. package/skills/close-story/scripts/check-story-closeable.sh +195 -0
  25. package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
  26. package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
  27. package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
  28. package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
  29. package/skills/commit/SKILL.md +30 -3
  30. package/skills/distribute/CONFIG_SCHEMA.md +148 -0
  31. package/skills/distribute/SKILL.md +173 -0
  32. package/skills/distribute/scripts/check-version.sh +74 -0
  33. package/skills/distribute/scripts/commit-version-bump.sh +108 -0
  34. package/skills/distribute/scripts/distribute-changes.sh +381 -0
  35. package/skills/do/SKILL.md +352 -1
  36. package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
  37. package/skills/doc/assets/path-objectives.yaml +13 -0
  38. package/skills/doc-sync/SKILL.md +16 -0
  39. package/skills/doc-sync/assets/doc_targets.md +11 -0
  40. package/skills/idea/SKILL.md +56 -0
  41. package/skills/idea/assets/idea_handoff_template.md +26 -0
  42. package/skills/idea/assets/idea_template.md +3 -0
  43. package/skills/init/SKILL.md +101 -7
  44. package/skills/init/assets/directory_structure.txt +1 -0
  45. package/skills/init/assets/strategy_stub_template.md +38 -0
  46. package/skills/init/assets/workflow_template.json +1 -1
  47. package/skills/init/scripts/apply-project-visibility.sh +176 -0
  48. package/skills/init/scripts/detect-existing-codebase.sh +166 -0
  49. package/skills/init/scripts/init.sh +35 -1
  50. package/skills/jenga/SKILL.md +206 -14
  51. package/skills/jenga/scripts/board-scan.sh +238 -0
  52. package/skills/jenga/scripts/cascade-resolve.sh +297 -0
  53. package/skills/jenga/scripts/render-confirmation.sh +679 -0
  54. package/skills/jenga/scripts/render-picker.sh +439 -0
  55. package/skills/jenga/scripts/resolve-id.sh +367 -0
  56. package/skills/jenga-permission-level/SKILL.md +81 -0
  57. package/skills/proceed/SKILL.md +1 -1
  58. package/skills/publish/SKILL.md +8 -5
  59. package/skills/publish/assets/ci-contract.md +2 -2
  60. package/skills/publish/assets/ownership-matrix.md +1 -1
  61. package/skills/publish/scripts/finalize_changelog.sh +115 -0
  62. package/skills/publish/scripts/generate_release_notes.sh +475 -28
  63. package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
  64. package/skills/publish/scripts/publish_deploy.sh +38 -8
  65. package/skills/publish/scripts/run_gates.sh +2 -2
  66. package/skills/reconcile/SKILL.md +117 -5
  67. package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
  68. package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
  69. package/skills/spinoff/SKILL.md +12 -7
  70. package/skills/todo/SKILL.md +2 -0
  71. package/skills/uncharted/SKILL.md +711 -0
  72. package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
  73. package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
  74. package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
  75. package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
  76. package/skills/uncharted/scripts/detect-tests.sh +553 -0
  77. package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
  78. package/skills/uncharted/scripts/enumerate-target.sh +470 -0
  79. package/skills/uncharted/scripts/import-source.sh +517 -0
  80. package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
  81. package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
  82. package/skills/uncharted/scripts/run-engine.sh +655 -0
  83. package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
  84. package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
  85. package/skills/wtf/SKILL.md +20 -0
  86. package/templates/CHANGELOG_TEMPLATE.md +13 -0
  87. package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
  88. package/templates/SCRUM_BOARD_SCHEMA.md +206 -10
  89. package/templates/permission-levels/README.md +73 -0
  90. package/templates/permission-levels/level-1-locked.json +71 -0
  91. package/templates/permission-levels/level-2-guarded.json +64 -0
  92. package/templates/permission-levels/level-3-standard.json +62 -0
  93. package/templates/permission-levels/level-4-elevated.json +60 -0
  94. package/templates/permission-levels/level-5-unrestricted.json +58 -0
  95. package/skills/convert/SKILL.md +0 -124
  96. package/skills/convert/convert_cli.py +0 -235
  97. package/skills/convert/tests/sample.csv +0 -4
  98. package/skills/convert/tests/sample.json +0 -5
  99. package/skills/convert/tests/sample.jsonl +0 -3
  100. package/skills/convert/tests/sample.yaml +0 -18
  101. package/skills/convert/tests/sample_obj.csv +0 -2
  102. package/skills/convert/tests/sample_obj.json +0 -9
  103. package/skills/mirror-public/SKILL.md +0 -237
  104. package/skills/mirror-public/assets/config.json +0 -5
  105. package/skills/mirror-public/scripts/mirror.sh +0 -374
  106. package/skills/self-sync/SKILL.md +0 -73
  107. package/skills/self-sync/scripts/run.js +0 -136
  108. package/skills/train/SKILL.md +0 -116
  109. package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
  110. package/skills/train/assets/dashboard-templates/nlp.html +0 -102
  111. package/skills/train/assets/dashboard-templates/transformers.html +0 -98
  112. package/skills/train/assets/results-parsers/__init__.py +0 -9
  113. package/skills/train/assets/results-parsers/classifiers.py +0 -84
  114. package/skills/train/assets/results-parsers/nlp.py +0 -88
  115. package/skills/train/assets/results-parsers/reporter.py +0 -154
  116. package/skills/train/assets/results-parsers/transformers.py +0 -120
  117. package/skills/train/train_cli.py +0 -786
@@ -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