diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..00a9e87df4 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./gui/node_modules/oxlint/configuration_schema.json", + "plugins": [], + "jsPlugins": [ + { + "name": "anti-slop", + "specifier": "./gui/.eslint/anti-slop/index.mjs" + } + ], + "categories": { + "correctness": "off" + }, + "env": { + "builtin": true, + "node": true + }, + "rules": { + "anti-slop/no-chained-type-assertions": "warn", + "anti-slop/no-known-value-widening": "warn", + "anti-slop/no-object-parameters": "warn", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-widen-then-assert": "warn", + "anti-slop/require-safety-comment-for-type-assertion": "warn" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e28f00f8eb..7382d309b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,18 +58,19 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Pre-push hook After cloning, run once to install a local pre-push hook that runs the typecheck, -unit-test, privacy-scan, and (when `gui/` changed) GUI eslint and React Doctor -portions of the CI gate: +repository anti-slop lint, unit-test, privacy-scan, and (when `gui/` changed) GUI +eslint and React Doctor portions of the CI gate: ```sh bun run setup:hooks ``` This installs a `pre-push` hook (into the hooks dir git reports, so worktrees and -`core.hooksPath` work) that runs `bun run prepush` — `typecheck`, -`lint:gui:if-changed`, `test`, `privacy:scan`, and `doctor:gui:if-changed` — -before every `git push`. Both `lint:gui:if-changed` and `doctor:gui:if-changed` -run their check only when the push touches `gui/`. +`core.hooksPath` work) that runs `bun run prepush` — `typecheck`, the repository +`lint` over `src/` and `scripts/`, `lint:gui:if-changed`, `test`, `privacy:scan`, +and `doctor:gui:if-changed` — before every `git push`. Both +`lint:gui:if-changed` and `doctor:gui:if-changed` run their check only when the +push touches `gui/`. The same checks run on ubuntu-latest, macos-latest, and windows-latest in CI (CI additionally builds the GUI and smoke-tests the CLI). Skip in an emergency with `git push --no-verify`. diff --git a/gui/.eslint/anti-slop/LICENSE b/gui/.eslint/anti-slop/LICENSE new file mode 100644 index 0000000000..69239ead1e --- /dev/null +++ b/gui/.eslint/anti-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dillon Mulroy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gui/.eslint/anti-slop/README.md b/gui/.eslint/anti-slop/README.md new file mode 100644 index 0000000000..1685ac0838 --- /dev/null +++ b/gui/.eslint/anti-slop/README.md @@ -0,0 +1,40 @@ +# OpenCodex anti-slop profile + +This directory contains an OpenCodex-local adaptation of selected rules from `dmmulroy/anti-slop`, snapshot `446268e5d15baa968eaec669ff65358d36ae6259`. + +The upstream project explicitly recommends vendoring and customising its rules. OpenCodex therefore owns this copy and its policy instead of taking a moving package dependency. + +## Enabled rules + +The repository enables these rules across runtime source, scripts, and dashboard TypeScript: + +- `no-chained-type-assertions` - warning during migration. +- `no-known-value-widening` - warning during migration. +- `no-object-parameters` - warning during migration. +- `no-reflect-apply` - error. +- `no-reflect-get` - error. +- `no-widen-then-assert` - warning during migration. +- `require-safety-comment-for-type-assertion` - warning during migration. + +Warnings keep the existing codebase lintable while making new low-evidence patterns visible. The two Reflect rules are errors because the repository has no legitimate production use of those dynamic escape hatches. + +## Intentionally excluded upstream rules + +OpenCodex accepts untrusted provider, protocol, process, and JSON input at explicit boundaries. `unknown`, narrow `typeof` checks, and dictionary-shaped boundary data can therefore be correct rather than slop. We do not enable upstream policies that broadly reject those patterns. + +The local profile intentionally omits: + +- `no-conditional-empty-object-spread` +- `no-module-mocking` +- `no-runtime-typeof` +- `no-shape-in-symbol-names` +- `no-unknown-parameters` +- `no-unknown-returns` +- `no-unknown-type-aliases` +- `no-unsafe-dictionary-type` + +## Implementation note + +The plugin uses Oxlint's ESLint-compatible JavaScript plugin shape directly. It has no runtime npm dependency, so the same vendored file can be loaded by both repository lint configurations without coupling one package install tree to another. + +Upstream is MIT licensed. See `LICENSE` in this directory. diff --git a/gui/.eslint/anti-slop/index.mjs b/gui/.eslint/anti-slop/index.mjs new file mode 100644 index 0000000000..40f46aa91c --- /dev/null +++ b/gui/.eslint/anti-slop/index.mjs @@ -0,0 +1,570 @@ +/** + * OpenCodex-local anti-slop rules. + * + * Adapted from dmmulroy/anti-slop at commit + * 446268e5d15baa968eaec669ff65358d36ae6259 under the MIT license. + * OpenCodex keeps only rules that fit this repository's TypeScript boundaries. + */ + +const FUNCTION_BOUNDARY_TYPES = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +const COMMENT_OWNER_TYPES = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +const PARAMETER_OWNER_TYPES = [ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSCallSignatureDeclaration", + "TSConstructSignatureDeclaration", + "TSConstructorType", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", + "TSFunctionType", + "TSMethodSignature", +]; + +function unwrapParenthesizedExpression(expression) { + let current = expression; + while (current?.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTransparentType(type) { + let current = type; + while ( + current?.type === "TSParenthesizedType" || + (current?.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function typeReferenceName(type) { + return type?.type === "TSTypeReference" && type.typeName?.type === "Identifier" + ? type.typeName.name + : null; +} + +function classifyBroadType(type) { + const current = unwrapTransparentType(type); + if (!current) return null; + if (current.type === "TSUnknownKeyword" || current.type === "TSAnyKeyword") return "unknown"; + if (current.type === "TSObjectKeyword") return "object"; + if (current.type === "TSMappedType") return "open dictionary"; + if (current.type === "TSTypeLiteral") { + if (current.members?.some((member) => member.type === "TSIndexSignature")) { + return "open dictionary"; + } + return current.members?.length > 0 ? "anonymous object" : null; + } + if (current.type !== "TSTypeReference") return null; + + const name = typeReferenceName(current); + if (["Readonly", "Partial", "Required", "NonNullable"].includes(name)) { + const inner = current.typeArguments?.params?.[0]; + return inner ? classifyBroadType(inner) : null; + } + return name === "Record" ? "open dictionary" : null; +} + +function resolveVariable(sourceCode, identifier) { + if (typeof sourceCode?.getScope !== "function") return null; + let scope = sourceCode.getScope(identifier); + while (scope) { + const variable = scope.set?.get?.(identifier.name); + if (variable) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable) { + const definitions = variable?.defs ?? []; + if (definitions.length !== 1) return null; + const [definition] = definitions; + return definition?.type === "Variable" && definition.node?.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstDeclarator(declarator) { + return declarator?.parent?.type === "VariableDeclaration" && declarator.parent.kind === "const"; +} + +function isKnownEvidenceExpression(sourceCode, expression, visitedVariables = new Set()) { + let current = expression; + while (current?.type === "ParenthesizedExpression" || current?.type === "TSNonNullExpression") { + current = current.expression; + } + + if (current?.type === "TSAsExpression" || current?.type === "TSTypeAssertion") { + if (classifyBroadType(current.typeAnnotation) === null) return true; + return isKnownEvidenceExpression(sourceCode, current.expression, visitedVariables); + } + if (current?.type === "TSSatisfiesExpression") { + return isKnownEvidenceExpression(sourceCode, current.expression, visitedVariables); + } + + if ( + current?.type === "Literal" || + current?.type === "TemplateLiteral" || + current?.type === "ArrayExpression" || + current?.type === "ArrowFunctionExpression" || + current?.type === "ClassExpression" || + current?.type === "FunctionExpression" || + current?.type === "NewExpression" || + current?.type === "ObjectExpression" + ) { + return true; + } + + if (current?.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, current); + if (!variable || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if (!declarator || !isStableConstDeclarator(declarator)) return false; + + if ( + declarator.id?.type === "Identifier" && + declarator.id.typeAnnotation?.typeAnnotation && + classifyBroadType(declarator.id.typeAnnotation.typeAnnotation) === null + ) { + return true; + } + if (!declarator.init) return false; + + const nextVisited = new Set(visitedVariables); + nextVisited.add(variable); + return isKnownEvidenceExpression(sourceCode, declarator.init, nextVisited); +} + +function isTypeAssertion(node) { + return node?.type === "TSAsExpression" || node?.type === "TSTypeAssertion"; +} + +function isConstAssertion(node) { + const annotation = node?.typeAnnotation; + return ( + annotation?.type === "TSTypeReference" && + annotation.typeName?.type === "Identifier" && + annotation.typeName.name === "const" + ); +} + +function outermostAssertionInChain(node) { + let current = node; + let parent = node.parent; + while (parent?.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + return !isTypeAssertion(parent) || parent.expression !== current; +} + +function forbiddenAssertionChain(node) { + let assertionCount = 0; + let hasNonConst = false; + let current = node; + while (isTypeAssertion(current)) { + assertionCount += 1; + hasNonConst ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + return assertionCount > 1 && hasNonConst; +} + +const noChainedTypeAssertions = { + meta: { + type: "problem", + docs: { description: "Disallow chained TypeScript type assertions." }, + schema: [], + messages: { + chained: + "This assertion chain discards type evidence. Keep the precise type or parse untrusted input before narrowing it.", + }, + }, + create(context) { + const check = (node) => { + if (outermostAssertionInChain(node) && forbiddenAssertionChain(node)) { + context.report({ node, messageId: "chained" }); + } + }; + return { TSAsExpression: check, TSTypeAssertion: check }; + }, +}; + +function nearestFunction(node) { + let current = node?.parent; + while (current && current.type !== "Program") { + if (FUNCTION_BOUNDARY_TYPES.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode, key) { + if (key?.type === "Identifier" || key?.type === "PrivateIdentifier") return key.name; + if (key?.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode, owner) { + if (!owner) return "anonymous function"; + if (owner.id?.name) return owner.id.name; + const parent = owner.parent; + if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") { + return parent.id.name; + } + if (parent?.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function hasParentAssertion(node) { + return isTypeAssertion(node?.parent); +} + +const noKnownValueWidening = { + meta: { + type: "problem", + docs: { description: "Reject clear syntax-only cases where known values are widened." }, + schema: [], + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, use `satisfies`, or use a named owner contract.", + }, + }, + create(context) { + const reportFlow = (expression, annotation, subject) => { + if (!expression || !annotation) return; + const target = classifyBroadType(annotation); + if (!target || !isKnownEvidenceExpression(context.sourceCode, expression)) return; + context.report({ node: expression, messageId: "widening", data: { subject, target } }); + }; + + return { + VariableDeclarator(node) { + if (node.init && node.id?.type === "Identifier") { + reportFlow(node.init, node.id.typeAnnotation?.typeAnnotation, `binding \`${node.id.name}\``); + } + }, + PropertyDefinition(node) { + if (node.value) { + reportFlow( + node.value, + node.typeAnnotation?.typeAnnotation, + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + } + }, + AccessorProperty(node) { + if (node.value) { + reportFlow( + node.value, + node.typeAnnotation?.typeAnnotation, + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + } + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left?.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + const declarator = variableDeclarator(variable); + if (declarator?.id?.type !== "Identifier") return; + reportFlow( + node.right, + declarator.id.typeAnnotation?.typeAnnotation, + `binding \`${declarator.id.name}\``, + ); + }, + ReturnStatement(node) { + if (!node.argument) return; + const owner = nearestFunction(node); + reportFlow( + node.argument, + owner?.returnType?.typeAnnotation, + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body?.type === "BlockStatement") return; + reportFlow( + node.body, + node.returnType?.typeAnnotation, + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (!hasParentAssertion(node)) reportFlow(node.expression, node.typeAnnotation, "assertion"); + }, + TSTypeAssertion(node) { + if (!hasParentAssertion(node)) reportFlow(node.expression, node.typeAnnotation, "assertion"); + }, + }; + }, +}; + +function parameterAnnotation(parameter) { + if (!parameter) return null; + if (parameter.type === "TSParameterProperty") return parameterAnnotation(parameter.parameter); + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left?.typeAnnotation ?? null; + } + return parameter.typeAnnotation ?? null; +} + +function parameterName(sourceCode, parameter) { + if (parameter?.type === "Identifier") return parameter.name; + return sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +function shadowedTypeNames(node) { + const names = new Set(); + let current = node; + while (current && current.type !== "Program") { + for (const parameter of current.typeParameters?.params ?? []) { + const name = parameter?.name?.name; + if (name) names.add(name); + } + current = current.parent; + } + return names; +} + +const noObjectParameters = { + meta: { + type: "problem", + docs: { description: "Disallow the broad object type on function inputs." }, + schema: [], + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type and parse external input at its boundary.", + }, + }, + create(context) { + const aliases = new Map(); + + const resolvesToObject = (type, shadowed, visited = new Set()) => { + const current = unwrapTransparentType(type); + if (!current) return false; + if (current.type === "TSObjectKeyword") return true; + if (current.type === "TSUnionType") { + return current.types?.some((member) => resolvesToObject(member, shadowed, visited)) ?? false; + } + if (current.type !== "TSTypeReference") return false; + const name = typeReferenceName(current); + if ( + !name || + current.typeArguments?.params?.length > 0 || + shadowed.has(name) || + visited.has(name) + ) { + return false; + } + const alias = aliases.get(name); + if (!alias) return false; + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToObject(alias, shadowed, nextVisited); + }; + + const checkParameters = (node) => { + const shadowed = shadowedTypeNames(node); + for (const parameter of node.params ?? []) { + const annotation = parameterAnnotation(parameter); + if (!annotation?.typeAnnotation) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowed)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(context.sourceCode, parameter) }, + }); + } + }; + + const visitors = { + Program(node) { + aliases.clear(); + for (const statement of node.body ?? []) { + const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters?.params?.length ?? 0) === 0 + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + }; + for (const type of PARAMETER_OWNER_TYPES) visitors[type] = checkParameters; + return visitors; + }, +}; + +function isGlobalReflect(sourceCode, expression) { + if (expression?.type !== "Identifier" || expression.name !== "Reflect") return false; + if (typeof sourceCode?.isGlobalReference === "function" && sourceCode.isGlobalReference(expression)) { + return true; + } + const variable = resolveVariable(sourceCode, expression); + return variable !== null && (variable.defs?.length ?? 0) === 0; +} + +function isGlobalReflectMethodCall(sourceCode, callee, methodName) { + if (!callee || !("property" in callee) || !("object" in callee) || !("computed" in callee)) { + return false; + } + if (!isGlobalReflect(sourceCode, callee.object)) return false; + return callee.computed + ? callee.property?.type === "Literal" && callee.property.value === methodName + : callee.property?.type === "Identifier" && callee.property.name === methodName; +} + +function reflectRule(methodName, messageId, description, message) { + return { + meta: { + type: "problem", + docs: { description }, + schema: [], + messages: { [messageId]: message }, + }, + create(context) { + return { + CallExpression(node) { + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, methodName)) { + context.report({ node, messageId }); + } + }, + }; + }, + }; +} + +const noReflectApply = reflectRule( + "apply", + "reflectApply", + "Disallow Reflect.apply in favour of typed calls.", + "Replace `Reflect.apply` with a typed function call or model dynamic dispatch behind a named interface.", +); + +const noReflectGet = reflectRule( + "get", + "reflectGet", + "Disallow Reflect.get in favour of typed property access.", + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", +); + +function initializerBroadType(declarator) { + const annotation = declarator?.id?.type === "Identifier" + ? declarator.id.typeAnnotation?.typeAnnotation + : null; + const declaredBroad = classifyBroadType(annotation); + if (declaredBroad) return { kind: declaredBroad, expression: declarator.init }; + + const init = unwrapParenthesizedExpression(declarator?.init); + if (isTypeAssertion(init)) { + const assertedBroad = classifyBroadType(init.typeAnnotation); + if (assertedBroad) return { kind: assertedBroad, expression: init.expression }; + } + return null; +} + +const noWidenThenAssert = { + meta: { + type: "problem", + docs: { description: "Detect local const flows that widen known values before asserting them back." }, + schema: [], + messages: { + widenThenAssert: + "Binding `{{name}}` discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use.", + }, + }, + create(context) { + const check = (node) => { + const expression = unwrapParenthesizedExpression(node.expression); + if (expression?.type !== "Identifier") return; + if (classifyBroadType(node.typeAnnotation) !== null) return; + + const variable = resolveVariable(context.sourceCode, expression); + const declarator = variableDeclarator(variable); + if (!declarator || !isStableConstDeclarator(declarator) || !declarator.init) return; + const widened = initializerBroadType(declarator); + if (!widened || !isKnownEvidenceExpression(context.sourceCode, widened.expression)) return; + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + return { TSAsExpression: check, TSTypeAssertion: check }; + }, +}; + +function hasSafetyComment(sourceCode, node) { + if (typeof sourceCode?.getCommentsBefore !== "function") return true; + let current = node; + while (current) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true; + } + if (COMMENT_OWNER_TYPES.has(current.type) || current.parent?.type === "Program") return false; + current = current.parent; + } + return false; +} + +const requireSafetyCommentForTypeAssertion = { + meta: { + type: "problem", + docs: { description: "Require a SAFETY justification for non-const type assertions." }, + schema: [], + messages: { + missingSafetyComment: + "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + create(context) { + const check = (node) => { + if (!isConstAssertion(node) && !hasSafetyComment(context.sourceCode, node)) { + context.report({ node, messageId: "missingSafetyComment" }); + } + }; + return { TSAsExpression: check, TSTypeAssertion: check }; + }, +}; + +export default { + meta: { + name: "anti-slop", + version: "opencodex-1", + }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertions, + "no-known-value-widening": noKnownValueWidening, + "no-object-parameters": noObjectParameters, + "no-reflect-apply": noReflectApply, + "no-reflect-get": noReflectGet, + "no-widen-then-assert": noWidenThenAssert, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertion, + }, +}; diff --git a/gui/.oxlintrc.json b/gui/.oxlintrc.json index 8ff48e7dc1..2a90602a18 100644 --- a/gui/.oxlintrc.json +++ b/gui/.oxlintrc.json @@ -5,6 +5,10 @@ { "name": "local-i18n", "specifier": "./.eslint/local-i18n-plugin.ts" + }, + { + "name": "anti-slop", + "specifier": "./.eslint/anti-slop/index.mjs" } ], "categories": { @@ -112,6 +116,14 @@ "typescript/prefer-namespace-keyword": "error", "typescript/triple-slash-reference": "error", + "anti-slop/no-chained-type-assertions": "warn", + "anti-slop/no-known-value-widening": "warn", + "anti-slop/no-object-parameters": "warn", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-widen-then-assert": "warn", + "anti-slop/require-safety-comment-for-type-assertion": "warn", + "react/exhaustive-deps": "warn", "react/rules-of-hooks": "error", "react/react-compiler": "error", @@ -152,4 +164,4 @@ } } ] -} \ No newline at end of file +} diff --git a/gui/package.json b/gui/package.json index edb573b417..6fb976c9ff 100644 --- a/gui/package.json +++ b/gui/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint .", + "lint:core": "oxlint ../src ../scripts --config ../.oxlintrc.json", "test": "bun test tests", "lint:i18n": "oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts", "doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry", diff --git a/package.json b/package.json index 1ff11a7288..3093b03289 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "start": "bun run src/cli/index.ts start", "test": "bun scripts/test.ts", "typecheck": "bun x tsc --noEmit", + "lint": "cd gui && bun run lint:core", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", "generate:model-metadata": "bun scripts/generate-model-metadata.ts", @@ -49,7 +50,7 @@ "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", "release": "bun scripts/release.ts", "release:watch": "bun scripts/release.ts watch", - "prepush": "bun run typecheck && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", + "prepush": "bun run typecheck && bun run lint && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", "lint:gui": "cd gui && bun run lint", "lint:gui:if-changed": "bun scripts/lint-gui-if-changed.ts", "postmerge": "bun scripts/build-gui-if-changed.ts", diff --git a/tests/anti-slop-lint.test.ts b/tests/anti-slop-lint.test.ts new file mode 100644 index 0000000000..ea5e9cd028 --- /dev/null +++ b/tests/anti-slop-lint.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const guiDir = join(repoRoot, "gui"); +const rootConfig = join(repoRoot, ".oxlintrc.json"); + +function runOxlint(path: string) { + return spawnSync("bun", ["x", "oxlint", path, "--config", rootConfig], { + cwd: guiDir, + encoding: "utf8", + env: { + ...process.env, + NO_COLOR: "1", + }, + }); +} + +describe("anti-slop lint contract", () => { + test("runtime source and scripts have no error-level anti-slop findings", () => { + const result = spawnSync("bun", ["run", "lint:core"], { + cwd: guiDir, + encoding: "utf8", + env: { + ...process.env, + NO_COLOR: "1", + }, + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + console.error(result.stdout); + console.error(result.stderr); + } + + expect(result.status).toBe(0); + }); + + test("Reflect escape hatches fail while a shadowed local API does not", () => { + const fixtureDir = mkdtempSync(join(tmpdir(), "ocx-anti-slop-")); + try { + const globalReflect = join(fixtureDir, "global-reflect.ts"); + const localReflect = join(fixtureDir, "local-reflect.ts"); + writeFileSync(globalReflect, "export const read = (value: object) => Reflect.get(value, 'x');\n"); + writeFileSync( + localReflect, + "export const read = (Reflect: { get(value: object, key: string): unknown }, value: object) => Reflect.get(value, 'x');\n", + ); + + const rejected = runOxlint(globalReflect); + if (rejected.error) throw rejected.error; + expect(rejected.status).not.toBe(0); + + const accepted = runOxlint(localReflect); + if (accepted.error) throw accepted.error; + if (accepted.status !== 0) { + console.error(accepted.stdout); + console.error(accepted.stderr); + } + expect(accepted.status).toBe(0); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); +});