rankforge 0.3.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.
@@ -0,0 +1,149 @@
1
+ import { normalizeUrl } from "./url-utils.mjs";
2
+
3
+ const pageEvidencePath = (pageIndex, path) => `$.pages[${pageIndex}].evidence.${path}`;
4
+ const renderEvidencePath = (pageIndex, path) => `$.pages[${pageIndex}].render.evidence.${path}`;
5
+
6
+ const cleanText = (value) =>
7
+ String(value ?? "")
8
+ .replace(/\s+/g, " ")
9
+ .trim();
10
+
11
+ const cleanTextFolded = (value) => cleanText(value).toLowerCase();
12
+
13
+ const normalizeCanonical = (value) => {
14
+ const cleaned = cleanText(value);
15
+ if (!cleaned) return "";
16
+
17
+ try {
18
+ return normalizeUrl(cleaned);
19
+ } catch {
20
+ return cleaned.toLowerCase();
21
+ }
22
+ };
23
+
24
+ const firstNormalized = (values) => {
25
+ if (!Array.isArray(values)) return "";
26
+ return cleanText(values[0]);
27
+ };
28
+
29
+ const structuredDataTypes = (value) => {
30
+ if (!value) return [];
31
+ if (Array.isArray(value)) return value.flatMap(structuredDataTypes);
32
+ if (typeof value !== "object") return [];
33
+
34
+ const types = [];
35
+ if (value["@type"]) {
36
+ if (Array.isArray(value["@type"])) types.push(...value["@type"].map(String));
37
+ else types.push(String(value["@type"]));
38
+ }
39
+ if (value["@graph"]) types.push(...structuredDataTypes(value["@graph"]));
40
+ return types;
41
+ };
42
+
43
+ const validStructuredDataBlocks = (evidence) =>
44
+ (Array.isArray(evidence?.structuredData) ? evidence.structuredData : []).filter((block) => !block?.parseError);
45
+
46
+ const schemaTypesFor = (evidence) => {
47
+ const explicitTypes = Array.isArray(evidence?.schemaTypes) ? evidence.schemaTypes.map(String) : [];
48
+ const blockTypes = validStructuredDataBlocks(evidence).flatMap((block) => structuredDataTypes(block?.data));
49
+ return [...new Set([...explicitTypes, ...blockTypes].map(cleanText).filter(Boolean))];
50
+ };
51
+
52
+ const visibleTextCharacters = (evidence) => {
53
+ const value = evidence?.counts?.visibleTextCharacters;
54
+ return Number.isFinite(value) ? value : 0;
55
+ };
56
+
57
+ const makeFact = (ruleId, pageIndex, paths, impact) => ({
58
+ ruleId,
59
+ evidence: paths.flatMap((path) => [pageEvidencePath(pageIndex, path), renderEvidencePath(pageIndex, path)]),
60
+ impact,
61
+ });
62
+
63
+ export const renderParityFacts = (snapshot, pageIndex = 0) => {
64
+ if (snapshot?.render?.status !== "rendered" || !snapshot.render.evidence) return [];
65
+
66
+ const raw = snapshot.evidence ?? {};
67
+ const rendered = snapshot.render.evidence;
68
+ const facts = [];
69
+
70
+ const rawTitle = cleanTextFolded(raw.title);
71
+ const renderedTitle = cleanTextFolded(rendered.title);
72
+ if (rawTitle && rawTitle !== renderedTitle) {
73
+ facts.push(makeFact(
74
+ "technical.rendered_title_changed",
75
+ pageIndex,
76
+ ["title"],
77
+ "Rendered title differs from the raw HTML title.",
78
+ ));
79
+ }
80
+
81
+ const rawDescription = cleanTextFolded(raw.description);
82
+ const renderedDescription = cleanTextFolded(rendered.description);
83
+ if (rawDescription && rawDescription !== renderedDescription) {
84
+ facts.push(makeFact(
85
+ "technical.rendered_description_changed",
86
+ pageIndex,
87
+ ["description"],
88
+ "Rendered description differs from the raw HTML description.",
89
+ ));
90
+ }
91
+
92
+ const rawCanonical = normalizeCanonical(raw.canonical);
93
+ const renderedCanonical = normalizeCanonical(rendered.canonical);
94
+ if (rawCanonical && rawCanonical !== renderedCanonical) {
95
+ facts.push(makeFact(
96
+ "technical.rendered_canonical_changed",
97
+ pageIndex,
98
+ ["canonical"],
99
+ "Rendered canonical URL differs from the raw HTML canonical URL.",
100
+ ));
101
+ }
102
+
103
+ const rawPrimaryHeading = firstNormalized(raw.h1);
104
+ const renderedPrimaryHeading = firstNormalized(rendered.h1);
105
+ if (rawPrimaryHeading && !renderedPrimaryHeading) {
106
+ facts.push(makeFact(
107
+ "technical.rendered_primary_heading_missing",
108
+ pageIndex,
109
+ ["h1"],
110
+ "Rendered page is missing the primary heading found in raw HTML.",
111
+ ));
112
+ }
113
+
114
+ const rawValidBlocks = validStructuredDataBlocks(raw);
115
+ const renderedValidBlocks = validStructuredDataBlocks(rendered);
116
+ const rawTypes = schemaTypesFor(raw);
117
+ const renderedTypes = schemaTypesFor(rendered);
118
+ const renderedTypeSet = new Set(renderedTypes);
119
+ const lostTypes = rawTypes.filter((type) => !renderedTypeSet.has(type));
120
+ if (rawValidBlocks.length > renderedValidBlocks.length || lostTypes.length > 0) {
121
+ const lostLabel = lostTypes.length > 0 ? ` Lost schema types: ${lostTypes.join(", ")}.` : "";
122
+ facts.push(makeFact(
123
+ "technical.rendered_structured_data_lost",
124
+ pageIndex,
125
+ ["structuredData"],
126
+ `Rendered page has less valid structured data than the raw HTML.${lostLabel}`,
127
+ ));
128
+ }
129
+
130
+ const rawVisibleText = visibleTextCharacters(raw);
131
+ const renderedVisibleText = visibleTextCharacters(rendered);
132
+ if (rawVisibleText >= 300 && renderedVisibleText < 150) {
133
+ facts.push(makeFact(
134
+ "technical.rendered_content_missing",
135
+ pageIndex,
136
+ ["counts.visibleTextCharacters"],
137
+ "Rendered page is missing most visible text found in raw HTML.",
138
+ ));
139
+ } else if (Math.abs(rawVisibleText - renderedVisibleText) > 300) {
140
+ facts.push(makeFact(
141
+ "technical.raw_rendered_mismatch",
142
+ pageIndex,
143
+ ["counts.visibleTextCharacters"],
144
+ "Rendered visible text count differs substantially from raw HTML.",
145
+ ));
146
+ }
147
+
148
+ return facts;
149
+ };
package/src/render.mjs ADDED
@@ -0,0 +1,45 @@
1
+ export const renderHtml = async (target, options = {}) => {
2
+ if (options.security?.mode === "restricted" && /^https?:\/\//i.test(target)) {
3
+ return {
4
+ status: "unavailable",
5
+ reason: "Restricted security mode disables browser rendering for URL targets.",
6
+ };
7
+ }
8
+
9
+ if (options.renderer) {
10
+ const html = await options.renderer({ target, html: options.html, finalUrl: options.finalUrl });
11
+ return { status: "rendered", html };
12
+ }
13
+
14
+ let browser;
15
+ let error;
16
+ let result;
17
+
18
+ try {
19
+ const launcher = options.launcher || (await import("playwright")).chromium;
20
+ browser = await launcher.launch({ headless: true });
21
+ const page = await browser.newPage({
22
+ viewport: options.viewport || { width: 390, height: 844 },
23
+ userAgent: "RankForge GEO SEO audit renderer",
24
+ });
25
+ if (/^https?:\/\//i.test(target)) {
26
+ await page.goto(target, { waitUntil: "networkidle", timeout: options.timeout || 30000 });
27
+ } else {
28
+ await page.setContent(options.html || "", { waitUntil: "networkidle", timeout: options.timeout || 30000 });
29
+ }
30
+ const html = await page.content();
31
+ result = { status: "rendered", html };
32
+ } catch (caughtError) {
33
+ error = caughtError;
34
+ } finally {
35
+ if (browser) {
36
+ try {
37
+ await browser.close();
38
+ } catch (closeError) {
39
+ error ||= closeError;
40
+ }
41
+ }
42
+ }
43
+
44
+ return error ? { status: "unavailable", reason: error.message } : result;
45
+ };
@@ -0,0 +1,429 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { runAudit } from "./audit.mjs";
4
+ import { detectRepo } from "./repo-detect.mjs";
5
+ import { sourceFinding } from "./repo-findings.mjs";
6
+ import { analyzeFrameworkRouteManifests } from "./repo-manifests.mjs";
7
+ import { runCommand, startPreview, stopPreview } from "./repo-process.mjs";
8
+ import { discoverStaticRoutes } from "./repo-routes.mjs";
9
+
10
+ const toolVersion = "0.3.0";
11
+
12
+ const relativePath = (repoPath, targetPath) => {
13
+ if (!targetPath) return null;
14
+ const relative = path.relative(repoPath, targetPath);
15
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative || "." : targetPath;
16
+ };
17
+
18
+ const previewErrorDetails = (error) => ({
19
+ message: error?.message || "Preview server did not become reachable.",
20
+ stdout: error?.preview?.stdout?.join("").trim() || undefined,
21
+ stderr: error?.preview?.stderr?.join("").trim() || undefined,
22
+ });
23
+
24
+ const commandText = (chunks) => chunks?.join("").trim() || undefined;
25
+
26
+ const buildErrorDetails = (error) => ({
27
+ message: error?.message || "Build command failed.",
28
+ stdout: commandText(error?.commandResult?.stdout),
29
+ stderr: commandText(error?.commandResult?.stderr),
30
+ exitCode: error?.commandResult?.exitCode ?? undefined,
31
+ signal: error?.commandResult?.signal ?? undefined,
32
+ timedOut: error?.commandResult?.timedOut || undefined,
33
+ durationMs: error?.commandResult?.durationMs,
34
+ });
35
+
36
+ const buildFindingFor = (error, command) => {
37
+ const timedOut = Boolean(error?.commandResult?.timedOut);
38
+ const restricted = /Restricted security mode disables local command execution/.test(error?.message || "");
39
+ return sourceFinding({
40
+ id: restricted ? "repo.build_unavailable" : timedOut ? "repo.build_timeout" : "repo.build_failed",
41
+ message: restricted
42
+ ? "Build command execution is disabled in restricted security mode."
43
+ : timedOut
44
+ ? "Build command timed out before repository audit could collect page evidence."
45
+ : "Build command failed before repository audit could collect page evidence.",
46
+ evidence: command,
47
+ recommendation: restricted
48
+ ? "Use local security mode for trusted repository builds, or audit prebuilt static output."
49
+ : "Run the build command locally, fix the failure, and rerun the repository audit.",
50
+ details: buildErrorDetails(error),
51
+ });
52
+ };
53
+
54
+ const generatedOutputFindings = (staticDir) => {
55
+ const findings = [];
56
+ if (!fs.existsSync(path.join(staticDir, "robots.txt"))) {
57
+ findings.push(
58
+ sourceFinding({
59
+ id: "repo.robots_missing",
60
+ severity: "P3",
61
+ message: "Static output does not include robots.txt.",
62
+ evidence: path.join(staticDir, "robots.txt"),
63
+ recommendation: "Generate robots.txt in static output when the deployed site should expose crawler directives.",
64
+ confidence: "medium",
65
+ }),
66
+ );
67
+ }
68
+ if (!fs.existsSync(path.join(staticDir, "sitemap.xml"))) {
69
+ findings.push(
70
+ sourceFinding({
71
+ id: "repo.sitemap_missing",
72
+ severity: "P3",
73
+ message: "Static output does not include sitemap.xml.",
74
+ evidence: path.join(staticDir, "sitemap.xml"),
75
+ recommendation: "Generate sitemap.xml in static output so important URLs can be discovered consistently.",
76
+ confidence: "medium",
77
+ }),
78
+ );
79
+ }
80
+ return findings;
81
+ };
82
+
83
+ const repoEvidence = (detected, overrides = {}) => ({
84
+ path: detected.repoRoot,
85
+ detectedFramework: detected.detectedFramework,
86
+ confidence: detected.confidence,
87
+ packageManager: detected.packageManager,
88
+ buildCommand: detected.buildCommand,
89
+ previewCommand: detected.previewCommand,
90
+ staticDir: detected.staticDir,
91
+ staticDirRelative: detected.staticDirRelative,
92
+ routeSources: detected.routeSources || [],
93
+ frameworkManifests: [],
94
+ sourceFindings: [],
95
+ notes: [],
96
+ ...overrides,
97
+ });
98
+
99
+ const htmlPathForRoute = (staticDir, route) => {
100
+ const cleanRoute = route.trim();
101
+ if (!cleanRoute || cleanRoute.startsWith("#")) return null;
102
+ if (path.isAbsolute(cleanRoute) && fs.existsSync(cleanRoute) && fs.statSync(cleanRoute).isFile()) return cleanRoute;
103
+ const normalized = cleanRoute.startsWith("/") ? cleanRoute.slice(1) : cleanRoute;
104
+ if (!normalized || normalized.endsWith("/")) return path.join(staticDir, normalized, "index.html");
105
+ if (normalized.endsWith(".html")) return path.join(staticDir, normalized);
106
+ if (path.extname(normalized)) return path.join(staticDir, normalized);
107
+ return path.join(staticDir, normalized, "index.html");
108
+ };
109
+
110
+ const routeForStaticFile = (staticDir, filePath) => {
111
+ const relative = path.relative(staticDir, filePath);
112
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null;
113
+
114
+ const parsed = path.parse(relative);
115
+ const routePath = relative.split(path.sep).join("/");
116
+ if (routePath === "index.html") return "/";
117
+ if (parsed.base === "index.html") return `/${parsed.dir.split(path.sep).join("/")}/`;
118
+ return `/${routePath}`;
119
+ };
120
+
121
+ const routeForEntry = (entry, staticDir, filePath) => {
122
+ const clean = entry.trim();
123
+ if (!clean || clean.startsWith("#")) return null;
124
+ if (path.isAbsolute(clean)) return routeForStaticFile(staticDir, filePath);
125
+ return clean.startsWith("/") ? clean : `/${clean}`;
126
+ };
127
+
128
+ const readRouteListRoutes = (routeListPath, staticDir) => {
129
+ if (!fs.existsSync(routeListPath) || !fs.statSync(routeListPath).isFile()) {
130
+ return {
131
+ routes: [],
132
+ sourceFindings: [
133
+ sourceFinding({
134
+ id: "repo.route_list_missing",
135
+ message: "Configured route list file does not exist.",
136
+ evidence: routeListPath,
137
+ recommendation: "Create the route list file or remove the route-list option.",
138
+ }),
139
+ ],
140
+ };
141
+ }
142
+
143
+ const entries = fs
144
+ .readFileSync(routeListPath, "utf8")
145
+ .split(/\r?\n/)
146
+ .filter((line) => line.trim() && !line.trim().startsWith("#"));
147
+ if (!entries.length) {
148
+ return {
149
+ routes: [],
150
+ sourceFindings: [
151
+ sourceFinding({
152
+ id: "repo.route_list_empty",
153
+ message: "Configured route list does not contain any routes.",
154
+ evidence: routeListPath,
155
+ recommendation: "Add at least one route to audit.",
156
+ }),
157
+ ],
158
+ };
159
+ }
160
+
161
+ const routes = [];
162
+ const sourceFindings = [];
163
+ for (const entry of entries) {
164
+ const filePath = htmlPathForRoute(staticDir, entry);
165
+ const route = filePath ? routeForEntry(entry, staticDir, filePath) : null;
166
+ if (!filePath || !fs.existsSync(filePath)) {
167
+ sourceFindings.push(
168
+ sourceFinding({
169
+ id: "repo.route_list_entry_missing",
170
+ message: "Route list entry does not resolve to a generated HTML file.",
171
+ evidence: entry,
172
+ recommendation: "Build the route or remove it from the route list.",
173
+ }),
174
+ );
175
+ continue;
176
+ }
177
+ if (!route) {
178
+ sourceFindings.push(
179
+ sourceFinding({
180
+ id: "repo.route_list_entry_outside_static_dir",
181
+ message: "Route list entry resolves outside the configured static output directory.",
182
+ evidence: entry,
183
+ recommendation: "Use routes or HTML files generated under the configured static output directory.",
184
+ }),
185
+ );
186
+ continue;
187
+ }
188
+ if (!filePath.endsWith(".html")) {
189
+ sourceFindings.push(
190
+ sourceFinding({
191
+ id: "repo.route_list_entry_not_html",
192
+ message: "Route list entry does not resolve to an HTML file.",
193
+ evidence: entry,
194
+ recommendation: "Route-list entries must point to generated HTML pages.",
195
+ }),
196
+ );
197
+ continue;
198
+ }
199
+ routes.push({ type: "route_list", route, path: filePath });
200
+ }
201
+
202
+ return { routes, sourceFindings };
203
+ };
204
+
205
+ const emptyAudit = (detected, repoOverrides = {}) => {
206
+ const now = new Date().toISOString();
207
+
208
+ return {
209
+ schemaVersion: "1.0.0",
210
+ toolVersion,
211
+ run: {
212
+ id: `repo-audit-${Date.now()}`,
213
+ startedAt: now,
214
+ endedAt: now,
215
+ target: repoOverrides.previewUrl || repoOverrides.staticDir || detected.repoRoot,
216
+ mode: "repo",
217
+ },
218
+ site: {
219
+ origin: null,
220
+ robots: null,
221
+ sitemaps: [],
222
+ skipped: [],
223
+ notes: ["No page audit evidence was collected."],
224
+ },
225
+ pages: [],
226
+ integrations: {},
227
+ scores: {},
228
+ findings: [],
229
+ evidenceGaps: [],
230
+ sources: [],
231
+ repo: repoEvidence(detected, repoOverrides),
232
+ };
233
+ };
234
+
235
+ export const runRepoAudit = async (options = {}) => {
236
+ const repoPath = path.resolve(options.repoPath || ".");
237
+ const detected = detectRepo(repoPath);
238
+ const buildCommand = options.buildCommand || detected.buildCommand;
239
+ let build;
240
+
241
+ if (options.buildCommand) {
242
+ try {
243
+ const result = await runCommand({
244
+ command: options.buildCommand,
245
+ cwd: repoPath,
246
+ timeoutMs: options.maxBuildMs ?? 120000,
247
+ label: "Build",
248
+ security: options.security,
249
+ });
250
+ build = {
251
+ executed: true,
252
+ durationMs: result.durationMs,
253
+ exitCode: result.exitCode,
254
+ stdout: commandText(result.stdout),
255
+ stderr: commandText(result.stderr),
256
+ };
257
+ } catch (error) {
258
+ return emptyAudit(detected, {
259
+ buildCommand: options.buildCommand,
260
+ build: {
261
+ executed: true,
262
+ durationMs: error?.commandResult?.durationMs,
263
+ exitCode: error?.commandResult?.exitCode ?? null,
264
+ stdout: commandText(error?.commandResult?.stdout),
265
+ stderr: commandText(error?.commandResult?.stderr),
266
+ timedOut: error?.commandResult?.timedOut || undefined,
267
+ },
268
+ sourceFindings: [buildFindingFor(error, options.buildCommand)],
269
+ });
270
+ }
271
+ }
272
+
273
+ const hasExplicitPreview = Boolean(options.previewCommand && options.previewUrl);
274
+ const staticDir = hasExplicitPreview ? null : options.staticDir ? path.resolve(repoPath, options.staticDir) : detected.staticDir;
275
+
276
+ if (staticDir) {
277
+ const staticDirRelative = options.staticDir ? relativePath(repoPath, staticDir) : detected.staticDirRelative;
278
+ const staticRepoFields = {
279
+ buildCommand,
280
+ build,
281
+ staticDir,
282
+ staticDirRelative,
283
+ routeSources: [],
284
+ sourceFindings: [],
285
+ notes: [],
286
+ };
287
+
288
+ if (!fs.existsSync(staticDir) || !fs.statSync(staticDir).isDirectory()) {
289
+ return emptyAudit(detected, {
290
+ ...staticRepoFields,
291
+ sourceFindings: [
292
+ sourceFinding({
293
+ id: "repo.static_dir_missing",
294
+ message: "Configured static output directory does not exist or is not a directory.",
295
+ evidence: staticDir,
296
+ recommendation: "Run the repository build or pass an existing static output directory.",
297
+ }),
298
+ ],
299
+ });
300
+ }
301
+
302
+ const routeList = options.routeList ? path.resolve(repoPath, options.routeList) : null;
303
+ const routeListResult = routeList ? readRouteListRoutes(routeList, staticDir) : null;
304
+ const routeSourceFindings = routeListResult?.sourceFindings || [];
305
+ if (routeSourceFindings.length) {
306
+ return emptyAudit(detected, {
307
+ ...staticRepoFields,
308
+ buildCommand: options.buildCommand || detected.buildCommand,
309
+ build,
310
+ routeList,
311
+ sourceFindings: routeSourceFindings,
312
+ });
313
+ }
314
+
315
+ const staticRoutes = discoverStaticRoutes(staticDir);
316
+ const routes = routeListResult ? routeListResult.routes : staticRoutes;
317
+
318
+ if (!routes.length) {
319
+ return emptyAudit(detected, {
320
+ ...staticRepoFields,
321
+ sourceFindings: [
322
+ sourceFinding({
323
+ id: "repo.static_routes_missing",
324
+ message: "Static output directory does not contain HTML routes.",
325
+ evidence: staticDir,
326
+ recommendation: "Build static HTML output before running a repository audit.",
327
+ }),
328
+ ],
329
+ });
330
+ }
331
+
332
+ const manifestAnalysis = analyzeFrameworkRouteManifests({
333
+ repoPath,
334
+ staticDir,
335
+ detectedFramework: detected.detectedFramework,
336
+ staticRoutes,
337
+ });
338
+ const outputSourceFindings = [...generatedOutputFindings(staticDir), ...manifestAnalysis.sourceFindings];
339
+ const audit = await runAudit({
340
+ ...options,
341
+ target: routes[0].path,
342
+ urlListEntries: routes.map((route) => route.path),
343
+ crawl: { ...(options.crawl || {}), mode: "single" },
344
+ });
345
+
346
+ audit.repo = repoEvidence(detected, {
347
+ buildCommand,
348
+ build,
349
+ staticDir,
350
+ staticDirRelative,
351
+ routeList,
352
+ routeSources: routes,
353
+ frameworkManifests: manifestAnalysis.frameworkManifests,
354
+ sourceFindings: outputSourceFindings,
355
+ notes: ["Audited static output directory."],
356
+ });
357
+ return audit;
358
+ }
359
+
360
+ if (options.previewCommand && options.previewUrl) {
361
+ let preview;
362
+ try {
363
+ preview = await startPreview({
364
+ command: options.previewCommand,
365
+ cwd: repoPath,
366
+ previewUrl: options.previewUrl,
367
+ timeoutMs: options.maxPreviewMs,
368
+ security: options.security,
369
+ limits: options.limits,
370
+ });
371
+ } catch (error) {
372
+ return emptyAudit(detected, {
373
+ buildCommand,
374
+ build,
375
+ previewCommand: options.previewCommand,
376
+ previewUrl: options.previewUrl,
377
+ sourceFindings: [
378
+ sourceFinding({
379
+ id: "repo.preview_unreachable",
380
+ message: "Preview server did not become reachable for repository audit.",
381
+ evidence: options.previewUrl,
382
+ recommendation: "Verify the preview command starts a server at the configured preview URL.",
383
+ details: previewErrorDetails(error),
384
+ }),
385
+ ],
386
+ });
387
+ }
388
+
389
+ try {
390
+ const audit = await runAudit({
391
+ ...options,
392
+ target: options.previewUrl,
393
+ crawl: {
394
+ mode: "full",
395
+ maxPages: options.maxPages ?? 25,
396
+ maxDepth: options.maxDepth ?? 2,
397
+ ...(options.crawl || {}),
398
+ },
399
+ });
400
+
401
+ audit.repo = repoEvidence(detected, {
402
+ buildCommand,
403
+ build,
404
+ previewCommand: options.previewCommand,
405
+ previewUrl: options.previewUrl,
406
+ sourceFindings: [],
407
+ notes: ["Audited explicit preview server."],
408
+ });
409
+ return audit;
410
+ } finally {
411
+ await stopPreview(preview);
412
+ }
413
+ }
414
+
415
+ return emptyAudit(detected, {
416
+ buildCommand,
417
+ build,
418
+ sourceFindings: [
419
+ sourceFinding({
420
+ id: "repo.audit_path_missing",
421
+ severity: "P2",
422
+ message: "Repository audit needs either a static output directory or an explicit preview command and URL.",
423
+ evidence: detected.repoRoot,
424
+ recommendation: "Pass staticDir, or pass both previewCommand and previewUrl.",
425
+ confidence: "high",
426
+ }),
427
+ ],
428
+ });
429
+ };