@codesema/speccov 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // Runs speccov on the runtime the project already has.
3
+ //
4
+ // The rules pass imports the user's TypeScript, and this package ships its own sources as
5
+ // they are. Bun runs TypeScript by itself. Node only strips the types, and stops at the
6
+ // first import without an extension, the first path alias, the first enum or decorator.
7
+ // So under Node the TypeScript compiler speccov already depends on does that work, file
8
+ // by file, with the options of the tsconfig nearest to each file.
9
+ import { readFileSync } from 'node:fs';
10
+ import nodeModule from 'node:module';
11
+ import { dirname, sep } from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+ import ts from 'typescript';
14
+
15
+ const TYPESCRIPT_FILE = /\.[cm]?tsx?$/u;
16
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/u;
17
+
18
+ // Reading a tsconfig also lists the files it includes. Only its options matter here.
19
+ const configHost = { ...ts.sys, readDirectory: () => [], onUnRecoverableConfigFileDiagnostic: () => {} };
20
+ const optionsByConfig = new Map();
21
+
22
+ function optionsFor(fileName) {
23
+ // A dependency was not written under the project's tsconfig: it gets the defaults.
24
+ const config = fileName.includes(`${sep}node_modules${sep}`)
25
+ ? undefined
26
+ : ts.findConfigFile(dirname(fileName), ts.sys.fileExists);
27
+ const key = config ?? '';
28
+ let options = optionsByConfig.get(key);
29
+ if (options === undefined) {
30
+ const parsed = config === undefined ? undefined : ts.getParsedCommandLineOfConfigFile(config, undefined, configHost);
31
+ // TODO(ceiling): every file comes out as an ES module, so a project that relies on
32
+ // `require` or `__dirname` in its TypeScript does not load: emit CommonJS for those.
33
+ options = {
34
+ ...parsed?.options,
35
+ module: ts.ModuleKind.ESNext,
36
+ importHelpers: false,
37
+ sourceMap: false,
38
+ inlineSourceMap: true,
39
+ inlineSources: true,
40
+ };
41
+ optionsByConfig.set(key, options);
42
+ }
43
+ return options;
44
+ }
45
+
46
+ function resolve(specifier, context, nextResolve) {
47
+ try {
48
+ return nextResolve(specifier, context);
49
+ } catch (error) {
50
+ if (context.parentURL?.startsWith('file:') !== true) {
51
+ throw error;
52
+ }
53
+ const parent = fileURLToPath(context.parentURL);
54
+ const found = ts.resolveModuleName(specifier, parent, optionsFor(parent), ts.sys).resolvedModule;
55
+ if (found === undefined || DECLARATION_FILE.test(found.resolvedFileName)) {
56
+ throw error;
57
+ }
58
+ return { url: pathToFileURL(found.resolvedFileName).href, shortCircuit: true };
59
+ }
60
+ }
61
+
62
+ function load(url, context, nextLoad) {
63
+ if (!url.startsWith('file:') || !TYPESCRIPT_FILE.test(url)) {
64
+ return nextLoad(url, context);
65
+ }
66
+ const fileName = fileURLToPath(url);
67
+ const { outputText } = ts.transpileModule(readFileSync(fileName, 'utf8'), {
68
+ compilerOptions: optionsFor(fileName),
69
+ fileName,
70
+ });
71
+ return { format: 'module', source: outputText, shortCircuit: true };
72
+ }
73
+
74
+ if (process.versions.bun === undefined) {
75
+ // The inlined source maps keep an error raised by a rule on the line the user wrote.
76
+ process.setSourceMapsEnabled(true);
77
+ nodeModule.registerHooks({ resolve, load });
78
+ }
79
+
80
+ await import('../src/cli.ts');
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
- "name": "@codesema/speccov",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@codesema/speccov",
3
+ "version": "0.2.1",
4
+ "description": "Finds the behaviours no rule guarantees, and puts them as questions.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Hasan TASKIN",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/getCodesema/speccov.git"
11
+ },
12
+ "keywords": [
13
+ "ast",
14
+ "codesema",
15
+ "hardening",
16
+ "n-plus-one",
17
+ "property-based-testing",
18
+ "static-analysis",
19
+ "typescript"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.15.0",
23
+ "bun": ">=1.3.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "bin": {
29
+ "speccov": "bin/speccov.mjs"
30
+ },
31
+ "exports": {
32
+ ".": "./src/index.ts",
33
+ "./typeorm": "./src/adapters/typeorm.ts"
34
+ },
35
+ "files": [
36
+ "bin",
37
+ "src",
38
+ "!src/**/*.test.ts",
39
+ "!src/scan/virtual-program.ts"
40
+ ],
41
+ "scripts": {
42
+ "prepare": "lefthook install || echo \"WARNING: lefthook install failed — git hooks are NOT active (no secret scan, no format or lint on commit). Run `bun run prepare` again once git and lefthook are available.\"",
43
+ "test": "bun test",
44
+ "typecheck": "tsc --noEmit",
45
+ "lint": "oxlint",
46
+ "format": "prettier --write . --cache",
47
+ "format:check": "prettier --check . --cache",
48
+ "verify": "bun run typecheck && bun run lint && bun test"
49
+ },
50
+ "dependencies": {
51
+ "typescript": "^6.0.3"
52
+ },
53
+ "devDependencies": {
54
+ "@commitlint/cli": "^21.2.3",
55
+ "@commitlint/config-conventional": "^21.2.3",
56
+ "@types/bun": "latest",
57
+ "better-sqlite3": "^13.0.3",
58
+ "lefthook": "^2.2.2",
59
+ "oxlint": "^1.88.0",
60
+ "pg": "^8.23.1",
61
+ "prettier": "^3.9.10",
62
+ "typeorm": "^1.1.2"
63
+ }
64
+ }
@@ -0,0 +1,90 @@
1
+ import type { Counter } from '../counter';
2
+
3
+ /**
4
+ * Structural subset of TypeORM's `Logger` interface. Declared here rather than
5
+ * imported: speccov depends on no ORM.
6
+ */
7
+ export type TypeOrmLogger = {
8
+ logQuery(query: string, parameters?: unknown[], queryRunner?: unknown): void;
9
+ logQueryError(error: string | Error, query: string, parameters?: unknown[], queryRunner?: unknown): void;
10
+ logQuerySlow(time: number, query: string, parameters?: unknown[], queryRunner?: unknown): void;
11
+ logSchemaBuild(message: string, queryRunner?: unknown): void;
12
+ logMigration(message: string, queryRunner?: unknown): void;
13
+ log(level: 'log' | 'info' | 'warn', message: unknown, queryRunner?: unknown): void;
14
+ };
15
+
16
+ export type CountingLoggerOptions = {
17
+ /** Decides whether a query counts. Defaults to {@link isDataQuery}. */
18
+ readonly counts?: (query: string) => boolean;
19
+ };
20
+
21
+ /** Transaction boundaries: TypeORM hands them to the logger like any other query. */
22
+ const TRANSACTION_CONTROL = ['START TRANSACTION', 'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE'];
23
+
24
+ /**
25
+ * Connection introspection functions. TypeORM calls them in several shapes
26
+ * (`SELECT version()`, `SELECT current_schema() AS "currentSchema"`, `SELECT * FROM
27
+ * current_schema()`): they are recognised by the function, not by the whole query.
28
+ */
29
+ const CONNECTION_FUNCTIONS = ['VERSION()', 'CURRENT_SCHEMA()', 'CURRENT_DATABASE()'];
30
+
31
+ /** `FROM pg_tables`, `JOIN pg_namespace`… Postgres reserves the `pg_` prefix: never a business table. */
32
+ const SYSTEM_RELATION = /\s(?:FROM|JOIN)\s+PG_/u;
33
+
34
+ /** A query that reads no table, or only a system function. */
35
+ const NO_USER_TABLE = /\sFROM\s+(?!CURRENT_)/u;
36
+
37
+ /** Schema definition verbs: `synchronize` and the migrations, not the business code. */
38
+ const SCHEMA_VERBS = ['CREATE ', 'ALTER ', 'DROP ', 'TRUNCATE '];
39
+
40
+ /** Introspection catalogues, read by `synchronize`. */
41
+ const CATALOGS = ['INFORMATION_SCHEMA.', 'OBJ_DESCRIPTION('];
42
+
43
+ /**
44
+ * True if the query is a read or a write of data, and not of infrastructure.
45
+ *
46
+ * Deliberately conservative: under-counting would *miss* N+1s, and that is the dangerous
47
+ * direction. When in doubt, a query counts.
48
+ */
49
+ export function isDataQuery(query: string): boolean {
50
+ // Quotes are stripped: TypeORM writes `"information_schema"."tables"`, and the dot is
51
+ // then no longer flush against the catalogue name.
52
+ const normalised = query.trim().replaceAll(/\s+/gu, ' ').replaceAll('"', '').toUpperCase();
53
+ if (TRANSACTION_CONTROL.some((keyword) => normalised === keyword || normalised.startsWith(`${keyword} `))) {
54
+ return false;
55
+ }
56
+ const callsConnectionFunction = CONNECTION_FUNCTIONS.some((name) => normalised.includes(name));
57
+ if (callsConnectionFunction && !NO_USER_TABLE.test(normalised)) {
58
+ return false;
59
+ }
60
+ if (SCHEMA_VERBS.some((verb) => normalised.startsWith(verb))) {
61
+ return false;
62
+ }
63
+ if (SYSTEM_RELATION.test(normalised)) {
64
+ return false;
65
+ }
66
+ return !CATALOGS.some((catalog) => normalised.includes(catalog));
67
+ }
68
+
69
+ /**
70
+ * A TypeORM logger that does nothing but count. Pass it as the DataSource `logger` option.
71
+ *
72
+ * `logQueryError` counts too: a query that fails did reach the database, it weighs as much as
73
+ * any other in an N+1.
74
+ */
75
+ export function countingLogger(counter: Counter, options: CountingLoggerOptions = {}): TypeOrmLogger {
76
+ const counts = options.counts ?? isDataQuery;
77
+ const hit = (query: string): void => {
78
+ if (counts(query)) {
79
+ counter.hit();
80
+ }
81
+ };
82
+ return {
83
+ logQuery: (query) => hit(query),
84
+ logQueryError: (_error, query) => hit(query),
85
+ logQuerySlow: () => {},
86
+ logSchemaBuild: () => {},
87
+ logMigration: () => {},
88
+ log: () => {},
89
+ };
90
+ }
package/src/answers.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { EPSILON } from './growth';
4
+ import type { Lacuna, Verdict } from './rule';
5
+
6
+ /**
7
+ * A human answer to a gap, made mechanisable.
8
+ *
9
+ * Accepting is writing a rule: `magnitude` is the accepted threshold. Past it, this is no
10
+ * longer the question someone answered — it is a violation.
11
+ */
12
+ export type Answer = {
13
+ readonly rule: string;
14
+ readonly magnitude: number;
15
+ /** What was being measured at the moment of acceptance. */
16
+ readonly detail: string;
17
+ /** Date of the acceptance. */
18
+ readonly at: string;
19
+ /** Why it is intended. Optional, but it is what gets reread in six months. */
20
+ readonly why?: string;
21
+ };
22
+
23
+ export const DEFAULT_ANSWERS_PATH = '.speccov/answers.json';
24
+
25
+ type AnswerFile = {
26
+ readonly version: 1;
27
+ readonly answers: readonly Answer[];
28
+ };
29
+
30
+ /**
31
+ * Loads the answers. A missing file is normal — it is the first pass.
32
+ *
33
+ * An unreadable file **throws**: taking it for an empty file would make everything noisy,
34
+ * taking it for "everything accepted" would make it blind. Both are worse than a halt.
35
+ */
36
+ export async function loadAnswers(path: string): Promise<readonly Answer[]> {
37
+ let raw: string;
38
+ try {
39
+ raw = await readFile(path, 'utf8');
40
+ } catch (cause) {
41
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT') {
42
+ return [];
43
+ }
44
+ throw cause;
45
+ }
46
+ return parseAnswers(JSON.parse(raw), path);
47
+ }
48
+
49
+ export async function saveAnswers(path: string, answers: readonly Answer[]): Promise<void> {
50
+ const file: AnswerFile = { version: 1, answers: [...answers].sort((left, right) => left.rule.localeCompare(right.rule)) };
51
+ await mkdir(dirname(path), { recursive: true });
52
+ await writeFile(path, `${JSON.stringify(file, null, 4)}\n`, 'utf8');
53
+ }
54
+
55
+ export function parseAnswers(value: unknown, path: string): readonly Answer[] {
56
+ const file = value as Partial<AnswerFile> | null;
57
+ if (file === null || typeof file !== 'object' || file.version !== 1 || !Array.isArray(file.answers)) {
58
+ throw new Error(`speccov: ${path} is not a valid answers file (expected { version: 1, answers: [] }).`);
59
+ }
60
+ return file.answers.map((answer, index) => parseAnswer(answer, path, index));
61
+ }
62
+
63
+ function parseAnswer(value: unknown, path: string, index: number): Answer {
64
+ const answer = value as Partial<Answer> | null;
65
+ const valid =
66
+ answer !== null &&
67
+ typeof answer === 'object' &&
68
+ typeof answer.rule === 'string' &&
69
+ typeof answer.magnitude === 'number' &&
70
+ Number.isFinite(answer.magnitude) &&
71
+ typeof answer.detail === 'string' &&
72
+ typeof answer.at === 'string';
73
+ if (!valid) {
74
+ throw new Error(`speccov: answer ${index} is unreadable in ${path}.`);
75
+ }
76
+ return typeof answer.why === 'string'
77
+ ? { ...(answer as Answer) }
78
+ : { rule: answer.rule, magnitude: answer.magnitude, detail: answer.detail, at: answer.at };
79
+ }
80
+
81
+ export type Applied = {
82
+ readonly verdicts: readonly Verdict[];
83
+ /** Gaps kept quiet because a human already answered. Counted, never invisible. */
84
+ readonly accepted: readonly Lacuna[];
85
+ };
86
+
87
+ /**
88
+ * Applies the known answers.
89
+ *
90
+ * An accepted gap disappears from the report but stays counted. An accepted gap whose
91
+ * magnitude has passed the threshold becomes visible again — as a violation, because a
92
+ * human had ruled on it and the fact changed under them.
93
+ */
94
+ export function applyAnswers(verdicts: readonly Verdict[], answers: readonly Answer[]): Applied {
95
+ const byRule = new Map(answers.map((answer) => [answer.rule, answer]));
96
+ const kept: Verdict[] = [];
97
+ const accepted: Lacuna[] = [];
98
+
99
+ for (const verdict of verdicts) {
100
+ const answer = verdict.kind === 'lacuna' ? byRule.get(verdict.rule.name) : undefined;
101
+ if (answer === undefined || verdict.kind !== 'lacuna') {
102
+ kept.push(verdict);
103
+ } else if (verdict.magnitude <= answer.magnitude + EPSILON) {
104
+ accepted.push(verdict);
105
+ } else {
106
+ kept.push({
107
+ kind: 'infraction',
108
+ rule: verdict.rule,
109
+ measurements: verdict.measurements,
110
+ magnitude: verdict.magnitude,
111
+ detail: `${verdict.detail}. Accepted on ${answer.at} at a magnitude of ${round(answer.magnitude)}; the threshold is exceeded.`,
112
+ });
113
+ }
114
+ }
115
+ return { verdicts: kept, accepted };
116
+ }
117
+
118
+ /** Turns a gap into a "this is intended" answer, at the threshold measured today. */
119
+ export function accept(lacuna: Lacuna, at: string, why?: string): Answer {
120
+ const answer = { rule: lacuna.rule.name, magnitude: lacuna.magnitude, detail: lacuna.detail, at };
121
+ return why === undefined || why.trim() === '' ? answer : { ...answer, why: why.trim() };
122
+ }
123
+
124
+ function round(value: number): number {
125
+ return Math.round(value * 100) / 100;
126
+ }