@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/src/roots.ts CHANGED
@@ -11,9 +11,7 @@
11
11
 
12
12
  import type { SourceFile } from "@typescript/native/unstable/ast";
13
13
  import { aliasChains } from "./alias-chain.ts";
14
- import { REPOSITORY_DOCUMENT } from "./config.ts";
15
14
  import { entryPoints, type EntryRule } from "./entry-points.ts";
16
- import type { Finding } from "./finding.ts";
17
15
  import { globExpression } from "./glob.ts";
18
16
  import { nodeKey, type Inventory, type InventorySymbol } from "./inventory.ts";
19
17
  import type { Manifest, ManifestEntry } from "./manifest.ts";
@@ -89,15 +87,6 @@ export interface RootOptions {
89
87
  readonly publishedAPI: boolean;
90
88
  }
91
89
 
92
- /** The code the Contract gives a configured root that matches nothing. */
93
- const UNMATCHED_ROOT_CODE = "DS1704";
94
-
95
- /** The subject kind a finding under that code carries, from the Contract's vocabulary. */
96
- const UNMATCHED_ROOT_SUBJECT = "root";
97
-
98
- /** The fixed position of a finding about a document rather than about a line of one. */
99
- const DOCUMENT_POSITION = 1;
100
-
101
90
  /** Whether a configured string carries either special character. */
102
91
  function isPattern(text: string): boolean {
103
92
  return text.includes("*") || text.includes("?");
@@ -406,31 +395,3 @@ export function unmatchedEverywhere(
406
395
  perProject.every((project) => project.unmatched.includes(pattern)),
407
396
  );
408
397
  }
