@cplieger/deadset-ts 2.3.0 → 3.1.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/references.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import {
2
2
  isArrayLiteralExpression,
3
3
  isBinaryExpression,
4
+ isCallExpression,
4
5
  isDeleteExpression,
5
6
  isElementAccessExpression,
7
+ isExportDeclaration,
8
+ isExternalModuleReference,
6
9
  isForInStatement,
7
10
  isForOfStatement,
11
+ isImportDeclaration,
12
+ isImportEqualsDeclaration,
13
+ isNamespaceExport,
14
+ isNoSubstitutionTemplateLiteral,
8
15
  isObjectLiteralExpression,
9
16
  isParenthesizedExpression,
10
17
  isPostfixUnaryExpression,
@@ -14,11 +21,13 @@ import {
14
21
  isShorthandPropertyAssignment,
15
22
  isSpreadAssignment,
16
23
  isSpreadElement,
24
+ isStringLiteral,
17
25
  SyntaxKind,
18
26
  type Node,
19
27
  type SourceFile,
20
28
  } from "@typescript/native/unstable/ast";
21
- import { SymbolFlags, type Symbol as TSSymbol } from "@typescript/native/unstable/sync";
29
+ import type { Symbol as TSSymbol } from "@typescript/native/unstable/sync";
30
+ import { aliasChains } from "./alias-chain.ts";
22
31
  import { globExpression } from "./glob.ts";
23
32
  import { nodeKey, type Inventory } from "./inventory.ts";
24
33
  import { byPosition, renderPosition, type Position } from "./position.ts";
@@ -35,13 +44,18 @@ import type { ProjectView } from "./session.ts";
35
44
  *
36
45
  * What the pass records and what it does not: a reference carries its use, its
37
46
  * position, the declaration that encloses it and the classification of the file that
38
- * made it. It carries no mode. Which of the references a run counts is the reading the
39
- * sweep makes of this set, so the mode is one value the run decides once and this pass
40
- * never sees it.
47
+ * made it, and every place that imports one of the project's own modules is an
48
+ * evaluation of that module's file. It carries no mode. Which of the references a run
49
+ * counts is the reading the sweep makes of this set, so the mode is one value the run
50
+ * decides once and this pass never sees it.
41
51
  */
42
52
 
43
- /** How one reference uses the declaration it names. */
44
- export type Use = "read" | "write";
53
+ /**
54
+ * How one reference uses the declaration it names. An `evaluation` names a file: the
55
+ * place it is written imports the module, which runs the module's top level and reads
56
+ * none of what it exports. A `read` of a file reads the module's namespace.
57
+ */
58
+ export type Use = "read" | "write" | "evaluation";
45
59
 
46
60
  /**
47
61
  * Which accessor answered for one reference, so that the cost of a run is attributable
@@ -110,6 +124,12 @@ export interface References {
110
124
  readonly references: readonly Reference[];
111
125
  /** The rules that classified files as test files, ordered by rule. */
112
126
  readonly testFileRules: readonly TestFileRule[];
127
+ /**
128
+ * Every file the rules classified as a test file, by its path below the target root,
129
+ * ascending. A declaration such a file holds is a test declaration, by the same
130
+ * classification that marks the references the file makes.
131
+ */
132
+ readonly testFilePaths: readonly string[];
113
133
  readonly cost: ReferenceCost;
114
134
  }
115
135
 
@@ -273,22 +293,131 @@ interface Site {
273
293
  readonly use: Use;
274
294
  }
275
295
 
