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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +34 -15
- package/package.json +2 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
- package/src/shared/scripts/skf-detect-language.py +277 -0
- package/src/shared/scripts/skf-detect-workspaces.py +427 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
- package/src/shared/scripts/skf-extract-public-api.py +29 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
- package/src/shared/scripts/skf-write-skill-brief.py +509 -0
- package/src/skf-brief-skill/SKILL.md +41 -12
- package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
- package/src/skf-brief-skill/assets/scope-templates.md +5 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
- package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
- package/src/skf-brief-skill/references/headless-args.md +22 -0
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
- package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
- package/src/skf-brief-skill/references/version-resolution.md +46 -0
- package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
- package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
- package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
- package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
- package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
- package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
package/docs/_data/pinned.yaml
CHANGED
|
@@ -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.
|
|
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
|
|
290
|
-
|
|
291
|
-
| Core
|
|
292
|
-
| Feature
|
|
293
|
-
| Quality
|
|
294
|
-
| Architecture Verification | VS, RA
|
|
295
|
-
| Management
|
|
296
|
-
| Utility
|
|
297
|
-
| In-Agent
|
|
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
|
|
317
|
-
|
|
318
|
-
| `forge`
|
|
319
|
-
| `forge-quick` | `QS TS EX`
|
|
320
|
-
| `onboard`
|
|
321
|
-
| `maintain`
|
|
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.
|
|
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:]))
|