samey-cli 0.0.0-stage → 0.1.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +224 -2
  4. package/dist/ai/local-model.d.ts +26 -0
  5. package/dist/ai/local-model.js +91 -0
  6. package/dist/audit.d.ts +18 -0
  7. package/dist/audit.js +250 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +166 -0
  10. package/dist/config.d.ts +34 -0
  11. package/dist/config.js +40 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +5 -0
  14. package/dist/render/crawl.d.ts +34 -0
  15. package/dist/render/crawl.js +337 -0
  16. package/dist/render/extract.d.ts +8 -0
  17. package/dist/render/extract.js +441 -0
  18. package/dist/render/interact.d.ts +8 -0
  19. package/dist/render/interact.js +76 -0
  20. package/dist/render/network.d.ts +33 -0
  21. package/dist/render/network.js +92 -0
  22. package/dist/render/robots.d.ts +8 -0
  23. package/dist/render/robots.js +61 -0
  24. package/dist/render/server.d.ts +15 -0
  25. package/dist/render/server.js +174 -0
  26. package/dist/report/html.d.ts +2 -0
  27. package/dist/report/html.js +368 -0
  28. package/dist/report/prompt.d.ts +12 -0
  29. package/dist/report/prompt.js +62 -0
  30. package/dist/report/terminal.d.ts +6 -0
  31. package/dist/report/terminal.js +73 -0
  32. package/dist/rules/content.d.ts +8 -0
  33. package/dist/rules/content.js +227 -0
  34. package/dist/rules/functional.d.ts +17 -0
  35. package/dist/rules/functional.js +467 -0
  36. package/dist/rules/helpers.d.ts +11 -0
  37. package/dist/rules/helpers.js +29 -0
  38. package/dist/rules/index.d.ts +13 -0
  39. package/dist/rules/index.js +100 -0
  40. package/dist/rules/normalize.d.ts +18 -0
  41. package/dist/rules/normalize.js +114 -0
  42. package/dist/rules/static.d.ts +9 -0
  43. package/dist/rules/static.js +314 -0
  44. package/dist/rules/types.d.ts +39 -0
  45. package/dist/rules/types.js +16 -0
  46. package/dist/rules/visual.d.ts +15 -0
  47. package/dist/rules/visual.js +463 -0
  48. package/dist/score.d.ts +31 -0
  49. package/dist/score.js +66 -0
  50. package/dist/security/fetch.d.ts +26 -0
  51. package/dist/security/fetch.js +86 -0
  52. package/dist/security/net-policy.d.ts +46 -0
  53. package/dist/security/net-policy.js +178 -0
  54. package/dist/static/scan.d.ts +15 -0
  55. package/dist/static/scan.js +191 -0
  56. package/dist/types.d.ts +316 -0
  57. package/dist/types.js +1 -0
  58. package/dist/util/color.d.ts +25 -0
  59. package/dist/util/color.js +180 -0
  60. package/dist/util/text.d.ts +22 -0
  61. package/dist/util/text.js +106 -0
  62. package/package.json +69 -4
