@cplieger/deadset-ts 3.7.0 → 3.9.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 (53) hide show
  1. package/README.md +13 -13
  2. package/dist/bin/node-host.js +32 -2
  3. package/dist/src/analysis.js +111 -26
  4. package/dist/src/conformance.js +22 -0
  5. package/dist/src/edges.js +190 -0
  6. package/dist/src/findings/completion.js +39 -12
  7. package/dist/src/findings/emitters.js +42 -6
  8. package/dist/src/findings/self-check.js +139 -2
  9. package/dist/src/findings/visibility-narrowing.js +9 -40
  10. package/dist/src/findings-pass.js +97 -0
  11. package/dist/src/json-document.js +113 -0
  12. package/dist/src/references.js +18 -12
  13. package/dist/src/report.js +237 -0
  14. package/dist/src/reporters/json.js +4 -0
  15. package/dist/src/reporters/reporter.js +1 -0
  16. package/dist/src/reporters/reporters.js +10 -0
  17. package/dist/src/reporters/text.js +34 -0
  18. package/dist/src/roots.js +0 -34
  19. package/dist/src/run.js +23 -10
  20. package/dist/src/suppress-file.js +196 -0
  21. package/dist/src/suppress-inline.js +259 -0
  22. package/dist/src/suppress.js +148 -0
  23. package/dist/src/verbs/analyze.js +184 -0
  24. package/dist/src/verbs/describe.js +45 -0
  25. package/jsr.json +1 -1
  26. package/package.json +1 -1
  27. package/src/analysis.ts +230 -35
  28. package/src/conformance.ts +34 -0
  29. package/src/edges.ts +241 -0
  30. package/src/finding.ts +13 -0
  31. package/src/findings/boundary.ts +0 -11
  32. package/src/findings/completion.ts +54 -15
  33. package/src/findings/emitter.ts +3 -0
  34. package/src/findings/emitters.ts +81 -7
  35. package/src/findings/self-check.ts +226 -2
  36. package/src/findings/visibility-narrowing.ts +10 -52
  37. package/src/findings-pass.ts +145 -0
  38. package/src/host.ts +5 -0
  39. package/src/json-document.ts +130 -0
  40. package/src/references.ts +23 -15
  41. package/src/report.ts +430 -0
  42. package/src/reporters/json.ts +7 -0
  43. package/src/reporters/reporter.ts +18 -0
  44. package/src/reporters/reporters.ts +13 -0
  45. package/src/reporters/text.ts +45 -0
  46. package/src/roots.ts +0 -39
  47. package/src/run.ts +40 -12
  48. package/src/suppress-file.ts +283 -0
  49. package/src/suppress-inline.ts +318 -0
  50. package/src/suppress.ts +262 -0
  51. package/src/verbs/analyze.ts +234 -0
  52. package/src/verbs/describe.ts +46 -0
  53. package/src/verbs/verb.ts +13 -0
@@ -1,5 +1,7 @@
1
+ import { evaluateEdges } from "../edges.js";
2
+ import { ledgerOf } from "../suppress.js";
3
+ import { completed, dialsOf, reportable } from "./completion.js";
1
4
  import { dependenciesAndModuleMachinery } from "./dependencies-and-module-machinery.js";
2
- import { completed, reportable } from "./completion.js";
3
5
  import { interfaces } from "./interfaces.js";
4
6
  import { intraFunction } from "./intra-function.js";
5
7
  import { nonCodeArtifacts } from "./non-code-artifacts.js";
@@ -18,12 +20,46 @@ export const EMITTERS = new Map([
18
20
  ["self-check", selfCheck],
19
21
  ["intra-function", intraFunction],
20
22
  ]);
23
+ /** Every family's findings over one sweep, completed by one step. */
24
+ function completedOver(input) {
25
+ return completed(input, [...EMITTERS.values()].flatMap((emit) => emit(input)));
26
+ }
21
27
  /**
22
- * Every finding of the run the dials do not withhold, each family's in table order: every
23
- * family's findings completed by one step, then withheld by one step, so a root one
24
- * family reports withholds what another family reports in its component.
28
+ * The dials over the findings a record could name, each read against the sweep that
29
+ * produced it, so a component one sweep numbers withholds nothing of the other's.
25
30
  */
