bmad-module-skill-forge 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/module-help.csv +2 -1
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +8 -3
- package/src/shared/scripts/skf-detect-language.py +5 -3
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-shape-detect.py +576 -25
- package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
- package/src/shared/scripts/skf-validate-pins.py +2 -2
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/step-auto-scope.md +196 -44
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-audit-skill/references/init.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/references/analyze-target.md +7 -4
- package/src/skf-brief-skill/references/confirm-brief.md +0 -1
- package/src/skf-brief-skill/references/gather-intent.md +8 -2
- package/src/skf-brief-skill/references/scope-definition.md +2 -1
- package/src/skf-brief-skill/references/step-auto-brief.md +21 -4
- package/src/skf-brief-skill/references/step-auto-validate.md +2 -2
- package/src/skf-brief-skill/references/write-brief.md +2 -3
- package/src/skf-campaign/SKILL.md +190 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +73 -0
- package/src/skf-campaign/manifest.yaml +11 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +122 -0
- package/src/skf-campaign/references/step-02-strategy.md +97 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +87 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +114 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +235 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +1 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
- package/src/skf-create-skill/references/compile.md +4 -1
- package/src/skf-create-skill/references/extract.md +9 -1
- package/src/skf-create-skill/references/extraction-patterns.md +3 -1
- package/src/skf-create-skill/references/generate-artifacts.md +11 -2
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/report.md +17 -1
- package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
- package/src/skf-create-skill/references/step-doc-rot.md +4 -4
- package/src/skf-create-skill/references/step-doc-sources.md +11 -2
- package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
- package/src/skf-create-skill/references/validate.md +12 -3
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-drop-skill/references/execute.md +20 -9
- package/src/skf-drop-skill/references/report.md +2 -0
- package/src/skf-drop-skill/references/select.md +7 -2
- package/src/skf-forger/SKILL.md +12 -7
- package/src/skf-refine-architecture/SKILL.md +2 -1
- package/src/skf-refine-architecture/references/compile.md +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-rename-skill/references/execute.md +5 -4
- package/src/skf-rename-skill/references/rebuild-context.md +2 -2
- package/src/skf-rename-skill/references/select.md +3 -3
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-setup/references/auto-index.md +3 -1
- package/src/skf-setup/references/detect-and-tier.md +4 -0
- package/src/skf-setup/references/report.md +4 -1
- package/src/skf-setup/references/tier-rules.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +10 -0
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/source-access-protocol.md +13 -3
- package/src/skf-test-skill/scripts/compute-score.py +4 -3
- package/src/skf-update-skill/customize.toml +0 -9
- package/src/skf-update-skill/references/detect-changes.md +33 -3
- package/src/skf-update-skill/references/health-check.md +2 -2
- package/src/skf-update-skill/references/init.md +1 -0
- package/src/skf-update-skill/references/re-extract.md +15 -1
- package/src/skf-verify-stack/references/report.md +1 -1
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
|
@@ -261,6 +261,29 @@ def parse_settings_gradle(content: str) -> list[str]:
|
|
|
261
261
|
return modules
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def parse_package_swift(content: str) -> dict:
|
|
265
|
+
"""Best-effort parse of a SwiftPM Package.swift manifest.
|
|
266
|
+
|
|
267
|
+
SwiftPM has no version field in the manifest (versions come from git tags),
|
|
268
|
+
so `version` is always None; the brief falls back to target_version / default.
|
|
269
|
+
"""
|
|
270
|
+
name_m = re.search(r"\bPackage\s*\(\s*name:\s*['\"]([^'\"]+)['\"]", content, re.DOTALL)
|
|
271
|
+
deps: list[str] = []
|
|
272
|
+
for m in re.finditer(r"\.package\s*\(\s*url:\s*['\"]([^'\"]+)['\"]", content):
|
|
273
|
+
seg = m.group(1).rstrip("/").rsplit("/", 1)[-1]
|
|
274
|
+
if seg.endswith(".git"):
|
|
275
|
+
seg = seg[: -len(".git")]
|
|
276
|
+
if seg:
|
|
277
|
+
deps.append(seg)
|
|
278
|
+
return {
|
|
279
|
+
"name": name_m.group(1).strip() if name_m else None,
|
|
280
|
+
"version": None,
|
|
281
|
+
"description": None,
|
|
282
|
+
"dependencies": deps,
|
|
283
|
+
"modules": [],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
264
287
|
# --------------------------------------------------------------------------
|
|
265
288
|
# Export scanners
|
|
266
289
|
# --------------------------------------------------------------------------
|
|
@@ -388,6 +411,35 @@ def scan_exports_kotlin(content: str, source_file: str) -> list[dict]:
|
|
|
388
411
|
return out
|
|
389
412
|
|
|
390
413
|
|
|
414
|
+
# Swift declarations are public only when explicitly marked `public`/`open`
|
|
415
|
+
# (the default is internal). The lazy `[^\n]*?` skips intervening modifiers
|
|
416
|
+
# (final/static/class-method/@attributes) between the access keyword and the
|
|
417
|
+
# declaration keyword.
|
|
418
|
+
_SWIFT_DECL_RE = re.compile(
|
|
419
|
+
r"^\s*(?:public|open)\b[^\n]*?\b"
|
|
420
|
+
r"(func|class|struct|enum|protocol|actor|typealias|var|let)\s+([A-Za-z_]\w*)",
|
|
421
|
+
re.MULTILINE,
|
|
422
|
+
)
|
|
423
|
+
_SWIFT_DECL_KEYWORDS = {
|
|
424
|
+
"func", "class", "struct", "enum", "protocol", "actor", "typealias", "var", "let",
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def scan_exports_swift(content: str, source_file: str) -> list[dict]:
|
|
429
|
+
"""Swift defaults to internal — emit only public/open declarations."""
|
|
430
|
+
out: list[dict] = []
|
|
431
|
+
seen: set[str] = set()
|
|
432
|
+
for m in _SWIFT_DECL_RE.finditer(content):
|
|
433
|
+
name = m.group(2)
|
|
434
|
+
# Guard the rare `public class func foo` case where the lazy skip stops
|
|
435
|
+
# on the `class` modifier and captures the next keyword as the name.
|
|
436
|
+
if name in _SWIFT_DECL_KEYWORDS or name in seen:
|
|
437
|
+
continue
|
|
438
|
+
seen.add(name)
|
|
439
|
+
out.append({"name": name, "type": m.group(1), "source_file": source_file})
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
391
443
|
# --------------------------------------------------------------------------
|
|
392
444
|
# Orchestrator
|
|
393
445
|
# --------------------------------------------------------------------------
|
|
@@ -405,6 +457,7 @@ LANGUAGE_DISPATCH: dict[str, tuple[ManifestParser, ExportScanner]] = {
|
|
|
405
457
|
"go": (parse_go_mod, scan_exports_go),
|
|
406
458
|
"java": (parse_pom_xml, scan_exports_java),
|
|
407
459
|
"kotlin": (parse_gradle, scan_exports_kotlin),
|
|
460
|
+
"swift": (parse_package_swift, scan_exports_swift),
|
|
408
461
|
}
|
|
409
462
|
|
|
410
463
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Language Corpora — canonical companion prose corpora for a language.
|
|
6
|
+
|
|
7
|
+
A whole-language skill (issue #427) is forged from a language-reference repo
|
|
8
|
+
(a compiler/interpreter such as rust-lang/rust). That repo carries the
|
|
9
|
+
language's CODE, but a skill's value comes from the language's PROSE — the
|
|
10
|
+
guide/Book, the standard/library API docs, idioms. README detection
|
|
11
|
+
(skf-detect-docs.py) is the primary source for those; this lookup guarantees
|
|
12
|
+
the canonical corpora for well-known languages even when the repo's README
|
|
13
|
+
does not link them.
|
|
14
|
+
|
|
15
|
+
Pure static lookup over src/shared/data/language-corpora.json — no network,
|
|
16
|
+
no git, no `gh`. Output is the brief `doc_urls` contract (`{url, label,
|
|
17
|
+
source}`), with `source` fixed to `"language-registry"` (issue #432), so the
|
|
18
|
+
result can be seeded directly into a skill brief and later distinguished from
|
|
19
|
+
README-detected docs.
|
|
20
|
+
|
|
21
|
+
CLI:
|
|
22
|
+
uv run src/shared/scripts/skf-language-corpora.py --language <id>
|
|
23
|
+
|
|
24
|
+
Output (JSON array on stdout): [{"url": "...", "label": "...", "source": "language-registry"}, ...]
|
|
25
|
+
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 registry hit — one or more corpora emitted
|
|
28
|
+
1 no entry for this language (long-tail / unknown) — emits []
|
|
29
|
+
2 error (bad args, missing/unreadable/invalid data file)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
_DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "language-corpora.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _die(message: str, code: str = "INTERNAL_ERROR") -> None:
|
|
43
|
+
json.dump({"error": message, "code": code}, sys.stderr, ensure_ascii=False)
|
|
44
|
+
sys.stderr.write("\n")
|
|
45
|
+
sys.exit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_registry() -> dict:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(_DATA_FILE.read_text(encoding="utf-8"))
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
_die(f"Cannot read {_DATA_FILE.as_posix()}: {exc}", "DATA_READ_ERROR")
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
_die(f"Cannot parse {_DATA_FILE.as_posix()}: {exc}", "DATA_PARSE_ERROR")
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
_die("Corpora registry must be a JSON object", "DATA_PARSE_ERROR")
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def corpora_for(language: str) -> list[dict]:
|
|
61
|
+
"""Return the `{url, label, source}` corpora for a language id, or [] if none.
|
|
62
|
+
|
|
63
|
+
Every emitted entry is stamped `source: "language-registry"` (issue #432) so
|
|
64
|
+
downstream — the brief writer, the noise-suppression filter (#431), and the
|
|
65
|
+
assembly tier-ordering (#430) — can distinguish a registry-guaranteed
|
|
66
|
+
corpus from an opportunistically README-detected doc.
|
|
67
|
+
"""
|
|
68
|
+
registry = _load_registry()
|
|
69
|
+
entries = registry.get((language or "").strip().lower())
|
|
70
|
+
if not isinstance(entries, list):
|
|
71
|
+
return []
|
|
72
|
+
out: list[dict] = []
|
|
73
|
+
for e in entries:
|
|
74
|
+
if isinstance(e, dict) and e.get("url"):
|
|
75
|
+
out.append({
|
|
76
|
+
"url": e["url"],
|
|
77
|
+
"label": e.get("label", ""),
|
|
78
|
+
"source": "language-registry",
|
|
79
|
+
})
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str]) -> int:
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
description="Look up canonical companion prose corpora for a language.",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--language", required=True,
|
|
89
|
+
help="Lowercase language id (rust, python, go, typescript, ruby, ...)",
|
|
90
|
+
)
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
result = corpora_for(args.language)
|
|
94
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
95
|
+
sys.stdout.write("\n")
|
|
96
|
+
return 0 if result else 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Merge Doc URLs — merge corpora-seeded + detected docs for a skill brief.
|
|
6
|
+
|
|
7
|
+
Deterministic merge of an upstream brief's `doc_urls` (registry-seeded
|
|
8
|
+
companion corpora, source: language-registry) with the docs freshly
|
|
9
|
+
discovered by `skf-detect-docs.py` (mapped to the brief `{url, label, source}`
|
|
10
|
+
contract). Replaces the prose dedup/suppression that lived inline in
|
|
11
|
+
`skf-brief-skill/references/step-auto-brief.md` §3 — that logic is fiddly
|
|
12
|
+
(normalized-URL collisions, non-corpus path segments, locale duplicates) and
|
|
13
|
+
must behave identically on every run, so it belongs in a tested script.
|
|
14
|
+
|
|
15
|
+
Three operations, in order:
|
|
16
|
+
|
|
17
|
+
1. Normalized-URL dedup — lowercase host, strip a trailing `/index.html`
|
|
18
|
+
and any trailing `/` before comparing, so a seeded `…/book/` and a
|
|
19
|
+
README's `…/book/index.html` collapse to one entry. Existing
|
|
20
|
+
(corpora-seeded) entries always win and are NEVER dropped.
|
|
21
|
+
|
|
22
|
+
2. Non-corpus segment suppression (whole-language references only) — when
|
|
23
|
+
the brief is a whole-language reference (scope_type == "full-library"
|
|
24
|
+
AND ≥1 existing entry carries source == "language-registry"), drop a
|
|
25
|
+
README-detected entry whose host matches a registry corpus host and
|
|
26
|
+
whose path contains a known non-corpus segment (whatsnew / contribute /
|
|
27
|
+
wiki). Path-component-anchored, never a bare substring — `/whatsnew/`
|
|
28
|
+
and `/whatsnew-2024/` drop, `/docs/whatsnewfeatures/` does not.
|
|
29
|
+
|
|
30
|
+
3. Non-primary-locale collapse (whole-language references only) — drop a
|
|
31
|
+
README-detected entry on a registry host whose leading path segment is a
|
|
32
|
+
non-primary locale (e.g. `/ja/master/`) when a locale-stripped twin is
|
|
33
|
+
already kept (e.g. a seeded `/en/master/` or `/master/`). Twin-required:
|
|
34
|
+
a `/ja/…` page with no `/en/…` counterpart is kept.
|
|
35
|
+
|
|
36
|
+
Suppression is GATED on the whole-language marker so ordinary skills pass
|
|
37
|
+
through with byte-identical merge behaviour (dedup only). N==0 whole-language
|
|
38
|
+
references (registry miss → no seeded corpora) carry no language-registry
|
|
39
|
+
entry, so the gate is inactive and their README docs are NOT suppressed — the
|
|
40
|
+
DEGRADED case is intentionally out of scope (there is no canonical corpus host
|
|
41
|
+
to anchor suppression against).
|
|
42
|
+
|
|
43
|
+
CLI:
|
|
44
|
+
echo '{"scope_type": "...", "existing": [...], "detected": [...]}' \
|
|
45
|
+
| uv run src/shared/scripts/skf-merge-doc-urls.py
|
|
46
|
+
|
|
47
|
+
Input (JSON object on stdin):
|
|
48
|
+
{
|
|
49
|
+
"scope_type": "full-library",
|
|
50
|
+
"existing": [{"url": "...", "label": "...", "source": "language-registry"}, ...],
|
|
51
|
+
"detected": [{"url": "...", "label": "...", "source": "readme-detection"}, ...]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Output (JSON object on stdout):
|
|
55
|
+
{
|
|
56
|
+
"doc_urls": [{"url": "...", "label": "...", "source": "..."}, ...],
|
|
57
|
+
"suppressed": [{"url": "...", "reason": "non-corpus-segment|non-primary-locale-dup"}, ...]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Exit codes:
|
|
61
|
+
0 success (merged list emitted)
|
|
62
|
+
2 error (bad JSON, wrong input shape)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import json
|
|
68
|
+
import re
|
|
69
|
+
import sys
|
|
70
|
+
from typing import Any
|
|
71
|
+
from urllib.parse import urlsplit
|
|
72
|
+
|
|
73
|
+
# Path components (case-insensitive) that mark a non-corpus page on a doc host:
|
|
74
|
+
# changelogs/news, contribution guides, wikis. Matched as whole path components
|
|
75
|
+
# (or component + a separator), never as a bare substring.
|
|
76
|
+
_NON_CORPUS_SEGMENTS = ("whatsnew", "contribute", "wiki")
|
|
77
|
+
|
|
78
|
+
# Closed allowlist of plausible doc-site locale codes. A leading path segment is
|
|
79
|
+
# treated as a locale only when it is in this set (or a `ll-rr` form whose
|
|
80
|
+
# language part is). Keeps a genuine subsection like `/go/` or `/is/` from being
|
|
81
|
+
# misread as a locale.
|
|
82
|
+
_LOCALE_CODES = {
|
|
83
|
+
"en", "ja", "zh", "ko", "fr", "de", "es", "pt", "ru", "it", "nl", "pl",
|
|
84
|
+
"tr", "uk", "cs", "id", "vi", "fa", "ar", "hi", "th", "sv", "da", "fi",
|
|
85
|
+
"nb", "no", "hu", "ro", "el", "he", "bg", "hr", "sk", "sl", "et", "lv",
|
|
86
|
+
"lt", "sr", "ca",
|
|
87
|
+
}
|
|
88
|
+
_LOCALE_REGION_RE = re.compile(r"^([a-z]{2})-[a-z]{2}$")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _norm_parts(url: str) -> tuple[str, str]:
|
|
92
|
+
"""Return (lowercased host, normalized path) for dedup / host matching.
|
|
93
|
+
|
|
94
|
+
Path normalization strips a trailing `/index.html` and any trailing `/`.
|
|
95
|
+
Scheme, query, and fragment are ignored — a doc page is identified by its
|
|
96
|
+
host + path.
|
|
97
|
+
"""
|
|
98
|
+
parts = urlsplit(url.strip())
|
|
99
|
+
host = parts.netloc.lower()
|
|
100
|
+
path = parts.path
|
|
101
|
+
low = path.lower()
|
|
102
|
+
if low.endswith("/index.html"):
|
|
103
|
+
path = path[: -len("/index.html")]
|
|
104
|
+
path = path.rstrip("/")
|
|
105
|
+
return host, path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _norm_key(url: str) -> tuple[str, str]:
|
|
109
|
+
return _norm_parts(url)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_locale(seg: str) -> bool:
|
|
113
|
+
s = seg.lower()
|
|
114
|
+
if s in _LOCALE_CODES:
|
|
115
|
+
return True
|
|
116
|
+
m = _LOCALE_REGION_RE.match(s)
|
|
117
|
+
return bool(m) and m.group(1) in _LOCALE_CODES
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_primary_locale(seg: str) -> bool:
|
|
121
|
+
s = seg.lower()
|
|
122
|
+
return s == "en" or s.startswith("en-")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _path_segments(path: str) -> list[str]:
|
|
126
|
+
return [s for s in path.split("/") if s]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _locale_stripped_key(url: str) -> tuple[str, str, bool, bool]:
|
|
130
|
+
"""Return (host, path-without-leading-locale, has_locale, is_primary).
|
|
131
|
+
|
|
132
|
+
The third/fourth flags let the caller tell `/ja/master/` (non-primary
|
|
133
|
+
locale) apart from `/master/` (no locale) and `/en/master/` (primary).
|
|
134
|
+
"""
|
|
135
|
+
host, path = _norm_parts(url)
|
|
136
|
+
segs = _path_segments(path)
|
|
137
|
+
has_locale = bool(segs) and _is_locale(segs[0])
|
|
138
|
+
is_primary = has_locale and _is_primary_locale(segs[0])
|
|
139
|
+
if has_locale:
|
|
140
|
+
segs = segs[1:]
|
|
141
|
+
return host, "/".join(segs), has_locale, is_primary
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _matches_non_corpus_segment(path: str) -> bool:
|
|
145
|
+
for seg in _path_segments(path):
|
|
146
|
+
low = seg.lower()
|
|
147
|
+
for token in _NON_CORPUS_SEGMENTS:
|
|
148
|
+
if low == token:
|
|
149
|
+
return True
|
|
150
|
+
# component + separator (whatsnew-2024) — but NOT whatsnewfeatures
|
|
151
|
+
if low.startswith(token) and len(low) > len(token) and not low[len(token)].isalnum():
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def merge_doc_urls(
|
|
157
|
+
scope_type: str,
|
|
158
|
+
existing: list[dict[str, Any]],
|
|
159
|
+
detected: list[dict[str, Any]],
|
|
160
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
161
|
+
"""Merge existing (seeded) + detected doc_urls. Returns (doc_urls, suppressed)."""
|
|
162
|
+
kept: list[dict[str, Any]] = []
|
|
163
|
+
suppressed: list[dict[str, Any]] = []
|
|
164
|
+
seen: set[tuple[str, str]] = set()
|
|
165
|
+
|
|
166
|
+
# 1. Existing (corpora-seeded) entries win and are never dropped.
|
|
167
|
+
for e in existing:
|
|
168
|
+
if not isinstance(e, dict) or not e.get("url"):
|
|
169
|
+
continue
|
|
170
|
+
key = _norm_key(e["url"])
|
|
171
|
+
if key in seen:
|
|
172
|
+
continue
|
|
173
|
+
seen.add(key)
|
|
174
|
+
kept.append(e)
|
|
175
|
+
|
|
176
|
+
# Whole-language gate: registry-seeded corpora present on a full-library brief.
|
|
177
|
+
registry_hosts = {
|
|
178
|
+
_norm_parts(e["url"])[0]
|
|
179
|
+
for e in kept
|
|
180
|
+
if isinstance(e, dict) and e.get("url") and e.get("source") == "language-registry"
|
|
181
|
+
}
|
|
182
|
+
suppression_active = scope_type == "full-library" and bool(registry_hosts)
|
|
183
|
+
|
|
184
|
+
for d in detected:
|
|
185
|
+
if not isinstance(d, dict) or not d.get("url"):
|
|
186
|
+
continue
|
|
187
|
+
url = d["url"]
|
|
188
|
+
key = _norm_key(url)
|
|
189
|
+
if key in seen:
|
|
190
|
+
continue # dedup — a seeded/earlier entry already covers this URL
|
|
191
|
+
|
|
192
|
+
if suppression_active:
|
|
193
|
+
host, path = _norm_parts(url)
|
|
194
|
+
if host in registry_hosts:
|
|
195
|
+
if _matches_non_corpus_segment(path):
|
|
196
|
+
suppressed.append({"url": url, "reason": "non-corpus-segment"})
|
|
197
|
+
continue
|
|
198
|
+
dh, dpath, has_locale, is_primary = _locale_stripped_key(url)
|
|
199
|
+
if has_locale and not is_primary:
|
|
200
|
+
twin = any(
|
|
201
|
+
_locale_stripped_key(k["url"])[0] == dh
|
|
202
|
+
and _locale_stripped_key(k["url"])[1] == dpath
|
|
203
|
+
for k in kept
|
|
204
|
+
if k.get("url")
|
|
205
|
+
)
|
|
206
|
+
if twin:
|
|
207
|
+
suppressed.append({"url": url, "reason": "non-primary-locale-dup"})
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
seen.add(key)
|
|
211
|
+
kept.append(d)
|
|
212
|
+
|
|
213
|
+
return kept, suppressed
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main() -> int:
|
|
217
|
+
raw = sys.stdin.read()
|
|
218
|
+
if not raw or not raw.strip():
|
|
219
|
+
sys.stderr.write("skf-merge-doc-urls: empty stdin (expected JSON object)\n")
|
|
220
|
+
return 2
|
|
221
|
+
try:
|
|
222
|
+
payload = json.loads(raw)
|
|
223
|
+
except json.JSONDecodeError as exc:
|
|
224
|
+
sys.stderr.write(f"skf-merge-doc-urls: invalid JSON on stdin: {exc}\n")
|
|
225
|
+
return 2
|
|
226
|
+
if not isinstance(payload, dict):
|
|
227
|
+
sys.stderr.write("skf-merge-doc-urls: input must be a JSON object\n")
|
|
228
|
+
return 2
|
|
229
|
+
|
|
230
|
+
scope_type = payload.get("scope_type") or ""
|
|
231
|
+
existing = payload.get("existing") or []
|
|
232
|
+
detected = payload.get("detected") or []
|
|
233
|
+
if not isinstance(existing, list) or not isinstance(detected, list):
|
|
234
|
+
sys.stderr.write("skf-merge-doc-urls: 'existing' and 'detected' must be arrays\n")
|
|
235
|
+
return 2
|
|
236
|
+
|
|
237
|
+
doc_urls, suppressed = merge_doc_urls(scope_type, existing, detected)
|
|
238
|
+
json.dump({"doc_urls": doc_urls, "suppressed": suppressed}, sys.stdout, ensure_ascii=False)
|
|
239
|
+
sys.stdout.write("\n")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|
|
@@ -9,7 +9,7 @@ registry, then scans target directory files for fingerprint matches and applies
|
|
|
9
9
|
the corresponding fixes.
|
|
10
10
|
|
|
11
11
|
CLI:
|
|
12
|
-
uv run
|
|
12
|
+
uv run src/shared/scripts/skf-preapply.py \
|
|
13
13
|
--target-dir <path> [--registry <path>] [--local-registry <path>] [--log-dir <path>]
|
|
14
14
|
|
|
15
15
|
Input:
|