From 0bb164904fb6a8352c6141fc44ff7cd30425e750 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 15:54:17 -0700 Subject: [PATCH] ci: make frontend lint fail closed --- .github/lint-baseline.json | 1857 +++++++++++++++++++++++ .github/scripts/check-frontend-lint.cjs | 396 +++++ .github/workflows/ci.yml | 14 +- GITHUB_ISSUES.md | 8 +- GITHUB_ISSUE_SLICES.md | 1 + OPERATIONS_RUNBOOK.md | 3 +- SECURITY.md | 4 +- package.json | 1 + tests/ci-workflow.test.js | 365 +++++ 9 files changed, 2640 insertions(+), 9 deletions(-) create mode 100644 .github/lint-baseline.json create mode 100644 .github/scripts/check-frontend-lint.cjs diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json new file mode 100644 index 00000000..54a5ad49 --- /dev/null +++ b/.github/lint-baseline.json @@ -0,0 +1,1857 @@ +{ + "schemaVersion": 2, + "algorithm": "sha256-canonical-file-and-finding-records-v2", + "extensions": [ + ".js", + ".jsx", + ".ts", + ".tsx" + ], + "targets": [ + "src" + ], + "scannedFileCount": 102, + "scannedFilesByExtension": { + ".js": 12, + ".jsx": 35, + ".ts": 31, + ".tsx": 24 + }, + "errorCount": 125, + "warningCount": 7, + "errorsByExtension": { + ".js": 2, + ".jsx": 27, + ".ts": 2, + ".tsx": 94 + }, + "warningsByExtension": { + ".js": 0, + ".jsx": 0, + ".ts": 0, + ".tsx": 7 + }, + "fingerprint": "sha256:47721020c5ff47ef62494b6dcc6f7b806a4d446d32ef5f9193b1437a324c69b0", + "scannedFiles": [ + "src/App.jsx", + "src/App.test.jsx", + "src/components/AnnouncementBanner.jsx", + "src/components/Card.jsx", + "src/components/ErrorBoundary.tsx", + "src/components/FlexColumnContainer.jsx", + "src/components/Footer.jsx", + "src/components/Header.jsx", + "src/components/ImageCarousel.jsx", + "src/components/MainHeader.jsx", + "src/components/MembersOnly.jsx", + "src/components/MetaText.jsx", + "src/components/Navbar.jsx", + "src/components/ObfuscatedEmail.jsx", + "src/components/Officer.jsx", + "src/components/Paragraph.jsx", + "src/components/SEO.d.ts", + "src/components/SEO.jsx", + "src/components/ScrollToTop.jsx", + "src/components/SectionHead.jsx", + "src/components/Subheader.jsx", + "src/components/UpcomingEventsPreview.tsx", + "src/components/VideoCarousel.jsx", + "src/components/index.ts", + "src/data.jsx", + "src/headerClearance.test.js", + "src/index.js", + "src/pages/about/About.jsx", + "src/pages/account/Account.test.tsx", + "src/pages/account/Account.tsx", + "src/pages/account/StravaCallback.tsx", + "src/pages/account/StravaSection.tsx", + "src/pages/activities/Activities.jsx", + "src/pages/admin/AdminGuard.tsx", + "src/pages/admin/AdminHome.tsx", + "src/pages/admin/events/AdminEventEditor.tsx", + "src/pages/admin/events/AdminEventRegistrations.tsx", + "src/pages/admin/events/AdminEventsList.tsx", + "src/pages/admin/members/AdminMembers.tsx", + "src/pages/admin/shop/AdminOrders.tsx", + "src/pages/admin/shop/AdminProductEditor.tsx", + "src/pages/admin/shop/AdminProducts.tsx", + "src/pages/contact/Contact.jsx", + "src/pages/events/EventCalendar.tsx", + "src/pages/events/EventDetail.tsx", + "src/pages/events/EventRegister.tsx", + "src/pages/events/Events.tsx", + "src/pages/events/RegisterSuccess.tsx", + "src/pages/home/Home.jsx", + "src/pages/joinUs/JoinUs.jsx", + "src/pages/joinUs/JoinUsConditionalRoute.jsx", + "src/pages/joinUs/Route.jsx", + "src/pages/joinUs/Waiver.jsx", + "src/pages/legal/Privacy.jsx", + "src/pages/legal/Terms.jsx", + "src/pages/login/LoginForm.jsx", + "src/pages/login/LoginForm.test.jsx", + "src/pages/login/loginReturnPath.ts", + "src/pages/notFound/NotFound.jsx", + "src/pages/officers/Committee.jsx", + "src/pages/shop/ProductDetail.tsx", + "src/pages/shop/PurchaseSuccess.tsx", + "src/pages/shop/Shop.tsx", + "src/reportWebVitals.js", + "src/services/ServiceLocatorContext.ts", + "src/services/ServiceLocatorProvider.tsx", + "src/services/account/accountService.test.ts", + "src/services/account/accountService.ts", + "src/services/account/adminMembersService.ts", + "src/services/analytics/analytics.test.ts", + "src/services/analytics/analytics.ts", + "src/services/events/adminService.ts", + "src/services/events/eventsService.ts", + "src/services/firebase/FirebaseResources.test.ts", + "src/services/firebase/FirebaseResources.ts", + "src/services/hooks/index.ts", + "src/services/hooks/useAuth.ts", + "src/services/identity/Identity.test.ts", + "src/services/identity/Identity.ts", + "src/services/index.ts", + "src/services/monitoring/capabilityCallback.ts", + "src/services/monitoring/clientDiagnostics.test.ts", + "src/services/monitoring/clientDiagnostics.ts", + "src/services/monitoring/sentry.test.ts", + "src/services/monitoring/sentry.ts", + "src/services/seo/index.ts", + "src/services/seo/structuredData.ts", + "src/services/shop/shopService.ts", + "src/services/strava/stravaService.ts", + "src/setupTests.js", + "src/text/Activities.js", + "src/text/Committee.js", + "src/text/ContactUs.js", + "src/text/Footer.js", + "src/text/Home.js", + "src/text/JoinUs.js", + "src/text/externalLinks.js", + "src/text/index.ts", + "src/theme.js", + "src/types/events.ts", + "src/types/member.ts", + "src/types/shop.ts" + ], + "findings": [ + { + "file": "src/components/ObfuscatedEmail.jsx", + "line": 21, + "column": 38, + "endLine": 23, + "endColumn": 4, + "severity": 2, + "ruleId": "arrow-body-style", + "message": "Unexpected block statement surrounding arrow body; move the returned value immediately after the `=>`.", + "messageId": "unexpectedSingleBlock", + "nodeType": "ArrowFunctionExpression", + "fatal": false + }, + { + "file": "src/data.jsx", + "line": 1, + "column": 1, + "endLine": 37, + "endColumn": 3, + "severity": 2, + "ruleId": "import/prefer-default-export", + "message": "Prefer default export on a file with single export.", + "messageId": null, + "nodeType": "ExportNamedDeclaration", + "fatal": false + }, + { + "file": "src/index.js", + "line": 10, + "column": 13, + "endLine": 10, + "endColumn": 20, + "severity": 2, + "ruleId": "react/jsx-filename-extension", + "message": "JSX not allowed in files with extension '.js'", + "messageId": "noJSXWithExtension", + "nodeType": "JSXElement", + "fatal": false + }, + { + "file": "src/pages/about/About.jsx", + "line": 61, + "column": 23, + "endLine": 61, + "endColumn": 49, + "severity": 2, + "ruleId": "react/no-array-index-key", + "message": "Do not use Array index in keys", + "messageId": "noArrayIndex", + "nodeType": "TemplateLiteral", + "fatal": false + }, + { + "file": "src/pages/about/About.jsx", + "line": 71, + "column": 23, + "endLine": 71, + "endColumn": 51, + "severity": 2, + "ruleId": "react/no-array-index-key", + "message": "Do not use Array index in keys", + "messageId": "noArrayIndex", + "nodeType": "TemplateLiteral", + "fatal": false + }, + { + "file": "src/pages/account/StravaSection.tsx", + "line": 187, + "column": 11, + "endLine": 187, + "endColumn": 42, + "severity": 2, + "ruleId": "react/jsx-no-bind", + "message": "JSX props should not use functions", + "messageId": "func", + "nodeType": "JSXAttribute", + "fatal": false + }, + { + "file": "src/pages/account/StravaSection.tsx", + "line": 191, + "column": 23, + "endLine": 191, + "endColumn": 48, + "severity": 2, + "ruleId": "react/jsx-no-bind", + "message": "JSX props should not use functions", + "messageId": "func", + "nodeType": "JSXAttribute", + "fatal": false + }, + { + "file": "src/pages/admin/AdminGuard.tsx", + "line": 36, + "column": 10, + "endLine": 36, + "endColumn": 25, + "severity": 2, + "ruleId": "react/jsx-no-useless-fragment", + "message": "Fragments should contain more than one child - otherwise, there’s no need for a Fragment at all.", + "messageId": "NeedsMoreChildren", + "nodeType": "JSXFragment", + "fatal": false + }, + { + "file": "src/pages/admin/AdminHome.tsx", + "line": 148, + "column": 1, + "endLine": 148, + "endColumn": 114, + "severity": 2, + "ruleId": "max-len", + "message": "This line has a length of 113. Maximum allowed is 100.", + "messageId": "max", + "nodeType": "Program", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 211, + "column": 19, + "endLine": 211, + "endColumn": 20, + "severity": 2, + "ruleId": "react/no-array-index-key", + "message": "Do not use Array index in keys", + "messageId": "noArrayIndex", + "nodeType": "Identifier", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 212, + "column": 11, + "endLine": 212, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 220, + "column": 11, + "endLine": 220, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 228, + "column": 11, + "endLine": 228, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 245, + "column": 11, + "endLine": 245, + "endColumn": 67, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 263, + "column": 13, + "endLine": 263, + "endColumn": 54, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 348, + "column": 11, + "endLine": 348, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 362, + "column": 11, + "endLine": 362, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 377, + "column": 11, + "endLine": 377, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 388, + "column": 13, + "endLine": 388, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 398, + "column": 13, + "endLine": 398, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 410, + "column": 13, + "endLine": 410, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 419, + "column": 13, + "endLine": 419, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 430, + "column": 11, + "endLine": 430, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 438, + "column": 11, + "endLine": 438, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 449, + "column": 13, + "endLine": 449, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 460, + "column": 13, + "endLine": 460, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 473, + "column": 13, + "endLine": 473, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 490, + "column": 15, + "endLine": 490, + "endColumn": 40, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 501, + "column": 15, + "endLine": 501, + "endColumn": 40, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 512, + "column": 15, + "endLine": 512, + "endColumn": 40, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 523, + "column": 15, + "endLine": 523, + "endColumn": 40, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 540, + "column": 13, + "endLine": 540, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 548, + "column": 1, + "endLine": 548, + "endColumn": 112, + "severity": 2, + "ruleId": "max-len", + "message": "This line has a length of 111. Maximum allowed is 100.", + "messageId": "max", + "nodeType": "Program", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 551, + "column": 13, + "endLine": 551, + "endColumn": 43, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 569, + "column": 13, + "endLine": 569, + "endColumn": 64, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 593, + "column": 13, + "endLine": 593, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 602, + "column": 13, + "endLine": 602, + "endColumn": 43, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventEditor.tsx", + "line": 625, + "column": 16, + "endLine": 625, + "endColumn": 79, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 182, + "column": 3, + "endLine": 182, + "endColumn": 58, + "severity": 2, + "ruleId": "react-hooks/exhaustive-deps", + "message": "Definition for rule 'react-hooks/exhaustive-deps' was not found.", + "messageId": null, + "nodeType": null, + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 233, + "column": 12, + "endLine": 233, + "endColumn": 13, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break after this opening brace.", + "messageId": "expectedLinebreakAfterOpeningBrace", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 233, + "column": 86, + "endLine": 233, + "endColumn": 87, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break before this closing brace.", + "messageId": "expectedLinebreakBeforeClosingBrace", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 236, + "column": 1, + "endLine": 236, + "endColumn": 109, + "severity": 2, + "ruleId": "max-len", + "message": "This line has a length of 108. Maximum allowed is 100.", + "messageId": "max", + "nodeType": "Program", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 249, + "column": 9, + "endLine": 252, + "endColumn": 10, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected prompt.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 491, + "column": 11, + "endLine": 491, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 514, + "column": 1, + "endLine": 514, + "endColumn": 102, + "severity": 2, + "ruleId": "max-len", + "message": "This line has a length of 101. Maximum allowed is 100.", + "messageId": "max", + "nodeType": "Program", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 516, + "column": 11, + "endLine": 516, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 593, + "column": 13, + "endLine": 593, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/events/AdminEventRegistrations.tsx", + "line": 607, + "column": 13, + "endLine": 607, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/members/AdminMembers.tsx", + "line": 60, + "column": 3, + "endLine": 60, + "endColumn": 58, + "severity": 2, + "ruleId": "react-hooks/exhaustive-deps", + "message": "Definition for rule 'react-hooks/exhaustive-deps' was not found.", + "messageId": null, + "nodeType": null, + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 56, + "column": 3, + "endLine": 56, + "endColumn": 58, + "severity": 2, + "ruleId": "react-hooks/exhaustive-deps", + "message": "Definition for rule 'react-hooks/exhaustive-deps' was not found.", + "messageId": null, + "nodeType": null, + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 73, + "column": 22, + "endLine": 73, + "endColumn": 66, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected prompt.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 74, + "column": 18, + "endLine": 74, + "endColumn": 51, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected prompt.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 78, + "column": 17, + "endLine": 81, + "endColumn": 6, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected prompt.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 84, + "column": 11, + "endLine": 84, + "endColumn": 78, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected confirm.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 94, + "column": 18, + "endLine": 94, + "endColumn": 59, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected prompt.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminOrders.tsx", + "line": 95, + "column": 9, + "endLine": 95, + "endColumn": 97, + "severity": 1, + "ruleId": "no-alert", + "message": "Unexpected confirm.", + "messageId": "unexpected", + "nodeType": "CallExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 28, + "column": 13, + "endLine": 28, + "endColumn": 18, + "severity": 2, + "ruleId": "object-property-newline", + "message": "Object properties must go on a new line if they aren't all on the same line.", + "messageId": "propertiesOnNewlineAll", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 28, + "column": 24, + "endLine": 28, + "endColumn": 35, + "severity": 2, + "ruleId": "object-property-newline", + "message": "Object properties must go on a new line if they aren't all on the same line.", + "messageId": "propertiesOnNewlineAll", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 28, + "column": 41, + "endLine": 28, + "endColumn": 53, + "severity": 2, + "ruleId": "object-property-newline", + "message": "Object properties must go on a new line if they aren't all on the same line.", + "messageId": "propertiesOnNewlineAll", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 29, + "column": 17, + "endLine": 29, + "endColumn": 22, + "severity": 2, + "ruleId": "object-property-newline", + "message": "Object properties must go on a new line if they aren't all on the same line.", + "messageId": "propertiesOnNewlineAll", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 29, + "column": 28, + "endLine": 29, + "endColumn": 34, + "severity": 2, + "ruleId": "object-property-newline", + "message": "Object properties must go on a new line if they aren't all on the same line.", + "messageId": "propertiesOnNewlineAll", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 29, + "column": 40, + "endLine": 29, + "endColumn": 46, + "severity": 2, + "ruleId": "object-property-newline", + "message": "Object properties must go on a new line if they aren't all on the same line.", + "messageId": "propertiesOnNewlineAll", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 122, + "column": 11, + "endLine": 122, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 135, + "column": 11, + "endLine": 135, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 148, + "column": 11, + "endLine": 148, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 158, + "column": 13, + "endLine": 158, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 170, + "column": 13, + "endLine": 170, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 184, + "column": 11, + "endLine": 184, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 196, + "column": 11, + "endLine": 196, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 205, + "column": 11, + "endLine": 205, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/admin/shop/AdminProductEditor.tsx", + "line": 223, + "column": 16, + "endLine": 223, + "endColumn": 81, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventCalendar.tsx", + "line": 52, + "column": 22, + "endLine": 56, + "endColumn": 36, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventCalendar.tsx", + "line": 174, + "column": 24, + "endLine": 174, + "endColumn": 25, + "severity": 2, + "ruleId": "react/no-array-index-key", + "message": "Do not use Array index in keys", + "messageId": "noArrayIndex", + "nodeType": "Identifier", + "fatal": false + }, + { + "file": "src/pages/events/EventDetail.tsx", + "line": 75, + "column": 26, + "endLine": 79, + "endColumn": 39, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventDetail.tsx", + "line": 218, + "column": 16, + "endLine": 226, + "endColumn": 51, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventDetail.tsx", + "line": 220, + "column": 19, + "endLine": 226, + "endColumn": 51, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventDetail.tsx", + "line": 222, + "column": 21, + "endLine": 226, + "endColumn": 51, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 57, + "column": 9, + "endLine": 57, + "endColumn": 20, + "severity": 2, + "ruleId": "react/jsx-props-no-spreading", + "message": "Prop spreading is forbidden", + "messageId": "noSpreading", + "nodeType": "JSXSpreadAttribute", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 65, + "column": 15, + "endLine": 65, + "endColumn": 26, + "severity": 2, + "ruleId": "react/jsx-props-no-spreading", + "message": "Prop spreading is forbidden", + "messageId": "noSpreading", + "nodeType": "JSXSpreadAttribute", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 87, + "column": 7, + "endLine": 87, + "endColumn": 18, + "severity": 2, + "ruleId": "react/jsx-props-no-spreading", + "message": "Prop spreading is forbidden", + "messageId": "noSpreading", + "nodeType": "JSXSpreadAttribute", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 117, + "column": 3, + "endLine": 117, + "endColumn": 58, + "severity": 2, + "ruleId": "react-hooks/exhaustive-deps", + "message": "Definition for rule 'react-hooks/exhaustive-deps' was not found.", + "messageId": null, + "nodeType": null, + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 250, + "column": 15, + "endLine": 250, + "endColumn": 167, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 261, + "column": 15, + "endLine": 261, + "endColumn": 167, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 285, + "column": 20, + "endLine": 289, + "endColumn": 37, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 313, + "column": 13, + "endLine": 313, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 323, + "column": 13, + "endLine": 323, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 335, + "column": 11, + "endLine": 335, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 347, + "column": 13, + "endLine": 347, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 356, + "column": 13, + "endLine": 356, + "endColumn": 38, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 367, + "column": 11, + "endLine": 367, + "endColumn": 36, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 387, + "column": 15, + "endLine": 387, + "endColumn": 40, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 396, + "column": 15, + "endLine": 396, + "endColumn": 40, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 411, + "column": 15, + "endLine": 411, + "endColumn": 56, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 433, + "column": 15, + "endLine": 433, + "endColumn": 65, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 452, + "column": 14, + "endLine": 458, + "endColumn": 73, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/EventRegister.tsx", + "line": 454, + "column": 17, + "endLine": 458, + "endColumn": 73, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/pages/events/Events.tsx", + "line": 124, + "column": 47, + "endLine": 124, + "endColumn": 54, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`Error: ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/events/Events.tsx", + "line": 124, + "column": 54, + "endLine": 124, + "endColumn": 61, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`{error}` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXExpressionContainer", + "fatal": false + }, + { + "file": "src/pages/joinUs/JoinUs.jsx", + "line": 130, + "column": 1, + "endLine": 130, + "endColumn": 104, + "severity": 2, + "ruleId": "max-len", + "message": "This line has a length of 103. Maximum allowed is 100.", + "messageId": "max", + "nodeType": "Program", + "fatal": false + }, + { + "file": "src/pages/joinUs/JoinUs.jsx", + "line": 134, + "column": 17, + "endLine": 134, + "endColumn": 36, + "severity": 2, + "ruleId": "react/no-array-index-key", + "message": "Do not use Array index in keys", + "messageId": "noArrayIndex", + "nodeType": "TemplateLiteral", + "fatal": false + }, + { + "file": "src/pages/joinUs/Route.jsx", + "line": 22, + "column": 34, + "endLine": 23, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` Seal Point Park → 3rd Ave Launch → Bridgeview Park → back ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/joinUs/Route.jsx", + "line": 25, + "column": 37, + "endLine": 26, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` ~5 miles out and back along the Bay Trail ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 15, + "column": 46, + "endLine": 15, + "endColumn": 60, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`Last updated: ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 15, + "column": 60, + "endLine": 15, + "endColumn": 94, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`strong` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXElement", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 18, + "column": 49, + "endLine": 21, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` Before going live, replace this with a policy reviewed by counsel or generated from a reputable service. If you accept registrations from California residents you may have CCPA obligations. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 26, + "column": 43, + "endLine": 28, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` name, email, phone, role (member, admin, unverified). ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 30, + "column": 48, + "endLine": 32, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` name, email, phone, date of birth, emergency contact, shirt size, event-specific responses, waiver acceptance. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 34, + "column": 43, + "endLine": 36, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` handled entirely by Stripe. MPRC does not see or store your card details. We receive a Stripe payment reference only. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 38, + "column": 40, + "endLine": 40, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` Firebase Analytics (anonymized usage) and basic server logs. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 55, + "column": 36, + "endLine": 56, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` — payment processing (subject to Stripe's Privacy Policy) ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 58, + "column": 45, + "endLine": 59, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` — hosting, auth, database, email delivery ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 61, + "column": 48, + "endLine": 62, + "endColumn": 11, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` — transactional email delivery ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 68, + "column": 53, + "endLine": 68, + "endColumn": 58, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`{' '}` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXExpressionContainer", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 69, + "column": 89, + "endLine": 71, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`. Contact us to request deletion of your account and associated registration data. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 75, + "column": 40, + "endLine": 75, + "endColumn": 45, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`{' '}` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXExpressionContainer", + "fatal": false + }, + { + "file": "src/pages/legal/Privacy.jsx", + "line": 76, + "column": 82, + "endLine": 77, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Terms.jsx", + "line": 15, + "column": 46, + "endLine": 15, + "endColumn": 60, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`Last updated: ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Terms.jsx", + "line": 15, + "column": 60, + "endLine": 15, + "endColumn": 94, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`strong` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXElement", + "fatal": false + }, + { + "file": "src/pages/legal/Terms.jsx", + "line": 18, + "column": 49, + "endLine": 24, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "` Before going live, replace this content with terms reviewed by an attorney or generated from a reputable service (iubenda, Termly, Rocket Lawyer). Specific clauses you'll want to include: event cancellation and refund policy, liability waiver reference, payment dispute resolution, intellectual property, account termination, and governing law (California). ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/legal/Terms.jsx", + "line": 61, + "column": 58, + "endLine": 61, + "endColumn": 63, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`{' '}` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXExpressionContainer", + "fatal": false + }, + { + "file": "src/pages/legal/Terms.jsx", + "line": 62, + "column": 82, + "endLine": 63, + "endColumn": 9, + "severity": 2, + "ruleId": "react/jsx-one-expression-per-line", + "message": "`. ` must be placed on a new line", + "messageId": "moveToNewLine", + "nodeType": "JSXText", + "fatal": false + }, + { + "file": "src/pages/shop/ProductDetail.tsx", + "line": 46, + "column": 3, + "endLine": 46, + "endColumn": 58, + "severity": 2, + "ruleId": "react-hooks/exhaustive-deps", + "message": "Definition for rule 'react-hooks/exhaustive-deps' was not found.", + "messageId": null, + "nodeType": null, + "fatal": false + }, + { + "file": "src/pages/shop/ProductDetail.tsx", + "line": 78, + "column": 18, + "endLine": 78, + "endColumn": 19, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break after this opening brace.", + "messageId": "expectedLinebreakAfterOpeningBrace", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/shop/ProductDetail.tsx", + "line": 78, + "column": 54, + "endLine": 78, + "endColumn": 55, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break before this closing brace.", + "messageId": "expectedLinebreakBeforeClosingBrace", + "nodeType": "ObjectExpression", + "fatal": false + }, + { + "file": "src/pages/shop/ProductDetail.tsx", + "line": 121, + "column": 17, + "endLine": 121, + "endColumn": 42, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/shop/ProductDetail.tsx", + "line": 137, + "column": 17, + "endLine": 137, + "endColumn": 42, + "severity": 2, + "ruleId": "jsx-a11y/label-has-associated-control", + "message": "A form label must be associated with a control.", + "messageId": null, + "nodeType": "JSXOpeningElement", + "fatal": false + }, + { + "file": "src/pages/shop/ProductDetail.tsx", + "line": 189, + "column": 18, + "endLine": 193, + "endColumn": 65, + "severity": 2, + "ruleId": "no-nested-ternary", + "message": "Do not nest ternary expressions.", + "messageId": "noNestedTernary", + "nodeType": "ConditionalExpression", + "fatal": false + }, + { + "file": "src/services/ServiceLocatorProvider.tsx", + "line": 1, + "column": 15, + "endLine": 1, + "endColumn": 16, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break after this opening brace.", + "messageId": "expectedLinebreakAfterOpeningBrace", + "nodeType": "ImportDeclaration", + "fatal": false + }, + { + "file": "src/services/ServiceLocatorProvider.tsx", + "line": 1, + "column": 57, + "endLine": 1, + "endColumn": 58, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break before this closing brace.", + "messageId": "expectedLinebreakBeforeClosingBrace", + "nodeType": "ImportDeclaration", + "fatal": false + }, + { + "file": "src/services/seo/structuredData.ts", + "line": 83, + "column": 9, + "endLine": 83, + "endColumn": 10, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break after this opening brace.", + "messageId": "expectedLinebreakAfterOpeningBrace", + "nodeType": "ObjectPattern", + "fatal": false + }, + { + "file": "src/services/seo/structuredData.ts", + "line": 83, + "column": 69, + "endLine": 83, + "endColumn": 70, + "severity": 2, + "ruleId": "object-curly-newline", + "message": "Expected a line break before this closing brace.", + "messageId": "expectedLinebreakBeforeClosingBrace", + "nodeType": "ObjectPattern", + "fatal": false + }, + { + "file": "src/text/ContactUs.js", + "line": 1, + "column": 1, + "endLine": 1, + "endColumn": 45, + "severity": 2, + "ruleId": "import/prefer-default-export", + "message": "Prefer default export on a file with single export.", + "messageId": null, + "nodeType": "ExportNamedDeclaration", + "fatal": false + } + ] +} diff --git a/.github/scripts/check-frontend-lint.cjs b/.github/scripts/check-frontend-lint.cjs new file mode 100644 index 00000000..92adb424 --- /dev/null +++ b/.github/scripts/check-frontend-lint.cjs @@ -0,0 +1,396 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { ESLint } = require('eslint'); +const onlyWarn = require('eslint-plugin-only-warn'); + +// The repository plugin patches ESLint globally and converts configured errors +// to warnings. Disable that patch in this dedicated process so the reviewed +// baseline preserves each rule's configured severity. +onlyWarn.disable(); + +const ROOT = path.resolve(__dirname, '..', '..'); +const BASELINE_PATH = path.join(ROOT, '.github', 'lint-baseline.json'); +const LINT_EXTENSIONS = Object.freeze(['.js', '.jsx', '.ts', '.tsx']); +const LINT_TARGETS = Object.freeze(['src']); +const ESLINT_OPTIONS = Object.freeze({ + cwd: ROOT, + extensions: LINT_EXTENSIONS, + fix: false, + cache: false, + errorOnUnmatchedPattern: true, +}); +const BASELINE_ALGORITHM = 'sha256-canonical-file-and-finding-records-v2'; +const MAX_RESULT_FILES = 10000; +const MAX_MESSAGES_PER_FILE = 10000; +const MAX_FINDINGS = 50000; +const MAX_MESSAGE_LENGTH = 2000; +const MAX_BASELINE_BYTES = 4 * 1024 * 1024; +const SAFE_REJECTION_MESSAGE = 'frontend_lint_gate_rejected'; +const SAFE_FAILURE_OUTPUT = 'frontend_lint_gate_failed:lint_rejected\n'; +const FINDING_KEYS = Object.freeze([ + 'file', + 'line', + 'column', + 'endLine', + 'endColumn', + 'severity', + 'ruleId', + 'message', + 'messageId', + 'nodeType', + 'fatal', +]); + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function requireExactKeys(value, expectedKeys, label) { + const actualKeys = Object.keys(value).sort(); + const requiredKeys = [...expectedKeys].sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(requiredKeys)) { + throw new Error(`invalid_${label}`); + } +} + +function requireInteger(value, minimum, label) { + if (!Number.isSafeInteger(value) || value < minimum) { + throw new Error(`invalid_${label}`); + } + return value; +} + +function optionalInteger(value, label) { + if (value === undefined || value === null) return null; + return requireInteger(value, 1, label); +} + +function optionalString(value, label) { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_MESSAGE_LENGTH) { + throw new Error(`invalid_${label}`); + } + return value; +} + +function validateRelativeFile(relative) { + if (typeof relative !== 'string' + || relative.length === 0 + || relative.includes('\\') + || relative.startsWith('/') + || relative.startsWith('../') + || relative.includes('/../') + || !relative.startsWith('src/')) { + throw new Error('invalid_lint_file'); + } + if (!LINT_EXTENSIONS.includes(path.posix.extname(relative))) { + throw new Error('invalid_lint_extension'); + } + return relative; +} + +function normalizeFilePath(filePath) { + if (typeof filePath !== 'string' || filePath.length === 0) { + throw new Error('invalid_lint_file'); + } + const absolute = path.resolve(filePath); + const relative = path.relative(ROOT, absolute).split(path.sep).join('/'); + if (relative === '' || relative.startsWith('../') || path.isAbsolute(relative)) { + throw new Error('invalid_lint_file'); + } + return validateRelativeFile(relative); +} + +function normalizeMessage(file, message) { + if (!isPlainObject(message)) throw new Error('invalid_lint_message'); + + const severity = requireInteger(message.severity, 1, 'lint_severity'); + if (severity !== 1 && severity !== 2) throw new Error('invalid_lint_severity'); + if (message.ruleId !== null + && (typeof message.ruleId !== 'string' || message.ruleId.length === 0)) { + throw new Error('invalid_lint_rule'); + } + if (typeof message.message !== 'string' + || message.message.length === 0 + || message.message.length > MAX_MESSAGE_LENGTH) { + throw new Error('invalid_lint_message_text'); + } + if (hasOwn(message, 'fatal') + && message.fatal !== undefined + && typeof message.fatal !== 'boolean') { + throw new Error('invalid_lint_fatal'); + } + + return Object.freeze({ + file, + line: requireInteger(message.line, 1, 'lint_line'), + column: requireInteger(message.column, 1, 'lint_column'), + endLine: optionalInteger(message.endLine, 'lint_end_line'), + endColumn: optionalInteger(message.endColumn, 'lint_end_column'), + severity, + ruleId: message.ruleId, + message: message.message, + messageId: optionalString(message.messageId, 'lint_message_id'), + nodeType: optionalString(message.nodeType, 'lint_node_type'), + fatal: message.fatal === true, + }); +} + +function compareValues(left, right) { + if (left === right) return 0; + if (left === null) return -1; + if (right === null) return 1; + if (typeof left === 'number' && typeof right === 'number') return left - right; + if (typeof left === 'boolean' && typeof right === 'boolean') return left ? 1 : -1; + return left < right ? -1 : 1; +} + +function compareFindings(left, right) { + for (const field of FINDING_KEYS) { + const comparison = compareValues(left[field], right[field]); + if (comparison !== 0) return comparison; + } + return 0; +} + +function compareFiles(left, right) { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function normalizeEslintResults(rawResults) { + if (!Array.isArray(rawResults) || rawResults.length > MAX_RESULT_FILES) { + throw new Error('invalid_lint_results'); + } + + const files = []; + const findings = []; + rawResults.forEach((result) => { + if (!isPlainObject(result) || !Array.isArray(result.messages) + || result.messages.length > MAX_MESSAGES_PER_FILE) { + throw new Error('invalid_lint_result'); + } + const file = normalizeFilePath(result.filePath); + files.push(file); + result.messages.forEach((message) => { + findings.push(normalizeMessage(file, message)); + if (findings.length > MAX_FINDINGS) throw new Error('too_many_lint_findings'); + }); + }); + + const sortedFiles = files.sort(compareFiles); + if (new Set(sortedFiles).size !== sortedFiles.length) { + throw new Error('duplicate_lint_file'); + } + return Object.freeze({ + files: Object.freeze(sortedFiles), + findings: Object.freeze(findings.sort(compareFindings)), + }); +} + +function countFilesByExtension(files) { + const counts = Object.fromEntries(LINT_EXTENSIONS.map((extension) => [extension, 0])); + files.forEach((file) => { + counts[path.posix.extname(file)] += 1; + }); + return Object.freeze(counts); +} + +function countFindingsByExtension(findings, severity) { + const counts = Object.fromEntries(LINT_EXTENSIONS.map((extension) => [extension, 0])); + findings.forEach((finding) => { + if (finding.severity === severity) counts[path.posix.extname(finding.file)] += 1; + }); + return Object.freeze(counts); +} + +function findingsFingerprint(scan) { + const canonical = JSON.stringify({ + scannedFiles: scan.files, + findings: scan.findings, + }); + return `sha256:${crypto.createHash('sha256').update(canonical, 'utf8').digest('hex')}`; +} + +function createBaseline(scan) { + if (!isPlainObject(scan) || !Array.isArray(scan.files) || !Array.isArray(scan.findings)) { + throw new Error('invalid_lint_scan'); + } + const fatal = scan.findings.filter((finding) => finding.fatal); + if (fatal.length !== 0) throw new Error(`fatal_lint_findings:${fatal.length}`); + + const errors = scan.findings.filter((finding) => finding.severity === 2); + const warnings = scan.findings.filter((finding) => finding.severity === 1); + if (errors.length + warnings.length !== scan.findings.length) { + throw new Error('invalid_lint_severity_set'); + } + + return Object.freeze({ + schemaVersion: 2, + algorithm: BASELINE_ALGORITHM, + extensions: [...LINT_EXTENSIONS], + targets: [...LINT_TARGETS], + scannedFileCount: scan.files.length, + scannedFilesByExtension: countFilesByExtension(scan.files), + errorCount: errors.length, + warningCount: warnings.length, + errorsByExtension: countFindingsByExtension(scan.findings, 2), + warningsByExtension: countFindingsByExtension(scan.findings, 1), + fingerprint: findingsFingerprint(scan), + scannedFiles: [...scan.files], + findings: [...scan.findings], + }); +} + +function normalizeStoredFinding(value) { + if (!isPlainObject(value)) throw new Error('invalid_lint_baseline_finding'); + requireExactKeys(value, FINDING_KEYS, 'lint_baseline_finding_keys'); + return normalizeMessage(validateRelativeFile(value.file), value); +} + +function parseBaseline(rawBaseline) { + if (!isPlainObject(rawBaseline)) throw new Error('invalid_lint_baseline'); + requireExactKeys(rawBaseline, [ + 'schemaVersion', + 'algorithm', + 'extensions', + 'targets', + 'scannedFileCount', + 'scannedFilesByExtension', + 'errorCount', + 'warningCount', + 'errorsByExtension', + 'warningsByExtension', + 'fingerprint', + 'scannedFiles', + 'findings', + ], 'lint_baseline_keys'); + if (rawBaseline.schemaVersion !== 2 || rawBaseline.algorithm !== BASELINE_ALGORITHM) { + throw new Error('invalid_lint_baseline_version'); + } + if (JSON.stringify(rawBaseline.extensions) !== JSON.stringify(LINT_EXTENSIONS) + || JSON.stringify(rawBaseline.targets) !== JSON.stringify(LINT_TARGETS)) { + throw new Error('invalid_lint_baseline_scope'); + } + if (!Array.isArray(rawBaseline.scannedFiles) + || rawBaseline.scannedFiles.length > MAX_RESULT_FILES + || !Array.isArray(rawBaseline.findings) + || rawBaseline.findings.length > MAX_FINDINGS) { + throw new Error('invalid_lint_baseline_records'); + } + + const files = rawBaseline.scannedFiles.map(validateRelativeFile); + const sortedFiles = [...files].sort(compareFiles); + if (new Set(files).size !== files.length + || JSON.stringify(files) !== JSON.stringify(sortedFiles)) { + throw new Error('invalid_lint_baseline_file_order'); + } + const findings = rawBaseline.findings.map(normalizeStoredFinding); + const sortedFindings = [...findings].sort(compareFindings); + if (JSON.stringify(findings) !== JSON.stringify(sortedFindings)) { + throw new Error('invalid_lint_baseline_finding_order'); + } + + const rebuilt = createBaseline({ files, findings }); + if (JSON.stringify(rebuilt) !== JSON.stringify(rawBaseline)) { + throw new Error('invalid_lint_baseline_integrity'); + } + return rebuilt; +} + +function verifyScan(scan, rawBaseline) { + const actual = createBaseline(scan); + const expected = parseBaseline(rawBaseline); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `lint_baseline_mismatch:expected=${expected.errorCount}/${expected.warningCount}` + + `:${expected.fingerprint}:actual=${actual.errorCount}/${actual.warningCount}` + + `:${actual.fingerprint}`, + ); + } + return actual; +} + +async function runEslint() { + const eslint = new ESLint(ESLINT_OPTIONS); + const results = await eslint.lintFiles(LINT_TARGETS); + return normalizeEslintResults(results); +} + +async function verifyCurrentLint(rawBaseline, lintRunner = runEslint) { + try { + const baseline = rawBaseline === undefined ? readBaseline() : rawBaseline; + const scan = await lintRunner(); + return verifyScan(scan, baseline); + } catch (_error) { + throw new Error(SAFE_REJECTION_MESSAGE); + } +} + +function readBaseline() { + let contents; + try { + contents = fs.readFileSync(BASELINE_PATH, 'utf8'); + } catch (_error) { + throw new Error('missing_lint_baseline'); + } + if (Buffer.byteLength(contents, 'utf8') > MAX_BASELINE_BYTES) { + throw new Error('lint_baseline_too_large'); + } + try { + return JSON.parse(contents); + } catch (_error) { + throw new Error('invalid_lint_baseline_json'); + } +} + +async function main(argv = process.argv.slice(2)) { + if (argv.length > 1 || (argv.length === 1 && argv[0] !== '--print-current-baseline')) { + throw new Error('invalid_lint_gate_argument'); + } + if (argv[0] === '--print-current-baseline') { + const scan = await runEslint(); + process.stdout.write(`${JSON.stringify(createBaseline(scan), null, 2)}\n`); + return; + } + + const baseline = await verifyCurrentLint(); + process.stdout.write( + `Frontend lint baseline verified: ${baseline.scannedFileCount} files; ` + + `${baseline.errorCount} reviewed legacy errors; ` + + `${baseline.warningCount} reviewed legacy warnings.\n`, + ); +} + +if (require.main === module) { + main().catch(() => { + process.stderr.write(SAFE_FAILURE_OUTPUT); + process.exitCode = 1; + }); +} + +module.exports = Object.freeze({ + BASELINE_ALGORITHM, + ESLINT_OPTIONS, + FINDING_KEYS, + LINT_EXTENSIONS, + LINT_TARGETS, + SAFE_FAILURE_OUTPUT, + SAFE_REJECTION_MESSAGE, + createBaseline, + findingsFingerprint, + normalizeEslintResults, + parseBaseline, + readBaseline, + verifyCurrentLint, + verifyScan, +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4441666a..06e93184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,24 @@ jobs: frontend: name: Frontend lint + build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: '20' cache: 'npm' - - run: npm ci --legacy-peer-deps + - run: npm ci --legacy-peer-deps --ignore-scripts + - name: Run non-mutating frontend lint + run: node .github/scripts/check-frontend-lint.cjs + - name: Verify frontend lint left the checkout unchanged + run: | + git diff --exit-code HEAD -- + frontend_git_status="$(git status --porcelain=v1 --untracked-files=all)" + test -z "$frontend_git_status" - name: Run frontend Jest tests env: CI: 'true' @@ -28,7 +39,6 @@ jobs: ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/deploy.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' ruby -e 'require "yaml"; YAML.safe_load(File.read(".github/workflows/ci.yml"), permitted_classes: [], permitted_symbols: [], aliases: true)' node --test tests/ci-workflow.test.js tests/release-workflow.test.js tests/firebase-release-verification.test.js tests/test-artifact-safety.test.js - - run: npm run lint:fix -- --max-warnings=0 || true - name: Build env: CI: 'true' diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 9a94c3a8..91be7ebe 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -137,12 +137,12 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. Optimized p ## CI-001 — Repair test gates and secure the deployment pipeline **Labels:** `priority:P0`, `type:security`, `type:testing`, `area:ci`, `size:L`, `needs-external-config` -**Status:** Published as tracker [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105). [#103](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/103), [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), and [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126) provide the local/hosted frontend and SPA baselines. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) owns the dedicated hosted command-journal Firestore transaction gate. [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135) owns the manual exact-commit, backend-first source gate and Git-triggered Netlify production containment. [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) owns protected OIDC/environment configuration, and [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual profile-recovery release. Fail-closed lint, branch-required-check configuration, scanning, staging/live evidence, and protected Netlify publication remain open, so the pipeline is still non-compliant overall. +**Status:** Published as tracker [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105). [#103](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/103), [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), and [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126) provide the local/hosted frontend and SPA baselines. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) owns the dedicated hosted command-journal Firestore transaction gate. CI-001B4 source/tests are tracked in [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) for the exact non-mutating frontend lint gate; exact pull-request and post-merge hosted evidence remain required before closure. [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135) owns the manual exact-commit, backend-first source gate and Git-triggered Netlify production containment. [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) owns protected OIDC/environment configuration, and [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual profile-recovery release. Branch-required-check configuration, scanning, staging/live evidence, legacy lint-debt cleanup, and protected Netlify publication remain open, so the pipeline is still non-compliant overall. **Depends on:** SAFETY-001 ### Problem -Before #103, frontend tests failed because the Jest environment lacked `TextEncoder`. That child repaired the local baseline, and #124 adds it to hosted CI. #167 adds a separate named Node 20/Java 17 job for the PAY-002B2A Firestore transaction suite and makes both protected-release CI reads require it. The job uses one explicit demo project, Firestore only, committed lockfiles, and no cloud/provider authority. CI still runs a mutating `lint:fix` command and ignores its failure with `|| true`. #135 removes the automatic/fail-open release source defects: merge and release are separate, exact CI checks are re-read, release targets are fixed, OIDC replaces JSON-key wiring, lockfile tooling is used, backend precedes Pages, and Git-triggered Netlify production builds stop. The protected identity/environments and actual staged/live release remain outside that source slice. +Before #103, frontend tests failed because the Jest environment lacked `TextEncoder`. That child repaired the local baseline, and #124 adds it to hosted CI. #167 adds a separate named Node 20/Java 17 job for the PAY-002B2A Firestore transaction suite and makes both protected-release CI reads require it. The job uses one explicit demo project, Firestore only, committed lockfiles, and no cloud/provider authority. CI-001B4/#186 replaces the mutating, ignored `lint:fix` step with a blocking check over 102 JS/JSX/TS/TSX files. It disables the repository-wide severity-masking `eslint-plugin-only-warn` hook inside the lint process and pins all 125 configured errors and 7 warnings as reviewable individual records. Any added, removed, moved, or changed record and every fatal parser/configuration failure rejects with one fixed diagnostic. The checker runs directly before application tests in an exact prefix that pins the hosted runner, read-only permission, credential-free checkout, Node version, lifecycle-disabled install, lint, and adjacent clean check. Global overrides and prefix changes reject. The clean check compares tracked files with `HEAD` plus non-ignored untracked files; ignored dependency/build paths are outside that proof. This is an explicit debt baseline, not a clean-lint claim. #135 removes the automatic/fail-open release source defects: merge and release are separate, exact CI checks are re-read, release targets are fixed, OIDC replaces JSON-key wiring, lockfile tooling is used, backend precedes Pages, and Git-triggered Netlify production builds stop. The protected identity/environments and actual staged/live release remain outside that source slice. ### Scope @@ -163,7 +163,7 @@ Before #103, frontend tests failed because the Jest environment lacked `TextEnco ### Acceptance criteria - [ ] `CI=true npm test -- --watchAll=false --runInBand` passes locally and in CI. -- [ ] Lint does not rewrite files and cannot be ignored. +- [ ] CI-001B4/#186 implements non-mutating, exact-record frontend lint in source; close only after exact pull-request and post-merge hosted evidence. Branch-required-check settings remain separate. - [ ] Rules tests run with Java 17 and include deny assertions from SEC-001. - [ ] Deployment job cannot begin until the exact commit's required checks pass. - [ ] Frontend build receives no service-account JSON or Stripe server secret. @@ -179,7 +179,7 @@ Before #103, frontend tests failed because the Jest environment lacked `TextEnco ### Agent handoff -Repository changes can implement tests/workflow shape, but OIDC/IAM/environment protection requires an authorized owner. CI-001A/#103 owns test reliability, CI-001B1/#124 owns hosted frontend Jest, CI-001B1A/#126 owns hosted SPA safety, CI-001B2/#135 owns the protected release source gate, CI-001B3/#167 owns the focused command-journal emulator gate, #133 owns cloud/environment authority, and #136 owns staged/target release evidence. Keep fail-closed lint, scanning, branch-required-check configuration, and Netlify hosting work in separately claimed children. +Repository changes can implement tests/workflow shape, but OIDC/IAM/environment protection requires an authorized owner. CI-001A/#103 owns test reliability, CI-001B1/#124 owns hosted frontend Jest, CI-001B1A/#126 owns hosted SPA safety, CI-001B2/#135 owns the protected release source gate, CI-001B3/#167 owns the focused command-journal emulator gate, CI-001B4/#186 owns the exact frontend lint gate, #133 owns cloud/environment authority, and #136 owns staged/target release evidence. Keep legacy lint-debt cleanup, scanning, branch-required-check configuration, and Netlify hosting work in separately claimed children. --- diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 16748389..09fd1492 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -21,6 +21,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | CI-001B1A | merged/closed as #126; #99 and CI-001B1 | Run the standalone same-origin SPA callback suite as a separate blocking hosted frontend step. | Local deliberate-failure proof plus the named hosted step passing on the exact pull-request and post-merge commits; no provider callback claim. | | CI-001B2 | published as #135; CI-001B1 | Provide the tested manual exact-commit source gate: fixed Rules/Functions, lockfile CLI, pinned Actions, short-lived-auth wiring, backend-first Pages dependency, missing-config failure, no frontend credential, and Netlify Git-production containment. | Source/static negative tests and exact PR evidence. #133 separately owns protected OIDC/environment configuration; #136 owns staged/target release; WEB-001 owns protected Netlify publication. | | CI-001B3 | source/tests tracked in live [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167); PAY-002B2A/#165 | Run the dedicated command-journal Firestore transaction suite as one named blocking Node 20/Java 17 job with exact demo project, explicit opt-in, Firestore-only scope, committed tooling, and no secrets/provider authority; require it at both protected-release CI reads. | Static negative workflow tests plus exact pull-request and post-merge hosted six-test evidence; no Firebase deployment, Stripe, website, or live claim. | +| CI-001B4 | source/tests tracked in live [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186); CI-001B1 | Replace the mutating fail-open frontend lint line with a named blocking JS/JSX/TS/TSX gate over 102 files. Disable the repository-wide severity-masking hook inside the lint process and baseline all 125 configured errors plus 7 warnings as reviewable individual records. Any added, removed, moved, or changed record and every fatal parser/configuration failure rejects with a fixed diagnostic; lint may not rewrite tracked files or create non-ignored untracked files. | Actual Node 20 lint, exact-record and hostile mutation tests, synthetic fatal-parser proof, tracked/non-ignored-untracked clean proof, and exact pull-request/post-merge hosted step; ignored dependency/build paths are outside this proof, with no clean-lint, branch-protection, deployment, website, Firebase, or provider claim. | | SUPPLY-001A | published as [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127); CI-001A | Prove `gpxparser` unused and remove its obsolete transitive chain. | `rg`/tree proof, before/after audits, root lockfile diff, full frontend checks. | | SUPPLY-001B | ready; CI-001A | Patch React Router within compatible 6.x and resolve/test future flags without route redesign. | Navigation/callback tests, audit delta, build. | | SUPPLY-001C | proposed; CI-001A | Upgrade Firebase web SDK within one compatibility boundary. | Auth/Firestore/Functions/App Check/emulator tests and bundle/audit diff. | diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index ae67e661..8109e27e 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -263,10 +263,11 @@ npm --prefix functions run lint npm --prefix functions run test:run -- --runInBand CI=true npm test -- --watchAll=false --runInBand npm run test:spa-navigation +node .github/scripts/check-frontend-lint.cjs node --test tests/test-artifact-safety.test.js ``` -The frontend Jest command is a dependable local baseline and hosted CI runs it as the blocking `Run frontend Jest tests` step under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). Hosted CI also runs the separate Node SPA suite as `Run SPA callback safety tests` under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126). CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) adds the blocking `Commerce command journal emulator` job. That job uses exact demo project `demo-pay002b2-test`, Firestore only, an explicit opt-in, and the focused transaction suite. TEST-001A2 [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) adds the exact `Test artifact scrubber` job. It deliberately creates bounded synthetic output and proves that the dependency-free scanner accepts reserved examples, rejects the documented recognizable sensitive patterns with fixed messages, ignores source outside an explicit output root, and protects both release job lists. Current CI uploads no test report. Later report-producing work must scan its explicit output before upload. A green scrubber proves only that this deliberate source test ran. It is not a general data-loss-prevention guarantee and does not prove that an unknown or newly obfuscated sensitive value is absent, that an artifact was uploaded, that all runtime logs are safe, or that Firebase, Stripe, the website, or another provider changed. The manual-release source invariants remain under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). Protected environment/OIDC configuration, actual staged/live deployment, fail-closed lint, branch-required-check configuration, and controlled Netlify publication remain **NOT AVAILABLE YET** under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105), [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133), [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136), and WEB-001. +The frontend Jest command is a dependable local baseline and hosted CI runs it as the blocking `Run frontend Jest tests` step under [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124). Hosted CI also runs the separate Node SPA suite as `Run SPA callback safety tests` under [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126). CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) adds the blocking `Commerce command journal emulator` job. That job uses exact demo project `demo-pay002b2-test`, Firestore only, an explicit opt-in, and the focused transaction suite. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) adds the named blocking `Run non-mutating frontend lint` step using the checker directly, before application tests can change its process environment. Its trusted prefix pins the hosted runner, read-only job permission, credential-free checkout, Node 20 setup, install with lifecycle scripts disabled, lint, and the immediately following clean check. It checks 102 frontend files: 12 JS, 35 JSX, 31 TS, and 24 TSX. The lint process disables the repository-wide `eslint-plugin-only-warn` hook so the original configured severities remain visible. The reviewable baseline contains every individual finding: 125 configured errors and 7 warnings. This is accepted debt, not a clean-lint claim. Any finding added, removed, moved, or changed must be understood and the exact record updated in the same reviewed pull request; a fatal parser, configuration, or unexpected process failure always stops with one fixed diagnostic. Never regenerate the baseline only to make a check green. The clean-checkout step compares tracked files with `HEAD` and rejects non-ignored untracked files. Ignored dependency and build paths are outside this proof. TEST-001A2 [#177](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/177) adds the exact `Test artifact scrubber` job. It deliberately creates bounded synthetic output and proves that the dependency-free scanner accepts reserved examples, rejects the documented recognizable sensitive patterns with fixed messages, ignores source outside an explicit output root, and protects both release job lists. Current CI uploads no test report. Later report-producing work must scan its explicit output before upload. A green scrubber proves only that this deliberate source test ran. It is not a general data-loss-prevention guarantee and does not prove that an unknown or newly obfuscated sensitive value is absent, that an artifact was uploaded, that all runtime logs are safe, or that Firebase, Stripe, the website, or another provider changed. The manual-release source invariants remain under [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135). Protected environment/OIDC configuration, actual staged/live deployment, branch-required-check configuration, and controlled Netlify publication remain **NOT AVAILABLE YET** under [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105), [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133), [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136), and WEB-001. ### Firestore Rules diff --git a/SECURITY.md b/SECURITY.md index ceade2e2..4763eb45 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -82,7 +82,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-032 | `auditLog` arrays grow within primary documents and are editable by broad admins. | Move to append-oriented, immutable-by-client audit records with retention/export rules. | | RISK-033 | CSV export mitigates formula injection, but roster export has no explicit re-auth, download audit, row limit/streaming limit, or data-minimization profiles. | Add scoped exports, reason/re-auth, audit, minimum columns, safe filename IDs, and large-export controls. | | RISK-034 | Webhook error response includes the Stripe library's signature error detail. | Return generic client errors; keep sanitized structured diagnostics server-side. | -| RISK-035 | The deterministic frontend Jest suite and standalone SPA callback suite run as separate blocking hosted CI steps, but CI's frontend lint step still mutates files and is allowed to fail with `|| true`; branch protection and broader domain/integration coverage remain incomplete. | Make lint non-mutating and mandatory, prove required branch checks, and add domain/integration coverage. | +| RISK-035 | The deterministic frontend Jest suite and standalone SPA callback suite run as separate blocking hosted CI steps. CI-001B4/#186 adds a non-mutating frontend lint gate over 102 JS/JSX/TS/TSX files. Its reviewable baseline records every one of the 125 configured errors and 7 warnings that remain after CI disables the repository's severity-masking `eslint-plugin-only-warn` hook. Branch protection, lint-debt cleanup, and broader domain/integration coverage remain incomplete. | Reduce the reviewed finding baseline in focused changes, prove required branch checks, and add domain/integration coverage. Never regenerate the baseline merely to make CI green. | | RISK-036 | #135 adds a manual exact-commit source gate, fixed profile-recovery targets, backend-first order, missing-config failure, and Git-triggered Netlify production containment. Protected environments/OIDC, isolated staging, live-Netlify publication, and rollback evidence are still absent. | Complete #133 and #136, provision isolated staging, protect the Netlify release path under WEB-001, and rehearse rollback/safe roll-forward before production. | | RISK-037 | Account/registration deletion, export, retention, backup, and restore procedures are incomplete. | Approve retention matrix, automate minimization, support access/deletion requests, and test backup restoration. | | RISK-038 | Source-controlled secret scan is ad hoc; no continuous secret scanner, dependency update bot, SBOM, provenance, or branch protection is documented. | Add secret/dependency/code scanning, reviewed lockfile updates, protected environments/branches, and artifact provenance appropriate to project scale. | @@ -112,7 +112,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | Remediate or time-bound the remaining root and Functions findings through the separately tested router, Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | -| Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. Static negative tests require that exact job at both protected-release CI rechecks. | #169, #173, and #182 expand the same focused suite for lease/fence, immutable plan, pre-send evidence, concurrent retry, and cutoff behavior. A green demo-emulator job proves synthetic source behavior only; it does not deploy Firebase or prove Stripe/live behavior. Make lint non-mutating/fail-closed, configure and prove branch-required checks, and add the broader TEST-001 suite. | +| Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) tracks a no-new-dependency exact-record checker over 102 frontend files, a named blocking hosted lint step, and a clean-checkout step. It disables `eslint-plugin-only-warn` in the lint process so original configured severities remain visible. Its static tests reject skipped, renamed, mutating, incomplete, conditional, lifecycle-hooked, execution-context-overridden, or failure-swallowing wiring. | The reviewable lint baseline deliberately records 125 configured errors and 7 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. #169, #173, and #182 expand the focused commerce suite. Green synthetic jobs do not deploy Firebase or prove Stripe/live behavior. Configure/prove branch-required checks and add the broader TEST-001 suite. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | | RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | | RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged as `23bca8c8`: My Account makes no false “sent” claim, blocks rapid repeats, and applies the same 60-second browser cooldown after either outcome. Its protected release run `29252492614` stopped before build because the required public App Check key was absent, so neither frontend revision is published. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one byte-equivalent password-reset result after provider success or failure, an immediate repeat guard, and the same browser wait. Synthetic tests use mocked calls and canary values only. | Publish and verify each exact frontend revision separately. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, and private Firebase email-enumeration-protection readback under #119. Identical browser copy does not prove the direct Firebase API suppresses account-state differences. An accepted verification request does not prove delivery, and browser cooldowns can reset; they are not server abuse protection. Incoming action-code recovery remains AUTH-MAIL-002C2 under #120. | diff --git a/package.json b/package.json index 2e460590..f636d305 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "predeploy": "npm run build", "deploy": "gh-pages -d build", "emulators": "firebase emulators:start --project demo-mprc-local --only auth,firestore,functions", + "lint:ci": "node .github/scripts/check-frontend-lint.cjs", "lint:fix": "eslint --fix --ext .js,.jsx .", "test": "react-scripts test", "test:spa-navigation": "node --test tests/spa-navigation.test.js", diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 88810ff5..fc4e0f1d 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -1,13 +1,20 @@ const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); +const util = require('node:util'); const ROOT = path.resolve(__dirname, '..'); const CI_PATH = path.join(ROOT, '.github/workflows/ci.yml'); const RELEASE_PATH = path.join(ROOT, '.github/workflows/deploy.yml'); +const PACKAGE_PATH = path.join(ROOT, 'package.json'); +const LINT_BASELINE_PATH = path.join(ROOT, '.github/lint-baseline.json'); const ciWorkflow = fs.readFileSync(CI_PATH, 'utf8'); const releaseWorkflow = fs.readFileSync(RELEASE_PATH, 'utf8'); +const packageJson = JSON.parse(fs.readFileSync(PACKAGE_PATH, 'utf8')); +const lintBaseline = JSON.parse(fs.readFileSync(LINT_BASELINE_PATH, 'utf8')); +const lintGate = require('../.github/scripts/check-frontend-lint.cjs'); const JOB_ID = 'commerce-command-journal'; const JOB_NAME = 'Commerce command journal emulator'; @@ -16,6 +23,69 @@ const OPT_IN = 'REQUIRE_COMMERCE_COMMAND_JOURNAL_EMULATOR'; const TEST_FILE = 'commerceCommandJournal.emulator.test.js'; const NEVER_RUN = ['$', '{{ false }}'].join(''); const ALWAYS_TRUE = ['$', '{{ true }}'].join(''); +const FRONTEND_JOB_ID = 'frontend'; +const FRONTEND_JOB_NAME = 'Frontend lint + build'; +const FRONTEND_RUNNER = 'ubuntu-latest'; +const LINT_STEP_NAME = 'Run non-mutating frontend lint'; +const LINT_COMMAND = 'node .github/scripts/check-frontend-lint.cjs'; +const CLEAN_STEP_NAME = 'Verify frontend lint left the checkout unchanged'; +const CLEAN_COMMANDS = Object.freeze([ + 'git diff --exit-code HEAD --', + 'frontend_git_status="$(git status --porcelain=v1 --untracked-files=all)"', + 'test -z "$frontend_git_status"', +]); +const LINT_SCRIPT = 'node .github/scripts/check-frontend-lint.cjs'; +const EXPECTED_LINT_FILES = 102; +const EXPECTED_LINT_ERRORS = 125; +const EXPECTED_LINT_WARNINGS = 7; +const YAML_TO_JSON = [ + 'require "yaml"', + 'require "json"', + 'source = STDIN.read', + 'tree = Psych.parse_stream(source)', + 'check = lambda do |node|', + ' if node.is_a?(Psych::Nodes::Mapping)', + ' seen = {}', + ' node.children.each_slice(2) do |key, value|', + ' raise "complex_yaml_key" unless key.is_a?(Psych::Nodes::Scalar)', + ' raise "duplicate_yaml_key" if seen[key.value]', + ' seen[key.value] = true', + ' check.call(value)', + ' end', + ' elsif node.is_a?(Psych::Nodes::Alias)', + ' raise "yaml_alias_forbidden"', + ' elsif node.is_a?(Psych::Nodes::Stream) || node.is_a?(Psych::Nodes::Document) || node.is_a?(Psych::Nodes::Sequence)', + ' node.children.each { |child| check.call(child) if child }', + ' end', + 'end', + 'check.call(tree)', + 'document = YAML.safe_load(source, permitted_classes: [], permitted_symbols: [], aliases: false)', + 'STDOUT.write(JSON.generate(document))', +].join("\n"); +const parsedWorkflowCache = new Map(); + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parseWorkflow(workflow) { + if (parsedWorkflowCache.has(workflow)) return parsedWorkflowCache.get(workflow); + const result = childProcess.spawnSync('ruby', ['-e', YAML_TO_JSON], { + input: workflow, + encoding: 'utf8', + maxBuffer: 2 * 1024 * 1024, + }); + let parsed = null; + if (result.status === 0) { + try { + parsed = JSON.parse(result.stdout); + } catch (_error) { + parsed = null; + } + } + parsedWorkflowCache.set(workflow, parsed); + return parsed; +} function jobBlock(workflow, jobId) { const startMarker = ` ${jobId}:\n`; @@ -72,6 +142,111 @@ function ciErrors(workflow) { return errors; } +function frontendLintErrors(workflow, scripts = packageJson.scripts) { + const errors = []; + const block = jobBlock(workflow, FRONTEND_JOB_ID); + if (!block) return ['missing frontend job']; + + const parsed = parseWorkflow(workflow); + const frontend = parsed?.jobs?.[FRONTEND_JOB_ID]; + if (!isPlainObject(parsed) || !isPlainObject(frontend) || !Array.isArray(frontend.steps)) { + return ['workflow YAML or frontend job is invalid']; + } + if (Object.prototype.hasOwnProperty.call(parsed, 'defaults') + || Object.prototype.hasOwnProperty.call(parsed, 'env')) { + errors.push('workflow execution context may override frontend commands'); + } + if (JSON.stringify(Object.keys(frontend).sort()) + !== JSON.stringify(['name', 'runs-on', 'permissions', 'steps'].sort())) { + errors.push('frontend job execution context is not exact'); + } + if (frontend.name !== FRONTEND_JOB_NAME || frontend['runs-on'] !== FRONTEND_RUNNER) { + errors.push('frontend job identity or runner is not exact'); + } + if (!util.isDeepStrictEqual(frontend.permissions, { contents: 'read' })) { + errors.push('frontend job permissions are not exact'); + } + + const lines = block.split('\n'); + const lintStep = [ + ` - name: ${LINT_STEP_NAME}`, + ` run: ${LINT_COMMAND}`, + ]; + const cleanStep = [ + ` - name: ${CLEAN_STEP_NAME}`, + ' run: |', + ` ${CLEAN_COMMANDS[0]}`, + ` ${CLEAN_COMMANDS[1]}`, + ` ${CLEAN_COMMANDS[2]}`, + ]; + const stepAt = (expected) => { + const start = lines.findIndex((line) => line === expected[0]); + if (start < 0) return false; + const nextStep = lines.findIndex((line, index) => ( + index > start && line.startsWith(' - ') + )); + const end = nextStep < 0 ? lines.length : nextStep; + return JSON.stringify(lines.slice(start, end).filter((line) => line !== '')) + === JSON.stringify(expected); + }; + if (!stepAt(lintStep)) errors.push('lint step is not exact'); + if (!stepAt(cleanStep)) errors.push('clean-check step is not exact'); + lintStep.concat(cleanStep).filter((requiredLine) => requiredLine !== ' run: |') + .forEach((requiredLine) => { + if (lines.filter((line) => line === requiredLine).length !== 1) { + errors.push(`required line must occur exactly once: ${requiredLine.trim()}`); + } + }); + + const lintIndexes = frontend.steps + .map((step, index) => (isPlainObject(step) && step.name === LINT_STEP_NAME ? index : -1)) + .filter((index) => index >= 0); + const cleanIndexes = frontend.steps + .map((step, index) => (isPlainObject(step) && step.name === CLEAN_STEP_NAME ? index : -1)) + .filter((index) => index >= 0); + if (lintIndexes.length !== 1 || cleanIndexes.length !== 1 + || lintIndexes[0] !== 3 || cleanIndexes[0] !== 4) { + errors.push('trusted setup, lint, and clean-check order is not exact'); + } else { + const lintDefinition = frontend.steps[lintIndexes[0]]; + const cleanDefinition = frontend.steps[cleanIndexes[0]]; + if (JSON.stringify(Object.keys(lintDefinition).sort()) !== JSON.stringify(['name', 'run'])) { + errors.push('lint step execution context is not exact'); + } + if (JSON.stringify(Object.keys(cleanDefinition).sort()) !== JSON.stringify(['name', 'run'])) { + errors.push('clean-check step execution context is not exact'); + } + if (lintDefinition.run !== LINT_COMMAND + || cleanDefinition.run !== `${CLEAN_COMMANDS.join('\n')}\n`) { + errors.push('parsed lint or clean command is not exact'); + } + } + + const expectedPrefix = [ + { + uses: 'actions/checkout@v6', + with: { 'persist-credentials': false }, + }, + { + uses: 'actions/setup-node@v6', + with: { 'node-version': '20', cache: 'npm' }, + }, + { run: 'npm ci --legacy-peer-deps --ignore-scripts' }, + { name: LINT_STEP_NAME, run: LINT_COMMAND }, + { name: CLEAN_STEP_NAME, run: `${CLEAN_COMMANDS.join('\n')}\n` }, + ]; + if (!util.isDeepStrictEqual(frontend.steps.slice(0, expectedPrefix.length), expectedPrefix)) { + errors.push('trusted setup and lint prefix is not exact'); + } + + if (!scripts || scripts['lint:ci'] !== LINT_SCRIPT) errors.push('wrong lint script'); + if (scripts?.['prelint:ci'] !== undefined || scripts?.['postlint:ci'] !== undefined) { + errors.push('lint lifecycle hooks are forbidden'); + } + if (/lint:fix|--fix\b/.test(block)) errors.push('CI lint may not mutate files'); + return errors; +} + function releaseErrors(workflow) { const errors = []; const requiredName = `"${JOB_NAME}"`; @@ -91,6 +266,196 @@ test('journal emulator CI job is exact, isolated, and blocking', () => { assert.deepEqual(releaseErrors(releaseWorkflow), []); }); +test('frontend lint covers every source extension without mutating or swallowing failures', async () => { + assert.deepEqual(frontendLintErrors(ciWorkflow), []); + assert.deepEqual(lintGate.LINT_EXTENSIONS, ['.js', '.jsx', '.ts', '.tsx']); + assert.deepEqual(lintGate.LINT_TARGETS, ['src']); + assert.equal(lintGate.ESLINT_OPTIONS.fix, false); + assert.equal(lintGate.ESLINT_OPTIONS.cache, false); + assert.equal(lintGate.ESLINT_OPTIONS.errorOnUnmatchedPattern, true); + assert.deepEqual(lintGate.ESLINT_OPTIONS.extensions, ['.js', '.jsx', '.ts', '.tsx']); + + const verified = await lintGate.verifyCurrentLint(lintBaseline); + assert.equal(verified.scannedFileCount, EXPECTED_LINT_FILES); + assert.equal(verified.errorCount, EXPECTED_LINT_ERRORS); + assert.equal(verified.warningCount, EXPECTED_LINT_WARNINGS); +}); + +test('frontend lint workflow guard rejects unsafe wiring mutations', () => { + const workflowMutations = [ + ciWorkflow.replace(LINT_STEP_NAME, 'Run frontend formatting'), + ciWorkflow.replace(LINT_COMMAND, 'npm run lint:fix'), + ciWorkflow.replace(LINT_COMMAND, `${LINT_COMMAND} || exit 0`), + ciWorkflow.replace( + ` - name: ${LINT_STEP_NAME}`, + ` - name: ${LINT_STEP_NAME}\n if: ${NEVER_RUN}`, + ), + ciWorkflow.replace( + ' frontend:\n', + ` frontend:\n if: ${NEVER_RUN}\n`, + ), + ciWorkflow.replace( + ` - name: ${CLEAN_STEP_NAME}`, + ` - continue-on-error: ${ALWAYS_TRUE}\n name: ${CLEAN_STEP_NAME}`, + ), + ciWorkflow.replace( + ` run: ${LINT_COMMAND}`, + ` # run: ${LINT_COMMAND}\n run: echo lint-skipped`, + ), + ciWorkflow.replace(CLEAN_COMMANDS[0], `# ${CLEAN_COMMANDS[0]}`), + ciWorkflow.replace(CLEAN_COMMANDS[1], 'git status --short'), + ciWorkflow.replace( + ` ${CLEAN_COMMANDS[1]}`, + ` ${CLEAN_COMMANDS[1]}\n ${CLEAN_COMMANDS[2]}\n if: ${NEVER_RUN}`, + ), + ciWorkflow.replace( + ` - name: ${LINT_STEP_NAME}\n` + + ` run: ${LINT_COMMAND}\n` + + ` - name: ${CLEAN_STEP_NAME}\n` + + ' run: |\n' + + ` ${CLEAN_COMMANDS.join('\n ')}`, + ` - name: ${CLEAN_STEP_NAME}\n` + + ' run: |\n' + + ` ${CLEAN_COMMANDS.join('\n ')}\n` + + ` - name: ${LINT_STEP_NAME}\n` + + ` run: ${LINT_COMMAND}`, + ), + ciWorkflow.replace(' frontend:\n', ` frontend:\n "if": ${NEVER_RUN}\n`), + ciWorkflow.replace(' frontend:\n', ' frontend:\n "continue-on-error": true\n'), + ciWorkflow.replace( + ' frontend:\n', + " frontend:\n defaults:\n run:\n shell: bash -c 'bash \"$1\" || true' _ {0}\n", + ), + ciWorkflow.replace( + 'jobs:\n', + "defaults:\n run:\n shell: bash -c 'bash \"$1\" || true' _ {0}\njobs:\n", + ), + ciWorkflow.replace( + ' name: Frontend lint + build\n', + ' name: Frontend lint + build\n "name": Frontend decoy\n', + ), + ciWorkflow.replace(' runs-on: ubuntu-latest\n', ' runs-on: self-hosted\n'), + ciWorkflow.replace( + ` - name: ${LINT_STEP_NAME}\n`, + ' - name: Poison later process environment\n' + + ' run: echo NPM_CONFIG_SCRIPT_SHELL=/usr/bin/true >> "$GITHUB_ENV"\n' + + ` - name: ${LINT_STEP_NAME}\n`, + ), + ]; + workflowMutations.forEach((mutated) => { + assert.notDeepEqual(frontendLintErrors(mutated), []); + }); + + const scriptMutations = [ + { ...packageJson.scripts, 'lint:ci': 'eslint --fix src' }, + { ...packageJson.scripts, 'lint:ci': 'eslint src || true' }, + { ...packageJson.scripts, 'lint:ci': 'eslint --ext .js,.jsx src' }, + { ...packageJson.scripts, 'lint:ci': undefined }, + { ...packageJson.scripts, 'prelint:ci': 'node replace-baseline.js' }, + { ...packageJson.scripts, 'postlint:ci': 'git checkout -- .' }, + ]; + scriptMutations.forEach((scripts) => { + assert.notDeepEqual(frontendLintErrors(ciWorkflow, scripts), []); + }); +}); + +function syntheticResult(messages) { + return [{ + filePath: path.join(ROOT, 'src', 'synthetic.tsx'), + messages, + }]; +} + +function syntheticMessage(overrides = {}) { + return { + ruleId: 'synthetic/rule', + severity: 1, + message: 'Synthetic warning.', + line: 1, + column: 1, + endLine: 1, + endColumn: 2, + nodeType: 'Identifier', + messageId: 'synthetic', + ...overrides, + }; +} + +test('lint baseline accepts only the exact normalized file and finding records', () => { + const exact = lintGate.normalizeEslintResults(syntheticResult([syntheticMessage()])); + const baseline = lintGate.createBaseline(exact); + assert.deepEqual(lintGate.verifyScan(exact, baseline), baseline); + + const added = lintGate.normalizeEslintResults(syntheticResult([ + syntheticMessage(), + syntheticMessage({ line: 2 }), + ])); + const removed = lintGate.normalizeEslintResults(syntheticResult([])); + const moved = lintGate.normalizeEslintResults(syntheticResult([ + syntheticMessage({ line: 2 }), + ])); + [added, removed, moved].forEach((scan) => { + assert.throws(() => lintGate.verifyScan(scan, baseline), /lint_baseline_mismatch/); + }); +}); + +test('lint baseline rejects parser errors and malformed result or baseline data', async () => { + const parserError = lintGate.normalizeEslintResults(syntheticResult([ + syntheticMessage({ ruleId: null, severity: 2, fatal: true, message: 'Parsing error.' }), + ])); + assert.throws( + () => lintGate.createBaseline(parserError), + /fatal_lint_findings/, + ); + assert.throws(() => lintGate.normalizeEslintResults({}), /invalid_lint_results/); + assert.throws( + () => lintGate.normalizeEslintResults([{ filePath: 'src/file.ts', messages: {} }]), + /invalid_lint_result/, + ); + assert.throws( + () => lintGate.parseBaseline({ schemaVersion: 1 }), + /invalid_lint_baseline_keys/, + ); + assert.throws( + () => lintGate.normalizeEslintResults(syntheticResult([ + syntheticMessage({ fatal: 'true' }), + ])), + /invalid_lint_fatal/, + ); + assert.throws( + () => lintGate.normalizeEslintResults(syntheticResult([ + syntheticMessage({ messageId: {} }), + ])), + /invalid_lint_message_id/, + ); + assert.throws( + () => lintGate.normalizeEslintResults(syntheticResult([ + syntheticMessage({ nodeType: [] }), + ])), + /invalid_lint_node_type/, + ); + const hostile = new Proxy(new Error('HOSTILE_MESSAGE_GETTER_CANARY_DO_NOT_LOG'), { + get() { + throw new Error('HOSTILE_PROXY_CANARY_DO_NOT_LOG'); + }, + getPrototypeOf() { + throw new Error('HOSTILE_PROTOTYPE_CANARY_DO_NOT_LOG'); + }, + }); + await assert.rejects( + lintGate.verifyCurrentLint(lintBaseline, async () => Promise.reject(hostile)), + (error) => error instanceof Error + && error.message === lintGate.SAFE_REJECTION_MESSAGE + && !error.stack.includes('HOSTILE_'), + ); + assert.equal(lintGate.SAFE_FAILURE_OUTPUT, 'frontend_lint_gate_failed:lint_rejected\n'); + const failedAssignment = childProcess.spawnSync('bash', ['-e', '-c', [ + 'frontend_git_status="$(false)"', + 'test -z "$frontend_git_status"', + ].join('\n')]); + assert.notEqual(failedAssignment.status, 0); +}); + test('guard rejects missing, renamed, broadened, or unsafe CI wiring', () => { const mutations = [ ciWorkflow.replace(` ${JOB_ID}:\n`, ' removed-journal-job:\n'),