31
+ function dialsOver(input, found, suppressed, unmarked) {
32
+ const marked = dialsOf(input, found);
33
+ const before = dialsOf(suppressed.unmarked, unmarked);
34
+ const fromUnmarked = new Set(unmarked);
35
+ return {
36
+ withholdsCode: marked.withholdsCode,
37
+ withholds: (finding) => fromUnmarked.has(finding) ? before.withholds(finding) : marked.withholds(finding),
38
+ };
39
+ }
40
+ /**
41
+ * Every finding of one run swept under its suppressions' marks, decided in one pass: every
42
+ * family's findings completed by one step, so every component number is minted once, a
43
+ * pending finding's among them; the finding each record claims withheld, whatever its
44
+ * kind; the rest withheld by the dials, so a root one family reports withholds what
45
+ * another family reports in its component; and every declared edge evaluated over what
46
+ * remains, so a finding it holds pending is in its evaluation and nowhere else.
47
+ */
48
+ export function decidedFindings(input, suppressed = { unmarked: input, records: [] }) {
49
+ const found = completedOver(input);
50
+ const unmarked = suppressed.records.length === 0 ? [] : completedOver(suppressed.unmarked);
51
+ const would = [...unmarked, ...found];
52
+ const ledger = ledgerOf(suppressed.records, would, dialsOver(input, found, suppressed, unmarked));
53
+ const kept = reportable(input, found.filter((finding) => !ledger.withheld(finding)));
54
+ const evaluated = evaluateEdges(kept, input.boundary.edges, input.swept.matrix.union.symbols);
55
+ return {
56
+ findings: evaluated.findings,
57
+ evaluations: evaluated.evaluations,
58
+ ledger,
59
+ would,
60
+ };
61
+ }
62
+ /** Every finding the run reports, each family's in table order. */
26
63
  export function findingsOf(input) {
27
- const found = [...EMITTERS.values()].flatMap((emit) => emit(input));
28
- return reportable(completed(input, found), input.config.analysis.minConfidence);
64
+ return decidedFindings(input).findings;
29
65
  }
