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.
Files changed (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +10 -5
  3. package/docs/_data/pinned.yaml +2 -2
  4. package/docs/agents.md +11 -2
  5. package/docs/architecture.md +5 -4
  6. package/docs/bmad-synergy.md +30 -7
  7. package/docs/campaign.md +172 -0
  8. package/docs/examples.md +7 -3
  9. package/docs/forge-auto.md +90 -0
  10. package/docs/getting-started.md +9 -0
  11. package/docs/how-it-works.md +6 -4
  12. package/docs/index.md +4 -3
  13. package/docs/skill-model.md +1 -1
  14. package/docs/troubleshooting.md +17 -1
  15. package/docs/verifying-a-skill.md +2 -2
  16. package/docs/why-skf.md +1 -1
  17. package/docs/workflows.md +64 -24
  18. package/package.json +2 -2
  19. package/src/module-help.csv +2 -1
  20. package/src/shared/data/language-corpora.json +32 -0
  21. package/src/shared/references/pipeline-contracts.md +6 -3
  22. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  23. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  24. package/src/shared/scripts/skf-detect-docs.py +8 -3
  25. package/src/shared/scripts/skf-detect-language.py +5 -3
  26. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  27. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  28. package/src/shared/scripts/skf-language-corpora.py +100 -0
  29. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  30. package/src/shared/scripts/skf-preapply.py +1 -1
  31. package/src/shared/scripts/skf-shape-detect.py +576 -25
  32. package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
  33. package/src/shared/scripts/skf-validate-pins.py +2 -2
  34. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  35. package/src/skf-analyze-source/SKILL.md +1 -1
  36. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  37. package/src/skf-analyze-source/references/step-auto-scope.md +196 -44
  38. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  39. package/src/skf-audit-skill/references/init.md +1 -1
  40. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  41. package/src/skf-brief-skill/references/analyze-target.md +7 -4
  42. package/src/skf-brief-skill/references/confirm-brief.md +0 -1
  43. package/src/skf-brief-skill/references/gather-intent.md +8 -2
  44. package/src/skf-brief-skill/references/scope-definition.md +2 -1
  45. package/src/skf-brief-skill/references/step-auto-brief.md +21 -4
  46. package/src/skf-brief-skill/references/step-auto-validate.md +2 -2
  47. package/src/skf-brief-skill/references/write-brief.md +2 -3
  48. package/src/skf-campaign/SKILL.md +190 -0
  49. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  50. package/src/skf-campaign/customize.toml +73 -0
  51. package/src/skf-campaign/manifest.yaml +11 -0
  52. package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
  53. package/src/skf-campaign/references/health-check.md +35 -0
  54. package/src/skf-campaign/references/step-01-setup.md +122 -0
  55. package/src/skf-campaign/references/step-02-strategy.md +97 -0
  56. package/src/skf-campaign/references/step-03-pins.md +56 -0
  57. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  58. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  59. package/src/skf-campaign/references/step-06-batch.md +87 -0
  60. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  61. package/src/skf-campaign/references/step-08-verify.md +75 -0
  62. package/src/skf-campaign/references/step-09-refine.md +83 -0
  63. package/src/skf-campaign/references/step-10-export.md +106 -0
  64. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  65. package/src/skf-campaign/references/step-resume.md +114 -0
  66. package/src/skf-campaign/scripts/.gitkeep +0 -0
  67. package/src/skf-campaign/scripts/campaign-deps.py +235 -0
  68. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  69. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  70. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  71. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  72. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  73. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  74. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  75. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  76. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  77. package/src/skf-create-skill/SKILL.md +1 -1
  78. package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
  79. package/src/skf-create-skill/references/compile.md +4 -1
  80. package/src/skf-create-skill/references/extract.md +9 -1
  81. package/src/skf-create-skill/references/extraction-patterns.md +3 -1
  82. package/src/skf-create-skill/references/generate-artifacts.md +11 -2
  83. package/src/skf-create-skill/references/health-check.md +2 -2
  84. package/src/skf-create-skill/references/report.md +17 -1
  85. package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
  86. package/src/skf-create-skill/references/step-doc-rot.md +4 -4
  87. package/src/skf-create-skill/references/step-doc-sources.md +11 -2
  88. package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
  89. package/src/skf-create-skill/references/validate.md +12 -3
  90. package/src/skf-drop-skill/SKILL.md +2 -2
  91. package/src/skf-drop-skill/references/execute.md +20 -9
  92. package/src/skf-drop-skill/references/report.md +2 -0
  93. package/src/skf-drop-skill/references/select.md +7 -2
  94. package/src/skf-forger/SKILL.md +12 -7
  95. package/src/skf-refine-architecture/SKILL.md +2 -1
  96. package/src/skf-refine-architecture/references/compile.md +1 -1
  97. package/src/skf-refine-architecture/references/report.md +1 -1
  98. package/src/skf-rename-skill/SKILL.md +2 -2
  99. package/src/skf-rename-skill/references/execute.md +5 -4
  100. package/src/skf-rename-skill/references/rebuild-context.md +2 -2
  101. package/src/skf-rename-skill/references/select.md +3 -3
  102. package/src/skf-setup/SKILL.md +1 -1
  103. package/src/skf-setup/references/auto-index.md +3 -1
  104. package/src/skf-setup/references/detect-and-tier.md +4 -0
  105. package/src/skf-setup/references/report.md +4 -1
  106. package/src/skf-setup/references/tier-rules.md +1 -1
  107. package/src/skf-test-skill/references/coverage-check.md +10 -0
  108. package/src/skf-test-skill/references/init.md +1 -1
  109. package/src/skf-test-skill/references/source-access-protocol.md +13 -3
  110. package/src/skf-test-skill/scripts/compute-score.py +4 -3
  111. package/src/skf-update-skill/customize.toml +0 -9
  112. package/src/skf-update-skill/references/detect-changes.md +33 -3
  113. package/src/skf-update-skill/references/health-check.md +2 -2
  114. package/src/skf-update-skill/references/init.md +1 -0
  115. package/src/skf-update-skill/references/re-extract.md +15 -1
  116. package/src/skf-verify-stack/references/report.md +1 -1
  117. package/tools/cli/lib/ui.js +3 -2
  118. 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 python src/shared/scripts/skf-preapply.py \
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: