@cplieger/deadset-ts 3.4.0 → 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/README.md +1 -1
- package/dist/src/components.js +61 -106
- package/dist/src/config.js +6 -0
- package/dist/src/kinds.js +40 -0
- package/dist/src/print.js +10 -1
- package/dist/src/resolve.js +90 -9
- package/dist/src/run.js +9 -3
- package/dist/src/schema.js +37 -1
- package/dist/src/scope.js +55 -21
- package/dist/src/serialization-contract.js +109 -19
- package/dist/src/version.js +1 -1
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/components.ts +67 -130
- package/src/config.ts +37 -2
- package/src/kinds.ts +41 -0
- package/src/print.ts +17 -1
- package/src/resolve.ts +123 -9
- package/src/run.ts +10 -3
- package/src/schema.ts +47 -2
- package/src/scope.ts +63 -22
- package/src/serialization-contract.ts +138 -20
- package/src/version.ts +1 -1
package/src/config.ts
CHANGED
|
@@ -122,6 +122,34 @@ export interface LifecycleContract {
|
|
|
122
122
|
readonly members: readonly string[];
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* One analyzer the orchestrator may run, in exactly one of two shapes: an installed
|
|
127
|
+
* analyzer, named by its command alone, or an acquirable one, which also names the
|
|
128
|
+
* artifact acquisition fetches and verifies. This analyzer validates and prints the
|
|
129
|
+
* list and runs nothing from it.
|
|
130
|
+
*/
|
|
131
|
+
export type Provider =
|
|
132
|
+
| {
|
|
133
|
+
readonly shape: "installed";
|
|
134
|
+
readonly name: string;
|
|
135
|
+
readonly languages: readonly Language[];
|
|
136
|
+
readonly command: string;
|
|
137
|
+
}
|
|
138
|
+
| {
|
|
139
|
+
readonly shape: "acquirable";
|
|
140
|
+
readonly name: string;
|
|
141
|
+
readonly languages: readonly Language[];
|
|
142
|
+
readonly command: string;
|
|
143
|
+
readonly source: string;
|
|
144
|
+
readonly version: string;
|
|
145
|
+
readonly digest: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** The analyzers the orchestrator runs. */
|
|
149
|
+
export interface Providers {
|
|
150
|
+
readonly analyzers: readonly Provider[];
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
/** The section the TypeScript analyzer owns. */
|
|
126
154
|
export interface TSSection {
|
|
127
155
|
readonly testFiles: readonly string[];
|
|
@@ -152,6 +180,7 @@ export interface Config {
|
|
|
152
180
|
readonly severity: ReadonlyMap<string, Severity>;
|
|
153
181
|
readonly exemptionsDisabled: readonly string[];
|
|
154
182
|
readonly reporters: Reporters;
|
|
183
|
+
readonly providers: Providers;
|
|
155
184
|
readonly ts: TSSection;
|
|
156
185
|
}
|
|
157
186
|
|
|
@@ -184,6 +213,12 @@ export function defaultConfig(): Config {
|
|
|
184
213
|
maxFindings: 0,
|
|
185
214
|
failOn: "deny",
|
|
186
215
|
},
|
|
216
|
+
providers: {
|
|
217
|
+
analyzers: [
|
|
218
|
+
{ shape: "installed", name: "deadset-go", languages: ["go"], command: "deadset-go" },
|
|
219
|
+
{ shape: "installed", name: "deadset-ts", languages: ["ts"], command: "deadset-ts" },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
187
222
|
ts: {
|
|
188
223
|
testFiles: ["**/*.test.{ts,tsx,mts,cts}"],
|
|
189
224
|
entryFiles: [],
|
|
@@ -246,8 +281,8 @@ export type ErrorKind =
|
|
|
246
281
|
*/
|
|
247
282
|
| "malformed"
|
|
248
283
|
/**
|
|
249
|
-
* A key the closed key list does not declare, or a severity key naming
|
|
250
|
-
* whose severity the Contract fixes.
|
|
284
|
+
* A key the closed key list does not declare, or a severity key naming no live
|
|
285
|
+
* kind or a kind whose severity the Contract fixes.
|
|
251
286
|
*/
|
|
252
287
|
| "unimplemented-key"
|
|
253
288
|
/** A resolved configuration no source supplied a target kind for. */
|
package/src/kinds.ts
CHANGED
|
@@ -10,3 +10,44 @@
|
|
|
10
10
|
* code the Contract fixes arrives here as a failing test.
|
|
11
11
|
*/
|
|
12
12
|
export const FIXED_SEVERITY_CODES: readonly string[] = ["DS1703", "DS1704"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The code of every live issue kind, in ascending order. A severity key names one of
|
|
16
|
+
* them or a family prefix whose range holds one; a key naming a retired code, an
|
|
17
|
+
* unassigned code or a range holding no live kind is an unimplemented key, so every
|
|
18
|
+
* severity a configuration sets is one some analyzer reports under. A test compares
|
|
19
|
+
* this list against the Contract release the analyzer is written against.
|
|
20
|
+
*/
|
|
21
|
+
export const LIVE_CODES: readonly string[] = [
|
|
22
|
+
"DS1001",
|
|
23
|
+
"DS1002",
|
|
24
|
+
"DS1003",
|
|
25
|
+
"DS1004",
|
|
26
|
+
"DS1005",
|
|
27
|
+
"DS1006",
|
|
28
|
+
"DS1101",
|
|
29
|
+
"DS1102",
|
|
30
|
+
"DS1103",
|
|
31
|
+
"DS1104",
|
|
32
|
+
"DS1201",
|
|
33
|
+
"DS1203",
|
|
34
|
+
"DS1204",
|
|
35
|
+
"DS1301",
|
|
36
|
+
"DS1302",
|
|
37
|
+
"DS1303",
|
|
38
|
+
"DS1501",
|
|
39
|
+
"DS1502",
|
|
40
|
+
"DS1601",
|
|
41
|
+
"DS1605",
|
|
42
|
+
"DS1701",
|
|
43
|
+
"DS1702",
|
|
44
|
+
"DS1703",
|
|
45
|
+
"DS1704",
|
|
46
|
+
"DS1705",
|
|
47
|
+
"DS1801",
|
|
48
|
+
"DS1802",
|
|
49
|
+
"DS1803",
|
|
50
|
+
"DS1805",
|
|
51
|
+
"DS1807",
|
|
52
|
+
"DS1809",
|
|
53
|
+
];
|
package/src/print.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
renderOrigin,
|
|
3
|
+
type Config,
|
|
4
|
+
type DeclarationEntry,
|
|
5
|
+
type Provenance,
|
|
6
|
+
type Provider,
|
|
7
|
+
} from "./config.ts";
|
|
2
8
|
|
|
3
9
|
/** One declaration entry as a document writes it: the members of its shape alone. */
|
|
4
10
|
function declarationOf(entry: DeclarationEntry): Record<string, string> {
|
|
@@ -12,6 +18,15 @@ function declarationOf(entry: DeclarationEntry): Record<string, string> {
|
|
|
12
18
|
}
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
/** One provider entry as a document writes it: the members of its shape alone. */
|
|
22
|
+
function providerOf(entry: Provider): Record<string, unknown> {
|
|
23
|
+
const installed = { name: entry.name, languages: entry.languages, command: entry.command };
|
|
24
|
+
if (entry.shape === "installed") {
|
|
25
|
+
return installed;
|
|
26
|
+
}
|
|
27
|
+
return { ...installed, source: entry.source, version: entry.version, digest: entry.digest };
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/**
|
|
16
31
|
* The resolved configuration and its provenance as one JSON object, indented,
|
|
17
32
|
* ending in a newline.
|
|
@@ -55,6 +70,7 @@ export function printConfig(config: Config, provenance: Provenance): string {
|
|
|
55
70
|
max_findings: config.reporters.maxFindings,
|
|
56
71
|
fail_on: config.reporters.failOn,
|
|
57
72
|
},
|
|
73
|
+
providers: { analyzers: config.providers.analyzers.map(providerOf) },
|
|
58
74
|
go: {},
|
|
59
75
|
ts: {
|
|
60
76
|
test_files: config.ts.testFiles,
|
package/src/resolve.ts
CHANGED
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
type LifecycleContract,
|
|
15
15
|
type Origin,
|
|
16
16
|
type Provenance,
|
|
17
|
+
type Provider,
|
|
17
18
|
type Severity,
|
|
18
19
|
type Sort,
|
|
19
20
|
type Source,
|
|
20
21
|
type TargetKind,
|
|
21
22
|
type TemplateDelimiters,
|
|
22
23
|
} from "./config.ts";
|
|
23
|
-
import { FIXED_SEVERITY_CODES } from "./kinds.ts";
|
|
24
|
+
import { FIXED_SEVERITY_CODES, LIVE_CODES } from "./kinds.ts";
|
|
24
25
|
import {
|
|
25
26
|
checkDocument,
|
|
26
27
|
malformed,
|
|
@@ -39,6 +40,13 @@ const FAMILY_KEY_LENGTH = 4;
|
|
|
39
40
|
const SEVERITY_KEY = /^DS[0-9]{2}([0-9]{2})?$/u;
|
|
40
41
|
const EXEMPTION_CLASS = /^[a-z][a-z0-9-]*$/u;
|
|
41
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;
|
|
42
50
|
|
|
43
51
|
/** The members only a platform build configuration carries. */
|
|
44
52
|
const PLATFORM_MEMBERS = ["os", "arch", "tags"] as const;
|
|
@@ -94,6 +102,7 @@ interface Doc {
|
|
|
94
102
|
readonly cascade: Cascade | undefined;
|
|
95
103
|
readonly maxFindings: number | undefined;
|
|
96
104
|
readonly failOn: Severity | undefined;
|
|
105
|
+
readonly providers: readonly Provider[] | undefined;
|
|
97
106
|
readonly testFiles: readonly string[] | undefined;
|
|
98
107
|
readonly entryFiles: readonly string[] | undefined;
|
|
99
108
|
readonly injectionRegistrations: readonly DeclarationEntry[] | undefined;
|
|
@@ -382,8 +391,8 @@ function readDelimiters(
|
|
|
382
391
|
}
|
|
383
392
|
|
|
384
393
|
/**
|
|
385
|
-
* A severity key that names no setting
|
|
386
|
-
* 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.
|
|
387
396
|
*/
|
|
388
397
|
function unimplementedSeverityKey(label: string, path: string, reason: string): ConfigError {
|
|
389
398
|
return new ConfigError(
|
|
@@ -414,16 +423,23 @@ function spellCodes(codes: readonly string[]): string {
|
|
|
414
423
|
return `${codes.slice(0, -1).join(", ")} and ${String(codes[codes.length - 1])}`;
|
|
415
424
|
}
|
|
416
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
|
+
|
|
417
434
|
/**
|
|
418
435
|
* The severity object, one code at a time.
|
|
419
436
|
*
|
|
420
|
-
* A key is one issue-kind code or one two-digit family prefix
|
|
421
|
-
* kind whose severity the Contract fixes
|
|
422
|
-
* 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.
|
|
423
440
|
*
|
|
424
|
-
* Whether a key names a kind this analyzer reports is not decided here
|
|
425
|
-
*
|
|
426
|
-
* 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.
|
|
427
443
|
*/
|
|
428
444
|
function readSeverity(
|
|
429
445
|
document: Record<string, unknown> | undefined,
|
|
@@ -443,6 +459,15 @@ function readSeverity(
|
|
|
443
459
|
"a severity key is one issue-kind code or one two-digit family prefix",
|
|
444
460
|
);
|
|
445
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
|
+
}
|
|
446
471
|
const fixed = fixedByContract(code);
|
|
447
472
|
if (fixed.length > 0) {
|
|
448
473
|
throw unimplementedSeverityKey(
|
|
@@ -612,6 +637,90 @@ function readLifecycleContracts(
|
|
|
612
637
|
);
|
|
613
638
|
}
|
|
614
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
|
+
|
|
615
724
|
/**
|
|
616
725
|
* Reads one already-parsed configuration document as the settings it supplies,
|
|
617
726
|
* refusing every constraint the closed key list declares that a parse cannot
|
|
@@ -628,6 +737,7 @@ function readDoc(value: unknown, label: string): Doc {
|
|
|
628
737
|
const consumers = section(value, "consumers", "consumers", label);
|
|
629
738
|
const roots = section(value, "roots", "roots", label);
|
|
630
739
|
const reporters = section(value, "reporters", "reporters", label);
|
|
740
|
+
const providers = section(value, "providers", "providers", label);
|
|
631
741
|
const ts = section(value, "ts", "ts", label);
|
|
632
742
|
section(value, "go", "go", label);
|
|
633
743
|
section(value, "provenance", "provenance", label);
|
|
@@ -679,6 +789,7 @@ function readDoc(value: unknown, label: string): Doc {
|
|
|
679
789
|
cascade: readEnum(reporters, "cascade", "reporters.cascade", label, CASCADES),
|
|
680
790
|
maxFindings: readCount(reporters, "max_findings", "reporters.max_findings", label),
|
|
681
791
|
failOn: readEnum(reporters, "fail_on", "reporters.fail_on", label, SEVERITIES),
|
|
792
|
+
providers: readProviders(providers, label),
|
|
682
793
|
testFiles: readStrings(ts, "test_files", "ts.test_files", label, 1),
|
|
683
794
|
entryFiles: readStrings(ts, "entry_files", "ts.entry_files", label, 0),
|
|
684
795
|
injectionRegistrations: readDeclarations(
|
|
@@ -959,6 +1070,9 @@ function settleAll(inputs: Inputs): { config: Config; provenance: Map<string, Or
|
|
|
959
1070
|
),
|
|
960
1071
|
failOn: at("reporters.fail_on", defaults.reporters.failOn, (doc) => doc.failOn),
|
|
961
1072
|
},
|
|
1073
|
+
providers: {
|
|
1074
|
+
analyzers: at("providers.analyzers", defaults.providers.analyzers, (doc) => doc.providers),
|
|
1075
|
+
},
|
|
962
1076
|
ts: {
|
|
963
1077
|
testFiles: at("ts.test_files", defaults.ts.testFiles, (doc) => doc.testFiles),
|
|
964
1078
|
entryFiles: at("ts.entry_files", defaults.ts.entryFiles, (doc) => doc.entryFiles),
|
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
|
-
/**
|
|
63
|
-
|
|
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] =
|
|
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,17 @@ 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 };
|
|
30
33
|
|
|
31
34
|
/** A list of declarations, each entry in one of the three shapes the `ts` section states. */
|
|
32
35
|
const declarations: KeyNode = {
|
|
@@ -73,10 +76,26 @@ export const SCHEMA_ROOT: KeyNode = {
|
|
|
73
76
|
formats: leaf,
|
|
74
77
|
sort: leaf,
|
|
75
78
|
cascade: leaf,
|
|
76
|
-
max_findings:
|
|
79
|
+
max_findings: integer,
|
|
77
80
|
fail_on: leaf,
|
|
78
81
|
},
|
|
79
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
|
+
},
|
|
80
99
|
go: { kind: "section", members: {} },
|
|
81
100
|
ts: {
|
|
82
101
|
kind: "section",
|
|
@@ -305,6 +324,28 @@ function skipScalar(cursor: Cursor): void {
|
|
|
305
324
|
}
|
|
306
325
|
}
|
|
307
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
|
+
|
|
308
349
|
function walkValue(cursor: Cursor, at: string, node: KeyNode, label: string): void {
|
|
309
350
|
skipSpace(cursor);
|
|
310
351
|
const ch = cursor.text.charCodeAt(cursor.at);
|
|
@@ -322,7 +363,11 @@ function walkValue(cursor: Cursor, at: string, node: KeyNode, label: string): vo
|
|
|
322
363
|
readName(cursor, at, label);
|
|
323
364
|
return;
|
|
324
365
|
}
|
|
366
|
+
const start = cursor.at;
|
|
325
367
|
skipScalar(cursor);
|
|
368
|
+
if (node.integer === true) {
|
|
369
|
+
checkInteger(cursor.text.slice(start, cursor.at), at, label);
|
|
370
|
+
}
|
|
326
371
|
}
|
|
327
372
|
|
|
328
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
|
|
47
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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`);
|