@astrale-os/cli 0.8.1-alpha.7 → 1.0.0-beta.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.
Files changed (64) hide show
  1. package/README.md +15 -3
  2. package/dist/astrale.js +86 -35
  3. package/package.json +5 -5
  4. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  5. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  6. package/src/commands/__tests__/install-direct.test.ts +3 -2
  7. package/src/commands/domain/install.ts +78 -15
  8. package/src/connection/__tests__/errors.test.ts +82 -6
  9. package/src/connection/command.ts +9 -1
  10. package/src/connection/errors.ts +19 -8
  11. package/src/connection/index.ts +1 -1
  12. package/src/connection/reasons.ts +26 -12
  13. package/src/program/__tests__/program.test.ts +1 -1
  14. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  15. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  16. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  17. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  18. package/studio/client/dist/index.html +2 -2
  19. package/studio/package.json +1 -1
  20. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  21. package/studio/server/agent/prompts/anchors.ts +85 -15
  22. package/studio/server/agent/prompts/system.test.ts +12 -0
  23. package/studio/server/agent/prompts/system.ts +6 -6
  24. package/studio/server/api.ts +1 -5
  25. package/studio/server/cache.ts +5 -2
  26. package/studio/server/domain.test.ts +67 -0
  27. package/studio/server/domain.ts +29 -6
  28. package/studio/server/index.ts +1 -3
  29. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  30. package/studio/server/introspect/anatomy-extras.ts +340 -8
  31. package/studio/server/introspect/anatomy.test.ts +33 -0
  32. package/studio/server/introspect/anatomy.ts +22 -7
  33. package/studio/server/introspect/bundle.ts +7 -1
  34. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  35. package/studio/server/introspect/canonical-schema.ts +751 -0
  36. package/studio/server/introspect/core-extractor.ts +30 -10
  37. package/studio/server/introspect/core.ts +4 -2
  38. package/studio/server/introspect/diff.test.ts +124 -0
  39. package/studio/server/introspect/diff.ts +252 -23
  40. package/studio/server/introspect/extractor.ts +46 -14
  41. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  42. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  43. package/studio/server/introspect/overlay.test.ts +72 -0
  44. package/studio/server/introspect/overlay.ts +16 -6
  45. package/studio/server/introspect/runtime.test.ts +217 -0
  46. package/studio/server/introspect/runtime.ts +18 -6
  47. package/studio/server/introspect/schema-refs.test.ts +100 -0
  48. package/studio/server/introspect/schema-refs.ts +23 -2
  49. package/studio/server/state/baseline.test.ts +51 -0
  50. package/studio/server/state/baseline.ts +31 -2
  51. package/studio/server/state/create.test.ts +37 -0
  52. package/studio/server/state/create.ts +21 -15
  53. package/studio/server/state/instance.test.ts +63 -0
  54. package/studio/server/state/instance.ts +65 -29
  55. package/studio/server/state/views.test.ts +209 -9
  56. package/studio/server/state/views.ts +176 -69
  57. package/studio/server/watch.test.ts +36 -0
  58. package/studio/server/watch.ts +24 -16
  59. package/studio/server/workspace-watch.ts +8 -3
  60. package/studio/shared/types.ts +164 -33
  61. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  62. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  63. package/studio/server/view-dev-server.test.ts +0 -111
  64. package/studio/server/view-dev-server.ts +0 -372
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
2
  import { dirname, isAbsolute, relative, resolve as resolvePath } from 'node:path'
3
3
  /**
4
4
  * ts-morph overlay for IR gaps: handler links, source spans, and annotations.
@@ -37,7 +37,14 @@ function tryAddFile(project: Project, file: string): SourceFile | undefined {
37
37
  /** Path relative to `root`, POSIX-style ('schema/monitor.ts'), never absolute. */
38
38
  function relToRoot(root: string, file: string): string {
39
39
  const abs = isAbsolute(file) ? file : resolvePath(root, file)
40
- return relative(root, abs).split('\\').join('/')
40
+ const canonical = (value: string) => {
41
+ try {
42
+ return realpathSync(value)
43
+ } catch {
44
+ return value
45
+ }
46
+ }
47
+ return relative(canonical(root), canonical(abs)).split('\\').join('/')
41
48
  }
42
49
 
43
50
  /** 1-based start line of a node. */
@@ -111,6 +118,9 @@ interface WiredMethod {
111
118
  /** the object literal / call expression carrying the handler config */
112
119
  config: Node
113
120
  wiringLine: number
121
+ wiringFile: string
122
+ ownerKind?: 'class' | 'interface' | 'function'
123
+ style?: 'legacy-config' | 'direct-handler'
114
124
  }
