bmad-module-skill-forge 1.4.1 → 1.5.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 (230) 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 +97 -8
  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-merge-ccc-exclusions.py +34 -4
  35. package/src/shared/scripts/skf-pair-intersect.py +250 -0
  36. package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
  37. package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
  38. package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
  39. package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
  40. package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
  41. package/src/shared/scripts/skf-scan-manifests.py +738 -0
  42. package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
  43. package/src/shared/scripts/skf-update-active-symlink.py +315 -0
  44. package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
  45. package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
  46. package/src/shared/scripts/skf-write-skill-brief.py +61 -5
  47. package/src/skf-analyze-source/SKILL.md +69 -16
  48. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  49. package/src/skf-analyze-source/customize.toml +55 -0
  50. package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
  51. package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
  52. package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  53. package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +44 -14
  54. package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +27 -6
  55. package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
  56. package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
  57. package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +12 -10
  58. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
  59. package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
  60. package/src/skf-audit-skill/SKILL.md +66 -15
  61. package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
  62. package/src/skf-audit-skill/customize.toml +49 -0
  63. package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  64. package/src/skf-audit-skill/{steps-c/step-01-init.md → references/init.md} +57 -19
  65. package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
  66. package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
  67. package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
  68. package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
  69. package/src/skf-audit-skill/references/severity-rules.md +2 -0
  70. package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +29 -17
  71. package/src/skf-brief-skill/SKILL.md +48 -18
  72. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  73. package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
  74. package/src/skf-brief-skill/customize.toml +44 -0
  75. package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +5 -5
  76. package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
  77. package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
  78. package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +24 -24
  79. package/src/skf-brief-skill/references/headless-args.md +9 -9
  80. package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
  81. package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
  82. package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
  83. package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
  84. package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +19 -16
  85. package/src/skf-brief-skill/references/version-resolution.md +2 -2
  86. package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +11 -10
  87. package/src/skf-create-skill/SKILL.md +25 -19
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
  89. package/src/skf-create-skill/assets/skill-sections.md +5 -5
  90. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
  91. package/src/skf-create-skill/customize.toml +35 -0
  92. package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
  93. package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
  94. package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
  95. package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
  96. package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
  97. package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
  98. package/src/skf-create-skill/references/extraction-patterns.md +8 -8
  99. package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +11 -11
  100. package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
  101. package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
  102. package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
  103. package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
  104. package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
  105. package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
  106. package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
  107. package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +46 -26
  108. package/src/skf-create-stack-skill/SKILL.md +76 -17
  109. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
  110. package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
  111. package/src/skf-create-stack-skill/customize.toml +46 -0
  112. package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
  113. package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
  114. package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +32 -11
  115. package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +28 -28
  116. package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
  117. package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
  118. package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
  119. package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
  120. package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
  121. package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +40 -17
  122. package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
  123. package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
  124. package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
  125. package/src/skf-drop-skill/SKILL.md +84 -12
  126. package/src/skf-drop-skill/customize.toml +57 -0
  127. package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
  128. package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
  129. package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  130. package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
  131. package/src/skf-export-skill/SKILL.md +85 -16
  132. package/src/skf-export-skill/assets/managed-section-format.md +3 -3
  133. package/src/skf-export-skill/assets/snippet-format.md +2 -2
  134. package/src/skf-export-skill/customize.toml +49 -0
  135. package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +11 -11
  136. package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  137. package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +11 -49
  138. package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
  139. package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
  140. package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
  141. package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
  142. package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
  143. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +74 -0
  144. package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +20 -8
  145. package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
  146. package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +109 -113
  147. package/src/skf-quick-skill/SKILL.md +63 -123
  148. package/src/skf-quick-skill/customize.toml +44 -0
  149. package/src/skf-quick-skill/references/batch-mode.md +102 -0
  150. package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
  151. package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
  152. package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
  153. package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
  154. package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
  155. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  156. package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
  157. package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
  158. package/src/skf-refine-architecture/SKILL.md +86 -16
  159. package/src/skf-refine-architecture/customize.toml +49 -0
  160. package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
  161. package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
  162. package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  163. package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
  164. package/src/skf-refine-architecture/references/init.md +144 -0
  165. package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
  166. package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
  167. package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
  168. package/src/skf-rename-skill/SKILL.md +82 -12
  169. package/src/skf-rename-skill/customize.toml +52 -0
  170. package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
  171. package/src/skf-rename-skill/references/health-check.md +30 -0
  172. package/src/skf-rename-skill/references/rebuild-context.md +110 -0
  173. package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  174. package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
  175. package/src/skf-setup/SKILL.md +35 -35
  176. package/src/skf-setup/customize.toml +33 -0
  177. package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
  178. package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +16 -31
  179. package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
  180. package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
  181. package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
  182. package/src/skf-setup/references/tier-rules.md +1 -1
  183. package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
  184. package/src/skf-test-skill/SKILL.md +77 -15
  185. package/src/skf-test-skill/customize.toml +54 -0
  186. package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +28 -71
  187. package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
  188. package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
  189. package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
  190. package/src/skf-test-skill/references/health-check.md +14 -0
  191. package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
  192. package/src/skf-test-skill/references/migration-section-rules.md +4 -2
  193. package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
  194. package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
  195. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  196. package/src/skf-test-skill/references/source-access-protocol.md +7 -5
  197. package/src/skf-test-skill/scripts/compute-score.py +88 -17
  198. package/src/skf-test-skill/templates/test-report-template.md +13 -13
  199. package/src/skf-update-skill/SKILL.md +23 -14
  200. package/src/skf-update-skill/customize.toml +44 -0
  201. package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
  202. package/src/skf-update-skill/references/health-check.md +32 -0
  203. package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
  204. package/src/skf-update-skill/references/manual-section-rules.md +4 -0
  205. package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
  206. package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
  207. package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
  208. package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
  209. package/src/skf-update-skill/references/report.md +214 -0
  210. package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
  211. package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
  212. package/src/skf-verify-stack/SKILL.md +89 -17
  213. package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
  214. package/src/skf-verify-stack/customize.toml +50 -0
  215. package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
  216. package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +7 -7
  217. package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
  218. package/src/skf-verify-stack/references/init.md +166 -0
  219. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  220. package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +12 -12
  221. package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +22 -41
  222. package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +7 -7
  223. package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +8 -8
  224. package/tools/validate-docs-drift.js +1 -1
  225. package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
  226. package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
  227. package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
  228. package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
  229. package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
  230. package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
