bmad-module-skill-forge 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -17,9 +17,11 @@ the layout looks like a monorepo.
17
17
  ecosystem table this script implements.
18
18
 
19
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.
20
+ manifest files in the broader project-scan pass: its `candidates[]`
21
+ are the candidate boundaries, its `services[]` lists the Docker,
22
+ compose and serverless files, whose folders are candidate boundaries
23
+ too, and each manifest's `version` is the version a brief records
24
+ (step-auto-scope and generate-briefs read it).
23
25
 
24
26
  Pulling this into a deterministic script gives both stages identical
25
27
  manifest-set semantics (no LLM drift on what counts as a manifest), a stable
@@ -27,63 +29,173 @@ JSON envelope for the LLM to consume, and a single place to evolve the
27
29
  ecosystem matrix as new languages land.
28
30
 
29
31
  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:
32
+ scan <root> [--ecosystems=auto] [--include-dev] [--workspaces-file <file>]
33
+ Walk `<root>` (every depth, pruning the excluded and hidden
34
+ directories) for recognised manifests, parse each one and emit:
34
35
  {
35
36
  "manifests": [
36
37
  {
37
38
  "path": "<rel-from-root, forward-slash>",
38
39
  "ecosystem": "<name>",
39
- "deps": [{"name": "...", "version": "...|null"}]
40
+ "name": "<the package's own name>" | null,
41
+ "private": true | false | null,
42
+ "version": "<the package's own version>" | null,
43
+ "version_dynamic": true | false,
44
+ "description": "<the package's own description>" | null,
45
+ "deps": [{"name": "...", "version": "...|null"}],
46
+ "internal_deps": ["<name of another scanned manifest>", ...]
40
47
  },
41
48
  ...
42
49
  ],
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
50
+ "total_unique": N, // unique runtime dep names across all manifests
51
+ "monorepo": <bool>, // >1 manifest of same ecosystem at non-overlapping depths
52
+ "folders": [ // each folder holding a manifest ("." for <root>), sorted
53
+ {"path": "apps/web", "names": ["web"]}, // the package names its manifests give
54
+ ...
55
+ ],
56
+ "services": ["deploy/docker-compose.yml", ...], // each service definition, sorted
57
+ "candidates": [ // the candidate boundaries, sorted by path
58
+ {"path": "apps/web", "names": ["web"], "manifests": ["apps/web/package.json"],
59
+ "services": ["apps/web/Dockerfile"], "own_source_files": N,
60
+ "workspace_member": true | false | null},
61
+ ...
62
+ ],
63
+ "umbrella_candidates": [
64
+ {"path": "...", "name": "...", "ecosystem": "...",
65
+ "internal_dep_count": N, "member_count": M},
66
+ ...
67
+ ],
68
+ "searched_filenames": ["package.json", ...], // every manifest name looked for
69
+ "warnings": ["..."] // optional: only present if any warning was emitted
46
70
  }
47
71
  Default `--ecosystems=auto` detects all supported ecosystems. The flag
48
- is currently a placeholder for future filtering — `auto` is the only
72
+ is currently a placeholder for future filtering: `auto` is the only
49
73
  accepted value today, but its presence keeps the CLI shape stable.
