bmad-module-skill-forge 1.2.0 → 1.4.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/skill-model.md +26 -32
- package/docs/troubleshooting.md +12 -0
- package/docs/workflows.md +87 -15
- package/package.json +2 -2
- package/src/shared/references/output-contract-schema.md +10 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
- package/src/shared/scripts/skf-detect-language.py +277 -0
- package/src/shared/scripts/skf-detect-workspaces.py +427 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
- package/src/shared/scripts/skf-extract-public-api.py +534 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
- package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
- package/src/shared/scripts/skf-resolve-package.py +264 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
- package/src/shared/scripts/skf-validate-output.py +24 -7
- package/src/shared/scripts/skf-write-skill-brief.py +509 -0
- package/src/skf-brief-skill/SKILL.md +41 -12
- package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
- package/src/skf-brief-skill/assets/scope-templates.md +5 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
- package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
- package/src/skf-brief-skill/references/headless-args.md +22 -0
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
- package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
- package/src/skf-brief-skill/references/version-resolution.md +46 -0
- package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
- package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
- package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
- package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
- package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
- package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
- package/src/skf-quick-skill/SKILL.md +178 -10
- package/src/skf-quick-skill/assets/skill-template.md +5 -1
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
- package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
- package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
- package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
- package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
- package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
- package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
- package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Detect Language — deterministic primary-language detection from a flat file tree.
|
|
6
|
+
|
|
7
|
+
Single source of truth for the language-detection rule table that
|
|
8
|
+
skf-brief-skill step-02 §3 applies. The rule walk is purely deterministic:
|
|
9
|
+
manifest-file presence first (Cargo.toml → rust, package.json → js/ts,
|
|
10
|
+
etc.), then extension-frequency fallback. Moving it into a shared script
|
|
11
|
+
saves ~150-250 tokens per workflow invocation and removes a quiet drift
|
|
12
|
+
seam — the JS-vs-TS disambiguation in particular benefits from being
|
|
13
|
+
co-located with a deterministic rule rather than restated in prose.
|
|
14
|
+
|
|
15
|
+
Detection rules (apply in order, first match wins):
|
|
16
|
+
|
|
17
|
+
1. package.json (with optional tsconfig.json companion):
|
|
18
|
+
tsconfig.json present → typescript (high)
|
|
19
|
+
tsconfig.json absent → javascript (high)
|
|
20
|
+
2. Cargo.toml → rust (high)
|
|
21
|
+
3. pyproject.toml | setup.py | setup.cfg → python (high)
|
|
22
|
+
4. go.mod → go (high)
|
|
23
|
+
5. pom.xml → java (high)
|
|
24
|
+
6. build.gradle.kts → kotlin (high)
|
|
25
|
+
7. build.gradle (Groovy) — check tree:
|
|
26
|
+
src/main/kotlin/ → kotlin (medium)
|
|
27
|
+
else → java (medium)
|
|
28
|
+
8. *.csproj | *.sln → csharp (high)
|
|
29
|
+
9. Gemfile → ruby (high)
|
|
30
|
+
10. Extension-frequency fallback over the full tree.
|
|
31
|
+
dominant extension >= 50% of code files → that language (medium)
|
|
32
|
+
no clear winner → unknown (low)
|
|
33
|
+
|
|
34
|
+
CLI:
|
|
35
|
+
echo '{"tree": ["path1", "path2", ...]}' | uv run skf-detect-language.py
|
|
36
|
+
uv run skf-detect-language.py --json '{"tree": [...]}'
|
|
37
|
+
|
|
38
|
+
Input (JSON object on stdin or via --json):
|
|
39
|
+
tree — list of repo-relative file paths (required, non-empty)
|
|
40
|
+
|
|
41
|
+
Output (JSON on stdout):
|
|
42
|
+
language — javascript | typescript | rust | python | go
|
|
43
|
+
| java | kotlin | csharp | ruby | swift | php
|
|
44
|
+
| unknown
|
|
45
|
+
confidence — "high" | "medium" | "low"
|
|
46
|
+
detection_source — human-readable string naming what fired (manifest
|
|
47
|
+
basename, extension share, etc.)
|
|
48
|
+
fallback_to_extension_frequency — bool (true when rule 10 fired)
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0 — recommendation produced (even when language is "unknown")
|
|
52
|
+
2 — internal error (bad JSON input, IO failure)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import argparse
|
|
58
|
+
import json
|
|
59
|
+
import sys
|
|
60
|
+
from collections import Counter
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
# Manifest basenames the rule table checks. Kept tight — tree-wide pattern
|
|
64
|
+
# matches (e.g. *.csproj) are handled separately to avoid false positives
|
|
65
|
+
# from generated artifacts under build/ or dist/.
|
|
66
|
+
_MANIFEST_RULES: list[tuple[str, str, str]] = [
|
|
67
|
+
# (basename, language, detection_source)
|
|
68
|
+
("Cargo.toml", "rust", "Cargo.toml present"),
|
|
69
|
+
("pyproject.toml", "python", "pyproject.toml present"),
|
|
70
|
+
("setup.py", "python", "setup.py present"),
|
|
71
|
+
("setup.cfg", "python", "setup.cfg present"),
|
|
72
|
+
("go.mod", "go", "go.mod present"),
|
|
73
|
+
("pom.xml", "java", "pom.xml present"),
|
|
74
|
+
("build.gradle.kts", "kotlin", "build.gradle.kts present"),
|
|
75
|
+
("Gemfile", "ruby", "Gemfile present"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# Glob-style suffix rules — matches anywhere in the tree. csproj/sln are
|
|
79
|
+
# C#-specific. Ordered so the first hit wins.
|
|
80
|
+
_SUFFIX_RULES: list[tuple[str, str, str]] = [
|
|
81
|
+
(".csproj", "csharp", ".csproj project file present"),
|
|
82
|
+
(".sln", "csharp", ".sln solution file present"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# Extension → language for the frequency fallback. Tightly limited to source
|
|
86
|
+
# extensions; keeping this small avoids the fallback being skewed by
|
|
87
|
+
# auto-generated assets (.json, .md, .yaml, etc.).
|
|
88
|
+
_EXTENSION_TO_LANGUAGE: dict[str, str] = {
|
|
89
|
+
".py": "python",
|
|
90
|
+
".js": "javascript",
|
|
91
|
+
".jsx": "javascript",
|
|
92
|
+
".mjs": "javascript",
|
|
93
|
+
".cjs": "javascript",
|
|
94
|
+
".ts": "typescript",
|
|
95
|
+
".tsx": "typescript",
|
|
96
|
+
".rs": "rust",
|
|
97
|
+
".go": "go",
|
|
98
|
+
".java": "java",
|
|
99
|
+
".kt": "kotlin",
|
|
100
|
+
".kts": "kotlin",
|
|
101
|
+
".cs": "csharp",
|
|
102
|
+
".rb": "ruby",
|
|
103
|
+
".swift": "swift",
|
|
104
|
+
".php": "php",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_DOMINANCE_THRESHOLD = 0.50
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _die(message: str, code: int = 2) -> None:
|
|
111
|
+
sys.stderr.write(f"skf-detect-language: {message}\n")
|
|
112
|
+
sys.exit(code)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _basename(path: str) -> str:
|
|
116
|
+
return path.rsplit("/", 1)[-1]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _has_basename(tree: list[str], basename: str) -> bool:
|
|
120
|
+
return any(_basename(p) == basename for p in tree)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_suffix(tree: list[str], suffix: str) -> bool:
|
|
124
|
+
return any(p.endswith(suffix) for p in tree)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _has_path_segment(tree: list[str], segment: str) -> bool:
|
|
128
|
+
"""True when any path contains the given segment (e.g. 'src/main/kotlin/')."""
|
|
129
|
+
return any(segment in p for p in tree)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extension(path: str) -> str:
|
|
133
|
+
"""Return the lowercased file extension including the leading dot, or empty string."""
|
|
134
|
+
base = _basename(path)
|
|
135
|
+
if "." not in base or base.startswith("."):
|
|
136
|
+
return ""
|
|
137
|
+
return "." + base.rsplit(".", 1)[-1].lower()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
|
|
141
|
+
"""Score the tree by source-extension frequency. Returns the result envelope."""
|
|
142
|
+
counter: Counter[str] = Counter()
|
|
143
|
+
for path in tree:
|
|
144
|
+
ext = _extension(path)
|
|
145
|
+
if ext in _EXTENSION_TO_LANGUAGE:
|
|
146
|
+
counter[ext] += 1
|
|
147
|
+
|
|
148
|
+
total = sum(counter.values())
|
|
149
|
+
if total == 0:
|
|
150
|
+
return {
|
|
151
|
+
"language": "unknown",
|
|
152
|
+
"confidence": "low",
|
|
153
|
+
"detection_source": "no recognized source extensions in tree",
|
|
154
|
+
"fallback_to_extension_frequency": True,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Largest-share extension wins. Ties (e.g. equal counts) broken by
|
|
158
|
+
# the natural order of _EXTENSION_TO_LANGUAGE — Counter.most_common
|
|
159
|
+
# is stable for equal counts and we don't rely on which loses.
|
|
160
|
+
top_ext, top_count = counter.most_common(1)[0]
|
|
161
|
+
share = top_count / total
|
|
162
|
+
language = _EXTENSION_TO_LANGUAGE[top_ext]
|
|
163
|
+
|
|
164
|
+
if share >= _DOMINANCE_THRESHOLD:
|
|
165
|
+
confidence = "medium"
|
|
166
|
+
source = f"extension frequency: {top_ext} is {top_count}/{total} of source files ({share:.0%})"
|
|
167
|
+
else:
|
|
168
|
+
confidence = "low"
|
|
169
|
+
source = f"no dominant extension: top is {top_ext} at {top_count}/{total} ({share:.0%}, threshold {int(_DOMINANCE_THRESHOLD * 100)}%)"
|
|
170
|
+
# Still surface the best-guess language; caller / step-03 §4 lets
|
|
171
|
+
# the user override on low confidence.
|
|
172
|
+
return {
|
|
173
|
+
"language": language,
|
|
174
|
+
"confidence": confidence,
|
|
175
|
+
"detection_source": source,
|
|
176
|
+
"fallback_to_extension_frequency": True,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect(payload: dict[str, Any]) -> dict[str, Any]:
|
|
181
|
+
"""Apply the documented rule walk. Always returns a recommendation."""
|
|
182
|
+
tree = payload.get("tree")
|
|
183
|
+
if not isinstance(tree, list):
|
|
184
|
+
_die("payload.tree must be an array of repo-relative file paths")
|
|
185
|
+
if len(tree) == 0:
|
|
186
|
+
_die("payload.tree must be non-empty")
|
|
187
|
+
|
|
188
|
+
# Rule 1 — package.json (with tsconfig.json disambiguation)
|
|
189
|
+
if _has_basename(tree, "package.json"):
|
|
190
|
+
if _has_basename(tree, "tsconfig.json"):
|
|
191
|
+
return {
|
|
192
|
+
"language": "typescript",
|
|
193
|
+
"confidence": "high",
|
|
194
|
+
"detection_source": "package.json + tsconfig.json present",
|
|
195
|
+
"fallback_to_extension_frequency": False,
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
"language": "javascript",
|
|
199
|
+
"confidence": "high",
|
|
200
|
+
"detection_source": "package.json present (no tsconfig.json)",
|
|
201
|
+
"fallback_to_extension_frequency": False,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Rules 2-6 — single-basename manifests walked in priority order. Note
|
|
205
|
+
# that build.gradle (Groovy DSL) is NOT in this table; it requires
|
|
206
|
+
# tree-aware Java/Kotlin disambiguation handled in Rule 7b below.
|
|
207
|
+
# build.gradle.kts IS in the table (returns kotlin high) and fires
|
|
208
|
+
# here before the Groovy variant is considered.
|
|
209
|
+
for basename, language, source in _MANIFEST_RULES:
|
|
210
|
+
if _has_basename(tree, basename):
|
|
211
|
+
return {
|
|
212
|
+
"language": language,
|
|
213
|
+
"confidence": "high",
|
|
214
|
+
"detection_source": source,
|
|
215
|
+
"fallback_to_extension_frequency": False,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Rule 7b — build.gradle (Groovy DSL): check src/main/kotlin/ to disambiguate.
|
|
219
|
+
# This rule sits *after* the basename loop because build.gradle.kts is
|
|
220
|
+
# already covered by the loop and we don't want it to pre-empt that match.
|
|
221
|
+
if _has_basename(tree, "build.gradle"):
|
|
222
|
+
if _has_path_segment(tree, "src/main/kotlin/"):
|
|
223
|
+
return {
|
|
224
|
+
"language": "kotlin",
|
|
225
|
+
"confidence": "medium",
|
|
226
|
+
"detection_source": "build.gradle (Groovy) + src/main/kotlin/ present",
|
|
227
|
+
"fallback_to_extension_frequency": False,
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
"language": "java",
|
|
231
|
+
"confidence": "medium",
|
|
232
|
+
"detection_source": "build.gradle (Groovy) present without src/main/kotlin/ — defaulting to java",
|
|
233
|
+
"fallback_to_extension_frequency": False,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Rules 8-9 — suffix-based (csproj, sln)
|
|
237
|
+
for suffix, language, source in _SUFFIX_RULES:
|
|
238
|
+
if _has_suffix(tree, suffix):
|
|
239
|
+
return {
|
|
240
|
+
"language": language,
|
|
241
|
+
"confidence": "high",
|
|
242
|
+
"detection_source": source,
|
|
243
|
+
"fallback_to_extension_frequency": False,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Rule 10 — extension-frequency fallback
|
|
247
|
+
return _frequency_fallback(tree)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_argv(argv: list[str]) -> dict:
|
|
251
|
+
parser = argparse.ArgumentParser(
|
|
252
|
+
description="Detect primary language from a flat repo file tree by walking the documented rule table.",
|
|
253
|
+
)
|
|
254
|
+
parser.add_argument("--json", help="JSON payload (alternative to stdin)")
|
|
255
|
+
args = parser.parse_args(argv)
|
|
256
|
+
raw = args.json if args.json is not None else sys.stdin.read()
|
|
257
|
+
if not raw or not raw.strip():
|
|
258
|
+
_die("empty input (expected JSON payload on stdin or via --json)")
|
|
259
|
+
try:
|
|
260
|
+
payload = json.loads(raw)
|
|
261
|
+
except json.JSONDecodeError as e:
|
|
262
|
+
_die(f"invalid JSON input: {e}")
|
|
263
|
+
if not isinstance(payload, dict):
|
|
264
|
+
_die("payload must be a JSON object")
|
|
265
|
+
return payload
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main(argv: list[str]) -> int:
|
|
269
|
+
payload = _parse_argv(argv)
|
|
270
|
+
result = detect(payload)
|
|
271
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
272
|
+
sys.stdout.write("\n")
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Detect Workspaces — pure detector for monorepo / multi-package layouts.
|
|
6
|
+
|
|
7
|
+
Takes a JSON payload on stdin describing a target repo's file tree plus the
|
|
8
|
+
contents of a small set of root manifests, and returns whether a workspace
|
|
9
|
+
layout is present, which manifest kind drives it, and the list of resolved
|
|
10
|
+
workspaces. The helper does NO file I/O — the caller (typically
|
|
11
|
+
skf-brief-skill step-02 §1b) fetches files via `gh api` / local filesystem
|
|
12
|
+
and pipes the relevant content in.
|
|
13
|
+
|
|
14
|
+
Detection runs in priority order; the first matching detector wins:
|
|
15
|
+
|
|
16
|
+
1. npm-workspaces package.json has `workspaces: [...]` or `{packages: [...]}`
|
|
17
|
+
2. pnpm-workspaces pnpm-workspace.yaml exists with a `packages:` list
|
|
18
|
+
3. lerna lerna.json exists (`packages` field optional, defaults to `packages/*`)
|
|
19
|
+
4. cargo-workspace Cargo.toml has `[workspace]` with `members = [...]`
|
|
20
|
+
5. python-multi-package multiple pyproject.toml under packages/* or apps/*
|
|
21
|
+
6. generic-folders apps/, packages/, libs/, or code/ each with subdirs
|
|
22
|
+
containing a recognisable manifest
|
|
23
|
+
|
|
24
|
+
Glob members (`packages/*`, `apps/*`, etc.) are resolved against the supplied
|
|
25
|
+
file tree; only directories whose recognisable manifest is present in the tree
|
|
26
|
+
become workspaces. A detector that finds zero non-excluded matches falls
|
|
27
|
+
through to the next detector instead of returning is_monorepo: true with an
|
|
28
|
+
empty list.
|
|
29
|
+
|
|
30
|
+
Input JSON shape (stdin):
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"tree": ["package.json", "packages/foo/package.json", "packages/foo/src/index.js", ...],
|
|
34
|
+
"manifests": {
|
|
35
|
+
"package.json": "<raw text>",
|
|
36
|
+
"Cargo.toml": "<raw text>",
|
|
37
|
+
"pnpm-workspace.yaml": "<raw text>",
|
|
38
|
+
"lerna.json": "<raw text>"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
- `tree` is a flat list of repository-relative file paths (POSIX separators).
|
|
43
|
+
Per-workspace child manifests (e.g. `packages/foo/package.json`) MUST appear
|
|
44
|
+
here for glob resolution; their contents are optional.
|
|
45
|
+
- `manifests` is a dict keyed by repository-relative path; only root-level
|
|
46
|
+
manifests must be supplied. Per-workspace manifest contents may be
|
|
47
|
+
included to populate the workspace `name` field, but absence is fine
|
|
48
|
+
(the path basename is used as a fallback).
|
|
49
|
+
|
|
50
|
+
Output JSON shape (stdout):
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
"is_monorepo": true | false,
|
|
54
|
+
"manifest_kind": "npm-workspaces" | "pnpm-workspaces" | "lerna"
|
|
55
|
+
| "cargo-workspace" | "python-multi-package"
|
|
56
|
+
| "generic-folders" | null,
|
|
57
|
+
"workspaces": [
|
|
58
|
+
{"name": "foo", "path": "packages/foo", "manifest": "packages/foo/package.json"},
|
|
59
|
+
...
|
|
60
|
+
],
|
|
61
|
+
"warnings": ["..."]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Exit codes:
|
|
65
|
+
|
|
66
|
+
0 success (regardless of is_monorepo value)
|
|
67
|
+
1 payload error (malformed top-level JSON shape)
|
|
68
|
+
2 stdin / argparse / JSON-decode error
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
import argparse
|
|
74
|
+
import fnmatch
|
|
75
|
+
import json
|
|
76
|
+
import re
|
|
77
|
+
import sys
|
|
78
|
+
import tomllib
|
|
79
|
+
from typing import Optional
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
GENERIC_PARENTS = ("apps", "packages", "libs", "code")
|
|
83
|
+
RECOGNISED_MANIFESTS = (
|
|
84
|
+
"package.json",
|
|
85
|
+
"Cargo.toml",
|
|
86
|
+
"pyproject.toml",
|
|
87
|
+
"setup.py",
|
|
88
|
+
"go.mod",
|
|
89
|
+
"build.gradle",
|
|
90
|
+
"build.gradle.kts",
|
|
91
|
+
"pom.xml",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
# Helpers
|
|
97
|
+
# --------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalise_path(path: str) -> str:
|
|
101
|
+
"""Strip leading './' and trailing '/' so comparisons are stable."""
|
|
102
|
+
while path.startswith("./"):
|
|
103
|
+
path = path[2:]
|
|
104
|
+
return path.rstrip("/")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _match_globs(tree: set[str], globs: list[str]) -> list[str]:
|
|
108
|
+
"""Resolve workspace globs against the tree.
|
|
109
|
+
|
|
110
|
+
Returns the sorted list of *directory paths* that have at least one matching
|
|
111
|
+
manifest under them. Patterns starting with `!` are exclusions applied after
|
|
112
|
+
the inclusion pass. Brace expansion is not supported (npm/pnpm rarely use
|
|
113
|
+
it in published configs); a literal pattern without `*` is treated as a
|
|
114
|
+
literal directory path.
|
|
115
|
+
"""
|
|
116
|
+
includes: list[str] = []
|
|
117
|
+
excludes: list[str] = []
|
|
118
|
+
for raw in globs:
|
|
119
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
120
|
+
continue
|
|
121
|
+
cleaned = _normalise_path(raw.strip())
|
|
122
|
+
if cleaned.startswith("!"):
|
|
123
|
+
excludes.append(cleaned[1:])
|
|
124
|
+
else:
|
|
125
|
+
includes.append(cleaned)
|
|
126
|
+
|
|
127
|
+
candidate_dirs: set[str] = set()
|
|
128
|
+
for pat in includes:
|
|
129
|
+
if "*" in pat:
|
|
130
|
+
for path in tree:
|
|
131
|
+
# match the directory portion of every file path against the glob
|
|
132
|
+
parts = path.split("/")
|
|
133
|
+
for depth in range(1, len(parts)):
|
|
134
|
+
candidate = "/".join(parts[:depth])
|
|
135
|
+
if fnmatch.fnmatchcase(candidate, pat):
|
|
136
|
+
candidate_dirs.add(candidate)
|
|
137
|
+
else:
|
|
138
|
+
# literal directory; include only if the tree contains a manifest under it
|
|
139
|
+
candidate_dirs.add(pat)
|
|
140
|
+
|
|
141
|
+
# Apply excludes
|
|
142
|
+
survivors: set[str] = set()
|
|
143
|
+
for cand in candidate_dirs:
|
|
144
|
+
if any(fnmatch.fnmatchcase(cand, ex) or cand == ex for ex in excludes):
|
|
145
|
+
continue
|
|
146
|
+
survivors.add(cand)
|
|
147
|
+
|
|
148
|
+
return sorted(survivors)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _find_workspace_manifest(workspace_path: str, tree: set[str]) -> Optional[str]:
|
|
152
|
+
"""Return the first recognised manifest under `workspace_path`, or None."""
|
|
153
|
+
for manifest in RECOGNISED_MANIFESTS:
|
|
154
|
+
candidate = f"{workspace_path}/{manifest}"
|
|
155
|
+
if candidate in tree:
|
|
156
|
+
return candidate
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_workspace_name(manifest_path: str, manifests: dict[str, str], fallback: str) -> str:
|
|
161
|
+
"""Extract a sensible name from a workspace's manifest.
|
|
162
|
+
|
|
163
|
+
Best-effort: returns the manifest's declared package name when parseable,
|
|
164
|
+
otherwise the directory basename.
|
|
165
|
+
"""
|
|
166
|
+
content = manifests.get(manifest_path)
|
|
167
|
+
if content is None:
|
|
168
|
+
return fallback
|
|
169
|
+
if manifest_path.endswith("package.json"):
|
|
170
|
+
try:
|
|
171
|
+
data = json.loads(content)
|
|
172
|
+
name = data.get("name")
|
|
173
|
+
if isinstance(name, str) and name:
|
|
174
|
+
return name
|
|
175
|
+
except (json.JSONDecodeError, AttributeError):
|
|
176
|
+
pass
|
|
177
|
+
elif manifest_path.endswith("Cargo.toml"):
|
|
178
|
+
try:
|
|
179
|
+
data = tomllib.loads(content)
|
|
180
|
+
name = data.get("package", {}).get("name")
|
|
181
|
+
if isinstance(name, str) and name:
|
|
182
|
+
return name
|
|
183
|
+
except tomllib.TOMLDecodeError:
|
|
184
|
+
pass
|
|
185
|
+
elif manifest_path.endswith("pyproject.toml"):
|
|
186
|
+
try:
|
|
187
|
+
data = tomllib.loads(content)
|
|
188
|
+
name = data.get("project", {}).get("name")
|
|
189
|
+
if isinstance(name, str) and name:
|
|
190
|
+
return name
|
|
191
|
+
except tomllib.TOMLDecodeError:
|
|
192
|
+
pass
|
|
193
|
+
return fallback
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _build_workspaces(
|
|
197
|
+
workspace_dirs: list[str],
|
|
198
|
+
tree: set[str],
|
|
199
|
+
manifests: dict[str, str],
|
|
200
|
+
) -> list[dict]:
|
|
201
|
+
"""Resolve each workspace dir to its manifest + name, drop dirs without a manifest."""
|
|
202
|
+
out: list[dict] = []
|
|
203
|
+
for ws_path in workspace_dirs:
|
|
204
|
+
manifest = _find_workspace_manifest(ws_path, tree)
|
|
205
|
+
if manifest is None:
|
|
206
|
+
continue
|
|
207
|
+
fallback_name = ws_path.rsplit("/", 1)[-1] or ws_path
|
|
208
|
+
name = _read_workspace_name(manifest, manifests, fallback_name)
|
|
209
|
+
out.append({"name": name, "path": ws_path, "manifest": manifest})
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------------
|
|
214
|
+
# Detectors
|
|
215
|
+
# --------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def detect_npm_workspaces(
|
|
219
|
+
tree: set[str], manifests: dict[str, str], warnings: list[str]
|
|
220
|
+
) -> Optional[list[dict]]:
|
|
221
|
+
content = manifests.get("package.json")
|
|
222
|
+
if content is None:
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
data = json.loads(content)
|
|
226
|
+
except json.JSONDecodeError as e:
|
|
227
|
+
warnings.append(f"package.json JSON parse error: {e}")
|
|
228
|
+
return None
|
|
229
|
+
if not isinstance(data, dict):
|
|
230
|
+
return None
|
|
231
|
+
ws = data.get("workspaces")
|
|
232
|
+
if ws is None:
|
|
233
|
+
return None
|
|
234
|
+
if isinstance(ws, dict):
|
|
235
|
+
ws = ws.get("packages", [])
|
|
236
|
+
if not isinstance(ws, list) or not ws:
|
|
237
|
+
return None
|
|
238
|
+
dirs = _match_globs(tree, ws)
|
|
239
|
+
return _build_workspaces(dirs, tree, manifests)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def detect_pnpm_workspaces(
|
|
243
|
+
tree: set[str], manifests: dict[str, str], warnings: list[str]
|
|
244
|
+
) -> Optional[list[dict]]:
|
|
245
|
+
content = manifests.get("pnpm-workspace.yaml")
|
|
246
|
+
if content is None:
|
|
247
|
+
return None
|
|
248
|
+
try:
|
|
249
|
+
import yaml # local import keeps top-level cheap when YAML is unused
|
|
250
|
+
except ImportError:
|
|
251
|
+
warnings.append("pyyaml not available — pnpm-workspace.yaml not parsed")
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
data = yaml.safe_load(content)
|
|
255
|
+
except yaml.YAMLError as e:
|
|
256
|
+
warnings.append(f"pnpm-workspace.yaml parse error: {e}")
|
|
257
|
+
return None
|
|
258
|
+
if not isinstance(data, dict):
|
|
259
|
+
return None
|
|
260
|
+
pkgs = data.get("packages")
|
|
261
|
+
if not isinstance(pkgs, list) or not pkgs:
|
|
262
|
+
return None
|
|
263
|
+
dirs = _match_globs(tree, pkgs)
|
|
264
|
+
return _build_workspaces(dirs, tree, manifests)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def detect_lerna(
|
|
268
|
+
tree: set[str], manifests: dict[str, str], warnings: list[str]
|
|
269
|
+
) -> Optional[list[dict]]:
|
|
270
|
+
content = manifests.get("lerna.json")
|
|
271
|
+
if content is None:
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
data = json.loads(content)
|
|
275
|
+
except json.JSONDecodeError as e:
|
|
276
|
+
warnings.append(f"lerna.json JSON parse error: {e}")
|
|
277
|
+
return None
|
|
278
|
+
if not isinstance(data, dict):
|
|
279
|
+
return None
|
|
280
|
+
pkgs = data.get("packages")
|
|
281
|
+
if pkgs is None:
|
|
282
|
+
return None
|
|
283
|
+
if not isinstance(pkgs, list) or not pkgs:
|
|
284
|
+
return None
|
|
285
|
+
dirs = _match_globs(tree, pkgs)
|
|
286
|
+
return _build_workspaces(dirs, tree, manifests)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def detect_cargo_workspace(
|
|
290
|
+
tree: set[str], manifests: dict[str, str], warnings: list[str]
|
|
291
|
+
) -> Optional[list[dict]]:
|
|
292
|
+
content = manifests.get("Cargo.toml")
|
|
293
|
+
if content is None:
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
data = tomllib.loads(content)
|
|
297
|
+
except tomllib.TOMLDecodeError as e:
|
|
298
|
+
warnings.append(f"Cargo.toml parse error: {e}")
|
|
299
|
+
return None
|
|
300
|
+
workspace = data.get("workspace")
|
|
301
|
+
if not isinstance(workspace, dict):
|
|
302
|
+
return None
|
|
303
|
+
members = workspace.get("members")
|
|
304
|
+
if not isinstance(members, list) or not members:
|
|
305
|
+
return None
|
|
306
|
+
excludes = workspace.get("exclude", [])
|
|
307
|
+
patterns = list(members) + [f"!{e}" for e in excludes if isinstance(e, str)]
|
|
308
|
+
dirs = _match_globs(tree, patterns)
|
|
309
|
+
return _build_workspaces(dirs, tree, manifests)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def detect_python_multi_package(
|
|
313
|
+
tree: set[str], manifests: dict[str, str], warnings: list[str]
|
|
314
|
+
) -> Optional[list[dict]]:
|
|
315
|
+
candidates: list[str] = []
|
|
316
|
+
for path in tree:
|
|
317
|
+
if not path.endswith("/pyproject.toml"):
|
|
318
|
+
continue
|
|
319
|
+
parent = path[: -len("/pyproject.toml")]
|
|
320
|
+
# match `packages/<x>/pyproject.toml`, `apps/<x>/pyproject.toml`, or `libs/<x>/pyproject.toml`
|
|
321
|
+
if re.match(r"^(packages|apps|libs)/[^/]+$", parent):
|
|
322
|
+
candidates.append(parent)
|
|
323
|
+
if len(candidates) < 2:
|
|
324
|
+
return None
|
|
325
|
+
return _build_workspaces(sorted(set(candidates)), tree, manifests)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def detect_generic_folders(
|
|
329
|
+
tree: set[str], manifests: dict[str, str], warnings: list[str]
|
|
330
|
+
) -> Optional[list[dict]]:
|
|
331
|
+
"""Last-resort: ≥2 manifested subdirs under any combination of apps/, packages/, libs/, code/.
|
|
332
|
+
|
|
333
|
+
Accumulates across all GENERIC_PARENTS — a repo with one manifested child under apps/
|
|
334
|
+
and one under packages/ counts as a 2-workspace monorepo. Earlier detectors (npm, pnpm,
|
|
335
|
+
cargo) take priority for repos with a root workspace manifest.
|
|
336
|
+
"""
|
|
337
|
+
discovered: list[str] = []
|
|
338
|
+
for parent in GENERIC_PARENTS:
|
|
339
|
+
children: set[str] = set()
|
|
340
|
+
for path in tree:
|
|
341
|
+
if not path.startswith(parent + "/"):
|
|
342
|
+
continue
|
|
343
|
+
parts = path.split("/")
|
|
344
|
+
if len(parts) < 3:
|
|
345
|
+
continue # need at least parent/child/file
|
|
346
|
+
children.add(f"{parent}/{parts[1]}")
|
|
347
|
+
for child in children:
|
|
348
|
+
if _find_workspace_manifest(child, tree) is not None:
|
|
349
|
+
discovered.append(child)
|
|
350
|
+
if len(discovered) < 2:
|
|
351
|
+
return None
|
|
352
|
+
return _build_workspaces(sorted(set(discovered)), tree, manifests)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
DETECTORS: list[tuple[str, callable]] = [
|
|
356
|
+
("npm-workspaces", detect_npm_workspaces),
|
|
357
|
+
("pnpm-workspaces", detect_pnpm_workspaces),
|
|
358
|
+
("lerna", detect_lerna),
|
|
359
|
+
("cargo-workspace", detect_cargo_workspace),
|
|
360
|
+
("python-multi-package", detect_python_multi_package),
|
|
361
|
+
("generic-folders", detect_generic_folders),
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# --------------------------------------------------------------------------
|
|
366
|
+
# Entry point
|
|
367
|
+
# --------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def detect(payload: dict) -> dict:
|
|
371
|
+
"""Pure detection — the CLI wraps this with stdin/stdout I/O and exit-code mapping."""
|
|
372
|
+
tree_raw = payload.get("tree")
|
|
373
|
+
manifests = payload.get("manifests")
|
|
374
|
+
warnings: list[str] = []
|
|
375
|
+
if not isinstance(tree_raw, list):
|
|
376
|
+
return {"_payload_error": "missing or non-list 'tree' field"}
|
|
377
|
+
if not isinstance(manifests, dict):
|
|
378
|
+
return {"_payload_error": "missing or non-dict 'manifests' field"}
|
|
379
|
+
tree: set[str] = {_normalise_path(p) for p in tree_raw if isinstance(p, str) and p.strip()}
|
|
380
|
+
|
|
381
|
+
for kind, detector in DETECTORS:
|
|
382
|
+
result = detector(tree, manifests, warnings)
|
|
383
|
+
if result and len(result) >= 1:
|
|
384
|
+
return {
|
|
385
|
+
"is_monorepo": True,
|
|
386
|
+
"manifest_kind": kind,
|
|
387
|
+
"workspaces": result,
|
|
388
|
+
"warnings": warnings,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
"is_monorepo": False,
|
|
393
|
+
"manifest_kind": None,
|
|
394
|
+
"workspaces": [],
|
|
395
|
+
"warnings": warnings,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
400
|
+
parser = argparse.ArgumentParser(description="Detect monorepo / workspace layouts from a tree + manifests payload.")
|
|
401
|
+
parser.add_argument("--json", help="Inline JSON payload (otherwise read from stdin).")
|
|
402
|
+
args = parser.parse_args(argv)
|
|
403
|
+
|
|
404
|
+
raw = args.json
|
|
405
|
+
if raw is None:
|
|
406
|
+
raw = sys.stdin.read()
|
|
407
|
+
if not raw.strip():
|
|
408
|
+
print(json.dumps({"error": "empty input"}), file=sys.stderr)
|
|
409
|
+
return 2
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
payload = json.loads(raw)
|
|
413
|
+
except json.JSONDecodeError as e:
|
|
414
|
+
print(json.dumps({"error": f"json decode error: {e}"}), file=sys.stderr)
|
|
415
|
+
return 2
|
|
416
|
+
|
|
417
|
+
result = detect(payload)
|
|
418
|
+
if "_payload_error" in result:
|
|
419
|
+
print(json.dumps({"error": result["_payload_error"]}), file=sys.stderr)
|
|
420
|
+
return 1
|
|
421
|
+
|
|
422
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
sys.exit(main())
|