@cplieger/deadset-ts 3.7.0 → 3.8.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/dist/src/analysis.js +92 -23
- package/dist/src/edges.js +190 -0
- package/dist/src/findings/completion.js +30 -9
- package/dist/src/findings/emitters.js +42 -6
- package/dist/src/findings/self-check.js +127 -2
- package/dist/src/findings/visibility-narrowing.js +9 -40
- package/dist/src/findings-pass.js +97 -0
- package/dist/src/json-document.js +113 -0
- package/dist/src/roots.js +0 -34
- package/dist/src/suppress-file.js +196 -0
- package/dist/src/suppress-inline.js +259 -0
- package/dist/src/suppress.js +148 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +176 -32
- package/src/edges.ts +241 -0
- package/src/findings/boundary.ts +0 -11
- package/src/findings/completion.ts +46 -12
- package/src/findings/emitter.ts +3 -0
- package/src/findings/emitters.ts +81 -7
- package/src/findings/self-check.ts +205 -2
- package/src/findings/visibility-narrowing.ts +10 -52
- package/src/findings-pass.ts +145 -0
- package/src/json-document.ts +130 -0
- package/src/roots.ts +0 -39
- package/src/suppress-file.ts +283 -0
- package/src/suppress-inline.ts +318 -0
- package/src/suppress.ts +262 -0
package/src/analysis.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { decorator } from "./decorator.ts";
|
|
|
14
14
|
import { dependenciesOf, projectNeeds } from "./dependencies.ts";
|
|
15
15
|
import { deprecatedDeclarations } from "./deprecation.ts";
|
|
16
16
|
import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.ts";
|
|
17
|
+
import { readEdgeSides } from "./edges.ts";
|
|
17
18
|
import { enumGroup } from "./enum-group.ts";
|
|
18
19
|
import {
|
|
19
20
|
computeExemptions,
|
|
@@ -26,7 +27,13 @@ import {
|
|
|
26
27
|
import type { TSExemptionClass } from "./exempt-classes.ts";
|
|
27
28
|
import { heldByInclusion, sourceTree } from "./file-facts.ts";
|
|
28
29
|
import type { Finding } from "./finding.ts";
|
|
30
|
+
import { findingsPass, type PassResult } from "./findings-pass.ts";
|
|
29
31
|
import type { EmitterInput } from "./findings/emitter.ts";
|
|
32
|
+
import {
|
|
33
|
+
NO_SELF_CHECK,
|
|
34
|
+
unmatchedRootFindings,
|
|
35
|
+
type SelfCheckFacts,
|
|
36
|
+
} from "./findings/self-check.ts";
|
|
30
37
|
import { frameworkLifecycle } from "./framework-lifecycle.ts";
|
|
31
38
|
import type { Host } from "./host.ts";
|
|
32
39
|
import { implementations, mergeImplementations } from "./implementations.ts";
|
|
@@ -39,12 +46,15 @@ import { relativePath, resolvePath } from "./paths.ts";
|
|
|
39
46
|
import { byPosition, type Position } from "./position.ts";
|
|
40
47
|
import { reflectiveLookup } from "./reflective-lookup.ts";
|
|
41
48
|
import { references } from "./references.ts";
|
|
42
|
-
import { roots, unmatchedEverywhere,
|
|
49
|
+
import { roots, unmatchedEverywhere, type RootKind, type Roots } from "./roots.ts";
|
|
43
50
|
import type { Scope } from "./scope.ts";
|
|
44
51
|
import { serializationContract } from "./serialization-contract.ts";
|
|
45
52
|
import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
|
|
46
53
|
import { accessorsOf, storesOf } from "./stores.ts";
|
|
47
|
-
import type {
|
|
54
|
+
import type { Suppressions } from "./suppress.ts";
|
|
55
|
+
import { readBaseline, readIgnoreFile } from "./suppress-file.ts";
|
|
56
|
+
import { inlineDirectives, inlineSuppressions, type InlineDirective } from "./suppress-inline.ts";
|
|
57
|
+
import type { Exemption, Mode, SweepInput } from "./sweep.ts";
|
|
48
58
|
import { readTemplates, templateField } from "./template-field.ts";
|
|
49
59
|
|
|
50
60
|
/** The setting whose source decides where a finding about a configured root sits. */
|
|
@@ -211,7 +221,10 @@ export function runRoots(
|
|
|
211
221
|
return {
|
|
212
222
|
configurations,
|
|
213
223
|
roots: ordered,
|
|
214
|
-
findings:
|
|
224
|
+
findings: unmatchedRootFindings(
|
|
225
|
+
unmatched,
|
|
226
|
+
documentOf(host, provenance, ROOTS_SETTING, targetRoot),
|
|
227
|
+
),
|
|
215
228
|
};
|
|
216
229
|
}
|
|
217
230
|
|
|
@@ -245,26 +258,27 @@ interface SweptProject<Extra> {
|
|
|
245
258
|
readonly extra: Extra;
|
|
246
259
|
}
|
|
247
260
|
|
|
248
|
-
/**
|
|
249
|
-
interface
|
|
250
|
-
readonly
|
|
261
|
+
/** What a run read of its projects before any sweep: the matrix, its exemptions, and each project's answers. */
|
|
262
|
+
interface ReadRun<Extra> {
|
|
263
|
+
readonly matrix: Matrix;
|
|
251
264
|
readonly exempt: readonly Exemption[];
|
|
252
265
|
readonly projects: readonly SweptProject<Extra>[];
|
|
253
266
|
}
|
|
254
267
|
|
|
255
268
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
269
|
+
* Reads every project of the run, its references and exemptions, and `extra`'s answer,
|
|
270
|
+
* while the project's view is open, and merges the projects into one matrix in the run's
|
|
271
|
+
* order.
|
|
258
272
|
*/
|
|
259
|
-
function
|
|
273
|
+
function readRun<Extra>(
|
|
260
274
|
engine: Engine,
|
|
261
275
|
host: Host,
|
|
262
276
|
scope: Scope,
|
|
263
277
|
config: Config,
|
|
264
|
-
|
|
278
|
+
mode: Mode,
|
|
265
279
|
detectors: Detectors,
|
|
266
280
|
extra: <Brand>(project: ProjectView<Brand>, read: ProjectRead) => Extra,
|
|
267
|
-
):
|
|
281
|
+
): ReadRun<Extra> {
|
|
268
282
|
const targetRoot = scope.target.path;
|
|
269
283
|
const disabled = disabledClasses(config);
|
|
270
284
|
const templates = disabled.has("template-field")
|
|
@@ -287,22 +301,32 @@ function sweepRun<Extra>(
|
|
|
287
301
|
detectors,
|
|
288
302
|
{
|
|
289
303
|
disabled,
|
|
290
|
-
mode
|
|
304
|
+
mode,
|
|
291
305
|
testFiles: new Set(resolved.testFilePaths),
|
|
292
306
|
},
|
|
293
307
|
);
|
|
294
308
|
return { configured, exempt, extra: extra(project, projectRead) };
|
|
295
309
|
});
|
|
296
|
-
const matrix = matrixOf(projects.map((one) => one.configured));
|
|
297
|
-
const exempt = exemptionsOf(projects.flatMap((one) => one.exempt));
|
|
298
|
-
const swept: SweepInput = { ...input, exempt };
|
|
299
310
|
return {
|
|
300
|
-
|
|
301
|
-
exempt,
|
|
311
|
+
matrix: matrixOf(projects.map((one) => one.configured)),
|
|
312
|
+
exempt: exemptionsOf(projects.flatMap((one) => one.exempt)),
|
|
302
313
|
projects,
|
|
303
314
|
};
|
|
304
315
|
}
|
|
305
316
|
|
|
317
|
+
/** The sweep of a run read once, under one set of marks. */
|
|
318
|
+
function sweptOf<Extra>(
|
|
319
|
+
read: ReadRun<Extra>,
|
|
320
|
+
input: Pick<SweepInput, "marked" | "mode">,
|
|
321
|
+
): RunSweep {
|
|
322
|
+
const swept: SweepInput = { ...input, exempt: read.exempt };
|
|
323
|
+
return {
|
|
324
|
+
matrix: read.matrix,
|
|
325
|
+
sweep: sweepMatrix(read.matrix, swept),
|
|
326
|
+
retained: retainedIn(read.matrix, swept),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
306
330
|
/**
|
|
307
331
|
* Sweeps the run the scope and the configuration describe. Each project's references
|
|
308
332
|
* and exemptions are read against its own inventory while its view is open; the
|
|
@@ -321,32 +345,59 @@ export function runSweep(
|
|
|
321
345
|
input: Pick<SweepInput, "marked" | "mode">,
|
|
322
346
|
detectors: Detectors = DETECTORS,
|
|
323
347
|
): RunSweep {
|
|
324
|
-
return
|
|
348
|
+
return sweptOf(
|
|
349
|
+
readRun(engine, host, scope, config, input.mode, detectors, () => undefined),
|
|
350
|
+
input,
|
|
351
|
+
);
|
|
325
352
|
}
|
|
326
353
|
|
|
327
|
-
/**
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
354
|
+
/** What each project contributes to the facts every emitter reads beside the sweep. */
|
|
355
|
+
interface EmitterExtra {
|
|
356
|
+
readonly deprecated: readonly string[];
|
|
357
|
+
readonly accessors: ReturnType<typeof accessorsOf>;
|
|
358
|
+
readonly needs: ReturnType<typeof projectNeeds>;
|
|
359
|
+
readonly implementations: ReturnType<typeof implementations>;
|
|
360
|
+
readonly heldByInclusion: readonly string[];
|
|
361
|
+
readonly rooted: Roots;
|
|
362
|
+
readonly directives: readonly InlineDirective[];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Reads a run's projects with every per-project fact an emitter reads. */
|
|
366
|
+
function readEmitterRun(
|
|
334
367
|
engine: Engine,
|
|
335
368
|
host: Host,
|
|
336
369
|
scope: Scope,
|
|
337
370
|
config: Config,
|
|
338
|
-
|
|
339
|
-
detectors: Detectors
|
|
340
|
-
):
|
|
371
|
+
mode: Mode,
|
|
372
|
+
detectors: Detectors,
|
|
373
|
+
): ReadRun<EmitterExtra> {
|
|
341
374
|
const targetRoot = scope.target.path;
|
|
342
|
-
|
|
375
|
+
return readRun(engine, host, scope, config, mode, detectors, (project, read) => ({
|
|
343
376
|
deprecated: deprecatedDeclarations(project, read.held),
|
|
344
377
|
accessors: accessorsOf(project, read.held),
|
|
345
378
|
needs: projectNeeds(project, read.held, host),
|
|
346
379
|
implementations: implementations(project, read.held),
|
|
347
380
|
heldByInclusion: heldByInclusion(project, targetRoot),
|
|
381
|
+
rooted: read.rooted,
|
|
382
|
+
directives: inlineDirectives(project, read.held, targetRoot),
|
|
348
383
|
}));
|
|
349
|
-
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The input every emitter reads over one sweep of a read run: the facts beside the sweep
|
|
388
|
+
* each kind family reads, every per-project one read in the same session as the sweep.
|
|
389
|
+
*/
|
|
390
|
+
function emitterInputOver(
|
|
391
|
+
host: Host,
|
|
392
|
+
scope: Scope,
|
|
393
|
+
config: Config,
|
|
394
|
+
read: ReadRun<EmitterExtra>,
|
|
395
|
+
input: Pick<SweepInput, "marked" | "mode">,
|
|
396
|
+
selfCheck: SelfCheckFacts,
|
|
397
|
+
): EmitterInput {
|
|
398
|
+
const targetRoot = scope.target.path;
|
|
399
|
+
const swept = sweptOf(read, input);
|
|
400
|
+
const { projects } = read;
|
|
350
401
|
return {
|
|
351
402
|
config,
|
|
352
403
|
swept,
|
|
@@ -356,7 +407,7 @@ export function runEmitterInput(
|
|
|
356
407
|
references: one.configured.references,
|
|
357
408
|
accessors: one.extra.accessors,
|
|
358
409
|
})),
|
|
359
|
-
|
|
410
|
+
read.exempt,
|
|
360
411
|
input.mode,
|
|
361
412
|
),
|
|
362
413
|
dependencies: dependenciesOf(
|
|
@@ -373,7 +424,100 @@ export function runEmitterInput(
|
|
|
373
424
|
boundary: {
|
|
374
425
|
consumers: { declared: scope.consumers.map((consumer) => consumer.id), loaded: [] },
|
|
375
426
|
encapsulated: readManifest(host, targetRoot).declaresExports,
|
|
376
|
-
edges:
|
|
427
|
+
edges: readEdgeSides(host, targetRoot),
|
|
377
428
|
},
|
|
429
|
+
selfCheck,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* What every emitter reads of the run the scope and the configuration describe, swept
|
|
435
|
+
* under the caller's marks: the run's sweep, the facts beside it each kind family reads,
|
|
436
|
+
* and the sides of the target's declared cross-language edges that name a symbol of this
|
|
437
|
+
* language. The run loads no consumer, and it reads no suppression document, so the
|
|
438
|
+
* self-check family has nothing to report.
|
|
439
|
+
*/
|
|
440
|
+
export function runEmitterInput(
|
|
441
|
+
engine: Engine,
|
|
442
|
+
host: Host,
|
|
443
|
+
scope: Scope,
|
|
444
|
+
config: Config,
|
|
445
|
+
input: Pick<SweepInput, "marked" | "mode">,
|
|
446
|
+
detectors: Detectors = DETECTORS,
|
|
447
|
+
): EmitterInput {
|
|
448
|
+
const read = readEmitterRun(engine, host, scope, config, input.mode, detectors);
|
|
449
|
+
return emitterInputOver(host, scope, config, read, input, NO_SELF_CHECK);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** One run read for its findings: the input swept with and without its suppressions' marks. */
|
|
453
|
+
export interface AnalysisInputs {
|
|
454
|
+
/** The run swept under the marks of every bound suppression record. */
|
|
455
|
+
readonly marked: EmitterInput;
|
|
456
|
+
/** The same run swept with no mark, which is what a record would have withheld. */
|
|
457
|
+
readonly unmarked: EmitterInput;
|
|
458
|
+
/** The run's suppression records and refusals, in reading order. */
|
|
459
|
+
readonly suppressions: Suppressions;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Reads the run the scope and the configuration describe for its findings. The inline
|
|
464
|
+
* directives, the ignore file's entries and the baseline's rows are read and bound before
|
|
465
|
+
* any sweep, and the run is swept once under every bound record's mark and once under
|
|
466
|
+
* none, so a record is decided against the finding its code would have produced. The
|
|
467
|
+
* self-check family reports the refused suppressions and the roots that named nothing.
|
|
468
|
+
*/
|
|
469
|
+
function runAnalysisInputs(
|
|
470
|
+
engine: Engine,
|
|
471
|
+
host: Host,
|
|
472
|
+
scope: Scope,
|
|
473
|
+
config: Config,
|
|
474
|
+
provenance: Provenance,
|
|
475
|
+
mode: Mode,
|
|
476
|
+
detectors: Detectors = DETECTORS,
|
|
477
|
+
): AnalysisInputs {
|
|
478
|
+
const targetRoot = scope.target.path;
|
|
479
|
+
const read = readEmitterRun(engine, host, scope, config, mode, detectors);
|
|
480
|
+
const symbols = read.matrix.union.symbols;
|
|
481
|
+
const documents = [
|
|
482
|
+
inlineSuppressions(read.projects.map((one) => one.extra.directives)),
|
|
483
|
+
readIgnoreFile(host, targetRoot, symbols),
|
|
484
|
+
readBaseline(host, targetRoot, symbols),
|
|
485
|
+
];
|
|
486
|
+
const suppressions: Suppressions = {
|
|
487
|
+
records: documents.flatMap((one) => one.records),
|
|
488
|
+
refusals: documents.flatMap((one) => one.refusals),
|
|
489
|
+
};
|
|
490
|
+
const facts: SelfCheckFacts = {
|
|
491
|
+
refusals: suppressions.refusals,
|
|
492
|
+
unmatchedRoots: unmatchedEverywhere(
|
|
493
|
+
config.rootPatterns,
|
|
494
|
+
read.projects.map((one) => one.extra.rooted),
|
|
495
|
+
),
|
|
496
|
+
rootsDocument: documentOf(host, provenance, ROOTS_SETTING, targetRoot),
|
|
378
497
|
};
|
|
498
|
+
const marked = [...new Set(suppressions.records.map((record) => record.bound))].filter(
|
|
499
|
+
(id) => id !== "",
|
|
500
|
+
);
|
|
501
|
+
return {
|
|
502
|
+
marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
|
|
503
|
+
unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
|
|
504
|
+
suppressions,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The findings of the run the scope and the configuration describe: the run read with its
|
|
510
|
+
* suppressions bound, then every family's findings with the suppressions applied and the
|
|
511
|
+
* declared edges evaluated.
|
|
512
|
+
*/
|
|
513
|
+
export function runFindings(
|
|
514
|
+
engine: Engine,
|
|
515
|
+
host: Host,
|
|
516
|
+
scope: Scope,
|
|
517
|
+
config: Config,
|
|
518
|
+
provenance: Provenance,
|
|
519
|
+
mode: Mode,
|
|
520
|
+
detectors: Detectors = DETECTORS,
|
|
521
|
+
): PassResult {
|
|
522
|
+
return findingsPass(runAnalysisInputs(engine, host, scope, config, provenance, mode, detectors));
|
|
379
523
|
}
|
package/src/edges.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared cross-language edges: the sides of the target's edges document that name a
|
|
3
|
+
* symbol of this language, and the evaluation the report publishes for each. This analyzer
|
|
4
|
+
* never resolves the paired symbol: the other language's analyzer decides whether the
|
|
5
|
+
* pairing is a use, and the merge reads the two evaluations together.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ConfigError } from "./config.ts";
|
|
9
|
+
import type { Finding } from "./finding.ts";
|
|
10
|
+
import type { EdgeSide, EdgeSideName } from "./findings/boundary.ts";
|
|
11
|
+
import type { Host } from "./host.ts";
|
|
12
|
+
import type { InventorySymbol } from "./inventory.ts";
|
|
13
|
+
import { positionAt, walkDocument } from "./json-document.ts";
|
|
14
|
+
import { joinPath } from "./paths.ts";
|
|
15
|
+
import { positionKey } from "./position.ts";
|
|
16
|
+
|
|
17
|
+
/** The name and the location the Contract fixes for the edges document. */
|
|
18
|
+
export const EDGES_FILE = "deadset-edges.json";
|
|
19
|
+
|
|
20
|
+
/** The language prefix of every reference this analyzer evaluates. */
|
|
21
|
+
const OWN_PREFIX = "ts://";
|
|
22
|
+
|
|
23
|
+
/** The closed key list of the document and of one edge. */
|
|
24
|
+
const DOCUMENT_KEYS: ReadonlySet<string> = new Set(["description", "edges"]);
|
|
25
|
+
const EDGE_KEYS: ReadonlySet<string> = new Set(["id", "because", "provides", "used_by"]);
|
|
26
|
+
|
|
27
|
+
/** An edge identifier: solidus-separated segments of ASCII letters, digits, `_`, `.` and `-`. */
|
|
28
|
+
const ID_FORM = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/u;
|
|
29
|
+
|
|
30
|
+
/** A reference naming one symbol of some language: `<language>://<scope>#<fragment>`. */
|
|
31
|
+
const REFERENCE_FORM = /^[a-z][a-z0-9]*:\/\/[^ \t\r\n#]+#[^ \t\r\n]*$/u;
|
|
32
|
+
|
|
33
|
+
const DOCUMENT_WANT = "an object carrying the edges array, and an optional description";
|
|
34
|
+
const EDGE_WANT = "an edge naming id, provides and used_by, each a string, and an optional because";
|
|
35
|
+
const ID_WANT =
|
|
36
|
+
"one or more solidus-separated segments of ASCII letters, digits, underscore, full stop and hyphen";
|
|
37
|
+
const REFERENCE_WANT =
|
|
38
|
+
"a stable symbol reference naming one symbol exactly, <language>://<scope>#<fragment>";
|
|
39
|
+
|
|
40
|
+
/** An edges document the Contract refuses: a malformed input, answered with the usage code. */
|
|
41
|
+
export class EdgesError extends ConfigError {
|
|
42
|
+
constructor(at: string, text: string, want: string) {
|
|
43
|
+
super("malformed", EDGES_FILE, `${at}: ${text}: want ${want}`);
|
|
44
|
+
this.name = "EdgesError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a reference carries a wildcard outside a quoted or a computed component, which
|
|
50
|
+
* would name a set of symbols rather than one.
|
|
51
|
+
*/
|
|
52
|
+
function carriesWildcard(ref: string): boolean {
|
|
53
|
+
let quoted = false;
|
|
54
|
+
let computed = false;
|
|
55
|
+
let escaped = false;
|
|
56
|
+
for (const char of ref) {
|
|
57
|
+
if (escaped) {
|
|
58
|
+
escaped = false;
|
|
59
|
+
} else if (quoted && char === "\\") {
|
|
60
|
+
escaped = true;
|
|
61
|
+
} else if (quoted) {
|
|
62
|
+
quoted = char !== "'";
|
|
63
|
+
} else if (computed) {
|
|
64
|
+
computed = char !== "]";
|
|
65
|
+
} else if (char === "'") {
|
|
66
|
+
quoted = true;
|
|
67
|
+
} else if (char === "[") {
|
|
68
|
+
computed = true;
|
|
69
|
+
} else if (char === "*") {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One edge, checked: the closed key list, the value types and the value forms. */
|
|
81
|
+
function readEdge(value: unknown, at: string): { id: string; provides: string; usedBy: string } {
|
|
82
|
+
const refuse = (text: string, want: string): never => {
|
|
83
|
+
throw new EdgesError(at, text, want);
|
|
84
|
+
};
|
|
85
|
+
if (!isObject(value)) {
|
|
86
|
+
return refuse(JSON.stringify(value), EDGE_WANT);
|
|
87
|
+
}
|
|
88
|
+
for (const [key, held] of Object.entries(value)) {
|
|
89
|
+
if (!EDGE_KEYS.has(key) || typeof held !== "string") {
|
|
90
|
+
refuse(`the member ${JSON.stringify(key)} holding ${JSON.stringify(held)}`, EDGE_WANT);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const edge = value as Readonly<Record<string, string | undefined>>;
|
|
94
|
+
const id = edge["id"] ?? refuse("an edge naming no id", EDGE_WANT);
|
|
95
|
+
const provides = edge["provides"] ?? refuse("an edge naming no provides side", EDGE_WANT);
|
|
96
|
+
const usedBy = edge["used_by"] ?? refuse("an edge naming no used_by side", EDGE_WANT);
|
|
97
|
+
if (!ID_FORM.test(id)) {
|
|
98
|
+
refuse(JSON.stringify(id), ID_WANT);
|
|
99
|
+
}
|
|
100
|
+
for (const ref of [provides, usedBy]) {
|
|
101
|
+
if (!REFERENCE_FORM.test(ref) || carriesWildcard(ref)) {
|
|
102
|
+
refuse(JSON.stringify(ref), REFERENCE_WANT);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { id, provides, usedBy };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every side of a declared edge that names a symbol of this language, in document order,
|
|
110
|
+
* `provides` before `used_by` within an edge. An absent document declares no edge. The
|
|
111
|
+
* document is strict JSON with a closed key list at both levels, and a defect in it is an
|
|
112
|
+
* {@link EdgesError}.
|
|
113
|
+
*/
|
|
114
|
+
export function readEdgeSides(host: Host, targetRoot: string): readonly EdgeSide[] {
|
|
115
|
+
const file = joinPath(targetRoot, EDGES_FILE);
|
|
116
|
+
if (host.kindOf(file) === "absent") {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const text = host.readFile(file);
|
|
120
|
+
let parsed: unknown;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(text);
|
|
123
|
+
} catch (error: unknown) {
|
|
124
|
+
throw new EdgesError(
|
|
125
|
+
EDGES_FILE,
|
|
126
|
+
error instanceof Error ? error.message : String(error),
|
|
127
|
+
DOCUMENT_WANT,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (!isObject(parsed)) {
|
|
131
|
+
throw new EdgesError(EDGES_FILE, JSON.stringify(parsed), DOCUMENT_WANT);
|
|
132
|
+
}
|
|
133
|
+
const walk = walkDocument(text, "edges");
|
|
134
|
+
if (walk.twice !== undefined) {
|
|
135
|
+
throw new EdgesError(
|
|
136
|
+
positionKey(positionAt(text, EDGES_FILE, walk.twice.offset)),
|
|
137
|
+
`the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`,
|
|
138
|
+
DOCUMENT_WANT,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const unknown = Object.keys(parsed).find((key) => !DOCUMENT_KEYS.has(key));
|
|
142
|
+
const description = parsed["description"];
|
|
143
|
+
const edges = parsed["edges"];
|
|
144
|
+
if (
|
|
145
|
+
unknown !== undefined ||
|
|
146
|
+
(description !== undefined && typeof description !== "string") ||
|
|
147
|
+
!Array.isArray(edges)
|
|
148
|
+
) {
|
|
149
|
+
throw new EdgesError(
|
|
150
|
+
EDGES_FILE,
|
|
151
|
+
unknown === undefined ? EDGES_FILE : `the member ${JSON.stringify(unknown)}`,
|
|
152
|
+
DOCUMENT_WANT,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sides: EdgeSide[] = [];
|
|
157
|
+
(edges as readonly unknown[]).forEach((value, index) => {
|
|
158
|
+
const offset = walk.opened[index];
|
|
159
|
+
const at =
|
|
160
|
+
offset === undefined ? EDGES_FILE : positionKey(positionAt(text, EDGES_FILE, offset));
|
|
161
|
+
const edge = readEdge(value, at);
|
|
162
|
+
const named: readonly [EdgeSideName, string][] = [
|
|
163
|
+
["provides", edge.provides],
|
|
164
|
+
["used_by", edge.usedBy],
|
|
165
|
+
];
|
|
166
|
+
for (const [side, symbol] of named) {
|
|
167
|
+
if (symbol.startsWith(OWN_PREFIX)) {
|
|
168
|
+
sides.push({ edge: edge.id, side, symbol });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return sides;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One side's verdict, as an evaluation names it. */
|
|
176
|
+
export type EdgeState = "live" | "dead" | "absent";
|
|
177
|
+
|
|
178
|
+
/** One edge evaluation: one side of this language, its state, and the finding it holds. */
|
|
179
|
+
export interface EdgeEvaluation<F extends Finding = Finding> extends EdgeSide {
|
|
180
|
+
readonly state: EdgeState;
|
|
181
|
+
/** Present exactly when the state is `dead`: the finding a live paired symbol cancels. */
|
|
182
|
+
readonly finding?: F;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The findings a report publishes, and one evaluation per declared side of this language. */
|
|
186
|
+
export interface Evaluated<F extends Finding> {
|
|
187
|
+
readonly findings: readonly F[];
|
|
188
|
+
/** Ordered by edge, then by side, each compared bytewise. */
|
|
189
|
+
readonly evaluations: readonly EdgeEvaluation<F>[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Two strings ordered bytewise. */
|
|
193
|
+
function compare(a: string, b: string): number {
|
|
194
|
+
if (a === b) {
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
return a < b ? -1 : 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Evaluates every declared side of this language over the run's findings. A side naming a
|
|
202
|
+
* symbol the run enumerates nothing under is `absent`; one naming a symbol the run holds a
|
|
203
|
+
* finding about is `dead` and carries that finding, pending until a merge reads the other
|
|
204
|
+
* side and reported nowhere else, a narrowing finding included; any other is `live`.
|
|
205
|
+
*/
|
|
206
|
+
export function evaluateEdges<F extends Finding>(
|
|
207
|
+
findings: readonly F[],
|
|
208
|
+
sides: readonly EdgeSide[],
|
|
209
|
+
symbols: readonly InventorySymbol[],
|
|
210
|
+
): Evaluated<F> {
|
|
211
|
+
if (sides.length === 0) {
|
|
212
|
+
return { findings, evaluations: [] };
|
|
213
|
+
}
|
|
214
|
+
const byRef = new Map<string, string>();
|
|
215
|
+
for (const symbol of symbols) {
|
|
216
|
+
if (symbol.kind !== "file" && !byRef.has(symbol.ref)) {
|
|
217
|
+
byRef.set(symbol.ref, symbol.id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const held = new Map<string, number>();
|
|
221
|
+
findings.forEach((finding, at) => {
|
|
222
|
+
held.set(positionKey(finding.position), at);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const pending = new Set<number>();
|
|
226
|
+
const evaluations = sides.map((side): EdgeEvaluation<F> => {
|
|
227
|
+
const id = byRef.get(side.symbol);
|
|
228
|
+
if (id === undefined) {
|
|
229
|
+
return { ...side, state: "absent" };
|
|
230
|
+
}
|
|
231
|
+
const at = held.get(id);
|
|
232
|
+
const finding = at === undefined ? undefined : findings[at];
|
|
233
|
+
if (at === undefined || finding === undefined) {
|
|
234
|
+
return { ...side, state: "live" };
|
|
235
|
+
}
|
|
236
|
+
pending.add(at);
|
|
237
|
+
return { ...side, state: "dead", finding };
|
|
238
|
+
});
|
|
239
|
+
evaluations.sort((a, b) => compare(a.edge, b.edge) || compare(a.side, b.side));
|
|
240
|
+
return { findings: findings.filter((_finding, at) => !pending.has(at)), evaluations };
|
|
241
|
+
}
|
package/src/findings/boundary.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { Finding } from "../finding.ts";
|
|
2
|
-
|
|
3
1
|
/** Which side of a declared cross-language edge, as the edges document spells it. */
|
|
4
2
|
export type EdgeSideName = "provides" | "used_by";
|
|
5
3
|
|
|
@@ -12,15 +10,6 @@ export interface EdgeSide {
|
|
|
12
10
|
readonly symbol: string;
|
|
13
11
|
}
|
|
14
12
|
|
|
15
|
-
/**
|
|
16
|
-
* One finding a declared edge holds back: the finding the analyzer would report about
|
|
17
|
-
* the symbol one side of the edge names, which a report carries only inside that side's
|
|
18
|
-
* evaluation, because the code that may use the symbol is in the other language.
|
|
19
|
-
*/
|
|
20
|
-
export interface PendingFinding extends EdgeSide {
|
|
21
|
-
readonly finding: Finding;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
13
|
/** What the run knows about the code outside the target that can reach its declarations. */
|
|
25
14
|
export interface Boundary {
|
|
26
15
|
readonly consumers: {
|
|
@@ -11,6 +11,7 @@ import type { CompletedFinding, Finding, FindingComponent } from "../finding.ts"
|
|
|
11
11
|
import { OUTSIDE } from "../graph.ts";
|
|
12
12
|
import { KINDS } from "../kinds.ts";
|
|
13
13
|
import { FAMILY_KEY_LENGTH } from "../resolve.ts";
|
|
14
|
+
import type { Dials } from "../suppress.ts";
|
|
14
15
|
import type { EmitterInput } from "./emitter.ts";
|
|
15
16
|
|
|
16
17
|
/** The classes ranked by the claim each makes, the strongest highest. */
|
|
@@ -141,23 +142,56 @@ export function completed(
|
|
|
141
142
|
});
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** Whether the dials withhold a finding by its own severity or confidence. */
|
|
146
|
+
function dialed(
|
|
147
|
+
finding: Pick<CompletedFinding, "severity" | "confidence">,
|
|
148
|
+
minConfidence: Confidence,
|
|
149
|
+
): boolean {
|
|
150
|
+
return finding.severity === "allow" || RANK[finding.confidence] < RANK[minConfidence];
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
/**
|
|
145
|
-
* The
|
|
146
|
-
* is withheld, and so is every finding of the component of a
|
|
147
|
-
* because a symbol that falls with a root is dead only through it
|
|
148
|
-
* does not compile while the root stays. A withheld finding that is no
|
|
149
|
-
* itself alone.
|
|
154
|
+
* The configuration's dials over one run's completed findings. A finding at `allow` or
|
|
155
|
+
* below the minimum confidence is withheld, and so is every finding of the component of a
|
|
156
|
+
* root the dials withhold, because a symbol that falls with a root is dead only through it
|
|
157
|
+
* and its deletion does not compile while the root stays. A withheld finding that is no
|
|
158
|
+
* root withholds itself alone. A code is withheld from a declaration when the finding it
|
|
159
|
+
* would report there, completed, is withheld by its own severity or confidence.
|
|
150
160
|
*/
|
|
151
|
-
export function
|
|
161
|
+
export function dialsOf(
|
|
162
|
+
input: EmitterInput,
|
|
152
163
|
findings: readonly CompletedFinding[],
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
finding.severity === "allow" || RANK[finding.confidence] < RANK[minConfidence];
|
|
164
|
+
): Dials<CompletedFinding> {
|
|
165
|
+
const minConfidence = input.config.analysis.minConfidence;
|
|
166
|
+
const classOf = reachabilityClasses(input.swept);
|
|
157
167
|
const components = new Set(
|
|
158
168
|
findings
|
|
159
|
-
.filter((finding) => finding.component.root && dialed(finding))
|
|
169
|
+
.filter((finding) => finding.component.root && dialed(finding, minConfidence))
|
|
160
170
|
.map((finding) => finding.component.id),
|
|
161
171
|
);
|
|
162
|
-
return
|
|
172
|
+
return {
|
|
173
|
+
withholdsCode: (code, id) => {
|
|
174
|
+
const row = KINDS.get(code);
|
|
175
|
+
return (
|
|
176
|
+
row !== undefined &&
|
|
177
|
+
dialed(
|
|
178
|
+
{
|
|
179
|
+
severity: severityOf(input.config, code, row.defaultSeverity),
|
|
180
|
+
confidence: capped(classOf(id), row.maxClass),
|
|
181
|
+
},
|
|
182
|
+
minConfidence,
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
},
|
|
186
|
+
withholds: (finding) => dialed(finding, minConfidence) || components.has(finding.component.id),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The findings of one run's completed findings that no dial withholds, in the order given. */
|
|
191
|
+
export function reportable(
|
|
192
|
+
input: EmitterInput,
|
|
193
|
+
findings: readonly CompletedFinding[],
|
|
194
|
+
): readonly CompletedFinding[] {
|
|
195
|
+
const dials = dialsOf(input, findings);
|
|
196
|
+
return findings.filter((finding) => !dials.withholds(finding));
|
|
163
197
|
}
|
package/src/findings/emitter.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Finding } from "../finding.ts";
|
|
|
6
6
|
import type { Implementations } from "../implementations.ts";
|
|
7
7
|
import type { Stores } from "../stores.ts";
|
|
8
8
|
import type { Boundary } from "./boundary.ts";
|
|
9
|
+
import type { SelfCheckFacts } from "./self-check.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* What every emitter reads: the configuration the run resolved, the run's sweep, and
|
|
@@ -27,6 +28,8 @@ export interface EmitterInput {
|
|
|
27
28
|
readonly files: FileFacts;
|
|
28
29
|
/** What can reach the target from outside it: consumers, declared edges, and `exports`. */
|
|
29
30
|
readonly boundary: Boundary;
|
|
31
|
+
/** The run's suppression outcomes and configured roots, which the self-check family reports. */
|
|
32
|
+
readonly selfCheck: SelfCheckFacts;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|