@cplieger/deadset-ts 3.10.0 → 3.12.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 (57) hide show
  1. package/README.md +13 -13
  2. package/dist/src/analysis.js +56 -16
  3. package/dist/src/config.js +6 -0
  4. package/dist/src/conformance-record.js +2 -12
  5. package/dist/src/consumers.js +49 -0
  6. package/dist/src/discover.js +59 -23
  7. package/dist/src/findings/completion.js +49 -17
  8. package/dist/src/findings/non-code-artifacts.js +9 -2
  9. package/dist/src/findings/reads-and-writes.js +3 -1
  10. package/dist/src/findings-pass.js +3 -5
  11. package/dist/src/graph.js +8 -5
  12. package/dist/src/matrix.js +14 -3
  13. package/dist/src/references.js +21 -9
  14. package/dist/src/report.js +4 -1
  15. package/dist/src/reporters/annotations.js +36 -0
  16. package/dist/src/reporters/fingerprint.js +61 -0
  17. package/dist/src/reporters/reporter.js +7 -1
  18. package/dist/src/reporters/reporters.js +18 -0
  19. package/dist/src/reporters/rules.js +178 -0
  20. package/dist/src/reporters/sarif.js +235 -0
  21. package/dist/src/reporters/sha256.js +101 -0
  22. package/dist/src/reporters/template.js +877 -0
  23. package/dist/src/run.js +4 -10
  24. package/dist/src/session.js +15 -0
  25. package/dist/src/sweep.js +11 -0
  26. package/dist/src/verbs/analyze.js +48 -16
  27. package/dist/src/verbs/explain.js +317 -0
  28. package/jsr.json +1 -1
  29. package/package.json +1 -1
  30. package/src/analysis.ts +123 -29
  31. package/src/config.ts +8 -0
  32. package/src/conformance-record.ts +2 -14
  33. package/src/consumers.ts +86 -0
  34. package/src/discover.ts +67 -23
  35. package/src/findings/completion.ts +56 -20
  36. package/src/findings/non-code-artifacts.ts +9 -2
  37. package/src/findings/reads-and-writes.ts +3 -1
  38. package/src/findings-pass.ts +5 -7
  39. package/src/graph.ts +14 -5
  40. package/src/matrix.ts +14 -5
  41. package/src/references.ts +48 -9
  42. package/src/report.ts +17 -2
  43. package/src/reporters/annotations.ts +67 -0
  44. package/src/reporters/fingerprint.ts +69 -0
  45. package/src/reporters/json.ts +2 -2
  46. package/src/reporters/reporter.ts +21 -2
  47. package/src/reporters/reporters.ts +18 -1
  48. package/src/reporters/rules.ts +195 -0
  49. package/src/reporters/sarif.ts +287 -0
  50. package/src/reporters/sha256.ts +120 -0
  51. package/src/reporters/template.ts +989 -0
  52. package/src/reporters/text.ts +2 -3
  53. package/src/run.ts +5 -11
  54. package/src/session.ts +19 -0
  55. package/src/sweep.ts +13 -1
  56. package/src/verbs/analyze.ts +60 -19
  57. package/src/verbs/explain.ts +380 -0
@@ -6,12 +6,13 @@
6
6
 
7
7
  import type { RunSweep } from "../analysis.ts";
8
8
  import { componentId, type Component } from "../components.ts";
9
- import type { Confidence, Config, Severity } from "../config.ts";
9
+ import type { Confidence, Severity } from "../config.ts";
10
10
  import type { CompletedFinding, Finding, FindingComponent } from "../finding.ts";
11
11
  import { OUTSIDE } from "../graph.ts";
12
- import { KINDS } from "../kinds.ts";
12
+ import { KINDS, type KindRow } from "../kinds.ts";
13
13
  import { FAMILY_KEY_LENGTH } from "../resolve.ts";
14
14
  import { isRowSubject, type Dials } from "../suppress.ts";
15
+ import type { Boundary } from "./boundary.ts";
15
16
  import type { EmitterInput } from "./emitter.ts";
16
17
 
17
18
  /** The classes ranked by the claim each makes, the strongest highest. */
@@ -22,31 +23,64 @@ function capped(found: Confidence, ceiling: Confidence): Confidence {
22
23
  return RANK[found] <= RANK[ceiling] ? found : ceiling;
23
24
  }
24
25
 
