testguard-cli 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.
@@ -0,0 +1,182 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { repoRoot as gitRoot, headSha, isDirty } from '../git.mjs';
5
+ import { createScratch, inPlace, PreconditionError } from './worktree.mjs';
6
+ import { applyFault, locate } from './inject.mjs';
7
+ import * as vitest from './runner-vitest.mjs';
8
+ import { classify, shouldStopEarly } from './classify.mjs';
9
+ import { blastRadius, rank } from './rank.mjs';
10
+ import { hashFile, sha256 } from '../util/hash.mjs';
11
+ import { fingerprint } from '../../spec/lib/fingerprint.mjs';
12
+
13
+ const isKill = (r) => r.outcome === 'fail' && r.assertionFailures > 0;
14
+
15
+ function runnerVersion(projectDir) {
16
+ try {
17
+ return JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve('vitest/package.json'), 'utf8')).version;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Probe every fault of every claim. Returns a spec-conformant evidence
25
+ * document; writing it is the caller's job.
26
+ */
27
+ export async function probe({
28
+ projectDir,
29
+ claims,
30
+ confirmRuns = 3,
31
+ mode = 'worktree',
32
+ ref = 'HEAD',
33
+ budgetMs = 120_000,
34
+ escalate = true,
35
+ scratchBase,
36
+ toolVersion = '0.0.0',
37
+ previous,
38
+ onProgress = () => {},
39
+ }) {
40
+ projectDir = resolve(projectDir);
41
+ const root = gitRoot(projectDir);
42
+ if (mode === 'in-place' && ref !== 'HEAD') throw new PreconditionError('--ref needs a scratch worktree; drop --in-place');
43
+ const head = headSha(root, ref);
44
+ if (!head) throw new PreconditionError(ref === 'HEAD' ? 'repository has no commits; every verdict is tied to a commit' : `ref ${ref} does not resolve to a commit`);
45
+
46
+ const targets = [...new Set(claims.claims.flatMap((c) => c.faults.map((f) => relative(root, join(projectDir, f.file)))))];
47
+ if (mode === 'in-place' && isDirty(root, targets)) {
48
+ throw new PreconditionError(`uncommitted changes in target files (${targets.join(', ')}); commit or stash first, or drop --in-place`);
49
+ }
50
+
51
+ const startedAt = new Date().toISOString();
52
+ const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref, scratchBase }) : inPlace({ repoRoot: root, projectDir });
53
+ const records = [];
54
+ try {
55
+ const allTests = vitest.listTestFiles(iso.projectDir);
56
+ const baselineCache = new Map();
57
+ const prior = previous && previous.run.confirmRuns === confirmRuns
58
+ ? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
59
+ : new Map();
60
+ const runDefenders = (files) => vitest.runVitest({ projectDir: iso.projectDir, files, budgetMs });
61
+
62
+ for (const claim of claims.claims) {
63
+ const defenders = vitest.resolveDefenders(iso.projectDir, claim.defendedBy);
64
+ for (const fault of claim.faults) {
65
+ const record = await probeOne({ claim, fault, defenders, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id });
66
+ records.push(record);
67
+ onProgress(record);
68
+ }
69
+ }
70
+ } finally {
71
+ iso.cleanup();
72
+ }
73
+
74
+ return {
75
+ schemaVersion: 1,
76
+ tool: { name: 'testguard', version: toolVersion },
77
+ run: {
78
+ id: `run-${startedAt.replace(/[-:.]/g, '').slice(0, 15)}`,
79
+ startedAt,
80
+ finishedAt: new Date().toISOString(),
81
+ repo: { head, dirty: isDirty(root) },
82
+ runner: { name: vitest.name, ...(runnerVersion(projectDir) ? { version: runnerVersion(projectDir) } : {}) },
83
+ confirmRuns,
84
+ mode,
85
+ },
86
+ records,
87
+ };
88
+ }
89
+
90
+ async function probeOne({ claim, fault, defenders, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, prior, priorRunId }) {
91
+ const targetPath = join(iso.projectDir, fault.file);
92
+ const targetExists = existsSync(targetPath);
93
+ const inputs = {
94
+ targetHash: targetExists ? hashFile(targetPath) : sha256(''),
95
+ defenderHashes: Object.fromEntries(defenders.map((f) => [f, hashFile(join(iso.projectDir, f))])),
96
+ };
97
+
98
+ // Same source, same defenders, same N: the verdict cannot have changed.
99
+ if (prior && sameInputs(prior, inputs, claim.defendedBy ?? [], defenders)) {
100
+ return { ...prior, reusedFrom: prior.reusedFrom ?? priorRunId };
101
+ }
102
+
103
+ const detail = { baselineRuns: [], probeRuns: [] };
104
+ const rawProbeRuns = []; // spec testRun + the runner's timeout count, which classify needs
105
+ let anchor = null;
106
+
107
+ if (defenders.length > 0) {
108
+ anchor = targetExists ? locate(readFileSync(targetPath, 'utf8'), fault) : { status: 'file-missing', hits: 0, expected: fault.expectHits ?? 1 };
109
+
110
+ if (anchor.status === 'ok') {
111
+ const key = defenders.join('\n');
112
+ if (!baselineCache.has(key)) {
113
+ const runs = [];
114
+ for (let i = 0; i < confirmRuns; i++) {
115
+ const { run } = await runDefenders(defenders);
116
+ runs.push(run);
117
+ if (run.outcome !== 'pass') break;
118
+ }
119
+ baselineCache.set(key, runs);
120
+ }
121
+ detail.baselineRuns = baselineCache.get(key);
122
+
123
+ if (detail.baselineRuns.every((r) => r.outcome === 'pass') && detail.baselineRuns.length === confirmRuns) {
124
+ const mutation = applyFault(iso.projectDir, fault);
125
+ try {
126
+ const probeRuns = rawProbeRuns;
127
+ for (let i = 0; i < confirmRuns; i++) {
128
+ const { run, timeouts, loadMessage } = await runDefenders(defenders);
129
+ probeRuns.push({ ...run, timeouts, loadMessage });
130
+ if (shouldStopEarly(probeRuns)) break;
131
+ }
132
+ detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, ...run }) => run);
133
+ const provisional = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns, confirmRuns });
134
+
135
+ // Escalation: does anything *undeclared* catch it? A single run cannot
136
+ // say — a flaky test elsewhere in the suite would take the credit — so
137
+ // a test is an undeclared killer only if it fails in all N runs.
138
+ const broader = allTests.filter((t) => !defenders.includes(t));
139
+ if (provisional.verdict === 'survived' && escalate && broader.length > 0) {
140
+ const runs = [];
141
+ let killers = null;
142
+ for (let i = 0; i < confirmRuns; i++) {
143
+ const { run, failedTests } = await runDefenders(allTests);
144
+ runs.push(run);
145
+ killers = killers === null ? new Set(failedTests) : new Set(failedTests.filter((t) => killers.has(t)));
146
+ if (killers.size === 0) break;
147
+ }
148
+ detail.escalated = true;
149
+ detail.escalationRuns = runs;
150
+ if (killers.size > 0 && runs.length === confirmRuns && runs.every(isKill)) detail.reason = 'killed-by-undeclared-tests';
151
+ }
152
+ } finally {
153
+ mutation.restore();
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ const { verdict, reason } = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns: rawProbeRuns, confirmRuns });
160
+ if (reason && !detail.reason) detail.reason = reason;
161
+
162
+ const blast = targetExists ? blastRadius(iso.projectDir, fault.file) : 0;
163
+
164
+ return {
165
+ fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict }),
166
+ claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
167
+ subject: { kind: 'fault', id: fault.id, description: fault.description, file: fault.file, faultClass: fault.faultClass, producedBy: fault.producedBy },
168
+ verdict,
169
+ detail,
170
+ defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0 },
171
+ inputs,
172
+ rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast }),
173
+ };
174
+ }
175
+
176
+ function sameInputs(prior, inputs, requested, resolved) {
177
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
178
+ return prior.inputs.targetHash === inputs.targetHash
179
+ && same(prior.inputs.defenderHashes, inputs.defenderHashes)
180
+ && same(prior.defenders.requested, requested)
181
+ && same(prior.defenders.resolved, resolved);
182
+ }
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve, extname } from 'node:path';
3
+ import { walk } from '../util/glob.mjs';
4
+
5
+ const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx']);
6
+ const IMPORT_RE = /(?:from\s*|import\s*\(?\s*|require\s*\(\s*)['"]([^'"]+)['"]/g;
7
+ const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
8
+
9
+ const SEVERITY_WEIGHT = { critical: 8, high: 4, medium: 2, low: 1 };
10
+ // A claim written by the same agent that wrote the tests carries less
11
+ // independent evidence than one from a human-governed spec.
12
+ const SOURCE_WEIGHT = { spec: 1, adr: 1, manual: 0.9, annotation: 0.75, comment: 0.75 };
13
+
14
+ function resolvesTo(fromFile, specifier, targetAbs) {
15
+ if (!specifier.startsWith('.')) return false;
16
+ const base = resolve(dirname(fromFile), specifier);
17
+ const candidates = [base, ...[...SOURCE_EXT].map((e) => base + e), ...[...SOURCE_EXT].map((e) => join(base, 'index' + e))];
18
+ const stripped = base.replace(/\.(js|mjs|cjs)$/, '');
19
+ candidates.push(stripped + '.ts', stripped + '.mts', stripped + '.tsx');
20
+ return candidates.includes(targetAbs);
21
+ }
22
+
23
+ /** Number of non-test source files that import `targetRel` (direct imports only, documented as such). */
24
+ export function blastRadius(projectDir, targetRel) {
25
+ const targetAbs = resolve(projectDir, targetRel);
26
+ let count = 0;
27
+ for (const rel of walk(projectDir)) {
28
+ if (!SOURCE_EXT.has(extname(rel)) || TEST_RE.test(rel) || rel === targetRel) continue;
29
+ const abs = join(projectDir, rel);
30
+ let src;
31
+ try {
32
+ src = readFileSync(abs, 'utf8');
33
+ } catch {
34
+ continue;
35
+ }
36
+ for (const m of src.matchAll(IMPORT_RE)) {
37
+ if (resolvesTo(abs, m[1], targetAbs)) {
38
+ count++;
39
+ break;
40
+ }
41
+ }
42
+ }
43
+ return count;
44
+ }
45
+
46
+ /** Additive ordering only. Never touches the verdict. */
47
+ export function rank({ severity, sourceKind, blast }) {
48
+ const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast));
49
+ return { score: Number(score.toFixed(3)), blastRadius: blast, tier: severity };
50
+ }
51
+
@@ -0,0 +1,85 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { matchGlobs } from '../util/glob.mjs';
7
+
8
+ const TEST_GLOBS = ['**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx',
9
+ '**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx'];
10
+
11
+ export const name = 'vitest';
12
+
13
+ /** Defender globs → existing files. Empty result is the `nocover` signal. */
14
+ export const resolveDefenders = (projectDir, globs) => matchGlobs(projectDir, globs ?? []);
15
+
16
+ export const listTestFiles = (projectDir) => matchGlobs(projectDir, TEST_GLOBS);
17
+
18
+ /**
19
+ * Turn vitest's JSON report into a spec `testRun`.
20
+ *
21
+ * `assertionFailures` counts test-level failures that are neither timeouts nor
22
+ * load failures — the test body ran and rejected the behaviour. Only those
23
+ * can kill a fault. A suite that fails to load, or a test that times out, is
24
+ * not evidence that the suite defends the claim.
25
+ */
26
+ export function parseReport(report, durationMs) {
27
+ const files = report.testResults ?? [];
28
+ const loadFailed = files.some((f) => f.status === 'failed' && (f.assertionResults?.length ?? 0) === 0);
29
+ const results = files.flatMap((f) => (f.assertionResults ?? []).map((t) => ({ ...t, id: `${f.name}::${t.fullName ?? t.title ?? ''}` }))).filter((t) => t.status === 'failed');
30
+ const timeouts = results.filter((t) => (t.failureMessages ?? []).some((m) => /timed out/i.test(m))).length;
31
+ const failedTests = results.map((t) => t.id);
32
+ const assertionFailures = results.length - timeouts;
33
+ const tests = { total: report.numTotalTests ?? 0, passed: report.numPassedTests ?? 0, failed: report.numFailedTests ?? 0 };
34
+
35
+ let outcome;
36
+ if (loadFailed || tests.total === 0) outcome = 'error';
37
+ else if (report.success) outcome = 'pass';
38
+ else outcome = 'fail';
39
+
40
+ const run = { outcome, tests, assertionFailures, durationMs };
41
+ const loadMessage = loadFailed ? (files.find((f) => f.message)?.message ?? '').split('\n')[0] : undefined;
42
+ return { run, timeouts, loadMessage, failedTests };
43
+ }
44
+
45
+ /**
46
+ * Run vitest on `files` inside `projectDir` with a hard wall-clock budget.
47
+ * The budget matters because a synchronous infinite loop is immune to
48
+ * vitest's own test timeout; only killing the process ends it.
49
+ */
50
+ export function runVitest({ projectDir, files, budgetMs = 120_000, command }) {
51
+ const outFile = join(tmpdir(), `testguard-vitest-${randomBytes(6).toString('hex')}.json`);
52
+ const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
53
+ const [cmd, ...args] = command ?? [npx, 'vitest', 'run', ...files, '--reporter=json', `--outputFile=${outFile}`];
54
+ const started = Date.now();
55
+
56
+ return new Promise((resolve) => {
57
+ const child = spawn(cmd, args, { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1', FORCE_COLOR: '0' } });
58
+ let stderr = '';
59
+ child.stderr.on('data', (d) => (stderr += d));
60
+ let killed = false;
61
+ const timer = setTimeout(() => {
62
+ killed = true;
63
+ child.kill('SIGKILL');
64
+ }, budgetMs);
65
+
66
+ child.on('close', () => {
67
+ clearTimeout(timer);
68
+ const durationMs = Date.now() - started;
69
+ let result;
70
+ if (killed) {
71
+ result = { run: { outcome: 'timeout', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 1, loadMessage: `budget of ${budgetMs}ms exceeded`, failedTests: [] };
72
+ } else if (!existsSync(outFile)) {
73
+ result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: stderr.trim().split('\n').filter(Boolean).slice(-1)[0] ?? 'runner produced no report', failedTests: [] };
74
+ } else {
75
+ try {
76
+ result = parseReport(JSON.parse(readFileSync(outFile, 'utf8')), durationMs);
77
+ } catch (e) {
78
+ result = { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs }, timeouts: 0, loadMessage: `unreadable report: ${e.message}`, failedTests: [] };
79
+ }
80
+ }
81
+ rmSync(outFile, { force: true });
82
+ resolve(result);
83
+ });
84
+ });
85
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync, mkdtempSync, readdirSync, symlinkSync, mkdirSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, relative, dirname } from 'node:path';
4
+ import { addWorktree, removeWorktree, headSha } from '../git.mjs';
5
+
6
+ export class PreconditionError extends Error {}
7
+
8
+ /**
9
+ * Find every node_modules directory in the main tree (to a shallow depth) so
10
+ * the scratch worktree can borrow them instead of reinstalling.
11
+ */
12
+ function findNodeModules(root, depth = 3) {
13
+ const found = [];
14
+ const visit = (dir, rel, d) => {
15
+ let entries;
16
+ try {
17
+ entries = readdirSync(dir, { withFileTypes: true });
18
+ } catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ if (!e.isDirectory()) continue;
23
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
24
+ if (e.name === 'node_modules') {
25
+ found.push(relPath);
26
+ } else if (d < depth && e.name !== '.git' && !e.name.startsWith('.')) {
27
+ visit(join(dir, e.name), relPath, d + 1);
28
+ }
29
+ }
30
+ };
31
+ visit(root, '', 0);
32
+ return found;
33
+ }
34
+
35
+ /**
36
+ * A scratch git worktree at HEAD, with the main tree's node_modules linked in.
37
+ * Faults are applied here; the user's tree is never touched.
38
+ */
39
+ export function createScratch({ repoRoot, projectDir, ref = 'HEAD', scratchBase = tmpdir() }) {
40
+ const sha = headSha(repoRoot, ref);
41
+ if (!sha) {
42
+ throw new PreconditionError(ref === 'HEAD' ? 'repository has no commits; commit first, or run with --in-place' : `ref ${ref} does not resolve to a commit`);
43
+ }
44
+ const dest = mkdtempSync(join(scratchBase, 'testguard-'));
45
+ addWorktree(repoRoot, dest, sha);
46
+ for (const rel of findNodeModules(repoRoot)) {
47
+ const target = join(dest, rel);
48
+ if (existsSync(target)) continue;
49
+ mkdirSync(dirname(target), { recursive: true });
50
+ symlinkSync(join(repoRoot, rel), target, 'dir');
51
+ }
52
+ return {
53
+ mode: 'worktree',
54
+ sha,
55
+ root: dest,
56
+ projectDir: join(dest, relative(repoRoot, projectDir)),
57
+ cleanup: () => removeWorktree(repoRoot, dest),
58
+ };
59
+ }
60
+
61
+ /** No isolation: mutate the user's tree and rely on restore. */
62
+ export function inPlace({ repoRoot, projectDir }) {
63
+ return { mode: 'in-place', root: repoRoot, projectDir, cleanup: () => {} };
64
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,28 @@
1
+ const ORDER = ['survived', 'nocover', 'unverifiable', 'fault-invalid', 'timeout', 'flaky-defender', 'killed'];
2
+
3
+ /** Non-passing verdicts shout; the one pass does not. */
4
+ export const formatVerdict = (v) => (v === 'killed' ? 'killed' : v.toUpperCase());
5
+
6
+ export function renderRecord(r) {
7
+ const head = `${formatVerdict(r.verdict).padEnd(15)} ${r.claim.id}/${r.subject.id}`.padEnd(38);
8
+ const why = r.detail.reason ? ` [${r.detail.reason}]` : '';
9
+ return `${head} ${r.claim.severity.padEnd(8)} ${r.subject.file} ${r.subject.description}${why}`;
10
+ }
11
+
12
+ export function summarize(records) {
13
+ const byVerdict = {};
14
+ for (const r of records) byVerdict[r.verdict] = (byVerdict[r.verdict] ?? 0) + 1;
15
+ return byVerdict;
16
+ }
17
+
18
+ export function renderSummary(records) {
19
+ const byVerdict = summarize(records);
20
+ const parts = ORDER.filter((v) => byVerdict[v]).map((v) => `${byVerdict[v]} ${formatVerdict(v)}`);
21
+ const gating = records.filter((r) => r.verdict !== 'killed').length;
22
+ return `${records.length} faults probed: ${parts.join(', ')}. ${gating} unproven claim${gating === 1 ? '' : 's'}.`;
23
+ }
24
+
25
+ /** Survivors first, then by rank score; killed last. */
26
+ export function sortForReport(records) {
27
+ return [...records].sort((a, b) => ORDER.indexOf(a.verdict) - ORDER.indexOf(b.verdict) || (b.rank?.score ?? 0) - (a.rank?.score ?? 0));
28
+ }
@@ -0,0 +1,56 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.testguard']);
5
+
6
+ /** Translate a minimal glob (`**`, `*`, `?`) into an anchored RegExp over posix paths. */
7
+ export function globToRegExp(glob) {
8
+ let re = '^';
9
+ for (let i = 0; i < glob.length; i++) {
10
+ const c = glob[i];
11
+ if (c === '*') {
12
+ if (glob[i + 1] === '*') {
13
+ i++;
14
+ if (glob[i + 1] === '/') {
15
+ i++;
16
+ re += '(?:.*/)?';
17
+ } else {
18
+ re += '.*';
19
+ }
20
+ } else {
21
+ re += '[^/]*';
22
+ }
23
+ } else if (c === '?') {
24
+ re += '[^/]';
25
+ } else if ('.+^${}()|[]\\'.includes(c)) {
26
+ re += '\\' + c;
27
+ } else {
28
+ re += c;
29
+ }
30
+ }
31
+ return new RegExp(re + '$');
32
+ }
33
+
34
+ /** Every file under `root` as a posix path relative to it, skipping tool and dependency directories. */
35
+ export function walk(root) {
36
+ const out = [];
37
+ const visit = (dir, rel) => {
38
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
39
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
40
+ if (entry.isDirectory()) {
41
+ if (!SKIP_DIRS.has(entry.name)) visit(join(dir, entry.name), relPath);
42
+ } else if (entry.isFile()) {
43
+ out.push(relPath);
44
+ }
45
+ }
46
+ };
47
+ visit(root, '');
48
+ return out.sort();
49
+ }
50
+
51
+ /** Files under `root` matching any of `globs`. A glob with no wildcard is an exact relative path. */
52
+ export function matchGlobs(root, globs) {
53
+ if (!globs || globs.length === 0) return [];
54
+ const regexps = globs.map(globToRegExp);
55
+ return walk(root).filter((f) => regexps.some((r) => r.test(f)));
56
+ }
@@ -0,0 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
5
+ export const hashFile = (path) => sha256(readFileSync(path));