@mcptoolshop/housekeeping 1.3.0 → 1.4.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/CHANGELOG.md +49 -0
- package/README.es.md +113 -70
- package/README.fr.md +145 -65
- package/README.hi.md +125 -65
- package/README.it.md +126 -65
- package/README.ja.md +111 -70
- package/README.md +89 -23
- package/README.pt-BR.md +144 -76
- package/README.zh.md +130 -66
- package/housekeeping.config.example.json +2 -1
- package/package.json +1 -1
- package/rules/atlas-map.md +6 -1
- package/rules/github-actions.md +7 -3
- package/src/analyze.mjs +201 -44
- package/src/cli.mjs +2 -2
- package/src/collect.mjs +5 -2
- package/src/config.mjs +13 -4
- package/src/load.mjs +7 -3
- package/src/mcp.mjs +2 -1
- package/src/report.mjs +14 -10
- package/src/schema.sql +9 -1
- package/src/workflow-risks.mjs +125 -0
package/src/workflow-risks.mjs
CHANGED
|
@@ -453,3 +453,128 @@ export function resolveFailedStep(doc, jobName, stepName, stepNumber = null) {
|
|
|
453
453
|
const st = steps[index];
|
|
454
454
|
return { job: jobId, step: typeof st.name === 'string' && st.name.trim() ? st.name : String(index), index, phase };
|
|
455
455
|
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// A scheduled workflow against rules/github-actions.md, "Scheduled workflows"
|
|
459
|
+
// (the org rule was amended 2026-09-08).
|
|
460
|
+
//
|
|
461
|
+
// The rule permits a schedule in an org repo when ALL of these hold: it does
|
|
462
|
+
// something a push cannot; it runs weekly or slower (daily needs a stated
|
|
463
|
+
// reason); it is bounded (ubuntu-latest, an explicit timeout-minutes, a
|
|
464
|
+
// concurrency block); it opens a PR and never pushes to a protected branch; and
|
|
465
|
+
// it carries workflow_dispatch. The first is judgment and is not checked here.
|
|
466
|
+
// Everything below is read from the file alone, and every unknown reads as
|
|
467
|
+
// "not shown to fail": an expression we cannot resolve is not a violation.
|
|
468
|
+
|
|
469
|
+
const FIELD_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
|
|
470
|
+
const NAMES = {
|
|
471
|
+
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
|
|
472
|
+
4: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'],
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Number of values a cron field admits, or null when it cannot be read. */
|
|
476
|
+
function cronFieldSize(text, i) {
|
|
477
|
+
const [lo, hi] = FIELD_RANGES[i];
|
|
478
|
+
const values = new Set();
|
|
479
|
+
const num = s => {
|
|
480
|
+
const k = NAMES[i]?.indexOf(s.toLowerCase());
|
|
481
|
+
if (k != null && k >= 0) return i === 3 ? k + 1 : k;
|
|
482
|
+
return /^\d+$/.test(s) ? +s : NaN;
|
|
483
|
+
};
|
|
484
|
+
for (const part of text.split(',')) {
|
|
485
|
+
const [range, stepText] = part.split('/');
|
|
486
|
+
const step = stepText == null ? 1 : +stepText;
|
|
487
|
+
let a, b;
|
|
488
|
+
if (range === '*') [a, b] = [lo, hi];
|
|
489
|
+
else if (range.includes('-')) [a, b] = range.split('-').map(num);
|
|
490
|
+
else { a = num(range); b = stepText == null ? a : hi; }
|
|
491
|
+
if (![a, b, step].every(Number.isFinite) || step < 1) return null;
|
|
492
|
+
for (let v = a; v <= b; v += step) values.add(i === 4 ? v % 7 : v);
|
|
493
|
+
}
|
|
494
|
+
return values.size;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* How many times a year a five-field cron fires, approximately. Day-of-month
|
|
499
|
+
* and day-of-week are ORed by cron when both are restricted, so both count.
|
|
500
|
+
*/
|
|
501
|
+
export function cronFiresPerYear(expr) {
|
|
502
|
+
const f = String(expr ?? '').trim().split(/\s+/);
|
|
503
|
+
if (f.length !== 5) return null;
|
|
504
|
+
const n = f.map(cronFieldSize);
|
|
505
|
+
if (n.some(x => x == null)) return null;
|
|
506
|
+
const domAll = f[2] === '*', dowAll = f[4] === '*';
|
|
507
|
+
const daysPerYear = domAll && dowAll ? 365
|
|
508
|
+
: dowAll ? n[2] * 12
|
|
509
|
+
: domAll ? n[4] * 52
|
|
510
|
+
: n[2] * 12 + n[4] * 52;
|
|
511
|
+
return n[0] * n[1] * daysPerYear * (n[3] / 12);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const PUSH = /\bgit\s+push\b/;
|
|
515
|
+
const NEW_BRANCH = /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC])\s/;
|
|
516
|
+
const AUTO_COMMIT = /^(?:stefanzweifel\/git-auto-commit-action|EndBug\/add-and-commit|ad-m\/github-push-action)@/;
|
|
517
|
+
|
|
518
|
+
/** Does this job push to the branch it checked out (the default, on a cron)? */
|
|
519
|
+
function pushesCheckedOutBranch(job) {
|
|
520
|
+
const steps = Array.isArray(job?.steps) ? job.steps : [];
|
|
521
|
+
let branched = false;
|
|
522
|
+
for (const st of steps) {
|
|
523
|
+
if (!st || typeof st !== 'object') continue;
|
|
524
|
+
if (typeof st.uses === 'string' && AUTO_COMMIT.test(st.uses)) {
|
|
525
|
+
const w = st.with ?? {};
|
|
526
|
+
if (!w.branch && !w.create_branch && !w.new_branch) return true;
|
|
527
|
+
}
|
|
528
|
+
if (typeof st.run !== 'string') continue;
|
|
529
|
+
if (NEW_BRANCH.test(st.run)) branched = true;
|
|
530
|
+
if (PUSH.test(st.run) && !branched) return true;
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The rule's mechanical conditions that a scheduled workflow fails, as short
|
|
537
|
+
* phrases. Empty: none shown to fail. null: the workflow has no schedule.
|
|
538
|
+
*/
|
|
539
|
+
export function scheduleGaps(doc, jobEntries) {
|
|
540
|
+
const on = doc?.on ?? doc?.[true];
|
|
541
|
+
const schedule = on && typeof on === 'object' && !Array.isArray(on) ? on.schedule : null;
|
|
542
|
+
if (!Array.isArray(schedule)) return null;
|
|
543
|
+
const gaps = [];
|
|
544
|
+
|
|
545
|
+
const fires = schedule.map(s => cronFiresPerYear(s?.cron)).filter(x => x != null);
|
|
546
|
+
const perYear = fires.reduce((a, b) => a + b, 0);
|
|
547
|
+
if (perYear > 53) {
|
|
548
|
+
const days = 365 / perYear;
|
|
549
|
+
const every = Math.abs(days - 1) < 0.05 ? 'daily'
|
|
550
|
+
: days >= 1 ? `every ${+days.toFixed(1)} days` : `${+(perYear / 365).toFixed(1)} times a day`;
|
|
551
|
+
gaps.push(`runs more often than weekly (${every}, which needs a stated reason)`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const offLinux = new Set();
|
|
555
|
+
const noTimeout = [], noConcurrency = [], pushers = [];
|
|
556
|
+
for (const [id, job] of jobEntries) {
|
|
557
|
+
if (!job || typeof job !== 'object') continue;
|
|
558
|
+
const reusable = typeof job.uses === 'string';
|
|
559
|
+
const ro = job['runs-on'];
|
|
560
|
+
const labels = (Array.isArray(ro) ? ro : [ro]).filter(r => typeof r === 'string');
|
|
561
|
+
const os = job.strategy?.matrix?.os;
|
|
562
|
+
for (const r of labels) {
|
|
563
|
+
if (r.includes('${{')) {
|
|
564
|
+
if (/matrix\.os/.test(r) && Array.isArray(os)) {
|
|
565
|
+
os.filter(o => typeof o === 'string' && !/^ubuntu-/.test(o)).forEach(o => offLinux.add(o));
|
|
566
|
+
}
|
|
567
|
+
} else if (!/^ubuntu-/.test(r)) offLinux.add(r);
|
|
568
|
+
}
|
|
569
|
+
// A job that calls a reusable workflow cannot carry timeout-minutes.
|
|
570
|
+
if (!reusable && job['timeout-minutes'] == null) noTimeout.push(id);
|
|
571
|
+
if (!job.concurrency) noConcurrency.push(id);
|
|
572
|
+
if (pushesCheckedOutBranch(job)) pushers.push(id);
|
|
573
|
+
}
|
|
574
|
+
if (offLinux.size) gaps.push(`runs off Linux (${[...offLinux].join(', ')})`);
|
|
575
|
+
if (noTimeout.length) gaps.push(`no timeout-minutes on ${noTimeout.join(', ')}`);
|
|
576
|
+
if (!doc.concurrency && noConcurrency.length) gaps.push('no concurrency block');
|
|
577
|
+
if (pushers.length) gaps.push(`pushes to the branch it checked out instead of opening a PR (${pushers.join(', ')})`);
|
|
578
|
+
if (!('workflow_dispatch' in on)) gaps.push('no workflow_dispatch');
|
|
579
|
+
return gaps;
|
|
580
|
+
}
|