26
+ /** The kind whose default turns on what the run knows about the target's consumers. */
27
+ const UNUSED_EXPORTED = "DS1001";
28
+
29
+ /** Whether the run loaded every consumer the scope declared, which holds of a scope declaring none. */
30
+ function everyDeclaredLoaded(boundary: Boundary): boolean {
31
+ const { declared, loaded } = boundary.consumers;
32
+ return declared.every((id) => loaded.includes(id));
33
+ }
34
+
35
+ /**
36
+ * Whether a library's published API may have a caller the run cannot see: the target is
37
+ * a library, and the run either holds no consumer information or did not load a consumer
38
+ * the scope declared. A configuration declaring the consumer set complete holds consumer
39
+ * information whether or not it declares a consumer, because it says no other exists.
40
+ */
41
+ function openWorld(input: EmitterInput): boolean {
42
+ const known = input.config.consumersComplete || input.boundary.consumers.declared.length > 0;
43
+ return input.config.targetKind === "library" && !(known && everyDeclaredLoaded(input.boundary));
44
+ }
45
+
25
46
  /**
26
47
  * The severity the configuration gives one code: the key naming the code, else the key
27
- * naming its family, else the code's default.
48
+ * naming its family, else the code's default. The unused-exported kind defaults to
49
+ * `allow` while a library's published API is open to callers the run cannot see.
28
50
  */
