From bf8d6fdf924a4050c09870d87ed480578eeb8a90 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 17 Apr 2024 03:11:50 -0400 Subject: [PATCH 01/10] first cherry pick --- ark/attest/__tests__/benchExpectedOutput.ts | 4 +- ark/attest/__tests__/instantiations.test.ts | 15 ++ ark/attest/assert/assertions.ts | 10 +- ark/attest/assert/attest.ts | 27 ++- ark/attest/assert/chainableAssertions.ts | 26 ++- ark/attest/bench/baseline.ts | 12 -- ark/attest/bench/bench.ts | 34 +++- ark/attest/bench/type.ts | 112 ++++++++--- ark/attest/cache/getCachedAssertions.ts | 17 +- ark/attest/cache/snapshots.ts | 100 +++++----- ark/attest/cache/ts.ts | 30 ++- ark/attest/cache/utils.ts | 203 ++++++++++++++++++++ ark/attest/cache/writeAssertionCache.ts | 88 +++------ ark/attest/config.ts | 23 ++- ark/attest/fixtures.ts | 2 +- ark/attest/instantiations.ts | 22 +++ ark/attest/main.ts | 1 - 17 files changed, 528 insertions(+), 198 deletions(-) create mode 100644 ark/attest/__tests__/instantiations.test.ts create mode 100644 ark/attest/cache/utils.ts create mode 100644 ark/attest/instantiations.ts diff --git a/ark/attest/__tests__/benchExpectedOutput.ts b/ark/attest/__tests__/benchExpectedOutput.ts index c3c0240738..db78710ffc 100644 --- a/ark/attest/__tests__/benchExpectedOutput.ts +++ b/ark/attest/__tests__/benchExpectedOutput.ts @@ -37,7 +37,7 @@ type makeComplexType = bench("bench type", () => { return {} as makeComplexType<"defenestration"> -}).types([177, "instantiations"]) +}).types([176, "instantiations"]) bench("bench type from external module", () => { return {} as externalmakeComplexType<"defenestration"> @@ -51,6 +51,6 @@ bench( fakeCallOptions ) .mean([2, "ms"]) - .types([345, "instantiations"]) + .types([344, "instantiations"]) bench("empty", () => {}).types([0, "instantiations"]) diff --git a/ark/attest/__tests__/instantiations.test.ts b/ark/attest/__tests__/instantiations.test.ts new file mode 100644 index 0000000000..f8359311d8 --- /dev/null +++ b/ark/attest/__tests__/instantiations.test.ts @@ -0,0 +1,15 @@ +import { attest } from "@arktype/attest" +import { type } from "arktype" +import { describe, it } from "mocha" + +type makeComplexType = S extends `${infer head}${infer tail}` + ? head | tail | makeComplexType + : S + +describe("instantiations", () => { + it("Can give me instantiations", () => { + const a = {} as makeComplexType<"defenestration"> + attest(type({ a: "Promise" })).snap("(function bound )") + attest.instantiations([6906, "instantiations"]) + }) +}) diff --git a/ark/attest/assert/assertions.ts b/ark/attest/assert/assertions.ts index 2fcbe3ee46..cdb7e73eb5 100644 --- a/ark/attest/assert/assertions.ts +++ b/ark/attest/assert/assertions.ts @@ -1,6 +1,12 @@ -import { printable, throwInternalError } from "@arktype/util" +import { + ReadonlyArray, + isArray, + printable, + throwInternalError +} from "@arktype/util" import { AssertionError } from "node:assert" import * as assert from "node:assert/strict" +import type { VersionedTypeAssertion } from "../cache/getCachedAssertions.js" import type { TypeAssertionData } from "../cache/writeAssertionCache.js" import type { AssertionContext } from "./attest.js" @@ -52,7 +58,7 @@ export const versionableAssertion = for (const [version, data] of ctx.typeAssertionEntries!) { let errorMessage = "" try { - const mapped = actual.fn(data, ctx) + const mapped = actual.fn(data as TypeAssertionData, ctx) if (mapped !== null) { fn( "expected" in mapped ? mapped.expected : expected, diff --git a/ark/attest/assert/attest.ts b/ark/attest/assert/attest.ts index ec26920954..6b665188b2 100644 --- a/ark/attest/assert/attest.ts +++ b/ark/attest/assert/attest.ts @@ -1,9 +1,13 @@ import { caller, getCallStack, type SourcePosition } from "@arktype/fs" import type { inferTypeRoot, validateTypeRoot } from "arktype" +import { getBenchCtx } from "../bench/bench.js" +import type { Measure, TypeUnit } from "../bench/measure.js" +import { instantiationDataHandler } from "../bench/type.js" import { getTypeAssertionsAtPosition, type VersionedTypeAssertion } from "../cache/getCachedAssertions.js" +import type { TypeAssertionData } from "../cache/writeAssertionCache.js" import { getConfig, type AttestConfig } from "../config.js" import { assertEquals, typeEqualityMapping } from "./assertions.js" import { @@ -58,7 +62,7 @@ export const attestInternal = ( } if (!cfg.skipTypes) { ctx.typeAssertionEntries = getTypeAssertionsAtPosition(position) - if (ctx.typeAssertionEntries[0]?.[1].typeArgs[0]) { + if ((ctx.typeAssertionEntries[0]?.[1] as TypeAssertionData).typeArgs[0]) { // if there is an expected type arg, check it immediately assertEquals(undefined, typeEqualityMapping, ctx) } @@ -66,4 +70,23 @@ export const attestInternal = ( return new ChainableAssertions(ctx) } -export const attest: AttestFn = attestInternal as never +const instantiations = () => ({ + instantiations: ( + ...args: [instantiations?: Measure | undefined] + ) => { + const attestConfig = getConfig() + if (attestConfig.skipInlineInstantiations) return + + const calledFrom = caller() + const ctx = getBenchCtx([calledFrom.file]) + ctx.isInlineBench = true + ctx.benchCallPosition = calledFrom + ctx.lastSnapCallPosition = calledFrom + instantiationDataHandler({ ...ctx, kind: "instantiations" }, args[0], false) + } +}) + +export const attest = Object.assign( + attestInternal as AttestFn, + instantiations() +) diff --git a/ark/attest/assert/chainableAssertions.ts b/ark/attest/assert/chainableAssertions.ts index 32eda196e6..2065585d43 100644 --- a/ark/attest/assert/chainableAssertions.ts +++ b/ark/attest/assert/chainableAssertions.ts @@ -13,7 +13,10 @@ import { updateExternalSnapshot, type SnapshotArgs } from "../cache/snapshots.js" -import type { Completions } from "../cache/writeAssertionCache.js" +import type { + Completions, + TypeAssertionData +} from "../cache/writeAssertionCache.js" import { chainableNoOpProxy } from "../utils.js" import { TypeAssertionMapping, @@ -79,7 +82,6 @@ export class ChainableAssertions implements AssertionRecord { assert.equal(this.actual, expected) return this } - equals(expected: unknown): this { assertEquals(expected, this.actual, this.ctx) return this @@ -168,7 +170,7 @@ export class ChainableAssertions implements AssertionRecord { }) } - get throws(): unknown { + get throws() { const result = callAssertedFunction(this.actual as Function) this.ctx.actual = getThrownMessage(result, this.ctx) this.ctx.allowRegex = true @@ -176,7 +178,7 @@ export class ChainableAssertions implements AssertionRecord { return this.immediateOrChained() } - throwsAndHasTypeError(matchValue: string | RegExp): void { + throwsAndHasTypeError(matchValue: string | RegExp) { assertEqualOrMatching( matchValue, getThrownMessage(callAssertedFunction(this.actual as Function), this.ctx), @@ -193,7 +195,21 @@ export class ChainableAssertions implements AssertionRecord { } } - get completions(): any { + instanceOf(expected: Constructor): this { + if (!(this.actual instanceof expected)) { + throwAssertionError({ + ctx: this.ctx, + message: `Expected an instance of ${expected.name} (was ${ + typeof this.actual === "object" && this.actual !== null ? + this.actual.constructor.name + : this.serializedActual + })` + }) + } + return this + } + + get completions() { if (this.ctx.cfg.skipTypes) return chainableNoOpProxy this.ctx.actual = new TypeAssertionMapping(data => { diff --git a/ark/attest/bench/baseline.ts b/ark/attest/bench/baseline.ts index f2c4e04ea1..dc50f113de 100644 --- a/ark/attest/bench/baseline.ts +++ b/ark/attest/bench/baseline.ts @@ -1,9 +1,6 @@ -import { ensureDir } from "@arktype/fs" import { snapshot } from "@arktype/util" -import { rmSync } from "node:fs" import process from "node:process" import { queueSnapshotUpdate } from "../cache/snapshots.js" -import { getConfig } from "../config.js" import type { BenchAssertionContext, BenchContext } from "./bench.js" import { stringifyMeasure, @@ -12,8 +9,6 @@ import { type MeasureComparison } from "./measure.js" -let isFirstQueuedUpdate = true - export const queueBaselineUpdateIfNeeded = ( updated: Measure | MarkMeasure, baseline: Measure | MarkMeasure | undefined, @@ -28,13 +23,6 @@ export const queueBaselineUpdateIfNeeded = ( `Unable to update baseline for ${ctx.qualifiedName} ('lastSnapCallPosition' was unset).` ) } - if (isFirstQueuedUpdate) { - // remove any leftover cached snaps before the first is written - const { benchSnapCacheDir } = getConfig() - rmSync(benchSnapCacheDir, { recursive: true, force: true }) - ensureDir(benchSnapCacheDir) - isFirstQueuedUpdate = false - } queueSnapshotUpdate({ position: ctx.lastSnapCallPosition, serializedValue, diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts index 4826c6dfcc..9e2ab37247 100644 --- a/ark/attest/bench/bench.ts +++ b/ark/attest/bench/bench.ts @@ -36,10 +36,11 @@ export type BenchContext = { benchCallPosition: SourcePosition lastSnapCallPosition: SourcePosition | undefined isAsync: boolean + isInlineBench: boolean } export type BenchAssertionContext = BenchContext & { - kind: TimeAssertionName | "types" + kind: TimeAssertionName | "types" | "instantiations" } export type BenchableFunction = () => unknown | Promise @@ -55,15 +56,12 @@ export const bench = ( options: BenchOptions = {} ): InitialBenchAssertions => { const qualifiedPath = [...currentSuitePath, name] - const ctx: BenchContext = { + const ctx = getBenchCtx( qualifiedPath, - qualifiedName: qualifiedPath.join("/"), - options, - cfg: getConfig(), - benchCallPosition: caller(), - lastSnapCallPosition: undefined, - isAsync: fn.constructor.name === "AsyncFunction" - } + fn.constructor.name === "AsyncFunction", + options + ) + ctx.benchCallPosition = caller() ensureCacheDirs() if ( typeof ctx.cfg.filter === "string" && @@ -80,3 +78,21 @@ export const bench = ( Object.assign(assertions, createBenchTypeAssertion(ctx)) return assertions as any } + +export const getBenchCtx = ( + qualifiedPath: string[], + isAsync: boolean = false, + options: BenchOptions = {}, + isInlineBench = false +) => { + return { + qualifiedPath, + qualifiedName: qualifiedPath.join("/"), + options, + cfg: getConfig(), + benchCallPosition: caller(), + lastSnapCallPosition: undefined, + isAsync, + isInlineBench + } as BenchContext +} diff --git a/ark/attest/bench/type.ts b/ark/attest/bench/type.ts index 45a127e793..cd38d47ffc 100644 --- a/ark/attest/bench/type.ts +++ b/ark/attest/bench/type.ts @@ -1,20 +1,24 @@ -import { caller, filePath } from "@arktype/fs" -import { throwInternalError } from "@arktype/util" -import * as tsvfs from "@typescript/vfs" +import { getTypeAssertionsAtPosition } from "@arktype/attest" +import { caller, type LinePositionRange } from "@arktype/fs" import ts from "typescript" import { TsServer, getAbsolutePosition, getAncestors, getDescendants, - getInternalTypeChecker, - getTsConfigInfoOrThrow, - getTsLibFiles, nearestCallExpressionChild } from "../cache/ts.js" -import { getExpressionsByName } from "../cache/writeAssertionCache.js" +import { + getCallExpressionsByName, + getInstantiationsContributedByNode +} from "../cache/utils.js" +import type { + Completions, + TypeRelationship +} from "../cache/writeAssertionCache.js" +import { getConfig } from "../config.js" import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" -import type { BenchContext } from "./bench.js" +import type { BenchAssertionContext, BenchContext } from "./bench.js" import { createTypeComparison, type Measure, @@ -126,33 +130,79 @@ export const createBenchTypeAssertion = ( ): BenchTypeAssertions => ({ types: (...args: [instantiations?: Measure | undefined]) => { ctx.lastSnapCallPosition = caller() - const instance = TsServer.instance - const file = instance.getSourceFileOrThrow(ctx.benchCallPosition.file) + instantiationDataHandler({ ...ctx, kind: "types" }, args[0]) + } +}) + +export const getContributedInstantiations = (ctx: BenchContext): number => { + const expressionsToFind = getConfig().expressionsToFind + const instance = TsServer.instance + const file = instance.getSourceFileOrThrow(ctx.benchCallPosition.file) + + const node = nearestCallExpressionChild( + file, + getAbsolutePosition(file, ctx.benchCallPosition) + ) + + const firstMatchingNamedCall = getAncestors(node).find( + call => getCallExpressionsByName(call, expressionsToFind).length + ) - const benchNode = nearestCallExpressionChild( - file, - getAbsolutePosition(file, ctx.benchCallPosition) + if (!firstMatchingNamedCall) { + throw new Error( + `No call expressions matching the name(s) '${expressionsToFind.join()}' were found` ) - const benchFn = getExpressionsByName(benchNode, ["bench"]) - if (!benchFn) throw new Error("Unable to retrieve bench expression node.") + } - const benchBody = getDescendants(benchFn[0]).find( - node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) - ) as ts.ArrowFunction | ts.FunctionExpression | undefined + const body = getDescendants(firstMatchingNamedCall).find( + node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) + ) as ts.ArrowFunction | ts.FunctionExpression | undefined + const benchNode = nearestCallExpressionChild( + file, + getAbsolutePosition(file, ctx.benchCallPosition) + ) + const benchFn = getExpressionsByName(benchNode, ["bench"]) + if (!benchFn) throw new Error("Unable to retrieve bench expression node.") - if (!benchBody) throw new Error("Unable to retrieve bench body node.") + const benchBody = getDescendants(benchFn[0]).find( + node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) + ) as ts.ArrowFunction | ts.FunctionExpression | undefined - const instantiationsContributed = - getInstantiationsContributedByNode(benchBody) + if (!body) + throw new Error("Unable to retrieve contents of the call expression") - const comparison: MeasureComparison = createTypeComparison( - instantiationsContributed, - args[0] - ) - compareToBaseline(comparison, ctx) - queueBaselineUpdateIfNeeded(comparison.updated, args[0], { - ...ctx, - kind: "types" - }) + return getInstantiationsContributedByNode(file, body) +} + +export type ArgAssertionData = { + type: string + relationships: { + args: TypeRelationship[] + typeArgs: TypeRelationship[] } -}) +} +export type TypeAssertionData = { + location: LinePositionRange + args: ArgAssertionData[] + typeArgs: ArgAssertionData[] + errors: string[] + completions: Completions + count: number +} + +export const instantiationDataHandler = ( + ctx: BenchAssertionContext, + args?: Measure, + isBench = true +): void => { + const instantiationsContributed = + isBench ? + getContributedInstantiations(ctx) + : getTypeAssertionsAtPosition(ctx.benchCallPosition)[0][1].count! + const comparison: MeasureComparison = createTypeComparison( + instantiationsContributed, + args + ) + compareToBaseline(comparison, ctx) + queueBaselineUpdateIfNeeded(comparison.updated, args, ctx) +} diff --git a/ark/attest/cache/getCachedAssertions.ts b/ark/attest/cache/getCachedAssertions.ts index d71d50bb8a..db8caabf40 100644 --- a/ark/attest/cache/getCachedAssertions.ts +++ b/ark/attest/cache/getCachedAssertions.ts @@ -1,21 +1,26 @@ -import { readJson, type LinePosition, type SourcePosition } from "@arktype/fs" +import { + readJson, + type LinePosition, + type LinePositionRange, + type SourcePosition +} from "@arktype/fs" import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import { getConfig } from "../config.js" import { getFileKey } from "../utils.js" import type { AssertionsByFile, - LinePositionRange, + LocationAndCountAssertionData, TypeAssertionData } from "./writeAssertionCache.js" -export type VersionedAssertionsByFile = [ +export type VerionedAssertionsByFile = [ tsVersion: string, assertions: AssertionsByFile ] -let assertionEntries: VersionedAssertionsByFile[] | undefined -export const getCachedAssertionEntries = (): VersionedAssertionsByFile[] => { +let assertionEntries: VerionedAssertionsByFile[] | undefined +export const getCachedAssertionEntries = (): VerionedAssertionsByFile[] => { if (!assertionEntries) { const config = getConfig() if (!existsSync(config.assertionCacheDir)) @@ -53,7 +58,7 @@ const isPositionWithinRange = ( export type VersionedTypeAssertion = [ tsVersion: string, - assertionData: TypeAssertionData + assertionData: TypeAssertionData | LocationAndCountAssertionData ] export const getTypeAssertionsAtPosition = ( diff --git a/ark/attest/cache/snapshots.ts b/ark/attest/cache/snapshots.ts index a3cb9d5373..0ef621690c 100644 --- a/ark/attest/cache/snapshots.ts +++ b/ark/attest/cache/snapshots.ts @@ -8,8 +8,7 @@ import { writeJson, type SourcePosition } from "@arktype/fs" -import { randomUUID } from "node:crypto" -import { existsSync, readdirSync, rmSync } from "node:fs" +import { existsSync } from "node:fs" import { basename, dirname, isAbsolute, join } from "node:path" import type ts from "typescript" import { getConfig } from "../config.js" @@ -19,7 +18,7 @@ import { getAbsolutePosition, nearestCallExpressionChild } from "./ts.js" -import { getExpressionsByName } from "./writeAssertionCache.js" +import { getCallExpressionsByName } from "./utils.js" export type SnapshotArgs = { position: SourcePosition @@ -41,7 +40,7 @@ export const getSnapshotByName = ( file: string, name: string, customPath: string | undefined -): object => { +) => { const snapshotPath = resolveSnapshotPath(file, customPath) return readJson(snapshotPath)?.[basename(file)]?.[name] } @@ -50,17 +49,10 @@ export const getSnapshotByName = ( * Writes the update and position to cacheDir, which will eventually be read and copied to the source * file by a cleanup process after all tests have completed. */ -export const queueSnapshotUpdate = (args: SnapshotArgs): void => { - const isBench = args.baselinePath +export const queueSnapshotUpdate = (args: SnapshotArgs) => { const config = getConfig() - writeJson( - join( - isBench ? config.benchSnapCacheDir : config.snapCacheDir, - `snap-${randomUUID()}.json` - ), - args - ) - if (isBench) writeSnapshotUpdatesOnExit() + writeSnapUpdate(config.defaultAssertionCachePath, args) + writeSnapshotUpdatesOnExit() } export type QueuedUpdate = { @@ -84,7 +76,7 @@ const findCallExpressionAncestor = ( const file = server.getSourceFileOrThrow(position.file) const absolutePosition = getAbsolutePosition(file, position) const startNode = nearestCallExpressionChild(file, absolutePosition) - const calls = getExpressionsByName(startNode, [functionName], true) + const calls = getCallExpressionsByName(startNode, [functionName], true) if (calls.length) return startNode throw new Error( @@ -99,7 +91,7 @@ export const updateExternalSnapshot = ({ position, name, customPath -}: ExternalSnapshotArgs): void => { +}: ExternalSnapshotArgs) => { const snapshotPath = resolveSnapshotPath(position.file, customPath) const snapshotData = readJson(snapshotPath) ?? {} const fileKey = basename(position.file) @@ -118,47 +110,50 @@ export const writeSnapshotUpdatesOnExit = (): void => { snapshotsWillBeWritten = true } -/** - * This will fail if you have a sub process that writes cached snapshots and then deletes the snapshot cache that the root - * process is using - */ const writeCachedInlineSnapshotUpdates = () => { const config = getConfig() const updates: QueuedUpdate[] = [] - if (existsSync(config.snapCacheDir)) - updates.push(...getQueuedUpdates(config.snapCacheDir)) - if (existsSync(config.benchSnapCacheDir)) - updates.push(...getQueuedUpdates(config.benchSnapCacheDir)) + if (existsSync(config.assertionCacheDir)) + updates.push(...getQueuedUpdates(config.defaultAssertionCachePath)) writeUpdates(updates) - rmSync(config.snapCacheDir, { recursive: true, force: true }) - rmSync(config.benchSnapCacheDir, { recursive: true, force: true }) + writeSnapUpdate(config.defaultAssertionCachePath) } -const getQueuedUpdates = (dir: string) => { - const queuedUpdates: QueuedUpdate[] = [] - for (const updateFile of readdirSync(dir)) { - if (/snap.*\.json$/.test(updateFile)) { - let snapshotData: SnapshotArgs | undefined - try { - snapshotData = readJson(join(dir, updateFile)) - } catch { - // If we can't read the snapshot, log an error and move onto the next update - console.error( - `Unable to read snapshot data from expected location ${updateFile}.` - ) - } - if (snapshotData) { - try { - queuedUpdates.push(snapshotArgsToQueuedUpdate(snapshotData)) - } catch (error) { - // If writeInlineSnapshotToFile throws an error, log it and move on to the next update - console.error(String(error)) - } - } +const writeSnapUpdate = (path: string, update?: SnapshotArgs) => { + const assertions = readJson(path) + const updates = assertions.updates ?? [] + if (update !== undefined) assertions.updates = [...updates, update] + else assertions.updates = [] + + writeJson(path, assertions) +} +const updateQueue = (queue: QueuedUpdate[], path: string) => { + let snapshotData: SnapshotArgs[] | undefined + try { + snapshotData = readJson(path).updates + } catch { + // If we can't read the snapshot, log an error and move onto the next update + console.error( + `Unable to read snapshot data from expected location ${path}.` + ) + } + if (snapshotData) { + try { + snapshotData.forEach(snapshot => + queue.push(snapshotArgsToQueuedUpdate(snapshot)) + ) + } catch (error) { + // If writeInlineSnapshotToFile throws an error, log it and move on to the next update + console.error(String(error)) } } +} + +const getQueuedUpdates = (path: string) => { + const queuedUpdates: QueuedUpdate[] = [] + updateQueue(queuedUpdates, path) return queuedUpdates } @@ -201,11 +196,14 @@ export const writeUpdates = (queuedUpdates: QueuedUpdate[]): void => { ) ) } - runPrettierIfAvailable(queuedUpdates) + runFormatterIfAvailable(queuedUpdates) } -const runPrettierIfAvailable = (queuedUpdates: QueuedUpdate[]) => { +const runFormatterIfAvailable = (queuedUpdates: QueuedUpdate[]) => { try { + const { formatter, shouldFormat } = getConfig() + if (!shouldFormat) throw new Error() + const updatedPaths = [ ...new Set( queuedUpdates.map(update => @@ -213,9 +211,9 @@ const runPrettierIfAvailable = (queuedUpdates: QueuedUpdate[]) => { ) ) ] - shell(`npm exec --no -- prettier --write ${updatedPaths.join(" ")}`) + shell(`${formatter} ${updatedPaths.join(" ")}`) } catch { - // If prettier is unavailable, do nothing. + // If formatter is unavailable or skipped, do nothing. } } diff --git a/ark/attest/cache/ts.ts b/ark/attest/cache/ts.ts index b5eb8bb07b..3bccace316 100644 --- a/ark/attest/cache/ts.ts +++ b/ark/attest/cache/ts.ts @@ -1,4 +1,5 @@ import { fromCwd, type SourcePosition } from "@arktype/fs" +import { throwInternalError } from "@arktype/util" import * as tsvfs from "@typescript/vfs" import { readFileSync } from "node:fs" import { dirname, join } from "node:path" @@ -98,13 +99,12 @@ export type TsconfigInfo = { } export const getTsConfigInfoOrThrow = (): TsconfigInfo => { - const config = getConfig() + const config = getConfig().tsconfig const configFilePath = - config.tsconfig ?? - ts.findConfigFile(fromCwd(), ts.sys.fileExists, "tsconfig.json") + config ?? ts.findConfigFile(fromCwd(), ts.sys.fileExists, "tsconfig.json") if (!configFilePath) { throw new Error( - `File ${config.tsconfig ?? join(fromCwd(), "tsconfig.json")} must exist.` + `File ${config ?? join(fromCwd(), "tsconfig.json")} must exist.` ) } @@ -128,10 +128,8 @@ export const getTsConfigInfoOrThrow = (): TsconfigInfo => { {}, configFilePath ) - // ensure type.toString is as precise as possible configParseResult.options.noErrorTruncation = true - if (configParseResult.errors.length > 0) { throw new Error( ts.formatDiagnostics(configParseResult.errors, { @@ -224,9 +222,23 @@ const getDescendantsRecurse = (node: ts.Node): ts.Node[] => [ export const getAncestors = (node: ts.Node): ts.Node[] => { const ancestors: ts.Node[] = [] - while (node.parent) { - ancestors.push(node) - node = node.parent + let baseNode = node + if (baseNode.parent) { + baseNode = baseNode.parent + ancestors.push(baseNode) + } + while (baseNode.parent !== undefined) { + baseNode = baseNode.parent + ancestors.push(baseNode) } return ancestors } + +export const getFirstAncestorByKindOrThrow = ( + node: ts.Node, + kind: ts.SyntaxKind +) => + getAncestors(node).find((ancestor) => ancestor.kind === kind) ?? + throwInternalError( + `Could not find an ancestor of kind ${ts.SyntaxKind[kind]}` + ) diff --git a/ark/attest/cache/utils.ts b/ark/attest/cache/utils.ts new file mode 100644 index 0000000000..2c8c4bdc4e --- /dev/null +++ b/ark/attest/cache/utils.ts @@ -0,0 +1,203 @@ +import { filePath, type LinePositionRange } from "@arktype/fs" +import { throwInternalError } from "@arktype/util" +import * as tsvfs from "@typescript/vfs" +import ts from "typescript" +import { getConfig } from "../config.js" +import { getFileKey } from "../utils.js" +import { + getDescendants, + getFirstAncestorByKindOrThrow, + getProgram, + getTsConfigInfoOrThrow, + getTsLibFiles +} from "./ts.js" +import type { AssertionsByFile } from "./writeAssertionCache.js" + +export const getCallLocationFromCallExpression = ( + callExpression: ts.CallExpression +): LinePositionRange => { + const start = ts.getLineAndCharacterOfPosition( + callExpression.getSourceFile(), + callExpression.getStart() + ) + const end = ts.getLineAndCharacterOfPosition( + callExpression.getSourceFile(), + callExpression.getEnd() + ) + // Add 1 to everything, since trace positions are 1-based and TS positions are 0-based. + const location: LinePositionRange = { + start: { + line: start.line + 1, + char: start.character + 1 + }, + end: { + line: end.line + 1, + char: end.character + 1 + } + } + return location +} + +export const gatherInlineInstantiationData = ( + file: ts.SourceFile, + fileAssertions: AssertionsByFile, + inlineInsantiationMatcher: RegExp +): void => { + const fileText = file.getFullText() + const methodCall = "attest.instantiations" + if (inlineInsantiationMatcher.test(fileText)) { + const expressions = getCallExpressionsByName(file, [methodCall]) + const enclosingFunctions = expressions.map((expression) => { + const attestInstantiationsExpression = getFirstAncestorByKindOrThrow( + expression, + ts.SyntaxKind.ExpressionStatement + ) + return { + ancestor: getFirstAncestorByKindOrThrow( + attestInstantiationsExpression, + ts.SyntaxKind.ExpressionStatement + ), + position: getCallLocationFromCallExpression(expression) + } + }) + const instantiationInfo = enclosingFunctions.map((enclosingFunction) => { + const body = getDescendants(enclosingFunction.ancestor).find( + (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) + ) as ts.ArrowFunction | ts.FunctionExpression | undefined + if (!body) { + throw new Error("Unable to find file contents") + } + + return { + location: enclosingFunction.position, + count: getInstantiationsContributedByNode(file, body) + } + }) + const assertions = fileAssertions[getFileKey(file.fileName)] ?? [] + fileAssertions[getFileKey(file.fileName)] = [ + ...assertions, + ...instantiationInfo + ] + } +} + +export const getCallExpressionsByName = ( + startNode: ts.Node, + names: string[], + isSnapCall = false +): ts.CallExpression[] => { + const calls: ts.CallExpression[] = [] + getDescendants(startNode).forEach((descendant) => { + if (ts.isCallExpression(descendant)) { + if (names.includes(descendant.expression.getText()) || !names.length) { + calls.push(descendant) + } + } else if (isSnapCall) { + if (ts.isIdentifier(descendant)) { + if (names.includes(descendant.getText()) || !names.length) { + calls.push(descendant as any as ts.CallExpression) + } + } + } + }) + return calls +} + +const instantiationsByPath: { [path: string]: number } = {} + +export const getInstantiationsContributedByNode = ( + file: ts.SourceFile, + benchBlock: ts.FunctionExpression | ts.ArrowFunction +): number => { + const originalPath = filePath(file.fileName) + const fakePath = originalPath + ".nonexistent.ts" + const inlineInsantiationMatcher = getConfig().inlineInstantiationMatcher + + const baselineFile = getBaselineSourceFile(file) + + const baselineFileWithBenchBlock = + baselineFile + + `\nconst $attestIsolatedBench = ${benchBlock + .getFullText() + .replaceAll(inlineInsantiationMatcher, "")}` + + if (!instantiationsByPath[fakePath]) { + console.log(`⏳ attest: Analyzing type assertions...`) + const instantiationsWithoutNode = getInstantiationsWithFile( + baselineFile, + fakePath + ) + + instantiationsByPath[fakePath] = instantiationsWithoutNode + console.log(`⏳ Cached type assertions \n`) + } + + const instantiationsWithNode = getInstantiationsWithFile( + baselineFileWithBenchBlock, + fakePath + ) + + return instantiationsWithNode - instantiationsByPath[fakePath] +} + +export const createOrUpdateFile = ( + env: tsvfs.VirtualTypeScriptEnvironment, + fileName: string, + fileText: string +): ts.SourceFile | undefined => { + env.sys.fileExists(fileName) + ? env.updateFile(fileName, fileText) + : env.createFile(fileName, fileText) + return env.getSourceFile(fileName) +} + +const getInstantiationsWithFile = (fileText: string, fileName: string) => { + const env = getIsolatedEnv() + const file = createOrUpdateFile(env, fileName, fileText) + const program = getProgram(env) + program.emit(file) + const count = program.getInstantiationCount() + if (count === undefined) { + throwInternalError(`Unable to gather instantiation count for ${fileText}`) + } + return count +} + +let virtualEnv: tsvfs.VirtualTypeScriptEnvironment | undefined = undefined + +export const getIsolatedEnv = (): tsvfs.VirtualTypeScriptEnvironment => { + if (virtualEnv !== undefined) { + return virtualEnv + } + const tsconfigInfo = getTsConfigInfoOrThrow() + const libFiles = getTsLibFiles(tsconfigInfo.parsed.options) + const projectRoot = process.cwd() + const system = tsvfs.createFSBackedSystem( + libFiles.defaultMapFromNodeModules, + projectRoot, + ts + ) + virtualEnv = tsvfs.createVirtualTypeScriptEnvironment( + system, + [], + ts, + tsconfigInfo.parsed.options + ) + return virtualEnv +} + +const getBaselineSourceFile = (originalFile: ts.SourceFile): string => { + const functionNames = getConfig().expressionsToFind + + const calls = getCallExpressionsByName(originalFile, functionNames) + + let baselineSourceFileText = originalFile.getFullText() + + calls.forEach((call) => { + baselineSourceFileText = baselineSourceFileText.replace( + call.getFullText(), + "" + ) + }) + return baselineSourceFileText +} diff --git a/ark/attest/cache/writeAssertionCache.ts b/ark/attest/cache/writeAssertionCache.ts index 2897a107a1..8f08700746 100644 --- a/ark/attest/cache/writeAssertionCache.ts +++ b/ark/attest/cache/writeAssertionCache.ts @@ -1,6 +1,7 @@ -import type { LinePosition } from "@arktype/fs" +import type { LinePositionRange } from "@arktype/fs" import { flatMorph } from "@arktype/util" import ts from "typescript" + import { getConfig } from "../config.js" import { getFileKey } from "../utils.js" import { @@ -11,8 +12,21 @@ import { type ArgumentTypes, type StringifiableType } from "./ts.js" +import { + gatherInlineInstantiationData, + getCallExpressionsByName, + getCallLocationFromCallExpression +} from "./utils.js" + +export type AssertionsByFile = Record< + string, + (TypeAssertionData | LocationAndCountAssertionData)[] +> -export type AssertionsByFile = Record +export type LocationAndCountAssertionData = Pick< + TypeAssertionData, + "location" | "count" +> export const analyzeProjectAssertions = (): AssertionsByFile => { const config = getConfig() @@ -29,6 +43,16 @@ export const analyzeProjectAssertions = (): AssertionsByFile => { ) if (assertionsInFile.length) assertionsByFile[getFileKey(file.fileName)] = assertionsInFile + } + if (!config.skipInlineInstantiations) { + if (path.endsWith(".test.ts")) { + gatherInlineInstantiationData( + file, + assertionsByFile, + config.inlineInstantiationMatcher + ) + } + } } return assertionsByFile } @@ -38,65 +62,16 @@ export const getAssertionsInFile = ( diagnosticsByFile: DiagnosticsByFile, attestAliases: string[] ): TypeAssertionData[] => { - const assertCalls = getExpressionsByName(file, attestAliases) + const assertCalls = getCallExpressionsByName(file, attestAliases) return assertCalls.map(call => analyzeAssertCall(call, diagnosticsByFile)) } -export const getAssertCallLocation = ( - assertCall: ts.CallExpression -): LinePositionRange => { - const start = ts.getLineAndCharacterOfPosition( - assertCall.getSourceFile(), - assertCall.getStart() - ) - const end = ts.getLineAndCharacterOfPosition( - assertCall.getSourceFile(), - assertCall.getEnd() - ) - // Add 1 to everything, since trace positions are 1-based and TS positions are 0-based. - return { - start: { - line: start.line + 1, - char: start.character + 1 - }, - end: { - line: end.line + 1, - char: end.character + 1 - } - } -} - -export const getExpressionsByName = ( - startNode: ts.Node, - names: string[], - isSnapCall = false -): ts.CallExpression[] => { - /* - * We get might get some extraneous calls to other "attest" functions, - * but they won't be referenced at runtime so shouldn't matter. - */ - const calls: ts.CallExpression[] = [] - const visit = (node: ts.Node) => { - if (ts.isCallExpression(node)) { - if (names.includes(node.expression.getText())) calls.push(node) - } else if (isSnapCall) { - if (ts.isIdentifier(node)) { - if (names.includes(node.getText())) - calls.push(node as any as ts.CallExpression) - } - } - ts.forEachChild(node, visit) - } - visit(startNode) - return calls -} - export const analyzeAssertCall = ( assertCall: ts.CallExpression, diagnosticsByFile: DiagnosticsByFile ): TypeAssertionData => { const types = extractArgumentTypesFromCall(assertCall) - const location = getAssertCallLocation(assertCall) + const location = getCallLocationFromCallExpression(assertCall) const args = types.args.map(arg => serializeArg(arg, types)) const typeArgs = types.typeArgs.map(typeArg => serializeArg(typeArg, types)) const errors = checkDiagnosticMessages(assertCall, diagnosticsByFile) @@ -216,11 +191,6 @@ const concatenateChainedErrors = ( ) .join("\n") -export type LinePositionRange = { - start: LinePosition - end: LinePosition -} - export type ArgAssertionData = { type: string relationships: { @@ -228,12 +198,14 @@ export type ArgAssertionData = { typeArgs: TypeRelationship[] } } + export type TypeAssertionData = { location: LinePositionRange args: ArgAssertionData[] typeArgs: ArgAssertionData[] errors: string[] completions: Completions + count?: number } export type TypeRelationship = "subtype" | "supertype" | "equality" | "none" diff --git a/ark/attest/config.ts b/ark/attest/config.ts index e29044606e..5408b01bae 100644 --- a/ark/attest/config.ts +++ b/ark/attest/config.ts @@ -32,10 +32,15 @@ type BaseAttestConfig = { */ tsVersions: TsVersionAliases | TsVersionData[] skipTypes: boolean + skipInlineInstantiations: boolean attestAliases: string[] benchPercentThreshold: number benchErrorOnThresholdExceeded: boolean filter: string | undefined + expressionsToFind: string[] + inlineInstantiationMatcher: RegExp + formatter: string + shouldFormat: true } export type AttestConfig = Partial @@ -49,10 +54,15 @@ export const getDefaultAttestConfig = (): BaseAttestConfig => { attestAliases: ["attest", "attestInternal"], updateSnapshots: false, skipTypes: false, + skipInlineInstantiations: false, tsVersions: "typescript", benchPercentThreshold: 20, benchErrorOnThresholdExceeded: false, - filter: undefined + filter: undefined, + expressionsToFind: ["bench", "it"], + inlineInstantiationMatcher: /attest.instantiations\(.*/g, + formatter: `npm exec --no -- prettier --write`, + shouldFormat: true } } @@ -99,24 +109,21 @@ const addEnvConfig = (config: BaseAttestConfig) => { export interface ParsedAttestConfig extends Readonly { cacheDir: string - snapCacheDir: string - benchSnapCacheDir: string assertionCacheDir: string + defaultAssertionCachePath: string tsVersions: TsVersionData[] } const parseConfig = (): ParsedAttestConfig => { const baseConfig = addEnvConfig(getDefaultAttestConfig()) const cacheDir = resolve(".attest") - const snapCacheDir = join(cacheDir, "snaps") - const benchSnapCacheDir = join(cacheDir, "benchSnaps") const assertionCacheDir = join(cacheDir, "assertions") + const defaultAssertionCachePath = join(assertionCacheDir, "typescript.json") return Object.assign(baseConfig, { cacheDir, - snapCacheDir, - benchSnapCacheDir, assertionCacheDir, + defaultAssertionCachePath, tsVersions: baseConfig.skipTypes ? [] : isTsVersionAliases(baseConfig.tsVersions) ? @@ -153,7 +160,5 @@ export const getConfig = (): ParsedAttestConfig => cachedConfig export const ensureCacheDirs = (): void => { ensureDir(cachedConfig.cacheDir) - ensureDir(cachedConfig.snapCacheDir) - ensureDir(cachedConfig.benchSnapCacheDir) ensureDir(cachedConfig.assertionCacheDir) } diff --git a/ark/attest/fixtures.ts b/ark/attest/fixtures.ts index 832c17ede2..8600b82598 100644 --- a/ark/attest/fixtures.ts +++ b/ark/attest/fixtures.ts @@ -17,7 +17,7 @@ export const setup = (options: Partial = {}): void => { config.tsVersions.length === 1 && config.tsVersions[0].alias === "typescript" ) - writeAssertionData(join(config.assertionCacheDir, "typescript.json")) + writeAssertionData(config.defaultAssertionCachePath) else { forTypeScriptVersions(config.tsVersions, version => shell( diff --git a/ark/attest/instantiations.ts b/ark/attest/instantiations.ts new file mode 100644 index 0000000000..02ac892404 --- /dev/null +++ b/ark/attest/instantiations.ts @@ -0,0 +1,22 @@ +// import { caller } from "@arktype/fs" +// import { getBenchCtx } from "./bench/bench.js" +// import type { Measure, TypeUnit } from "./bench/measure.js" +// import { instantiationDataHandler } from "./bench/type.js" +// import { getConfig } from "./config.js" + +// export const instantiations = () => ({ +// instantiations: ( +// ...args: [instantiations?: Measure | undefined] +// ) => { +// const attestConfig = getConfig() +// if (attestConfig.skipInlineInstantiations) { +// return +// } +// const calledFrom = caller() +// const ctx = getBenchCtx([calledFrom.file]) +// ctx.isInlineBench = true +// ctx.benchCallPosition = calledFrom +// ctx.lastSnapCallPosition = calledFrom +// instantiationDataHandler({ ...ctx, kind: "instantiations" }, args[0], false) +// } +// }) diff --git a/ark/attest/main.ts b/ark/attest/main.ts index 1f23315ca7..27073c9f5a 100644 --- a/ark/attest/main.ts +++ b/ark/attest/main.ts @@ -4,7 +4,6 @@ export { bench } from "./bench/bench.js" export { getTypeAssertionsAtPosition } from "./cache/getCachedAssertions.js" export type { ArgAssertionData, - LinePositionRange, TypeAssertionData, TypeRelationship } from "./cache/writeAssertionCache.js" From 7713a8e021e326529e7c3e4cc23da321ade724ac Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 17 Apr 2024 03:31:02 -0400 Subject: [PATCH 02/10] add line range --- ark/fs/caller.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ark/fs/caller.ts b/ark/fs/caller.ts index f2e47cd18b..8f5679fc9d 100644 --- a/ark/fs/caller.ts +++ b/ark/fs/caller.ts @@ -21,6 +21,11 @@ export type LinePosition = { char: number } +export type LinePositionRange = { + start: LinePosition + end: LinePosition +} + export type SourcePosition = LinePosition & { file: string method: string From 5a3f42020c1fba0959481d8c529019a632d89ed5 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 17 Apr 2024 03:32:35 -0400 Subject: [PATCH 03/10] remove old file --- ark/attest/instantiations.ts | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 ark/attest/instantiations.ts diff --git a/ark/attest/instantiations.ts b/ark/attest/instantiations.ts deleted file mode 100644 index 02ac892404..0000000000 --- a/ark/attest/instantiations.ts +++ /dev/null @@ -1,22 +0,0 @@ -// import { caller } from "@arktype/fs" -// import { getBenchCtx } from "./bench/bench.js" -// import type { Measure, TypeUnit } from "./bench/measure.js" -// import { instantiationDataHandler } from "./bench/type.js" -// import { getConfig } from "./config.js" - -// export const instantiations = () => ({ -// instantiations: ( -// ...args: [instantiations?: Measure | undefined] -// ) => { -// const attestConfig = getConfig() -// if (attestConfig.skipInlineInstantiations) { -// return -// } -// const calledFrom = caller() -// const ctx = getBenchCtx([calledFrom.file]) -// ctx.isInlineBench = true -// ctx.benchCallPosition = calledFrom -// ctx.lastSnapCallPosition = calledFrom -// instantiationDataHandler({ ...ctx, kind: "instantiations" }, args[0], false) -// } -// }) From cd74dd44900d2c7789819d03cfb0c2bc7e3c6e07 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Sun, 21 Apr 2024 09:07:38 -0400 Subject: [PATCH 04/10] pr --- ark/attest/__tests__/benchExpectedOutput.ts | 5 + ark/attest/__tests__/benchTemplate.ts | 5 + ark/attest/__tests__/instantiations.test.ts | 5 +- ark/attest/assert/assertions.ts | 15 +- ark/attest/assert/attest.ts | 38 ++- ark/attest/assert/chainableAssertions.ts | 3 +- ark/attest/bench/bench.ts | 256 +++++++++++++++++++- ark/attest/bench/call.ts | 248 ------------------- ark/attest/bench/measure.ts | 2 +- ark/attest/bench/type.ts | 118 +-------- ark/attest/cache/getCachedAssertions.ts | 25 +- ark/attest/cache/snapshots.ts | 20 +- ark/attest/cache/ts.ts | 20 +- ark/attest/cache/utils.ts | 93 +++---- ark/attest/cache/writeAssertionCache.ts | 33 ++- ark/attest/config.ts | 8 +- ark/attest/main.ts | 1 + ark/fs/caller.ts | 5 - 18 files changed, 404 insertions(+), 496 deletions(-) delete mode 100644 ark/attest/bench/call.ts diff --git a/ark/attest/__tests__/benchExpectedOutput.ts b/ark/attest/__tests__/benchExpectedOutput.ts index db78710ffc..600a451166 100644 --- a/ark/attest/__tests__/benchExpectedOutput.ts +++ b/ark/attest/__tests__/benchExpectedOutput.ts @@ -1,4 +1,5 @@ import { bench } from "@arktype/attest" +import { type } from "arktype" import type { makeComplexType as externalmakeComplexType } from "./utils.js" const fakeCallOptions = { @@ -53,4 +54,8 @@ bench( .mean([2, "ms"]) .types([344, "instantiations"]) +bench("arktype type", () => { + type("string") +}).types([4766, "instantiations"]) + bench("empty", () => {}).types([0, "instantiations"]) diff --git a/ark/attest/__tests__/benchTemplate.ts b/ark/attest/__tests__/benchTemplate.ts index 077d49a7ff..26292dea29 100644 --- a/ark/attest/__tests__/benchTemplate.ts +++ b/ark/attest/__tests__/benchTemplate.ts @@ -1,4 +1,5 @@ import { bench } from "@arktype/attest" +import { type } from "arktype" import type { makeComplexType as externalmakeComplexType } from "./utils.js" const fakeCallOptions = { @@ -53,4 +54,8 @@ bench( .mean() .types() +bench("arktype type", () => { + type("string") +}).types() + bench("empty", () => {}).types() diff --git a/ark/attest/__tests__/instantiations.test.ts b/ark/attest/__tests__/instantiations.test.ts index f8359311d8..f88619d640 100644 --- a/ark/attest/__tests__/instantiations.test.ts +++ b/ark/attest/__tests__/instantiations.test.ts @@ -8,8 +8,7 @@ type makeComplexType = S extends `${infer head}${infer tail}` describe("instantiations", () => { it("Can give me instantiations", () => { - const a = {} as makeComplexType<"defenestration"> - attest(type({ a: "Promise" })).snap("(function bound )") - attest.instantiations([6906, "instantiations"]) + type("string") + attest.instantiations([4766, "instantiations"]) }) }) diff --git a/ark/attest/assert/assertions.ts b/ark/attest/assert/assertions.ts index cdb7e73eb5..11a47bbdbc 100644 --- a/ark/attest/assert/assertions.ts +++ b/ark/attest/assert/assertions.ts @@ -1,12 +1,6 @@ -import { - ReadonlyArray, - isArray, - printable, - throwInternalError -} from "@arktype/util" +import { printable, throwInternalError } from "@arktype/util" import { AssertionError } from "node:assert" import * as assert from "node:assert/strict" -import type { VersionedTypeAssertion } from "../cache/getCachedAssertions.js" import type { TypeAssertionData } from "../cache/writeAssertionCache.js" import type { AssertionContext } from "./attest.js" @@ -58,7 +52,7 @@ export const versionableAssertion = for (const [version, data] of ctx.typeAssertionEntries!) { let errorMessage = "" try { - const mapped = actual.fn(data as TypeAssertionData, ctx) + const mapped = actual.fn(data, ctx) if (mapped !== null) { fn( "expected" in mapped ? mapped.expected : expected, @@ -114,7 +108,10 @@ export const typeEqualityMapping = new TypeAssertionMapping(data => { } return null }) - +/** + * todoshawn + * extract entires -> should just be an array should be type assertion data + */ export const assertEqualOrMatching = versionableAssertion( (expected, actual, ctx) => { const assertionArgs = { actual, expected, ctx } diff --git a/ark/attest/assert/attest.ts b/ark/attest/assert/attest.ts index 6b665188b2..ac39ac2c8b 100644 --- a/ark/attest/assert/attest.ts +++ b/ark/attest/assert/attest.ts @@ -1,7 +1,7 @@ import { caller, getCallStack, type SourcePosition } from "@arktype/fs" import type { inferTypeRoot, validateTypeRoot } from "arktype" import { getBenchCtx } from "../bench/bench.js" -import type { Measure, TypeUnit } from "../bench/measure.js" +import type { Measure } from "../bench/measure.js" import { instantiationDataHandler } from "../bench/type.js" import { getTypeAssertionsAtPosition, @@ -26,6 +26,8 @@ export type AttestFn = { def: validateTypeRoot ): asserts actual is unknown extends actual ? inferTypeRoot & actual : Extract> + + instantiations: (count?: Measure<"instantiations"> | undefined) => void } export type AssertionContext = { @@ -62,7 +64,8 @@ export const attestInternal = ( } if (!cfg.skipTypes) { ctx.typeAssertionEntries = getTypeAssertionsAtPosition(position) - if ((ctx.typeAssertionEntries[0]?.[1] as TypeAssertionData).typeArgs[0]) { + //todoshawn is this one ok to cast + if ((ctx.typeAssertionEntries[0][1] as TypeAssertionData).typeArgs[0]) { // if there is an expected type arg, check it immediately assertEquals(undefined, typeEqualityMapping, ctx) } @@ -70,23 +73,18 @@ export const attestInternal = ( return new ChainableAssertions(ctx) } -const instantiations = () => ({ - instantiations: ( - ...args: [instantiations?: Measure | undefined] - ) => { - const attestConfig = getConfig() - if (attestConfig.skipInlineInstantiations) return +attestInternal.instantiations = ( + args: Measure<"instantiations"> | undefined +) => { + const attestConfig = getConfig() + if (attestConfig.skipInlineInstantiations) return - const calledFrom = caller() - const ctx = getBenchCtx([calledFrom.file]) - ctx.isInlineBench = true - ctx.benchCallPosition = calledFrom - ctx.lastSnapCallPosition = calledFrom - instantiationDataHandler({ ...ctx, kind: "instantiations" }, args[0], false) - } -}) + const calledFrom = caller() + const ctx = getBenchCtx([calledFrom.file]) + ctx.isInlineBench = true + ctx.benchCallPosition = calledFrom + ctx.lastSnapCallPosition = calledFrom + instantiationDataHandler({ ...ctx, kind: "instantiations" }, args, false) +} -export const attest = Object.assign( - attestInternal as AttestFn, - instantiations() -) +export const attest = attestInternal as AttestFn diff --git a/ark/attest/assert/chainableAssertions.ts b/ark/attest/assert/chainableAssertions.ts index 2065585d43..3af3b8e788 100644 --- a/ark/attest/assert/chainableAssertions.ts +++ b/ark/attest/assert/chainableAssertions.ts @@ -42,6 +42,7 @@ export class ChainableAssertions implements AssertionRecord { return snapshot(value) } + //todoshawn unsafe casting maybe private get actual() { return this.ctx.actual instanceof TypeAssertionMapping ? this.ctx.actual.fn(this.ctx.typeAssertionEntries![0][1], this.ctx)! @@ -178,7 +179,7 @@ export class ChainableAssertions implements AssertionRecord { return this.immediateOrChained() } - throwsAndHasTypeError(matchValue: string | RegExp) { + throwsAndHasTypeError(matchValue: string | RegExp): void { assertEqualOrMatching( matchValue, getThrownMessage(callAssertedFunction(this.actual as Function), this.ctx), diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts index 9e2ab37247..cb1e8eda5c 100644 --- a/ark/attest/bench/bench.ts +++ b/ark/attest/bench/bench.ts @@ -1,13 +1,257 @@ import { caller, type SourcePosition } from "@arktype/fs" +import { performance } from "node:perf_hooks" import { ensureCacheDirs, getConfig, type ParsedAttestConfig } from "../config.js" import { chainableNoOpProxy } from "../utils.js" -import { BenchAssertions, type TimeAssertionName } from "./call.js" +import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" +import { await1K } from "./generated/await1k.js" +import { call1K } from "./generated/call1k.js" +import { + createTimeComparison, + createTimeMeasure, + type MarkMeasure, + type Measure, + type TimeUnit +} from "./measure.js" import { createBenchTypeAssertion, type BenchTypeAssertions } from "./type.js" +export type StatName = keyof typeof stats + +export type TimeAssertionName = StatName | "mark" + +export const stats = { + mean: (callTimes: number[]): number => { + const totalCallMs = callTimes.reduce((sum, duration) => sum + duration, 0) + return totalCallMs / callTimes.length + }, + median: (callTimes: number[]): number => { + const middleIndex = Math.floor(callTimes.length / 2) + const ms = + callTimes.length % 2 === 0 + ? (callTimes[middleIndex - 1] + callTimes[middleIndex]) / 2 + : callTimes[middleIndex] + return ms + } +} + +class ResultCollector { + results: number[] = [] + private benchStart = performance.now() + private bounds: Required + private lastInvocationStart: number + + constructor(private ctx: BenchContext) { + // By default, will run for either 5 seconds or 100_000 call sets (of 1000 calls), whichever comes first + this.bounds = { + ms: 5000, + count: 100_000, + ...ctx.options.until + } + this.lastInvocationStart = -1 + } + + start() { + this.ctx.options.hooks?.beforeCall?.() + this.lastInvocationStart = performance.now() + } + + stop() { + this.results.push((performance.now() - this.lastInvocationStart) / 1000) + this.ctx.options.hooks?.afterCall?.() + } + + done() { + const metMsTarget = performance.now() - this.benchStart >= this.bounds.ms + const metCountTarget = this.results.length >= this.bounds.count + return metMsTarget || metCountTarget + } +} + +const loopCalls = (fn: () => void, ctx: BenchContext) => { + const collector = new ResultCollector(ctx) + while (!collector.done()) { + collector.start() + // we use a function like this to make 1k explicit calls to the function + // to avoid certain optimizations V8 makes when looping + call1K(fn) + collector.stop() + } + return collector.results +} + +const loopAsyncCalls = async (fn: () => Promise, ctx: BenchContext) => { + const collector = new ResultCollector(ctx) + while (!collector.done()) { + collector.start() + await await1K(fn) + collector.stop() + } + return collector.results +} + +export class BenchAssertions< + Fn extends BenchableFunction, + NextAssertions = BenchTypeAssertions, + ReturnedAssertions = Fn extends () => Promise + ? Promise + : NextAssertions +> { + private label: string + private lastCallTimes: number[] | undefined + constructor( + private fn: Fn, + private ctx: BenchContext + ) { + this.label = `Call: ${ctx.qualifiedName}` + } + + private applyCallTimeHooks() { + if (this.ctx.options.fakeCallMs !== undefined) { + const fakeMs = + this.ctx.options.fakeCallMs === "count" + ? this.lastCallTimes!.length + : this.ctx.options.fakeCallMs + this.lastCallTimes = this.lastCallTimes!.map(() => fakeMs) + } + } + + private callTimesSync() { + if (!this.lastCallTimes) { + this.lastCallTimes = loopCalls(this.fn as any, this.ctx) + this.lastCallTimes.sort() + } + this.applyCallTimeHooks() + return this.lastCallTimes + } + + private async callTimesAsync() { + if (!this.lastCallTimes) { + this.lastCallTimes = await loopAsyncCalls(this.fn as any, this.ctx) + this.lastCallTimes.sort() + } + this.applyCallTimeHooks() + return this.lastCallTimes + } + + private createAssertion( + name: Name, + baseline: Name extends "mark" + ? Record> | undefined + : Measure | undefined, + callTimes: number[] + ) { + if (name === "mark") { + return this.markAssertion(baseline as any, callTimes) + } + const ms: number = stats[name as StatName](callTimes) + const comparison = createTimeComparison(ms, baseline as Measure) + console.group(`${this.label} (${name}):`) + compareToBaseline(comparison, this.ctx) + console.groupEnd() + queueBaselineUpdateIfNeeded(createTimeMeasure(ms), baseline, { + ...this.ctx, + kind: name + }) + return this.getNextAssertions() + } + + private markAssertion( + baseline: MarkMeasure | undefined, + callTimes: number[] + ) { + console.group(`${this.label}:`) + const markEntries: [StatName, Measure | undefined][] = ( + baseline + ? Object.entries(baseline) + : // If nothing was passed, gather all available baselines by setting their values to undefined. + Object.entries(stats).map(([kind]) => [kind, undefined]) + ) as any + const markResults = Object.fromEntries( + markEntries.map(([kind, kindBaseline]) => { + console.group(kind) + const ms = stats[kind](callTimes) + const comparison = createTimeComparison(ms, kindBaseline) + compareToBaseline(comparison, this.ctx) + console.groupEnd() + return [kind, comparison.updated] + }) + ) + console.groupEnd() + queueBaselineUpdateIfNeeded(markResults, baseline, { + ...this.ctx, + kind: "mark" + }) + return this.getNextAssertions() + } + + private getNextAssertions(): NextAssertions { + return createBenchTypeAssertion(this.ctx) as any as NextAssertions + } + + private createStatMethod( + name: Name, + baseline: Name extends "mark" + ? Record> | undefined + : Measure | undefined + ) { + if (this.ctx.isAsync) { + return new Promise((resolve) => { + this.callTimesAsync().then( + (callTimes) => { + resolve(this.createAssertion(name, baseline, callTimes)) + }, + (e) => { + this.addUnhandledBenchException(e) + resolve(chainableNoOpProxy) + } + ) + }) + } + let assertions = chainableNoOpProxy + try { + assertions = this.createAssertion(name, baseline, this.callTimesSync()) + } catch (e) { + this.addUnhandledBenchException(e) + } + return assertions + } + + private addUnhandledBenchException(reason: unknown) { + const message = `Bench ${ + this.ctx.qualifiedName + } threw during execution:\n${String(reason)}` + console.error(message) + unhandledExceptionMessages.push(message) + } + + median(baseline?: Measure): ReturnedAssertions { + this.ctx.lastSnapCallPosition = caller() + const assertions = this.createStatMethod( + "median", + baseline + ) as any as ReturnedAssertions + return assertions + } + + mean(baseline?: Measure): ReturnedAssertions { + this.ctx.lastSnapCallPosition = caller() + return this.createStatMethod("mean", baseline) as any as ReturnedAssertions + } + + mark(baseline?: MarkMeasure): ReturnedAssertions { + this.ctx.lastSnapCallPosition = caller() + return this.createStatMethod( + "mark", + baseline as any + ) as any as ReturnedAssertions + } +} + +const unhandledExceptionMessages: string[] = [] + export type UntilOptions = { ms?: number count?: number @@ -79,12 +323,20 @@ export const bench = ( return assertions as any } +process.on("beforeExit", () => { + if (unhandledExceptionMessages.length) { + console.error( + `${unhandledExceptionMessages.length} unhandled exception(s) occurred during your benches (see details above).` + ) + process.exit(1) + } +}) export const getBenchCtx = ( qualifiedPath: string[], isAsync: boolean = false, options: BenchOptions = {}, isInlineBench = false -) => { +): BenchContext => { return { qualifiedPath, qualifiedName: qualifiedPath.join("/"), diff --git a/ark/attest/bench/call.ts b/ark/attest/bench/call.ts deleted file mode 100644 index 0d27c07e69..0000000000 --- a/ark/attest/bench/call.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { caller } from "@arktype/fs" -import { performance } from "node:perf_hooks" -import { chainableNoOpProxy } from "../utils.js" -import { await1K } from "./await1k.js" -import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" -import type { BenchContext, BenchableFunction, UntilOptions } from "./bench.js" -import { call1K } from "./call1k.js" -import { - createTimeComparison, - createTimeMeasure, - type MarkMeasure, - type Measure, - type TimeUnit -} from "./measure.js" -import { createBenchTypeAssertion, type BenchTypeAssertions } from "./type.js" - -export type StatName = keyof typeof stats - -export type TimeAssertionName = StatName | "mark" - -export const stats = { - mean: (callTimes: number[]): number => { - const totalCallMs = callTimes.reduce((sum, duration) => sum + duration, 0) - return totalCallMs / callTimes.length - }, - median: (callTimes: number[]): number => { - const middleIndex = Math.floor(callTimes.length / 2) - const ms = - callTimes.length % 2 === 0 ? - (callTimes[middleIndex - 1] + callTimes[middleIndex]) / 2 - : callTimes[middleIndex] - return ms - } -} - -class ResultCollector { - results: number[] = [] - private benchStart = performance.now() - private bounds: Required - private lastInvocationStart: number - - constructor(private ctx: BenchContext) { - // By default, will run for either 5 seconds or 100_000 call sets (of 1000 calls), whichever comes first - this.bounds = { - ms: 5000, - count: 100_000, - ...ctx.options.until - } - this.lastInvocationStart = -1 - } - - start() { - this.ctx.options.hooks?.beforeCall?.() - this.lastInvocationStart = performance.now() - } - - stop() { - this.results.push((performance.now() - this.lastInvocationStart) / 1000) - this.ctx.options.hooks?.afterCall?.() - } - - done() { - const metMsTarget = performance.now() - this.benchStart >= this.bounds.ms - const metCountTarget = this.results.length >= this.bounds.count - return metMsTarget || metCountTarget - } -} - -const loopCalls = (fn: () => void, ctx: BenchContext) => { - const collector = new ResultCollector(ctx) - while (!collector.done()) { - collector.start() - // we use a function like this to make 1k explicit calls to the function - // to avoid certain optimizations V8 makes when looping - call1K(fn) - collector.stop() - } - return collector.results -} - -const loopAsyncCalls = async (fn: () => Promise, ctx: BenchContext) => { - const collector = new ResultCollector(ctx) - while (!collector.done()) { - collector.start() - await await1K(fn) - collector.stop() - } - return collector.results -} - -export class BenchAssertions< - Fn extends BenchableFunction, - NextAssertions = BenchTypeAssertions, - ReturnedAssertions = Fn extends () => Promise ? Promise - : NextAssertions -> { - private label: string - private lastCallTimes: number[] | undefined - constructor( - private fn: Fn, - private ctx: BenchContext - ) { - this.label = `Call: ${ctx.qualifiedName}` - } - - private applyCallTimeHooks() { - if (this.ctx.options.fakeCallMs !== undefined) { - const fakeMs = - this.ctx.options.fakeCallMs === "count" ? - this.lastCallTimes!.length - : this.ctx.options.fakeCallMs - this.lastCallTimes = this.lastCallTimes!.map(() => fakeMs) - } - } - - private callTimesSync() { - if (!this.lastCallTimes) { - this.lastCallTimes = loopCalls(this.fn as any, this.ctx) - this.lastCallTimes.sort() - } - this.applyCallTimeHooks() - return this.lastCallTimes - } - - private async callTimesAsync() { - if (!this.lastCallTimes) { - this.lastCallTimes = await loopAsyncCalls(this.fn as any, this.ctx) - this.lastCallTimes.sort() - } - this.applyCallTimeHooks() - return this.lastCallTimes - } - - private createAssertion( - name: Name, - baseline: Name extends "mark" ? - Record> | undefined - : Measure | undefined, - callTimes: number[] - ) { - if (name === "mark") return this.markAssertion(baseline as any, callTimes) - - const ms: number = stats[name as StatName](callTimes) - const comparison = createTimeComparison(ms, baseline as Measure) - console.group(`${this.label} (${name}):`) - compareToBaseline(comparison, this.ctx) - console.groupEnd() - queueBaselineUpdateIfNeeded(createTimeMeasure(ms), baseline, { - ...this.ctx, - kind: name - }) - return this.getNextAssertions() - } - - private markAssertion( - baseline: MarkMeasure | undefined, - callTimes: number[] - ) { - console.group(`${this.label}:`) - const markEntries: [StatName, Measure | undefined][] = ( - baseline ? - Object.entries(baseline) - // If nothing was passed, gather all available baselines by setting their values to undefined. - : Object.entries(stats).map(([kind]) => [kind, undefined])) as any - const markResults = Object.fromEntries( - markEntries.map(([kind, kindBaseline]) => { - console.group(kind) - const ms = stats[kind](callTimes) - const comparison = createTimeComparison(ms, kindBaseline) - compareToBaseline(comparison, this.ctx) - console.groupEnd() - return [kind, comparison.updated] - }) - ) - console.groupEnd() - queueBaselineUpdateIfNeeded(markResults, baseline, { - ...this.ctx, - kind: "mark" - }) - return this.getNextAssertions() - } - - private getNextAssertions(): NextAssertions { - return createBenchTypeAssertion(this.ctx) as any as NextAssertions - } - - private createStatMethod( - name: Name, - baseline: Name extends "mark" ? - Record> | undefined - : Measure | undefined - ) { - if (this.ctx.isAsync) { - return new Promise(resolve => { - this.callTimesAsync().then( - callTimes => { - resolve(this.createAssertion(name, baseline, callTimes)) - }, - e => { - this.addUnhandledBenchException(e) - resolve(chainableNoOpProxy) - } - ) - }) - } - let assertions = chainableNoOpProxy - try { - assertions = this.createAssertion(name, baseline, this.callTimesSync()) - } catch (e) { - this.addUnhandledBenchException(e) - } - return assertions - } - - private addUnhandledBenchException(reason: unknown) { - const message = `Bench ${ - this.ctx.qualifiedName - } threw during execution:\n${String(reason)}` - console.error(message) - unhandledExceptionMessages.push(message) - } - - median(baseline?: Measure): ReturnedAssertions { - this.ctx.lastSnapCallPosition = caller() - return this.createStatMethod("median", baseline) - } - - mean(baseline?: Measure): ReturnedAssertions { - this.ctx.lastSnapCallPosition = caller() - return this.createStatMethod("mean", baseline) - } - - mark(baseline?: MarkMeasure): ReturnedAssertions { - this.ctx.lastSnapCallPosition = caller() - return this.createStatMethod("mark", baseline as any) - } -} - -const unhandledExceptionMessages: string[] = [] - -process.on("beforeExit", () => { - if (unhandledExceptionMessages.length) { - console.error( - `${unhandledExceptionMessages.length} unhandled exception(s) occurred during your benches (see details above).` - ) - process.exit(1) - } -}) diff --git a/ark/attest/bench/measure.ts b/ark/attest/bench/measure.ts index 0fec3cc8d9..88c810ac1d 100644 --- a/ark/attest/bench/measure.ts +++ b/ark/attest/bench/measure.ts @@ -1,4 +1,4 @@ -import type { StatName } from "./call.js" +import type { StatName } from "./bench.js" type MeasureUnit = TimeUnit | TypeUnit diff --git a/ark/attest/bench/type.ts b/ark/attest/bench/type.ts index cd38d47ffc..a9036ae361 100644 --- a/ark/attest/bench/type.ts +++ b/ark/attest/bench/type.ts @@ -1,6 +1,6 @@ -import { getTypeAssertionsAtPosition } from "@arktype/attest" -import { caller, type LinePositionRange } from "@arktype/fs" +import { caller } from "@arktype/fs" import ts from "typescript" +import { getTypeAssertionsAtPosition } from "../cache/getCachedAssertions.js" import { TsServer, getAbsolutePosition, @@ -12,10 +12,7 @@ import { getCallExpressionsByName, getInstantiationsContributedByNode } from "../cache/utils.js" -import type { - Completions, - TypeRelationship -} from "../cache/writeAssertionCache.js" +import type { TypeRelationship } from "../cache/writeAssertionCache.js" import { getConfig } from "../config.js" import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" import type { BenchAssertionContext, BenchContext } from "./bench.js" @@ -30,101 +27,6 @@ export type BenchTypeAssertions = { types: (instantiations?: Measure) => void } -const getIsolatedEnv = () => { - const tsconfigInfo = getTsConfigInfoOrThrow() - const libFiles = getTsLibFiles(tsconfigInfo.parsed.options) - const projectRoot = process.cwd() - const system = tsvfs.createFSBackedSystem( - libFiles.defaultMapFromNodeModules, - projectRoot, - ts - ) - return tsvfs.createVirtualTypeScriptEnvironment( - system, - [], - ts, - tsconfigInfo.parsed.options - ) -} - -const createFile = ( - env: tsvfs.VirtualTypeScriptEnvironment, - fileName: string, - fileText: string -) => { - env.createFile(fileName, fileText) - return env.getSourceFile(fileName) -} - -const getProgram = (env?: tsvfs.VirtualTypeScriptEnvironment) => { - return env?.languageService.getProgram() -} -const getInstantiationsWithFile = (fileText: string, fileName: string) => { - const env = getIsolatedEnv() - const file = createFile(env, fileName, fileText) - getProgram(env)?.emit(file) - const instantiationCount = getInternalTypeChecker(env).getInstantiationCount() - return instantiationCount -} - -const getFirstAncestorByKindOrThrow = (node: ts.Node, kind: ts.SyntaxKind) => - getAncestors(node).find(ancestor => ancestor.kind === kind) ?? - throwInternalError( - `Could not find an ancestor of kind ${ts.SyntaxKind[kind]}` - ) - -const getBaselineSourceFile = (originalFile: ts.SourceFile): string => { - const benchCalls = getExpressionsByName(originalFile, ["bench"]) - - const benchExpressions = benchCalls.map(node => - getFirstAncestorByKindOrThrow(node, ts.SyntaxKind.ExpressionStatement) - ) - - let baselineSourceFileText = originalFile.getFullText() - - benchExpressions.forEach(benchExpression => { - baselineSourceFileText = baselineSourceFileText.replace( - benchExpression.getFullText(), - "" - ) - }) - - return baselineSourceFileText -} - -const instantiationsByPath: { [path: string]: number } = {} - -const getInstantiationsContributedByNode = ( - benchBlock: ts.FunctionExpression | ts.ArrowFunction -) => { - const originalFile = benchBlock.getSourceFile() - const originalPath = filePath(originalFile.fileName) - const fakePath = originalPath + ".nonexistent.ts" - - const baselineFile = getBaselineSourceFile(originalFile) - - const baselineFileWithBenchBlock = - baselineFile + `\nconst $attestIsolatedBench = ${benchBlock.getFullText()}` - - if (!instantiationsByPath[fakePath]) { - console.log(`⏳ attest: Analyzing type assertions...`) - const instantiationsWithoutNode = getInstantiationsWithFile( - baselineFile, - fakePath - ) - - instantiationsByPath[fakePath] = instantiationsWithoutNode - console.log(`⏳ Cached type assertions \n`) - } - - const instantiationsWithNode = getInstantiationsWithFile( - baselineFileWithBenchBlock, - fakePath - ) - - return instantiationsWithNode - instantiationsByPath[fakePath] -} - export const createBenchTypeAssertion = ( ctx: BenchContext ): BenchTypeAssertions => ({ @@ -135,7 +37,7 @@ export const createBenchTypeAssertion = ( }) export const getContributedInstantiations = (ctx: BenchContext): number => { - const expressionsToFind = getConfig().expressionsToFind + const testDeclarationAliases = getConfig().testDeclarationAliases const instance = TsServer.instance const file = instance.getSourceFileOrThrow(ctx.benchCallPosition.file) @@ -145,12 +47,12 @@ export const getContributedInstantiations = (ctx: BenchContext): number => { ) const firstMatchingNamedCall = getAncestors(node).find( - call => getCallExpressionsByName(call, expressionsToFind).length + call => getCallExpressionsByName(call, testDeclarationAliases).length ) if (!firstMatchingNamedCall) { throw new Error( - `No call expressions matching the name(s) '${expressionsToFind.join()}' were found` + `No call expressions matching the name(s) '${testDeclarationAliases.join()}' were found` ) } @@ -181,14 +83,6 @@ export type ArgAssertionData = { typeArgs: TypeRelationship[] } } -export type TypeAssertionData = { - location: LinePositionRange - args: ArgAssertionData[] - typeArgs: ArgAssertionData[] - errors: string[] - completions: Completions - count: number -} export const instantiationDataHandler = ( ctx: BenchAssertionContext, diff --git a/ark/attest/cache/getCachedAssertions.ts b/ark/attest/cache/getCachedAssertions.ts index db8caabf40..17f4684cc3 100644 --- a/ark/attest/cache/getCachedAssertions.ts +++ b/ark/attest/cache/getCachedAssertions.ts @@ -1,26 +1,22 @@ -import { - readJson, - type LinePosition, - type LinePositionRange, - type SourcePosition -} from "@arktype/fs" +import { readJson, type LinePosition, type SourcePosition } from "@arktype/fs" import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import { getConfig } from "../config.js" import { getFileKey } from "../utils.js" import type { AssertionsByFile, - LocationAndCountAssertionData, - TypeAssertionData + LinePositionRange, + TypeBenchmarkingAssertionData, + TypeRelationshipAssertionData } from "./writeAssertionCache.js" -export type VerionedAssertionsByFile = [ +export type VersionedAssertionsByFile = [ tsVersion: string, assertions: AssertionsByFile ] -let assertionEntries: VerionedAssertionsByFile[] | undefined -export const getCachedAssertionEntries = (): VerionedAssertionsByFile[] => { +let assertionEntries: VersionedAssertionsByFile[] | undefined +export const getCachedAssertionEntries = (): VersionedAssertionsByFile[] => { if (!assertionEntries) { const config = getConfig() if (!existsSync(config.assertionCacheDir)) @@ -56,9 +52,14 @@ const isPositionWithinRange = ( return true } +/** + * todoshawn typeassertiondata should be it's own union + * typerelationshipassertiondata + * typebenchmarkingassertiondata + */ export type VersionedTypeAssertion = [ tsVersion: string, - assertionData: TypeAssertionData | LocationAndCountAssertionData + assertionData: TypeBenchmarkingAssertionData | TypeRelationshipAssertionData ] export const getTypeAssertionsAtPosition = ( diff --git a/ark/attest/cache/snapshots.ts b/ark/attest/cache/snapshots.ts index 0ef621690c..56e9632816 100644 --- a/ark/attest/cache/snapshots.ts +++ b/ark/attest/cache/snapshots.ts @@ -40,7 +40,7 @@ export const getSnapshotByName = ( file: string, name: string, customPath: string | undefined -) => { +): any => { const snapshotPath = resolveSnapshotPath(file, customPath) return readJson(snapshotPath)?.[basename(file)]?.[name] } @@ -49,7 +49,7 @@ export const getSnapshotByName = ( * Writes the update and position to cacheDir, which will eventually be read and copied to the source * file by a cleanup process after all tests have completed. */ -export const queueSnapshotUpdate = (args: SnapshotArgs) => { +export const queueSnapshotUpdate = (args: SnapshotArgs): void => { const config = getConfig() writeSnapUpdate(config.defaultAssertionCachePath, args) writeSnapshotUpdatesOnExit() @@ -91,7 +91,7 @@ export const updateExternalSnapshot = ({ position, name, customPath -}: ExternalSnapshotArgs) => { +}: ExternalSnapshotArgs): void => { const snapshotPath = resolveSnapshotPath(position.file, customPath) const snapshotData = readJson(snapshotPath) ?? {} const fileKey = basename(position.file) @@ -122,10 +122,11 @@ const writeCachedInlineSnapshotUpdates = () => { } const writeSnapUpdate = (path: string, update?: SnapshotArgs) => { - const assertions = readJson(path) - const updates = assertions.updates ?? [] - if (update !== undefined) assertions.updates = [...updates, update] - else assertions.updates = [] + const assertions = + existsSync(path) ? readJson(path) : { updates: [] as SnapshotArgs[] } + + assertions.updates = + update !== undefined ? [...(assertions.updates ?? []), update] : [] writeJson(path, assertions) } @@ -200,10 +201,9 @@ export const writeUpdates = (queuedUpdates: QueuedUpdate[]): void => { } const runFormatterIfAvailable = (queuedUpdates: QueuedUpdate[]) => { + const { formatter, shouldFormat } = getConfig() + if (!shouldFormat) return try { - const { formatter, shouldFormat } = getConfig() - if (!shouldFormat) throw new Error() - const updatedPaths = [ ...new Set( queuedUpdates.map(update => diff --git a/ark/attest/cache/ts.ts b/ark/attest/cache/ts.ts index 3bccace316..e15749a94f 100644 --- a/ark/attest/cache/ts.ts +++ b/ark/attest/cache/ts.ts @@ -10,7 +10,7 @@ export class TsServer { rootFiles!: string[] virtualEnv!: tsvfs.VirtualTypeScriptEnvironment - private static _instance: TsServer | null = null + static #instance: TsServer | null = null static get instance(): TsServer { return new TsServer() } @@ -145,8 +145,7 @@ export const getTsConfigInfoOrThrow = (): TsconfigInfo => { parsed: configParseResult } } - -export type TsLibFiles = { +type TsLibFiles = { defaultMapFromNodeModules: Map resolvedPaths: string[] } @@ -180,7 +179,8 @@ export interface InternalTypeChecker extends ts.TypeChecker { export const getInternalTypeChecker = ( env?: tsvfs.VirtualTypeScriptEnvironment -): InternalTypeChecker => getProgram(env).getTypeChecker() as never +): InternalTypeChecker => + getProgram(env).getTypeChecker() as InternalTypeChecker export interface StringifiableType extends ts.Type { toString(): string @@ -222,14 +222,10 @@ const getDescendantsRecurse = (node: ts.Node): ts.Node[] => [ export const getAncestors = (node: ts.Node): ts.Node[] => { const ancestors: ts.Node[] = [] - let baseNode = node - if (baseNode.parent) { - baseNode = baseNode.parent - ancestors.push(baseNode) - } + let baseNode = node.parent while (baseNode.parent !== undefined) { - baseNode = baseNode.parent ancestors.push(baseNode) + baseNode = baseNode.parent } return ancestors } @@ -237,8 +233,8 @@ export const getAncestors = (node: ts.Node): ts.Node[] => { export const getFirstAncestorByKindOrThrow = ( node: ts.Node, kind: ts.SyntaxKind -) => - getAncestors(node).find((ancestor) => ancestor.kind === kind) ?? +): ts.Node => + getAncestors(node).find(ancestor => ancestor.kind === kind) ?? throwInternalError( `Could not find an ancestor of kind ${ts.SyntaxKind[kind]}` ) diff --git a/ark/attest/cache/utils.ts b/ark/attest/cache/utils.ts index 2c8c4bdc4e..dec0f77f20 100644 --- a/ark/attest/cache/utils.ts +++ b/ark/attest/cache/utils.ts @@ -1,4 +1,4 @@ -import { filePath, type LinePositionRange } from "@arktype/fs" +import { filePath } from "@arktype/fs" import { throwInternalError } from "@arktype/util" import * as tsvfs from "@typescript/vfs" import ts from "typescript" @@ -11,7 +11,10 @@ import { getTsConfigInfoOrThrow, getTsLibFiles } from "./ts.js" -import type { AssertionsByFile } from "./writeAssertionCache.js" +import type { + AssertionsByFile, + LinePositionRange +} from "./writeAssertionCache.js" export const getCallLocationFromCallExpression = ( callExpression: ts.CallExpression @@ -38,47 +41,53 @@ export const getCallLocationFromCallExpression = ( return location } +let attestAliasInstantiationMethodCalls: string[] export const gatherInlineInstantiationData = ( file: ts.SourceFile, - fileAssertions: AssertionsByFile, - inlineInsantiationMatcher: RegExp + fileAssertions: AssertionsByFile ): void => { - const fileText = file.getFullText() - const methodCall = "attest.instantiations" - if (inlineInsantiationMatcher.test(fileText)) { - const expressions = getCallExpressionsByName(file, [methodCall]) - const enclosingFunctions = expressions.map((expression) => { - const attestInstantiationsExpression = getFirstAncestorByKindOrThrow( - expression, + const { attestAliases } = getConfig() + attestAliasInstantiationMethodCalls ??= attestAliases.map( + (alias) => `${alias}.instantiations` + ) + const expressions = getCallExpressionsByName( + file, + attestAliasInstantiationMethodCalls + ) + if (!expressions.length) { + return + } + const enclosingFunctions = expressions.map((expression) => { + const attestInstantiationsExpression = getFirstAncestorByKindOrThrow( + expression, + ts.SyntaxKind.ExpressionStatement + ) + return { + ancestor: getFirstAncestorByKindOrThrow( + attestInstantiationsExpression, ts.SyntaxKind.ExpressionStatement - ) - return { - ancestor: getFirstAncestorByKindOrThrow( - attestInstantiationsExpression, - ts.SyntaxKind.ExpressionStatement - ), - position: getCallLocationFromCallExpression(expression) - } - }) - const instantiationInfo = enclosingFunctions.map((enclosingFunction) => { - const body = getDescendants(enclosingFunction.ancestor).find( - (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) - ) as ts.ArrowFunction | ts.FunctionExpression | undefined - if (!body) { - throw new Error("Unable to find file contents") - } + ), + position: getCallLocationFromCallExpression(expression) + } + }) + const instantiationInfo = enclosingFunctions.map((enclosingFunction) => { + const body = getDescendants(enclosingFunction.ancestor).find( + (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) + ) as ts.ArrowFunction | ts.FunctionExpression | undefined + if (!body) { + throw new Error("Unable to find file contents") + } - return { - location: enclosingFunction.position, - count: getInstantiationsContributedByNode(file, body) - } - }) - const assertions = fileAssertions[getFileKey(file.fileName)] ?? [] - fileAssertions[getFileKey(file.fileName)] = [ - ...assertions, - ...instantiationInfo - ] - } + return { + location: enclosingFunction.position, + count: getInstantiationsContributedByNode(file, body) + } + }) + const assertions = fileAssertions[getFileKey(file.fileName)] ?? [] + fileAssertions[getFileKey(file.fileName)] = [ + ...assertions, + ...instantiationInfo + ] } export const getCallExpressionsByName = ( @@ -111,15 +120,11 @@ export const getInstantiationsContributedByNode = ( ): number => { const originalPath = filePath(file.fileName) const fakePath = originalPath + ".nonexistent.ts" - const inlineInsantiationMatcher = getConfig().inlineInstantiationMatcher const baselineFile = getBaselineSourceFile(file) const baselineFileWithBenchBlock = - baselineFile + - `\nconst $attestIsolatedBench = ${benchBlock - .getFullText() - .replaceAll(inlineInsantiationMatcher, "")}` + baselineFile + `\nconst $attestIsolatedBench = ${benchBlock.getFullText()}` if (!instantiationsByPath[fakePath]) { console.log(`⏳ attest: Analyzing type assertions...`) @@ -187,7 +192,7 @@ export const getIsolatedEnv = (): tsvfs.VirtualTypeScriptEnvironment => { } const getBaselineSourceFile = (originalFile: ts.SourceFile): string => { - const functionNames = getConfig().expressionsToFind + const functionNames = getConfig().testDeclarationAliases const calls = getCallExpressionsByName(originalFile, functionNames) diff --git a/ark/attest/cache/writeAssertionCache.ts b/ark/attest/cache/writeAssertionCache.ts index 8f08700746..feda9aa2d8 100644 --- a/ark/attest/cache/writeAssertionCache.ts +++ b/ark/attest/cache/writeAssertionCache.ts @@ -1,4 +1,4 @@ -import type { LinePositionRange } from "@arktype/fs" +import type { LinePosition } from "@arktype/fs" import { flatMorph } from "@arktype/util" import ts from "typescript" @@ -20,7 +20,7 @@ import { export type AssertionsByFile = Record< string, - (TypeAssertionData | LocationAndCountAssertionData)[] + (TypeAssertionData | TypeBenchmarkingAssertionData)[] > export type LocationAndCountAssertionData = Pick< @@ -45,13 +45,7 @@ export const analyzeProjectAssertions = (): AssertionsByFile => { assertionsByFile[getFileKey(file.fileName)] = assertionsInFile } if (!config.skipInlineInstantiations) { - if (path.endsWith(".test.ts")) { - gatherInlineInstantiationData( - file, - assertionsByFile, - config.inlineInstantiationMatcher - ) - } + gatherInlineInstantiationData(file, assertionsByFile) } } return assertionsByFile @@ -69,7 +63,7 @@ export const getAssertionsInFile = ( export const analyzeAssertCall = ( assertCall: ts.CallExpression, diagnosticsByFile: DiagnosticsByFile -): TypeAssertionData => { +): TypeRelationshipAssertionData => { const types = extractArgumentTypesFromCall(assertCall) const location = getCallLocationFromCallExpression(assertCall) const args = types.args.map(arg => serializeArg(arg, types)) @@ -199,13 +193,28 @@ export type ArgAssertionData = { } } -export type TypeAssertionData = { +/** + * todoshawn typeassertiondata should be it's own union + * typerelationshipassertiondata + * typebenchmarkingassertiondata + */ +export type TypeRelationshipAssertionData = { location: LinePositionRange args: ArgAssertionData[] typeArgs: ArgAssertionData[] errors: string[] completions: Completions - count?: number +} +export type TypeBenchmarkingAssertionData = { + location: LinePositionRange + count: number +} +export type TypeAssertionData = TypeRelationshipAssertionData & + TypeBenchmarkingAssertionData + +export type LinePositionRange = { + start: LinePosition + end: LinePosition } export type TypeRelationship = "subtype" | "supertype" | "equality" | "none" diff --git a/ark/attest/config.ts b/ark/attest/config.ts index 5408b01bae..1d0b971ade 100644 --- a/ark/attest/config.ts +++ b/ark/attest/config.ts @@ -37,10 +37,9 @@ type BaseAttestConfig = { benchPercentThreshold: number benchErrorOnThresholdExceeded: boolean filter: string | undefined - expressionsToFind: string[] - inlineInstantiationMatcher: RegExp + testDeclarationAliases: string[] formatter: string - shouldFormat: true + shouldFormat: boolean } export type AttestConfig = Partial @@ -59,8 +58,7 @@ export const getDefaultAttestConfig = (): BaseAttestConfig => { benchPercentThreshold: 20, benchErrorOnThresholdExceeded: false, filter: undefined, - expressionsToFind: ["bench", "it"], - inlineInstantiationMatcher: /attest.instantiations\(.*/g, + testDeclarationAliases: ["bench", "it"], formatter: `npm exec --no -- prettier --write`, shouldFormat: true } diff --git a/ark/attest/main.ts b/ark/attest/main.ts index 27073c9f5a..1f23315ca7 100644 --- a/ark/attest/main.ts +++ b/ark/attest/main.ts @@ -4,6 +4,7 @@ export { bench } from "./bench/bench.js" export { getTypeAssertionsAtPosition } from "./cache/getCachedAssertions.js" export type { ArgAssertionData, + LinePositionRange, TypeAssertionData, TypeRelationship } from "./cache/writeAssertionCache.js" diff --git a/ark/fs/caller.ts b/ark/fs/caller.ts index 8f5679fc9d..f2e47cd18b 100644 --- a/ark/fs/caller.ts +++ b/ark/fs/caller.ts @@ -21,11 +21,6 @@ export type LinePosition = { char: number } -export type LinePositionRange = { - start: LinePosition - end: LinePosition -} - export type SourcePosition = LinePosition & { file: string method: string From c2a01402e17e3c0472bf1a32acc3f7ee517f94aa Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Tue, 30 Apr 2024 11:21:39 -0400 Subject: [PATCH 05/10] pr --- ark/attest/__tests__/benchExpectedOutput.ts | 4 ---- ark/attest/__tests__/benchTemplate.ts | 5 ----- ark/attest/assert/assertions.ts | 2 +- ark/attest/cache/snapshots.ts | 2 +- 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/ark/attest/__tests__/benchExpectedOutput.ts b/ark/attest/__tests__/benchExpectedOutput.ts index 600a451166..2dd62c457a 100644 --- a/ark/attest/__tests__/benchExpectedOutput.ts +++ b/ark/attest/__tests__/benchExpectedOutput.ts @@ -54,8 +54,4 @@ bench( .mean([2, "ms"]) .types([344, "instantiations"]) -bench("arktype type", () => { - type("string") -}).types([4766, "instantiations"]) - bench("empty", () => {}).types([0, "instantiations"]) diff --git a/ark/attest/__tests__/benchTemplate.ts b/ark/attest/__tests__/benchTemplate.ts index 26292dea29..077d49a7ff 100644 --- a/ark/attest/__tests__/benchTemplate.ts +++ b/ark/attest/__tests__/benchTemplate.ts @@ -1,5 +1,4 @@ import { bench } from "@arktype/attest" -import { type } from "arktype" import type { makeComplexType as externalmakeComplexType } from "./utils.js" const fakeCallOptions = { @@ -54,8 +53,4 @@ bench( .mean() .types() -bench("arktype type", () => { - type("string") -}).types() - bench("empty", () => {}).types() diff --git a/ark/attest/assert/assertions.ts b/ark/attest/assert/assertions.ts index 11a47bbdbc..2c4fbb3f8f 100644 --- a/ark/attest/assert/assertions.ts +++ b/ark/attest/assert/assertions.ts @@ -49,7 +49,7 @@ export const versionableAssertion = `Unexpected missing typeAssertionEntries when passed a TypeAssertionMapper` ) } - for (const [version, data] of ctx.typeAssertionEntries!) { + for (const [version, data] of ctx.typeRelationshipAssertionEntries) { let errorMessage = "" try { const mapped = actual.fn(data, ctx) diff --git a/ark/attest/cache/snapshots.ts b/ark/attest/cache/snapshots.ts index 56e9632816..068ee4633f 100644 --- a/ark/attest/cache/snapshots.ts +++ b/ark/attest/cache/snapshots.ts @@ -40,7 +40,7 @@ export const getSnapshotByName = ( file: string, name: string, customPath: string | undefined -): any => { +): object => { const snapshotPath = resolveSnapshotPath(file, customPath) return readJson(snapshotPath)?.[basename(file)]?.[name] } From 4f640c744bcacbdc56aac4fa9f26f60319d55901 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 1 May 2024 10:58:37 -0400 Subject: [PATCH 06/10] inline instantiations --- ark/attest/__tests__/assertions.test.ts | 2 +- ark/attest/__tests__/benchExpectedOutput.ts | 1 - ark/attest/__tests__/benchTemplate.ts | 1 + ark/attest/__tests__/instantiations.test.ts | 14 +-- ark/attest/__tests__/snapExpectedOutput.ts | 14 +-- ark/attest/__tests__/snapPopulation.test.ts | 4 +- ark/attest/__tests__/snapTemplate.ts | 4 +- ark/attest/assert/assertions.ts | 13 +-- ark/attest/assert/attest.ts | 18 +-- ark/attest/assert/chainableAssertions.ts | 38 ++---- ark/attest/bench/baseline.ts | 2 +- ark/attest/bench/bench.ts | 111 +++++++++--------- ark/attest/bench/measure.ts | 2 +- ark/attest/bench/type.ts | 19 +-- ark/attest/cache/getCachedAssertions.ts | 123 +++++++++++++------- ark/attest/cache/snapshots.ts | 1 + ark/attest/cache/ts.ts | 3 +- ark/attest/cache/utils.ts | 50 ++++---- ark/attest/cache/writeAssertionCache.ts | 34 +++--- ark/attest/main.ts | 5 +- 20 files changed, 232 insertions(+), 227 deletions(-) diff --git a/ark/attest/__tests__/assertions.test.ts b/ark/attest/__tests__/assertions.test.ts index 851972f735..17f3c1303d 100644 --- a/ark/attest/__tests__/assertions.test.ts +++ b/ark/attest/__tests__/assertions.test.ts @@ -3,7 +3,7 @@ import * as assert from "node:assert/strict" const o = { ark: "type" } -specify(() => { +specify("type assertions", () => { it("type parameter", () => { attest<{ ark: string }>(o) assert.throws( diff --git a/ark/attest/__tests__/benchExpectedOutput.ts b/ark/attest/__tests__/benchExpectedOutput.ts index 2dd62c457a..db78710ffc 100644 --- a/ark/attest/__tests__/benchExpectedOutput.ts +++ b/ark/attest/__tests__/benchExpectedOutput.ts @@ -1,5 +1,4 @@ import { bench } from "@arktype/attest" -import { type } from "arktype" import type { makeComplexType as externalmakeComplexType } from "./utils.js" const fakeCallOptions = { diff --git a/ark/attest/__tests__/benchTemplate.ts b/ark/attest/__tests__/benchTemplate.ts index 077d49a7ff..2d0b9d9b28 100644 --- a/ark/attest/__tests__/benchTemplate.ts +++ b/ark/attest/__tests__/benchTemplate.ts @@ -1,4 +1,5 @@ import { bench } from "@arktype/attest" +import { type } from "arktype" import type { makeComplexType as externalmakeComplexType } from "./utils.js" const fakeCallOptions = { diff --git a/ark/attest/__tests__/instantiations.test.ts b/ark/attest/__tests__/instantiations.test.ts index f88619d640..3c4df29928 100644 --- a/ark/attest/__tests__/instantiations.test.ts +++ b/ark/attest/__tests__/instantiations.test.ts @@ -1,14 +1,10 @@ -import { attest } from "@arktype/attest" +import { attest, contextualize } from "@arktype/attest" import { type } from "arktype" -import { describe, it } from "mocha" +import { it } from "mocha" -type makeComplexType = S extends `${infer head}${infer tail}` - ? head | tail | makeComplexType - : S - -describe("instantiations", () => { - it("Can give me instantiations", () => { +contextualize(() => { + it("Inline instantiations", () => { type("string") - attest.instantiations([4766, "instantiations"]) + attest.instantiations([1968, "instantiations"]) }) }) diff --git a/ark/attest/__tests__/snapExpectedOutput.ts b/ark/attest/__tests__/snapExpectedOutput.ts index 404aee2e4f..d661034701 100644 --- a/ark/attest/__tests__/snapExpectedOutput.ts +++ b/ark/attest/__tests__/snapExpectedOutput.ts @@ -2,25 +2,25 @@ import { attest, cleanup, setup } from "@arktype/attest" setup() -attest({ re: "do" }).equals({ re: "do" }).type.toString.snap(`{ re: string; }`) +attest({ re: "do" }).equals({ re: "do" }).type.toString.snap("{ re: string; }") attest(5).snap(5) -attest({ re: "do" }).snap({ re: `do` }) +attest({ re: "do" }).snap({ re: "do" }) // @ts-expect-error (using internal updateSnapshots hook) -attest({ re: "dew" }, { updateSnapshots: true }).snap({ re: `dew` }) +attest({ re: "dew" }, { cfg: { updateSnapshots: true } }).snap({ re: "dew" }) // @ts-expect-error (using internal updateSnapshots hook) -attest(5, { updateSnapshots: true }).snap(5) +attest(5, { cfg: { updateSnapshots: true } }).snap(5) -attest(undefined).snap(`(undefined)`) +attest(undefined).snap("(undefined)") -attest({ a: undefined }).snap({ a: `(undefined)` }) +attest({ a: undefined }).snap({ a: "(undefined)" }) attest("multiline\nmultiline").snap(`multiline multiline`) -attest("with `quotes`").snap(`with \`quotes\``) +attest("with `quotes`").snap("with `quotes`") cleanup() diff --git a/ark/attest/__tests__/snapPopulation.test.ts b/ark/attest/__tests__/snapPopulation.test.ts index bedced28a0..2b8bea9eee 100644 --- a/ark/attest/__tests__/snapPopulation.test.ts +++ b/ark/attest/__tests__/snapPopulation.test.ts @@ -10,7 +10,7 @@ contextualize(() => { fromHere("benchExpectedOutput.ts") ).replaceAll("\r\n", "\n") equal(actual, expectedOutput) - }) + }).timeout(10000) it("snap populates file", () => { const actual = runThenGetContents(fromHere("snapTemplate.ts")) @@ -18,5 +18,5 @@ contextualize(() => { fromHere("snapExpectedOutput.ts") ).replaceAll("\r\n", "\n") equal(actual, expectedOutput) - }) + }).timeout(10000) }) diff --git a/ark/attest/__tests__/snapTemplate.ts b/ark/attest/__tests__/snapTemplate.ts index bcabe87801..c2d584b236 100644 --- a/ark/attest/__tests__/snapTemplate.ts +++ b/ark/attest/__tests__/snapTemplate.ts @@ -9,10 +9,10 @@ attest(5).snap() attest({ re: "do" }).snap() // @ts-expect-error (using internal updateSnapshots hook) -attest({ re: "dew" }, { updateSnapshots: true }).snap() +attest({ re: "dew" }, { cfg: { updateSnapshots: true } }).snap({ re: "do" }) // @ts-expect-error (using internal updateSnapshots hook) -attest(5, { updateSnapshots: true }).snap(6) +attest(5, { cfg: { updateSnapshots: true } }).snap(6) attest(undefined).snap() diff --git a/ark/attest/assert/assertions.ts b/ark/attest/assert/assertions.ts index 2c4fbb3f8f..83527ceae0 100644 --- a/ark/attest/assert/assertions.ts +++ b/ark/attest/assert/assertions.ts @@ -1,7 +1,7 @@ -import { printable, throwInternalError } from "@arktype/util" +import { hasKey, printable, throwInternalError } from "@arktype/util" import { AssertionError } from "node:assert" import * as assert from "node:assert/strict" -import type { TypeAssertionData } from "../cache/writeAssertionCache.js" +import type { TypeRelationshipAssertionData } from "../cache/writeAssertionCache.js" import type { AssertionContext } from "./attest.js" export type ThrowAssertionErrorContext = { @@ -34,7 +34,7 @@ export type MappedTypeAssertionResult = { export class TypeAssertionMapping { constructor( public fn: ( - data: TypeAssertionData, + data: TypeRelationshipAssertionData, ctx: AssertionContext ) => MappedTypeAssertionResult ) {} @@ -44,7 +44,7 @@ export const versionableAssertion = (fn: AssertFn): AssertFn => (expected, actual, ctx) => { if (actual instanceof TypeAssertionMapping) { - if (!ctx.typeAssertionEntries) { + if (!ctx.typeRelationshipAssertionEntries) { throwInternalError( `Unexpected missing typeAssertionEntries when passed a TypeAssertionMapper` ) @@ -108,10 +108,7 @@ export const typeEqualityMapping = new TypeAssertionMapping(data => { } return null }) -/** - * todoshawn - * extract entires -> should just be an array should be type assertion data - */ + export const assertEqualOrMatching = versionableAssertion( (expected, actual, ctx) => { const assertionArgs = { actual, expected, ctx } diff --git a/ark/attest/assert/attest.ts b/ark/attest/assert/attest.ts index ac39ac2c8b..7a0322aa05 100644 --- a/ark/attest/assert/attest.ts +++ b/ark/attest/assert/attest.ts @@ -4,10 +4,13 @@ import { getBenchCtx } from "../bench/bench.js" import type { Measure } from "../bench/measure.js" import { instantiationDataHandler } from "../bench/type.js" import { - getTypeAssertionsAtPosition, + getTypeRelationshipAssertionsAtPosition, type VersionedTypeAssertion } from "../cache/getCachedAssertions.js" -import type { TypeAssertionData } from "../cache/writeAssertionCache.js" +import type { + TypeBenchmarkingAssertionData, + TypeRelationshipAssertionData +} from "../cache/writeAssertionCache.js" import { getConfig, type AttestConfig } from "../config.js" import { assertEquals, typeEqualityMapping } from "./assertions.js" import { @@ -38,7 +41,8 @@ export type AssertionContext = { position: SourcePosition defaultExpected?: unknown assertionStack: string - typeAssertionEntries?: VersionedTypeAssertion[] + typeRelationshipAssertionEntries?: VersionedTypeAssertion[] + typeBenchmarkingAssertionEntries?: VersionedTypeAssertion[] lastSnapName?: string } @@ -63,9 +67,9 @@ export const attestInternal = ( ...ctxHooks } if (!cfg.skipTypes) { - ctx.typeAssertionEntries = getTypeAssertionsAtPosition(position) - //todoshawn is this one ok to cast - if ((ctx.typeAssertionEntries[0][1] as TypeAssertionData).typeArgs[0]) { + ctx.typeRelationshipAssertionEntries = + getTypeRelationshipAssertionsAtPosition(position) + if (ctx.typeRelationshipAssertionEntries[0][1].typeArgs[0]) { // if there is an expected type arg, check it immediately assertEquals(undefined, typeEqualityMapping, ctx) } @@ -87,4 +91,4 @@ attestInternal.instantiations = ( instantiationDataHandler({ ...ctx, kind: "instantiations" }, args, false) } -export const attest = attestInternal as AttestFn +export const attest: AttestFn = attestInternal as AttestFn diff --git a/ark/attest/assert/chainableAssertions.ts b/ark/attest/assert/chainableAssertions.ts index 3af3b8e788..30c2092cdf 100644 --- a/ark/attest/assert/chainableAssertions.ts +++ b/ark/attest/assert/chainableAssertions.ts @@ -13,10 +13,7 @@ import { updateExternalSnapshot, type SnapshotArgs } from "../cache/snapshots.js" -import type { - Completions, - TypeAssertionData -} from "../cache/writeAssertionCache.js" +import type { Completions } from "../cache/writeAssertionCache.js" import { chainableNoOpProxy } from "../utils.js" import { TypeAssertionMapping, @@ -42,12 +39,12 @@ export class ChainableAssertions implements AssertionRecord { return snapshot(value) } - //todoshawn unsafe casting maybe private get actual() { - return this.ctx.actual instanceof TypeAssertionMapping ? - this.ctx.actual.fn(this.ctx.typeAssertionEntries![0][1], this.ctx)! - .actual - : this.ctx.actual + if (this.ctx.actual instanceof TypeAssertionMapping) { + const assertionEntry = this.ctx.typeRelationshipAssertionEntries![0][1] + return this.ctx.actual.fn(assertionEntry, this.ctx)!.actual + } + return this.ctx.actual } private get serializedActual() { @@ -69,7 +66,9 @@ export class ChainableAssertions implements AssertionRecord { ctx: this.ctx, message: messageOnError ?? - `${this.serializedActual} failed to satisfy predicate${predicate.name ? ` ${predicate.name}` : ""}` + `${this.serializedActual} failed to satisfy predicate${ + predicate.name ? ` ${predicate.name}` : "" + }` }) } return this.actual as never @@ -83,6 +82,7 @@ export class ChainableAssertions implements AssertionRecord { assert.equal(this.actual, expected) return this } + equals(expected: unknown): this { assertEquals(expected, this.actual, this.ctx) return this @@ -171,7 +171,7 @@ export class ChainableAssertions implements AssertionRecord { }) } - get throws() { + get throws(): unknown { const result = callAssertedFunction(this.actual as Function) this.ctx.actual = getThrownMessage(result, this.ctx) this.ctx.allowRegex = true @@ -196,21 +196,7 @@ export class ChainableAssertions implements AssertionRecord { } } - instanceOf(expected: Constructor): this { - if (!(this.actual instanceof expected)) { - throwAssertionError({ - ctx: this.ctx, - message: `Expected an instance of ${expected.name} (was ${ - typeof this.actual === "object" && this.actual !== null ? - this.actual.constructor.name - : this.serializedActual - })` - }) - } - return this - } - - get completions() { + get completions(): any { if (this.ctx.cfg.skipTypes) return chainableNoOpProxy this.ctx.actual = new TypeAssertionMapping(data => { diff --git a/ark/attest/bench/baseline.ts b/ark/attest/bench/baseline.ts index dc50f113de..1c081625d3 100644 --- a/ark/attest/bench/baseline.ts +++ b/ark/attest/bench/baseline.ts @@ -69,4 +69,4 @@ const handleNegativeDelta = (formattedDelta: string, ctx: BenchContext) => { 1 )}! Consider setting a new baseline.` ) -} +} \ No newline at end of file diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts index cb1e8eda5c..932a97f057 100644 --- a/ark/attest/bench/bench.ts +++ b/ark/attest/bench/bench.ts @@ -6,9 +6,9 @@ import { type ParsedAttestConfig } from "../config.js" import { chainableNoOpProxy } from "../utils.js" +import { await1K } from "./await1k.js" import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" -import { await1K } from "./generated/await1k.js" -import { call1K } from "./generated/call1k.js" +import { call1K } from "./call1k.js" import { createTimeComparison, createTimeMeasure, @@ -22,6 +22,35 @@ export type StatName = keyof typeof stats export type TimeAssertionName = StatName | "mark" +export const bench = ( + name: string, + fn: Fn, + options: BenchOptions = {} +): InitialBenchAssertions => { + const qualifiedPath = [...currentSuitePath, name] + const ctx = getBenchCtx( + qualifiedPath, + fn.constructor.name === "AsyncFunction", + options + ) + ctx.benchCallPosition = caller() + ensureCacheDirs() + if ( + typeof ctx.cfg.filter === "string" && + !qualifiedPath.includes(ctx.cfg.filter) + ) + return chainableNoOpProxy + else if ( + Array.isArray(ctx.cfg.filter) && + ctx.cfg.filter.some((segment, i) => segment !== qualifiedPath[i]) + ) + return chainableNoOpProxy + + const assertions = new BenchAssertions(fn, ctx) + Object.assign(assertions, createBenchTypeAssertion(ctx)) + return assertions as any +} + export const stats = { mean: (callTimes: number[]): number => { const totalCallMs = callTimes.reduce((sum, duration) => sum + duration, 0) @@ -30,9 +59,9 @@ export const stats = { median: (callTimes: number[]): number => { const middleIndex = Math.floor(callTimes.length / 2) const ms = - callTimes.length % 2 === 0 - ? (callTimes[middleIndex - 1] + callTimes[middleIndex]) / 2 - : callTimes[middleIndex] + callTimes.length % 2 === 0 ? + (callTimes[middleIndex - 1] + callTimes[middleIndex]) / 2 + : callTimes[middleIndex] return ms } } @@ -95,9 +124,8 @@ const loopAsyncCalls = async (fn: () => Promise, ctx: BenchContext) => { export class BenchAssertions< Fn extends BenchableFunction, NextAssertions = BenchTypeAssertions, - ReturnedAssertions = Fn extends () => Promise - ? Promise - : NextAssertions + ReturnedAssertions = Fn extends () => Promise ? Promise + : NextAssertions > { private label: string private lastCallTimes: number[] | undefined @@ -111,9 +139,9 @@ export class BenchAssertions< private applyCallTimeHooks() { if (this.ctx.options.fakeCallMs !== undefined) { const fakeMs = - this.ctx.options.fakeCallMs === "count" - ? this.lastCallTimes!.length - : this.ctx.options.fakeCallMs + this.ctx.options.fakeCallMs === "count" ? + this.lastCallTimes!.length + : this.ctx.options.fakeCallMs this.lastCallTimes = this.lastCallTimes!.map(() => fakeMs) } } @@ -138,14 +166,13 @@ export class BenchAssertions< private createAssertion( name: Name, - baseline: Name extends "mark" - ? Record> | undefined - : Measure | undefined, + baseline: Name extends "mark" ? + Record> | undefined + : Measure | undefined, callTimes: number[] ) { - if (name === "mark") { - return this.markAssertion(baseline as any, callTimes) - } + if (name === "mark") return this.markAssertion(baseline as any, callTimes) + const ms: number = stats[name as StatName](callTimes) const comparison = createTimeComparison(ms, baseline as Measure) console.group(`${this.label} (${name}):`) @@ -164,11 +191,10 @@ export class BenchAssertions< ) { console.group(`${this.label}:`) const markEntries: [StatName, Measure | undefined][] = ( - baseline - ? Object.entries(baseline) - : // If nothing was passed, gather all available baselines by setting their values to undefined. - Object.entries(stats).map(([kind]) => [kind, undefined]) - ) as any + baseline ? + Object.entries(baseline) + // If nothing was passed, gather all available baselines by setting their values to undefined. + : Object.entries(stats).map(([kind]) => [kind, undefined])) as any const markResults = Object.fromEntries( markEntries.map(([kind, kindBaseline]) => { console.group(kind) @@ -193,17 +219,17 @@ export class BenchAssertions< private createStatMethod( name: Name, - baseline: Name extends "mark" - ? Record> | undefined - : Measure | undefined + baseline: Name extends "mark" ? + Record> | undefined + : Measure | undefined ) { if (this.ctx.isAsync) { - return new Promise((resolve) => { + return new Promise(resolve => { this.callTimesAsync().then( - (callTimes) => { + callTimes => { resolve(this.createAssertion(name, baseline, callTimes)) }, - (e) => { + e => { this.addUnhandledBenchException(e) resolve(chainableNoOpProxy) } @@ -294,35 +320,6 @@ export type InitialBenchAssertions = const currentSuitePath: string[] = [] -export const bench = ( - name: string, - fn: Fn, - options: BenchOptions = {} -): InitialBenchAssertions => { - const qualifiedPath = [...currentSuitePath, name] - const ctx = getBenchCtx( - qualifiedPath, - fn.constructor.name === "AsyncFunction", - options - ) - ctx.benchCallPosition = caller() - ensureCacheDirs() - if ( - typeof ctx.cfg.filter === "string" && - !qualifiedPath.includes(ctx.cfg.filter) - ) - return chainableNoOpProxy - else if ( - Array.isArray(ctx.cfg.filter) && - ctx.cfg.filter.some((segment, i) => segment !== qualifiedPath[i]) - ) - return chainableNoOpProxy - - const assertions = new BenchAssertions(fn, ctx) - Object.assign(assertions, createBenchTypeAssertion(ctx)) - return assertions as any -} - process.on("beforeExit", () => { if (unhandledExceptionMessages.length) { console.error( diff --git a/ark/attest/bench/measure.ts b/ark/attest/bench/measure.ts index 88c810ac1d..916771cb45 100644 --- a/ark/attest/bench/measure.ts +++ b/ark/attest/bench/measure.ts @@ -89,4 +89,4 @@ export const createTimeComparison = ( updated: createTimeMeasure(ms), baseline: undefined } -} +} \ No newline at end of file diff --git a/ark/attest/bench/type.ts b/ark/attest/bench/type.ts index a9036ae361..e9ba737c39 100644 --- a/ark/attest/bench/type.ts +++ b/ark/attest/bench/type.ts @@ -1,6 +1,6 @@ import { caller } from "@arktype/fs" import ts from "typescript" -import { getTypeAssertionsAtPosition } from "../cache/getCachedAssertions.js" +import { getTypeBenchAssertionsAtPosition } from "../cache/getCachedAssertions.js" import { TsServer, getAbsolutePosition, @@ -59,16 +59,6 @@ export const getContributedInstantiations = (ctx: BenchContext): number => { const body = getDescendants(firstMatchingNamedCall).find( node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) ) as ts.ArrowFunction | ts.FunctionExpression | undefined - const benchNode = nearestCallExpressionChild( - file, - getAbsolutePosition(file, ctx.benchCallPosition) - ) - const benchFn = getExpressionsByName(benchNode, ["bench"]) - if (!benchFn) throw new Error("Unable to retrieve bench expression node.") - - const benchBody = getDescendants(benchFn[0]).find( - node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) - ) as ts.ArrowFunction | ts.FunctionExpression | undefined if (!body) throw new Error("Unable to retrieve contents of the call expression") @@ -87,12 +77,13 @@ export type ArgAssertionData = { export const instantiationDataHandler = ( ctx: BenchAssertionContext, args?: Measure, - isBench = true + isBenchFunction = true ): void => { const instantiationsContributed = - isBench ? + isBenchFunction ? getContributedInstantiations(ctx) - : getTypeAssertionsAtPosition(ctx.benchCallPosition)[0][1].count! + : getTypeBenchAssertionsAtPosition(ctx.benchCallPosition)[0][1].count + const comparison: MeasureComparison = createTypeComparison( instantiationsContributed, args diff --git a/ark/attest/cache/getCachedAssertions.ts b/ark/attest/cache/getCachedAssertions.ts index 17f4684cc3..27e6a7339a 100644 --- a/ark/attest/cache/getCachedAssertions.ts +++ b/ark/attest/cache/getCachedAssertions.ts @@ -6,13 +6,15 @@ import { getFileKey } from "../utils.js" import type { AssertionsByFile, LinePositionRange, + TypeAssertionData, TypeBenchmarkingAssertionData, TypeRelationshipAssertionData } from "./writeAssertionCache.js" export type VersionedAssertionsByFile = [ tsVersion: string, - assertions: AssertionsByFile + relationshipAssertions: AssertionsByFile, + benchAssertions: AssertionsByFile ] let assertionEntries: VersionedAssertionsByFile[] | undefined @@ -23,11 +25,28 @@ export const getCachedAssertionEntries = (): VersionedAssertionsByFile[] => { throwMissingAssertionDataError(config.assertionCacheDir) const assertionFiles = readdirSync(config.assertionCacheDir) - assertionEntries = assertionFiles.map(file => [ - // remove .json extension - file.slice(0, -5), - readJson(join(config.assertionCacheDir, file)) - ]) + const relationshipAssertions: AssertionsByFile = {} + const benchAssertions: AssertionsByFile = {} + + assertionEntries = assertionFiles.map(file => { + const data = readJson(join(config.assertionCacheDir, file)) + for (const fileName of Object.keys(data)) { + const relationshipAssertionData = data[fileName].filter( + (entry: TypeAssertionData) => "args" in entry + ) + const benchAssertionData = data[fileName].filter( + (entry: TypeAssertionData) => "count" in entry + ) + relationshipAssertions[fileName] = relationshipAssertionData + benchAssertions[fileName] = benchAssertionData + } + return [ + // remove .json extension + file.slice(0, -5), + relationshipAssertions, + benchAssertions + ] + }) } return assertionEntries! } @@ -52,42 +71,66 @@ const isPositionWithinRange = ( return true } -/** - * todoshawn typeassertiondata should be it's own union - * typerelationshipassertiondata - * typebenchmarkingassertiondata - */ -export type VersionedTypeAssertion = [ - tsVersion: string, - assertionData: TypeBenchmarkingAssertionData | TypeRelationshipAssertionData -] +export type VersionedTypeAssertion< + data extends TypeAssertionData = TypeAssertionData +> = [tsVersion: string, assertionData: data] -export const getTypeAssertionsAtPosition = ( - position: SourcePosition -): VersionedTypeAssertion[] => { +enum Assertion { + Bench, + Type +} + +const getTypeAssertionsAtPosition = ( + position: SourcePosition, + assertionType: Assertion +): VersionedTypeAssertion[] => { const fileKey = getFileKey(position.file) - return getCachedAssertionEntries().map(([version, data]) => { - if (!data[fileKey]) { - throw new Error( - `Found no assertion data for '${fileKey}' for TypeScript version ${version}.` - ) - } - const matchingAssertion = data[fileKey].find(assertion => { - /** - * Depending on the environment, a trace can refer to any of these points - * attest(...) - * ^ ^ ^ - * Because of this, it's safest to check if the call came from anywhere in the expected range. - * - */ - return isPositionWithinRange(position, assertion.location) - }) - if (!matchingAssertion) { - throw new Error( - `Found no assertion for TypeScript version ${version} at line ${position.line} char ${position.char} in '${fileKey}'. + return getCachedAssertionEntries().map( + ([version, typeRelationshipAssertions, BenchAssertionAssertions]) => { + const assertions = + assertionType === Assertion.Type ? + typeRelationshipAssertions + : BenchAssertionAssertions + if (!assertions[fileKey]) { + throw new Error( + `Found no assertion data for '${fileKey}' for TypeScript version ${version}.` + ) + } + const matchingAssertion = assertions[fileKey].find(assertion => { + /** + * Depending on the environment, a trace can refer to any of these points + * attest(...) + * ^ ^ ^ + * Because of this, it's safest to check if the call came from anywhere in the expected range. + * + */ + return isPositionWithinRange(position, assertion.location) + }) + if (!matchingAssertion) { + throw new Error( + `Found no assertion for TypeScript version ${version} at line ${position.line} char ${position.char} in '${fileKey}'. Are sourcemaps enabled and working properly?` - ) + ) + } + return [version, matchingAssertion] as VersionedTypeAssertion } - return [version, matchingAssertion] - }) + ) +} + +export const getTypeRelationshipAssertionsAtPosition = ( + position: SourcePosition +): VersionedTypeAssertion[] => { + return getTypeAssertionsAtPosition( + position, + Assertion.Type + ) } + +export const getTypeBenchAssertionsAtPosition = ( + position: SourcePosition +): VersionedTypeAssertion[] => { + return getTypeAssertionsAtPosition( + position, + Assertion.Bench + ) +} \ No newline at end of file diff --git a/ark/attest/cache/snapshots.ts b/ark/attest/cache/snapshots.ts index 068ee4633f..5be8c08dc0 100644 --- a/ark/attest/cache/snapshots.ts +++ b/ark/attest/cache/snapshots.ts @@ -203,6 +203,7 @@ export const writeUpdates = (queuedUpdates: QueuedUpdate[]): void => { const runFormatterIfAvailable = (queuedUpdates: QueuedUpdate[]) => { const { formatter, shouldFormat } = getConfig() if (!shouldFormat) return + try { const updatedPaths = [ ...new Set( diff --git a/ark/attest/cache/ts.ts b/ark/attest/cache/ts.ts index e15749a94f..1d06474064 100644 --- a/ark/attest/cache/ts.ts +++ b/ark/attest/cache/ts.ts @@ -10,7 +10,7 @@ export class TsServer { rootFiles!: string[] virtualEnv!: tsvfs.VirtualTypeScriptEnvironment - static #instance: TsServer | null = null + private static _instance: TsServer | null = null static get instance(): TsServer { return new TsServer() } @@ -145,6 +145,7 @@ export const getTsConfigInfoOrThrow = (): TsconfigInfo => { parsed: configParseResult } } + type TsLibFiles = { defaultMapFromNodeModules: Map resolvedPaths: string[] diff --git a/ark/attest/cache/utils.ts b/ark/attest/cache/utils.ts index dec0f77f20..5aba5362e3 100644 --- a/ark/attest/cache/utils.ts +++ b/ark/attest/cache/utils.ts @@ -41,23 +41,18 @@ export const getCallLocationFromCallExpression = ( return location } -let attestAliasInstantiationMethodCalls: string[] export const gatherInlineInstantiationData = ( file: ts.SourceFile, - fileAssertions: AssertionsByFile + fileAssertions: AssertionsByFile, + attestAliasInstantiationMethodCalls: string[] ): void => { - const { attestAliases } = getConfig() - attestAliasInstantiationMethodCalls ??= attestAliases.map( - (alias) => `${alias}.instantiations` - ) const expressions = getCallExpressionsByName( file, attestAliasInstantiationMethodCalls ) - if (!expressions.length) { - return - } - const enclosingFunctions = expressions.map((expression) => { + if (!expressions.length) return + + const enclosingFunctions = expressions.map(expression => { const attestInstantiationsExpression = getFirstAncestorByKindOrThrow( expression, ts.SyntaxKind.ExpressionStatement @@ -70,13 +65,11 @@ export const gatherInlineInstantiationData = ( position: getCallLocationFromCallExpression(expression) } }) - const instantiationInfo = enclosingFunctions.map((enclosingFunction) => { + const instantiationInfo = enclosingFunctions.map(enclosingFunction => { const body = getDescendants(enclosingFunction.ancestor).find( - (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) + node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) ) as ts.ArrowFunction | ts.FunctionExpression | undefined - if (!body) { - throw new Error("Unable to find file contents") - } + if (!body) throw new Error("Unable to find file contents") return { location: enclosingFunction.position, @@ -96,16 +89,14 @@ export const getCallExpressionsByName = ( isSnapCall = false ): ts.CallExpression[] => { const calls: ts.CallExpression[] = [] - getDescendants(startNode).forEach((descendant) => { + getDescendants(startNode).forEach(descendant => { if (ts.isCallExpression(descendant)) { - if (names.includes(descendant.expression.getText()) || !names.length) { + if (names.includes(descendant.expression.getText()) || !names.length) calls.push(descendant) - } } else if (isSnapCall) { if (ts.isIdentifier(descendant)) { - if (names.includes(descendant.getText()) || !names.length) { + if (names.includes(descendant.getText()) || !names.length) calls.push(descendant as any as ts.CallExpression) - } } } }) @@ -150,9 +141,9 @@ export const createOrUpdateFile = ( fileName: string, fileText: string ): ts.SourceFile | undefined => { - env.sys.fileExists(fileName) - ? env.updateFile(fileName, fileText) - : env.createFile(fileName, fileText) + env.sys.fileExists(fileName) ? + env.updateFile(fileName, fileText) + : env.createFile(fileName, fileText) return env.getSourceFile(fileName) } @@ -162,18 +153,17 @@ const getInstantiationsWithFile = (fileText: string, fileName: string) => { const program = getProgram(env) program.emit(file) const count = program.getInstantiationCount() - if (count === undefined) { + if (count === undefined) throwInternalError(`Unable to gather instantiation count for ${fileText}`) - } + return count } let virtualEnv: tsvfs.VirtualTypeScriptEnvironment | undefined = undefined export const getIsolatedEnv = (): tsvfs.VirtualTypeScriptEnvironment => { - if (virtualEnv !== undefined) { - return virtualEnv - } + if (virtualEnv !== undefined) return virtualEnv + const tsconfigInfo = getTsConfigInfoOrThrow() const libFiles = getTsLibFiles(tsconfigInfo.parsed.options) const projectRoot = process.cwd() @@ -198,11 +188,11 @@ const getBaselineSourceFile = (originalFile: ts.SourceFile): string => { let baselineSourceFileText = originalFile.getFullText() - calls.forEach((call) => { + calls.forEach(call => { baselineSourceFileText = baselineSourceFileText.replace( call.getFullText(), "" ) }) return baselineSourceFileText -} +} \ No newline at end of file diff --git a/ark/attest/cache/writeAssertionCache.ts b/ark/attest/cache/writeAssertionCache.ts index feda9aa2d8..9147c95746 100644 --- a/ark/attest/cache/writeAssertionCache.ts +++ b/ark/attest/cache/writeAssertionCache.ts @@ -18,15 +18,7 @@ import { getCallLocationFromCallExpression } from "./utils.js" -export type AssertionsByFile = Record< - string, - (TypeAssertionData | TypeBenchmarkingAssertionData)[] -> - -export type LocationAndCountAssertionData = Pick< - TypeAssertionData, - "location" | "count" -> +export type AssertionsByFile = Record export const analyzeProjectAssertions = (): AssertionsByFile => { const config = getConfig() @@ -34,6 +26,9 @@ export const analyzeProjectAssertions = (): AssertionsByFile => { const filePaths = instance.rootFiles const diagnosticsByFile = getDiagnosticsByFile() const assertionsByFile: AssertionsByFile = {} + const attestAliasInstantiationMethodCalls = config.attestAliases.map( + alias => `${alias}.instantiations` + ) for (const path of filePaths) { const file = instance.getSourceFileOrThrow(path) const assertionsInFile = getAssertionsInFile( @@ -43,9 +38,12 @@ export const analyzeProjectAssertions = (): AssertionsByFile => { ) if (assertionsInFile.length) assertionsByFile[getFileKey(file.fileName)] = assertionsInFile - } if (!config.skipInlineInstantiations) { - gatherInlineInstantiationData(file, assertionsByFile) + gatherInlineInstantiationData( + file, + assertionsByFile, + attestAliasInstantiationMethodCalls + ) } } return assertionsByFile @@ -63,7 +61,7 @@ export const getAssertionsInFile = ( export const analyzeAssertCall = ( assertCall: ts.CallExpression, diagnosticsByFile: DiagnosticsByFile -): TypeRelationshipAssertionData => { +): TypeAssertionData => { const types = extractArgumentTypesFromCall(assertCall) const location = getCallLocationFromCallExpression(assertCall) const args = types.args.map(arg => serializeArg(arg, types)) @@ -193,11 +191,6 @@ export type ArgAssertionData = { } } -/** - * todoshawn typeassertiondata should be it's own union - * typerelationshipassertiondata - * typebenchmarkingassertiondata - */ export type TypeRelationshipAssertionData = { location: LinePositionRange args: ArgAssertionData[] @@ -205,12 +198,15 @@ export type TypeRelationshipAssertionData = { errors: string[] completions: Completions } + export type TypeBenchmarkingAssertionData = { location: LinePositionRange count: number } -export type TypeAssertionData = TypeRelationshipAssertionData & - TypeBenchmarkingAssertionData + +export type TypeAssertionData = + | TypeRelationshipAssertionData + | TypeBenchmarkingAssertionData export type LinePositionRange = { start: LinePosition diff --git a/ark/attest/main.ts b/ark/attest/main.ts index 1f23315ca7..b1596dffae 100644 --- a/ark/attest/main.ts +++ b/ark/attest/main.ts @@ -1,7 +1,10 @@ export { caller, type CallerOfOptions } from "@arktype/fs" export { attest } from "./assert/attest.js" export { bench } from "./bench/bench.js" -export { getTypeAssertionsAtPosition } from "./cache/getCachedAssertions.js" +export { + getTypeBenchAssertionsAtPosition, + getTypeRelationshipAssertionsAtPosition +} from "./cache/getCachedAssertions.js" export type { ArgAssertionData, LinePositionRange, From 584206dc9e3d9880c23f0d8d70a57b511d9b7bb0 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 1 May 2024 11:12:22 -0400 Subject: [PATCH 07/10] pr --- ark/attest/__tests__/benchTemplate.ts | 1 - ark/attest/assert/attest.ts | 1 - ark/attest/assert/chainableAssertions.ts | 6 ++++-- ark/attest/bench/bench.ts | 8 +++----- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/ark/attest/__tests__/benchTemplate.ts b/ark/attest/__tests__/benchTemplate.ts index 2d0b9d9b28..077d49a7ff 100644 --- a/ark/attest/__tests__/benchTemplate.ts +++ b/ark/attest/__tests__/benchTemplate.ts @@ -1,5 +1,4 @@ import { bench } from "@arktype/attest" -import { type } from "arktype" import type { makeComplexType as externalmakeComplexType } from "./utils.js" const fakeCallOptions = { diff --git a/ark/attest/assert/attest.ts b/ark/attest/assert/attest.ts index 7a0322aa05..84c39510b9 100644 --- a/ark/attest/assert/attest.ts +++ b/ark/attest/assert/attest.ts @@ -85,7 +85,6 @@ attestInternal.instantiations = ( const calledFrom = caller() const ctx = getBenchCtx([calledFrom.file]) - ctx.isInlineBench = true ctx.benchCallPosition = calledFrom ctx.lastSnapCallPosition = calledFrom instantiationDataHandler({ ...ctx, kind: "instantiations" }, args, false) diff --git a/ark/attest/assert/chainableAssertions.ts b/ark/attest/assert/chainableAssertions.ts index 30c2092cdf..1a2e51193d 100644 --- a/ark/attest/assert/chainableAssertions.ts +++ b/ark/attest/assert/chainableAssertions.ts @@ -41,8 +41,10 @@ export class ChainableAssertions implements AssertionRecord { private get actual() { if (this.ctx.actual instanceof TypeAssertionMapping) { - const assertionEntry = this.ctx.typeRelationshipAssertionEntries![0][1] - return this.ctx.actual.fn(assertionEntry, this.ctx)!.actual + return this.ctx.actual.fn( + this.ctx.typeRelationshipAssertionEntries![0][1], + this.ctx + )!.actual } return this.ctx.actual } diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts index 932a97f057..a64afdd192 100644 --- a/ark/attest/bench/bench.ts +++ b/ark/attest/bench/bench.ts @@ -306,7 +306,6 @@ export type BenchContext = { benchCallPosition: SourcePosition lastSnapCallPosition: SourcePosition | undefined isAsync: boolean - isInlineBench: boolean } export type BenchAssertionContext = BenchContext & { @@ -328,11 +327,11 @@ process.on("beforeExit", () => { process.exit(1) } }) + export const getBenchCtx = ( qualifiedPath: string[], isAsync: boolean = false, - options: BenchOptions = {}, - isInlineBench = false + options: BenchOptions = {} ): BenchContext => { return { qualifiedPath, @@ -341,7 +340,6 @@ export const getBenchCtx = ( cfg: getConfig(), benchCallPosition: caller(), lastSnapCallPosition: undefined, - isAsync, - isInlineBench + isAsync } as BenchContext } From f773a68d34fe35fff58153862cfe34e9775eada6 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 1 May 2024 11:13:00 -0400 Subject: [PATCH 08/10] remove unused import --- ark/attest/assert/assertions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ark/attest/assert/assertions.ts b/ark/attest/assert/assertions.ts index 83527ceae0..f6c0813cdf 100644 --- a/ark/attest/assert/assertions.ts +++ b/ark/attest/assert/assertions.ts @@ -1,4 +1,4 @@ -import { hasKey, printable, throwInternalError } from "@arktype/util" +import { printable, throwInternalError } from "@arktype/util" import { AssertionError } from "node:assert" import * as assert from "node:assert/strict" import type { TypeRelationshipAssertionData } from "../cache/writeAssertionCache.js" From 36acdbe51dafdc1f2ca2922ab41641e8d626dcd9 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 1 May 2024 13:27:27 -0400 Subject: [PATCH 09/10] remove enum --- ark/attest/__tests__/assert.snapshots.json | 10 ++++++++++ ark/attest/__tests__/custom.snapshots.json | 7 +++++++ ark/attest/cache/getCachedAssertions.ts | 15 ++++++--------- 3 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 ark/attest/__tests__/assert.snapshots.json create mode 100644 ark/attest/__tests__/custom.snapshots.json diff --git a/ark/attest/__tests__/assert.snapshots.json b/ark/attest/__tests__/assert.snapshots.json new file mode 100644 index 0000000000..5996ddc646 --- /dev/null +++ b/ark/attest/__tests__/assert.snapshots.json @@ -0,0 +1,10 @@ +{ + "externalSnapshots.test.ts": { + "toFile": { + "re": "do" + }, + "toFileUpdate": { + "re": "oldValue" + } + } +} diff --git a/ark/attest/__tests__/custom.snapshots.json b/ark/attest/__tests__/custom.snapshots.json new file mode 100644 index 0000000000..d9007de20b --- /dev/null +++ b/ark/attest/__tests__/custom.snapshots.json @@ -0,0 +1,7 @@ +{ + "externalSnapshots.test.ts": { + "toCustomFile": { + "re": "do" + } + } +} diff --git a/ark/attest/cache/getCachedAssertions.ts b/ark/attest/cache/getCachedAssertions.ts index 27e6a7339a..9653232053 100644 --- a/ark/attest/cache/getCachedAssertions.ts +++ b/ark/attest/cache/getCachedAssertions.ts @@ -75,20 +75,17 @@ export type VersionedTypeAssertion< data extends TypeAssertionData = TypeAssertionData > = [tsVersion: string, assertionData: data] -enum Assertion { - Bench, - Type -} +type AssertionKind = "bench" | "type" const getTypeAssertionsAtPosition = ( position: SourcePosition, - assertionType: Assertion + assertionType: AssertionKind ): VersionedTypeAssertion[] => { const fileKey = getFileKey(position.file) return getCachedAssertionEntries().map( ([version, typeRelationshipAssertions, BenchAssertionAssertions]) => { const assertions = - assertionType === Assertion.Type ? + assertionType === "type" ? typeRelationshipAssertions : BenchAssertionAssertions if (!assertions[fileKey]) { @@ -122,7 +119,7 @@ export const getTypeRelationshipAssertionsAtPosition = ( ): VersionedTypeAssertion[] => { return getTypeAssertionsAtPosition( position, - Assertion.Type + "type" ) } @@ -131,6 +128,6 @@ export const getTypeBenchAssertionsAtPosition = ( ): VersionedTypeAssertion[] => { return getTypeAssertionsAtPosition( position, - Assertion.Bench + "bench" ) -} \ No newline at end of file +} From 33eb0d6972d50a8e54cdaa4b630153f36c641838 Mon Sep 17 00:00:00 2001 From: Shawn Morreau Date: Wed, 1 May 2024 13:31:35 -0400 Subject: [PATCH 10/10] pr --- ark/attest/__tests__/assert.snapshots.json | 10 --------- ark/attest/__tests__/custom.snapshots.json | 7 ------- ark/attest/__tests__/demo.test.ts | 24 +++++++++++----------- 3 files changed, 12 insertions(+), 29 deletions(-) delete mode 100644 ark/attest/__tests__/assert.snapshots.json delete mode 100644 ark/attest/__tests__/custom.snapshots.json diff --git a/ark/attest/__tests__/assert.snapshots.json b/ark/attest/__tests__/assert.snapshots.json deleted file mode 100644 index 5996ddc646..0000000000 --- a/ark/attest/__tests__/assert.snapshots.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "externalSnapshots.test.ts": { - "toFile": { - "re": "do" - }, - "toFileUpdate": { - "re": "oldValue" - } - } -} diff --git a/ark/attest/__tests__/custom.snapshots.json b/ark/attest/__tests__/custom.snapshots.json deleted file mode 100644 index d9007de20b..0000000000 --- a/ark/attest/__tests__/custom.snapshots.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "externalSnapshots.test.ts": { - "toCustomFile": { - "re": "do" - } - } -} diff --git a/ark/attest/__tests__/demo.test.ts b/ark/attest/__tests__/demo.test.ts index a00a45598e..c4e5aba0aa 100644 --- a/ark/attest/__tests__/demo.test.ts +++ b/ark/attest/__tests__/demo.test.ts @@ -77,17 +77,17 @@ contextualize(() => { // works for keys or index access as well (may need prettier-ignore to // avoid removing quotes) // prettier-ignore - attest({ f: "🐐" } as Legends).completions({ f: ["faker"] }) - }) - - it("integrate runtime logic with type assertions", () => { - const arrayOf = type("", "t[]") - const numericArray = arrayOf("number | bigint") - // flexibly combine runtime logic with type assertions to customize your - // tests beyond what is possible from pure static-analysis based type testing tools - if (getPrimaryTsVersionUnderTest().startsWith("5")) { - // this assertion will only occur when testing TypeScript 5+! - attest<(number | bigint)[]>(numericArray.infer) - } + attest({ "f": "🐐" } as Legends).completions({ f: ["faker"] }) }) + // TODO: reenable once generics are finished + // it("integrate runtime logic with type assertions", () => { + // const arrayOf = type("", "t[]") + // const numericArray = arrayOf("number | bigint") + // // flexibly combine runtime logic with type assertions to customize your + // // tests beyond what is possible from pure static-analysis based type testing tools + // if (getPrimaryTsVersionUnderTest().startsWith("5")) { + // // this assertion will only occur when testing TypeScript 5+! + // attest<(number | bigint)[]>(numericArray.infer) + // } + // }) })