bmad-module-skill-forge 1.3.0 → 1.4.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 (31) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +34 -15
  4. package/package.json +2 -2
  5. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
  6. package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
  7. package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
  8. package/src/shared/scripts/skf-detect-language.py +277 -0
  9. package/src/shared/scripts/skf-detect-workspaces.py +427 -0
  10. package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
  11. package/src/shared/scripts/skf-extract-public-api.py +29 -0
  12. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  13. package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
  14. package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
  15. package/src/shared/scripts/skf-write-skill-brief.py +509 -0
  16. package/src/skf-brief-skill/SKILL.md +41 -12
  17. package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
  18. package/src/skf-brief-skill/assets/scope-templates.md +5 -0
  19. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
  20. package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
  21. package/src/skf-brief-skill/references/headless-args.md +22 -0
  22. package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
  23. package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
  24. package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
  25. package/src/skf-brief-skill/references/version-resolution.md +46 -0
  26. package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
  27. package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
  28. package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
  29. package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
  30. package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
  31. package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
@@ -30,7 +30,7 @@
30
30
  "name": "skill-forge",
31
31
  "source": "./",
32
32
  "description": "Evidence-based agent skills compiler with progressive capability tiers (Quick/Forge/Forge+/Deep).",
33
- "version": "1.3.0",
33
+ "version": "1.4.0",
34
34
  "author": {
35
35
  "name": "Armel"
36
36
  },
@@ -26,7 +26,7 @@
26
26
  # Enforced two ways: (1) release.yaml bumps this line in the same commit
27
27
  # as package.json + marketplace.json on every release; (2) validate-docs-drift.js
28
28
  # cross-checks this value against package.json.version and fails on mismatch.
29
- skf_version: "1.3.0"
29
+ skf_version: "1.4.0"
30
30
 
31
31
  # Path to the oh-my-skills repo, resolved relative to this repo's root.
32
32
  # Override at runtime with the OMS environment variable if oh-my-skills
package/docs/workflows.md CHANGED
@@ -286,15 +286,15 @@ flowchart TD
286
286
 
287
287
  ## Workflow Categories
288
288
 
289
- | Category | Workflows | Description |
290
- |----------|-----------|-------------|
291
- | Core | SF, BS, CS, US | Setup, brief, create, and update skills |
292
- | Feature | QS, SS, AN | Quick skill, stack skill, and analyze source |
293
- | Quality | AS, TS | Detect skill drift (AS) and verify skill completeness (TS) |
294
- | Architecture Verification | VS, RA | Pre-code architecture feasibility and refinement |
295
- | Management | RS, DS | Rename and drop skill versions with transactional safety |
296
- | Utility | EX | Package and export for consumption |
297
- | In-Agent | WS, KI | WS: show lifecycle position, active briefs, and forge tier; KI: list knowledge fragments (both in-agent, no file-based workflow) |
289
+ | Category | Workflows | Description |
290
+ | ------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
291
+ | Core | SF, BS, CS, US | Setup, brief, create, and update skills |
292
+ | Feature | QS, SS, AN | Quick skill, stack skill, and analyze source |
293
+ | Quality | AS, TS | Detect skill drift (AS) and verify skill completeness (TS) |
294
+ | Architecture Verification | VS, RA | Pre-code architecture feasibility and refinement |
295
+ | Management | RS, DS | Rename and drop skill versions with transactional safety |
296
+ | Utility | EX | Package and export for consumption |
297
+ | In-Agent | WS, KI | WS: show lifecycle position, active briefs, and forge tier; KI: list knowledge fragments (both in-agent, no file-based workflow) |
298
298
 
299
299
  ---
300
300
 
@@ -313,12 +313,12 @@ Instead of running one workflow per session, you can chain multiple workflows in
313
313
 
314
314
  ### Pipeline Aliases
315
315
 
