dungbeetle 0.1.1

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 (116) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +19 -0
  3. package/README.md +139 -0
  4. package/dist/api/capture.d.ts +24 -0
  5. package/dist/api/capture.js +61 -0
  6. package/dist/baselines.d.ts +7 -0
  7. package/dist/baselines.js +38 -0
  8. package/dist/brand.d.ts +2 -0
  9. package/dist/brand.js +9 -0
  10. package/dist/capture.d.ts +15 -0
  11. package/dist/capture.js +7 -0
  12. package/dist/captures/api.d.ts +2 -0
  13. package/dist/captures/api.js +114 -0
  14. package/dist/captures/check.d.ts +2 -0
  15. package/dist/captures/check.js +116 -0
  16. package/dist/captures/desktop.d.ts +2 -0
  17. package/dist/captures/desktop.js +97 -0
  18. package/dist/captures/game.d.ts +4 -0
  19. package/dist/captures/game.js +266 -0
  20. package/dist/captures/performance.d.ts +2 -0
  21. package/dist/captures/performance.js +47 -0
  22. package/dist/captures/registry.d.ts +4 -0
  23. package/dist/captures/registry.js +23 -0
  24. package/dist/captures/terminal.d.ts +2 -0
  25. package/dist/captures/terminal.js +65 -0
  26. package/dist/captures/types.d.ts +18 -0
  27. package/dist/captures/types.js +1 -0
  28. package/dist/captures/web.d.ts +3 -0
  29. package/dist/captures/web.js +248 -0
  30. package/dist/check/capture.d.ts +15 -0
  31. package/dist/check/capture.js +76 -0
  32. package/dist/check/junit.d.ts +9 -0
  33. package/dist/check/junit.js +51 -0
  34. package/dist/check/laravel.d.ts +2 -0
  35. package/dist/check/laravel.js +44 -0
  36. package/dist/check/parsers.d.ts +12 -0
  37. package/dist/check/parsers.js +278 -0
  38. package/dist/check/schema.d.ts +2 -0
  39. package/dist/check/schema.js +114 -0
  40. package/dist/cloud.d.ts +42 -0
  41. package/dist/cloud.js +334 -0
  42. package/dist/compare/shared.d.ts +42 -0
  43. package/dist/compare/shared.js +115 -0
  44. package/dist/compare.d.ts +3 -0
  45. package/dist/compare.js +33 -0
  46. package/dist/config.d.ts +146 -0
  47. package/dist/config.js +382 -0
  48. package/dist/desktop/a11y.d.ts +18 -0
  49. package/dist/desktop/a11y.js +74 -0
  50. package/dist/desktop/capture.d.ts +13 -0
  51. package/dist/desktop/capture.js +80 -0
  52. package/dist/desktop/macos.d.ts +8 -0
  53. package/dist/desktop/macos.js +98 -0
  54. package/dist/desktop/ocr.d.ts +17 -0
  55. package/dist/desktop/ocr.js +99 -0
  56. package/dist/diff/lcs.d.ts +5 -0
  57. package/dist/diff/lcs.js +42 -0
  58. package/dist/diff/numeric.d.ts +6 -0
  59. package/dist/diff/numeric.js +24 -0
  60. package/dist/diff/pixel.d.ts +23 -0
  61. package/dist/diff/pixel.js +97 -0
  62. package/dist/diff/structural.d.ts +11 -0
  63. package/dist/diff/structural.js +38 -0
  64. package/dist/diff/text.d.ts +7 -0
  65. package/dist/diff/text.js +64 -0
  66. package/dist/diff/tree.d.ts +46 -0
  67. package/dist/diff/tree.js +188 -0
  68. package/dist/doctor.d.ts +18 -0
  69. package/dist/doctor.js +57 -0
  70. package/dist/game/capture.d.ts +24 -0
  71. package/dist/game/capture.js +51 -0
  72. package/dist/game/protocol.d.ts +30 -0
  73. package/dist/game/protocol.js +146 -0
  74. package/dist/game/walkthrough.d.ts +45 -0
  75. package/dist/game/walkthrough.js +85 -0
  76. package/dist/guards.d.ts +2 -0
  77. package/dist/guards.js +15 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.js +504 -0
  80. package/dist/json.d.ts +2 -0
  81. package/dist/json.js +40 -0
  82. package/dist/lifecycle.d.ts +14 -0
  83. package/dist/lifecycle.js +190 -0
  84. package/dist/normalization.d.ts +4 -0
  85. package/dist/normalization.js +27 -0
  86. package/dist/perf/ab.d.ts +6 -0
  87. package/dist/perf/ab.js +89 -0
  88. package/dist/perf/autocannon.d.ts +6 -0
  89. package/dist/perf/autocannon.js +101 -0
  90. package/dist/perf/capture.d.ts +7 -0
  91. package/dist/perf/capture.js +6 -0
  92. package/dist/perf/k6.d.ts +9 -0
  93. package/dist/perf/k6.js +44 -0
  94. package/dist/perf/parsers.d.ts +15 -0
  95. package/dist/perf/parsers.js +69 -0
  96. package/dist/perf/run.d.ts +8 -0
  97. package/dist/perf/run.js +45 -0
  98. package/dist/perf/toolOutput.d.ts +3 -0
  99. package/dist/perf/toolOutput.js +24 -0
  100. package/dist/reporters.d.ts +11 -0
  101. package/dist/reporters.js +314 -0
  102. package/dist/runner.d.ts +48 -0
  103. package/dist/runner.js +352 -0
  104. package/dist/snapshot.d.ts +48 -0
  105. package/dist/snapshot.js +37 -0
  106. package/dist/terminal/ansi.d.ts +21 -0
  107. package/dist/terminal/ansi.js +144 -0
  108. package/dist/terminal/capture.d.ts +30 -0
  109. package/dist/terminal/capture.js +91 -0
  110. package/dist/tty.d.ts +72 -0
  111. package/dist/tty.js +175 -0
  112. package/dist/web/domSnapshot.d.ts +27 -0
  113. package/dist/web/domSnapshot.js +55 -0
  114. package/dist/web/playwrightCapture.d.ts +16 -0
  115. package/dist/web/playwrightCapture.js +64 -0
  116. package/package.json +79 -0
