@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,655 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# run-engine.sh — mode-agnostic entry point for the /uncharted investigative engine
|
|
3
|
+
#
|
|
4
|
+
# Usage: run-engine.sh [options] <target>
|
|
5
|
+
# run-engine.sh --help
|
|
6
|
+
#
|
|
7
|
+
# This is the ONE script the calling agent invokes. It does not reimplement any analysis:
|
|
8
|
+
# it orchestrates the three deterministic detectors delivered by E40_S01_T02/T03, merges
|
|
9
|
+
# their JSON, renders the understanding document from
|
|
10
|
+
# skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md, writes it into
|
|
11
|
+
# project/rapports/analysis/, and prints the written path — and nothing else — on stdout.
|
|
12
|
+
#
|
|
13
|
+
# enumerate-target.sh → Target + Structure sections
|
|
14
|
+
# detect-dependencies.sh → Key Dependencies section
|
|
15
|
+
# detect-tests.sh → Existing Tests section
|
|
16
|
+
#
|
|
17
|
+
# All three modes (`segment`, `import`, `onboard`) call this script. That is what makes the
|
|
18
|
+
# engine shared: mode changes the filename, the Target section, and the default tree depth —
|
|
19
|
+
# never the analysis itself.
|
|
20
|
+
#
|
|
21
|
+
# MECHANICAL vs JUDGEMENT. This script fills Target, Structure, Key Dependencies and Existing
|
|
22
|
+
# Tests from real detector output. It leaves Purpose, Risk Areas and Open Questions as the
|
|
23
|
+
# template's `_TODO(agent):_` placeholders. It must never fabricate them; the scrum-master fills
|
|
24
|
+
# them in from the mechanical evidence.
|
|
25
|
+
#
|
|
26
|
+
# Side effects: exactly one file written (the document), plus the merged JSON when --json-out is
|
|
27
|
+
# given, plus mkdir -p on the output directory. The three detectors it calls are strictly
|
|
28
|
+
# read-only. No application code is ever modified.
|
|
29
|
+
#
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# OPTIONS
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# --mode segment|import|onboard
|
|
34
|
+
# Mode hint. Default: segment. Reflected in the output filename and in the document's
|
|
35
|
+
# Target section. `onboard` additionally defaults --max-depth to 2 (coarse by design).
|
|
36
|
+
# --max-depth N Tree depth bound, forwarded to enumerate-target.sh.
|
|
37
|
+
# Default 2 for onboard, 3 otherwise. 0 = unlimited.
|
|
38
|
+
# --top N Number of largest files to report. Default 10. 0 = all.
|
|
39
|
+
# --no-gitignore Forwarded to enumerate-target.sh: enumerate .gitignore'd content too.
|
|
40
|
+
# --origin <text> Value for the Target section's Origin row. Default is derived:
|
|
41
|
+
# "in-repo", or "outside this repository (<path>)". `import` mode should
|
|
42
|
+
# pass its acquired source, e.g. --origin "imported from <git url>".
|
|
43
|
+
# --out-dir <dir> Output directory. Default: <repo-root>/project/rapports/analysis
|
|
44
|
+
# --json-out <file> Also write the merged raw detector JSON here, for an agent that wants
|
|
45
|
+
# to reason over the evidence directly. Not written unless requested.
|
|
46
|
+
# --template <file> Override the understanding-document template. Defaults to
|
|
47
|
+
# ../assets/UNDERSTANDING_DOC_TEMPLATE.md relative to this script.
|
|
48
|
+
# -h, --help Show this help and exit 0.
|
|
49
|
+
#
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# OUTPUT
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# stdout : the absolute path of the written document. One line. Nothing else — so callers can
|
|
54
|
+
# do DOC=$(run-engine.sh ...) safely.
|
|
55
|
+
# stderr : notices and skipped-path diagnostics forwarded from the detectors, plus any render
|
|
56
|
+
# warnings. Never silently swallowed, never injected into the document (the seven
|
|
57
|
+
# document headings are fixed, so there is nowhere to put them).
|
|
58
|
+
#
|
|
59
|
+
# Filename: uncharted-<mode>-<slug>-<YYYYMMDDTHHMMSSZ>.md
|
|
60
|
+
# <slug> is derived from the target's basename. A collision suffix (-2, -3, …) is
|
|
61
|
+
# appended rather than overwriting an existing document.
|
|
62
|
+
#
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# TEMPLATE CONTRACT CONSUMED
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# SCALAR tokens {{TOKEN}} substituted once, in place.
|
|
67
|
+
# ROW templates a table row line ending in `<!-- ROW -->` is repeated once per record and
|
|
68
|
+
# dropped entirely when there are no records. A row whose tokens this renderer
|
|
69
|
+
# does not recognise is dropped with a stderr warning — an unsubstituted
|
|
70
|
+
# {{TOKEN}} must never reach the document.
|
|
71
|
+
# The template's own leading `<!-- OUTPUT CONTRACT ... -->` block is instructions to this
|
|
72
|
+
# renderer, not document content, and is removed from the output. Per-section
|
|
73
|
+
# MECHANICAL/JUDGEMENT comments are kept: they instruct the agent who fills the judgement
|
|
74
|
+
# sections.
|
|
75
|
+
#
|
|
76
|
+
# Exit codes:
|
|
77
|
+
# 0 — success; document written and its path printed
|
|
78
|
+
# 1 — usage error (unknown flag, bad mode, missing/duplicate target, bad numeric value)
|
|
79
|
+
# 2 — target error (does not exist / not readable)
|
|
80
|
+
# 3 — an upstream detector script is missing or failed (its stderr is surfaced)
|
|
81
|
+
# 4 — render or write failure (template missing, heading check failed, unwritable output or
|
|
82
|
+
# --json-out directory). No document is left behind on this path: both destinations are
|
|
83
|
+
# validated before any detector runs, and the document is the last thing written.
|
|
84
|
+
#
|
|
85
|
+
# Requires: bash, python3, and the three sibling detector scripts. jq is NOT required.
|
|
86
|
+
|
|
87
|
+
set -euo pipefail
|
|
88
|
+
|
|
89
|
+
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
|
90
|
+
|
|
91
|
+
MODE="segment"
|
|
92
|
+
MAX_DEPTH=""
|
|
93
|
+
TOP=10
|
|
94
|
+
NO_GITIGNORE=0
|
|
95
|
+
ORIGIN=""
|
|
96
|
+
OUT_DIR=""
|
|
97
|
+
JSON_OUT=""
|
|
98
|
+
TEMPLATE="$SCRIPT_DIR/../assets/UNDERSTANDING_DOC_TEMPLATE.md"
|
|
99
|
+
TARGET=""
|
|
100
|
+
|
|
101
|
+
usage() {
|
|
102
|
+
cat <<EOF
|
|
103
|
+
Usage: $(basename "$0") [options] <target>
|
|
104
|
+
|
|
105
|
+
Run the shared /uncharted investigative engine against <target> (a file, a directory, or a
|
|
106
|
+
repository root). Invokes enumerate-target.sh, detect-dependencies.sh and detect-tests.sh,
|
|
107
|
+
renders the understanding document, and prints the written path on stdout.
|
|
108
|
+
|
|
109
|
+
Options:
|
|
110
|
+
--mode MODE segment | import | onboard (default: segment)
|
|
111
|
+
--max-depth N tree depth bound (default: 2 for onboard, 3 otherwise; 0 = unlimited)
|
|
112
|
+
--top N largest-files count (default: 10; 0 = all)
|
|
113
|
+
--no-gitignore enumerate .gitignore'd content too
|
|
114
|
+
--origin TEXT Origin value for the Target section (default: derived)
|
|
115
|
+
--out-dir DIR output directory (default: <repo-root>/project/rapports/analysis)
|
|
116
|
+
--json-out FILE also write the merged raw detector JSON here
|
|
117
|
+
--template FILE override the understanding-document template
|
|
118
|
+
-h, --help show this help and exit
|
|
119
|
+
|
|
120
|
+
Exit codes: 0 success, 1 usage error, 2 target error, 3 detector failure, 4 render/write failure.
|
|
121
|
+
EOF
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
die_usage() {
|
|
125
|
+
echo "Error: $1" >&2
|
|
126
|
+
echo >&2
|
|
127
|
+
usage >&2
|
|
128
|
+
exit 1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
require_int() {
|
|
132
|
+
case "$2" in
|
|
133
|
+
''|*[!0-9]*) die_usage "$1 requires a non-negative integer, got \"$2\"" ;;
|
|
134
|
+
esac
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
require_value() {
|
|
138
|
+
# require_value <flag> <remaining-arg-count>
|
|
139
|
+
[ "$2" -ge 2 ] || die_usage "$1 requires a value"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
while [ "$#" -gt 0 ]; do
|
|
143
|
+
case "$1" in
|
|
144
|
+
--mode) require_value "--mode" "$#"; MODE="$2"; shift 2 ;;
|
|
145
|
+
--mode=*) MODE="${1#*=}"; shift ;;
|
|
146
|
+
--max-depth) require_value "--max-depth" "$#"; require_int "--max-depth" "$2"; MAX_DEPTH="$2"; shift 2 ;;
|
|
147
|
+
--max-depth=*) require_int "--max-depth" "${1#*=}"; MAX_DEPTH="${1#*=}"; shift ;;
|
|
148
|
+
--top) require_value "--top" "$#"; require_int "--top" "$2"; TOP="$2"; shift 2 ;;
|
|
149
|
+
--top=*) require_int "--top" "${1#*=}"; TOP="${1#*=}"; shift ;;
|
|
150
|
+
--no-gitignore) NO_GITIGNORE=1; shift ;;
|
|
151
|
+
--origin) require_value "--origin" "$#"; ORIGIN="$2"; shift 2 ;;
|
|
152
|
+
--origin=*) ORIGIN="${1#*=}"; shift ;;
|
|
153
|
+
--out-dir) require_value "--out-dir" "$#"; OUT_DIR="$2"; shift 2 ;;
|
|
154
|
+
--out-dir=*) OUT_DIR="${1#*=}"; shift ;;
|
|
155
|
+
--json-out) require_value "--json-out" "$#"; JSON_OUT="$2"; shift 2 ;;
|
|
156
|
+
--json-out=*) JSON_OUT="${1#*=}"; shift ;;
|
|
157
|
+
--template) require_value "--template" "$#"; TEMPLATE="$2"; shift 2 ;;
|
|
158
|
+
--template=*) TEMPLATE="${1#*=}"; shift ;;
|
|
159
|
+
-h|--help) usage; exit 0 ;;
|
|
160
|
+
--)
|
|
161
|
+
shift
|
|
162
|
+
[ "$#" -eq 1 ] || die_usage "exactly one target is required"
|
|
163
|
+
TARGET="$1"; shift ;;
|
|
164
|
+
-*)
|
|
165
|
+
die_usage "unknown option \"$1\"" ;;
|
|
166
|
+
*)
|
|
167
|
+
[ -z "$TARGET" ] || die_usage "exactly one target is required (got \"$TARGET\" and \"$1\")"
|
|
168
|
+
TARGET="$1"; shift ;;
|
|
169
|
+
esac
|
|
170
|
+
done
|
|
171
|
+
|
|
172
|
+
case "$MODE" in
|
|
173
|
+
segment|import|onboard) ;;
|
|
174
|
+
*) die_usage "--mode must be one of segment, import, onboard (got \"$MODE\")" ;;
|
|
175
|
+
esac
|
|
176
|
+
|
|
177
|
+
[ -n "$TARGET" ] || die_usage "a target is required"
|
|
178
|
+
|
|
179
|
+
if [ ! -e "$TARGET" ]; then
|
|
180
|
+
echo "Error: target does not exist: $TARGET" >&2
|
|
181
|
+
exit 2
|
|
182
|
+
fi
|
|
183
|
+
if [ ! -r "$TARGET" ]; then
|
|
184
|
+
echo "Error: target is not readable: $TARGET" >&2
|
|
185
|
+
exit 2
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
# `onboard` is coarse-first by design (E40 epic): it wants subsystems, not a per-file listing.
|
|
189
|
+
if [ -z "$MAX_DEPTH" ]; then
|
|
190
|
+
if [ "$MODE" = "onboard" ]; then MAX_DEPTH=2; else MAX_DEPTH=3; fi
|
|
191
|
+
fi
|
|
192
|
+
|
|
193
|
+
if [ ! -f "$TEMPLATE" ]; then
|
|
194
|
+
echo "Error: understanding-document template not found: $TEMPLATE" >&2
|
|
195
|
+
exit 4
|
|
196
|
+
fi
|
|
197
|
+
TEMPLATE=$(cd -- "$(dirname -- "$TEMPLATE")" && pwd -P)/$(basename -- "$TEMPLATE")
|
|
198
|
+
|
|
199
|
+
# --- repo root ----------------------------------------------------------------------------------
|
|
200
|
+
# Anchored on THIS SCRIPT, not on the target: the analysis rapport belongs to the project that owns
|
|
201
|
+
# the engine, even when the target lives outside it (import mode staging areas, out-of-repo paths).
|
|
202
|
+
REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
|
203
|
+
[ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
|
|
204
|
+
|
|
205
|
+
[ -n "$OUT_DIR" ] || OUT_DIR="$REPO_ROOT/project/rapports/analysis"
|
|
206
|
+
mkdir -p "$OUT_DIR" 2>/dev/null || {
|
|
207
|
+
echo "Error: could not create output directory: $OUT_DIR" >&2
|
|
208
|
+
exit 4
|
|
209
|
+
}
|
|
210
|
+
[ -w "$OUT_DIR" ] || { echo "Error: output directory is not writable: $OUT_DIR" >&2; exit 4; }
|
|
211
|
+
OUT_DIR=$(cd -- "$OUT_DIR" && pwd -P)
|
|
212
|
+
|
|
213
|
+
# Pre-flight the --json-out destination too. Checking it here, before any detector runs, is what
|
|
214
|
+
# keeps the exit-4 contract honest: a bad path fails fast with nothing written, rather than
|
|
215
|
+
# surfacing after the document has already been created.
|
|
216
|
+
if [ -n "$JSON_OUT" ]; then
|
|
217
|
+
JSON_OUT_DIR=$(dirname -- "$JSON_OUT")
|
|
218
|
+
if [ ! -d "$JSON_OUT_DIR" ]; then
|
|
219
|
+
echo "Error: --json-out directory does not exist: $JSON_OUT_DIR" >&2
|
|
220
|
+
exit 4
|
|
221
|
+
fi
|
|
222
|
+
if [ ! -w "$JSON_OUT_DIR" ]; then
|
|
223
|
+
echo "Error: --json-out directory is not writable: $JSON_OUT_DIR" >&2
|
|
224
|
+
exit 4
|
|
225
|
+
fi
|
|
226
|
+
JSON_OUT=$(cd -- "$JSON_OUT_DIR" && pwd -P)/$(basename -- "$JSON_OUT")
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
# --- run the three detectors --------------------------------------------------------------------
|
|
230
|
+
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/uncharted-engine.XXXXXX")
|
|
231
|
+
cleanup() { rm -rf "$TMP_DIR"; }
|
|
232
|
+
trap cleanup EXIT
|
|
233
|
+
|
|
234
|
+
run_detector() {
|
|
235
|
+
# run_detector <script-name> <output-file> [args...]
|
|
236
|
+
local name="$1" out="$2"; shift 2
|
|
237
|
+
local path="$SCRIPT_DIR/$name" rc=0
|
|
238
|
+
if [ ! -f "$path" ]; then
|
|
239
|
+
echo "Error: required detector script is missing: $path" >&2
|
|
240
|
+
exit 3
|
|
241
|
+
fi
|
|
242
|
+
bash "$path" "$@" >"$out" 2>"$TMP_DIR/$name.err" || rc=$?
|
|
243
|
+
if [ "$rc" -ne 0 ]; then
|
|
244
|
+
echo "Error: $name exited $rc for target: $TARGET" >&2
|
|
245
|
+
sed 's/^/ ['"$name"'] /' "$TMP_DIR/$name.err" >&2 || true
|
|
246
|
+
exit 3
|
|
247
|
+
fi
|
|
248
|
+
if [ -s "$TMP_DIR/$name.err" ]; then
|
|
249
|
+
sed 's/^/ ['"$name"'] /' "$TMP_DIR/$name.err" >&2 || true
|
|
250
|
+
fi
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
GITIGNORE_ARGS=()
|
|
254
|
+
[ "$NO_GITIGNORE" -eq 1 ] && GITIGNORE_ARGS+=(--no-gitignore)
|
|
255
|
+
|
|
256
|
+
run_detector enumerate-target.sh "$TMP_DIR/enumerate.json" \
|
|
257
|
+
--max-depth "$MAX_DEPTH" --top "$TOP" "${GITIGNORE_ARGS[@]+"${GITIGNORE_ARGS[@]}"}" -- "$TARGET"
|
|
258
|
+
run_detector detect-dependencies.sh "$TMP_DIR/dependencies.json" "$TARGET"
|
|
259
|
+
run_detector detect-tests.sh "$TMP_DIR/tests.json" "$TARGET"
|
|
260
|
+
|
|
261
|
+
# --- output path --------------------------------------------------------------------------------
|
|
262
|
+
# Resolve before slugging: a target of "." or "path/to/dir/" would otherwise slug to nothing.
|
|
263
|
+
if [ -d "$TARGET" ]; then
|
|
264
|
+
RESOLVED_TARGET=$(cd -- "$TARGET" && pwd -P)
|
|
265
|
+
else
|
|
266
|
+
RESOLVED_TARGET=$(cd -- "$(dirname -- "$TARGET")" && pwd -P)/$(basename -- "$TARGET")
|
|
267
|
+
fi
|
|
268
|
+
BASE=$(basename -- "$RESOLVED_TARGET")
|
|
269
|
+
SLUG=$(printf '%s' "$BASE" \
|
|
270
|
+
| tr '[:upper:]' '[:lower:]' \
|
|
271
|
+
| sed -e 's/[^a-z0-9]\{1,\}/-/g' -e 's/^-*//' -e 's/-*$//' \
|
|
272
|
+
| cut -c1-40)
|
|
273
|
+
[ -n "$SLUG" ] || SLUG="target"
|
|
274
|
+
|
|
275
|
+
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
|
276
|
+
ISO_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
277
|
+
|
|
278
|
+
OUT_FILE="$OUT_DIR/uncharted-$MODE-$SLUG-$STAMP.md"
|
|
279
|
+
n=2
|
|
280
|
+
while [ -e "$OUT_FILE" ]; do
|
|
281
|
+
OUT_FILE="$OUT_DIR/uncharted-$MODE-$SLUG-$STAMP-$n.md"
|
|
282
|
+
n=$((n + 1))
|
|
283
|
+
done
|
|
284
|
+
|
|
285
|
+
# --- render -------------------------------------------------------------------------------------
|
|
286
|
+
PY_SRC=$(cat <<'PY'
|
|
287
|
+
import json
|
|
288
|
+
import os
|
|
289
|
+
import re
|
|
290
|
+
import sys
|
|
291
|
+
|
|
292
|
+
(tpl_path, enum_path, deps_path, tests_path, mode, origin_arg,
|
|
293
|
+
timestamp, out_path, repo_root, json_out) = sys.argv[1:11]
|
|
294
|
+
|
|
295
|
+
warnings = []
|
|
296
|
+
notices = []
|
|
297
|
+
|
|
298
|
+
EXT_ROW_CAP = 20
|
|
299
|
+
LIST_CAP = 25
|
|
300
|
+
TREE_LINE_CAP = 200
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load(path, label):
|
|
304
|
+
try:
|
|
305
|
+
with open(path, encoding="utf-8") as fh:
|
|
306
|
+
return json.load(fh)
|
|
307
|
+
except (OSError, ValueError) as exc:
|
|
308
|
+
sys.stderr.write("Error: could not parse %s output: %s\n" % (label, exc))
|
|
309
|
+
sys.exit(4)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
enum = load(enum_path, "enumerate-target.sh")
|
|
313
|
+
deps = load(deps_path, "detect-dependencies.sh")
|
|
314
|
+
tests = load(tests_path, "detect-tests.sh")
|
|
315
|
+
|
|
316
|
+
root = repo_root.rstrip("/")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def rel(path):
|
|
320
|
+
"""Repo-relative when the path is inside the engine's repo, absolute otherwise."""
|
|
321
|
+
if not path:
|
|
322
|
+
return path
|
|
323
|
+
if path == root:
|
|
324
|
+
return "."
|
|
325
|
+
if path.startswith(root + "/"):
|
|
326
|
+
return path[len(root) + 1:]
|
|
327
|
+
return path
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def code(s):
|
|
331
|
+
return "`%s`" % s
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def num(n):
|
|
335
|
+
return "{:,}".format(n)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def bullets(items, empty):
|
|
339
|
+
if not items:
|
|
340
|
+
return empty
|
|
341
|
+
shown = items[:LIST_CAP]
|
|
342
|
+
out = ["- " + i for i in shown]
|
|
343
|
+
if len(items) > len(shown):
|
|
344
|
+
out.append("- _… %d more; re-run with `--json-out` for the full list._"
|
|
345
|
+
% (len(items) - len(shown)))
|
|
346
|
+
return "\n".join(out)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --- Target ---------------------------------------------------------------------------------
|
|
350
|
+
target_abs = enum.get("target", "")
|
|
351
|
+
target_type = enum.get("target_type", "unknown")
|
|
352
|
+
target_rel = rel(target_abs)
|
|
353
|
+
target_name = target_rel if target_rel not in ("", ".") else os.path.basename(target_abs) or target_abs
|
|
354
|
+
|
|
355
|
+
if origin_arg:
|
|
356
|
+
origin = origin_arg
|
|
357
|
+
elif target_abs == root or target_abs.startswith(root + "/"):
|
|
358
|
+
origin = "in-repo"
|
|
359
|
+
else:
|
|
360
|
+
origin = "outside this repository (`%s`)" % target_abs
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def board_linkage():
|
|
364
|
+
"""Mechanical: does any board item's text mention this target's repo-relative path?"""
|
|
365
|
+
if target_type == "repo_root" or target_rel in ("", "."):
|
|
366
|
+
# A substring scan for "." would match every board file; say nothing rather than lie.
|
|
367
|
+
return "n/a — target is the repository root"
|
|
368
|
+
if not (target_abs == root or target_abs.startswith(root + "/")):
|
|
369
|
+
return "unlinked — target is outside this repository"
|
|
370
|
+
board = os.path.join(root, "project", "board")
|
|
371
|
+
if not os.path.isdir(board):
|
|
372
|
+
return "unlinked (no `project/board/` in this repository)"
|
|
373
|
+
ids = set()
|
|
374
|
+
for dirpath, _dirnames, filenames in os.walk(board):
|
|
375
|
+
for fn in filenames:
|
|
376
|
+
if not fn.endswith(".md"):
|
|
377
|
+
continue
|
|
378
|
+
try:
|
|
379
|
+
with open(os.path.join(dirpath, fn), encoding="utf-8", errors="replace") as fh:
|
|
380
|
+
text = fh.read()
|
|
381
|
+
except OSError:
|
|
382
|
+
continue
|
|
383
|
+
if target_rel in text:
|
|
384
|
+
m = re.match(r"^(E\d+(?:_S\d+)?(?:_T\d+)?)", fn)
|
|
385
|
+
ids.add(m.group(1) if m else fn[:-3])
|
|
386
|
+
if not ids:
|
|
387
|
+
return "unlinked"
|
|
388
|
+
ordered = sorted(ids)
|
|
389
|
+
shown = ordered[:8]
|
|
390
|
+
out = "linked to " + ", ".join(code(i) for i in shown)
|
|
391
|
+
if len(ordered) > len(shown):
|
|
392
|
+
out += " (+%d more)" % (len(ordered) - len(shown))
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --- Structure ------------------------------------------------------------------------------
|
|
397
|
+
ext_rows = [{"EXT": code(e.get("extension", "(none)")), "EXT_COUNT": num(e.get("files", 0))}
|
|
398
|
+
for e in enum.get("extensions", [])]
|
|
399
|
+
if len(ext_rows) > EXT_ROW_CAP:
|
|
400
|
+
hidden = len(ext_rows) - EXT_ROW_CAP
|
|
401
|
+
ext_rows = ext_rows[:EXT_ROW_CAP]
|
|
402
|
+
ext_rows.append({"EXT": "_… %d more extensions_" % hidden, "EXT_COUNT": "—"})
|
|
403
|
+
|
|
404
|
+
largest_rows = []
|
|
405
|
+
for f in enum.get("largest_files", []):
|
|
406
|
+
lines = "— (binary, %s bytes)" % num(f.get("bytes", 0)) if f.get("binary") else num(f.get("lines", 0))
|
|
407
|
+
largest_rows.append({"LARGEST_FILE": f.get("path", ""), "LARGEST_FILE_LINES": lines})
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def render_tree():
|
|
411
|
+
entries = enum.get("tree", [])
|
|
412
|
+
if not entries:
|
|
413
|
+
return "(no files enumerated)"
|
|
414
|
+
# A file target's "tree" is the one file itself; a root label would just repeat its name.
|
|
415
|
+
is_file = target_type == "file"
|
|
416
|
+
lines = [] if is_file else [target_name + "/"]
|
|
417
|
+
for e in entries[:TREE_LINE_CAP]:
|
|
418
|
+
depth = e.get("depth", 1)
|
|
419
|
+
indent = "" if is_file else " " * depth
|
|
420
|
+
name = e["path"].rsplit("/", 1)[-1]
|
|
421
|
+
if e.get("type") == "directory":
|
|
422
|
+
count = e.get("file_count", 0)
|
|
423
|
+
line = "%s%s/ (%s file%s)" % (indent, name, num(count), "" if count == 1 else "s")
|
|
424
|
+
if e.get("truncated"):
|
|
425
|
+
line += " [truncated]"
|
|
426
|
+
elif e.get("binary"):
|
|
427
|
+
line = "%s%s (binary, %s bytes)" % (indent, name, num(e.get("bytes", 0)))
|
|
428
|
+
else:
|
|
429
|
+
n_lines = e.get("lines", 0)
|
|
430
|
+
line = "%s%s (%s line%s)" % (indent, name, num(n_lines), "" if n_lines == 1 else "s")
|
|
431
|
+
lines.append(line)
|
|
432
|
+
if len(entries) > TREE_LINE_CAP:
|
|
433
|
+
lines.append("… %d further entries not shown (renderer line cap)"
|
|
434
|
+
% (len(entries) - TREE_LINE_CAP))
|
|
435
|
+
if enum.get("tree_truncated"):
|
|
436
|
+
lines.append("… tree bounded at max depth %s; deeper entries exist"
|
|
437
|
+
% enum.get("max_depth"))
|
|
438
|
+
return "\n".join(lines)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --- Key Dependencies -----------------------------------------------------------------------
|
|
442
|
+
def dep_sources(entry):
|
|
443
|
+
src = entry.get("sources") or []
|
|
444
|
+
return " (e.g. %s)" % ", ".join(code(s) for s in src[:3]) if src else ""
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
internal_items = []
|
|
448
|
+
for d in deps.get("internal", []):
|
|
449
|
+
resolved = d.get("resolved")
|
|
450
|
+
target_part = code(resolved) if resolved else "_unresolved_"
|
|
451
|
+
internal_items.append("%s → %s — %d occurrence(s), %s%s" % (
|
|
452
|
+
code(d.get("raw", "")), target_part, d.get("occurrences", 0),
|
|
453
|
+
d.get("language", "unknown"), dep_sources(d)))
|
|
454
|
+
|
|
455
|
+
external_items = []
|
|
456
|
+
for d in deps.get("external", []):
|
|
457
|
+
external_items.append("%s — %s, %d occurrence(s), %s%s" % (
|
|
458
|
+
code(d.get("name", "")), d.get("kind", "unknown"), d.get("occurrences", 0),
|
|
459
|
+
d.get("language", "unknown"), dep_sources(d)))
|
|
460
|
+
|
|
461
|
+
if deps.get("unrecognised"):
|
|
462
|
+
empty_dep = "_No recognised source language in this target — nothing to extract._"
|
|
463
|
+
else:
|
|
464
|
+
empty_dep = "_None detected._"
|
|
465
|
+
|
|
466
|
+
manifest_items = []
|
|
467
|
+
for m in deps.get("manifests", []):
|
|
468
|
+
declared = m.get("declared_dependencies")
|
|
469
|
+
if declared is None:
|
|
470
|
+
decl = "declared dependencies not parsed"
|
|
471
|
+
else:
|
|
472
|
+
decl = "declares %d dependenc%s" % (len(declared), "y" if len(declared) == 1 else "ies")
|
|
473
|
+
where = "in the target" if m.get("distance", 0) == 0 else "%d level(s) above the target" % m.get("distance", 0)
|
|
474
|
+
manifest_items.append("%s — %s; %s" % (code(m.get("path", m.get("name", ""))), where, decl))
|
|
475
|
+
|
|
476
|
+
# --- Existing Tests -------------------------------------------------------------------------
|
|
477
|
+
coverage_status = tests.get("coverage_status", "unknown")
|
|
478
|
+
coverage_summary = tests.get("coverage_summary", "")
|
|
479
|
+
coverage_cell = code(coverage_status) + (" — %s" % coverage_summary if coverage_summary else "")
|
|
480
|
+
|
|
481
|
+
test_items = ["%s (inside the target)" % code(p) for p in tests.get("target_test_files", [])]
|
|
482
|
+
for r in tests.get("referencing_test_files", []):
|
|
483
|
+
matched = r.get("matched") or []
|
|
484
|
+
suffix = " — matches: %s" % ", ".join(code(m) for m in matched[:5]) if matched else ""
|
|
485
|
+
test_items.append("%s%s" % (code(r.get("path", "")), suffix))
|
|
486
|
+
|
|
487
|
+
if test_items:
|
|
488
|
+
test_block = bullets(test_items, "")
|
|
489
|
+
ref_names = tests.get("reference_names") or []
|
|
490
|
+
if ref_names:
|
|
491
|
+
test_block += "\n\n_Matched against reference name(s): %s._" % ", ".join(
|
|
492
|
+
code(n) for n in ref_names[:8])
|
|
493
|
+
else:
|
|
494
|
+
if coverage_status == "repo_tests_only":
|
|
495
|
+
test_block = ("_None. The repository contains %s test file(s), but none of them "
|
|
496
|
+
"reference this target._" % num(tests.get("repo_test_file_count", 0)))
|
|
497
|
+
elif coverage_status == "no_tests_in_repo":
|
|
498
|
+
test_block = "_None. No test files were found anywhere in the repository._"
|
|
499
|
+
else:
|
|
500
|
+
test_block = "_None._"
|
|
501
|
+
|
|
502
|
+
runner_items = []
|
|
503
|
+
for c in tests.get("test_runner_configs", []):
|
|
504
|
+
detail = c.get("detail")
|
|
505
|
+
runner_items.append("%s — runner: %s%s" % (
|
|
506
|
+
code(c.get("path", c.get("name", ""))), c.get("runner", "unknown"),
|
|
507
|
+
" (%s)" % detail if detail else ""))
|
|
508
|
+
|
|
509
|
+
# --- token maps ------------------------------------------------------------------------------
|
|
510
|
+
SCALARS = {
|
|
511
|
+
"TARGET_NAME": target_name,
|
|
512
|
+
"MODE": mode,
|
|
513
|
+
"TIMESTAMP": timestamp,
|
|
514
|
+
"TARGET_PATH": target_rel,
|
|
515
|
+
"TARGET_TYPE": target_type,
|
|
516
|
+
"BOARD_LINKAGE": board_linkage(),
|
|
517
|
+
"ORIGIN": origin,
|
|
518
|
+
"FILE_COUNT": num(enum.get("total_files", 0)),
|
|
519
|
+
"MAX_DEPTH": "unlimited" if enum.get("max_depth", 0) == 0 else str(enum.get("max_depth")),
|
|
520
|
+
"DIRECTORY_TREE": render_tree(),
|
|
521
|
+
"INTERNAL_DEPENDENCIES": bullets(internal_items, empty_dep),
|
|
522
|
+
"EXTERNAL_DEPENDENCIES": bullets(external_items, empty_dep),
|
|
523
|
+
"MANIFEST_FILES": bullets(manifest_items, "_None found in or above the target._"),
|
|
524
|
+
"TEST_COVERAGE_STATUS": coverage_cell,
|
|
525
|
+
"TEST_FILES": test_block,
|
|
526
|
+
"TEST_RUNNER_CONFIG": bullets(runner_items, "_None detected._"),
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
ROW_SPECS = [({"EXT", "EXT_COUNT"}, ext_rows),
|
|
530
|
+
({"LARGEST_FILE", "LARGEST_FILE_LINES"}, largest_rows)]
|
|
531
|
+
|
|
532
|
+
TOKEN_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}")
|
|
533
|
+
ROW_SENTINEL = "<!-- ROW -->"
|
|
534
|
+
|
|
535
|
+
# --- render ----------------------------------------------------------------------------------
|
|
536
|
+
try:
|
|
537
|
+
with open(tpl_path, encoding="utf-8") as fh:
|
|
538
|
+
template = fh.read()
|
|
539
|
+
except OSError as exc:
|
|
540
|
+
sys.stderr.write("Error: could not read template: %s\n" % exc)
|
|
541
|
+
sys.exit(4)
|
|
542
|
+
|
|
543
|
+
# Drop the template's own contract block: it is instruction to this renderer, not document
|
|
544
|
+
# content, and it contains a literal {{DOUBLE_BRACE}} that would look like a missed substitution.
|
|
545
|
+
# The block is matched by its LINE-ANCHORED delimiters (`<!--` and `-->` each alone on a line).
|
|
546
|
+
# A plain non-greedy `<!--.*?-->` would stop at the indented `<!-- ROW -->` quoted *inside* the
|
|
547
|
+
# block and leave its tail behind as document text.
|
|
548
|
+
BLOCK_COMMENT_RE = re.compile(r"^<!--[ \t]*\n.*?^-->[ \t]*\n?", re.S | re.M)
|
|
549
|
+
for m in BLOCK_COMMENT_RE.finditer(template):
|
|
550
|
+
if "OUTPUT CONTRACT" in m.group(0):
|
|
551
|
+
template = template[:m.start()] + template[m.end():]
|
|
552
|
+
break
|
|
553
|
+
else:
|
|
554
|
+
warnings.append("template has no line-anchored OUTPUT CONTRACT block to strip")
|
|
555
|
+
|
|
556
|
+
out_lines = []
|
|
557
|
+
for line in template.split("\n"):
|
|
558
|
+
if ROW_SENTINEL not in line:
|
|
559
|
+
out_lines.append(line)
|
|
560
|
+
continue
|
|
561
|
+
row_tpl = line.replace(ROW_SENTINEL, "").rstrip()
|
|
562
|
+
tokens = set(TOKEN_RE.findall(row_tpl))
|
|
563
|
+
records = None
|
|
564
|
+
for spec_tokens, spec_records in ROW_SPECS:
|
|
565
|
+
if tokens & spec_tokens:
|
|
566
|
+
records = spec_records
|
|
567
|
+
break
|
|
568
|
+
if records is None:
|
|
569
|
+
# An unknown repeating row — added to the template after this renderer was written.
|
|
570
|
+
# Dropping it is the only safe move: emitting it would leak raw {{TOKEN}}s.
|
|
571
|
+
warnings.append("dropped unrecognised ROW template: %s" % row_tpl.strip())
|
|
572
|
+
continue
|
|
573
|
+
for rec in records:
|
|
574
|
+
rendered = row_tpl
|
|
575
|
+
for tok, val in rec.items():
|
|
576
|
+
rendered = rendered.replace("{{%s}}" % tok, val)
|
|
577
|
+
out_lines.append(rendered)
|
|
578
|
+
|
|
579
|
+
document = "\n".join(out_lines)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def substitute(match):
|
|
583
|
+
tok = match.group(1)
|
|
584
|
+
if tok in SCALARS:
|
|
585
|
+
return SCALARS[tok]
|
|
586
|
+
warnings.append("no value for token {{%s}}" % tok)
|
|
587
|
+
return "_(not provided by the engine)_"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
document = TOKEN_RE.sub(substitute, document)
|
|
591
|
+
|
|
592
|
+
# --- self-check: the seven headings are the document's contract with downstream consumers ----
|
|
593
|
+
REQUIRED = ["## Target", "## Purpose", "## Structure", "## Key Dependencies",
|
|
594
|
+
"## Existing Tests", "## Risk Areas", "## Open Questions"]
|
|
595
|
+
present = [ln.rstrip() for ln in document.split("\n")]
|
|
596
|
+
missing = [h for h in REQUIRED if h not in present]
|
|
597
|
+
if missing:
|
|
598
|
+
sys.stderr.write("Error: rendered document is missing required heading(s): %s\n"
|
|
599
|
+
% ", ".join(missing))
|
|
600
|
+
sys.exit(4)
|
|
601
|
+
|
|
602
|
+
if json_out:
|
|
603
|
+
merged = {
|
|
604
|
+
"engine": {
|
|
605
|
+
"script": "run-engine.sh",
|
|
606
|
+
"version": 1,
|
|
607
|
+
"mode": mode,
|
|
608
|
+
"timestamp": timestamp,
|
|
609
|
+
"target": target_abs,
|
|
610
|
+
"target_relative": target_rel,
|
|
611
|
+
"origin": origin,
|
|
612
|
+
"document": out_path,
|
|
613
|
+
"warnings": warnings,
|
|
614
|
+
},
|
|
615
|
+
"enumerate": enum,
|
|
616
|
+
"dependencies": deps,
|
|
617
|
+
"tests": tests,
|
|
618
|
+
}
|
|
619
|
+
try:
|
|
620
|
+
with open(json_out, "w", encoding="utf-8") as fh:
|
|
621
|
+
json.dump(merged, fh, indent=2, ensure_ascii=False)
|
|
622
|
+
fh.write("\n")
|
|
623
|
+
except OSError as exc:
|
|
624
|
+
sys.stderr.write("Error: could not write --json-out file: %s\n" % exc)
|
|
625
|
+
sys.exit(4)
|
|
626
|
+
|
|
627
|
+
# The document is written LAST, deliberately. It is the script's advertised output, so it must be
|
|
628
|
+
# the final side effect: any earlier failure then exits non-zero with nothing left behind, which is
|
|
629
|
+
# what the exit-4 contract promises.
|
|
630
|
+
try:
|
|
631
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
632
|
+
fh.write(document.rstrip("\n") + "\n")
|
|
633
|
+
except OSError as exc:
|
|
634
|
+
sys.stderr.write("Error: could not write document: %s\n" % exc)
|
|
635
|
+
sys.exit(4)
|
|
636
|
+
|
|
637
|
+
# --- diagnostics to stderr, never into the document ------------------------------------------
|
|
638
|
+
for reason, info in sorted((enum.get("skipped") or {}).items()):
|
|
639
|
+
notices.append("enumerate-target.sh skipped %d path(s): %s" % (info.get("count", 0), reason))
|
|
640
|
+
for n in deps.get("notices", []) or []:
|
|
641
|
+
notices.append("detect-dependencies.sh: %s" % n)
|
|
642
|
+
for n in tests.get("notices", []) or []:
|
|
643
|
+
notices.append("detect-tests.sh: %s" % n)
|
|
644
|
+
for w in warnings:
|
|
645
|
+
sys.stderr.write("Warning: %s\n" % w)
|
|
646
|
+
for n in notices:
|
|
647
|
+
sys.stderr.write("Notice: %s\n" % n)
|
|
648
|
+
|
|
649
|
+
print(out_path)
|
|
650
|
+
PY
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
python3 -c "$PY_SRC" \
|
|
654
|
+
"$TEMPLATE" "$TMP_DIR/enumerate.json" "$TMP_DIR/dependencies.json" "$TMP_DIR/tests.json" \
|
|
655
|
+
"$MODE" "$ORIGIN" "$ISO_TS" "$OUT_FILE" "$REPO_ROOT" "$JSON_OUT"
|