@cplieger/deadset-ts 3.2.0 → 3.3.1

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/src/analysis.js +14 -2
  3. package/dist/src/decorator.js +64 -0
  4. package/dist/src/exempt-classes.js +57 -10
  5. package/dist/src/exempt.js +23 -3
  6. package/dist/src/findings/dependencies-and-module-machinery.js +2 -0
  7. package/dist/src/findings/emitter.js +1 -0
  8. package/dist/src/findings/emitters.js +19 -0
  9. package/dist/src/findings/interfaces.js +2 -0
  10. package/dist/src/findings/intra-function.js +2 -0
  11. package/dist/src/findings/non-code-artifacts.js +2 -0
  12. package/dist/src/findings/reads-and-writes.js +2 -0
  13. package/dist/src/findings/self-check.js +2 -0
  14. package/dist/src/findings/unused-declarations.js +2 -0
  15. package/dist/src/findings/visibility-narrowing.js +2 -0
  16. package/dist/src/interface-satisfaction.js +310 -0
  17. package/dist/src/member-names.js +76 -0
  18. package/dist/src/references.js +22 -3
  19. package/dist/src/reflective-lookup.js +96 -0
  20. package/dist/src/run.js +60 -176
  21. package/dist/src/template-field.js +173 -0
  22. package/dist/src/verbs/print-config.js +9 -0
  23. package/dist/src/verbs/print-projects.js +34 -0
  24. package/dist/src/verbs/print-retained.js +23 -0
  25. package/dist/src/verbs/print-roots.js +36 -0
  26. package/dist/src/verbs/verb.js +5 -0
  27. package/dist/src/verbs/version.js +7 -0
  28. package/jsr.json +1 -1
  29. package/package.json +1 -1
  30. package/src/analysis.ts +24 -6
  31. package/src/decorator.ts +74 -0
  32. package/src/exempt-classes.ts +75 -12
  33. package/src/exempt.ts +29 -3
  34. package/src/findings/dependencies-and-module-machinery.ts +4 -0
  35. package/src/findings/emitter.ts +19 -0
  36. package/src/findings/emitters.ts +21 -0
  37. package/src/findings/interfaces.ts +4 -0
  38. package/src/findings/intra-function.ts +4 -0
  39. package/src/findings/non-code-artifacts.ts +4 -0
  40. package/src/findings/reads-and-writes.ts +4 -0
  41. package/src/findings/self-check.ts +4 -0
  42. package/src/findings/unused-declarations.ts +4 -0
  43. package/src/findings/visibility-narrowing.ts +4 -0
  44. package/src/interface-satisfaction.ts +417 -0
  45. package/src/member-names.ts +89 -0
  46. package/src/references.ts +41 -6
  47. package/src/reflective-lookup.ts +127 -0
  48. package/src/run.ts +64 -200
  49. package/src/template-field.ts +214 -0
  50. package/src/verbs/print-config.ts +10 -0
  51. package/src/verbs/print-projects.ts +35 -0
  52. package/src/verbs/print-retained.ts +24 -0
  53. package/src/verbs/print-roots.ts +38 -0
  54. package/src/verbs/verb.ts +38 -0
  55. package/src/verbs/version.ts +8 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The template-field class: a member is held back, at the lowest confidence, when a
