@platforma-sdk/tengo-builder 4.0.28 → 4.1.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.
@@ -0,0 +1,110 @@
1
+ import { Command, Option } from "commander";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createLogger, getTengoFiles, pathType } from "../compiler/util";
5
+ import { dropImportLines, findUnusedImports } from "../compiler/imports";
6
+ import { resolvePackageJsonRoot } from "../compiler/main";
7
+ import * as opts from "../shared/basecmd";
8
+
9
+ // The same limit the tags generation uses: deep enough for any real source
10
+ // tree, shallow enough to keep a scan started in a wrong place cheap.
11
+ const maxScanDepth = 9;
12
+
13
+ export default function importsCommand(): Command {
14
+ const cmd = new Command("imports").description("find imports tengo sources do not use");
15
+
16
+ cmd.argument("[paths...]", "source paths to scan (defaults to ./src)");
17
+ opts.addOptions(cmd, opts.GlobalOptions());
18
+ cmd.addOption(new Option("--check", "report unused imports and fail, change no file"));
19
+ cmd.addOption(new Option("--fix", "remove unused imports from the sources"));
20
+
21
+ cmd.action(async (paths: string[], o) => {
22
+ const logger = createLogger(o.logLevel as string);
23
+
24
+ if (o.check && o.fix) {
25
+ cmd.error("error: options '--check' and '--fix' cannot be used together");
26
+ }
27
+
28
+ // Without an explicit mode: fix the sources when a human runs the command,
29
+ // report and fail when CI does. CI must never commit a change of its own.
30
+ const fix = o.fix ? true : o.check ? false : !process.env.CI;
31
+
32
+ const roots = paths.length == 0 ? ["./src"] : paths;
33
+ const localPackageName = getLocalPackageName();
34
+
35
+ let hasUnusedImports = false;
36
+ let hasBrokenSources = false;
37
+
38
+ for (const file of collectSources(roots)) {
39
+ const src = fs.readFileSync(file).toString();
40
+
41
+ let unused;
42
+ try {
43
+ unused = findUnusedImports(logger, src, localPackageName, file);
44
+ } catch (err: unknown) {
45
+ // A source we cannot parse is a problem for 'pl-tengo check' to
46
+ // report in full. Here we keep the file as it is and move on, so a
47
+ // single broken source does not block the cleanup of all the others.
48
+ logger.error(err);
49
+ hasBrokenSources = true;
50
+ continue;
51
+ }
52
+
53
+ if (unused.length == 0) {
54
+ continue;
55
+ }
56
+
57
+ hasUnusedImports = true;
58
+ logger.warn(`${file}: unused imports: ${unused.map((im) => im.alias).join(", ")}`);
59
+
60
+ if (fix) {
61
+ logger.info(`fixing ${file}...`);
62
+ fs.writeFileSync(
63
+ file,
64
+ dropImportLines(
65
+ src,
66
+ unused.map((im) => im.lineNo),
67
+ ),
68
+ );
69
+ }
70
+ }
71
+
72
+ if (hasBrokenSources || (hasUnusedImports && !fix)) {
73
+ process.exit(1);
74
+ }
75
+ });
76
+
77
+ return cmd;
78
+ }
79
+
80
+ /** Package name of the sources we scan. Used to resolve local ':<item>'
81
+ * imports. An empty name keeps the parser working for a directory that is
82
+ * not a package. */
83
+ function getLocalPackageName(): string {
84
+ try {
85
+ const packageJson = fs.readFileSync(resolvePackageJsonRoot(process.cwd())).toString();
86
+ return (JSON.parse(packageJson) as { name?: string }).name ?? "";
87
+ } catch {
88
+ return "";
89
+ }
90
+ }
91
+
92
+ function collectSources(roots: string[]): string[] {
93
+ const sources: string[] = [];
94
+
95
+ for (const root of roots) {
96
+ const type = pathType(root);
97
+ if (type === "dir") {
98
+ sources.push(...getTengoFiles(path.resolve(root), maxScanDepth));
99
+ continue;
100
+ }
101
+ if (type === "file") {
102
+ sources.push(path.resolve(root));
103
+ continue;
104
+ }
105
+
106
+ throw new Error(`source path not found: ${root}`);
107
+ }
108
+
109
+ return sources;
110
+ }
@@ -0,0 +1,181 @@
1
+ import { expect, describe, test } from "vitest";
2
+ import { dropImportLines, findUnusedImports } from "./imports";
3
+ import { createLogger } from "./util";
4
+
5
+ const logger = createLogger("error");
6
+
7
+ const unusedAliases = (src: string, localPackageName = "current-package"): string[] =>
8
+ findUnusedImports(logger, src, localPackageName, "test.lib.tengo").map((im) => im.alias);
9
+
10
+ describe("findUnusedImports", () => {
11
+ test("keeps an import the code uses", () => {
12
+ const src = `ll := import(":ll")
13
+
14
+ doSomething := func() {
15
+ ll.assert(true, "always")
16
+ }
17
+ `;
18
+ expect(unusedAliases(src)).toEqual([]);
19
+ });
20
+
21
+ test("reports an import nothing uses", () => {
22
+ const src = `ll := import(":ll")
23
+ sets := import(":sets")
24
+
25
+ doSomething := func() {
26
+ ll.assert(true, "always")
27
+ }
28
+ `;
29
+ expect(unusedAliases(src)).toEqual(["sets"]);
30
+ });
31
+
32
+ test("reports standard library imports too", () => {
33
+ const src = `json := import("json")
34
+ text := import("text")
35
+
36
+ doSomething := func(s) {
37
+ return text.split(s, ",")
38
+ }
39
+ `;
40
+ expect(unusedAliases(src)).toEqual(["json"]);
41
+ });
42
+
43
+ test("a single line comment is not a usage", () => {
44
+ const src = `validation := import(":validation")
45
+
46
+ doSomething := func(spec) {
47
+ // @TODO fix validation
48
+ // validation.assertValidJson(spec, schema)
49
+ return spec
50
+ }
51
+ `;
52
+ expect(unusedAliases(src)).toEqual(["validation"]);
53
+ });
54
+
55
+ test("a comment block is not a usage", () => {
56
+ const src = `maps := import(":maps")
57
+
58
+ /*
59
+ maps.clone(m) is what this used to do.
60
+ */
61
+ doSomething := func(m) {
62
+ return m
63
+ }
64
+ `;
65
+ expect(unusedAliases(src)).toEqual(["maps"]);
66
+ });
67
+
68
+ test("a compiler option line is not a usage", () => {
69
+ const src = `//tengo:hash_override 8ad4b0c4-1f31-4c4a-9b53-c2e50d2f0d9e
70
+ ll := import(":ll")
71
+
72
+ doSomething := func() {
73
+ return 1
74
+ }
75
+ `;
76
+ expect(unusedAliases(src)).toEqual(["ll"]);
77
+ });
78
+
79
+ test("a longer name that ends with the alias is not a usage", () => {
80
+ const src = `ll := import(":ll")
81
+
82
+ doSomething := func(xll) {
83
+ return xll.value
84
+ }
85
+ `;
86
+ expect(unusedAliases(src)).toEqual(["ll"]);
87
+ });
88
+
89
+ test("a usage inside a multiline statement counts", () => {
90
+ const src = `ll := import(":ll")
91
+ exec := import(":exec")
92
+
93
+ doSomething := func() {
94
+ return exec.builder().
95
+ cmd(ll.toStrings(["a"])).
96
+ run()
97
+ }
98
+ `;
99
+ expect(unusedAliases(src)).toEqual([]);
100
+ });
101
+
102
+ test("a module the code only passes around counts as unused", () => {
103
+ // We ask for the dot on purpose: keeping an import too long costs
104
+ // nothing, dropping one the source needs breaks the build.
105
+ const src = `text := import("text")
106
+
107
+ doSomething := func() {
108
+ return apply(text)
109
+ }
110
+ `;
111
+ expect(unusedAliases(src)).toEqual(["text"]);
112
+ });
113
+
114
+ test("reports the line of each import", () => {
115
+ const src = `ll := import(":ll")
116
+ sets := import(":sets")
117
+ `;
118
+ expect(findUnusedImports(logger, src, "current-package", "test.lib.tengo")).toEqual([
119
+ { alias: "ll", module: ":ll", lineNo: 0 },
120
+ { alias: "sets", module: ":sets", lineNo: 1 },
121
+ ]);
122
+ });
123
+
124
+ test("a source without imports gives nothing", () => {
125
+ expect(unusedAliases("doSomething := func() { return 1 }\n")).toEqual([]);
126
+ });
127
+
128
+ test("throws with the source name and the line for a source it cannot parse", () => {
129
+ const src = `assets := import("@platforma-sdk/workflow-tengo:assets")
130
+
131
+ softwareName := "sw:main"
132
+ sw := assets.importSoftware(softwareName)
133
+ `;
134
+ expect(() => unusedAliases(src)).toThrow("[line 4 in test.lib.tengo]");
135
+ });
136
+ });
137
+
138
+ describe("dropImportLines", () => {
139
+ test("removes only the given lines", () => {
140
+ const src = `ll := import(":ll")
141
+ sets := import(":sets")
142
+
143
+ doSomething := func() {
144
+ ll.assert(true, "always")
145
+ }
146
+ `;
147
+ expect(dropImportLines(src, [1])).toEqual(`ll := import(":ll")
148
+
149
+ doSomething := func() {
150
+ ll.assert(true, "always")
151
+ }
152
+ `);
153
+ });
154
+
155
+ test("keeps comments, blank lines and the trailing newline", () => {
156
+ const src = `// header comment
157
+
158
+ json := import("json")
159
+
160
+ /* a block
161
+ comment */
162
+ doSomething := func() {
163
+ return 1
164
+ }
165
+ `;
166
+ expect(dropImportLines(src, [2])).toEqual(`// header comment
167
+
168
+
169
+ /* a block
170
+ comment */
171
+ doSomething := func() {
172
+ return 1
173
+ }
174
+ `);
175
+ });
176
+
177
+ test("keeps the source as it is when there is nothing to remove", () => {
178
+ const src = `ll := import(":ll")\n`;
179
+ expect(dropImportLines(src, [])).toEqual(src);
180
+ });
181
+ });
@@ -0,0 +1,96 @@
1
+ // Unused import detection for Tengo sources.
2
+ //
3
+ // The check runs on the parser's own line results, not on the raw text: a
4
+ // comment line comes back empty, so an alias that appears only in a comment
5
+ // correctly counts as unused.
6
+
7
+ import type { MiLogger } from "@milaboratories/ts-helpers";
8
+ import type { ImportInfo, sourceParserContext } from "./source";
9
+ import { parseSingleSourceLine } from "./source";
10
+
11
+ /** A module import statement and the line it is on. */
12
+ export type TengoImport = ImportInfo & {
13
+ /** Line index in the source, counted from 0. */
14
+ lineNo: number;
15
+ };
16
+
17
+ function newParserContext(): sourceParserContext {
18
+ return {
19
+ isInCommentBlock: false,
20
+ canDetectOptions: true,
21
+ artifactImportREs: new Map(),
22
+ importLikeREs: new Map(),
23
+ multilineStatement: "",
24
+ lineNo: 0,
25
+ };
26
+ }
27
+
28
+ function escapeForRegExp(s: string): string {
29
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
+ }
31
+
32
+ /**
33
+ * Find imports the source does not use.
34
+ *
35
+ * An import is used when its alias appears with a dot somewhere else in the
36
+ * code: 'text.split(...)'. A module passed around as a value, say
37
+ * 'myFunc(text)', counts as unused. This is deliberate: the cost of keeping an
38
+ * import the source no longer needs is nothing, while the cost of dropping one
39
+ * it does need is a broken build.
40
+ *
41
+ * Throws when the source does not parse, with the same line context the
42
+ * compiler reports.
43
+ */
44
+ export function findUnusedImports(
45
+ logger: MiLogger,
46
+ src: string,
47
+ localPackageName: string,
48
+ srcName: string,
49
+ ): TengoImport[] {
50
+ const imports: TengoImport[] = [];
51
+
52
+ // Lines as the parser sees them: comments are empty, code is untouched.
53
+ const codeLines: string[] = [];
54
+
55
+ let context = newParserContext();
56
+
57
+ const lines = src.split("\n");
58
+ for (const line of lines) {
59
+ context.lineNo++;
60
+
61
+ try {
62
+ const result = parseSingleSourceLine(logger, line, context, localPackageName, false);
63
+ context = result.context;
64
+ codeLines.push(result.line);
65
+
66
+ if (result.moduleImport) {
67
+ imports.push({ ...result.moduleImport, lineNo: context.lineNo - 1 });
68
+ }
69
+ } catch (error: unknown) {
70
+ const err = error as Error;
71
+ throw new Error(`[line ${context.lineNo} in ${srcName}]: ${err.message}\n\t${line}`, {
72
+ cause: err,
73
+ });
74
+ }
75
+ }
76
+
77
+ return imports.filter((im) => {
78
+ const usageRE = new RegExp(`\\b${escapeForRegExp(im.alias)}\\s*\\.`);
79
+ return !codeLines.some((code, lineNo) => lineNo !== im.lineNo && usageRE.test(code));
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Drop the given lines from the source.
85
+ *
86
+ * The source is cut as it was written: comments, blank lines and formatting
87
+ * outside the dropped lines stay byte for byte the same. An import is always a
88
+ * single line, as the parser accepts it only as an assignment.
89
+ */
90
+ export function dropImportLines(src: string, lineNos: number[]): string {
91
+ const drop = new Set(lineNos);
92
+ return src
93
+ .split("\n")
94
+ .filter((_, lineNo) => !drop.has(lineNo))
95
+ .join("\n");
96
+ }
@@ -238,6 +238,9 @@ export type lineProcessingResult = {
238
238
  context: sourceParserContext;
239
239
  artifacts: TypedArtifactName[];
240
240
  option: CompilerOption | undefined;
241
+ /** Set when the line is a module import, for every module: Platforma
242
+ * artifacts, `plapi` and the Tengo standard library alike. */
243
+ moduleImport?: ImportInfo;
241
244
  };
242
245
 
243
246
  export function parseSingleSourceLine(
@@ -347,7 +350,7 @@ function processModuleImport(
347
350
  ["software", newGetSoftwareInfoRE(iInfo.alias)],
348
351
  ]);
349
352
  }
350
- return { line: originalLine, context, artifacts: [], option: undefined };
353
+ return { line: originalLine, context, artifacts: [], option: undefined, moduleImport: iInfo };
351
354
  }
352
355
 
353
356
  if (
@@ -391,7 +394,7 @@ function processModuleImport(
391
394
  const artifact = parseArtifactName(iInfo.module, "library", localPackageName);
392
395
  if (!artifact) {
393
396
  // not a Platforma Tengo library import
394
- return { line: originalLine, context, artifacts: [], option: undefined };
397
+ return { line: originalLine, context, artifacts: [], option: undefined, moduleImport: iInfo };
395
398
  }
396
399
 
397
400
  if (globalizeImports) {
@@ -401,7 +404,13 @@ function processModuleImport(
401
404
  );
402
405
  }
403
406
 
404
- return { line: originalLine, context, artifacts: [artifact], option: undefined };
407
+ return {
408
+ line: originalLine,
409
+ context,
410
+ artifacts: [artifact],
411
+ option: undefined,
412
+ moduleImport: iInfo,
413
+ };
405
414
  }
406
415
 
407
416
  function processAssetImport(
@@ -495,7 +504,7 @@ function processAssetImport(
495
504
  return { line: originalLine, context, artifacts: [], option: undefined };
496
505
  }
497
506
 
498
- interface ImportInfo {
507
+ export interface ImportInfo {
499
508
  module: string; // the module name without wrapping quotes: import("<module>")
500
509
  alias: string; // the name of variable that keeps imported module: <alias> := import("<module>")
501
510
  }
@@ -64,3 +64,33 @@ export function isUUID(uuid: string): boolean {
64
64
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
65
65
  return uuidRegex.test(uuid.toLowerCase());
66
66
  }
67
+
68
+ /**
69
+ * Collect all '.tengo' files in the directory and its subdirectories.
70
+ *
71
+ * The depth limit keeps the walk cheap: without it a scan started in a wrong
72
+ * place can become unbearably slow and even get the node process OOM killed.
73
+ * Increase the limit if you need to.
74
+ */
75
+ export function getTengoFiles(dir: string, depth: number): string[] {
76
+ if (depth === 0) {
77
+ return [];
78
+ }
79
+
80
+ const files = fs.readdirSync(dir, { withFileTypes: true });
81
+
82
+ let tengoFiles: string[] = [];
83
+ files.forEach((file) => {
84
+ const absPath = path.join(dir, file.name);
85
+
86
+ if (file.isDirectory()) {
87
+ tengoFiles = tengoFiles.concat(getTengoFiles(absPath, depth - 1));
88
+ }
89
+
90
+ if (!file.isDirectory() && file.name.endsWith(".tengo")) {
91
+ tengoFiles.push(absPath);
92
+ }
93
+ });
94
+
95
+ return tengoFiles;
96
+ }