@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,30 +1,75 @@
1
+ import { UriString } from '@platformos/platformos-check-common';
1
2
  import {
2
- recursiveReadDirectory as findAllFiles,
3
- isLayout,
4
- isPage,
5
- path,
6
- UriString,
7
- } from '@platformos/platformos-check-common';
8
- import { IDependencies, AppGraph, AppModule } from '../types';
3
+ getFileType,
4
+ PlatformOSFileType,
5
+ SourceCodeType,
6
+ sourceCodeTypeOf,
7
+ walkAppSourceFiles,
8
+ } from '@platformos/platformos-common';
9
+ import { IDependencies, AppGraph, AppModule, GraphBuildOptions } from '../types';
9
10
  import { augmentDependencies } from './augment';
10
- import { getModule } from './module';
11
+ import { getModule, getSchemaModule } from './module';
11
12
  import { traverseModule } from './traverse';
12
13
 
14
+ /**
15
+ * Build the project dependency graph.
16
+ *
17
+ * - `entryPoints` omitted → FULL build: a single directory sweep discovers pages
18
+ * + layouts (the render entry points) and standalone custom-model-type/schema
19
+ * nodes, and every reachable module is materialized.
20
+ * - `entryPoints` provided → SCOPED build (e.g. an LSP rebuild for changed
21
+ * files): built verbatim from those roots. Schema nodes are NOT auto-discovered
22
+ * in this mode — schema discovery is a full-build concern.
23
+ *
24
+ * `options.includeStructural` (default off) additionally populates each
25
+ * `LiquidModule.structural`; see {@link GraphBuildOptions}.
26
+ */
13
27
  export async function buildAppGraph(
14
28
  rootUri: UriString,
15
29
  ideps: IDependencies,
16
30
  entryPoints?: UriString[],
31
+ options: GraphBuildOptions = {},
17
32
  ): Promise<AppGraph> {
18
33
  const deps = augmentDependencies(rootUri, ideps);
19
34
 
20
- entryPoints =
21
- entryPoints ??
22
- (await findAllFiles(deps.fs, rootUri, ([uri]) => {
23
- if (!uri.endsWith('.liquid')) return false;
24
- // Layouts are entry points — they wrap all page content.
25
- // Pages are also entry points — they are directly requested.
26
- return isLayout(uri) || isPage(uri);
27
- }));
35
+ // Table (schema / custom-model-type) files are platform nodes but are NOT
36
+ // render-reachable — nothing renders them — so they never appear via edge traversal. On
37
+ // a full build they are discovered as standalone leaf nodes, never entry points, so
38
+ // reachability/orphan semantics for the render graph are unaffected.
39
+ let schemaUris: UriString[] = [];
40
+
41
+ // An explicit entryPoints scope is built verbatim (e.g. a scoped LSP rebuild); the
42
+ // default full build (`entryPoints === undefined`) also discovers standalone Table
43
+ // nodes. Branching on the parameter directly lets the compiler narrow it below.
44
+ if (entryPoints === undefined) {
45
+ // ONE sweep yields both populations, partitioned by SourceCodeType below, so the
46
+ // tree is walked once. `walkAppSourceFiles` is anchored on APP_SOURCE_SUBTREES: the
47
+ // walk it replaces started at the root and skipped directories by NAME, which both
48
+ // dropped `app/views/pages/vendor/**` (a live site section — 137 files on one real
49
+ // project) and admitted `tmp/app/views/partials/x.liquid`.
50
+ const discovered = await walkAppSourceFiles(deps.fs, rootUri, ([uri]) => {
51
+ // Root-ANCHORED classification. The unanchored `getFileType(uri)` this used to call
52
+ // matched a known directory anywhere in the path, so
53
+ // `seed/post_import/app/migrations/x.liquid` classified as a Migration while being
54
+ // correctly absent from the lint's app.
55
+ const fileType = getFileType(uri, rootUri);
56
+
57
+ // A GRAPH-domain restriction, not a second answer to "what is a source file":
58
+ // Liquid is the only source that can reference another file, so a non-Liquid entry
59
+ // point would have no edges to traverse. Which extensions ARE Liquid still comes
60
+ // from platformos-common, never spelled here — which is also why the `.yml`/`.yaml`
61
+ // tests that used to partition this sweep are gone: `.yaml` is not a platformOS
62
+ // extension at all, so spelling it here promised coverage the platform never had.
63
+ if (sourceCodeTypeOf(uri) === SourceCodeType.LiquidHtml) {
64
+ // Layouts wrap all page content; pages are directly requested — both are entry points.
65
+ return fileType === PlatformOSFileType.Layout || fileType === PlatformOSFileType.Page;
66
+ }
67
+ return fileType === PlatformOSFileType.Table;
68
+ });
69
+
70
+ entryPoints = discovered.filter((uri) => sourceCodeTypeOf(uri) === SourceCodeType.LiquidHtml);
71
+ schemaUris = discovered.filter((uri) => sourceCodeTypeOf(uri) !== SourceCodeType.LiquidHtml);
72
+ }
28
73
 
29
74
  const graph: AppGraph = {
30
75
  entryPoints: [],
@@ -36,7 +81,13 @@ export async function buildAppGraph(
36
81
  .map((uri) => getModule(graph, uri))
37
82
  .filter((x): x is AppModule => x !== undefined);
38
83
 
39
- await Promise.all(graph.entryPoints.map((entry) => traverseModule(entry, graph, deps)));
84
+ await Promise.all(graph.entryPoints.map((entry) => traverseModule(entry, graph, deps, options)));
85
+
86
+ if (schemaUris.length > 0) {
87
+ await Promise.all(
88
+ schemaUris.map((uri) => traverseModule(getSchemaModule(graph, uri), graph, deps, options)),
89
+ );
90
+ }
40
91
 
41
92
  return graph;
42
93
  }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { path } from '@platformos/platformos-check-common';
4
+ import { FileType, getFileType, PlatformOSFileType } from '@platformos/platformos-common';
5
+ import type { AbstractFileSystem, UriString } from '@platformos/platformos-common';
6
+ import { MockFileSystem, type MockApp } from '@platformos/platformos-check-common/dist/test';
7
+
8
+ import { enumerateEdgeSources, isEdgeSource } from '../index';
9
+
10
+ /**
11
+ * `enumerateEdgeSources` is the SINGLE canonical "what are the
12
+ * edge-source liquid files under a project root" primitive. The supervisor's
13
+ * GraphCache consumes it for BOTH the fingerprint domain and the build's entry
14
+ * points, so its definition must never silently drift from the file-type
15
+ * classifier (`getFileType` ← `FILE_TYPE_DIRS`).
16
+ */
17
+ describe('Unit: enumerateEdgeSources (canonical edge-source enumeration)', () => {
18
+ const rootUri = path.normalize('file:/');
19
+ const uri = (rel: string) => path.join(rootUri, rel);
20
+
21
+ // Edge sources under every canonical location a Page/Layout/Partial can live.
22
+ const EDGE_SOURCES = [
23
+ 'app/views/pages/home.liquid', // Page (modern app/ root)
24
+ 'app/views/layouts/application.liquid', // Layout
25
+ 'app/views/partials/card.liquid', // Partial (views/partials)
26
+ 'app/lib/helper.liquid', // Partial (lib)
27
+ 'marketplace_builder/views/pages/legacy.liquid', // Page (legacy root)
28
+ 'modules/shop/public/views/partials/widget.liquid', // Partial (top-level module, public)
29
+ 'modules/shop/private/lib/secret.liquid', // Partial (top-level module, PRIVATE access level)
30
+ 'app/modules/blog/private/views/pages/post.liquid', // Page (nested app/modules)
31
+ ];
32
+
33
+ // Files that are NOT edge sources: leaves, non-liquid, and a bundled
34
+ // non-platformOS sibling that must never be walked.
35
+ const NON_EDGE_SOURCES = {
36
+ 'app/graphql/get_posts.graphql': 'query { records { results { id } } }',
37
+ 'app/schema/blog_post.yml': 'name: blog_post',
38
+ 'app/assets/logo.css': 'body {}',
39
+ 'react-app/src/components/Widget.liquid': 'noise that is never a source',
40
+ 'README.md': '# project',
41
+ };
42
+
43
+ const makeFs = () => {
44
+ const files: MockApp = { ...NON_EDGE_SOURCES };
45
+ for (const rel of EDGE_SOURCES) files[rel] = '<div></div>';
46
+ return new MockFileSystem(files, rootUri);
47
+ };
48
+
49
+ const sorted = (uris: string[]) => [...uris].sort();
50
+
51
+ it('gathers exactly the edge sources across all source roots (nothing else)', async () => {
52
+ const fs = makeFs();
53
+ expect(sorted(await enumerateEdgeSources(fs, rootUri))).toEqual(sorted(EDGE_SOURCES.map(uri)));
54
+ });
55
+
56
+ it('the scoped walk equals a whole-tree walk filtered by the classifier (no drift)', async () => {
57
+ const fs = makeFs();
58
+ const scoped = sorted(await enumerateEdgeSources(fs, rootUri));
59
+ const wholeTree = sorted(
60
+ (await everyFileUnder(fs, rootUri)).filter((u) => isEdgeSource(u, rootUri)),
61
+ );
62
+ expect(scoped).toEqual(wholeTree);
63
+ });
64
+
65
+ it('isEdgeSource is exactly the Layout/Page/Partial classification', () => {
66
+ const EDGE_SOURCE_TYPES = [
67
+ PlatformOSFileType.Layout,
68
+ PlatformOSFileType.Page,
69
+ PlatformOSFileType.Partial,
70
+ ];
71
+ for (const rel of EDGE_SOURCES) {
72
+ const u = uri(rel);
73
+ expect(isEdgeSource(u, rootUri)).toBe(true);
74
+ expect(EDGE_SOURCE_TYPES).toContain(getFileType(u, rootUri));
75
+ }
76
+ for (const rel of Object.keys(NON_EDGE_SOURCES)) {
77
+ const u = uri(rel);
78
+ expect(isEdgeSource(u, rootUri)).toBe(false);
79
+ expect(EDGE_SOURCE_TYPES).not.toContain(getFileType(u, rootUri));
80
+ }
81
+ });
82
+
83
+ /**
84
+ * The reason the classification is anchored on the root. Unanchored, a directory
85
+ * named like a platformOS one ANYWHERE in the path classified: these three files
86
+ * are not part of the app, and counting them in the fingerprint domain made the
87
+ * supervisor's GraphCache rebuild on edits to files the app never reads.
88
+ */
89
+ it('does not admit a platformOS-looking path outside the app subtrees', async () => {
90
+ const outside = {
91
+ 'tmp/app/views/partials/scratch.liquid': '<div></div>',
92
+ 'seed/post_import/app/views/pages/fixture.liquid': '<div></div>',
93
+ 'node_modules/pkg/app/lib/helper.liquid': '<div></div>',
94
+ };
95
+ for (const rel of Object.keys(outside)) expect(isEdgeSource(uri(rel), rootUri)).toBe(false);
96
+
97
+ // Control: the same three spellings ARE found when they sit at the real root, so
98
+ // the silence above is the anchoring, not the fixture or the walk.
99
+ const files: MockApp = { ...outside };
100
+ for (const rel of EDGE_SOURCES) files[rel] = '<div></div>';
101
+ const fs = new MockFileSystem(files, rootUri);
102
+ expect(sorted(await enumerateEdgeSources(fs, rootUri))).toEqual(sorted(EDGE_SOURCES.map(uri)));
103
+ });
104
+
105
+ it('never yields a non-platformOS sibling (a bundled react-app/ is skipped)', async () => {
106
+ const fs = makeFs();
107
+ const result = await enumerateEdgeSources(fs, rootUri);
108
+ expect(result).not.toContain(uri('react-app/src/components/Widget.liquid'));
109
+ });
110
+ });
111
+
112
+ /**
113
+ * Every file under `baseUri`, by brute-force recursion into every directory.
114
+ */
115
+ async function everyFileUnder(fs: AbstractFileSystem, baseUri: UriString): Promise<UriString[]> {
116
+ const entries = await fs.readDirectory(baseUri).catch(() => []);
117
+
118
+ const nested = await Promise.all(
119
+ entries.map(async ([uri, type]) =>
120
+ type === FileType.Directory ? everyFileUnder(fs, uri) : [uri],
121
+ ),
122
+ );
123
+
124
+ return nested.flat();
125
+ }
@@ -0,0 +1,49 @@
1
+ import { type UriString } from '@platformos/platformos-check-common';
2
+ import {
3
+ getFileType,
4
+ PlatformOSFileType,
5
+ walkAppSourceFiles,
6
+ type AbstractFileSystem,
7
+ } from '@platformos/platformos-common';
8
+
9
+ /**
10
+ * The canonical definition of an EDGE SOURCE: a liquid file whose own content can declare
11
+ * outgoing edges (a Page, Layout, or Partial). Only these files' add/remove/modify can change
12
+ * any file's set of dependents — `.graphql`/`.yml`/asset files are leaves.
13
+ *
14
+ * "Which files" derives entirely from the file-type classifier, so this predicate never
15
+ * re-encodes the classification. It is ANCHORED on `rootUri`, because a file's type IS its
16
+ * position relative to the project root: unanchored, `tmp/app/lib/x.liquid` is a Partial.
17
+ */
18
+ export function isEdgeSource(uri: UriString, rootUri: UriString): boolean {
19
+ switch (getFileType(uri, rootUri)) {
20
+ case PlatformOSFileType.Layout:
21
+ case PlatformOSFileType.Page:
22
+ case PlatformOSFileType.Partial:
23
+ return true;
24
+ default:
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Enumerate every edge-source liquid file under a project root — the single canonical primitive
31
+ * for "which files are the graph's edge sources". Consumers never re-derive the walk or the
32
+ * {@link isEdgeSource} predicate.
33
+ *
34
+ * The walk is `walkAppSourceFiles`, platformos-common's anchored expansion of
35
+ * `APP_SOURCE_SUBTREES`, so this file names no directory at all and a bundled `react-app/` is
36
+ * never walked. `edge-sources.spec` pins the scoped result against a whole-tree walk filtered
37
+ * by {@link isEdgeSource}.
38
+ *
39
+ * NOT shared with `buildAppGraph`'s full-build discovery: the two gather DIFFERENT domains —
40
+ * that one takes render entry points (pages + layouts, partials being edge-reached) plus
41
+ * standalone schema nodes, this one takes page + layout + partial. They share the walk
42
+ * primitive and the classifier, which are the parts that must not drift.
43
+ */
44
+ export async function enumerateEdgeSources(
45
+ fs: AbstractFileSystem,
46
+ rootUri: UriString,
47
+ ): Promise<UriString[]> {
48
+ return walkAppSourceFiles(fs, rootUri, ([uri]) => isEdgeSource(uri, rootUri));
49
+ }
@@ -0,0 +1,168 @@
1
+ import { DocumentsLocator, FileType } from '@platformos/platformos-common';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { URI } from 'vscode-uri';
4
+ import { AppGraph, ModuleType } from '../types';
5
+ import {
6
+ getAssetModule,
7
+ getLayoutModule,
8
+ getLayoutModuleByUri,
9
+ getModule,
10
+ getPageModule,
11
+ getPartialModuleByUri,
12
+ } from './module';
13
+
14
+ /**
15
+ * Node-identity invariant (code-review F10): a file must resolve to ONE module
16
+ * regardless of which producer creates it. Entry-point factories
17
+ * (getLayoutModule/getPageModule) take a URI straight from directory discovery,
18
+ * while edge-target factories (getLayoutModuleByUri/getPartialModuleByUri) take a
19
+ * DocumentsLocator-resolved URI; both normalize, so a Windows-style backslash
20
+ * URI from one and a forward-slash URI from the other key to the SAME cached
21
+ * node — never a split identity that would drop an incoming edge.
22
+ */
23
+ describe('module factories: normalized node identity', () => {
24
+ const newGraph = (): AppGraph => ({ rootUri: 'file:///project', entryPoints: [], modules: {} });
25
+
26
+ it('dedupes an entry-point layout with the same layout resolved as an edge target', () => {
27
+ const graph = newGraph();
28
+ // As if discovered by directory traversal on Windows (backslashes)…
29
+ const entry = getLayoutModule(graph, 'file:///project\\app\\views\\layouts\\theme.liquid');
30
+ // …and as if resolved from a page's frontmatter `layout:` (forward slashes).
31
+ const edgeTarget = getLayoutModuleByUri(
32
+ graph,
33
+ 'file:///project/app/views/layouts/theme.liquid',
34
+ );
35
+
36
+ expect(entry).toBe(edgeTarget); // same cached object — one node
37
+ expect(entry?.uri).toEqual('file:///project/app/views/layouts/theme.liquid');
38
+ });
39
+
40
+ it('dedupes an entry-point page regardless of separator style', () => {
41
+ const graph = newGraph();
42
+ const first = getPageModule(graph, 'file:///project\\app\\views\\pages\\index.liquid');
43
+ const second = getPageModule(graph, 'file:///project/app/views/pages/index.liquid');
44
+
45
+ expect(first).toBe(second);
46
+ expect(first.uri).toEqual('file:///project/app/views/pages/index.liquid');
47
+ });
48
+
49
+ it('dedupes a partial across separator styles too (regression guard for the shared contract)', () => {
50
+ const graph = newGraph();
51
+ const a = getPartialModuleByUri(graph, 'file:///project\\app\\views\\partials\\card.liquid');
52
+ const b = getPartialModuleByUri(graph, 'file:///project/app/views/partials/card.liquid');
53
+
54
+ expect(a).toBe(b);
55
+ expect(a.uri).toEqual('file:///project/app/views/partials/card.liquid');
56
+ });
57
+ });
58
+
59
+ /**
60
+ * `getModule` (the entry-point dispatcher) must key a partial by its OWN
61
+ * resolved URI — like it does for layouts/pages/assets — NOT by rebuilding the
62
+ * path from the basename. Rebuilding from the basename forced every partial into
63
+ * `app/views/partials/<basename>.liquid`, which mis-keyed any `lib/` or nested
64
+ * partial (e.g. `app/lib/can/payment_request.liquid` → the phantom
65
+ * `app/views/partials/payment_request.liquid`), splitting it from the same file
66
+ * resolved as an edge target and losing its edges in the full build.
67
+ */
68
+ describe('getModule: partial entry point keys by its own URI', () => {
69
+ const newGraph = (): AppGraph => ({ rootUri: 'file:///project', entryPoints: [], modules: {} });
70
+
71
+ it('keys a lib partial at its own URI, not app/views/partials/<basename>', () => {
72
+ const graph = newGraph();
73
+ const uri = 'file:///project/app/lib/can/payment_request.liquid';
74
+ expect(getModule(graph, uri)?.uri).toEqual(uri);
75
+ });
76
+
77
+ it('keys a nested lib partial at its own URI', () => {
78
+ const graph = newGraph();
79
+ const uri = 'file:///project/app/lib/queries/v2/projects/find.liquid';
80
+ expect(getModule(graph, uri)?.uri).toEqual(uri);
81
+ });
82
+
83
+ it('a lib partial entry point and the same file resolved as an edge target are ONE node', () => {
84
+ const graph = newGraph();
85
+ const uri = 'file:///project/app/lib/commands/create.liquid';
86
+ const entry = getModule(graph, uri);
87
+ const edgeTarget = getPartialModuleByUri(graph, uri);
88
+ expect(entry).toBe(edgeTarget);
89
+ });
90
+
91
+ it('a flat app/views/partials partial is unaffected', () => {
92
+ const graph = newGraph();
93
+ const uri = 'file:///project/app/views/partials/card.liquid';
94
+ expect(getModule(graph, uri)?.uri).toEqual(uri);
95
+ });
96
+ });
97
+
98
+ const ROOT = 'file:///project';
99
+
100
+ const emptyGraph = (): AppGraph => ({ rootUri: ROOT, entryPoints: [], modules: {} });
101
+
102
+ /**
103
+ * Assets follow exactly the same placement rules as every other platformOS file type:
104
+ * `app/assets/` or `modules/<name>/{public,private}/assets/`. Resolution goes through
105
+ * platformos-common, so the graph cannot disagree with `DocumentsLocator` about where
106
+ * `{{ 'app.js' | asset_url }}` points.
107
+ */
108
+ describe('asset reference resolution', () => {
109
+ it("resolves 'app.js' to app/assets/app.js", () => {
110
+ expect(getAssetModule(emptyGraph(), 'app.js')!.uri).toBe(`${ROOT}/app/assets/app.js`);
111
+ });
112
+
113
+ it('keeps a subdirectory instead of collapsing to a basename', () => {
114
+ expect(getAssetModule(emptyGraph(), 'styles/theme.css')!.uri).toBe(
115
+ `${ROOT}/app/assets/styles/theme.css`,
116
+ );
117
+ });
118
+
119
+ it('routes a modules/<name>/ reference into that module, not into app/assets', () => {
120
+ expect(getAssetModule(emptyGraph(), 'modules/admin/app.css')!.uri).toBe(
121
+ `${ROOT}/app/modules/admin/public/assets/app.css`,
122
+ );
123
+ });
124
+
125
+ it('ignores a reference with an extension no asset uses', () => {
126
+ expect(getAssetModule(emptyGraph(), 'notes.txt')).toBe(undefined);
127
+ });
128
+
129
+ it('agrees with DocumentsLocator for an asset that exists', async () => {
130
+ const assetUri = `${ROOT}/app/assets/app.js`;
131
+ const locator = new DocumentsLocator({
132
+ stat: async (uri: string) => {
133
+ if (uri !== assetUri) throw new Error(`ENOENT: ${uri}`);
134
+ return { type: FileType.File, size: 0 };
135
+ },
136
+ readFile: async () => '',
137
+ // Resolution reads the candidate directory's listing, not a stat per spelling.
138
+ readDirectory: async (uri: string) => {
139
+ if (uri !== `${ROOT}/app/assets`) throw new Error(`ENOENT: ${uri}`);
140
+ return [[assetUri, FileType.File]];
141
+ },
142
+ });
143
+
144
+ expect(getAssetModule(emptyGraph(), 'app.js')!.uri).toBe(
145
+ await locator.locate(URI.parse(ROOT), 'asset', 'app.js'),
146
+ );
147
+ });
148
+
149
+ it('classifies an asset URI as an asset module without reconstructing its path', () => {
150
+ const nested = `${ROOT}/app/assets/styles/theme.css`;
151
+ const moduleAsset = `${ROOT}/modules/admin/private/assets/app.css`;
152
+
153
+ expect(getModule(emptyGraph(), nested)).toMatchObject({
154
+ type: ModuleType.Asset,
155
+ uri: nested,
156
+ });
157
+ // A module ORIGINAL keeps its own path — resolving by name would have moved it to
158
+ // the app-overwrite slot.
159
+ expect(getModule(emptyGraph(), moduleAsset)).toMatchObject({
160
+ type: ModuleType.Asset,
161
+ uri: moduleAsset,
162
+ });
163
+ });
164
+
165
+ it('does not treat a root-level assets/ path as part of the app', () => {
166
+ expect(getModule(emptyGraph(), `${ROOT}/assets/app.js`)).toBe(undefined);
167
+ });
168
+ });