@platformos/platformos-graph 0.0.20 → 0.1.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.
- package/CHANGELOG.md +511 -8
- package/dist/cache-probes.spec.d.ts +1 -0
- package/dist/cache-probes.spec.js +62 -0
- package/dist/cache-probes.spec.js.map +1 -0
- package/dist/cli.js +18 -13
- package/dist/cli.js.map +1 -1
- package/dist/cli.spec.d.ts +1 -0
- package/dist/cli.spec.js +235 -0
- package/dist/cli.spec.js.map +1 -0
- package/dist/graph/app-index-resolution.spec.d.ts +1 -0
- package/dist/graph/app-index-resolution.spec.js +167 -0
- package/dist/graph/app-index-resolution.spec.js.map +1 -0
- package/dist/graph/asset-resolution.spec.d.ts +1 -0
- package/dist/graph/asset-resolution.spec.js +64 -0
- package/dist/graph/asset-resolution.spec.js.map +1 -0
- package/dist/graph/augment.js +3 -0
- package/dist/graph/augment.js.map +1 -1
- package/dist/graph/build.d.ts +15 -2
- package/dist/graph/build.js +54 -12
- package/dist/graph/build.js.map +1 -1
- package/dist/graph/build.spec.d.ts +1 -0
- package/dist/graph/build.spec.js +138 -0
- package/dist/graph/build.spec.js.map +1 -0
- package/dist/graph/edge-sources.d.ts +28 -0
- package/dist/graph/edge-sources.js +43 -0
- package/dist/graph/edge-sources.js.map +1 -0
- package/dist/graph/edge-sources.spec.d.ts +1 -0
- package/dist/graph/edge-sources.spec.js +125 -0
- package/dist/graph/edge-sources.spec.js.map +1 -0
- package/dist/graph/extract.spec.d.ts +1 -0
- package/dist/graph/extract.spec.js +163 -0
- package/dist/graph/extract.spec.js.map +1 -0
- package/dist/graph/module.d.ts +60 -7
- package/dist/graph/module.js +137 -41
- package/dist/graph/module.js.map +1 -1
- package/dist/graph/module.spec.d.ts +1 -0
- package/dist/graph/module.spec.js +74 -0
- package/dist/graph/module.spec.js.map +1 -0
- package/dist/graph/query.d.ts +72 -0
- package/dist/graph/query.js +140 -0
- package/dist/graph/query.js.map +1 -0
- package/dist/graph/query.spec.d.ts +1 -0
- package/dist/graph/query.spec.js +209 -0
- package/dist/graph/query.spec.js.map +1 -0
- package/dist/graph/serialize.spec.d.ts +1 -0
- package/dist/graph/serialize.spec.js +50 -0
- package/dist/graph/serialize.spec.js.map +1 -0
- package/dist/graph/structural.spec.d.ts +1 -0
- package/dist/graph/structural.spec.js +146 -0
- package/dist/graph/structural.spec.js.map +1 -0
- package/dist/graph/test-helpers.js +3 -3
- package/dist/graph/test-helpers.js.map +1 -1
- package/dist/graph/traverse-edges.spec.d.ts +1 -0
- package/dist/graph/traverse-edges.spec.js +337 -0
- package/dist/graph/traverse-edges.spec.js.map +1 -0
- package/dist/graph/traverse.d.ts +51 -20
- package/dist/graph/traverse.js +239 -29
- package/dist/graph/traverse.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/parsers.d.ts +35 -0
- package/dist/parsers.js +72 -0
- package/dist/parsers.js.map +1 -0
- package/dist/parsers.spec.d.ts +1 -0
- package/dist/parsers.spec.js +70 -0
- package/dist/parsers.spec.js.map +1 -0
- package/dist/toSourceCode.d.ts +14 -2
- package/dist/toSourceCode.js +23 -19
- package/dist/toSourceCode.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +102 -3
- package/dist/types.js.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/fixtures/asset-edges/app/assets/logo.css +1 -0
- package/fixtures/asset-edges/app/assets/site/app.js +1 -0
- package/fixtures/asset-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/graphql-table/app/graphql/with_table.graphql +5 -0
- package/fixtures/graphql-table/app/graphql/without_table.graphql +3 -0
- package/fixtures/graphql-table/app/views/pages/index.liquid +4 -0
- package/fixtures/layout-edges/app/views/layouts/theme.liquid +4 -0
- package/fixtures/layout-edges/app/views/pages/broken.liquid +5 -0
- package/fixtures/layout-edges/app/views/pages/index.liquid +5 -0
- package/fixtures/schema-nodes/app/schema/blog_post.yml +6 -0
- package/fixtures/schema-nodes/app/schema/no_name.yml +3 -0
- package/fixtures/schema-nodes/app/views/pages/index.liquid +1 -0
- package/fixtures/structural/app/views/pages/about.liquid +1 -0
- package/fixtures/structural/app/views/pages/blog/show.liquid +4 -0
- package/fixtures/structural/app/views/pages/index.liquid +6 -0
- package/fixtures/structural/app/views/pages/rich.liquid +8 -0
- package/fixtures/structural/app/views/partials/card.liquid +1 -0
- package/fixtures/structural/app/views/partials/documented.liquid +5 -0
- package/package.json +7 -5
- package/src/cli.spec.ts +37 -31
- package/src/cli.ts +31 -15
- package/src/graph/augment.ts +4 -0
- package/src/graph/build.spec.ts +871 -6
- package/src/graph/build.ts +68 -17
- package/src/graph/edge-sources.spec.ts +125 -0
- package/src/graph/edge-sources.ts +49 -0
- package/src/graph/module.spec.ts +168 -0
- package/src/graph/module.ts +139 -45
- package/src/graph/query.spec.ts +260 -0
- package/src/graph/query.ts +154 -0
- package/src/graph/serialize.spec.ts +4 -4
- package/src/graph/test-helpers.ts +3 -3
- package/src/graph/{extract.spec.ts → traverse.spec.ts} +125 -2
- package/src/graph/traverse.ts +278 -37
- package/src/index.ts +16 -1
- package/src/parsers.spec.ts +85 -0
- package/src/parsers.ts +75 -0
- package/src/toSourceCode.ts +26 -26
- package/src/types.ts +106 -7
- package/src/utils/index.ts +1 -1
- package/dist/getWebComponentMap.d.ts +0 -10
- package/dist/getWebComponentMap.js +0 -66
- package/dist/getWebComponentMap.js.map +0 -1
- package/src/graph/traverse-edges.spec.ts +0 -278
- /package/fixtures/skeleton/{assets → app/assets}/app.css +0 -0
- /package/fixtures/skeleton/{assets → app/assets}/app.js +0 -0
package/src/graph/build.ts
CHANGED
|
@@ -1,30 +1,75 @@
|
|
|
1
|
+
import { UriString } from '@platformos/platformos-check-common';
|
|
1
2
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from '@platformos/platformos-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
});
|