@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.
- package/README.md +10 -7
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +215 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/mcp/router/embedder.js +1 -1
- package/mcp/training_runner/index.js +239 -0
- package/mcp/training_runner/package-lock.json +1065 -0
- package/mcp/training_runner/package.json +15 -0
- package/package.json +14 -16
- 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/close-story/SKILL.md +203 -0
- package/skills/close-story/scripts/check-story-closeable.sh +195 -0
- package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
- package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
- package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
- package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
- package/skills/commit/SKILL.md +30 -3
- package/skills/distribute/CONFIG_SCHEMA.md +148 -0
- package/skills/distribute/SKILL.md +173 -0
- package/skills/distribute/scripts/check-version.sh +74 -0
- package/skills/distribute/scripts/commit-version-bump.sh +108 -0
- package/skills/distribute/scripts/distribute-changes.sh +381 -0
- package/skills/do/SKILL.md +352 -1
- package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
- package/skills/doc/assets/path-objectives.yaml +13 -0
- 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 +101 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/strategy_stub_template.md +38 -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 +35 -1
- package/skills/jenga/SKILL.md +206 -14
- 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 +206 -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/train/SKILL.md +0 -116
- package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
- package/skills/train/assets/dashboard-templates/nlp.html +0 -102
- package/skills/train/assets/dashboard-templates/transformers.html +0 -98
- package/skills/train/assets/results-parsers/__init__.py +0 -9
- package/skills/train/assets/results-parsers/classifiers.py +0 -84
- package/skills/train/assets/results-parsers/nlp.py +0 -88
- package/skills/train/assets/results-parsers/reporter.py +0 -154
- package/skills/train/assets/results-parsers/transformers.py +0 -120
- package/skills/train/train_cli.py +0 -786
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# skills/reconcile/scripts/detect-unlinked-code.sh
|
|
4
|
+
#
|
|
5
|
+
# The inverse half of `/reconcile`. The normal pass asks "does this board item
|
|
6
|
+
# exist in the code?"; this asks "does this code exist on the board?" and finds
|
|
7
|
+
# paths with NO Jenga provenance of any kind.
|
|
8
|
+
#
|
|
9
|
+
# A tracked path is UNLINKED when BOTH provenance signals are absent:
|
|
10
|
+
#
|
|
11
|
+
# Signal A board linkage -- no file under `project/board/` references it,
|
|
12
|
+
# and no board file references the directory it
|
|
13
|
+
# lives in
|
|
14
|
+
# Signal B commit tag -- no commit naming a board item added it
|
|
15
|
+
#
|
|
16
|
+
# Either signal alone is provenance. `lib/inject-settings.js` has no board file
|
|
17
|
+
# naming it but WAS added by a `task(...)` commit, so it is linked, not unlinked.
|
|
18
|
+
#
|
|
19
|
+
# Signal B accepts three subject forms. The first is the current EST convention;
|
|
20
|
+
# the other two exist because this repo's own history predates it, and a commit
|
|
21
|
+
# that plainly names the board item it implements is provenance regardless of
|
|
22
|
+
# the punctuation around it:
|
|
23
|
+
#
|
|
24
|
+
# est_tag epic(...) / story(...) / task(E##_S##_T##)
|
|
25
|
+
# id_prefix E04_S01: Implement core /convert skill
|
|
26
|
+
# conventional feat(train): implement E01_S05 - results parsers
|
|
27
|
+
#
|
|
28
|
+
# Crediting too narrowly is the expensive mistake: it makes /reconcile offer to
|
|
29
|
+
# re-onboard code the board demonstrably already owns.
|
|
30
|
+
#
|
|
31
|
+
# It scans, it classifies, it reports. It NEVER writes anything, never touches
|
|
32
|
+
# the board, and never invokes `/uncharted`. Deciding which groups are worth
|
|
33
|
+
# investigating and presenting the offer is agent judgement and lives in
|
|
34
|
+
# `skills/reconcile/SKILL.md`.
|
|
35
|
+
#
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# SIGNAL A IS BORROWED, NOT REBUILT
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# The board-linkage question is answered by
|
|
40
|
+
# `skills/uncharted/scripts/resolve-segment-target.sh` (E40_S02_T01) through its
|
|
41
|
+
# documented batch interface:
|
|
42
|
+
#
|
|
43
|
+
# git ls-files | resolve-segment-target.sh --paths-from -
|
|
44
|
+
#
|
|
45
|
+
# That interface exists FOR this caller. The board is read and inverted into a
|
|
46
|
+
# prefix index once, then each path costs a dict lookup, so the whole repo is
|
|
47
|
+
# classified in well under a second. We read `.results[].board_linkage.status`
|
|
48
|
+
# and never re-derive it.
|
|
49
|
+
#
|
|
50
|
+
# If the resolver is missing, this script FAILS (exit 4). It does not fall back
|
|
51
|
+
# to a local copy of the check. A second, quietly divergent answer to "is this
|
|
52
|
+
# path on the board" is the exact outcome this design is meant to prevent.
|
|
53
|
+
#
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# `not_checked` IS A THIRD STATE, NOT A SYNONYM FOR `unlinked`
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# The resolver reports three linkage states, and the third one is load-bearing:
|
|
58
|
+
#
|
|
59
|
+
# linked >=1 board file references the path
|
|
60
|
+
# unlinked the board was read and nothing references it
|
|
61
|
+
# not_checked the question could not be answered here -- the target is
|
|
62
|
+
# outside this repository, is the repo root, no `project/board/`
|
|
63
|
+
# exists, or the path is a stale index entry that no longer
|
|
64
|
+
# exists on disk
|
|
65
|
+
#
|
|
66
|
+
# `not_checked` means "we could not check", NOT "nothing references it". A path
|
|
67
|
+
# in that state is reported in its own `not_checked[]` array with the resolver's
|
|
68
|
+
# reason verbatim. It is never placed in a directory group, never counted as
|
|
69
|
+
# unlinked, and never included in the `/uncharted segment` offer.
|
|
70
|
+
#
|
|
71
|
+
# This is deliberate. `run-engine.sh` currently renders the out-of-repo
|
|
72
|
+
# `not_checked` case as `unlinked` in its understanding document, which asserts
|
|
73
|
+
# a verified absence that was never verified. `skills/uncharted/SKILL.md` names
|
|
74
|
+
# Step 1 (`resolve-segment-target.sh`) authoritative on linkage where the two
|
|
75
|
+
# disagree, so this script passes the resolver's status through unchanged and
|
|
76
|
+
# does not repeat that conflation.
|
|
77
|
+
#
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# GROUPING -- FILES ARE CLASSIFIED, DIRECTORIES ARE ONLY REPORTED
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# A flat list of a hundred files is not actionable; the useful unit for
|
|
82
|
+
# `/uncharted segment` is a directory or feature. But grouping must not be done
|
|
83
|
+
# by testing directory paths. Board matching is prefix-based, so nearly every
|
|
84
|
+
# top-level directory reports `linked` on the strength of one board mention
|
|
85
|
+
# while specific files inside it are `unlinked` -- `mcp/` is linked while
|
|
86
|
+
# `mcp/training_runner/` is not. Testing directories and reporting the survivors
|
|
87
|
+
# would report zero unlinked code in a repo that demonstrably has some.
|
|
88
|
+
#
|
|
89
|
+
# So: only FILES are classified. Groups are then derived from the results.
|
|
90
|
+
#
|
|
91
|
+
# Rollup. Every directory gets subtree totals. A directory is `fully_unlinked`
|
|
92
|
+
# when every candidate beneath it is unlinked; each unlinked file is keyed to
|
|
93
|
+
# its SHALLOWEST fully-unlinked ancestor, falling back to its immediate parent.
|
|
94
|
+
# That collapses a wholly-uncharted subtree into the one directory worth naming
|
|
95
|
+
# instead of one group per leaf folder.
|
|
96
|
+
#
|
|
97
|
+
# A `not_checked` file inside a subtree blocks that subtree from being
|
|
98
|
+
# `fully_unlinked` -- we will not claim a whole directory is uncharted while
|
|
99
|
+
# part of it was never checked.
|
|
100
|
+
#
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# THE GROUP DIRECTORY IS CHECKED TOO -- `covered_groups`
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Classifying files and then reporting directories would assert about a
|
|
105
|
+
# directory something that was only ever verified about its contents. Because
|
|
106
|
+
# the resolver's match is a path-boundary test, a board item naming
|
|
107
|
+
# `skills/convert/` links THAT DIRECTORY without linking
|
|
108
|
+
# `skills/convert/convert_cli.py`. Both facts are true, and the directory-level
|
|
109
|
+
# one is what decides whether a segment is worth investigating -- offering
|
|
110
|
+
# `/uncharted segment` there would duplicate a board item that already exists.
|
|
111
|
+
#
|
|
112
|
+
# So every group directory goes back through the same borrowed checker in a
|
|
113
|
+
# second batch call, and groups are split:
|
|
114
|
+
#
|
|
115
|
+
# groups[] directory is `unlinked` (or `not_checked`) -- neither the
|
|
116
|
+
# files nor the directory have provenance. These are the
|
|
117
|
+
# offer candidates.
|
|
118
|
+
# covered_groups[] directory is `linked` -- the files have no provenance of
|
|
119
|
+
# their own but sit under a directory the board references.
|
|
120
|
+
# Reported with the owning board items, never offered.
|
|
121
|
+
#
|
|
122
|
+
# `not_checked` on a DIRECTORY keeps the group rather than dropping it: under-
|
|
123
|
+
# reporting a finding is worse than reporting one the user can dismiss.
|
|
124
|
+
#
|
|
125
|
+
# MIRROR SPELLINGS. Per CLAUDE.md the canonical file lives in the root tree and
|
|
126
|
+
# `.agents/`, `.claude/` are generated build outputs -- but older board items
|
|
127
|
+
# were often written against the mirror path. E17_S05 owns `/reconcile-origin`
|
|
128
|
+
# and names it `.agents/skills/reconcile-origin/SKILL.md`, so a match on the
|
|
129
|
+
# root path alone misses a board item that plainly owns the directory. Each
|
|
130
|
+
# group directory is therefore asked about under its own name and under both
|
|
131
|
+
# mirror prefixes, and `directory_linkage.matched_as` records which spelling
|
|
132
|
+
# answered. This adds spellings to the QUESTION; it does not add a second
|
|
133
|
+
# answer to it.
|
|
134
|
+
#
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Usage
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# detect-unlinked-code.sh [options]
|
|
139
|
+
#
|
|
140
|
+
# Options:
|
|
141
|
+
# --repo-root <dir> Treat this directory as the repo root instead of asking
|
|
142
|
+
# git. Mainly for testing.
|
|
143
|
+
# --board-dir <dir> Board directory to consult.
|
|
144
|
+
# Default: <repo-root>/project/board
|
|
145
|
+
# --resolver <path> Path to resolve-segment-target.sh.
|
|
146
|
+
# Default: ../../uncharted/scripts/resolve-segment-target.sh
|
|
147
|
+
# --exclude <glob> Additional exclusion, matched against the repo-relative
|
|
148
|
+
# path with shell-glob semantics. Repeatable.
|
|
149
|
+
# --limit N Cap on files listed per group. Default 10. 0 = unlimited.
|
|
150
|
+
# Group counts are always the true totals, pre-cap.
|
|
151
|
+
# -h, --help Show this help and exit 0.
|
|
152
|
+
#
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Exclusions
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Removed from the candidate set before anything is classified:
|
|
157
|
+
# - Jenga's own scaffolding and generated build outputs: `project/`,
|
|
158
|
+
# `.claude/`, `.agents/`
|
|
159
|
+
# - build outputs and vendored dependencies: `node_modules/`, `dist/`,
|
|
160
|
+
# `build/`, `out/`, `target/`, `vendor/`, `.venv/`, `venv/`, `__pycache__/`,
|
|
161
|
+
# `.git/`
|
|
162
|
+
# - anything `.gitignore` matches. Checked explicitly with
|
|
163
|
+
# `git check-ignore --no-index`, because a path that is both tracked and
|
|
164
|
+
# ignored stays tracked -- listing it in `git ls-files` is not evidence that
|
|
165
|
+
# the ignore rules do not cover it.
|
|
166
|
+
# - anything given via `--exclude`
|
|
167
|
+
#
|
|
168
|
+
# The default prefix list is kept deliberately in step with
|
|
169
|
+
# CANDIDATE_EXCLUDED_PREFIXES in `resolve-segment-target.sh`.
|
|
170
|
+
#
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Output
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# stdout: one JSON object. stderr: notices. Never mixed.
|
|
175
|
+
#
|
|
176
|
+
# {
|
|
177
|
+
# "schema": "detect-unlinked-code/2",
|
|
178
|
+
# "repo_root": "/abs/path",
|
|
179
|
+
# "board_dir": "/abs/path/project/board",
|
|
180
|
+
# "resolver": "/abs/path/resolve-segment-target.sh",
|
|
181
|
+
# "summary": { "tracked_paths": N, "excluded_paths": N,
|
|
182
|
+
# "candidate_paths": N, "linked_paths": N,
|
|
183
|
+
# "linked_by_board": N, "linked_by_commit": N,
|
|
184
|
+
# "unlinked_paths": N, // files with neither file-level signal
|
|
185
|
+
# "uncharted_paths": N, // of those, the ones in groups[]
|
|
186
|
+
# "covered_paths": N, // of those, the ones in covered_groups[]
|
|
187
|
+
# "not_checked_paths": N,
|
|
188
|
+
# "groups": N, "covered_groups": N },
|
|
189
|
+
# "groups": [
|
|
190
|
+
# { "directory": "skills/skillify",
|
|
191
|
+
# "unlinked_count": N, // files keyed to THIS group
|
|
192
|
+
# "subtree_candidates": N, // all candidates under the directory
|
|
193
|
+
# "subtree_unlinked": N, // all unlinked under the directory
|
|
194
|
+
# "fully_unlinked": true,
|
|
195
|
+
# "directory_linkage": { "status": "unlinked", "reason": "...",
|
|
196
|
+
# "items": [], "match_count": 0,
|
|
197
|
+
# "matched_as": "skills/skillify" },
|
|
198
|
+
# "files": ["..."], // capped by --limit
|
|
199
|
+
# "files_truncated": N }
|
|
200
|
+
# ],
|
|
201
|
+
# "covered_groups": [ /* same shape; directory_linkage.status == "linked" */ ],
|
|
202
|
+
# "not_checked": [ { "path": "...", "reason": "..." } ],
|
|
203
|
+
#
|
|
204
|
+
# Paths are reported exactly as `git ls-files` gives them. A tracked symlink is classified by
|
|
205
|
+
# what it points at -- the resolver resolves the target -- but is still reported under its own
|
|
206
|
+
# name, so it never displaces the record of the file it points to.
|
|
207
|
+
# "exclusions": { "prefixes": [...], "globs": [...], "gitignored": N },
|
|
208
|
+
# "notices": [...]
|
|
209
|
+
# }
|
|
210
|
+
#
|
|
211
|
+
# Both group arrays are sorted by unlinked_count descending, then by path.
|
|
212
|
+
# `unlinked_paths` == `uncharted_paths` + `covered_paths`.
|
|
213
|
+
#
|
|
214
|
+
# Exit codes:
|
|
215
|
+
# 0 the scan completed -- findings or not. Callers read
|
|
216
|
+
# `summary.unlinked_paths`; finding unlinked code is a normal result, not
|
|
217
|
+
# an error, and must stay safe under `set -e` inside the reconcile pass.
|
|
218
|
+
# 1 usage error
|
|
219
|
+
# 4 environment error (python3/git unavailable, repo root undeterminable,
|
|
220
|
+
# resolver missing or not executable, resolver failed)
|
|
221
|
+
#
|
|
222
|
+
# Requires: bash, python3, git, and resolve-segment-target.sh. jq is NOT required.
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
set -euo pipefail
|
|
226
|
+
|
|
227
|
+
SELF="$(basename "$0")"
|
|
228
|
+
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
|
229
|
+
|
|
230
|
+
die() {
|
|
231
|
+
local code="$1"; shift
|
|
232
|
+
printf '%s: error: %s\n' "$SELF" "$*" >&2
|
|
233
|
+
exit "$code"
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
usage() {
|
|
237
|
+
sed -n '/^# Usage$/,/^# Requires:/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^-\{10,\}$/d'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
die_usage() {
|
|
241
|
+
printf '%s: error: %s\n' "$SELF" "$*" >&2
|
|
242
|
+
echo >&2
|
|
243
|
+
usage >&2
|
|
244
|
+
exit 1
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
REPO_ROOT=""
|
|
248
|
+
BOARD_DIR=""
|
|
249
|
+
RESOLVER=""
|
|
250
|
+
LIMIT=10
|
|
251
|
+
EXCLUDES=""
|
|
252
|
+
|
|
253
|
+
require_value() {
|
|
254
|
+
# require_value <flag> <remaining-arg-count>
|
|
255
|
+
[ "$2" -ge 2 ] || die_usage "$1 requires a value"
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
while [ "$#" -gt 0 ]; do
|
|
259
|
+
case "$1" in
|
|
260
|
+
--repo-root) require_value "--repo-root" "$#"; REPO_ROOT="$2"; shift 2 ;;
|
|
261
|
+
--repo-root=*) REPO_ROOT="${1#*=}"; shift ;;
|
|
262
|
+
--board-dir) require_value "--board-dir" "$#"; BOARD_DIR="$2"; shift 2 ;;
|
|
263
|
+
--board-dir=*) BOARD_DIR="${1#*=}"; shift ;;
|
|
264
|
+
--resolver) require_value "--resolver" "$#"; RESOLVER="$2"; shift 2 ;;
|
|
265
|
+
--resolver=*) RESOLVER="${1#*=}"; shift ;;
|
|
266
|
+
--exclude) require_value "--exclude" "$#"; EXCLUDES="${EXCLUDES}$2"$'\n'; shift 2 ;;
|
|
267
|
+
--exclude=*) EXCLUDES="${EXCLUDES}${1#*=}"$'\n'; shift ;;
|
|
268
|
+
--limit) require_value "--limit" "$#"; LIMIT="$2"; shift 2 ;;
|
|
269
|
+
--limit=*) LIMIT="${1#*=}"; shift ;;
|
|
270
|
+
-h|--help) usage; exit 0 ;;
|
|
271
|
+
*) die_usage "unknown argument \"$1\"" ;;
|
|
272
|
+
esac
|
|
273
|
+
done
|
|
274
|
+
|
|
275
|
+
case "$LIMIT" in
|
|
276
|
+
''|*[!0-9]*) die_usage "--limit requires a non-negative integer, got \"$LIMIT\"" ;;
|
|
277
|
+
esac
|
|
278
|
+
|
|
279
|
+
command -v python3 >/dev/null 2>&1 || die 4 "python3 is required but was not found on PATH"
|
|
280
|
+
command -v git >/dev/null 2>&1 || die 4 "git is required but was not found on PATH"
|
|
281
|
+
|
|
282
|
+
# --- repo root ------------------------------------------------------------------------------
|
|
283
|
+
# Anchored on THIS SCRIPT, matching resolve-segment-target.sh and run-engine.sh: the board being
|
|
284
|
+
# consulted is the board of the project that owns the skill.
|
|
285
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
286
|
+
REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
|
287
|
+
[ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
|
|
288
|
+
fi
|
|
289
|
+
[ -d "$REPO_ROOT" ] || die 4 "repo root is not a directory: $REPO_ROOT"
|
|
290
|
+
REPO_ROOT=$(cd -- "$REPO_ROOT" && pwd -P)
|
|
291
|
+
|
|
292
|
+
[ -n "$BOARD_DIR" ] || BOARD_DIR="$REPO_ROOT/project/board"
|
|
293
|
+
|
|
294
|
+
# --- the borrowed linkage check -------------------------------------------------------------
|
|
295
|
+
[ -n "$RESOLVER" ] || RESOLVER="$SCRIPT_DIR/../../uncharted/scripts/resolve-segment-target.sh"
|
|
296
|
+
[ -f "$RESOLVER" ] || die 4 "board-linkage checker not found: $RESOLVER
|
|
297
|
+
This script deliberately has no fallback implementation -- see the header. Restore
|
|
298
|
+
skills/uncharted/scripts/resolve-segment-target.sh or pass --resolver <path>."
|
|
299
|
+
[ -x "$RESOLVER" ] || die 4 "board-linkage checker is not executable: $RESOLVER"
|
|
300
|
+
RESOLVER=$(cd -- "$(dirname -- "$RESOLVER")" && pwd -P)/$(basename -- "$RESOLVER")
|
|
301
|
+
|
|
302
|
+
TMPDIR_RUN=$(mktemp -d "${TMPDIR:-/tmp}/detect-unlinked-code.XXXXXX")
|
|
303
|
+
cleanup() { rm -rf "$TMPDIR_RUN"; }
|
|
304
|
+
trap cleanup EXIT
|
|
305
|
+
|
|
306
|
+
TRACKED="$TMPDIR_RUN/tracked"
|
|
307
|
+
IGNORED="$TMPDIR_RUN/ignored"
|
|
308
|
+
CANDIDATES="$TMPDIR_RUN/candidates"
|
|
309
|
+
LINKAGE="$TMPDIR_RUN/linkage.json"
|
|
310
|
+
COMMITS="$TMPDIR_RUN/commits"
|
|
311
|
+
EXCLUDE_GLOBS="$TMPDIR_RUN/excludes"
|
|
312
|
+
|
|
313
|
+
# --- tracked paths --------------------------------------------------------------------------
|
|
314
|
+
git -C "$REPO_ROOT" ls-files -z > "$TRACKED" 2>/dev/null || : > "$TRACKED"
|
|
315
|
+
|
|
316
|
+
# --- gitignored paths -----------------------------------------------------------------------
|
|
317
|
+
# --no-index is the point: without it, git refuses to call a TRACKED path ignored, so a path
|
|
318
|
+
# that is both tracked and covered by .gitignore would silently survive the filter.
|
|
319
|
+
: > "$IGNORED"
|
|
320
|
+
if [ -s "$TRACKED" ]; then
|
|
321
|
+
set +e
|
|
322
|
+
git -C "$REPO_ROOT" check-ignore --no-index --stdin -z < "$TRACKED" > "$IGNORED" 2>/dev/null
|
|
323
|
+
CI_RC=$?
|
|
324
|
+
set -e
|
|
325
|
+
# 0 = some paths ignored, 1 = none ignored. Anything else means check-ignore itself failed,
|
|
326
|
+
# in which case an empty ignore list is the safe reading (over-report, never under-report).
|
|
327
|
+
if [ "$CI_RC" -gt 1 ]; then
|
|
328
|
+
printf '%s: notice: git check-ignore exited %d; proceeding with no gitignore filter\n' \
|
|
329
|
+
"$SELF" "$CI_RC" >&2
|
|
330
|
+
: > "$IGNORED"
|
|
331
|
+
fi
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
# --- EST-tagged commit provenance -----------------------------------------------------------
|
|
335
|
+
# One pass over HEAD. --no-renames is deliberate: it decomposes a rename into delete-old +
|
|
336
|
+
# add-new, so the ADDED entry is unambiguously the path as it exists today. With rename
|
|
337
|
+
# detection on, a rename is one record and crediting the right side of it becomes guesswork.
|
|
338
|
+
# Merge commits contribute no file list by default, which is correct: a merge does not originate
|
|
339
|
+
# content, and the branch commits that do are already walked.
|
|
340
|
+
# \x01 prefixes the subject line so the parser can tell subjects from paths unambiguously.
|
|
341
|
+
git -C "$REPO_ROOT" log --diff-filter=A --no-renames --name-only \
|
|
342
|
+
--format='%x01%s' HEAD -- . > "$COMMITS" 2>/dev/null || : > "$COMMITS"
|
|
343
|
+
|
|
344
|
+
# --- candidate set --------------------------------------------------------------------------
|
|
345
|
+
printf '%s' "$EXCLUDES" > "$EXCLUDE_GLOBS"
|
|
346
|
+
|
|
347
|
+
PY_FILTER=$(cat <<'PY'
|
|
348
|
+
import fnmatch, os, sys
|
|
349
|
+
|
|
350
|
+
tracked_path, ignored_path, excludes_path, out_path = sys.argv[1:5]
|
|
351
|
+
|
|
352
|
+
# Kept deliberately in step with CANDIDATE_EXCLUDED_PREFIXES in resolve-segment-target.sh.
|
|
353
|
+
EXCLUDED_PREFIXES = (
|
|
354
|
+
"project/", ".claude/", ".agents/", "node_modules/", "dist/", "build/",
|
|
355
|
+
"out/", "vendor/", "target/", ".venv/", "venv/", "__pycache__/", ".git/",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def read_nul(path):
|
|
360
|
+
try:
|
|
361
|
+
with open(path, "rb") as fh:
|
|
362
|
+
return [p.decode("utf-8", "replace") for p in fh.read().split(b"\0") if p]
|
|
363
|
+
except OSError:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
tracked = read_nul(tracked_path)
|
|
368
|
+
ignored = set(read_nul(ignored_path))
|
|
369
|
+
try:
|
|
370
|
+
with open(excludes_path, encoding="utf-8") as fh:
|
|
371
|
+
globs = [ln.strip() for ln in fh if ln.strip()]
|
|
372
|
+
except OSError:
|
|
373
|
+
globs = []
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def excluded(path):
|
|
377
|
+
if path.startswith(EXCLUDED_PREFIXES):
|
|
378
|
+
return True
|
|
379
|
+
# A nested build/vendor directory counts too: `mcp/help/node_modules/x` is not source.
|
|
380
|
+
for part in path.split("/")[:-1]:
|
|
381
|
+
if part + "/" in EXCLUDED_PREFIXES:
|
|
382
|
+
return True
|
|
383
|
+
if path in ignored:
|
|
384
|
+
return True
|
|
385
|
+
return any(fnmatch.fnmatch(path, g) for g in globs)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
candidates = [p for p in tracked if not excluded(p)]
|
|
389
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
390
|
+
for p in candidates:
|
|
391
|
+
fh.write(p + "\n")
|
|
392
|
+
sys.stdout.write("%d %d %d\n" % (len(tracked), len(candidates), len(ignored)))
|
|
393
|
+
PY
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
COUNTS=$(python3 -c "$PY_FILTER" "$TRACKED" "$IGNORED" "$EXCLUDE_GLOBS" "$CANDIDATES") \
|
|
397
|
+
|| die 4 "failed to build the candidate path set"
|
|
398
|
+
|
|
399
|
+
# --- signal A: board linkage, via the borrowed checker --------------------------------------
|
|
400
|
+
# Exit 2 means "at least one input path does not exist" -- a stale index entry. That is a normal
|
|
401
|
+
# finding here, the JSON is still complete, and the resolver reports it as not_checked. Any other
|
|
402
|
+
# non-zero status is a real failure.
|
|
403
|
+
set +e
|
|
404
|
+
"$RESOLVER" --paths-from "$CANDIDATES" --board-dir "$BOARD_DIR" --repo-root "$REPO_ROOT" \
|
|
405
|
+
--limit 0 > "$LINKAGE"
|
|
406
|
+
RESOLVER_RC=$?
|
|
407
|
+
set -e
|
|
408
|
+
if [ "$RESOLVER_RC" -ne 0 ] && [ "$RESOLVER_RC" -ne 2 ]; then
|
|
409
|
+
die 4 "board-linkage checker failed (exit $RESOLVER_RC): $RESOLVER"
|
|
410
|
+
fi
|
|
411
|
+
[ -s "$LINKAGE" ] || die 4 "board-linkage checker produced no output: $RESOLVER"
|
|
412
|
+
|
|
413
|
+
PY_MAIN=$(cat <<'PY'
|
|
414
|
+
import json
|
|
415
|
+
import os
|
|
416
|
+
import re
|
|
417
|
+
import subprocess
|
|
418
|
+
import sys
|
|
419
|
+
|
|
420
|
+
(repo_root, board_dir, resolver, linkage_path, commits_path,
|
|
421
|
+
excludes_path, limit_s, counts_s) = sys.argv[1:9]
|
|
422
|
+
limit = int(limit_s)
|
|
423
|
+
tracked_n, candidate_n, ignored_n = (int(x) for x in counts_s.split())
|
|
424
|
+
|
|
425
|
+
notices = []
|
|
426
|
+
|
|
427
|
+
EXCLUDED_PREFIXES = [
|
|
428
|
+
"project/", ".claude/", ".agents/", "node_modules/", "dist/", "build/",
|
|
429
|
+
"out/", "vendor/", "target/", ".venv/", "venv/", "__pycache__/", ".git/",
|
|
430
|
+
]
|
|
431
|
+
try:
|
|
432
|
+
with open(excludes_path, encoding="utf-8") as fh:
|
|
433
|
+
globs = [ln.strip() for ln in fh if ln.strip()]
|
|
434
|
+
except OSError:
|
|
435
|
+
globs = []
|
|
436
|
+
|
|
437
|
+
# --- signal B: EST commit tags ---------------------------------------------------------------
|
|
438
|
+
# Three accepted subject forms. The current EST convention is the first; the other two exist
|
|
439
|
+
# because this repo's own history predates it, and a commit that plainly names the board item it
|
|
440
|
+
# implements is provenance regardless of the punctuation around it. Crediting too narrowly is the
|
|
441
|
+
# expensive mistake here -- it makes /reconcile offer to re-onboard code the board already owns.
|
|
442
|
+
#
|
|
443
|
+
# est_tag epic(...) / story(...) / task(E##_S##_T##)
|
|
444
|
+
# id_prefix E04_S01: Implement core /convert skill
|
|
445
|
+
# conventional feat(train): implement E01_S05 - results parsers
|
|
446
|
+
#
|
|
447
|
+
# A bare `E##` is only honoured inside an EST tag or as a subject prefix; loose in a sentence it
|
|
448
|
+
# is as likely to be a version or a variable as a board ID.
|
|
449
|
+
BOARD_ID = r"E\d+(?:_S\d+(?:_T\d+)?)?"
|
|
450
|
+
EST_TAG_RE = re.compile(r"^(epic|story|task)\(([^)]*)\)")
|
|
451
|
+
ID_PREFIX_RE = re.compile(r"^(%s)\b\s*[:\-–—]" % BOARD_ID)
|
|
452
|
+
CONVENTIONAL_RE = re.compile(r"^\w+(?:\([^)]*\))?!?:")
|
|
453
|
+
QUALIFIED_ID_RE = re.compile(r"\bE\d+_S\d+(?:_T\d+)?\b")
|
|
454
|
+
ANY_ID_RE = re.compile(r"\b%s\b" % BOARD_ID)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def est_provenance(subject):
|
|
458
|
+
"""-> (board id or tag word, matched form) or None."""
|
|
459
|
+
m = EST_TAG_RE.match(subject)
|
|
460
|
+
if m:
|
|
461
|
+
ids = ANY_ID_RE.findall(m.group(2))
|
|
462
|
+
return (ids[0] if ids else m.group(1)), "est_tag"
|
|
463
|
+
m = ID_PREFIX_RE.match(subject)
|
|
464
|
+
if m:
|
|
465
|
+
return m.group(1), "id_prefix"
|
|
466
|
+
if CONVENTIONAL_RE.match(subject):
|
|
467
|
+
ids = QUALIFIED_ID_RE.findall(subject)
|
|
468
|
+
if ids:
|
|
469
|
+
return ids[0], "conventional"
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
commit_provenance = {}
|
|
474
|
+
try:
|
|
475
|
+
with open(commits_path, encoding="utf-8", errors="replace") as fh:
|
|
476
|
+
raw = fh.read()
|
|
477
|
+
except OSError:
|
|
478
|
+
raw = ""
|
|
479
|
+
|
|
480
|
+
current = None
|
|
481
|
+
for line in raw.split("\n"):
|
|
482
|
+
if line.startswith("\x01"):
|
|
483
|
+
current = est_provenance(line[1:])
|
|
484
|
+
continue
|
|
485
|
+
path = line.strip()
|
|
486
|
+
if path and current is not None:
|
|
487
|
+
# First writer wins: git log walks newest-first, so this records the most recent EST
|
|
488
|
+
# commit that put the path in place, which is the one worth citing.
|
|
489
|
+
commit_provenance.setdefault(path, current)
|
|
490
|
+
|
|
491
|
+
# --- signal A: read the borrowed linkage result ----------------------------------------------
|
|
492
|
+
try:
|
|
493
|
+
with open(linkage_path, encoding="utf-8") as fh:
|
|
494
|
+
linkage = json.load(fh)
|
|
495
|
+
except (OSError, ValueError) as exc:
|
|
496
|
+
sys.stderr.write("error: could not read board-linkage output: %s\n" % exc)
|
|
497
|
+
sys.exit(4)
|
|
498
|
+
|
|
499
|
+
if linkage.get("schema") != "resolve-segment-target/1":
|
|
500
|
+
notices.append("board-linkage checker returned unexpected schema %r; "
|
|
501
|
+
"results may be incomplete" % linkage.get("schema"))
|
|
502
|
+
|
|
503
|
+
# --- classify --------------------------------------------------------------------------------
|
|
504
|
+
# Order matters. Commit provenance is checked FIRST so that a path the board check could not
|
|
505
|
+
# evaluate is still rescued by a real EST commit, instead of being reported as unknown.
|
|
506
|
+
status_of = {}
|
|
507
|
+
not_checked = []
|
|
508
|
+
linked_by_board = linked_by_commit = 0
|
|
509
|
+
|
|
510
|
+
for rec in linkage.get("results", []):
|
|
511
|
+
# Key on the path we ASKED about, not the resolver's `target_relative`. The resolver
|
|
512
|
+
# realpath()s its target, so a tracked symlink (`AGENTS.md -> AGENT.md`) reports its
|
|
513
|
+
# destination and would silently overwrite that destination's own record.
|
|
514
|
+
path = rec.get("argument") or rec.get("target_relative")
|
|
515
|
+
if not path:
|
|
516
|
+
continue
|
|
517
|
+
link = rec.get("board_linkage") or {}
|
|
518
|
+
board_status = link.get("status", "not_checked")
|
|
519
|
+
|
|
520
|
+
if path in commit_provenance:
|
|
521
|
+
status_of[path] = "linked"
|
|
522
|
+
linked_by_commit += 1
|
|
523
|
+
elif board_status == "linked":
|
|
524
|
+
status_of[path] = "linked"
|
|
525
|
+
linked_by_board += 1
|
|
526
|
+
elif board_status == "unlinked":
|
|
527
|
+
status_of[path] = "unlinked"
|
|
528
|
+
else:
|
|
529
|
+
# THE THIRD STATE. "Could not check" is not "nothing references it". Reported on its
|
|
530
|
+
# own, never grouped, never counted as unlinked, never offered to /uncharted segment.
|
|
531
|
+
status_of[path] = "not_checked"
|
|
532
|
+
not_checked.append({
|
|
533
|
+
"path": path,
|
|
534
|
+
"reason": link.get("reason") or "board linkage could not be determined",
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
# --- subtree rollup ----------------------------------------------------------------------------
|
|
538
|
+
def ancestors(path):
|
|
539
|
+
"""Shallowest first: 'a/b/c.txt' -> ['.', 'a', 'a/b']."""
|
|
540
|
+
parts = path.split("/")[:-1]
|
|
541
|
+
out = ["."]
|
|
542
|
+
for k in range(1, len(parts) + 1):
|
|
543
|
+
out.append("/".join(parts[:k]))
|
|
544
|
+
return out
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
sub_total = {}
|
|
548
|
+
sub_unlinked = {}
|
|
549
|
+
for path, status in status_of.items():
|
|
550
|
+
for d in ancestors(path):
|
|
551
|
+
sub_total[d] = sub_total.get(d, 0) + 1
|
|
552
|
+
if status == "unlinked":
|
|
553
|
+
sub_unlinked[d] = sub_unlinked.get(d, 0) + 1
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def fully_unlinked(d):
|
|
557
|
+
"""Every candidate beneath d is unlinked. A single not_checked file blocks this."""
|
|
558
|
+
t = sub_total.get(d, 0)
|
|
559
|
+
return t > 0 and sub_unlinked.get(d, 0) == t
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
grouped = {}
|
|
563
|
+
for path, status in sorted(status_of.items()):
|
|
564
|
+
if status != "unlinked":
|
|
565
|
+
continue
|
|
566
|
+
chain = ancestors(path)
|
|
567
|
+
key = next((d for d in chain if fully_unlinked(d)), chain[-1])
|
|
568
|
+
grouped.setdefault(key, []).append(path)
|
|
569
|
+
|
|
570
|
+
# --- second pass: is the GROUP DIRECTORY itself on the board? ---------------------------------
|
|
571
|
+
# Reporting a directory while only ever having checked the files inside it asserts an absence
|
|
572
|
+
# that was never verified -- the same error this script is careful to avoid for `not_checked`.
|
|
573
|
+
# The resolver's match is a path-boundary test, so a board item naming `skills/convert/` links
|
|
574
|
+
# that directory without linking `skills/convert/convert_cli.py`. Both facts are true and the
|
|
575
|
+
# directory-level one is the one that decides whether a segment is worth investigating.
|
|
576
|
+
#
|
|
577
|
+
# Same borrowed checker, same batch interface, one extra call. No new linkage logic -- the only
|
|
578
|
+
# thing added on this side is WHICH SPELLINGS of a path we ask about.
|
|
579
|
+
#
|
|
580
|
+
# MIRROR SPELLINGS. Per CLAUDE.md the canonical file lives in the root tree and `.agents/` and
|
|
581
|
+
# `.claude/` are generated build outputs, but plenty of older board items were written against
|
|
582
|
+
# the mirror path -- E17_S05 owns `/reconcile-origin` and names it as
|
|
583
|
+
# `.agents/skills/reconcile-origin/SKILL.md`. A boundary match on the root path alone therefore
|
|
584
|
+
# misses a board item that plainly owns the directory. So each directory is asked about under its
|
|
585
|
+
# own name and under both mirror prefixes, and a hit on any spelling is board provenance. This
|
|
586
|
+
# adds path spellings to the QUESTION; it does not add a second answer to it.
|
|
587
|
+
MIRROR_PREFIXES = (".agents/", ".claude/")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def spellings(directory):
|
|
591
|
+
if directory == "." or directory.startswith(MIRROR_PREFIXES):
|
|
592
|
+
return [directory]
|
|
593
|
+
return [directory] + [pre + directory for pre in MIRROR_PREFIXES]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def resolve_directories(dirs):
|
|
597
|
+
if not dirs:
|
|
598
|
+
return {}
|
|
599
|
+
queries = []
|
|
600
|
+
for d in dirs:
|
|
601
|
+
queries.extend(spellings(d))
|
|
602
|
+
cmd = [resolver, "--paths-from", "-", "--board-dir", board_dir,
|
|
603
|
+
"--repo-root", repo_root, "--limit", "0"]
|
|
604
|
+
try:
|
|
605
|
+
proc = subprocess.run(cmd, input="\n".join(queries) + "\n", stdout=subprocess.PIPE,
|
|
606
|
+
stderr=subprocess.DEVNULL, universal_newlines=True)
|
|
607
|
+
except OSError as exc:
|
|
608
|
+
notices.append("directory linkage check could not run (%s); every group is reported "
|
|
609
|
+
"with an unverified directory" % exc)
|
|
610
|
+
return {}
|
|
611
|
+
# Exit 2 means some input path did not exist -- normal, and the JSON is still complete.
|
|
612
|
+
if proc.returncode not in (0, 2):
|
|
613
|
+
notices.append("directory linkage check failed (exit %d); every group is reported with "
|
|
614
|
+
"an unverified directory" % proc.returncode)
|
|
615
|
+
return {}
|
|
616
|
+
try:
|
|
617
|
+
payload = json.loads(proc.stdout)
|
|
618
|
+
except ValueError:
|
|
619
|
+
notices.append("directory linkage check returned unreadable output; every group is "
|
|
620
|
+
"reported with an unverified directory")
|
|
621
|
+
return {}
|
|
622
|
+
by_query = {r.get("argument"): (r.get("board_linkage") or {}) for r in payload.get("results", [])}
|
|
623
|
+
|
|
624
|
+
out = {}
|
|
625
|
+
for d in dirs:
|
|
626
|
+
chosen = None
|
|
627
|
+
for spelling in spellings(d):
|
|
628
|
+
link = by_query.get(spelling)
|
|
629
|
+
if not link:
|
|
630
|
+
continue
|
|
631
|
+
if link.get("status") == "linked":
|
|
632
|
+
chosen = dict(link, matched_as=spelling)
|
|
633
|
+
break
|
|
634
|
+
# Keep the root spelling's own verdict as the fallback, so a `not_checked` or
|
|
635
|
+
# `unlinked` answer is still the one reported when no spelling is linked.
|
|
636
|
+
if chosen is None:
|
|
637
|
+
chosen = dict(link, matched_as=spelling)
|
|
638
|
+
if chosen is not None:
|
|
639
|
+
out[d] = chosen
|
|
640
|
+
return out
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
dir_linkage = resolve_directories(sorted(grouped))
|
|
644
|
+
|
|
645
|
+
UNVERIFIED = {
|
|
646
|
+
"status": "not_checked",
|
|
647
|
+
"reason": "directory linkage was not verified",
|
|
648
|
+
"items": [], "files": [], "match_count": 0,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def build_group(directory, files):
|
|
653
|
+
link = dir_linkage.get(directory) or dict(UNVERIFIED)
|
|
654
|
+
shown = files if limit == 0 else files[:limit]
|
|
655
|
+
return {
|
|
656
|
+
"directory": directory,
|
|
657
|
+
"unlinked_count": len(files),
|
|
658
|
+
"subtree_candidates": sub_total.get(directory, 0),
|
|
659
|
+
"subtree_unlinked": sub_unlinked.get(directory, 0),
|
|
660
|
+
"fully_unlinked": fully_unlinked(directory),
|
|
661
|
+
"directory_linkage": {
|
|
662
|
+
"status": link.get("status", "not_checked"),
|
|
663
|
+
"reason": link.get("reason") or "directory linkage was not verified",
|
|
664
|
+
"items": link.get("items") or [],
|
|
665
|
+
"match_count": link.get("match_count", 0),
|
|
666
|
+
"matched_as": link.get("matched_as", directory),
|
|
667
|
+
},
|
|
668
|
+
"files": shown,
|
|
669
|
+
"files_truncated": len(files) - len(shown),
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
groups, covered_groups = [], []
|
|
674
|
+
for directory, files in grouped.items():
|
|
675
|
+
g = build_group(directory, files)
|
|
676
|
+
# `linked` is the only status that disqualifies a group. `not_checked` -- the repo root, or a
|
|
677
|
+
# failed second pass -- keeps the group and carries the caveat, because under-reporting a
|
|
678
|
+
# finding is worse than reporting one the user can dismiss.
|
|
679
|
+
(covered_groups if g["directory_linkage"]["status"] == "linked" else groups).append(g)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def order(g):
|
|
683
|
+
return (-g["unlinked_count"], g["directory"])
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
groups.sort(key=order)
|
|
687
|
+
covered_groups.sort(key=order)
|
|
688
|
+
not_checked.sort(key=lambda r: r["path"])
|
|
689
|
+
|
|
690
|
+
unlinked_n = sum(1 for s in status_of.values() if s == "unlinked")
|
|
691
|
+
uncharted_n = sum(g["unlinked_count"] for g in groups)
|
|
692
|
+
covered_n = sum(g["unlinked_count"] for g in covered_groups)
|
|
693
|
+
|
|
694
|
+
if len(status_of) != candidate_n:
|
|
695
|
+
notices.append("board-linkage checker returned %d records for %d candidate paths"
|
|
696
|
+
% (len(status_of), candidate_n))
|
|
697
|
+
if not_checked:
|
|
698
|
+
notices.append("%d path(s) could not be checked for board linkage; they are reported "
|
|
699
|
+
"separately and are NOT counted as unlinked" % len(not_checked))
|
|
700
|
+
if covered_groups:
|
|
701
|
+
notices.append("%d file(s) in %d director(y|ies) have no provenance of their own but sit "
|
|
702
|
+
"under a board-referenced directory; reported as covered, not offered"
|
|
703
|
+
% (covered_n, len(covered_groups)))
|
|
704
|
+
|
|
705
|
+
payload = {
|
|
706
|
+
"schema": "detect-unlinked-code/2",
|
|
707
|
+
"repo_root": repo_root,
|
|
708
|
+
"board_dir": board_dir,
|
|
709
|
+
"resolver": resolver,
|
|
710
|
+
"summary": {
|
|
711
|
+
"tracked_paths": tracked_n,
|
|
712
|
+
"excluded_paths": tracked_n - candidate_n,
|
|
713
|
+
"candidate_paths": candidate_n,
|
|
714
|
+
"linked_paths": linked_by_board + linked_by_commit,
|
|
715
|
+
"linked_by_board": linked_by_board,
|
|
716
|
+
"linked_by_commit": linked_by_commit,
|
|
717
|
+
"unlinked_paths": unlinked_n,
|
|
718
|
+
"uncharted_paths": uncharted_n,
|
|
719
|
+
"covered_paths": covered_n,
|
|
720
|
+
"not_checked_paths": len(not_checked),
|
|
721
|
+
"groups": len(groups),
|
|
722
|
+
"covered_groups": len(covered_groups),
|
|
723
|
+
},
|
|
724
|
+
"groups": groups,
|
|
725
|
+
"covered_groups": covered_groups,
|
|
726
|
+
"not_checked": not_checked,
|
|
727
|
+
"exclusions": {
|
|
728
|
+
"prefixes": EXCLUDED_PREFIXES,
|
|
729
|
+
"globs": globs,
|
|
730
|
+
"gitignored": ignored_n,
|
|
731
|
+
},
|
|
732
|
+
"notices": notices,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
736
|
+
PY
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
python3 -c "$PY_MAIN" \
|
|
740
|
+
"$REPO_ROOT" "$BOARD_DIR" "$RESOLVER" "$LINKAGE" "$COMMITS" "$EXCLUDE_GLOBS" \
|
|
741
|
+
"$LIMIT" "$COUNTS"
|