50
74
 
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
75
+ `--include-dev` also lists each manifest's development dependencies in
76
+ its `deps[]`, each tagged `"scope": "dev"` (a dependency with no
77
+ `scope` is a runtime one; a name the manifest already lists as runtime
78
+ is not repeated), and adds `total_unique_dev`: the unique names that
79
+ appear only as dev dependencies. Without the flag the output holds
80
+ runtime dependencies only, as before.
81
+
82
+ `--workspaces-file` reads the JSON skf-detect-workspaces.py printed
83
+ for the same root (its `--snapshot` output too): each candidate's
84
+ `workspace_member` says whether its folder is one of that JSON's
85
+ `workspaces[]` paths. Without it `workspace_member` is null.
86
+
87
+ Manifest fields:
88
+ name the name the manifest gives its own package: package.json,
89
+ composer.json and pyproject.toml ([project] or
90
+ [tool.poetry]) `name`, setup.py and setup.cfg `name`, Cargo
91
+ [package] `name`, the go.mod `module` path, pom.xml
92
+ `groupId:artifactId` (the group inherited from <parent> when
93
+ absent), Package.swift `Package(name:)`; null for
94
+ requirements.txt, Pipfile, Gemfile and Gradle build scripts,
95
+ which name no package.
96
+ private true when the manifest says the package is not published:
97
+ package.json `"private": true`, Cargo `publish = false` (or
98
+ `[]`), the `Private :: Do Not Upload` classifier or Poetry
99
+ `package-mode = false`, pom.xml `maven.deploy.skip`; false
100
+ when the manifest could set such a flag and does not; null
101
+ when it cannot tell (go.mod, composer.json, Package.swift,
102
+ the formats that name no package, a Cargo virtual workspace
103
+ or a Cargo `publish` inherited from the workspace).
104
+ version the version the manifest gives its own package, as it is
105
+ written (no semver check): package.json and composer.json
106
+ `version`, pyproject.toml `[project]` (or `[tool.poetry]`)
107
+ `version`, setup.py and setup.cfg `version` when it is a
108
+ literal, Cargo `[package] version` (a `version.workspace =
109
+ true` read from the `[workspace.package] version` of the
110
+ nearest Cargo.toml above it that has one), pom.xml
111
+ `<version>` (the parent's when absent), a Gradle build
112
+ script's `version = '...'`; null when it gives none (go.mod,
113
+ Package.swift and the formats that name no package).
114
+ version_dynamic true when the manifest says the version is computed at
115
+ build time, so it is not in the manifest: pyproject.toml
116
+ `dynamic = ["version"]`, a setup.cfg `attr:` or `file:`
117
+ version, a setup.py `version=` that is not a literal.
118
+ description the one-line description the manifest gives its package:
119
+ package.json and composer.json `description`,
120
+ pyproject.toml `[project]` (or `[tool.poetry]`)
121
+ `description`, setup.py and setup.cfg `description` when
122
+ it is a literal, Cargo `[package] description` (null for
123
+ one inherited from the workspace), pom.xml `<description>`,
124
+ a Gradle build script's `description = '...'`; else null.
125
+ internal_deps the runtime dependencies that name another scanned manifest
126
+ of the same ecosystem (the workspace members this one uses),
127
+ as those members spell their names, sorted. Names compare
128
+ as the ecosystem does (PEP 503 for Python, `-` and `_` alike
129
+ for Rust, case-insensitive for Composer and Swift), and a
130
+ pom.xml `${project.groupId}` resolves to its own group.
131
+
132
+ services lists the service definitions the walk finds, by path from the
133
+ root: a Docker, compose or serverless file (is_service_definition, which
134
+ skf-disqualify-candidates.py's `service_definition` signal reads too).
135
+
136
+ candidates lists the folders a project scan proposes as boundaries (Candidate
137
+ boundaries in skf-analyze-source's unit-detection-heuristics.md names this
138
+ list), each folder of `folders[]` and each folder that holds a `services[]`
139
+ file, less those a workspace only gathers: a folder is left out when it
140
+ holds no source file of its own (`own_source_files`, the source files under
141
+ it outside every other such folder below it) and another such folder lies
142
+ below it. So a workspace root such as a private package.json that lists
143
+ `workspaces`, or a Cargo virtual workspace, with no code of its own is left
144
+ out, and so is `.` unless it holds code of its own or nothing else is
145
+ proposed. A source file is one skf-detect-language.py's is_source_file()
146
+ counts (the sibling in this folder, which skf-detect-workspaces.py's snapshot
147
+ reads too). Each record gives the folder's `names` (as `folders[]`), the
148
+ `manifests` and `services` files directly in it, `own_source_files` and
149
+ `workspace_member` (see --workspaces-file).
150
+
151
+ umbrella_candidates lists the published manifests (named, `private` not
152
+ true) whose internal_deps cover at least 2 of the other published members of
153
+ their ecosystem, and at least half of them: a root or facade package that
154
+ depends on the members it would re-export. A private package (an example or
155
+ an app) is never a candidate, since a facade is the public surface. Sorted by
156
+ internal_dep_count (highest first), then path. It is a signal for the
157
+ cohesion judgment, not a verdict.
158
+
159
+ Supported ecosystems (manifest filename, then ecosystem): the
160
+ `MANIFEST_ECOSYSTEMS` table below. `references/manifest-patterns.md` in
161
+ skf-create-stack-skill documents it, and test/test-skf-scan-manifests.py
162
+ fails when the two drift.
66
163
 
67
164
  Parsing approach: JSON manifests via stdlib `json`, TOML via stdlib
68
165
  `tomllib` (3.11+); text manifests (requirements.txt, Pipfile, setup.py,
69
166
  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).
167
+ ad-hoc regex extraction. `deps[]` holds production dependencies by default,
168
+ so the consumer-facing list stays focused on runtime libraries. The
169
+ development sections read with `--include-dev`: package.json
170
+ `devDependencies`; pyproject.toml `[dependency-groups]`, the
171
+ `[project.optional-dependencies]` extras named dev, develop, development,
172
+ test, tests, testing, lint, docs or doc, `[tool.poetry.dev-dependencies]`,
173
+ every `[tool.poetry.group.<name>.dependencies]`,
174
+ `[tool.pdm.dev-dependencies]` and `[tool.uv] dev-dependencies`; setup.py and
175
+ setup.cfg `tests_require` and those same extras; Pipfile `[dev-packages]`;
176
+ Cargo `[dev-dependencies]`; pom.xml `<scope>test</scope>`; Gradle `test*`
177
+ and `androidTest*` configurations; Gemfile gems inside a `group
178
+ :development` or `:test` block; composer.json `require-dev`. requirements.txt,
179
+ go.mod and Package.swift have no development section. Unparseable manifests
180
+ emit a top-level `warnings[]` entry and the manifest still appears in
181
+ `manifests[]` with whatever deps were salvageable (possibly empty).
75
182
 
76
183
  Exit codes:
77
- 0 — success (including: zero manifests found — emit empty result)
78
- 1 — user error (bad root path, root not a directory)
184
+ 0 success (including: zero manifests found, which emits an empty result)
185
+ 1 user error (bad root path, root not a directory, a --workspaces-file
186
+ that cannot be read as JSON), or skf-detect-language.py missing beside
187
+ this script
79
188
 
80
189
  CLI example:
81
190
  uv run skf-scan-manifests.py scan /path/to/repo
191
+ uv run skf-scan-manifests.py scan /path/to/repo --include-dev
192
+ uv run skf-scan-manifests.py scan /path/to/repo --workspaces-file snapshot.json
82
193
  """
83
194
 
84
195
  from __future__ import annotations
85
196
 
86
197
  import argparse
198
+ import importlib.util
87
199
  import json
88
200
  import re
89
201
  import sys
@@ -101,6 +213,9 @@ from typing import Iterable
101
213
  # (we test each name in order, but every match is recorded). Lowercase compare
102
214
  # is NOT used — manifest filenames are case-sensitive on POSIX, and Windows
103
215
  # CI normalises case during the FS walk anyway.
216
+ # The same order is `searched_filenames`. The Supported Ecosystems table in
217
+ # skf-create-stack-skill's references/manifest-patterns.md mirrors this dict
218
+ # (test/test-skf-scan-manifests.py keeps the two equal).
104
219
  MANIFEST_ECOSYSTEMS: dict[str, str] = {
105
220
  "package.json": "npm",
106
221
  "pyproject.toml": "python",
@@ -119,7 +234,8 @@ MANIFEST_ECOSYSTEMS: dict[str, str] = {
119
234
  }
120
235
 
121
236
  # Directories the scan must never descend into. Mirrors
122
- # references/manifest-patterns.md "Scan Exclusion Patterns".
237
+ # references/manifest-patterns.md "Scan Exclusion Patterns"
238
+ # (test/test-skf-scan-manifests.py keeps the two equal).
123
239
  EXCLUDED_DIRS: frozenset[str] = frozenset(
124
240
  {
125
241
  "node_modules",
@@ -156,13 +272,56 @@ def _is_excluded_dir(name: str) -> bool:
156
272
  return False
157
273
 
158
274
 
275
+ # A service definition: a Docker, compose or serverless file, by its
276
+ # lower-cased name, and a `Dockerfile.<x>` or `<x>.dockerfile` variant.
277
+ SERVICE_DEFINITION_NAMES: frozenset[str] = frozenset({
278
+ "dockerfile", "containerfile", "docker-compose.yml", "docker-compose.yaml",
279
+ "compose.yml", "compose.yaml", "serverless.yml", "serverless.yaml",
280
+ })
281
+
282
+
283
+ def is_service_definition(name: str) -> bool:
284
+ """True for the file name of a service definition (SERVICE_DEFINITION_NAMES)."""
285
+ lname = name.lower()
286
+ return (
287
+ lname in SERVICE_DEFINITION_NAMES
288
+ or lname.startswith("dockerfile.")
289
+ or lname.endswith(".dockerfile")
290
+ )
291
+
292
+
293
+ _DETECT_LANGUAGE = None
294
+
295
+
296
+ def _is_source_file(name: str) -> bool:
297
+ """skf-detect-language.py's is_source_file(), the sibling beside this
298
+ script, loaded once. Raises ImportError when it cannot be loaded."""
299
+ global _DETECT_LANGUAGE
300
+ if _DETECT_LANGUAGE is None:
301
+ path = Path(__file__).resolve().parent / "skf-detect-language.py"
302
+ spec = importlib.util.spec_from_file_location("skf_detect_language", path)
303
+ if spec is None or spec.loader is None or not path.is_file():
304
+ raise ImportError(f"cannot load {path.name} beside {Path(__file__).name}")
305
+ module = importlib.util.module_from_spec(spec)
306
+ spec.loader.exec_module(module)
307
+ _DETECT_LANGUAGE = module
308
+ return _DETECT_LANGUAGE.is_source_file(name)
309
+
310
+
159
311
  def find_manifests(root: Path) -> list[Path]:
160
312
  """Walk `root` returning every recognised manifest file.
161
313
 
162
314
  Excludes `EXCLUDED_DIRS` and all hidden directories. Returns absolute
163
315
  paths in stable sorted order so caller output is reproducible.
164
316
  """
317
+ return _walk(root)[0]
318
+
319
+
320
+ def _walk(root: Path, sources: list[str] | None = None) -> tuple[list[Path], list[Path]]:
321
+ """(manifests, service definitions) under `root`, as find_manifests
322
+ walks; each source file's path from `root` joins `sources` when given."""
165
323
  found: list[Path] = []
324
+ services: list[Path] = []
166
325
  # Iterative BFS so we can prune at the directory level.
167
326
  stack: list[Path] = [root]
168
327
  while stack:
@@ -179,10 +338,15 @@ def find_manifests(root: Path) -> list[Path]:
179
338
  stack.append(entry)
180
339
  elif entry.is_file() and entry.name in MANIFEST_ECOSYSTEMS:
181
340
  found.append(entry)
341
+ elif entry.is_file() and is_service_definition(entry.name):
342
+ services.append(entry)
343
+ elif sources is not None and entry.is_file() and _is_source_file(entry.name):
344
+ sources.append(entry.relative_to(root).as_posix())
182
345
  except OSError:
183
346
  continue
184
347
  found.sort()
185
- return found
348
+ services.sort()
349
+ return found, services
186
350
 
187
351
 
188
352
  # --------------------------------------------------------------------------
@@ -583,23 +747,672 @@ PARSERS = {
583
747
  }
584
748
 
585
749
 
750
+ # --------------------------------------------------------------------------
751
+ # Development dependencies (--include-dev)
752
+ # --------------------------------------------------------------------------
753
+
754
+
755
+ # Optional-dependency extras read as development dependencies.
756
+ DEV_EXTRAS: frozenset[str] = frozenset(
757
+ {"dev", "develop", "development", "test", "tests", "testing", "lint", "docs", "doc"}
758
+ )
759
+
760
+
761
+ def _table_deps(
762
+ table: object, *, skip_python: bool = False, star_as_none: bool = False
763
+ ) -> list[Dep]:
764
+ """Deps from a TOML or JSON table of name -> version string or spec table.
765
+
766
+ `skip_python` drops Poetry's `python` constraint; `star_as_none` reads a
767
+ `*` version as no version, as Pipfile does.
768
+ """
769
+ if not isinstance(table, dict):
770
+ return []
771
+ deps: list[Dep] = []
772
+ for name, spec in table.items():
773
+ if not isinstance(name, str) or (skip_python and name.lower() == "python"):
774
+ continue
775
+ if isinstance(spec, dict):
776
+ spec = spec.get("version")
777
+ version = spec if isinstance(spec, str) else None
778
+ if star_as_none and version == "*":
779
+ version = None
780
+ deps.append({"name": name, "version": version})
781
+ return deps
782
+
783
+
784
+ def _requirement_list(entries: object) -> list[Dep]:
785
+ """Deps from a list of PEP 508 strings (tables such as include-group skipped)."""
786
+ if not isinstance(entries, list):
787
+ return []
788
+ deps: list[Dep] = []
789
+ for entry in entries:
790
+ if isinstance(entry, str):
791
+ parsed = _parse_pep508_or_pip(entry)
792
+ if parsed:
793
+ deps.append(parsed)
794
+ return deps
795
+
796
+
797
+ def dev_package_json(text: str) -> list[Dep]:
798
+ try:
799
+ data = json.loads(text)
800
+ except json.JSONDecodeError:
801
+ return []
802
+ return _table_deps(data.get("devDependencies")) if isinstance(data, dict) else []
803
+
804
+
805
+ def dev_pyproject_toml(text: str) -> list[Dep]:
806
+ try:
807
+ data = tomllib.loads(text)
808
+ except tomllib.TOMLDecodeError:
809
+ return []
810
+ deps: list[Dep] = []
811
+ groups = data.get("dependency-groups")
812
+ if isinstance(groups, dict):
813
+ for entries in groups.values():
814
+ deps.extend(_requirement_list(entries))
815
+ project = data.get("project")
816
+ extras = project.get("optional-dependencies") if isinstance(project, dict) else None
817
+ if isinstance(extras, dict):
818
+ for extra, entries in extras.items():
819
+ if isinstance(extra, str) and extra.lower() in DEV_EXTRAS:
820
+ deps.extend(_requirement_list(entries))
821
+ tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
822
+ poetry = tool.get("poetry")
823
+ if isinstance(poetry, dict):
824
+ deps.extend(_table_deps(poetry.get("dev-dependencies"), skip_python=True))
825
+ poetry_groups = poetry.get("group")
826
+ if isinstance(poetry_groups, dict):
827
+ for group in poetry_groups.values():
828
+ if isinstance(group, dict):
829
+ deps.extend(_table_deps(group.get("dependencies"), skip_python=True))
830
+ pdm = tool.get("pdm")
831
+ if isinstance(pdm, dict) and isinstance(pdm.get("dev-dependencies"), dict):
832
+ for entries in pdm["dev-dependencies"].values():
833
+ deps.extend(_requirement_list(entries))
834
+ uv = tool.get("uv")
835
+ if isinstance(uv, dict):
836
+ deps.extend(_requirement_list(uv.get("dev-dependencies")))
837
+ return deps
838
+
839
+
840
+ def dev_setup_py(text: str) -> list[Dep]:
841
+ deps: list[Dep] = []
842
+ m = re.search(r"tests_require\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
843
+ if m:
844
+ for match in re.finditer(r"""(['"])([^'"]+)\1""", m.group(1)):
845
+ parsed = _parse_pep508_or_pip(match.group(2).strip())
846
+ if parsed:
847
+ deps.append(parsed)
848
+ extras = re.search(r"extras_require\s*=\s*\{(.*?)\}", text, re.DOTALL)
849
+ if extras:
850
+ for group in re.finditer(
851
+ r"""(['"])([\w.-]+)\1\s*:\s*\[([^\]]*)\]""", extras.group(1)
852
+ ):
853
+ if group.group(2).lower() not in DEV_EXTRAS:
854
+ continue
855
+ for match in re.finditer(r"""(['"])([^'"]+)\1""", group.group(3)):
856
+ parsed = _parse_pep508_or_pip(match.group(2).strip())
857
+ if parsed:
858
+ deps.append(parsed)
859
+ return deps
860
+
861
+
862
+ def _cfg_section(text: str, name: str) -> str:
863
+ sec = re.search(
864
+ rf"^\[{re.escape(name)}\][ \t]*$(.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL
865
+ )
866
+ return sec.group(1) if sec else ""
867
+
868
+
869
+ def _cfg_values(value: str) -> list[Dep]:
870
+ deps: list[Dep] = []
871
+ for line in value.splitlines():
872
+ parsed = _parse_pep508_or_pip(line.strip())
873
+ if parsed:
874
+ deps.append(parsed)
875
+ return deps
876
+
877
+
878
+ # `key = value` in a setup.cfg section, with its indented continuation lines.
879
+ _CFG_OPTION = re.compile(
880
+ r"^(?P<key>[\w.-]+)[ \t]*=[ \t]*(?P<value>.*(?:\n[ \t]+\S.*)*)", re.MULTILINE
881
+ )
882
+
883
+
884
+ def dev_setup_cfg(text: str) -> list[Dep]:
885
+ deps: list[Dep] = []
886
+ for m in _CFG_OPTION.finditer(_cfg_section(text, "options")):
887
+ if m.group("key") == "tests_require":
888
+ deps.extend(_cfg_values(m.group("value")))
889
+ for m in _CFG_OPTION.finditer(_cfg_section(text, "options.extras_require")):
890
+ if m.group("key").lower() in DEV_EXTRAS:
891
+ deps.extend(_cfg_values(m.group("value")))
892
+ return deps
893
+
894
+
895
+ def dev_pipfile(text: str) -> list[Dep]:
896
+ try:
897
+ data = tomllib.loads(text)
898
+ except tomllib.TOMLDecodeError:
899
+ return []
900
+ return _table_deps(data.get("dev-packages"), star_as_none=True)
901
+
902
+
903
+ def dev_cargo_toml(text: str) -> list[Dep]:
904
+ try:
905
+ data = tomllib.loads(text)
906
+ except tomllib.TOMLDecodeError:
907
+ return []
908
+ return _table_deps(data.get("dev-dependencies"))
909
+
910
+
911
+ def dev_pom_xml(text: str) -> list[Dep]:
912
+ deps: list[Dep] = []
913
+ for block in re.finditer(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
914
+ body = block.group(1)
915
+ scope_m = re.search(r"<scope>\s*(.*?)\s*</scope>", body)
916
+ if not scope_m or scope_m.group(1).strip().lower() != "test":
917
+ continue
918
+ gid = re.search(r"<groupId>\s*(.*?)\s*</groupId>", body)
919
+ aid = re.search(r"<artifactId>\s*(.*?)\s*</artifactId>", body)
920
+ ver = re.search(r"<version>\s*(.*?)\s*</version>", body)
921
+ if gid and aid:
922
+ deps.append(
923
+ {
924
+ "name": f"{gid.group(1).strip()}:{aid.group(1).strip()}",
925
+ "version": ver.group(1).strip() if ver else None,
926
+ }
927
+ )
928
+ return deps
929
+
930
+
931
+ # Whitespace or `(` must follow the configuration, so the `"test"` inside
932
+ # `testImplementation(kotlin("test"))` never starts a match.
933
+ _GRADLE_DEV_DEP = re.compile(
934
+ r"""\b(?:test|androidTest)[A-Za-z]*
935
+ (?:\s+|\s*\(\s*)
936
+ (?:['"])
937
+ (?P<coord>[^'"\n]+)
938
+ (?:['"])
939
+ """,
940
+ re.VERBOSE,
941
+ )
942
+
943
+
944
+ def dev_gradle(text: str) -> list[Dep]:
945
+ deps: list[Dep] = []
946
+ for m in _GRADLE_DEV_DEP.finditer(text):
947
+ parts = m.group("coord").split(":")
948
+ if len(parts) >= 2:
949
+ deps.append(
950
+ {"name": f"{parts[0]}:{parts[1]}", "version": parts[2] if len(parts) >= 3 else None}
951
+ )
952
+ return deps
953
+
954
+
955
+ def dev_gemfile(text: str) -> list[Dep]:
956
+ """Gems inside `group :development` / `group :test` blocks."""
957
+ deps: list[Dep] = []
958
+ depth = 0
959
+ for raw_line in text.splitlines():
960
+ line = raw_line.split("#", 1)[0]
961
+ if re.search(r"group\s+:(development|test)\b", line):
962
+ depth += 1
963
+ continue
964
+ if depth > 0:
965
+ if re.search(r"\bend\b", line):
966
+ depth -= 1
967
+ continue
968
+ m = _GEMFILE_LINE.match(line)
969
+ if m:
970
+ deps.append({"name": m.group("name"), "version": m.group("version")})
971
+ return deps
972
+
973
+
974
+ def dev_composer_json(text: str) -> list[Dep]:
975
+ try:
976
+ data = json.loads(text)
977
+ except json.JSONDecodeError:
978
+ return []
979
+ if not isinstance(data, dict):
980
+ return []
981
+ return [
982
+ d
983
+ for d in _table_deps(data.get("require-dev"))
984
+ if d["name"].lower() != "php" and not d["name"].startswith("ext-")
985
+ ]
986
+
987
+
988
+ DEV_PARSERS = {
989
+ "package.json": dev_package_json,
990
+ "pyproject.toml": dev_pyproject_toml,
991
+ "setup.py": dev_setup_py,
992
+ "setup.cfg": dev_setup_cfg,
993
+ "Pipfile": dev_pipfile,
994
+ "Cargo.toml": dev_cargo_toml,
995
+ "pom.xml": dev_pom_xml,
996
+ "build.gradle": dev_gradle,
997
+ "build.gradle.kts": dev_gradle,
998
+ "Gemfile": dev_gemfile,
999
+ "composer.json": dev_composer_json,
1000
+ }
1001
+
1002
+
1003
+ # --------------------------------------------------------------------------
1004
+ # Package identity: the manifest's own name and publish flag
1005
+ # --------------------------------------------------------------------------
1006
+
1007
+
1008
+ PRIVATE_CLASSIFIER = "Private :: Do Not Upload"
1009
+
1010
+ Identity = tuple # (name: str | None, private: bool | None)
1011
+
1012
+
1013
+ def identity_package_json(text: str) -> Identity:
1014
+ try:
1015
+ data = json.loads(text)
1016
+ except json.JSONDecodeError:
1017
+ return None, None
1018
+ if not isinstance(data, dict):
1019
+ return None, None
1020
+ name = data.get("name") if isinstance(data.get("name"), str) and data.get("name") else None
1021
+ return name, data.get("private") is True
1022
+
1023
+
1024
+ def identity_pyproject_toml(text: str) -> Identity:
1025
+ try:
1026
+ data = tomllib.loads(text)
1027
+ except tomllib.TOMLDecodeError:
1028
+ return None, None
1029
+ project = data.get("project") if isinstance(data.get("project"), dict) else {}
1030
+ tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
1031
+ poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
1032
+ name = project.get("name") or poetry.get("name")
1033
+ name = name if isinstance(name, str) and name else None
1034
+ if not project and not poetry:
1035
+ return None, None
1036
+ classifiers = [
1037
+ c
1038
+ for source in (project.get("classifiers"), poetry.get("classifiers"))
1039
+ if isinstance(source, list)
1040
+ for c in source
1041
+ ]
1042
+ private = PRIVATE_CLASSIFIER in classifiers or poetry.get("package-mode") is False
1043
+ return name, private
1044
+
1045
+
1046
+ def identity_setup_py(text: str) -> Identity:
1047
+ call = re.search(r"\bsetup\s*\((.*)", text, re.DOTALL)
1048
+ m = re.search(r"""\bname\s*=\s*(['"])([^'"]+)\1""", call.group(1)) if call else None
1049
+ if not m:
1050
+ return None, None
1051
+ return m.group(2), PRIVATE_CLASSIFIER in text
1052
+
1053
+
1054
+ def identity_setup_cfg(text: str) -> Identity:
1055
+ metadata = _cfg_section(text, "metadata")
1056
+ m = re.search(r"^name[ \t]*=[ \t]*(\S+)", metadata, re.MULTILINE)
1057
+ if not m:
1058
+ return None, None
1059
+ return m.group(1), PRIVATE_CLASSIFIER in metadata
1060
+
1061
+
1062
+ def identity_cargo_toml(text: str) -> Identity:
1063
+ try:
1064
+ data = tomllib.loads(text)
1065
+ except tomllib.TOMLDecodeError:
1066
+ return None, None
1067
+ package = data.get("package")
1068
+ if not isinstance(package, dict):
1069
+ return None, None # a virtual workspace manifest names no package
1070
+ name = package.get("name") if isinstance(package.get("name"), str) else None
1071
+ publish = package.get("publish")
1072
+ if isinstance(publish, dict):
1073
+ return name, None # `publish.workspace = true`: set by the workspace root
1074
+ return name, publish is False or publish == []
1075
+
1076
+
1077
+ def identity_go_mod(text: str) -> Identity:
1078
+ m = re.search(r"""^\s*module\s+["`]?([^\s"`]+)""", text, re.MULTILINE)
1079
+ return (m.group(1) if m else None), None
1080
+
1081
+
1082
+ # Sections of a pom.xml whose <groupId>/<artifactId> are not the project's own.
1083
+ _POM_FOREIGN_SECTIONS = (
1084
+ "parent",
1085
+ "dependencies",
1086
+ "dependencyManagement",
1087
+ "build",
1088
+ "profiles",
1089
+ "reporting",
1090
+ "modules",
1091
+ )
1092
+
1093
+
1094
+ def identity_pom_xml(text: str) -> Identity:
1095
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
1096
+ parent = re.search(r"<parent>(.*?)</parent>", text, re.DOTALL)
1097
+ own = text
1098
+ for tag in _POM_FOREIGN_SECTIONS:
1099
+ own = re.sub(rf"<{tag}>.*?</{tag}>", "", own, flags=re.DOTALL)
1100
+ aid = re.search(r"<artifactId>\s*(.*?)\s*</artifactId>", own)
1101
+ if not aid:
1102
+ return None, None
1103
+ gid = re.search(r"<groupId>\s*(.*?)\s*</groupId>", own)
1104
+ if not gid and parent:
1105
+ gid = re.search(r"<groupId>\s*(.*?)\s*</groupId>", parent.group(1))
1106
+ name = f"{gid.group(1)}:{aid.group(1)}" if gid else aid.group(1)
1107
+ skip = re.search(r"<maven\.deploy\.skip>\s*true\s*</maven\.deploy\.skip>", own)
1108
+ return name, skip is not None
1109
+
1110
+
1111
+ def identity_composer_json(text: str) -> Identity:
1112
+ try:
1113
+ data = json.loads(text)
1114
+ except json.JSONDecodeError:
1115
+ return None, None
1116
+ if not isinstance(data, dict):
1117
+ return None, None
1118
+ name = data.get("name")
1119
+ return (name if isinstance(name, str) and name else None), None
1120
+
1121
+
1122
+ def identity_package_swift(text: str) -> Identity:
1123
+ m = re.search(r'\bPackage\s*\(\s*name\s*:\s*"([^"]+)"', text)
1124
+ return (m.group(1) if m else None), None
1125
+
1126
+
1127
+ # --------------------------------------------------------------------------
1128
+ # Package version: the version the manifest gives its own package
1129
+ # --------------------------------------------------------------------------
1130
+
1131
+
1132
+ # (version, how): `how` is "dynamic" for a version computed at build time,
1133
+ # "workspace" for a Cargo `version.workspace = true`, else None.
1134
+ Version = tuple
1135
+
1136
+
1137
+ def _literal(value: object) -> str | None:
1138
+ return value.strip() if isinstance(value, str) and value.strip() else None
1139
+
1140
+
1141
+ def version_package_json(text: str) -> Version:
1142
+ data = json.loads(text)
1143
+ return (_literal(data.get("version")) if isinstance(data, dict) else None), None
1144
+
1145
+
1146
+ def version_pyproject_toml(text: str) -> Version:
1147
+ data = tomllib.loads(text)
1148
+ project = data.get("project") if isinstance(data.get("project"), dict) else {}
1149
+ tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
1150
+ poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
1151
+ version = _literal(project.get("version")) or _literal(poetry.get("version"))
1152
+ dynamic = isinstance(project.get("dynamic"), list) and "version" in project["dynamic"]
1153
+ return version, ("dynamic" if dynamic and version is None else None)
1154
+
1155
+
1156
+ def version_setup_py(text: str) -> Version:
1157
+ call = re.search(r"\bsetup\s*\((.*)", text, re.DOTALL)
1158
+ if not call:
1159
+ return None, None
1160
+ m = re.search(r"""\bversion\s*=\s*(['"])([^'"]+)\1""", call.group(1))
1161
+ if m:
1162
+ return m.group(2).strip(), None
1163
+ return None, ("dynamic" if re.search(r"\bversion\s*=", call.group(1)) else None)
1164
+
1165
+
1166
+ def version_setup_cfg(text: str) -> Version:
1167
+ m = re.search(r"^version[ \t]*=[ \t]*(\S+)", _cfg_section(text, "metadata"), re.MULTILINE)
1168
+ if not m:
1169
+ return None, None
1170
+ return (None, "dynamic") if m.group(1).startswith(("attr:", "file:")) else (m.group(1), None)
1171
+
1172
+
1173
+ def version_cargo_toml(text: str) -> Version:
1174
+ package = tomllib.loads(text).get("package")
1175
+ if not isinstance(package, dict):
1176
+ return None, None
1177
+ version = package.get("version")
1178
+ if isinstance(version, dict) and version.get("workspace") is True:
1179
+ return None, "workspace"
1180
+ return _literal(version), None
1181
+
1182
+
1183
+ def cargo_workspace_version(text: str) -> str | None:
1184
+ """The `[workspace.package] version` a Cargo workspace root gives its members."""
1185
+ workspace = tomllib.loads(text).get("workspace")
1186
+ package = workspace.get("package") if isinstance(workspace, dict) else None
1187
+ return _literal(package.get("version")) if isinstance(package, dict) else None
1188
+
1189
+
1190
+ def version_pom_xml(text: str) -> Version:
1191
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
1192
+ parent = re.search(r"<parent>(.*?)</parent>", text, re.DOTALL)
1193
+ own = text
1194
+ for tag in _POM_FOREIGN_SECTIONS:
1195
+ own = re.sub(rf"<{tag}>.*?</{tag}>", "", own, flags=re.DOTALL)
1196
+ m = re.search(r"<version>\s*(.*?)\s*</version>", own)
1197
+ if not m and parent:
1198
+ m = re.search(r"<version>\s*(.*?)\s*</version>", parent.group(1))
1199
+ return (m.group(1) or None) if m else None, None
1200
+
1201
+
1202
+ def version_gradle(text: str) -> Version:
1203
+ m = re.search(r"""^\s*version\s*=\s*['"]([^'"]+)['"]""", text, re.MULTILINE)
1204
+ return (m.group(1) if m else None), None
1205
+
1206
+
1207
+ VERSION_READERS = {
1208
+ "package.json": version_package_json,
1209
+ "pyproject.toml": version_pyproject_toml,
1210
+ "setup.py": version_setup_py,
1211
+ "setup.cfg": version_setup_cfg,
1212
+ "Cargo.toml": version_cargo_toml,
1213
+ "pom.xml": version_pom_xml,
1214
+ "build.gradle": version_gradle,
1215
+ "build.gradle.kts": version_gradle,
1216
+ "composer.json": version_package_json,
1217
+ }
1218
+
1219
+
1220
+ # --------------------------------------------------------------------------
1221
+ # Package description: the one line the manifest gives its own package
1222
+ # --------------------------------------------------------------------------
1223
+
1224
+
1225
+ def description_package_json(text: str) -> str | None:
1226
+ data = json.loads(text)
1227
+ return _literal(data.get("description")) if isinstance(data, dict) else None
1228
+
1229
+
1230
+ def description_pyproject_toml(text: str) -> str | None:
1231
+ data = tomllib.loads(text)
1232
+ project = data.get("project") if isinstance(data.get("project"), dict) else {}
1233
+ tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
1234
+ poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
1235
+ return _literal(project.get("description")) or _literal(poetry.get("description"))
1236
+
1237
+
1238
+ def description_setup_py(text: str) -> str | None:
1239
+ call = re.search(r"\bsetup\s*\((.*)", text, re.DOTALL)
1240
+ m = re.search(r"""\bdescription\s*=\s*(['"])([^'"]+)\1""", call.group(1)) if call else None
1241
+ return _literal(m.group(2)) if m else None
1242
+
1243
+
1244
+ def description_setup_cfg(text: str) -> str | None:
1245
+ m = re.search(r"^description[ \t]*=[ \t]*(.+)$", _cfg_section(text, "metadata"), re.MULTILINE)
1246
+ return _literal(m.group(1)) if m and not m.group(1).startswith(("attr:", "file:")) else None
1247
+
1248
+
1249
+ def description_cargo_toml(text: str) -> str | None:
1250
+ package = tomllib.loads(text).get("package")
1251
+ return _literal(package.get("description")) if isinstance(package, dict) else None
1252
+
1253
+
1254
+ def description_pom_xml(text: str) -> str | None:
1255
+ own = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
1256
+ for tag in _POM_FOREIGN_SECTIONS:
1257
+ own = re.sub(rf"<{tag}>.*?</{tag}>", "", own, flags=re.DOTALL)
1258
+ m = re.search(r"<description>\s*(.*?)\s*</description>", own, re.DOTALL)
1259
+ return _literal(" ".join(m.group(1).split())) if m else None
1260
+
1261
+
1262
+ def description_gradle(text: str) -> str | None:
1263
+ m = re.search(r"""^\s*description\s*=\s*['"]([^'"]+)['"]""", text, re.MULTILINE)
1264
+ return _literal(m.group(1)) if m else None
1265
+
1266
+
1267
+ DESCRIPTION_READERS = {
1268
+ "package.json": description_package_json,
1269
+ "pyproject.toml": description_pyproject_toml,
1270
+ "setup.py": description_setup_py,
1271
+ "setup.cfg": description_setup_cfg,
1272
+ "Cargo.toml": description_cargo_toml,
1273
+ "pom.xml": description_pom_xml,
1274
+ "build.gradle": description_gradle,
1275
+ "build.gradle.kts": description_gradle,
1276
+ "composer.json": description_package_json,
1277
+ }
1278
+
1279
+
1280
+ IDENTITY_READERS = {
1281
+ "package.json": identity_package_json,
1282
+ "pyproject.toml": identity_pyproject_toml,
1283
+ "setup.py": identity_setup_py,
1284
+ "setup.cfg": identity_setup_cfg,
1285
+ "Cargo.toml": identity_cargo_toml,
1286
+ "go.mod": identity_go_mod,
1287
+ "pom.xml": identity_pom_xml,
1288
+ "composer.json": identity_composer_json,
1289
+ "Package.swift": identity_package_swift,
1290
+ }
1291
+
1292
+
586
1293
  # --------------------------------------------------------------------------
587
1294
  # Aggregation
588
1295
  # --------------------------------------------------------------------------
589
1296
 
590
1297
 
591
- def _parse_manifest(path: Path) -> tuple[list[Dep], list[str]]:
592
- """Read and parse a single manifest file. Returns (deps, warnings)."""
1298
+ def _describe_manifest(path: Path, include_dev: bool) -> tuple[dict, list[str]]:
1299
+ """Read one manifest once: its identity, runtime deps and (optionally)
1300
+ dev deps tagged `scope: dev`. Returns (record fields, warnings)."""
1301
+ record: dict = {"name": None, "private": None, "version": None, "version_how": None,
1302
+ "workspace_version": None, "description": None, "deps": []}
593
1303
  parser = PARSERS.get(path.name)
594
1304
  if parser is None:
595
- return [], [f"{path.name}: no parser registered"]
1305
+ return record, [f"{path.name}: no parser registered"]
596
1306
  text = _safe_read_text(path)
597
1307
  if text is None:
598
- return [], [f"{path.name}: file unreadable"]
1308
+ return record, [f"{path.name}: file unreadable"]
1309
+ try:
1310
+ deps, warnings = parser(text)
1311
+ except Exception as exc: # noqa: BLE001 (best-effort parsing)
1312
+ deps, warnings = [], [f"{path.name}: parser raised {type(exc).__name__}: {exc}"]
1313
+ # identity and dev readers are best-effort too; the runtime parser has
1314
+ # already reported a malformed file
1315
+ reader = IDENTITY_READERS.get(path.name)
1316
+ try:
1317
+ record["name"], record["private"] = reader(text) if reader else (None, None)
1318
+ except Exception: # noqa: BLE001
1319
+ pass
1320
+ version_reader = VERSION_READERS.get(path.name)
599
1321
  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}"]
1322
+ if version_reader:
1323
+ record["version"], record["version_how"] = version_reader(text)
1324
+ if path.name == "Cargo.toml":
1325
+ record["workspace_version"] = cargo_workspace_version(text)
1326
+ except Exception: # noqa: BLE001
1327
+ pass
1328
+ description_reader = DESCRIPTION_READERS.get(path.name)
1329
+ try:
1330
+ record["description"] = description_reader(text) if description_reader else None
1331
+ except Exception: # noqa: BLE001
1332
+ pass
1333
+ if include_dev and path.name in DEV_PARSERS:
1334
+ try:
1335
+ dev = DEV_PARSERS[path.name](text)
1336
+ except Exception: # noqa: BLE001
1337
+ dev = []
1338
+ runtime = {d["name"] for d in deps}
1339
+ for dep in dev:
1340
+ if dep["name"] not in runtime:
1341
+ runtime.add(dep["name"])
1342
+ deps.append({**dep, "scope": "dev"})
1343
+ record["deps"] = deps
1344
+ return record, warnings
1345
+
1346
+
1347
+ def _member_key(ecosystem: str, name: str) -> str:
1348
+ """How an ecosystem compares package names."""
1349
+ if ecosystem == "python":
1350
+ return re.sub(r"[-_.]+", "-", name).lower()
1351
+ if ecosystem == "rust":
1352
+ return name.lower().replace("_", "-")
1353
+ if ecosystem in ("composer", "swift"):
1354
+ return name.lower()
1355
+ return name
1356
+
1357
+
1358
+ def _add_internal_deps(manifests: list[dict]) -> None:
1359
+ """Set each manifest's `internal_deps`: its runtime deps that name another
1360
+ scanned manifest of the same ecosystem, spelled as that member names itself."""
1361
+ members: dict[str, dict[str, str]] = {}
1362
+ for m in manifests:
1363
+ if m["name"]:
1364
+ members.setdefault(m["ecosystem"], {}).setdefault(
1365
+ _member_key(m["ecosystem"], m["name"]), m["name"]
1366
+ )
1367
+ for m in manifests:
1368
+ ecosystem = m["ecosystem"]
1369
+ known = members.get(ecosystem, {})
1370
+ own = _member_key(ecosystem, m["name"]) if m["name"] else None
1371
+ # a pom.xml names sibling modules as ${project.groupId}:<artifact>
1372
+ group = None
1373
+ if ecosystem == "maven" and m["name"] and ":" in m["name"]:
1374
+ group = m["name"].split(":", 1)[0]
1375
+ internal: set[str] = set()
1376
+ for dep in m["deps"]:
1377
+ name = dep.get("name")
1378
+ if not isinstance(name, str) or dep.get("scope") == "dev":
1379
+ continue
1380
+ if group:
1381
+ for token in ("${project.groupId}", "${pom.groupId}", "${groupId}"):
1382
+ name = name.replace(token, group)
1383
+ key = _member_key(ecosystem, name)
1384
+ if key != own and key in known:
1385
+ internal.add(known[key])
1386
+ m["internal_deps"] = sorted(internal)
1387
+
1388
+
1389
+ def _umbrella_candidates(manifests: list[dict]) -> list[dict]:
1390
+ """Published manifests whose internal_deps cover at least 2, and at least
1391
+ half, of the other published members of their ecosystem."""
1392
+ published: dict[str, set[str]] = {}
1393
+ for m in manifests:
1394
+ if m["name"] and m["private"] is not True:
1395
+ published.setdefault(m["ecosystem"], set()).add(_member_key(m["ecosystem"], m["name"]))
1396
+ candidates: list[dict] = []
1397
+ for m in manifests:
1398
+ if not m["name"] or m["private"] is True:
1399
+ continue
1400
+ ecosystem = m["ecosystem"]
1401
+ own = _member_key(ecosystem, m["name"])
1402
+ pool = published.get(ecosystem, set()) - {own}
1403
+ covered = {_member_key(ecosystem, n) for n in m["internal_deps"]} & pool
1404
+ if len(covered) >= 2 and 2 * len(covered) >= len(pool):
1405
+ candidates.append(
1406
+ {
1407
+ "path": m["path"],
1408
+ "name": m["name"],
1409
+ "ecosystem": ecosystem,
1410
+ "internal_dep_count": len(covered),
1411
+ "member_count": len(pool),
1412
+ }
1413
+ )
1414
+ candidates.sort(key=lambda c: (-c["internal_dep_count"], c["path"]))
1415
+ return candidates
603
1416
 
604
1417
 
605
1418
  def _is_monorepo(manifests: list[dict]) -> bool:
@@ -641,37 +1454,135 @@ def _path_is_ancestor(a: str, b: str) -> bool:
641
1454
  return b.startswith(a + "/")
642
1455
 
643
1456
 
644
- def scan(root: Path) -> dict:
645
- """Run a full manifest scan rooted at `root`."""
646
- paths = find_manifests(root)
1457
+ def _folders(manifests: list[dict]) -> list[dict]:
1458
+ """Each folder holding a manifest ("." for the root), with the names its manifests give."""
1459
+ by_folder: dict[str, set[str]] = {}
1460
+ for m in manifests:
1461
+ folder = m["path"].rsplit("/", 1)[0] if "/" in m["path"] else "."
1462
+ names = by_folder.setdefault(folder, set())
1463
+ if isinstance(m["name"], str) and m["name"]:
1464
+ names.add(m["name"])
1465
+ return [{"path": folder, "names": sorted(names)} for folder, names in sorted(by_folder.items())]
1466
+
1467
+
1468
+ def _folder_of(rel: str) -> str:
1469
+ """The folder of a path relative to the scan root, "." for the root itself."""
1470
+ return rel.rsplit("/", 1)[0] if "/" in rel else "."
1471
+
1472
+
1473
+ def _candidates(manifests: list[dict], services: list[str], sources: list[str],
1474
+ members: set[str] | None) -> list[dict]:
1475
+ """The candidate boundaries: see "candidates" in the module docstring.
1476
+ `members` is the workspace paths --workspaces-file gives, else None."""
1477
+ folders: dict[str, dict] = {}
1478
+
1479
+ def folder_entry(path: str) -> dict:
1480
+ return folders.setdefault(
1481
+ _folder_of(path), {"names": set(), "manifests": [], "services": []}
1482
+ )
1483
+
1484
+ for m in manifests:
1485
+ entry = folder_entry(m["path"])
1486
+ entry["manifests"].append(m["path"])
1487
+ if isinstance(m["name"], str) and m["name"]:
1488
+ entry["names"].add(m["name"])
1489
+ for path in services:
1490
+ folder_entry(path)["services"].append(path)
1491
+ own = dict.fromkeys(folders, 0)
1492
+ for path in sources:
1493
+ folder = _folder_of(path)
1494
+ while folder not in folders and folder != ".":
1495
+ folder = _folder_of(folder)
1496
+ if folder in folders:
1497
+ own[folder] += 1
1498
+ out: list[dict] = []
1499
+ for folder in sorted(folders):
1500
+ below = any(
1501
+ other != folder and (folder == "." or other.startswith(folder + "/"))
1502
+ for other in folders
1503
+ )
1504
+ if own[folder] == 0 and below:
1505
+ continue
1506
+ entry = folders[folder]
1507
+ out.append({
1508
+ "path": folder,
1509
+ "names": sorted(entry["names"]),
1510
+ "manifests": entry["manifests"],
1511
+ "services": entry["services"],
1512
+ "own_source_files": own[folder],
1513
+ "workspace_member": None if members is None else folder in members,
1514
+ })
1515
+ return out
1516
+
1517
+
1518
+ def scan(root: Path, include_dev: bool = False, workspaces: set[str] | None = None) -> dict:
1519
+ """Run a full manifest scan rooted at `root`.
1520
+
1521
+ With `include_dev`, each manifest's development dependencies join its
1522
+ `deps[]` tagged `scope: dev`, and `total_unique_dev` is added.
1523
+ `workspaces` is the workspace paths that set each candidate's
1524
+ `workspace_member` (None leaves it null). Raises ImportError when
1525
+ skf-detect-language.py does not sit beside this script.
1526
+ """
1527
+ sources: list[str] = []
1528
+ paths, services = _walk(root, sources)
647
1529
  manifests: list[dict] = []
648
1530
  warnings: list[str] = []
1531
+ # the folder of a Cargo workspace root: the version its members inherit
1532
+ workspace_versions: dict[str, str] = {}
1533
+ inheriting: list[dict] = []
649
1534
 
650
1535
  for path in paths:
651
- deps, warns = _parse_manifest(path)
1536
+ record, warns = _describe_manifest(path, include_dev)
652
1537
  rel = path.relative_to(root).as_posix()
653
1538
  for w in warns:
654
1539
  warnings.append(f"{rel}: {w}")
655
- manifests.append(
656
- {
657
- "path": rel,
658
- "ecosystem": MANIFEST_ECOSYSTEMS[path.name],
659
- "deps": deps,
660
- }
661
- )
1540
+ manifest = {
1541
+ "path": rel,
1542
+ "ecosystem": MANIFEST_ECOSYSTEMS[path.name],
1543
+ "name": record["name"],
1544
+ "private": record["private"],
1545
+ "version": record["version"],
1546
+ "version_dynamic": record["version_how"] == "dynamic",
1547
+ "description": record["description"],
1548
+ "deps": record["deps"],
1549
+ }
1550
+ manifests.append(manifest)
1551
+ if record["workspace_version"]:
1552
+ workspace_versions[_folder_of(rel)] = record["workspace_version"]
1553
+ if record["version_how"] == "workspace":
1554
+ inheriting.append(manifest)
1555
+ for manifest in inheriting:
1556
+ folder = _folder_of(manifest["path"])
1557
+ while True:
1558
+ if folder in workspace_versions:
1559
+ manifest["version"] = workspace_versions[folder]
1560
+ break
1561
+ if folder == ".":
1562
+ break
1563
+ folder = _folder_of(folder)
1564
+ _add_internal_deps(manifests)
662
1565
 
663
1566
  unique_names: set[str] = set()
1567
+ dev_names: set[str] = set()
664
1568
  for m in manifests:
665
1569
  for dep in m["deps"]:
666
1570
  name = dep.get("name")
667
1571
  if isinstance(name, str) and name and name != "<unparsable>":
668
- unique_names.add(name)
1572
+ (dev_names if dep.get("scope") == "dev" else unique_names).add(name)
669
1573
 
670
1574
  result: dict = {
671
1575
  "manifests": manifests,
672
1576
  "total_unique": len(unique_names),
673
- "monorepo": _is_monorepo(manifests),
674
1577
  }
1578
+ if include_dev:
1579
+ result["total_unique_dev"] = len(dev_names - unique_names)
1580
+ result["monorepo"] = _is_monorepo(manifests)
1581
+ result["folders"] = _folders(manifests)
1582
+ result["services"] = [path.relative_to(root).as_posix() for path in services]
1583
+ result["candidates"] = _candidates(manifests, result["services"], sources, workspaces)
1584
+ result["umbrella_candidates"] = _umbrella_candidates(manifests)
1585
+ result["searched_filenames"] = list(MANIFEST_ECOSYSTEMS)
675
1586
  if warnings:
676
1587
  result["warnings"] = warnings
677
1588
  return result
@@ -693,11 +1604,30 @@ def _cmd_scan(args: argparse.Namespace) -> int:
693
1604
  file=sys.stderr,
694
1605
  )
695
1606
  return 1
1607
+ workspaces = None
1608
+ if args.workspaces_file is not None:
1609
+ try:
1610
+ data = json.loads(Path(args.workspaces_file).read_text(encoding="utf-8-sig"))
1611
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
1612
+ print(
1613
+ f"error: cannot read --workspaces-file {args.workspaces_file}: {exc}",
1614
+ file=sys.stderr,
1615
+ )
1616
+ return 1
1617
+ listed = data.get("workspaces") if isinstance(data, dict) else None
1618
+ workspaces = {
1619
+ w["path"].strip("/").removeprefix("./")
1620
+ for w in (listed if isinstance(listed, list) else [])
1621
+ if isinstance(w, dict) and isinstance(w.get("path"), str)
1622
+ }
696
1623
  try:
697
- result = scan(root)
1624
+ result = scan(root, include_dev=args.include_dev, workspaces=workspaces)
698
1625
  except OSError as exc:
699
1626
  print(f"error: filesystem error during scan: {exc}", file=sys.stderr)
700
1627
  return 1
1628
+ except ImportError as exc:
1629
+ print(f"error: {exc}", file=sys.stderr)
1630
+ return 1
701
1631
  json.dump(result, sys.stdout, indent=2)
702
1632
  sys.stdout.write("\n")
703
1633
  return 0
@@ -723,6 +1653,17 @@ def _build_parser() -> argparse.ArgumentParser:
723
1653
  default="auto",
724
1654
  help="ecosystem filter (currently only 'auto' is accepted)",
725
1655
  )
1656
+ p_scan.add_argument(
1657
+ "--include-dev",
1658
+ action="store_true",
1659
+ help="also list development dependencies, each tagged scope: dev",
1660
+ )
1661
+ p_scan.add_argument(
1662
+ "--workspaces-file",
1663
+ metavar="PATH",
1664
+ help="the JSON skf-detect-workspaces.py printed for the same root: sets each "
1665
+ "candidate's workspace_member",
1666
+ )
726
1667
  p_scan.set_defaults(func=_cmd_scan)
727
1668
 
728
1669
  return parser