bmad-module-skill-forge 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +10 -5
  3. package/docs/_data/pinned.yaml +2 -2
  4. package/docs/agents.md +11 -2
  5. package/docs/architecture.md +5 -4
  6. package/docs/bmad-synergy.md +42 -3
  7. package/docs/campaign.md +172 -0
  8. package/docs/examples.md +7 -3
  9. package/docs/forge-auto.md +90 -0
  10. package/docs/getting-started.md +9 -0
  11. package/docs/how-it-works.md +6 -4
  12. package/docs/index.md +4 -3
  13. package/docs/skill-model.md +1 -1
  14. package/docs/troubleshooting.md +17 -1
  15. package/docs/verifying-a-skill.md +9 -3
  16. package/docs/why-skf.md +1 -1
  17. package/docs/workflows.md +73 -27
  18. package/package.json +2 -2
  19. package/src/module-help.csv +2 -1
  20. package/src/shared/_known-workarounds.yaml +155 -0
  21. package/src/shared/data/language-corpora.json +32 -0
  22. package/src/shared/references/pipeline-contracts.md +14 -2
  23. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  26. package/src/shared/scripts/skf-detect-docs.py +417 -0
  27. package/src/shared/scripts/skf-detect-language.py +5 -3
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  29. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  30. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  31. package/src/shared/scripts/skf-language-corpora.py +100 -0
  32. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  33. package/src/shared/scripts/skf-preapply.py +192 -0
  34. package/src/shared/scripts/skf-shape-detect.py +1114 -0
  35. package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
  36. package/src/shared/scripts/skf-validate-pins.py +368 -0
  37. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  38. package/src/skf-analyze-source/SKILL.md +26 -20
  39. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  40. package/src/skf-analyze-source/references/init.md +30 -0
  41. package/src/skf-analyze-source/references/step-auto-scope.md +677 -0
  42. package/src/skf-analyze-source/references/step-shape-detect.md +86 -0
  43. package/src/skf-audit-skill/SKILL.md +1 -0
  44. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  45. package/src/skf-audit-skill/references/init.md +1 -1
  46. package/src/skf-audit-skill/references/report.md +4 -0
  47. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  48. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  49. package/src/skf-brief-skill/SKILL.md +7 -3
  50. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  51. package/src/skf-brief-skill/references/analyze-target.md +7 -4
  52. package/src/skf-brief-skill/references/confirm-brief.md +0 -1
  53. package/src/skf-brief-skill/references/gather-intent.md +22 -2
  54. package/src/skf-brief-skill/references/scope-definition.md +2 -1
  55. package/src/skf-brief-skill/references/step-auto-brief.md +188 -0
  56. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  57. package/src/skf-brief-skill/references/write-brief.md +2 -3
  58. package/src/skf-campaign/SKILL.md +190 -0
  59. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  60. package/src/skf-campaign/customize.toml +73 -0
  61. package/src/skf-campaign/manifest.yaml +11 -0
  62. package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
  63. package/src/skf-campaign/references/health-check.md +35 -0
  64. package/src/skf-campaign/references/step-01-setup.md +122 -0
  65. package/src/skf-campaign/references/step-02-strategy.md +97 -0
  66. package/src/skf-campaign/references/step-03-pins.md +56 -0
  67. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  68. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  69. package/src/skf-campaign/references/step-06-batch.md +87 -0
  70. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  71. package/src/skf-campaign/references/step-08-verify.md +75 -0
  72. package/src/skf-campaign/references/step-09-refine.md +83 -0
  73. package/src/skf-campaign/references/step-10-export.md +106 -0
  74. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  75. package/src/skf-campaign/references/step-resume.md +114 -0
  76. package/src/skf-campaign/scripts/.gitkeep +0 -0
  77. package/src/skf-campaign/scripts/campaign-deps.py +235 -0
  78. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  79. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  80. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  81. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  82. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  83. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  84. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  85. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  86. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  87. package/src/skf-create-skill/SKILL.md +4 -1
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
  89. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  90. package/src/skf-create-skill/references/compile.md +5 -2
  91. package/src/skf-create-skill/references/extract.md +9 -1
  92. package/src/skf-create-skill/references/extraction-patterns.md +3 -1
  93. package/src/skf-create-skill/references/generate-artifacts.md +11 -2
  94. package/src/skf-create-skill/references/health-check.md +2 -2
  95. package/src/skf-create-skill/references/report.md +17 -1
  96. package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
  97. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  98. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  99. package/src/skf-create-skill/references/step-doc-sources.md +123 -0
  100. package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
  101. package/src/skf-create-skill/references/validate.md +12 -3
  102. package/src/skf-drop-skill/SKILL.md +2 -2
  103. package/src/skf-drop-skill/references/execute.md +20 -9
  104. package/src/skf-drop-skill/references/report.md +2 -0
  105. package/src/skf-drop-skill/references/select.md +7 -2
  106. package/src/skf-forger/SKILL.md +15 -4
  107. package/src/skf-refine-architecture/SKILL.md +2 -1
  108. package/src/skf-refine-architecture/references/compile.md +1 -1
  109. package/src/skf-refine-architecture/references/report.md +1 -1
  110. package/src/skf-rename-skill/SKILL.md +2 -2
  111. package/src/skf-rename-skill/references/execute.md +5 -4
  112. package/src/skf-rename-skill/references/rebuild-context.md +2 -2
  113. package/src/skf-rename-skill/references/select.md +3 -3
  114. package/src/skf-setup/SKILL.md +1 -1
  115. package/src/skf-setup/references/auto-index.md +3 -1
  116. package/src/skf-setup/references/detect-and-tier.md +4 -0
  117. package/src/skf-setup/references/report.md +4 -1
  118. package/src/skf-setup/references/tier-rules.md +1 -1
  119. package/src/skf-test-skill/SKILL.md +8 -7
  120. package/src/skf-test-skill/customize.toml +2 -1
  121. package/src/skf-test-skill/references/coverage-check.md +10 -0
  122. package/src/skf-test-skill/references/external-validators.md +1 -1
  123. package/src/skf-test-skill/references/init.md +16 -1
  124. package/src/skf-test-skill/references/report.md +5 -1
  125. package/src/skf-test-skill/references/score.md +95 -6
  126. package/src/skf-test-skill/references/source-access-protocol.md +13 -3
  127. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  128. package/src/skf-test-skill/scripts/compute-score.py +4 -3
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/customize.toml +0 -9
  131. package/src/skf-update-skill/references/detect-changes.md +33 -3
  132. package/src/skf-update-skill/references/health-check.md +2 -2
  133. package/src/skf-update-skill/references/init.md +1 -0
  134. package/src/skf-update-skill/references/re-extract.md +15 -1
  135. package/src/skf-verify-stack/references/report.md +1 -1
  136. package/tools/cli/lib/ui.js +3 -2
