@cplieger/deadset-ts 3.9.0 → 3.10.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.
@@ -0,0 +1,26 @@
1
+ // Written by the conformance run from conformance.json and conformance-results.json.
2
+ // Regenerate it with `UPDATE_GOLDEN=1 npx vitest --run src/conformance.corpus.test.ts`.
3
+ export const RECORD = {
4
+ conformance: {
5
+ corpusVersion: "1.9.0",
6
+ result: "fail",
7
+ digest: "sha256:8c29f19fc189f4f5d9ea210d556b62d12c3701c221eb7a8adde499b7060bec0d",
8
+ },
9
+ gaps: [
10
+ {
11
+ fixture: "private-member-unread",
12
+ capability: "reflective-lookup",
13
+ reason: "The reference pass resolves a string-literal element access to the member it names, so the member is live by that reference and the retained listing names no class for it.",
14
+ },
15
+ {
16
+ fixture: "redundant-export-keyword",
17
+ capability: "DS1104",
18
+ reason: "No consumer is loaded beside the target, so the published surface is unknown and no export is narrowed; review an export only the target uses by hand.",
19
+ },
20
+ {
21
+ fixture: "unused-exported-consumer",
22
+ capability: "DS1001",
23
+ reason: "No consumer is loaded beside the target, so an export nothing in the target references is reported at the possible class rather than the certain one.",
24
+ },
25
+ ],
26
+ };
@@ -1,22 +1,10 @@
1
1
  /**
2
- * What this analyzer states about its own conformance: the corpus version it is written
3
- * against and its recorded result over that corpus, which `describe` and every report name.
2
+ * What this analyzer states about its own conformance: its recorded result over the
3
+ * conformance corpus and every capability it declines, which `describe` and every report
4
+ * name.
4
5
  */
