Skip to content

Commit c4589a1

Browse files
authored
preserve jsdoc and navigation for homomorphic keys (#1341)
1 parent 508106d commit c4589a1

File tree

16 files changed

+245
-43
lines changed

16 files changed

+245
-43
lines changed

ark/attest/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
NOTE: This changelog is incomplete, but will include notable attest-specific changes (many updates consist almost entirely of bumped `arktype` versions for assertions).
44

5+
## 0.44.0
6+
7+
Support assertions for JSDoc contents associated with an `attest`ed value
8+
9+
```ts
10+
const t = type({
11+
/** FOO */
12+
foo: "string"
13+
})
14+
15+
const out = t.assert({ foo: "foo" })
16+
17+
// match or snapshot expected jsdoc associated with the value passed to attest
18+
attest(out.foo).jsdoc.snap("FOO")
19+
```
20+
521
## 0.41.0
622

723
### Bail early for obviously incorrect `equals` comparisons

ark/attest/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ describe("attest features", () => {
112112
attest({ "f": "🐐" } as Legends).completions({ "f": ["faker"] })
113113
})
114114

115+
it("jsdoc snapshotting", () => {
116+
// match or snapshot expected jsdoc associated with the value passed to attest
117+
const t = type({
118+
/** FOO */
119+
foo: "string"
120+
})
121+
122+
const out = t.assert({ foo: "foo" })
123+
124+
attest(out.foo).jsdoc.snap("FOO")
125+
})
126+
115127
it("integrate runtime logic with type assertions", () => {
116128
const arrayOf = type("<t>", "t[]")
117129
const numericArray = arrayOf("number | bigint")

ark/attest/__tests__/assertions.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,25 @@ Actual: string`)
180180
Expected: {"a":"five"}
181181
Actual: ArkErrors`)
182182
})
183+
184+
it("jsdoc ", () => {
185+
type O = {
186+
/** FOO */
187+
foo: string
188+
bar: number
189+
}
190+
191+
const o: O = {
192+
foo: "foo",
193+
bar: 5
194+
}
195+
196+
attest(o.foo).jsdoc.equals("FOO")
197+
198+
assert.throws(
199+
() => attest(o.bar).jsdoc.equals("BAR"),
200+
assert.AssertionError,
201+
"BAR"
202+
)
203+
})
183204
})

ark/attest/assert/chainableAssertions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,16 @@ export class ChainableAssertions implements AssertionRecord {
234234
return this.snap
235235
}
236236

