bmad-module-skill-forge 1.4.1 → 1.5.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 (231) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +0 -8
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/STABILITY.md +1 -1
  5. package/docs/architecture.md +2 -2
  6. package/docs/examples.md +1 -1
  7. package/docs/workflows.md +5 -5
  8. package/package.json +2 -2
  9. package/src/README.md +1 -1
  10. package/src/knowledge/ccc-bridge.md +12 -12
  11. package/src/knowledge/qmd-registry.md +5 -5
  12. package/src/shared/health-check.md +5 -5
  13. package/src/shared/references/description-guard-protocol.md +100 -0
  14. package/src/shared/references/output-contract-schema.md +1 -1
  15. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -6
  16. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +9 -3
  17. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +149 -0
  18. package/src/shared/scripts/schemas/skill-brief.v1.json +22 -2
  19. package/src/shared/scripts/skf-build-change-manifest.py +420 -0
  20. package/src/shared/scripts/skf-check-workspace-drift.py +321 -0
  21. package/src/shared/scripts/skf-compare-file-hashes.py +357 -0
  22. package/src/shared/scripts/skf-description-guard.py +359 -0
  23. package/src/shared/scripts/skf-detect-language.py +2 -2
  24. package/src/shared/scripts/skf-detect-scripts-assets.py +613 -0
  25. package/src/shared/scripts/skf-detect-tools.py +147 -16
  26. package/src/shared/scripts/skf-detect-workspaces.py +1 -1
  27. package/src/shared/scripts/skf-disqualify-candidates.py +576 -0
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +3 -3
  29. package/src/shared/scripts/skf-emit-result-envelope.py +93 -9
  30. package/src/shared/scripts/skf-enumerate-stack-skills.py +514 -0
  31. package/src/shared/scripts/skf-forge-tier-rw.py +11 -11
  32. package/src/shared/scripts/skf-hash-content.py +284 -0
  33. package/src/shared/scripts/skf-load-provenance.py +295 -0
  34. package/src/shared/scripts/skf-manifest-ops.py +22 -6
  35. package/src/shared/scripts/skf-merge-ccc-exclusions.py +34 -4
  36. package/src/shared/scripts/skf-pair-intersect.py +250 -0
  37. package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
  38. package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
  39. package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
  40. package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
  41. package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
  42. package/src/shared/scripts/skf-scan-manifests.py +738 -0
  43. package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
  44. package/src/shared/scripts/skf-update-active-symlink.py +315 -0
  45. package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
  46. package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
  47. package/src/shared/scripts/skf-write-skill-brief.py +61 -5
  48. package/src/skf-analyze-source/SKILL.md +69 -16
  49. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  50. package/src/skf-analyze-source/customize.toml +55 -0
  51. package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
  52. package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
  53. package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  54. package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +48 -14
  55. package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +32 -9
  56. package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
  57. package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
  58. package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +16 -10
  59. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
  60. package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
  61. package/src/skf-audit-skill/SKILL.md +66 -15
  62. package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
  63. package/src/skf-audit-skill/customize.toml +49 -0
  64. package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  65. package/src/skf-audit-skill/{steps-c/step-01-init.md → references/init.md} +63 -19
  66. package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
  67. package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
  68. package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
  69. package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
  70. package/src/skf-audit-skill/references/severity-rules.md +2 -0
  71. package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +35 -17
  72. package/src/skf-brief-skill/SKILL.md +49 -19
  73. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  74. package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
  75. package/src/skf-brief-skill/customize.toml +44 -0
  76. package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +27 -15
  77. package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
  78. package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
  79. package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +35 -29
  80. package/src/skf-brief-skill/references/headless-args.md +9 -9
  81. package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
  82. package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
  83. package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
  84. package/src/skf-brief-skill/references/qmd-collection-registration.md +2 -2
  85. package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +27 -20
  86. package/src/skf-brief-skill/references/version-resolution.md +2 -2
  87. package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +29 -16
  88. package/src/skf-create-skill/SKILL.md +25 -19
  89. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
  90. package/src/skf-create-skill/assets/skill-sections.md +5 -5
  91. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
  92. package/src/skf-create-skill/customize.toml +35 -0
  93. package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
  94. package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
  95. package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
  96. package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
  97. package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
  98. package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
  99. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  100. package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +11 -11
  101. package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
  102. package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
  103. package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
  104. package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
  105. package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
  106. package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
  107. package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
  108. package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +48 -28
  109. package/src/skf-create-stack-skill/SKILL.md +76 -17
  110. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
  111. package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
  112. package/src/skf-create-stack-skill/customize.toml +46 -0
  113. package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
  114. package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
  115. package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +37 -11
  116. package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +32 -28
  117. package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
  118. package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
  119. package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
  120. package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
  121. package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
  122. package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +44 -17
  123. package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
  124. package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
  125. package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
  126. package/src/skf-drop-skill/SKILL.md +84 -12
  127. package/src/skf-drop-skill/customize.toml +57 -0
  128. package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
  129. package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
  130. package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  131. package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
  132. package/src/skf-export-skill/SKILL.md +85 -16
  133. package/src/skf-export-skill/assets/managed-section-format.md +3 -3
  134. package/src/skf-export-skill/assets/snippet-format.md +2 -2
  135. package/src/skf-export-skill/customize.toml +49 -0
  136. package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +11 -11
  137. package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  138. package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +11 -49
  139. package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
  140. package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
  141. package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
  142. package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
  143. package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
  144. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +79 -0
  145. package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +23 -11
  146. package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
  147. package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +109 -113
  148. package/src/skf-quick-skill/SKILL.md +63 -123
  149. package/src/skf-quick-skill/customize.toml +44 -0
  150. package/src/skf-quick-skill/references/batch-mode.md +102 -0
  151. package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
  152. package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
  153. package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
  154. package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
  155. package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
  156. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  157. package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
  158. package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
  159. package/src/skf-refine-architecture/SKILL.md +86 -16
  160. package/src/skf-refine-architecture/customize.toml +49 -0
  161. package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
  162. package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
  163. package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  164. package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
  165. package/src/skf-refine-architecture/references/init.md +144 -0
  166. package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
  167. package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
  168. package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
  169. package/src/skf-rename-skill/SKILL.md +82 -12
  170. package/src/skf-rename-skill/customize.toml +52 -0
  171. package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
  172. package/src/skf-rename-skill/references/health-check.md +30 -0
  173. package/src/skf-rename-skill/references/rebuild-context.md +110 -0
  174. package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  175. package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
  176. package/src/skf-setup/SKILL.md +35 -35
  177. package/src/skf-setup/customize.toml +33 -0
  178. package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
  179. package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +16 -31
  180. package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
  181. package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
  182. package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
  183. package/src/skf-setup/references/tier-rules.md +1 -1
  184. package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
  185. package/src/skf-test-skill/SKILL.md +77 -15
  186. package/src/skf-test-skill/customize.toml +54 -0
  187. package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +37 -73
  188. package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
  189. package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
  190. package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
  191. package/src/skf-test-skill/references/health-check.md +14 -0
  192. package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
  193. package/src/skf-test-skill/references/migration-section-rules.md +4 -2
  194. package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
  195. package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
  196. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  197. package/src/skf-test-skill/references/source-access-protocol.md +7 -5
  198. package/src/skf-test-skill/scripts/compute-score.py +88 -17
  199. package/src/skf-test-skill/templates/test-report-template.md +13 -13
  200. package/src/skf-update-skill/SKILL.md +23 -14
  201. package/src/skf-update-skill/customize.toml +44 -0
  202. package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
  203. package/src/skf-update-skill/references/health-check.md +32 -0
  204. package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
  205. package/src/skf-update-skill/references/manual-section-rules.md +4 -0
  206. package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
  207. package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
  208. package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
  209. package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
  210. package/src/skf-update-skill/references/report.md +214 -0
  211. package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
  212. package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
  213. package/src/skf-verify-stack/SKILL.md +89 -17
  214. package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
  215. package/src/skf-verify-stack/customize.toml +50 -0
  216. package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
  217. package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +13 -9
  218. package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
  219. package/src/skf-verify-stack/references/init.md +170 -0
  220. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  221. package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +18 -14
  222. package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +27 -42
  223. package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +13 -9
  224. package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +14 -10
  225. package/tools/validate-docs-drift.js +1 -1
  226. package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
  227. package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
  228. package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
  229. package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
  230. package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
  231. package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