@@ -0,0 +1,1114 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Shape Detect — classify repos into known skill shapes from manifest files.
6
+
7
+ Single source of truth for shape-level classification consumed by
8
+ skf-analyze-source (AN auto-scope), skf-brief-skill (BS auto-brief),
9
+ and skf-test-skill (TS threshold selection). Moving shape heuristics
10
+ into a shared script eliminates duplicate classification logic across
11
+ three pipelines.
12
+
13
+ The five-shape heuristic ladder (apply in order, first match wins):
14
+
15
+ 1. language-reference — parser/grammar/language-toolchain project
16
+ Signals: parser-related deps (pest, antlr4, tree-sitter, lark ...)
17
+ 2. stack-compose — multi-ecosystem composite project
18
+ Signals: manifests from 2+ distinct ecosystems
19
+ 3. reference-app — application, CLI, or demo project
20
+ Signals: npm bin field, Rust [[bin]], framework deps
21
+ 4. library-API — library exposing a programmatic API
22
+ Signals: main/module/exports fields, [lib] target, export count
23
+ 5. unknown — no heuristic matched
24
+
25
+ CLI:
26
+ uv run src/shared/scripts/skf-shape-detect.py \\
27
+ --repo-url <url> --manifests <path1,path2,...>
28
+
29
+ Input:
30
+ --repo-url repository URL (required; context only, no cloning)
31
+ --manifests comma-separated local file paths to manifest files (may be
32
+ empty when a tree-level signal is supplied instead)
33
+ --grammar-files comma-separated repo-relative grammar files (*.y, *.g4,
34
+ *.pest, Grammar/python.gram, ...); a whole-language signal
35
+ --tree-paths comma-separated repo-relative directory/structural signals
36
+ harvested from the clone (compiler/ dir, lexer/parser/ast)
37
+
38
+ Output (JSON on stdout):
39
+ shape library-API | reference-app | language-reference
40
+ | stack-compose | unknown
41
+ signals array of human-readable evidence strings
42
+ confidence float 0.0-1.0
43
+ export_count integer (total public-facing exports)
44
+ package_count integer (distinct packages detected)
45
+
46
+ Exit codes:
47
+ 0 shape classified (not unknown)
48
+ 1 unknown shape (no heuristic matched)
49
+ 2 error (invalid args, missing/unreadable files, parse failure)
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import json
56
+ import re
57
+ import sys
58
+ from pathlib import Path
59
+ from typing import Any
60
+
61
+ try:
62
+ import tomllib
63
+ except ImportError:
64
+ tomllib = None # type: ignore[assignment]
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Parser/grammar deps that signal language-reference shape
68
+ # ---------------------------------------------------------------------------
69
+
70
+ _PARSER_DEPS_NPM = frozenset({
71
+ "antlr4", "antlr4-runtime", "tree-sitter", "nearley",
72
+ "chevrotain", "pegjs", "peggy", "ohm-js", "jison",
73
+ "moo", "lezer", "@lezer/generator",
74
+ })
75
+ _PARSER_DEPS_PYTHON = frozenset({
76
+ "antlr4-tools", "antlr4-runtime", "lark", "lark-parser",
77
+ "ply", "tree-sitter", "textx", "parso", "pyparsing",
78
+ "sly", "tatsu",
79
+ })
80
+ _PARSER_DEPS_RUST = frozenset({
81
+ "pest", "pest_derive", "lalrpop", "lalrpop-util",
82
+ "tree-sitter", "nom", "chumsky", "winnow", "logos",
83
+ })
84
+
85
+ _ALL_PARSER_DEPS = _PARSER_DEPS_NPM | _PARSER_DEPS_PYTHON | _PARSER_DEPS_RUST
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Tree-level whole-language signals (issue #427)
89
+ #
90
+ # A hand-written compiler (rustc, TypeScript, Go) declares no parser-generator
91
+ # dependency, and a language's own repo may carry no supported manifest at all
92
+ # (CPython, Ruby). These sets drive the grammar-file and compiler-directory
93
+ # rungs that classify such repos from tree evidence rather than manifests.
94
+ # ---------------------------------------------------------------------------
95
+
96
+ # Declared grammars — the strongest, most intentional whole-language signal.
97
+ # Matched on extension or whole basename, NEVER on substring.
98
+ _GRAMMAR_EXTS = frozenset({
99
+ ".g4", ".pest", ".lalrpop", ".y", ".gram", ".lark", ".ebnf", ".peg",
100
+ ".ungram",
101
+ })
102
+ _GRAMMAR_BASENAMES = frozenset({"grammar.js", "grammar.json", "python.gram"})
103
+
104
+ # Concrete parsers a repo CONSUMES. If a repo's own runtime deps contain one of
105
+ # these it delegates parsing — a formatter/linter/bundler, never a
106
+ # whole-language reference (prettier→@babel/parser, eslint→espree).
107
+ _CONSUMED_PARSERS = frozenset({
108
+ "espree", "acorn", "@babel/parser", "babel-parser", "flow-parser",
109
+ "swc_ecma_parser", "deno_ast", "graphql", "remark-parse", "yaml",
110
+ "esquery", "estree", "@types/estree", "@webassemblyjs/ast", "smol-toml",
111
+ })
112
+
113
+ # Tools that own a parser-ish module but consume an external parser and are NOT
114
+ # whole-language references — bundlers, formatters, linters, markup/CSS libs.
115
+ _DELEGATING_TOOL_NAMES = frozenset({
116
+ "prettier", "eslint", "stylelint", "biome", "rome",
117
+ "webpack", "rollup", "esbuild", "vite", "parcel", "terser",
118
+ "marked", "remark", "remark-parse", "markdown-it", "micromark", "commonmark",
119
+ "postcss", "css-tree", "less", "sass", "node-sass",
120
+ })
121
+
122
+ # Markup / DSL / query languages — a real lexer+parser+AST for a
123
+ # non-general-purpose language (CSS, markdown, GraphQL, JSON). Their identity is
124
+ # a format parser, not a programming-language toolchain.
125
+ _MARKUP_DSL_NAMES = frozenset({
126
+ "css", "less", "scss", "sass", "html", "markdown", "graphql",
127
+ "graphql-schema", "json", "yaml", "toml", "xml",
128
+ })
129
+
130
+ # Dedicated compiler directories — the primary gate for the tree-triad rung
131
+ # (Rung B). Matched on a real DIRECTORY by exact path-tail, never a file and
132
+ # never a bare src/lib/language/parser dir. 'Parser' is case-sensitive
133
+ # (CPython's Parser/) so it does not match a lib/parser/ dir.
134
+ _COMPILER_DIRS = frozenset({
135
+ "compiler", "src/compiler", "cmd/compile", "internal/syntax",
136
+ })
137
+ _COMPILER_DIRS_CASE = frozenset({"Parser"})
138
+
139
+ # Triad member name stems. A hand-written compiler spreads a lexer, a parser,
140
+ # and an AST across these conventional file/dir names.
141
+ _LEXER_STEMS = frozenset({"scanner", "lexer", "tokenizer"})
142
+ _PARSER_STEMS = frozenset({"parser", "parse"})
143
+ _AST_STEMS = frozenset({"ast"})
144
+
145
+ # Corroborating whole-language member (gate W). A markdown/CSS parser ships a
146
+ # lexer+parser+AST but no code generator, VM, or type checker — so requiring one
147
+ # of these excludes a markup library that merely sits under a compiler/ dir.
148
+ _CODEGEN_STEMS = frozenset({"codegen", "compile", "ssagen"})
149
+ _VM_STEMS = frozenset({"interpreter", "vm", "eval", "ceval"})
150
+ _CHECK_STEMS = frozenset({"checker", "check", "binder", "typeck", "typecheck"})
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Framework deps that signal reference-app shape
154
+ # ---------------------------------------------------------------------------
155
+
156
+ _FRAMEWORK_DEPS_NPM = frozenset({
157
+ "next", "nuxt", "express", "fastify", "koa", "hono",
158
+ "@nestjs/core", "gatsby", "electron",
159
+ })
160
+ _FRAMEWORK_DEPS_PYTHON = frozenset({
161
+ "django", "flask", "fastapi", "uvicorn", "starlette",
162
+ "tornado", "aiohttp", "sanic", "streamlit", "gradio",
163
+ })
164
+ _FRAMEWORK_DEPS_RUST = frozenset({
165
+ "actix-web", "axum", "rocket", "warp", "tide",
166
+ "tauri", "dioxus", "leptos", "yew",
167
+ })
168
+
169
+ _ALL_FRAMEWORK_DEPS = _FRAMEWORK_DEPS_NPM | _FRAMEWORK_DEPS_PYTHON | _FRAMEWORK_DEPS_RUST
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Core vs non-core members
174
+ #
175
+ # In a monorepo, a `bin` field or framework dependency from an examples /
176
+ # devtools / tooling member must NOT flip the whole repo to `reference-app`.
177
+ # A manifest is "non-core" when it lives under a non-core directory or its
178
+ # package name marks it as a demo / example / dev-tool. Only core members
179
+ # drive the app-shape (`reference-app`) signals; library detection still uses
180
+ # every manifest.
181
+ # ---------------------------------------------------------------------------
182
+
183
+ _NON_CORE_PATH_SEGMENTS = frozenset({
184
+ "example", "examples", "demo", "demos", "sample", "samples",
185
+ "playground", "playgrounds", "e2e", "benchmark", "benchmarks", "bench",
186
+ "fixture", "fixtures", "website", "websites", "www",
187
+ "docs", "doc", "scripts", "tools", "tooling", "devtools", "dev-tools",
188
+ "test", "tests", "__tests__", "integration", "smoke",
189
+ })
190
+
191
+ _NON_CORE_NAME_FRAGMENTS = (
192
+ "devtools", "dev-tools", "example", "playground", "benchmark",
193
+ "fixture", "e2e", "codemod", "upgrade", "eslint-plugin", "eslint-config",
194
+ )
195
+
196
+
197
+ def is_core_manifest(rel_path: str, pkg_name: str) -> bool:
198
+ """Whether a manifest counts toward app-shape (`reference-app`) signals.
199
+
200
+ Non-core when any path segment is a known non-core directory, or the
201
+ package name contains a dev/demo/tooling fragment. Keeps a single CLI,
202
+ example, or devtools package in a library monorepo from masquerading the
203
+ whole repo as an application.
204
+ """
205
+ for seg in Path(rel_path).parts:
206
+ if seg.lower() in _NON_CORE_PATH_SEGMENTS:
207
+ return False
208
+ name = (pkg_name or "").lower()
209
+ return not any(frag in name for frag in _NON_CORE_NAME_FRAGMENTS)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Helpers
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def _die(message: str, code: str = "INTERNAL_ERROR") -> None:
217
+ json.dump({"error": message, "code": code}, sys.stderr, ensure_ascii=False)
218
+ sys.stderr.write("\n")
219
+ sys.exit(2)
220
+
221
+
222
+ def _clamp(value: float, lo: float, hi: float) -> float:
223
+ return max(lo, min(hi, value))
224
+
225
+
226
+ def _is_grammar_file(path: str) -> bool:
227
+ """Whether a repo-relative path is a declared grammar file.
228
+
229
+ Matched on whole basename (tree-sitter `grammar.js`, CPython
230
+ `python.gram`) or extension (`.y`, `.g4`, `.pest`, ...) — never substring,
231
+ so a file merely named `grammar_test_data.txt` does not match.
232
+ """
233
+ name = Path(path).name.lower()
234
+ if name in _GRAMMAR_BASENAMES:
235
+ return True
236
+ return Path(name).suffix in _GRAMMAR_EXTS
237
+
238
+
239
+ def _whole_language_tree(tree_paths: list[str]) -> tuple[str, str] | None:
240
+ """Detect a hand-written compiler from directory/structural signals.
241
+
242
+ Returns ``(compiler_dir, member_summary)`` when ``tree_paths`` satisfies all
243
+ three structural gates from issue #427, else ``None``:
244
+
245
+ (C) a DEDICATED compiler directory — a real directory (trailing ``/``)
246
+ whose path-tail is in ``_COMPILER_DIRS`` (case-insensitive) or
247
+ ``_COMPILER_DIRS_CASE`` (case-sensitive ``Parser``). A file named
248
+ ``Parser.js`` or ``compiler.dart`` never satisfies this; a bare
249
+ ``src/`` / ``lib/`` / ``src/language/`` directory never does either.
250
+ (D) a lexer+parser+AST triad, parser MANDATORY, at least 2 of 3 present.
251
+ (W) a corroborating codegen / VM / type-checker member, so a markdown or
252
+ CSS library (lexer+parser+AST only) does not qualify.
253
+
254
+ Gates G (delegating consumer) and L (markup identity) depend on manifest
255
+ data and are applied by the caller.
256
+ """
257
+ if not tree_paths:
258
+ return None
259
+
260
+ compiler_dirs: list[str] = []
261
+ stems: set[str] = set()
262
+ basenames: set[str] = set()
263
+ for tp in tree_paths:
264
+ norm = tp.rstrip("/")
265
+ if not norm:
266
+ continue
267
+ base = norm.rsplit("/", 1)[-1]
268
+ basenames.add(base)
269
+ stems.add(base.rsplit(".", 1)[0].lower() if "." in base else base.lower())
270
+ if tp.endswith("/"):
271
+ low = norm.lower()
272
+ if any(low == m or low.endswith("/" + m) for m in _COMPILER_DIRS) or \
273
+ any(norm == m or norm.endswith("/" + m) for m in _COMPILER_DIRS_CASE):
274
+ compiler_dirs.append(norm)
275
+
276
+ # (C)
277
+ if not compiler_dirs:
278
+ return None
279
+
280
+ # (D) — triad, parser mandatory, >= 2 of 3
281
+ lexer = bool(stems & _LEXER_STEMS) or "rustc_lexer" in basenames
282
+ parser = bool(stems & _PARSER_STEMS) or "rustc_parse" in basenames
283
+ binder, checker = "binder" in stems, "checker" in stems
284
+ ast = (bool(stems & _AST_STEMS) or "rustc_ast" in basenames
285
+ or (binder and checker))
286
+ if not parser or (lexer + parser + ast) < 2:
287
+ return None
288
+
289
+ # (W) — corroborating compiler-grade member
290
+ w = bool(stems & (_CODEGEN_STEMS | _VM_STEMS | _CHECK_STEMS)) or \
291
+ any(b.startswith("rustc_codegen") for b in basenames)
292
+ if not w:
293
+ return None
294
+
295
+ members = ",".join(
296
+ m for m, present in (("lexer", lexer), ("parser", parser), ("ast", ast))
297
+ if present
298
+ )
299
+ return compiler_dirs[0], members
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Minimal TOML parser (Python < 3.11 fallback)
304
+ # ---------------------------------------------------------------------------
305
+
306
+ def _split_preserving_nesting(s: str, sep: str) -> list[str]:
307
+ parts: list[str] = []
308
+ buf: list[str] = []
309
+ depth = 0
310
+ in_str = ""
311
+ for ch in s:
312
+ if in_str:
313
+ buf.append(ch)
314
+ if ch == in_str:
315
+ in_str = ""
316
+ continue
317
+ if ch in ('"', "'"):
318
+ in_str = ch
319
+ buf.append(ch)
320
+ continue
321
+ if ch in ("[", "{"):
322
+ depth += 1
323
+ buf.append(ch)
324
+ continue
325
+ if ch in ("]", "}"):
326
+ depth -= 1
327
+ buf.append(ch)
328
+ continue
329
+ if ch == sep and depth == 0:
330
+ parts.append("".join(buf))
331
+ buf = []
332
+ continue
333
+ buf.append(ch)
334
+ if buf:
335
+ parts.append("".join(buf))
336
+ return parts
337
+
338
+
339
+ def _decode_toml_value(raw: str) -> Any:
340
+ s = raw.strip()
341
+ if not s:
342
+ return ""
343
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
344
+ return s[1:-1]
345
+ if len(s) >= 2 and s[0] == "'" and s[-1] == "'":
346
+ return s[1:-1]
347
+ if s == "true":
348
+ return True
349
+ if s == "false":
350
+ return False
351
+ if s.startswith("["):
352
+ idx = s.rfind("]")
353
+ if idx < 0:
354
+ return []
355
+ inner = s[1:idx].strip()
356
+ if not inner:
357
+ return []
358
+ items = []
359
+ for item in _split_preserving_nesting(inner, ","):
360
+ item = item.strip()
361
+ if item and item[0] != "#":
362
+ items.append(_decode_toml_value(item))
363
+ return items
364
+ if s.startswith("{"):
365
+ idx = s.rfind("}")
366
+ if idx < 0:
367
+ return {}
368
+ inner = s[1:idx].strip()
369
+ result: dict[str, Any] = {}
370
+ for pair in _split_preserving_nesting(inner, ","):
371
+ pair = pair.strip()
372
+ if "=" in pair:
373
+ k, _, v = pair.partition("=")
374
+ result[k.strip()] = _decode_toml_value(v.strip())
375
+ return result
376
+ try:
377
+ return int(s) if "." not in s else float(s)
378
+ except ValueError:
379
+ return s
380
+
381
+
382
+ def _loads_toml_fallback(content: str) -> dict[str, Any]:
383
+ """Parse the TOML subset found in pyproject.toml / Cargo.toml."""
384
+ root: dict[str, Any] = {}
385
+ current = root
386
+ lines = content.split("\n")
387
+ i = 0
388
+ while i < len(lines):
389
+ raw = lines[i]
390
+ i += 1
391
+ stripped = raw.strip()
392
+ if not stripped or stripped[0] == "#":
393
+ continue
394
+
395
+ # [[array.of.tables]]
396
+ if stripped.startswith("[["):
397
+ end = stripped.find("]]")
398
+ if end < 0:
399
+ continue
400
+ path = [p.strip() for p in stripped[2:end].split(".")]
401
+ target = root
402
+ for key in path[:-1]:
403
+ target = target.setdefault(key, {})
404
+ arr = target.setdefault(path[-1], [])
405
+ if not isinstance(arr, list):
406
+ arr = [arr]
407
+ target[path[-1]] = arr
408
+ entry: dict[str, Any] = {}
409
+ arr.append(entry)
410
+ current = entry
411
+ continue
412
+
413
+ # [table]
414
+ if stripped.startswith("[") and not stripped.startswith("[["):
415
+ end = stripped.find("]")
416
+ if end < 0:
417
+ continue
418
+ path = [p.strip() for p in stripped[1:end].split(".")]
419
+ current = root
420
+ for key in path:
421
+ nxt = current.setdefault(key, {})
422
+ if not isinstance(nxt, dict):
423
+ nxt = {}
424
+ current[key] = nxt
425
+ current = nxt
426
+ continue
427
+
428
+ # key = value
429
+ eq = stripped.find("=")
430
+ if eq < 0:
431
+ continue
432
+ key = stripped[:eq].strip().strip('"')
433
+ val_str = stripped[eq + 1:].strip()
434
+
435
+ # Remove trailing comment outside strings
436
+ if val_str and val_str[0] not in ('"', "'", "[", "{"):
437
+ ci = val_str.find(" #")
438
+ if ci >= 0:
439
+ val_str = val_str[:ci].strip()
440
+
441
+ # Multi-line array
442
+ if val_str.startswith("[") and "]" not in val_str:
443
+ while i < len(lines):
444
+ val_str += " " + lines[i].strip()
445
+ i += 1
446
+ if "]" in val_str:
447
+ break
448
+
449
+ current[key] = _decode_toml_value(val_str)
450
+ return root
451
+
452
+
453
+ def _parse_toml(content: str) -> dict[str, Any]:
454
+ if tomllib is not None:
455
+ return tomllib.loads(content)
456
+ return _loads_toml_fallback(content)
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # Manifest parsers — each returns a normalised dict
461
+ # ---------------------------------------------------------------------------
462
+
463
+ def _parse_package_json(path: Path) -> dict[str, Any]:
464
+ try:
465
+ content = path.read_text(encoding="utf-8")
466
+ data = json.loads(content)
467
+ except (OSError, json.JSONDecodeError) as exc:
468
+ _die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
469
+ return {} # unreachable
470
+
471
+ if not isinstance(data, dict):
472
+ _die(f"Expected JSON object in {path.as_posix()}, got {type(data).__name__}", "MANIFEST_PARSE_ERROR")
473
+ return {} # unreachable
474
+
475
+ deps: set[str] = set()
476
+ runtime_deps: set[str] = set()
477
+ for key in ("dependencies", "devDependencies", "peerDependencies"):
478
+ section = data.get(key)
479
+ if isinstance(section, dict):
480
+ deps.update(section)
481
+ # Only hard `dependencies` signal app-ness: a framework in
482
+ # devDependencies means "tested against", in peerDependencies means
483
+ # "this is an adapter for it" — both are library behaviour.
484
+ if key == "dependencies":
485
+ runtime_deps.update(section)
486
+
487
+ exports_field = data.get("exports")
488
+ export_count = 0
489
+ if isinstance(exports_field, dict):
490
+ export_count = len(exports_field)
491
+ elif isinstance(exports_field, str):
492
+ export_count = 1
493
+ elif data.get("main") or data.get("module"):
494
+ export_count = 1
495
+
496
+ return {
497
+ "ecosystem": "npm",
498
+ "name": data.get("name", ""),
499
+ "deps": deps,
500
+ "runtime_deps": runtime_deps,
501
+ "has_bin": bool(data.get("bin")),
502
+ "has_library_structure": bool(
503
+ data.get("main") or data.get("module") or exports_field
504
+ ),
505
+ "export_count": export_count,
506
+ }
507
+
508
+
509
+ def _dep_name_from_pep508(spec: str) -> str:
510
+ """Extract package name from a PEP 508 dependency string."""
511
+ for ch in (">", "<", "=", "!", "[", ";", " "):
512
+ spec = spec.split(ch, 1)[0]
513
+ return spec.strip().lower()
514
+
515
+
516
+ def _parse_pyproject_toml(path: Path) -> dict[str, Any]:
517
+ try:
518
+ content = path.read_text(encoding="utf-8")
519
+ data = _parse_toml(content)
520
+ except OSError as exc:
521
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
522
+ return {}
523
+ except Exception as exc:
524
+ _die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
525
+ return {}
526
+
527
+ project = data.get("project", {})
528
+ if not isinstance(project, dict):
529
+ project = {}
530
+
531
+ deps: set[str] = set()
532
+ for raw in project.get("dependencies", []):
533
+ if isinstance(raw, str):
534
+ name = _dep_name_from_pep508(raw)
535
+ if name:
536
+ deps.add(name)
537
+ poetry_deps = (
538
+ data.get("tool", {}).get("poetry", {}).get("dependencies", {})
539
+ )
540
+ if isinstance(poetry_deps, dict):
541
+ deps.update(k.lower() for k in poetry_deps if k.lower() != "python")
542
+
543
+ scripts = project.get("scripts", {})
544
+ gui_scripts = project.get("gui-scripts", {})
545
+ if not isinstance(scripts, dict):
546
+ scripts = {}
547
+ if not isinstance(gui_scripts, dict):
548
+ gui_scripts = {}
549
+ export_count = len(scripts) + len(gui_scripts)
550
+ if export_count == 0 and project.get("name"):
551
+ export_count = 1
552
+
553
+ return {
554
+ "ecosystem": "python",
555
+ "name": project.get("name", ""),
556
+ "deps": deps,
557
+ "runtime_deps": set(deps),
558
+ "has_bin": False,
559
+ "has_library_structure": bool(project.get("name")),
560
+ "export_count": export_count,
561
+ }
562
+
563
+
564
+ def _parse_cargo_toml(path: Path) -> dict[str, Any]:
565
+ try:
566
+ content = path.read_text(encoding="utf-8")
567
+ data = _parse_toml(content)
568
+ except OSError as exc:
569
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
570
+ return {}
571
+ except Exception as exc:
572
+ _die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
573
+ return {}
574
+
575
+ pkg = data.get("package", {})
576
+ if not isinstance(pkg, dict):
577
+ pkg = {}
578
+
579
+ deps: set[str] = set()
580
+ runtime_deps: set[str] = set()
581
+ for dep_key in ("dependencies", "dev-dependencies", "build-dependencies"):
582
+ section = data.get(dep_key, {})
583
+ if isinstance(section, dict):
584
+ deps.update(k.lower() for k in section)
585
+ if dep_key == "dependencies":
586
+ runtime_deps.update(k.lower() for k in section)
587
+
588
+ has_lib = "lib" in data and isinstance(data["lib"], dict)
589
+ bin_targets = data.get("bin", [])
590
+ if not isinstance(bin_targets, list):
591
+ bin_targets = []
592
+ has_bin = len(bin_targets) > 0
593
+
594
+ export_count = (1 if has_lib else 0) + len(bin_targets)
595
+ if export_count == 0 and pkg.get("name"):
596
+ export_count = 1
597
+
598
+ workspace_members = data.get("workspace", {}).get("members", [])
599
+ if not isinstance(workspace_members, list):
600
+ workspace_members = []
601
+
602
+ return {
603
+ "ecosystem": "rust",
604
+ "name": pkg.get("name", ""),
605
+ "deps": deps,
606
+ "runtime_deps": runtime_deps,
607
+ "has_bin": has_bin,
608
+ "has_library_structure": has_lib or bool(pkg.get("name")),
609
+ "export_count": export_count,
610
+ }
611
+
612
+
613
+ def _parse_go_mod(path: Path) -> dict[str, Any]:
614
+ """Parse a go.mod: the module path (name) and required modules (deps).
615
+
616
+ go.mod is line-oriented — `module <path>`, single-line `require <mod> <ver>`,
617
+ and a `require ( ... )` block. The scanner already recognises go.mod; this
618
+ mirror lets shape-detect classify Go repos (most resolve to library-API;
619
+ the Go toolchain itself is caught by the tree-triad rung).
620
+ """
621
+ try:
622
+ content = path.read_text(encoding="utf-8")
623
+ except OSError as exc:
624
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
625
+ return {} # unreachable
626
+
627
+ module = ""
628
+ deps: set[str] = set()
629
+ in_require_block = False
630
+ for line in content.splitlines():
631
+ stripped = line.strip()
632
+ if not stripped or stripped.startswith("//"):
633
+ continue
634
+ if in_require_block:
635
+ if stripped.startswith(")"):
636
+ in_require_block = False
637
+ continue
638
+ deps.add(stripped.split()[0].lower())
639
+ continue
640
+ if stripped.startswith("module "):
641
+ module = stripped[len("module "):].strip()
642
+ elif stripped.startswith("require ("):
643
+ in_require_block = True
644
+ elif stripped.startswith("require "):
645
+ parts = stripped[len("require "):].split()
646
+ if parts:
647
+ deps.add(parts[0].lower())
648
+
649
+ return {
650
+ "ecosystem": "go",
651
+ "name": module,
652
+ "deps": deps,
653
+ "runtime_deps": set(deps),
654
+ # A go.mod under a cmd/ path marks a command (binary) member.
655
+ "has_bin": "cmd" in Path(path).parts,
656
+ "has_library_structure": bool(module),
657
+ "export_count": 0,
658
+ }
659
+
660
+
661
+ # Maven build plugins that turn a jar into an executable/deployable app — used
662
+ # to mark a pom.xml as a binary (reference-app) rather than a library.
663
+ _MAVEN_APP_PLUGINS = (
664
+ "spring-boot-maven-plugin",
665
+ "maven-shade-plugin",
666
+ "exec-maven-plugin",
667
+ "maven-assembly-plugin",
668
+ )
669
+
670
+
671
+ def _parse_pom_xml(path: Path) -> dict[str, Any]:
672
+ """Parse a Maven pom.xml: artifactId (name) and non-test dependency coords.
673
+
674
+ The scanner already recognises pom.xml; this mirror lets shape-detect
675
+ classify Maven repos (most resolve to library-API via the artifactId; an
676
+ app/exec/assembly plugin or `war` packaging marks a deployable app).
677
+ """
678
+ try:
679
+ content = path.read_text(encoding="utf-8")
680
+ except OSError as exc:
681
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
682
+ return {} # unreachable
683
+
684
+ # Project artifactId: strip <parent>/<dependencies>/<build> first so the
685
+ # project's own artifactId is matched, not a parent's or a dependency's.
686
+ trimmed = content
687
+ for tag in ("parent", "dependencies", "dependencyManagement", "build"):
688
+ trimmed = re.sub(rf"<{tag}>.*?</{tag}>", "", trimmed, flags=re.DOTALL | re.IGNORECASE)
689
+ aid = re.search(r"<artifactId>\s*(.*?)\s*</artifactId>", trimmed, re.DOTALL)
690
+ name = aid.group(1).strip() if aid else ""
691
+
692
+ deps: set[str] = set()
693
+ for block in re.finditer(r"<dependency>(.*?)</dependency>", content, re.DOTALL):
694
+ body = block.group(1)
695
+ scope_m = re.search(r"<scope>\s*(.*?)\s*</scope>", body, re.DOTALL)
696
+ if scope_m and scope_m.group(1).strip().lower() in {"test", "provided", "system"}:
697
+ continue
698
+ d_aid = re.search(r"<artifactId>\s*(.*?)\s*</artifactId>", body, re.DOTALL)
699
+ if d_aid:
700
+ deps.add(d_aid.group(1).strip().lower())
701
+
702
+ pkg_m = re.search(r"<packaging>\s*(.*?)\s*</packaging>", content, re.DOTALL)
703
+ packaging = pkg_m.group(1).strip().lower() if pkg_m else "jar"
704
+ lc = content.lower()
705
+ has_bin = packaging == "war" or any(p in lc for p in _MAVEN_APP_PLUGINS)
706
+
707
+ return {
708
+ "ecosystem": "maven",
709
+ "name": name,
710
+ "deps": deps,
711
+ "runtime_deps": set(deps),
712
+ "has_bin": has_bin,
713
+ "has_library_structure": bool(name),
714
+ "export_count": 0,
715
+ }
716
+
717
+
718
+ def _parse_gradle(path: Path) -> dict[str, Any]:
719
+ """Parse a Gradle build script (Groovy or Kotlin DSL): dependency coords.
720
+
721
+ Gradle scripts rarely declare their own coordinate name, so — like go.mod's
722
+ bool(module) precedent — a present build script marks a buildable module
723
+ (has_library_structure=True). An `application` plugin (or an Android/Spring
724
+ app plugin) marks a deployable app (reference-app).
725
+ """
726
+ try:
727
+ content = path.read_text(encoding="utf-8")
728
+ except OSError as exc:
729
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
730
+ return {} # unreachable
731
+
732
+ deps: set[str] = set()
733
+ for m in re.finditer(
734
+ r"(?:implementation|api|compile|runtimeOnly)\s*\(?\s*['\"]([^'\"]+)['\"]",
735
+ content,
736
+ ):
737
+ parts = m.group(1).split(":")
738
+ if len(parts) >= 2:
739
+ deps.add((parts[0] + ":" + parts[1]).lower())
740
+
741
+ has_bin = bool(
742
+ re.search(
743
+ r"id\s*\(?\s*['\"]application['\"]"
744
+ r"|apply\s+plugin:\s*['\"]application['\"]"
745
+ r"|^\s*application\s*\{"
746
+ r"|com\.android\.application"
747
+ r"|org\.springframework\.boot",
748
+ content,
749
+ re.MULTILINE | re.IGNORECASE,
750
+ )
751
+ )
752
+
753
+ return {
754
+ "ecosystem": "gradle",
755
+ "name": "",
756
+ "deps": deps,
757
+ "runtime_deps": set(deps),
758
+ "has_bin": has_bin,
759
+ "has_library_structure": True,
760
+ "export_count": 0,
761
+ }
762
+
763
+
764
+ def _parse_package_swift(path: Path) -> dict[str, Any]:
765
+ """Parse a SwiftPM Package.swift: package name and dependency package names.
766
+
767
+ A `.library`/`.target` declaration (or a name) marks a library; an
768
+ `.executableTarget`/`.executable` product marks a CLI (reference-app).
769
+ """
770
+ try:
771
+ content = path.read_text(encoding="utf-8")
772
+ except OSError as exc:
773
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
774
+ return {} # unreachable
775
+
776
+ name_m = re.search(r"\bPackage\s*\(\s*name:\s*['\"]([^'\"]+)['\"]", content, re.DOTALL)
777
+ name = name_m.group(1).strip() if name_m else ""
778
+
779
+ deps: set[str] = set()
780
+ for m in re.finditer(r"\.package\s*\(\s*url:\s*['\"]([^'\"]+)['\"]", content):
781
+ seg = m.group(1).rstrip("/").rsplit("/", 1)[-1]
782
+ if seg.endswith(".git"):
783
+ seg = seg[: -len(".git")]
784
+ if seg:
785
+ deps.add(seg.lower())
786
+
787
+ has_exec = bool(re.search(r"\.executableTarget\s*\(|\.executable\s*\(", content))
788
+ has_lib = bool(re.search(r"\.library\s*\(|\.target\s*\(", content)) or bool(name)
789
+
790
+ return {
791
+ "ecosystem": "swift",
792
+ "name": name,
793
+ "deps": deps,
794
+ "runtime_deps": set(deps),
795
+ "has_bin": has_exec,
796
+ "has_library_structure": has_lib,
797
+ "export_count": 0,
798
+ }
799
+
800
+
801
+ _PARSERS = {
802
+ "package.json": _parse_package_json,
803
+ "pyproject.toml": _parse_pyproject_toml,
804
+ "Cargo.toml": _parse_cargo_toml,
805
+ "go.mod": _parse_go_mod,
806
+ "pom.xml": _parse_pom_xml,
807
+ "build.gradle": _parse_gradle,
808
+ "build.gradle.kts": _parse_gradle,
809
+ "Package.swift": _parse_package_swift,
810
+ }
811
+
812
+
813
+ def _parse_manifest(path: Path) -> dict[str, Any]:
814
+ parser = _PARSERS.get(path.name)
815
+ if parser is None:
816
+ _die(f"Unsupported manifest type: {path.name}", "UNSUPPORTED_MANIFEST")
817
+ return parser(path)
818
+
819
+
820
+ # ---------------------------------------------------------------------------
821
+ # Core classification
822
+ # ---------------------------------------------------------------------------
823
+
824
+ def detect(
825
+ repo_url: str,
826
+ manifest_paths: list[str],
827
+ grammar_files: list[str] | None = None,
828
+ tree_paths: list[str] | None = None,
829
+ ) -> dict[str, Any]:
830
+ """Classify a repo into a skill shape from its manifest files.
831
+
832
+ `grammar_files` (grammar files like Grammar/python.gram, *.y, *.g4) and
833
+ `tree_paths` (repo-relative directory/structural signals) are optional
834
+ tree-level signals harvested from the clone; they let whole-language repos
835
+ that carry no parser-generator dependency — and even manifest-less ones —
836
+ be classified. When all three inputs are empty there is nothing to
837
+ classify and we error, exactly as before.
838
+ """
839
+ grammar_files = grammar_files or []
840
+ tree_paths = tree_paths or []
841
+ if not manifest_paths and not grammar_files and not tree_paths:
842
+ _die("--manifests requires at least one path", "MISSING_MANIFESTS")
843
+
844
+ parsed: list[dict[str, Any]] = []
845
+ for mp in manifest_paths:
846
+ p = Path(mp)
847
+ if not p.is_file():
848
+ _die(f"Manifest not found: {p.as_posix()}", "MANIFEST_NOT_FOUND")
849
+ m = _parse_manifest(p)
850
+ m["_path"] = mp
851
+ m["_core"] = is_core_manifest(mp, m.get("name", ""))
852
+ parsed.append(m)
853
+
854
+ all_deps: set[str] = set()
855
+ all_runtime_deps: set[str] = set()
856
+ core_runtime_deps: set[str] = set()
857
+ ecosystems: set[str] = set()
858
+ total_exports = 0
859
+ signals: list[str] = []
860
+ has_bin = False # any manifest
861
+ core_has_bin = False # app-eligible manifests only
862
+ has_library_structure = False
863
+
864
+ package_count = len(parsed)
865
+
866
+ # In a monorepo, a *coordinator* root (the unique shallowest manifest that has
867
+ # no library structure of its own) holds build/script deps, not the product —
868
+ # exclude it from app-shape signals. A root that is itself the published
869
+ # library (has main/exports) stays in.
870
+ depths = [len(Path(m["_path"]).parts) for m in parsed]
871
+ min_depth = min(depths) if depths else -1
872
+ root_coord_idx = -1
873
+ if package_count > 1 and depths.count(min_depth) == 1:
874
+ cand = depths.index(min_depth)
875
+ if not parsed[cand].get("has_library_structure"):
876
+ root_coord_idx = cand
877
+ if root_coord_idx >= 0:
878
+ signals.append("monorepo_root_coordinator_excluded")
879
+
880
+ for idx, m in enumerate(parsed):
881
+ eco = m["ecosystem"]
882
+ ecosystems.add(eco)
883
+ signals.append(f"has_{path_to_manifest_name(eco)}")
884
+ all_deps.update(m.get("deps", set()))
885
+ all_runtime_deps.update(m.get("runtime_deps", set()))
886
+ total_exports += m.get("export_count", 0)
887
+ if m.get("has_bin"):
888
+ has_bin = True
889
+ if m.get("has_library_structure"):
890
+ has_library_structure = True
891
+ # App-shape signals: core members, excluding the monorepo root coordinator.
892
+ if m.get("_core") and idx != root_coord_idx:
893
+ core_runtime_deps.update(m.get("runtime_deps", set()))
894
+ if m.get("has_bin"):
895
+ core_has_bin = True
896
+
897
+ # `reference-app` signals come from core members' RUNTIME deps only: a
898
+ # framework in devDependencies (testing/building against it), in an
899
+ # examples/devtools member, or in the monorepo coordinator root does not make
900
+ # the repo an application.
901
+ app_has_bin = core_has_bin
902
+ app_runtime = core_runtime_deps
903
+ framework_deps = sorted(d for d in app_runtime if d.lower() in _ALL_FRAMEWORK_DEPS)
904
+ has_framework = len(framework_deps) > 0
905
+
906
+ # Excluded app signals are surfaced separately so the classification stays
907
+ # explainable: non-core runtime frameworks, and dev-only frameworks.
908
+ noncore_framework = sorted(
909
+ d for d in (all_runtime_deps - app_runtime) if d.lower() in _ALL_FRAMEWORK_DEPS
910
+ )
911
+ dev_framework = sorted(
912
+ d for d in (all_deps - all_runtime_deps) if d.lower() in _ALL_FRAMEWORK_DEPS
913
+ )
914
+ noncore_has_bin = has_bin and not app_has_bin
915
+
916
+ if total_exports > 50:
917
+ signals.append("exports_count_gt_50")
918
+ if app_has_bin:
919
+ signals.append("has_bin_field")
920
+ elif has_library_structure:
921
+ signals.append("no_bin_field")
922
+ if noncore_has_bin:
923
+ signals.append("has_bin_field_noncore")
924
+ if has_library_structure:
925
+ signals.append("has_library_structure")
926
+
927
+ # Collect parser/grammar signals — both directions of the relationship.
928
+ #
929
+ # PRODUCER (issue #427): a repo whose own published package name is itself a
930
+ # known parser/grammar tool IS language tooling — pest, lalrpop, lark, peggy
931
+ # name *themselves*. A language tool's repo does not depend on a parser
932
+ # generator; it is one, so the old dependency-only check never fired for it.
933
+ # This keys on own-name ∈ parser-gen-set ONLY — never on substring tokens
934
+ # like "parser"/"compiler"/"lang", which are false-positive farms (a CSS
935
+ # parser, compiler-builtins, an arg parser are ordinary libraries).
936
+ #
937
+ # CONSUMER: a project that depends on a parser generator (a DSL built on
938
+ # lalrpop) is also a language project. Exclude the repo's own producer name
939
+ # from the consumer list so a self-reference isn't double-counted as "uses".
940
+ own_names = {
941
+ (m.get("name") or "").strip().lower() for m in parsed if m.get("name")
942
+ }
943
+ parser_producers = sorted(n for n in own_names if n in _ALL_PARSER_DEPS)
944
+ parser_deps = sorted(
945
+ d for d in all_deps
946
+ if d.lower() in _ALL_PARSER_DEPS and d.lower() not in parser_producers
947
+ )
948
+
949
+ for d in parser_producers:
950
+ signals.append(f"parser_producer:{d}")
951
+ for d in parser_deps:
952
+ signals.append(f"parser_dep:{d}")
953
+
954
+ # Tree-level whole-language signals (issue #427). A grammar file is the
955
+ # strongest, most intentional signal; the compiler-directory triad (a later
956
+ # rung) catches hand-written compilers. Two guard gates keep formatters,
957
+ # linters, bundlers, and markup/DSL parsers out.
958
+ grammar_matches = sorted(
959
+ {Path(g).name for g in grammar_files if _is_grammar_file(g)}
960
+ )
961
+ for g in grammar_matches:
962
+ signals.append(f"grammar_file:{g}")
963
+
964
+ # Gate G — delegating-consumer exclusion. A repo that depends on a concrete
965
+ # parser (prettier→@babel/parser, eslint→espree) or whose own name is a
966
+ # known formatter/linter/bundler delegates parsing; never a whole-language
967
+ # reference, even if it ships a parser-ish module of its own.
968
+ runtime_deps_lc = {d.lower() for d in all_runtime_deps}
969
+ delegating_consumer = bool(runtime_deps_lc & _CONSUMED_PARSERS) or bool(
970
+ own_names & _DELEGATING_TOOL_NAMES
971
+ )
972
+ if delegating_consumer:
973
+ signals.append("delegating_consumer")
974
+ # Gate L — language identity. A markup/DSL/format parser (postcss, marked,
975
+ # graphql-js) has a real lexer+parser+AST but is not a general-purpose
976
+ # programming-language reference.
977
+ markup_identity = bool(
978
+ own_names & (_MARKUP_DSL_NAMES | _DELEGATING_TOOL_NAMES)
979
+ )
980
+
981
+ for d in framework_deps:
982
+ signals.append(f"framework_dep:{d}")
983
+ for d in noncore_framework:
984
+ signals.append(f"framework_dep_noncore:{d}")
985
+ for d in dev_framework:
986
+ signals.append(f"framework_dep_dev:{d}")
987
+ if len(ecosystems) > 1:
988
+ signals.append("multiple_ecosystems")
989
+ for eco in sorted(ecosystems):
990
+ signals.append(f"ecosystem:{eco}")
991
+
992
+ result_base = {
993
+ "export_count": total_exports,
994
+ "package_count": package_count,
995
+ }
996
+
997
+ # --- Heuristic ladder (first match wins) ---
998
+
999
+ # 1a-pre. language-reference — a hand-written compiler detected from tree
1000
+ # structure (issue #427): a dedicated compiler/ directory holding a
1001
+ # lexer+parser+AST triad plus a codegen/VM/type-checker member. This catches
1002
+ # rustc, TypeScript, and the Go toolchain, which declare no parser-generator
1003
+ # dependency and carry no grammar file. Ranked first so it outranks the
1004
+ # bin→reference-app rung (TypeScript ships `tsc`). Gates G and L still apply.
1005
+ tree_triad = _whole_language_tree(tree_paths)
1006
+ if tree_triad and not delegating_consumer and not markup_identity:
1007
+ compiler_dir, members = tree_triad
1008
+ signals.append(f"tree_triad:{compiler_dir}:{members}")
1009
+ return {"shape": "language-reference", "signals": signals,
1010
+ "confidence": 0.85, **result_base}
1011
+
1012
+ # 1a. language-reference — a declared grammar file (issue #427). A repo that
1013
+ # ships a grammar (Grammar/python.gram, parse.y, a *.g4) authors a language.
1014
+ # This is the strongest signal and ranks above the dependency-based rung so
1015
+ # a real grammar outranks an incidental parser dep from a sub-tool. Gate G
1016
+ # excludes delegating consumers; gate L excludes markup/DSL parsers.
1017
+ if grammar_matches and not delegating_consumer and not markup_identity:
1018
+ confidence = _clamp(0.85 + len(grammar_matches) * 0.02, 0.85, 0.90)
1019
+ return {"shape": "language-reference", "signals": signals,
1020
+ "confidence": round(confidence, 2), **result_base}
1021
+
1022
+ # 1b. language-reference — a parser/grammar producer (own name) or a project
1023
+ # built on a parser generator (consumer dep).
1024
+ if parser_producers or parser_deps:
1025
+ # A producer (named itself a grammar tool) is a stronger signal than a
1026
+ # consumer (merely depends on one).
1027
+ base = 0.80 if parser_producers else 0.75
1028
+ n_sig = len(parser_producers) + len(parser_deps)
1029
+ confidence = _clamp(base + n_sig * 0.05, base, 0.90)
1030
+ return {"shape": "language-reference", "signals": signals,
1031
+ "confidence": round(confidence, 2), **result_base}
1032
+
1033
+ # 2. stack-compose
1034
+ if len(ecosystems) > 1:
1035
+ confidence = _clamp(0.80 + (len(ecosystems) - 2) * 0.05, 0.80, 0.90)
1036
+ return {"shape": "stack-compose", "signals": signals,
1037
+ "confidence": round(confidence, 2), **result_base}
1038
+
1039
+ # 3. reference-app — an application/CLI built on a framework. In a monorepo
1040
+ # a lone `bin` (a tooling package among libraries) is not enough; require a
1041
+ # core runtime framework. A single-package repo with a bin is an app.
1042
+ app_trigger = has_framework if package_count > 1 else (app_has_bin or has_framework)
1043
+ if app_trigger:
1044
+ strength = (1 if app_has_bin else 0) + (1 if has_framework else 0)
1045
+ confidence = _clamp(0.80 + (strength - 1) * 0.05, 0.80, 0.90)
1046
+ return {"shape": "reference-app", "signals": signals,
1047
+ "confidence": round(confidence, 2), **result_base}
1048
+
1049
+ # 4. library-API
1050
+ if has_library_structure or total_exports > 0:
1051
+ base = 0.65
1052
+ if has_library_structure:
1053
+ base = 0.80
1054
+ if total_exports > 50:
1055
+ base = max(base, 0.90)
1056
+ elif total_exports > 10:
1057
+ base = max(base, 0.80)
1058
+ confidence = _clamp(base, 0.65, 0.95)
1059
+ return {"shape": "library-API", "signals": signals,
1060
+ "confidence": round(confidence, 2), **result_base}
1061
+
1062
+ # 5. unknown
1063
+ return {"shape": "unknown", "signals": signals,
1064
+ "confidence": 0.0, **result_base}
1065
+
1066
+
1067
+ def path_to_manifest_name(ecosystem: str) -> str:
1068
+ return {"npm": "package_json", "python": "pyproject_toml",
1069
+ "rust": "cargo_toml", "go": "go_mod",
1070
+ "maven": "pom_xml", "gradle": "build_gradle",
1071
+ "swift": "package_swift"}.get(ecosystem, ecosystem)
1072
+
1073
+
1074
+ # ---------------------------------------------------------------------------
1075
+ # CLI wiring
1076
+ # ---------------------------------------------------------------------------
1077
+
1078
+ def main(argv: list[str]) -> int:
1079
+ parser = argparse.ArgumentParser(
1080
+ description="Classify a repo into a known skill shape from its manifest files.",
1081
+ )
1082
+ parser.add_argument("--repo-url", required=True, help="Repository URL")
1083
+ parser.add_argument(
1084
+ "--manifests", required=True,
1085
+ help="Comma-separated local file paths to manifest files (may be empty "
1086
+ "when --grammar-files or --tree-paths carry the signal)",
1087
+ )
1088
+ parser.add_argument(
1089
+ "--grammar-files", default="",
1090
+ help="Comma-separated repo-relative grammar file paths (*.y, *.g4, "
1091
+ "*.pest, Grammar/python.gram, ...)",
1092
+ )
1093
+ parser.add_argument(
1094
+ "--tree-paths", default="",
1095
+ help="Comma-separated repo-relative directory (trailing /) and "
1096
+ "structural file signals harvested from the clone",
1097
+ )
1098
+ args = parser.parse_args(argv)
1099
+
1100
+ manifest_paths = [p.strip() for p in args.manifests.split(",") if p.strip()]
1101
+ grammar_files = [p.strip() for p in args.grammar_files.split(",") if p.strip()]
1102
+ tree_paths = [p.strip() for p in args.tree_paths.split(",") if p.strip()]
1103
+ if not manifest_paths and not grammar_files and not tree_paths:
1104
+ _die("--manifests requires at least one path", "MISSING_MANIFESTS")
1105
+
1106
+ result = detect(args.repo_url, manifest_paths, grammar_files, tree_paths)
1107
+ json.dump(result, sys.stdout, ensure_ascii=False)
1108
+ sys.stdout.write("\n")
1109
+
1110
+ return 1 if result["shape"] == "unknown" else 0
1111
+
1112
+
1113
+ if __name__ == "__main__":
1114
+ sys.exit(main(sys.argv[1:]))