kryptheon 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -161,6 +161,36 @@ wanted, and one new name of the same kind. A button that is gone with nothing
161
161
  in its place, or a rename next to other new buttons, fails as it always did.
162
162
  Checks you added with the toolbar are never redirected.
163
163
 
164
+ ## Dashboard
165
+
166
+ ```
167
+ npx kryptheon
168
+ ```
169
+
170
+ In a terminal, with no command, this opens a page on your own machine
171
+ (`http://127.0.0.1:4789/`) for this project: what is broken, what needs a
172
+ look, every run, recent changes, and switches for each check. `Run now`
173
+ replays your recordings or reads your code from the page. It is only
174
+ reachable from this machine; press Ctrl+C to stop it. From a script, use
175
+ `npx kryptheon dashboard`.
176
+
177
+ Checks that are not built yet are listed as "not available" - never as
178
+ passed.
179
+
180
+ ## Reading your code
181
+
182
+ ```
183
+ npx kryptheon code
184
+ ```
185
+
186
+ Reads your frontend code for values put into the page as HTML - `innerHTML`,
187
+ `insertAdjacentHTML`, `document.write`, React's `dangerouslySetInnerHTML`,
188
+ Vue's `v-html`. If such a value can hold text someone typed, a `<` in it can
189
+ run script with the signed-in person's session. Nothing is run; every result
190
+ is "verification required", ranked HIGH when the value comes from a server
191
+ response or an error message, MEDIUM when the code alone cannot say. The fix
192
+ prompt for each one names the file, line and value.
193
+
164
194
  ## What goes where
165
195
 
166
196
  Only your recordings go in your project, in `tests/`. Everything Kryptheon