5
- /** The version of the conformance corpus this analyzer is written against. */
6
- const CORPUS_VERSION = "1.9.0";
7
- /** The SHA-256 of zero bytes, which is the digest of a results document no run wrote. */
8
- const EMPTY_DIGEST = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
9
- /**
10
- * The conformance run this analyzer records, or undefined where it records none. A
11
- * description states exactly this, so a reader that requires a pass refuses an analyzer
12
- * that has not answered the corpus.
13
- */
14
- export const RECORDED_CONFORMANCE = undefined;
15
- /**
16
- * The conformance a report states. A report requires the block, so an analyzer that
17
- * records no run states a failed one over the results document no run wrote, which a merge
18
- * refuses as it refuses any result other than a pass.
19
- */
20
- export function reportedConformance(recorded) {
21
- return recorded ?? { corpusVersion: CORPUS_VERSION, result: "fail", digest: EMPTY_DIGEST };
22
- }
6
+ import { RECORD } from "./conformance-record.js";
7
+ /** The conformance run this analyzer records. */
8
+ export const CONFORMANCE = RECORD.conformance;
9
+ /** Every capability this analyzer declines, in the order `conformance.json` declares them. */
10
+ export const DECLARED_GAPS = RECORD.gaps;
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The members a destructuring reads. An object pattern reads each property it names
3
+ * from the value it destructures, so the property's name in the pattern is a read of
4
+ * the member the value's type declares under that name, whether the pattern binds it
5
+ * to a local of the same name, renames it, gives it a default or nests a pattern under
6
+ * it. A rest element names no property: what it copies is read where the copy is.
7
+ */
8
+ import { isArrayLiteralExpression, isBinaryExpression, isForOfStatement, isIdentifier, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectBindingPattern, isObjectLiteralExpression, isParameterDeclaration, isParenthesizedExpression, isPropertyAssignment, isShorthandPropertyAssignment, isStringLiteral, SyntaxKind, } from "@typescript/native/unstable/ast";
9
+ import { TypeFlags } from "@typescript/native/unstable/sync";
10
+ /** The text a property name spells, or `undefined` for a computed name. */
11
+ function nameText(node) {
12
+ if (node === undefined) {
13
+ return undefined;
14
+ }
15
+ if (isIdentifier(node) ||
16
+ isStringLiteral(node) ||
17
+ isNoSubstitutionTemplateLiteral(node) ||
18
+ isNumericLiteral(node)) {
19
+ return node.text;
20
+ }
21
+ return undefined;
22
+ }
23
+ /** The node one expression is written inside, through however many parentheses enclose it. */
24
+ function container(node) {
25
+ let child = node;
26
+ while (isParenthesizedExpression(child.parent)) {
27
+ child = child.parent;
28
+ }
29
+ return { child, parent: child.parent };
30
+ }
31
+ /** The plain assignment one node is the left side of, if it is one. */
32
+ function assignmentOf(node, parent) {
33
+ return isBinaryExpression(parent) &&
34
+ parent.operatorToken.kind === SyntaxKind.EqualsToken &&
35
+ parent.left === node
36
+ ? parent
37
+ : undefined;
38
+ }
39
+ /**
40
+ * Whether an object or array literal is a destructuring target rather than a value: the
41
+ * left side of an assignment, the target of a `for…of`, or an element of a literal that
42
+ * is one, with or without a default.
43
+ */
44
+ function isDestructuringTarget(literal) {
45
+ const { child, parent } = container(literal);
46
+ if (assignmentOf(child, parent) !== undefined) {
47
+ return true;
48
+ }
49
+ if (isForOfStatement(parent)) {
50
+ return parent.initializer === child;
51
+ }
52
+ return isElement(child, parent);
53
+ }
54
+ /** Whether one node is an element of a destructuring target: a property's value, or an array element. */
55
+ function isElement(child, parent) {
56
+ if (isPropertyAssignment(parent)) {
57
+ return parent.initializer === child && isDestructuringTarget(parent.parent);
58
+ }
59
+ return isArrayLiteralExpression(parent) && isDestructuringTarget(parent);
60
+ }
61
+ /**
62
+ * Whether an object binding pattern runs: a pattern in the parameter list of a signature
63
+ * with no body, a function type or an overload among them, binds nothing at runtime.
64
+ */
65
+ function binds(pattern) {
66
+ let at = pattern.parent;
67
+ while (at.kind === SyntaxKind.BindingElement) {
68
+ at = at.parent.parent;
69
+ }
70
+ if (!isParameterDeclaration(at)) {
71
+ return true;
72
+ }
73
+ return at.parent.body !== undefined;
74
+ }
75
+ /** The properties one node reads, where it is an object pattern; none otherwise. */
76
+ export function propertyReadsOf(node) {
77
+ const reads = [];
78
+ if (isObjectBindingPattern(node)) {
79
+ if (!binds(node)) {
80
+ return reads;
81
+ }
82
+ for (const element of node.elements) {
83
+ const key = element.propertyName ?? element.name;
84
+ const name = nameText(key);
85
+ if (element.dotDotDotToken === undefined && key !== undefined && name !== undefined) {
86
+ reads.push({ pattern: node, key, name });
87
+ }
88
+ }
89
+ return reads;
90
+ }
91
+ if (isObjectLiteralExpression(node) && isDestructuringTarget(node)) {
92
+ for (const property of node.properties) {
93
+ if (isShorthandPropertyAssignment(property) || isPropertyAssignment(property)) {
94
+ const name = nameText(property.name);
95
+ if (name !== undefined) {
96
+ reads.push({ pattern: node, key: property.name, name });
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return reads;
102
+ }
103
+ /**
104
+ * Where the value one destructuring target receives comes from. The checker answers for
105
+ * a binding pattern's type itself, so this is asked of an assignment target only, whose
106
+ * own type is the literal's rather than the assigned value's. A default does not move
107
+ * the source: `{ a: { b } = fallback }` reads `b` from `a`. A target inside a spread
108
+ * takes the rest of a value and has no source here.
109
+ */
110
+ function sourceOf(target) {
111
+ const steps = [];
112
+ let at = target;
113
+ for (;;) {
114
+ const { child, parent } = container(at);
115
+ const assignment = assignmentOf(child, parent);
116
+ if (assignment !== undefined) {
117
+ const outer = container(assignment);
118
+ if (!isElement(outer.child, outer.parent)) {
119
+ return { expression: assignment.right, steps: steps.reverse() };
120
+ }
121
+ at = assignment;
122
+ continue;
123
+ }
124
+ if (isForOfStatement(parent) && parent.initializer === child) {
125
+ steps.push({ kind: "iterated" });
126
+ return { expression: parent.expression, steps: steps.reverse() };
127
+ }
128
+ if (isPropertyAssignment(parent) && parent.initializer === child) {
129
+ const name = nameText(parent.name);
130
+ if (name === undefined) {
131
+ return undefined;
132
+ }
133
+ steps.push({ kind: "property", name });
134
+ at = parent.parent;
135
+ continue;
136
+ }
137
+ if (isArrayLiteralExpression(parent)) {
138
+ steps.push({
139
+ kind: "element",
140
+ index: parent.elements.findIndex((element) => element === child),
141
+ });
142
+ at = parent;
143
+ continue;
144
+ }
145
+ return undefined;
146
+ }
147
+ }
148
+ /**
149
+ * The resolution over one project, its batches capped at `cap`. Property tables are
150
+ * read once per type for the whole project, since one type is destructured in many
151
+ * places and a table answers every name of it.
152
+ */
153
+ export function destructuring(project, cap) {
154
+ const checker = project.checker;
155
+ const tables = new Map();
156
+ let patternBatches = 0;
157
+ let patternLookups = 0;
158
+ const typesAt = (nodes) => {
159
+ const types = [];
160
+ for (let from = 0; from < nodes.length; from += cap) {
161
+ types.push(...checker.getTypeAtLocation(nodes.slice(from, from + cap)));
162
+ patternBatches += 1;
163
+ }
164
+ return types;
165
+ };
166
+ const tableOf = (type) => {
167
+ const known = tables.get(type.id);
168
+ if (known !== undefined) {
169
+ return known;
170
+ }
171
+ const table = new Map();
172
+ if ((type.flags & TypeFlags.AnyOrUnknown) === 0) {
173
+ patternLookups += 1;
174
+ for (const property of checker.getPropertiesOfType(type)) {
175
+ table.set(property.name, property);
176
+ }
177
+ }
178
+ tables.set(type.id, table);
179
+ return table;
180
+ };
181
+ const definedOf = (type) => {
182
+ if ((type.flags & TypeFlags.Union) === 0) {
183
+ return type;
184
+ }
185
+ patternLookups += 1;
186
+ return checker.getNonNullableType(type);
187
+ };
188
+ /**
189
+ * The type one element of an array or tuple value has: a tuple's element at `index`,
190
+ * an array's element type at any index, and `undefined` for a tuple iterated whole.
191
+ */
192
+ const elementOf = (type, index) => {
193
+ if (!type.isTypeReference()) {
194
+ return undefined;
195
+ }
196
+ patternLookups += 1;
197
+ if (checker.isTupleType(type)) {
198
+ if (index === undefined) {
199
+ return undefined;
200
+ }
201
+ patternLookups += 1;
202
+ return checker.getTypeArguments(type)[index];
203
+ }
204
+ patternLookups += 1;
205
+ if (!checker.isArrayType(type)) {
206
+ return undefined;
207
+ }
208
+ patternLookups += 1;
209
+ return checker.getTypeArguments(type)[0];
210
+ };
211
+ const step = (type, next) => {
212
+ const defined = definedOf(type);
213
+ if (defined === undefined) {
214
+ return undefined;
215
+ }
216
+ if (next.kind === "property") {
217
+ const property = tableOf(defined).get(next.name);
218
+ if (property === undefined) {
219
+ return undefined;
220
+ }
221
+ patternLookups += 1;
222
+ return checker.getTypeOfSymbol(property);
223
+ }
224
+ return elementOf(defined, next.kind === "element" ? next.index : undefined);
225
+ };
226
+ const resolve = (reads) => {
227
+ const patterns = [...new Set(reads.map((read) => read.pattern))];
228
+ const sources = new Map();
229
+ const asking = new Set();
230
+ for (const pattern of patterns) {
231
+ if (isObjectBindingPattern(pattern)) {
232
+ asking.add(pattern);
233
+ continue;
234
+ }
235
+ const source = sourceOf(pattern);
236
+ sources.set(pattern, source);
237
+ if (source !== undefined) {
238
+ asking.add(source.expression);
239
+ }
240
+ }
241
+ const answered = new Map();
242
+ const asked = [...asking];
243
+ typesAt(asked).forEach((type, index) => {
244
+ const node = asked[index];
245
+ if (type !== undefined && node !== undefined) {
246
+ answered.set(node, type);
247
+ }
248
+ });
249
+ const patternTypes = new Map();
250
+ for (const pattern of patterns) {
251
+ const source = sources.get(pattern);
252
+ if (source === undefined) {
253
+ patternTypes.set(pattern, answered.get(pattern));
254
+ continue;
255
+ }
256
+ let type = answered.get(source.expression);
257
+ for (const next of source.steps) {
258
+ type = type === undefined ? undefined : step(type, next);
259
+ }
260
+ patternTypes.set(pattern, type === undefined ? undefined : definedOf(type));
261
+ }
262
+ return reads.map((read) => {
263
+ const type = patternTypes.get(read.pattern);
264
+ return type === undefined ? undefined : tableOf(type).get(read.name);
265
+ });
266
+ };
267
+ return {
268
+ resolve,
269
+ get cost() {
270
+ return { patternBatches, patternLookups };
271
+ },
272
+ };
273
+ }
@@ -1,5 +1,6 @@
1
1
  import { isArrayLiteralExpression, isBinaryExpression, isCallExpression, isDecorator, isDeleteExpression, isElementAccessExpression, isExportDeclaration, isExternalModuleReference, isForInStatement, isForOfStatement, isImportDeclaration, isImportEqualsDeclaration, isNamespaceExport, isNoSubstitutionTemplateLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPostfixUnaryExpression, isPrefixUnaryExpression, isPropertyAccessExpression, isPropertyAssignment, isShorthandPropertyAssignment, isSpreadAssignment, isSpreadElement, isStringLiteral, SyntaxKind, } from "@typescript/native/unstable/ast";
2
2
  import { aliasChains } from "./alias-chain.js";
3
+ import { destructuring, propertyReadsOf } from "./destructuring.js";
3
4
  import { globExpression } from "./glob.js";
4
5
  import { nodeKey } from "./inventory.js";
5
6
  import { byPosition, renderPosition } from "./position.js";
@@ -205,6 +206,7 @@ function linksOf(file, node, declarations, enclosing) {
205
206
  */
206
207
  function sitesOf(file, declarations, fileId) {
207
208
  const found = [];
209
+ const reads = [];
208
210
  const links = [];
209
211
  const evaluations = [];
210
212
  const decorated = [];
@@ -243,6 +245,9 @@ function sitesOf(file, declarations, fileId) {
243
245
  }
244
246
  markNames(node);
245
247
  markStores(node, writes);
248
+ for (const read of propertyReadsOf(node)) {
249
+ reads.push({ ...read, from: enclosing });
250
+ }
246
251
  links.push(...linksOf(file, node, declarations, enclosing));
247
252
  const evaluated = evaluatedBy(node);
248
253
  if (evaluated !== undefined) {
@@ -262,7 +267,7 @@ function sitesOf(file, declarations, fileId) {
262
267
  enclosing = outer;
263
268
  };
264
269
  file.forEachChild(visit);
265
- return { uses: found, links, evaluations, decorated };
270
+ return { uses: found, reads, links, evaluations, decorated };
266
271
  }
267
272
  /**
268
273
  * The name one test-file rule takes. A rule's name is a word list the report's own
@@ -316,6 +321,7 @@ export function references(project, held, targetRoot, options) {
316
321
  const found = [];
317
322
  const tests = [];
318
323
  const chains = aliasChains(project, held);
324
+ const destructured = destructuring(project, cap);
319
325
  let batched = 0;
320
326
  let fileBatches = 0;
321
327
  let residueFallbacks = 0;
@@ -351,7 +357,7 @@ export function references(project, held, targetRoot, options) {
351
357
  if (test) {
352
358
  tests.push(path);
353
359
  }
354
- const { uses, links, evaluations, decorated } = sitesOf(file, declarations, declaredAt(file) ?? "");
360
+ const { uses, reads, links, evaluations, decorated } = sitesOf(file, declarations, declaredAt(file) ?? "");
355
361
  // A decorator is written inside the declaration it is attached to and receives it,
356
362
  // so the declaration is used where the decorator is written, whatever the
357
363
  // decorator does with it.
@@ -426,6 +432,25 @@ export function references(project, held, targetRoot, options) {
426
432
  }
427
433
  }
428
434
  }
435
+ // A pattern names a property of the value it destructures, which no name node of
436
+ // the pattern resolves to: a shorthand's name is the local it binds.
437
+ destructured.resolve(reads).forEach((symbol, index) => {
438
+ const read = reads[index];
439
+ if (symbol === undefined || read === undefined) {
440
+ return;
441
+ }
442
+ const position = renderPosition(file, targetRoot, read.key.getStart());
443
+ for (const target of chains.chainOf(symbol)) {
444
+ found.push({
445
+ from: read.from,
446
+ to: target.id,
447
+ position,
448
+ use: "read",
449
+ resolution: "destructured",
450
+ test,
451
+ });
452
+ }
453
+ });
429
454
  for (const site of uses) {
430
455
  const direct = site.shorthand !== undefined
431
456
  ? "shorthand"
@@ -459,6 +484,14 @@ export function references(project, held, targetRoot, options) {
459
484
  configFile: project.configFile,
460
485
  references: found,
461
486
  testFilePaths: tests.sort(compare),
462
- cost: { batched, fileBatches, residueFallbacks, shorthandLookups, aliasSteps: chains.steps },
487
+ cost: {
488
+ batched,
489
+ fileBatches,
490
+ residueFallbacks,
491
+ shorthandLookups,
492
+ aliasSteps: chains.steps,
493
+ patternBatches: destructured.cost.patternBatches,
494
+ patternLookups: destructured.cost.patternLookups,
495
+ },
463
496
  };
464
497
  }
@@ -133,6 +133,15 @@ function canonicalStale(a, b) {
133
133
  compare(a.code, b.code) ||
134
134
  compare(a.symbol, b.symbol));
135
135
  }
136
+ /** One declared gap as a report carries it, its members in the schema's order. */
137
+ function wireGap(gap) {
138
+ return {
139
+ fixture: gap.fixture,
140
+ ...(gap.symbol === undefined ? {} : { symbol: gap.symbol }),
141
+ capability: gap.capability,
142
+ reason: gap.reason,
143
+ };
144
+ }
136
145
  /** Edge evaluations by edge, then side, then their compact encodings. */
137
146
  function byEdge(a, b) {
138
147
  return (compare(a.edge, b.edge) ||
@@ -202,7 +211,11 @@ export function buildReport(input) {
202
211
  findings,
203
212
  edge_evaluations: input.result.edgeEvaluations.map(wireEvaluation).sort(byEdge),
204
213
  stale_suppressions: input.result.staleSuppressions.map(wireStale).sort(canonicalStale),
205
- declared_gaps: [],
214
+ // Every gap shares the one component of the canonical key a gap supplies, the analyzer's
215
+ // name, so the compact encodings alone order them.
216
+ declared_gaps: input.declaredGaps
217
+ .map(wireGap)
218
+ .sort((a, b) => compare(JSON.stringify(a), JSON.stringify(b))),
206
219
  excluded_by_cgo: [],
207
220
  test_file_rules: [...input.testFileRules]
208
221
  .sort((a, b) => compare(a.rule, b.rule) || a.matched - b.matched)
@@ -1,6 +1,6 @@
1
1
  import { runAnalysis } from "../analysis.js";
2
2
  import { ConfigError } from "../config.js";
3
- import { RECORDED_CONFORMANCE, reportedConformance } from "../conformance.js";
3
+ import { CONFORMANCE, DECLARED_GAPS } from "../conformance.js";
4
4
  import { analyzerProvenance, recordedFindings, verdictOf } from "../findings-pass.js";
5
5
  import { packageScope } from "../inventory.js";
6
6
  import { normalizePath, relativePath, resolvePath } from "../paths.js";
@@ -129,7 +129,8 @@ export const analyzeVerb = ({ err, host, inputs, scope, openClient, option, repe
129
129
  const built = buildReport({
130
130
  contractVersion: CONTRACT_VERSION,
131
131
  version,
132
- conformance: reportedConformance(RECORDED_CONFORMANCE),
132
+ conformance: CONFORMANCE,
133
+ declaredGaps: DECLARED_GAPS,
133
134
  target: { kind, root, identity: identityOf(scoped.target, host) },
134
135
  configurations: configurationsOf(run.projects, targetRoot),
135
136
  unavailable: scoped.consumers.map((consumer) => ({
@@ -1,11 +1,11 @@
1
- import { RECORDED_CONFORMANCE } from "../conformance.js";
1
+ import { CONFORMANCE } from "../conformance.js";
2
2
  import { ANALYZER_NAME, LANGUAGE, SCHEMA_VERSION } from "../report.js";
3
3
  import { CONTRACT_VERSION } from "../version.js";
4
4
  import { EXIT_CLEAN, EXIT_FAILURE, EXIT_USAGE } from "./verb.js";
5
5
  /**
6
6
  * The document `describe` writes: the analyzer, the Contract version it implements, the
7
7
  * report schema versions it reads, the languages it claims, and its recorded result over
8
- * the conformance corpus, absent where it records none.
8
+ * the conformance corpus.
9
9
  */
10
10
  export function describeDocument(version, recorded) {
11
11
  const document = {
@@ -14,15 +14,11 @@ export function describeDocument(version, recorded) {
14
14
  contract_version: CONTRACT_VERSION,
15
15
  schema_versions_accepted: [SCHEMA_VERSION],
16
16
  languages: [LANGUAGE],
17
- ...(recorded === undefined
18
- ? {}
19
- : {
20
- conformance: {
21
- corpus_version: recorded.corpusVersion,
22
- result: recorded.result,
23
- digest: recorded.digest,
24
- },
25
- }),
17
+ conformance: {
18
+ corpus_version: recorded.corpusVersion,
19
+ result: recorded.result,
20
+ digest: recorded.digest,
21
+ },
26
22
  };
27
23
  return `${JSON.stringify(document, null, 2)}\n`;
28
24
  }
@@ -40,6 +36,6 @@ export const describeVerb = ({ out, err, host, args }) => {
40
36
  err.write(`deadset-ts: describe: ${error instanceof Error ? error.message : String(error)}\n`);
41
37
  return EXIT_FAILURE;
42
38
  }
43
- out.write(describeDocument(version, RECORDED_CONFORMANCE));
39
+ out.write(describeDocument(version, CONFORMANCE));
44
40
  return EXIT_CLEAN;
45
41
  };
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
@@ -0,0 +1,29 @@
1
+ // Written by the conformance run from conformance.json and conformance-results.json.
2
+ // Regenerate it with `UPDATE_GOLDEN=1 npx vitest --run src/conformance.corpus.test.ts`.
3
+ export const RECORD = {
4
+ conformance: {
5
+ corpusVersion: "1.9.0",
6
+ result: "fail",
7
+ digest: "sha256:8c29f19fc189f4f5d9ea210d556b62d12c3701c221eb7a8adde499b7060bec0d",
8
+ },
9
+ gaps: [
10
+ {
11
+ fixture: "private-member-unread",
12
+ capability: "reflective-lookup",
13
+ reason:
14
+ "The reference pass resolves a string-literal element access to the member it names, so the member is live by that reference and the retained listing names no class for it.",
15
+ },
16
+ {
17
+ fixture: "redundant-export-keyword",
18
+ capability: "DS1104",
19
+ reason:
20
+ "No consumer is loaded beside the target, so the published surface is unknown and no export is narrowed; review an export only the target uses by hand.",
21
+ },
22
+ {
23
+ fixture: "unused-exported-consumer",
24
+ capability: "DS1001",
25
+ reason:
26
+ "No consumer is loaded beside the target, so an export nothing in the target references is reported at the possible class rather than the certain one.",
27
+ },
28
+ ],
29
+ } as const;
@@ -1,34 +1,32 @@
1
1
  /**
2
- * What this analyzer states about its own conformance: the corpus version it is written
3
- * against and its recorded result over that corpus, which `describe` and every report name.
2
+ * What this analyzer states about its own conformance: its recorded result over the
3
+ * conformance corpus and every capability it declines, which `describe` and every report
4
+ * name.
4
5
  */
6
+ import { RECORD } from "./conformance-record.ts";
5
7
 
6
- /** The result one product reached over the conformance corpus. */
8
+ /**
9
+ * The result one product reached over the conformance corpus. A declared gap never lowers
10
+ * it: the result is `pass` when no fixture fails, gaps or not, and `fail` otherwise.
11
+ */
7
12
  export interface Conformance {
8
13
  readonly corpusVersion: string;
9
14
  readonly result: "pass" | "fail";
10
- /** `sha256:` and the lowercase hexadecimal SHA-256 of the results document the runner wrote. */
15
+ /** `sha256:` and the lowercase hexadecimal SHA-256 of the results document the run wrote. */
11
16
  readonly digest: string;
12
17
  }
13
18
 
14
- /** The version of the conformance corpus this analyzer is written against. */
15
- const CORPUS_VERSION = "1.9.0";
19
+ /** One capability this analyzer declines, as `conformance.json` declares it. */
20
+ export interface DeclaredGap {
21
+ readonly fixture: string;
22
+ /** The one expectation the gap covers, where it is narrower than the fixture. */
23
+ readonly symbol?: string;
24
+ readonly capability: string;
25
+ readonly reason: string;
26
+ }
16
27
 
17
- /** The SHA-256 of zero bytes, which is the digest of a results document no run wrote. */
18
- const EMPTY_DIGEST = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
28
+ /** The conformance run this analyzer records. */
29
+ export const CONFORMANCE: Conformance = RECORD.conformance;
19
30
 
20
- /**
21
- * The conformance run this analyzer records, or undefined where it records none. A
22
- * description states exactly this, so a reader that requires a pass refuses an analyzer
23
- * that has not answered the corpus.
24
- */
25
- export const RECORDED_CONFORMANCE: Conformance | undefined = undefined;
26
-
27
- /**
28
- * The conformance a report states. A report requires the block, so an analyzer that
29
- * records no run states a failed one over the results document no run wrote, which a merge
30
- * refuses as it refuses any result other than a pass.
31
- */
32
- export function reportedConformance(recorded: Conformance | undefined): Conformance {
33
- return recorded ?? { corpusVersion: CORPUS_VERSION, result: "fail", digest: EMPTY_DIGEST };
34
- }
31
+ /** Every capability this analyzer declines, in the order `conformance.json` declares them. */
32
+ export const DECLARED_GAPS: readonly DeclaredGap[] = RECORD.gaps;
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The members a destructuring reads. An object pattern reads each property it names
3
+ * from the value it destructures, so the property's name in the pattern is a read of
4
+ * the member the value's type declares under that name, whether the pattern binds it
5
+ * to a local of the same name, renames it, gives it a default or nests a pattern under
6
+ * it. A rest element names no property: what it copies is read where the copy is.
7
+ */
8
+
9
+ import {
10
+ isArrayLiteralExpression,
11
+ isBinaryExpression,
12
+ isForOfStatement,
13
+ isIdentifier,
14
+ isNoSubstitutionTemplateLiteral,
15
+ isNumericLiteral,
16
+ isObjectBindingPattern,
17
+ isObjectLiteralExpression,
18
+ isParameterDeclaration,
19
+ isParenthesizedExpression,
20
+ isPropertyAssignment,
21
+ isShorthandPropertyAssignment,
22
+ isStringLiteral,
23
+ SyntaxKind,
24
+ type BinaryExpression,
25
+ type Node,
26
+ } from "@typescript/native/unstable/ast";
27
+ import { TypeFlags, type Symbol as TSSymbol, type Type } from "@typescript/native/unstable/sync";
28
+ import type { ProjectView } from "./session.ts";
29
+
30
+ /** One property one object pattern reads. */
31
+ export interface PropertyRead {
32
+ /** The pattern, whose destructured value declares the property. */
33
+ readonly pattern: Node;
34
+ /** The property's name as the pattern writes it, which is where the read is written. */
35
+ readonly key: Node;
36
+ /** The property's name. */
37
+ readonly name: string;
38
+ }
39
+
40
+ /** The text a property name spells, or `undefined` for a computed name. */
41
+ function nameText(node: Node | undefined): string | undefined {
42
+ if (node === undefined) {
43
+ return undefined;
44
+ }
45
+ if (
46
+ isIdentifier(node) ||
47
+ isStringLiteral(node) ||
48
+ isNoSubstitutionTemplateLiteral(node) ||
49
+ isNumericLiteral(node)
50
+ ) {
51
+ return node.text;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** The node one expression is written inside, through however many parentheses enclose it. */
57
+ function container(node: Node): { readonly child: Node; readonly parent: Node } {
58
+ let child = node;
59
+ while (isParenthesizedExpression(child.parent)) {
60
+ child = child.parent;
61
+ }
62
+ return { child, parent: child.parent };
63
+ }
64
+
65
+ /** The plain assignment one node is the left side of, if it is one. */
66
+ function assignmentOf(node: Node, parent: Node): BinaryExpression | undefined {
67
+ return isBinaryExpression(parent) &&
68
+ parent.operatorToken.kind === SyntaxKind.EqualsToken &&
69
+ parent.left === node
70
+ ? parent
71
+ : undefined;
72
+ }
73
+
74
+ /**
75
+ * Whether an object or array literal is a destructuring target rather than a value: the
76
+ * left side of an assignment, the target of a `for…of`, or an element of a literal that
77
+ * is one, with or without a default.
78
+ */
79
+ function isDestructuringTarget(literal: Node): boolean {
80
+ const { child, parent } = container(literal);
81
+ if (assignmentOf(child, parent) !== undefined) {
82
+ return true;
83
+ }
84
+ if (isForOfStatement(parent)) {
85
+ return parent.initializer === child;
86
+ }
87
+ return isElement(child, parent);
88
+ }
89
+
90
+ /** Whether one node is an element of a destructuring target: a property's value, or an array element. */
91
+ function isElement(child: Node, parent: Node): boolean {
92
+ if (isPropertyAssignment(parent)) {
93
+ return parent.initializer === child && isDestructuringTarget(parent.parent);
94
+ }
95
+ return isArrayLiteralExpression(parent) && isDestructuringTarget(parent);
96
+ }
97
+
98
+ /**
99
+ * Whether an object binding pattern runs: a pattern in the parameter list of a signature
100
+ * with no body, a function type or an overload among them, binds nothing at runtime.
101
+ */
102
+ function binds(pattern: Node): boolean {
103
+ let at = pattern.parent;
104
+ while (at.kind === SyntaxKind.BindingElement) {
105
+ at = at.parent.parent;
106
+ }
107
+ if (!isParameterDeclaration(at)) {
108
+ return true;
109
+ }
110
+ return (at.parent as { readonly body?: Node }).body !== undefined;
111
+ }
112
+
113
+ /** The properties one node reads, where it is an object pattern; none otherwise. */
114
+ export function propertyReadsOf(node: Node): readonly PropertyRead[] {
115
+ const reads: PropertyRead[] = [];
116
+ if (isObjectBindingPattern(node)) {
117
+ if (!binds(node)) {
118
+ return reads;
119
+ }
120
+ for (const element of node.elements) {
121
+ const key = element.propertyName ?? element.name;
122
+ const name = nameText(key);
123
+ if (element.dotDotDotToken === undefined && key !== undefined && name !== undefined) {
124
+ reads.push({ pattern: node, key, name });
125
+ }
126
+ }
127
+ return reads;
128
+ }
129
+ if (isObjectLiteralExpression(node) && isDestructuringTarget(node)) {
130
+ for (const property of node.properties) {
131
+ if (isShorthandPropertyAssignment(property) || isPropertyAssignment(property)) {
132
+ const name = nameText(property.name);
133
+ if (name !== undefined) {
134
+ reads.push({ pattern: node, key: property.name, name });
135
+ }
136
+ }
137
+ }
138
+ }
139
+ return reads;
140
+ }
141
+
142
+ /** One step from a destructured value to the value a nested target destructures. */
143
+ type Step =
144
+ | { readonly kind: "property"; readonly name: string }
145
+ | { readonly kind: "element"; readonly index: number }
146
+ | { readonly kind: "iterated" };
147
+
148
+ /** Where an assignment target's value comes from: an expression, and the steps into it. */
149
+ interface Source {
150
+ readonly expression: Node;
151
+ readonly steps: readonly Step[];
152
+ }
153
+
154
+ /**
155
+ * Where the value one destructuring target receives comes from. The checker answers for
156
+ * a binding pattern's type itself, so this is asked of an assignment target only, whose
157
+ * own type is the literal's rather than the assigned value's. A default does not move
158
+ * the source: `{ a: { b } = fallback }` reads `b` from `a`. A target inside a spread
159
+ * takes the rest of a value and has no source here.
160
+ */
161
+ function sourceOf(target: Node): Source | undefined {
162
+ const steps: Step[] = [];
163
+ let at = target;
164
+ for (;;) {
165
+ const { child, parent } = container(at);
166
+ const assignment = assignmentOf(child, parent);
167
+ if (assignment !== undefined) {
168
+ const outer = container(assignment);
169
+ if (!isElement(outer.child, outer.parent)) {
170
+ return { expression: assignment.right, steps: steps.reverse() };
171
+ }
172
+ at = assignment;
173
+ continue;
174
+ }
175
+ if (isForOfStatement(parent) && parent.initializer === child) {
176
+ steps.push({ kind: "iterated" });
177
+ return { expression: parent.expression, steps: steps.reverse() };
178
+ }
179
+ if (isPropertyAssignment(parent) && parent.initializer === child) {
180
+ const name = nameText(parent.name);
181
+ if (name === undefined) {
182
+ return undefined;
183
+ }
184
+ steps.push({ kind: "property", name });
185
+ at = parent.parent;
186
+ continue;
187
+ }
188
+ if (isArrayLiteralExpression(parent)) {
189
+ steps.push({
190
+ kind: "element",
191
+ index: parent.elements.findIndex((element) => element === child),
192
+ });
193
+ at = parent;
194
+ continue;
195
+ }
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ /** What resolving one project's property reads cost at the client boundary. */
201
+ interface DestructuringCost {
202
+ /** Batched type lookups, one per capped run of a file's patterns and assigned values. */
203
+ readonly patternBatches: number;
204
+ /**
205
+ * Per-type lookups: one property table per distinct destructured type, and each step
206
+ * an assignment target takes into its assigned value.
207
+ */
208
+ readonly patternLookups: number;
209
+ }
210
+
211
+ /** Resolves property reads to the symbols the destructured values' types declare. */
212
+ interface Destructuring {
213
+ /**
214
+ * The property symbol each read names, in the order given, `undefined` where the
215
+ * destructured value's type declares no property of that name or is not known.
216
+ */
217
+ resolve(reads: readonly PropertyRead[]): readonly (TSSymbol | undefined)[];
218
+ readonly cost: DestructuringCost;
219
+ }
220
+
221
+ /**
222
+ * The resolution over one project, its batches capped at `cap`. Property tables are
223
+ * read once per type for the whole project, since one type is destructured in many
224
+ * places and a table answers every name of it.
225
+ */
226
+ export function destructuring<Brand>(project: ProjectView<Brand>, cap: number): Destructuring {
227
+ const checker = project.checker;
228
+ const tables = new Map<number, ReadonlyMap<string, TSSymbol>>();
229
+ let patternBatches = 0;
230
+ let patternLookups = 0;
231
+
232
+ const typesAt = (nodes: readonly Node[]): (Type | undefined)[] => {
233
+ const types: (Type | undefined)[] = [];
234
+ for (let from = 0; from < nodes.length; from += cap) {
235
+ types.push(...checker.getTypeAtLocation(nodes.slice(from, from + cap)));
236
+ patternBatches += 1;
237
+ }
238
+ return types;
239
+ };
240
+
241
+ const tableOf = (type: Type): ReadonlyMap<string, TSSymbol> => {
242
+ const known = tables.get(type.id);
243
+ if (known !== undefined) {
244
+ return known;
245
+ }
246
+ const table = new Map<string, TSSymbol>();
247
+ if ((type.flags & TypeFlags.AnyOrUnknown) === 0) {
248
+ patternLookups += 1;
249
+ for (const property of checker.getPropertiesOfType(type)) {
250
+ table.set(property.name, property);
251
+ }
252
+ }
253
+ tables.set(type.id, table);
254
+ return table;
255
+ };
256
+
257
+ const definedOf = (type: Type): Type | undefined => {
258
+ if ((type.flags & TypeFlags.Union) === 0) {
259
+ return type;
260
+ }
261
+ patternLookups += 1;
262
+ return checker.getNonNullableType(type);
263
+ };
264
+
265
+ /**
266
+ * The type one element of an array or tuple value has: a tuple's element at `index`,
267
+ * an array's element type at any index, and `undefined` for a tuple iterated whole.
268
+ */
269
+ const elementOf = (type: Type, index: number | undefined): Type | undefined => {
270
+ if (!type.isTypeReference()) {
271
+ return undefined;
272
+ }
273
+ patternLookups += 1;
274
+ if (checker.isTupleType(type)) {
275
+ if (index === undefined) {
276
+ return undefined;
277
+ }
278
+ patternLookups += 1;
279
+ return checker.getTypeArguments(type)[index];
280
+ }
281
+ patternLookups += 1;
282
+ if (!checker.isArrayType(type)) {
283
+ return undefined;
284
+ }
285
+ patternLookups += 1;
286
+ return checker.getTypeArguments(type)[0];
287
+ };
288
+
289
+ const step = (type: Type, next: Step): Type | undefined => {
290
+ const defined = definedOf(type);
291
+ if (defined === undefined) {
292
+ return undefined;
293
+ }
294
+ if (next.kind === "property") {
295
+ const property = tableOf(defined).get(next.name);
296
+ if (property === undefined) {
297
+ return undefined;
298
+ }
299
+ patternLookups += 1;
300
+ return checker.getTypeOfSymbol(property);
301
+ }
302
+ return elementOf(defined, next.kind === "element" ? next.index : undefined);
303
+ };
304
+
305
+ const resolve = (reads: readonly PropertyRead[]): readonly (TSSymbol | undefined)[] => {
306
+ const patterns = [...new Set(reads.map((read) => read.pattern))];
307
+ const sources = new Map<Node, Source | undefined>();
308
+ const asking = new Set<Node>();
309
+ for (const pattern of patterns) {
310
+ if (isObjectBindingPattern(pattern)) {
311
+ asking.add(pattern);
312
+ continue;
313
+ }
314
+ const source = sourceOf(pattern);
315
+ sources.set(pattern, source);
316
+ if (source !== undefined) {
317
+ asking.add(source.expression);
318
+ }
319
+ }
320
+ const answered = new Map<Node, Type>();
321
+ const asked = [...asking];
322
+ typesAt(asked).forEach((type, index) => {
323
+ const node = asked[index];
324
+ if (type !== undefined && node !== undefined) {
325
+ answered.set(node, type);
326
+ }
327
+ });
328
+ const patternTypes = new Map<Node, Type | undefined>();
329
+ for (const pattern of patterns) {
330
+ const source = sources.get(pattern);
331
+ if (source === undefined) {
332
+ patternTypes.set(pattern, answered.get(pattern));
333
+ continue;
334
+ }
335
+ let type = answered.get(source.expression);
336
+ for (const next of source.steps) {
337
+ type = type === undefined ? undefined : step(type, next);
338
+ }
339
+ patternTypes.set(pattern, type === undefined ? undefined : definedOf(type));
340
+ }
341
+ return reads.map((read) => {
342
+ const type = patternTypes.get(read.pattern);
343
+ return type === undefined ? undefined : tableOf(type).get(read.name);
344
+ });
345
+ };
346
+
347
+ return {
348
+ resolve,
349
+ get cost() {
350
+ return { patternBatches, patternLookups };
351
+ },
352
+ };
353
+ }
package/src/references.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from "@typescript/native/unstable/ast";
30
30
  import type { Symbol as TSSymbol } from "@typescript/native/unstable/sync";
31
31
  import { aliasChains } from "./alias-chain.ts";
32
+ import { destructuring, propertyReadsOf, type PropertyRead } from "./destructuring.ts";
32
33
  import { globExpression } from "./glob.ts";
33
34
  import { nodeKey, type Inventory } from "./inventory.ts";
34
35
  import { byPosition, renderPosition, type Position } from "./position.ts";
@@ -71,8 +72,11 @@ export type Use = "read" | "write" | "evaluation" | "decorator";
71
72
  * the literal declares rather than to the declaration that name reads.
72
73
  * - `alias` is a step along an import or export chain, from one link to what it names.
73
74
  * - `syntax` is the tree alone, which says what a decorator is attached to.
75
+ * - `destructured` is the property a pattern names, looked up on the type of the value
76
+ * the pattern destructures.
74
77
  */
75
- export type Resolution = "batch" | "resolved-symbol" | "shorthand" | "alias" | "syntax";
78
+ export type Resolution =
79
+ "batch" | "resolved-symbol" | "shorthand" | "alias" | "syntax" | "destructured";
76
80
 
77
81
  /** One use of one declaration by one declaration. */
78
82
  export interface Reference {
@@ -119,6 +123,16 @@ export interface ReferenceCost {
119
123
  readonly shorthandLookups: number;
120
124
  /** Per-symbol lookups that step from one alias to what it names, one per alias. */
121
125
  readonly aliasSteps: number;
126
+ /**
127
+ * Batched type lookups for the object patterns: per file, the patterns and the values
128
+ * assigned to destructuring targets, divided by the batch cap, rounded up.
129
+ */
130
+ readonly patternBatches: number;
131
+ /**
132
+ * Per-type lookups for the object patterns: one property table per distinct type a
133
+ * pattern destructures, and each step a nested assignment target takes into its value.
134
+ */
135
+ readonly patternLookups: number;
122
136
  }
123
137
 
124
138
  /** One project's references, in position order, and what reading them cost. */
@@ -326,9 +340,15 @@ interface Decorated {
326
340
  readonly declaration: string;
327
341
  }
328
342
 
343
+ /** One property a pattern reads, and the declaration the pattern is written inside. */
344
+ interface Read extends PropertyRead {
345
+ readonly from: string;
346
+ }
347
+
329
348
  /** Everything one file's walk found to resolve, and the decorators it needs no resolution for. */
330
349
  interface FileSites {
331
350
  readonly uses: readonly Site[];
351
+ readonly reads: readonly Read[];
332
352
  readonly links: readonly Link[];
333
353
  readonly evaluations: readonly Evaluation[];
334
354
  readonly decorated: readonly Decorated[];
@@ -425,6 +445,7 @@ function sitesOf(
425
445
  fileId: string,
426
446
  ): FileSites {
427
447
  const found: Site[] = [];
448
+ const reads: Read[] = [];
428
449
  const links: Link[] = [];
429
450
  const evaluations: Evaluation[] = [];
430
451
  const decorated: Decorated[] = [];
@@ -465,6 +486,9 @@ function sitesOf(
465
486
  }
466
487
  markNames(node);
467
488
  markStores(node, writes);
489
+ for (const read of propertyReadsOf(node)) {
490
+ reads.push({ ...read, from: enclosing });
491
+ }
468
492
  links.push(...linksOf(file, node, declarations, enclosing));
469
493
  const evaluated = evaluatedBy(node);
470
494
  if (evaluated !== undefined) {
@@ -485,7 +509,7 @@ function sitesOf(
485
509
  };
486
510
 
487
511
  file.forEachChild(visit);
488
- return { uses: found, links, evaluations, decorated };
512
+ return { uses: found, reads, links, evaluations, decorated };
489
513
  }
490
514
 
491
515
  /**
@@ -551,6 +575,7 @@ export function references<Brand>(
551
575
  const found: Reference[] = [];
552
576
  const tests: string[] = [];
553
577
  const chains = aliasChains(project, held);
578
+ const destructured = destructuring(project, cap);
554
579
  let batched = 0;
555
580
  let fileBatches = 0;
556
581
  let residueFallbacks = 0;
@@ -595,7 +620,7 @@ export function references<Brand>(
595
620
  if (test) {
596
621
  tests.push(path);
597
622
  }
598
- const { uses, links, evaluations, decorated } = sitesOf(
623
+ const { uses, reads, links, evaluations, decorated } = sitesOf(
599
624
  file,
600
625
  declarations,
601
626
  declaredAt(file) ?? "",
@@ -679,6 +704,26 @@ export function references<Brand>(
679
704
  }
680
705
  }
681
706
 
707
+ // A pattern names a property of the value it destructures, which no name node of
708
+ // the pattern resolves to: a shorthand's name is the local it binds.
709
+ destructured.resolve(reads).forEach((symbol, index) => {
710
+ const read = reads[index];
711
+ if (symbol === undefined || read === undefined) {
712
+ return;
713
+ }
714
+ const position = renderPosition(file, targetRoot, read.key.getStart());
715
+ for (const target of chains.chainOf(symbol)) {
716
+ found.push({
717
+ from: read.from,
718
+ to: target.id,
719
+ position,
720
+ use: "read",
721
+ resolution: "destructured",
722
+ test,
723
+ });
724
+ }
725
+ });
726
+
682
727
  for (const site of uses) {
683
728
  const direct: Resolution =
684
729
  site.shorthand !== undefined
@@ -718,6 +763,14 @@ export function references<Brand>(
718
763
  configFile: project.configFile,
719
764
  references: found,
720
765
  testFilePaths: tests.sort(compare),
721
- cost: { batched, fileBatches, residueFallbacks, shorthandLookups, aliasSteps: chains.steps },
766
+ cost: {
767
+ batched,
768
+ fileBatches,
769
+ residueFallbacks,
770
+ shorthandLookups,
771
+ aliasSteps: chains.steps,
772
+ patternBatches: destructured.cost.patternBatches,
773
+ patternLookups: destructured.cost.patternLookups,
774
+ },
722
775
  };
723
776
  }
package/src/report.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Severity, Sort, TargetKind } from "./config.ts";
8
- import type { Conformance } from "./conformance.ts";
8
+ import type { Conformance, DeclaredGap } from "./conformance.ts";
9
9
  import type { EdgeEvaluation } from "./edges.ts";
10
10
  import type {
11
11
  CompletedFinding,
@@ -105,6 +105,13 @@ export interface WireStaleSuppression {
105
105
  readonly message: string;
106
106
  }
107
107
 
108
+ interface WireDeclaredGap {
109
+ readonly fixture: string;
110
+ readonly symbol?: string;
111
+ readonly capability: string;
112
+ readonly reason: string;
113
+ }
114
+
108
115
  export interface WireTotals {
109
116
  readonly findings: number;
110
117
  readonly by_severity: { readonly allow: number; readonly warn: number; readonly deny: number };
@@ -159,7 +166,7 @@ export interface Report {
159
166
  readonly findings: readonly WireFinding[];
160
167
  readonly edge_evaluations: readonly WireEdgeEvaluation[];
161
168
  readonly stale_suppressions: readonly WireStaleSuppression[];
162
- readonly declared_gaps: readonly never[];
169
+ readonly declared_gaps: readonly WireDeclaredGap[];
163
170
  readonly excluded_by_cgo: readonly never[];
164
171
  readonly test_file_rules: readonly TestFileRule[];
165
172
  readonly totals: WireTotals;
@@ -170,6 +177,7 @@ export interface ReportInput {
170
177
  readonly contractVersion: string;
171
178
  readonly version: string;
172
179
  readonly conformance: Conformance;
180
+ readonly declaredGaps: readonly DeclaredGap[];
173
181
  readonly target: { readonly kind: TargetKind; readonly root: string; readonly identity: string };
174
182
  readonly configurations: readonly ReportConfiguration[];
175
183
  readonly unavailable: readonly UnavailableConsumer[];
@@ -318,6 +326,16 @@ function canonicalStale(a: WireStaleSuppression, b: WireStaleSuppression): numbe
318
326
  );
319
327
  }
320
328
 
329
+ /** One declared gap as a report carries it, its members in the schema's order. */
330
+ function wireGap(gap: DeclaredGap): WireDeclaredGap {
331
+ return {
332
+ fixture: gap.fixture,
333
+ ...(gap.symbol === undefined ? {} : { symbol: gap.symbol }),
334
+ capability: gap.capability,
335
+ reason: gap.reason,
336
+ };
337
+ }
338
+
321
339
  /** Edge evaluations by edge, then side, then their compact encodings. */
322
340
  function byEdge(a: WireEdgeEvaluation, b: WireEdgeEvaluation): number {
323
341
  return (
@@ -392,7 +410,11 @@ export function buildReport(input: ReportInput): Report {
392
410
  findings,
393
411
  edge_evaluations: input.result.edgeEvaluations.map(wireEvaluation).sort(byEdge),
394
412
  stale_suppressions: input.result.staleSuppressions.map(wireStale).sort(canonicalStale),
395
- declared_gaps: [],
413
+ // Every gap shares the one component of the canonical key a gap supplies, the analyzer's
414
+ // name, so the compact encodings alone order them.
415
+ declared_gaps: input.declaredGaps
416
+ .map(wireGap)
417
+ .sort((a, b) => compare(JSON.stringify(a), JSON.stringify(b))),
396
418
  excluded_by_cgo: [],
397
419
  test_file_rules: [...input.testFileRules]
398
420
  .sort((a, b) => compare(a.rule, b.rule) || a.matched - b.matched)
@@ -1,6 +1,6 @@
1
1
  import { runAnalysis, type RunProject } from "../analysis.ts";
2
2
  import { ConfigError, type Format } from "../config.ts";
3
- import { RECORDED_CONFORMANCE, reportedConformance } from "../conformance.ts";
3
+ import { CONFORMANCE, DECLARED_GAPS } from "../conformance.ts";
4
4
  import { analyzerProvenance, recordedFindings, verdictOf } from "../findings-pass.ts";
5
5
  import { packageScope } from "../inventory.ts";
6
6
  import { normalizePath, relativePath, resolvePath } from "../paths.ts";
@@ -175,7 +175,8 @@ export const analyzeVerb: Verb = ({ err, host, inputs, scope, openClient, option
175
175
  const built = buildReport({
176
176
  contractVersion: CONTRACT_VERSION,
177
177
  version,
178
- conformance: reportedConformance(RECORDED_CONFORMANCE),
178
+ conformance: CONFORMANCE,
179
+ declaredGaps: DECLARED_GAPS,
179
180
  target: { kind, root, identity: identityOf(scoped.target, host) },
180
181
  configurations: configurationsOf(run.projects, targetRoot),
181
182
  unavailable: scoped.consumers.map((consumer) => ({
@@ -1,4 +1,4 @@
1
- import { RECORDED_CONFORMANCE, type Conformance } from "../conformance.ts";
1
+ import { CONFORMANCE, type Conformance } from "../conformance.ts";
2
2
  import { ANALYZER_NAME, LANGUAGE, SCHEMA_VERSION } from "../report.ts";
3
3
  import { CONTRACT_VERSION } from "../version.ts";
4
4
  import { EXIT_CLEAN, EXIT_FAILURE, EXIT_USAGE, type Verb } from "./verb.ts";
@@ -6,24 +6,20 @@ import { EXIT_CLEAN, EXIT_FAILURE, EXIT_USAGE, type Verb } from "./verb.ts";
6
6
  /**
7
7
  * The document `describe` writes: the analyzer, the Contract version it implements, the
8
8
  * report schema versions it reads, the languages it claims, and its recorded result over
9
- * the conformance corpus, absent where it records none.
9
+ * the conformance corpus.
10
10
  */
11
- export function describeDocument(version: string, recorded: Conformance | undefined): string {
11
+ export function describeDocument(version: string, recorded: Conformance): string {
12
12
  const document = {
13
13
  name: ANALYZER_NAME,
14
14
  version,
15
15
  contract_version: CONTRACT_VERSION,
16
16
  schema_versions_accepted: [SCHEMA_VERSION],
17
17
  languages: [LANGUAGE],
18
- ...(recorded === undefined
19
- ? {}
20
- : {
21
- conformance: {
22
- corpus_version: recorded.corpusVersion,
23
- result: recorded.result,
24
- digest: recorded.digest,
25
- },
26
- }),
18
+ conformance: {
19
+ corpus_version: recorded.corpusVersion,
20
+ result: recorded.result,
21
+ digest: recorded.digest,
22
+ },
27
23
  };
28
24
  return `${JSON.stringify(document, null, 2)}\n`;
29
25
  }
@@ -41,6 +37,6 @@ export const describeVerb: Verb = ({ out, err, host, args }) => {
41
37
  err.write(`deadset-ts: describe: ${error instanceof Error ? error.message : String(error)}\n`);
42
38
  return EXIT_FAILURE;
43
39
  }
44
- out.write(describeDocument(version, RECORDED_CONFORMANCE));
40
+ out.write(describeDocument(version, CONFORMANCE));
45
41
  return EXIT_CLEAN;
46
42
  };