@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,470 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# skills/uncharted/scripts/enumerate-target.sh
|
|
3
|
+
#
|
|
4
|
+
# Read-only target resolution and structure enumeration for the /uncharted investigative engine.
|
|
5
|
+
#
|
|
6
|
+
# Resolves a single target argument to one of three target types and emits a machine-readable
|
|
7
|
+
# JSON structure summary on stdout. Deterministic: the output is a pure function of the target
|
|
8
|
+
# and the flags -- no timestamps, no hostnames, no unsorted collections -- so two runs against an
|
|
9
|
+
# unchanged target produce byte-identical output and can be diffed directly.
|
|
10
|
+
#
|
|
11
|
+
# The script has NO side effects. It writes nothing to disk, creates no temp files, and invokes
|
|
12
|
+
# only read-only git plumbing (rev-parse, ls-files).
|
|
13
|
+
#
|
|
14
|
+
# ---------------------------------------------------------------------------------------------
|
|
15
|
+
# USAGE
|
|
16
|
+
# ---------------------------------------------------------------------------------------------
|
|
17
|
+
# enumerate-target.sh [options] <target>
|
|
18
|
+
#
|
|
19
|
+
# <target> A file path, a directory path, or a repository root.
|
|
20
|
+
#
|
|
21
|
+
# Options:
|
|
22
|
+
# --max-depth N Bound the emitted directory tree to N levels below the target.
|
|
23
|
+
# Default: 3. Use 0 for unlimited depth. Bounds the TREE only -- file
|
|
24
|
+
# counts and line counts always cover the whole target.
|
|
25
|
+
# --top N Report the N largest files by line count. Default: 10. Use 0 for all.
|
|
26
|
+
# --no-gitignore Force `find`-based discovery even inside a git work tree, so that
|
|
27
|
+
# .gitignore'd content is enumerated. Sets "gitignore_respected": false.
|
|
28
|
+
# -h, --help Print this usage block to stdout and exit 0.
|
|
29
|
+
# -- End of options; everything after is the target.
|
|
30
|
+
#
|
|
31
|
+
# Exit codes:
|
|
32
|
+
# 0 Success. JSON summary on stdout.
|
|
33
|
+
# 1 Usage or argument error (unknown flag, missing/extra target, bad numeric value).
|
|
34
|
+
# 2 Target error (does not exist, is not readable/traversable, or is neither a regular
|
|
35
|
+
# file nor a directory).
|
|
36
|
+
#
|
|
37
|
+
# ---------------------------------------------------------------------------------------------
|
|
38
|
+
# TARGET TYPES
|
|
39
|
+
# ---------------------------------------------------------------------------------------------
|
|
40
|
+
# "file" The target is a regular file.
|
|
41
|
+
# "repo_root" The target is a directory that is the top level of a git work tree.
|
|
42
|
+
# "directory" Any other directory.
|
|
43
|
+
#
|
|
44
|
+
# ---------------------------------------------------------------------------------------------
|
|
45
|
+
# FILE DISCOVERY
|
|
46
|
+
# ---------------------------------------------------------------------------------------------
|
|
47
|
+
# When the target is inside a git work tree, discovery uses:
|
|
48
|
+
# git ls-files --cached --others --exclude-standard -z
|
|
49
|
+
# so tracked files AND untracked-but-not-ignored files are both included, while .gitignore'd
|
|
50
|
+
# paths are excluded by construction. Including untracked files matters for /uncharted's `import`
|
|
51
|
+
# mode, where a freshly pulled-in source has not been committed yet.
|
|
52
|
+
#
|
|
53
|
+
# When the target is NOT inside a git work tree, discovery falls back to `find` with .git
|
|
54
|
+
# directories pruned. Ignore rules cannot be applied in that case, and the output says so
|
|
55
|
+
# explicitly via "listing_source": "find" and "gitignore_respected": false -- no silent pruning
|
|
56
|
+
# of paths the caller did not ask to prune.
|
|
57
|
+
#
|
|
58
|
+
# IGNORED TARGETS. Passing a target that is itself .gitignore'd -- or whose entire contents are
|
|
59
|
+
# ignored -- is a first-class case for /uncharted's `import` mode: a source dropped into vendor/,
|
|
60
|
+
# tmp/, or any other ignored path IS inside the work tree, so `git ls-files` runs and correctly
|
|
61
|
+
# returns nothing. Left alone that would render an empty result byte-identical to a genuinely empty
|
|
62
|
+
# directory, and a downstream understanding document would confidently describe a directory that
|
|
63
|
+
# actually has content. To prevent that silent-empty failure, an empty git listing for a directory
|
|
64
|
+
# target is cross-checked against a bounded `find` probe; if the probe finds files, discovery falls
|
|
65
|
+
# back to `find` and the output carries "ignored_target": true with "gitignore_respected": false.
|
|
66
|
+
# An explicitly-named ignored FILE is always enumerated (an explicit target wins) and is likewise
|
|
67
|
+
# flagged with "ignored_target": true. Use --no-gitignore to force the fallback unconditionally.
|
|
68
|
+
#
|
|
69
|
+
# Symlinks, unreadable files, and paths listed by git but absent from the working tree are all
|
|
70
|
+
# excluded from the counts and reported under the "skipped" key rather than aborting the run.
|
|
71
|
+
# Empty directories are not represented: both discovery mechanisms enumerate files, not dirs.
|
|
72
|
+
#
|
|
73
|
+
# ---------------------------------------------------------------------------------------------
|
|
74
|
+
# OUTPUT CONTRACT (stdout, JSON)
|
|
75
|
+
# ---------------------------------------------------------------------------------------------
|
|
76
|
+
# Additive by design -- consumers should ignore keys they do not need, and new keys may be added
|
|
77
|
+
# without notice. Depth is counted in path components relative to the target: for "a/b/c.sh" the
|
|
78
|
+
# file is depth 3 and the directory "a/b" is depth 2.
|
|
79
|
+
#
|
|
80
|
+
# {
|
|
81
|
+
# "target": absolute, symlink-resolved path to the target
|
|
82
|
+
# "target_type": "file" | "directory" | "repo_root"
|
|
83
|
+
# "base_dir": absolute directory all "path" values are relative to
|
|
84
|
+
# "listing_source": "git-ls-files" | "find" | "explicit"
|
|
85
|
+
# "explicit" means the target was a single named file, taken as given
|
|
86
|
+
# with no discovery pass and therefore no ignore filtering
|
|
87
|
+
# "gitignore_respected": true when listing_source is "git-ls-files"
|
|
88
|
+
# "ignored_target": true when the target is .gitignore'd (or all of its content is), in
|
|
89
|
+
# which case discovery fell back to `find` so the content is not lost
|
|
90
|
+
# "max_depth": the depth bound in effect (0 == unlimited)
|
|
91
|
+
# "total_files": count of enumerated regular files
|
|
92
|
+
# "total_lines": summed line count across non-binary files
|
|
93
|
+
# "total_bytes": summed size in bytes
|
|
94
|
+
# "binary_files": count of files detected as binary (lines reported as 0)
|
|
95
|
+
# "extensions": [ { "extension", "files", "lines" } ] sorted by files desc, then name
|
|
96
|
+
# "tree": [ tree entries with depth <= max_depth ] sorted by path
|
|
97
|
+
# "tree_truncated": true when at least one tree entry was truncated by the depth bound
|
|
98
|
+
# "largest_files": [ { "path", "lines", "bytes", "binary" } ] sorted by lines desc, then path
|
|
99
|
+
# "skipped": { "<reason>": { "count", "paths" } } paths capped at 25 per reason
|
|
100
|
+
# }
|
|
101
|
+
#
|
|
102
|
+
# Tree entries are one of:
|
|
103
|
+
# { "path", "type": "directory", "depth", "file_count", "truncated" }
|
|
104
|
+
# file_count is RECURSIVE -- it includes files below the depth bound, so a truncated
|
|
105
|
+
# directory still carries a coarse size signal for `onboard` mode.
|
|
106
|
+
# { "path", "type": "file", "depth", "lines", "bytes", "binary" }
|
|
107
|
+
#
|
|
108
|
+
# Files with no extension (including dotfiles such as .gitignore) bucket under "(none)".
|
|
109
|
+
#
|
|
110
|
+
# ---------------------------------------------------------------------------------------------
|
|
111
|
+
# EXAMPLES
|
|
112
|
+
# ---------------------------------------------------------------------------------------------
|
|
113
|
+
# enumerate-target.sh scripts/board_resolver.sh
|
|
114
|
+
# enumerate-target.sh --max-depth 2 skills/
|
|
115
|
+
# enumerate-target.sh --max-depth 1 --top 20 .
|
|
116
|
+
# enumerate-target.sh skills/ | jq -r '.extensions[] | "\(.files)\t\(.extension)"'
|
|
117
|
+
#
|
|
118
|
+
# Requires: bash, git, python3. (python3 is already a hard dependency of scripts/validate-board.sh.)
|
|
119
|
+
# jq is NOT required -- JSON is emitted by python3.
|
|
120
|
+
|
|
121
|
+
set -euo pipefail
|
|
122
|
+
|
|
123
|
+
MAX_DEPTH=3
|
|
124
|
+
TOP=10
|
|
125
|
+
TARGET=""
|
|
126
|
+
NO_GITIGNORE=0
|
|
127
|
+
|
|
128
|
+
usage() {
|
|
129
|
+
cat <<EOF
|
|
130
|
+
Usage: $(basename "$0") [options] <target>
|
|
131
|
+
|
|
132
|
+
Resolve <target> (a file, a directory, or a repository root) and print a JSON structure
|
|
133
|
+
summary to stdout. Read-only: nothing is written to disk.
|
|
134
|
+
|
|
135
|
+
Options:
|
|
136
|
+
--max-depth N Bound the emitted directory tree to N levels (default: 3, 0 = unlimited)
|
|
137
|
+
--top N Report the N largest files by line count (default: 10, 0 = all)
|
|
138
|
+
--no-gitignore Enumerate .gitignore'd content too (forces find-based discovery)
|
|
139
|
+
-h, --help Show this help and exit
|
|
140
|
+
|
|
141
|
+
Exit codes: 0 success, 1 usage error, 2 target missing/unreadable/unsupported.
|
|
142
|
+
EOF
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
die_usage() {
|
|
146
|
+
echo "Error: $1" >&2
|
|
147
|
+
echo >&2
|
|
148
|
+
usage >&2
|
|
149
|
+
exit 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
require_int() {
|
|
153
|
+
# require_int <flag> <value> -- non-negative integer
|
|
154
|
+
case "$2" in
|
|
155
|
+
''|*[!0-9]*) die_usage "$1 requires a non-negative integer, got \"$2\"" ;;
|
|
156
|
+
esac
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# --- argument parsing --------------------------------------------------------------------------
|
|
160
|
+
while [ "$#" -gt 0 ]; do
|
|
161
|
+
case "$1" in
|
|
162
|
+
--max-depth)
|
|
163
|
+
[ "$#" -ge 2 ] || die_usage "--max-depth requires a value"
|
|
164
|
+
require_int "--max-depth" "$2"
|
|
165
|
+
MAX_DEPTH="$2"; shift 2 ;;
|
|
166
|
+
--max-depth=*)
|
|
167
|
+
require_int "--max-depth" "${1#*=}"
|
|
168
|
+
MAX_DEPTH="${1#*=}"; shift ;;
|
|
169
|
+
--top)
|
|
170
|
+
[ "$#" -ge 2 ] || die_usage "--top requires a value"
|
|
171
|
+
require_int "--top" "$2"
|
|
172
|
+
TOP="$2"; shift 2 ;;
|
|
173
|
+
--top=*)
|
|
174
|
+
require_int "--top" "${1#*=}"
|
|
175
|
+
TOP="${1#*=}"; shift ;;
|
|
176
|
+
--no-gitignore)
|
|
177
|
+
NO_GITIGNORE=1; shift ;;
|
|
178
|
+
-h|--help)
|
|
179
|
+
usage; exit 0 ;;
|
|
180
|
+
--)
|
|
181
|
+
shift
|
|
182
|
+
[ "$#" -eq 1 ] || die_usage "exactly one target is required"
|
|
183
|
+
TARGET="$1"; shift ;;
|
|
184
|
+
-*)
|
|
185
|
+
die_usage "unknown option \"$1\"" ;;
|
|
186
|
+
*)
|
|
187
|
+
[ -z "$TARGET" ] || die_usage "exactly one target is required (got \"$TARGET\" and \"$1\")"
|
|
188
|
+
TARGET="$1"; shift ;;
|
|
189
|
+
esac
|
|
190
|
+
done
|
|
191
|
+
|
|
192
|
+
[ -n "$TARGET" ] || die_usage "a target is required"
|
|
193
|
+
|
|
194
|
+
# --- target resolution -------------------------------------------------------------------------
|
|
195
|
+
# Portable absolute-path resolution. `readlink -f` is GNU-only and absent from older macOS/BSD
|
|
196
|
+
# userland, so resolve via cd + `pwd -P` instead.
|
|
197
|
+
resolve_path() {
|
|
198
|
+
local p="$1" dir base rdir
|
|
199
|
+
if [ -d "$p" ]; then
|
|
200
|
+
(cd -- "$p" && pwd -P)
|
|
201
|
+
else
|
|
202
|
+
dir=$(dirname -- "$p")
|
|
203
|
+
base=$(basename -- "$p")
|
|
204
|
+
rdir=$(cd -- "$dir" && pwd -P)
|
|
205
|
+
printf '%s\n' "${rdir%/}/$base"
|
|
206
|
+
fi
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if [ ! -e "$TARGET" ]; then
|
|
210
|
+
echo "Error: target does not exist: $TARGET" >&2
|
|
211
|
+
exit 2
|
|
212
|
+
fi
|
|
213
|
+
if [ ! -r "$TARGET" ]; then
|
|
214
|
+
echo "Error: target is not readable: $TARGET" >&2
|
|
215
|
+
exit 2
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
RESOLVED=$(resolve_path "$TARGET") || {
|
|
219
|
+
echo "Error: could not resolve target to an absolute path: $TARGET" >&2
|
|
220
|
+
exit 2
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if [ -f "$RESOLVED" ]; then
|
|
224
|
+
TARGET_TYPE="file"
|
|
225
|
+
BASE_DIR=$(dirname -- "$RESOLVED")
|
|
226
|
+
SEARCH_DIR="$BASE_DIR"
|
|
227
|
+
elif [ -d "$RESOLVED" ]; then
|
|
228
|
+
if [ ! -x "$RESOLVED" ]; then
|
|
229
|
+
echo "Error: target directory is not traversable (no execute permission): $RESOLVED" >&2
|
|
230
|
+
exit 2
|
|
231
|
+
fi
|
|
232
|
+
BASE_DIR="$RESOLVED"
|
|
233
|
+
SEARCH_DIR="$RESOLVED"
|
|
234
|
+
TOPLEVEL=$(git -C "$RESOLVED" rev-parse --show-toplevel 2>/dev/null || true)
|
|
235
|
+
if [ -n "$TOPLEVEL" ] && [ "$(cd -- "$TOPLEVEL" && pwd -P)" = "$RESOLVED" ]; then
|
|
236
|
+
TARGET_TYPE="repo_root"
|
|
237
|
+
else
|
|
238
|
+
TARGET_TYPE="directory"
|
|
239
|
+
fi
|
|
240
|
+
else
|
|
241
|
+
echo "Error: target is neither a regular file nor a directory: $RESOLVED" >&2
|
|
242
|
+
exit 2
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
# --- file discovery ----------------------------------------------------------------------------
|
|
246
|
+
IN_GIT=0
|
|
247
|
+
if git -C "$SEARCH_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
248
|
+
IN_GIT=1
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
IGNORED_TARGET=0
|
|
252
|
+
|
|
253
|
+
# Discovery implementations. Both emit NUL-separated paths relative to the target directory.
|
|
254
|
+
git_list() { git -C "$RESOLVED" ls-files --cached --others --exclude-standard -z -- . ; }
|
|
255
|
+
find_list() {
|
|
256
|
+
( cd -- "$RESOLVED" && find . \( -name .git -type d \) -prune -o -type f -print0 )
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Cheap emptiness probes. `head -c 1` closes the pipe after the first byte, so the producer is
|
|
260
|
+
# SIGPIPE'd immediately instead of walking the whole tree -- these stay O(1) on large targets.
|
|
261
|
+
# `|| true` absorbs both that SIGPIPE and, for git, a non-zero exit under `set -o pipefail`.
|
|
262
|
+
produces_output() {
|
|
263
|
+
# Do NOT collapse the declaration and the assignment into `local n=$(...)`: `local` would then
|
|
264
|
+
# supply the statement's exit status and mask the pipeline's (shellcheck SC2155), defeating the
|
|
265
|
+
# `|| true` below. The split, the `|| true`, and the fact that every call site is a condition
|
|
266
|
+
# context (where `set -e` is suspended) are all three load-bearing.
|
|
267
|
+
local n
|
|
268
|
+
n=$( "$1" 2>/dev/null | head -c 1 | wc -c | tr -d '[:space:]' ) || true
|
|
269
|
+
[ "${n:-0}" -gt 0 ]
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if [ "$TARGET_TYPE" = "file" ]; then
|
|
273
|
+
# A single file needs no discovery pass; git/find would only re-derive the one path.
|
|
274
|
+
# An explicitly-named file is ALWAYS enumerated even when ignored -- an explicit target wins --
|
|
275
|
+
# but the caller is told about it so `import` mode can reason about provenance.
|
|
276
|
+
if [ "$IN_GIT" -eq 1 ] && [ "$NO_GITIGNORE" -eq 0 ] \
|
|
277
|
+
&& git -C "$BASE_DIR" check-ignore -q -- "$RESOLVED" 2>/dev/null; then
|
|
278
|
+
IGNORED_TARGET=1
|
|
279
|
+
fi
|
|
280
|
+
LISTING_SOURCE="explicit"
|
|
281
|
+
GITIGNORE_RESPECTED=0
|
|
282
|
+
list_files() { printf '%s\0' "$(basename -- "$RESOLVED")"; }
|
|
283
|
+
elif [ "$IN_GIT" -eq 1 ] && [ "$NO_GITIGNORE" -eq 0 ]; then
|
|
284
|
+
if produces_output git_list; then
|
|
285
|
+
LISTING_SOURCE="git-ls-files"
|
|
286
|
+
GITIGNORE_RESPECTED=1
|
|
287
|
+
list_files() { git_list; }
|
|
288
|
+
elif produces_output find_list; then
|
|
289
|
+
# Git found nothing but files exist on disk: the target itself is ignored, or all of its
|
|
290
|
+
# content is. Falling back keeps `import` mode from silently analysing an empty payload.
|
|
291
|
+
IGNORED_TARGET=1
|
|
292
|
+
LISTING_SOURCE="find"
|
|
293
|
+
GITIGNORE_RESPECTED=0
|
|
294
|
+
list_files() { find_list; }
|
|
295
|
+
else
|
|
296
|
+
# Genuinely empty. Report it as a normal gitignore-respecting run with zero files.
|
|
297
|
+
LISTING_SOURCE="git-ls-files"
|
|
298
|
+
GITIGNORE_RESPECTED=1
|
|
299
|
+
list_files() { git_list; }
|
|
300
|
+
fi
|
|
301
|
+
else
|
|
302
|
+
if [ "$IN_GIT" -eq 1 ] && [ "$NO_GITIGNORE" -eq 1 ] \
|
|
303
|
+
&& ! produces_output git_list && produces_output find_list; then
|
|
304
|
+
IGNORED_TARGET=1
|
|
305
|
+
fi
|
|
306
|
+
LISTING_SOURCE="find"
|
|
307
|
+
GITIGNORE_RESPECTED=0
|
|
308
|
+
list_files() { find_list; }
|
|
309
|
+
fi
|
|
310
|
+
|
|
311
|
+
# --- aggregation and JSON emission ---------------------------------------------------------------
|
|
312
|
+
# The aggregator is held in a variable and handed to `python3 -c` rather than piped in on stdin:
|
|
313
|
+
# `python3 -` would consume stdin as the program text, leaving nothing for the file list to arrive on.
|
|
314
|
+
PY_SRC=$(cat <<'PY'
|
|
315
|
+
import json
|
|
316
|
+
import os
|
|
317
|
+
import sys
|
|
318
|
+
from collections import defaultdict
|
|
319
|
+
|
|
320
|
+
(base_dir, target, target_type, listing_source,
|
|
321
|
+
max_depth_s, top_s, gitignore_s, ignored_target_s) = sys.argv[1:9]
|
|
322
|
+
max_depth = int(max_depth_s)
|
|
323
|
+
top = int(top_s)
|
|
324
|
+
gitignore_respected = gitignore_s == "1"
|
|
325
|
+
ignored_target = ignored_target_s == "1"
|
|
326
|
+
unlimited = max_depth <= 0
|
|
327
|
+
|
|
328
|
+
CHUNK = 1 << 20 # 1 MiB read window -- keeps memory flat on very large files
|
|
329
|
+
SNIFF = 8192 # bytes inspected for a NUL byte when classifying binary
|
|
330
|
+
SKIP_CAP = 25 # per-reason cap on reported skipped paths
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def normalise(raw):
|
|
334
|
+
s = os.fsdecode(raw)
|
|
335
|
+
while s.startswith("./"):
|
|
336
|
+
s = s[2:]
|
|
337
|
+
return s
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
data = sys.stdin.buffer.read()
|
|
341
|
+
rel_paths = sorted({normalise(p) for p in data.split(b"\0") if p})
|
|
342
|
+
|
|
343
|
+
files = []
|
|
344
|
+
skipped = defaultdict(list)
|
|
345
|
+
|
|
346
|
+
for rel in rel_paths:
|
|
347
|
+
abs_path = os.path.join(base_dir, rel)
|
|
348
|
+
if os.path.islink(abs_path):
|
|
349
|
+
# Excluded deliberately: following them would double-count, or escape the target entirely.
|
|
350
|
+
skipped["symlinks"].append(rel)
|
|
351
|
+
continue
|
|
352
|
+
if not os.path.isfile(abs_path):
|
|
353
|
+
# git ls-files reports index entries that may be deleted on disk, and gitlink entries
|
|
354
|
+
# for submodules, neither of which is a readable regular file here.
|
|
355
|
+
skipped["missing"].append(rel)
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
size = os.path.getsize(abs_path)
|
|
359
|
+
binary = False
|
|
360
|
+
lines = 0
|
|
361
|
+
with open(abs_path, "rb") as fh:
|
|
362
|
+
head = fh.read(SNIFF)
|
|
363
|
+
if b"\0" in head:
|
|
364
|
+
binary = True
|
|
365
|
+
else:
|
|
366
|
+
lines = head.count(b"\n")
|
|
367
|
+
last = head[-1:]
|
|
368
|
+
while True:
|
|
369
|
+
chunk = fh.read(CHUNK)
|
|
370
|
+
if not chunk:
|
|
371
|
+
break
|
|
372
|
+
lines += chunk.count(b"\n")
|
|
373
|
+
last = chunk[-1:]
|
|
374
|
+
if size > 0 and last != b"\n":
|
|
375
|
+
lines += 1 # count a final line with no trailing newline
|
|
376
|
+
except OSError:
|
|
377
|
+
skipped["unreadable"].append(rel)
|
|
378
|
+
continue
|
|
379
|
+
files.append({"path": rel, "lines": 0 if binary else lines, "bytes": size, "binary": binary})
|
|
380
|
+
|
|
381
|
+
# --- per-extension aggregation -------------------------------------------------------------------
|
|
382
|
+
ext_files = defaultdict(int)
|
|
383
|
+
ext_lines = defaultdict(int)
|
|
384
|
+
for f in files:
|
|
385
|
+
ext = os.path.splitext(f["path"])[1] or "(none)"
|
|
386
|
+
ext_files[ext] += 1
|
|
387
|
+
ext_lines[ext] += f["lines"]
|
|
388
|
+
|
|
389
|
+
extensions = [
|
|
390
|
+
{"extension": e, "files": ext_files[e], "lines": ext_lines[e]}
|
|
391
|
+
for e in sorted(ext_files, key=lambda e: (-ext_files[e], e))
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
# --- depth-bounded tree ----------------------------------------------------------------------------
|
|
395
|
+
dir_file_count = defaultdict(int) # recursive: every ancestor of a file is credited with it
|
|
396
|
+
for f in files:
|
|
397
|
+
parts = f["path"].split("/")
|
|
398
|
+
for i in range(1, len(parts)):
|
|
399
|
+
dir_file_count["/".join(parts[:i])] += 1
|
|
400
|
+
|
|
401
|
+
tree = []
|
|
402
|
+
tree_truncated = False
|
|
403
|
+
|
|
404
|
+
for d in dir_file_count:
|
|
405
|
+
depth = d.count("/") + 1
|
|
406
|
+
if not unlimited and depth > max_depth:
|
|
407
|
+
continue
|
|
408
|
+
# A directory sitting exactly at the bound has all of its children excluded, so it is
|
|
409
|
+
# truncated by definition -- directories only exist here because they contain files.
|
|
410
|
+
truncated = (not unlimited) and depth == max_depth
|
|
411
|
+
tree_truncated = tree_truncated or truncated
|
|
412
|
+
tree.append({
|
|
413
|
+
"path": d,
|
|
414
|
+
"type": "directory",
|
|
415
|
+
"depth": depth,
|
|
416
|
+
"file_count": dir_file_count[d],
|
|
417
|
+
"truncated": truncated,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
for f in files:
|
|
421
|
+
depth = f["path"].count("/") + 1
|
|
422
|
+
if not unlimited and depth > max_depth:
|
|
423
|
+
tree_truncated = True
|
|
424
|
+
continue
|
|
425
|
+
tree.append({
|
|
426
|
+
"path": f["path"],
|
|
427
|
+
"type": "file",
|
|
428
|
+
"depth": depth,
|
|
429
|
+
"lines": f["lines"],
|
|
430
|
+
"bytes": f["bytes"],
|
|
431
|
+
"binary": f["binary"],
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
tree.sort(key=lambda e: (e["path"], e["type"]))
|
|
435
|
+
|
|
436
|
+
# --- largest files ------------------------------------------------------------------------------
|
|
437
|
+
largest = sorted(files, key=lambda f: (-f["lines"], f["path"]))
|
|
438
|
+
if top > 0:
|
|
439
|
+
largest = largest[:top]
|
|
440
|
+
|
|
441
|
+
summary = {
|
|
442
|
+
"target": target,
|
|
443
|
+
"target_type": target_type,
|
|
444
|
+
"base_dir": base_dir,
|
|
445
|
+
"listing_source": listing_source,
|
|
446
|
+
"gitignore_respected": gitignore_respected,
|
|
447
|
+
"ignored_target": ignored_target,
|
|
448
|
+
"max_depth": max_depth,
|
|
449
|
+
"total_files": len(files),
|
|
450
|
+
"total_lines": sum(f["lines"] for f in files),
|
|
451
|
+
"total_bytes": sum(f["bytes"] for f in files),
|
|
452
|
+
"binary_files": sum(1 for f in files if f["binary"]),
|
|
453
|
+
"extensions": extensions,
|
|
454
|
+
"tree": tree,
|
|
455
|
+
"tree_truncated": tree_truncated,
|
|
456
|
+
"largest_files": largest,
|
|
457
|
+
"skipped": {
|
|
458
|
+
reason: {"count": len(paths), "paths": sorted(paths)[:SKIP_CAP]}
|
|
459
|
+
for reason, paths in sorted(skipped.items())
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
json.dump(summary, sys.stdout, indent=2, ensure_ascii=False)
|
|
464
|
+
sys.stdout.write("\n")
|
|
465
|
+
PY
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
list_files | python3 -c "$PY_SRC" \
|
|
469
|
+
"$BASE_DIR" "$RESOLVED" "$TARGET_TYPE" "$LISTING_SOURCE" \
|
|
470
|
+
"$MAX_DEPTH" "$TOP" "$GITIGNORE_RESPECTED" "$IGNORED_TARGET"
|