package/bin/kryptheon.js CHANGED
@@ -85,6 +85,10 @@ function usage() {
85
85
  console.log(' npx kryptheon accept <name> agree that one test\'s new result is the correct one');
86
86
  console.log(' npx kryptheon remove [name] list the recordings, or remove one you no longer want');
87
87
  console.log(' npx kryptheon setup-ai tell your AI assistant to check its work');
88
+ console.log(' npx kryptheon verify after a change: what changed, what broke, what got fixed');
89
+ console.log(' npx kryptheon recheck [id] prove a fix: run the check again, and say FIXED or not');
90
+ console.log(' npx kryptheon code read your frontend code for text inserted as HTML');
91
+ console.log(' npx kryptheon dashboard open this project\'s dashboard (also: npx kryptheon, in a terminal)');
88
92
  console.log('');
89
93
  console.log(' add --quiet to check for one line when everything passes');
90
94
  console.log(' npx kryptheon --version print the version you have installed');
@@ -1984,6 +1988,377 @@ function setupAi() {
1984
1988
  return results.some((r) => r.action === 'failed') ? 1 : 0;
1985
1989
  }
1986
1990
 
1991
+ // --- code -------------------------------------------------------------------
1992
+ //
1993
+ // A read of the project's own frontend code for text inserted as HTML
1994
+ // (kryptheon-code.js). Nothing is run and nothing is sent; every finding is
1995
+ // "verification required", so the exit code is 0 unless the read could not
1996
+ // happen at all - a script must never take an unproven risk for a break.
1997
+
1998
+ // How many fix prompts are printed in full. The rest are saved in the store
1999
+ // and shown by the dashboard; forty prompts in a terminal is a wall nobody reads.
2000
+ const CODE_PROMPTS_SHOWN = 5;
2001
+
2002
+ // The read itself, saved to the store. Shared by the command and the
2003
+ // dashboard's "Run now", so both leave the same record behind.
2004
+ function readCodeAndSave(opened) {
2005
+ const reader = require(path.join(PACKAGE_DIR, 'kryptheon-code.js'));
2006
+ const result = reader.scanProject(USER_DIR, { packageDir: PACKAGE_DIR });
2007
+ if (!result.ran) return result;
2008
+ try {
2009
+ fs.writeFileSync(opened.codeFindings, JSON.stringify({
2010
+ checkedAt: new Date().toISOString(),
2011
+ filesRead: result.filesRead,
2012
+ findings: result.findings,
2013
+ unreadable: result.unreadable,
2014
+ }, null, 2) + String.fromCharCode(10), 'utf8');
2015
+ } catch (err) {
2016
+ result.saveError = err.message;
2017
+ }
2018
+ return result;
2019
+ }
2020
+
2021
+ function codeRead() {
2022
+ const here = project.inspectProject(USER_DIR);
2023
+ if (!here.ok) {
2024
+ for (const line of project.noProjectLines(here)) console.error(line);
2025
+ return 1;
2026
+ }
2027
+ const opened = openStore();
2028
+ if (!opened) return 1;
2029
+
2030
+ const result = readCodeAndSave(opened);
2031
+ if (!result.ran) {
2032
+ console.error('');
2033
+ console.error(' I could not read your code this time: ' + result.why + '.');
2034
+ console.error(' Nothing was checked - this is not a pass.');
2035
+ console.error('');
2036
+ return 2;
2037
+ }
2038
+ if (result.saveError) console.error(' (could not save this read: ' + result.saveError + ')');
2039
+
2040
+ const high = result.findings.filter((f) => f.severity === 'HIGH');
2041
+ const medium = result.findings.filter((f) => f.severity !== 'HIGH');
2042
+ console.log('');
2043
+ console.log(' I read ' + result.filesRead + ' file' + (result.filesRead === 1 ? '' : 's') + ' of your app\'s code.');
2044
+ if (!result.findings.length) {
2045
+ console.log(' I found no place where text that could come from outside the page is');
2046
+ console.log(' inserted as HTML. That is what this read looks for, and only that.');
2047
+ } else {
2048
+ console.log(' ' + result.findings.length + ' place' + (result.findings.length === 1 ? '' : 's') +
2049
+ ' where text may be inserted as HTML (' + high.length + ' HIGH, ' + medium.length + ' MEDIUM).');
2050
+ console.log(' I did not run anything, so each one needs a look before it is called a bug.');
2051
+ for (const f of high.concat(medium)) {
2052
+ console.log('');
2053
+ console.log(' ' + f.severity + ' ' + f.file + ':' + f.line + ' (verification required)');
2054
+ console.log(' ' + f.headline);
2055
+ console.log(' ' + f.sink + ': ' + f.expression);
2056
+ }
2057
+ const prompted = high.slice(0, CODE_PROMPTS_SHOWN);
2058
+ if (prompted.length) {
2059
+ console.log('');
2060
+ console.log(' ' + '-'.repeat(68));
2061
+ console.log(' For the HIGH ones, paste this into your AI tool - one at a time:');
2062
+ for (const f of prompted) {
2063
+ console.log('');
2064
+ f.fixPrompt.split(String.fromCharCode(10)).forEach((l) => console.log(' ' + l));
2065
+ }
2066
+ }
2067
+ if (result.findings.length > prompted.length) {
2068
+ console.log('');
2069
+ console.log(' The fix for every one is saved in:');
2070
+ console.log(' ' + opened.codeFindings);
2071
+ }
2072
+ }
2073
+ if (result.unreadable.length) {
2074
+ console.log('');
2075
+ console.log(' What I could not read (unknown, not clear):');
2076
+ for (const u of result.unreadable) console.log(' ' + u.file + ' - ' + u.why);
2077
+ }
2078
+ console.log('');
2079
+ return 0;
2080
+ }
2081
+
2082
+ // --- verify -----------------------------------------------------------------
2083
+ //
2084
+ // The command for the end of an AI task: what changed since Kryptheon last
2085
+ // looked, which checks that calls for, run the ones that can run here, and
2086
+ // say what broke and what got fixed. "The AI said done" is not the answer;
2087
+ // this is. The looking itself is kryptheon-look.js, shared with the dashboard.
2088
+
2089
+ function verify() {
2090
+ const here = project.inspectProject(USER_DIR);
2091
+ if (!here.ok) {
2092
+ for (const line of project.noProjectLines(here)) console.error(line);
2093
+ return 1;
2094
+ }
2095
+ const opened = openStore();
2096
+ if (!opened) return 1;
2097
+ const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
2098
+
2099
+ const result = looker.look(USER_DIR, {
2100
+ beforeRun: (diff, now) => {
2101
+ console.log('');
2102
+ if (diff.first) {
2103
+ console.log(' First look at this project: ' + Object.keys(now.files).length + ' files noted.');
2104
+ console.log(' Next time, I will say exactly what changed since now.');
2105
+ } else if (!diff.files.length) {
2106
+ console.log(' Nothing changed since the last look.');
2107
+ } else {
2108
+ console.log(' WHAT CHANGED ' + diff.files.length + ' file' + (diff.files.length === 1 ? '' : 's') + ', in: ' + diff.parts.join(', '));
2109
+ for (const part of diff.parts) {
2110
+ const inPart = diff.files.filter((f) => f.part === part);
2111
+ console.log('');
2112
+ console.log(' ' + part);
2113
+ for (const f of inPart.slice(0, 15)) console.log(' ' + f.state.padEnd(9) + f.path);
2114
+ if (inPart.length > 15) console.log(' ... and ' + (inPart.length - 15) + ' more');
2115
+ if (part === 'dependencies') {
2116
+ for (const d of diff.dependencies) {
2117
+ console.log(' package ' + d.name + ': ' + (d.change === 'added' ? 'added ' + d.to : d.change === 'removed' ? 'removed (was ' + d.from + ')' : d.from + ' -> ' + d.to));
2118
+ }
2119
+ }
2120
+ }
2121
+ }
2122
+ },
2123
+ frontend: () => readCodeAndSave(opened),
2124
+ regression: () => {
2125
+ console.log('');
2126
+ console.log(' Replaying your recordings...');
2127
+ const r = spawnSync(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2128
+ cwd: USER_DIR,
2129
+ stdio: 'inherit',
2130
+ env: process.env,
2131
+ });
2132
+ return r.status;
2133
+ },
2134
+ }, { by: 'verify' });
2135
+
2136
+ const { diff, plan, fresh, fixed, removed, openConfirmed, after } = result;
2137
+ console.log('');
2138
+ if (plan.length) {
2139
+ console.log(' CHECKS');
2140
+ for (const step of plan) console.log(' ' + step.id.padEnd(12) + step.state);
2141
+ } else if (!diff.first && diff.files.length) {
2142
+ console.log(' CHECKS none apply to this change (' + diff.parts.join(', ') + ').');
2143
+ }
2144
+
2145
+ console.log('');
2146
+ if (diff.first) {
2147
+ console.log(' STARTING POINT ' + after.findings.length + ' finding' + (after.findings.length === 1 ? '' : 's') +
2148
+ ' already here (see npx kryptheon dashboard). Only what changes from now on will be called new.');
2149
+ } else {
2150
+ if (fresh.length) {
2151
+ console.log(' WHAT BROKE ' + fresh.length + ' new');
2152
+ for (const f of fresh) {
2153
+ console.log(' ' + f.severity + ' ' + f.status + ' ' + f.where);
2154
+ console.log(' ' + f.headline);
2155
+ }
2156
+ } else {
2157
+ console.log(' WHAT BROKE nothing new that the checks above could see.');
2158
+ }
2159
+ if (fixed.length) {
2160
+ console.log('');
2161
+ console.log(' WHAT GOT FIXED ' + fixed.length);
2162
+ for (const f of fixed) console.log(' ' + f.where + ' ' + f.headline);
2163
+ }
2164
+ if (removed.length) {
2165
+ console.log('');
2166
+ console.log(' GONE WITH ITS FILE ' + removed.length + ' (the file was deleted - not the same as fixed)');
2167
+ for (const f of removed) console.log(' ' + f.where + ' ' + f.headline);
2168
+ }
2169
+ }
2170
+ if (openConfirmed.length) {
2171
+ console.log('');
2172
+ console.log(' Still broken: ' + openConfirmed.length + ' confirmed problem' + (openConfirmed.length === 1 ? '' : 's') + '. The fix prompts are in the dashboard.');
2173
+ }
2174
+ console.log('');
2175
+ if (result.saveError) console.error(' (could not save this look: ' + result.saveError + ')');
2176
+
2177
+ // Non-zero while anything confirmed is broken - new or not - so an AI tool
2178
+ // reading only the exit code is never told its work left things fine when
2179
+ // they are not. Findings that only need a look do not count.
2180
+ return openConfirmed.length || result.regressionExit === 1 ? 1 : 0;
2181
+ }
2182
+
2183
+ // --- recheck ----------------------------------------------------------------
2184
+ //
2185
+ // The end of the fix loop: prove a fix. The finding's own check runs again,
2186
+ // and so does everything the changes since call for, so "fixed" never means
2187
+ // "the code changed", and a fix that broke something else says so.
2188
+
2189
+ function replayForLoop() {
2190
+ console.log('');
2191
+ console.log(' Replaying your recordings...');
2192
+ const r = spawnSync(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2193
+ cwd: USER_DIR,
2194
+ stdio: 'inherit',
2195
+ env: process.env,
2196
+ });
2197
+ return r.status;
2198
+ }
2199
+
2200
+ const VERDICT_WORDS = {
2201
+ fixed: 'FIXED',
2202
+ 'still open': 'STILL OPEN',
2203
+ 'gone with its file': 'GONE WITH ITS FILE (not the same as fixed)',
2204
+ 'could not confirm': 'COULD NOT CONFIRM',
2205
+ };
2206
+
2207
+ function recheckCommand(id) {
2208
+ const here = project.inspectProject(USER_DIR);
2209
+ if (!here.ok) {
2210
+ for (const line of project.noProjectLines(here)) console.error(line);
2211
+ return 1;
2212
+ }
2213
+ const opened = openStore();
2214
+ if (!opened) return 1;
2215
+ const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
2216
+ const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
2217
+
2218
+ if (!id) {
2219
+ const open = dash.buildState(USER_DIR).findings;
2220
+ console.log('');
2221
+ if (!open.length) {
2222
+ console.log(' Nothing is open. There is nothing to re-check.');
2223
+ } else {
2224
+ console.log(' Open findings - re-check one after fixing it: npx kryptheon recheck <id>');
2225
+ console.log('');
2226
+ for (const f of open) console.log(' ' + f.id + ' ' + f.severity.padEnd(8) + f.status.padEnd(23) + f.where + ' ' + f.headline);
2227
+ }
2228
+ console.log('');
2229
+ return 0;
2230
+ }
2231
+
2232
+ const r = looker.recheck(USER_DIR, { frontend: () => readCodeAndSave(opened), regression: replayForLoop }, String(id).trim());
2233
+ console.log('');
2234
+ if (!r.found) {
2235
+ console.error(' There is no open finding with the id ' + id + '.');
2236
+ console.error(' npx kryptheon recheck, on its own, lists the ones that are open.');
2237
+ console.error('');
2238
+ return 1;
2239
+ }
2240
+ console.log(' RE-CHECK ' + r.id + ' ' + r.where);
2241
+ console.log(' ' + r.headline);
2242
+ console.log('');
2243
+ console.log(' VERDICT ' + VERDICT_WORDS[r.verdict] + (r.why ? ' - ' + r.why : ''));
2244
+ if (r.newProblems.length) {
2245
+ console.log('');
2246
+ console.log(' NEW PROBLEMS ' + r.newProblems.length + ' appeared with this change:');
2247
+ for (const p of r.newProblems) console.log(' ' + p.id + ' ' + p.severity + ' ' + p.status + ' ' + p.where + ' ' + p.headline);
2248
+ } else if (r.verdict === 'fixed') {
2249
+ console.log('');
2250
+ console.log(' Nothing new broke that the checks could see.');
2251
+ }
2252
+ console.log('');
2253
+ if (r.verdict === 'could not confirm') return 2;
2254
+ return r.verdict === 'fixed' && !r.newProblems.length ? 0 : 1;
2255
+ }
2256
+
2257
+ // --- dashboard --------------------------------------------------------------
2258
+ //
2259
+ // One page on this machine for everything Kryptheon knows about this project
2260
+ // (kryptheon-dashboard.js). Runs until Ctrl+C.
2261
+
2262
+ const DASHBOARD_PORT = 4789;
2263
+
2264
+ function runFromDashboard(id, opened) {
2265
+ if (id === 'frontend') {
2266
+ const result = readCodeAndSave(opened);
2267
+ return result.ran ? { ok: true } : { ok: false, why: result.why };
2268
+ }
2269
+ if (id === 'regression') {
2270
+ // The same command a person types, as a separate process, so the page
2271
+ // stays answerable while a browser replays the flows.
2272
+ return new Promise((resolve) => {
2273
+ const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2274
+ cwd: USER_DIR,
2275
+ stdio: 'ignore',
2276
+ env: process.env,
2277
+ });
2278
+ child.on('error', (err) => resolve({ ok: false, why: err.message }));
2279
+ // A run that found broken flows is a run that finished; its results are
2280
+ // in the history the page reads next.
2281
+ child.on('close', (code) => resolve(code === 0 || code === 1 ? { ok: true, exit: code } : { ok: false, why: 'the run stopped with exit ' + code }));
2282
+ });
2283
+ }
2284
+ return { ok: false, why: '"' + id + '" cannot be run from the dashboard' };
2285
+ }
2286
+
2287
+ async function dashboardCommand() {
2288
+ const here = project.inspectProject(USER_DIR);
2289
+ if (!here.ok) {
2290
+ for (const line of project.noProjectLines(here)) console.error(line);
2291
+ return 1;
2292
+ }
2293
+ const opened = openStore();
2294
+ if (!opened) return 1;
2295
+ const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
2296
+ const app = dash.createServer(USER_DIR, {
2297
+ run: (id) => runFromDashboard(id, opened),
2298
+ // The same `recheck` a person types, in its own process, so the page stays
2299
+ // answerable while a flow is replayed. Its verdict is read from the store.
2300
+ recheck: (id) => new Promise((resolve) => {
2301
+ // Only a verdict written by this re-check counts: an older one for the
2302
+ // same finding, read after a child that failed early, would be a stale
2303
+ // answer shown as a fresh one.
2304
+ const startedAt = new Date().toISOString();
2305
+ const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'recheck', id], {
2306
+ cwd: USER_DIR,
2307
+ stdio: 'ignore',
2308
+ env: process.env,
2309
+ });
2310
+ child.on('error', (err) => resolve({ ok: false, why: err.message }));
2311
+ child.on('close', () => {
2312
+ let last = null;
2313
+ try {
2314
+ const lines = fs.readFileSync(opened.fixes, 'utf8').split(String.fromCharCode(10)).filter(Boolean);
2315
+ for (let i = lines.length - 1; i >= 0 && !last; i--) {
2316
+ const a = JSON.parse(lines[i]);
2317
+ if (a.id === id && a.at >= startedAt) last = a;
2318
+ }
2319
+ } catch (err) {
2320
+ last = null;
2321
+ }
2322
+ resolve(last || { ok: false, why: 'the re-check did not record a verdict' });
2323
+ });
2324
+ }),
2325
+ });
2326
+ let port;
2327
+ try {
2328
+ port = await app.listen(DASHBOARD_PORT);
2329
+ } catch (err) {
2330
+ // Taken - by another project's dashboard, most likely. Any free port will do.
2331
+ port = await app.listen(0);
2332
+ }
2333
+ console.log('');
2334
+ console.log(' Kryptheon dashboard for ' + path.basename(USER_DIR) + ':');
2335
+ console.log('');
2336
+ console.log(' http://127.0.0.1:' + port + '/');
2337
+ console.log('');
2338
+ console.log(' Open that address in your browser. It is only reachable from this');
2339
+ console.log(' machine. Press Ctrl+C here to stop it.');
2340
+ console.log('');
2341
+
2342
+ // While the page is open, Kryptheon notices changes by itself: once a change
2343
+ // has settled, it looks, runs the fast checks, and the page shows what
2344
+ // changed and what came of it. Recordings are replayed only from the page's
2345
+ // Run now - a replay while the app is down would call every flow broken.
2346
+ const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
2347
+ looker.startWatch(USER_DIR, {
2348
+ frontend: () => readCodeAndSave(opened),
2349
+ regression: () => null,
2350
+ }, {
2351
+ onLook: (r) => {
2352
+ if (r.diff.first) return;
2353
+ const when = new Date().toLocaleTimeString();
2354
+ console.log(' ' + when + ' ' + r.diff.files.length + ' file' + (r.diff.files.length === 1 ? '' : 's') + ' changed (' +
2355
+ r.diff.parts.join(', ') + ') - ' + r.fresh.length + ' new, ' + r.fixed.length + ' fixed. See the page.');
2356
+ },
2357
+ onError: (err) => console.error(' (could not look at a change: ' + err.message + ')'),
2358
+ });
2359
+ return new Promise(() => {});
2360
+ }
2361
+
1987
2362
  // --- accept -----------------------------------------------------------------
