bmad-module-skill-forge 1.9.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/README.md +10 -5
  4. package/docs/_data/pinned.yaml +2 -2
  5. package/docs/agents.md +11 -2
  6. package/docs/architecture.md +5 -4
  7. package/docs/bmad-synergy.md +30 -7
  8. package/docs/campaign.md +172 -0
  9. package/docs/examples.md +7 -3
  10. package/docs/forge-auto.md +90 -0
  11. package/docs/getting-started.md +9 -0
  12. package/docs/how-it-works.md +6 -4
  13. package/docs/index.md +4 -3
  14. package/docs/skill-model.md +1 -1
  15. package/docs/troubleshooting.md +17 -1
  16. package/docs/verifying-a-skill.md +2 -2
  17. package/docs/why-skf.md +1 -1
  18. package/docs/workflows.md +64 -24
  19. package/package.json +2 -2
  20. package/src/knowledge/skill-lifecycle.md +1 -1
  21. package/src/module-help.csv +4 -3
  22. package/src/shared/data/language-corpora.json +32 -0
  23. package/src/shared/references/pipeline-contracts.md +6 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  26. package/src/shared/scripts/skf-count-tokens.py +222 -0
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  28. package/src/shared/scripts/skf-detect-docs.py +240 -4
  29. package/src/shared/scripts/skf-detect-language.py +82 -3
  30. package/src/shared/scripts/skf-detect-tools.py +105 -2
  31. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  32. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  33. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  34. package/src/shared/scripts/skf-find-cycles.py +246 -0
  35. package/src/shared/scripts/skf-hash-content.py +487 -2
  36. package/src/shared/scripts/skf-language-corpora.py +100 -0
  37. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  39. package/src/shared/scripts/skf-preapply.py +1 -1
  40. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  41. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  42. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  43. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  44. package/src/shared/scripts/skf-shape-detect.py +576 -25
  45. package/src/shared/scripts/skf-shard-body.py +461 -0
  46. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  47. package/src/shared/scripts/skf-structural-diff.py +307 -67
  48. package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
  49. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  50. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  51. package/src/shared/scripts/skf-validate-output.py +443 -10
  52. package/src/shared/scripts/skf-validate-pins.py +3 -3
  53. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  54. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  55. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  56. package/src/skf-analyze-source/SKILL.md +17 -32
  57. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  58. package/src/skf-analyze-source/customize.toml +2 -0
  59. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  60. package/src/skf-analyze-source/references/continue.md +8 -13
  61. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  62. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  63. package/src/skf-analyze-source/references/health-check.md +1 -2
  64. package/src/skf-analyze-source/references/identify-units.md +25 -36
  65. package/src/skf-analyze-source/references/init.md +12 -25
  66. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  67. package/src/skf-analyze-source/references/recommend.md +4 -14
  68. package/src/skf-analyze-source/references/scan-project.md +3 -11
  69. package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
  70. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  71. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  72. package/src/skf-audit-skill/SKILL.md +9 -6
  73. package/src/skf-audit-skill/customize.toml +2 -0
  74. package/src/skf-audit-skill/references/init.md +58 -32
  75. package/src/skf-audit-skill/references/re-index.md +15 -34
  76. package/src/skf-audit-skill/references/report.md +11 -58
  77. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  78. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  79. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  80. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  81. package/src/skf-brief-skill/SKILL.md +7 -36
  82. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  83. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  84. package/src/skf-brief-skill/customize.toml +14 -0
  85. package/src/skf-brief-skill/references/analyze-target.md +13 -18
  86. package/src/skf-brief-skill/references/confirm-brief.md +2 -15
  87. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  88. package/src/skf-brief-skill/references/gather-intent.md +27 -24
  89. package/src/skf-brief-skill/references/headless-args.md +1 -1
  90. package/src/skf-brief-skill/references/health-check.md +1 -22
  91. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  92. package/src/skf-brief-skill/references/scope-definition.md +6 -15
  93. package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
  94. package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
  95. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  96. package/src/skf-brief-skill/references/write-brief.md +19 -13
  97. package/src/skf-campaign/SKILL.md +121 -0
  98. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  99. package/src/skf-campaign/customize.toml +75 -0
  100. package/src/skf-campaign/manifest.yaml +15 -0
  101. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  102. package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
  103. package/src/skf-campaign/references/health-check.md +35 -0
  104. package/src/skf-campaign/references/step-01-setup.md +121 -0
  105. package/src/skf-campaign/references/step-02-strategy.md +102 -0
  106. package/src/skf-campaign/references/step-03-pins.md +56 -0
  107. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  108. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  109. package/src/skf-campaign/references/step-06-batch.md +88 -0
  110. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  111. package/src/skf-campaign/references/step-08-verify.md +75 -0
  112. package/src/skf-campaign/references/step-09-refine.md +83 -0
  113. package/src/skf-campaign/references/step-10-export.md +106 -0
  114. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  115. package/src/skf-campaign/references/step-resume.md +115 -0
  116. package/src/skf-campaign/scripts/.gitkeep +0 -0
  117. package/src/skf-campaign/scripts/campaign-deps.py +244 -0
  118. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  119. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  120. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  121. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  122. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  123. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  124. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  125. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  126. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  127. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  128. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  129. package/src/skf-create-skill/SKILL.md +15 -4
  130. package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
  131. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  132. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  133. package/src/skf-create-skill/customize.toml +13 -0
  134. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  135. package/src/skf-create-skill/references/compile.md +37 -47
  136. package/src/skf-create-skill/references/component-extraction.md +8 -35
  137. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  138. package/src/skf-create-skill/references/enrich.md +4 -17
  139. package/src/skf-create-skill/references/extract.md +20 -27
  140. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  141. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  142. package/src/skf-create-skill/references/generate-artifacts.md +20 -25
  143. package/src/skf-create-skill/references/health-check.md +2 -2
  144. package/src/skf-create-skill/references/load-brief.md +16 -18
  145. package/src/skf-create-skill/references/report.md +27 -14
  146. package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
  147. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  148. package/src/skf-create-skill/references/step-doc-rot.md +33 -15
  149. package/src/skf-create-skill/references/step-doc-sources.md +13 -4
  150. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  151. package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
  152. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  153. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  154. package/src/skf-create-skill/references/validate.md +69 -29
  155. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  156. package/src/skf-create-stack-skill/SKILL.md +15 -9
  157. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  158. package/src/skf-create-stack-skill/customize.toml +10 -0
  159. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  160. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  161. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  162. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  163. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  164. package/src/skf-create-stack-skill/references/init.md +9 -81
  165. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  166. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  167. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  168. package/src/skf-create-stack-skill/references/report.md +27 -62
  169. package/src/skf-create-stack-skill/references/validate.md +35 -31
  170. package/src/skf-drop-skill/SKILL.md +20 -23
  171. package/src/skf-drop-skill/customize.toml +10 -0
  172. package/src/skf-drop-skill/references/execute.md +67 -36
  173. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  174. package/src/skf-drop-skill/references/health-check.md +1 -13
  175. package/src/skf-drop-skill/references/report.md +44 -10
  176. package/src/skf-drop-skill/references/select.md +93 -48
  177. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  178. package/src/skf-export-skill/SKILL.md +17 -14
  179. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  180. package/src/skf-export-skill/customize.toml +10 -6
  181. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  182. package/src/skf-export-skill/references/load-skill.md +44 -42
  183. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  184. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  185. package/src/skf-export-skill/references/package.md +27 -33
  186. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  187. package/src/skf-export-skill/references/result-envelope.md +28 -0
  188. package/src/skf-export-skill/references/summary.md +11 -5
  189. package/src/skf-export-skill/references/token-report.md +42 -24
  190. package/src/skf-export-skill/references/update-context.md +32 -33
  191. package/src/skf-forger/SKILL.md +32 -39
  192. package/src/skf-forger/references/pipeline-mode.md +34 -0
  193. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  194. package/src/skf-quick-skill/SKILL.md +10 -60
  195. package/src/skf-quick-skill/customize.toml +9 -0
  196. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  197. package/src/skf-quick-skill/references/compile.md +15 -52
  198. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  199. package/src/skf-quick-skill/references/finalize.md +12 -6
  200. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  201. package/src/skf-quick-skill/references/health-check.md +3 -4
  202. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  203. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  204. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  205. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  206. package/src/skf-refine-architecture/SKILL.md +16 -8
  207. package/src/skf-refine-architecture/customize.toml +9 -0
  208. package/src/skf-refine-architecture/references/compile.md +10 -8
  209. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  210. package/src/skf-refine-architecture/references/health-check.md +1 -1
  211. package/src/skf-refine-architecture/references/improvements.md +9 -25
  212. package/src/skf-refine-architecture/references/init.md +9 -16
  213. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  214. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  215. package/src/skf-refine-architecture/references/report.md +14 -11
  216. package/src/skf-rename-skill/SKILL.md +11 -21
  217. package/src/skf-rename-skill/customize.toml +10 -0
  218. package/src/skf-rename-skill/references/execute.md +112 -82
  219. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  220. package/src/skf-rename-skill/references/report.md +16 -8
  221. package/src/skf-rename-skill/references/select.md +69 -42
  222. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  223. package/src/skf-setup/SKILL.md +21 -13
  224. package/src/skf-setup/customize.toml +10 -0
  225. package/src/skf-setup/references/auto-index.md +11 -10
  226. package/src/skf-setup/references/ccc-index.md +14 -15
  227. package/src/skf-setup/references/detect-and-tier.md +10 -8
  228. package/src/skf-setup/references/report.md +26 -32
  229. package/src/skf-setup/references/tier-rules.md +2 -32
  230. package/src/skf-setup/references/write-config.md +9 -10
  231. package/src/skf-test-skill/SKILL.md +10 -8
  232. package/src/skf-test-skill/customize.toml +2 -0
  233. package/src/skf-test-skill/references/coherence-check.md +19 -31
  234. package/src/skf-test-skill/references/coverage-check.md +111 -67
  235. package/src/skf-test-skill/references/detect-mode.md +10 -41
  236. package/src/skf-test-skill/references/external-validators.md +11 -19
  237. package/src/skf-test-skill/references/init.md +39 -21
  238. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  239. package/src/skf-test-skill/references/report.md +60 -40
  240. package/src/skf-test-skill/references/score.md +32 -64
  241. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  242. package/src/skf-test-skill/references/source-access-protocol.md +15 -5
  243. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  244. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  245. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  246. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  247. package/src/skf-test-skill/scripts/compute-score.py +135 -4
  248. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  249. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  250. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  251. package/src/skf-update-skill/SKILL.md +17 -5
  252. package/src/skf-update-skill/customize.toml +11 -6
  253. package/src/skf-update-skill/references/detect-changes.md +48 -36
  254. package/src/skf-update-skill/references/health-check.md +5 -11
  255. package/src/skf-update-skill/references/init.md +40 -32
  256. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  257. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  258. package/src/skf-update-skill/references/merge.md +13 -57
  259. package/src/skf-update-skill/references/re-extract.md +27 -25
  260. package/src/skf-update-skill/references/report.md +14 -7
  261. package/src/skf-update-skill/references/validate.md +32 -30
  262. package/src/skf-update-skill/references/write.md +86 -51
  263. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  264. package/src/skf-verify-stack/SKILL.md +11 -21
  265. package/src/skf-verify-stack/customize.toml +12 -3
  266. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  267. package/src/skf-verify-stack/references/coverage.md +21 -10
  268. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  269. package/src/skf-verify-stack/references/health-check.md +1 -1
  270. package/src/skf-verify-stack/references/init.md +20 -25
  271. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  272. package/src/skf-verify-stack/references/integrations.md +44 -49
  273. package/src/skf-verify-stack/references/report.md +28 -34
  274. package/src/skf-verify-stack/references/requirements.md +3 -3
  275. package/src/skf-verify-stack/references/synthesize.md +36 -39
  276. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  277. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  278. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  279. package/tools/cli/lib/ui.js +3 -2
  280. package/docs/deepwiki.md +0 -89
  281. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -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 python src/shared/scripts/skf-shape-detect.py \\
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 repository URL (required; context only, no cloning)
31
- --manifests comma-separated local file paths to manifest files (required)
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(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
433
- """Classify a repo into a skill shape from its manifest files."""
434
- if not manifest_paths:
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
- parsed.append(_parse_manifest(p))
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
- for m in parsed:
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
- package_count = len(parsed)
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 has_bin:
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 dep-category matches
474
- parser_deps = sorted(d for d in all_deps if d.lower() in _ALL_PARSER_DEPS)
475
- framework_deps = sorted(d for d in all_deps if d.lower() in _ALL_FRAMEWORK_DEPS)
476
- has_framework = len(framework_deps) > 0
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
- # 1. language-reference
495
- if parser_deps:
496
- confidence = _clamp(0.75 + len(parser_deps) * 0.05, 0.75, 0.85)
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
- if has_bin or has_framework:
508
- strength = (1 if has_bin else 0) + (1 if has_framework else 0)
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"}.get(ecosystem, ecosystem)
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 not manifest_paths:
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