316
- | Alias | Expands To | First Workflow | Required Target |
317
- |-------|-----------|----------------|-----------------|
318
- | `forge` | `BS CS TS EX` | BS | GitHub URL or local path **+** skill name |
319
- | `forge-quick` | `QS TS EX` | QS | GitHub URL **or** package name |
320
- | `onboard` | `AN CS TS EX` | AN | Project path (defaults to current directory) |
321
- | `maintain` | `AS US TS EX` | AS | Existing skill name |
316
+ | Alias | Expands To | First Workflow | Required Target |
317
+ | ------------- | ------------- | -------------- | -------------------------------------------- |
318
+ | `forge` | `BS CS TS EX` | BS | GitHub URL or local path **+** skill name |
319
+ | `forge-quick` | `QS TS EX` | QS | GitHub URL **or** package name |
320
+ | `onboard` | `AN CS TS EX` | AN | Project path (defaults to current directory) |
321
+ | `maintain` | `AS US TS EX` | AS | Existing skill name |
322
322
 
323
323
  **The first workflow's input contract defines what arguments the pipeline needs.** A bare package name works for `forge-quick` (QS resolves packages via the registry) but **not** for `forge` — BS requires both an unambiguous target (URL or path) and a skill name.
324
324
 
@@ -390,6 +390,25 @@ Parent skills and CI pipelines `grep` one line out of the workflow log to learn
390
390
 
