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.
181 lines
6 KiB
TypeScript
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)
|
|
})
|
|
})
|