bmad-module-skill-forge 1.9.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/module-help.csv +2 -1
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +8 -3
- package/src/shared/scripts/skf-detect-language.py +5 -3
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-shape-detect.py +576 -25
- package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
- package/src/shared/scripts/skf-validate-pins.py +2 -2
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/step-auto-scope.md +196 -44
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-audit-skill/references/init.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/references/analyze-target.md +7 -4
- package/src/skf-brief-skill/references/confirm-brief.md +0 -1
- package/src/skf-brief-skill/references/gather-intent.md +8 -2
- package/src/skf-brief-skill/references/scope-definition.md +2 -1
- package/src/skf-brief-skill/references/step-auto-brief.md +21 -4
- package/src/skf-brief-skill/references/step-auto-validate.md +2 -2
- package/src/skf-brief-skill/references/write-brief.md +2 -3
- package/src/skf-campaign/SKILL.md +190 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +73 -0
- package/src/skf-campaign/manifest.yaml +11 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +122 -0
- package/src/skf-campaign/references/step-02-strategy.md +97 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +87 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +114 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +235 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +1 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
- package/src/skf-create-skill/references/compile.md +4 -1
- package/src/skf-create-skill/references/extract.md +9 -1
- package/src/skf-create-skill/references/extraction-patterns.md +3 -1
- package/src/skf-create-skill/references/generate-artifacts.md +11 -2
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/report.md +17 -1
- package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
- package/src/skf-create-skill/references/step-doc-rot.md +4 -4
- package/src/skf-create-skill/references/step-doc-sources.md +11 -2
- package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
- package/src/skf-create-skill/references/validate.md +12 -3
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-drop-skill/references/execute.md +20 -9
- package/src/skf-drop-skill/references/report.md +2 -0
- package/src/skf-drop-skill/references/select.md +7 -2
- package/src/skf-forger/SKILL.md +12 -7
- package/src/skf-refine-architecture/SKILL.md +2 -1
- package/src/skf-refine-architecture/references/compile.md +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-rename-skill/references/execute.md +5 -4
- package/src/skf-rename-skill/references/rebuild-context.md +2 -2
- package/src/skf-rename-skill/references/select.md +3 -3
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-setup/references/auto-index.md +3 -1
- package/src/skf-setup/references/detect-and-tier.md +4 -0
- package/src/skf-setup/references/report.md +4 -1
- package/src/skf-setup/references/tier-rules.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +10 -0
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/source-access-protocol.md +13 -3
- package/src/skf-test-skill/scripts/compute-score.py +4 -3
- package/src/skf-update-skill/customize.toml +0 -9
- package/src/skf-update-skill/references/detect-changes.md +33 -3
- package/src/skf-update-skill/references/health-check.md +2 -2
- package/src/skf-update-skill/references/init.md +1 -0
- package/src/skf-update-skill/references/re-extract.md +15 -1
- package/src/skf-verify-stack/references/report.md +1 -1
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
|
@@ -23,12 +23,17 @@ The five-shape heuristic ladder (apply in order, first match wins):
|
|
|
23
23
|
5. unknown — no heuristic matched
|
|
24
24
|
|
|
25
25
|
CLI:
|
|
26
|
-
uv run
|
|
26
|
+
uv run src/shared/scripts/skf-shape-detect.py \\
|
|
27
27
|
--repo-url <url> --manifests <path1,path2,...>
|
|
28
28
|
|
|
29
29
|
Input:
|
|
30
|
-
--repo-url
|
|
31
|
-
--manifests
|
|
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)
|
|
32
37
|
|
|
33
38
|
Output (JSON on stdout):
|
|
34
39
|
shape library-API | reference-app | language-reference
|
|
@@ -48,6 +53,7 @@ from __future__ import annotations
|
|
|
48
53
|
|
|
49
54
|
import argparse
|
|
50
55
|
import json
|
|
56
|
+
import re
|
|
51
57
|
import sys
|
|
52
58
|
from pathlib import Path
|
|
53
59
|
from typing import Any
|
|
@@ -78,6 +84,71 @@ _PARSER_DEPS_RUST = frozenset({
|
|
|
78
84
|
|
|
79
85
|
_ALL_PARSER_DEPS = _PARSER_DEPS_NPM | _PARSER_DEPS_PYTHON | _PARSER_DEPS_RUST
|
|
80
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
|
+
|
|
81
152
|
# ---------------------------------------------------------------------------
|
|
82
153
|
# Framework deps that signal reference-app shape
|
|
83
154
|
# ---------------------------------------------------------------------------
|
|
@@ -98,6 +169,46 @@ _FRAMEWORK_DEPS_RUST = frozenset({
|
|
|
98
169
|
_ALL_FRAMEWORK_DEPS = _FRAMEWORK_DEPS_NPM | _FRAMEWORK_DEPS_PYTHON | _FRAMEWORK_DEPS_RUST
|
|
99
170
|
|
|
100
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
|
+
|
|
101
212
|
# ---------------------------------------------------------------------------
|
|
102
213
|
# Helpers
|
|
103
214
|
# ---------------------------------------------------------------------------
|
|
@@ -112,6 +223,82 @@ def _clamp(value: float, lo: float, hi: float) -> float:
|
|
|
112
223
|
return max(lo, min(hi, value))
|
|
113
224
|
|
|
114
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
|
+
|
|
115
302
|
# ---------------------------------------------------------------------------
|
|
116
303
|
# Minimal TOML parser (Python < 3.11 fallback)
|
|
117
304
|
# ---------------------------------------------------------------------------
|
|
@@ -286,10 +473,16 @@ def _parse_package_json(path: Path) -> dict[str, Any]:
|
|
|
286
473
|
return {} # unreachable
|
|
287
474
|
|
|
288
475
|
deps: set[str] = set()
|
|
476
|
+
runtime_deps: set[str] = set()
|
|
289
477
|
for key in ("dependencies", "devDependencies", "peerDependencies"):
|
|
290
478
|
section = data.get(key)
|
|
291
479
|
if isinstance(section, dict):
|
|
292
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)
|
|
293
486
|
|
|
294
487
|
exports_field = data.get("exports")
|
|
295
488
|
export_count = 0
|
|
@@ -304,6 +497,7 @@ def _parse_package_json(path: Path) -> dict[str, Any]:
|
|
|
304
497
|
"ecosystem": "npm",
|
|
305
498
|
"name": data.get("name", ""),
|
|
306
499
|
"deps": deps,
|
|
500
|
+
"runtime_deps": runtime_deps,
|
|
307
501
|
"has_bin": bool(data.get("bin")),
|
|
308
502
|
"has_library_structure": bool(
|
|
309
503
|
data.get("main") or data.get("module") or exports_field
|
|
@@ -360,6 +554,7 @@ def _parse_pyproject_toml(path: Path) -> dict[str, Any]:
|
|
|
360
554
|
"ecosystem": "python",
|
|
361
555
|
"name": project.get("name", ""),
|
|
362
556
|
"deps": deps,
|
|
557
|
+
"runtime_deps": set(deps),
|
|
363
558
|
"has_bin": False,
|
|
364
559
|
"has_library_structure": bool(project.get("name")),
|
|
365
560
|
"export_count": export_count,
|
|
@@ -382,10 +577,13 @@ def _parse_cargo_toml(path: Path) -> dict[str, Any]:
|
|
|
382
577
|
pkg = {}
|
|
383
578
|
|
|
384
579
|
deps: set[str] = set()
|
|
580
|
+
runtime_deps: set[str] = set()
|
|
385
581
|
for dep_key in ("dependencies", "dev-dependencies", "build-dependencies"):
|
|
386
582
|
section = data.get(dep_key, {})
|
|
387
583
|
if isinstance(section, dict):
|
|
388
584
|
deps.update(k.lower() for k in section)
|
|
585
|
+
if dep_key == "dependencies":
|
|
586
|
+
runtime_deps.update(k.lower() for k in section)
|
|
389
587
|
|
|
390
588
|
has_lib = "lib" in data and isinstance(data["lib"], dict)
|
|
391
589
|
bin_targets = data.get("bin", [])
|
|
@@ -405,16 +603,210 @@ def _parse_cargo_toml(path: Path) -> dict[str, Any]:
|
|
|
405
603
|
"ecosystem": "rust",
|
|
406
604
|
"name": pkg.get("name", ""),
|
|
407
605
|
"deps": deps,
|
|
606
|
+
"runtime_deps": runtime_deps,
|
|
408
607
|
"has_bin": has_bin,
|
|
409
608
|
"has_library_structure": has_lib or bool(pkg.get("name")),
|
|
410
609
|
"export_count": export_count,
|
|
411
610
|
}
|
|
412
611
|
|
|
413
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
|
+
|
|
414
801
|
_PARSERS = {
|
|
415
802
|
"package.json": _parse_package_json,
|
|
416
803
|
"pyproject.toml": _parse_pyproject_toml,
|
|
417
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,
|
|
418
810
|
}
|
|
419
811
|
|
|
420
812
|
|
|
@@ -429,9 +821,24 @@ def _parse_manifest(path: Path) -> dict[str, Any]:
|
|
|
429
821
|
# Core classification
|
|
430
822
|
# ---------------------------------------------------------------------------
|
|
431
823
|
|
|
432
|
-
def detect(
|
|
433
|
-
|
|
434
|
-
|
|
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:
|
|
435
842
|
_die("--manifests requires at least one path", "MISSING_MANIFESTS")
|
|
436
843
|
|
|
437
844
|
parsed: list[dict[str, Any]] = []
|
|
@@ -439,46 +846,144 @@ def detect(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
|
|
|
439
846
|
p = Path(mp)
|
|
440
847
|
if not p.is_file():
|
|
441
848
|
_die(f"Manifest not found: {p.as_posix()}", "MANIFEST_NOT_FOUND")
|
|
442
|
-
|
|
849
|
+
m = _parse_manifest(p)
|
|
850
|
+
m["_path"] = mp
|
|
851
|
+
m["_core"] = is_core_manifest(mp, m.get("name", ""))
|
|
852
|
+
parsed.append(m)
|
|
443
853
|
|
|
444
854
|
all_deps: set[str] = set()
|
|
855
|
+
all_runtime_deps: set[str] = set()
|
|
856
|
+
core_runtime_deps: set[str] = set()
|
|
445
857
|
ecosystems: set[str] = set()
|
|
446
858
|
total_exports = 0
|
|
447
859
|
signals: list[str] = []
|
|
448
|
-
has_bin = False
|
|
860
|
+
has_bin = False # any manifest
|
|
861
|
+
core_has_bin = False # app-eligible manifests only
|
|
449
862
|
has_library_structure = False
|
|
450
863
|
|
|
451
|
-
|
|
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):
|
|
452
881
|
eco = m["ecosystem"]
|
|
453
882
|
ecosystems.add(eco)
|
|
454
883
|
signals.append(f"has_{path_to_manifest_name(eco)}")
|
|
455
884
|
all_deps.update(m.get("deps", set()))
|
|
885
|
+
all_runtime_deps.update(m.get("runtime_deps", set()))
|
|
456
886
|
total_exports += m.get("export_count", 0)
|
|
457
887
|
if m.get("has_bin"):
|
|
458
888
|
has_bin = True
|
|
459
889
|
if m.get("has_library_structure"):
|
|
460
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
|
|
461
905
|
|
|
462
|
-
|
|
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
|
|
463
915
|
|
|
464
916
|
if total_exports > 50:
|
|
465
917
|
signals.append("exports_count_gt_50")
|
|
466
|
-
if
|
|
918
|
+
if app_has_bin:
|
|
467
919
|
signals.append("has_bin_field")
|
|
468
920
|
elif has_library_structure:
|
|
469
921
|
signals.append("no_bin_field")
|
|
922
|
+
if noncore_has_bin:
|
|
923
|
+
signals.append("has_bin_field_noncore")
|
|
470
924
|
if has_library_structure:
|
|
471
925
|
signals.append("has_library_structure")
|
|
472
926
|
|
|
473
|
-
# Collect
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
)
|
|
477
948
|
|
|
949
|
+
for d in parser_producers:
|
|
950
|
+
signals.append(f"parser_producer:{d}")
|
|
478
951
|
for d in parser_deps:
|
|
479
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
|
+
|
|
480
981
|
for d in framework_deps:
|
|
481
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}")
|
|
482
987
|
if len(ecosystems) > 1:
|
|
483
988
|
signals.append("multiple_ecosystems")
|
|
484
989
|
for eco in sorted(ecosystems):
|
|
@@ -491,9 +996,37 @@ def detect(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
|
|
|
491
996
|
|
|
492
997
|
# --- Heuristic ladder (first match wins) ---
|
|
493
998
|
|
|
494
|
-
#
|
|
495
|
-
|
|
496
|
-
|
|
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)
|
|
497
1030
|
return {"shape": "language-reference", "signals": signals,
|
|
498
1031
|
"confidence": round(confidence, 2), **result_base}
|
|
499
1032
|
|
|
@@ -503,9 +1036,12 @@ def detect(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
|
|
|
503
1036
|
return {"shape": "stack-compose", "signals": signals,
|
|
504
1037
|
"confidence": round(confidence, 2), **result_base}
|
|
505
1038
|
|
|
506
|
-
# 3. reference-app
|
|
507
|
-
|
|
508
|
-
|
|
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)
|
|
509
1045
|
confidence = _clamp(0.80 + (strength - 1) * 0.05, 0.80, 0.90)
|
|
510
1046
|
return {"shape": "reference-app", "signals": signals,
|
|
511
1047
|
"confidence": round(confidence, 2), **result_base}
|
|
@@ -530,7 +1066,9 @@ def detect(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
|
|
|
530
1066
|
|
|
531
1067
|
def path_to_manifest_name(ecosystem: str) -> str:
|
|
532
1068
|
return {"npm": "package_json", "python": "pyproject_toml",
|
|
533
|
-
"rust": "cargo_toml"
|
|
1069
|
+
"rust": "cargo_toml", "go": "go_mod",
|
|
1070
|
+
"maven": "pom_xml", "gradle": "build_gradle",
|
|
1071
|
+
"swift": "package_swift"}.get(ecosystem, ecosystem)
|
|
534
1072
|
|
|
535
1073
|
|
|
536
1074
|
# ---------------------------------------------------------------------------
|
|
@@ -544,15 +1082,28 @@ def main(argv: list[str]) -> int:
|
|
|
544
1082
|
parser.add_argument("--repo-url", required=True, help="Repository URL")
|
|
545
1083
|
parser.add_argument(
|
|
546
1084
|
"--manifests", required=True,
|
|
547
|
-
help="Comma-separated local file paths to manifest files"
|
|
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",
|
|
548
1097
|
)
|
|
549
1098
|
args = parser.parse_args(argv)
|
|
550
1099
|
|
|
551
1100
|
manifest_paths = [p.strip() for p in args.manifests.split(",") if p.strip()]
|
|
552
|
-
if
|
|
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:
|
|
553
1104
|
_die("--manifests requires at least one path", "MISSING_MANIFESTS")
|
|
554
1105
|
|
|
555
|
-
result = detect(args.repo_url, manifest_paths)
|
|
1106
|
+
result = detect(args.repo_url, manifest_paths, grammar_files, tree_paths)
|
|
556
1107
|
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
557
1108
|
sys.stdout.write("\n")
|
|
558
1109
|
|