@cplieger/deadset-ts 3.3.1 → 3.5.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/resolve.ts CHANGED
@@ -6,19 +6,22 @@ import {
6
6
  type Config,
7
7
  type Confidence,
8
8
  type ConsumerTests,
9
+ type DeclarationEntry,
9
10
  type Format,
10
11
  type GeneratedFiles,
11
12
  type Inputs,
12
13
  type Language,
14
+ type LifecycleContract,
13
15
  type Origin,
14
16
  type Provenance,
17
+ type Provider,
15
18
  type Severity,
16
19
  type Sort,
17
20
  type Source,
18
21
  type TargetKind,
19
22
  type TemplateDelimiters,
20
23
  } from "./config.ts";
21
- import { FIXED_SEVERITY_CODES } from "./kinds.ts";
24
+ import { FIXED_SEVERITY_CODES, LIVE_CODES } from "./kinds.ts";
22
25
  import {
23
26
  checkDocument,
24
27
  malformed,
@@ -37,6 +40,13 @@ const FAMILY_KEY_LENGTH = 4;
37
40
  const SEVERITY_KEY = /^DS[0-9]{2}([0-9]{2})?$/u;
38
41
  const EXEMPTION_CLASS = /^[a-z][a-z0-9-]*$/u;
39
42
  const CONTRACT_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/u;
43
+ const ANALYZER_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/u;
44
+ const PROVIDER_SOURCE = /^(go|npm):[^ \t\r\n]+$/u;
45
+ const PROVIDER_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/u;
46
+ const PROVIDER_DIGEST = /^sha256:[0-9a-f]{64}$/u;
47
+
48
+ /** The members only an acquirable provider entry carries. */
49
+ const ACQUIRABLE_MEMBERS = ["source", "version", "digest"] as const;
40
50
 
41
51
  /** The members only a platform build configuration carries. */
42
52
  const PLATFORM_MEMBERS = ["os", "arch", "tags"] as const;
@@ -92,8 +102,12 @@ interface Doc {
92
102
  readonly cascade: Cascade | undefined;
93
103
  readonly maxFindings: number | undefined;
94
104
  readonly failOn: Severity | undefined;
105
+ readonly providers: readonly Provider[] | undefined;
95
106
  readonly testFiles: readonly string[] | undefined;
96
107
  readonly entryFiles: readonly string[] | undefined;
108
+ readonly injectionRegistrations: readonly DeclarationEntry[] | undefined;
109
+ readonly lifecycleContracts: readonly LifecycleContract[] | undefined;
110
+ readonly serializers: readonly DeclarationEntry[] | undefined;
97
111
  }
98
112
 
99
113
  /** One document the resolution reads, with the origin its settings carry. */
@@ -377,8 +391,8 @@ function readDelimiters(
377
391
  }
378
392
 
379
393
  /**
380
- * A severity key that names no setting, because the Contract fixes the severity of
381
- * the kind or kinds it names.
394
+ * A severity key that names no setting: it names no live kind, or the Contract
395
+ * fixes the severity of the kind or kinds it names.
382
396
  */
383
397
  function unimplementedSeverityKey(label: string, path: string, reason: string): ConfigError {
384
398
  return new ConfigError(
@@ -409,16 +423,23 @@ function spellCodes(codes: readonly string[]): string {
409
423
  return `${codes.slice(0, -1).join(", ")} and ${String(codes[codes.length - 1])}`;
410
424
  }
411
425
 
426
+ /** Whether one severity key names a live kind: the code itself, or a family holding one. */
427
+ function namesLiveKind(code: string): boolean {
428
+ return LIVE_CODES.some(
429
+ (candidate) =>
430
+ code === candidate || (code.length === FAMILY_KEY_LENGTH && candidate.startsWith(code)),
431
+ );
432
+ }
433
+
412
434
  /**
413
435
  * The severity object, one code at a time.
414
436
  *
415
- * A key is one issue-kind code or one two-digit family prefix, and a key naming a
416
- * kind whose severity the Contract fixes, or a family prefix whose range holds
417
- * one, is an unimplemented key rather than a setting.
437
+ * A key is one issue-kind code or one two-digit family prefix. A key naming no
438
+ * live kind, and a key naming a kind whose severity the Contract fixes or a family
439
+ * prefix whose range holds one, is an unimplemented key rather than a setting.
418
440
  *
419
- * Whether a key names a kind this analyzer reports is not decided here. This
420
- * analyzer reports no issue kind, so the question has one answer for every key
421
- * and asking it would refuse the whole object.
441
+ * Whether a key names a kind this analyzer reports is not decided here: a live
442
+ * kind of another language is a setting the analyzer of that language reads.
422
443
  */
423
444
  function readSeverity(
424
445
  document: Record<string, unknown> | undefined,
@@ -438,6 +459,15 @@ function readSeverity(
438
459
  "a severity key is one issue-kind code or one two-digit family prefix",
439
460
  );
440
461
  }
462
+ if (!namesLiveKind(code)) {
463
+ throw unimplementedSeverityKey(
464
+ label,
465
+ path,
466
+ code.length === FAMILY_KEY_LENGTH
467
+ ? "the family this prefix names holds no live issue kind"
468
+ : "the code names no live issue kind",
469
+ );
470
+ }
441
471
  const fixed = fixedByContract(code);
442
472
  if (fixed.length > 0) {
443
473
  throw unimplementedSeverityKey(
@@ -476,6 +506,221 @@ function readExemptions(
476
506
  return disabled;
477
507
  }
478
508
 
509
+ /** The members that name a declaration, each belonging to exactly one shape. */
510
+ const SHAPE_MEMBERS = ["symbol", "module", "name", "global"] as const;
511
+
512
+ /** The prefix of a stable symbol reference in the TypeScript form. */
513
+ const SYMBOL_PREFIX = "ts://";
514
+
515
+ /**
516
+ * A bare specifier: neither relative nor absolute, and not one the package's own
517
+ * imports field maps, each of which names a file of the analyzed program.
518
+ */
519
+ const BARE_SPECIFIER = /^[^./#]/u;
520
+
521
+ /**
522
+ * One declaration entry, read as the one shape its members name. An entry carrying
523
+ * members of two shapes, or of none, is refused at the entry, as is a module named
524
+ * without the declaration's path or a path without its module.
525
+ */
526
+ function readDeclaration(entry: unknown, at: string, label: string): DeclarationEntry {
527
+ if (!isRecord(entry)) {
528
+ throw malformed(label, at, "is not an object");
529
+ }
530
+ const named = SHAPE_MEMBERS.filter((name) => Object.hasOwn(entry, name));
531
+ if (named.length === 1 && named[0] === "symbol") {
532
+ const symbol = requireMember(
533
+ readString(entry, "symbol", `${at}.symbol`, label),
534
+ `${at}.symbol`,
535
+ label,
536
+ );
537
+ if (!symbol.startsWith(SYMBOL_PREFIX)) {
538
+ throw malformed(label, `${at}.symbol`, `${JSON.stringify(symbol)} is not a ts:// reference`);
539
+ }
540
+ return { shape: "symbol", symbol };
541
+ }
542
+ if (named.length === 1 && named[0] === "global") {
543
+ const path = `${at}.global`;
544
+ return {
545
+ shape: "global",
546
+ global: requireMember(readString(entry, "global", path, label), path, label),
547
+ };
548
+ }
549
+ if (named.length === 2 && named.includes("module") && named.includes("name")) {
550
+ const module = requireMember(
551
+ readString(entry, "module", `${at}.module`, label),
552
+ `${at}.module`,
553
+ label,
554
+ );
555
+ if (!BARE_SPECIFIER.test(module)) {
556
+ throw malformed(
557
+ label,
558
+ `${at}.module`,
559
+ `${JSON.stringify(module)} is not a bare specifier: a file of the analyzed program is named by its symbol`,
560
+ );
561
+ }
562
+ const path = `${at}.name`;
563
+ return {
564
+ shape: "module",
565
+ module,
566
+ name: requireMember(readString(entry, "name", path, label), path, label),
567
+ };
568
+ }
569
+ throw malformed(
570
+ label,
571
+ at,
572
+ named.length === 0
573
+ ? 'names no declaration: an entry carries "symbol", "module" with "name", or "global"'
574
+ : `carries ${spell(named)}, which is not one of "symbol", "module" with "name", or "global"`,
575
+ );
576
+ }
577
+
578
+ /** One array of declaration entries, absent where the document does not write it. */
579
+ function readDeclarations(
580
+ parent: Record<string, unknown> | undefined,
581
+ name: string,
582
+ path: string,
583
+ label: string,
584
+ ): readonly DeclarationEntry[] | undefined {
585
+ const value = member(parent, name);
586
+ if (value === undefined) {
587
+ return undefined;
588
+ }
589
+ if (!Array.isArray(value)) {
590
+ throw malformed(label, path, "is not an array");
591
+ }
592
+ return (value as unknown[]).map((entry, index) =>
593
+ readDeclaration(entry, `${path}[${String(index)}]`, label),
594
+ );
595
+ }
596
+
597
+ /**
598
+ * One lifecycle contract. The member names are required, and the entry names at least
599
+ * one component declaration or one base class, because a contract naming neither
600
+ * makes no class a component and so retains nothing.
601
+ */
602
+ function readLifecycleContract(entry: unknown, at: string, label: string): LifecycleContract {
603
+ if (!isRecord(entry)) {
604
+ throw malformed(label, at, "is not an object");
605
+ }
606
+ const components = readDeclarations(entry, "components", `${at}.components`, label) ?? [];
607
+ const bases = readDeclarations(entry, "bases", `${at}.bases`, label) ?? [];
608
+ const members = readStrings(entry, "members", `${at}.members`, label, 1);
609
+ if (members === undefined) {
610
+ throw malformed(label, `${at}.members`, "is required and names nothing");
611
+ }
612
+ if (components.length === 0 && bases.length === 0) {
613
+ throw malformed(
614
+ label,
615
+ at,
616
+ 'names no declaration in "components" and no class in "bases", so it makes no class a component',
617
+ );
618
+ }
619
+ return { components, bases, members };
620
+ }
621
+
622
+ /** The lifecycle contracts, one per framework the project declares. */
623
+ function readLifecycleContracts(
624
+ ts: Record<string, unknown> | undefined,
625
+ label: string,
626
+ ): readonly LifecycleContract[] | undefined {
627
+ const path = "ts.lifecycle_contracts";
628
+ const value = member(ts, "lifecycle_contracts");
629
+ if (value === undefined) {
630
+ return undefined;
631
+ }
632
+ if (!Array.isArray(value)) {
633
+ throw malformed(label, path, "is not an array");
634
+ }
635
+ return (value as unknown[]).map((entry, index) =>
636
+ readLifecycleContract(entry, `${path}[${String(index)}]`, label),
637
+ );
638
+ }
639
+
640
+ /** One string member a provider entry requires, refused where it fails `pattern`. */
641
+ function readMatching(
642
+ entry: Record<string, unknown>,
643
+ name: string,
644
+ at: string,
645
+ label: string,
646
+ pattern: RegExp,
647
+ ): string {
648
+ const path = `${at}.${name}`;
649
+ const value = requireMember(readString(entry, name, path, label), path, label);
650
+ if (!pattern.test(value)) {
651
+ throw malformed(label, path, `${JSON.stringify(value)} does not match ${pattern.source}`);
652
+ }
653
+ return value;
654
+ }
655
+
656
+ /**
657
+ * One provider entry, read as the one shape its members name: a member only an
658
+ * acquirable analyzer carries makes it acquirable, and then it carries all three.
659
+ */
660
+ function readProvider(entry: unknown, at: string, label: string): Provider {
661
+ if (!isRecord(entry)) {
662
+ throw malformed(label, at, "is not an object");
663
+ }
664
+ const name = readMatching(entry, "name", at, label, ANALYZER_NAME);
665
+ const languages = readStrings(entry, "languages", `${at}.languages`, label, 1, LANGUAGES);
666
+ if (languages === undefined) {
667
+ throw malformed(label, `${at}.languages`, "is required and names nothing");
668
+ }
669
+ const commandPath = `${at}.command`;
670
+ const command = requireMember(
671
+ readString(entry, "command", commandPath, label),
672
+ commandPath,
673
+ label,
674
+ );
675
+ if (!ACQUIRABLE_MEMBERS.some((member) => Object.hasOwn(entry, member))) {
676
+ return { shape: "installed", name, languages, command };
677
+ }
678
+ return {
679
+ shape: "acquirable",
680
+ name,
681
+ languages,
682
+ command,
683
+ source: readMatching(entry, "source", at, label, PROVIDER_SOURCE),
684
+ version: readMatching(entry, "version", at, label, PROVIDER_VERSION),
685
+ digest: readMatching(entry, "digest", at, label, PROVIDER_DIGEST),
686
+ };
687
+ }
688
+
689
+ /**
690
+ * The provider list. Every file a run writes for an analyzer is keyed by the
691
+ * entry's name, so a list naming one name twice is refused at the later entry.
692
+ */
693
+ function readProviders(
694
+ providers: Record<string, unknown> | undefined,
695
+ label: string,
696
+ ): readonly Provider[] | undefined {
697
+ const path = "providers.analyzers";
698
+ const value = member(providers, "analyzers");
699
+ if (value === undefined) {
700
+ return undefined;
701
+ }
702
+ if (!Array.isArray(value)) {
703
+ throw malformed(label, path, "is not an array");
704
+ }
705
+ const read = (value as unknown[]).map((entry, index) =>
706
+ readProvider(entry, `${path}[${String(index)}]`, label),
707
+ );
708
+ const first = new Map<string, number>();
709
+ read.forEach((entry, index) => {
710
+ const earlier = first.get(entry.name);
711
+ if (earlier !== undefined) {
712
+ throw malformed(
713
+ label,
714
+ `${path}[${String(index)}].name`,
715
+ `${JSON.stringify(entry.name)} is already the name of ${path}[${String(earlier)}], ` +
716
+ "and every entry's name keys the files a run writes for it",
717
+ );
718
+ }
719
+ first.set(entry.name, index);
720
+ });
721
+ return read;
722
+ }
723
+
479
724
  /**
480
725
  * Reads one already-parsed configuration document as the settings it supplies,
481
726
  * refusing every constraint the closed key list declares that a parse cannot
@@ -492,6 +737,7 @@ function readDoc(value: unknown, label: string): Doc {
492
737
  const consumers = section(value, "consumers", "consumers", label);
493
738
  const roots = section(value, "roots", "roots", label);
494
739
  const reporters = section(value, "reporters", "reporters", label);
740
+ const providers = section(value, "providers", "providers", label);
495
741
  const ts = section(value, "ts", "ts", label);
496
742
  section(value, "go", "go", label);
497
743
  section(value, "provenance", "provenance", label);
@@ -543,8 +789,17 @@ function readDoc(value: unknown, label: string): Doc {
543
789
  cascade: readEnum(reporters, "cascade", "reporters.cascade", label, CASCADES),
544
790
  maxFindings: readCount(reporters, "max_findings", "reporters.max_findings", label),
545
791
  failOn: readEnum(reporters, "fail_on", "reporters.fail_on", label, SEVERITIES),
792
+ providers: readProviders(providers, label),
546
793
  testFiles: readStrings(ts, "test_files", "ts.test_files", label, 1),
547
794
  entryFiles: readStrings(ts, "entry_files", "ts.entry_files", label, 0),
795
+ injectionRegistrations: readDeclarations(
796
+ ts,
797
+ "injection_registrations",
798
+ "ts.injection_registrations",
799
+ label,
800
+ ),
801
+ lifecycleContracts: readLifecycleContracts(ts, label),
802
+ serializers: readDeclarations(ts, "serializers", "ts.serializers", label),
548
803
  };
549
804
  }
550
805
 
@@ -815,9 +1070,23 @@ function settleAll(inputs: Inputs): { config: Config; provenance: Map<string, Or
815
1070
  ),
816
1071
  failOn: at("reporters.fail_on", defaults.reporters.failOn, (doc) => doc.failOn),
817
1072
  },
1073
+ providers: {
1074
+ analyzers: at("providers.analyzers", defaults.providers.analyzers, (doc) => doc.providers),
1075
+ },
818
1076
  ts: {
819
1077
  testFiles: at("ts.test_files", defaults.ts.testFiles, (doc) => doc.testFiles),
820
1078
  entryFiles: at("ts.entry_files", defaults.ts.entryFiles, (doc) => doc.entryFiles),
1079
+ injectionRegistrations: at(
1080
+ "ts.injection_registrations",
1081
+ defaults.ts.injectionRegistrations,
1082
+ (doc) => doc.injectionRegistrations,
1083
+ ),
1084
+ lifecycleContracts: at(
1085
+ "ts.lifecycle_contracts",
1086
+ defaults.ts.lifecycleContracts,
1087
+ (doc) => doc.lifecycleContracts,
1088
+ ),
1089
+ serializers: at("ts.serializers", defaults.ts.serializers, (doc) => doc.serializers),
821
1090
  },
822
1091
  };
823
1092
 
package/src/run.ts CHANGED
@@ -59,8 +59,15 @@ export const SETTING_OPTIONS: ReadonlyMap<string, string> = new Map([
59
59
  /** The options that take a value and are not settings. */
60
60
  const PLAIN_OPTIONS: ReadonlySet<string> = new Set(["target", "config", "central", "scope"]);
61
61
 
62
- /** The setting options whose value is a number rather than a string. */
63
- const NUMBER_OPTIONS: ReadonlySet<string> = new Set(["max-findings"]);
62
+ /**
63
+ * The setting options whose value is an integer rather than a string. A value
64
+ * written other than as digits is passed on as the string it is, so resolution
65
+ * refuses it by its type rather than reading `1e2` as 100.
66
+ */
67
+ const INTEGER_OPTIONS: ReadonlySet<string> = new Set(["max-findings"]);
68
+
69
+ /** An integer as an option writes it: an optional minus sign and digits alone. */
70
+ const INTEGER_TEXT = /^-?[0-9]+$/u;
64
71
 
65
72
  /** One invocation's options, by name, with the verb's remaining arguments. */
66
73
  interface Options {
@@ -145,7 +152,7 @@ function inputsOf(host: Host, options: Options): Inputs {
145
152
  if (value === undefined) {
146
153
  continue;
147
154
  }
148
- flat[path] = NUMBER_OPTIONS.has(name) ? Number(value) : value;
155
+ flat[path] = INTEGER_OPTIONS.has(name) && INTEGER_TEXT.test(value) ? Number(value) : value;
149
156
  flagLabels.set(path, `--${name}`);
150
157
  }
151
158
  const flags = Object.keys(flat).length === 0 ? undefined : JSON.stringify(flat);
package/src/schema.ts CHANGED
@@ -19,14 +19,23 @@ export type KeyKind = "leaf" | "section" | "map" | "list" | "object";
19
19
 
20
20
  /**
21
21
  * One node of the closed key list. `members` holds a section's declared members,
22
- * or a list entry's, and is absent otherwise.
22
+ * or a list entry's, and is absent otherwise. `integer` marks a leaf of the
23
+ * integer type, whose number the document writes as digits alone.
23
24
  */
24
25
  export interface KeyNode {
25
26
  readonly kind: KeyKind;
26
27
  readonly members?: Readonly<Record<string, KeyNode>>;
28
+ readonly integer?: true;
27
29
  }
28
30
 
29
31
  const leaf: KeyNode = { kind: "leaf" };
32
+ const integer: KeyNode = { kind: "leaf", integer: true };
33
+
34
+ /** A list of declarations, each entry in one of the three shapes the `ts` section states. */
35
+ const declarations: KeyNode = {
36
+ kind: "list",
37
+ members: { symbol: leaf, module: leaf, name: leaf, global: leaf },
38
+ };
30
39
 
31
40
  /**
32
41
  * The closed key list the Contract's configuration schema declares, derived from
@@ -67,12 +76,40 @@ export const SCHEMA_ROOT: KeyNode = {
67
76
  formats: leaf,
68
77
  sort: leaf,
69
78
  cascade: leaf,
70
- max_findings: leaf,
79
+ max_findings: integer,
71
80
  fail_on: leaf,
72
81
  },
73
82
  },
83
+ providers: {
84
+ kind: "section",
85
+ members: {
86
+ analyzers: {
87
+ kind: "list",
88
+ members: {
89
+ name: leaf,
90
+ languages: leaf,
91
+ command: leaf,
92
+ source: leaf,
93
+ version: leaf,
94
+ digest: leaf,
95
+ },
96
+ },
97
+ },
98
+ },
74
99
  go: { kind: "section", members: {} },
75
- ts: { kind: "section", members: { test_files: leaf, entry_files: leaf } },
100
+ ts: {
101
+ kind: "section",
102
+ members: {
103
+ test_files: leaf,
104
+ entry_files: leaf,
105
+ injection_registrations: declarations,
106
+ lifecycle_contracts: {
107
+ kind: "list",
108
+ members: { components: declarations, bases: declarations, members: leaf },
109
+ },
110
+ serializers: declarations,
111
+ },
112
+ },
76
113
  provenance: { kind: "map" },
77
114
  },
78
115
  };
@@ -287,6 +324,28 @@ function skipScalar(cursor: Cursor): void {
287
324
  }
288
325
  }
289
326
 
327
+ /** A number the document writes as an optional minus sign and digits alone. */
328
+ const INTEGER_TEXT = /^-?[0-9]+$/u;
329
+
330
+ /** The start of a JSON number: a minus sign or a digit. */
331
+ const NUMBER_START = /^[-0-9]/u;
332
+
333
+ /**
334
+ * Refuses a number written with a fraction or an exponent where the key list
335
+ * declares an integer. A parse reads `100.0` and `1e2` as the integer 100, so only
336
+ * the text can tell them apart. A scalar that is no number is the decoder's to
337
+ * refuse by its type.
338
+ */
339
+ function checkInteger(text: string, at: string, label: string): void {
340
+ if (NUMBER_START.test(text) && !INTEGER_TEXT.test(text)) {
341
+ throw malformed(
342
+ label,
343
+ at,
344
+ `${text} is written with a fraction or an exponent, and an integer is digits alone`,
345
+ );
346
+ }
347
+ }
348
+
290
349
  function walkValue(cursor: Cursor, at: string, node: KeyNode, label: string): void {
291
350
  skipSpace(cursor);
292
351
  const ch = cursor.text.charCodeAt(cursor.at);
@@ -304,7 +363,11 @@ function walkValue(cursor: Cursor, at: string, node: KeyNode, label: string): vo
304
363
  readName(cursor, at, label);
305
364
  return;
306
365
  }
366
+ const start = cursor.at;
307
367
  skipScalar(cursor);
368
+ if (node.integer === true) {
369
+ checkInteger(cursor.text.slice(start, cursor.at), at, label);
370
+ }
308
371
  }
309
372
 
310
373
  function walkObject(cursor: Cursor, at: string, node: KeyNode, label: string): void {
package/src/scope.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { ConfigError } from "./config.ts";
1
2
  import type { Host } from "./host.ts";
2
3
  import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.ts";
4
+ import { checkDocument, type KeyNode } from "./schema.ts";
3
5
 
4
6
  /** The roles a scope document declares for a module. */
5
7
  export const ROLE_TARGET = "target";
@@ -43,20 +45,44 @@ export interface Scope {
43
45
  readonly consumers: readonly Module[];
44
46
  }
45
47
 
46
- const DECLARED_KEYS = new Set(["target", "workspace", "consumers"]);
47
- const MODULE_KEYS = new Set(["id", "role", "path"]);
48
+ const MODULE: KeyNode = {
49
+ kind: "section",
50
+ members: { id: { kind: "leaf" }, role: { kind: "leaf" }, path: { kind: "leaf" } },
51
+ };
52
+
53
+ /** The closed key list of the scope document, compared as bytes. */
54
+ const SCOPE_ROOT: KeyNode = {
55
+ kind: "section",
56
+ members: {
57
+ target: MODULE,
58
+ workspace: { kind: "leaf" },
59
+ consumers: { kind: "list", members: MODULE.members ?? {} },
60
+ },
61
+ };
48
62
 
49
63
  function isRecord(value: unknown): value is Record<string, unknown> {
50
64
  return typeof value === "object" && value !== null && !Array.isArray(value);
51
65
  }
52
66
 
53
- function stringMember(parent: Record<string, unknown>, name: string, at: string): string {
67
+ /**
68
+ * One string member, or undefined where the document omits it. A member the
69
+ * document writes is a string holding at least one character, so `null` and `""`
70
+ * are refused rather than read as absent.
71
+ */
72
+ function stringMember(
73
+ parent: Record<string, unknown>,
74
+ name: string,
75
+ at: string,
76
+ ): string | undefined {
54
77
  if (!Object.hasOwn(parent, name)) {
55
- return "";
78
+ return undefined;
56
79
  }
57
80
  const value = parent[name];
58
81
  if (typeof value !== "string") {
59
- throw new ScopeError(`scope: ${at}.${name} is not a string`);
82
+ throw new ScopeError(`scope: ${at === "" ? name : `${at}.${name}`} is not a string`);
83
+ }
84
+ if (value === "") {
85
+ throw new ScopeError(`scope: ${at === "" ? name : `${at}.${name}`} names nothing`);
60
86
  }
61
87
  return value;
62
88
  }
@@ -65,33 +91,51 @@ function readModule(value: unknown, at: string, role: string, documentDir: strin
65
91
  if (!isRecord(value)) {
66
92
  throw new ScopeError(`scope: ${at} is not an object`);
67
93
  }
68
- for (const name of Object.keys(value)) {
69
- if (!MODULE_KEYS.has(name)) {
70
- throw new ScopeError(`scope: ${at}.${name} is not a declared key`);
71
- }
72
- }
73
94
  const declared = stringMember(value, "role", at);
74
- if (declared !== "" && declared !== role) {
95
+ if (declared !== undefined && declared !== role) {
75
96
  throw new ScopeError(
76
97
  `scope: ${at}.role is ${JSON.stringify(declared)}, want ${JSON.stringify(role)}`,
77
98
  );
78
99
  }
79
100
  const path = stringMember(value, "path", at);
80
- if (path === "") {
101
+ if (path === undefined) {
81
102
  throw new ScopeError(`scope: ${at}.path names nothing`);
82
103
  }
83
104
  return {
84
- id: stringMember(value, "id", at),
105
+ id: stringMember(value, "id", at) ?? "",
85
106
  path: isAbsolutePath(path) ? path : joinPath(documentDir, path),
86
107
  };
87
108
  }
88
109
 
89
110
  /**
90
- * Decodes the scope document at `file`. Decoding is strict: an undeclared key is an
91
- * error, and a relative path inside the document resolves against the document's
92
- * own directory.
111
+ * Walks the document's text against the closed key list, refusing a key the list
112
+ * does not declare, compared as bytes, and a key one object writes twice, which a
113
+ * parse cannot report because it keeps the last value.
114
+ */
115
+ function checkKeys(text: string, file: string): void {
116
+ try {
117
+ checkDocument(text, SCOPE_ROOT, `scope: ${file}`);
118
+ } catch (error: unknown) {
119
+ if (error instanceof ConfigError) {
120
+ // The configuration's refusal names the nearest configuration key, which a
121
+ // scope document does not have.
122
+ throw new ScopeError(
123
+ error.kind === "unimplemented-key"
124
+ ? `scope: ${file}: ${error.key} is not a declared key`
125
+ : error.message,
126
+ );
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Decodes the scope document at `file`. Decoding is strict: an undeclared key, a
134
+ * key written twice in one object, and a value of a type the document does not
135
+ * admit, `null` included, are errors, and a relative path inside the document
136
+ * resolves against the document's own directory.
93
137
  *
94
- * The `workspace` key the format declares is read and carries nothing here: it
138
+ * The `workspace` key the format declares is checked and carries nothing here: it
95
139
  * names the file a Go consumer resolves the target through, and a TypeScript
96
140
  * project resolves one through its own compiler configuration.
97
141
  */
@@ -119,11 +163,8 @@ export function readScope(host: Host, file: string): Scope {
119
163
  if (!isRecord(value)) {
120
164
  throw new ScopeError(`scope: ${file} is not one JSON object`);
121
165
  }
122
- for (const name of Object.keys(value)) {
123
- if (!DECLARED_KEYS.has(name)) {
124
- throw new ScopeError(`scope: ${file}: ${name} is not a declared key`);
125
- }
126
- }
166
+ checkKeys(text, file);
167
+ stringMember(value, "workspace", "");
127
168
  const documentDir = dirnamePath(absolute);
128
169
  if (!Object.hasOwn(value, "target")) {
129
170
  throw new ScopeError(`scope: ${file} names no target`);