diff --git a/.oxlintrc.json b/.oxlintrc.json index 0a760d3d6..1a8119d65 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "import", "unicorn", "oxc"], - "jsPlugins": [{ "name": "rea", "specifier": "./scripts/lint/index.mjs" }], + "plugins": ["typescript", "import", "unicorn", "oxc", "vitest"], "rules": { "rea/no-buffer-deep-equality": "error", "rea/cancellation-cause": "error", @@ -29,7 +28,37 @@ "max-params": ["warn", 4], "no-var": "warn", "no-var-requires": "error", - "typescript/no-import-type-side-effects": "error" + "typescript/no-import-type-side-effects": "error", + "vitest/no-standalone-expect": "off", + "vitest/no-conditional-expect": "off", + "vitest/expect-expect": "off", + "vitest/no-disabled-tests": "off", + "vitest/require-mock-type-parameters": "off", + "vitest/require-to-throw-message": "off", + "import/no-duplicates": [ + "warn", + { + "preferInline": false + } + ], + "vitest/valid-expect": [ + "warn", + { + "maxArgs": 2 + } + ], + "rea/no-evidence-json-preadmission": "error", + "rea/no-positional-tool-contract": "error", + "rea/no-fixture-root-cleanup": "error", + "typescript/no-floating-promises": "warn", + "typescript/no-misused-promises": "warn", + "typescript/return-await": ["warn", "error-handling-correctness-only"], + "typescript/switch-exhaustiveness-check": [ + "warn", + { + "considerDefaultExhaustiveForUnions": false + } + ] }, "overrides": [ { @@ -42,6 +71,12 @@ "no-var-requires": "off" } }, + { + "files": ["src/**/*.ts"], + "rules": { + "typescript/no-misused-promises": "error" + } + }, { "files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"], "rules": { @@ -53,7 +88,8 @@ "skipBlankLines": true, "skipComments": true } - ] + ], + "typescript/no-misused-promises": "warn" } }, { @@ -97,6 +133,25 @@ } ] } + }, + { + "files": ["tests/**/*.test.ts", "src/**/*.test.ts"], + "rules": { + "vitest/valid-expect": [ + "warn", + { + "maxArgs": 2 + } + ], + "vitest/valid-expect-in-promise": "error", + "vitest/no-focused-tests": "error" + } + }, + { + "files": ["src/**/*.test.ts"], + "rules": { + "typescript/no-floating-promises": "error" + } } ], "env": { @@ -115,5 +170,14 @@ ".devin", ".factory", "third_party" + ], + "options": { + "reportUnusedDisableDirectives": "error" + }, + "jsPlugins": [ + { + "name": "rea", + "specifier": "./scripts/lint/index.mjs" + } ] } diff --git a/docs/testing.md b/docs/testing.md index 7af365f6f..f9dc40c84 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -159,13 +159,31 @@ cause when a directly checked caller `AbortSignal` is converted to an follow indirect cancellation helpers, or prove that an explicit cause equals the caller's reason. Keep focused boundary and lifecycle tests for those facts. -Type-aware linting remains outside the routine gate: a full source, test, and -script scan exceeded the bounded process-family memory envelope, including -with Go runtime limits. Future scoped evaluation can consider -`typescript/no-floating-promises`, `typescript/return-await` with -`error-handling-correctness-only`, `typescript/no-misused-promises`, and -`typescript/switch-exhaustiveness-check`. None replaces behavioral checks for -source identity, omission versus unknown, or Evidence provenance. +`rea/no-evidence-json-preadmission` rejects direct generic JSON parses in +`createEvidence` payload expressions; it does not analyze intermediate values. +`rea/no-positional-tool-contract` rejects catalog indices used to select +operation names, schemas or types; display-only selection remains allowed. +`rea/no-fixture-root-cleanup` follows `const` fixture-root aliases into teardown +deletion; mutable bindings and mixed root arrays remain outside its scope. + +Focused `.only` declarations, broken assertion promise chains, and unused disable +directives are errors. `vitest/valid-expect` warns (`maxArgs: 2`) because valid stored and +collected assertions can be awaited later. `import/no-duplicates` warns with +`preferInline: false`, preserving separate type imports. + +Fast lint stays syntax-based. `npm run lint:typed` runs serially with pinned +`oxlint-tsgolint` 7.0.2005 and records native plus typed diagnostics in +`.cache/static-checks/typed-oxlint.json`. Production `src` treats +`no-misused-promises` as an error; tests/fixtures warn. `no-floating-promises` is +an error in `src/**/*.test.ts`; other scopes, return-await and exhaustiveness +findings warn pending triage. + +`npm run knip:production` writes advisory candidates to +`.cache/static-checks/production-reachability.json`; execution/report errors fail. +Ordinary Knip remains. `check`, `check:pr`, and `check:ci:deep` run reachability +then typed lint sequentially after cached work. Typed lint builds the project +type graph and stays separate from fast feedback. Its default Go settings are +`GOMAXPROCS=2` and `GOMEMLIMIT=1024MiB`; caller-selected settings take precedence. Routine iterations and rebases need focused regressions and relevant checks. Before handing off a PR, run `npm run check` and generated-document checks when diff --git a/knip.production.json b/knip.production.json new file mode 100644 index 000000000..5ccf4fd2a --- /dev/null +++ b/knip.production.json @@ -0,0 +1,35 @@ +{ + "$schema": "./node_modules/knip/schema.json", + "entry": [ + "src/main.ts!", + "src/cli.ts!", + "src/cliOutput.ts!", + "src/cliBuiltinArguments.ts!", + "src/cli/cliResultOutput.ts!", + "src/mcpDoctor.ts!", + "src/composition/javascriptAnalysisWorkerProcess.ts!", + "src/composition/webBundleAnalysisWorkerProcess.ts!", + "src/sqlite/SqliteDatabaseWorker.ts!", + "src/evm/EvmoleInterfaceWorker.ts!", + "src/browser/history/NodeCaptureProcess.ts!", + "src/javascript/sourceMaps/SourceMapCodecProcess.ts!", + "scripts/rea.mjs!", + "scripts/rea-android-workspace-guardian.mjs!", + "scripts/electron-active-hook.cjs!", + "scripts/prepack.mjs!", + "scripts/prepare.mjs!", + "scripts/verify-windows-native-artifact.mjs!" + ], + "project": ["src/**/*.ts!", "!src/**/*.test.ts", "!src/**/*.fixture.ts"], + "ignoreExportsUsedInFile": true, + "include": [ + "files", + "exports", + "nsExports", + "types", + "nsTypes", + "enumMembers", + "namespaceMembers", + "duplicates" + ] +} diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index aaf5608cb..df798b062 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 956; runtime: 180. +Locked package entries: 963; runtime: 180. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -1100,6 +1100,36 @@ commander@13.1.0 | runtime | MIT Package: https://www.npmjs.com/package/@oxfmt/binding-win32-x64-msvc/v/0.72.0 Archive: https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz Integrity: sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw== +@oxlint-tsgolint/darwin-arm64@7.0.2005 | development, optional | MIT + Lockfile path: node_modules/@oxlint-tsgolint/darwin-arm64 + Package: https://www.npmjs.com/package/@oxlint-tsgolint/darwin-arm64/v/7.0.2005 + Archive: https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2005.tgz + Integrity: sha512-VqDZxcZVjlonNLuuvraVNgDQt+1ndwa4yJo8OqsK4HYh/61ba9IlMStCVfi/aaIqRoDhNbAKC8gdcTrXqnb+Ug== +@oxlint-tsgolint/darwin-x64@7.0.2005 | development, optional | MIT + Lockfile path: node_modules/@oxlint-tsgolint/darwin-x64 + Package: https://www.npmjs.com/package/@oxlint-tsgolint/darwin-x64/v/7.0.2005 + Archive: https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2005.tgz + Integrity: sha512-4kUfLRNYoVpv0Tm3jPYrvxyfOc/LmdvFC3pKZ4tL6K7lA7K/VS81SAtmxRzbYXYSyb3SBWhwsuX+df4neqzxxA== +@oxlint-tsgolint/linux-arm64@7.0.2005 | development, optional | MIT + Lockfile path: node_modules/@oxlint-tsgolint/linux-arm64 + Package: https://www.npmjs.com/package/@oxlint-tsgolint/linux-arm64/v/7.0.2005 + Archive: https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2005.tgz + Integrity: sha512-rhUNcPsBlKvhXtrbhbBUidtRBRdJ8fbEHN9H5Q5N59gby18ruGuDA7KUxl7oRnUzp797uykYQhkBDm+0KpfK/Q== +@oxlint-tsgolint/linux-x64@7.0.2005 | development, optional | MIT + Lockfile path: node_modules/@oxlint-tsgolint/linux-x64 + Package: https://www.npmjs.com/package/@oxlint-tsgolint/linux-x64/v/7.0.2005 + Archive: https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2005.tgz + Integrity: sha512-tKs9yinDQZOHygM0e8EJnjTU3mzJtdfkOZP6Udu82iLMePUSHOhTJ9zzU/mSd1hQxURI4LTRRiFhfzC+PzwdNA== +@oxlint-tsgolint/win32-arm64@7.0.2005 | development, optional | MIT + Lockfile path: node_modules/@oxlint-tsgolint/win32-arm64 + Package: https://www.npmjs.com/package/@oxlint-tsgolint/win32-arm64/v/7.0.2005 + Archive: https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2005.tgz + Integrity: sha512-VYPnu1gjfbSv+ROhuyNfMs382D4zQU2ZRc8Uc3nuEZo1k2kf+1KfTrg4hrlWh73h9GI+dUWyo+DYCQzUBlPwcQ== +@oxlint-tsgolint/win32-x64@7.0.2005 | development, optional | MIT + Lockfile path: node_modules/@oxlint-tsgolint/win32-x64 + Package: https://www.npmjs.com/package/@oxlint-tsgolint/win32-x64/v/7.0.2005 + Archive: https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2005.tgz + Integrity: sha512-ekU89cTGy9fpQzuvxIsFHrnoP8XoFjGIRgPEW6eqgY2NYjGy9pwpjF79o04WXRMJFKRrcaVqkZXUUBdLsqpQDw== @oxlint/binding-android-arm-eabi@1.87.0 | development, optional | MIT Lockfile path: node_modules/@oxlint/binding-android-arm-eabi Package: https://www.npmjs.com/package/@oxlint/binding-android-arm-eabi/v/1.87.0 @@ -3540,6 +3570,11 @@ oxlint@1.87.0 | development | MIT Package: https://www.npmjs.com/package/oxlint/v/1.87.0 Archive: https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz Integrity: sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg== +oxlint-tsgolint@7.0.2005 | development | MIT + Lockfile path: node_modules/oxlint-tsgolint + Package: https://www.npmjs.com/package/oxlint-tsgolint/v/7.0.2005 + Archive: https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2005.tgz + Integrity: sha512-6ccPGu4u4J9bwYPHXBFPee3RZrX3BboRdU7k14zRDDB1lvN3O7J/vPRFdaEfa1+CWx4xnQQOp4ctoaF66oO0uw== p-limit@2.3.0 | development | MIT Lockfile path: node_modules/p-limit Package: https://www.npmjs.com/package/p-limit/v/2.3.0 diff --git a/package-lock.json b/package-lock.json index 170aee203..0efee88df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "lint-staged": "16.4.0", "oxfmt": "0.72.0", "oxlint": "1.87.0", + "oxlint-tsgolint": "7.0.2005", "release-please": "17.3.0", "rolldown": "1.2.12", "tsrs": "0.18.0-ts7.1.0-dev.20260929", @@ -3491,6 +3492,90 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2005.tgz", + "integrity": "sha512-VqDZxcZVjlonNLuuvraVNgDQt+1ndwa4yJo8OqsK4HYh/61ba9IlMStCVfi/aaIqRoDhNbAKC8gdcTrXqnb+Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2005.tgz", + "integrity": "sha512-4kUfLRNYoVpv0Tm3jPYrvxyfOc/LmdvFC3pKZ4tL6K7lA7K/VS81SAtmxRzbYXYSyb3SBWhwsuX+df4neqzxxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2005.tgz", + "integrity": "sha512-rhUNcPsBlKvhXtrbhbBUidtRBRdJ8fbEHN9H5Q5N59gby18ruGuDA7KUxl7oRnUzp797uykYQhkBDm+0KpfK/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2005.tgz", + "integrity": "sha512-tKs9yinDQZOHygM0e8EJnjTU3mzJtdfkOZP6Udu82iLMePUSHOhTJ9zzU/mSd1hQxURI4LTRRiFhfzC+PzwdNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2005.tgz", + "integrity": "sha512-VYPnu1gjfbSv+ROhuyNfMs382D4zQU2ZRc8Uc3nuEZo1k2kf+1KfTrg4hrlWh73h9GI+dUWyo+DYCQzUBlPwcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2005.tgz", + "integrity": "sha512-ekU89cTGy9fpQzuvxIsFHrnoP8XoFjGIRgPEW6eqgY2NYjGy9pwpjF79o04WXRMJFKRrcaVqkZXUUBdLsqpQDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxlint/binding-android-arm-eabi": { "version": "1.87.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", @@ -10137,6 +10222,24 @@ } } }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2005.tgz", + "integrity": "sha512-6ccPGu4u4J9bwYPHXBFPee3RZrX3BboRdU7k14zRDDB1lvN3O7J/vPRFdaEfa1+CWx4xnQQOp4ctoaF66oO0uw==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2005", + "@oxlint-tsgolint/darwin-x64": "7.0.2005", + "@oxlint-tsgolint/linux-arm64": "7.0.2005", + "@oxlint-tsgolint/linux-x64": "7.0.2005", + "@oxlint-tsgolint/win32-arm64": "7.0.2005", + "@oxlint-tsgolint/win32-x64": "7.0.2005" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", diff --git a/package.json b/package.json index 28356cfa8..a1e8ae5d6 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "prebuild": "npm run deps:check", "build": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:skills --force", "precheck": "npm run deps:check", - "check": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check && npm run verify:architecture-guards", + "check": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check && npm run verify:architecture-guards && npm run knip:production && npm run lint:typed", "precheck:fast": "npm run deps:check", "check:fast": "node scripts/run-turbo.mjs run typecheck lint --output-logs=errors-only", "precheck:changed": "npm run deps:check", @@ -120,14 +120,14 @@ "precheck:test": "npm run deps:check", "check:test": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run", "precheck:pr": "npm run deps:check", - "check:pr": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos", + "check:pr": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos && npm run knip:production && npm run lint:typed", "precheck:ci": "npm run deps:check", "precheck:ci:fast": "npm run deps:check", "precheck:ci:deep": "npm run deps:check", "check:ci": "npm run check:ci:fast && npm run check:ci:deep", "check:ci:fast": "node scripts/run-exclusive.mjs artifacts npm run check:ci:fast:tasks", "check:ci:fast:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check", - "check:ci:deep": "node scripts/run-exclusive.mjs artifacts npm run check:ci:deep:tasks && npm run verify:architecture-guards && npm run verify:test-discovery && npm run test:python", + "check:ci:deep": "node scripts/run-exclusive.mjs artifacts npm run check:ci:deep:tasks && npm run verify:architecture-guards && npm run verify:test-discovery && npm run test:python && npm run knip:production && npm run lint:typed", "check:ci:deep:tasks": "node scripts/run-turbo.mjs run knip metadata:check jscpd scan:todos", "check:ci:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check jscpd scan:todos", "ci:static-context": "node scripts/ci/static-task-context.mjs", @@ -326,7 +326,9 @@ "docs:prepare": "node scripts/generate-package-metadata.mjs && node scripts/run-turbo.mjs run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema", "verify:web:qlog": "npm run build:cached && node scripts/verify-qlog-captures.mjs", "licenses:generate": "node scripts/generate-third-party-inventory.mjs", - "licenses:check": "node scripts/generate-third-party-inventory.mjs --check" + "licenses:check": "node scripts/generate-third-party-inventory.mjs --check", + "lint:typed": "node scripts/ci/static-check-report.mjs typed-lint", + "knip:production": "node scripts/ci/production-reachability-report.mjs" }, "lint-staged": { "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [ @@ -404,6 +406,7 @@ "lint-staged": "16.4.0", "oxfmt": "0.72.0", "oxlint": "1.87.0", + "oxlint-tsgolint": "7.0.2005", "release-please": "17.3.0", "rolldown": "1.2.12", "tsrs": "0.18.0-ts7.1.0-dev.20260929", diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index 7865e1157..a93872181 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -108,7 +108,7 @@ function classifyPackage(base, head) { continue; } if ( - /^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test( + /^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip(?::|$)|jscpd$)/.test( script, ) ) diff --git a/scripts/ci/production-reachability-report.d.mts b/scripts/ci/production-reachability-report.d.mts new file mode 100644 index 000000000..3970ca390 --- /dev/null +++ b/scripts/ci/production-reachability-report.d.mts @@ -0,0 +1,33 @@ +export interface ProductionReachabilityFinding { + readonly name: string; + readonly namespace?: string; + readonly kind?: string; + readonly specifier?: string; + readonly line?: number; + readonly col?: number; + readonly pos?: number; +} + +export interface ProductionReachabilityIssue { + readonly file: string; + readonly owners?: readonly { readonly name: string }[]; + readonly files?: readonly ProductionReachabilityFinding[]; + readonly exports?: readonly ProductionReachabilityFinding[]; + readonly nsExports?: readonly ProductionReachabilityFinding[]; + readonly types?: readonly ProductionReachabilityFinding[]; + readonly nsTypes?: readonly ProductionReachabilityFinding[]; + readonly enumMembers?: readonly ProductionReachabilityFinding[]; + readonly namespaceMembers?: readonly ProductionReachabilityFinding[]; + readonly duplicates?: readonly (readonly ProductionReachabilityFinding[])[]; +} + +export interface ProductionReachabilityReport { + readonly issues: readonly ProductionReachabilityIssue[]; +} + +export function parseProductionReachabilityReport( + text: string, +): ProductionReachabilityReport; +export function recordProductionReachabilityReport( + root?: string, +): Promise; diff --git a/scripts/ci/production-reachability-report.mjs b/scripts/ci/production-reachability-report.mjs new file mode 100644 index 000000000..8758e6a12 --- /dev/null +++ b/scripts/ci/production-reachability-report.mjs @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; + +const require = createRequire(import.meta.url); +const finding = z.object({ + name: z.string(), + namespace: z.string().optional(), + kind: z.string().optional(), + specifier: z.string().optional(), + line: z.number().optional(), + col: z.number().optional(), + pos: z.number().optional(), +}); +const issue = z.strictObject({ + file: z.string(), + owners: z.array(z.object({ name: z.string() })).optional(), + files: z.array(finding).optional(), + exports: z.array(finding).optional(), + nsExports: z.array(finding).optional(), + types: z.array(finding).optional(), + nsTypes: z.array(finding).optional(), + enumMembers: z.array(finding).optional(), + namespaceMembers: z.array(finding).optional(), + duplicates: z.array(z.array(finding)).optional(), +}); +const reportSchema = z.strictObject({ issues: z.array(issue) }); + +/** Admit the installed Knip JSON reporter's candidate records. */ +export const parseProductionReachabilityReport = (text) => + reportSchema.parse(JSON.parse(text)); + +/** Run production reachability without turning candidates into failures. */ +export const recordProductionReachabilityReport = async ( + root = process.cwd(), +) => { + const reportDirectory = join(root, ".cache", "static-checks"); + const reportPath = join(reportDirectory, "production-reachability.json"); + + // Findings are advisory. Execution, configuration and report-admission failures + // still fail, and must never leave an older successful report at this path. + await mkdir(reportDirectory, { recursive: true }); + await rm(reportPath, { force: true }); + const result = spawnSync( + process.execPath, + [ + join(dirname(require.resolve("knip")), "..", "bin", "knip.js"), + "--config", + "knip.production.json", + "--production", + "--reporter", + "json", + "--no-exit-code", + ], + { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, + ); + if (result.stderr) process.stderr.write(result.stderr); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + if (result.stdout) process.stderr.write(result.stdout); + throw new Error( + result.status === null + ? `Production reachability analysis terminated by ${result.signal}` + : `Production reachability analysis failed with status ${result.status}`, + ); + } + const report = parseProductionReachabilityReport(result.stdout); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log( + `Production reachability: ${report.issues.length} files with advisory candidates.`, + ); + console.log(`Full report: ${reportPath}`); + console.log( + "Candidates require runtime, verifier and published-contract review before removal; ordinary Knip remains separate.", + ); + return report; +}; + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + await recordProductionReachabilityReport(); diff --git a/scripts/ci/static-check-report.mjs b/scripts/ci/static-check-report.mjs index eecaf750a..6654f9a60 100644 --- a/scripts/ci/static-check-report.mjs +++ b/scripts/ci/static-check-report.mjs @@ -48,7 +48,12 @@ const duplicateReport = z }) .passthrough(); -const run = (packageName, entrypoint, arguments_) => { +const run = ( + packageName, + entrypoint, + arguments_, + environment = process.env, +) => { const executable = join( dirname(require.resolve(`${packageName}/package.json`)), entrypoint, @@ -56,6 +61,7 @@ const run = (packageName, entrypoint, arguments_) => { const result = spawnSync(process.execPath, [executable, ...arguments_], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, + env: environment, }); if (result.stderr) process.stderr.write(result.stderr); if (result.error !== undefined) throw result.error; @@ -66,14 +72,30 @@ const run = (packageName, entrypoint, arguments_) => { try { await mkdir(reportRoot, { recursive: true }); - if (mode === "lint") { - const reportPath = join(reportRoot, "oxlint.json"); + if (mode === "lint" || mode === "typed-lint") { + const typed = mode === "typed-lint"; + const reportPath = join( + reportRoot, + typed ? "typed-oxlint.json" : "oxlint.json", + ); await rm(reportPath, { force: true }); - const result = run("oxlint", "bin/oxlint", [ - ...extraArguments, - "--format", - "json", - ]); + const result = run( + "oxlint", + "bin/oxlint", + [ + ...(typed ? ["--type-aware", "--threads", "1"] : []), + ...extraArguments, + "--format", + "json", + ], + typed + ? { + ...process.env, + GOMAXPROCS: process.env.GOMAXPROCS ?? "2", + GOMEMLIMIT: process.env.GOMEMLIMIT ?? "1024MiB", + } + : process.env, + ); let report; try { report = lintReport.parse(JSON.parse(result.stdout)); @@ -89,7 +111,7 @@ try { ({ severity }) => severity === "error", ); console.log( - `Lint: ${report.number_of_files} files, ${errors.length} errors, ${warnings.length} warnings.`, + `${typed ? "Typed lint" : "Lint"}: ${report.number_of_files} files, ${errors.length} errors, ${warnings.length} warnings.`, ); for (const diagnostic of errors) { const location = diagnostic.labels[0]?.span; @@ -106,7 +128,9 @@ try { ) .slice(0, 8)) console.log(` ${rule}: ${count} warnings`); - console.log(`Full diagnostics: ${reportPath}; npm run lint:details`); + console.log( + `Full diagnostics: ${reportPath}${typed ? "" : "; npm run lint:details"}`, + ); process.exitCode = result.status; } else if (mode === "duplicates") { const output = join(reportRoot, "jscpd"); @@ -143,7 +167,7 @@ try { console.log(`Full diagnostics: ${reportPath}; npm run jscpd:details`); process.exitCode = result.status; } else { - throw new Error("Expected lint or duplicates"); + throw new Error("Expected lint, typed-lint or duplicates"); } } catch (cause) { console.error(cause instanceof Error ? cause.message : String(cause)); diff --git a/scripts/lint/index.mjs b/scripts/lint/index.mjs index c04cbd95c..db0365acf 100644 --- a/scripts/lint/index.mjs +++ b/scripts/lint/index.mjs @@ -1,10 +1,17 @@ import noBufferDeepEquality from "./no-buffer-deep-equality.mjs"; import cancellationCause from "./cancellation-cause.mjs"; +import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mjs"; +import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs"; +import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs"; + export default { meta: { name: "rea" }, rules: { "no-buffer-deep-equality": noBufferDeepEquality, "cancellation-cause": cancellationCause, + "no-evidence-json-preadmission": noEvidenceJsonPreadmission, + "no-fixture-root-cleanup": noFixtureRootCleanup, + "no-positional-tool-contract": noPositionalToolContract, }, }; diff --git a/scripts/lint/rules/import-source.d.mts b/scripts/lint/rules/import-source.d.mts new file mode 100644 index 000000000..e7d68389b --- /dev/null +++ b/scripts/lint/rules/import-source.d.mts @@ -0,0 +1,4 @@ +export declare const resolveImportSource: ( + filename: string, + specifier: unknown, +) => string | undefined; diff --git a/scripts/lint/rules/import-source.mjs b/scripts/lint/rules/import-source.mjs new file mode 100644 index 000000000..ec524cefc --- /dev/null +++ b/scripts/lint/rules/import-source.mjs @@ -0,0 +1,20 @@ +import { dirname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Resolve local import spellings without assigning local meaning to bare or HTTP imports. */ +export const resolveImportSource = (filename, specifier) => { + if (typeof specifier !== "string") return undefined; + if (isAbsolute(specifier)) return resolve(specifier); + if (specifier.startsWith("file:")) { + try { + const url = new URL(specifier); + if (url.search !== "" || url.hash !== "") return undefined; + return fileURLToPath(url); + } catch { + return undefined; + } + } + if (specifier.startsWith("./") || specifier.startsWith("../")) + return resolve(dirname(filename), specifier); + return undefined; +}; diff --git a/scripts/lint/rules/no-evidence-json-preadmission.d.mts b/scripts/lint/rules/no-evidence-json-preadmission.d.mts new file mode 100644 index 000000000..86d61729e --- /dev/null +++ b/scripts/lint/rules/no-evidence-json-preadmission.d.mts @@ -0,0 +1,4 @@ +import type { RuleTester } from "oxlint/plugins-dev"; + +declare const rule: Parameters[1]; +export default rule; diff --git a/scripts/lint/rules/no-evidence-json-preadmission.mjs b/scripts/lint/rules/no-evidence-json-preadmission.mjs new file mode 100644 index 000000000..d591d54ad --- /dev/null +++ b/scripts/lint/rules/no-evidence-json-preadmission.mjs @@ -0,0 +1,137 @@ +import { findBinding } from "../bindings.mjs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveImportSource } from "./import-source.mjs"; + +const domain = fileURLToPath(new URL("../../../src/domain/", import.meta.url)); +const payloadFields = new Set(["parameters", "result", "rawResult"]); + +const propertyName = (node) => { + if (!node.computed && node.property?.type === "Identifier") + return node.property.name; + if ( + node.property?.type === "Literal" && + typeof node.property.value === "string" + ) + return node.property.value; + return undefined; +}; + +const fieldName = (node) => { + if (node.type !== "Property") return undefined; + if (!node.computed && node.key.type === "Identifier") return node.key.name; + if (node.key.type === "Literal" && typeof node.key.value === "string") + return node.key.value; + return undefined; +}; + +const importedBinding = (sourceCode, node) => { + if (node.type !== "Identifier") return undefined; + const variable = findBinding(sourceCode, node); + if (!variable || variable.defs.length !== 1) return undefined; + const definition = variable.defs[0]; + return definition.type === "ImportBinding" ? definition : undefined; +}; + +const isImported = (context, node, module, names) => { + const member = node.type === "MemberExpression" && !node.optional; + const binding = importedBinding( + context.sourceCode, + member ? node.object : node, + ); + if (!binding || binding.parent?.type !== "ImportDeclaration") return false; + const declaration = binding.parent; + if (declaration.importKind === "type" || binding.node.importKind === "type") + return false; + const specifier = declaration.source.value; + const target = resolveImportSource( + context.physicalFilename ?? context.filename, + specifier, + ); + if ( + target !== resolve(domain, `${module}.js`) && + target !== resolve(domain, `${module}.ts`) + ) + return false; + if (member) + return ( + binding.node.type === "ImportNamespaceSpecifier" && + names.has(propertyName(node)) + ); + if (binding.node.type !== "ImportSpecifier") return false; + return names.has(binding.node.imported.name ?? binding.node.imported.value); +}; + +const evidenceCreator = new Set(["createEvidence"]); +const genericJsonSchemas = new Set(["jsonValueSchema", "jsonObjectSchema"]); + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Leave ordinary Evidence JSON payload admission with createEvidence.", + }, + schema: [], + messages: { + redundantJsonAdmission: + "Pass the {{field}} payload directly to createEvidence; it owns generic JSON admission. Keep semantic validation at its own boundary.", + }, + }, + create(context) { + return { + CallExpression(node) { + if ( + node.optional || + !isImported(context, node.callee, "evidence", evidenceCreator) + ) + return; + const observation = node.arguments[2]; + if (observation?.type !== "ObjectExpression") return; + for (const [index, property] of observation.properties.entries()) { + const field = fieldName(property); + if ( + !payloadFields.has(field) || + property.kind !== "init" || + property.method + ) + continue; + // A later spread or unknown key may replace this field before admission. + if ( + observation.properties + .slice(index + 1) + .some( + (later) => + later.type !== "Property" || + fieldName(later) === undefined || + fieldName(later) === field, + ) + ) + continue; + const parsed = property.value; + if ( + parsed.type !== "CallExpression" || + parsed.optional || + parsed.arguments.length !== 1 + ) + continue; + const parse = parsed.callee; + if ( + parse.type !== "MemberExpression" || + parse.optional || + propertyName(parse) !== "parse" + ) + continue; + if ( + isImported(context, parse.object, "jsonValue", genericJsonSchemas) + ) + context.report({ + node: parsed, + messageId: "redundantJsonAdmission", + data: { field }, + }); + } + }, + }; + }, +}; diff --git a/scripts/lint/rules/no-fixture-root-cleanup.d.mts b/scripts/lint/rules/no-fixture-root-cleanup.d.mts new file mode 100644 index 000000000..b5656592e --- /dev/null +++ b/scripts/lint/rules/no-fixture-root-cleanup.d.mts @@ -0,0 +1,3 @@ +import type { RuleTester } from "oxlint/plugins-dev"; +declare const rule: Parameters[1]; +export default rule; diff --git a/scripts/lint/rules/no-fixture-root-cleanup.mjs b/scripts/lint/rules/no-fixture-root-cleanup.mjs new file mode 100644 index 000000000..bf97ae130 --- /dev/null +++ b/scripts/lint/rules/no-fixture-root-cleanup.mjs @@ -0,0 +1,149 @@ +import { findBinding } from "../bindings.mjs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveImportSource } from "./import-source.mjs"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const fixtureModule = resolve(root, "tests/fixtures/temporaryDirectory"); +const workspaceModule = resolve( + root, + "tests/support/workspace/workspaceFixture", +); +const hooks = new Set(["afterEach", "afterAll", "onTestFinished"]); +const unwrap = (node) => { + while ( + node && + [ + "AwaitExpression", + "TSAsExpression", + "TSNonNullExpression", + "ChainExpression", + ].includes(node.type) + ) + node = node.argument ?? node.expression; + return node; +}; +const isFunction = (node) => + node && + [ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", + ].includes(node.type); + +export default { + meta: { + type: "problem", + docs: { + description: "Let createTestTempDirectory own removal of its root.", + }, + schema: [], + messages: { + duplicate: + "createTestTempDirectory already owns root cleanup; remove the duplicate teardown deletion, retaining cleanup of other resources.", + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const filename = context.physicalFilename ?? context.filename; + const calls = []; + const variable = (node) => findBinding(sourceCode, node); + const imported = (node) => { + const member = node?.type === "MemberExpression" ? node : undefined; + const binding = member ? member.object : node; + if (binding?.type !== "Identifier") return undefined; + const definition = variable(binding)?.defs[0]; + if ( + definition?.type !== "ImportBinding" || + (member + ? definition.node.type !== "ImportNamespaceSpecifier" + : definition.node.type !== "ImportSpecifier") + ) + return undefined; + const declaration = definition.parent ?? definition.node.parent; + const specifier = declaration.source?.value; + if (typeof specifier !== "string") return undefined; + return { + name: member + ? member.computed + ? member.property.value + : member.property.name + : (definition.node.imported.name ?? definition.node.imported.value), + module: + resolveImportSource(filename, specifier)?.replace( + /\.(?:[cm]?[jt]s)$/u, + "", + ) ?? specifier, + }; + }; + const fixtureRoot = (raw, seen = new Set()) => { + const node = unwrap(raw); + if (node?.type === "CallExpression") { + const identity = imported(node.callee); + return ( + identity?.module === fixtureModule && + identity.name === "createTestTempDirectory" + ); + } + if (node?.type !== "Identifier") return false; + const binding = variable(node); + if (!binding || seen.has(binding)) return false; + seen.add(binding); + const definition = binding.defs[0]; + // Mutable ownership and mixed root registries require flow analysis, not + // a guess based on the variable name or one assignment. + return ( + definition?.type === "Variable" && + definition.parent.kind === "const" && + definition.node.id.type === "Identifier" && + fixtureRoot(definition.node.init, seen) + ); + }; + const callback = (raw) => { + const node = unwrap(raw); + if (isFunction(node)) return node; + if (node?.type !== "Identifier") return undefined; + const definition = variable(node)?.defs[0]; + if (definition?.type === "FunctionName") return definition.node; + if ( + definition?.type === "Variable" && + definition.parent.kind === "const" + ) { + const value = unwrap(definition.node.init); + if (isFunction(value)) return value; + } + return undefined; + }; + return { + CallExpression(node) { + calls.push(node); + }, + "Program:exit"() { + const teardowns = new Set(); + for (const call of calls) { + const identity = imported(call.callee); + if (identity?.module === "vitest" && hooks.has(identity.name)) { + const body = callback(call.arguments[0]); + if (body) teardowns.add(body); + } + } + for (const call of calls) { + const identity = imported(call.callee); + if (!identity) continue; + const removesRoot = + (identity.module === "node:fs/promises" && + ["rm", "rmdir"].includes(identity.name)) || + (identity.module === "node:fs" && + ["rm", "rmSync", "rmdir", "rmdirSync"].includes(identity.name)) || + (identity.module === workspaceModule && + identity.name === "removeTestWorkspace"); + if (!removesRoot || !fixtureRoot(call.arguments[0])) continue; + let parent = call.parent; + while (parent && !isFunction(parent)) parent = parent.parent; + if (teardowns.has(parent)) + context.report({ node: call, messageId: "duplicate" }); + } + }, + }; + }, +}; diff --git a/scripts/lint/rules/no-positional-tool-contract.d.mts b/scripts/lint/rules/no-positional-tool-contract.d.mts new file mode 100644 index 000000000..b5656592e --- /dev/null +++ b/scripts/lint/rules/no-positional-tool-contract.d.mts @@ -0,0 +1,3 @@ +import type { RuleTester } from "oxlint/plugins-dev"; +declare const rule: Parameters[1]; +export default rule; diff --git a/scripts/lint/rules/no-positional-tool-contract.mjs b/scripts/lint/rules/no-positional-tool-contract.mjs new file mode 100644 index 000000000..9a8276db6 --- /dev/null +++ b/scripts/lint/rules/no-positional-tool-contract.mjs @@ -0,0 +1,218 @@ +import { findBinding } from "../bindings.mjs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { collectToolCatalogSources } from "./tool-catalog-sources.mjs"; +import { resolveImportSource } from "./import-source.mjs"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const catalogs = collectToolCatalogSources( + resolve(root, "src/contracts/toolContracts.ts"), +); + +const unwrap = (node) => { + while ( + node && + [ + "TSAsExpression", + "TSSatisfiesExpression", + "TSNonNullExpression", + "ChainExpression", + ].includes(node.type) + ) + node = node.expression; + return node; +}; +const numeric = (node) => + (node?.type === "Literal" && + (typeof node.value === "number" || + (typeof node.value === "string" && + /^(?:0|[1-9][0-9]*)$/u.test(node.value)))) || + (node?.type === "UnaryExpression" && + ["+", "-"].includes(node.operator) && + node.argument.type === "Literal" && + typeof node.argument.value === "number"); + +export default { + meta: { + type: "problem", + docs: { + description: + "Select canonical tool contracts by name, never catalog position.", + }, + schema: [], + messages: { + positional: + "Select a named tool contract; catalog ordering must not select an operation or schema.", + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const filename = context.physicalFilename ?? context.filename; + const variable = (node) => findBinding(sourceCode, node); + const imported = (node) => { + if (node?.type !== "Identifier") return undefined; + const definition = variable(node)?.defs[0]; + if (definition?.type !== "ImportBinding") return undefined; + const declaration = definition.parent ?? definition.node.parent; + const specifier = declaration.source?.value; + const path = resolveImportSource(filename, specifier)?.replace( + /\.(?:[cm]?[jt]s)$/u, + "", + ); + const expected = catalogs.get(path); + if (expected === undefined) return undefined; + return { definition, expected }; + }; + const isCatalog = (raw, seen = new Set()) => { + const node = unwrap(raw); + if (!node) return false; + if (node.type === "Identifier") { + const binding = variable(node); + if (!binding || seen.has(binding)) return false; + seen.add(binding); + const identity = imported(node); + if (identity) { + const specifier = identity.definition.node; + return ( + specifier.type === "ImportSpecifier" && + identity.expected.has( + specifier.imported.name ?? specifier.imported.value, + ) + ); + } + const definition = binding.defs[0]; + return ( + definition?.type === "Variable" && + definition.parent.kind === "const" && + definition.node.id.type === "Identifier" && + isCatalog(definition.node.init, seen) + ); + } + if (node.type === "MemberExpression" || node.type === "TSQualifiedName") { + const object = + node.type === "MemberExpression" ? node.object : node.left; + const identity = imported(object); + if (identity?.definition.node.type !== "ImportNamespaceSpecifier") + return false; + const property = + node.type === "MemberExpression" ? node.property : node.right; + const name = node.computed ? property.value : property.name; + return identity.expected.has(name); + } + return false; + }; + const selectedContract = (raw, seen = new Set()) => { + const node = unwrap(raw); + if (!node) return undefined; + if ( + node.type === "MemberExpression" && + node.computed && + numeric(node.property) && + isCatalog(node.object) + ) + return node; + if (node.type === "CallExpression") { + const callee = unwrap(node.callee); + if ( + callee?.type === "MemberExpression" && + (callee.computed ? callee.property.value : callee.property.name) === + "at" && + numeric(node.arguments[0]) && + isCatalog(callee.object) + ) + return node; + } + if (node.type !== "Identifier") return undefined; + const binding = variable(node); + if (!binding || seen.has(binding)) return undefined; + seen.add(binding); + const definition = binding.defs[0]; + if (definition?.type !== "Variable" || definition.parent.kind !== "const") + return undefined; + if (definition.node.id.type === "Identifier") + return selectedContract(definition.node.init, seen); + if ( + definition.node.id.type === "ArrayPattern" && + definition.node.id.elements.some( + (element) => + element?.type === "Identifier" && element.name === node.name, + ) && + isCatalog(definition.node.init) + ) + return definition.node; + return undefined; + }; + const bindingFields = new Set(["name", "inputSchema", "outputSchema"]); + const isRegistrationOptions = (node) => { + if (node?.type !== "Identifier") return false; + const definition = variable(node)?.defs[0]; + if ( + definition?.type !== "ImportBinding" || + definition.node.type !== "ImportSpecifier" + ) + return false; + const declaration = definition.parent ?? definition.node.parent; + const specifier = declaration.source?.value; + const path = resolveImportSource(filename, specifier)?.replace( + /\.(?:[cm]?[jt]s)$/u, + "", + ); + const name = + definition.node.imported.name ?? definition.node.imported.value; + return ( + path === resolve(root, "src/server/toolRegistrationOptions") && + [ + "toolRegistrationOptions", + "applicationToolRegistrationOptions", + ].includes(name) + ); + }; + const reported = new Set(); + const reportSelection = (raw) => { + const node = selectedContract(raw); + if (node && !reported.has(node)) { + reported.add(node); + context.report({ node, messageId: "positional" }); + } + }; + return { + MemberExpression(node) { + const name = node.computed ? node.property.value : node.property.name; + const parent = node.parent; + const writesProperty = + (parent?.type === "AssignmentExpression" && parent.left === node) || + parent?.type === "UpdateExpression" || + (parent?.type === "UnaryExpression" && parent.operator === "delete"); + if (bindingFields.has(name) && !writesProperty) + reportSelection(node.object); + }, + CallExpression(node) { + if (isRegistrationOptions(unwrap(node.callee))) + reportSelection(node.arguments[0]); + }, + VariableDeclarator(node) { + if ( + node.id.type === "ObjectPattern" && + node.id.properties.some( + (property) => + property.type === "Property" && + bindingFields.has( + property.computed ? property.key.value : property.key.name, + ), + ) + ) + reportSelection(node.init); + }, + TSIndexedAccessType(node) { + if ( + node.objectType.type === "TSTypeQuery" && + node.indexType.type === "TSLiteralType" && + numeric(node.indexType.literal) && + isCatalog(node.objectType.exprName) + ) + context.report({ node, messageId: "positional" }); + }, + }; + }, +}; diff --git a/scripts/lint/rules/tool-catalog-sources.d.mts b/scripts/lint/rules/tool-catalog-sources.d.mts new file mode 100644 index 000000000..6d45ede86 --- /dev/null +++ b/scripts/lint/rules/tool-catalog-sources.d.mts @@ -0,0 +1,4 @@ +export declare const collectToolCatalogSources: ( + entryPath: string, + readSource?: (path: string) => string, +) => ReadonlyMap>; diff --git a/scripts/lint/rules/tool-catalog-sources.mjs b/scripts/lint/rules/tool-catalog-sources.mjs new file mode 100644 index 000000000..da07422f8 --- /dev/null +++ b/scripts/lint/rules/tool-catalog-sources.mjs @@ -0,0 +1,118 @@ +import { parse } from "@babel/parser"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const withoutExtension = (path) => path.replace(/\.(?:[cm]?[jt]s)$/u, ""); +const arrayExpression = (raw) => { + let node = raw; + while ( + node && + ["TSAsExpression", "TSSatisfiesExpression"].includes(node.type) + ) + node = node.expression; + return node; +}; + +/** Read only source composition, never execute the runtime contract catalog. */ +export const collectToolCatalogSources = ( + entryPath, + readSource = (path) => readFileSync(path, "utf8"), +) => { + const catalogs = new Map(); + const modules = new Map(); + const active = new Set(); + const complete = new Set(); + const moduleFacts = (path) => { + let facts = modules.get(path); + if (facts) return facts; + // Read/parse errors intentionally escape: a broken inventory cannot disable + // the rule by presenting an empty set of catalog bindings. + const ast = parse(readSource(path), { + sourceType: "module", + plugins: ["typescript"], + }); + const imports = new Map(); + const exports = new Map(); + const locals = new Map(); + for (const statement of ast.program.body) { + if (statement.type === "ImportDeclaration") { + for (const specifier of statement.specifiers) { + if (specifier.type === "ImportSpecifier") + imports.set(specifier.local.name, { + source: statement.source.value, + name: specifier.imported.name ?? specifier.imported.value, + }); + } + } + const declarations = + statement.type === "ExportNamedDeclaration" + ? statement.declaration + : statement; + if (declarations?.type === "VariableDeclaration") + for (const declaration of declarations.declarations) + if (declaration.id.type === "Identifier") { + locals.set(declaration.id.name, declaration.init); + if (statement.type === "ExportNamedDeclaration") + exports.set(declaration.id.name, declaration.init); + } + } + facts = { imports, exports, locals }; + modules.set(path, facts); + return facts; + }; + const visit = (path, name) => { + const key = JSON.stringify([path, name]); + if (complete.has(key)) return; + if (active.has(key)) + throw new Error(`Cyclic tool catalog composition: ${path}#${name}`); + active.add(key); + const facts = moduleFacts(path); + const array = arrayExpression(facts.exports.get(name)); + if (array?.type !== "ArrayExpression") + throw new Error(`Expected exported tool catalog array: ${path}#${name}`); + const source = withoutExtension(path); + const names = catalogs.get(source) ?? new Set(); + names.add(name); + catalogs.set(source, names); + const composition = (raw, localPath = new Set()) => { + const expression = arrayExpression(raw); + if (expression?.type === "ArrayExpression") { + for (const element of expression.elements) + if (element?.type === "SpreadElement") + composition(element.argument, localPath); + return; + } + // Calls such as HOPPER_MEMORY_TOOL_DEFINITIONS.map(official) construct + // entries locally; they are not imported catalog arrays. Do not execute + // or infer catalog ownership from arbitrary computed expressions. + if (expression?.type !== "Identifier") return; + const binding = facts.imports.get(expression.name); + if (binding) { + if (!binding.source.startsWith(".")) + throw new Error(`Expected local catalog module: ${path}#${name}`); + visit( + `${withoutExtension(resolve(dirname(path), binding.source))}.ts`, + binding.name, + ); + return; + } + if (localPath.has(expression.name)) + throw new Error( + `Cyclic local catalog composition: ${path}#${expression.name}`, + ); + if (!facts.locals.has(expression.name)) + throw new Error( + `Unresolved local catalog spread: ${path}#${expression.name}`, + ); + composition( + facts.locals.get(expression.name), + new Set([...localPath, expression.name]), + ); + }; + composition(array); + active.delete(key); + complete.add(key); + }; + visit(entryPath, "TOOL_CONTRACTS"); + return catalogs; +}; diff --git a/src/application/ReconstructionObligationCandidates.semantic.test.ts b/src/application/ReconstructionObligationCandidates.semantic.test.ts index a56652033..9b11b56f5 100644 --- a/src/application/ReconstructionObligationCandidates.semantic.test.ts +++ b/src/application/ReconstructionObligationCandidates.semantic.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { createEvidence } from "../domain/evidence.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js"; import { artifactEvidence } from "../domain/javascript/javascriptApplicationGraph.fixture.js"; import { @@ -108,7 +107,7 @@ const semanticApplication = ( predicateType: example.predicate_type, operation: example.operation, parameters: example.parameters, - result: jsonValueSchema.parse({ ...base, semantic_graph: graph }), + result: { ...base, semantic_graph: graph }, confidence: "derived", authority: "shipped-artifact", }, diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index cc15e6b29..759a060e8 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -33,7 +33,6 @@ import { createJavaScriptApplicationGraph, createJavaScriptApplicationNode, } from "../domain/javascript/javascriptApplicationGraph.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; import { MANAGED_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js"; import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js"; @@ -90,7 +89,7 @@ const applicationWithUnknowns = ( predicateType: "rea.managed-application-graph", operation: "project_managed_application_graph", parameters: {}, - result: jsonValueSchema.parse({ + result: { projection_id: `magp_${"a".repeat(64)}`, root_artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, source_evidence: { @@ -113,7 +112,7 @@ const applicationWithUnknowns = ( coverage: { status: "complete-within-inputs" }, evidence_links: [source.evidence_id], limitations: [], - }), + }, confidence: "inferred", authority: "analyst-inference", evidenceLinks: [source.evidence_id], @@ -212,7 +211,7 @@ describe("reconstruction obligation candidates", () => { predicateType: "rea.process-capture", operation: "capture_process_scenario", parameters: {}, - result: jsonValueSchema.parse({ + result: { ...rest, manifest, exit: { @@ -224,7 +223,7 @@ describe("reconstruction obligation candidates", () => { ], }, }, - }), + }, }); const generated = deriveCandidates(createEvidenceBundle([evidence]), []); @@ -308,7 +307,7 @@ it("keeps each semantic candidate bound to its own file and authority context", predicateType: example.predicate_type, operation: example.operation, parameters: example.parameters, - result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }), + result: { ...base, semantic_graph: semanticGraph }, confidence: "derived", authority: "shipped-artifact", evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id), @@ -415,7 +414,7 @@ it("falls back to a kind-based title when a semantic node label is blank", () => predicateType: example.predicate_type, operation: example.operation, parameters: example.parameters, - result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }), + result: { ...base, semantic_graph: semanticGraph }, confidence: "derived", authority: "shipped-artifact", evidenceLinks: [source.evidence_id], diff --git a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts index 1d2ea42ae..9d7fe276f 100644 --- a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts +++ b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts @@ -9,7 +9,6 @@ import { compareArtifacts } from "../../domain/artifactComparison.js"; import { parseArtifactInventoryEvidence } from "../../domain/artifactInventoryEvidence.js"; import { projectAppleInventory } from "../../domain/apple/appleApplication.js"; import { createEvidence } from "../../domain/evidence.js"; -import { jsonValueSchema } from "../../domain/jsonValue.js"; import { thinMach } from "../../domain/binaryTarget.fixture.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; @@ -32,7 +31,7 @@ const observeArchive = async (order: readonly string[]) => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(inventory), + result: inventory, confidence: "observed", authority: "shipped-artifact", }, @@ -128,7 +127,7 @@ describe("content identity and occurrence facts", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(inventory), + result: inventory, confidence: "observed", authority: "shipped-artifact", }, diff --git a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts index 898a0995f..95bd2de05 100644 --- a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts @@ -19,7 +19,6 @@ import { import { readPyzArchive } from "./PyzArchive.js"; import { marshalCode } from "../../python/PythonPyc.fixture.js"; import { createEvidence } from "../../domain/evidence.js"; -import { jsonValueSchema } from "../../domain/jsonValue.js"; import { parseArtifactInventoryEvidence } from "../../domain/artifactInventoryEvidence.js"; import { ArtifactReaderFailure, @@ -365,7 +364,7 @@ it.each([true, false])( parameters: { page: index }, confidence: "observed", authority: "shipped-artifact", - result: jsonValueSchema.parse({ + result: { ...inventory, nodes: index === 0 ? inventory.nodes : [], edges: index === 0 ? inventory.edges : [], @@ -378,7 +377,7 @@ it.each([true, false])( row, ] : [row], - }), + }, }, ), ); diff --git a/src/browser/PlaywrightElectronActiveActions.ts b/src/browser/PlaywrightElectronActiveActions.ts index d06657e86..3826e6482 100644 --- a/src/browser/PlaywrightElectronActiveActions.ts +++ b/src/browser/PlaywrightElectronActiveActions.ts @@ -373,7 +373,6 @@ const emitDeepLink = ( ); /** Read the current Electron windows, metrics, and hook snapshot. */ -// oxlint-disable-next-line max-lines-per-function -- one state read must share one capture boundary. export const readApplicationState = async ( application: ElectronApplication, ): Promise<{ diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index ccb00c333..8a9685a88 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -10,7 +10,6 @@ import { AnalysisInputError } from "../analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { createEvidence, type Evidence } from "../evidence.js"; -import { jsonValueSchema } from "../jsonValue.js"; type EntryKind = "file" | "directory" | "symlink" | "slice"; type RootFormat = "ipa" | "zip" | "dmg" | "directory" | "asar"; @@ -136,7 +135,7 @@ const inventoryEvidence = ( parameters: {}, confidence: "observed", authority: "shipped-artifact", - result: jsonValueSchema.parse({ + result: { manifest: { manifest_id: `agm_${digestCanonicalValue( { root_artifact_id: rootId, graph_sha256: graphSha256 }, @@ -157,7 +156,7 @@ const inventoryEvidence = ( integrity_contradictions: [], coverage: { scope: "container-entries", status: "complete" }, limitations: [], - }), + }, }, ); }; diff --git a/src/domain/artifactInventoryEvidence.test.ts b/src/domain/artifactInventoryEvidence.test.ts index 9ab18f2d5..0a5508a87 100644 --- a/src/domain/artifactInventoryEvidence.test.ts +++ b/src/domain/artifactInventoryEvidence.test.ts @@ -14,7 +14,6 @@ import { parseArtifactInspectionInventoryResult, } from "./artifactInventoryEvidence.js"; import { createEvidence } from "./evidence.js"; -import { jsonValueSchema } from "./jsonValue.js"; const inventoryEvidence = ARTIFACT_COMPARISON_EXAMPLE.left; const target = inventoryEvidence.subject; @@ -31,7 +30,7 @@ const inspectionEvidence = (result: unknown, subject = target) => { operation: "inspect_artifact", parameters: {}, - result: jsonValueSchema.parse(result), + result, confidence: "observed", authority: "shipped-artifact", }, @@ -152,12 +151,12 @@ it("keeps a commitment failure discovered across individually incomplete pages a parameters: {}, confidence: "observed", authority: "shipped-artifact", - result: jsonValueSchema.parse({ + result: { ...complete, manifest, nodes: includeNodes ? complete.nodes : [], occurrences: includeNodes ? [] : complete.occurrences, - }), + }, }, ); const nodes = page(true); diff --git a/src/domain/changedBehavior.test.ts b/src/domain/changedBehavior.test.ts index 8686065a4..414262336 100644 --- a/src/domain/changedBehavior.test.ts +++ b/src/domain/changedBehavior.test.ts @@ -7,7 +7,7 @@ import { } from "./changedBehavior.js"; import { createEvidence, type Evidence } from "./evidence.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; -import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; +import type { JsonValue } from "./jsonValue.js"; const source = (digit: string): Evidence => createEvidence( @@ -350,13 +350,11 @@ describe("changed-behavior input and Evidence validation", () => { predicateType: base.predicate_type, operation: base.operation, parameters: base.parameters, - result: jsonValueSchema.parse( - functionComparisonResultSchema.parse({ - ...result, - dimensions, - changes: dimensions.filter(({ status }) => status !== "unchanged"), - }), - ), + result: functionComparisonResultSchema.parse({ + ...result, + dimensions, + changes: dimensions.filter(({ status }) => status !== "unchanged"), + }), confidence: base.confidence, authority: base.authority, evidenceLinks: base.evidence_links, diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index f61a6babd..6f2f767cd 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -664,7 +664,7 @@ describe("function comparison ordering controls", () => { parameters: enhancedInputSchemas.analyze_function.parse({ procedure: "dispatch", }), - result: jsonValueSchema.parse(result), + result, confidence: "derived", authority: "shipped-artifact", relationshipCoverage: { status: "complete" }, diff --git a/src/domain/staticRuntimeCorrelation.test.ts b/src/domain/staticRuntimeCorrelation.test.ts index 008149523..1fe58af42 100644 --- a/src/domain/staticRuntimeCorrelation.test.ts +++ b/src/domain/staticRuntimeCorrelation.test.ts @@ -35,11 +35,9 @@ const artifactComparison = createEvidence( left_evidence_ids: [ARTIFACT_COMPARISON_EXAMPLE.left.evidence_id], right_evidence_ids: [ARTIFACT_COMPARISON_EXAMPLE.right.evidence_id], }, - result: jsonValueSchema.parse( - compareArtifacts( - ARTIFACT_COMPARISON_EXAMPLE.left, - ARTIFACT_COMPARISON_EXAMPLE.right, - ), + result: compareArtifacts( + ARTIFACT_COMPARISON_EXAMPLE.left, + ARTIFACT_COMPARISON_EXAMPLE.right, ), confidence: "derived", authority: "analyst-inference", @@ -157,10 +155,10 @@ it("keeps text, assembly and unavailable CFG caveats on their selected correlati { operation: source.operation, parameters: source.parameters, - result: jsonValueSchema.parse({ + result: { ...functionDossierSchema.parse(source.normalized_result), basic_blocks: [{ start: "0x1000", end: "0x1000", successors: [] }], - }), + }, confidence: source.confidence, authority: source.authority, }, @@ -178,7 +176,7 @@ it("keeps text, assembly and unavailable CFG caveats on their selected correlati left_evidence_id: left.evidence_id, right_evidence_id: right.evidence_id, }, - result: jsonValueSchema.parse(computed), + result: computed, confidence: "derived", authority: "analyst-inference", limitations: computed.limitations, diff --git a/src/domain/webMcpDiscovery.test.ts b/src/domain/webMcpDiscovery.test.ts index 452038d6b..29c57973a 100644 --- a/src/domain/webMcpDiscovery.test.ts +++ b/src/domain/webMcpDiscovery.test.ts @@ -6,7 +6,6 @@ import { parseEvidenceBundle, serializeEvidenceBundle, } from "./evidenceBundle.js"; -import { jsonValueSchema } from "./jsonValue.js"; import { webMcpDiscoverySchema, type WebMcpDiscovery, @@ -106,7 +105,7 @@ describe("WebMCP discovery consequential annotation", () => { predicateType: "rea.webmcp-discovery", operation: "discover_webmcp_tools", parameters: {}, - result: jsonValueSchema.parse(result), + result, }); const bundle = createEvidenceBundle([evidence]); const reread = parseEvidenceBundle( diff --git a/tests/acceptance/applications/artifactComparisonCoverage.test.ts b/tests/acceptance/applications/artifactComparisonCoverage.test.ts index 94c0ceab1..b60d46da1 100644 --- a/tests/acceptance/applications/artifactComparisonCoverage.test.ts +++ b/tests/acceptance/applications/artifactComparisonCoverage.test.ts @@ -14,7 +14,7 @@ import { parseEvidence, type Evidence, } from "../../../src/domain/evidence.js"; -import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; + import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; @@ -75,7 +75,7 @@ const assertCoverageControls = async ({ { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(result), + result: result, confidence: "derived", authority: "shipped-artifact", }, diff --git a/tests/boundary/browser/cdpCleanupCancellation.test.ts b/tests/boundary/browser/cdpCleanupCancellation.test.ts index 1c31341d4..8c59661d8 100644 --- a/tests/boundary/browser/cdpCleanupCancellation.test.ts +++ b/tests/boundary/browser/cdpCleanupCancellation.test.ts @@ -1,4 +1,4 @@ -import { rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -145,7 +145,6 @@ it("closes an Electron inspection session when cleanup is cancelled", async () = }); onTestFinished(async () => { await browser.close(); - await rm(root, { recursive: true, force: true }); }); const controller = new AbortController(); const pending = new CdpElectronProvider().inspectPage( @@ -204,7 +203,6 @@ it("classifies cancellation that arrives during final Electron detach", async () }); onTestFinished(async () => { await browser.close(); - await rm(root, { recursive: true, force: true }); }); const result = await new CdpElectronProvider().inspectPage( @@ -283,7 +281,6 @@ it.each( closing.mockRestore(); await provider.close(); await browser.close(); - await rm(root, { recursive: true, force: true }); }); const result = provider instanceof CdpBrowserProvider diff --git a/tests/boundary/browser/cdpProviderCleanupOwnership.test.ts b/tests/boundary/browser/cdpProviderCleanupOwnership.test.ts index 9a3582bf5..edb801456 100644 --- a/tests/boundary/browser/cdpProviderCleanupOwnership.test.ts +++ b/tests/boundary/browser/cdpProviderCleanupOwnership.test.ts @@ -1,4 +1,4 @@ -import { rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -52,7 +52,6 @@ it.each([ | undefined; if (closeProvider !== undefined) await closeProvider.call(provider); await browser.close(); - await rm(root, { recursive: true, force: true }); }); const close = vi diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index 4b9b07321..e15fdbf4b 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -36,7 +36,7 @@ const observe = async (path: string) => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(inventory), + result: inventory, confidence: "observed", authority: "shipped-artifact", }, @@ -62,7 +62,7 @@ const inspectPages = ( { operation: "inspect_artifact", parameters: {}, - result: jsonValueSchema.parse({ + result: { inspection_id: `ai_${"f".repeat(64)}`, subject: { manifest_id: inventory.manifest.manifest_id, @@ -96,7 +96,7 @@ const inspectPages = ( }, evidence_links: pages.map(({ evidence_id }) => evidence_id), limitations: [], - }), + }, confidence: "observed", authority: "shipped-artifact", }, @@ -144,7 +144,7 @@ describe("leaf artifact comparison", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(withoutCoverage), + result: withoutCoverage, confidence: "observed", authority: "shipped-artifact", }, @@ -306,11 +306,11 @@ describe("artifact inventory coverage", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse({ + result: { ...graph, coverage: { scope: "root", status: "unknown" }, limitations: ["No extra child entries were observed."], - }), + }, confidence: "derived", authority: "shipped-artifact", }, @@ -348,10 +348,10 @@ describe("artifact inventory coverage", () => { { operation: "inventory_artifact", parameters: fragment.parameters, - result: jsonValueSchema.parse({ + result: { ...fragmentGraph, coverage: { ...fragmentGraph.coverage, status: "unknown" }, - }), + }, confidence: "derived", authority: "shipped-artifact", }, @@ -448,10 +448,10 @@ describe("artifact comparison", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse({ + result: { ...completeInventory, nodes: [], - }), + }, confidence: "observed", authority: "shipped-artifact", limitations: ["Inventory node observations are unavailable."], @@ -496,10 +496,10 @@ describe("artifact comparison completeness", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse({ + result: { ...inventory, coverage: { scope: "root", status: "complete" }, - }), + }, confidence: "derived", authority: "shipped-artifact", }, @@ -862,10 +862,10 @@ describe("artifact comparison fragment integrity", () => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse({ + result: { ...artifactInventoryResultSchema.parse(fragment.normalized_result), coverage: { scope: "root", status: "complete" }, - }), + }, }, ); expect(() => diff --git a/tests/boundary/filesystem/artifactComparisonSummary.test.ts b/tests/boundary/filesystem/artifactComparisonSummary.test.ts index 5042c916c..ef1e911a6 100644 --- a/tests/boundary/filesystem/artifactComparisonSummary.test.ts +++ b/tests/boundary/filesystem/artifactComparisonSummary.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { compareArtifacts } from "../../../src/domain/artifactComparison.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; + import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const observe = async ( @@ -30,7 +30,7 @@ const observe = async ( { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(inventory), + result: inventory, confidence: partial ? "derived" : "observed", authority: "shipped-artifact", }, diff --git a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts index 3d1d25210..258f1fe09 100644 --- a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts +++ b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts @@ -10,7 +10,7 @@ import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { compareArtifacts } from "../../../src/domain/artifactComparison.js"; import type { ArtifactInventoryResult } from "../../../src/domain/artifactGraph.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; + import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const observeZip = async (entries: readonly (readonly [string, string])[]) => { @@ -32,7 +32,7 @@ const observeZip = async (entries: readonly (readonly [string, string])[]) => { { operation: "inventory_artifact", parameters: {}, - result: jsonValueSchema.parse(inventory), + result: inventory, confidence: "observed", authority: "shipped-artifact", }, diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index ce3217f79..87b472c1f 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -1360,6 +1360,30 @@ it.each([ }, ); +it.each(["knip:production", "lint:typed"])( + "keeps the %s analysis command within static checks", + async (script) => { + const { directory } = await fixture(); + const base = await change( + directory, + ["package.json"], + JSON.stringify({ scripts: { [script]: "node scripts/old.mjs" } }), + ); + const head = await change( + directory, + ["package.json"], + JSON.stringify({ scripts: { [script]: "node scripts/new.mjs" } }), + ); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + runtime: false, + package: false, + evm: false, + browser: false, + }); + }, +); + it("retains a broad fallback when a provider script adds shared shell work", async () => { const { directory } = await fixture(); const before = { diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index 0c48a5ef0..3a442708b 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -14,7 +14,7 @@ import { import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; -import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; + import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { applicationVersionComparisonResultSchema, @@ -377,10 +377,10 @@ describe("Application graph Evidence envelopes", () => { predicateType: source.predicate_type, operation: source.operation, parameters: source.parameters, - result: jsonValueSchema.parse({ + result: { ...result, evidence_links: [...result.evidence_links, extraLink], - }), + }, rawResult: source.raw_result, confidence: source.confidence, authority: source.authority, @@ -479,7 +479,7 @@ describe("JavaScript application workflow error diagnostics", () => { predicateType: evidence.predicate_type, operation: evidence.operation, parameters: evidence.parameters, - result: jsonValueSchema.parse(malformedResult), + result: malformedResult, confidence: evidence.confidence, authority: evidence.authority, limitations: evidence.limitations, diff --git a/tests/boundary/filesystem/lintRuleConfiguration.test.ts b/tests/boundary/filesystem/lintRuleConfiguration.test.ts new file mode 100644 index 000000000..fb5dc363c --- /dev/null +++ b/tests/boundary/filesystem/lintRuleConfiguration.test.ts @@ -0,0 +1,270 @@ +import { parse } from "@babel/parser"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { writeRepositoryOxlintConfiguration } from "../../fixtures/oxlintConfiguration.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const oxlint = join(repository, "node_modules/oxlint/bin/oxlint"); +const applicationSource = "src/application/lintRuleAcceptance.ts"; +const testSource = "tests/boundary/lintRuleAcceptance.test.ts"; + +interface AcceptanceCase { + readonly name: string; + readonly filename: string; + readonly source: string; + readonly exitCode: number; + readonly diagnostics: readonly { + readonly code?: string; + readonly severity: "error" | "warning"; + readonly message?: string; + }[]; +} + +const cases: readonly AcceptanceCase[] = [ + { + name: "enables imported Evidence JSON admission errors through the repository plugin", + filename: applicationSource, + source: ` + import { createEvidence as record } from "../domain/evidence.js"; + import { jsonValueSchema as json } from "../domain/jsonValue.js"; + export const evidence = record(undefined, {}, { result: json.parse({ ready: true }) }); + `, + exitCode: 1, + diagnostics: [ + { code: "rea(no-evidence-json-preadmission)", severity: "error" }, + ], + }, + { + name: "recognizes canonical Evidence owners imported through file URLs", + filename: applicationSource, + source: ` + import { createEvidence as record } from ${JSON.stringify(pathToFileURL(join(repository, "src/domain/evidence.js")).href)}; + import { jsonValueSchema as json } from ${JSON.stringify(pathToFileURL(join(repository, "src/domain/jsonValue.js")).href)}; + export const evidence = record(undefined, {}, { result: json.parse({ ready: true }) }); + `, + exitCode: 1, + diagnostics: [ + { code: "rea(no-evidence-json-preadmission)", severity: "error" }, + ], + }, + { + name: "allows semantic schema admission and stored JSON contracts", + filename: applicationSource, + source: ` + import { z } from "zod"; + import { createEvidence } from "../domain/evidence.js"; + import { jsonValueSchema } from "../domain/jsonValue.js"; + const semanticSchema = z.object({ ready: z.boolean() }); + export const semantic = createEvidence(undefined, {}, { result: semanticSchema.parse({ ready: true }) }); + const result = jsonValueSchema.parse({ ready: true }); + export const stored = createEvidence(undefined, {}, { result }); + `, + exitCode: 0, + diagnostics: [], + }, + { + name: "enables positional catalog type errors through the repository plugin", + filename: applicationSource, + source: ` + import { GPU_TOOL_CONTRACTS as contracts } from "../contracts/gpu/gpuToolContracts.js"; + export type Selected = (typeof contracts)[0]; + `, + exitCode: 1, + diagnostics: [ + { code: "rea(no-positional-tool-contract)", severity: "error" }, + ], + }, + { + name: "enables duplicate fixture root teardown errors through the repository plugin", + filename: testSource, + source: ` + import { afterEach } from "vitest"; + import { rm } from "node:fs/promises"; + import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + const root = await createTestTempDirectory("rea-lint-rule-"); + afterEach(() => rm(root, { recursive: true, force: true })); + `, + exitCode: 1, + diagnostics: [{ code: "rea(no-fixture-root-cleanup)", severity: "error" }], + }, + { + name: "allows explicit root deletion outside fixture teardown", + filename: testSource, + source: ` + import { rm } from "node:fs/promises"; + import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + const root = await createTestTempDirectory("rea-lint-rule-"); + await rm(root, { recursive: true, force: true }); + `, + exitCode: 0, + diagnostics: [], + }, + { + name: "rejects an unreturned promise callback containing assertions", + filename: testSource, + source: ` + import { expect, it } from "vitest"; + it("unjoined assertion", () => { + Promise.resolve("ready").then(value => { expect(value).toBe("ready"); }); + }); + `, + exitCode: 1, + diagnostics: [ + { code: "vitest(valid-expect-in-promise)", severity: "error" }, + ], + }, + { + name: "rejects focused tests through the repository Vitest configuration", + filename: testSource, + source: ` + import { expect, it } from "vitest"; + it.only("focused assertion", () => { expect("ready").toBe("ready"); }); + `, + exitCode: 1, + diagnostics: [{ code: "vitest(no-focused-tests)", severity: "error" }], + }, + { + name: "keeps native stored assertion limitations at warning severity", + filename: testSource, + source: ` + import { expect, it } from "vitest"; + it("joins a stored assertion", async () => { + const assertion = expect(Promise.reject(new Error("controlled"))).rejects.toThrow("controlled"); + await assertion; + }); + `, + exitCode: 0, + diagnostics: [{ code: "vitest(valid-expect)", severity: "warning" }], + }, + { + name: "allows an assertion diagnostic message", + filename: testSource, + source: ` + import { expect, it } from "vitest"; + it("keeps diagnostic context", () => { + expect("ready", "controlled diagnostic context").toBe("ready"); + }); + `, + exitCode: 0, + diagnostics: [], + }, + { + name: "rejects unused suppression directives through the repository configuration", + filename: testSource, + source: ` + // oxlint-disable-next-line no-var + export const ready = true; + `, + exitCode: 1, + diagnostics: [ + { + severity: "error", + message: "Unused oxlint-disable directive (no problems were reported).", + }, + ], + }, +]; + +const reportSchema = z.object({ + diagnostics: z.array( + z.object({ + code: z.string().optional(), + severity: z.enum(["error", "warning"]), + message: z.string(), + }), + ), +}); + +const relocateImports = (scenario: AcceptanceCase, path: string): string => { + const source = scenario.source; + const parsed = parse(source, { + sourceType: "module", + plugins: ["typescript"], + }); + const replacements = []; + for (const statement of parsed.program.body) { + if ( + statement.type !== "ImportDeclaration" || + !statement.source.value.startsWith(".") + ) + continue; + const target = resolve( + repository, + dirname(scenario.filename), + statement.source.value, + ); + const relativePath = relative(dirname(path), target); + const specifier = isAbsolute(relativePath) + ? pathToFileURL(target).href + : relativePath.startsWith(".") + ? relativePath.split(sep).join("/") + : `./${relativePath.split(sep).join("/")}`; + const { start, end } = statement.source; + if ( + start === null || + start === undefined || + end === null || + end === undefined + ) + throw new Error("Authored lint fixture import has no source range"); + replacements.push({ + start, + end, + specifier, + }); + } + let relocated = source; + for (const replacement of replacements.reverse()) + relocated = + relocated.slice(0, replacement.start) + + JSON.stringify(replacement.specifier) + + relocated.slice(replacement.end); + return relocated; +}; + +for (const scenario of cases) { + processTest(scenario.name, async ({ processes }) => { + const project = await createTestTempDirectory("rea-lint-configuration-"); + const configuration = await writeRepositoryOxlintConfiguration(project); + const path = join(project, scenario.filename); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, relocateImports(scenario, path)); + const result = await processes.run( + process.execPath, + [ + oxlint, + "--config", + configuration, + "--threads", + "1", + "--format", + "json", + scenario.filename, + ], + { + cwd: project, + timeoutMs: 15_000, + }, + ); + expect(result.signal, result.stderr).toBeNull(); + expect(result.exitCode, result.stderr || result.stdout).toBe( + scenario.exitCode, + ); + const { diagnostics } = reportSchema.parse(JSON.parse(result.stdout)); + expect( + diagnostics.map(({ code, severity }) => ({ code, severity })), + ).toEqual( + scenario.diagnostics.map(({ code, severity }) => ({ code, severity })), + ); + for (const [index, expected] of scenario.diagnostics.entries()) { + if (expected.message !== undefined) + expect(diagnostics[index]?.message).toBe(expected.message); + } + }); +} diff --git a/tests/boundary/filesystem/productionReachabilityReport.test.ts b/tests/boundary/filesystem/productionReachabilityReport.test.ts new file mode 100644 index 000000000..b7c6c6a71 --- /dev/null +++ b/tests/boundary/filesystem/productionReachabilityReport.test.ts @@ -0,0 +1,117 @@ +import { spawnSync } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; + +import { parseProductionReachabilityReport } from "../../../scripts/ci/production-reachability-report.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const script = fileURLToPath( + new URL( + "../../../scripts/ci/production-reachability-report.mjs", + import.meta.url, + ), +); +const configuration = new URL("../../../knip.production.json", import.meta.url); +const reportPath = (root: string) => + join(root, ".cache/static-checks/production-reachability.json"); +const invoke = (root: string) => + spawnSync(process.execPath, [script], { + cwd: root, + encoding: "utf8", + timeout: 15_000, + }); + +it("reports test-only candidates without losing dynamic, worker or published roots", async () => { + const root = await createTestTempDirectory("rea-production-reachability-"); + const files = { + "package.json": JSON.stringify({ + name: "reachability-fixture", + type: "module", + bin: { fixture: "scripts/rea.mjs" }, + exports: { "./published": "./src/published.ts" }, + }), + "src/main.ts": + 'import { live } from "./facts.js"; console.log(live); void import("./lazy.js");', + "src/facts.ts": "export const live = 1; export const testOnly = 2;", + "src/lazy.ts": "console.log('dynamic production module');", + "src/testOnly.ts": "export const retired = 3;", + "src/published.ts": "export const publicContract = 4;", + "src/composition/javascriptAnalysisWorkerProcess.ts": + 'import { workerFact } from "../workerFacts.js"; console.log(workerFact);', + "src/workerFacts.ts": "export const workerFact = 5;", + "scripts/rea.mjs": 'import "../src/main.js";', + "tests/facts.test.ts": + 'import { testOnly } from "../src/facts.js"; import { retired } from "../src/testOnly.js"; console.log(testOnly, retired);', + }; + for (const [path, content] of Object.entries(files)) { + const destination = join(root, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, content); + } + await writeFile( + join(root, "knip.production.json"), + await readFile(configuration, "utf8"), + ); + + const result = invoke(root); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("advisory candidates"); + const { issues } = parseProductionReachabilityReport( + await readFile(reportPath(root), "utf8"), + ); + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "src/facts.ts", + exports: expect.arrayContaining([ + expect.objectContaining({ name: "testOnly" }), + ]), + }), + expect.objectContaining({ file: "src/testOnly.ts" }), + ]), + ); + const reached = [ + "src/main.ts", + "src/lazy.ts", + "src/published.ts", + "src/workerFacts.ts", + "src/composition/javascriptAnalysisWorkerProcess.ts", + "tests/facts.test.ts", + ]; + expect(issues.filter(({ file }) => reached.includes(file))).toEqual([]); + expect( + issues.flatMap(({ exports: findings = [] }) => + findings.map(({ name }) => name), + ), + ).not.toContain("live"); +}); + +it("fails on Knip configuration errors and removes a stale candidate report", async () => { + const root = await createTestTempDirectory( + "rea-production-reachability-error-", + ); + await writeFile(join(root, "package.json"), '{"name":"broken-config"}'); + await writeFile(join(root, "knip.production.json"), "{"); + await mkdir(dirname(reportPath(root)), { recursive: true }); + await writeFile(reportPath(root), '{"issues":[]}'); + + const result = invoke(root); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Production reachability analysis failed"); + await expect(access(reportPath(root))).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it.each([ + "not JSON", + '{"issues":[{"file":"src/facts.ts","exports":[{"name":42}]}]}', + '{"issues":[{"file":"src/facts.ts","unrecognized":[]}]}', +])("rejects malformed checker output: %s", (text) => { + expect(() => parseProductionReachabilityReport(text)).toThrow(); +}); diff --git a/tests/boundary/filesystem/staticCheckReports.test.ts b/tests/boundary/filesystem/staticCheckReports.test.ts index c133e21cf..b433dc9f5 100644 --- a/tests/boundary/filesystem/staticCheckReports.test.ts +++ b/tests/boundary/filesystem/staticCheckReports.test.ts @@ -1,10 +1,11 @@ import { spawnSync } from "node:child_process"; -import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; import { z } from "zod"; +import { writeRepositoryOxlintConfiguration } from "../../fixtures/oxlintConfiguration.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const script = fileURLToPath( @@ -149,11 +150,11 @@ it("keeps lint errors failing and visible alongside the retained warnings", asyn it("excludes translation staging while retaining authored website errors", async () => { const root = await fixture(); - for (const name of [".gitignore", ".oxlintrc.json"]) - await writeFile(join(root, name), await readFile(join(repository, name))); - await cp(join(repository, "scripts/lint"), join(root, "scripts/lint"), { - recursive: true, - }); + await writeFile( + join(root, ".gitignore"), + await readFile(join(repository, ".gitignore")), + ); + await writeRepositoryOxlintConfiguration(root); const authored = "website/public/assets/authored.js"; const staged = "website/.i18n-regression/site/examples/notes-electron/main.js"; @@ -166,7 +167,7 @@ it("excludes translation staging while retaining authored website errors", async ); } - const result = invoke(root, "lint", "website"); + const result = invoke(root, "lint"); expect(result.error).toBeUndefined(); expect(result.status).toBe(1); expect(result.stdout).toContain("1 errors, 0 warnings"); diff --git a/tests/boundary/filesystem/typedLintRules.test.ts b/tests/boundary/filesystem/typedLintRules.test.ts new file mode 100644 index 000000000..74694abb2 --- /dev/null +++ b/tests/boundary/filesystem/typedLintRules.test.ts @@ -0,0 +1,192 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeRepositoryOxlintConfiguration } from "../../fixtures/oxlintConfiguration.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const dependencies = fileURLToPath( + new URL("../../../node_modules", import.meta.url), +); +const reporter = fileURLToPath( + new URL("../../../scripts/ci/static-check-report.mjs", import.meta.url), +); +const sources = { + "src/unjoined.test.ts": ` +import { expect, it } from "vitest"; + +const cliTest = it.extend<{ label: string }>({ label: "owned" }); + +it("forgets the assertion promise", () => { + expect(Promise.resolve("ready")).resolves.toBe("ready"); +}); + +cliTest("forgets the assertion in an extended callback", ({ label }) => { + expect(Promise.resolve(label)).resolves.toBe("owned"); +}); +`, + "src/joined.test.ts": ` +import { expect, it } from "vitest"; + +const cliTest = it.extend<{ label: string }>({ label: "owned" }); + +it("joins a stored assertion promise", async () => { + const assertion = expect(Promise.resolve("ready")).resolves.toBe("ready"); + await assertion; +}); + +cliTest("joins a stored assertion in an extended callback", async ({ label }) => { + const assertion = expect(Promise.resolve(label)).resolves.toBe("owned"); + await assertion; +}); + +export async function finishBeforeCleanup(): Promise { + try { + return await Promise.resolve("ready"); + } finally { + await Promise.resolve(); + } +} +`, + "src/misused.ts": ` +export function promiseCondition(): string { + if (Promise.resolve(false)) return "ready"; + return "pending"; +} +`, + "src/nonexhaustive.ts": ` +type Outcome = { kind: "open" } | { kind: "closed" }; + +export function describeOutcome(outcome: Outcome): string { + switch (outcome.kind) { + case "open": return "open"; + default: return "unknown"; + } +} +`, + "src/unawaited-finally.ts": ` +export async function cleanupBeforeCompletion(): Promise { + try { + return Promise.resolve("ready"); + } finally { + await Promise.resolve(); + } +} +`, + "src/test-misused.test.ts": ` +import { it } from "vitest"; +it("keeps test-only promise conditions advisory", () => { + if (Promise.resolve(false)) throw new Error("test fixture"); +}); +`, + "tests/unjoined.test.ts": ` +import { expect, it } from "vitest"; +const cliTest = it.extend<{ label: string }>({ label: "owned" }); +it("warns about an unjoined boundary-test assertion", () => { + expect(Promise.resolve("ready")).resolves.toBe("ready"); +}); +cliTest("warns in an extended boundary-test callback", ({ label }) => { + expect(Promise.resolve(label)).resolves.toBe("owned"); +}); +`, +}; + +processTest( + "runs typed promise and exhaustiveness rules on an isolated real TypeScript project", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-typed-lint-"); + await symlink( + dependencies, + join(root, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + await Promise.all( + Object.keys(sources).map((name) => + mkdir(dirname(join(root, name)), { recursive: true }), + ), + ); + await writeRepositoryOxlintConfiguration(root); + await Promise.all([ + writeFile(join(root, "package.json"), '{"type":"module"}\n'), + writeFile( + join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + module: "NodeNext", + moduleResolution: "NodeNext", + target: "ES2023", + types: ["node"], + }, + files: Object.keys(sources), + }), + ), + ...Object.entries(sources).map(([name, source]) => + writeFile(join(root, name), source), + ), + ]); + + const result = await processes.run( + process.execPath, + [reporter, "typed-lint", ...Object.keys(sources)], + { + cwd: root, + timeoutMs: 20_000, + env: { ...process.env, GOMAXPROCS: "2", GOMEMLIMIT: "1GiB" }, + }, + ); + expect(result.signal, result.stderr).toBeNull(); + expect(result.exitCode, result.stderr || result.stdout).toBe(1); + const { diagnostics } = z + .object({ + diagnostics: z.array( + z.object({ + code: z.string(), + severity: z.enum(["error", "warning"]), + filename: z.string(), + }), + ), + }) + .parse( + JSON.parse( + await readFile( + join(root, ".cache/static-checks/typed-oxlint.json"), + "utf8", + ), + ), + ); + expect( + diagnostics + .filter(({ code }) => + [ + "typescript(no-floating-promises)", + "typescript(no-misused-promises)", + "typescript(return-await)", + "typescript(switch-exhaustiveness-check)", + ].includes(code), + ) + .map( + ({ code, filename, severity }) => + `${(isAbsolute(filename) ? relative(root, filename) : filename).split(sep).join("/")}: ${severity}: ${code}`, + ) + .sort(), + ).toEqual([ + "src/misused.ts: error: typescript(no-misused-promises)", + "src/nonexhaustive.ts: warning: typescript(switch-exhaustiveness-check)", + "src/test-misused.test.ts: warning: typescript(no-misused-promises)", + "src/unawaited-finally.ts: warning: typescript(return-await)", + "src/unjoined.test.ts: error: typescript(no-floating-promises)", + "src/unjoined.test.ts: error: typescript(no-floating-promises)", + "tests/unjoined.test.ts: warning: typescript(no-floating-promises)", + "tests/unjoined.test.ts: warning: typescript(no-floating-promises)", + ]); + expect( + diagnostics.filter(({ severity }) => severity === "error"), + ).toHaveLength(3); + }, +); diff --git a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts index e378d7a38..1caf6ee3d 100644 --- a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts +++ b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished, vi } from "vitest"; @@ -43,12 +43,11 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it("retains cancelled source read ownership until service close retries it", async () => { const root = await createTestTempDirectory("rea-web-artifact-read-owner-"); - onTestFinished(async () => { + onTestFinished(() => { injected.sourcePath = undefined; injected.abortController = undefined; injected.failClose = false; injected.closeAttempts = 0; - await rm(root, { recursive: true, force: true }); }); const source = 'import "./dep.js";'; const fixture = webModuleArtifactsFixture(source); diff --git a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts index 33be84d85..842c086e1 100644 --- a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts +++ b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts @@ -20,7 +20,7 @@ import { } from "../../../src/domain/evidence.js"; import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";