@@ -0,0 +1,738 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Scan Manifests — discover and parse package manifests across ecosystems.
6
+
7
+ Two skill workflows ask the LLM to perform the same deterministic operation:
8
+ walk a project root, find every recognised dependency manifest, parse each
9
+ one into a `{name, version}` dep list, dedupe across files, and flag whether
10
+ the layout looks like a monorepo.
11
+
12
+ 1. **skf-create-stack-skill / detect-manifests.md §2–§3** — Scans the
13
+ project root to enumerate manifest files (depth 0–1), parses every
14
+ manifest to extract dependency names + versions, dedupes the unique
15
+ set, and feeds the dep list into the ranking stage. The prose lists
16
+ every supported ecosystem with parsing hints; that list is exactly the
17
+ ecosystem table this script implements.
18
+
19
+ 2. **skf-analyze-source / scan-project.md §2** — Finds the same set of
20
+ manifest files in the broader project-scan pass. Service-config files
21
+ (Dockerfile, docker-compose.yml) stay LLM-driven; only the manifest
22
+ enumeration is extracted here.
23
+
24
+ Pulling this into a deterministic script gives both stages identical
25
+ manifest-set semantics (no LLM drift on what counts as a manifest), a stable
26
+ JSON envelope for the LLM to consume, and a single place to evolve the
27
+ ecosystem matrix as new languages land.
28
+
29
+ Subcommand:
30
+ scan <root> [--ecosystems=auto]
31
+ Walk `<root>` (depth 0–1 plus common monorepo packages/* paths) for
32
+ recognised manifests, parse each to extract `production` deps only,
33
+ and emit:
34
+ {
35
+ "manifests": [
36
+ {
37
+ "path": "<rel-from-root, forward-slash>",
38
+ "ecosystem": "<name>",
39
+ "deps": [{"name": "...", "version": "...|null"}]
40
+ },
41
+ ...
42
+ ],
43
+ "total_unique": N, // unique dep names across all manifests
44
+ "monorepo": <bool>, // >1 manifest of same ecosystem at non-overlapping depths
45
+ "warnings": ["..."] // optional: only present if any warning was emitted
46
+ }
47
+ Default `--ecosystems=auto` detects all supported ecosystems. The flag
48
+ is currently a placeholder for future filtering — `auto` is the only
49
+ accepted value today, but its presence keeps the CLI shape stable.
50
+
51
+ Supported ecosystems (manifest filename → ecosystem):
52
+ - package.json → npm (npm/pnpm/yarn share this)
53
+ - pyproject.toml → python (pip/poetry/pdm)
54
+ - requirements.txt → python
55
+ - setup.py → python
56
+ - setup.cfg → python
57
+ - Pipfile → python
58
+ - Cargo.toml → rust
59
+ - go.mod → go
60
+ - pom.xml → maven (java/kotlin)
61
+ - build.gradle → gradle
62
+ - build.gradle.kts → gradle
63
+ - Gemfile → ruby
64
+ - composer.json → composer
65
+ - Package.swift → swift
66
+
67
+ Parsing approach: JSON manifests via stdlib `json`, TOML via stdlib
68
+ `tomllib` (3.11+); text manifests (requirements.txt, Pipfile, setup.py,
69
+ setup.cfg, go.mod, Gemfile, pom.xml, build.gradle*, Package.swift) via
70
+ ad-hoc regex extraction. Production deps only — devDependencies /
71
+ [tool.poetry.dev-dependencies] / require-dev are intentionally skipped to
72
+ keep the consumer-facing dep list focused on runtime libraries. Unparseable
73
+ manifests emit a top-level `warnings[]` entry and the manifest still appears
74
+ in `manifests[]` with whatever deps were salvageable (possibly empty).
75
+
76
+ Exit codes:
77
+ 0 — success (including: zero manifests found — emit empty result)
78
+ 1 — user error (bad root path, root not a directory)
79
+
80
+ CLI example:
81
+ uv run skf-scan-manifests.py scan /path/to/repo
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ import argparse
87
+ import json
88
+ import re
89
+ import sys
90
+ import tomllib
91
+ from pathlib import Path
92
+ from typing import Iterable
93
+
94
+
95
+ # --------------------------------------------------------------------------
96
+ # Ecosystem table
97
+ # --------------------------------------------------------------------------
98
+
99
+
100
+ # (manifest filename) → ecosystem label. Order matters only for the scan walk
101
+ # (we test each name in order, but every match is recorded). Lowercase compare
102
+ # is NOT used — manifest filenames are case-sensitive on POSIX, and Windows
103
+ # CI normalises case during the FS walk anyway.
104
+ MANIFEST_ECOSYSTEMS: dict[str, str] = {
105
+ "package.json": "npm",
106
+ "pyproject.toml": "python",
107
+ "requirements.txt": "python",
108
+ "setup.py": "python",
109
+ "setup.cfg": "python",
110
+ "Pipfile": "python",
111
+ "Cargo.toml": "rust",
112
+ "go.mod": "go",
113
+ "pom.xml": "maven",
114
+ "build.gradle": "gradle",
115
+ "build.gradle.kts": "gradle",
116
+ "Gemfile": "ruby",
117
+ "composer.json": "composer",
118
+ "Package.swift": "swift",
119
+ }
120
+
121
+ # Directories the scan must never descend into. Mirrors
122
+ # references/manifest-patterns.md "Scan Exclusion Patterns".
123
+ EXCLUDED_DIRS: frozenset[str] = frozenset(
124
+ {
125
+ "node_modules",
126
+ ".venv",
127
+ "venv",
128
+ ".env",
129
+ "vendor",
130
+ "Pods",
131
+ "dist",
132
+ "build",
133
+ "out",
134
+ "target",
135
+ "__pycache__",
136
+ ".next",
137
+ ".nuxt",
138
+ ".output",
139
+ ".git",
140
+ }
141
+ )
142
+
143
+
144
+ # --------------------------------------------------------------------------
145
+ # Walking
146
+ # --------------------------------------------------------------------------
147
+
148
+
149
+ def _is_excluded_dir(name: str) -> bool:
150
+ """Return True if the dir name should be skipped during scan."""
151
+ if name in EXCLUDED_DIRS:
152
+ return True
153
+ # Hidden directories (".github", ".idea", etc.) — never descend
154
+ if name.startswith(".") and name != ".":
155
+ return True
156
+ return False
157
+
158
+
159
+ def find_manifests(root: Path) -> list[Path]:
160
+ """Walk `root` returning every recognised manifest file.
161
+
162
+ Excludes `EXCLUDED_DIRS` and all hidden directories. Returns absolute
163
+ paths in stable sorted order so caller output is reproducible.
164
+ """
165
+ found: list[Path] = []
166
+ # Iterative BFS so we can prune at the directory level.
167
+ stack: list[Path] = [root]
168
+ while stack:
169
+ current = stack.pop()
170
+ try:
171
+ entries = sorted(current.iterdir(), key=lambda p: p.name)
172
+ except OSError:
173
+ continue
174
+ for entry in entries:
175
+ try:
176
+ if entry.is_dir():
177
+ if _is_excluded_dir(entry.name):
178
+ continue
179
+ stack.append(entry)
180
+ elif entry.is_file() and entry.name in MANIFEST_ECOSYSTEMS:
181
+ found.append(entry)
182
+ except OSError:
183
+ continue
184
+ found.sort()
185
+ return found
186
+
187
+
188
+ # --------------------------------------------------------------------------
189
+ # Parsers
190
+ # --------------------------------------------------------------------------
191
+
192
+
193
+ Dep = dict # {"name": str, "version": str | None}
194
+
195
+
196
+ def _safe_read_text(path: Path) -> str | None:
197
+ """Read a file as utf-8 text; return None on failure (caller emits warning)."""
198
+ try:
199
+ return path.read_text(encoding="utf-8")
200
+ except (OSError, UnicodeDecodeError):
201
+ return None
202
+
203
+
204
+ def parse_package_json(text: str) -> tuple[list[Dep], list[str]]:
205
+ """Extract `dependencies` (skip devDependencies) from a package.json."""
206
+ warnings: list[str] = []
207
+ try:
208
+ data = json.loads(text)
209
+ except json.JSONDecodeError as exc:
210
+ return [], [f"package.json: JSON parse failed ({exc.msg})"]
211
+ if not isinstance(data, dict):
212
+ return [], ["package.json: top-level is not an object"]
213
+ deps_raw = data.get("dependencies")
214
+ if deps_raw is None:
215
+ return [], warnings
216
+ if not isinstance(deps_raw, dict):
217
+ return [], ["package.json: `dependencies` is not an object"]
218
+ deps: list[Dep] = []
219
+ for name, version in deps_raw.items():
220
+ if not isinstance(name, str):
221
+ continue
222
+ ver = version if isinstance(version, str) else None
223
+ deps.append({"name": name, "version": ver})
224
+ return deps, warnings
225
+
226
+
227
+ def parse_pyproject_toml(text: str) -> tuple[list[Dep], list[str]]:
228
+ """Extract production deps from a pyproject.toml.
229
+
230
+ Supports both PEP 621 `[project] dependencies = [...]` and Poetry-style
231
+ `[tool.poetry.dependencies]`. Dev-only sections (`[project.optional-dependencies.dev]`,
232
+ `[tool.poetry.dev-dependencies]`) are skipped.
233
+ """
234
+ warnings: list[str] = []
235
+ try:
236
+ data = tomllib.loads(text)
237
+ except tomllib.TOMLDecodeError as exc:
238
+ return [], [f"pyproject.toml: TOML parse failed ({exc})"]
239
+ deps: list[Dep] = []
240
+
241
+ # PEP 621
242
+ project = data.get("project")
243
+ if isinstance(project, dict):
244
+ deps_raw = project.get("dependencies")
245
+ if isinstance(deps_raw, list):
246
+ for entry in deps_raw:
247
+ if not isinstance(entry, str):
248
+ continue
249
+ parsed = _parse_pep508_or_pip(entry)
250
+ if parsed:
251
+ deps.append(parsed)
252
+
253
+ # Poetry
254
+ poetry = data.get("tool", {}).get("poetry") if isinstance(data.get("tool"), dict) else None
255
+ if isinstance(poetry, dict):
256
+ deps_raw = poetry.get("dependencies")
257
+ if isinstance(deps_raw, dict):
258
+ for name, spec in deps_raw.items():
259
+ if not isinstance(name, str) or name.lower() == "python":
260
+ continue
261
+ if isinstance(spec, str):
262
+ deps.append({"name": name, "version": spec})
263
+ elif isinstance(spec, dict):
264
+ ver = spec.get("version") if isinstance(spec.get("version"), str) else None
265
+ deps.append({"name": name, "version": ver})
266
+ else:
267
+ deps.append({"name": name, "version": None})
268
+
269
+ return deps, warnings
270
+
271
+
272
+ _PIP_REQ = re.compile(
273
+ r"""
274
+ ^\s*
275
+ (?P<name>[A-Za-z0-9_.\-]+)
276
+ \s*
277
+ (?:\[[^\]]*\])? # optional extras
278
+ \s*
279
+ (?P<op>==|>=|<=|~=|!=|>|<)?
280
+ \s*
281
+ (?P<version>[A-Za-z0-9_.\-+*]+)?
282
+ """,
283
+ re.VERBOSE,
284
+ )
285
+
286
+
287
+ def _parse_pep508_or_pip(line: str) -> Dep | None:
288
+ """Best-effort parse of a PEP 508 / pip requirement line."""
289
+ line = line.strip()
290
+ if not line or line.startswith("#") or line.startswith("-"):
291
+ return None
292
+ # Strip environment markers and comments
293
+ if ";" in line:
294
+ line = line.split(";", 1)[0].strip()
295
+ if "#" in line:
296
+ line = line.split("#", 1)[0].strip()
297
+ m = _PIP_REQ.match(line)
298
+ if not m:
299
+ return {"name": "<unparsable>", "version": None}
300
+ name = m.group("name")
301
+ ver = m.group("version")
302
+ op = m.group("op")
303
+ version = (op + ver) if (op and ver) else (ver if ver else None)
304
+ return {"name": name, "version": version}
305
+
306
+
307
+ def parse_requirements_txt(text: str) -> tuple[list[Dep], list[str]]:
308
+ deps: list[Dep] = []
309
+ warnings: list[str] = []
310
+ for raw_line in text.splitlines():
311
+ line = raw_line.strip()
312
+ if not line or line.startswith("#") or line.startswith("-"):
313
+ continue
314
+ parsed = _parse_pep508_or_pip(line)
315
+ if parsed:
316
+ deps.append(parsed)
317
+ return deps, warnings
318
+
319
+
320
+ def parse_setup_py(text: str) -> tuple[list[Dep], list[str]]:
321
+ """Best-effort regex extraction of `install_requires=[...]` from setup.py."""
322
+ warnings: list[str] = []
323
+ m = re.search(r"install_requires\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
324
+ if not m:
325
+ return [], warnings
326
+ deps: list[Dep] = []
327
+ for match in re.finditer(r"""(['"])([^'"]+)\1""", m.group(1)):
328
+ entry = match.group(2).strip()
329
+ parsed = _parse_pep508_or_pip(entry)
330
+ if parsed:
331
+ deps.append(parsed)
332
+ return deps, warnings
333
+
334
+
335
+ def parse_setup_cfg(text: str) -> tuple[list[Dep], list[str]]:
336
+ """Extract `install_requires` from setup.cfg's `[options]` section."""
337
+ warnings: list[str] = []
338
+ # find [options] section through next [section]
339
+ sec = re.search(
340
+ r"\[options\](.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL
341
+ )
342
+ if not sec:
343
+ return [], warnings
344
+ body = sec.group(1)
345
+ m = re.search(r"install_requires\s*=\s*((?:\n[ \t]+\S.*)+)", body)
346
+ if not m:
347
+ return [], warnings
348
+ deps: list[Dep] = []
349
+ for line in m.group(1).splitlines():
350
+ entry = line.strip()
351
+ if not entry:
352
+ continue
353
+ parsed = _parse_pep508_or_pip(entry)
354
+ if parsed:
355
+ deps.append(parsed)
356
+ return deps, warnings
357
+
358
+
359
+ def parse_pipfile(text: str) -> tuple[list[Dep], list[str]]:
360
+ """Pipfile is TOML — read `[packages]` (skip `[dev-packages]`)."""
361
+ warnings: list[str] = []
362
+ try:
363
+ data = tomllib.loads(text)
364
+ except tomllib.TOMLDecodeError as exc:
365
+ return [], [f"Pipfile: TOML parse failed ({exc})"]
366
+ deps_raw = data.get("packages")
367
+ if not isinstance(deps_raw, dict):
368
+ return [], warnings
369
+ deps: list[Dep] = []
370
+ for name, spec in deps_raw.items():
371
+ if not isinstance(name, str):
372
+ continue
373
+ if isinstance(spec, str):
374
+ deps.append({"name": name, "version": spec if spec != "*" else None})
375
+ elif isinstance(spec, dict):
376
+ ver = spec.get("version") if isinstance(spec.get("version"), str) else None
377
+ deps.append({"name": name, "version": ver if ver and ver != "*" else None})
378
+ else:
379
+ deps.append({"name": name, "version": None})
380
+ return deps, warnings
381
+
382
+
383
+ def parse_cargo_toml(text: str) -> tuple[list[Dep], list[str]]:
384
+ """Extract `[dependencies]` (skip `[dev-dependencies]`) from Cargo.toml."""
385
+ warnings: list[str] = []
386
+ try:
387
+ data = tomllib.loads(text)
388
+ except tomllib.TOMLDecodeError as exc:
389
+ return [], [f"Cargo.toml: TOML parse failed ({exc})"]
390
+ deps_raw = data.get("dependencies")
391
+ if not isinstance(deps_raw, dict):
392
+ return [], warnings
393
+ deps: list[Dep] = []
394
+ for name, spec in deps_raw.items():
395
+ if not isinstance(name, str):
396
+ continue
397
+ if isinstance(spec, str):
398
+ deps.append({"name": name, "version": spec})
399
+ elif isinstance(spec, dict):
400
+ ver = spec.get("version") if isinstance(spec.get("version"), str) else None
401
+ deps.append({"name": name, "version": ver})
402
+ else:
403
+ deps.append({"name": name, "version": None})
404
+ return deps, warnings
405
+
406
+
407
+ _GO_REQUIRE_LINE = re.compile(
408
+ r"^\s*(?P<name>[A-Za-z0-9_./\-]+)\s+(?P<version>v[\w.\-+]+|[\w.\-+]+)\s*(?://.*)?$"
409
+ )
410
+
411
+
412
+ def parse_go_mod(text: str) -> tuple[list[Dep], list[str]]:
413
+ """Extract `require (...)` and single-line `require` entries from go.mod."""
414
+ warnings: list[str] = []
415
+ deps: list[Dep] = []
416
+ # Single-line: `require <module> <version>`
417
+ for line in text.splitlines():
418
+ m = re.match(
419
+ r"^\s*require\s+(?P<name>[A-Za-z0-9_./\-]+)\s+(?P<version>v[\w.\-+]+|[\w.\-+]+)",
420
+ line,
421
+ )
422
+ if m:
423
+ deps.append({"name": m.group("name"), "version": m.group("version")})
424
+
425
+ # Block: `require ( ... )`
426
+ for block in re.finditer(r"require\s*\(\s*(.*?)\s*\)", text, re.DOTALL):
427
+ body = block.group(1)
428
+ for line in body.splitlines():
429
+ stripped = line.strip()
430
+ if not stripped or stripped.startswith("//"):
431
+ continue
432
+ # exclude "// indirect" suffix from version
433
+ m = _GO_REQUIRE_LINE.match(stripped)
434
+ if m:
435
+ deps.append({"name": m.group("name"), "version": m.group("version")})
436
+ return deps, warnings
437
+
438
+
439
+ def parse_pom_xml(text: str) -> tuple[list[Dep], list[str]]:
440
+ """Best-effort regex extraction of <dependency>...</dependency> blocks."""
441
+ warnings: list[str] = []
442
+ deps: list[Dep] = []
443
+ for block in re.finditer(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
444
+ body = block.group(1)
445
+ scope_m = re.search(r"<scope>\s*(.*?)\s*</scope>", body)
446
+ # skip test/provided/system scopes — runtime + compile + (no-scope) are production
447
+ if scope_m and scope_m.group(1).strip().lower() in {"test", "provided", "system"}:
448
+ continue
449
+ gid = re.search(r"<groupId>\s*(.*?)\s*</groupId>", body)
450
+ aid = re.search(r"<artifactId>\s*(.*?)\s*</artifactId>", body)
451
+ ver = re.search(r"<version>\s*(.*?)\s*</version>", body)
452
+ if not gid or not aid:
453
+ continue
454
+ name = f"{gid.group(1).strip()}:{aid.group(1).strip()}"
455
+ version = ver.group(1).strip() if ver else None
456
+ deps.append({"name": name, "version": version})
457
+ return deps, warnings
458
+
459
+
460
+ _GRADLE_DEP = re.compile(
461
+ r"""(?P<scope>implementation|api|compile|runtimeOnly)
462
+ \s*\(?\s*
463
+ (?:['"])
464
+ (?P<coord>[^'"]+)
465
+ (?:['"])
466
+ """,
467
+ re.VERBOSE,
468
+ )
469
+
470
+
471
+ def parse_gradle(text: str) -> tuple[list[Dep], list[str]]:
472
+ """Extract `implementation/api/compile/runtimeOnly` coords from a Gradle build script."""
473
+ warnings: list[str] = []
474
+ deps: list[Dep] = []
475
+ for m in _GRADLE_DEP.finditer(text):
476
+ coord = m.group("coord")
477
+ parts = coord.split(":")
478
+ if len(parts) == 2:
479
+ name, version = parts[0] + ":" + parts[1], None
480
+ deps.append({"name": name, "version": version})
481
+ elif len(parts) >= 3:
482
+ name = parts[0] + ":" + parts[1]
483
+ version = parts[2]
484
+ deps.append({"name": name, "version": version})
485
+ return deps, warnings
486
+
487
+
488
+ _GEMFILE_LINE = re.compile(
489
+ r"""^\s*gem\s+
490
+ (?:['"])(?P<name>[^'"]+)(?:['"])
491
+ (?:\s*,\s*(?:['"])(?P<version>[^'"]+)(?:['"]))?
492
+ """,
493
+ re.VERBOSE,
494
+ )
495
+
496
+
497
+ def parse_gemfile(text: str) -> tuple[list[Dep], list[str]]:
498
+ """Extract `gem 'name', 'version'` entries from a Gemfile."""
499
+ warnings: list[str] = []
500
+ deps: list[Dep] = []
501
+ # naive: skip lines inside `group :development|:test do ... end` blocks
502
+ skip_depth = 0
503
+ for raw_line in text.splitlines():
504
+ line = raw_line.split("#", 1)[0] # strip comments
505
+ if re.search(r"group\s+:(development|test)\b", line):
506
+ skip_depth += 1
507
+ continue
508
+ if skip_depth > 0:
509
+ if re.search(r"\bend\b", line):
510
+ skip_depth -= 1
511
+ continue
512
+ m = _GEMFILE_LINE.match(line)
513
+ if m:
514
+ deps.append({"name": m.group("name"), "version": m.group("version")})
515
+ return deps, warnings
516
+
517
+
518
+ def parse_composer_json(text: str) -> tuple[list[Dep], list[str]]:
519
+ """Extract `require` (skip `require-dev`) from a composer.json."""
520
+ warnings: list[str] = []
521
+ try:
522
+ data = json.loads(text)
523
+ except json.JSONDecodeError as exc:
524
+ return [], [f"composer.json: JSON parse failed ({exc.msg})"]
525
+ if not isinstance(data, dict):
526
+ return [], ["composer.json: top-level is not an object"]
527
+ deps_raw = data.get("require")
528
+ if deps_raw is None:
529
+ return [], warnings
530
+ if not isinstance(deps_raw, dict):
531
+ return [], ["composer.json: `require` is not an object"]
532
+ deps: list[Dep] = []
533
+ for name, version in deps_raw.items():
534
+ if not isinstance(name, str):
535
+ continue
536
+ # skip platform / php constraints
537
+ if name.lower() == "php" or name.startswith("ext-"):
538
+ continue
539
+ deps.append(
540
+ {"name": name, "version": version if isinstance(version, str) else None}
541
+ )
542
+ return deps, warnings
543
+
544
+
545
+ _SWIFT_PACKAGE = re.compile(
546
+ r"""\.package\s*\(\s*url\s*:\s*(?:['"])(?P<url>[^'"]+)(?:['"])
547
+ (?:[^)]*?from\s*:\s*(?:['"])(?P<version>[^'"]+)(?:['"]))?
548
+ """,
549
+ re.VERBOSE | re.DOTALL,
550
+ )
551
+
552
+
553
+ def parse_package_swift(text: str) -> tuple[list[Dep], list[str]]:
554
+ """Extract `.package(url:..., from:...)` entries from a Package.swift."""
555
+ warnings: list[str] = []
556
+ deps: list[Dep] = []
557
+ for m in _SWIFT_PACKAGE.finditer(text):
558
+ url = m.group("url")
559
+ # derive name from final path segment, trim trailing `.git`
560
+ name = url.rstrip("/").rsplit("/", 1)[-1]
561
+ if name.endswith(".git"):
562
+ name = name[: -len(".git")]
563
+ version = m.group("version")
564
+ deps.append({"name": name, "version": version})
565
+ return deps, warnings
566
+
567
+
568
+ PARSERS = {
569
+ "package.json": parse_package_json,
570
+ "pyproject.toml": parse_pyproject_toml,
571
+ "requirements.txt": parse_requirements_txt,
572
+ "setup.py": parse_setup_py,
573
+ "setup.cfg": parse_setup_cfg,
574
+ "Pipfile": parse_pipfile,
575
+ "Cargo.toml": parse_cargo_toml,
576
+ "go.mod": parse_go_mod,
577
+ "pom.xml": parse_pom_xml,
578
+ "build.gradle": parse_gradle,
579
+ "build.gradle.kts": parse_gradle,
580
+ "Gemfile": parse_gemfile,
581
+ "composer.json": parse_composer_json,
582
+ "Package.swift": parse_package_swift,
583
+ }
584
+
585
+
586
+ # --------------------------------------------------------------------------
587
+ # Aggregation
588
+ # --------------------------------------------------------------------------
589
+
590
+
591
+ def _parse_manifest(path: Path) -> tuple[list[Dep], list[str]]:
592
+ """Read and parse a single manifest file. Returns (deps, warnings)."""
593
+ parser = PARSERS.get(path.name)
594
+ if parser is None:
595
+ return [], [f"{path.name}: no parser registered"]
596
+ text = _safe_read_text(path)
597
+ if text is None:
598
+ return [], [f"{path.name}: file unreadable"]
599
+ try:
600
+ return parser(text)
601
+ except Exception as exc: # noqa: BLE001 — best-effort parsing
602
+ return [], [f"{path.name}: parser raised {type(exc).__name__}: {exc}"]
603
+
604
+
605
+ def _is_monorepo(manifests: list[dict]) -> bool:
606
+ """True if any ecosystem has >1 manifest at non-overlapping depths.
607
+
608
+ "Non-overlapping" means two manifest paths don't share a strict parent-
609
+ child relationship. Two `package.json` at `./package.json` and
610
+ `./packages/foo/package.json` ARE overlapping (the second is under the
611
+ first), but `./packages/foo/package.json` and `./packages/bar/package.json`
612
+ are NOT overlapping siblings — that's the monorepo signal.
613
+ """
614
+ by_ecosystem: dict[str, list[str]] = {}
615
+ for m in manifests:
616
+ by_ecosystem.setdefault(m["ecosystem"], []).append(m["path"])
617
+
618
+ for paths in by_ecosystem.values():
619
+ if len(paths) < 2:
620
+ continue
621
+ # collect parent directories; two manifests overlap iff one's parent
622
+ # dir is a prefix of the other's parent dir
623
+ parents = [p.rsplit("/", 1)[0] if "/" in p else "" for p in paths]
624
+ for i, a in enumerate(parents):
625
+ for j, b in enumerate(parents):
626
+ if i >= j:
627
+ continue
628
+ if _path_is_ancestor(a, b) or _path_is_ancestor(b, a):
629
+ continue
630
+ # found a non-overlapping pair — monorepo
631
+ return True
632
+ return False
633
+
634
+
635
+ def _path_is_ancestor(a: str, b: str) -> bool:
636
+ """True if `a` is a strict ancestor of `b` (or equal). Both POSIX-style."""
637
+ if a == b:
638
+ return True
639
+ if a == "":
640
+ return True # root is ancestor of everything
641
+ return b.startswith(a + "/")
642
+
643
+
644
+ def scan(root: Path) -> dict:
645
+ """Run a full manifest scan rooted at `root`."""
646
+ paths = find_manifests(root)
647
+ manifests: list[dict] = []
648
+ warnings: list[str] = []
649
+
650
+ for path in paths:
651
+ deps, warns = _parse_manifest(path)
652
+ rel = path.relative_to(root).as_posix()
653
+ for w in warns:
654
+ warnings.append(f"{rel}: {w}")
655
+ manifests.append(
656
+ {
657
+ "path": rel,
658
+ "ecosystem": MANIFEST_ECOSYSTEMS[path.name],
659
+ "deps": deps,
660
+ }
661
+ )
662
+
663
+ unique_names: set[str] = set()
664
+ for m in manifests:
665
+ for dep in m["deps"]:
666
+ name = dep.get("name")
667
+ if isinstance(name, str) and name and name != "<unparsable>":
668
+ unique_names.add(name)
669
+
670
+ result: dict = {
671
+ "manifests": manifests,
672
+ "total_unique": len(unique_names),
673
+ "monorepo": _is_monorepo(manifests),
674
+ }
675
+ if warnings:
676
+ result["warnings"] = warnings
677
+ return result
678
+
679
+
680
+ # --------------------------------------------------------------------------
681
+ # CLI
682
+ # --------------------------------------------------------------------------
683
+
684
+
685
+ def _cmd_scan(args: argparse.Namespace) -> int:
686
+ root = Path(args.root)
687
+ if not root.is_dir():
688
+ print(f"error: root not a directory: {root}", file=sys.stderr)
689
+ return 1
690
+ if args.ecosystems != "auto":
691
+ print(
692
+ f"error: --ecosystems supports only 'auto' today; got {args.ecosystems!r}",
693
+ file=sys.stderr,
694
+ )
695
+ return 1
696
+ try:
697
+ result = scan(root)
698
+ except OSError as exc:
699
+ print(f"error: filesystem error during scan: {exc}", file=sys.stderr)
700
+ return 1
701
+ json.dump(result, sys.stdout, indent=2)
702
+ sys.stdout.write("\n")
703
+ return 0
704
+
705
+
706
+ def _build_parser() -> argparse.ArgumentParser:
707
+ parser = argparse.ArgumentParser(
708
+ prog="skf-scan-manifests",
709
+ description=(
710
+ "Scan a project root for dependency manifests, parse each, and emit "
711
+ "a deduplicated JSON envelope describing the dep set + monorepo flag."
712
+ ),
713
+ )
714
+ sub = parser.add_subparsers(dest="cmd", required=True)
715
+
716
+ p_scan = sub.add_parser(
717
+ "scan",
718
+ help="walk root for recognised manifests and emit JSON",
719
+ )
720
+ p_scan.add_argument("root", help="path to project root")
721
+ p_scan.add_argument(
722
+ "--ecosystems",
723
+ default="auto",
724
+ help="ecosystem filter (currently only 'auto' is accepted)",
725
+ )
726
+ p_scan.set_defaults(func=_cmd_scan)
727
+
728
+ return parser
729
+
730
+
731
+ def main(argv: Iterable[str] | None = None) -> int:
732
+ parser = _build_parser()
733
+ args = parser.parse_args(list(argv) if argv is not None else None)
734
+ return args.func(args)
735
+
736
+
737
+ if __name__ == "__main__":
738
+ raise SystemExit(main())