@highstate/cli 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,17 +50,44 @@ async function applyZodMetaTransformations(content: string): Promise<string> {
50
50
  // handle zod object patterns
51
51
  if (isZodObjectProperty(node, parentStack)) {
52
52
  const jsdoc = findLeadingComment(content, node, comments)
53
- if (jsdoc) {
54
- const description = cleanJsdoc(jsdoc.value)
55
- const fieldName =
56
- "name" in node.key && typeof node.key.name === "string" ? node.key.name : "unknown"
57
- const originalValue = content.substring(node.value.start, node.value.end)
58
-
59
- if (!originalValue.includes(".meta(")) {
60
- const newValue = `${originalValue}.meta({ title: __camelCaseToHumanReadable("${fieldName}"), description: \`${description}\` })`
61
- result.update(node.value.start, node.value.end, newValue)
62
- hasTransformations = true
53
+ if (!jsdoc) {
54
+ return
55
+ }
56
+
57
+ const description = cleanJsdoc(jsdoc.value)
58
+ const fieldName =
59
+ "name" in node.key && typeof node.key.name === "string" ? node.key.name : "unknown"
60
+
61
+ // Getter properties (get foo() { return ... }) need special handling:
62
+ // we inject `.meta(...)` into the returned schema expression.
63
+ if (isGetterProperty(node)) {
64
+ const returnArgument = findFirstReturnArgument(node)
65
+ if (!returnArgument) {
66
+ return
63
67
  }
68
+
69
+ const originalReturnValue = content.substring(returnArgument.start, returnArgument.end)
70
+ if (originalReturnValue.includes(".meta(")) {
71
+ return
72
+ }
73
+
74
+ const newReturnValue = `${originalReturnValue}.meta({ title: __camelCaseToHumanReadable("${fieldName}"), description: \`${description}\` })`
75
+ result.update(returnArgument.start, returnArgument.end, newReturnValue)
76
+ hasTransformations = true
77
+ return
78
+ }
79
+
80
+ // Standard properties (foo: z.string())
81
+ if (!hasValue(node) || !hasSourceRange(node.value)) {
82
+ return
83
+ }
84
+
85
+ const originalValue = content.substring(node.value.start, node.value.end)
86
+
87
+ if (!originalValue.includes(".meta(")) {
88
+ const newValue = `${originalValue}.meta({ title: __camelCaseToHumanReadable("${fieldName}"), description: \`${description}\` })`
89
+ result.update(node.value.start, node.value.end, newValue)
90
+ hasTransformations = true
64
91
  }
65
92
  }
66
93
  },
@@ -101,11 +128,36 @@ async function applyHelperFunctionTransformations(content: string): Promise<stri
101
128
 
102
129
  if (jsdoc) {
103
130
  const description = cleanJsdoc(jsdoc.value)
131
+ // Getter properties (get foo() { return ... }) need special handling:
132
+ // inject the helper call into the returned expression.
133
+ if (isGetterProperty(propertyNode)) {
134
+ const returnArgument = findFirstReturnArgument(propertyNode)
135
+ if (!returnArgument) {
136
+ return
137
+ }
138
+
139
+ const originalReturnValue = content.substring(
140
+ returnArgument.start,
141
+ returnArgument.end,
142
+ )
143
+ if (originalReturnValue.trimStart().startsWith(`${helperFunction}(`)) {
144
+ return
145
+ }
146
+
147
+ const newReturnValue = `${helperFunction}(${originalReturnValue}, \`${description}\`)`
148
+ result.update(returnArgument.start, returnArgument.end, newReturnValue)
149
+ hasTransformations = true
150
+ return
151
+ }
152
+
153
+ if (!hasValue(propertyNode) || !hasSourceRange(propertyNode.value)) {
154
+ return
155
+ }
156
+
104
157
  const originalValue = content.substring(
105
158
  propertyNode.value.start,
106
159
  propertyNode.value.end,
107
160
  )
108
-
109
161
  const newValue = `${helperFunction}(${originalValue}, \`${description}\`)`
110
162
  result.update(propertyNode.value.start, propertyNode.value.end, newValue)
111
163
  hasTransformations = true
@@ -242,6 +294,60 @@ function isZodObjectProperty(node: Node, parentStack: Node[]): node is ObjectPro
242
294
  )
243
295
  }
244
296
 
297
+ type GetterObjectProperty = ObjectProperty & {
298
+ kind: "get"
299
+ value: Node & { type: "FunctionExpression" }
300
+ }
301
+
302
+ function isGetterProperty(node: Node): node is GetterObjectProperty {
303
+ if (node.type !== "Property") {
304
+ return false
305
+ }
306
+
307
+ if (!hasValue(node)) {
308
+ return false
309
+ }
310
+
311
+ return "kind" in node && node.kind === "get" && node.value.type === "FunctionExpression"
312
+ }
313
+
314
+ function findFirstReturnArgument(node: ObjectProperty): Node | null {
315
+ if (!hasValue(node) || node.value.type !== "FunctionExpression") {
316
+ return null
317
+ }
318
+
319
+ const body = node.value.body
320
+ if (!body || body.type !== "BlockStatement") {
321
+ return null
322
+ }
323
+
324
+ for (const statement of body.body) {
325
+ if (statement.type !== "ReturnStatement") {
326
+ continue
327
+ }
328
+
329
+ const returnArgument = "argument" in statement ? statement.argument : null
330
+ if (returnArgument && hasSourceRange(returnArgument)) {
331
+ return returnArgument
332
+ }
333
+ }
334
+
335
+ return null
336
+ }
337
+
338
+ function hasValue(node: Node): node is Node & { value: Node } {
339
+ return "value" in node && !!node.value
340
+ }
341
+
342
+ function hasSourceRange(node: Node): node is Node & { start: number; end: number } {
343
+ return (
344
+ "start" in node &&
345
+ typeof node.start === "number" &&
346
+ "end" in node &&
347
+ typeof node.end === "number"
348
+ )
349
+ }
350
+
245
351
  function findLeadingComment(content: string, node: Node, comments: Comment[]): Comment | null {
246
352
  return comments.find(comment => isLeadingComment(content, node, comment)) ?? null
247
353
  }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { parseFileDependencies } from "./source-hash-calculator"
3
+
4
+ describe("parseFileDependencies", () => {
5
+ test("parses relative imports", () => {
6
+ const filePath = "/repo/src/main.ts"
7
+ const content = "import { x } from './utils'\n"
8
+
9
+ const deps = parseFileDependencies(filePath, content)
10
+
11
+ expect(deps).toEqual([
12
+ {
13
+ type: "relative",
14
+ id: "relative:/repo/src/utils",
15
+ fullPath: "/repo/src/utils",
16
+ },
17
+ ])
18
+ })
19
+
20
+ test("parses npm imports", () => {
21
+ const filePath = "/repo/src/main.ts"
22
+ const content = "import { mapValues } from 'remeda'\n"
23
+
24
+ const deps = parseFileDependencies(filePath, content)
25
+
26
+ expect(deps).toEqual([
27
+ {
28
+ type: "npm",
29
+ id: "npm:remeda",
30
+ package: "remeda",
31
+ },
32
+ ])
33
+ })
34
+
35
+ test("ignores node: built-in imports", () => {
36
+ const filePath = "/repo/src/main.ts"
37
+ const content = "import { readFile } from 'node:fs/promises'\n"
38
+
39
+ const deps = parseFileDependencies(filePath, content)
40
+
41
+ expect(deps).toEqual([])
42
+ })
43
+
44
+ test("supports multi-line imports", () => {
45
+ const filePath = "/repo/src/main.ts"
46
+ const content = 'import {\n a,\n b,\n} from "pkg-types"\n'
47
+
48
+ const deps = parseFileDependencies(filePath, content)
49
+
50
+ expect(deps).toEqual([
51
+ {
52
+ type: "npm",
53
+ id: "npm:pkg-types",
54
+ package: "pkg-types",
55
+ },
56
+ ])
57
+ })
58
+
59
+ test("does not match identifiers like importBasePath or template strings containing from", () => {
60
+ const filePath = "/repo/src/main.ts"
61
+ const content = [
62
+ "const x = {",
63
+ " importBasePath: '/some/path',",
64
+ "}",
65
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: false positive
66
+ 'throw new Error(`Cannot use output "foo" from "${input.input.instanceId}"`)\n',
67
+ ].join("\n")
68
+
69
+ const deps = parseFileDependencies(filePath, content)
70
+
71
+ expect(deps).toEqual([])
72
+ })
73
+ })
@@ -28,6 +28,44 @@ type FileDependency =
28
28
  package: string
29
29
  }
