@@ -0,0 +1,149 @@
|
||||
// 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",
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user