296
+ /**
297
+ * One re-export the walk found, which names what it carries forward without a name
298
+ * that is a use: an export specifier, a namespace export, or a bare star re-export.
299
+ */
300
+ interface Link {
301
+ /**
302
+ * The node whose symbol says what the re-export carries: the alias a specifier or a
303
+ * namespace export declares, or the module a bare star re-export names.
304
+ */
305
+ readonly node: Node;
306
+ /** Where the reference is written: the name a specifier re-exports, or the module specifier. */
307
+ readonly at: Node;
308
+ /** The re-export's own declaration, or for a bare star the declaration enclosing it. */
309
+ readonly from: string;
310
+ /** Whether the node's symbol is an alias, which is followed one link along. */
311
+ readonly alias: boolean;
312
+ }
313
+
314
+ /** One place the walk found that evaluates a module, and the declaration that holds it. */
315
+ interface Evaluation {
316
+ /** The module specifier, whose symbol is the module evaluated. */
317
+ readonly node: Node;
318
+ readonly from: string;
319
+ }
320
+
321
+ /** Everything one file's walk found to resolve. */
322
+ interface FileSites {
323
+ readonly uses: readonly Site[];
324
+ readonly links: readonly Link[];
325
+ readonly evaluations: readonly Evaluation[];
326
+ }
327
+
328
+ /** Whether one node is a string literal or a template literal with no substitution. */
329
+ function isLiteralText(node: Node | undefined): node is Node {
330
+ return node !== undefined && (isStringLiteral(node) || isNoSubstitutionTemplateLiteral(node));
331
+ }
332
+
333
+ /**
334
+ * The module specifier of the module one node evaluates, if it evaluates one: an import
335
+ * declaration, an import-equals declaration of an external module, an export
336
+ * declaration that names a module, and an `import()` call whose argument is literal
337
+ * text. A type-only form evaluates nothing. An `import()` of a computed argument names
338
+ * no module this pass can know.
339
+ */
340
+ function evaluatedBy(node: Node): Node | undefined {
341
+ if (isImportDeclaration(node)) {
342
+ return node.importClause?.phaseModifier === SyntaxKind.TypeKeyword
343
+ ? undefined
344
+ : node.moduleSpecifier;
345
+ }
346
+ if (isExportDeclaration(node)) {
347
+ return node.isTypeOnly ? undefined : node.moduleSpecifier;
348
+ }
349
+ if (isImportEqualsDeclaration(node)) {
350
+ const reference = node.moduleReference;
351
+ return !node.isTypeOnly && isExternalModuleReference(reference)
352
+ ? reference.expression
353
+ : undefined;
354
+ }
355
+ if (isCallExpression(node) && node.expression.kind === SyntaxKind.ImportKeyword) {
356
+ const [argument] = node.arguments;
357
+ return isLiteralText(argument) ? argument : undefined;
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ /**
363
+ * The re-exports one node writes, if it is an export declaration. A specifier and a
364
+ * namespace export are declarations whose names are where they are written, so no use is
365
+ * read from them; each references what it carries forward instead. A bare star declares
366
+ * nothing, so the declaration enclosing it, at the top level the file, references the
367
+ * module it names.
368
+ */
369
+ function linksOf(
370
+ file: SourceFile,
371
+ node: Node,
372
+ declarations: ReadonlyMap<string, string>,
373
+ enclosing: string,
374
+ ): Link[] {
375
+ if (!isExportDeclaration(node)) {
376
+ return [];
377
+ }
378
+ const clause = node.exportClause;
379
+ const specifier = node.moduleSpecifier;
380
+ if (clause === undefined) {
381
+ return specifier === undefined
382
+ ? []
383
+ : [{ node: specifier, at: specifier, from: enclosing, alias: false }];
384
+ }
385
+ if (isNamespaceExport(clause)) {
386
+ const from = declarations.get(nodeKey(file, clause));
387
+ return from === undefined
388
+ ? []
389
+ : [{ node: clause.name, at: specifier ?? clause.name, from, alias: true }];
390
+ }
391
+ const found: Link[] = [];
392
+ for (const element of clause.elements) {
393
+ const from = declarations.get(nodeKey(file, element));
394
+ if (from !== undefined) {
395
+ found.push({
396
+ node: element.name,
397
+ at: element.propertyName ?? element.name,
398
+ from,
399
+ alias: true,
400
+ });
401
+ }
402
+ }
403
+ return found;
404
+ }
405
+
276
406
  /**
277
407
  * Every name node one file holds that is a use rather than a declaration, in source
278
- * order, each with the declaration that encloses it and the use it makes.
279
- *
280
- * One walk answers all three, because the three facts are positional and a second walk
281
- * would have to agree with this one about which nodes are names. A name that is the
282
- * declared name of the declaration it belongs to is left out: it is where that
283
- * declaration is written rather than a use of it, which is what makes a declaration
284
- * referenced only from its own site an unreferenced declaration.
408
+ * order, each with its enclosing declaration and its use, and every re-export the file
409
+ * writes. One walk answers all of it, because a second would have to agree with this one
410
+ * about which nodes are names. A declaration's own declared name is left out, which is
411
+ * what makes a declaration referenced only from its own site an unreferenced one.
285
412
  */
286
413
  function sitesOf(
287
414
  file: SourceFile,
288
415
  declarations: ReadonlyMap<string, string>,
289
416
  fileId: string,
290
- ): Site[] {
417
+ ): FileSites {
291
418
  const found: Site[] = [];
419
+ const links: Link[] = [];
420
+ const evaluations: Evaluation[] = [];
292
421
  const writes = new Set<number>();
293
422
  const declared = new Set<number>();
294
423
  const shorthands = new Map<number, Node>();
@@ -326,6 +455,11 @@ function sitesOf(
326
455
  }
327
456
  markNames(node);
328
457
  markStores(node, writes);
458
+ links.push(...linksOf(file, node, declarations, enclosing));
459
+ const evaluated = evaluatedBy(node);
460
+ if (evaluated !== undefined) {
461
+ evaluations.push({ node: evaluated, from: enclosing });
462
+ }
329
463
  const held = declarations.get(nodeKey(file, node));
330
464
  const outer = enclosing;
331
465
  if (held !== undefined) {
@@ -336,7 +470,7 @@ function sitesOf(
336
470
  };
337
471
 
338
472
  file.forEachChild(visit);
339
- return found;
473
+ return { uses: found, links, evaluations };
340
474
  }
341
475
 
342
476
  /**
@@ -356,12 +490,6 @@ function compare(a: string, b: string): number {
356
490
  return a < b ? -1 : 1;
357
491
  }
358
492
 
359
- /** One declaration one symbol reaches, and whether a step along an alias reached it. */
360
- interface Target {
361
- readonly id: string;
362
- readonly stepped: boolean;
363
- }
364
-
365
493
  /**
366
494
  * Every reference one project's own files make to the project's own declarations, in
367
495
  * position order, and the test-file rules that classified those files.
@@ -373,7 +501,8 @@ interface Target {
373
501
  * The order is by the referencing position, then by the declaration named, the
374
502
  * declaration referencing it and the use. That is a total order because one name
375
503
  * references one declaration once from one declaration, while a chain of aliases reaches
376
- * several declarations at one position.
504
+ * several declarations at one position and a bare star re-export's specifier both reads
505
+ * and evaluates the module it names.
377
506
  */
378
507
  export function references<Brand>(
379
508
  project: ProjectView<Brand>,
@@ -386,78 +515,39 @@ export function references<Brand>(
386
515
  const patterns = options.testFiles.map(globExpression);
387
516
  const matched = options.testFiles.map(() => 0);
388
517
  const found: Reference[] = [];
389
- const reached = new Map<number, readonly Target[]>();
518
+ const tests: string[] = [];
519
+ const chains = aliasChains(project, held);
390
520
  let batched = 0;
391
521
  let fileBatches = 0;
392
522
  let residueFallbacks = 0;
393
523
  let shorthandLookups = 0;
394
- let aliasSteps = 0;
395
524
 
396
525
  const files = project.ownSourceFiles();
397
- const ownFiles = new Set(files.map((file) => file.fileName));
526
+ const fileIds = new Set(
527
+ held.symbols.filter((symbol) => symbol.kind === "file").map((symbol) => symbol.id),
528
+ );
398
529
 
399
530
  /** The identifier of the declaration one node of this program is, where it is one. */
400
531
  const declaredAt = (node: Node): string | undefined =>
401
532
  declarations.get(nodeKey(node.getSourceFile(), node));
402
533
 
403
534
  /**
404
- * The declarations one symbol names: the declaration it is, and every link of the
405
- * alias chain it stands at the head of. A re-export something imports is used and so
406
- * is the declaration behind it, so both are named; a link this project does not
407
- * declare is stepped over rather than recorded.
408
- *
409
- * A symbol is asked to step only where the binder flagged it an alias, which is the
410
- * same condition the step itself asserts on. The declaration's syntax is not that
411
- * condition and does not stand in for it: a default export of an expression is
412
- * written as one of the alias forms and names nothing declared elsewhere, so it is
413
- * recorded as the declaration it is and the chain ends there.
414
- *
415
- * A declaration handle carries the file it is in, so the question the walk asks of a
416
- * declaration is answered before it is resolved: a declaration outside the project's
417
- * own files belongs to another program and is never read, which is what keeps the
418
- * pass from fetching a library file to discover that the inventory does not hold what
419
- * is in it.
535
+ * The declarations one re-export carries forward: for an alias, the first link along
536
+ * its chain that the inventory declares, stepping over the links it does not, such as
537
+ * an import the re-export is written against; for a bare star, the module it names.
538
+ * Each later link is a declaration of its own that references the next, so one link
539
+ * per re-export is what lets a chain be followed one declaration at a time.
420
540
  */
421
- const targetsOf = (symbol: TSSymbol): readonly Target[] => {
422
- const cached = reached.get(symbol.id);
423
- if (cached !== undefined) {
424
- return cached;
425
- }
426
- const targets: Target[] = [];
427
- const walked = new Set<number>();
428
- let at: TSSymbol | undefined = symbol;
429
- let stepped = false;
430
- while (at !== undefined && !walked.has(at.id)) {
431
- walked.add(at.id);
432
- const alias = (at.flags & SymbolFlags.Alias) !== 0;
433
- for (const handle of at.declarations) {
434
- if (!ownFiles.has(handle.path)) {
435
- continue;
436
- }
437
- const node = project.declarationAt(handle)?.node;
438
- const id = node === undefined ? undefined : declaredAt(node);
439
- if (id !== undefined && !targets.some((target) => target.id === id)) {
440
- targets.push({ id, stepped });
441
- }
442
- }
443
- if (!alias) {
444
- break;
445
- }
446
- at = project.aliasStepOf(at);
447
- aliasSteps += 1;
448
- stepped = true;
449
- }
450
- reached.set(symbol.id, targets);
451
- return targets;
452
- };
541
+ const carriedBy = (link: Link, symbol: TSSymbol): readonly string[] =>
542
+ link.alias ? chains.nextDeclared(symbol) : chains.declarationsOf(symbol);
453
543
 
454
544
  /** The symbol one site resolves to, asking only the accessor the site's form names. */
455
- const symbolFor = (site: Site, answered: ReadonlyMap<number, TSSymbol>): TSSymbol | undefined => {
545
+ const symbolFor = (site: Site, answered: ReadonlyMap<Node, TSSymbol>): TSSymbol | undefined => {
456
546
  if (site.shorthand !== undefined) {
457
547
  shorthandLookups += 1;
458
548
  return project.shorthandValueAt(project.handle(site.shorthand));
459
549
  }
460
- const batched = answered.get(site.node.pos);
550
+ const batched = answered.get(site.node);
461
551
  if (batched !== undefined) {
462
552
  return batched;
463
553
  }
@@ -467,38 +557,87 @@ export function references<Brand>(
467
557
 
468
558
  for (const file of files) {
469
559
  const path = renderPosition(file, targetRoot, 0).path;
470
- let test = false;
471
- patterns.forEach((pattern, index) => {
472
- if (pattern.test(path)) {
560
+ const hits = patterns.map((pattern) => pattern.test(path));
561
+ hits.forEach((hit, index) => {
562
+ if (hit) {
473
563
  matched[index] = (matched[index] ?? 0) + 1;
474
- test = true;
475
564
  }
476
565
  });
477
- const sites = sitesOf(file, declarations, declaredAt(file) ?? "");
478
-
479
- // One batch per capped run of the file's name nodes. A shorthand is left out of
480
- // the batch: its name resolves to the property the literal declares, so the batch's
481
- // answer for it would name something this project's inventory never holds.
482
- const batching = sites.filter((site) => site.shorthand === undefined);
483
- const answered = new Map<number, TSSymbol>();
566
+ const test = hits.includes(true);
567
+ if (test) {
568
+ tests.push(path);
569
+ }
570
+ const { uses, links, evaluations } = sitesOf(file, declarations, declaredAt(file) ?? "");
571
+
572
+ // One batch per capped run of the file's name nodes, the re-exports' nodes after
573
+ // the uses and the evaluated modules' specifiers last, each node once: a bare star
574
+ // re-export's specifier is a link and an evaluation both. A shorthand is left out
575
+ // of the batch: its name resolves to the property the literal declares, so the
576
+ // batch's answer for it would name something this project's inventory never holds.
577
+ const batching = [
578
+ ...new Set([
579
+ ...uses.filter((site) => site.shorthand === undefined).map((site) => site.node),
580
+ ...links.map((link) => link.node),
581
+ ...evaluations.map((evaluation) => evaluation.node),
582
+ ]),
583
+ ];
584
+ const answered = new Map<Node, TSSymbol>();
484
585
  batched += batching.length;
485
586
  for (let from = 0; from < batching.length; from += cap) {
486
587
  const run = batching.slice(from, from + cap);
487
- const answers = project.symbolsAt(run.map((site) => project.handle(site.node)));
588
+ const answers = project.symbolsAt(run.map((node) => project.handle(node)));
488
589
  fileBatches += 1;
489
- run.forEach((site, index) => {
590
+ run.forEach((node, index) => {
490
591
  const symbol = answers[index];
491
592
  if (symbol !== undefined) {
492
- answered.set(site.node.pos, symbol);
593
+ answered.set(node, symbol);
493
594
  }
494
595
  });
495
596
  }
496
597
 
497
- for (const site of sites) {
598
+ for (const link of links) {
599
+ const symbol = answered.get(link.node);
600
+ if (symbol === undefined) {
601
+ continue;
602
+ }
603
+ const position = renderPosition(file, targetRoot, link.at.getStart());
604
+ for (const id of carriedBy(link, symbol)) {
605
+ found.push({
606
+ from: link.from,
607
+ to: id,
608
+ position,
609
+ use: "read",
610
+ resolution: link.alias ? "alias" : "batch",
611
+ test,
612
+ });
613
+ }
614
+ }
615
+
616
+ for (const evaluation of evaluations) {
617
+ const module = answered.get(evaluation.node);
618
+ if (module === undefined) {
619
+ continue;
620
+ }
621
+ const position = renderPosition(file, targetRoot, evaluation.node.getStart());
622
+ for (const id of chains.declarationsOf(module)) {
623
+ if (fileIds.has(id)) {
624
+ found.push({
625
+ from: evaluation.from,
626
+ to: id,
627
+ position,
628
+ use: "evaluation",
629
+ resolution: "batch",
630
+ test,
631
+ });
632
+ }
633
+ }
634
+ }
635
+
636
+ for (const site of uses) {
498
637
  const direct: Resolution =
499
638
  site.shorthand !== undefined
500
639
  ? "shorthand"
501
- : answered.has(site.node.pos)
640
+ : answered.has(site.node)
502
641
  ? "batch"
503
642
  : "resolved-symbol";
504
643
  const symbol = symbolFor(site, answered);
@@ -506,7 +645,9 @@ export function references<Brand>(
506
645
  continue;
507
646
  }
508
647
  const position = renderPosition(file, targetRoot, site.node.getStart());
509
- for (const target of targetsOf(symbol)) {
648
+ // A re-export something imports is used and so is the declaration behind it, so
649
+ // the use names every link of the chain its name stands at the head of.
650
+ for (const target of chains.chainOf(symbol)) {
510
651
  found.push({
511
652
  from: site.from,
512
653
  to: target.id,
@@ -533,6 +674,7 @@ export function references<Brand>(
533
674
  testFileRules: options.testFiles
534
675
  .map((_pattern, index) => ({ rule: patternRule(index), matched: matched[index] ?? 0 }))
535
676
  .sort((a, b) => compare(a.rule, b.rule)),
536
- cost: { batched, fileBatches, residueFallbacks, shorthandLookups, aliasSteps },
677
+ testFilePaths: tests.sort(compare),
678
+ cost: { batched, fileBatches, residueFallbacks, shorthandLookups, aliasSteps: chains.steps },
537
679
  };
538
680
  }
package/src/resolve.ts CHANGED
@@ -38,6 +38,24 @@ const SEVERITY_KEY = /^DS[0-9]{2}([0-9]{2})?$/u;
38
38
  const EXEMPTION_CLASS = /^[a-z][a-z0-9-]*$/u;
39
39
  const CONTRACT_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/u;
40
40
 
41
+ /** The members only a platform build configuration carries. */
42
+ const PLATFORM_MEMBERS = ["os", "arch", "tags"] as const;
43
+
44
+ /** The member only a project build configuration carries. */
45
+ const PROJECT_MEMBER = "project";
46
+
47
+ /**
48
+ * One segment of a project path: not empty, not `.` or `..`, and holding no
49
+ * solidus, backslash or line break.
50
+ */
51
+ const PROJECT_SEGMENT = String.raw`(?:[^/\\.\r\n][^/\\\r\n]*|\.[^/\\.\r\n][^/\\\r\n]*|\.\.[^/\\\r\n]+)`;
52
+
53
+ /**
54
+ * A project path: segments joined by a solidus, so the path is relative, below
55
+ * the root it is read against, and spelled one way on every machine.
56
+ */
57
+ const PROJECT_PATH = new RegExp(`^${PROJECT_SEGMENT}(?:/${PROJECT_SEGMENT})*$`, "u");
58
+
41
59
  const TARGET_KINDS: readonly TargetKind[] = ["application", "library"];
42
60
  const CONFIDENCES: readonly Confidence[] = ["certain", "probable", "possible"];
43
61
  const GENERATED_FILES: readonly GeneratedFiles[] = ["exclude", "include"];
@@ -252,10 +270,75 @@ function requireMember(value: string | undefined, path: string, label: string):
252
270
  return value;
253
271
  }
254
272
 
273
+ /** One build configuration entry as a platform: an identifier, an operating system and an architecture. */
274
+ function readPlatform(
275
+ entry: Record<string, unknown>,
276
+ at: string,
277
+ label: string,
278
+ ): BuildConfiguration {
279
+ return {
280
+ shape: "platform",
281
+ id: requireMember(readString(entry, "id", `${at}.id`, label), `${at}.id`, label),
282
+ os: requireMember(readString(entry, "os", `${at}.os`, label), `${at}.os`, label),
283
+ arch: requireMember(readString(entry, "arch", `${at}.arch`, label), `${at}.arch`, label),
284
+ tags: readStrings(entry, "tags", `${at}.tags`, label, 0) ?? [],
285
+ };
286
+ }
287
+
288
+ /** One build configuration entry as a project: an identifier and a configuration file below the target root. */
289
+ function readProject(
290
+ entry: Record<string, unknown>,
291
+ at: string,
292
+ label: string,
293
+ ): BuildConfiguration {
294
+ const id = requireMember(readString(entry, "id", `${at}.id`, label), `${at}.id`, label);
295
+ const path = `${at}.${PROJECT_MEMBER}`;
296
+ const project = requireMember(readString(entry, PROJECT_MEMBER, path, label), path, label);
297
+ if (!PROJECT_PATH.test(project)) {
298
+ throw malformed(
299
+ label,
300
+ path,
301
+ `${JSON.stringify(project)} is not a path below the target root: segments joined by /, ` +
302
+ "none empty, . or .., and no backslash or line break",
303
+ );
304
+ }
305
+ return { shape: "project", id, project };
306
+ }
307
+
255
308
  /**
256
- * The build matrix: every entry names an identifier, an operating system and an
257
- * architecture.
309
+ * One build configuration entry, read as the one shape its members name: a member
310
+ * only a platform carries makes it a platform, the member only a project carries
311
+ * makes it a project, and an entry naming members of both, or of neither, is
312
+ * refused at the entry, because the two shapes are exclusive.
258
313
  */
314
+ function readConfiguration(entry: unknown, at: string, label: string): BuildConfiguration {
315
+ if (!isRecord(entry)) {
316
+ throw malformed(label, at, "is not an object");
317
+ }
318
+ const platform = PLATFORM_MEMBERS.filter((name) => Object.hasOwn(entry, name));
319
+ const project = Object.hasOwn(entry, PROJECT_MEMBER);
320
+ if (platform.length > 0 && project) {
321
+ throw malformed(
322
+ label,
323
+ at,
324
+ `carries ${spell(platform)} of a platform and ${JSON.stringify(PROJECT_MEMBER)} of a ` +
325
+ "project, and an entry is one shape or the other",
326
+ );
327
+ }
328
+ if (project) {
329
+ return readProject(entry, at, label);
330
+ }
331
+ if (platform.length === 0) {
332
+ throw malformed(
333
+ label,
334
+ at,
335
+ `names neither a platform's "os" and "arch" nor a project's ${JSON.stringify(PROJECT_MEMBER)}`,
336
+ );
337
+ }
338
+ return readPlatform(entry, at, label);
339
+ }
340
+
341
+ /** The build matrix, one entry in one shape at a time. */
259
342
  function readConfigurations(
260
343
  analysis: Record<string, unknown> | undefined,
261
344
  label: string,
@@ -267,18 +350,9 @@ function readConfigurations(
267
350
  if (!Array.isArray(value)) {
268
351
  throw malformed(label, "analysis.configurations", "is not an array");
269
352
  }
270
- return (value as unknown[]).map((entry, index) => {
271
- const at = `analysis.configurations[${String(index)}]`;
272
- if (!isRecord(entry)) {
273
- throw malformed(label, at, "is not an object");
274
- }
275
- return {
276
- id: requireMember(readString(entry, "id", `${at}.id`, label), `${at}.id`, label),
277
- os: requireMember(readString(entry, "os", `${at}.os`, label), `${at}.os`, label),
278
- arch: requireMember(readString(entry, "arch", `${at}.arch`, label), `${at}.arch`, label),
279
- tags: readStrings(entry, "tags", `${at}.tags`, label, 0) ?? [],
280
- };
281
- });
353
+ return (value as unknown[]).map((entry, index) =>
354
+ readConfiguration(entry, `analysis.configurations[${String(index)}]`, label),
355
+ );
282
356
  }
283
357
 
284
358
  /**
@@ -642,6 +716,35 @@ function checkLabels(provenance: ReadonlyMap<string, Origin>): void {
642
716
  * the usage exit code.
643
717
  */
644
718
  export function resolve(inputs: Inputs): { config: Config; provenance: Provenance } {
719
+ const { config, provenance } = settleAll(inputs);
720
+ if (config.targetKind === "") {
721
+ throw new ConfigError(
722
+ "missing-target-kind",
723
+ "target.kind",
724
+ "target.kind is not set, it has no default and is never inferred: searched " +
725
+ `${describeSource("the repository configuration", inputs.repository, inputs.repositoryLabel)} and ` +
726
+ describeSource("the central configuration", inputs.central, inputs.centralLabel),
727
+ );
728
+ }
729
+ checkLabels(provenance);
730
+ return { config, provenance };
731
+ }
732
+
733
+ /**
734
+ * The build matrix the configuration documents resolve to, every document read and
735
+ * refused as {@link resolve} refuses it, and nothing else required of them: which
736
+ * projects a run analyzes depends on the matrix alone, so a question about the
737
+ * projects asks no target kind.
738
+ */
739
+ export function resolveMatrix(inputs: Inputs): readonly BuildConfiguration[] {
740
+ return settleAll(inputs).config.analysis.configurations;
741
+ }
742
+
743
+ /**
744
+ * Every setting the documents supply, each from the highest-ranked source carrying
745
+ * it, with the origin of each. The target kind is empty where no source names one.
746
+ */
747
+ function settleAll(inputs: Inputs): { config: Config; provenance: Map<string, Origin> } {
645
748
  const sources = readSources(inputs);
646
749
  const defaults = defaultConfig();
647
750
  const provenance = new Map<string, Origin>();
@@ -718,15 +821,5 @@ export function resolve(inputs: Inputs): { config: Config; provenance: Provenanc
718
821
  },
719
822
  };
720
823
 
721
- if (config.targetKind === "") {
722
- throw new ConfigError(
723
- "missing-target-kind",
724
- "target.kind",
725
- "target.kind is not set, it has no default and is never inferred: searched " +
726
- `${describeSource("the repository configuration", inputs.repository, inputs.repositoryLabel)} and ` +
727
- describeSource("the central configuration", inputs.central, inputs.centralLabel),
728
- );
729
- }
730
- checkLabels(provenance);
731
824
  return { config, provenance };
732
825
  }