30
30
 
31
+ export function parseFileDependencies(filePath: string, content: string): FileDependency[] {
32
+ type DependencyMatch = {
33
+ relativePath?: string
34
+ nodeBuiltin?: string
35
+ npmPackage?: string
36
+ }
37
+
38
+ const dependencyRegex =
39
+ /^[ \t]*import\b[\s\S]*?\bfrom\s*["']((?<relativePath>\.\.?\/[^"']+)|(?<nodeBuiltin>node:[^"']+)|(?<npmPackage>[^"']+))["']/gm
40
+
41
+ const matches = content.matchAll(dependencyRegex)
42
+ const dependencies: FileDependency[] = []
43
+
44
+ for (const match of matches) {
45
+ const { nodeBuiltin, npmPackage, relativePath } = match.groups as DependencyMatch
46
+
47
+ if (relativePath) {
48
+ const fullPath = resolve(dirname(filePath), relativePath)
49
+
50
+ dependencies.push({
51
+ type: "relative",
52
+ id: `relative:${fullPath}`,
53
+ fullPath,
54
+ })
55
+ } else if (npmPackage) {
56
+ dependencies.push({
57
+ type: "npm",
58
+ id: `npm:${npmPackage}`,
59
+ package: npmPackage,
60
+ })
61
+ } else if (nodeBuiltin) {
62
+ // ignore node built-in modules
63
+ }
64
+ }
65
+
66
+ return dependencies
67
+ }
68
+
31
69
  export class SourceHashCalculator {
32
70
  private readonly dependencyHashes = new Map<string, Promise<number>>()
33
71
  private readonly fileHashes = new Map<string, Promise<number>>()
@@ -174,7 +212,7 @@ export class SourceHashCalculator {
174
212
 
175
213
  private async calculateFileHash(fullPath: string): Promise<number> {
176
214
  const content = await readFile(fullPath, "utf8")
177
- const fileDeps = this.parseDependencies(fullPath, content)
215
+ const fileDeps = parseFileDependencies(fullPath, content)
178
216
 
179
217
  const hashes = await Promise.all([
180
218
  this.hashString(content),
@@ -284,42 +322,4 @@ export class SourceHashCalculator {
284
322
  basePath = resolve(dirname(basePath), "..")
285
323
  }
286
324
  }
287
-
288
- private parseDependencies(filePath: string, content: string): FileDependency[] {
289
- type DependencyMatch = {
290
- relativePath?: string
291
- nodeBuiltin?: string
292
- npmPackage?: string
293
- }
294
-
295
- const dependencyRegex =
296
- /^[ \t]*import[\s\S]*?\bfrom\s*["']((?<relativePath>\.\.?\/[^"']+)|(?<nodeBuiltin>node:[^"']+)|(?<npmPackage>[^"']+))["']/gm
297
-
298
- const matches = content.matchAll(dependencyRegex)
299
- const dependencies: FileDependency[] = []
300
-
301
- for (const match of matches) {
302
- const { nodeBuiltin, npmPackage, relativePath } = match.groups as DependencyMatch
303
-
304
- if (relativePath) {
305
- const fullPath = resolve(dirname(filePath), relativePath)
306
-
307
- dependencies.push({
308
- type: "relative",
309
- id: `relative:${fullPath}`,
310
- fullPath,
311
- })
312
- } else if (npmPackage) {
313
- dependencies.push({
314
- type: "npm",
315
- id: `npm:${npmPackage}`,
316
- package: npmPackage,
317
- })
318
- } else if (nodeBuiltin) {
319
- // ignore node built-in modules
320
- }
321
- }
322
-
323
- return dependencies
324
- }
325
325
  }