@@ -1,2 +1,139 @@
1
- /** The findings of the self-check family, `DS1700` to `DS1799`. */
2
- export const selfCheck = () => [];
1
+ import { REPOSITORY_DOCUMENT } from "../config.js";
2
+ import { STALE_SUPPRESSION, SUPPRESSION_WITHOUT_REASON, } from "../suppress.js";
3
+ /** The code a configured root that matches nothing is reported under. */
4
+ const UNMATCHED_ROOT = "DS1704";
5
+ /** The subject kind of a finding about a suppression record. */
6
+ const SUPPRESSION_SUBJECT = "suppression";
7
+ /** The subject kind of a finding about a configured root. */
8
+ const ROOT_SUBJECT = "root";
9
+ /** The fixed position of a finding about a document rather than about a line of one. */
10
+ const DOCUMENT_POSITION = 1;
11
+ /** A run with nothing for the self-check family to report. */
12
+ export const NO_SELF_CHECK = {
13
+ refusals: [],
14
+ unmatchedRoots: [],
15
+ rootsDocument: "",
16
+ };
17
+ /** The words a message names a mechanism by. */
18
+ const WRITTEN = {
19
+ inline: "inline directive",
20
+ ignore: "ignore entry",
21
+ baseline: "baseline row",
22
+ };
23
+ /** The file references of a run's declarations, by path. */
24
+ export function fileRefsOf(symbols) {
25
+ const byPath = new Map(symbols
26
+ .filter((symbol) => symbol.kind === "file")
27
+ .map((symbol) => [symbol.position.path, symbol.ref]));
28
+ return (path) => byPath.get(path);
29
+ }
30
+ /** A list of codes as a sentence names them. */
31
+ function spelled(codes) {
32
+ if (codes.length < 2) {
33
+ return codes.join("");
34
+ }
35
+ return `${codes.slice(0, -1).join(", ")} and ${codes.at(-1) ?? ""}`;
36
+ }
37
+ /** A finding about one suppression, at the suppression's own site. */
38
+ function aboutSuppression(code, ref, site, message, details) {
39
+ return {
40
+ code,
41
+ position: { ...site, endLine: site.line },
42
+ symbol: { ref, kind: SUPPRESSION_SUBJECT, name: ref, sizeLines: 1 },
43
+ message,
44
+ details,
45
+ };
46
+ }
47
+ /** Whether a reason holds a character other than whitespace. */
48
+ const STATED = /[^ \t\r\n]/u;
49
+ /** A refused suppression as written: a member it lacks is absent. */
50
+ function writtenEntry(refused) {
51
+ return {
52
+ code: refused.code,
53
+ ...(refused.symbol === "" ? {} : { symbol: refused.symbol }),
54
+ ...(refused.path === "" ? {} : { path: refused.path }),
55
+ ...(STATED.test(refused.reason) ? { reason: refused.reason } : {}),
56
+ };
57
+ }
58
+ /**
59
+ * The reference a finding about a suppression names: the symbol it names or bound, else
60
+ * the file form of the document that holds it, else that document's path.
61
+ */
62
+ function suppressionRef(symbol, path, files) {
63
+ return symbol === "" ? (files(path) ?? path) : symbol;
64
+ }
65
+ /** One finding per refused suppression: the grammar's reason refusal and its scope refusal. */
66
+ export function refusedSuppressions(refusals, files) {
67
+ return refusals.map((refused) => aboutSuppression(refused.reported, suppressionRef(refused.symbol, refused.path, files), refused.site, `${WRITTEN[refused.mechanism]} for ${refused.code} ${refused.reported === SUPPRESSION_WITHOUT_REASON
68
+ ? "carries no reason"
69
+ : "names a symbol and no path"}`, { mechanism: refused.mechanism, entry: writtenEntry(refused) }));
70
+ }
71
+ /**
72
+ * One record per site at which a suppression matched nothing, whatever the number of
73
+ * codes or declarations written there: the first record names the entry, and the message
74
+ * names every code the site's records named and every code the declaration reports
75
+ * instead, so the maintainer corrects the code rather than hunting for it.
76
+ */
77
+ export function staleSuppressions(stale, files) {
78
+ const bySite = new Map();
79
+ for (const one of stale) {
80
+ const { site, mechanism } = one.record;
81
+ const key = `${mechanism}\u0000${site.path}\u0000${String(site.line)}\u0000${String(site.column)}`;
82
+ const held = bySite.get(key) ?? { first: one.record, records: [] };
83
+ held.records.push(one);
84
+ bySite.set(key, held);
85
+ }
86
+ return [...bySite.values()].map(({ first, records }) => {
87
+ const codes = [...new Set(records.map((one) => one.record.code))].sort();
88
+ const reports = [...new Set(records.flatMap((one) => one.reports))]
89
+ .filter((code) => !codes.includes(code))
90
+ .sort();
91
+ const instead = reports.length === 0 ? "" : `, and its declaration reports ${spelled(reports)}`;
92
+ return {
93
+ code: STALE_SUPPRESSION,
94
+ mechanism: first.mechanism,
95
+ entry: {
96
+ code: first.code,
97
+ ...(first.symbol === "" ? {} : { symbol: first.symbol }),
98
+ path: first.path,
99
+ reason: first.reason,
100
+ },
101
+ position: first.site,
102
+ symbol: suppressionRef(first.symbol, first.path, files),
103
+ message: `${WRITTEN[first.mechanism]} for ${spelled(codes)} matches no current finding${instead}`,
104
+ };
105
+ });
106
+ }
107
+ /**
108
+ * One finding per configured root or pattern that named nothing in any project. A finding
109
+ * names the document that declared the roots, or the conventional repository configuration
110
+ * where no document of the target did, and every finding of one run sits at that
111
+ * document's first position: the roots are an array whose members carry no line of their
112
+ * own, so the string each finding names tells one from another.
113
+ */
114
+ export function unmatchedRootFindings(sources, document) {
115
+ const position = {
116
+ path: document === "" ? REPOSITORY_DOCUMENT : document,
117
+ line: DOCUMENT_POSITION,
118
+ column: DOCUMENT_POSITION,
119
+ endLine: DOCUMENT_POSITION,
120
+ };
121
+ return sources.map((source) => ({
122
+ code: UNMATCHED_ROOT,
123
+ position,
124
+ symbol: { ref: source, kind: ROOT_SUBJECT, name: source, sizeLines: 1 },
125
+ message: source.includes("*") || source.includes("?")
126
+ ? "configured root pattern matches no symbol of the inventory"
127
+ : "configured root matches no symbol of the inventory",
128
+ }));
129
+ }
130
+ /**
131
+ * The findings of the self-check family, `DS1700` to `DS1799`: a suppression refused for
132
+ * carrying no reason or naming no path, and a configured root that named nothing. A
133
+ * suppression that matched nothing is a record of the report's stale suppressions, which
134
+ * {@link staleSuppressions} builds, and a stale cross-language edge is the merge's to report.
135
+ */
136
+ export const selfCheck = ({ selfCheck: facts, swept }) => [
137
+ ...refusedSuppressions(facts.refusals, fileRefsOf(swept.matrix.union.symbols)),
138
+ ...unmatchedRootFindings(facts.unmatchedRoots, facts.rootsDocument),
139
+ ];
@@ -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
+ }
@@ -272,6 +272,22 @@ function sitesOf(file, declarations, fileId) {
272
272
  function patternRule(index) {
273
273
  return `test-file-pattern-${String(index + 1)}`;
274
274
  }
275
+ /**
276
+ * The rules over the test files of several projects at once, ordered by rule: a file two
277
+ * projects hold is one file, so each rule counts the distinct paths it matches.
278
+ */
279
+ export function testFileRulesOf(testFiles, paths) {
280
+ const distinct = [...new Set(paths)];
281
+ return testFiles
282
+ .map((pattern, index) => {
283
+ const expression = globExpression(pattern);
284
+ return {
285
+ rule: patternRule(index),
286
+ matched: distinct.filter((path) => expression.test(path)).length,
287
+ };
288
+ })
289
+ .sort((a, b) => compare(a.rule, b.rule));
290
+ }
275
291
  /** Two strings ordered bytewise, which is the order every set of a run is read in. */
276
292
  function compare(a, b) {
277
293
  if (a === b) {
@@ -281,7 +297,7 @@ function compare(a, b) {
281
297
  }
282
298
  /**
283
299
  * Every reference one project's own files make to the project's own declarations, in
284
- * position order, and the test-file rules that classified those files.
300
+ * position order, and the files the test-file rules classified.
285
301
  *
286
302
  * `held` is the inventory of the same project, whose declaration map is what a resolved
287
303
  * symbol is looked up in; `targetRoot` is the absolute path every position is rendered
@@ -297,7 +313,6 @@ export function references(project, held, targetRoot, options) {
297
313
  const cap = options.batchCap ?? DEFAULT_BATCH_CAP;
298
314
  const declarations = held.declarations;
299
315
  const patterns = options.testFiles.map(globExpression);
300
- const matched = options.testFiles.map(() => 0);
301
316
  const found = [];
302
317
  const tests = [];
303
318
  const chains = aliasChains(project, held);
@@ -332,13 +347,7 @@ export function references(project, held, targetRoot, options) {
332
347
  };
333
348
  for (const file of files) {
334
349
  const path = renderPosition(file, targetRoot, 0).path;
335
- const hits = patterns.map((pattern) => pattern.test(path));
336
- hits.forEach((hit, index) => {
337
- if (hit) {
338
- matched[index] = (matched[index] ?? 0) + 1;
339
- }
340
- });
341
- const test = hits.includes(true);
350
+ const test = patterns.some((pattern) => pattern.test(path));
342
351
  if (test) {
343
352
  tests.push(path);
344
353
  }
@@ -449,9 +458,6 @@ export function references(project, held, targetRoot, options) {
449
458
  return {
450
459
  configFile: project.configFile,
451
460
  references: found,
452
- testFileRules: options.testFiles
453
- .map((_pattern, index) => ({ rule: patternRule(index), matched: matched[index] ?? 0 }))
454
- .sort((a, b) => compare(a.rule, b.rule)),
455
461
  testFilePaths: tests.sort(compare),
456
462
  cost: { batched, fileBatches, residueFallbacks, shorthandLookups, aliasSteps: chains.steps },
457
463
  };