eval-quality 2.0.0 → 3.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.
- package/README.md +4 -1
- package/dist/application/index.d.ts +4 -0
- package/dist/application/index.js +2 -0
- package/dist/core/compile/compile.js +6 -1
- package/dist/core/compile/schema-version.d.ts +15 -2
- package/dist/core/compile/schema-version.js +11 -3
- package/dist/core/emit/emit.js +4 -2
- package/dist/core/preflight/plan.js +15 -0
- package/dist/core/preflight/reduce.d.ts +1 -1
- package/dist/core/preflight/reduce.js +5 -2
- package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
- package/dist/core/schemas/evaluator-configuration.js +9 -0
- package/dist/core/schemas/evidence-artifact.d.ts +9 -0
- package/dist/core/schemas/evidence-artifact.js +9 -0
- package/dist/core/schemas/isolation-manifest.d.ts +18 -0
- package/dist/core/schemas/isolation-manifest.js +18 -0
- package/dist/core/schemas/preflight-verdict.d.ts +9 -0
- package/dist/core/schemas/preflight-verdict.js +9 -0
- package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
- package/dist/core/schemas/private-artifact-manifest.js +10 -0
- package/dist/core/schemas/probe.d.ts +41 -0
- package/dist/core/schemas/probe.js +43 -0
- package/dist/core/schemas/scoring-policy.d.ts +11 -0
- package/dist/core/schemas/scoring-policy.js +11 -0
- package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
- package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
- package/dist/core/schemas/sealed-run-record.d.ts +11 -0
- package/dist/core/schemas/sealed-run-record.js +11 -0
- package/dist/core/score/score.d.ts +1 -1
- package/dist/core/score/score.js +23 -0
- package/dist/core/seal/seal.js +4 -5
- package/dist/gates/audit-lockfile-age.mjs +295 -0
- package/dist/gates/check-dependency-direction.js +303 -0
- package/dist/gates/check-licenses.mjs +305 -0
- package/dist/gates/dependency-direction.js +555 -0
- package/dist/gates/discover-source-files.js +44 -0
- package/dist/gates/gate-config.js +251 -0
- package/dist/gates/gates-cli.js +410 -0
- package/dist/gates/lineage-ownership.js +364 -0
- package/dist/gates/package-boundary.js +388 -0
- package/dist/gates/token-scan.js +203 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +20 -1
- package/dist/testing/probe-conformance.d.ts +23 -18
- package/package.json +20 -8
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// The layer-rule evaluator, over a graph the caller supplies. Pure and
|
|
2
|
+
// synchronous, with no filesystem I/O: `check-dependency-direction.ts` reads the
|
|
3
|
+
// trees and `dependency-direction.test.ts` calls `scanSources` against both
|
|
4
|
+
// synthetic and real source maps, so one function backs both.
|
|
5
|
+
//
|
|
6
|
+
// This module is reached through a dynamic import, after the optional peer
|
|
7
|
+
// `typescript` has been probed by name. Nothing else may import it statically:
|
|
8
|
+
// `typescript/unstable/ast` below is the runtime value that makes a static
|
|
9
|
+
// import fail at load in a consumer that installed only the other gates.
|
|
10
|
+
//
|
|
11
|
+
// Tokenizing is `token-scan.ts`'s job. Every construct is a short, fixed token
|
|
12
|
+
// shape over that stream, and an unresolved shape is reported (fail-closed).
|
|
13
|
+
import { posix } from 'node:path';
|
|
14
|
+
import { SyntaxKind } from 'typescript/unstable/ast';
|
|
15
|
+
import { computeLineStarts, lineOf, scanTokens, } from './token-scan.js';
|
|
16
|
+
/**
|
|
17
|
+
* Turns a validated configuration section into the form the scan reads. It
|
|
18
|
+
* preserves `layers` order exactly and sorts nothing: the order is the graph,
|
|
19
|
+
* and the section's schema refuses a row an earlier row already matches in full.
|
|
20
|
+
*/
|
|
21
|
+
export function compileGraph(section) {
|
|
22
|
+
const pure = new Set(section.purity?.layers ?? []);
|
|
23
|
+
const exemptions = new Map();
|
|
24
|
+
for (const exemption of section.exemptions) {
|
|
25
|
+
const existing = exemptions.get(exemption.file) ?? [];
|
|
26
|
+
existing.push({
|
|
27
|
+
module: exemption.module,
|
|
28
|
+
binding: exemption.binding,
|
|
29
|
+
rule: exemption.rule,
|
|
30
|
+
});
|
|
31
|
+
exemptions.set(exemption.file, existing);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
layers: section.layers.map((layer) => ({
|
|
35
|
+
name: layer.name,
|
|
36
|
+
label: layer.label ?? layer.name,
|
|
37
|
+
match: layer.match,
|
|
38
|
+
path: layer.path,
|
|
39
|
+
imports: new Set(layer.imports),
|
|
40
|
+
externals: layer.externals,
|
|
41
|
+
pure: pure.has(layer.name),
|
|
42
|
+
})),
|
|
43
|
+
rootPaths: section.roots.map((root) => root.path),
|
|
44
|
+
extensions: [...new Set(section.roots.flatMap((root) => root.extensions))],
|
|
45
|
+
exemptions,
|
|
46
|
+
purity: section.purity === undefined
|
|
47
|
+
? undefined
|
|
48
|
+
: {
|
|
49
|
+
awaitRule: section.purity.awaitRule,
|
|
50
|
+
asyncFunctionRule: section.purity.asyncFunctionRule,
|
|
51
|
+
newDateRule: section.purity.newDateRule,
|
|
52
|
+
members: new Map(section.purity.members.map((entry) => [entry.member, entry.rule])),
|
|
53
|
+
},
|
|
54
|
+
commonjs: section.commonjs,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The first layer in declared order that matches this repository-relative POSIX path, or `undefined` for a file the graph does not place. */
|
|
58
|
+
export function classifyLayer(file, layers) {
|
|
59
|
+
return layers.find((layer) => layer.match === 'exact' ? file === layer.path : file.startsWith(layer.path));
|
|
60
|
+
}
|
|
61
|
+
const isAllowedEdge = (from, to) => from.imports.has(to.name);
|
|
62
|
+
const underARoot = (path, rootPaths) => rootPaths.some((root) => path === root || path.startsWith(`${root}/`));
|
|
63
|
+
/** Resolves a literal relative specifier against its containing file, trying the declared extensions and their `index` files. Fails closed: a specifier leaving the declared roots, or one that resolves to no scanned file, is an error rather than a best-effort guess. */
|
|
64
|
+
function resolveRelative(fromFile, specifier, files, graph) {
|
|
65
|
+
const fromDir = posix.dirname(fromFile);
|
|
66
|
+
const joined = posix.normalize(posix.join(fromDir, specifier));
|
|
67
|
+
if (!underARoot(joined, graph.rootPaths)) {
|
|
68
|
+
return { ok: false, error: 'escapes-roots' };
|
|
69
|
+
}
|
|
70
|
+
if (files.has(joined))
|
|
71
|
+
return { ok: true, resolved: joined };
|
|
72
|
+
for (const extension of graph.extensions) {
|
|
73
|
+
if (files.has(`${joined}${extension}`)) {
|
|
74
|
+
return { ok: true, resolved: `${joined}${extension}` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const extension of graph.extensions) {
|
|
78
|
+
if (files.has(`${joined}/index${extension}`)) {
|
|
79
|
+
return { ok: true, resolved: `${joined}/index${extension}` };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { ok: false, error: 'unresolved' };
|
|
83
|
+
}
|
|
84
|
+
/** Statement-starting keywords that can never appear mid-clause inside an `import`/`export` clause: a bounded-scan abort signal, so a re-export search never runs past its own statement into unrelated code. */
|
|
85
|
+
const DECLARATION_STARTERS = new Set([
|
|
86
|
+
SyntaxKind.ImportKeyword,
|
|
87
|
+
SyntaxKind.ExportKeyword,
|
|
88
|
+
SyntaxKind.FunctionKeyword,
|
|
89
|
+
SyntaxKind.ConstKeyword,
|
|
90
|
+
SyntaxKind.LetKeyword,
|
|
91
|
+
SyntaxKind.VarKeyword,
|
|
92
|
+
SyntaxKind.ClassKeyword,
|
|
93
|
+
SyntaxKind.InterfaceKeyword,
|
|
94
|
+
]);
|
|
95
|
+
const MAX_LOOKAHEAD = 300;
|
|
96
|
+
/**
|
|
97
|
+
* Whether an import clause binds exactly `binding`, as `{ binding }` or
|
|
98
|
+
* `{ binding as other }`, optionally type-only.
|
|
99
|
+
*
|
|
100
|
+
* This predicate is the gate's own and is not configurable: the binding name is
|
|
101
|
+
* data, the token shape it has to hold is the thing being enforced. A default or
|
|
102
|
+
* namespace binding beside the brace group pulls in the rest of the module, so
|
|
103
|
+
* the whole clause has to be that group.
|
|
104
|
+
*/
|
|
105
|
+
function isSoleBindingClause(clauseTokens, binding) {
|
|
106
|
+
const clause = clauseTokens[0]?.kind === SyntaxKind.TypeKeyword
|
|
107
|
+
? clauseTokens.slice(1)
|
|
108
|
+
: clauseTokens;
|
|
109
|
+
if (clause[0]?.kind !== SyntaxKind.OpenBraceToken)
|
|
110
|
+
return false;
|
|
111
|
+
if (clause[clause.length - 1]?.kind !== SyntaxKind.CloseBraceToken) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// Commas are punctuation, so a formatter's trailing comma never changes what
|
|
115
|
+
// the clause binds.
|
|
116
|
+
const inner = clause
|
|
117
|
+
.slice(1, -1)
|
|
118
|
+
.filter((token) => token.kind !== SyntaxKind.CommaToken);
|
|
119
|
+
if (inner[0]?.kind !== SyntaxKind.Identifier || inner[0].text !== binding) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (inner.length === 1)
|
|
123
|
+
return true;
|
|
124
|
+
return (inner.length === 3 &&
|
|
125
|
+
inner[1]?.kind === SyntaxKind.AsKeyword &&
|
|
126
|
+
inner[2]?.kind === SyntaxKind.Identifier);
|
|
127
|
+
}
|
|
128
|
+
function checkExternalSpecifier(file, layer, specifier, line, clauseTokens, via, graph, violations) {
|
|
129
|
+
if (layer.externals.policy === 'unrestricted')
|
|
130
|
+
return;
|
|
131
|
+
const suffix = via === 'reference'
|
|
132
|
+
? '; reached through a triple-slash reference directive rather than an import'
|
|
133
|
+
: '';
|
|
134
|
+
const exemption = graph.exemptions
|
|
135
|
+
.get(file)
|
|
136
|
+
?.find((entry) => entry.module === specifier);
|
|
137
|
+
if (exemption !== undefined) {
|
|
138
|
+
// The exemption reaches a static import declaration and nothing else. A
|
|
139
|
+
// re-export, a dynamic import and a reference directive all arrive with no
|
|
140
|
+
// clause tokens, and none of them can be held to a binding list.
|
|
141
|
+
if (via === 'import' &&
|
|
142
|
+
clauseTokens !== undefined &&
|
|
143
|
+
isSoleBindingClause(clauseTokens, exemption.binding)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
violations.push({
|
|
147
|
+
file,
|
|
148
|
+
line,
|
|
149
|
+
specifier,
|
|
150
|
+
rule: `${exemption.rule}${suffix}`,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (layer.externals.policy === 'allow' &&
|
|
155
|
+
layer.externals.modules.includes(specifier)) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
violations.push({
|
|
159
|
+
file,
|
|
160
|
+
line,
|
|
161
|
+
specifier,
|
|
162
|
+
rule: `${layer.externals.rule}${suffix}`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function handleRelative(file, layer, specifier, line, files, via, graph, violations) {
|
|
166
|
+
const resolution = resolveRelative(file, specifier, files, graph);
|
|
167
|
+
if (!resolution.ok) {
|
|
168
|
+
const subject = via === 'reference' ? 'triple-slash reference path' : 'relative import';
|
|
169
|
+
violations.push({
|
|
170
|
+
file,
|
|
171
|
+
line,
|
|
172
|
+
specifier,
|
|
173
|
+
rule: resolution.error === 'escapes-roots'
|
|
174
|
+
? `${subject} escapes the declared scan roots`
|
|
175
|
+
: `${subject} does not resolve to a scanned source file`,
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const target = classifyLayer(resolution.resolved, graph.layers);
|
|
180
|
+
if (target !== undefined && isAllowedEdge(layer, target))
|
|
181
|
+
return;
|
|
182
|
+
const toLabel = target === undefined ? resolution.resolved : target.label;
|
|
183
|
+
violations.push({
|
|
184
|
+
file,
|
|
185
|
+
line,
|
|
186
|
+
specifier,
|
|
187
|
+
rule: via === 'reference'
|
|
188
|
+
? `${layer.label} may not depend on ${toLabel}; a triple-slash reference directive is a dependency with no import statement`
|
|
189
|
+
: `${layer.label} may not import ${toLabel}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function handleSpecifier(file, layer, specifier, line, files, clauseTokens, graph, violations) {
|
|
193
|
+
if (specifier.startsWith('.')) {
|
|
194
|
+
handleRelative(file, layer, specifier, line, files, 'import', graph, violations);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
checkExternalSpecifier(file, layer, specifier, line, clauseTokens, 'import', graph, violations);
|
|
198
|
+
}
|
|
199
|
+
const NONE = { kind: 'none' };
|
|
200
|
+
const INDETERMINATE = { kind: 'indeterminate' };
|
|
201
|
+
/** Index of the `}` matching an `{` at `openIndex`, or -1 within the bounded window. */
|
|
202
|
+
function matchingBrace(tokens, openIndex) {
|
|
203
|
+
let depth = 0;
|
|
204
|
+
for (let i = openIndex; i < tokens.length && i < openIndex + MAX_LOOKAHEAD; i++) {
|
|
205
|
+
const kind = tokens[i]?.kind;
|
|
206
|
+
if (kind === SyntaxKind.OpenBraceToken)
|
|
207
|
+
depth++;
|
|
208
|
+
else if (kind === SyntaxKind.CloseBraceToken) {
|
|
209
|
+
depth--;
|
|
210
|
+
if (depth === 0)
|
|
211
|
+
return i;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return -1;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Finds the specifier of a plain (non-dynamic, non-`import =`) import
|
|
218
|
+
* declaration starting at `tokens[importIndex]`. The bare side-effect form
|
|
219
|
+
* (`import 'x'`) puts the string literal directly after `import`; every
|
|
220
|
+
* other form puts it directly after a `from` keyword that appears at brace
|
|
221
|
+
* depth zero, so a multi-line named-import list never confuses the scan.
|
|
222
|
+
*/
|
|
223
|
+
function findImportSpecifier(tokens, importIndex) {
|
|
224
|
+
const first = tokens[importIndex + 1];
|
|
225
|
+
if (first?.kind === SyntaxKind.StringLiteral) {
|
|
226
|
+
return { kind: 'specifier', specifierToken: first, clauseTokens: [] };
|
|
227
|
+
}
|
|
228
|
+
// `import.meta.url` is a meta-property, not an import declaration.
|
|
229
|
+
if (first?.kind === SyntaxKind.DotToken)
|
|
230
|
+
return NONE;
|
|
231
|
+
let depth = 0;
|
|
232
|
+
for (let i = importIndex + 1; i < tokens.length && i < importIndex + 1 + MAX_LOOKAHEAD; i++) {
|
|
233
|
+
const token = tokens[i];
|
|
234
|
+
if (token === undefined)
|
|
235
|
+
break;
|
|
236
|
+
if (token.kind === SyntaxKind.OpenBraceToken)
|
|
237
|
+
depth++;
|
|
238
|
+
else if (token.kind === SyntaxKind.CloseBraceToken)
|
|
239
|
+
depth--;
|
|
240
|
+
else if (depth <= 0 && token.kind === SyntaxKind.FromKeyword) {
|
|
241
|
+
const specifierToken = tokens[i + 1];
|
|
242
|
+
if (specifierToken?.kind === SyntaxKind.StringLiteral) {
|
|
243
|
+
return {
|
|
244
|
+
kind: 'specifier',
|
|
245
|
+
specifierToken,
|
|
246
|
+
clauseTokens: tokens.slice(importIndex + 1, i),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return INDETERMINATE;
|
|
250
|
+
}
|
|
251
|
+
else if (depth <= 0 &&
|
|
252
|
+
DECLARATION_STARTERS.has(token.kind) &&
|
|
253
|
+
i !== importIndex + 1) {
|
|
254
|
+
return INDETERMINATE;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return INDETERMINATE;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Same shape as `findImportSpecifier`, for `export * from '...'`, `export
|
|
261
|
+
* type * from '...'`, and `export [type] { ... } from '...'`. A local
|
|
262
|
+
* re-declaration (`export { x }` with no `from`) and a plain declaration
|
|
263
|
+
* export carry no specifier and yield `none`.
|
|
264
|
+
*/
|
|
265
|
+
function findExportSpecifier(tokens, exportIndex) {
|
|
266
|
+
const first = tokens[exportIndex + 1];
|
|
267
|
+
const afterType = first?.kind === SyntaxKind.TypeKeyword ? exportIndex + 2 : exportIndex + 1;
|
|
268
|
+
const head = tokens[afterType];
|
|
269
|
+
if (head?.kind === SyntaxKind.OpenBraceToken) {
|
|
270
|
+
// `export [type] { ... }`: a `from` right after the closing brace makes
|
|
271
|
+
// it a re-export; anything else is a local re-declaration.
|
|
272
|
+
const close = matchingBrace(tokens, afterType);
|
|
273
|
+
if (close === -1)
|
|
274
|
+
return INDETERMINATE;
|
|
275
|
+
if (tokens[close + 1]?.kind !== SyntaxKind.FromKeyword)
|
|
276
|
+
return NONE;
|
|
277
|
+
const specifierToken = tokens[close + 2];
|
|
278
|
+
if (specifierToken?.kind !== SyntaxKind.StringLiteral)
|
|
279
|
+
return INDETERMINATE;
|
|
280
|
+
return { kind: 'specifier', specifierToken, clauseTokens: [] };
|
|
281
|
+
}
|
|
282
|
+
if (head?.kind === SyntaxKind.AsteriskToken) {
|
|
283
|
+
// `export [type] * [as ns] from '...'`. The optional `as ns` is the only
|
|
284
|
+
// thing that can sit between the asterisk and `from`.
|
|
285
|
+
const fromIndex = tokens[afterType + 1]?.kind === SyntaxKind.AsKeyword
|
|
286
|
+
? afterType + 3
|
|
287
|
+
: afterType + 1;
|
|
288
|
+
if (tokens[fromIndex]?.kind !== SyntaxKind.FromKeyword)
|
|
289
|
+
return INDETERMINATE;
|
|
290
|
+
const specifierToken = tokens[fromIndex + 1];
|
|
291
|
+
if (specifierToken?.kind !== SyntaxKind.StringLiteral)
|
|
292
|
+
return INDETERMINATE;
|
|
293
|
+
return { kind: 'specifier', specifierToken, clauseTokens: [] };
|
|
294
|
+
}
|
|
295
|
+
return NONE;
|
|
296
|
+
}
|
|
297
|
+
/** True when `AsyncKeyword` at `tokens[index]` structurally opens an async function declaration/expression, async method (named, quoted, computed, or generator), or async arrow. TypeScript emits `AsyncKeyword` for the text "async" unconditionally, since it's only a contextual keyword, so a bare use as an identifier, parameter or property name reaches here too. This structural check is what keeps `const async = 5` from false-positiving, and it stays the gate's own: no table of banned words could express it. */
|
|
298
|
+
function isAsyncFunctionStart(tokens, index) {
|
|
299
|
+
const next = tokens[index + 1];
|
|
300
|
+
if (next === undefined)
|
|
301
|
+
return false;
|
|
302
|
+
if (next.kind === SyntaxKind.FunctionKeyword)
|
|
303
|
+
return true;
|
|
304
|
+
if (next.kind === SyntaxKind.OpenParenToken)
|
|
305
|
+
return true; // async (...) => … / async method(...)
|
|
306
|
+
if (next.kind === SyntaxKind.AsteriskToken)
|
|
307
|
+
return true; // async *gen() {}
|
|
308
|
+
if (next.kind === SyntaxKind.Identifier ||
|
|
309
|
+
next.kind === SyntaxKind.StringLiteral ||
|
|
310
|
+
next.kind === SyntaxKind.NumericLiteral) {
|
|
311
|
+
const after = tokens[index + 2];
|
|
312
|
+
// `async x => …` (bare single-param arrow), or `async name(...)` /
|
|
313
|
+
// `async 'name'(...)` (method).
|
|
314
|
+
return (after?.kind === SyntaxKind.EqualsGreaterThanToken ||
|
|
315
|
+
after?.kind === SyntaxKind.OpenParenToken);
|
|
316
|
+
}
|
|
317
|
+
if (next.kind === SyntaxKind.OpenBracketToken) {
|
|
318
|
+
// `async ['computed']() {}`: a method whose name is computed. The
|
|
319
|
+
// trailing `(` is what separates it from an index access on a variable
|
|
320
|
+
// that happens to be named `async`.
|
|
321
|
+
let depth = 0;
|
|
322
|
+
for (let i = index + 1; i < tokens.length && i < index + 1 + MAX_LOOKAHEAD; i++) {
|
|
323
|
+
const kind = tokens[i]?.kind;
|
|
324
|
+
if (kind === SyntaxKind.OpenBracketToken)
|
|
325
|
+
depth++;
|
|
326
|
+
else if (kind === SyntaxKind.CloseBracketToken) {
|
|
327
|
+
depth--;
|
|
328
|
+
if (depth === 0) {
|
|
329
|
+
return tokens[i + 1]?.kind === SyntaxKind.OpenParenToken;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A triple-slash reference directive, which TypeScript honours only in a file's
|
|
338
|
+
* leading trivia. It is a dependency written as a comment, so the tokenizer,
|
|
339
|
+
* which skips trivia by construction, can never see one: an edge declared this
|
|
340
|
+
* way crossed every layer boundary in this gate's first shipped form without
|
|
341
|
+
* producing a single token to check. The leading trivia is scanned as text for
|
|
342
|
+
* that reason, which is where TypeScript itself reads these.
|
|
343
|
+
*/
|
|
344
|
+
const REFERENCE_DIRECTIVE = /^[ \t]*\/\/\/[ \t]*<reference\b([^\n>]*)>/gm;
|
|
345
|
+
const attributeOf = (attributes, name) => new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`).exec(attributes)?.[1];
|
|
346
|
+
function scanReferenceDirectives(file, leading, lineStarts, layer, files, graph, violations) {
|
|
347
|
+
for (const match of leading.matchAll(REFERENCE_DIRECTIVE)) {
|
|
348
|
+
const attributes = match[1] ?? '';
|
|
349
|
+
const line = lineOf(lineStarts, match.index ?? 0);
|
|
350
|
+
const path = attributeOf(attributes, 'path');
|
|
351
|
+
if (path !== undefined) {
|
|
352
|
+
// A reference path is always resolved against the containing file,
|
|
353
|
+
// with or without a leading "./", so it never takes the bare-specifier
|
|
354
|
+
// branch an import would.
|
|
355
|
+
handleRelative(file, layer, path, line, files, 'reference', graph, violations);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const types = attributeOf(attributes, 'types');
|
|
359
|
+
if (types !== undefined) {
|
|
360
|
+
checkExternalSpecifier(file, layer, types, line, undefined, 'reference', graph, violations);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// `lib` and `no-default-lib` name a TypeScript library file, so neither is
|
|
364
|
+
// an edge in any graph a consumer declares.
|
|
365
|
+
if (attributeOf(attributes, 'lib') !== undefined ||
|
|
366
|
+
/\bno-default-lib\s*=/.test(attributes)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
violations.push({
|
|
370
|
+
file,
|
|
371
|
+
line,
|
|
372
|
+
specifier: match[0].trim(),
|
|
373
|
+
rule: 'could not read this triple-slash reference directive; it declares a dependency and the layer rules could not be applied to it',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Scans one file's token stream, appending every violation it finds. */
|
|
378
|
+
function scanFile(file, source, files, graph, violations) {
|
|
379
|
+
const layer = classifyLayer(file, graph.layers);
|
|
380
|
+
if (layer === undefined) {
|
|
381
|
+
// Fails closed: a file under a declared scan root that sits in no declared
|
|
382
|
+
// layer would otherwise be scanned for nothing at all, so every rule below
|
|
383
|
+
// would silently pass over it.
|
|
384
|
+
violations.push({
|
|
385
|
+
file,
|
|
386
|
+
line: 1,
|
|
387
|
+
specifier: file,
|
|
388
|
+
rule: 'file sits under a declared scan root but in no declared layer; move it into a layer, or declare the layer',
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const tokens = scanTokens(source);
|
|
393
|
+
const lineStarts = computeLineStarts(source);
|
|
394
|
+
const purity = layer.pure ? graph.purity : undefined;
|
|
395
|
+
scanReferenceDirectives(file, source.slice(0, tokens[0]?.start ?? source.length), lineStarts, layer, files, graph, violations);
|
|
396
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
397
|
+
const token = tokens[i];
|
|
398
|
+
if (token === undefined)
|
|
399
|
+
continue;
|
|
400
|
+
const line = lineOf(lineStarts, token.start);
|
|
401
|
+
if (token.kind === SyntaxKind.ImportKeyword) {
|
|
402
|
+
const next = tokens[i + 1];
|
|
403
|
+
if (next?.kind === SyntaxKind.OpenParenToken) {
|
|
404
|
+
// Dynamic import() call. A comma after the specifier is the
|
|
405
|
+
// import-attributes form (`import('x', { with: … })`), which is
|
|
406
|
+
// still a literal specifier.
|
|
407
|
+
const arg = tokens[i + 2];
|
|
408
|
+
const closing = tokens[i + 3];
|
|
409
|
+
if (arg?.kind === SyntaxKind.StringLiteral &&
|
|
410
|
+
(closing?.kind === SyntaxKind.CloseParenToken ||
|
|
411
|
+
closing?.kind === SyntaxKind.CommaToken)) {
|
|
412
|
+
handleSpecifier(file, layer, arg.value, lineOf(lineStarts, arg.start), files, undefined, graph, violations);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
violations.push({
|
|
416
|
+
file,
|
|
417
|
+
line,
|
|
418
|
+
specifier: arg?.text ?? '',
|
|
419
|
+
rule: 'dynamic import() argument must be a string literal',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// `import [type] Identifier = ...` (import-equals, with or without a
|
|
425
|
+
// leading `type`).
|
|
426
|
+
const equalsIndex = next?.kind === SyntaxKind.TypeKeyword ? i + 3 : i + 2;
|
|
427
|
+
const identIndex = equalsIndex - 1;
|
|
428
|
+
if (tokens[identIndex]?.kind === SyntaxKind.Identifier &&
|
|
429
|
+
tokens[equalsIndex]?.kind === SyntaxKind.EqualsToken) {
|
|
430
|
+
// Under `commonjs: "check"` the `require` token that follows is what
|
|
431
|
+
// carries the specifier, and the branch below checks its edge, so
|
|
432
|
+
// reporting here as well would count one dependency twice.
|
|
433
|
+
if (graph.commonjs === 'forbid') {
|
|
434
|
+
violations.push({
|
|
435
|
+
file,
|
|
436
|
+
line,
|
|
437
|
+
specifier: tokens[identIndex]?.text ?? '',
|
|
438
|
+
rule: 'import-equals declarations are prohibited in the scanned trees',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const found = findImportSpecifier(tokens, i);
|
|
444
|
+
if (found.kind === 'specifier') {
|
|
445
|
+
handleSpecifier(file, layer, found.specifierToken.value, lineOf(lineStarts, found.specifierToken.start), files, found.clauseTokens, graph, violations);
|
|
446
|
+
}
|
|
447
|
+
else if (found.kind === 'indeterminate') {
|
|
448
|
+
violations.push({
|
|
449
|
+
file,
|
|
450
|
+
line,
|
|
451
|
+
specifier: '',
|
|
452
|
+
rule: "could not determine this import declaration's specifier within the bounded token scan; the layer rules could not be applied to it",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (token.kind === SyntaxKind.ExportKeyword) {
|
|
458
|
+
const found = findExportSpecifier(tokens, i);
|
|
459
|
+
if (found.kind === 'specifier') {
|
|
460
|
+
handleSpecifier(file, layer, found.specifierToken.value, lineOf(lineStarts, found.specifierToken.start), files, undefined, graph, violations);
|
|
461
|
+
}
|
|
462
|
+
else if (found.kind === 'indeterminate') {
|
|
463
|
+
violations.push({
|
|
464
|
+
file,
|
|
465
|
+
line,
|
|
466
|
+
specifier: '',
|
|
467
|
+
rule: "could not determine this re-export's specifier within the bounded token scan; the layer rules could not be applied to it",
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (token.kind === SyntaxKind.RequireKeyword) {
|
|
473
|
+
const openIndex = tokens[i + 1]?.kind === SyntaxKind.QuestionDotToken ? i + 2 : i + 1;
|
|
474
|
+
if (tokens[openIndex]?.kind !== SyntaxKind.OpenParenToken)
|
|
475
|
+
continue;
|
|
476
|
+
if (graph.commonjs === 'forbid') {
|
|
477
|
+
violations.push({
|
|
478
|
+
file,
|
|
479
|
+
line,
|
|
480
|
+
specifier: 'require',
|
|
481
|
+
rule: 'CommonJS require is prohibited in the scanned trees; set "commonjs" to "check" to have require() edges read and held to the layer rules instead',
|
|
482
|
+
});
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const arg = tokens[openIndex + 1];
|
|
486
|
+
if (arg?.kind === SyntaxKind.StringLiteral &&
|
|
487
|
+
tokens[openIndex + 2]?.kind === SyntaxKind.CloseParenToken) {
|
|
488
|
+
handleSpecifier(file, layer, arg.value, lineOf(lineStarts, arg.start), files, undefined, graph, violations);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
violations.push({
|
|
492
|
+
file,
|
|
493
|
+
line,
|
|
494
|
+
specifier: arg?.text ?? '',
|
|
495
|
+
rule: 'require() argument must be a string literal',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (purity === undefined)
|
|
501
|
+
continue;
|
|
502
|
+
if (token.kind === SyntaxKind.AwaitKeyword) {
|
|
503
|
+
violations.push({
|
|
504
|
+
file,
|
|
505
|
+
line,
|
|
506
|
+
specifier: 'await',
|
|
507
|
+
rule: purity.awaitRule,
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (token.kind === SyntaxKind.AsyncKeyword &&
|
|
512
|
+
isAsyncFunctionStart(tokens, i)) {
|
|
513
|
+
violations.push({
|
|
514
|
+
file,
|
|
515
|
+
line,
|
|
516
|
+
specifier: 'async',
|
|
517
|
+
rule: purity.asyncFunctionRule,
|
|
518
|
+
});
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (token.kind === SyntaxKind.NewKeyword &&
|
|
522
|
+
tokens[i + 1]?.kind === SyntaxKind.Identifier &&
|
|
523
|
+
tokens[i + 1]?.text === 'Date') {
|
|
524
|
+
violations.push({
|
|
525
|
+
file,
|
|
526
|
+
line,
|
|
527
|
+
specifier: 'new Date',
|
|
528
|
+
rule: purity.newDateRule,
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (token.kind === SyntaxKind.Identifier &&
|
|
533
|
+
tokens[i + 1]?.kind === SyntaxKind.DotToken &&
|
|
534
|
+
tokens[i + 2]?.kind === SyntaxKind.Identifier) {
|
|
535
|
+
const member = `${token.text}.${tokens[i + 2]?.text}`;
|
|
536
|
+
const rule = purity.members.get(member);
|
|
537
|
+
if (rule !== undefined) {
|
|
538
|
+
violations.push({ file, line, specifier: member, rule });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Scans every source file in `files` (repo-relative POSIX path -> source text)
|
|
545
|
+
* against `graph` and returns every violation found, in no particular cross-file
|
|
546
|
+
* order.
|
|
547
|
+
*/
|
|
548
|
+
export function scanSources(files, graph) {
|
|
549
|
+
const fileSet = new Set(files.keys());
|
|
550
|
+
const violations = [];
|
|
551
|
+
for (const [file, source] of files) {
|
|
552
|
+
scanFile(file, source, fileSet, graph, violations);
|
|
553
|
+
}
|
|
554
|
+
return violations;
|
|
555
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Every matching file under every declared root, keyed by repository-relative
|
|
5
|
+
* POSIX path. Fails closed on the three ways a walk can quietly under-report: a
|
|
6
|
+
* root that cannot be read, a symlink (whose target could sit outside the root,
|
|
7
|
+
* or outside the repository altogether), and a root that yielded nothing, which
|
|
8
|
+
* would otherwise read as "scanned everything, found nothing wrong".
|
|
9
|
+
*/
|
|
10
|
+
export async function discoverSourceFiles(repoRoot, roots) {
|
|
11
|
+
const files = new Map();
|
|
12
|
+
async function walk(relativeDir, root) {
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = await readdir(join(repoRoot, relativeDir), {
|
|
16
|
+
withFileTypes: true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
throw new Error(`the scan root "${root.path}" could not be walked at ${relativeDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
21
|
+
}
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const child = `${relativeDir}/${entry.name}`;
|
|
24
|
+
if (entry.isSymbolicLink()) {
|
|
25
|
+
throw new Error(`${child} is a symbolic link; the dependency-direction scan does not follow links, and silently skipping one would leave source unscanned`);
|
|
26
|
+
}
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
await walk(child, root);
|
|
29
|
+
}
|
|
30
|
+
else if (entry.isFile() &&
|
|
31
|
+
root.extensions.some((extension) => entry.name.endsWith(extension))) {
|
|
32
|
+
files.set(child, await readFile(join(repoRoot, child), 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const root of roots) {
|
|
37
|
+
const before = files.size;
|
|
38
|
+
await walk(root.path, root);
|
|
39
|
+
if (files.size === before) {
|
|
40
|
+
throw new Error(`the scan root "${root.path}" holds no ${root.extensions.join(' or ')} file; a scan of nothing reports zero violations for the wrong reason`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|