Files
TREK/server/scripts/dialect-lint.mjs
T
Maurice 1d6719347b build(server): count the bare datetime name the query builder takes
The datetime rule only matched a literal holding a call such as
datetime('now'). The query builder spells the same function as
eb.fn('datetime', [...]), where the literal is just the name, so that form
slipped through while strftime, json_extract and glob are caught bare. A
literal that is exactly datetime, julianday or unixepoch now counts as well,
the same way the glob rule treats a bare glob. The only such call lives in the
dialect layer, which is not scanned, so the baseline does not change.
2026-10-08 18:39:34 +02:00

314 lines
12 KiB
JavaScript

#!/usr/bin/env node
/*
* lint:dialect: SQLite-only SQL may only shrink.
*
* The server runs on SQLite today and is meant to run on Postgres next. What
* stands in the way is SQL that only SQLite understands, spelled out inline:
* INSERT OR IGNORE, datetime('now'), strftime, json_extract, GLOB,
* last_insert_rowid, PRAGMA, AUTOINCREMENT and `||` string concatenation.
* Each of them has a dialect-neutral form: the query builder's onConflict(),
* returning('id'), or a helper in src/db/dialect/sql-functions.ts, which
* dispatches on the live platform and is the one file allowed to spell the
* SQLite forms.
*
* The check reads every string and template literal under src/ (comments
* never count) and every `.insertId` read, counts the hits per file and rule,
* and holds them to scripts/dialect-baseline.json. A new hit fails, and so
* does a baseline entry above what its file holds now, so the change that
* removes one lowers the baseline with --update in the same commit.
*
* Not scanned: src/db/dialect/sql-functions.ts (the dialect layer itself) and
* the migrations that have shipped (append-only, never edited again; the last
* one is FROZEN_MIGRATIONS_THROUGH). A newer migration is scanned for the
* DML rules; its DDL may still be engine specific. PRAGMA is allowed in the
* src/db/ lifecycle files (src/db/*.ts and the migrations), nowhere else.
*
* npm run lint:dialect check against the baseline (CI)
* npm run lint:dialect -- --update lower the baseline to what the files hold now;
* it never raises or adds an entry
*
* --dir=<path> points the check at another server root (the unit tests use it).
*/
import { createRequire } from 'node:module';
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const ts = require('typescript');
/** The last migration that shipped before this check existed. Later ones are scanned. */
export const FROZEN_MIGRATIONS_THROUGH = 'Migration20200101042400';
/** The dialect layer: the one file that may spell SQLite's own SQL. */
const DIALECT_LAYER = 'src/db/dialect/sql-functions.ts';
const SQL_KEYWORDS = /\b(SELECT|UPDATE|INSERT|DELETE|WHERE|SET|CASE|WHEN|COALESCE|CAST|VALUES|FROM)\b/i;
/**
* Each rule: a name, a test over one literal's text, the advice the failure
* prints, and whether it applies to a migration newer than the frozen ones.
*/
export const RULES = [
{
name: 'insert-or',
test: (text) => /\bINSERT\s+OR\s+(IGNORE|REPLACE)\b|\bREPLACE\s+INTO\b/i.test(text),
advice: "use the query builder's onConflict((oc) => oc.columns([...]).doNothing() / .doUpdateSet(...))",
migrations: true,
},
{
name: 'datetime',
// The call in SQL text, or the bare name the query builder takes in eb.fn('datetime', [...]).
test: (text) =>
/\b(datetime|julianday|unixepoch)\s*\(/i.test(text) || /^(datetime|julianday|unixepoch)$/i.test(text.trim()),
advice: 'use currentTimestamp/nowMinusDays/nowPlusSeconds and friends from sql-functions.ts',
migrations: true,
},
{
name: 'strftime',
test: (text) => /\bstrftime\b/i.test(text),
advice: 'use unixEpochToIsoKysely or another sql-functions.ts helper',
migrations: true,
},
{
name: 'json',
test: (text) => /\bjson_(extract|each|set|patch|insert|replace|remove|group_array|group_object)\b/i.test(text),
advice: 'read the JSON column and parse it in the service, or add a helper to sql-functions.ts',
migrations: true,
},
{
name: 'glob',
test: (text) => /\bGLOB\b/.test(text) || text.trim().toLowerCase() === 'glob',
advice: 'use LIKE, or startsWithIsoDate and its siblings in sql-functions.ts',
migrations: true,
},
{
name: 'last-insert-rowid',
test: (text) => /\blast_insert_rowid\b/i.test(text),
advice: "read the new id with .returning('id')",
migrations: true,
},
{
name: 'autoincrement',
test: (text) => /\bAUTOINCREMENT\b/i.test(text),
advice: 'tables are created by migrations, not by query-builder code',
migrations: false,
},
{
name: 'pragma',
// The statement, `PRAGMA name`: the bare word is also an HTTP header.
test: (text) => /\bPRAGMA\s+[a-z_]/i.test(text),
advice: 'PRAGMA belongs in the src/db/ lifecycle files (connection, durability, migrations)',
migrations: false,
},
{
name: 'concat',
test: (text, inSqlTag) => text.includes('||') && (inSqlTag || SQL_KEYWORDS.test(text)),
advice: 'use concat/concatKysely from sql-functions.ts',
migrations: true,
},
];
/** `.insertId` reads, the JS face of last_insert_rowid. */
const INSERT_ID_RULE = 'insert-id';
const SOURCE = /\.ts$/;
const TEST = /\.(?:test|spec)\./;
/** 'skip', 'migration' (a newer one, DML rules only) or 'source'. */
export function classify(file) {
if (file === DIALECT_LAYER) return 'skip';
const migration = /^src\/db\/migrations\/(Migration\d+)_/.exec(file);
if (migration) return migration[1] <= FROZEN_MIGRATIONS_THROUGH ? 'skip' : 'migration';
if (file.startsWith('src/db/migrations/')) return 'skip';
return 'source';
}
/** PRAGMA is fine in src/db/*.ts (connection, durability, baseline, snapshot) and the migrations. */
export function pragmaAllowed(file) {
return /^src\/db\/[^/]+\.ts$/.test(file) || file.startsWith('src/db/migrations/');
}
function isSqlTag(node) {
let tag = node.tag;
if (ts.isPropertyAccessExpression(tag)) tag = tag.expression;
return ts.isIdentifier(tag) && tag.text === 'sql';
}
/** Hits per rule in one file's source. */
export function scanSource(file, text) {
const kind = classify(file);
const counts = {};
if (kind === 'skip') return counts;
const add = (rule) => {
counts[rule] = (counts[rule] ?? 0) + 1;
};
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const checkText = (literal, inSqlTag) => {
for (const rule of RULES) {
if (kind === 'migration' && !rule.migrations) continue;
if (rule.name === 'pragma' && pragmaAllowed(file)) continue;
if (rule.test(literal, inSqlTag)) add(rule.name);
}
};
const visit = (node, inSqlTag) => {
if (ts.isTaggedTemplateExpression(node) && isSqlTag(node)) {
ts.forEachChild(node, (child) => visit(child, true));
return;
}
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
checkText(node.text, inSqlTag);
} else if (ts.isTemplateExpression(node)) {
// The literal parts only: an interpolated value is code, scanned on its own.
checkText([node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(' '), inSqlTag);
} else if (ts.isPropertyAccessExpression(node) && node.name.text === 'insertId') {
add(INSERT_ID_RULE);
} else if (
ts.isBindingElement(node) &&
((node.propertyName && ts.isIdentifier(node.propertyName) && node.propertyName.text === 'insertId') ||
(!node.propertyName && ts.isIdentifier(node.name) && node.name.text === 'insertId'))
) {
add(INSERT_ID_RULE);
}
ts.forEachChild(node, (child) => visit(child, inSqlTag));
};
visit(sf, false);
return counts;
}
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, files);
else if (SOURCE.test(name) && !TEST.test(name) && !name.endsWith('.d.ts')) files.push(path);
}
return files;
}
/** `{ file: { rule: n } }` for every file under src/ with at least one hit. */
export function scan(serverDir) {
const src = join(serverDir, 'src');
if (!existsSync(src) || !statSync(src).isDirectory()) {
throw new Error(`src/ does not exist under ${serverDir}: the check would pass without looking at anything`);
}
const result = {};
for (const path of walk(src)) {
const file = relative(serverDir, path).split('\\').join('/');
const counts = scanSource(file, readFileSync(path, 'utf8'));
if (Object.keys(counts).length > 0) result[file] = sortKeys(counts);
}
return sortKeys(result);
}
function sortKeys(obj) {
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
}
const RULE_NAMES = new Set([...RULES.map((r) => r.name), INSERT_ID_RULE]);
/** The committed baseline. Missing or malformed stops the run rather than reading as empty. */
export function readBaseline(path) {
let parsed;
try {
parsed = JSON.parse(readFileSync(path, 'utf8'));
} catch (err) {
throw new Error(`scripts/dialect-baseline.json cannot be read: ${err.message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('scripts/dialect-baseline.json must be an object of file paths to { rule: count }');
}
for (const [file, rules] of Object.entries(parsed)) {
if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
throw new Error(`scripts/dialect-baseline.json: ${file} must map rule names to counts`);
}
for (const [rule, n] of Object.entries(rules)) {
if (!RULE_NAMES.has(rule)) throw new Error(`scripts/dialect-baseline.json: ${file} names an unknown rule "${rule}"`);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`scripts/dialect-baseline.json: ${file} ${rule} holds ${JSON.stringify(n)}, expected a positive integer`);
}
}
}
return parsed;
}
/** The baseline lowered to what the files hold now. Never raises an entry, never adds one. */
export function lowerBaseline(baseline, counts) {
const lowered = {};
for (const [file, rules] of Object.entries(baseline)) {
const kept = {};
for (const [rule, allowed] of Object.entries(rules)) {
const now = counts[file]?.[rule] ?? 0;
if (now > 0) kept[rule] = Math.min(allowed, now);
}
if (Object.keys(kept).length > 0) lowered[file] = sortKeys(kept);
}
return sortKeys(lowered);
}
/** New hits over the baseline, and baseline entries above what their file holds now. */
export function compare(baseline, counts) {
const grown = [];
for (const [file, rules] of Object.entries(counts)) {
for (const [rule, n] of Object.entries(rules)) {
const allowed = baseline[file]?.[rule] ?? 0;
if (n > allowed) grown.push({ file, rule, n, allowed });
}
}
const lowered = lowerBaseline(baseline, counts);
const stale = [];
for (const [file, rules] of Object.entries(baseline)) {
for (const [rule, allowed] of Object.entries(rules)) {
const now = lowered[file]?.[rule] ?? 0;
if (now !== allowed) stale.push({ file, rule, allowed, now: counts[file]?.[rule] ?? 0 });
}
}
return { grown, stale };
}
function adviceFor(rule) {
if (rule === INSERT_ID_RULE) return "read the new id with .returning('id'); insertId is undefined on Postgres";
return RULES.find((r) => r.name === rule).advice;
}
function main(argv) {
const dirArg = argv.find((a) => a.startsWith('--dir='));
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
const baselinePath = join(serverDir, 'scripts', 'dialect-baseline.json');
const update = argv.includes('--update');
const counts = scan(serverDir);
let baseline = readBaseline(baselinePath);
if (update) {
baseline = lowerBaseline(baseline, counts);
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
}
const { grown, stale } = compare(baseline, counts);
for (const { file, rule, n, allowed } of grown) {
console.error(`FAIL ${file}: ${n} SQLite-only ${rule} hit(s), ${allowed} allowed. ${adviceFor(rule)}.`);
}
for (const { file, rule, allowed, now } of stale) {
console.error(`FAIL ${file} is held at ${allowed} ${rule} hit(s) in scripts/dialect-baseline.json, but has ${now} now.`);
}
if (stale.length) {
console.error('Run npm run lint:dialect -- --update to lower the baseline with the change that removed them.');
}
const total = Object.values(baseline).reduce((sum, rules) => sum + Object.values(rules).reduce((a, b) => a + b, 0), 0);
console.log(`dialect: ${Object.keys(baseline).length} file(s) hold ${total} SQLite-only spelling(s) at their baseline`);
return grown.length || stale.length ? 1 : 0;
}
const isCli =
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
process.exitCode = main(process.argv.slice(2));
} catch (err) {
console.error(`FAIL ${err.message}`);
process.exitCode = 1;
}
}