@veda-hq/pariksha 0.5.7 → 0.5.9
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 +206 -5
- package/baselines/claude-settings.json +14 -0
- package/dist/cli.js +109 -6
- package/dist/codec-registry.js +360 -0
- package/dist/fault-injection-registry.js +203 -0
- package/dist/git.js +40 -0
- package/dist/module-exports.js +169 -0
- package/dist/registry-ratchet.js +402 -0
- package/dist/required-settings.js +54 -20
- package/package.json +6 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// What a module exports, by its syntax tree: each exported name and whether
|
|
2
|
+
// it is a function, another value or only a type. Re-exports are followed
|
|
3
|
+
// (`export { a } from './x'`, `export *`, a local name bound by an import), so
|
|
4
|
+
// a barrel's names resolve to their declarations. Only relative specifiers are
|
|
5
|
+
// followed; a package specifier is reported as unfollowed, never guessed.
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import { isFile, readText } from "./registry-ratchet.js";
|
|
8
|
+
const EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx'];
|
|
9
|
+
/** The root-relative file a relative specifier in `from` names, as TypeScript and bundlers resolve it, or undefined. */
|
|
10
|
+
export const resolveRelative = (root, from, specifier) => {
|
|
11
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../'))
|
|
12
|
+
return undefined;
|
|
13
|
+
const base = posix.join(posix.dirname(from), specifier);
|
|
14
|
+
// `./x.js` in TypeScript source names `./x.ts`.
|
|
15
|
+
const stem = base.replace(/\.(?:[cm]?js|jsx)$/, '');
|
|
16
|
+
const candidates = [base, ...EXTENSIONS.map((e) => `${stem}${e}`), ...EXTENSIONS.map((e) => `${base}/index${e}`)];
|
|
17
|
+
return candidates.find((c) => !c.startsWith('../') && isFile(root, c));
|
|
18
|
+
};
|
|
19
|
+
/** Whether a variable's initializer makes it a function: an arrow or a function expression, through parentheses and `as`/`satisfies`. */
|
|
20
|
+
const isFunctionInit = (ts, init) => {
|
|
21
|
+
let e = init;
|
|
22
|
+
while (e !== undefined && (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)))
|
|
23
|
+
e = e.expression;
|
|
24
|
+
return e !== undefined && (ts.isArrowFunction(e) || ts.isFunctionExpression(e));
|
|
25
|
+
};
|
|
26
|
+
const hasExport = (ts, node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
27
|
+
const hasDefault = (ts, node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
28
|
+
/** The exports of the module at `file` (root-relative), following relative re-exports. */
|
|
29
|
+
export const moduleExports = (ts, root, file) => {
|
|
30
|
+
const unfollowed = [];
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
const visiting = new Set();
|
|
33
|
+
const exportsOf = (at) => {
|
|
34
|
+
const cached = cache.get(at);
|
|
35
|
+
if (cached !== undefined)
|
|
36
|
+
return cached;
|
|
37
|
+
const out = new Map();
|
|
38
|
+
// A cycle reads as no further exports; the outer visit fills the rest.
|
|
39
|
+
if (visiting.has(at))
|
|
40
|
+
return out;
|
|
41
|
+
visiting.add(at);
|
|
42
|
+
const source = readText(root, at) ?? '';
|
|
43
|
+
const tree = ts.createSourceFile(at, source, ts.ScriptTarget.Latest, true);
|
|
44
|
+
const locals = new Map();
|
|
45
|
+
const follow = (specifier) => {
|
|
46
|
+
const target = resolveRelative(root, at, specifier);
|
|
47
|
+
if (target === undefined)
|
|
48
|
+
unfollowed.push(`${at}: '${specifier}'`);
|
|
49
|
+
return target;
|
|
50
|
+
};
|
|
51
|
+
/** What a local name is, following an import to the module it came from. */
|
|
52
|
+
const kindOfLocal = (name) => {
|
|
53
|
+
const local = locals.get(name);
|
|
54
|
+
if (local === undefined)
|
|
55
|
+
return 'unresolved';
|
|
56
|
+
if ('kind' in local)
|
|
57
|
+
return local.kind;
|
|
58
|
+
const target = local.from ?? follow(local.specifier);
|
|
59
|
+
return target === undefined ? 'unresolved' : (exportsOf(target).get(local.name) ?? 'unresolved');
|
|
60
|
+
};
|
|
61
|
+
const stars = [];
|
|
62
|
+
const named = [];
|
|
63
|
+
for (const statement of tree.statements) {
|
|
64
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
65
|
+
if (statement.name !== undefined)
|
|
66
|
+
locals.set(statement.name.text, { kind: 'function' });
|
|
67
|
+
// `export default function` exports `default`, a function, named or not.
|
|
68
|
+
if (hasExport(ts, statement) && hasDefault(ts, statement))
|
|
69
|
+
out.set('default', 'function');
|
|
70
|
+
else if (hasExport(ts, statement) && statement.name !== undefined)
|
|
71
|
+
out.set(statement.name.text, 'function');
|
|
72
|
+
}
|
|
73
|
+
else if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
74
|
+
// `export default <expression>`: a function expression, or a local resolved once every local is known.
|
|
75
|
+
const e = statement.expression;
|
|
76
|
+
if (isFunctionInit(ts, e))
|
|
77
|
+
out.set('default', 'function');
|
|
78
|
+
else if (ts.isIdentifier(e))
|
|
79
|
+
named.push(() => out.set('default', kindOfLocal(e.text)));
|
|
80
|
+
else
|
|
81
|
+
out.set('default', 'value');
|
|
82
|
+
}
|
|
83
|
+
else if (ts.isVariableStatement(statement)) {
|
|
84
|
+
for (const d of statement.declarationList.declarations) {
|
|
85
|
+
if (!ts.isIdentifier(d.name))
|
|
86
|
+
continue;
|
|
87
|
+
const kind = isFunctionInit(ts, d.initializer) ? 'function' : 'value';
|
|
88
|
+
locals.set(d.name.text, { kind });
|
|
89
|
+
if (hasExport(ts, statement))
|
|
90
|
+
out.set(d.name.text, kind);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) {
|
|
94
|
+
// An enum always has a name; a default class may not.
|
|
95
|
+
const name = statement.name?.text;
|
|
96
|
+
if (name !== undefined)
|
|
97
|
+
locals.set(name, { kind: 'value' });
|
|
98
|
+
if (hasExport(ts, statement) && hasDefault(ts, statement))
|
|
99
|
+
out.set('default', 'value');
|
|
100
|
+
else if (hasExport(ts, statement) && name !== undefined)
|
|
101
|
+
out.set(name, 'value');
|
|
102
|
+
}
|
|
103
|
+
else if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
|
104
|
+
locals.set(statement.name.text, { kind: 'type' });
|
|
105
|
+
if (hasExport(ts, statement))
|
|
106
|
+
out.set(statement.name.text, 'type');
|
|
107
|
+
}
|
|
108
|
+
else if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.importClause !== undefined) {
|
|
109
|
+
const clause = statement.importClause;
|
|
110
|
+
const specifier = statement.moduleSpecifier.text;
|
|
111
|
+
// A default import is the module's `default` export, followed like a named one.
|
|
112
|
+
if (clause.name !== undefined)
|
|
113
|
+
locals.set(clause.name.text, clause.isTypeOnly ? { kind: 'type' } : { from: undefined, specifier, name: 'default' });
|
|
114
|
+
const bindings = clause.namedBindings;
|
|
115
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
116
|
+
locals.set(bindings.name.text, { kind: clause.isTypeOnly ? 'type' : 'value' });
|
|
117
|
+
if (bindings !== undefined && ts.isNamedImports(bindings)) {
|
|
118
|
+
for (const el of bindings.elements) {
|
|
119
|
+
locals.set(el.name.text, clause.isTypeOnly || el.isTypeOnly ? { kind: 'type' } : { from: undefined, specifier, name: (el.propertyName ?? el.name).text });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (ts.isExportDeclaration(statement)) {
|
|
124
|
+
const spec = statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : undefined;
|
|
125
|
+
const clause = statement.exportClause;
|
|
126
|
+
if (clause === undefined) {
|
|
127
|
+
if (spec !== undefined && !statement.isTypeOnly)
|
|
128
|
+
stars.push(spec);
|
|
129
|
+
}
|
|
130
|
+
else if (ts.isNamespaceExport(clause)) {
|
|
131
|
+
out.set(clause.name.text, statement.isTypeOnly ? 'type' : 'value');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
for (const el of clause.elements) {
|
|
135
|
+
const exported = el.name.text;
|
|
136
|
+
const original = (el.propertyName ?? el.name).text;
|
|
137
|
+
if (statement.isTypeOnly || el.isTypeOnly) {
|
|
138
|
+
out.set(exported, 'type');
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Resolved after every local is known: an export list may precede the declarations it names.
|
|
142
|
+
named.push(() => {
|
|
143
|
+
if (spec === undefined)
|
|
144
|
+
out.set(exported, kindOfLocal(original));
|
|
145
|
+
else {
|
|
146
|
+
const target = follow(spec);
|
|
147
|
+
out.set(exported, target === undefined ? 'unresolved' : (exportsOf(target).get(original) ?? 'unresolved'));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const resolve of named)
|
|
155
|
+
resolve();
|
|
156
|
+
for (const spec of stars) {
|
|
157
|
+
const target = follow(spec);
|
|
158
|
+
if (target === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
for (const [name, kind] of exportsOf(target))
|
|
161
|
+
if (name !== 'default' && !out.has(name))
|
|
162
|
+
out.set(name, kind);
|
|
163
|
+
}
|
|
164
|
+
visiting.delete(at);
|
|
165
|
+
cache.set(at, out);
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
return { exports: exportsOf(file), unfollowed: [...new Set(unfollowed)] };
|
|
169
|
+
};
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// The engine the registry ratchets share (codec-registry, fault-injection-registry).
|
|
2
|
+
//
|
|
3
|
+
// A registry ratchet holds three things to account. Discovery finds every
|
|
4
|
+
// item that owes a suite. The config registers an item with its suite, or
|
|
5
|
+
// exempts it with a reason. The baseline lists the items still owed, and only
|
|
6
|
+
// shrinks: an entry the merge base did not have is growth, unless its file
|
|
7
|
+
// only moved. Everything here is the shared part: config validation helpers,
|
|
8
|
+
// the baseline's shape, the audit, the growth check and the suite wiring a
|
|
9
|
+
// runner must collect.
|
|
10
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { isAbsolute, join, posix } from 'node:path';
|
|
12
|
+
import { fileAt, mergeBaseOf, renamesSince } from "./git.js";
|
|
13
|
+
export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
14
|
+
/**
|
|
15
|
+
* A normalized relative POSIX path that stays inside the root. A backslash
|
|
16
|
+
* (a separator to Windows path operations, so `..\x` could leave the root) and
|
|
17
|
+
* a NUL are refused before any host path operation sees the path.
|
|
18
|
+
*/
|
|
19
|
+
export const isRepoPath = (p) => !/[\\\0]/.test(p) && !isAbsolute(p) && !posix.isAbsolute(p) && posix.normalize(p) === p && p !== '.' && p !== '..' && !p.startsWith('../') && !p.endsWith('/');
|
|
20
|
+
/**
|
|
21
|
+
* An empty dictionary with no prototype, for keys a config or a file names: a
|
|
22
|
+
* key such as `__proto__` or `constructor` is then an entry like any other,
|
|
23
|
+
* never the prototype or an inherited member.
|
|
24
|
+
*/
|
|
25
|
+
export const dict = () => Object.create(null);
|
|
26
|
+
/** Throws naming the first key of `raw` that `allowed` lacks. */
|
|
27
|
+
export const onlyKeys = (raw, allowed, where) => {
|
|
28
|
+
const extra = Object.keys(raw).find((k) => !allowed.includes(k));
|
|
29
|
+
if (extra !== undefined)
|
|
30
|
+
throw new Error(`${where}unknown key "${extra}"`);
|
|
31
|
+
};
|
|
32
|
+
/** The object at `raw[key]`; throws when it is not one. */
|
|
33
|
+
export const object = (raw, key, where) => {
|
|
34
|
+
const v = raw[key];
|
|
35
|
+
if (!isRecord(v))
|
|
36
|
+
throw new Error(`${where}"${key}" must be an object`);
|
|
37
|
+
return v;
|
|
38
|
+
};
|
|
39
|
+
/** A required non-blank string. */
|
|
40
|
+
export const text = (raw, key, where) => {
|
|
41
|
+
const v = raw[key];
|
|
42
|
+
if (typeof v !== 'string' || v.trim() === '')
|
|
43
|
+
throw new Error(`${where}"${key}" must be a non-empty string`);
|
|
44
|
+
return v;
|
|
45
|
+
};
|
|
46
|
+
/** An optional string, non-blank when present. */
|
|
47
|
+
export const optionalText = (raw, key, where) => (raw[key] === undefined ? undefined : text(raw, key, where));
|
|
48
|
+
/** A required root-relative path. */
|
|
49
|
+
export const path = (raw, key, where) => {
|
|
50
|
+
const v = text(raw, key, where);
|
|
51
|
+
if (!isRepoPath(v))
|
|
52
|
+
throw new Error(`${where}"${key}": "${v}" is not a normalized root-relative path`);
|
|
53
|
+
return v;
|
|
54
|
+
};
|
|
55
|
+
/** A list, empty when absent; throws when present and not a list. */
|
|
56
|
+
export const list = (raw, key, where) => {
|
|
57
|
+
const v = raw[key] ?? [];
|
|
58
|
+
if (!Array.isArray(v))
|
|
59
|
+
throw new Error(`${where}"${key}" must be a list`);
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
/** A non-empty list of root-relative paths. */
|
|
63
|
+
export const paths = (raw, key, where) => {
|
|
64
|
+
const v = list(raw, key, where);
|
|
65
|
+
if (v.length === 0)
|
|
66
|
+
throw new Error(`${where}"${key}" is empty, so nothing would be read`);
|
|
67
|
+
return v.map((p, i) => {
|
|
68
|
+
if (typeof p !== 'string' || !isRepoPath(p))
|
|
69
|
+
throw new Error(`${where}${key}[${i}]: ${JSON.stringify(p)} is not a normalized root-relative path`);
|
|
70
|
+
return p;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
/** A regular expression from its source text. */
|
|
74
|
+
export const regex = (source, where, flags = '') => {
|
|
75
|
+
if (typeof source !== 'string' || source === '')
|
|
76
|
+
throw new Error(`${where} must be a non-empty regular expression`);
|
|
77
|
+
try {
|
|
78
|
+
return new RegExp(source, flags);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
throw new Error(`${where}: "${source}" is not a regular expression: ${e.message}`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/** A non-empty list of regular expressions. */
|
|
85
|
+
export const regexes = (raw, key, where) => {
|
|
86
|
+
const v = list(raw, key, where);
|
|
87
|
+
if (v.length === 0)
|
|
88
|
+
throw new Error(`${where}"${key}" is empty, so nothing would match`);
|
|
89
|
+
return v.map((s, i) => regex(s, `${where}${key}[${i}]`));
|
|
90
|
+
};
|
|
91
|
+
/** Each entry of `items` is a record; `where` names it in a message. */
|
|
92
|
+
export const records = (items, key) => items.map((raw, i) => {
|
|
93
|
+
const where = `${key}[${i}]: `;
|
|
94
|
+
if (!isRecord(raw))
|
|
95
|
+
throw new Error(`${key}[${i}] must be an object`);
|
|
96
|
+
return { raw, where };
|
|
97
|
+
});
|
|
98
|
+
/** An exemption must say why, in words a reviewer reads: at least `min` characters, trimmed. */
|
|
99
|
+
export const reason = (raw, where, min = 1) => {
|
|
100
|
+
const v = raw['reason'];
|
|
101
|
+
if (typeof v !== 'string' || v.trim() === '')
|
|
102
|
+
throw new Error(`${where}an exemption needs a reason`);
|
|
103
|
+
if (v.trim().length < min)
|
|
104
|
+
throw new Error(`${where}"reason" is shorter than minNoteLength (${min}): say why in words`);
|
|
105
|
+
return v;
|
|
106
|
+
};
|
|
107
|
+
/** A note (what a suite leaves out, why it runs LF only…): optional or required, at least `min` characters trimmed when present. */
|
|
108
|
+
export const note = (raw, key, where, min, required) => {
|
|
109
|
+
const v = required ? text(raw, key, where) : optionalText(raw, key, where);
|
|
110
|
+
if (v !== undefined && v.trim().length < min)
|
|
111
|
+
throw new Error(`${where}"${key}" is shorter than minNoteLength (${min}): say it in words`);
|
|
112
|
+
return v;
|
|
113
|
+
};
|
|
114
|
+
/** `minNoteLength`: the fewest characters, trimmed, an exemption reason or note may have. Required. */
|
|
115
|
+
export const minNoteLength = (raw) => {
|
|
116
|
+
const v = raw['minNoteLength'];
|
|
117
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1)
|
|
118
|
+
throw new Error('"minNoteLength" must be a whole number of at least 1');
|
|
119
|
+
return v;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* A key every config must name, whose `null` opts its check out. Absence is
|
|
123
|
+
* refused: a forgotten key would drop a check silently. The opt-out is recorded
|
|
124
|
+
* in `skipped`, which the run prints as not checked.
|
|
125
|
+
*/
|
|
126
|
+
export const required = (raw, key, where, skipped, parse) => {
|
|
127
|
+
if (!(key in raw))
|
|
128
|
+
throw new Error(`${where}"${key}" is required (null opts its check out)`);
|
|
129
|
+
if (raw[key] === null) {
|
|
130
|
+
skipped.push(`${where.replace(/: $/, '.')}${key}`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return parse();
|
|
134
|
+
};
|
|
135
|
+
/** `findingIds: { motivatedBy, example }`, each a regex or null. */
|
|
136
|
+
export const parseFindingIds = (raw, skipped) => {
|
|
137
|
+
const v = required(raw, 'findingIds', '', skipped, () => object(raw, 'findingIds', ''));
|
|
138
|
+
if (v === undefined)
|
|
139
|
+
return { motivatedBy: undefined, example: undefined };
|
|
140
|
+
onlyKeys(v, ['motivatedBy', 'example'], 'findingIds: ');
|
|
141
|
+
return {
|
|
142
|
+
motivatedBy: required(v, 'motivatedBy', 'findingIds: ', skipped, () => regex(v['motivatedBy'], 'findingIds.motivatedBy')),
|
|
143
|
+
example: required(v, 'example', 'findingIds: ', skipped, () => regex(v['example'], 'findingIds.example')),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
/** The config text as JSON; throws naming why not. */
|
|
147
|
+
export const parseJson = (source) => {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(source);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
throw new Error(`not JSON: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
if (!isRecord(raw))
|
|
156
|
+
throw new Error('the config must be a JSON object');
|
|
157
|
+
return raw;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* The baseline a JSON text holds: exactly the `lists` named, each a list of
|
|
161
|
+
* strings. Throws naming the first list that is wrong, so a malformed
|
|
162
|
+
* baseline fails loudly rather than reading as empty.
|
|
163
|
+
*
|
|
164
|
+
* `atBase` reads the baseline as the merge base holds it, against today's
|
|
165
|
+
* lists: a list the base lacks (a list added since) is empty, and one the
|
|
166
|
+
* config no longer names (a list removed since) is ignored, so changing the
|
|
167
|
+
* lists is never an unreadable base. A list it does hold must still be well formed.
|
|
168
|
+
*/
|
|
169
|
+
export const parseBaseline = (source, lists, where, noun, atBase = false) => {
|
|
170
|
+
let raw;
|
|
171
|
+
try {
|
|
172
|
+
raw = JSON.parse(source);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
throw new Error(`${where}: not JSON: ${e.message}`);
|
|
176
|
+
}
|
|
177
|
+
if (!isRecord(raw))
|
|
178
|
+
throw new Error(`${where}: must be an object with ${lists.map((l) => `"${l}"`).join(' and ')} lists`);
|
|
179
|
+
const extra = Object.keys(raw).find((k) => !lists.includes(k));
|
|
180
|
+
if (extra !== undefined && !atBase)
|
|
181
|
+
throw new Error(`${where}: unknown list "${extra}"`);
|
|
182
|
+
const out = dict();
|
|
183
|
+
for (const name of lists) {
|
|
184
|
+
const held = Object.hasOwn(raw, name) ? raw[name] : undefined;
|
|
185
|
+
const v = atBase && held === undefined ? [] : held;
|
|
186
|
+
if (!Array.isArray(v) || !v.every((s) => typeof s === 'string'))
|
|
187
|
+
throw new Error(`${where}: "${name}" must be a list of ${noun}`);
|
|
188
|
+
out[name] = v;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
};
|
|
192
|
+
const all = (baseline) => Object.values(baseline).flat();
|
|
193
|
+
const listOf = (baseline, name) => (Object.hasOwn(baseline, name) ? baseline[name] : []);
|
|
194
|
+
/**
|
|
195
|
+
* List by list: `unaccounted`, discovered and uncovered but not in its list;
|
|
196
|
+
* `stale`, in a list that does not owe it. An id in the wrong list is both.
|
|
197
|
+
*/
|
|
198
|
+
export const audit = (expected, actual) => {
|
|
199
|
+
const unaccounted = [];
|
|
200
|
+
const stale = [];
|
|
201
|
+
for (const name of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
|
|
202
|
+
const want = listOf(expected, name);
|
|
203
|
+
const have = listOf(actual, name);
|
|
204
|
+
unaccounted.push(...want.filter((id) => !have.includes(id)));
|
|
205
|
+
stale.push(...have.filter((id) => !want.includes(id)));
|
|
206
|
+
}
|
|
207
|
+
return { unaccounted, stale };
|
|
208
|
+
};
|
|
209
|
+
/** The file an id names, when the id is the path itself. */
|
|
210
|
+
export const wholeId = (id) => id;
|
|
211
|
+
/**
|
|
212
|
+
* Entries the head baseline has that the base did not, across its lists (an
|
|
213
|
+
* entry that only changed list is not growth). `fileOf` reads the file an id
|
|
214
|
+
* names (the whole id for a path; the part before a suffix for `path#suffix`);
|
|
215
|
+
* when that file was renamed (`renamed`: new path to old path) the id counts
|
|
216
|
+
* under its old path, so a move is not growth.
|
|
217
|
+
*/
|
|
218
|
+
export const grownEntries = (base, head, renamed = new Map(), fileOf = wholeId) => {
|
|
219
|
+
const before = new Set(all(base));
|
|
220
|
+
const asBefore = (id) => {
|
|
221
|
+
const file = fileOf(id);
|
|
222
|
+
const old = renamed.get(file);
|
|
223
|
+
return old === undefined ? id : old + id.slice(file.length);
|
|
224
|
+
};
|
|
225
|
+
return all(head).filter((id) => !before.has(asBefore(id)));
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* The growth of the baseline at `baselinePath` since the merge base of HEAD
|
|
229
|
+
* and `base`: empty when the merge base has no baseline yet. A git failure,
|
|
230
|
+
* or a baseline the merge base holds malformed, throws.
|
|
231
|
+
*/
|
|
232
|
+
export const growthSince = (git, base, baselinePath, head, parse, fileOf = wholeId) => {
|
|
233
|
+
const commit = mergeBaseOf(git, base);
|
|
234
|
+
const before = fileAt(git, commit, baselinePath);
|
|
235
|
+
if (before === null)
|
|
236
|
+
return [];
|
|
237
|
+
return grownEntries(parse(before, `${baselinePath} at the merge base ${commit.slice(0, 12)}`), head, renamesSince(git, commit), fileOf);
|
|
238
|
+
};
|
|
239
|
+
/** The roots among `roots` under which none of `files` lies: a typo'd root reads nothing and would pass. */
|
|
240
|
+
export const emptyRoots = (files, roots) => roots.filter((r) => !files.some((f) => f === r || f.startsWith(`${r}/`)));
|
|
241
|
+
/** The baseline file at `at` under the root, parsed; throws when it is missing or malformed. */
|
|
242
|
+
export const readBaseline = (root, at, parse) => {
|
|
243
|
+
const source = readText(root, at);
|
|
244
|
+
if (source === undefined)
|
|
245
|
+
throw new Error(`the baseline ${at} is not a readable file`);
|
|
246
|
+
return parse(source, at);
|
|
247
|
+
};
|
|
248
|
+
// ── suite wiring ────────────────────────────────────────────────────────────
|
|
249
|
+
const EXTENSION = /\.(?:ts|tsx|mts|cts|mjs|cjs|js|jsx)$/;
|
|
250
|
+
/**
|
|
251
|
+
* The module specifiers a suite imports, read from its syntax tree: each
|
|
252
|
+
* import declaration that brings in a value (`import { a } from`, a default
|
|
253
|
+
* or namespace import, `import './x'`), each `export … from`, and each
|
|
254
|
+
* `import('…')` of a literal. A specifier in a comment or another string, or
|
|
255
|
+
* a type-only import, is no import.
|
|
256
|
+
*/
|
|
257
|
+
export const importSpecifiers = (ts, file, source) => {
|
|
258
|
+
const out = [];
|
|
259
|
+
const visit = (node) => {
|
|
260
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.importClause?.isTypeOnly !== true)
|
|
261
|
+
out.push(node.moduleSpecifier.text);
|
|
262
|
+
else if (ts.isExportDeclaration(node) && !node.isTypeOnly && node.moduleSpecifier !== undefined && ts.isStringLiteral(node.moduleSpecifier))
|
|
263
|
+
out.push(node.moduleSpecifier.text);
|
|
264
|
+
else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
265
|
+
const first = node.arguments[0];
|
|
266
|
+
if (first !== undefined && ts.isStringLiteralLike(first))
|
|
267
|
+
out.push(first.text);
|
|
268
|
+
}
|
|
269
|
+
ts.forEachChild(node, visit);
|
|
270
|
+
};
|
|
271
|
+
visit(ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true));
|
|
272
|
+
return out;
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Whether the suite at `suite`, importing `specifiers`, imports the module at
|
|
276
|
+
* `target` by a relative specifier: each specifier is resolved against the
|
|
277
|
+
* suite's folder, so a same-named module elsewhere does not count. A folder's
|
|
278
|
+
* `index` module may be imported by the folder.
|
|
279
|
+
*/
|
|
280
|
+
export const importsModule = (suite, specifiers, target) => {
|
|
281
|
+
const want = target.replace(EXTENSION, '');
|
|
282
|
+
const folder = posix.basename(want) === 'index' ? posix.dirname(want) : undefined;
|
|
283
|
+
return specifiers.some((specifier) => {
|
|
284
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../'))
|
|
285
|
+
return false;
|
|
286
|
+
const got = posix.join(posix.dirname(suite), specifier).replace(EXTENSION, '').replace(/\/$/, '');
|
|
287
|
+
return got === want || got === folder;
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
/** Whether `specifiers` (a suite's imports) include the bare module specifier. */
|
|
291
|
+
export const importsPackage = (specifiers, specifier) => specifiers.includes(specifier);
|
|
292
|
+
/** Whether the text names a module specifier as a string, either quote. */
|
|
293
|
+
export const namesModule = (source, specifier) => source.includes(`'${specifier}'`) || source.includes(`"${specifier}"`);
|
|
294
|
+
/** Whether a runner collects a suite at `path`, by the collected-path patterns. */
|
|
295
|
+
export const isCollected = (collected, path) => collected.some((p) => p.test(path));
|
|
296
|
+
/** Whether `path` is a regular file under `root`. */
|
|
297
|
+
export const isFile = (root, path) => statSync(join(root, path), { throwIfNoEntry: false })?.isFile() === true;
|
|
298
|
+
/** The text of a file under `root`, or undefined when it is not a readable file. */
|
|
299
|
+
export const readText = (root, path) => {
|
|
300
|
+
if (!isFile(root, path))
|
|
301
|
+
return undefined;
|
|
302
|
+
try {
|
|
303
|
+
return readFileSync(join(root, path), 'utf8');
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
/** The `examples` key of a config: required, `null` to opt out, its `catalogue` likewise. */
|
|
310
|
+
export const parseExamples = (raw, skipped) => {
|
|
311
|
+
const v = required(raw, 'examples', '', skipped, () => object(raw, 'examples', ''));
|
|
312
|
+
if (v === undefined)
|
|
313
|
+
return undefined;
|
|
314
|
+
onlyKeys(v, ['catalogue', 'fixtures'], 'examples: ');
|
|
315
|
+
const catalogue = required(v, 'catalogue', 'examples: ', skipped, () => {
|
|
316
|
+
const c = object(v, 'catalogue', 'examples: ');
|
|
317
|
+
onlyKeys(c, ['path', 'slice'], 'examples.catalogue: ');
|
|
318
|
+
return { path: path(c, 'path', 'examples.catalogue: '), slice: text(c, 'slice', 'examples.catalogue: ') };
|
|
319
|
+
});
|
|
320
|
+
const fixturesRaw = object(v, 'fixtures', 'examples: ');
|
|
321
|
+
const fixtures = dict();
|
|
322
|
+
for (const [id, entry] of Object.entries(fixturesRaw)) {
|
|
323
|
+
const where = `examples.fixtures.${id}: `;
|
|
324
|
+
if (!isRecord(entry))
|
|
325
|
+
throw new Error(`examples.fixtures.${id} must be an object`);
|
|
326
|
+
onlyKeys(entry, ['example', 'test'], where);
|
|
327
|
+
fixtures[id] = { example: text(entry, 'example', where), test: path(entry, 'test', where) };
|
|
328
|
+
}
|
|
329
|
+
if (Object.keys(fixtures).length === 0)
|
|
330
|
+
throw new Error('examples: "fixtures" is empty, so nothing would be checked');
|
|
331
|
+
return { catalogue, fixtures };
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* Findings about the catalogue examples: the fixtures cover exactly the
|
|
335
|
+
* catalogue's checks for the slice, each from its own example; each fixture
|
|
336
|
+
* suite exists where a runner collects it and names its finding id; `extra`
|
|
337
|
+
* adds what a ratchet asks of a fixture's text. An unreadable catalogue throws.
|
|
338
|
+
*/
|
|
339
|
+
export const checkExamples = (root, examples, collected, extra = () => []) => {
|
|
340
|
+
const out = [];
|
|
341
|
+
if (examples.catalogue !== undefined) {
|
|
342
|
+
const { path: at, slice } = examples.catalogue;
|
|
343
|
+
const source = readText(root, at);
|
|
344
|
+
if (source === undefined)
|
|
345
|
+
throw new Error(`the catalogue ${at} is not a readable file`);
|
|
346
|
+
let rows;
|
|
347
|
+
try {
|
|
348
|
+
rows = JSON.parse(source).checks;
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
throw new Error(`the catalogue ${at}: not JSON: ${e.message}`);
|
|
352
|
+
}
|
|
353
|
+
const isRow = (r) => isRecord(r) && typeof r['id'] === 'string' && typeof r['slice'] === 'string' && typeof r['example'] === 'string';
|
|
354
|
+
if (!Array.isArray(rows) || !rows.every(isRow))
|
|
355
|
+
throw new Error(`the catalogue ${at}: "checks" must be a list of { id, slice, example }`);
|
|
356
|
+
const mine = rows.filter((r) => r.slice === slice);
|
|
357
|
+
for (const row of mine) {
|
|
358
|
+
const fixture = examples.fixtures[row.id];
|
|
359
|
+
if (fixture === undefined)
|
|
360
|
+
out.push(`${at}: ${row.id} (slice ${slice}) has no example fixture`);
|
|
361
|
+
else if (!row.example.startsWith(`${fixture.example}:`))
|
|
362
|
+
out.push(`examples.fixtures.${row.id}: the catalogue's example is not ${fixture.example} ("${row.example}")`);
|
|
363
|
+
}
|
|
364
|
+
for (const id of Object.keys(examples.fixtures)) {
|
|
365
|
+
if (!mine.some((r) => r.id === id))
|
|
366
|
+
out.push(`examples.fixtures.${id}: not a check ${at} gives slice ${slice}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const [id, fixture] of Object.entries(examples.fixtures)) {
|
|
370
|
+
const source = readText(root, fixture.test);
|
|
371
|
+
if (source === undefined) {
|
|
372
|
+
out.push(`${fixture.test}: the ${id} example suite does not exist`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (!isCollected(collected, fixture.test))
|
|
376
|
+
out.push(`${fixture.test}: no runner collects the ${id} example suite`);
|
|
377
|
+
if (!source.includes(fixture.example))
|
|
378
|
+
out.push(`${fixture.test}: the ${id} example suite does not name ${fixture.example}`);
|
|
379
|
+
out.push(...extra(id, source).map((m) => `${fixture.test}: ${m}`));
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
};
|
|
383
|
+
/** Throws when an id a finding pattern must match does not. */
|
|
384
|
+
export const checkFindingIds = (pattern, ids) => {
|
|
385
|
+
if (pattern === undefined)
|
|
386
|
+
return;
|
|
387
|
+
const bad = ids.find(([, id]) => id !== undefined && !pattern.test(id));
|
|
388
|
+
if (bad !== undefined)
|
|
389
|
+
throw new Error(`${bad[0]}: "${String(bad[1])}" is not a finding id (${pattern.source})`);
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* The verdict: findings on stderr, then what to do; a one-line summary on
|
|
393
|
+
* stdout when there are none. Whatever the config opted out of, and a growth
|
|
394
|
+
* comparison not made, is printed on stdout either way, so a pass says what
|
|
395
|
+
* it did not check.
|
|
396
|
+
*/
|
|
397
|
+
export const formatOutcome = (prefix, findings, summary, hint, notChecked = []) => {
|
|
398
|
+
const skipped = notChecked.length === 0 ? [] : [`${prefix} not checked: ${notChecked.join(', ')}`];
|
|
399
|
+
return findings.length === 0
|
|
400
|
+
? { ok: true, out: [`${prefix} ${summary}`, ...skipped], err: [] }
|
|
401
|
+
: { ok: false, out: skipped, err: [...findings.map((f) => `${prefix} ${f}`), `${prefix} ${findings.length} problem(s). ${hint}`] };
|
|
402
|
+
};
|