@platformos/platformos-graph 0.0.20 → 0.2.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 (122) hide show
  1. package/CHANGELOG.md +622 -8
  2. package/dist/cache-probes.spec.d.ts +1 -0
  3. package/dist/cache-probes.spec.js +62 -0
  4. package/dist/cache-probes.spec.js.map +1 -0
  5. package/dist/cli.js +18 -13
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cli.spec.d.ts +1 -0
  8. package/dist/cli.spec.js +235 -0
  9. package/dist/cli.spec.js.map +1 -0
  10. package/dist/graph/app-index-resolution.spec.d.ts +1 -0
  11. package/dist/graph/app-index-resolution.spec.js +167 -0
  12. package/dist/graph/app-index-resolution.spec.js.map +1 -0
  13. package/dist/graph/asset-resolution.spec.d.ts +1 -0
  14. package/dist/graph/asset-resolution.spec.js +64 -0
  15. package/dist/graph/asset-resolution.spec.js.map +1 -0
  16. package/dist/graph/augment.js +3 -0
  17. package/dist/graph/augment.js.map +1 -1
  18. package/dist/graph/build.d.ts +15 -2
  19. package/dist/graph/build.js +54 -12
  20. package/dist/graph/build.js.map +1 -1
  21. package/dist/graph/build.spec.d.ts +1 -0
  22. package/dist/graph/build.spec.js +138 -0
  23. package/dist/graph/build.spec.js.map +1 -0
  24. package/dist/graph/edge-sources.d.ts +28 -0
  25. package/dist/graph/edge-sources.js +43 -0
  26. package/dist/graph/edge-sources.js.map +1 -0
  27. package/dist/graph/edge-sources.spec.d.ts +1 -0
  28. package/dist/graph/edge-sources.spec.js +125 -0
  29. package/dist/graph/edge-sources.spec.js.map +1 -0
  30. package/dist/graph/extract.spec.d.ts +1 -0
  31. package/dist/graph/extract.spec.js +163 -0
  32. package/dist/graph/extract.spec.js.map +1 -0
  33. package/dist/graph/module.d.ts +60 -7
  34. package/dist/graph/module.js +137 -41
  35. package/dist/graph/module.js.map +1 -1
  36. package/dist/graph/module.spec.d.ts +1 -0
  37. package/dist/graph/module.spec.js +74 -0
  38. package/dist/graph/module.spec.js.map +1 -0
  39. package/dist/graph/query.d.ts +72 -0
  40. package/dist/graph/query.js +140 -0
  41. package/dist/graph/query.js.map +1 -0
  42. package/dist/graph/query.spec.d.ts +1 -0
  43. package/dist/graph/query.spec.js +209 -0
  44. package/dist/graph/query.spec.js.map +1 -0
  45. package/dist/graph/serialize.spec.d.ts +1 -0
  46. package/dist/graph/serialize.spec.js +50 -0
  47. package/dist/graph/serialize.spec.js.map +1 -0
  48. package/dist/graph/structural.spec.d.ts +1 -0
  49. package/dist/graph/structural.spec.js +146 -0
  50. package/dist/graph/structural.spec.js.map +1 -0
  51. package/dist/graph/test-helpers.js +3 -3
  52. package/dist/graph/test-helpers.js.map +1 -1
  53. package/dist/graph/traverse-edges.spec.d.ts +1 -0
  54. package/dist/graph/traverse-edges.spec.js +337 -0
  55. package/dist/graph/traverse-edges.spec.js.map +1 -0
  56. package/dist/graph/traverse.d.ts +51 -20
  57. package/dist/graph/traverse.js +239 -29
  58. package/dist/graph/traverse.js.map +1 -1
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +19 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/parsers.d.ts +35 -0
  63. package/dist/parsers.js +72 -0
  64. package/dist/parsers.js.map +1 -0
  65. package/dist/parsers.spec.d.ts +1 -0
  66. package/dist/parsers.spec.js +70 -0
  67. package/dist/parsers.spec.js.map +1 -0
  68. package/dist/toSourceCode.d.ts +14 -2
  69. package/dist/toSourceCode.js +23 -19
  70. package/dist/toSourceCode.js.map +1 -1
  71. package/dist/tsconfig.tsbuildinfo +1 -1
  72. package/dist/types.d.ts +107 -3
  73. package/dist/types.js.map +1 -1
  74. package/dist/utils/index.d.ts +1 -1
  75. package/fixtures/asset-edges/app/assets/logo.css +1 -0
  76. package/fixtures/asset-edges/app/assets/site/app.js +1 -0
  77. package/fixtures/asset-edges/app/views/pages/index.liquid +3 -0
  78. package/fixtures/graphql-table/app/graphql/remote_table.graphql +9 -0
  79. package/fixtures/graphql-table/app/graphql/table_as_argument.graphql +5 -0
  80. package/fixtures/graphql-table/app/graphql/with_table.graphql +5 -0
  81. package/fixtures/graphql-table/app/graphql/without_table.graphql +3 -0
  82. package/fixtures/graphql-table/app/views/pages/index.liquid +6 -0
  83. package/fixtures/layout-edges/app/views/layouts/theme.liquid +4 -0
  84. package/fixtures/layout-edges/app/views/pages/broken.liquid +5 -0
  85. package/fixtures/layout-edges/app/views/pages/index.liquid +5 -0
  86. package/fixtures/schema-nodes/app/schema/blog_post.yml +6 -0
  87. package/fixtures/schema-nodes/app/schema/no_name.yml +3 -0
  88. package/fixtures/schema-nodes/app/views/pages/index.liquid +1 -0
  89. package/fixtures/structural/app/views/pages/about.liquid +1 -0
  90. package/fixtures/structural/app/views/pages/blog/show.liquid +4 -0
  91. package/fixtures/structural/app/views/pages/index.liquid +6 -0
  92. package/fixtures/structural/app/views/pages/rich.liquid +8 -0
  93. package/fixtures/structural/app/views/partials/card.liquid +1 -0
  94. package/fixtures/structural/app/views/partials/documented.liquid +5 -0
  95. package/package.json +7 -5
  96. package/src/cli.spec.ts +37 -31
  97. package/src/cli.ts +31 -15
  98. package/src/graph/augment.ts +4 -0
  99. package/src/graph/build.spec.ts +891 -6
  100. package/src/graph/build.ts +68 -17
  101. package/src/graph/edge-sources.spec.ts +125 -0
  102. package/src/graph/edge-sources.ts +49 -0
  103. package/src/graph/module.spec.ts +168 -0
  104. package/src/graph/module.ts +139 -45
  105. package/src/graph/query.spec.ts +260 -0
  106. package/src/graph/query.ts +154 -0
  107. package/src/graph/serialize.spec.ts +4 -4
  108. package/src/graph/test-helpers.ts +3 -3
  109. package/src/graph/{extract.spec.ts → traverse.spec.ts} +125 -2
  110. package/src/graph/traverse.ts +278 -37
  111. package/src/index.ts +16 -1
  112. package/src/parsers.spec.ts +85 -0
  113. package/src/parsers.ts +75 -0
  114. package/src/toSourceCode.ts +26 -26
  115. package/src/types.ts +111 -7
  116. package/src/utils/index.ts +1 -1
  117. package/dist/getWebComponentMap.d.ts +0 -10
  118. package/dist/getWebComponentMap.js +0 -66
  119. package/dist/getWebComponentMap.js.map +0 -1
  120. package/src/graph/traverse-edges.spec.ts +0 -278
  121. /package/fixtures/skeleton/{assets → app/assets}/app.css +0 -0
  122. /package/fixtures/skeleton/{assets → app/assets}/app.js +0 -0
