@shivaedev/quality 0.5.1 → 0.6.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/CHANGELOG.md +10 -0
- package/README.md +20 -1
- package/biome/preset.json +12 -1
- package/dist/imports/departures.d.ts +9 -0
- package/dist/imports/departures.d.ts.map +1 -0
- package/dist/imports/departures.js +49 -0
- package/dist/imports/departures.js.map +1 -0
- package/dist/imports/specifiers.d.ts +9 -0
- package/dist/imports/specifiers.d.ts.map +1 -0
- package/dist/imports/specifiers.js +71 -0
- package/dist/imports/specifiers.js.map +1 -0
- package/dist/rules/built-in.d.ts +1 -1
- package/dist/rules/built-in.d.ts.map +1 -1
- package/dist/rules/built-in.js +2 -0
- package/dist/rules/built-in.js.map +1 -1
- package/dist/rules/imports/aliased.d.ts +2 -0
- package/dist/rules/imports/aliased.d.ts.map +1 -0
- package/dist/rules/imports/aliased.js +23 -0
- package/dist/rules/imports/aliased.js.map +1 -0
- package/package.json +1 -1
- package/src/imports/departures.ts +72 -0
- package/src/imports/specifiers.ts +94 -0
- package/src/rules/built-in.ts +2 -0
- package/src/rules/imports/aliased.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-10-04
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add `imports/aliased`. A relative import may name only a file in its own folder; one that goes up (`../format.ts`) or down into a subfolder (`./parts/deeper.ts`) is reported, in static imports, re-exports, side-effect imports, `import()`, `require()`, `import()` types, JSDoc `@import` tags and the paths of `vi.mock` and its kin. The message names the fix for where the import lands: a `#` alias from the `imports` of its own package's `package.json`, declared if none fits; the name of another workspace package, through a path its `exports` lists; or the name of an installed package. The rule only reports: the author picks the alias. Like every rule it is an error by default, so a repository that upgrades adopts it into its baseline.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- The Biome preset organizes imports in five groups: Node and Bun builtins, packages, `@shivaedev/*` packages, aliases (`#…`, `@/…`, `~…`) and relative paths. `quality fix` regroups the imports of every file once; until then each file out of order is a `biome/assist/source/organizeImports` finding.
|
|
12
|
+
|
|
3
13
|
## 0.5.1 - 2026-10-04
|
|
4
14
|
|
|
5
15
|
### Changed
|
package/README.md
CHANGED
|
@@ -197,13 +197,14 @@ A weakening the preset makes without such a declaration is reported at the line
|
|
|
197
197
|
|
|
198
198
|
### Imports
|
|
199
199
|
|
|
200
|
-
|
|
200
|
+
Four rules read the imports of the TypeScript and JavaScript modules among the sources. They build it once per run with the TypeScript compiler, without a bundler or another dependency.
|
|
201
201
|
|
|
202
202
|
| Rule | Reports | Options |
|
|
203
203
|
| --- | --- | --- |
|
|
204
204
|
| `imports/cycles` | Modules that import each other at run time | none |
|
|
205
205
|
| `imports/resolvable` | An import that resolves to nothing | `generated` |
|
|
206
206
|
| `imports/fences` | An import that crosses a fence the config declares | `fences` |
|
|
207
|
+
| `imports/aliased` | A relative import that leaves its own folder | none |
|
|
207
208
|
|
|
208
209
|
Each import resolves the way the compiler resolves it, with the options of the nearest `tsconfig.json` and the projects it references, under bundler resolution: `paths`, `package.json` `imports` and `exports` and the `source` condition hold, so a workspace package resolves to its source. The graph reads static and dynamic imports, `export ... from`, `require()`, `require.resolve()`, `import.meta.resolve()` of a bare specifier (a relative one is URL arithmetic that never checks the path), `import()` types, `/// <reference types>`, `/// <reference path>` and JSDoc `@import` tags.
|
|
209
210
|
|
|
@@ -221,6 +222,23 @@ Each import resolves the way the compiler resolves it, with the options of the n
|
|
|
221
222
|
|
|
222
223
|
The gate fails closed: when the sources hold no module at all, the imports rules stop the run instead of passing on an empty graph. Point `sources` at the code, or turn the rules off.
|
|
223
224
|
|
|
225
|
+
#### Aliases
|
|
226
|
+
|
|
227
|
+
A relative import names only a file in its own folder, such as `./format.ts`: the folder is one module. Every other import goes through an alias, so moving a file never rewrites a chain of `../`:
|
|
228
|
+
|
|
229
|
+
- the `imports` of the importer's nearest `package.json`, such as `"#lib/*": "./src/lib/*"`, or `"#shared/*": "@demo/shared/*"`, which names another workspace package;
|
|
230
|
+
- the name of another workspace package, as far as its `exports` reach.
|
|
231
|
+
|
|
232
|
+
An import that already goes through a tsconfig `paths` alias is not relative, so the rule leaves it alone.
|
|
233
|
+
|
|
234
|
+
`imports/aliased` reports a relative import that goes up (`../format.ts`) or down into a subfolder (`./parts/deeper.ts`, or `./widgets` for `widgets/index.ts`). It reads static imports, `export ... from`, side-effect imports, `import()`, `require()`, `import()` types, JSDoc `@import` tags and the paths of `vi.mock`, `vi.doMock`, `vi.unmock`, `vi.doUnmock`, `vi.importActual` and `vi.importMock`, so a mock keeps pointing at the module it replaces. An escaped path, such as `"\x2e\x2e/format.ts"`, counts as the path it spells. Each finding has the import as its subject. Its message names the fix for where the import lands:
|
|
235
|
+
|
|
236
|
+
- inside its own package: import through a `#` alias from the `imports` of the package's `package.json`, and declare one if none fits;
|
|
237
|
+
- inside another workspace package: import it by that package's name, through a path its `exports` lists;
|
|
238
|
+
- inside an installed package under `node_modules`: import that package by its name.
|
|
239
|
+
|
|
240
|
+
The rule only reports; nothing rewrites the import. Whoever writes the import picks the alias, because only they know which conditions their runtimes, bundler and tests use.
|
|
241
|
+
|
|
224
242
|
#### Fences
|
|
225
243
|
|
|
226
244
|
A fence is one prohibition, stated in the config without patterns:
|
|
@@ -389,6 +407,7 @@ The preset sets:
|
|
|
389
407
|
|
|
390
408
|
- **Formatting:** tabs, a line width of 150, double quotes, semicolons, trailing commas and operators at the start of a wrapped line.
|
|
391
409
|
- **Lint:** every rule Biome recommends, at `error`, and a list of stricter rules on top, among them `noUnsafeTypeAssertion`, `useBlockStatements`, `noEqualsToNull`, `useNumericSeparators`, `useUnicodeRegex`, `useLiteralKeys`, `readonly T[]` arrays, `noFloatingPromises`, `noMisusedPromises`, `useExhaustiveSwitchCases`, `useExhaustiveDependencies`, `it` for every test, function declarations over function expressions, interfaces for object types, a cognitive complexity limit of 15, no nested ternaries, no barrel files and no `export *`.
|
|
410
|
+
- **Imports:** organized in five groups: Node and Bun builtins, packages, `@shivaedev/*` packages, aliases and relative paths. Biome counts as an alias a specifier that starts with `#`, `@/`, `~`, `$` or `%`; a tsconfig path such as `@app/*` sorts with the packages, and Biome's `noUndeclaredDependencies` takes it for one, so name aliases in a form Biome recognizes.
|
|
392
411
|
- **Assist:** organized imports and sorted keys, attributes, enum members, interface members and properties. Keys are sorted in JSON and in object literals alike; `package.json` is left out, because `manifests/sorted` gives it the order npm users expect.
|
|
393
412
|
- **Plugins:** GritQL rules that ban ambient time, randomness, `console` and `process.env` for Effect's services, and that ask `Effect.fn` for a literal span name shaped `Owner.operation`. They load from `./node_modules/@shivaedev/quality/biome/plugins`, so the package must be installed at the repository root.
|
|
394
413
|
- Files ignored by git are skipped.
|
package/biome/preset.json
CHANGED
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
"actions": {
|
|
4
4
|
"source": {
|
|
5
5
|
"noDuplicateClasses": "on",
|
|
6
|
-
"organizeImports":
|
|
6
|
+
"organizeImports": {
|
|
7
|
+
"level": "on",
|
|
8
|
+
"options": {
|
|
9
|
+
"groups": [
|
|
10
|
+
[":NODE:", ":BUN:"],
|
|
11
|
+
[":PACKAGE:", ":PACKAGE_WITH_PROTOCOL:", ":URL:", "!@shivaedev/**"],
|
|
12
|
+
["@shivaedev/**"],
|
|
13
|
+
[":ALIAS:"],
|
|
14
|
+
[":PATH:"]
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
7
18
|
"useSortedAttributes": "on",
|
|
8
19
|
"useSortedEnumMembers": "on",
|
|
9
20
|
"useSortedInterfaceMembers": "on",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RuleInputs } from "../rule.ts";
|
|
2
|
+
import { type SpecifierSite } from "./specifiers.ts";
|
|
3
|
+
export interface Departure extends SpecifierSite {
|
|
4
|
+
readonly file: string;
|
|
5
|
+
readonly installed: string | undefined;
|
|
6
|
+
readonly workspace: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
export declare function departures(inputs: RuleInputs): Promise<readonly Departure[]>;
|
|
9
|
+
//# sourceMappingURL=departures.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"departures.d.ts","sourceRoot":"","sources":["../../src/imports/departures.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAK7C,OAAO,EAAE,KAAK,aAAa,EAAkB,MAAM,iBAAiB,CAAC;AAGrE,MAAM,WAAW,SAAU,SAAQ,aAAa;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACvC;AA4CD,wBAAsB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,SAAS,EAAE,CAAC,CAalF"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { parse } from "../rules/syntax.js";
|
|
4
|
+
import { projectsFor } from "./projects.js";
|
|
5
|
+
import { resolveImport } from "./resolve.js";
|
|
6
|
+
import { specifierSites } from "./specifiers.js";
|
|
7
|
+
import { packageOf, workspacePackages } from "./workspace.js";
|
|
8
|
+
const QUERY = /\?.*$/u;
|
|
9
|
+
const RELATIVE = /^\.\.?(?:\/|$)/u;
|
|
10
|
+
const MAY_BE_RELATIVE = /["'`]\.\.?[/"'`]|\\/u;
|
|
11
|
+
const TRAILING_SLASHES = /\/+$/u;
|
|
12
|
+
function noAmbient() {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
function leavesLexically(path) {
|
|
16
|
+
const normal = posix.normalize(path).replace(TRAILING_SLASHES, "");
|
|
17
|
+
return normal === ".." || normal.startsWith("../") || normal.includes("/");
|
|
18
|
+
}
|
|
19
|
+
// A relative import leaves its folder when its path climbs or descends, or when it names a folder whose index file lies below.
|
|
20
|
+
function departureAt(context, site) {
|
|
21
|
+
const path = site.specifier.replace(QUERY, "");
|
|
22
|
+
if (!RELATIVE.test(path)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const request = { kind: "import", line: site.line, specifier: site.specifier, type: site.type };
|
|
26
|
+
const target = resolveImport(context.root, noAmbient, request, context.from, context.project);
|
|
27
|
+
const local = target?.kind === "file" ? target.path : undefined;
|
|
28
|
+
if (!(leavesLexically(path) || (local !== undefined && posix.dirname(local) !== posix.dirname(context.path)))) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const owner = local === undefined ? undefined : packageOf(context.packages, local);
|
|
32
|
+
const workspace = owner !== undefined && owner !== packageOf(context.packages, context.path) ? owner.name : undefined;
|
|
33
|
+
return { ...site, file: context.path, installed: target?.kind === "external" ? target.package : undefined, workspace };
|
|
34
|
+
}
|
|
35
|
+
export async function departures(inputs) {
|
|
36
|
+
const root = realpathSync(inputs.root);
|
|
37
|
+
const projectOf = projectsFor(root);
|
|
38
|
+
const packages = await workspacePackages(inputs);
|
|
39
|
+
return inputs.sources.flatMap((file) => {
|
|
40
|
+
const syntax = MAY_BE_RELATIVE.test(file.text) ? parse(file) : undefined;
|
|
41
|
+
if (syntax === undefined) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const from = posix.join(root, file.path);
|
|
45
|
+
const context = { from, packages, path: file.path, project: projectOf(from), root };
|
|
46
|
+
return specifierSites(syntax).flatMap((site) => departureAt(context, site) ?? []);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=departures.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"departures.js","sourceRoot":"","sources":["../../src/imports/departures.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export interface SpecifierSite {
|
|
3
|
+
readonly line: number;
|
|
4
|
+
readonly specifier: string;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly type: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function specifierSites(source: ts.SourceFile): readonly SpecifierSite[];
|
|
9
|
+
//# sourceMappingURL=specifiers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specifiers.d.ts","sourceRoot":"","sources":["../../src/imports/specifiers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACvB;AAwED,wBAAgB,cAAc,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,GAAG,SAAS,aAAa,EAAE,CAc9E"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
const MOCKS = new Set(["doMock", "doUnmock", "importActual", "importMock", "mock", "unmock"]);
|
|
3
|
+
const MAY_CALL = /\b(?:import|require)\s*\(|\bvi\s*\.\s*(?:doMock|doUnmock|importActual|importMock|mock|unmock)\b/u;
|
|
4
|
+
function declared(node) {
|
|
5
|
+
if (ts.isImportDeclaration(node)) {
|
|
6
|
+
return { node: node.moduleSpecifier, type: node.importClause?.isTypeOnly === true };
|
|
7
|
+
}
|
|
8
|
+
if (ts.isExportDeclaration(node)) {
|
|
9
|
+
return { node: node.moduleSpecifier, type: node.isTypeOnly };
|
|
10
|
+
}
|
|
11
|
+
if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
|
12
|
+
return { node: node.moduleReference.expression, type: node.isTypeOnly };
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
function isMock(callee) {
|
|
17
|
+
return (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "vi" && MOCKS.has(callee.name.text));
|
|
18
|
+
}
|
|
19
|
+
function called(node) {
|
|
20
|
+
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
21
|
+
return { node: node.argument.literal, type: true };
|
|
22
|
+
}
|
|
23
|
+
if (!ts.isCallExpression(node)) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const callee = node.expression;
|
|
27
|
+
const loads = callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === "require") || isMock(callee);
|
|
28
|
+
return loads ? { node: node.arguments[0], type: false } : undefined;
|
|
29
|
+
}
|
|
30
|
+
function siteOf(source, found) {
|
|
31
|
+
const literal = found?.node;
|
|
32
|
+
if (found === undefined || literal === undefined || !ts.isStringLiteralLike(literal)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const start = literal.getStart(source);
|
|
36
|
+
return [{ line: source.getLineAndCharacterOfPosition(start).line + 1, specifier: literal.text, start, type: found.type }];
|
|
37
|
+
}
|
|
38
|
+
const JSDOC_IMPORT = /^[ \t]*(?:\/\*\*|\*)?[ \t]*@import\b[^"'`]*?\bfrom\s*(?<quote>["'])(?<path>[^"'\r\n]*)\k<quote>/gmu;
|
|
39
|
+
function siteAt(source, start, specifier) {
|
|
40
|
+
return { line: source.getLineAndCharacterOfPosition(start).line + 1, specifier, start, type: true };
|
|
41
|
+
}
|
|
42
|
+
// A node keeps only its last JSDoc block, so @import tags are read from every comment before it.
|
|
43
|
+
function jsDocImports(source, node, seen) {
|
|
44
|
+
return (ts.getLeadingCommentRanges(source.text, node.pos) ?? []).flatMap((range) => {
|
|
45
|
+
const comment = source.text.slice(range.pos, range.end);
|
|
46
|
+
if (seen.has(range.pos) || !comment.startsWith("/**")) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
seen.add(range.pos);
|
|
50
|
+
return [...comment.matchAll(JSDOC_IMPORT)].map((match) => {
|
|
51
|
+
const path = match.groups?.path ?? "";
|
|
52
|
+
return siteAt(source, range.pos + match.index + match[0].length - path.length - 1, path);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function specifierSites(source) {
|
|
57
|
+
const sites = [];
|
|
58
|
+
const nested = MAY_CALL.test(source.text);
|
|
59
|
+
const documented = source.text.includes("@import");
|
|
60
|
+
const comments = new Set();
|
|
61
|
+
function visit(node) {
|
|
62
|
+
sites.push(...siteOf(source, declared(node) ?? (nested ? called(node) : undefined)));
|
|
63
|
+
if (documented) {
|
|
64
|
+
sites.push(...jsDocImports(source, node, comments));
|
|
65
|
+
}
|
|
66
|
+
ts.forEachChild(node, visit);
|
|
67
|
+
}
|
|
68
|
+
visit(source);
|
|
69
|
+
return [...new Map(sites.map((site) => [site.start, site])).values()].sort((left, right) => left.start - right.start);
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=specifiers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specifiers.js","sourceRoot":"","sources":["../../src/imports/specifiers.ts"],"names":[],"mappings":"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"}
|
package/dist/rules/built-in.d.ts
CHANGED
|
@@ -17,5 +17,5 @@ export declare const builtInRules: readonly [import("../rule.ts").Rule<"structur
|
|
|
17
17
|
readonly fences?: readonly import("../index.ts").Fence[];
|
|
18
18
|
}>, import("../rule.ts").Rule<"imports/resolvable", {
|
|
19
19
|
readonly generated?: readonly string[];
|
|
20
|
-
}>, import("../rule.ts").Rule<"manifests/sorted", undefined>];
|
|
20
|
+
}>, import("../rule.ts").Rule<"imports/aliased", undefined>, import("../rule.ts").Rule<"manifests/sorted", undefined>];
|
|
21
21
|
//# sourceMappingURL=built-in.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"built-in.d.ts","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"built-in.d.ts","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;sHAkBf,CAAC"}
|
package/dist/rules/built-in.js
CHANGED
|
@@ -6,6 +6,7 @@ import { noJsdoc } from "./comments/no-jsdoc.js";
|
|
|
6
6
|
import { noLineReference } from "./comments/no-line-reference.js";
|
|
7
7
|
import { noPrReference } from "./comments/no-pr-reference.js";
|
|
8
8
|
import { noTodo } from "./comments/no-todo.js";
|
|
9
|
+
import { importsAliased } from "./imports/aliased.js";
|
|
9
10
|
import { importCycles } from "./imports/cycles.js";
|
|
10
11
|
import { importFences } from "./imports/fences.js";
|
|
11
12
|
import { importsResolvable } from "./imports/resolvable.js";
|
|
@@ -30,6 +31,7 @@ export const builtInRules = [
|
|
|
30
31
|
importCycles,
|
|
31
32
|
importFences,
|
|
32
33
|
importsResolvable,
|
|
34
|
+
importsAliased,
|
|
33
35
|
manifestsSorted,
|
|
34
36
|
];
|
|
35
37
|
//# sourceMappingURL=built-in.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"built-in.js","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAEvD,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,QAAQ;IACR,OAAO;IACP,eAAe;IACf,aAAa;IACb,QAAQ;IACR,MAAM;IACN,mBAAmB;IACnB,UAAU;IACV,QAAQ;IACR,YAAY;IACZ,cAAc;IACd,KAAK;IACL,YAAY;IACZ,YAAY;IACZ,iBAAiB;IACjB,eAAe;CACN,CAAC"}
|
|
1
|
+
{"version":3,"file":"built-in.js","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAEvD,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,QAAQ;IACR,OAAO;IACP,eAAe;IACf,aAAa;IACb,QAAQ;IACR,MAAM;IACN,mBAAmB;IACnB,UAAU;IACV,QAAQ;IACR,YAAY;IACZ,cAAc;IACd,KAAK;IACL,YAAY;IACZ,YAAY;IACZ,iBAAiB;IACjB,cAAc;IACd,eAAe;CACN,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aliased.d.ts","sourceRoot":"","sources":["../../../src/rules/imports/aliased.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,cAAc,4DAWzB,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { departures } from "../../imports/departures.js";
|
|
2
|
+
import { defineRule } from "../../rule.js";
|
|
3
|
+
function messageOf({ installed, specifier, workspace }) {
|
|
4
|
+
const leaves = `"${specifier}" leaves its folder.`;
|
|
5
|
+
if (installed !== undefined) {
|
|
6
|
+
return `${leaves} It reaches into the installed package ${installed}; import that package by its name.`;
|
|
7
|
+
}
|
|
8
|
+
if (workspace !== undefined) {
|
|
9
|
+
return `${leaves} It reaches into the workspace package ${workspace}; import it by that name, through a path its package.json "exports" lists.`;
|
|
10
|
+
}
|
|
11
|
+
return `${leaves} Import it through a "#…" alias from the "imports" of its package.json, and declare one there if none fits.`;
|
|
12
|
+
}
|
|
13
|
+
export const importsAliased = defineRule({
|
|
14
|
+
check: async (inputs) => (await departures(inputs)).map((departure) => ({
|
|
15
|
+
file: departure.file,
|
|
16
|
+
line: departure.line,
|
|
17
|
+
message: messageOf(departure),
|
|
18
|
+
subject: departure.specifier,
|
|
19
|
+
})),
|
|
20
|
+
description: "A relative import names only a file in its own folder. Every other import goes through an alias: the package.json `imports` of its package or another workspace package's name.",
|
|
21
|
+
id: "imports/aliased",
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=aliased.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aliased.js","sourceRoot":"","sources":["../../../src/rules/imports/aliased.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACzE,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE3C,SAAS,SAAS,CAAC,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAa;IAChE,MAAM,MAAM,GAAG,IAAI,SAAS,sBAAsB,CAAC;IACnD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,GAAG,MAAM,0CAA0C,SAAS,oCAAoC,CAAC;IACzG,CAAC;IACD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,GAAG,MAAM,0CAA0C,SAAS,4EAA4E,CAAC;IACjJ,CAAC;IACD,OAAO,GAAG,MAAM,6GAA6G,CAAC;AAC/H,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,UAAU,CAAC;IACxC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,CACvB,CAAC,MAAM,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAC9C,IAAI,EAAE,SAAS,CAAC,IAAI;QACpB,IAAI,EAAE,SAAS,CAAC,IAAI;QACpB,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC;QAC7B,OAAO,EAAE,SAAS,CAAC,SAAS;KAC5B,CAAC,CAAC;IACJ,WAAW,EACV,iLAAiL;IAClL,EAAE,EAAE,iBAAiB;CACrB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/quality",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Repository quality gate with typed rules, a shrink-only baseline and a registry of reasoned exceptions",
|
|
5
5
|
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/quality#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import type { RuleInputs } from "../rule.ts";
|
|
4
|
+
import { parse } from "../rules/syntax.ts";
|
|
5
|
+
import type { ImportRequest } from "./extract.ts";
|
|
6
|
+
import { type Project, projectsFor } from "./projects.ts";
|
|
7
|
+
import { resolveImport } from "./resolve.ts";
|
|
8
|
+
import { type SpecifierSite, specifierSites } from "./specifiers.ts";
|
|
9
|
+
import { packageOf, type WorkspacePackage, workspacePackages } from "./workspace.ts";
|
|
10
|
+
|
|
11
|
+
export interface Departure extends SpecifierSite {
|
|
12
|
+
readonly file: string;
|
|
13
|
+
readonly installed: string | undefined;
|
|
14
|
+
readonly workspace: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const QUERY = /\?.*$/u;
|
|
18
|
+
|
|
19
|
+
const RELATIVE = /^\.\.?(?:\/|$)/u;
|
|
20
|
+
|
|
21
|
+
const MAY_BE_RELATIVE = /["'`]\.\.?[/"'`]|\\/u;
|
|
22
|
+
|
|
23
|
+
const TRAILING_SLASHES = /\/+$/u;
|
|
24
|
+
|
|
25
|
+
function noAmbient(): boolean {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function leavesLexically(path: string): boolean {
|
|
30
|
+
const normal = posix.normalize(path).replace(TRAILING_SLASHES, "");
|
|
31
|
+
return normal === ".." || normal.startsWith("../") || normal.includes("/");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FileContext {
|
|
35
|
+
readonly from: string;
|
|
36
|
+
readonly packages: readonly WorkspacePackage[];
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly project: Project;
|
|
39
|
+
readonly root: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A relative import leaves its folder when its path climbs or descends, or when it names a folder whose index file lies below.
|
|
43
|
+
function departureAt(context: FileContext, site: SpecifierSite): Departure | undefined {
|
|
44
|
+
const path = site.specifier.replace(QUERY, "");
|
|
45
|
+
if (!RELATIVE.test(path)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const request: ImportRequest = { kind: "import", line: site.line, specifier: site.specifier, type: site.type };
|
|
49
|
+
const target = resolveImport(context.root, noAmbient, request, context.from, context.project);
|
|
50
|
+
const local = target?.kind === "file" ? target.path : undefined;
|
|
51
|
+
if (!(leavesLexically(path) || (local !== undefined && posix.dirname(local) !== posix.dirname(context.path)))) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const owner = local === undefined ? undefined : packageOf(context.packages, local);
|
|
55
|
+
const workspace = owner !== undefined && owner !== packageOf(context.packages, context.path) ? owner.name : undefined;
|
|
56
|
+
return { ...site, file: context.path, installed: target?.kind === "external" ? target.package : undefined, workspace };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function departures(inputs: RuleInputs): Promise<readonly Departure[]> {
|
|
60
|
+
const root = realpathSync(inputs.root);
|
|
61
|
+
const projectOf = projectsFor(root);
|
|
62
|
+
const packages = await workspacePackages(inputs);
|
|
63
|
+
return inputs.sources.flatMap((file) => {
|
|
64
|
+
const syntax = MAY_BE_RELATIVE.test(file.text) ? parse(file) : undefined;
|
|
65
|
+
if (syntax === undefined) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const from = posix.join(root, file.path);
|
|
69
|
+
const context = { from, packages, path: file.path, project: projectOf(from), root };
|
|
70
|
+
return specifierSites(syntax).flatMap((site) => departureAt(context, site) ?? []);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface SpecifierSite {
|
|
4
|
+
readonly line: number;
|
|
5
|
+
readonly specifier: string;
|
|
6
|
+
readonly start: number;
|
|
7
|
+
readonly type: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Found {
|
|
11
|
+
readonly node: ts.Node | undefined;
|
|
12
|
+
readonly type: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MOCKS = new Set(["doMock", "doUnmock", "importActual", "importMock", "mock", "unmock"]);
|
|
16
|
+
|
|
17
|
+
const MAY_CALL = /\b(?:import|require)\s*\(|\bvi\s*\.\s*(?:doMock|doUnmock|importActual|importMock|mock|unmock)\b/u;
|
|
18
|
+
|
|
19
|
+
function declared(node: ts.Node): Found | undefined {
|
|
20
|
+
if (ts.isImportDeclaration(node)) {
|
|
21
|
+
return { node: node.moduleSpecifier, type: node.importClause?.isTypeOnly === true };
|
|
22
|
+
}
|
|
23
|
+
if (ts.isExportDeclaration(node)) {
|
|
24
|
+
return { node: node.moduleSpecifier, type: node.isTypeOnly };
|
|
25
|
+
}
|
|
26
|
+
if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
|
27
|
+
return { node: node.moduleReference.expression, type: node.isTypeOnly };
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isMock(callee: ts.Expression): boolean {
|
|
33
|
+
return (
|
|
34
|
+
ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "vi" && MOCKS.has(callee.name.text)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function called(node: ts.Node): Found | undefined {
|
|
39
|
+
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
40
|
+
return { node: node.argument.literal, type: true };
|
|
41
|
+
}
|
|
42
|
+
if (!ts.isCallExpression(node)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const callee = node.expression;
|
|
46
|
+
const loads = callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === "require") || isMock(callee);
|
|
47
|
+
return loads ? { node: node.arguments[0], type: false } : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function siteOf(source: ts.SourceFile, found: Found | undefined): readonly SpecifierSite[] {
|
|
51
|
+
const literal = found?.node;
|
|
52
|
+
if (found === undefined || literal === undefined || !ts.isStringLiteralLike(literal)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const start = literal.getStart(source);
|
|
56
|
+
return [{ line: source.getLineAndCharacterOfPosition(start).line + 1, specifier: literal.text, start, type: found.type }];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const JSDOC_IMPORT = /^[ \t]*(?:\/\*\*|\*)?[ \t]*@import\b[^"'`]*?\bfrom\s*(?<quote>["'])(?<path>[^"'\r\n]*)\k<quote>/gmu;
|
|
60
|
+
|
|
61
|
+
function siteAt(source: ts.SourceFile, start: number, specifier: string): SpecifierSite {
|
|
62
|
+
return { line: source.getLineAndCharacterOfPosition(start).line + 1, specifier, start, type: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A node keeps only its last JSDoc block, so @import tags are read from every comment before it.
|
|
66
|
+
function jsDocImports(source: ts.SourceFile, node: ts.Node, seen: Set<number>): readonly SpecifierSite[] {
|
|
67
|
+
return (ts.getLeadingCommentRanges(source.text, node.pos) ?? []).flatMap((range) => {
|
|
68
|
+
const comment = source.text.slice(range.pos, range.end);
|
|
69
|
+
if (seen.has(range.pos) || !comment.startsWith("/**")) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
seen.add(range.pos);
|
|
73
|
+
return [...comment.matchAll(JSDOC_IMPORT)].map((match) => {
|
|
74
|
+
const path = match.groups?.path ?? "";
|
|
75
|
+
return siteAt(source, range.pos + match.index + match[0].length - path.length - 1, path);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function specifierSites(source: ts.SourceFile): readonly SpecifierSite[] {
|
|
81
|
+
const sites: SpecifierSite[] = [];
|
|
82
|
+
const nested = MAY_CALL.test(source.text);
|
|
83
|
+
const documented = source.text.includes("@import");
|
|
84
|
+
const comments = new Set<number>();
|
|
85
|
+
function visit(node: ts.Node): void {
|
|
86
|
+
sites.push(...siteOf(source, declared(node) ?? (nested ? called(node) : undefined)));
|
|
87
|
+
if (documented) {
|
|
88
|
+
sites.push(...jsDocImports(source, node, comments));
|
|
89
|
+
}
|
|
90
|
+
ts.forEachChild(node, visit);
|
|
91
|
+
}
|
|
92
|
+
visit(source);
|
|
93
|
+
return [...new Map(sites.map((site) => [site.start, site])).values()].sort((left, right) => left.start - right.start);
|
|
94
|
+
}
|
package/src/rules/built-in.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { noJsdoc } from "./comments/no-jsdoc.ts";
|
|
|
6
6
|
import { noLineReference } from "./comments/no-line-reference.ts";
|
|
7
7
|
import { noPrReference } from "./comments/no-pr-reference.ts";
|
|
8
8
|
import { noTodo } from "./comments/no-todo.ts";
|
|
9
|
+
import { importsAliased } from "./imports/aliased.ts";
|
|
9
10
|
import { importCycles } from "./imports/cycles.ts";
|
|
10
11
|
import { importFences } from "./imports/fences.ts";
|
|
11
12
|
import { importsResolvable } from "./imports/resolvable.ts";
|
|
@@ -31,5 +32,6 @@ export const builtInRules = [
|
|
|
31
32
|
importCycles,
|
|
32
33
|
importFences,
|
|
33
34
|
importsResolvable,
|
|
35
|
+
importsAliased,
|
|
34
36
|
manifestsSorted,
|
|
35
37
|
] as const;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Departure, departures } from "../../imports/departures.ts";
|
|
2
|
+
import { defineRule } from "../../rule.ts";
|
|
3
|
+
|
|
4
|
+
function messageOf({ installed, specifier, workspace }: Departure): string {
|
|
5
|
+
const leaves = `"${specifier}" leaves its folder.`;
|
|
6
|
+
if (installed !== undefined) {
|
|
7
|
+
return `${leaves} It reaches into the installed package ${installed}; import that package by its name.`;
|
|
8
|
+
}
|
|
9
|
+
if (workspace !== undefined) {
|
|
10
|
+
return `${leaves} It reaches into the workspace package ${workspace}; import it by that name, through a path its package.json "exports" lists.`;
|
|
11
|
+
}
|
|
12
|
+
return `${leaves} Import it through a "#…" alias from the "imports" of its package.json, and declare one there if none fits.`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const importsAliased = defineRule({
|
|
16
|
+
check: async (inputs) =>
|
|
17
|
+
(await departures(inputs)).map((departure) => ({
|
|
18
|
+
file: departure.file,
|
|
19
|
+
line: departure.line,
|
|
20
|
+
message: messageOf(departure),
|
|
21
|
+
subject: departure.specifier,
|
|
22
|
+
})),
|
|
23
|
+
description:
|
|
24
|
+
"A relative import names only a file in its own folder. Every other import goes through an alias: the package.json `imports` of its package or another workspace package's name.",
|
|
25
|
+
id: "imports/aliased",
|
|
26
|
+
});
|