3
+ * template under a configured template directory writes its name inside an action,
4
+ * because a template engine reads the member by that name at run time.
5
+ *
6
+ * Every file under a configured directory is a template. An action runs from an
7
+ * opening delimiter to the first closing one after it, at the pair
8
+ * `analysis.template_delimiters` sets, `{{` and `}}` by default, and each identifier
9
+ * it writes outside a quoted string is a name it reads: `{{ user.name }}` names `user`
10
+ * and `name`. A file holding an unclosed action names nothing. A name reaches the
11
+ * instance members of classes and the members of interfaces and object types, on any
12
+ * type, because nothing ties the name to a type.
13
+ */
14
+
15
+ import { ConfigError, type Analysis, type TemplateDelimiters } from "./config.ts";
16
+ import type { DetectorInput, Evidence } from "./exempt.ts";
17
+ import type { Host } from "./host.ts";
18
+ import type { SymbolKind } from "./inventory.ts";
19
+ import { memberNames } from "./member-names.ts";
20
+ import { isAbsolutePath, joinPath, normalizePath } from "./paths.ts";
21
+ import type { Position } from "./position.ts";
22
+
23
+ /** One template, by its path below the target root. */
24
+ export interface TemplateFile {
25
+ readonly path: string;
26
+ readonly text: string;
27
+ }
28
+
29
+ /** The templates one run scans, and the pair their actions are written with. */
30
+ export interface Templates {
31
+ readonly delimiters: TemplateDelimiters;
32
+ readonly files: readonly TemplateFile[];
33
+ }
34
+
35
+ /** The kinds of declaration an action can name. */
36
+ const READ_BY_A_TEMPLATE: ReadonlySet<SymbolKind> = new Set([
37
+ "method",
38
+ "class-member",
39
+ "interface-method",
40
+ "type-member",
41
+ ]);
42
+
43
+ /** One identifier, as the grammar of the analyzed language spells one. */
44
+ const IDENTIFIER = /[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*/uy;
45
+
46
+ /** Two strings ordered bytewise. */
47
+ function compare(a: string, b: string): number {
48
+ if (a === b) {
49
+ return 0;
50
+ }
51
+ return a < b ? -1 : 1;
52
+ }
53
+
54
+ /**
55
+ * Every template under the configured directories, read once for the run, in path
56
+ * order and each file once. A directory is named relative to the target root, and
57
+ * one the target does not hold refuses the run as a usage error: the class would
58
+ * scan nothing, so a name that no longer matches the tree would silently switch the
59
+ * exemption it was configured for off. A symbolic link to a directory is not
60
+ * followed.
61
+ */
62
+ export function readTemplates(host: Host, targetRoot: string, analysis: Analysis): Templates {
63
+ const files = new Map<string, string>();
64
+ const read = (dir: string): void => {
65
+ const entries = [...host.readDirectory(joinPath(targetRoot, dir))].sort((a, b) =>
66
+ compare(a.name, b.name),
67
+ );
68
+ for (const entry of entries) {
69
+ const path = joinPath(dir, entry.name);
70
+ if (entry.directory) {
71
+ read(path);
72
+ continue;
73
+ }
74
+ const absolute = joinPath(targetRoot, path);
75
+ if (!files.has(path) && host.kindOf(absolute) === "file") {
76
+ files.set(path, host.readFile(absolute));
77
+ }
78
+ }
79
+ };
80
+ for (const configured of analysis.templateDirs) {
81
+ const dir = normalizePath(configured);
82
+ if (
83
+ isAbsolutePath(dir) ||
84
+ dir === ".." ||
85
+ dir.startsWith("../") ||
86
+ host.kindOf(joinPath(targetRoot, dir)) !== "directory"
87
+ ) {
88
+ throw new ConfigError(
89
+ "malformed",
90
+ "analysis.template_dirs",
91
+ `analysis.template_dirs: ${JSON.stringify(configured)} is not a directory below the target root`,
92
+ );
93
+ }
94
+ read(dir);
95
+ }
96
+ return {
97
+ delimiters: analysis.templateDelimiters,
98
+ files: [...files]
99
+ .map(([path, text]) => ({ path, text }))
100
+ .sort((a, b) => compare(a.path, b.path)),
101
+ };
102
+ }
103
+
104
+ /** One identifier an action writes, where it is written, and the action that writes it. */
105
+ interface Named {
106
+ readonly name: string;
107
+ readonly offset: number;
108
+ readonly action: string;
109
+ }
110
+
111
+ /** The end of the quoted string that opens at `from`, past its closing quote. */
112
+ function stringEnd(text: string, from: number): number {
113
+ const quote = text[from];
114
+ let at = from + 1;
115
+ while (at < text.length && text[at] !== quote) {
116
+ at += text[at] === "\\" ? 2 : 1;
117
+ }
118
+ return at + 1;
119
+ }
120
+
121
+ /** The identifiers one action's inner text writes outside a quoted string, with their offsets in it. */
122
+ function identifiersOf(inner: string): readonly { name: string; offset: number }[] {
123
+ const found: { name: string; offset: number }[] = [];
124
+ let at = 0;
125
+ while (at < inner.length) {
126
+ const char = inner[at] ?? "";
127
+ if (char === '"' || char === "'" || char === "`") {
128
+ at = stringEnd(inner, at);
129
+ continue;
130
+ }
131
+ if (/[0-9]/u.test(char)) {
132
+ // A number's letters are part of the number: `1e3` names nothing.
133
+ while (at < inner.length && /[0-9A-Za-z_.]/u.test(inner[at] ?? "")) {
134
+ at += 1;
135
+ }
136
+ continue;
137
+ }
138
+ IDENTIFIER.lastIndex = at;
139
+ const match = IDENTIFIER.exec(inner);
140
+ if (match !== null) {
141
+ found.push({ name: match[0], offset: at });
142
+ at += match[0].length;
143
+ continue;
144
+ }
145
+ at += 1;
146
+ }
147
+ return found;
148
+ }
149
+
150
+ /**
151
+ * Every identifier one template's actions write, in the order the file writes them,
152
+ * or none where an action is not closed.
153
+ */
154
+ function namesOf(text: string, delimiters: TemplateDelimiters): readonly Named[] {
155
+ const found: Named[] = [];
156
+ let from = 0;
157
+ for (;;) {
158
+ const open = text.indexOf(delimiters.left, from);
159
+ if (open < 0) {
160
+ return found;
161
+ }
162
+ const start = open + delimiters.left.length;
163
+ const close = text.indexOf(delimiters.right, start);
164
+ if (close < 0) {
165
+ return [];
166
+ }
167
+ const inner = text.slice(start, close);
168
+ const action = `${delimiters.left}${inner.trim().replace(/\s+/gu, " ")}${delimiters.right}`;
169
+ for (const { name, offset } of identifiersOf(inner)) {
170
+ found.push({ name, offset: start + offset, action });
171
+ }
172
+ from = close + delimiters.right.length;
173
+ }
174
+ }
175
+
176
+ /** Where one offset of a template is, its column counted in UTF-16 code units. */
177
+ function positionIn(file: TemplateFile, offset: number): Position {
178
+ const before = file.text.slice(0, offset);
179
+ const lineStart = before.lastIndexOf("\n") + 1;
180
+ return {
181
+ path: file.path,
182
+ line: before.split("\n").length,
183
+ column: offset - lineStart + 1,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * The template-field detector: every instance member of a class and every member of
189
+ * an interface or an object type whose name an action of a configured template
190
+ * writes, recorded at the identifier.
191
+ */
192
+ export function templateField<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
193
+ const { templates } = input;
194
+ if (templates.files.length === 0) {
195
+ return [];
196
+ }
197
+ const members = memberNames(input.project.ownSourceFiles(), input.held);
198
+ const found: Evidence[] = [];
199
+ for (const file of templates.files) {
200
+ for (const named of namesOf(file.text, templates.delimiters)) {
201
+ for (const member of members.get(named.name) ?? []) {
202
+ if (!READ_BY_A_TEMPLATE.has(member.kind) || member.static) {
203
+ continue;
204
+ }
205
+ found.push({
206
+ id: member.id,
207
+ detail: `named by ${named.action}`,
208
+ site: positionIn(file, named.offset),
209
+ });
210
+ }
211
+ }
212
+ }
213
+ return found;
214
+ }
@@ -0,0 +1,10 @@
1
+ import { printConfig } from "../print.ts";
2
+ import { resolve } from "../resolve.ts";
3
+ import { EXIT_CLEAN, type Verb } from "./verb.ts";
4
+
5
+ /** Writes the resolved configuration with the source of every setting. */
6
+ export const printConfigVerb: Verb = ({ out, inputs }) => {
7
+ const { config, provenance } = resolve(inputs());
8
+ out.write(printConfig(config, provenance));
9
+ return EXIT_CLEAN;
10
+ };
@@ -0,0 +1,35 @@
1
+ import { diagnosticErrors, discoverProjects, renderDiagnostic } from "../discover.ts";
2
+ import { resolveMatrix } from "../resolve.ts";
3
+ import { diagnosticsOf, runSession } from "../session.ts";
4
+ import { EXIT_CLEAN, EXIT_FAILURE, type Verb } from "./verb.ts";
5
+
6
+ /**
7
+ * Writes the name of every project one run analyzes, one per line, having read each
8
+ * project's diagnostics first: the identifier the build matrix declares for it, or,
9
+ * where discovery derived the project, its configuration file's path below the
10
+ * target root.
11
+ */
12
+ export const printProjectsVerb: Verb = ({ out, err, host, inputs, scope, openClient }) => {
13
+ const matrix = resolveMatrix(inputs());
14
+ const scoped = scope();
15
+ const engine = openClient(false);
16
+ const discovered = discoverProjects(engine, host, scoped, matrix);
17
+ const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
18
+ const failures: string[] = [];
19
+ const { projects } = runSession(engine, discovered.configFiles, (project) => {
20
+ const errors = diagnosticErrors(diagnosticsOf(project));
21
+ failures.push(...errors.map(renderDiagnostic));
22
+ return ids.get(project.configFile) ?? project.configFile;
23
+ });
24
+ if (failures.length > 0) {
25
+ for (const line of failures) {
26
+ err.write(`${line}\n`);
27
+ }
28
+ err.write(`deadset-ts: ${String(failures.length)} error(s); no answer was produced\n`);
29
+ return EXIT_FAILURE;
30
+ }
31
+ for (const id of projects) {
32
+ out.write(`${id}\n`);
33
+ }
34
+ return EXIT_CLEAN;
35
+ };
@@ -0,0 +1,24 @@
1
+ import { runSweep } from "../analysis.ts";
2
+ import { retainedLines } from "../exempt.ts";
3
+ import { resolve } from "../resolve.ts";
4
+ import { EXIT_CLEAN, type Verb } from "./verb.ts";
5
+
6
+ /**
7
+ * Writes every declaration an exemption held back in the production sweep a report is
8
+ * built from, one per line, and nothing where no exemption held one back. One fact
9
+ * several configurations found is one record, so the line names no configuration. The
10
+ * line shape is this command's own rather than a Contract format, so that sort and cut
11
+ * read it.
12
+ */
13
+ export const printRetainedVerb: Verb = ({ out, host, inputs, scope, openClient }) => {
14
+ const { config } = resolve(inputs());
15
+ const scoped = scope();
16
+ const swept = runSweep(openClient(false), host, scoped, config, {
17
+ marked: [],
18
+ mode: { production: true },
19
+ });
20
+ for (const line of retainedLines(swept.matrix.union.symbols, swept.retained)) {
21
+ out.write(`${line}\n`);
22
+ }
23
+ return EXIT_CLEAN;
24
+ };
@@ -0,0 +1,38 @@
1
+ import { runRoots, type RunRoot } from "../analysis.ts";
2
+ import { resolve } from "../resolve.ts";
3
+ import { EXIT_CLEAN, EXIT_FINDINGS, type Verb } from "./verb.ts";
4
+
5
+ /**
6
+ * One root as a line: the declaration's reference, why it is a root, and the string
7
+ * that named it, separated by tabs. A run of several configurations adds the ones
8
+ * whose project holds the root as a fourth field, and writes the third present and
9
+ * empty where no string named the root, so every line of one run holds the same
10
+ * fields.
11
+ */
12
+ function rootLine(root: RunRoot, several: boolean): string {
13
+ const line = `${root.ref}\t${root.kind}`;
14
+ if (several) {
15
+ return `${line}\t${root.source}\t${root.configurations.join(" ")}`;
16
+ }
17
+ return root.source === "" ? line : `${line}\t${root.source}`;
18
+ }
19
+
20
+ /**
21
+ * Writes the root set of the run, one root per line, and then names on the error
22
+ * stream, by its issue kind, every configured root or pattern that named nothing;
23
+ * one such string fails the run. The line shape is this command's own rather than a
24
+ * Contract format, so that sort and cut read it.
25
+ */
26
+ export const printRootsVerb: Verb = ({ out, err, host, inputs, scope, openClient }) => {
27
+ const { config, provenance } = resolve(inputs());
28
+ const scoped = scope();
29
+ const answer = runRoots(openClient(false), host, scoped, config, provenance);
30
+ const several = answer.configurations.length > 1;
31
+ for (const root of answer.roots) {
32
+ out.write(`${rootLine(root, several)}\n`);
33
+ }
34
+ for (const finding of answer.findings) {
35
+ err.write(`${finding.code}: roots.patterns names nothing: ${finding.symbol.ref}\n`);
36
+ }
37
+ return answer.findings.length > 0 ? EXIT_FINDINGS : EXIT_CLEAN;
38
+ };
@@ -0,0 +1,38 @@
1
+ import type { Inputs } from "../config.ts";
2
+ import type { Emitters } from "../findings/emitter.ts";
3
+ import type { Host } from "../host.ts";
4
+ import type { Writer } from "../run.ts";
5
+ import type { Scope } from "../scope.ts";
6
+ import type { Engine } from "../session.ts";
7
+
8
+ /** The exit codes the Contract's exit-code table names. */
9
+ export const EXIT_CLEAN = 0;
10
+ export const EXIT_FINDINGS = 1;
11
+ export const EXIT_USAGE = 2;
12
+ export const EXIT_FAILURE = 3;
13
+
14
+ /**
15
+ * What one verb is handed: the streams it writes, the platform it reads, and the
16
+ * options that follow its name, read only when the verb asks for what they name, so
17
+ * a verb that asks for nothing refuses no option.
18
+ */
19
+ export interface Invocation {
20
+ readonly out: Writer;
21
+ readonly err: Writer;
22
+ readonly host: Host;
23
+ /** The configuration documents the options name, and the settings they supply. */
24
+ readonly inputs: () => Inputs;
25
+ /** The scope the options name: the document `--scope` names, or the target. */
26
+ readonly scope: () => Scope;
27
+ /** Opens the compiler client. A client the verb opened is closed if the verb throws. */
28
+ readonly openClient: (collectTiming: boolean) => Engine;
29
+ /** The emitter of every kind family, which a verb that reports findings runs. */
30
+ readonly emitters: Emitters;
31
+ }
32
+
33
+ /**
34
+ * One verb of the command line. It returns the exit code, and refuses by throwing a
35
+ * configuration, discovery or scope error, which the command line writes and turns
36
+ * into the code the Contract's table gives it.
37
+ */
38
+ export type Verb = (invocation: Invocation) => number;
@@ -0,0 +1,8 @@
1
+ import { CONTRACT_VERSION } from "../version.ts";
2
+ import { EXIT_CLEAN, type Verb } from "./verb.ts";
3
+
4
+ /** Writes the version the host reports and the Contract version this analyzer implements. */
5
+ export const versionVerb: Verb = ({ out, host }) => {
6
+ out.write(`deadset-ts ${host.analyzerVersion()}\ncontract ${CONTRACT_VERSION}\n`);
7
+ return EXIT_CLEAN;
8
+ };