@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.
@@ -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
- * A finding about a symbol a declared cross-language edge names is pending instead of
178
- * reported, once per side naming it: the edge counts as a reference from outside the
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 === undefined) {
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
- pending.sort((a, b) => compare(a.edge, b.edge) || compare(a.side, b.side));
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
- * The findings of the visibility-narrowing family, `DS1100` to `DS1199`. A finding a
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
+ }