patchwarden 0.6.4 → 1.1.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 (212) hide show
  1. package/README.en.md +18 -18
  2. package/README.md +20 -20
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +15 -2
  8. package/dist/doctor.js +32 -1
  9. package/dist/goal/acceptanceEngine.d.ts +62 -0
  10. package/dist/goal/acceptanceEngine.js +103 -0
  11. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  12. package/dist/goal/acceptanceTemplate.js +104 -0
  13. package/dist/goal/goalGraph.d.ts +58 -0
  14. package/dist/goal/goalGraph.js +189 -0
  15. package/dist/goal/goalProgress.d.ts +81 -0
  16. package/dist/goal/goalProgress.js +216 -0
  17. package/dist/goal/goalStatus.d.ts +60 -0
  18. package/dist/goal/goalStatus.js +137 -0
  19. package/dist/goal/goalStore.d.ts +79 -0
  20. package/dist/goal/goalStore.js +211 -0
  21. package/dist/goal/handoffExport.d.ts +34 -0
  22. package/dist/goal/handoffExport.js +183 -0
  23. package/dist/goal/subgoalSync.d.ts +40 -0
  24. package/dist/goal/subgoalSync.js +72 -0
  25. package/dist/goal/worktreeManager.d.ts +96 -0
  26. package/dist/goal/worktreeManager.js +292 -0
  27. package/dist/logging.d.ts +44 -0
  28. package/dist/logging.js +68 -0
  29. package/dist/release/releaseGate.d.ts +99 -0
  30. package/dist/release/releaseGate.js +475 -0
  31. package/dist/runner/postTaskCleanup.d.ts +13 -0
  32. package/dist/runner/postTaskCleanup.js +147 -0
  33. package/dist/runner/runTask.js +50 -10
  34. package/dist/security/discoveryTokenStore.d.ts +62 -0
  35. package/dist/security/discoveryTokenStore.js +100 -0
  36. package/dist/security/toolInvocationGuard.d.ts +35 -0
  37. package/dist/security/toolInvocationGuard.js +153 -0
  38. package/dist/smoke-test.js +18 -10
  39. package/dist/taskRuntime.d.ts +17 -0
  40. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  41. package/dist/test/unit/acceptance-engine.test.js +228 -0
  42. package/dist/test/unit/agent-router.test.d.ts +1 -0
  43. package/dist/test/unit/agent-router.test.js +287 -0
  44. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  45. package/dist/test/unit/audit-checks.test.js +350 -0
  46. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  47. package/dist/test/unit/diagnose-task.test.js +457 -0
  48. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  49. package/dist/test/unit/discovery-token-store.test.js +139 -0
  50. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  51. package/dist/test/unit/goal-graph.test.js +298 -0
  52. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  53. package/dist/test/unit/goal-progress.test.js +381 -0
  54. package/dist/test/unit/goal-status.test.d.ts +1 -0
  55. package/dist/test/unit/goal-status.test.js +215 -0
  56. package/dist/test/unit/goal-store.test.d.ts +1 -0
  57. package/dist/test/unit/goal-store.test.js +253 -0
  58. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  59. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  60. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  61. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  62. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  63. package/dist/test/unit/handoff-export.test.js +263 -0
  64. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  65. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  66. package/dist/test/unit/logging.test.js +127 -5
  67. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  68. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  69. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  70. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  71. package/dist/test/unit/release-gate.test.d.ts +1 -0
  72. package/dist/test/unit/release-gate.test.js +242 -0
  73. package/dist/test/unit/safe-views.test.d.ts +1 -0
  74. package/dist/test/unit/safe-views.test.js +171 -0
  75. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  76. package/dist/test/unit/schema-drift-check.test.js +175 -0
  77. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  78. package/dist/test/unit/subgoal-sync.test.js +183 -0
  79. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  80. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  81. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  82. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  83. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  84. package/dist/test/unit/toolSearch.test.js +571 -0
  85. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  86. package/dist/test/unit/worktree-manager.test.js +176 -0
  87. package/dist/tools/auditTask.d.ts +103 -1
  88. package/dist/tools/auditTask.js +461 -8
  89. package/dist/tools/cancelTask.js +1 -1
  90. package/dist/tools/checkReleaseGate.d.ts +21 -0
  91. package/dist/tools/checkReleaseGate.js +22 -0
  92. package/dist/tools/createTask.d.ts +18 -2
  93. package/dist/tools/createTask.js +29 -1
  94. package/dist/tools/diagnoseTask.d.ts +45 -0
  95. package/dist/tools/diagnoseTask.js +347 -0
  96. package/dist/tools/discardWorktree.d.ts +24 -0
  97. package/dist/tools/discardWorktree.js +27 -0
  98. package/dist/tools/discoverTools.d.ts +11 -0
  99. package/dist/tools/discoverTools.js +39 -0
  100. package/dist/tools/explainTool.d.ts +11 -0
  101. package/dist/tools/explainTool.js +21 -0
  102. package/dist/tools/getTaskSummary.js +2 -1
  103. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  104. package/dist/tools/goalSubgoalTask.js +110 -0
  105. package/dist/tools/healthCheck.d.ts +1 -0
  106. package/dist/tools/healthCheck.js +5 -1
  107. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  108. package/dist/tools/invokeDiscoveredTool.js +112 -0
  109. package/dist/tools/listTasks.d.ts +3 -1
  110. package/dist/tools/listTasks.js +14 -1
  111. package/dist/tools/mergeWorktree.d.ts +24 -0
  112. package/dist/tools/mergeWorktree.js +27 -0
  113. package/dist/tools/reconcileTasks.d.ts +41 -0
  114. package/dist/tools/reconcileTasks.js +352 -0
  115. package/dist/tools/registry.js +550 -0
  116. package/dist/tools/safeStatus.d.ts +31 -1
  117. package/dist/tools/safeStatus.js +43 -1
  118. package/dist/tools/safeViews.d.ts +256 -0
  119. package/dist/tools/safeViews.js +250 -0
  120. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  121. package/dist/tools/schemaDriftCheck.js +80 -0
  122. package/dist/tools/toolCatalog.d.ts +4 -3
  123. package/dist/tools/toolCatalog.js +33 -11
  124. package/dist/tools/toolRegistry.d.ts +61 -0
  125. package/dist/tools/toolRegistry.js +724 -0
  126. package/dist/tools/toolSearch.d.ts +110 -0
  127. package/dist/tools/toolSearch.js +331 -0
  128. package/dist/tools/toolUsageStats.d.ts +41 -0
  129. package/dist/tools/toolUsageStats.js +116 -0
  130. package/dist/tools/waitForTask.js +1 -0
  131. package/dist/version.d.ts +2 -2
  132. package/dist/version.js +2 -2
  133. package/dist/watcherStatus.d.ts +29 -0
  134. package/dist/watcherStatus.js +92 -1
  135. package/package.json +3 -2
  136. package/scripts/checks/control-smoke.js +2 -2
  137. package/scripts/checks/doctor-smoke.js +1 -0
  138. package/scripts/checks/lifecycle-smoke.js +13 -9
  139. package/scripts/checks/mcp-manifest-check.js +53 -6
  140. package/scripts/checks/mcp-smoke.js +36 -9
  141. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  142. package/src/agents/agentRouter.ts +149 -0
  143. package/src/config.ts +9 -3
  144. package/src/controlCenter.ts +17 -3
  145. package/src/doctor.ts +36 -1
  146. package/src/goal/acceptanceEngine.ts +160 -0
  147. package/src/goal/acceptanceTemplate.ts +121 -0
  148. package/src/goal/goalGraph.ts +225 -0
  149. package/src/goal/goalProgress.ts +301 -0
  150. package/src/goal/goalStatus.ts +234 -0
  151. package/src/goal/goalStore.ts +306 -0
  152. package/src/goal/handoffExport.ts +211 -0
  153. package/src/goal/subgoalSync.ts +82 -0
  154. package/src/goal/worktreeManager.ts +404 -0
  155. package/src/logging.ts +91 -0
  156. package/src/release/releaseGate.ts +567 -0
  157. package/src/runner/postTaskCleanup.ts +154 -0
  158. package/src/runner/runTask.ts +49 -10
  159. package/src/security/discoveryTokenStore.ts +157 -0
  160. package/src/security/toolInvocationGuard.ts +251 -0
  161. package/src/smoke-test.ts +15 -7
  162. package/src/taskRuntime.ts +17 -0
  163. package/src/test/unit/acceptance-engine.test.ts +261 -0
  164. package/src/test/unit/agent-router.test.ts +342 -0
  165. package/src/test/unit/audit-checks.test.ts +567 -0
  166. package/src/test/unit/diagnose-task.test.ts +544 -0
  167. package/src/test/unit/discovery-token-store.test.ts +181 -0
  168. package/src/test/unit/goal-graph.test.ts +347 -0
  169. package/src/test/unit/goal-progress.test.ts +538 -0
  170. package/src/test/unit/goal-status.test.ts +270 -0
  171. package/src/test/unit/goal-store.test.ts +318 -0
  172. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  173. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  174. package/src/test/unit/handoff-export.test.ts +295 -0
  175. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  176. package/src/test/unit/logging.test.ts +177 -5
  177. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  178. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  179. package/src/test/unit/release-gate.test.ts +314 -0
  180. package/src/test/unit/safe-views.test.ts +184 -0
  181. package/src/test/unit/schema-drift-check.test.ts +258 -0
  182. package/src/test/unit/subgoal-sync.test.ts +236 -0
  183. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  184. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  185. package/src/test/unit/toolSearch.test.ts +756 -0
  186. package/src/test/unit/worktree-manager.test.ts +247 -0
  187. package/src/tools/auditTask.ts +831 -402
  188. package/src/tools/cancelTask.ts +1 -1
  189. package/src/tools/checkReleaseGate.ts +45 -0
  190. package/src/tools/createTask.ts +64 -6
  191. package/src/tools/diagnoseTask.ts +460 -0
  192. package/src/tools/discardWorktree.ts +42 -0
  193. package/src/tools/discoverTools.ts +51 -0
  194. package/src/tools/explainTool.ts +26 -0
  195. package/src/tools/getTaskSummary.ts +2 -1
  196. package/src/tools/goalSubgoalTask.ts +170 -0
  197. package/src/tools/healthCheck.ts +4 -1
  198. package/src/tools/invokeDiscoveredTool.ts +163 -0
  199. package/src/tools/listTasks.ts +16 -2
  200. package/src/tools/mergeWorktree.ts +42 -0
  201. package/src/tools/reconcileTasks.ts +464 -0
  202. package/src/tools/registry.ts +618 -1
  203. package/src/tools/safeStatus.ts +73 -2
  204. package/src/tools/safeViews.ts +271 -0
  205. package/src/tools/schemaDriftCheck.ts +120 -0
  206. package/src/tools/toolCatalog.ts +34 -11
  207. package/src/tools/toolRegistry.ts +786 -0
  208. package/src/tools/toolSearch.ts +464 -0
  209. package/src/tools/toolUsageStats.ts +151 -0
  210. package/src/tools/waitForTask.ts +1 -0
  211. package/src/version.ts +2 -2
  212. package/src/watcherStatus.ts +97 -1
