bmad-module-skill-forge 2.0.0 → 2.0.2
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/.nvmrc +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +1 -1
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +2 -2
- package/src/shared/health-check.md +40 -3
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-detect-docs.py +232 -1
- package/src/shared/scripts/skf-detect-language.py +77 -0
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +39 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +42 -180
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +6 -14
- package/src/skf-brief-skill/references/confirm-brief.md +2 -14
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +19 -22
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +4 -14
- package/src/skf-brief-skill/references/step-auto-brief.md +1 -5
- package/src/skf-brief-skill/references/step-auto-validate.md +1 -5
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +17 -10
- package/src/skf-campaign/SKILL.md +15 -84
- package/src/skf-campaign/customize.toml +2 -0
- package/src/skf-campaign/manifest.yaml +4 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +14 -103
- package/src/skf-campaign/references/step-01-setup.md +4 -5
- package/src/skf-campaign/references/step-02-strategy.md +8 -3
- package/src/skf-campaign/references/step-03-pins.md +1 -1
- package/src/skf-campaign/references/step-04-provenance.md +1 -1
- package/src/skf-campaign/references/step-05-skill-loop.md +1 -1
- package/src/skf-campaign/references/step-06-batch.md +7 -6
- package/src/skf-campaign/references/step-11-maintenance.md +2 -2
- package/src/skf-campaign/references/step-resume.md +9 -8
- package/src/skf-campaign/scripts/campaign-deps.py +10 -1
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +1 -1
- package/src/skf-create-skill/SKILL.md +14 -3
- package/src/skf-create-skill/assets/compile-assembly-rules.md +17 -38
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +34 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +14 -29
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +7 -7
- package/src/skf-create-skill/references/generate-artifacts.md +9 -23
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +12 -15
- package/src/skf-create-skill/references/source-resolution-protocols.md +3 -3
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +30 -12
- package/src/skf-create-skill/references/step-doc-sources.md +2 -2
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +8 -50
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +59 -28
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +51 -31
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +42 -10
- package/src/skf-drop-skill/references/select.md +88 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +27 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +14 -7
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +9 -7
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +13 -10
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +107 -78
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +20 -12
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +9 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +6 -8
- package/src/skf-setup/references/report.md +22 -31
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +109 -75
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +38 -20
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +6 -6
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +131 -1
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +14 -0
- package/src/skf-update-skill/references/detect-changes.md +19 -37
- package/src/skf-update-skill/references/health-check.md +3 -9
- package/src/skf-update-skill/references/init.md +39 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +12 -24
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- package/src/skf-rename-skill/references/rebuild-context.md +0 -110
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Count Tokens — Deterministic per-artifact token/word metrics for a skill package.
|
|
6
|
+
|
|
7
|
+
Pre-pass that measures the exported artifacts of a single skill package and the
|
|
8
|
+
shared managed section, so the export skill's token report (references/token-report.md)
|
|
9
|
+
and snippet token check (references/generate-snippet.md §4) render exact numbers from
|
|
10
|
+
JSON instead of re-reading every reference file to word-count it in-prompt.
|
|
11
|
+
|
|
12
|
+
Metrics:
|
|
13
|
+
- words: whitespace-split word count (`len(text.split())`) — the report's "Words" column
|
|
14
|
+
- tokens: char-over-four estimate (`len(text) // 4`) — the SKF-wide convention already
|
|
15
|
+
used by skf-validate-output.py, so every SKF token number agrees
|
|
16
|
+
|
|
17
|
+
Measured artifacts (all relative to the positional skill-package dir):
|
|
18
|
+
- context-snippet.md — the compressed passive-context snippet (if present)
|
|
19
|
+
- SKILL.md — the active skill document
|
|
20
|
+
- metadata.json — machine-readable metadata
|
|
21
|
+
- references/* — every file under references/ (recursively), summed as references_total
|
|
22
|
+
- managed section — the complete <!-- SKF:BEGIN ... --> ... <!-- SKF:END --> block,
|
|
23
|
+
extracted from the first --target-file that contains one
|
|
24
|
+
(shared across the batch; measured once)
|
|
25
|
+
|
|
26
|
+
package_total sums context-snippet.md + SKILL.md + metadata.json + references_total and
|
|
27
|
+
DELIBERATELY EXCLUDES the managed-section row — the managed section is a shared all-skills
|
|
28
|
+
cost reported separately, and including it would double-count this skill's own snippet
|
|
29
|
+
(see references/token-report.md §1).
|
|
30
|
+
|
|
31
|
+
CLI:
|
|
32
|
+
python3 skf-count-tokens.py <skill-package-dir>
|
|
33
|
+
python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md
|
|
34
|
+
python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md --target-file AGENTS.md
|
|
35
|
+
python3 skf-count-tokens.py <skill-package-dir> -o report.json
|
|
36
|
+
|
|
37
|
+
Exit codes: 0 = metrics emitted, 2 = error (skill package dir missing / not a directory).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
# Mirrors skf-rebuild-managed-sections.py: a BEGIN marker may carry attributes
|
|
49
|
+
# (e.g. `updated:2026-07-13`) before its closing `-->`; the END marker is fixed.
|
|
50
|
+
MANAGED_SECTION_RE = re.compile(
|
|
51
|
+
r"<!-- SKF:BEGIN[^>]*-->.*?<!-- SKF:END -->",
|
|
52
|
+
re.DOTALL,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Core per-skill artifacts, in report order. (references/ is handled separately.)
|
|
56
|
+
CORE_ARTIFACTS = [
|
|
57
|
+
("context-snippet.md", "context-snippet"),
|
|
58
|
+
("SKILL.md", "skill-md"),
|
|
59
|
+
("metadata.json", "metadata"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def estimate_tokens(text: str) -> int:
|
|
64
|
+
"""Char-over-four token estimate — the SKF-wide convention (see skf-validate-output.py)."""
|
|
65
|
+
return len(text) // 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def count_words(text: str) -> int:
|
|
69
|
+
"""Whitespace-split word count — the token report's 'Words' column."""
|
|
70
|
+
return len(text.split())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def measure(text: str) -> dict:
|
|
74
|
+
"""Return the {words, tokens} metrics for a piece of text."""
|
|
75
|
+
return {"words": count_words(text), "tokens": estimate_tokens(text)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_text(path: Path) -> str:
|
|
79
|
+
# encoding is pinned to utf-8 so non-ASCII content is not mojibaked on
|
|
80
|
+
# Windows (cp1252 default).
|
|
81
|
+
return path.read_text(encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_managed_section(text: str) -> str | None:
|
|
85
|
+
"""Return the complete BEGIN..END managed-section span (markers included), or None."""
|
|
86
|
+
m = MANAGED_SECTION_RE.search(text)
|
|
87
|
+
return m.group(0) if m else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def measure_reference_files(references_dir: Path) -> tuple[list[dict], dict]:
|
|
91
|
+
"""Measure every file under references/ (recursively, sorted). Returns (per-file rows, total)."""
|
|
92
|
+
rows: list[dict] = []
|
|
93
|
+
total_words = 0
|
|
94
|
+
total_tokens = 0
|
|
95
|
+
if references_dir.is_dir():
|
|
96
|
+
for path in sorted(references_dir.rglob("*"), key=lambda p: p.as_posix()):
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
continue
|
|
99
|
+
text = _read_text(path)
|
|
100
|
+
metrics = measure(text)
|
|
101
|
+
rows.append(
|
|
102
|
+
{
|
|
103
|
+
"path": path.relative_to(references_dir.parent).as_posix(),
|
|
104
|
+
"role": "reference",
|
|
105
|
+
"exists": True,
|
|
106
|
+
**metrics,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
total_words += metrics["words"]
|
|
110
|
+
total_tokens += metrics["tokens"]
|
|
111
|
+
total = {"count": len(rows), "words": total_words, "tokens": total_tokens}
|
|
112
|
+
return rows, total
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> dict:
|
|
116
|
+
"""Measure a skill package's artifacts and the shared managed section.
|
|
117
|
+
|
|
118
|
+
Returns the metrics dict (see module docstring for the shape). Missing core
|
|
119
|
+
artifacts are reported with exists=False and zero metrics so package_total is
|
|
120
|
+
always well-defined and callers can render 'N/A' rows without guessing.
|
|
121
|
+
"""
|
|
122
|
+
skill_dir = Path(skill_dir)
|
|
123
|
+
target_files = target_files or []
|
|
124
|
+
|
|
125
|
+
files: list[dict] = []
|
|
126
|
+
|
|
127
|
+
# Core per-skill artifacts.
|
|
128
|
+
for name, role in CORE_ARTIFACTS:
|
|
129
|
+
path = skill_dir / name
|
|
130
|
+
if path.is_file():
|
|
131
|
+
metrics = measure(_read_text(path))
|
|
132
|
+
files.append({"path": name, "role": role, "exists": True, **metrics})
|
|
133
|
+
else:
|
|
134
|
+
files.append({"path": name, "role": role, "exists": False, "words": 0, "tokens": 0})
|
|
135
|
+
|
|
136
|
+
# references/ — per-file rows + summed total.
|
|
137
|
+
ref_rows, references_total = measure_reference_files(skill_dir / "references")
|
|
138
|
+
files.extend(ref_rows)
|
|
139
|
+
|
|
140
|
+
# package_total = snippet + SKILL.md + metadata + references_total; EXCLUDES managed section.
|
|
141
|
+
core_by_role = {row["role"]: row for row in files if row["role"] in {"context-snippet", "skill-md", "metadata"}}
|
|
142
|
+
package_total = {
|
|
143
|
+
"words": sum(core_by_role[r]["words"] for r in core_by_role) + references_total["words"],
|
|
144
|
+
"tokens": sum(core_by_role[r]["tokens"] for r in core_by_role) + references_total["tokens"],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Managed section — first --target-file that actually contains a block wins.
|
|
148
|
+
managed_section = {"present": False, "source_file": None, "words": 0, "tokens": 0}
|
|
149
|
+
for tf in target_files:
|
|
150
|
+
tf = Path(tf)
|
|
151
|
+
if not tf.is_file():
|
|
152
|
+
continue
|
|
153
|
+
section = extract_managed_section(_read_text(tf))
|
|
154
|
+
if section is not None:
|
|
155
|
+
managed_section = {
|
|
156
|
+
"present": True,
|
|
157
|
+
"source_file": tf.as_posix(),
|
|
158
|
+
**measure(section),
|
|
159
|
+
}
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"status": "ok",
|
|
164
|
+
"skill_package": skill_dir.as_posix(),
|
|
165
|
+
"files": files,
|
|
166
|
+
"references_total": references_total,
|
|
167
|
+
"managed_section": managed_section,
|
|
168
|
+
"package_total": package_total,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
parser = argparse.ArgumentParser(
|
|
174
|
+
prog="skf-count-tokens.py",
|
|
175
|
+
description=(
|
|
176
|
+
"Deterministic per-artifact word/token metrics for a skill package. Emits JSON "
|
|
177
|
+
"with per-file rows, references_total, the shared managed-section block, and a "
|
|
178
|
+
"package_total that excludes the managed section (see references/token-report.md §1). "
|
|
179
|
+
"tokens = len(text)//4 (SKF-wide convention); words = whitespace-split count."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"skill_package",
|
|
184
|
+
help="Resolved skill-package directory (holds context-snippet.md, SKILL.md, metadata.json, references/)",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--target-file",
|
|
188
|
+
action="append",
|
|
189
|
+
default=[],
|
|
190
|
+
metavar="PATH",
|
|
191
|
+
help="Context file (CLAUDE.md / AGENTS.md / .cursorrules) to extract the managed "
|
|
192
|
+
"section from; repeatable — the first file with a BEGIN..END block is measured.",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("-o", "--output", metavar="PATH", help="Write JSON here (default: stdout)")
|
|
195
|
+
parser.add_argument("--verbose", action="store_true", help="Emit diagnostics to stderr")
|
|
196
|
+
args = parser.parse_args(argv)
|
|
197
|
+
|
|
198
|
+
skill_dir = Path(args.skill_package)
|
|
199
|
+
if not skill_dir.is_dir():
|
|
200
|
+
print(f"error: skill package directory not found: {skill_dir}", file=sys.stderr)
|
|
201
|
+
return 2
|
|
202
|
+
|
|
203
|
+
if args.verbose:
|
|
204
|
+
print(f"measuring skill package: {skill_dir}", file=sys.stderr)
|
|
205
|
+
if args.target_file:
|
|
206
|
+
print(f"managed-section candidates: {args.target_file}", file=sys.stderr)
|
|
207
|
+
|
|
208
|
+
result = count_package(skill_dir, [Path(p) for p in args.target_file])
|
|
209
|
+
|
|
210
|
+
payload = json.dumps(result, indent=2)
|
|
211
|
+
if args.output:
|
|
212
|
+
Path(args.output).write_text(payload + "\n", encoding="utf-8")
|
|
213
|
+
if args.verbose:
|
|
214
|
+
print(f"wrote {args.output}", file=sys.stderr)
|
|
215
|
+
else:
|
|
216
|
+
print(payload)
|
|
217
|
+
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(main())
|
|
@@ -38,6 +38,42 @@ Exit codes:
|
|
|
38
38
|
0 found >=1 documentation source
|
|
39
39
|
1 no documentation sources found (empty array)
|
|
40
40
|
2 error (invalid args, gh not found, etc.)
|
|
41
|
+
|
|
42
|
+
---------------------------------------------------------------------------
|
|
43
|
+
Subcommand: compare-hashes
|
|
44
|
+
---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
Doc-drift detection for audit-skill. Given a set of tracked doc sources
|
|
47
|
+
(each `{url, content_hash}`), re-fetch every URL, hash the response bytes with
|
|
48
|
+
the SAME fetch+sha256 primitive the compile side used, and classify each entry
|
|
49
|
+
as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
|
|
50
|
+
per-URL fetch/hash/compare/count prose at
|
|
51
|
+
`src/skf-audit-skill/references/step-doc-drift.md` §2-3 — the model no longer
|
|
52
|
+
orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
|
|
53
|
+
depends on whether a WebFetch tool is wired.
|
|
54
|
+
|
|
55
|
+
uv run src/shared/scripts/skf-detect-docs.py compare-hashes <source>
|
|
56
|
+
|
|
57
|
+
Input `<source>`:
|
|
58
|
+
- a path to a JSON file that is EITHER an array of `{url, content_hash}`
|
|
59
|
+
entries OR an object with a `doc_sources` array (e.g. a skill's
|
|
60
|
+
metadata.json), OR
|
|
61
|
+
- `-` to read that JSON from stdin.
|
|
62
|
+
|
|
63
|
+
Output (JSON object on stdout):
|
|
64
|
+
{
|
|
65
|
+
"changed": [{"url","old_hash","new_hash"}],
|
|
66
|
+
"unchanged": [{"url"}],
|
|
67
|
+
"fetch_failed": [{"url","old_hash","reason"}],
|
|
68
|
+
"skipped_null_hash": [{"url"}],
|
|
69
|
+
"stats": {"total_tracked","changed","unchanged",
|
|
70
|
+
"fetch_failed","skipped_null_hash"}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Each list is stably sorted by url. Stored hashes are normalized (leading
|
|
74
|
+
`algo:` prefix stripped) before comparison so a bare-hex writer form still
|
|
75
|
+
matches. Exit 0 on any well-formed input (never blocks the informational
|
|
76
|
+
audit); exit 2 on malformed args or JSON.
|
|
41
77
|
"""
|
|
42
78
|
|
|
43
79
|
from __future__ import annotations
|
|
@@ -47,12 +83,13 @@ import base64
|
|
|
47
83
|
import hashlib
|
|
48
84
|
import json
|
|
49
85
|
import re
|
|
86
|
+
import socket
|
|
50
87
|
import subprocess
|
|
51
88
|
import sys
|
|
52
89
|
import urllib.error
|
|
53
90
|
import urllib.request
|
|
54
91
|
from pathlib import Path
|
|
55
|
-
from typing import Any, Dict, List, Optional
|
|
92
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
56
93
|
|
|
57
94
|
_GITHUB_URL_RE = re.compile(
|
|
58
95
|
r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
|
|
@@ -224,6 +261,57 @@ def _fetch_and_hash(url: str) -> Optional[str]:
|
|
|
224
261
|
return None
|
|
225
262
|
|
|
226
263
|
|
|
264
|
+
# Leading algorithm-name prefix on a stored hash (`sha256:`, `sha1:`, ...) so a
|
|
265
|
+
# bare-hex writer form and the prefixed form compare equal.
|
|
266
|
+
_HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _normalize_hash(value: Optional[str]) -> Optional[str]:
|
|
270
|
+
"""Strip a leading `algo:` prefix from a hash so bare-hex and prefixed
|
|
271
|
+
forms compare equal. Returns None for non-string input. Idempotent."""
|
|
272
|
+
if not isinstance(value, str):
|
|
273
|
+
return None
|
|
274
|
+
return _HASH_PREFIX_RE.sub("", value, count=1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
278
|
+
"""Byte-symmetric sibling of `_fetch_and_hash` that surfaces WHY a fetch
|
|
279
|
+
failed for drift reporting.
|
|
280
|
+
|
|
281
|
+
Uses the identical fetch+sha256 primitive as the compile side — same
|
|
282
|
+
`USER_AGENT`, same `_FETCH_TIMEOUT`, sha256 of the raw response bytes with
|
|
283
|
+
the `sha256:` prefix — so a hash produced here compares byte-for-byte
|
|
284
|
+
against a `content_hash` written by `_fetch_and_hash` at compile time.
|
|
285
|
+
|
|
286
|
+
Returns `(content_hash, None)` on success or `(None, reason)` on failure.
|
|
287
|
+
Never raises: the doc-drift audit must never abort on a bad URL.
|
|
288
|
+
"""
|
|
289
|
+
if url.startswith("file://"):
|
|
290
|
+
local_path = url[7:]
|
|
291
|
+
try:
|
|
292
|
+
with open(local_path, "rb") as fh:
|
|
293
|
+
content = fh.read()
|
|
294
|
+
except OSError as exc:
|
|
295
|
+
return None, f"local read failed: {exc}"
|
|
296
|
+
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
297
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
298
|
+
try:
|
|
299
|
+
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
300
|
+
content = resp.read()
|
|
301
|
+
except urllib.error.HTTPError as exc:
|
|
302
|
+
return None, f"HTTP {exc.code}"
|
|
303
|
+
except (socket.timeout, TimeoutError):
|
|
304
|
+
return None, f"timeout after {_FETCH_TIMEOUT}s"
|
|
305
|
+
except urllib.error.URLError as exc:
|
|
306
|
+
reason = getattr(exc, "reason", exc)
|
|
307
|
+
if isinstance(reason, (socket.timeout, TimeoutError)):
|
|
308
|
+
return None, f"timeout after {_FETCH_TIMEOUT}s"
|
|
309
|
+
return None, f"URL error: {reason}"
|
|
310
|
+
except Exception as exc: # never let a malformed URL crash the audit
|
|
311
|
+
return None, f"fetch failed: {exc}"
|
|
312
|
+
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
313
|
+
|
|
314
|
+
|
|
227
315
|
# ---------------------------------------------------------------------------
|
|
228
316
|
# Detection method 1 — homepageUrl
|
|
229
317
|
# ---------------------------------------------------------------------------
|
|
@@ -376,11 +464,154 @@ def detect(
|
|
|
376
464
|
return deduped
|
|
377
465
|
|
|
378
466
|
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
# compare-hashes subcommand — doc-drift detection for audit-skill
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
def _load_doc_sources(raw_text: str, source_label: str) -> List[Any]:
|
|
472
|
+
"""Parse a compare-hashes input blob into a list of doc-source entries.
|
|
473
|
+
|
|
474
|
+
Accepts either a top-level JSON array of `{url, content_hash}` entries or a
|
|
475
|
+
top-level object with a `doc_sources` array (e.g. a skill's metadata.json).
|
|
476
|
+
An object without `doc_sources` yields an empty list (nothing tracked).
|
|
477
|
+
|
|
478
|
+
Raises ValueError on malformed JSON or an unexpected top-level shape.
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
data = json.loads(raw_text)
|
|
482
|
+
except json.JSONDecodeError as exc:
|
|
483
|
+
raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
|
|
484
|
+
if isinstance(data, list):
|
|
485
|
+
return data
|
|
486
|
+
if isinstance(data, dict):
|
|
487
|
+
entries = data.get("doc_sources")
|
|
488
|
+
if entries is None:
|
|
489
|
+
return []
|
|
490
|
+
if not isinstance(entries, list):
|
|
491
|
+
raise ValueError(f"`doc_sources` in {source_label} is not an array")
|
|
492
|
+
return entries
|
|
493
|
+
raise ValueError(
|
|
494
|
+
f"{source_label} must be a JSON array or an object with a "
|
|
495
|
+
f"`doc_sources` array; got {type(data).__name__}"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def compare_doc_hashes(doc_sources: List[Any]) -> Dict[str, Any]:
|
|
500
|
+
"""Fetch/hash/compare each tracked doc source and categorize it.
|
|
501
|
+
|
|
502
|
+
Deterministic: the same entries served the same upstream bytes always
|
|
503
|
+
yield the same categorization. Every entry lands in exactly one bucket, so
|
|
504
|
+
`stats.total_tracked` == the sum of the four category counts.
|
|
505
|
+
"""
|
|
506
|
+
changed: List[Dict[str, Any]] = []
|
|
507
|
+
unchanged: List[Dict[str, Any]] = []
|
|
508
|
+
fetch_failed: List[Dict[str, Any]] = []
|
|
509
|
+
skipped_null_hash: List[Dict[str, Any]] = []
|
|
510
|
+
|
|
511
|
+
for entry in doc_sources:
|
|
512
|
+
if not isinstance(entry, dict):
|
|
513
|
+
fetch_failed.append(
|
|
514
|
+
{"url": "", "old_hash": None, "reason": "invalid entry: not an object"}
|
|
515
|
+
)
|
|
516
|
+
continue
|
|
517
|
+
url = entry.get("url")
|
|
518
|
+
if not isinstance(url, str) or not url:
|
|
519
|
+
fetch_failed.append({
|
|
520
|
+
"url": url if isinstance(url, str) else "",
|
|
521
|
+
"old_hash": entry.get("content_hash"),
|
|
522
|
+
"reason": "invalid entry: missing url",
|
|
523
|
+
})
|
|
524
|
+
continue
|
|
525
|
+
stored = entry.get("content_hash")
|
|
526
|
+
if stored is None:
|
|
527
|
+
# No baseline hash was recorded at compile time — nothing to
|
|
528
|
+
# compare against, so skip the fetch entirely.
|
|
529
|
+
skipped_null_hash.append({"url": url})
|
|
530
|
+
continue
|
|
531
|
+
new_hash, reason = _fetch_and_hash_reason(url)
|
|
532
|
+
if new_hash is None:
|
|
533
|
+
fetch_failed.append({"url": url, "old_hash": stored, "reason": reason})
|
|
534
|
+
elif _normalize_hash(new_hash) == _normalize_hash(stored):
|
|
535
|
+
unchanged.append({"url": url})
|
|
536
|
+
else:
|
|
537
|
+
changed.append({"url": url, "old_hash": stored, "new_hash": new_hash})
|
|
538
|
+
|
|
539
|
+
changed.sort(key=lambda e: e["url"])
|
|
540
|
+
unchanged.sort(key=lambda e: e["url"])
|
|
541
|
+
fetch_failed.sort(key=lambda e: e["url"])
|
|
542
|
+
skipped_null_hash.sort(key=lambda e: e["url"])
|
|
543
|
+
|
|
544
|
+
total = len(changed) + len(unchanged) + len(fetch_failed) + len(skipped_null_hash)
|
|
545
|
+
return {
|
|
546
|
+
"changed": changed,
|
|
547
|
+
"unchanged": unchanged,
|
|
548
|
+
"fetch_failed": fetch_failed,
|
|
549
|
+
"skipped_null_hash": skipped_null_hash,
|
|
550
|
+
"stats": {
|
|
551
|
+
"total_tracked": total,
|
|
552
|
+
"changed": len(changed),
|
|
553
|
+
"unchanged": len(unchanged),
|
|
554
|
+
"fetch_failed": len(fetch_failed),
|
|
555
|
+
"skipped_null_hash": len(skipped_null_hash),
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _cmd_compare_hashes(argv: List[str]) -> int:
|
|
561
|
+
parser = argparse.ArgumentParser(
|
|
562
|
+
prog="skf-detect-docs.py compare-hashes",
|
|
563
|
+
description=(
|
|
564
|
+
"Re-fetch each tracked doc URL, hash the response bytes, and "
|
|
565
|
+
"compare against the stored content_hash to detect doc drift."
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
parser.add_argument(
|
|
569
|
+
"source",
|
|
570
|
+
help=(
|
|
571
|
+
"path to a JSON file (array of {url, content_hash} entries, or an "
|
|
572
|
+
"object with a doc_sources array such as metadata.json), or - to "
|
|
573
|
+
"read that JSON from stdin"
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
args = parser.parse_args(argv)
|
|
577
|
+
|
|
578
|
+
if args.source == "-":
|
|
579
|
+
raw = sys.stdin.read()
|
|
580
|
+
label = "<stdin>"
|
|
581
|
+
else:
|
|
582
|
+
path = Path(args.source)
|
|
583
|
+
try:
|
|
584
|
+
raw = path.read_text(encoding="utf-8")
|
|
585
|
+
except OSError as exc:
|
|
586
|
+
json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
|
|
587
|
+
sys.stderr.write("\n")
|
|
588
|
+
return 2
|
|
589
|
+
label = str(path)
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
doc_sources = _load_doc_sources(raw, label)
|
|
593
|
+
except ValueError as exc:
|
|
594
|
+
json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
|
|
595
|
+
sys.stderr.write("\n")
|
|
596
|
+
return 2
|
|
597
|
+
|
|
598
|
+
result = compare_doc_hashes(doc_sources)
|
|
599
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
600
|
+
sys.stdout.write("\n")
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
|
|
379
604
|
# ---------------------------------------------------------------------------
|
|
380
605
|
# CLI entry point
|
|
381
606
|
# ---------------------------------------------------------------------------
|
|
382
607
|
|
|
383
608
|
def main() -> int:
|
|
609
|
+
# Additive subcommand: route `compare-hashes ...` to the doc-drift path
|
|
610
|
+
# before the existing detect parser runs. The default (no subcommand)
|
|
611
|
+
# invocation `--repo-url <url> ...` is unchanged.
|
|
612
|
+
if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
|
|
613
|
+
return _cmd_compare_hashes(sys.argv[2:])
|
|
614
|
+
|
|
384
615
|
parser = argparse.ArgumentParser(
|
|
385
616
|
description="Detect documentation URLs for a GitHub repository.",
|
|
386
617
|
)
|
|
@@ -58,6 +58,16 @@ Output (JSON on stdout):
|
|
|
58
58
|
detection_source — human-readable string naming what fired (manifest
|
|
59
59
|
basename, extension share, etc.)
|
|
60
60
|
fallback_to_extension_frequency — bool (true when rule 10 fired)
|
|
61
|
+
detected_languages — ordered, deduplicated list of EVERY manifest-level
|
|
62
|
+
match in the same priority order the winner walk uses.
|
|
63
|
+
language == detected_languages[0] whenever the manifest
|
|
64
|
+
table fired, so a caller can auto-pick detected_languages[0]
|
|
65
|
+
and gate multi-language disambiguation on
|
|
66
|
+
len(detected_languages) > 1. Special cases: a
|
|
67
|
+
workspace_signal override (rule 0) is decisive and returns
|
|
68
|
+
a single-element [language]; the extension-frequency
|
|
69
|
+
fallback (rule 10) contributes [language] for a best guess
|
|
70
|
+
or [] when language is "unknown".
|
|
61
71
|
|
|
62
72
|
Exit codes:
|
|
63
73
|
0 — recommendation produced (even when language is "unknown")
|
|
@@ -201,7 +211,74 @@ def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
|
|
|
201
211
|
}
|
|
202
212
|
|
|
203
213
|
|
|
214
|
+
def _detected_languages(payload: dict[str, Any], winner: dict[str, Any]) -> list[str]:
|
|
215
|
+
"""Accumulate every manifest-level match into an ordered, deduplicated list.
|
|
216
|
+
|
|
217
|
+
The walk mirrors the winner-selection priority order exactly, so
|
|
218
|
+
winner["language"] == detected_languages[0] whenever the manifest table
|
|
219
|
+
fired. Two decisive short-circuits diverge from a full walk on purpose:
|
|
220
|
+
|
|
221
|
+
* A workspace_signal override (rule 0) is authoritative — the workspace
|
|
222
|
+
root language wins over any nested package.json, so this returns a
|
|
223
|
+
single-element [winner_language] and never surfaces a spurious
|
|
224
|
+
multi-language gate for what workspace detection already resolved.
|
|
225
|
+
* When no manifest matched (rule 10 fired), the list carries the
|
|
226
|
+
extension-frequency best guess as a single element, or [] when the
|
|
227
|
+
guess was "unknown".
|
|
228
|
+
"""
|
|
229
|
+
tree: list[str] = payload["tree"] # validated non-empty by _winner()
|
|
230
|
+
|
|
231
|
+
workspace_signal = payload.get("workspace_signal")
|
|
232
|
+
if isinstance(workspace_signal, str) and workspace_signal in _WORKSPACE_SIGNAL_LANGUAGE:
|
|
233
|
+
return [winner["language"]]
|
|
234
|
+
|
|
235
|
+
langs: list[str] = []
|
|
236
|
+
|
|
237
|
+
def add(lang: str) -> None:
|
|
238
|
+
if lang not in langs:
|
|
239
|
+
langs.append(lang)
|
|
240
|
+
|
|
241
|
+
# Rule 1 — package.json (tsconfig.json disambiguation)
|
|
242
|
+
if _has_basename(tree, "package.json"):
|
|
243
|
+
add("typescript" if _has_basename(tree, "tsconfig.json") else "javascript")
|
|
244
|
+
|
|
245
|
+
# Rules 2-6 / 8 / 10 — single-basename manifests (same order as _MANIFEST_RULES)
|
|
246
|
+
for basename, language, _source in _MANIFEST_RULES:
|
|
247
|
+
if _has_basename(tree, basename):
|
|
248
|
+
add(language)
|
|
249
|
+
|
|
250
|
+
# Rule 7b — build.gradle (Groovy) Java/Kotlin disambiguation
|
|
251
|
+
if _has_basename(tree, "build.gradle"):
|
|
252
|
+
add("kotlin" if _has_path_segment(tree, "src/main/kotlin/") else "java")
|
|
253
|
+
|
|
254
|
+
# Rules 8-9 — suffix-based (csproj, sln)
|
|
255
|
+
for suffix, language, _source in _SUFFIX_RULES:
|
|
256
|
+
if _has_suffix(tree, suffix):
|
|
257
|
+
add(language)
|
|
258
|
+
|
|
259
|
+
if langs:
|
|
260
|
+
return langs
|
|
261
|
+
|
|
262
|
+
# No manifest matched — the winner came from the extension-frequency
|
|
263
|
+
# fallback (rule 10). Carry its best guess, or [] when it was "unknown".
|
|
264
|
+
fallback_language = winner["language"]
|
|
265
|
+
return [] if fallback_language == "unknown" else [fallback_language]
|
|
266
|
+
|
|
267
|
+
|
|
204
268
|
def detect(payload: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
"""Apply the documented rule walk and annotate the full manifest match set.
|
|
270
|
+
|
|
271
|
+
Returns the single-winner envelope (language/confidence/detection_source/
|
|
272
|
+
fallback_to_extension_frequency) unchanged, plus detected_languages[] — the
|
|
273
|
+
ordered, deduplicated set of every manifest-level match — so a caller can
|
|
274
|
+
both auto-pick and gate multi-language disambiguation off one JSON shape.
|
|
275
|
+
"""
|
|
276
|
+
winner = _winner(payload)
|
|
277
|
+
winner["detected_languages"] = _detected_languages(payload, winner)
|
|
278
|
+
return winner
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _winner(payload: dict[str, Any]) -> dict[str, Any]:
|
|
205
282
|
"""Apply the documented rule walk. Always returns a recommendation."""
|
|
206
283
|
tree = payload.get("tree")
|
|
207
284
|
if not isinstance(tree, list):
|