@@ -0,0 +1,248 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { asNodes, coerceSnapshot, compareScreenshot, render } from "../compare/shared.js";
5
+ import { structuralChanges } from "../diff/structural.js";
6
+ import { diffDomTrees, renderTreeChanges } from "../diff/tree.js";
7
+ import { isRecord } from "../guards.js";
8
+ import { canonicalizeSnapshot } from "../snapshot.js";
9
+ import { createDomSnapshot } from "../web/domSnapshot.js";
10
+ import { capturePlaywrightWeb } from "../web/playwrightCapture.js";
11
+ // Web has its own sub-dispatch: a Playwright driver, a local HTML fixture, or a
12
+ // lightweight URL fetch — all of which normalize to a `web` snapshot.
13
+ async function captureWebTarget(target, { config, cwd }) {
14
+ if (target.screenshotFile) {
15
+ const filePath = path.resolve(cwd, target.screenshotFile);
16
+ const data = await readFile(filePath);
17
+ return {
18
+ kind: "web",
19
+ source: filePath,
20
+ capturedAt: "masked",
21
+ root: [],
22
+ driver: "file",
23
+ screenshot: { mimeType: "image/png", data: data.toString("base64") }
24
+ };
25
+ }
26
+ if (target.driver === "playwright") {
27
+ return capturePlaywrightWeb(target, {
28
+ maskRules: config.normalization.masks,
29
+ timeoutMs: target.timeoutMs ?? config.lifecycle.wait.timeoutMs
30
+ });
31
+ }
32
+ if (target.html) {
33
+ const htmlPath = path.resolve(cwd, target.html);
34
+ const html = await readFile(htmlPath, "utf8");
35
+ return createDomSnapshot(html, {
36
+ source: htmlPath,
37
+ maskRules: config.normalization.masks
38
+ });
39
+ }
40
+ if (target.url) {
41
+ const response = await fetch(target.url, {
42
+ signal: AbortSignal.timeout(target.timeoutMs ?? config.lifecycle.wait.timeoutMs)
43
+ });
44
+ if (!response.ok) {
45
+ throw new Error(`Web target "${target.name}" returned HTTP ${response.status} for ${target.url}.`);
46
+ }
47
+ return createDomSnapshot(await response.text(), {
48
+ source: target.url,
49
+ maskRules: config.normalization.masks
50
+ });
51
+ }
52
+ throw new Error(`Web target "${target.name}" must provide an html fixture path or url.`);
53
+ }
54
+ function compareWeb(baseline, candidate, options) {
55
+ const sections = [];
56
+ const webTarget = options.target?.kind === "web" ? options.target : undefined;
57
+ const tree = diffDomTrees(asNodes(baseline.root), asNodes(candidate.root));
58
+ if (!tree.equal) {
59
+ sections.push(renderTreeChanges(tree.changes));
60
+ }
61
+ if (baseline.driver !== candidate.driver) {
62
+ sections.push(`~ driver: ${String(baseline.driver)} → ${String(candidate.driver)}`);
63
+ }
64
+ if (baseline.accessibility !== undefined || candidate.accessibility !== undefined) {
65
+ const a11y = structuralChanges(baseline.accessibility, candidate.accessibility, {
66
+ numericTolerance: options.comparison.numericTolerance
67
+ });
68
+ for (const change of a11y) {
69
+ sections.push(`~ accessibility ${change.path}: ${render(change.before)} → ${render(change.after)}`);
70
+ }
71
+ }
72
+ const screenshot = compareScreenshot(baseline, candidate, options, webTarget?.pixelTolerance ?? options.comparison.pixelTolerance);
73
+ // "strict" (default): a screenshot change beyond tolerance gates the run.
74
+ // "advisory": the DOM is the gate — the change is reported, not failed.
75
+ const advisory = [];
76
+ if (screenshot.line) {
77
+ if (webTarget?.screenshotMode === "advisory") {
78
+ advisory.push(`${screenshot.line} (advisory — the DOM is the gate)`);
79
+ }
80
+ else {
81
+ sections.push(screenshot.line);
82
+ }
83
+ }
84
+ return {
85
+ equal: sections.length === 0,
86
+ rendered: [...sections, ...advisory].join("\n"),
87
+ pixel: screenshot.pixel,
88
+ ...(screenshot.images ? { screenshotImages: screenshot.images } : {})
89
+ };
90
+ }
91
+ // Screenshots are reduced to a content digest so baselines stay small and
92
+ // reviewable: the raw base64 blob never lands in the diffable JSON, but a pixel
93
+ // change still shows up as a changed hash. The PNG itself is written alongside
94
+ // the baseline by the runner for human inspection.
95
+ export function digestScreenshot(screenshot) {
96
+ const data = typeof screenshot.data === "string" ? screenshot.data : "";
97
+ const buffer = Buffer.from(data, "base64");
98
+ return {
99
+ mimeType: screenshot.mimeType ?? "image/png",
100
+ byteLength: buffer.byteLength,
101
+ sha256: createHash("sha256").update(buffer).digest("hex")
102
+ };
103
+ }
104
+ function canonicalizeWeb(value) {
105
+ const canonical = {
106
+ kind: value.kind,
107
+ root: canonicalizeSnapshot(value.root)
108
+ };
109
+ if (value.driver !== undefined) {
110
+ canonical.driver = value.driver;
111
+ }
112
+ if (value.accessibility !== undefined && value.accessibility !== null) {
113
+ canonical.accessibility = canonicalizeSnapshot(value.accessibility);
114
+ }
115
+ if (isRecord(value.screenshot)) {
116
+ canonical.screenshot = digestScreenshot(value.screenshot);
117
+ }
118
+ return canonical;
119
+ }
120
+ async function validateWebTarget(target, cwd) {
121
+ const checks = [];
122
+ if (target.screenshotFile) {
123
+ const filePath = path.resolve(cwd, target.screenshotFile);
124
+ const present = await access(filePath).then(() => true, () => false);
125
+ checks.push(present
126
+ ? {
127
+ name: "web-target",
128
+ severity: "pass",
129
+ target: target.name,
130
+ message: `Screenshot file exists at ${filePath}.`
131
+ }
132
+ : {
133
+ name: "web-target",
134
+ severity: "warn",
135
+ target: target.name,
136
+ message: `Screenshot file is missing at ${filePath} — run the browser tests that produce it first.`
137
+ });
138
+ return checks;
139
+ }
140
+ if (target.html) {
141
+ checks.push(await validateHtmlTarget(target, cwd));
142
+ }
143
+ else if (target.url) {
144
+ checks.push({
145
+ name: "web-target",
146
+ severity: "pass",
147
+ target: target.name,
148
+ message: `Web target "${target.name}" has a URL.`
149
+ });
150
+ }
151
+ else {
152
+ checks.push({
153
+ name: "web-target",
154
+ severity: "fail",
155
+ target: target.name,
156
+ message: `Web target "${target.name}" must configure html or url.`
157
+ });
158
+ }
159
+ if (target.driver === "playwright") {
160
+ checks.push(await validatePlaywrightTarget(target));
161
+ }
162
+ return checks;
163
+ }
164
+ async function validateHtmlTarget(target, cwd) {
165
+ const htmlPath = path.resolve(cwd, target.html ?? "");
166
+ try {
167
+ await access(htmlPath);
168
+ return {
169
+ name: "web-target",
170
+ severity: "pass",
171
+ target: target.name,
172
+ message: `HTML fixture exists at ${htmlPath}.`
173
+ };
174
+ }
175
+ catch {
176
+ return {
177
+ name: "web-target",
178
+ severity: "fail",
179
+ target: target.name,
180
+ message: `HTML fixture is missing at ${htmlPath}.`
181
+ };
182
+ }
183
+ }
184
+ async function validatePlaywrightTarget(target) {
185
+ const executablePath = target.browser?.executablePath ?? process.env.DUNGBEETLE_CHROMIUM_EXECUTABLE_PATH;
186
+ if (executablePath) {
187
+ try {
188
+ await access(executablePath);
189
+ return {
190
+ name: "playwright-browser",
191
+ severity: "pass",
192
+ target: target.name,
193
+ message: `Playwright browser executable exists at ${executablePath}.`
194
+ };
195
+ }
196
+ catch {
197
+ return {
198
+ name: "playwright-browser",
199
+ severity: "fail",
200
+ target: target.name,
201
+ message: `Playwright browser executable was not found at ${executablePath}.`
202
+ };
203
+ }
204
+ }
205
+ if (target.browser?.channel) {
206
+ return {
207
+ name: "playwright-browser",
208
+ severity: "warn",
209
+ target: target.name,
210
+ message: `Playwright will use browser channel "${target.browser.channel}". Ensure it is installed on this machine.`
211
+ };
212
+ }
213
+ return {
214
+ name: "playwright-browser",
215
+ severity: "warn",
216
+ target: target.name,
217
+ message: "Playwright target has no browser executable or channel. Set browser.executablePath, browser.channel, or DUNGBEETLE_CHROMIUM_EXECUTABLE_PATH."
218
+ };
219
+ }
220
+ export const web = {
221
+ kind: "web",
222
+ hasScreenshots: true,
223
+ parallelSafe: true,
224
+ capture: (target, ctx) => captureWebTarget(target, ctx),
225
+ canonicalize: canonicalizeWeb,
226
+ compare: (baseline, candidate, options) => compareWeb(coerceSnapshot(baseline), coerceSnapshot(candidate), options),
227
+ validateConfig: (target, { label, issues }) => {
228
+ const webTarget = target;
229
+ if (!webTarget.html && !webTarget.url && !webTarget.screenshotFile) {
230
+ issues.push(`${label} (web "${webTarget.name}") must configure "html", "url", or "screenshotFile".`);
231
+ }
232
+ if (webTarget.screenshotFile && (webTarget.html || webTarget.url || webTarget.driver)) {
233
+ issues.push(`${label} (web "${webTarget.name}") sets "screenshotFile" alongside a page capture — use one source.`);
234
+ }
235
+ if (webTarget.screenshotMode !== undefined &&
236
+ webTarget.screenshotMode !== "strict" &&
237
+ webTarget.screenshotMode !== "advisory") {
238
+ issues.push(`${label} (web "${webTarget.name}") has unknown screenshotMode "${String(webTarget.screenshotMode)}" — use "strict" or "advisory".`);
239
+ }
240
+ if (webTarget.driver && webTarget.driver !== "fetch" && webTarget.driver !== "playwright") {
241
+ issues.push(`${label} (web "${webTarget.name}") has unknown driver "${webTarget.driver}".`);
242
+ }
243
+ if (webTarget.driver === "playwright" && !webTarget.url) {
244
+ issues.push(`${label} (web "${webTarget.name}") uses the playwright driver and must set "url".`);
245
+ }
246
+ },
247
+ doctorChecks: (target, cwd) => validateWebTarget(target, cwd)
248
+ };
@@ -0,0 +1,15 @@
1
+ import type { CaptureTarget } from "../config.js";
2
+ import { type CheckParser } from "./parsers.js";
3
+ export type CheckTarget = Extract<CaptureTarget, {
4
+ kind: "check";
5
+ }>;
6
+ export type CheckSnapshot = {
7
+ kind: "check";
8
+ tool: string;
9
+ data: Record<string, unknown>;
10
+ };
11
+ export declare function checkParserFor(target: CheckTarget): CheckParser;
12
+ export declare function captureCheck(target: CheckTarget, options: {
13
+ cwd: string;
14
+ timeoutMs: number;
15
+ }): Promise<CheckSnapshot>;
@@ -0,0 +1,76 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseJsonFile } from "../json.js";
4
+ import { runShellCommand } from "../terminal/capture.js";
5
+ import { checkParsers } from "./parsers.js";
6
+ export function checkParserFor(target) {
7
+ const parser = checkParsers[target.tool];
8
+ if (!parser) {
9
+ throw new Error(`Check target "${target.name}" uses unknown tool "${target.tool}". ` +
10
+ `Available: ${Object.keys(checkParsers).join(", ")}.`);
11
+ }
12
+ return parser;
13
+ }
14
+ // Capture a check snapshot, then normalize via the tool parser. Modes:
15
+ // - command only (or parser default): run it, read stdout.
16
+ // - output only: ingest an existing report file, run nothing.
17
+ // - command + output (or parser defaults, e.g. test runners): run the
18
+ // command, then read the report file it wrote.
19
+ export async function captureCheck(target, options) {
20
+ const parser = checkParserFor(target);
21
+ const command = target.command ?? (target.output ? undefined : parser.defaultCommand);
22
+ const outputFile = target.output ?? (target.command ? undefined : parser.defaultOutput);
23
+ let stdout = "";
24
+ if (command) {
25
+ const result = await runShellCommand({
26
+ command,
27
+ cwd: options.cwd,
28
+ timeoutMs: target.timeoutMs ?? options.timeoutMs
29
+ });
30
+ const detail = result.stderr.trim();
31
+ const exit = result.exitCode ?? `signal ${String(result.signal)}`;
32
+ // A null exit code means the process was killed by a signal — for a check
33
+ // command that is the timeout's SIGTERM (see runShellCommand.killTree). Unlike
34
+ // a non-zero exit, a signal-kill is never "findings exist"; the run was cut off
35
+ // mid-execution, so `toleratesNonZeroExit` must NOT wave it through. Otherwise a
36
+ // timed-out `phpunit --log-junit` would fall through to reading whatever stale
37
+ // report a previous run left on disk and report a stale PASS.
38
+ if (result.exitCode === null) {
39
+ throw new Error(`Check target "${target.name}": \`${command}\` was killed (${exit}) before it completed` +
40
+ `${detail ? `:\n${detail}` : "."}`);
41
+ }
42
+ // Some tools (phpstan, test runners) exit non-zero when they *found*
43
+ // something — for those the findings are the snapshot, so failure is only
44
+ // missing/unparseable output below.
45
+ if (result.exitCode !== 0 && !parser.toleratesNonZeroExit) {
46
+ throw new Error(`Check target "${target.name}": \`${command}\` exited with ${exit}${detail ? `:\n${detail}` : "."}`);
47
+ }
48
+ stdout = result.stdout;
49
+ if (result.exitCode !== 0 && !outputFile && stdout.trim() === "") {
50
+ throw new Error(`Check target "${target.name}": \`${command}\` exited with ${exit} and produced no output` +
51
+ `${detail ? `:\n${detail}` : "."}`);
52
+ }
53
+ }
54
+ let rawText;
55
+ let source;
56
+ if (outputFile) {
57
+ source = path.resolve(options.cwd, outputFile);
58
+ try {
59
+ rawText = await readFile(source, "utf8");
60
+ }
61
+ catch {
62
+ throw new Error(`Check target "${target.name}": expected report at ${source}` +
63
+ `${command ? ` after \`${command}\`` : ""}, but it could not be read.`);
64
+ }
65
+ }
66
+ else {
67
+ source = command;
68
+ rawText = stdout;
69
+ }
70
+ const raw = parser.format === "text" ? rawText : parseJsonFile(rawText, source);
71
+ return {
72
+ kind: "check",
73
+ tool: parser.tool,
74
+ data: parser.normalize(raw, { cwd: options.cwd })
75
+ };
76
+ }
@@ -0,0 +1,9 @@
1
+ export type JUnitCase = {
2
+ name: string;
3
+ suite: string;
4
+ file?: string;
5
+ status: "passed" | "failed" | "error" | "skipped";
6
+ type?: string;
7
+ message?: string;
8
+ };
9
+ export declare function parseJUnit(xml: string): JUnitCase[];
@@ -0,0 +1,51 @@
1
+ // Minimal JUnit XML extraction for the check kind's test-runner parsers.
2
+ //
3
+ // Not a general XML parser — it targets the JUnit report shape emitted by
4
+ // PHPUnit/Pest (and most other runners): `<testcase>` elements with attributes
5
+ // and at most one `<failure>` / `<error>` / `<skipped>` child. Kept dependency-
6
+ // free on purpose; the fixtures it is tested against are real runner output.
7
+ const TESTCASE = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
8
+ const CHILD = /<(failure|error|skipped)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/;
9
+ const ATTR = /([\w.-]+)="([^"]*)"/g;
10
+ function unescapeXml(value) {
11
+ return value
12
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
13
+ .replace(/&lt;/g, "<")
14
+ .replace(/&gt;/g, ">")
15
+ .replace(/&quot;/g, '"')
16
+ .replace(/&apos;/g, "'")
17
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
18
+ .replace(/&amp;/g, "&");
19
+ }
20
+ function attributes(raw) {
21
+ const attrs = {};
22
+ for (const match of raw.matchAll(ATTR)) {
23
+ attrs[match[1]] = unescapeXml(match[2]);
24
+ }
25
+ return attrs;
26
+ }
27
+ export function parseJUnit(xml) {
28
+ if (!/<testsuites?\b/.test(xml)) {
29
+ throw new Error("Invalid JUnit report: no <testsuite> element found.");
30
+ }
31
+ const cases = [];
32
+ for (const match of xml.matchAll(TESTCASE)) {
33
+ const attrs = attributes(match[1]);
34
+ const body = match[2] ?? "";
35
+ const child = CHILD.exec(body);
36
+ const status = child
37
+ ? { failure: "failed", error: "error", skipped: "skipped" }[child[1]]
38
+ : "passed";
39
+ const childAttrs = child ? attributes(child[2]) : {};
40
+ const message = child ? unescapeXml((child[3] ?? "").trim()) : "";
41
+ cases.push({
42
+ name: attrs.name ?? "?",
43
+ suite: attrs.class ?? attrs.classname ?? attrs.file ?? "?",
44
+ ...(attrs.file ? { file: attrs.file } : {}),
45
+ status,
46
+ ...(childAttrs.type ? { type: childAttrs.type } : {}),
47
+ ...(message ? { message } : {})
48
+ });
49
+ }
50
+ return cases;
51
+ }
@@ -0,0 +1,2 @@
1
+ import type { CaptureTarget } from "../config.js";
2
+ export declare function detectLaravelTargets(cwd: string): Promise<CaptureTarget[] | null>;
@@ -0,0 +1,44 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isRecord } from "../guards.js";
4
+ // Zero-config on-ramp: scaffold check targets for a Laravel app, detected via
5
+ // composer.json's laravel/framework requirement. App-shape surfaces are always
6
+ // included; tool targets only when the tool is actually installed
7
+ // (require/require-dev). Returns null when this isn't a Laravel app.
8
+ export async function detectLaravelTargets(cwd) {
9
+ let composer;
10
+ try {
11
+ composer = JSON.parse(await readFile(path.join(cwd, "composer.json"), "utf8"));
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ if (!isRecord(composer)) {
17
+ return null;
18
+ }
19
+ const required = isRecord(composer.require) ? composer.require : {};
20
+ const requiredDev = isRecord(composer["require-dev"]) ? composer["require-dev"] : {};
21
+ if (!Object.hasOwn(required, "laravel/framework")) {
22
+ return null;
23
+ }
24
+ const has = (pkg) => Object.hasOwn(required, pkg) || Object.hasOwn(requiredDev, pkg);
25
+ const targets = [
26
+ { kind: "check", name: "routes", tool: "laravel-routes" },
27
+ { kind: "check", name: "about", tool: "laravel-about" },
28
+ { kind: "check", name: "schedule", tool: "laravel-schedule" },
29
+ { kind: "check", name: "schema", tool: "laravel-schema" }
30
+ ];
31
+ if (has("pestphp/pest")) {
32
+ targets.push({ kind: "check", name: "tests", tool: "pest" });
33
+ }
34
+ else if (has("phpunit/phpunit")) {
35
+ targets.push({ kind: "check", name: "tests", tool: "phpunit" });
36
+ }
37
+ if (has("laravel/pint")) {
38
+ targets.push({ kind: "check", name: "style", tool: "pint" });
39
+ }
40
+ if (has("phpstan/phpstan") || has("larastan/larastan") || has("nunomaduro/larastan")) {
41
+ targets.push({ kind: "check", name: "static-analysis", tool: "phpstan" });
42
+ }
43
+ return targets;
44
+ }
@@ -0,0 +1,12 @@
1
+ export type CheckData = Record<string, unknown>;
2
+ export interface CheckParser {
3
+ tool: string;
4
+ defaultCommand: string;
5
+ defaultOutput?: string;
6
+ format?: "json" | "text";
7
+ toleratesNonZeroExit?: boolean;
8
+ normalize(raw: unknown, options: {
9
+ cwd: string;
10
+ }): CheckData;
11
+ }
12
+ export declare const checkParsers: Record<string, CheckParser>;