diff --git a/.eslintrc.js b/.eslintrc.js index debf922a..ec23ecaf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,11 +19,19 @@ module.exports = { ecmaVersion: 2018, sourceType: 'module', }, - plugins: ['react', '@typescript-eslint', 'typescript-sort-keys', 'sort-destructure-keys', 'sort-keys-fix'], + plugins: [ + 'react', + '@typescript-eslint', + 'typescript-sort-keys', + 'sort-destructure-keys', + 'sort-keys-fix', + 'no-direct-record-string', + ], rules: { '@typescript-eslint/explicit-module-boundary-types': 0, // Verbose '@typescript-eslint/no-empty-function': 0, // unnecessary '@typescript-eslint/no-unused-vars': 1, // hint not error + 'no-direct-record-string/no-direct-record-string': 'error', // type safety 'react/jsx-sort-props': [2, { callbacksLast: true, shorthandFirst: true }], // style 'react/no-unknown-property': [2, { ignore: ['jsx', 'global'] }], // inserted by next's styled-jsx 'react/react-in-jsx-scope': 0, // Handled by Next.js diff --git a/package.json b/package.json index 951d8c34..b70d2334 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "eslint": "^8", "eslint-config-prettier": "^6.10.1", "eslint-plugin-cypress": "^2.12.1", + "eslint-plugin-no-direct-record-string": "file:src/_shared/custom-lint-plugins/no-direct-record-string", "eslint-plugin-react": "^7.20.1", "eslint-plugin-sort-destructure-keys": "1.5.0", "eslint-plugin-sort-keys-fix": "^1.1.1", diff --git a/src/_shared/custom-lint-plugins/no-direct-record-string/index.js b/src/_shared/custom-lint-plugins/no-direct-record-string/index.js new file mode 100644 index 00000000..a60a3e0c --- /dev/null +++ b/src/_shared/custom-lint-plugins/no-direct-record-string/index.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + 'no-direct-record-string': require('./no-direct-record-string'), + }, +} diff --git a/src/_shared/custom-lint-plugins/no-direct-record-string/no-direct-record-string.js b/src/_shared/custom-lint-plugins/no-direct-record-string/no-direct-record-string.js new file mode 100644 index 00000000..ec187f43 --- /dev/null +++ b/src/_shared/custom-lint-plugins/no-direct-record-string/no-direct-record-string.js @@ -0,0 +1,30 @@ +module.exports = { + create(context) { + return { + TSTypeReference(node) { + if ( + node.typeName.name === 'Record' && + node.typeParameters.params.length === 2 && + node.typeParameters.params[0].type === 'TSStringKeyword' + ) { + // Check if the parent node is a TSTypeReference or TSTypeOperator + // This handles cases like Partial> + let parent = node.parent + while (parent) { + if (parent.type === 'TSTypeReference' || parent.type === 'TSTypeOperator') { + // If the parent type is TSTypeReference or TSTypeOperator, it means + // Record is nested inside another type, so skip reporting + return + } + parent = parent.parent + } + + context.report({ + message: 'Direct use of Record is unsafe. Use Partial> or explicit indices.', + node, + }) + } + }, + } + }, +} diff --git a/src/_shared/custom-lint-plugins/no-direct-record-string/package.json b/src/_shared/custom-lint-plugins/no-direct-record-string/package.json new file mode 100644 index 00000000..281ad0de --- /dev/null +++ b/src/_shared/custom-lint-plugins/no-direct-record-string/package.json @@ -0,0 +1,15 @@ +{ + "name": "eslint-plugin-no-direct-record-string", + "version": "1.0.0", + "description": "Custom eslint rule to warn against type Record, which is unsafe. Use Partial", + "keywords": [ + "eslint", + "eslintplugin", + "eslint-plugin", + "typescript", + "typesafety" + ], + "license": "ISC", + "author": "dsernst", + "main": "index.js" +} diff --git a/src/status/tally-votes.ts b/src/status/tally-votes.ts index b6be8f27..d8d1982c 100644 --- a/src/status/tally-votes.ts +++ b/src/status/tally-votes.ts @@ -4,12 +4,17 @@ import { Item } from 'src/vote/storeElectionInfo' import { mapValues } from '../utils' import { tally_IRV_Items } from './tallying/rcv-irv' -export function tallyVotes(ballot_items_by_id: Record, votes: Record[]) { +type SafeRecord = Partial> + +export function tallyVotes( + ballot_items_by_id: Partial>, + votes: Partial>[], +) { const multi_vote_regex = /_(\d+)$/ // Sum up votes - const tallies: Record> = {} - const IRV_columns_seen: Record = {} + const tallies: SafeRecord> = {} + const IRV_columns_seen: SafeRecord = {} votes.forEach((vote) => { Object.keys(vote).forEach((key) => { // Skip 'tracking' key @@ -41,7 +46,7 @@ export function tallyVotes(ballot_items_by_id: Record, votes: Reco }) // Calc total votes cast per item, to speed up calc'ing %s - const totalsCastPerItems: Record = {} + const totalsCastPerItems: SafeRecord = {} Object.keys(tallies).forEach((item) => { totalsCastPerItems[item] = 0 Object.keys(tallies[item]).forEach((choice) => (totalsCastPerItems[item] += tallies[item][choice])) @@ -50,11 +55,11 @@ export function tallyVotes(ballot_items_by_id: Record, votes: Reco // Sort each item's totals from highest to lowest, with ties sorted alphabetically const ordered = mapValues(tallies, (item_totals, item_id) => orderBy( - orderBy(Object.keys(item_totals as Record)), + orderBy(Object.keys(item_totals as SafeRecord)), (selection) => tallies[item_id][selection], 'desc', ), - ) as Record + ) as SafeRecord // Go back and IRV tally any of those that we skipped const irv = tally_IRV_Items(IRV_columns_seen, ballot_items_by_id, votes)