From 6fc1bd948ba1e3a1b8ba387f0538043782832f30 Mon Sep 17 00:00:00 2001 From: Miles Blackwood Date: Tue, 23 Jun 2026 14:32:15 -0400 Subject: [PATCH] Migrate scripts/ from JavaScript to TypeScript Convert the build/validate/lint tooling under scripts/ from .mjs to .mts, type-checked with TypeScript via `tsc --noEmit` and run directly by Node's native type-stripping (no compile/emit step; Node >=22.18 strips types at runtime, already pinned via .nvmrc). - Add tsconfig.json (strict, noEmit, NodeNext, verbatimModuleSyntax) and a `typecheck` script wired into `npm run check`; add typescript + @types/node. - Add scripts/lib/types.mts with shared Location/Finding/LintResult/Form/ FormMapData shapes. - Type the selector linter against css-what's own token types, using explicit type-predicate filters and `in` guards for the discriminated union. - ajv/ajv-formats are CommonJS; alias their `.default` since NodeNext types the ESM default import as the module namespace. - Repoint package.json scripts and lint-staged, and the README link, to .mts. No behavior change: the built dist/forms.v1.json is byte-identical to the pre-migration output, and all 189 tests pass. --- README.md | 2 +- package-lock.json | 24 ++- package.json | 21 +- scripts/{build.mjs => build.mts} | 97 +++++++-- ...{lint-selectors.mjs => lint-selectors.mts} | 185 ++++++++++++------ ...ctors.test.mjs => lint-selectors.test.mts} | 27 ++- scripts/lib/types.mts | 65 ++++++ ...{lint-selectors.mjs => lint-selectors.mts} | 43 ++-- scripts/utils.mjs | 9 - scripts/utils.mts | 9 + ...idate-schemas.mjs => validate-schemas.mts} | 18 +- tsconfig.json | 17 ++ 12 files changed, 390 insertions(+), 127 deletions(-) rename scripts/{build.mjs => build.mts} (84%) rename scripts/lib/{lint-selectors.mjs => lint-selectors.mts} (88%) rename scripts/lib/{lint-selectors.test.mjs => lint-selectors.test.mts} (98%) create mode 100644 scripts/lib/types.mts rename scripts/{lint-selectors.mjs => lint-selectors.mts} (88%) delete mode 100644 scripts/utils.mjs create mode 100644 scripts/utils.mts rename scripts/{validate-schemas.mjs => validate-schemas.mts} (80%) create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 5420cfa..678116a 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ steps: `schemaVersion` to the new value and adjust the data shape to satisfy the new schema. 4. **Register a downward migration** in - [`scripts/build.mjs`](scripts/build.mjs). Add an entry under + [`scripts/build.mts`](scripts/build.mts). Add an entry under `MIGRATIONS[""]` keyed by the previous major (`N`); the function projects new-source-shape data into old-schema-shape data. 5. **Mark the previous schema deprecated** by adding `"deprecated": true` at diff --git a/package-lock.json b/package-lock.json index 6db2947..86ee564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "@bitwarden/map-the-web", "license": "GPL-3.0-only", "devDependencies": { + "@types/node": "^22.20.0", "ajv": "8.18.0", "ajv-formats": "3.0.1", "css-what": "8.0.0", @@ -19,7 +20,8 @@ "remark-lint-no-trailing-spaces": "4.0.3", "remark-preset-lint-consistent": "6.0.1", "remark-preset-lint-recommended": "7.0.1", - "strip-json-comments": "5.0.3" + "strip-json-comments": "5.0.3", + "typescript": "^6.0.3" }, "engines": { "node": ">=22", @@ -346,9 +348,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "dev": true, "license": "MIT", "dependencies": { @@ -3775,6 +3777,20 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 8d0127c..0dcb416 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,13 @@ "prettier:fix": "prettier --write .", "prettier": "prettier --check .", "lint:md": "remark . --frail --quiet", - "lint:selectors": "node scripts/lint-selectors.mjs", - "validate": "node scripts/validate-schemas.mjs", - "check": "npm run prettier && npm run lint:md && npm run validate && npm run lint:selectors && npm test", - "test": "node --test scripts/**/*.test.mjs", - "build": "node scripts/build.mjs", - "build:clean": "rm -rf dist && node scripts/build.mjs" + "lint:selectors": "node scripts/lint-selectors.mts", + "typecheck": "tsc --noEmit", + "validate": "node scripts/validate-schemas.mts", + "check": "npm run prettier && npm run typecheck && npm run lint:md && npm run validate && npm run lint:selectors && npm test", + "test": "node --test scripts/**/*.test.mts", + "build": "node scripts/build.mts", + "build:clean": "rm -rf dist && node scripts/build.mts" }, "engines": { "node": ">=22", @@ -33,16 +34,17 @@ "prettier --check" ], "maps/**/*.jsonc": [ - "node scripts/validate-schemas.mjs" + "node scripts/validate-schemas.mts" ], "maps/forms/*.jsonc": [ - "node scripts/lint-selectors.mjs" + "node scripts/lint-selectors.mts" ], "*.md": [ "remark --frail --quiet" ] }, "devDependencies": { + "@types/node": "^22.20.0", "ajv": "8.18.0", "ajv-formats": "3.0.1", "css-what": "8.0.0", @@ -55,6 +57,7 @@ "remark-lint-no-trailing-spaces": "4.0.3", "remark-preset-lint-consistent": "6.0.1", "remark-preset-lint-recommended": "7.0.1", - "strip-json-comments": "5.0.3" + "strip-json-comments": "5.0.3", + "typescript": "^6.0.3" } } diff --git a/scripts/build.mjs b/scripts/build.mts similarity index 84% rename from scripts/build.mjs rename to scripts/build.mts index 21e2220..b4124b8 100644 --- a/scripts/build.mjs +++ b/scripts/build.mts @@ -3,13 +3,74 @@ import { readFileSync, writeFileSync, mkdirSync, rmSync, cpSync } from "fs"; import { createHash } from "crypto"; import { basename, dirname, join, relative } from "path"; import { glob } from "node:fs/promises"; -import Ajv2020 from "ajv/dist/2020.js"; -import addFormats from "ajv-formats"; +import Ajv2020Import from "ajv/dist/2020.js"; +import addFormatsImport from "ajv-formats"; import stripJsonComments from "strip-json-comments"; -import { red, yellow, green, cyan } from "./utils.mjs"; +import { red, yellow, green, cyan } from "./utils.mts"; + +// ajv and ajv-formats are CommonJS; under NodeNext their ESM default import is +// the module namespace, so the constructor/function lives on `.default`. +const Ajv2020 = Ajv2020Import.default; +const addFormats = addFormatsImport.default; const DIST = "dist"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Parsed source data for a Map (`.jsonc`). Treated generically here. */ +interface MapSourceData { + schemaVersion?: string; + hosts?: Record; + [key: string]: unknown; +} + +/** A migration projecting the latest source shape onto an older major. */ +type MigrationFn = (data: MapSourceData) => MapSourceData; + +/** The subset of a JSON Schema document the build reads. */ +interface SchemaJson { + $id?: string; + deprecated?: boolean; + properties?: { schemaVersion?: { const?: string } }; + [key: string]: unknown; +} + +interface SchemaEntry { + file: string; + schema: SchemaJson; + major: number; + expectedVersion: string; +} + +interface BuildEntry { + target: SchemaEntry; + payload: MapSourceData; +} + +interface MapEntry { + name: string; + dir: string; + dataFile: string; + schemas: SchemaEntry[]; + builds: BuildEntry[]; +} + +interface ManifestMapVersion { + filename: string; + cid: string; + schema: string; + deprecated?: boolean; +} + +interface Manifest { + buildId: string; + timestamp: string; + gitSha: string; + maps: Record>; +} + // --------------------------------------------------------------------------- // Per-Map backwards-compatibility migrations // @@ -38,7 +99,7 @@ const DIST = "dist"; // To drop support for an older schema major, either remove its migration // entry below or delete the corresponding `.v.schema.json` file. // --------------------------------------------------------------------------- -const MIGRATIONS = { +const MIGRATIONS: Record> = { forms: { // 0: (data) => data, // example: latest source projecting to v0 // 1: (data) => data, // example: latest source projecting to v1 @@ -51,7 +112,7 @@ rmSync(DIST, { recursive: true, force: true }); // Step 1: Discover Maps and their schemas -const mapsByName = new Map(); +const mapsByName = new Map(); // Each Map lives one level deep under maps/ (e.g. maps/forms/). // Schema files are versioned: .v.schema.json. @@ -68,7 +129,9 @@ for await (const schemaFile of glob("maps/*/*.v*.schema.json")) { const major = parseInt(majorMatch[1], 10); - const schemaJson = JSON.parse(readFileSync(schemaFile, "utf-8")); + const schemaJson = JSON.parse( + readFileSync(schemaFile, "utf-8"), + ) as SchemaJson; const expectedVersion = schemaJson?.properties?.schemaVersion?.const; if (typeof expectedVersion !== "string") { @@ -110,10 +173,10 @@ for await (const schemaFile of glob("maps/*/*.v*.schema.json")) { } if (!mapsByName.has(name)) { - mapsByName.set(name, { name, dir, dataFile, schemas: [] }); + mapsByName.set(name, { name, dir, dataFile, schemas: [], builds: [] }); } - mapsByName.get(name).schemas.push({ + mapsByName.get(name)!.schemas.push({ file: schemaFile, schema: schemaJson, major, @@ -175,7 +238,7 @@ for (const map of maps) { const sourceData = JSON.parse( stripJsonComments(readFileSync(map.dataFile, "utf-8")), - ); + ) as MapSourceData; // Normalize unicode host keys to punycode (once) and warn on www. prefixes. if (sourceData.hosts) { @@ -232,7 +295,7 @@ for (const map of maps) { map.builds = []; for (const target of targets) { - let projectedData; + let projectedData: MapSourceData; if (target.major === sourceSchema.major) { projectedData = sourceData; } else { @@ -241,7 +304,7 @@ for (const map of maps) { console.error( red( `${map.name}: no migration registered for source v${sourceSchema.major} → v${target.major}. ` + - `Register MIGRATIONS["${map.name}"][${target.major}] in scripts/build.mjs, ` + + `Register MIGRATIONS["${map.name}"][${target.major}] in scripts/build.mts, ` + `or delete ${map.dir}/${map.name}.v${target.major}.schema.json to drop support for v${target.major}.`, ), ); @@ -260,7 +323,7 @@ for (const map of maps) { if (!validate(payload)) { console.error(red(`Validation failed: ${map.dataFile} → ${target.file}`)); - for (const err of validate.errors) { + for (const err of validate.errors ?? []) { console.error(` ${err.instancePath || "/"}: ${err.message}`); } @@ -307,14 +370,14 @@ const gitSha = } })(); -const manifest = { +const manifest: Manifest = { buildId, timestamp: new Date().toISOString(), gitSha, maps: {}, }; -const checksums = []; +const checksums: string[] = []; mkdirSync(DIST, { recursive: true }); @@ -351,13 +414,15 @@ for (const map of maps) { // Validate the assembled manifest against its schema before writing. const manifestSchemaSrc = "scripts/manifest.schema.json"; -const manifestSchema = JSON.parse(readFileSync(manifestSchemaSrc, "utf-8")); +const manifestSchema = JSON.parse( + readFileSync(manifestSchemaSrc, "utf-8"), +) as SchemaJson; const validateManifest = ajv.compile(manifestSchema); if (!validateManifest(manifest)) { console.error( red(`Manifest failed validation against ${manifestSchemaSrc}:`), ); - for (const err of validateManifest.errors) { + for (const err of validateManifest.errors ?? []) { console.error(` ${err.instancePath || "/"}: ${err.message}`); } process.exit(1); diff --git a/scripts/lib/lint-selectors.mjs b/scripts/lib/lint-selectors.mts similarity index 88% rename from scripts/lib/lint-selectors.mjs rename to scripts/lib/lint-selectors.mts index 277854b..697426c 100644 --- a/scripts/lib/lint-selectors.mjs +++ b/scripts/lib/lint-selectors.mts @@ -1,4 +1,23 @@ import { parse as parseCss } from "css-what"; +import type { + Selector, + PseudoSelector, + PseudoElement, + AttributeSelector, + TagSelector, + UniversalSelector, +} from "css-what"; +import type { + Location, + Finding, + LintResult, + CompositeSelectorArray, + Form, + FormMapData, +} from "./types.mts"; + +/** Tokens that carry an optional `namespace` (the only ones we render). */ +type NamespacedSelector = AttributeSelector | TagSelector | UniversalSelector; // --------------------------------------------------------------------------- // Constants @@ -137,7 +156,7 @@ const ALWAYS_FALSE_EMPTY_ACTIONS = new Map([["element", "~="]]); /** * Format a location object into a readable path string. */ -export function formatLocation(location) { +export function formatLocation(location: Location): string { const parts = [location.host]; if (location.pathname) { @@ -168,7 +187,7 @@ export function formatLocation(location) { * Check whether a parsed selector segment is "bare"; only an element tag * with no qualifying ID, class, attribute, or pseudo-class. */ -function isBareElement(tokens) { +function isBareElement(tokens: Selector[]): boolean { const compound = getLastCompound(tokens); return compound.length === 1 && compound[0].type === "tag"; } @@ -177,7 +196,7 @@ function isBareElement(tokens) { * Check whether a parsed selector segment is class-only; one or more class * selectors with no element, ID, attribute, or pseudo-class qualifier. */ -function isClassOnly(tokens) { +function isClassOnly(tokens: Selector[]): boolean { const compound = getLastCompound(tokens); return ( compound.length > 0 && @@ -191,7 +210,7 @@ function isClassOnly(tokens) { /** * Check whether a parsed selector segment contains a universal selector. */ -function hasUniversal(tokens) { +function hasUniversal(tokens: Selector[]): boolean { return tokens.some((t) => t.type === "universal"); } @@ -199,13 +218,14 @@ function hasUniversal(tokens) { * Check whether a parsed selector segment is ID-only; a single ID selector * with no element type or other qualifier on the target compound. */ -function isIdOnly(tokens) { +function isIdOnly(tokens: Selector[]): boolean { const compound = getLastCompound(tokens); + const [first] = compound; return ( compound.length === 1 && - compound[0].type === "attribute" && - compound[0].name === "id" && - compound[0].action === "equals" + first.type === "attribute" && + first.name === "id" && + first.action === "equals" ); } @@ -220,7 +240,7 @@ function isIdOnly(tokens) { * (id/class/attribute) before firing. Pure pseudo-class selectors are * caught separately by `isPseudoOnly`. */ -function lacksTagAnchor(tokens) { +function lacksTagAnchor(tokens: Selector[]): boolean { const compound = getLastCompound(tokens); if (compound.some((t) => t.type === "tag")) { return false; @@ -238,7 +258,7 @@ function lacksTagAnchor(tokens) { * without a tag/id/class/attribute anchor isn't claiming what the target * IS, only what it ISN'T (or what state it's in). */ -function isPseudoOnly(tokens) { +function isPseudoOnly(tokens: Selector[]): boolean { const compound = getLastCompound(tokens); if (compound.length === 0) { return false; @@ -257,7 +277,7 @@ function isPseudoOnly(tokens) { /** * Return the last compound selector (tokens after the final combinator). */ -function getLastCompound(tokens) { +function getLastCompound(tokens: Selector[]): Selector[] { let start = 0; for (let i = 0; i < tokens.length; i++) { if (COMBINATOR_TYPES.has(tokens[i].type)) { @@ -270,7 +290,7 @@ function getLastCompound(tokens) { /** * Count the number of combinators (nesting depth) in a token list. */ -function combinatorDepth(tokens) { +function combinatorDepth(tokens: Selector[]): number { return tokens.filter((t) => COMBINATOR_TYPES.has(t.type)).length; } @@ -279,7 +299,7 @@ function combinatorDepth(tokens) { * functional pseudo-classes (e.g. :not(...), :is(...), :has(...)). * Pseudos with string .data (e.g. :lang("en")) are not descended into. */ -function* walkTokens(tokens) { +function* walkTokens(tokens: Selector[]): Generator { for (const t of tokens) { yield t; if (t.type === "pseudo" && Array.isArray(t.data)) { @@ -293,61 +313,73 @@ function* walkTokens(tokens) { /** * Return a flat array of every token, descending into functional pseudos. */ -function collectAllTokens(tokens) { +function collectAllTokens(tokens: Selector[]): Selector[] { return [...walkTokens(tokens)]; } /** * Find positional pseudo-classes in a token list. */ -function findPositionalPseudos(tokens) { +function findPositionalPseudos(tokens: Selector[]): string[] { return tokens - .filter((t) => t.type === "pseudo" && POSITIONAL_PSEUDOS.has(t.name)) + .filter( + (t): t is PseudoSelector => + t.type === "pseudo" && POSITIONAL_PSEUDOS.has(t.name), + ) .map((t) => `:${t.name}`); } /** * Find state-dependent pseudo-classes in a token list. */ -function findStatePseudos(tokens) { +function findStatePseudos(tokens: Selector[]): string[] { return tokens - .filter((t) => t.type === "pseudo" && STATE_PSEUDOS.has(t.name)) + .filter( + (t): t is PseudoSelector => + t.type === "pseudo" && STATE_PSEUDOS.has(t.name), + ) .map((t) => `:${t.name}`); } /** * Find root / shadow-root pseudo-classes in a token list. */ -function findRootPseudos(tokens) { +function findRootPseudos(tokens: Selector[]): string[] { return tokens - .filter((t) => t.type === "pseudo" && ROOT_PSEUDOS.has(t.name)) + .filter( + (t): t is PseudoSelector => + t.type === "pseudo" && ROOT_PSEUDOS.has(t.name), + ) .map((t) => `:${t.name}`); } /** * Find context-dependent pseudo-classes in a token list. */ -function findContextDependentPseudos(tokens) { +function findContextDependentPseudos(tokens: Selector[]): string[] { return tokens - .filter((t) => t.type === "pseudo" && CONTEXT_DEPENDENT_PSEUDOS.has(t.name)) + .filter( + (t): t is PseudoSelector => + t.type === "pseudo" && CONTEXT_DEPENDENT_PSEUDOS.has(t.name), + ) .map((t) => `:${t.name}`); } /** * Find pseudo-elements in a token list. */ -function findPseudoElements(tokens) { +function findPseudoElements(tokens: Selector[]): string[] { return tokens - .filter((t) => t.type === "pseudo-element") + .filter((t): t is PseudoElement => t.type === "pseudo-element") .map((t) => `::${t.name}`); } /** * Find tag tokens whose name starts with "@" (at-rules mis-parsed as tags). */ -function findAtRuleTags(tokens) { +function findAtRuleTags(tokens: Selector[]): string[] { return tokens - .filter((t) => t.type === "tag" && t.name.startsWith("@")) + .filter((t): t is TagSelector => t.type === "tag" && t.name.startsWith("@")) .map((t) => t.name); } @@ -355,11 +387,17 @@ function findAtRuleTags(tokens) { * Find namespace-qualified tokens and return readable renderings of each * (e.g. "svg|rect", "*|foo", "[html|lang]"). */ -function findNamespacedTokens(tokens) { +function findNamespacedTokens(tokens: Selector[]): string[] { return tokens - .filter((t) => t.namespace != null) + .filter( + (t): t is NamespacedSelector => + (t.type === "attribute" || + t.type === "tag" || + t.type === "universal") && + t.namespace != null, + ) .map((t) => { - const name = t.name ?? "*"; + const name = "name" in t ? t.name : "*"; const prefix = t.namespace; const rendering = `${prefix}|${name}`; return t.type === "attribute" ? `[${rendering}]` : rendering; @@ -370,9 +408,9 @@ function findNamespacedTokens(tokens) { * If the target compound's tag is in NON_CONTAINER_TAGS, return that tag * name; otherwise return null. Used only when linting `container` entries. */ -function findNonContainerTarget(tokens) { +function findNonContainerTarget(tokens: Selector[]): string | null { const compound = getLastCompound(tokens); - const tag = compound.find((t) => t.type === "tag"); + const tag = compound.find((t): t is TagSelector => t.type === "tag"); if (tag && NON_CONTAINER_TAGS.has(tag.name)) { return tag.name; } @@ -389,7 +427,7 @@ function findNonContainerTarget(tokens) { * satisfy the check, which is a deliberate simplification; the common * authoring patterns we want to accept are a `form` tag or an explicit role. */ -function hasFormAnchor(tokens) { +function hasFormAnchor(tokens: Selector[]): boolean { const compound = getLastCompound(tokens); for (const t of compound) { if (t.type === "tag" && t.name === "form") { @@ -411,10 +449,10 @@ function hasFormAnchor(tokens) { * Find attribute matchers that use an operator equivalent to existence check * when the value is empty (*=, ^=, $= with empty value). */ -function findExistenceEquivalentEmpty(tokens) { +function findExistenceEquivalentEmpty(tokens: Selector[]): string[] { return tokens .filter( - (t) => + (t): t is AttributeSelector => t.type === "attribute" && EXISTENCE_EQUIVALENT_ACTIONS.has(t.action) && t.value === "", @@ -426,10 +464,10 @@ function findExistenceEquivalentEmpty(tokens) { * Find attribute matchers that match no elements when the value is empty * (~= with empty value). */ -function findAlwaysFalseEmpty(tokens) { +function findAlwaysFalseEmpty(tokens: Selector[]): string[] { return tokens .filter( - (t) => + (t): t is AttributeSelector => t.type === "attribute" && ALWAYS_FALSE_EMPTY_ACTIONS.has(t.action) && t.value === "", @@ -440,8 +478,8 @@ function findAlwaysFalseEmpty(tokens) { /** * Return which sibling combinators (if any) appear in a token list. */ -function findSiblingCombinators(tokens) { - const found = []; +function findSiblingCombinators(tokens: Selector[]): string[] { + const found: string[] = []; for (const t of tokens) { if (t.type === "adjacent") { found.push("+"); @@ -466,9 +504,12 @@ function findSiblingCombinators(tokens) { * the caller can keep aggregating other errors against the remainder * rather than bailing at the first boundary violation. */ -function checkBoundaryCombinator(raw) { +function checkBoundaryCombinator(raw: string): { + messages: string[]; + sanitized: string; +} { let sanitized = raw.trim(); - const messages = []; + const messages: string[] = []; if (!sanitized.includes(BOUNDARY_COMBINATOR)) { return { messages, sanitized }; @@ -499,9 +540,9 @@ function checkBoundaryCombinator(raw) { * mistake (copying from a SCSS/Tailwind/native-nesting stylesheet). Catching * this before the parser lets us emit a targeted remediation instead. */ -function containsNestingAmpersand(segment) { +function containsNestingAmpersand(segment: string): boolean { let bracketDepth = 0; - let quote = null; + let quote: string | null = null; for (let i = 0; i < segment.length; i++) { const ch = segment[i]; if (quote) { @@ -544,7 +585,7 @@ function containsNestingAmpersand(segment) { * should be replaced with a tokenizing split that respects bracket/quote * regions. */ -function splitBoundarySegments(raw) { +function splitBoundarySegments(raw: string): string[] { return raw.split(BOUNDARY_COMBINATOR).map((s) => s.trim()); } @@ -555,9 +596,9 @@ function splitBoundarySegments(raw) { /** * Lint a single selector string. Returns { errors: [], warnings: [] }. */ -export function lintSelector(raw, location) { - const errors = []; - const warnings = []; +export function lintSelector(raw: string, location: Location): LintResult { + const errors: Finding[] = []; + const warnings: Finding[] = []; const formattedLocation = formatLocation(location); // Empty-or-whitespace-only selector: schema's minLength:1 allows these @@ -636,14 +677,15 @@ export function lintSelector(raw, location) { continue; } - let parsedSelectors; + let parsedSelectors: Selector[][]; try { parsedSelectors = parseCss(segment); } catch (e) { + const message = e instanceof Error ? e.message : String(e); errors.push({ location: formattedLocation, selector: raw, - message: `Invalid CSS syntax in segment "${segment}" - ${e.message}`, + message: `Invalid CSS syntax in segment "${segment}" - ${message}`, }); continue; } @@ -896,9 +938,9 @@ export function lintSelector(raw, location) { /** * Extract and lint all selectors from a parsed map data object. */ -export function lintMapData(data) { - const allErrors = []; - const allWarnings = []; +export function lintMapData(data: FormMapData): LintResult { + const allErrors: Finding[] = []; + const allWarnings: Finding[] = []; if (!data.hosts) { return { errors: allErrors, warnings: allWarnings }; @@ -938,7 +980,12 @@ export function lintMapData(data) { /** * Lint all selectors within a forms array. */ -function lintForms(forms, context, errors, warnings) { +function lintForms( + forms: Form[], + context: Location, + errors: Finding[], + warnings: Finding[], +): void { for (const form of forms) { const category = form.category || "unknown"; lintPasswordFieldSemantics(form, category, context, errors); @@ -982,7 +1029,12 @@ function lintForms(forms, context, errors, warnings) { /** * Lint a selectorArray (array of selector strings). */ -function lintSelectorArray(selectors, context, errors, warnings) { +function lintSelectorArray( + selectors: string[], + context: Location, + errors: Finding[], + warnings: Finding[], +): void { checkDuplicates(selectors, context, errors); for (let i = 0; i < selectors.length; i++) { @@ -995,7 +1047,12 @@ function lintSelectorArray(selectors, context, errors, warnings) { /** * Lint a compositeSelectorArray (items can be strings or arrays of strings). */ -function lintCompositeSelectorArray(selectors, context, errors, warnings) { +function lintCompositeSelectorArray( + selectors: CompositeSelectorArray, + context: Location, + errors: Finding[], + warnings: Finding[], +): void { // Duplicate check on top-level string entries. Pass the original array so // the reported selectorIndex reflects the author's file position; nested // sequence arrays are skipped by `checkDuplicates`'s non-string guard. @@ -1035,10 +1092,15 @@ function lintCompositeSelectorArray(selectors, context, errors, warnings) { * selector sequences) are skipped; duplicates inside a sequence are allowed * and handled by the caller. */ -function checkDuplicates(selectors, context, errors) { - const seen = new Set(); +function checkDuplicates( + selectors: CompositeSelectorArray, + context: Location, + errors: Finding[], +): void { + const seen = new Set(); for (let i = 0; i < selectors.length; i++) { - const s = typeof selectors[i] === "string" ? selectors[i] : null; + const item = selectors[i]; + const s = typeof item === "string" ? item : null; if (s == null) { continue; @@ -1056,13 +1118,18 @@ function checkDuplicates(selectors, context, errors) { } /** Reject password key mismatches for login vs creation forms. */ -function lintPasswordFieldSemantics(form, category, context, errors) { +function lintPasswordFieldSemantics( + form: Form, + category: string, + context: Location, + errors: Finding[], +): void { const fields = form.fields; if (!fields) { return; } - const headSelector = (selectors) => + const headSelector = (selectors: CompositeSelectorArray): string | null => Array.isArray(selectors) && typeof selectors[0] === "string" ? selectors[0] : null; diff --git a/scripts/lib/lint-selectors.test.mjs b/scripts/lib/lint-selectors.test.mts similarity index 98% rename from scripts/lib/lint-selectors.test.mjs rename to scripts/lib/lint-selectors.test.mts index 3a36c8d..3868298 100644 --- a/scripts/lib/lint-selectors.test.mjs +++ b/scripts/lib/lint-selectors.test.mts @@ -4,10 +4,11 @@ import { lintSelector, lintMapData, formatLocation, -} from "./lint-selectors.mjs"; +} from "./lint-selectors.mts"; +import type { Location, Finding, CompositeSelectorArray } from "./types.mts"; // Shorthand: build a minimal location object for lintSelector calls -function loc(overrides = {}) { +function loc(overrides: Partial = {}): Location { return { host: "example.com", category: "account-login", @@ -19,10 +20,10 @@ function loc(overrides = {}) { } // Helpers to pull just error/warning counts from a lintSelector result -function errorsFor(selector, location) { +function errorsFor(selector: string, location?: Partial) { return lintSelector(selector, loc(location)).errors; } -function warningsFor(selector, location) { +function warningsFor(selector: string, location?: Partial) { return lintSelector(selector, loc(location)).warnings; } @@ -1014,7 +1015,7 @@ describe("container non-container target", () => { describe("missing tag anchor", () => { // Distinct from the ID-only message (which also contains "omits the // element type"). Match on the remediation-specific phrase instead. - const missingTagMatcher = (w) => /Add a tag anchor/.test(w.message); + const missingTagMatcher = (w: Finding) => /Add a tag anchor/.test(w.message); it("warns on attribute-only selector [name='username']", () => { const warnings = warningsFor("[name='username']"); @@ -1131,7 +1132,8 @@ describe("missing tag anchor", () => { // --------------------------------------------------------------------------- describe("pseudo-only selector", () => { - const pseudoOnlyMatcher = (w) => /Pseudo-only selector/.test(w.message); + const pseudoOnlyMatcher = (w: Finding) => + /Pseudo-only selector/.test(w.message); it("warns on a bare state pseudo (`:hover`)", () => { const warnings = warningsFor(":hover"); @@ -1225,7 +1227,7 @@ describe("container form anchor", () => { }; } - const anchorMatcher = (w) => + const anchorMatcher = (w: Finding) => /does not anchor on a form element/.test(w.message); it("warns when a container targets a generic
", () => { @@ -1673,7 +1675,10 @@ describe("clean selectors produce no issues", () => { // --------------------------------------------------------------------------- describe("password field semantics", () => { - function lintExampleForm(fields, category) { + function lintExampleForm( + fields: Record, + category: string, + ) { return lintMapData({ hosts: { "example.com": { @@ -1683,7 +1688,11 @@ describe("password field semantics", () => { }); } - function semanticErrors(fields, category, pattern) { + function semanticErrors( + fields: Record, + category: string, + pattern: RegExp, + ) { return lintExampleForm(fields, category).errors.filter((e) => pattern.test(e.message), ); diff --git a/scripts/lib/types.mts b/scripts/lib/types.mts new file mode 100644 index 0000000..f8b9ba0 --- /dev/null +++ b/scripts/lib/types.mts @@ -0,0 +1,65 @@ +// --------------------------------------------------------------------------- +// Shared types for the selector linter and the map data it operates on. +// --------------------------------------------------------------------------- + +/** + * Identifies where in a map a selector lives. `host` is always known; the + * remaining fields are filled in as the linter descends into a host's forms, + * fields, and actions. `formatLocation` renders these into a readable path. + */ +export interface Location { + host: string; + pathname?: string; + category?: string; + kind?: string; + key?: string; + selectorIndex?: number; + sequenceIndex?: number; +} + +/** A single lint error or warning. `selector` is null when none applies. */ +export interface Finding { + location: string; + selector: string | null; + message: string; +} + +/** Result of linting a selector, segment, or whole map. */ +export interface LintResult { + errors: Finding[]; + warnings: Finding[]; +} + +// --------------------------------------------------------------------------- +// Map data shapes (the subset the selector linter reads). +// +// Source data is parsed from JSONC, so callers should treat it as `unknown` +// and cast to `FormMapData` at the boundary; only the properties read here are +// modeled. The schema is the source of truth for the full shape. +// --------------------------------------------------------------------------- + +/** + * A field value: either a single selector array, or a sequence of selector + * arrays (when one logical value is split across multiple inputs). + */ +export type CompositeSelectorArray = (string | string[])[]; + +export interface Form { + category?: string; + container?: string[]; + fields?: Record; + actions?: Record; +} + +export interface HostEntry { + forms?: Form[]; + pathnames?: Record; +} + +export interface PathEntry { + forms?: Form[]; +} + +export interface FormMapData { + hosts?: Record; +} diff --git a/scripts/lint-selectors.mjs b/scripts/lint-selectors.mts similarity index 88% rename from scripts/lint-selectors.mjs rename to scripts/lint-selectors.mts index ab3bf5b..ed3a540 100644 --- a/scripts/lint-selectors.mjs +++ b/scripts/lint-selectors.mts @@ -1,9 +1,10 @@ -import { lintMapData } from "./lib/lint-selectors.mjs"; +import { lintMapData } from "./lib/lint-selectors.mts"; import stripJsonComments from "strip-json-comments"; import { readFileSync } from "fs"; import { glob } from "node:fs/promises"; -import { red, yellow, green, dim } from "./utils.mjs"; +import { red, yellow, green, dim } from "./utils.mts"; import { execFileSync } from "node:child_process"; +import type { FormMapData } from "./lib/types.mts"; // --------------------------------------------------------------------------- // Environment configuration @@ -31,8 +32,8 @@ const scopeAnnotationsToDiff = emitAnnotations && isPullRequest && !!baseRef; * computed (e.g., base ref not fetched), signaling callers to fall back to * emitting every annotation. */ -function getChangedLinesForFile(file) { - let output; +function getChangedLinesForFile(file: string): Set | null { + let output: string; try { output = execFileSync( "git", @@ -43,7 +44,7 @@ function getChangedLinesForFile(file) { return null; } - const changed = new Set(); + const changed = new Set(); for (const line of output.split("\n")) { // Hunk header: "@@ -[,] +[,] @@" const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line); @@ -67,7 +68,11 @@ function getChangedLinesForFile(file) { * within that scope. Returns null when any anchor or the selector can't be * located; callers should fall back to the logical location in that case. */ -function findLineInSource(source, formattedLocation, selector) { +function findLineInSource( + source: string, + formattedLocation: string, + selector: string | null, +): number | null { if (!selector) { return null; } @@ -119,14 +124,20 @@ function findLineInSource(source, formattedLocation, selector) { * Escape a message for a GitHub Actions workflow command. * https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#about-workflow-commands */ -function ghEscape(value) { +function ghEscape(value: string): string { return String(value) .replace(/%/g, "%25") .replace(/\r/g, "%0D") .replace(/\n/g, "%0A"); } -function ghWorkflowCommand(severity, file, codeLine, title, message) { +function ghWorkflowCommand( + severity: string, + file: string, + codeLine: number | null, + title: string, + message: string, +): string { const parts = [`file=${ghEscape(file)}`]; if (codeLine != null) { parts.push(`line=${codeLine}`); @@ -159,11 +170,12 @@ let totalErrors = 0; let totalWarnings = 0; for (const file of files) { - let source; + let source: string; try { source = readFileSync(file, "utf-8"); } catch (e) { - console.error(red(`Failed to read ${file}: ${e.message}`)); + const message = e instanceof Error ? e.message : String(e); + console.error(red(`Failed to read ${file}: ${message}`)); totalErrors++; continue; } @@ -174,11 +186,12 @@ for (const file of files) { // produce false matches. const stripped = stripJsonComments(source); - let data; + let data: FormMapData; try { - data = JSON.parse(stripped); + data = JSON.parse(stripped) as FormMapData; } catch (e) { - console.error(red(`Failed to parse ${file}: ${e.message}`)); + const message = e instanceof Error ? e.message : String(e); + console.error(red(`Failed to parse ${file}: ${message}`)); totalErrors++; continue; } @@ -190,7 +203,7 @@ for (const file of files) { continue; } - let changedLines; + let changedLines: Set | null | undefined; if (scopeAnnotationsToDiff) { changedLines = getChangedLinesForFile(file); if (changedLines == null) { @@ -205,7 +218,7 @@ for (const file of files) { // A finding without a resolved line number can't be verified as in-diff, // so it's dropped from inline annotations under diff-scoping. Console // output is unaffected. - const shouldAnnotate = (codeLine) => { + const shouldAnnotate = (codeLine: number | null): boolean => { if (!emitAnnotations) { return false; } diff --git a/scripts/utils.mjs b/scripts/utils.mjs deleted file mode 100644 index 6dc38c7..0000000 --- a/scripts/utils.mjs +++ /dev/null @@ -1,9 +0,0 @@ -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -export const red = (s) => `\x1b[31m${s}\x1b[0m`; -export const yellow = (s) => `\x1b[33m${s}\x1b[0m`; -export const green = (s) => `\x1b[32m${s}\x1b[0m`; -export const cyan = (s) => `\x1b[36m${s}\x1b[0m`; -export const dim = (s) => `\x1b[2m${s}\x1b[0m`; diff --git a/scripts/utils.mts b/scripts/utils.mts new file mode 100644 index 0000000..6e243ff --- /dev/null +++ b/scripts/utils.mts @@ -0,0 +1,9 @@ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export const red = (s: string): string => `\x1b[31m${s}\x1b[0m`; +export const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`; +export const green = (s: string): string => `\x1b[32m${s}\x1b[0m`; +export const cyan = (s: string): string => `\x1b[36m${s}\x1b[0m`; +export const dim = (s: string): string => `\x1b[2m${s}\x1b[0m`; diff --git a/scripts/validate-schemas.mjs b/scripts/validate-schemas.mts similarity index 80% rename from scripts/validate-schemas.mjs rename to scripts/validate-schemas.mts index b01d81b..63b6fb4 100644 --- a/scripts/validate-schemas.mjs +++ b/scripts/validate-schemas.mts @@ -1,10 +1,15 @@ -import Ajv2020 from "ajv/dist/2020.js"; -import addFormats from "ajv-formats"; +import Ajv2020Import from "ajv/dist/2020.js"; +import addFormatsImport from "ajv-formats"; import stripJsonComments from "strip-json-comments"; import { readFileSync, existsSync } from "fs"; import { basename, dirname, join } from "path"; import { glob } from "node:fs/promises"; -import { red, yellow, green } from "./utils.mjs"; +import { red, yellow, green } from "./utils.mts"; + +// ajv and ajv-formats are CommonJS; under NodeNext their ESM default import is +// the module namespace, so the constructor/function lives on `.default`. +const Ajv2020 = Ajv2020Import.default; +const addFormats = addFormatsImport.default; const ajv = new Ajv2020({ allErrors: true }); addFormats(ajv); @@ -30,7 +35,10 @@ for (const file of files) { const name = basename(file, ".jsonc"); // Parse data first to read schemaVersion for schema file lookup - const data = JSON.parse(stripJsonComments(readFileSync(file, "utf-8"))); + const data = JSON.parse(stripJsonComments(readFileSync(file, "utf-8"))) as { + schemaVersion?: string; + hosts?: Record; + }; if (!data.schemaVersion) { console.error(red(`No schemaVersion found in ${file}`)); @@ -52,7 +60,7 @@ for (const file of files) { const validate = ajv.compile(schema); if (!validate(data)) { console.error(red(`Validation failed: ${file}`)); - for (const err of validate.errors) { + for (const err of validate.errors ?? []) { console.error(red(` ${err.instancePath || "/"}: ${err.message}`)); } hasErrors = true; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..461e4d8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "esnext", + "lib": ["esnext"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["scripts/**/*.mts"] +}