diff --git a/.changeset/typecheck-covers-the-test-layer.md b/.changeset/typecheck-covers-the-test-layer.md new file mode 100644 index 0000000000..896d4ea04b --- /dev/null +++ b/.changeset/typecheck-covers-the-test-layer.md @@ -0,0 +1,4 @@ +--- +--- + +ci(dx): the type-check coverage ratchet now judges the test layer too (#4311). #4324 counted a package covered the moment it declared a `typecheck` script — but 27 of the 48 it waved through `exclude` `**/*.test.ts` from the very `tsconfig.json` that `tsc --noEmit` reads, hiding 568 test files and 1451 errors behind a green check (`@objectstack/spec` alone: 272 files, 902 errors). A new `TESTS_COVERED` invariant fails any package that excludes its own tests without a measured `TEST_DEBT` entry, and the gate now prints both layers so the src figure can never again stand in for the whole. Nine packages that were already clean with tests included had the exclusion dropped outright; nine more graduate out of DEBT after repairing 44 NodeNext imports missing their `.js` extension — a repair that closed 110 errors (most of the "noise" tier was one unresolved import degrading symbols to `any`) and exposed 12 real defects in `service-settings` that those broken imports had been masking. Dev scripts, tests and CI only; releases nothing. diff --git a/AGENTS.md b/AGENTS.md index 115f6235ef..d47db92f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,19 @@ workspace package declares a `typecheck` script or carries a measured DEBT/EXEMP in `scripts/check-type-check-coverage.mjs`. New packages must arrive covered; a package that graduates deletes its ledger entry in the same PR. +**Do not `exclude` `*.test.ts` / `*.spec.ts` from a package's `tsconfig.json`.** `tsc +--noEmit` reads that config, so an exclusion there hides the tests from the check the +`typecheck` script advertises — a green gate over source nothing read, which is the +#4311 defect itself. The ratchet's `TESTS_COVERED` invariant fails on any new exclusion; +the packages that already had one carry a measured `TEST_DEBT` entry and graduate by +dropping the exclusion. + +One trap worth knowing before you read any of these counts: under `moduleResolution: +NodeNext` a relative import missing its `.js` extension does not resolve, every symbol it +names becomes `any`, and the callbacks over those symbols then report TS7006 "implicitly +any". A pile of TS7006 is usually one broken import upstream, not a package that needs +type annotations — fix the extension first and re-measure. + ### Running the dev server | Scenario | Command | Notes | diff --git a/packages/connectors/connector-slack/tsconfig.json b/packages/connectors/connector-slack/tsconfig.json index 385be7ea89..354835d7ce 100644 --- a/packages/connectors/connector-slack/tsconfig.json +++ b/packages/connectors/connector-slack/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "**/*.test.ts"] + "exclude": ["dist", "node_modules"] } diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 8cb7eeac16..5424485de9 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -7,6 +7,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit", "dev": "tsup --watch", "test": "vitest run" }, diff --git a/packages/create-objectstack/tsconfig.json b/packages/create-objectstack/tsconfig.json index 41b0c20ca0..cf6d50d407 100644 --- a/packages/create-objectstack/tsconfig.json +++ b/packages/create-objectstack/tsconfig.json @@ -19,5 +19,8 @@ }, "include": [ "src" + ], + "exclude": [ + "src/templates" ] } diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index f0986ed205..c260b3efe8 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -14,6 +14,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" }, "dependencies": { diff --git a/packages/plugins/plugin-approvals/tsconfig.json b/packages/plugins/plugin-approvals/tsconfig.json index 385be7ea89..d42eb175a2 100644 --- a/packages/plugins/plugin-approvals/tsconfig.json +++ b/packages/plugins/plugin-approvals/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "types": ["node"] + "types": ["node"], + "lib": ["ES2021"] }, "include": ["src/**/*"], "exclude": ["dist", "node_modules", "**/*.test.ts"] diff --git a/packages/plugins/plugin-audit/tsconfig.json b/packages/plugins/plugin-audit/tsconfig.json index f6a1e8bad5..caff4761a5 100644 --- a/packages/plugins/plugin-audit/tsconfig.json +++ b/packages/plugins/plugin-audit/tsconfig.json @@ -12,7 +12,6 @@ ], "exclude": [ "dist", - "node_modules", - "**/*.test.ts" + "node_modules" ] } diff --git a/packages/plugins/plugin-email/tsconfig.json b/packages/plugins/plugin-email/tsconfig.json index 385be7ea89..354835d7ce 100644 --- a/packages/plugins/plugin-email/tsconfig.json +++ b/packages/plugins/plugin-email/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "**/*.test.ts"] + "exclude": ["dist", "node_modules"] } diff --git a/packages/plugins/plugin-hono-server/tsconfig.json b/packages/plugins/plugin-hono-server/tsconfig.json index e1db4ba390..7d6fa817bf 100644 --- a/packages/plugins/plugin-hono-server/tsconfig.json +++ b/packages/plugins/plugin-hono-server/tsconfig.json @@ -18,7 +18,6 @@ ], "exclude": [ "node_modules", - "dist", - "**/*.test.ts" + "dist" ] } diff --git a/packages/plugins/plugin-pinyin-search/tsconfig.json b/packages/plugins/plugin-pinyin-search/tsconfig.json index 385be7ea89..354835d7ce 100644 --- a/packages/plugins/plugin-pinyin-search/tsconfig.json +++ b/packages/plugins/plugin-pinyin-search/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "**/*.test.ts"] + "exclude": ["dist", "node_modules"] } diff --git a/packages/plugins/plugin-reports/tsconfig.json b/packages/plugins/plugin-reports/tsconfig.json index 385be7ea89..354835d7ce 100644 --- a/packages/plugins/plugin-reports/tsconfig.json +++ b/packages/plugins/plugin-reports/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "**/*.test.ts"] + "exclude": ["dist", "node_modules"] } diff --git a/packages/sdui-parser/tsconfig.json b/packages/sdui-parser/tsconfig.json index 84f5d95f7e..9338828f6d 100644 --- a/packages/sdui-parser/tsconfig.json +++ b/packages/sdui-parser/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"], + "exclude": ["node_modules", "dist"], "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 80f201bbec..833eceaa66 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/services/service-cache/src/cache-service-plugin.metrics.test.ts b/packages/services/service-cache/src/cache-service-plugin.metrics.test.ts index 812bc3c2cb..4d52d7defe 100644 --- a/packages/services/service-cache/src/cache-service-plugin.metrics.test.ts +++ b/packages/services/service-cache/src/cache-service-plugin.metrics.test.ts @@ -6,7 +6,7 @@ import { OBSERVABILITY_METRICS_SERVICE, SEMCONV, } from '@objectstack/observability'; -import { CacheServicePlugin } from './cache-service-plugin'; +import { CacheServicePlugin } from './cache-service-plugin.js'; /** * Verifies the metrics resolution chain wired in PR #3: diff --git a/packages/services/service-cache/src/memory-cache-adapter.metrics.test.ts b/packages/services/service-cache/src/memory-cache-adapter.metrics.test.ts index 320bed059e..659c4043cc 100644 --- a/packages/services/service-cache/src/memory-cache-adapter.metrics.test.ts +++ b/packages/services/service-cache/src/memory-cache-adapter.metrics.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { InMemoryMetricsRegistry, SEMCONV } from '@objectstack/observability'; -import { MemoryCacheAdapter } from './memory-cache-adapter'; +import { MemoryCacheAdapter } from './memory-cache-adapter.js'; describe('MemoryCacheAdapter instrumentation', () => { it('emits cache_lookups_total{result=hit|miss} on get()', async () => { diff --git a/packages/services/service-cache/src/memory-cache-adapter.test.ts b/packages/services/service-cache/src/memory-cache-adapter.test.ts index b53d89795a..796dea8178 100644 --- a/packages/services/service-cache/src/memory-cache-adapter.test.ts +++ b/packages/services/service-cache/src/memory-cache-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { MemoryCacheAdapter } from './memory-cache-adapter'; +import { MemoryCacheAdapter } from './memory-cache-adapter.js'; import type { ICacheService } from '@objectstack/spec/contracts'; describe('MemoryCacheAdapter', () => { diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 963f4c66d1..2be7630e09 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/services/service-i18n/src/error-envelope.conformance.test.ts b/packages/services/service-i18n/src/error-envelope.conformance.test.ts index 6f4833f7a5..cdd7d7ddd5 100644 --- a/packages/services/service-i18n/src/error-envelope.conformance.test.ts +++ b/packages/services/service-i18n/src/error-envelope.conformance.test.ts @@ -33,7 +33,7 @@ import { describe, it, expect, vi } from 'vitest'; import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; -import { I18nServicePlugin } from './i18n-service-plugin'; +import { I18nServicePlugin } from './i18n-service-plugin.js'; const BASE = '/api/v1/i18n'; diff --git a/packages/services/service-i18n/src/file-i18n-adapter.test.ts b/packages/services/service-i18n/src/file-i18n-adapter.test.ts index 1025399fcc..f6823ef79a 100644 --- a/packages/services/service-i18n/src/file-i18n-adapter.test.ts +++ b/packages/services/service-i18n/src/file-i18n-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { FileI18nAdapter } from './file-i18n-adapter'; +import { FileI18nAdapter } from './file-i18n-adapter.js'; import type { II18nService } from '@objectstack/spec/contracts'; import * as fs from 'node:fs'; import * as path from 'node:path'; diff --git a/packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts b/packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts index fe81507ec3..60bbd1675c 100644 --- a/packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts +++ b/packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts @@ -21,8 +21,8 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { I18nServicePlugin } from './i18n-service-plugin'; -import { I18N_ROUTE_LEDGER } from './i18n-route-ledger'; +import { I18nServicePlugin } from './i18n-service-plugin.js'; +import { I18N_ROUTE_LEDGER } from './i18n-route-ledger.js'; /** Minimal IHttpServer mock that records registrations. */ function createMockServer() { diff --git a/packages/services/service-i18n/src/i18n-service-plugin.test.ts b/packages/services/service-i18n/src/i18n-service-plugin.test.ts index 2ee4012cc3..d6d2c08c0a 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.test.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { I18nServicePlugin } from './i18n-service-plugin'; +import { I18nServicePlugin } from './i18n-service-plugin.js'; import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; // --------------------------------------------------------------------------- diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 2839c660b8..40950a75b9 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/services/service-job/src/cron-job-adapter.test.ts b/packages/services/service-job/src/cron-job-adapter.test.ts index 187899b82a..c073efb9d4 100644 --- a/packages/services/service-job/src/cron-job-adapter.test.ts +++ b/packages/services/service-job/src/cron-job-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, afterEach } from 'vitest'; -import { CronJobAdapter } from './cron-job-adapter'; +import { CronJobAdapter } from './cron-job-adapter.js'; describe('CronJobAdapter', () => { let adapter: CronJobAdapter; diff --git a/packages/services/service-job/src/db-job-adapter.test.ts b/packages/services/service-job/src/db-job-adapter.test.ts index 9ef48eec50..2c11e19478 100644 --- a/packages/services/service-job/src/db-job-adapter.test.ts +++ b/packages/services/service-job/src/db-job-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { DbJobAdapter } from './db-job-adapter'; +import { DbJobAdapter } from './db-job-adapter.js'; function makeFakeEngine() { const tables = new Map(); diff --git a/packages/services/service-job/src/interval-job-adapter.test.ts b/packages/services/service-job/src/interval-job-adapter.test.ts index a935f49aae..919c91ff11 100644 --- a/packages/services/service-job/src/interval-job-adapter.test.ts +++ b/packages/services/service-job/src/interval-job-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, afterEach } from 'vitest'; -import { IntervalJobAdapter } from './interval-job-adapter'; +import { IntervalJobAdapter } from './interval-job-adapter.js'; import type { IJobService } from '@objectstack/spec/contracts'; describe('IntervalJobAdapter', () => { diff --git a/packages/services/service-job/src/job-service-plugin.test.ts b/packages/services/service-job/src/job-service-plugin.test.ts index 9a07d47c68..f44a1ea395 100644 --- a/packages/services/service-job/src/job-service-plugin.test.ts +++ b/packages/services/service-job/src/job-service-plugin.test.ts @@ -15,7 +15,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { JobServicePlugin } from './job-service-plugin'; +import { JobServicePlugin } from './job-service-plugin.js'; function makeFakeEngine() { const tables = new Map(); diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 23aaef503a..b83c8f6c8f 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts index 66c6be8495..91fc96fdd2 100644 --- a/packages/services/service-messaging/src/email-channel.test.ts +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -64,7 +64,7 @@ function fakeEmail() { }; } -function channel(getEmail: () => any, data: any, templates: any[] = []) { +function channel(getEmail: () => any, data: any) { const store = new NotificationTemplateStore({ getData: () => data }); return createEmailChannel({ getEmail, getData: () => data, store: store }); } diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index eefaaa909e..362acf1918 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/services/service-queue/src/db-queue-adapter.test.ts b/packages/services/service-queue/src/db-queue-adapter.test.ts index d5125e0987..7508698a05 100644 --- a/packages/services/service-queue/src/db-queue-adapter.test.ts +++ b/packages/services/service-queue/src/db-queue-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach } from 'vitest'; -import { DbQueueAdapter } from './db-queue-adapter'; +import { DbQueueAdapter } from './db-queue-adapter.js'; /** * In-memory engine that mimics objectql's `where:`-based find and diff --git a/packages/services/service-queue/src/memory-queue-adapter.test.ts b/packages/services/service-queue/src/memory-queue-adapter.test.ts index 714853bcc5..cbe5eb45a5 100644 --- a/packages/services/service-queue/src/memory-queue-adapter.test.ts +++ b/packages/services/service-queue/src/memory-queue-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { MemoryQueueAdapter } from './memory-queue-adapter'; +import { MemoryQueueAdapter } from './memory-queue-adapter.js'; import type { IQueueService, QueueMessage } from '@objectstack/spec/contracts'; describe('MemoryQueueAdapter', () => { diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index e5dfbb4a1b..c78d7af88c 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "tsup --config ../../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts b/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts index 3ba78375b8..20178ef790 100644 --- a/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts +++ b/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { InMemoryRealtimeAdapter, DEFAULT_MAX_SUBSCRIPTIONS } from './in-memory-realtime-adapter'; +import { InMemoryRealtimeAdapter, DEFAULT_MAX_SUBSCRIPTIONS } from './in-memory-realtime-adapter.js'; import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; describe('InMemoryRealtimeAdapter', () => { diff --git a/packages/services/service-settings/src/envelope.conformance.test.ts b/packages/services/service-settings/src/envelope.conformance.test.ts index 007a25fb5b..44ad37a3af 100644 --- a/packages/services/service-settings/src/envelope.conformance.test.ts +++ b/packages/services/service-settings/src/envelope.conformance.test.ts @@ -35,9 +35,9 @@ import { describe, expect, it, vi } from 'vitest'; import { ApiErrorSchema, BaseResponseSchema, FieldErrorSchema, envelopeViolations } from '@objectstack/spec/api'; import { SettingsNamespacePayloadSchema } from '@objectstack/spec/system'; import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; -import { SettingsService } from './settings-service'; -import { registerSettingsRoutes } from './settings-routes'; -import { brandingSettingsManifest } from './manifests/branding.manifest'; +import { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { brandingSettingsManifest } from './manifests/branding.manifest.js'; class MockHttp implements IHttpServer { routes = new Map(); diff --git a/packages/services/service-settings/src/local-crypto-provider.test.ts b/packages/services/service-settings/src/local-crypto-provider.test.ts index c766313d5c..da3ec70602 100644 --- a/packages/services/service-settings/src/local-crypto-provider.test.ts +++ b/packages/services/service-settings/src/local-crypto-provider.test.ts @@ -8,7 +8,7 @@ import { randomBytes } from 'node:crypto'; import { LocalCryptoProvider, InMemoryCryptoProvider, -} from './local-crypto-provider'; +} from './local-crypto-provider.js'; const ctx = { namespace: 'mail', key: 'api_key' }; diff --git a/packages/services/service-settings/src/manifests/ai.manifest.test.ts b/packages/services/service-settings/src/manifests/ai.manifest.test.ts index 2ae63f727f..44f612e42b 100644 --- a/packages/services/service-settings/src/manifests/ai.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/ai.manifest.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; -import { aiSettingsManifest, aiTestActionHandler, aiTestEmbedderActionHandler } from './ai.manifest'; +import { aiSettingsManifest, aiTestActionHandler, aiTestEmbedderActionHandler } from './ai.manifest.js'; describe('aiSettingsManifest', () => { it('parses against SettingsManifestSchema', () => { diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index b08e5bb603..32d0f70ab9 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; -import { authSettingsManifest } from './auth.manifest'; +import { authSettingsManifest } from './auth.manifest.js'; describe('authSettingsManifest', () => { it('parses against SettingsManifestSchema', () => { diff --git a/packages/services/service-settings/src/manifests/company.manifest.test.ts b/packages/services/service-settings/src/manifests/company.manifest.test.ts index 5887957a26..45b8704f98 100644 --- a/packages/services/service-settings/src/manifests/company.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/company.manifest.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; -import { companySettingsManifest } from './company.manifest'; +import { companySettingsManifest } from './company.manifest.js'; describe('companySettingsManifest', () => { it('parses against SettingsManifestSchema', () => { diff --git a/packages/services/service-settings/src/manifests/knowledge.manifest.test.ts b/packages/services/service-settings/src/manifests/knowledge.manifest.test.ts index d07f31c899..654be2503b 100644 --- a/packages/services/service-settings/src/manifests/knowledge.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/knowledge.manifest.test.ts @@ -5,7 +5,7 @@ import { SettingsManifestSchema } from '@objectstack/spec/system'; import { knowledgeSettingsManifest, knowledgeTestActionHandler, -} from './knowledge.manifest'; +} from './knowledge.manifest.js'; describe('knowledgeSettingsManifest', () => { it('parses against SettingsManifestSchema', () => { @@ -137,21 +137,21 @@ describe('knowledgeTestActionHandler', () => { describe('aiTestEmbedderActionHandler', () => { it('returns warning when embedder is disabled', async () => { - const handler = await import('./ai.manifest').then((m) => m.aiTestEmbedderActionHandler); + const handler = await import('./ai.manifest.js').then((m) => m.aiTestEmbedderActionHandler); const r = await handler({ values: { embedder_provider: 'none' } } as any); expect(r.ok).toBe(false); expect(r.severity).toBe('warning'); }); it('rejects OpenAI-compatible provider without api key', async () => { - const handler = await import('./ai.manifest').then((m) => m.aiTestEmbedderActionHandler); + const handler = await import('./ai.manifest.js').then((m) => m.aiTestEmbedderActionHandler); const r = await handler({ values: { embedder_provider: 'dashscope' } } as any); expect(r.ok).toBe(false); expect(r.severity).toBe('error'); }); it('rejects custom / azure without base URL', async () => { - const handler = await import('./ai.manifest').then((m) => m.aiTestEmbedderActionHandler); + const handler = await import('./ai.manifest.js').then((m) => m.aiTestEmbedderActionHandler); const r = await handler({ values: { embedder_provider: 'custom', embedder_api_key: 'k' }, } as any); @@ -160,7 +160,7 @@ describe('aiTestEmbedderActionHandler', () => { }); it('does not require api key for ollama', async () => { - const handler = await import('./ai.manifest').then((m) => m.aiTestEmbedderActionHandler); + const handler = await import('./ai.manifest.js').then((m) => m.aiTestEmbedderActionHandler); const r = await handler({ values: { embedder_provider: 'ollama', embedder_model: 'bge-m3' }, } as any); @@ -169,7 +169,7 @@ describe('aiTestEmbedderActionHandler', () => { }); it('accepts siliconflow with key + model', async () => { - const handler = await import('./ai.manifest').then((m) => m.aiTestEmbedderActionHandler); + const handler = await import('./ai.manifest.js').then((m) => m.aiTestEmbedderActionHandler); const r = await handler({ values: { embedder_provider: 'siliconflow', diff --git a/packages/services/service-settings/src/manifests/localization.manifest.test.ts b/packages/services/service-settings/src/manifests/localization.manifest.test.ts index b47814f1f0..bb0a038929 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; -import { localizationSettingsManifest } from './localization.manifest'; +import { localizationSettingsManifest } from './localization.manifest.js'; describe('localizationSettingsManifest', () => { it('parses against SettingsManifestSchema', () => { diff --git a/packages/services/service-settings/src/manifests/sms.manifest.test.ts b/packages/services/service-settings/src/manifests/sms.manifest.test.ts index d85e52d878..d7691a7125 100644 --- a/packages/services/service-settings/src/manifests/sms.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/sms.manifest.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; -import { smsSettingsManifest, smsTestActionHandler } from './sms.manifest'; +import { smsSettingsManifest, smsTestActionHandler } from './sms.manifest.js'; describe('sms settings manifest', () => { it('parses against SettingsManifestSchema', () => { diff --git a/packages/services/service-settings/src/manifests/storage.manifest.test.ts b/packages/services/service-settings/src/manifests/storage.manifest.test.ts index 7cfbf49cc6..23ee1602a5 100644 --- a/packages/services/service-settings/src/manifests/storage.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/storage.manifest.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; -import { storageSettingsManifest, storageTestActionHandler } from './storage.manifest'; +import { storageSettingsManifest, storageTestActionHandler } from './storage.manifest.js'; describe('storageSettingsManifest', () => { it('parses against SettingsManifestSchema', () => { diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index 169e309a2a..1c821dc5de 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; -import { SettingsService } from './settings-service'; -import { registerSettingsRoutes } from './settings-routes'; -import { brandingSettingsManifest } from './manifests/branding.manifest'; +import { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { brandingSettingsManifest } from './manifests/branding.manifest.js'; class MockHttp implements IHttpServer { routes = new Map(); diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index a8de884e82..a054fe2b12 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -1,13 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, expect, it } from 'vitest'; -import { SettingsService } from './settings-service'; -import { SettingsLockedError, UnknownKeyError, UnknownNamespaceError, envKeyOf } from './settings-service.types'; -import { NoopCryptoAdapter } from './crypto-adapter'; -import { mailSettingsManifest, mailTestActionHandler } from './manifests/mail.manifest'; -import { aiSettingsManifest } from './manifests/ai.manifest'; -import { brandingSettingsManifest } from './manifests/branding.manifest'; -import { featureFlagsSettingsManifest } from './manifests/feature-flags.manifest'; +import { SettingsService } from './settings-service.js'; +import { SettingsLockedError, UnknownKeyError, UnknownNamespaceError, envKeyOf } from './settings-service.types.js'; +import { NoopCryptoAdapter } from './crypto-adapter.js'; +import { mailSettingsManifest, mailTestActionHandler } from './manifests/mail.manifest.js'; +import { aiSettingsManifest } from './manifests/ai.manifest.js'; +import { brandingSettingsManifest } from './manifests/branding.manifest.js'; +import { featureFlagsSettingsManifest } from './manifests/feature-flags.manifest.js'; import { SettingsManifestSchema } from '@objectstack/spec/system'; describe('reference manifests are spec-valid', () => { diff --git a/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts b/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts index 9aaecd8ddb..d5a7f20188 100644 --- a/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts +++ b/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts @@ -18,8 +18,8 @@ */ import { describe, it, expect } from 'vitest'; -import * as manifestsModule from '../manifests/index'; -import { zhCN, jaJP, esES } from './index'; +import * as manifestsModule from '../manifests/index.js'; +import { zhCN, jaJP, esES } from './index.js'; type Specifier = { type?: string; id?: string; key?: string; label?: string; description?: string }; type Manifest = { namespace: string; description?: string; specifiers: Specifier[] }; diff --git a/packages/services/service-settings/src/visibility-eval.test.ts b/packages/services/service-settings/src/visibility-eval.test.ts index 3706d250c1..cd2714c84f 100644 --- a/packages/services/service-settings/src/visibility-eval.test.ts +++ b/packages/services/service-settings/src/visibility-eval.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, expect, it } from 'vitest'; -import { evaluateVisibility, referencedKeys, VisibilityParseError } from './visibility-eval'; +import { evaluateVisibility, referencedKeys, VisibilityParseError } from './visibility-eval.js'; describe('evaluateVisibility', () => { it('handles every expression shape used by the bundled manifests', () => { diff --git a/packages/triggers/trigger-api/tsconfig.json b/packages/triggers/trigger-api/tsconfig.json index f6a1e8bad5..caff4761a5 100644 --- a/packages/triggers/trigger-api/tsconfig.json +++ b/packages/triggers/trigger-api/tsconfig.json @@ -12,7 +12,6 @@ ], "exclude": [ "dist", - "node_modules", - "**/*.test.ts" + "node_modules" ] } diff --git a/packages/triggers/trigger-schedule/tsconfig.json b/packages/triggers/trigger-schedule/tsconfig.json index f6a1e8bad5..caff4761a5 100644 --- a/packages/triggers/trigger-schedule/tsconfig.json +++ b/packages/triggers/trigger-schedule/tsconfig.json @@ -12,7 +12,6 @@ ], "exclude": [ "dist", - "node_modules", - "**/*.test.ts" + "node_modules" ] } diff --git a/packages/types/package.json b/packages/types/package.json index 5209a778f6..0a1056cab5 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -14,6 +14,7 @@ }, "scripts": { "build": "tsup --config ../../tsup.config.ts", + "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 9338828f6d..260259c521 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -4,6 +4,7 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] } } diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 535b084221..80e2e958eb 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -26,6 +26,15 @@ // must arrive covered -- the ledger is closed to new debt. // REAL a declared `typecheck` script actually invokes tsc. A script // that echoes, lints, or runs tests is not type coverage. +// TESTS_COVERED +// a package whose tsconfig `exclude`s its own `*.test.ts` / +// `*.spec.ts` carries a measured TEST_DEBT entry. `tsc --noEmit` +// reads the package tsconfig, so an exclusion there hides the +// tests from the very check the `typecheck` script advertises -- +// COVERED and REAL both pass while nothing reads the files +// #4311 is actually about. This invariant is why the ledger is +// two ledgers: DEBT is "src does not check", TEST_DEBT is "src +// checks, tests are hidden", and they are independent. // RUNNABLE turbo.json declares the `typecheck` task, the root `typecheck` // script aggregates it (`turbo run typecheck`, the build/test // convention), and lint.yml invokes it -- a script CI never @@ -46,6 +55,13 @@ // `"typecheck": "tsc --noEmit"` to its package.json, and delete its entry // here in the same PR. Deleting the entry without the script fails COVERED; // keeping the entry alongside the script fails RECONCILED. +// +// TEST_DEBT is the same discipline for the second hole. The first pass of this +// gate (#4324) counted a package covered the moment it declared `typecheck` -- +// and 27 of the 48 it waved through exclude `**/*.test.ts` from the tsconfig +// that very script runs against, hiding 568 test files and 1451 errors behind +// a green check. `spec` alone hid 902 across 272 test files. To onboard: drop +// the exclusion from tsconfig.json and delete the entry here in the same PR. import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix, resolve } from 'node:path'; @@ -56,10 +72,15 @@ const ROOT = resolve(import.meta.dirname, '..'); const SELF = 'scripts/check-type-check-coverage.mjs'; const WORKSPACE_FILE = 'pnpm-workspace.yaml'; const TRACKING_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/4311'; +// An `exclude` pattern that names tests (`**/*.test.ts`, `**/*.spec.tsx`, ...) +// and the files such a pattern hides. Kept deliberately broad: the question is +// "does this config steer tsc away from the test layer", not "which exact glob". +const TEST_GLOB = /\*\.(test|spec)\.tsx?$/; +const TEST_FILE = /\.(test|spec)\.tsx?$/; // Package name -> { errors, note? }. `errors` is the raw `tsc --noEmit` count -// measured per package on main @ e5a4d26 (2026-07-31, the #4311 audit re-run -// one commit later; aggregates matched exactly: 48 clean / 29 failing). +// measured per package on main @ b07d829 (2026-07-31), re-measured after the +// NodeNext repair below. // Raw counts include all three of #4311's tiers -- code-tier (real defects), // config-tier (the check itself misconfigured: TS2591/TS2584 missing // `types:["node"]`, TS2835/TS2307 module resolution) and noise (TS7006 @@ -67,6 +88,16 @@ const TRACKING_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/431 // made of. Nobody should mistake a config-tier pile for real breakage, or -- // worse -- the reverse: `core` at 91 raw has 3 real errors, while // `driver-sql`'s 241 are ALL real. +// +// The tiers are not independent, which is why these numbers get re-measured +// rather than decremented. Under `moduleResolution: NodeNext` a relative +// import without its `.js` extension does not resolve, so every symbol it +// names degrades to `any` -- and each callback over one of those symbols then +// reports TS7006 "implicitly any". Repairing 44 such imports in this PR closed +// 110 errors across eight packages, most of them "noise" that was never noise +// at all, and simultaneously EXPOSED 12 real defects in `service-settings` +// that the unresolved imports had been masking. A config-tier count is an +// upper bound on nothing: fix the config first, then read the residue. const DEBT = { '@objectstack/cloud-connection': { errors: 13, @@ -105,17 +136,13 @@ const DEBT = { note: 'code-tier 31 (TS2345/TS2353); the rest is config-tier (TS2835) and noise (TS7006).', }, '@objectstack/metadata-protocol': { - errors: 21, - note: 'code-tier 2; the rest is config-tier (TS2835) and noise (TS7006).', + errors: 28, + note: 'code-tier 9 (was read as 2 at 21 raw); the rest is config-tier (TS2835) and noise (TS7006).', }, '@objectstack/observability': { errors: 11, note: 'all code-tier (TS2554 wrong arity x10, TS2552).', }, - '@objectstack/plugin-approvals': { - errors: 1, - note: 'config-tier only (TS2550 lib); no code-tier finding in #4311.', - }, '@objectstack/rest': { errors: 2, note: 'code-tier 2 (TS2345).', @@ -132,41 +159,17 @@ const DEBT = { errors: 2, note: 'code-tier 2 (TS2741: engine.test.ts misses resumeAuthority, the #4198 discovery that opened #4311).', }, - '@objectstack/service-cache': { - errors: 3, - note: 'config-tier only (TS2835); no code-tier finding in #4311.', - }, '@objectstack/service-cluster': { errors: 1, note: 'code-tier 1 (TS2322).', }, - '@objectstack/service-i18n': { - errors: 14, - note: 'config-tier (TS2835) and noise (TS7006) only; no code-tier finding in #4311.', - }, - '@objectstack/service-job': { - errors: 6, - note: 'config-tier (TS2835) and noise only; no code-tier finding in #4311.', - }, '@objectstack/service-knowledge': { errors: 8, note: 'code-tier 3 (TS2339/TS2352/TS2493); the rest config-tier and noise.', }, - '@objectstack/service-messaging': { - errors: 1, - note: 'noise only (TS6133); no code-tier finding in #4311.', - }, - '@objectstack/service-queue': { - errors: 9, - note: 'config-tier (TS2835) and noise (TS7006) only; no code-tier finding in #4311.', - }, - '@objectstack/service-realtime': { - errors: 9, - note: 'config-tier (TS2835) and noise (TS7006) only; no code-tier finding in #4311.', - }, '@objectstack/service-settings': { - errors: 44, - note: 'config-tier (TS2307/TS2835 module resolution) and noise (TS7006) only; no code-tier finding in #4311.', + errors: 13, + note: 'code-tier 12 (TS2345 x7: manifest action handlers called without `namespace`/`actionId`; TS2322) + 1 noise. Was ledgered at 44 with "no code-tier finding" -- wrong in both directions: 31 of those 44 were unresolved imports (see the NodeNext note at the top of this ledger), and the resolution they were blocking is what made the 12 real ones visible.', }, '@objectstack/service-storage': { errors: 42, @@ -176,14 +179,6 @@ const DEBT = { errors: 50, note: 'the workspace root itself: code-tier 2 (TS2304); the rest is config-tier (TS2307/TS2591/TS2584 -- the root tsconfig has no `types:["node"]`) and noise.', }, - '@objectstack/types': { - errors: 107, - note: 'config-tier only (TS2591/TS2584: missing `types:["node"]`/lib in the check config); no code-tier finding in #4311.', - }, - 'create-objectstack': { - errors: 5, - note: 'config-tier only (TS2307 module resolution of template files); no code-tier finding in #4311.', - }, }; // Package name -> why running tsc over it is not applicable at all. An EXEMPT @@ -194,6 +189,46 @@ const EXEMPT = { 'Published objectui build artifact -- package.json/README/CHANGELOG plus a dist/ pulled in by `pnpm objectui:refresh`. No TypeScript sources, no tsconfig; the sources are type-checked in the objectui repo.', }; +// Package name -> { tests, errors, note? } for packages whose tsconfig excludes +// their own test files. `tests` is the number of hidden `*.test.ts`/`*.spec.ts` +// files, `errors` what `tsc --noEmit` reports once the exclusion is lifted -- +// both measured by re-running each package's own config with the test globs +// dropped. These packages are NOT uncovered: their src type-checks and most +// declare `typecheck`. What they hide is the test layer, which is where #4311 +// found the defects (a passing vitest run proves the code executes, not that +// the call shapes match). Sorted by what each is hiding, worst first. +const TEST_DEBT = { + '@objectstack/spec': { + tests: 272, + errors: 902, + note: 'TS6133 x208, TS2739 x193, TS2741 x146, TS2322 x96 -- overwhelmingly incomplete object literals in test fixtures against the schemas spec itself defines.', + }, + '@objectstack/plugin-approvals': { + tests: 13, + errors: 467, + note: 'TS2339 x255, TS2345 x188. Larger than driver-sql; src is clean, so the whole pile is test-only and invisible to every gate today.', + }, + '@objectstack/runtime': { tests: 66, errors: 220, note: 'TS18048 x81 (possibly-undefined), TS2345 x26, TS6133 x25. Also in DEBT: its src does not check either.' }, + '@objectstack/objectql': { tests: 87, errors: 219, note: 'TS2339 x88, TS2554 x28 (wrong arity), TS7006 x25.' }, + '@objectstack/plugin-auth': { tests: 26, errors: 124, note: 'TS2493 x40 (tuple index out of range), TS18048 x24, TS2740 x18.' }, + '@objectstack/rest': { tests: 35, errors: 105, note: 'TS2835 x43 (NodeNext extensions), TS7006 x42. Also in DEBT.' }, + '@objectstack/mcp': { tests: 8, errors: 52, note: 'TS18046 x51 -- `error` is of type unknown, one catch-block idiom repeated.' }, + '@objectstack/driver-mongodb': { tests: 7, errors: 44, note: 'TS2345 x22, TS2591 x15 (`process` -- the test files need types:["node"] once included).' }, + '@objectstack/lint': { tests: 39, errors: 26, note: 'TS7006 x20, TS2835 x6.' }, + '@objectstack/plugin-security': { tests: 32, errors: 20, note: 'TS2739 x8, TS2740 x5 -- incomplete literals.' }, + '@objectstack/client': { tests: 15, errors: 19, note: 'TS6059 x5 (rootDir), TS2740 x5, TS6133 x3.' }, + '@objectstack/formula': { tests: 13, errors: 12, note: 'TS2345 x3, TS2352 x3, TS2591 x3.' }, + '@objectstack/trigger-record-change': { tests: 4, errors: 8, note: 'TS2353 x8 -- one unknown-property shape repeated.' }, + '@objectstack/verify': { tests: 2, errors: 6, note: 'TS7006 x4, TS2835 x2.' }, + '@objectstack/connector-mcp': { tests: 3, errors: 5, note: 'TS2339 x5.' }, + '@objectstack/connector-openapi': { tests: 3, errors: 5, note: 'TS2339 x5.' }, + '@objectstack/platform-objects': { tests: 8, errors: 3, note: 'TS2339 x2, TS7006 x1.' }, + '@objectstack/plugin-sharing': { tests: 11, errors: 3, note: 'TS6133 x2, TS18048 x1.' }, + '@objectstack/http-conformance': { tests: 2, errors: 1, note: 'TS2740 x1.' }, + '@objectstack/service-sms': { tests: 3, errors: 1, note: 'TS2493 x1.' }, + '@objectstack/connector-rest': { tests: 3, errors: 1, note: 'TS6133 x1.' }, +}; + /** * The `packages:` globs from pnpm-workspace.yaml. Blank lines and comments are * skipped rather than treated as the end of the list: stopping early would @@ -218,7 +253,67 @@ function workspaceGlobs() { return globs; } -/** Every workspace member as { name, dir, scripts, hasTsconfig }. */ +/** + * Does this package's tsconfig `exclude` its own test files, and how many are + * there to hide? Read with a tolerant parse -- these configs carry `//` + * comments, and a parse failure must not silently read as "excludes nothing" + * (that would turn TESTS_COVERED into a gate that passes on unparseable input). + * + * @returns {{excludesTests: boolean, testFiles: number}} + */ +function testCoverage(dir) { + const tsconfigPath = join(ROOT, dir, 'tsconfig.json'); + let excludesTests = false; + let parsedInclude = null; + if (existsSync(tsconfigPath)) { + const raw = readFileSync(tsconfigPath, 'utf8').replace(/^\s*\/\/.*$/gm, ''); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (cause) { + throw new Error(`${dir}/tsconfig.json is not parseable, so its test coverage cannot be judged`, { cause }); + } + excludesTests = (parsed.exclude ?? []).some((pattern) => TEST_GLOB.test(pattern)); + parsedInclude = parsed.include ?? null; + } + + // Count only under the `include` roots: `exclude` subtracts from what + // `include` selected, so a test file outside those roots is already out of + // the program and is not what this exclusion hides. Several packages keep a + // sibling `test/` tree that their `include` never mentions -- counting it + // here would attribute files to an exclusion that has no bearing on them. + // Roots come from the glob prefix (`src/**/*` -> `src`); no `include` at all + // means tsc walks the whole package directory. + const roots = Array.isArray(parsedInclude) && parsedInclude.length > 0 + ? [...new Set(parsedInclude.map((g) => g.split('*')[0].replace(/\/$/, '')).filter((p) => !p.includes('..')))] + : ['']; + + let testFiles = 0; + const seen = new Set(); + const walk = (abs, depth) => { + let entries = []; + try { + entries = readdirSync(abs, { withFileTypes: true }); + } catch { + return; // no such root, or unreadable -- nothing to hide either way + } + for (const entry of entries) { + const child = join(abs, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue; + if (depth > 0 && existsSync(join(child, 'package.json'))) continue; // another package's problem + walk(child, depth + 1); + } else if (TEST_FILE.test(entry.name) && !seen.has(child)) { + seen.add(child); // overlapping include roots must not double-count + testFiles++; + } + } + }; + for (const root of roots) walk(join(ROOT, dir, root), 0); + return { excludesTests, testFiles }; +} + +/** Every workspace member as { name, dir, scripts, hasTsconfig, excludesTests, testFiles }. */ function workspacePackages() { const dirs = []; for (const glob of workspaceGlobs()) { @@ -243,6 +338,7 @@ function workspacePackages() { dir, scripts: manifest.scripts ?? {}, hasTsconfig: existsSync(join(ROOT, dir, 'tsconfig.json')), + ...testCoverage(dir), }; }); } @@ -252,10 +348,12 @@ function workspacePackages() { * self-test both go through here, so the semantics the fixtures prove are the * semantics the gate applies. * - * @param {Array<{name: string, dir: string, scripts: Record, hasTsconfig: boolean}>} packages + * @param {Array<{name: string, dir: string, scripts: Record, hasTsconfig: boolean, + * excludesTests?: boolean, testFiles?: number}>} packages * @param {{name: string, scripts: Record}} root * @param {{ debt: Record, * exempt: Record, + * testDebt: Record, * turboHasTask: boolean, ciInvokesTask: boolean, ciInvokesRoot: boolean }} state * @returns {string[]} problems, empty when the ratchet holds */ @@ -269,6 +367,34 @@ function evaluate(packages, root, state) { const inDebt = Object.hasOwn(state.debt, pkg.name); const inExempt = Object.hasOwn(state.exempt, pkg.name); + // TESTS_COVERED, and its RECONCILED counterpart. Independent of whether the + // package is covered or in DEBT: this is about what the tsconfig hides, not + // about whether a script exists to run against it. + const inTestDebt = Object.hasOwn(state.testDebt, pkg.name); + if (pkg.excludesTests && pkg.testFiles > 0) { + if (!inTestDebt) { + problems.push( + `${pkg.name} (${pkg.dir}): tsconfig.json excludes its own test files, hiding ${pkg.testFiles} of them ` + + `from \`tsc --noEmit\` -- the check reports green over source it never read (${TRACKING_ISSUE}). ` + + `Drop the \`*.test.ts\`/\`*.spec.ts\` entry from \`exclude\`, or measure what surfaces and add a ` + + `TEST_DEBT entry in ${SELF}.`, + ); + } else { + const entry = state.testDebt[pkg.name]; + if (!entry || typeof entry.errors !== 'number' || entry.errors <= 0) { + problems.push( + `${pkg.name}: TEST_DEBT entry has no measured error count -- lift the exclusion, run ` + + `\`tsc --noEmit\`, record the number, or drop the exclusion for good.`, + ); + } + } + } else if (inTestDebt) { + problems.push( + `${pkg.name}: has a TEST_DEBT entry but ${pkg.testFiles === 0 ? 'has no test files' : 'no longer excludes its tests'} -- ` + + `it graduated; delete its entry from TEST_DEBT in ${SELF}.`, + ); + } + if (script !== undefined) { // REAL: the script must put tsc in front of the package's sources. if (!/\btsc\b/.test(script)) { @@ -367,6 +493,11 @@ function evaluate(packages, root, state) { problems.push(`EXEMPT entry for "${name}" names no workspace package -- remove it from ${SELF}.`); } } + for (const name of Object.keys(state.testDebt)) { + if (!byName.has(name)) { + problems.push(`TEST_DEBT entry for "${name}" names no workspace package -- remove it from ${SELF}.`); + } + } // RUNNABLE: coverage that nothing executes is not coverage. if (!state.turboHasTask) { @@ -401,6 +532,7 @@ function observed() { state: { debt: DEBT, exempt: EXEMPT, + testDebt: TEST_DEBT, turboHasTask: Object.hasOwn(turbo.tasks ?? {}, 'typecheck'), ciInvokesTask: /turbo run typecheck/.test(lintYml), ciInvokesRoot: /typecheck:root/.test(lintYml), @@ -420,7 +552,7 @@ function selfTest() { name: 'root', scripts: { typecheck: 'turbo run typecheck', 'typecheck:root': 'tsc --noEmit' }, }; - const okState = { debt: {}, exempt: {}, turboHasTask: true, ciInvokesTask: true, ciInvokesRoot: true }; + const okState = { debt: {}, exempt: {}, testDebt: {}, turboHasTask: true, ciInvokesTask: true, ciInvokesRoot: true }; const cases = [ { label: 'covered package passes', @@ -429,6 +561,51 @@ function selfTest() { state: okState, expect: [], }, + { + label: 'a covered package that excludes its tests fails TESTS_COVERED', + packages: [pkg('a', { scripts: { typecheck: 'tsc --noEmit' }, excludesTests: true, testFiles: 12 })], + root: okRoot, + state: okState, + expect: [/excludes its own test files, hiding 12 of them/], + }, + { + label: 'a test-debt entry covers the exclusion, but an empty measurement fails', + packages: [ + pkg('a', { scripts: { typecheck: 'tsc --noEmit' }, excludesTests: true, testFiles: 3 }), + pkg('b', { scripts: { typecheck: 'tsc --noEmit' }, excludesTests: true, testFiles: 4 }), + ], + root: okRoot, + state: { ...okState, testDebt: { a: { tests: 3, errors: 9 }, b: { tests: 4, errors: 0 } } }, + expect: [/b: TEST_DEBT entry has no measured error count/], + }, + { + label: 'excluding tests when there are none to hide is not debt', + packages: [pkg('a', { scripts: { typecheck: 'tsc --noEmit' }, excludesTests: true, testFiles: 0 })], + root: okRoot, + state: okState, + expect: [], + }, + { + label: 'dropping the exclusion without deleting TEST_DEBT fails RECONCILED', + packages: [pkg('a', { scripts: { typecheck: 'tsc --noEmit' }, excludesTests: false, testFiles: 5 })], + root: okRoot, + state: { ...okState, testDebt: { a: { tests: 5, errors: 7 } } }, + expect: [/a: has a TEST_DEBT entry but no longer excludes its tests/], + }, + { + label: 'TEST_DEBT for a vanished package fails RECONCILED', + packages: [pkg('a', { scripts: { typecheck: 'tsc --noEmit' } })], + root: okRoot, + state: { ...okState, testDebt: { gone: { tests: 1, errors: 1 } } }, + expect: [/TEST_DEBT entry for "gone" names no workspace package/], + }, + { + label: 'test exclusion is judged independently of src coverage', + packages: [pkg('a', { excludesTests: true, testFiles: 6 })], + root: okRoot, + state: { ...okState, debt: { a: { errors: 4 } } }, + expect: [/a \(packages\/a\): tsconfig.json excludes its own test files/], + }, { label: 'uncovered, unledgered package fails COVERED', packages: [pkg('a')], @@ -540,8 +717,14 @@ if (problems.length) { const covered = packages.filter((p) => p.scripts.typecheck !== undefined).length; const debtTotal = Object.values(DEBT).reduce((sum, e) => sum + (e.errors ?? 0), 0); +const testDebtErrors = Object.values(TEST_DEBT).reduce((sum, e) => sum + (e.errors ?? 0), 0); +const testDebtFiles = Object.values(TEST_DEBT).reduce((sum, e) => sum + (e.tests ?? 0), 0); +// Both numbers, always. Reporting only the src figure is how the first pass of +// this gate read as 48/77 green while 568 test files went unchecked. console.log( `check-type-check-coverage: OK — ${covered}/${packages.length} workspace packages type-checked ` + `(plus the root), ${Object.keys(DEBT).length} in the DEBT ledger (${debtTotal} frozen raw errors, ` + - `${TRACKING_ISSUE}), ${Object.keys(EXEMPT).length} exempt.`, + `${TRACKING_ISSUE}), ${Object.keys(EXEMPT).length} exempt.\n` + + ` test layer: ${Object.keys(TEST_DEBT).length} package(s) still exclude their own tests ` + + `(${testDebtFiles} files, ${testDebtErrors} frozen raw errors in TEST_DEBT).`, );