@@ -0,0 +1,613 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Detect Scripts & Assets — file-level artifact detection for skill compilation.
6
+
7
+ Scans a source tree and identifies script and asset files per the detection
8
+ heuristics documented in
9
+ `src/skf-create-skill/references/extraction-patterns-tracing.md` (Script/Asset
10
+ Extraction Patterns section). The output feeds `scripts_inventory[]` and
11
+ `assets_inventory[]` in the create-skill workflow's extraction inventory
12
+ (see step 3 §4c).
13
+
14
+ This script replaces ~13 lines of prose plus implicit "compute SHA-256, read
15
+ shebang, find header comments" operations that would otherwise be re-derived
16
+ by the LLM on every run.
17
+
18
+ Subcommand: a single `detect` subcommand keeps room for future extensions
19
+ (e.g., `detect-entry-points` that only parses package.json/Cargo.toml).
20
+
21
+ CLI:
22
+ uv run skf-detect-scripts-assets.py detect <source-root> \\
23
+ [--scripts-intent detect|none] \\
24
+ [--assets-intent detect|none] \\
25
+ [--scope-include "pattern1,pattern2,..."] \\
26
+ [--max-lines 500]
27
+
28
+ Detection rules — scripts:
29
+ Directory convention: scripts/, bin/, tools/, cli/
30
+ Shebang signals: #!/bin/bash, #!/usr/bin/env python|node|bash|sh, etc.
31
+ Entry point declarations: package.json `bin`, pyproject.toml [project.scripts]
32
+ CLI argument-parser imports are detected for the LLM-judgment tier only —
33
+ this script flags the directory/shebang/entry-point cases that are fully
34
+ deterministic.
35
+
36
+ Detection rules — assets:
37
+ Directory convention: assets/, templates/, schemas/, configs/, examples/
38
+ Pattern matches: *.schema.json (contains "$schema"), *.example,
39
+ *.template.*, *.sample, openapi.json, *.graphql, swagger.yaml
40
+
41
+ Exclusions:
42
+ Binary extensions: .so, .dll, .jar, .wasm, .exe, .dylib, .a, .o, .pyc,
43
+ .class, .png, .jpg, .jpeg, .gif, .ico, .pdf, .zip, .tar, .gz, .tgz
44
+ Generated paths: dist/, build/, .webpack/, node_modules/, __pycache__/,
45
+ target/ (Rust), .next/, .nuxt/, out/, coverage/
46
+
47
+ Size flag:
48
+ Files >max-lines (default 500) get size_flag="oversized" but are still
49
+ included — the caller decides whether to bundle them.
50
+
51
+ Output JSON (stdout):
52
+ {
53
+ "scripts_inventory": [ {name, source_file, purpose, language,
54
+ content_hash, confidence, lines, size_flag}, ... ],
55
+ "assets_inventory": [ {name, source_file, purpose, type, content_hash,
56
+ confidence, lines, size_flag}, ... ],
57
+ "scripts_skipped": false,
58
+ "assets_skipped": false,
59
+ "stats": { "scripts_found": N, "assets_found": M, "files_scanned": K }
60
+ }
61
+
62
+ Confidence is always "T1-low" — file existence verified, content not
63
+ AST-analyzed. Matches the spec at extraction-patterns-tracing.md §Provenance.
64
+
65
+ Exit codes:
66
+ 0 — detection succeeded (including --scripts-intent=none --assets-intent=none)
67
+ 1 — user error (bad source root, malformed args)
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import argparse
73
+ import fnmatch
74
+ import hashlib
75
+ import json
76
+ import re
77
+ import sys
78
+ from pathlib import Path
79
+ from typing import Iterable
80
+
81
+
82
+ # --------------------------------------------------------------------------
83
+ # Constants — detection rules (single source of truth)
84
+ # --------------------------------------------------------------------------
85
+
86
+
87
+ SCRIPT_DIRS = {"scripts", "bin", "tools", "cli"}
88
+ ASSET_DIRS = {"assets", "templates", "schemas", "configs", "examples"}
89
+
90
+ SHEBANG_LANGS = {
91
+ "bash": "bash",
92
+ "sh": "shell",
93
+ "zsh": "zsh",
94
+ "python": "python",
95
+ "python2": "python",
96
+ "python3": "python",
97
+ "node": "javascript",
98
+ "deno": "typescript",
99
+ "ruby": "ruby",
100
+ "perl": "perl",
101
+ }
102
+
103
+ EXTENSION_LANGS = {
104
+ ".sh": "shell",
105
+ ".bash": "bash",
106
+ ".zsh": "zsh",
107
+ ".py": "python",
108
+ ".js": "javascript",
109
+ ".mjs": "javascript",
110
+ ".cjs": "javascript",
111
+ ".ts": "typescript",
112
+ ".rb": "ruby",
113
+ ".pl": "perl",
114
+ ".go": "go",
115
+ ".rs": "rust",
116
+ }
117
+
118
+ BINARY_EXTS = {
119
+ ".so", ".dll", ".jar", ".wasm", ".exe", ".dylib", ".a", ".o",
120
+ ".pyc", ".class", ".png", ".jpg", ".jpeg", ".gif", ".ico",
121
+ ".pdf", ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z",
122
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
123
+ }
124
+
125
+ # Path-segment names that mark generated/vendored output trees.
126
+ EXCLUDED_DIR_NAMES = {
127
+ "node_modules", "__pycache__", "dist", "build", ".webpack",
128
+ "target", ".next", ".nuxt", "out", "coverage", ".git",
129
+ ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
130
+ ".ruff_cache", ".gradle", ".idea", ".vscode",
131
+ }
132
+
133
+ ASSET_TYPE_SCHEMA = "schema"
134
+ ASSET_TYPE_TEMPLATE = "template"
135
+ ASSET_TYPE_CONFIG = "config"
136
+ ASSET_TYPE_EXAMPLE = "example"
137
+
138
+ CONFIDENCE = "T1-low"
139
+
140
+
141
+ # --------------------------------------------------------------------------
142
+ # Walk + filter
143
+ # --------------------------------------------------------------------------
144
+
145
+
146
+ def iter_files(source_root: Path) -> Iterable[Path]:
147
+ """Yield every file under source_root, skipping excluded directory trees.
148
+
149
+ Done as a manual walk so we can prune EXCLUDED_DIR_NAMES at any depth
150
+ without scanning their contents (Path.rglob can't prune efficiently).
151
+ """
152
+ stack: list[Path] = [source_root]
153
+ while stack:
154
+ current = stack.pop()
155
+ try:
156
+ entries = list(current.iterdir())
157
+ except (PermissionError, FileNotFoundError):
158
+ continue
159
+ for entry in entries:
160
+ if entry.is_symlink():
161
+ continue
162
+ if entry.is_dir():
163
+ if entry.name in EXCLUDED_DIR_NAMES:
164
+ continue
165
+ stack.append(entry)
166
+ elif entry.is_file():
167
+ yield entry
168
+
169
+
170
+ def is_binary_path(path: Path) -> bool:
171
+ return path.suffix.lower() in BINARY_EXTS
172
+
173
+
174
+ def relative_segments(path: Path, root: Path) -> tuple[str, ...]:
175
+ """Return path parts relative to root, dropping the filename."""
176
+ rel = path.relative_to(root)
177
+ return rel.parts[:-1]
178
+
179
+
180
+ def in_directory(path: Path, root: Path, dir_names: set[str]) -> bool:
181
+ """True if any segment of path's relative dir is in dir_names."""
182
+ return any(seg in dir_names for seg in relative_segments(path, root))
183
+
184
+
185
+ def matches_scope(path: Path, root: Path, scope_patterns: list[str]) -> bool:
186
+ """Match `path` against scope-include glob patterns relative to root.
187
+
188
+ Forward-slash form so scope patterns like "scripts/*" match on Windows
189
+ too — fnmatch is byte-exact and won't normalize separators for us.
190
+ """
191
+ if not scope_patterns:
192
+ return True
193
+ rel = path.relative_to(root).as_posix()
194
+ return any(fnmatch.fnmatch(rel, pattern) for pattern in scope_patterns)
195
+
196
+
197
+ # --------------------------------------------------------------------------
198
+ # Read helpers
199
+ # --------------------------------------------------------------------------
200
+
201
+
202
+ def read_text_safe(path: Path, *, max_bytes: int | None = None) -> str | None:
203
+ """Read text from `path` as UTF-8, returning None on decode failure
204
+ (treated as binary). Caller can pass max_bytes to bound reads of large
205
+ files."""
206
+ try:
207
+ if max_bytes is None:
208
+ return path.read_text(encoding="utf-8")
209
+ with path.open("rb") as fh:
210
+ blob = fh.read(max_bytes)
211
+ return blob.decode("utf-8")
212
+ except (UnicodeDecodeError, OSError):
213
+ return None
214
+
215
+
216
+ def sha256_of_file(path: Path) -> str:
217
+ """Compute SHA-256 of file content; emits with `sha256:` prefix."""
218
+ h = hashlib.sha256()
219
+ with path.open("rb") as fh:
220
+ for chunk in iter(lambda: fh.read(65536), b""):
221
+ h.update(chunk)
222
+ return "sha256:" + h.hexdigest()
223
+
224
+
225
+ def count_lines(path: Path) -> int:
226
+ """Count newlines in the file; returns 0 if file is unreadable."""
227
+ n = 0
228
+ try:
229
+ with path.open("rb") as fh:
230
+ for chunk in iter(lambda: fh.read(65536), b""):
231
+ n += chunk.count(b"\n")
232
+ except OSError:
233
+ return 0
234
+ return n
235
+
236
+
237
+ # --------------------------------------------------------------------------
238
+ # Script detection
239
+ # --------------------------------------------------------------------------
240
+
241
+
242
+ _SHEBANG_RE = re.compile(rb"^#!.*?(\b(?:" + "|".join(SHEBANG_LANGS).encode() + rb")\b)")
243
+
244
+
245
+ def detect_shebang_language(path: Path) -> str | None:
246
+ """Return language name if the file starts with a recognized shebang."""
247
+ try:
248
+ with path.open("rb") as fh:
249
+ first = fh.readline(256)
250
+ except OSError:
251
+ return None
252
+ if not first.startswith(b"#!"):
253
+ return None
254
+ match = _SHEBANG_RE.match(first)
255
+ if not match:
256
+ return None
257
+ interp = match.group(1).decode()
258
+ return SHEBANG_LANGS.get(interp)
259
+
260
+
261
+ def detect_language(path: Path, *, shebang_lang: str | None) -> str:
262
+ """Determine the script's language for the inventory record."""
263
+ if shebang_lang:
264
+ return shebang_lang
265
+ ext_lang = EXTENSION_LANGS.get(path.suffix.lower())
266
+ if ext_lang:
267
+ return ext_lang
268
+ return "unknown"
269
+
270
+
271
+ def entry_point_script_paths(source_root: Path) -> set[Path]:
272
+ """Parse package.json `bin` and pyproject.toml [project.scripts] to find
273
+ explicit entry-point scripts. Returns absolute paths that exist on disk.
274
+
275
+ Other manifest formats (Cargo.toml [[bin]]) are handled by directory
276
+ convention (`src/bin/` etc.) rather than parsing — adding a Cargo parser
277
+ is future work and not in this script's scope per the deterministic-only
278
+ extraction rule.
279
+ """
280
+ found: set[Path] = set()
281
+ found.update(_parse_package_json_bin(source_root))
282
+ found.update(_parse_pyproject_scripts(source_root))
283
+ return found
284
+
285
+
286
+ def _parse_package_json_bin(source_root: Path) -> Iterable[Path]:
287
+ pkg = source_root / "package.json"
288
+ if not pkg.is_file():
289
+ return ()
290
+ text = read_text_safe(pkg, max_bytes=512 * 1024)
291
+ if text is None:
292
+ return ()
293
+ try:
294
+ data = json.loads(text)
295
+ except json.JSONDecodeError:
296
+ return ()
297
+ bin_field = data.get("bin")
298
+ if isinstance(bin_field, str):
299
+ candidates = [bin_field]
300
+ elif isinstance(bin_field, dict):
301
+ candidates = [v for v in bin_field.values() if isinstance(v, str)]
302
+ else:
303
+ return ()
304
+ out: list[Path] = []
305
+ for cand in candidates:
306
+ # bin paths are relative to package.json
307
+ p = (source_root / cand).resolve()
308
+ if p.is_file():
309
+ out.append(p)
310
+ return out
311
+
312
+
313
+ def _parse_pyproject_scripts(source_root: Path) -> Iterable[Path]:
314
+ """Pyproject [project.scripts] points at a dotted module path, not a file
315
+ path — we can't deterministically map `mypkg.cli:main` to a script file
316
+ without invoking the package's installer. We instead surface the existence
317
+ of any [project.scripts] entry as a hint by treating any file under
318
+ `<source_root>/<top-level>/__main__.py` or files matching the cli module
319
+ in standard layouts. To avoid speculative mapping, this function returns
320
+ an empty set today; the directory-convention path picks up most cases
321
+ (script-like files in scripts/ or bin/). Future work: parse setuptools
322
+ package layout to resolve.
323
+ """
324
+ _ = source_root
325
+ return ()
326
+
327
+
328
+ def is_script(path: Path, source_root: Path, *, entry_points: set[Path]) -> tuple[bool, str | None]:
329
+ """Decide whether `path` is a script. Returns (is_script, shebang_lang)."""
330
+ if path.resolve() in entry_points:
331
+ return True, detect_shebang_language(path)
332
+ if in_directory(path, source_root, SCRIPT_DIRS):
333
+ return True, detect_shebang_language(path)
334
+ shebang_lang = detect_shebang_language(path)
335
+ if shebang_lang is not None:
336
+ return True, shebang_lang
337
+ return False, None
338
+
339
+
340
+ # --------------------------------------------------------------------------
341
+ # Asset detection
342
+ # --------------------------------------------------------------------------
343
+
344
+
345
+ _ASSET_FILENAME_TYPE: list[tuple[re.Pattern[str], str]] = [
346
+ (re.compile(r".*\.schema\.json$", re.IGNORECASE), ASSET_TYPE_SCHEMA),
347
+ (re.compile(r"^openapi\.(json|ya?ml)$", re.IGNORECASE), ASSET_TYPE_SCHEMA),
348
+ (re.compile(r"^swagger\.(json|ya?ml)$", re.IGNORECASE), ASSET_TYPE_SCHEMA),
349
+ (re.compile(r".*\.graphql$", re.IGNORECASE), ASSET_TYPE_SCHEMA),
350
+ (re.compile(r".*\.template\..+$", re.IGNORECASE), ASSET_TYPE_TEMPLATE),
351
+ (re.compile(r".*\.(example|sample)(\..+)?$", re.IGNORECASE), ASSET_TYPE_EXAMPLE),
352
+ ]
353
+
354
+
355
+ def asset_type(path: Path, source_root: Path) -> str | None:
356
+ """Return the asset type for `path` or None if it doesn't match asset rules."""
357
+ # Pattern-based detection (works regardless of containing directory)
358
+ for pattern, atype in _ASSET_FILENAME_TYPE:
359
+ if pattern.match(path.name):
360
+ return atype
361
+
362
+ if in_directory(path, source_root, ASSET_DIRS):
363
+ segments = set(relative_segments(path, source_root))
364
+ if "templates" in segments:
365
+ return ASSET_TYPE_TEMPLATE
366
+ if "schemas" in segments:
367
+ # JSON schema content check
368
+ if path.suffix.lower() == ".json":
369
+ text = read_text_safe(path, max_bytes=64 * 1024)
370
+ if text and '"$schema"' in text:
371
+ return ASSET_TYPE_SCHEMA
372
+ return ASSET_TYPE_SCHEMA
373
+ if "configs" in segments:
374
+ return ASSET_TYPE_CONFIG
375
+ if "examples" in segments:
376
+ return ASSET_TYPE_EXAMPLE
377
+ # assets/ — type unknown, default to config (safer than template)
378
+ return ASSET_TYPE_CONFIG
379
+ return None
380
+
381
+
382
+ # --------------------------------------------------------------------------
383
+ # Purpose extraction
384
+ # --------------------------------------------------------------------------
385
+
386
+
387
+ _HEADER_COMMENT_RE = re.compile(
388
+ r"^\s*(?:#|//|/\*|\*)\s*(.{3,})$"
389
+ )
390
+
391
+
392
+ def extract_purpose(path: Path, *, asset_type_hint: str | None) -> str:
393
+ """Pull a one-line purpose summary from the file's leading content.
394
+
395
+ Heuristics (first match wins):
396
+ - JSON schema: read top-level `title` or `description` field
397
+ - Files with header comments: first comment line after any shebang
398
+ - Otherwise: fall back to the filename (per spec)
399
+ """
400
+ if asset_type_hint == ASSET_TYPE_SCHEMA and path.suffix.lower() == ".json":
401
+ text = read_text_safe(path, max_bytes=64 * 1024)
402
+ if text:
403
+ try:
404
+ data = json.loads(text)
405
+ for key in ("title", "description"):
406
+ val = data.get(key) if isinstance(data, dict) else None
407
+ if isinstance(val, str) and val.strip():
408
+ return val.strip()
409
+ except json.JSONDecodeError:
410
+ pass
411
+
412
+ text = read_text_safe(path, max_bytes=4 * 1024)
413
+ if text:
414
+ for i, line in enumerate(text.splitlines()[:10]):
415
+ if i == 0 and line.startswith("#!"):
416
+ continue
417
+ if not line.strip():
418
+ continue
419
+ match = _HEADER_COMMENT_RE.match(line)
420
+ if match:
421
+ purpose = match.group(1).strip().rstrip("*/").strip()
422
+ if purpose:
423
+ return purpose
424
+
425
+ return path.name
426
+
427
+
428
+ # --------------------------------------------------------------------------
429
+ # Build records
430
+ # --------------------------------------------------------------------------
431
+
432
+
433
+ def build_script_record(
434
+ path: Path, source_root: Path, *, shebang_lang: str | None, max_lines: int
435
+ ) -> dict:
436
+ rel = path.relative_to(source_root).as_posix()
437
+ lines = count_lines(path)
438
+ return {
439
+ "name": path.name,
440
+ "source_file": rel,
441
+ "purpose": extract_purpose(path, asset_type_hint=None),
442
+ "language": detect_language(path, shebang_lang=shebang_lang),
443
+ "content_hash": sha256_of_file(path),
444
+ "confidence": CONFIDENCE,
445
+ "lines": lines,
446
+ "size_flag": "oversized" if lines > max_lines else None,
447
+ }
448
+
449
+
450
+ def build_asset_record(
451
+ path: Path, source_root: Path, *, atype: str, max_lines: int
452
+ ) -> dict:
453
+ rel = path.relative_to(source_root).as_posix()
454
+ lines = count_lines(path)
455
+ return {
456
+ "name": path.name,
457
+ "source_file": rel,
458
+ "purpose": extract_purpose(path, asset_type_hint=atype),
459
+ "type": atype,
460
+ "content_hash": sha256_of_file(path),
461
+ "confidence": CONFIDENCE,
462
+ "lines": lines,
463
+ "size_flag": "oversized" if lines > max_lines else None,
464
+ }
465
+
466
+
467
+ # --------------------------------------------------------------------------
468
+ # Main detection
469
+ # --------------------------------------------------------------------------
470
+
471
+
472
+ def detect(
473
+ source_root: Path,
474
+ *,
475
+ scripts_intent: str = "detect",
476
+ assets_intent: str = "detect",
477
+ scope_patterns: list[str] | None = None,
478
+ max_lines: int = 500,
479
+ ) -> dict:
480
+ scope_patterns = scope_patterns or []
481
+ scripts_skipped = scripts_intent == "none"
482
+ assets_skipped = assets_intent == "none"
483
+
484
+ scripts_inventory: list[dict] = []
485
+ assets_inventory: list[dict] = []
486
+ files_scanned = 0
487
+
488
+ entry_points = (
489
+ entry_point_script_paths(source_root) if not scripts_skipped else set()
490
+ )
491
+
492
+ if not scripts_skipped or not assets_skipped:
493
+ for path in iter_files(source_root):
494
+ files_scanned += 1
495
+ if is_binary_path(path):
496
+ continue
497
+ if not matches_scope(path, source_root, scope_patterns):
498
+ continue
499
+
500
+ if not scripts_skipped:
501
+ is_scr, shebang_lang = is_script(
502
+ path, source_root, entry_points=entry_points
503
+ )
504
+ if is_scr:
505
+ scripts_inventory.append(
506
+ build_script_record(
507
+ path, source_root,
508
+ shebang_lang=shebang_lang, max_lines=max_lines,
509
+ )
510
+ )
511
+ continue # a script can't also be an asset
512
+
513
+ if not assets_skipped:
514
+ atype = asset_type(path, source_root)
515
+ if atype is not None:
516
+ assets_inventory.append(
517
+ build_asset_record(
518
+ path, source_root, atype=atype, max_lines=max_lines,
519
+ )
520
+ )
521
+
522
+ # Deterministic ordering for stable diffs / cacheability
523
+ scripts_inventory.sort(key=lambda r: r["source_file"])
524
+ assets_inventory.sort(key=lambda r: r["source_file"])
525
+
526
+ return {
527
+ "scripts_inventory": scripts_inventory,
528
+ "assets_inventory": assets_inventory,
529
+ "scripts_skipped": scripts_skipped,
530
+ "assets_skipped": assets_skipped,
531
+ "stats": {
532
+ "scripts_found": len(scripts_inventory),
533
+ "assets_found": len(assets_inventory),
534
+ "files_scanned": files_scanned,
535
+ },
536
+ }
537
+
538
+
539
+ # --------------------------------------------------------------------------
540
+ # CLI
541
+ # --------------------------------------------------------------------------
542
+
543
+
544
+ def _cmd_detect(args: argparse.Namespace) -> int:
545
+ source_root = Path(args.source_root).resolve()
546
+ if not source_root.is_dir():
547
+ print(f"error: source root not a directory: {source_root}", file=sys.stderr)
548
+ return 1
549
+ scope_patterns = (
550
+ [s.strip() for s in args.scope_include.split(",") if s.strip()]
551
+ if args.scope_include
552
+ else []
553
+ )
554
+ if args.scripts_intent not in ("detect", "none"):
555
+ print(f"error: --scripts-intent must be detect|none", file=sys.stderr)
556
+ return 1
557
+ if args.assets_intent not in ("detect", "none"):
558
+ print(f"error: --assets-intent must be detect|none", file=sys.stderr)
559
+ return 1
560
+
561
+ result = detect(
562
+ source_root,
563
+ scripts_intent=args.scripts_intent,
564
+ assets_intent=args.assets_intent,
565
+ scope_patterns=scope_patterns,
566
+ max_lines=args.max_lines,
567
+ )
568
+ json.dump(result, sys.stdout, indent=2)
569
+ sys.stdout.write("\n")
570
+ return 0
571
+
572
+
573
+ def _build_parser() -> argparse.ArgumentParser:
574
+ parser = argparse.ArgumentParser(
575
+ prog="skf-detect-scripts-assets",
576
+ description="Detect script and asset files in a source tree per SKF extraction rules.",
577
+ )
578
+ sub = parser.add_subparsers(dest="cmd", required=True)
579
+ p = sub.add_parser("detect", help="scan a source tree and emit inventory JSON")
580
+ p.add_argument("source_root", help="path to the source tree root")
581
+ p.add_argument(
582
+ "--scripts-intent",
583
+ default="detect",
584
+ help="detect|none (default: detect)",
585
+ )
586
+ p.add_argument(
587
+ "--assets-intent",
588
+ default="detect",
589
+ help="detect|none (default: detect)",
590
+ )
591
+ p.add_argument(
592
+ "--scope-include",
593
+ default=None,
594
+ help="comma-separated glob patterns to limit detection (relative to source-root)",
595
+ )
596
+ p.add_argument(
597
+ "--max-lines",
598
+ type=int,
599
+ default=500,
600
+ help="files above this line count get size_flag='oversized' (default: 500)",
601
+ )
602
+ p.set_defaults(func=_cmd_detect)
603
+ return parser
604
+
605
+
606
+ def main(argv: list[str] | None = None) -> int:
607
+ parser = _build_parser()
608
+ args = parser.parse_args(argv)
609
+ return args.func(args)
610
+
611
+
612
+ if __name__ == "__main__":
613
+ raise SystemExit(main())