29
- function severityOf(config: Config, code: string, fallback: Severity): Severity {
30
- return (
31
- config.severity.get(code) ?? config.severity.get(code.slice(0, FAMILY_KEY_LENGTH)) ?? fallback
32
- );
51
+ function severityOf(input: EmitterInput, code: string, fallback: Severity): Severity {
52
+ const { config } = input;
53
+ const named = config.severity.get(code) ?? config.severity.get(code.slice(0, FAMILY_KEY_LENGTH));
54
+ if (named !== undefined) {
55
+ return named;
56
+ }
57
+ return code === UNUSED_EXPORTED && openWorld(input) ? "allow" : fallback;
33
58
  }
34
59
 
35
60
  /**
36
61
  * The reachability class of one declaration of the run. A declaration of a library's
37
- * published API has callers outside the target, and the run loads no consumer that
38
- * would name them, so it is `possible`; every other declaration has every reference
39
- * in the loaded program, a private member and a `#private` name among them, and is
40
- * `certain`.
62
+ * published API has callers outside the target: it is `certain` where the run loaded
63
+ * every consumer the scope declared and declared at least one, `probable` where it
64
+ * declared consumers and did not load them all, and `possible` where it declared none.
65
+ * Whether the configuration declares the consumer set complete decides nothing here.
66
+ * Every other declaration has every reference in the loaded program, a private member
67
+ * and a `#private` name among them, and is `certain`.
41
68
  */
42
- function reachabilityClasses(swept: RunSweep): (id: string) => Confidence {
43
- const union = swept.matrix.union;
69
+ function reachabilityClasses(input: EmitterInput): (id: string) => Confidence {
70
+ const union = input.swept.matrix.union;
44
71
  const published = new Set(
45
72
  union.rooted
46
73
  .filter((root) => root.kind === "published-api")
47
74
  .map((root) => union.symbols[root.at]?.id),
48
75
  );
49
- return (id) => (published.has(id) ? "possible" : "certain");
76
+ const { declared } = input.boundary.consumers;
77
+ const outside: Confidence =
78
+ declared.length === 0
79
+ ? "possible"
80
+ : everyDeclaredLoaded(input.boundary)
81
+ ? "certain"
82
+ : "probable";
83
+ return (id) => (published.has(id) ? outside : "certain");
50
84
  }
51
85
 
52
86
  /** The component each dead declaration of the run falls in, and none for a live one. */
@@ -76,22 +110,24 @@ function findingComponents(swept: RunSweep): (id: string) => FindingComponent |
76
110
  * relation and holds where it is declared; a file or a manifest row is `certain` and
77
111
  * holds in every configuration. A subject in no dead component gets one of its own,
78
112
  * numbered past the computed ones in finding order, so no two families mint one; nothing
79
- * falls with it, so its deletion removes no line.
113
+ * falls with it, so its deletion removes no line. `kinds` is the vocabulary each code's
114
+ * row is read from, the shipped one unless a caller supplies its own.
80
115
  */
81
116
  export function completed(
82
117
  input: EmitterInput,
83
118
  findings: readonly Finding[],
119
+ kinds: ReadonlyMap<string, KindRow> = KINDS,
84
120
  ): readonly CompletedFinding[] {
85
121
  const { swept } = input;
86
122
  const union = swept.matrix.union;
87
123
  const positions = new Map(union.symbols.map((symbol, at) => [symbol.ref, at]));
88
124
  const candidates = new Map(swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
89
- const classOf = reachabilityClasses(swept);
125
+ const classOf = reachabilityClasses(input);
90
126
  const componentOf = findingComponents(swept);
91
127
  let minted = swept.sweep.components.length;
92
128
 
93
129
  return findings.map((finding): CompletedFinding => {
94
- const row = KINDS.get(finding.code);
130
+ const row = kinds.get(finding.code);
95
131
  if (row === undefined) {
96
132
  throw new Error(`${finding.code} names no live row of the issue-kind vocabulary`);
97
133
  }
@@ -140,7 +176,7 @@ export function completed(
140
176
  (judged === undefined ? swept.matrix.configurations : (swept.matrix.heldIn[at] ?? [])),
141
177
  consumersLoaded: input.boundary.consumers.loaded,
142
178
  fixability: row.fixability,
143
- severity: severityOf(input.config, finding.code, row.defaultSeverity),
179
+ severity: severityOf(input, finding.code, row.defaultSeverity),
144
180
  message: finding.message,
145
181
  details: finding.details ?? {},
146
182
  };
@@ -168,7 +204,7 @@ export function dialsOf(
168
204
  findings: readonly CompletedFinding[],
169
205
  ): Dials<CompletedFinding> {
170
206
  const minConfidence = input.config.analysis.minConfidence;
171
- const classOf = reachabilityClasses(input.swept);
207
+ const classOf = reachabilityClasses(input);
172
208
  const components = new Set(
173
209
  findings
174
210
  .filter((finding) => finding.component.root && dialed(finding, minConfidence))
@@ -181,7 +217,7 @@ export function dialsOf(
181
217
  row !== undefined &&
182
218
  dialed(
183
219
  {
184
- severity: severityOf(input.config, code, row.defaultSeverity),
220
+ severity: severityOf(input, code, row.defaultSeverity),
185
221
  confidence: capped(classOf(id), row.maxClass),
186
222
  },
187
223
  minConfidence,
@@ -63,11 +63,18 @@ function fileNeverBuilt({ config, swept, files }: EmitterInput): readonly Findin
63
63
 
64
64
  /**
65
65
  * The paths of every file a reference written in another file reaches, naming the
66
- * file or a declaration in it. An import is such a reference, so an import from any
67
- * file reaches what it names, whatever reaches the importing file.
66
+ * file or a declaration in it, a loaded consumer's file included. An import is such a
67
+ * reference, so an import from any file reaches what it names, whatever reaches the
68
+ * importing file.
68
69
  */
69
70
  function reachedFromOutside(union: Graph): ReadonlySet<string> {
70
71
  const reached = new Set<string>();
72
+ for (const edge of union.consumed) {
73
+ const to = union.symbols[edge.to];
74
+ if (to !== undefined) {
75
+ reached.add(to.position.path);
76
+ }
77
+ }
71
78
  union.out.forEach((edges, from) => {
72
79
  const fromPath = union.symbols[from]?.position.path;
73
80
  for (const edge of edges) {
@@ -94,7 +94,9 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
94
94
  held = { writes: [], reads: 0 };
95
95
  uses.set(reference.to, held);
96
96
  }
97
- if (reference.use !== "write") {
97
+ // A consumer's store is written at a position no report of the target can name, so
98
+ // it counts as a use that keeps the declaration from this kind.
99
+ if (reference.use !== "write" || reference.consumer !== undefined) {
98
100
  held.reads += 1;
99
101
  continue;
100
102
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { AnalysisInputs } from "./analysis.ts";
8
- import type { Config, Severity } from "./config.ts";
8
+ import { fails, type Config } from "./config.ts";
9
9
  import type { EdgeEvaluation } from "./edges.ts";
10
10
  import type { CompletedFinding, Finding } from "./finding.ts";
11
11
  import { decidedFindings } from "./findings/emitters.ts";
@@ -44,9 +44,6 @@ export interface PassResult {
44
44
  /** The exit code of a report holding at least one pending finding. */
45
45
  export const EXIT_PENDING = 4;
46
46
 
47
- /** The severities ranked, the strongest highest. */
48
- const SEVERITY_RANK: Readonly<Record<Severity, number>> = { allow: 0, warn: 1, deny: 2 };
49
-
50
47
  /** Two strings ordered bytewise. */
51
48
  function compare(a: string, b: string): number {
52
49
  if (a === b) {
@@ -119,9 +116,10 @@ export function verdictOf(result: PassResult, config: Config): number {
119
116
  if (result.totals.staleSuppressions > 0) {
120
117
  return EXIT_FINDINGS;
121
118
  }
122
- const failing = SEVERITY_RANK[config.reporters.failOn];
123
- const fails = result.findings.some((finding) => SEVERITY_RANK[finding.severity] >= failing);
124
- return fails ? EXIT_FINDINGS : EXIT_CLEAN;
119
+ const failing = result.findings.some((finding) =>
120
+ fails(finding.severity, config.reporters.failOn),
121
+ );
122
+ return failing ? EXIT_FINDINGS : EXIT_CLEAN;
125
123
  }
126
124
 
127
125
  /**
package/src/graph.ts CHANGED
@@ -113,6 +113,12 @@ export interface Graph {
113
113
  readonly exportsOf: readonly (readonly number[])[];
114
114
  /** Every root naming a declaration the inventory holds, in the order the root set gives them. */
115
115
  readonly rooted: readonly Rooted[];
116
+ /**
117
+ * Every reference a loaded consumer makes to a declaration the inventory holds, in the
118
+ * order given. A consumer is a caller the analysis read, so the sweep holds what it
119
+ * names live under both relations and reaches on from there.
120
+ */
121
+ readonly consumed: readonly Edge[];
116
122
  /** The position of the declaration one identifier names, or {@link OUTSIDE}. */
117
123
  at(id: string): number;
118
124
  }
@@ -136,6 +142,7 @@ export function graphOf(
136
142
 
137
143
  const out: Edge[][] = symbols.map(() => []);
138
144
  const made = symbols.map(() => ({ production: 0, test: 0 }));
145
+ const consumed: Edge[] = [];
139
146
  for (const reference of references) {
140
147
  const to = at(reference.to);
141
148
  if (to === OUTSIDE) {
@@ -149,11 +156,12 @@ export function graphOf(
149
156
  counts.production += 1;
150
157
  }
151
158
  }
152
- out[at(reference.from)]?.push({
153
- to,
154
- test: reference.test,
155
- evaluation: reference.use === "evaluation",
156
- });
159
+ const edge = { to, test: reference.test, evaluation: reference.use === "evaluation" };
160
+ if (reference.consumer !== undefined) {
161
+ consumed.push(edge);
162
+ continue;
163
+ }
164
+ out[at(reference.from)]?.push(edge);
157
165
  }
158
166
 
159
167
  const parent = symbols.map((symbol) => at(symbol.parent));
@@ -182,6 +190,7 @@ export function graphOf(
182
190
  subject: symbols.map((symbol) => swept(symbol.kind)),
183
191
  exportsOf,
184
192
  rooted,
193
+ consumed,
185
194
  at,
186
195
  };
187
196
  }
package/src/matrix.ts CHANGED
@@ -69,12 +69,21 @@ function compare(a: string, b: string): number {
69
69
  return a < b ? -1 : 1;
70
70
  }
71
71
 
72
- /** What makes one reference one reference, whichever configuration made it. */
73
- function referenceKey(reference: Reference): string {
72
+ /**
73
+ * What makes one reference one reference, whichever configuration made it. A consumer's
74
+ * position is rendered against its own root, so the consumer is part of what it names.
75
+ */
76
+ export function referenceKey(reference: Reference): string {
74
77
  const { path, line, column } = reference.position;
75
- return [reference.from, reference.to, path, String(line), String(column), reference.use].join(
76
- "\u0000",
77
- );
78
+ return [
79
+ reference.consumer ?? "",
80
+ reference.from,
81
+ reference.to,
82
+ path,
83
+ String(line),
84
+ String(column),
85
+ reference.use,
86
+ ].join("\u0000");
78
87
  }
79
88
 
80
89
  /**
package/src/references.ts CHANGED
@@ -32,6 +32,7 @@ import { aliasChains } from "./alias-chain.ts";
32
32
  import { destructuring, propertyReadsOf, type PropertyRead } from "./destructuring.ts";
33
33
  import { globExpression } from "./glob.ts";
34
34
  import { nodeKey, type Inventory } from "./inventory.ts";
35
+ import { relativePath } from "./paths.ts";
35
36
  import { byPosition, renderPosition, type Position } from "./position.ts";
36
37
  import type { ProjectView } from "./session.ts";
37
38
 
@@ -95,6 +96,21 @@ export interface Reference {
95
96
  readonly resolution: Resolution;
96
97
  /** Whether the file that made the reference is classified as a test file. */
97
98
  readonly test: boolean;
99
+ /**
100
+ * The identifier of the consumer whose file made the reference, absent for one the
101
+ * target made. A consumer's declarations are no declarations of the run, so such a
102
+ * reference comes from outside the inventory, and its position is rendered against
103
+ * the consumer's root.
104
+ */
105
+ readonly consumer?: string;
106
+ }
107
+
108
+ /** The consumer one reference pass reads the files of, beside the target it names. */
109
+ export interface ConsumerSide {
110
+ /** The name the consumer publishes itself under. */
111
+ readonly id: string;
112
+ /** The consumer's directory, absolute: the files below it are its own. */
113
+ readonly root: string;
98
114
  }
99
115
 
100
116
  /** One rule that classified files as test files, and how many files it matched. */
@@ -162,6 +178,13 @@ export interface ReferenceOptions {
162
178
  * Each pattern is a rule of its own, which {@link testFileRulesOf} counts.
163
179
  */
164
180
  readonly testFiles: readonly string[];
181
+ /**
182
+ * The consumer whose files the pass walks instead of the target's. Its files are the
183
+ * program's own files below its root and not below the target root, each position is
184
+ * rendered against its root and the test patterns read the path below it, and only a
185
+ * reference to a declaration of `held`, the target's, is recorded.
186
+ */
187
+ readonly consumer?: ConsumerSide;
165
188
  }
166
189
 
167
190
  /**
@@ -555,7 +578,9 @@ function compare(a: string, b: string): number {
555
578
  *
556
579
  * `held` is the inventory of the same project, whose declaration map is what a resolved
557
580
  * symbol is looked up in; `targetRoot` is the absolute path every position is rendered
558
- * against, which is the root the inventory was read against.
581
+ * against, which is the root the inventory was read against. A pass over a consumer
582
+ * walks the consumer's files instead and renders against its root, as
583
+ * {@link ReferenceOptions.consumer} states.
559
584
  *
560
585
  * The order is by the referencing position, then by the declaration named, the
561
586
  * declaration referencing it and the use. That is a total order because one name
@@ -581,7 +606,18 @@ export function references<Brand>(
581
606
  let residueFallbacks = 0;
582
607
  let shorthandLookups = 0;
583
608
 
584
- const files = project.ownSourceFiles();
609
+ const consumer = options.consumer;
610
+ const root = consumer?.root ?? targetRoot;
611
+ const files =
612
+ consumer === undefined
613
+ ? project.ownSourceFiles()
614
+ : project
615
+ .ownSourceFiles()
616
+ .filter(
617
+ (file) =>
618
+ relativePath(consumer.root, file.fileName) !== undefined &&
619
+ relativePath(targetRoot, file.fileName) === undefined,
620
+ );
585
621
  const fileIds = new Set(
586
622
  held.symbols.filter((symbol) => symbol.kind === "file").map((symbol) => symbol.id),
587
623
  );
@@ -615,7 +651,7 @@ export function references<Brand>(
615
651
  };
616
652
 
617
653
  for (const file of files) {
618
- const path = renderPosition(file, targetRoot, 0).path;
654
+ const path = renderPosition(file, root, 0).path;
619
655
  const test = patterns.some((pattern) => pattern.test(path));
620
656
  if (test) {
621
657
  tests.push(path);
@@ -633,7 +669,7 @@ export function references<Brand>(
633
669
  found.push({
634
670
  from: declaration,
635
671
  to: declaration,
636
- position: renderPosition(file, targetRoot, decorator.getStart()),
672
+ position: renderPosition(file, root, decorator.getStart()),
637
673
  use: "decorator",
638
674
  resolution: "syntax",
639
675
  test,
@@ -671,7 +707,7 @@ export function references<Brand>(
671
707
  if (symbol === undefined) {
672
708
  continue;
673
709
  }
674
- const position = renderPosition(file, targetRoot, link.at.getStart());
710
+ const position = renderPosition(file, root, link.at.getStart());
675
711
  for (const id of carriedBy(link, symbol)) {
676
712
  found.push({
677
713
  from: link.from,
@@ -689,7 +725,7 @@ export function references<Brand>(
689
725
  if (module === undefined) {
690
726
  continue;
691
727
  }
692
- const position = renderPosition(file, targetRoot, evaluation.node.getStart());
728
+ const position = renderPosition(file, root, evaluation.node.getStart());
693
729
  for (const id of chains.declarationsOf(module)) {
694
730
  if (fileIds.has(id)) {
695
731
  found.push({
@@ -711,7 +747,7 @@ export function references<Brand>(
711
747
  if (symbol === undefined || read === undefined) {
712
748
  return;
713
749
  }
714
- const position = renderPosition(file, targetRoot, read.key.getStart());
750
+ const position = renderPosition(file, root, read.key.getStart());
715
751
  for (const target of chains.chainOf(symbol)) {
716
752
  found.push({
717
753
  from: read.from,
@@ -735,7 +771,7 @@ export function references<Brand>(
735
771
  if (symbol === undefined) {
736
772
  continue;
737
773
  }
738
- const position = renderPosition(file, targetRoot, site.node.getStart());
774
+ const position = renderPosition(file, root, site.node.getStart());
739
775
  // A re-export something imports is used and so is the declaration behind it, so
740
776
  // the use names every link of the chain its name stands at the head of.
741
777
  for (const target of chains.chainOf(symbol)) {
@@ -761,7 +797,10 @@ export function references<Brand>(
761
797
 
762
798
  return {
763
799
  configFile: project.configFile,
764
- references: found,
800
+ references:
801
+ consumer === undefined
802
+ ? found
803
+ : found.map((reference) => ({ ...reference, consumer: consumer.id })),
765
804
  testFilePaths: tests.sort(compare),
766
805
  cost: {
767
806
  batched,
package/src/report.ts CHANGED
@@ -130,6 +130,13 @@ export interface ReportConfiguration {
130
130
  readonly project: string;
131
131
  }
132
132
 
133
+ /** One consumer the run loaded, at the directory a report names it by. */
134
+ interface ReportConsumer {
135
+ readonly id: string;
136
+ /** The consumer's directory, as the report names its target's: below the run directory. */
137
+ readonly path: string;
138
+ }
139
+
133
140
  /** One consumer the scope declared and the run did not load, with why. */
134
141
  export interface UnavailableConsumer {
135
142
  readonly id: string;
@@ -156,7 +163,11 @@ export interface Report {
156
163
  readonly configurations_not_built: readonly never[];
157
164
  readonly consumers: {
158
165
  readonly declared: number;
159
- readonly loaded: readonly never[];
166
+ readonly loaded: readonly {
167
+ readonly id: string;
168
+ readonly role: "consumer";
169
+ readonly path: string;
170
+ }[];
160
171
  readonly unavailable: readonly {
161
172
  readonly id: string;
162
173
  readonly role: "consumer";
@@ -180,6 +191,7 @@ export interface ReportInput {
180
191
  readonly declaredGaps: readonly DeclaredGap[];
181
192
  readonly target: { readonly kind: TargetKind; readonly root: string; readonly identity: string };
182
193
  readonly configurations: readonly ReportConfiguration[];
194
+ readonly loaded: readonly ReportConsumer[];
183
195
  readonly unavailable: readonly UnavailableConsumer[];
184
196
  readonly result: PassResult;
185
197
  readonly testFileRules: readonly TestFileRule[];
@@ -387,6 +399,9 @@ export function buildReport(input: ReportInput): Report {
387
399
  const unavailable = [...new Map(input.unavailable.map((one) => [one.id, one])).values()]
388
400
  .sort((a, b) => compare(a.id, b.id))
389
401
  .map((one) => ({ id: one.id, role: "consumer" as const, reason: one.reason }));
402
+ const loaded = [...input.loaded]
403
+ .sort((a, b) => compare(a.id, b.id) || compare(a.path, b.path))
404
+ .map((one) => ({ id: one.id, role: "consumer" as const, path: one.path }));
390
405
  return {
391
406
  schema_version: SCHEMA_VERSION,
392
407
  contract_version: input.contractVersion,
@@ -406,7 +421,7 @@ export function buildReport(input: ReportInput): Report {
406
421
  .sort((a, b) => compare(a.id, b.id))
407
422
  .map((one) => ({ id: one.id, project: one.project })),
408
423
  configurations_not_built: [],
409
- consumers: { declared: unavailable.length, loaded: [], unavailable },
424
+ consumers: { declared: loaded.length + unavailable.length, loaded, unavailable },
410
425
  findings,
411
426
  edge_evaluations: input.result.edgeEvaluations.map(wireEvaluation).sort(byEdge),
412
427
  stale_suppressions: input.result.staleSuppressions.map(wireStale).sort(canonicalStale),
@@ -0,0 +1,67 @@
1
+ import { fails } from "../config.ts";
2
+ import { KINDS } from "../kinds.ts";
3
+ import type { Report } from "../report.ts";
4
+ import type { RenderOptions } from "./reporter.ts";
5
+
6
+ /** The escapes a workflow command's message needs, so a line break does not end the command. */
7
+ const DATA_ESCAPES: Readonly<Record<string, string>> = { "%": "%25", "\r": "%0D", "\n": "%0A" };
8
+
9
+ /** The escapes a property value needs: a message's, and the property list's two separators. */
10
+ const PROPERTY_ESCAPES: Readonly<Record<string, string>> = {
11
+ ...DATA_ESCAPES,
12
+ ":": "%3A",
13
+ ",": "%2C",
14
+ };
15
+
16
+ function escapeData(value: string): string {
17
+ return value.replace(/[%\r\n]/gu, (char) => DATA_ESCAPES[char] ?? char);
18
+ }
19
+
20
+ function escapeProperty(value: string): string {
21
+ return value.replace(/[%\r\n:,]/gu, (char) => PROPERTY_ESCAPES[char] ?? char);
22
+ }
23
+
24
+ /** The name of the kind a code names, and the code itself where no live kind carries it. */
25
+ function kindName(code: string): string {
26
+ return KINDS.get(code)?.name ?? code;
27
+ }
28
+
29
+ function command(
30
+ level: "error" | "warning",
31
+ at: { readonly path: string; readonly line: number; readonly column: number },
32
+ endLine: number,
33
+ title: string,
34
+ message: string,
35
+ ): string {
36
+ return `::${level} file=${escapeProperty(at.path)},line=${String(at.line)},col=${String(at.column)},endLine=${String(endLine)},title=${escapeProperty(title)}::${escapeData(message)}`;
37
+ }
38
+
39
+ /**
40
+ * One workflow annotation per finding, then one per stale suppression, each naming its
41
+ * file, line, column and last line, titled by its code and kind. A finding at or above the
42
+ * failing severity is an error and one below it a warning; a stale suppression is an error,
43
+ * because its kind is fixed on at the failing severity.
44
+ */
45
+ export function annotations(report: Report, options: RenderOptions): string {
46
+ const lines = report.findings.map((found) =>
47
+ command(
48
+ fails(found.severity, options.failOn) ? "error" : "warning",
49
+ found.position,
50
+ found.position.end_line,
51
+ `${found.code} ${found.kind}`,
52
+ found.message,
53
+ ),
54
+ );
55
+ for (const stale of report.stale_suppressions) {
56
+ lines.push(
57
+ command(
58
+ "error",
59
+ stale.position,
60
+ stale.position.line,
61
+ `${stale.code} ${kindName(stale.code)}`,
62
+ stale.message,
63
+ ),
64
+ );
65
+ }
66
+ return lines.map((line) => `${line}\n`).join("");
67
+ }
@@ -0,0 +1,69 @@
1
+ import { sha256Hex } from "./sha256.ts";
2
+
3
+ /** The unit appended after the last unit of a file. */
4
+ const SENTINEL = 0xffffn;
5
+
6
+ /** The number of significant units one line's hash covers. */
7
+ const WINDOW = 100;
8
+
9
+ const FACTOR = 37n;
10
+
11
+ /** Arithmetic is unsigned 64-bit with wraparound. */
12
+ const MASK = (1n << 64n) - 1n;
13
+
14
+ const SPACE = 0x20;
15
+ const TAB = 0x09;
16
+ const CR = 0x0d;
17
+ const LF = 0x0a;
18
+
19
+ /**
20
+ * The units of one file the line fingerprint counts: every UTF-16 code unit but a space or
21
+ * a tab, a carriage return read as a line feed and the line feed following it dropped, then
22
+ * the sentinel.
23
+ */
24
+ function significantUnits(text: string): bigint[] {
25
+ const kept: bigint[] = [];
26
+ let afterCR = false;
27
+ for (let i = 0; i < text.length; i += 1) {
28
+ const unit = text.charCodeAt(i);
29
+ if (unit === SPACE || unit === TAB || (afterCR && unit === LF)) {
30
+ afterCR = false;
31
+ continue;
32
+ }
33
+ afterCR = unit === CR;
34
+ kept.push(BigInt(afterCR ? LF : unit));
35
+ }
36
+ kept.push(SENTINEL);
37
+ return kept;
38
+ }
39
+
40
+ /**
41
+ * The line fingerprint of every line of one file, indexed from zero. The procedure is the one
42
+ * a code-scanning service computes for a result that carries none, followed exactly, because
43
+ * a value that differs from the service's opens a second alert for one finding.
44
+ */
45
+ export function lineHashes(text: string): readonly string[] {
46
+ const units = significantUnits(text);
47
+ const starts = [0];
48
+ for (let i = 0; i < units.length - 1; i += 1) {
49
+ if (units[i] === BigInt(LF)) {
50
+ starts.push(i + 1);
51
+ }
52
+ }
53
+ const seen = new Map<string, number>();
54
+ return starts.map((start) => {
55
+ let hash = 0n;
56
+ for (let i = start; i < start + WINDOW; i += 1) {
57
+ hash = (hash * FACTOR + (units[i] ?? 0n)) & MASK;
58
+ }
59
+ const rendered = hash.toString(16);
60
+ const count = (seen.get(rendered) ?? 0) + 1;
61
+ seen.set(rendered, count);
62
+ return `${rendered}:${String(count)}`;
63
+ });
64
+ }
65
+
66
+ /** The key a baseline joins on, which a line move leaves unchanged. */
67
+ export function symbolFingerprint(code: string, ref: string): string {
68
+ return sha256Hex(`${code}\n${ref}`);
69
+ }
@@ -1,7 +1,7 @@
1
- import type { Reporter } from "./reporter.ts";
1
+ import type { Report } from "../report.ts";
2
2
 
3
3
  /** The indentation of the report document, which with the schema's member order fixes its bytes. */
4
4
  const INDENT = 2;
5
5
 
6
6
  /** The report document itself: the report, indented, with a closing newline. */
7
- export const json: Reporter = (report) => `${JSON.stringify(report, null, INDENT)}\n`;
7
+ export const json = (report: Report): string => `${JSON.stringify(report, null, INDENT)}\n`;
@@ -1,8 +1,19 @@
1
- import type { Format } from "../config.ts";
1
+ import type { Format, Severity } from "../config.ts";
2
2
  import type { Report } from "../report.ts";
3
+ import type { Template } from "./template.ts";
4
+
5
+ /** What a rendering reads beside the report. */
6
+ export interface RenderOptions {
7
+ /** The lowest severity at which a finding fails the run. */
8
+ readonly failOn: Severity;
9
+ /** The text of the file at one path below the target root, or a throw naming why not. */
10
+ readonly readSource: (path: string) => string;
11
+ /** The template the template rendering renders, where one was named. */
12
+ readonly template: Template | undefined;
13
+ }
3
14
 
4
15
  /** One rendering of a report, as the text of the file it is written to. */
5
- export type Reporter = (report: Report) => string;
16
+ type Reporter = (report: Report, options: RenderOptions) => string;
6
17
 
7
18
  /**
8
19
  * One format this analyzer renders: its reporter, and the suffix appended to the report's
@@ -16,3 +27,11 @@ export interface Rendering {
16
27
 
17
28
  /** Every format this analyzer renders, each by its rendering. */
18
29
  export type Renderings = ReadonlyMap<Format, Rendering>;
30
+
31
+ /** A rendering that cannot be written, which ends the run with the failure code. */
32
+ export class RenderError extends Error {
33
+ constructor(message: string) {
34
+ super(message);
35
+ this.name = "RenderError";
36
+ }
37
+ }