its-magic 0.1.2-9 → 0.1.3-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 (237) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +93 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +1036 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +312 -0
  9. package/scripts/doc_profile_lib.py +415 -0
  10. package/scripts/guard_installer_publish.py +88 -0
  11. package/scripts/intake_bug_routing_guard.py +67 -0
  12. package/scripts/intake_evidence_lib.py +802 -0
  13. package/scripts/intake_evidence_validate.py +73 -0
  14. package/scripts/materialize_codebase_map.py +184 -0
  15. package/scripts/remote_config_summary.py +243 -0
  16. package/template/.cursor/agents/curator.mdc +35 -0
  17. package/template/.cursor/agents/dev.mdc +29 -0
  18. package/template/.cursor/agents/po.mdc +141 -0
  19. package/template/.cursor/agents/qa.mdc +28 -0
  20. package/template/.cursor/agents/release.mdc +28 -0
  21. package/template/.cursor/agents/security.mdc +98 -0
  22. package/template/.cursor/agents/tech-lead.mdc +57 -0
  23. package/template/.cursor/commands/architecture.md +127 -0
  24. package/template/.cursor/commands/ask.md +55 -0
  25. package/template/.cursor/commands/auto.md +533 -0
  26. package/template/.cursor/commands/discovery.md +47 -0
  27. package/template/.cursor/commands/execute.md +343 -0
  28. package/template/.cursor/commands/intake.md +323 -0
  29. package/template/.cursor/commands/map-codebase.md +46 -0
  30. package/template/.cursor/commands/memory-audit.md +175 -0
  31. package/template/.cursor/commands/milestone-complete.md +51 -0
  32. package/template/.cursor/commands/milestone-start.md +59 -0
  33. package/template/.cursor/commands/pause.md +64 -0
  34. package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
  35. package/template/.cursor/commands/plan-verify.md +34 -0
  36. package/template/.cursor/commands/qa.md +226 -0
  37. package/template/.cursor/commands/quick.md +41 -0
  38. package/template/.cursor/commands/refresh-context.md +82 -0
  39. package/template/.cursor/commands/release.md +523 -0
  40. package/template/.cursor/commands/research.md +49 -0
  41. package/template/.cursor/commands/resume.md +67 -0
  42. package/template/.cursor/commands/security-review.md +81 -0
  43. package/template/.cursor/commands/sprint-plan.md +103 -0
  44. package/template/.cursor/commands/status-reconcile.md +95 -0
  45. package/template/.cursor/commands/verify-work.md +152 -0
  46. package/template/.cursor/dev-environment.json.example +22 -0
  47. package/{.cursor → template/.cursor}/hooks/README.md +3 -2
  48. package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
  49. package/template/.cursor/hooks.json +26 -0
  50. package/template/.cursor/remote.json +31 -0
  51. package/template/.cursor/rules/caveman.mdc +184 -0
  52. package/template/.cursor/rules/coding-standards.mdc +39 -0
  53. package/template/.cursor/rules/core.mdc +111 -0
  54. package/template/.cursor/rules/handoffs.mdc +27 -0
  55. package/template/.cursor/rules/quality.mdc +49 -0
  56. package/template/.cursor/scratchpad.local.example.md +323 -0
  57. package/template/.cursor/scratchpad.md +324 -0
  58. package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
  59. package/template/.cursorignore +6 -0
  60. package/template/.env.example +28 -0
  61. package/template/.github/workflows/ci.yml +194 -0
  62. package/{.github → template/.github}/workflows/deploy.yml +5 -2
  63. package/template/.its-magic-version +1 -0
  64. package/template/CHANGELOG.md +11 -0
  65. package/template/README.md +1602 -0
  66. package/template/decisions/DEC-0001.md +11 -0
  67. package/template/decisions/DEC-0002.md +11 -0
  68. package/template/docs/developer/README.md +44 -0
  69. package/template/docs/engineering/architecture.md +9 -0
  70. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  71. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  72. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  73. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  74. package/template/docs/engineering/compatibility-report.md +20 -0
  75. package/template/docs/engineering/compatibility-signals.md +7 -0
  76. package/template/docs/engineering/component-scope-report.md +16 -0
  77. package/template/docs/engineering/component-scope.md +19 -0
  78. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  79. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  80. package/template/docs/engineering/decisions.md +16 -0
  81. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  82. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  83. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  84. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  85. package/template/docs/engineering/phase-context.md +16 -0
  86. package/template/docs/engineering/release-targets.json +123 -0
  87. package/template/docs/engineering/research.md +29 -0
  88. package/template/docs/engineering/runbook.md +2391 -0
  89. package/template/docs/engineering/runtime-connectivity.md +81 -0
  90. package/template/docs/engineering/security-review.md +33 -0
  91. package/template/docs/engineering/spec-pack/README.md +20 -0
  92. package/template/docs/engineering/state-archive/README.md +15 -0
  93. package/template/docs/engineering/state.md +20 -0
  94. package/template/docs/engineering/status-normalization-report.md +19 -0
  95. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  96. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  97. package/template/docs/product/acceptance.md +1 -0
  98. package/template/docs/product/backlog.md +5 -0
  99. package/template/docs/product/vision.md +11 -0
  100. package/template/docs/user-guides/README.md +40 -0
  101. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  102. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  103. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  104. package/template/handoffs/release_notes.md +51 -0
  105. package/template/handoffs/release_queue.md +47 -0
  106. package/template/handoffs/release_to_dev.md +31 -0
  107. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  108. package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
  109. package/template/handoffs/resume_brief.md +14 -0
  110. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  111. package/template/handoffs/token_cost_runs/README.md +26 -0
  112. package/template/its_magic/.its-magic-version +1 -0
  113. package/template/its_magic/README.md +9 -0
  114. package/template/scripts/auto_outer_driver.py +521 -0
  115. package/template/scripts/caveman_compress_input.py +903 -0
  116. package/template/scripts/check_downstream_ci_guard.py +67 -0
  117. package/template/scripts/check_intake_template_parity.py +312 -0
  118. package/template/scripts/check_token_cost_parity.py +69 -0
  119. package/template/scripts/dev_environment_lib.py +601 -0
  120. package/template/scripts/doc_profile_lib.py +415 -0
  121. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  122. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  123. package/template/scripts/guard_installer_publish.py +88 -0
  124. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  125. package/template/scripts/intake_bug_routing_guard.py +67 -0
  126. package/template/scripts/intake_evidence_lib.py +802 -0
  127. package/template/scripts/intake_evidence_validate.py +73 -0
  128. package/template/scripts/materialize_codebase_map.py +184 -0
  129. package/template/scripts/pack_json_validate.py +130 -0
  130. package/template/scripts/project_readme_coverage_lib.py +417 -0
  131. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  132. package/{scripts → template/scripts}/release-all.sh +27 -4
  133. package/template/scripts/release_changelog_backfill.py +153 -0
  134. package/template/scripts/release_changelog_lib.py +544 -0
  135. package/template/scripts/release_changelog_validate.py +134 -0
  136. package/template/scripts/remote_config_summary.py +243 -0
  137. package/template/scripts/sync_push_gates.py +198 -0
  138. package/template/scripts/token_cost_compare.py +40 -0
  139. package/template/scripts/token_cost_lib.py +108 -0
  140. package/template/scripts/uat_probe_lib.py +868 -0
  141. package/template/scripts/validate-and-push.ps1 +280 -0
  142. package/template/scripts/validate-and-push.sh +243 -0
  143. package/template/scripts/validate_doc_profile.py +103 -0
  144. package/template/scripts/validate_project_readme_coverage.py +151 -0
  145. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  146. package/template/sprints/S0001/progress.md +1 -0
  147. package/template/sprints/S0001/qa-findings.md +9 -0
  148. package/template/sprints/S0001/release-findings.md +24 -0
  149. package/template/sprints/S0001/sprint.md +9 -0
  150. package/template/sprints/S0001/summary.md +7 -0
  151. package/template/sprints/S0001/tasks.md +1 -0
  152. package/template/sprints/S0001/uat.json +6 -0
  153. package/template/sprints/S0001/uat.md +5 -0
  154. package/template/sprints/quick/Q0001/summary.md +1 -0
  155. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  156. package/.cursor/agents/curator.mdc +0 -21
  157. package/.cursor/agents/dev.mdc +0 -20
  158. package/.cursor/agents/po.mdc +0 -19
  159. package/.cursor/agents/qa.mdc +0 -19
  160. package/.cursor/agents/release.mdc +0 -19
  161. package/.cursor/agents/tech-lead.mdc +0 -21
  162. package/.cursor/commands/gsd-architecture.md +0 -29
  163. package/.cursor/commands/gsd-auto.md +0 -27
  164. package/.cursor/commands/gsd-discovery.md +0 -27
  165. package/.cursor/commands/gsd-execute.md +0 -32
  166. package/.cursor/commands/gsd-intake.md +0 -28
  167. package/.cursor/commands/gsd-map-codebase.md +0 -25
  168. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  169. package/.cursor/commands/gsd-milestone-start.md +0 -26
  170. package/.cursor/commands/gsd-pause.md +0 -25
  171. package/.cursor/commands/gsd-plan-verify.md +0 -26
  172. package/.cursor/commands/gsd-qa.md +0 -28
  173. package/.cursor/commands/gsd-quick.md +0 -24
  174. package/.cursor/commands/gsd-refresh-context.md +0 -26
  175. package/.cursor/commands/gsd-release.md +0 -29
  176. package/.cursor/commands/gsd-research.md +0 -28
  177. package/.cursor/commands/gsd-resume.md +0 -26
  178. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  179. package/.cursor/commands/gsd-verify-work.md +0 -25
  180. package/.cursor/hooks.json +0 -26
  181. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  182. package/.cursor/remote.json +0 -18
  183. package/.cursor/rules/gsd-core.mdc +0 -18
  184. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  185. package/.cursor/rules/gsd-quality.mdc +0 -15
  186. package/.cursor/scratchpad.md +0 -34
  187. package/.github/workflows/ci.yml +0 -47
  188. package/decisions/DEC-0001.md +0 -21
  189. package/decisions/DEC-0002.md +0 -21
  190. package/docs/engineering/architecture.md +0 -354
  191. package/docs/engineering/decisions.md +0 -6
  192. package/docs/engineering/research.md +0 -11
  193. package/docs/engineering/runbook.md +0 -32
  194. package/docs/engineering/state.md +0 -33
  195. package/docs/product/acceptance.md +0 -6
  196. package/docs/product/backlog.md +0 -7
  197. package/docs/product/vision.md +0 -46
  198. package/gsd-installer.ps1 +0 -189
  199. package/gsd-installer.py +0 -195
  200. package/gsd-installer.sh +0 -201
  201. package/handoffs/release_notes.md +0 -14
  202. package/handoffs/resume_brief.md +0 -8
  203. package/milestones/M0001/milestone.json +0 -7
  204. package/milestones/M0001/phases.json +0 -9
  205. package/milestones/M0001/progress.md +0 -3
  206. package/milestones/M0001/summary.md +0 -3
  207. package/scripts/generate-release-notes.ps1 +0 -74
  208. package/scripts/generate-release-notes.sh +0 -63
  209. package/scripts/release-all.ps1 +0 -423
  210. package/sprints/S0001/progress.md +0 -4
  211. package/sprints/S0001/qa-findings.md +0 -113
  212. package/sprints/S0001/sprint.md +0 -70
  213. package/sprints/S0001/summary.md +0 -46
  214. package/sprints/S0001/tasks.md +0 -35
  215. package/sprints/S0001/uat.json +0 -8
  216. package/sprints/S0001/uat.md +0 -8
  217. package/sprints/quick/Q0001/summary.md +0 -3
  218. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  230. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  231. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  232. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  233. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  234. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  235. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  236. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  237. /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
