@jenga-ai/agent 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +215 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/mcp/router/embedder.js +1 -1
- package/mcp/training_runner/index.js +239 -0
- package/mcp/training_runner/package-lock.json +1065 -0
- package/mcp/training_runner/package.json +15 -0
- package/package.json +14 -16
- package/scripts/check-permission-level.sh +107 -0
- package/scripts/check-publicignore-match.sh +122 -0
- package/scripts/check-worktree-liveness.sh +193 -0
- package/scripts/generate-rapport-manifest.sh +43 -0
- package/scripts/idea_manager.sh +47 -0
- package/scripts/install-worktree-commit-guard.sh +134 -0
- package/scripts/jenga-permission-level-switch.sh +109 -0
- package/scripts/smoke-harness.sh +139 -0
- package/scripts/validate-board.sh +62 -0
- package/scripts/with-lock.sh +158 -0
- package/scripts/worktree-remove-guard.sh +204 -0
- package/skills/clearify/SKILL.md +52 -0
- package/skills/close-story/SKILL.md +203 -0
- package/skills/close-story/scripts/check-story-closeable.sh +195 -0
- package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
- package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
- package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
- package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
- package/skills/commit/SKILL.md +30 -3
- package/skills/distribute/CONFIG_SCHEMA.md +148 -0
- package/skills/distribute/SKILL.md +173 -0
- package/skills/distribute/scripts/check-version.sh +74 -0
- package/skills/distribute/scripts/commit-version-bump.sh +108 -0
- package/skills/distribute/scripts/distribute-changes.sh +381 -0
- package/skills/do/SKILL.md +352 -1
- package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
- package/skills/doc/assets/path-objectives.yaml +13 -0
- package/skills/doc-sync/SKILL.md +16 -0
- package/skills/doc-sync/assets/doc_targets.md +11 -0
- package/skills/idea/SKILL.md +56 -0
- package/skills/idea/assets/idea_handoff_template.md +26 -0
- package/skills/idea/assets/idea_template.md +3 -0
- package/skills/init/SKILL.md +101 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/strategy_stub_template.md +38 -0
- package/skills/init/assets/workflow_template.json +1 -1
- package/skills/init/scripts/apply-project-visibility.sh +176 -0
- package/skills/init/scripts/detect-existing-codebase.sh +166 -0
- package/skills/init/scripts/init.sh +35 -1
- package/skills/jenga/SKILL.md +206 -14
- package/skills/jenga/scripts/board-scan.sh +238 -0
- package/skills/jenga/scripts/cascade-resolve.sh +297 -0
- package/skills/jenga/scripts/render-confirmation.sh +679 -0
- package/skills/jenga/scripts/render-picker.sh +439 -0
- package/skills/jenga/scripts/resolve-id.sh +367 -0
- package/skills/jenga-permission-level/SKILL.md +81 -0
- package/skills/proceed/SKILL.md +1 -1
- package/skills/publish/SKILL.md +8 -5
- package/skills/publish/assets/ci-contract.md +2 -2
- package/skills/publish/assets/ownership-matrix.md +1 -1
- package/skills/publish/scripts/finalize_changelog.sh +115 -0
- package/skills/publish/scripts/generate_release_notes.sh +475 -28
- package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
- package/skills/publish/scripts/publish_deploy.sh +38 -8
- package/skills/publish/scripts/run_gates.sh +2 -2
- package/skills/reconcile/SKILL.md +117 -5
- package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
- package/skills/spinoff/SKILL.md +12 -7
- package/skills/todo/SKILL.md +2 -0
- package/skills/uncharted/SKILL.md +711 -0
- package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
- package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
- package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
- package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
- package/skills/uncharted/scripts/detect-tests.sh +553 -0
- package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
- package/skills/uncharted/scripts/enumerate-target.sh +470 -0
- package/skills/uncharted/scripts/import-source.sh +517 -0
- package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
- package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
- package/skills/uncharted/scripts/run-engine.sh +655 -0
- package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
- package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
- package/skills/wtf/SKILL.md +20 -0
- package/templates/CHANGELOG_TEMPLATE.md +13 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +206 -10
- package/templates/permission-levels/README.md +73 -0
- package/templates/permission-levels/level-1-locked.json +71 -0
- package/templates/permission-levels/level-2-guarded.json +64 -0
- package/templates/permission-levels/level-3-standard.json +62 -0
- package/templates/permission-levels/level-4-elevated.json +60 -0
- package/templates/permission-levels/level-5-unrestricted.json +58 -0
- package/skills/convert/SKILL.md +0 -124
- package/skills/convert/convert_cli.py +0 -235
- package/skills/convert/tests/sample.csv +0 -4
- package/skills/convert/tests/sample.json +0 -5
- package/skills/convert/tests/sample.jsonl +0 -3
- package/skills/convert/tests/sample.yaml +0 -18
- package/skills/convert/tests/sample_obj.csv +0 -2
- package/skills/convert/tests/sample_obj.json +0 -9
- package/skills/mirror-public/SKILL.md +0 -237
- package/skills/mirror-public/assets/config.json +0 -5
- package/skills/mirror-public/scripts/mirror.sh +0 -374
- package/skills/self-sync/SKILL.md +0 -73
- package/skills/self-sync/scripts/run.js +0 -136
- package/skills/train/SKILL.md +0 -116
- package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
- package/skills/train/assets/dashboard-templates/nlp.html +0 -102
- package/skills/train/assets/dashboard-templates/transformers.html +0 -98
- package/skills/train/assets/results-parsers/__init__.py +0 -9
- package/skills/train/assets/results-parsers/classifiers.py +0 -84
- package/skills/train/assets/results-parsers/nlp.py +0 -88
- package/skills/train/assets/results-parsers/reporter.py +0 -154
- package/skills/train/assets/results-parsers/transformers.py +0 -120
- package/skills/train/train_cli.py +0 -786
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# discover-subsystems.sh — bounded, coarse-grained subsystem discovery for /uncharted onboard
|
|
3
|
+
#
|
|
4
|
+
# Usage: discover-subsystems.sh [options] <root>
|
|
5
|
+
# discover-subsystems.sh --help
|
|
6
|
+
#
|
|
7
|
+
# Given the root of an existing codebase, emits a ranked JSON list of candidate SUBSYSTEMS on
|
|
8
|
+
# stdout: the major modules/packages a human would name when asked "what are the main parts of
|
|
9
|
+
# this project?". It is deliberately COARSE. `onboard` mode backfills a board for a codebase
|
|
10
|
+
# that was never built through Jenga, and a per-file investigation of such a codebase is both
|
|
11
|
+
# useless as a board and prohibitively expensive. Boundedness is the point of this script, not
|
|
12
|
+
# a limitation of it:
|
|
13
|
+
#
|
|
14
|
+
# - discovery depth is capped (default 2 levels below the root, --max-depth)
|
|
15
|
+
# - build outputs, vendored dependencies and .gitignore'd paths are never candidates
|
|
16
|
+
# - Jenga's own scaffolding is never a candidate — it is the framework, not the application
|
|
17
|
+
# - the directory tree is partitioned, never double-counted (see CANDIDATES VS CONTAINERS)
|
|
18
|
+
#
|
|
19
|
+
# This script is STRICTLY READ-ONLY. It creates no files, no temp files, and mutates no git
|
|
20
|
+
# state. It never writes into the codebase being analysed — `onboard` mode must never modify,
|
|
21
|
+
# move, or restructure a consumer's application code, and this script is the analysis half of
|
|
22
|
+
# that guarantee. Its only outputs are JSON on stdout and diagnostics on stderr.
|
|
23
|
+
#
|
|
24
|
+
# Enumeration is DELEGATED, not reimplemented: every file count, line count and directory
|
|
25
|
+
# listing comes from `enumerate-target.sh` in this same directory. This script contributes the
|
|
26
|
+
# clustering, bounding and scoring on top of it.
|
|
27
|
+
#
|
|
28
|
+
# Determinism: the output is a pure function of the tree and the flags. No timestamps, no
|
|
29
|
+
# hostnames, no unsorted collections — two runs against an unchanged tree produce byte-identical
|
|
30
|
+
# output and can be diffed directly.
|
|
31
|
+
#
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Options
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# --max-depth N How many levels below <root> a candidate may sit. Default 2.
|
|
36
|
+
# Must be >= 1; there is deliberately no "unlimited" value, because an
|
|
37
|
+
# unbounded walk is exactly what this script exists to prevent.
|
|
38
|
+
# --min-files N Noise floor: a directory holding fewer than N files (recursively) is
|
|
39
|
+
# not a candidate. Default 1 (i.e. off).
|
|
40
|
+
# --max-candidates N Runtime safety bound on how many directories receive a second,
|
|
41
|
+
# per-candidate enumeration pass. Default 200. When the bound is hit the
|
|
42
|
+
# largest candidates are kept and the remainder are reported under
|
|
43
|
+
# "excluded" with a notice.
|
|
44
|
+
# THIS IS NOT THE `onboard` EPIC CAP. It is a cost guard on the analysis
|
|
45
|
+
# itself. The epic cap — and the surfacing of anything it drops — is
|
|
46
|
+
# applied by a separate layer on top of this script's output.
|
|
47
|
+
# --include-framework Do not exclude Jenga scaffolding. Needed to point the script at the
|
|
48
|
+
# JengaAgent repo itself, where the scaffolding IS the application.
|
|
49
|
+
# --include-training-scaffolding
|
|
50
|
+
# Do not exclude .training/ job-template scaffolding. Needed to point the
|
|
51
|
+
# script at JengaAgent's own /train subsystem, or at a project that
|
|
52
|
+
# genuinely wants job templates catalogued as a candidate.
|
|
53
|
+
# --with-dependencies Additionally compute the "cohesion" signal by calling
|
|
54
|
+
# detect-dependencies.sh once per candidate. Off by default: it is an
|
|
55
|
+
# extra full-text scan per candidate and the default pass must stay
|
|
56
|
+
# coarse. Degrades to a notice if that script is unavailable.
|
|
57
|
+
# -h, --help Show help and exit.
|
|
58
|
+
# -- End of options; the next argument is <root>.
|
|
59
|
+
#
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Exclusions
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Four independent layers, and every exclusion is REPORTED in the "excluded" array with its
|
|
64
|
+
# reason — nothing is silently dropped:
|
|
65
|
+
#
|
|
66
|
+
# 1. .gitignore'd paths. Free, and by construction: enumerate-target.sh lists via
|
|
67
|
+
# `git ls-files --cached --others --exclude-standard`, so ignored content never reaches
|
|
68
|
+
# this script. When it reports "gitignore_respected": false (the root is not in a git work
|
|
69
|
+
# tree, or its content is itself ignored) a notice says so, because layer 2 is then the
|
|
70
|
+
# only guard left.
|
|
71
|
+
# 2. Build/vendor/VCS directory NAMES at any depth: node_modules, vendor, dist, build, .git,
|
|
72
|
+
# target, out, coverage, .venv, venv, __pycache__, .next, bower_components, .terraform,
|
|
73
|
+
# .gradle, .tox, .mypy_cache, .pytest_cache, .idea, .svn, .hg, .cache, .claude/worktrees.
|
|
74
|
+
# This layer applies BOTH when selecting candidates AND inside a candidate that has already
|
|
75
|
+
# been selected. The second half matters for committed (non-gitignored) vendor content —
|
|
76
|
+
# common in Go and PHP projects, which is exactly the kind of codebase `onboard` targets.
|
|
77
|
+
# Without it a candidate's own totals and score would silently absorb its vendored tree.
|
|
78
|
+
# enumerate-target.sh has no name-exclusion concept — .gitignore is its only filter — so
|
|
79
|
+
# each nested match is measured by enumerate-target.sh in its own right and SUBTRACTED from
|
|
80
|
+
# the candidate, keeping enumeration delegated rather than recounted here. Gitignored
|
|
81
|
+
# directories never reach this step (layer 1 already removed them from the listing), so the
|
|
82
|
+
# subtraction cannot double-count. Every discount is reported twice: on the candidate as
|
|
83
|
+
# "nested_excluded", and in the top-level "excluded" array.
|
|
84
|
+
# 3. Jenga scaffolding: project/, skills/, agents/, hooks/, templates/, .claude/, .agents/.
|
|
85
|
+
# Matched as ROOT-RELATIVE PATHS, not as bare names at any depth — a consumer's own
|
|
86
|
+
# src/agents/ or app/templates/ is a real subsystem and must not be swallowed by a rule
|
|
87
|
+
# about Jenga's top-level layout. Disable with --include-framework.
|
|
88
|
+
# 4. Training-job scaffolding: .training/. Matched as a ROOT-RELATIVE PATH at depth 1, same
|
|
89
|
+
# discipline as layer 3 and for the same reason — a consumer's own src/.training/ (unlikely,
|
|
90
|
+
# but the rule should not assume) would need the same protection a bare-name match at any
|
|
91
|
+
# depth would remove. This is deliberately its own layer rather than folded into
|
|
92
|
+
# FRAMEWORK_PATHS: .training/ is job-template scaffolding for the /train skill, not Jenga's
|
|
93
|
+
# own runtime scaffolding, and the two can be disabled independently. Content under it
|
|
94
|
+
# (e.g. .training/template/{transformers,nlp,classifiers}/train.py) describes how to run a
|
|
95
|
+
# training job, not application code an `onboard` backfill should reason about. Disable with
|
|
96
|
+
# --include-training-scaffolding.
|
|
97
|
+
#
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Candidates vs containers
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Ranking packages/ alongside packages/api and packages/web would count the same files two or
|
|
102
|
+
# three times and bury real subsystems under their own parents. So the tree is PARTITIONED:
|
|
103
|
+
# walking down from the root, each directory becomes exactly one of
|
|
104
|
+
#
|
|
105
|
+
# candidate — a subsystem. It is scored and ranked, and its subtree is NOT descended further.
|
|
106
|
+
# container — a folder OF subsystems (the classic packages/ or apps/ case). It is expanded
|
|
107
|
+
# into its children, is not itself ranked, and is reported in "containers" with
|
|
108
|
+
# the reason it was expanded and what it expanded into.
|
|
109
|
+
#
|
|
110
|
+
# A directory is expanded into its children only when ALL of these hold:
|
|
111
|
+
# - it sits above --max-depth (a directory at the depth bound is always a candidate),
|
|
112
|
+
# - it has at least 2 child directories that would themselves be viable candidates,
|
|
113
|
+
# - its own direct files are incidental (<= 3 of them, or <= 20% of its recursive file count),
|
|
114
|
+
# - it does NOT carry its own manifest.
|
|
115
|
+
# The manifest rule is what makes monorepos work: a directory with its own package.json /
|
|
116
|
+
# go.mod / pyproject.toml is a unit by definition and is never split apart, whatever its shape.
|
|
117
|
+
#
|
|
118
|
+
# Files sitting loose at the root belong to no subsystem, so they are reported under
|
|
119
|
+
# "root_files" rather than being invented as a candidate.
|
|
120
|
+
#
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Scoring
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Each candidate carries every signal that scored it — name, normalised 0..1 value, weight,
|
|
125
|
+
# contribution and a human-readable detail — so a ranking can be audited rather than trusted.
|
|
126
|
+
#
|
|
127
|
+
# size 30 log-scaled line count relative to the largest candidate. Log-scaled so
|
|
128
|
+
# one enormous module does not flatten everything else to zero.
|
|
129
|
+
# manifest 20 own dependency manifest — the strongest "this is a unit" signal there is
|
|
130
|
+
# own_tests 15 own test directory or test-named files
|
|
131
|
+
# source_density 15 share of files with recognised source extensions, which separates a
|
|
132
|
+
# code module from an assets/fixtures/docs folder of the same size
|
|
133
|
+
# structure 10 has internal sub-structure rather than being a flat bag of files
|
|
134
|
+
# docs 10 own README / docs/ — somebody thought this was worth describing
|
|
135
|
+
# cohesion 20 --with-dependencies only: internal vs external dependency ratio
|
|
136
|
+
#
|
|
137
|
+
# score = 100 * sum(value * weight) / sum(weight), renormalised over whichever weights were in
|
|
138
|
+
# play, so enabling --with-dependencies changes the ranking but never the 0-100 scale. Ranks are
|
|
139
|
+
# assigned by score descending, then path ascending.
|
|
140
|
+
#
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Output contract (stdout, JSON)
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Additive by design — consumers should ignore keys they do not need, and new keys may be added
|
|
145
|
+
# without notice.
|
|
146
|
+
#
|
|
147
|
+
# {
|
|
148
|
+
# "script": "discover-subsystems.sh",
|
|
149
|
+
# "version": 1,
|
|
150
|
+
# "root": "<repo-relative path to the analysed root>",
|
|
151
|
+
# "root_absolute": "<absolute path>",
|
|
152
|
+
# "repo_root": "<absolute path to the git root, or null when not in a work tree>",
|
|
153
|
+
# "max_depth": <int>, // the depth bound in effect
|
|
154
|
+
# "min_files": <int>,
|
|
155
|
+
# "max_candidates": <int>, // runtime bound, NOT the onboard epic cap
|
|
156
|
+
# "framework_excluded": <bool>,
|
|
157
|
+
# "gitignore_respected": <bool>, // false => layer-1 exclusions did not apply
|
|
158
|
+
# "with_dependencies": <bool>,
|
|
159
|
+
# "totals": { "files": <int>, "lines": <int>, "bytes": <int> }, // whole analysed root
|
|
160
|
+
# "candidate_count": <int>,
|
|
161
|
+
# "candidates": [
|
|
162
|
+
# {
|
|
163
|
+
# "rank": <int>, // 1 = strongest
|
|
164
|
+
# "path": "<path relative to root>",
|
|
165
|
+
# "absolute_path": "<absolute path>",
|
|
166
|
+
# "depth": <int>, // levels below root
|
|
167
|
+
# "parent": "<path relative to root, or null at depth 1>",
|
|
168
|
+
# "score": <float 0-100>,
|
|
169
|
+
# "files": <int>, // recursive, from enumerate-target.sh, net of
|
|
170
|
+
# "lines": <int>, // anything listed in "nested_excluded" below
|
|
171
|
+
# "bytes": <int>,
|
|
172
|
+
# "direct_files": <int>, // files sitting directly in it, not in subdirectories
|
|
173
|
+
# "subdirectories": <int>,
|
|
174
|
+
# "manifests": [ "<name>", ... ],
|
|
175
|
+
# "test_paths": [ "<path relative to the candidate>", ... ], // capped at 5
|
|
176
|
+
# "doc_paths": [ "<path relative to the candidate>", ... ], // capped at 5
|
|
177
|
+
# "top_extensions": [ { "extension", "files", "lines" }, ... ], // capped at 5
|
|
178
|
+
# "largest_files": [ { "path", "lines" }, ... ], // capped at 5
|
|
179
|
+
# "nested_excluded": [ // vendored/build dirs found inside it and discounted from its
|
|
180
|
+
# // totals above; empty in the common (gitignored) case
|
|
181
|
+
# { "path", "reason", "files", "lines" }
|
|
182
|
+
# ],
|
|
183
|
+
# "signals": [
|
|
184
|
+
# { "name", "value": <0..1>, "weight": <int>, "contribution": <float>, "detail": "<why>" }
|
|
185
|
+
# ]
|
|
186
|
+
# }
|
|
187
|
+
# ],
|
|
188
|
+
# "containers": [
|
|
189
|
+
# { "path", "depth", "reason", "expanded_into": [ "<child path>", ... ] }
|
|
190
|
+
# ],
|
|
191
|
+
# "excluded": [
|
|
192
|
+
# { "path", "reason" }
|
|
193
|
+
# ],
|
|
194
|
+
# "root_files": { "count": <int>, "paths": [ "<name>", ... ] }, // paths capped at 25
|
|
195
|
+
# "notices": [ "<non-fatal diagnostic>", ... ]
|
|
196
|
+
# }
|
|
197
|
+
#
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Behaviour notes
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# - No candidates found: NOT an error. Exits 0 with an empty "candidates" array and a notice
|
|
202
|
+
# explaining why (empty root, everything excluded, or everything below --min-files).
|
|
203
|
+
# - Root outside a git repository: NOT an error. "repo_root" is null, "gitignore_respected"
|
|
204
|
+
# is false, and a notice says only name-based exclusions applied.
|
|
205
|
+
# - Cost: the tree is read roughly twice — once for the repo-wide sweep that finds the
|
|
206
|
+
# candidates, then once per candidate for its statistics. That is acceptable for a one-shot
|
|
207
|
+
# onboarding pass and is bounded by --max-candidates.
|
|
208
|
+
# - Nested vendored/build content is hunted 4 levels into a candidate, not indefinitely. A
|
|
209
|
+
# node_modules/ buried deeper than that is still counted in its candidate's totals. The bound
|
|
210
|
+
# is stated rather than silently assumed: real vendor directories sit at a module's root, and
|
|
211
|
+
# an unbounded per-candidate walk would contradict the whole point of this script.
|
|
212
|
+
# - The candidate/container split is a heuristic and will sometimes disagree with a human's
|
|
213
|
+
# mental model of a codebase. That is why every decision is emitted with its reason: a wrong
|
|
214
|
+
# call is inspectable and overridable, not hidden.
|
|
215
|
+
#
|
|
216
|
+
# Exit codes:
|
|
217
|
+
# 0 — success (including every graceful-degradation path above)
|
|
218
|
+
# 1 — usage error (unknown flag, missing/extra root, bad numeric value)
|
|
219
|
+
# 2 — root missing, unreadable, or not a directory
|
|
220
|
+
# 3 — enumerate-target.sh is missing, not executable, or failed on the root
|
|
221
|
+
#
|
|
222
|
+
# Examples:
|
|
223
|
+
# discover-subsystems.sh .
|
|
224
|
+
# discover-subsystems.sh --max-depth 3 --min-files 5 /path/to/project
|
|
225
|
+
# discover-subsystems.sh --include-framework --with-dependencies .
|
|
226
|
+
# discover-subsystems.sh . | jq -r '.candidates[] | "\(.rank)\t\(.score)\t\(.path)"'
|
|
227
|
+
#
|
|
228
|
+
# Requires: bash, git, python3. jq is NOT required — JSON is emitted by python3.
|
|
229
|
+
|
|
230
|
+
set -euo pipefail
|
|
231
|
+
|
|
232
|
+
MAX_DEPTH=2
|
|
233
|
+
MIN_FILES=1
|
|
234
|
+
MAX_CANDIDATES=200
|
|
235
|
+
INCLUDE_FRAMEWORK=0
|
|
236
|
+
INCLUDE_TRAINING=0
|
|
237
|
+
WITH_DEPENDENCIES=0
|
|
238
|
+
ROOT=""
|
|
239
|
+
|
|
240
|
+
usage() {
|
|
241
|
+
cat <<EOF
|
|
242
|
+
Usage: $(basename "$0") [options] <root>
|
|
243
|
+
|
|
244
|
+
Print a ranked JSON list of the major subsystems of an existing codebase. Coarse by design:
|
|
245
|
+
depth-bounded, build/vendor/ignored paths excluded, Jenga scaffolding excluded.
|
|
246
|
+
Read-only: nothing is written to disk, and the analysed codebase is never modified.
|
|
247
|
+
|
|
248
|
+
Arguments:
|
|
249
|
+
<root> Root directory of the codebase to analyse.
|
|
250
|
+
|
|
251
|
+
Options:
|
|
252
|
+
--max-depth N Levels below <root> a candidate may sit (default: 2, minimum: 1)
|
|
253
|
+
--min-files N Ignore directories holding fewer than N files (default: 1)
|
|
254
|
+
--max-candidates N Runtime bound on candidates enumerated (default: 200).
|
|
255
|
+
Not the onboard epic cap — that is applied by a separate layer.
|
|
256
|
+
--include-framework Do not exclude project/ skills/ agents/ hooks/ templates/ .claude/ .agents/
|
|
257
|
+
--include-training-scaffolding
|
|
258
|
+
Do not exclude .training/ job-template scaffolding
|
|
259
|
+
--with-dependencies Also compute the cohesion signal via detect-dependencies.sh (slower)
|
|
260
|
+
-h, --help Show this help and exit
|
|
261
|
+
|
|
262
|
+
Exit codes: 0 success, 1 usage error, 2 root missing/unreadable/not a directory,
|
|
263
|
+
3 enumerate-target.sh unavailable or failed.
|
|
264
|
+
|
|
265
|
+
Examples:
|
|
266
|
+
$(basename "$0") .
|
|
267
|
+
$(basename "$0") --max-depth 3 --min-files 5 /path/to/project
|
|
268
|
+
EOF
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
die_usage() {
|
|
272
|
+
echo "Error: $1" >&2
|
|
273
|
+
echo >&2
|
|
274
|
+
usage >&2
|
|
275
|
+
exit 1
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
require_int() {
|
|
279
|
+
# require_int <flag> <value> <minimum>
|
|
280
|
+
case "$2" in
|
|
281
|
+
''|*[!0-9]*) die_usage "$1 requires a non-negative integer, got \"$2\"" ;;
|
|
282
|
+
esac
|
|
283
|
+
if [ "$2" -lt "$3" ]; then
|
|
284
|
+
die_usage "$1 must be >= $3, got \"$2\""
|
|
285
|
+
fi
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Argument parsing
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
while [ "$#" -gt 0 ]; do
|
|
293
|
+
case "$1" in
|
|
294
|
+
--max-depth)
|
|
295
|
+
[ "$#" -ge 2 ] || die_usage "--max-depth requires a value"
|
|
296
|
+
require_int "--max-depth" "$2" 1
|
|
297
|
+
MAX_DEPTH="$2"; shift 2 ;;
|
|
298
|
+
--max-depth=*)
|
|
299
|
+
require_int "--max-depth" "${1#*=}" 1
|
|
300
|
+
MAX_DEPTH="${1#*=}"; shift ;;
|
|
301
|
+
--min-files)
|
|
302
|
+
[ "$#" -ge 2 ] || die_usage "--min-files requires a value"
|
|
303
|
+
require_int "--min-files" "$2" 1
|
|
304
|
+
MIN_FILES="$2"; shift 2 ;;
|
|
305
|
+
--min-files=*)
|
|
306
|
+
require_int "--min-files" "${1#*=}" 1
|
|
307
|
+
MIN_FILES="${1#*=}"; shift ;;
|
|
308
|
+
--max-candidates)
|
|
309
|
+
[ "$#" -ge 2 ] || die_usage "--max-candidates requires a value"
|
|
310
|
+
require_int "--max-candidates" "$2" 1
|
|
311
|
+
MAX_CANDIDATES="$2"; shift 2 ;;
|
|
312
|
+
--max-candidates=*)
|
|
313
|
+
require_int "--max-candidates" "${1#*=}" 1
|
|
314
|
+
MAX_CANDIDATES="${1#*=}"; shift ;;
|
|
315
|
+
--include-framework)
|
|
316
|
+
INCLUDE_FRAMEWORK=1; shift ;;
|
|
317
|
+
--include-training-scaffolding)
|
|
318
|
+
INCLUDE_TRAINING=1; shift ;;
|
|
319
|
+
--with-dependencies)
|
|
320
|
+
WITH_DEPENDENCIES=1; shift ;;
|
|
321
|
+
-h|--help)
|
|
322
|
+
usage; exit 0 ;;
|
|
323
|
+
--)
|
|
324
|
+
shift
|
|
325
|
+
[ "$#" -eq 1 ] || die_usage "exactly one root is required"
|
|
326
|
+
ROOT="$1"; shift ;;
|
|
327
|
+
-*)
|
|
328
|
+
die_usage "unknown option \"$1\"" ;;
|
|
329
|
+
*)
|
|
330
|
+
[ -z "$ROOT" ] || die_usage "exactly one root is required (got \"$ROOT\" and \"$1\")"
|
|
331
|
+
ROOT="$1"; shift ;;
|
|
332
|
+
esac
|
|
333
|
+
done
|
|
334
|
+
|
|
335
|
+
[ -n "$ROOT" ] || die_usage "a root directory is required"
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Root resolution
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
if [ ! -e "$ROOT" ]; then
|
|
342
|
+
echo "Error: root does not exist: $ROOT" >&2
|
|
343
|
+
exit 2
|
|
344
|
+
fi
|
|
345
|
+
if [ ! -d "$ROOT" ]; then
|
|
346
|
+
echo "Error: root is not a directory: $ROOT" >&2
|
|
347
|
+
exit 2
|
|
348
|
+
fi
|
|
349
|
+
if [ ! -r "$ROOT" ] || [ ! -x "$ROOT" ]; then
|
|
350
|
+
echo "Error: root is not readable/traversable: $ROOT" >&2
|
|
351
|
+
exit 2
|
|
352
|
+
fi
|
|
353
|
+
|
|
354
|
+
# `readlink -f` is GNU-only and absent from older macOS/BSD userland; cd + `pwd -P` is portable.
|
|
355
|
+
ROOT_ABS=$(cd -- "$ROOT" && pwd -P)
|
|
356
|
+
REPO_ROOT=$(git -C "$ROOT_ABS" rev-parse --show-toplevel 2>/dev/null || true)
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Sibling scripts
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
|
363
|
+
ENUMERATE="$SCRIPT_DIR/enumerate-target.sh"
|
|
364
|
+
DETECT_DEPS="$SCRIPT_DIR/detect-dependencies.sh"
|
|
365
|
+
|
|
366
|
+
if [ ! -x "$ENUMERATE" ]; then
|
|
367
|
+
echo "Error: required helper is missing or not executable: $ENUMERATE" >&2
|
|
368
|
+
echo " discover-subsystems.sh delegates all enumeration to it rather than duplicating it." >&2
|
|
369
|
+
exit 3
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Discover and emit
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
python3 - \
|
|
377
|
+
"$ROOT_ABS" "$REPO_ROOT" "$ENUMERATE" "$DETECT_DEPS" \
|
|
378
|
+
"$MAX_DEPTH" "$MIN_FILES" "$MAX_CANDIDATES" "$INCLUDE_FRAMEWORK" "$INCLUDE_TRAINING" \
|
|
379
|
+
"$WITH_DEPENDENCIES" <<'PY'
|
|
380
|
+
import json
|
|
381
|
+
import math
|
|
382
|
+
import os
|
|
383
|
+
import re
|
|
384
|
+
import subprocess
|
|
385
|
+
import sys
|
|
386
|
+
|
|
387
|
+
(ROOT_ABS, REPO_ROOT, ENUMERATE, DETECT_DEPS,
|
|
388
|
+
MAX_DEPTH_S, MIN_FILES_S, MAX_CANDIDATES_S,
|
|
389
|
+
INCLUDE_FRAMEWORK_S, INCLUDE_TRAINING_S, WITH_DEPENDENCIES_S) = sys.argv[1:11]
|
|
390
|
+
|
|
391
|
+
MAX_DEPTH = int(MAX_DEPTH_S)
|
|
392
|
+
MIN_FILES = int(MIN_FILES_S)
|
|
393
|
+
MAX_CANDIDATES = int(MAX_CANDIDATES_S)
|
|
394
|
+
INCLUDE_FRAMEWORK = INCLUDE_FRAMEWORK_S == "1"
|
|
395
|
+
INCLUDE_TRAINING = INCLUDE_TRAINING_S == "1"
|
|
396
|
+
WITH_DEPENDENCIES = WITH_DEPENDENCIES_S == "1"
|
|
397
|
+
|
|
398
|
+
notices = []
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Exclusion vocabulary
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
# Build outputs, vendored dependencies and VCS/tooling metadata. Excluded by NAME at any depth,
|
|
405
|
+
# because they mean the same thing wherever they appear. Baseline kept in step with
|
|
406
|
+
# detect-tests.sh / detect-dependencies.sh so the three scripts agree on what "not code" means.
|
|
407
|
+
SKIP_DIRS = {
|
|
408
|
+
".git", ".svn", ".hg",
|
|
409
|
+
"node_modules", "bower_components", "vendor",
|
|
410
|
+
"dist", "build", "out", "target",
|
|
411
|
+
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".tox",
|
|
412
|
+
".next", ".nuxt", ".output", ".parcel-cache", ".turbo", ".cache",
|
|
413
|
+
"coverage", ".nyc_output",
|
|
414
|
+
".gradle", ".idea", ".vscode", ".terraform", ".serverless", ".dart_tool",
|
|
415
|
+
"Pods", "DerivedData",
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
# Jenga's own scaffolding. Excluded by ROOT-RELATIVE PATH, never by bare name: a consumer's
|
|
419
|
+
# src/agents/ or app/templates/ is a real subsystem of their application and must survive.
|
|
420
|
+
FRAMEWORK_PATHS = {
|
|
421
|
+
"project", "skills", "agents", "hooks", "templates", ".claude", ".agents",
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
# Training-job template scaffolding for the /train skill (e.g. .training/template/{transformers,
|
|
425
|
+
# nlp,classifiers}/train.py). Kept separate from FRAMEWORK_PATHS — same root-relative-path
|
|
426
|
+
# discipline, but a distinct concept with its own opt-out, since a caller may want Jenga
|
|
427
|
+
# scaffolding excluded while still wanting job templates as a candidate, or vice versa.
|
|
428
|
+
TRAINING_SCAFFOLD_PATHS = {
|
|
429
|
+
".training",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
# Own-manifest = "this directory is a unit". Strong enough to veto splitting it apart.
|
|
433
|
+
MANIFEST_NAMES = {
|
|
434
|
+
"package.json", "deno.json", "deno.jsonc",
|
|
435
|
+
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile",
|
|
436
|
+
"go.mod", "Cargo.toml", "Gemfile", "composer.json",
|
|
437
|
+
"pom.xml", "build.gradle", "build.gradle.kts", "build.sbt",
|
|
438
|
+
"mix.exs", "pubspec.yaml", "Package.swift", "CMakeLists.txt",
|
|
439
|
+
"Chart.yaml", "terraform.tf",
|
|
440
|
+
}
|
|
441
|
+
MANIFEST_SUFFIXES = (".csproj", ".fsproj", ".gemspec", ".podspec", ".cabal")
|
|
442
|
+
|
|
443
|
+
TEST_DIR_NAMES = {"test", "tests", "spec", "specs", "__tests__", "e2e", "testing", "it"}
|
|
444
|
+
TEST_FILE_PATTERNS = [
|
|
445
|
+
re.compile(r".+_test\.[A-Za-z0-9]+$"),
|
|
446
|
+
re.compile(r".+\.test\.[A-Za-z0-9]+$"),
|
|
447
|
+
re.compile(r"^test_.+\.[A-Za-z0-9]+$"),
|
|
448
|
+
re.compile(r".+_spec\.[A-Za-z0-9]+$"),
|
|
449
|
+
re.compile(r".+\.spec\.[A-Za-z0-9]+$"),
|
|
450
|
+
re.compile(r".+Tests?\.(?:java|kt|kts|cs|scala)$"),
|
|
451
|
+
re.compile(r".+\.bats$"),
|
|
452
|
+
re.compile(r"^conftest\.py$"),
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
DOC_DIR_NAMES = {"docs", "doc"}
|
|
456
|
+
DOC_FILE_PATTERN = re.compile(r"^(README|readme|Readme)(\..+)?$")
|
|
457
|
+
|
|
458
|
+
SOURCE_EXTS = {
|
|
459
|
+
".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".vue", ".svelte",
|
|
460
|
+
".py", ".pyi", ".go", ".rs", ".rb", ".php", ".java", ".kt", ".kts",
|
|
461
|
+
".scala", ".swift", ".m", ".mm", ".cs", ".fs", ".c", ".h", ".cc",
|
|
462
|
+
".cpp", ".hpp", ".hh", ".ex", ".exs", ".erl", ".dart", ".lua", ".pl",
|
|
463
|
+
".r", ".jl", ".sh", ".bash", ".zsh", ".sql", ".proto", ".tf", ".hcl",
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# Reporting caps — these bound the SIZE OF THE OUTPUT, not the analysis.
|
|
467
|
+
CAP_TEST_PATHS = 5
|
|
468
|
+
CAP_DOC_PATHS = 5
|
|
469
|
+
CAP_EXTENSIONS = 5
|
|
470
|
+
CAP_LARGEST = 5
|
|
471
|
+
|
|
472
|
+
# Pass-2 depth windows, deliberately separate:
|
|
473
|
+
# CANDIDATE_TREE_DEPTH how deep a candidate's own tree is walked when hunting for nested
|
|
474
|
+
# vendored/build content to discount. Deeper than the signal window
|
|
475
|
+
# because vendor directories are not always at a module's root
|
|
476
|
+
# (packages/api/src/deep/node_modules is unusual but real). Costs only
|
|
477
|
+
# JSON size: enumerate-target.sh counts the whole subtree either way.
|
|
478
|
+
# CANDIDATE_SIGNAL_DEPTH how deep manifests, tests and docs are looked for. Stays shallow on
|
|
479
|
+
# purpose -- a manifest or test directory that far from a module's root
|
|
480
|
+
# is not evidence about the module itself.
|
|
481
|
+
CANDIDATE_TREE_DEPTH = 4
|
|
482
|
+
CANDIDATE_SIGNAL_DEPTH = 2
|
|
483
|
+
CAP_ROOT_FILES = 25
|
|
484
|
+
|
|
485
|
+
WEIGHTS = {
|
|
486
|
+
"size": 30,
|
|
487
|
+
"manifest": 20,
|
|
488
|
+
"own_tests": 15,
|
|
489
|
+
"source_density": 15,
|
|
490
|
+
"structure": 10,
|
|
491
|
+
"docs": 10,
|
|
492
|
+
"cohesion": 20, # only in play under --with-dependencies
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
# enumerate-target.sh delegation
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def enumerate_target(path, depth, top):
|
|
501
|
+
"""Run enumerate-target.sh and return its parsed JSON, or (None, reason)."""
|
|
502
|
+
cmd = [ENUMERATE, "--max-depth", str(depth), "--top", str(top), "--", path]
|
|
503
|
+
try:
|
|
504
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
505
|
+
except OSError as exc:
|
|
506
|
+
return None, "could not execute enumerate-target.sh: %s" % exc
|
|
507
|
+
if proc.returncode != 0:
|
|
508
|
+
detail = proc.stderr.decode("utf-8", "replace").strip().splitlines()
|
|
509
|
+
return None, "enumerate-target.sh exited %d%s" % (
|
|
510
|
+
proc.returncode, (": " + detail[0]) if detail else "")
|
|
511
|
+
try:
|
|
512
|
+
return json.loads(proc.stdout.decode("utf-8", "replace")), None
|
|
513
|
+
except ValueError as exc:
|
|
514
|
+
return None, "enumerate-target.sh emitted unparseable JSON: %s" % exc
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def parent_of(rel):
|
|
518
|
+
return rel.rsplit("/", 1)[0] if "/" in rel else ""
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def basename_of(rel):
|
|
522
|
+
return rel.rsplit("/", 1)[-1]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def is_manifest(name):
|
|
526
|
+
return name in MANIFEST_NAMES or name.endswith(MANIFEST_SUFFIXES)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def looks_like_test_file(name):
|
|
530
|
+
return any(p.match(name) for p in TEST_FILE_PATTERNS)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# Pass 1 — a single repo-wide enumeration builds the directory graph
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
# MAX_DEPTH + 1 is required, not generous: deciding whether a directory AT the depth bound is a
|
|
537
|
+
# candidate needs to see the files sitting directly inside it, which live one level deeper.
|
|
538
|
+
|
|
539
|
+
survey, err = enumerate_target(ROOT_ABS, MAX_DEPTH + 1, 1)
|
|
540
|
+
if survey is None:
|
|
541
|
+
sys.stderr.write("Error: %s\n" % err)
|
|
542
|
+
sys.stderr.write(" root: %s\n" % ROOT_ABS)
|
|
543
|
+
sys.exit(3)
|
|
544
|
+
|
|
545
|
+
gitignore_respected = bool(survey.get("gitignore_respected"))
|
|
546
|
+
if not gitignore_respected:
|
|
547
|
+
if survey.get("ignored_target"):
|
|
548
|
+
notices.append(
|
|
549
|
+
"The analysed root is itself .gitignore'd, so enumerate-target.sh fell back to a "
|
|
550
|
+
"filesystem walk. .gitignore exclusions did not apply; only the build/vendor name "
|
|
551
|
+
"list did."
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
notices.append(
|
|
555
|
+
"The analysed root is not inside a git work tree, so .gitignore could not be "
|
|
556
|
+
"honoured. Only the build/vendor name list and the framework exclusions applied — "
|
|
557
|
+
"ignored-but-present paths may appear as candidates."
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
dir_files = {} # rel dir path -> recursive file count
|
|
561
|
+
dir_depth = {}
|
|
562
|
+
file_paths = set() # rel file paths within MAX_DEPTH + 1
|
|
563
|
+
|
|
564
|
+
for entry in survey.get("tree", []):
|
|
565
|
+
if entry.get("type") == "directory":
|
|
566
|
+
dir_files[entry["path"]] = entry.get("file_count", 0)
|
|
567
|
+
dir_depth[entry["path"]] = entry.get("depth", entry["path"].count("/") + 1)
|
|
568
|
+
elif entry.get("type") == "file":
|
|
569
|
+
file_paths.add(entry["path"])
|
|
570
|
+
|
|
571
|
+
children_by_dir = {}
|
|
572
|
+
for d in dir_files:
|
|
573
|
+
children_by_dir.setdefault(parent_of(d), []).append(d)
|
|
574
|
+
for key in children_by_dir:
|
|
575
|
+
children_by_dir[key].sort()
|
|
576
|
+
|
|
577
|
+
files_by_dir = {}
|
|
578
|
+
for f in file_paths:
|
|
579
|
+
files_by_dir.setdefault(parent_of(f), []).append(f)
|
|
580
|
+
for key in files_by_dir:
|
|
581
|
+
files_by_dir[key].sort()
|
|
582
|
+
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
# Exclusion, viability, and the candidate/container partition
|
|
585
|
+
# ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
excluded = []
|
|
588
|
+
containers = []
|
|
589
|
+
candidate_paths = []
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def exclusion_reason(rel, depth):
|
|
593
|
+
name = basename_of(rel)
|
|
594
|
+
if name in SKIP_DIRS:
|
|
595
|
+
return "build output, vendored dependency, or tooling directory (%s/)" % name
|
|
596
|
+
if name == "worktrees" and parent_of(rel).endswith(".claude"):
|
|
597
|
+
return "worktree directory (.claude/worktrees holds full copies of the repo)"
|
|
598
|
+
if not INCLUDE_FRAMEWORK and depth == 1 and rel in FRAMEWORK_PATHS:
|
|
599
|
+
return ("Jenga framework scaffolding, not consumer application code "
|
|
600
|
+
"(use --include-framework to keep it)")
|
|
601
|
+
if not INCLUDE_TRAINING and depth == 1 and rel in TRAINING_SCAFFOLD_PATHS:
|
|
602
|
+
return ("training-job template scaffolding, not consumer application code "
|
|
603
|
+
"(use --include-training-scaffolding to keep it)")
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def viable_children(dirpath, depth):
|
|
608
|
+
"""Child directories of dirpath that survive exclusion and the --min-files floor."""
|
|
609
|
+
out = []
|
|
610
|
+
for child in children_by_dir.get(dirpath, []):
|
|
611
|
+
if exclusion_reason(child, depth + 1):
|
|
612
|
+
continue
|
|
613
|
+
if dir_files.get(child, 0) < MIN_FILES:
|
|
614
|
+
continue
|
|
615
|
+
out.append(child)
|
|
616
|
+
return out
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def has_own_manifest(dirpath):
|
|
620
|
+
return any(is_manifest(basename_of(f)) for f in files_by_dir.get(dirpath, []))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def descend(dirpath, depth):
|
|
624
|
+
"""Classify every child of dirpath as candidate, container, or excluded."""
|
|
625
|
+
for child in children_by_dir.get(dirpath, []):
|
|
626
|
+
reason = exclusion_reason(child, depth + 1)
|
|
627
|
+
if reason:
|
|
628
|
+
excluded.append({"path": child, "reason": reason})
|
|
629
|
+
continue
|
|
630
|
+
count = dir_files.get(child, 0)
|
|
631
|
+
if count < MIN_FILES:
|
|
632
|
+
excluded.append({
|
|
633
|
+
"path": child,
|
|
634
|
+
"reason": "below the --min-files floor of %d (%d file%s)" % (
|
|
635
|
+
MIN_FILES, count, "" if count == 1 else "s"),
|
|
636
|
+
})
|
|
637
|
+
continue
|
|
638
|
+
classify(child, depth + 1)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def classify(dirpath, depth):
|
|
642
|
+
"""Decide whether dirpath is a subsystem or a folder of subsystems."""
|
|
643
|
+
if depth >= MAX_DEPTH:
|
|
644
|
+
candidate_paths.append(dirpath)
|
|
645
|
+
return
|
|
646
|
+
if has_own_manifest(dirpath):
|
|
647
|
+
# A directory with its own manifest is a unit by definition. This is the rule that keeps
|
|
648
|
+
# monorepo workspace packages intact regardless of their internal shape.
|
|
649
|
+
candidate_paths.append(dirpath)
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
kids = viable_children(dirpath, depth)
|
|
653
|
+
if len(kids) < 2:
|
|
654
|
+
candidate_paths.append(dirpath)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
direct = len(files_by_dir.get(dirpath, []))
|
|
658
|
+
total = dir_files.get(dirpath, 0)
|
|
659
|
+
incidental = direct <= 3 or (total > 0 and (float(direct) / total) <= 0.20)
|
|
660
|
+
if not incidental:
|
|
661
|
+
# It holds substantial code of its own, so it is a subsystem in its own right rather
|
|
662
|
+
# than a wrapper around its children.
|
|
663
|
+
candidate_paths.append(dirpath)
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
containers.append({
|
|
667
|
+
"path": dirpath,
|
|
668
|
+
"depth": depth,
|
|
669
|
+
"reason": ("holds %d viable child directories and only %d file%s of its own, so it "
|
|
670
|
+
"reads as a folder of subsystems rather than a subsystem" % (
|
|
671
|
+
len(kids), direct, "" if direct == 1 else "s")),
|
|
672
|
+
"expanded_into": kids,
|
|
673
|
+
})
|
|
674
|
+
descend(dirpath, depth)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
descend("", 0)
|
|
678
|
+
candidate_paths.sort()
|
|
679
|
+
|
|
680
|
+
# --- runtime bound (NOT the onboard epic cap) --------------------------------------------------
|
|
681
|
+
if len(candidate_paths) > MAX_CANDIDATES:
|
|
682
|
+
ordered = sorted(candidate_paths, key=lambda p: (-dir_files.get(p, 0), p))
|
|
683
|
+
kept, dropped = ordered[:MAX_CANDIDATES], ordered[MAX_CANDIDATES:]
|
|
684
|
+
for p in sorted(dropped):
|
|
685
|
+
excluded.append({
|
|
686
|
+
"path": p,
|
|
687
|
+
"reason": "beyond the --max-candidates runtime bound of %d (analysis cost guard, "
|
|
688
|
+
"not the onboard epic cap)" % MAX_CANDIDATES,
|
|
689
|
+
})
|
|
690
|
+
notices.append(
|
|
691
|
+
"%d candidate directories were found but only the %d largest were enumerated "
|
|
692
|
+
"(--max-candidates). The remainder are listed under \"excluded\". This is a cost guard "
|
|
693
|
+
"on the analysis, not the onboard epic cap." % (len(candidate_paths), MAX_CANDIDATES)
|
|
694
|
+
)
|
|
695
|
+
candidate_paths = sorted(kept)
|
|
696
|
+
|
|
697
|
+
# ---------------------------------------------------------------------------
|
|
698
|
+
# Pass 2 — per-candidate statistics, delegated to enumerate-target.sh
|
|
699
|
+
# ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
deps_available = WITH_DEPENDENCIES and os.access(DETECT_DEPS, os.X_OK)
|
|
702
|
+
if WITH_DEPENDENCIES and not deps_available:
|
|
703
|
+
notices.append(
|
|
704
|
+
"--with-dependencies was requested but detect-dependencies.sh is missing or not "
|
|
705
|
+
"executable at %s; the cohesion signal was dropped and the remaining weights were "
|
|
706
|
+
"renormalised." % DETECT_DEPS
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def cohesion_for(abs_path):
|
|
711
|
+
"""internal / (internal + external) dependency ratio, or None when unavailable."""
|
|
712
|
+
try:
|
|
713
|
+
proc = subprocess.run([DETECT_DEPS, abs_path],
|
|
714
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
715
|
+
except OSError:
|
|
716
|
+
return None
|
|
717
|
+
if proc.returncode != 0:
|
|
718
|
+
return None
|
|
719
|
+
try:
|
|
720
|
+
report = json.loads(proc.stdout.decode("utf-8", "replace"))
|
|
721
|
+
except ValueError:
|
|
722
|
+
return None
|
|
723
|
+
counts = report.get("counts") or {}
|
|
724
|
+
internal = counts.get("internal", 0)
|
|
725
|
+
external = counts.get("external", 0)
|
|
726
|
+
if internal + external == 0:
|
|
727
|
+
return None
|
|
728
|
+
return float(internal) / (internal + external), internal, external
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def skip_dir_reason(rel_in_candidate):
|
|
732
|
+
"""Layer-2 exclusion, evaluated INSIDE an already-selected candidate."""
|
|
733
|
+
name = basename_of(rel_in_candidate)
|
|
734
|
+
if name in SKIP_DIRS:
|
|
735
|
+
return "build output, vendored dependency, or tooling directory (%s/)" % name
|
|
736
|
+
if name == "worktrees" and parent_of(rel_in_candidate).endswith(".claude"):
|
|
737
|
+
return "worktree directory (.claude/worktrees holds full copies of the repo)"
|
|
738
|
+
return None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def nested_skip_roots(stats, candidate_rel):
|
|
742
|
+
"""Outermost SKIP_DIRS directories sitting inside a candidate's own subtree.
|
|
743
|
+
|
|
744
|
+
enumerate-target.sh has no name-exclusion concept -- .gitignore is its only filter -- so a
|
|
745
|
+
COMMITTED node_modules/vendor/dist/build inside a candidate would otherwise be folded into
|
|
746
|
+
that candidate's own totals and score. Gitignored ones never reach here: they are absent from
|
|
747
|
+
the parent's listing entirely, which is also why subtracting the ones that ARE here can never
|
|
748
|
+
double-subtract. Only outermost matches are returned; the tree is path-sorted, so a parent
|
|
749
|
+
always precedes its children.
|
|
750
|
+
|
|
751
|
+
Paths are re-qualified against candidate_rel before being tested. The tree is relative to the
|
|
752
|
+
candidate, but skip_dir_reason() reasons about a path's PARENT (that is how .claude/worktrees
|
|
753
|
+
is recognised), and a candidate-relative path has no parent to inspect at its top level. Test
|
|
754
|
+
"worktrees" alone and the .claude/worktrees rule silently never fires for a candidate that IS
|
|
755
|
+
.claude -- returning paths still candidate-relative, since that is what the caller subtracts
|
|
756
|
+
against.
|
|
757
|
+
"""
|
|
758
|
+
roots = []
|
|
759
|
+
for entry in stats.get("tree", []):
|
|
760
|
+
if entry.get("type") != "directory":
|
|
761
|
+
continue
|
|
762
|
+
path = entry["path"]
|
|
763
|
+
if any(path == r or path.startswith(r + "/") for r, _ in roots):
|
|
764
|
+
continue
|
|
765
|
+
qualified = (candidate_rel + "/" + path) if candidate_rel else path
|
|
766
|
+
reason = skip_dir_reason(qualified)
|
|
767
|
+
if reason:
|
|
768
|
+
roots.append((path, reason))
|
|
769
|
+
return roots
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
measured = []
|
|
773
|
+
for rel in candidate_paths:
|
|
774
|
+
abs_path = os.path.join(ROOT_ABS, rel)
|
|
775
|
+
stats, err = enumerate_target(abs_path, CANDIDATE_TREE_DEPTH, CAP_LARGEST)
|
|
776
|
+
if stats is None:
|
|
777
|
+
excluded.append({"path": rel, "reason": "enumeration failed (%s)" % err})
|
|
778
|
+
notices.append("Skipped candidate %s: %s" % (rel, err))
|
|
779
|
+
continue
|
|
780
|
+
|
|
781
|
+
# --- discount any vendored/build content nested inside this candidate ---------------------
|
|
782
|
+
# Enumeration stays delegated: each nested directory's weight is measured by
|
|
783
|
+
# enumerate-target.sh in its own right and then subtracted, rather than recounted here.
|
|
784
|
+
skip_roots = nested_skip_roots(stats, rel)
|
|
785
|
+
nested_excluded = []
|
|
786
|
+
discount_files = discount_lines = discount_bytes = 0
|
|
787
|
+
discount_ext_files = {}
|
|
788
|
+
discount_ext_lines = {}
|
|
789
|
+
|
|
790
|
+
for skip_path, skip_reason in skip_roots:
|
|
791
|
+
sub, sub_err = enumerate_target(os.path.join(abs_path, skip_path), 1, 1)
|
|
792
|
+
if sub is None:
|
|
793
|
+
notices.append(
|
|
794
|
+
"Could not measure %s nested inside candidate %s (%s); its content remains "
|
|
795
|
+
"counted in that candidate's statistics." % (skip_path, rel, sub_err))
|
|
796
|
+
continue
|
|
797
|
+
discount_files += sub.get("total_files", 0)
|
|
798
|
+
discount_lines += sub.get("total_lines", 0)
|
|
799
|
+
discount_bytes += sub.get("total_bytes", 0)
|
|
800
|
+
for e in sub.get("extensions", []):
|
|
801
|
+
ext = e.get("extension")
|
|
802
|
+
discount_ext_files[ext] = discount_ext_files.get(ext, 0) + e.get("files", 0)
|
|
803
|
+
discount_ext_lines[ext] = discount_ext_lines.get(ext, 0) + e.get("lines", 0)
|
|
804
|
+
nested_excluded.append({
|
|
805
|
+
"path": skip_path,
|
|
806
|
+
"reason": skip_reason,
|
|
807
|
+
"files": sub.get("total_files", 0),
|
|
808
|
+
"lines": sub.get("total_lines", 0),
|
|
809
|
+
})
|
|
810
|
+
excluded.append({
|
|
811
|
+
"path": (rel + "/" + skip_path),
|
|
812
|
+
"reason": skip_reason + ", discounted from its parent candidate's statistics",
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
def under_skip_root(path):
|
|
816
|
+
return any(path == r or path.startswith(r + "/") for r, _ in skip_roots)
|
|
817
|
+
|
|
818
|
+
direct_files = []
|
|
819
|
+
subdirs = []
|
|
820
|
+
manifests = []
|
|
821
|
+
test_paths = []
|
|
822
|
+
doc_paths = []
|
|
823
|
+
|
|
824
|
+
for entry in stats.get("tree", []):
|
|
825
|
+
path = entry["path"]
|
|
826
|
+
if under_skip_root(path):
|
|
827
|
+
continue
|
|
828
|
+
depth = entry.get("depth", path.count("/") + 1)
|
|
829
|
+
name = basename_of(path)
|
|
830
|
+
if entry.get("type") == "directory":
|
|
831
|
+
if depth == 1:
|
|
832
|
+
subdirs.append(path)
|
|
833
|
+
if name.lower() in TEST_DIR_NAMES:
|
|
834
|
+
test_paths.append(path + "/")
|
|
835
|
+
if name.lower() in DOC_DIR_NAMES:
|
|
836
|
+
doc_paths.append(path + "/")
|
|
837
|
+
else:
|
|
838
|
+
if depth == 1:
|
|
839
|
+
direct_files.append(path)
|
|
840
|
+
if is_manifest(name):
|
|
841
|
+
manifests.append(name)
|
|
842
|
+
if DOC_FILE_PATTERN.match(name):
|
|
843
|
+
doc_paths.append(path)
|
|
844
|
+
if depth <= CANDIDATE_SIGNAL_DEPTH and looks_like_test_file(name):
|
|
845
|
+
test_paths.append(path)
|
|
846
|
+
|
|
847
|
+
# Clamped at zero: the subtraction is exact by construction, but a candidate's stats must
|
|
848
|
+
# never be able to go negative on the back of an enumeration disagreement.
|
|
849
|
+
total_files = max(0, stats.get("total_files", 0) - discount_files)
|
|
850
|
+
total_lines = max(0, stats.get("total_lines", 0) - discount_lines)
|
|
851
|
+
total_bytes = max(0, stats.get("total_bytes", 0) - discount_bytes)
|
|
852
|
+
|
|
853
|
+
extensions = []
|
|
854
|
+
for e in stats.get("extensions", []):
|
|
855
|
+
ext = e.get("extension")
|
|
856
|
+
files_left = e.get("files", 0) - discount_ext_files.get(ext, 0)
|
|
857
|
+
if files_left <= 0:
|
|
858
|
+
continue
|
|
859
|
+
extensions.append({
|
|
860
|
+
"extension": ext,
|
|
861
|
+
"files": files_left,
|
|
862
|
+
"lines": max(0, e.get("lines", 0) - discount_ext_lines.get(ext, 0)),
|
|
863
|
+
})
|
|
864
|
+
extensions.sort(key=lambda e: (-e["files"], e["extension"]))
|
|
865
|
+
|
|
866
|
+
source_files = sum(e["files"] for e in extensions if e["extension"] in SOURCE_EXTS)
|
|
867
|
+
|
|
868
|
+
measured.append({
|
|
869
|
+
"path": rel,
|
|
870
|
+
"absolute_path": abs_path,
|
|
871
|
+
"depth": dir_depth.get(rel, rel.count("/") + 1),
|
|
872
|
+
"parent": parent_of(rel) or None,
|
|
873
|
+
"files": total_files,
|
|
874
|
+
"lines": total_lines,
|
|
875
|
+
"bytes": total_bytes,
|
|
876
|
+
"direct_files": len(direct_files),
|
|
877
|
+
"subdirectories": len(subdirs),
|
|
878
|
+
"manifests": sorted(set(manifests)),
|
|
879
|
+
"test_paths": sorted(set(test_paths))[:CAP_TEST_PATHS],
|
|
880
|
+
"doc_paths": sorted(set(doc_paths))[:CAP_DOC_PATHS],
|
|
881
|
+
"top_extensions": extensions[:CAP_EXTENSIONS],
|
|
882
|
+
"largest_files": [{"path": f["path"], "lines": f["lines"]}
|
|
883
|
+
for f in stats.get("largest_files", [])
|
|
884
|
+
if not under_skip_root(f["path"])][:CAP_LARGEST],
|
|
885
|
+
"nested_excluded": sorted(nested_excluded, key=lambda n: n["path"]),
|
|
886
|
+
"_source_files": source_files,
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
# ---------------------------------------------------------------------------
|
|
890
|
+
# Scoring
|
|
891
|
+
# ---------------------------------------------------------------------------
|
|
892
|
+
|
|
893
|
+
max_lines = max([c["lines"] for c in measured], default=0)
|
|
894
|
+
|
|
895
|
+
for c in measured:
|
|
896
|
+
signals = []
|
|
897
|
+
|
|
898
|
+
def add(name, value, detail):
|
|
899
|
+
value = max(0.0, min(1.0, float(value)))
|
|
900
|
+
weight = WEIGHTS[name]
|
|
901
|
+
signals.append({
|
|
902
|
+
"name": name,
|
|
903
|
+
"value": round(value, 4),
|
|
904
|
+
"weight": weight,
|
|
905
|
+
"contribution": round(value * weight, 2),
|
|
906
|
+
"detail": detail,
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
if max_lines > 0:
|
|
910
|
+
size_value = math.log1p(c["lines"]) / math.log1p(max_lines)
|
|
911
|
+
else:
|
|
912
|
+
size_value = 0.0
|
|
913
|
+
add("size", size_value,
|
|
914
|
+
"%d line%s across %d file%s (log-scaled against the largest candidate, %d lines)" % (
|
|
915
|
+
c["lines"], "" if c["lines"] == 1 else "s",
|
|
916
|
+
c["files"], "" if c["files"] == 1 else "s", max_lines))
|
|
917
|
+
|
|
918
|
+
add("manifest", 1.0 if c["manifests"] else 0.0,
|
|
919
|
+
("declares its own %s" % ", ".join(c["manifests"])) if c["manifests"]
|
|
920
|
+
else "no dependency manifest of its own")
|
|
921
|
+
|
|
922
|
+
add("own_tests", 1.0 if c["test_paths"] else 0.0,
|
|
923
|
+
("carries its own tests (%s)" % ", ".join(c["test_paths"][:3])) if c["test_paths"]
|
|
924
|
+
else "no test directory or test-named files of its own")
|
|
925
|
+
|
|
926
|
+
density = (float(c["_source_files"]) / c["files"]) if c["files"] else 0.0
|
|
927
|
+
add("source_density", density,
|
|
928
|
+
"%d of %d file%s have a recognised source extension" % (
|
|
929
|
+
c["_source_files"], c["files"], "" if c["files"] == 1 else "s"))
|
|
930
|
+
|
|
931
|
+
add("structure", min(c["subdirectories"], 4) / 4.0,
|
|
932
|
+
"%d immediate subdirector%s" % (
|
|
933
|
+
c["subdirectories"], "y" if c["subdirectories"] == 1 else "ies"))
|
|
934
|
+
|
|
935
|
+
add("docs", 1.0 if c["doc_paths"] else 0.0,
|
|
936
|
+
("documented by %s" % ", ".join(c["doc_paths"][:3])) if c["doc_paths"]
|
|
937
|
+
else "no README or docs/ of its own")
|
|
938
|
+
|
|
939
|
+
if deps_available:
|
|
940
|
+
result = cohesion_for(c["absolute_path"])
|
|
941
|
+
if result is None:
|
|
942
|
+
add("cohesion", 0.0, "no import/require statements could be resolved")
|
|
943
|
+
else:
|
|
944
|
+
ratio, internal, external = result
|
|
945
|
+
add("cohesion", ratio,
|
|
946
|
+
"%d internal vs %d external dependencies" % (internal, external))
|
|
947
|
+
|
|
948
|
+
total_weight = sum(s["weight"] for s in signals)
|
|
949
|
+
raw = sum(s["contribution"] for s in signals)
|
|
950
|
+
c["score"] = round(100.0 * raw / total_weight, 2) if total_weight else 0.0
|
|
951
|
+
c["signals"] = signals
|
|
952
|
+
del c["_source_files"]
|
|
953
|
+
|
|
954
|
+
measured.sort(key=lambda c: (-c["score"], c["path"]))
|
|
955
|
+
|
|
956
|
+
candidates = []
|
|
957
|
+
for i, c in enumerate(measured, start=1):
|
|
958
|
+
candidates.append({
|
|
959
|
+
"rank": i,
|
|
960
|
+
"path": c["path"],
|
|
961
|
+
"absolute_path": c["absolute_path"],
|
|
962
|
+
"depth": c["depth"],
|
|
963
|
+
"parent": c["parent"],
|
|
964
|
+
"score": c["score"],
|
|
965
|
+
"files": c["files"],
|
|
966
|
+
"lines": c["lines"],
|
|
967
|
+
"bytes": c["bytes"],
|
|
968
|
+
"direct_files": c["direct_files"],
|
|
969
|
+
"subdirectories": c["subdirectories"],
|
|
970
|
+
"manifests": c["manifests"],
|
|
971
|
+
"test_paths": c["test_paths"],
|
|
972
|
+
"doc_paths": c["doc_paths"],
|
|
973
|
+
"top_extensions": c["top_extensions"],
|
|
974
|
+
"largest_files": c["largest_files"],
|
|
975
|
+
"nested_excluded": c["nested_excluded"],
|
|
976
|
+
"signals": c["signals"],
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
# ---------------------------------------------------------------------------
|
|
980
|
+
# Assemble
|
|
981
|
+
# ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
if not candidates:
|
|
984
|
+
if not dir_files and not file_paths:
|
|
985
|
+
notices.append("No files were found under the analysed root — nothing to discover.")
|
|
986
|
+
elif excluded:
|
|
987
|
+
notices.append(
|
|
988
|
+
"No candidate subsystems survived filtering: all %d directory candidate(s) were "
|
|
989
|
+
"excluded. See \"excluded\" for the reason on each." % len(excluded)
|
|
990
|
+
)
|
|
991
|
+
else:
|
|
992
|
+
notices.append(
|
|
993
|
+
"No candidate subsystems were found. The root appears to hold only loose files — "
|
|
994
|
+
"see \"root_files\"."
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
root_file_names = files_by_dir.get("", [])
|
|
998
|
+
|
|
999
|
+
result = {
|
|
1000
|
+
"script": "discover-subsystems.sh",
|
|
1001
|
+
"version": 1,
|
|
1002
|
+
"root": os.path.relpath(ROOT_ABS, REPO_ROOT) if REPO_ROOT else ROOT_ABS,
|
|
1003
|
+
"root_absolute": ROOT_ABS,
|
|
1004
|
+
"repo_root": REPO_ROOT or None,
|
|
1005
|
+
"max_depth": MAX_DEPTH,
|
|
1006
|
+
"min_files": MIN_FILES,
|
|
1007
|
+
"max_candidates": MAX_CANDIDATES,
|
|
1008
|
+
"framework_excluded": not INCLUDE_FRAMEWORK,
|
|
1009
|
+
"gitignore_respected": gitignore_respected,
|
|
1010
|
+
"with_dependencies": deps_available,
|
|
1011
|
+
"totals": {
|
|
1012
|
+
"files": survey.get("total_files", 0),
|
|
1013
|
+
"lines": survey.get("total_lines", 0),
|
|
1014
|
+
"bytes": survey.get("total_bytes", 0),
|
|
1015
|
+
},
|
|
1016
|
+
"candidate_count": len(candidates),
|
|
1017
|
+
"candidates": candidates,
|
|
1018
|
+
"containers": sorted(containers, key=lambda c: c["path"]),
|
|
1019
|
+
"excluded": sorted(excluded, key=lambda e: e["path"]),
|
|
1020
|
+
"root_files": {
|
|
1021
|
+
"count": len(root_file_names),
|
|
1022
|
+
"paths": sorted(root_file_names)[:CAP_ROOT_FILES],
|
|
1023
|
+
},
|
|
1024
|
+
"notices": notices,
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
|
|
1028
|
+
sys.stdout.write("\n")
|
|
1029
|
+
PY
|