@@ -0,0 +1,567 @@
1
+ /**
2
+ * PatchWarden Release Gate — v1.0.0
3
+ *
4
+ * Five-stage release verification gate. Stages run in order; any failed stage
5
+ * blocks all subsequent stages (they become "not_checked").
6
+ *
7
+ * 1. local_ready — npm run build + npm test + npm run doctor:ci
8
+ * 2. packed_ready — npm pack --dry-run + forbidden/required checks
9
+ * 3. published_verified — npm registry confirms version exists
10
+ * 4. github_release_verified— GitHub Releases confirms tag exists
11
+ * 5. ci_verified — GitHub Actions latest run is green
12
+ *
13
+ * Remote queries use node:https GET only (never child_process curl/git).
14
+ * Network errors resolve to "not_checked" so transient issues don't block.
15
+ * GITHUB_TOKEN is used as Bearer auth but never logged or returned.
16
+ */
17
+
18
+ import { execSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { readFileSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { get } from "node:https";
23
+
24
+ // ── Public types ───────────────────────────────────────────────────
25
+
26
+ export type ReleaseStage =
27
+ | "local_ready"
28
+ | "packed_ready"
29
+ | "published_verified"
30
+ | "github_release_verified"
31
+ | "ci_verified";
32
+
33
+ export type ReleaseStageStatus = "passed" | "failed" | "not_checked";
34
+
35
+ export interface ReleaseGateResult {
36
+ target_stage: ReleaseStage;
37
+ stages: Record<ReleaseStage, ReleaseStageStatus>;
38
+ blocked_reason?: string;
39
+ }
40
+
41
+ export interface StageCheckResult {
42
+ status: ReleaseStageStatus;
43
+ reason?: string;
44
+ }
45
+
46
+ export interface PackedCheckResult extends StageCheckResult {
47
+ manifestPath?: string;
48
+ }
49
+
50
+ export interface HttpResponse {
51
+ statusCode: number;
52
+ data: any;
53
+ }
54
+
55
+ export type HttpGetFn = (
56
+ url: string,
57
+ headers?: Record<string, string>,
58
+ timeoutMs?: number,
59
+ ) => Promise<HttpResponse>;
60
+
61
+ export interface ReleaseGateOptions {
62
+ packageName?: string;
63
+ version?: string;
64
+ githubRepo?: string;
65
+ branch?: string;
66
+ }
67
+
68
+ export interface ReleaseGateDeps {
69
+ checkLocalReady?: typeof checkLocalReady;
70
+ checkPackedReady?: typeof checkPackedReady;
71
+ checkPublishedVerified?: typeof checkPublishedVerified;
72
+ checkGitHubReleaseVerified?: typeof checkGitHubReleaseVerified;
73
+ checkCiVerified?: typeof checkCiVerified;
74
+ httpGet?: HttpGetFn;
75
+ }
76
+
77
+ // ── Constants ──────────────────────────────────────────────────────
78
+
79
+ const STAGE_ORDER: ReleaseStage[] = [
80
+ "local_ready",
81
+ "packed_ready",
82
+ "published_verified",
83
+ "github_release_verified",
84
+ "ci_verified",
85
+ ];
86
+
87
+ const LOCAL_TIMEOUT_MS = 300000;
88
+ const REMOTE_TIMEOUT_MS = 10000;
89
+ const USER_AGENT = "PatchWarden-ReleaseGate";
90
+
91
+ const PACK_FORBIDDEN_PATTERNS: RegExp[] = [
92
+ /(^|[\\/])\.env$/,
93
+ /(^|[\\/])\.patchwarden([\\/]|$)/,
94
+ /(^|[\\/])patchwarden\.config\.json$/,
95
+ /(^|[\\/])\.local([\\/]|$)/,
96
+ /(^|[\\/])node_modules([\\/]|$)/,
97
+ /\.dpapi$/i,
98
+ ];
99
+
100
+ const PACK_REQUIRED_FILES = ["PatchWarden.cmd", "package.json"];
101
+
102
+ // ── Helpers ────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Compute a sha256 digest of a token for safe logging.
106
+ * The raw token is never persisted; only this digest may appear in logs.
107
+ */
108
+ export function computeTokenDigest(token: string): string {
109
+ const hash = createHash("sha256").update(token, "utf-8").digest("hex");
110
+ return `sha256:${hash}`;
111
+ }
112
+
113
+ /**
114
+ * Perform an HTTPS GET and parse the JSON body.
115
+ * Network errors and timeouts throw — callers catch and convert to "not_checked".
116
+ */
117
+ export async function httpsGetJson(
118
+ url: string,
119
+ headers?: Record<string, string>,
120
+ timeoutMs?: number,
121
+ ): Promise<HttpResponse> {
122
+ const timeout = timeoutMs ?? REMOTE_TIMEOUT_MS;
123
+ return new Promise<HttpResponse>((resolve, reject) => {
124
+ const timer = setTimeout(() => {
125
+ req.destroy(new Error(`Request timed out after ${timeout}ms`));
126
+ }, timeout);
127
+
128
+ const req = get(
129
+ url,
130
+ {
131
+ headers: {
132
+ "User-Agent": USER_AGENT,
133
+ "Accept": "application/json",
134
+ ...(headers || {}),
135
+ },
136
+ },
137
+ (res) => {
138
+ const chunks: Buffer[] = [];
139
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
140
+ res.on("end", () => {
141
+ clearTimeout(timer);
142
+ const body = Buffer.concat(chunks).toString("utf-8");
143
+ let data: any = null;
144
+ if (body.length > 0) {
145
+ try {
146
+ data = JSON.parse(body);
147
+ } catch {
148
+ data = body;
149
+ }
150
+ }
151
+ resolve({ statusCode: res.statusCode ?? 0, data });
152
+ });
153
+ },
154
+ );
155
+ req.on("error", (err) => {
156
+ clearTimeout(timer);
157
+ reject(err);
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Build GitHub auth headers from GITHUB_TOKEN env var.
164
+ * Returns an empty object when no token is set.
165
+ */
166
+ function buildGithubHeaders(): Record<string, string> {
167
+ const headers: Record<string, string> = {};
168
+ const token = process.env.GITHUB_TOKEN;
169
+ if (token) {
170
+ headers["Authorization"] = `Bearer ${token}`;
171
+ }
172
+ return headers;
173
+ }
174
+
175
+ /**
176
+ * Classify a packaged file into a category for the artifact manifest.
177
+ */
178
+ function classifyFile(filePath: string): string {
179
+ const lower = filePath.toLowerCase();
180
+ if (lower.startsWith("dist/")) return "dist";
181
+ if (lower.startsWith("docs/")) return "docs";
182
+ if (lower.startsWith("examples/")) return "examples";
183
+ if (lower.startsWith("scripts/")) return "scripts";
184
+ if (lower.startsWith("src/")) return "src";
185
+ if (lower.startsWith("ui/")) return "ui";
186
+ if (lower.endsWith(".cmd")) return "control";
187
+ if (lower === "package.json" || lower === "package-lock.json" || lower === "tsconfig.json") {
188
+ return "config";
189
+ }
190
+ if (lower === "license") return "license";
191
+ if (lower === "readme.md" || lower === "readme.en.md") return "readme";
192
+ return "other";
193
+ }
194
+
195
+ // ── Stage checks ───────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Stage 1: local_ready
199
+ * Runs npm.cmd run build, npm.cmd test, npm.cmd run doctor:ci in repoPath.
200
+ * Returns "passed" only when all three exit with code 0.
201
+ */
202
+ export function checkLocalReady(repoPath: string): StageCheckResult {
203
+ const commands = ["npm.cmd run build", "npm.cmd test", "npm.cmd run doctor:ci"];
204
+ for (const cmdStr of commands) {
205
+ try {
206
+ execSync(cmdStr, {
207
+ cwd: repoPath,
208
+ encoding: "utf-8",
209
+ timeout: LOCAL_TIMEOUT_MS,
210
+ stdio: ["ignore", "pipe", "pipe"],
211
+ });
212
+ } catch (err) {
213
+ if (err && typeof err === "object" && "status" in err) {
214
+ const code = (err as { status: unknown }).status;
215
+ return {
216
+ status: "failed",
217
+ reason: `Command "${cmdStr}" exited with code ${code}`,
218
+ };
219
+ }
220
+ return {
221
+ status: "failed",
222
+ reason: `Command "${cmdStr}" failed: ${err instanceof Error ? err.message : String(err)}`,
223
+ };
224
+ }
225
+ }
226
+ return { status: "passed" };
227
+ }
228
+
229
+ /**
230
+ * Stage 2: packed_ready
231
+ * Runs npm.cmd pack --dry-run --json, verifies forbidden files are absent and
232
+ * required control files are present, then writes release-artifact-manifest.json
233
+ * (with sha256 + size + category for every packaged file).
234
+ */
235
+ export function checkPackedReady(repoPath: string): PackedCheckResult {
236
+ let packOutput: string;
237
+ try {
238
+ packOutput = execSync("npm.cmd pack --dry-run --json", {
239
+ cwd: repoPath,
240
+ encoding: "utf-8",
241
+ timeout: LOCAL_TIMEOUT_MS,
242
+ stdio: ["ignore", "pipe", "pipe"],
243
+ });
244
+ } catch (err) {
245
+ return {
246
+ status: "failed",
247
+ reason: `npm pack --dry-run failed: ${err instanceof Error ? err.message : String(err)}`,
248
+ };
249
+ }
250
+
251
+ let packData: unknown;
252
+ try {
253
+ packData = JSON.parse(packOutput);
254
+ } catch (err) {
255
+ return {
256
+ status: "failed",
257
+ reason: `npm pack output is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
258
+ };
259
+ }
260
+
261
+ if (!Array.isArray(packData) || packData.length === 0) {
262
+ return { status: "failed", reason: "npm pack returned empty result array" };
263
+ }
264
+
265
+ const filesEntry = packData[0] as { files?: Array<{ path: string; size?: number }>; name?: string };
266
+ const files = Array.isArray(filesEntry.files) ? filesEntry.files : [];
267
+ if (files.length === 0) {
268
+ return { status: "failed", reason: "npm pack reports zero files in artifact" };
269
+ }
270
+
271
+ // Check forbidden files
272
+ const forbiddenHits: string[] = [];
273
+ for (const file of files) {
274
+ const normalized = file.path.replace(/\\/g, "/");
275
+ if (PACK_FORBIDDEN_PATTERNS.some((re) => re.test(normalized))) {
276
+ forbiddenHits.push(normalized);
277
+ }
278
+ }
279
+
280
+ // Check required files
281
+ const filePaths = new Set(files.map((f) => f.path.replace(/\\/g, "/")));
282
+ const requiredMissing = PACK_REQUIRED_FILES.filter((r) => !filePaths.has(r));
283
+
284
+ // Build manifest with sha256 + size + category
285
+ const manifestFiles = files.map((file) => {
286
+ const normalized = file.path.replace(/\\/g, "/");
287
+ const fullPath = join(repoPath, normalized);
288
+ let sha256 = "sha256:unreadable";
289
+ let size = file.size ?? 0;
290
+ try {
291
+ const content = readFileSync(fullPath);
292
+ sha256 = `sha256:${createHash("sha256").update(content).digest("hex")}`;
293
+ if (!size) size = content.length;
294
+ } catch {
295
+ // keep defaults
296
+ }
297
+ return { path: normalized, size, sha256, category: classifyFile(normalized) };
298
+ });
299
+
300
+ const manifest = {
301
+ generated_at: new Date().toISOString(),
302
+ package_name: filesEntry.name ?? null,
303
+ file_count: files.length,
304
+ total_size: manifestFiles.reduce((sum, f) => sum + f.size, 0),
305
+ files: manifestFiles,
306
+ forbidden_violations: forbiddenHits,
307
+ required_missing: requiredMissing,
308
+ };
309
+
310
+ const manifestPath = join(repoPath, "release-artifact-manifest.json");
311
+ try {
312
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
313
+ } catch (err) {
314
+ return {
315
+ status: "failed",
316
+ reason: `Failed to write artifact manifest: ${err instanceof Error ? err.message : String(err)}`,
317
+ };
318
+ }
319
+
320
+ if (forbiddenHits.length > 0) {
321
+ return {
322
+ status: "failed",
323
+ reason: `Forbidden files in artifact: ${forbiddenHits.join(", ")}`,
324
+ manifestPath,
325
+ };
326
+ }
327
+ if (requiredMissing.length > 0) {
328
+ return {
329
+ status: "failed",
330
+ reason: `Required control files missing: ${requiredMissing.join(", ")}`,
331
+ manifestPath,
332
+ };
333
+ }
334
+
335
+ return { status: "passed", manifestPath };
336
+ }
337
+
338
+ /**
339
+ * Stage 3: published_verified
340
+ * Queries the npm registry and confirms the given version exists.
341
+ * Network errors resolve to "not_checked"; a 404 package or missing version is "failed".
342
+ */
343
+ export async function checkPublishedVerified(
344
+ packageName: string,
345
+ version: string,
346
+ httpGet?: HttpGetFn,
347
+ ): Promise<StageCheckResult> {
348
+ const fn = httpGet ?? httpsGetJson;
349
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
350
+ try {
351
+ const res = await fn(url, {}, REMOTE_TIMEOUT_MS);
352
+ if (res.statusCode >= 500) {
353
+ return { status: "not_checked", reason: `npm registry returned ${res.statusCode}` };
354
+ }
355
+ if (res.statusCode === 404) {
356
+ return { status: "failed", reason: `Package "${packageName}" not found on npm registry` };
357
+ }
358
+ const data = res.data;
359
+ if (!data || !data.versions || typeof data.versions !== "object") {
360
+ return { status: "failed", reason: "npm registry response missing versions object" };
361
+ }
362
+ if (Object.prototype.hasOwnProperty.call(data.versions, version)) {
363
+ return { status: "passed" };
364
+ }
365
+ return {
366
+ status: "failed",
367
+ reason: `Version "${version}" not found for package "${packageName}"`,
368
+ };
369
+ } catch (err) {
370
+ return {
371
+ status: "not_checked",
372
+ reason: `Network error: ${err instanceof Error ? err.message : String(err)}`,
373
+ };
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Stage 4: github_release_verified
379
+ * Queries GitHub Releases by tag. 200 = passed, 404 = failed, network error = not_checked.
380
+ * Uses GITHUB_TOKEN as Bearer auth when available; the token is never logged or returned.
381
+ */
382
+ export async function checkGitHubReleaseVerified(
383
+ repo: string,
384
+ tag: string,
385
+ httpGet?: HttpGetFn,
386
+ ): Promise<StageCheckResult> {
387
+ const fn = httpGet ?? httpsGetJson;
388
+ const url = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`;
389
+ const headers = buildGithubHeaders();
390
+ try {
391
+ const res = await fn(url, headers, REMOTE_TIMEOUT_MS);
392
+ if (res.statusCode === 200) {
393
+ return { status: "passed" };
394
+ }
395
+ if (res.statusCode === 404) {
396
+ return { status: "failed", reason: `GitHub release for tag "${tag}" not found` };
397
+ }
398
+ return { status: "not_checked", reason: `GitHub API returned ${res.statusCode}` };
399
+ } catch (err) {
400
+ return {
401
+ status: "not_checked",
402
+ reason: `Network error: ${err instanceof Error ? err.message : String(err)}`,
403
+ };
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Stage 5: ci_verified
409
+ * Queries GitHub Actions runs for a branch. conclusion "success" = passed,
410
+ * "failure" = failed, null/other = not_checked, network error = not_checked.
411
+ * Uses GITHUB_TOKEN as Bearer auth when available; the token is never logged or returned.
412
+ */
413
+ export async function checkCiVerified(
414
+ repo: string,
415
+ branch: string,
416
+ httpGet?: HttpGetFn,
417
+ ): Promise<StageCheckResult> {
418
+ const fn = httpGet ?? httpsGetJson;
419
+ const url = `https://api.github.com/repos/${repo}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=1`;
420
+ const headers = buildGithubHeaders();
421
+ try {
422
+ const res = await fn(url, headers, REMOTE_TIMEOUT_MS);
423
+ if (res.statusCode !== 200) {
424
+ return { status: "not_checked", reason: `GitHub API returned ${res.statusCode}` };
425
+ }
426
+ const data = res.data;
427
+ const runs = Array.isArray(data?.workflow_runs) ? data.workflow_runs : [];
428
+ if (runs.length === 0) {
429
+ return { status: "not_checked", reason: `No CI runs found for branch "${branch}"` };
430
+ }
431
+ const conclusion = runs[0]?.conclusion;
432
+ if (conclusion === "success") {
433
+ return { status: "passed" };
434
+ }
435
+ if (conclusion === "failure") {
436
+ return { status: "failed", reason: "Latest CI run concluded with failure" };
437
+ }
438
+ return {
439
+ status: "not_checked",
440
+ reason: `Latest CI run conclusion is "${conclusion}" (not success)`,
441
+ };
442
+ } catch (err) {
443
+ return {
444
+ status: "not_checked",
445
+ reason: `Network error: ${err instanceof Error ? err.message : String(err)}`,
446
+ };
447
+ }
448
+ }
449
+
450
+ // ── Orchestrator ───────────────────────────────────────────────────
451
+
452
+ /**
453
+ * Run release-gate stages up to and including targetStage, in order.
454
+ * If any stage before the target fails, all subsequent stages become "not_checked"
455
+ * and blocked_reason explains the first failure.
456
+ *
457
+ * The optional `deps` parameter allows injecting mock checkers (for tests).
458
+ */
459
+ export async function runReleaseGateCheck(
460
+ repoPath: string,
461
+ targetStage: ReleaseStage,
462
+ options: ReleaseGateOptions = {},
463
+ deps: ReleaseGateDeps = {},
464
+ ): Promise<ReleaseGateResult> {
465
+ const stages: Record<ReleaseStage, ReleaseStageStatus> = {
466
+ local_ready: "not_checked",
467
+ packed_ready: "not_checked",
468
+ published_verified: "not_checked",
469
+ github_release_verified: "not_checked",
470
+ ci_verified: "not_checked",
471
+ };
472
+
473
+ const targetIndex = STAGE_ORDER.indexOf(targetStage);
474
+ if (targetIndex < 0) {
475
+ return {
476
+ target_stage: targetStage,
477
+ stages,
478
+ blocked_reason: `Unknown target stage: ${targetStage}`,
479
+ };
480
+ }
481
+
482
+ const localReady = deps.checkLocalReady ?? checkLocalReady;
483
+ const packedReady = deps.checkPackedReady ?? checkPackedReady;
484
+ const publishedVerified = deps.checkPublishedVerified ?? checkPublishedVerified;
485
+ const githubReleaseVerified = deps.checkGitHubReleaseVerified ?? checkGitHubReleaseVerified;
486
+ const ciVerified = deps.checkCiVerified ?? checkCiVerified;
487
+ const httpGet = deps.httpGet;
488
+
489
+ let blocked = false;
490
+ let blockedReason: string | undefined;
491
+
492
+ for (let i = 0; i <= targetIndex; i++) {
493
+ const stage = STAGE_ORDER[i];
494
+ if (blocked) {
495
+ stages[stage] = "not_checked";
496
+ continue;
497
+ }
498
+
499
+ let result: StageCheckResult;
500
+ try {
501
+ switch (stage) {
502
+ case "local_ready":
503
+ result = localReady(repoPath);
504
+ break;
505
+ case "packed_ready":
506
+ result = packedReady(repoPath);
507
+ break;
508
+ case "published_verified": {
509
+ if (!options.packageName || !options.version) {
510
+ result = {
511
+ status: "failed",
512
+ reason: "package_name and version are required for published_verified stage",
513
+ };
514
+ } else {
515
+ result = await publishedVerified(options.packageName, options.version, httpGet);
516
+ }
517
+ break;
518
+ }
519
+ case "github_release_verified": {
520
+ if (!options.githubRepo || !options.version) {
521
+ result = {
522
+ status: "failed",
523
+ reason: "github_repo and version are required for github_release_verified stage",
524
+ };
525
+ } else {
526
+ const tag = `v${options.version}`;
527
+ result = await githubReleaseVerified(options.githubRepo, tag, httpGet);
528
+ }
529
+ break;
530
+ }
531
+ case "ci_verified": {
532
+ if (!options.githubRepo || !options.branch) {
533
+ result = {
534
+ status: "failed",
535
+ reason: "github_repo and branch are required for ci_verified stage",
536
+ };
537
+ } else {
538
+ result = await ciVerified(options.githubRepo, options.branch, httpGet);
539
+ }
540
+ break;
541
+ }
542
+ default:
543
+ result = { status: "not_checked", reason: `Unknown stage: ${stage}` };
544
+ }
545
+ } catch (err) {
546
+ result = {
547
+ status: "failed",
548
+ reason: `Stage "${stage}" threw: ${err instanceof Error ? err.message : String(err)}`,
549
+ };
550
+ }
551
+
552
+ stages[stage] = result.status;
553
+ if (result.status === "failed") {
554
+ blocked = true;
555
+ blockedReason = `Stage "${stage}" failed: ${result.reason || "no reason provided"}`;
556
+ }
557
+ }
558
+
559
+ const gateResult: ReleaseGateResult = {
560
+ target_stage: targetStage,
561
+ stages,
562
+ };
563
+ if (blockedReason) {
564
+ gateResult.blocked_reason = blockedReason;
565
+ }
566
+ return gateResult;
567
+ }