409
-
410
- /**
411
- * The findings a run reports for the configured strings that named nothing.
412
- *
413
- * `document` is the path of the document that declared the roots, below the target
414
- * root; where the run read them from no document of the target it is empty and the
415
- * finding names the conventional repository configuration, which is the document a
416
- * maintainer writes a configured root in. Every finding of one run sits at that
417
- * document's first position: the roots are written as an array whose members carry
418
- * no line of their own, so the string each finding names is what tells one from
419
- * another.
420
- */
421
- export function unmatchedRoots(sources: readonly string[], document: string): readonly Finding[] {
422
- const position = {
423
- path: document === "" ? REPOSITORY_DOCUMENT : document,
424
- line: DOCUMENT_POSITION,
425
- column: DOCUMENT_POSITION,
426
- endLine: DOCUMENT_POSITION,
427
- };
428
- return sources.map((source) => ({
429
- code: UNMATCHED_ROOT_CODE,
430
- position,
431
- symbol: { ref: source, kind: UNMATCHED_ROOT_SUBJECT, name: source, sizeLines: 1 },
432
- message: isPattern(source)
433
- ? "configured root pattern matches no symbol of the inventory"
434
- : "configured root matches no symbol of the inventory",
435
- }));
436
- }
@@ -0,0 +1,283 @@
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
+
8
+ import type { Host } from "./host.ts";
9
+ import type { InventorySymbol } from "./inventory.ts";
10
+ import { joinPath } from "./paths.ts";
11
+ import { positionAt, walkDocument } from "./json-document.ts";
12
+ import type { Position } from "./position.ts";
13
+ import {
14
+ CODE_FORM,
15
+ SUPPRESSION_WITHOUT_REASON,
16
+ SuppressionError,
17
+ UNSCOPED_ENTRY,
18
+ blankReason,
19
+ isEntryPath,
20
+ isSymbolRef,
21
+ siteText,
22
+ type Mechanism,
23
+ type Refusal,
24
+ type SuppressionRecord,
25
+ type Suppressions,
26
+ } from "./suppress.ts";
27
+
28
+ /** The name and the location the grammar fixes for the ignore file. */
29
+ export const IGNORE_FILE = "deadset-ignore.json";
30
+
31
+ /** The name and the location the grammar fixes for the baseline. */
32
+ export const BASELINE_FILE = "deadset-baseline.json";
33
+
34
+ /** One file-backed document as a reader sees it. */
35
+ interface Shape {
36
+ readonly file: string;
37
+ /** The member holding the records. */
38
+ readonly array: string;
39
+ readonly mechanism: Mechanism;
40
+ readonly documentWant: string;
41
+ readonly recordWant: string;
42
+ }
43
+
44
+ const IGNORE_SHAPE: Shape = {
45
+ file: IGNORE_FILE,
46
+ array: "ignore",
47
+ mechanism: "ignore",
48
+ documentWant: "an object carrying the ignore array, and an optional description",
49
+ recordWant: "an entry naming code, symbol, path and reason, each a string",
50
+ };
51
+
52
+ const BASELINE_SHAPE: Shape = {
53
+ file: BASELINE_FILE,
54
+ array: "baseline",
55
+ mechanism: "baseline",
56
+ documentWant: "an object carrying the baseline array, and an optional description",
57
+ recordWant: "a row naming code, symbol, path and reason, each a string",
58
+ };
59
+
60
+ /** The closed key list of one entry or row. */
61
+ const RECORD_KEYS: ReadonlySet<string> = new Set(["code", "symbol", "path", "reason"]);
62
+
63
+ const CODE_WANT = "DS followed by four digits";
64
+ const SYMBOL_WANT =
65
+ "a stable symbol reference, go://<import-path>#<fragment> or ts://<package>/<source-path>#<fragment>";
66
+ const PATH_WANT = "a path relative to the target root, with the solidus as separator";
67
+
68
+ /** The document itself, for a defect no record position describes. */
69
+ function documentSite(shape: Shape): Position {
70
+ return { path: shape.file, line: 0, column: 0 };
71
+ }
72
+
73
+ function isObject(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ /** What one entry or row resolves to. */
78
+ interface Read {
79
+ readonly records: readonly SuppressionRecord[];
80
+ readonly refusals: readonly Refusal[];
81
+ }
82
+
83
+ /** The declarations of the run by reference and file, which is what an entry names. */
84
+ type ByReference = ReadonlyMap<string, readonly InventorySymbol[]>;
85
+
86
+ function referenceKey(ref: string, path: string): string {
87
+ return `${ref}\u0000${path}`;
88
+ }
89
+
90
+ /**
91
+ * Reads one entry or row: the records it binds, the refusals it carries, or the
92
+ * malformed value that ends the run.
93
+ */
94
+ function readRecord(value: unknown, site: Position, byReference: ByReference, shape: Shape): Read {
95
+ const refuse = (text: string, want: string): never => {
96
+ throw new SuppressionError(shape.mechanism, `${shape.file} ${siteText(site)}`, text, want);
97
+ };
98
+ if (!isObject(value)) {
99
+ return refuse(JSON.stringify(value), shape.recordWant);
100
+ }
101
+ for (const [key, held] of Object.entries(value)) {
102
+ if (!RECORD_KEYS.has(key)) {
103
+ refuse(`the member ${JSON.stringify(key)} is not one the grammar declares`, shape.recordWant);
104
+ }
105
+ if (typeof held !== "string") {
106
+ refuse(`the member ${JSON.stringify(key)} holds ${JSON.stringify(held)}`, shape.recordWant);
107
+ }
108
+ }
109
+ const entry = value as Readonly<Record<string, string | undefined>>;
110
+ const { code, symbol, path, reason } = {
111
+ code: entry["code"],
112
+ symbol: entry["symbol"],
113
+ path: entry["path"],
114
+ reason: entry["reason"],
115
+ };
116
+ if (code === undefined) {
117
+ return refuse("a record naming no code", shape.recordWant);
118
+ }
119
+ if (symbol === undefined) {
120
+ return refuse("a record naming no symbol", shape.recordWant);
121
+ }
122
+ if (!CODE_FORM.test(code)) {
123
+ return refuse(JSON.stringify(code), CODE_WANT);
124
+ }
125
+ if (!isSymbolRef(symbol)) {
126
+ return refuse(JSON.stringify(symbol), SYMBOL_WANT);
127
+ }
128
+ if (path !== undefined && path !== "" && !isEntryPath(path)) {
129
+ return refuse(JSON.stringify(path), PATH_WANT);
130
+ }
131
+
132
+ const written = {
133
+ code,
134
+ symbol,
135
+ path: path ?? "",
136
+ reason: reason ?? "",
137
+ site,
138
+ mechanism: shape.mechanism,
139
+ };
140
+ const refusals: Refusal[] = [];
141
+ if (blankReason(reason)) {
142
+ refusals.push({ ...written, reported: SUPPRESSION_WITHOUT_REASON });
143
+ }
144
+ if (written.path === "") {
145
+ refusals.push({ ...written, reported: UNSCOPED_ENTRY });
146
+ }
147
+ if (refusals.length > 0) {
148
+ return { records: [], refusals };
149
+ }
150
+ const named = byReference.get(referenceKey(symbol, written.path)) ?? [];
151
+ if (named.length === 0) {
152
+ return { records: [{ ...written, bound: "" }], refusals: [] };
153
+ }
154
+ return { records: named.map((one) => ({ ...written, bound: one.id })), refusals: [] };
155
+ }
156
+
157
+ /** Reads one document at the target root, binding each record against the run's declarations. */
158
+ function readDocument(
159
+ host: Host,
160
+ targetRoot: string,
161
+ symbols: readonly InventorySymbol[],
162
+ shape: Shape,
163
+ ): Suppressions {
164
+ const file = joinPath(targetRoot, shape.file);
165
+ if (host.kindOf(file) === "absent") {
166
+ return { records: [], refusals: [] };
167
+ }
168
+ const text = host.readFile(file);
169
+ const malformed = (detail: string, site = documentSite(shape)): never => {
170
+ throw new SuppressionError(shape.mechanism, siteText(site), detail, shape.documentWant);
171
+ };
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(text);
175
+ } catch (error: unknown) {
176
+ return malformed(`${shape.file}: ${error instanceof Error ? error.message : String(error)}`);
177
+ }
178
+ if (!isObject(parsed)) {
179
+ return malformed(shape.file);
180
+ }
181
+ const walk = walkDocument(text, shape.array);
182
+ if (walk.twice !== undefined) {
183
+ malformed(
184
+ `the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`,
185
+ positionAt(text, shape.file, walk.twice.offset),
186
+ );
187
+ }
188
+ for (const key of Object.keys(parsed)) {
189
+ if (key !== "description" && key !== shape.array) {
190
+ malformed(`the member ${JSON.stringify(key)} is not one the grammar declares`);
191
+ }
192
+ }
193
+ const description = parsed["description"];
194
+ const held = parsed[shape.array];
195
+ if ((description !== undefined && typeof description !== "string") || !Array.isArray(held)) {
196
+ return malformed(shape.file);
197
+ }
198
+
199
+ const byReference = new Map<string, InventorySymbol[]>();
200
+ for (const symbol of symbols) {
201
+ const key = referenceKey(symbol.ref, symbol.position.path);
202
+ byReference.set(key, [...(byReference.get(key) ?? []), symbol]);
203
+ }
204
+ const records: SuppressionRecord[] = [];
205
+ const refusals: Refusal[] = [];
206
+ (held as readonly unknown[]).forEach((value, index) => {
207
+ const offset = walk.opened[index];
208
+ const site = offset === undefined ? documentSite(shape) : positionAt(text, shape.file, offset);
209
+ const read = readRecord(value, site, byReference, shape);
210
+ records.push(...read.records);
211
+ refusals.push(...read.refusals);
212
+ });
213
+ return { records, refusals };
214
+ }
215
+
216
+ /**
217
+ * The ignore file at the target root, each entry bound to the declaration whose reference
218
+ * and file both equal the ones it names, and never by glob, pattern or substring. An
219
+ * absent file is an empty one.
220
+ */
221
+ export function readIgnoreFile(
222
+ host: Host,
223
+ targetRoot: string,
224
+ symbols: readonly InventorySymbol[],
225
+ ): Suppressions {
226
+ return readDocument(host, targetRoot, symbols, IGNORE_SHAPE);
227
+ }
228
+
229
+ /**
230
+ * The baseline at the target root, each row matched exactly as an ignore entry is. A row's
231
+ * reason is the provenance an analyzer wrote, never a maintainer's judgement: the finding
232
+ * it records is real, and the row says only that it was there when the baseline was
233
+ * written. An absent file is an empty one.
234
+ */
235
+ export function readBaseline(
236
+ host: Host,
237
+ targetRoot: string,
238
+ symbols: readonly InventorySymbol[],
239
+ ): Suppressions {
240
+ return readDocument(host, targetRoot, symbols, BASELINE_SHAPE);
241
+ }
242
+
243
+ /** One finding a written row records: what a later run matches the row on. */
244
+ export interface Recorded {
245
+ readonly code: string;
246
+ readonly symbol: string;
247
+ readonly path: string;
248
+ }
249
+
250
+ /** The analyzer a written row names as its provenance, as the same run's report names it. */
251
+ export interface Provenance {
252
+ readonly analyzer: string;
253
+ readonly version: string;
254
+ }
255
+
256
+ /** What a written document says about itself, so the same report writes the same bytes. */
257
+ const BASELINE_DESCRIPTION =
258
+ "Recorded findings: a run fails only on a finding this document does not hold. " +
259
+ "Every reason is the analyzer that recorded the row, never an adjudication.";
260
+
261
+ /**
262
+ * A baseline holding one row per finding, in the order given, which is the report's. Every
263
+ * row carries the provenance as its reason. A finding missing a value a row needs, and an
264
+ * identity missing its name or version, are refused rather than written, because the
265
+ * grammar requires a reason on every row.
266
+ */
267
+ export function writeBaseline(findings: readonly Recorded[], provenance: Provenance): string {
268
+ if (provenance.analyzer === "" || provenance.version === "") {
269
+ throw new Error(
270
+ `a baseline row would carry no reason: the analyzer identity names ${JSON.stringify(provenance.analyzer)} at version ${JSON.stringify(provenance.version)}`,
271
+ );
272
+ }
273
+ const reason = `recorded by ${provenance.analyzer} ${provenance.version}`;
274
+ const baseline = findings.map((found) => {
275
+ if (found.code === "" || found.symbol === "" || found.path === "") {
276
+ throw new Error(
277
+ `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)}`,
278
+ );
279
+ }
280
+ return { code: found.code, symbol: found.symbol, path: found.path, reason };
281
+ });
282
+ return `${JSON.stringify({ description: BASELINE_DESCRIPTION, baseline }, null, 2)}\n`;
283
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The inline directive: `deadset:ignore`, the first token of a `//` line comment, naming
3
+ * one or more codes and a reason, and binding to every declaration that begins on the line
4
+ * below it.
5
+ */
6
+
7
+ import { SyntaxKind, type Node, type SourceFile } from "@typescript/native/unstable/ast";
8
+ import { nodeKey, type Inventory } from "./inventory.ts";
9
+ import { byPosition, positionKey, renderPosition, type Position } from "./position.ts";
10
+ import type { ProjectView } from "./session.ts";
11
+ import {
12
+ SUPPRESSION_WITHOUT_REASON,
13
+ SuppressionError,
14
+ siteText,
15
+ type Refusal,
16
+ type SuppressionRecord,
17
+ type Suppressions,
18
+ } from "./suppress.ts";
19
+
20
+ /**
21
+ * The three expressions the grammar applies to the text of each line comment, in this
22
+ * order: a comment in the namespace, a well-formed directive, and a directive that lacks
23
+ * only its reason. A test pins each to the line the grammar page carries.
24
+ */
25
+ export const DIRECTIVE_EXPRESSIONS = {
26
+ candidate: String.raw`^//[ \t]*deadset:`,
27
+ wellFormed: String.raw`^//[ \t]*deadset:ignore[ \t]+(?<codes>DS[0-9]{4}(?:,DS[0-9]{4})*)[ \t]+--[ \t]+(?<reason>[^ \t\r\n][^\r\n]*?)[ \t]*$`,
28
+ noReason: String.raw`^//[ \t]*deadset:ignore[ \t]+(?<codes>DS[0-9]{4}(?:,DS[0-9]{4})*)(?:[ \t]+--)?[ \t]*$`,
29
+ } as const;
30
+
31
+ const CANDIDATE = new RegExp(DIRECTIVE_EXPRESSIONS.candidate);
32
+ const WELL_FORMED = new RegExp(DIRECTIVE_EXPRESSIONS.wellFormed);
33
+ const NO_REASON = new RegExp(DIRECTIVE_EXPRESSIONS.noReason);
34
+
35
+ /** The form a malformed directive is told to take. */
36
+ const DIRECTIVE_FORM = "// deadset:ignore DS0000[,DS0000...] -- <reason>";
37
+
38
+ /** What the grammar's decision procedure makes of one comment. */
39
+ export type Directive =
40
+ | { readonly is: "none" }
41
+ | { readonly is: "directive"; readonly codes: readonly string[]; readonly reason: string }
42
+ | { readonly is: "no-reason"; readonly codes: readonly string[] }
43
+ | { readonly is: "malformed" };
44
+
45
+ /** Applies the grammar's three expressions to the text of one comment, in order. */
46
+ export function classifyComment(text: string): Directive {
47
+ if (!CANDIDATE.test(text)) {
48
+ return { is: "none" };
49
+ }
50
+ const groups = WELL_FORMED.exec(text)?.groups;
51
+ if (groups !== undefined) {
52
+ return {
53
+ is: "directive",
54
+ codes: (groups["codes"] ?? "").split(","),
55
+ reason: groups["reason"] ?? "",
56
+ };
57
+ }
58
+ const bare = NO_REASON.exec(text)?.groups;
59
+ if (bare !== undefined) {
60
+ return { is: "no-reason", codes: (bare["codes"] ?? "").split(",") };
61
+ }
62
+ return { is: "malformed" };
63
+ }
64
+
65
+ /** One `//` line comment: the offset of its first solidus, and its text to the end of its line. */
66
+ export interface LineComment {
67
+ readonly offset: number;
68
+ readonly text: string;
69
+ }
70
+
71
+ /** The literal tokens whose text may hold `//` or `/*` without starting a comment. */
72
+ const LITERALS: ReadonlySet<SyntaxKind> = new Set([
73
+ SyntaxKind.StringLiteral,
74
+ SyntaxKind.NoSubstitutionTemplateLiteral,
75
+ SyntaxKind.TemplateHead,
76
+ SyntaxKind.TemplateMiddle,
77
+ SyntaxKind.TemplateTail,
78
+ SyntaxKind.RegularExpressionLiteral,
79
+ SyntaxKind.JsxText,
80
+ ]);
81
+
82
+ /** Whether one character ends a line, under the language's line terminators. */
83
+ function endsLine(char: string | undefined): boolean {
84
+ return char === "\n" || char === "\r" || char === "\u2028" || char === "\u2029";
85
+ }
86
+
87
+ /** The offset where the line holding `from` ends. */
88
+ function lineEnd(text: string, from: number): number {
89
+ let at = from;
90
+ while (at < text.length && !endsLine(text[at])) {
91
+ at += 1;
92
+ }
93
+ return at;
94
+ }
95
+
96
+ /**
97
+ * The offset of the first token at or after `from`: whitespace, comments and a leading
98
+ * hashbang are trivia.
99
+ */
100
+ export function tokenStart(text: string, from: number): number {
101
+ let at = from;
102
+ if (at === 0 && text.startsWith("#!")) {
103
+ at = lineEnd(text, 0);
104
+ }
105
+ for (;;) {
106
+ if (/\s/u.test(text[at] ?? "")) {
107
+ at += 1;
108
+ } else if (text.startsWith("//", at)) {
109
+ at = lineEnd(text, at);
110
+ } else if (text.startsWith("/*", at)) {
111
+ const close = text.indexOf("*/", at + 2);
112
+ at = close < 0 ? text.length : close + 2;
113
+ } else {
114
+ return at;
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Every `//` line comment of one file, in file order. The text between tokens is read as
121
+ * the language reads it: a block comment holds no line comment, and the literal tokens the
122
+ * syntax tree holds are skipped, so a `//` inside a string, a template or a regular
123
+ * expression starts nothing.
124
+ */
125
+ export function lineComments(file: SourceFile): readonly LineComment[] {
126
+ const text = file.text;
127
+ const spans: { readonly start: number; readonly end: number }[] = [];
128
+ const visit = (node: Node): void => {
129
+ if (LITERALS.has(node.kind)) {
130
+ spans.push({ start: tokenStart(text, node.pos), end: node.end });
131
+ }
132
+ node.forEachChild(visit);
133
+ };
134
+ file.forEachChild(visit);
135
+ spans.sort((a, b) => a.start - b.start);
136
+
137
+ const found: LineComment[] = [];
138
+ let next = 0;
139
+ let at = 0;
140
+ while (at < text.length) {
141
+ while (next < spans.length && (spans[next]?.start ?? 0) < at) {
142
+ next += 1;
143
+ }
144
+ const span = spans[next];
145
+ if (span?.start === at) {
146
+ at = Math.max(span.end, at + 1);
147
+ } else if (text.startsWith("//", at)) {
148
+ const end = lineEnd(text, at);
149
+ found.push({ offset: at, text: text.slice(at, end) });
150
+ at = end;
151
+ } else if (text.startsWith("/*", at)) {
152
+ const close = text.indexOf("*/", at + 2);
153
+ at = close < 0 ? text.length : close + 2;
154
+ } else {
155
+ at += 1;
156
+ }
157
+ }
158
+ return found;
159
+ }
160
+
161
+ /** One declaration a directive can bind: its identifier and its reference. */
162
+ interface Declared {
163
+ readonly id: string;
164
+ readonly ref: string;
165
+ readonly position: Position;
166
+ }
167
+
168
+ /**
169
+ * The declarations of one file by the line each begins on, the line of its first token: a
170
+ * decorator, a modifier or the keyword. A variable's first token is its statement's
171
+ * keyword, and a variable written on a later line of the statement also begins there.
172
+ */
173
+ function declarationLines(
174
+ file: SourceFile,
175
+ held: Inventory,
176
+ symbols: ReadonlyMap<string, Declared>,
177
+ ): ReadonlyMap<number, readonly Declared[]> {
178
+ const below = new Map<number, Declared[]>();
179
+ const add = (offset: number, one: Declared): void => {
180
+ const line = file.getLineAndCharacterOfPosition(tokenStart(file.text, offset)).line + 1;
181
+ const at = below.get(line) ?? [];
182
+ if (!at.some((known) => known.id === one.id)) {
183
+ at.push(one);
184
+ }
185
+ below.set(line, at);
186
+ };
187
+ const visit = (node: Node): void => {
188
+ const id = held.declarations.get(nodeKey(file, node));
189
+ const one = id === undefined ? undefined : symbols.get(id);
190
+ if (one !== undefined) {
191
+ add(node.pos, one);
192
+ const list = node.parent;
193
+ if (
194
+ node.kind === SyntaxKind.VariableDeclaration &&
195
+ list.kind === SyntaxKind.VariableDeclarationList &&
196
+ list.parent.kind === SyntaxKind.VariableStatement
197
+ ) {
198
+ add(list.parent.pos, one);
199
+ }
200
+ }
201
+ node.forEachChild(visit);
202
+ };
203
+ file.forEachChild(visit);
204
+ return below;
205
+ }
206
+
207
+ /** One comment in the namespace, what the grammar made of it, and what it bound. */
208
+ export interface InlineDirective {
209
+ readonly site: Position;
210
+ readonly text: string;
211
+ readonly directive: Directive;
212
+ readonly bound: Declared[];
213
+ }
214
+
215
+ /**
216
+ * Every comment in the namespace one project's own files hold, each with the declarations
217
+ * it binds, read while the project's view is open.
218
+ */
219
+ export function inlineDirectives<Brand>(
220
+ project: ProjectView<Brand>,
221
+ held: Inventory,
222
+ targetRoot: string,
223
+ ): readonly InlineDirective[] {
224
+ const symbols = new Map<string, Declared>(
225
+ held.symbols
226
+ .filter((symbol) => symbol.kind !== "file")
227
+ .map((symbol) => [symbol.id, { id: symbol.id, ref: symbol.ref, position: symbol.position }]),
228
+ );
229
+ const found: InlineDirective[] = [];
230
+ for (const file of project.ownSourceFiles()) {
231
+ const comments = lineComments(file).filter((comment) => CANDIDATE.test(comment.text));
232
+ if (comments.length === 0) {
233
+ continue;
234
+ }
235
+ const below = declarationLines(file, held, symbols);
236
+ for (const comment of comments) {
237
+ const site = renderPosition(file, targetRoot, comment.offset);
238
+ found.push({
239
+ site,
240
+ text: comment.text,
241
+ directive: classifyComment(comment.text),
242
+ bound: [...(below.get(site.line + 1) ?? [])],
243
+ });
244
+ }
245
+ }
246
+ return found;
247
+ }
248
+
249
+ /**
250
+ * The inline directives of a run's projects, each bound to the declarations that begin on
251
+ * the line below it, one directive binding the union of what each project holding its file
252
+ * declares there. A directive that lacks only its reason is one {@link Refusal} per code;
253
+ * any other malformed comment in the namespace ends the read with a
254
+ * {@link SuppressionError} naming the first one written.
255
+ */
256
+ export function inlineSuppressions(
257
+ perProject: readonly (readonly InlineDirective[])[],
258
+ ): Suppressions {
259
+ const merged = new Map<string, InlineDirective>();
260
+ for (const written of perProject.flat()) {
261
+ const key = positionKey(written.site);
262
+ const known = merged.get(key);
263
+ if (known === undefined) {
264
+ merged.set(key, { ...written, bound: [...written.bound] });
265
+ continue;
266
+ }
267
+ for (const one of written.bound) {
268
+ if (!known.bound.some((other) => other.id === one.id)) {
269
+ known.bound.push(one);
270
+ }
271
+ }
272
+ }
273
+ const ordered = [...merged.values()].sort((a, b) => byPosition(a.site, b.site));
274
+
275
+ const records: SuppressionRecord[] = [];
276
+ const refusals: Refusal[] = [];
277
+ for (const { site, text, directive, bound } of ordered) {
278
+ switch (directive.is) {
279
+ case "none":
280
+ break;
281
+ case "malformed":
282
+ throw new SuppressionError("inline", siteText(site), JSON.stringify(text), DIRECTIVE_FORM);
283
+ case "no-reason":
284
+ for (const code of directive.codes) {
285
+ refusals.push({
286
+ reported: SUPPRESSION_WITHOUT_REASON,
287
+ code,
288
+ symbol: "",
289
+ path: site.path,
290
+ reason: "",
291
+ site,
292
+ mechanism: "inline",
293
+ });
294
+ }
295
+ break;
296
+ case "directive": {
297
+ const declared = [...bound].sort((a, b) => byPosition(a.position, b.position));
298
+ for (const code of directive.codes) {
299
+ const base = {
300
+ code,
301
+ path: site.path,
302
+ reason: directive.reason,
303
+ site,
304
+ mechanism: "inline",
305
+ } as const;
306
+ if (declared.length === 0) {
307
+ records.push({ ...base, symbol: "", bound: "" });
308
+ }
309
+ for (const one of declared) {
310
+ records.push({ ...base, symbol: one.ref, bound: one.id });
311
+ }
312
+ }
313
+ break;
314
+ }
315
+ }
316
+ }
317
+ return { records, refusals };
318
+ }