@mcptoolshop/housekeeping 1.3.1 → 1.5.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.
@@ -184,14 +184,47 @@ export function auditSteps(doc, jobs) {
184
184
 
185
185
  const jobSteps = job => (job && typeof job === 'object' && Array.isArray(job.steps) ? job.steps : []);
186
186
 
187
- /** Job ids with a step that uses actions/deploy-pages, at any version. */
188
- export function pagesDeployJobs(jobEntries) {
187
+ /**
188
+ * Job ids with a step that uses actions/deploy-pages, at any version.
189
+ *
190
+ * In a private repository a job that skips itself there is left out: Pages on
191
+ * a private repo is a plan feature, and a deploy gated on
192
+ * `!github.event.repository.private` is how a workflow says "only when public".
193
+ * That job never reaches deploy-pages, so Pages being off refuses nothing.
194
+ */
195
+ export function pagesDeployJobs(jobEntries, { isPrivate = false } = {}) {
196
+ const byId = new Map(jobEntries);
189
197
  return jobEntries
190
198
  .filter(([, job]) => jobSteps(job).some(st =>
191
199
  typeof st?.uses === 'string' && /^actions\/deploy-pages(?:@|$)/i.test(st.uses.trim())))
200
+ .filter(([id]) => !(isPrivate && skipsWhenPrivate(byId, id)))
192
201
  .map(([id]) => id);
193
202
  }
194
203
 
204
+ // A term that is false exactly when the repository is private.
205
+ const PUBLIC_ONLY = /^(?:!\s*github\.event\.repository\.private|github\.event\.repository\.private\s*==\s*false|github\.event\.repository\.private\s*!=\s*true|github\.event\.repository\.visibility\s*==\s*'public')$/;
206
+
207
+ /**
208
+ * True when the job cannot run in a private repository: its own `if:` is a
209
+ * conjunction with a public-only term, or it has no `if:` of its own (so
210
+ * GitHub's implicit success() applies) and a job it needs cannot run. Any
211
+ * `||`, or anything else this cannot read, is false: unknown never excuses.
212
+ */
213
+ export function skipsWhenPrivate(byId, id, seen = new Set()) {
214
+ if (seen.has(id)) return false;
215
+ seen.add(id);
216
+ const job = byId.get(id);
217
+ if (!job || typeof job !== 'object') return false;
218
+ if (job.if !== undefined) {
219
+ if (typeof job.if !== 'string') return false;
220
+ const expr = job.if.trim().replace(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/, '$1');
221
+ if (expr.includes('||')) return false;
222
+ return expr.split('&&').some(t => PUBLIC_ONLY.test(t.trim().replace(/^\((.*)\)$/, '$1').trim()));
223
+ }
224
+ const needs = typeof job.needs === 'string' ? [job.needs] : (Array.isArray(job.needs) ? job.needs : []);
225
+ return needs.some(n => skipsWhenPrivate(byId, n, seen));
226
+ }
227
+
195
228
  /**
196
229
  * The environment a job names, or null when it names none or names it with an
197
230
  * expression. `environment:` is either a string or `{ name, url }`. A name built
@@ -453,3 +486,128 @@ export function resolveFailedStep(doc, jobName, stepName, stepNumber = null) {
453
486
  const st = steps[index];
454
487
  return { job: jobId, step: typeof st.name === 'string' && st.name.trim() ? st.name : String(index), index, phase };
455
488
  }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // A scheduled workflow against rules/github-actions.md, "Scheduled workflows"
492
+ // (the org rule was amended 2026-09-08).
493
+ //
494
+ // The rule permits a schedule in an org repo when ALL of these hold: it does
495
+ // something a push cannot; it runs weekly or slower (daily needs a stated
496
+ // reason); it is bounded (ubuntu-latest, an explicit timeout-minutes, a
497
+ // concurrency block); it opens a PR and never pushes to a protected branch; and
498
+ // it carries workflow_dispatch. The first is judgment and is not checked here.
499
+ // Everything below is read from the file alone, and every unknown reads as
500
+ // "not shown to fail": an expression we cannot resolve is not a violation.
501
+
502
+ const FIELD_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
503
+ const NAMES = {
504
+ 3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
505
+ 4: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'],
506
+ };
507
+
508
+ /** Number of values a cron field admits, or null when it cannot be read. */
509
+ function cronFieldSize(text, i) {
510
+ const [lo, hi] = FIELD_RANGES[i];
511
+ const values = new Set();
512
+ const num = s => {
513
+ const k = NAMES[i]?.indexOf(s.toLowerCase());
514
+ if (k != null && k >= 0) return i === 3 ? k + 1 : k;
515
+ return /^\d+$/.test(s) ? +s : NaN;
516
+ };
517
+ for (const part of text.split(',')) {
518
+ const [range, stepText] = part.split('/');
519
+ const step = stepText == null ? 1 : +stepText;
520
+ let a, b;
521
+ if (range === '*') [a, b] = [lo, hi];
522
+ else if (range.includes('-')) [a, b] = range.split('-').map(num);
523
+ else { a = num(range); b = stepText == null ? a : hi; }
524
+ if (![a, b, step].every(Number.isFinite) || step < 1) return null;
525
+ for (let v = a; v <= b; v += step) values.add(i === 4 ? v % 7 : v);
526
+ }
527
+ return values.size;
528
+ }
529
+
530
+ /**
531
+ * How many times a year a five-field cron fires, approximately. Day-of-month
532
+ * and day-of-week are ORed by cron when both are restricted, so both count.
533
+ */
534
+ export function cronFiresPerYear(expr) {
535
+ const f = String(expr ?? '').trim().split(/\s+/);
536
+ if (f.length !== 5) return null;
537
+ const n = f.map(cronFieldSize);
538
+ if (n.some(x => x == null)) return null;
539
+ const domAll = f[2] === '*', dowAll = f[4] === '*';
540
+ const daysPerYear = domAll && dowAll ? 365
541
+ : dowAll ? n[2] * 12
542
+ : domAll ? n[4] * 52
543
+ : n[2] * 12 + n[4] * 52;
544
+ return n[0] * n[1] * daysPerYear * (n[3] / 12);
545
+ }
546
+
547
+ const PUSH = /\bgit\s+push\b/;
548
+ const NEW_BRANCH = /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC])\s/;
549
+ const AUTO_COMMIT = /^(?:stefanzweifel\/git-auto-commit-action|EndBug\/add-and-commit|ad-m\/github-push-action)@/;
550
+
551
+ /** Does this job push to the branch it checked out (the default, on a cron)? */
552
+ function pushesCheckedOutBranch(job) {
553
+ const steps = Array.isArray(job?.steps) ? job.steps : [];
554
+ let branched = false;
555
+ for (const st of steps) {
556
+ if (!st || typeof st !== 'object') continue;
557
+ if (typeof st.uses === 'string' && AUTO_COMMIT.test(st.uses)) {
558
+ const w = st.with ?? {};
559
+ if (!w.branch && !w.create_branch && !w.new_branch) return true;
560
+ }
561
+ if (typeof st.run !== 'string') continue;
562
+ if (NEW_BRANCH.test(st.run)) branched = true;
563
+ if (PUSH.test(st.run) && !branched) return true;
564
+ }
565
+ return false;
566
+ }
567
+
568
+ /**
569
+ * The rule's mechanical conditions that a scheduled workflow fails, as short
570
+ * phrases. Empty: none shown to fail. null: the workflow has no schedule.
571
+ */
572
+ export function scheduleGaps(doc, jobEntries) {
573
+ const on = doc?.on ?? doc?.[true];
574
+ const schedule = on && typeof on === 'object' && !Array.isArray(on) ? on.schedule : null;
575
+ if (!Array.isArray(schedule)) return null;
576
+ const gaps = [];
577
+
578
+ const fires = schedule.map(s => cronFiresPerYear(s?.cron)).filter(x => x != null);
579
+ const perYear = fires.reduce((a, b) => a + b, 0);
580
+ if (perYear > 53) {
581
+ const days = 365 / perYear;
582
+ const every = Math.abs(days - 1) < 0.05 ? 'daily'
583
+ : days >= 1 ? `every ${+days.toFixed(1)} days` : `${+(perYear / 365).toFixed(1)} times a day`;
584
+ gaps.push(`runs more often than weekly (${every}, which needs a stated reason)`);
585
+ }
586
+
587
+ const offLinux = new Set();
588
+ const noTimeout = [], noConcurrency = [], pushers = [];
589
+ for (const [id, job] of jobEntries) {
590
+ if (!job || typeof job !== 'object') continue;
591
+ const reusable = typeof job.uses === 'string';
592
+ const ro = job['runs-on'];
593
+ const labels = (Array.isArray(ro) ? ro : [ro]).filter(r => typeof r === 'string');
594
+ const os = job.strategy?.matrix?.os;
595
+ for (const r of labels) {
596
+ if (r.includes('${{')) {
597
+ if (/matrix\.os/.test(r) && Array.isArray(os)) {
598
+ os.filter(o => typeof o === 'string' && !/^ubuntu-/.test(o)).forEach(o => offLinux.add(o));
599
+ }
600
+ } else if (!/^ubuntu-/.test(r)) offLinux.add(r);
601
+ }
602
+ // A job that calls a reusable workflow cannot carry timeout-minutes.
603
+ if (!reusable && job['timeout-minutes'] == null) noTimeout.push(id);
604
+ if (!job.concurrency) noConcurrency.push(id);
605
+ if (pushesCheckedOutBranch(job)) pushers.push(id);
606
+ }
607
+ if (offLinux.size) gaps.push(`runs off Linux (${[...offLinux].join(', ')})`);
608
+ if (noTimeout.length) gaps.push(`no timeout-minutes on ${noTimeout.join(', ')}`);
609
+ if (!doc.concurrency && noConcurrency.length) gaps.push('no concurrency block');
610
+ if (pushers.length) gaps.push(`pushes to the branch it checked out instead of opening a PR (${pushers.join(', ')})`);
611
+ if (!('workflow_dispatch' in on)) gaps.push('no workflow_dispatch');
612
+ return gaps;
613
+ }