@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.
Files changed (67) hide show
  1. package/README.md +61 -47
  2. package/package.json +3 -4
  3. package/src/cli.mjs +27 -176
  4. package/src/detect.mjs +24 -94
  5. package/src/doctor.mjs +54 -559
  6. package/src/init.mjs +92 -535
  7. package/templates/agents/address-review.md +53 -0
  8. package/templates/agents/architect.md +50 -0
  9. package/templates/agents/builder.md +58 -0
  10. package/templates/agents/ci-doctor.md +55 -0
  11. package/templates/agents/pr-reviewer.md +52 -0
  12. package/templates/agents/security-audit.md +54 -0
  13. package/modules/README.md +0 -35
  14. package/modules/ai-queryability/agents/queryability-reviewer.md +0 -35
  15. package/modules/ai-queryability/module.json +0 -9
  16. package/modules/ai-queryability/standard-section.md +0 -22
  17. package/modules/analytics/agents/analytics-reviewer.md +0 -32
  18. package/modules/analytics/commands/add-telemetry.md +0 -23
  19. package/modules/analytics/module.json +0 -10
  20. package/modules/analytics/standard-section.md +0 -23
  21. package/modules/database/agents/data-security-reviewer.md +0 -38
  22. package/modules/database/commands/new-migration.md +0 -24
  23. package/modules/database/guards/migration-versions.mjs +0 -41
  24. package/modules/database/guards/migrations-immutable.mjs +0 -57
  25. package/modules/database/hooks/protect-migrations.fragment.mjs +0 -10
  26. package/modules/database/module.json +0 -25
  27. package/modules/database/standard-section.md +0 -20
  28. package/modules/design-system/agents/design-reviewer.md +0 -37
  29. package/modules/design-system/module.json +0 -9
  30. package/modules/design-system/standard-section.md +0 -15
  31. package/src/add.mjs +0 -77
  32. package/src/platform-admin.mjs +0 -1552
  33. package/src/platform-config.mjs +0 -39
  34. package/src/platform.mjs +0 -1759
  35. package/src/render.mjs +0 -66
  36. package/templates/claude/settings.json +0 -71
  37. package/templates/delivery/verify.mjs +0 -157
  38. package/templates/doctor/resolve.mjs +0 -572
  39. package/templates/guards/README.md +0 -30
  40. package/templates/guards/_kit.mjs +0 -81
  41. package/templates/guards/actions-pinned.mjs +0 -38
  42. package/templates/guards/run.mjs +0 -111
  43. package/templates/guards/watchtower-locked.mjs +0 -66
  44. package/templates/prompts/address-review.md +0 -14
  45. package/templates/prompts/architect.md +0 -63
  46. package/templates/prompts/builder.md +0 -79
  47. package/templates/prompts/doctor.md +0 -69
  48. package/templates/prompts/review.md +0 -14
  49. package/templates/prompts/sweep.md +0 -75
  50. package/templates/receipts/collect.mjs +0 -297
  51. package/templates/review/finalize.mjs +0 -38
  52. package/templates/scripts/move-board-status.sh +0 -155
  53. package/templates/security/sync-findings.mjs +0 -226
  54. package/templates/standard/STANDARD.md +0 -141
  55. package/templates/standard/agents-block.md +0 -25
  56. package/templates/watchtower/budgets.json +0 -12
  57. package/templates/watchtower/canary.mjs +0 -216
  58. package/templates/watchtower/health.mjs +0 -148
  59. package/templates/watchtower/outcomes.mjs +0 -188
  60. package/templates/workflows/facility-address-review.yml +0 -154
  61. package/templates/workflows/facility-canary.yml +0 -61
  62. package/templates/workflows/facility-codex.yml +0 -327
  63. package/templates/workflows/facility-crew.yml +0 -351
  64. package/templates/workflows/facility-doctor.yml +0 -174
  65. package/templates/workflows/facility-review.yml +0 -135
  66. package/templates/workflows/facility-security-sweep.yml +0 -204
  67. 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
- }