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.
Files changed (45) hide show
  1. package/README.md +4 -1
  2. package/dist/application/index.d.ts +4 -0
  3. package/dist/application/index.js +2 -0
  4. package/dist/core/compile/compile.js +6 -1
  5. package/dist/core/compile/schema-version.d.ts +15 -2
  6. package/dist/core/compile/schema-version.js +11 -3
  7. package/dist/core/emit/emit.js +4 -2
  8. package/dist/core/preflight/plan.js +15 -0
  9. package/dist/core/preflight/reduce.d.ts +1 -1
  10. package/dist/core/preflight/reduce.js +5 -2
  11. package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
  12. package/dist/core/schemas/evaluator-configuration.js +9 -0
  13. package/dist/core/schemas/evidence-artifact.d.ts +9 -0
  14. package/dist/core/schemas/evidence-artifact.js +9 -0
  15. package/dist/core/schemas/isolation-manifest.d.ts +18 -0
  16. package/dist/core/schemas/isolation-manifest.js +18 -0
  17. package/dist/core/schemas/preflight-verdict.d.ts +9 -0
  18. package/dist/core/schemas/preflight-verdict.js +9 -0
  19. package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
  20. package/dist/core/schemas/private-artifact-manifest.js +10 -0
  21. package/dist/core/schemas/probe.d.ts +41 -0
  22. package/dist/core/schemas/probe.js +43 -0
  23. package/dist/core/schemas/scoring-policy.d.ts +11 -0
  24. package/dist/core/schemas/scoring-policy.js +11 -0
  25. package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
  26. package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
  27. package/dist/core/schemas/sealed-run-record.d.ts +11 -0
  28. package/dist/core/schemas/sealed-run-record.js +11 -0
  29. package/dist/core/score/score.d.ts +1 -1
  30. package/dist/core/score/score.js +23 -0
  31. package/dist/core/seal/seal.js +4 -5
  32. package/dist/gates/audit-lockfile-age.mjs +295 -0
  33. package/dist/gates/check-dependency-direction.js +303 -0
  34. package/dist/gates/check-licenses.mjs +305 -0
  35. package/dist/gates/dependency-direction.js +555 -0
  36. package/dist/gates/discover-source-files.js +44 -0
  37. package/dist/gates/gate-config.js +251 -0
  38. package/dist/gates/gates-cli.js +410 -0
  39. package/dist/gates/lineage-ownership.js +364 -0
  40. package/dist/gates/package-boundary.js +388 -0
  41. package/dist/gates/token-scan.js +203 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +20 -1
  44. package/dist/testing/probe-conformance.d.ts +23 -18
  45. 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
+ }