package/dist/audit.js ADDED
@@ -0,0 +1,250 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { checkModel, critiqueScreens } from './ai/local-model.js';
6
+ import { BrowserMissingError, crawl, launchBrowser, VIEWPORTS } from './render/crawl.js';
7
+ import { serveStatic, startDevServer } from './render/server.js';
8
+ import { renderHtml } from './report/html.js';
9
+ import { fullPrompt } from './report/prompt.js';
10
+ import { rulePenalties, ruleVersions, runRules } from './rules/index.js';
11
+ import { normalizeFindings } from './rules/normalize.js';
12
+ import { CATEGORY_LABELS, prioritize, SCORING, scoreFindings } from './score.js';
13
+ import { BlockedError, displayUrl, DEV_POLICY, REMOTE_POLICY, resolveTarget } from './security/net-policy.js';
14
+ import { scanRepo } from './static/scan.js';
15
+ export function toolVersion() {
16
+ try {
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ return JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
19
+ }
20
+ catch {
21
+ return '0.0.0';
22
+ }
23
+ }
24
+ export class AuditError extends Error {
25
+ }
26
+ export async function runAudit(opts) {
27
+ const began = Date.now();
28
+ const log = opts.onProgress ?? (() => undefined);
29
+ if (!opts.path && !opts.url)
30
+ throw new AuditError('Nothing to audit: pass a project path, --url, or both.');
31
+ // Validate the URL before doing any other work. Non-public addresses need explicit --dev.
32
+ const urlPolicy = opts.policyOverride ?? (opts.dev ? DEV_POLICY : REMOTE_POLICY);
33
+ if (opts.url) {
34
+ try {
35
+ await resolveTarget(opts.url, urlPolicy);
36
+ }
37
+ catch (err) {
38
+ if (!(err instanceof BlockedError))
39
+ throw err;
40
+ const local = /loopback|private/.test(err.message);
41
+ throw new AuditError(local && !opts.dev
42
+ ? `${err.message}. To audit a local or private development server, pass --dev.`
43
+ : `Refusing to audit ${opts.url}: ${err.message}.`);
44
+ }
45
+ }
46
+ const outDir = path.resolve(opts.outDir);
47
+ const shotsDir = path.join(outDir, 'shots');
48
+ mkdirSync(outDir, { recursive: true });
49
+ // Clear screenshots from a previous run so the report never shows stale images.
50
+ try {
51
+ for (const f of readdirSync(shotsDir))
52
+ if (f.endsWith('.jpg'))
53
+ rmSync(path.join(shotsDir, f));
54
+ }
55
+ catch {
56
+ /* no previous shots */
57
+ }
58
+ const coverage = {
59
+ staticAnalysis: false,
60
+ rendered: false,
61
+ renderAttempted: false,
62
+ pagesAudited: 0,
63
+ pagesFailed: 0,
64
+ routeLimitHit: false,
65
+ skipped: [],
66
+ interactions: false,
67
+ blockedRequests: [],
68
+ networkBudgetExceeded: false,
69
+ model: { enabled: false },
70
+ limitations: [],
71
+ };
72
+ let repo;
73
+ if (opts.path) {
74
+ log(`Scanning ${path.relative(process.cwd(), opts.path) || '.'}`);
75
+ repo = scanRepo(opts.path);
76
+ coverage.staticAnalysis = true;
77
+ log(`Found ${repo.fileCount} source files${repo.frameworks.length ? ` (${repo.frameworks.join(', ')})` : ''}${repo.truncated ? ', file limit reached' : ''}`);
78
+ }
79
+ let crawlResult;
80
+ let server;
81
+ try {
82
+ let target = opts.url;
83
+ if (!target && !opts.staticOnly && repo) {
84
+ if (opts.serve) {
85
+ log(`Starting dev server: ${opts.serve}`);
86
+ server = await startDevServer(opts.serve, repo.root, opts.port, opts.serverTimeoutMs);
87
+ target = server.url;
88
+ }
89
+ else if (repo.staticRoot) {
90
+ server = await serveStatic(repo.staticRoot);
91
+ target = server.url;
92
+ log(`Serving ${path.relative(process.cwd(), repo.staticRoot) || '.'} at ${target}`);
93
+ }
94
+ else {
95
+ coverage.renderedNote =
96
+ 'No URL was given, the project has no static build to serve, and --serve was not passed, so rendered checks did not run. ' +
97
+ "Pass --url http://localhost:3000 for a running server, or --serve 'npm run dev' to let samey start it.";
98
+ }
99
+ }
100
+ if (opts.staticOnly)
101
+ coverage.renderedNote = 'Rendered checks were disabled with --static-only.';
102
+ if (target) {
103
+ // A server samey started is on loopback; it does not need access to the rest of the private network.
104
+ const policy = opts.policyOverride ?? (server ? { allowLoopback: true, allowPrivate: false } : urlPolicy);
105
+ const browser = await launchBrowser(policy).catch((err) => {
106
+ if (err instanceof BrowserMissingError) {
107
+ coverage.renderedNote = err.message;
108
+ log(err.message);
109
+ return undefined;
110
+ }
111
+ throw err;
112
+ });
113
+ if (browser) {
114
+ try {
115
+ const isLocalProject = !!server || !!(repo && opts.url && opts.dev);
116
+ crawlResult = await crawl(browser, {
117
+ startUrl: target,
118
+ maxRoutes: opts.maxRoutes,
119
+ navTimeoutMs: opts.navTimeoutMs,
120
+ pageTimeoutMs: opts.pageTimeoutMs,
121
+ seeds: [...opts.routes, ...(isLocalProject && repo ? repo.routeHints : [])],
122
+ shotsDir: opts.screenshots ? shotsDir : undefined,
123
+ interact: opts.interact,
124
+ respectRobots: opts.respectRobots && !isLocalProject,
125
+ policy,
126
+ onProgress: log,
127
+ });
128
+ }
129
+ finally {
130
+ await browser.close();
131
+ }
132
+ coverage.renderAttempted = true;
133
+ coverage.rendered = crawlResult.pages.some((p) => p.desktop);
134
+ coverage.pagesAudited = crawlResult.pages.length;
135
+ coverage.pagesFailed = crawlResult.pages.filter((p) => !p.desktop).length;
136
+ coverage.routeLimitHit = crawlResult.limitHit;
137
+ coverage.skipped = crawlResult.skipped;
138
+ coverage.interactions = opts.interact;
139
+ coverage.blockedRequests = crawlResult.network.blocked.slice(0, 20);
140
+ coverage.networkBudgetExceeded = crawlResult.network.budgetExceeded;
141
+ if (!coverage.rendered) {
142
+ const why = crawlResult.pages.find((p) => p.error)?.error ?? (crawlResult.pages[0]?.status ? `HTTP ${crawlResult.pages[0].status}` : 'unknown error');
143
+ coverage.renderedNote = `No page under ${server ? 'the local server' : target} could be rendered (${why}).`;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ finally {
149
+ await server?.close();
150
+ }
151
+ log('Running rules');
152
+ const { findings, errors } = runRules({ crawl: crawlResult, repo }, opts.ignoreRules);
153
+ let modelFindings = [];
154
+ if (opts.ai && opts.model && crawlResult && opts.screenshots) {
155
+ const mopts = { model: opts.model, endpoint: opts.modelEndpoint, timeoutMs: opts.modelTimeoutMs, maxPages: 3 };
156
+ const check = await checkModel(mopts).catch((e) => ({ ok: false, note: e.message }));
157
+ coverage.model = { enabled: check.ok, name: opts.model, note: check.note };
158
+ if (check.ok) {
159
+ log(`Asking local model ${opts.model} for a visual critique`);
160
+ const drafts = await critiqueScreens(crawlResult, shotsDir, mopts).catch((e) => {
161
+ coverage.model.note = `model run failed: ${e.message}`;
162
+ return [];
163
+ });
164
+ modelFindings = normalizeFindings(drafts);
165
+ }
166
+ }
167
+ else {
168
+ coverage.model = {
169
+ enabled: false,
170
+ note: !opts.ai ? 'disabled with --no-ai' : !opts.model ? 'no local model configured (optional; see --model)' : 'needs rendered screenshots',
171
+ };
172
+ }
173
+ const all = [...findings, ...modelFindings];
174
+ const { score, categories } = scoreFindings(all, coverage, rulePenalties());
175
+ coverage.limitations = limitations(coverage, categories.filter((c) => c.score === null).map((c) => CATEGORY_LABELS[c.category]));
176
+ const pages = crawlResult?.pages ?? [];
177
+ const projectKey = repo?.root ?? (opts.url ? new URL(opts.url).origin : 'unknown');
178
+ const report = {
179
+ schemaVersion: 1,
180
+ scanId: randomUUID(),
181
+ tool: { name: 'samey', version: toolVersion() },
182
+ generatedAt: new Date().toISOString(),
183
+ durationMs: Date.now() - began,
184
+ project: { id: createHash('sha256').update(projectKey).digest('hex').slice(0, 12), name: repo?.name ?? (opts.url ? new URL(opts.url).hostname : undefined) },
185
+ // The ephemeral 127.0.0.1 address of a server samey started itself is not useful to report.
186
+ target: { path: repo?.root, url: opts.url ? displayUrl(opts.url) : undefined },
187
+ score,
188
+ categories,
189
+ findings: prioritize(all),
190
+ coverage,
191
+ versions: { scoring: SCORING.version, rules: ruleVersions(), model: coverage.model.enabled ? coverage.model.name : undefined },
192
+ viewports: crawlResult ? ['desktop', 'mobile'].map((name) => ({ name, width: VIEWPORTS[name].width, height: VIEWPORTS[name].height })) : [],
193
+ failedRoutes: pages
194
+ .filter((p) => !p.desktop)
195
+ .map((p) => ({ url: displayUrl(p.url), reason: p.error ?? (p.status ? `HTTP ${p.status}` : 'not rendered') })),
196
+ repo: repo ? { name: repo.name, frameworks: repo.frameworks, fileCount: repo.fileCount, truncated: repo.truncated } : undefined,
197
+ pages: pages.map((p) => ({
198
+ url: displayUrl(p.url),
199
+ path: p.path,
200
+ status: p.status,
201
+ error: p.error,
202
+ title: p.desktop?.title,
203
+ screenshots: Object.fromEntries(['desktop', 'mobile']
204
+ .filter((vp) => p[vp]?.screenshot)
205
+ .map((vp) => [vp, { file: `shots/${p[vp].screenshot}`, width: VIEWPORTS[vp].width, height: p[vp].screenshotHeight ?? 0 }])),
206
+ })),
207
+ };
208
+ const files = {};
209
+ // A ready-to-paste prompt for an AI coding agent, so fixing does not start with copying from the report.
210
+ const promptFile = path.join(outDir, 'fix-prompt.md');
211
+ const prompt = fullPrompt(report);
212
+ if (prompt) {
213
+ writeFileSync(promptFile, prompt);
214
+ files.prompt = promptFile;
215
+ }
216
+ else
217
+ rmSync(promptFile, { force: true });
218
+ if (opts.formats.includes('json')) {
219
+ files.json = path.join(outDir, 'report.json');
220
+ writeFileSync(files.json, JSON.stringify(report, null, 2));
221
+ }
222
+ if (opts.formats.includes('html')) {
223
+ files.html = path.join(outDir, 'report.html');
224
+ writeFileSync(files.html, renderHtml(report, outDir));
225
+ }
226
+ return { report, files, ruleErrors: errors };
227
+ }
228
+ function limitations(c, unmeasured) {
229
+ const out = [];
230
+ if (c.renderedNote)
231
+ out.push(c.renderedNote);
232
+ if (unmeasured.length)
233
+ out.push(`Not measured, so left out of the overall score: ${unmeasured.join(', ')}.`);
234
+ if (c.routeLimitHit)
235
+ out.push(`The route limit was reached; ${c.skipped.filter((s) => s.reason.startsWith('route limit')).length} discovered pages were not audited.`);
236
+ if (c.pagesFailed)
237
+ out.push(`${c.pagesFailed} page${c.pagesFailed === 1 ? '' : 's'} failed to load or returned an error status.`);
238
+ const robots = c.skipped.filter((s) => s.reason.includes('robots')).length;
239
+ if (robots)
240
+ out.push(`${robots} page${robots === 1 ? ' was' : 's were'} skipped because robots.txt disallows them.`);
241
+ if (c.blockedRequests.length)
242
+ out.push(`${c.blockedRequests.length}${c.blockedRequests.length === 20 ? '+' : ''} requests were blocked by the network policy (private, loopback, or unsupported addresses).`);
243
+ if (c.networkBudgetExceeded)
244
+ out.push('The crawl hit its network budget; some resources were not loaded.');
245
+ if (c.rendered)
246
+ out.push('Technical quality currently covers page metadata only; performance and asset weight are not measured yet.');
247
+ if (c.rendered && !c.interactions)
248
+ out.push('Button click testing was turned off.');
249
+ return out;
250
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ import { Command, InvalidArgumentError, Option } from 'commander';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import pc from 'picocolors';
8
+ import { runAudit, toolVersion } from './audit.js';
9
+ import { DEFAULTS, loadConfigFile } from './config.js';
10
+ import { allRules } from './rules/index.js';
11
+ import { fullPrompt } from './report/prompt.js';
12
+ import { summarize } from './report/terminal.js';
13
+ const int = (name, min = 1) => (v) => {
14
+ const n = Number(v);
15
+ if (!Number.isInteger(n) || n < min)
16
+ throw new InvalidArgumentError(`${name} must be an integer >= ${min}.`);
17
+ return n;
18
+ };
19
+ const program = new Command();
20
+ program.name('samey').description('Lint your AI-built UI. Finds what looks generic, what is broken, and where to fix it.').version(toolVersion());
21
+ program
22
+ .command('audit')
23
+ .description('Audit a local project, a URL, or both')
24
+ .argument('[path]', 'project directory to scan (and serve, if it has a static build)')
25
+ .option('-u, --url <url>', 'audit a running site at this URL')
26
+ .option('--serve [command]', 'start the project dev server with this command (default: npm run dev). Runs project code.')
27
+ .option('--dev', 'development mode: allow --url to point at localhost or a private network address')
28
+ .option('--port <port>', 'port the dev server listens on', int('port'), DEFAULTS.port)
29
+ .option('--max-routes <n>', 'maximum pages to render', int('max-routes'), DEFAULTS.maxRoutes)
30
+ .option('--timeout <seconds>', 'navigation timeout per page', int('timeout'), DEFAULTS.navTimeoutMs / 1000)
31
+ .option('--route <path...>', 'extra routes to audit, e.g. --route /pricing /about')
32
+ .addOption(new Option('--format <format>', 'report output').choices(['html', 'json', 'both']).default('both'))
33
+ .addOption(new Option('--report <format>', 'alias for --format').choices(['html', 'json', 'both']))
34
+ .option('-o, --out <dir>', 'output directory (default: <path>/.samey or ./.samey)')
35
+ .option('--static-only', 'skip rendering; only scan source files')
36
+ .option('--no-screenshots', 'do not capture screenshots')
37
+ .option('--no-interact', 'do not click any buttons')
38
+ .option('--ignore-robots', 'crawl pages disallowed by robots.txt (only for sites you own)')
39
+ .option('--ignore <rules...>', 'rule ids to skip')
40
+ .option('--fail-above <score>', 'exit with code 1 if the slop score is above this (0 = clean, 100 = worst)', int('fail-above', 0))
41
+ .option('--no-ai', 'never use a local model, even if one is configured')
42
+ .option('--model <name>', 'local Ollama vision model for an advisory critique, e.g. qwen2.5vl:7b')
43
+ .option('--model-endpoint <url>', 'local model server (loopback only)', DEFAULTS.modelEndpoint)
44
+ .option('--json', 'print the JSON report to stdout instead of the summary')
45
+ .option('-q, --quiet', 'only print the final summary')
46
+ .action(async (target, o) => {
47
+ const dir = target ? path.resolve(target) : undefined;
48
+ if (!dir && !o.url) {
49
+ program.error('Pass a project path, --url, or both. Example: samey audit . or samey audit --url https://example.com', { exitCode: 2 });
50
+ }
51
+ let file;
52
+ try {
53
+ file = loadConfigFile(dir);
54
+ }
55
+ catch (err) {
56
+ console.error(pc.red(err.message));
57
+ process.exit(2);
58
+ }
59
+ const src = (name) => program.commands[0].getOptionValueSource(name);
60
+ const pick = (cli, name, fromFile) => (src(name) === 'default' && fromFile !== undefined ? fromFile : cli);
61
+ // A dev-server command from the config file is only used when --serve is passed on the command line.
62
+ const serve = o.serve === true ? (file.serve ?? 'npm run dev') : typeof o.serve === 'string' ? o.serve : undefined;
63
+ const quiet = o.quiet || o.json;
64
+ const opts = {
65
+ ...DEFAULTS,
66
+ path: dir,
67
+ url: o.url,
68
+ serve,
69
+ dev: !!o.dev,
70
+ port: pick(o.port, 'port', file.port),
71
+ maxRoutes: pick(o.maxRoutes, 'maxRoutes', file.maxRoutes),
72
+ navTimeoutMs: src('timeout') === 'default' && file.navTimeoutMs ? file.navTimeoutMs : o.timeout * 1000,
73
+ pageTimeoutMs: file.pageTimeoutMs ?? DEFAULTS.pageTimeoutMs,
74
+ serverTimeoutMs: file.serverTimeoutMs ?? DEFAULTS.serverTimeoutMs,
75
+ outDir: o.out ? path.resolve(o.out) : path.join(dir ?? process.cwd(), '.samey'),
76
+ formats: (o.report ?? o.format) === 'both' ? ['html', 'json'] : [o.report ?? o.format],
77
+ screenshots: o.screenshots && file.screenshots !== false,
78
+ interact: o.interact && file.interact !== false,
79
+ staticOnly: !!o.staticOnly,
80
+ respectRobots: !o.ignoreRobots && file.respectRobots !== false,
81
+ ignoreRules: [...(file.ignoreRules ?? []), ...(o.ignore ?? [])],
82
+ routes: [...(file.routes ?? []), ...(o.route ?? [])],
83
+ failAbove: o.failAbove ?? file.failAbove,
84
+ ai: o.ai,
85
+ model: o.model ?? file.model,
86
+ modelEndpoint: pick(o.modelEndpoint, 'modelEndpoint', file.modelEndpoint),
87
+ modelTimeoutMs: DEFAULTS.modelTimeoutMs,
88
+ onProgress: quiet ? undefined : (m) => console.error(pc.dim(` ${m}`)),
89
+ };
90
+ const unknownRules = opts.ignoreRules.filter((r) => !allRules.some((x) => x.id === r));
91
+ if (unknownRules.length)
92
+ console.error(pc.yellow(` Unknown rule id${unknownRules.length > 1 ? 's' : ''} in ignore list: ${unknownRules.join(', ')}`));
93
+ try {
94
+ const { report, files, ruleErrors } = await runAudit(opts);
95
+ for (const e of ruleErrors)
96
+ console.error(pc.yellow(` Rule ${e.ruleId} failed and was skipped: ${e.message}`));
97
+ if (o.json)
98
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
99
+ else
100
+ console.log(summarize(report, files));
101
+ if (report.coverage.renderAttempted && !report.coverage.rendered) {
102
+ console.error(pc.red(` ${report.coverage.renderedNote}`));
103
+ process.exitCode = 2;
104
+ return;
105
+ }
106
+ if (opts.failAbove !== undefined) {
107
+ if (report.score === null) {
108
+ console.error(pc.red(` --fail-above ${opts.failAbove}: nothing could be scored.`));
109
+ process.exitCode = 1;
110
+ }
111
+ else if (report.score > opts.failAbove) {
112
+ console.error(pc.red(` Slop score ${report.score} is above --fail-above ${opts.failAbove}.`));
113
+ process.exitCode = 1;
114
+ }
115
+ }
116
+ }
117
+ catch (err) {
118
+ console.error(pc.red(`\n Audit failed: ${err.message}\n`));
119
+ process.exitCode = 2;
120
+ }
121
+ });
122
+ program
123
+ .command('prompt')
124
+ .description('Print a fix prompt for an AI coding agent from the last audit')
125
+ .argument('[path]', 'project directory that was audited', '.')
126
+ .option('-o, --out <dir>', 'output directory used by the audit (default: <path>/.samey)')
127
+ .option('--only-defects', 'leave out design observations and include measured defects only')
128
+ .action((target, o) => {
129
+ const file = path.join(o.out ? path.resolve(o.out) : path.join(path.resolve(target), '.samey'), 'report.json');
130
+ let report;
131
+ try {
132
+ report = JSON.parse(readFileSync(file, 'utf8'));
133
+ }
134
+ catch {
135
+ console.error(pc.red(` No report at ${file}. Run \`npx samey-cli audit\` first.`));
136
+ process.exit(2);
137
+ }
138
+ const text = fullPrompt(report, { onlyDefects: !!o.onlyDefects });
139
+ if (!text) {
140
+ console.error(' The last audit found nothing to fix.');
141
+ return;
142
+ }
143
+ process.stdout.write(text);
144
+ });
145
+ program
146
+ .command('setup')
147
+ .description('Download the Chromium build that samey uses for rendered checks (about 150 MB, once)')
148
+ .action(() => {
149
+ // Use the Playwright version samey ships with, so the browser always matches it.
150
+ const require = createRequire(import.meta.url);
151
+ const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
152
+ const res = spawnSync(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' });
153
+ process.exitCode = res.status ?? 2;
154
+ });
155
+ program
156
+ .command('rules')
157
+ .description('List every rule samey runs')
158
+ .action(() => {
159
+ for (const r of allRules) {
160
+ console.log(`${pc.bold(r.id.padEnd(26))} ${pc.dim(`[${r.category}, ${r.needs}]`)}\n ${r.description}`);
161
+ }
162
+ });
163
+ program.parseAsync().catch((err) => {
164
+ console.error(pc.red(err.message));
165
+ process.exit(2);
166
+ });
@@ -0,0 +1,34 @@
1
+ export interface AuditOptions {
2
+ /** Local project directory to analyze statically (and serve, if it has a static build). */
3
+ path?: string;
4
+ /** Rendered target. Overrides any local server. */
5
+ url?: string;
6
+ /** Shell command that starts a dev server. Passing it is the authorization to run project code. */
7
+ serve?: string;
8
+ /** Development mode: allows --url to point at loopback and private addresses. */
9
+ dev: boolean;
10
+ /** Test seam: replaces the network policy for the crawl. Never set from the CLI. */
11
+ policyOverride?: import('./security/net-policy.js').NetworkPolicy;
12
+ port: number;
13
+ maxRoutes: number;
14
+ navTimeoutMs: number;
15
+ pageTimeoutMs: number;
16
+ serverTimeoutMs: number;
17
+ outDir: string;
18
+ formats: ('html' | 'json')[];
19
+ screenshots: boolean;
20
+ interact: boolean;
21
+ staticOnly: boolean;
22
+ respectRobots: boolean;
23
+ ignoreRules: string[];
24
+ routes: string[];
25
+ /** Exit 1 when the slop score is above this. */
26
+ failAbove?: number;
27
+ ai: boolean;
28
+ model?: string;
29
+ modelEndpoint: string;
30
+ modelTimeoutMs: number;
31
+ onProgress?: (message: string) => void;
32
+ }
33
+ export declare const DEFAULTS: Omit<AuditOptions, 'outDir'>;
34
+ export declare function loadConfigFile(dir: string | undefined): Partial<AuditOptions>;
package/dist/config.js ADDED
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export const DEFAULTS = {
4
+ dev: false,
5
+ port: 4173,
6
+ maxRoutes: 10,
7
+ navTimeoutMs: 30_000,
8
+ pageTimeoutMs: 20_000,
9
+ serverTimeoutMs: 60_000,
10
+ formats: ['html', 'json'],
11
+ screenshots: true,
12
+ interact: true,
13
+ staticOnly: false,
14
+ respectRobots: true,
15
+ ignoreRules: [],
16
+ routes: [],
17
+ ai: true,
18
+ modelEndpoint: 'http://127.0.0.1:11434',
19
+ modelTimeoutMs: 120_000,
20
+ };
21
+ /** Keys accepted in samey.config.json. Anything else is rejected so typos surface. */
22
+ const FILE_KEYS = new Set(['maxRoutes', 'navTimeoutMs', 'pageTimeoutMs', 'serverTimeoutMs', 'ignoreRules', 'routes', 'failAbove', 'model', 'modelEndpoint', 'serve', 'port', 'interact', 'respectRobots', 'screenshots']);
23
+ export function loadConfigFile(dir) {
24
+ if (!dir)
25
+ return {};
26
+ const file = path.join(dir, 'samey.config.json');
27
+ if (!existsSync(file))
28
+ return {};
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
32
+ }
33
+ catch (err) {
34
+ throw new Error(`Could not parse ${file}: ${err.message}`);
35
+ }
36
+ const unknown = Object.keys(parsed).filter((k) => !FILE_KEYS.has(k) && k !== '$schema');
37
+ if (unknown.length)
38
+ throw new Error(`Unknown key${unknown.length > 1 ? 's' : ''} in samey.config.json: ${unknown.join(', ')}`);
39
+ return parsed;
40
+ }
@@ -0,0 +1,6 @@
1
+ export { runAudit, type AuditResult } from './audit.js';
2
+ export { DEFAULTS, type AuditOptions } from './config.js';
3
+ export { allRules, runRules } from './rules/index.js';
4
+ export { scoreFindings, prioritize } from './score.js';
5
+ export { scanRepo } from './static/scan.js';
6
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { runAudit } from './audit.js';
2
+ export { DEFAULTS } from './config.js';
3
+ export { allRules, runRules } from './rules/index.js';
4
+ export { scoreFindings, prioritize } from './score.js';
5
+ export { scanRepo } from './static/scan.js';
@@ -0,0 +1,34 @@
1
+ import type { Browser } from 'playwright';
2
+ import { type NetworkPolicy } from '../security/net-policy.js';
3
+ import type { CrawlResult, ViewportName } from '../types.js';
4
+ import { type NetworkLimits } from './network.js';
5
+ export interface CrawlOptions {
6
+ startUrl: string;
7
+ maxRoutes: number;
8
+ navTimeoutMs: number;
9
+ pageTimeoutMs: number;
10
+ seeds: string[];
11
+ shotsDir?: string;
12
+ interact: boolean;
13
+ respectRobots: boolean;
14
+ policy: NetworkPolicy;
15
+ limits?: NetworkLimits;
16
+ onProgress?: (message: string) => void;
17
+ }
18
+ export declare const VIEWPORTS: Record<ViewportName, {
19
+ width: number;
20
+ height: number;
21
+ isMobile: boolean;
22
+ }>;
23
+ export declare class BrowserMissingError extends Error {
24
+ }
25
+ /**
26
+ * In remote mode every request is served from Node by the network guard. Anything Chromium
27
+ * tries to send on its own (preconnects, WebRTC) is pointed at a closed local port so it
28
+ * cannot reach the network.
29
+ */
30
+ export declare function launchBrowser(policy: NetworkPolicy): Promise<Browser>;
31
+ /** Canonical key for a URL: origin + path without trailing slash, no query or hash. */
32
+ export declare function urlKey(u: URL): string;
33
+ export declare function normalizeLink(href: string, base: string): URL | null;
34
+ export declare function crawl(browser: Browser, opts: CrawlOptions): Promise<CrawlResult>;