115
125
 
116
126
  const SINGLE_METHOD_HELPERS = new Set(['method', 'remoteMethod'])
@@ -529,7 +539,19 @@ function resolveViaModuleSpecifier(id: Node): { file: string; line: number } | u
529
539
 
530
540
  /** Resolve a relative module specifier to a concrete .ts file path on disk. */
531
541
  function resolveModuleFile(from: SourceFile, spec: string): string | undefined {
532
- if (!spec.startsWith('.')) return undefined
542
+ if (!spec.startsWith('.')) {
543
+ // Current SDK projects use package `imports` aliases such as
544
+ // `#actions/risk`. Resolve them from the authored file's directory through
545
+ // Bun so the overlay follows the same project-local map as runtime imports.
546
+ if (!spec.startsWith('#')) return undefined
547
+ try {
548
+ const resolved = Bun.resolveSync(spec, dirname(from.getFilePath()))
549
+ if (existsSync(resolved)) return resolved
550
+ } catch {
551
+ return undefined
552
+ }
553
+ return undefined
554
+ }
533
555
  const baseDir = dirname(from.getFilePath())
534
556
  const base = resolvePath(baseDir, spec)
535
557
  const sourceBase = base.replace(/\.(?:[cm]?js|jsx)$/, '')
@@ -625,55 +647,98 @@ export function buildHandlerLinks(args: {
625
647
  }): HandlerLink[] {
626
648
  const { ir, domainRoot } = args
627
649
  if (!domainRoot) return []
628
- const wiringRel = 'runtime/index.ts'
629
- const wiringAbs = resolvePath(domainRoot, wiringRel)
630
650
  const project = newProject()
631
- const sf = tryAddFile(project, wiringAbs)
632
- if (!sf) return []
651
+ const legacyWiringRel = 'runtime/index.ts'
652
+ const legacySf = tryAddFile(project, resolvePath(domainRoot, legacyWiringRel))
633
653
 
634
654
  const interfaceNames = new Set(ir ? Object.keys(ir.interfaces ?? {}) : [])
635
655
 
636
656
  const wired: WiredMethod[] = []
637
657
 
638
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
639
- const name = calleeName(call)
640
- if (!name) continue
641
-
642
- // Style A: single-method helper — method(schema, 'Owner', 'name', { … }).
643
- if (SINGLE_METHOD_HELPERS.has(name)) {
644
- const owner = stringArg(call, 1)
645
- const method = stringArg(call, 2)
646
- const config = call.getArguments()[3]
647
- if (owner && method && config) {
648
- wired.push({ owner, method, config, wiringLine: call.getStartLineNumber() })
658
+ if (legacySf) {
659
+ for (const call of legacySf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
660
+ const name = calleeName(call)
661
+ if (!name) continue
662
+
663
+ // Style A: single-method helper — method(schema, 'Owner', 'name', { … }).
664
+ if (SINGLE_METHOD_HELPERS.has(name)) {
665
+ const owner = stringArg(call, 1)
666
+ const method = stringArg(call, 2)
667
+ const config = call.getArguments()[3]
668
+ if (owner && method && config) {
669
+ wired.push({
670
+ owner,
671
+ method,
672
+ config,
673
+ wiringLine: call.getStartLineNumber(),
674
+ wiringFile: legacyWiringRel,
675
+ style: 'legacy-config',
676
+ })
677
+ }
678
+ continue
649
679
  }
650
- continue
651
- }
652
680
 
653
- // Style B: group helper — classMethods(schema, 'Owner', { name: cfg, … }).
654
- if (GROUP_HELPERS.has(name)) {
655
- const owner = stringArg(call, 1)
656
- const mapArg = call.getArguments()[2]
657
- if (!owner || !mapArg || !Node.isObjectLiteralExpression(mapArg)) continue
658
- for (const prop of mapArg.getProperties()) {
659
- let methodName: string | undefined
660
- let valueNode: Node | undefined
661
- if (Node.isPropertyAssignment(prop)) {
662
- methodName = prop.getName()
663
- valueNode = prop.getInitializer()
664
- } else if (Node.isShorthandPropertyAssignment(prop)) {
665
- methodName = prop.getName()
666
- valueNode = prop.getNameNode()
681
+ // Style B: group helper — classMethods(schema, 'Owner', { name: cfg, … }).
682
+ if (GROUP_HELPERS.has(name)) {
683
+ const owner = stringArg(call, 1)
684
+ const mapArg = call.getArguments()[2]
685
+ if (!owner || !mapArg || !Node.isObjectLiteralExpression(mapArg)) continue
686
+ for (const prop of mapArg.getProperties()) {
687
+ const valueNode = objectPropertyValue(prop)
688
+ const methodName = propertyKey(prop)
689
+ if (!methodName || !valueNode) continue
690
+ wired.push({
691
+ owner,
692
+ method: methodName,
693
+ config: valueNode,
694
+ wiringLine: prop.getStartLineNumber(),
695
+ wiringFile: legacyWiringRel,
696
+ style: 'legacy-config',
697
+ })
667
698
  }
668
- if (!methodName || !valueNode) continue
669
- wired.push({
670
- owner,
671
- method: methodName,
672
- config: valueNode,
673
- wiringLine: prop.getStartLineNumber(),
674
- })
699
+ continue
675
700
  }
676
- continue
701
+ }
702
+ }
703
+
704
+ // Current SDK layout: `implementation.ts` owns a plain
705
+ // `handlers: { classes, interfaces, functions }` object passed to
706
+ // `defineDomain` / `defineDomain.public`. Start from that call so inline
707
+ // handler maps and arbitrarily named handler variables are both visible.
708
+ // Class/interface entries point directly at handler functions; there is no
709
+ // legacy `{ authorize, execute }` wrapper.
710
+ for (const wiringRel of ['implementation.ts', 'domain.ts']) {
711
+ const sf = tryAddFile(project, resolvePath(domainRoot, wiringRel))
712
+ if (!sf) continue
713
+
714
+ const handlerObjects: import('ts-morph').ObjectLiteralExpression[] = []
715
+ const seenHandlerObjects = new Set<string>()
716
+ const addHandlerObject = (candidate: Node | undefined) => {
717
+ const handlersObject = candidate ? resolveObjectLiteral(candidate) : undefined
718
+ if (!handlersObject) return
719
+ const key = `${handlersObject.getSourceFile().getFilePath()}:${handlersObject.getStart()}`
720
+ if (seenHandlerObjects.has(key)) return
721
+ seenHandlerObjects.add(key)
722
+ handlerObjects.push(handlersObject)
723
+ }
724
+
725
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
726
+ if (!isDefineDomainCall(call)) continue
727
+ const input = call.getArguments()[0]
728
+ const inputObject = input ? resolveObjectLiteral(input) : undefined
729
+ addHandlerObject(inputObject ? getProp(inputObject, 'handlers') : undefined)
730
+ }
731
+
732
+ // Tolerate pre-composition fixtures that expose a conventional `handlers`
733
+ // variable without a parseable defineDomain call.
734
+ const handlersDecl = sf.getVariableDeclaration('handlers')
735
+ addHandlerObject(handlersDecl?.getInitializer())
736
+
737
+ for (const handlersObject of handlerObjects) {
738
+ const handlersFile = relToRoot(domainRoot, handlersObject.getSourceFile().getFilePath())
739
+ collectDirectHandlerSection(wired, handlersObject, 'classes', 'class', handlersFile)
740
+ collectDirectHandlerSection(wired, handlersObject, 'interfaces', 'interface', handlersFile)
741
+ collectDirectFunctionHandlers(wired, handlersObject, ir?.domain ?? 'domain', handlersFile)
677
742
  }
678
743
  }
679
744
 
@@ -683,7 +748,7 @@ export function buildHandlerLinks(args: {
683
748
  // falling back to the group entry (which references a variable / todo()).
684
749
  const byKey = new Map<string, WiredMethod>()
685
750
  for (const w of wired) {
686
- const key = `${w.owner}.${w.method}`
751
+ const key = `${w.ownerKind ?? 'legacy'}:${w.owner}.${w.method}`
687
752
  const existing = byKey.get(key)
688
753
  if (!existing) {
689
754
  byKey.set(key, w)
@@ -697,7 +762,8 @@ export function buildHandlerLinks(args: {
697
762
 
698
763
  const links: HandlerLink[] = []
699
764
  for (const w of byKey.values()) {
700
- const ownerKind: 'class' | 'interface' = interfaceNames.has(w.owner) ? 'interface' : 'class'
765
+ const ownerKind: 'class' | 'interface' | 'function' =
766
+ w.ownerKind ?? (interfaceNames.has(w.owner) ? 'interface' : 'class')
701
767
  const isStatic = irMethodStatic(ir, w.owner, w.method, ownerKind)
702
768
 
703
769
  const link: HandlerLink = {
@@ -705,20 +771,24 @@ export function buildHandlerLinks(args: {
705
771
  ownerKind,
706
772
  method: w.method,
707
773
  static: isStatic,
708
- wiringFile: wiringRel,
774
+ wiringFile: w.wiringFile,
709
775
  wiringLine: w.wiringLine,
710
776
  implemented: true,
711
777
  }
712
778
 
713
- const resolution = resolveConfigObject(w.config)
714
- link.implemented = resolution.implemented
715
- if (resolution.auth) link.auth = resolution.auth
716
- if (resolution.authorize) link.authorize = resolution.authorize
717
- if (resolution.authorizeSnippet) link.authorizeSnippet = resolution.authorizeSnippet
718
-
719
779
  let target: { file: string; line: number } | undefined
720
- if (resolution.executeNode) {
721
- target = resolveExecuteTarget(resolution.executeNode)
780
+ if (w.style === 'direct-handler') {
781
+ const direct = resolveDirectHandler(w.config)
782
+ link.implemented = direct.implemented
783
+ target = direct.target
784
+ applyCanonicalMethodAuth(link, ir, w.owner, w.method, ownerKind)
785
+ } else {
786
+ const resolution = resolveConfigObject(w.config)
787
+ link.implemented = resolution.implemented
788
+ if (resolution.auth) link.auth = resolution.auth
789
+ if (resolution.authorize) link.authorize = resolution.authorize
790
+ if (resolution.authorizeSnippet) link.authorizeSnippet = resolution.authorizeSnippet
791
+ if (resolution.executeNode) target = resolveExecuteTarget(resolution.executeNode)
722
792
  }
723
793
 
724
794
  if (target) {
@@ -740,14 +810,159 @@ export function buildHandlerLinks(args: {
740
810
  return links
741
811
  }
742
812
 
813
+ /** Whether a call is the current SDK composition entrypoint. */
814
+ function isDefineDomainCall(call: CallExpression): boolean {
815
+ const expression = unwrapExpression(call.getExpression())
816
+ if (Node.isIdentifier(expression)) return expression.getText() === 'defineDomain'
817
+ if (!Node.isPropertyAccessExpression(expression) || expression.getName() !== 'public') {
818
+ return false
819
+ }
820
+ const receiver = unwrapExpression(expression.getExpression())
821
+ return Node.isIdentifier(receiver) && receiver.getText() === 'defineDomain'
822
+ }
823
+
824
+ /** Resolve a value to an object literal, following local/imported identifiers. */
825
+ function resolveObjectLiteral(
826
+ node: Node,
827
+ seen = new Set<string>(),
828
+ ): import('ts-morph').ObjectLiteralExpression | undefined {
829
+ const value = unwrapExpression(node)
830
+ const key = `${value.getSourceFile().getFilePath()}:${value.getStart()}`
831
+ if (seen.has(key)) return undefined
832
+ seen.add(key)
833
+ if (Node.isObjectLiteralExpression(value)) return value
834
+ if (Node.isIdentifier(value)) {
835
+ const resolved = valueOfIdentifier(value)
836
+ if (resolved) return resolveObjectLiteral(resolved, seen)
837
+ }
838
+ return undefined
839
+ }
840
+
841
+ /** Value carried by an object-literal property, including shorthand/method syntax. */
842
+ function objectPropertyValue(prop: Node): Node | undefined {
843
+ if (Node.isPropertyAssignment(prop)) return prop.getInitializer()
844
+ if (Node.isShorthandPropertyAssignment(prop)) return prop.getNameNode()
845
+ if (Node.isMethodDeclaration(prop)) return prop
846
+ return undefined
847
+ }
848
+
849
+ /** Collect current `handlers.classes/interfaces.Owner.method = fn` bindings. */
850
+ function collectDirectHandlerSection(
851
+ out: WiredMethod[],
852
+ handlers: import('ts-morph').ObjectLiteralExpression,
853
+ section: 'classes' | 'interfaces',
854
+ ownerKind: 'class' | 'interface',
855
+ wiringFile: string,
856
+ ): void {
857
+ const sectionValue = getProp(handlers, section)
858
+ const sectionObject = sectionValue ? resolveObjectLiteral(sectionValue) : undefined
859
+ if (!sectionObject) return
860
+ for (const ownerProp of sectionObject.getProperties()) {
861
+ const owner = propertyKey(ownerProp)
862
+ const ownerValue = objectPropertyValue(ownerProp)
863
+ const ownerObject = ownerValue ? resolveObjectLiteral(ownerValue) : undefined
864
+ if (!owner || !ownerObject) continue
865
+ for (const methodProp of ownerObject.getProperties()) {
866
+ const method = propertyKey(methodProp)
867
+ const handler = objectPropertyValue(methodProp)
868
+ if (!method || !handler) continue
869
+ out.push({
870
+ owner,
871
+ ownerKind,
872
+ method,
873
+ config: handler,
874
+ wiringLine: methodProp.getStartLineNumber(),
875
+ wiringFile,
876
+ style: 'direct-handler',
877
+ })
878
+ }
879
+ }
880
+ }
881
+
882
+ /** Collect current standalone `handlers.functions.name = fn` bindings. */
883
+ function collectDirectFunctionHandlers(
884
+ out: WiredMethod[],
885
+ handlers: import('ts-morph').ObjectLiteralExpression,
886
+ owner: string,
887
+ wiringFile: string,
888
+ ): void {
889
+ const functionsValue = getProp(handlers, 'functions')
890
+ const functions = functionsValue ? resolveObjectLiteral(functionsValue) : undefined
891
+ if (!functions) return
892
+ for (const functionProp of functions.getProperties()) {
893
+ const method = propertyKey(functionProp)
894
+ const handler = objectPropertyValue(functionProp)
895
+ if (!method || !handler) continue
896
+ out.push({
897
+ owner,
898
+ ownerKind: 'function',
899
+ method,
900
+ config: handler,
901
+ wiringLine: functionProp.getStartLineNumber(),
902
+ wiringFile,
903
+ style: 'direct-handler',
904
+ })
905
+ }
906
+ }
907
+
908
+ /** Follow a direct SDK handler to its defining source node. */
909
+ function resolveDirectHandler(config: Node): {
910
+ implemented: boolean
911
+ target?: { file: string; line: number }
912
+ } {
913
+ let value = unwrapExpression(config)
914
+ if (Node.isIdentifier(value)) value = valueOfIdentifier(value) ?? value
915
+ value = unwrapExpression(value)
916
+ const concrete =
917
+ Node.isArrowFunction(value) ||
918
+ Node.isFunctionExpression(value) ||
919
+ Node.isFunctionDeclaration(value) ||
920
+ Node.isMethodDeclaration(value) ||
921
+ Node.isCallExpression(value)
922
+ if (!concrete) return { implemented: false }
923
+ return {
924
+ implemented: Node.isCallExpression(value) ? true : !isStubFunction(value),
925
+ target: { file: value.getSourceFile().getFilePath(), line: value.getStartLineNumber() },
926
+ }
927
+ }
928
+
929
+ /** Map the canonical callable auth declaration onto the existing Studio badge model. */
930
+ function applyCanonicalMethodAuth(
931
+ link: HandlerLink,
932
+ ir: SchemaIR | null,
933
+ owner: string,
934
+ method: string,
935
+ ownerKind: 'class' | 'interface' | 'function',
936
+ ): void {
937
+ const callable =
938
+ ownerKind === 'function'
939
+ ? ir?.functions?.[method]
940
+ : ownerKind === 'interface'
941
+ ? ir?.interfaces?.[owner]?.methods?.[method]
942
+ : ir?.classes?.[owner]?.methods?.[method]
943
+ const auth = callable?.auth
944
+ if (auth) link.callableAuth = auth
945
+ if (auth === 'anonymous') {
946
+ link.auth = 'public'
947
+ link.authorize = 'absent'
948
+ } else if (auth === 'authorized') {
949
+ link.auth = 'required'
950
+ link.authorize = 'custom'
951
+ } else if (auth === 'authenticated') {
952
+ link.auth = 'required'
953
+ link.authorize = 'absent'
954
+ }
955
+ }
956
+
743
957
  /** Look up a method's `static` flag from the IR (class or interface bucket). */
744
958
  function irMethodStatic(
745
959
  ir: SchemaIR | null,
746
960
  owner: string,
747
961
  method: string,
748
- ownerKind: 'class' | 'interface',
962
+ ownerKind: 'class' | 'interface' | 'function',
749
963
  ): boolean {
750
964
  if (!ir) return false
965
+ if (ownerKind === 'function') return true
751
966
  const bucket = ownerKind === 'interface' ? ir.interfaces?.[owner] : ir.classes?.[owner]
752
967
  const m = bucket?.methods?.[method]
753
968
  return m ? m.static === true : false
@@ -755,10 +970,29 @@ function irMethodStatic(
755
970
 
756
971
  // ───────────────────────────── source spans ─────────────────────────────
757
972
 
758
- const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge'> = {
973
+ const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge' | 'function'> = {
759
974
  nodeClass: 'node',
760
975
  nodeInterface: 'interface',
761
976
  edgeClass: 'edge',
977
+ fn: 'function',
978
+ }
979
+
980
+ /** Recognize both legacy `edgeClass(...)` and current
981
+ * `edgeClass.directed/undirected({...})` authoring forms. */
982
+ function declarationHelper(
983
+ call: CallExpression,
984
+ ): 'node' | 'interface' | 'edge' | 'function' | undefined {
985
+ const direct = calleeName(call)
986
+ if (direct && direct in DECL_HELPERS) return DECL_HELPERS[direct]
987
+ const expression = call.getExpression()
988
+ if (
989
+ Node.isPropertyAccessExpression(expression) &&
990
+ (expression.getName() === 'directed' || expression.getName() === 'undirected') &&
991
+ expression.getExpression().getText() === 'edgeClass'
992
+ ) {
993
+ return 'edge'
994
+ }
995
+ return undefined
762
996
  }
763
997
 
764
998
  /**
@@ -772,46 +1006,71 @@ const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge'> = {
772
1006
  */
773
1007
  interface MemberName {
774
1008
  schemaName: string
775
- section: 'interface' | 'class' // the `classes` map also holds edge classes
1009
+ section: 'interface' | 'class' | 'function' // the `classes` map also holds edge classes
1010
+ }
1011
+
1012
+ interface MemberNameMap {
1013
+ /** Exact declaration initializer, robust to imported/local aliases. */
1014
+ byValue: Map<string, MemberName>
1015
+ /** Legacy fallback when a symbol cannot be resolved. */
1016
+ byIdentifier: Map<string, MemberName>
1017
+ }
1018
+
1019
+ /** Stable source coordinate for the value behind a local/imported alias. */
1020
+ function memberValueKey(node: Node, seen = new Set<string>()): string | undefined {
1021
+ const value = unwrapExpression(node)
1022
+ const key = `${value.getSourceFile().getFilePath()}:${value.getStart()}`
1023
+ if (seen.has(key)) return undefined
1024
+ seen.add(key)
1025
+ if (Node.isIdentifier(value)) {
1026
+ const resolved = valueOfIdentifier(value)
1027
+ if (resolved) return memberValueKey(resolved, seen)
1028
+ }
1029
+ return key
776
1030
  }
777
1031
 
778
1032
  /** Map each registered member VARIABLE (as referenced in `defineSchema`) to its
779
1033
  * schema name + section, from the domain's own schema files — never node_modules
780
1034
  * (a dependency's `defineSchema` is not this domain's). */
781
- function buildMemberNameMap(files: SourceFile[]): Map<string, MemberName> {
782
- const map = new Map<string, MemberName>()
1035
+ function buildMemberNameMap(files: SourceFile[]): MemberNameMap {
1036
+ const map: MemberNameMap = {
1037
+ byValue: new Map<string, MemberName>(),
1038
+ byIdentifier: new Map<string, MemberName>(),
1039
+ }
783
1040
  for (const sf of files) {
784
1041
  if (sf.getFilePath().includes('/node_modules/')) continue
785
1042
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
786
1043
  if (calleeName(call) !== 'defineSchema') continue
787
- const cfg = call.getArguments()[1]
788
- if (!cfg || !Node.isObjectLiteralExpression(cfg)) continue
1044
+ const input = call.getArguments()[1]
1045
+ const cfg = input ? resolveObjectLiteral(input) : undefined
1046
+ if (!cfg) continue
789
1047
  collectSchemaSection(map, cfg, 'interfaces', 'interface')
790
1048
  collectSchemaSection(map, cfg, 'classes', 'class')
1049
+ collectSchemaSection(map, cfg, 'functions', 'function')
791
1050
  }
792
1051
  }
793
1052
  return map
794
1053
  }
795
1054
 
796
- /** Record `variable → { schemaName, section }` for one `defineSchema` section
797
- * (`interfaces` / `classes`), handling both `Key: alias` and shorthand `Key`. */
1055
+ /** Record `variable → { schemaName, section }` for one `defineSchema` section,
1056
+ * handling both `Key: alias` and shorthand `Key`. */
798
1057
  function collectSchemaSection(
799
- map: Map<string, MemberName>,
1058
+ map: MemberNameMap,
800
1059
  cfg: Node,
801
- prop: 'interfaces' | 'classes',
802
- section: 'interface' | 'class',
1060
+ prop: 'interfaces' | 'classes' | 'functions',
1061
+ section: 'interface' | 'class' | 'function',
803
1062
  ): void {
804
1063
  const obj = getObjectProp(cfg, prop)
805
1064
  if (!obj) return
806
1065
  for (const p of obj.getProperties()) {
807
- if (Node.isShorthandPropertyAssignment(p)) {
808
- map.set(p.getName(), { schemaName: p.getName(), section })
809
- } else if (Node.isPropertyAssignment(p)) {
810
- const init = p.getInitializer()
811
- if (init && Node.isIdentifier(init)) {
812
- map.set(init.getText(), { schemaName: p.getName(), section })
813
- }
814
- }
1066
+ const schemaName = propertyKey(p)
1067
+ const memberValue = objectPropertyValue(p)
1068
+ if (!schemaName || !memberValue) continue
1069
+ const member = { schemaName, section } satisfies MemberName
1070
+ const valueKey = memberValueKey(memberValue)
1071
+ if (valueKey) map.byValue.set(valueKey, member)
1072
+ const unwrapped = unwrapExpression(memberValue)
1073
+ if (Node.isIdentifier(unwrapped)) map.byIdentifier.set(unwrapped.getText(), member)
815
1074
  }
816
1075
  }
817
1076
 
@@ -826,9 +1085,10 @@ function collectSchemaSection(
826
1085
  function resolveMemberKind(
827
1086
  ir: SchemaIR | null,
828
1087
  name: string,
829
- section: 'interface' | 'class' | undefined,
830
- helperKind: 'node' | 'interface' | 'edge',
831
- ): 'class' | 'interface' | 'edge' {
1088
+ section: 'interface' | 'class' | 'function' | undefined,
1089
+ helperKind: 'node' | 'interface' | 'edge' | 'function',
1090
+ ): 'class' | 'interface' | 'edge' | 'function' {
1091
+ if (section === 'function' || helperKind === 'function') return 'function'
832
1092
  if (section === 'interface') return 'interface'
833
1093
  const isEdge = helperKind === 'edge' || ir?.classes?.[name]?.type === 'edge'
834
1094
  if (section === 'class') return isEdge ? 'edge' : 'class'
@@ -870,19 +1130,22 @@ export function buildSourceSpans(args: {
870
1130
  if (!v.isExported()) continue
871
1131
  const init = v.getInitializer()
872
1132
  if (!init || !Node.isCallExpression(init)) continue
873
- const helper = calleeName(init)
874
- if (!helper || !(helper in DECL_HELPERS)) continue
875
- const helperKind = DECL_HELPERS[helper]
876
- const member = memberNames.get(v.getName())
1133
+ const helperKind = declarationHelper(init)
1134
+ if (!helperKind) continue
1135
+ const valueKey = memberValueKey(init)
1136
+ const member =
1137
+ (valueKey ? memberNames.byValue.get(valueKey) : undefined) ??
1138
+ memberNames.byIdentifier.get(v.getName())
877
1139
  const name = member?.schemaName ?? v.getName()
878
1140
  const ns = resolveMemberKind(ir, name, member?.section, helperKind)
879
1141
 
880
1142
  const stmt = v.getVariableStatement() ?? v
881
1143
  spans[`${ns}.${name}`] = makeSpan(domainRoot, fileRel, stmt, v)
882
1144
 
883
- // The single object-literal argument: nodeClass({ props, methods }) etc.
1145
+ // The single object-literal argument: nodeClass({ properties, methods })
1146
+ // and current edgeClass.directed({ source, target, properties }).
884
1147
  const cfgArg = init.getArguments()[0]
885
- if (cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
1148
+ if (ns !== 'function' && cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
886
1149
  collectPropsAndMethods(spans, ns, name, cfgArg, domainRoot, fileRel)
887
1150
  }
888
1151
 
@@ -918,7 +1181,7 @@ function collectPropsAndMethods(
918
1181
  fileRel: string,
919
1182
  ): void {
920
1183
  if (!Node.isObjectLiteralExpression(cfg)) return
921
- const propsObj = getObjectProp(cfg, 'props')
1184
+ const propsObj = getObjectProp(cfg, 'properties') ?? getObjectProp(cfg, 'props')
922
1185
  if (propsObj) {
923
1186
  for (const p of propsObj.getProperties()) {
924
1187
  const pName = propertyKey(p)
@@ -945,6 +1208,34 @@ function collectEdge(
945
1208
  fileRel: string,
946
1209
  ): void {
947
1210
  const argsList = init.getArguments()
1211
+ const expression = init.getExpression()
1212
+ const currentObjectForm =
1213
+ Node.isPropertyAccessExpression(expression) &&
1214
+ (expression.getName() === 'directed' || expression.getName() === 'undirected') &&
1215
+ argsList[0] &&
1216
+ Node.isObjectLiteralExpression(argsList[0])
1217
+
1218
+ if (currentObjectForm) {
1219
+ const config = argsList[0]
1220
+ if (Node.isObjectLiteralExpression(config)) {
1221
+ for (const endpointName of ['source', 'target'] as const) {
1222
+ const ep = getObjectProp(config, endpointName)
1223
+ if (!ep) continue
1224
+ const role = stringLiteralOfProp(ep, 'as') ?? stringLiteralOfProp(ep, 'role')
1225
+ if (role) spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
1226
+ }
1227
+ const properties = getObjectProp(config, 'properties') ?? getObjectProp(config, 'props')
1228
+ if (properties) {
1229
+ for (const p of properties.getProperties()) {
1230
+ const pName = propertyKey(p)
1231
+ if (!pName) continue
1232
+ spans[`edge.${name}.property.${pName}`] = makeSpan(domainRoot, fileRel, p, p)
1233
+ }
1234
+ }
1235
+ }
1236
+ return
1237
+ }
1238
+
948
1239
  for (let i = 0; i < Math.min(2, argsList.length); i++) {
949
1240
  const ep = argsList[i]
950
1241
  if (!Node.isObjectLiteralExpression(ep)) continue
@@ -952,10 +1243,10 @@ function collectEdge(
952
1243
  if (!role) continue
953
1244
  spans[`edge.${name}.endpoint.${role}`] = makeSpan(domainRoot, fileRel, ep, ep)
954
1245
  }
955
- // Edge props live in the third (config) arg's `props`.
1246
+ // Legacy edge props live in the third (config) arg.
956
1247
  const cfgArg = argsList[2]
957
1248
  if (cfgArg && Node.isObjectLiteralExpression(cfgArg)) {
958
- const propsObj = getObjectProp(cfgArg, 'props')
1249
+ const propsObj = getObjectProp(cfgArg, 'properties') ?? getObjectProp(cfgArg, 'props')
959
1250
  if (propsObj) {
960
1251
  for (const p of propsObj.getProperties()) {
961
1252
  const pName = propertyKey(p)
@@ -977,8 +1268,7 @@ function getObjectProp(
977
1268
  let value: Node | undefined
978
1269
  if (Node.isPropertyAssignment(prop)) value = prop.getInitializer()
979
1270
  else if (Node.isShorthandPropertyAssignment(prop)) value = prop.getNameNode()
980
- if (value && Node.isObjectLiteralExpression(value)) return value
981
- return undefined
1271
+ return value ? resolveObjectLiteral(value) : undefined
982
1272
  }
983
1273
 
984
1274
  /** A string-literal property value, e.g. `as: 'page'` → 'page'. */
@@ -1010,23 +1300,8 @@ function propertyKey(prop: Node): string | undefined {
1010
1300
 
1011
1301
  // ───────────────────────────── annotations ─────────────────────────────
1012
1302
 
1013
- export function buildSchemaAnnotations(args: { ir: SchemaIR | null }): SchemaAnnotation[] {
1014
- const { ir } = args
1015
- if (!ir) return []
1016
- const out: SchemaAnnotation[] = []
1017
- for (const cls of Object.values(ir.classes ?? {})) {
1018
- const ns = cls.type === 'edge' ? 'edge' : 'class'
1019
- for (const [propName, schema] of Object.entries(cls.properties ?? {})) {
1020
- if (Array.isArray(schema?.enum) && schema.enum.length > 0) {
1021
- out.push({
1022
- target: `${ns}.${cls.name}.property.${propName}`,
1023
- severity: 'warn',
1024
- code: 'ENUM_DROPPED_BY_UPDATE',
1025
- message:
1026
- 'z.enum props are silently dropped by ::update — track as a plain string if it must be updated.',
1027
- })
1028
- }
1029
- }
1030
- }
1031
- return out
1303
+ export function buildSchemaAnnotations(_args: { ir: SchemaIR | null }): SchemaAnnotation[] {
1304
+ // The current Kernel admits updated values against their exact Value Schema;
1305
+ // enum properties therefore need no Studio-specific compatibility warning.
1306
+ return []
1032
1307
  }