237+
get jsdoc(): any {
238+
if (this.ctx.cfg.skipTypes) return chainableNoOpProxy
239+
240+
this.ctx.versionableActual = new TypeAssertionMapping(data => ({
241+
actual: formatTypeString(data.jsdoc ?? "")
242+
}))
243+
this.ctx.allowRegex = true
244+
return this.immediateOrChained()
245+
}
246+
237247
get type(): any {
238248
if (this.ctx.cfg.skipTypes) return chainableNoOpProxy
239249

@@ -351,6 +361,7 @@ export type comparableValueAssertion<expected, kind extends AssertionKind> = {
351361
instanceOf: (constructor: Constructor) => nextAssertions<kind>
352362
is: (value: expected) => nextAssertions<kind>
353363
completions: CompletionsSnap
364+
jsdoc: comparableValueAssertion<string, kind>
354365
satisfies: <const def>(
355366
def: type.validate<def> &
356367
validateExpectedOverlaps<expected, type.infer.In<def>>

ark/attest/cache/utils.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@ import {
1111
getTsConfigInfoOrThrow,
1212
getTsLibFiles
1313
} from "./ts.ts"
14-
import type {
15-
AssertionsByFile,
16-
LinePositionRange
17-
} from "./writeAssertionCache.ts"
14+
import type { LinePositionRange } from "./writeAssertionCache.ts"
1815

1916
export const getCallLocationFromCallExpression = (
2017
callExpression: ts.CallExpression
@@ -41,15 +38,16 @@ export const getCallLocationFromCallExpression = (
4138
return location
4239
}
4340

41+
/**
42+
* Processes inline instantiations from an attest call
43+
* Preserves any JSDoc comments that are associated with the original expression
44+
*/
4445
export const gatherInlineInstantiationData = (
4546
file: ts.SourceFile,
46-
fileAssertions: AssertionsByFile,
47-
attestAliasInstantiationMethodCalls: string[]
47+
assertionsByFile: Record<string, any[]>,
48+
instantiationMethodCalls: string[]
4849
): void => {
49-
const expressions = getCallExpressionsByName(
50-
file,
51-
attestAliasInstantiationMethodCalls
52-
)
50+
const expressions = getCallExpressionsByName(file, instantiationMethodCalls)
5351
if (!expressions.length) return
5452

5553
const enclosingFunctions = expressions.map(expression => {
@@ -81,8 +79,8 @@ ${enclosingFunction.ancestor.getText()}`
8179
count: getInstantiationsContributedByNode(file, body)
8280
}
8381
})
84-
const assertions = fileAssertions[getFileKey(file.fileName)] ?? []
85-
fileAssertions[getFileKey(file.fileName)] = [
82+
const assertions = assertionsByFile[getFileKey(file.fileName)] ?? []
83+
assertionsByFile[getFileKey(file.fileName)] = [
8684
...assertions,
8785
...instantiationInfo
8886
]

ark/attest/cache/writeAssertionCache.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,99 @@ export const analyzeAssertCall = (
6868
const typeArgs = types.typeArgs.map(typeArg => serializeArg(typeArg, types))
6969
const errors = checkDiagnosticMessages(assertCall, diagnosticsByFile)
7070
const completions = getCompletions(assertCall)
71-
return {
71+
72+
// Extract JSDoc comment for first argument if available
73+
const jsdoc = extractJSDocFromArgument(assertCall)
74+
75+
const result: TypeAssertionData = {
7276
location,
7377
args,
7478
typeArgs,
7579
errors,
7680
completions
7781
}
82+
83+
if (jsdoc) result.jsdoc = jsdoc
84+
85+
return result
86+
}
87+
88+
/**
89+
* Extract JSDoc comments associated with the first argument of a call expression
90+
*/
91+
const extractJSDocFromArgument = (
92+
callExpr: ts.CallExpression
93+
): string | undefined => {
94+
// We're only interested in the first argument
95+
const firstArg = callExpr.arguments[0]
96+
if (!firstArg) return undefined
97+
98+
const checker = getInternalTypeChecker()
99+
100+
// If the argument is a property access expression (e.g., out.foo)
101+
if (ts.isPropertyAccessExpression(firstArg)) {
102+
// Try to find the symbol for the property
103+
const propSymbol = checker.getSymbolAtLocation(firstArg)
104+
if (propSymbol) {
105+
// Get JSDoc from property declarations
106+
return getJSDocFromSymbol(propSymbol)
107+
}
108+
}
109+
// If argument is an identifier, try to find its declaration's JSDoc
110+
else if (ts.isIdentifier(firstArg)) {
111+
const symbol = checker.getSymbolAtLocation(firstArg)
112+
if (symbol) return getJSDocFromSymbol(symbol)
113+
}
114+
115+
return undefined
116+
}
117+
118+
/**
119+
* Extract JSDoc comments from a symbol's declarations
120+
*/
121+
const getJSDocFromSymbol = (symbol: ts.Symbol): string | undefined => {
122+
// Get JSDoc directly from the symbol if possible
123+
const symbolDocumentation = ts.displayPartsToString(
124+
symbol.getDocumentationComment(getInternalTypeChecker())
125+
)
126+
if (symbolDocumentation) return symbolDocumentation.trim()
127+
128+
// If no symbol documentation, try to get JSDoc from declarations
129+
const declarations = symbol.getDeclarations() || []
130+
for (const declaration of declarations) {
131+
// For property declarations in object literals, get the JSDoc comment
132+
if (
133+
ts.isPropertyAssignment(declaration) ||
134+
ts.isShorthandPropertyAssignment(declaration) ||
135+
ts.isPropertyDeclaration(declaration)
136+
) {
137+
const jsDocTags = ts.getJSDocTags(declaration)
138+
if (jsDocTags.length > 0) {
139+
return jsDocTags
140+
.map(tag => {
141+
const comment = tag.comment?.toString() || ""
142+
return tag.tagName.text + (comment ? ` ${comment}` : "")
143+
})
144+
.join("\n")
145+
}
146+
147+
// Try to get JSDoc comment before the property
148+
const jsDocComments = ts.getJSDocCommentsAndTags(declaration)
149+
if (jsDocComments && jsDocComments.length > 0) {
150+
return jsDocComments
151+
.map(doc => {
152+
if (ts.isJSDoc(doc)) return doc.comment || ""
153+
154+
return ""
155+
})
156+
.filter(Boolean)
157+
.join("\n")
158+
.trim()
159+
}
160+
}
161+
}
162+
163+
return undefined
78164
}
79165

80166
const serializeArg = (
@@ -197,6 +283,8 @@ export type TypeRelationshipAssertionData = {
197283
typeArgs: ArgAssertionData[]
198284
errors: string[]
199285
completions: Completions
286+
/** JSDoc comment for the first argument, if any */
287+
jsdoc?: string
200288
}
201289

202290
export type TypeBenchmarkingAssertionData = {

ark/attest/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ark/attest",
3-
"version": "0.43.4",
3+
"version": "0.44.0",
44
"license": "MIT",
55
"author": {
66
"name": "David Blass",

ark/fs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ark/fs",
3-
"version": "0.43.4",
3+
"version": "0.44.0",
44
"license": "MIT",
55
"author": {
66
"name": "David Blass",

ark/repo/scratch.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { type } from "arktype"
22

3-
const types = type.module(
4-
{
5-
foo: {
6-
test: "string = 'test'"
7-
}
8-
},
9-
{ jitless: true }
10-
)
3+
const t = type({
4+
/** FOO */
5+
foo: "string",
6+
/** BAR */
7+
bar: "number?"
8+
})
119

12-
types.foo({}) //?
10+
const out = t.assert({ foo: "foo" })
11+
12+
out.foo
13+
out.bar

ark/schema/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ark/schema",
3-
"version": "0.43.4",
3+
"version": "0.44.0",
44
"license": "MIT",
55
"author": {
66
"name": "David Blass",

0 commit comments

Comments
 (0)