kryptheon 0.1.19 → 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.
@@ -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
+ };