@ikon85/agent-workflow-kit 0.10.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 (273) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +139 -0
  2. package/.agents/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
  3. package/.agents/skills/audit-skills/SKILL.md +145 -0
  4. package/.agents/skills/board-to-waves/SKILL.md +135 -0
  5. package/.agents/skills/code-review/SKILL.md +64 -0
  6. package/.agents/skills/codebase-design/DEEPENING.md +37 -0
  7. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  8. package/.agents/skills/codebase-design/SKILL.md +114 -0
  9. package/.agents/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
  10. package/.agents/skills/codex-adapter-sync/SKILL.md +151 -0
  11. package/.agents/skills/decision-gate/SKILL.md +67 -0
  12. package/.agents/skills/diagnose/SKILL.md +136 -0
  13. package/.agents/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
  14. package/.agents/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  15. package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
  16. package/.agents/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  17. package/.agents/skills/domain-modeling/SKILL.md +76 -0
  18. package/.agents/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
  19. package/.agents/skills/git-worktree-recover/SKILL.md +176 -0
  20. package/.agents/skills/grill-me/SKILL.md +54 -0
  21. package/.agents/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
  22. package/.agents/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  23. package/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
  24. package/.agents/skills/grill-with-docs/SKILL.md +137 -0
  25. package/.agents/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
  26. package/.agents/skills/implement/SKILL.md +15 -0
  27. package/.agents/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
  28. package/.agents/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
  29. package/.agents/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
  30. package/.agents/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  31. package/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  32. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
  33. package/.agents/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  34. package/.agents/skills/improve-codebase-architecture/SKILL.md +88 -0
  35. package/.agents/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
  36. package/.agents/skills/kit-release/SKILL.md +56 -0
  37. package/.agents/skills/local-ci/SKILL.md +94 -0
  38. package/.agents/skills/orchestrate-wave/SKILL.md +261 -0
  39. package/.agents/skills/orchestrate-wave/references/builder-contract.md +67 -0
  40. package/.agents/skills/prototype/LOGIC.md +79 -0
  41. package/.agents/skills/prototype/SKILL.md +30 -0
  42. package/.agents/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
  43. package/.agents/skills/prototype/UI.md +112 -0
  44. package/.agents/skills/research/SKILL.md +12 -0
  45. package/.agents/skills/research/THIRD-PARTY-NOTICES.md +29 -0
  46. package/.agents/skills/resolving-merge-conflicts/SKILL.md +14 -0
  47. package/.agents/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
  48. package/.agents/skills/retro/SKILL.md +209 -0
  49. package/.agents/skills/scale-check/SKILL.md +141 -0
  50. package/.agents/skills/security-audit/SKILL.md +67 -0
  51. package/.agents/skills/setup-workflow/SKILL.md +187 -0
  52. package/.agents/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
  53. package/.agents/skills/setup-workflow/board-sync.md +111 -0
  54. package/.agents/skills/setup-workflow/code-review.md +26 -0
  55. package/.agents/skills/setup-workflow/domain.md +51 -0
  56. package/.agents/skills/setup-workflow/issue-tracker-github.md +35 -0
  57. package/.agents/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
  58. package/.agents/skills/setup-workflow/issue-tracker-local.md +30 -0
  59. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
  60. package/.agents/skills/setup-workflow/spec-completeness-seed.md +44 -0
  61. package/.agents/skills/setup-workflow/spec-self-critique-seed.md +43 -0
  62. package/.agents/skills/setup-workflow/triage-labels.md +18 -0
  63. package/.agents/skills/setup-workflow/workflow-overview.md +33 -0
  64. package/.agents/skills/spec-self-critique/SKILL.md +152 -0
  65. package/.agents/skills/spec-self-critique/scenarios.md +82 -0
  66. package/.agents/skills/tdd/SKILL.md +120 -0
  67. package/.agents/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
  68. package/.agents/skills/tdd/deep-modules.md +33 -0
  69. package/.agents/skills/tdd/interface-design.md +31 -0
  70. package/.agents/skills/tdd/mocking.md +59 -0
  71. package/.agents/skills/tdd/refactoring.md +10 -0
  72. package/.agents/skills/tdd/tests.md +61 -0
  73. package/.agents/skills/to-issues/SKILL.md +325 -0
  74. package/.agents/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
  75. package/.agents/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
  76. package/.agents/skills/to-prd/SKILL.md +180 -0
  77. package/.agents/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
  78. package/.agents/skills/to-waves/SKILL.md +260 -0
  79. package/.agents/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
  80. package/.agents/skills/triage/AGENT-BRIEF.md +168 -0
  81. package/.agents/skills/triage/OUT-OF-SCOPE.md +101 -0
  82. package/.agents/skills/triage/SKILL.md +122 -0
  83. package/.agents/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
  84. package/.agents/skills/verify-spike/SKILL.md +39 -0
  85. package/.agents/skills/wayfinder/SKILL.md +127 -0
  86. package/.agents/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
  87. package/.agents/skills/wrapup/SKILL.md +71 -0
  88. package/.agents/skills/wrapup/SOURCES.txt +6 -0
  89. package/.agents/skills/zoom-out/SKILL.md +7 -0
  90. package/.agents/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
  91. package/.claude/hooks/_hook_utils.py +184 -0
  92. package/.claude/hooks/drift-guard.py +176 -0
  93. package/.claude/hooks/skill-drift-hint.py +122 -0
  94. package/.claude/hooks/sync-board-status.py +191 -0
  95. package/.claude/skills/ask-matt/SKILL.md +139 -0
  96. package/.claude/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
  97. package/.claude/skills/audit-skills/SKILL.md +145 -0
  98. package/.claude/skills/board-to-waves/SKILL.md +148 -0
  99. package/.claude/skills/code-review/SKILL.md +64 -0
  100. package/.claude/skills/codebase-design/DEEPENING.md +37 -0
  101. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  102. package/.claude/skills/codebase-design/SKILL.md +114 -0
  103. package/.claude/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
  104. package/.claude/skills/codex-build/SKILL.md +126 -0
  105. package/.claude/skills/codex-build/THIRD-PARTY-NOTICES.md +35 -0
  106. package/.claude/skills/codex-review/SKILL.md +145 -0
  107. package/.claude/skills/codex-review/THIRD-PARTY-NOTICES.md +31 -0
  108. package/.claude/skills/decision-gate/SKILL.md +67 -0
  109. package/.claude/skills/diagnose/SKILL.md +136 -0
  110. package/.claude/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
  111. package/.claude/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  112. package/.claude/skills/domain-modeling/ADR-FORMAT.md +47 -0
  113. package/.claude/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  114. package/.claude/skills/domain-modeling/SKILL.md +76 -0
  115. package/.claude/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
  116. package/.claude/skills/git-guardrails-claude-code/SKILL.md +96 -0
  117. package/.claude/skills/git-guardrails-claude-code/THIRD-PARTY-NOTICES.md +29 -0
  118. package/.claude/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh +28 -0
  119. package/.claude/skills/git-worktree-recover/SKILL.md +182 -0
  120. package/.claude/skills/grill-me/SKILL.md +54 -0
  121. package/.claude/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
  122. package/.claude/skills/grill-me-codex/SKILL.md +158 -0
  123. package/.claude/skills/grill-me-codex/THIRD-PARTY-NOTICES.md +32 -0
  124. package/.claude/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  125. package/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
  126. package/.claude/skills/grill-with-docs/SKILL.md +137 -0
  127. package/.claude/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
  128. package/.claude/skills/grill-with-docs-codex/ADR-FORMAT.md +47 -0
  129. package/.claude/skills/grill-with-docs-codex/CONTEXT-FORMAT.md +60 -0
  130. package/.claude/skills/grill-with-docs-codex/SKILL.md +233 -0
  131. package/.claude/skills/grill-with-docs-codex/THIRD-PARTY-NOTICES.md +33 -0
  132. package/.claude/skills/implement/SKILL.md +15 -0
  133. package/.claude/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
  134. package/.claude/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
  135. package/.claude/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
  136. package/.claude/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  137. package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  138. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
  139. package/.claude/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  140. package/.claude/skills/improve-codebase-architecture/SKILL.md +88 -0
  141. package/.claude/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
  142. package/.claude/skills/kit-release/SKILL.md +56 -0
  143. package/.claude/skills/local-ci/SKILL.md +94 -0
  144. package/.claude/skills/orchestrate-wave/SKILL.md +269 -0
  145. package/.claude/skills/orchestrate-wave/references/builder-contract.md +67 -0
  146. package/.claude/skills/prototype/LOGIC.md +79 -0
  147. package/.claude/skills/prototype/SKILL.md +30 -0
  148. package/.claude/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
  149. package/.claude/skills/prototype/UI.md +112 -0
  150. package/.claude/skills/research/SKILL.md +12 -0
  151. package/.claude/skills/research/THIRD-PARTY-NOTICES.md +29 -0
  152. package/.claude/skills/resolving-merge-conflicts/SKILL.md +14 -0
  153. package/.claude/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
  154. package/.claude/skills/retro/SKILL.md +209 -0
  155. package/.claude/skills/scale-check/SKILL.md +141 -0
  156. package/.claude/skills/security-audit/SKILL.md +67 -0
  157. package/.claude/skills/setup-pre-commit/SKILL.md +104 -0
  158. package/.claude/skills/setup-pre-commit/scripts/pre-commit.template.sh +35 -0
  159. package/.claude/skills/setup-workflow/SKILL.md +187 -0
  160. package/.claude/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
  161. package/.claude/skills/setup-workflow/board-sync.md +111 -0
  162. package/.claude/skills/setup-workflow/code-review.md +26 -0
  163. package/.claude/skills/setup-workflow/domain.md +51 -0
  164. package/.claude/skills/setup-workflow/issue-tracker-github.md +35 -0
  165. package/.claude/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
  166. package/.claude/skills/setup-workflow/issue-tracker-local.md +30 -0
  167. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
  168. package/.claude/skills/setup-workflow/spec-completeness-seed.md +44 -0
  169. package/.claude/skills/setup-workflow/spec-self-critique-seed.md +43 -0
  170. package/.claude/skills/setup-workflow/triage-labels.md +18 -0
  171. package/.claude/skills/setup-workflow/workflow-overview.md +33 -0
  172. package/.claude/skills/skill-manifest.json +389 -0
  173. package/.claude/skills/spec-self-critique/SKILL.md +152 -0
  174. package/.claude/skills/spec-self-critique/scenarios.md +82 -0
  175. package/.claude/skills/tdd/SKILL.md +120 -0
  176. package/.claude/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
  177. package/.claude/skills/tdd/deep-modules.md +33 -0
  178. package/.claude/skills/tdd/interface-design.md +31 -0
  179. package/.claude/skills/tdd/mocking.md +59 -0
  180. package/.claude/skills/tdd/refactoring.md +10 -0
  181. package/.claude/skills/tdd/tests.md +61 -0
  182. package/.claude/skills/to-issues/SKILL.md +325 -0
  183. package/.claude/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
  184. package/.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
  185. package/.claude/skills/to-prd/SKILL.md +180 -0
  186. package/.claude/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
  187. package/.claude/skills/to-waves/SKILL.md +260 -0
  188. package/.claude/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
  189. package/.claude/skills/triage/AGENT-BRIEF.md +168 -0
  190. package/.claude/skills/triage/OUT-OF-SCOPE.md +101 -0
  191. package/.claude/skills/triage/SKILL.md +122 -0
  192. package/.claude/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
  193. package/.claude/skills/verify-spike/SKILL.md +39 -0
  194. package/.claude/skills/wayfinder/SKILL.md +127 -0
  195. package/.claude/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
  196. package/.claude/skills/wrapup/SKILL.md +81 -0
  197. package/.claude/skills/wrapup/SOURCES.txt +6 -0
  198. package/.claude/skills/write-a-skill/GLOSSARY.md +201 -0
  199. package/.claude/skills/write-a-skill/SKILL.md +88 -0
  200. package/.claude/skills/write-a-skill/THIRD-PARTY-NOTICES.md +29 -0
  201. package/.claude/skills/zoom-out/SKILL.md +7 -0
  202. package/.claude/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
  203. package/LICENSE +28 -0
  204. package/PROVENANCE.md +39 -0
  205. package/README.md +619 -0
  206. package/agent-workflow-kit.package.json +1939 -0
  207. package/docs/agents/board-sync.md +71 -0
  208. package/docs/agents/code-review.md +14 -0
  209. package/docs/agents/domain.md +7 -0
  210. package/docs/agents/issue-tracker.md +12 -0
  211. package/docs/agents/security-audit-runbook-template.md +205 -0
  212. package/docs/agents/skills/orchestrate-wave.md +13 -0
  213. package/docs/agents/skills/spec-self-critique.md +18 -0
  214. package/docs/agents/triage-labels.md +10 -0
  215. package/docs/agents/wave-anchor-template.md +88 -0
  216. package/docs/conventions/spec-completeness.md +23 -0
  217. package/docs/index.html +56 -0
  218. package/docs/methodology.html +488 -0
  219. package/docs/methodology.svg +93 -0
  220. package/docs/workflow.png +0 -0
  221. package/package.json +40 -0
  222. package/scripts/anchor_table.py +343 -0
  223. package/scripts/board-sync.py +1232 -0
  224. package/scripts/board_config.py +147 -0
  225. package/scripts/board_fields.py +192 -0
  226. package/scripts/build-kit.mjs +116 -0
  227. package/scripts/build-kit.test.mjs +59 -0
  228. package/scripts/check-kit-staleness.mjs +45 -0
  229. package/scripts/check-kit-staleness.test.mjs +19 -0
  230. package/scripts/execute-ready-check.py +516 -0
  231. package/scripts/issue_deps.py +79 -0
  232. package/scripts/kit-release.mjs +100 -0
  233. package/scripts/kit-release.test.mjs +67 -0
  234. package/scripts/lib/audit-refs.mjs +146 -0
  235. package/scripts/lib/scrub.mjs +113 -0
  236. package/scripts/lib/scrub.test.mjs +187 -0
  237. package/scripts/loc_offender_core.py +196 -0
  238. package/scripts/loc_offender_gate.py +182 -0
  239. package/scripts/node_kind.py +68 -0
  240. package/scripts/portability_profile_scan.py +53 -0
  241. package/scripts/pr-body-check.py +263 -0
  242. package/scripts/program_graph.py +125 -0
  243. package/scripts/program_graph_parse.py +275 -0
  244. package/scripts/program_graph_validate.py +256 -0
  245. package/scripts/program_sync.py +160 -0
  246. package/scripts/release-delta-guard.mjs +96 -0
  247. package/scripts/release-delta-guard.test.mjs +53 -0
  248. package/scripts/test_board_sync_create_idempotency.py +104 -0
  249. package/scripts/test_dist_kit_smoke.py +199 -0
  250. package/scripts/test_kit_docs_language_census.py +178 -0
  251. package/scripts/test_skill_code_review_seed.py +89 -0
  252. package/scripts/test_skill_frontmatter_lint.py +226 -0
  253. package/scripts/test_skill_gh_lint.py +124 -0
  254. package/scripts/test_skill_language_census.py +254 -0
  255. package/scripts/test_skill_portability_lint.py +773 -0
  256. package/scripts/test_skill_publish_audit.py +300 -0
  257. package/scripts/test_skill_selfcontainment_lint.py +255 -0
  258. package/scripts/test_skill_setup_workflow_seeds.py +200 -0
  259. package/scripts/test_skill_stale_name_lint.py +126 -0
  260. package/scripts/test_skill_surface_refs.py +300 -0
  261. package/scripts/test_skill_trailing_artifact_lint.py +71 -0
  262. package/scripts/wrapup-land.py +573 -0
  263. package/src/cli.mjs +70 -0
  264. package/src/commands/diff.mjs +6 -0
  265. package/src/commands/init.mjs +59 -0
  266. package/src/commands/uninstall.mjs +43 -0
  267. package/src/commands/update.mjs +95 -0
  268. package/src/lib/atomicWrite.mjs +49 -0
  269. package/src/lib/bundle.mjs +133 -0
  270. package/src/lib/hash.mjs +13 -0
  271. package/src/lib/manifest.mjs +58 -0
  272. package/src/lib/sentinel.mjs +20 -0
  273. package/src/lib/settings.mjs +23 -0
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed publish audit for dist-kit/ (Welle 26 / Slice 6 / #990, Step 13).
3
+
4
+ The scrub (scripts/lib/scrub.mjs) removes project-private tokens at build time;
5
+ THIS audit is the BACKSTOP — it scans the assembled dist-kit/ and DENIES the
6
+ publish if anything private survived. Scrub may be imperfect; this fails loud so
7
+ nothing private ever ships silently.
8
+
9
+ Deny classes (each with a negative fixture below):
10
+ - private repo slug `iKon85/Testreporter` + the bare project name
11
+ - board node/field IDs (`PVT…`) + status/wave/cluster option hashes
12
+ - private deploy domains (`*.iverra.de`), Coolify
13
+ - email addresses
14
+ - absolute home paths (`/home/<user>`)
15
+ - residual issue refs (`#NNN`) / hard-rule refs (`HRn`)
16
+ - unresolvable provenance cross-refs (`ADR-####` / `Welle N` / `Slice N`),
17
+ with a documented fixture/example allowlist (PROVENANCE_FIXTURE_SUFFIXES)
18
+ - `../` cross-skill reaches (skills/scripts/docs — NOT the CLI src, which
19
+ legitimately imports `../lib/…`)
20
+ - bare owner/maintainer names OUTSIDE the generated credit files
21
+ - high-entropy secrets (after exempting the manifest's own sha256 file-hashes)
22
+
23
+ Allowlisted: the PUBLIC repo slug `iKon85/agent-workflow-kit`.
24
+ File-exempt (credits legitimately name people): LICENSE, README.md, PROVENANCE.md,
25
+ THIRD-PARTY-NOTICES.md — but these still may NOT carry board IDs / domains /
26
+ secrets / the private repo slug.
27
+
28
+ Run: python3 scripts/test_skill_publish_audit.py
29
+ """
30
+ import json
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ import unittest
36
+ from pathlib import Path
37
+
38
+ REPO = Path(__file__).resolve().parent.parent
39
+ PUBLIC_SLUG = "iKon85/agent-workflow-kit"
40
+ CREDIT_FILES = {"LICENSE", "README.md", "PROVENANCE.md", "THIRD-PARTY-NOTICES.md"}
41
+ PRIVATE_SKILLS = {
42
+ "drizzle", "migrations", "forecast-logic", "bug-bucketing", "iverra-brand",
43
+ "ui-ux-pro-max",
44
+ }
45
+ # pin SHAs are legitimate attribution (full forms exempted from high-entropy)
46
+ KNOWN_PIN_HASHES = {
47
+ "2bf70051928429983de3b5718d277150926f8c89",
48
+ "ba71f82e8469395d3f7c4ed824334b16676e87a0",
49
+ }
50
+
51
+ # (label, regex) — denied EVERYWHERE, even in credit files.
52
+ HARD_DENY = [
53
+ ("private repo slug", re.compile(r"iKon85/Testreporter")),
54
+ ("board node/field id", re.compile(r"PVT(?:SSF|F)?_[A-Za-z0-9]")),
55
+ ("private deploy domain", re.compile(r"[A-Za-z0-9.-]*\.iverra\.de")),
56
+ ("Coolify reference", re.compile(r"\bCoolify\b")),
57
+ ("email address", re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")),
58
+ ("absolute home path", re.compile(r"/home/[A-Za-z0-9._-]+")),
59
+ ("home-encoded project slug", re.compile(r"-home-[a-z0-9]+-projects")),
60
+ ("issue ref", re.compile(r"#\d{3,5}\b")),
61
+ ("hard-rule ref", re.compile(r"\bHR\d+\b")),
62
+ ("project name", re.compile(r"[Tt]estreporter")),
63
+ ("kit issue ref", re.compile(r"\bkit#\d+\b")),
64
+ ]
65
+ # Provenance cross-refs a kit consumer cannot resolve (no docs/adr dir, no wave
66
+ # history). Denied EVERYWHERE except the documented fixture/example files below,
67
+ # where a Welle/Slice/ADR token is legitimate CONTENT — synthetic PRD scenarios,
68
+ # a template placeholder, illustrative ADR names, or board-sync's wave-prefix
69
+ # parser examples — not project provenance. Keep in sync with scrub.mjs's PROV
70
+ # class (which strips citation-shaped provenance from every other body/script).
71
+ PROVENANCE_DENY = [
72
+ ("ADR ref", re.compile(r"\bADR-\d{3,4}\b")),
73
+ ("Welle ref", re.compile(r"\bWelle \d+\b")),
74
+ ("Slice ref", re.compile(r"\bSlice \d+[a-z]?\b")),
75
+ ]
76
+ # Matched by dest-path SUFFIX so both surfaces (.claude/skills, .agents/skills)
77
+ # resolve with one entry each.
78
+ PROVENANCE_FIXTURE_SUFFIXES = (
79
+ "spec-self-critique/scenarios.md", # synthetic Program-PRD fixtures
80
+ "to-issues/SKILL.md", # `Welle $WAVE / Slice 1a` title example
81
+ "improve-codebase-architecture/SKILL.md", # illustrative `ADR-0007` example
82
+ "setup-workflow/domain.md", # illustrative `ADR-0007` example
83
+ "docs/agents/wave-anchor-template.md", # `Slice 1` / `Welle <N>` placeholders
84
+ "scripts/board-sync.py", # `Welle 7 — X` wave-prefix parser examples
85
+ )
86
+ # denied only OUTSIDE credit files (scrub should have neutralized these)
87
+ BARE_PRIVATE = [
88
+ ("bare owner iKon85", re.compile(r"iKon85")),
89
+ ("maintainer name", re.compile(r"\bNiko\b")),
90
+ ]
91
+ PARENT_REF = re.compile(r"\.\./")
92
+ LONG_HEX = re.compile(r"\b[0-9a-f]{40,}\b")
93
+ GH_TOKEN = re.compile(r"\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,}")
94
+
95
+
96
+ def _parent_ref_scan_text(rel: str, text: str) -> str:
97
+ if rel != "scripts/loc_offender_core.py":
98
+ return text
99
+ # This is the portable path-traversal guard itself, not a cross-skill reach.
100
+ return "\n".join(
101
+ line for line in text.splitlines()
102
+ if 'p.startswith("../")' not in line and '"/../" in p' not in line
103
+ )
104
+
105
+
106
+ def _known_hashes(root: Path) -> set:
107
+ hashes = set(KNOWN_PIN_HASHES)
108
+ pkg = root / "agent-workflow-kit.package.json"
109
+ if pkg.exists():
110
+ data = json.loads(pkg.read_text(encoding="utf-8"))
111
+ for f in data.get("files", []):
112
+ if "sha256" in f:
113
+ hashes.add(f["sha256"])
114
+ return hashes
115
+
116
+
117
+ def _scan_file(rel: str, text: str, known_hashes: set) -> list:
118
+ out = []
119
+ base = Path(rel).name
120
+ credit = base in CREDIT_FILES
121
+ scan = text.replace(PUBLIC_SLUG, "") # allowlist the public slug
122
+ for label, pat in HARD_DENY:
123
+ if pat.search(scan):
124
+ out.append(f"{rel}: {label}")
125
+ if not rel.endswith(PROVENANCE_FIXTURE_SUFFIXES):
126
+ for label, pat in PROVENANCE_DENY:
127
+ if pat.search(scan):
128
+ out.append(f"{rel}: {label}")
129
+ if not credit:
130
+ for label, pat in BARE_PRIVATE:
131
+ if pat.search(scan):
132
+ out.append(f"{rel}: {label}")
133
+ if not rel.startswith("src/") and PARENT_REF.search(_parent_ref_scan_text(rel, text)):
134
+ out.append(f"{rel}: ../ parent reference")
135
+ for m in LONG_HEX.finditer(text):
136
+ if m.group(0) not in known_hashes:
137
+ out.append(f"{rel}: high-entropy hex (possible secret)")
138
+ break
139
+ if GH_TOKEN.search(text):
140
+ out.append(f"{rel}: GitHub-token-like string")
141
+ return out
142
+
143
+
144
+ def audit_dir(root) -> list:
145
+ """Return a list of violation strings ([] = clean = publishable)."""
146
+ root = Path(root)
147
+ known = _known_hashes(root)
148
+ violations = []
149
+ for f in sorted(root.rglob("*")):
150
+ if not f.is_file():
151
+ continue
152
+ rel = f.relative_to(root).as_posix()
153
+ raw = f.read_bytes()
154
+ if b"\x00" in raw: # binary — skip
155
+ continue
156
+ violations += _scan_file(rel, raw.decode("utf-8", errors="replace"), known)
157
+ # project-private skill dirs must never appear in the kit
158
+ for surface in (".claude/skills", ".agents/skills"):
159
+ d = root / surface
160
+ if d.is_dir():
161
+ for child in d.iterdir():
162
+ if child.is_dir() and child.name in PRIVATE_SKILLS:
163
+ violations.append(f"{surface}/{child.name}: project-private skill shipped")
164
+ return violations
165
+
166
+
167
+ # --------------------------------------------------------------------------- #
168
+ class AuditCatchesEachClass(unittest.TestCase):
169
+ """Inject one violation per class into a clean fixture → it must be caught."""
170
+
171
+ def setUp(self):
172
+ self.dir = Path(tempfile.mkdtemp())
173
+ # a minimal CLEAN kit
174
+ (self.dir / "agent-workflow-kit.package.json").write_text(
175
+ json.dumps({"kitVersion": "0.1.0", "files": [
176
+ {"path": ".claude/skills/x/SKILL.md", "sha256": "a" * 64}]}),
177
+ encoding="utf-8")
178
+ self.skill = self.dir / ".claude/skills/x"
179
+ self.skill.mkdir(parents=True)
180
+ (self.skill / "SKILL.md").write_text("# clean skill\nuse `#<n>` and #0f172a\n",
181
+ encoding="utf-8")
182
+
183
+ def _body(self, content):
184
+ (self.skill / "SKILL.md").write_text(content, encoding="utf-8")
185
+ return audit_dir(self.dir)
186
+
187
+ def test_clean_passes(self):
188
+ self.assertEqual(audit_dir(self.dir), [])
189
+
190
+ def test_private_slug(self):
191
+ self.assertTrue(any("repo slug" in v for v in self._body("see iKon85/Testreporter")))
192
+
193
+ def test_board_id(self):
194
+ self.assertTrue(any("board" in v for v in self._body("id PVT_kwHOAuH31M4BVtcf")))
195
+
196
+ def test_domain(self):
197
+ self.assertTrue(any("domain" in v for v in self._body("curl testreporter.iverra.de")))
198
+
199
+ def test_email(self):
200
+ self.assertTrue(any("email" in v for v in self._body("ping a@b.com please")))
201
+
202
+ def test_home_path(self):
203
+ self.assertTrue(any("home path" in v for v in self._body("at /home/niko/x")))
204
+
205
+ def test_home_encoded_slug(self):
206
+ leak = '$HOME/.claude/projects/-home-niko-projects-x/memory'
207
+ self.assertTrue(any("home-encoded" in v for v in self._body(leak)))
208
+
209
+ def test_issue_ref(self):
210
+ self.assertTrue(any("issue ref" in v for v in self._body("fixed in #824 now")))
211
+
212
+ def test_hr_ref(self):
213
+ self.assertTrue(any("hard-rule" in v for v in self._body("per HR16 rule")))
214
+
215
+ def test_kit_issue_ref(self):
216
+ self.assertTrue(any("kit issue ref" in v for v in self._body("see kit#27 §2")))
217
+
218
+ def test_adr_ref(self):
219
+ self.assertTrue(any("ADR ref" in v for v in self._body("per ADR-0034 rule")))
220
+
221
+ def test_welle_ref(self):
222
+ self.assertTrue(any("Welle ref" in v for v in self._body("built in Welle 52")))
223
+
224
+ def test_slice_ref(self):
225
+ self.assertTrue(any("Slice ref" in v for v in self._body("done in Slice 1g")))
226
+
227
+ def test_parent_ref(self):
228
+ self.assertTrue(any("parent" in v for v in self._body("[x](../../other/SKILL.md)")))
229
+
230
+ def test_bare_owner_in_body(self):
231
+ self.assertTrue(any("iKon85" in v for v in self._body("authored by iKon85")))
232
+
233
+ def test_secret(self):
234
+ self.assertTrue(any("secret" in v for v in self._body("token " + "d" * 50)))
235
+
236
+ def test_gh_token(self):
237
+ self.assertTrue(any("token" in v for v in self._body("ghp_" + "A" * 30)))
238
+
239
+ def test_private_skill_dir_flagged(self):
240
+ (self.dir / ".claude/skills/drizzle").mkdir(parents=True)
241
+ self.assertTrue(any("project-private" in v for v in audit_dir(self.dir)))
242
+
243
+
244
+ class Exemptions(unittest.TestCase):
245
+ """Public slug is allowlisted; credit files may legitimately name people."""
246
+
247
+ def setUp(self):
248
+ self.dir = Path(tempfile.mkdtemp())
249
+ (self.dir / "agent-workflow-kit.package.json").write_text(
250
+ '{"kitVersion":"0.1.0","files":[]}', encoding="utf-8")
251
+
252
+ def _write(self, name, content):
253
+ (self.dir / name).write_text(content, encoding="utf-8")
254
+
255
+ def test_public_slug_allowed(self):
256
+ self._write("README.md", "npx github:iKon85/agent-workflow-kit init")
257
+ self.assertEqual(audit_dir(self.dir), [])
258
+
259
+ def test_credit_file_may_name_maintainer(self):
260
+ self._write("LICENSE", "Copyright (c) 2026 Niko (iKon85)\nMatt Pocock\nChase AI")
261
+ self.assertEqual(audit_dir(self.dir), [])
262
+
263
+ def test_credit_file_still_denies_board_id(self):
264
+ self._write("LICENSE", "Niko (iKon85) PVT_leak")
265
+ self.assertTrue(any("board" in v for v in audit_dir(self.dir)))
266
+
267
+ def test_manifest_own_hashes_not_flagged_as_secret(self):
268
+ # the package manifest's 64-hex file hashes are exempt (they ARE the kit's hashes)
269
+ (self.dir / "agent-workflow-kit.package.json").write_text(
270
+ json.dumps({"kitVersion": "0.1.0", "files": [
271
+ {"path": "x", "sha256": "f" * 64}]}), encoding="utf-8")
272
+ self.assertEqual(audit_dir(self.dir), [])
273
+
274
+ def test_loc_offender_traversal_guard_allowed(self):
275
+ p = self.dir / "scripts"
276
+ p.mkdir()
277
+ (p / "loc_offender_core.py").write_text(
278
+ 'if p == ".." or p.startswith("../") or "/../" in p or p.endswith("/.."):\n'
279
+ ' return None\n',
280
+ encoding="utf-8")
281
+ self.assertEqual(audit_dir(self.dir), [])
282
+
283
+
284
+ class RealDistKitIsClean(unittest.TestCase):
285
+ """Build dist-kit/ from SSOT and assert the audit passes (the publish gate)."""
286
+
287
+ def test_built_dist_kit_passes_audit(self):
288
+ build = REPO / "scripts/build-kit.mjs"
289
+ try:
290
+ r = subprocess.run(["node", str(build)], cwd=REPO,
291
+ capture_output=True, text=True, timeout=120)
292
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
293
+ self.skipTest(f"node/build unavailable: {e}")
294
+ self.assertEqual(r.returncode, 0, r.stderr)
295
+ violations = audit_dir(REPO / "dist-kit")
296
+ self.assertEqual(violations, [], "\n".join(violations[:40]))
297
+
298
+
299
+ if __name__ == "__main__":
300
+ unittest.main(verbosity=2)
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """Self-containment lint (Welle 26 / Slice 2 / #986): generic & vendored skills
3
+ must be portable.
4
+
5
+ Why: a published skill (npx/git-clone into a foreign repo, or a plugin cache)
6
+ cannot resolve `../../../docs/...` — the path does not exist there and `../`
7
+ escapes the plugin root. Project-specific content is found by *runtime
8
+ convention* (an inline project-root path like `docs/agents/board-sync.md` that
9
+ the model resolves from the project root), never a hardcoded `../` link.
10
+ project-private skills are exempt: they live in this repo forever and their
11
+ `../` refs are correct and clickable.
12
+
13
+ Scope: skills whose manifest class is `generic` or `vendored`. Every distributed
14
+ `*.md` file in the skill dir is scanned (SKILL.md + support docs), in both the
15
+ Claude source (`.claude/skills`) and the Codex mirror (`.agents/skills`).
16
+
17
+ A line carrying `self-containment-lint: ok` is exempt — line-scoped, for
18
+ deliberate "don't do this" doc examples.
19
+
20
+ Run: python3 scripts/test_skill_selfcontainment_lint.py
21
+ """
22
+ import json
23
+ import re
24
+ import unittest
25
+ from pathlib import Path
26
+
27
+ REPO_ROOT = Path(__file__).resolve().parent.parent
28
+ MANIFEST = REPO_ROOT / ".claude/skills/skill-manifest.json"
29
+ SKILL_DIRS = [".claude/skills", ".agents/skills"]
30
+ ENFORCED_CLASSES = {"generic", "vendored"}
31
+ PUBLISH_CLASSES = {"generic", "vendored", "adapter"}
32
+ VALID_CLASSES = {"generic", "vendored", "adapter", "project-private"}
33
+ VALID_SURFACES = {"claude", "codex"}
34
+
35
+ PARENT_REF = re.compile(r"\.\./")
36
+ EXEMPT = "self-containment-lint: ok"
37
+
38
+
39
+ def load_manifest() -> dict:
40
+ return json.loads(MANIFEST.read_text(encoding="utf-8"))
41
+
42
+
43
+ def find_parent_refs(text: str) -> list[tuple[int, str]]:
44
+ """Return (line_no, line) for every line with a `../` reach, minus exempt lines."""
45
+ out = []
46
+ for n, line in enumerate(text.splitlines(), 1):
47
+ if EXEMPT in line:
48
+ continue
49
+ if PARENT_REF.search(line):
50
+ out.append((n, line.strip()))
51
+ return out
52
+
53
+
54
+ def skill_dirs_on_disk(tree: str) -> set[str]:
55
+ base = REPO_ROOT / tree
56
+ if not base.is_dir():
57
+ return set()
58
+ # dot-dirs are tool caches (e.g. .impeccable/hook.cache.json, gitignored), not skills
59
+ return {p.name for p in base.iterdir() if p.is_dir() and not p.name.startswith(".")}
60
+
61
+
62
+ class Detector(unittest.TestCase):
63
+ """The `../` detector itself behaves."""
64
+
65
+ def test_flags_parent_link(self):
66
+ self.assertEqual(len(find_parent_refs("see [x](../../../docs/y.md)")), 1)
67
+
68
+ def test_flags_parent_in_code(self):
69
+ self.assertEqual(len(find_parent_refs("import x from '../lib/y'")), 1)
70
+
71
+ def test_ignores_same_dir_relative(self):
72
+ self.assertEqual(find_parent_refs("see [x](./sub/y.md)"), [])
73
+
74
+ def test_ignores_root_relative_convention(self):
75
+ # the portable form: an inline project-root path, no `../`
76
+ self.assertEqual(find_parent_refs("read `docs/agents/board-sync.md`"), [])
77
+
78
+ def test_exempt_marker_line_scoped(self):
79
+ self.assertEqual(
80
+ find_parent_refs("bad: [x](../y) <!-- self-containment-lint: ok -->"), [])
81
+
82
+ def test_exempt_is_line_scoped_not_file_wide(self):
83
+ text = "ok line <!-- self-containment-lint: ok -->\n[x](../../bad)"
84
+ self.assertEqual(len(find_parent_refs(text)), 1)
85
+
86
+
87
+ class ManifestValid(unittest.TestCase):
88
+ def test_schema_and_classes(self):
89
+ m = load_manifest()
90
+ self.assertEqual(m.get("schema_version"), 1)
91
+ for name, entry in m["skills"].items():
92
+ self.assertIn(entry.get("class"), VALID_CLASSES, name)
93
+ self.assertTrue(entry.get("surfaces"), f"{name}: no surfaces")
94
+ for s in entry["surfaces"]:
95
+ self.assertIn(s, VALID_SURFACES, f"{name}:{s}")
96
+ # publish is a mandatory bool — build-kit + lints consume it, not class
97
+ # (Slice 6 / #990 / Codex R2#3+#5).
98
+ self.assertIsInstance(entry.get("publish"), bool,
99
+ f"{name}: `publish` must be present and boolean")
100
+
101
+
102
+ class ManifestCompleteness(unittest.TestCase):
103
+ """Manifest <-> filesystem, both trees (Codex R1#4 / R2#1)."""
104
+
105
+ def test_every_disk_skill_classified(self):
106
+ m = load_manifest()["skills"]
107
+ problems = [f"{tree}/{d} on disk but missing from manifest"
108
+ for tree in SKILL_DIRS
109
+ for d in sorted(skill_dirs_on_disk(tree)) if d not in m]
110
+ self.assertEqual(problems, [], "\n".join(problems))
111
+
112
+ def test_every_entry_matches_its_surfaces(self):
113
+ m = load_manifest()["skills"]
114
+ claude = skill_dirs_on_disk(".claude/skills")
115
+ agents = skill_dirs_on_disk(".agents/skills")
116
+ problems = []
117
+ for name, entry in m.items():
118
+ surfaces = set(entry.get("surfaces", []))
119
+ if "claude" in surfaces and name not in claude:
120
+ problems.append(f"{name}: surfaces claude but no .claude/skills/{name}")
121
+ if "codex" in surfaces and name not in agents:
122
+ problems.append(f"{name}: surfaces codex but no .agents/skills/{name}")
123
+ if name in claude and "claude" not in surfaces:
124
+ problems.append(f"{name}: in .claude/skills but surfaces lacks claude")
125
+ if name in agents and "codex" not in surfaces:
126
+ problems.append(f"{name}: in .agents/skills but surfaces lacks codex")
127
+ self.assertEqual(problems, [], "\n".join(problems))
128
+
129
+
130
+ class PublishExclusion(unittest.TestCase):
131
+ """`publish` is authoritative; project-private skills never ship.
132
+
133
+ Adapters MAY ship — `adapter` is in PUBLISH_CLASSES (above). A published
134
+ skill can reference an adapter as a real workflow step (to-issues:59 +
135
+ orchestrate-wave -> codex-adapter-sync mirror), in which case build-kit's
136
+ shipped-deps-must-ship check (build-kit.mjs) *requires* the adapter to
137
+ publish — that dependency check is the authoritative consistency guardrail.
138
+ An adapter only ships when something published references it (spark-coordinator
139
+ stays publish:false — nothing published references it). Narrowed from the
140
+ original project-private+adapter rule (#990 R1#10/R2#3/#5) once a published
141
+ skill was found to depend on codex-adapter-sync; the blanket adapter ban
142
+ contradicted both PUBLISH_CLASSES and that dependency."""
143
+
144
+ def test_project_private_not_published(self):
145
+ m = load_manifest()["skills"]
146
+ leaks = sorted(n for n, e in m.items()
147
+ if e["class"] == "project-private" and e.get("publish"))
148
+ self.assertEqual(leaks, [], f"project-private marked publish:true: {leaks}")
149
+
150
+ def test_published_skill_dirs_exist_per_surface(self):
151
+ """every publish:true skill exists on disk in each surface it claims."""
152
+ m = load_manifest()["skills"]
153
+ claude = skill_dirs_on_disk(".claude/skills")
154
+ agents = skill_dirs_on_disk(".agents/skills")
155
+ problems = []
156
+ for name, entry in m.items():
157
+ if not entry.get("publish"):
158
+ continue
159
+ if "claude" in entry["surfaces"] and name not in claude:
160
+ problems.append(f"{name}: publish:true, surfaces claude, but missing on disk")
161
+ if "codex" in entry["surfaces"] and name not in agents:
162
+ problems.append(f"{name}: publish:true, surfaces codex, but missing on disk")
163
+ self.assertEqual(problems, [], "\n".join(problems))
164
+
165
+
166
+ class EnforcedSkillsSelfContained(unittest.TestCase):
167
+ """generic & vendored skills carry no `../` reach, all .md files, both trees."""
168
+
169
+ def test_no_parent_refs(self):
170
+ m = load_manifest()["skills"]
171
+ enforced = {n for n, e in m.items() if e["class"] in ENFORCED_CLASSES}
172
+ problems = []
173
+ for tree in SKILL_DIRS:
174
+ for name in sorted(enforced):
175
+ d = REPO_ROOT / tree / name
176
+ if not d.is_dir():
177
+ continue
178
+ for md in sorted(d.rglob("*.md")):
179
+ for ln, src in find_parent_refs(md.read_text(encoding="utf-8")):
180
+ problems.append(f"{md.relative_to(REPO_ROOT)}:{ln}: {src}")
181
+ self.assertEqual(
182
+ problems, [],
183
+ "`../` reach in a generic/vendored skill — replace with a project-root "
184
+ "convention path (e.g. `docs/agents/x.md`) + runtime fallback:\n"
185
+ + "\n".join(problems))
186
+
187
+
188
+ class LookupContract(unittest.TestCase):
189
+ """Skills migrated this slice reference project config by a project-root
190
+ convention path (no `../`) AND state a runtime fallback (Codex R2#2)."""
191
+
192
+ def test_migrated_skills_have_convention_path_and_fallback(self):
193
+ for name in ("to-prd", "to-issues", "spec-self-critique"):
194
+ txt = (REPO_ROOT / ".claude/skills" / name / "SKILL.md").read_text(encoding="utf-8")
195
+ self.assertRegex(txt, r"docs/(agents|conventions)/",
196
+ f"{name}: no project-root convention path")
197
+ self.assertIn("/setup-workflow", txt, f"{name}: no fallback")
198
+ # same scanner as the lint (honors the line-scoped exempt marker), not a
199
+ # raw substring — keeps LookupContract consistent with the real check (R2#4)
200
+ self.assertEqual(find_parent_refs(txt), [], f"{name}: still has a parent-dir reach")
201
+
202
+
203
+ class MockRepoLookup(unittest.TestCase):
204
+ """Runtime-lookup contract against a mock consumer repo. The lookup itself is
205
+ a model convention (no resolver to unit-test), so we assert both branches:
206
+ project file present → resolves from the project root; absent → the skill's
207
+ stated fallback path applies."""
208
+
209
+ def test_present_resolves_absent_falls_back(self):
210
+ import tempfile
211
+ to_prd = (REPO_ROOT / ".claude/skills/to-prd/SKILL.md").read_text(encoding="utf-8")
212
+ with tempfile.TemporaryDirectory() as d:
213
+ target = Path(d) / "docs/agents/board-sync.md"
214
+ self.assertFalse(target.exists()) # absent → fallback branch
215
+ self.assertIn("/setup-workflow", to_prd) # skill states the fallback
216
+ target.parent.mkdir(parents=True)
217
+ target.write_text("x", encoding="utf-8")
218
+ self.assertTrue(target.exists()) # present → resolves from root
219
+
220
+ def test_spec_self_critique_layer_present_absent(self):
221
+ """spec-self-critique runs with the project layer (full pass) and without
222
+ it (base pass + warning). Text/path contract — the skill names the layer
223
+ path + a fallback; behavior is verified manually in the mock-repo run."""
224
+ import tempfile
225
+ skill = (REPO_ROOT / ".claude/skills/spec-self-critique/SKILL.md").read_text(encoding="utf-8")
226
+ self.assertIn("docs/agents/skills/spec-self-critique.md", skill) # names the layer
227
+ self.assertIn("/setup-workflow", skill) # states the fallback
228
+ with tempfile.TemporaryDirectory() as d:
229
+ target = Path(d) / "docs/agents/skills/spec-self-critique.md"
230
+ self.assertFalse(target.exists()) # absent → base pass + warning
231
+ target.parent.mkdir(parents=True)
232
+ target.write_text("x", encoding="utf-8")
233
+ self.assertTrue(target.exists()) # present → full pass
234
+
235
+
236
+ class NegativeFixture(unittest.TestCase):
237
+ """A real `../` in a scanned .md file is caught end-to-end (regression guard)."""
238
+
239
+ def test_parent_ref_in_md_file_is_flagged(self):
240
+ import tempfile
241
+ with tempfile.TemporaryDirectory() as d:
242
+ f = Path(d) / "SKILL.md"
243
+ f.write_text("see [x](../../../docs/y.md)\n", encoding="utf-8")
244
+ self.assertEqual(len(find_parent_refs(f.read_text(encoding="utf-8"))), 1)
245
+
246
+ def test_exempt_line_in_md_file_is_not_flagged(self):
247
+ import tempfile
248
+ with tempfile.TemporaryDirectory() as d:
249
+ f = Path(d) / "SKILL.md"
250
+ f.write_text("anti: [x](../bad) <!-- self-containment-lint: ok -->\n", encoding="utf-8")
251
+ self.assertEqual(find_parent_refs(f.read_text(encoding="utf-8")), [])
252
+
253
+
254
+ if __name__ == "__main__":
255
+ unittest.main(verbosity=2)