@reddoorla/maintenance 0.1.0 → 0.1.2

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.
@@ -4,15 +4,18 @@ import { resolve as resolve2 } from "path";
4
4
  // src/audits/util/spawn.ts
5
5
  import { spawn } from "child_process";
6
6
  var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve3, reject) => {
7
+ const streaming = opts.streaming === true;
7
8
  const child = spawn(cmd, [...args], {
8
9
  cwd: opts.cwd,
9
10
  env: opts.env ?? process.env,
10
- stdio: ["ignore", "pipe", "pipe"]
11
+ stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"]
11
12
  });
12
13
  let stdout = "";
13
14
  let stderr = "";
14
- child.stdout.on("data", (chunk) => stdout += String(chunk));
15
- child.stderr.on("data", (chunk) => stderr += String(chunk));
15
+ if (!streaming) {
16
+ child.stdout?.on("data", (chunk) => stdout += String(chunk));
17
+ child.stderr?.on("data", (chunk) => stderr += String(chunk));
18
+ }
16
19
  const timer = opts.timeoutMs ? setTimeout(() => {
17
20
  child.kill("SIGTERM");
18
21
  reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
@@ -140,7 +143,7 @@ async function depsAudit(ctx) {
140
143
  // src/audits/lint.ts
141
144
  import { existsSync } from "fs";
142
145
  import { readFile as readFile2 } from "fs/promises";
143
- import { join as join2, relative } from "path";
146
+ import { join as join2 } from "path";
144
147
  import { ESLint } from "eslint";
145
148
  import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
146
149
  import { glob } from "tinyglobby";
@@ -169,19 +172,19 @@ async function lintAudit(ctx) {
169
172
  errorOnUnmatchedPattern: false
170
173
  });
171
174
  const relFiles = await listFiles(site.path);
172
- const filesToLint = relFiles.map((f) => join2(site.path, f));
173
- const eslintResults = await eslint.lintFiles(filesToLint);
175
+ const eslintResults = await eslint.lintFiles(relFiles);
174
176
  const eslintErrors = eslintResults.reduce((n, r) => n + r.errorCount, 0);
175
177
  const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
176
178
  const prettierUnformatted = [];
177
- for (const file of filesToLint) {
178
- const source = await readFile2(file, "utf-8");
179
- const options = await prettierResolveConfig(file) ?? {};
180
- const ok = await prettierCheck(source, { ...options, filepath: file });
181
- if (!ok) prettierUnformatted.push(relative(site.path, file));
179
+ for (const rel of relFiles) {
180
+ const absForResolve = join2(site.path, rel);
181
+ const source = await readFile2(absForResolve, "utf-8");
182
+ const options = await prettierResolveConfig(absForResolve) ?? {};
183
+ const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
184
+ if (!ok) prettierUnformatted.push(rel);
182
185
  }
183
186
  const status = eslintErrors > 0 || prettierUnformatted.length > 0 ? "fail" : eslintWarnings > 0 ? "warn" : "pass";
184
- const summary = status === "pass" ? `lint clean across ${filesToLint.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
187
+ const summary = status === "pass" ? `lint clean across ${relFiles.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
185
188
  return {
186
189
  audit: "lint",
187
190
  site: siteLabel2(site),
@@ -191,7 +194,7 @@ async function lintAudit(ctx) {
191
194
  eslintErrors,
192
195
  eslintWarnings,
193
196
  prettierUnformatted,
194
- files: filesToLint.length
197
+ files: relFiles.length
195
198
  }
196
199
  };
197
200
  }
@@ -205,6 +208,61 @@ function classify(v) {
205
208
  if (v.moderate > 0 || v.low > 0) return "warn";
206
209
  return "pass";
207
210
  }
211
+ function normalizeSeverity(s) {
212
+ if (s === "low" || s === "moderate" || s === "high" || s === "critical") return s;
213
+ return "low";
214
+ }
215
+ function extractAdvisoriesFromPnpm(parsed) {
216
+ const out = [];
217
+ for (const a of Object.values(parsed.advisories ?? {})) {
218
+ if (!a) continue;
219
+ out.push({
220
+ module: a.module_name ?? "unknown",
221
+ severity: normalizeSeverity(a.severity),
222
+ title: a.title ?? "(no title)",
223
+ ...a.cves ? { cves: a.cves } : {},
224
+ ...a.url ? { url: a.url } : {}
225
+ });
226
+ }
227
+ return out;
228
+ }
229
+ function resolveNpmAdvisoryRoot(startName, vulnerabilities) {
230
+ const seen = /* @__PURE__ */ new Set();
231
+ let current = startName;
232
+ while (!seen.has(current)) {
233
+ seen.add(current);
234
+ const entry = vulnerabilities[current];
235
+ if (!entry || !Array.isArray(entry.via)) return { rootName: current };
236
+ const detailed = entry.via.find(
237
+ (e) => typeof e === "object" && e !== null
238
+ );
239
+ if (detailed) return { rootName: current, detail: detailed };
240
+ const next = entry.via.find((e) => typeof e === "string");
241
+ if (!next || next === current) return { rootName: current };
242
+ current = next;
243
+ }
244
+ return { rootName: current };
245
+ }
246
+ function extractAdvisoriesFromNpm(parsed) {
247
+ const vulnerabilities = parsed.vulnerabilities ?? {};
248
+ const roots = /* @__PURE__ */ new Map();
249
+ for (const [name, v] of Object.entries(vulnerabilities)) {
250
+ if (!v) continue;
251
+ const { rootName, detail } = resolveNpmAdvisoryRoot(name, vulnerabilities);
252
+ if (roots.has(rootName)) continue;
253
+ const rootEntry = vulnerabilities[rootName];
254
+ const severity = normalizeSeverity(rootEntry?.severity ?? v.severity);
255
+ const title = detail?.title ?? rootName;
256
+ const url = detail?.url;
257
+ roots.set(rootName, {
258
+ module: rootEntry?.name ?? rootName,
259
+ severity,
260
+ title,
261
+ ...url ? { url } : {}
262
+ });
263
+ }
264
+ return [...roots.values()];
265
+ }
208
266
  async function tryRun(spawn2, cmd, args, cwd) {
209
267
  try {
210
268
  return await spawn2(cmd, args, { cwd });
@@ -258,26 +316,27 @@ async function securityAudit(ctx) {
258
316
  details: { error: String(err), stdout: raw.stdout.slice(0, 500) }
259
317
  };
260
318
  }
261
- const vuln = {
319
+ const counts = {
262
320
  low: parsed.metadata?.vulnerabilities?.low ?? 0,
263
321
  moderate: parsed.metadata?.vulnerabilities?.moderate ?? 0,
264
322
  high: parsed.metadata?.vulnerabilities?.high ?? 0,
265
323
  critical: parsed.metadata?.vulnerabilities?.critical ?? 0
266
324
  };
267
- const status = classify(vuln);
268
- const total = vuln.low + vuln.moderate + vuln.high + vuln.critical;
269
- const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${vuln.critical}C/${vuln.high}H/${vuln.moderate}M/${vuln.low}L)`;
325
+ const advisories = used === "pnpm audit" ? extractAdvisoriesFromPnpm(parsed) : extractAdvisoriesFromNpm(parsed);
326
+ const status = classify(counts);
327
+ const total = counts.low + counts.moderate + counts.high + counts.critical;
328
+ const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${counts.critical}C/${counts.high}H/${counts.moderate}M/${counts.low}L)`;
270
329
  return {
271
330
  audit: "security",
272
331
  site: label,
273
332
  status,
274
333
  summary,
275
- details: vuln
334
+ details: { counts, advisories }
276
335
  };
277
336
  }
278
337
 
279
338
  // src/audits/lighthouse.ts
280
- import { writeFile, mkdtemp, rm } from "fs/promises";
339
+ import { readFile as readFile3, writeFile, mkdtemp, rm } from "fs/promises";
281
340
  import { tmpdir } from "os";
282
341
  import { join as join3 } from "path";
283
342
 
@@ -313,29 +372,56 @@ var lighthouseConfig = {
313
372
  function siteLabel4(site) {
314
373
  return site.name ?? site.path;
315
374
  }
316
- function isFakeShape(stdout) {
375
+ async function readJsonMaybe(path) {
317
376
  try {
318
- const parsed = JSON.parse(stdout);
319
- if (typeof parsed.assertionsFailed === "number" && parsed.summary) return parsed;
377
+ const raw = await readFile3(path, "utf-8");
378
+ return JSON.parse(raw);
320
379
  } catch {
321
380
  return null;
322
381
  }
323
- return null;
382
+ }
383
+ function averageSummaries(entries) {
384
+ if (entries.length === 0) return {};
385
+ const sums = {};
386
+ const counts = {};
387
+ for (const e of entries) {
388
+ for (const [k, v] of Object.entries(e.summary ?? {})) {
389
+ if (typeof v !== "number") continue;
390
+ sums[k] = (sums[k] ?? 0) + v;
391
+ counts[k] = (counts[k] ?? 0) + 1;
392
+ }
393
+ }
394
+ const out = {};
395
+ for (const k of Object.keys(sums)) {
396
+ const total = sums[k] ?? 0;
397
+ const count = counts[k] ?? 1;
398
+ out[k] = total / count;
399
+ }
400
+ return out;
401
+ }
402
+ function categoryFromAssertion(a) {
403
+ const colonIdx = a.name.indexOf(":");
404
+ return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;
405
+ }
406
+ function messageForAssertion(a) {
407
+ return `${a.name} ${a.operator} ${a.expected} (actual: ${a.actual.toFixed(2)})`;
324
408
  }
325
409
  async function lighthouseAudit(ctx) {
326
410
  const spawn2 = ctx.spawn ?? defaultSpawn;
327
411
  const site = ctx.site;
328
412
  const label = siteLabel4(site);
329
- const dir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
330
- const configPath = join3(dir, "lighthouserc.json");
413
+ const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
414
+ const configPath = join3(configDir, "lighthouserc.json");
331
415
  await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
416
+ const resultsDir = join3(site.path, ".lighthouseci");
417
+ await rm(resultsDir, { recursive: true, force: true });
332
418
  let raw;
333
419
  try {
334
420
  raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
335
421
  cwd: site.path
336
422
  });
337
423
  } catch (err) {
338
- await rm(dir, { recursive: true, force: true });
424
+ await rm(configDir, { recursive: true, force: true });
339
425
  const e = err;
340
426
  if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
341
427
  return {
@@ -347,17 +433,32 @@ async function lighthouseAudit(ctx) {
347
433
  }
348
434
  throw err;
349
435
  }
350
- await rm(dir, { recursive: true, force: true });
351
- const fake = isFakeShape(raw.stdout);
352
- const normalized = fake ?? {
353
- summary: {},
354
- assertionsFailed: raw.code === 0 ? 0 : 1,
355
- assertions: raw.code === 0 ? [] : [{ category: "unknown", level: "error", message: raw.stderr.slice(0, 200) }]
356
- };
357
- const anyError = (normalized.assertions ?? []).some((a) => a.level === "error");
358
- const anyWarn = (normalized.assertions ?? []).some((a) => a.level === "warn");
436
+ await rm(configDir, { recursive: true, force: true });
437
+ const manifest = await readJsonMaybe(join3(resultsDir, "manifest.json"));
438
+ if (!manifest || manifest.length === 0) {
439
+ return {
440
+ audit: "lighthouse",
441
+ site: label,
442
+ status: "fail",
443
+ summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
444
+ };
445
+ }
446
+ const assertionResults = await readJsonMaybe(join3(resultsDir, "assertion-results.json")) ?? [];
447
+ const failed = assertionResults.filter((a) => !a.passed);
448
+ const assertions = failed.map((a) => ({
449
+ category: categoryFromAssertion(a),
450
+ level: a.level,
451
+ message: messageForAssertion(a)
452
+ }));
453
+ const anyError = assertions.some((a) => a.level === "error");
454
+ const anyWarn = assertions.some((a) => a.level === "warn");
359
455
  const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
360
- const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${normalized.assertionsFailed} assertion(s) failed`;
456
+ const normalized = {
457
+ summary: averageSummaries(manifest),
458
+ assertionsFailed: failed.length,
459
+ assertions
460
+ };
461
+ const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
361
462
  return {
362
463
  audit: "lighthouse",
363
464
  site: label,
@@ -368,7 +469,7 @@ async function lighthouseAudit(ctx) {
368
469
  }
369
470
 
370
471
  // src/audits/a11y.ts
371
- import { writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
472
+ import { readFile as readFile4, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
372
473
  import { tmpdir as tmpdir2 } from "os";
373
474
  import { join as join4 } from "path";
374
475
 
@@ -404,48 +505,81 @@ var playwrightA11yConfig = defineConfig({
404
505
  });
405
506
 
406
507
  // src/audits/a11y.ts
508
+ var RESULTS_REL = ".reddoor-a11y/results.json";
407
509
  function siteLabel5(site) {
408
510
  return site.name ?? site.path;
409
511
  }
410
- function isFakeShape2(stdout) {
512
+ async function readJsonMaybe2(path) {
411
513
  try {
412
- const parsed = JSON.parse(stdout);
413
- if (typeof parsed.totalViolations === "number" && parsed.byImpact) return parsed;
514
+ const raw = await readFile4(path, "utf-8");
515
+ return JSON.parse(raw);
414
516
  } catch {
415
517
  return null;
416
518
  }
417
- return null;
418
519
  }
419
520
  function buildSpec() {
420
- return `
421
- import { test, expect } from "@playwright/test";
521
+ return `import { test, expect } from "@playwright/test";
422
522
  import AxeBuilder from "@axe-core/playwright";
523
+ import { mkdir, writeFile } from "node:fs/promises";
524
+ import { dirname } from "node:path";
525
+
423
526
  const pages = ${JSON.stringify(a11yRoutes)};
424
- for (const { path, name } of pages) {
425
- test(\`\${name} has no axe violations\`, async ({ page }) => {
527
+ const OUTPUT = process.env.REDDOOR_A11Y_OUTPUT;
528
+
529
+ // Playwright's default per-test timeout is 30s. We loop through every
530
+ // configured route in a single test, so the budget needs to scale.
531
+ test.setTimeout(5 * 60_000);
532
+
533
+ test("a11y across configured routes", async ({ page }) => {
534
+ const violations = [];
535
+ for (const { path, name } of pages) {
426
536
  await page.goto(path);
427
537
  const results = await new AxeBuilder({ page })
428
538
  .withTags(["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"])
429
539
  .analyze();
430
- expect(results.violations).toEqual([]);
431
- });
432
- }
540
+ for (const v of results.violations) {
541
+ violations.push({
542
+ id: v.id,
543
+ impact: v.impact ?? "moderate",
544
+ route: name,
545
+ help: v.help,
546
+ helpUrl: v.helpUrl,
547
+ nodes: v.nodes.map((n) => ({ html: n.html, target: n.target })),
548
+ });
549
+ }
550
+ }
551
+ const byImpact = {};
552
+ for (const v of violations) {
553
+ byImpact[v.impact] = (byImpact[v.impact] ?? 0) + 1;
554
+ }
555
+ if (OUTPUT) {
556
+ await mkdir(dirname(OUTPUT), { recursive: true });
557
+ await writeFile(
558
+ OUTPUT,
559
+ JSON.stringify({ totalViolations: violations.length, byImpact, violations }, null, 2),
560
+ );
561
+ }
562
+ expect(violations).toEqual([]);
563
+ });
433
564
  `;
434
565
  }
435
566
  async function a11yAudit(ctx) {
436
567
  const spawn2 = ctx.spawn ?? defaultSpawn;
437
568
  const site = ctx.site;
438
569
  const label = siteLabel5(site);
439
- const dir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-"));
440
- const specPath = join4(dir, "a11y.spec.ts");
570
+ const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
571
+ const specPath = join4(specDir, "a11y.spec.ts");
441
572
  await writeFile2(specPath, buildSpec(), "utf-8");
573
+ const resultsPath = join4(site.path, RESULTS_REL);
574
+ await rm2(join4(site.path, ".reddoor-a11y"), { recursive: true, force: true });
442
575
  let raw;
443
576
  try {
444
- raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=json", specPath], {
445
- cwd: site.path
577
+ raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
578
+ cwd: site.path,
579
+ env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath }
446
580
  });
447
581
  } catch (err) {
448
- await rm2(dir, { recursive: true, force: true });
582
+ await rm2(specDir, { recursive: true, force: true });
449
583
  const e = err;
450
584
  if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
451
585
  return {
@@ -457,22 +591,26 @@ async function a11yAudit(ctx) {
457
591
  }
458
592
  throw err;
459
593
  }
460
- await rm2(dir, { recursive: true, force: true });
461
- const fake = isFakeShape2(raw.stdout);
462
- const normalized = fake ?? {
463
- totalViolations: raw.code === 0 ? 0 : 1,
464
- byImpact: raw.code === 0 ? {} : { moderate: 1 }
465
- };
466
- const hasSerious = (normalized.byImpact.serious ?? 0) > 0 || (normalized.byImpact.critical ?? 0) > 0;
467
- const hasAny = normalized.totalViolations > 0;
594
+ await rm2(specDir, { recursive: true, force: true });
595
+ const artifact = await readJsonMaybe2(resultsPath);
596
+ if (!artifact) {
597
+ return {
598
+ audit: "a11y",
599
+ site: label,
600
+ status: "fail",
601
+ summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
602
+ };
603
+ }
604
+ const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
605
+ const hasAny = artifact.totalViolations > 0;
468
606
  const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
469
- const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes` : `a11y: ${normalized.totalViolations} violations`;
607
+ const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes` : `a11y: ${artifact.totalViolations} violations`;
470
608
  return {
471
609
  audit: "a11y",
472
610
  site: label,
473
611
  status,
474
612
  summary,
475
- details: normalized
613
+ details: artifact
476
614
  };
477
615
  }
478
616
 
@@ -522,7 +660,8 @@ function localPath(path, opts = {}) {
522
660
  }
523
661
 
524
662
  // src/inventory/json.ts
525
- import { readFile as readFile3 } from "fs/promises";
663
+ import { readFile as readFile5 } from "fs/promises";
664
+ import { isAbsolute } from "path";
526
665
  function validate(raw) {
527
666
  if (!Array.isArray(raw)) {
528
667
  throw new Error("inventory JSON must be an array of sites");
@@ -535,6 +674,11 @@ function validate(raw) {
535
674
  if (typeof e.path !== "string" || e.path.length === 0) {
536
675
  throw new Error(`inventory entry ${i} is missing required field: path`);
537
676
  }
677
+ if (!isAbsolute(e.path)) {
678
+ throw new Error(
679
+ `inventory entry ${i}: path must be absolute (got "${e.path}"). Relative paths are rejected so cwd at invocation can't change which site is targeted.`
680
+ );
681
+ }
538
682
  const site = { path: e.path };
539
683
  if (typeof e.name === "string") site.name = e.name;
540
684
  if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
@@ -546,7 +690,7 @@ function validate(raw) {
546
690
  }
547
691
  function fromJsonFile(path) {
548
692
  return async () => {
549
- const raw = JSON.parse(await readFile3(path, "utf-8"));
693
+ const raw = JSON.parse(await readFile5(path, "utf-8"));
550
694
  return validate(raw);
551
695
  };
552
696
  }
@@ -585,11 +729,22 @@ async function resolveSites(input) {
585
729
 
586
730
  // src/cli/fleet/clone-if-needed.ts
587
731
  import { stat, readdir, mkdir } from "fs/promises";
588
- import { join as join5 } from "path";
732
+ import { isAbsolute as isAbsolute2, join as join5 } from "path";
589
733
  function deriveNameFromRepoUrl(repoUrl) {
590
734
  const slash = repoUrl.split("/").pop() ?? repoUrl;
591
735
  return slash.replace(/\.git$/, "");
592
736
  }
737
+ function assertSafeName(name) {
738
+ if (isAbsolute2(name)) {
739
+ throw new Error(`unsafe site name (absolute path not allowed): ${name}`);
740
+ }
741
+ if (name.includes("/") || name.includes("\\")) {
742
+ throw new Error(`unsafe site name (path separator not allowed): ${name}`);
743
+ }
744
+ if (name.split(/[\\/]/).some((seg) => seg === "..")) {
745
+ throw new Error(`unsafe site name (traversal segment not allowed): ${name}`);
746
+ }
747
+ }
593
748
  async function isNonEmptyDir(path) {
594
749
  try {
595
750
  const s = await stat(path);
@@ -606,6 +761,7 @@ async function cloneIfNeeded(site, opts) {
606
761
  throw new Error(`site path does not exist (${site.path}) and no repoUrl is set \u2014 cannot clone`);
607
762
  }
608
763
  const name = site.name ?? deriveNameFromRepoUrl(site.repoUrl);
764
+ assertSafeName(name);
609
765
  const target = join5(opts.workdir, name);
610
766
  await mkdir(opts.workdir, { recursive: true });
611
767
  if (await isNonEmptyDir(target)) {