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 +30 -0
- package/bin/kryptheon.js +390 -0
- package/kryptheon-changes.js +222 -0
- package/kryptheon-code.js +564 -0
- package/kryptheon-dashboard-page.js +1003 -0
- package/kryptheon-dashboard.html +446 -0
- package/kryptheon-dashboard.js +532 -0
- package/kryptheon-logo.png +0 -0
- package/kryptheon-look.js +259 -0
- package/kryptheon-store.js +15 -0
- package/package.json +16 -3
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// One look at a project: what changed since the last look, which checks that
|
|
2
|
+
// calls for, run the ones that can run, and what is new, fixed or merely
|
|
3
|
+
// deleted. Shared by `npx kryptheon verify` and the dashboard's own watch, so
|
|
4
|
+
// the command and the page can never tell two different stories.
|
|
5
|
+
//
|
|
6
|
+
// Printing is not done here; the caller decides how to say it.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const store = require('./kryptheon-store.js');
|
|
11
|
+
const changes = require('./kryptheon-changes.js');
|
|
12
|
+
const dash = require('./kryptheon-dashboard.js');
|
|
13
|
+
|
|
14
|
+
function readJson(file) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Looks once. `runners` does the work that needs the caller's tools:
|
|
24
|
+
* runners.frontend() -> { ran, why } (the code read)
|
|
25
|
+
* runners.regression() -> exit code, or null (replaying the recordings)
|
|
26
|
+
* `options.replay` false keeps the recordings from being replayed - the
|
|
27
|
+
* dashboard's automatic look does that by default, because a replay while the
|
|
28
|
+
* app is not running reports every flow broken, and that would be a lie.
|
|
29
|
+
*/
|
|
30
|
+
function look(root, runners, options) {
|
|
31
|
+
const opts = options || {};
|
|
32
|
+
const paths = store.pathsFor(root);
|
|
33
|
+
const previous = readJson(paths.snapshot);
|
|
34
|
+
const now = changes.takeSnapshot(root, previous);
|
|
35
|
+
const diff = changes.compare(previous, now);
|
|
36
|
+
const before = dash.buildState(root);
|
|
37
|
+
const enabled = dash.readConfig(paths).enabled;
|
|
38
|
+
const available = new Set(dash.CHECKS.filter((c) => c.available).map((c) => c.id));
|
|
39
|
+
|
|
40
|
+
// On a first look, every check that can run, so the next look has
|
|
41
|
+
// something to compare against. `force` adds checks that must run whatever
|
|
42
|
+
// changed - a re-check has to run the check that found the problem, even
|
|
43
|
+
// when the fix was made somewhere that check would not otherwise cover.
|
|
44
|
+
const wanted = (diff.first ? ['frontend', 'regression', 'data'] : diff.checks).slice();
|
|
45
|
+
for (const id of opts.force || []) if (!wanted.includes(id)) wanted.push(id);
|
|
46
|
+
const plan = wanted.map((id) => {
|
|
47
|
+
if (!available.has(id)) return { id: id, state: 'not available yet' };
|
|
48
|
+
if (!enabled[id]) return { id: id, state: 'switched off in the dashboard' };
|
|
49
|
+
if (id === 'data') return { id: id, state: 'needs your database - run npx kryptheon-night' };
|
|
50
|
+
if (id === 'regression' && !before.recordings) return { id: id, state: 'no recordings yet - npx kryptheon record <url>' };
|
|
51
|
+
if (id === 'regression' && opts.replay === false) return { id: id, state: 'not replayed automatically - press Run now when your app is running' };
|
|
52
|
+
return { id: id, state: 'run', run: true };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// What changed is said before anything runs: a replay can take a minute,
|
|
56
|
+
// and the person should not sit through it without knowing why.
|
|
57
|
+
if (typeof runners.beforeRun === 'function') runners.beforeRun(diff, now);
|
|
58
|
+
|
|
59
|
+
let regressionExit = null;
|
|
60
|
+
for (const step of plan.filter((s) => s.run)) {
|
|
61
|
+
if (step.id === 'frontend') {
|
|
62
|
+
const r = runners.frontend();
|
|
63
|
+
step.state = r.ran ? 'ran' : 'could not run: ' + r.why;
|
|
64
|
+
} else if (step.id === 'regression') {
|
|
65
|
+
regressionExit = runners.regression();
|
|
66
|
+
step.state = regressionExit === 0 ? 'ran - every recording held'
|
|
67
|
+
: regressionExit === 1 ? 'ran - something broke'
|
|
68
|
+
: 'could not run (exit ' + regressionExit + ')';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const after = dash.buildState(root);
|
|
73
|
+
const fresh = changes.newFindings(before.findings, after.findings);
|
|
74
|
+
const gone = changes.goneFindings(before.findings, after.findings);
|
|
75
|
+
// A finding whose file was deleted is gone, not fixed - the same rule as a
|
|
76
|
+
// dropped table in the database check. Kept apart, so deleting the code a
|
|
77
|
+
// finding points at is never mistaken for making it safe.
|
|
78
|
+
const fileOf = (f) => path.join(root, String(f.where || '').replace(/:\d+$/, ''));
|
|
79
|
+
const removed = gone.filter((f) => f.check !== 'data' && !fs.existsSync(fileOf(f)));
|
|
80
|
+
const fixed = gone.filter((f) => removed.indexOf(f) === -1);
|
|
81
|
+
const openConfirmed = after.findings.filter((f) => f.status === 'confirmed');
|
|
82
|
+
|
|
83
|
+
const event = {
|
|
84
|
+
at: now.takenAt,
|
|
85
|
+
by: opts.by || 'verify',
|
|
86
|
+
first: diff.first,
|
|
87
|
+
files: diff.files,
|
|
88
|
+
parts: diff.parts,
|
|
89
|
+
dependencies: diff.dependencies,
|
|
90
|
+
checks: plan.map((s) => ({ id: s.id, state: s.state })),
|
|
91
|
+
newFindings: fresh.length,
|
|
92
|
+
fixed: fixed.length,
|
|
93
|
+
goneWithFile: removed.length,
|
|
94
|
+
openConfirmed: openConfirmed.length,
|
|
95
|
+
};
|
|
96
|
+
let saveError = null;
|
|
97
|
+
try {
|
|
98
|
+
fs.mkdirSync(paths.dir, { recursive: true });
|
|
99
|
+
fs.writeFileSync(paths.snapshot, JSON.stringify(now) + '\n', 'utf8');
|
|
100
|
+
fs.appendFileSync(paths.changes, JSON.stringify(event) + '\n', 'utf8');
|
|
101
|
+
} catch (err) {
|
|
102
|
+
saveError = err.message;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
diff: diff,
|
|
107
|
+
snapshot: now,
|
|
108
|
+
plan: plan,
|
|
109
|
+
fresh: fresh,
|
|
110
|
+
fixed: fixed,
|
|
111
|
+
removed: removed,
|
|
112
|
+
openConfirmed: openConfirmed,
|
|
113
|
+
after: after,
|
|
114
|
+
regressionExit: regressionExit,
|
|
115
|
+
event: event,
|
|
116
|
+
saveError: saveError,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether anything changed since the last look, without looking. Cheap enough
|
|
122
|
+
* to poll: unchanged files keep their hash without being read. The signature
|
|
123
|
+
* carries each changed file's content hash, so a file still being written
|
|
124
|
+
* keeps changing it - which is what tells a pause from the end of a task.
|
|
125
|
+
*/
|
|
126
|
+
function pending(root) {
|
|
127
|
+
const paths = store.pathsFor(root);
|
|
128
|
+
const previous = readJson(paths.snapshot);
|
|
129
|
+
if (!previous) return { changed: false, first: true };
|
|
130
|
+
const now = changes.takeSnapshot(root, previous);
|
|
131
|
+
const diff = changes.compare(previous, now);
|
|
132
|
+
const signature = JSON.stringify(diff.files.map((f) => [f.path, f.state, now.files[f.path] ? now.files[f.path].hash : null]));
|
|
133
|
+
return { changed: diff.files.length > 0, files: diff.files.length, signature: signature };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Looks by itself whenever the project changes, and waits for the change to
|
|
138
|
+
* settle first. An AI tool writes a dozen files over half a minute; looking
|
|
139
|
+
* after the first one would report a half-finished task as broken. So a look
|
|
140
|
+
* happens once the changed files have stayed the same for `quietMs`.
|
|
141
|
+
*
|
|
142
|
+
* Recorded flows are not replayed here unless `replay` is true: an automatic
|
|
143
|
+
* replay while the app is not running reports every flow broken.
|
|
144
|
+
*/
|
|
145
|
+
function startWatch(root, runners, options) {
|
|
146
|
+
const opts = options || {};
|
|
147
|
+
const intervalMs = opts.intervalMs || 2000;
|
|
148
|
+
const quietMs = opts.quietMs || 4000;
|
|
149
|
+
let seen = null;
|
|
150
|
+
let since = 0;
|
|
151
|
+
let busy = false;
|
|
152
|
+
const doLook = () => {
|
|
153
|
+
busy = true;
|
|
154
|
+
try {
|
|
155
|
+
const result = look(root, runners, { by: 'watch', replay: opts.replay === true });
|
|
156
|
+
if (typeof opts.onLook === 'function') opts.onLook(result);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (typeof opts.onError === 'function') opts.onError(err);
|
|
159
|
+
} finally {
|
|
160
|
+
busy = false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const tick = () => {
|
|
164
|
+
if (busy) return;
|
|
165
|
+
const p = pending(root);
|
|
166
|
+
// Nothing to compare against yet: this first look is the starting point.
|
|
167
|
+
if (p.first) return doLook();
|
|
168
|
+
if (!p.changed) {
|
|
169
|
+
seen = null;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (p.signature !== seen) {
|
|
173
|
+
seen = p.signature;
|
|
174
|
+
since = Date.now();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (Date.now() - since >= quietMs) doLook();
|
|
178
|
+
};
|
|
179
|
+
const timer = setInterval(tick, intervalMs);
|
|
180
|
+
if (timer.unref && opts.unref) timer.unref();
|
|
181
|
+
// Now, not one interval from now: a change made in the first seconds after
|
|
182
|
+
// the page opened would otherwise land in the starting point and never be
|
|
183
|
+
// seen as a change at all.
|
|
184
|
+
tick();
|
|
185
|
+
return {
|
|
186
|
+
tick: tick,
|
|
187
|
+
stop() {
|
|
188
|
+
clearInterval(timer);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// What a broken flow says when the app itself did not answer - which is not
|
|
194
|
+
// evidence either way about the fix.
|
|
195
|
+
const APP_DID_NOT_ANSWER = /could not be opened|took too long to load|net::ERR|ECONNREFUSED/i;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The second half of the loop: someone says a finding is fixed; prove it.
|
|
199
|
+
*
|
|
200
|
+
* The check that found it is run again - always, whatever else changed - and
|
|
201
|
+
* so is every check the changes since the last look call for, so a fix that
|
|
202
|
+
* breaks something else is caught in the same step. The verdict:
|
|
203
|
+
*
|
|
204
|
+
* fixed the check ran, and the finding is not there
|
|
205
|
+
* still open the check ran, and the finding is still there
|
|
206
|
+
* gone with its file the file it pointed at was deleted - not the same as fixed
|
|
207
|
+
* could not confirm the check did not run, or the app did not answer
|
|
208
|
+
*
|
|
209
|
+
* A fix is never assumed from a changed file. Every attempt is kept.
|
|
210
|
+
*/
|
|
211
|
+
function recheck(root, runners, id, options) {
|
|
212
|
+
const paths = store.pathsFor(root);
|
|
213
|
+
const target = dash.buildState(root).findings.find((f) => f.id === id);
|
|
214
|
+
if (!target) return { found: false };
|
|
215
|
+
|
|
216
|
+
const result = look(root, runners, { by: 'recheck', force: [target.check], replay: true });
|
|
217
|
+
const own = result.plan.find((s) => s.id === target.check);
|
|
218
|
+
const still = result.after.findings.find((f) => f.id === id);
|
|
219
|
+
let verdict;
|
|
220
|
+
let why = '';
|
|
221
|
+
if (!own || !/^ran/.test(own.state)) {
|
|
222
|
+
verdict = 'could not confirm';
|
|
223
|
+
why = own ? own.state : 'the check that found it did not run';
|
|
224
|
+
} else if (still && target.check === 'regression' && APP_DID_NOT_ANSWER.test(still.detail || '')) {
|
|
225
|
+
verdict = 'could not confirm';
|
|
226
|
+
why = 'your app did not answer while the flow was replayed - start it and re-check';
|
|
227
|
+
} else if (still) {
|
|
228
|
+
verdict = 'still open';
|
|
229
|
+
why = still.detail || '';
|
|
230
|
+
} else if (result.removed.some((f) => f.id === id)) {
|
|
231
|
+
verdict = 'gone with its file';
|
|
232
|
+
why = 'the file it pointed at was deleted, which is not the same as fixing it';
|
|
233
|
+
} else {
|
|
234
|
+
verdict = 'fixed';
|
|
235
|
+
}
|
|
236
|
+
// Problems that appeared with this fix. The finding itself is never one of
|
|
237
|
+
// them: it was there before, so it cannot be new.
|
|
238
|
+
const newProblems = result.fresh.map((f) => ({
|
|
239
|
+
id: f.id, check: f.check, severity: f.severity, status: f.status, where: f.where, headline: f.headline,
|
|
240
|
+
}));
|
|
241
|
+
const attempt = {
|
|
242
|
+
id: id,
|
|
243
|
+
at: new Date().toISOString(),
|
|
244
|
+
check: target.check,
|
|
245
|
+
where: target.where,
|
|
246
|
+
headline: target.headline,
|
|
247
|
+
verdict: verdict,
|
|
248
|
+
why: why,
|
|
249
|
+
newProblems: newProblems,
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
fs.appendFileSync(paths.fixes, JSON.stringify(attempt) + '\n', 'utf8');
|
|
253
|
+
} catch (err) {
|
|
254
|
+
attempt.saveError = err.message;
|
|
255
|
+
}
|
|
256
|
+
return Object.assign({ found: true, target: target, look: result }, attempt);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = { look: look, pending: pending, startWatch: startWatch, recheck: recheck };
|
package/kryptheon-store.js
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
// history.jsonl was kryptheon-history.jsonl
|
|
15
15
|
// test-results/ was test-results/ (Playwright's screenshots of a failure)
|
|
16
16
|
// night-last.json was .kryptheon-last.json (kryptheon-night)
|
|
17
|
+
// code-findings.json the last frontend code read (kryptheon code)
|
|
18
|
+
// config.json which checks are switched on (the dashboard)
|
|
19
|
+
// snapshot.json a hash of each project file, to tell what changed
|
|
20
|
+
// changes.jsonl one line per change Kryptheon noticed, and what it ran
|
|
21
|
+
// fixes.jsonl every re-check of a finding someone tried to fix, and its verdict
|
|
17
22
|
//
|
|
18
23
|
// Only tests/ - the recordings the person made - stays in the project.
|
|
19
24
|
//
|
|
@@ -40,6 +45,11 @@ const FILES = {
|
|
|
40
45
|
history: 'history.jsonl',
|
|
41
46
|
testResults: 'test-results',
|
|
42
47
|
nightLast: 'night-last.json',
|
|
48
|
+
codeFindings: 'code-findings.json',
|
|
49
|
+
config: 'config.json',
|
|
50
|
+
snapshot: 'snapshot.json',
|
|
51
|
+
changes: 'changes.jsonl',
|
|
52
|
+
fixes: 'fixes.jsonl',
|
|
43
53
|
};
|
|
44
54
|
|
|
45
55
|
// What older versions wrote into the project, and where each one goes now.
|
|
@@ -122,6 +132,11 @@ function pathsIn(dir) {
|
|
|
122
132
|
history: path.join(dir, FILES.history),
|
|
123
133
|
testResults: path.join(dir, FILES.testResults),
|
|
124
134
|
nightLast: path.join(dir, FILES.nightLast),
|
|
135
|
+
codeFindings: path.join(dir, FILES.codeFindings),
|
|
136
|
+
config: path.join(dir, FILES.config),
|
|
137
|
+
snapshot: path.join(dir, FILES.snapshot),
|
|
138
|
+
changes: path.join(dir, FILES.changes),
|
|
139
|
+
fixes: path.join(dir, FILES.fixes),
|
|
125
140
|
};
|
|
126
141
|
}
|
|
127
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Record what you do in your app and get plain-language reports when it breaks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,7 +31,14 @@
|
|
|
31
31
|
"kryptheon-cleanup.js",
|
|
32
32
|
"kryptheon-baselines.js",
|
|
33
33
|
"kryptheon-project.js",
|
|
34
|
-
"kryptheon-store.js"
|
|
34
|
+
"kryptheon-store.js",
|
|
35
|
+
"kryptheon-code.js",
|
|
36
|
+
"kryptheon-dashboard.js",
|
|
37
|
+
"kryptheon-dashboard.html",
|
|
38
|
+
"kryptheon-dashboard-page.js",
|
|
39
|
+
"kryptheon-logo.png",
|
|
40
|
+
"kryptheon-changes.js",
|
|
41
|
+
"kryptheon-look.js"
|
|
35
42
|
],
|
|
36
43
|
"engines": {
|
|
37
44
|
"node": ">=20.6.0"
|
|
@@ -40,8 +47,14 @@
|
|
|
40
47
|
"@playwright/test": "^1.62.1"
|
|
41
48
|
},
|
|
42
49
|
"scripts": {
|
|
43
|
-
"check": "node kryptheon-store.check.js && node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
|
|
50
|
+
"check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-dashboard.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
|
|
44
51
|
"check:store": "node kryptheon-store.check.js",
|
|
52
|
+
"check:code": "node kryptheon-code.check.js",
|
|
53
|
+
"check:dashboard": "node kryptheon-dashboard.check.js",
|
|
54
|
+
"check:changes": "node kryptheon-changes.check.js",
|
|
55
|
+
"check:look": "node kryptheon-look.check.js",
|
|
56
|
+
"check:verify": "node kryptheon-verify.check.js",
|
|
57
|
+
"check:recheck": "node kryptheon-recheck.check.js",
|
|
45
58
|
"check:reporter": "node kryptheon-reporter.check.js",
|
|
46
59
|
"check:signature": "node kryptheon-signature.check.js",
|
|
47
60
|
"check:stream": "node kryptheon-signature.stream.check.js",
|