@@ -1,4 +1,5 @@
1
- import { isLayout, isPage, isPartial, path, UriString } from '@platformos/platformos-check-common';
1
+ import { path, UriString } from '@platformos/platformos-check-common';
2
+ import { getFileType, nameToPaths, PlatformOSFileType } from '@platformos/platformos-common';
2
3
  import {
3
4
  AssetModule,
4
5
  AppGraph,
@@ -7,6 +8,7 @@ import {
7
8
  LiquidModule,
8
9
  LiquidModuleKind,
9
10
  ModuleType,
11
+ SchemaModule,
10
12
  SUPPORTED_ASSET_IMAGE_EXTENSIONS,
11
13
  } from '../types';
12
14
  import { extname } from '../utils';
@@ -33,57 +35,103 @@ export function getModule(appGraph: AppGraph, uri: UriString): AppModule | undef
33
35
  return cache.get(uri)!;
34
36
  }
35
37
 
36
- const relativePath = path.relative(uri, appGraph.rootUri);
37
-
38
- switch (true) {
39
- case isLayout(uri):
38
+ // One anchored classification, not four unanchored ones: a file's type is its
39
+ // position relative to the project root, and the graph has that root.
40
+ switch (getFileType(uri, appGraph.rootUri)) {
41
+ case PlatformOSFileType.Layout:
40
42
  return getLayoutModule(appGraph, uri);
41
43
 
42
- case isPage(uri):
44
+ case PlatformOSFileType.Page:
43
45
  return getPageModule(appGraph, uri);
44
46
 
45
- case isPartial(uri):
46
- return getPartialModule(appGraph, path.basename(uri, '.liquid'));
47
+ case PlatformOSFileType.Partial:
48
+ // The URI is already resolved, so it is used as-is. Reducing it to a
49
+ // `basename` and rebuilding a path from that loses any subdirectory and any
50
+ // module prefix — `app/views/partials/ui/card.liquid` came back as
51
+ // `app/views/partials/card.liquid`, a node for a file that need not exist,
52
+ // and `app/lib/can/x.liquid` was split from the same file resolved as an
53
+ // edge target (which comes through getPartialModuleByUri).
54
+ return getPartialModuleByUri(appGraph, uri);
47
55
 
48
- case relativePath.startsWith('assets') || relativePath.startsWith('modules'):
49
- return getAssetModule(appGraph, path.basename(uri));
56
+ case PlatformOSFileType.Asset:
57
+ return getAssetModuleByUri(appGraph, uri);
50
58
  }
51
59
  }
