@intentius/chant 0.71.0 → 0.72.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/dist/discovery/fold-import.d.ts +28 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +29 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +34 -1
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/lint.test.ts +38 -4
- package/src/discovery/fold-host-packages.test.ts +89 -0
- package/src/discovery/fold-import.ts +35 -53
- package/src/fold/fold.test.ts +99 -6
- package/src/fold/fold.ts +139 -0
- package/src/fold/subset.test.ts +161 -20
- package/src/fold/subset.ts +136 -1
- package/src/index.ts +1 -1
- package/src/meta/declared-imports.test.ts +141 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { builtinModules } from "node:module";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A package may only import, by name, what it declares.
|
|
10
|
+
*
|
|
11
|
+
* chant-v0.71.0 shipped `packages/core/src/op/activities/apply.ts` doing
|
|
12
|
+
* `await import("js-yaml")` while core's package.json declared no such
|
|
13
|
+
* dependency. Inside this repository it resolved anyway, hoisted out of the six
|
|
14
|
+
* lexicons that do declare it, so every test and every local build passed. A
|
|
15
|
+
* consumer installing `@intentius/chant` alone got an unresolvable import, and
|
|
16
|
+
* the three warden repositories all failed to bundle on the version bump.
|
|
17
|
+
*
|
|
18
|
+
* That is the shape worth gating: a missing dependency is invisible in a
|
|
19
|
+
* workspace and only fails downstream, which is the most expensive place to
|
|
20
|
+
* find it, after a release is already tagged and published. It is the same
|
|
21
|
+
* reasoning as the two guards next door — `peer-deps.test.ts` and
|
|
22
|
+
* `publish-metadata.test.ts` both exist because a packaging field nobody could
|
|
23
|
+
* see locally broke a publish or an install.
|
|
24
|
+
*
|
|
25
|
+
* Scope and precision. This walks the real import nodes with the TypeScript
|
|
26
|
+
* parser rather than matching text, so a specifier inside a comment, a
|
|
27
|
+
* code-generation template string, or this repository's own fold-import
|
|
28
|
+
* fixtures is not mistaken for an import. Only a literal specifier counts: a
|
|
29
|
+
* variable specifier is the codebase's established way of saying "a package
|
|
30
|
+
* this one deliberately does not depend on" (see `loadK8sApplier` in
|
|
31
|
+
* `op/activities/apply.ts`), and nothing statically resolves it.
|
|
32
|
+
*/
|
|
33
|
+
const CORE = fileURLToPath(new URL("../../", import.meta.url));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A package core imports by name on purpose without declaring it, and why.
|
|
37
|
+
*
|
|
38
|
+
* An entry here is a promise that the import is guarded: reaching it without
|
|
39
|
+
* the package installed produces an actionable error rather than a crash.
|
|
40
|
+
* Anything else belongs in `dependencies`.
|
|
41
|
+
*/
|
|
42
|
+
const DELIBERATELY_UNDECLARED = new Map<string, string>([
|
|
43
|
+
[
|
|
44
|
+
"@cdktf/hcl2json",
|
|
45
|
+
"carries a ~1.8 MB wasm blob and is only needed by `chant carve`; " +
|
|
46
|
+
"`terraform/parse.ts` catches the failed import and prints the install line",
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
"@intentius/chant-lexicon-gitlab",
|
|
50
|
+
"the only lexicon shipping migration rules today; `cli/commands/migrate.ts` " +
|
|
51
|
+
"catches the failed import and migrates with no extra rules. Core depending on " +
|
|
52
|
+
"a lexicon would invert the dependency direction the whole layering rests on",
|
|
53
|
+
],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Every `.ts` file that ships, excluding tests and the fixture trees. */
|
|
57
|
+
function sourceFiles(dir: string): string[] {
|
|
58
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
59
|
+
const path = join(dir, entry);
|
|
60
|
+
if (statSync(path).isDirectory()) {
|
|
61
|
+
return /__fixtures__|__tests__|[/\\]fixtures$/.test(path) ? [] : sourceFiles(path);
|
|
62
|
+
}
|
|
63
|
+
return /\.ts$/.test(entry) && !/\.test\.ts$/.test(entry) ? [path] : [];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The package name a specifier resolves to: `zod/v4` -> `zod`, `@a/b/c` -> `@a/b`. */
|
|
68
|
+
function packageOf(specifier: string): string {
|
|
69
|
+
const parts = specifier.split("/");
|
|
70
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Literal bare specifiers this file imports, from real import nodes only. */
|
|
74
|
+
function importedPackages(file: string): Set<string> {
|
|
75
|
+
const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
|
|
76
|
+
const out = new Set<string>();
|
|
77
|
+
const add = (node: ts.Expression | undefined): void => {
|
|
78
|
+
if (!node || !ts.isStringLiteral(node)) return;
|
|
79
|
+
const specifier = node.text;
|
|
80
|
+
if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("node:")) return;
|
|
81
|
+
const name = packageOf(specifier);
|
|
82
|
+
if (builtins.has(name)) return;
|
|
83
|
+
out.add(name);
|
|
84
|
+
};
|
|
85
|
+
const visit = (node: ts.Node): void => {
|
|
86
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(node.moduleSpecifier);
|
|
87
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) add(node.argument.literal as ts.Expression);
|
|
88
|
+
else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) add(node.arguments[0]);
|
|
89
|
+
ts.forEachChild(node, visit);
|
|
90
|
+
};
|
|
91
|
+
ts.forEachChild(source, visit);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const builtins = new Set(builtinModules);
|
|
96
|
+
|
|
97
|
+
describe("@intentius/chant imports only what it declares", () => {
|
|
98
|
+
const pkg = JSON.parse(readFileSync(join(CORE, "package.json"), "utf8")) as {
|
|
99
|
+
name: string;
|
|
100
|
+
dependencies?: Record<string, string>;
|
|
101
|
+
peerDependencies?: Record<string, string>;
|
|
102
|
+
optionalDependencies?: Record<string, string>;
|
|
103
|
+
};
|
|
104
|
+
const declared = new Set([
|
|
105
|
+
pkg.name,
|
|
106
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
107
|
+
...Object.keys(pkg.peerDependencies ?? {}),
|
|
108
|
+
...Object.keys(pkg.optionalDependencies ?? {}),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
test("every literal bare import is declared, or deliberately not", () => {
|
|
112
|
+
const undeclared = new Map<string, string[]>();
|
|
113
|
+
for (const file of sourceFiles(join(CORE, "src"))) {
|
|
114
|
+
for (const name of importedPackages(file)) {
|
|
115
|
+
if (declared.has(name) || DELIBERATELY_UNDECLARED.has(name)) continue;
|
|
116
|
+
const where = undeclared.get(name) ?? [];
|
|
117
|
+
where.push(relative(CORE, file));
|
|
118
|
+
undeclared.set(name, where);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
[...undeclared].map(([name, where]) => `${name} (${where.join(", ")})`),
|
|
124
|
+
"imported by name but absent from package.json. Add it to dependencies, " +
|
|
125
|
+
"or guard the import and record it in DELIBERATELY_UNDECLARED with the reason.",
|
|
126
|
+
).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("js-yaml specifically, since that is the one that shipped broken", () => {
|
|
130
|
+
// chant-v0.71.0's regression, pinned by name so the fix cannot be reverted
|
|
131
|
+
// quietly: `renderKustomization` parses `kustomize build` output with it.
|
|
132
|
+
expect(declared.has("js-yaml")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the deliberate exceptions are still imported, so the list cannot rot", () => {
|
|
136
|
+
const imported = new Set(sourceFiles(join(CORE, "src")).flatMap((f) => [...importedPackages(f)]));
|
|
137
|
+
for (const name of DELIBERATELY_UNDECLARED.keys()) {
|
|
138
|
+
expect(imported.has(name), `${name} is listed as deliberately undeclared but nothing imports it`).toBe(true);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|