chore: scaffold toolchain for the Electron shell
Set up the package, compiler and quality gates before any app code so every later change lands under the same rules: Node 26 via .nvmrc and engines, TypeScript 7 in strict mode with the extra strictness flags, swc for transpiling, oxlint with type-aware, Unicorn and security rules (eslint-plugin-security and no-unsanitized loaded as JS plugins), oxfmt, Vitest with a 100% coverage gate, and husky hooks that run the checks and commitlint. Type escape hatches, lint suppressions and coverage exemptions are banned unless registered in exceptions.json. scripts/audit-exceptions enforces that, and its test runs it against this repository, so an unregistered suppression fails both the pre-commit hook and CI. npm 11 blocks dependency install scripts by default. None are needed: Electron downloads its binary on first use, swc's native binding comes from optionalDependencies, fsevents ships prebuilt, and electron-winstaller only serves Squirrel installers, which aren't used. They are recorded as denied in allowScripts.
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.e2e-coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit "$1"
|
||||
@@ -0,0 +1 @@
|
||||
npm run check
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve",
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": ["e2e/docs-fixture/**", "package-lock.json", "patches/**"]
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "import", "node", "promise", "vitest"],
|
||||
"jsPlugins": ["eslint-plugin-security", "eslint-plugin-no-unsanitized"],
|
||||
"options": {
|
||||
"typeAware": true,
|
||||
"denyWarnings": true,
|
||||
"reportUnusedDisableDirectives": "error"
|
||||
},
|
||||
"env": { "es2024": true },
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
"pedantic": "error",
|
||||
"perf": "error",
|
||||
"restriction": "error"
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "release/**", "coverage/**", "e2e/docs-fixture/**"],
|
||||
"rules": {
|
||||
// Type escape hatches. Any exception needs an entry in exceptions.json.
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
"typescript/ban-ts-comment": [
|
||||
"error",
|
||||
{ "ts-expect-error": true, "ts-ignore": true, "ts-nocheck": true, "ts-check": false }
|
||||
],
|
||||
"typescript/consistent-type-assertions": ["error", { "assertionStyle": "never" }],
|
||||
"typescript/no-unsafe-type-assertion": "error",
|
||||
"typescript/strict-boolean-expressions": "error",
|
||||
"typescript/switch-exhaustiveness-check": "error",
|
||||
|
||||
// Curated style rules.
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"unicorn/filename-case": ["error", { "case": "kebabCase" }],
|
||||
"eslint/curly": ["error", "all"],
|
||||
"eslint/eqeqeq": "error",
|
||||
"import/extensions": ["error", "always", { "ignorePackages": true }],
|
||||
|
||||
// Restriction rules that contradict how this codebase is written: modern syntax
|
||||
// is the target, not something to avoid.
|
||||
"oxc/no-async-await": "off",
|
||||
"oxc/no-optional-chaining": "off",
|
||||
"oxc/no-rest-spread-properties": "off",
|
||||
"eslint/no-undefined": "off",
|
||||
"eslint/no-plusplus": "off",
|
||||
"import/no-relative-parent-imports": "off",
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
"typescript/explicit-member-accessibility": "off",
|
||||
// switch-exhaustiveness-check replaces this, and a default branch would hide
|
||||
// a missing case from it.
|
||||
"eslint/default-case": "off",
|
||||
// tsc reports temporal-dead-zone use; hoisted function declarations are fine.
|
||||
"eslint/no-use-before-define": "off",
|
||||
// Flags every DOM node, Electron object and Node stream parameter; deep
|
||||
// readonly isn't expressible for those types.
|
||||
"typescript/prefer-readonly-parameter-types": "off",
|
||||
// vitest.config.ts sets testTimeout globally.
|
||||
"vitest/require-test-timeout": "off",
|
||||
|
||||
// Security (eslint-plugin-security, loaded as a JS plugin).
|
||||
"security/detect-unsafe-regex": "error",
|
||||
"security/detect-non-literal-regexp": "error",
|
||||
"security/detect-non-literal-require": "error",
|
||||
"security/detect-eval-with-expression": "error",
|
||||
"security/detect-pseudoRandomBytes": "error",
|
||||
"security/detect-possible-timing-attacks": "error",
|
||||
"security/detect-no-csrf-before-method-override": "error",
|
||||
"security/detect-buffer-noassert": "error",
|
||||
"security/detect-child-process": "error",
|
||||
"security/detect-disable-mustache-escape": "error",
|
||||
"security/detect-new-buffer": "error",
|
||||
"security/detect-bidi-characters": "error",
|
||||
"security/detect-invisible-characters": "error",
|
||||
// Approved off: fires on every bracket access. noUncheckedIndexedAccess and
|
||||
// zod-validated data cover the risk this rule guesses at.
|
||||
"security/detect-object-injection": "off",
|
||||
// Approved off: fires on every fs call with a variable path. Paths reach fs
|
||||
// only through open-target/contain, which the boundary tests cover.
|
||||
"security/detect-non-literal-fs-filename": "off",
|
||||
|
||||
// DOM injection sinks (eslint-plugin-no-unsanitized).
|
||||
"no-unsanitized/method": "error",
|
||||
"no-unsanitized/property": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.config.ts", "e2e/playwright.config.ts"],
|
||||
"rules": { "import/no-default-export": "off" }
|
||||
},
|
||||
{
|
||||
"files": ["scripts/**/*.ts"],
|
||||
// CLI entry points run directly under node and are never require()d.
|
||||
"rules": {
|
||||
"eslint/no-console": "off",
|
||||
"unicorn/no-process-exit": "off",
|
||||
"node/no-top-level-await": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.ts", "e2e/**/*.ts"],
|
||||
"rules": {
|
||||
"eslint/max-lines-per-function": "off",
|
||||
"eslint/max-lines": "off",
|
||||
"node/no-process-env": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jason Ross
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,22 @@
|
||||
# mdts Desktop
|
||||
|
||||
A desktop app for browsing and previewing folders of Markdown, built on
|
||||
[mdts](https://github.com/unhappychoice/mdts) and Electron. Work in progress.
|
||||
|
||||
## Development
|
||||
|
||||
Requires Node 26 (see `.nvmrc`) and npm 11.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check # format, lint, typecheck, exception audit
|
||||
npm run test:unit # Vitest, 100% coverage gate
|
||||
```
|
||||
|
||||
Type-safety escape hatches, lint suppressions and coverage exemptions are only
|
||||
allowed with an entry in `exceptions.json`; `npm run audit:exceptions` enforces
|
||||
this.
|
||||
|
||||
## License
|
||||
|
||||
MIT. mdts is MIT-licensed by its authors.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { UserConfig } from '@commitlint/types';
|
||||
|
||||
const config: UserConfig = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"exceptions": []
|
||||
}
|
||||
Generated
+13585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "mdts-desktop",
|
||||
"productName": "mdts Desktop",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Desktop app for browsing and previewing Markdown folders, built on mdts and Electron",
|
||||
"homepage": "https://github.com/JMR-dev/mdts-desktop",
|
||||
"bugs": "https://github.com/JMR-dev/mdts-desktop/issues",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Jason Ross",
|
||||
"email": "jason.ross841@gmail.com"
|
||||
},
|
||||
"repository": "github:JMR-dev/mdts-desktop",
|
||||
"type": "module",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"postinstall": "patch-package",
|
||||
"clean": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"build": "npm run clean && npm run build:esm && npm run build:preload",
|
||||
"build:esm": "swc src/main src/host src/shared src/renderer --out-dir dist --strip-leading-paths --copy-files --ignore \"**/*.test.ts\" --config-file swc/esm.swcrc",
|
||||
"build:preload": "swc src/preload/start.ts --out-file dist/preload/start.cjs --config-file swc/preload.swcrc",
|
||||
"start": "npm run build && electron .",
|
||||
"typecheck": "tsc --build",
|
||||
"lint": "oxlint --type-aware --deny-warnings",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check",
|
||||
"audit:exceptions": "node scripts/audit-exceptions.ts",
|
||||
"check": "npm run format:check && npm run lint && npm run typecheck && npm run audit:exceptions",
|
||||
"test:unit": "vitest run --coverage",
|
||||
"test:e2e": "npm run build && playwright test",
|
||||
"dist": "npm run build && electron-builder --publish never"
|
||||
},
|
||||
"dependencies": {
|
||||
"mdts": "0.21.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "21.2.3",
|
||||
"@commitlint/config-conventional": "21.2.3",
|
||||
"@commitlint/types": "21.2.3",
|
||||
"@cyclonedx/cyclonedx-npm": "6.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@swc/cli": "0.8.1",
|
||||
"@swc/core": "1.16.2",
|
||||
"@types/node": "24.19.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/browser": "5.0.2",
|
||||
"@vitest/browser-playwright": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"electron": "44.4.5",
|
||||
"electron-builder": "26.17.0",
|
||||
"eslint-plugin-no-unsanitized": "4.1.5",
|
||||
"eslint-plugin-security": "4.1.0",
|
||||
"husky": "9.1.7",
|
||||
"monocart-coverage-reports": "2.13.0",
|
||||
"oxfmt": "0.71.0",
|
||||
"oxlint": "1.86.0",
|
||||
"oxlint-tsgolint": "7.0.2003",
|
||||
"patch-package": "8.0.1",
|
||||
"playwright": "1.63.0",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "5.0.2",
|
||||
"ws": "8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=26 <27"
|
||||
},
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": ">=26 <27",
|
||||
"onFail": "error"
|
||||
},
|
||||
"packageManager": {
|
||||
"name": "npm",
|
||||
"version": ">=11",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"@swc/core": false,
|
||||
"electron-winstaller": false,
|
||||
"fsevents": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('passes on this repository', async () => {
|
||||
await import('./audit-exceptions.ts');
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runAudit } from './lib/exceptions.ts';
|
||||
|
||||
process.exitCode = await runAudit(process.cwd(), (line) => {
|
||||
console.log(line);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { audit, parseRegistry, runAudit, unitCoverageExemptPaths } from './exceptions.ts';
|
||||
|
||||
const approved = (id: string, kind: string, paths: string[]): Record<string, unknown> => ({
|
||||
id,
|
||||
kind,
|
||||
paths,
|
||||
reason: 'test reason',
|
||||
approvedBy: 'test approver',
|
||||
});
|
||||
|
||||
describe('parseRegistry', () => {
|
||||
it('accepts a well-formed registry', () => {
|
||||
const registry = parseRegistry({
|
||||
exceptions: [approved('LINT-1', 'lint-suppression', ['src/a.ts'])],
|
||||
});
|
||||
|
||||
expect(registry.exceptions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects entries with an unknown kind', () => {
|
||||
expect(() =>
|
||||
parseRegistry({ exceptions: [approved('X-1', 'anything-goes', ['src/a.ts'])] }),
|
||||
).toThrow(/kind/u);
|
||||
});
|
||||
|
||||
it('rejects duplicate ids', () => {
|
||||
expect(() =>
|
||||
parseRegistry({
|
||||
exceptions: [
|
||||
approved('LINT-1', 'lint-suppression', ['src/a.ts']),
|
||||
approved('LINT-1', 'lint-suppression', ['src/b.ts']),
|
||||
],
|
||||
}),
|
||||
).toThrow(/duplicate id LINT-1/u);
|
||||
});
|
||||
|
||||
it('rejects ids that the APPROVED(...) marker cannot express', () => {
|
||||
expect(() =>
|
||||
parseRegistry({ exceptions: [approved('lint one', 'lint-suppression', ['src/a.ts'])] }),
|
||||
).toThrow(/id/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit', () => {
|
||||
const registry = parseRegistry({
|
||||
exceptions: [
|
||||
approved('LINT-1', 'lint-suppression', ['src/a.ts']),
|
||||
approved('COV-1', 'coverage-ignore', ['src/**/*.ts']),
|
||||
approved('DTS-1', 'dts-shim', ['src/types/mdts.d.ts']),
|
||||
approved('UNIT-1', 'unit-coverage', ['src/main/electron/**']),
|
||||
],
|
||||
});
|
||||
const baseline = [
|
||||
{ path: 'src/a.ts', content: 'export const a = 1;\n' },
|
||||
{ path: 'src/types/mdts.d.ts', content: 'export {};\n' },
|
||||
{ path: 'src/main/electron/menu.ts', content: 'export {};\n' },
|
||||
];
|
||||
|
||||
it('passes a tree with no suppressions and no stale entries', () => {
|
||||
const files = [
|
||||
...baseline,
|
||||
{ path: 'src/a.ts', content: 'x(); // oxlint-disable-line no-x -- APPROVED(LINT-1)\n' },
|
||||
{ path: 'src/b.ts', content: '/* v8 ignore next -- APPROVED(COV-1) */\n' },
|
||||
];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('flags an oxlint suppression without an approval marker', () => {
|
||||
const files = [
|
||||
...baseline,
|
||||
{ path: 'src/a.ts', content: 'const a = 1;\n// oxlint-disable-next-line no-x\n' },
|
||||
];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([
|
||||
'src/a.ts:2: suppression has no APPROVED(<id>) marker',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats eslint directives, c8 and istanbul comments the same way', () => {
|
||||
const files = [
|
||||
...baseline,
|
||||
{
|
||||
path: 'src/c.ts',
|
||||
content: '/* eslint-disable no-x */\n/* c8 ignore next */\n// istanbul ignore else\n',
|
||||
},
|
||||
];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([
|
||||
'src/c.ts:1: suppression has no APPROVED(<id>) marker',
|
||||
'src/c.ts:2: suppression has no APPROVED(<id>) marker',
|
||||
'src/c.ts:3: suppression has no APPROVED(<id>) marker',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores directive text inside string literals', () => {
|
||||
const content = [
|
||||
"const a = '// oxlint-disable';",
|
||||
"const b = 'x(); /* v8 ignore next */';",
|
||||
'const c = "it\\"s // eslint-disable";',
|
||||
'const d = `// c8 ignore next`;',
|
||||
"// a comment that doesn't disable anything",
|
||||
'',
|
||||
].join('\n');
|
||||
const files = [...baseline, { path: 'src/d.ts', content }];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('flags an approval id that is not in the registry', () => {
|
||||
const files = [
|
||||
...baseline,
|
||||
{ path: 'src/a.ts', content: '// oxlint-disable -- APPROVED(NOPE)\n' },
|
||||
];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([
|
||||
'src/a.ts:1: APPROVED(NOPE) is not in exceptions.json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags an approval whose entry does not cover the file', () => {
|
||||
const files = [
|
||||
...baseline,
|
||||
{ path: 'src/other.ts', content: '// oxlint-disable-line -- APPROVED(LINT-1)\n' },
|
||||
];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([
|
||||
'src/other.ts:1: APPROVED(LINT-1) does not cover this file',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags an approval of the wrong kind', () => {
|
||||
const files = [
|
||||
...baseline,
|
||||
{ path: 'src/a.ts', content: '// oxlint-disable-line -- APPROVED(COV-1)\n' },
|
||||
];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([
|
||||
'src/a.ts:1: APPROVED(COV-1) is a coverage-ignore exception, not lint-suppression',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags a declaration file with no dts-shim entry', () => {
|
||||
const files = [...baseline, { path: 'src/types/other.d.ts', content: 'export {};\n' }];
|
||||
|
||||
expect(audit(files, registry)).toStrictEqual([
|
||||
'src/types/other.d.ts: declaration file is not approved in exceptions.json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags registry entries that no longer match any file', () => {
|
||||
expect(audit([], registry)).toStrictEqual([
|
||||
'exceptions.json: LINT-1 matches no files',
|
||||
'exceptions.json: COV-1 matches no files',
|
||||
'exceptions.json: DTS-1 matches no files',
|
||||
'exceptions.json: UNIT-1 matches no files',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unitCoverageExemptPaths', () => {
|
||||
it('lists the paths of unit-coverage entries only', () => {
|
||||
const registry = parseRegistry({
|
||||
exceptions: [
|
||||
approved('UNIT-1', 'unit-coverage', ['src/main/electron/**', 'src/main/index.ts']),
|
||||
approved('LINT-1', 'lint-suppression', ['src/a.ts']),
|
||||
],
|
||||
});
|
||||
|
||||
expect(unitCoverageExemptPaths(registry)).toStrictEqual([
|
||||
'src/main/electron/**',
|
||||
'src/main/index.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAudit', () => {
|
||||
let root = '';
|
||||
const lines: string[] = [];
|
||||
const log = (line: string): void => {
|
||||
lines.push(line);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'audit-'));
|
||||
lines.length = 0;
|
||||
await mkdir(path.join(root, 'src'), { recursive: true });
|
||||
await mkdir(path.join(root, 'node_modules', 'pkg'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'exceptions.json'),
|
||||
JSON.stringify({ exceptions: [approved('LINT-1', 'lint-suppression', ['src/a.ts'])] }),
|
||||
);
|
||||
await writeFile(path.join(root, 'node_modules', 'pkg', 'index.d.ts'), 'export {};\n');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns 0 and reports success for a clean tree', async () => {
|
||||
await writeFile(path.join(root, 'src', 'a.ts'), '// oxlint-disable-line -- APPROVED(LINT-1)\n');
|
||||
|
||||
await expect(runAudit(root, log)).resolves.toBe(0);
|
||||
expect(lines).toStrictEqual(['audit-exceptions: 1 approved exception, no problems']);
|
||||
});
|
||||
|
||||
it('returns 1 and prints every problem', async () => {
|
||||
await writeFile(path.join(root, 'src', 'a.ts'), '// oxlint-disable-line no-x\n');
|
||||
|
||||
await expect(runAudit(root, log)).resolves.toBe(1);
|
||||
expect(lines).toStrictEqual(['src/a.ts:1: suppression has no APPROVED(<id>) marker']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { globSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const kinds = ['lint-suppression', 'coverage-ignore', 'dts-shim', 'unit-coverage'] as const;
|
||||
type Kind = (typeof kinds)[number];
|
||||
|
||||
const entrySchema = z.strictObject({
|
||||
id: z.string().regex(/^[A-Z0-9-]+$/u, 'id must be upper-case letters, digits and dashes'),
|
||||
kind: z.enum(kinds),
|
||||
paths: z.array(z.string().min(1)).min(1),
|
||||
reason: z.string().min(1),
|
||||
approvedBy: z.string().min(1),
|
||||
});
|
||||
|
||||
const registrySchema = z
|
||||
.object({ exceptions: z.array(entrySchema) })
|
||||
.superRefine((registry, context) => {
|
||||
const seen = new Set<string>();
|
||||
for (const entry of registry.exceptions) {
|
||||
if (seen.has(entry.id)) {
|
||||
context.addIssue({ code: 'custom', message: `duplicate id ${entry.id}` });
|
||||
}
|
||||
seen.add(entry.id);
|
||||
}
|
||||
});
|
||||
|
||||
export type Registry = z.infer<typeof registrySchema>;
|
||||
type Entry = Registry['exceptions'][number];
|
||||
|
||||
export interface SourceFile {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export const parseRegistry = (value: unknown): Registry => registrySchema.parse(value);
|
||||
|
||||
export const unitCoverageExemptPaths = (registry: Registry): string[] =>
|
||||
registry.exceptions
|
||||
.filter((entry) => entry.kind === 'unit-coverage')
|
||||
.flatMap((entry) => entry.paths);
|
||||
|
||||
// Directive text inside string literals (test fixtures, messages) isn't a
|
||||
// directive, so literals are blanked out before matching.
|
||||
const stringLiteral = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/gu;
|
||||
const lintDirective = /(?:\/\/|\/\*)\s*(?:oxlint|eslint)-disable/u;
|
||||
const coverageDirective = /(?:\/\/|\/\*)\s*(?:v8|c8|istanbul) ignore/u;
|
||||
const approvalMarker = /APPROVED\(([^)]*)\)/u;
|
||||
|
||||
const matches = (entry: Entry, file: string): boolean =>
|
||||
entry.paths.some((pattern) => path.posix.matchesGlob(file, pattern));
|
||||
|
||||
const directiveKind = (line: string): Kind | undefined => {
|
||||
const code = line.replaceAll(stringLiteral, "''");
|
||||
if (lintDirective.test(code)) {
|
||||
return 'lint-suppression';
|
||||
}
|
||||
if (coverageDirective.test(code)) {
|
||||
return 'coverage-ignore';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const checkLine = (
|
||||
file: string,
|
||||
lineNumber: number,
|
||||
line: string,
|
||||
byId: ReadonlyMap<string, Entry>,
|
||||
): string | undefined => {
|
||||
const kind = directiveKind(line);
|
||||
if (kind === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const where = `${file}:${String(lineNumber)}`;
|
||||
const id = approvalMarker.exec(line)?.[1];
|
||||
if (id === undefined) {
|
||||
return `${where}: suppression has no APPROVED(<id>) marker`;
|
||||
}
|
||||
const entry = byId.get(id);
|
||||
if (entry === undefined) {
|
||||
return `${where}: APPROVED(${id}) is not in exceptions.json`;
|
||||
}
|
||||
if (entry.kind !== kind) {
|
||||
return `${where}: APPROVED(${id}) is a ${entry.kind} exception, not ${kind}`;
|
||||
}
|
||||
if (!matches(entry, file)) {
|
||||
return `${where}: APPROVED(${id}) does not cover this file`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const audit = (files: readonly SourceFile[], registry: Registry): string[] => {
|
||||
const byId = new Map(registry.exceptions.map((entry) => [entry.id, entry]));
|
||||
const shims = registry.exceptions.filter((entry) => entry.kind === 'dts-shim');
|
||||
const problems: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
for (const [index, line] of file.content.split('\n').entries()) {
|
||||
const problem = checkLine(file.path, index + 1, line, byId);
|
||||
if (problem !== undefined) {
|
||||
problems.push(problem);
|
||||
}
|
||||
}
|
||||
if (file.path.endsWith('.d.ts') && !shims.some((entry) => matches(entry, file.path))) {
|
||||
problems.push(`${file.path}: declaration file is not approved in exceptions.json`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of registry.exceptions) {
|
||||
if (!files.some((file) => matches(entry, file.path))) {
|
||||
problems.push(`exceptions.json: ${entry.id} matches no files`);
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
};
|
||||
|
||||
const sourceGlobs = ['**/*.{ts,mts,cts,js,mjs,cjs}'];
|
||||
const ignoredGlobs = [
|
||||
'**/node_modules/**',
|
||||
'dist/**',
|
||||
'release/**',
|
||||
'coverage/**',
|
||||
'e2e/docs-fixture/**',
|
||||
];
|
||||
|
||||
export const runAudit = async (root: string, log: (line: string) => void): Promise<number> => {
|
||||
const registry = parseRegistry(
|
||||
JSON.parse(await readFile(path.join(root, 'exceptions.json'), 'utf8')),
|
||||
);
|
||||
const relativePaths = globSync(sourceGlobs, { cwd: root, exclude: ignoredGlobs });
|
||||
const files = await Promise.all(
|
||||
relativePaths.map(async (relativePath) => ({
|
||||
path: relativePath.split(path.sep).join('/'),
|
||||
content: await readFile(path.join(root, relativePath), 'utf8'),
|
||||
})),
|
||||
);
|
||||
|
||||
const problems = audit(files, registry);
|
||||
for (const problem of problems) {
|
||||
log(problem);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
return 1;
|
||||
}
|
||||
const count = registry.exceptions.length;
|
||||
log(
|
||||
`audit-exceptions: ${String(count)} approved exception${count === 1 ? '' : 's'}, no problems`,
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2024",
|
||||
"lib": ["es2024"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": [],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"lib": ["es2024", "dom"]
|
||||
},
|
||||
"include": ["e2e/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.scripts.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.tooling.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/main/**/*.ts", "src/host/**/*.ts", "src/shared/**/*.ts", "src/types/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2024", "dom"]
|
||||
},
|
||||
"include": ["src/preload/**/*.ts", "src/shared/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2024", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src/renderer/**/*.ts", "src/shared/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["scripts/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2024", "dom"],
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["*.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
import { parseRegistry, unitCoverageExemptPaths } from './scripts/lib/exceptions.ts';
|
||||
|
||||
const registry = parseRegistry(JSON.parse(readFileSync('exceptions.json', 'utf8')));
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 10_000,
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'node',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||
exclude: ['src/renderer/**'],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'renderer',
|
||||
include: ['src/renderer/**/*.test.ts'],
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts', 'scripts/**/*.ts'],
|
||||
exclude: ['**/*.test.ts', '**/*.d.ts', ...unitCoverageExemptPaths(registry)],
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
thresholds: { lines: 100, branches: 100, functions: 100, statements: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user