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.
- 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 +42 -3
- 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 +9 -3
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +73 -27
- package/package.json +2 -2
- package/src/module-help.csv +2 -1
- package/src/shared/_known-workarounds.yaml +155 -0
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +14 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
- 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 +417 -0
- package/src/shared/scripts/skf-detect-language.py +5 -3
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
- 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 +192 -0
- package/src/shared/scripts/skf-shape-detect.py +1114 -0
- package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/init.md +30 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +677 -0
- package/src/skf-analyze-source/references/step-shape-detect.md +86 -0
- package/src/skf-audit-skill/SKILL.md +1 -0
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
- package/src/skf-audit-skill/references/init.md +1 -1
- package/src/skf-audit-skill/references/report.md +4 -0
- package/src/skf-audit-skill/references/severity-classify.md +1 -1
- package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
- package/src/skf-brief-skill/SKILL.md +7 -3
- 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 +22 -2
- package/src/skf-brief-skill/references/scope-definition.md +2 -1
- package/src/skf-brief-skill/references/step-auto-brief.md +188 -0
- package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
- 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 +4 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -0
- package/src/skf-create-skill/references/compile.md +5 -2
- 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-auto-shard.md +110 -0
- package/src/skf-create-skill/references/step-doc-rot.md +109 -0
- package/src/skf-create-skill/references/step-doc-sources.md +123 -0
- 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 +15 -4
- 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/SKILL.md +8 -7
- package/src/skf-test-skill/customize.toml +2 -1
- package/src/skf-test-skill/references/coverage-check.md +10 -0
- package/src/skf-test-skill/references/external-validators.md +1 -1
- package/src/skf-test-skill/references/init.md +16 -1
- package/src/skf-test-skill/references/report.md +5 -1
- package/src/skf-test-skill/references/score.md +95 -6
- package/src/skf-test-skill/references/source-access-protocol.md +13 -3
- package/src/skf-test-skill/references/step-hard-gate.md +73 -0
- package/src/skf-test-skill/scripts/compute-score.py +4 -3
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- 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
|
@@ -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:]))
|