391
391
  **Batch mode (`--batch <file>`).** Drives N targets through the full pipeline (steps 1–7 each) in sequence. Input format: one target per line, `#` comments and blank lines ignored, optional per-line modifiers `language=<lang>` and `scope=<path>`. Per-target output lands in `{skill_package}/` as today; an aggregated summary writes to `{skills_output_folder}/_batch/quick-skill-batch-{ts}.json` (with `quick-skill-batch-latest.json` copy). Per-target boundary events (`{"batch":N,"target":"…","status":"start|done|fail",…}`) and a final `{"batch_summary":true,…}` event extend the per-step event stream above. Full input grammar, summary schema, and exit-code semantics in [`src/skf-quick-skill/SKILL.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-quick-skill/SKILL.md) § "Batch Mode".
392
392
 
393
+ **Exception — `/skf-brief-skill` headless emits a final result envelope and supports presets.** Headless `/skf-brief-skill` is a first-class building block for scripted brief generation (e.g. seeding briefs for N SaaS SDKs that share scope/authority defaults). Three operational contracts beyond per-gate auto-proceed:
394
+
395
+ 1. **Pre-supplied inputs replace prompts.** Headless args are consumed at step-01's GATE in place of the interactive menus: `target_repo`, `skill_name`, `target_version`, `language`, `source_type`, `source_authority`, `doc_urls`, `scope_type`, `scripts_intent`, `force`, `preset`. Required-arg shape depends on `source_type` (`target_repo` + `skill_name` for source-backed; `doc_urls` + `skill_name` for `docs-only`). Absent `source_authority` and `scope_type` are NOT guessed — they're resolved by signal-driven detection (`gh api user` vs. repo owner for authority; the 5-rule heuristic ladder for scope). Full grammar in [`src/skf-brief-skill/references/headless-args.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-brief-skill/references/headless-args.md).
396
+
397
+ 2. **Structured exit-code map.** Every HARD HALT exits with a stable code so pipelines branch on the failure class without grepping message text:
398
+
399
+ | Code | Meaning |
400
+ | ---- | ----------------------------- |
401
+ | 0 | success |
402
+ | 2 | input-missing / input-invalid |
403
+ | 3 | resolution-failure |
404
+ | 4 | write-failure |
405
+ | 5 | overwrite-cancelled |
406
+ | 6 | user-cancelled |
407
+
408
+ 3. **Final result envelope on every terminal exit.** Step-05 emits a single-line `SKF_BRIEF_RESULT_JSON: {…}` envelope on **stdout** before chaining to step-06 on success; every HARD HALT emits the same envelope shape on **stderr** with `status: "error"` and a typed `halt_reason` (`input-missing`, `input-invalid`, `forge-tier-missing`, `target-inaccessible`, `gh-auth-failed`, `write-failed`, `overwrite-cancelled`, `user-cancelled`). Full envelope schema and population rules in [`src/skf-brief-skill/SKILL.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-brief-skill/SKILL.md) § "Result Contract (Headless)".
409
+
410
+ **Presets (`--preset <name>`).** Loads `{sidecar_path}/brief-presets/{name}.yaml` and merges its keys as defaults at step-01 §8; explicit headless args override preset values. The preset file is YAML containing any subset of the headless args above; unknown fields are ignored with a warning. Useful for repeated patterns — e.g. briefing 5 SaaS SDKs that all share `source_authority=community`, `scope_type=full-library`, `scripts_intent=skip`.
411
+
393
412
  ---
394
413
 
395
414
  ## Terminal Step: Health Check
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-module-skill-forge",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
6
6
  "keywords": [
7
7
  "bmad",
@@ -53,7 +53,7 @@
53
53
  "test:cli": "node test/test-cli-integration.js",
54
54
  "test:install": "node test/test-installation-components.js",
55
55
  "test:knowledge": "node test/test-knowledge-base.js",
56
- "test:python": "uv run --with pytest --with pyyaml pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py -v",
56
+ "test:python": "uv run --with pytest --with pyyaml pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py test/test-skf-validate-brief-inputs.py test/test-skf-emit-brief-result-envelope.py test/test-skf-write-skill-brief.py test/test-skf-detect-workspaces.py test/test-skf-recommend-scope-type.py test/test-skf-detect-language.py -v",
57
57
  "test:schemas": "node test/test-agent-schema.js",
58
58
  "test:workflow": "node test/test-workflow-state.js",
59
59
  "validate:refs": "node tools/validate-file-refs.js --strict",
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/armelhbobdad/bmad-module-skill-forge/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json",
4
+ "title": "SKF_BRIEF_RESULT_JSON envelope (v1)",
5
+ "description": "Single-line JSON document emitted by skf-brief-skill step-05 §4b when {headless_mode} is true, prefixed with the literal 'SKF_BRIEF_RESULT_JSON: '. Pipelines grep one line out of the workflow log to branch on the brief outcome without parsing prose. Schema mirrors the contract in src/skf-brief-skill/SKILL.md Result Contract section.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["status", "brief_path", "skill_name", "version", "language", "scope_type", "exit_code", "halt_reason"],
9
+ "properties": {
10
+ "status": {
11
+ "type": "string",
12
+ "enum": ["success", "error"],
13
+ "description": "'success' on the terminal happy path (after step-05 wrote the brief and step-06 returned). 'error' on any HARD HALT."
14
+ },
15
+ "brief_path": {
16
+ "type": ["string", "null"],
17
+ "description": "Absolute path to the written skill-brief.yaml on success. Null when the run errored before any file was written (or when overwrite-cancelled aborted before write)."
18
+ },
19
+ "skill_name": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "description": "Brief's skill_name field — present in every envelope (success and error) since it is captured at step-01 §6 before any of the other failure branches."
23
+ },
24
+ "version": {
25
+ "type": ["string", "null"],
26
+ "description": "Resolved brief version (target_version if set, else auto-detected, else '1.0.0'). Null when the run errored before step-05's version-resolution ran."
27
+ },
28
+ "language": {
29
+ "type": ["string", "null"],
30
+ "description": "Detected or user-confirmed primary language. Null when the run errored before step-02's language-detection ran."
31
+ },
32
+ "scope_type": {
33
+ "type": ["string", "null"],
34
+ "enum": ["full-library", "specific-modules", "public-api", "component-library", "reference-app", "docs-only", null],
35
+ "description": "Confirmed scope.type from step-03. Null when the run errored before scope was confirmed."
36
+ },
37
+ "exit_code": {
38
+ "type": "integer",
39
+ "enum": [0, 2, 3, 4, 5, 6],
40
+ "description": "Process exit code matching the SKILL.md Exit Codes table. Derived deterministically from halt_reason: null→0, input-missing/input-invalid→2, forge-tier-missing/target-inaccessible/gh-auth-failed→3, write-failed→4, overwrite-cancelled→5, user-cancelled→6."
41
+ },
42
+ "halt_reason": {
43
+ "type": ["string", "null"],
44
+ "enum": [
45
+ null,
46
+ "input-missing",
47
+ "input-invalid",
48
+ "forge-tier-missing",
49
+ "target-inaccessible",
50
+ "gh-auth-failed",
51
+ "write-failed",
52
+ "overwrite-cancelled",
53
+ "user-cancelled"
54
+ ],
55
+ "description": "Null on success. One of the documented HALT classes on error. Pipelines branch on this to surface the right operator message without grepping the prose error."
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/armelhbobdad/bmad-module-skill-forge/src/shared/scripts/schemas/skill-brief.v1.json",
4
+ "title": "skill-brief.yaml (v1)",
5
+ "description": "Canonical schema for the skill-brief.yaml artifact written by skf-brief-skill step-05 and consumed by skf-create-skill, skf-update-skill, skf-audit-skill. Mirrors src/skf-brief-skill/assets/skill-brief-schema.md.",
6
+ "type": "object",
7
+ "required": ["name", "version", "source_repo", "language", "description", "forge_tier", "created", "created_by", "scope"],
8
+ "properties": {
9
+ "name": {
10
+ "type": "string",
11
+ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
12
+ "description": "Unique skill identifier (kebab-case)."
13
+ },
14
+ "version": {
15
+ "type": "string",
16
+ "pattern": "^v?\\d+\\.\\d+\\.\\d+([.\\-+][0-9A-Za-z][0-9A-Za-z.\\-+]*)?$",
17
+ "description": "Resolved semver. target_version wins; else auto-detected; else 1.0.0."
18
+ },
19
+ "target_version": {
20
+ "type": "string",
21
+ "pattern": "^v?\\d+\\.\\d+\\.\\d+([.\\-+][0-9A-Za-z][0-9A-Za-z.\\-+]*)?$",
22
+ "description": "User-specified target. When present, MUST equal version."
23
+ },
24
+ "source_type": {
25
+ "type": "string",
26
+ "enum": ["source", "docs-only"]
27
+ },
28
+ "source_repo": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "description": "GitHub URL or local path. Required even for docs-only (set to doc site URL)."
32
+ },
33
+ "source_authority": {
34
+ "type": "string",
35
+ "enum": ["official", "community", "internal"]
36
+ },
37
+ "doc_urls": {
38
+ "type": "array",
39
+ "items": {
40
+ "type": "object",
41
+ "required": ["url"],
42
+ "properties": {
43
+ "url": { "type": "string", "minLength": 1 },
44
+ "label": { "type": "string" }
45
+ }
46
+ },
47
+ "minItems": 1,
48
+ "description": "Required when source_type is docs-only; allowed as supplemental for source."
49
+ },
50
+ "language": { "type": "string", "minLength": 1 },
51
+ "description": { "type": "string", "minLength": 1 },
52
+ "forge_tier": {
53
+ "type": "string",
54
+ "enum": ["Quick", "Forge", "Forge+", "Deep"]
55
+ },
56
+ "created": {
57
+ "type": "string",
58
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
59
+ },
60
+ "created_by": { "type": "string", "minLength": 1 },
61
+ "scripts_intent": { "type": "string", "minLength": 1 },
62
+ "assets_intent": { "type": "string", "minLength": 1 },
63
+ "scope": {
64
+ "type": "object",
65
+ "required": ["type", "include", "exclude", "notes"],
66
+ "properties": {
67
+ "type": {
68
+ "type": "string",
69
+ "enum": ["full-library", "specific-modules", "public-api", "component-library", "reference-app", "docs-only"]
70
+ },
71
+ "include": { "type": "array", "items": { "type": "string" } },
72
+ "exclude": { "type": "array", "items": { "type": "string" } },
73
+ "notes": { "type": "string" }
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://armelhbobdad.github.io/bmad-module-skill-forge/schemas/workspace-detection.v1.json",
4
+ "title": "Workspace Detection Result (v1)",
5
+ "description": "Result envelope returned by skf-detect-workspaces.py. Reports whether the inspected target is a monorepo, which manifest kind drives it, and the list of resolved workspaces.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["is_monorepo", "manifest_kind", "workspaces", "warnings"],
9
+ "properties": {
10
+ "is_monorepo": {
11
+ "type": "boolean",
12
+ "description": "True if any detector matched at least one workspace."
13
+ },
14
+ "manifest_kind": {
15
+ "description": "Which detector produced the result. null when is_monorepo is false.",
16
+ "oneOf": [
17
+ {
18
+ "type": "string",
19
+ "enum": ["npm-workspaces", "pnpm-workspaces", "lerna", "cargo-workspace", "python-multi-package", "generic-folders"]
20
+ },
21
+ { "type": "null" }
22
+ ]
23
+ },
24
+ "workspaces": {
25
+ "type": "array",
26
+ "description": "Resolved workspaces. Empty when is_monorepo is false. Each entry has a name, a repository-relative path, and the path to its recognised manifest.",
27
+ "items": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": ["name", "path", "manifest"],
31
+ "properties": {
32
+ "name": { "type": "string", "minLength": 1 },
33
+ "path": { "type": "string", "minLength": 1 },
34
+ "manifest": { "type": "string", "minLength": 1 }
35
+ }
36
+ }
37
+ },
38
+ "warnings": {
39
+ "type": "array",
40
+ "description": "Non-fatal parse warnings encountered during detection (e.g. malformed manifests that the detector skipped).",
41
+ "items": { "type": "string" }
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,277 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Detect Language — deterministic primary-language detection from a flat file tree.
6
+
7
+ Single source of truth for the language-detection rule table that
8
+ skf-brief-skill step-02 §3 applies. The rule walk is purely deterministic:
9
+ manifest-file presence first (Cargo.toml → rust, package.json → js/ts,
10
+ etc.), then extension-frequency fallback. Moving it into a shared script
11
+ saves ~150-250 tokens per workflow invocation and removes a quiet drift
12
+ seam — the JS-vs-TS disambiguation in particular benefits from being
13
+ co-located with a deterministic rule rather than restated in prose.
14
+
15
+ Detection rules (apply in order, first match wins):
16
+
17
+ 1. package.json (with optional tsconfig.json companion):
18
+ tsconfig.json present → typescript (high)
19
+ tsconfig.json absent → javascript (high)
20
+ 2. Cargo.toml → rust (high)
21
+ 3. pyproject.toml | setup.py | setup.cfg → python (high)
22
+ 4. go.mod → go (high)
23
+ 5. pom.xml → java (high)
24
+ 6. build.gradle.kts → kotlin (high)
25
+ 7. build.gradle (Groovy) — check tree:
26
+ src/main/kotlin/ → kotlin (medium)
27
+ else → java (medium)
28
+ 8. *.csproj | *.sln → csharp (high)
29
+ 9. Gemfile → ruby (high)
30
+ 10. Extension-frequency fallback over the full tree.
31
+ dominant extension >= 50% of code files → that language (medium)
32
+ no clear winner → unknown (low)
33
+
34
+ CLI:
35
+ echo '{"tree": ["path1", "path2", ...]}' | uv run skf-detect-language.py
36
+ uv run skf-detect-language.py --json '{"tree": [...]}'
37
+
38
+ Input (JSON object on stdin or via --json):
39
+ tree — list of repo-relative file paths (required, non-empty)
40
+
41
+ Output (JSON on stdout):
42
+ language — javascript | typescript | rust | python | go
43
+ | java | kotlin | csharp | ruby | swift | php
44
+ | unknown
45
+ confidence — "high" | "medium" | "low"
46
+ detection_source — human-readable string naming what fired (manifest
47
+ basename, extension share, etc.)
48
+ fallback_to_extension_frequency — bool (true when rule 10 fired)
49
+
50
+ Exit codes:
51
+ 0 — recommendation produced (even when language is "unknown")
52
+ 2 — internal error (bad JSON input, IO failure)
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import argparse
58
+ import json
59
+ import sys
60
+ from collections import Counter
61
+ from typing import Any
62
+
63
+ # Manifest basenames the rule table checks. Kept tight — tree-wide pattern
64
+ # matches (e.g. *.csproj) are handled separately to avoid false positives
65
+ # from generated artifacts under build/ or dist/.
66
+ _MANIFEST_RULES: list[tuple[str, str, str]] = [
67
+ # (basename, language, detection_source)
68
+ ("Cargo.toml", "rust", "Cargo.toml present"),
69
+ ("pyproject.toml", "python", "pyproject.toml present"),
70
+ ("setup.py", "python", "setup.py present"),
71
+ ("setup.cfg", "python", "setup.cfg present"),
72
+ ("go.mod", "go", "go.mod present"),
73
+ ("pom.xml", "java", "pom.xml present"),
74
+ ("build.gradle.kts", "kotlin", "build.gradle.kts present"),
75
+ ("Gemfile", "ruby", "Gemfile present"),
76
+ ]
77
+
78
+ # Glob-style suffix rules — matches anywhere in the tree. csproj/sln are
79
+ # C#-specific. Ordered so the first hit wins.
80
+ _SUFFIX_RULES: list[tuple[str, str, str]] = [
81
+ (".csproj", "csharp", ".csproj project file present"),
82
+ (".sln", "csharp", ".sln solution file present"),
83
+ ]
84
+
85
+ # Extension → language for the frequency fallback. Tightly limited to source
86
+ # extensions; keeping this small avoids the fallback being skewed by
87
+ # auto-generated assets (.json, .md, .yaml, etc.).
88
+ _EXTENSION_TO_LANGUAGE: dict[str, str] = {
89
+ ".py": "python",
90
+ ".js": "javascript",
91
+ ".jsx": "javascript",
92
+ ".mjs": "javascript",
93
+ ".cjs": "javascript",
94
+ ".ts": "typescript",
95
+ ".tsx": "typescript",
96
+ ".rs": "rust",
97
+ ".go": "go",
98
+ ".java": "java",
99
+ ".kt": "kotlin",
100
+ ".kts": "kotlin",
101
+ ".cs": "csharp",
102
+ ".rb": "ruby",
103
+ ".swift": "swift",
104
+ ".php": "php",
105
+ }
106
+
107
+ _DOMINANCE_THRESHOLD = 0.50
108
+
109
+
110
+ def _die(message: str, code: int = 2) -> None:
111
+ sys.stderr.write(f"skf-detect-language: {message}\n")
112
+ sys.exit(code)
113
+
114
+
115
+ def _basename(path: str) -> str:
116
+ return path.rsplit("/", 1)[-1]
117
+
118
+
119
+ def _has_basename(tree: list[str], basename: str) -> bool:
120
+ return any(_basename(p) == basename for p in tree)
121
+
122
+
123
+ def _has_suffix(tree: list[str], suffix: str) -> bool:
124
+ return any(p.endswith(suffix) for p in tree)
125
+
126
+
127
+ def _has_path_segment(tree: list[str], segment: str) -> bool:
128
+ """True when any path contains the given segment (e.g. 'src/main/kotlin/')."""
129
+ return any(segment in p for p in tree)
130
+
131
+
132
+ def _extension(path: str) -> str:
133
+ """Return the lowercased file extension including the leading dot, or empty string."""
134
+ base = _basename(path)
135
+ if "." not in base or base.startswith("."):
136
+ return ""
137
+ return "." + base.rsplit(".", 1)[-1].lower()
138
+
139
+
140
+ def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
141
+ """Score the tree by source-extension frequency. Returns the result envelope."""
142
+ counter: Counter[str] = Counter()
143
+ for path in tree:
144
+ ext = _extension(path)
145
+ if ext in _EXTENSION_TO_LANGUAGE:
146
+ counter[ext] += 1
147
+
148
+ total = sum(counter.values())
149
+ if total == 0:
150
+ return {
151
+ "language": "unknown",
152
+ "confidence": "low",
153
+ "detection_source": "no recognized source extensions in tree",
154
+ "fallback_to_extension_frequency": True,
155
+ }
156
+
157
+ # Largest-share extension wins. Ties (e.g. equal counts) broken by
158
+ # the natural order of _EXTENSION_TO_LANGUAGE — Counter.most_common
159
+ # is stable for equal counts and we don't rely on which loses.
160
+ top_ext, top_count = counter.most_common(1)[0]
161
+ share = top_count / total
162
+ language = _EXTENSION_TO_LANGUAGE[top_ext]
163
+
164
+ if share >= _DOMINANCE_THRESHOLD:
165
+ confidence = "medium"
166
+ source = f"extension frequency: {top_ext} is {top_count}/{total} of source files ({share:.0%})"
167
+ else:
168
+ confidence = "low"
169
+ source = f"no dominant extension: top is {top_ext} at {top_count}/{total} ({share:.0%}, threshold {int(_DOMINANCE_THRESHOLD * 100)}%)"
170
+ # Still surface the best-guess language; caller / step-03 §4 lets
171
+ # the user override on low confidence.
172
+ return {
173
+ "language": language,
174
+ "confidence": confidence,
175
+ "detection_source": source,
176
+ "fallback_to_extension_frequency": True,
177
+ }
178
+
179
+
180
+ def detect(payload: dict[str, Any]) -> dict[str, Any]:
181
+ """Apply the documented rule walk. Always returns a recommendation."""
182
+ tree = payload.get("tree")
183
+ if not isinstance(tree, list):
184
+ _die("payload.tree must be an array of repo-relative file paths")
185
+ if len(tree) == 0:
186
+ _die("payload.tree must be non-empty")
187
+
188
+ # Rule 1 — package.json (with tsconfig.json disambiguation)
189
+ if _has_basename(tree, "package.json"):
190
+ if _has_basename(tree, "tsconfig.json"):
191
+ return {
192
+ "language": "typescript",
193
+ "confidence": "high",
194
+ "detection_source": "package.json + tsconfig.json present",
195
+ "fallback_to_extension_frequency": False,
196
+ }
197
+ return {
198
+ "language": "javascript",
199
+ "confidence": "high",
200
+ "detection_source": "package.json present (no tsconfig.json)",
201
+ "fallback_to_extension_frequency": False,
202
+ }
203
+
204
+ # Rules 2-6 — single-basename manifests walked in priority order. Note
205
+ # that build.gradle (Groovy DSL) is NOT in this table; it requires
206
+ # tree-aware Java/Kotlin disambiguation handled in Rule 7b below.
207
+ # build.gradle.kts IS in the table (returns kotlin high) and fires
208
+ # here before the Groovy variant is considered.
209
+ for basename, language, source in _MANIFEST_RULES:
210
+ if _has_basename(tree, basename):
211
+ return {
212
+ "language": language,
213
+ "confidence": "high",
214
+ "detection_source": source,
215
+ "fallback_to_extension_frequency": False,
216
+ }
217
+
218
+ # Rule 7b — build.gradle (Groovy DSL): check src/main/kotlin/ to disambiguate.
219
+ # This rule sits *after* the basename loop because build.gradle.kts is
220
+ # already covered by the loop and we don't want it to pre-empt that match.
221
+ if _has_basename(tree, "build.gradle"):
222
+ if _has_path_segment(tree, "src/main/kotlin/"):
223
+ return {
224
+ "language": "kotlin",
225
+ "confidence": "medium",
226
+ "detection_source": "build.gradle (Groovy) + src/main/kotlin/ present",
227
+ "fallback_to_extension_frequency": False,
228
+ }
229
+ return {
230
+ "language": "java",
231
+ "confidence": "medium",
232
+ "detection_source": "build.gradle (Groovy) present without src/main/kotlin/ — defaulting to java",
233
+ "fallback_to_extension_frequency": False,
234
+ }
235
+
236
+ # Rules 8-9 — suffix-based (csproj, sln)
237
+ for suffix, language, source in _SUFFIX_RULES:
238
+ if _has_suffix(tree, suffix):
239
+ return {
240
+ "language": language,
241
+ "confidence": "high",
242
+ "detection_source": source,
243
+ "fallback_to_extension_frequency": False,
244
+ }
245
+
246
+ # Rule 10 — extension-frequency fallback
247
+ return _frequency_fallback(tree)
248
+
249
+
250
+ def _parse_argv(argv: list[str]) -> dict:
251
+ parser = argparse.ArgumentParser(
252
+ description="Detect primary language from a flat repo file tree by walking the documented rule table.",
253
+ )
254
+ parser.add_argument("--json", help="JSON payload (alternative to stdin)")
255
+ args = parser.parse_args(argv)
256
+ raw = args.json if args.json is not None else sys.stdin.read()
257
+ if not raw or not raw.strip():
258
+ _die("empty input (expected JSON payload on stdin or via --json)")
259
+ try:
260
+ payload = json.loads(raw)
261
+ except json.JSONDecodeError as e:
262
+ _die(f"invalid JSON input: {e}")
263
+ if not isinstance(payload, dict):
264
+ _die("payload must be a JSON object")
265
+ return payload
266
+
267
+
268
+ def main(argv: list[str]) -> int:
269
+ payload = _parse_argv(argv)
270
+ result = detect(payload)
271
+ json.dump(result, sys.stdout, ensure_ascii=False)
272
+ sys.stdout.write("\n")
273
+ return 0
274
+
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main(sys.argv[1:]))