@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
|
@@ -164,49 +164,29 @@ function unreachable(facts, at, symbol, candidate, input) {
|
|
|
164
164
|
: "has no reference in the target";
|
|
165
165
|
return findingOf(symbol, UNREACHABLE_EXPORT, `exported ${wordOf(symbol)} ${found}, and nothing outside can import the file that declares it`);
|
|
166
166
|
}
|
|
167
|
-
/** Two strings ordered bytewise. */
|
|
168
|
-
function compare(a, b) {
|
|
169
|
-
if (a === b) {
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
return a < b ? -1 : 1;
|
|
173
|
-
}
|
|
174
167
|
/**
|
|
175
|
-
* The visibility-narrowing family over one run: `DS1101`, `DS1103` and `DS1104`.
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* file and the package, and whether it is a use is decided on the side in the other
|
|
180
|
-
* language.
|
|
168
|
+
* The visibility-narrowing family over one run: `DS1101`, `DS1103` and `DS1104`. A finding
|
|
169
|
+
* about a symbol a declared cross-language edge names is reported here like any other, and
|
|
170
|
+
* the edge's evaluation then holds it pending, because the edge stands for a reference from
|
|
171
|
+
* outside the file and the package whose use is decided in the other language.
|
|
181
172
|
*/
|
|
182
173
|
export function narrowings(input) {
|
|
183
|
-
const { boundary } = input;
|
|
184
174
|
const facts = factsOf(input.swept.matrix.union);
|
|
185
|
-
const { declared, loaded } = boundary.consumers;
|
|
175
|
+
const { declared, loaded } = input.boundary.consumers;
|
|
186
176
|
const closed = input.config.consumersComplete && declared.every((id) => loaded.includes(id));
|
|
187
177
|
const world = { closed, writeOnly: writeOnlyDeclarations(input) };
|
|
188
178
|
const candidates = new Map(input.swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
|
|
189
179
|
const findings = [];
|
|
190
|
-
const pending = [];
|
|
191
180
|
facts.graph.symbols.forEach((symbol, at) => {
|
|
192
181
|
const candidate = candidates.get(symbol.id);
|
|
193
182
|
const found = candidate === undefined
|
|
194
183
|
? narrowing(facts, at, symbol, world)
|
|
195
184
|
: unreachable(facts, at, symbol, candidate, input);
|
|
196
|
-
if (found
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const named = boundary.edges.filter((side) => side.symbol === symbol.ref);
|
|
200
|
-
if (named.length === 0) {
|
|
185
|
+
if (found !== undefined) {
|
|
201
186
|
findings.push(found);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
for (const side of named) {
|
|
205
|
-
pending.push({ ...side, finding: found });
|
|
206
187
|
}
|
|
207
188
|
});
|
|
208
|
-
|
|
209
|
-
return { findings, pending };
|
|
189
|
+
return findings;
|
|
210
190
|
}
|
|
211
191
|
/**
|
|
212
192
|
* The declarations the unreachable-export kind reports, whether or not a declared edge
|
|
@@ -224,16 +204,5 @@ export function unreachableExports(input) {
|
|
|
224
204
|
}
|
|
225
205
|
return found;
|
|
226
206
|
}
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
* declared edge holds pending belongs in that edge's evaluation, which this analyzer
|
|
230
|
-
* does not publish yet, so a run that holds one is refused rather than reported
|
|
231
|
-
* without it.
|
|
232
|
-
*/
|
|
233
|
-
export const visibilityNarrowing = (input) => {
|
|
234
|
-
const { findings, pending } = narrowings(input);
|
|
235
|
-
if (pending.length > 0) {
|
|
236
|
-
throw new Error(`${String(pending.length)} finding(s) a declared edge holds pending, and no edge evaluation is published`);
|
|
237
|
-
}
|
|
238
|
-
return findings;
|
|
239
|
-
};
|
|
207
|
+
/** The findings of the visibility-narrowing family, `DS1100` to `DS1199`. */
|
|
208
|
+
export const visibilityNarrowing = narrowings;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The findings pass over one run: the emitter table's findings with the suppressions
|
|
3
|
+
* applied and the declared edges evaluated, and the stale suppressions, the totals and the
|
|
4
|
+
* verdict a report carries beside them.
|
|
5
|
+
*/
|
|
6
|
+
import { decidedFindings } from "./findings/emitters.js";
|
|
7
|
+
import { fileRefsOf, staleSuppressions, } from "./findings/self-check.js";
|
|
8
|
+
import { isRowSubject, pairs, suppressionCounts } from "./suppress.js";
|
|
9
|
+
import { EXIT_CLEAN, EXIT_FINDINGS } from "./verbs/verb.js";
|
|
10
|
+
/** The exit code of a report holding at least one pending finding. */
|
|
11
|
+
export const EXIT_PENDING = 4;
|
|
12
|
+
/** The severities ranked, the strongest highest. */
|
|
13
|
+
const SEVERITY_RANK = { allow: 0, warn: 1, deny: 2 };
|
|
14
|
+
/** Two strings ordered bytewise. */
|
|
15
|
+
function compare(a, b) {
|
|
16
|
+
if (a === b) {
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
return a < b ? -1 : 1;
|
|
20
|
+
}
|
|
21
|
+
/** The canonical order: path, line, column, code, then the symbol reference. */
|
|
22
|
+
function canonical(a, b) {
|
|
23
|
+
return (compare(a.position.path, b.position.path) ||
|
|
24
|
+
a.position.line - b.position.line ||
|
|
25
|
+
a.position.column - b.position.column ||
|
|
26
|
+
compare(a.code, b.code) ||
|
|
27
|
+
compare(a.symbol.ref, b.symbol.ref));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The pass over one run. The emitter table decides every record against every finding the
|
|
31
|
+
* run would produce with no record there, withholds what the records claim, and evaluates
|
|
32
|
+
* the declared edges; a stale record then names the codes its declaration reports instead.
|
|
33
|
+
*/
|
|
34
|
+
export function findingsPass(inputs) {
|
|
35
|
+
const { marked, unmarked, suppressions } = inputs;
|
|
36
|
+
const decided = decidedFindings(marked, { unmarked, records: suppressions.records });
|
|
37
|
+
const { ledger, would } = decided;
|
|
38
|
+
const stale = [];
|
|
39
|
+
suppressions.records.forEach((record, at) => {
|
|
40
|
+
if (ledger.verdicts[at] !== "stale") {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const reports = would
|
|
44
|
+
.filter((finding) => finding.code !== record.code && pairs({ ...record, code: finding.code }, finding))
|
|
45
|
+
.map((finding) => finding.code);
|
|
46
|
+
stale.push({ record, reports });
|
|
47
|
+
});
|
|
48
|
+
const records = staleSuppressions(stale, fileRefsOf(marked.swept.matrix.union.symbols));
|
|
49
|
+
const counts = suppressionCounts(suppressions.records, ledger);
|
|
50
|
+
return {
|
|
51
|
+
findings: [...decided.findings].sort(canonical),
|
|
52
|
+
staleSuppressions: records,
|
|
53
|
+
edgeEvaluations: decided.evaluations,
|
|
54
|
+
totals: {
|
|
55
|
+
suppressionsInEffect: counts.inEffect,
|
|
56
|
+
reasonsRecorded: counts.reasonsRecorded,
|
|
57
|
+
staleSuppressions: records.length,
|
|
58
|
+
pending: decided.evaluations.filter((one) => one.finding !== undefined).length,
|
|
59
|
+
},
|
|
60
|
+
ledger,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The exit code of a report the pass answered: pending when an evaluation holds a finding,
|
|
65
|
+
* which outranks the rest because an unmerged report is not an answer; findings when a
|
|
66
|
+
* suppression is stale or a finding's severity reaches the configured failing severity;
|
|
67
|
+
* clean otherwise.
|
|
68
|
+
*/
|
|
69
|
+
export function verdictOf(result, config) {
|
|
70
|
+
if (result.totals.pending > 0) {
|
|
71
|
+
return EXIT_PENDING;
|
|
72
|
+
}
|
|
73
|
+
if (result.totals.staleSuppressions > 0) {
|
|
74
|
+
return EXIT_FINDINGS;
|
|
75
|
+
}
|
|
76
|
+
const failing = SEVERITY_RANK[config.reporters.failOn];
|
|
77
|
+
const fails = result.findings.some((finding) => SEVERITY_RANK[finding.severity] >= failing);
|
|
78
|
+
return fails ? EXIT_FINDINGS : EXIT_CLEAN;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The findings of a report as a baseline records them, in report order. A finding about a
|
|
82
|
+
* row of a document is not recorded: no record withholds one, so a row recording it would
|
|
83
|
+
* be stale on the first read back.
|
|
84
|
+
*/
|
|
85
|
+
export function recordedFindings(result) {
|
|
86
|
+
return result.findings
|
|
87
|
+
.filter((finding) => !isRowSubject(finding.symbol.kind))
|
|
88
|
+
.map((finding) => ({
|
|
89
|
+
code: finding.code,
|
|
90
|
+
symbol: finding.symbol.ref,
|
|
91
|
+
path: finding.position.path,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
/** The analyzer identity a baseline this analyzer writes names as its rows' provenance. */
|
|
95
|
+
export function analyzerProvenance(version) {
|
|
96
|
+
return { analyzer: "deadset-ts", version };
|
|
97
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read of a strict JSON document beside its parse: what a parse into a value cannot
|
|
3
|
+
* report, the offset of each record of one array and a member written twice.
|
|
4
|
+
*/
|
|
5
|
+
/** The token walk over a document `JSON.parse` accepted, so every token is well formed. */
|
|
6
|
+
class Walk {
|
|
7
|
+
opened = [];
|
|
8
|
+
/** The member written twice, where one is, as the offset of its name. */
|
|
9
|
+
twice;
|
|
10
|
+
text;
|
|
11
|
+
array;
|
|
12
|
+
at = 0;
|
|
13
|
+
constructor(text, array) {
|
|
14
|
+
this.text = text;
|
|
15
|
+
this.array = array;
|
|
16
|
+
}
|
|
17
|
+
run() {
|
|
18
|
+
this.value("document");
|
|
19
|
+
}
|
|
20
|
+
skip() {
|
|
21
|
+
while (/[ \t\r\n]/u.test(this.text[this.at] ?? "")) {
|
|
22
|
+
this.at += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
string() {
|
|
26
|
+
const start = this.at;
|
|
27
|
+
this.at += 1;
|
|
28
|
+
while (this.text[this.at] !== '"') {
|
|
29
|
+
this.at += this.text[this.at] === "\\" ? 2 : 1;
|
|
30
|
+
}
|
|
31
|
+
this.at += 1;
|
|
32
|
+
return JSON.parse(this.text.slice(start, this.at));
|
|
33
|
+
}
|
|
34
|
+
value(role) {
|
|
35
|
+
this.skip();
|
|
36
|
+
const char = this.text[this.at];
|
|
37
|
+
if (char === "{") {
|
|
38
|
+
if (role === "record") {
|
|
39
|
+
this.opened.push(this.at);
|
|
40
|
+
}
|
|
41
|
+
this.object(role);
|
|
42
|
+
}
|
|
43
|
+
else if (char === "[") {
|
|
44
|
+
this.list(role === "array" ? "record" : "other");
|
|
45
|
+
}
|
|
46
|
+
else if (char === '"') {
|
|
47
|
+
this.string();
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
while (this.at < this.text.length && !/[,\]} \t\r\n]/u.test(this.text[this.at] ?? "")) {
|
|
51
|
+
this.at += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
object(role) {
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
this.at += 1;
|
|
58
|
+
for (;;) {
|
|
59
|
+
this.skip();
|
|
60
|
+
if (this.text[this.at] === "}") {
|
|
61
|
+
this.at += 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (this.text[this.at] === ",") {
|
|
65
|
+
this.at += 1;
|
|
66
|
+
this.skip();
|
|
67
|
+
}
|
|
68
|
+
const offset = this.at;
|
|
69
|
+
const name = this.string();
|
|
70
|
+
if (seen.has(name) && this.twice === undefined) {
|
|
71
|
+
this.twice = { name, offset };
|
|
72
|
+
}
|
|
73
|
+
seen.add(name);
|
|
74
|
+
this.skip();
|
|
75
|
+
this.at += 1;
|
|
76
|
+
this.value(role === "document" && name === this.array ? "array" : "other");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
list(element) {
|
|
80
|
+
this.at += 1;
|
|
81
|
+
for (;;) {
|
|
82
|
+
this.skip();
|
|
83
|
+
if (this.text[this.at] === "]") {
|
|
84
|
+
this.at += 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (this.text[this.at] === ",") {
|
|
88
|
+
this.at += 1;
|
|
89
|
+
}
|
|
90
|
+
this.value(element);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Walks a document `JSON.parse` accepted, whose top-level member `array` holds the
|
|
96
|
+
* records. A member one object writes twice reads as though it were written once, its
|
|
97
|
+
* later value silently replacing the earlier one, so a document holding one is one a
|
|
98
|
+
* reader names rather than reads.
|
|
99
|
+
*/
|
|
100
|
+
export function walkDocument(text, array) {
|
|
101
|
+
const walk = new Walk(text, array);
|
|
102
|
+
walk.run();
|
|
103
|
+
return { opened: walk.opened, twice: walk.twice };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* One offset of a document as a finding carries it: the line counted from one, and the
|
|
107
|
+
* column counted from one in UTF-16 code units.
|
|
108
|
+
*/
|
|
109
|
+
export function positionAt(text, file, offset) {
|
|
110
|
+
const before = text.slice(0, offset);
|
|
111
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
112
|
+
return { path: file, line: before.split("\n").length, column: offset - lineStart + 1 };
|
|
113
|
+
}
|
package/dist/src/roots.js
CHANGED
|
@@ -9,18 +9,11 @@
|
|
|
9
9
|
* candidate under reference counting.
|
|
10
10
|
*/
|
|
11
11
|
import { aliasChains } from "./alias-chain.js";
|
|
12
|
-
import { REPOSITORY_DOCUMENT } from "./config.js";
|
|
13
12
|
import { entryPoints } from "./entry-points.js";
|
|
14
13
|
import { globExpression } from "./glob.js";
|
|
15
14
|
import { nodeKey } from "./inventory.js";
|
|
16
15
|
import { byPosition } from "./position.js";
|
|
17
16
|
import { sourceFilesOf } from "./source-files.js";
|
|
18
|
-
/** The code the Contract gives a configured root that matches nothing. */
|
|
19
|
-
const UNMATCHED_ROOT_CODE = "DS1704";
|
|
20
|
-
/** The subject kind a finding under that code carries, from the Contract's vocabulary. */
|
|
21
|
-
const UNMATCHED_ROOT_SUBJECT = "root";
|
|
22
|
-
/** The fixed position of a finding about a document rather than about a line of one. */
|
|
23
|
-
const DOCUMENT_POSITION = 1;
|
|
24
17
|
/** Whether a configured string carries either special character. */
|
|
25
18
|
function isPattern(text) {
|
|
26
19
|
return text.includes("*") || text.includes("?");
|
|
@@ -293,30 +286,3 @@ function configured(held, patterns, add) {
|
|
|
293
286
|
export function unmatchedEverywhere(patterns, perProject) {
|
|
294
287
|
return [...new Set(patterns)].filter((pattern) => perProject.every((project) => project.unmatched.includes(pattern)));
|
|
295
288
|
}
|
|
296
|
-
/**
|
|
297
|
-
* The findings a run reports for the configured strings that named nothing.
|
|
298
|
-
*
|
|
299
|
-
* `document` is the path of the document that declared the roots, below the target
|
|
300
|
-
* root; where the run read them from no document of the target it is empty and the
|
|
301
|
-
* finding names the conventional repository configuration, which is the document a
|
|
302
|
-
* maintainer writes a configured root in. Every finding of one run sits at that
|
|
303
|
-
* document's first position: the roots are written as an array whose members carry
|
|
304
|
-
* no line of their own, so the string each finding names is what tells one from
|
|
305
|
-
* another.
|
|
306
|
-
*/
|
|
307
|
-
export function unmatchedRoots(sources, document) {
|
|
308
|
-
const position = {
|
|
309
|
-
path: document === "" ? REPOSITORY_DOCUMENT : document,
|
|
310
|
-
line: DOCUMENT_POSITION,
|
|
311
|
-
column: DOCUMENT_POSITION,
|
|
312
|
-
endLine: DOCUMENT_POSITION,
|
|
313
|
-
};
|
|
314
|
-
return sources.map((source) => ({
|
|
315
|
-
code: UNMATCHED_ROOT_CODE,
|
|
316
|
-
position,
|
|
317
|
-
symbol: { ref: source, kind: UNMATCHED_ROOT_SUBJECT, name: source, sizeLines: 1 },
|
|
318
|
-
message: isPattern(source)
|
|
319
|
-
? "configured root pattern matches no symbol of the inventory"
|
|
320
|
-
: "configured root matches no symbol of the inventory",
|
|
321
|
-
}));
|
|
322
|
-
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two file-backed suppression documents at the target root: the ignore file, whose
|
|
3
|
+
* entries a maintainer writes, and the baseline, whose rows an analyzer writes. They share
|
|
4
|
+
* one record shape, so one decoder and one matcher serve both, and a row moves into the
|
|
5
|
+
* ignore file by gaining a maintainer's reason.
|
|
6
|
+
*/
|
|
7
|
+
import { joinPath } from "./paths.js";
|
|
8
|
+
import { positionAt, walkDocument } from "./json-document.js";
|
|
9
|
+
import { CODE_FORM, SUPPRESSION_WITHOUT_REASON, SuppressionError, UNSCOPED_ENTRY, blankReason, isEntryPath, isSymbolRef, siteText, } from "./suppress.js";
|
|
10
|
+
/** The name and the location the grammar fixes for the ignore file. */
|
|
11
|
+
export const IGNORE_FILE = "deadset-ignore.json";
|
|
12
|
+
/** The name and the location the grammar fixes for the baseline. */
|
|
13
|
+
export const BASELINE_FILE = "deadset-baseline.json";
|
|
14
|
+
const IGNORE_SHAPE = {
|
|
15
|
+
file: IGNORE_FILE,
|
|
16
|
+
array: "ignore",
|
|
17
|
+
mechanism: "ignore",
|
|
18
|
+
documentWant: "an object carrying the ignore array, and an optional description",
|
|
19
|
+
recordWant: "an entry naming code, symbol, path and reason, each a string",
|
|
20
|
+
};
|
|
21
|
+
const BASELINE_SHAPE = {
|
|
22
|
+
file: BASELINE_FILE,
|
|
23
|
+
array: "baseline",
|
|
24
|
+
mechanism: "baseline",
|
|
25
|
+
documentWant: "an object carrying the baseline array, and an optional description",
|
|
26
|
+
recordWant: "a row naming code, symbol, path and reason, each a string",
|
|
27
|
+
};
|
|
28
|
+
/** The closed key list of one entry or row. */
|
|
29
|
+
const RECORD_KEYS = new Set(["code", "symbol", "path", "reason"]);
|
|
30
|
+
const CODE_WANT = "DS followed by four digits";
|
|
31
|
+
const SYMBOL_WANT = "a stable symbol reference, go://<import-path>#<fragment> or ts://<package>/<source-path>#<fragment>";
|
|
32
|
+
const PATH_WANT = "a path relative to the target root, with the solidus as separator";
|
|
33
|
+
/** The document itself, for a defect no record position describes. */
|
|
34
|
+
function documentSite(shape) {
|
|
35
|
+
return { path: shape.file, line: 0, column: 0 };
|
|
36
|
+
}
|
|
37
|
+
function isObject(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function referenceKey(ref, path) {
|
|
41
|
+
return `${ref}\u0000${path}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads one entry or row: the records it binds, the refusals it carries, or the
|
|
45
|
+
* malformed value that ends the run.
|
|
46
|
+
*/
|
|
47
|
+
function readRecord(value, site, byReference, shape) {
|
|
48
|
+
const refuse = (text, want) => {
|
|
49
|
+
throw new SuppressionError(shape.mechanism, `${shape.file} ${siteText(site)}`, text, want);
|
|
50
|
+
};
|
|
51
|
+
if (!isObject(value)) {
|
|
52
|
+
return refuse(JSON.stringify(value), shape.recordWant);
|
|
53
|
+
}
|
|
54
|
+
for (const [key, held] of Object.entries(value)) {
|
|
55
|
+
if (!RECORD_KEYS.has(key)) {
|
|
56
|
+
refuse(`the member ${JSON.stringify(key)} is not one the grammar declares`, shape.recordWant);
|
|
57
|
+
}
|
|
58
|
+
if (typeof held !== "string") {
|
|
59
|
+
refuse(`the member ${JSON.stringify(key)} holds ${JSON.stringify(held)}`, shape.recordWant);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const entry = value;
|
|
63
|
+
const { code, symbol, path, reason } = {
|
|
64
|
+
code: entry["code"],
|
|
65
|
+
symbol: entry["symbol"],
|
|
66
|
+
path: entry["path"],
|
|
67
|
+
reason: entry["reason"],
|
|
68
|
+
};
|
|
69
|
+
if (code === undefined) {
|
|
70
|
+
return refuse("a record naming no code", shape.recordWant);
|
|
71
|
+
}
|
|
72
|
+
if (symbol === undefined) {
|
|
73
|
+
return refuse("a record naming no symbol", shape.recordWant);
|
|
74
|
+
}
|
|
75
|
+
if (!CODE_FORM.test(code)) {
|
|
76
|
+
return refuse(JSON.stringify(code), CODE_WANT);
|
|
77
|
+
}
|
|
78
|
+
if (!isSymbolRef(symbol)) {
|
|
79
|
+
return refuse(JSON.stringify(symbol), SYMBOL_WANT);
|
|
80
|
+
}
|
|
81
|
+
if (path !== undefined && path !== "" && !isEntryPath(path)) {
|
|
82
|
+
return refuse(JSON.stringify(path), PATH_WANT);
|
|
83
|
+
}
|
|
84
|
+
const written = {
|
|
85
|
+
code,
|
|
86
|
+
symbol,
|
|
87
|
+
path: path ?? "",
|
|
88
|
+
reason: reason ?? "",
|
|
89
|
+
site,
|
|
90
|
+
mechanism: shape.mechanism,
|
|
91
|
+
};
|
|
92
|
+
const refusals = [];
|
|
93
|
+
if (blankReason(reason)) {
|
|
94
|
+
refusals.push({ ...written, reported: SUPPRESSION_WITHOUT_REASON });
|
|
95
|
+
}
|
|
96
|
+
if (written.path === "") {
|
|
97
|
+
refusals.push({ ...written, reported: UNSCOPED_ENTRY });
|
|
98
|
+
}
|
|
99
|
+
if (refusals.length > 0) {
|
|
100
|
+
return { records: [], refusals };
|
|
101
|
+
}
|
|
102
|
+
const named = byReference.get(referenceKey(symbol, written.path)) ?? [];
|
|
103
|
+
if (named.length === 0) {
|
|
104
|
+
return { records: [{ ...written, bound: "" }], refusals: [] };
|
|
105
|
+
}
|
|
106
|
+
return { records: named.map((one) => ({ ...written, bound: one.id })), refusals: [] };
|
|
107
|
+
}
|
|
108
|
+
/** Reads one document at the target root, binding each record against the run's declarations. */
|
|
109
|
+
function readDocument(host, targetRoot, symbols, shape) {
|
|
110
|
+
const file = joinPath(targetRoot, shape.file);
|
|
111
|
+
if (host.kindOf(file) === "absent") {
|
|
112
|
+
return { records: [], refusals: [] };
|
|
113
|
+
}
|
|
114
|
+
const text = host.readFile(file);
|
|
115
|
+
const malformed = (detail, site = documentSite(shape)) => {
|
|
116
|
+
throw new SuppressionError(shape.mechanism, siteText(site), detail, shape.documentWant);
|
|
117
|
+
};
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return malformed(`${shape.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
if (!isObject(parsed)) {
|
|
126
|
+
return malformed(shape.file);
|
|
127
|
+
}
|
|
128
|
+
const walk = walkDocument(text, shape.array);
|
|
129
|
+
if (walk.twice !== undefined) {
|
|
130
|
+
malformed(`the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`, positionAt(text, shape.file, walk.twice.offset));
|
|
131
|
+
}
|
|
132
|
+
for (const key of Object.keys(parsed)) {
|
|
133
|
+
if (key !== "description" && key !== shape.array) {
|
|
134
|
+
malformed(`the member ${JSON.stringify(key)} is not one the grammar declares`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const description = parsed["description"];
|
|
138
|
+
const held = parsed[shape.array];
|
|
139
|
+
if ((description !== undefined && typeof description !== "string") || !Array.isArray(held)) {
|
|
140
|
+
return malformed(shape.file);
|
|
141
|
+
}
|
|
142
|
+
const byReference = new Map();
|
|
143
|
+
for (const symbol of symbols) {
|
|
144
|
+
const key = referenceKey(symbol.ref, symbol.position.path);
|
|
145
|
+
byReference.set(key, [...(byReference.get(key) ?? []), symbol]);
|
|
146
|
+
}
|
|
147
|
+
const records = [];
|
|
148
|
+
const refusals = [];
|
|
149
|
+
held.forEach((value, index) => {
|
|
150
|
+
const offset = walk.opened[index];
|
|
151
|
+
const site = offset === undefined ? documentSite(shape) : positionAt(text, shape.file, offset);
|
|
152
|
+
const read = readRecord(value, site, byReference, shape);
|
|
153
|
+
records.push(...read.records);
|
|
154
|
+
refusals.push(...read.refusals);
|
|
155
|
+
});
|
|
156
|
+
return { records, refusals };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The ignore file at the target root, each entry bound to the declaration whose reference
|
|
160
|
+
* and file both equal the ones it names, and never by glob, pattern or substring. An
|
|
161
|
+
* absent file is an empty one.
|
|
162
|
+
*/
|
|
163
|
+
export function readIgnoreFile(host, targetRoot, symbols) {
|
|
164
|
+
return readDocument(host, targetRoot, symbols, IGNORE_SHAPE);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The baseline at the target root, each row matched exactly as an ignore entry is. A row's
|
|
168
|
+
* reason is the provenance an analyzer wrote, never a maintainer's judgement: the finding
|
|
169
|
+
* it records is real, and the row says only that it was there when the baseline was
|
|
170
|
+
* written. An absent file is an empty one.
|
|
171
|
+
*/
|
|
172
|
+
export function readBaseline(host, targetRoot, symbols) {
|
|
173
|
+
return readDocument(host, targetRoot, symbols, BASELINE_SHAPE);
|
|
174
|
+
}
|
|
175
|
+
/** What a written document says about itself, so the same report writes the same bytes. */
|
|
176
|
+
const BASELINE_DESCRIPTION = "Recorded findings: a run fails only on a finding this document does not hold. " +
|
|
177
|
+
"Every reason is the analyzer that recorded the row, never an adjudication.";
|
|
178
|
+
/**
|
|
179
|
+
* A baseline holding one row per finding, in the order given, which is the report's. Every
|
|
180
|
+
* row carries the provenance as its reason. A finding missing a value a row needs, and an
|
|
181
|
+
* identity missing its name or version, are refused rather than written, because the
|
|
182
|
+
* grammar requires a reason on every row.
|
|
183
|
+
*/
|
|
184
|
+
export function writeBaseline(findings, provenance) {
|
|
185
|
+
if (provenance.analyzer === "" || provenance.version === "") {
|
|
186
|
+
throw new Error(`a baseline row would carry no reason: the analyzer identity names ${JSON.stringify(provenance.analyzer)} at version ${JSON.stringify(provenance.version)}`);
|
|
187
|
+
}
|
|
188
|
+
const reason = `recorded by ${provenance.analyzer} ${provenance.version}`;
|
|
189
|
+
const baseline = findings.map((found) => {
|
|
190
|
+
if (found.code === "" || found.symbol === "" || found.path === "") {
|
|
191
|
+
throw new Error(`a baseline row would carry no reason: a finding names code ${JSON.stringify(found.code)}, symbol ${JSON.stringify(found.symbol)} and path ${JSON.stringify(found.path)}`);
|
|
192
|
+
}
|
|
193
|
+
return { code: found.code, symbol: found.symbol, path: found.path, reason };
|
|
194
|
+
});
|
|
195
|
+
return `${JSON.stringify({ description: BASELINE_DESCRIPTION, baseline }, null, 2)}\n`;
|
|
196
|
+
}
|