@@ -0,0 +1,415 @@
1
+ """
2
+ Documentation profile resolution and surface helpers (DEC-0059).
3
+
4
+ Shared by installer (optional surface sync) and scripts/validate_doc_profile.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Dict, List, Optional, Set, Tuple
11
+
12
+ DOC_AUDIENCE_ALLOWED = frozenset({"user", "developer", "both"})
13
+ DOC_DETAIL_ALLOWED = frozenset({"concise", "balanced", "technical-deep"})
14
+
15
+ USER_KEY_TO_H2: Dict[str, str] = {
16
+ "USER_PURPOSE": "Purpose",
17
+ "USER_QUICKSTART": "Quickstart",
18
+ "USER_EXAMPLES": "Examples",
19
+ "USER_TROUBLESHOOTING": "Troubleshooting",
20
+ "USER_LIMITATIONS": "Limitations",
21
+ "USER_RELATED_DOCS": "Related documentation",
22
+ }
23
+
24
+ DEV_KEY_TO_H2: Dict[str, str] = {
25
+ "DEV_PREREQS": "Prerequisites",
26
+ "DEV_WORKFLOW": "Workflow",
27
+ "DEV_QUALITY_GATES": "Quality gates",
28
+ "DEV_ARCHITECTURE": "Architecture notes",
29
+ "DEV_CONTRACTS": "Contracts and interfaces",
30
+ "DEV_DECISIONS": "Engineering decisions",
31
+ }
32
+
33
+ DEV_H2_TITLES = frozenset(DEV_KEY_TO_H2.values())
34
+ USER_H2_TITLES = frozenset(USER_KEY_TO_H2.values())
35
+
36
+ POINTER_H2 = "Contributing"
37
+
38
+ # Root README H2 budget (user-channel headings + optional Contributing pointer only).
39
+ ROOT_BUDGET: Dict[Tuple[str, str], int] = {}
40
+ for aud in ("user", "developer", "both"):
41
+ for det in ("concise", "balanced", "technical-deep"):
42
+ if aud == "user":
43
+ ROOT_BUDGET[(aud, det)] = {"concise": 5, "balanced": 7, "technical-deep": 9}[det]
44
+ elif aud == "developer":
45
+ ROOT_BUDGET[(aud, det)] = {"concise": 4, "balanced": 6, "technical-deep": 8}[det]
46
+ else:
47
+ ROOT_BUDGET[(aud, det)] = {"concise": 6, "balanced": 8, "technical-deep": 6}[det]
48
+
49
+
50
+ def resolve_doc_profile(merged: Dict[str, str]) -> Tuple[Optional[str], Optional[str], List[str]]:
51
+ """Return (audience, detail, errors). Empty profile keys default per DEC-0059 §6."""
52
+ raw_a = (merged.get("DOC_AUDIENCE_PROFILE") or "").strip().lower()
53
+ raw_d = (merged.get("DOC_DETAIL_LEVEL") or "").strip().lower()
54
+ errors: List[str] = []
55
+ if raw_a and raw_a not in DOC_AUDIENCE_ALLOWED:
56
+ errors.append(
57
+ "[DOC_PROFILE_INVALID] DOC_AUDIENCE_PROFILE must be one of: "
58
+ f"user, developer, both (got={raw_a!r})."
59
+ )
60
+ if raw_d and raw_d not in DOC_DETAIL_ALLOWED:
61
+ errors.append(
62
+ "[DOC_PROFILE_INVALID] DOC_DETAIL_LEVEL must be one of: "
63
+ f"concise, balanced, technical-deep (got={raw_d!r})."
64
+ )
65
+ if errors:
66
+ return None, None, errors
67
+ audience = raw_a or "both"
68
+ detail = raw_d or "balanced"
69
+ return audience, detail, []
70
+
71
+
72
+ def required_user_keys(audience: str, detail: str) -> Set[str]:
73
+ if audience not in ("user", "both"):
74
+ return set()
75
+ if detail == "concise":
76
+ return {"USER_PURPOSE", "USER_QUICKSTART", "USER_LIMITATIONS"}
77
+ if detail == "balanced":
78
+ return {
79
+ "USER_PURPOSE",
80
+ "USER_QUICKSTART",
81
+ "USER_LIMITATIONS",
82
+ "USER_EXAMPLES",
83
+ "USER_RELATED_DOCS",
84
+ }
85
+ return {
86
+ "USER_PURPOSE",
87
+ "USER_QUICKSTART",
88
+ "USER_LIMITATIONS",
89
+ "USER_EXAMPLES",
90
+ "USER_RELATED_DOCS",
91
+ "USER_TROUBLESHOOTING",
92
+ }
93
+
94
+
95
+ def required_dev_keys(audience: str, detail: str) -> Set[str]:
96
+ if audience not in ("developer", "both"):
97
+ return set()
98
+ if detail == "concise":
99
+ return {"DEV_PREREQS", "DEV_WORKFLOW"}
100
+ if detail == "balanced":
101
+ return {"DEV_PREREQS", "DEV_WORKFLOW", "DEV_QUALITY_GATES", "DEV_ARCHITECTURE"}
102
+ return {
103
+ "DEV_PREREQS",
104
+ "DEV_WORKFLOW",
105
+ "DEV_QUALITY_GATES",
106
+ "DEV_ARCHITECTURE",
107
+ "DEV_CONTRACTS",
108
+ "DEV_DECISIONS",
109
+ }
110
+
111
+
112
+ def extract_h2_titles(markdown: str) -> List[str]:
113
+ titles: List[str] = []
114
+ for line in markdown.splitlines():
115
+ if line.startswith("## ") and not line.startswith("###"):
116
+ titles.append(line[3:].strip())
117
+ return titles
118
+
119
+
120
+ def has_exact_h2(markdown: str, title: str) -> bool:
121
+ for line in markdown.splitlines():
122
+ if line.startswith("## ") and not line.startswith("###"):
123
+ if line[3:].strip() == title:
124
+ return True
125
+ return False
126
+
127
+
128
+ def count_profile_root_h2s(
129
+ markdown: str,
130
+ audience: str,
131
+ detail: str,
132
+ required_user_keys_set: Set[str],
133
+ ) -> int:
134
+ """
135
+ Count H2 lines in budget scope: required USER_* titles only for user/both;
136
+ Contributing pointer alone for developer-only (DEC-0059 / R-0054 user H2 budgets).
137
+ """
138
+ titles = extract_h2_titles(markdown)
139
+ want: Set[str] = set()
140
+ for key in required_user_keys_set:
141
+ want.add(USER_KEY_TO_H2[key])
142
+ if audience == "developer":
143
+ return sum(1 for t in titles if t == POINTER_H2)
144
+ n = 0
145
+ for t in titles:
146
+ if t in want:
147
+ n += 1
148
+ return n
149
+
150
+
151
+ def dev_h2_forbidden_in_root(markdown: str) -> List[str]:
152
+ """Return DEV_* H2 titles present in root (split layout forbids these in README)."""
153
+ found: List[str] = []
154
+ for t in extract_h2_titles(markdown):
155
+ if t in DEV_H2_TITLES:
156
+ found.append(t)
157
+ return found
158
+
159
+
160
+ def validate_optional_modes(merged: Dict[str, str], readme_text: str) -> List[str]:
161
+ """
162
+ US-0031 / US-0032 compatibility: never require optional artifacts when modes are off.
163
+ When on, ensure profile surfaces mention cross-links (additive, lightweight).
164
+ """
165
+ out: List[str] = []
166
+ sp = (merged.get("SPEC_PACK_MODE") or "0").strip()
167
+ ug = (merged.get("USER_GUIDE_MODE") or "0").strip()
168
+ if sp != "1" and ug != "1":
169
+ return out
170
+ if sp == "1":
171
+ if "docs/engineering" not in readme_text and "spec" not in readme_text.lower():
172
+ out.append(
173
+ "[DOC_OPTIONAL_CROSSLINK_WEAK] SPEC_PACK_MODE=1: root README should mention "
174
+ "engineering docs or spec-pack paths in a user channel section (see Related documentation)."
175
+ )
176
+ if ug == "1":
177
+ if "user-guides" not in readme_text and "user guide" not in readme_text.lower():
178
+ out.append(
179
+ "[DOC_OPTIONAL_CROSSLINK_WEAK] USER_GUIDE_MODE=1: root README should mention "
180
+ "docs/user-guides in a user channel section."
181
+ )
182
+ return out
183
+
184
+
185
+ def ensure_section(path: str, h2_title: str, body: str) -> Tuple[bool, str]:
186
+ """
187
+ Append ## h2_title + body if missing. Non-destructive.
188
+ Returns (changed, message).
189
+ """
190
+ ensure_parent_dir(path)
191
+ if os.path.isfile(path):
192
+ text = _read_utf8(path)
193
+ else:
194
+ text = ""
195
+
196
+ if has_exact_h2(text, h2_title):
197
+ return False, f"[DOC_PROFILE_SYNC] skip existing: ## {h2_title} ({path})"
198
+
199
+ block = f"\n\n## {h2_title}\n\n{body.strip()}\n"
200
+ if not text:
201
+ base = os.path.basename(path)
202
+ if base.lower() == "readme.md":
203
+ text = f"# Documentation\n"
204
+ else:
205
+ text = f"# {base.replace('.md', '').replace('_', ' ')}\n"
206
+ new_text = text.rstrip() + block
207
+ _write_utf8(path, new_text)
208
+ return True, f"[DOC_PROFILE_SYNC] appended: ## {h2_title} ({path})"
209
+
210
+
211
+ def ensure_doc_surfaces_merged(
212
+ merged: Dict[str, str],
213
+ target_root: str,
214
+ print_ok: bool = True,
215
+ ) -> List[str]:
216
+ audience, detail, errors = resolve_doc_profile(merged)
217
+ if errors:
218
+ return list(errors)
219
+ assert audience is not None and detail is not None
220
+ messages: List[str] = []
221
+ uk = required_user_keys(audience, detail)
222
+ dk = required_dev_keys(audience, detail)
223
+ readme = os.path.join(target_root, "README.md")
224
+ dev_readme = os.path.join(target_root, "docs", "developer", "README.md")
225
+
226
+ stubs_user = {
227
+ "USER_PURPOSE": (
228
+ "Describe what this repository is for in plain language. "
229
+ "Replace this placeholder with your product outcome."
230
+ ),
231
+ "USER_QUICKSTART": (
232
+ "Link to your fastest path to success. For its-magic, see [Setup](#setup) above."
233
+ ),
234
+ "USER_EXAMPLES": "Add short, copy-paste friendly examples for common tasks.",
235
+ "USER_TROUBLESHOOTING": (
236
+ "List frequent issues, what to check, and where logs or docs live."
237
+ ),
238
+ "USER_LIMITATIONS": "Call out known limits, unsupported environments, or sharp edges.",
239
+ "USER_RELATED_DOCS": (
240
+ "Link runbooks, architecture notes, and deeper guides. "
241
+ "Operator commands live in `docs/engineering/runbook.md`."
242
+ ),
243
+ }
244
+ stubs_dev = {
245
+ "DEV_PREREQS": "Toolchain, repo layout, and local prerequisites for contributors.",
246
+ "DEV_WORKFLOW": "Branching, phases, and day-to-day contributor workflow.",
247
+ "DEV_QUALITY_GATES": "Tests, lint, typecheck, and review expectations before merge.",
248
+ "DEV_ARCHITECTURE": "High-level modules, boundaries, and extension points.",
249
+ "DEV_CONTRACTS": "Public interfaces, file formats, and compatibility promises.",
250
+ "DEV_DECISIONS": "Pointers to `decisions/` and architecture sections that matter.",
251
+ }
252
+
253
+ for key in sorted(uk):
254
+ h2 = USER_KEY_TO_H2[key]
255
+ changed, msg = ensure_section(readme, h2, stubs_user[key])
256
+ if print_ok or changed:
257
+ messages.append(msg)
258
+
259
+ if dk:
260
+ for key in sorted(dk):
261
+ h2 = DEV_KEY_TO_H2[key]
262
+ changed, msg = ensure_section(dev_readme, h2, stubs_dev[key])
263
+ if print_ok or changed:
264
+ messages.append(msg)
265
+
266
+ if audience in ("developer", "both"):
267
+ contrib_body = (
268
+ "Contributor-focused workflow and guardrails live in "
269
+ "[`docs/developer/README.md`](docs/developer/README.md)."
270
+ )
271
+ changed, msg = ensure_section(readme, POINTER_H2, contrib_body)
272
+ if print_ok or changed:
273
+ messages.append(msg)
274
+
275
+ return messages
276
+
277
+
278
+ def optional_mode_warnings(merged: Dict[str, str], readme_text: str) -> List[str]:
279
+ """Non-blocking hints when optional doc modes are enabled (US-0031 / US-0032)."""
280
+ return validate_optional_modes(merged, readme_text)
281
+
282
+
283
+ def ensure_parent_dir(path: str) -> None:
284
+ parent = os.path.dirname(path)
285
+ if parent and not os.path.isdir(parent):
286
+ os.makedirs(parent, exist_ok=True)
287
+
288
+
289
+ def _read_utf8(path: str) -> str:
290
+ with open(path, "r", encoding="utf-8") as f:
291
+ return f.read()
292
+
293
+
294
+ def _write_utf8(path: str, text: str) -> None:
295
+ ensure_parent_dir(path)
296
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
297
+ f.write(text)
298
+
299
+
300
+ def validate_repo_doc_profile(
301
+ target_root: str,
302
+ merged: Dict[str, str],
303
+ template_root: Optional[str],
304
+ ) -> List[str]:
305
+ """
306
+ Full validation for active target_root; optional template_root for parity.
307
+ Returns error strings (empty => pass).
308
+ """
309
+ errors: List[str] = []
310
+ audience, detail, err = resolve_doc_profile(merged)
311
+ errors.extend(err)
312
+ if errors:
313
+ return errors
314
+ assert audience is not None and detail is not None
315
+
316
+ uk = required_user_keys(audience, detail)
317
+ dk = required_dev_keys(audience, detail)
318
+
319
+ readme_path = os.path.join(target_root, "README.md")
320
+ dev_path = os.path.join(target_root, "docs", "developer", "README.md")
321
+
322
+ if not os.path.isfile(readme_path):
323
+ errors.append("[DOC_PROFILE_MERGE_ERROR] README.md missing (required for user channel checks).")
324
+ return errors
325
+
326
+ readme_text = _read_utf8(readme_path)
327
+
328
+ if audience in ("developer", "both") and dk:
329
+ if not os.path.isfile(dev_path):
330
+ errors.append(
331
+ "[DOC_SECTION_MISSING:shard] docs/developer/README.md missing but profile requires developer sections."
332
+ )
333
+
334
+ if audience in ("developer", "both"):
335
+ if not has_exact_h2(readme_text, POINTER_H2):
336
+ errors.append(
337
+ f"[DOC_SECTION_MISSING:DEV_SHARD_POINTER] Missing H2 ## {POINTER_H2} in README.md "
338
+ "(required pointer to docs/developer/README.md per DEC-0059)."
339
+ )
340
+ bad = dev_h2_forbidden_in_root(readme_text)
341
+ if bad:
342
+ errors.append(
343
+ "[DOC_PROFILE_INVALID] DEV sections must not use root README for split layout; "
344
+ f"found H2 titles {bad!r}. Move them to docs/developer/README.md."
345
+ )
346
+
347
+ for key in sorted(uk):
348
+ h2 = USER_KEY_TO_H2[key]
349
+ if not has_exact_h2(readme_text, h2):
350
+ errors.append(f"[DOC_SECTION_MISSING:{key}] Missing H2 ## {h2} in README.md.")
351
+
352
+ dev_text = ""
353
+ if os.path.isfile(dev_path):
354
+ dev_text = _read_utf8(dev_path)
355
+
356
+ for key in sorted(dk):
357
+ h2 = DEV_KEY_TO_H2[key]
358
+ if not has_exact_h2(dev_text, h2):
359
+ errors.append(f"[DOC_SECTION_MISSING:{key}] Missing H2 ## {h2} in docs/developer/README.md.")
360
+
361
+ budget = ROOT_BUDGET.get((audience, detail), 8)
362
+ counted = count_profile_root_h2s(readme_text, audience, detail, uk)
363
+ if counted > budget:
364
+ errors.append(
365
+ f"[DOC_SECTION_BUDGET_EXCEEDED] Root README profile-scoped H2 count={counted} "
366
+ f"exceeds budget={budget} for audience={audience!r} detail={detail!r}."
367
+ )
368
+
369
+ if template_root:
370
+ tr = os.path.join(template_root, "README.md")
371
+ td = os.path.join(template_root, "docs", "developer", "README.md")
372
+ if not os.path.isfile(tr):
373
+ errors.append("[DOC_TEMPLATE_PARITY_FAIL] template/README.md missing.")
374
+ else:
375
+ tt = _read_utf8(tr)
376
+ for key in sorted(uk):
377
+ h2 = USER_KEY_TO_H2[key]
378
+ if has_exact_h2(readme_text, h2) != has_exact_h2(tt, h2):
379
+ errors.append(
380
+ f"[DOC_TEMPLATE_PARITY_FAIL] USER H2 ## {h2} presence differs active vs template README."
381
+ )
382
+ if audience in ("developer", "both"):
383
+ if has_exact_h2(readme_text, POINTER_H2) != has_exact_h2(tt, POINTER_H2):
384
+ errors.append(
385
+ "[DOC_TEMPLATE_PARITY_FAIL] ## Contributing pointer presence differs active vs template README."
386
+ )
387
+ if dk:
388
+ if not os.path.isfile(td) or not os.path.isfile(dev_path):
389
+ errors.append(
390
+ "[DOC_TEMPLATE_PARITY_FAIL] developer README missing in active or template."
391
+ )
392
+ else:
393
+ tdev = _read_utf8(td)
394
+ for key in sorted(dk):
395
+ h2 = DEV_KEY_TO_H2[key]
396
+ if has_exact_h2(dev_text, h2) != has_exact_h2(tdev, h2):
397
+ errors.append(
398
+ f"[DOC_TEMPLATE_PARITY_FAIL] DEV H2 ## {h2} presence differs active vs template."
399
+ )
400
+
401
+ return errors
402
+
403
+
404
+ def self_test_resolver() -> None:
405
+ """Tier B: assert matrix key sets."""
406
+ assert required_user_keys("user", "concise") == {
407
+ "USER_PURPOSE",
408
+ "USER_QUICKSTART",
409
+ "USER_LIMITATIONS",
410
+ }
411
+ assert required_dev_keys("developer", "technical-deep") == set(DEV_KEY_TO_H2.keys())
412
+ a, d, e = resolve_doc_profile({"DOC_AUDIENCE_PROFILE": "", "DOC_DETAIL_LEVEL": ""})
413
+ assert not e and a == "both" and d == "balanced"
414
+ _, _, e2 = resolve_doc_profile({"DOC_AUDIENCE_PROFILE": "nope"})
415
+ assert e2 and "DOC_PROFILE_INVALID" in e2[0]
@@ -0,0 +1,222 @@
1
+ """
2
+ Downstream CI drift guard — template forbidden-pattern scan + active inventory (BUG-0009 / DEC-0075).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ JOB_KEY_RE = re.compile(r"^\s{2}(\w[\w-]*):", re.MULTILINE)
14
+
15
+ FORBIDDEN_JOB_IDS = frozenset({"npm-test", "brew-test", "choco-test"})
16
+ ALLOWED_TEMPLATE_JOBS = frozenset({"checks", "auto-fix"})
17
+ REQUIRED_ACTIVE_JOBS = frozenset(
18
+ {"checks", "auto-fix", "npm-test", "brew-test", "choco-test"}
19
+ )
20
+
21
+ FORBIDDEN_SUBSTRINGS: Tuple[str, ...] = (
22
+ "npm-test",
23
+ "brew-test",
24
+ "choco-test",
25
+ "npm pack",
26
+ "its-magic-*.tgz",
27
+ "installer.sh",
28
+ "packaging/chocolatey",
29
+ "packaging/homebrew",
30
+ "choco pack",
31
+ "brew style",
32
+ )
33
+
34
+ REASON_FORBIDDEN_PATTERN = "DOWNSTREAM_CI_FORBIDDEN_PATTERN"
35
+ REASON_JOB_LEAK = "DOWNSTREAM_CI_JOB_LEAK"
36
+ REASON_PACKAGING_MISSING = "KIT_CI_PACKAGING_JOBS_MISSING"
37
+
38
+ REPORT_SCHEMA_VERSION = 1
39
+
40
+
41
+ @dataclass
42
+ class GuardViolation:
43
+ reason_code: str
44
+ detail: str
45
+
46
+
47
+ @dataclass
48
+ class GuardReport:
49
+ template_job_keys: List[str] = field(default_factory=list)
50
+ active_job_keys: List[str] = field(default_factory=list)
51
+ forbidden_hits: List[str] = field(default_factory=list)
52
+ violations: List[GuardViolation] = field(default_factory=list)
53
+ ok: bool = True
54
+
55
+
56
+ def read_utf8(path: str) -> str:
57
+ with open(path, "r", encoding="utf-8") as f:
58
+ return f.read()
59
+
60
+
61
+ def extract_job_keys(yaml_text: str) -> List[str]:
62
+ """Extract top-level job ids from a GitHub Actions workflow (stdlib regex only)."""
63
+ keys: List[str] = []
64
+ in_jobs = False
65
+ for line in yaml_text.splitlines():
66
+ if re.match(r"^jobs:\s*$", line):
67
+ in_jobs = True
68
+ continue
69
+ if not in_jobs:
70
+ continue
71
+ m = JOB_KEY_RE.match(line)
72
+ if m:
73
+ keys.append(m.group(1))
74
+ elif line and not line.startswith(" ") and not line.startswith("#"):
75
+ break
76
+ return keys
77
+
78
+
79
+ def scan_forbidden_patterns(yaml_text: str) -> List[str]:
80
+ hits: List[str] = []
81
+ for pattern in FORBIDDEN_SUBSTRINGS:
82
+ if pattern in yaml_text:
83
+ hits.append(pattern)
84
+ return hits
85
+
86
+
87
+ def check_template_ci(yaml_text: str) -> List[GuardViolation]:
88
+ violations: List[GuardViolation] = []
89
+ job_keys = extract_job_keys(yaml_text)
90
+ extra_jobs = set(job_keys) - ALLOWED_TEMPLATE_JOBS
91
+ if extra_jobs:
92
+ violations.append(
93
+ GuardViolation(
94
+ REASON_JOB_LEAK,
95
+ f"template ci.yml job keys {sorted(job_keys)!r} exceed allowed "
96
+ f"{sorted(ALLOWED_TEMPLATE_JOBS)!r}",
97
+ )
98
+ )
99
+ forbidden = scan_forbidden_patterns(yaml_text)
100
+ if forbidden:
101
+ violations.append(
102
+ GuardViolation(
103
+ REASON_FORBIDDEN_PATTERN,
104
+ f"template ci.yml contains forbidden pattern(s): {forbidden}",
105
+ )
106
+ )
107
+ return violations
108
+
109
+
110
+ def check_active_ci(yaml_text: str) -> List[GuardViolation]:
111
+ violations: List[GuardViolation] = []
112
+ job_keys = set(extract_job_keys(yaml_text))
113
+ missing = REQUIRED_ACTIVE_JOBS - job_keys
114
+ if missing:
115
+ violations.append(
116
+ GuardViolation(
117
+ REASON_PACKAGING_MISSING,
118
+ f"active ci.yml missing required job id(s): {sorted(missing)!r}; "
119
+ f"found {sorted(job_keys)!r}",
120
+ )
121
+ )
122
+ return violations
123
+
124
+
125
+ def build_report(repo_root: str) -> Tuple[GuardReport, List[str]]:
126
+ report = GuardReport()
127
+ stderr_lines: List[str] = []
128
+
129
+ template_ci = os.path.join(
130
+ repo_root, "template", ".github", "workflows", "ci.yml"
131
+ )
132
+ active_ci = os.path.join(repo_root, ".github", "workflows", "ci.yml")
133
+
134
+ if not os.path.isfile(template_ci):
135
+ stderr_lines.append(f"missing template ci.yml: {template_ci}")
136
+ report.ok = False
137
+ return report, stderr_lines
138
+ if not os.path.isfile(active_ci):
139
+ stderr_lines.append(f"missing active ci.yml: {active_ci}")
140
+ report.ok = False
141
+ return report, stderr_lines
142
+
143
+ template_text = read_utf8(template_ci)
144
+ active_text = read_utf8(active_ci)
145
+
146
+ report.template_job_keys = extract_job_keys(template_text)
147
+ report.active_job_keys = extract_job_keys(active_text)
148
+ report.forbidden_hits = scan_forbidden_patterns(template_text)
149
+
150
+ for v in check_template_ci(template_text):
151
+ report.violations.append(v)
152
+ stderr_lines.append(f"{v.reason_code}: {v.detail}")
153
+ for v in check_active_ci(active_text):
154
+ report.violations.append(v)
155
+ stderr_lines.append(f"{v.reason_code}: {v.detail}")
156
+
157
+ report.ok = len(report.violations) == 0
158
+ return report, stderr_lines
159
+
160
+
161
+ def report_to_dict(report: GuardReport) -> Dict[str, Any]:
162
+ return {
163
+ "schema_version": REPORT_SCHEMA_VERSION,
164
+ "template_job_keys": report.template_job_keys,
165
+ "active_job_keys": report.active_job_keys,
166
+ "forbidden_hits": report.forbidden_hits,
167
+ "violations": [
168
+ {"reason_code": v.reason_code, "detail": v.detail}
169
+ for v in report.violations
170
+ ],
171
+ "ok": report.ok,
172
+ }
173
+
174
+
175
+ def self_test() -> None:
176
+ sample_template = """name: ci
177
+ jobs:
178
+ checks:
179
+ runs-on: ubuntu-latest
180
+ auto-fix:
181
+ runs-on: ubuntu-latest
182
+ """
183
+ keys = extract_job_keys(sample_template)
184
+ assert keys == ["checks", "auto-fix"], keys
185
+
186
+ bad_template = """name: ci
187
+ jobs:
188
+ checks:
189
+ runs-on: ubuntu-latest
190
+ npm-test:
191
+ runs-on: ubuntu-latest
192
+ steps:
193
+ - run: npm pack
194
+ """
195
+ v = check_template_ci(bad_template)
196
+ assert any(x.reason_code == REASON_JOB_LEAK for x in v)
197
+ assert any(x.reason_code == REASON_FORBIDDEN_PATTERN for x in v)
198
+
199
+ good_active = """name: ci
200
+ jobs:
201
+ checks:
202
+ runs-on: ubuntu-latest
203
+ auto-fix:
204
+ runs-on: ubuntu-latest
205
+ npm-test:
206
+ runs-on: ubuntu-latest
207
+ brew-test:
208
+ runs-on: ubuntu-latest
209
+ choco-test:
210
+ runs-on: ubuntu-latest
211
+ """
212
+ assert not check_active_ci(good_active)
213
+
214
+ bad_active = """name: ci
215
+ jobs:
216
+ checks:
217
+ runs-on: ubuntu-latest
218
+ auto-fix:
219
+ runs-on: ubuntu-latest
220
+ """
221
+ v2 = check_active_ci(bad_active)
222
+ assert len(v2) == 1 and v2[0].reason_code == REASON_PACKAGING_MISSING