diff --git a/packages/rest/test-typecheck-debt.json b/packages/rest/test-typecheck-debt.json index 5a2960808a..077f4270a9 100644 --- a/packages/rest/test-typecheck-debt.json +++ b/packages/rest/test-typecheck-debt.json @@ -1,7 +1,9 @@ { - "_comment": "Per-file tsc error debt of the @objectstack/rest TEST layer (#5286). `tsconfig.test.json` compiles `src/**/*.test.ts` — which `tsconfig.json` excludes and therefore no gate ever read — and every file below still carries errors from before that gate existed. THIS FIELD IS GENERATED: every regeneration rewrites it from scripts/check-test-typecheck.mts, and the EXACT ratchet below requires a regeneration on every repair — so an edit made here is gone by the next one. Anything true of THIS package goes in the sibling `_note` field, which is authored, is preserved verbatim, and is never written by the generator (#12624). This comment states NO cause for the errors, deliberately: the classes differ per package and per file, they move as the debt is paid down, and a cause written here is rewritten verbatim into every ledger by every regeneration — so it outlives its own repair and cannot be corrected in the file where it is read. Measure instead, before repairing anything: `tsc --noEmit --pretty false -p tsconfig.test.json` in the package prints the real classes with their TS codes. EXACT ratchet, judged by re-running tsc: a file that gains errors is red, a file that loses them is red until its number is re-recorded, a file that reaches zero is red until its entry is deleted, and a file NOT listed here may have no errors at all. Regenerate with: pnpm --filter @objectstack/rest gen:test-typecheck-debt", - "_note": "Everything still recorded here is held by its own card, and none of it is an annotation repair. #13454 holds the one entry that remains: src/rest.test.ts's two hand-built IHttpResponse literals, which omit the send and header members that interface requires and whose status is not typed as returning the interface, so res.status(...).json(...) does not chain. WARNING, and the reason this note exists: the count did NOT move when #13377 landed, but the errors underneath it were replaced wholesale. This file was recorded at 2 before that card and measures 2 after, and neither of the two is the same error. tsc reports at most ONE argument-assignability error per call expression, so the request literals #13377 removed had been masking these response literals at the very same two call sites. Read that as the warning an EXACT ledger cannot give you itself: a per-file count measures a QUANTITY, never an identity, and a constant number is not evidence that nothing changed. The request literals #13377 held are gone from this package: the five members IHttpRequest requires are stated once, in src/http-request-test-builder.ts, and a request built there takes its method and its path from the route under test instead of from a default, so the two cannot disagree. The exceljs call that #13378 held is likewise no longer here: that dependency declares its own module-local Buffer, which shadows Node's inside every exceljs signature, so no Node Buffer can be passed to Workbook.xlsx.load - the assertion that costs is stated once, in src/xlsx-test-loader.ts, and every xlsx-reading test in this package goes through it.", + "_comment": "Per-file tsc error debt of the @objectstack/rest TEST layer (#5286). `tsconfig.test.json` compiles `src/**/*.test.ts` — which `tsconfig.json` excludes and therefore no gate ever read — and every file below still carries errors from before that gate existed. THIS FIELD IS GENERATED: every regeneration rewrites it from scripts/check-test-typecheck.mts, and the EXACT ratchet below requires a regeneration on every repair — so an edit made here is gone by the next one. Anything true of THIS package goes in the sibling `_note` field, which is authored, is preserved verbatim, and is never written by the generator (#12624). This comment states NO cause for the errors, deliberately: the classes differ per package and per file, they move as the debt is paid down, and a cause written here is rewritten verbatim into every ledger by every regeneration — so it outlives its own repair and cannot be corrected in the file where it is read. Measure instead, before repairing anything: `tsc --noEmit --pretty false -p tsconfig.test.json` in the package prints the real classes with their TS codes. Each entry maps a file to its per-SIGNATURE error counts, never to a bare total (#13470): a signature is the TS code plus the diagnostic message with structural type blobs collapsed, and it carries NO line or column — so the pin survives edits that move code around, and only stops matching when the error itself becomes a different error. EXACT ratchet, judged by re-running tsc: a file that gains errors is red, a file that loses them is red until its number is re-recorded, a file that reaches zero is red until its entry is deleted, a signature that ARRIVES or VANISHES is red even when the file total is unchanged, and a file NOT listed here may have no errors at all. Regenerate with: pnpm --filter @objectstack/rest gen:test-typecheck-debt", + "_note": "Everything still recorded here is held by its own card, and none of it is an annotation repair. #13454 holds the one entry that remains: src/rest.test.ts's two hand-built IHttpResponse literals, which omit the send and header members that interface requires and whose status is not typed as returning the interface, so res.status(...).json(...) does not chain. WARNING, and the reason this note exists: the count did NOT move when #13377 landed, but the errors underneath it were replaced wholesale. This file was recorded at 2 before that card and measures 2 after, and neither of the two is the same error. tsc reports at most ONE argument-assignability error per call expression, so the request literals #13377 removed had been masking these response literals at the very same two call sites. That blindness is now CLOSED (#13470): an entry is no longer one integer but a map of normalized error SIGNATURE to count, so this file pins WHICH errors it carries and not merely how many, and the very substitution described above now reds with the signature that ARRIVED and the one that VANISHED both named. The history is kept because the mechanism is not rest-specific and has not gone away: tsc still reports at most one argument error per call, so a repair here can still uncover a different error at the same site. What changed is that the signature keys below - never their sum - are what say so. The request literals #13377 held are gone from this package: the five members IHttpRequest requires are stated once, in src/http-request-test-builder.ts, and a request built there takes its method and its path from the route under test instead of from a default, so the two cannot disagree. The exceljs call that #13378 held is likewise no longer here: that dependency declares its own module-local Buffer, which shadows Node's inside every exceljs signature, so no Node Buffer can be passed to Workbook.xlsx.load - the assertion that costs is stated once, in src/xlsx-test-loader.ts, and every xlsx-reading test in this package goes through it.", "entries": { - "src/rest.test.ts": 2 + "src/rest.test.ts": { + "TS2345: Argument of type '…' is not assignable to parameter of type 'IHttpResponse'.": 2 + } } } diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 99d45e3072..3d7e8b931c 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -1,59 +1,259 @@ { - "_comment": "Per-file tsc error debt of the @objectstack/spec TEST layer (#5286). `tsconfig.test.json` compiles `src/**/*.test.ts` — which `tsconfig.json` excludes and therefore no gate ever read — and every file below still carries errors from before that gate existed. THIS FIELD IS GENERATED: every regeneration rewrites it from scripts/check-test-typecheck.mts, and the EXACT ratchet below requires a regeneration on every repair — so an edit made here is gone by the next one. Anything true of THIS package goes in the sibling `_note` field, which is authored, is preserved verbatim, and is never written by the generator (#12624). This comment states NO cause for the errors, deliberately: the classes differ per package and per file, they move as the debt is paid down, and a cause written here is rewritten verbatim into every ledger by every regeneration — so it outlives its own repair and cannot be corrected in the file where it is read. Measure instead, before repairing anything: `tsc --noEmit --pretty false -p tsconfig.test.json` in the package prints the real classes with their TS codes. EXACT ratchet, judged by re-running tsc: a file that gains errors is red, a file that loses them is red until its number is re-recorded, a file that reaches zero is red until its entry is deleted, and a file NOT listed here may have no errors at all. Regenerate with: pnpm --filter @objectstack/spec gen:test-typecheck-debt", + "_comment": "Per-file tsc error debt of the @objectstack/spec TEST layer (#5286). `tsconfig.test.json` compiles `src/**/*.test.ts` — which `tsconfig.json` excludes and therefore no gate ever read — and every file below still carries errors from before that gate existed. THIS FIELD IS GENERATED: every regeneration rewrites it from scripts/check-test-typecheck.mts, and the EXACT ratchet below requires a regeneration on every repair — so an edit made here is gone by the next one. Anything true of THIS package goes in the sibling `_note` field, which is authored, is preserved verbatim, and is never written by the generator (#12624). This comment states NO cause for the errors, deliberately: the classes differ per package and per file, they move as the debt is paid down, and a cause written here is rewritten verbatim into every ledger by every regeneration — so it outlives its own repair and cannot be corrected in the file where it is read. Measure instead, before repairing anything: `tsc --noEmit --pretty false -p tsconfig.test.json` in the package prints the real classes with their TS codes. Each entry maps a file to its per-SIGNATURE error counts, never to a bare total (#13470): a signature is the TS code plus the diagnostic message with structural type blobs collapsed, and it carries NO line or column — so the pin survives edits that move code around, and only stops matching when the error itself becomes a different error. EXACT ratchet, judged by re-running tsc: a file that gains errors is red, a file that loses them is red until its number is re-recorded, a file that reaches zero is red until its entry is deleted, a signature that ARRIVES or VANISHES is red even when the file total is unchanged, and a file NOT listed here may have no errors at all. Regenerate with: pnpm --filter @objectstack/spec gen:test-typecheck-debt", "entries": { - "src/ai/conversation.test.ts": 5, - "src/api/documentation.test.ts": 1, - "src/api/errors.test.ts": 1, - "src/api/odata.test.ts": 1, - "src/api/package-api.test.ts": 4, - "src/api/rest-server.test.ts": 2, - "src/api/router.test.ts": 1, - "src/automation/control-flow.test.ts": 5, - "src/automation/schemaless-node-config.test.ts": 1, - "src/compose-stacks-key-loss.test.ts": 7, - "src/compose-stacks.test.ts": 8, - "src/contracts/ai-service.test.ts": 1, - "src/contracts/analytics-service.test.ts": 1, - "src/contracts/automation-service.test.ts": 1, - "src/contracts/core-service-contracts.test.ts": 15, - "src/contracts/data-engine.test.ts": 1, - "src/contracts/http-server.test.ts": 3, - "src/contracts/logger.test.ts": 2, - "src/contracts/metadata-service.test.ts": 2, - "src/contracts/package-service.test.ts": 2, - "src/contracts/plugin-lifecycle-events.test.ts": 2, - "src/contracts/service-registry.test.ts": 7, - "src/contracts/storage-service.test.ts": 1, - "src/data/data-engine.test.ts": 6, - "src/data/display-name.test.ts": 18, - "src/data/driver-nosql.test.ts": 2, - "src/data/driver-sql.test.ts": 1, - "src/data/driver.test.ts": 11, - "src/data/field.test.ts": 2, - "src/data/object-strictness-batch20.test.ts": 1, - "src/data/query.test.ts": 25, - "src/identity/scim.test.ts": 7, - "src/integration/connector.test.ts": 7, - "src/kernel/cluster.test.ts": 2, - "src/kernel/metadata-plugin.test.ts": 1, - "src/kernel/public-auth-features.test.ts": 6, - "src/shared/metadata-collection.test.ts": 1, - "src/stack.test.ts": 33, - "src/system/collaboration.test.ts": 2, - "src/system/deploy-bundle.test.ts": 2, - "src/system/disaster-recovery.test.ts": 3, - "src/system/i18n-resolver.test.ts": 11, - "src/system/logging.test.ts": 1, - "src/system/metrics.test.ts": 1, - "src/system/object-storage.test.ts": 14, - "src/system/tracing.test.ts": 1, - "src/system/worker.test.ts": 2, - "src/ui/action.test.ts": 1, - "src/ui/app.test.ts": 13, - "src/ui/chart.test.ts": 1, - "src/ui/i18n.test.ts": 1, - "src/ui/report.test.ts": 3, - "src/ui/view.test.ts": 8, - "src/ui/widget.test.ts": 1 + "src/ai/conversation.test.ts": { + "TS2353: Object literal may only specify known properties, and 'tags' does not exist in type '…'.": 2, + "TS6133: 'ConversationMessage' is declared but its value is never read.": 1, + "TS6133: 'FunctionCallSchema' is declared but its value is never read.": 1, + "TS6133: 'ToolCallSchema' is declared but its value is never read.": 1 + }, + "src/api/documentation.test.ts": { + "TS18046: 'result.body' is of type 'unknown'.": 1 + }, + "src/api/errors.test.ts": { + "TS18046: 'error.details' is of type 'unknown'.": 1 + }, + "src/api/odata.test.ts": { + "TS6133: 'ODataQuery' is declared but its value is never read.": 1 + }, + "src/api/package-api.test.ts": { + "TS6133: 'GetInstalledPackageRequestSchema' is declared but its value is never read.": 1, + "TS6133: 'UninstallPackageApiRequestSchema' is declared but its value is never read.": 1, + "TS6133: '…' is declared but its value is never read.": 2 + }, + "src/api/rest-server.test.ts": { + "TS18048: 'config.patterns.create' is possibly 'undefined'.": 1, + "TS6133: 'RestApiConfigType' is declared but its value is never read.": 1 + }, + "src/api/router.test.ts": { + "TS2353: Object literal may only specify known properties, and 'maxAge' does not exist in type '…'.": 1 + }, + "src/automation/control-flow.test.ts": { + "TS2345: Argument of type '\"parallel\"' is not assignable to parameter of type '\"loop\"'.": 1, + "TS2739: Type '…' is missing the following properties from type '…': type, isDefault": 4 + }, + "src/automation/schemaless-node-config.test.ts": { + "TS18046: 'branch' is of type 'unknown'.": 1 + }, + "src/compose-stacks-key-loss.test.ts": { + "TS7006: Parameter 'c' implicitly has an 'any' type.": 4, + "TS7006: Parameter 'w' implicitly has an 'any' type.": 3 + }, + "src/compose-stacks.test.ts": { + "TS2322: Type '…' is not assignable to type 'undefined'.": 2, + "TS2322: Type '…' is not assignable to type '…'.": 2, + "TS2353: Object literal may only specify known properties, and 'object' does not exist in type '…'.": 2, + "TS2353: Object literal may only specify known properties, and 'route' does not exist in type '…'.": 2 + }, + "src/contracts/ai-service.test.ts": { + "TS2739: Type '…' is missing the following properties from type 'IAIConversationService': getTurnState, update": 1 + }, + "src/contracts/analytics-service.test.ts": { + "TS6133: 'query' is declared but its value is never read.": 1 + }, + "src/contracts/automation-service.test.ts": { + "TS2739: Type '…' is missing the following properties from type '…': type, isDefault": 1 + }, + "src/contracts/core-service-contracts.test.ts": { + "TS6196: '_Alias' is declared but never used.": 1, + "TS6196: '_Automation' is declared but never used.": 2, + "TS6196: '_Data' is declared but never used.": 1, + "TS6196: '_Http' is declared but never used.": 1, + "TS6196: '_I18n' is declared but never used.": 1, + "TS6196: '_IsFull' is declared but never used.": 1, + "TS6196: '_Mcp' is declared but never used.": 1, + "TS6196: '_NotSame' is declared but never used.": 1, + "TS6196: '_Notification' is declared but never used.": 1, + "TS6196: '_Protocol' is declared but never used.": 1, + "TS6196: '_Security' is declared but never used.": 1, + "TS6196: '_ShareLinks' is declared but never used.": 1, + "TS6196: '_Ui' is declared but never used.": 1, + "TS6196: '_Widens' is declared but never used.": 1 + }, + "src/contracts/data-engine.test.ts": { + "TS6133: 'options' is declared but its value is never read.": 1 + }, + "src/contracts/http-server.test.ts": { + "TS6133: 'code' is declared but its value is never read.": 1, + "TS6133: 'name' is declared but its value is never read.": 1, + "TS6133: 'value' is declared but its value is never read.": 1 + }, + "src/contracts/logger.test.ts": { + "TS6133: 'context' is declared but its value is never read.": 1, + "TS6133: 'traceId' is declared but its value is never read.": 1 + }, + "src/contracts/metadata-service.test.ts": { + "TS6133: 'options' is declared but its value is never read.": 1, + "TS6133: 'type' is declared but its value is never read.": 1 + }, + "src/contracts/package-service.test.ts": { + "TS6133: 'input' is declared but its value is never read.": 1, + "TS6196: 'InstallPackageInput' is declared but never used.": 1 + }, + "src/contracts/plugin-lifecycle-events.test.ts": { + "TS6196: '_EveryDeclaredNameFires' is declared but never used.": 1, + "TS6196: '_EveryFiredNameIsDeclared' is declared but never used.": 1 + }, + "src/contracts/service-registry.test.ts": { + "TS2322: Type '…' is not assignable to type 'T'.": 4, + "TS2322: Type '…' is not assignable to type '…'.": 3 + }, + "src/contracts/storage-service.test.ts": { + "TS6133: 'key' is declared but its value is never read.": 1 + }, + "src/data/data-engine.test.ts": { + "TS18046: 'request.command' is of type 'unknown'.": 1, + "TS2339: Property 'created_at' does not exist on type '…'.": 2, + "TS2339: Property 'name' does not exist on type '…'.": 3 + }, + "src/data/display-name.test.ts": { + "TS2339: Property 'fields' does not exist on type '…'.": 1, + "TS2339: Property 'name' does not exist on type '…'.": 2, + "TS2339: Property 'nameField' does not exist on type '…'.": 15 + }, + "src/data/driver-nosql.test.ts": { + "TS6133: 'NoSQLIndexTypeSchema' is declared but its value is never read.": 1, + "TS6133: 'NoSQLOperationTypeSchema' is declared but its value is never read.": 1 + }, + "src/data/driver-sql.test.ts": { + "TS6133: 'SQLDialect' is declared but its value is never read.": 1 + }, + "src/data/driver.test.ts": { + "TS6133: 'ids' is declared but its value is never read.": 1, + "TS6133: 'object' is declared but its value is never read.": 8, + "TS6133: 'query' is declared but its value is never read.": 1, + "TS6133: 'schema' is declared but its value is never read.": 1 + }, + "src/data/field.test.ts": { + "TS2322: Type 'string' is not assignable to type '…'.": 2 + }, + "src/data/object-strictness-batch20.test.ts": { + "TS2352: Conversion of type '…' to type '…' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.": 1 + }, + "src/data/query.test.ts": { + "TS2322: Type '…' is not assignable to type '…'.": 12, + "TS2339: Property 'fuzzy' does not exist on type '…'.": 1, + "TS2339: Property 'highlight' does not exist on type '…'.": 1, + "TS2339: Property 'operator' does not exist on type '…'.": 2, + "TS2353: Object literal may only specify known properties, and 'filters' does not exist in type 'QueryAST'.": 6, + "TS2353: Object literal may only specify known properties, and 'skip' does not exist in type 'QueryAST'.": 2, + "TS2741: Property 'order' is missing in type '…' but required in type '…'.": 1 + }, + "src/identity/scim.test.ts": { + "TS18048: 'group.members' is possibly 'undefined'.": 1, + "TS18048: 'result.emails' is possibly 'undefined'.": 2, + "TS18048: 'result.members' is possibly 'undefined'.": 2, + "TS18048: 'user.emails' is possibly 'undefined'.": 2 + }, + "src/integration/connector.test.ts": { + "TS6133: 'BasicAuthSchema' is declared but its value is never read.": 1, + "TS6133: 'BearerAuthSchema' is declared but its value is never read.": 1, + "TS6133: 'ConnectorStatusSchema' is declared but its value is never read.": 1, + "TS6133: 'ConnectorTypeSchema' is declared but its value is never read.": 1, + "TS6133: 'NoAuthSchema' is declared but its value is never read.": 1, + "TS6133: 'SyncStrategySchema' is declared but its value is never read.": 1, + "TS6133: 'WebhookEventSchema' is declared but its value is never read.": 1 + }, + "src/kernel/cluster.test.ts": { + "TS6133: 'ServiceClusterScopeSchema' is declared but its value is never read.": 1, + "TS6133: 'ServiceLeaderStrategySchema' is declared but its value is never read.": 1 + }, + "src/kernel/metadata-plugin.test.ts": { + "TS2367: This comparison appears to be unintentional because the types '…' and '\"validation\"' have no overlap.": 1 + }, + "src/kernel/public-auth-features.test.ts": { + "TS2339: Property 'exempt' does not exist on type '…'.": 2, + "TS2339: Property 'gatedInputs' does not exist on type '…'.": 4 + }, + "src/shared/metadata-collection.test.ts": { + "TS2339: Property 'hooks' does not exist on type '…'.": 1 + }, + "src/stack.test.ts": { + "TS18048: 'result.manifest' is possibly 'undefined'.": 4, + "TS2345: Argument of type '…' is not assignable to parameter of type 'ObjectStackDefinitionInput'.": 27, + "TS2739: Type '…' is missing the following properties from type '…': label, type, edges": 1, + "TS2741: Property 'label' is missing in type '…' but required in type '…'.": 1 + }, + "src/system/collaboration.test.ts": { + "TS2339: Property 'attributes' does not exist on type '…'.": 2 + }, + "src/system/deploy-bundle.test.ts": { + "TS6133: 'DeployValidationIssueSchema' is declared but its value is never read.": 1, + "TS6133: 'MigrationStatementSchema' is declared but its value is never read.": 1 + }, + "src/system/disaster-recovery.test.ts": { + "TS6133: 'BackupConfig' is declared but its value is never read.": 1, + "TS6133: 'DisasterRecoveryPlan' is declared but its value is never read.": 1, + "TS6133: 'FailoverConfig' is declared but its value is never read.": 1 + }, + "src/system/i18n-resolver.test.ts": { + "TS2339: Property 'actions' does not exist on type '…'.": 1, + "TS2339: Property 'description' does not exist on type '…'.": 1, + "TS2339: Property 'helpText' does not exist on type '…'.": 1, + "TS2339: Property 'label' does not exist on type '…'.": 2, + "TS2339: Property 'subtitle' does not exist on type '…'.": 1, + "TS2339: Property 'title' does not exist on type '…'.": 2, + "TS2551: Property 'fields' does not exist on type '…'. Did you mean 'field'?": 3 + }, + "src/system/logging.test.ts": { + "TS6133: '…' is declared but its value is never read.": 1 + }, + "src/system/metrics.test.ts": { + "TS6133: 'TimeSeriesDataPointSchema' is declared but its value is never read.": 1 + }, + "src/system/object-storage.test.ts": { + "TS6133: 'AccessControlConfig' is declared but its value is never read.": 1, + "TS6133: 'BucketConfig' is declared but its value is never read.": 1, + "TS6133: 'LifecycleActionSchema' is declared but its value is never read.": 1, + "TS6133: 'LifecyclePolicyConfig' is declared but its value is never read.": 1, + "TS6133: 'LifecyclePolicyRule' is declared but its value is never read.": 1, + "TS6133: 'MultipartUploadConfig' is declared but its value is never read.": 1, + "TS6133: 'ObjectMetadata' is declared but its value is never read.": 1, + "TS6133: 'ObjectStorageConfig' is declared but its value is never read.": 1, + "TS6133: 'PresignedUrlConfig' is declared but its value is never read.": 1, + "TS6133: 'StorageAcl' is declared but its value is never read.": 1, + "TS6133: 'StorageClass' is declared but its value is never read.": 1, + "TS6133: 'StorageConnection' is declared but its value is never read.": 1, + "TS6133: 'StorageProvider' is declared but its value is never read.": 1, + "TS6133: 'StorageScope' is declared but its value is never read.": 1 + }, + "src/system/tracing.test.ts": { + "TS6133: 'TraceStateSchema' is declared but its value is never read.": 1 + }, + "src/system/worker.test.ts": { + "TS2538: Type 'undefined' cannot be used as an index type.": 2 + }, + "src/ui/action.test.ts": { + "TS4104: The type '…' is 'readonly' and cannot be assigned to the mutable type '…'.": 1 + }, + "src/ui/app.test.ts": { + "TS2339: Property 'actionDef' does not exist on type 'NavigationItem'.": 1, + "TS2339: Property 'badge' does not exist on type 'NavigationItem'.": 4, + "TS2339: Property 'children' does not exist on type 'NavigationItem'.": 2, + "TS2339: Property 'reportName' does not exist on type 'NavigationItem'.": 1, + "TS2339: Property 'requiredPermissions' does not exist on type 'NavigationItem'.": 3, + "TS6133: 'NavigationArea' is declared but its value is never read.": 1, + "TS6133: 'NavigationItem' is declared but its value is never read.": 1 + }, + "src/ui/chart.test.ts": { + "TS6133: 'ChartType' is declared but its value is never read.": 1 + }, + "src/ui/i18n.test.ts": { + "TS6133: 'measureDoors' is declared but its value is never read.": 1 + }, + "src/ui/report.test.ts": { + "TS18046: 'b' is of type 'unknown'.": 1, + "TS18048: 'r.blocks' is possibly 'undefined'.": 1, + "TS2571: Object is of type 'unknown'.": 1 + }, + "src/ui/view.test.ts": { + "TS2322: Type 'string' is not assignable to type '…'.": 1, + "TS2322: Type '…' is not assignable to type 'FormField'.": 4, + "TS2339: Property 'description' does not exist on type '…'.": 1, + "TS2339: Property 'visibleOn' does not exist on type 'FormField'.": 2 + }, + "src/ui/widget.test.ts": { + "TS18046: 'result.options.editor' is of type 'unknown'.": 1 + } } } diff --git a/scripts/check-test-typecheck.mts b/scripts/check-test-typecheck.mts index d85ec699fd..444184125e 100644 --- a/scripts/check-test-typecheck.mts +++ b/scripts/check-test-typecheck.mts @@ -48,6 +48,15 @@ // • a file loses errors → red ("shrank; re-record") — so the number // tracks reality downward instead of rotting // • a file reaches zero → red ("graduated; delete the entry") +// • a file's errors are SWAPPED for different ones of the same cardinality +// → red ("arrived" / "vanished", both named). +// This direction did NOT exist while an entry +// was one integer: a count measures a QUANTITY, +// never an identity, so the whole error +// population of a file could rotate underneath +// a constant number and the gate printed OK. +// Measured by ablation in packages/rest, not +// argued (#13470); entries are per-signature. // • an unledgered file errors→ red — this is the everyday case, and it is // why the pin files carry NO entry: any error in // them, including the TS2578 that a deleted (or @@ -126,9 +135,14 @@ const LEDGER_COMMENT = 'every ledger by every regeneration — so it outlives its own repair and cannot be corrected in the ' + 'file where it is read. Measure instead, before repairing anything: `tsc --noEmit --pretty false -p ' + 'tsconfig.test.json` in the package prints the real classes with their TS codes. ' + + 'Each entry maps a file to its per-SIGNATURE error counts, never to a bare total (#13470): a ' + + 'signature is the TS code plus the diagnostic message with structural type blobs collapsed, and it ' + + 'carries NO line or column — so the pin survives edits that move code around, and only stops ' + + 'matching when the error itself becomes a different error. ' + 'EXACT ratchet, judged by re-running tsc: a file that gains errors is red, ' + 'a file that loses them is red until its number is re-recorded, a file that reaches zero is red until ' + - 'its entry is deleted, and a file NOT listed here may have no errors at all. Regenerate with: ' + + 'its entry is deleted, a signature that ARRIVES or VANISHES is red even when the file total is ' + + 'unchanged, and a file NOT listed here may have no errors at all. Regenerate with: ' + UPDATE_COMMAND; // ── What the ledger's own prose must say, pinned (#12624) ─────────────────── @@ -159,6 +173,9 @@ const LEDGER_COMMENT_REQUIRED: ReadonlyArray = [ ['tells the reader no cause is stated and the real classes must be measured', 'states NO cause'], ['warns that this field is regenerated, so an edit here does not survive', 'THIS FIELD IS GENERATED'], ['names the authored field a package-specific note survives in', '`_note`'], + ['red direction 4 — IDENTITY, not only quantity', 'a signature that ARRIVES or VANISHES is red even when the file total is unchanged'], + ['says entries are per-signature rather than a bare total', 'never to a bare total'], + ['says a signature carries no position, so it survives code movement', 'carries NO line or column'], ]; /** @@ -221,7 +238,7 @@ const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; * carries a path-spelled control precisely because that failure is invisible. */ const RATCHET_EXPANSION_OFFER = new RegExp( - `add the file to\\s+(?:\\S*\\/)?${LEDGER_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + `add the (?:file|signature) to\\s+(?:\\S*\\/)?${LEDGER_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, ); /** @@ -234,44 +251,123 @@ export function ratchetRemedyCarriesAuthority(message: string): boolean { return message.includes(RATCHET_AUTHORITY_MARKER); } +/** A file's error population: normalised signature → how many carry it. */ +export type SignatureCounts = Record; + type Ledger = { /** GENERATED — rebuilt from `LEDGER_COMMENT` by every `--update`. */ _comment: string; /** AUTHORED — preserved verbatim by `--update`, never written by it (#12624). */ _note?: string; - entries: Record; + entries: Record; }; -/** `path(line,col): error TSxxxx: …` — continuation lines of a multi-line message never match. */ -const DIAGNOSTIC = /^(\S[^(]*)\((\d+),(\d+)\): error (TS\d+): /; +/** Total errors a signature multiset accounts for — the old per-file count. */ +export function totalErrors(sigs: SignatureCounts | Map): number { + const values = sigs instanceof Map ? [...sigs.values()] : Object.values(sigs); + return values.reduce((a, b) => a + b, 0); +} + +/** A ledger entry is well-formed iff it is a non-empty map of positive integer counts. */ +export function isSignatureCounts(value: unknown): value is SignatureCounts { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const entries = Object.entries(value as Record); + return ( + entries.length > 0 && + entries.every(([, n]) => typeof n === 'number' && Number.isInteger(n) && n > 0) + ); +} + +/** `path(line,col): error TSxxxx: message` — continuation lines never match. */ +const DIAGNOSTIC = /^(\S[^(]*)\((\d+),(\d+)\): error (TS\d+): (.*)$/; + +// ── What a signature keeps, and what it deliberately throws away (#13470) ─── +// +// WHY THIS IS NOT A COUNT. The ledger used to record one integer per file, and +// an integer measures a QUANTITY, never an identity. `packages/rest` proved the +// consequence by ablation: `src/rest.test.ts` was recorded at 2, PR #13466 +// replaced both hand-built `IHttpRequest` literals with a typed builder, and it +// measured 2 again — while NEITHER error was the same error. tsc reports at +// most ONE argument-assignability error per call expression, so the request +// literals had been MASKING response-literal errors at the very same two call +// sites; repairing the request unmasked the response one line down. The count +// was conserved across a change that replaced the file's entire error +// population, and the EXACT ratchet printed OK both times. +// +// WHY POSITION IS EXCLUDED. The obvious identity — code plus line/column — +// churns on every edit anywhere above it in the file, which would turn a +// generated ledger into a merge-conflict magnet and buy a gate that gets +// weakened later. A signature therefore carries NO position: moving the code +// around the file does not touch the ledger, and only a change to WHICH error +// is present does. +// +// WHY THE MESSAGE IS COLLAPSED. Measured over the 264 diagnostics the three +// ledgered packages actually carry (2026-08-30): raw messages run to 546 +// characters and embed whole structural types, including tsc's own +// version-dependent elisions ("... 37 more ..."). Those blobs are churn, not +// identity. Collapsing every quoted span that is long or structural leaves the +// discriminating half — the TS code, the prose, and the NAMED types and +// identifiers — and it is what separates the two populations above: +// `parameter of type 'IHttpRequest'` vs `parameter of type 'IHttpResponse'`. +// Measured effect: 264 errors collapse to 147 signature keys, longest 186 +// characters, and rest's file reduces to a single key. + +/** Longest quoted span kept verbatim; beyond this it is a type blob, not a name. */ +const STABLE_QUOTED_SPAN = 32; /** - * Per-file error counts from a raw `tsc --noEmit --pretty false` transcript. - * Paths are normalised to posix and relative to the package being judged, so - * the ledger reads the same on every platform. + * A diagnostic message with its churn-prone spans collapsed. A quoted span + * survives only when it is short AND free of the structural punctuation that + * makes tsc print a whole shape — i.e. when it is a name, a dotted path, or a + * small literal type, all of which change only when the error does. */ -export function parseDiagnostics(output: string): Map { - const counts = new Map(); +export function normalizeMessage(message: string): string { + return message + .replace(/'([^']*)'/g, (whole, inner: string) => + inner.length <= STABLE_QUOTED_SPAN && !/[{<(\[]/.test(inner) ? whole : "'…'", + ) + .replace(/\s+/g, ' ') + .trim(); +} + +/** The pinned identity of one diagnostic: its code plus its normalised message. */ +export function diagnosticSignature(code: string, message: string): string { + return `${code}: ${normalizeMessage(message)}`; +} + +/** + * Per-file signature multisets from a raw `tsc --noEmit --pretty false` + * transcript. Paths are normalised to posix and relative to the package being + * judged, so the ledger reads the same on every platform. + */ +export function parseDiagnostics(output: string): Map> { + const files = new Map>(); for (const line of output.split(/\r?\n/)) { const m = DIAGNOSTIC.exec(line); if (!m) continue; const file = m[1].split(path.sep).join('/'); - counts.set(file, (counts.get(file) ?? 0) + 1); + const sig = diagnosticSignature(m[4], m[5]); + const sigs = files.get(file) ?? new Map(); + sigs.set(sig, (sigs.get(sig) ?? 0) + 1); + files.set(file, sigs); } - return counts; + return files; } /** * The verdict, as a pure function over observed counts and the recorded ledger, * so the self-test proves the semantics the real run applies. */ -export function evaluate(actual: Map, ledger: Record): string[] { +export function evaluate( + actual: Map>, + ledger: Record, +): string[] { const problems: string[] = []; - for (const [file, count] of [...actual].sort(([a], [b]) => a.localeCompare(b))) { + for (const [file, sigs] of [...actual].sort(([a], [b]) => a.localeCompare(b))) { if (!Object.hasOwn(ledger, file)) { problems.push( - `${file}: ${count} type error(s) in a file the ledger does not cover. Fix them — this file is ` + + `${file}: ${totalErrors(sigs)} type error(s) in a file the ledger does not cover. Fix them — this file is ` + `inside the checked zone, which is the point of ${PROJECT}. (A deleted \`@ts-expect-error\` ` + `shows up exactly here, as TS2578/TS2694.) That is the fix, and the only one of the two you ` + `can take on your own. ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal option: add the file to ` + @@ -282,30 +378,71 @@ export function evaluate(actual: Map, ledger: Record recorded) { - problems.push( - `${file}: ${count} type error(s), ledger records ${recorded} — the debt GREW. Fix the ${count - recorded} ` + - `new one(s); the ledger only ratchets down (${ISSUE}).`, - ); - } else if (count < recorded) { + // Malformed entries are reported once, by the ledger-shape pass below; + // judging a file against an unreadable entry would report the same defect + // twice and in the second case describe it wrongly. + if (!isSignatureCounts(recorded)) continue; + + // ARRIVED and VANISHED are the two the old per-file count could not see. + // They are reported per SIGNATURE and by NAME: a wholesale substitution + // holds the file total constant, so a reader who is told only that + // "something changed" still cannot tell which debt was paid and which was + // uncovered. Both halves of the set difference are named. + for (const [sig, count] of [...sigs].sort(([a], [b]) => a.localeCompare(b))) { + const was = recorded[sig]; + if (was === undefined) { + problems.push( + `${file}: ${count} type error(s) carrying a signature the ledger does not record — ARRIVED: ` + + `${sig} — and the file total may be UNCHANGED, because repairing one error routinely ` + + `unmasks another at the same site (${ISSUE}). Fix it — that is the fix, and the only one ` + + `of the two you can take on your own. ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal option: ` + + `add the signature to ${LEDGER_NAME}. This is an EXACT ratchet and the ledger only ` + + `ratchets down, so a new signature EXPANDS it — that needs a maintainer to agree the debt ` + + `is legitimate first. Do not take this path to get CI green.`, + ); + } else if (count > was) { + problems.push( + `${file}: ${count} type error(s), ledger records ${was} — the debt GREW for ${sig}. Fix the ` + + `${count - was} new one(s); the ledger only ratchets down (${ISSUE}).`, + ); + } else if (count < was) { + problems.push( + `${file}: ${count} type error(s), ledger records ${was} — the debt SHRANK for ${sig}, which is ` + + `the goal. Re-record it so the number stays true: ${UPDATE_COMMAND}.`, + ); + } + } + + for (const [sig, was] of Object.entries(recorded).sort(([a], [b]) => a.localeCompare(b))) { + if (sigs.has(sig)) continue; problems.push( - `${file}: ${count} type error(s), ledger records ${recorded} — the debt SHRANK, which is the goal. ` + - `Re-record it so the number stays true: ${UPDATE_COMMAND}.`, + `${file}: the ledger records ${was} type error(s) carrying ${sig} but tsc reports none — it ` + + `VANISHED: repaired, or REPLACED by a different error while the file total stayed the same. ` + + `That is the ratchet tightening. Re-record it so the ledger stays true: ${UPDATE_COMMAND}.`, ); } } for (const [file, recorded] of Object.entries(ledger).sort(([a], [b]) => a.localeCompare(b))) { - if (typeof recorded !== 'number' || !Number.isInteger(recorded) || recorded <= 0) { + if (typeof recorded === 'number') { + problems.push( + `${file}: ledger entry is a bare error COUNT (${JSON.stringify(recorded)}), not a per-signature ` + + `map — this ledger predates the identity pin (${ISSUE}). A count measures a QUANTITY, never ` + + `an identity, so it stays green through a wholesale substitution of the errors underneath it. ` + + `Re-record it: ${UPDATE_COMMAND}.`, + ); + continue; + } + if (!isSignatureCounts(recorded)) { problems.push( - `${file}: ledger entry is not a positive integer error count (${JSON.stringify(recorded)}) — ` + - `a ledger without a measurement is a permission slip.`, + `${file}: ledger entry is not a map of signature → positive integer error count ` + + `(${JSON.stringify(recorded)}) — a ledger without a measurement is a permission slip.`, ); continue; } if (!actual.has(file)) { problems.push( - `${file}: ledger records ${recorded} type error(s) but tsc reports none — it GRADUATED, or the file ` + + `${file}: ledger records ${totalErrors(recorded)} type error(s) but tsc reports none — it GRADUATED, or the file ` + `moved/vanished. Delete its entry from ${LEDGER_NAME} in the same change.`, ); } @@ -329,9 +466,14 @@ function loadLedger(): Ledger { * counts pass through untouched and sorted: a regeneration re-records what tsc * measured and moves no number of its own. */ -export function buildLedger(counts: Map, note?: string): Ledger { - const entries: Record = {}; - for (const file of [...counts.keys()].sort()) entries[file] = counts.get(file)!; +export function buildLedger(counts: Map>, note?: string): Ledger { + const entries: Record = {}; + for (const file of [...counts.keys()].sort()) { + const sigs = counts.get(file)!; + const recorded: SignatureCounts = {}; + for (const sig of [...sigs.keys()].sort()) recorded[sig] = sigs.get(sig)!; + entries[file] = recorded; + } return note === undefined ? { _comment: LEDGER_COMMENT, entries } : { _comment: LEDGER_COMMENT, _note: note, entries }; } @@ -342,7 +484,7 @@ export function buildLedger(counts: Map, note?: string): Ledger * rather than silently dropping the authored half of its prose — the loss this * whole split exists to stop (#12624). */ -function writeLedger(counts: Map): void { +function writeLedger(counts: Map>): void { fs.writeFileSync(LEDGER_PATH, `${JSON.stringify(buildLedger(counts, loadLedger()._note), null, 2)}\n`, 'utf8'); } @@ -369,7 +511,23 @@ function runTsc(): string { } function selfTest(): void { - const cases: Array<{ label: string; actual: Array<[string, number]>; ledger: Record; expect: RegExp[] }> = [ + // The two REAL signatures from the ablation that produced #13470: `packages/ + // rest`'s two call sites passed a bad request AND a bad response, tsc showed + // only the request error, and PR #13466's repair uncovered the response one. + // Same file, same count, entirely different errors. + const REQ = "TS2345: Argument of type '…' is not assignable to parameter of type 'IHttpRequest'."; + const RES = "TS2345: Argument of type '…' is not assignable to parameter of type 'IHttpResponse'."; + + /** `[file, [[signature, count], …]]` → the shape `parseDiagnostics` returns. */ + const observed = (pairs: Array<[string, Array<[string, number]>]>): Map> => + new Map(pairs.map(([file, sigs]) => [file, new Map(sigs)])); + + const cases: Array<{ + label: string; + actual: Array<[string, Array<[string, number]>]>; + ledger: Record; + expect: RegExp[]; + }> = [ { label: 'a clean, unledgered file passes', actual: [], @@ -377,48 +535,80 @@ function selfTest(): void { expect: [], }, { - label: 'a ledgered file at its recorded count passes', - actual: [['a.test.ts', 3]], - ledger: { 'a.test.ts': 3 }, + label: 'a ledgered file at its recorded signature counts passes', + actual: [['a.test.ts', [[REQ, 3]]]], + ledger: { 'a.test.ts': { [REQ]: 3 } }, expect: [], }, { label: 'an error in an unledgered file is red — the everyday case, and what a deleted directive produces', - actual: [['pin.test.ts', 1]], + actual: [['pin.test.ts', [['TS2578: Unused directive.', 1]]]], ledger: {}, expect: [/pin\.test\.ts: 1 type error\(s\) in a file the ledger does not cover/], }, { - label: 'a ledgered file that gains an error is red', - actual: [['a.test.ts', 4]], - ledger: { 'a.test.ts': 3 }, + label: 'a ledgered file that gains an error of a RECORDED signature is red', + actual: [['a.test.ts', [[REQ, 4]]]], + ledger: { 'a.test.ts': { [REQ]: 3 } }, expect: [/a\.test\.ts: 4 type error\(s\), ledger records 3 — the debt GREW/], }, { - label: 'a ledgered file that loses an error is red until re-recorded', - actual: [['a.test.ts', 2]], - ledger: { 'a.test.ts': 3 }, + label: 'a ledgered file that loses an error of a recorded signature is red until re-recorded', + actual: [['a.test.ts', [[REQ, 2]]]], + ledger: { 'a.test.ts': { [REQ]: 3 } }, expect: [/a\.test\.ts: 2 type error\(s\), ledger records 3 — the debt SHRANK/], }, { label: 'a graduated file is red until its entry is deleted', actual: [], - ledger: { 'a.test.ts': 3 }, + ledger: { 'a.test.ts': { [REQ]: 3 } }, expect: [/a\.test\.ts: ledger records 3 type error\(s\) but tsc reports none/], }, { label: 'a ledger entry without a real measurement is red', actual: [], - ledger: { 'a.test.ts': 0 }, - expect: [/a\.test\.ts: ledger entry is not a positive integer error count/], + ledger: { 'a.test.ts': { [REQ]: 0 } }, + expect: [/a\.test\.ts: ledger entry is not a map of signature → positive integer error count/], + }, + { + label: + 'a legacy BARE-COUNT entry is red rather than silently trusted — a count cannot be judged for ' + + 'identity, so a ledger that predates the pin must be re-recorded, not read', + actual: [['a.test.ts', [[REQ, 3]]]], + ledger: { 'a.test.ts': 3 as unknown as SignatureCounts }, + expect: [/a\.test\.ts: ledger entry is a bare error COUNT \(3\), not a per-signature map/], + }, + { + // ⭐ THE CASE THIS GATE WAS REOPENED FOR (#13470). The old per-file count + // is IDENTICAL across this substitution — 2 before, 2 after — and printed + // OK. Both halves of the set difference must be named: which debt was + // paid, and which one it uncovered. + label: + '⭐ #13470 — a WHOLESALE substitution of error identity at CONSTANT cardinality is red, and ' + + 'names both the signature that ARRIVED and the one that VANISHED', + actual: [['rest.test.ts', [[RES, 2]]]], + ledger: { 'rest.test.ts': { [REQ]: 2 } }, + expect: [ + /rest\.test\.ts: 2 type error\(s\) carrying a signature the ledger does not record — ARRIVED: TS2345: .*'IHttpResponse'/, + /rest\.test\.ts: the ledger records 2 type error\(s\) carrying TS2345: .*'IHttpRequest'.* but tsc reports none — it VANISHED/, + ], + }, + { + label: 'a PARTIAL substitution is red too, and the surviving signature is left unmentioned', + actual: [['a.test.ts', [[REQ, 1], [RES, 1]]]], + ledger: { 'a.test.ts': { [REQ]: 2 } }, + expect: [ + /a\.test\.ts: 1 type error\(s\), ledger records 2 — the debt SHRANK for TS2345: .*'IHttpRequest'/, + /a\.test\.ts: 1 type error\(s\) carrying a signature the ledger does not record — ARRIVED: TS2345: .*'IHttpResponse'/, + ], }, { label: 'problems from both directions are reported together', actual: [ - ['a.test.ts', 4], - ['new.test.ts', 1], + ['a.test.ts', [[REQ, 4]]], + ['new.test.ts', [['TS2304: Cannot find name.', 1]]], ], - ledger: { 'a.test.ts': 3, 'gone.test.ts': 2 }, + ledger: { 'a.test.ts': { [REQ]: 3 }, 'gone.test.ts': { [RES]: 2 } }, expect: [ /a\.test\.ts: 4 type error\(s\), ledger records 3 — the debt GREW/, /new\.test\.ts: 1 type error\(s\) in a file the ledger does not cover/, @@ -429,12 +619,26 @@ function selfTest(): void { const failures: string[] = []; for (const c of cases) { - const got = evaluate(new Map(c.actual), c.ledger); + const got = evaluate(observed(c.actual), c.ledger); if (got.length !== c.expect.length || !c.expect.every((rx, i) => rx.test(got[i]))) { failures.push(`${c.label}: expected ${c.expect.length} problem(s) matching ${c.expect}, got ${JSON.stringify(got)}`); } } + const expect = (label: string, cond: boolean): void => { + if (!cond) failures.push(label); + }; + + // The control that makes the substitution case above mean anything: the two + // populations really are the same size, so the number the ledger used to hold + // is UNCHANGED across the swap. Without this the case would only be proving + // "a changed count is red", which the per-file count already did. + expect( + '⭐ #13470 — the substitution fixture is cardinality-preserving (else the case above proves nothing ' + + 'the old per-file count could not already see)', + totalErrors(new Map([[RES, 2]])) === totalErrors({ [REQ]: 2 }), + ); + // The parser is the other half of the semantics: a multi-line tsc message // must count once, and a continuation line must never be read as a file. const parsed = parseDiagnostics( @@ -446,8 +650,58 @@ function selfTest(): void { '', ].join('\n'), ); - if (parsed.size !== 2 || parsed.get('src/a.test.ts') !== 2 || parsed.get('src/b.test.ts') !== 1) { - failures.push(`parseDiagnostics mis-read a multi-line transcript: ${JSON.stringify([...parsed])}`); + if ( + parsed.size !== 2 || + totalErrors(parsed.get('src/a.test.ts') ?? new Map()) !== 2 || + totalErrors(parsed.get('src/b.test.ts') ?? new Map()) !== 1 || + (parsed.get('src/a.test.ts')?.size ?? 0) !== 2 + ) { + failures.push( + `parseDiagnostics mis-read a multi-line transcript: ${JSON.stringify([...parsed].map(([f, m]) => [f, [...m]]))}`, + ); + } + + // ── What a signature must and must not notice (#13470) ──────────────────── + // + // These are the two properties that decide whether pinning identity is worth + // its cost. (i) POSITION-BLIND: the same errors after unrelated edits above + // them must produce an IDENTICAL ledger, or every commit churns a generated + // file and the gate gets weakened. (ii) IDENTITY-SHARP: a different named + // type must produce a different key, or the pin is decoration. + { + const atLine = (line: number, type: string): string => + `src/rest.test.ts(${line},7): error TS2345: Argument of type '{ json: Mock; ` + + `status: Mock; }' is not assignable to parameter of type '${type}'.`; + + const before = parseDiagnostics([atLine(2063, 'IHttpResponse'), atLine(2088, 'IHttpResponse')].join('\n')); + const moved = parseDiagnostics([atLine(9001, 'IHttpResponse'), atLine(9099, 'IHttpResponse')].join('\n')); + const swapped = parseDiagnostics([atLine(2063, 'IHttpRequest'), atLine(2088, 'IHttpRequest')].join('\n')); + + const keysOf = (m: Map>): string => + JSON.stringify([...m].map(([f, sigs]) => [f, [...sigs]])); + + expect( + `#13470 (i) POSITION-BLIND — the same errors at different line/column produce the SAME ledger, so ` + + `unrelated edits above them do not churn a generated file: ${keysOf(before)} vs ${keysOf(moved)}`, + keysOf(before) === keysOf(moved), + ); + expect( + '#13470 (ii) IDENTITY-SHARP — swapping only the NAMED parameter type produces a DIFFERENT ' + + 'signature, which is the whole discrimination the per-file count lacked', + keysOf(before) !== keysOf(swapped), + ); + expect( + '#13470 (ii-control) — and the swap is cardinality-preserving, so nothing but identity separates ' + + 'the two readings', + totalErrors(before.get('src/rest.test.ts')!) === totalErrors(swapped.get('src/rest.test.ts')!), + ); + expect( + '#13470 — a signature drops the churn-prone structural blob but keeps the named type (a full ' + + 'message string would re-pin every anonymous shape tsc prints, including its own ' + + '"... 37 more ..." elisions)', + [...before.get('src/rest.test.ts')!.keys()][0] === + "TS2345: Argument of type '…' is not assignable to parameter of type 'IHttpResponse'.", + ); } // ── The ratchet-remedy authority convention (#8435) ──────────────────────── @@ -475,11 +729,7 @@ function selfTest(): void { // // (3) is what makes (2) worth having: without it, a predicate that approved // everything would keep this block green while the convention is gone. - const expect = (label: string, cond: boolean): void => { - if (!cond) failures.push(label); - }; - - const unledgered = evaluate(new Map([['pin.test.ts', 1]]), {})[0] ?? ''; + const unledgered = evaluate(observed([['pin.test.ts', [['TS2578: Unused directive.', 1]]]]), {})[0] ?? ''; expect( '#8435 — the ratchet-offer DETECTOR still matches the unledgered-file verdict (else every ' + 'assertion below it passes vacuously)', @@ -526,7 +776,7 @@ function selfTest(): void { RATCHET_EXPANSION_OFFER.test(pathSpelledOffer), ); - const shrank = evaluate(new Map([['a.test.ts', 2]]), { 'a.test.ts': 3 })[0] ?? ''; + const shrank = evaluate(observed([['a.test.ts', [[REQ, 2]]]]), { 'a.test.ts': { [REQ]: 3 } })[0] ?? ''; if (!shrank.includes('SHRANK')) { expect( '#8435 — the ratchet-DOWN control is no longer the SHRANK verdict, so it cannot prove the ' @@ -542,7 +792,7 @@ function selfTest(): void { ); } - const graduated = evaluate(new Map(), { 'a.test.ts': 3 })[0] ?? ''; + const graduated = evaluate(observed([]), { 'a.test.ts': { [REQ]: 3 } })[0] ?? ''; if (!graduated.includes(LEDGER_NAME)) { expect( `#8435 — the GRADUATED control no longer names ${LEDGER_NAME}, so it cannot prove the detector is ` @@ -559,7 +809,7 @@ function selfTest(): void { ); } - const grew = evaluate(new Map([['a.test.ts', 4]]), { 'a.test.ts': 3 })[0] ?? ''; + const grew = evaluate(observed([['a.test.ts', [[REQ, 4]]]]), { 'a.test.ts': { [REQ]: 3 } })[0] ?? ''; if (!grew.includes('GREW')) { expect( '#8435 — the GREW control is no longer the debt-grew verdict, so it cannot prove the detector ' @@ -574,6 +824,34 @@ function selfTest(): void { ); } + // ── The same authority rule, applied to the per-SIGNATURE offer (#13470) ── + // + // ARRIVED is the unledgered-file verdict's sibling one level down: recording + // a NEW signature against a file that is already ledgered expands an EXACT + // shrink-only ratchet exactly as adding a whole file does, and it is the more + // tempting of the two — the file total need not have moved, so it reads like + // bookkeeping. VANISHED is its opposite and must stay unmarked. + const arrived = evaluate(observed([['a.test.ts', [[RES, 2]]]]), { 'a.test.ts': { [REQ]: 2 } }); + const arrivedMsg = arrived.find((m) => m.includes('ARRIVED')) ?? ''; + const vanishedMsg = arrived.find((m) => m.includes('VANISHED')) ?? ''; + + expect( + '#13470 — the ratchet-offer DETECTOR reaches the ARRIVED verdict (else the marker assertion below ' + + 'it passes vacuously forever)', + RATCHET_EXPANSION_OFFER.test(arrivedMsg), + ); + expect( + `#13470 — the ARRIVED verdict marks its ledger offer ${RATCHET_AUTHORITY_MARKER}: recording a NEW ` + + 'signature EXPANDS the same shrink-only ratchet that adding a whole file does, and it is the ' + + 'more tempting path because the file total need not have moved', + ratchetRemedyCarriesAuthority(arrivedMsg), + ); + expect( + '#13470 — the detector does NOT reach the VANISHED verdict (an error that is gone is the ratchet ' + + "tightening and squarely the author's job to re-record)", + vanishedMsg !== '' && !RATCHET_EXPANSION_OFFER.test(vanishedMsg) && ratchetRemedyCarriesAuthority(vanishedMsg), + ); + // ── The ledger's own prose (#12624) ─────────────────────────────────────── // // Asserted against what `buildLedger()` REALLY emits, because that object is @@ -584,7 +862,7 @@ function selfTest(): void { // that clause. A departure pin alone is decoration: it passes against an // empty `_comment`, which is exactly the regression it would be there to // catch. - const written = buildLedger(new Map([['a.test.ts', 3]]))._comment; + const written = buildLedger(observed([['a.test.ts', [[REQ, 3]]]]))._comment; const omissions = ledgerCommentOmissions(written); expect( @@ -622,15 +900,21 @@ function selfTest(): void { // The other half of `--update`: it re-records what tsc measured and invents, // reorders or rounds nothing. The ledger's NUMBERS are the ratchet itself. const built = buildLedger( - new Map([ - ['b.test.ts', 2], - ['a.test.ts', 1], + observed([ + ['b.test.ts', [[RES, 2]]], + ['a.test.ts', [[REQ, 1]]], ]), ); expect( '#12624 — `--update` writes the measured counts through unchanged and key-sorted (a regeneration ' + `must move no number): got ${JSON.stringify(built.entries)}`, - JSON.stringify(built.entries) === '{"a.test.ts":1,"b.test.ts":2}', + JSON.stringify(built.entries) === JSON.stringify({ 'a.test.ts': { [REQ]: 1 }, 'b.test.ts': { [RES]: 2 } }), + ); + expect( + '#13470 — and it writes SIGNATURE keys sorted too, so a regeneration that measured the same errors ' + + `produces a byte-identical file: got ${JSON.stringify(buildLedger(observed([['a.test.ts', [[RES, 1], [REQ, 1]]]])).entries)}`, + JSON.stringify(buildLedger(observed([['a.test.ts', [[RES, 1], [REQ, 1]]]])).entries) === + JSON.stringify(buildLedger(observed([['a.test.ts', [[REQ, 1], [RES, 1]]]])).entries), ); expect( '#12624 — with no authored note the written ledger is exactly { _comment, entries } in that order, ' @@ -653,7 +937,7 @@ function selfTest(): void { // Asserted VERBATIM and in its own key — that is the whole answer to "how // does a reader tell preserved prose from generated prose". const note = 'ADR-0122 phase 2 (#6083) emptied this ledger.'; - const withNote = buildLedger(new Map([['a.test.ts', 3]]), note); + const withNote = buildLedger(observed([['a.test.ts', [[REQ, 3]]]]), note); expect( `#12624 — an authored _note is preserved VERBATIM by a regeneration: got ${JSON.stringify(withNote._note)}`, withNote._note === note, @@ -666,7 +950,7 @@ function selfTest(): void { ); expect( '#12624 — preserving a note moves no number either', - JSON.stringify(withNote.entries) === '{"a.test.ts":3}', + JSON.stringify(withNote.entries) === JSON.stringify({ 'a.test.ts': { [REQ]: 3 } }), ); } @@ -676,7 +960,10 @@ function selfTest(): void { process.exit(1); } console.log( - `✓ check:test-typecheck --self-test — ${cases.length} semantic case(s), the parser, the #8435 ` + `✓ check:test-typecheck --self-test — ${cases.length} semantic case(s), the parser, the #13470 ` + + 'identity pins (a wholesale substitution at constant cardinality is red and names both the ' + + 'signature that ARRIVED and the one that VANISHED; signatures are position-blind but ' + + 'identity-sharp; the ARRIVED offer is marked maintainer-only and VANISHED is not), the #8435 ' + 'convention (the unledgered-file verdict keeps its ledger offer marked maintainer-only, and the ' + 'SHRANK / GRADUATED / GREW verdicts stay unmarked) and the #12624 ledger-prose pins (the text ' + '`--update` writes carries every mechanism sentence, states no refuted cause, passes the ' @@ -694,8 +981,12 @@ const counts = parseDiagnostics(runTsc()); if (process.argv.includes('--update')) { writeLedger(counts); - const total = [...counts.values()].reduce((a, b) => a + b, 0); - console.log(`check:test-typecheck — re-recorded ${LEDGER_NAME}: ${counts.size} file(s), ${total} error(s).`); + const total = [...counts.values()].reduce((a, sigs) => a + totalErrors(sigs), 0); + const signatures = [...counts.values()].reduce((a, sigs) => a + sigs.size, 0); + console.log( + `check:test-typecheck — re-recorded ${LEDGER_NAME}: ${counts.size} file(s), ${total} error(s), ` + + `${signatures} distinct signature(s).`, + ); process.exit(0); } @@ -706,8 +997,10 @@ if (problems.length) { process.exit(1); } -const total = [...counts.values()].reduce((a, b) => a + b, 0); +const total = [...counts.values()].reduce((a, sigs) => a + totalErrors(sigs), 0); +const signatures = [...counts.values()].reduce((a, sigs) => a + sigs.size, 0); console.log( `check:test-typecheck: OK — ${PKG_NAME}'s test layer compiles under ${PKG_DIR}/${PROJECT}; ` + - `${counts.size} file(s) / ${total} error(s) held in ${LEDGER_NAME} (shrink-only, ${ISSUE}).`, + `${counts.size} file(s) / ${total} error(s) / ${signatures} pinned signature(s) held in ` + + `${LEDGER_NAME} (shrink-only and identity-pinned, ${ISSUE}).`, );