@jenga-ai/agent 3.6.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +32 -1
  2. package/lib/generate-skill-allow-list.js +42 -5
  3. package/lib/skill-allow-list.json +1 -1
  4. package/package.json +1 -2
  5. package/project/app/api/lib/resolve-project-root.js +1 -1
  6. package/project/app/api/scripts/capture-snapshot.js +12 -7
  7. package/project/app/package.json +4 -0
  8. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  9. package/project/app/ui/dist/assets/index-DX2pfTAW.js +104 -0
  10. package/project/app/ui/dist/index.html +2 -2
  11. package/scripts/acquire-concurrency-slot.sh +34 -4
  12. package/scripts/apply-j-prefix.sh +1 -1
  13. package/scripts/delete-bare-skill-dirs.sh +1 -2
  14. package/scripts/idea_manager.sh +16 -1
  15. package/scripts/release-concurrency-slot.sh +34 -4
  16. package/scripts/render-ranked-list.sh +270 -0
  17. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  18. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  19. package/scripts/strip-polyfill-frontmatter.py +166 -0
  20. package/scripts/todo_manager.sh +16 -1
  21. package/scripts/validate-typed-object.sh +750 -0
  22. package/scripts/verify-postinstall-reconcile.sh +33 -7
  23. package/skills/index/scripts/board_index.py +29 -1
  24. package/skills/j-brainstorm/SKILL.md +3 -4
  25. package/skills/j-btw/SKILL.md +3 -4
  26. package/skills/j-clearify/SKILL.md +3 -4
  27. package/skills/j-close-story/SKILL.md +11 -12
  28. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  29. package/skills/j-commit/SKILL.md +3 -4
  30. package/skills/j-continue/SKILL.md +5 -6
  31. package/skills/j-deep-dive/SKILL.md +3 -4
  32. package/skills/j-distribute/SKILL.md +3 -4
  33. package/skills/j-do/SKILL.md +13 -15
  34. package/skills/j-doc/SKILL.md +3 -4
  35. package/skills/j-doc/scripts/resolve_last_update.py +27 -1
  36. package/skills/j-doc-sync/SKILL.md +3 -4
  37. package/skills/j-dooo/SKILL.md +6 -15
  38. package/skills/j-error/SKILL.md +3 -4
  39. package/skills/j-evaluate/SKILL.md +3 -4
  40. package/skills/j-examplify/SKILL.md +3 -4
  41. package/skills/j-help/SKILL.md +3 -4
  42. package/skills/j-idea/SKILL.md +3 -4
  43. package/skills/j-improve/SKILL.md +3 -4
  44. package/skills/j-init/SKILL.md +20 -11
  45. package/skills/j-init/scripts/apply-scaffold-visibility.sh +8 -6
  46. package/skills/j-jbp/SKILL.md +3 -4
  47. package/skills/j-lgtm/SKILL.md +3 -4
  48. package/skills/j-pi-plan/SKILL.md +3 -4
  49. package/skills/j-proceed/SKILL.md +3 -4
  50. package/skills/j-publish/SKILL.md +3 -4
  51. package/skills/j-publish/scripts/run_gates.sh +1 -1
  52. package/skills/j-reconcile/SKILL.md +38 -5
  53. package/skills/j-reconcile/assets/report_format.md +11 -0
  54. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +2 -2
  55. package/skills/j-reconcile-origin/SKILL.md +3 -4
  56. package/skills/j-redo/SKILL.md +3 -4
  57. package/skills/j-skillify/SKILL.md +3 -4
  58. package/skills/j-spinoff/SKILL.md +3 -4
  59. package/skills/j-status/SKILL.md +4 -5
  60. package/skills/j-todo/SKILL.md +42 -5
  61. package/skills/j-todo/scripts/add_trivial_task.sh +12 -1
  62. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  63. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  64. package/skills/j-uncharted/SKILL.md +251 -12
  65. package/skills/j-uncharted/scripts/detect-dependencies.sh +79 -21
  66. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  67. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  68. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  69. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  70. package/skills/j-wtf/SKILL.md +3 -4
  71. package/skills/jenga/SKILL.md +106 -10
  72. package/skills/jenga/playbooks/schema.json +4 -4
  73. package/skills/jenga/scripts/enrich-nl-prompt.sh +225 -0
  74. package/skills/jenga/scripts/load-nl-catalog.js +6 -3
  75. package/skills/jenga/scripts/load-playbooks.sh +289 -4
  76. package/skills/jenga/scripts/match-playbook.sh +6 -5
  77. package/skills/jenga/scripts/run-playbook-step.sh +270 -3
  78. package/templates/permission-levels/level-1-locked.json +1 -1
  79. package/templates/permission-levels/level-2-guarded.json +1 -1
  80. package/templates/permission-levels/level-3-standard.json +1 -1
  81. package/templates/permission-levels/level-4-elevated.json +1 -1
  82. package/templates/permission-levels/level-5-unrestricted.json +1 -1
  83. package/templates/playbook-types.json +6 -0
  84. package/project/app/ui/dist/assets/index-BVR_7Owg.css +0 -1
  85. package/project/app/ui/dist/assets/index-CtU2xLQm.js +0 -104
  86. package/scripts/audit-twin-divergence.sh +0 -693
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env bash
2
+ # diff-since-baseline.sh — classify candidates as unchanged/changed/new/removed since a baseline
3
+ #
4
+ # Usage: diff-since-baseline.sh [options] [<baseline.json>]
5
+ # find-scan-baseline.sh <root> | diff-since-baseline.sh [options]
6
+ # diff-since-baseline.sh --help
7
+ #
8
+ # `refresh` mode (E40_S07_T04, not yet implemented) needs to know what actually changed since a
9
+ # baseline before it decides what to do about it. This script is the "what changed" step, and
10
+ # only that step — it does not locate the baseline (find-scan-baseline.sh, E40_S07_T02, already
11
+ # implemented) and does not decide what `refresh` does with the classified result (that belongs
12
+ # in skills/j-uncharted/SKILL.md's refresh mode section, E40_S07_T04). It is read-only: it never
13
+ # writes to the analysed codebase or the board.
14
+ #
15
+ # ---------------------------------------------------------------------------
16
+ # INPUT
17
+ # ---------------------------------------------------------------------------
18
+ # BASELINE (required): find-scan-baseline.sh's JSON output, from a file argument or from stdin
19
+ # (`-`, or no argument) — the same input contract write-scan-record.sh / apply-subsystem-cap.sh
20
+ # use. Two shapes are accepted, matching find-scan-baseline.sh's own two success tiers exactly
21
+ # (read that script's header comment for the authoritative description):
22
+ #
23
+ # Tier 1 ("source": "scan-record") — has a `candidates[]` array ({path, files, lines} each),
24
+ # `baseline_commit`, and `root`. Both `new` and `removed` are computed by set-difference
25
+ # against the fresh discovery run below.
26
+ #
27
+ # Tier 2 ("source": "inferred") — has NO `candidates[]` array at all (only `baseline_commit`,
28
+ # `root`, and `inferred_from[]`). There is nothing to diff the fresh candidate set's
29
+ # MEMBERSHIP against, so `new` and `removed` are NOT computed for a Tier 2 baseline — both
30
+ # stay empty arrays by construction, always, never an error. Every fresh-discovery candidate
31
+ # is instead classified as EITHER `unchanged` or `changed`, via the identical git-diff-since-
32
+ # `baseline_commit` check Tier 1 uses. This is documented, intentional behaviour, not a
33
+ # degraded fallback — see the task's own acceptance criteria (E40_S07_T03).
34
+ #
35
+ # A Tier 3 "no baseline" result (find-scan-baseline.sh exit 3) is the CALLER's problem, not this
36
+ # script's — this script always receives an already-found baseline. Passing it one anyway (e.g. a
37
+ # hand-built JSON object with neither shape) is an input error (exit 2).
38
+ #
39
+ # FRESH CANDIDATE SET: by default this script invokes discover-subsystems.sh itself (no extra
40
+ # flags — a plain default-options pass) against the baseline's own `root` field, resolved
41
+ # relative to the engine repo root (see REPO ANCHORING below). Pass `--fresh FILE` (or `--fresh -`
42
+ # for stdin) to supply an already-computed discover-subsystems.sh report instead — useful when a
43
+ # caller already ran discovery once in the same pass and wants to reuse it rather than pay for a
44
+ # second walk. Only ONE of `<baseline.json>` / `--fresh` may be stdin (`-`) at a time; asking for
45
+ # both is a usage error.
46
+ #
47
+ # Candidates are matched between baseline and fresh discovery BY `path` ONLY — never by `rank` or
48
+ # `score`. apply-subsystem-cap.sh's `kept`/`dropped` entries recompute rank/score fresh every run,
49
+ # and those can shift between runs with zero material change underneath, so they are never a
50
+ # valid diff key. A directory rename is deliberately NOT detected as a rename — it classifies as
51
+ # a `removed` (old path) plus a `new` (new path). Rename detection is out of scope.
52
+ #
53
+ # ---------------------------------------------------------------------------
54
+ # REPO ANCHORING
55
+ # ---------------------------------------------------------------------------
56
+ # `REPO_ROOT` is resolved from THIS SCRIPT's own location (`git -C <script-dir> rev-parse
57
+ # --show-toplevel`), exactly like every sibling script (find-scan-baseline.sh,
58
+ # write-scan-record.sh, apply-subsystem-cap.sh) — the engine repo owns this lookup even when the
59
+ # analysed root lives elsewhere. `baseline_commit` is understood to be a commit reachable from
60
+ # this same repo's history (write-scan-record.sh writes it as the ENGINE repo's HEAD, not the
61
+ # analysed root's — see that script's own header), so the git-diff check below always runs
62
+ # against REPO_ROOT. The baseline's `root` field is repo-relative to REPO_ROOT; a candidate's own
63
+ # `path` field is relative to `root`. The full repo-relative path used for both `discover-
64
+ # subsystems.sh`'s target directory and the `git diff` path is therefore built by joining the two
65
+ # (see FULL PATH RESOLUTION below).
66
+ #
67
+ # ---------------------------------------------------------------------------
68
+ # CLASSIFICATION
69
+ # ---------------------------------------------------------------------------
70
+ # unchanged — same path present in both candidate sets, and
71
+ # `git diff --name-only <baseline_commit>..HEAD -- <full-path>` is EMPTY.
72
+ # changed — same path present in both candidate sets, and that git diff is NON-EMPTY.
73
+ # new — a candidate path present in the fresh discovery run with no baseline counterpart.
74
+ # Tier 2: always empty.
75
+ # removed — a candidate path present in the baseline with no fresh-run counterpart.
76
+ # Tier 2: always empty.
77
+ #
78
+ # Two edge cases in the git-diff check itself, both handled explicitly rather than silently:
79
+ # - `baseline_commit` is `null` (write-scan-record.sh's own documented case: the engine repo had
80
+ # zero commits at scan time). There is nothing to diff against, so EVERY matched/fresh
81
+ # candidate is conservatively classified `changed` (never guessed `unchanged`), with a notice
82
+ # explaining why.
83
+ # - `baseline_commit` does not resolve to a real commit in this repo's history (e.g. squashed or
84
+ # rebased away since the baseline was captured). Checked ONCE up front via
85
+ # `git cat-file -e <sha>^{commit}`; on failure this is an INPUT error (exit 2) — the baseline
86
+ # itself is stale/invalid, not something this script can safely guess around per-candidate.
87
+ #
88
+ # ---------------------------------------------------------------------------
89
+ # FULL PATH RESOLUTION
90
+ # ---------------------------------------------------------------------------
91
+ # Both `root` (from the baseline) and each candidate's `path` are normalized the same way
92
+ # find-scan-baseline.sh's own `norm()` does: strip a trailing slash; collapse "", ".", "./" to the
93
+ # canonical "." meaning "the whole repository" / "the whole root". The full repo-relative path is:
94
+ # root == "." -> candidate path (or "." if the candidate path is also ".")
95
+ # candidate path == "." -> root itself
96
+ # otherwise -> "<root>/<candidate path>"
97
+ #
98
+ # ---------------------------------------------------------------------------
99
+ # OPTIONS
100
+ # ---------------------------------------------------------------------------
101
+ # --fresh FILE Use this discover-subsystems.sh JSON report as the fresh
102
+ # candidate set instead of running discover-subsystems.sh.
103
+ # File path, or "-" to read from stdin.
104
+ # --root ROOT Override the root passed to discover-subsystems.sh when
105
+ # --fresh is not given. Default: the baseline's own `root` field.
106
+ # --discover-subsystems-bin PATH Path to discover-subsystems.sh. Default: the sibling script
107
+ # in this same directory. Override for testing.
108
+ # --json-out FILE Also write the same JSON this script prints on stdout to this
109
+ # exact path. Not written unless requested. Pre-flighted before
110
+ # any real work, same fail-fast contract as every sibling script.
111
+ # -h, --help Show this help and exit 0.
112
+ #
113
+ # ---------------------------------------------------------------------------
114
+ # OUTPUT
115
+ # ---------------------------------------------------------------------------
116
+ # stdout : on success, a single JSON object, one line, nothing else — so
117
+ # `RESULT=$(diff-since-baseline.sh ...)` is safe. Shape:
118
+ #
119
+ # {
120
+ # "script": "diff-since-baseline.sh",
121
+ # "version": 1,
122
+ # "root": "<repo-relative root, from the baseline>",
123
+ # "baseline_source": "scan-record" | "inferred",
124
+ # "baseline_commit": "<short SHA>" | null,
125
+ # "unchanged": [ { "path", "files", "lines" }, ... ],
126
+ # "changed": [ { "path", "files", "lines", "changed_files": [ "...", ... ] }, ... ],
127
+ # "new": [ { "path", "files", "lines" }, ... ], // Tier 2: always []
128
+ # "removed": [ { "path", "files", "lines" }, ... ], // Tier 2: always []
129
+ # "unchanged_count": <int>, "changed_count": <int>, "new_count": <int>, "removed_count": <int>,
130
+ # "notices": [ "<non-fatal diagnostic>", ... ]
131
+ # }
132
+ #
133
+ # `files`/`lines` come from whichever side actually has them for that entry (the fresh
134
+ # discovery run for `unchanged`/`changed`/`new`, the baseline for `removed`). Every entry
135
+ # carries at minimum `path`. `changed_files` is the (capped at 50) list of repo-relative
136
+ # paths `git diff --name-only` reported under that candidate, for auditability — never
137
+ # silently dropped, matching this script family's house style.
138
+ #
139
+ # stderr : notices and diagnostics only, never document/record content.
140
+ #
141
+ # ---------------------------------------------------------------------------
142
+ # EXIT CODES
143
+ # ---------------------------------------------------------------------------
144
+ # 0 — success; classification computed and printed as JSON on stdout
145
+ # 1 — usage error: unknown flag, missing value, more than one positional baseline argument,
146
+ # both the baseline and --fresh set to stdin at once
147
+ # 2 — input error: baseline JSON missing/unreadable/unparseable or not a recognised
148
+ # find-scan-baseline.sh shape; --fresh JSON missing/unreadable/unparseable or not a
149
+ # discover-subsystems.sh report (no "candidates" array); discover-subsystems.sh itself
150
+ # failed or produced unparseable output; baseline_commit does not resolve to a real commit
151
+ # in this repo's history
152
+ # 4 — write failure: --json-out's directory could not be found or is not writable
153
+ #
154
+ # NOTE: `3` is deliberately NOT reused here. It is reserved by find-scan-baseline.sh for its own
155
+ # "no baseline found" case, and that is a distinct concern from anything this script does — this
156
+ # script always receives an already-found baseline as input.
157
+ #
158
+ # Examples:
159
+ # find-scan-baseline.sh skills/j-uncharted | diff-since-baseline.sh
160
+ # diff-since-baseline.sh /tmp/baseline.json
161
+ # discover-subsystems.sh skills/j-uncharted > /tmp/fresh.json
162
+ # find-scan-baseline.sh skills/j-uncharted | diff-since-baseline.sh --fresh /tmp/fresh.json
163
+ #
164
+ # Requires: bash, git, python3. jq is NOT required — JSON is parsed and emitted by python3.
165
+
166
+ set -euo pipefail
167
+
168
+ FRESH=""
169
+ ROOT_OVERRIDE=""
170
+ DISCOVER_BIN=""
171
+ JSON_OUT=""
172
+ BASELINE_INPUT=""
173
+
174
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
175
+
176
+ usage() {
177
+ cat <<EOF
178
+ Usage: $(basename "$0") [options] [<baseline.json>]
179
+
180
+ Classify each candidate as unchanged/changed/new/removed since a baseline: consumes
181
+ find-scan-baseline.sh's JSON output (Tier 1 "scan-record" or Tier 2 "inferred") plus a fresh
182
+ discover-subsystems.sh run, matches candidates by path (never rank/score), and reports which
183
+ paths are unchanged, changed (via git diff since baseline_commit), newly appeared, or removed.
184
+ Tier 2 baselines have no candidates[] to diff membership against, so new/removed always stay
185
+ empty arrays for them -- documented, intentional behaviour, not an error.
186
+
187
+ Arguments:
188
+ <baseline.json> find-scan-baseline.sh output. Omit, or pass "-", to read stdin.
189
+
190
+ Options:
191
+ --fresh FILE Use this discover-subsystems.sh report as the fresh candidate
192
+ set instead of running discover-subsystems.sh. "-" for stdin.
193
+ --root ROOT Root to pass to discover-subsystems.sh when --fresh is not
194
+ given (default: the baseline's own "root" field)
195
+ --discover-subsystems-bin PATH Path to discover-subsystems.sh (default: sibling script)
196
+ --json-out FILE Also write the result JSON to this exact path
197
+ -h, --help Show this help and exit
198
+
199
+ Exit codes: 0 success, 1 usage error, 2 input error, 4 write failure. (3 is reserved by
200
+ find-scan-baseline.sh and is never reused here.)
201
+ EOF
202
+ }
203
+
204
+ die_usage() {
205
+ echo "Error: $1" >&2
206
+ echo >&2
207
+ usage >&2
208
+ exit 1
209
+ }
210
+
211
+ require_value() {
212
+ # require_value <flag> <remaining-arg-count>
213
+ [ "$2" -ge 2 ] || die_usage "$1 requires a value"
214
+ }
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Argument parsing
218
+ # ---------------------------------------------------------------------------
219
+
220
+ while [ "$#" -gt 0 ]; do
221
+ case "$1" in
222
+ --fresh) require_value "--fresh" "$#"; FRESH="$2"; shift 2 ;;
223
+ --fresh=*) FRESH="${1#*=}"; shift ;;
224
+ --root) require_value "--root" "$#"; ROOT_OVERRIDE="$2"; shift 2 ;;
225
+ --root=*) ROOT_OVERRIDE="${1#*=}"; shift ;;
226
+ --discover-subsystems-bin) require_value "--discover-subsystems-bin" "$#"; DISCOVER_BIN="$2"; shift 2 ;;
227
+ --discover-subsystems-bin=*) DISCOVER_BIN="${1#*=}"; shift ;;
228
+ --json-out) require_value "--json-out" "$#"; JSON_OUT="$2"; shift 2 ;;
229
+ --json-out=*) JSON_OUT="${1#*=}"; shift ;;
230
+ -h|--help) usage; exit 0 ;;
231
+ --)
232
+ shift
233
+ [ "$#" -le 1 ] || die_usage "at most one <baseline.json> argument is accepted"
234
+ [ "$#" -eq 0 ] || BASELINE_INPUT="$1"
235
+ break ;;
236
+ -)
237
+ BASELINE_INPUT="-"; shift ;;
238
+ -*)
239
+ die_usage "unknown option \"$1\"" ;;
240
+ *)
241
+ [ -z "$BASELINE_INPUT" ] || die_usage "at most one <baseline.json> argument is accepted (got \"$BASELINE_INPUT\" and \"$1\")"
242
+ BASELINE_INPUT="$1"; shift ;;
243
+ esac
244
+ done
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Input resolution
248
+ # ---------------------------------------------------------------------------
249
+
250
+ if [ -z "$BASELINE_INPUT" ] || [ "$BASELINE_INPUT" = "-" ]; then
251
+ BASELINE_INPUT="-"
252
+ else
253
+ if [ ! -e "$BASELINE_INPUT" ]; then
254
+ echo "Error: baseline input does not exist: $BASELINE_INPUT" >&2
255
+ echo " Expected find-scan-baseline.sh JSON output." >&2
256
+ exit 2
257
+ fi
258
+ if [ ! -f "$BASELINE_INPUT" ] || [ ! -r "$BASELINE_INPUT" ]; then
259
+ echo "Error: baseline input is not a readable file: $BASELINE_INPUT" >&2
260
+ exit 2
261
+ fi
262
+ BASELINE_INPUT=$(cd -- "$(dirname -- "$BASELINE_INPUT")" && pwd -P)/$(basename -- "$BASELINE_INPUT")
263
+ fi
264
+
265
+ if [ -n "$FRESH" ] && [ "$FRESH" != "-" ]; then
266
+ if [ ! -e "$FRESH" ]; then
267
+ echo "Error: --fresh input does not exist: $FRESH" >&2
268
+ exit 2
269
+ fi
270
+ if [ ! -f "$FRESH" ] || [ ! -r "$FRESH" ]; then
271
+ echo "Error: --fresh input is not a readable file: $FRESH" >&2
272
+ exit 2
273
+ fi
274
+ FRESH=$(cd -- "$(dirname -- "$FRESH")" && pwd -P)/$(basename -- "$FRESH")
275
+ fi
276
+
277
+ if [ "$BASELINE_INPUT" = "-" ] && [ "$FRESH" = "-" ]; then
278
+ die_usage "only one of <baseline.json> / --fresh may be stdin (\"-\") at a time"
279
+ fi
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Repo anchoring & output pre-flight — before any real work, same fail-fast contract as every
283
+ # sibling script.
284
+ # ---------------------------------------------------------------------------
285
+
286
+ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
287
+ [ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
288
+
289
+ if [ -z "$DISCOVER_BIN" ]; then
290
+ DISCOVER_BIN="$SCRIPT_DIR/discover-subsystems.sh"
291
+ fi
292
+ if [ -z "$FRESH" ]; then
293
+ if [ ! -x "$DISCOVER_BIN" ]; then
294
+ echo "Error: discover-subsystems.sh not found or not executable: $DISCOVER_BIN" >&2
295
+ echo " Pass --fresh to supply an already-computed report instead." >&2
296
+ exit 2
297
+ fi
298
+ fi
299
+
300
+ if [ -n "$JSON_OUT" ]; then
301
+ JSON_OUT_DIR=$(dirname -- "$JSON_OUT")
302
+ [ -d "$JSON_OUT_DIR" ] || { echo "Error: --json-out directory does not exist: $JSON_OUT_DIR" >&2; exit 4; }
303
+ [ -w "$JSON_OUT_DIR" ] || { echo "Error: --json-out directory is not writable: $JSON_OUT_DIR" >&2; exit 4; }
304
+ JSON_OUT=$(cd -- "$JSON_OUT_DIR" && pwd -P)/$(basename -- "$JSON_OUT")
305
+ fi
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Read the baseline, obtain the fresh candidate set (running discover-subsystems.sh ourselves
309
+ # unless --fresh was given), classify, emit.
310
+ #
311
+ # Captured into a variable and run with `python3 -c`, exactly as find-scan-baseline.sh /
312
+ # write-scan-record.sh / apply-subsystem-cap.sh do: a `python3 - <<PY` heredoc would occupy
313
+ # python's stdin, and this script must itself be able to read stdin for the baseline.
314
+ # ---------------------------------------------------------------------------
315
+
316
+ PY_SRC=$(cat <<'PY'
317
+ import json
318
+ import os
319
+ import subprocess
320
+ import sys
321
+
322
+ (REPO_ROOT, BASELINE_INPUT, FRESH, ROOT_OVERRIDE, DISCOVER_BIN, JSON_OUT) = sys.argv[1:7]
323
+
324
+ notices = []
325
+ CHANGED_FILES_CAP = 50
326
+
327
+
328
+ def norm(path):
329
+ """Same normalization as find-scan-baseline.sh's own norm(): strip a trailing slash,
330
+ collapse "", ".", "./" to the canonical "." meaning 'the whole repository'."""
331
+ p = (path or "").strip()
332
+ if p in ("", ".", "./"):
333
+ return "."
334
+ return p.rstrip("/")
335
+
336
+
337
+ def full_path(root, cand_path):
338
+ root = norm(root)
339
+ cand_path = norm(cand_path)
340
+ if root == ".":
341
+ return cand_path
342
+ if cand_path == ".":
343
+ return root
344
+ return root + "/" + cand_path
345
+
346
+
347
+ # --- read a JSON document from a file path or "-" (stdin) -------------------------------------
348
+
349
+ def read_json(source, label):
350
+ try:
351
+ if source == "-":
352
+ raw = sys.stdin.read()
353
+ origin = "stdin"
354
+ else:
355
+ with open(source, "r", encoding="utf-8") as fh:
356
+ raw = fh.read()
357
+ origin = source
358
+ except OSError as exc:
359
+ sys.stderr.write("Error: could not read %s: %s\n" % (label, exc))
360
+ sys.exit(2)
361
+
362
+ if not raw.strip():
363
+ sys.stderr.write("Error: %s is empty (%s).\n" % (label, origin))
364
+ sys.exit(2)
365
+
366
+ try:
367
+ doc = json.loads(raw)
368
+ except ValueError as exc:
369
+ sys.stderr.write("Error: %s is not valid JSON (%s): %s\n" % (label, origin, exc))
370
+ sys.exit(2)
371
+
372
+ if not isinstance(doc, dict):
373
+ sys.stderr.write("Error: %s did not contain a JSON object (%s).\n" % (label, origin))
374
+ sys.exit(2)
375
+
376
+ return doc
377
+
378
+
379
+ # --- baseline -----------------------------------------------------------------------------------
380
+
381
+ baseline = read_json(BASELINE_INPUT, "baseline input")
382
+
383
+ source = baseline.get("source")
384
+ if source not in ("scan-record", "inferred"):
385
+ sys.stderr.write(
386
+ "Error: baseline input is not a recognised find-scan-baseline.sh shape "
387
+ "(expected \"source\": \"scan-record\" or \"inferred\", got %r).\n" % (source,)
388
+ )
389
+ sys.exit(2)
390
+
391
+ root = norm(baseline.get("root", "."))
392
+ baseline_commit = baseline.get("baseline_commit")
393
+
394
+ if source == "scan-record":
395
+ baseline_candidates = baseline.get("candidates")
396
+ if not isinstance(baseline_candidates, list):
397
+ sys.stderr.write(
398
+ "Error: Tier 1 (\"source\": \"scan-record\") baseline is missing its "
399
+ "\"candidates\" array.\n"
400
+ )
401
+ sys.exit(2)
402
+ else:
403
+ # Tier 2 ("inferred"): no candidates[] by design -- new/removed are never computed for it.
404
+ baseline_candidates = None
405
+ notices.append(
406
+ "Tier 2 (\"inferred\") baseline has no candidates[] -- \"new\" and \"removed\" cannot "
407
+ "be computed and are reported as empty arrays; every fresh candidate is classified "
408
+ "unchanged/changed only."
409
+ )
410
+
411
+ baseline_by_path = {}
412
+ if baseline_candidates is not None:
413
+ for entry in baseline_candidates:
414
+ if not isinstance(entry, dict) or "path" not in entry:
415
+ notices.append("skipped a baseline candidate entry with no \"path\": %r" % (entry,))
416
+ continue
417
+ baseline_by_path[norm(entry["path"])] = entry
418
+
419
+ # --- fresh candidate set --------------------------------------------------------------------
420
+
421
+ if FRESH:
422
+ fresh_report = read_json(FRESH, "--fresh input")
423
+ else:
424
+ target_root = ROOT_OVERRIDE if ROOT_OVERRIDE else root
425
+ if norm(target_root) == ".":
426
+ discover_target = REPO_ROOT
427
+ else:
428
+ discover_target = os.path.join(REPO_ROOT, norm(target_root))
429
+ try:
430
+ proc = subprocess.run(
431
+ [DISCOVER_BIN, discover_target],
432
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True,
433
+ )
434
+ except OSError as exc:
435
+ sys.stderr.write("Error: could not run discover-subsystems.sh: %s\n" % exc)
436
+ sys.exit(2)
437
+ if proc.returncode != 0:
438
+ sys.stderr.write(
439
+ "Error: discover-subsystems.sh exited %d for %r:\n%s\n"
440
+ % (proc.returncode, discover_target, proc.stderr)
441
+ )
442
+ sys.exit(2)
443
+ if proc.stderr:
444
+ for line in proc.stderr.splitlines():
445
+ notices.append("discover-subsystems.sh: %s" % line)
446
+ try:
447
+ fresh_report = json.loads(proc.stdout)
448
+ except ValueError as exc:
449
+ sys.stderr.write(
450
+ "Error: discover-subsystems.sh produced unparseable JSON for %r: %s\n"
451
+ % (discover_target, exc)
452
+ )
453
+ sys.exit(2)
454
+ if not isinstance(fresh_report, dict):
455
+ sys.stderr.write(
456
+ "Error: discover-subsystems.sh did not produce a JSON object for %r.\n"
457
+ % (discover_target,)
458
+ )
459
+ sys.exit(2)
460
+
461
+ fresh_candidates = fresh_report.get("candidates")
462
+ if not isinstance(fresh_candidates, list):
463
+ sys.stderr.write(
464
+ "Error: fresh discovery report is missing its \"candidates\" array "
465
+ "(not a discover-subsystems.sh report).\n"
466
+ )
467
+ sys.exit(2)
468
+
469
+ fresh_by_path = {}
470
+ for entry in fresh_candidates:
471
+ if not isinstance(entry, dict) or "path" not in entry:
472
+ notices.append("skipped a fresh candidate entry with no \"path\": %r" % (entry,))
473
+ continue
474
+ fresh_by_path[norm(entry["path"])] = entry
475
+
476
+ # --- resolve baseline_commit once, up front ------------------------------------------------
477
+
478
+ no_commit_available = False
479
+ if baseline_commit is None:
480
+ no_commit_available = True
481
+ notices.append(
482
+ "baseline_commit is null (the engine repo had no commits at scan time, per "
483
+ "write-scan-record.sh's own documented null case) -- git diff cannot be computed; "
484
+ "every matched/fresh candidate is conservatively classified \"changed\"."
485
+ )
486
+ else:
487
+ check = subprocess.run(
488
+ ["git", "-C", REPO_ROOT, "cat-file", "-e", "%s^{commit}" % baseline_commit],
489
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
490
+ )
491
+ if check.returncode != 0:
492
+ sys.stderr.write(
493
+ "Error: baseline_commit %r does not resolve to a commit in this repository's "
494
+ "history (rebased/squashed away since the baseline was captured?).\n" % (baseline_commit,)
495
+ )
496
+ sys.exit(2)
497
+
498
+
499
+ def git_diff_touched(path):
500
+ """Returns the (possibly empty) list of repo-relative files git reports touched under
501
+ `path` since baseline_commit..HEAD. Only called when a real baseline_commit is available."""
502
+ proc = subprocess.run(
503
+ ["git", "-C", REPO_ROOT, "diff", "--name-only", "%s..HEAD" % baseline_commit, "--", path],
504
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True,
505
+ )
506
+ if proc.returncode != 0:
507
+ notices.append(
508
+ "git diff failed for path %r (%s); conservatively classified \"changed\"" % (path, proc.stderr.strip())
509
+ )
510
+ return None # signals "treat as changed, could not verify"
511
+ return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
512
+
513
+
514
+ def classify(cand_path, source_entry):
515
+ """Returns (bucket, changed_files_or_None) for a matched (present in both, or Tier-2-only)
516
+ candidate path."""
517
+ if no_commit_available:
518
+ return "changed", None
519
+ path = full_path(root, cand_path)
520
+ touched = git_diff_touched(path)
521
+ if touched is None:
522
+ return "changed", None
523
+ if touched:
524
+ return "changed", touched[:CHANGED_FILES_CAP]
525
+ return "unchanged", None
526
+
527
+
528
+ unchanged = []
529
+ changed = []
530
+ new = []
531
+ removed = []
532
+
533
+ if baseline_candidates is not None:
534
+ # Tier 1: full unchanged/changed/new/removed classification.
535
+ all_paths = sorted(set(baseline_by_path) | set(fresh_by_path))
536
+ for p in all_paths:
537
+ in_baseline = p in baseline_by_path
538
+ in_fresh = p in fresh_by_path
539
+ if in_baseline and in_fresh:
540
+ entry = dict(fresh_by_path[p])
541
+ bucket, touched = classify(p, entry)
542
+ if bucket == "unchanged":
543
+ unchanged.append({"path": p, "files": entry.get("files"), "lines": entry.get("lines")})
544
+ else:
545
+ out = {"path": p, "files": entry.get("files"), "lines": entry.get("lines")}
546
+ out["changed_files"] = touched if touched is not None else []
547
+ changed.append(out)
548
+ elif in_fresh:
549
+ entry = fresh_by_path[p]
550
+ new.append({"path": p, "files": entry.get("files"), "lines": entry.get("lines")})
551
+ else:
552
+ entry = baseline_by_path[p]
553
+ removed.append({"path": p, "files": entry.get("files"), "lines": entry.get("lines")})
554
+ else:
555
+ # Tier 2: only unchanged/changed, over the fresh candidate set.
556
+ for p in sorted(fresh_by_path):
557
+ entry = fresh_by_path[p]
558
+ bucket, touched = classify(p, entry)
559
+ if bucket == "unchanged":
560
+ unchanged.append({"path": p, "files": entry.get("files"), "lines": entry.get("lines")})
561
+ else:
562
+ out = {"path": p, "files": entry.get("files"), "lines": entry.get("lines")}
563
+ out["changed_files"] = touched if touched is not None else []
564
+ changed.append(out)
565
+
566
+ result = {
567
+ "script": "diff-since-baseline.sh",
568
+ "version": 1,
569
+ "root": root,
570
+ "baseline_source": source,
571
+ "baseline_commit": baseline_commit,
572
+ "unchanged": unchanged,
573
+ "changed": changed,
574
+ "new": new,
575
+ "removed": removed,
576
+ "unchanged_count": len(unchanged),
577
+ "changed_count": len(changed),
578
+ "new_count": len(new),
579
+ "removed_count": len(removed),
580
+ "notices": notices,
581
+ }
582
+
583
+ body = json.dumps(result, ensure_ascii=False)
584
+
585
+ if JSON_OUT:
586
+ try:
587
+ with open(JSON_OUT, "w", encoding="utf-8") as fh:
588
+ fh.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
589
+ except OSError as exc:
590
+ sys.stderr.write("Error: could not write --json-out file: %s\n" % exc)
591
+ sys.exit(4)
592
+
593
+ for n in notices:
594
+ sys.stderr.write("Notice: %s\n" % n)
595
+
596
+ print(body)
597
+ PY
598
+ )
599
+
600
+ python3 -c "$PY_SRC" "$REPO_ROOT" "$BASELINE_INPUT" "$FRESH" "$ROOT_OVERRIDE" "$DISCOVER_BIN" "$JSON_OUT"