150 lines
6.9 KiB
TypeScript
150 lines
6.9 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The kit's markers stay where the import sorter cannot move other code into them.
|
|
//
|
|
// `kit template:prune` removes every line that carries a `// kit:<name>` comment and every line between
|
|
// `// kit:<name>:begin` and `// kit:<name>:end`. Biome's import sorter (`organizeImports`) reorders an import
|
|
// section across comments and blank lines, and merges two imports of one source:
|
|
//
|
|
// - an import inside a begin/end block can trade places with an app's own import, which the prune then
|
|
// deletes without a word; so no block holds an import or a re-export;
|
|
// - a whole-line marker survives the sort only on a one-line import the sorter cannot merge: it merges two
|
|
// value imports (or two type-only imports, or two re-exports) of one source into one and keeps neither
|
|
// line's comment. A namespace import is never merged.
|
|
// A module's imports are one namespace import (`import * as widgets from "@app/modules/widgets" //
|
|
// kit:widgets`), or one marked name inside a shared import (`filesDeps, // kit:widgets`), which the
|
|
// sorter keeps with its name.
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
import { join, relative } from "node:path"
|
|
import ts from "typescript"
|
|
import { describe, expect, it } from "vitest"
|
|
|
|
const root = new URL("..", import.meta.url).pathname
|
|
const FOLDERS = ["apps", "config", "modules", "test", "e2e", "evals", "scripts"]
|
|
const SKIP = new Set(["node_modules", "dist", ".tanstack", "generated", ".logs"])
|
|
|
|
function sources(dir: string): string[] {
|
|
if (!existsSync(dir)) return []
|
|
return readdirSync(dir).flatMap((name) => {
|
|
const path = join(dir, name)
|
|
if (statSync(path).isDirectory()) return SKIP.has(name) ? [] : sources(path)
|
|
return /\.(?:ts|tsx|mts|cts|mjs|cjs|js)$/.test(name) && !name.endsWith(".gen.ts") ? [path] : []
|
|
})
|
|
}
|
|
|
|
const BEGIN = /\/\/\s*kit:([\w-]+):begin\b/
|
|
const END = /\/\/\s*kit:([\w-]+):end\b/
|
|
const LINE_MARK = /(?:\/\/|\/\*|\{\/\*)\s*kit:[\w-]+\b(?!:)/
|
|
|
|
interface Statement {
|
|
readonly first: number
|
|
readonly last: number
|
|
readonly source: string
|
|
/** What the sorter merges with what: value imports, type-only imports and re-exports, each among their own. */
|
|
readonly kind: "value" | "type" | "export"
|
|
/** `import * as x`: never merged with another import. */
|
|
readonly namespace: boolean
|
|
}
|
|
|
|
/** The file's static imports and `export … from`, with their 1-based first and last lines. */
|
|
function importsOf(file: string, text: string): Statement[] {
|
|
const parsed = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false)
|
|
const out: Statement[] = []
|
|
for (const node of parsed.statements) {
|
|
const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier : undefined
|
|
if (!specifier || !ts.isStringLiteral(specifier)) continue
|
|
const first = parsed.getLineAndCharacterOfPosition(node.getStart(parsed)).line + 1
|
|
const last = parsed.getLineAndCharacterOfPosition(node.getEnd()).line + 1
|
|
const clause = ts.isImportDeclaration(node) ? node.importClause : undefined
|
|
const kind = ts.isExportDeclaration(node) ? "export" : clause?.isTypeOnly ? "type" : "value"
|
|
const namespace = clause?.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)
|
|
out.push({ first, last, source: specifier.text, kind, namespace })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Whether the import sorter would merge the two statements into one. */
|
|
const mergeable = (a: Statement, b: Statement): boolean =>
|
|
a !== b && a.source === b.source && a.kind === b.kind && !a.namespace && !b.namespace
|
|
|
|
/** `file:line: why` for each marker the import sorter can break. */
|
|
function markerProblems(file: string, text: string): string[] {
|
|
const lines = text.split("\n")
|
|
const imports = importsOf(file, text)
|
|
const where = (line: number) => `${file}:${line}`
|
|
const problems: string[] = []
|
|
|
|
const open: { name: string; line: number }[] = []
|
|
lines.forEach((line, index) => {
|
|
const begin = BEGIN.exec(line)
|
|
const end = END.exec(line)
|
|
if (begin) open.push({ name: begin[1] as string, line: index + 1 })
|
|
else if (end) {
|
|
const at = open.findLastIndex((block) => block.name === end[1])
|
|
if (at < 0) return
|
|
const [block] = open.splice(at, 1)
|
|
for (const statement of imports) {
|
|
if (block && statement.last >= block.line && statement.first <= index + 1) {
|
|
problems.push(
|
|
`${where(statement.first)}: an import of "${statement.source}" inside the kit:${block.name} block of line ${block.line}; the import sorter can move an app's own import into the block, and the prune would delete it`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
for (const statement of imports) {
|
|
for (let line = statement.first; line <= statement.last; line++) {
|
|
if (!LINE_MARK.test(lines[line - 1] ?? "")) continue
|
|
const multiLine = statement.first !== statement.last
|
|
if (multiLine && (line === statement.first || line === statement.last)) {
|
|
problems.push(
|
|
`${where(line)}: a marker on the ${line === statement.first ? "first" : "last"} line of a multi-line import of "${statement.source}"; mark the names inside it, or make it one line`,
|
|
)
|
|
} else if (!multiLine && !statement.namespace && imports.some((other) => mergeable(statement, other))) {
|
|
problems.push(
|
|
`${where(line)}: a marked import of "${statement.source}" beside another import of it; the import sorter merges them and drops the marker. Mark the names inside one import`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
describe("the kit's markers and the import sorter", () => {
|
|
it("no marker in the template's sources can be broken by sorting or merging imports", () => {
|
|
const problems = FOLDERS.flatMap((folder) => sources(join(root, folder))).flatMap((path) =>
|
|
markerProblems(relative(root, path), readFileSync(path, "utf8")),
|
|
)
|
|
expect(problems).toEqual([])
|
|
})
|
|
|
|
it("names an import inside a block, a marker on the edge of a multi-line import and a mergeable marked import", () => {
|
|
const text = [
|
|
'import { a } from "@app/modules/account"',
|
|
"// kit:widgets:begin",
|
|
'import { w } from "@app/modules/widgets"',
|
|
"// kit:widgets:end",
|
|
"import {",
|
|
" d,",
|
|
'} from "@app/modules/gadgets" // kit:gadgets',
|
|
'import { f1 } from "@conusai-kit/files"',
|
|
'import { f2 } from "@conusai-kit/files" // kit:widgets',
|
|
"import {",
|
|
" g1, // kit:widgets",
|
|
" g2,",
|
|
'} from "@conusai-kit/data"',
|
|
'import * as gizmos from "@conusai-kit/gizmos" // kit:gizmos',
|
|
'import { s } from "@conusai-kit/gizmos"',
|
|
'import type { T } from "@conusai-kit/auth"',
|
|
'import { load } from "@conusai-kit/auth" // kit:sprockets',
|
|
"export const all = [a, w, d, f1, f2, g1, g2, gizmos, s, load]",
|
|
].join("\n")
|
|
expect(markerProblems("x.ts", text).map((problem) => problem.split(": ")[0])).toEqual([
|
|
"x.ts:3",
|
|
"x.ts:7",
|
|
"x.ts:9",
|
|
])
|
|
})
|
|
})
|