From da8d137be480c4a2c04696041c18417cf55667e6 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 7 Jun 2026 16:29:27 +0700 Subject: [PATCH] chore(encoding): eslint/tsconfig migration Migrate to eslint flat config (qr-code.ts override for the mask-penalty sliding-window) and composite tsconfig. --- core/encoding/eslint.config.ts | 13 +++++++ core/encoding/oxlint.config.ts | 14 ------- core/encoding/package.json | 9 ++--- core/encoding/src/qr/__test__/index.bench.ts | 2 +- core/encoding/src/qr/__test__/index.test.ts | 38 ++++++++++++++++++- core/encoding/src/qr/__test__/segment.test.ts | 2 +- core/encoding/src/qr/encode.ts | 4 +- core/encoding/src/qr/qr-code.ts | 36 ++++++++++-------- core/encoding/src/qr/utils.ts | 2 +- core/encoding/tsconfig.json | 6 ++- core/encoding/tsconfig.node.json | 8 ++++ core/encoding/tsconfig.src.json | 9 +++++ core/encoding/tsdown.config.ts | 1 + 13 files changed, 103 insertions(+), 41 deletions(-) create mode 100644 core/encoding/eslint.config.ts delete mode 100644 core/encoding/oxlint.config.ts create mode 100644 core/encoding/tsconfig.node.json create mode 100644 core/encoding/tsconfig.src.json diff --git a/core/encoding/eslint.config.ts b/core/encoding/eslint.config.ts new file mode 100644 index 0000000..78536c2 --- /dev/null +++ b/core/encoding/eslint.config.ts @@ -0,0 +1,13 @@ +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic, { + name: 'encoding/overrides', + files: ['src/qr/qr-code.ts'], + rules: { + '@stylistic/max-statements-per-line': 'off', + '@stylistic/no-mixed-operators': 'off', + /* Uniform sliding-window register shift (h6 = h5; h5 = h4; …) where the + oldest register's seed/last write is intentionally dead — keep symmetry. */ + 'no-useless-assignment': 'off', + }, +}); diff --git a/core/encoding/oxlint.config.ts b/core/encoding/oxlint.config.ts deleted file mode 100644 index a45b20d..0000000 --- a/core/encoding/oxlint.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'oxlint'; -import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint'; - -export default defineConfig(compose(base, typescript, imports, stylistic, { - overrides: [ - { - files: ['src/qr/qr-code.ts'], - rules: { - '@stylistic/max-statements-per-line': 'off', - '@stylistic/no-mixed-operators': 'off', - }, - }, - ], -})); diff --git a/core/encoding/package.json b/core/encoding/package.json index d3d070a..92b383f 100644 --- a/core/encoding/package.json +++ b/core/encoding/package.json @@ -34,19 +34,18 @@ } }, "scripts": { - "lint:check": "oxlint -c oxlint.config.ts", - "lint:fix": "oxlint -c oxlint.config.ts --fix", + "lint:check": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest run", "dev": "vitest dev", "bench": "vitest bench", "build": "tsdown" }, "devDependencies": { - "@robonen/oxlint": "workspace:*", + "@robonen/eslint": "workspace:*", "@robonen/tsconfig": "workspace:*", "@robonen/tsdown": "workspace:*", - "@stylistic/eslint-plugin": "catalog:", - "oxlint": "catalog:", + "eslint": "catalog:", "tsdown": "catalog:" } } diff --git a/core/encoding/src/qr/__test__/index.bench.ts b/core/encoding/src/qr/__test__/index.bench.ts index d3f45c0..ca1df65 100644 --- a/core/encoding/src/qr/__test__/index.bench.ts +++ b/core/encoding/src/qr/__test__/index.bench.ts @@ -1,5 +1,5 @@ import { bench, describe } from 'vitest'; -import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '..'; +import { EccMap, LOW, encodeBinary, encodeSegments, encodeText, makeSegments } from '..'; /* -- Test data -- */ diff --git a/core/encoding/src/qr/__test__/index.test.ts b/core/encoding/src/qr/__test__/index.test.ts index 7e2e1c3..8e1d1a4 100644 --- a/core/encoding/src/qr/__test__/index.test.ts +++ b/core/encoding/src/qr/__test__/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { encodeText, encodeBinary, encodeSegments, makeSegments, isNumeric, isAlphanumeric, QrCode, QrCodeDataType, EccMap, LOW, MEDIUM, QUARTILE, HIGH } from '..'; +import { EccMap, HIGH, LOW, MEDIUM, QUARTILE, QrCode, QrCodeDataType, encodeBinary, encodeSegments, encodeText, isAlphanumeric, isNumeric, makeSegments } from '..'; describe('isNumeric', () => { it('accepts pure digit strings', () => { @@ -255,3 +255,39 @@ describe('getType semantics', () => { expect(qr.getType(8, 6)).toBe(QrCodeDataType.Timing); }); }); + +describe('automatic mask selection (regression)', () => { + // The mask is chosen by minimizing getPenaltyScore() over all 8 patterns. + // These version/mask pairs were verified against the canonical Project Nayuki + // penalty algorithm. A regression in the finder-pattern (PENALTY_N3) rolling + // window — e.g. dropping a history slot during the shift — silently selects a + // different, non-spec-optimal mask, which these fixtures catch. + it.each([ + ['HELLO WORLD', QUARTILE, 1, 0], + ['HELLO WORLD', MEDIUM, 1, 0], + ['https://example.com', LOW, 2, 0], + ['12345678901234567890', LOW, 1, 4], + ['Hello, World!', MEDIUM, 1, 3], + ['a'.repeat(200), LOW, 9, 1], + ] as const)('locks version/mask for %j', (text, ecc, version, mask) => { + const qr = encodeText(text, ecc); + expect(qr.version).toBe(version); + expect(qr.mask).toBe(mask); + }); + + it('produces a stable full-grid fingerprint for HELLO WORLD / QUARTILE', () => { + const qr = encodeText('HELLO WORLD', QUARTILE); + let dark = 0; + let hash = 0; + for (let y = 0; y < qr.size; y++) { + for (let x = 0; x < qr.size; x++) { + const bit = qr.getModule(x, y) ? 1 : 0; + dark += bit; + hash = (hash * 31 + bit) >>> 0; + } + } + expect(qr.size).toBe(21); + expect(dark).toBe(218); + expect(hash).toBe(1_118_257_212); + }); +}); diff --git a/core/encoding/src/qr/__test__/segment.test.ts b/core/encoding/src/qr/__test__/segment.test.ts index fff8a3d..0b99706 100644 --- a/core/encoding/src/qr/__test__/segment.test.ts +++ b/core/encoding/src/qr/__test__/segment.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { QrSegment, makeNumeric, makeAlphanumeric, makeBytes } from '../segment'; +import { QrSegment, makeAlphanumeric, makeBytes, makeNumeric } from '../segment'; import { MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC } from '../constants'; describe('QrSegment', () => { diff --git a/core/encoding/src/qr/encode.ts b/core/encoding/src/qr/encode.ts index ed1a7e7..76f74f7 100644 --- a/core/encoding/src/qr/encode.ts +++ b/core/encoding/src/qr/encode.ts @@ -21,7 +21,7 @@ export function encodeText(text: string, ecl: QrCodeEcc): QrCode { * This function always encodes using the binary segment mode, not any text mode. * The maximum number of bytes allowed is 2953. */ -export function encodeBinary(data: Readonly, ecl: QrCodeEcc): QrCode { +export function encodeBinary(data: readonly number[], ecl: QrCodeEcc): QrCode { const seg = makeBytes(data); return encodeSegments([seg], ecl); } @@ -32,7 +32,7 @@ export function encodeBinary(data: Readonly, ecl: QrCodeEcc): QrCode { * This is a mid-level API; the high-level API is encodeText() and encodeBinary(). */ export function encodeSegments( - segs: Readonly, + segs: readonly QrSegment[], ecl: QrCodeEcc, minVersion = 1, maxVersion = 40, diff --git a/core/encoding/src/qr/qr-code.ts b/core/encoding/src/qr/qr-code.ts index 6db3b9a..5af22db 100644 --- a/core/encoding/src/qr/qr-code.ts +++ b/core/encoding/src/qr/qr-code.ts @@ -38,7 +38,7 @@ export class QrCode { public readonly version: number, /** The error correction level used in this QR Code. */ public readonly ecc: QrCodeEcc, - dataCodewords: Readonly, + dataCodewords: readonly number[], msk: number, ) { if (version < MIN_VERSION || version > MAX_VERSION) @@ -202,7 +202,7 @@ export class QrCode { /* -- Private helper methods for constructor: Codewords and masking -- */ - private addEccAndInterleave(data: Readonly): number[] { + private addEccAndInterleave(data: readonly number[]): number[] { const ver = this.version; const ecl = this.ecc; if (data.length !== getNumDataCodewords(ver, ecl)) @@ -239,7 +239,7 @@ export class QrCode { return result; } - private drawCodewords(data: Readonly): void { + private drawCodewords(data: readonly number[]): void { if (data.length !== ((getNumRawDataModules(this.version) / 8) | 0)) throw new RangeError('Invalid argument'); @@ -317,8 +317,9 @@ export class QrCode { result++; } else { - if (h0 === 0) runX += size; - h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX; + let v = runX; + if (h0 === 0) v += size; + h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = h0; h0 = v; if (runColor === 0) { const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1; if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3; @@ -331,13 +332,15 @@ export class QrCode { { let currentRunLength = runX; if (runColor === 1) { - if (h0 === 0) currentRunLength += size; - h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength; + let v = currentRunLength; + if (h0 === 0) v += size; + h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = h0; h0 = v; currentRunLength = 0; } currentRunLength += size; - if (h0 === 0) currentRunLength += size; - h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength; + let v = currentRunLength; + if (h0 === 0) v += size; + h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = h0; h0 = v; const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1; if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3; if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3; @@ -359,8 +362,9 @@ export class QrCode { result++; } else { - if (h0 === 0) runY += size; - h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runY; h0 = runY; + let v = runY; + if (h0 === 0) v += size; + h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = h0; h0 = v; if (runColor === 0) { const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1; if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3; @@ -373,13 +377,15 @@ export class QrCode { { let currentRunLength = runY; if (runColor === 1) { - if (h0 === 0) currentRunLength += size; - h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength; + let v = currentRunLength; + if (h0 === 0) v += size; + h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = h0; h0 = v; currentRunLength = 0; } currentRunLength += size; - if (h0 === 0) currentRunLength += size; - h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength; + let v = currentRunLength; + if (h0 === 0) v += size; + h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = h0; h0 = v; const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1; if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3; if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3; diff --git a/core/encoding/src/qr/utils.ts b/core/encoding/src/qr/utils.ts index 30e8791..9a29c32 100644 --- a/core/encoding/src/qr/utils.ts +++ b/core/encoding/src/qr/utils.ts @@ -67,7 +67,7 @@ export function getNumDataCodewords(ver: number, ecl: QrCodeEcc): number { * Calculates and returns the number of bits needed to encode the given segments at the given version. * The result is infinity if a segment has too many characters to fit its length field. */ -export function getTotalBits(segs: Readonly, version: number): number { +export function getTotalBits(segs: readonly QrSegment[], version: number): number { let result = 0; for (const seg of segs) { const ccbits = numCharCountBits(seg.mode, version); diff --git a/core/encoding/tsconfig.json b/core/encoding/tsconfig.json index ab255ac..2781e66 100644 --- a/core/encoding/tsconfig.json +++ b/core/encoding/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "@robonen/tsconfig/tsconfig.json" + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] } diff --git a/core/encoding/tsconfig.node.json b/core/encoding/tsconfig.node.json new file mode 100644 index 0000000..edc474f --- /dev/null +++ b/core/encoding/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.node.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["*.config.ts"] +} diff --git a/core/encoding/tsconfig.src.json b/core/encoding/tsconfig.src.json new file mode 100644 index 0000000..6661594 --- /dev/null +++ b/core/encoding/tsconfig.src.json @@ -0,0 +1,9 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "types": [], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/core/encoding/tsdown.config.ts b/core/encoding/tsdown.config.ts index ae9657f..6e391e5 100644 --- a/core/encoding/tsdown.config.ts +++ b/core/encoding/tsdown.config.ts @@ -3,5 +3,6 @@ import { sharedConfig } from '@robonen/tsdown'; export default defineConfig({ ...sharedConfig, + tsconfig: './tsconfig.src.json', entry: ['src/index.ts'], });