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