Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 9m58s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m58s
`Complete<T>` (#807) makes SPA full-replace bodies fail typecheck when a builder omits a schema member. Nothing checked it was APPLIED: `completeRequest.guard.test.ts` proves the type's semantics and would stay green with every annotation deleted, and `test_optional_request_members.py`'s COVERED disposition — "the builder is annotated `Complete<T>`" — was a claim about another language's source that nothing verified. Adds `completeAnnotationScan.ts` (compiler-API scanners) + `completeAnnotations.guard.test.ts`, with a synthetic-source fixture suite. Two derived populations: the `Complete<…>` annotations (SPA AST ∩ git index) and the droppable schemas (parsed from the generated `v1.d.ts`, a pass-through of the OpenAPI `required` array). It asserts a production annotation per schema dispositioned as needing one, NO annotation on the server-computed and load-bearing-omission schemas, that every `Complete<X>` resolves to a generated schema rather than a hand-written mirror, and set equality between droppable schemas and the reviewed dispositions. `test_complete_annotation_dispositions.py` cross-checks that table against the authoritative Python one and ships a declared, harness-executed mutation. Found one live defect: `playouts.ts` declared two request types as hand-written mirrors SHADOWING generated schemas of the same name, so their `Complete<>` was checking a local copy rather than the contract — the #754 mechanism wearing the annotation meant to prevent it. Eight review rounds, seven BLOCKED, two independent cold reviewers. A wrapper-signature scanner was built and REMOVED: every blocker traced to that one mechanism (obligation on the wrong population; reachability mistaken for protection, since `Complete<T>` is shallow; body discovery keyed on a parameter name, then parameter-vs-local; and finally `export function` → `export const` blinding the scanner and its cross-check together). Five defects from one mechanism, so the mechanism went rather than a sixth patch. Residuals stated in §4b, the guard-inventory row and the record: per-SCHEMA not per-site or per-wrapper; token presence not liveness; the phantom direction unchecked (#777); a second `setupFiles` entry could discharge; and plugin-level population integrity borrowed from the sibling guard. fixes #820 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
470 lines
25 KiB
TypeScript
470 lines
25 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import trackedSources from 'virtual:etv-tracked-source-files';
|
|
import {
|
|
declaresLocalComplete,
|
|
scanCompleteAnnotations,
|
|
scanOptionalSchemaMembers,
|
|
scanSchemaAliases,
|
|
type CompleteAnnotation
|
|
} from './completeAnnotationScan';
|
|
|
|
/**
|
|
* ersatztv#820 — `Complete<T>` is APPLIED where the contract says it must be, and nowhere it must
|
|
* not be.
|
|
*
|
|
* ## What was missing
|
|
*
|
|
* Two guards existed and neither covered this. `completeRequest.guard.test.ts` proves the TYPE's
|
|
* semantics — `Complete<T>` rejects a missing member and a phantom one — and would stay green with
|
|
* every annotation in the SPA deleted. `scripts/tests/test_optional_request_members.py` derives,
|
|
* from `ErsatzTV/wwwroot/openapi/v1.json` on every run, which schemas can silently drop a member on
|
|
* a write, and files each under a disposition; one of those, `COVERED`, is worded "the SPA builds
|
|
* this body and the builder is annotated `Complete<T>`".
|
|
*
|
|
* That wording is a CLAIM ABOUT ANOTHER LANGUAGE'S SOURCE that nothing checked. A `COVERED` row
|
|
* and a SPA with the annotation deleted were indistinguishable to the whole suite. So was the
|
|
* opposite error: `docs/spa-conventions.md` §4b's "do NOT apply `Complete<T>` to a schema whose
|
|
* optional members are computed server-side" was prose with no executable form.
|
|
*
|
|
* ## The population is derived; the dispositions are the reviewed scope
|
|
*
|
|
* The POPULATION — which schemas can drop a member — is read every run off
|
|
* `src/api/generated/v1.d.ts` with the compiler API. That file is a pure pass-through of the
|
|
* OpenAPI `required` array (`web/scripts/generate-openapi-types.mjs` does
|
|
* `const optional = required.has(name) ? '' : '?'`), so "optional here" and "outside `required` in
|
|
* `v1.json`" are ONE fact read at two points, not two derivations that could disagree.
|
|
* `DISPOSITIONS` below is the SCOPE: a reviewed policy choice per schema, hand-written on purpose,
|
|
* and FORCED to exist by the set-equality assertion — a new optional member fails this guard until
|
|
* someone writes down what should happen about it.
|
|
*
|
|
* RELATIONSHIP TO THE PYTHON GUARD, stated because "they check the same thing" would be wrong.
|
|
* That guard scopes to schemas REACHABLE FROM A REQUEST BODY (9 rows today); this one scopes to
|
|
* every generated schema with an optional member (13), so it additionally sees response models. The
|
|
* two tables therefore overlap rather than coincide, and they can drift: re-dispositioning a schema
|
|
* on one side does not redden the other. What is NOT possible is a schema going unnoticed by both,
|
|
* because both derive their population from the same `required` arrays.
|
|
*
|
|
* ## Residual, stated rather than left to be discovered
|
|
*
|
|
* This guard proves an annotation EXISTS for a schema; it does not prove that every construction
|
|
* site of that schema carries one. The missing-member direction does not need it — the wrapper
|
|
* parameter's annotation reaches every caller — but the PHANTOM direction does, because
|
|
* TypeScript's excess-property check fires only on a fresh object literal in a contextually typed
|
|
* position, which a generic `.map` callback's return is not (§4b). That per-site population is
|
|
* "sites in code", the case `testing.guard-derives-population-from-source` explicitly scopes out
|
|
* and #777 tracks. A deep `Complete<T>` would close the missing direction into nested types but
|
|
* NOT the phantom one, and it cannot be applied blanket-wise anyway — see `MUST-NOT-ANNOTATE`.
|
|
*/
|
|
|
|
// --- the reviewed scope ---------------------------------------------------------------------
|
|
//
|
|
// ANNOTATED a write body that can drop a member, so the SPA MUST annotate it `Complete<T>`.
|
|
// Equivalent to the Python guard's COVERED, and this is the row that turns that
|
|
// registry's claim into a check.
|
|
// MUST-NOT-ANNOTATE the optional members are computed get-only properties on the C# record. Nothing
|
|
// deserializes them, so omitting them drops nothing — and annotating WOULD BE A
|
|
// BUG, forcing a caller to fabricate server-computed values (§4b). Asserted as an
|
|
// absence, so the prohibition is executable rather than advisory.
|
|
// CREATE a POST that creates an entity, where an omitted member correctly means "use the
|
|
// default".
|
|
// TRIGGER the body parameterises an action and replaces no stored entity.
|
|
// RESPONSE-ONLY never sent as a request body, so there is nothing for a builder to drop.
|
|
//
|
|
// The last three are PERMISSIVE by design: they require no annotation and forbid none. That is
|
|
// deliberate rather than an omission — annotating one is harmless (it costs a caller nothing to name
|
|
// a field it was going to set anyway), while FORBIDDING it would make a future full-replace endpoint
|
|
// over the same schema harder to protect. Where annotating is actively wrong, the row is
|
|
// MUST-NOT-ANNOTATE and the prohibition is asserted; `CreateChannelFromLineupAdvancedOptionsRequest`
|
|
// is such a row despite being a create.
|
|
type Disposition = 'ANNOTATED' | 'MUST-NOT-ANNOTATE' | 'CREATE' | 'TRIGGER' | 'RESPONSE-ONLY';
|
|
|
|
const DISPOSITIONS: Record<string, { disposition: Disposition; note: string }> = {
|
|
CreateFFmpegProfileRequest: {
|
|
disposition: 'ANNOTATED',
|
|
note: "FFmpegProfilesScreen's `Draft` feeds BOTH the POST and the full-replace PUT, so the draft type itself is `Complete<…>`. `qsvPreferNativeDecoder` was a live silent drop before #807."
|
|
},
|
|
UpdateFFmpegProfileRequest: {
|
|
disposition: 'ANNOTATED',
|
|
note: 'PUT /ffmpeg/profiles/{id} is a full replace; `qsvPreferNativeDecoder` is a defaulted ctor param so ASP.NET drops it from `required`.'
|
|
},
|
|
MultiCollectionItemRequest: {
|
|
disposition: 'ANNOTATED',
|
|
note: 'Nested in the full-replace PUT /multi-collections/{id}. Dropping `weight` resets every weight to 1 — a prose comment warned about it, and a comment is not a check.'
|
|
},
|
|
ArtworkContentTypeModel: {
|
|
disposition: 'MUST-NOT-ANNOTATE',
|
|
note: 'Reachable from the full-replace PUT /channels/{id} via `UpdateChannelRequest.logo`, so an endpoint-shaped rule would demand an annotation. `isExternalUrl`/`hasContentType`/`urlWithContentType` are computed get-only properties, never deserialized.'
|
|
},
|
|
AutoTunedChannelRequest: {
|
|
disposition: 'CREATE',
|
|
note: 'POST /channels/auto-tune. The handler adds a channel and rejects a duplicate number; it never overwrites an existing one.'
|
|
},
|
|
AutoTuneSourceWeightRequest: {
|
|
disposition: 'CREATE',
|
|
note: 'Nested in the auto-tune POST body; same create-time semantics as its parent.'
|
|
},
|
|
CreateChannelFromLineupAdvancedOptionsRequest: {
|
|
// MUST-NOT-ANNOTATE rather than CREATE, so the prohibition is asserted rather than merely
|
|
// described: annotating this one is a BUG for the same reason as the server-computed case, just
|
|
// arrived at differently. Cold review pointed out that CREATE was permissive here while the note
|
|
// said annotating would break the semantics.
|
|
disposition: 'MUST-NOT-ANNOTATE',
|
|
note: 'POST /channels/from-lineup. Omission is LOAD-BEARING: an omitted override means INHERIT the template value, with a separate `clear` list to force NONE. `Complete<T>` would collapse that third state into explicit-null.'
|
|
},
|
|
ScanShowRequest: {
|
|
disposition: 'TRIGGER',
|
|
note: 'POST /libraries/{id}/scan-show starts a scan; `deepScan` parameterises the action and replaces no entity.'
|
|
},
|
|
ChannelLogoResponseModel: {
|
|
disposition: 'RESPONSE-ONLY',
|
|
note: 'Read model. Same computed get-only trio as ArtworkContentTypeModel.'
|
|
},
|
|
LibraryBrowseItemResponseModel: {
|
|
disposition: 'RESPONSE-ONLY',
|
|
note: 'Read model for the library browser; `subtitle`/`seasonId` are present only for the item kinds that have them. The SPA never sends it.'
|
|
},
|
|
MultiCollectionItemResponseModel: {
|
|
disposition: 'RESPONSE-ONLY',
|
|
note: 'Read model. The REQUEST twin of this schema is dispositioned ANNOTATED above.'
|
|
},
|
|
ProblemDetails: { disposition: 'RESPONSE-ONLY', note: 'RFC 7807 error payload; never sent by the SPA.' },
|
|
ValidationProblemDetails: {
|
|
disposition: 'RESPONSE-ONLY',
|
|
note: 'RFC 7807 validation error payload returned by the API on a 400; never constructed or sent by the SPA.'
|
|
}
|
|
};
|
|
|
|
// --- discovery ------------------------------------------------------------------------------
|
|
|
|
const rawSourceModules = import.meta.glob('/src/**/*.{ts,tsx,mts,cts}', {
|
|
query: '?raw',
|
|
import: 'default',
|
|
eager: true
|
|
}) as Record<string, string>;
|
|
|
|
const GENERATED_DTS_PATH = 'src/api/generated/v1.d.ts';
|
|
|
|
/** Glob keys are root-absolute (`/src/x.ts`); the git index spells them root-relative. */
|
|
function globKeyToRootRelativePath(key: string): string {
|
|
return key.startsWith('/') ? key.slice(1) : key;
|
|
}
|
|
|
|
/**
|
|
* In scope for the ANNOTATION scan: tracked SPA source, excluding generated output and excluding
|
|
* this guard itself (whose `DISPOSITIONS` notes and doc comment mention `Complete<…>` in prose —
|
|
* harmless, since the scanner matches type-reference NODES, but scanning the file that defines the
|
|
* expectation is the "check walks the same tree as its subject" shape and is avoided by construction).
|
|
*
|
|
* Test files are deliberately IN scope, unlike the pageSize guard's population: a `Complete<…>` in a
|
|
* test fixture is a real application of the type, and #807's own regression fixture lives in
|
|
* `ffmpegProfiles.test.ts`. They are scanned but cannot DISCHARGE a production obligation — see
|
|
* `isProductionSource`.
|
|
*
|
|
* The `*.guard.test.ts` exclusion is broader than this file and has a stated cost:
|
|
* `completeRequest.guard.test.ts` annotates synthetic inline types (`Complete<{ a: number }>`) to
|
|
* prove the type's semantics, which resolve to no schema and would fail the unresolved assertion for
|
|
* no benefit. The consequence to accept: a MUST-NOT-ANNOTATE violation written inside a
|
|
* `*.guard.test.ts` would not be caught. That is a test-only file annotating a schema it must not,
|
|
* which changes no production behaviour — recorded rather than fixed.
|
|
*/
|
|
function isInScopeForAnnotations(rootRelativePath: string): boolean {
|
|
if (!/\.(ts|tsx|mts|cts)$/.test(rootRelativePath)) {
|
|
return false;
|
|
}
|
|
return !rootRelativePath.includes('/generated/') && !rootRelativePath.endsWith('.guard.test.ts');
|
|
}
|
|
|
|
interface DiscoveredAnnotation extends CompleteAnnotation {
|
|
file: string;
|
|
/** The generated schema the written alias resolves to, or `undefined` when nothing maps it. */
|
|
schemaName: string | undefined;
|
|
}
|
|
|
|
// Each of these parses the whole tracked population with the compiler API, and several tests need
|
|
// the same result, so they are computed ONCE. Not a micro-optimisation: unmemoised, the annotation
|
|
// scan walked the population twice per call (directly, and again through the alias map) across four
|
|
// tests, and the guard passed in isolation while timing out at vitest's 5s default under full-suite
|
|
// load — green alone, red in `npm test`, which is the worst way for a guard to fail. The inputs are
|
|
// module-level immutable strings, so caching cannot mask a mutation: each `vitest run` is a fresh
|
|
// process that re-reads the tree.
|
|
let cachedSources: { file: string; text: string }[] | undefined;
|
|
let cachedAliases: Map<string, string> | undefined;
|
|
let cachedAnnotations: DiscoveredAnnotation[] | undefined;
|
|
let cachedOptionalMembers: Map<string, string[]> | undefined;
|
|
|
|
function trackedInScopeSources(): { file: string; text: string }[] {
|
|
if (cachedSources === undefined) {
|
|
const tracked = new Set(trackedSources.tracked);
|
|
cachedSources = Object.entries(rawSourceModules)
|
|
.map(([key, text]) => ({ file: globKeyToRootRelativePath(key), text }))
|
|
.filter(({ file }) => tracked.has(file) && isInScopeForAnnotations(file))
|
|
.sort((a, b) => a.file.localeCompare(b.file));
|
|
}
|
|
return cachedSources;
|
|
}
|
|
|
|
/** localName -> generated schema key, built across the WHOLE population (aliases cross files). */
|
|
function schemaAliasMap(): Map<string, string> {
|
|
if (cachedAliases === undefined) {
|
|
const map = new Map<string, string>();
|
|
for (const { file, text } of trackedInScopeSources()) {
|
|
for (const alias of scanSchemaAliases(text, file)) {
|
|
map.set(alias.localName, alias.schemaName);
|
|
}
|
|
}
|
|
cachedAliases = map;
|
|
}
|
|
return cachedAliases;
|
|
}
|
|
|
|
function discoverAnnotations(): DiscoveredAnnotation[] {
|
|
if (cachedAnnotations === undefined) {
|
|
const aliases = schemaAliasMap();
|
|
const found: DiscoveredAnnotation[] = [];
|
|
for (const { file, text } of trackedInScopeSources()) {
|
|
for (const annotation of scanCompleteAnnotations(text, file)) {
|
|
found.push({ ...annotation, file, schemaName: aliases.get(annotation.typeName) });
|
|
}
|
|
}
|
|
cachedAnnotations = found;
|
|
}
|
|
return cachedAnnotations;
|
|
}
|
|
|
|
/**
|
|
* A source file whose annotations can DISCHARGE a production obligation.
|
|
*
|
|
* Test files are scanned (a `Complete<…>` there is still an application, and #807's own regression
|
|
* fixture lives in one) but they cannot satisfy the requirement that a schema be annotated in
|
|
* production. Cold review demonstrated why with a working bypass: strip `Complete<>` off the
|
|
* `updateFFmpegProfile` WRAPPER and retype `ffmpegProfiles.test.ts`'s fixture to name
|
|
* `UpdateFFmpegProfileRequest`, and a per-schema "annotated somewhere" rule stays green while the
|
|
* wrapper stops protecting every future caller.
|
|
*/
|
|
function isProductionSource(rootRelativePath: string): boolean {
|
|
// `.spec.` and `setupTests.ts` are here because cold review measured the `.test.`-only form false:
|
|
// vitest's own default include is `**/*.{test,spec}.?(c|m)[jt]s?(x)` (measured on 4.1.9), so a `*.spec.ts` fixture was
|
|
// a TEST to the runner and PRODUCTION to this predicate, and could discharge the obligation while
|
|
// the real builder lost its annotation. Same for the setup file, which vitest loads for every run.
|
|
//
|
|
// WHAT THIS COVERS, bounded rather than universal — an earlier version of this comment said it had
|
|
// to be true "in all of the spellings vitest accepts", and review measured THAT false in turn:
|
|
// `*.test.*`, `*.spec.*`, and the ONE setup file named in `vite.config.ts`, whose path is pinned by
|
|
// the scope-pin test so a rename reddens. NOT covered: a SECOND `setupFiles` entry. vitest would
|
|
// load it every run, so an annotation there discharges the obligation — measured, with the real
|
|
// wrapper unprotected and this guard green. Deriving `setupFiles` from the vite config needs
|
|
// another virtual-module plugin and is not proportionate to a three-site coordinated edit; the
|
|
// residual is stated instead, which is the same tolerance `pageSizeCallSites.guard.test.ts` takes.
|
|
return !/\.(test|spec)\.(ts|tsx|mts|cts)$/.test(rootRelativePath) && rootRelativePath !== 'src/setupTests.ts';
|
|
}
|
|
|
|
function generatedDtsText(): string {
|
|
const text = rawSourceModules[`/${GENERATED_DTS_PATH}`];
|
|
if (text === undefined) {
|
|
throw new Error(
|
|
`completeAnnotations guard: the glob supplied no content for ${GENERATED_DTS_PATH}. The ` +
|
|
`population of droppable schemas is read from that file, so an empty read must fail rather ` +
|
|
`than report every schema as having no optional member.`
|
|
);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function allSchemaOptionalMembers(): Map<string, string[]> {
|
|
if (cachedOptionalMembers === undefined) {
|
|
cachedOptionalMembers = scanOptionalSchemaMembers(generatedDtsText());
|
|
}
|
|
return cachedOptionalMembers;
|
|
}
|
|
|
|
function schemasWithOptionalMembers(): Map<string, string[]> {
|
|
return new Map([...allSchemaOptionalMembers().entries()].filter(([, optionals]) => optionals.length > 0));
|
|
}
|
|
|
|
// --- the guard ------------------------------------------------------------------------------
|
|
|
|
describe('Complete<T> application guard (#820)', () => {
|
|
it('scans a healthy number of TRACKED source files (anti-vacuity: a broken glob or index read must not pass on zero input)', () => {
|
|
expect(trackedInScopeSources().length).toBeGreaterThan(200);
|
|
}, 20000);
|
|
|
|
it('parses a healthy number of schemas out of the generated types (anti-vacuity: a broken d.ts parse must not report zero droppable schemas)', () => {
|
|
expect(allSchemaOptionalMembers().size).toBeGreaterThan(100);
|
|
}, 20000);
|
|
|
|
it('discovers a healthy number of Complete<…> annotations (anti-vacuity: a broken scan must not pass on zero matches)', () => {
|
|
expect(discoverAnnotations().length).toBeGreaterThan(25);
|
|
}, 20000);
|
|
|
|
it('matches the schemas that can DROP A MEMBER exactly against the reviewed dispositions (not a non-empty check)', () => {
|
|
const discovered = new Set(schemasWithOptionalMembers().keys());
|
|
const registered = new Set(Object.keys(DISPOSITIONS));
|
|
|
|
const undispositioned = [...discovered].filter((s) => !registered.has(s)).sort();
|
|
const stale = [...registered].filter((s) => !discovered.has(s)).sort();
|
|
|
|
if (undispositioned.length > 0 || stale.length > 0) {
|
|
const optionals = schemasWithOptionalMembers();
|
|
throw new Error(
|
|
[
|
|
`UNDISPOSITIONED (${undispositioned.length}) — schema(s) with an optional member and no row in DISPOSITIONS.`,
|
|
'A member outside the OpenAPI `required` array can be silently dropped by a SPA builder; say what should happen about it.',
|
|
...undispositioned.map((s) => ` + ${s} (optional: ${optionals.get(s)?.join(', ')})`),
|
|
`STALE (${stale.length}) — DISPOSITIONS row(s) whose schema no longer has any optional member:`,
|
|
...stale.map((s) => ` - ${s}`)
|
|
].join('\n')
|
|
);
|
|
}
|
|
}, 20000);
|
|
|
|
it('every schema dispositioned ANNOTATED actually carries a Complete<…> annotation in the SPA', () => {
|
|
// THE point of this file: it converts the Python registry's `COVERED` wording — "the builder is
|
|
// annotated `Complete<T>`" — from a claim into a check. Reverting a covered site to its
|
|
// pre-#807 form reddens here.
|
|
const annotatedSchemas = new Set(
|
|
discoverAnnotations()
|
|
.filter((a) => isProductionSource(a.file))
|
|
.map((a) => a.schemaName)
|
|
.filter((s): s is string => s !== undefined)
|
|
);
|
|
const required = Object.entries(DISPOSITIONS)
|
|
.filter(([, v]) => v.disposition === 'ANNOTATED')
|
|
.map(([schema]) => schema)
|
|
.sort();
|
|
|
|
const unannotated = required.filter((s) => !annotatedSchemas.has(s));
|
|
expect(
|
|
unannotated,
|
|
`Dispositioned ANNOTATED but no \`Complete<…>\` names them anywhere in the SPA: ${unannotated.join(', ')}. ` +
|
|
`Either the annotation was removed (the member can now be silently dropped on a full replace) ` +
|
|
`or the disposition is wrong.`
|
|
).toEqual([]);
|
|
// Anti-vacuity for THIS assertion specifically: an empty `required` would satisfy it trivially.
|
|
expect(required.length).toBeGreaterThan(0);
|
|
}, 20000);
|
|
|
|
it('no schema dispositioned MUST-NOT-ANNOTATE is annotated (§4b: it would force a caller to invent server-computed values)', () => {
|
|
const forbidden = new Set(
|
|
Object.entries(DISPOSITIONS)
|
|
.filter(([, v]) => v.disposition === 'MUST-NOT-ANNOTATE')
|
|
.map(([schema]) => schema)
|
|
);
|
|
const violations = discoverAnnotations()
|
|
.filter((a) => a.schemaName !== undefined && forbidden.has(a.schemaName))
|
|
.map((a) => `${a.file}:${a.line}:${a.column} Complete<${a.typeName}>`);
|
|
|
|
expect(violations, `Annotated a schema whose optional members are computed server-side: ${violations.join(', ')}`).toEqual(
|
|
[]
|
|
);
|
|
expect(forbidden.size).toBeGreaterThan(0);
|
|
}, 20000);
|
|
|
|
it('every Complete<…> resolves to a GENERATED schema, so it checks the contract and not a local mirror', () => {
|
|
// The finding that produced this assertion: `playouts.ts` declared
|
|
// `ReplacePlayoutTemplatesRequest` and `ReplacePlayoutAlternateSchedulesRequest` as hand-written
|
|
// object types that SHADOWED generated schemas of the same name. They dropped no field — they
|
|
// narrowed `null | Array<T>` to `T[]` — but `Complete<T>` over a hand-maintained mirror proves
|
|
// only that the caller filled in the MIRROR, which is the #754 mechanism wearing the annotation
|
|
// that is supposed to prevent it.
|
|
const unresolved = discoverAnnotations()
|
|
.filter((a) => a.schemaName === undefined)
|
|
.map((a) => `${a.file}:${a.line}:${a.column} Complete<${a.typeName}>`);
|
|
|
|
expect(
|
|
unresolved,
|
|
`\`Complete<…>\` over a type that is not an alias of \`components['schemas'][…]\`: ${unresolved.join(', ')}. ` +
|
|
`Alias the generated schema instead of re-declaring its shape.`
|
|
).toEqual([]);
|
|
}, 20000);
|
|
|
|
it('scans nearly ALL tracked TypeScript, so a scope edit cannot quietly drop a quarter of it', () => {
|
|
// Named pins catch a narrowing that happens to hit one of five files; cold review dropped 65 of
|
|
// 257 files with every pin and floor still green. This bounds the whole population instead.
|
|
//
|
|
// The denominator is deliberately NOT computed through `isInScopeForAnnotations`: a helper on
|
|
// both sides of a comparison cancels out of it, which is the #819 finding recorded at length in
|
|
// the pageSize guard. It is a different and cruder expression — every tracked TypeScript file —
|
|
// so the two can actually disagree. The legitimate gap between them is only `/generated/` and
|
|
// `*.guard.test.ts`, about 1.5% today, hence the 95% floor.
|
|
const trackedTypeScript = trackedSources.tracked.filter((p) => /\.(ts|tsx|mts|cts)$/.test(p));
|
|
const scanned = trackedInScopeSources().length;
|
|
expect(trackedTypeScript.length).toBeGreaterThan(200);
|
|
expect(
|
|
scanned / trackedTypeScript.length,
|
|
`only ${scanned} of ${trackedTypeScript.length} tracked TypeScript files reached the scanner. ` +
|
|
`The scope predicate excludes \`/generated/\` and \`*.guard.test.ts\` and nothing else, so a ` +
|
|
`larger shortfall means it was narrowed.`
|
|
).toBeGreaterThan(0.95);
|
|
}, 20000);
|
|
|
|
it('keeps the modules carrying the motivating defects inside the scanned population (scope pin)', () => {
|
|
// A floor cannot see SELECTIVE narrowing: cold review showed that excluding one path from
|
|
// `isInScopeForAnnotations` stays comfortably above every count while erasing this guard's reach
|
|
// over exactly the file whose defect motivated it. These pin the paths rather than the count.
|
|
// `isProductionSource` hardcodes 'src/setupTests.ts', mirroring `vite.config.ts`'s `setupFiles`
|
|
// with nothing coupling them. Renaming the setup file would leave the exclusion stale, vitest
|
|
// would still load the new one for every run, and the round-5 `.spec.ts` bypass would reopen
|
|
// green. Pinning it turns that silent staleness into a red — the same remedy as the paths below.
|
|
expect(
|
|
trackedSources.tracked,
|
|
"src/setupTests.ts moved or was renamed; isProductionSource still excludes the old path"
|
|
).toContain('src/setupTests.ts');
|
|
|
|
const files = new Set(trackedInScopeSources().map((s) => s.file));
|
|
for (const pinned of [
|
|
'src/api/playouts.ts',
|
|
'src/api/ffmpegProfiles.ts',
|
|
'src/api/multiCollections.ts',
|
|
'src/screens/MultiCollectionsScreen.tsx',
|
|
'src/screens/ffmpegProfileDraft.ts'
|
|
]) {
|
|
expect(files.has(pinned), `${pinned} dropped out of the scanned population`).toBe(true);
|
|
}
|
|
}, 20000);
|
|
|
|
it('supplies content for every TRACKED in-scope path (a shortfall is a hole, not a skip)', () => {
|
|
// The glob/index operation is an inner join, so a tracked in-scope path the glob never yielded
|
|
// would vanish from the population silently.
|
|
const globbed = new Set(Object.keys(rawSourceModules).map(globKeyToRootRelativePath));
|
|
const missing = trackedSources.tracked
|
|
.filter((p) => isInScopeForAnnotations(p) && !globbed.has(p))
|
|
.filter((p) => !trackedSources.absentFromDisk.includes(p));
|
|
expect(missing, `Tracked, on-disk, in-scope path(s) the scanner never saw: ${missing.join(', ')}`).toEqual([]);
|
|
}, 20000);
|
|
|
|
it('no file declares its OWN Complete, which would make an annotation an identity type', () => {
|
|
// `Complete<X>` is matched by NAME. A local `type Complete<T> = T` would satisfy every
|
|
// annotation assertion here while providing none of the checking — a false green cold review
|
|
// demonstrated. Nothing declares one today; this keeps it that way.
|
|
const shadows = trackedInScopeSources()
|
|
.filter(({ file }) => file !== 'src/api/completeRequest.ts')
|
|
.filter(({ file, text }) => declaresLocalComplete(text, file))
|
|
.map(({ file }) => file);
|
|
expect(shadows, `Local \`Complete\` declaration(s) shadowing the mapped type: ${shadows.join(', ')}`).toEqual([]);
|
|
}, 20000);
|
|
|
|
it('resolves every alias unambiguously — two files may not map the same local name to different schemas', () => {
|
|
// The alias map is global and last-write-wins, so a collision would silently credit the wrong
|
|
// schema. Detected rather than tolerated.
|
|
const byLocal = new Map<string, Set<string>>();
|
|
for (const { file, text } of trackedInScopeSources()) {
|
|
for (const alias of scanSchemaAliases(text, file)) {
|
|
const set = byLocal.get(alias.localName) ?? new Set<string>();
|
|
set.add(alias.schemaName);
|
|
byLocal.set(alias.localName, set);
|
|
}
|
|
}
|
|
const collisions = [...byLocal.entries()]
|
|
.filter(([, schemas]) => schemas.size > 1)
|
|
.map(([local, schemas]) => `${local} -> {${[...schemas].join(', ')}}`);
|
|
expect(collisions, `Ambiguous local alias name(s): ${collisions.join('; ')}`).toEqual([]);
|
|
}, 20000);
|
|
|
|
it('every disposition row states a reason', () => {
|
|
const unexplained = Object.entries(DISPOSITIONS)
|
|
.filter(([, v]) => v.note.trim().length < 40)
|
|
.map(([schema]) => schema);
|
|
expect(unexplained, `DISPOSITIONS rows with no usable note: ${unexplained.join(', ')}`).toEqual([]);
|
|
}, 20000);
|
|
});
|