openpencil/tools/lint/tests/plugin.test.ts
Danila Poyarkov f2bfaba287 refactor: narrow lint policies and consolidate their support
Remove the disabled widening audit and its resolver. Fix Bun aliases and bounded reducer slices, reject parser failures in shared fixtures, and retain upstream attribution in NOTICE.
2026-09-14 10:10:27 +03:00

181 lines
6 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import lintPlugin from '#lint/plugin.ts'
import { normalizedFilename } from '#lint/support/context.ts'
import { resolveWorkspaceRoot } from '@open-pencil/package-artifacts'
import { lint, ruleDiagnostics } from './helpers/lint.ts'
const temporaryDirectories: string[] = []
async function runRule(ruleName: string, source: string, filename: string): Promise<number> {
const plugin = lintPlugin
let reports = 0
const visitors = plugin.rules[ruleName].create({
filename,
physicalFilename: filename,
options: [],
report: () => {
reports += 1
},
sourceCode: {
getText: () => source
}
})
visitors.Program?.({ type: 'Program' })
return reports
}
test('domain naming uses ownership prefixes, not control-kind suffixes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'open-pencil-domains-'))
temporaryDirectories.push(directory)
await mkdir(join(directory, 'list'))
expect(
await runRule('no-sibling-domain-prefixed-files', '', join(directory, 'page-list.ts'))
).toBe(0)
expect(
await runRule('no-sibling-domain-prefixed-files', '', join(directory, 'list-actions.ts'))
).toBe(1)
})
test('storage rule permits data keys but rejects storage access', async () => {
const rule = 'no-direct-storage-access'
for (const [source, count] of [
['const state = {localStorage: []}', 0],
['localStorage.clear()', 1],
['window.sessionStorage.clear()', 1],
['const value = {localStorage}', 1]
] as const) {
const diagnostics = await lint(source, { [`open-pencil/${rule}`]: 'error' })
expect(ruleDiagnostics(diagnostics, rule)).toHaveLength(count)
}
})
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('no-conditional-object-spreads', () => {
const rule = 'no-conditional-object-spreads'
const rules = { [`open-pencil/${rule}`]: 'error' }
test('accepts a simple one-property projection', async () => {
const diagnostics = await lint(
'export const result = { ...(enabled ? { value: 1 } : {}) }',
rules
)
expect(ruleDiagnostics(diagnostics, rule)).toHaveLength(0)
})
test('reports a multi-property conditional projection', async () => {
const diagnostics = await lint(
'export const result = { ...(enabled ? { value: 1, label: "yes" } : {}) }',
rules
)
expect(ruleDiagnostics(diagnostics, rule)).toHaveLength(1)
})
test('reports multiple conditional projections in one object', async () => {
const diagnostics = await lint(
'export const result = { ...(first ? { a: 1 } : {}), ...(second ? { b: 2 } : {}) }',
rules
)
expect(ruleDiagnostics(diagnostics, rule)).toHaveLength(2)
})
})
describe('AST-backed rules', () => {
test('recognizes Record<string, unknown> from the TypeScript AST', async () => {
const rule = 'no-unknown-record-double-cast'
const diagnostics = await lint(
'export const result = value as unknown as Record< string, unknown >',
{ [`open-pencil/${rule}`]: 'error' }
)
expect(ruleDiagnostics(diagnostics, rule)).toHaveLength(1)
})
test('keeps distinct callback signatures out of duplicate type diagnostics', async () => {
const rule = 'no-duplicate-type-shapes'
const diagnostics = await lint(
[
'interface First { handler: (value: string) => number; enabled: boolean }',
'interface Second { handler: (value: number) => number; enabled: boolean }'
].join('\n'),
{ [`open-pencil/${rule}`]: 'error' }
)
expect(ruleDiagnostics(diagnostics, rule)).toHaveLength(0)
})
test('recognizes native title attributes from the Vue template AST', async () => {
const rule = 'no-native-title-attributes-in-vue'
const reports = await runRule(
rule,
'<template><button title="Save">Save</button></template>',
'/repo/src/components/SaveButton.vue'
)
expect(reports).toBe(1)
})
test('finds raw SVG elements with v-if directives', async () => {
const reports = await runRule(
'no-raw-svg-in-app-vue-templates',
'<template><svg v-if="shown" /></template>',
'/repo/src/components/ConditionalIcon.vue'
)
expect(reports).toBe(1)
})
test('does not treat a Vue component title prop as a native title attribute', async () => {
const reports = await runRule(
'no-native-title-attributes-in-vue',
'<template><Dialog title="Settings" /></template>',
'/repo/src/components/SettingsDialog.vue'
)
expect(reports).toBe(0)
})
test('reports a hardcoded Vue tooltip label', async () => {
const reports = await runRule(
'no-hardcoded-tip-labels-in-vue',
'<template><Tip label="Save" /></template>',
'/repo/src/components/SaveButton.vue'
)
expect(reports).toBe(1)
})
test('accepts a localized Vue tooltip binding', async () => {
const rule = 'no-hardcoded-tip-labels-in-vue'
const reports = await runRule(
rule,
'<template><Tip :label="messages.save" /></template>',
'/repo/src/components/SaveButton.vue'
)
expect(reports).toBe(0)
})
})
describe('path support', () => {
test('prefers and normalizes the physical filename', () => {
const context = {
filename: 'virtual.ts',
physicalFilename: String.raw`C:\repo\src\physical.ts`
}
expect(normalizedFilename(context)).toBe('C:/repo/src/physical.ts')
})
})
describe('plugin entrypoint', () => {
test('loads all custom rules through the compatibility entrypoint', async () => {
const pluginPath = join(await resolveWorkspaceRoot(import.meta.dir), 'lint/plugin.js')
const module = (await import(pluginPath)) as { default: typeof lintPlugin }
expect(Object.keys(module.default.rules)).toContain('no-conditional-object-spreads')
expect(Object.keys(module.default.rules).length).toBeGreaterThan(60)
})
})