1988
2363
 
1989
2364
  function baselineApi() {
@@ -2243,12 +2618,27 @@ async function main() {
2243
2618
  case 'accept':
2244
2619
  return finishWith(accept(rest.join(' ').trim()));
2245
2620
  break;
2621
+ case 'code':
2622
+ return finishWith(codeRead());
2623
+ case 'verify':
2624
+ return finishWith(verify());
2625
+ case 'recheck':
2626
+ return finishWith(recheckCommand(rest[0]));
2246
2627
  case '-v':
2247
2628
  case '--version':
2248
2629
  case 'version':
2249
2630
  console.log(packageVersion());
2250
2631
  return finishWith(0);
2632
+ case 'dashboard':
2633
+ return finishWith(await dashboardCommand());
2251
2634
  case undefined:
2635
+ // One command starts Kryptheon - in a terminal a person is looking at.
2636
+ // Run by a script or an AI tool there is nobody to open a page for, and
2637
+ // a server that never exits would hang it, so it prints the usage as it
2638
+ // always did.
2639
+ if (process.stdin.isTTY && process.stdout.isTTY) return finishWith(await dashboardCommand());
2640
+ usage();
2641
+ return finishWith(0);
2252
2642
  case '-h':
2253
2643
  case '--help':
2254
2644
  case 'help':
@@ -0,0 +1,222 @@
1
+ // What changed in a project since Kryptheon last looked, and what that means
2
+ // for which checks have to run.
3
+ //
4
+ // A snapshot is a content hash of every file that is the project's own - its
5
+ // source, its database migrations, its dependency list - kept in the store,
6
+ // never in the project. Comparing two snapshots answers the first question a
7
+ // person has after an AI tool says "done": what did it actually touch?
8
+ //
9
+ // Content, not timestamps and not git: an editor that saves a file unchanged
10
+ // is not a change, and a landing page with no git repository still has one.
11
+ //
12
+ // Each changed file is put in a part of the app (frontend, backend, database,
13
+ // tests, dependencies, config, docs), and each part names the checks that
14
+ // can speak to it. The mapping is written here, once, and deliberately
15
+ // plain: a path-based guess that is right for how most projects are laid
16
+ // out, and said as a guess ("likely affected") rather than a proof.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ const SKIP_DIRS = new Set([
23
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit', 'coverage',
24
+ '.vercel', '.netlify', '.turbo', '.cache', 'test-results', 'playwright-report', '.kryptheon',
25
+ ]);
26
+ const MAX_BYTES = 5 * 1024 * 1024;
27
+
28
+ // Where a file sits in the app. The first rule that matches wins, so the
29
+ // specific ones come first: a .sql file inside backend/ is a database change.
30
+ const PARTS = [
31
+ { part: 'dependencies', test: (p) => /(^|\/)(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|requirements\.txt|poetry\.lock|Gemfile\.lock|go\.sum)$/i.test(p) },
32
+ { part: 'database', test: (p) => /\.sql$/i.test(p) || /(^|\/)(migrations|supabase|prisma|drizzle|db\/schema)(\/|$)/i.test(p) || /(^|\/)schema\.prisma$/i.test(p) },
33
+ { part: 'tests', test: (p) => /(^|\/)(tests?|__tests__|e2e|cypress)\//i.test(p) || /\.(spec|test)\.[cm]?[jt]sx?$/i.test(p) },
34
+ { part: 'docs', test: (p) => /\.(md|mdx|txt|rst)$/i.test(p) || /(^|\/)docs\//i.test(p) },
35
+ { part: 'config', test: (p) => /(^|\/)(\.env\.example|\.env\.sample|tsconfig[^/]*\.json|jsconfig\.json|vercel\.json|netlify\.toml|dockerfile|docker-compose[^/]*\.ya?ml)$/i.test(p) || /\.config\.[cm]?[jt]s$/i.test(p) || /(^|\/)\.github\//i.test(p) },
36
+ { part: 'backend', test: (p) => /(^|\/)(server|backend|api|routes|controllers|services|middleware|functions|workers|jobs|lib\/server)(\/|\.[cm]?[jt]s$)/i.test(p) || /(^|\/)(server|app|index)\.[cm]?[jt]s$/i.test(p) && !/(^|\/)(src|public|frontend|client|pages|components)\//i.test(p) },
37
+ { part: 'frontend', test: (p) => /\.(html?|css|scss|sass|less|jsx|tsx|vue|svelte|astro)$/i.test(p) || /(^|\/)(frontend|client|public|src|pages|components|app|views|static)\//i.test(p) && /\.[cm]?[jt]s$/i.test(p) },
38
+ { part: 'assets', test: (p) => /\.(png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|mp4|webm|mp3|pdf)$/i.test(p) },
39
+ ];
40
+
41
+ // Which checks can speak to a change in each part. Ids are the dashboard's.
42
+ const CHECKS_FOR = {
43
+ frontend: ['frontend', 'regression'],
44
+ backend: ['backend', 'api', 'regression'],
45
+ database: ['data', 'regression'],
46
+ dependencies: ['dependency', 'build', 'regression'],
47
+ tests: ['regression'],
48
+ config: ['build', 'regression'],
49
+ code: ['regression'],
50
+ docs: [],
51
+ assets: [],
52
+ };
53
+
54
+ function partOf(rel) {
55
+ const p = rel.split(path.sep).join('/');
56
+ const hit = PARTS.find((r) => r.test(p));
57
+ if (hit) return hit.part;
58
+ return /\.[cm]?[jt]sx?$/i.test(p) ? 'code' : 'other';
59
+ }
60
+
61
+ /** Every file that is the project's own, as relative paths with forward slashes. */
62
+ function listFiles(root) {
63
+ const out = [];
64
+ (function walk(dir) {
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(dir, { withFileTypes: true });
68
+ } catch (err) {
69
+ return;
70
+ }
71
+ for (const entry of entries) {
72
+ const full = path.join(dir, entry.name);
73
+ if (entry.isDirectory()) {
74
+ if (!SKIP_DIRS.has(entry.name)) walk(full);
75
+ } else if (entry.isFile()) {
76
+ // A real .env holds secrets; its content is never hashed or kept.
77
+ if (/^\.env(\..+)?$/i.test(entry.name) && !/\.(example|sample)$/i.test(entry.name)) continue;
78
+ out.push(path.relative(root, full).split(path.sep).join('/'));
79
+ }
80
+ }
81
+ })(root);
82
+ return out.sort();
83
+ }
84
+
85
+ /** The dependency lists of package.json, kept so a change can say which package moved. */
86
+ function dependenciesOf(root) {
87
+ try {
88
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
89
+ return Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
90
+ } catch (err) {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * The project as it is now. Files whose size and modified time match the
97
+ * previous snapshot keep its hash without being read again - on a large
98
+ * project that is the difference between milliseconds and seconds, and an
99
+ * unchanged size and time is the same bytes for every editor that exists.
100
+ */
101
+ function takeSnapshot(root, previous) {
102
+ const before = (previous && previous.files) || {};
103
+ const files = {};
104
+ for (const rel of listFiles(root)) {
105
+ let stat;
106
+ try {
107
+ stat = fs.statSync(path.join(root, rel));
108
+ } catch (err) {
109
+ continue;
110
+ }
111
+ const old = before[rel];
112
+ if (old && old.size === stat.size && old.mtimeMs === stat.mtimeMs) {
113
+ files[rel] = old;
114
+ continue;
115
+ }
116
+ let hash;
117
+ if (stat.size > MAX_BYTES) {
118
+ hash = 'size:' + stat.size + ':' + stat.mtimeMs;
119
+ } else {
120
+ try {
121
+ hash = crypto.createHash('sha1').update(fs.readFileSync(path.join(root, rel))).digest('hex');
122
+ } catch (err) {
123
+ continue;
124
+ }
125
+ }
126
+ files[rel] = { hash: hash, size: stat.size, mtimeMs: stat.mtimeMs };
127
+ }
128
+ return { takenAt: new Date().toISOString(), files: files, dependencies: dependenciesOf(root) };
129
+ }
130
+
131
+ /** Packages added, removed or moved to another version between two snapshots. */
132
+ function dependencyChanges(before, after) {
133
+ const a = (before && before.dependencies) || {};
134
+ const b = (after && after.dependencies) || {};
135
+ const out = [];
136
+ for (const name of new Set(Object.keys(a).concat(Object.keys(b)))) {
137
+ if (!(name in a)) out.push({ name: name, change: 'added', to: b[name] });
138
+ else if (!(name in b)) out.push({ name: name, change: 'removed', from: a[name] });
139
+ else if (a[name] !== b[name]) out.push({ name: name, change: 'changed', from: a[name], to: b[name] });
140
+ }
141
+ return out.sort((x, y) => x.name.localeCompare(y.name));
142
+ }
143
+
144
+ /**
145
+ * What changed between two snapshots, by part of the app, and which checks
146
+ * that calls for. With no earlier snapshot there is nothing to compare, and
147
+ * that is said - it is not reported as "everything changed".
148
+ */
149
+ function compare(before, after) {
150
+ if (!before) return { first: true, files: [], parts: [], checks: [], dependencies: [] };
151
+ const files = [];
152
+ const a = before.files || {};
153
+ const b = after.files || {};
154
+ for (const rel of Object.keys(b)) {
155
+ if (!a[rel]) files.push({ path: rel, state: 'added' });
156
+ else if (a[rel].hash !== b[rel].hash) files.push({ path: rel, state: 'modified' });
157
+ }
158
+ for (const rel of Object.keys(a)) if (!b[rel]) files.push({ path: rel, state: 'deleted' });
159
+ files.sort((x, y) => x.path.localeCompare(y.path));
160
+ for (const f of files) f.part = partOf(f.path);
161
+
162
+ const parts = [];
163
+ for (const f of files) if (!parts.includes(f.part)) parts.push(f.part);
164
+ const checks = [];
165
+ for (const part of parts) for (const id of CHECKS_FOR[part] || []) if (!checks.includes(id)) checks.push(id);
166
+ return {
167
+ first: false,
168
+ files: files,
169
+ parts: parts,
170
+ checks: checks,
171
+ dependencies: dependencyChanges(before, after),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Findings that were not there last time. Keyed by what they are about rather
177
+ * than by line number: a line added above a known finding moves it, and a
178
+ * moved finding reported as new would teach a person to ignore this list.
179
+ */
180
+ //
181
+ // The detail is part of what a finding is only for the code read, where it is
182
+ // the expression itself. For a recorded flow or a database finding it is the
183
+ // account of how it broke this time - "ended up somewhere different", "could
184
+ // not be opened" - and a flow that is still broken in a new way is the same
185
+ // finding, not a fixed one and a new one.
186
+ function findingKey(f) {
187
+ return [
188
+ f.check || '',
189
+ f.where ? String(f.where).replace(/:\d+$/, '') : '',
190
+ f.headline ? String(f.headline).replace(/:\d+\b/g, '') : '',
191
+ f.check === 'frontend' ? f.detail || '' : '',
192
+ ].join('|');
193
+ }
194
+
195
+ /** A short, stable name for a finding: what a person types to re-check it. */
196
+ function findingId(f) {
197
+ return crypto.createHash('sha1').update(findingKey(f)).digest('hex').slice(0, 8);
198
+ }
199
+
200
+ function newFindings(before, after) {
201
+ const seen = new Set((before || []).map(findingKey));
202
+ return (after || []).filter((f) => !seen.has(findingKey(f)));
203
+ }
204
+
205
+ function goneFindings(before, after) {
206
+ const now = new Set((after || []).map(findingKey));
207
+ return (before || []).filter((f) => !now.has(findingKey(f)));
208
+ }
209
+
210
+ module.exports = {
211
+ PARTS: PARTS,
212
+ CHECKS_FOR: CHECKS_FOR,
213
+ partOf: partOf,
214
+ listFiles: listFiles,
215
+ takeSnapshot: takeSnapshot,
216
+ compare: compare,
217
+ dependencyChanges: dependencyChanges,
218
+ findingKey: findingKey,
219
+ findingId: findingId,
220
+ newFindings: newFindings,
221
+ goneFindings: goneFindings,
222
+ };