52
60
 
53
- export function getAssetModule(appGraph: AppGraph, asset: string): AssetModule | undefined {
54
- const extension = extname(asset);
55
-
56
- const SUPPORTED_ASSET_EXTENSIONS = [
57
- ...SUPPORTED_ASSET_IMAGE_EXTENSIONS,
58
- 'js',
59
- 'css',
60
- 'svg',
61
- 'pdf',
62
- 'woff',
63
- 'woff2',
64
- 'ttf',
65
- 'eot',
66
- ];
67
-
68
- if (!SUPPORTED_ASSET_EXTENSIONS.includes(extension)) {
69
- return undefined;
70
- }
61
+ /**
62
+ * Create (or fetch the cached) asset module for an already-resolved URI.
63
+ *
64
+ * Preferred over {@link getAssetModule} whenever the file is known, for the same
65
+ * reason as {@link getPartialModuleByUri}: it does not reconstruct a path from a
66
+ * name, so a nested or module asset keeps the location it actually has.
67
+ *
68
+ * Gated on {@link isSupportedAsset} HERE rather than at each call site, so a new
69
+ * caller cannot forget the gate and mint an Asset node for a value that is not an
70
+ * asset file.
71
+ */
72
+ export function getAssetModuleByUri(appGraph: AppGraph, uri: string): AssetModule | undefined {
73
+ if (!isSupportedAsset(uri)) return undefined;
71
74
 
72
75
  return module(appGraph, {
73
76
  type: ModuleType.Asset,
74
77
  kind: 'unused',
78
+ // Normalize to forward slashes — see getPartialModuleByUri.
79
+ uri: path.normalize(uri),
75
80
  dependencies: [],
76
81
  references: [],
77
- uri: path.join(appGraph.rootUri, 'assets', asset),
78
82
  });
79
83
  }
80
84
 
81
- export function getPartialModule(appGraph: AppGraph, partial: string): LiquidModule {
82
- const uri = path.join(appGraph.rootUri, 'app/views/partials', `${partial}.liquid`);
85
+ /** File extensions the graph treats as assets (the `asset_url` edge gate). */
86
+ const SUPPORTED_ASSET_EXTENSIONS = [
87
+ ...SUPPORTED_ASSET_IMAGE_EXTENSIONS,
88
+ 'js',
89
+ 'css',
90
+ 'svg',
91
+ 'pdf',
92
+ 'woff',
93
+ 'woff2',
94
+ 'ttf',
95
+ 'eot',
96
+ ];
97
+
98
+ /**
99
+ * Whether `nameOrUri` refers to a supported asset file (by extension).
100
+ *
101
+ * The single gate for admitting an asset, whether the subject is a reference (the
102
+ * string inside `{{ 'app.js' | asset_url }}`) or a resolved URI. It preserves the
103
+ * graph's historical behavior of ignoring an `asset_url`-family filter applied to
104
+ * a value that is not an asset file. Exported so the traversal can skip the
105
+ * filesystem probe for a non-asset value BEFORE resolving it; the module
106
+ * factories gate on it again, so neither layer depends on the other remembering.
107
+ */
108
+ export function isSupportedAsset(nameOrUri: string): boolean {
109
+ return SUPPORTED_ASSET_EXTENSIONS.includes(extname(nameOrUri));
110
+ }
111
+
112
+ /**
113
+ * Create (or fetch the cached) asset module for an asset REFERENCE — the string
114
+ * inside `{{ 'app.js' | asset_url }}`, which may carry a `modules/<name>/` prefix.
115
+ *
116
+ * Assets live under the same roots as every other file type: `app/assets/` or
117
+ * `modules/<name>/{public,private}/assets/`. Those come from platformos-common, so the
118
+ * graph resolves a reference to the same place the linter and the platform do.
119
+ *
120
+ * The FIRST candidate is used as the canonical location: this is sync and has no
121
+ * filesystem, so it cannot tell which candidate exists. Prefer
122
+ * {@link getAssetModuleByUri} — the traversal resolves through `DocumentsLocator`,
123
+ * which can.
124
+ */
125
+ export function getAssetModule(appGraph: AppGraph, asset: string): AssetModule | undefined {
126
+ if (!isSupportedAsset(asset)) return undefined;
127
+
128
+ const [canonical] = nameToPaths(PlatformOSFileType.Asset, asset);
129
+ if (!canonical) return undefined;
130
+
83
131
  return module(appGraph, {
84
- type: ModuleType.Liquid,
85
- kind: LiquidModuleKind.Partial,
86
- uri: uri,
132
+ type: ModuleType.Asset,
133
+ kind: 'unused',
134
+ uri: path.join(appGraph.rootUri, canonical),
87
135
  dependencies: [],
88
136
  references: [],
89
137
  });
@@ -91,21 +139,24 @@ export function getPartialModule(appGraph: AppGraph, partial: string): LiquidMod
91
139
 
92
140
  /**
93
141
  * Create (or fetch the cached) Liquid Partial module for an ALREADY-RESOLVED
94
- * full URI — used for `{% function %}` / `{% include %}` targets whose URI is
95
- * resolved canonically by `DocumentsLocator` (which handles lib paths, module
96
- * prefixes, and extensions). Unlike {@link getPartialModule}, it does not
97
- * reconstruct the path from a name. Commands/queries/lib helpers are all
98
- * `Partial` kind, consistent with check-common's file-type classification.
142
+ * full URI — used for `{% render %}` / `{% include %}` / `{% function %}` /
143
+ * `{% background %}` targets whose URI is resolved canonically by
144
+ * `DocumentsLocator` (which handles lib paths, module prefixes, and extensions).
145
+ * It keys the node by that URI verbatim (normalized), never reconstructing a path
146
+ * from a name: partials have two search roots (`views/partials`, `lib`) and two
147
+ * extensions, so a name-derived path is a guess, and guessing split a `lib/`
148
+ * partial into two nodes. Commands/queries/lib helpers are all `Partial` kind,
149
+ * consistent with check-common's file-type classification.
99
150
  */
100
151
  export function getPartialModuleByUri(appGraph: AppGraph, uri: string): LiquidModule {
101
152
  return module(appGraph, {
102
153
  type: ModuleType.Liquid,
103
154
  kind: LiquidModuleKind.Partial,
104
155
  // Normalize to forward slashes so module keys match the rest of the graph
105
- // (getPartialModule/getAssetModule build URIs via path.join, which
106
- // normalizes). DocumentsLocator returns `Utils.joinPath(...).toString()`
107
- // unnormalized, which on Windows keeps backslashes and breaks key/edge
108
- // matching against the normalized URIs everywhere else.
156
+ // (getAssetModule builds its URI via path.join, which normalizes).
157
+ // DocumentsLocator returns `Utils.joinPath(...).toString()` unnormalized,
158
+ // which on Windows keeps backslashes and breaks key/edge matching against the
159
+ // normalized URIs everywhere else.
109
160
  uri: path.normalize(uri),
110
161
  dependencies: [],
111
162
  references: [],
@@ -125,6 +176,24 @@ export function getGraphQLModuleByUri(appGraph: AppGraph, uri: string): GraphQLM
125
176
  uri: path.normalize(uri),
126
177
  dependencies: [],
127
178
  references: [],
179
+ // Always present (empty = no table declared); populated during traversal.
180
+ tables: [],
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Create (or fetch the cached) Schema module for an already-resolved
186
+ * `custom_model_type`/schema URI — discovered during a full `buildAppGraph`.
187
+ * A leaf node; its `table` (the model `name:`) is populated during traversal.
188
+ */
189
+ export function getSchemaModule(appGraph: AppGraph, uri: string): SchemaModule {
190
+ return module(appGraph, {
191
+ type: ModuleType.Schema,
192
+ kind: 'schema',
193
+ // Normalize to forward slashes — see getPartialModuleByUri.
194
+ uri: path.normalize(uri),
195
+ dependencies: [],
196
+ references: [],
128
197
  });
129
198
  }
130
199
 
@@ -136,7 +205,30 @@ export function getLayoutModule(
136
205
  return module(appGraph, {
137
206
  type: ModuleType.Liquid,
138
207
  kind: LiquidModuleKind.Layout,
139
- uri: layoutUri,
208
+ // Normalize so a layout discovered as an entry point keys identically to the
209
+ // same file resolved as a frontmatter `layout:` edge target (which comes
210
+ // through getLayoutModuleByUri, also normalized) — one node, never a split
211
+ // identity. See getPartialModuleByUri for why DocumentsLocator/fs URIs must
212
+ // be normalized.
213
+ uri: path.normalize(layoutUri),
214
+ dependencies: [],
215
+ references: [],
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Create (or fetch the cached) Layout module for an already-resolved layout URI
221
+ * — used for the frontmatter `layout:` association edge, whose target is
222
+ * resolved by `DocumentsLocator` (`'layout'` type: `app/views/layouts`, module
223
+ * prefixes, `.html.liquid`/`.liquid`). Distinct from {@link getLayoutModule} only
224
+ * in that the latter accepts the `string | false | undefined` a frontmatter read
225
+ * yields; both normalize, so both key the same node.
226
+ */
227
+ export function getLayoutModuleByUri(appGraph: AppGraph, uri: string): LiquidModule {
228
+ return module(appGraph, {
229
+ type: ModuleType.Liquid,
230
+ kind: LiquidModuleKind.Layout,
231
+ uri: path.normalize(uri),
140
232
  dependencies: [],
141
233
  references: [],
142
234
  });
@@ -146,7 +238,9 @@ export function getPageModule(appGraph: AppGraph, pageUri: string): LiquidModule
146
238
  return module(appGraph, {
147
239
  type: ModuleType.Liquid,
148
240
  kind: LiquidModuleKind.Page,
149
- uri: pageUri,
241
+ // Normalize for the same node-identity reason as getLayoutModule / the other
242
+ // factories (see getPartialModuleByUri).
243
+ uri: path.normalize(pageUri),
150
244
  dependencies: [],
151
245
  references: [],
152
246
  });
@@ -0,0 +1,260 @@
1
+ import { path as pathUtils } from '@platformos/platformos-check-common';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import { buildAppGraph } from '../index';
4
+ import { AppGraph } from '../types';
5
+ import {
6
+ dependenciesOf,
7
+ dependentsOf,
8
+ exists,
9
+ isEntryPoint,
10
+ isOrphan,
11
+ missingDependencies,
12
+ missingTargets,
13
+ nearestModules,
14
+ orphans,
15
+ reachableFrom,
16
+ } from './query';
17
+ import {
18
+ getGraphQLModuleByUri,
19
+ getLayoutModule,
20
+ getPageModule,
21
+ getPartialModuleByUri,
22
+ getSchemaModule,
23
+ } from './module';
24
+ import { bind } from './traverse';
25
+ import { getDependencies, skeleton } from './test-helpers';
26
+
27
+ const targets = (refs: { target: { uri: string } }[]) =>
28
+ refs.map((r) => r.target.uri).sort((a, b) => a.localeCompare(b));
29
+ const sources = (refs: { source: { uri: string } }[]) =>
30
+ refs.map((r) => r.source.uri).sort((a, b) => a.localeCompare(b));
31
+
32
+ describe('Graph queries: over the built skeleton app graph', () => {
33
+ const p = (part: string) => pathUtils.join(skeleton, ...part.split('/'));
34
+ let graph: AppGraph;
35
+
36
+ beforeAll(async () => {
37
+ graph = await buildAppGraph(skeleton, getDependencies());
38
+ }, 15000);
39
+
40
+ it('dependenciesOf returns a file’s outgoing targets', () => {
41
+ expect(targets(dependenciesOf(graph, p('app/views/pages/index.liquid')))).toEqual([
42
+ p('app/views/layouts/application.liquid'),
43
+ p('app/views/partials/parent.liquid'),
44
+ ]);
45
+ });
46
+
47
+ it('dependency edges are call sites carrying kind + named-argument names', () => {
48
+ // index.liquid: `layout: application` (no args) + `{% render "parent", children: "hello" %}`.
49
+ const edges = dependenciesOf(graph, p('app/views/pages/index.liquid'))
50
+ .map((ref) => ({ kind: ref.kind, target: ref.target.uri, args: ref.args }))
51
+ .sort((a, b) => a.target.localeCompare(b.target));
52
+
53
+ expect(edges).toEqual([
54
+ { kind: 'layout', target: p('app/views/layouts/application.liquid'), args: undefined },
55
+ { kind: 'render', target: p('app/views/partials/parent.liquid'), args: ['children'] },
56
+ ]);
57
+ });
58
+
59
+ it('dependentsOf returns every caller of a file', () => {
60
+ expect(sources(dependentsOf(graph, p('app/views/partials/child.liquid')))).toEqual([
61
+ p('app/views/partials/header.liquid'),
62
+ p('app/views/partials/parent.liquid'),
63
+ ]);
64
+ expect(sources(dependentsOf(graph, p('app/views/layouts/application.liquid')))).toEqual([
65
+ p('app/views/pages/index.liquid'),
66
+ ]);
67
+ });
68
+
69
+ it('dependenciesOf / dependentsOf return [] for a URI absent from the graph', () => {
70
+ expect(dependenciesOf(graph, p('app/views/partials/nope.liquid'))).toEqual([]);
71
+ expect(dependentsOf(graph, p('app/views/partials/nope.liquid'))).toEqual([]);
72
+ });
73
+
74
+ it('reachableFrom returns the transitive outgoing closure', () => {
75
+ // Sorted by URI — `app/assets/*` sorts ahead of `app/views/*`.
76
+ expect(reachableFrom(graph, p('app/views/pages/index.liquid'))).toEqual([
77
+ p('app/assets/app.css'),
78
+ p('app/assets/app.js'),
79
+ p('app/views/layouts/application.liquid'),
80
+ p('app/views/partials/child.liquid'),
81
+ p('app/views/partials/header.liquid'),
82
+ p('app/views/partials/parent.liquid'),
83
+ ]);
84
+ });
85
+
86
+ it('exists reflects on-disk presence', () => {
87
+ expect(exists(graph, p('app/views/partials/child.liquid'))).toBe(true);
88
+ expect(exists(graph, p('app/views/partials/nope.liquid'))).toBe(false);
89
+ });
90
+
91
+ it('isEntryPoint distinguishes pages/layouts from partials', () => {
92
+ expect(isEntryPoint(graph, p('app/views/pages/index.liquid'))).toBe(true);
93
+ expect(isEntryPoint(graph, p('app/views/layouts/application.liquid'))).toBe(true);
94
+ expect(isEntryPoint(graph, p('app/views/partials/child.liquid'))).toBe(false);
95
+ });
96
+
97
+ it('a fully-wired skeleton has no orphans and no missing targets', () => {
98
+ expect(orphans(graph)).toEqual([]);
99
+ expect(missingTargets(graph)).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe('Graph queries: orphan and missing-target detection (hermetic graph)', () => {
104
+ const rootUri = 'file:///app';
105
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
106
+
107
+ // page (entry) ─render→ used (exists)
108
+ // └─render→ missing (exists:false)
109
+ // orphan (exists, referenced by nothing, not an entry point)
110
+ // schema (exists, referenced by nothing, not an entry point — but NOT an orphan)
111
+ let graph: AppGraph;
112
+ let pageUri: string;
113
+ let usedUri: string;
114
+ let orphanUri: string;
115
+ let missingUri: string;
116
+ let schemaUri: string;
117
+
118
+ beforeAll(() => {
119
+ graph = { rootUri, entryPoints: [], modules: {} };
120
+ const page = getPageModule(graph, p('app/views/pages/index.liquid'));
121
+ const used = getPartialModuleByUri(graph, p('app/views/partials/used.liquid'));
122
+ const orphan = getPartialModuleByUri(graph, p('app/views/partials/orphan.liquid'));
123
+ const missing = getPartialModuleByUri(graph, p('app/views/partials/missing.liquid'));
124
+ const schema = getSchemaModule(graph, p('app/schema/blog_post.yml'));
125
+
126
+ page.exists = true;
127
+ used.exists = true;
128
+ orphan.exists = true;
129
+ missing.exists = false;
130
+ schema.exists = true;
131
+
132
+ bind(page, used, { sourceRange: [0, 10], kind: 'render' });
133
+ bind(page, missing, { sourceRange: [11, 21], kind: 'render' });
134
+
135
+ graph.entryPoints = [page];
136
+ for (const module of [page, used, orphan, missing, schema]) graph.modules[module.uri] = module;
137
+
138
+ pageUri = page.uri;
139
+ usedUri = used.uri;
140
+ orphanUri = orphan.uri;
141
+ missingUri = missing.uri;
142
+ schemaUri = schema.uri;
143
+ });
144
+
145
+ it('flags an unreferenced, non-entry-point file as an orphan', () => {
146
+ expect(isOrphan(graph, orphanUri)).toBe(true);
147
+ });
148
+
149
+ it('does not flag referenced files, entry points, or missing targets as orphans', () => {
150
+ expect(isOrphan(graph, usedUri)).toBe(false); // referenced by the page
151
+ expect(isOrphan(graph, pageUri)).toBe(false); // entry point
152
+ expect(isOrphan(graph, missingUri)).toBe(false); // missing, not orphan
153
+ });
154
+
155
+ it('never flags a schema node as an orphan (referenced by table name, not edges)', () => {
156
+ // Without the guard this would be a false positive: schema exists, is not an
157
+ // entry point, and has zero incoming edges.
158
+ expect(isOrphan(graph, schemaUri)).toBe(false);
159
+ });
160
+
161
+ it('orphans() lists exactly the orphan modules (excludes the schema)', () => {
162
+ expect(orphans(graph).map((m) => m.uri)).toEqual([orphanUri]);
163
+ });
164
+
165
+ it('missingDependencies returns a file’s edges to non-existent targets', () => {
166
+ expect(missingDependencies(graph, pageUri)).toEqual([
167
+ {
168
+ source: { uri: pageUri, range: [11, 21] },
169
+ target: { uri: missingUri },
170
+ type: 'direct',
171
+ kind: 'render',
172
+ },
173
+ ]);
174
+ });
175
+
176
+ it('missingTargets lists every unresolved edge in the graph', () => {
177
+ expect(missingTargets(graph)).toEqual([
178
+ {
179
+ source: { uri: pageUri, range: [11, 21] },
180
+ target: { uri: missingUri },
181
+ type: 'direct',
182
+ kind: 'render',
183
+ },
184
+ ]);
185
+ });
186
+ });
187
+
188
+ describe('Graph queries: nearest-name candidates (did-you-mean)', () => {
189
+ const rootUri = 'file:///app';
190
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
191
+
192
+ // Partials: header, footer, sidebar (exist). A layout ALSO named "header"
193
+ // (exists) — same name, different category. A graphql op blog/find (exists).
194
+ // Typo targets: partial "headr" and graphql "blog/fnd" (both exists:false).
195
+ let graph: AppGraph;
196
+ let headerUri: string;
197
+ let footerUri: string;
198
+ let sidebarUri: string;
199
+ let headrUri: string;
200
+ let graphqlFindUri: string;
201
+ let graphqlTypoUri: string;
202
+
203
+ beforeAll(() => {
204
+ graph = { rootUri, entryPoints: [], modules: {} };
205
+ const page = getPageModule(graph, p('app/views/pages/index.liquid'));
206
+ const header = getPartialModuleByUri(graph, p('app/views/partials/header.liquid'));
207
+ const footer = getPartialModuleByUri(graph, p('app/views/partials/footer.liquid'));
208
+ const sidebar = getPartialModuleByUri(graph, p('app/views/partials/sidebar.liquid'));
209
+ const headerLayout = getLayoutModule(graph, p('app/views/layouts/header.liquid'))!;
210
+ const find = getGraphQLModuleByUri(graph, p('app/graphql/blog/find.graphql'));
211
+ const headr = getPartialModuleByUri(graph, p('app/views/partials/headr.liquid'));
212
+ const graphqlTypo = getGraphQLModuleByUri(graph, p('app/graphql/blog/fnd.graphql'));
213
+
214
+ for (const m of [page, header, footer, sidebar, headerLayout, find]) m.exists = true;
215
+ headr.exists = false;
216
+ graphqlTypo.exists = false;
217
+
218
+ graph.entryPoints = [page];
219
+ for (const m of [page, header, footer, sidebar, headerLayout, find, headr, graphqlTypo]) {
220
+ graph.modules[m.uri] = m;
221
+ }
222
+
223
+ headerUri = header.uri;
224
+ footerUri = footer.uri;
225
+ sidebarUri = sidebar.uri;
226
+ headrUri = headr.uri;
227
+ graphqlFindUri = find.uri;
228
+ graphqlTypoUri = graphqlTypo.uri;
229
+ });
230
+
231
+ it('ranks the closest same-category name first', () => {
232
+ expect(nearestModules(graph, headrUri, { limit: 1 }).map((m) => m.uri)).toEqual([headerUri]);
233
+ });
234
+
235
+ it('only considers existing modules of the same category (excludes self, layouts, graphql, missing)', () => {
236
+ // The layout "header" shares the name but is a different kind; the graphql
237
+ // op, the page, the typo itself, and missing modules are all out.
238
+ const uris = nearestModules(graph, headrUri, { limit: 10 })
239
+ .map((m) => m.uri)
240
+ .sort((a, b) => a.localeCompare(b));
241
+ expect(uris).toEqual([footerUri, headerUri, sidebarUri]);
242
+ });
243
+
244
+ it('honours maxDistance', () => {
245
+ // Only "header" is within edit distance 1 of "headr".
246
+ expect(nearestModules(graph, headrUri, { maxDistance: 1 }).map((m) => m.uri)).toEqual([
247
+ headerUri,
248
+ ]);
249
+ });
250
+
251
+ it('suggests graphql operations for a missing graphql target', () => {
252
+ expect(nearestModules(graph, graphqlTypoUri, { limit: 1 }).map((m) => m.uri)).toEqual([
253
+ graphqlFindUri,
254
+ ]);
255
+ });
256
+
257
+ it('returns [] for a URI absent from the graph', () => {
258
+ expect(nearestModules(graph, p('app/views/partials/unknown.liquid'))).toEqual([]);
259
+ });
260
+ });
@@ -0,0 +1,154 @@
1
+ import { levenshtein, path, UriString } from '@platformos/platformos-check-common';
2
+ import { AppGraph, AppModule, ModuleType, Reference } from '../types';
3
+
4
+ /**
5
+ * Project-structure queries over a BUILT {@link AppGraph}.
6
+ *
7
+ * Every function here is PURE and synchronous: it only reads the in-memory
8
+ * graph produced by `buildAppGraph`. All I/O (disk traversal, parsing,
9
+ * resolution) stays in `buildAppGraph`; this layer never touches the filesystem.
10
+ *
11
+ * These resurrect the old in-supervisor `ProjectFactGraph` / `ProjectMap`
12
+ * capabilities as the graph package's own API, so consumers (the MCP supervisor,
13
+ * the LSP) read project facts without re-deriving any graph logic.
14
+ *
15
+ * Note on graph scope: `buildAppGraph` only materializes modules reachable from
16
+ * its entry points. Whole-project queries like {@link orphans} are therefore
17
+ * only as complete as the graph they are given — to detect unreferenced files,
18
+ * build the graph with every file as an entry point.
19
+ */
20
+
21
+ /** The outgoing dependency edges of `uri` (what it renders/includes/runs/queries/wraps). */
22
+ export function dependenciesOf(graph: AppGraph, uri: UriString): Reference[] {
23
+ return graph.modules[uri]?.dependencies ?? [];
24
+ }
25
+
26
+ /** The incoming reference edges to `uri` (who renders/includes/runs/queries/wraps it). */
27
+ export function dependentsOf(graph: AppGraph, uri: UriString): Reference[] {
28
+ return graph.modules[uri]?.references ?? [];
29
+ }
30
+
31
+ /**
32
+ * Whether `uri` is a module in the graph that resolves to a file on disk.
33
+ * Returns `false` for a URI absent from the graph or a known-missing target.
34
+ */
35
+ export function exists(graph: AppGraph, uri: UriString): boolean {
36
+ return graph.modules[uri]?.exists ?? false;
37
+ }
38
+
39
+ /** Whether `uri` is one of the graph's entry points (a page or layout root). */
40
+ export function isEntryPoint(graph: AppGraph, uri: UriString): boolean {
41
+ return graph.entryPoints.some((entry) => entry.uri === uri);
42
+ }
43
+
44
+ /**
45
+ * Whether `uri` is an orphan: an existing, non-entry-point module that nothing
46
+ * references. Entry points (pages, layouts) are roots reachable independently of
47
+ * render edges, so they are never orphans; known-missing targets are "missing"
48
+ * (see {@link missingDependencies}), not orphans.
49
+ */
50
+ export function isOrphan(graph: AppGraph, uri: UriString): boolean {
51
+ const module = graph.modules[uri];
52
+ if (!module || module.exists === false) return false;
53
+ if (isEntryPoint(graph, uri)) return false;
54
+ // Schema nodes are referenced by model table name (from graphql/commands), not
55
+ // by file edges, so they are NEVER edge-referenced — "no incoming references"
56
+ // is meaningless for them. Excluding them keeps orphan = render dead-code.
57
+ if (module.type === ModuleType.Schema) return false;
58
+ return module.references.length === 0;
59
+ }
60
+
61
+ /** Every orphan module in the graph (see {@link isOrphan}), sorted by URI. */
62
+ export function orphans(graph: AppGraph): AppModule[] {
63
+ return Object.values(graph.modules)
64
+ .filter((module) => isOrphan(graph, module.uri))
65
+ .sort((a, b) => a.uri.localeCompare(b.uri));
66
+ }
67
+
68
+ /**
69
+ * Every module URI transitively reachable from `uri` by following outgoing
70
+ * dependency edges, sorted. Excludes `uri` itself unless a cycle leads back to
71
+ * it. Modules absent from the graph contribute nothing.
72
+ */
73
+ export function reachableFrom(graph: AppGraph, uri: UriString): UriString[] {
74
+ const seen = new Set<UriString>();
75
+ const queue: UriString[] = dependenciesOf(graph, uri).map((dep) => dep.target.uri);
76
+
77
+ while (queue.length > 0) {
78
+ const next = queue.shift()!;
79
+ if (seen.has(next)) continue;
80
+ seen.add(next);
81
+ for (const dep of dependenciesOf(graph, next)) queue.push(dep.target.uri);
82
+ }
83
+
84
+ return [...seen].sort((a, b) => a.localeCompare(b));
85
+ }
86
+
87
+ /** The outgoing edges of `uri` whose target does not exist on disk. */
88
+ export function missingDependencies(graph: AppGraph, uri: UriString): Reference[] {
89
+ return dependenciesOf(graph, uri).filter((ref) => !exists(graph, ref.target.uri));
90
+ }
91
+
92
+ /**
93
+ * Every edge in the graph that points at a non-existent target — the project's
94
+ * unresolved references. Sorted by (source URI, target URI) for stable output.
95
+ */
96
+ export function missingTargets(graph: AppGraph): Reference[] {
97
+ return Object.values(graph.modules)
98
+ .flatMap((module) => module.dependencies)
99
+ .filter((ref) => !exists(graph, ref.target.uri))
100
+ .sort(
101
+ (a, b) =>
102
+ a.source.uri.localeCompare(b.source.uri) || a.target.uri.localeCompare(b.target.uri),
103
+ );
104
+ }
105
+
106
+ export interface NearestModulesOptions {
107
+ /** Maximum number of candidates to return, closest first. Default 3. */
108
+ limit?: number;
109
+ /** Maximum edit distance to include. Default: no cap (consumer decides relevance). */
110
+ maxDistance?: number;
111
+ }
112
+
113
+ /**
114
+ * The existing modules whose names are closest to `uri` — the "did you mean?"
115
+ * candidate set for a typo'd or missing reference.
116
+ *
117
+ * Candidates are restricted to the SAME category as `uri` (same module `type`,
118
+ * and for Liquid the same `kind`), so a missing `{% render %}` only suggests
119
+ * partials, a missing `{% graphql %}` only graphql operations, etc. Ranked by
120
+ * Levenshtein distance over the project-relative path (reusing check-common's
121
+ * `levenshtein` + `path.relative` — never re-implemented), closest first, ties
122
+ * broken by URI. `uri` itself and known-missing modules are excluded.
123
+ *
124
+ * Pure over the graph: candidates come only from modules present in it, so build
125
+ * with every file as an entry point for a complete candidate pool (see header).
126
+ */
127
+ export function nearestModules(
128
+ graph: AppGraph,
129
+ uri: UriString,
130
+ options: NearestModulesOptions = {},
131
+ ): AppModule[] {
132
+ const { limit = 3, maxDistance = Infinity } = options;
133
+ const target = graph.modules[uri];
134
+ if (!target) return [];
135
+
136
+ const targetPath = path.relative(uri, graph.rootUri);
137
+
138
+ return Object.values(graph.modules)
139
+ .filter(
140
+ (module) =>
141
+ module.uri !== uri &&
142
+ module.exists !== false &&
143
+ module.type === target.type &&
144
+ module.kind === target.kind,
145
+ )
146
+ .map((module) => ({
147
+ module,
148
+ distance: levenshtein(path.relative(module.uri, graph.rootUri), targetPath),
149
+ }))
150
+ .filter((candidate) => candidate.distance <= maxDistance)
151
+ .sort((a, b) => a.distance - b.distance || a.module.uri.localeCompare(b.module.uri))
152
+ .slice(0, limit)
153
+ .map((candidate) => candidate.module);
154
+ }