@theagilemonkeys/facility 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -47
- package/package.json +3 -4
- package/src/cli.mjs +27 -176
- package/src/detect.mjs +24 -94
- package/src/doctor.mjs +54 -559
- package/src/init.mjs +92 -535
- package/templates/agents/address-review.md +53 -0
- package/templates/agents/architect.md +50 -0
- package/templates/agents/builder.md +58 -0
- package/templates/agents/ci-doctor.md +55 -0
- package/templates/agents/pr-reviewer.md +52 -0
- package/templates/agents/security-audit.md +54 -0
- package/modules/README.md +0 -35
- package/modules/ai-queryability/agents/queryability-reviewer.md +0 -35
- package/modules/ai-queryability/module.json +0 -9
- package/modules/ai-queryability/standard-section.md +0 -22
- package/modules/analytics/agents/analytics-reviewer.md +0 -32
- package/modules/analytics/commands/add-telemetry.md +0 -23
- package/modules/analytics/module.json +0 -10
- package/modules/analytics/standard-section.md +0 -23
- package/modules/database/agents/data-security-reviewer.md +0 -38
- package/modules/database/commands/new-migration.md +0 -24
- package/modules/database/guards/migration-versions.mjs +0 -41
- package/modules/database/guards/migrations-immutable.mjs +0 -57
- package/modules/database/hooks/protect-migrations.fragment.mjs +0 -10
- package/modules/database/module.json +0 -25
- package/modules/database/standard-section.md +0 -20
- package/modules/design-system/agents/design-reviewer.md +0 -37
- package/modules/design-system/module.json +0 -9
- package/modules/design-system/standard-section.md +0 -15
- package/src/add.mjs +0 -77
- package/src/platform-admin.mjs +0 -1552
- package/src/platform-config.mjs +0 -39
- package/src/platform.mjs +0 -1759
- package/src/render.mjs +0 -66
- package/templates/claude/settings.json +0 -71
- package/templates/delivery/verify.mjs +0 -157
- package/templates/doctor/resolve.mjs +0 -572
- package/templates/guards/README.md +0 -30
- package/templates/guards/_kit.mjs +0 -81
- package/templates/guards/actions-pinned.mjs +0 -38
- package/templates/guards/run.mjs +0 -111
- package/templates/guards/watchtower-locked.mjs +0 -66
- package/templates/prompts/address-review.md +0 -14
- package/templates/prompts/architect.md +0 -63
- package/templates/prompts/builder.md +0 -79
- package/templates/prompts/doctor.md +0 -69
- package/templates/prompts/review.md +0 -14
- package/templates/prompts/sweep.md +0 -75
- package/templates/receipts/collect.mjs +0 -297
- package/templates/review/finalize.mjs +0 -38
- package/templates/scripts/move-board-status.sh +0 -155
- package/templates/security/sync-findings.mjs +0 -226
- package/templates/standard/STANDARD.md +0 -141
- package/templates/standard/agents-block.md +0 -25
- package/templates/watchtower/budgets.json +0 -12
- package/templates/watchtower/canary.mjs +0 -216
- package/templates/watchtower/health.mjs +0 -148
- package/templates/watchtower/outcomes.mjs +0 -188
- package/templates/workflows/facility-address-review.yml +0 -154
- package/templates/workflows/facility-canary.yml +0 -61
- package/templates/workflows/facility-codex.yml +0 -327
- package/templates/workflows/facility-crew.yml +0 -351
- package/templates/workflows/facility-doctor.yml +0 -174
- package/templates/workflows/facility-review.yml +0 -135
- package/templates/workflows/facility-security-sweep.yml +0 -204
- package/templates/workflows/facility-watchtower.yml +0 -87
|
@@ -1,572 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Generated by facility — https://github.com/theam/facility
|
|
3
|
-
//
|
|
4
|
-
// Deterministic CI Doctor admission policy. The model only runs after this
|
|
5
|
-
// resolver has proved that the PR head is current, every check is terminal,
|
|
6
|
-
// the failure is low risk, and the bounded retry budget remains.
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
|
-
|
|
12
|
-
const MAX_REPAIR_ATTEMPTS = 2;
|
|
13
|
-
const MAX_BRANCH_REPAIR_ATTEMPTS = 3;
|
|
14
|
-
const FAILURE_CONCLUSIONS = new Set([
|
|
15
|
-
"action_required",
|
|
16
|
-
"cancelled",
|
|
17
|
-
"failure",
|
|
18
|
-
"stale",
|
|
19
|
-
"startup_failure",
|
|
20
|
-
"timed_out",
|
|
21
|
-
]);
|
|
22
|
-
const LOW_RISK_CATEGORIES = new Set(["build", "lint", "typecheck", "unit_test"]);
|
|
23
|
-
const CATEGORY_PRIORITY = [
|
|
24
|
-
"secret_scan",
|
|
25
|
-
"workflow_security",
|
|
26
|
-
"auth_access",
|
|
27
|
-
"dependency_supply_chain",
|
|
28
|
-
"verify_guard",
|
|
29
|
-
"unknown",
|
|
30
|
-
"e2e",
|
|
31
|
-
"flaky_infra",
|
|
32
|
-
"typecheck",
|
|
33
|
-
"lint",
|
|
34
|
-
"unit_test",
|
|
35
|
-
"build",
|
|
36
|
-
];
|
|
37
|
-
const SENSITIVE_PATHS = [
|
|
38
|
-
/^\.github\//,
|
|
39
|
-
/^\.claude\//,
|
|
40
|
-
/^\.agents\//,
|
|
41
|
-
/(^|\/)guards\//,
|
|
42
|
-
/(^|\/)scripts\/ci\//,
|
|
43
|
-
/(^|\/)\.env(?:\.|$)/,
|
|
44
|
-
/(^|\/)migrations?\//,
|
|
45
|
-
/(^|\/)(?:auth|authorization|rbac|access-control|permissions?|secrets?|crypto)(?:\/|[._-]|$)/i,
|
|
46
|
-
/(^|\/)middleware\.[cm]?[jt]sx?$/,
|
|
47
|
-
/(^|\/)(?:package(?:-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|pyproject\.toml|poetry\.lock|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum)$/,
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
const TOKEN_RE = /\b(?:gh[pousr]_|github_pat_|sk-|sb_)[A-Za-z0-9_=-]{8,}\b/g;
|
|
51
|
-
const URL_RE = /https?:\/\/\S+/g;
|
|
52
|
-
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi;
|
|
53
|
-
const SHA_RE = /\b[0-9a-f]{7,40}\b/gi;
|
|
54
|
-
const NUMBER_RE = /\b\d{2,}\b/g;
|
|
55
|
-
const ATTEMPT_RE =
|
|
56
|
-
/<!--\s*facility-doctor\s+attempt\s+fingerprint="([^"]+)"\s+head_sha="([0-9a-f]{40})"\s+outcome="([^"]+)"\s*-->/g;
|
|
57
|
-
|
|
58
|
-
export function sanitizeFailureSignal(value, maxLength = 1_200) {
|
|
59
|
-
return String(value ?? "")
|
|
60
|
-
.replace(TOKEN_RE, "[redacted-token]")
|
|
61
|
-
.replace(URL_RE, "[url]")
|
|
62
|
-
.replace(UUID_RE, "[uuid]")
|
|
63
|
-
.replace(SHA_RE, "[sha]")
|
|
64
|
-
.replace(NUMBER_RE, "[n]")
|
|
65
|
-
.slice(0, maxLength)
|
|
66
|
-
.trim();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function classifyFailure(check) {
|
|
70
|
-
const output = sanitizeFailureSignal(
|
|
71
|
-
`${check.output?.title ?? ""}\n${check.output?.summary ?? ""}`,
|
|
72
|
-
);
|
|
73
|
-
// Check names are workflow-owned evidence. Check output can contain text
|
|
74
|
-
// derived from the PR, so it affects only the fingerprint, never admission.
|
|
75
|
-
const haystack = String(check.name ?? "").toLowerCase();
|
|
76
|
-
let category = "unknown";
|
|
77
|
-
|
|
78
|
-
if (/\b(gitleaks|secret scan|secret-scanning|credential leak)\b/.test(haystack)) {
|
|
79
|
-
category = "secret_scan";
|
|
80
|
-
} else if (
|
|
81
|
-
/\b(codeql|security|trivy|scorecard|workflow security|github actions|pull_request_target)\b/.test(
|
|
82
|
-
haystack,
|
|
83
|
-
)
|
|
84
|
-
) {
|
|
85
|
-
category = "workflow_security";
|
|
86
|
-
} else if (
|
|
87
|
-
/\b(auth|authorization|rbac|rls|jwt|service role|security definer|migration)\b/.test(haystack)
|
|
88
|
-
) {
|
|
89
|
-
category = "auth_access";
|
|
90
|
-
} else if (
|
|
91
|
-
/\b(audit|dependabot|dependency|supply chain|lockfile|package lock)\b/.test(haystack)
|
|
92
|
-
) {
|
|
93
|
-
category = "dependency_supply_chain";
|
|
94
|
-
} else if (/\b(verify|guard|policy check|invariant)\b/.test(haystack)) {
|
|
95
|
-
category = "verify_guard";
|
|
96
|
-
} else if (/\b(playwright|e2e|browser|ui smoke|visual regression)\b/.test(haystack)) {
|
|
97
|
-
category = "e2e";
|
|
98
|
-
} else if (["cancelled", "stale", "startup_failure", "timed_out"].includes(check.conclusion)) {
|
|
99
|
-
category = "flaky_infra";
|
|
100
|
-
} else if (/\b(typecheck|tsc|typescript|type error)\b/.test(haystack)) {
|
|
101
|
-
category = "typecheck";
|
|
102
|
-
} else if (/\b(lint|eslint|biome)\b/.test(haystack)) {
|
|
103
|
-
category = "lint";
|
|
104
|
-
} else if (/\b(vitest|jest|unit test|test)\b/.test(haystack)) {
|
|
105
|
-
category = "unit_test";
|
|
106
|
-
} else if (/\b(build|compile)\b/.test(haystack)) {
|
|
107
|
-
category = "build";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const normalizedSignal = output
|
|
111
|
-
.split("\n")
|
|
112
|
-
.map((line) => line.trim())
|
|
113
|
-
.filter((line) => /error|failed|failure|exception|expected|received|cannot|timeout/i.test(line))
|
|
114
|
-
.slice(0, 3)
|
|
115
|
-
.join(" | ");
|
|
116
|
-
const fingerprint = createHash("sha256")
|
|
117
|
-
.update(
|
|
118
|
-
[
|
|
119
|
-
category,
|
|
120
|
-
normalize(check.name),
|
|
121
|
-
normalize(check.conclusion),
|
|
122
|
-
normalize(normalizedSignal),
|
|
123
|
-
].join("|"),
|
|
124
|
-
)
|
|
125
|
-
.digest("hex")
|
|
126
|
-
.slice(0, 16);
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
category,
|
|
130
|
-
check,
|
|
131
|
-
displayName: safeLabel(check.name),
|
|
132
|
-
fingerprint,
|
|
133
|
-
risk: LOW_RISK_CATEGORIES.has(category) ? "low" : "high",
|
|
134
|
-
verificationCommands: verificationCommands(category),
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function countAttempts(comments, fingerprint) {
|
|
139
|
-
let count = 0;
|
|
140
|
-
for (const comment of comments) {
|
|
141
|
-
ATTEMPT_RE.lastIndex = 0;
|
|
142
|
-
let match = ATTEMPT_RE.exec(comment.body ?? "");
|
|
143
|
-
while (match) {
|
|
144
|
-
if (match[1] === fingerprint && match[3] === "started") count += 1;
|
|
145
|
-
match = ATTEMPT_RE.exec(comment.body ?? "");
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return count;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function countBranchAttempts(comments) {
|
|
152
|
-
let count = 0;
|
|
153
|
-
for (const comment of comments) {
|
|
154
|
-
ATTEMPT_RE.lastIndex = 0;
|
|
155
|
-
let match = ATTEMPT_RE.exec(comment.body ?? "");
|
|
156
|
-
while (match) {
|
|
157
|
-
if (match[3] === "started") count += 1;
|
|
158
|
-
match = ATTEMPT_RE.exec(comment.body ?? "");
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return count;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function attemptState(comments, fingerprint, headSha) {
|
|
165
|
-
const state = { startedAtHead: false, triageSeen: false };
|
|
166
|
-
for (const comment of comments) {
|
|
167
|
-
ATTEMPT_RE.lastIndex = 0;
|
|
168
|
-
let match = ATTEMPT_RE.exec(comment.body ?? "");
|
|
169
|
-
while (match) {
|
|
170
|
-
if (match[1] === fingerprint && match[2] === headSha && match[3] === "started") {
|
|
171
|
-
state.startedAtHead = true;
|
|
172
|
-
}
|
|
173
|
-
if (match[1] === fingerprint && match[3] === "triage") state.triageSeen = true;
|
|
174
|
-
match = ATTEMPT_RE.exec(comment.body ?? "");
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return state;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function hasSensitiveFiles(files) {
|
|
181
|
-
return files.some((file) => SENSITIVE_PATHS.some((pattern) => pattern.test(file)));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function decideDoctorAction({
|
|
185
|
-
eventHeadSha,
|
|
186
|
-
pullRequest,
|
|
187
|
-
checks,
|
|
188
|
-
comments = [],
|
|
189
|
-
doctorRunIds = [],
|
|
190
|
-
allowedBotLogins = ["claude[bot]"],
|
|
191
|
-
}) {
|
|
192
|
-
if (!pullRequest) return none("no same-repository PR is associated with this run");
|
|
193
|
-
if (pullRequest.state !== "open") return none("PR is not open");
|
|
194
|
-
if (!eventHeadSha || pullRequest.head?.sha !== eventHeadSha) {
|
|
195
|
-
return none("workflow run is stale for the current PR head");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const relevantChecks = latestChecks(checks).filter(
|
|
199
|
-
(check) => !isDoctorCheck(check, doctorRunIds),
|
|
200
|
-
);
|
|
201
|
-
if (relevantChecks.length === 0) return none("no current-head checks were found");
|
|
202
|
-
if (relevantChecks.some((check) => check.status !== "completed")) {
|
|
203
|
-
return none("waiting for all non-doctor checks to reach a terminal state");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const failures = relevantChecks
|
|
207
|
-
.filter((check) => FAILURE_CONCLUSIONS.has(check.conclusion))
|
|
208
|
-
.map(classifyFailure)
|
|
209
|
-
.sort(compareFailureRisk);
|
|
210
|
-
if (failures.length === 0) return none("all terminal checks passed or were skipped");
|
|
211
|
-
|
|
212
|
-
const failure = failures[0];
|
|
213
|
-
const attempts = countAttempts(comments, failure.fingerprint);
|
|
214
|
-
const branchAttempts = countBranchAttempts(comments);
|
|
215
|
-
const markers = attemptState(comments, failure.fingerprint, eventHeadSha);
|
|
216
|
-
const base = {
|
|
217
|
-
attempts,
|
|
218
|
-
branchAttempts,
|
|
219
|
-
failure,
|
|
220
|
-
triageSeen: markers.triageSeen,
|
|
221
|
-
pullRequest,
|
|
222
|
-
};
|
|
223
|
-
const crossRepository =
|
|
224
|
-
!pullRequest.head?.repo?.full_name ||
|
|
225
|
-
pullRequest.head.repo.full_name !== pullRequest.base?.repo?.full_name;
|
|
226
|
-
if (crossRepository) {
|
|
227
|
-
return triageOnce(base, "fork or cross-repository PRs are never auto-repaired");
|
|
228
|
-
}
|
|
229
|
-
if (hasSensitiveFiles(pullRequest.changedFiles ?? [])) {
|
|
230
|
-
return triageOnce(base, "PR touches a privileged or sensitive boundary");
|
|
231
|
-
}
|
|
232
|
-
if (failure.risk === "high") {
|
|
233
|
-
return triageOnce(base, `failure category ${failure.category} requires human review`);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const authorLogin = String(pullRequest.user?.login ?? "");
|
|
237
|
-
const authorIsBot = pullRequest.user?.type === "Bot";
|
|
238
|
-
const authorIsCrewBot = authorIsBot && allowedBotLogins.includes(authorLogin);
|
|
239
|
-
if (!authorIsCrewBot) {
|
|
240
|
-
if (authorIsBot) {
|
|
241
|
-
return triageOnce(
|
|
242
|
-
base,
|
|
243
|
-
"bot author is not the configured Facility App; set FACILITY_BOT_LOGIN to its App slug",
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
if (pullRequest.draft) return none("human-authored draft is still work in progress");
|
|
247
|
-
return triageOnce(base, "human-authored PRs never receive uninvited commits");
|
|
248
|
-
}
|
|
249
|
-
if (markers.startedAtHead) {
|
|
250
|
-
return none(`repair already attempted at current head for ${failure.fingerprint}`);
|
|
251
|
-
}
|
|
252
|
-
if (attempts >= MAX_REPAIR_ATTEMPTS) {
|
|
253
|
-
return none(`repair attempt limit reached for fingerprint ${failure.fingerprint}`);
|
|
254
|
-
}
|
|
255
|
-
if (branchAttempts >= MAX_BRANCH_REPAIR_ATTEMPTS) {
|
|
256
|
-
return none(`repair attempt limit reached for this pull-request branch`);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
...base,
|
|
261
|
-
action: "repair",
|
|
262
|
-
reason: "low-risk failure on a current, same-repository, bot-authored PR",
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export async function resolveDoctor({
|
|
267
|
-
repository,
|
|
268
|
-
event,
|
|
269
|
-
gh,
|
|
270
|
-
currentDoctorRunId,
|
|
271
|
-
allowedBotLogin,
|
|
272
|
-
}) {
|
|
273
|
-
const run = event?.workflow_run;
|
|
274
|
-
if (run?.event !== "pull_request") return none("event is not a pull-request workflow run");
|
|
275
|
-
if (!/^[0-9a-f]{40}$/i.test(String(run.head_sha ?? ""))) {
|
|
276
|
-
return none("workflow run head SHA is malformed");
|
|
277
|
-
}
|
|
278
|
-
const prNumber =
|
|
279
|
-
run.pull_requests?.find((pr) => pr.number)?.number ??
|
|
280
|
-
(await associatedPr(gh, repository, run.head_sha));
|
|
281
|
-
if (!Number.isInteger(prNumber) || prNumber < 1) {
|
|
282
|
-
return none("no valid PR is associated with the workflow run");
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const pullRequest = JSON.parse(await gh(["api", `repos/${repository}/pulls/${prNumber}`]));
|
|
286
|
-
pullRequest.changedFiles = flattenPages(
|
|
287
|
-
JSON.parse(
|
|
288
|
-
await gh([
|
|
289
|
-
"api",
|
|
290
|
-
`repos/${repository}/pulls/${prNumber}/files?per_page=100`,
|
|
291
|
-
"--paginate",
|
|
292
|
-
"--slurp",
|
|
293
|
-
]),
|
|
294
|
-
),
|
|
295
|
-
).map((file) => file.filename);
|
|
296
|
-
|
|
297
|
-
const checkPages = asPages(
|
|
298
|
-
JSON.parse(
|
|
299
|
-
await gh([
|
|
300
|
-
"api",
|
|
301
|
-
`repos/${repository}/commits/${run.head_sha}/check-runs?per_page=100`,
|
|
302
|
-
"--paginate",
|
|
303
|
-
"--slurp",
|
|
304
|
-
]),
|
|
305
|
-
),
|
|
306
|
-
);
|
|
307
|
-
const checks = checkPages.flatMap((page) => page.check_runs ?? []);
|
|
308
|
-
const workflowRunPages = asPages(
|
|
309
|
-
JSON.parse(
|
|
310
|
-
await gh([
|
|
311
|
-
"api",
|
|
312
|
-
`repos/${repository}/actions/runs?head_sha=${run.head_sha}&per_page=100`,
|
|
313
|
-
"--paginate",
|
|
314
|
-
"--slurp",
|
|
315
|
-
]),
|
|
316
|
-
),
|
|
317
|
-
);
|
|
318
|
-
const doctorRunIds = workflowRunPages
|
|
319
|
-
.flatMap((page) => page.workflow_runs ?? [])
|
|
320
|
-
.filter((candidate) => isDoctorWorkflow(candidate.name))
|
|
321
|
-
.map((candidate) => String(candidate.id));
|
|
322
|
-
if (currentDoctorRunId) doctorRunIds.push(String(currentDoctorRunId));
|
|
323
|
-
const comments = flattenPages(
|
|
324
|
-
JSON.parse(
|
|
325
|
-
await gh([
|
|
326
|
-
"api",
|
|
327
|
-
`repos/${repository}/issues/${prNumber}/comments?per_page=100`,
|
|
328
|
-
"--paginate",
|
|
329
|
-
"--slurp",
|
|
330
|
-
]),
|
|
331
|
-
),
|
|
332
|
-
);
|
|
333
|
-
|
|
334
|
-
return decideDoctorAction({
|
|
335
|
-
eventHeadSha: run.head_sha,
|
|
336
|
-
pullRequest,
|
|
337
|
-
checks,
|
|
338
|
-
comments,
|
|
339
|
-
doctorRunIds,
|
|
340
|
-
allowedBotLogins: crewBotLogins(allowedBotLogin),
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function main() {
|
|
345
|
-
const outputPath = process.env.GITHUB_OUTPUT;
|
|
346
|
-
const output = (key, value) => {
|
|
347
|
-
if (outputPath) appendFileSync(outputPath, `${key}=${String(value).replaceAll("\n", " ")}\n`);
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
let decision;
|
|
351
|
-
try {
|
|
352
|
-
const repository = requiredEnv("GITHUB_REPOSITORY");
|
|
353
|
-
const event = JSON.parse(readFileSync(requiredEnv("GITHUB_EVENT_PATH"), "utf8"));
|
|
354
|
-
const gh = async (args) =>
|
|
355
|
-
execFileSync("gh", args, { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
|
356
|
-
decision = await resolveDoctor({
|
|
357
|
-
repository,
|
|
358
|
-
event,
|
|
359
|
-
gh,
|
|
360
|
-
currentDoctorRunId: process.env.GITHUB_RUN_ID,
|
|
361
|
-
allowedBotLogin: process.env.FACILITY_BOT_LOGIN,
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
if (decision.action === "triage") {
|
|
365
|
-
const body = renderTriageComment(decision);
|
|
366
|
-
await gh([
|
|
367
|
-
"api",
|
|
368
|
-
`repos/${repository}/issues/${decision.pullRequest.number}/comments`,
|
|
369
|
-
"-f",
|
|
370
|
-
`body=${body}`,
|
|
371
|
-
]);
|
|
372
|
-
} else if (decision.action === "repair") {
|
|
373
|
-
const startedComment = JSON.parse(
|
|
374
|
-
await gh([
|
|
375
|
-
"api",
|
|
376
|
-
`repos/${repository}/issues/${decision.pullRequest.number}/comments`,
|
|
377
|
-
"-f",
|
|
378
|
-
`body=${renderRepairStartedComment(decision)}`,
|
|
379
|
-
]),
|
|
380
|
-
);
|
|
381
|
-
if (!Number.isInteger(startedComment.id))
|
|
382
|
-
throw new Error("repair attempt comment was not created");
|
|
383
|
-
writeRepairContext(decision, startedComment.id);
|
|
384
|
-
output("pr_number", decision.pullRequest.number);
|
|
385
|
-
output("head_ref", decision.pullRequest.head.ref);
|
|
386
|
-
output("head_sha", decision.pullRequest.head.sha);
|
|
387
|
-
output("fingerprint", decision.failure.fingerprint);
|
|
388
|
-
}
|
|
389
|
-
} catch {
|
|
390
|
-
decision = none("resolver failed closed because GitHub evidence was invalid or unavailable");
|
|
391
|
-
process.exitCode = 1;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
console.log(`doctor: ${decision.action} — ${decision.reason}`);
|
|
395
|
-
output("action", decision.action);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function writeRepairContext(decision, commentId) {
|
|
399
|
-
const marker = attemptMarker(
|
|
400
|
-
decision.failure.fingerprint,
|
|
401
|
-
decision.pullRequest.head.sha,
|
|
402
|
-
"started",
|
|
403
|
-
);
|
|
404
|
-
mkdirSync(".facility-doctor", { recursive: true });
|
|
405
|
-
writeFileSync(
|
|
406
|
-
".facility-doctor/context.json",
|
|
407
|
-
`${JSON.stringify(
|
|
408
|
-
{
|
|
409
|
-
schema: "facility.doctor.context.v2",
|
|
410
|
-
failure: {
|
|
411
|
-
category: decision.failure.category,
|
|
412
|
-
check: decision.failure.displayName,
|
|
413
|
-
conclusion: decision.failure.check.conclusion,
|
|
414
|
-
verificationCommands: decision.failure.verificationCommands,
|
|
415
|
-
},
|
|
416
|
-
pr: {
|
|
417
|
-
number: decision.pullRequest.number,
|
|
418
|
-
headRef: decision.pullRequest.head.ref,
|
|
419
|
-
headSha: decision.pullRequest.head.sha,
|
|
420
|
-
baseRef: decision.pullRequest.base.ref,
|
|
421
|
-
draft: decision.pullRequest.draft === true,
|
|
422
|
-
},
|
|
423
|
-
attempt: decision.attempts + 1,
|
|
424
|
-
maxAttempts: MAX_REPAIR_ATTEMPTS,
|
|
425
|
-
commentId,
|
|
426
|
-
fingerprint: decision.failure.fingerprint,
|
|
427
|
-
marker,
|
|
428
|
-
},
|
|
429
|
-
null,
|
|
430
|
-
2,
|
|
431
|
-
)}\n`,
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function renderRepairStartedComment(decision) {
|
|
436
|
-
return [
|
|
437
|
-
"### Facility CI Doctor repair",
|
|
438
|
-
"",
|
|
439
|
-
`Starting bounded attempt ${decision.attempts + 1}/${MAX_REPAIR_ATTEMPTS} for \`${decision.failure.displayName}\` (${decision.failure.category.replaceAll("_", " ")}).`,
|
|
440
|
-
"The repair agent will update this comment with its verified result.",
|
|
441
|
-
attemptMarker(decision.failure.fingerprint, decision.pullRequest.head.sha, "started"),
|
|
442
|
-
].join("\n");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function renderTriageComment(decision) {
|
|
446
|
-
return [
|
|
447
|
-
"### Facility CI Doctor triage",
|
|
448
|
-
"",
|
|
449
|
-
`- Failing check: ${decision.failure.displayName}`,
|
|
450
|
-
`- Category: ${decision.failure.category.replaceAll("_", " ")}`,
|
|
451
|
-
`- Reason: ${decision.reason}.`,
|
|
452
|
-
"",
|
|
453
|
-
"A human must review this failure; no repair agent was started.",
|
|
454
|
-
attemptMarker(decision.failure.fingerprint, decision.pullRequest.head.sha, "triage"),
|
|
455
|
-
].join("\n");
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function triageOnce(base, reason) {
|
|
459
|
-
if (base.triageSeen) return none(`triage already posted for ${base.failure.fingerprint}`);
|
|
460
|
-
return triage(base, reason);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function triage(base, reason) {
|
|
464
|
-
return { ...base, action: "triage", reason };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function none(reason) {
|
|
468
|
-
return { action: "none", reason };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function compareFailureRisk(a, b) {
|
|
472
|
-
if (a.risk !== b.risk) return a.risk === "high" ? -1 : 1;
|
|
473
|
-
return CATEGORY_PRIORITY.indexOf(a.category) - CATEGORY_PRIORITY.indexOf(b.category);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function latestChecks(checks) {
|
|
477
|
-
const latest = new Map();
|
|
478
|
-
for (const check of [...checks].sort((a, b) => Number(b.id ?? 0) - Number(a.id ?? 0))) {
|
|
479
|
-
const key = `${check.app?.slug ?? "unknown"}:${check.name ?? "unknown"}`;
|
|
480
|
-
if (!latest.has(key)) latest.set(key, check);
|
|
481
|
-
}
|
|
482
|
-
return [...latest.values()];
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function isDoctorCheck(check, doctorRunIds) {
|
|
486
|
-
const name = String(check.name ?? "").toLowerCase();
|
|
487
|
-
const url = String(check.details_url ?? "");
|
|
488
|
-
return (
|
|
489
|
-
name.includes("facility-doctor") ||
|
|
490
|
-
name.includes("ci-doctor") ||
|
|
491
|
-
doctorRunIds.some((runId) => url.includes(`/actions/runs/${runId}/`))
|
|
492
|
-
);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function isDoctorWorkflow(name) {
|
|
496
|
-
const normalized = String(name ?? "").toLowerCase();
|
|
497
|
-
return normalized.includes("facility-doctor") || normalized.includes("ci-doctor");
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function crewBotLogins(configured) {
|
|
501
|
-
const logins = new Set(["claude[bot]"]);
|
|
502
|
-
for (const value of String(configured ?? "").split(",")) {
|
|
503
|
-
const login = value.trim();
|
|
504
|
-
if (!login) continue;
|
|
505
|
-
logins.add(login.endsWith("[bot]") ? login : `${login}[bot]`);
|
|
506
|
-
}
|
|
507
|
-
return [...logins];
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function verificationCommands(category) {
|
|
511
|
-
if (category === "lint") return ["lint"];
|
|
512
|
-
if (category === "typecheck") return ["typecheck"];
|
|
513
|
-
if (category === "unit_test") return ["test"];
|
|
514
|
-
if (category === "build") return ["typecheck", "build"];
|
|
515
|
-
return [];
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function normalize(value) {
|
|
519
|
-
return String(value ?? "")
|
|
520
|
-
.toLowerCase()
|
|
521
|
-
.replace(/[^a-z0-9._/-]+/g, " ")
|
|
522
|
-
.replace(/\s+/g, " ")
|
|
523
|
-
.trim();
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function safeLabel(value) {
|
|
527
|
-
return String(value ?? "unknown")
|
|
528
|
-
.replace(/[\r\n\t]+/g, " ")
|
|
529
|
-
.replaceAll("@", "@")
|
|
530
|
-
.replaceAll("<", "‹")
|
|
531
|
-
.replaceAll(">", "›")
|
|
532
|
-
.replaceAll("`", "'")
|
|
533
|
-
.replace(/\s+/g, " ")
|
|
534
|
-
.slice(0, 160)
|
|
535
|
-
.trim();
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function attemptMarker(fingerprint, headSha, outcome) {
|
|
539
|
-
return `<!-- facility-doctor attempt fingerprint="${fingerprint}" head_sha="${headSha}" outcome="${outcome}" -->`;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function asPages(value) {
|
|
543
|
-
return Array.isArray(value) ? value : [value];
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function flattenPages(value) {
|
|
547
|
-
return asPages(value).flatMap((page) => (Array.isArray(page) ? page : [page]));
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
async function associatedPr(gh, repository, headSha) {
|
|
551
|
-
if (!headSha) return undefined;
|
|
552
|
-
const pages = JSON.parse(
|
|
553
|
-
await gh([
|
|
554
|
-
"api",
|
|
555
|
-
`repos/${repository}/commits/${headSha}/pulls?per_page=100`,
|
|
556
|
-
"--paginate",
|
|
557
|
-
"--slurp",
|
|
558
|
-
"-H",
|
|
559
|
-
"Accept: application/vnd.github+json",
|
|
560
|
-
]),
|
|
561
|
-
);
|
|
562
|
-
return flattenPages(pages).find((pr) => pr.number)?.number;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function requiredEnv(name) {
|
|
566
|
-
const value = process.env[name];
|
|
567
|
-
if (!value) throw new Error(`${name} is required`);
|
|
568
|
-
return value;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
572
|
-
if (import.meta.url === invokedPath) await main();
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# Guards
|
|
2
|
-
|
|
3
|
-
Deterministic, fast invariant checks for this repository, run as **one**
|
|
4
|
-
umbrella status. Generated by [facility](https://github.com/theam/facility);
|
|
5
|
-
every file here is yours.
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
node guards/run.mjs # run all guards
|
|
9
|
-
node guards/run.mjs --only=<name> # run one guard (repeatable)
|
|
10
|
-
node guards/run.mjs --json # machine-readable (CI / agents)
|
|
11
|
-
node guards/run.mjs --list # list registered guards
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
The rule behind this directory, from `STANDARD.md`: **if a rule is repeatedly
|
|
15
|
-
missed, add a deterministic check instead of more prose.** Prose decays;
|
|
16
|
-
checks hold.
|
|
17
|
-
|
|
18
|
-
## Add a guard
|
|
19
|
-
|
|
20
|
-
1. Create `guards/<name>.mjs` exporting a default `{ name, description,
|
|
21
|
-
requires?, run() }`. Return a list of `{ file?, line?, message }`
|
|
22
|
-
violations (empty = pass). Keep it deterministic and read-only.
|
|
23
|
-
2. Give it an in-code `ALLOWLIST` (keyed, with a written reason per entry) for
|
|
24
|
-
justified exceptions — `applyAllowlist` from `_kit.mjs` flags stale entries
|
|
25
|
-
so the list cannot rot.
|
|
26
|
-
3. To wrap an existing CLI/DB check instead of porting its logic, use
|
|
27
|
-
`commandGuard(...)` from `_kit.mjs`.
|
|
28
|
-
4. Guards needing external state declare `requires: ["SOME_ENV_VAR"]` and are
|
|
29
|
-
**skipped** (not failed) when it is absent, so local runs stay fast while
|
|
30
|
-
CI runs everything.
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
// Generated by facility — https://github.com/theam/facility
|
|
2
|
-
//
|
|
3
|
-
// Small helpers for writing guards. Zero dependencies; everything is yours
|
|
4
|
-
// to read and change.
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
|
|
9
|
-
/** Recursively list files under `dir` matching any of `extensions`. */
|
|
10
|
-
export function listFiles(dir, extensions, ignore = ["node_modules", ".git"]) {
|
|
11
|
-
const out = [];
|
|
12
|
-
let entries;
|
|
13
|
-
try {
|
|
14
|
-
entries = readdirSync(dir);
|
|
15
|
-
} catch {
|
|
16
|
-
return out;
|
|
17
|
-
}
|
|
18
|
-
for (const entry of entries) {
|
|
19
|
-
if (ignore.includes(entry)) continue;
|
|
20
|
-
const path = join(dir, entry);
|
|
21
|
-
const stats = statSync(path);
|
|
22
|
-
if (stats.isDirectory()) out.push(...listFiles(path, extensions, ignore));
|
|
23
|
-
else if (extensions.some((ext) => entry.endsWith(ext))) out.push(path);
|
|
24
|
-
}
|
|
25
|
-
return out;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Read a file as UTF-8, returning "" when it does not exist. */
|
|
29
|
-
export function readText(path) {
|
|
30
|
-
try {
|
|
31
|
-
return readFileSync(path, "utf8");
|
|
32
|
-
} catch {
|
|
33
|
-
return "";
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Wrap an existing CLI/DB check as a guard instead of re-implementing its
|
|
39
|
-
* logic. The command's non-zero exit becomes a single violation carrying its
|
|
40
|
-
* output tail.
|
|
41
|
-
*/
|
|
42
|
-
export function commandGuard({ name, description, command, args = [], requires = [] }) {
|
|
43
|
-
return {
|
|
44
|
-
name,
|
|
45
|
-
description,
|
|
46
|
-
requires,
|
|
47
|
-
run() {
|
|
48
|
-
try {
|
|
49
|
-
execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
|
|
50
|
-
return [];
|
|
51
|
-
} catch (error) {
|
|
52
|
-
const tail = `${error.stdout ?? ""}${error.stderr ?? ""}`
|
|
53
|
-
.trim()
|
|
54
|
-
.split("\n")
|
|
55
|
-
.slice(-12)
|
|
56
|
-
.join("\n");
|
|
57
|
-
return [{ message: `\`${[command, ...args].join(" ")}\` failed:\n${tail}` }];
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Filter violations through a keyed allowlist. Every allowlist entry must
|
|
65
|
-
* carry a written reason; entries that no longer match anything are reported
|
|
66
|
-
* as stale so the list cannot rot.
|
|
67
|
-
*/
|
|
68
|
-
export function applyAllowlist(violations, allowlist) {
|
|
69
|
-
const used = new Set();
|
|
70
|
-
const kept = violations.filter((violation) => {
|
|
71
|
-
const hit = Object.keys(allowlist).find((key) => violation.key === key);
|
|
72
|
-
if (hit) used.add(hit);
|
|
73
|
-
return !hit;
|
|
74
|
-
});
|
|
75
|
-
for (const key of Object.keys(allowlist)) {
|
|
76
|
-
if (!used.has(key)) {
|
|
77
|
-
kept.push({ message: `stale allowlist entry "${key}" (${allowlist[key]}) — remove it` });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return kept;
|
|
81
|
-
}
|