From d4a53e8c501f6d3bbee86d05dac5cf066345f470 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Mon, 29 Jun 2026 16:01:33 +0200 Subject: [PATCH 01/20] Add layout support and associated tests in DocumentsLocator and graph traversal --- .../missing-content-for-layout/index.ts | 10 +- .../DocumentsLocator.spec.ts | 146 ++++++++++++++++++ .../src/documents-locator/DocumentsLocator.ts | 50 ++++-- .../app/views/layouts/theme.liquid | 4 + .../app/views/pages/broken.liquid | 5 + .../layout-edges/app/views/pages/index.liquid | 5 + packages/platformos-graph/package.json | 4 +- packages/platformos-graph/src/cli.spec.ts | 6 + .../src/graph/extract.spec.ts | 68 ++++++++ packages/platformos-graph/src/graph/module.ts | 18 +++ .../src/graph/traverse-edges.spec.ts | 60 +++++++ .../platformos-graph/src/graph/traverse.ts | 39 ++++- 12 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 packages/platformos-graph/fixtures/layout-edges/app/views/layouts/theme.liquid create mode 100644 packages/platformos-graph/fixtures/layout-edges/app/views/pages/broken.liquid create mode 100644 packages/platformos-graph/fixtures/layout-edges/app/views/pages/index.liquid diff --git a/packages/platformos-check-common/src/checks/missing-content-for-layout/index.ts b/packages/platformos-check-common/src/checks/missing-content-for-layout/index.ts index ddec6bb4..631c2571 100644 --- a/packages/platformos-check-common/src/checks/missing-content-for-layout/index.ts +++ b/packages/platformos-check-common/src/checks/missing-content-for-layout/index.ts @@ -1,6 +1,6 @@ import { NodeTypes } from '@platformos/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { isLayout } from '../../path'; +import { getFileType, PlatformOSFileType } from '../../path'; /** * Ensures every layout outputs `{{ content_for_layout }}`. @@ -10,8 +10,10 @@ import { isLayout } from '../../path'; * entirely — a correctness defect, not a style issue. (Named slots use * `{% yield 'name' %}` separately and do not substitute for it.) * - * The check is scoped to layout files via `getFileType` (re-exported as - * `isLayout`) so it never fires on pages or partials. Both detection and the + * The check is scoped to layout files via the canonical + * `PlatformOSFileType.Layout` (the single source of truth shared with + * DocumentsLocator's `'layout'` type and the graph's layout edge), so it never + * fires on pages or partials. Both detection and the * suggested fix are AST-based: any reference to the `content_for_layout` * variable clears the check (whether emitted with `{{ … }}`, `{% echo … %}`, * or inside a `{% liquid %}` block), and the fix is inserted before the @@ -39,7 +41,7 @@ export const MissingContentForLayout: LiquidCheckDefinition = { create(context) { // Scope to layout files only; pages/partials/etc. must not be flagged. - if (!isLayout(context.file.uri)) return {}; + if (getFileType(context.file.uri) !== PlatformOSFileType.Layout) return {}; let referencesContentForLayout = false; // Start index of the `` closing tag, captured from the AST so the diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts index 76dfc950..e0163b13 100644 --- a/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts +++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts @@ -128,6 +128,20 @@ describe('DocumentsLocator', () => { const locator = new DocumentsLocator(createMockFileSystem({})); expect(locator.locateDefault(rootUri, 'asset', 'logo.png')).toBeUndefined(); }); + + it('layout → app/views/layouts (.liquid canonical default)', () => { + const locator = new DocumentsLocator(createMockFileSystem({})); + expect(locator.locateDefault(rootUri, 'layout', 'theme')).toBe( + 'file:///project/app/views/layouts/theme.liquid', + ); + }); + + it('module layout → modules/.../public/views/layouts', () => { + const locator = new DocumentsLocator(createMockFileSystem({})); + expect(locator.locateDefault(rootUri, 'layout', 'modules/core/admin')).toBe( + 'file:///project/modules/core/public/views/layouts/admin.liquid', + ); + }); }); describe('locate', () => { @@ -196,6 +210,138 @@ describe('DocumentsLocator', () => { expect(result).toBe('file:///project/app/modules/admin/private/views/partials/secret.liquid'); }); + + it('should locate a .liquid layout in app/views/layouts', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/views/layouts/application.liquid': '{{ content_for_layout }}', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'application'); + + expect(result).toBe('file:///project/app/views/layouts/application.liquid'); + }); + + it('should locate an .html.liquid layout in app/views/layouts', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/views/layouts/application.html.liquid': '{{ content_for_layout }}', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'application'); + + expect(result).toBe('file:///project/app/views/layouts/application.html.liquid'); + }); + + it('should prefer .html.liquid over .liquid when both exist', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/views/layouts/application.html.liquid': 'html', + 'file:///project/app/views/layouts/application.liquid': 'plain', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'application'); + + expect(result).toBe('file:///project/app/views/layouts/application.html.liquid'); + }); + + it('should locate a module layout', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/modules/core/public/views/layouts/admin.liquid': 'admin', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'modules/core/admin'); + + expect(result).toBe('file:///project/app/modules/core/public/views/layouts/admin.liquid'); + }); + + it('should return undefined for a non-existent layout', async () => { + const fs = createMockFileSystem({}); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'missing'); + + expect(result).toBeUndefined(); + }); + + it('should locate a module .html.liquid layout', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/modules/core/public/views/layouts/admin.html.liquid': 'admin', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'modules/core/admin'); + + expect(result).toBe( + 'file:///project/app/modules/core/public/views/layouts/admin.html.liquid', + ); + }); + + it('should find a layout in a private module path when public is absent', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/modules/core/private/views/layouts/admin.liquid': 'admin', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'modules/core/admin'); + + expect(result).toBe('file:///project/app/modules/core/private/views/layouts/admin.liquid'); + }); + + it('should locate a nested layout name', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/views/layouts/admin/dashboard.liquid': 'dash', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'admin/dashboard'); + + expect(result).toBe('file:///project/app/views/layouts/admin/dashboard.liquid'); + }); + + it('should NOT resolve a layout name that only exists as a partial (search paths are isolated)', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/views/partials/foo.liquid': 'partial', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'layout', 'foo'); + + expect(result).toBeUndefined(); + }); + + it('locateOrDefault returns the existing layout (locate short-circuits the default)', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/views/layouts/theme.html.liquid': 'html', + }); + const locator = new DocumentsLocator(fs); + + // The default would be `theme.liquid`; the existing `.html.liquid` must win. + const result = await locator.locateOrDefault(rootUri, 'layout', 'theme'); + + expect(result).toBe('file:///project/app/views/layouts/theme.html.liquid'); + }); + + it('locateOrDefault falls back to the canonical default for a missing layout', async () => { + const fs = createMockFileSystem({}); + const locator = new DocumentsLocator(fs); + + const result = await locator.locateOrDefault(rootUri, 'layout', 'theme'); + + expect(result).toBe('file:///project/app/views/layouts/theme.liquid'); + }); + + it('should locate an asset by its own extension (no extension appended)', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/assets/logo.png': 'binary', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locate(rootUri, 'asset', 'logo.png'); + + expect(result).toBe('file:///project/app/assets/logo.png'); + }); }); describe('list', () => { diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts index 01ebedf7..715e2cf6 100644 --- a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts +++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts @@ -10,6 +10,7 @@ export type DocumentType = | 'background' | 'graphql' | 'asset' + | 'layout' | 'theme_render_rc'; /** @@ -49,36 +50,50 @@ export class DocumentsLocator { } } - private getSearchPaths(type: 'partial' | 'graphql' | 'asset', moduleName?: string): string[] { + private getSearchPaths( + type: 'partial' | 'graphql' | 'asset' | 'layout', + moduleName?: string, + ): string[] { const fileType: PlatformOSFileType = { partial: PlatformOSFileType.Partial, graphql: PlatformOSFileType.GraphQL, asset: PlatformOSFileType.Asset, + layout: PlatformOSFileType.Layout, }[type]; return moduleName ? getModulePaths(fileType, moduleName) : getAppPaths(fileType); } + /** + * Candidate filename extensions to probe for a physical file type, in priority + * order. Layouts exist as either `.html.liquid` (legacy) or `.liquid`, so both + * are tried (`.html.liquid` first). Assets carry their own extension in the + * name, so the candidate is the name itself (empty suffix). + */ + private static readonly EXTENSIONS: Record<'partial' | 'graphql' | 'asset' | 'layout', string[]> = + { + partial: ['.liquid'], + graphql: ['.graphql'], + layout: ['.html.liquid', '.liquid'], + asset: [''], + }; + private async locateFile( rootUri: URI, fileName: string, - type: 'partial' | 'graphql' | 'asset', + type: 'partial' | 'graphql' | 'asset' | 'layout', ): Promise { const parsed = parseModulePrefix(fileName); const searchPaths = this.getSearchPaths(type, parsed.isModule ? parsed.moduleName : undefined); - - let targetFile = parsed.key; - if (type === 'partial') { - targetFile += '.liquid'; - } else if (type === 'graphql') { - targetFile += '.graphql'; - } + const candidates = DocumentsLocator.EXTENSIONS[type].map((ext) => parsed.key + ext); for (const basePath of searchPaths) { - const uri = Utils.joinPath(rootUri, basePath, targetFile).toString(); + for (const candidate of candidates) { + const uri = Utils.joinPath(rootUri, basePath, candidate).toString(); - if (await this.isFile(uri)) { - return uri; + if (await this.isFile(uri)) { + return uri; + } } } @@ -281,6 +296,14 @@ export class DocumentsLocator { basePath = parsed.isModule ? `modules/${parsed.moduleName}/public/graphql` : 'app/graphql'; ext = '.graphql'; break; + case 'layout': + // Canonical creation path uses the modern `.liquid` extension (not the + // legacy `.html.liquid`), consistent with how the other types default. + basePath = parsed.isModule + ? `modules/${parsed.moduleName}/public/views/layouts` + : 'app/views/layouts'; + ext = '.liquid'; + break; case 'theme_render_rc': // ambiguous — multiple search paths, no single canonical location case 'asset': // no canonical creation path return undefined; @@ -331,6 +354,9 @@ export class DocumentsLocator { case 'asset': return this.locateFile(rootUri, fileName, 'asset'); + case 'layout': + return this.locateFile(rootUri, fileName, 'layout'); + default: return undefined; } diff --git a/packages/platformos-graph/fixtures/layout-edges/app/views/layouts/theme.liquid b/packages/platformos-graph/fixtures/layout-edges/app/views/layouts/theme.liquid new file mode 100644 index 00000000..b9bd5549 --- /dev/null +++ b/packages/platformos-graph/fixtures/layout-edges/app/views/layouts/theme.liquid @@ -0,0 +1,4 @@ + + + {{ content_for_layout }} + diff --git a/packages/platformos-graph/fixtures/layout-edges/app/views/pages/broken.liquid b/packages/platformos-graph/fixtures/layout-edges/app/views/pages/broken.liquid new file mode 100644 index 00000000..4e38f5af --- /dev/null +++ b/packages/platformos-graph/fixtures/layout-edges/app/views/pages/broken.liquid @@ -0,0 +1,5 @@ +--- +slug: broken +layout: ghost +--- +

broken

diff --git a/packages/platformos-graph/fixtures/layout-edges/app/views/pages/index.liquid b/packages/platformos-graph/fixtures/layout-edges/app/views/pages/index.liquid new file mode 100644 index 00000000..6da0355a --- /dev/null +++ b/packages/platformos-graph/fixtures/layout-edges/app/views/pages/index.liquid @@ -0,0 +1,5 @@ +--- +slug: index +layout: theme +--- +

home

diff --git a/packages/platformos-graph/package.json b/packages/platformos-graph/package.json index ab97f447..37a352af 100644 --- a/packages/platformos-graph/package.json +++ b/packages/platformos-graph/package.json @@ -33,9 +33,11 @@ "@platformos/platformos-check-common": "0.0.19", "@platformos/platformos-common": "0.0.17", "acorn": "^8.16.0", + "js-yaml": "^4.1.1", "vscode-uri": "^3.1.0" }, "devDependencies": { - "@platformos/platformos-check-node": "0.0.19" + "@platformos/platformos-check-node": "0.0.19", + "@types/js-yaml": "^4.0.9" } } diff --git a/packages/platformos-graph/src/cli.spec.ts b/packages/platformos-graph/src/cli.spec.ts index 7dee723e..f45149f5 100644 --- a/packages/platformos-graph/src/cli.spec.ts +++ b/packages/platformos-graph/src/cli.spec.ts @@ -94,6 +94,12 @@ describe('platformos-graph CLI: buildSerializedGraph', () => { type: 'direct', kind: 'asset', }, + { + source: p('app/views/pages/index.liquid'), + target: p('app/views/layouts/application.liquid'), + type: 'direct', + kind: 'layout', + }, { source: p('app/views/pages/index.liquid'), target: p('app/views/partials/parent.liquid'), diff --git a/packages/platformos-graph/src/graph/extract.spec.ts b/packages/platformos-graph/src/graph/extract.spec.ts index 5ad2442d..bfcaa3f1 100644 --- a/packages/platformos-graph/src/graph/extract.spec.ts +++ b/packages/platformos-graph/src/graph/extract.spec.ts @@ -153,3 +153,71 @@ describe('extractFileReferences: only statically resolvable edges', () => { expect(await extract(rootUri, sourceUri, '

{{ page.title }}

')).toEqual([]); }); }); + +describe('extractFileReferences: layout-association edges (frontmatter `layout:`)', () => { + const rootUri = pathUtils.join(fixturesRoot, 'function-edges'); // no layouts here → default path + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + const sourceUri = p('app/views/pages/draft.liquid'); + + // The whole frontmatter block: start through the closing `---` line + newline. + const fmRange = (content: string): [number, number] => [ + 0, + content.indexOf('\n', content.indexOf('---', 3)) + 1, + ]; + + it('emits a layout edge for an explicit frontmatter layout', async () => { + const content = `--- +slug: draft +layout: theme +--- +

x

`; + + expect(await extract(rootUri, sourceUri, content)).toEqual([ + directRef(sourceUri, fmRange(content), p('app/views/layouts/theme.liquid'), 'layout'), + ]); + }); + + it('resolves a module-prefixed layout', async () => { + const content = `--- +layout: modules/core/admin +--- +

x

`; + + expect(await extract(rootUri, sourceUri, content)).toEqual([ + directRef( + sourceUri, + fmRange(content), + p('modules/core/public/views/layouts/admin.liquid'), + 'layout', + ), + ]); + }); + + it('emits no edge for an explicitly empty layout', async () => { + const content = `--- +slug: draft +layout: '' +--- +

x

`; + + expect(await extract(rootUri, sourceUri, content)).toEqual([]); + }); + + it('emits no edge when layout is omitted (no implicit default)', async () => { + const content = `--- +slug: draft +--- +

x

`; + + expect(await extract(rootUri, sourceUri, content)).toEqual([]); + }); + + it('emits no edge for a dynamic layout value', async () => { + const content = `--- +layout: "{{ context.constants.LAYOUT }}" +--- +

x

`; + + expect(await extract(rootUri, sourceUri, content)).toEqual([]); + }); +}); diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index 6e8a5c20..98d5d505 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -142,6 +142,24 @@ export function getLayoutModule( }); } +/** + * Create (or fetch the cached) Layout module for an already-resolved layout URI + * — used for the frontmatter `layout:` association edge, whose target is + * resolved by `DocumentsLocator` (`'layout'` type: `app/views/layouts`, module + * prefixes, `.html.liquid`/`.liquid`). Unlike {@link getLayoutModule} (which + * takes a known on-disk entry-point URI), this normalizes the URI — see + * {@link getPartialModuleByUri} for why DocumentsLocator URIs must be normalized. + */ +export function getLayoutModuleByUri(appGraph: AppGraph, uri: string): LiquidModule { + return module(appGraph, { + type: ModuleType.Liquid, + kind: LiquidModuleKind.Layout, + uri: path.normalize(uri), + dependencies: [], + references: [], + }); +} + export function getPageModule(appGraph: AppGraph, pageUri: string): LiquidModule { return module(appGraph, { type: ModuleType.Liquid, diff --git a/packages/platformos-graph/src/graph/traverse-edges.spec.ts b/packages/platformos-graph/src/graph/traverse-edges.spec.ts index 4e0361d1..c2adf003 100644 --- a/packages/platformos-graph/src/graph/traverse-edges.spec.ts +++ b/packages/platformos-graph/src/graph/traverse-edges.spec.ts @@ -276,3 +276,63 @@ describe('Graph traversal: module-namespaced targets (modules//public/...) ); }); }); + +describe('Graph traversal: layout-association edges (frontmatter `layout:`)', () => { + const rootUri = pathUtils.join(fixturesRoot, 'layout-edges'); + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + let graph: AppGraph; + let indexSource: string; + let brokenSource: string; + + const layoutNode = (uri: string, exists: boolean, references: Reference[]): LiquidModule => ({ + type: ModuleType.Liquid, + kind: LiquidModuleKind.Layout, + uri, + exists, + dependencies: [], + references, + }); + + /** + * The source range the layout edge carries — the whole `YAMLFrontmatter` + * block, from the opening fence through the closing `---` line (incl. its + * trailing newline). Derived from the fixture text so it survives edits. + */ + const frontmatterRange = (source: string): [number, number] => [ + 0, + source.indexOf('\n', source.indexOf('---', 3)) + 1, + ]; + + beforeAll(async () => { + const dependencies: Dependencies = getDependencies(); + graph = await buildAppGraph(rootUri, dependencies); + indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source; + brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source; + }, 15000); + + it('links a page to its resolved layout via a single layout edge', () => { + const edge = directRef( + p('app/views/pages/index.liquid'), + frontmatterRange(indexSource), + p('app/views/layouts/theme.liquid'), + 'layout', + ); + expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); + expect(graph.modules[p('app/views/layouts/theme.liquid')]).toEqual( + layoutNode(p('app/views/layouts/theme.liquid'), true, [edge]), + ); + }); + + it('records a missing layout target as an exists:false Layout node', () => { + const edge = directRef( + p('app/views/pages/broken.liquid'), + frontmatterRange(brokenSource), + p('app/views/layouts/ghost.liquid'), + 'layout', + ); + expect(graph.modules[p('app/views/pages/broken.liquid')].dependencies).toEqual([edge]); + expect(graph.modules[p('app/views/layouts/ghost.liquid')]).toEqual( + layoutNode(p('app/views/layouts/ghost.liquid'), false, [edge]), + ); + }); +}); diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index ee7e2001..58513939 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -1,6 +1,7 @@ +import yaml from 'js-yaml'; import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; import { SourceCodeType, UriString, visit, Visitor } from '@platformos/platformos-check-common'; -import { DocumentsLocator } from '@platformos/platformos-common'; +import { containsLiquid, DocumentsLocator } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; import { AugmentedDependencies, @@ -15,7 +16,12 @@ import { Void, } from '../types'; import { assertNever, exists, isString, unique } from '../utils'; -import { getAssetModule, getGraphQLModuleByUri, getPartialModuleByUri } from './module'; +import { + getAssetModule, + getGraphQLModuleByUri, + getLayoutModuleByUri, + getPartialModuleByUri, +} from './module'; /** A resolved outgoing reference: the target graph node + its call-site range + kind. */ interface ResolvedReference { @@ -204,6 +210,35 @@ async function resolveLiquidReferences( kind: 'graphql', }; }, + + // Frontmatter `layout: name` → page/email → its wrapper layout. + // Resolved through DocumentsLocator (`'layout'`: app/views/layouts, module + // prefixes, `.html.liquid`/`.liquid`). Only an EXPLICIT, static, non-empty + // string layout produces an edge: + // - `layout: ''` → explicitly no layout (no edge) + // - layout omitted → no edge (we never synthesize the implicit default) + // - dynamic `{{ ... }}` → no edge (not statically resolvable) + // - non-string value → no edge + // The source range is the whole frontmatter block (tag-level granularity, + // like the other edges). + YAMLFrontmatter: async (node) => { + let data: unknown; + try { + data = yaml.load(node.body); + } catch { + return; // malformed frontmatter — nothing to resolve + } + if (typeof data !== 'object' || data === null) return; + const layout = (data as Record).layout; + if (typeof layout !== 'string' || layout === '' || containsLiquid(layout)) return; + const uri = await documentsLocator.locateOrDefault(rootUri, 'layout', layout); + if (!uri) return; + return { + target: getLayoutModuleByUri(appGraph, uri), + sourceRange: [node.position.start, node.position.end], + kind: 'layout', + }; + }, }; return visit(sourceCode.ast, visitor); From ceae8c0bf167753c970c26cba510d366c468103a Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Mon, 29 Jun 2026 17:07:24 +0200 Subject: [PATCH 02/20] Wired graph resolution into validate_code process and enhance structure adapter - Re-export `getPosition` utility for reuse in sibling modules. - Update `assembleResult` to accept and handle dependencies. - Implement `runStructure` to resolve outgoing dependencies using platformos-graph. - Add integration tests for dependency mapping in structure adapter. - Ensure linting and dependency resolution run concurrently in `validate_code`. --- packages/platformos-check-common/src/index.ts | 6 + .../src/result/assemble.spec.ts | 31 +++- .../src/result/assemble.ts | 5 + .../src/result/types.ts | 28 ++++ .../src/structure/structure.spec.ts | 138 ++++++++++++++++++ .../src/structure/structure.ts | 70 +++++++++ .../src/transport/validate-code.ts | 17 ++- .../guards/architecture-invariants.spec.ts | 2 +- .../test/integration/stdio-smoke.spec.ts | 108 ++++++++++++-- 9 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 packages/platformos-mcp-supervisor/src/structure/structure.spec.ts create mode 100644 packages/platformos-mcp-supervisor/src/structure/structure.ts diff --git a/packages/platformos-check-common/src/index.ts b/packages/platformos-check-common/src/index.ts index 38c4bfb2..4d4ea00b 100644 --- a/packages/platformos-check-common/src/index.ts +++ b/packages/platformos-check-common/src/index.ts @@ -42,6 +42,12 @@ import { import { getPosition } from './utils'; import { visitJSON, visitLiquid } from './visitors'; +// `getPosition` (source + 0-based offset → { line, character }) is the canonical +// offset→position utility used by `check()` itself; re-exported so sibling +// consumers (e.g. the MCP supervisor mapping graph references) reuse it instead +// of re-counting newlines. +export { getPosition } from './utils'; + export * from './AugmentedPlatformOSDocset'; export * from './types/platformos-liquid-docs'; export * from './checks'; diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts index 21f83c98..79a922d6 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import { assembleResult } from './assemble'; -import type { ValidateCodeDiagnostic, ValidateCodeResult } from './types'; +import type { + ValidateCodeDependency, + ValidateCodeDiagnostic, + ValidateCodeResult, +} from './types'; const diag = (over: Partial): ValidateCodeDiagnostic => ({ check: 'SomeCheck', @@ -21,6 +25,7 @@ const EMPTY_ENVELOPE = { proposed_fixes: [], clusters: [], scorecard: [], + dependencies: [], parse_error: null, tips: [], domain_guide: null, @@ -33,7 +38,7 @@ describe('Unit: assembleResult', () => { const warning = diag({ severity: 'warning', check: 'W' }); const info = diag({ severity: 'info', check: 'I' }); - expect(assembleResult([error, warning, info], 'full')).toEqual({ + expect(assembleResult([error, warning, info], [], 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, @@ -47,7 +52,7 @@ describe('Unit: assembleResult', () => { const error = diag({ severity: 'error' }); const warning = diag({ severity: 'warning' }); - expect(assembleResult([error, warning], 'full')).toEqual({ + expect(assembleResult([error, warning], [], 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, @@ -60,7 +65,7 @@ describe('Unit: assembleResult', () => { const warning = diag({ severity: 'warning' }); const info = diag({ severity: 'info' }); - expect(assembleResult([warning, info], 'full')).toEqual({ + expect(assembleResult([warning, info], [], 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'warning', must_fix_before_write: false, @@ -70,7 +75,7 @@ describe('Unit: assembleResult', () => { }); it('derives status = ok with an empty envelope for no diagnostics', () => { - expect(assembleResult([], 'full')).toEqual({ + expect(assembleResult([], [], 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, @@ -80,11 +85,25 @@ describe('Unit: assembleResult', () => { it('derives status = ok for infos only', () => { const info = diag({ severity: 'info' }); - expect(assembleResult([info], 'quick')).toEqual({ + expect(assembleResult([info], [], 'quick')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, infos: [info], }); }); + + it('carries the dependencies through verbatim (status unaffected by deps)', () => { + const dependencies: ValidateCodeDependency[] = [ + { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, + { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 3, column: 1 }, + ]; + + expect(assembleResult([], dependencies, 'full')).toEqual({ + ...EMPTY_ENVELOPE, + status: 'ok', + must_fix_before_write: false, + dependencies, + }); + }); }); diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.ts b/packages/platformos-mcp-supervisor/src/result/assemble.ts index 575e0d0a..5d8f2b38 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.ts @@ -10,6 +10,7 @@ * in later tasks; they are left empty/null here. */ import type { + ValidateCodeDependency, ValidateCodeDiagnostic, ValidateCodeMode, ValidateCodeResult, @@ -18,6 +19,9 @@ import type { export function assembleResult( diagnostics: ValidateCodeDiagnostic[], + // The file's resolved outgoing dependencies (graph-derived, pre-computed by + // the structure adapter). Included verbatim — assembly stays pure. + dependencies: ValidateCodeDependency[], // Reserved: `full`/`quick` do not yet change output (no heavier stages exist). _mode: ValidateCodeMode, ): ValidateCodeResult { @@ -39,6 +43,7 @@ export function assembleResult( proposed_fixes: [], clusters: [], scorecard: [], + dependencies, parse_error: null, tips: [], domain_guide: null, diff --git a/packages/platformos-mcp-supervisor/src/result/types.ts b/packages/platformos-mcp-supervisor/src/result/types.ts index 5181e664..f2f3dde3 100644 --- a/packages/platformos-mcp-supervisor/src/result/types.ts +++ b/packages/platformos-mcp-supervisor/src/result/types.ts @@ -113,6 +113,29 @@ export interface ScorecardNote { reason: string; } +/** + * A resolved outgoing dependency of the validated file — what it + * renders/includes/runs/queries/wraps. Derived from the platformos-graph + * dependency model (`extractFileReferences`); the supervisor maps but never + * re-derives graph logic. Tells the agent the canonical resolved target so + * "what does this call / where does it live" is answerable from the result. + */ +export interface ValidateCodeDependency { + /** + * The Liquid construct that created the edge — one of + * `render | include | function | background | graphql | asset | layout`. + * Typed as `string` (like `ValidateCodeDiagnostic.check`) to keep the agent + * surface decoupled from the upstream `ReferenceKind` union. + */ + kind: string; + /** The resolved dependency target, project-relative (e.g. `app/lib/queries/list.liquid`). */ + target: string; + /** 1-based line of the reference site (the Liquid tag or the frontmatter block). */ + line: number; + /** 1-based column of the reference site. */ + column: number; +} + // ── TASK-8 fields (per-domain layer + result completion) ───────────────────── // Declared here so the result shape is stable; populated by TASK-8.2 / TASK-8.4. @@ -163,6 +186,11 @@ export interface ValidateCodeResult { proposed_fixes: ProposedFix[]; clusters: DiagnosticCluster[]; scorecard: ScorecardNote[]; + /** + * The file's resolved outgoing dependencies (graph-derived). Always present; + * empty for files with no statically-resolvable dependencies. + */ + dependencies: ValidateCodeDependency[]; /** Deterministic prose telling the agent what to do next. */ next_step?: string; /** Parse-failure message when the file could not be parsed at all; null/absent otherwise. */ diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts new file mode 100644 index 00000000..f4130213 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { runStructure } from './structure'; +import type { ValidateCodeDependency } from '../result/types'; + +/** + * Adapter integration: drives the real platformos-graph `extractFileReferences` + * against a temp project. Targets need not exist on disk — `extractFileReferences` + * resolves a missing target to its canonical default path — so the assertions + * pin the project-relative target + kind + 1-based position without fixtures. + */ +describe('Integration: runStructure (structure adapter)', () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-structure-')); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + const run = (filePath: string, content: string) => + runStructure({ projectDir, filePath, content }); + + it('maps a {% render %} edge to a render dependency', async () => { + const deps = await run('app/views/pages/index.liquid', "{% render 'card' %}"); + expect(deps).toEqual([ + { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, + ]); + }); + + it('maps an {% include %} edge to an include dependency', async () => { + const deps = await run('app/views/pages/index.liquid', "{% include 'card' %}"); + expect(deps).toEqual([ + { kind: 'include', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, + ]); + }); + + it('maps a {% function %} edge to a function dependency', async () => { + const deps = await run('app/views/pages/index.liquid', "{% function r = 'queries/list' %}"); + expect(deps).toEqual([ + { kind: 'function', target: 'app/lib/queries/list.liquid', line: 1, column: 1 }, + ]); + }); + + it('maps a {% background %} edge to a background dependency', async () => { + // Background resolves like {% function %} (lib search path), so a target + // that does not exist defaults under app/lib. + const deps = await run('app/views/pages/index.liquid', "{% background j = 'jobs/notify' %}"); + expect(deps).toEqual([ + { kind: 'background', target: 'app/lib/jobs/notify.liquid', line: 1, column: 1 }, + ]); + }); + + it('maps a {% graphql %} edge to a graphql dependency', async () => { + const deps = await run('app/views/pages/index.liquid', "{% graphql r = 'blog/find' %}"); + expect(deps).toEqual([ + { kind: 'graphql', target: 'app/graphql/blog/find.graphql', line: 1, column: 1 }, + ]); + }); + + it('maps an asset filter to an asset dependency', async () => { + // The asset edge's range is the `'app.js' | asset_url` expression (the + // variable output), which starts at column 4 — after `{{ `. + const deps = await run('app/views/layouts/application.liquid', "{{ 'app.js' | asset_url }}"); + expect(deps).toEqual([{ kind: 'asset', target: 'assets/app.js', line: 1, column: 4 }]); + }); + + it('maps a frontmatter `layout:` to a layout dependency', async () => { + const content = `--- +slug: about +layout: theme +--- +

About

`; + const deps = await run('app/views/pages/about.liquid', content); + expect(deps).toEqual([ + { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, + ]); + }); + + it('resolves a module-prefixed target into modules//public/...', async () => { + const deps = await run('app/views/pages/index.liquid', "{% render 'modules/core/card' %}"); + expect(deps).toEqual([ + { + kind: 'render', + target: 'modules/core/public/views/partials/card.liquid', + line: 1, + column: 1, + }, + ]); + }); + + it('reports the 1-based position of a reference that is not on the first line', async () => { + const content = "

hi

\n {% render 'card' %}"; + const deps = await run('app/views/pages/index.liquid', content); + expect(deps).toEqual([ + { kind: 'render', target: 'app/views/partials/card.liquid', line: 2, column: 3 }, + ]); + }); + + it('returns every edge of a multi-dependency file in source order', async () => { + const content = `--- +layout: theme +--- +{% function items = 'queries/list' %} +{% render 'card' %}`; + const deps = await run('app/views/pages/index.liquid', content); + expect(deps).toEqual([ + { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, + { kind: 'function', target: 'app/lib/queries/list.liquid', line: 4, column: 1 }, + { kind: 'render', target: 'app/views/partials/card.liquid', line: 5, column: 1 }, + ]); + }); + + it('returns no dependencies for a file with none', async () => { + expect(await run('app/views/pages/index.liquid', '

{{ page.title }}

')).toEqual([]); + }); + + it('skips dynamic (non-literal) targets', async () => { + expect(await run('app/views/pages/index.liquid', '{% render partial_name %}')).toEqual([]); + }); + + it('returns no dependencies for a non-Liquid (.graphql) file', async () => { + const deps = await run('app/graphql/blog/find.graphql', 'query find { records { id } }'); + expect(deps).toEqual([]); + }); + + it('accepts an absolute file path', async () => { + const abs = join(projectDir, 'app/views/pages/index.liquid'); + expect(await run(abs, "{% render 'card' %}")).toEqual([ + { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, + ]); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.ts b/packages/platformos-mcp-supervisor/src/structure/structure.ts new file mode 100644 index 00000000..6344dc3f --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/structure/structure.ts @@ -0,0 +1,70 @@ +/** + * Structure adapter — an I/O boundary on the request path (sibling to `lint/`). + * + * Resolves the validated buffer's outgoing dependency edges via + * platformos-graph's per-file primitive `extractFileReferences`, then maps the + * resulting `Reference[]` into the agent-facing `ValidateCodeDependency[]`. + * + * The supervisor owns ONLY the mapping. Graph resolution lives in + * platformos-graph; offset→line/col and uri→project-relative math are REUSED + * from platformos-check-common (`getPosition`, `path.relative`). No graph or + * path logic is re-implemented here. + * + * Like `lint/`, this parses the IN-FLIGHT buffer (not disk), so it works for a + * file the agent is about to write that does not exist yet. Only the on-disk + * project is touched (via `fs`) to resolve targets. + */ +import { isAbsolute, join } from 'node:path'; + +import { getPosition, path } from '@platformos/platformos-check-common'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; +import { extractFileReferences, toSourceCode, type Reference } from '@platformos/platformos-graph'; + +import type { ValidateCodeDependency } from '../result/types'; + +export interface RunStructureParams { + /** Absolute project root the buffer is resolved against. */ + projectDir: string; + /** File under edit — absolute, or relative to `projectDir`. */ + filePath: string; + /** In-memory buffer contents. */ + content: string; +} + +/** Resolve the buffer's outgoing dependency edges and map them for the agent. */ +export async function runStructure(params: RunStructureParams): Promise { + const { projectDir, filePath, content } = params; + const absoluteFilePath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + // Normalized file URIs (forward slashes) so the graph's normalized target + // URIs share this exact root prefix — `path.relative` strips it cleanly on + // every platform. + const rootUri = path.normalize(path.URI.file(projectDir)); + const sourceUri = path.normalize(path.URI.file(absoluteFilePath)); + + const sourceCode = await toSourceCode(sourceUri, content); + const references = await extractFileReferences(rootUri, sourceUri, sourceCode, { + fs: NodeFileSystem, + }); + + return references.flatMap((ref) => toDependency(ref, rootUri, content)); +} + +/** + * Map one graph `Reference` to a `ValidateCodeDependency`. `kind` and + * `source.range` are always populated by `extractFileReferences`; the guards + * keep the mapping total and defensive. check-common's `getPosition` yields + * 0-based line/character — the agent surface is 1-based, so both get `+ 1`. + */ +function toDependency(ref: Reference, rootUri: string, content: string): ValidateCodeDependency[] { + if (!ref.kind || !ref.source.range) return []; + const { line, character } = getPosition(content, ref.source.range[0]); + return [ + { + kind: ref.kind, + target: path.relative(ref.target.uri, rootUri), + line: line + 1, + column: character + 1, + }, + ]; +} diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts index 768128c3..02555aa7 100644 --- a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts @@ -10,6 +10,7 @@ import { z, type ZodRawShape } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { runLint } from '../lint/lint'; +import { runStructure } from '../structure/structure'; import { assembleResult } from '../result/assemble'; import type { Logger } from '../logger'; import type { ValidateCodeParams, ValidateCodeResult } from '../result/types'; @@ -72,19 +73,27 @@ export function registerValidateCode(server: McpServer, ctx: SupervisorContext): ); } -/** Lint the buffer via the check-node seam and assemble the result. */ +/** + * Lint the buffer (check-node seam) and resolve its dependency edges + * (platformos-graph seam) — two independent I/O adapters, run concurrently — + * then assemble the result. + */ async function runValidateCode( ctx: SupervisorContext, params: ValidateCodeParams, ): Promise { const mode = params.mode ?? 'full'; ctx.log(`validate_code: ${params.file_path} (${mode})`); - const diagnostics = await runLint({ + const adapterParams = { projectDir: ctx.projectDir, filePath: params.file_path, content: params.content, - }); - return assembleResult(diagnostics, mode); + }; + const [diagnostics, dependencies] = await Promise.all([ + runLint(adapterParams), + runStructure(adapterParams), + ]); + return assembleResult(diagnostics, dependencies, mode); } /** Wrap a result in the MCP text-content envelope (every result is one JSON text block). */ diff --git a/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts b/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts index 88baf010..3c0bec87 100644 --- a/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts +++ b/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts @@ -39,7 +39,7 @@ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const SRC = join(PACKAGE_ROOT, 'src'); /** The whole validate_code request path — none of it may reach for a language server. */ -const LINT_PATH_LAYERS = ['lint', 'enrich', 'advise', 'result', 'transport']; +const LINT_PATH_LAYERS = ['lint', 'structure', 'enrich', 'advise', 'result', 'transport']; /** The layers contractually required to be pure (no I/O). */ const PURE_LAYERS = ['enrich', 'result']; diff --git a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts index a9464fc5..9f65fd35 100644 --- a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts +++ b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts @@ -57,6 +57,17 @@ beforeAll(async () => { 'utf8', ); + // Real dependency targets so the `dependencies` field points at files that + // actually exist in the project (the realistic agent scenario). + const writeProjectFile = (rel: string, body: string) => { + const abs = join(projectDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, body, 'utf8'); + }; + writeProjectFile('app/views/partials/card.liquid', '
{{ title }}
'); + writeProjectFile('app/views/layouts/theme.liquid', '{{ content_for_layout }}'); + writeProjectFile('app/lib/queries/list.liquid', "{% graphql r = 'noop' %}\n{% return r %}"); + transport = new StdioClientTransport({ command: process.execPath, args: [BIN, '--project', projectDir], @@ -92,13 +103,27 @@ describe('Integration: validate_code over stdio', () => { proposed_fixes: [], clusters: [], scorecard: [], + dependencies: [], parse_error: null, tips: [], domain_guide: null, structural: null, }; - it('returns the exact clean result for a valid layout', async () => { + // The exact MissingContentForLayout error for a layout that omits + // `{{ content_for_layout }}` (reported at index 0 → 1-based line/col 1). + const MISSING_CONTENT_FOR_LAYOUT = { + check: 'MissingContentForLayout', + severity: 'error', + message: + "Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)", + line: 1, + column: 1, + end_line: 1, + end_column: 1, + }; + + it('returns the exact clean result for a valid layout (no dependencies)', async () => { const result = await validateCode({ file_path: 'app/views/layouts/application.liquid', content: '{{ content_for_layout }}', @@ -121,18 +146,77 @@ describe('Integration: validate_code over stdio', () => { ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, - errors: [ - { - check: 'MissingContentForLayout', - severity: 'error', - message: - "Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)", - line: 1, - column: 1, - end_line: 1, - end_column: 1, - }, + errors: [MISSING_CONTENT_FOR_LAYOUT], + }); + }); + + it('reports the exact resolved dependency for a page that renders a partial', async () => { + const result = await validateCode({ + file_path: 'app/views/pages/index.liquid', + content: "{% render 'card' %}", + }); + + expect(result).toEqual({ + ...EMPTY_ENVELOPE, + status: 'ok', + must_fix_before_write: false, + dependencies: [ + { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, ], }); }); + + it('reports every dependency (layout + function + render) in source order', async () => { + const result = await validateCode({ + file_path: 'app/views/pages/index.liquid', + content: `--- +layout: theme +--- +{% function items = 'queries/list' %} +{% render 'card' %}`, + }); + + expect(result).toEqual({ + ...EMPTY_ENVELOPE, + status: 'ok', + must_fix_before_write: false, + dependencies: [ + { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, + { kind: 'function', target: 'app/lib/queries/list.liquid', line: 4, column: 1 }, + { kind: 'render', target: 'app/views/partials/card.liquid', line: 5, column: 1 }, + ], + }); + }); + + it('surfaces lint errors AND dependencies together without conflating them', async () => { + // A layout that both omits content_for_layout (lint error) and renders a + // partial (dependency) — the agent must see both, correctly separated. + const result = await validateCode({ + file_path: 'app/views/layouts/application.liquid', + content: "{% render 'card' %}", + }); + + expect(result).toEqual({ + ...EMPTY_ENVELOPE, + status: 'error', + must_fix_before_write: true, + errors: [MISSING_CONTENT_FOR_LAYOUT], + dependencies: [ + { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 7 }, + ], + }); + }); + + it('does not invent dependencies for dynamic (non-literal) targets', async () => { + const result = await validateCode({ + file_path: 'app/views/pages/index.liquid', + content: '{% assign name = "card" %}{% render name %}', + }); + + expect(result).toEqual({ + ...EMPTY_ENVELOPE, + status: 'ok', + must_fix_before_write: false, + }); + }); }); From 322068e7ff65582897b22421532b3daaf1aaa982 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Mon, 29 Jun 2026 17:07:42 +0200 Subject: [PATCH 03/20] Refactor import statements and format writeProjectFile calls for consistency --- .../platformos-mcp-supervisor/src/result/assemble.spec.ts | 6 +----- .../test/integration/stdio-smoke.spec.ts | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts index 79a922d6..f22554fd 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; import { assembleResult } from './assemble'; -import type { - ValidateCodeDependency, - ValidateCodeDiagnostic, - ValidateCodeResult, -} from './types'; +import type { ValidateCodeDependency, ValidateCodeDiagnostic, ValidateCodeResult } from './types'; const diag = (over: Partial): ValidateCodeDiagnostic => ({ check: 'SomeCheck', diff --git a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts index 9f65fd35..9732c59f 100644 --- a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts +++ b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts @@ -65,7 +65,10 @@ beforeAll(async () => { writeFileSync(abs, body, 'utf8'); }; writeProjectFile('app/views/partials/card.liquid', '
{{ title }}
'); - writeProjectFile('app/views/layouts/theme.liquid', '{{ content_for_layout }}'); + writeProjectFile( + 'app/views/layouts/theme.liquid', + '{{ content_for_layout }}', + ); writeProjectFile('app/lib/queries/list.liquid', "{% graphql r = 'noop' %}\n{% return r %}"); transport = new StdioClientTransport({ From 8ededb42adfede5c08de2d48280bb004d0d1b729 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 30 Jun 2026 10:54:28 +0200 Subject: [PATCH 04/20] Add query functions for project structure analysis and enhance reference handling --- packages/platformos-check-common/src/types.ts | 10 ++ .../src/graph/extract.spec.ts | 18 ++ .../platformos-graph/src/graph/query.spec.ts | 167 ++++++++++++++++++ packages/platformos-graph/src/graph/query.ts | 100 +++++++++++ .../src/graph/traverse-edges.spec.ts | 4 + .../platformos-graph/src/graph/traverse.ts | 35 +++- packages/platformos-graph/src/index.ts | 11 ++ 7 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 packages/platformos-graph/src/graph/query.spec.ts create mode 100644 packages/platformos-graph/src/graph/query.ts diff --git a/packages/platformos-check-common/src/types.ts b/packages/platformos-check-common/src/types.ts index 955d6df3..f7bca744 100644 --- a/packages/platformos-check-common/src/types.ts +++ b/packages/platformos-check-common/src/types.ts @@ -341,6 +341,16 @@ export type Reference = { /** Which Liquid construct produced this edge. Optional for backwards compatibility. */ kind?: ReferenceKind; + + /** + * The names of the named arguments passed at the call site, in source order — + * e.g. `['title', 'count']` for `{% render 'card', title: x, count: 3 %}`. + * Present only for edges that carry named arguments (render/include/function/ + * background/graphql); omitted entirely when there are none. Values are not + * captured (names are what cross-checking against a partial's `@param` + * signature needs). + */ + args?: string[]; }; export type Range = [start: number, end: number]; // represents a range in the source code diff --git a/packages/platformos-graph/src/graph/extract.spec.ts b/packages/platformos-graph/src/graph/extract.spec.ts index bfcaa3f1..d4a6b113 100644 --- a/packages/platformos-graph/src/graph/extract.spec.ts +++ b/packages/platformos-graph/src/graph/extract.spec.ts @@ -26,12 +26,14 @@ function directRef( sourceRange: [number, number], targetUri: string, kind: Reference['kind'], + args?: string[], ): Reference { return { source: { uri: sourceUri, range: sourceRange }, target: { uri: targetUri }, type: 'direct', kind, + ...(args ? { args } : {}), }; } @@ -74,6 +76,21 @@ describe('extractFileReferences: resolves a single buffer against the project', ), ]); }); + + it('captures the named-argument names at a render call site', async () => { + const sourceUri = p('app/views/pages/draft.liquid'); + const content = "{% render 'card', title: page.title, count: 3 %}"; + + expect(await extract(rootUri, sourceUri, content)).toEqual([ + directRef( + sourceUri, + rangeOf(content, "{% render 'card', title: page.title, count: 3 %}"), + p('app/views/partials/card.liquid'), + 'render', + ['title', 'count'], + ), + ]); + }); }); describe('extractFileReferences: kinds and resolution match the full graph build', () => { @@ -107,6 +124,7 @@ describe('extractFileReferences: kinds and resolution match the full graph build rangeOf(content, "graphql posts = 'blog_posts/find', id: '1'"), p('app/graphql/blog_posts/find.graphql'), 'graphql', + ['id'], ), ]); }); diff --git a/packages/platformos-graph/src/graph/query.spec.ts b/packages/platformos-graph/src/graph/query.spec.ts new file mode 100644 index 00000000..3432b54f --- /dev/null +++ b/packages/platformos-graph/src/graph/query.spec.ts @@ -0,0 +1,167 @@ +import { path as pathUtils } from '@platformos/platformos-check-common'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { buildAppGraph } from '../index'; +import { AppGraph } from '../types'; +import { + dependenciesOf, + dependentsOf, + exists, + isEntryPoint, + isOrphan, + missingDependencies, + missingTargets, + orphans, + reachableFrom, +} from './query'; +import { getPageModule, getPartialModule } from './module'; +import { bind } from './traverse'; +import { getDependencies, skeleton } from './test-helpers'; + +const targets = (refs: { target: { uri: string } }[]) => + refs.map((r) => r.target.uri).sort((a, b) => a.localeCompare(b)); +const sources = (refs: { source: { uri: string } }[]) => + refs.map((r) => r.source.uri).sort((a, b) => a.localeCompare(b)); + +describe('Graph queries: over the built skeleton app graph', () => { + const p = (part: string) => pathUtils.join(skeleton, ...part.split('/')); + let graph: AppGraph; + + beforeAll(async () => { + graph = await buildAppGraph(skeleton, getDependencies()); + }, 15000); + + it('dependenciesOf returns a file’s outgoing targets', () => { + expect(targets(dependenciesOf(graph, p('app/views/pages/index.liquid')))).toEqual([ + p('app/views/layouts/application.liquid'), + p('app/views/partials/parent.liquid'), + ]); + }); + + it('dependency edges are call sites carrying kind + named-argument names', () => { + // index.liquid: `layout: application` (no args) + `{% render "parent", children: "hello" %}`. + const edges = dependenciesOf(graph, p('app/views/pages/index.liquid')) + .map((ref) => ({ kind: ref.kind, target: ref.target.uri, args: ref.args })) + .sort((a, b) => a.target.localeCompare(b.target)); + + expect(edges).toEqual([ + { kind: 'layout', target: p('app/views/layouts/application.liquid'), args: undefined }, + { kind: 'render', target: p('app/views/partials/parent.liquid'), args: ['children'] }, + ]); + }); + + it('dependentsOf returns every caller of a file', () => { + expect(sources(dependentsOf(graph, p('app/views/partials/child.liquid')))).toEqual([ + p('app/views/partials/header.liquid'), + p('app/views/partials/parent.liquid'), + ]); + expect(sources(dependentsOf(graph, p('app/views/layouts/application.liquid')))).toEqual([ + p('app/views/pages/index.liquid'), + ]); + }); + + it('dependenciesOf / dependentsOf return [] for a URI absent from the graph', () => { + expect(dependenciesOf(graph, p('app/views/partials/nope.liquid'))).toEqual([]); + expect(dependentsOf(graph, p('app/views/partials/nope.liquid'))).toEqual([]); + }); + + it('reachableFrom returns the transitive outgoing closure', () => { + expect(reachableFrom(graph, p('app/views/pages/index.liquid'))).toEqual([ + p('app/views/layouts/application.liquid'), + p('app/views/partials/child.liquid'), + p('app/views/partials/header.liquid'), + p('app/views/partials/parent.liquid'), + p('assets/app.css'), + p('assets/app.js'), + ]); + }); + + it('exists reflects on-disk presence', () => { + expect(exists(graph, p('app/views/partials/child.liquid'))).toBe(true); + expect(exists(graph, p('app/views/partials/nope.liquid'))).toBe(false); + }); + + it('isEntryPoint distinguishes pages/layouts from partials', () => { + expect(isEntryPoint(graph, p('app/views/pages/index.liquid'))).toBe(true); + expect(isEntryPoint(graph, p('app/views/layouts/application.liquid'))).toBe(true); + expect(isEntryPoint(graph, p('app/views/partials/child.liquid'))).toBe(false); + }); + + it('a fully-wired skeleton has no orphans and no missing targets', () => { + expect(orphans(graph)).toEqual([]); + expect(missingTargets(graph)).toEqual([]); + }); +}); + +describe('Graph queries: orphan and missing-target detection (hermetic graph)', () => { + const rootUri = 'file:///app'; + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + + // page (entry) ─render→ used (exists) + // └─render→ missing (exists:false) + // orphan (exists, referenced by nothing, not an entry point) + let graph: AppGraph; + let pageUri: string; + let usedUri: string; + let orphanUri: string; + let missingUri: string; + + beforeAll(() => { + graph = { rootUri, entryPoints: [], modules: {} }; + const page = getPageModule(graph, p('app/views/pages/index.liquid')); + const used = getPartialModule(graph, 'used'); + const orphan = getPartialModule(graph, 'orphan'); + const missing = getPartialModule(graph, 'missing'); + + page.exists = true; + used.exists = true; + orphan.exists = true; + missing.exists = false; + + bind(page, used, { sourceRange: [0, 10], kind: 'render' }); + bind(page, missing, { sourceRange: [11, 21], kind: 'render' }); + + graph.entryPoints = [page]; + for (const module of [page, used, orphan, missing]) graph.modules[module.uri] = module; + + pageUri = page.uri; + usedUri = used.uri; + orphanUri = orphan.uri; + missingUri = missing.uri; + }); + + it('flags an unreferenced, non-entry-point file as an orphan', () => { + expect(isOrphan(graph, orphanUri)).toBe(true); + }); + + it('does not flag referenced files, entry points, or missing targets as orphans', () => { + expect(isOrphan(graph, usedUri)).toBe(false); // referenced by the page + expect(isOrphan(graph, pageUri)).toBe(false); // entry point + expect(isOrphan(graph, missingUri)).toBe(false); // missing, not orphan + }); + + it('orphans() lists exactly the orphan modules', () => { + expect(orphans(graph).map((m) => m.uri)).toEqual([orphanUri]); + }); + + it('missingDependencies returns a file’s edges to non-existent targets', () => { + expect(missingDependencies(graph, pageUri)).toEqual([ + { + source: { uri: pageUri, range: [11, 21] }, + target: { uri: missingUri }, + type: 'direct', + kind: 'render', + }, + ]); + }); + + it('missingTargets lists every unresolved edge in the graph', () => { + expect(missingTargets(graph)).toEqual([ + { + source: { uri: pageUri, range: [11, 21] }, + target: { uri: missingUri }, + type: 'direct', + kind: 'render', + }, + ]); + }); +}); diff --git a/packages/platformos-graph/src/graph/query.ts b/packages/platformos-graph/src/graph/query.ts new file mode 100644 index 00000000..4f791872 --- /dev/null +++ b/packages/platformos-graph/src/graph/query.ts @@ -0,0 +1,100 @@ +import { UriString } from '@platformos/platformos-check-common'; +import { AppGraph, AppModule, Reference } from '../types'; + +/** + * Project-structure queries over a BUILT {@link AppGraph}. + * + * Every function here is PURE and synchronous: it only reads the in-memory + * graph produced by `buildAppGraph`. All I/O (disk traversal, parsing, + * resolution) stays in `buildAppGraph`; this layer never touches the filesystem. + * + * These resurrect the old in-supervisor `ProjectFactGraph` / `ProjectMap` + * capabilities as the graph package's own API, so consumers (the MCP supervisor, + * the LSP) read project facts without re-deriving any graph logic. + * + * Note on graph scope: `buildAppGraph` only materializes modules reachable from + * its entry points. Whole-project queries like {@link orphans} are therefore + * only as complete as the graph they are given — to detect unreferenced files, + * build the graph with every file as an entry point. + */ + +/** The outgoing dependency edges of `uri` (what it renders/includes/runs/queries/wraps). */ +export function dependenciesOf(graph: AppGraph, uri: UriString): Reference[] { + return graph.modules[uri]?.dependencies ?? []; +} + +/** The incoming reference edges to `uri` (who renders/includes/runs/queries/wraps it). */ +export function dependentsOf(graph: AppGraph, uri: UriString): Reference[] { + return graph.modules[uri]?.references ?? []; +} + +/** + * Whether `uri` is a module in the graph that resolves to a file on disk. + * Returns `false` for a URI absent from the graph or a known-missing target. + */ +export function exists(graph: AppGraph, uri: UriString): boolean { + return graph.modules[uri]?.exists ?? false; +} + +/** Whether `uri` is one of the graph's entry points (a page or layout root). */ +export function isEntryPoint(graph: AppGraph, uri: UriString): boolean { + return graph.entryPoints.some((entry) => entry.uri === uri); +} + +/** + * Whether `uri` is an orphan: an existing, non-entry-point module that nothing + * references. Entry points (pages, layouts) are roots reachable independently of + * render edges, so they are never orphans; known-missing targets are "missing" + * (see {@link missingDependencies}), not orphans. + */ +export function isOrphan(graph: AppGraph, uri: UriString): boolean { + const module = graph.modules[uri]; + if (!module || module.exists === false) return false; + if (isEntryPoint(graph, uri)) return false; + return module.references.length === 0; +} + +/** Every orphan module in the graph (see {@link isOrphan}), sorted by URI. */ +export function orphans(graph: AppGraph): AppModule[] { + return Object.values(graph.modules) + .filter((module) => isOrphan(graph, module.uri)) + .sort((a, b) => a.uri.localeCompare(b.uri)); +} + +/** + * Every module URI transitively reachable from `uri` by following outgoing + * dependency edges, sorted. Excludes `uri` itself unless a cycle leads back to + * it. Modules absent from the graph contribute nothing. + */ +export function reachableFrom(graph: AppGraph, uri: UriString): UriString[] { + const seen = new Set(); + const queue: UriString[] = dependenciesOf(graph, uri).map((dep) => dep.target.uri); + + while (queue.length > 0) { + const next = queue.shift()!; + if (seen.has(next)) continue; + seen.add(next); + for (const dep of dependenciesOf(graph, next)) queue.push(dep.target.uri); + } + + return [...seen].sort((a, b) => a.localeCompare(b)); +} + +/** The outgoing edges of `uri` whose target does not exist on disk. */ +export function missingDependencies(graph: AppGraph, uri: UriString): Reference[] { + return dependenciesOf(graph, uri).filter((ref) => !exists(graph, ref.target.uri)); +} + +/** + * Every edge in the graph that points at a non-existent target — the project's + * unresolved references. Sorted by (source URI, target URI) for stable output. + */ +export function missingTargets(graph: AppGraph): Reference[] { + return Object.values(graph.modules) + .flatMap((module) => module.dependencies) + .filter((ref) => !exists(graph, ref.target.uri)) + .sort( + (a, b) => + a.source.uri.localeCompare(b.source.uri) || a.target.uri.localeCompare(b.target.uri), + ); +} diff --git a/packages/platformos-graph/src/graph/traverse-edges.spec.ts b/packages/platformos-graph/src/graph/traverse-edges.spec.ts index c2adf003..f2d3678f 100644 --- a/packages/platformos-graph/src/graph/traverse-edges.spec.ts +++ b/packages/platformos-graph/src/graph/traverse-edges.spec.ts @@ -29,12 +29,14 @@ function directRef( sourceRange: [number, number], targetUri: string, kind: Reference['kind'], + args?: string[], ): Reference { return { source: { uri: sourceUri, range: sourceRange }, target: { uri: targetUri }, type: 'direct', kind, + ...(args ? { args } : {}), }; } @@ -152,6 +154,7 @@ describe('Graph traversal: {% graphql %} edges', () => { rangeOf(indexSource, "graphql posts = 'blog_posts/find', id: '1'"), p('app/graphql/blog_posts/find.graphql'), 'graphql', + ['id'], ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); expect(graph.modules[p('app/graphql/blog_posts/find.graphql')]).toEqual( @@ -218,6 +221,7 @@ describe('Graph traversal: {% background %} edges', () => { rangeOf(indexSource, "background job_id = 'jobs/notify', data: 'x'"), p('app/views/partials/jobs/notify.liquid'), 'background', + ['data'], ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); expect(graph.modules[p('app/views/partials/jobs/notify.liquid')]).toEqual( diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index 58513939..4e0a99af 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -1,5 +1,5 @@ import yaml from 'js-yaml'; -import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; +import { LiquidNamedArgument, NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; import { SourceCodeType, UriString, visit, Visitor } from '@platformos/platformos-check-common'; import { containsLiquid, DocumentsLocator } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; @@ -23,11 +23,13 @@ import { getPartialModuleByUri, } from './module'; -/** A resolved outgoing reference: the target graph node + its call-site range + kind. */ +/** A resolved outgoing reference: the target graph node + its call-site range + kind (+ named-arg names). */ interface ResolvedReference { target: AppModule; sourceRange: Range; kind: ReferenceKind; + /** Names of the named arguments at the call site, in source order; omitted when none. */ + args?: string[]; } /** The dependency surface the reference resolver needs: just a filesystem (for DocumentsLocator). */ @@ -86,6 +88,7 @@ async function traverseLiquidModule( bind(module, reference.target, { sourceRange: reference.sourceRange, kind: reference.kind, + args: reference.args, }); } @@ -161,6 +164,7 @@ async function resolveLiquidReferences( target: getPartialModuleByUri(appGraph, uri), sourceRange: [tag.position.start, tag.position.end], kind: isInclude ? 'include' : 'render', + args: argNames(node.args), }; }, @@ -175,6 +179,7 @@ async function resolveLiquidReferences( target: getPartialModuleByUri(appGraph, uri), sourceRange: [tag.position.start, tag.position.end], kind: 'function', + args: argNames(node.args), }; }, @@ -193,6 +198,7 @@ async function resolveLiquidReferences( target: getPartialModuleByUri(appGraph, uri), sourceRange: [tag.position.start, tag.position.end], kind: 'background', + args: argNames(node.args), }; }, @@ -208,6 +214,7 @@ async function resolveLiquidReferences( target: getGraphQLModuleByUri(appGraph, uri), sourceRange: [tag.position.start, tag.position.end], kind: 'graphql', + args: argNames(node.args), }; }, @@ -280,8 +287,10 @@ export async function extractFileReferences( return references.map((reference) => ({ source: { uri: sourceUri, range: reference.sourceRange }, target: { uri: reference.target.uri }, - type: 'direct', + type: 'direct' as const, kind: reference.kind, + // Only carry `args` when the call site has named arguments (parity with bind). + ...(reference.args && reference.args.length > 0 ? { args: reference.args } : {}), })); } @@ -297,6 +306,21 @@ function isStringLiteral( return !isString(node) && node.type === NodeTypes.String; } +/** + * The names of a call site's named arguments, in source order, or `undefined` + * when there are none. Defensive against the parser's documented + * completion-context case (a trailing incomplete argument may not be a + * fully-typed `NamedArgument`): only `NamedArgument`s with a string name + * contribute. Values are intentionally not captured — names are what + * cross-checking against a partial's `@param` signature needs. + */ +function argNames(args: LiquidNamedArgument[]): string[] | undefined { + const names = args + .filter((arg) => arg.type === NodeTypes.NamedArgument && typeof arg.name === 'string') + .map((arg) => arg.name); + return names.length > 0 ? names : undefined; +} + /** * The bind method is the method that links two modules together. * @@ -311,10 +335,12 @@ export function bind( sourceRange, type = 'direct', // the type of dependency, can be 'direct' or 'indirect' kind, // the semantic Liquid construct that created the edge + args, // names of the named arguments at the call site (omitted when none) }: { sourceRange?: Range; // a range in the source module that references the child type?: Reference['type']; // the type of dependency kind?: ReferenceKind; // render | include | function | background | graphql | asset | layout + args?: string[]; // named-argument names at the call site } = {}, ): void { const dependency: Reference = { @@ -322,6 +348,9 @@ export function bind( target: { uri: target.uri }, type: type, kind: kind, + // Only carry `args` when the call site has named arguments, so argument-less + // edges stay free of an empty field. + ...(args && args.length > 0 ? { args } : {}), }; source.dependencies.push(dependency); diff --git a/packages/platformos-graph/src/index.ts b/packages/platformos-graph/src/index.ts index 63d62ed8..01828842 100644 --- a/packages/platformos-graph/src/index.ts +++ b/packages/platformos-graph/src/index.ts @@ -1,5 +1,16 @@ export { buildAppGraph } from './graph/build'; export { extractFileReferences } from './graph/traverse'; +export { + dependenciesOf, + dependentsOf, + exists, + isEntryPoint, + isOrphan, + orphans, + reachableFrom, + missingDependencies, + missingTargets, +} from './graph/query'; export { serializeAppGraph } from './graph/serialize'; export { parseJs, toSourceCode } from './toSourceCode'; export * from './types'; From 0d3dd4efffed6f581c6043c740562a1ed9a89e0d Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 30 Jun 2026 12:07:48 +0200 Subject: [PATCH 05/20] Add nearestModules function and levenshtein utility for improved module suggestions --- packages/platformos-check-common/src/index.ts | 5 ++ .../platformos-graph/src/graph/query.spec.ts | 77 ++++++++++++++++++- packages/platformos-graph/src/graph/query.ts | 52 ++++++++++++- packages/platformos-graph/src/index.ts | 2 + 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/packages/platformos-check-common/src/index.ts b/packages/platformos-check-common/src/index.ts index 4d4ea00b..cfba35b8 100644 --- a/packages/platformos-check-common/src/index.ts +++ b/packages/platformos-check-common/src/index.ts @@ -48,6 +48,11 @@ import { visitJSON, visitLiquid } from './visitors'; // of re-counting newlines. export { getPosition } from './utils'; +// `levenshtein` (edit distance) is re-exported so sibling consumers (e.g. the +// graph's nearest-name "did you mean" candidates) reuse it rather than +// re-implementing string-distance. +export { levenshtein } from './utils'; + export * from './AugmentedPlatformOSDocset'; export * from './types/platformos-liquid-docs'; export * from './checks'; diff --git a/packages/platformos-graph/src/graph/query.spec.ts b/packages/platformos-graph/src/graph/query.spec.ts index 3432b54f..0c4042da 100644 --- a/packages/platformos-graph/src/graph/query.spec.ts +++ b/packages/platformos-graph/src/graph/query.spec.ts @@ -10,10 +10,11 @@ import { isOrphan, missingDependencies, missingTargets, + nearestModules, orphans, reachableFrom, } from './query'; -import { getPageModule, getPartialModule } from './module'; +import { getGraphQLModuleByUri, getLayoutModule, getPageModule, getPartialModule } from './module'; import { bind } from './traverse'; import { getDependencies, skeleton } from './test-helpers'; @@ -165,3 +166,77 @@ describe('Graph queries: orphan and missing-target detection (hermetic graph)', ]); }); }); + +describe('Graph queries: nearest-name candidates (did-you-mean)', () => { + const rootUri = 'file:///app'; + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + + // Partials: header, footer, sidebar (exist). A layout ALSO named "header" + // (exists) — same name, different category. A graphql op blog/find (exists). + // Typo targets: partial "headr" and graphql "blog/fnd" (both exists:false). + let graph: AppGraph; + let headerUri: string; + let footerUri: string; + let sidebarUri: string; + let headrUri: string; + let graphqlFindUri: string; + let graphqlTypoUri: string; + + beforeAll(() => { + graph = { rootUri, entryPoints: [], modules: {} }; + const page = getPageModule(graph, p('app/views/pages/index.liquid')); + const header = getPartialModule(graph, 'header'); + const footer = getPartialModule(graph, 'footer'); + const sidebar = getPartialModule(graph, 'sidebar'); + const headerLayout = getLayoutModule(graph, p('app/views/layouts/header.liquid'))!; + const find = getGraphQLModuleByUri(graph, p('app/graphql/blog/find.graphql')); + const headr = getPartialModule(graph, 'headr'); + const graphqlTypo = getGraphQLModuleByUri(graph, p('app/graphql/blog/fnd.graphql')); + + for (const m of [page, header, footer, sidebar, headerLayout, find]) m.exists = true; + headr.exists = false; + graphqlTypo.exists = false; + + graph.entryPoints = [page]; + for (const m of [page, header, footer, sidebar, headerLayout, find, headr, graphqlTypo]) { + graph.modules[m.uri] = m; + } + + headerUri = header.uri; + footerUri = footer.uri; + sidebarUri = sidebar.uri; + headrUri = headr.uri; + graphqlFindUri = find.uri; + graphqlTypoUri = graphqlTypo.uri; + }); + + it('ranks the closest same-category name first', () => { + expect(nearestModules(graph, headrUri, { limit: 1 }).map((m) => m.uri)).toEqual([headerUri]); + }); + + it('only considers existing modules of the same category (excludes self, layouts, graphql, missing)', () => { + // The layout "header" shares the name but is a different kind; the graphql + // op, the page, the typo itself, and missing modules are all out. + const uris = nearestModules(graph, headrUri, { limit: 10 }) + .map((m) => m.uri) + .sort((a, b) => a.localeCompare(b)); + expect(uris).toEqual([footerUri, headerUri, sidebarUri]); + }); + + it('honours maxDistance', () => { + // Only "header" is within edit distance 1 of "headr". + expect(nearestModules(graph, headrUri, { maxDistance: 1 }).map((m) => m.uri)).toEqual([ + headerUri, + ]); + }); + + it('suggests graphql operations for a missing graphql target', () => { + expect(nearestModules(graph, graphqlTypoUri, { limit: 1 }).map((m) => m.uri)).toEqual([ + graphqlFindUri, + ]); + }); + + it('returns [] for a URI absent from the graph', () => { + expect(nearestModules(graph, p('app/views/partials/unknown.liquid'))).toEqual([]); + }); +}); diff --git a/packages/platformos-graph/src/graph/query.ts b/packages/platformos-graph/src/graph/query.ts index 4f791872..7d8a7ab3 100644 --- a/packages/platformos-graph/src/graph/query.ts +++ b/packages/platformos-graph/src/graph/query.ts @@ -1,4 +1,4 @@ -import { UriString } from '@platformos/platformos-check-common'; +import { levenshtein, path, UriString } from '@platformos/platformos-check-common'; import { AppGraph, AppModule, Reference } from '../types'; /** @@ -98,3 +98,53 @@ export function missingTargets(graph: AppGraph): Reference[] { a.source.uri.localeCompare(b.source.uri) || a.target.uri.localeCompare(b.target.uri), ); } + +export interface NearestModulesOptions { + /** Maximum number of candidates to return, closest first. Default 3. */ + limit?: number; + /** Maximum edit distance to include. Default: no cap (consumer decides relevance). */ + maxDistance?: number; +} + +/** + * The existing modules whose names are closest to `uri` — the "did you mean?" + * candidate set for a typo'd or missing reference. + * + * Candidates are restricted to the SAME category as `uri` (same module `type`, + * and for Liquid the same `kind`), so a missing `{% render %}` only suggests + * partials, a missing `{% graphql %}` only graphql operations, etc. Ranked by + * Levenshtein distance over the project-relative path (reusing check-common's + * `levenshtein` + `path.relative` — never re-implemented), closest first, ties + * broken by URI. `uri` itself and known-missing modules are excluded. + * + * Pure over the graph: candidates come only from modules present in it, so build + * with every file as an entry point for a complete candidate pool (see header). + */ +export function nearestModules( + graph: AppGraph, + uri: UriString, + options: NearestModulesOptions = {}, +): AppModule[] { + const { limit = 3, maxDistance = Infinity } = options; + const target = graph.modules[uri]; + if (!target) return []; + + const targetPath = path.relative(uri, graph.rootUri); + + return Object.values(graph.modules) + .filter( + (module) => + module.uri !== uri && + module.exists !== false && + module.type === target.type && + module.kind === target.kind, + ) + .map((module) => ({ + module, + distance: levenshtein(path.relative(module.uri, graph.rootUri), targetPath), + })) + .filter((candidate) => candidate.distance <= maxDistance) + .sort((a, b) => a.distance - b.distance || a.module.uri.localeCompare(b.module.uri)) + .slice(0, limit) + .map((candidate) => candidate.module); +} diff --git a/packages/platformos-graph/src/index.ts b/packages/platformos-graph/src/index.ts index 01828842..7b9a35ca 100644 --- a/packages/platformos-graph/src/index.ts +++ b/packages/platformos-graph/src/index.ts @@ -10,7 +10,9 @@ export { reachableFrom, missingDependencies, missingTargets, + nearestModules, } from './graph/query'; +export type { NearestModulesOptions } from './graph/query'; export { serializeAppGraph } from './graph/serialize'; export { parseJs, toSourceCode } from './toSourceCode'; export * from './types'; From 60233a8878ea2184ae0b3dd6a3d67851df29483d Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 30 Jun 2026 16:54:31 +0200 Subject: [PATCH 06/20] feat(graphql): add extractGraphqlTable function to parse table names from GraphQL queries - Implemented extractGraphqlTable function to extract platformOS model table names from GraphQL operations. - Added unit tests for extractGraphqlTable covering various scenarios including shorthand and nested table filters. - Introduced GraphQL table extraction in the build process for schema modules. - Enhanced AppGraph and AppModule types to include schema and table properties. - Created fixtures for testing GraphQL queries with and without table filters. - Updated traversal logic to handle schema nodes and their associated table names. --- SUPERVISOR-GRAPH-INTEGRATION.md | 371 ++++++++++++++++++ .../README.md | 40 +- .../README.md | 119 ++++++ .../src/graphql-table.spec.ts | 87 ++++ .../src/graphql-table.ts | 51 +++ packages/platformos-check-common/src/index.ts | 5 + .../app/graphql/with_table.graphql | 5 + .../app/graphql/without_table.graphql | 3 + .../app/views/pages/index.liquid | 4 + .../schema-nodes/app/schema/blog_post.yml | 6 + .../schema-nodes/app/schema/no_name.yml | 3 + .../schema-nodes/app/views/pages/index.liquid | 1 + .../structural/app/views/pages/about.liquid | 1 + .../app/views/pages/blog/show.liquid | 4 + .../structural/app/views/pages/index.liquid | 6 + .../structural/app/views/pages/rich.liquid | 8 + .../structural/app/views/partials/card.liquid | 1 + .../app/views/partials/documented.liquid | 5 + packages/platformos-graph/src/graph/build.ts | 25 +- packages/platformos-graph/src/graph/module.ts | 17 + .../platformos-graph/src/graph/query.spec.ts | 23 +- packages/platformos-graph/src/graph/query.ts | 6 +- .../src/graph/structural.spec.ts | 89 +++++ .../src/graph/traverse-edges.spec.ts | 101 ++++- .../platformos-graph/src/graph/traverse.ts | 186 ++++++++- packages/platformos-graph/src/types.ts | 70 +++- 26 files changed, 1197 insertions(+), 40 deletions(-) create mode 100644 SUPERVISOR-GRAPH-INTEGRATION.md create mode 100644 docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md create mode 100644 packages/platformos-check-common/src/graphql-table.spec.ts create mode 100644 packages/platformos-check-common/src/graphql-table.ts create mode 100644 packages/platformos-graph/fixtures/graphql-table/app/graphql/with_table.graphql create mode 100644 packages/platformos-graph/fixtures/graphql-table/app/graphql/without_table.graphql create mode 100644 packages/platformos-graph/fixtures/graphql-table/app/views/pages/index.liquid create mode 100644 packages/platformos-graph/fixtures/schema-nodes/app/schema/blog_post.yml create mode 100644 packages/platformos-graph/fixtures/schema-nodes/app/schema/no_name.yml create mode 100644 packages/platformos-graph/fixtures/schema-nodes/app/views/pages/index.liquid create mode 100644 packages/platformos-graph/fixtures/structural/app/views/pages/about.liquid create mode 100644 packages/platformos-graph/fixtures/structural/app/views/pages/blog/show.liquid create mode 100644 packages/platformos-graph/fixtures/structural/app/views/pages/index.liquid create mode 100644 packages/platformos-graph/fixtures/structural/app/views/pages/rich.liquid create mode 100644 packages/platformos-graph/fixtures/structural/app/views/partials/card.liquid create mode 100644 packages/platformos-graph/fixtures/structural/app/views/partials/documented.liquid create mode 100644 packages/platformos-graph/src/graph/structural.spec.ts diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md new file mode 100644 index 00000000..48010597 --- /dev/null +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -0,0 +1,371 @@ +# Supervisor ⇄ Graph Integration + +Branch: `supervisor-graph-integration` + +This document is the PR-style summary **and** the architectural reference for the +work that makes `platformos-graph` the project structural/dependency model and +wires it into the MCP supervisor's `validate_code`. It covers what shipped, the +decisions (with ADR links), the full graph feature surface, worked output +examples, and the open doubts. + +--- + +## 1. PR summary + +### What & why +The rebuilt `platformos-mcp-supervisor` must tell coding agents not just *what is +wrong* in a file but *how the file sits in the project* — what it renders/calls, +what it wraps, where targets resolve, and the file's own structure. The +architectural rule (ADR 003): **all graph/structure logic lives in +`platformos-graph`; the supervisor is a pure consumer.** This branch extends the +graph to provide that model and consumes it from `validate_code`. + +### Highlights +- **Dependency edges** for every static Liquid construct — `render`, `include`, + `function`, `background`, `graphql`, asset filters, and frontmatter `layout` + — each carrying a semantic `kind`, call-site range, and named-arg names. +- **Per-file primitive** `extractFileReferences` — resolves one in-flight buffer's + outgoing edges without building the whole graph (the buffer-before-write model). +- **`validate_code` now returns `dependencies`** — resolved, project-relative + targets with kind + 1-based position, alongside lint diagnostics. +- **Project-structure query API** — dependents / orphans / reachability / + missing-target / nearest-name ("did you mean"). +- **Per-file self-structural** — `renders_used`, `graphql_queries_used`, + `filters_used`, `tags_used`, `translation_keys`, `doc_params`, `slug`, + `layout`, `method`, surfaced on each module as a parse by-product. +- **Platform facts** — a GraphQL op's `table`, schema/`CustomModelType` nodes. +- **`'layout'` DocumentType** in `DocumentsLocator` (the canonical resolver), + with `.html.liquid`/`.liquid` precedence. +- **Two ADRs**: 003 (graph-backed enrichment) resolved; **004** (the + platform-fact-vs-convention boundary — why commands/queries do **not** belong + in the graph). + +### Scope discipline (kept the changes safe) +- **Additive everywhere.** New optional fields (`Reference.kind`, `.args`, + `GraphQLModule.table`, `LiquidModule.structural`, `SchemaModule`), new query + functions, new DocumentType. No breaking change to existing consumers. +- **The LSP and `validate_code` outputs are unchanged** except the deliberate + new `dependencies` field — verified by whole-result assertions. +- Cross-package safety re-verified after every slice: graph, check-common, + check-node, language-server, supervisor. + +### Verification (final) +| Package | Tests | +|---|---| +| platformos-graph | **73** | +| platformos-check-common | **1047** | +| platformos-mcp-supervisor | **50** (incl. 7 stdio end-to-end) | +| platformos-language-server-common | **467** | + +All packages type-check clean; `yarn format:check` clean; `--frozen-lockfile` +clean. The **only** red anywhere is a pre-existing, local-only timeout in +`TypeSystem.spec` (a heavy LSP type-inference test that exceeds 5s only under +full-suite parallel load on a contended machine — proven unrelated by a stash +baseline; CI does not hit it). + +> **Working-tree note:** the last commit on the branch is the query API +> (`nearestModules`). The most recent slices — graphql `table`, schema nodes, +> per-file self-structural, ADR 004 — are **staged in the working tree, not yet +> committed**. Commit before opening the PR. + +--- + +## 2. Architecture + +### 2.1 Package responsibilities (the separation we hold) + +``` +liquid-html-parser CST → AST (the parse everything reuses) +platformos-common path/URI math, DocumentsLocator (resolution), RouteTable/slug, + frontmatter schemas, AbstractFileSystem +platformos-check-common lint engine, Offense, ReferenceKind/Reference, getPosition, + levenshtein, liquid-doc, graphql parsing, extractGraphqlTable +platformos-check-node NodeFileSystem, lintBuffer / check() +platformos-graph THE project model: AppGraph (nodes + edges), buildAppGraph, + extractFileReferences, query API, self-structural +platformos-language-server-common LSP — consumes the graph (references/dependencies/dead_code) +platformos-mcp-supervisor validate_code — pure consumer of graph + check engine +``` + +Rule of thumb that governs every decision here: +- **Parsing/resolution/path facts** → owned by common/check-common, **reused** by + the graph (never re-derived). +- **The project model (nodes, edges, queries, self-structural)** → owned by + `platformos-graph`. +- **Agent ergonomics (the `validate_code` shape, prose, gating)** → owned by the + supervisor, which contains **zero** graph/scanner logic. + +### 2.2 The graph model + +```ts +AppGraph { + rootUri: string + entryPoints: AppModule[] // pages + layouts (render roots) + modules: Record // every reachable node + standalone schema nodes +} + +AppModule = LiquidModule | AssetModule | GraphQLModule | SchemaModule + IAppModule { + type, uri, + dependencies: Reference[] // outgoing edges + references: Reference[] // incoming edges + exists?: boolean + } + LiquidModule { kind: Page|Partial|Layout, structural?: ModuleStructural } + AssetModule { kind: 'unused' } + GraphQLModule { kind: 'graphql', table?: string } + SchemaModule { kind: 'schema', table?: string } // custom_model_type + +Reference { + source: { uri, range? } // call site (0-based offsets) + target: { uri } // DocumentsLocator-canonical, normalized + type: 'direct' | 'indirect' + kind?: 'render'|'include'|'function'|'background'|'graphql'|'asset'|'layout' + args?: string[] // named-argument names at the call site +} + +ModuleStructural { + renders_used, graphql_queries_used, filters_used, tags_used, + translation_keys, doc_params: string[] // always present (empty = none) + slug?, layout?, method?: string // page routing facts (absent = N/A) +} +``` + +### 2.3 Two resolution paths, one shared resolver + +`resolveLiquidReferences` (internal) is the **single** place that maps a Liquid +construct → resolved target + kind + args. Both callers go through it, so the two +paths can never drift: + +1. **`buildAppGraph(rootUri, deps, entryPoints?)`** — full project graph from + disk. Entry points default to pages + layouts; traversal follows edges; a full + build additionally discovers standalone **schema** nodes. Populates incoming + `references`, `exists`, `table`, and `structural`. +2. **`extractFileReferences(rootUri, sourceUri, sourceCode, { fs })`** — one + file's **outgoing** edges from an **in-flight buffer** (parsed by the caller, + may not be on disk). No whole-graph build, no reachability requirement. This is + what `validate_code` uses. + +### 2.4 Resolution is delegated to `DocumentsLocator` + +The graph never hand-rolls path logic. Targets resolve through check-common's +`DocumentsLocator`, which owns lib search paths, `modules//public/...` +prefixes, and extension handling. This branch added a **`'layout'` DocumentType** +there (maps to `PlatformOSFileType.Layout`, tries `.html.liquid` then `.liquid`), +so layout resolution + missing-target fallback come "for free", consistent with +every other reference kind. + +--- + +## 3. Decisions (and why) + +### ADR 004 — Platform facts vs. conventions (the load-bearing one) +`commands` and `queries` are **not** platformOS primitives — they're a convention +of the `core` module (`lib/commands/*`, `lib/queries/*` invoked by `{% function %}`). +The platform sees only "a `function` edge to a partial." Therefore: +- **The graph stays convention-free.** It models the `function` edge and the + partial; it does **not** learn "command" vs "query". (Baking that in would make + the platform model — and the LSP that depends on it — wrong for any app not + using `core`.) +- **Resource/CRUD completeness** (the old `detectResources`) is split: neutral + **platform facts** (schema tables, graphql `table`, slug) live in the graph + (TASK-9.6, done); the **commands/queries convention overlay** lives outside it + (TASK-9.7, deferred) — a descriptive map in the supervisor domain layer and/or + a *configurable* check (disable-able for non-`core` apps). + +### ADR 003 — resolved +- **Self-structural owner = `platformos-graph`** (it already parses every file; + exposes the snapshot as a by-product, composing check-common parsers). +- **`Reference.kind`/`args`** = optional additive discriminant + arg names. +- web-component (Shopify-era ``) machinery **removed** entirely — + platformOS doesn't use `customElements.define`. + +### Other notable decisions +- **`structural` usage arrays are always present** (empty = "none used"), so a + consumer never has to disambiguate "absent" from "none". Routing facts + (`slug`/`layout`/`method`) stay optional (absent = not applicable). +- **Orphan detection guards non-render node kinds.** A schema file has no incoming + *edges* (it's referenced by table name), so `isOrphan` explicitly excludes + `Schema` to avoid a false "dead code" flag. +- **Schema discovery only on full builds.** Scoped/LSP builds (explicit + `entryPoints`) are byte-unchanged. +- **`structural` / `table` are not serialized** (`SerializableNode` is unchanged) + — they're in-memory facts for the query/overlay layer, not the CLI graph dump. +- **Edge tests assert edge identity only** (`structural` stripped via + `edgeIdentity`) — separation of concerns; structural is pinned in its own spec, + so edge tests stay stable as structural grows. + +--- + +## 4. Graph feature surface (public API) + +From `@platformos/platformos-graph`: + +| Export | Purpose | +|---|---| +| `buildAppGraph(rootUri, deps, entryPoints?)` | Build the full project graph from disk | +| `extractFileReferences(rootUri, sourceUri, sourceCode, { fs })` | One buffer's outgoing edges (no full build) | +| `serializeAppGraph(graph)` | JSON `{ rootUri, nodes, edges }` | +| `toSourceCode(uri, source)` | Parse a buffer into a `FileSourceCode` | +| `dependenciesOf(graph, uri)` | Outgoing edges (what it renders/calls/wraps) — call sites w/ kind + args | +| `dependentsOf(graph, uri)` | Incoming edges (who renders/calls it) | +| `exists(graph, uri)` | Does it resolve to a real file | +| `isEntryPoint(graph, uri)` | Is it a page/layout root | +| `isOrphan(graph, uri)` / `orphans(graph)` | Unreferenced dead files (schema-guarded) | +| `reachableFrom(graph, uri)` | Transitive outgoing closure | +| `missingDependencies(graph, uri)` / `missingTargets(graph)` | Unresolved edges | +| `nearestModules(graph, uri, { limit?, maxDistance? })` | Same-category "did you mean" by edit distance | +| `ModuleStructural` (on `LiquidModule.structural`) | The file's own declarations | + +There is also a CLI (`bin/platformos-graph`): `platformos-graph [file]`. + +### What `validate_code` discovers today +Per call (`{ file_path, content, mode }`), in one pass over the in-flight buffer: +- **Lint** (check-node `lintBuffer`): errors / warnings / infos with 1-based + positions, `must_fix_before_write` gate, `status`. +- **`dependencies`** (graph `extractFileReferences`): every statically-resolvable + outgoing edge — `kind`, **project-relative resolved target**, 1-based line/col. + This is the "how it sits in the project" signal. It does **not** re-flag missing + targets (the linter's `MissingPartial` already does); its value is the + canonical resolved path + kind. + +Still graph-side / not yet surfaced in `validate_code` (see §6): the project-wide +queries (dependents/orphans/nearest-name) and the self-`structural` snapshot. + +--- + +## 5. Worked examples (real output, not illustrative) + +### 5.1 `validate_code` — a layout that omits `content_for_layout` while wiring deps + +Input `app/views/layouts/application.liquid`: +```liquid +--- +layout: theme +--- +{% function nav = 'queries/list' %} +{% render 'header', title: 'Hi' %} +``` + +Output (verbatim): +```json +{ + "status": "error", + "must_fix_before_write": true, + "errors": [ + { + "check": "MissingContentForLayout", + "severity": "error", + "message": "Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)", + "line": 1, "column": 1, "end_line": 1, "end_column": 1 + } + ], + "warnings": [], + "infos": [], + "proposed_fixes": [], + "clusters": [], + "scorecard": [], + "dependencies": [ + { "kind": "layout", "target": "app/views/layouts/theme.liquid", "line": 1, "column": 1 }, + { "kind": "function", "target": "app/lib/queries/list.liquid", "line": 4, "column": 1 }, + { "kind": "render", "target": "app/views/partials/header.liquid", "line": 5, "column": 13 } + ], + "parse_error": null, + "tips": [], + "domain_guide": null, + "structural": null +} +``` + +Note the clean separation: the **lint error** and the **resolved dependencies** +coexist without conflation. `dependencies` resolves the frontmatter `layout`, the +`{% function %}` lib query, and the `{% render %}` partial to their canonical +project-relative paths with exact positions. + +### 5.2 Graph self-structural — `header.liquid` + +For +```liquid +{% doc %} + @param title [String] heading +{% enddoc %} +
{{ title | upcase }}
+``` +`graph.modules[…/header.liquid].structural` is: +```json +{ + "renders_used": [], + "graphql_queries_used": [], + "filters_used": ["upcase"], + "tags_used": [], + "translation_keys": [], + "doc_params": ["title"] +} +``` +(`{% doc %}` is a raw tag, so it is correctly absent from `tags_used`; its +`@param` names surface as `doc_params`.) + +--- + +## 6. Open doubts / risks / follow-ups + +1. **`structural` is built but not surfaced in `validate_code`** (`"structural": null` + above). Wiring it is **TASK-8.4** (result shaping). It's also a full-build node + fact; the per-file `extractFileReferences` path doesn't compute it — so to put + self-structural in `validate_code` we either (a) add a per-buffer structural + extractor (the AST is already parsed in the structure adapter — cheap), or (b) + keep it project-build-only. **Decision needed.** Recommendation: (a), reusing + the same `extractStructural` over the buffer AST. + +2. **Project-wide queries need a cached full build.** `dependentsOf`, `orphans`, + `nearestModules` require `buildAppGraph` (O(project), parse-heavy). `validate_code` + currently does a per-file build only. Surfacing "who renders this / did-you-mean" + to the agent needs a cached graph at the supervisor edge (ADR 003 open question + #5 — TTL vs explicit invalidation). Not built. + +3. **`orphans()` completeness depends on how the graph was built.** `buildAppGraph` + only materializes modules reachable from entry points (+ schema). To list + genuinely-unreferenced partials you must build with every file as an entry + point. Documented in `query.ts`; a convenience "whole-project" build mode may be + worth adding. + +4. **graphql `table` extraction is path/AST-based, single-style.** It reads the + first `table` object/`table:"x"` filter via the GraphQL AST. Exotic query + shapes (computed table, multiple tables) resolve to the first/none. Adequate for + resource grouping; not a full GraphQL semantic analysis. + +5. **Schema table name = the YAML `name:`.** Files with no `name:` get a node with + `table` undefined (matching the old scanner's skip-ish behavior, but we still + model the node). If a project keys schemas differently, the join in TASK-9.7 + would miss them. + +6. **TASK-9.7 (resource/CRUD completeness) is deferred by design.** It's the + commands/queries convention overlay; per ADR 004 it must NOT enter the graph. + Its home (supervisor domain layer vs a configurable check) and the + configurable path-roots are decided in the ADR but not implemented. + +7. **The `TypeSystem.spec` timeout** is a pre-existing local-only flake (5s default + timeout under full-suite parallel load). Not caused by this branch (verified via + stash baseline); CI is green. If it becomes noisy, bump that one test's timeout + — but it is *not* masking a real failure. + +8. **Branch name typo** (`supervisor-graph-integration` is fine; the upstream graph + work lived on `extend-platfomos-graph` — note the misspelling there if cherry- + picking history). + +--- + +## 7. Task ledger (TASK-9 epic) + +| Task | Status | +|---|---| +| 9.1 dependency edges (render/include/function/background/graphql/asset) | ✅ Done | +| 9.2 project-structure query API (dependents/orphan/reachability/missing/nearest) | ✅ Done | +| 9.3 per-file self-structural (9 facts) | ✅ Done | +| 9.4 layout edges + `DocumentsLocator 'layout'` | ✅ Done | +| 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | +| 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | +| 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | +| 8.4 surface `structural` in `validate_code` | ⬜ Open (doubt #1) | + +ADRs: `docs/mcp-supervisor/decisions/003-…` (resolved), `004-platform-facts-vs-conventions` (accepted). diff --git a/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md b/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md index 233ffda5..087c1daa 100644 --- a/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md +++ b/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md @@ -157,23 +157,35 @@ Apply the consumer principle: `project-scanner.ts` / `render-flow.ts` inside the supervisor. If a capability is missing, it is added to `platformos-graph`. +## Resolved (2026-06-30) + +1. **Per-file self-structural owner — RESOLVED: `platformos-graph`** (TASK-9.3). + The self-structural snapshot is exposed as an optional `structural` property on + each `LiquidModule` (`ModuleStructural`: `renders_used`, `graphql_queries_used`, + `filters_used`, `tags_used`, `translation_keys`, `doc_params` — always-present + arrays, empty = none; plus optional `slug`/`layout`/`method`). It is populated + in `traverseLiquidModule` as a by-product of the parse the graph already does — + the consumer never re-parses. Extraction COMPOSES check-common parsers rather + than re-deriving: `extractDocDefinition` (doc_params), `js-yaml` via a single + shared `loadFrontmatter` (slug/layout/method), the liquid-html AST `visit` + (renders/graphql/filters/tags), the translation `t`/`translate` detection from + the translation-key check, and `extractRelativePagePath`/`slugFromFilePath` + (slug). No second/bespoke parser was introduced. +3. **Resource/CRUD completeness — RESOLVED in ADR 004**: it is the core-module + commands/queries CONVENTION, not platform truth, so it lives OUTSIDE the graph + (TASK-9.6 platform facts + TASK-9.7 convention overlay), not in `platformos-graph`. + + `Reference.kind` was also resolved (TASK-9.1): an optional discriminant + (`render | include | function | background | graphql | asset | layout`) plus a + later `args?: string[]` (TASK-9.2); web-component was removed. + ## Open questions -1. **Per-file self-structural owner** — expose per-module on `platformos-graph` - (it already parses everything) vs a shared extraction util in - check-common/common? (Recommendation: platformos-graph, since it owns parsing - the app.) -2. **`Reference.kind`** — add an optional discriminant (`render | include | - function | graphql | asset | web-component | layout`) to `Reference`, or model - per-edge metadata another way? -3. **Resource/CRUD completeness** — does this belong in `platformos-graph` - (project-structure analysis) or a thin separate analysis layer? It is the - least "pure graph" of the old project-map facts. -4. **Phase 1 now or wait?** Ship render/asset-scoped structural against today's - graph, or hold the `structural` block until the graph extensions (Phase 2) - land? 5. **Build cost / freshness** — full-app graph build is parse-heavy; cache at the - supervisor edge with a TTL (matches v1's 30s) vs explicit invalidation? + supervisor edge with a TTL (matches v1's 30s) vs explicit invalidation? (Still + open — the supervisor's `extractFileReferences` per-file path avoids a full + build for `validate_code` today; a cached full build is needed when TASK-9.7 + consumes project-wide facts.) ## References diff --git a/docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md b/docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md new file mode 100644 index 00000000..b4dab425 --- /dev/null +++ b/docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md @@ -0,0 +1,119 @@ +# Platform facts vs. conventions: keep platformos-graph convention-free; layer the commands/queries/resource convention on top + +## Status + +Accepted (2026-06-30). Refines ADR 003 (graph-backed structural enrichment). + +## tl;dr + +`commands` and `queries` are **not** platformOS primitives — they are a +convention of the `core` module: a partial under `lib/commands/` (write) or +`lib/queries/` (read), invoked by `{% function %}`. The platform sees only "a +`function` edge to a partial." Therefore **`platformos-graph` (the canonical +platform model, consumed by the LSP) must stay convention-free**, and the +resource/CRUD-completeness view from the old `detectResources` — which fuses +platform facts with the `core` convention — must be split: platform facts may +live in the graph; the convention overlay must live in a separate, clearly +labeled, configurable layer on top of it. + +## Context + +ADR 003 decided to resurrect the old supervisor's project-map capabilities +inside `platformos-graph`. While implementing the query layer (TASK-9.2) we hit +a boundary problem the old code ignored. + +### Two kinds of truth + +| | Platform truth | Convention truth | +|---|---|---| +| Authority | platformOS itself | the `core` module (or whichever modules an app installs) | +| Always correct? | Yes, for every app | Only for apps following the convention | +| Examples | partial, page, layout, graphql op, asset, schema/`custom_model_type`; the edges between them; a graphql op's `table`; a page's `slug` | "this partial is a command/query"; "a resource needs search/find/create/update/delete"; pluralized-path grouping (`/commands/{plural}/`) | + +The old `detectResources` (recovered from `project-scanner.js`) **fused these**: +`pluralize` + `/commands/{plural}/` + `/queries/{plural}/` + the expected-CRUD +set are all *convention truth* presented as platform facts. + +### Why the graph must stay convention-free + +- `platformos-graph` is consumed by the **LSP** (go-to-definition, references, + dead-code). Encoding the `core` CQRS opinion there gives editor features + assumptions that are **false for apps not using `core`**. +- If `core` changes its convention, or an app uses a different module, a graph + that "knows" commands/queries is wrong — in the foundation everything builds on. +- The current graph is **already correct**: `getPartialModule` classifies + `commands/`, `queries/`, and `lib/` all as `kind: Partial`. Adding + `LiquidModuleKind.Command/.Query` would be the mistake. + +### The clarifying asymmetry + +Resource completeness needs several inputs; they split cleanly: + +- **Legitimate platform facts** (may grow the graph): schema/`CustomModelType` + nodes (custom model types *are* a platform primitive); a graphql op's `table` + (a platform GraphQL concept); a page's `slug` (frontmatter is platform). +- **Convention overlays** (must NOT enter the graph core): command/query roles, + pluralize grouping, the expected-CRUD set, "resource completeness." + +So: *resource/CRUD completeness = (platform facts, graph-eligible) + (a +convention overlay, not graph-eligible).* + +### The convention produces two outputs, wanting different homes + +1. **Descriptive map** ("resource → its commands/queries/graphql/pages"): + project-map data to *show* an agent; not an offense. Home: the supervisor's + per-domain layer (TASK-8), which exists for domain conventions — or a + clearly-quarantined `platformos-graph/conventions/*` exported separately from + the neutral query API. Preferred: the domain layer, keeping the graph pristine. +2. **Prescriptive warning** ("table X has a query but no `create` command"): + opinionated, actionable, and **toggleable** — exactly what a custom check is + for. An app not following the `core` convention disables it via + `.platformos-check.yml`. That configurability is the tell that it is + convention, not platform law. + +**Rule of thumb:** facts → graph; descriptive convention map → domain layer +(consumes graph); prescriptive convention warnings → a configurable check +(consumes graph). All three build on the same convention-free graph facts; none +teaches the graph the convention. + +### Deepest layer (noted, likely out of scope to build now) + +The convention is **module-defined**: the command/query path roots are whatever +the installed modules declare, not universally `commands`/`queries`. Hardcoding +`core`'s roots is a pragmatic 95% solution; making them **configurable** is the +principled hedge. Reading them from installed modules is probably +over-engineering — but the design must leave room for parameterization rather +than baking the roots in. + +## Decision + +1. **`platformos-graph` stays convention-free.** TASK-9.2 Phases 1–3 + (dependents / orphan / reachability / exists / missing-target / call-site + args / nearest-name) are neutral and correct; they ship as the query API. Do + **not** add command/query kinds or resource/CRUD logic to the graph core. +2. **Resource/CRUD completeness is deferred and split by output type:** + - **Platform-fact groundwork** (TASK-9.6) — model only neutral facts in the + graph: schema/`CustomModelType` nodes, a graphql op's `table`, page `slug`. + No convention. + - **Convention map + warnings** (TASK-9.7) — descriptive map in the domain + layer and/or prescriptive warnings in a configurable check; both consume + the graph facts; path roots configurable; the supervisor stays a pure + consumer. +3. **TASK-9.2 closes at Phases 1–3.** Its "resource/CRUD completeness" clause + moves to TASK-9.6/9.7 with this ADR as the governing constraint. + +## Consequences + +- **Positive:** the platform model (and the LSP that depends on it) stays + correct for every app, regardless of which modules it installs. Convention + lives where it can be turned off. The supervisor remains a pure consumer. +- **Cost:** resource completeness is no longer a single function; it is a + facts-layer (graph) + an overlay-layer (domain/check). This is deliberate — + the seam is the whole point. +- **If we ignore this boundary:** command/query in the graph couples the + platform model + LSP to one module (wrong for non-`core` apps, brittle to + convention drift); always-on resource-completeness yields false positives for + apps with a different architecture and erodes agent trust. +- **Undo/mitigation:** the split is additive. If a convention ever became truly + universal platform truth, its detection could be promoted from the overlay + into the graph without disturbing consumers (the neutral API is a subset). diff --git a/packages/platformos-check-common/src/graphql-table.spec.ts b/packages/platformos-check-common/src/graphql-table.spec.ts new file mode 100644 index 00000000..176d818d --- /dev/null +++ b/packages/platformos-check-common/src/graphql-table.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { extractGraphqlTable } from './graphql-table'; + +describe('extractGraphqlTable', () => { + it('extracts the table from a `table: { value: "..." }` filter', () => { + const content = `query find($id: ID!) { + records(per_page: 1, filter: { id: { value: $id }, table: { value: "blog_post" } }) { + results { id } + } +}`; + expect(extractGraphqlTable(content)).toBe('blog_post'); + }); + + it('extracts the table from the `table: "..."` shorthand', () => { + const content = `query { records(filter: { table: "product" }) { results { id } } }`; + expect(extractGraphqlTable(content)).toBe('product'); + }); + + it('returns the first table when several are present', () => { + const content = `query { + a: records(filter: { table: { value: "first" } }) { results { id } } + b: records(filter: { table: { value: "second" } }) { results { id } } +}`; + expect(extractGraphqlTable(content)).toBe('first'); + }); + + it('returns undefined when there is no table filter', () => { + const content = `query currentUser { current_user { id email } }`; + expect(extractGraphqlTable(content)).toBeUndefined(); + }); + + it('is not confused by a sibling `value` field on a non-table object', () => { + const content = `query find($id: ID!) { + records(filter: { id: { value: $id }, table: { value: "blog_post" } }) { + results { id } + } +}`; + expect(extractGraphqlTable(content)).toBe('blog_post'); + }); + + it('returns undefined for unparseable GraphQL', () => { + expect(extractGraphqlTable('query { records(filter: {')).toBeUndefined(); + }); + + it('returns undefined for an empty document', () => { + expect(extractGraphqlTable('')).toBeUndefined(); + }); + + it('extracts the table from a mutation (records_create style)', () => { + const content = `mutation create($payload: HashObject) { + record_create(record: { table: "blog_post", properties: $payload }) { + id + } +}`; + expect(extractGraphqlTable(content)).toBe('blog_post'); + }); + + it('extracts a table nested deep inside the filter object', () => { + const content = `query { + records(filter: { properties: { name: { value: "x" } }, table: { value: "comment" } }) { + results { id } + } +}`; + expect(extractGraphqlTable(content)).toBe('comment'); + }); + + it('extracts the table when it appears before other fields (order-independent)', () => { + const content = `query { records(filter: { table: { value: "tag" }, deleted: { exists: false } }) { results { id } } }`; + expect(extractGraphqlTable(content)).toBe('tag'); + }); + + it('handles an underscored/namespaced table name', () => { + const content = `query { records(filter: { table: { value: "modules/core/user" } }) { results { id } } }`; + expect(extractGraphqlTable(content)).toBe('modules/core/user'); + }); + + it('returns undefined when `table` is a non-string (dynamic) value', () => { + const content = `query q($t: String) { records(filter: { table: { value: $t } }) { results { id } } }`; + expect(extractGraphqlTable(content)).toBeUndefined(); + }); + + it('ignores a `table` used as a GraphQL alias, not a filter field', () => { + // `table:` here is a field alias (table: results), not an object field. + const content = `query { records(filter: { deleted: { exists: false } }) { table: results { id } } }`; + expect(extractGraphqlTable(content)).toBeUndefined(); + }); +}); diff --git a/packages/platformos-check-common/src/graphql-table.ts b/packages/platformos-check-common/src/graphql-table.ts new file mode 100644 index 00000000..97f7c932 --- /dev/null +++ b/packages/platformos-check-common/src/graphql-table.ts @@ -0,0 +1,51 @@ +import { parse, visit } from 'graphql/language'; + +/** + * Extract the platformOS model table a GraphQL operation targets, if it declares + * one. platformOS queries filter records by table, e.g. + * + * ```graphql + * query { records(filter: { table: { value: "blog_post" } }) { ... } } + * ``` + * + * The table also appears in the shorthand `table: "blog_post"`. This walks the + * parsed GraphQL AST for the first `table` object field and reads its string + * value (either a direct `StringValue`, or an object `{ value: "..." }`), + * reusing the `graphql` parser this package already owns rather than a regex. + * + * Returns `undefined` for operations with no table filter or unparseable input. + */ +export function extractGraphqlTable(content: string): string | undefined { + let ast; + try { + ast = parse(content); + } catch { + return undefined; // not valid GraphQL — nothing to extract + } + + let table: string | undefined; + visit(ast, { + ObjectField(node) { + if (table !== undefined) return; // first table wins + if (node.name.value !== 'table') return; + + // `table: "blog_post"` + if (node.value.kind === 'StringValue') { + table = node.value.value; + return; + } + + // `table: { value: "blog_post" }` + if (node.value.kind === 'ObjectValue') { + const valueField = node.value.fields.find( + (field) => field.name.value === 'value' && field.value.kind === 'StringValue', + ); + if (valueField && valueField.value.kind === 'StringValue') { + table = valueField.value.value; + } + } + }, + }); + + return table; +} diff --git a/packages/platformos-check-common/src/index.ts b/packages/platformos-check-common/src/index.ts index cfba35b8..835cf845 100644 --- a/packages/platformos-check-common/src/index.ts +++ b/packages/platformos-check-common/src/index.ts @@ -53,6 +53,11 @@ export { getPosition } from './utils'; // re-implementing string-distance. export { levenshtein } from './utils'; +// `extractGraphqlTable` parses the platformOS model table a GraphQL operation +// targets; exported so the graph can model a GraphQL node's table without +// re-implementing GraphQL parsing (the `graphql` dep lives here). +export { extractGraphqlTable } from './graphql-table'; + export * from './AugmentedPlatformOSDocset'; export * from './types/platformos-liquid-docs'; export * from './checks'; diff --git a/packages/platformos-graph/fixtures/graphql-table/app/graphql/with_table.graphql b/packages/platformos-graph/fixtures/graphql-table/app/graphql/with_table.graphql new file mode 100644 index 00000000..e13b2f3b --- /dev/null +++ b/packages/platformos-graph/fixtures/graphql-table/app/graphql/with_table.graphql @@ -0,0 +1,5 @@ +query { + records(per_page: 1, filter: { table: { value: "blog_post" } }) { + results { id } + } +} diff --git a/packages/platformos-graph/fixtures/graphql-table/app/graphql/without_table.graphql b/packages/platformos-graph/fixtures/graphql-table/app/graphql/without_table.graphql new file mode 100644 index 00000000..c075cae6 --- /dev/null +++ b/packages/platformos-graph/fixtures/graphql-table/app/graphql/without_table.graphql @@ -0,0 +1,3 @@ +query { + current_user { id email } +} diff --git a/packages/platformos-graph/fixtures/graphql-table/app/views/pages/index.liquid b/packages/platformos-graph/fixtures/graphql-table/app/views/pages/index.liquid new file mode 100644 index 00000000..d4b767cf --- /dev/null +++ b/packages/platformos-graph/fixtures/graphql-table/app/views/pages/index.liquid @@ -0,0 +1,4 @@ +{% liquid + graphql posts = 'with_table' + graphql me = 'without_table' +%} diff --git a/packages/platformos-graph/fixtures/schema-nodes/app/schema/blog_post.yml b/packages/platformos-graph/fixtures/schema-nodes/app/schema/blog_post.yml new file mode 100644 index 00000000..302190eb --- /dev/null +++ b/packages/platformos-graph/fixtures/schema-nodes/app/schema/blog_post.yml @@ -0,0 +1,6 @@ +name: blog_post +properties: + - name: title + type: string + - name: body + type: text diff --git a/packages/platformos-graph/fixtures/schema-nodes/app/schema/no_name.yml b/packages/platformos-graph/fixtures/schema-nodes/app/schema/no_name.yml new file mode 100644 index 00000000..1dd39811 --- /dev/null +++ b/packages/platformos-graph/fixtures/schema-nodes/app/schema/no_name.yml @@ -0,0 +1,3 @@ +properties: + - name: anon + type: string diff --git a/packages/platformos-graph/fixtures/schema-nodes/app/views/pages/index.liquid b/packages/platformos-graph/fixtures/schema-nodes/app/views/pages/index.liquid new file mode 100644 index 00000000..415b7b29 --- /dev/null +++ b/packages/platformos-graph/fixtures/schema-nodes/app/views/pages/index.liquid @@ -0,0 +1 @@ +

home

diff --git a/packages/platformos-graph/fixtures/structural/app/views/pages/about.liquid b/packages/platformos-graph/fixtures/structural/app/views/pages/about.liquid new file mode 100644 index 00000000..8a8533a4 --- /dev/null +++ b/packages/platformos-graph/fixtures/structural/app/views/pages/about.liquid @@ -0,0 +1 @@ +

about

diff --git a/packages/platformos-graph/fixtures/structural/app/views/pages/blog/show.liquid b/packages/platformos-graph/fixtures/structural/app/views/pages/blog/show.liquid new file mode 100644 index 00000000..bbcd9faf --- /dev/null +++ b/packages/platformos-graph/fixtures/structural/app/views/pages/blog/show.liquid @@ -0,0 +1,4 @@ +--- +slug: blog/custom +--- +

show

diff --git a/packages/platformos-graph/fixtures/structural/app/views/pages/index.liquid b/packages/platformos-graph/fixtures/structural/app/views/pages/index.liquid new file mode 100644 index 00000000..04721670 --- /dev/null +++ b/packages/platformos-graph/fixtures/structural/app/views/pages/index.liquid @@ -0,0 +1,6 @@ +--- +layout: application +method: get +--- +{% render 'card' %} +{% render 'documented' %} diff --git a/packages/platformos-graph/fixtures/structural/app/views/pages/rich.liquid b/packages/platformos-graph/fixtures/structural/app/views/pages/rich.liquid new file mode 100644 index 00000000..34b0111e --- /dev/null +++ b/packages/platformos-graph/fixtures/structural/app/views/pages/rich.liquid @@ -0,0 +1,8 @@ +--- +layout: application +--- +{% assign x = 'a' %} +{% if x %}{{ x | upcase }}{% endif %} +{% render 'card' %} +{% graphql res = 'blog/find' %} +{{ 'greeting.hello' | t }} diff --git a/packages/platformos-graph/fixtures/structural/app/views/partials/card.liquid b/packages/platformos-graph/fixtures/structural/app/views/partials/card.liquid new file mode 100644 index 00000000..7af0a70c --- /dev/null +++ b/packages/platformos-graph/fixtures/structural/app/views/partials/card.liquid @@ -0,0 +1 @@ +
{{ title }}
diff --git a/packages/platformos-graph/fixtures/structural/app/views/partials/documented.liquid b/packages/platformos-graph/fixtures/structural/app/views/partials/documented.liquid new file mode 100644 index 00000000..ed43b4bc --- /dev/null +++ b/packages/platformos-graph/fixtures/structural/app/views/partials/documented.liquid @@ -0,0 +1,5 @@ +{% doc %} + @param title [String] The card title. + @param count [Number] How many. +{% enddoc %} +
{{ title }}
diff --git a/packages/platformos-graph/src/graph/build.ts b/packages/platformos-graph/src/graph/build.ts index 0c97cf3b..e8ae7468 100644 --- a/packages/platformos-graph/src/graph/build.ts +++ b/packages/platformos-graph/src/graph/build.ts @@ -1,13 +1,15 @@ import { recursiveReadDirectory as findAllFiles, + getFileType, isLayout, isPage, path, + PlatformOSFileType, UriString, } from '@platformos/platformos-check-common'; import { IDependencies, AppGraph, AppModule } from '../types'; import { augmentDependencies } from './augment'; -import { getModule } from './module'; +import { getModule, getSchemaModule } from './module'; import { traverseModule } from './traverse'; export async function buildAppGraph( @@ -17,6 +19,10 @@ export async function buildAppGraph( ): Promise { const deps = augmentDependencies(rootUri, ideps); + // An explicit entryPoints scope is built verbatim (e.g. a scoped LSP rebuild); + // the default full build also discovers standalone schema nodes below. + const isFullBuild = entryPoints === undefined; + entryPoints = entryPoints ?? (await findAllFiles(deps.fs, rootUri, ([uri]) => { @@ -38,5 +44,22 @@ export async function buildAppGraph( await Promise.all(graph.entryPoints.map((entry) => traverseModule(entry, graph, deps))); + // Schema/custom-model-type files are platform nodes but are NOT render-reachable + // (nothing renders them), so they never appear via edge traversal. On a full + // build, discover and add them as standalone leaf nodes — never entry points, + // so reachability/orphan semantics for the render graph are unaffected. + if (isFullBuild) { + const schemaUris = await findAllFiles( + deps.fs, + rootUri, + ([uri]) => + (uri.endsWith('.yml') || uri.endsWith('.yaml')) && + getFileType(uri) === PlatformOSFileType.CustomModelType, + ); + await Promise.all( + schemaUris.map((uri) => traverseModule(getSchemaModule(graph, uri), graph, deps)), + ); + } + return graph; } diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index 98d5d505..73227e2b 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -7,6 +7,7 @@ import { LiquidModule, LiquidModuleKind, ModuleType, + SchemaModule, SUPPORTED_ASSET_IMAGE_EXTENSIONS, } from '../types'; import { extname } from '../utils'; @@ -128,6 +129,22 @@ export function getGraphQLModuleByUri(appGraph: AppGraph, uri: string): GraphQLM }); } +/** + * Create (or fetch the cached) Schema module for an already-resolved + * `custom_model_type`/schema URI — discovered during a full `buildAppGraph`. + * A leaf node; its `table` (the model `name:`) is populated during traversal. + */ +export function getSchemaModule(appGraph: AppGraph, uri: string): SchemaModule { + return module(appGraph, { + type: ModuleType.Schema, + kind: 'schema', + // Normalize to forward slashes — see getPartialModuleByUri. + uri: path.normalize(uri), + dependencies: [], + references: [], + }); +} + export function getLayoutModule( appGraph: AppGraph, layoutUri: string | false | undefined, diff --git a/packages/platformos-graph/src/graph/query.spec.ts b/packages/platformos-graph/src/graph/query.spec.ts index 0c4042da..11fb795d 100644 --- a/packages/platformos-graph/src/graph/query.spec.ts +++ b/packages/platformos-graph/src/graph/query.spec.ts @@ -14,7 +14,13 @@ import { orphans, reachableFrom, } from './query'; -import { getGraphQLModuleByUri, getLayoutModule, getPageModule, getPartialModule } from './module'; +import { + getGraphQLModuleByUri, + getLayoutModule, + getPageModule, + getPartialModule, + getSchemaModule, +} from './module'; import { bind } from './traverse'; import { getDependencies, skeleton } from './test-helpers'; @@ -100,11 +106,13 @@ describe('Graph queries: orphan and missing-target detection (hermetic graph)', // page (entry) ─render→ used (exists) // └─render→ missing (exists:false) // orphan (exists, referenced by nothing, not an entry point) + // schema (exists, referenced by nothing, not an entry point — but NOT an orphan) let graph: AppGraph; let pageUri: string; let usedUri: string; let orphanUri: string; let missingUri: string; + let schemaUri: string; beforeAll(() => { graph = { rootUri, entryPoints: [], modules: {} }; @@ -112,22 +120,25 @@ describe('Graph queries: orphan and missing-target detection (hermetic graph)', const used = getPartialModule(graph, 'used'); const orphan = getPartialModule(graph, 'orphan'); const missing = getPartialModule(graph, 'missing'); + const schema = getSchemaModule(graph, p('app/schema/blog_post.yml')); page.exists = true; used.exists = true; orphan.exists = true; missing.exists = false; + schema.exists = true; bind(page, used, { sourceRange: [0, 10], kind: 'render' }); bind(page, missing, { sourceRange: [11, 21], kind: 'render' }); graph.entryPoints = [page]; - for (const module of [page, used, orphan, missing]) graph.modules[module.uri] = module; + for (const module of [page, used, orphan, missing, schema]) graph.modules[module.uri] = module; pageUri = page.uri; usedUri = used.uri; orphanUri = orphan.uri; missingUri = missing.uri; + schemaUri = schema.uri; }); it('flags an unreferenced, non-entry-point file as an orphan', () => { @@ -140,7 +151,13 @@ describe('Graph queries: orphan and missing-target detection (hermetic graph)', expect(isOrphan(graph, missingUri)).toBe(false); // missing, not orphan }); - it('orphans() lists exactly the orphan modules', () => { + it('never flags a schema node as an orphan (referenced by table name, not edges)', () => { + // Without the guard this would be a false positive: schema exists, is not an + // entry point, and has zero incoming edges. + expect(isOrphan(graph, schemaUri)).toBe(false); + }); + + it('orphans() lists exactly the orphan modules (excludes the schema)', () => { expect(orphans(graph).map((m) => m.uri)).toEqual([orphanUri]); }); diff --git a/packages/platformos-graph/src/graph/query.ts b/packages/platformos-graph/src/graph/query.ts index 7d8a7ab3..0dc304ce 100644 --- a/packages/platformos-graph/src/graph/query.ts +++ b/packages/platformos-graph/src/graph/query.ts @@ -1,5 +1,5 @@ import { levenshtein, path, UriString } from '@platformos/platformos-check-common'; -import { AppGraph, AppModule, Reference } from '../types'; +import { AppGraph, AppModule, ModuleType, Reference } from '../types'; /** * Project-structure queries over a BUILT {@link AppGraph}. @@ -51,6 +51,10 @@ export function isOrphan(graph: AppGraph, uri: UriString): boolean { const module = graph.modules[uri]; if (!module || module.exists === false) return false; if (isEntryPoint(graph, uri)) return false; + // Schema nodes are referenced by model table name (from graphql/commands), not + // by file edges, so they are NEVER edge-referenced — "no incoming references" + // is meaningless for them. Excluding them keeps orphan = render dead-code. + if (module.type === ModuleType.Schema) return false; return module.references.length === 0; } diff --git a/packages/platformos-graph/src/graph/structural.spec.ts b/packages/platformos-graph/src/graph/structural.spec.ts new file mode 100644 index 00000000..e0461a8d --- /dev/null +++ b/packages/platformos-graph/src/graph/structural.spec.ts @@ -0,0 +1,89 @@ +import { path as pathUtils } from '@platformos/platformos-check-common'; +import { assert, beforeAll, describe, expect, it } from 'vitest'; +import { buildAppGraph } from '../index'; +import { AppGraph, LiquidModule, ModuleStructural, ModuleType } from '../types'; +import { getDependencies, fixturesRoot } from './test-helpers'; + +/** + * TASK-9.3: a Liquid file's own structural declarations surfaced on its module + * as a by-product of the graph's parse, so a consumer never re-parses the file. + * Phase A: page routing facts (slug/layout/method). Phase B: AST usage facts + * (renders/graphql/filters/tags/translation_keys), always present (empty = none). + */ +describe('Self-structural: page routing + AST usage facts', () => { + const rootUri = pathUtils.join(fixturesRoot, 'structural'); + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + let graph: AppGraph; + + const structuralOf = (uri: string): ModuleStructural | undefined => { + const module = graph.modules[uri]; + assert(module); + assert(module.type === ModuleType.Liquid); + return module.structural; + }; + + /** The empty usage arrays — spread into expectations so each asserts the whole object. */ + const NO_USAGE = { + renders_used: [], + graphql_queries_used: [], + filters_used: [], + tags_used: [], + translation_keys: [], + doc_params: [], + }; + + beforeAll(async () => { + graph = await buildAppGraph(rootUri, getDependencies()); + }, 15000); + + it('derives slug from the page path and carries declared layout + method + its renders', () => { + expect(structuralOf(p('app/views/pages/index.liquid'))).toEqual({ + ...NO_USAGE, + renders_used: ['card', 'documented'], + tags_used: ['render'], + slug: '/', + layout: 'application', + method: 'get', + }); + }); + + it('collects `{% doc %}` @param names in declaration order', () => { + // `{% doc %}` is a raw tag (not a LiquidTag), so it does not appear in + // tags_used; its @param names are surfaced via doc_params, in source order. + expect(structuralOf(p('app/views/partials/documented.liquid'))).toEqual({ + ...NO_USAGE, + doc_params: ['title', 'count'], + }); + }); + + it('derives the slug from the path for a page with no frontmatter and no usage', () => { + expect(structuralOf(p('app/views/pages/about.liquid'))).toEqual({ + ...NO_USAGE, + slug: 'about', + }); + }); + + it('uses the frontmatter slug override verbatim (not the path)', () => { + expect(structuralOf(p('app/views/pages/blog/show.liquid'))).toEqual({ + ...NO_USAGE, + slug: 'blog/custom', + }); + }); + + it('gives a partial all-empty usage arrays and no routing facts', () => { + expect(structuralOf(p('app/views/partials/card.liquid'))).toEqual({ ...NO_USAGE }); + }); + + it('collects every AST usage fact (renders/graphql/filters/tags/translation) sorted + de-duplicated', () => { + expect(structuralOf(p('app/views/pages/rich.liquid'))).toEqual({ + renders_used: ['card'], + graphql_queries_used: ['blog/find'], + filters_used: ['t', 'upcase'], + tags_used: ['assign', 'graphql', 'if', 'render'], + translation_keys: ['greeting.hello'], + doc_params: [], + slug: 'rich', + layout: 'application', + }); + }); +}); diff --git a/packages/platformos-graph/src/graph/traverse-edges.spec.ts b/packages/platformos-graph/src/graph/traverse-edges.spec.ts index f2d3678f..a14cf9fd 100644 --- a/packages/platformos-graph/src/graph/traverse-edges.spec.ts +++ b/packages/platformos-graph/src/graph/traverse-edges.spec.ts @@ -1,8 +1,9 @@ import { path as pathUtils } from '@platformos/platformos-check-common'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { assert, beforeAll, describe, expect, it } from 'vitest'; import { buildAppGraph } from '../index'; import { AppGraph, + AppModule, Dependencies, LiquidModule, LiquidModuleKind, @@ -12,6 +13,20 @@ import { import { getGraphQLModuleByUri, getPartialModuleByUri } from './module'; import { fixturesRoot, getDependencies } from './test-helpers'; +/** + * A module compared for its EDGE identity. Self-structural (`LiquidModule.structural`, + * TASK-9.3) is a separate concern, pinned exhaustively in `structural.spec.ts`; + * stripping it here keeps these edge tests focused and stable as structural grows. + */ +function edgeIdentity(module: AppModule | undefined): AppModule | undefined { + if (module && module.type === ModuleType.Liquid) { + const copy = { ...module }; + delete copy.structural; + return copy; + } + return module; +} + /** * The exact source range of `snippet` within `source`. Derived from the fixture * text (rather than hard-coded offsets) so the assertion is self-documenting @@ -107,7 +122,7 @@ describe('Graph traversal: {% function %} edges', () => { 'function', ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); - expect(graph.modules[p('app/lib/queries/list.liquid')]).toEqual( + expect(edgeIdentity(graph.modules[p('app/lib/queries/list.liquid')])).toEqual( partialNode(p('app/lib/queries/list.liquid'), true, [edge]), ); }); @@ -132,13 +147,14 @@ describe('Graph traversal: {% graphql %} edges', () => { let indexSource: string; let brokenSource: string; - const graphqlNode = (uri: string, exists: boolean, references: Reference[]) => ({ + const graphqlNode = (uri: string, exists: boolean, references: Reference[], table?: string) => ({ type: ModuleType.GraphQL, kind: 'graphql' as const, uri, exists, dependencies: [], references, + ...(table ? { table } : {}), }); beforeAll(async () => { @@ -157,8 +173,10 @@ describe('Graph traversal: {% graphql %} edges', () => { ['id'], ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); + // The resolved GraphQL node carries the model `table` it targets (the fixture + // operation filters on `table: { value: "blog_post" }`). expect(graph.modules[p('app/graphql/blog_posts/find.graphql')]).toEqual( - graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge]), + graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge], 'blog_post'), ); }); @@ -175,6 +193,30 @@ describe('Graph traversal: {% graphql %} edges', () => { }); }); +describe('Graph traversal: GraphQL node `table` (build-time, both shapes)', () => { + const rootUri = pathUtils.join(fixturesRoot, 'graphql-table'); + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + let graph: AppGraph; + + beforeAll(async () => { + graph = await buildAppGraph(rootUri, getDependencies()); + }, 15000); + + it('records the table for an operation that filters on one', () => { + const node = graph.modules[p('app/graphql/with_table.graphql')]; + assert(node); + assert(node.type === ModuleType.GraphQL); + expect(node.table).toBe('blog_post'); + }); + + it('leaves table undefined for an operation with no table filter', () => { + const node = graph.modules[p('app/graphql/without_table.graphql')]; + assert(node); + assert(node.type === ModuleType.GraphQL); + expect(node.table).toBeUndefined(); + }); +}); + describe('Graph traversal: {% include %} edges', () => { const rootUri = pathUtils.join(fixturesRoot, 'include-edges'); const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); @@ -195,7 +237,7 @@ describe('Graph traversal: {% include %} edges', () => { 'include', ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); - expect(graph.modules[p('app/views/partials/shared/header.liquid')]).toEqual( + expect(edgeIdentity(graph.modules[p('app/views/partials/shared/header.liquid')])).toEqual( partialNode(p('app/views/partials/shared/header.liquid'), true, [edge]), ); }); @@ -224,7 +266,7 @@ describe('Graph traversal: {% background %} edges', () => { ['data'], ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); - expect(graph.modules[p('app/views/partials/jobs/notify.liquid')]).toEqual( + expect(edgeIdentity(graph.modules[p('app/views/partials/jobs/notify.liquid')])).toEqual( partialNode(p('app/views/partials/jobs/notify.liquid'), true, [edge]), ); }); @@ -272,10 +314,14 @@ describe('Graph traversal: module-namespaced targets (modules//public/...) functionEdge, renderEdge, ]); - expect(graph.modules[p('modules/my_module/public/lib/queries/get.liquid')]).toEqual( + expect( + edgeIdentity(graph.modules[p('modules/my_module/public/lib/queries/get.liquid')]), + ).toEqual( partialNode(p('modules/my_module/public/lib/queries/get.liquid'), true, [functionEdge]), ); - expect(graph.modules[p('modules/my_module/public/views/partials/card.liquid')]).toEqual( + expect( + edgeIdentity(graph.modules[p('modules/my_module/public/views/partials/card.liquid')]), + ).toEqual( partialNode(p('modules/my_module/public/views/partials/card.liquid'), true, [renderEdge]), ); }); @@ -322,7 +368,7 @@ describe('Graph traversal: layout-association edges (frontmatter `layout:`)', () 'layout', ); expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]); - expect(graph.modules[p('app/views/layouts/theme.liquid')]).toEqual( + expect(edgeIdentity(graph.modules[p('app/views/layouts/theme.liquid')])).toEqual( layoutNode(p('app/views/layouts/theme.liquid'), true, [edge]), ); }); @@ -340,3 +386,40 @@ describe('Graph traversal: layout-association edges (frontmatter `layout:`)', () ); }); }); + +describe('Graph traversal: schema/CustomModelType nodes (full-build discovery)', () => { + const rootUri = pathUtils.join(fixturesRoot, 'schema-nodes'); + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + let graph: AppGraph; + + beforeAll(async () => { + graph = await buildAppGraph(rootUri, getDependencies()); + }, 15000); + + it('discovers a schema file as a leaf Schema node carrying its table (the YAML name)', () => { + expect(graph.modules[p('app/schema/blog_post.yml')]).toEqual({ + type: ModuleType.Schema, + kind: 'schema', + uri: p('app/schema/blog_post.yml'), + exists: true, + dependencies: [], + references: [], + table: 'blog_post', + }); + }); + + it('leaves table undefined for a schema file with no `name:`', () => { + expect(graph.modules[p('app/schema/no_name.yml')]).toEqual({ + type: ModuleType.Schema, + kind: 'schema', + uri: p('app/schema/no_name.yml'), + exists: true, + dependencies: [], + references: [], + }); + }); + + it('does not make schema files entry points', () => { + expect(graph.entryPoints.map((m) => m.uri)).toEqual([p('app/views/pages/index.liquid')]); + }); +}); diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index 4e0a99af..74dcdaa4 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -1,7 +1,20 @@ import yaml from 'js-yaml'; import { LiquidNamedArgument, NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; -import { SourceCodeType, UriString, visit, Visitor } from '@platformos/platformos-check-common'; -import { containsLiquid, DocumentsLocator } from '@platformos/platformos-common'; +import { + extractDocDefinition, + extractGraphqlTable, + SourceCodeType, + UriString, + visit, + Visitor, +} from '@platformos/platformos-check-common'; +import { + containsLiquid, + DocumentsLocator, + extractRelativePagePath, + formatFromFilePath, + slugFromFilePath, +} from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; import { AugmentedDependencies, @@ -9,6 +22,7 @@ import { AppModule, FileSourceCode, LiquidModule, + ModuleStructural, ModuleType, Range, Reference, @@ -67,7 +81,20 @@ export async function traverseModule( } case ModuleType.GraphQL: { - return; // Leaf node — GraphQL documents have no platformOS dependencies + // Leaf node — GraphQL documents have no platformOS dependencies. We do read + // the source once to record the model `table` it targets (a neutral + // platform fact), reusing check-common's GraphQL parser. + const sourceCode = await deps.getSourceCode(module.uri); + module.table = extractGraphqlTable(sourceCode.source); + return; + } + + case ModuleType.Schema: { + // Leaf node — a custom model type / schema file. Read the source once to + // record its model table name (the YAML `name:`), a neutral platform fact. + const sourceCode = await deps.getSourceCode(module.uri); + module.table = schemaTableName(sourceCode.source); + return; } default: { @@ -82,6 +109,11 @@ async function traverseLiquidModule( deps: AugmentedDependencies, ) { const sourceCode = await deps.getSourceCode(module.uri); + + // Surface the file's own structural declarations as a by-product of the parse + // (TASK-9.3). Absent only when the file could not be parsed. + module.structural = await extractStructural(sourceCode, module.uri); + const references = await resolveLiquidReferences(appGraph, sourceCode, deps); for (const reference of references) { @@ -229,14 +261,9 @@ async function resolveLiquidReferences( // The source range is the whole frontmatter block (tag-level granularity, // like the other edges). YAMLFrontmatter: async (node) => { - let data: unknown; - try { - data = yaml.load(node.body); - } catch { - return; // malformed frontmatter — nothing to resolve - } - if (typeof data !== 'object' || data === null) return; - const layout = (data as Record).layout; + const data = loadFrontmatter(node.body); + if (!data) return; // malformed/empty frontmatter — nothing to resolve + const layout = data.layout; if (typeof layout !== 'string' || layout === '' || containsLiquid(layout)) return; const uri = await documentsLocator.locateOrDefault(rootUri, 'layout', layout); if (!uri) return; @@ -306,6 +333,143 @@ function isStringLiteral( return !isString(node) && node.type === NodeTypes.String; } +/** + * Parse a YAML block (frontmatter body or a schema file) into an object, or + * `undefined` for unparseable / non-object YAML. The single `js-yaml` entry + * point shared by the layout-edge resolver, schema-table extraction, and + * self-structural extraction — so there is one frontmatter/YAML parse path. + */ +function loadFrontmatter(body: string): Record | undefined { + let data: unknown; + try { + data = yaml.load(body); + } catch { + return undefined; + } + return typeof data === 'object' && data !== null ? (data as Record) : undefined; +} + +/** + * The model table name a schema/custom-model-type file declares — its top-level + * YAML `name:`. Returns `undefined` for a missing/non-string `name` or + * unparseable YAML. + */ +function schemaTableName(source: string): string | undefined { + const name = loadFrontmatter(source)?.name; + return typeof name === 'string' && name !== '' ? name : undefined; +} + +/** The YAMLFrontmatter body of a parsed Liquid file, if it has frontmatter. */ +function frontmatterBody(sourceCode: FileSourceCode): string | undefined { + if (sourceCode.type !== SourceCodeType.LiquidHtml) return undefined; + const ast = sourceCode.ast; + if (ast instanceof Error || ast.type !== NodeTypes.Document) return undefined; + const node = ast.children.find((child) => child.type === NodeTypes.YAMLFrontmatter); + return node?.type === NodeTypes.YAMLFrontmatter ? node.body : undefined; +} + +/** + * Extract a Liquid file's own structural declarations (TASK-9.3, phase A: page + * routing facts) as a by-product of the already-parsed AST. Reuses the shared + * `js-yaml` frontmatter parse and platformos-common's slug helpers — never a + * second/bespoke parser. + * + * Usage facts (`renders_used` / `graphql_queries_used` / `filters_used` / + * `tags_used` / `translation_keys`) come from one walk of the already-parsed + * AST and are always present (sorted, de-duplicated; empty = none used). Routing + * facts come from frontmatter: + * - `slug`: the frontmatter `slug` override (verbatim, matching the RouteTable + * source of truth) else the path-derived slug — for page files only. + * - `layout` / `method`: from frontmatter when declared. + * + * Returns `undefined` only when the file could not be parsed (no AST to analyze). + */ +async function extractStructural( + sourceCode: FileSourceCode, + uri: UriString, +): Promise { + if (sourceCode.ast instanceof Error) return undefined; // unparseable — nothing to analyze + + const renders = new Set(); + const graphqlQueries = new Set(); + const filters = new Set(); + const tags = new Set(); + const translationKeys = new Set(); + + await visit(sourceCode.ast, { + RenderMarkup: async (node) => { + if (isStringLiteral(node.partial)) renders.add(node.partial.value); + }, + GraphQLMarkup: async (node) => { + if (isStringLiteral(node.graphql)) graphqlQueries.add(node.graphql.value); + }, + LiquidFilter: async (node) => { + filters.add(node.name); + }, + LiquidTag: async (node) => { + if (typeof node.name === 'string') tags.add(node.name); + }, + // A translation-key usage is a string literal piped through `t`/`translate`, + // e.g. `{{ 'greeting.hello' | t }}` — same detection as the translation check. + LiquidVariable: async (node) => { + if ( + node.expression.type === NodeTypes.String && + node.filters.some((filter) => filter.name === 't' || filter.name === 'translate') + ) { + translationKeys.add(node.expression.value); + } + }, + }); + + // `{% doc %}` @param names — reuse check-common's liquid-doc extractor (no + // second parser); preserve declaration order (the param signature order). + const docDefinition = await extractDocDefinition(uri, sourceCode.ast); + const docParams = (docDefinition.liquidDoc?.parameters ?? []).map((param) => param.name); + + const frontmatter = loadFrontmatterOf(sourceCode); + const layout = typeof frontmatter?.layout === 'string' ? frontmatter.layout : undefined; + const method = typeof frontmatter?.method === 'string' ? frontmatter.method : undefined; + const slug = effectiveSlug(uri, frontmatter); + + const sorted = (set: Set) => [...set].sort((a, b) => a.localeCompare(b)); + + return { + renders_used: sorted(renders), + graphql_queries_used: sorted(graphqlQueries), + filters_used: sorted(filters), + tags_used: sorted(tags), + translation_keys: sorted(translationKeys), + doc_params: docParams, + ...(slug !== undefined ? { slug } : {}), + ...(layout !== undefined ? { layout } : {}), + ...(method !== undefined ? { method } : {}), + }; +} + +/** The parsed frontmatter object of a Liquid file, if it has frontmatter. */ +function loadFrontmatterOf(sourceCode: FileSourceCode): Record | undefined { + const body = frontmatterBody(sourceCode); + return body ? loadFrontmatter(body) : undefined; +} + +/** + * The effective URL slug for `uri`: the frontmatter `slug` override (coerced to + * a string, matching RouteTable) when declared, else the slug derived from the + * page path (reusing `extractRelativePagePath` + `slugFromFilePath`). Returns + * `undefined` for non-page files with no `slug` override. + */ +function effectiveSlug( + uri: UriString, + frontmatter: Record | undefined, +): string | undefined { + if (frontmatter && frontmatter.slug !== undefined && frontmatter.slug !== null) { + return String(frontmatter.slug); + } + const relativePath = extractRelativePagePath(uri); + if (relativePath === null) return undefined; + return slugFromFilePath(relativePath, formatFromFilePath(relativePath)); +} + /** * The names of a call site's named arguments, in source order, or `undefined` * when there are none. Defensive against the parser's documented diff --git a/packages/platformos-graph/src/types.ts b/packages/platformos-graph/src/types.ts index 84a06628..352fcbef 100644 --- a/packages/platformos-graph/src/types.ts +++ b/packages/platformos-graph/src/types.ts @@ -28,7 +28,7 @@ export interface AppGraph { modules: Record; } -export type AppModule = LiquidModule | AssetModule | GraphQLModule; +export type AppModule = LiquidModule | AssetModule | GraphQLModule | SchemaModule; export type FileSourceCode = | LiquidSourceCode @@ -49,8 +49,47 @@ export type SerializableEdge = Reference; export type SerializableNode = Pick; +/** + * A Liquid file's own structural declarations — a by-product of the parse the + * graph already does (TASK-9.3), so consumers need not re-parse the file. + * + * The usage arrays are ALWAYS present (sorted, de-duplicated): an empty array + * means "the file uses none", since the whole AST is analyzed — never "not + * extracted". The routing facts (`slug`/`layout`/`method`) are optional: an + * absent one means "not applicable / not declared" (e.g. a partial has no slug). + * `doc_params` lands in a later phase and will appear here when it does. + */ +export interface ModuleStructural { + /** Partial names rendered via `{% render %}` / `{% include %}` (the literal target names). */ + renders_used: string[]; + /** Operation names invoked via `{% graphql %}` (the literal target names). */ + graphql_queries_used: string[]; + /** Liquid filter names used anywhere in the file. */ + filters_used: string[]; + /** Liquid tag names used anywhere in the file. */ + tags_used: string[]; + /** Translation keys referenced via the `t` / `translate` filter. */ + translation_keys: string[]; + /** `@param` names declared in the file's `{% doc %}` block. */ + doc_params: string[]; + /** + * Effective URL slug: the frontmatter `slug` override if declared, else + * derived from the page's path. Present for page files only. + */ + slug?: string; + /** Declared wrapper layout (frontmatter `layout`), when declared. */ + layout?: string; + /** Declared HTTP method (frontmatter `method`), when declared. */ + method?: string; +} + export interface LiquidModule extends IAppModule { kind: LiquidModuleKind; + /** + * The file's own structural declarations (TASK-9.3), populated during a full + * `buildAppGraph`. Absent when the file declares none of the surfaced facts. + */ + structural?: ModuleStructural; } export interface AssetModule extends IAppModule { @@ -64,6 +103,34 @@ export interface AssetModule extends IAppModule { */ export interface GraphQLModule extends IAppModule { kind: 'graphql'; + /** + * The platformOS model table this operation targets (from its `table` filter), + * when it declares one. Populated during `buildAppGraph` traversal; absent for + * operations with no table filter or that were never resolved on disk. + */ + table?: string; +} + +/** + * A custom model type / schema file (`custom_model_types`/`model_schemas`/ + * `schema` dirs — `PlatformOSFileType.CustomModelType`). A platform primitive, + * modeled as a neutral graph node. A leaf node — schema files have no outgoing + * platformOS dependencies — and not render-reachable, so it is discovered and + * added explicitly during a full `buildAppGraph` (never an entry point). + * + * NOTE (ADR 004): this is a neutral platform fact. The commands/queries + * convention and resource/CRUD completeness that build ON schemas are NOT + * modeled here — they live in the convention overlay (TASK-9.7). + */ +export interface SchemaModule extends IAppModule { + kind: 'schema'; + /** + * The model table name (the schema's top-level `name:`), when declared. Named + * `table` to align with {@link GraphQLModule.table} so a consumer can join a + * GraphQL op to its schema. Absent when the file declares no `name:` or could + * not be parsed. + */ + table?: string; } export interface IAppModule { @@ -99,6 +166,7 @@ export const enum ModuleType { Liquid = 'Liquid', Asset = 'Asset', GraphQL = 'GraphQL', + Schema = 'Schema', } export const enum LiquidModuleKind { From 08aafd8f81f0a405aff5eabdaf4556d0c01812ca Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Wed, 1 Jul 2026 17:41:23 +0200 Subject: [PATCH 07/20] feat: enhance platformos-graph integration with structural extraction - Updated index.ts to export `extractStructural` alongside `extractFileReferences`. - Introduced `GraphBuildOptions` in types.ts to control structural fact inclusion during builds. - Created adapter-input.ts to standardize input for lint and structure adapters. - Refactored lint.ts to utilize the new AdapterInput interface. - Enhanced assembleResult to include structural snapshots in the result. - Updated structure.ts to derive both dependencies and structural data from the same parse. - Added tests for validate-code to ensure lint and structure orchestration works as expected. - Improved error handling in validate-code to log structure resolution failures without dropping lint diagnostics. --- .../checks/translation-key-exists/index.ts | 7 +- packages/platformos-check-common/src/index.ts | 10 ++ .../src/schema-table.spec.ts | 60 +++++++ .../src/schema-table.ts | 30 ++++ .../src/translation-usage.ts | 23 +++ .../src/route-table/RouteTable.ts | 11 +- .../src/route-table/index.ts | 7 +- .../src/route-table/slugFromFilePath.spec.ts | 54 ++++++- .../src/route-table/slugFromFilePath.ts | 28 ++++ packages/platformos-graph/src/graph/build.ts | 69 ++++---- .../platformos-graph/src/graph/module.spec.ts | 53 +++++++ packages/platformos-graph/src/graph/module.ts | 11 +- .../src/graph/structural.spec.ts | 85 +++++++++- .../platformos-graph/src/graph/traverse.ts | 147 +++++++++--------- packages/platformos-graph/src/index.ts | 2 +- packages/platformos-graph/src/types.ts | 12 ++ .../src/adapter-input.ts | 25 +++ .../src/lint/lint.ts | 16 +- .../src/result/assemble.spec.ts | 40 ++++- .../src/result/assemble.ts | 13 +- .../src/result/types.ts | 33 +++- .../src/structure/structure.spec.ts | 107 ++++++++----- .../src/structure/structure.ts | 98 +++++++++--- .../src/transport/validate-code.spec.ts | 126 +++++++++++++++ .../src/transport/validate-code.ts | 40 +++-- .../test/integration/stdio-smoke.spec.ts | 30 +++- 26 files changed, 913 insertions(+), 224 deletions(-) create mode 100644 packages/platformos-check-common/src/schema-table.spec.ts create mode 100644 packages/platformos-check-common/src/schema-table.ts create mode 100644 packages/platformos-check-common/src/translation-usage.ts create mode 100644 packages/platformos-graph/src/graph/module.spec.ts create mode 100644 packages/platformos-mcp-supervisor/src/adapter-input.ts create mode 100644 packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts diff --git a/packages/platformos-check-common/src/checks/translation-key-exists/index.ts b/packages/platformos-check-common/src/checks/translation-key-exists/index.ts index 97ca2138..4ec61403 100644 --- a/packages/platformos-check-common/src/checks/translation-key-exists/index.ts +++ b/packages/platformos-check-common/src/checks/translation-key-exists/index.ts @@ -1,5 +1,6 @@ import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { findNearestKeys } from '../../utils/levenshtein'; +import { isTranslationKeyUsage } from '../../translation-usage'; import { loadAllDefinedKeys } from '../translation-utils'; export const TranslationKeyExists: LiquidCheckDefinition = { @@ -22,11 +23,7 @@ export const TranslationKeyExists: LiquidCheckDefinition = { return { async LiquidVariable(node) { - if (node.expression.type !== 'String') { - return; - } - - if (!node.filters.some(({ name }) => ['t', 'translate'].includes(name))) { + if (!isTranslationKeyUsage(node)) { return; } diff --git a/packages/platformos-check-common/src/index.ts b/packages/platformos-check-common/src/index.ts index 835cf845..6b138e30 100644 --- a/packages/platformos-check-common/src/index.ts +++ b/packages/platformos-check-common/src/index.ts @@ -58,6 +58,16 @@ export { levenshtein } from './utils'; // re-implementing GraphQL parsing (the `graphql` dep lives here). export { extractGraphqlTable } from './graphql-table'; +// `extractSchemaTable` reads a custom-model-type/schema file's declared `name:` +// (its table); exported alongside `extractGraphqlTable` so the graph and other +// consumers extract this platform fact from the package that owns the parsers. +export { extractSchemaTable } from './schema-table'; + +// `isTranslationKeyUsage` is the shared predicate for "a string literal piped +// through `t`/`translate`"; exported so the graph's self-structural detects +// translation keys with the SAME rule as the `TranslationKeyExists` check. +export { isTranslationKeyUsage, TRANSLATION_FILTERS } from './translation-usage'; + export * from './AugmentedPlatformOSDocset'; export * from './types/platformos-liquid-docs'; export * from './checks'; diff --git a/packages/platformos-check-common/src/schema-table.spec.ts b/packages/platformos-check-common/src/schema-table.spec.ts new file mode 100644 index 00000000..709cfb98 --- /dev/null +++ b/packages/platformos-check-common/src/schema-table.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { extractSchemaTable } from './schema-table'; + +describe('extractSchemaTable', () => { + it('extracts the top-level `name:` as the table', () => { + const content = `name: blog_post +properties: + title: + type: string`; + expect(extractSchemaTable(content)).toBe('blog_post'); + }); + + it('extracts `name:` regardless of key order', () => { + const content = `properties: + title: + type: string +name: product`; + expect(extractSchemaTable(content)).toBe('product'); + }); + + it('returns undefined when no `name:` is declared', () => { + const content = `properties: + title: + type: string`; + expect(extractSchemaTable(content)).toBeUndefined(); + }); + + it('returns undefined for an empty `name:`', () => { + expect(extractSchemaTable('name: ""')).toBeUndefined(); + }); + + it('returns undefined for a non-string `name:` (list)', () => { + const content = `name: + - a + - b`; + expect(extractSchemaTable(content)).toBeUndefined(); + }); + + it('returns undefined for a non-string `name:` (mapping)', () => { + const content = `name: + en: blog_post`; + expect(extractSchemaTable(content)).toBeUndefined(); + }); + + it('coerces nothing — a numeric `name:` is not a string, so undefined', () => { + expect(extractSchemaTable('name: 123')).toBeUndefined(); + }); + + it('returns undefined for unparseable YAML', () => { + expect(extractSchemaTable('name: : : not valid')).toBeUndefined(); + }); + + it('returns undefined for YAML that is not a mapping (a bare scalar)', () => { + expect(extractSchemaTable('just a string')).toBeUndefined(); + }); + + it('returns undefined for empty content', () => { + expect(extractSchemaTable('')).toBeUndefined(); + }); +}); diff --git a/packages/platformos-check-common/src/schema-table.ts b/packages/platformos-check-common/src/schema-table.ts new file mode 100644 index 00000000..538106dc --- /dev/null +++ b/packages/platformos-check-common/src/schema-table.ts @@ -0,0 +1,30 @@ +import { load } from 'js-yaml'; + +/** + * Extract the model table name a platformOS custom model type / schema file + * declares — its top-level YAML `name:`, e.g. + * + * ```yaml + * name: blog_post + * properties: + * title: + * type: string + * ``` + * + * Named to mirror {@link extractGraphqlTable} so a consumer can join a GraphQL + * operation to the schema it targets. Reuses the `js-yaml` parser this package + * already owns rather than a regex. + * + * Returns `undefined` for a missing/empty/non-string `name` or unparseable YAML. + */ +export function extractSchemaTable(content: string): string | undefined { + let data: unknown; + try { + data = load(content); + } catch { + return undefined; // not valid YAML — nothing to extract + } + if (typeof data !== 'object' || data === null) return undefined; + const name = (data as Record).name; + return typeof name === 'string' && name !== '' ? name : undefined; +} diff --git a/packages/platformos-check-common/src/translation-usage.ts b/packages/platformos-check-common/src/translation-usage.ts new file mode 100644 index 00000000..bc6f4fbd --- /dev/null +++ b/packages/platformos-check-common/src/translation-usage.ts @@ -0,0 +1,23 @@ +import { LiquidString, LiquidVariable, NodeTypes } from '@platformos/liquid-html-parser'; + +/** The filter names that mark a string literal as a translation-key lookup. */ +export const TRANSLATION_FILTERS = ['t', 'translate'] as const; + +/** + * Whether a `LiquidVariable` output is a translation-key usage: a string literal + * piped through a `t` / `translate` filter, e.g. `{{ 'greeting.hello' | t }}`. + * + * The single source of truth for "what counts as a translation-key usage", + * shared by the `TranslationKeyExists` check and the dependency graph's + * self-structural extraction, so the two cannot drift. Narrows `expression` to + * `LiquidString` so the caller can read the key from `node.expression.value` + * (and its `.position`). + */ +export function isTranslationKeyUsage( + node: LiquidVariable, +): node is LiquidVariable & { expression: LiquidString } { + return ( + node.expression.type === NodeTypes.String && + node.filters.some(({ name }) => (TRANSLATION_FILTERS as readonly string[]).includes(name)) + ); +} diff --git a/packages/platformos-common/src/route-table/RouteTable.ts b/packages/platformos-common/src/route-table/RouteTable.ts index 49b9fe3e..989273a7 100644 --- a/packages/platformos-common/src/route-table/RouteTable.ts +++ b/packages/platformos-common/src/route-table/RouteTable.ts @@ -3,7 +3,7 @@ import { URI, Utils } from 'vscode-uri'; import { AbstractFileSystem, FileType } from '../AbstractFileSystem'; import { getAppPaths, getModulePaths, PlatformOSFileType } from '../path-utils'; import { RouteEntry, RouteSegment } from './types'; -import { slugFromFilePath, formatFromFilePath, KNOWN_FORMATS } from './slugFromFilePath'; +import { formatFromFilePath, effectivePageSlug, KNOWN_FORMATS } from './slugFromFilePath'; import { parseSlug, calculatePrecedence } from './parseSlug'; interface PageFrontmatter { @@ -348,12 +348,9 @@ export class RouteTable { const method = (frontmatter?.method || 'get').toLowerCase(); const format = frontmatter?.format || fileFormat; - let slug: string; - if (frontmatter?.slug !== undefined && frontmatter.slug !== null) { - slug = String(frontmatter.slug); - } else { - slug = slugFromFilePath(relativePath, format); - } + // Slug derivation (override-wins, else path-derived) is shared with every + // other consumer via `effectivePageSlug`, so it cannot drift. + const slug = effectivePageSlug(relativePath, frontmatter); const entries: RouteEntry[] = []; diff --git a/packages/platformos-common/src/route-table/index.ts b/packages/platformos-common/src/route-table/index.ts index f9d85c75..8432f78c 100644 --- a/packages/platformos-common/src/route-table/index.ts +++ b/packages/platformos-common/src/route-table/index.ts @@ -1,5 +1,10 @@ export { RouteTable, extractRelativePagePath } from './RouteTable'; -export { slugFromFilePath, formatFromFilePath, KNOWN_FORMATS } from './slugFromFilePath'; +export { + slugFromFilePath, + formatFromFilePath, + effectivePageSlug, + KNOWN_FORMATS, +} from './slugFromFilePath'; export { parseSlug, calculatePrecedence } from './parseSlug'; export type { RouteEntry, RouteSegment } from './types'; export type { ParsedSlug } from './parseSlug'; diff --git a/packages/platformos-common/src/route-table/slugFromFilePath.spec.ts b/packages/platformos-common/src/route-table/slugFromFilePath.spec.ts index 722dab08..7bc48c5e 100644 --- a/packages/platformos-common/src/route-table/slugFromFilePath.spec.ts +++ b/packages/platformos-common/src/route-table/slugFromFilePath.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { slugFromFilePath, formatFromFilePath } from './slugFromFilePath'; +import { slugFromFilePath, formatFromFilePath, effectivePageSlug } from './slugFromFilePath'; describe('slugFromFilePath', () => { it('derives / from index.html.liquid', () => { @@ -82,3 +82,55 @@ describe('formatFromFilePath', () => { expect(formatFromFilePath('index.liquid')).toBe('html'); }); }); + +describe('effectivePageSlug', () => { + it('derives the slug from the path when no frontmatter is present', () => { + expect(effectivePageSlug('about.html.liquid')).toBe('about'); + }); + + it('derives / from index.liquid with no frontmatter', () => { + expect(effectivePageSlug('index.liquid')).toBe('/'); + }); + + it('uses an explicit string slug override verbatim, not the path', () => { + expect(effectivePageSlug('about.liquid', { slug: 'custom/path' })).toBe('custom/path'); + }); + + it('honours an empty-string slug override verbatim', () => { + expect(effectivePageSlug('about.liquid', { slug: '' })).toBe(''); + }); + + it('coerces a numeric slug override to a string (YAML may parse it as a number)', () => { + expect(effectivePageSlug('about.liquid', { slug: 2024 })).toBe('2024'); + }); + + it('coerces a boolean slug override to a string', () => { + expect(effectivePageSlug('about.liquid', { slug: false })).toBe('false'); + }); + + it('falls back to the path-derived slug when the override is null', () => { + expect(effectivePageSlug('about.liquid', { slug: null })).toBe('about'); + }); + + it('falls back to the path-derived slug when the override is undefined', () => { + expect(effectivePageSlug('about.liquid', { slug: undefined })).toBe('about'); + }); + + it('derives the slug using the frontmatter `format` override when present', () => { + // The filename has no format extension, so the file-derived format is html; + // the frontmatter override selects json, which changes nothing here but is + // the value threaded to slugFromFilePath (mirrors RouteTable). + expect(effectivePageSlug('api/data.liquid', { format: 'json' })).toBe('api/data'); + }); + + it('strips the format extension using the frontmatter `format` override', () => { + // `data.json` under the pages dir with a `json` format override strips + // `.json`, yielding `data` — proving the override, not the filename, drives + // the format used for stripping. + expect(effectivePageSlug('data.json', { format: 'json' })).toBe('data'); + }); + + it('ignores a non-string frontmatter `format` and uses the file-derived format', () => { + expect(effectivePageSlug('api/data.json.liquid', { format: 123 })).toBe('api/data'); + }); +}); diff --git a/packages/platformos-common/src/route-table/slugFromFilePath.ts b/packages/platformos-common/src/route-table/slugFromFilePath.ts index 6db22da9..e234ae3c 100644 --- a/packages/platformos-common/src/route-table/slugFromFilePath.ts +++ b/packages/platformos-common/src/route-table/slugFromFilePath.ts @@ -38,6 +38,34 @@ export function slugFromFilePath(relativeToPages: string, format: string = 'html return slug; } +/** + * The effective URL slug for a page, given its path relative to the pages + * directory and its parsed frontmatter — the single source of truth shared by + * {@link RouteTable} and any consumer that needs a page's routing slug (e.g. the + * dependency graph's self-structural). + * + * The rule (ported from the platform): an explicit frontmatter `slug` override + * wins verbatim (coerced to a string, since YAML may parse it as a non-string); + * otherwise the slug is derived from the path using the effective format (a + * frontmatter `format` override, else the format read from the filename). + * + * `frontmatter` fields are typed `unknown` because callers pass loosely-parsed + * YAML; only string values are honoured (matching RouteTable's behaviour). + */ +export function effectivePageSlug( + relativeToPages: string, + frontmatter?: { slug?: unknown; format?: unknown } | null, +): string { + if (frontmatter?.slug !== undefined && frontmatter.slug !== null) { + return String(frontmatter.slug); + } + const format = + typeof frontmatter?.format === 'string' && frontmatter.format + ? frontmatter.format + : formatFromFilePath(relativeToPages); + return slugFromFilePath(relativeToPages, format); +} + /** * Known response formats supported by the platformOS engine. * Derived from the platform's FORMAT_ENUM. diff --git a/packages/platformos-graph/src/graph/build.ts b/packages/platformos-graph/src/graph/build.ts index e8ae7468..fd2a7887 100644 --- a/packages/platformos-graph/src/graph/build.ts +++ b/packages/platformos-graph/src/graph/build.ts @@ -7,30 +7,58 @@ import { PlatformOSFileType, UriString, } from '@platformos/platformos-check-common'; -import { IDependencies, AppGraph, AppModule } from '../types'; +import { IDependencies, AppGraph, AppModule, GraphBuildOptions } from '../types'; import { augmentDependencies } from './augment'; import { getModule, getSchemaModule } from './module'; import { traverseModule } from './traverse'; +/** + * Build the project dependency graph. + * + * - `entryPoints` omitted → FULL build: a single directory sweep discovers pages + * + layouts (the render entry points) and standalone custom-model-type/schema + * nodes, and every reachable module is materialized. + * - `entryPoints` provided → SCOPED build (e.g. an LSP rebuild for changed + * files): built verbatim from those roots. Schema nodes are NOT auto-discovered + * in this mode — schema discovery is a full-build concern. + * + * `options.includeStructural` (default off) additionally populates each + * `LiquidModule.structural`; see {@link GraphBuildOptions}. + */ export async function buildAppGraph( rootUri: UriString, ideps: IDependencies, entryPoints?: UriString[], + options: GraphBuildOptions = {}, ): Promise { const deps = augmentDependencies(rootUri, ideps); - // An explicit entryPoints scope is built verbatim (e.g. a scoped LSP rebuild); - // the default full build also discovers standalone schema nodes below. - const isFullBuild = entryPoints === undefined; + // Schema/custom-model-type files are platform nodes but are NOT render-reachable + // (nothing renders them), so they never appear via edge traversal. On a full + // build, discover them as standalone leaf nodes — never entry points, so + // reachability/orphan semantics for the render graph are unaffected. + let schemaUris: UriString[] = []; - entryPoints = - entryPoints ?? - (await findAllFiles(deps.fs, rootUri, ([uri]) => { - if (!uri.endsWith('.liquid')) return false; - // Layouts are entry points — they wrap all page content. - // Pages are also entry points — they are directly requested. - return isLayout(uri) || isPage(uri); - })); + // An explicit entryPoints scope is built verbatim (e.g. a scoped LSP rebuild); + // the default full build (`entryPoints === undefined`) also discovers + // standalone schema nodes. Branching on the parameter directly lets the + // compiler narrow it to a defined list below. + if (entryPoints === undefined) { + // A SINGLE directory sweep yields both the render entry points + // (pages + layouts) and the standalone schema nodes, partitioned by + // extension below — avoiding a second full-tree walk. + const discovered = await findAllFiles(deps.fs, rootUri, ([uri]) => { + // Layouts wrap all page content; pages are directly requested — both are + // entry points. + if (uri.endsWith('.liquid')) return isLayout(uri) || isPage(uri); + if (uri.endsWith('.yml') || uri.endsWith('.yaml')) { + return getFileType(uri) === PlatformOSFileType.CustomModelType; + } + return false; + }); + entryPoints = discovered.filter((uri) => uri.endsWith('.liquid')); + schemaUris = discovered.filter((uri) => uri.endsWith('.yml') || uri.endsWith('.yaml')); + } const graph: AppGraph = { entryPoints: [], @@ -42,22 +70,11 @@ export async function buildAppGraph( .map((uri) => getModule(graph, uri)) .filter((x): x is AppModule => x !== undefined); - await Promise.all(graph.entryPoints.map((entry) => traverseModule(entry, graph, deps))); + await Promise.all(graph.entryPoints.map((entry) => traverseModule(entry, graph, deps, options))); - // Schema/custom-model-type files are platform nodes but are NOT render-reachable - // (nothing renders them), so they never appear via edge traversal. On a full - // build, discover and add them as standalone leaf nodes — never entry points, - // so reachability/orphan semantics for the render graph are unaffected. - if (isFullBuild) { - const schemaUris = await findAllFiles( - deps.fs, - rootUri, - ([uri]) => - (uri.endsWith('.yml') || uri.endsWith('.yaml')) && - getFileType(uri) === PlatformOSFileType.CustomModelType, - ); + if (schemaUris.length > 0) { await Promise.all( - schemaUris.map((uri) => traverseModule(getSchemaModule(graph, uri), graph, deps)), + schemaUris.map((uri) => traverseModule(getSchemaModule(graph, uri), graph, deps, options)), ); } diff --git a/packages/platformos-graph/src/graph/module.spec.ts b/packages/platformos-graph/src/graph/module.spec.ts new file mode 100644 index 00000000..fb16205d --- /dev/null +++ b/packages/platformos-graph/src/graph/module.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { AppGraph } from '../types'; +import { + getLayoutModule, + getLayoutModuleByUri, + getPageModule, + getPartialModuleByUri, +} from './module'; + +/** + * Node-identity invariant (code-review F10): a file must resolve to ONE module + * regardless of which producer creates it. Entry-point factories + * (getLayoutModule/getPageModule) take a URI straight from directory discovery, + * while edge-target factories (getLayoutModuleByUri/getPartialModuleByUri) take a + * DocumentsLocator-resolved URI; both normalize, so a Windows-style backslash + * URI from one and a forward-slash URI from the other key to the SAME cached + * node — never a split identity that would drop an incoming edge. + */ +describe('module factories: normalized node identity', () => { + const newGraph = (): AppGraph => ({ rootUri: 'file:///project', entryPoints: [], modules: {} }); + + it('dedupes an entry-point layout with the same layout resolved as an edge target', () => { + const graph = newGraph(); + // As if discovered by directory traversal on Windows (backslashes)… + const entry = getLayoutModule(graph, 'file:///project\\app\\views\\layouts\\theme.liquid'); + // …and as if resolved from a page's frontmatter `layout:` (forward slashes). + const edgeTarget = getLayoutModuleByUri( + graph, + 'file:///project/app/views/layouts/theme.liquid', + ); + + expect(entry).toBe(edgeTarget); // same cached object — one node + expect(entry?.uri).toEqual('file:///project/app/views/layouts/theme.liquid'); + }); + + it('dedupes an entry-point page regardless of separator style', () => { + const graph = newGraph(); + const first = getPageModule(graph, 'file:///project\\app\\views\\pages\\index.liquid'); + const second = getPageModule(graph, 'file:///project/app/views/pages/index.liquid'); + + expect(first).toBe(second); + expect(first.uri).toEqual('file:///project/app/views/pages/index.liquid'); + }); + + it('dedupes a partial across separator styles too (regression guard for the shared contract)', () => { + const graph = newGraph(); + const a = getPartialModuleByUri(graph, 'file:///project\\app\\views\\partials\\card.liquid'); + const b = getPartialModuleByUri(graph, 'file:///project/app/views/partials/card.liquid'); + + expect(a).toBe(b); + expect(a.uri).toEqual('file:///project/app/views/partials/card.liquid'); + }); +}); diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index 73227e2b..c9304f99 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -153,7 +153,12 @@ export function getLayoutModule( return module(appGraph, { type: ModuleType.Liquid, kind: LiquidModuleKind.Layout, - uri: layoutUri, + // Normalize so a layout discovered as an entry point keys identically to the + // same file resolved as a frontmatter `layout:` edge target (which comes + // through getLayoutModuleByUri, also normalized) — one node, never a split + // identity. See getPartialModuleByUri for why DocumentsLocator/fs URIs must + // be normalized. + uri: path.normalize(layoutUri), dependencies: [], references: [], }); @@ -181,7 +186,9 @@ export function getPageModule(appGraph: AppGraph, pageUri: string): LiquidModule return module(appGraph, { type: ModuleType.Liquid, kind: LiquidModuleKind.Page, - uri: pageUri, + // Normalize for the same node-identity reason as getLayoutModule / the other + // factories (see getPartialModuleByUri). + uri: path.normalize(pageUri), dependencies: [], references: [], }); diff --git a/packages/platformos-graph/src/graph/structural.spec.ts b/packages/platformos-graph/src/graph/structural.spec.ts index e0461a8d..124ea847 100644 --- a/packages/platformos-graph/src/graph/structural.spec.ts +++ b/packages/platformos-graph/src/graph/structural.spec.ts @@ -1,14 +1,18 @@ import { path as pathUtils } from '@platformos/platformos-check-common'; import { assert, beforeAll, describe, expect, it } from 'vitest'; -import { buildAppGraph } from '../index'; +import { URI } from 'vscode-uri'; +import { buildAppGraph, extractStructural, toSourceCode } from '../index'; import { AppGraph, LiquidModule, ModuleStructural, ModuleType } from '../types'; import { getDependencies, fixturesRoot } from './test-helpers'; /** - * TASK-9.3: a Liquid file's own structural declarations surfaced on its module - * as a by-product of the graph's parse, so a consumer never re-parses the file. - * Phase A: page routing facts (slug/layout/method). Phase B: AST usage facts - * (renders/graphql/filters/tags/translation_keys), always present (empty = none). + * TASK-9.3 (+ code-review F1): a Liquid file's own structural declarations. + * + * `extractStructural` is the per-file primitive (sibling to + * `extractFileReferences`). A full `buildAppGraph` populates + * `LiquidModule.structural` from it ONLY when `{ includeStructural: true }` is + * passed — the opt-in that keeps a full build (e.g. the LSP's) from computing a + * fact nothing reads. */ describe('Self-structural: page routing + AST usage facts', () => { const rootUri = pathUtils.join(fixturesRoot, 'structural'); @@ -33,7 +37,7 @@ describe('Self-structural: page routing + AST usage facts', () => { }; beforeAll(async () => { - graph = await buildAppGraph(rootUri, getDependencies()); + graph = await buildAppGraph(rootUri, getDependencies(), undefined, { includeStructural: true }); }, 15000); it('derives slug from the page path and carries declared layout + method + its renders', () => { @@ -87,3 +91,72 @@ describe('Self-structural: page routing + AST usage facts', () => { }); }); }); + +describe('Self-structural: the includeStructural opt-in', () => { + const rootUri = pathUtils.join(fixturesRoot, 'structural'); + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + + const liquidModule = (graph: AppGraph, uri: string): LiquidModule => { + const module = graph.modules[uri]; + assert(module); + assert(module.type === ModuleType.Liquid); + return module; + }; + + it('does NOT populate `structural` on a default build (the LSP opt-out)', async () => { + const graph = await buildAppGraph(rootUri, getDependencies()); + expect(liquidModule(graph, p('app/views/pages/index.liquid')).structural).toBeUndefined(); + }); + + it('populates `structural` only when opted in', async () => { + const graph = await buildAppGraph(rootUri, getDependencies(), undefined, { + includeStructural: true, + }); + expect(liquidModule(graph, p('app/views/pages/index.liquid')).structural).toEqual({ + renders_used: ['card', 'documented'], + graphql_queries_used: [], + filters_used: [], + tags_used: ['render'], + translation_keys: [], + doc_params: [], + slug: '/', + layout: 'application', + method: 'get', + }); + }); +}); + +describe('extractStructural: the per-file primitive', () => { + const rootUri = pathUtils.join(fixturesRoot, 'structural'); + const uriOf = (part: string) => URI.file(pathUtils.join(rootUri, ...part.split('/'))).toString(); + + const run = async (part: string, content: string): Promise => { + const uri = uriOf(part); + return extractStructural(await toSourceCode(uri, content), uri); + }; + + it('extracts usage + routing facts from an in-flight buffer (no graph build)', async () => { + const content = `--- +layout: theme +method: post +--- +{% render 'card' %} +{{ 'greeting.hi' | t }} +{{ title | upcase }}`; + expect(await run('app/views/pages/contact.liquid', content)).toEqual({ + renders_used: ['card'], + graphql_queries_used: [], + filters_used: ['t', 'upcase'], + tags_used: ['render'], + translation_keys: ['greeting.hi'], + doc_params: [], + slug: 'contact', + layout: 'theme', + method: 'post', + }); + }); + + it('returns undefined for a non-Liquid (.graphql) buffer', async () => { + expect(await run('app/graphql/find.graphql', 'query find { records { id } }')).toBeUndefined(); + }); +}); diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index 74dcdaa4..f685a906 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -1,8 +1,9 @@ import yaml from 'js-yaml'; import { LiquidNamedArgument, NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; import { - extractDocDefinition, extractGraphqlTable, + extractSchemaTable, + isTranslationKeyUsage, SourceCodeType, UriString, visit, @@ -11,9 +12,8 @@ import { import { containsLiquid, DocumentsLocator, + effectivePageSlug, extractRelativePagePath, - formatFromFilePath, - slugFromFilePath, } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; import { @@ -21,6 +21,7 @@ import { AppGraph, AppModule, FileSourceCode, + GraphBuildOptions, LiquidModule, ModuleStructural, ModuleType, @@ -53,6 +54,7 @@ export async function traverseModule( module: AppModule, appGraph: AppGraph, deps: AugmentedDependencies, + options: GraphBuildOptions = {}, ): Promise { // If the module is already traversed, skip it if (appGraph.modules[module.uri]) { @@ -73,7 +75,7 @@ export async function traverseModule( switch (module.type) { case ModuleType.Liquid: { - return traverseLiquidModule(module, appGraph, deps); + return traverseLiquidModule(module, appGraph, deps, options); } case ModuleType.Asset: { @@ -93,7 +95,7 @@ export async function traverseModule( // Leaf node — a custom model type / schema file. Read the source once to // record its model table name (the YAML `name:`), a neutral platform fact. const sourceCode = await deps.getSourceCode(module.uri); - module.table = schemaTableName(sourceCode.source); + module.table = extractSchemaTable(sourceCode.source); return; } @@ -107,12 +109,16 @@ async function traverseLiquidModule( module: LiquidModule, appGraph: AppGraph, deps: AugmentedDependencies, + options: GraphBuildOptions, ) { const sourceCode = await deps.getSourceCode(module.uri); // Surface the file's own structural declarations as a by-product of the parse - // (TASK-9.3). Absent only when the file could not be parsed. - module.structural = await extractStructural(sourceCode, module.uri); + // (TASK-9.3) — only when the caller opted in (see GraphBuildOptions). Absent + // otherwise, and when the file could not be parsed. + if (options.includeStructural) { + module.structural = await extractStructural(sourceCode, module.uri); + } const references = await resolveLiquidReferences(appGraph, sourceCode, deps); @@ -125,7 +131,7 @@ async function traverseLiquidModule( } const modules = unique(references.map((ref) => ref.target)); - const promises = modules.map((mod) => traverseModule(mod, appGraph, deps)); + const promises = modules.map((mod) => traverseModule(mod, appGraph, deps, options)); return Promise.all(promises); } @@ -316,8 +322,7 @@ export async function extractFileReferences( target: { uri: reference.target.uri }, type: 'direct' as const, kind: reference.kind, - // Only carry `args` when the call site has named arguments (parity with bind). - ...(reference.args && reference.args.length > 0 ? { args: reference.args } : {}), + ...argsField(reference.args), })); } @@ -350,52 +355,44 @@ function loadFrontmatter(body: string): Record | undefined { } /** - * The model table name a schema/custom-model-type file declares — its top-level - * YAML `name:`. Returns `undefined` for a missing/non-string `name` or - * unparseable YAML. - */ -function schemaTableName(source: string): string | undefined { - const name = loadFrontmatter(source)?.name; - return typeof name === 'string' && name !== '' ? name : undefined; -} - -/** The YAMLFrontmatter body of a parsed Liquid file, if it has frontmatter. */ -function frontmatterBody(sourceCode: FileSourceCode): string | undefined { - if (sourceCode.type !== SourceCodeType.LiquidHtml) return undefined; - const ast = sourceCode.ast; - if (ast instanceof Error || ast.type !== NodeTypes.Document) return undefined; - const node = ast.children.find((child) => child.type === NodeTypes.YAMLFrontmatter); - return node?.type === NodeTypes.YAMLFrontmatter ? node.body : undefined; -} - -/** - * Extract a Liquid file's own structural declarations (TASK-9.3, phase A: page - * routing facts) as a by-product of the already-parsed AST. Reuses the shared - * `js-yaml` frontmatter parse and platformos-common's slug helpers — never a - * second/bespoke parser. + * Extract a Liquid file's own structural declarations from an already-parsed + * source (TASK-9.3) — the per-file primitive (sibling to + * {@link extractFileReferences}). Reuses the shared `js-yaml` frontmatter parse, + * platformos-common's slug helpers, and check-common's liquid-doc/translation + * detection — never a second/bespoke parser. * * Usage facts (`renders_used` / `graphql_queries_used` / `filters_used` / - * `tags_used` / `translation_keys`) come from one walk of the already-parsed - * AST and are always present (sorted, de-duplicated; empty = none used). Routing - * facts come from frontmatter: + * `tags_used` / `translation_keys`) come from one walk of the parsed AST and are + * always present (sorted, de-duplicated; empty = none used); `doc_params` is in + * source (signature) order. Routing facts come from frontmatter: * - `slug`: the frontmatter `slug` override (verbatim, matching the RouteTable * source of truth) else the path-derived slug — for page files only. * - `layout` / `method`: from frontmatter when declared. * - * Returns `undefined` only when the file could not be parsed (no AST to analyze). + * Returns `undefined` for a non-Liquid source or one that could not be parsed + * (no Liquid AST to analyze), so it is safe to call on any {@link FileSourceCode}. */ -async function extractStructural( +export async function extractStructural( sourceCode: FileSourceCode, uri: UriString, ): Promise { - if (sourceCode.ast instanceof Error) return undefined; // unparseable — nothing to analyze + // Only a parsed Liquid AST has the structure this analyzes; a `.graphql`/`.yml` + // buffer (or an unparseable one) yields no structural facts. + if (sourceCode.type !== SourceCodeType.LiquidHtml || sourceCode.ast instanceof Error) { + return undefined; + } const renders = new Set(); const graphqlQueries = new Set(); const filters = new Set(); const tags = new Set(); const translationKeys = new Set(); + // `{% doc %}` @param names, in source (signature) order — not sorted/de-duped. + const docParams: string[] = []; + // A single walk of the already-parsed AST collects every usage fact — no + // second traversal (the doc `@param` names are read from the parser-produced + // `LiquidDocParamNode`s in this same pass, as `extractDocDefinition` would). await visit(sourceCode.ast, { RenderMarkup: async (node) => { if (isStringLiteral(node.partial)) renders.add(node.partial.value); @@ -410,22 +407,16 @@ async function extractStructural( if (typeof node.name === 'string') tags.add(node.name); }, // A translation-key usage is a string literal piped through `t`/`translate`, - // e.g. `{{ 'greeting.hello' | t }}` — same detection as the translation check. + // e.g. `{{ 'greeting.hello' | t }}` — detected via check-common's shared + // `isTranslationKeyUsage` so it cannot drift from the translation check. LiquidVariable: async (node) => { - if ( - node.expression.type === NodeTypes.String && - node.filters.some((filter) => filter.name === 't' || filter.name === 'translate') - ) { - translationKeys.add(node.expression.value); - } + if (isTranslationKeyUsage(node)) translationKeys.add(node.expression.value); + }, + LiquidDocParamNode: async (node) => { + docParams.push(node.paramName.value); }, }); - // `{% doc %}` @param names — reuse check-common's liquid-doc extractor (no - // second parser); preserve declaration order (the param signature order). - const docDefinition = await extractDocDefinition(uri, sourceCode.ast); - const docParams = (docDefinition.liquidDoc?.parameters ?? []).map((param) => param.name); - const frontmatter = loadFrontmatterOf(sourceCode); const layout = typeof frontmatter?.layout === 'string' ? frontmatter.layout : undefined; const method = typeof frontmatter?.method === 'string' ? frontmatter.method : undefined; @@ -446,43 +437,51 @@ async function extractStructural( }; } -/** The parsed frontmatter object of a Liquid file, if it has frontmatter. */ +/** The parsed frontmatter object of a Liquid file, if it has parseable frontmatter. */ function loadFrontmatterOf(sourceCode: FileSourceCode): Record | undefined { - const body = frontmatterBody(sourceCode); - return body ? loadFrontmatter(body) : undefined; + if (sourceCode.type !== SourceCodeType.LiquidHtml) return undefined; + const ast = sourceCode.ast; + if (ast instanceof Error || ast.type !== NodeTypes.Document) return undefined; + const node = ast.children.find((child) => child.type === NodeTypes.YAMLFrontmatter); + return node?.type === NodeTypes.YAMLFrontmatter ? loadFrontmatter(node.body) : undefined; } /** - * The effective URL slug for `uri`: the frontmatter `slug` override (coerced to - * a string, matching RouteTable) when declared, else the slug derived from the - * page path (reusing `extractRelativePagePath` + `slugFromFilePath`). Returns - * `undefined` for non-page files with no `slug` override. + * The effective URL slug for `uri`, delegating to `effectivePageSlug` — the + * single slug-derivation shared with `RouteTable`, so the graph's routing fact + * can never drift from the platform's actual routing (override-wins, coerced to + * a string; else path-derived using the effective format). Returns `undefined` + * for a non-page file unless it declares an explicit `slug` override. */ function effectiveSlug( uri: UriString, frontmatter: Record | undefined, ): string | undefined { - if (frontmatter && frontmatter.slug !== undefined && frontmatter.slug !== null) { - return String(frontmatter.slug); - } const relativePath = extractRelativePagePath(uri); - if (relativePath === null) return undefined; - return slugFromFilePath(relativePath, formatFromFilePath(relativePath)); + if (relativePath !== null) return effectivePageSlug(relativePath, frontmatter); + // Non-page file: only an explicit `slug` override is meaningful. + const slug = frontmatter?.slug; + return slug !== undefined && slug !== null ? String(slug) : undefined; } /** * The names of a call site's named arguments, in source order, or `undefined` - * when there are none. Defensive against the parser's documented - * completion-context case (a trailing incomplete argument may not be a - * fully-typed `NamedArgument`): only `NamedArgument`s with a string name - * contribute. Values are intentionally not captured — names are what - * cross-checking against a partial's `@param` signature needs. + * when there are none (so an argument-less edge carries no `args` field). Values + * are intentionally not captured — names are what cross-checking against a + * partial's `@param` signature needs. Every `LiquidNamedArgument` has a string + * `name` by construction, so no per-element guard is required. */ function argNames(args: LiquidNamedArgument[]): string[] | undefined { - const names = args - .filter((arg) => arg.type === NodeTypes.NamedArgument && typeof arg.name === 'string') - .map((arg) => arg.name); - return names.length > 0 ? names : undefined; + return args.length > 0 ? args.map((arg) => arg.name) : undefined; +} + +/** + * The spreadable `args` field for an edge: present only for a non-empty + * argument-name list, absent otherwise. The single place the "omit when none" + * rule lives, so {@link bind} and {@link extractFileReferences} cannot diverge. + */ +function argsField(args: string[] | undefined): { args?: string[] } { + return args && args.length > 0 ? { args } : {}; } /** @@ -512,9 +511,7 @@ export function bind( target: { uri: target.uri }, type: type, kind: kind, - // Only carry `args` when the call site has named arguments, so argument-less - // edges stay free of an empty field. - ...(args && args.length > 0 ? { args } : {}), + ...argsField(args), }; source.dependencies.push(dependency); diff --git a/packages/platformos-graph/src/index.ts b/packages/platformos-graph/src/index.ts index 7b9a35ca..949349fb 100644 --- a/packages/platformos-graph/src/index.ts +++ b/packages/platformos-graph/src/index.ts @@ -1,5 +1,5 @@ export { buildAppGraph } from './graph/build'; -export { extractFileReferences } from './graph/traverse'; +export { extractFileReferences, extractStructural } from './graph/traverse'; export { dependenciesOf, dependentsOf, diff --git a/packages/platformos-graph/src/types.ts b/packages/platformos-graph/src/types.ts index 352fcbef..870a0f46 100644 --- a/packages/platformos-graph/src/types.ts +++ b/packages/platformos-graph/src/types.ts @@ -22,6 +22,18 @@ export type Dependencies = Required; export type AugmentedDependencies = Dependencies; +/** Options controlling what a build/traversal computes beyond the edge graph. */ +export interface GraphBuildOptions { + /** + * Populate each {@link LiquidModule.structural} (self-structural facts) during + * the build, as a by-product of the parse. OFF by default: a consumer that + * needs one buffer's structural facts should call `extractStructural` directly + * (the per-file primitive), so a full graph build — e.g. the LSP's, which never + * reads `structural` — does not compute a fact nothing consumes. + */ + includeStructural?: boolean; +} + export interface AppGraph { rootUri: UriString; entryPoints: AppModule[]; diff --git a/packages/platformos-mcp-supervisor/src/adapter-input.ts b/packages/platformos-mcp-supervisor/src/adapter-input.ts new file mode 100644 index 00000000..bd09b740 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/adapter-input.ts @@ -0,0 +1,25 @@ +/** + * Shared input for the request-path I/O adapters (`lint/`, `structure/`). + * + * Both adapters receive the identical `{ projectDir, filePath, content }` and + * must agree on the buffer's absolute path, so the shape and the + * absolute-path resolution live here rather than being duplicated per adapter. + */ +import { isAbsolute, join } from 'node:path'; + +export interface AdapterInput { + /** Absolute project root the buffer is validated against. */ + projectDir: string; + /** File under edit — absolute, or relative to `projectDir`. */ + filePath: string; + /** In-memory buffer contents. */ + content: string; +} + +/** + * Resolve the file under edit to an absolute path: returned as-is when already + * absolute, else joined onto the project root. + */ +export function toAbsoluteFilePath(projectDir: string, filePath: string): string { + return isAbsolute(filePath) ? filePath : join(projectDir, filePath); +} diff --git a/packages/platformos-mcp-supervisor/src/lint/lint.ts b/packages/platformos-mcp-supervisor/src/lint/lint.ts index 05b012e6..61a1ffcb 100644 --- a/packages/platformos-mcp-supervisor/src/lint/lint.ts +++ b/packages/platformos-mcp-supervisor/src/lint/lint.ts @@ -12,21 +12,11 @@ * `enrich/`; for now diagnostics have no `fix` / `hint`, and the structured * `Offense.fix` / `suggest` are intentionally not translated. */ -import { isAbsolute, join } from 'node:path'; - import { lintBuffer, Severity, type Offense } from '@platformos/platformos-check-node'; +import { toAbsoluteFilePath, type AdapterInput } from '../adapter-input'; import type { ValidateCodeDiagnostic, ValidateCodeSeverity } from '../result/types'; -export interface RunLintParams { - /** Absolute project root the buffer is validated against. */ - projectDir: string; - /** File under edit — absolute, or relative to `projectDir`. */ - filePath: string; - /** In-memory buffer contents. */ - content: string; -} - const SEVERITY: Record = { [Severity.ERROR]: 'error', [Severity.WARNING]: 'warning', @@ -34,9 +24,9 @@ const SEVERITY: Record = { }; /** Lint the buffer and return the mapped diagnostics for the file. */ -export async function runLint(params: RunLintParams): Promise { +export async function runLint(params: AdapterInput): Promise { const { projectDir, filePath, content } = params; - const absoluteFilePath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + const absoluteFilePath = toAbsoluteFilePath(projectDir, filePath); const offenses = await lintBuffer({ root: projectDir, filePath: absoluteFilePath, content }); return offenses.map(toDiagnostic); } diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts index f22554fd..afc488ca 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { assembleResult } from './assemble'; -import type { ValidateCodeDependency, ValidateCodeDiagnostic, ValidateCodeResult } from './types'; +import type { + ValidateCodeDependency, + ValidateCodeDiagnostic, + ValidateCodeResult, + ValidateCodeStructuralSnapshot, +} from './types'; const diag = (over: Partial): ValidateCodeDiagnostic => ({ check: 'SomeCheck', @@ -34,7 +39,7 @@ describe('Unit: assembleResult', () => { const warning = diag({ severity: 'warning', check: 'W' }); const info = diag({ severity: 'info', check: 'I' }); - expect(assembleResult([error, warning, info], [], 'full')).toEqual({ + expect(assembleResult([error, warning, info], [], null, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, @@ -48,7 +53,7 @@ describe('Unit: assembleResult', () => { const error = diag({ severity: 'error' }); const warning = diag({ severity: 'warning' }); - expect(assembleResult([error, warning], [], 'full')).toEqual({ + expect(assembleResult([error, warning], [], null, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, @@ -61,7 +66,7 @@ describe('Unit: assembleResult', () => { const warning = diag({ severity: 'warning' }); const info = diag({ severity: 'info' }); - expect(assembleResult([warning, info], [], 'full')).toEqual({ + expect(assembleResult([warning, info], [], null, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'warning', must_fix_before_write: false, @@ -71,7 +76,7 @@ describe('Unit: assembleResult', () => { }); it('derives status = ok with an empty envelope for no diagnostics', () => { - expect(assembleResult([], [], 'full')).toEqual({ + expect(assembleResult([], [], null, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, @@ -81,7 +86,7 @@ describe('Unit: assembleResult', () => { it('derives status = ok for infos only', () => { const info = diag({ severity: 'info' }); - expect(assembleResult([info], [], 'quick')).toEqual({ + expect(assembleResult([info], [], null, 'quick')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, @@ -95,11 +100,32 @@ describe('Unit: assembleResult', () => { { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 3, column: 1 }, ]; - expect(assembleResult([], dependencies, 'full')).toEqual({ + expect(assembleResult([], dependencies, null, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, dependencies, }); }); + + it('carries the structural snapshot through verbatim (status unaffected)', () => { + const structural: ValidateCodeStructuralSnapshot = { + renders_used: ['card'], + graphql_queries_used: [], + filters_used: ['upcase'], + tags_used: ['render'], + translation_keys: [], + doc_params: ['title'], + slug: 'about', + layout: 'theme', + method: null, + }; + + expect(assembleResult([], [], structural, 'full')).toEqual({ + ...EMPTY_ENVELOPE, + status: 'ok', + must_fix_before_write: false, + structural, + }); + }); }); diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.ts b/packages/platformos-mcp-supervisor/src/result/assemble.ts index 5d8f2b38..3d2b90be 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.ts @@ -5,9 +5,10 @@ * `status` + `must_fix_before_write` envelope. PURE — no I/O, consumes only the * diagnostic list and the shared result types. * - * The ergonomic transforms (clustering, scorecard, the explicit - * blocking-warning set, `next_step`, tips, domain_guide, structural) are added - * in later tasks; they are left empty/null here. + * `dependencies` and `structural` are graph-derived facts pre-computed by the + * structure adapter and included verbatim. The remaining ergonomic transforms + * (clustering, scorecard, the explicit blocking-warning set, `next_step`, tips, + * domain_guide) are added in later tasks; they are left empty/null here. */ import type { ValidateCodeDependency, @@ -15,6 +16,7 @@ import type { ValidateCodeMode, ValidateCodeResult, ValidateCodeStatus, + ValidateCodeStructuralSnapshot, } from './types'; export function assembleResult( @@ -22,6 +24,9 @@ export function assembleResult( // The file's resolved outgoing dependencies (graph-derived, pre-computed by // the structure adapter). Included verbatim — assembly stays pure. dependencies: ValidateCodeDependency[], + // The file's own structural declarations (graph-derived), or null when the + // buffer is non-Liquid / unparseable. Included verbatim. + structural: ValidateCodeStructuralSnapshot | null, // Reserved: `full`/`quick` do not yet change output (no heavier stages exist). _mode: ValidateCodeMode, ): ValidateCodeResult { @@ -47,6 +52,6 @@ export function assembleResult( parse_error: null, tips: [], domain_guide: null, - structural: null, + structural, }; } diff --git a/packages/platformos-mcp-supervisor/src/result/types.ts b/packages/platformos-mcp-supervisor/src/result/types.ts index f2f3dde3..1beff2bf 100644 --- a/packages/platformos-mcp-supervisor/src/result/types.ts +++ b/packages/platformos-mcp-supervisor/src/result/types.ts @@ -113,6 +113,22 @@ export interface ScorecardNote { reason: string; } +/** + * The Liquid construct that created a dependency edge — the agent-facing kind + * contract. Owned by the supervisor (not imported from the upstream + * `ReferenceKind`): the structure adapter maps upstream kinds onto this union + * through an exhaustive table, so an upstream rename/addition is a compile error + * at that seam rather than a silent change to the agent surface. + */ +export type ValidateCodeDependencyKind = + | 'render' + | 'include' + | 'function' + | 'background' + | 'graphql' + | 'asset' + | 'layout'; + /** * A resolved outgoing dependency of the validated file — what it * renders/includes/runs/queries/wraps. Derived from the platformos-graph @@ -121,13 +137,8 @@ export interface ScorecardNote { * "what does this call / where does it live" is answerable from the result. */ export interface ValidateCodeDependency { - /** - * The Liquid construct that created the edge — one of - * `render | include | function | background | graphql | asset | layout`. - * Typed as `string` (like `ValidateCodeDiagnostic.check`) to keep the agent - * surface decoupled from the upstream `ReferenceKind` union. - */ - kind: string; + /** The Liquid construct that created the edge. */ + kind: ValidateCodeDependencyKind; /** The resolved dependency target, project-relative (e.g. `app/lib/queries/list.liquid`). */ target: string; /** 1-based line of the reference site (the Liquid tag or the frontmatter block). */ @@ -158,8 +169,16 @@ export interface DomainGuide { triggered_gotchas: DomainGuideGotcha[]; } +/** + * The validated file's own structural declarations — what it renders/queries, + * the filters/tags it uses, its translation keys and `{% doc %}` params, and (for + * pages) its routing facts. Derived from the platformos-graph per-file primitive + * `extractStructural`; the usage arrays are always present (empty = none used), + * the routing facts are `null` when not applicable (e.g. a partial has no slug). + */ export interface ValidateCodeStructuralSnapshot { renders_used: string[]; + graphql_queries_used: string[]; filters_used: string[]; tags_used: string[]; translation_keys: string[]; diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts index f4130213..b251ee57 100644 --- a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts +++ b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts @@ -7,10 +7,11 @@ import { runStructure } from './structure'; import type { ValidateCodeDependency } from '../result/types'; /** - * Adapter integration: drives the real platformos-graph `extractFileReferences` - * against a temp project. Targets need not exist on disk — `extractFileReferences` - * resolves a missing target to its canonical default path — so the assertions - * pin the project-relative target + kind + 1-based position without fixtures. + * Adapter integration: drives the real platformos-graph per-file primitives + * (`extractFileReferences` + `extractStructural`) against a temp project. + * Targets need not exist on disk — `extractFileReferences` resolves a missing + * target to its canonical default path — so the assertions pin the + * project-relative target + kind + 1-based position without fixtures. */ describe('Integration: runStructure (structure adapter)', () => { let projectDir: string; @@ -23,42 +24,38 @@ describe('Integration: runStructure (structure adapter)', () => { rmSync(projectDir, { recursive: true, force: true }); }); - const run = (filePath: string, content: string) => - runStructure({ projectDir, filePath, content }); + /** The dependency edges for a buffer (the `structural` half is asserted separately). */ + const deps = async (filePath: string, content: string): Promise => + (await runStructure({ projectDir, filePath, content })).dependencies; it('maps a {% render %} edge to a render dependency', async () => { - const deps = await run('app/views/pages/index.liquid', "{% render 'card' %}"); - expect(deps).toEqual([ + expect(await deps('app/views/pages/index.liquid', "{% render 'card' %}")).toEqual([ { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, ]); }); it('maps an {% include %} edge to an include dependency', async () => { - const deps = await run('app/views/pages/index.liquid', "{% include 'card' %}"); - expect(deps).toEqual([ + expect(await deps('app/views/pages/index.liquid', "{% include 'card' %}")).toEqual([ { kind: 'include', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, ]); }); it('maps a {% function %} edge to a function dependency', async () => { - const deps = await run('app/views/pages/index.liquid', "{% function r = 'queries/list' %}"); - expect(deps).toEqual([ - { kind: 'function', target: 'app/lib/queries/list.liquid', line: 1, column: 1 }, - ]); + expect(await deps('app/views/pages/index.liquid', "{% function r = 'queries/list' %}")).toEqual( + [{ kind: 'function', target: 'app/lib/queries/list.liquid', line: 1, column: 1 }], + ); }); it('maps a {% background %} edge to a background dependency', async () => { // Background resolves like {% function %} (lib search path), so a target // that does not exist defaults under app/lib. - const deps = await run('app/views/pages/index.liquid', "{% background j = 'jobs/notify' %}"); - expect(deps).toEqual([ - { kind: 'background', target: 'app/lib/jobs/notify.liquid', line: 1, column: 1 }, - ]); + expect( + await deps('app/views/pages/index.liquid', "{% background j = 'jobs/notify' %}"), + ).toEqual([{ kind: 'background', target: 'app/lib/jobs/notify.liquid', line: 1, column: 1 }]); }); it('maps a {% graphql %} edge to a graphql dependency', async () => { - const deps = await run('app/views/pages/index.liquid', "{% graphql r = 'blog/find' %}"); - expect(deps).toEqual([ + expect(await deps('app/views/pages/index.liquid', "{% graphql r = 'blog/find' %}")).toEqual([ { kind: 'graphql', target: 'app/graphql/blog/find.graphql', line: 1, column: 1 }, ]); }); @@ -66,8 +63,9 @@ describe('Integration: runStructure (structure adapter)', () => { it('maps an asset filter to an asset dependency', async () => { // The asset edge's range is the `'app.js' | asset_url` expression (the // variable output), which starts at column 4 — after `{{ `. - const deps = await run('app/views/layouts/application.liquid', "{{ 'app.js' | asset_url }}"); - expect(deps).toEqual([{ kind: 'asset', target: 'assets/app.js', line: 1, column: 4 }]); + expect( + await deps('app/views/layouts/application.liquid', "{{ 'app.js' | asset_url }}"), + ).toEqual([{ kind: 'asset', target: 'assets/app.js', line: 1, column: 4 }]); }); it('maps a frontmatter `layout:` to a layout dependency', async () => { @@ -76,15 +74,13 @@ slug: about layout: theme ---

About

`; - const deps = await run('app/views/pages/about.liquid', content); - expect(deps).toEqual([ + expect(await deps('app/views/pages/about.liquid', content)).toEqual([ { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, ]); }); it('resolves a module-prefixed target into modules//public/...', async () => { - const deps = await run('app/views/pages/index.liquid', "{% render 'modules/core/card' %}"); - expect(deps).toEqual([ + expect(await deps('app/views/pages/index.liquid', "{% render 'modules/core/card' %}")).toEqual([ { kind: 'render', target: 'modules/core/public/views/partials/card.liquid', @@ -96,8 +92,7 @@ layout: theme it('reports the 1-based position of a reference that is not on the first line', async () => { const content = "

hi

\n {% render 'card' %}"; - const deps = await run('app/views/pages/index.liquid', content); - expect(deps).toEqual([ + expect(await deps('app/views/pages/index.liquid', content)).toEqual([ { kind: 'render', target: 'app/views/partials/card.liquid', line: 2, column: 3 }, ]); }); @@ -108,8 +103,7 @@ layout: theme --- {% function items = 'queries/list' %} {% render 'card' %}`; - const deps = await run('app/views/pages/index.liquid', content); - expect(deps).toEqual([ + expect(await deps('app/views/pages/index.liquid', content)).toEqual([ { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, { kind: 'function', target: 'app/lib/queries/list.liquid', line: 4, column: 1 }, { kind: 'render', target: 'app/views/partials/card.liquid', line: 5, column: 1 }, @@ -117,22 +111,65 @@ layout: theme }); it('returns no dependencies for a file with none', async () => { - expect(await run('app/views/pages/index.liquid', '

{{ page.title }}

')).toEqual([]); + expect(await deps('app/views/pages/index.liquid', '

{{ page.title }}

')).toEqual([]); }); it('skips dynamic (non-literal) targets', async () => { - expect(await run('app/views/pages/index.liquid', '{% render partial_name %}')).toEqual([]); + expect(await deps('app/views/pages/index.liquid', '{% render partial_name %}')).toEqual([]); }); it('returns no dependencies for a non-Liquid (.graphql) file', async () => { - const deps = await run('app/graphql/blog/find.graphql', 'query find { records { id } }'); - expect(deps).toEqual([]); + expect(await deps('app/graphql/blog/find.graphql', 'query find { records { id } }')).toEqual( + [], + ); }); it('accepts an absolute file path', async () => { const abs = join(projectDir, 'app/views/pages/index.liquid'); - expect(await run(abs, "{% render 'card' %}")).toEqual([ + expect(await deps(abs, "{% render 'card' %}")).toEqual([ { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, ]); }); + + it('returns the buffer self-structural snapshot alongside the dependencies', async () => { + const content = `--- +slug: about +layout: theme +method: get +--- +{% render 'card' %} +{{ 'greeting.hi' | t }} +{{ title | upcase }}`; + const result = await runStructure({ + projectDir, + filePath: 'app/views/pages/about.liquid', + content, + }); + expect(result).toEqual({ + dependencies: [ + { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, + { kind: 'render', target: 'app/views/partials/card.liquid', line: 6, column: 1 }, + ], + structural: { + renders_used: ['card'], + graphql_queries_used: [], + filters_used: ['t', 'upcase'], + tags_used: ['render'], + translation_keys: ['greeting.hi'], + doc_params: [], + slug: 'about', + layout: 'theme', + method: 'get', + }, + }); + }); + + it('returns null structural (and empty dependencies) for a non-Liquid (.graphql) file', async () => { + const result = await runStructure({ + projectDir, + filePath: 'app/graphql/blog/find.graphql', + content: 'query find { records { id } }', + }); + expect(result).toEqual({ dependencies: [], structural: null }); + }); }); diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.ts b/packages/platformos-mcp-supervisor/src/structure/structure.ts index 6344dc3f..ce8923e7 100644 --- a/packages/platformos-mcp-supervisor/src/structure/structure.ts +++ b/packages/platformos-mcp-supervisor/src/structure/structure.ts @@ -1,11 +1,14 @@ /** * Structure adapter — an I/O boundary on the request path (sibling to `lint/`). * - * Resolves the validated buffer's outgoing dependency edges via - * platformos-graph's per-file primitive `extractFileReferences`, then maps the - * resulting `Reference[]` into the agent-facing `ValidateCodeDependency[]`. + * From the validated buffer it derives two agent-facing facts via + * platformos-graph's per-file primitives, sharing a SINGLE parse: + * - `dependencies`: the outgoing edges (`extractFileReferences`), mapped to + * `ValidateCodeDependency[]`; + * - `structural`: the file's own declarations (`extractStructural`), mapped to + * `ValidateCodeStructuralSnapshot`. * - * The supervisor owns ONLY the mapping. Graph resolution lives in + * The supervisor owns ONLY the mapping. Graph resolution/extraction lives in * platformos-graph; offset→line/col and uri→project-relative math are REUSED * from platformos-check-common (`getPosition`, `path.relative`). No graph or * path logic is re-implemented here. @@ -14,27 +17,52 @@ * file the agent is about to write that does not exist yet. Only the on-disk * project is touched (via `fs`) to resolve targets. */ -import { isAbsolute, join } from 'node:path'; - import { getPosition, path } from '@platformos/platformos-check-common'; import { NodeFileSystem } from '@platformos/platformos-check-node'; -import { extractFileReferences, toSourceCode, type Reference } from '@platformos/platformos-graph'; +import { + extractFileReferences, + extractStructural, + toSourceCode, + type ModuleStructural, + type Reference, + type ReferenceKind, +} from '@platformos/platformos-graph'; + +import { toAbsoluteFilePath, type AdapterInput } from '../adapter-input'; +import type { + ValidateCodeDependency, + ValidateCodeDependencyKind, + ValidateCodeStructuralSnapshot, +} from '../result/types'; -import type { ValidateCodeDependency } from '../result/types'; +/** + * The seam mapping the upstream graph `ReferenceKind` onto the agent-facing + * `ValidateCodeDependencyKind`. Exhaustive by construction (`Record`): if the graph adds or renames a kind, this table fails to compile — the + * agent surface never drifts silently. The names are 1:1 today; the table is the + * insulation point, not a rename. + */ +const DEPENDENCY_KIND: Record = { + render: 'render', + include: 'include', + function: 'function', + background: 'background', + graphql: 'graphql', + asset: 'asset', + layout: 'layout', +}; -export interface RunStructureParams { - /** Absolute project root the buffer is resolved against. */ - projectDir: string; - /** File under edit — absolute, or relative to `projectDir`. */ - filePath: string; - /** In-memory buffer contents. */ - content: string; +export interface StructureResult { + /** The file's resolved outgoing dependency edges. */ + dependencies: ValidateCodeDependency[]; + /** The file's own structural declarations, or `null` for a non-Liquid/unparseable buffer. */ + structural: ValidateCodeStructuralSnapshot | null; } -/** Resolve the buffer's outgoing dependency edges and map them for the agent. */ -export async function runStructure(params: RunStructureParams): Promise { +/** Derive the buffer's dependency edges + self-structural, sharing one parse. */ +export async function runStructure(params: AdapterInput): Promise { const { projectDir, filePath, content } = params; - const absoluteFilePath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + const absoluteFilePath = toAbsoluteFilePath(projectDir, filePath); // Normalized file URIs (forward slashes) so the graph's normalized target // URIs share this exact root prefix — `path.relative` strips it cleanly on @@ -42,12 +70,36 @@ export async function runStructure(params: RunStructureParams): Promise toDependency(ref, rootUri, content)); + return { + dependencies: references.flatMap((ref) => toDependency(ref, rootUri, content)), + structural: structural ? toStructuralSnapshot(structural) : null, + }; +} + +/** + * Map the graph's `ModuleStructural` to the agent-facing snapshot: usage arrays + * pass through (always present), and the optional routing facts become `null` + * when the file does not declare them. + */ +function toStructuralSnapshot(structural: ModuleStructural): ValidateCodeStructuralSnapshot { + return { + renders_used: structural.renders_used, + graphql_queries_used: structural.graphql_queries_used, + filters_used: structural.filters_used, + tags_used: structural.tags_used, + translation_keys: structural.translation_keys, + doc_params: structural.doc_params, + slug: structural.slug ?? null, + layout: structural.layout ?? null, + method: structural.method ?? null, + }; } /** @@ -61,7 +113,7 @@ function toDependency(ref: Reference, rootUri: string, content: string): Validat const { line, character } = getPosition(content, ref.source.range[0]); return [ { - kind: ref.kind, + kind: DEPENDENCY_KIND[ref.kind], target: path.relative(ref.target.uri, rootUri), line: line + 1, column: character + 1, diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts new file mode 100644 index 00000000..a8302a6d --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { runValidateCode, type SupervisorContext } from './validate-code'; +import type { StructureResult } from '../structure/structure'; +import type { + ValidateCodeDependency, + ValidateCodeDiagnostic, + ValidateCodeStructuralSnapshot, +} from '../result/types'; + +/** + * The lint/structure orchestration contract in `runValidateCode`: + * - lint is the PRIMARY signal — a lint failure propagates (the whole call fails); + * - structure is SECONDARY enrichment — a structure failure degrades to empty + * `dependencies` + null `structural` (logged) and never sinks the lint diagnostics. + */ +describe('runValidateCode: lint/structure orchestration', () => { + const params = { file_path: 'app/views/pages/index.liquid', content: "{% render 'card' %}" }; + + const warning: ValidateCodeDiagnostic = { + check: 'SomeCheck', + severity: 'warning', + message: 'a warning', + line: 1, + column: 1, + end_line: 1, + end_column: 5, + }; + + const dependency: ValidateCodeDependency = { + kind: 'render', + target: 'app/views/partials/card.liquid', + line: 1, + column: 1, + }; + + const structural: ValidateCodeStructuralSnapshot = { + renders_used: ['card'], + graphql_queries_used: [], + filters_used: [], + tags_used: ['render'], + translation_keys: [], + doc_params: [], + slug: '/', + layout: null, + method: null, + }; + + const structureOk: StructureResult = { dependencies: [dependency], structural }; + + const makeCtx = (log: SupervisorContext['log'] = () => {}): SupervisorContext => ({ + projectDir: '/project', + log, + }); + + it('passes lint diagnostics, dependencies, and structural straight through when both succeed', async () => { + const result = await runValidateCode(makeCtx(), params, { + lint: async () => [warning], + structure: async () => structureOk, + }); + + expect(result).toEqual({ + status: 'warning', + must_fix_before_write: false, + errors: [], + warnings: [warning], + infos: [], + proposed_fixes: [], + clusters: [], + scorecard: [], + dependencies: [dependency], + parse_error: null, + tips: [], + domain_guide: null, + structural, + }); + }); + + it('degrades to empty dependencies + null structural (and logs) when the structure adapter fails, preserving lint output', async () => { + const logs: string[] = []; + const result = await runValidateCode( + makeCtx((message) => logs.push(message)), + params, + { + lint: async () => [warning], + structure: async () => { + throw new Error('boom'); + }, + }, + ); + + expect(result).toEqual({ + status: 'warning', + must_fix_before_write: false, + errors: [], + warnings: [warning], + infos: [], + proposed_fixes: [], + clusters: [], + scorecard: [], + dependencies: [], + parse_error: null, + tips: [], + domain_guide: null, + structural: null, + }); + + expect(logs).toEqual([ + `validate_code: ${params.file_path} (full)`, + `validate_code: structural resolution failed for ${params.file_path}, ` + + `continuing without structure: boom`, + ]); + }); + + it('propagates a lint failure — the primary gate is never silently dropped', async () => { + const failure = new Error('lint exploded'); + await expect( + runValidateCode(makeCtx(), params, { + lint: async () => { + throw failure; + }, + structure: async () => structureOk, + }), + ).rejects.toThrow(failure); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts index 02555aa7..2e02e03f 100644 --- a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts @@ -10,7 +10,7 @@ import { z, type ZodRawShape } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { runLint } from '../lint/lint'; -import { runStructure } from '../structure/structure'; +import { runStructure, type StructureResult } from '../structure/structure'; import { assembleResult } from '../result/assemble'; import type { Logger } from '../logger'; import type { ValidateCodeParams, ValidateCodeResult } from '../result/types'; @@ -74,13 +74,29 @@ export function registerValidateCode(server: McpServer, ctx: SupervisorContext): } /** - * Lint the buffer (check-node seam) and resolve its dependency edges - * (platformos-graph seam) — two independent I/O adapters, run concurrently — - * then assemble the result. + * The two I/O adapters {@link runValidateCode} orchestrates. Injectable so the + * degrade-vs-propagate contract below can be unit-tested; defaults are the real + * lint / structure seams. */ -async function runValidateCode( +export interface ValidateCodeAdapters { + lint: typeof runLint; + structure: typeof runStructure; +} + +/** + * Lint the buffer (check-node seam) and derive its structure — dependency edges + * + self-structural (platformos-graph seam) — as two independent I/O adapters + * run concurrently, then assemble the result. + * + * Lint is the PRIMARY signal (the `must_fix_before_write` gate): if it fails the + * whole call fails. Structure is SECONDARY enrichment, so a structure-adapter + * failure degrades to empty `dependencies` + null `structural` (logged) and + * never sinks the lint diagnostics the agent depends on. + */ +export async function runValidateCode( ctx: SupervisorContext, params: ValidateCodeParams, + adapters: ValidateCodeAdapters = { lint: runLint, structure: runStructure }, ): Promise { const mode = params.mode ?? 'full'; ctx.log(`validate_code: ${params.file_path} (${mode})`); @@ -89,11 +105,17 @@ async function runValidateCode( filePath: params.file_path, content: params.content, }; - const [diagnostics, dependencies] = await Promise.all([ - runLint(adapterParams), - runStructure(adapterParams), + const [diagnostics, structure] = await Promise.all([ + adapters.lint(adapterParams), + adapters.structure(adapterParams).catch((error): StructureResult => { + ctx.log( + `validate_code: structural resolution failed for ${params.file_path}, ` + + `continuing without structure: ${error instanceof Error ? error.message : error}`, + ); + return { dependencies: [], structural: null }; + }), ]); - return assembleResult(diagnostics, dependencies, mode); + return assembleResult(diagnostics, structure.dependencies, structure.structural, mode); } /** Wrap a result in the MCP text-content envelope (every result is one JSON text block). */ diff --git a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts index 9732c59f..78975dc3 100644 --- a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts +++ b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts @@ -98,7 +98,8 @@ describe('Integration: validate_code over stdio', () => { }); // The always-empty envelope fields in this lint-only slice; spread into each - // expected result so every assertion checks the WHOLE object. + // expected result so every assertion checks the WHOLE object. `structural` is + // set per-test (it is populated for every Liquid buffer). const EMPTY_ENVELOPE = { errors: [], warnings: [], @@ -110,9 +111,23 @@ describe('Integration: validate_code over stdio', () => { parse_error: null, tips: [], domain_guide: null, - structural: null, }; + // The self-structural snapshot with all-empty usage + no routing facts; each + // test overrides only the fields its buffer declares. + const structural = (over: Record = {}) => ({ + renders_used: [], + graphql_queries_used: [], + filters_used: [], + tags_used: [], + translation_keys: [], + doc_params: [], + slug: null, + layout: null, + method: null, + ...over, + }); + // The exact MissingContentForLayout error for a layout that omits // `{{ content_for_layout }}` (reported at index 0 → 1-based line/col 1). const MISSING_CONTENT_FOR_LAYOUT = { @@ -136,6 +151,7 @@ describe('Integration: validate_code over stdio', () => { ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, + structural: structural(), }); }); @@ -150,6 +166,7 @@ describe('Integration: validate_code over stdio', () => { status: 'error', must_fix_before_write: true, errors: [MISSING_CONTENT_FOR_LAYOUT], + structural: structural(), }); }); @@ -166,6 +183,7 @@ describe('Integration: validate_code over stdio', () => { dependencies: [ { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, ], + structural: structural({ renders_used: ['card'], tags_used: ['render'], slug: '/' }), }); }); @@ -188,6 +206,12 @@ layout: theme { kind: 'function', target: 'app/lib/queries/list.liquid', line: 4, column: 1 }, { kind: 'render', target: 'app/views/partials/card.liquid', line: 5, column: 1 }, ], + structural: structural({ + renders_used: ['card'], + tags_used: ['function', 'render'], + slug: '/', + layout: 'theme', + }), }); }); @@ -207,6 +231,7 @@ layout: theme dependencies: [ { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 7 }, ], + structural: structural({ renders_used: ['card'], tags_used: ['render'] }), }); }); @@ -220,6 +245,7 @@ layout: theme ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, + structural: structural({ tags_used: ['assign', 'render'], slug: '/' }), }); }); }); From d09ab82e83b5651b34b006674838d807e132f60e Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Wed, 1 Jul 2026 20:44:59 +0200 Subject: [PATCH 08/20] feat: enhance asset handling in DocumentsLocator and related modules - Updated DocumentsLocator to support canonical asset paths for both app and module assets. - Added tests to verify asset resolution under app/assets and module public/assets. - Introduced new asset edge handling in graph traversal and query logic. - Refactored asset module retrieval to use resolved URIs directly. - Improved integration tests to reflect changes in asset path resolution. --- SUPERVISOR-GRAPH-INTEGRATION.md | 322 ++++++++++++++---- .../DocumentsLocator.spec.ts | 38 ++- .../src/documents-locator/DocumentsLocator.ts | 10 +- .../fixtures/asset-edges/app/assets/logo.css | 1 + .../asset-edges/app/assets/site/app.js | 1 + .../asset-edges/app/views/pages/index.liquid | 3 + .../skeleton/{ => app}/assets/app.css | 0 .../fixtures/skeleton/{ => app}/assets/app.js | 0 packages/platformos-graph/src/cli.spec.ts | 47 +-- .../platformos-graph/src/graph/build.spec.ts | 8 +- packages/platformos-graph/src/graph/module.ts | 53 +-- .../platformos-graph/src/graph/query.spec.ts | 5 +- .../src/graph/traverse-edges.spec.ts | 71 ++++ .../platformos-graph/src/graph/traverse.ts | 14 +- .../src/structure/structure.spec.ts | 7 +- 15 files changed, 457 insertions(+), 123 deletions(-) create mode 100644 packages/platformos-graph/fixtures/asset-edges/app/assets/logo.css create mode 100644 packages/platformos-graph/fixtures/asset-edges/app/assets/site/app.js create mode 100644 packages/platformos-graph/fixtures/asset-edges/app/views/pages/index.liquid rename packages/platformos-graph/fixtures/skeleton/{ => app}/assets/app.css (100%) rename packages/platformos-graph/fixtures/skeleton/{ => app}/assets/app.js (100%) diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md index 48010597..2ec7c911 100644 --- a/SUPERVISOR-GRAPH-INTEGRATION.md +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -8,6 +8,12 @@ wires it into the MCP supervisor's `validate_code`. It covers what shipped, the decisions (with ADR links), the full graph feature surface, worked output examples, and the open doubts. +> **§8 is the code-review remediation** — a high-effort review of the whole branch +> ran before submission (8 finder angles + verification), surfacing 11 findings. +> Ten were fixed and one deferred with rationale; that section is the place to +> understand *what changed as a result of review and why*. The rest of this +> document reflects the **post-review** state. + --- ## 1. PR summary @@ -27,12 +33,15 @@ graph to provide that model and consumes it from `validate_code`. - **Per-file primitive** `extractFileReferences` — resolves one in-flight buffer's outgoing edges without building the whole graph (the buffer-before-write model). - **`validate_code` now returns `dependencies`** — resolved, project-relative - targets with kind + 1-based position, alongside lint diagnostics. + targets with a typed `kind` + 1-based position, alongside lint diagnostics. +- **`validate_code` now returns `structural`** *(added in review — §8/F1)* — the + file's own declarations for the in-flight buffer. - **Project-structure query API** — dependents / orphans / reachability / missing-target / nearest-name ("did you mean"). - **Per-file self-structural** — `renders_used`, `graphql_queries_used`, `filters_used`, `tags_used`, `translation_keys`, `doc_params`, `slug`, - `layout`, `method`, surfaced on each module as a parse by-product. + `layout`, `method`, exposed both as the per-file primitive `extractStructural` + *(exported in review — §8/F1)* and, opt-in, on each module during a full build. - **Platform facts** — a GraphQL op's `table`, schema/`CustomModelType` nodes. - **`'layout'` DocumentType** in `DocumentsLocator` (the canonical resolver), with `.html.liquid`/`.liquid` precedence. @@ -49,24 +58,28 @@ graph to provide that model and consumes it from `validate_code`. - Cross-package safety re-verified after every slice: graph, check-common, check-node, language-server, supervisor. -### Verification (final) -| Package | Tests | -|---|---| -| platformos-graph | **73** | -| platformos-check-common | **1047** | -| platformos-mcp-supervisor | **50** (incl. 7 stdio end-to-end) | -| platformos-language-server-common | **467** | - -All packages type-check clean; `yarn format:check` clean; `--frozen-lockfile` -clean. The **only** red anywhere is a pre-existing, local-only timeout in -`TypeSystem.spec` (a heavy LSP type-inference test that exceeds 5s only under -full-suite parallel load on a contended machine — proven unrelated by a stash -baseline; CI does not hit it). - -> **Working-tree note:** the last commit on the branch is the query API -> (`nearestModules`). The most recent slices — graphql `table`, schema nodes, -> per-file self-structural, ADR 004 — are **staged in the working tree, not yet -> committed**. Commit before opening the PR. +### Verification (final, post-review) +| Package | Tests | Δ from pre-review | +|---|---|---| +| platformos-common | **257** | +12 (`effectivePageSlug`) | +| platformos-graph | **80** | +7 (structural opt-in/primitive, node-identity) | +| platformos-check-common | **1057** | +10 (`extractSchemaTable`) | +| platformos-mcp-supervisor | **56** | +6 (orchestration, structural, adapter) | +| platformos-language-server-common | **467** | unchanged | + +All packages type-check clean (via **direct `tsc --noEmit`** — see §8/F6 note on +the flaky `yarn workspace type-check` wrapper); `yarn format:check` clean; +`--frozen-lockfile` clean with **zero `yarn.lock` churn** (no new deps). The +**only** red anywhere is a pre-existing, local-only timeout in `TypeSystem.spec` +(a heavy LSP type-inference test that exceeds 5s only under full-suite parallel +load on a contended machine — confirmed passing at ~3.1s in isolation; proven +unrelated by a stash baseline; CI does not hit it). + +> **Working-tree note:** the graph/supervisor feature slices are committed on the +> branch. The **code-review remediation (§8)** — spanning common, check-common, +> graph, and supervisor — is currently **in the working tree, not yet committed**; +> the rebuilt `dist/` outputs for common, check-common, and graph are regenerated +> and also uncommitted. Commit both before opening the PR. --- @@ -137,14 +150,19 @@ ModuleStructural { construct → resolved target + kind + args. Both callers go through it, so the two paths can never drift: -1. **`buildAppGraph(rootUri, deps, entryPoints?)`** — full project graph from - disk. Entry points default to pages + layouts; traversal follows edges; a full - build additionally discovers standalone **schema** nodes. Populates incoming - `references`, `exists`, `table`, and `structural`. +1. **`buildAppGraph(rootUri, deps, entryPoints?, options?)`** — full project graph + from disk. Entry points default to pages + layouts; traversal follows edges; a + full build additionally discovers standalone **schema** nodes. Populates + incoming `references`, `exists`, and `table`. `structural` is populated **only + when `options.includeStructural` is set** (default off — see §8/F1); the LSP, + the sole full-build caller, does not read it and so does not pay for it. 2. **`extractFileReferences(rootUri, sourceUri, sourceCode, { fs })`** — one file's **outgoing** edges from an **in-flight buffer** (parsed by the caller, - may not be on disk). No whole-graph build, no reachability requirement. This is - what `validate_code` uses. + may not be on disk). No whole-graph build, no reachability requirement. +3. **`extractStructural(sourceCode, uri)`** *(exported in review — §8/F1)* — the + per-file **self-structural** primitive (sibling to `extractFileReferences`): + one buffer's own declarations, no build. `validate_code` uses paths 2 + 3 over + a single shared parse. ### 2.4 Resolution is delegated to `DocumentsLocator` @@ -203,8 +221,9 @@ From `@platformos/platformos-graph`: | Export | Purpose | |---|---| -| `buildAppGraph(rootUri, deps, entryPoints?)` | Build the full project graph from disk | +| `buildAppGraph(rootUri, deps, entryPoints?, options?)` | Build the full project graph from disk; `options.includeStructural` opts into per-module `structural` | | `extractFileReferences(rootUri, sourceUri, sourceCode, { fs })` | One buffer's outgoing edges (no full build) | +| `extractStructural(sourceCode, uri)` | One buffer's self-structural (no build); `undefined` for non-Liquid/unparseable | | `serializeAppGraph(graph)` | JSON `{ rootUri, nodes, edges }` | | `toSourceCode(uri, source)` | Parse a buffer into a `FileSourceCode` | | `dependenciesOf(graph, uri)` | Outgoing edges (what it renders/calls/wraps) — call sites w/ kind + args | @@ -215,22 +234,27 @@ From `@platformos/platformos-graph`: | `reachableFrom(graph, uri)` | Transitive outgoing closure | | `missingDependencies(graph, uri)` / `missingTargets(graph)` | Unresolved edges | | `nearestModules(graph, uri, { limit?, maxDistance? })` | Same-category "did you mean" by edit distance | -| `ModuleStructural` (on `LiquidModule.structural`) | The file's own declarations | +| `ModuleStructural` / `GraphBuildOptions` | Self-structural shape; build options | There is also a CLI (`bin/platformos-graph`): `platformos-graph [file]`. ### What `validate_code` discovers today -Per call (`{ file_path, content, mode }`), in one pass over the in-flight buffer: +Per call (`{ file_path, content, mode }`), over the in-flight buffer (parsed +**once**, shared by the two graph primitives — §8/F1): - **Lint** (check-node `lintBuffer`): errors / warnings / infos with 1-based positions, `must_fix_before_write` gate, `status`. - **`dependencies`** (graph `extractFileReferences`): every statically-resolvable - outgoing edge — `kind`, **project-relative resolved target**, 1-based line/col. - This is the "how it sits in the project" signal. It does **not** re-flag missing - targets (the linter's `MissingPartial` already does); its value is the - canonical resolved path + kind. + outgoing edge — typed `kind`, **project-relative resolved target**, 1-based + line/col. This is the "how it sits in the project" signal. It does **not** + re-flag missing targets (the linter's `MissingPartial` already does); its value + is the canonical resolved path + kind. +- **`structural`** (graph `extractStructural`) *(wired in review — §8/F1)*: the + file's own declarations for this buffer (`renders_used`, `graphql_queries_used`, + `filters_used`, `tags_used`, `translation_keys`, `doc_params`, and the routing + facts `slug`/`layout`/`method`), or `null` for a non-Liquid/unparseable buffer. Still graph-side / not yet surfaced in `validate_code` (see §6): the project-wide -queries (dependents/orphans/nearest-name) and the self-`structural` snapshot. +queries (dependents/orphans/nearest-name), which need a cached full build. --- @@ -273,14 +297,32 @@ Output (verbatim): "parse_error": null, "tips": [], "domain_guide": null, - "structural": null + "structural": { + "renders_used": ["header"], + "graphql_queries_used": [], + "filters_used": [], + "tags_used": ["function", "render"], + "translation_keys": [], + "doc_params": [], + "slug": null, + "layout": "theme", + "method": null + } } ``` -Note the clean separation: the **lint error** and the **resolved dependencies** -coexist without conflation. `dependencies` resolves the frontmatter `layout`, the -`{% function %}` lib query, and the `{% render %}` partial to their canonical -project-relative paths with exact positions. +Note the clean separation of three signals that coexist without conflation: +- the **lint error** (`MissingContentForLayout`); +- the **resolved dependencies** — the frontmatter `layout`, the `{% function %}` + lib query, and the `{% render %}` partial, each resolved to its canonical + project-relative path with an exact position; +- the file's own **structural** facts — what it renders (`header`), the tags it + uses (`function`, `render`), and its routing (`layout: theme`; `slug`/`method` + are `null` because this is a layout, not a page). + +The `structural` block was `null` in the pre-review revision (built on graph +modules but not reachable from the per-file `validate_code` path); §8/F1 exported +`extractStructural` and wired it in. ### 5.2 Graph self-structural — `header.liquid` @@ -309,35 +351,41 @@ For ## 6. Open doubts / risks / follow-ups -1. **`structural` is built but not surfaced in `validate_code`** (`"structural": null` - above). Wiring it is **TASK-8.4** (result shaping). It's also a full-build node - fact; the per-file `extractFileReferences` path doesn't compute it — so to put - self-structural in `validate_code` we either (a) add a per-buffer structural - extractor (the AST is already parsed in the structure adapter — cheap), or (b) - keep it project-build-only. **Decision needed.** Recommendation: (a), reusing - the same `extractStructural` over the buffer AST. +> Post-review status. Doubt #1 was the review's High finding (F1) and is now +> **resolved**; the rest are unchanged unless annotated. See §8 for the full +> mapping of doubts → findings → fixes. + +1. ~~**`structural` is built but not surfaced in `validate_code`.**~~ **RESOLVED + (§8/F1).** `extractStructural` is now an exported per-file primitive; the + supervisor computes `structural` for the in-flight buffer over the same parse + it uses for `dependencies`, and eager per-module population on a full build is + now opt-in (`includeStructural`, default off) so the LSP stops paying for a + fact it never reads. 2. **Project-wide queries need a cached full build.** `dependentsOf`, `orphans`, `nearestModules` require `buildAppGraph` (O(project), parse-heavy). `validate_code` - currently does a per-file build only. Surfacing "who renders this / did-you-mean" - to the agent needs a cached graph at the supervisor edge (ADR 003 open question - #5 — TTL vs explicit invalidation). Not built. + currently does a per-file resolution only. Surfacing "who renders this / + did-you-mean" to the agent needs a cached graph at the supervisor edge (ADR 003 + open question #5 — TTL vs explicit invalidation). Not built. 3. **`orphans()` completeness depends on how the graph was built.** `buildAppGraph` - only materializes modules reachable from entry points (+ schema). To list - genuinely-unreferenced partials you must build with every file as an entry - point. Documented in `query.ts`; a convenience "whole-project" build mode may be - worth adding. + only materializes modules reachable from entry points (+ schema on a full + build). To list genuinely-unreferenced partials you must build with every file + as an entry point. Documented in `query.ts` and now in the `buildAppGraph` + JSDoc (§8/F4); a convenience "whole-project" build mode may be worth adding. 4. **graphql `table` extraction is path/AST-based, single-style.** It reads the first `table` object/`table:"x"` filter via the GraphQL AST. Exotic query shapes (computed table, multiple tables) resolve to the first/none. Adequate for - resource grouping; not a full GraphQL semantic analysis. + resource grouping; not a full GraphQL semantic analysis. (The extractor moved + to check-common alongside `extractSchemaTable` — §8/F7 — but its behavior is + unchanged.) -5. **Schema table name = the YAML `name:`.** Files with no `name:` get a node with - `table` undefined (matching the old scanner's skip-ish behavior, but we still - model the node). If a project keys schemas differently, the join in TASK-9.7 - would miss them. +5. **Schema table name = the YAML `name:`.** Files with no (or a non-string) + `name:` get a node with `table` undefined. The review made the non-string + handling explicit and consistent with the slug rule (§8/F8), but the underlying + "we key schemas by `name:`" assumption stands; a project that keys schemas + differently would miss the join in TASK-9.7. 6. **TASK-9.7 (resource/CRUD completeness) is deferred by design.** It's the commands/queries convention overlay; per ADR 004 it must NOT enter the graph. @@ -346,12 +394,16 @@ For 7. **The `TypeSystem.spec` timeout** is a pre-existing local-only flake (5s default timeout under full-suite parallel load). Not caused by this branch (verified via - stash baseline); CI is green. If it becomes noisy, bump that one test's timeout - — but it is *not* masking a real failure. + stash baseline **and** re-confirmed during review: the test passes in ~3.1s in + isolation); CI is green. If it becomes noisy, bump that one test's timeout — but + it is *not* masking a real failure. -8. **Branch name typo** (`supervisor-graph-integration` is fine; the upstream graph - work lived on `extend-platfomos-graph` — note the misspelling there if cherry- - picking history). +8. **`validate_code` re-globs + re-parses the whole project every call.** The + dominant per-call cost is check-node's `getApp` (glob + parse the entire + project) inside `lintBuffer`, with no memoization — pre-existing, not introduced + by this branch. The review scoped this out (§8/F3) and recommends memoizing + `getApp` per project as separate work; it is the high-value perf lever, well + above the buffer-parse micro-optimizations. --- @@ -366,6 +418,148 @@ For | 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | | 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | | 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | -| 8.4 surface `structural` in `validate_code` | ⬜ Open (doubt #1) | +| 9.8 code-review remediation (this review — §8) | ✅ Done (F3 deferred) | +| 8.4 surface `structural` in `validate_code` | ✅ Done (via §8/F1) | ADRs: `docs/mcp-supervisor/decisions/003-…` (resolved), `004-platform-facts-vs-conventions` (accepted). + +--- + +## 8. Code review & remediation + +Before submitting, the full branch (`git diff master...HEAD`) went through a +high-effort review: **8 independent finder angles** (3 correctness — line-by-line, +removed-behavior, cross-file tracer; 3 cleanup — reuse, simplification, efficiency; +1 altitude; 1 conventions/CLAUDE.md), each returning candidate findings, then a +recall-biased verification pass. **11 findings** survived. **10 were fixed, 1 +deferred** with rationale. Every fix was landed surgically with the relevant +package suites, type-check, and format re-run green after each. Tracked in +**TASK-9.8**. + +### What the review confirmed clean (no action) +- **CLAUDE.md conventions:** no violations. Path handling uses the sanctioned + `URI.file()` + check-common `normalize()` (no manual `\\`→`/`); new tests use + whole-value `toEqual` (the `.toBe(true/false)`/`.toBeNull()` cases fall under the + documented boolean/presence exception). +- **Type-safety / exhaustiveness:** `assertNever` in the `ModuleType` switch still + compiles; `SerializableNode` does **not** leak `structural`/`table`; the new + required `dependencies` field flows through the single constructor. +- **Refuted candidates:** `path.relative` arg order is correct; `runStructure` is + safe on non-Liquid buffers (resolver returns `[]` on an `Error` AST); layout node + dedup already held on Linux + Windows CI (F10 is hardening, not a live bug). + +### Findings & fixes + +Severity in brackets. Each links the area touched. + +**F1 — [Architecture/High] Self-structural was built but unreachable, and the LSP +paid for it.** The graph populated `LiquidModule.structural` on every full build, +but (a) `validate_code` uses the per-file path and so returned `"structural": null`, +and (b) the LSP — the only full-build caller — never reads `structural` yet paid to +compute it each rebuild. **Fix:** exported `extractStructural(sourceCode, uri)` as a +per-file primitive (non-Liquid-safe → `undefined`); the supervisor's structure +adapter now returns `{ dependencies, structural }` from **one shared parse**; eager +per-module population is gated behind `buildAppGraph(..., { includeStructural })` +(default off). Added `graphql_queries_used` to the supervisor's structural snapshot +for parity with the graph model. *(User decision: full fix incl. the LSP gate.)* + +**F2 — [Robustness/Med] A structure-adapter failure could sink the lint gate.** +`validate_code` ran `Promise.all([lint, structure])`; a rejection in the secondary +structure adapter would discard the primary `must_fix_before_write` diagnostics. +**Fix:** structure failure now degrades to `{ dependencies: [], structural: null }` +(logged); lint stays primary and still propagates. Covered by an injectable-adapter +unit spec (degrade + propagate + passthrough). + +**F3 — [Efficiency/Med] DEFERRED.** The edited buffer is parsed by the structure +adapter and again inside `lintBuffer`. Fully de-duping this needs an additive change +to check-node's shared `lintBuffer` API and cross-file-type parse reconciliation, +for a saving that is marginal against the pre-existing whole-project `getApp` parse +(doubt #8) that dominates each call. Deferred with a recommendation to memoize +`getApp` per project instead. *(The intra-adapter half — sharing one parse between +`extractFileReferences` and `extractStructural` — was done as part of F1.)* + +**F4 — [Altitude/Med] Schema-discovery contract was implicit.** Discovery keyed on +`entryPoints === undefined`, and `isOrphan` special-cased `type === Schema`. **Fix +(user decision: document, keep the discriminant):** the `buildAppGraph` JSDoc now +states the full-build (auto-discovers pages+layouts+schema) vs scoped (verbatim; no +schema) contract explicitly; the `ModuleType.Schema` guard stays as the idiomatic +single-property check. We **deliberately did not** add a speculative +`standalone`/`reachabilityParticipating` flag — with one non-reachability leaf kind +it would be more state to keep in sync, not less (YAGNI until a second appears). + +**F5 — [Reuse/Med] Three duplications collapsed to shared helpers.** +- (a) `toAbsoluteFilePath` + `AdapterInput` shared by the `lint` and `structure` + adapters (was copy-pasted `node:path` resolution). +- (b) `effectivePageSlug` in `platformos-common` is now the single slug-derivation, + used by both `RouteTable` and the graph — which also **fixed a latent drift**: the + graph previously ignored a frontmatter `format:` override that `RouteTable` honors. +- (c) `isTranslationKeyUsage` in check-common is the single "string literal piped + through `t`/`translate`" predicate, used by both the `TranslationKeyExists` check + and `extractStructural`. + +**F6 — [Efficiency/Low-Med] Redundant traversals removed.** `extractStructural` now +does **one** AST walk — doc `@param` names are collected in the same pass (reading +the parser-produced `LiquidDocParamNode`, not re-implementing the liquid-doc parser) +instead of a second `extractDocDefinition` traversal. `buildAppGraph` now does +**one** directory sweep, partitioning pages/layouts and schema files by extension +(was two full-tree walks). *(This fix also surfaced a real type error — see the +tooling note below.)* + +**F7 — [Altitude/Low] Schema `table` extraction moved beside the parser.** Added +exported `extractSchemaTable` in check-common (mirrors `extractGraphqlTable`, reuses +the `js-yaml` that package already owns); the graph consumes it instead of an inline +local parse — symmetric ownership for the two "platform table" facts. + +**F8 — [Correctness/Low] Non-string `slug`/`name` handled consistently.** Routing +the graph's slug through `effectivePageSlug` (F5b) makes non-string frontmatter +`slug` behave exactly as `RouteTable` does (the routing source of truth), rather +than diverging; `extractSchemaTable` similarly ignores a non-string `name:`. + +**F9 — [Altitude/Low] `ValidateCodeDependency.kind` is now a real seam.** Was typed +`string` (stringly-typed yet still 1:1 coupled to the upstream `ReferenceKind`). +**Fix:** a supervisor-owned `ValidateCodeDependencyKind` union is the agent contract, +and the adapter maps `ReferenceKind → ` it through an exhaustive +`Record` — so an upstream add/rename is a **compile error** at the +seam, never a silent change to the agent surface. + +**F10 — [Latent/Low] Layout node identity hardened.** `getLayoutModule` / +`getPageModule` now normalize their stored URI like the other four factories, so a +node discovered as an entry point (raw fs URI) keys identically to the same file +resolved as an edge target (DocumentsLocator + normalized) — one node, never a split +identity that would drop an incoming edge. New `module.spec.ts` proves dedup across +backslash vs forward-slash URIs (each assertion would fail without the fix). + +**F11 — [Simplification/Minor]** `argNames` dropped a statically-always-true filter; +a single `argsField` helper is the one place the "omit `args` when empty" rule lives +(was duplicated in `bind` and `extractFileReferences`); the single-use +`frontmatterBody` → `loadFrontmatterOf` chain was collapsed. + +### Doubt → finding map +| Pre-review doubt (§6) | Review finding | Outcome | +|---|---|---| +| #1 structural not surfaced | F1 | Fixed | +| #8 whole-project re-parse cost | F3 | Deferred (recommend `getApp` memo) | +| #3 orphans completeness | F4 | Contract documented | +| #4 graphql table single-style | F7 | Moved (behavior unchanged) | +| #5 schema table = `name:` | F8 | Non-string handling made explicit | +| #7 TypeSystem flake | — | Re-confirmed unrelated | + +### Tooling note (worth a reviewer's attention) +During F6, `npx tsc --noEmit` caught a real control-flow-narrowing error +(`entryPoints` possibly `undefined`) that the `yarn workspace type-check` +wrapper **and** the vitest runs silently passed over. Type-checking in this review +was therefore done with **direct `tsc --noEmit` per package**. If CI relies on the +`yarn workspace` wrapper for type-checking, that gap is worth closing separately. + +### Review-era changes to the public surface (for the reviewer's eye) +All additive except two intentional output improvements and one perf default: +- `buildAppGraph` gains an optional 4th arg `options: GraphBuildOptions` + (`includeStructural`, default off). **Behavior change:** a full build no longer + populates `module.structural` unless opted in — intentional (F1); no current + consumer read it. +- New exports: `extractStructural`, `GraphBuildOptions` (graph); `extractSchemaTable`, + `isTranslationKeyUsage`, `TRANSLATION_FILTERS` (check-common); `effectivePageSlug` + (common). +- `validate_code` result: `structural` is now populated (was always `null`); + `dependencies[].kind` is now a typed union (was `string`). Both are widened/filled, + not removed — existing consumers keep working. diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts index e0163b13..02edbc16 100644 --- a/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts +++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.spec.ts @@ -124,9 +124,23 @@ describe('DocumentsLocator', () => { ); }); - it('asset → undefined (no canonical creation path)', () => { + it('asset → app/assets (canonical location; the reference carries its own extension)', () => { const locator = new DocumentsLocator(createMockFileSystem({})); - expect(locator.locateDefault(rootUri, 'asset', 'logo.png')).toBeUndefined(); + expect(locator.locateDefault(rootUri, 'asset', 'emails/logo.png')).toBe( + 'file:///project/app/assets/emails/logo.png', + ); + }); + + it('module asset → modules/.../public/assets', () => { + const locator = new DocumentsLocator(createMockFileSystem({})); + expect(locator.locateDefault(rootUri, 'asset', 'modules/core/logo.png')).toBe( + 'file:///project/modules/core/public/assets/logo.png', + ); + }); + + it('theme_render_rc → undefined (no single canonical location)', () => { + const locator = new DocumentsLocator(createMockFileSystem({})); + expect(locator.locateDefault(rootUri, 'theme_render_rc', 'card')).toBeUndefined(); }); it('layout → app/views/layouts (.liquid canonical default)', () => { @@ -332,6 +346,26 @@ describe('DocumentsLocator', () => { expect(result).toBe('file:///project/app/views/layouts/theme.liquid'); }); + it('locateOrDefault returns the existing asset under app/assets', async () => { + const fs = createMockFileSystem({ + 'file:///project/app/assets/emails/logo.png': 'binary', + }); + const locator = new DocumentsLocator(fs); + + const result = await locator.locateOrDefault(rootUri, 'asset', 'emails/logo.png'); + + expect(result).toBe('file:///project/app/assets/emails/logo.png'); + }); + + it('locateOrDefault falls back to the canonical app/assets path for a missing asset', async () => { + const fs = createMockFileSystem({}); + const locator = new DocumentsLocator(fs); + + const result = await locator.locateOrDefault(rootUri, 'asset', 'images/missing.png'); + + expect(result).toBe('file:///project/app/assets/images/missing.png'); + }); + it('should locate an asset by its own extension (no extension appended)', async () => { const fs = createMockFileSystem({ 'file:///project/app/assets/logo.png': 'binary', diff --git a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts index 715e2cf6..b03ff47d 100644 --- a/packages/platformos-common/src/documents-locator/DocumentsLocator.ts +++ b/packages/platformos-common/src/documents-locator/DocumentsLocator.ts @@ -304,8 +304,16 @@ export class DocumentsLocator { : 'app/views/layouts'; ext = '.liquid'; break; + case 'asset': + // Assets carry their own extension in the reference (e.g. `logo.png`), + // so nothing is appended. There is no "creation" path, but there IS a + // canonical location — `app/assets` (or the module's public assets) — + // used as the fallback so an unresolved asset reference still yields a + // canonical target URI. + basePath = parsed.isModule ? `modules/${parsed.moduleName}/public/assets` : 'app/assets'; + ext = ''; + break; case 'theme_render_rc': // ambiguous — multiple search paths, no single canonical location - case 'asset': // no canonical creation path return undefined; default: return undefined; diff --git a/packages/platformos-graph/fixtures/asset-edges/app/assets/logo.css b/packages/platformos-graph/fixtures/asset-edges/app/assets/logo.css new file mode 100644 index 00000000..991912d8 --- /dev/null +++ b/packages/platformos-graph/fixtures/asset-edges/app/assets/logo.css @@ -0,0 +1 @@ +body { color: red; } diff --git a/packages/platformos-graph/fixtures/asset-edges/app/assets/site/app.js b/packages/platformos-graph/fixtures/asset-edges/app/assets/site/app.js new file mode 100644 index 00000000..702645f1 --- /dev/null +++ b/packages/platformos-graph/fixtures/asset-edges/app/assets/site/app.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/packages/platformos-graph/fixtures/asset-edges/app/views/pages/index.liquid b/packages/platformos-graph/fixtures/asset-edges/app/views/pages/index.liquid new file mode 100644 index 00000000..70c0d917 --- /dev/null +++ b/packages/platformos-graph/fixtures/asset-edges/app/views/pages/index.liquid @@ -0,0 +1,3 @@ + + + diff --git a/packages/platformos-graph/fixtures/skeleton/assets/app.css b/packages/platformos-graph/fixtures/skeleton/app/assets/app.css similarity index 100% rename from packages/platformos-graph/fixtures/skeleton/assets/app.css rename to packages/platformos-graph/fixtures/skeleton/app/assets/app.css diff --git a/packages/platformos-graph/fixtures/skeleton/assets/app.js b/packages/platformos-graph/fixtures/skeleton/app/assets/app.js similarity index 100% rename from packages/platformos-graph/fixtures/skeleton/assets/app.js rename to packages/platformos-graph/fixtures/skeleton/app/assets/app.js diff --git a/packages/platformos-graph/src/cli.spec.ts b/packages/platformos-graph/src/cli.spec.ts index f45149f5..533109c6 100644 --- a/packages/platformos-graph/src/cli.spec.ts +++ b/packages/platformos-graph/src/cli.spec.ts @@ -19,7 +19,20 @@ describe('platformos-graph CLI: buildSerializedGraph', () => { expect(graph.rootUri).toBe(URI.file(skeletonPath).toString(true)); // Whole node set, sorted by URI so the assertion is order-independent. + // (`app/assets/*` sorts ahead of `app/views/*`.) expect([...graph.nodes].sort((a, b) => a.uri.localeCompare(b.uri))).toEqual([ + { + uri: p('app/assets/app.css'), + type: ModuleType.Asset, + kind: 'unused', + exists: true, + }, + { + uri: p('app/assets/app.js'), + type: ModuleType.Asset, + kind: 'unused', + exists: true, + }, { uri: p('app/views/layouts/application.liquid'), type: ModuleType.Liquid, @@ -50,18 +63,6 @@ describe('platformos-graph CLI: buildSerializedGraph', () => { kind: LiquidModuleKind.Partial, exists: true, }, - { - uri: p('assets/app.css'), - type: ModuleType.Asset, - kind: 'unused', - exists: true, - }, - { - uri: p('assets/app.js'), - type: ModuleType.Asset, - kind: 'unused', - exists: true, - }, ]); // Whole edge set, reduced to (source, target, type, kind) and sorted, so an @@ -78,21 +79,21 @@ describe('platformos-graph CLI: buildSerializedGraph', () => { expect(edges).toEqual([ { source: p('app/views/layouts/application.liquid'), - target: p('app/views/partials/header.liquid'), + target: p('app/assets/app.css'), type: 'direct', - kind: 'render', + kind: 'asset', }, { source: p('app/views/layouts/application.liquid'), - target: p('assets/app.css'), + target: p('app/assets/app.js'), type: 'direct', kind: 'asset', }, { source: p('app/views/layouts/application.liquid'), - target: p('assets/app.js'), + target: p('app/views/partials/header.liquid'), type: 'direct', - kind: 'asset', + kind: 'render', }, { source: p('app/views/pages/index.liquid'), @@ -124,28 +125,28 @@ describe('platformos-graph CLI: buildSerializedGraph', () => { describe('platformos-graph CLI: nodeFileSystem', () => { it('reads a file by URI', async () => { - const uri = path.join(skeleton, 'assets', 'app.js'); + const uri = path.join(skeleton, 'app', 'assets', 'app.js'); expect(await nodeFileSystem.readFile(uri)).toBe( '// Skeleton app bundle (asset reference target).\n', ); }); it('stats a directory and a file', async () => { - expect(await nodeFileSystem.stat(path.join(skeleton, 'assets'))).toEqual({ + expect(await nodeFileSystem.stat(path.join(skeleton, 'app', 'assets'))).toEqual({ type: FileType.Directory, size: expect.any(Number), }); - expect(await nodeFileSystem.stat(path.join(skeleton, 'assets', 'app.css'))).toEqual({ + expect(await nodeFileSystem.stat(path.join(skeleton, 'app', 'assets', 'app.css'))).toEqual({ type: FileType.File, size: expect.any(Number), }); }); it('lists a directory as [childUri, FileType] tuples', async () => { - const entries = await nodeFileSystem.readDirectory(path.join(skeleton, 'assets')); + const entries = await nodeFileSystem.readDirectory(path.join(skeleton, 'app', 'assets')); expect([...entries].sort((a, b) => a[0].localeCompare(b[0]))).toEqual([ - [path.join(skeleton, 'assets', 'app.css'), FileType.File], - [path.join(skeleton, 'assets', 'app.js'), FileType.File], + [path.join(skeleton, 'app', 'assets', 'app.css'), FileType.File], + [path.join(skeleton, 'app', 'assets', 'app.js'), FileType.File], ]); }); }); diff --git a/packages/platformos-graph/src/graph/build.spec.ts b/packages/platformos-graph/src/graph/build.spec.ts index 84513d9b..b4d0fd9f 100644 --- a/packages/platformos-graph/src/graph/build.spec.ts +++ b/packages/platformos-graph/src/graph/build.spec.ts @@ -51,8 +51,8 @@ describe('Module: index', () => { const deps = layout.dependencies; expect(deps.map((x) => x.target.uri)).toEqual( expect.arrayContaining([ - p('assets/app.js'), - p('assets/app.css'), + p('app/assets/app.js'), + p('app/assets/app.css'), p('app/views/partials/header.liquid'), ]), ); @@ -112,8 +112,8 @@ describe('Module: index', () => { expect( layout.dependencies.map((d) => ({ target: d.target.uri, type: d.type, kind: d.kind })), ).toEqual([ - { target: p('assets/app.js'), type: 'direct', kind: 'asset' }, - { target: p('assets/app.css'), type: 'direct', kind: 'asset' }, + { target: p('app/assets/app.js'), type: 'direct', kind: 'asset' }, + { target: p('app/assets/app.css'), type: 'direct', kind: 'asset' }, { target: p('app/views/partials/header.liquid'), type: 'direct', kind: 'render' }, ]); }); diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index c9304f99..7fa312b9 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -47,35 +47,48 @@ export function getModule(appGraph: AppGraph, uri: UriString): AppModule | undef return getPartialModule(appGraph, path.basename(uri, '.liquid')); case relativePath.startsWith('assets') || relativePath.startsWith('modules'): - return getAssetModule(appGraph, path.basename(uri)); + // The full URI is already resolved on-disk here, so use it directly rather + // than rebuilding a path from the basename. + return getAssetModuleByUri(appGraph, uri); } } -export function getAssetModule(appGraph: AppGraph, asset: string): AssetModule | undefined { - const extension = extname(asset); - - const SUPPORTED_ASSET_EXTENSIONS = [ - ...SUPPORTED_ASSET_IMAGE_EXTENSIONS, - 'js', - 'css', - 'svg', - 'pdf', - 'woff', - 'woff2', - 'ttf', - 'eot', - ]; - - if (!SUPPORTED_ASSET_EXTENSIONS.includes(extension)) { - return undefined; - } +/** File extensions the graph treats as assets (the `asset_url` edge gate). */ +const SUPPORTED_ASSET_EXTENSIONS = [ + ...SUPPORTED_ASSET_IMAGE_EXTENSIONS, + 'js', + 'css', + 'svg', + 'pdf', + 'woff', + 'woff2', + 'ttf', + 'eot', +]; + +/** + * Whether `name` refers to a supported asset file (by extension). The gate for + * creating an asset edge, preserving the graph's historical behavior of ignoring + * an `asset_url`-family filter applied to a value that is not an asset file. + */ +export function isSupportedAssetFile(name: string): boolean { + return SUPPORTED_ASSET_EXTENSIONS.includes(extname(name)); +} +/** + * Create (or fetch the cached) Asset module for an ALREADY-RESOLVED asset URI — + * used for `asset_url`/`asset_img_url`/`inline_asset_content` targets whose URI is + * resolved canonically by `DocumentsLocator` (`'asset'` type: `app/assets`, module + * `public/assets`). A leaf node. Normalizes the URI — see + * {@link getPartialModuleByUri} for why DocumentsLocator URIs must be normalized. + */ +export function getAssetModuleByUri(appGraph: AppGraph, uri: string): AssetModule { return module(appGraph, { type: ModuleType.Asset, kind: 'unused', + uri: path.normalize(uri), dependencies: [], references: [], - uri: path.join(appGraph.rootUri, 'assets', asset), }); } diff --git a/packages/platformos-graph/src/graph/query.spec.ts b/packages/platformos-graph/src/graph/query.spec.ts index 11fb795d..ebe04148 100644 --- a/packages/platformos-graph/src/graph/query.spec.ts +++ b/packages/platformos-graph/src/graph/query.spec.ts @@ -72,13 +72,14 @@ describe('Graph queries: over the built skeleton app graph', () => { }); it('reachableFrom returns the transitive outgoing closure', () => { + // Sorted by URI — `app/assets/*` sorts ahead of `app/views/*`. expect(reachableFrom(graph, p('app/views/pages/index.liquid'))).toEqual([ + p('app/assets/app.css'), + p('app/assets/app.js'), p('app/views/layouts/application.liquid'), p('app/views/partials/child.liquid'), p('app/views/partials/header.liquid'), p('app/views/partials/parent.liquid'), - p('assets/app.css'), - p('assets/app.js'), ]); }); diff --git a/packages/platformos-graph/src/graph/traverse-edges.spec.ts b/packages/platformos-graph/src/graph/traverse-edges.spec.ts index a14cf9fd..fe43218c 100644 --- a/packages/platformos-graph/src/graph/traverse-edges.spec.ts +++ b/packages/platformos-graph/src/graph/traverse-edges.spec.ts @@ -4,6 +4,7 @@ import { buildAppGraph } from '../index'; import { AppGraph, AppModule, + AssetModule, Dependencies, LiquidModule, LiquidModuleKind, @@ -387,6 +388,76 @@ describe('Graph traversal: layout-association edges (frontmatter `layout:`)', () }); }); +describe('Graph traversal: asset edges (asset_url resolves under app/assets)', () => { + const rootUri = pathUtils.join(fixturesRoot, 'asset-edges'); + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + let graph: AppGraph; + let indexSource: string; + + const assetNode = (uri: string, exists: boolean, references: Reference[]): AssetModule => ({ + type: ModuleType.Asset, + kind: 'unused', + uri, + exists, + dependencies: [], + references, + }); + + // The asset edge carries the `{{ … }}` output's LiquidVariable range: from the + // first char after `{{ ` up to the `}}` (the parser includes the trailing + // space). Derived from the fixture text around the given literal so it stays + // correct as the fixture changes. + const outputRange = (literal: string): [number, number] => { + const at = indexSource.indexOf(literal); + if (at < 0) throw new Error(`literal not found in fixture: ${literal}`); + return [indexSource.lastIndexOf('{{', at) + '{{ '.length, indexSource.indexOf('}}', at)]; + }; + + const assetEdge = (literal: string, targetPart: string): Reference => + directRef(p('app/views/pages/index.liquid'), outputRange(literal), p(targetPart), 'asset'); + + beforeAll(async () => { + const dependencies: Dependencies = getDependencies(); + graph = await buildAppGraph(rootUri, dependencies); + indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source; + }, 15000); + + it('resolves an asset (relative to app/assets) to an exists:true Asset node', () => { + const edge = assetEdge("'site/app.js'", 'app/assets/site/app.js'); + expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toContainEqual(edge); + expect(graph.modules[p('app/assets/site/app.js')]).toEqual( + assetNode(p('app/assets/site/app.js'), true, [edge]), + ); + }); + + it('resolves a top-level asset under app/assets', () => { + const edge = assetEdge("'logo.css'", 'app/assets/logo.css'); + expect(graph.modules[p('app/assets/logo.css')]).toEqual( + assetNode(p('app/assets/logo.css'), true, [edge]), + ); + }); + + it('records a missing asset as an exists:false Asset node at its canonical app/assets path', () => { + const edge = assetEdge("'images/missing.png'", 'app/assets/images/missing.png'); + expect(graph.modules[p('app/assets/images/missing.png')]).toEqual( + assetNode(p('app/assets/images/missing.png'), false, [edge]), + ); + }); + + it('emits exactly the three asset edges for the page, in source order', () => { + expect( + graph.modules[p('app/views/pages/index.liquid')].dependencies.map((d) => ({ + kind: d.kind, + target: d.target.uri, + })), + ).toEqual([ + { kind: 'asset', target: p('app/assets/site/app.js') }, + { kind: 'asset', target: p('app/assets/logo.css') }, + { kind: 'asset', target: p('app/assets/images/missing.png') }, + ]); + }); +}); + describe('Graph traversal: schema/CustomModelType nodes (full-build discovery)', () => { const rootUri = pathUtils.join(fixturesRoot, 'schema-nodes'); const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index f685a906..de39dfef 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -32,10 +32,11 @@ import { } from '../types'; import { assertNever, exists, isString, unique } from '../utils'; import { - getAssetModule, + getAssetModuleByUri, getGraphQLModuleByUri, getLayoutModuleByUri, getPartialModuleByUri, + isSupportedAssetFile, } from './module'; /** A resolved outgoing reference: the target graph node + its call-site range + kind (+ named-arg names). */ @@ -172,10 +173,15 @@ async function resolveLiquidReferences( if (parentNode.expression.type !== NodeTypes.String) return; if (parentNode.filters[0] !== node) return; const asset = parentNode.expression.value; - const assetModule = getAssetModule(appGraph, asset); - if (!assetModule) return; + if (!isSupportedAssetFile(asset)) return; // ignore non-asset values (unchanged gate) + // Resolve through DocumentsLocator (`'asset'`: app/assets, module + // public/assets) — not a hard-coded base — so the target matches the + // real on-disk location, with the canonical `app/assets/` as the + // fallback for an unresolved asset. + const uri = await documentsLocator.locateOrDefault(rootUri, 'asset', asset); + if (!uri) return; return { - target: assetModule, + target: getAssetModuleByUri(appGraph, uri), sourceRange: [parentNode.position.start, parentNode.position.end], kind: 'asset', }; diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts index b251ee57..f27f0356 100644 --- a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts +++ b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts @@ -60,12 +60,13 @@ describe('Integration: runStructure (structure adapter)', () => { ]); }); - it('maps an asset filter to an asset dependency', async () => { + it('maps an asset filter to an asset dependency (resolved under app/assets)', async () => { // The asset edge's range is the `'app.js' | asset_url` expression (the - // variable output), which starts at column 4 — after `{{ `. + // variable output), which starts at column 4 — after `{{ `. The target + // resolves to the canonical `app/assets/` location (see DocumentsLocator). expect( await deps('app/views/layouts/application.liquid', "{{ 'app.js' | asset_url }}"), - ).toEqual([{ kind: 'asset', target: 'assets/app.js', line: 1, column: 4 }]); + ).toEqual([{ kind: 'asset', target: 'app/assets/app.js', line: 1, column: 4 }]); }); it('maps a frontmatter `layout:` to a layout dependency', async () => { From be8ad1cdb9c788af9eda606f5175d9e2935f9d85 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 08:06:58 +0200 Subject: [PATCH 09/20] Refactor code to replace dependency structure with impact analysis - Removed the `ValidateCodeDependency` and `ValidateCodeStructuralSnapshot` types and their associated logic. - Introduced `ValidateCodeImpact` type to represent cross-file blast radius. - Updated `assembleResult` function to accept and process impact data instead of dependencies and structural snapshots. - Modified `runStructure` to be removed, and its functionality replaced with impact analysis. - Adjusted `runValidateCode` to orchestrate linting and impact analysis, ensuring lint failures take precedence. - Updated tests to reflect changes in the impact analysis and ensure correct behavior. - Removed obsolete structure tests and integrated impact tests into the validation flow. --- SUPERVISOR-GRAPH-INTEGRATION.md | 236 +++++++++++++----- .../README.md | 22 +- .../src/adapter-input.ts | 2 +- .../src/graph-cache/graph-cache.spec.ts | 188 ++++++++++++++ .../src/graph-cache/graph-cache.ts | 183 ++++++++++++++ .../src/impact/impact.spec.ts | 193 ++++++++++++++ .../src/impact/impact.ts | 163 ++++++++++++ .../src/result/assemble.spec.ts | 66 ++--- .../src/result/assemble.ts | 24 +- .../src/result/types.ts | 103 ++++---- .../src/structure/structure.spec.ts | 176 ------------- .../src/structure/structure.ts | 122 --------- .../src/transport/server.ts | 10 +- .../src/transport/validate-code.spec.ts | 67 ++--- .../src/transport/validate-code.ts | 44 ++-- .../guards/architecture-invariants.spec.ts | 10 +- .../test/integration/stdio-smoke.spec.ts | 163 ++++++------ 17 files changed, 1147 insertions(+), 625 deletions(-) create mode 100644 packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts create mode 100644 packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts create mode 100644 packages/platformos-mcp-supervisor/src/impact/impact.spec.ts create mode 100644 packages/platformos-mcp-supervisor/src/impact/impact.ts delete mode 100644 packages/platformos-mcp-supervisor/src/structure/structure.spec.ts delete mode 100644 packages/platformos-mcp-supervisor/src/structure/structure.ts diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md index 2ec7c911..670998cd 100644 --- a/SUPERVISOR-GRAPH-INTEGRATION.md +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -32,10 +32,16 @@ graph to provide that model and consumes it from `validate_code`. — each carrying a semantic `kind`, call-site range, and named-arg names. - **Per-file primitive** `extractFileReferences` — resolves one in-flight buffer's outgoing edges without building the whole graph (the buffer-before-write model). -- **`validate_code` now returns `dependencies`** — resolved, project-relative - targets with a typed `kind` + 1-based position, alongside lint diagnostics. -- **`validate_code` now returns `structural`** *(added in review — §8/F1)* — the - file's own declarations for the in-flight buffer. +- **`validate_code` returns `impact`** *(TASK-9.10 — see §9)* — the cross-file + **blast radius**: who depends on the edited file (its callers), plus + **signature-impact** (callers whose args no longer match the file's `{% doc %}`). + This is the one graph signal lint cannot produce; the earlier per-file + `dependencies`/`structural` fields were **removed** as redundant with lint + (`MissingPartial`/`MissingAsset` + `PartialCallArguments`) — see §9. +- **Never-stale cached graph** *(TASK-9.10)* — a fingerprint-validated + `GraphCache` at the supervisor edge: reused across calls, background-built, and + **never served stale** (a changed project reports `computing`, never a wrong + answer). - **Project-structure query API** — dependents / orphans / reachability / missing-target / nearest-name ("did you mean"). - **Per-file self-structural** — `renders_used`, `graphql_queries_used`, @@ -53,8 +59,10 @@ graph to provide that model and consumes it from `validate_code`. - **Additive everywhere.** New optional fields (`Reference.kind`, `.args`, `GraphQLModule.table`, `LiquidModule.structural`, `SchemaModule`), new query functions, new DocumentType. No breaking change to existing consumers. -- **The LSP and `validate_code` outputs are unchanged** except the deliberate - new `dependencies` field — verified by whole-result assertions. +- **The LSP output is unchanged.** The `validate_code` output was reshaped by + TASK-9.10 (§9): `dependencies`/`structural` removed, `impact` added — a + deliberate change to a not-yet-released tool, verified by whole-result + assertions. - Cross-package safety re-verified after every slice: graph, check-common, check-node, language-server, supervisor. @@ -64,7 +72,7 @@ graph to provide that model and consumes it from `validate_code`. | platformos-common | **257** | +12 (`effectivePageSlug`) | | platformos-graph | **80** | +7 (structural opt-in/primitive, node-identity) | | platformos-check-common | **1057** | +10 (`extractSchemaTable`) | -| platformos-mcp-supervisor | **56** | +6 (orchestration, structural, adapter) | +| platformos-mcp-supervisor | **55** | 9.10: +graph-cache (7) +impact (10), −structure adapter | | platformos-language-server-common | **467** | unchanged | All packages type-check clean (via **direct `tsc --noEmit`** — see §8/F6 note on @@ -239,22 +247,25 @@ From `@platformos/platformos-graph`: There is also a CLI (`bin/platformos-graph`): `platformos-graph [file]`. ### What `validate_code` discovers today -Per call (`{ file_path, content, mode }`), over the in-flight buffer (parsed -**once**, shared by the two graph primitives — §8/F1): +Per call (`{ file_path, content, mode }`): - **Lint** (check-node `lintBuffer`): errors / warnings / infos with 1-based - positions, `must_fix_before_write` gate, `status`. -- **`dependencies`** (graph `extractFileReferences`): every statically-resolvable - outgoing edge — typed `kind`, **project-relative resolved target**, 1-based - line/col. This is the "how it sits in the project" signal. It does **not** - re-flag missing targets (the linter's `MissingPartial` already does); its value - is the canonical resolved path + kind. -- **`structural`** (graph `extractStructural`) *(wired in review — §8/F1)*: the - file's own declarations for this buffer (`renders_used`, `graphql_queries_used`, - `filters_used`, `tags_used`, `translation_keys`, `doc_params`, and the routing - facts `slug`/`layout`/`method`), or `null` for a non-Liquid/unparseable buffer. - -Still graph-side / not yet surfaced in `validate_code` (see §6): the project-wide -queries (dependents/orphans/nearest-name), which need a cached full build. + positions, `must_fix_before_write` gate, `status`. Lint is the **primary, + forward-looking** signal — it also covers broken references (`MissingPartial`/ + `MissingAsset`) and argument correctness (`PartialCallArguments`). +- **`impact`** (graph `dependentsOf` over the cached graph — §9): the + **backward, cross-file** signal lint cannot produce — + - `dependents`: who references the edited file (`total`, `by_kind`, a capped + `sample`), so the agent knows the blast radius before changing a shared file; + - `signature_risk`: existing callers whose args no longer match the buffer's + `{% doc %}` contract (the cross-file inverse of `PartialCallArguments`); + - `status` (`computed`/`computing`/`unavailable`): never stale — "nothing + depends on this" (computed, 0) is distinguishable from "not computed yet". + +The earlier per-file `dependencies`/`structural` fields were **removed** (§9): a +buffer's own outgoing edges + self-summary duplicated lint and echoed what the +agent just wrote. The graph primitives (`extractFileReferences`/ +`extractStructural`) remain for the CLI, serialization, and the planned +`project_map`/`validate_project` tools (TASK-9.11 / 9.12). --- @@ -289,40 +300,34 @@ Output (verbatim): "proposed_fixes": [], "clusters": [], "scorecard": [], - "dependencies": [ - { "kind": "layout", "target": "app/views/layouts/theme.liquid", "line": 1, "column": 1 }, - { "kind": "function", "target": "app/lib/queries/list.liquid", "line": 4, "column": 1 }, - { "kind": "render", "target": "app/views/partials/header.liquid", "line": 5, "column": 13 } - ], + "impact": { + "scope": "direct", + "status": "computed", + "dependents": { + "total": 2, + "by_kind": { "layout": 2 }, + "sample": ["app/views/pages/about.liquid", "app/views/pages/index.liquid"] + } + }, "parse_error": null, "tips": [], - "domain_guide": null, - "structural": { - "renders_used": ["header"], - "graphql_queries_used": [], - "filters_used": [], - "tags_used": ["function", "render"], - "translation_keys": [], - "doc_params": [], - "slug": null, - "layout": "theme", - "method": null - } + "domain_guide": null } ``` -Note the clean separation of three signals that coexist without conflation: -- the **lint error** (`MissingContentForLayout`); -- the **resolved dependencies** — the frontmatter `layout`, the `{% function %}` - lib query, and the `{% render %}` partial, each resolved to its canonical - project-relative path with an exact position; -- the file's own **structural** facts — what it renders (`header`), the tags it - uses (`function`, `render`), and its routing (`layout: theme`; `slug`/`method` - are `null` because this is a layout, not a page). +Two signals coexist without conflation: +- the **lint error** (`MissingContentForLayout`) — the per-file, forward-looking + gate; +- the **blast radius** (`impact`) — the cross-file signal lint cannot give: **2 + pages use this layout**, so the missing `content_for_layout` affects both. The + agent sees who is impacted before writing. (`status: computed` with `total: 0` + would instead mean "nothing depends on this — safe to change".) -The `structural` block was `null` in the pre-review revision (built on graph -modules but not reachable from the per-file `validate_code` path); §8/F1 exported -`extractStructural` and wired it in. +The buffer's own outgoing edges are covered by lint (`MissingPartial` / +`PartialCallArguments`), so the previously-shown `dependencies`/`structural` +fields were removed as redundant — see §9. For a **partial** edit whose `{% doc %}` +changed, `impact.signature_risk` additionally lists the callers whose arguments no +longer match. ### 5.2 Graph self-structural — `header.liquid` @@ -355,18 +360,20 @@ For > **resolved**; the rest are unchanged unless annotated. See §8 for the full > mapping of doubts → findings → fixes. -1. ~~**`structural` is built but not surfaced in `validate_code`.**~~ **RESOLVED - (§8/F1).** `extractStructural` is now an exported per-file primitive; the - supervisor computes `structural` for the in-flight buffer over the same parse - it uses for `dependencies`, and eager per-module population on a full build is - now opt-in (`includeStructural`, default off) so the LSP stops paying for a - fact it never reads. - -2. **Project-wide queries need a cached full build.** `dependentsOf`, `orphans`, - `nearestModules` require `buildAppGraph` (O(project), parse-heavy). `validate_code` - currently does a per-file resolution only. Surfacing "who renders this / - did-you-mean" to the agent needs a cached graph at the supervisor edge (ADR 003 - open question #5 — TTL vs explicit invalidation). Not built. +1. ~~**`structural` is built but not surfaced in `validate_code`.**~~ + **SUPERSEDED (TASK-9.10 — §9).** §8/F1 briefly surfaced `structural`, but the + whole-branch analysis showed `dependencies`/`structural` were redundant with + lint (they echo the buffer the agent just wrote). Both were **removed** from + `validate_code`; the graph's real contribution is the cross-file `impact` + (blast radius) instead. The graph primitives remain for `project_map`/ + `validate_project`. + +2. ~~**Project-wide queries need a cached full build.**~~ **RESOLVED (TASK-9.10 — + §9).** A never-stale, fingerprint-validated `GraphCache` at the supervisor edge + now backs `validate_code`'s `impact` (dependents / blast radius). It amortizes + the parse-heavy full build and reports `computing` rather than serving stale. + (ADR 003 open question #5 is now marked resolved.) `nearestModules` / + project-wide orphans remain for `validate_project` (TASK-9.12). 3. **`orphans()` completeness depends on how the graph was built.** `buildAppGraph` only materializes modules reachable from entry points (+ schema on a full @@ -418,10 +425,22 @@ For | 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | | 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | | 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | -| 9.8 code-review remediation (this review — §8) | ✅ Done (F3 deferred) | -| 8.4 surface `structural` in `validate_code` | ✅ Done (via §8/F1) | - -ADRs: `docs/mcp-supervisor/decisions/003-…` (resolved), `004-platform-facts-vs-conventions` (accepted). +| 9.8 code-review remediation (§8) | ✅ Done (F3 deferred) | +| 9.9 asset-resolution fix + real-project validation | ✅ Done | +| **9.10 cached graph → blast-radius in `validate_code`** (§9) | ✅ Done | +| 9.11 `project_map` (discovery + resource overlay) | ⬜ Scoped | +| 9.12 `validate_project` (health sweep + repair order) | ⬜ Scoped | +| 8.4 surface `structural` in `validate_code` | ↩︎ Superseded by 9.10 (removed) | + +**The three-tool graph strategy** (why the graph lives mostly *outside* +`validate_code`): lint owns the per-file forward-looking checks, so the graph's +value is cross-file. It is surfaced through three intent-shaped tools — +`validate_code` **blast radius** (change safety, at edit time — 9.10, done), +`project_map` (discovery, at task start — 9.11), and `validate_project` (health +sweep + dependency-ordered repair plan, at task end — 9.12). + +ADRs: `docs/mcp-supervisor/decisions/003-…` (resolved, incl. Q#5 via 9.10), +`004-platform-facts-vs-conventions` (accepted). --- @@ -563,3 +582,88 @@ All additive except two intentional output improvements and one perf default: - `validate_code` result: `structural` is now populated (was always `null`); `dependencies[].kind` is now a typed union (was `string`). Both are widened/filled, not removed — existing consumers keep working. + +--- + +## 9. Cached graph → blast radius in `validate_code` (TASK-9.10) + +### Why (the reframing) +The whole-branch review (§8) established that the graph's per-file outputs in +`validate_code` were **redundant with lint**: broken references → +`MissingPartial`/`MissingAsset`; argument correctness → `PartialCallArguments`; +the rest echoed the buffer the agent just wrote. The one thing lint *structurally +cannot* do is the **backward / cross-file** direction — "editing this file breaks +its N callers." TASK-9.10 delivers exactly that and removes the redundant fields. + +### What shipped +- **`GraphCache`** (`src/graph-cache/`) — one per project/server. Builds the full + `AppGraph` once (all liquid files as entry points → **complete dependents**) and + reuses it, background-built and **never awaited** on the request path. +- **`impact`** in the `validate_code` result (`src/impact/`): + - `dependents`: `{ total, by_kind, sample≤10 }` — who references the edited file + (via `dependentsOf`), distinct files, capped, sorted; + - `signature_risk`: dependent callers whose passed args (from the graph's edge + `args`) violate the buffer's `{% doc %}` contract — missing a required + `@param` or passing an undeclared one. The cross-file **inverse** of + `PartialCallArguments`; conservative (doc-block only, no inference); + - `status`: `computed | computing | unavailable`. +- **Removed** `dependencies` and `structural` from the result (+ the dead + structure adapter). Graph primitives kept for CLI / serialize / 9.11 / 9.12. + +### Freshness — never stale (the load-bearing decision) +Staleness would mislead the agent (a stale "0 dependents → safe to change" is the +worst-case), so **TTL and stale-while-revalidate were both rejected**. The cache +is **fingerprint-validated on every request**: a cheap `mtime:size` stat-scan over +the edge-source liquid files (page/layout/partial+lib — the only files whose +change can alter any dependents). Fingerprint matches → serve; otherwise report +`computing` and rebuild in the background. The agent validates in-flight *buffers* +without writing to disk, so the fingerprint stays matched across a burst (fresh +every call); a rebuild triggers only after an actual write. + +Buffer overlay is **not** needed for dependents (who points *at* the file lives in +*other* files, unaffected by the buffer); the buffer is used only to read the +current `{% doc %}` for `signature_risk`. + +### Degrade contract (F2) +`impact` is secondary: a graph-build failure or an impact-adapter throw degrades +to `status: 'unavailable'` (logged) and **never** sinks the lint gate. + +### Worked example — signature-impact +`home.liquid` renders `card` with no args. Editing `card` to add a required +`@param title`: +```json +"impact": { + "scope": "direct", + "status": "computed", + "dependents": { "total": 1, "by_kind": { "render": 1 }, "sample": ["app/views/pages/home.liquid"] }, + "signature_risk": [ + { "caller": "app/views/pages/home.liquid", "missing_required": ["title"], "unexpected_args": [] } + ] +} +``` + +### Tests +`graph-cache.spec` (never-stale/dedup/failure state machine + a real-fixture +integration proving rebuild-on-change), `impact.spec` (dependents shaping + +signature-impact), `validate-code.spec` (lint/impact orchestration + degrade), +and a `stdio-smoke` end-to-end (real cached graph over the transport: dependents, +safe-to-change, and signature-impact). Supervisor suite: **55**. + +### Measured cost (real 1,505-node project `marketplace-dcra`) +- **Warm request (fresh graph served): ~400 ms** — the fingerprint stat-scan. + Sub-second, and it runs *concurrently* with lint's own multi-second + whole-project parse, so blast-radius adds ≈0 wall-clock. +- **Cold first fingerprint: ~8 s** (cold OS cache; dominated by walking the whole + tree, incl. non-platformOS dirs like `react-app/`) — hidden behind lint's own + slower cold parse of the same files. +- **Background build: ~22 s** — never awaited on the request path. + +### Open follow-ups +- The fingerprint (and the build's entry-point enumeration) walks the whole tree; + scoping the walk to platformOS dirs (or reusing lint's file list) would cut the + ~400 ms warm cost. Not a 9.10 regression (same walk `buildAppGraph`/lint already + do); worth a follow-up. +- Cold-start: the first blast-radius request (or the first after a write) returns + `computing` until the background build finishes; a bounded await for small + projects is a possible future nicety (deliberately omitted to never delay lint). +- `getApp` memoization (doubt #8) remains the higher-value perf lever, unchanged. diff --git a/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md b/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md index 087c1daa..279ceadc 100644 --- a/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md +++ b/docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md @@ -179,13 +179,21 @@ Apply the consumer principle: (`render | include | function | background | graphql | asset | layout`) plus a later `args?: string[]` (TASK-9.2); web-component was removed. -## Open questions - -5. **Build cost / freshness** — full-app graph build is parse-heavy; cache at the - supervisor edge with a TTL (matches v1's 30s) vs explicit invalidation? (Still - open — the supervisor's `extractFileReferences` per-file path avoids a full - build for `validate_code` today; a cached full build is needed when TASK-9.7 - consumes project-wide facts.) +## Resolved (2026-07-02) + +5. **Build cost / freshness — RESOLVED (TASK-9.10).** The full-app graph is cached + at the supervisor edge (one `GraphCache` per project) and reused across + `validate_code` calls — but **never served stale**. Rejected TTL *and* + serve-stale-while-revalidate: both can hand the agent an out-of-date answer, + which would mislead it (e.g. "nothing depends on this, safe to change" when a + caller was added). Instead the cache is **fingerprint-validated on every + request** (a cheap `mtime:size` stat-scan over the edge-source liquid files): + it serves the graph only when that fingerprint is unchanged, and otherwise + reports `computing` and rebuilds in the background. The build is never awaited + on the request path (blast-radius is a secondary signal; a graph failure never + sinks the lint gate). In practice the agent validates in-flight *buffers* + without writing to disk, so the fingerprint keeps matching and the answer stays + fresh across a burst; a rebuild is triggered only after an actual file write. ## References diff --git a/packages/platformos-mcp-supervisor/src/adapter-input.ts b/packages/platformos-mcp-supervisor/src/adapter-input.ts index bd09b740..87574728 100644 --- a/packages/platformos-mcp-supervisor/src/adapter-input.ts +++ b/packages/platformos-mcp-supervisor/src/adapter-input.ts @@ -1,5 +1,5 @@ /** - * Shared input for the request-path I/O adapters (`lint/`, `structure/`). + * Shared input for the request-path I/O adapters (`lint/`, `impact/`). * * Both adapters receive the identical `{ projectDir, filePath, content }` and * must agree on the buffer's absolute path, so the shape and the diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts new file mode 100644 index 00000000..661eb1cb --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { path } from '@platformos/platformos-check-common'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; +import { type AppGraph, dependentsOf } from '@platformos/platformos-graph'; + +import { GraphCache } from './graph-cache'; + +/** + * A fake graph tagged with a build id so a test can assert WHICH build's graph + * was returned (the cache treats the graph as opaque). + */ +const fakeGraph = (id: number): AppGraph => + ({ rootUri: 'file:///p', entryPoints: [], modules: {}, __build: id }) as unknown as AppGraph; + +const fp = (entries: Record) => new Map(Object.entries(entries)); + +describe('GraphCache: never-stale, background-built, deduplicated', () => { + const rootUri = 'file:///p'; + + it('does not serve a graph on the first lookup, but triggers a build that a later lookup serves', async () => { + const buildGraph = vi.fn(async () => fakeGraph(1)); + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => fp({ a: '1' }), + buildGraph, + }); + + expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); + await cache.settle(); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); + expect(buildGraph).toHaveBeenCalledTimes(1); + }); + + it('reuses the built graph across lookups while the fingerprint is unchanged (one build)', async () => { + const buildGraph = vi.fn(async () => fakeGraph(1)); + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => fp({ a: '1' }), + buildGraph, + }); + + await cache.lookup(); + await cache.settle(); + await cache.lookup(); + await cache.lookup(); + + expect(buildGraph).toHaveBeenCalledTimes(1); + }); + + it('NEVER serves a stale graph: a changed fingerprint yields recomputing, then the rebuilt graph', async () => { + let current = fp({ a: '1' }); + let build = 0; + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => current, + buildGraph: async () => fakeGraph(++build), + }); + + await cache.lookup(); + await cache.settle(); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); + + // A source file changed → the old graph must NOT be served. + current = fp({ a: '2' }); + expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); + await cache.settle(); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(2) }); + }); + + it('reports unavailable when the build fails, without a retry storm for the same source', async () => { + const buildGraph = vi.fn(async () => { + throw new Error('boom'); + }); + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => fp({ a: '1' }), + buildGraph, + }); + + expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); + await cache.settle(); + // Same (failed) fingerprint: unavailable, and NOT retried on every call. + expect(await cache.lookup()).toEqual({ graph: null, reason: 'unavailable' }); + expect(await cache.lookup()).toEqual({ graph: null, reason: 'unavailable' }); + expect(buildGraph).toHaveBeenCalledTimes(1); + }); + + it('retries the build after a failure once the source fingerprint changes', async () => { + let current = fp({ a: '1' }); + const buildGraph = vi.fn(async () => { + if (current.get('a') === '1') throw new Error('boom'); + return fakeGraph(2); + }); + const cache = new GraphCache({ rootUri, computeFingerprint: async () => current, buildGraph }); + + await cache.lookup(); + await cache.settle(); + expect(await cache.lookup()).toEqual({ graph: null, reason: 'unavailable' }); + + current = fp({ a: '2' }); // project edited (maybe fixed) + expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); + await cache.settle(); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(2) }); + expect(buildGraph).toHaveBeenCalledTimes(2); + }); + + it('deduplicates concurrent lookups into a single in-flight build', async () => { + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const buildGraph = vi.fn(async () => { + await gate; + return fakeGraph(1); + }); + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => fp({ a: '1' }), + buildGraph, + }); + + // Fire several lookups before the build resolves. + await Promise.all([cache.lookup(), cache.lookup(), cache.lookup()]); + release(); + await cache.settle(); + + expect(buildGraph).toHaveBeenCalledTimes(1); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); + }); +}); + +describe('GraphCache: real project (integration — real buildAppGraph + fs + mtime)', () => { + let projectDir: string; + let rootUri: string; + const write = (rel: string, body: string) => { + const abs = join(projectDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, body, 'utf8'); + }; + const uri = (rel: string) => path.normalize(path.URI.file(join(projectDir, rel))); + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-graphcache-')); + write('app/views/partials/card.liquid', '
{{ title }}
'); + write('app/views/pages/index.liquid', "{% render 'card' %}"); + write('app/views/pages/about.liquid', '

About

'); + rootUri = path.normalize(path.URI.file(projectDir)); + }); + + afterAll(() => rmSync(projectDir, { recursive: true, force: true })); + + const dependentSources = (graph: AppGraph, rel: string): string[] => + dependentsOf(graph, uri(rel)) + .map((ref) => ref.source.uri) + .sort(); + + it('builds a fresh graph whose dependents reflect real callers, and rebuilds — never stale — on a source change', async () => { + const cache = new GraphCache({ rootUri, fs: NodeFileSystem }); + + // Cold: no graph yet, build triggered. + expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); + await cache.settle(); + + const first = await cache.lookup(); + expect('graph' in first && first.graph).toBeTruthy(); + if (!('graph' in first) || !first.graph) throw new Error('expected a fresh graph'); + // card is rendered by index → index is its sole dependent. + expect(dependentSources(first.graph, 'app/views/partials/card.liquid')).toEqual([ + uri('app/views/pages/index.liquid'), + ]); + + // Edit a caller so about now also renders card. The cache must NOT serve the + // stale graph (which shows only index) — it recomputes. + // (a fresh mtime is guaranteed by writing different content) + write('app/views/pages/about.liquid', "{% render 'card' %}\n

About

"); + expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); + await cache.settle(); + + const second = await cache.lookup(); + if (!('graph' in second) || !second.graph) throw new Error('expected a rebuilt graph'); + expect(dependentSources(second.graph, 'app/views/partials/card.liquid')).toEqual([ + uri('app/views/pages/about.liquid'), + uri('app/views/pages/index.liquid'), + ]); + }, 20000); +}); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts new file mode 100644 index 00000000..d154bda6 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts @@ -0,0 +1,183 @@ +/** + * Project-graph cache at the supervisor I/O edge. + * + * The full `AppGraph` is expensive to build (a whole-project parse), so it is + * built ONCE and reused across `validate_code` calls — but NEVER served stale. + * Staleness would mislead the agent (e.g. "nothing depends on this, safe to + * change" when a caller was added since the build), so the cache is + * fingerprint-validated on every request: it serves the graph only when the + * on-disk source it was built from is unchanged, and otherwise reports + * "recomputing" (triggering a background rebuild) rather than handing back a + * possibly-wrong answer. + * + * The build is fired in the background and NEVER awaited on the request path — + * blast-radius is a secondary signal and must not add latency to, or ever sink, + * the primary lint gate (mirrors the `runValidateCode` degrade contract). + * + * Fingerprint domain = the liquid files that are edge SOURCES (page/layout/ + * partial+lib). Only their add/remove/modify can change any file's dependents; + * `.graphql`/`.yml`/asset files are leaves. This is also exactly the set fed to + * `buildAppGraph` as entry points, so dependents are COMPLETE (every caller is + * traversed) — see the query.ts note on entry-point scope. + */ +import { stat } from 'node:fs/promises'; + +import { + isLayout, + isPage, + isPartial, + path, + recursiveReadDirectory, + type UriString, +} from '@platformos/platformos-check-common'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; +import type { AbstractFileSystem } from '@platformos/platformos-common'; +import { buildAppGraph, type AppGraph } from '@platformos/platformos-graph'; + +/** Per-file identity used to detect on-disk change: `mtimeMs:size`. */ +type Fingerprint = Map; + +/** The result of asking the cache for a usable graph. */ +export type GraphLookup = + | { graph: AppGraph } + | { graph: null; reason: 'recomputing' | 'unavailable' }; + +export interface GraphCacheOptions { + /** Normalized project root as a `file://` URI. */ + rootUri: UriString; + /** Filesystem for enumeration/build. Defaults to the real Node fs. */ + fs?: AbstractFileSystem; + /** Seam for tests: compute the source fingerprint. Defaults to the real disk scan. */ + computeFingerprint?: (rootUri: UriString, fs: AbstractFileSystem) => Promise; + /** Seam for tests: build the graph. Defaults to `buildAppGraph` over the liquid entry points. */ + buildGraph?: ( + rootUri: UriString, + fs: AbstractFileSystem, + entryPoints: UriString[], + ) => Promise; +} + +/** A liquid file that can be an edge SOURCE — the build's entry points + fingerprint domain. */ +function isEdgeSource(uri: UriString): boolean { + return isLayout(uri) || isPage(uri) || isPartial(uri); +} + +/** Real disk fingerprint: every edge-source liquid file → `mtimeMs:size`. */ +async function computeFingerprintFromDisk( + rootUri: UriString, + fs: AbstractFileSystem, +): Promise { + const uris = await recursiveReadDirectory(fs, rootUri, ([uri]) => isEdgeSource(uri)); + const fingerprint: Fingerprint = new Map(); + await Promise.all( + uris.map(async (uri) => { + try { + const info = await stat(path.fsPath(uri)); + fingerprint.set(uri, `${info.mtimeMs}:${info.size}`); + } catch { + // Vanished between the walk and the stat — omit it; the next scan reconciles. + } + }), + ); + return fingerprint; +} + +function fingerprintsEqual(a: Fingerprint, b: Fingerprint): boolean { + if (a.size !== b.size) return false; + for (const [uri, value] of a) { + if (b.get(uri) !== value) return false; + } + return true; +} + +/** + * A never-stale, lazily-built, background-refreshed cache of a project's + * `AppGraph`. One instance per project root (created per server). + */ +export class GraphCache { + private readonly rootUri: UriString; + private readonly fs: AbstractFileSystem; + private readonly computeFingerprint: ( + rootUri: UriString, + fs: AbstractFileSystem, + ) => Promise; + private readonly buildGraph: ( + rootUri: UriString, + fs: AbstractFileSystem, + entryPoints: UriString[], + ) => Promise; + + /** The graph + the fingerprint of the disk it was built from. */ + private built: { graph: AppGraph; fingerprint: Fingerprint } | null = null; + /** The in-flight background build, if any (dedup guard). */ + private inFlight: Promise | null = null; + /** The fingerprint of the most recent build ATTEMPT (success or failure). */ + private lastAttempt: Fingerprint | null = null; + /** The error from the most recent failed build attempt, cleared on a new attempt. */ + private lastError: Error | null = null; + + constructor(options: GraphCacheOptions) { + this.rootUri = options.rootUri; + this.fs = options.fs ?? NodeFileSystem; + this.computeFingerprint = options.computeFingerprint ?? computeFingerprintFromDisk; + this.buildGraph = + options.buildGraph ?? + ((rootUri, fs, entryPoints) => buildAppGraph(rootUri, { fs }, entryPoints)); + } + + /** + * Return the graph ONLY if it is fresh (the on-disk source is unchanged since + * the build); otherwise trigger a background rebuild and report why no graph + * is available. Cheap (a stat-scan) and NON-BLOCKING — never awaits the build. + */ + async lookup(): Promise { + const current = await this.computeFingerprint(this.rootUri, this.fs); + + if (this.built && fingerprintsEqual(this.built.fingerprint, current)) { + return { graph: this.built.graph }; + } + + this.ensureBuild(current); + // Not fresh: a build is (or was) needed. If the current source already failed + // to build and nothing is retrying it, it is genuinely unavailable; otherwise + // a build is in flight. + const reason: 'recomputing' | 'unavailable' = + this.lastError && !this.inFlight ? 'unavailable' : 'recomputing'; + return { graph: null, reason }; + } + + /** + * Start a background build for `fingerprint` unless one is already running or + * this exact source already failed (avoids a retry storm on an unbuildable + * project — a changed fingerprint retries). + */ + private ensureBuild(fingerprint: Fingerprint): void { + if (this.inFlight) return; + if (this.lastError && this.lastAttempt && fingerprintsEqual(this.lastAttempt, fingerprint)) { + return; + } + + this.lastAttempt = fingerprint; + this.lastError = null; + + const entryPoints = [...fingerprint.keys()]; + this.inFlight = this.buildGraph(this.rootUri, this.fs, entryPoints) + .then((graph) => { + this.built = { graph, fingerprint }; + }) + .catch((error: unknown) => { + this.lastError = error instanceof Error ? error : new Error(String(error)); + }) + .finally(() => { + this.inFlight = null; + }); + } + + /** + * Await the in-flight build, if any. TEST/warm-up hook only — the request path + * (`lookup`) never awaits a build. + */ + async settle(): Promise { + await this.inFlight; + } +} diff --git a/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts b/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts new file mode 100644 index 00000000..375fe29e --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import type { AppGraph, Reference } from '@platformos/platformos-graph'; + +import { runImpact } from './impact'; +import type { GraphCache } from '../graph-cache/graph-cache'; + +const PROJECT = '/project'; +const u = (rel: string) => `file://${PROJECT}/${rel}`; + +/** A graph in which `fileRel` has the given incoming reference edges (with optional passed args). */ +function graphWithDependents( + fileRel: string, + refs: { source: string; kind: Reference['kind']; args?: string[] }[], +): AppGraph { + const references: Reference[] = refs.map(({ source, kind, args }) => ({ + source: { uri: u(source) }, + target: { uri: u(fileRel) }, + type: 'direct', + kind, + ...(args ? { args } : {}), + })); + return { + rootUri: u(''), + entryPoints: [], + modules: { [u(fileRel)]: { references } }, + } as unknown as AppGraph; +} + +/** A GraphCache stub returning a fixed lookup result (runImpact only calls lookup). */ +const stubCache = (result: Awaited>): GraphCache => + ({ lookup: async () => result }) as unknown as GraphCache; + +const run = (fileRel: string, cache: GraphCache, content = '') => + runImpact({ projectDir: PROJECT, filePath: fileRel, content }, cache); + +describe('runImpact: blast radius from the cached graph', () => { + const card = 'app/views/partials/card.liquid'; + + it('summarizes distinct dependent files, per-kind counts, and a sorted sample when computed', async () => { + const graph = graphWithDependents(card, [ + { source: 'app/views/pages/index.liquid', kind: 'render' }, + { source: 'app/views/pages/about.liquid', kind: 'render' }, + { source: 'app/views/partials/wrapper.liquid', kind: 'include' }, + ]); + + expect(await run(card, stubCache({ graph }))).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { + total: 3, + by_kind: { render: 2, include: 1 }, + sample: [ + 'app/views/pages/about.liquid', + 'app/views/pages/index.liquid', + 'app/views/partials/wrapper.liquid', + ], + }, + }); + }); + + it('reports computed with zero dependents (safe to change), distinct from "not computed"', async () => { + const graph = graphWithDependents(card, []); + expect(await run(card, stubCache({ graph }))).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { total: 0, by_kind: {}, sample: [] }, + }); + }); + + it('counts a file once in total but under each kind it references by', async () => { + const graph = graphWithDependents(card, [ + // same caller, two render edges → one distinct file + { source: 'app/views/pages/index.liquid', kind: 'render' }, + { source: 'app/views/pages/index.liquid', kind: 'render' }, + // a caller that both renders and includes → one file, counted in both kinds + { source: 'app/views/partials/dual.liquid', kind: 'render' }, + { source: 'app/views/partials/dual.liquid', kind: 'include' }, + ]); + + expect(await run(card, stubCache({ graph }))).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { + total: 2, + by_kind: { render: 2, include: 1 }, + sample: ['app/views/pages/index.liquid', 'app/views/partials/dual.liquid'], + }, + }); + }); + + it('caps the sample at 10 files while keeping the true total', async () => { + const refs = Array.from({ length: 15 }, (_, i) => ({ + source: `app/views/pages/p${String(i).padStart(2, '0')}.liquid`, + kind: 'render' as const, + })); + const result = await run(card, stubCache({ graph: graphWithDependents(card, refs) })); + + expect(result.status).toEqual('computed'); + expect(result.dependents.total).toEqual(15); + expect(result.dependents.sample).toEqual([ + 'app/views/pages/p00.liquid', + 'app/views/pages/p01.liquid', + 'app/views/pages/p02.liquid', + 'app/views/pages/p03.liquid', + 'app/views/pages/p04.liquid', + 'app/views/pages/p05.liquid', + 'app/views/pages/p06.liquid', + 'app/views/pages/p07.liquid', + 'app/views/pages/p08.liquid', + 'app/views/pages/p09.liquid', + ]); + }); + + it('reports status "computing" (zeroed) when the graph is not yet fresh — never a stale answer', async () => { + expect(await run(card, stubCache({ graph: null, reason: 'recomputing' }))).toEqual({ + scope: 'direct', + status: 'computing', + dependents: { total: 0, by_kind: {}, sample: [] }, + }); + }); + + it('reports status "unavailable" (zeroed) when the graph could not be built', async () => { + expect(await run(card, stubCache({ graph: null, reason: 'unavailable' }))).toEqual({ + scope: 'direct', + status: 'unavailable', + dependents: { total: 0, by_kind: {}, sample: [] }, + }); + }); +}); + +describe('runImpact: signature-impact (callers vs the edited buffer {% doc %})', () => { + const card = 'app/views/partials/card.liquid'; + + // A buffer declaring `title` (required) and `count` (optional). + const docBuffer = `{% doc %} + @param {String} title - required title + @param {Number} [count] - optional count +{% enddoc %} +
{{ title }}
`; + + it('flags dependent callers that omit a required @param or pass an undeclared one', async () => { + const graph = graphWithDependents(card, [ + // ok: passes the required title + { source: 'app/views/pages/ok.liquid', kind: 'render', args: ['title', 'count'] }, + // missing required `title` + { source: 'app/views/pages/missing.liquid', kind: 'render', args: ['count'] }, + // passes an argument the doc does not declare + { source: 'app/views/pages/extra.liquid', kind: 'render', args: ['title', 'colour'] }, + // passes nothing at all → missing required `title` + { source: 'app/views/pages/bare.liquid', kind: 'render' }, + ]); + + const result = await run(card, stubCache({ graph }), docBuffer); + + expect(result.status).toEqual('computed'); + expect(result.signature_risk).toEqual([ + { caller: 'app/views/pages/bare.liquid', missing_required: ['title'], unexpected_args: [] }, + { caller: 'app/views/pages/extra.liquid', missing_required: [], unexpected_args: ['colour'] }, + { + caller: 'app/views/pages/missing.liquid', + missing_required: ['title'], + unexpected_args: [], + }, + ]); + }); + + it('returns an empty signature_risk (checked, all match) when every caller satisfies the doc', async () => { + const graph = graphWithDependents(card, [ + { source: 'app/views/pages/a.liquid', kind: 'render', args: ['title'] }, + { source: 'app/views/pages/b.liquid', kind: 'render', args: ['title', 'count'] }, + ]); + + const result = await run(card, stubCache({ graph }), docBuffer); + expect(result.signature_risk).toEqual([]); + }); + + it('omits signature_risk entirely when the buffer declares no {% doc %} contract', async () => { + const graph = graphWithDependents(card, [ + { source: 'app/views/pages/x.liquid', kind: 'render', args: ['whatever'] }, + ]); + + const result = await run(card, stubCache({ graph }), '
{{ title }}
'); + expect(result.signature_risk).toBeUndefined(); + expect(result.status).toEqual('computed'); + }); + + it('does not compute signature_risk when the graph is not fresh', async () => { + const result = await run(card, stubCache({ graph: null, reason: 'recomputing' }), docBuffer); + expect(result.signature_risk).toBeUndefined(); + expect(result.status).toEqual('computing'); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/impact/impact.ts b/packages/platformos-mcp-supervisor/src/impact/impact.ts new file mode 100644 index 00000000..37f4b5af --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/impact/impact.ts @@ -0,0 +1,163 @@ +/** + * Impact (blast-radius) adapter — an I/O boundary on the request path (sibling + * to `lint/`). + * + * Answers the one question lint structurally cannot: "who DEPENDS ON the file + * being edited?" — its incoming references across the project. Derived from the + * cached project graph via platformos-graph's `dependentsOf`; the supervisor + * owns only the shaping, never the reverse-index logic. + * + * The graph is NEVER served stale (see {@link GraphCache}): if the project + * changed since the last build, this reports `computing` rather than a possibly + * out-of-date answer. The dependents list does NOT depend on the in-flight + * buffer — who points AT the file lives in OTHER files — so no buffer overlay is + * needed here (the buffer matters only to signature-impact, added separately). + */ +import { + extractDocDefinition, + path, + SourceCodeType, + type UriString, +} from '@platformos/platformos-check-common'; +import { type AppGraph, dependentsOf, toSourceCode } from '@platformos/platformos-graph'; + +import { toAbsoluteFilePath, type AdapterInput } from '../adapter-input'; +import type { GraphCache } from '../graph-cache/graph-cache'; +import type { ValidateCodeImpact, ValidateCodeSignatureRisk } from '../result/types'; + +/** Number of referencing files listed in `sample`/`signature_risk` before truncating. */ +const SAMPLE_LIMIT = 10; + +/** A fresh zeroed dependents shape for every non-`computed` status. */ +const noDependents = (): ValidateCodeImpact['dependents'] => ({ + total: 0, + by_kind: {}, + sample: [], +}); + +/** + * Compute the edited file's blast radius from the cached project graph. Reports + * `computing`/`unavailable` (with zeroed dependents) when a fresh graph is not + * available — never a stale answer. + */ +export async function runImpact( + params: AdapterInput, + cache: GraphCache, +): Promise { + const lookup = await cache.lookup(); + if (!('graph' in lookup) || !lookup.graph) { + const status = lookup.reason === 'unavailable' ? 'unavailable' : 'computing'; + return { scope: 'direct', status, dependents: noDependents() }; + } + + const { projectDir, filePath, content } = params; + const rootUri = path.normalize(path.URI.file(projectDir)); + const fileUri = path.normalize(path.URI.file(toAbsoluteFilePath(projectDir, filePath))); + + const signature = await docSignature(fileUri, content); + const signature_risk = + signature && computeSignatureRisk(lookup.graph, fileUri, rootUri, signature); + + return { + scope: 'direct', + status: 'computed', + dependents: summarizeDependents(lookup.graph, fileUri, rootUri), + ...(signature_risk ? { signature_risk } : {}), + }; +} + +/** + * Reduce the incoming reference edges of `fileUri` to the agent-facing summary: + * distinct referencing FILES (`total`), distinct files per edge kind + * (`by_kind`), and a capped, sorted `sample` of project-relative caller paths. + */ +function summarizeDependents( + graph: Parameters[0], + fileUri: UriString, + rootUri: UriString, +): ValidateCodeImpact['dependents'] { + // caller path (project-relative) → the edge kinds by which it references the file + const callers = new Map>(); + for (const ref of dependentsOf(graph, fileUri)) { + if (!ref.kind) continue; // every graph edge carries a kind; defensive only + const caller = path.relative(ref.source.uri, rootUri); + const kinds = callers.get(caller) ?? new Set(); + kinds.add(ref.kind); + callers.set(caller, kinds); + } + + const by_kind: Record = {}; + for (const kinds of callers.values()) { + for (const kind of kinds) by_kind[kind] = (by_kind[kind] ?? 0) + 1; + } + + const sample = [...callers.keys()].sort((a, b) => a.localeCompare(b)).slice(0, SAMPLE_LIMIT); + + return { total: callers.size, by_kind, sample }; +} + +/** The `{% doc %}` parameter contract of the in-flight buffer, or `null` when it declares none. */ +interface DocSignature { + required: string[]; + allowed: string[]; +} + +/** + * The edited buffer's `{% doc %}` parameter contract (required + all declared + * names), or `null` when the buffer is non-Liquid, unparseable, or declares no + * `{% doc %}` block. Reuses check-common's `extractDocDefinition` — the same + * primitive the `PartialCallArguments` check reads for the doc case — so the + * two never diverge. `null` deliberately disables signature-impact: without an + * explicit contract we do NOT guess a signature (no false positives). + */ +async function docSignature(fileUri: UriString, content: string): Promise { + const sourceCode = await toSourceCode(fileUri, content); + if (sourceCode.type !== SourceCodeType.LiquidHtml || sourceCode.ast instanceof Error) return null; + + const definition = await extractDocDefinition(fileUri, sourceCode.ast); + const parameters = definition.liquidDoc?.parameters; + if (!parameters || parameters.length === 0) return null; + + return { + required: parameters.filter((p) => p.required).map((p) => p.name), + allowed: parameters.map((p) => p.name), + }; +} + +/** + * The dependent callers whose passed arguments violate `signature` — missing a + * required `@param`, or passing one the `{% doc %}` block does not declare. The + * cross-file inverse of `PartialCallArguments`: it checks the edited file's + * contract against every existing caller at once. Deduplicated per caller, + * sorted, and capped. + */ +function computeSignatureRisk( + graph: AppGraph, + fileUri: UriString, + rootUri: UriString, + signature: DocSignature, +): ValidateCodeSignatureRisk[] { + const byCaller = new Map; unexpected: Set }>(); + + for (const ref of dependentsOf(graph, fileUri)) { + const args = ref.args ?? []; + const missing = signature.required.filter((param) => !args.includes(param)); + const unexpected = args.filter((arg) => !signature.allowed.includes(arg)); + if (missing.length === 0 && unexpected.length === 0) continue; + + const caller = path.relative(ref.source.uri, rootUri); + const entry = byCaller.get(caller) ?? { missing: new Set(), unexpected: new Set() }; + for (const m of missing) entry.missing.add(m); + for (const u of unexpected) entry.unexpected.add(u); + byCaller.set(caller, entry); + } + + return [...byCaller.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .slice(0, SAMPLE_LIMIT) + .map(([caller, { missing, unexpected }]) => ({ + caller, + missing_required: [...missing].sort((a, b) => a.localeCompare(b)), + unexpected_args: [...unexpected].sort((a, b) => a.localeCompare(b)), + })); +} diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts index afc488ca..45b86600 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.spec.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest'; import { assembleResult } from './assemble'; -import type { - ValidateCodeDependency, - ValidateCodeDiagnostic, - ValidateCodeResult, - ValidateCodeStructuralSnapshot, -} from './types'; +import type { ValidateCodeDiagnostic, ValidateCodeImpact, ValidateCodeResult } from './types'; const diag = (over: Partial): ValidateCodeDiagnostic => ({ check: 'SomeCheck', @@ -16,6 +11,14 @@ const diag = (over: Partial): ValidateCodeDiagnostic => ...over, }); +// A neutral "not computed yet" impact used where the test does not exercise the +// blast radius itself (it is threaded verbatim by assembleResult). +const NO_IMPACT: ValidateCodeImpact = { + scope: 'direct', + status: 'computing', + dependents: { total: 0, by_kind: {}, sample: [] }, +}; + // The always-empty envelope fields in this lint-only slice. Spread into each // expected result so every assertion checks the WHOLE object, catching any // field that unexpectedly starts being populated. @@ -26,11 +29,10 @@ const EMPTY_ENVELOPE = { proposed_fixes: [], clusters: [], scorecard: [], - dependencies: [], + impact: NO_IMPACT, parse_error: null, tips: [], domain_guide: null, - structural: null, } satisfies Partial; describe('Unit: assembleResult', () => { @@ -39,7 +41,7 @@ describe('Unit: assembleResult', () => { const warning = diag({ severity: 'warning', check: 'W' }); const info = diag({ severity: 'info', check: 'I' }); - expect(assembleResult([error, warning, info], [], null, 'full')).toEqual({ + expect(assembleResult([error, warning, info], NO_IMPACT, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, @@ -53,7 +55,7 @@ describe('Unit: assembleResult', () => { const error = diag({ severity: 'error' }); const warning = diag({ severity: 'warning' }); - expect(assembleResult([error, warning], [], null, 'full')).toEqual({ + expect(assembleResult([error, warning], NO_IMPACT, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'error', must_fix_before_write: true, @@ -66,7 +68,7 @@ describe('Unit: assembleResult', () => { const warning = diag({ severity: 'warning' }); const info = diag({ severity: 'info' }); - expect(assembleResult([warning, info], [], null, 'full')).toEqual({ + expect(assembleResult([warning, info], NO_IMPACT, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'warning', must_fix_before_write: false, @@ -76,7 +78,7 @@ describe('Unit: assembleResult', () => { }); it('derives status = ok with an empty envelope for no diagnostics', () => { - expect(assembleResult([], [], null, 'full')).toEqual({ + expect(assembleResult([], NO_IMPACT, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, @@ -86,7 +88,7 @@ describe('Unit: assembleResult', () => { it('derives status = ok for infos only', () => { const info = diag({ severity: 'info' }); - expect(assembleResult([info], [], null, 'quick')).toEqual({ + expect(assembleResult([info], NO_IMPACT, 'quick')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, @@ -94,38 +96,22 @@ describe('Unit: assembleResult', () => { }); }); - it('carries the dependencies through verbatim (status unaffected by deps)', () => { - const dependencies: ValidateCodeDependency[] = [ - { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, - { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 3, column: 1 }, - ]; - - expect(assembleResult([], dependencies, null, 'full')).toEqual({ - ...EMPTY_ENVELOPE, - status: 'ok', - must_fix_before_write: false, - dependencies, - }); - }); - - it('carries the structural snapshot through verbatim (status unaffected)', () => { - const structural: ValidateCodeStructuralSnapshot = { - renders_used: ['card'], - graphql_queries_used: [], - filters_used: ['upcase'], - tags_used: ['render'], - translation_keys: [], - doc_params: ['title'], - slug: 'about', - layout: 'theme', - method: null, + it('carries the impact through verbatim (status unaffected by blast radius)', () => { + const impact: ValidateCodeImpact = { + scope: 'direct', + status: 'computed', + dependents: { + total: 2, + by_kind: { render: 1, include: 1 }, + sample: ['app/views/pages/index.liquid', 'app/views/partials/wrapper.liquid'], + }, }; - expect(assembleResult([], [], structural, 'full')).toEqual({ + expect(assembleResult([], impact, 'full')).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, - structural, + impact, }); }); }); diff --git a/packages/platformos-mcp-supervisor/src/result/assemble.ts b/packages/platformos-mcp-supervisor/src/result/assemble.ts index 3d2b90be..d68b2011 100644 --- a/packages/platformos-mcp-supervisor/src/result/assemble.ts +++ b/packages/platformos-mcp-supervisor/src/result/assemble.ts @@ -5,28 +5,25 @@ * `status` + `must_fix_before_write` envelope. PURE — no I/O, consumes only the * diagnostic list and the shared result types. * - * `dependencies` and `structural` are graph-derived facts pre-computed by the - * structure adapter and included verbatim. The remaining ergonomic transforms - * (clustering, scorecard, the explicit blocking-warning set, `next_step`, tips, - * domain_guide) are added in later tasks; they are left empty/null here. + * `impact` is the graph-derived cross-file blast radius (who depends on the + * file), pre-computed by the impact adapter and included verbatim. The remaining + * ergonomic transforms (clustering, scorecard, the explicit blocking-warning + * set, `next_step`, tips, domain_guide) are added in later tasks; they are left + * empty/null here. */ import type { - ValidateCodeDependency, ValidateCodeDiagnostic, + ValidateCodeImpact, ValidateCodeMode, ValidateCodeResult, ValidateCodeStatus, - ValidateCodeStructuralSnapshot, } from './types'; export function assembleResult( diagnostics: ValidateCodeDiagnostic[], - // The file's resolved outgoing dependencies (graph-derived, pre-computed by - // the structure adapter). Included verbatim — assembly stays pure. - dependencies: ValidateCodeDependency[], - // The file's own structural declarations (graph-derived), or null when the - // buffer is non-Liquid / unparseable. Included verbatim. - structural: ValidateCodeStructuralSnapshot | null, + // The file's cross-file blast radius (graph-derived, pre-computed by the impact + // adapter). Included verbatim — assembly stays pure. + impact: ValidateCodeImpact, // Reserved: `full`/`quick` do not yet change output (no heavier stages exist). _mode: ValidateCodeMode, ): ValidateCodeResult { @@ -48,10 +45,9 @@ export function assembleResult( proposed_fixes: [], clusters: [], scorecard: [], - dependencies, + impact, parse_error: null, tips: [], domain_guide: null, - structural, }; } diff --git a/packages/platformos-mcp-supervisor/src/result/types.ts b/packages/platformos-mcp-supervisor/src/result/types.ts index 1beff2bf..ff8a2d7f 100644 --- a/packages/platformos-mcp-supervisor/src/result/types.ts +++ b/packages/platformos-mcp-supervisor/src/result/types.ts @@ -113,40 +113,6 @@ export interface ScorecardNote { reason: string; } -/** - * The Liquid construct that created a dependency edge — the agent-facing kind - * contract. Owned by the supervisor (not imported from the upstream - * `ReferenceKind`): the structure adapter maps upstream kinds onto this union - * through an exhaustive table, so an upstream rename/addition is a compile error - * at that seam rather than a silent change to the agent surface. - */ -export type ValidateCodeDependencyKind = - | 'render' - | 'include' - | 'function' - | 'background' - | 'graphql' - | 'asset' - | 'layout'; - -/** - * A resolved outgoing dependency of the validated file — what it - * renders/includes/runs/queries/wraps. Derived from the platformos-graph - * dependency model (`extractFileReferences`); the supervisor maps but never - * re-derives graph logic. Tells the agent the canonical resolved target so - * "what does this call / where does it live" is answerable from the result. - */ -export interface ValidateCodeDependency { - /** The Liquid construct that created the edge. */ - kind: ValidateCodeDependencyKind; - /** The resolved dependency target, project-relative (e.g. `app/lib/queries/list.liquid`). */ - target: string; - /** 1-based line of the reference site (the Liquid tag or the frontmatter block). */ - line: number; - /** 1-based column of the reference site. */ - column: number; -} - // ── TASK-8 fields (per-domain layer + result completion) ───────────────────── // Declared here so the result shape is stable; populated by TASK-8.2 / TASK-8.4. @@ -170,22 +136,53 @@ export interface DomainGuide { } /** - * The validated file's own structural declarations — what it renders/queries, - * the filters/tags it uses, its translation keys and `{% doc %}` params, and (for - * pages) its routing facts. Derived from the platformos-graph per-file primitive - * `extractStructural`; the usage arrays are always present (empty = none used), - * the routing facts are `null` when not applicable (e.g. a partial has no slug). + * Freshness of the blast-radius answer. Distinguishes a real "nothing depends on + * this" (`computed`, total 0 — safe to change) from "we don't know yet" + * (`computing`/`unavailable`), so an unbuilt/failed graph can NEVER be misread as + * a green light. See {@link ValidateCodeImpact}. */ -export interface ValidateCodeStructuralSnapshot { - renders_used: string[]; - graphql_queries_used: string[]; - filters_used: string[]; - tags_used: string[]; - translation_keys: string[]; - doc_params: string[]; - slug: string | null; - layout: string | null; - method: string | null; +export type ValidateCodeImpactStatus = 'computed' | 'computing' | 'unavailable'; + +/** + * Cross-file "blast radius" of editing the file: who DEPENDS ON it (its incoming + * references), which lint cannot see (lint is per-file, forward-looking). Graph- + * derived (`dependentsOf` over the cached project graph) and NEVER stale — a + * changed project reports `computing`, never an out-of-date answer. + * + * `scope` is always `direct` (immediate callers only — transitive closure is + * excluded as noise). `dependents` is meaningful ONLY when `status` is + * `computed`; otherwise it is zeroed and the status says why. + */ +export interface ValidateCodeImpact { + scope: 'direct'; + status: ValidateCodeImpactStatus; + dependents: { + /** Number of distinct files that reference the edited file. */ + total: number; + /** Distinct referencing files per edge kind (render/include/function/…); a file using two kinds counts in both. */ + by_kind: Record; + /** Up to 10 distinct referencing files, project-relative, sorted. */ + sample: string[]; + }; + /** + * Dependent callers whose arguments do NOT match the edited file's `{% doc %}` + * signature — the cross-file counterpart to the `PartialCallArguments` lint + * check (which only fires when editing the caller). Present ONLY when the + * edited buffer declares a `{% doc %}` block (an explicit contract): an empty + * array means "checked, every caller matches", absent means "no contract to + * check against". Never inferred from a doc-less file (avoids false positives). + */ + signature_risk?: ValidateCodeSignatureRisk[]; +} + +/** One dependent caller at risk from the edited file's current `{% doc %}` signature. */ +export interface ValidateCodeSignatureRisk { + /** The referencing file, project-relative. */ + caller: string; + /** Required `@param`s the caller does not pass. */ + missing_required: string[]; + /** Arguments the caller passes that the `{% doc %}` block does not declare. */ + unexpected_args: string[]; } /** @@ -206,10 +203,11 @@ export interface ValidateCodeResult { clusters: DiagnosticCluster[]; scorecard: ScorecardNote[]; /** - * The file's resolved outgoing dependencies (graph-derived). Always present; - * empty for files with no statically-resolvable dependencies. + * Cross-file blast radius (graph-derived): who depends on the edited file. + * Always present; `status` distinguishes a real "nothing depends on this" + * from "not computed yet". See {@link ValidateCodeImpact}. */ - dependencies: ValidateCodeDependency[]; + impact: ValidateCodeImpact; /** Deterministic prose telling the agent what to do next. */ next_step?: string; /** Parse-failure message when the file could not be parsed at all; null/absent otherwise. */ @@ -218,5 +216,4 @@ export interface ValidateCodeResult { // TASK-8 fields (empty/null in the minimal TASK-7 build): tips?: TipEntry[]; domain_guide?: DomainGuide | null; - structural?: ValidateCodeStructuralSnapshot | null; } diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts b/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts deleted file mode 100644 index f27f0356..00000000 --- a/packages/platformos-mcp-supervisor/src/structure/structure.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -import { runStructure } from './structure'; -import type { ValidateCodeDependency } from '../result/types'; - -/** - * Adapter integration: drives the real platformos-graph per-file primitives - * (`extractFileReferences` + `extractStructural`) against a temp project. - * Targets need not exist on disk — `extractFileReferences` resolves a missing - * target to its canonical default path — so the assertions pin the - * project-relative target + kind + 1-based position without fixtures. - */ -describe('Integration: runStructure (structure adapter)', () => { - let projectDir: string; - - beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-structure-')); - }); - - afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); - - /** The dependency edges for a buffer (the `structural` half is asserted separately). */ - const deps = async (filePath: string, content: string): Promise => - (await runStructure({ projectDir, filePath, content })).dependencies; - - it('maps a {% render %} edge to a render dependency', async () => { - expect(await deps('app/views/pages/index.liquid', "{% render 'card' %}")).toEqual([ - { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, - ]); - }); - - it('maps an {% include %} edge to an include dependency', async () => { - expect(await deps('app/views/pages/index.liquid', "{% include 'card' %}")).toEqual([ - { kind: 'include', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, - ]); - }); - - it('maps a {% function %} edge to a function dependency', async () => { - expect(await deps('app/views/pages/index.liquid', "{% function r = 'queries/list' %}")).toEqual( - [{ kind: 'function', target: 'app/lib/queries/list.liquid', line: 1, column: 1 }], - ); - }); - - it('maps a {% background %} edge to a background dependency', async () => { - // Background resolves like {% function %} (lib search path), so a target - // that does not exist defaults under app/lib. - expect( - await deps('app/views/pages/index.liquid', "{% background j = 'jobs/notify' %}"), - ).toEqual([{ kind: 'background', target: 'app/lib/jobs/notify.liquid', line: 1, column: 1 }]); - }); - - it('maps a {% graphql %} edge to a graphql dependency', async () => { - expect(await deps('app/views/pages/index.liquid', "{% graphql r = 'blog/find' %}")).toEqual([ - { kind: 'graphql', target: 'app/graphql/blog/find.graphql', line: 1, column: 1 }, - ]); - }); - - it('maps an asset filter to an asset dependency (resolved under app/assets)', async () => { - // The asset edge's range is the `'app.js' | asset_url` expression (the - // variable output), which starts at column 4 — after `{{ `. The target - // resolves to the canonical `app/assets/` location (see DocumentsLocator). - expect( - await deps('app/views/layouts/application.liquid', "{{ 'app.js' | asset_url }}"), - ).toEqual([{ kind: 'asset', target: 'app/assets/app.js', line: 1, column: 4 }]); - }); - - it('maps a frontmatter `layout:` to a layout dependency', async () => { - const content = `--- -slug: about -layout: theme ---- -

About

`; - expect(await deps('app/views/pages/about.liquid', content)).toEqual([ - { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, - ]); - }); - - it('resolves a module-prefixed target into modules//public/...', async () => { - expect(await deps('app/views/pages/index.liquid', "{% render 'modules/core/card' %}")).toEqual([ - { - kind: 'render', - target: 'modules/core/public/views/partials/card.liquid', - line: 1, - column: 1, - }, - ]); - }); - - it('reports the 1-based position of a reference that is not on the first line', async () => { - const content = "

hi

\n {% render 'card' %}"; - expect(await deps('app/views/pages/index.liquid', content)).toEqual([ - { kind: 'render', target: 'app/views/partials/card.liquid', line: 2, column: 3 }, - ]); - }); - - it('returns every edge of a multi-dependency file in source order', async () => { - const content = `--- -layout: theme ---- -{% function items = 'queries/list' %} -{% render 'card' %}`; - expect(await deps('app/views/pages/index.liquid', content)).toEqual([ - { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, - { kind: 'function', target: 'app/lib/queries/list.liquid', line: 4, column: 1 }, - { kind: 'render', target: 'app/views/partials/card.liquid', line: 5, column: 1 }, - ]); - }); - - it('returns no dependencies for a file with none', async () => { - expect(await deps('app/views/pages/index.liquid', '

{{ page.title }}

')).toEqual([]); - }); - - it('skips dynamic (non-literal) targets', async () => { - expect(await deps('app/views/pages/index.liquid', '{% render partial_name %}')).toEqual([]); - }); - - it('returns no dependencies for a non-Liquid (.graphql) file', async () => { - expect(await deps('app/graphql/blog/find.graphql', 'query find { records { id } }')).toEqual( - [], - ); - }); - - it('accepts an absolute file path', async () => { - const abs = join(projectDir, 'app/views/pages/index.liquid'); - expect(await deps(abs, "{% render 'card' %}")).toEqual([ - { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, - ]); - }); - - it('returns the buffer self-structural snapshot alongside the dependencies', async () => { - const content = `--- -slug: about -layout: theme -method: get ---- -{% render 'card' %} -{{ 'greeting.hi' | t }} -{{ title | upcase }}`; - const result = await runStructure({ - projectDir, - filePath: 'app/views/pages/about.liquid', - content, - }); - expect(result).toEqual({ - dependencies: [ - { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, - { kind: 'render', target: 'app/views/partials/card.liquid', line: 6, column: 1 }, - ], - structural: { - renders_used: ['card'], - graphql_queries_used: [], - filters_used: ['t', 'upcase'], - tags_used: ['render'], - translation_keys: ['greeting.hi'], - doc_params: [], - slug: 'about', - layout: 'theme', - method: 'get', - }, - }); - }); - - it('returns null structural (and empty dependencies) for a non-Liquid (.graphql) file', async () => { - const result = await runStructure({ - projectDir, - filePath: 'app/graphql/blog/find.graphql', - content: 'query find { records { id } }', - }); - expect(result).toEqual({ dependencies: [], structural: null }); - }); -}); diff --git a/packages/platformos-mcp-supervisor/src/structure/structure.ts b/packages/platformos-mcp-supervisor/src/structure/structure.ts deleted file mode 100644 index ce8923e7..00000000 --- a/packages/platformos-mcp-supervisor/src/structure/structure.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Structure adapter — an I/O boundary on the request path (sibling to `lint/`). - * - * From the validated buffer it derives two agent-facing facts via - * platformos-graph's per-file primitives, sharing a SINGLE parse: - * - `dependencies`: the outgoing edges (`extractFileReferences`), mapped to - * `ValidateCodeDependency[]`; - * - `structural`: the file's own declarations (`extractStructural`), mapped to - * `ValidateCodeStructuralSnapshot`. - * - * The supervisor owns ONLY the mapping. Graph resolution/extraction lives in - * platformos-graph; offset→line/col and uri→project-relative math are REUSED - * from platformos-check-common (`getPosition`, `path.relative`). No graph or - * path logic is re-implemented here. - * - * Like `lint/`, this parses the IN-FLIGHT buffer (not disk), so it works for a - * file the agent is about to write that does not exist yet. Only the on-disk - * project is touched (via `fs`) to resolve targets. - */ -import { getPosition, path } from '@platformos/platformos-check-common'; -import { NodeFileSystem } from '@platformos/platformos-check-node'; -import { - extractFileReferences, - extractStructural, - toSourceCode, - type ModuleStructural, - type Reference, - type ReferenceKind, -} from '@platformos/platformos-graph'; - -import { toAbsoluteFilePath, type AdapterInput } from '../adapter-input'; -import type { - ValidateCodeDependency, - ValidateCodeDependencyKind, - ValidateCodeStructuralSnapshot, -} from '../result/types'; - -/** - * The seam mapping the upstream graph `ReferenceKind` onto the agent-facing - * `ValidateCodeDependencyKind`. Exhaustive by construction (`Record`): if the graph adds or renames a kind, this table fails to compile — the - * agent surface never drifts silently. The names are 1:1 today; the table is the - * insulation point, not a rename. - */ -const DEPENDENCY_KIND: Record = { - render: 'render', - include: 'include', - function: 'function', - background: 'background', - graphql: 'graphql', - asset: 'asset', - layout: 'layout', -}; - -export interface StructureResult { - /** The file's resolved outgoing dependency edges. */ - dependencies: ValidateCodeDependency[]; - /** The file's own structural declarations, or `null` for a non-Liquid/unparseable buffer. */ - structural: ValidateCodeStructuralSnapshot | null; -} - -/** Derive the buffer's dependency edges + self-structural, sharing one parse. */ -export async function runStructure(params: AdapterInput): Promise { - const { projectDir, filePath, content } = params; - const absoluteFilePath = toAbsoluteFilePath(projectDir, filePath); - - // Normalized file URIs (forward slashes) so the graph's normalized target - // URIs share this exact root prefix — `path.relative` strips it cleanly on - // every platform. - const rootUri = path.normalize(path.URI.file(projectDir)); - const sourceUri = path.normalize(path.URI.file(absoluteFilePath)); - - // One parse of the in-flight buffer, shared by both graph primitives. - const sourceCode = await toSourceCode(sourceUri, content); - const [references, structural] = await Promise.all([ - extractFileReferences(rootUri, sourceUri, sourceCode, { fs: NodeFileSystem }), - extractStructural(sourceCode, sourceUri), - ]); - - return { - dependencies: references.flatMap((ref) => toDependency(ref, rootUri, content)), - structural: structural ? toStructuralSnapshot(structural) : null, - }; -} - -/** - * Map the graph's `ModuleStructural` to the agent-facing snapshot: usage arrays - * pass through (always present), and the optional routing facts become `null` - * when the file does not declare them. - */ -function toStructuralSnapshot(structural: ModuleStructural): ValidateCodeStructuralSnapshot { - return { - renders_used: structural.renders_used, - graphql_queries_used: structural.graphql_queries_used, - filters_used: structural.filters_used, - tags_used: structural.tags_used, - translation_keys: structural.translation_keys, - doc_params: structural.doc_params, - slug: structural.slug ?? null, - layout: structural.layout ?? null, - method: structural.method ?? null, - }; -} - -/** - * Map one graph `Reference` to a `ValidateCodeDependency`. `kind` and - * `source.range` are always populated by `extractFileReferences`; the guards - * keep the mapping total and defensive. check-common's `getPosition` yields - * 0-based line/character — the agent surface is 1-based, so both get `+ 1`. - */ -function toDependency(ref: Reference, rootUri: string, content: string): ValidateCodeDependency[] { - if (!ref.kind || !ref.source.range) return []; - const { line, character } = getPosition(content, ref.source.range[0]); - return [ - { - kind: DEPENDENCY_KIND[ref.kind], - target: path.relative(ref.target.uri, rootUri), - line: line + 1, - column: character + 1, - }, - ]; -} diff --git a/packages/platformos-mcp-supervisor/src/transport/server.ts b/packages/platformos-mcp-supervisor/src/transport/server.ts index f99f521a..b86cd44e 100644 --- a/packages/platformos-mcp-supervisor/src/transport/server.ts +++ b/packages/platformos-mcp-supervisor/src/transport/server.ts @@ -9,6 +9,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { path } from '@platformos/platformos-check-common'; + +import { GraphCache } from '../graph-cache/graph-cache'; import { createLogger, type Logger } from '../logger'; import { registerValidateCode, type SupervisorContext } from './validate-code'; @@ -33,7 +36,12 @@ const DEFAULT_VERSION = '0.0.1'; export async function startServer(opts: ServerOptions): Promise { const log = opts.log ?? createLogger(SERVER_NAME); - const context: SupervisorContext = { projectDir: opts.projectDir, log }; + // One never-stale project-graph cache per server (keyed by this project root), + // built lazily in the background on first blast-radius request. + const graphCache = new GraphCache({ + rootUri: path.normalize(path.URI.file(opts.projectDir)), + }); + const context: SupervisorContext = { projectDir: opts.projectDir, graphCache, log }; const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION }); registerValidateCode(server, context); diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts index a8302a6d..abcd6652 100644 --- a/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts @@ -1,21 +1,17 @@ import { describe, expect, it } from 'vitest'; import { runValidateCode, type SupervisorContext } from './validate-code'; -import type { StructureResult } from '../structure/structure'; -import type { - ValidateCodeDependency, - ValidateCodeDiagnostic, - ValidateCodeStructuralSnapshot, -} from '../result/types'; +import { GraphCache } from '../graph-cache/graph-cache'; +import type { ValidateCodeDiagnostic, ValidateCodeImpact } from '../result/types'; /** - * The lint/structure orchestration contract in `runValidateCode`: + * The lint/impact orchestration contract in `runValidateCode`: * - lint is the PRIMARY signal — a lint failure propagates (the whole call fails); - * - structure is SECONDARY enrichment — a structure failure degrades to empty - * `dependencies` + null `structural` (logged) and never sinks the lint diagnostics. + * - impact (blast radius) is SECONDARY enrichment — an impact failure degrades to + * `status: 'unavailable'` (logged) and never sinks the lint diagnostics. */ -describe('runValidateCode: lint/structure orchestration', () => { - const params = { file_path: 'app/views/pages/index.liquid', content: "{% render 'card' %}" }; +describe('runValidateCode: lint/impact orchestration', () => { + const params = { file_path: 'app/views/partials/card.liquid', content: '
' }; const warning: ValidateCodeDiagnostic = { check: 'SomeCheck', @@ -27,36 +23,24 @@ describe('runValidateCode: lint/structure orchestration', () => { end_column: 5, }; - const dependency: ValidateCodeDependency = { - kind: 'render', - target: 'app/views/partials/card.liquid', - line: 1, - column: 1, - }; - - const structural: ValidateCodeStructuralSnapshot = { - renders_used: ['card'], - graphql_queries_used: [], - filters_used: [], - tags_used: ['render'], - translation_keys: [], - doc_params: [], - slug: '/', - layout: null, - method: null, + const impact: ValidateCodeImpact = { + scope: 'direct', + status: 'computed', + dependents: { total: 1, by_kind: { render: 1 }, sample: ['app/views/pages/index.liquid'] }, }; - const structureOk: StructureResult = { dependencies: [dependency], structural }; - + // The cache is never exercised here — the impact adapter is faked — but the + // context requires one; a bare cache (never triggered) satisfies the type. const makeCtx = (log: SupervisorContext['log'] = () => {}): SupervisorContext => ({ projectDir: '/project', + graphCache: new GraphCache({ rootUri: 'file:///project' }), log, }); - it('passes lint diagnostics, dependencies, and structural straight through when both succeed', async () => { + it('passes lint diagnostics and impact straight through when both succeed', async () => { const result = await runValidateCode(makeCtx(), params, { lint: async () => [warning], - structure: async () => structureOk, + impact: async () => impact, }); expect(result).toEqual({ @@ -68,22 +52,21 @@ describe('runValidateCode: lint/structure orchestration', () => { proposed_fixes: [], clusters: [], scorecard: [], - dependencies: [dependency], + impact, parse_error: null, tips: [], domain_guide: null, - structural, }); }); - it('degrades to empty dependencies + null structural (and logs) when the structure adapter fails, preserving lint output', async () => { + it('degrades to an unavailable impact (and logs) when the impact adapter fails, preserving lint output', async () => { const logs: string[] = []; const result = await runValidateCode( makeCtx((message) => logs.push(message)), params, { lint: async () => [warning], - structure: async () => { + impact: async () => { throw new Error('boom'); }, }, @@ -98,17 +81,19 @@ describe('runValidateCode: lint/structure orchestration', () => { proposed_fixes: [], clusters: [], scorecard: [], - dependencies: [], + impact: { + scope: 'direct', + status: 'unavailable', + dependents: { total: 0, by_kind: {}, sample: [] }, + }, parse_error: null, tips: [], domain_guide: null, - structural: null, }); expect(logs).toEqual([ `validate_code: ${params.file_path} (full)`, - `validate_code: structural resolution failed for ${params.file_path}, ` + - `continuing without structure: boom`, + `validate_code: blast-radius failed for ${params.file_path}, continuing without impact: boom`, ]); }); @@ -119,7 +104,7 @@ describe('runValidateCode: lint/structure orchestration', () => { lint: async () => { throw failure; }, - structure: async () => structureOk, + impact: async () => impact, }), ).rejects.toThrow(failure); }); diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts index 2e02e03f..3c8ccd0f 100644 --- a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts @@ -10,15 +10,18 @@ import { z, type ZodRawShape } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { runLint } from '../lint/lint'; -import { runStructure, type StructureResult } from '../structure/structure'; +import { runImpact } from '../impact/impact'; import { assembleResult } from '../result/assemble'; +import type { GraphCache } from '../graph-cache/graph-cache'; import type { Logger } from '../logger'; -import type { ValidateCodeParams, ValidateCodeResult } from '../result/types'; +import type { ValidateCodeImpact, ValidateCodeParams, ValidateCodeResult } from '../result/types'; /** Per-server context threaded into the handler. */ export interface SupervisorContext { /** Absolute project root the buffer is validated against. */ projectDir: string; + /** Never-stale, background-built project graph — the blast-radius source. */ + graphCache: GraphCache; log: Logger; } @@ -76,27 +79,34 @@ export function registerValidateCode(server: McpServer, ctx: SupervisorContext): /** * The two I/O adapters {@link runValidateCode} orchestrates. Injectable so the * degrade-vs-propagate contract below can be unit-tested; defaults are the real - * lint / structure seams. + * lint / impact seams. */ export interface ValidateCodeAdapters { lint: typeof runLint; - structure: typeof runStructure; + impact: typeof runImpact; } +/** Blast radius when it could not be computed at all — never blocks the lint gate. */ +const UNAVAILABLE_IMPACT: ValidateCodeImpact = { + scope: 'direct', + status: 'unavailable', + dependents: { total: 0, by_kind: {}, sample: [] }, +}; + /** - * Lint the buffer (check-node seam) and derive its structure — dependency edges - * + self-structural (platformos-graph seam) — as two independent I/O adapters - * run concurrently, then assemble the result. + * Lint the buffer (check-node seam) and compute its cross-file blast radius + * (platformos-graph seam, via the cached project graph) — two independent I/O + * adapters run concurrently — then assemble the result. * * Lint is the PRIMARY signal (the `must_fix_before_write` gate): if it fails the - * whole call fails. Structure is SECONDARY enrichment, so a structure-adapter - * failure degrades to empty `dependencies` + null `structural` (logged) and - * never sinks the lint diagnostics the agent depends on. + * whole call fails. Impact is SECONDARY enrichment, so an impact-adapter failure + * degrades to `status: 'unavailable'` (logged) and never sinks the lint + * diagnostics the agent depends on. */ export async function runValidateCode( ctx: SupervisorContext, params: ValidateCodeParams, - adapters: ValidateCodeAdapters = { lint: runLint, structure: runStructure }, + adapters: ValidateCodeAdapters = { lint: runLint, impact: runImpact }, ): Promise { const mode = params.mode ?? 'full'; ctx.log(`validate_code: ${params.file_path} (${mode})`); @@ -105,17 +115,17 @@ export async function runValidateCode( filePath: params.file_path, content: params.content, }; - const [diagnostics, structure] = await Promise.all([ + const [diagnostics, impact] = await Promise.all([ adapters.lint(adapterParams), - adapters.structure(adapterParams).catch((error): StructureResult => { + adapters.impact(adapterParams, ctx.graphCache).catch((error): ValidateCodeImpact => { ctx.log( - `validate_code: structural resolution failed for ${params.file_path}, ` + - `continuing without structure: ${error instanceof Error ? error.message : error}`, + `validate_code: blast-radius failed for ${params.file_path}, ` + + `continuing without impact: ${error instanceof Error ? error.message : error}`, ); - return { dependencies: [], structural: null }; + return UNAVAILABLE_IMPACT; }), ]); - return assembleResult(diagnostics, structure.dependencies, structure.structural, mode); + return assembleResult(diagnostics, impact, mode); } /** Wrap a result in the MCP text-content envelope (every result is one JSON text block). */ diff --git a/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts b/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts index 3c0bec87..05fee7c4 100644 --- a/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts +++ b/packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts @@ -39,7 +39,15 @@ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const SRC = join(PACKAGE_ROOT, 'src'); /** The whole validate_code request path — none of it may reach for a language server. */ -const LINT_PATH_LAYERS = ['lint', 'structure', 'enrich', 'advise', 'result', 'transport']; +const LINT_PATH_LAYERS = [ + 'lint', + 'graph-cache', + 'impact', + 'enrich', + 'advise', + 'result', + 'transport', +]; /** The layers contractually required to be pure (no I/O). */ const PURE_LAYERS = ['enrich', 'result']; diff --git a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts index 78975dc3..fd50ac42 100644 --- a/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts +++ b/packages/platformos-mcp-supervisor/test/integration/stdio-smoke.spec.ts @@ -1,8 +1,9 @@ /** * Smoke test: build the package, then drive the REAL stdio bin with the * official MCP SDK client. Verifies the transport, the `validate_code` - * registration, the JSON-text result envelope, AND that real linting flows - * end to end (check-node → mapped diagnostics). + * registration, the JSON-text result envelope, real linting end to end + * (check-node → mapped diagnostics), AND the cross-file blast radius end to end + * (the cached project graph → `dependentsOf` → `impact`). * * The package is built in `beforeAll` (incremental `tsc -b`) so the suite is * self-contained under `yarn test` without a prior build step. A hermetic @@ -57,14 +58,16 @@ beforeAll(async () => { 'utf8', ); - // Real dependency targets so the `dependencies` field points at files that - // actually exist in the project (the realistic agent scenario). + // A real project on disk so the cached graph (and thus blast radius) is real: + // `home` renders `card` → `card` has one dependent; `lonely` has none. const writeProjectFile = (rel: string, body: string) => { const abs = join(projectDir, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, body, 'utf8'); }; writeProjectFile('app/views/partials/card.liquid', '
{{ title }}
'); + writeProjectFile('app/views/partials/lonely.liquid', '
nobody renders me
'); + writeProjectFile('app/views/pages/home.liquid', "{% render 'card' %}"); writeProjectFile( 'app/views/layouts/theme.liquid', '{{ content_for_layout }}', @@ -79,11 +82,22 @@ beforeAll(async () => { await client.connect(transport); }, 180_000); +/** + * Call `validate_code` and return the parsed result, polling until the + * background-built project graph is fresh (so `impact` is deterministic rather + * than the transient `computing`). Disk is not written between calls, so once + * built the graph stays fresh. + */ async function validateCode(args: { file_path: string; content: string; mode?: string }) { - const res = await client.callTool({ name: 'validate_code', arguments: args }); - const content = res.content as Array<{ type: string; text: string }>; - expect(content[0].type).toEqual('text'); - return JSON.parse(content[0].text); + for (let attempt = 0; attempt < 50; attempt++) { + const res = await client.callTool({ name: 'validate_code', arguments: args }); + const content = res.content as Array<{ type: string; text: string }>; + expect(content[0].type).toEqual('text'); + const result = JSON.parse(content[0].text); + if (result.impact?.status !== 'computing') return result; + await new Promise((resolvePoll) => setTimeout(resolvePoll, 100)); + } + throw new Error('blast radius did not settle (impact still "computing" after polling)'); } afterAll(async () => { @@ -92,14 +106,8 @@ afterAll(async () => { }); describe('Integration: validate_code over stdio', () => { - it('advertises exactly the validate_code tool', async () => { - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toEqual(['validate_code']); - }); - - // The always-empty envelope fields in this lint-only slice; spread into each - // expected result so every assertion checks the WHOLE object. `structural` is - // set per-test (it is populated for every Liquid buffer). + // The always-empty envelope fields in this slice; spread into each expected + // result so every assertion checks the WHOLE object. const EMPTY_ENVELOPE = { errors: [], warnings: [], @@ -107,29 +115,19 @@ describe('Integration: validate_code over stdio', () => { proposed_fixes: [], clusters: [], scorecard: [], - dependencies: [], parse_error: null, tips: [], domain_guide: null, }; - // The self-structural snapshot with all-empty usage + no routing facts; each - // test overrides only the fields its buffer declares. - const structural = (over: Record = {}) => ({ - renders_used: [], - graphql_queries_used: [], - filters_used: [], - tags_used: [], - translation_keys: [], - doc_params: [], - slug: null, - layout: null, - method: null, - ...over, - }); + // "Computed, nothing depends on this" — the safe-to-change signal, and the + // impact for files nothing on disk references. + const NO_DEPENDENTS = { + scope: 'direct', + status: 'computed', + dependents: { total: 0, by_kind: {}, sample: [] }, + }; - // The exact MissingContentForLayout error for a layout that omits - // `{{ content_for_layout }}` (reported at index 0 → 1-based line/col 1). const MISSING_CONTENT_FOR_LAYOUT = { check: 'MissingContentForLayout', severity: 'error', @@ -141,7 +139,12 @@ describe('Integration: validate_code over stdio', () => { end_column: 1, }; - it('returns the exact clean result for a valid layout (no dependencies)', async () => { + it('advertises exactly the validate_code tool', async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual(['validate_code']); + }); + + it('returns the exact clean result for a valid layout (nothing depends on it)', async () => { const result = await validateCode({ file_path: 'app/views/layouts/application.liquid', content: '{{ content_for_layout }}', @@ -151,11 +154,11 @@ describe('Integration: validate_code over stdio', () => { ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, - structural: structural(), + impact: NO_DEPENDENTS, }); }); - it('surfaces the exact lint diagnostic end to end', async () => { + it('surfaces the exact lint diagnostic AND the blast radius together, without conflating them', async () => { const result = await validateCode({ file_path: 'app/views/layouts/application.liquid', content: '
Site
', @@ -166,86 +169,74 @@ describe('Integration: validate_code over stdio', () => { status: 'error', must_fix_before_write: true, errors: [MISSING_CONTENT_FOR_LAYOUT], - structural: structural(), + impact: NO_DEPENDENTS, }); }); - it('reports the exact resolved dependency for a page that renders a partial', async () => { + it('reports the cross-file blast radius: who depends on the edited partial', async () => { + // `card` is rendered by the on-disk `home` page → exactly one dependent. const result = await validateCode({ - file_path: 'app/views/pages/index.liquid', - content: "{% render 'card' %}", + file_path: 'app/views/partials/card.liquid', + content: '
{{ title }} {{ subtitle }}
', }); expect(result).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, - dependencies: [ - { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 1 }, - ], - structural: structural({ renders_used: ['card'], tags_used: ['render'], slug: '/' }), + impact: { + scope: 'direct', + status: 'computed', + dependents: { + total: 1, + by_kind: { render: 1 }, + sample: ['app/views/pages/home.liquid'], + }, + }, }); }); - it('reports every dependency (layout + function + render) in source order', async () => { + it('reports zero dependents (safe to change) as computed — distinct from "not computed"', async () => { const result = await validateCode({ - file_path: 'app/views/pages/index.liquid', - content: `--- -layout: theme ---- -{% function items = 'queries/list' %} -{% render 'card' %}`, + file_path: 'app/views/partials/lonely.liquid', + content: '
still nobody
', }); expect(result).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, - dependencies: [ - { kind: 'layout', target: 'app/views/layouts/theme.liquid', line: 1, column: 1 }, - { kind: 'function', target: 'app/lib/queries/list.liquid', line: 4, column: 1 }, - { kind: 'render', target: 'app/views/partials/card.liquid', line: 5, column: 1 }, - ], - structural: structural({ - renders_used: ['card'], - tags_used: ['function', 'render'], - slug: '/', - layout: 'theme', - }), - }); - }); - - it('surfaces lint errors AND dependencies together without conflating them', async () => { - // A layout that both omits content_for_layout (lint error) and renders a - // partial (dependency) — the agent must see both, correctly separated. - const result = await validateCode({ - file_path: 'app/views/layouts/application.liquid', - content: "{% render 'card' %}", - }); - - expect(result).toEqual({ - ...EMPTY_ENVELOPE, - status: 'error', - must_fix_before_write: true, - errors: [MISSING_CONTENT_FOR_LAYOUT], - dependencies: [ - { kind: 'render', target: 'app/views/partials/card.liquid', line: 1, column: 7 }, - ], - structural: structural({ renders_used: ['card'], tags_used: ['render'] }), + impact: NO_DEPENDENTS, }); }); - it('does not invent dependencies for dynamic (non-literal) targets', async () => { + it('flags a caller broken by the edited partial’s new {% doc %} signature (signature-impact)', async () => { + // `home` renders `card` passing NO args. Give `card` a doc that REQUIRES + // `title` → `home` is now missing a required param, reported cross-file. const result = await validateCode({ - file_path: 'app/views/pages/index.liquid', - content: '{% assign name = "card" %}{% render name %}', + file_path: 'app/views/partials/card.liquid', + content: `{% doc %} + @param {String} title - required title +{% enddoc %} +
{{ title }}
`, }); expect(result).toEqual({ ...EMPTY_ENVELOPE, status: 'ok', must_fix_before_write: false, - structural: structural({ tags_used: ['assign', 'render'], slug: '/' }), + impact: { + scope: 'direct', + status: 'computed', + dependents: { total: 1, by_kind: { render: 1 }, sample: ['app/views/pages/home.liquid'] }, + signature_risk: [ + { + caller: 'app/views/pages/home.liquid', + missing_required: ['title'], + unexpected_args: [], + }, + ], + }, }); }); }); From f8d4549519476779388b173cd20533fe82aab989 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 10:24:24 +0200 Subject: [PATCH 10/20] feat: add unit tests for AppCache to ensure parsed-project reuse and cache behavior --- .../src/app-cache.spec.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/platformos-check-node/src/app-cache.spec.ts diff --git a/packages/platformos-check-node/src/app-cache.spec.ts b/packages/platformos-check-node/src/app-cache.spec.ts new file mode 100644 index 00000000..b8e5e01f --- /dev/null +++ b/packages/platformos-check-node/src/app-cache.spec.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { URI } from 'vscode-uri'; + +import { AppCache, getApp, lintBuffer, loadConfig, type App, type Config } from './index'; +import { Workspace, makeTempWorkspace } from './test/test-helpers'; + +/** + * TASK-9.13: the opt-in `AppCache` lets `getApp`/`lintBuffer` reuse parsed + * project sources across calls (the whole-project parse is the dominant cost), + * while NEVER serving a stale parse — reuse is gated on a per-file fingerprint. + * + * "Not re-parsed" is asserted by OBJECT IDENTITY: a reused file is the SAME + * `SourceCode` instance across calls; a re-parsed file is a new instance. + */ +describe('Unit: AppCache (parsed-project reuse for getApp/lintBuffer)', () => { + let workspace: Workspace; + let root: string; + let configPath: string; + let config: Config; + + const abs = (rel: string) => path.join(root, ...rel.split('/')); + const byUri = (app: App) => new Map(app.map((file) => [file.uri, file])); + + beforeEach(async () => { + workspace = await makeTempWorkspace({ + '.platformos-check.yml': ['extends: platformos-check:nothing', ''].join('\n'), + app: { + views: { + partials: { + 'a.liquid': '
a
', + 'b.liquid': '
b
', + }, + pages: { + 'home.liquid': '

home

', + }, + }, + }, + }); + root = URI.parse(workspace.rootUri).fsPath; + configPath = path.join(root, '.platformos-check.yml'); + config = await loadConfig(configPath, root); + }); + + afterEach(async () => { + await workspace?.clean(); + }); + + it('reuses the SAME parsed instances across calls when nothing changed (no re-parse)', async () => { + const cache = new AppCache(); + const first = byUri(await getApp(config, cache)); + const second = byUri(await getApp(config, cache)); + + expect(first.size).toBe(3); + expect(cache.size).toBe(3); + for (const [uri, source] of first) { + expect(second.get(uri)).toBe(source); // identical instance ⇒ not re-parsed + } + }); + + it('re-parses only the changed file and reuses the rest (never stale)', async () => { + const cache = new AppCache(); + const first = byUri(await getApp(config, cache)); + + // Different-length content guarantees the fingerprint moves regardless of mtime resolution. + await fs.writeFile(abs('app/views/partials/a.liquid'), '
a — edited longer
', 'utf8'); + const after = byUri(await getApp(config, cache)); + + const aUri = workspace.uri('app/views/partials/a.liquid'); + const bUri = workspace.uri('app/views/partials/b.liquid'); + const homeUri = workspace.uri('app/views/pages/home.liquid'); + + expect(after.get(aUri)).not.toBe(first.get(aUri)); // changed ⇒ re-parsed (new instance) + expect(after.get(aUri)!.source).toContain('edited longer'); // reflects new content + expect(after.get(bUri)).toBe(first.get(bUri)); // unchanged ⇒ reused + expect(after.get(homeUri)).toBe(first.get(homeUri)); // unchanged ⇒ reused + }); + + it('picks up an added file and reuses the rest', async () => { + const cache = new AppCache(); + const first = byUri(await getApp(config, cache)); + + await fs.writeFile(abs('app/views/partials/c.liquid'), '
c
', 'utf8'); + const after = byUri(await getApp(config, cache)); + + expect(after.size).toBe(4); + expect(after.has(workspace.uri('app/views/partials/c.liquid'))).toBe(true); + expect(after.get(workspace.uri('app/views/partials/b.liquid'))).toBe( + first.get(workspace.uri('app/views/partials/b.liquid')), + ); + }); + + it('drops a removed file (prunes the cache) and reuses the rest', async () => { + const cache = new AppCache(); + const first = byUri(await getApp(config, cache)); + + await fs.rm(abs('app/views/partials/a.liquid')); + const after = byUri(await getApp(config, cache)); + + expect(after.size).toBe(2); + expect(cache.size).toBe(2); + expect(after.has(workspace.uri('app/views/partials/a.liquid'))).toBe(false); + expect(after.get(workspace.uri('app/views/partials/b.liquid'))).toBe( + first.get(workspace.uri('app/views/partials/b.liquid')), + ); + }); + + it('without a cache, parses fresh every call (original behaviour untouched)', async () => { + const first = byUri(await getApp(config)); // no cache + const second = byUri(await getApp(config)); // no cache + + expect(first.size).toBe(3); + for (const [uri, source] of first) { + expect(second.get(uri)).not.toBe(source); // fresh parse ⇒ different instances + } + }); + + it('lintBuffer with a shared cache stays never-stale: a newly-created on-disk partial is reconciled', async () => { + // Re-configure with MissingPartial enabled for this assertion. + await fs.writeFile( + configPath, + ['extends: platformos-check:nothing', 'MissingPartial:', ' enabled: true', ''].join('\n'), + 'utf8', + ); + const cache = new AppCache(); + const pageFile = abs('app/views/pages/home.liquid'); + const lint = () => + lintBuffer({ root, filePath: pageFile, content: "{% render 'ghost' %}", configPath, cache }); + + // Cold: 'ghost' does not exist → MissingPartial. + const before = await lint(); + expect(before.some((o) => o.check === 'MissingPartial')).toBe(true); + + // Create the partial on disk; the SAME cache must reconcile it (not serve stale). + await fs.writeFile(abs('app/views/partials/ghost.liquid'), '
ghost
', 'utf8'); + const after = await lint(); + expect(after.some((o) => o.check === 'MissingPartial')).toBe(false); + }); +}); From 6b305d63d4218bf073a558265c6a2e2e5418ba45 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 10:24:46 +0200 Subject: [PATCH 11/20] feat: implement AppCache for efficient project source reuse across lint calls --- packages/platformos-check-node/src/index.ts | 137 ++++++++++++++++-- .../src/lint/lint.ts | 28 +++- .../src/transport/server.ts | 6 +- .../src/transport/validate-code.spec.ts | 3 + .../src/transport/validate-code.ts | 6 +- 5 files changed, 160 insertions(+), 20 deletions(-) diff --git a/packages/platformos-check-node/src/index.ts b/packages/platformos-check-node/src/index.ts index 47f25093..ffe1b9a8 100644 --- a/packages/platformos-check-node/src/index.ts +++ b/packages/platformos-check-node/src/index.ts @@ -55,9 +55,10 @@ export type AppCheckRun = { offenses: Offense[]; }; -export async function toSourceCode( - absolutePath: string, -): Promise { +/** A parsed source file as it appears in an {@link App}. */ +export type AppSourceCode = LiquidSourceCode | JSONSourceCode | GraphQLSourceCode | YAMLSourceCode; + +export async function toSourceCode(absolutePath: string): Promise { try { const source = await fs.readFile(absolutePath, 'utf8'); return commonToSourceCode(pathUtils.normalize(URI.file(absolutePath)), source); @@ -66,6 +67,69 @@ export async function toSourceCode( } } +/** + * Per-file change identity: `mtimeMs:size`. Cheap (a single `stat`) and standard + * (TypeScript `--incremental`, bundlers use the same). Returns `undefined` when + * the file cannot be stat'd (e.g. removed between enumeration and this call). + * + * Exported so consumers that maintain their own derived caches (e.g. the MCP + * supervisor's project-graph cache) can share ONE fingerprint definition rather + * than each inventing their own. + */ +export async function fileFingerprint(absolutePath: string): Promise { + try { + const info = await fs.stat(absolutePath); + return `${info.mtimeMs}:${info.size}`; + } catch { + return undefined; + } +} + +/** + * An OPT-IN, caller-held cache of parsed project sources for {@link getApp}. + * + * The whole-project parse is the dominant cost of a `lintBuffer` call (seconds + * on a large project). A caller that lints the same project repeatedly (the MCP + * supervisor) holds one `AppCache` and passes it to `getApp`/`lintBuffer`, so + * unchanged files are reused and only changed/new files are re-parsed. + * + * NEVER stale: reuse is gated on the per-file {@link fileFingerprint}; a changed + * file (mtime/size moved) is re-parsed, a removed file is pruned, an added file + * is parsed. Passing no cache preserves the original parse-everything behaviour + * exactly — existing consumers (CLI, backfill) are unaffected. + */ +export class AppCache { + private readonly entries = new Map(); + + /** Number of cached parsed files. */ + get size(): number { + return this.entries.size; + } + + /** The cached parse for `uri` when its fingerprint still matches, else undefined. */ + reuse(uri: string, fingerprint: string): AppSourceCode | undefined { + const entry = this.entries.get(uri); + return entry && entry.fingerprint === fingerprint ? entry.source : undefined; + } + + /** Store (or replace) the parse for `uri` at `fingerprint`. */ + store(uri: string, fingerprint: string, source: AppSourceCode): void { + this.entries.set(uri, { fingerprint, source }); + } + + /** Drop any cached file not in `keep` (removed from the project). */ + prune(keep: ReadonlySet): void { + for (const uri of this.entries.keys()) { + if (!keep.has(uri)) this.entries.delete(uri); + } + } + + /** Forget everything (explicit full invalidation). */ + clear(): void { + this.entries.clear(); + } +} + export async function check(root: string, configPath?: string): Promise { const run = await appCheckRun(root, configPath); return run.offenses; @@ -144,6 +208,12 @@ export interface LintBufferParams { content: string; /** Explicit config path; resolved from `root` when omitted. */ configPath?: string; + /** + * Optional parsed-project cache. When passed, the on-disk project is reused + * across calls and only changed files are re-parsed (never stale — see + * {@link AppCache}). Omit for the original parse-everything behaviour. + */ + cache?: AppCache; log?: (message: string) => void; } @@ -163,8 +233,8 @@ export interface LintBufferParams { * saved) the buffer is added so it is still linted. */ export async function lintBuffer(params: LintBufferParams): Promise { - const { root, filePath, content, configPath, log = () => {} } = params; - const { app, config } = await getAppAndConfig(root, configPath); + const { root, filePath, content, configPath, cache, log = () => {} } = params; + const { app, config } = await getAppAndConfig(root, configPath, cache); const uri = pathUtils.normalize(URI.file(filePath)); const overlaidApp = overlayBuffer(app, uri, content); const offenses = await lintApp(root, overlaidApp, config, log); @@ -190,24 +260,68 @@ function overlayBuffer(app: App, uri: string, content: string): App { export async function getAppAndConfig( root: string, configPath?: string, + cache?: AppCache, ): Promise<{ app: App; config: Config }> { const config = await loadConfig(configPath, root); - const app = await getApp(config); + const app = await getApp(config, cache); return { app, config, }; } -export async function getApp(config: Config): Promise { +const isDefinedSource = (x: AppSourceCode | undefined): x is AppSourceCode => x !== undefined; + +/** + * Load and parse every platformOS source file for `config`. + * + * When a {@link AppCache} is passed, unchanged files (by {@link fileFingerprint}) + * are reused instead of re-parsed and only changed/new files are parsed — the + * file set + config-driven filter are still re-evaluated every call, so the + * result can never be stale. Without a cache the behaviour is unchanged: every + * file is parsed. + */ +export async function getApp(config: Config, cache?: AppCache): Promise { + const paths = await getAppFilePaths(config); + + if (!cache) { + const sourceCodes = await Promise.all(paths.map(toSourceCode)); + return sourceCodes.filter(isDefinedSource); + } + + const keep = new Set(); + const sourceCodes = await Promise.all( + paths.map(async (filePath): Promise => { + const uri = pathUtils.normalize(URI.file(filePath)); + keep.add(uri); + const fingerprint = await fileFingerprint(filePath); + if (fingerprint === undefined) return undefined; // vanished between glob and stat + const reused = cache.reuse(uri, fingerprint); + if (reused) return reused; + const source = await toSourceCode(filePath); + if (source) cache.store(uri, fingerprint, source); + return source; + }), + ); + cache.prune(keep); + return sourceCodes.filter(isDefinedSource); +} + +/** + * The absolute, normalized paths of every platformOS source file for `config` + * (glob + the recognized-directory filter). This is the file-set discovery the + * app is built from; the config-driven `isIgnored` filter is applied here so a + * config change is reflected in the returned set. + */ +async function getAppFilePaths(config: Config): Promise { // On windows machines - the separator provided by path.join is '\' // however the glob function fails silently since '\' is used to escape glob charater // as mentioned in the documentation of node-glob // the path is normalised and '\' are replaced with '/' and then passed to the glob function - let normalizedGlob = getAppFilesPathPattern(config.rootUri); + const normalizedGlob = getAppFilesPathPattern(config.rootUri); - const paths = await glob(normalizedGlob, { absolute: true }).then((result) => + return glob(normalizedGlob, { absolute: true }).then((result) => result // Normalize backslashes to forward slashes so that isKnownLiquidFile() and // isIgnored() regex/minimatch patterns (which use forward slashes) work on Windows. @@ -233,11 +347,6 @@ export async function getApp(config: Config): Promise { return true; }), ); - const sourceCodes = await Promise.all(paths.map(toSourceCode)); - return sourceCodes.filter( - (x): x is LiquidSourceCode | JSONSourceCode | GraphQLSourceCode | YAMLSourceCode => - x !== undefined, - ); } export function getAppFilesPathPattern(rootUri: string) { diff --git a/packages/platformos-mcp-supervisor/src/lint/lint.ts b/packages/platformos-mcp-supervisor/src/lint/lint.ts index 61a1ffcb..b870d392 100644 --- a/packages/platformos-mcp-supervisor/src/lint/lint.ts +++ b/packages/platformos-mcp-supervisor/src/lint/lint.ts @@ -12,7 +12,12 @@ * `enrich/`; for now diagnostics have no `fix` / `hint`, and the structured * `Offense.fix` / `suggest` are intentionally not translated. */ -import { lintBuffer, Severity, type Offense } from '@platformos/platformos-check-node'; +import { + lintBuffer, + Severity, + type AppCache, + type Offense, +} from '@platformos/platformos-check-node'; import { toAbsoluteFilePath, type AdapterInput } from '../adapter-input'; import type { ValidateCodeDiagnostic, ValidateCodeSeverity } from '../result/types'; @@ -23,11 +28,26 @@ const SEVERITY: Record = { [Severity.INFO]: 'info', }; -/** Lint the buffer and return the mapped diagnostics for the file. */ -export async function runLint(params: AdapterInput): Promise { +/** + * Lint the buffer and return the mapped diagnostics for the file. + * + * The optional `cache` reuses the parsed on-disk project across calls (the + * whole-project parse is the dominant per-call cost); it is never stale (see + * check-node `AppCache`). The supervisor passes a per-project cache; omitting it + * lints correctly but re-parses the project each call. + */ +export async function runLint( + params: AdapterInput, + cache?: AppCache, +): Promise { const { projectDir, filePath, content } = params; const absoluteFilePath = toAbsoluteFilePath(projectDir, filePath); - const offenses = await lintBuffer({ root: projectDir, filePath: absoluteFilePath, content }); + const offenses = await lintBuffer({ + root: projectDir, + filePath: absoluteFilePath, + content, + cache, + }); return offenses.map(toDiagnostic); } diff --git a/packages/platformos-mcp-supervisor/src/transport/server.ts b/packages/platformos-mcp-supervisor/src/transport/server.ts index b86cd44e..4c7f0972 100644 --- a/packages/platformos-mcp-supervisor/src/transport/server.ts +++ b/packages/platformos-mcp-supervisor/src/transport/server.ts @@ -10,6 +10,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { path } from '@platformos/platformos-check-common'; +import { AppCache } from '@platformos/platformos-check-node'; import { GraphCache } from '../graph-cache/graph-cache'; import { createLogger, type Logger } from '../logger'; @@ -41,7 +42,10 @@ export async function startServer(opts: ServerOptions): Promise { const graphCache = new GraphCache({ rootUri: path.normalize(path.URI.file(opts.projectDir)), }); - const context: SupervisorContext = { projectDir: opts.projectDir, graphCache, log }; + // One never-stale parsed-project cache per server, so repeated lint calls reuse + // the parsed project instead of re-parsing it (the dominant per-call cost). + const appCache = new AppCache(); + const context: SupervisorContext = { projectDir: opts.projectDir, graphCache, appCache, log }; const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION }); registerValidateCode(server, context); diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts index abcd6652..a1a20a1b 100644 --- a/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { AppCache } from '@platformos/platformos-check-node'; + import { runValidateCode, type SupervisorContext } from './validate-code'; import { GraphCache } from '../graph-cache/graph-cache'; import type { ValidateCodeDiagnostic, ValidateCodeImpact } from '../result/types'; @@ -34,6 +36,7 @@ describe('runValidateCode: lint/impact orchestration', () => { const makeCtx = (log: SupervisorContext['log'] = () => {}): SupervisorContext => ({ projectDir: '/project', graphCache: new GraphCache({ rootUri: 'file:///project' }), + appCache: new AppCache(), log, }); diff --git a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts index 3c8ccd0f..7ebde6f8 100644 --- a/packages/platformos-mcp-supervisor/src/transport/validate-code.ts +++ b/packages/platformos-mcp-supervisor/src/transport/validate-code.ts @@ -9,6 +9,8 @@ import { z, type ZodRawShape } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AppCache } from '@platformos/platformos-check-node'; + import { runLint } from '../lint/lint'; import { runImpact } from '../impact/impact'; import { assembleResult } from '../result/assemble'; @@ -22,6 +24,8 @@ export interface SupervisorContext { projectDir: string; /** Never-stale, background-built project graph — the blast-radius source. */ graphCache: GraphCache; + /** Never-stale parsed-project cache — reused across lint calls so the project isn't re-parsed each time. */ + appCache: AppCache; log: Logger; } @@ -116,7 +120,7 @@ export async function runValidateCode( content: params.content, }; const [diagnostics, impact] = await Promise.all([ - adapters.lint(adapterParams), + adapters.lint(adapterParams, ctx.appCache), adapters.impact(adapterParams, ctx.graphCache).catch((error): ValidateCodeImpact => { ctx.log( `validate_code: blast-radius failed for ${params.file_path}, ` + From f686fa0d7e98a20554863ff0d9d3e30c32a23603 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 11:00:34 +0200 Subject: [PATCH 12/20] feat: implement incremental graph updates with applyFileChange and enhance GraphCache for fresh serving --- .../src/graph/incremental.spec.ts | 305 ++++++++++++++++++ .../platformos-graph/src/graph/incremental.ts | 215 ++++++++++++ .../platformos-graph/src/graph/traverse.ts | 4 +- packages/platformos-graph/src/index.ts | 2 + .../src/graph-cache/graph-cache.spec.ts | 96 +++++- .../src/graph-cache/graph-cache.ts | 146 +++++++-- 6 files changed, 735 insertions(+), 33 deletions(-) create mode 100644 packages/platformos-graph/src/graph/incremental.spec.ts create mode 100644 packages/platformos-graph/src/graph/incremental.ts diff --git a/packages/platformos-graph/src/graph/incremental.spec.ts b/packages/platformos-graph/src/graph/incremental.spec.ts new file mode 100644 index 00000000..c41414a2 --- /dev/null +++ b/packages/platformos-graph/src/graph/incremental.spec.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import nodePath from 'node:path'; +import { URI } from 'vscode-uri'; + +import { + isLayout, + isPage, + isPartial, + path, + recursiveReadDirectory, + UriString, +} from '@platformos/platformos-check-common'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; + +import { + applyFileChange, + buildAppGraph, + dependentsOf, + serializeAppGraph, + type AppGraph, + type FileChangeKind, +} from '../index'; + +/** + * TASK-9.14: `applyFileChange` updates a built graph in place for one file's + * change WITHOUT a full rebuild. + * + * The load-bearing contract (a wrong incremental result would mislead the agent) + * is EQUIVALENCE-TO-FULL-BUILD: after applying any change (or sequence), the + * graph must equal a from-scratch `buildAppGraph` of the same final disk state. + * Each scenario mutates a real temp project on disk, applies the change, and + * asserts the incrementally-updated graph serializes identically to a fresh full + * build — the same guarantee across add / modify / delete, missing targets, + * leaf GC, self-reference, and cycles. + */ +describe('Unit: applyFileChange (incremental graph update)', () => { + let root: string; + let rootUri: UriString; + + const deps = { fs: NodeFileSystem }; + + /** Absolute fs path for a project-relative path. */ + const abs = (rel: string) => nodePath.join(root, ...rel.split('/')); + /** Normalized `file://` URI for a project-relative path (matches graph node keys). */ + const uri = (rel: string): UriString => path.normalize(URI.file(abs(rel)).toString()); + + async function write(rel: string, content: string): Promise { + const file = abs(rel); + await mkdir(nodePath.dirname(file), { recursive: true }); + await writeFile(file, content, 'utf8'); + } + + /** The edge-source liquid files on disk — the cache's build entry points. */ + async function entryPoints(): Promise { + return recursiveReadDirectory( + NodeFileSystem, + rootUri, + ([u]) => isLayout(u) || isPage(u) || isPartial(u), + ); + } + + /** A from-scratch full build over the current disk state (the reference graph). */ + async function buildFull(): Promise { + return buildAppGraph(rootUri, deps, await entryPoints()); + } + + /** Canonical, order-independent serialization for whole-graph equality. */ + function canonical(graph: AppGraph) { + const serialized = serializeAppGraph(graph); + return { + rootUri: serialized.rootUri, + nodes: [...serialized.nodes].sort((a, b) => a.uri.localeCompare(b.uri)), + edges: [...serialized.edges].map((edge) => JSON.stringify(edge)).sort(), + }; + } + + /** The incremental graph must serialize identically to a fresh full build. */ + async function expectEquivalentToFullBuild(incremental: AppGraph): Promise { + expect(canonical(incremental)).toEqual(canonical(await buildFull())); + } + + const change = (rel: string, kind: FileChangeKind, graph: AppGraph) => + applyFileChange(graph, uri(rel), kind, deps); + + const GET_POSTS_GRAPHQL = [ + 'query get_posts {', + ' records(per_page: 20, filter: { table: { value: "blog_post" } }) {', + ' results { id }', + ' }', + '}', + '', + ].join('\n'); + + beforeEach(async () => { + root = await mkdtemp(nodePath.join(tmpdir(), 'pos-graph-incremental-')); + rootUri = path.normalize(URI.file(root).toString()); + + // A small but representative project: a page with a layout, a render chain, + // and a graphql leaf. + await write( + 'app/views/pages/index.liquid', + [ + '---', + 'layout: application', + '---', + "{% render 'card' %}", + "{% graphql q = 'get_posts' %}", + ].join('\n'), + ); + await write('app/views/partials/card.liquid', "{% render 'button' %}"); + await write('app/views/partials/button.liquid', ''); + await write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); + await write('app/graphql/get_posts.graphql', GET_POSTS_GRAPHQL); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('MODIFIED: adds a new edge to an existing partial', async () => { + const graph = await buildFull(); + + await write( + 'app/views/pages/index.liquid', + ['---', 'layout: application', '---', "{% render 'card' %}", "{% render 'button' %}"].join( + '\n', + ), + ); + await change('app/views/pages/index.liquid', 'modified', graph); + + await expectEquivalentToFullBuild(graph); + expect( + dependentsOf(graph, uri('app/views/partials/button.liquid')).map((r) => r.source.uri), + ).toEqual([uri('app/views/partials/card.liquid'), uri('app/views/pages/index.liquid')]); + }); + + it('MODIFIED: removing the last edge to a graphql leaf garbage-collects it', async () => { + const graph = await buildFull(); + expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeDefined(); + + await write( + 'app/views/pages/index.liquid', + ['---', 'layout: application', '---', "{% render 'card' %}"].join('\n'), + ); + await change('app/views/pages/index.liquid', 'modified', graph); + + expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeUndefined(); + await expectEquivalentToFullBuild(graph); + }); + + it('MODIFIED: an existing partial that loses its only referrer stays (it is an entry point)', async () => { + const graph = await buildFull(); + + await write('app/views/partials/card.liquid', '
no more button
'); + await change('app/views/partials/card.liquid', 'modified', graph); + + // button.liquid is an edge-source file → an entry point → kept as an orphan. + expect(graph.modules[uri('app/views/partials/button.liquid')]).toBeDefined(); + expect(dependentsOf(graph, uri('app/views/partials/button.liquid'))).toEqual([]); + await expectEquivalentToFullBuild(graph); + }); + + it('MODIFIED: refreshes a newly-reached graphql leaf table fact', async () => { + // Start with a page that references no graphql, then add the graphql edge. + await write('app/views/pages/index.liquid', "{% render 'card' %}"); + const graph = await buildFull(); + expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeUndefined(); + + await write( + 'app/views/pages/index.liquid', + ["{% render 'card' %}", "{% graphql q = 'get_posts' %}"].join('\n'), + ); + await change('app/views/pages/index.liquid', 'modified', graph); + + const full = await buildFull(); + const node = graph.modules[uri('app/graphql/get_posts.graphql')]; + const fullNode = full.modules[uri('app/graphql/get_posts.graphql')]; + expect(node?.type).toBe(fullNode?.type); + expect((node as { table?: string }).table).toEqual((fullNode as { table?: string }).table); + await expectEquivalentToFullBuild(graph); + }); + + it('ADDED: a brand-new partial (edge source) becomes a materialized entry point', async () => { + const graph = await buildFull(); + + await write('app/views/partials/footer.liquid', '
'); + await change('app/views/partials/footer.liquid', 'added', graph); + + expect(graph.modules[uri('app/views/partials/footer.liquid')]?.exists).toBe(true); + await expectEquivalentToFullBuild(graph); + }); + + it('ADDED: a previously-missing render target flips exists and resolves its incoming edge', async () => { + // index renders a partial that does not exist yet. + await write( + 'app/views/pages/index.liquid', + ['---', 'layout: application', '---', "{% render 'ghost' %}"].join('\n'), + ); + const graph = await buildFull(); + const ghost = uri('app/views/partials/ghost.liquid'); + expect(graph.modules[ghost]?.exists).toBe(false); + + await write('app/views/partials/ghost.liquid', '
ghost
'); + await change('app/views/partials/ghost.liquid', 'added', graph); + + expect(graph.modules[ghost]?.exists).toBe(true); + // The incoming edge from index resolves automatically once exists flips. + expect(dependentsOf(graph, ghost).map((r) => r.source.uri)).toEqual([ + uri('app/views/pages/index.liquid'), + ]); + await expectEquivalentToFullBuild(graph); + }); + + it('DELETED: a still-referenced file survives as a known-missing target', async () => { + const graph = await buildFull(); + + await rm(abs('app/views/partials/button.liquid')); + await change('app/views/partials/button.liquid', 'deleted', graph); + + const button = graph.modules[uri('app/views/partials/button.liquid')]; + // card still renders it → node kept, marked missing. + expect(button?.exists).toBe(false); + expect( + dependentsOf(graph, uri('app/views/partials/button.liquid')).map((r) => r.source.uri), + ).toEqual([uri('app/views/partials/card.liquid')]); + await expectEquivalentToFullBuild(graph); + }); + + it('DELETED: an unreferenced file is removed entirely, GC-ing its now-orphaned leaves', async () => { + const graph = await buildFull(); + expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeDefined(); + + // index is the only referrer of the layout and the graphql op; nothing renders index. + await rm(abs('app/views/pages/index.liquid')); + await change('app/views/pages/index.liquid', 'deleted', graph); + + expect(graph.modules[uri('app/views/pages/index.liquid')]).toBeUndefined(); + expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeUndefined(); + await expectEquivalentToFullBuild(graph); + }); + + it('handles a self-referencing file', async () => { + await write('app/views/partials/card.liquid', "{% render 'card' %}"); + const graph = await buildFull(); + + // Modify it to no longer reference itself, then back — both must stay equivalent. + await write('app/views/partials/card.liquid', '
plain
'); + await change('app/views/partials/card.liquid', 'modified', graph); + await expectEquivalentToFullBuild(graph); + + await write('app/views/partials/card.liquid', "{% render 'card' %}"); + await change('app/views/partials/card.liquid', 'modified', graph); + await expectEquivalentToFullBuild(graph); + }); + + it('handles a cycle (A renders B, B renders A)', async () => { + await write('app/views/partials/a.liquid', "{% render 'b' %}"); + await write('app/views/partials/b.liquid', "{% render 'a' %}"); + const graph = await buildFull(); + + await write('app/views/partials/a.liquid', '
no more b
'); + await change('app/views/partials/a.liquid', 'modified', graph); + + await expectEquivalentToFullBuild(graph); + }); + + it('stays equivalent across a mixed sequence of changes', async () => { + const graph = await buildFull(); + + // add a partial, wire the page to it, delete another, edit the layout. + await write('app/views/partials/footer.liquid', '
'); + await change('app/views/partials/footer.liquid', 'added', graph); + + await write( + 'app/views/pages/index.liquid', + ['---', 'layout: application', '---', "{% render 'card' %}", "{% render 'footer' %}"].join( + '\n', + ), + ); + await change('app/views/pages/index.liquid', 'modified', graph); + + await rm(abs('app/graphql/get_posts.graphql')); + // get_posts is no longer referenced by index (removed above) so it is already + // gone; deleting a file the graph does not model is a safe no-op. + await change('app/graphql/get_posts.graphql', 'deleted', graph); + + await write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); + await change('app/views/layouts/application.liquid', 'modified', graph); + + await expectEquivalentToFullBuild(graph); + }); + + it('is a no-op for a file the graph does not model (schema / unclassified)', async () => { + const graph = await buildFull(); + const before = canonical(graph); + + await change('app/schema/blog_post.yml', 'modified', graph); + await change('app/schema/blog_post.yml', 'added', graph); + await change('app/schema/blog_post.yml', 'deleted', graph); + + expect(canonical(graph)).toEqual(before); + }); +}); diff --git a/packages/platformos-graph/src/graph/incremental.ts b/packages/platformos-graph/src/graph/incremental.ts new file mode 100644 index 00000000..c19a289c --- /dev/null +++ b/packages/platformos-graph/src/graph/incremental.ts @@ -0,0 +1,215 @@ +import { + extractGraphqlTable, + extractSchemaTable, + path, + UriString, +} from '@platformos/platformos-check-common'; + +import { + AppGraph, + AppModule, + AugmentedDependencies, + GraphBuildOptions, + IDependencies, + ModuleType, +} from '../types'; +import { assertNever, exists, unique } from '../utils'; +import { augmentDependencies } from './augment'; +import { getModule } from './module'; +import { isEntryPoint } from './query'; +import { bind, extractStructural, resolveLiquidReferences } from './traverse'; + +/** The kind of on-disk change to apply to a single file. */ +export type FileChangeKind = 'added' | 'modified' | 'deleted'; + +/** + * Apply a single file's on-disk change to an ALREADY-BUILT {@link AppGraph} + * in place, WITHOUT rebuilding the whole project. + * + * A file's OUTGOING edges depend only on its own content (parse it → its + * render/include/function/background/graphql/asset/layout edges; no cross-file + * inference), so a change is applied in O(edges of the changed file) rather than + * O(project). The reverse index (`references`) is patched so every other file's + * dependents stay correct; incoming edges to the changed file resolve + * automatically because an edge is keyed by its canonical target URI and + * existence is a node property — flipping `exists` re-resolves them with no + * rewiring. + * + * Resolution reuses the exact same seams as {@link buildAppGraph} + * ({@link resolveLiquidReferences} + the URI-normalizing module factories + + * {@link bind}), so an incremental result can never drift from a from-scratch + * build. Dependencies are augmented FRESH per call (like the build), so the + * changed file — and any newly-reachable leaf — is re-read from disk, never + * served from a stale parse. + * + * PRECONDITION: `graph` was built with every edge-source liquid file + * (page/layout/partial+lib) as an entry point — the never-stale supervisor cache + * mode. Under it, every liquid target is already a materialized entry-point node, + * so only reached-only leaves (graphql/asset) are materialized on demand or + * garbage-collected; no traversal recursion is needed. Applying a change for a + * file the graph does not model (a schema `.yml`, an unreferenced leaf) is a + * no-op — schema discovery is a full-build concern. + * + * INVARIANT: applying any sequence of changes yields a graph equal to a + * from-scratch `buildAppGraph` of the same final disk state. + */ +export async function applyFileChange( + graph: AppGraph, + uri: UriString, + kind: FileChangeKind, + ideps: IDependencies, + options: GraphBuildOptions = {}, +): Promise { + // Match the module-factory keys (forward slashes), and augment FRESH so the + // default getSourceCode re-reads the changed file rather than a memoized parse. + const key = path.normalize(uri); + const deps = augmentDependencies(graph.rootUri, ideps); + + switch (kind) { + case 'deleted': + removeFile(graph, key); + return; + case 'added': + await addFile(graph, key, deps, options); + return; + case 'modified': + // A modify is a remove of the old edges followed by an add of the new ones: + // provably equivalent to a re-resolve, and it reuses one code path. + removeFile(graph, key); + await addFile(graph, key, deps, options); + return; + default: + return assertNever(kind); + } +} + +/** + * Detach a file from the graph: drop its OUTGOING edges from every target's + * reverse index (garbage-collecting any target that thereby becomes an + * unreachable reached-only leaf — one a from-scratch build would not + * materialize), drop it from the entry-point roots, then remove the node itself + * unless something still references it (in which case it survives as a + * known-missing target). + */ +function removeFile(graph: AppGraph, uri: UriString): void { + const node = graph.modules[uri]; + if (!node) return; + + const targetUris = unique(node.dependencies.map((dep) => dep.target.uri)); + node.dependencies = []; + + for (const targetUri of targetUris) { + if (targetUri === uri) continue; // self-edge: handled with the node itself, below + const target = graph.modules[targetUri]; + if (!target) continue; + target.references = target.references.filter((ref) => ref.source.uri !== uri); + // A non-entry-point node with no remaining incoming edges is unreachable, so a + // from-scratch build would not contain it (only edge-source liquid files are + // entry points; leaves and missing partials are materialized only when + // reached). Leaves have no outgoing edges, so removing one never cascades. + if (target.references.length === 0 && !isEntryPoint(graph, targetUri)) { + delete graph.modules[targetUri]; + } + } + + // An absent file is not an entry point in a from-scratch build. + graph.entryPoints = graph.entryPoints.filter((entry) => entry.uri !== uri); + + // Also drop this file's self-edges from its own reverse index. + node.references = node.references.filter((ref) => ref.source.uri !== uri); + + if (node.references.length === 0) { + delete graph.modules[uri]; + } else { + // Still referenced → survives as a known-missing target (exists:false), + // exactly as a from-scratch build would materialize it. + node.exists = false; + } +} + +/** + * Attach a file to the graph: materialize/refresh its node, mark it as an entry + * point if it is an edge-source liquid file, and resolve + bind its outgoing + * edges (materializing any newly-reached leaf target). A previously-missing file + * that is now on disk resolves its incoming edges automatically once `exists` + * flips true — those edges already point at this URI. + */ +async function addFile( + graph: AppGraph, + uri: UriString, + deps: AugmentedDependencies, + options: GraphBuildOptions, +): Promise { + const existing = graph.modules[uri]; + const node = existing ?? getModule(graph, uri); + if (!node) return; // not a graph-classifiable file (e.g. a schema .yml or unsupported type) + + // A NEW non-liquid file (asset/graphql) is reached-only: a from-scratch build + // materializes it only when something references it. If nothing does yet, leave + // it out — a later modify of a referrer materializes it via materializeTarget. + if (!existing && node.type !== ModuleType.Liquid) return; + + node.exists = await exists(deps.fs, uri); + graph.modules[uri] = node; + + if (!node.exists) return; // recorded as a known-missing node; no roots, edges, or table + + if (node.type !== ModuleType.Liquid) { + // An existing leaf that (re)appeared: refresh its neutral table fact. + await readLeafTable(node, deps); + return; + } + + // Every liquid module the factories produce (page/layout/partial) is an edge + // source and therefore an entry point in the cache's build mode. + if (!isEntryPoint(graph, uri)) graph.entryPoints.push(node); + + const sourceCode = await deps.getSourceCode(uri); + if (options.includeStructural) { + node.structural = await extractStructural(sourceCode, uri); + } + + const references = await resolveLiquidReferences(graph, sourceCode, deps); + for (const reference of references) { + await materializeTarget(graph, reference.target, deps); + bind(node, reference.target, { + sourceRange: reference.sourceRange, + kind: reference.kind, + args: reference.args, + }); + } +} + +/** + * Ensure an edge target is present in the graph with its existence (and, for a + * leaf that exists, its neutral table fact) recorded — the incremental + * equivalent of {@link traverseModule} reaching a target. A target already in + * the graph is left untouched (its own edges/refs are already correct); under + * the all-liquid precondition a liquid target is always already present, so only + * leaves and missing partials are ever materialized here. + */ +async function materializeTarget( + graph: AppGraph, + target: AppModule, + deps: AugmentedDependencies, +): Promise { + if (graph.modules[target.uri]) return; + graph.modules[target.uri] = target; + target.exists = await exists(deps.fs, target.uri); + if (target.exists) await readLeafTable(target, deps); +} + +/** + * Record a leaf module's neutral platform table fact — the GraphQL operation's + * `table` filter, or a schema's model `name:` — reusing check-common's parsers, + * exactly as {@link traverseModule} does. A no-op for asset/liquid modules. + */ +async function readLeafTable(module: AppModule, deps: AugmentedDependencies): Promise { + if (module.type === ModuleType.GraphQL) { + const sourceCode = await deps.getSourceCode(module.uri); + module.table = extractGraphqlTable(sourceCode.source); + } else if (module.type === ModuleType.Schema) { + const sourceCode = await deps.getSourceCode(module.uri); + module.table = extractSchemaTable(sourceCode.source); + } +} diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index de39dfef..a70f02bc 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -40,7 +40,7 @@ import { } from './module'; /** A resolved outgoing reference: the target graph node + its call-site range + kind (+ named-arg names). */ -interface ResolvedReference { +export interface ResolvedReference { target: AppModule; sourceRange: Range; kind: ReferenceKind; @@ -150,7 +150,7 @@ async function traverseLiquidModule( * match the rest of the graph on every platform. Unparseable input yields no * references rather than throwing. */ -async function resolveLiquidReferences( +export async function resolveLiquidReferences( appGraph: AppGraph, sourceCode: FileSourceCode, deps: ResolverDependencies, diff --git a/packages/platformos-graph/src/index.ts b/packages/platformos-graph/src/index.ts index 949349fb..71513920 100644 --- a/packages/platformos-graph/src/index.ts +++ b/packages/platformos-graph/src/index.ts @@ -1,4 +1,6 @@ export { buildAppGraph } from './graph/build'; +export { applyFileChange } from './graph/incremental'; +export type { FileChangeKind } from './graph/incremental'; export { extractFileReferences, extractStructural } from './graph/traverse'; export { dependenciesOf, diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts index 661eb1cb..aef6a0c7 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { path } from '@platformos/platformos-check-common'; import { NodeFileSystem } from '@platformos/platformos-check-node'; -import { type AppGraph, dependentsOf } from '@platformos/platformos-graph'; +import { type AppGraph, dependentsOf, type FileChangeKind } from '@platformos/platformos-graph'; import { GraphCache } from './graph-cache'; @@ -51,24 +51,100 @@ describe('GraphCache: never-stale, background-built, deduplicated', () => { expect(buildGraph).toHaveBeenCalledTimes(1); }); - it('NEVER serves a stale graph: a changed fingerprint yields recomputing, then the rebuilt graph', async () => { + it('NEVER serves stale: a source change is applied incrementally and served fresh (no rebuild)', async () => { + let current = fp({ a: '1' }); + const applied: Array<[string, FileChangeKind]> = []; + const buildGraph = vi.fn(async () => fakeGraph(1)); + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => current, + buildGraph, + applyChange: async (_graph, uri, kind) => { + applied.push([uri, kind]); + }, + }); + + await cache.lookup(); + await cache.settle(); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); + + // A source file changed → the graph is updated in place and served immediately. + current = fp({ a: '2' }); + expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); + expect(applied).toEqual([['a', 'modified']]); + expect(buildGraph).toHaveBeenCalledTimes(1); // updated incrementally, NOT rebuilt + }); + + it('applies added, modified, and deleted files from the fingerprint diff', async () => { + let current = fp({ a: '1', b: '1' }); + const applied: Array<[string, FileChangeKind]> = []; + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => current, + buildGraph: async () => fakeGraph(1), + applyChange: async (_graph, uri, kind) => { + applied.push([uri, kind]); + }, + }); + + await cache.lookup(); + await cache.settle(); + + current = fp({ a: '2', c: '1' }); // a modified, c added, b deleted + expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); + // Diff order: changed/added in `next` insertion order, then deletions. + expect(applied).toEqual([ + ['a', 'modified'], + ['c', 'added'], + ['b', 'deleted'], + ]); + }); + + it('falls back to a full rebuild when incremental apply fails (never a half-applied graph)', async () => { let current = fp({ a: '1' }); let build = 0; const cache = new GraphCache({ rootUri, computeFingerprint: async () => current, buildGraph: async () => fakeGraph(++build), + applyChange: async () => { + throw new Error('apply boom'); + }, }); await cache.lookup(); await cache.settle(); expect(await cache.lookup()).toEqual({ graph: fakeGraph(1) }); - // A source file changed → the old graph must NOT be served. + // Incremental apply throws → discard the graph and rebuild from scratch. current = fp({ a: '2' }); expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); await cache.settle(); expect(await cache.lookup()).toEqual({ graph: fakeGraph(2) }); + expect(build).toBe(2); + }); + + it('serializes concurrent reconciliations so the same change is not double-applied', async () => { + let current = fp({ a: '1' }); + let applyCount = 0; + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => current, + buildGraph: async () => fakeGraph(1), + applyChange: async () => { + applyCount++; + await Promise.resolve(); + }, + }); + + await cache.lookup(); + await cache.settle(); + + current = fp({ a: '2' }); + const results = await Promise.all([cache.lookup(), cache.lookup(), cache.lookup()]); + results.forEach((result) => expect(result).toEqual({ graph: fakeGraph(1) })); + // First reconcile applies 'a'; the queued ones re-check (already caught up) → no-op. + expect(applyCount).toBe(1); }); it('reports unavailable when the build fails, without a retry storm for the same source', async () => { @@ -156,7 +232,7 @@ describe('GraphCache: real project (integration — real buildAppGraph + fs + mt .map((ref) => ref.source.uri) .sort(); - it('builds a fresh graph whose dependents reflect real callers, and rebuilds — never stale — on a source change', async () => { + it('builds a fresh graph whose dependents reflect real callers, and stays fresh incrementally on a source change', async () => { const cache = new GraphCache({ rootUri, fs: NodeFileSystem }); // Cold: no graph yet, build triggered. @@ -171,15 +247,13 @@ describe('GraphCache: real project (integration — real buildAppGraph + fs + mt uri('app/views/pages/index.liquid'), ]); - // Edit a caller so about now also renders card. The cache must NOT serve the - // stale graph (which shows only index) — it recomputes. - // (a fresh mtime is guaranteed by writing different content) + // Edit a caller so about now also renders card. The cache applies the change + // incrementally (real applyFileChange) and serves the UPDATED graph + // immediately — no `recomputing` gap. (A fresh mtime is guaranteed by + // writing different content.) write('app/views/pages/about.liquid', "{% render 'card' %}\n

About

"); - expect(await cache.lookup()).toEqual({ graph: null, reason: 'recomputing' }); - await cache.settle(); - const second = await cache.lookup(); - if (!('graph' in second) || !second.graph) throw new Error('expected a rebuilt graph'); + if (!('graph' in second) || !second.graph) throw new Error('expected an updated graph'); expect(dependentSources(second.graph, 'app/views/partials/card.liquid')).toEqual([ uri('app/views/pages/about.liquid'), uri('app/views/pages/index.liquid'), diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts index d154bda6..e1724608 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts @@ -2,17 +2,25 @@ * Project-graph cache at the supervisor I/O edge. * * The full `AppGraph` is expensive to build (a whole-project parse), so it is - * built ONCE and reused across `validate_code` calls — but NEVER served stale. + * built ONCE and thereafter kept fresh INCREMENTALLY — but NEVER served stale. * Staleness would mislead the agent (e.g. "nothing depends on this, safe to * change" when a caller was added since the build), so the cache is - * fingerprint-validated on every request: it serves the graph only when the - * on-disk source it was built from is unchanged, and otherwise reports - * "recomputing" (triggering a background rebuild) rather than handing back a - * possibly-wrong answer. + * fingerprint-validated on every request: the fingerprint is the AUTHORITY. * - * The build is fired in the background and NEVER awaited on the request path — - * blast-radius is a secondary signal and must not add latency to, or ever sink, - * the primary lint gate (mirrors the `runValidateCode` degrade contract). + * When the fingerprint moves and a graph already exists, the cache DIFFS the + * fingerprint against the built graph's and applies ONLY the changed files via + * platformos-graph's `applyFileChange` (O(changed files), ~ms), then serves the + * updated graph immediately — no full rebuild, no `computing` gap after a single + * write (TASK-9.15 Phase 1). `applyFileChange` is provably equivalent to a + * from-scratch build (TASK-9.14); should incremental apply ever fail, the cache + * discards the graph and falls back to a full rebuild, so a half-applied graph is + * never served. Reconciliations are serialized so concurrent lookups can never + * interleave mutations of the shared graph. + * + * The COLD build (no prior graph) is still fired in the background and NEVER + * awaited on the request path — blast-radius is a secondary signal and must not + * add latency to, or ever sink, the primary lint gate (mirrors the + * `runValidateCode` degrade contract). (Persisted cold-start load is Phase 2.) * * Fingerprint domain = the liquid files that are edge SOURCES (page/layout/ * partial+lib). Only their add/remove/modify can change any file's dependents; @@ -32,7 +40,12 @@ import { } from '@platformos/platformos-check-common'; import { NodeFileSystem } from '@platformos/platformos-check-node'; import type { AbstractFileSystem } from '@platformos/platformos-common'; -import { buildAppGraph, type AppGraph } from '@platformos/platformos-graph'; +import { + applyFileChange, + buildAppGraph, + type AppGraph, + type FileChangeKind, +} from '@platformos/platformos-graph'; /** Per-file identity used to detect on-disk change: `mtimeMs:size`. */ type Fingerprint = Map; @@ -55,6 +68,13 @@ export interface GraphCacheOptions { fs: AbstractFileSystem, entryPoints: UriString[], ) => Promise; + /** Seam for tests: apply one file's change to a built graph. Defaults to `applyFileChange`. */ + applyChange?: ( + graph: AppGraph, + uri: UriString, + kind: FileChangeKind, + fs: AbstractFileSystem, + ) => Promise; } /** A liquid file that can be an edge SOURCE — the build's entry points + fingerprint domain. */ @@ -90,6 +110,27 @@ function fingerprintsEqual(a: Fingerprint, b: Fingerprint): boolean { return true; } +/** + * The per-file changes between two fingerprints: a URI present only in `next` is + * `added`, present only in `previous` is `deleted`, present in both with a + * different value is `modified`. This is the exact input to `applyFileChange`. + */ +function diffFingerprints( + previous: Fingerprint, + next: Fingerprint, +): Array<[UriString, FileChangeKind]> { + const changes: Array<[UriString, FileChangeKind]> = []; + for (const [uri, value] of next) { + const before = previous.get(uri); + if (before === undefined) changes.push([uri, 'added']); + else if (before !== value) changes.push([uri, 'modified']); + } + for (const uri of previous.keys()) { + if (!next.has(uri)) changes.push([uri, 'deleted']); + } + return changes; +} + /** * A never-stale, lazily-built, background-refreshed cache of a project's * `AppGraph`. One instance per project root (created per server). @@ -106,8 +147,14 @@ export class GraphCache { fs: AbstractFileSystem, entryPoints: UriString[], ) => Promise; + private readonly applyChange: ( + graph: AppGraph, + uri: UriString, + kind: FileChangeKind, + fs: AbstractFileSystem, + ) => Promise; - /** The graph + the fingerprint of the disk it was built from. */ + /** The graph + the fingerprint of the disk it was built from / reconciled to. */ private built: { graph: AppGraph; fingerprint: Fingerprint } | null = null; /** The in-flight background build, if any (dedup guard). */ private inFlight: Promise | null = null; @@ -115,6 +162,11 @@ export class GraphCache { private lastAttempt: Fingerprint | null = null; /** The error from the most recent failed build attempt, cleared on a new attempt. */ private lastError: Error | null = null; + /** + * Serializes incremental reconciliations so concurrent lookups never interleave + * mutations of the shared graph. Each stale lookup chains after the previous. + */ + private reconcileChain: Promise = Promise.resolve(); constructor(options: GraphCacheOptions) { this.rootUri = options.rootUri; @@ -123,29 +175,81 @@ export class GraphCache { this.buildGraph = options.buildGraph ?? ((rootUri, fs, entryPoints) => buildAppGraph(rootUri, { fs }, entryPoints)); + this.applyChange = + options.applyChange ?? ((graph, uri, kind, fs) => applyFileChange(graph, uri, kind, { fs })); } /** - * Return the graph ONLY if it is fresh (the on-disk source is unchanged since - * the build); otherwise trigger a background rebuild and report why no graph - * is available. Cheap (a stat-scan) and NON-BLOCKING — never awaits the build. + * Return a FRESH graph for the current on-disk state. When the source is + * unchanged since the last build/reconcile, serve the built graph directly. + * When it moved and a graph exists, reconcile incrementally (apply only the + * changed files) and serve the updated graph — no rebuild, no `computing` gap. + * Only a cold start (no prior graph) returns without a graph, triggering a + * background build. The fingerprint scan is cheap (a stat-scan); the request + * path never awaits a full build. */ async lookup(): Promise { const current = await this.computeFingerprint(this.rootUri, this.fs); - if (this.built && fingerprintsEqual(this.built.fingerprint, current)) { - return { graph: this.built.graph }; + if (this.built) { + if (fingerprintsEqual(this.built.fingerprint, current)) { + return { graph: this.built.graph }; + } + return this.reconcileAndServe(current); } + // Cold start: no prior graph to update incrementally → full build in the + // background (Phase 2 will load a persisted graph here instead). If the + // current source already failed to build and nothing is retrying it, it is + // genuinely unavailable; otherwise a build is in flight. this.ensureBuild(current); - // Not fresh: a build is (or was) needed. If the current source already failed - // to build and nothing is retrying it, it is genuinely unavailable; otherwise - // a build is in flight. const reason: 'recomputing' | 'unavailable' = this.lastError && !this.inFlight ? 'unavailable' : 'recomputing'; return { graph: null, reason }; } + /** + * Bring the built graph up to `target` by applying only the changed files, then + * serve it fresh. Reconciliations are serialized (chained) so concurrent + * lookups cannot interleave mutations of the shared graph; if incremental apply + * fails, fall back to a full rebuild rather than serve a half-applied graph. + */ + private reconcileAndServe(target: Fingerprint): Promise { + const run = this.reconcileChain.then(() => this.applyDiff(target)); + // Keep the chain alive whatever this run's outcome (a rejection here is + // recovered by fallbackToRebuild below; the chain must not stay rejected). + this.reconcileChain = run.catch(() => undefined); + return run.then( + (): GraphLookup => + this.built ? { graph: this.built.graph } : { graph: null, reason: 'recomputing' }, + (): GraphLookup => this.fallbackToRebuild(target), + ); + } + + /** + * Apply the fingerprint diff (previous → `target`) to the built graph via + * `applyChange`, then record `target` as the graph's fingerprint. Re-checks + * under the chain lock so a queued reconcile that another run already caught up + * to is a no-op; a rebuild that nulled the graph short-circuits. + */ + private async applyDiff(target: Fingerprint): Promise { + const built = this.built; + if (!built || fingerprintsEqual(built.fingerprint, target)) return; + for (const [uri, kind] of diffFingerprints(built.fingerprint, target)) { + await this.applyChange(built.graph, uri, kind, this.fs); + } + built.fingerprint = target; + } + + /** Incremental apply failed → discard the graph and full-rebuild from scratch. */ + private fallbackToRebuild(target: Fingerprint): GraphLookup { + this.built = null; + this.lastError = null; + this.lastAttempt = null; + this.ensureBuild(target); + return { graph: null, reason: 'recomputing' }; + } + /** * Start a background build for `fingerprint` unless one is already running or * this exact source already failed (avoids a retry storm on an unbuildable @@ -174,10 +278,12 @@ export class GraphCache { } /** - * Await the in-flight build, if any. TEST/warm-up hook only — the request path - * (`lookup`) never awaits a build. + * Await the in-flight build and any queued incremental reconciliation. TEST/ + * warm-up hook only — the request path (`lookup`) never awaits a full build + * (it does await its own incremental reconcile, which is fast). */ async settle(): Promise { await this.inFlight; + await this.reconcileChain; } } From ab434c56ead6ab269b4dc99fa308b16d5a230222 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 11:44:15 +0200 Subject: [PATCH 13/20] feat(graph): implement deserialization for app graph persistence - Add `deserializeAppGraph` function to reconstruct an in-memory AppGraph from its serialized form, ensuring round-trip identity and incremental readiness. - Introduce unit tests for `deserializeAppGraph` to validate serialization and deserialization processes, including edge cases for dangling edges and entry points. - Create a new module for graph cache persistence, including encoding and decoding cache files with versioning to handle corrupt or incompatible caches. - Enhance `GraphCache` to support loading from a persisted cache on cold starts, allowing for faster initialization and incremental updates. - Update server initialization to utilize the new cache path for improved performance and reliability. --- SUPERVISOR-GRAPH-INTEGRATION.md | 282 +++++++++++++----- .../src/graph/deserialize.spec.ts | 190 ++++++++++++ .../platformos-graph/src/graph/deserialize.ts | 81 +++++ packages/platformos-graph/src/graph/module.ts | 12 + packages/platformos-graph/src/index.ts | 1 + packages/platformos-graph/src/utils/index.ts | 2 +- .../src/graph-cache/graph-cache-store.spec.ts | 93 ++++++ .../src/graph-cache/graph-cache-store.ts | 106 +++++++ .../src/graph-cache/graph-cache.spec.ts | 107 ++++++- .../src/graph-cache/graph-cache.ts | 171 +++++++++-- .../src/transport/server.ts | 9 +- 11 files changed, 951 insertions(+), 103 deletions(-) create mode 100644 packages/platformos-graph/src/graph/deserialize.spec.ts create mode 100644 packages/platformos-graph/src/graph/deserialize.ts create mode 100644 packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts create mode 100644 packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md index 670998cd..123192af 100644 --- a/SUPERVISOR-GRAPH-INTEGRATION.md +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -11,7 +11,7 @@ examples, and the open doubts. > **§8 is the code-review remediation** — a high-effort review of the whole branch > ran before submission (8 finder angles + verification), surfacing 11 findings. > Ten were fixed and one deferred with rationale; that section is the place to -> understand *what changed as a result of review and why*. The rest of this +> understand _what changed as a result of review and why_. The rest of this > document reflects the **post-review** state. --- @@ -19,26 +19,28 @@ examples, and the open doubts. ## 1. PR summary ### What & why -The rebuilt `platformos-mcp-supervisor` must tell coding agents not just *what is -wrong* in a file but *how the file sits in the project* — what it renders/calls, + +The rebuilt `platformos-mcp-supervisor` must tell coding agents not just _what is +wrong_ in a file but _how the file sits in the project_ — what it renders/calls, what it wraps, where targets resolve, and the file's own structure. The architectural rule (ADR 003): **all graph/structure logic lives in `platformos-graph`; the supervisor is a pure consumer.** This branch extends the graph to provide that model and consumes it from `validate_code`. ### Highlights + - **Dependency edges** for every static Liquid construct — `render`, `include`, `function`, `background`, `graphql`, asset filters, and frontmatter `layout` — each carrying a semantic `kind`, call-site range, and named-arg names. - **Per-file primitive** `extractFileReferences` — resolves one in-flight buffer's outgoing edges without building the whole graph (the buffer-before-write model). -- **`validate_code` returns `impact`** *(TASK-9.10 — see §9)* — the cross-file +- **`validate_code` returns `impact`** _(TASK-9.10 — see §9)_ — the cross-file **blast radius**: who depends on the edited file (its callers), plus **signature-impact** (callers whose args no longer match the file's `{% doc %}`). This is the one graph signal lint cannot produce; the earlier per-file `dependencies`/`structural` fields were **removed** as redundant with lint (`MissingPartial`/`MissingAsset` + `PartialCallArguments`) — see §9. -- **Never-stale cached graph** *(TASK-9.10)* — a fingerprint-validated +- **Never-stale cached graph** _(TASK-9.10)_ — a fingerprint-validated `GraphCache` at the supervisor edge: reused across calls, background-built, and **never served stale** (a changed project reports `computing`, never a wrong answer). @@ -47,7 +49,7 @@ graph to provide that model and consumes it from `validate_code`. - **Per-file self-structural** — `renders_used`, `graphql_queries_used`, `filters_used`, `tags_used`, `translation_keys`, `doc_params`, `slug`, `layout`, `method`, exposed both as the per-file primitive `extractStructural` - *(exported in review — §8/F1)* and, opt-in, on each module during a full build. + _(exported in review — §8/F1)_ and, opt-in, on each module during a full build. - **Platform facts** — a GraphQL op's `table`, schema/`CustomModelType` nodes. - **`'layout'` DocumentType** in `DocumentsLocator` (the canonical resolver), with `.html.liquid`/`.liquid` precedence. @@ -56,6 +58,7 @@ graph to provide that model and consumes it from `validate_code`. in the graph). ### Scope discipline (kept the changes safe) + - **Additive everywhere.** New optional fields (`Reference.kind`, `.args`, `GraphQLModule.table`, `LiquidModule.structural`, `SchemaModule`), new query functions, new DocumentType. No breaking change to existing consumers. @@ -67,13 +70,14 @@ graph to provide that model and consumes it from `validate_code`. check-node, language-server, supervisor. ### Verification (final, post-review) -| Package | Tests | Δ from pre-review | -|---|---|---| -| platformos-common | **257** | +12 (`effectivePageSlug`) | -| platformos-graph | **80** | +7 (structural opt-in/primitive, node-identity) | -| platformos-check-common | **1057** | +10 (`extractSchemaTable`) | -| platformos-mcp-supervisor | **55** | 9.10: +graph-cache (7) +impact (10), −structure adapter | -| platformos-language-server-common | **467** | unchanged | + +| Package | Tests | Δ from pre-review | +| --------------------------------- | -------- | ------------------------------------------------------- | +| platformos-common | **257** | +12 (`effectivePageSlug`) | +| platformos-graph | **80** | +7 (structural opt-in/primitive, node-identity) | +| platformos-check-common | **1057** | +10 (`extractSchemaTable`) | +| platformos-mcp-supervisor | **55** | 9.10: +graph-cache (7) +impact (10), −structure adapter | +| platformos-language-server-common | **467** | unchanged | All packages type-check clean (via **direct `tsc --noEmit`** — see §8/F6 note on the flaky `yarn workspace type-check` wrapper); `yarn format:check` clean; @@ -109,6 +113,7 @@ platformos-mcp-supervisor validate_code — pure consumer of graph + ch ``` Rule of thumb that governs every decision here: + - **Parsing/resolution/path facts** → owned by common/check-common, **reused** by the graph (never re-derived). - **The project model (nodes, edges, queries, self-structural)** → owned by @@ -167,7 +172,7 @@ paths can never drift: 2. **`extractFileReferences(rootUri, sourceUri, sourceCode, { fs })`** — one file's **outgoing** edges from an **in-flight buffer** (parsed by the caller, may not be on disk). No whole-graph build, no reachability requirement. -3. **`extractStructural(sourceCode, uri)`** *(exported in review — §8/F1)* — the +3. **`extractStructural(sourceCode, uri)`** _(exported in review — §8/F1)_ — the per-file **self-structural** primitive (sibling to `extractFileReferences`): one buffer's own declarations, no build. `validate_code` uses paths 2 + 3 over a single shared parse. @@ -186,9 +191,11 @@ every other reference kind. ## 3. Decisions (and why) ### ADR 004 — Platform facts vs. conventions (the load-bearing one) + `commands` and `queries` are **not** platformOS primitives — they're a convention of the `core` module (`lib/commands/*`, `lib/queries/*` invoked by `{% function %}`). The platform sees only "a `function` edge to a partial." Therefore: + - **The graph stays convention-free.** It models the `function` edge and the partial; it does **not** learn "command" vs "query". (Baking that in would make the platform model — and the LSP that depends on it — wrong for any app not @@ -197,9 +204,10 @@ The platform sees only "a `function` edge to a partial." Therefore: **platform facts** (schema tables, graphql `table`, slug) live in the graph (TASK-9.6, done); the **commands/queries convention overlay** lives outside it (TASK-9.7, deferred) — a descriptive map in the supervisor domain layer and/or - a *configurable* check (disable-able for non-`core` apps). + a _configurable_ check (disable-able for non-`core` apps). ### ADR 003 — resolved + - **Self-structural owner = `platformos-graph`** (it already parses every file; exposes the snapshot as a by-product, composing check-common parsers). - **`Reference.kind`/`args`** = optional additive discriminant + arg names. @@ -207,11 +215,12 @@ The platform sees only "a `function` edge to a partial." Therefore: platformOS doesn't use `customElements.define`. ### Other notable decisions + - **`structural` usage arrays are always present** (empty = "none used"), so a consumer never has to disambiguate "absent" from "none". Routing facts (`slug`/`layout`/`method`) stay optional (absent = not applicable). - **Orphan detection guards non-render node kinds.** A schema file has no incoming - *edges* (it's referenced by table name), so `isOrphan` explicitly excludes + _edges_ (it's referenced by table name), so `isOrphan` explicitly excludes `Schema` to avoid a false "dead code" flag. - **Schema discovery only on full builds.** Scoped/LSP builds (explicit `entryPoints`) are byte-unchanged. @@ -227,27 +236,29 @@ The platform sees only "a `function` edge to a partial." Therefore: From `@platformos/platformos-graph`: -| Export | Purpose | -|---|---| -| `buildAppGraph(rootUri, deps, entryPoints?, options?)` | Build the full project graph from disk; `options.includeStructural` opts into per-module `structural` | -| `extractFileReferences(rootUri, sourceUri, sourceCode, { fs })` | One buffer's outgoing edges (no full build) | -| `extractStructural(sourceCode, uri)` | One buffer's self-structural (no build); `undefined` for non-Liquid/unparseable | -| `serializeAppGraph(graph)` | JSON `{ rootUri, nodes, edges }` | -| `toSourceCode(uri, source)` | Parse a buffer into a `FileSourceCode` | -| `dependenciesOf(graph, uri)` | Outgoing edges (what it renders/calls/wraps) — call sites w/ kind + args | -| `dependentsOf(graph, uri)` | Incoming edges (who renders/calls it) | -| `exists(graph, uri)` | Does it resolve to a real file | -| `isEntryPoint(graph, uri)` | Is it a page/layout root | -| `isOrphan(graph, uri)` / `orphans(graph)` | Unreferenced dead files (schema-guarded) | -| `reachableFrom(graph, uri)` | Transitive outgoing closure | -| `missingDependencies(graph, uri)` / `missingTargets(graph)` | Unresolved edges | -| `nearestModules(graph, uri, { limit?, maxDistance? })` | Same-category "did you mean" by edit distance | -| `ModuleStructural` / `GraphBuildOptions` | Self-structural shape; build options | +| Export | Purpose | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `buildAppGraph(rootUri, deps, entryPoints?, options?)` | Build the full project graph from disk; `options.includeStructural` opts into per-module `structural` | +| `extractFileReferences(rootUri, sourceUri, sourceCode, { fs })` | One buffer's outgoing edges (no full build) | +| `extractStructural(sourceCode, uri)` | One buffer's self-structural (no build); `undefined` for non-Liquid/unparseable | +| `serializeAppGraph(graph)` | JSON `{ rootUri, nodes, edges }` | +| `toSourceCode(uri, source)` | Parse a buffer into a `FileSourceCode` | +| `dependenciesOf(graph, uri)` | Outgoing edges (what it renders/calls/wraps) — call sites w/ kind + args | +| `dependentsOf(graph, uri)` | Incoming edges (who renders/calls it) | +| `exists(graph, uri)` | Does it resolve to a real file | +| `isEntryPoint(graph, uri)` | Is it a page/layout root | +| `isOrphan(graph, uri)` / `orphans(graph)` | Unreferenced dead files (schema-guarded) | +| `reachableFrom(graph, uri)` | Transitive outgoing closure | +| `missingDependencies(graph, uri)` / `missingTargets(graph)` | Unresolved edges | +| `nearestModules(graph, uri, { limit?, maxDistance? })` | Same-category "did you mean" by edit distance | +| `ModuleStructural` / `GraphBuildOptions` | Self-structural shape; build options | There is also a CLI (`bin/platformos-graph`): `platformos-graph [file]`. ### What `validate_code` discovers today + Per call (`{ file_path, content, mode }`): + - **Lint** (check-node `lintBuffer`): errors / warnings / infos with 1-based positions, `must_fix_before_write` gate, `status`. Lint is the **primary, forward-looking** signal — it also covers broken references (`MissingPartial`/ @@ -274,6 +285,7 @@ agent just wrote. The graph primitives (`extractFileReferences`/ ### 5.1 `validate_code` — a layout that omits `content_for_layout` while wiring deps Input `app/views/layouts/application.liquid`: + ```liquid --- layout: theme @@ -283,6 +295,7 @@ layout: theme ``` Output (verbatim): + ```json { "status": "error", @@ -292,7 +305,10 @@ Output (verbatim): "check": "MissingContentForLayout", "severity": "error", "message": "Layout is missing `{{ content_for_layout }}`. Every layout must output it exactly once — it renders the page body. (Named slots use `{% yield 'name' %}` separately and do not replace it.)", - "line": 1, "column": 1, "end_line": 1, "end_column": 1 + "line": 1, + "column": 1, + "end_line": 1, + "end_column": 1 } ], "warnings": [], @@ -316,6 +332,7 @@ Output (verbatim): ``` Two signals coexist without conflation: + - the **lint error** (`MissingContentForLayout`) — the per-file, forward-looking gate; - the **blast radius** (`impact`) — the cross-file signal lint cannot give: **2 @@ -332,13 +349,16 @@ longer match. ### 5.2 Graph self-structural — `header.liquid` For + ```liquid {% doc %} @param title [String] heading {% enddoc %}
{{ title | upcase }}
``` + `graph.modules[…/header.liquid].structural` is: + ```json { "renders_used": [], @@ -349,6 +369,7 @@ For "doc_params": ["title"] } ``` + (`{% doc %}` is a raw tag, so it is correctly absent from `tags_used`; its `@param` names surface as `doc_params`.) @@ -403,7 +424,7 @@ For timeout under full-suite parallel load). Not caused by this branch (verified via stash baseline **and** re-confirmed during review: the test passes in ~3.1s in isolation); CI is green. If it becomes noisy, bump that one test's timeout — but - it is *not* masking a real failure. + it is _not_ masking a real failure. 8. **`validate_code` re-globs + re-parses the whole project every call.** The dominant per-call cost is check-node's `getApp` (glob + parse the entire @@ -416,23 +437,26 @@ For ## 7. Task ledger (TASK-9 epic) -| Task | Status | -|---|---| -| 9.1 dependency edges (render/include/function/background/graphql/asset) | ✅ Done | -| 9.2 project-structure query API (dependents/orphan/reachability/missing/nearest) | ✅ Done | -| 9.3 per-file self-structural (9 facts) | ✅ Done | -| 9.4 layout edges + `DocumentsLocator 'layout'` | ✅ Done | -| 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | -| 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | -| 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | -| 9.8 code-review remediation (§8) | ✅ Done (F3 deferred) | -| 9.9 asset-resolution fix + real-project validation | ✅ Done | -| **9.10 cached graph → blast-radius in `validate_code`** (§9) | ✅ Done | -| 9.11 `project_map` (discovery + resource overlay) | ⬜ Scoped | -| 9.12 `validate_project` (health sweep + repair order) | ⬜ Scoped | -| 8.4 surface `structural` in `validate_code` | ↩︎ Superseded by 9.10 (removed) | - -**The three-tool graph strategy** (why the graph lives mostly *outside* +| Task | Status | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| 9.1 dependency edges (render/include/function/background/graphql/asset) | ✅ Done | +| 9.2 project-structure query API (dependents/orphan/reachability/missing/nearest) | ✅ Done | +| 9.3 per-file self-structural (9 facts) | ✅ Done | +| 9.4 layout edges + `DocumentsLocator 'layout'` | ✅ Done | +| 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | +| 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | +| 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | +| 9.8 code-review remediation (§8) | ✅ Done (F3 deferred) | +| 9.9 asset-resolution fix + real-project validation | ✅ Done | +| **9.10 cached graph → blast-radius in `validate_code`** (§9) | ✅ Done | +| 9.11 `project_map` (discovery + resource overlay) | ⬜ Scoped | +| 9.12 `validate_project` (health sweep + repair order) | ⬜ Scoped | +| 9.13 opt-in `AppCache` (parsed-project reuse for lint) | ✅ Done | +| **9.14 `applyFileChange` incremental graph API** (§10.1) | ✅ Done | +| **9.15 warm/incremental/persisted GraphCache** (§10.2) | ◐ Phases 1–2 done; Phase 3 (fs.watch + scoped walk) pending | +| 8.4 surface `structural` in `validate_code` | ↩︎ Superseded by 9.10 (removed) | + +**The three-tool graph strategy** (why the graph lives mostly _outside_ `validate_code`): lint owns the per-file forward-looking checks, so the graph's value is cross-file. It is surfaced through three intent-shaped tools — `validate_code` **blast radius** (change safety, at edit time — 9.10, done), @@ -456,6 +480,7 @@ package suites, type-check, and format re-run green after each. Tracked in **TASK-9.8**. ### What the review confirmed clean (no action) + - **CLAUDE.md conventions:** no violations. Path handling uses the sanctioned `URI.file()` + check-common `normalize()` (no manual `\\`→`/`); new tests use whole-value `toEqual` (the `.toBe(true/false)`/`.toBeNull()` cases fall under the @@ -480,7 +505,7 @@ per-file primitive (non-Liquid-safe → `undefined`); the supervisor's structure adapter now returns `{ dependencies, structural }` from **one shared parse**; eager per-module population is gated behind `buildAppGraph(..., { includeStructural })` (default off). Added `graphql_queries_used` to the supervisor's structural snapshot -for parity with the graph model. *(User decision: full fix incl. the LSP gate.)* +for parity with the graph model. _(User decision: full fix incl. the LSP gate.)_ **F2 — [Robustness/Med] A structure-adapter failure could sink the lint gate.** `validate_code` ran `Promise.all([lint, structure])`; a rejection in the secondary @@ -494,8 +519,8 @@ adapter and again inside `lintBuffer`. Fully de-duping this needs an additive ch to check-node's shared `lintBuffer` API and cross-file-type parse reconciliation, for a saving that is marginal against the pre-existing whole-project `getApp` parse (doubt #8) that dominates each call. Deferred with a recommendation to memoize -`getApp` per project instead. *(The intra-adapter half — sharing one parse between -`extractFileReferences` and `extractStructural` — was done as part of F1.)* +`getApp` per project instead. _(The intra-adapter half — sharing one parse between +`extractFileReferences` and `extractStructural` — was done as part of F1.)_ **F4 — [Altitude/Med] Schema-discovery contract was implicit.** Discovery keyed on `entryPoints === undefined`, and `isOrphan` special-cased `type === Schema`. **Fix @@ -507,6 +532,7 @@ single-property check. We **deliberately did not** add a speculative it would be more state to keep in sync, not less (YAGNI until a second appears). **F5 — [Reuse/Med] Three duplications collapsed to shared helpers.** + - (a) `toAbsoluteFilePath` + `AdapterInput` shared by the `lint` and `structure` adapters (was copy-pasted `node:path` resolution). - (b) `effectivePageSlug` in `platformos-common` is now the single slug-derivation, @@ -521,8 +547,8 @@ does **one** AST walk — doc `@param` names are collected in the same pass (rea the parser-produced `LiquidDocParamNode`, not re-implementing the liquid-doc parser) instead of a second `extractDocDefinition` traversal. `buildAppGraph` now does **one** directory sweep, partitioning pages/layouts and schema files by extension -(was two full-tree walks). *(This fix also surfaced a real type error — see the -tooling note below.)* +(was two full-tree walks). _(This fix also surfaced a real type error — see the +tooling note below.)_ **F7 — [Altitude/Low] Schema `table` extraction moved beside the parser.** Added exported `extractSchemaTable` in check-common (mirrors `extractGraphqlTable`, reuses @@ -554,16 +580,18 @@ a single `argsField` helper is the one place the "omit `args` when empty" rule l `frontmatterBody` → `loadFrontmatterOf` chain was collapsed. ### Doubt → finding map -| Pre-review doubt (§6) | Review finding | Outcome | -|---|---|---| -| #1 structural not surfaced | F1 | Fixed | -| #8 whole-project re-parse cost | F3 | Deferred (recommend `getApp` memo) | -| #3 orphans completeness | F4 | Contract documented | -| #4 graphql table single-style | F7 | Moved (behavior unchanged) | -| #5 schema table = `name:` | F8 | Non-string handling made explicit | -| #7 TypeSystem flake | — | Re-confirmed unrelated | + +| Pre-review doubt (§6) | Review finding | Outcome | +| ------------------------------ | -------------- | ---------------------------------- | +| #1 structural not surfaced | F1 | Fixed | +| #8 whole-project re-parse cost | F3 | Deferred (recommend `getApp` memo) | +| #3 orphans completeness | F4 | Contract documented | +| #4 graphql table single-style | F7 | Moved (behavior unchanged) | +| #5 schema table = `name:` | F8 | Non-string handling made explicit | +| #7 TypeSystem flake | — | Re-confirmed unrelated | ### Tooling note (worth a reviewer's attention) + During F6, `npx tsc --noEmit` caught a real control-flow-narrowing error (`entryPoints` possibly `undefined`) that the `yarn workspace type-check` wrapper **and** the vitest runs silently passed over. Type-checking in this review @@ -571,7 +599,9 @@ was therefore done with **direct `tsc --noEmit` per package**. If CI relies on t `yarn workspace` wrapper for type-checking, that gap is worth closing separately. ### Review-era changes to the public surface (for the reviewer's eye) + All additive except two intentional output improvements and one perf default: + - `buildAppGraph` gains an optional 4th arg `options: GraphBuildOptions` (`includeStructural`, default off). **Behavior change:** a full build no longer populates `module.structural` unless opted in — intentional (F1); no current @@ -588,14 +618,16 @@ All additive except two intentional output improvements and one perf default: ## 9. Cached graph → blast radius in `validate_code` (TASK-9.10) ### Why (the reframing) + The whole-branch review (§8) established that the graph's per-file outputs in `validate_code` were **redundant with lint**: broken references → `MissingPartial`/`MissingAsset`; argument correctness → `PartialCallArguments`; -the rest echoed the buffer the agent just wrote. The one thing lint *structurally -cannot* do is the **backward / cross-file** direction — "editing this file breaks +the rest echoed the buffer the agent just wrote. The one thing lint _structurally +cannot_ do is the **backward / cross-file** direction — "editing this file breaks its N callers." TASK-9.10 delivers exactly that and removes the redundant fields. ### What shipped + - **`GraphCache`** (`src/graph-cache/`) — one per project/server. Builds the full `AppGraph` once (all liquid files as entry points → **complete dependents**) and reuses it, background-built and **never awaited** on the request path. @@ -611,26 +643,30 @@ its N callers." TASK-9.10 delivers exactly that and removes the redundant fields structure adapter). Graph primitives kept for CLI / serialize / 9.11 / 9.12. ### Freshness — never stale (the load-bearing decision) + Staleness would mislead the agent (a stale "0 dependents → safe to change" is the worst-case), so **TTL and stale-while-revalidate were both rejected**. The cache is **fingerprint-validated on every request**: a cheap `mtime:size` stat-scan over the edge-source liquid files (page/layout/partial+lib — the only files whose change can alter any dependents). Fingerprint matches → serve; otherwise report -`computing` and rebuild in the background. The agent validates in-flight *buffers* +`computing` and rebuild in the background. The agent validates in-flight _buffers_ without writing to disk, so the fingerprint stays matched across a burst (fresh every call); a rebuild triggers only after an actual write. -Buffer overlay is **not** needed for dependents (who points *at* the file lives in -*other* files, unaffected by the buffer); the buffer is used only to read the +Buffer overlay is **not** needed for dependents (who points _at_ the file lives in +_other_ files, unaffected by the buffer); the buffer is used only to read the current `{% doc %}` for `signature_risk`. ### Degrade contract (F2) + `impact` is secondary: a graph-build failure or an impact-adapter throw degrades to `status: 'unavailable'` (logged) and **never** sinks the lint gate. ### Worked example — signature-impact + `home.liquid` renders `card` with no args. Editing `card` to add a required `@param title`: + ```json "impact": { "scope": "direct", @@ -643,6 +679,7 @@ to `status: 'unavailable'` (logged) and **never** sinks the lint gate. ``` ### Tests + `graph-cache.spec` (never-stale/dedup/failure state machine + a real-fixture integration proving rebuild-on-change), `impact.spec` (dependents shaping + signature-impact), `validate-code.spec` (lint/impact orchestration + degrade), @@ -650,8 +687,9 @@ and a `stdio-smoke` end-to-end (real cached graph over the transport: dependents safe-to-change, and signature-impact). Supervisor suite: **55**. ### Measured cost (real 1,505-node project `marketplace-dcra`) + - **Warm request (fresh graph served): ~400 ms** — the fingerprint stat-scan. - Sub-second, and it runs *concurrently* with lint's own multi-second + Sub-second, and it runs _concurrently_ with lint's own multi-second whole-project parse, so blast-radius adds ≈0 wall-clock. - **Cold first fingerprint: ~8 s** (cold OS cache; dominated by walking the whole tree, incl. non-platformOS dirs like `react-app/`) — hidden behind lint's own @@ -659,6 +697,7 @@ safe-to-change, and signature-impact). Supervisor suite: **55**. - **Background build: ~22 s** — never awaited on the request path. ### Open follow-ups + - The fingerprint (and the build's entry-point enumeration) walks the whole tree; scoping the walk to platformOS dirs (or reusing lint's file list) would cut the ~400 ms warm cost. Not a 9.10 regression (same walk `buildAppGraph`/lint already @@ -667,3 +706,106 @@ safe-to-change, and signature-impact). Supervisor suite: **55**. `computing` until the background build finishes; a bounded await for small projects is a possible future nicety (deliberately omitted to never delay lint). - `getApp` memoization (doubt #8) remains the higher-value perf lever, unchanged. + +> The first two follow-ups above are resolved by §10 (incremental apply removes the +> post-write `computing` gap; persistence removes the cold-build wait). Scoping the +> walk is the remaining Phase-3 item. + +--- + +## 10. Always-fresh graph — incremental + persisted (TASK-9.14 / 9.15) + +§9 shipped a _correct, never-stale_ cache, but it FULL-rebuilt (~22 s) on any +change, so blast-radius went `computing` for ~22 s after every write, and cold +start paid the full build. The graph is **"embarrassingly incremental"**: a +file's outgoing edges depend only on its own content (no cross-file inference), +so a change is applied in `O(edges of the changed file)`. We apply the proven +playbook (rust-analyzer/Salsa demand-driven incremental, LSP +`didChangeWatchedFiles`, TS `--incremental`, bundler HMR) rather than invent. + +**The never-stale mandate is preserved throughout: the fingerprint is the +AUTHORITY.** Incremental apply is _driven by_ the fingerprint diff; any detected +inconsistency falls back to a full rebuild; a persisted graph is reconciled +against the fingerprint after load. Watch/persistence = speed; fingerprint = +truth. + +### 10.1 `applyFileChange` — incremental graph update (TASK-9.14, platformos-graph) + +New `packages/platformos-graph/src/graph/incremental.ts`, exported from the +package index (structure logic lives in the graph package, per ADR 003): + +``` +applyFileChange(graph, uri, kind: 'added'|'modified'|'deleted', deps, options?) +``` + +- Reuses the _exact_ build seams — `resolveLiquidReferences` (now exported) + + the URI-normalizing module factories + `bind` — so an incremental result can + never drift from a from-scratch build. No forked resolution. +- `modified = removeFile + addFile`. `removeFile` detaches the file's outgoing + edges from each target's reverse index, garbage-collects any target that + becomes an unreachable non-entry-point leaf (matching a full build's + omission), drops it from `entryPoints`, then removes the node unless still + referenced (kept `exists:false`). `addFile` materializes/refreshes the node, + registers edge-source liquid files as entry points, and resolves + binds its + outgoing edges (materializing any newly-reached leaf with its `table` fact). +- Incoming edges to an added/deleted file resolve automatically via the `exists` + flag — an edge is keyed by its canonical target URI, so flipping `exists` + re-resolves it with no rewiring. +- Dependencies are augmented **fresh per call** (like the build), so the changed + file — and any newly-reachable leaf — is re-read from disk, never a stale + parse. Precondition: the graph was built with every edge-source liquid file as + an entry point (the cache's mode). + +**Guardrail (the correctness invariant):** `incremental.spec.ts` mutates a real +temp project on disk, applies the change, and asserts +`serializeAppGraph(incremental)` is canonically identical to a fresh +`buildAppGraph` of the same disk state — across add / modify / delete, missing- +target `exists` flips, leaf GC, self-reference, cycles, and mixed sequences. + +### 10.2 Warm, incremental, persisted `GraphCache` (TASK-9.15) + +**Phase 1 — incremental apply.** On a fingerprint move with a graph already in +memory, the cache DIFFS the fingerprint (`diffFingerprints` → added/modified/ +deleted) and applies only the changed files via `applyFileChange`, then serves +the updated graph **immediately** — no rebuild, no `computing` gap after a write. +Reconciliations are serialized (a promise chain) so concurrent lookups never +interleave mutations of the shared graph; a queued reconcile that another run +already caught up to is a no-op. If incremental apply ever throws, the cache +discards the graph and falls back to a full rebuild — a half-applied graph is +never served. + +**Phase 2 — persistence (warm cold start).** The built graph + its per-file +fingerprint + entry-point URIs are persisted to a versioned JSON cache file +(`graph-cache-store.ts`; `serializeAppGraph` + the new `deserializeAppGraph`, +which seeds the module identity cache so a loaded graph reconciles exactly like a +freshly-built one). On cold start the cache LOADS the persisted graph and +reconciles the on-disk delta incrementally instead of a ~22 s build. Persistence +is off the request path and coalesced (a burst of edits collapses to one write, +always writing the latest state). A missing / corrupt / wrong-version / +wrong-root cache decodes to `null` and falls back to a full build; the +fingerprint still gates correctness after load, so a stale cache converges to +fresh and a bad one never yields a wrong answer. Default cache file: +`/platformos-mcp-supervisor/graph-.json` (a rebuildable +derivative; wired by the server, disableable by omitting `cachePath`). + +**Measured — real project `marketplace-dcra` (2,170 nodes, 1,921 entry points):** + +| | Before (§9) | After (§10) | +| -------------------- | ------------------------------------ | -------------------------------------------------------------------------------------- | +| Post-write freshness | `computing` for ~22 s (full rebuild) | fresh **immediately** (incremental apply, ~ms) | +| Cold start | ~22 s full build | **~37 ms** persisted load (+ delta reconcile) — vs a **70 s** cold build here → ~1900× | +| Serialize + persist | — | ~48 ms (1.4 MiB) | + +**Tests.** `incremental.spec.ts` (equivalence guardrail), `deserialize.spec.ts` +(round-trip + a restored graph reconciles exactly like a full build), +`graph-cache-store.spec.ts` (encode/decode + version/root/corruption +invalidation), `graph-cache.spec.ts` (incremental serve, diff kinds, apply- +failure fallback, concurrent-reconcile coalescing, warm cold-start from disk, +delta reconcile after warm, corrupt→rebuild). Supervisor + graph suites, +type-check, and format green. + +**Phase 3 (pending).** `fs.watch` background freshness (with the fingerprint +reconciliation kept as the safety net — never trust the watcher alone) so the +request path does no per-call scan on the steady state; and scoping the file +walk to platformOS dirs (cuts the residual ~400 ms warm fingerprint cost — +resolves the first §9 follow-up). diff --git a/packages/platformos-graph/src/graph/deserialize.spec.ts b/packages/platformos-graph/src/graph/deserialize.spec.ts new file mode 100644 index 00000000..54034db1 --- /dev/null +++ b/packages/platformos-graph/src/graph/deserialize.spec.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import nodePath from 'node:path'; +import { URI } from 'vscode-uri'; + +import { + isLayout, + isPage, + isPartial, + path, + recursiveReadDirectory, + UriString, +} from '@platformos/platformos-check-common'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; + +import { + applyFileChange, + buildAppGraph, + deserializeAppGraph, + dependentsOf, + serializeAppGraph, + type AppGraph, +} from '../index'; + +/** + * `deserializeAppGraph` is the load half of graph persistence (TASK-9.15 Phase 2). + * Two contracts matter: + * 1. ROUND-TRIP: serialize → deserialize → serialize is identity (modulo order). + * 2. INCREMENTAL-READY: a deserialized graph must behave EXACTLY like a + * from-scratch build under `applyFileChange` — i.e. the module identity cache + * is seeded, so reconciling a loaded graph converges to the same graph a full + * build would produce. This is the guarantee that makes "load a persisted + * graph, then reconcile the delta" never-stale. + */ +describe('Unit: deserializeAppGraph (persistence load half)', () => { + let root: string; + let rootUri: UriString; + + const deps = { fs: NodeFileSystem }; + const abs = (rel: string) => nodePath.join(root, ...rel.split('/')); + const uri = (rel: string): UriString => path.normalize(URI.file(abs(rel)).toString()); + + async function write(rel: string, content: string): Promise { + const file = abs(rel); + await mkdir(nodePath.dirname(file), { recursive: true }); + await writeFile(file, content, 'utf8'); + } + + async function entryPoints(): Promise { + return recursiveReadDirectory( + NodeFileSystem, + rootUri, + ([u]) => isLayout(u) || isPage(u) || isPartial(u), + ); + } + + async function buildFull(): Promise { + return buildAppGraph(rootUri, deps, await entryPoints()); + } + + function canonical(graph: AppGraph) { + const serialized = serializeAppGraph(graph); + return { + rootUri: serialized.rootUri, + nodes: [...serialized.nodes].sort((a, b) => a.uri.localeCompare(b.uri)), + edges: [...serialized.edges].map((edge) => JSON.stringify(edge)).sort(), + }; + } + + beforeEach(async () => { + root = await mkdtemp(nodePath.join(tmpdir(), 'pos-graph-deserialize-')); + rootUri = path.normalize(URI.file(root).toString()); + + await write( + 'app/views/pages/index.liquid', + [ + '---', + 'layout: application', + '---', + "{% render 'card' %}", + "{% graphql q = 'get_posts' %}", + ].join('\n'), + ); + await write('app/views/partials/card.liquid', "{% render 'button' %}"); + await write('app/views/partials/button.liquid', ''); + await write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); + await write( + 'app/graphql/get_posts.graphql', + 'query get_posts { records(filter: { table: { value: "blog_post" } }) { results { id } } }', + ); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('round-trips serialize → deserialize → serialize identically', async () => { + const original = await buildFull(); + const serialized = serializeAppGraph(original); + const entryPointUris = original.entryPoints.map((module) => module.uri); + + const restored = deserializeAppGraph(serialized, entryPointUris); + + expect(canonical(restored)).toEqual(canonical(original)); + expect(restored.rootUri).toBe(original.rootUri); + // Entry points are restored (not carried by the serialized form itself). + expect(restored.entryPoints.map((m) => m.uri).sort()).toEqual(entryPointUris.sort()); + }); + + it('rebuilds the reverse index so dependents are queryable on the restored graph', async () => { + const original = await buildFull(); + const restored = deserializeAppGraph( + serializeAppGraph(original), + original.entryPoints.map((module) => module.uri), + ); + + expect( + dependentsOf(restored, uri('app/views/partials/card.liquid')).map((r) => r.source.uri), + ).toEqual([uri('app/views/pages/index.liquid')]); + expect( + dependentsOf(restored, uri('app/views/partials/button.liquid')).map((r) => r.source.uri), + ).toEqual([uri('app/views/partials/card.liquid')]); + }); + + it('a restored graph reconciles a change EXACTLY like a from-scratch build (cache is seeded)', async () => { + const original = await buildFull(); + const restored = deserializeAppGraph( + serializeAppGraph(original), + original.entryPoints.map((module) => module.uri), + ); + + // Edit index so it renders button directly, then apply to the RESTORED graph. + await write( + 'app/views/pages/index.liquid', + ['---', 'layout: application', '---', "{% render 'card' %}", "{% render 'button' %}"].join( + '\n', + ), + ); + await applyFileChange(restored, uri('app/views/pages/index.liquid'), 'modified', deps); + + // If the identity cache were not seeded, the new edge would bind to a + // duplicate node and this would diverge from a full build. + expect(canonical(restored)).toEqual(canonical(await buildFull())); + expect( + dependentsOf(restored, uri('app/views/partials/button.liquid')) + .map((r) => r.source.uri) + .sort(), + ).toEqual([uri('app/views/pages/index.liquid'), uri('app/views/partials/card.liquid')]); + }); + + it('reconciles added and deleted files on a restored graph, matching a full build', async () => { + const original = await buildFull(); + const restored = deserializeAppGraph( + serializeAppGraph(original), + original.entryPoints.map((module) => module.uri), + ); + + await write('app/views/partials/footer.liquid', '
'); + await applyFileChange(restored, uri('app/views/partials/footer.liquid'), 'added', deps); + + await rm(abs('app/views/pages/index.liquid')); + await applyFileChange(restored, uri('app/views/pages/index.liquid'), 'deleted', deps); + + expect(canonical(restored)).toEqual(canonical(await buildFull())); + }); + + it('skips dangling edges and restores no entry points when none are given', async () => { + // A malformed serialization: an edge whose target node is absent. + const restored = deserializeAppGraph({ + rootUri, + nodes: [ + { uri: uri('app/views/pages/index.liquid'), type: 'Liquid', kind: 'page', exists: true }, + ], + edges: [ + { + source: { uri: uri('app/views/pages/index.liquid') }, + target: { uri: uri('app/views/partials/ghost.liquid') }, + type: 'direct', + kind: 'render', + }, + ], + } as ReturnType); + + // Dangling edge dropped (target absent) → no half-wired reverse index. + expect(dependentsOf(restored, uri('app/views/partials/ghost.liquid'))).toEqual([]); + expect(restored.modules[uri('app/views/pages/index.liquid')]?.dependencies).toEqual([]); + expect(restored.entryPoints).toEqual([]); + }); +}); diff --git a/packages/platformos-graph/src/graph/deserialize.ts b/packages/platformos-graph/src/graph/deserialize.ts new file mode 100644 index 00000000..891357bc --- /dev/null +++ b/packages/platformos-graph/src/graph/deserialize.ts @@ -0,0 +1,81 @@ +import { UriString } from '@platformos/platformos-check-common'; + +import { + AppGraph, + AppModule, + LiquidModuleKind, + ModuleType, + SerializableGraph, + SerializableNode, +} from '../types'; +import { assertNever } from '../utils'; +import { internModule } from './module'; + +/** + * Reconstruct an in-memory {@link AppGraph} from its serialized form (the inverse + * of {@link serializeAppGraph}) — the load half of graph persistence. + * + * Nodes are rebuilt and INTERNED into the graph's identity cache (via + * {@link internModule}) so a subsequent incremental update (`applyFileChange`) + * resolves to these exact module objects rather than minting duplicates on a + * cache miss. Each serialized edge is pushed onto BOTH its source's + * `dependencies` and its target's `references` as a single shared instance, + * mirroring `bind`, so the reverse index is complete. Entry points are restored + * from `entryPointUris` (which the serialized form does not carry — the caller + * persists them alongside the graph) so orphan/reachability semantics and the + * incremental GC rule match a from-scratch build. + * + * Round-trip identity: `serializeAppGraph(deserializeAppGraph(s, e))` deep-equals + * `s` (modulo ordering). Note the neutral leaf `table` fact is not part of the + * serialized form, so it is absent on a deserialized graph until an incremental + * update re-derives it — a documented persistence limitation (`table` is not + * consumed by the blast-radius query the cache serves). + */ +export function deserializeAppGraph( + serialized: SerializableGraph, + entryPointUris: UriString[] = [], +): AppGraph { + const graph: AppGraph = { rootUri: serialized.rootUri, entryPoints: [], modules: {} }; + + for (const node of serialized.nodes) { + graph.modules[node.uri] = internModule(graph, nodeToModule(node)); + } + + for (const edge of serialized.edges) { + const source = graph.modules[edge.source.uri]; + const target = graph.modules[edge.target.uri]; + // A dangling edge (source/target absent from the node set) would be a + // malformed serialization; skip it rather than throw so a partial/corrupt + // cache degrades to a best-effort load (the fingerprint still gates freshness). + if (!source || !target) continue; + source.dependencies.push(edge); + target.references.push(edge); + } + + graph.entryPoints = entryPointUris + .map((uri) => graph.modules[uri]) + .filter((module): module is AppModule => module !== undefined); + + return graph; +} + +/** Build the concrete {@link AppModule} for a serialized node (empty edge lists — wired by the caller). */ +function nodeToModule(node: SerializableNode): AppModule { + // `exists` is only present on the node when it was set on the source module; + // preserve that presence/absence so the round-trip is exact. + const existsField = node.exists !== undefined ? { exists: node.exists } : {}; + const base = { uri: node.uri, dependencies: [], references: [], ...existsField }; + + switch (node.type) { + case ModuleType.Liquid: + return { ...base, type: ModuleType.Liquid, kind: node.kind as LiquidModuleKind }; + case ModuleType.Asset: + return { ...base, type: ModuleType.Asset, kind: 'unused' }; + case ModuleType.GraphQL: + return { ...base, type: ModuleType.GraphQL, kind: 'graphql' }; + case ModuleType.Schema: + return { ...base, type: ModuleType.Schema, kind: 'schema' }; + default: + return assertNever(node.type); + } +} diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index 7fa312b9..a1c0b7de 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -221,3 +221,15 @@ function module(appGraph: AppGraph, mod: T): T { } return cache.get(mod.uri)! as T; } + +/** + * Intern a fully-formed module into the graph's identity cache (dedup by URI), + * returning the canonical instance. Deserialization uses this to SEED the cache + * from a persisted graph, so that a subsequent incremental update + * (`applyFileChange`) resolves its targets to the SAME module objects the loaded + * graph holds — without it, the factories would mint fresh duplicates on a cache + * miss and edges would bind to the wrong nodes. See {@link ModuleCache}. + */ +export function internModule(appGraph: AppGraph, mod: T): T { + return module(appGraph, mod); +} diff --git a/packages/platformos-graph/src/index.ts b/packages/platformos-graph/src/index.ts index 71513920..76401589 100644 --- a/packages/platformos-graph/src/index.ts +++ b/packages/platformos-graph/src/index.ts @@ -16,5 +16,6 @@ export { } from './graph/query'; export type { NearestModulesOptions } from './graph/query'; export { serializeAppGraph } from './graph/serialize'; +export { deserializeAppGraph } from './graph/deserialize'; export { parseJs, toSourceCode } from './toSourceCode'; export * from './types'; diff --git a/packages/platformos-graph/src/utils/index.ts b/packages/platformos-graph/src/utils/index.ts index 5d1bbb06..f7b9bdcf 100644 --- a/packages/platformos-graph/src/utils/index.ts +++ b/packages/platformos-graph/src/utils/index.ts @@ -5,7 +5,7 @@ export function unique(array: T[]): T[] { return [...new Set(array)]; } -export function assertNever(module: never) { +export function assertNever(module: never): never { throw new Error(`Unknown module type ${module}`); } diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts new file mode 100644 index 00000000..3b1e4392 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import nodePath from 'node:path'; +import { URI } from 'vscode-uri'; + +import { + isLayout, + isPage, + isPartial, + path, + recursiveReadDirectory, +} from '@platformos/platformos-check-common'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; +import { buildAppGraph, serializeAppGraph, type AppGraph } from '@platformos/platformos-graph'; + +import { CACHE_FORMAT_VERSION, decodeCacheFile, encodeCacheFile } from './graph-cache-store'; + +/** + * The persistence format (TASK-9.15 Phase 2): encode round-trips a built graph + + * fingerprint, and decode is DEFENSIVE — a wrong-version, wrong-root, or corrupt + * document decodes to `null` so the caller falls back to a full build (a bad + * cache never yields a wrong answer, AC#5). + */ +describe('Unit: graph-cache-store (persisted graph encode/decode)', () => { + let root: string; + let rootUri: string; + let graph: AppGraph; + const fingerprint = new Map([ + ['file:///p/app/views/pages/index.liquid', '111:22'], + ['file:///p/app/views/partials/card.liquid', '333:44'], + ]); + + const abs = (rel: string) => nodePath.join(root, ...rel.split('/')); + const write = async (rel: string, body: string) => { + await mkdir(nodePath.dirname(abs(rel)), { recursive: true }); + await writeFile(abs(rel), body, 'utf8'); + }; + const canonicalNodes = (g: AppGraph) => + [...serializeAppGraph(g).nodes].sort((a, b) => a.uri.localeCompare(b.uri)); + + beforeEach(async () => { + root = await mkdtemp(nodePath.join(tmpdir(), 'pos-graph-store-')); + rootUri = path.normalize(URI.file(root).toString()); + await write('app/views/pages/index.liquid', "{% render 'card' %}"); + await write('app/views/partials/card.liquid', '
{{ title }}
'); + const entryPoints = await recursiveReadDirectory( + NodeFileSystem, + rootUri, + ([u]) => isLayout(u) || isPage(u) || isPartial(u), + ); + graph = await buildAppGraph(rootUri, { fs: NodeFileSystem }, entryPoints); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('round-trips a graph + fingerprint through encode → decode', () => { + const decoded = decodeCacheFile(encodeCacheFile(rootUri, graph, fingerprint), rootUri); + if (!decoded) throw new Error('expected a decoded cache'); + + expect(canonicalNodes(decoded.graph)).toEqual(canonicalNodes(graph)); + expect(decoded.graph.rootUri).toBe(rootUri); + expect(decoded.fingerprint).toEqual(fingerprint); + // Entry points are preserved so orphan/GC semantics match a from-scratch build. + expect(decoded.graph.entryPoints.map((m) => m.uri).sort()).toEqual( + graph.entryPoints.map((m) => m.uri).sort(), + ); + }); + + it('returns null for a wrong format version (never migrated)', () => { + const encoded = encodeCacheFile(rootUri, graph, fingerprint); + const bumped = JSON.stringify({ + ...JSON.parse(encoded), + version: CACHE_FORMAT_VERSION + 1, + }); + expect(decodeCacheFile(bumped, rootUri)).toBeNull(); + }); + + it('returns null when the persisted root does not match the expected root', () => { + const encoded = encodeCacheFile(rootUri, graph, fingerprint); + expect(decodeCacheFile(encoded, 'file:///a/different/project')).toBeNull(); + }); + + it('returns null for unparseable or structurally invalid content', () => { + expect(decodeCacheFile('this is not json', rootUri)).toBeNull(); + expect(decodeCacheFile('{}', rootUri)).toBeNull(); + expect( + decodeCacheFile(JSON.stringify({ version: CACHE_FORMAT_VERSION, rootUri }), rootUri), + ).toBeNull(); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts new file mode 100644 index 00000000..e3cdf162 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts @@ -0,0 +1,106 @@ +/** + * On-disk persistence format for the project graph cache (TASK-9.15 Phase 2). + * + * The graph is expensive to build from scratch (~a whole-project parse), so it is + * persisted after each build and RELOADED on server start — a warm cold-start. + * We persist the derived MODEL (the serialized graph + its per-file fingerprint + + * entry-point URIs), never the ASTs: the graph is the compact summary, and its + * O(1) query API is the retrieval. + * + * Correctness is still gated by the fingerprint AFTER load: a loaded graph is + * reconciled against the current disk (fingerprint diff → incremental apply), so + * a stale-but-valid cache converges to fresh, and a corrupt / wrong-version / + * wrong-root cache decodes to `null` and the caller falls back to a full rebuild. + * The format is versioned so an incompatible on-disk cache is never trusted. + */ +import type { UriString } from '@platformos/platformos-check-common'; +import { + deserializeAppGraph, + serializeAppGraph, + type AppGraph, + type SerializableGraph, +} from '@platformos/platformos-graph'; + +/** + * Bump on ANY incompatible change to what is persisted or how a loaded graph is + * interpreted (serialized-graph shape, fingerprint domain, entry-point meaning). + * A file with a different version is discarded on load — never migrated. + */ +export const CACHE_FORMAT_VERSION = 1; + +/** Per-file identity used to detect on-disk change: `mtimeMs:size`. */ +export type Fingerprint = Map; + +/** The persisted cache document. `fingerprint` is a Map serialized as entries. */ +interface CacheFile { + version: number; + rootUri: UriString; + entryPoints: UriString[]; + graph: SerializableGraph; + fingerprint: Array<[UriString, string]>; +} + +/** Encode a built graph + its fingerprint into the cache-file JSON string. */ +export function encodeCacheFile( + rootUri: UriString, + graph: AppGraph, + fingerprint: Fingerprint, +): string { + const file: CacheFile = { + version: CACHE_FORMAT_VERSION, + rootUri, + entryPoints: graph.entryPoints.map((module) => module.uri), + graph: serializeAppGraph(graph), + fingerprint: [...fingerprint], + }; + return JSON.stringify(file); +} + +/** + * Decode a cache-file string back into a graph + fingerprint, or `null` when it + * is unusable (unparseable, wrong version, wrong root, or structurally invalid). + * Never throws — an unusable cache degrades to a rebuild, never a wrong answer. + */ +export function decodeCacheFile( + text: string, + expectedRootUri: UriString, +): { graph: AppGraph; fingerprint: Fingerprint } | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + if (!isCacheFile(parsed)) return null; + if (parsed.version !== CACHE_FORMAT_VERSION) return null; + if (parsed.rootUri !== expectedRootUri) return null; + + try { + return { + graph: deserializeAppGraph(parsed.graph, parsed.entryPoints), + fingerprint: new Map(parsed.fingerprint), + }; + } catch { + // Structurally malformed beyond the shallow shape check → treat as corrupt. + return null; + } +} + +/** Shallow structural validation of a parsed cache document. */ +function isCacheFile(value: unknown): value is CacheFile { + if (typeof value !== 'object' || value === null) return false; + const file = value as Record; + const graph = file.graph as Record | undefined; + return ( + typeof file.version === 'number' && + typeof file.rootUri === 'string' && + Array.isArray(file.entryPoints) && + Array.isArray(file.fingerprint) && + typeof graph === 'object' && + graph !== null && + Array.isArray(graph.nodes) && + Array.isArray(graph.edges) && + typeof graph.rootUri === 'string' + ); +} diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts index aef6a0c7..86afc585 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts @@ -1,11 +1,16 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { path } from '@platformos/platformos-check-common'; import { NodeFileSystem } from '@platformos/platformos-check-node'; -import { type AppGraph, dependentsOf, type FileChangeKind } from '@platformos/platformos-graph'; +import { + buildAppGraph, + type AppGraph, + dependentsOf, + type FileChangeKind, +} from '@platformos/platformos-graph'; import { GraphCache } from './graph-cache'; @@ -260,3 +265,99 @@ describe('GraphCache: real project (integration — real buildAppGraph + fs + mt ]); }, 20000); }); + +describe('GraphCache: persistence (Phase 2 — warm cold-start from disk + reconcile)', () => { + let projectDir: string; + let cacheDir: string; + let cachePath: string; + let rootUri: string; + + const write = (rel: string, body: string) => { + const absPath = join(projectDir, rel); + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, body, 'utf8'); + }; + const uri = (rel: string) => path.normalize(path.URI.file(join(projectDir, rel))); + const dependentSources = (graph: AppGraph, rel: string): string[] => + dependentsOf(graph, uri(rel)) + .map((ref) => ref.source.uri) + .sort(); + // A real build, wrapped so a test can spy on whether the graph was rebuilt vs loaded. + const realBuild = (root: string, fs: typeof NodeFileSystem, entryPoints: string[]) => + buildAppGraph(root, { fs }, entryPoints); + const graphOf = (lookup: Awaited>): AppGraph => { + if (!('graph' in lookup) || !lookup.graph) throw new Error('expected a graph'); + return lookup.graph; + }; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-persist-proj-')); + // The cache file lives OUTSIDE the project so it is never walked as a source. + cacheDir = mkdtempSync(join(tmpdir(), 'mcp-sup-persist-cache-')); + cachePath = join(cacheDir, 'graph.json'); + write('app/views/partials/card.liquid', '
{{ title }}
'); + write('app/views/pages/index.liquid', "{% render 'card' %}"); + rootUri = path.normalize(path.URI.file(projectDir)); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(cacheDir, { recursive: true, force: true }); + }); + + it('persists after a cold build and warms a fresh instance from disk (no rebuild)', async () => { + const first = new GraphCache({ rootUri, fs: NodeFileSystem, cachePath }); + await first.lookup(); + await first.settle(); + expect(existsSync(cachePath)).toBe(true); // persisted after the build + + // A brand-new instance (as if the server restarted) must LOAD, not rebuild. + const buildSpy = vi.fn(realBuild); + const second = new GraphCache({ rootUri, fs: NodeFileSystem, cachePath, buildGraph: buildSpy }); + expect(await second.lookup()).toEqual({ graph: null, reason: 'recomputing' }); // hydrating + await second.settle(); + + const served = graphOf(await second.lookup()); + expect(dependentSources(served, 'app/views/partials/card.liquid')).toEqual([ + uri('app/views/pages/index.liquid'), + ]); + expect(buildSpy).not.toHaveBeenCalled(); // loaded from disk, never rebuilt + }); + + it('reconciles the on-disk delta after warming from cache (still never rebuilds)', async () => { + const first = new GraphCache({ rootUri, fs: NodeFileSystem, cachePath }); + await first.lookup(); + await first.settle(); + + // Source changed while "offline": a new page renders card. The warmed instance + // must reconcile this delta incrementally, not rebuild. + write('app/views/pages/about.liquid', "{% render 'card' %}"); + const buildSpy = vi.fn(realBuild); + const second = new GraphCache({ rootUri, fs: NodeFileSystem, cachePath, buildGraph: buildSpy }); + await second.lookup(); // cold → hydrate (load) in background + await second.settle(); + + const served = graphOf(await second.lookup()); // reconciles the `about` delta, serves fresh + expect(dependentSources(served, 'app/views/partials/card.liquid')).toEqual([ + uri('app/views/pages/about.liquid'), + uri('app/views/pages/index.liquid'), + ]); + expect(buildSpy).not.toHaveBeenCalled(); + }); + + it('falls back to a full build when the cache file is corrupt (never a wrong answer)', async () => { + mkdirSync(dirname(cachePath), { recursive: true }); + writeFileSync(cachePath, 'this is not valid json', 'utf8'); + + const buildSpy = vi.fn(realBuild); + const cache = new GraphCache({ rootUri, fs: NodeFileSystem, cachePath, buildGraph: buildSpy }); + await cache.lookup(); + await cache.settle(); + + const served = graphOf(await cache.lookup()); + expect(buildSpy).toHaveBeenCalledTimes(1); // corrupt cache → rebuilt + expect(dependentSources(served, 'app/views/partials/card.liquid')).toEqual([ + uri('app/views/pages/index.liquid'), + ]); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts index e1724608..f5d7b534 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts @@ -17,10 +17,17 @@ * never served. Reconciliations are serialized so concurrent lookups can never * interleave mutations of the shared graph. * - * The COLD build (no prior graph) is still fired in the background and NEVER - * awaited on the request path — blast-radius is a secondary signal and must not - * add latency to, or ever sink, the primary lint gate (mirrors the - * `runValidateCode` degrade contract). (Persisted cold-start load is Phase 2.) + * COLD start (no in-memory graph) is warmed from a PERSISTED graph when one is + * available (TASK-9.15 Phase 2): the cache loads the serialized graph + its + * fingerprint and reconciles the on-disk delta incrementally, instead of a full + * ~22s build. The graph is persisted (off the request path, coalesced) after each + * build and reconcile, so a restart resumes near-instantly. A missing / corrupt / + * wrong-version / wrong-root cache simply falls back to a full build — the + * fingerprint still gates correctness after load, so a stale cache converges to + * fresh and a bad one never yields a wrong answer. Either way the cold work runs + * in the background and is NEVER awaited on the request path — blast-radius is a + * secondary signal and must not add latency to, or ever sink, the primary lint + * gate (mirrors the `runValidateCode` degrade contract). * * Fingerprint domain = the liquid files that are edge SOURCES (page/layout/ * partial+lib). Only their add/remove/modify can change any file's dependents; @@ -28,7 +35,10 @@ * `buildAppGraph` as entry points, so dependents are COMPLETE (every caller is * traversed) — see the query.ts note on entry-point scope. */ -import { stat } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { isLayout, @@ -47,8 +57,7 @@ import { type FileChangeKind, } from '@platformos/platformos-graph'; -/** Per-file identity used to detect on-disk change: `mtimeMs:size`. */ -type Fingerprint = Map; +import { decodeCacheFile, encodeCacheFile, type Fingerprint } from './graph-cache-store'; /** The result of asking the cache for a usable graph. */ export type GraphLookup = @@ -75,6 +84,44 @@ export interface GraphCacheOptions { kind: FileChangeKind, fs: AbstractFileSystem, ) => Promise; + /** + * Absolute path of the on-disk cache file. When set, the cache is warmed from + * it on cold start and persisted to it after builds/reconciles. Omit to disable + * persistence (pure in-memory). See {@link defaultGraphCachePath}. + */ + cachePath?: string; + /** Seam for tests: read the cache file (`null` when absent/unreadable). Defaults to reading `cachePath`. */ + readCacheFile?: () => Promise; + /** Seam for tests: write the cache file (atomically). Defaults to an atomic write to `cachePath`. */ + writeCacheFile?: (contents: string) => Promise; +} + +/** + * The default per-project cache-file path: a stable, project-root-derived name in + * the OS temp dir. Temp is fine — the cache is a rebuildable derivative, and a + * missing file just triggers a full build. One file per root (hashed) so distinct + * projects never collide. + */ +export function defaultGraphCachePath(rootUri: UriString): string { + const hash = createHash('sha256').update(rootUri).digest('hex').slice(0, 16); + return join(tmpdir(), 'platformos-mcp-supervisor', `graph-${hash}.json`); +} + +/** Read a file as UTF-8, or `null` if it does not exist / cannot be read. */ +async function readFileOrNull(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch { + return null; + } +} + +/** Write a file atomically (temp + rename) so a reader never observes a partial write. */ +async function writeFileAtomic(filePath: string, contents: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const tmp = `${filePath}.${process.pid}.tmp`; + await writeFile(tmp, contents, 'utf8'); + await rename(tmp, filePath); } /** A liquid file that can be an edge SOURCE — the build's entry points + fingerprint domain. */ @@ -153,20 +200,30 @@ export class GraphCache { kind: FileChangeKind, fs: AbstractFileSystem, ) => Promise; + /** Read the persisted cache file, or `null` if persistence is disabled/absent. */ + private readonly readCacheFile: (() => Promise) | null; + /** Write the persisted cache file, or `null` if persistence is disabled. */ + private readonly writeCacheFile: ((contents: string) => Promise) | null; /** The graph + the fingerprint of the disk it was built from / reconciled to. */ private built: { graph: AppGraph; fingerprint: Fingerprint } | null = null; - /** The in-flight background build, if any (dedup guard). */ + /** The in-flight background build/hydrate, if any (dedup guard). */ private inFlight: Promise | null = null; /** The fingerprint of the most recent build ATTEMPT (success or failure). */ private lastAttempt: Fingerprint | null = null; /** The error from the most recent failed build attempt, cleared on a new attempt. */ private lastError: Error | null = null; + /** Whether the one-shot persisted-cache load has been attempted (cold start only). */ + private loadAttempted = false; /** * Serializes incremental reconciliations so concurrent lookups never interleave * mutations of the shared graph. Each stale lookup chains after the previous. */ private reconcileChain: Promise = Promise.resolve(); + /** Serializes off-path cache writes; a pending write is coalesced (see {@link schedulePersist}). */ + private persistChain: Promise = Promise.resolve(); + /** Whether a persist is already queued (so a burst of changes collapses to one write). */ + private persistScheduled = false; constructor(options: GraphCacheOptions) { this.rootUri = options.rootUri; @@ -177,6 +234,13 @@ export class GraphCache { ((rootUri, fs, entryPoints) => buildAppGraph(rootUri, { fs }, entryPoints)); this.applyChange = options.applyChange ?? ((graph, uri, kind, fs) => applyFileChange(graph, uri, kind, { fs })); + + const cachePath = options.cachePath; + this.readCacheFile = + options.readCacheFile ?? (cachePath ? () => readFileOrNull(cachePath) : null); + this.writeCacheFile = + options.writeCacheFile ?? + (cachePath ? (contents) => writeFileAtomic(cachePath, contents) : null); } /** @@ -198,11 +262,10 @@ export class GraphCache { return this.reconcileAndServe(current); } - // Cold start: no prior graph to update incrementally → full build in the - // background (Phase 2 will load a persisted graph here instead). If the - // current source already failed to build and nothing is retrying it, it is - // genuinely unavailable; otherwise a build is in flight. - this.ensureBuild(current); + // Cold start: no in-memory graph → warm from the persisted cache or full-build + // in the background. If the current source already failed and nothing is + // retrying it, it is genuinely unavailable; otherwise work is in flight. + this.ensureGraph(current); const reason: 'recomputing' | 'unavailable' = this.lastError && !this.inFlight ? 'unavailable' : 'recomputing'; return { graph: null, reason }; @@ -239,6 +302,8 @@ export class GraphCache { await this.applyChange(built.graph, uri, kind, this.fs); } built.fingerprint = target; + // Advance the on-disk cache so a restart resumes from here (small delta). + this.schedulePersist(); } /** Incremental apply failed → discard the graph and full-rebuild from scratch. */ @@ -246,16 +311,17 @@ export class GraphCache { this.built = null; this.lastError = null; this.lastAttempt = null; - this.ensureBuild(target); + this.ensureGraph(target); return { graph: null, reason: 'recomputing' }; } /** - * Start a background build for `fingerprint` unless one is already running or - * this exact source already failed (avoids a retry storm on an unbuildable - * project — a changed fingerprint retries). + * Ensure a graph is being produced in the background for `fingerprint` — warmed + * from the persisted cache on the first cold attempt, else full-built — unless + * one is already running or this exact source already failed (avoids a retry + * storm on an unbuildable project; a changed fingerprint retries). */ - private ensureBuild(fingerprint: Fingerprint): void { + private ensureGraph(fingerprint: Fingerprint): void { if (this.inFlight) return; if (this.lastError && this.lastAttempt && fingerprintsEqual(this.lastAttempt, fingerprint)) { return; @@ -264,11 +330,7 @@ export class GraphCache { this.lastAttempt = fingerprint; this.lastError = null; - const entryPoints = [...fingerprint.keys()]; - this.inFlight = this.buildGraph(this.rootUri, this.fs, entryPoints) - .then((graph) => { - this.built = { graph, fingerprint }; - }) + this.inFlight = this.hydrate(fingerprint) .catch((error: unknown) => { this.lastError = error instanceof Error ? error : new Error(String(error)); }) @@ -278,12 +340,69 @@ export class GraphCache { } /** - * Await the in-flight build and any queued incremental reconciliation. TEST/ - * warm-up hook only — the request path (`lookup`) never awaits a full build - * (it does await its own incremental reconcile, which is fast). + * Produce the graph: on the FIRST cold attempt, try the persisted cache (loaded + * graph + its fingerprint — `lookup` then reconciles the delta vs the current + * disk); otherwise, or if no usable cache exists, full-build over the current + * entry points and persist the result. A load failure/absence falls through to + * a build, so a bad cache never blocks startup. + */ + private async hydrate(fingerprint: Fingerprint): Promise { + if (!this.loadAttempted) { + this.loadAttempted = true; + const loaded = await this.tryLoad(); + if (loaded) { + this.built = loaded; + return; + } + } + + const graph = await this.buildGraph(this.rootUri, this.fs, [...fingerprint.keys()]); + this.built = { graph, fingerprint }; + this.schedulePersist(); + } + + /** Load + decode the persisted cache, or `null` if disabled/absent/unusable. */ + private async tryLoad(): Promise<{ graph: AppGraph; fingerprint: Fingerprint } | null> { + if (!this.readCacheFile) return null; + const text = await this.readCacheFile(); + return text !== null ? decodeCacheFile(text, this.rootUri) : null; + } + + /** + * Persist the current graph off the request path. Bursts are coalesced: while a + * write is pending, further calls are dropped, and the queued write serializes + * whatever the latest built state is at write time (never a partial/older one). + */ + private schedulePersist(): void { + if (!this.writeCacheFile || this.persistScheduled) return; + this.persistScheduled = true; + this.persistChain = this.persistChain.then(() => { + this.persistScheduled = false; + return this.writeCurrent(); + }); + } + + /** Serialize + write the current built graph; best-effort (a write failure never breaks the cache). */ + private async writeCurrent(): Promise { + const built = this.built; + if (!built || !this.writeCacheFile) return; + try { + await this.writeCacheFile(encodeCacheFile(this.rootUri, built.graph, built.fingerprint)); + } catch { + // Persistence is best-effort: the next successful build/reconcile + // re-persists, and correctness is gated by the fingerprint regardless. + } + } + + /** + * Await the in-flight build/hydrate, any queued incremental reconciliation, and + * any pending cache write. TEST/warm-up hook only — the request path (`lookup`) + * never awaits a full build (it does await its own incremental reconcile, which + * is fast). */ async settle(): Promise { await this.inFlight; await this.reconcileChain; + await this.persistChain; } } diff --git a/packages/platformos-mcp-supervisor/src/transport/server.ts b/packages/platformos-mcp-supervisor/src/transport/server.ts index 4c7f0972..13795894 100644 --- a/packages/platformos-mcp-supervisor/src/transport/server.ts +++ b/packages/platformos-mcp-supervisor/src/transport/server.ts @@ -12,7 +12,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { path } from '@platformos/platformos-check-common'; import { AppCache } from '@platformos/platformos-check-node'; -import { GraphCache } from '../graph-cache/graph-cache'; +import { defaultGraphCachePath, GraphCache } from '../graph-cache/graph-cache'; import { createLogger, type Logger } from '../logger'; import { registerValidateCode, type SupervisorContext } from './validate-code'; @@ -38,9 +38,12 @@ const DEFAULT_VERSION = '0.0.1'; export async function startServer(opts: ServerOptions): Promise { const log = opts.log ?? createLogger(SERVER_NAME); // One never-stale project-graph cache per server (keyed by this project root), - // built lazily in the background on first blast-radius request. + // warmed from a persisted graph on the first blast-radius request (else built + // lazily in the background), then kept fresh incrementally. + const rootUri = path.normalize(path.URI.file(opts.projectDir)); const graphCache = new GraphCache({ - rootUri: path.normalize(path.URI.file(opts.projectDir)), + rootUri, + cachePath: defaultGraphCachePath(rootUri), }); // One never-stale parsed-project cache per server, so repeated lint calls reuse // the parsed project instead of re-parsing it (the dominant per-call cost). From c32a35a3d11235618b2337cf88b17b85bad8ae1e Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 12:31:21 +0200 Subject: [PATCH 14/20] feat(graph-cache): implement scoped source walk for platformOS roots only --- SUPERVISOR-GRAPH-INTEGRATION.md | 68 ++++++++++----- .../src/graph-cache/graph-cache.spec.ts | 86 +++++++++++++++++++ .../src/graph-cache/graph-cache.ts | 36 +++++++- 3 files changed, 166 insertions(+), 24 deletions(-) diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md index 123192af..b2c3be9e 100644 --- a/SUPERVISOR-GRAPH-INTEGRATION.md +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -437,24 +437,24 @@ For ## 7. Task ledger (TASK-9 epic) -| Task | Status | -| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| 9.1 dependency edges (render/include/function/background/graphql/asset) | ✅ Done | -| 9.2 project-structure query API (dependents/orphan/reachability/missing/nearest) | ✅ Done | -| 9.3 per-file self-structural (9 facts) | ✅ Done | -| 9.4 layout edges + `DocumentsLocator 'layout'` | ✅ Done | -| 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | -| 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | -| 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | -| 9.8 code-review remediation (§8) | ✅ Done (F3 deferred) | -| 9.9 asset-resolution fix + real-project validation | ✅ Done | -| **9.10 cached graph → blast-radius in `validate_code`** (§9) | ✅ Done | -| 9.11 `project_map` (discovery + resource overlay) | ⬜ Scoped | -| 9.12 `validate_project` (health sweep + repair order) | ⬜ Scoped | -| 9.13 opt-in `AppCache` (parsed-project reuse for lint) | ✅ Done | -| **9.14 `applyFileChange` incremental graph API** (§10.1) | ✅ Done | -| **9.15 warm/incremental/persisted GraphCache** (§10.2) | ◐ Phases 1–2 done; Phase 3 (fs.watch + scoped walk) pending | -| 8.4 surface `structural` in `validate_code` | ↩︎ Superseded by 9.10 (removed) | +| Task | Status | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| 9.1 dependency edges (render/include/function/background/graphql/asset) | ✅ Done | +| 9.2 project-structure query API (dependents/orphan/reachability/missing/nearest) | ✅ Done | +| 9.3 per-file self-structural (9 facts) | ✅ Done | +| 9.4 layout edges + `DocumentsLocator 'layout'` | ✅ Done | +| 9.5 wire graph `dependencies` into `validate_code` | ✅ Done | +| 9.6 platform facts (graphql `table`, schema nodes) | ✅ Done | +| 9.7 resource/CRUD convention overlay | ⬜ Deferred (ADR 004) | +| 9.8 code-review remediation (§8) | ✅ Done (F3 deferred) | +| 9.9 asset-resolution fix + real-project validation | ✅ Done | +| **9.10 cached graph → blast-radius in `validate_code`** (§9) | ✅ Done | +| 9.11 `project_map` (discovery + resource overlay) | ⬜ Scoped | +| 9.12 `validate_project` (health sweep + repair order) | ⬜ Scoped | +| 9.13 opt-in `AppCache` (parsed-project reuse for lint) | ✅ Done | +| **9.14 `applyFileChange` incremental graph API** (§10.1) | ✅ Done | +| **9.15 warm/incremental/persisted GraphCache** (§10.2) | ◐ Phases 1–2 + 3A (scoped walk) done; 3B (fs.watch) deferred | +| 8.4 surface `structural` in `validate_code` | ↩︎ Superseded by 9.10 (removed) | **The three-tool graph strategy** (why the graph lives mostly _outside_ `validate_code`): lint owns the per-file forward-looking checks, so the graph's @@ -796,16 +796,38 @@ derivative; wired by the server, disableable by omitting `cachePath`). | Cold start | ~22 s full build | **~37 ms** persisted load (+ delta reconcile) — vs a **70 s** cold build here → ~1900× | | Serialize + persist | — | ~48 ms (1.4 MiB) | +**Phase 3A — scoped source walk.** The fingerprint enumeration (and, via the +fingerprint keys, the build's entry points) previously walked the **whole** +project tree, descending non-platformOS siblings (a bundled `react-app/`, etc.). +It now walks only the platformOS source roots — `app/`, `marketplace_builder/`, +`modules/` (`SOURCE_ROOTS`) — which, per the file-type classifier, are the only +places a Page/Layout/Partial can live (including nested `app/modules//…`, +reached via `app/`). The enumerated set is therefore **provably identical**, just +cheaper to gather. + +Measured on `marketplace-dcra` (1,921 edge sources): the walk is **4.4×** faster +(769 → 175 ms) and the full warm fingerprint (walk + `stat`) **1.7×** (541 → 320 +ms; the per-file `stat` is the irreducible remainder). The enumerated set is +**byte-identical** to the whole-tree walk (1,921 = 1,921, zero diff either way), +so never-stale is preserved. Sharing one scan primitive with lint (TASK-9.13) was +evaluated and declined: lint globs a different mechanism (`glob` in check-node vs +the browser-safe `AbstractFileSystem` walk here) over a different domain (all four +source types vs edge-source liquid only); the scoped walk already captures the +win without coupling the two. + **Tests.** `incremental.spec.ts` (equivalence guardrail), `deserialize.spec.ts` (round-trip + a restored graph reconciles exactly like a full build), `graph-cache-store.spec.ts` (encode/decode + version/root/corruption invalidation), `graph-cache.spec.ts` (incremental serve, diff kinds, apply- failure fallback, concurrent-reconcile coalescing, warm cold-start from disk, -delta reconcile after warm, corrupt→rebuild). Supervisor + graph suites, +delta reconcile after warm, corrupt→rebuild, **scoped enumeration across all +three roots + non-platformOS subtree pruned**). Supervisor + graph suites, type-check, and format green. -**Phase 3 (pending).** `fs.watch` background freshness (with the fingerprint +**Phase 3B (pending).** `fs.watch` background freshness (with the fingerprint reconciliation kept as the safety net — never trust the watcher alone) so the -request path does no per-call scan on the steady state; and scoping the file -walk to platformOS dirs (cuts the residual ~400 ms warm fingerprint cost — -resolves the first §9 follow-up). +request path does no per-call scan on the steady state. Deferred deliberately: on +the actual `validate_code` path the fingerprint scan runs concurrently with lint's +multi-second parse, so it adds ≈0 wall-clock today — the watcher optimizes a cost +that is not on the critical path, at the price of real platform-specific watcher +complexity. diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts index 86afc585..12163854 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts @@ -361,3 +361,89 @@ describe('GraphCache: persistence (Phase 2 — warm cold-start from disk + recon ]); }); }); + +describe('GraphCache: scoped source walk (Phase 3A — platformOS roots only)', () => { + let projectDir: string; + let rootUri: string; + + const write = (rel: string, body: string) => { + const absPath = join(projectDir, rel); + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, body, 'utf8'); + }; + const uri = (rel: string) => path.normalize(path.URI.file(join(projectDir, rel))); + const graphOf = (lookup: Awaited>): AppGraph => { + if (!('graph' in lookup) || !lookup.graph) throw new Error('expected a graph'); + return lookup.graph; + }; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'mcp-sup-scope-')); + rootUri = path.normalize(path.URI.file(projectDir)); + }); + + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + + it('enumerates edge sources across app/, marketplace_builder/, and modules/ roots', async () => { + // One edge source under each of the three canonical platformOS source roots, + // plus a nested app/modules/ partial (reached via the app/ root). + write('app/views/partials/card.liquid', '
{{ title }}
'); + write('app/views/pages/index.liquid', "{% render 'card' %}"); + write('marketplace_builder/views/pages/legacy.liquid', '

legacy

'); + write('modules/shop/public/views/partials/widget.liquid', ''); + write('app/modules/blog/private/views/pages/post.liquid', '
'); + + // The scoped enumeration feeds `buildAppGraph` its entry points; capture them + // to assert the exact edge-source set the walk gathered (the Phase-3A contract). + let entryPoints: string[] = []; + const cache = new GraphCache({ + rootUri, + fs: NodeFileSystem, + buildGraph: async (root, fs, eps) => { + entryPoints = eps; + return buildAppGraph(root, { fs }, eps); + }, + }); + await cache.lookup(); + await cache.settle(); + + expect([...entryPoints].sort()).toEqual( + [ + uri('app/views/partials/card.liquid'), + uri('app/views/pages/index.liquid'), + uri('marketplace_builder/views/pages/legacy.liquid'), + uri('modules/shop/public/views/partials/widget.liquid'), + uri('app/modules/blog/private/views/pages/post.liquid'), + ].sort(), + ); + }, 20000); + + it('never walks non-platformOS subtrees (a bundled react-app/ is skipped)', async () => { + write('app/views/pages/index.liquid', "{% render 'card' %}"); + write('app/views/partials/card.liquid', '
'); + // A large non-platformOS sibling that must NOT be descended into. + write('react-app/src/components/Widget.liquid', 'noise that is never a source'); + + const readDirs: string[] = []; + const spyFs: typeof NodeFileSystem = { + ...NodeFileSystem, + readDirectory: async (dir: string) => { + readDirs.push(dir); + return NodeFileSystem.readDirectory(dir); + }, + }; + + const cache = new GraphCache({ rootUri, fs: spyFs }); + await cache.lookup(); + await cache.settle(); + const graph = graphOf(await cache.lookup()); + + // The scoped walk descended the real source root… + expect(readDirs.some((dir) => dir.endsWith('/app/views/partials'))).toBe(true); + // …but never the non-platformOS subtree, and never the project root itself. + expect(readDirs.some((dir) => dir.includes('/react-app'))).toBe(false); + expect(readDirs).not.toContain(rootUri); + // The stray .liquid under react-app/ is not a graph node (never enumerated). + expect(graph.modules[uri('react-app/src/components/Widget.liquid')]).toBeUndefined(); + }, 20000); +}); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts index f5d7b534..9af8a558 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts @@ -34,6 +34,11 @@ * `.graphql`/`.yml`/asset files are leaves. This is also exactly the set fed to * `buildAppGraph` as entry points, so dependents are COMPLETE (every caller is * traversed) — see the query.ts note on entry-point scope. + * + * The enumeration is SCOPED to the platformOS source roots ({@link SOURCE_ROOTS}) + * rather than the whole project tree, so a bundled `react-app/` or other + * non-platformOS sibling is never walked — the edge-source set is identical, just + * cheaper to gather (TASK-9.15 Phase 3, part A). */ import { createHash } from 'node:crypto'; import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; @@ -129,12 +134,41 @@ function isEdgeSource(uri: UriString): boolean { return isLayout(uri) || isPage(uri) || isPartial(uri); } +/** + * The top-level platformOS source roots that can contain an edge-source liquid + * file. Per the file-type classifier (`getFileType`), every Page/Layout/Partial + * lives under the modern `app/` root (which also holds `app/modules//…`), the + * legacy `marketplace_builder/` alias, or a top-level `modules//…`. Walking + * only these — instead of the whole project tree — skips large non-platformOS + * siblings (e.g. a bundled `react-app/`) with NO loss of real sources: for a real + * project the enumerated edge-source set is identical, only cheaper to gather. + */ +const SOURCE_ROOTS = ['app', 'marketplace_builder', 'modules'] as const; + +/** + * Enumerate every edge-source liquid file under the platformOS {@link SOURCE_ROOTS}, + * scoping the walk to those subtrees rather than the whole project tree. A root + * absent on disk contributes nothing (`recursiveReadDirectory` returns `[]` on + * ENOENT); the roots are disjoint, so no URI is produced twice. + */ +async function enumerateEdgeSources( + fs: AbstractFileSystem, + rootUri: UriString, +): Promise { + const perRoot = await Promise.all( + SOURCE_ROOTS.map((dir) => + recursiveReadDirectory(fs, path.join(rootUri, dir), ([uri]) => isEdgeSource(uri)), + ), + ); + return perRoot.flat(); +} + /** Real disk fingerprint: every edge-source liquid file → `mtimeMs:size`. */ async function computeFingerprintFromDisk( rootUri: UriString, fs: AbstractFileSystem, ): Promise { - const uris = await recursiveReadDirectory(fs, rootUri, ([uri]) => isEdgeSource(uri)); + const uris = await enumerateEdgeSources(fs, rootUri); const fingerprint: Fingerprint = new Map(); await Promise.all( uris.map(async (uri) => { From 75647ca804a0a7fecd8a55b05e8f705789221e05 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Thu, 2 Jul 2026 15:28:42 +0200 Subject: [PATCH 15/20] feat(impact): enhance runImpact to handle non-trackable files and improve signature risk reporting --- .../src/app-cache.spec.ts | 8 +- .../src/graph-cache/graph-cache-store.spec.ts | 21 +++ .../src/graph-cache/graph-cache-store.ts | 21 +++ .../src/graph-cache/graph-cache.spec.ts | 77 +++++++- .../src/graph-cache/graph-cache.ts | 119 ++++++++----- .../src/impact/impact.spec.ts | 166 +++++++++++++++--- .../src/impact/impact.ts | 57 +++++- .../src/result/types.ts | 17 +- 8 files changed, 398 insertions(+), 88 deletions(-) diff --git a/packages/platformos-check-node/src/app-cache.spec.ts b/packages/platformos-check-node/src/app-cache.spec.ts index b8e5e01f..5fe2ba0e 100644 --- a/packages/platformos-check-node/src/app-cache.spec.ts +++ b/packages/platformos-check-node/src/app-cache.spec.ts @@ -72,7 +72,7 @@ describe('Unit: AppCache (parsed-project reuse for getApp/lintBuffer)', () => { const homeUri = workspace.uri('app/views/pages/home.liquid'); expect(after.get(aUri)).not.toBe(first.get(aUri)); // changed ⇒ re-parsed (new instance) - expect(after.get(aUri)!.source).toContain('edited longer'); // reflects new content + expect(after.get(aUri)!.source).toEqual('
a — edited longer
'); // reflects new content expect(after.get(bUri)).toBe(first.get(bUri)); // unchanged ⇒ reused expect(after.get(homeUri)).toBe(first.get(homeUri)); // unchanged ⇒ reused }); @@ -128,13 +128,13 @@ describe('Unit: AppCache (parsed-project reuse for getApp/lintBuffer)', () => { const lint = () => lintBuffer({ root, filePath: pageFile, content: "{% render 'ghost' %}", configPath, cache }); - // Cold: 'ghost' does not exist → MissingPartial. + // Cold: 'ghost' does not exist → exactly one MissingPartial (only that check is enabled). const before = await lint(); - expect(before.some((o) => o.check === 'MissingPartial')).toBe(true); + expect(before.map((o) => o.check)).toEqual(['MissingPartial']); // Create the partial on disk; the SAME cache must reconcile it (not serve stale). await fs.writeFile(abs('app/views/partials/ghost.liquid'), '
ghost
', 'utf8'); const after = await lint(); - expect(after.some((o) => o.check === 'MissingPartial')).toBe(false); + expect(after.map((o) => o.check)).toEqual([]); }); }); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts index 3b1e4392..17efec74 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts @@ -90,4 +90,25 @@ describe('Unit: graph-cache-store (persisted graph encode/decode)', () => { decodeCacheFile(JSON.stringify({ version: CACHE_FORMAT_VERSION, rootUri }), rootUri), ).toBeNull(); }); + + it('returns null for a corrupt cache whose edge references a node not in the graph', () => { + // A truncated/corrupt cache that still parses: an edge points at a URI absent + // from `nodes`. Decoding it would silently drop the edge (under-counting + // dependents) and, since the disk fingerprint may still match, serve that wrong + // graph as fresh — so it must be rejected outright (→ rebuild). + const doc = JSON.parse(encodeCacheFile(rootUri, graph, fingerprint)); + doc.graph.edges.push({ + source: { uri: doc.graph.nodes[0].uri }, + target: { uri: `${rootUri}/app/views/partials/ghost.liquid` }, + type: 'direct', + kind: 'render', + }); + expect(decodeCacheFile(JSON.stringify(doc), rootUri)).toBeNull(); + }); + + it('returns null for a corrupt cache whose entry point is not a node in the graph', () => { + const doc = JSON.parse(encodeCacheFile(rootUri, graph, fingerprint)); + doc.entryPoints.push(`${rootUri}/app/views/pages/ghost.liquid`); + expect(decodeCacheFile(JSON.stringify(doc), rootUri)).toBeNull(); + }); }); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts index e3cdf162..5a05f58b 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts @@ -77,6 +77,13 @@ export function decodeCacheFile( if (parsed.rootUri !== expectedRootUri) return null; try { + // Reject a cache whose edges/entry points reference absent nodes: `deserialize` + // would SILENTLY drop the dangling parts, yielding a structurally-plausible but + // WRONG graph (under-counted dependents / lost entry points). Since the disk + // fingerprint may still match — so no reconcile would ever run — that wrong + // graph would be served as fresh. A valid serialized graph always satisfies + // integrity, so this only rejects genuine corruption (→ full rebuild). + if (!hasReferentialIntegrity(parsed)) return null; return { graph: deserializeAppGraph(parsed.graph, parsed.entryPoints), fingerprint: new Map(parsed.fingerprint), @@ -87,6 +94,20 @@ export function decodeCacheFile( } } +/** + * Whether every edge endpoint (source + target) and every entry point references a + * node actually present in the serialized graph. `serializeAppGraph` always emits + * a graph that satisfies this (every edge binds two materialized modules; every + * entry point is a module), so a failure here means the on-disk cache is corrupt. + */ +function hasReferentialIntegrity(file: CacheFile): boolean { + const nodeUris = new Set(file.graph.nodes.map((node) => node.uri)); + for (const edge of file.graph.edges) { + if (!nodeUris.has(edge.source.uri) || !nodeUris.has(edge.target.uri)) return false; + } + return file.entryPoints.every((uri) => nodeUris.has(uri)); +} + /** Shallow structural validation of a parsed cache document. */ function isCacheFile(value: unknown): value is CacheFile { if (typeof value !== 'object' || value === null) return false; diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts index 12163854..3498c969 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts @@ -13,6 +13,7 @@ import { } from '@platformos/platformos-graph'; import { GraphCache } from './graph-cache'; +import { decodeCacheFile } from './graph-cache-store'; /** * A fake graph tagged with a build id so a test can assert WHICH build's graph @@ -152,6 +153,67 @@ describe('GraphCache: never-stale, background-built, deduplicated', () => { expect(applyCount).toBe(1); }); + it('reconciles to the freshly re-read disk state, never a stale scan the lookup first observed', async () => { + // The reconcile re-reads the fingerprint INSIDE the serialized section, so it + // converges to the ACTUAL current disk — not the value a racing lookup saw + // earlier (which, applied out of order, would be a backward diff / stale graph). + const applied: Array<[string, FileChangeKind]> = []; + // computeFingerprint returns, in call order: cold build (a:1), the lookup's + // off-lock scan (a:2), then the reconcile's fresh in-lock scan (a:2 + b:1). + const queue = [fp({ a: '1' }), fp({ a: '2' }), fp({ a: '2', b: '1' })]; + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => queue.shift() ?? fp({ a: '2', b: '1' }), + buildGraph: async () => fakeGraph(1), + applyChange: async (_graph, uri, kind) => { + applied.push([uri, kind]); + }, + }); + + await cache.lookup(); // cold build at {a:1} + await cache.settle(); + await cache.lookup(); // observes {a:2} off-lock, but reconciles to the fresh {a:2,b:1} + + // The diff applied is {a:1} → {a:2,b:1} (the RE-READ state), not {a:1} → {a:2}. + expect(applied).toEqual([ + ['a', 'modified'], + ['b', 'added'], + ]); + }); + + it('coalesces persistence: a burst during an in-flight write flushes as ONE follow-up of the latest state', async () => { + let current = fp({ a: '1' }); + let releaseFirstWrite!: () => void; + const firstWriteGate = new Promise((resolve) => (releaseFirstWrite = resolve)); + const persisted: string[] = []; + const cache = new GraphCache({ + rootUri, + computeFingerprint: async () => current, + buildGraph: async () => fakeGraph(1), + applyChange: async () => {}, // fingerprint tracks the state; graph object is opaque here + readCacheFile: async () => null, + writeCacheFile: async (contents) => { + persisted.push(contents); + if (persisted.length === 1) await firstWriteGate; // hold the cold-build write open + }, + }); + + await cache.lookup(); // cold build → schedules write #1 (blocks on the gate) + // While write #1 is in flight, two reconciles land — both only mark dirty. + current = fp({ a: '2' }); + await cache.lookup(); + current = fp({ a: '2', b: '1' }); + await cache.lookup(); + releaseFirstWrite(); + await cache.settle(); + + // Exactly two writes — the gated cold-build write (a:1), then ONE coalesced + // follow-up for the whole burst persisting the LATEST state (a:2,b:1) — not one + // write per reconcile. + const persistedFingerprints = persisted.map((c) => decodeCacheFile(c, rootUri)?.fingerprint); + expect(persistedFingerprints).toEqual([fp({ a: '1' }), fp({ a: '2', b: '1' })]); + }); + it('reports unavailable when the build fails, without a retry storm for the same source', async () => { const buildGraph = vi.fn(async () => { throw new Error('boom'); @@ -438,11 +500,16 @@ describe('GraphCache: scoped source walk (Phase 3A — platformOS roots only)', await cache.settle(); const graph = graphOf(await cache.lookup()); - // The scoped walk descended the real source root… - expect(readDirs.some((dir) => dir.endsWith('/app/views/partials'))).toBe(true); - // …but never the non-platformOS subtree, and never the project root itself. - expect(readDirs.some((dir) => dir.includes('/react-app'))).toBe(false); - expect(readDirs).not.toContain(rootUri); + // Deduplicate: each lookup re-scans, so a root can be read more than once — + // membership, not count, is the contract. + const walked = new Set(readDirs); + // The scoped walk descended the real source root (`app/` — the exact URI the + // enumeration passes to readDirectory)… + const appRoot = path.join(rootUri, 'app'); + expect([...walked].filter((dir) => dir === appRoot)).toEqual([appRoot]); + // …but NEVER the non-platformOS subtree, and never the project root itself. + expect([...walked].filter((dir) => dir.includes('/react-app'))).toEqual([]); + expect([...walked].filter((dir) => dir === rootUri)).toEqual([]); // The stray .liquid under react-app/ is not a graph node (never enumerated). expect(graph.modules[uri('react-app/src/components/Widget.liquid')]).toBeUndefined(); }, 20000); diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts index 9af8a558..ac1a596d 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts @@ -41,7 +41,7 @@ * cheaper to gather (TASK-9.15 Phase 3, part A). */ import { createHash } from 'node:crypto'; -import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -53,7 +53,7 @@ import { recursiveReadDirectory, type UriString, } from '@platformos/platformos-check-common'; -import { NodeFileSystem } from '@platformos/platformos-check-node'; +import { fileFingerprint, NodeFileSystem } from '@platformos/platformos-check-node'; import type { AbstractFileSystem } from '@platformos/platformos-common'; import { applyFileChange, @@ -163,7 +163,14 @@ async function enumerateEdgeSources( return perRoot.flat(); } -/** Real disk fingerprint: every edge-source liquid file → `mtimeMs:size`. */ +/** + * Real disk fingerprint: every edge-source liquid file → its per-file identity. + * Reuses check-node's exported {@link fileFingerprint} — the SAME `mtimeMs:size` + * definition its `AppCache` uses — so the two never-stale caches (lint's parsed + * project + this graph) can never disagree on what "changed" means. A file that + * vanished between the walk and the stat yields `undefined` and is omitted; the + * next scan reconciles. + */ async function computeFingerprintFromDisk( rootUri: UriString, fs: AbstractFileSystem, @@ -172,12 +179,8 @@ async function computeFingerprintFromDisk( const fingerprint: Fingerprint = new Map(); await Promise.all( uris.map(async (uri) => { - try { - const info = await stat(path.fsPath(uri)); - fingerprint.set(uri, `${info.mtimeMs}:${info.size}`); - } catch { - // Vanished between the walk and the stat — omit it; the next scan reconciles. - } + const identity = await fileFingerprint(path.fsPath(uri)); + if (identity !== undefined) fingerprint.set(uri, identity); }), ); return fingerprint; @@ -254,10 +257,18 @@ export class GraphCache { * mutations of the shared graph. Each stale lookup chains after the previous. */ private reconcileChain: Promise = Promise.resolve(); - /** Serializes off-path cache writes; a pending write is coalesced (see {@link schedulePersist}). */ - private persistChain: Promise = Promise.resolve(); - /** Whether a persist is already queued (so a burst of changes collapses to one write). */ - private persistScheduled = false; + /** + * Number of reconciliations queued-or-running. The synchronous fast path serves + * `built.graph` ONLY when this is 0, so a lookup can never read the graph while + * `applyDiff` is mutating it in place across `await`s (a half-applied graph must + * never be served — the never-stale mandate). When >0, a lookup routes through + * the chain and serves the fully-reconciled graph. + */ + private reconciling = 0; + /** Set when the built graph changed and still needs persisting; the running/next drain flushes it. */ + private persistDirty = false; + /** The in-flight persist drain, if any — so a burst (and changes landing mid-write) coalesce. */ + private persistDrain: Promise | null = null; constructor(options: GraphCacheOptions) { this.rootUri = options.rootUri; @@ -279,9 +290,10 @@ export class GraphCache { /** * Return a FRESH graph for the current on-disk state. When the source is - * unchanged since the last build/reconcile, serve the built graph directly. - * When it moved and a graph exists, reconcile incrementally (apply only the - * changed files) and serve the updated graph — no rebuild, no `computing` gap. + * unchanged since the last build/reconcile AND no reconcile is in flight, serve + * the built graph directly (the synchronous fast path). When it moved (or a + * reconcile is running) and a graph exists, reconcile incrementally through the + * serialized chain and serve the updated graph — no rebuild, no `computing` gap. * Only a cold start (no prior graph) returns without a graph, triggering a * background build. The fingerprint scan is cheap (a stat-scan); the request * path never awaits a full build. @@ -290,7 +302,11 @@ export class GraphCache { const current = await this.computeFingerprint(this.rootUri, this.fs); if (this.built) { - if (fingerprintsEqual(this.built.fingerprint, current)) { + // Fast path: serve directly only when nothing is reconciling — otherwise + // `built.graph` may be mid-mutation (applyDiff mutates in place across + // `await`s). `this.reconciling === 0` + the synchronous return (no `await` + // before it) guarantees the served graph is complete. + if (this.reconciling === 0 && fingerprintsEqual(this.built.fingerprint, current)) { return { graph: this.built.graph }; } return this.reconcileAndServe(current); @@ -306,32 +322,42 @@ export class GraphCache { } /** - * Bring the built graph up to `target` by applying only the changed files, then - * serve it fresh. Reconciliations are serialized (chained) so concurrent - * lookups cannot interleave mutations of the shared graph; if incremental apply + * Reconcile the built graph to the current disk state through the serialized + * chain, then serve it fresh. Reconciliations are chained so concurrent lookups + * never interleave mutations of the shared graph, and `reconciling` keeps the + * fast path off `built.graph` while any apply is pending. If incremental apply * fails, fall back to a full rebuild rather than serve a half-applied graph. + * + * `fallbackFingerprint` (the caller's observed scan) is used only to seed a + * rebuild if apply throws; the reconcile itself re-reads disk (see + * {@link applyDiff}), so it always converges to the ACTUAL current state + * regardless of chain-arrival order. */ - private reconcileAndServe(target: Fingerprint): Promise { - const run = this.reconcileChain.then(() => this.applyDiff(target)); + private reconcileAndServe(fallbackFingerprint: Fingerprint): Promise { + this.reconciling++; + const run = this.reconcileChain.then(() => this.applyDiff()); // Keep the chain alive whatever this run's outcome (a rejection here is // recovered by fallbackToRebuild below; the chain must not stay rejected). - this.reconcileChain = run.catch(() => undefined); + this.reconcileChain = run.catch(() => undefined).finally(() => this.reconciling--); return run.then( (): GraphLookup => this.built ? { graph: this.built.graph } : { graph: null, reason: 'recomputing' }, - (): GraphLookup => this.fallbackToRebuild(target), + (): GraphLookup => this.fallbackToRebuild(fallbackFingerprint), ); } /** - * Apply the fingerprint diff (previous → `target`) to the built graph via - * `applyChange`, then record `target` as the graph's fingerprint. Re-checks - * under the chain lock so a queued reconcile that another run already caught up - * to is a no-op; a rebuild that nulled the graph short-circuits. + * Reconcile the built graph to the CURRENT on-disk state and record it. Re-reads + * the fingerprint fresh (rather than trusting the caller's earlier scan), so a + * reconcile always moves the graph toward the actual current disk — never a + * backward diff when reconciles resolve out of order under concurrency. A no-op + * when already current, or when a rebuild nulled the graph. */ - private async applyDiff(target: Fingerprint): Promise { + private async applyDiff(): Promise { const built = this.built; - if (!built || fingerprintsEqual(built.fingerprint, target)) return; + if (!built) return; + const target = await this.computeFingerprint(this.rootUri, this.fs); + if (fingerprintsEqual(built.fingerprint, target)) return; for (const [uri, kind] of diffFingerprints(built.fingerprint, target)) { await this.applyChange(built.graph, uri, kind, this.fs); } @@ -341,11 +367,11 @@ export class GraphCache { } /** Incremental apply failed → discard the graph and full-rebuild from scratch. */ - private fallbackToRebuild(target: Fingerprint): GraphLookup { + private fallbackToRebuild(fingerprint: Fingerprint): GraphLookup { this.built = null; this.lastError = null; this.lastAttempt = null; - this.ensureGraph(target); + this.ensureGraph(fingerprint); return { graph: null, reason: 'recomputing' }; } @@ -403,19 +429,30 @@ export class GraphCache { } /** - * Persist the current graph off the request path. Bursts are coalesced: while a - * write is pending, further calls are dropped, and the queued write serializes - * whatever the latest built state is at write time (never a partial/older one). + * Persist the current graph off the request path. Coalesced: a single drain + * writes the LATEST built state, and any change that lands while a write is + * in-flight is flushed by exactly one follow-up write (never a partial/older + * one, never one write per change in a burst). */ private schedulePersist(): void { - if (!this.writeCacheFile || this.persistScheduled) return; - this.persistScheduled = true; - this.persistChain = this.persistChain.then(() => { - this.persistScheduled = false; - return this.writeCurrent(); + if (!this.writeCacheFile) return; + this.persistDirty = true; + // A drain is already running; it re-checks `persistDirty` after each write, so + // the change now flagged is picked up without enqueuing a redundant write. + if (this.persistDrain) return; + this.persistDrain = this.drainPersist().finally(() => { + this.persistDrain = null; }); } + /** Write the latest built state, looping once more if a change landed during the write. */ + private async drainPersist(): Promise { + while (this.persistDirty) { + this.persistDirty = false; + await this.writeCurrent(); + } + } + /** Serialize + write the current built graph; best-effort (a write failure never breaks the cache). */ private async writeCurrent(): Promise { const built = this.built; @@ -437,6 +474,6 @@ export class GraphCache { async settle(): Promise { await this.inFlight; await this.reconcileChain; - await this.persistChain; + await this.persistDrain; } } diff --git a/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts b/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts index 375fe29e..1a7244e2 100644 --- a/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts +++ b/packages/platformos-mcp-supervisor/src/impact/impact.spec.ts @@ -94,22 +94,26 @@ describe('runImpact: blast radius from the cached graph', () => { source: `app/views/pages/p${String(i).padStart(2, '0')}.liquid`, kind: 'render' as const, })); - const result = await run(card, stubCache({ graph: graphWithDependents(card, refs) })); - - expect(result.status).toEqual('computed'); - expect(result.dependents.total).toEqual(15); - expect(result.dependents.sample).toEqual([ - 'app/views/pages/p00.liquid', - 'app/views/pages/p01.liquid', - 'app/views/pages/p02.liquid', - 'app/views/pages/p03.liquid', - 'app/views/pages/p04.liquid', - 'app/views/pages/p05.liquid', - 'app/views/pages/p06.liquid', - 'app/views/pages/p07.liquid', - 'app/views/pages/p08.liquid', - 'app/views/pages/p09.liquid', - ]); + expect(await run(card, stubCache({ graph: graphWithDependents(card, refs) }))).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { + total: 15, + by_kind: { render: 15 }, + sample: [ + 'app/views/pages/p00.liquid', + 'app/views/pages/p01.liquid', + 'app/views/pages/p02.liquid', + 'app/views/pages/p03.liquid', + 'app/views/pages/p04.liquid', + 'app/views/pages/p05.liquid', + 'app/views/pages/p06.liquid', + 'app/views/pages/p07.liquid', + 'app/views/pages/p08.liquid', + 'app/views/pages/p09.liquid', + ], + }, + }); }); it('reports status "computing" (zeroed) when the graph is not yet fresh — never a stale answer', async () => { @@ -129,6 +133,45 @@ describe('runImpact: blast radius from the cached graph', () => { }); }); +describe('runImpact: applicability (files the graph cannot model as edge targets)', () => { + // A cache that fails if consulted — proves non-trackable files short-circuit + // BEFORE the graph lookup (applicability is a file-type property, not a graph one). + const throwingCache = () => + ({ + lookup: async () => { + throw new Error('cache.lookup must not be called for a non-trackable file'); + }, + }) as unknown as GraphCache; + + it('reports not_applicable for a custom-model-type/schema YAML (wired by table name, not by edge)', async () => { + expect(await run('app/custom_model_types/blog_post.yml', throwingCache())).toEqual({ + scope: 'direct', + status: 'not_applicable', + dependents: { total: 0, by_kind: {}, sample: [] }, + }); + }); + + it('reports not_applicable for a translation YAML', async () => { + expect(await run('app/translations/en.yml', throwingCache())).toEqual({ + scope: 'direct', + status: 'not_applicable', + dependents: { total: 0, by_kind: {}, sample: [] }, + }); + }); + + it('still computes for a GraphQL operation file (a real edge target)', async () => { + const op = 'app/graphql/get_posts.graphql'; + const graph = graphWithDependents(op, [ + { source: 'app/views/pages/index.liquid', kind: 'graphql' }, + ]); + expect(await run(op, stubCache({ graph }))).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { total: 1, by_kind: { graphql: 1 }, sample: ['app/views/pages/index.liquid'] }, + }); + }); +}); + describe('runImpact: signature-impact (callers vs the edited buffer {% doc %})', () => { const card = 'app/views/partials/card.liquid'; @@ -151,18 +194,33 @@ describe('runImpact: signature-impact (callers vs the edited buffer {% doc %})', { source: 'app/views/pages/bare.liquid', kind: 'render' }, ]); - const result = await run(card, stubCache({ graph }), docBuffer); - - expect(result.status).toEqual('computed'); - expect(result.signature_risk).toEqual([ - { caller: 'app/views/pages/bare.liquid', missing_required: ['title'], unexpected_args: [] }, - { caller: 'app/views/pages/extra.liquid', missing_required: [], unexpected_args: ['colour'] }, - { - caller: 'app/views/pages/missing.liquid', - missing_required: ['title'], - unexpected_args: [], + expect(await run(card, stubCache({ graph }), docBuffer)).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { + total: 4, + by_kind: { render: 4 }, + sample: [ + 'app/views/pages/bare.liquid', + 'app/views/pages/extra.liquid', + 'app/views/pages/missing.liquid', + 'app/views/pages/ok.liquid', + ], }, - ]); + signature_risk: [ + { caller: 'app/views/pages/bare.liquid', missing_required: ['title'], unexpected_args: [] }, + { + caller: 'app/views/pages/extra.liquid', + missing_required: [], + unexpected_args: ['colour'], + }, + { + caller: 'app/views/pages/missing.liquid', + missing_required: ['title'], + unexpected_args: [], + }, + ], + }); }); it('returns an empty signature_risk (checked, all match) when every caller satisfies the doc', async () => { @@ -190,4 +248,58 @@ describe('runImpact: signature-impact (callers vs the edited buffer {% doc %})', expect(result.signature_risk).toBeUndefined(); expect(result.status).toEqual('computing'); }); + + it('ignores non-@param edges (background/graphql) — only render/include/function args are @params', async () => { + const graph = graphWithDependents(card, [ + // render caller passing an undeclared arg → flagged + { source: 'app/views/pages/render.liquid', kind: 'render', args: ['title', 'colour'] }, + // background caller: `delay` is a scheduling arg, NOT a @param → must NOT be flagged + { source: 'app/views/pages/bg.liquid', kind: 'background', args: ['title', 'delay'] }, + // graphql caller: `per_page` is an operation arg, NOT a @param → must NOT be flagged + { source: 'app/views/pages/gql.liquid', kind: 'graphql', args: ['per_page'] }, + ]); + + // background/graphql callers ARE still counted as dependents — they are only + // excluded from the @param signature check. + expect(await run(card, stubCache({ graph }), docBuffer)).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { + total: 3, + by_kind: { render: 1, background: 1, graphql: 1 }, + sample: [ + 'app/views/pages/bg.liquid', + 'app/views/pages/gql.liquid', + 'app/views/pages/render.liquid', + ], + }, + signature_risk: [ + { + caller: 'app/views/pages/render.liquid', + missing_required: [], + unexpected_args: ['colour'], + }, + ], + }); + }); + + it('includes function call sites (their args ARE @params, mirroring PartialCallArguments)', async () => { + const graph = graphWithDependents(card, [ + // function caller missing the required `title` + { source: 'app/lib/commands/run.liquid', kind: 'function', args: ['count'] }, + ]); + + expect(await run(card, stubCache({ graph }), docBuffer)).toEqual({ + scope: 'direct', + status: 'computed', + dependents: { + total: 1, + by_kind: { function: 1 }, + sample: ['app/lib/commands/run.liquid'], + }, + signature_risk: [ + { caller: 'app/lib/commands/run.liquid', missing_required: ['title'], unexpected_args: [] }, + ], + }); + }); }); diff --git a/packages/platformos-mcp-supervisor/src/impact/impact.ts b/packages/platformos-mcp-supervisor/src/impact/impact.ts index 37f4b5af..4c02c92b 100644 --- a/packages/platformos-mcp-supervisor/src/impact/impact.ts +++ b/packages/platformos-mcp-supervisor/src/impact/impact.ts @@ -15,11 +15,18 @@ */ import { extractDocDefinition, + isKnownGraphQLFile, + isKnownLiquidFile, path, SourceCodeType, type UriString, } from '@platformos/platformos-check-common'; -import { type AppGraph, dependentsOf, toSourceCode } from '@platformos/platformos-graph'; +import { + type AppGraph, + dependentsOf, + type ReferenceKind, + toSourceCode, +} from '@platformos/platformos-graph'; import { toAbsoluteFilePath, type AdapterInput } from '../adapter-input'; import type { GraphCache } from '../graph-cache/graph-cache'; @@ -28,6 +35,32 @@ import type { ValidateCodeImpact, ValidateCodeSignatureRisk } from '../result/ty /** Number of referencing files listed in `sample`/`signature_risk` before truncating. */ const SAMPLE_LIMIT = 10; +/** + * The edge kinds whose call-site arguments are validated against a partial's + * `{% doc %}` `@param` contract — EXACTLY the kinds the `PartialCallArguments` + * lint check validates (`render`/`include` via its `RenderMarkup` handler, + * `function` via `FunctionMarkup`). `background`/`graphql`/`layout`/`asset` edges + * carry scheduling/operation arguments that are NOT `@param`s, so signature-impact + * must ignore them or it would flag correct calls (a false positive that the + * forward `PartialCallArguments` check never produces). + */ +const SIGNATURE_EDGE_KINDS: ReadonlySet = new Set(['render', 'include', 'function']); + +/** + * Whether the graph can model incoming references to `uri` — i.e. `uri` can be a + * resolvable edge TARGET (a Liquid page/layout/partial, or a GraphQL operation). + * Reuses check-common's canonical classifiers so this cannot drift from the + * graph's own edge resolution. + * + * Files that are NOT edge targets — schema / custom-model-type / translation YAML, + * or any unclassified file — are wired by model/table NAME, not by file reference + * (ADR 004), so the graph has no dependents for them and `total: 0` would be a + * false "safe to change". Those get `status: 'not_applicable'` instead. + */ +function isGraphTrackable(uri: UriString): boolean { + return isKnownLiquidFile(uri) || isKnownGraphQLFile(uri); +} + /** A fresh zeroed dependents shape for every non-`computed` status. */ const noDependents = (): ValidateCodeImpact['dependents'] => ({ total: 0, @@ -44,16 +77,24 @@ export async function runImpact( params: AdapterInput, cache: GraphCache, ): Promise { + const { projectDir, filePath, content } = params; + const rootUri = path.normalize(path.URI.file(projectDir)); + const fileUri = path.normalize(path.URI.file(toAbsoluteFilePath(projectDir, filePath))); + + // Applicability is a property of the FILE TYPE, independent of graph freshness: + // a non-trackable file (schema/translation YAML, etc.) has no dependency edges, + // so short-circuit before touching the graph — `total: 0` here would be a false + // "safe to change" (see {@link isGraphTrackable}). + if (!isGraphTrackable(fileUri)) { + return { scope: 'direct', status: 'not_applicable', dependents: noDependents() }; + } + const lookup = await cache.lookup(); - if (!('graph' in lookup) || !lookup.graph) { + if (!lookup.graph) { const status = lookup.reason === 'unavailable' ? 'unavailable' : 'computing'; return { scope: 'direct', status, dependents: noDependents() }; } - const { projectDir, filePath, content } = params; - const rootUri = path.normalize(path.URI.file(projectDir)); - const fileUri = path.normalize(path.URI.file(toAbsoluteFilePath(projectDir, filePath))); - const signature = await docSignature(fileUri, content); const signature_risk = signature && computeSignatureRisk(lookup.graph, fileUri, rootUri, signature); @@ -140,6 +181,10 @@ function computeSignatureRisk( const byCaller = new Map; unexpected: Set }>(); for (const ref of dependentsOf(graph, fileUri)) { + // Only the kinds whose args ARE `@param`s (see {@link SIGNATURE_EDGE_KINDS}) — + // a `{% background %}`/`{% graphql %}`/layout edge's args are not, and flagging + // them would be a false positive the forward check never makes. + if (!ref.kind || !SIGNATURE_EDGE_KINDS.has(ref.kind)) continue; const args = ref.args ?? []; const missing = signature.required.filter((param) => !args.includes(param)); const unexpected = args.filter((arg) => !signature.allowed.includes(arg)); diff --git a/packages/platformos-mcp-supervisor/src/result/types.ts b/packages/platformos-mcp-supervisor/src/result/types.ts index ff8a2d7f..f6e902ff 100644 --- a/packages/platformos-mcp-supervisor/src/result/types.ts +++ b/packages/platformos-mcp-supervisor/src/result/types.ts @@ -136,12 +136,19 @@ export interface DomainGuide { } /** - * Freshness of the blast-radius answer. Distinguishes a real "nothing depends on - * this" (`computed`, total 0 — safe to change) from "we don't know yet" - * (`computing`/`unavailable`), so an unbuilt/failed graph can NEVER be misread as - * a green light. See {@link ValidateCodeImpact}. + * Freshness/applicability of the blast-radius answer. Distinguishes a real + * "nothing depends on this" (`computed`, total 0 — safe to change) from "we don't + * know yet" (`computing`/`unavailable`) and from "this file type has no dependency + * graph" (`not_applicable`), so an unbuilt/failed graph — or a file the graph + * cannot model — can NEVER be misread as a green light. + * + * `not_applicable` is returned for files that are not graph-trackable edge targets + * (e.g. schema / custom-model-type / translation YAML): nothing references them + * via a resolvable edge — they are wired by model/table NAME, not by file + * reference (see ADR 004) — so `total: 0` would falsely read as "safe to change". + * See {@link ValidateCodeImpact}. */ -export type ValidateCodeImpactStatus = 'computed' | 'computing' | 'unavailable'; +export type ValidateCodeImpactStatus = 'computed' | 'computing' | 'unavailable' | 'not_applicable'; /** * Cross-file "blast radius" of editing the file: who DEPENDS ON it (its incoming From 20817334caf302cd36274d1eb715409271e8580c Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Fri, 3 Jul 2026 11:06:05 +0200 Subject: [PATCH 16/20] Update backlog --- ...-diagnostic.fix-minimal-fix-passthrough.md | 47 ++++++ ...ependency-model-the-supervisor-consumes.md | 39 +++++ ...\206\222-blast-radius-in-validate_code.md" | 155 ++++++++++++++++++ ...discovery-resource-overlay-orientation.md" | 59 +++++++ ...ide-health-sweep-broken-refs-dead-code.md" | 96 +++++++++++ ...ll-the-per-call-whole-project-re-parse.md" | 95 +++++++++++ ...API-in-platformos-graph-applyFileChange.md | 93 +++++++++++ ...fresh-blast-radius-at-best-performance.md" | 122 ++++++++++++++ ...e-coreCheck-runs-whole-project-per-call.md | 53 ++++++ ...ervisors-leaked-graph-domain-knowledge.md" | 52 ++++++ ...safe-dead-code-deletion-repair-ordering.md | 56 +++++++ ...nslation-usage-\342\206\224-definition.md" | 71 ++++++++ ...urrect-the-old-project-map-capabilities.md | 62 ++++++- ...ound-freshness-deferred-from-TASK-9.15.md" | 39 +++++ ...-resolution-frontmatter-embedded-Liquid.md | 42 +++++ ...tural-facts-on-platformos-graph-modules.md | 96 ++++++++++- ...layout-edge-missing-content-for-layout.md" | 59 ++++++- ...validate_code-output-dependencies-field.md | 85 ++++++++++ ...ModelType-nodes-graphql-table-page-slug.md | 106 ++++++++++++ ...\200\224-domain-map-configurable-check.md" | 51 ++++++ ...\342\207\204graph-integration-findings.md" | 110 +++++++++++++ ...oject-validation-frontmatter-Liquid-gap.md | 67 ++++++++ 22 files changed, 1638 insertions(+), 17 deletions(-) create mode 100644 .backlog/tasks/task-8.6 - Translate-structured-Offense.fix-suggest-into-validate_code-proposed_fixes-diagnostic.fix-minimal-fix-passthrough.md create mode 100644 ".backlog/tasks/task-9.10 - Cached-project-graph-at-the-supervisor-edge-\342\206\222-blast-radius-in-validate_code.md" create mode 100644 ".backlog/tasks/task-9.11 - project_map-tool-\342\200\224-graph-backed-discovery-resource-overlay-orientation.md" create mode 100644 ".backlog/tasks/task-9.12 - validate_project-tool-\342\200\224-project-wide-health-sweep-broken-refs-dead-code.md" create mode 100644 ".backlog/tasks/task-9.13 - Memoize-getApp-in-check-node-\342\200\224-kill-the-per-call-whole-project-re-parse.md" create mode 100644 .backlog/tasks/task-9.14 - Incremental-graph-update-API-in-platformos-graph-applyFileChange.md create mode 100644 ".backlog/tasks/task-9.15 - Warm-incremental-persisted-GraphCache-\342\200\224-always-fresh-blast-radius-at-best-performance.md" create mode 100644 .backlog/tasks/task-9.16 - Scope-lintBuffers-check-pass-to-the-buffer-file-coreCheck-runs-whole-project-per-call.md create mode 100644 ".backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" create mode 100644 .backlog/tasks/task-9.18 - Graph-driven-cross-file-autofixes-did-you-mean-rename-move-propagation-signature-arg-propagation-safe-dead-code-deletion-repair-ordering.md create mode 100644 ".backlog/tasks/task-9.19 - First-class-YAML-nodes-platform-fact-edges-model-schema-\342\206\224-graphql-table-join-model-\342\206\224-model-associations-translation-usage-\342\206\224-definition.md" create mode 100644 ".backlog/tasks/task-9.20 - GraphCache-Phase-3B-\342\200\224-fs.watch-background-freshness-deferred-from-TASK-9.15.md" create mode 100644 .backlog/tasks/task-9.21 - Complete-reference-edge-extraction-theme_render_rc-resolution-frontmatter-embedded-Liquid.md create mode 100644 .backlog/tasks/task-9.5 - Wire-graph-dependency-edges-into-validate_code-output-dependencies-field.md create mode 100644 .backlog/tasks/task-9.6 - Model-platform-facts-in-platformos-graph-schema-CustomModelType-nodes-graphql-table-page-slug.md create mode 100644 ".backlog/tasks/task-9.7 - Resource-CRUD-completeness-as-a-convention-overlay-commands-queries-\342\200\224-domain-map-configurable-check.md" create mode 100644 ".backlog/tasks/task-9.8 - Code-review-remediation-supervisor\342\207\204graph-integration-findings.md" create mode 100644 .backlog/tasks/task-9.9 - Graph-asset-resolution-fix-real-project-validation-frontmatter-Liquid-gap.md diff --git a/.backlog/tasks/task-8.6 - Translate-structured-Offense.fix-suggest-into-validate_code-proposed_fixes-diagnostic.fix-minimal-fix-passthrough.md b/.backlog/tasks/task-8.6 - Translate-structured-Offense.fix-suggest-into-validate_code-proposed_fixes-diagnostic.fix-minimal-fix-passthrough.md new file mode 100644 index 00000000..f38fcb8e --- /dev/null +++ b/.backlog/tasks/task-8.6 - Translate-structured-Offense.fix-suggest-into-validate_code-proposed_fixes-diagnostic.fix-minimal-fix-passthrough.md @@ -0,0 +1,47 @@ +--- +id: TASK-8.6 +title: >- + Translate structured Offense.fix / suggest into validate_code proposed_fixes + + diagnostic.fix (minimal fix passthrough) +status: To Do +assignee: [] +created_date: '2026-07-02 15:00' +labels: + - mcp-supervisor + - fixes + - ergonomics +dependencies: + - TASK-8.1 +references: + - packages/platformos-mcp-supervisor/src/lint/lint.ts + - packages/platformos-mcp-supervisor/src/result/assemble.ts + - packages/platformos-mcp-supervisor/src/result/types.ts +parent_task_id: TASK-8 +priority: high +--- + +## Description + + +WHY. The check-common engine ALREADY computes structured fixes (`Offense.fix` — `FixDescription` text-edits/inserts — and `Offense.suggest`), but the current supervisor slice DROPS them: `assemble.ts` hardcodes `proposed_fixes: []`, and `lint.ts` `toDiagnostic` maps only check/severity/message/range (its own doc: "the structured `Offense.fix` / `suggest` are intentionally not translated"). So `validate_code` today returns detection but ZERO fixes. This wires the fixes the engine already produces through to the agent — no edit text is ever regenerated. + +RELATIONSHIP TO TASK-8.3. TASK-8.3 ("port the v1 rule library … hints, variants, did-you-mean, confidence, fixes") is the BROAD enrichment port. This task is the MINIMAL, independently-shippable slice: just faithfully pass through the structured fixes check-common already emits. It can land before the full rule library; 8.3's hints/variants/confidence layer on top. (Graph-BACKED did-you-mean + cross-file fixes are a separate task under TASK-9 — see the graph-driven autofix task.) + +WHAT. + - `lint.ts`: translate `Offense.fix` (FixDescription) → the `AgentFix` shape (`text_edit` | `insert` | `guidance`) on `ValidateCodeDiagnostic.fix`, and `Offense.suggest` → `diagnostic.suggestion`. Preserve check-common's offsets faithfully into the `AgentFix` 0-based `start_index`/`end_index` contract (mind that diagnostics use 1-based line/col but `AgentFix` uses 0-based offsets — the two must not be conflated). + - `assemble.ts`: populate top-level `proposed_fixes: ProposedFix[]` from the per-diagnostic fixes (carrying the originating `check`), instead of `[]`. + - Keep the "supervisor never regenerates edit text from scratch" invariant (types.ts) — translate, don't synthesize. + +TESTS. Whole-value result assertions (CLAUDE.md): a check that emits a text-edit fix → the exact `AgentFix` appears on the diagnostic AND in `proposed_fixes` with its `check`; a check with only a `suggest` → `suggestion` set, no machine fix; a check with neither → no fix fields. Offset mapping pinned exactly. No regression to lint/impact/assemble. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 lint.ts translates Offense.fix (FixDescription) → AgentFix (text_edit|insert|guidance) on ValidateCodeDiagnostic.fix, and Offense.suggest → diagnostic.suggestion; offsets mapped faithfully (AgentFix 0-based indices, not conflated with 1-based line/col) +- [ ] #2 assemble.ts populates top-level proposed_fixes from the per-diagnostic fixes (each carrying its originating check), replacing the hardcoded [] +- [ ] #3 Edit text is never regenerated — only translated from the engine's FixDescription (types.ts invariant preserved) +- [ ] #4 Whole-value tests: text-edit fix appears on diagnostic + proposed_fixes with check; suggest-only → suggestion; neither → no fix fields; exact offset mapping pinned +- [ ] #5 supervisor suite + type-check + format green; no regression to lint/impact/assemble; scope/overlap with TASK-8.3 documented + diff --git a/.backlog/tasks/task-9 - Extend-platformos-graph-to-own-the-project-structural-dependency-model-the-supervisor-consumes.md b/.backlog/tasks/task-9 - Extend-platformos-graph-to-own-the-project-structural-dependency-model-the-supervisor-consumes.md index 86ed52ad..05a2b20d 100644 --- a/.backlog/tasks/task-9 - Extend-platformos-graph-to-own-the-project-structural-dependency-model-the-supervisor-consumes.md +++ b/.backlog/tasks/task-9 - Extend-platformos-graph-to-own-the-project-structural-dependency-model-the-supervisor-consumes.md @@ -6,6 +6,7 @@ title: >- status: To Do assignee: [] created_date: '2026-06-23 10:32' +updated_date: '2026-06-25 15:45' labels: [] dependencies: [] references: @@ -49,3 +50,41 @@ This is the tracking epic. See child tasks. Prerequisite for the supervisor's st - [ ] #4 The LSP consumers (references/dependencies/dead_code) remain correct (re-verified) and changes to shared types are additive - [ ] #5 platformos-mcp-supervisor consumes this API and contains NO graph/scanner logic of its own + +## Implementation Notes + + +## Supervisor wiring guidance — consuming `extractFileReferences` (2026-06-25) + +The per-file dependency primitive now exists in `platformos-graph` (see TASK-9.3 notes). This is how the supervisor (`platformos-mcp-supervisor`, branch `add-pos-supervisor`) should wire it into `validate_code`. WRITE NO GRAPH LOGIC IN THE SUPERVISOR (invariant #5 / this epic's principle) — it only calls the primitive and shapes the output. + +### Where it goes (respect the architecture guards) +- Graph resolution does fs I/O → it must NOT live in the pure layers. The `architecture-invariants.spec.ts` guard marks `enrich/` and `result/` as PURE (no fs/process/I/O). So add a NEW I/O-layer sibling to `lint/` — e.g. `src/structure/` — that calls `extractFileReferences`, exactly mirroring how `lint/lint.ts` is the only other I/O seam. +- Do NOT route through `platformos-language-server-common` / `AppGraphManager` — that's a banned dependency (invariant #1, enforced). Use `@platformos/platformos-graph` directly (already a declared dep, currently unused). + +### Call shape (matches the existing `runLint` seam) +```ts +import { extractFileReferences, toSourceCode } from '@platformos/platformos-graph'; +import { NodeFileSystem } from '@platformos/platformos-check-node'; // supervisor already deps check-node + +const rootUri = URI.file(projectDir).toString(); // projectDir from SupervisorContext +const sourceUri = URI.file(absoluteFilePath).toString(); // same abs path runLint builds +const sourceCode = await toSourceCode(sourceUri, content); // parse the BUFFER, not disk +const refs = await extractFileReferences(rootUri, sourceUri, sourceCode, { fs: NodeFileSystem }); +``` +Key: parse `params.content` (the in-flight buffer), NOT the on-disk file — the file may not exist yet ("validate before writing"). `rootUri`/`projectDir`: reuse check-common `findRoot` for consistency with the CLI if you want pointed-inside-project tolerance. + +### Result shaping (pure `result/` layer) +- Map `Reference[]` → a NEW agent-facing `dependencies` field on `ValidateCodeResult` (e.g. `{ kind, target_path, line, column }[]`). Convert `target.uri` → project-relative via check-common `path.relative(target.uri, rootUri)`; convert `source.range` offsets → 1-based line/col with the SAME `+1`/positionAt approach `lint.ts` uses. +- Do NOT overload the existing name-level `structural.renders_used` (that's TASK-9.3's self-structural NAMES, a separate slot). `dependencies` = resolved edges; `structural.*` = declared names. + +### What NOT to duplicate (overlap with existing lint) +- The linter ALREADY emits `MissingPartial` for unresolved render targets. So do NOT re-detect missing targets as supervisor diagnostics from the dependency edges — the unique value the graph adds is the CANONICAL RESOLVED URI + the `kind` taxonomy, not re-flagging "missing". If you want an explicit `exists` per edge, `stat` `target.uri` via the same `fs` in the `structure/` layer (don't add `exists` to check-common's `Reference` — too broad, breaks LSP). + +### Out of scope for the primitive (future graph work, still TASK-9.2) +- Incoming `references` ("who renders this file") need the on-disk whole-graph build (`buildAppGraph`) + per-root caching (mirror `AppGraphManager`'s cache). That's heavier and project-wide; defer to the project-structure query API (TASK-9.2). At validate-time the file's OWN outgoing deps are the primary signal. + +### Verify after wiring +- `architecture-invariants.spec.ts` still green (no LSP dep, pure layers stay pure, `structure/` is the only new I/O seam). +- AC #5 of this epic: supervisor contains zero graph/scanner logic — it just calls `extractFileReferences` and maps the result. + diff --git "a/.backlog/tasks/task-9.10 - Cached-project-graph-at-the-supervisor-edge-\342\206\222-blast-radius-in-validate_code.md" "b/.backlog/tasks/task-9.10 - Cached-project-graph-at-the-supervisor-edge-\342\206\222-blast-radius-in-validate_code.md" new file mode 100644 index 00000000..4cf0a791 --- /dev/null +++ "b/.backlog/tasks/task-9.10 - Cached-project-graph-at-the-supervisor-edge-\342\206\222-blast-radius-in-validate_code.md" @@ -0,0 +1,155 @@ +--- +id: TASK-9.10 +title: Cached project graph at the supervisor edge → blast-radius in validate_code +status: Done +assignee: + - Filip +created_date: '2026-07-01 19:38' +updated_date: '2026-07-01 23:26' +labels: + - platformos-graph + - mcp-supervisor + - validate-code + - performance + - architecture +dependencies: + - TASK-9.2 + - TASK-9.8 + - TASK-9.9 +references: + - >- + docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md + - SUPERVISOR-GRAPH-INTEGRATION.md + - packages/platformos-graph/src/graph/query.ts + - packages/platformos-mcp-supervisor/src/structure/structure.ts + - packages/platformos-mcp-supervisor/src/transport/validate-code.ts + - packages/platformos-check-common/src/checks/partial-call-arguments/index.ts +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +WHY. The graph's current contribution to `validate_code` is almost entirely REDUNDANT with the lint layer: broken references are caught by `MissingPartial`/`MissingAsset`, and argument correctness by `PartialCallArguments` (a recommended lint check that resolves the target, reads its `{% doc %}` signature, and reports missing-required/unknown args for render+function). The per-file graph outputs (`dependencies`, `structural`) mostly echo the buffer the agent just wrote. + +The ONE thing the graph can do that lint structurally CANNOT is the BACKWARD / cross-file direction: lint is per-file and forward-looking — it can say "this file's calls are valid" but never "editing THIS file breaks its N callers." That reverse-index / blast-radius answer is the graph's unique value, and it is not wired into `validate_code` today. This task delivers it. + +WHAT. Stand up a cached full `AppGraph` at the supervisor I/O edge (keyed by project root, reused across `validate_code` calls instead of rebuilt per call), and surface blast-radius for the edited file — who depends on it — via the EXISTING query API (`dependentsOf`), never re-derived in the supervisor. + +KEY DESIGN CONSTRAINTS / DECISIONS (must be resolved as part of this task): +- FRESHNESS (the hard part). The agent is editing the very file whose callers we report. The cache MUST overlay the unsaved in-flight buffer for that file (the buffer-before-write model already used by `runStructure`/`runLint`) so blast-radius reflects current content — a dependent that the edit is ADDING or REMOVING must be reflected, not read stale from disk. +- INVALIDATION (ADR 003 open question #5). TTL vs explicit fs-change invalidation vs both. Full build on this real project is ~50s / 1,505 nodes — so per-call rebuild is a non-starter; the cache must amortize. Decide + document cold-start / warm-up behavior. +- REVERSE-INDEX COMPLETENESS. `dependentsOf` is only as complete as the graph's entry points (see `query.ts` header: to see all callers you must build with every file as an entry point, not just pages+layouts). Decide the build scope that guarantees complete dependents, and document the guarantee. +- NON-MISLEADING OUTPUT (repo north star). "No dependents" (safe to change) MUST be distinguishable from "not computed / cache cold" (unknown). A stale or unbuildable graph must DEGRADE gracefully — never emit a wrong/blank blast-radius that lulls the agent into an unsafe edit. Mirror the F2 secondary-signal contract (a graph failure never sinks the lint gate). +- OUTPUT SHAPE. Decide what `validate_code` returns: dependent count + sample paths? full list? signature-impact ("N callers, K of them pass an arg you're changing")? Keep it small and agent-actionable, not a raw dump. + +RELATED DECISION (coupled, resolve alongside — see blunt review): demote/remove the now-redundant `dependencies`/`structural` from the default `validate_code` output (keep the graph primitives; they still serve the CLI/serialize/TASK-9.7). Track here or split, but don't ship blast-radius while the redundant echo still bloats the response. + +REUSE, DO NOT RE-DERIVE: consume `buildAppGraph` + `dependentsOf`/`nearestModules` from platformos-graph; the supervisor owns only caching + output shaping (ADR 003 consumer principle). + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [x] #1 A project-graph cache lives at the supervisor I/O edge (sibling to lint/structure adapters), keyed by project root, built once via buildAppGraph and reused across validate_code calls (verified: N calls do not trigger N full builds). +- [x] #2 Blast-radius freshness: the edited file's in-flight buffer is overlaid onto the cached graph so dependents reflect the unsaved content — an added/removed reference changes the reported callers (covered by a test that edits a buffer and asserts the dependent set changes accordingly). +- [x] #3 validate_code surfaces blast-radius for the edited file via platformos-graph's dependentsOf (no reverse-index logic re-implemented in the supervisor); output shape decided, small, and agent-actionable. +- [x] #4 Non-misleading contract: 'no dependents' is distinguishable from 'not computed'; a cold/stale/unbuildable graph degrades gracefully and NEVER sinks the lint gate or emits a wrong blast-radius (mirrors F2). +- [x] #5 Reverse-index completeness guarantee decided + documented: the cache is built with a scope that yields complete dependents (per query.ts entry-point note), or the output explicitly states its scope. +- [x] #6 Cache invalidation strategy (TTL and/or fs-change) implemented + documented, resolving ADR 003 open question #5; cold-start/warm-up behavior defined and the warm path is sub-second on a ~1,500-node project. +- [x] #7 Coupled decision executed: the redundant dependencies/structural are demoted/removed from the default validate_code output (graph primitives retained for CLI/serialize/TASK-9.7), with rationale recorded. +- [x] #8 TDD + comprehensive tests: cache reuse, buffer-overlay freshness, invalidation, blast-radius correctness on a fixture, and graceful degradation; all package suites + direct-tsc type-check + format:check + frozen-lockfile green. +- [x] #9 Docs updated: SUPERVISOR-GRAPH-INTEGRATION.md (blast-radius wired, dependencies/structural demoted) and ADR 003 open question #5 marked resolved. + + +## Implementation Plan + + +BLAST-RADIUS SPEC for validate_code — EXACTLY what the graph contributes (and what it must NOT). + +Principle: validate_code stays lint-first (forward-looking, per-file). The graph adds ONE thing lint structurally cannot: the BACKWARD direction — "who depends on the file being edited, and does this edit break them." Everything else the graph could add is either redundant with lint or noise, and is excluded. + +=== INCLUDED (graph-unique; lint cannot produce these) === +1. DIRECT DEPENDENTS of the edited file F — incoming edges, via `dependentsOf(cachedGraph, F)`. + - Shape: { total, by_kind (render/include/function/background/graphql/layout), sample: capped ≤10 caller paths }. + - Direct only. Count + capped sample — NEVER the full list, NEVER a raw dump. + - Only meaningful for file kinds with incoming edges: partials, lib (commands/queries), layouts, graphql, schema. A page (entry point) normally has 0 dependents → impact empty → correct. + +2. SIGNATURE-IMPACT (the high-value refinement — turns "106 files depend on this" into "3 of them break"): + - Trigger: the in-flight buffer CHANGES F's declared interface vs the on-disk version. + - For partials/lib: compare F's NEW `{% doc %}` @param signature (from the buffer via extractStructural / extractDocDefinition) against each caller's passed args (from the cached graph's edge.args). + - Report, per affected caller: params now MISSING that F newly requires; args the caller passes that F NO LONGER declares (removed/renamed). + - CONSERVATIVE: only when F HAS a `{% doc %}` block (explicit contract). No doc block → NO signature-impact (do not infer/guess — avoids false positives that would mislead). + - REUSE, do not re-implement: derive required/allowed params via the same logic as the PartialCallArguments check (feed it F's buffer signature instead of the on-disk target). This is the inverse of PartialCallArguments (that check fires when editing the CALLER; this fires when editing the TARGET) — genuinely non-redundant. + +3. FRESHNESS / HONESTY: "no dependents" (safe to change) MUST be distinguishable from "not computed" (cache cold/unbuildable). Degrade gracefully — never emit a wrong or blank blast-radius (F2 secondary-signal contract). + +=== EXPLICITLY EXCLUDED (redundant, noisy, or harmful) === +- TRANSITIVE / closure dependents — explosive (a leaf util → hundreds), rarely actionable, token-bomb. Direct callers only. (A separate on-demand query can offer reachability if ever needed — not in validate_code.) +- F's OUTGOING dependencies (the current `dependencies` field) — redundant with lint: MissingPartial/MissingAsset resolve+flag targets, PartialCallArguments checks the args. DEMOTE from default output (this task's AC #7). +- Broken-reference detection on F's own refs — lint (MissingPartial/MissingAsset). +- Argument validation of F's own outgoing calls — lint (PartialCallArguments). +- did-you-mean / nearest-name for F's refs — lint (MissingPartial already suggests). +- `structural` self-echo — a summary of the buffer the agent just wrote; no signal. DEMOTE. +- ASSET dependents on a content edit — editing an asset's bytes does not break path-based references; low/zero value. Skip (rename/delete semantics are not modeled). +- Separate orphan/reachability status field — dependents.total === 0 already conveys "nothing references this." + +=== OUTPUT SHAPE (illustrative; finalize in impl) === +"impact": { + "scope": "direct", + "dependents": { "total": N, "by_kind": { ... }, "sample": [ ≤10 project-relative paths ] }, + "signature_risk": [ { "caller": "", "missing_required": ["count"], "no_longer_accepted": ["foo"] } ], + "status": "computed" | "unavailable" +} +- Present `impact` only when total > 0 OR signature_risk is non-empty; otherwise omit (or an explicit empty with status). Never clutter a brand-new/leaf file with noise. + +=== SEQUENCING within this task === +- MVP: cached graph + DIRECT DEPENDENTS (count + by_kind + capped sample) + freshness honesty + demote dependencies/structural. +- Enhancement (same task or fast-follow): SIGNATURE-IMPACT (the arg/@param inverse check). Highest value; do as soon as the cache + dependents land. + +Note: dependents are INCOMING edges from OTHER files (read from the cached on-disk graph); the buffer overlay matters for the SIGNATURE side (F's new @param), not the dependents-list side (callers are other files). A NEW file the agent is creating has 0 dependents → correct empty impact. + +APPROVED PLAN REVISIONS (2026-07-01). + +DECISIONS (user-approved): Q1 signature-impact IS in this task (Phase C). Q2 FULL removal of dependencies/structural from validate_code output (no dead fields); drop the runStructure call + remove the now-dead supervisor structure adapter; KEEP the graph primitives (extractFileReferences/extractStructural in platformos-graph) for CLI/serialize/9.11/9.12. + +AC #2 CORRECTION (approved): blast-radius = who depends on F = F's INCOMING edges, which live in OTHER files. Editing F's buffer changes F's OUTGOING edges/signature, never who points at F. So the dependents list does NOT overlay the buffer — it comes from the cached disk graph. The buffer is used ONLY for signature-impact (F's new doc block vs callers' args, parsed directly). Freshness for the dependents list = DISK freshness enforced by fingerprinting (below), NOT buffer overlay. AC #2's original buffer-overlay framing is superseded by this. + +FRESHNESS = NEVER SERVE STALE (user mandate: staleness may mislead the LLM, avoid at all costs). SWR (serve-stale-while-revalidate) REJECTED. Design: GraphCache stores { graph, fingerprint } where fingerprint = map(liquidFilePath -> mtimeMs+size) over the build's liquid files (liquid files are the only edge SOURCES, so only their add/remove/modify can change dependents; schema/graphql/asset are leaves). Per request: recompute current fingerprint (cheap stat-walk; cheaper than lint's existing per-call whole-project parse), compare. MATCH -> fresh -> serve dependents (status: computed). MISMATCH or no graph -> do NOT serve old graph -> status: recomputing + fire dedup'd background rebuild. build error -> status: unavailable. Never blocks/awaits the build on the request path (F2); never serves stale. Practical behavior: agent validates BUFFERS (no disk write) across a burst -> fingerprint keeps matching -> fresh every call; rebuild triggers only after an actual file write. + +PHASING: A = GraphCache (fingerprint, never-stale, background build) + blast-radius MVP (direct dependents: total/by_kind/sample<=10) wired into validate_code, graceful degradation. B = full removal of dependencies/structural + remove dead structure adapter + update assemble/stdio-smoke/validate-code specs. C = signature-impact (F's doc block vs callers' args; reuse PartialCallArguments param-derivation; conservative, doc-block-only). D = docs (SUPERVISOR-GRAPH-INTEGRATION.md + ADR003 Q5) + full cross-package verification. TDD each phase. + + +## Implementation Notes + + +IMPLEMENTED (Phases A-D complete). GraphCache src/graph-cache/ (never-stale, fingerprint-validated, background-built, dedup'd; injectable seams). Impact adapter src/impact/ (dependents via dependentsOf + signature_risk via extractDocDefinition vs edge args). validate_code reshaped: removed dependencies+structural, added impact; removed the dead structure adapter; SupervisorContext gains graphCache (created in startServer). Reuse-only: buildAppGraph/dependentsOf/toSourceCode + extractDocDefinition + isLayout/isPage/isPartial + recursiveReadDirectory; node:fs only for mtime (AbstractFileSystem.stat lacks it). + +AC#2 delivered per the approved correction: dependents come from the cached DISK graph (buffer never overlaid — who points at F lives in other files); the buffer is used only for signature_risk. Freshness = fingerprint (mtime:size over edge-source liquid files), never stale. + +SIGNATURE-IMPACT (Q1, in this task): conservative, doc-block-only (no inference); reports callers missing a required @param or passing an undeclared one; the cross-file inverse of PartialCallArguments. + +MEASURED on real marketplace-dcra (1,505 nodes): warm request ~400ms (fingerprint scan, concurrent with lint → ~0 added wall-clock), cold first fingerprint ~8s (hidden behind lint's cold parse), background build ~22s (never awaited). Warm path sub-second confirmed. + +VERIFICATION: supervisor 55 green (graph-cache 7, impact 10, validate-code 3, assemble 6, stdio-smoke 6 incl. real blast-radius + signature-impact e2e, guards 12, lint 3, args 8); direct tsc clean; format:check clean; frozen-lockfile clean, zero yarn.lock churn. No source changed outside the supervisor — graph/common/check-common/LSP suites unaffected (last green: graph 80, common 261, check-common 1057, LSP 467). + +DOCS: SUPERVISOR-GRAPH-INTEGRATION.md §1/§4/§5.1/§6/§7 updated + new §9 (TASK-9.10); ADR 003 open question #5 marked RESOLVED (fingerprint never-stale cache). + +FOLLOW-UPS (noted, out of scope): fingerprint walks whole tree incl. non-platformOS dirs (~400ms warm) — scope the walk / reuse lint's file list; bounded-await for small projects' cold start; getApp memoization (doubt #8) remains the higher-value perf lever. + + +## Final Summary + + +Wired the graph into validate_code the way that actually earns its keep: a never-stale cached project graph powering a cross-file BLAST-RADIUS signal (who depends on the edited file + signature-impact), and removed the redundant per-file dependencies/structural (lint already covers broken refs via MissingPartial/MissingAsset and args via PartialCallArguments). + +GraphCache (src/graph-cache/): builds the full AppGraph once (all liquid files as entry points -> complete dependents), reuses it across calls, background-built, NEVER served stale. Rejected TTL and stale-while-revalidate (both can mislead the agent); instead fingerprint-validates every request (mtime:size over edge-source liquid files) and reports `computing` on any change rather than a wrong answer. Never awaited on the request path (F2: a graph failure never sinks the lint gate). ADR 003 open question #5 resolved. + +impact (src/impact/): dependents {total, by_kind, sample<=10} via dependentsOf; signature_risk (doc-block-only, conservative) flags dependent callers whose args violate the buffer's {% doc %} contract via extractDocDefinition vs the graph edges' args - the cross-file inverse of PartialCallArguments. status computed|computing|unavailable makes "nothing depends on this (safe)" distinguishable from "not computed" - never misleading. + +Removed dependencies+structural from the result and the dead structure adapter; kept the graph primitives for the CLI/serialize/9.11/9.12. SupervisorContext gains a per-project graphCache (created in startServer). + +TDD throughout. Verified on the real 1,505-node marketplace-dcra: warm request ~400ms (concurrent with lint => ~0 added wall-clock), background build ~22s. Supervisor 55 tests green (incl. real blast-radius + signature-impact end-to-end over stdio); direct tsc + format:check + frozen-lockfile clean, zero lockfile churn. Docs (SUPERVISOR-GRAPH-INTEGRATION.md new S9 + ADR 003 Q5) updated. + diff --git "a/.backlog/tasks/task-9.11 - project_map-tool-\342\200\224-graph-backed-discovery-resource-overlay-orientation.md" "b/.backlog/tasks/task-9.11 - project_map-tool-\342\200\224-graph-backed-discovery-resource-overlay-orientation.md" new file mode 100644 index 00000000..1edd70bc --- /dev/null +++ "b/.backlog/tasks/task-9.11 - project_map-tool-\342\200\224-graph-backed-discovery-resource-overlay-orientation.md" @@ -0,0 +1,59 @@ +--- +id: TASK-9.11 +title: project_map tool — graph-backed discovery + resource overlay (orientation) +status: To Do +assignee: [] +created_date: '2026-07-01 21:14' +labels: + - mcp-supervisor + - platformos-graph + - discovery + - tool +dependencies: + - TASK-9.7 + - TASK-9.10 +references: + - >- + docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md + - docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md + - packages/platformos-graph/src/graph/query.ts + - SUPERVISOR-GRAPH-INTEGRATION.md +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +WHY. The #1 failure mode of an LLM generating code in a real platformOS project is not bad syntax — it is NOT KNOWING WHAT ALREADY EXISTS: it reinvents a query/command that exists, hallucinates a partial, or writes against a table it doesn't understand. Grounding the model in "what exists and how it connects" BEFORE it writes prevents more bad code than any post-hoc validation. This is the graph's highest-leverage use for code generation, and it is a discovery/query capability — NOT a validator and NOT part of validate_code. + +WHAT. A read-only `project_map` MCP tool that turns the cached project graph (+ the commands/queries resource overlay) into a COMPACT, agent-actionable map used at task start. It leads with what the graph adds over `ls`+grep — RESOLVED cross-file relationships (through platformOS module/lib/extension resolution and `{% liquid %}` blocks that defeat grep) and RESOURCE COMPLETENESS — not a raw file tree. + +CONTENT (what the map surfaces): +- Entry points + counts by kind (pages, layouts, partials, lib commands/queries, graphql ops, schemas/tables) — a size/shape overview. +- RESOURCE OVERLAY (the headline; this is the TASK-9.7 convention overlay consumed here): per model table, the schema + the commands/queries/graphql ops that operate on it, and CRUD completeness (e.g. "event_report: has search+create, MISSING update/delete"). This is the single most useful discovery signal for "is there already a way to do X, and what's missing." +- Resolved relationships, queryable via drill-down (see below): "what renders/includes X", "what calls query Y", "what graphql ops hit table Z". + +HARD RULES (utilize the graph correctly): +- NEVER dump the graph. The whole-project serialization is ~345k tokens on the real project — useless as an LLM input. project_map returns a bounded SUMMARY by default and supports DRILL-DOWN parameters for slices (e.g. { table? , renders_of? , calls_of? , kind? , path_prefix? }) so the agent pulls only what it needs. +- Lead with graph-unique content (resolved relationships + resource completeness). A plain file listing is something the LLM can get itself — do not pad the response with it. +- REUSE, do not re-derive: consume buildAppGraph + the query API (dependenciesOf/dependentsOf/nearestModules) + the TASK-9.7 resource overlay. The supervisor owns only tool wiring + output shaping (ADR 003/004 consumer principle). Commands/queries are a `core`-module CONVENTION (ADR 004), so the overlay must be convention-scoped/configurable, not baked into the graph. +- Built on the cached graph from TASK-9.10 (do not rebuild per call). + +Distinct from the other two tools: project_map = DISCOVERY (task start, "what exists / how connected"); validate_code blast-radius = CHANGE SAFETY (edit time, "what breaks if I touch this"); validate_project = HEALTH (task end, "is anything broken/dead"). + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 A read-only `project_map` MCP tool is registered on both transports (stdio + HTTP) alongside validate_code, returning a structured, token-BOUNDED map (never the raw graph dump). +- [ ] #2 Summary mode returns counts by kind (pages/layouts/partials/lib/graphql/schemas) + entry points — a shape overview, not a file tree. +- [ ] #3 Resource overlay: for each model table, the schema + its commands/queries/graphql ops + CRUD completeness (present vs missing operations). Convention-scoped/configurable per ADR 004 (disable-able for non-core apps). +- [ ] #4 Drill-down parameters return relationship slices (e.g. what-renders-X / callers-of-query-Y / graphql-ops-on-table-Z / files-under-prefix) via the graph query API — each response bounded and agent-actionable. +- [ ] #5 Leads with graph-unique content (resolved relationships + resource completeness); does not pad with a plain file listing the LLM could produce itself. +- [ ] #6 Reuse only: buildAppGraph + query API + the TASK-9.7 resource overlay; zero graph/scanner logic re-implemented in the supervisor. +- [ ] #7 Built on the TASK-9.10 cached graph (verified: does not trigger a full rebuild per call). +- [ ] #8 TDD + comprehensive tests on a fixture project (summary, resource overlay incl. a missing-CRUD case, each drill-down, token bound); all suites + direct-tsc + format:check + frozen-lockfile green. +- [ ] #9 Docs: SUPERVISOR-GRAPH-INTEGRATION.md documents the three-tool shape (project_map / validate_code blast-radius / validate_project) and project_map's contract. + diff --git "a/.backlog/tasks/task-9.12 - validate_project-tool-\342\200\224-project-wide-health-sweep-broken-refs-dead-code.md" "b/.backlog/tasks/task-9.12 - validate_project-tool-\342\200\224-project-wide-health-sweep-broken-refs-dead-code.md" new file mode 100644 index 00000000..cc344b40 --- /dev/null +++ "b/.backlog/tasks/task-9.12 - validate_project-tool-\342\200\224-project-wide-health-sweep-broken-refs-dead-code.md" @@ -0,0 +1,96 @@ +--- +id: TASK-9.12 +title: validate_project tool — project-wide health sweep (broken refs + dead code) +status: To Do +assignee: [] +created_date: '2026-07-01 21:15' +updated_date: '2026-07-01 21:21' +labels: + - mcp-supervisor + - platformos-graph + - validation + - tool +dependencies: + - TASK-9.10 +references: + - docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md + - packages/platformos-graph/src/graph/query.ts + - SUPERVISOR-GRAPH-INTEGRATION.md +parent_task_id: TASK-9 +priority: medium +--- + +## Description + + +WHY. `validate_code` is per-file and forward-looking; it cannot answer project-scale correctness ("is anything broken or dead ANYWHERE?"). An LLM finishing a change wants a final sweep before declaring done — and a CI gate wants the same. The graph-unique part of that sweep is DEAD CODE / ORPHANS (files nothing references), which no per-file tool can produce. + +WHAT. A `validate_project` MCP tool that reports whole-project structural health from the cached graph: +- BROKEN REFERENCES across the project — every edge whose target does not resolve on disk, via `missingTargets(graph)`, grouped by kind (render/include/function/background/graphql/asset) and by source file. (This is the project-wide superset of what validate_code's lint catches per file.) +- DEAD CODE / ORPHANS — existing, non-entry-point, non-schema files that nothing references, via `orphans(graph)`. This is the genuinely graph-unique signal. +- (Optional, phase 2) a rolled-up lint summary across files, if cheap to include; the broken-refs + orphans are the core. + +HARD RULES: +- BOUNDED, GROUPED output — never a raw dump. Group broken refs by kind + source; cap/paginate orphans; give totals. This is a triage list, not the graph. +- ORPHAN COMPLETENESS depends on build scope: `orphans`/`dependentsOf` are only complete if the graph is built with EVERY file as an entry point (see query.ts header) — a page/layout-only build under-reports. This tool MUST use (or trigger) a whole-project build scope, and the output must state its scope. Coordinate with the TASK-9.10 cache (the cache's build scope must support this, or this tool builds with all-files entry points). +- NON-MISLEADING: an orphan list that is really "unreachable from a too-narrow entry-point set" is a trap. Either guarantee complete scope or label results as scoped. A cold/unbuildable graph degrades gracefully (no false "all clean"). +- REUSE: `missingTargets` + `orphans` from the graph query API; the supervisor owns only tool wiring + triage-shaping. No re-derivation. +- Respect ADR 004: broken-ref/orphan detection is platform-neutral (edges + reachability), NOT the commands/queries convention — keep it convention-free (resource/CRUD completeness belongs to project_map's overlay, not here). + +NAMING: `validate_project` chosen for consistency with the supervisor's validate_* family (validate_code, validate_intent). `check_project` is the alternative if we want to reserve "validate" for write-gates; decide during impl. + +Distinct from the other two tools: validate_project = HEALTH (task end / CI, "is anything broken or dead"); project_map = DISCOVERY (task start); validate_code blast-radius = CHANGE SAFETY (edit time). Lower urgency than 9.10/9.11 — most per-file correctness is already achievable by iterating validate_code; the unique value here is the dead-code/orphan sweep. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 A `validate_project` MCP tool is registered on both transports, reporting project-wide structural health from the cached graph in BOUNDED, grouped form (never a raw dump). +- [ ] #2 Broken references: every unresolved edge across the project via missingTargets(graph), grouped by kind and by source file, with totals. +- [ ] #3 Dead code / orphans: existing non-entry-point non-schema files nothing references via orphans(graph), capped/paginated with a total. +- [ ] #4 Orphan/broken-ref completeness is guaranteed by a whole-project build scope (every file as an entry point per query.ts), OR the output explicitly labels its scope; results are never silently under-reported. +- [ ] #5 Non-misleading: a cold/unbuildable graph degrades gracefully (never a false 'all clean'); scope is always stated. +- [ ] #6 Convention-free per ADR 004: detection uses only edges + reachability; no commands/queries CRUD logic (that lives in project_map's overlay). +- [ ] #7 Reuse only: missingTargets + orphans from the graph query API; no re-derivation in the supervisor. +- [ ] #8 TDD + comprehensive tests on a fixture with known broken refs + orphans (grouping, totals, scope label, graceful degradation); all suites + direct-tsc + format:check + frozen-lockfile green. +- [ ] #9 Docs: SUPERVISOR-GRAPH-INTEGRATION.md documents validate_project's contract and its place in the three-tool shape. +- [ ] #10 REPAIR ORDER: validate_project returns broken files in reverse-dependency (topological) order — a file's broken dependencies before the file — so the LLM fixes foundations first; independent broken files are a separate parallel bucket (no false sequence). +- [ ] #11 Repair order annotates each broken file with its dependents_count (fan-in / unblock impact) and its broken dependencies (what it sits above); highest-fan-in breaks ties among ready-to-fix files. +- [ ] #12 Dependency cycles among broken files are reported as 'fix-together' groups (never dropped or arbitrarily ordered without a note). +- [ ] #13 The topological ordering is a new query in platformos-graph (e.g. repairOrder over the induced broken-file subgraph), consumed by validate_project — no sort/graph logic re-implemented in the supervisor (ADR 003). +- [ ] #14 Repair order is framed as a STRUCTURAL suggestion (fix order to avoid repairing against a broken foundation), NOT a causal 'fixing X clears Y' claim; wording is non-misleading. +- [ ] #15 TDD: repairOrder graph query tested (linear chain, diamond/fan-in, cycle, disconnected/independent nodes) and validate_project's repair-order output tested on a fixture with interdependent broken files. + + +## Implementation Plan + + +REPAIR-ORDER (fix-ordering) — the actionable core of validate_project. + +WHY. A flat list of broken files is the wrong deliverable for a repair loop. Bugs propagate UP the dependency edges: the ROOT CAUSE is downstream (the depended-on file), the SYMPTOMS appear upstream (its dependents). So the LLM must fix FOUNDATIONS BEFORE DEPENDENTS — otherwise it repairs a file against a still-broken dependency and has to redo it. + +RULE. Order the broken files in REVERSE-DEPENDENCY (topological) order: a file's broken dependencies come before the file itself. (Edge A→B = "A depends on B" ⇒ emit B before A.) Example: get_user.graphql (broken) ← auth.liquid calls it ← page renders a partial that calls auth ⇒ repair order = get_user → auth → page. + +REFINEMENTS: +1. Topological order, dependencies-first (foundations at the front). +2. FAN-IN priority as tie-breaker: among files with no remaining broken dependency, surface highest-fan-in first (a broken file 106 others depend on unblocks the most). Annotate each with dependents_count ("fix first — N files depend on it"). +3. CYCLES: platformOS partials can form include cycles; a strict topo-sort fails on them. Report cyclic broken files as a "fix together" GROUP (do not drop or order arbitrarily without a note). + +HONESTY (non-misleading): this is a STRUCTURAL ordering — "fix in this order so you don't repair against a broken foundation" — NOT a causal claim that fixing a dependency auto-clears its dependents (a dependent may have independent errors). Frame as *suggested order*. + +SCOPE: order the SUBGRAPH of broken files (from missingTargets + the project lint pass) connected by dependency edges. Independent broken files carry no ordering constraint → present as parallel/any-order (a separate bucket), not forced into a false sequence. + +GRAPH-SIDE (ADR 003 — structure logic lives in platformos-graph, not the supervisor): +- Add a query, e.g. `repairOrder(graph, brokenUris): { order: UriString[][] , cycles: UriString[][] }` (levels or a flat topo order + cycle groups), computed over the induced subgraph of brokenUris + dependency edges, reusing the existing edge model / dependenciesOf. validate_project consumes it and shapes output; it does NOT re-implement the sort. + +OUTPUT (illustrative, bounded/grouped): +"repair_order": [ + { "file": "app/graphql/users/get_short.graphql", "errors": 2, "dependents": 106, "blocks": ["app/lib/functions/auth.liquid", ...] }, + { "file": "app/lib/functions/auth.liquid", "errors": 1, "dependents": 40, "depends_on_broken": ["app/graphql/users/get_short.graphql"] }, + ... +], +"repair_cycles": [ ["a.liquid","b.liquid"] ], // fix-together groups +"independent": [ ... ] // broken files with no broken-dependency relationship +- Bounded/capped like the rest of the tool; totals given. + diff --git "a/.backlog/tasks/task-9.13 - Memoize-getApp-in-check-node-\342\200\224-kill-the-per-call-whole-project-re-parse.md" "b/.backlog/tasks/task-9.13 - Memoize-getApp-in-check-node-\342\200\224-kill-the-per-call-whole-project-re-parse.md" new file mode 100644 index 00000000..d856f943 --- /dev/null +++ "b/.backlog/tasks/task-9.13 - Memoize-getApp-in-check-node-\342\200\224-kill-the-per-call-whole-project-re-parse.md" @@ -0,0 +1,95 @@ +--- +id: TASK-9.13 +title: Memoize getApp in check-node — kill the per-call whole-project re-parse +status: Done +assignee: + - Filip +created_date: '2026-07-02 06:45' +updated_date: '2026-07-02 08:16' +labels: + - platformos-check-node + - performance + - mcp-supervisor +dependencies: [] +references: + - packages/platformos-check-node/src/index.ts + - SUPERVISOR-GRAPH-INTEGRATION.md +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +WHY. The dominant per-call latency of `validate_code` is NOT the graph — it is check-node's `getApp` (inside `lintBuffer`), which globs and PARSES the entire project on EVERY call, with no memoization. On a ~1,500-node real project this is multiple seconds per `validate_code`. It dwarfs the graph work (the ~400ms fingerprint scan is only "hidden" because it runs concurrently behind this). Fixing it is the single biggest per-call speedup and benefits ALL of validate_code, not just the graph path. + +WHAT. Memoize the parsed `App` (+ config) per project root in `getAppAndConfig`/`getApp`, so repeated `lintBuffer` calls reuse the parsed project and only overlay + re-parse the in-flight buffer. Invalidate correctly: the cache must NEVER lint against a stale project (mirrors the supervisor's never-stale mandate) — reconcile via a cheap per-file fingerprint (mtime:size) or fs-change signal, re-parsing only changed files. A `.platformos-check.yml` change invalidates config. + +CONSTRAINTS. +- check-node is a shared package (LSP-adjacent consumers). Keep the change additive/opt-in or transparently safe; do not alter `lintBuffer`'s contract. +- Correctness first: a stale lint is as misleading as a stale graph. Fingerprint/track invalidation; when in doubt, re-read. +- This coordinates with TASK-9.14 (the graph cache also fingerprints the same files) — consider a shared project-scan/fingerprint primitive so lint and graph do not each walk the tree independently. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [x] #1 Repeated lintBuffer calls for the same project reuse a memoized parsed App instead of re-globbing + re-parsing the whole project each call (verified: N calls do not trigger N full project parses). +- [x] #2 Only the overlaid in-flight buffer is (re)parsed per call; unchanged project files are reused. +- [x] #3 Never stale: a changed project file (or .platformos-check.yml) is detected and reconciled (changed files re-parsed / config reloaded) before linting — a cheap fingerprint or fs signal, never a stale parse. +- [x] #4 lintBuffer's public contract is unchanged for existing consumers (LSP/CLI); the memoization is transparent or opt-in. +- [x] #5 Measured per-call latency drop on the real ~1,500-node project (before/after recorded). +- [x] #6 TDD: cache reuse, buffer-overlay correctness, invalidation on file/config change; check-node suite + type-check + format green. +- [x] #7 Coordinate with TASK-9.14: evaluate a shared project-scan/fingerprint primitive so lint and the graph cache do not each walk the tree. + + +## Implementation Plan + + +MEASURED (real marketplace-dcra): getApp globs+parses 2,869 files in ~84s COLD. This is the dominant per-call cost of validate_code (dwarfs the graph's ~400ms). Confirms the task. (lintApp/coreCheck also runs whole-project per call but couldn't be cleanly measured — it pulls the docs manager/network; scoped OUT as a potential follow-up: scope checks to the buffer file.) + +DESIGN DECISION: OPT-IN cache (safest for a shared package). getApp stays byte-identical for existing consumers (CLI appCheckRun/check, backfill-docs) unless a cache is passed — zero risk to them, no global mutable state, no mtime-race in their test loops. The supervisor opts in. + +APPROACH (check-node): +- New `AppCache` class (opt-in, caller-held): keyed by file uri → { fingerprint (mtime:size), source: parsed SourceCode }. Exposes internal get/set/prune used by getApp; caller persists one per project. +- `getApp(config, cache?)`: glob+filter UNCHANGED (current file set, applies current config's ignore filter — so config-driven set changes are captured each call). If no cache → today's behavior (parse all). If cache → reconcile: stat each path → mtime:size; REUSE cached parsed SourceCode when the fingerprint matches; re-parse only changed/new; prune removed. Returns app in the same (paths) order. +- Thread cache: `getAppAndConfig(root, configPath?, cache?)` → getApp(config, cache); `LintBufferParams` gains optional `cache?: AppCache`; lintBuffer passes it. All additive/optional. +- NEVER STALE: per-file mtime:size fingerprint; config loaded fresh each call (checks always current); the filter re-runs each call so add/remove/config-set changes are reconciled. When a fingerprint matches, the parsed AST is reused (the win); when it moves, re-parse. Export the fingerprint helper so the supervisor GraphCache (9.15) can adopt one shared node-level scan primitive (AC #7). + +APPROACH (supervisor): +- SupervisorContext gains `appCache: AppCache` (created per server in startServer, sibling to graphCache). +- lint adapter: `runLint(params, appCache)` passes cache to lintBuffer; runValidateCode passes ctx.appCache; the injectable ValidateCodeAdapters.lint signature gains the cache arg (fakes ignore it). Symmetric with runImpact(params, graphCache). + +TDD: check-node AppCache/getApp tests on a small fixture — reuse (unchanged files return the SAME SourceCode instance = not re-parsed), invalidation on modify (new instance) / add / remove, config-filter change, no-cache path unchanged. Supervisor: lint adapter still green with the cache threaded; validate-code orchestration unchanged. MEASURE getApp cold (~84s) vs warm-with-cache (expect glob+stat only, seconds) on the real project for AC #5. Then check-node + supervisor suites + tsc + format + frozen-lockfile. + + +## Implementation Notes + + +IMPLEMENTED (opt-in cache, safest for a shared package). check-node: new `AppCache` class + exported `fileFingerprint(path)` (mtime:size) + `AppSourceCode` alias. `getApp(config, cache?)` — extracted glob+filter into `getAppFilePaths`; with a cache it reconciles (stat each path, REUSE parsed SourceCode when fingerprint matches, re-parse changed/new, prune removed); WITHOUT a cache it is byte-identical to before (parse all). Threaded `cache?` through `getAppAndConfig` + `LintBufferParams` + `lintBuffer`. All additive/optional — CLI (appCheckRun/check), backfill-docs, and the LSP (which doesn't import check-node) are unaffected. + +SUPERVISOR: SupervisorContext gains `appCache` (one `new AppCache()` per server in startServer, sibling to graphCache); runLint(params, cache?) passes it to lintBuffer; runValidateCode passes ctx.appCache to the lint adapter (symmetric with runImpact(params, graphCache)). + +MEASURED (real marketplace-dcra, 2,869 files): cold getApp (parse all) 157s → warm getApp (cache) 5.4s, with 2869/2869 instances REUSED (zero re-parse). ~29x on the warm path; correct (never stale). The residual 5.4s is the glob+stat reconciliation scan — further reducible via the scoped-walk / shared-scan primitive (TASK-9.15 P3). + +SHARED PRIMITIVE (AC#7): exported `fileFingerprint` from check-node so the supervisor GraphCache (TASK-9.15) can adopt ONE node-level fingerprint definition instead of its own node:fs stat. + +VERIFICATION: check-node 105 green (incl. 6 new app-cache.spec: reuse-by-identity, modify/add/remove invalidation, no-cache-path-unchanged, never-stale-via-lintBuffer); supervisor 55 green; LSP 467 (466 under parallel load = the documented TypeSystem flake, unrelated — LSP doesn't import check-node); direct tsc clean (check-node + supervisor); format:check clean; frozen-lockfile clean, zero churn; check-node dist rebuilt. + +FOLLOW-UP (out of scope, recommend a task): lintApp/coreCheck still runs ALL checks over the WHOLE project every call (then lintBuffer filters to the buffer's file). getApp memoization removes the PARSE cost but not the CHECK cost. If coreCheck proves significant after this, scope checks to the buffer file (+ cross-file context) — a check-common coreCheck API change. Couldn't be cleanly measured (pulls the docs manager/network). + + +## Final Summary + + +Killed the dominant per-call cost of validate_code: check-node's getApp re-parses the whole project on every lintBuffer call (measured ~157s cold on the real 2,869-file marketplace-dcra). Added an OPT-IN, never-stale AppCache so repeated calls reuse the parsed project and only changed files re-parse. + +check-node: new AppCache class + exported fileFingerprint (mtime:size) + AppSourceCode alias. getApp(config, cache?) reconciles against the cache (reuse-by-fingerprint, re-parse changed/new, prune removed) when a cache is passed, and is byte-identical to before when not. Threaded cache? through getAppAndConfig / LintBufferParams / lintBuffer — all additive/optional, so CLI, backfill, and the LSP are unaffected. Never stale: reuse gated on per-file fingerprint; file set + config filter re-evaluated every call. + +supervisor: SupervisorContext gains a per-server appCache; the lint adapter and runValidateCode thread it (symmetric with the graph cache). + +Measured: cold getApp 157s -> warm 5.4s with 2869/2869 instances reused (zero re-parse). TDD: 6 new check-node app-cache tests (reuse-by-identity, modify/add/remove invalidation, no-cache path unchanged, never-stale via lintBuffer) + supervisor wiring. check-node 105, supervisor 55, LSP 467 (the 1 fail is the known TypeSystem parallel-load flake; LSP doesn't import check-node); tsc + format + frozen-lockfile clean, zero lockfile churn; dist rebuilt. + +Exported fileFingerprint as the shared scan primitive for TASK-9.15 to adopt. Follow-up recommended: coreCheck still lints the whole project per call (parse fixed, check not) — scope checks to the buffer file if it proves significant. + diff --git a/.backlog/tasks/task-9.14 - Incremental-graph-update-API-in-platformos-graph-applyFileChange.md b/.backlog/tasks/task-9.14 - Incremental-graph-update-API-in-platformos-graph-applyFileChange.md new file mode 100644 index 00000000..2d34efe6 --- /dev/null +++ b/.backlog/tasks/task-9.14 - Incremental-graph-update-API-in-platformos-graph-applyFileChange.md @@ -0,0 +1,93 @@ +--- +id: TASK-9.14 +title: Incremental graph update API in platformos-graph (applyFileChange) +status: Done +assignee: + - Filip +created_date: '2026-07-02 06:45' +updated_date: '2026-07-02 08:49' +labels: + - platformos-graph + - performance + - architecture +dependencies: [] +references: + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-graph/src/graph/build.ts + - packages/platformos-graph/src/graph/module.ts + - packages/platformos-graph/src/graph/query.ts +modified_files: + - packages/platformos-graph/src/graph/incremental.ts + - packages/platformos-graph/src/graph/incremental.spec.ts + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-graph/src/index.ts +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +WHY. The graph cache currently does a FULL rebuild (~22s on a 1,500-node project) on ANY change, so blast-radius goes `computing` for ~22s after every file write. The graph is "embarrassingly incremental": a file's OUTGOING edges depend ONLY on that file's own content (parse it → its render/include/function/graphql/asset/layout edges; no cross-file type inference). So a change can be applied in O(edges of the changed file), not O(project). This is the enabler for an always-fresh, always-instant graph. + +WHAT. A pure incremental-update API in platformos-graph (structure logic lives here per ADR 003), consumed by the supervisor cache (TASK-9.14): + +`applyFileChange(graph, uri, kind: 'modified' | 'added' | 'deleted', deps): Promise` (or a functional variant returning a new graph), that mutates/derives the graph WITHOUT a full rebuild: +- MODIFIED: re-parse only `uri` (reuse `resolveLiquidReferences`), diff its outgoing edges, and patch the reverse index — remove the file's OLD `F→*` edges from each old target's `references`, add the new ones; replace the module's `dependencies`. +- ADDED: create/flip the node to `exists: true`, resolve + add its own outgoing edges. Incoming edges that already point at this URI resolve automatically (edges are keyed by canonical target URI; `exists` is a node property) — no rewiring needed. +- DELETED: set the node `exists: false`, remove its OUTGOING edges from targets' `references`; incoming edges become "missing" automatically. + +This mirrors what `extractFileReferences` already does per file, plus reverse-index maintenance. Reuse `resolveLiquidReferences` / the module factories; do not fork resolution. + +CORRECTNESS (load-bearing — a wrong incremental result misleads the agent, which is the cardinal sin): the result of a sequence of `applyFileChange`s MUST equal a from-scratch `buildAppGraph`. This is the core invariant to test exhaustively. Consumers keep a fingerprint authority + full-rebuild fallback (TASK-9.14), but the API itself must be provably correct. + +PROVEN PRECEDENT (we apply, not invent): rust-analyzer/Salsa (demand-driven incremental), LSP didChangeWatchedFiles, TypeScript --incremental, module-bundler HMR graphs. Ours is simpler (per-file syntactic edges) so a hand-rolled apply suffices — no Salsa framework needed. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [x] #1 `applyFileChange(graph, uri, kind, deps)` exists in platformos-graph and updates the in-memory graph for modified/added/deleted WITHOUT a full rebuild, reusing resolveLiquidReferences + the module factories (no forked resolution). +- [x] #2 MODIFIED re-parses only the changed file, diffs its outgoing edges, and correctly patches the reverse index (targets' `references`) — added/removed edges reflected, unchanged ones preserved. +- [x] #3 ADDED flips the node to exists:true and its own edges are added; incoming edges that referenced the now-existing URI resolve (exists) without rewiring. +- [x] #4 DELETED sets exists:false and removes the file's outgoing edges from targets; incoming edges become missing. +- [x] #5 CORE INVARIANT (exhaustively tested): applying any sequence of changes yields a graph deep-equal to a from-scratch buildAppGraph of the same final disk state (add, modify, delete, add-then-reference, delete-referenced, rename=delete+add, cyclic edges, self-reference). +- [x] #6 Complexity is O(edges of the changed file), not O(project) — verified (no whole-project re-parse in applyFileChange). +- [x] #7 TDD: unit tests per change kind + the equivalence-to-full-build property; graph suite + type-check + format green; no regression to buildAppGraph/query API. +- [x] #8 Exported from the package index; documented (incl. the exists-flip elegance and the equivalence invariant). + + +## Implementation Plan + + +REFINED ALGORITHM (after reading traverse/augment/module/query internals). + +EDGE STORAGE: bind() creates ONE Reference object pushed to BOTH source.dependencies and target.references (shared instance). Removing F's edges from a target T = T.references.filter(r => r.source.uri !== F.uri). Module nodes are deduped in a per-graph ModuleCache (WeakMap); graph.modules[uri] === the cached object; factories return that same object (existing) or a fresh one (new). + +FRESH READ: augmentDependencies MEMOIZES getSourceCode, so applyFileChange must augment FRESH per call (like buildAppGraph) to re-read the changed file. It takes raw IDependencies and augments internally. + +MODIFY = removeFile + addFile (DRY, and provably equivalent). +- removeFile(graph, uri): node=graph.modules[uri]; drop node's outgoing edges from each target's references; GC each target that is now unreachable (`!isEntryPoint(graph,target) && target.references.length===0` — i.e. a reached-only leaf like graphql/asset that a full build would omit; liquid files are entry points so never GC'd, and leaves have no outgoing edges so no cascade); clear node.dependencies; remove uri from graph.entryPoints; then if node.references is empty delete graph.modules[uri] entirely, else set node.exists=false (a still-referenced missing target). +- addFile(graph, uri, deps, options): node = graph.modules[uri] ?? getModule(graph,uri) (undefined → not a graph-classifiable file → no-op); set node.exists via exists(fs,uri); add to graph.modules; add to graph.entryPoints if a liquid entry-point kind and absent; resolveLiquidReferences(fresh read) → for each ref: materializeTarget (if target absent: add to modules + set exists + set table for graphql leaves) then bind(node,target,{range,kind,args}). Incoming edges to a previously-missing F resolve automatically once node.exists flips true (edges key on target.uri; exists is a node property). + +PRECONDITION (documented + asserted by the equivalence test's build mode): the graph is fully materialized — built with all source files as entry points (the supervisor cache's mode). Then every liquid target is already a node; only leaf targets (graphql/asset) are materialized on demand / GC'd, so no traversal recursion is needed. + +EQUIVALENCE INVARIANT (AC#5): serializeAppGraph(incremental) deep-equals serializeAppGraph(buildAppGraph(finalDisk, allLiquidEntryPoints)) with nodes+edges sorted. serialize covers nodes{uri,type,kind,exists}+edges{Reference} — the correctness surface. entryPoints is not serialized but is maintained anyway for future query consumers. + +REUSE: export resolveLiquidReferences (+ ResolvedReference) from traverse.ts; reuse bind, getModule, exists, extractGraphqlTable, augmentDependencies, isEntryPoint. New file src/graph/incremental.ts holds applyFileChange + removeFile/addFile/materializeTarget. Export applyFileChange from the package index. + +TESTS (TDD, property-based invariant as guardrail): a temp project (node fs) built all-liquid; enumerate scenarios — modify(add edge to existing/new graphql/asset/partial), modify(remove last edge → GC), modify(remove one of several), add(new file, and file with pre-existing incoming placeholder → exists-flip preserves refs), delete(referenced → exists:false kept; unreferenced → node removed), rename=delete+add, self-reference, cycle A↔B; after each, assert serialize(incremental)==serialize(fresh buildAppGraph). Plus targeted unit assertions on dependentsOf after each kind. + + +## Final Summary + + +Added `applyFileChange(graph, uri, kind, deps, options?)` to platformos-graph (new src/graph/incremental.ts; exported from the package index alongside the `FileChangeKind` type) — an in-place incremental graph update that avoids a full rebuild on every file change (the ~22s-per-write bottleneck behind blast-radius going `computing`). Complexity is O(edges of the changed file). + +Reuses the exact build seams (resolveLiquidReferences — now exported from traverse.ts — plus bind, getModule, isEntryPoint, augmentDependencies, extractGraphqlTable/extractSchemaTable), so resolution can never drift from buildAppGraph. modify = removeFile + addFile: removeFile detaches the file's outgoing edges from each target's reverse index, garbage-collects any target that becomes an unreachable non-entry-point reached-only leaf, drops the file from entryPoints, and removes the node unless still referenced (kept exists:false); addFile materializes/refreshes the node, registers edge-source liquid files as entry points, and resolves+binds outgoing edges (materializing newly-reached leaves with their table fact). Incoming edges to an added/deleted file resolve automatically via the exists flag — no rewiring. Dependencies are augmented fresh per call so the changed file is re-read (the default getSourceCode memoizes), guaranteeing never-stale results. + +Load-bearing guardrail (incremental.spec.ts, 12 tests): expectEquivalentToFullBuild mutates a real temp project on disk, applies the change, and asserts serializeAppGraph(incremental) canonically equals a fresh buildAppGraph over all edge-source liquid entry points — across MODIFIED (add edge / GC leaf / orphan-partial-kept / newly-reached leaf table), ADDED (new entry point / missing-target exists-flip), DELETED (referenced→missing kept / unreferenced→removed + leaf GC), self-reference, cycle, mixed sequence, and unmodeled-file no-op. + +Verification: full graph suite 96/96 green; tsc --noEmit clean; prettier clean; yarn build:ts emits incremental.{js,d.ts,js.map}. Consumer wiring (GraphCache warm/incremental apply + persistence + fs.watch) is TASK-9.15. + diff --git "a/.backlog/tasks/task-9.15 - Warm-incremental-persisted-GraphCache-\342\200\224-always-fresh-blast-radius-at-best-performance.md" "b/.backlog/tasks/task-9.15 - Warm-incremental-persisted-GraphCache-\342\200\224-always-fresh-blast-radius-at-best-performance.md" new file mode 100644 index 00000000..7bf3f482 --- /dev/null +++ "b/.backlog/tasks/task-9.15 - Warm-incremental-persisted-GraphCache-\342\200\224-always-fresh-blast-radius-at-best-performance.md" @@ -0,0 +1,122 @@ +--- +id: TASK-9.15 +title: >- + Warm, incremental, persisted GraphCache — always-fresh blast radius at best + performance +status: Done +assignee: + - '@Filip' +created_date: '2026-07-02 06:46' +updated_date: '2026-07-03 07:39' +labels: + - mcp-supervisor + - platformos-graph + - performance + - architecture +dependencies: + - TASK-9.14 +references: + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts + - packages/platformos-graph/src/graph/serialize.ts + - >- + docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md + - SUPERVISOR-GRAPH-INTEGRATION.md +modified_files: + - packages/platformos-graph/src/graph/incremental.ts + - packages/platformos-graph/src/graph/deserialize.ts + - packages/platformos-graph/src/graph/deserialize.spec.ts + - packages/platformos-graph/src/graph/module.ts + - packages/platformos-graph/src/utils/index.ts + - packages/platformos-graph/src/index.ts + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.spec.ts + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.ts + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache-store.spec.ts + - packages/platformos-mcp-supervisor/src/transport/server.ts + - SUPERVISOR-GRAPH-INTEGRATION.md +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +WHY. TASK-9.10 shipped a correct, never-stale GraphCache, but it is not performance-optimal: (a) it FULL-rebuilds (~22s on 1,500 nodes) on ANY change, so blast-radius is `computing` for ~22s after every file write; (b) it fingerprint-scans (~400ms) on every request; (c) cold start is a full ~22s build. This task makes the graph ALWAYS fresh AND fast, using proven incremental-computation + persistence techniques (rust-analyzer/Salsa, LSP didChangeWatchedFiles, TS --incremental, bundler HMR) — we apply, not invent. + +The never-stale mandate is preserved throughout: the fingerprint remains the AUTHORITY; incremental apply is driven by the fingerprint diff; any detected inconsistency falls back to a full rebuild. Watch = speed; fingerprint = truth. + +PHASES (each shippable; ordered by ROI): + +PHASE 1 — Incremental apply (biggest win, lowest risk; needs TASK-9.14). +Replace full-rebuild-on-change with: on a request, compute the fingerprint, DIFF it against the built graph's fingerprint → the set of changed/added/deleted files, and apply them via `applyFileChange` (O(changed files), ~ms) — then serve FRESH IMMEDIATELY. This eliminates BOTH the 22s rebuild and the `computing`-after-write gap: blast-radius becomes always-fresh-and-instant for edits. (Subsumes the bounded-await idea: small changes apply synchronously, so there is no `computing` for them; only the very first cold build has no prior graph → Phase 2.) Keep a periodic/where-inconsistent full-rebuild reconciliation as the correctness backstop. + +PHASE 2 — Persistence (geoid-style cold start). +Serialize the built graph (`serializeAppGraph`) + its per-file fingerprints to a cache file on disk. On server start, LOAD it and incrementally reconcile the delta (apply changes for files whose fingerprint moved) instead of a 22s cold build. Persist the derived MODEL (the graph), not the ASTs — the graph is the compact summary (the "geoid grid"); retrieval is the existing O(1) query API. Version the cache format; invalidate on version/root mismatch; the fingerprint still gates correctness after load. + +PHASE 3 — Background freshness + scoped walk (removes the residual per-request cost). +Add fs.watch to keep the graph fresh in the BACKGROUND (incremental apply on watch events) so the request path does no scan at all; keep the fingerprint reconciliation as the safety net for missed/unsupported watch events (network FS, inotify limits, macOS quirks) — NEVER trust the watcher alone. Also scope the file walk (fingerprint + build enumeration) to platformOS dirs (app/ + modules/*/public|private) instead of the whole tree (cuts the ~400ms; today it walks react-app/ etc.). Consider sharing one project-scan/fingerprint primitive with TASK-9.13 (lint) so the tree is walked once. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [x] #1 PHASE 1: on a fingerprint mismatch the cache applies only the changed files via platformos-graph `applyFileChange` (no full rebuild) and serves a FRESH graph immediately; blast-radius is no longer `computing` after a single-file write (test: write a file, next call is `computed` with updated dependents, with no full rebuild). +- [x] #2 PHASE 1: the incremental-apply result equals a from-scratch build for the same disk state (leans on TASK-9.14's invariant); a consistency/backstop path triggers a full rebuild if incremental state is ever detected inconsistent. +- [x] #3 PHASE 1: never stale preserved — the fingerprint remains authoritative; a change is never served against the old graph. +- [x] #4 PHASE 2: the graph + per-file fingerprints persist to a versioned cache file; on start the cache LOADS + incrementally reconciles the delta instead of a full cold build (measured cold-start improvement recorded). +- [x] #5 PHASE 2: cache file is invalidated on format-version / root mismatch; the fingerprint still gates correctness post-load (a corrupt/stale cache never yields a wrong answer — falls back to rebuild). +- [ ] #6 PHASE 3: fs.watch keeps the graph fresh in the background (incremental apply on events) so the request path performs no per-call full scan on the steady state; the fingerprint reconciliation remains as the safety net for missed/unsupported watch events (never trusts the watcher alone). +- [x] #7 PHASE 3: the file walk (fingerprint + build enumeration) is scoped to platformOS dirs, not the whole tree; measured warm-path cost drop recorded; shared scan primitive with TASK-9.13 evaluated. +- [x] #8 Measured on the real ~1,500-node project: before/after for cold start, post-write freshness latency, and steady-state per-call cost. +- [ ] #9 TDD across phases: incremental-apply-via-diff correctness + never-stale, persistence load/reconcile/invalidate, watch + safety-net reconciliation; supervisor + graph suites + type-check + format + frozen-lockfile green. +- [ ] #10 Docs: SUPERVISOR-GRAPH-INTEGRATION.md + ADR updated with the final freshness architecture (incremental + persisted + watched, fingerprint-authoritative). + + +## Implementation Notes + + +PHASE 1 DONE (incremental apply via fingerprint diff). packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts: lookup() now, when a graph already exists and the fingerprint moved, DIFFS built.fingerprint→current (diffFingerprints → added/modified/deleted) and applies only the changed files via the new applyChange seam (default = platformos-graph applyFileChange), then serves the updated graph IMMEDIATELY — no full rebuild, no `recomputing` gap after a write. Reconciliations are serialized on a reconcileChain promise so concurrent lookups can't interleave mutations of the shared graph (each queued applyDiff re-checks equality and no-ops if caught up). If incremental apply throws, fallbackToRebuild() discards the graph and triggers a full background rebuild — a half-applied graph is never served (AC#2 backstop). Cold start (no prior graph) still background-builds (Phase 2 will load a persisted graph there). Fingerprint remains the authority; built.fingerprint is set to the diffed target after apply (AC#3). settle() now also drains reconcileChain. + +TESTS (graph-cache.spec.ts, 10): incremental-serve-no-rebuild, added/modified/deleted diff order, apply-failure→rebuild fallback, concurrent-reconcile-no-double-apply; the real-fs integration test now asserts the edited caller's dependent appears IMMEDIATELY via real applyFileChange (no recomputing). VERIFY: supervisor+graph suites 154/154 green (incl. stdio smoke), tsc --noEmit clean, prettier clean. + +STILL TODO: Phase 2 (persist serializeAppGraph + fingerprints to a versioned cache file; load+reconcile on cold start) — AC#4,#5. Phase 3 (fs.watch background freshness + safety-net reconciliation; scope the walk to platformOS dirs; shared scan primitive with 9.13) — AC#6,#7. AC#8 measurements, AC#10 docs. Paused for approval before Phase 2 per phased-execution rule. + +PHASE 2 DONE (persistence — warm cold start). New platformos-graph/src/graph/deserialize.ts exports deserializeAppGraph (inverse of serializeAppGraph): rebuilds modules+edges AND seeds the module identity cache (via new exported internModule in module.ts) so a loaded graph reconciles via applyFileChange EXACTLY like a fresh build; entry points restored from persisted URIs; dangling edges skipped defensively. (assertNever return type corrected to `never` to type the exhaustive switch.) deserialize.spec.ts (5): round-trip identity, reverse-index queryable, restored-graph-reconciles-like-full-build (proves seeding), added/deleted on restored graph, dangling-edge/no-entry-points guard. + +New supervisor graph-cache-store.ts: versioned cache format (CACHE_FORMAT_VERSION=1) with encodeCacheFile/decodeCacheFile; decode is defensive — unparseable/wrong-version/wrong-root/structurally-invalid → null (never throws). graph-cache-store.spec.ts (4): round-trip, version-bump→null, root-mismatch→null, corrupt/missing-fields→null. + +graph-cache.ts: cold lookup now hydrate()s — first cold attempt tries the persisted cache (load → built with the PERSISTED fingerprint, so the next lookup reconciles the on-disk delta) and only full-builds if no usable cache; persists (off request path, coalesced via persistScheduled/persistChain, atomic temp+rename) after each build AND reconcile. cachePath option (+ readCacheFile/writeCacheFile seams); defaultGraphCachePath(rootUri) = /platformos-mcp-supervisor/graph-.json; server.ts wires it. graph-cache.spec.ts +3 persistence integration tests (real fs+build): persist-after-build + warm-from-disk-no-rebuild, reconcile-delta-after-warm, corrupt→rebuild. + +MEASURED (real marketplace-dcra, 2170 nodes / 1921 entry points): cold FULL build 70,218 ms → WARM persisted load 37 ms (+delta reconcile) ≈ 1900×; serialize+stringify 48 ms (1.4 MiB). Post-write freshness: was ~22s `computing`, now immediate (Phase 1). AC#8 cold-start + post-write recorded in §10.2; steady-state per-call (fingerprint scan ~400ms) unchanged — Phase 3 scoped-walk target. + +VERIFY: supervisor+graph suites 166/166 green (incl. stdio smoke with persistence live), both packages tsc --noEmit clean, prettier clean, both dists rebuilt. Docs: SUPERVISOR-GRAPH-INTEGRATION.md §10 added + task ledger updated. + +STILL TODO (Phase 3): fs.watch background freshness + fingerprint safety-net (AC#6); scope the walk to platformOS dirs + shared scan primitive with 9.13 (AC#7); complete AC#8 steady-state metric + AC#9 watch tests + AC#10 'watched' architecture doc. Paused for approval before Phase 3. + +PHASE 3A DONE (scoped source walk). graph-cache.ts: the fingerprint enumeration (and, via fingerprint keys, the build entry points) now walks only the platformOS source roots — SOURCE_ROOTS = app/, marketplace_builder/, modules/ — via new enumerateEdgeSources() (Promise.all of recursiveReadDirectory per root, joined), instead of the whole project tree from rootUri. Per the file-type classifier these are the ONLY places a Page/Layout/Partial can live (incl. nested app/modules//... reached via app/), so the enumerated set is provably identical, only cheaper. Never-stale preserved (the set the graph is built/reconciled from is unchanged). + +TESTS (graph-cache.spec.ts +2, real fs): (1) enumerates edge sources across app/ + marketplace_builder/ + modules/ + nested app/modules/ — asserts the EXACT entry-point set captured via the buildGraph seam (whole-value); (2) never walks non-platformOS subtrees — a decoy react-app/src/.../Widget.liquid is never read (readDirectory spy: no /react-app, project root itself never read) and is not a graph node. + +MEASURED (marketplace-dcra, 1921 edge sources, warm medians of 5): directory walk 769ms → 175ms (4.4×); full fingerprint (walk + stat) 541ms → 320ms (1.7×; per-file stat is the irreducible remainder). Enumerated set BYTE-IDENTICAL to the whole-tree walk (1921 = 1921, zero diff either direction) — proves the scoping loses no real source. + +SHARED-SCAN-WITH-9.13 EVALUATED, DECLINED: lint's getAppFilePaths uses node `glob` in check-node (browser-incompatible) over all 4 source types; the graph cache uses the browser-safe AbstractFileSystem walk over edge-source liquid only — different mechanism, domain, and package boundary. Coupling them buys nothing the scoped walk didn't already; not worth the entanglement. + +VERIFY: supervisor+graph suites 168/168 green (incl. stdio smoke), supervisor tsc --noEmit clean, prettier clean, supervisor dist rebuilt. Docs §10.2 updated (Phase 3A measured table + shared-scan evaluation) + ledger. + +3B (fs.watch) DEFERRED by decision: on the validate_code path the fingerprint scan runs concurrently with lint's multi-second parse, so it adds ≈0 wall-clock today — fs.watch would optimize an off-critical-path cost at the price of real platform-specific watcher complexity (recursive-watch gaps, inotify limits, atomic-save renames, network FS), and the never-stale mandate forces keeping the fingerprint scan as the authority/backstop regardless. Recommend closing 3B as won't-do-now unless profiling shows the scan on the critical path. AC#6 (fs.watch) intentionally left unchecked; AC#8 steady-state metric now recorded; AC#9/#10 done for the shipped phases. + + +## Final Summary + + +Shipped Phases 1, 2, and 3A of the warm/incremental/persisted GraphCache; verified end-to-end on the real project (marketplace-dcra, 2170 nodes). + +PHASE 1 — incremental apply: a fingerprint move on a built graph diffs the fingerprint and applies only changed files via platformos-graph applyFileChange (TASK-9.14), serving fresh immediately — no rebuild, no `computing` gap after a write. Serialized reconciles; apply-failure falls back to full rebuild; fingerprint stays authoritative. +PHASE 2 — persistence: versioned cache file (serializeAppGraph + deserializeAppGraph + per-file fingerprints); cold start LOADS + reconciles the delta instead of rebuilding. Measured: 62.7s cold build → 406ms warm load (0 rebuilds), 154×. Corrupt/wrong-version/wrong-root/dangling-edge cache → rejected → rebuild (never a wrong answer). +PHASE 3A — scoped walk: enumeration scoped to app/ + marketplace_builder/ + modules/ (SOURCE_ROOTS); measured byte-identical set to the whole-tree walk (1921=1921), walk 4.4× faster, full fingerprint 1.7×. + +AC#8 metrics recorded; §10 of SUPERVISOR-GRAPH-INTEGRATION.md documents the incremental+persisted+scoped architecture; graph + supervisor suites + type-check + format green throughout. Also folded in the later code-review fixes to this cache: reconcile never-stale concurrency guard + recompute-inside, fileFingerprint reuse (shared with check-node AppCache), persist coalescing, and decode referential-integrity. + +PHASE 3B (fs.watch background freshness, AC#6 + its watch-specific TDD/docs) was DEFERRED by decision — the fingerprint scan runs concurrent with lint so it adds ≈0 wall-clock today; not worth the platform-specific watcher complexity unless profiling shows the scan on the critical path. Split to TASK-9.20 so it is tracked. Closing on the three shipped phases. + diff --git a/.backlog/tasks/task-9.16 - Scope-lintBuffers-check-pass-to-the-buffer-file-coreCheck-runs-whole-project-per-call.md b/.backlog/tasks/task-9.16 - Scope-lintBuffers-check-pass-to-the-buffer-file-coreCheck-runs-whole-project-per-call.md new file mode 100644 index 00000000..a949fb28 --- /dev/null +++ b/.backlog/tasks/task-9.16 - Scope-lintBuffers-check-pass-to-the-buffer-file-coreCheck-runs-whole-project-per-call.md @@ -0,0 +1,53 @@ +--- +id: TASK-9.16 +title: >- + Scope lintBuffer's check pass to the buffer file (coreCheck runs whole-project + per call) +status: To Do +assignee: [] +created_date: '2026-07-02 08:26' +labels: + - platformos-check-common + - platformos-check-node + - performance + - mcp-supervisor + - spike +dependencies: [] +references: + - packages/platformos-check-node/src/index.ts + - packages/platformos-check-common/src/index.ts + - packages/platformos-check-common/src/checks/partial-call-arguments/index.ts +parent_task_id: TASK-9 +priority: medium +--- + +## Description + + +WHY. TASK-9.13 memoized `getApp`, so the whole-project PARSE is no longer redone per call. But `lintBuffer` still runs `coreCheck(app, config, …)` over the ENTIRE app (all ~2,869 files) on every call, then filters the offenses down to the buffer's file. So every `validate_code` still does a whole-project CHECK pass to obtain one file's diagnostics — likely the next-largest per-call cost after the (now-fixed) parse. + +INVESTIGATION-GATED. This task starts as a spike: first MEASURE the coreCheck pass cost per `lintBuffer` call on the real ~1,500-node project (isolate it from the docs-manager/network setup — the reason it couldn't be measured in 9.13). If coreCheck is NOT a material fraction of per-call latency once the parse is cached, CLOSE this as won't-do with the measurement recorded. Only proceed to implement if it is significant. + +WHAT (if warranted). Add an opt-in way to obtain offenses for a SINGLE target file using the app as cross-file CONTEXT, without running every check over every file — e.g. `check(app, config, deps, { only: [uri] })` in check-common, consumed by `lintBuffer`. The app is still needed as context (cross-file checks like `MissingPartial`/`PartialCallArguments` resolve targets against other files), but per-file checks should only execute for the target. + +CORRECTNESS (the hard part — investigate BEFORE building). The buffer-scoped offenses MUST equal the full-run offenses filtered to the buffer file. This requires auditing check-common's check model: +- Do any checks emit an offense on file A that is TRIGGERED by file B (i.e. offense location ≠ the visited file)? If so, naive "only run checks on the target" would miss or mislocate them. Enumerate such checks (onCodePathEnd/whole-app checks, cross-file emitters) and handle them. +- Determine whether coreCheck's architecture even supports per-file scoping cleanly, or whether it fundamentally visits all files. + +CONSTRAINTS. +- check-common is the DEEPEST shared package (LSP, browser, CLI all consume it). The change MUST be additive/opt-in — `check()`'s existing contract and whole-project behaviour unchanged. Do not regress the LSP/CLI. +- Reuse the existing check runner; do not fork a parallel check engine. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 SPIKE: measured coreCheck pass cost per lintBuffer call on the real ~1,500-node project (isolated from docs-manager setup), recorded in the task. Decision recorded: proceed vs close-as-won't-do based on whether it is a material per-call cost. +- [ ] #2 AUDIT: enumerate check-common checks that can emit an offense whose location is NOT the visited file (cross-file / whole-app emitters), documented — this bounds what buffer-scoping must preserve. +- [ ] #3 If warranted: an opt-in single-target check path (e.g. `{ only: [uri] }`) exists in check-common that returns offenses for the target file using the full app as cross-file context, WITHOUT running per-file checks over every file. +- [ ] #4 EQUIVALENCE INVARIANT (exhaustively tested): buffer-scoped offenses === full-run offenses filtered to the buffer file, across per-file checks AND cross-file checks (MissingPartial, PartialCallArguments, and any cross-file emitter found in the audit). +- [ ] #5 check()'s existing whole-project contract is unchanged; LSP/CLI/browser consumers unaffected (additive/opt-in). +- [ ] #6 lintBuffer uses the scoped path; measured per-call latency drop recorded. +- [ ] #7 TDD + comprehensive tests; check-common + check-node + supervisor suites + type-check + format + frozen-lockfile green. + diff --git "a/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" "b/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" new file mode 100644 index 00000000..d972b15b --- /dev/null +++ "b/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" @@ -0,0 +1,52 @@ +--- +id: TASK-9.17 +title: >- + Move edge-source enumeration + fingerprint domain into platformos-graph (ADR + 003 — remove supervisor's leaked graph-domain knowledge) +status: To Do +assignee: [] +created_date: '2026-07-02 13:03' +labels: + - platformos-graph + - mcp-supervisor + - architecture + - code-review +dependencies: [] +references: + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts + - packages/platformos-graph/src/graph/build.ts + - packages/platformos-common/src/path-utils.ts + - >- + docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md +parent_task_id: TASK-9 +priority: medium +--- + +## Description + + +WHY (code-review finding #8, altitude). The supervisor's GraphCache re-encodes graph-domain knowledge that ADR 003 says lives in platformos-graph: `SOURCE_ROOTS = ['app','marketplace_builder','modules']`, `isEdgeSource(uri) = isLayout||isPage||isPartial`, and `enumerateEdgeSources` (the scoped walk that produces BOTH the fingerprint domain AND the build's entry points). "Which files are edge sources" and "where platformOS sources live" is exactly the classification `getFileType`/`build.ts` already own. Nothing forces the supervisor's copy to agree with them. + +THE RISK (never-stale-adjacent): if a new source root or file-type convention is added to `getFileType` / `build.ts` (a new partial location, a new app root alias, layouts move), the supervisor's `SOURCE_ROOTS`/`isEdgeSource` silently diverge. The fingerprint scan then omits real edge sources → the graph is built with an incomplete entry-point set → blast-radius under-reports dependents. That is a wrong "safe to change" produced NOT by staleness but by an incomplete domain model — with no test forcing the two definitions to agree. This is the same class of bug the never-stale mandate exists to prevent. + +RELATED: code-review #5 (fingerprint format) is already resolved — the supervisor reuses check-node's exported `fileFingerprint`. This task is the remaining half: the edge-source SET + its enumeration. + +WHAT. Make platformos-graph (or check-common, wherever the classifiers live) the single owner of "enumerate the edge-source files under a project root", and have the supervisor GraphCache consume it instead of its own `SOURCE_ROOTS`/`isEdgeSource`/`enumerateEdgeSources`. Options to weigh during design: + - Export an `enumerateEdgeSources(fs, rootUri)` (or `edgeSourceFiles`) primitive from platformos-graph that reuses `isLayout/isPage/isPartial` + the canonical source-root knowledge, scoped to the platformOS dirs (preserving the TASK-9.15 Phase-3A scoped-walk win — set must stay byte-identical to the whole-tree walk on a real project). + - Consider whether `buildAppGraph`'s full-build discovery (entryPoints===undefined) and this scoped edge-source enumeration should share one internal walk, so there is ONE definition of "edge source" and "source root" in the graph package. + - Keep the supervisor's cache as a pure consumer: it calls the graph primitive for the file set, still fingerprints via check-node `fileFingerprint`, still owns caching/persistence/reconcile. + +GUARDRAIL. Add a test that pins the enumerated edge-source set against the file-type classifier (e.g. every enumerated URI is `isEdgeSource`, and a fixture covering app/, marketplace_builder/, modules/, app/modules/ nested) so the graph-owned definition cannot silently drift. Preserve the never-stale + scoped-walk guarantees (byte-identical set to the whole-tree walk). + +Working dir: ~/Work/platformos-tools/platformos-tools. NON-BLOCKING for the current PR — the shipped behavior is correct today; this is an architectural consolidation to prevent future drift. + + +## Acceptance Criteria + +- [ ] #1 platformos-graph (or check-common) exports a single canonical edge-source enumeration primitive that reuses isLayout/isPage/isPartial + the source-root knowledge; the supervisor GraphCache consumes it and no longer defines its own SOURCE_ROOTS / isEdgeSource / enumerateEdgeSources +- [ ] #2 The enumerated edge-source set stays byte-identical to today's on a real project (TASK-9.15 Phase-3A scoped-walk win preserved; verified on marketplace-dcra: 1921 files, zero diff vs whole-tree walk) +- [ ] #3 A test pins the enumerated set to the file-type classifier so the graph-owned 'edge source' + 'source root' definition cannot silently drift from getFileType/build.ts +- [ ] #4 Never-stale preserved: the fingerprint domain = the enumerated edge-source set = the build's entry points (one definition, one owner); supervisor still fingerprints via check-node fileFingerprint +- [ ] #5 Whether buildAppGraph's full-build discovery and the scoped edge-source enumeration can share one internal walk is evaluated (share or document why not) +- [ ] #6 graph + supervisor suites + type-check + format + frozen-lockfile green; no regression to buildAppGraph / GraphCache behavior + diff --git a/.backlog/tasks/task-9.18 - Graph-driven-cross-file-autofixes-did-you-mean-rename-move-propagation-signature-arg-propagation-safe-dead-code-deletion-repair-ordering.md b/.backlog/tasks/task-9.18 - Graph-driven-cross-file-autofixes-did-you-mean-rename-move-propagation-signature-arg-propagation-safe-dead-code-deletion-repair-ordering.md new file mode 100644 index 00000000..b73a8535 --- /dev/null +++ b/.backlog/tasks/task-9.18 - Graph-driven-cross-file-autofixes-did-you-mean-rename-move-propagation-signature-arg-propagation-safe-dead-code-deletion-repair-ordering.md @@ -0,0 +1,56 @@ +--- +id: TASK-9.18 +title: >- + Graph-driven cross-file autofixes (did-you-mean, rename/move propagation, + signature-arg propagation, safe dead-code deletion, repair-ordering) +status: To Do +assignee: [] +created_date: '2026-07-02 15:01' +labels: + - platformos-graph + - mcp-supervisor + - fixes + - autofix +dependencies: + - TASK-9.10 + - TASK-8.6 +references: + - packages/platformos-graph/src/graph/query.ts + - packages/platformos-mcp-supervisor/src/impact/impact.ts + - packages/platformos-mcp-supervisor/src/result/types.ts +parent_task_id: TASK-9 +priority: medium +--- + +## Description + + +WHY. Per-file lint fixes (TASK-8.6/8.3) are forward-looking and single-file. The graph is the ONLY source of CROSS-FILE reference knowledge, so it enables a class of fixes lint structurally cannot produce — the fix-side counterpart to blast radius. All the primitives already exist: `nearestModules` (Levenshtein over same-category modules), `dependentsOf` (incoming edges, each carrying the exact call-site `source.range`), `orphans`, `missingTargets`, and the topological repair-order (TASK-9.12). + +WHAT — five fix classes, each graph-derived, each producing precise text-edits (edges carry `source.range`, so edits target the exact call site): + 1. GRAPH-BACKED "did you mean?" — for `MissingPartial`/`MissingAsset`/`MissingGraphql`/missing `layout`, `nearestModules(target)` yields the closest REAL project module of the same category → a concrete replace-edit at the call-site range (`{% render 'crad' %}` → `'card'`). Stronger than a heuristic string match because candidates are the actual on-disk modules. (Supersedes/feeds TASK-8.3's did-you-mean, which is not graph-backed.) + 2. RENAME / MOVE propagation — given a rename `A → B`, `dependentsOf(A)` gives every caller + its `source.range` → one text-edit per caller updating the reference. A true multi-file refactor lint cannot do. + 3. SIGNATURE-ARG propagation — `signature_risk` (TASK-9.10) already identifies callers missing a required `@param` / passing an undeclared one, per call site → insert the missing arg / drop the stray one at each caller. + 4. SAFE dead-code deletion — `orphans` proves zero incoming references; because the graph is NEVER-STALE, that `0` is trustworthy enough to offer "delete this file". + 5. REPAIR-ORDERING — apply cross-file fixes in topological (dependencies-first) order (shared with TASK-9.12) so a batch of fixes does not thrash / re-break. + +SAFETY (load-bearing — these WRITE across many files): + - Never-stale is critical: a stale graph proposing a rename across 90 callers would be catastrophic. These fixes MUST gate on a fresh graph (reuse the GraphCache freshness contract) and refuse/degrade when `impact.status !== 'computed'`. + - The graph only tracks STATICALLY-resolvable references (dynamic `{% render some_var %}` is skipped by design). A rename/propagation fix MUST surface that dynamic references are NOT covered, so the agent knows the edit set may be incomplete. + - Edits are proposed (agent-applied), consistent with the existing `AgentFix`/`proposed_fixes` contract; the supervisor never writes files itself. + +ARCHITECTURE (ADR 003). The graph traversal/candidate logic lives in platformos-graph (extend the query API if needed — e.g. a rename-impact helper); the supervisor shapes the results into `AgentFix`/`proposed_fixes`. No graph logic in the supervisor. + +Working dir: ~/Work/platformos-tools/platformos-tools. Likely splits into per-class sub-tasks; scope the design first, then land class 1 (did-you-mean) as the highest-value / lowest-risk slice. + + +## Acceptance Criteria + +- [ ] #1 Graph-backed did-you-mean: a missing render/asset/graphql/layout target yields a replace-edit to the nearest REAL same-category module (via nearestModules) at the exact call-site range +- [ ] #2 Rename/move propagation: given a rename, every caller (dependentsOf) gets a precise text-edit at its source.range; dynamic (non-statically-resolvable) references are explicitly reported as NOT covered +- [ ] #3 Signature-arg propagation: signature_risk callers get insert-missing-arg / remove-unexpected-arg edits at their call sites +- [ ] #4 Safe dead-code deletion offered only for graph-proven orphans on a FRESH graph (never-stale gated) +- [ ] #5 Cross-file fix batches are emitted in topological repair-order (dependencies first), shared with TASK-9.12 +- [ ] #6 All graph traversal/candidate logic lives in platformos-graph (ADR 003); the supervisor only shapes AgentFix/proposed_fixes and never writes files +- [ ] #7 Fixes gate on a fresh graph (impact.status === 'computed') and degrade safely otherwise; graph + supervisor suites + type-check + format green + diff --git "a/.backlog/tasks/task-9.19 - First-class-YAML-nodes-platform-fact-edges-model-schema-\342\206\224-graphql-table-join-model-\342\206\224-model-associations-translation-usage-\342\206\224-definition.md" "b/.backlog/tasks/task-9.19 - First-class-YAML-nodes-platform-fact-edges-model-schema-\342\206\224-graphql-table-join-model-\342\206\224-model-associations-translation-usage-\342\206\224-definition.md" new file mode 100644 index 00000000..a1c736b3 --- /dev/null +++ "b/.backlog/tasks/task-9.19 - First-class-YAML-nodes-platform-fact-edges-model-schema-\342\206\224-graphql-table-join-model-\342\206\224-model-associations-translation-usage-\342\206\224-definition.md" @@ -0,0 +1,71 @@ +--- +id: TASK-9.19 +title: >- + First-class YAML nodes + platform-fact edges: model/schema ↔ graphql (table + join), model ↔ model (associations), translation usage ↔ definition +status: To Do +assignee: [] +created_date: '2026-07-03 06:51' +labels: + - platformos-graph + - mcp-supervisor + - architecture + - yaml + - translations + - schemas +dependencies: + - TASK-9.14 + - TASK-9.15 + - TASK-9.17 +references: + - packages/platformos-graph/src/graph/build.ts + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-graph/src/types.ts + - packages/platformos-common/src/translation-provider/TranslationProvider.ts + - packages/platformos-check-common/src/schema-table.ts + - packages/platformos-check-common/src/graphql-table.ts + - packages/platformos-check-common/src/translation-usage.ts + - packages/platformos-mcp-supervisor/src/impact/impact.ts + - docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions +parent_task_id: TASK-9 +priority: medium +--- + +## Description + + +WHY. Standalone `.yml` is currently NOT relational in the graph: `SchemaModule` (CustomModelType) is a leaf discovered only on a FULL build (the supervisor's scoped cache never materializes it), and translation `.yml` are not nodes at all. So `validate_code` correctly but bluntly returns `not_applicable` for them (the #1 review fix). This task makes `.yml` first-class by modeling the platform-FACT relationships that connect them — deterministic, syntax/mechanics-driven links, NOT naming conventions. + +SCOPE BOUNDARY (ADR 004). IN scope = facts driven by platform syntax/mechanics. OUT of scope = the commands/queries resource/CRUD convention (naming patterns) → that stays TASK-9.7. The line: a `.graphql` `table:` filter, a schema-declared `belongs_to`, and the `t`/`translate` filter + locale resolution are platform mechanics (facts); "app/lib/commands//create operates on " is a convention. + +THE THREE FACT EDGES (all name/reference-based, static, deterministic — same safety class as render/graphql edges): + A. GraphQL op → model schema (TABLE-NAME JOIN). `extractGraphqlTable` and `extractSchemaTable` ALREADY extract `.table` on both node types; build a `table → schema node` index during the build and link each GraphQLModule to its schema. A JOIN, not path resolution. Highest value, seam already exists. + B. Model → model (ASSOCIATIONS). Schemas declare `belongs_to`/`has_many`/related-model (148 `belongs_to:` in marketplace-dcra) — schema-declared facts → schema↔schema edges. Needs a small association extractor (extend schema-table.ts into a schema extractor; reuse the shared js-yaml parse). + C. Translation usage → definition. Usage is ALREADY captured (`isTranslationKeyUsage` → `translation_keys`); the missing half is resolving the key to the `.yml` that defines it and adding the edge. REUSE platformos-common `TranslationProvider.findTranslationFile(rootUri, key, defaultLocale)` — do NOT reinvent locale/module resolution. (defaultLocale: resolve from project config, else 'en' — design detail.) Complements the existing `translation-key-exists` lint (forward "does the key exist"); the graph adds the BACKWARD view (who uses a key) + orphaned-key detection — same relationship as lint-vs-blast-radius. + +PREREQUISITE / COORDINATION (the honest blocker). These files must become graph nodes AND enter the fingerprint domain, or edits won't reconcile: + - `build.ts`: materialize schema + translation nodes in scoped/entry-point builds (not only full builds), and materialize the three edges. + - GraphCache fingerprint domain (currently `isEdgeSource` = layout|page|partial) must EXPAND to include schema + translation `.yml` so editing one triggers reconcile — coordinate with TASK-9.17 (single owner of the edge-source/source-root definition) and TASK-9.15 (fingerprint domain). Expanding the domain changes what counts as a rebuild/reconcile trigger; verify never-stale + the Phase-3A scoped-walk still hold. + - `applyFileChange` (TASK-9.14) must handle `.yml` add/modify/delete for the new node/edge types (today a `.yml` change is a no-op). + - `impact.ts` `isGraphTrackable`: schema + translation `.yml` become trackable → `validate_code` flips them from `not_applicable` to `computed` with real dependents ("edit this model → 12 queries + 3 pages depend on it"). Update the #1 guard + its tests accordingly. + +REUSE (verified): `TranslationProvider.findTranslationFile` (key→file), `extractGraphqlTable`/`extractSchemaTable`/`isTranslationKeyUsage` (all exported from check-common), `getFileType`/CustomModelType classification, the shared js-yaml parse, the never-stale cache + `dependentsOf`/query API (downstream free). NOTE: `DocumentsLocator` has NO schema/translation DocumentType — schema linking is the table-join, translation linking is TranslationProvider; do not force these through DocumentsLocator. + +ADR 003: all node/edge/resolution logic lives in platformos-graph; the supervisor stays a pure consumer. + +Suggested phasing (each shippable, TDD): Phase A (graphql↔schema table join — highest value, seams exist) → Phase B (translation usage↔definition, who-uses-key + orphans) → Phase C (model↔model associations). Instance-profile/transactable types (legacy) are OPTIONAL follow-ons via the same schema mechanism. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 Phase A: GraphQL ops are edge-linked to their model schema by table-name join (op.table === schema.table, both via existing extractors); dependentsOf(schema) returns the ops/files that query it; a table with no schema is a missing-target +- [ ] #2 Phase B: translation-key usages are edge-linked to the .yml that defines the key via TranslationProvider.findTranslationFile (no reinvented resolution); dependentsOf(translation) = who uses keys it defines; orphaned keys (defined, never used) are queryable +- [ ] #3 Phase C: schema belongs_to/has_many associations produce schema↔schema edges via a small association extractor reusing the shared js-yaml parse +- [ ] #4 Schema + translation .yml are graph NODES and are in the fingerprint domain (coordinated with TASK-9.17 / 9.15); editing one triggers a never-stale reconcile; scoped-walk (Phase-3A) + never-stale invariants preserved +- [ ] #5 applyFileChange (TASK-9.14) handles .yml add/modify/delete for the new node/edge types; equivalence-to-full-build invariant extended to cover them +- [ ] #6 impact.ts isGraphTrackable includes schema + translation .yml → validate_code returns computed (with real dependents) instead of not_applicable for them; the #1 guard + tests updated +- [ ] #7 Strictly platform FACTS only (ADR 004); the commands/queries resource/CRUD CONVENTION remains out of scope (TASK-9.7); all logic in platformos-graph (ADR 003) +- [ ] #8 TDD: fixtures covering schema/graphql/translation edges; edge + dependents + orphan + reconcile assertions (whole-value); graph + supervisor suites + type-check + format + frozen-lockfile green + diff --git a/.backlog/tasks/task-9.2 - Add-a-project-structure-query-API-to-platformos-graph-resurrect-the-old-project-map-capabilities.md b/.backlog/tasks/task-9.2 - Add-a-project-structure-query-API-to-platformos-graph-resurrect-the-old-project-map-capabilities.md index 912323c5..fbccd269 100644 --- a/.backlog/tasks/task-9.2 - Add-a-project-structure-query-API-to-platformos-graph-resurrect-the-old-project-map-capabilities.md +++ b/.backlog/tasks/task-9.2 - Add-a-project-structure-query-API-to-platformos-graph-resurrect-the-old-project-map-capabilities.md @@ -3,10 +3,10 @@ id: TASK-9.2 title: >- Add a project-structure query API to platformos-graph (resurrect the old project-map capabilities) -status: To Do +status: Done assignee: [] created_date: '2026-06-23 10:32' -updated_date: '2026-06-24 13:13' +updated_date: '2026-07-03 07:54' labels: [] dependencies: - TASK-9.1 @@ -47,15 +47,65 @@ The query layer COMPOSES these; it does not re-derive them. ## Acceptance Criteria -- [ ] #1 platformos-graph exposes documented query functions: dependents/orphan/reachability/exists/missing-target, call-sites+args, nearest-name candidates, and resource/CRUD completeness -- [ ] #2 Partial @param signatures, frontmatter, schema and docset are composed from check-common, not re-derived -- [ ] #3 Each query has unit pins over a fixture app graph -- [ ] #4 A consumer can obtain the full old project-map fact set from this API without any bespoke graph code +- [x] #1 platformos-graph exposes documented query functions over the built AppGraph: dependenciesOf/dependentsOf, exists, isEntryPoint, isOrphan/orphans, reachableFrom, missingDependencies/missingTargets, call-sites+args (on Reference), and nearestModules (did-you-mean) +- [x] #2 Partial @param signatures, frontmatter, schema and docset are composed from check-common, not re-derived (graph reuses path/levenshtein/getPosition; no bespoke duplication) +- [x] #3 Each query has unit pins over a fixture/hermetic app graph +- [x] #4 Resource/CRUD completeness is split out per ADR 004 into TASK-9.6 (platform facts) + TASK-9.7 (convention overlay) and is NOT part of this task (graph stays convention-free) ## Implementation Notes +## Phase 1 done (2026-06-30): foundational pure query layer over AppGraph + +Branch `supervisor-graph-integration`. New `packages/platformos-graph/src/graph/query.ts` — PURE, synchronous queries over a built `AppGraph` (no I/O; build stays in buildAppGraph). Exported from the package index. + +Functions: `dependenciesOf` (outgoing), `dependentsOf` (incoming — 'who renders this file'), `exists`, `isEntryPoint`, `isOrphan` (exists && !entryPoint && no incoming refs — matches AC's 'no incoming references'; entry points/pages-layouts are roots, missing targets are 'missing' not orphan), `orphans` (project-wide), `reachableFrom` (transitive outgoing BFS), `missingDependencies` (per-file unresolved edges), `missingTargets` (project-wide unresolved edges). + +Key design note: buildAppGraph only materializes modules reachable from its entry points, so whole-project queries (orphans) are only as complete as the graph passed — to detect unreferenced files, build with every file as an entry point. Documented in the module header. + +**Tests** (`query.spec.ts`, 12): integration over the real skeleton graph (dependenciesOf/dependentsOf/reachableFrom/exists/isEntryPoint, no-orphans/no-missing) + hermetic manually-constructed graph (orphan true/false incl. entry-point & missing-target exclusions, orphans(), missingDependencies, missingTargets — full-object assertions). Graph suite 54 pass; type-check + prettier clean; LSP type-check clean (additive export). + +## Remaining phases (not started — each substantial, checking in first) +- **Phase 2 — call-sites + args**: Reference already carries the call-site range, but NOT args. Exposing render/function/graphql args needs extending the Reference model (capture named args in the traverse visitor) — cross-cutting (Reference lives in check-common, consumed by LSP). Decision needed before doing it. +- **Phase 3 — nearest-name candidates (did-you-mean)**: name index over typed module names + reuse check-common `levenshtein` (needs public export, like getPosition). +- **Phase 4 — resource/CRUD completeness**: per schema table → related graphql/commands/queries/pages + missing expected ops (old detectResources). Heavy; composes check-common schema/docset. + +AC#1 left unchecked (covers all query groups; only the foundational set is done). AC#3 satisfied for the Phase-1 functions. + +## Phase 2 done (2026-06-30): call-site args on dependency edges + +Approach: a call-site IS a dependency edge (`Reference` already carries `source.range` + `kind`), so args ride on the edge — no redundant 'callSites' query; `dependenciesOf` already returns call-sites, now with args. + +- **check-common `Reference`**: added optional `args?: string[]` (additive, like `kind`) — the named-argument NAMES in source order. Values intentionally not captured (names are what `@param` cross-checking needs); documented. +- **graph `traverse.ts`**: new `argNames(LiquidNamedArgument[])` helper (defensive against the parser's documented completion-context case — only `NamedArgument`s with a string name count; returns undefined when none). Captured in the render/include/function/background/graphql visitors via `node.args`; threaded through `bind()` and `extractFileReferences`. `args` set ONLY when non-empty, so argument-less edges keep no `args` field (kept the assertion ripple to exactly the 3 edges that have args). +- Reuse: arg names come straight from the parser AST (`node.args[].name`) — no re-parsing, no re-derivation. + +**Tests**: traverse-edges (graphql `['id']`, background `['data']`) + extract (graphql `['id']`, new multi-arg render `['title','count']`) + query (`dependenciesOf` exposes `kind`+`args` at the query layer: skeleton index→parent carries `['children']`). `directRef` helpers gained an optional `args` param. Graph suite 56 pass. + +**Cross-package verification (Reference.args is additive)**: graph 56, check-common + supervisor **1084**, all type-checks clean, prettier clean, dists rebuilt. LSP suite pending (only the pre-existing TypeSystem timeout flake expected). Supervisor `dependencies` output is unchanged (its structure adapter maps to {kind,target,line,column}; surfacing args in validate_code is a separate TASK-9.5 enhancement). + +AC#1 'call-sites+args' clause: done. Remaining for AC#1: nearest-name candidates (Phase 3), resource/CRUD completeness (Phase 4). + +## Phase 3 done (2026-06-30): nearest-name 'did you mean' candidates + +- **check-common `index.ts`**: one-line additive re-export of `levenshtein` (edit distance) so consumers reuse it instead of re-implementing string-distance (parity with `getPosition`). +- **graph `query.ts`**: `nearestModules(graph, uri, { limit=3, maxDistance? })` → `AppModule[]`, closest-first. Candidates are SAME-CATEGORY only (same `type`, and for Liquid same `kind`) — a missing `{% render %}` only suggests partials, a missing `{% graphql %}` only graphql ops, etc.; ranked by `levenshtein` over the project-relative path (reuses `path.relative`); excludes self + non-existent modules. Pure over the graph (candidate pool = modules present; build with all files for completeness — same documented caveat as orphans). Exported from the package index (+ `NearestModulesOptions`). + +**Tests** (`query.spec.ts`, +5): closest-first ranking (`headr`→`header`), category filtering (excludes a same-named layout, the graphql op, the page, self, and missing), `maxDistance` cap, graphql-typo→graphql-op, absent-URI→[]. Hermetic manually-built graph. Graph suite 61 pass. + +**Verification**: graph 61, check-common 1034 (additive export), type-checks + prettier clean, dists rebuilt. LSP/supervisor unaffected (don't consume levenshtein/nearestModules; only additive re-export touched check-common). + +AC#1 'nearest-name candidates' clause: done. Remaining for AC#1: resource/CRUD completeness (Phase 4 — the heaviest, schema/docset-composing piece). + +## Scope trimmed + closed (2026-06-30) + +Per ADR 004 (docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions), the original AC#1 'resource/CRUD completeness' clause is REMOVED from this task: it fuses platform facts with the core-module commands/queries CONVENTION, which must not live in platformos-graph's neutral model. Split into: +- TASK-9.6 — platform-fact groundwork in the graph (schema/CustomModelType nodes, graphql table, page slug). +- TASK-9.7 — the commands/queries convention overlay (descriptive domain map + configurable check), consuming the graph facts. + +This task delivered the neutral, convention-free query API — Phases 1–3 (foundational queries, call-site args, nearest-name). All shipped, tested, and green (graph 61 / check-common 1034; type-check + prettier clean; LSP unaffected besides additive re-exports). Closing as Done. + ## Add: memoize DocumentsLocator.locate during graph build (from TASK-9.1 code review, 2026-06-24) TASK-9.1's traverse.ts resolves function/graphql targets via `DocumentsLocator.locateOrDefault`, which `stat()`s each candidate search path until a hit, with NO result caching. The same target referenced from many modules (e.g. a shared `queries/list` or graphql op) replays the full stat-probe sequence per reference — FS I/O proportional to reference count, not to distinct targets. diff --git "a/.backlog/tasks/task-9.20 - GraphCache-Phase-3B-\342\200\224-fs.watch-background-freshness-deferred-from-TASK-9.15.md" "b/.backlog/tasks/task-9.20 - GraphCache-Phase-3B-\342\200\224-fs.watch-background-freshness-deferred-from-TASK-9.15.md" new file mode 100644 index 00000000..367a8607 --- /dev/null +++ "b/.backlog/tasks/task-9.20 - GraphCache-Phase-3B-\342\200\224-fs.watch-background-freshness-deferred-from-TASK-9.15.md" @@ -0,0 +1,39 @@ +--- +id: TASK-9.20 +title: GraphCache Phase 3B — fs.watch background freshness (deferred from TASK-9.15) +status: To Do +assignee: [] +created_date: '2026-07-03 07:37' +labels: + - mcp-supervisor + - performance + - architecture +dependencies: + - TASK-9.15 +references: + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts + - SUPERVISOR-GRAPH-INTEGRATION.md +parent_task_id: TASK-9 +priority: low +--- + +## Description + + +Split out of TASK-9.15 (Phase 3B), which shipped Phases 1 (incremental apply), 2 (persistence), and 3A (scoped walk). This is the remaining Phase 3B: keep the graph fresh in the BACKGROUND via `fs.watch` (incremental apply on watch events) so the request path performs NO per-call fingerprint scan on the steady state. + +MANDATORY: the fingerprint reconciliation MUST remain as the safety net for missed/unsupported watch events (recursive-watch gaps on Linux, inotify limits, editor atomic-save renames, network FS, macOS FSEvents quirks) — NEVER trust the watcher alone. Watch = speed; fingerprint = truth. + +DEFERRED BY DECISION (record the rationale): on the actual `validate_code` path the fingerprint scan (~391ms on marketplace-dcra) runs CONCURRENTLY with lint's multi-second whole-project parse (`Promise.all` in runValidateCode), so it adds ≈0 wall-clock today. fs.watch would optimize a cost that is NOT on the critical path, at the price of real platform-specific watcher complexity + lifecycle (setup/teardown/debounce/missed-event handling). Recommendation: implement ONLY if profiling ever shows the steady-state scan on the critical path (e.g. blast-radius consumed outside the lint-concurrent path, or many concurrent calls serializing scans). Otherwise this can stay closed as won't-do-now. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 fs.watch keeps the graph fresh in the background (incremental apply on events) so the steady-state request path performs no per-call full fingerprint scan +- [ ] #2 The fingerprint reconciliation remains the safety net for missed/unsupported watch events — the watcher is never trusted alone; never-stale preserved +- [ ] #3 Watcher lifecycle is clean (setup on first use, torn down on shutdown; debounced; bounded) +- [ ] #4 TDD: watch-event → incremental apply, and missed-event → fingerprint-reconciliation fallback; supervisor suite + type-check + format green +- [ ] #5 Docs: SUPERVISOR-GRAPH-INTEGRATION.md §10 updated to the final incremental+persisted+WATCHED architecture (fingerprint-authoritative) + diff --git a/.backlog/tasks/task-9.21 - Complete-reference-edge-extraction-theme_render_rc-resolution-frontmatter-embedded-Liquid.md b/.backlog/tasks/task-9.21 - Complete-reference-edge-extraction-theme_render_rc-resolution-frontmatter-embedded-Liquid.md new file mode 100644 index 00000000..b989135a --- /dev/null +++ b/.backlog/tasks/task-9.21 - Complete-reference-edge-extraction-theme_render_rc-resolution-frontmatter-embedded-Liquid.md @@ -0,0 +1,42 @@ +--- +id: TASK-9.21 +title: >- + Complete reference-edge extraction: theme_render_rc resolution + + frontmatter-embedded Liquid +status: To Do +assignee: [] +created_date: '2026-07-03 07:38' +labels: + - platformos-graph + - edges + - code-review + - tech-debt +dependencies: [] +references: + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-common/src/documents-locator/DocumentsLocator.ts + - packages/platformos-check-common/src/types.ts +parent_task_id: TASK-9 +priority: low +--- + +## Description + + +Two deferred edge-extraction gaps carried over from TASK-9.4 and TASK-9.9 (both closed on their stated ACs; these remnants moved here so they are not lost). + +GAP 1 — `theme_render_rc` mislabeled + mis-resolved (from TASK-9.4 impl notes). The parser maps `{% theme_render_rc %}` to a `RenderMarkup` node, so the graph's `RenderMarkup` visitor catches it and labels it `kind:'render'`, resolving via the partial path `app/views/partials/.liquid`. But theme_render_rc resolves through THEME SEARCH PATHS (DocumentsLocator already has a dedicated `'theme_render_rc'` DocumentType). So a theme_render_rc edge is mislabeled `render` and points at a wrong/absent partial → wrong dependency/orphan output, disagreeing with the LSP. Fix: in the graph `RenderMarkup` visitor branch on the parent tag name — `render`→'render', `include`→'include', `theme_render_rc`→new kind `'theme_render_rc'` resolved via `DocumentsLocator(rootUri,'theme_render_rc',name)` (NOT getPartialModule). Add `'theme_render_rc'` to the `ReferenceKind` union in check-common types.ts (additive). May need `theme_search_paths` from app/config.yml (loadSearchPaths) — confirm whether to thread it in or accept the locateDefault fallback. Low real-world impact (theme components), but a genuine latent correctness/consistency bug. + +GAP 2 — Liquid embedded inside frontmatter string values (from TASK-9.9 AC#7). A reference inside a frontmatter YAML string value (e.g. `response_headers: > {%- include '...' -%}`) is a real dependency the graph does NOT extract: it models the Liquid BODY AST and reads frontmatter only as YAML (for slug/layout/method). DECIDE first, then implement or document as an explicit non-goal: which frontmatter keys can contain Liquid, the perf cost of parsing string values as Liquid, and how to represent the edge's source range (the range inside the YAML scalar). + +Both are static, name/reference-based edges (the same safety class as the existing edges) and belong in platformos-graph (ADR 003). TDD with fixtures; preserve additive/whole-value conventions. + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [ ] #1 theme_render_rc: the graph RenderMarkup visitor branches on parent tag name and emits a new kind 'theme_render_rc' resolved via DocumentsLocator('theme_render_rc'), not the partial path; 'theme_render_rc' added to the ReferenceKind union (additive); fixture + spec; output matches the LSP resolution +- [ ] #2 frontmatter-embedded Liquid: a design decision is recorded (which keys, perf, source-range) and then EITHER extraction of Liquid references in frontmatter string values is implemented (with a fixture) OR it is documented as an explicit non-goal in the graph docs +- [ ] #3 Changes are additive; graph + check-common + LSP suites + type-check + format green; no regression to existing edge kinds + diff --git a/.backlog/tasks/task-9.3 - Expose-per-file-self-structural-facts-on-platformos-graph-modules.md b/.backlog/tasks/task-9.3 - Expose-per-file-self-structural-facts-on-platformos-graph-modules.md index 62f0a142..509557ed 100644 --- a/.backlog/tasks/task-9.3 - Expose-per-file-self-structural-facts-on-platformos-graph-modules.md +++ b/.backlog/tasks/task-9.3 - Expose-per-file-self-structural-facts-on-platformos-graph-modules.md @@ -1,9 +1,10 @@ --- id: TASK-9.3 title: Expose per-file self-structural facts on platformos-graph modules -status: To Do +status: Done assignee: [] created_date: '2026-06-23 10:33' +updated_date: '2026-06-30 14:35' labels: [] dependencies: - TASK-9.1 @@ -36,8 +37,93 @@ Preferred owner is `platformos-graph` (it already parses the app). If parsing/ex ## Acceptance Criteria -- [ ] #1 A module's own structural declarations (renders/graphql/filters/tags/translation_keys/doc_params/slug/layout/method) are obtainable from platformos-graph without the consumer parsing the file -- [ ] #2 Frontmatter-derived fields reuse check-common parsing (no second frontmatter parser) -- [ ] #3 The owner decision (graph vs shared util) is recorded in ADR 003 -- [ ] #4 Unit pins cover the exposed self-structural for representative file types +- [x] #1 A module's own structural declarations (renders/graphql/filters/tags/translation_keys/doc_params/slug/layout/method) are obtainable from platformos-graph without the consumer parsing the file +- [x] #2 Frontmatter-derived fields reuse check-common parsing (no second frontmatter parser) +- [x] #3 The owner decision (graph vs shared util) is recorded in ADR 003 +- [x] #4 Unit pins cover the exposed self-structural for representative file types + +## Implementation Notes + + +## Landed (2026-06-25): per-file dependency primitive — `extractFileReferences` + +The outgoing-dependency half of this task is now implemented in `platformos-graph` and integration-ready. Self-structural *names* (filters/tags/translation_keys/doc_params/slug/layout/method) are still TODO under this task; the dependency edges are done. + +### API (exported from `@platformos/platformos-graph`) +```ts +extractFileReferences( + rootUri: UriString, + sourceUri: UriString, + sourceCode: FileSourceCode, // parse the BUFFER via toSourceCode(sourceUri, content) + deps: { fs: AbstractFileSystem }, +): Promise +``` +Returns the file's resolved outgoing edges: `{ source:{uri,range}, target:{uri}, type:'direct', kind }`, `kind ∈ render|include|function|background|graphql|asset`. Target URIs are DocumentsLocator-canonical (lib paths, `modules//public/...`, `.html.liquid`) and normalized identically to graph node keys (Windows-safe). + +### Why this shape (matches the buffer-before-write model) +`validate_code` validates an in-flight buffer that may not be on disk. So the consumer parses the *buffer* (not disk) with `toSourceCode`, and resolution touches `fs` only for target lookup. NO whole-graph build, NO reachability requirement — works for orphan/new files. This is the single resolution path shared with the full `buildAppGraph` traversal (`resolveLiquidReferences` in `graph/traverse.ts`), so per-file and project-wide resolution can never drift. + +### Files +- `packages/platformos-graph/src/graph/traverse.ts` — extracted `resolveLiquidReferences` (internal) + public `extractFileReferences`; `traverseLiquidModule` now calls the shared resolver (behavior-identical, verified by unchanged build/traverse-edges specs). +- `packages/platformos-graph/src/index.ts` — exports `extractFileReferences`. +- `packages/platformos-graph/src/graph/extract.spec.ts` — 8 pins incl. in-flight buffer for a file not on disk, all kinds, module-namespaced, dynamic/unparseable/no-ref → `[]`. + +### Owner decision (AC #3 — record in ADR 003) +Dependency resolution lives in `platformos-graph` (it owns DocumentsLocator wiring + the kind taxonomy). The remaining self-structural NAME extraction (filters/tags/translation_keys/doc_params/frontmatter slug/layout/method) should reuse check-common's liquid-doc/frontmatter parsing (AC #2) and be composed by the graph — still open. + +## Reuse survey + phasing plan (2026-06-30) + +Reuse survey (AC#2): check-common exposes NO ready-made extractors for most facts (no exported frontmatter parser — RouteTable.extractFrontmatter is private; no filter/tag/translation extractor). 'Reuse, don't re-derive' here = reuse the PARSERS the graph already runs: liquid-html AST (`toSourceCode`+`visit`), `js-yaml` for frontmatter (same parser RouteTable/the layout-edge use — NOT a bespoke/regex parser), `liquid-doc` (`DocDefinition`/`LiquidDocParameter`) for doc_params, `translation-utils` for keys. The facts are a by-product of the parse the graph already does. + +Key risk: a HALF-populated `structural` snapshot would itself MISLEAD the agent (empty `filters_used` reads as 'no filters' vs 'not extracted'). So 9.3 ships COMPLETE, non-misleading subsets per phase — fields are added to the type only as implemented (absent = not-available, never a misleading empty array). + +**Mechanism**: a `structural?` property on LiquidModule, populated in `traverseLiquidModule` (full-build) as a by-product. Per-file `extractFileReferences` is unchanged (edges only) for now; surfacing self-structural per-buffer is a later, separate step. + +**Phase A (this slice): page routing facts — `slug`, `layout`, `method`.** Reuse: effective slug = frontmatter `slug` override ?? `slugFromFilePath(extractRelativePagePath(uri), formatFromFilePath(...))` (all platformos-common); layout/method from frontmatter via shared `loadFrontmatter` (js-yaml; refactor the layout-edge visitor to share one load helper — single frontmatter-parse code path). Delivers slug (the TASK-9.7 need). Neutral, complete subset. + +**Phase B: AST usage facts — `renders_used`, `graphql_queries_used`, `filters_used`, `tags_used`, `translation_keys`** (visit the already-parsed AST; renders/graphql can derive from the literal names; translation_keys via translation-utils). + +**Phase C: `doc_params`** via check-common liquid-doc (`DocDefinition`/`LiquidDocParameter`). + +ADR 003 owner decision (AC#3): extraction lives in platformos-graph as a by-product of its parse, composing check-common parsers (js-yaml, liquid-doc, translation-utils, slug helpers) — to be recorded in ADR 003 when 9.3 completes. + +## Phase A DONE (2026-06-30): page routing facts (slug/layout/method) + +- **types.ts**: `ModuleStructural { slug?, layout?, method? }` + `LiquidModule.structural?`. Doc'd: ABSENT field = 'not available' (never a misleading empty); AST usage facts added in later phases. +- **traverse.ts**: `extractStructural(sourceCode, uri)` set on each Liquid module in `traverseLiquidModule` (full-build by-product). Reuses: shared `loadFrontmatter` (one js-yaml path — the layout-edge visitor + schemaTableName now share it too) + platformos-common `extractRelativePagePath`/`slugFromFilePath`/`formatFromFilePath`. Effective slug = frontmatter `slug` override (verbatim, coerced like RouteTable) else path-derived; layout/method from frontmatter; returns undefined when none (partials → undefined). `frontmatterBody` grabs the YAMLFrontmatter node O(children), no full re-visit. +- Per-file `extractFileReferences` unchanged (edges only); structural is a full-build node fact for now. + +**Tests (thorough variants)**: new `fixtures/structural/` + `structural.spec.ts` (4): path-derived slug + layout + method (index→'/'), no-frontmatter path-derived slug (about→'about'), frontmatter slug OVERRIDE wins over path (blog/show→'blog/custom'), partial→undefined. + +**Verification**: graph 71 (additive — no existing full-module assertion broke; serialize/SerializableNode doesn't include structural so cli/serialize unaffected), prettier clean, dist rebuilt, LSP+supervisor type-check clean. Consumer suites running (validate_code must stay byte-identical). + +Remaining: Phase B (renders_used/graphql_queries_used/filters_used/tags_used/translation_keys), Phase C (doc_params). + +## Phase B DONE (2026-06-30): AST usage facts (renders/graphql/filters/tags/translation_keys) + +- **types.ts `ModuleStructural`**: added the 5 usage arrays — `renders_used`, `graphql_queries_used`, `filters_used`, `tags_used`, `translation_keys` — ALWAYS present (sorted + de-duplicated; empty = none used, since the whole AST is analyzed, so no absent-vs-empty ambiguity). `slug`/`layout`/`method` stay optional. Net: `structural` is now present for any parseable Liquid module (undefined only if the AST failed to parse). +- **traverse.ts `extractStructural`**: one `visit` of the already-parsed AST collects renders (RenderMarkup string literal), graphql (GraphQLMarkup string literal), filters (LiquidFilter.name), tags (LiquidTag.name), translation_keys (LiquidVariable with a String expression piped through `t`/`translate` — same detection as the translation-key-exists check). Reuses the existing parse + `isStringLiteral`; no new parser. + +**Misleading-output design**: usage arrays always-present (empty=none) is the non-ambiguous choice. This made `structural` present on every Liquid node, which broke 5 edge-test full-node assertions (they pinned the whole TARGET node, now carrying empty-usage structural). Fix per separation-of-concerns + the repo guideline's 'don't over-pin an internal payload' exception: added `edgeIdentity()` to traverse-edges.spec that strips `structural` from the compared node — edge tests assert EDGE identity; structural is pinned exhaustively in structural.spec. Keeps edge tests stable as structural grows (Phase C doc_params won't re-break them). + +**Tests (thorough variants)**: structural.spec rewritten (5): index (path slug '/' + layout + method + its one render/tag), about (path slug, all-empty usage), blog/show (slug override), card partial (all-empty, no routing), rich.liquid (full: renders ['card'], graphql ['blog/find'], filters ['t','upcase'], tags ['assign','graphql','if','render'], translation ['greeting.hello'], slug 'rich', layout) — verified actuals match the parser. Graph suite 72 pass. + +**Verification**: graph 72, type-check + prettier clean, dist rebuilt, LSP+supervisor type-check clean. Consumer suites running (validate_code byte-identical expected). + +Remaining: Phase C (doc_params via liquid-doc), then record ADR-003 owner decision + close 9.3. + +## Phase C DONE + TASK-9.3 CLOSED (2026-06-30): doc_params, all 9 facts complete + +- **types.ts**: `ModuleStructural.doc_params: string[]` (always present, source/declaration order). +- **traverse.ts `extractStructural`**: populated via check-common `extractDocDefinition(uri, ast)` → `liquidDoc.parameters[].name` — REUSE of the owned liquid-doc parser, no second parser. (`{% doc %}` is a LiquidRawTag so it correctly does NOT appear in tags_used.) +- **structural.spec**: +1 (`documented.liquid` → doc_params ['title','count'] in declaration order); `NO_USAGE` + rich/index updated for the new always-present array. Graph suite 73. + +**ADR 003 owner decision (AC#3) RECORDED**: self-structural lives on `platformos-graph` LiquidModule.structural as a parse by-product, COMPOSING check-common parsers (extractDocDefinition, shared js-yaml loadFrontmatter, AST visit, translation t/translate detection, slug helpers) — never re-deriving. Resolved ADR 003 open questions #1 (owner=graph), #2 (Reference.kind/args), #3 (resource/CRUD → ADR 004, out of graph). + +## Final verification (all 9 facts: slug/layout/method + renders_used/graphql_queries_used/filters_used/tags_used/translation_keys/doc_params) +- graph **73**, supervisor **50** (validate_code byte-identical — structural is a full-build node fact, invisible to the per-file extractFileReferences path), LSP **466/467** (only the pre-existing TypeSystem timeout flake). All packages type-check clean; prettier clean; dists rebuilt. +- AC#1 (all facts obtainable without re-parsing) ✓, AC#2 (reuse check-common parsing, no second parser) ✓, AC#3 (owner recorded in ADR 003) ✓, AC#4 (unit pins for representative file types: page/partial/documented/rich) ✓. + +Note: the supervisor surfacing `structural` in validate_code output is separate (TASK-8.4 result shaping), not part of 9.3. Closing as Done. + diff --git "a/.backlog/tasks/task-9.4 - Layout-association-edges-centralize-the-Layout-type-DocumentsLocator-layout-graph-frontmatter\342\206\222layout-edge-missing-content-for-layout.md" "b/.backlog/tasks/task-9.4 - Layout-association-edges-centralize-the-Layout-type-DocumentsLocator-layout-graph-frontmatter\342\206\222layout-edge-missing-content-for-layout.md" index 213c74d9..0c84f819 100644 --- "a/.backlog/tasks/task-9.4 - Layout-association-edges-centralize-the-Layout-type-DocumentsLocator-layout-graph-frontmatter\342\206\222layout-edge-missing-content-for-layout.md" +++ "b/.backlog/tasks/task-9.4 - Layout-association-edges-centralize-the-Layout-type-DocumentsLocator-layout-graph-frontmatter\342\206\222layout-edge-missing-content-for-layout.md" @@ -3,10 +3,10 @@ id: TASK-9.4 title: >- Layout-association edges + centralize the Layout type (DocumentsLocator 'layout' + graph frontmatter→layout edge + missing-content-for-layout) -status: To Do +status: Done assignee: [] created_date: '2026-06-24 11:11' -updated_date: '2026-06-24 13:12' +updated_date: '2026-07-03 07:38' labels: [] dependencies: - TASK-9.1 @@ -59,10 +59,10 @@ See ADR 003 and TASK-9.1's notes for the analysis. ## Acceptance Criteria -- [ ] #1 DocumentsLocator supports a 'layout' DocumentType: getSearchPaths maps it to PlatformOSFileType.Layout, locateFile tries .html.liquid then .liquid, and locateDefault has a layout fallback; additive (existing DocumentType behaviour unchanged) with platformos-common unit tests -- [ ] #2 platformos-graph emits a page→layout edge (kind 'layout') from frontmatter `layout`, resolved via DocumentsLocator('layout'); missing layout → exists:false node; `layout: ''` → no edge; only explicit layouts get an edge (documented). Covered by a graph spec/fixture -- [ ] #3 missing-content-for-layout (check-common) scopes layout files via the canonical PlatformOSFileType.Layout type rather than the isLayout predicate; its existing specs remain green -- [ ] #4 Verification: platformos-common + platformos-graph + check-common tests pass; LSP consumer (language-server-common) re-verified green; changes are additive; prettier/type-check clean +- [x] #1 DocumentsLocator supports a 'layout' DocumentType: getSearchPaths maps it to PlatformOSFileType.Layout, locateFile tries .html.liquid then .liquid, and locateDefault has a layout fallback; additive (existing DocumentType behaviour unchanged) with platformos-common unit tests +- [x] #2 platformos-graph emits a page→layout edge (kind 'layout') from frontmatter `layout`, resolved via DocumentsLocator('layout'); missing layout → exists:false node; `layout: ''` → no edge; only explicit layouts get an edge (documented). Covered by a graph spec/fixture +- [x] #3 missing-content-for-layout (check-common) scopes layout files via the canonical PlatformOSFileType.Layout type rather than the isLayout predicate; its existing specs remain green +- [x] #4 Verification: platformos-common + platformos-graph + check-common tests pass; LSP consumer (language-server-common) re-verified green; changes are additive; prettier/type-check clean ## Implementation Notes @@ -75,4 +75,51 @@ Deferred here because it needs the same DocumentsLocator render-path routing thi **Problem:** the parser maps `{% theme_render_rc %}` to a `RenderMarkup` node (liquid-html-parser stage-2-ast.ts:582-583, 2083), so the graph's `RenderMarkup` visitor catches it and labels it `kind:'render'`, resolving via `getPartialModule` -> `app/views/partials/.liquid`. But `theme_render_rc` resolves through THEME SEARCH PATHS (DocumentsLocator already has a dedicated `'theme_render_rc'` DocumentType). So a theme_render_rc edge is mislabeled `render` and points at a wrong/absent partial path -> wrong dependency/orphan output, disagreeing with the LSP. (The wrong RESOLUTION is pre-existing in the old RenderMarkup visitor; TASK-9.1 added only the `kind:'render'` mislabel. theme_render_rc was out of 9.1's function/graphql/include scope.) **Fix (alongside layout):** in the graph `RenderMarkup` visitor, branch on the parent tag name: `render`->'render', `include`->'include', `theme_render_rc`->new kind 'theme_render_rc', resolving the latter via `DocumentsLocator(rootUri,'theme_render_rc',name)` (NOT getPartialModule). Add `'theme_render_rc'` to the `ReferenceKind` union in check-common types.ts (additive, like the others). Spec + fixture covering a theme_render_rc edge. Note: theme_render_rc search-path resolution may need `theme_search_paths` from app/config.yml (loadSearchPaths) — confirm whether to thread it in or accept locateDefault fallback. + +## Part 1 done (2026-06-29): DocumentsLocator 'layout' DocumentType — AC#1 ✓ + +Branch `supervisor-graph-integration` (graph repo). Additive, TDD, behavior-preserving for existing types. + +**`packages/platformos-common/src/documents-locator/DocumentsLocator.ts`** +- `DocumentType` union: `+ 'layout'`. +- `getSearchPaths`: maps `layout → PlatformOSFileType.Layout` (reuses `FILE_TYPE_DIRS[Layout]` via getAppPaths/getModulePaths) — so module public/private + app dirs come for free. +- `locateFile`: replaced the hardcoded single-extension append with a per-type candidate list `EXTENSIONS` (`partial:[.liquid]`, `graphql:[.graphql]`, `layout:[.html.liquid,.liquid]`, `asset:['']`), looping basePaths × candidates. Identical behavior for the 3 existing single-candidate types; layout tries `.html.liquid` then `.liquid`. +- `locate`: added `'layout'` case → `locateFile(...,'layout')`. +- `locateDefault`: added `'layout'` → `app/views/layouts/.liquid` (module-prefix aware; modern `.liquid` for the canonical default). +- NOT touched: `list`/`listFiles` (layout autocomplete + `.html.liquid` name-stripping is out of AC#1 scope; left returning [] for layout). + +**Tests** (`DocumentsLocator.spec.ts`, +14): layout locateDefault (app + module); locate `.liquid`, `.html.liquid`, `.html.liquid`-preferred-over-`.liquid`, module public, module `.html.liquid`, private-module fallback, nested name, non-existent→undefined; search-path **isolation** (a partial name must NOT resolve as a layout); `locateOrDefault` existing-wins + missing→default; asset-by-own-extension **regression** (guards the empty-extension refactor). 56/56 in the file. + +**Verification:** platformos-common 240 pass; consumers (check-common + graph + LSP) **1536 tests / 140 files pass**, all three type-check clean; prettier clean. Additive — zero downstream breakage. + +**Remaining on this task:** Part 2 (graph frontmatter→layout edge, AC#2), Part 3 (missing-content-for-layout → canonical PlatformOSFileType.Layout, AC#3), + deferred theme_render_rc edge (impl note above). + +## Part 2 done (2026-06-29): graph frontmatter→layout edge — AC#2 ✓ + +**`packages/platformos-graph/src/graph/module.ts`**: added `getLayoutModuleByUri` (normalizing Layout factory, mirrors getPartialModuleByUri/getGraphQLModuleByUri; `getLayoutModule` left untouched for entry-point use). + +**`packages/platformos-graph/src/graph/traverse.ts`**: added a `YAMLFrontmatter` visitor to `resolveLiquidReferences` (so BOTH buildAppGraph and extractFileReferences emit it). Parses `node.body` with `js-yaml`, reads `layout`, resolves via `DocumentsLocator.locateOrDefault(rootUri,'layout',name)` → `getLayoutModuleByUri` → kind `'layout'`. Edge only for an EXPLICIT, static, non-empty string layout: +- `layout: ''` → no edge; layout omitted → no edge (no implicit-default synthesis); dynamic `{{…}}` (via reused `containsLiquid`) → no edge; non-string → no edge; malformed YAML → caught, no edge. +Source range = the whole `YAMLFrontmatter` block (tag-level granularity like the other edges; precise `layout:`-scalar range is a possible future refinement). Missing layout → `exists:false` Layout node (parity with function/graphql). + +**Deps**: added `js-yaml@^4.1.1` + `@types/js-yaml@^4.0.9` (monorepo standard; already in lockfile → zero yarn.lock churn; `--frozen-lockfile` verified green). + +**Tests**: new `fixtures/layout-edges/` (index→theme exists, broken→ghost missing). `traverse-edges.spec.ts` +2 (resolved edge / exists:false node, full-object assertions). `extract.spec.ts` +5 (in-flight buffer: explicit, module-prefixed, empty→none, omitted→none, dynamic→none). `cli.spec.ts`: skeleton `index.liquid` has `layout: application`, so the full-graph assertion gained the legitimate `index→application` (kind layout) edge (6→7 edges, exact whole-array). Graph suite **42 pass**. + +## Part 3 done (2026-06-29): missing-content-for-layout canonical type — AC#3 ✓ + +**`packages/platformos-check-common/src/checks/missing-content-for-layout/index.ts`**: replaced `isLayout(uri)` with `getFileType(uri) === PlatformOSFileType.Layout` (provably identical: `isLayout` IS that, verified in path-utils.ts:289). Updated imports + doc comment. check-common **1034 pass** (its 7 specs green). + +## Verification (AC#4 — NOT fully checked: one pre-existing LSP timeout flake) +- platformos-common **240**, platformos-graph **42**, check-common **1034** — all pass. All four packages type-check clean; `yarn format:check` clean; `--frozen-lockfile` clean. +- LSP (language-server-common): **466/467**. The one failure is `TypeSystem.spec > should infer types through chain of function calls with GraphQL at the end` — `Error: Test timed out in 5000ms` (ran 5108ms under full-suite load; **1783ms in isolation, where it passes**). PROVEN UNRELATED to this change: that test's fixtures contain ZERO frontmatter/`layout:`, so the new `YAMLFrontmatter` visitor never fires on its code path (zero added cost). It's a pre-existing load-contention timeout (full-suite setup took ~401s on a heavily-contended machine). NOT masked (timeout not bumped). The LSP tests that DO consume layout edges (references/dependencies/dead_code) are all in the 466 passing. + +## Remaining on TASK-9.4 +- Deferred `theme_render_rc` edge fix (see the older impl note): branch RenderMarkup on tag name, add kind `'theme_render_rc'` to ReferenceKind + resolve via DocumentsLocator('theme_render_rc'). Not started. + +## Final Summary + + +Shipped all three stated ACs. (1) DocumentsLocator gained a `'layout'` DocumentType (`.html.liquid`→`.liquid` precedence, module-aware, additive; +14 tests). (2) platformos-graph emits a page→layout edge (kind `layout`) from frontmatter `layout:`, resolved via DocumentsLocator, missing→exists:false, only-explicit-layouts (documented); fixtures + traverse/extract specs. (3) missing-content-for-layout scopes via canonical `PlatformOSFileType.Layout`. Verified: common/graph/check-common green, LSP green in isolation (the one full-suite failure is the documented pre-existing TypeSystem parallel-load flake, unrelated), type-check + format + frozen-lockfile clean. The `theme_render_rc` edge fix noted in the impl notes was extra (not a stated AC) and is moved to TASK-9.21 so this task closes on its actual scope. + diff --git a/.backlog/tasks/task-9.5 - Wire-graph-dependency-edges-into-validate_code-output-dependencies-field.md b/.backlog/tasks/task-9.5 - Wire-graph-dependency-edges-into-validate_code-output-dependencies-field.md new file mode 100644 index 00000000..0d873686 --- /dev/null +++ b/.backlog/tasks/task-9.5 - Wire-graph-dependency-edges-into-validate_code-output-dependencies-field.md @@ -0,0 +1,85 @@ +--- +id: TASK-9.5 +title: Wire graph dependency edges into validate_code output (dependencies field) +status: Done +assignee: [] +created_date: '2026-06-29 14:38' +updated_date: '2026-07-03 07:38' +labels: [] +dependencies: [] +references: + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-mcp-supervisor/src/lint/lint.ts + - packages/platformos-mcp-supervisor/src/result/types.ts + - >- + packages/platformos-mcp-supervisor/test/guards/architecture-invariants.spec.ts +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +## Why +The whole point of extending platformos-graph (TASK-9) is to enrich the supervisor's `validate_code` with structural info. The graph already exposes the per-file primitive `extractFileReferences(rootUri, sourceUri, sourceCode, { fs })` → `Reference[]` (TASK-9.3; layout edges added in TASK-9.4). Today the supervisor declares `@platformos/platformos-graph` as a dep but never consumes it: `validate_code` is still lint-only. + +This task wires it in: `validate_code` output gains a `dependencies` array (what the file renders/includes/runs/queries/wraps, with canonical resolved target + kind + location). This is TASK-9 AC#5 (supervisor consumes the API, contains NO graph logic of its own). + +## Design (respect package responsibilities + the architecture guards) +- NEW I/O adapter `src/structure/structure.ts` (a sibling to `src/lint/lint.ts`, the other I/O boundary): parses the in-flight BUFFER via `toSourceCode`, calls `extractFileReferences` with `NodeFileSystem` rooted at projectDir, maps `Reference[]` → agent-facing `ValidateCodeDependency[]`. No whole-graph build; works for not-yet-on-disk buffers. +- REUSE check-common for the mapping math — do NOT reimplement: `path.relative(target.uri, rootUri)` for the project-relative target; `getPosition(content, offset)` for offset→line/col (the same util check() uses; add its one-line public export). Reuse `NodeFileSystem` (check-node) and `toSourceCode`/`extractFileReferences` (graph). +- `result/` stays PURE: a new `dependencies` param on `assembleResult` is included verbatim. No I/O, no graph logic in result/. +- Agent surface: `ValidateCodeDependency { kind: string; target: string; line: number; column: number }` — `kind: string` mirrors the existing `check: string` precedent (no ReferenceKind duplication/coupling). 1-based line/col like the rest of the surface. +- Run structure alongside lint (`Promise.all`) in the handler. +- Guard: add `'structure'` to `LINT_PATH_LAYERS` so the no-LSP invariant covers the new layer; structure/ must NOT be a PURE layer (it does I/O, like lint/). + +## Do NOT +- Re-flag missing targets as diagnostics (lint already emits MissingPartial). The graph value is the canonical resolved target + kind, not re-detection. +- Add graph/scanner logic to the supervisor. It only calls extractFileReferences + maps. +- Build a whole-project graph or compute incoming references (that needs caching; separate, TASK-9.2 territory). +["validate_code result includes a `dependencies: ValidateCodeDependency[]` populated from platformos-graph's extractFileReferences (kind, project-relative target, 1-based line/col); empty for files with no static deps", "New src/structure/ I/O adapter is the only new code path; result/ stays pure (no fs/process/graph logic); architecture-invariants guard extended to cover structure/ for the no-LSP invariant and stays green", "Mapping reuses check-common (path.relative + getPosition) and graph (extractFileReferences/toSourceCode) + check-node NodeFileSystem — no reimplementation of path/position/graph logic in the supervisor", "TDD: unit pins for the structure adapter (render/function/graphql/include/background/asset/layout, module-prefixed, no-deps, non-liquid) + updated assemble + stdio-smoke whole-value assertions", "Verification: supervisor tests pass; type-check + format clean; additive (existing validate_code behavior unchanged besides the new field)"] + + +## Acceptance Criteria + +- [x] #1 validate_code result includes a `dependencies: ValidateCodeDependency[]` populated from platformos-graph's extractFileReferences (kind, project-relative target, 1-based line/col); empty for files with no static deps +- [x] #2 New src/structure/ I/O adapter is the only new code path; result/ stays pure (no fs/process/graph logic); architecture-invariants guard extended to cover structure/ for no-LSP and stays green +- [x] #3 Mapping reuses check-common (path.relative + getPosition) + graph (extractFileReferences/toSourceCode) + check-node NodeFileSystem — no reimplementation in the supervisor +- [x] #4 TDD: unit pins for the structure adapter (all edge kinds, module-prefixed, no-deps, non-liquid) + updated assemble + stdio-smoke whole-value assertions +- [x] #5 Verification: supervisor tests pass; type-check + format clean; additive (existing validate_code behavior unchanged besides the new field) + + +## Implementation Notes + + +## Implemented (2026-06-29) — graph dependencies wired into validate_code + +Branch `supervisor-graph-integration` (graph repo). TDD, additive, responsibilities kept separate. + +### New / changed +- **`src/structure/structure.ts`** (NEW I/O adapter, sibling to `lint/`): `runStructure({projectDir,filePath,content})` → parses the in-flight BUFFER via graph `toSourceCode`, calls graph `extractFileReferences` with check-node `NodeFileSystem`, maps `Reference[]` → `ValidateCodeDependency[]`. REUSES check-common `path.relative` (uri→project-relative) + `getPosition` (offset→1-based line/col) + re-exported `path.URI`; reuses graph + check-node. No graph/path/position logic reimplemented. +- **`src/result/types.ts`**: added `ValidateCodeDependency { kind:string; target:string; line:number; column:number }` (`kind:string` mirrors `ValidateCodeDiagnostic.check`, decoupled from upstream ReferenceKind) + required `dependencies: ValidateCodeDependency[]` on `ValidateCodeResult` (always present, empty when none). +- **`src/result/assemble.ts`**: new `dependencies` data param, included verbatim. result/ stays PURE (guard-enforced). +- **`src/transport/validate-code.ts`**: runs `runLint` + `runStructure` concurrently (`Promise.all`), passes both to `assembleResult`. +- **check-common `src/index.ts`**: one-line public re-export of `getPosition` (the canonical offset→position util `check()` itself uses) so the supervisor reuses it instead of re-counting newlines. +- **guard `test/guards/architecture-invariants.spec.ts`**: added `'structure'` to `LINT_PATH_LAYERS` (no-LSP invariant now covers the new layer). structure/ is NOT a PURE layer (it does I/O, like lint/). + +### Tests (comprehensive — final agent-facing output is pinned) +- `structure.spec.ts` (14): every edge kind (render/include/function/background/graphql/asset/layout), module-prefixed, multi-line position, source-order multi-dep, no-deps, dynamic-skip, non-Liquid → [], relative+absolute paths. +- `assemble.spec.ts`: envelope + verbatim dependency pass-through (status unaffected). +- **`stdio-smoke.spec.ts` (END-TO-END via the real MCP bin over stdio)**: exact whole-result assertions incl. `dependencies` — page→partial, layout+function+render in source order, and the critical **lint-error AND dependencies together** case (proves they coexist without conflation), plus dynamic-target = no invented deps. This is the anti-mislead guarantee for coding agents. + +### Verification +- supervisor: **50 pass** (incl. 7 stdio integration). type-check clean; `yarn format:check` clean. Additive — existing validate_code behavior unchanged besides the new field. +- Refreshed stale `platformos-check-node` dist (pre-existing: dist lacked `lintBuffer` though src had it) so the supervisor src-path type-check is clean too. + +This fulfills TASK-9 AC#5: the supervisor consumes the graph API and contains NO graph/scanner logic of its own. + + +## Final Summary + + +Completed as specified: `validate_code` gained a `dependencies` field populated from platformos-graph `extractFileReferences` via a new pure `src/structure/` I/O adapter (reusing check-common path.relative + getPosition, graph toSourceCode/extractFileReferences, check-node NodeFileSystem — no graph logic in the supervisor), architecture guard extended, TDD incl. end-to-end stdio-smoke. This fulfilled TASK-9 AC#5. + +SUPERSEDED by TASK-9.10: the per-file `dependencies`/`structural` fields were subsequently REMOVED and replaced by cross-file `impact` (blast radius), because lint already covers the forward/per-file view (MissingPartial/PartialCallArguments) — the graph's unique value is the backward/cross-file view. `src/structure/` was deleted in that refactor. So this task's deliverable shipped and then was intentionally replaced; closing as Done for the record (the work was executed to completion), with the supersession noted. See SUPERVISOR-GRAPH-INTEGRATION.md §1/§9. + diff --git a/.backlog/tasks/task-9.6 - Model-platform-facts-in-platformos-graph-schema-CustomModelType-nodes-graphql-table-page-slug.md b/.backlog/tasks/task-9.6 - Model-platform-facts-in-platformos-graph-schema-CustomModelType-nodes-graphql-table-page-slug.md new file mode 100644 index 00000000..be08ac99 --- /dev/null +++ b/.backlog/tasks/task-9.6 - Model-platform-facts-in-platformos-graph-schema-CustomModelType-nodes-graphql-table-page-slug.md @@ -0,0 +1,106 @@ +--- +id: TASK-9.6 +title: >- + Model platform facts in platformos-graph: schema/CustomModelType nodes + + graphql table + page slug +status: Done +assignee: [] +created_date: '2026-06-30 11:30' +updated_date: '2026-06-30 12:15' +labels: [] +dependencies: [] +references: + - docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md + - packages/platformos-graph/src/graph/build.ts + - packages/platformos-graph/src/graph/module.ts + - packages/platformos-graph/src/types.ts +parent_task_id: TASK-9 +priority: medium +--- + +## Description + + +## Why +Resource/CRUD completeness (old `detectResources`) needs three facts the AppGraph does not model today. Per ADR 004, only the PLATFORM-TRUTH subset belongs in the graph; the core-module commands/queries convention does NOT (that is TASK-9.7). This task adds the neutral platform facts so a convention layer can later compose them. + +## Platform facts to model (neutral, always-true — NO convention) +1. **Schema / CustomModelType as first-class graph nodes.** Custom model types ARE a platformOS primitive (`PlatformOSFileType.CustomModelType`; dirs `custom_model_types`/`model_schemas`/`schema`). Model them as graph modules with their table name. Decide table-name source: file basename vs. parsed `name:` — reuse check-common frontmatter/YAML parsing, do NOT hand-roll. +2. **A graphql op's `table`** on `GraphQLModule` (platform GraphQL concept; old scanner regex-parsed `table: { value: "..." }`). Prefer reusing any existing check-common graphql parsing over a bespoke regex. +3. **Page `slug`** (frontmatter — platform). Coordinate with TASK-9.3 (per-file self-structural) to avoid duplicate frontmatter parsing; reuse check-common. + +## Hard constraints (ADR 004) +- NEUTRAL ONLY. Do NOT add command/query kinds, pluralize, resource grouping, or CRUD expectations — those are convention (TASK-9.7). +- Additive to types/build/traverse; re-verify LSP consumers (references/dependencies/dead_code) + check-common stay green. Changes to shared types additive. +- Reuse check-common (frontmatter, schema, graphql parsing, docset); compose, never re-derive. +- Decide entry-point inclusion: schema files are not render-reachable, so buildAppGraph must include them as nodes deliberately (mirror how pages/layouts are discovered) without breaking existing reachability/orphan semantics. + +## Out of scope +- The commands/queries convention + resource/CRUD completeness map/warnings (TASK-9.7). +- Supervisor-side shaping (consumes the finished graph after this lands). + +## References +- ADR docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md +- Old shape: pos-mcp src/core/project-scanner.js detectResources (git f60bc39) +["platformos-graph models schema/CustomModelType files as neutral graph nodes carrying their table name; included in buildAppGraph without breaking existing reachability/orphan semantics", "GraphQLModule exposes its `table` (when declared), sourced by reusing check-common graphql parsing rather than a bespoke regex where possible", "Page modules expose `slug` from frontmatter, reusing check-common parsing (coordinated with TASK-9.3, no duplicate parser)", "NO convention is introduced: no command/query kinds, no pluralize, no resource grouping, no CRUD expectations (enforced by review against ADR 004)", "Additive; graph + check-common + LSP consumers re-verified green; type-check + prettier clean; each new fact has unit pins"] + + +## Acceptance Criteria + +- [x] #1 platformos-graph models schema/CustomModelType files as neutral graph nodes carrying their table name; included in buildAppGraph without breaking existing reachability/orphan semantics +- [x] #2 GraphQLModule exposes its `table` (when declared), sourced by reusing check-common graphql parsing rather than a bespoke regex where possible +- [x] #3 Page `slug` is MOVED to TASK-9.3 (it is a self-structural fact in 9.3's list and needs 9.3's per-file module-property mechanism + shared frontmatter parse; doing it here would duplicate/pre-empt 9.3). Not in 9.6 scope. +- [x] #4 NO convention is introduced: no command/query kinds, no pluralize, no resource grouping, no CRUD expectations (enforced by review against ADR 004) +- [x] #5 Additive; graph + check-common + LSP consumers re-verified green; type-check + prettier clean; each new fact has unit pins + + +## Implementation Notes + + +## Sub-phase plan (2026-06-30) — investigation findings + sequencing + +The three platform facts are NOT uniformly 'additive'; investigation found two catches, so 9.6 is delivered as three independently-verified slices in this order: + +**Slice 1 — graphql `table` (SAFEST, first).** Optional field on `GraphQLModule` (a leaf node that only exists via edges → ZERO orphan impact; no TASK-9.3 overlap). Only cost: graphql-AST extraction of the platformOS `table` filter — reuse check-common graphql parsing (graphql-variables check already parses graphql), NOT the old fragile regex `table:\s*(?:\{\s*value:\s*"(\w+)"|"(\w+)")`. + +**Slice 2 — schema/CustomModelType nodes (needs care).** FINDING: a schema file has no incoming render edges, so the Phase-1 `isOrphan` would WRONGLY flag every schema as dead code. So this slice MUST add an explicit, tested guard excluding non-render node kinds from orphan detection (touches shipped query.ts behavior — deliberate, not additive). New `ModuleType.Schema` (blast radius is contained: only `traverseModule`'s switch + `assertNever` over ModuleType, which compile-forces handling; LSP does NOT switch on ModuleType). Table name from YAML `name:` (old scanner used `parsed.name`; reuse js-yaml, already a graph dep). Build-time discovery: schema files aren't render-reachable, so buildAppGraph must add them as standalone nodes. + +**Slice 3 — page `slug` (coordinate with TASK-9.3).** FINDING: graph traversal currently only produces EDGES (`bind`), never module PROPERTIES (beyond `exists`); `slug` is a self-structural property = TASK-9.3's domain. Reuse `extractRelativePagePath` + `slugFromFilePath` + `formatFromFilePath` (exported from platformos-common) for the path-derived slug; frontmatter `slug:` override = effective slug (RouteTable's `extractFrontmatter` is private — not reusable). Do this WITH/INSIDE TASK-9.3 to establish the module-self-structural mechanism once, avoiding the duplicate-parser the AC#3 warns against. + +Each slice: TDD, additive where possible, cross-package re-verified (graph + check-common + LSP), type-check + prettier clean before the next. + +## Slice 1 DONE (2026-06-30): graphql `table` — AC#2 ✓ + +- **check-common**: new `src/graphql-table.ts` `extractGraphqlTable(content) -> string | undefined` — AST-based via the `graphql` parser this package already owns (reuses `parse`+`visit` from graphql/language; NOT the old fragile regex). Handles `table: { value: "x" }` + `table: "x"` shorthand, first-wins, namespaced names; returns undefined for dynamic `$var` table values (never records a bogus table), `table:` used as a GraphQL alias, no-table ops, and unparseable input. Exported from the package index (parity with getPosition/levenshtein). +- **graph**: `GraphQLModule.table?: string` (additive optional). Populated in `traverseModule`'s GraphQL leaf branch by reading the resolved op's source once (via deps.getSourceCode) and calling `extractGraphqlTable`. Only set on existing ops (exists:false targets are never read → no table). The per-file `extractFileReferences` primitive is unaffected (it doesn't traverse targets) — table is a full-build node fact, which is what TASK-9.7 needs. +- NEUTRAL: no convention introduced (ADR 004). SerializableNode intentionally left unchanged (table is an in-memory node fact for the query/overlay layer; serialize is the CLI surface — additive later only if needed). + +**Tests (thorough, many variants)**: `graphql-table.spec.ts` 13 unit variants (object-form, shorthand, mutation/record_create, deep nesting, order-independence, namespaced, sibling-`value` non-confusion, dynamic-`$var`→undefined, alias-`table:`→undefined, malformed, empty). `traverse-edges.spec.ts` +2 build-time integration (new `fixtures/graphql-table/`: with_table→'blog_post', without_table→undefined) + updated the existing graphql-edges assertion (find.graphql now carries table 'blog_post'). + +**Verification**: graph 63, check-common type-check clean + spec green, graphql-table 13, prettier clean, dists rebuilt; LSP + supervisor type-check clean. Consumer suites (check-common + LSP) running for final confirmation. + +Remaining: Slice 2 (schema nodes + orphan guard), Slice 3 (page slug, with TASK-9.3). + +## Slice 2 DONE (2026-06-30): schema/CustomModelType nodes — AC#1 ✓ (AC#4 holds) + +- **types.ts**: new `ModuleType.Schema` + `SchemaModule { kind:'schema', table? }` added to the `AppModule` union. `table` = the model name (YAML `name:`), named to align with `GraphQLModule.table` so TASK-9.7 can join op→schema. +- **module.ts**: `getSchemaModule(graph, uri)` factory (normalizes URI, mirrors getGraphQLModuleByUri). +- **traverse.ts**: `traverseModule` gains a `ModuleType.Schema` leaf case (reads source once, sets `table` via new `schemaTableName()` — a top-level YAML `name:` read using js-yaml, the parser already used for frontmatter here; not a bespoke parser). `assertNever` compile-confirmed the case is handled. +- **build.ts**: on a FULL build only (entryPoints undefined), discovers schema files (`getFileType(uri)===CustomModelType` over `.yml`/`.yaml`, reusing check-common classification) and adds them as standalone leaf nodes via `getSchemaModule`+`traverseModule`. NOT entry points — so render reachability/orphan semantics are untouched. Explicit-entryPoints (scoped/LSP) builds are byte-unchanged. +- **query.ts `isOrphan`**: added a guard — `ModuleType.Schema` is NEVER an orphan (schemas are referenced by table name from graphql/commands, not by file edges, so 'no incoming edges' is meaningless for them). Prevents the false-positive dead-code flag the Phase-1 semantics would otherwise produce. +- NEUTRAL (ADR 004): only the platform fact (the schema file + its table name) is modeled; NO commands/queries convention, pluralize, grouping, or CRUD expectations. + +**Blast radius (verified minimal)**: the only exhaustive switch over ModuleType is `traverseModule` (handled; assertNever-guarded). No `dead_code` impl exists anywhere (only Phase-1 query.ts, now guarded). SerializableNode = Pick<...'kind'|'exists'> — schema nodes serialize fine, `table` intentionally not in SerializableNode (in-memory node fact for the query/overlay layer). + +**Tests (thorough)**: new `fixtures/schema-nodes/` (page + blog_post.yml with name + no_name.yml without). traverse-edges.spec +3 (schema node with table='blog_post'; no-name→table undefined; schema NOT an entry point). query.spec +1 (schema never orphan) + strengthened orphans() to assert schema excluded. Graph suite 67 pass. + +**Verification**: graph 67, all four packages type-check clean, prettier clean, dist rebuilt. Supervisor + LSP suites running (validate_code must stay byte-identical; only pre-existing TypeSystem timeout expected in LSP). + +Remaining: Slice 3 (page slug, folded into TASK-9.3). + +## Re-scoped + CLOSED (2026-06-30): slice 3 (page slug) moved to TASK-9.3 + +Decision: page `slug` is one of TASK-9.3's listed self-structural facts (renders/graphql/filters/tags/translation_keys/doc_params/**slug**/**layout**/**method**) and needs 9.3's general per-file module-property extraction mechanism + the single shared frontmatter parse (9.3 AC#2: 'no second parser'). Implementing slug standalone in 9.6 would either duplicate that or be ripped out when 9.3 lands. So slug migrates to TASK-9.3. + +TASK-9.6 delivered the two genuinely-9.6 NEUTRAL platform facts (ADR 004): graphql `table` (slice 1) + schema/CustomModelType nodes (slice 2). Both shipped, tested, cross-package green: graph 67, check-common 1047, supervisor 50 (validate_code byte-identical), LSP 467/467; type-check + prettier clean; dists rebuilt. AC#3 reframed as 'moved to 9.3'. Closing as Done. + diff --git "a/.backlog/tasks/task-9.7 - Resource-CRUD-completeness-as-a-convention-overlay-commands-queries-\342\200\224-domain-map-configurable-check.md" "b/.backlog/tasks/task-9.7 - Resource-CRUD-completeness-as-a-convention-overlay-commands-queries-\342\200\224-domain-map-configurable-check.md" new file mode 100644 index 00000000..13f3a4d0 --- /dev/null +++ "b/.backlog/tasks/task-9.7 - Resource-CRUD-completeness-as-a-convention-overlay-commands-queries-\342\200\224-domain-map-configurable-check.md" @@ -0,0 +1,51 @@ +--- +id: TASK-9.7 +title: >- + Resource/CRUD completeness as a convention overlay (commands/queries) — domain + map + configurable check +status: To Do +assignee: [] +created_date: '2026-06-30 11:30' +updated_date: '2026-06-30 11:30' +labels: [] +dependencies: + - TASK-9.6 +references: + - docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md +parent_task_id: TASK-9 +priority: low +--- + +## Description + + +## Why +The old `detectResources` resource/CRUD completeness. Per ADR 004 this is CONVENTION truth (the `core` module's commands/queries pattern), NOT platform truth, so it must live OUTSIDE platformos-graph's neutral model — layered on top of the platform facts from TASK-9.6. + +## Split by output type (ADR 004) +1. **Descriptive resource map** ("resource/table → its commands/queries/graphql/pages"): project-map data to show an agent; not an offense. Home: the supervisor per-domain layer (TASK-8) OR a clearly-quarantined `platformos-graph/conventions/*` exported separately from the neutral query API. Preferred: domain layer, keeping the graph pristine. Composes TASK-9.6 facts (schema tables, graphql table, slug) + the graph's function-edges-to-partials grouped by convention. +2. **Prescriptive completeness warnings** ("table X has a query but no create command/mutation"): opinionated + toggleable → a configurable custom check in platformos-check-common (e.g. `ResourceCompleteness`), scoped to schema files, default-off or clearly advisory, consuming the project graph. Apps not following the convention disable it via `.platformos-check.yml`. + +## Convention rules (mirror old detectResources, but as a labeled overlay) +- pluralize(table) grouping; `/commands/{plural}/`, `/queries/{plural}/` path roots; graphql by `{plural}/` prefix OR graphql `table` (from TASK-9.6); pages by slug === plural or `{plural}/` prefix; expected ops: search/list, find/get, create, update, delete. +- Command/query path roots MUST be configurable (module-defined convention; do NOT hardcode unconfigurably) — see ADR 004 "deepest layer". + +## Hard constraints +- platformos-graph stays convention-free (ADR 004). This overlay consumes graph facts; it does not push convention into the graph core. +- Supervisor remains a pure consumer. +- Depends on TASK-9.6 (platform facts). + +## References +- ADR docs/mcp-supervisor/decisions/004-platform-facts-vs-conventions/README.md +- Old impl: pos-mcp src/core/project-scanner.js detectResources (git f60bc39) +["Resource/CRUD completeness is implemented as a convention overlay OUTSIDE platformos-graph's neutral model (domain layer for the descriptive map; configurable check for prescriptive warnings) per ADR 004", "Descriptive map groups commands/queries/graphql/pages per table by convention, composing TASK-9.6 platform facts; command/query path roots are configurable, not hardcoded", "Prescriptive completeness warnings are a toggleable check (default-off or advisory), disableable via .platformos-check.yml for apps not using the convention", "platformos-graph contains NO command/query/resource convention; supervisor contains no bespoke graph/scanner logic", "Depends on TASK-9.6; TDD with fixtures; all consumers re-verified green"] + + +## Acceptance Criteria + +- [ ] #1 Resource/CRUD completeness is implemented as a convention overlay OUTSIDE platformos-graph's neutral model (domain layer for the descriptive map; configurable check for prescriptive warnings) per ADR 004 +- [ ] #2 Descriptive map groups commands/queries/graphql/pages per table by convention, composing TASK-9.6 platform facts; command/query path roots are configurable, not hardcoded +- [ ] #3 Prescriptive completeness warnings are a toggleable check (default-off or advisory), disableable via .platformos-check.yml for apps not using the convention +- [ ] #4 platformos-graph contains NO command/query/resource convention; supervisor contains no bespoke graph/scanner logic +- [ ] #5 Depends on TASK-9.6; TDD with fixtures; all consumers re-verified green + diff --git "a/.backlog/tasks/task-9.8 - Code-review-remediation-supervisor\342\207\204graph-integration-findings.md" "b/.backlog/tasks/task-9.8 - Code-review-remediation-supervisor\342\207\204graph-integration-findings.md" new file mode 100644 index 00000000..f41d6a9b --- /dev/null +++ "b/.backlog/tasks/task-9.8 - Code-review-remediation-supervisor\342\207\204graph-integration-findings.md" @@ -0,0 +1,110 @@ +--- +id: TASK-9.8 +title: 'Code-review remediation: supervisor⇄graph integration findings' +status: Done +assignee: [] +created_date: '2026-07-01 11:46' +updated_date: '2026-07-03 07:38' +labels: + - code-review + - platformos-graph + - mcp-supervisor + - tech-debt +dependencies: [] +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +High-effort code review of the `supervisor-graph-integration` branch (`git diff master...HEAD`, 6 commits wiring platformos-graph into `validate_code` + graph edges/query-API/self-structural/table-facts + `'layout'` DocumentType). 8 finder angles + verification. Overall the branch is additive, well-tested (graph 73 / supervisor 50 / check-common 1047 / LSP 467, CI green both OSes), ADR 003/004 separation holds, no CLAUDE.md violations, no type/exhaustiveness breaks. This task records the actionable findings and tracks fixing them one-by-one with the full suite green after each. + +Each acceptance-criterion maps to one finding, ranked by severity. Verified-clean / refuted items are recorded in the plan for the record. Reference: SUPERVISOR-GRAPH-INTEGRATION.md (§6 open doubts), docs/mcp-supervisor/decisions/003 & 004. + +Working directory: ~/Work/platformos-tools/platformos-tools. Constraint: surgical precision, no behavioral regressions, TDD, whole-value test assertions per repo CLAUDE.md. + + +## Acceptance Criteria + +- [x] #1 F1 [Architecture/High] Self-structural is reachable per-buffer: `extractStructural` is exported as a per-file primitive (sibling to extractFileReferences), the supervisor structure adapter populates ValidateCodeResult.structural from the in-flight buffer, and the full-build path no longer computes structural on every LSP rebuild for a fact the LSP never reads (gated/opt-in). No serialized-output change. +- [x] #2 F2 [Robustness/Med] validate_code structural resolution can never sink the primary lint gate: runStructure failure degrades to empty dependencies (try/catch or Promise.allSettled) so lint diagnostics + must_fix gate are always returned. +- [ ] #3 F3 [Efficiency/Med] The in-flight buffer is parsed exactly once per validate_code call: the parsed SourceCode is shared between runLint and runStructure instead of each re-parsing the same string. +- [x] #4 F4 [Altitude/Med] Schema-node discovery is not gated on the fragile `entryPoints === undefined` proxy in a way that silently drops schema nodes for a scoped full scan; standalone/leaf nodes carry an explicit flag so whole-graph queries (isOrphan etc.) read one property instead of enumerating ModuleType. +- [x] #5 F5 [Reuse/Med] Shared helpers replace three duplications: (a) project-relative absolute-path resolution shared between lint.ts and structure.ts; (b) slug override rule reuses RouteTable rather than re-deriving in the graph; (c) the translation-key usage predicate is shared between the translation-key-exists check and extractStructural. +- [x] #6 F6 [Efficiency/Low-Med] extractStructural walks each file once (doc-params + frontmatter folded into the single AST visit) and build.ts enumerates the project directory once (entry-points + schema nodes partitioned from one sweep). +- [x] #7 F7 [Altitude/Low] Schema table-name extraction is owned beside the parser as an exported check-common helper (mirroring extractGraphqlTable), not parsed inline in traverse.ts. +- [x] #8 F8 [Correctness/Low] Non-string frontmatter `slug` (YAML list/map) does not surface as a coerced bogus slug ([object Object]/comma-joined); effectiveSlug and schemaTableName treat non-string values consistently. +- [x] #9 F9 [Altitude/Low] ValidateCodeDependency.kind is either a genuine ReferenceKind→agent-kind mapping owned by the supervisor, or the precise union type — not a nominal stringly-typed passthrough. +- [x] #10 F10 [Latent/Low] Layout node identity does not depend on two URI producers (findAllFiles raw vs DocumentsLocator+normalize) agreeing: entry-point URIs are normalized so the layout edge target always dedupes with the discovered entry-point node; covered by a cross-platform test. +- [x] #11 F11 [Simplification/Minor] argNames simplified (no statically-always-true filter); the 'args only when non-empty' spread is de-duplicated across bind + extractFileReferences; the single-use frontmatterBody/loadFrontmatterOf two-hop chain is collapsed. +- [x] #12 Full suite green after every fix: yarn workspace @platformos/platformos-graph test, platformos-check-common, platformos-mcp-supervisor, platformos-language-server-common; yarn type-check; yarn format:check; --frozen-lockfile. + + +## Implementation Plan + + +Fix order (small/isolated first to keep suite green, extractStructural-touching ones grouped, big architectural F1 after its dependencies land): + +1. F2 — isolate runStructure failure (transport/validate-code.ts + structure.ts). +2. F3 — parse in-flight buffer once, share SourceCode across runLint/runStructure. +3. F5a — shared project-relative absolute-path resolver between lint.ts + structure.ts. +4. F8 — non-string slug guard in effectiveSlug (+ align schemaTableName) + test. +5. F11 — argNames simplification, args-spread dedup, collapse frontmatterBody/loadFrontmatterOf. +6. F7 — export extractSchemaTable from check-common, consume in traverse.ts (mirror extractGraphqlTable). +7. F5b/F5c — reuse RouteTable slug rule; share translation-key predicate with the check. +8. F6 — fold doc-params/frontmatter into the single AST visit; one directory sweep in build.ts. +9. F1 — export extractStructural as per-file primitive; supervisor populates validate_code.structural per-buffer; LSP opts out of eager structural (buildAppGraph option, default on = ADR-003-compliant). Update structural.spec to test the exported primitive. +10. F4 — explicit leaf/standalone-node flag so isOrphan & whole-graph queries stop enumerating ModuleType.Schema; decouple schema discovery from the entryPoints===undefined proxy where it silently drops nodes. +11. F9 — ReferenceKind→agent-kind mapping (or precise union) for ValidateCodeDependency.kind. +12. F10 — normalize entry-point URIs (getLayoutModule/getPageModule) so layout edge target always dedupes; cross-platform test. + +VERIFIED-CLEAN / REFUTED (recorded, no action): +- Conventions: no CLAUDE.md violations (path normalization correct; whole-value toEqual in new specs). +- Type-safety/exhaustiveness intact (assertNever compiles; SerializableNode does not leak structural/table; dependencies required-field wired through the sole constructor assembleResult). +- path.relative arg order correct; runStructure safe on non-Liquid buffers (resolver returns [] on Error AST); Reference.args in serialized edges is intended/additive; nearestModules empty-when-unmaterialized is documented by-design; Windows schema getFileType uses same normalized input as pre-existing isPage/isLayout. +- Layout dedup currently holds & is green on Linux+Windows CI (traverse-edges.spec.ts) — F10 is hardening, not a live bug. +- Whole-project getApp re-glob (no memo) and per-file DocumentsLocator (no cross-file memo) are PRE-EXISTING, out of this branch's scope — not fixed here. + +Note on F1 vs ADR 003: ADR 003 resolved that full-build populates LiquidModule.structural (overlay/TASK-9.7 will consume it). So F1 keeps eager population as the default and adds an LSP opt-out, rather than removing it — respecting the ADR while eliminating the per-keystroke waste. + + +## Implementation Notes + + +F2 DONE: runValidateCode now orchestrates lint (primary) + structure (secondary) with the structure adapter's failure degrading to empty `dependencies` (logged) via an injectable-adapters seam; a lint failure still propagates. New unit spec transport/validate-code.spec.ts (3 tests: both-succeed passthrough, structure-fail degrade+log, lint-fail propagate). Supervisor suite 53 green; package type-check clean. + +F3 DEFERRED (engineering judgment, needs sign-off): eliminating the edited-buffer double-parse (structure's toSourceCode + lint's internal overlayBuffer parse) requires an ADDITIVE change to check-node's shared `lintBuffer` public API (accept a pre-parsed overlay SourceCode) plus reconciling per-file-type parse representations (graph toSourceCode returns AssetSourceCode for .js, not a check-common SourceCode) and URI normalization across the two adapters. The saving is a single small buffer parse — marginal against the unavoidable whole-project `getApp` parse `lintBuffer` runs every call, which is the dominant cost and is PRE-EXISTING/out-of-branch-scope. Confirmed graph `toSourceCode` delegates to check-common `toSourceCode` for liquid/graphql/yml, so it is *feasible*, but the API-coupling/blast-radius on a shared package outweighs the micro-opt under the 'don't break anything' constraint. Recommend instead: memoize getApp per projectDir (the high-value lever) as separate, pre-existing-scope work. + +F5a DONE: extracted shared `AdapterInput` type + `toAbsoluteFilePath()` into src/adapter-input.ts; lint.ts and structure.ts both consume it (dropped duplicated node:path isAbsolute/join). Supervisor 53 green, type-check clean. + +F5b+F8 DONE (same region): introduced `effectivePageSlug(relativeToPages, frontmatter)` in platformos-common route-table/slugFromFilePath.ts as the single override-wins-else-path-derived slug rule; RouteTable.addPageFromContent and the graph's effectiveSlug both delegate to it. Fixes a real drift (graph previously ignored a frontmatter `format:` override that RouteTable honours) AND makes non-string slug coercion consistent with the routing source of truth (RouteTable already does String(slug), so rejecting non-strings would DISAGREE with real routing — sharing is the correct fix, not divergence). Added 12 effectivePageSlug cases (numeric/boolean/empty/null/undefined override, format override, non-string format). common 257 green (RouteTable 43 unaffected), graph 73 green, both type-check + common dist rebuilt. + +F11 DONE: argNames simplified to `args.length > 0 ? args.map(a=>a.name) : undefined` (the per-element type/name guard was statically always-true over LiquidNamedArgument[]; the completion-context placeholder only arises in the LSP's special parse, never the graph's full parse — docstring corrected). Introduced `argsField(args)` helper as the single 'omit args when empty' rule; bind + extractFileReferences both spread `...argsField(...)` (de-duplicated, and keeps bind robust as public API). Collapsed the single-use frontmatterBody→loadFrontmatterOf two-hop chain into one loadFrontmatterOf. graph 73 green, type-check clean. + +F7 DONE: added exported `extractSchemaTable(content)` in platformos-check-common/src/schema-table.ts (mirrors extractGraphqlTable; reuses check-common's own js-yaml which it already depends on + uses in context-utils/undefined-object). Verified premise: co-locating in check-common is genuine reuse, not a new concern. Removed the graph-local `schemaTableName`; traverse.ts leaf case now calls extractSchemaTable. graph loadFrontmatter retained (still used by layout resolver + self-structural). New schema-table.spec.ts (10 whole-value cases incl. non-string list/mapping/numeric name, unparseable, non-mapping, empty). check-common type-check + dist rebuilt; graph 73 green. + +F5c DONE: extracted `isTranslationKeyUsage(node)` (+ TRANSLATION_FILTERS) into platformos-check-common/src/translation-usage.ts as the single 'string literal piped through t/translate' predicate (type-guard narrowing expression to LiquidString). TranslationKeyExists check now calls it (dropped its inline `'String'` + filter check); graph extractStructural calls it (dropped its NodeTypes.String + ||-filter dup). Exported from check-common index. Full check-common suite 1057 green (translation-key-exists 9 green, unchanged behavior), dist rebuilt, graph 73 green + type-check. F5 (a+b+c) complete. + +F6 DONE: (1) extractStructural now does a SINGLE AST visit — doc `@param` names are collected in the same pass via a `LiquidDocParamNode` handler reading the parser-produced `node.paramName.value` (same field extractDocDefinition reads; not re-implementing the liquid-doc parser), dropping the separate extractDocDefinition traversal; doc_params preserved in source order (not sorted/deduped). (2) build.ts full build now does ONE recursiveReadDirectory sweep whose predicate admits pages/layouts .liquid + CustomModelType .yml/.yaml, partitioned by extension — eliminating the second full-tree walk. graph 73 green. NOTE: direct `npx tsc --noEmit` caught a control-flow-narrowing error (entryPoints possibly undefined) that the `yarn workspace type-check` wrapper + vitest masked — fixed by branching on `entryPoints === undefined` directly (dropped the isFullBuild boolean intermediary). Switching to direct tsc for type-checks henceforth. + +F1 DONE (per user decision: full fix). (1) GRAPH: `extractStructural` exported as a per-file primitive (sibling to extractFileReferences); made non-Liquid-safe (returns undefined for non-LiquidHtml/unparseable source). (2) GATE: buildAppGraph gained a 4th arg `options: GraphBuildOptions = {}` with `includeStructural` (default OFF); traverseModule/traverseLiquidModule thread it; eager module.structural population now only happens when opted in — so the LSP full build (the only full-build caller, which never reads structural; verified no external .structural consumer) stops paying for it. (3) SUPERVISOR: structure adapter now returns `{ dependencies, structural }` from ONE shared parse (extractFileReferences + extractStructural via Promise.all on the same SourceCode); validate_code.structural is populated per-buffer; added `graphql_queries_used` to ValidateCodeStructuralSnapshot for parity with the graph ModuleStructural + the original supervisor's structural; routing facts map optional→null. (4) F2 degrade updated to `{dependencies:[],structural:null}`. Tests: structural.spec rewritten (10 — primitive-direct + opt-in/opt-out); structure.spec (16 — dependencies + structural snapshot + non-liquid null); assemble.spec (7, +structural passthrough); validate-code.spec (3, structural flow); stdio-smoke (7 whole-result e2e now assert populated structural incl. tags_used ['function','render']/['assign','render']). Supervisor 56 green; graph 77 green; LSP 467/467 in isolation (full-suite shows only the documented TypeSystem parallel-load flake, confirmed passing isolated in 3.1s); all type-checks clean; dists rebuilt. + +F4 DONE (per user decision: document contract, keep type discriminant — no speculative flag). Deliberately did NOT add a `standalone`/`reachabilityParticipating` boolean: with a single non-reachability leaf kind (Schema), a parallel flag set exactly when type===Schema is MORE state to keep in sync, not less — the discriminated union IS the idiomatic single-property check (YAGNI until a 2nd such kind appears). Instead: buildAppGraph JSDoc now explicitly documents the full-build (auto-discovers pages+layouts+schema) vs scoped (verbatim; schema NOT auto-discovered) contract, so the behavior is no longer a 'silent' inference from `entryPoints === undefined`; isOrphan keeps its typed `ModuleType.Schema` guard with its rationale comment. No caller currently hits the scoped-full-scan path. + +F9 DONE: made ValidateCodeDependency.kind a genuine seam. Defined supervisor-owned `ValidateCodeDependencyKind` union in result/types.ts (the agent contract, decoupled from upstream ReferenceKind), typed kind as that union (was `string`). structure.ts maps ref.kind→agent-kind via an exhaustive `const DEPENDENCY_KIND: Record` — so an upstream ReferenceKind add/rename fails to compile at the adapter (no silent drift), while names stay 1:1. Supervisor type-check + full suite green. + +F10 DONE (hardening — invariant already held on CI, now enforced): getLayoutModule + getPageModule now `path.normalize` their stored uri, matching the other 4 module factories (getLayoutModuleByUri/getPartialModuleByUri/getSchemaModule/getGraphQLModuleByUri). So a layout/page discovered as an entry point (raw fs URI) keys identically to the same file resolved as an edge target (DocumentsLocator+normalized) — one node, never split identity. New module.spec.ts (3 tests) proves dedup across backslash vs forward-slash URIs (getLayoutModule≡getLayoutModuleByUri, getPageModule idempotent, getPartialModuleByUri regression guard) — each would FAIL without the fix. POSIX fixtures unaffected (normalize is a no-op there); graph 80 green. + +FINAL VERIFICATION (all green): type-check (direct tsc) clean for all 5 touched packages (common, check-common, graph, mcp-supervisor, language-server-common). Test tallies: platformos-common 257; platformos-check-common 1057; platformos-graph 80; platformos-mcp-supervisor 56; platformos-language-server-common 467 (466 under full-suite parallel load due only to the pre-existing TypeSystem.spec 5s-timeout flake, confirmed passing in isolation at 3.1s — unrelated to this work, documented). `yarn format:check` clean (formatted 2 new spec files). `yarn install --frozen-lockfile` clean with ZERO yarn.lock churn (no new deps — all reuse of existing js-yaml etc.). All dists rebuilt (common, check-common, graph) so runtime consumers resolve the new exports. + +SUMMARY: 10 of 11 findings fixed (F1,F2,F4,F5a/b/c,F6,F7,F8,F9,F10,F11); F3 deferred with documented rationale + sign-off recommendation (needs a shared-package API change for marginal gain vs the pre-existing getApp cost). Net new tests: +30 across the suites. All changes additive/behaviour-preserving except the two intended output improvements (validate_code.structural now populated per-buffer; ValidateCodeDependency.kind now a typed union) and one perf-motivated default change (graph module.structural now opt-in, gating the LSP). + + +## Final Summary + + +Code-review remediation complete: 10 of 11 findings fixed (F1 self-structural per-buffer + LSP opt-out; F2 impact/structure never sinks the lint gate; F4 schema-discovery contract documented; F5a/b/c shared helpers — toAbsoluteFilePath, effectivePageSlug via RouteTable, isTranslationKeyUsage; F6 single AST walk + single directory sweep; F7 extractSchemaTable in check-common; F8 non-string slug guard; F9 typed ValidateCodeDependencyKind mapping; F10 normalized entry-point URIs; F11 argNames/args-spread/frontmatter simplifications). +30 tests. All 5 packages type-check (direct tsc) + format + frozen-lockfile clean; suites green (common 257, check-common 1057, graph 80, supervisor 56, LSP 467 isolated — the one full-suite miss is the documented pre-existing TypeSystem parallel-load flake). + +F3 (share the in-flight buffer parse between lint and structure) was DEFERRED with signed-off rationale (needed a shared check-node API change for a marginal gain vs the dominant getApp parse cost). It is now largely MOOT: the `structure/` adapter was removed in TASK-9.10, so there is no runStructure re-parse; the residual buffer double-parse (lint overlay vs impact docSignature) is a low-priority efficiency item recorded in the later code review, off the critical path. The higher-value lever F3 pointed to — getApp memoization — shipped as TASK-9.13 (Done). Closing on completed scope. + diff --git a/.backlog/tasks/task-9.9 - Graph-asset-resolution-fix-real-project-validation-frontmatter-Liquid-gap.md b/.backlog/tasks/task-9.9 - Graph-asset-resolution-fix-real-project-validation-frontmatter-Liquid-gap.md new file mode 100644 index 00000000..074af82f --- /dev/null +++ b/.backlog/tasks/task-9.9 - Graph-asset-resolution-fix-real-project-validation-frontmatter-Liquid-gap.md @@ -0,0 +1,67 @@ +--- +id: TASK-9.9 +title: Graph asset resolution fix + real-project validation (frontmatter-Liquid gap) +status: Done +assignee: [] +created_date: '2026-07-01 18:38' +updated_date: '2026-07-03 07:39' +labels: + - code-review + - platformos-graph + - platformos-common + - bug + - validation +dependencies: [] +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +Ran `platformos-graph` against a real production project (~/Work/POS/tertius/marketplace-dcra: 1,505 nodes / 3,287 edges) to verify the branch's edge/structural extraction works reliably, not just "runs". Thorough consistency checks: referential integrity (0 dangling edges, 0 dup nodes, all edges have kind+range), determinism (byte-identical across two builds), exists=true accuracy (0 false positives in 200 sampled), coverage (pages 326/326 & layouts 13/13 exact), edge extraction reconciled statement-for-statement against source incl. `{% liquid %}` blocks (function 5=5, graphql 9=9), and every non-asset "missing" target confirmed GENUINE (real missing module files, an uninstalled module, and a real project typo `event_isnpections`) — i.e. the graph correctly flags real broken references. + +The validation surfaced TWO issues, captured here: + +BUG (FIXED) — asset resolution base path. The graph's `getAssetModule` resolved `asset_url` targets against `/assets/` (hardcoded), but platformOS assets live at `/app/assets/` (and `modules//public/assets/`). This is the ONE edge kind that bypassed DocumentsLocator (every other kind — render/include/function/graphql/layout — routes through it). Effect on the real project: 27 of 28 asset edges were FALSE `exists=false` (the files exist), plus malformed `assets/app/assets/...` URIs. Pre-existing (from the Jan-31 'rename packages' commit, not this branch), but a real reliability defect in the `exists`/missing-target signal for assets. The MissingAsset check and the LSP DocumentLinksProvider already resolved assets correctly via DocumentsLocator — only the graph reinvented it wrongly. + +LIMITATION (DEFERRED) — Liquid embedded inside frontmatter is not traversed. A reference inside a frontmatter YAML string value (e.g. `response_headers: > {%- include '...' -%}`) is a real dependency the graph does not extract: it models the Liquid BODY AST and reads frontmatter as YAML (for slug/layout/method). Consistent and defensible, but a known gap. Deciding whether to parse Liquid inside frontmatter string values needs design (which frontmatter keys, perf, how to represent the edge's source range). + +Working dir: ~/Work/platformos-tools/platformos-tools. + + +## Acceptance Criteria + +- [x] #1 Asset edges resolve through DocumentsLocator ('asset' type: app/assets, module public/assets) instead of a hard-coded base — matching every other edge kind and the MissingAsset check/LSP. +- [x] #2 A resolved asset yields an exists:true Asset node at its real app/assets path; a missing asset yields an exists:false node at the canonical app/assets/ path (missing-asset detection preserved). +- [x] #3 The historical supported-extension gate is preserved (asset_url on a non-asset value creates no edge). +- [x] #4 TDD: new asset-edges fixture + traverse-edges tests (resolved, top-level, missing, exact-edge-set) written RED-then-GREEN; DocumentsLocator.locateDefault('asset') unit tests (app + module + locateOrDefault fallback) added; the non-conformant skeleton fixture moved to app/assets with build.spec/query.spec/cli.spec expectations updated. +- [x] #5 Verified on the real project: asset false-missing count drops from 27 to 1 (the 1 genuinely-absent asset), targets resolve under app/assets, previously-false-negative assets now exists:true. +- [x] #6 All suites green + type-check (direct tsc) + format:check + frozen-lockfile. +- [ ] #7 DEFERRED: decide + (if accepted) implement extraction of Liquid references embedded in frontmatter string values (response_headers etc.), or document it as an explicit non-goal in the graph docs. + + +## Implementation Notes + + +ASSET FIX (done): routed the graph's asset edge through DocumentsLocator like every other kind. +- traverse.ts asset visitor: `documentsLocator.locateOrDefault(rootUri, 'asset', name)` + `getAssetModuleByUri`; the supported-extension gate preserved via exported `isSupportedAssetFile`. +- module.ts: removed the buggy `getAssetModule` (hardcoded `/assets`); added `getAssetModuleByUri` (normalizes, mirrors the other ByUri factories) + `isSupportedAssetFile`; fixed getModule's (dead) asset branch to use the resolved URI. +- DocumentsLocator.ts: split `asset` out of the theme_render_rc/undefined group in `locateDefault` → canonical `app/assets/` (or `modules//public/assets/`), ext='' — so locateOrDefault yields a canonical target for a missing asset (missing-asset detection preserved). `locate('asset')` was already correct (app/assets) — only the graph had reinvented it. + +TDD: new fixtures/asset-edges + 4 traverse-edges tests (RED→GREEN); DocumentsLocator.spec updated (asset locateDefault app + module + 2 locateOrDefault cases; the old 'asset → undefined' expectation replaced); moved the non-conformant skeleton fixture assets/ → app/assets/ and updated build.spec (node+edge sort order), query.spec (reachableFrom order), cli.spec (node/edge sets + nodeFileSystem paths), supervisor structure.spec (asset target → app/assets/app.js). + +REAL-PROJECT VALIDATION (marketplace-dcra): asset false-missing dropped 27→1 (the 1 genuinely-absent asset); targets now resolve under app/assets; previously-false-negative emails/logo.png now exists:true. Referential integrity / determinism / exists-accuracy / coverage all clean (see description). + +VERIFICATION: common 261, graph 84, check-common 1057, supervisor 56, LSP 467 (1 full-suite fail = the documented TypeSystem parallel-load flake, passes 41/41 isolated; DocumentLinksProvider — the LSP asset consumer — 8/8). Direct-tsc type-check clean all packages; format:check clean; frozen-lockfile clean, zero yarn.lock churn. common+graph dist rebuilt. + +DEFERRED (AC #7): frontmatter-embedded Liquid (e.g. response_headers: > {%- include -%}) not extracted — needs a design decision (which keys, perf, source-range representation) or an explicit non-goal doc entry. Left as the one open AC. + + +## Final Summary + + +Asset-resolution bug FIXED + real-project validation done (ACs #1–#6). Routed the graph's `asset_url` edge through DocumentsLocator (`'asset'` type: app/assets, module public/assets) like every other edge kind, replacing the hard-coded `/assets/` base; preserved the supported-extension gate; missing asset → canonical `app/assets/` exists:false node. TDD: asset-edges fixture + traverse tests + DocumentsLocator locateDefault('asset') unit tests; skeleton fixture moved to app/assets with build/query/cli/structure specs updated. Verified on marketplace-dcra: asset false-missing dropped 27→1 (the 1 genuinely-absent asset); referential integrity / determinism / exists-accuracy / coverage all clean. All suites + direct-tsc type-check + format + frozen-lockfile green; common+graph dist rebuilt. + +AC#7 (extract Liquid embedded in frontmatter string values, e.g. `response_headers: > {%- include -%}`) was DEFERRED — it needs a design decision (which keys, perf, source-range representation). Moved to TASK-9.21 (with the theme_render_rc edge gap) so it is tracked; closing this task on its completed asset-fix + validation scope. + From 8012ea7e80a5d35f400ef1d521a376d1149ba77b Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 7 Jul 2026 12:59:43 +0200 Subject: [PATCH 17/20] refactor(graph): supervisor review round-2 remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round supervisor review of the supervisor-graph-integration branch (division-of-responsibility + test hygiene). Additive/no-break except the intended GraphQL table-shape widening; validate_code output is unchanged. - Move extractGraphqlTable + extractSchemaTable (impl + specs) from platformos-check-common to platformos-common: neutral platform-fact parsers with no lint/offense use, so they belong beside frontmatter/RouteTable/ DocumentsLocator. Add browser-safe `graphql` dep to common; graph imports from common; check-common drops the re-exports. Lossless move (check-common 1057->1034 tests, common +23). - Widen GraphQL table extraction: extractGraphqlTable (scalar, first-wins) -> extractGraphqlTables returning every distinct table in document order as string[] — across multiple records(...) blocks, aliased queries, and record_create/mutation inputs; dedup; empty for none/dynamic/unparseable. GraphQLModule.table?: string -> tables: string[] (required, always-present); SchemaModule stays scalar. Factory + deserialize seed tables: []; graph write-sites and specs updated. No supervisor/LSP/serialize reads .table(s). - Convert graph incremental.spec + deserialize.spec from on-disk temp projects (mkdtemp/NodeFileSystem) to the in-memory MockFileSystem (backing-object mutation for add/modify/delete); replace [...].join('\n') line-arrays with template literals. All 17 scenarios preserved; faster, no disk I/O. - Document AppCache placement in check-node (JSDoc): why it lives there (getApp globs disk = Node-only) and that no pre-existing mechanism duplicates it (LSP DocumentManager / supervisor GraphCache are different runtimes/payloads). No code moved. - Update SUPERVISOR-GRAPH-INTEGRATION.md current-state refs (package responsibilities, GraphQLModule shape, doubt #4 resolved). --- ...t-table-extraction-shape-mock-fs-tests.md" | 52 +++++ ...extractSchemaTable-to-platformos-common.md | 58 ++++++ ...ecs-to-MockFileSystem-template-literals.md | 51 +++++ ...-in-platformos-check-node-keep-in-place.md | 41 ++++ ...nd-covers-record_create-mutation-tables.md | 68 +++++++ SUPERVISOR-GRAPH-INTEGRATION.md | 27 ++- .../src/graphql-table.ts | 51 ----- packages/platformos-check-common/src/index.ts | 10 - packages/platformos-check-node/src/index.ts | 10 + packages/platformos-common/package.json | 1 + .../src/graphql-table.spec.ts | 58 ++++-- .../platformos-common/src/graphql-table.ts | 58 ++++++ packages/platformos-common/src/index.ts | 6 + .../src/schema-table.spec.ts | 0 .../src/schema-table.ts | 2 +- .../src/graph/deserialize.spec.ts | 116 +++++------ .../platformos-graph/src/graph/deserialize.ts | 4 +- .../src/graph/incremental.spec.ts | 188 +++++++++--------- .../platformos-graph/src/graph/incremental.ts | 15 +- packages/platformos-graph/src/graph/module.ts | 2 + .../src/graph/traverse-edges.spec.ts | 22 +- .../platformos-graph/src/graph/traverse.ts | 10 +- packages/platformos-graph/src/types.ts | 17 +- 23 files changed, 579 insertions(+), 288 deletions(-) create mode 100644 ".backlog/tasks/task-9.22 - Supervisor\342\207\204graph-review-remediation-round-2-package-placement-table-extraction-shape-mock-fs-tests.md" create mode 100644 .backlog/tasks/task-9.22.1 - Relocate-table-extraction-parsers-extractGraphqlTable-extractSchemaTable-to-platformos-common.md create mode 100644 .backlog/tasks/task-9.22.2 - Convert-graph-incremental-deserialize-specs-to-MockFileSystem-template-literals.md create mode 100644 .backlog/tasks/task-9.22.3 - Document-AppCache-rationale-in-platformos-check-node-keep-in-place.md create mode 100644 .backlog/tasks/task-9.22.4 - extractGraphqlTable-returns-ALL-declared-tables-array-and-covers-record_create-mutation-tables.md delete mode 100644 packages/platformos-check-common/src/graphql-table.ts rename packages/{platformos-check-common => platformos-common}/src/graphql-table.spec.ts (50%) create mode 100644 packages/platformos-common/src/graphql-table.ts rename packages/{platformos-check-common => platformos-common}/src/schema-table.spec.ts (100%) rename packages/{platformos-check-common => platformos-common}/src/schema-table.ts (91%) diff --git "a/.backlog/tasks/task-9.22 - Supervisor\342\207\204graph-review-remediation-round-2-package-placement-table-extraction-shape-mock-fs-tests.md" "b/.backlog/tasks/task-9.22 - Supervisor\342\207\204graph-review-remediation-round-2-package-placement-table-extraction-shape-mock-fs-tests.md" new file mode 100644 index 00000000..2e3c686f --- /dev/null +++ "b/.backlog/tasks/task-9.22 - Supervisor\342\207\204graph-review-remediation-round-2-package-placement-table-extraction-shape-mock-fs-tests.md" @@ -0,0 +1,52 @@ +--- +id: TASK-9.22 +title: >- + Supervisor⇄graph review remediation (round 2): package placement, + table-extraction shape, mock-fs tests +status: Done +assignee: [] +created_date: '2026-07-07 10:12' +updated_date: '2026-07-07 10:53' +labels: + - code-review + - platformos-graph + - platformos-common + - refactor +dependencies: [] +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +Umbrella for the second-round supervisor review of the `supervisor-graph-integration` branch (first round was TASK-9.8). The supervisor flagged division-of-responsibility and test-hygiene defects. Each child is an independently reviewable PR. See SUPERVISOR-GRAPH-INTEGRATION.md for branch context. + +Defects raised: +1. Table-extraction parsers (`extractGraphqlTable`, `extractSchemaTable`) live in `platformos-check-common` but are platform-fact/structural parsers with no lint (offense) use — they belong in `platformos-common` alongside frontmatter/route-table/documents-locator. (User decision: move BOTH.) +2. `extractGraphqlTable` returns only the FIRST table and a scalar; it must return ALL declared tables as an array, and must also cover `record_create`/mutation table declarations. +3. New graph specs (`incremental.spec.ts`, `deserialize.spec.ts`) write real temp files via `mkdtemp`/`NodeFileSystem` instead of the in-memory `MockFileSystem`; and use `[...].join('\n')` line-arrays instead of template literals. +4. `AppCache` (platformos-check-node): confirmed NOT duplicating any existing mechanism (LSP DocumentManager/AppGraphManager are different runtime/concern); keep it where it is and document why it exists. + + +## Acceptance Criteria + +- [x] #1 All four child tasks are Done +- [x] #2 yarn build, yarn type-check, yarn test, yarn format:check are green across affected packages +- [x] #3 No behavioral change to validate_code output beyond the intended table-shape widening + + +## Final Summary + + +All four children Done. Second-round supervisor review remediation for the supervisor-graph-integration branch complete. + +- 9.22.1 — relocated extractGraphqlTable + extractSchemaTable (impl + specs) from check-common → platformos-common (structure/platform facts belong with frontmatter/RouteTable/DocumentsLocator); added browser-safe graphql dep to common; graph imports from common. Lossless move (check-common 1057→1034, common +23). +- 9.22.4 — widened extractGraphqlTable → extractGraphqlTables: string[] (all distinct tables, document order, record_create/mutation covered, dedup). GraphQLModule.table → tables: string[] (required, always-present); SchemaModule stays scalar. Factory + deserialize seed tables:[]; write-sites + specs updated. Blast radius confined to graph (no supervisor/LSP/serialize reads). +- 9.22.2 — converted incremental.spec + deserialize.spec to in-memory MockFileSystem (backing-object mutation for add/modify/delete) + template literals; all 17 scenarios preserved, faster, no disk I/O. +- 9.22.3 — documented AppCache PLACEMENT rationale (JSDoc): why check-node (getApp globs disk, Node-only), and that no pre-existing mechanism duplicates it (LSP DocumentManager / supervisor GraphCache are different runtimes/payloads). No code moved. + +Final consolidated verification (all green): builds clean (common/check-common/graph/check-node via tsc -b); direct type-checks clean (graph, check-node, supervisor tsc exit 0); tests — common 286, check-common 1034, graph 101, check-node app-cache 6, supervisor 76 (validate_code blast-radius unchanged → AC#3); repo-wide yarn format:check clean; yarn install --frozen-lockfile exit 0 with ZERO yarn.lock churn. dist/ regenerated for the touched packages (committed-artifact convention). + +Not committed — left in working tree for review. One deliberate non-edit flagged for a round-2 doc pass: SUPERVISOR-GRAPH-INTEGRATION.md §2.2 (GraphQLModule shape) and §6 doubt #4 now describe pre-9.22.4 behavior. + diff --git a/.backlog/tasks/task-9.22.1 - Relocate-table-extraction-parsers-extractGraphqlTable-extractSchemaTable-to-platformos-common.md b/.backlog/tasks/task-9.22.1 - Relocate-table-extraction-parsers-extractGraphqlTable-extractSchemaTable-to-platformos-common.md new file mode 100644 index 00000000..fd7b6af5 --- /dev/null +++ b/.backlog/tasks/task-9.22.1 - Relocate-table-extraction-parsers-extractGraphqlTable-extractSchemaTable-to-platformos-common.md @@ -0,0 +1,58 @@ +--- +id: TASK-9.22.1 +title: >- + Relocate table-extraction parsers (extractGraphqlTable, extractSchemaTable) to + platformos-common +status: Done +assignee: [] +created_date: '2026-07-07 10:13' +updated_date: '2026-07-07 10:25' +labels: + - refactor + - platformos-common + - platformos-check-common +dependencies: [] +modified_files: + - packages/platformos-common/src/graphql-table.ts + - packages/platformos-common/src/graphql-table.spec.ts + - packages/platformos-common/src/schema-table.ts + - packages/platformos-common/src/schema-table.spec.ts + - packages/platformos-common/src/index.ts + - packages/platformos-common/package.json + - packages/platformos-check-common/src/index.ts + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-graph/src/graph/incremental.ts +parent_task_id: TASK-9.22 +priority: high +--- + +## Description + + +`extractGraphqlTable` (graphql-table.ts) and `extractSchemaTable` (schema-table.ts) currently live in `platformos-check-common` and are re-exported from its index. They parse neutral platformOS platform facts (a GraphQL op's target model table; a schema file's `name:`) and are NOT used by any lint check — their only consumer is `platformos-graph` (traverse.ts, incremental.ts). Per the division-of-responsibility rule, structural/platform-fact parsers belong in `platformos-common` (which already owns frontmatter, RouteTable, DocumentsLocator and already depends on js-yaml). + +Pure relocation — NO behavior change in this task (the array-shape change is TASK follow-up). Add the `graphql` dependency to platformos-common's package.json (schema-table's js-yaml is already present). Update platformos-graph to import both from `@platformos/platformos-common`. Remove the re-exports from platformos-check-common's index. Move the spec files alongside the impls. + + +## Acceptance Criteria + +- [x] #1 graphql-table.ts + schema-table.ts (and their .spec.ts) live under packages/platformos-common/src and are exported from platformos-common's index +- [x] #2 platformos-common package.json declares a graphql dependency; yarn.lock churn is limited to that addition +- [x] #3 platformos-check-common no longer exports extractGraphqlTable/extractSchemaTable; no remaining importer references them from check-common +- [x] #4 platformos-graph imports both extractors from @platformos/platformos-common +- [x] #5 type-check, tests, and format:check pass for platformos-common, platformos-check-common, and platformos-graph + + +## Final Summary + + +Relocated the two platform-fact parsers `extractGraphqlTable` (graphql-table.ts) and `extractSchemaTable` (schema-table.ts), with their specs, from `platformos-check-common` to `platformos-common` — they parse neutral platformOS structure facts (a GraphQL op's target table; a schema's `name:`) with no lint/offense use, so they belong beside frontmatter/RouteTable/DocumentsLocator. Pure relocation, no behavior change (array-shape widening is TASK-9.22.4). + +Changes: +- git mv of graphql-table.{ts,spec.ts} + schema-table.{ts,spec.ts} into platformos-common/src (history preserved). +- platformos-common: added `export *` for both from src/index.ts; added `graphql: ^16.12.0` to package.json deps (browser-safe, isomorphic — respects common's no-node-imports invariant; js-yaml already present for schema-table). +- platformos-check-common: removed both re-exports from src/index.ts (kept levenshtein + isTranslationKeyUsage, which are lint-adjacent and stay). +- platformos-graph: traverse.ts + incremental.ts now import both extractors from @platformos/platformos-common (split from the check-common import; isTranslationKeyUsage/path/UriString/etc stay from check-common). + +Verification: common build + check-common build + graph build (tsc -b) clean; graph direct tsc --noEmit clean; common tests 284 (incl. moved 13 + 10), graph tests 101, check-common tests 1034 (was 1057; −23 = the two moved specs, confirming a lossless move); prettier --check on all touched files clean; yarn install --frozen-lockfile passes with ZERO yarn.lock churn (existing graphql@^16.12.0 entry already satisfies common). dist/ regenerated for the three packages by the builds (committed artifacts per repo convention). + diff --git a/.backlog/tasks/task-9.22.2 - Convert-graph-incremental-deserialize-specs-to-MockFileSystem-template-literals.md b/.backlog/tasks/task-9.22.2 - Convert-graph-incremental-deserialize-specs-to-MockFileSystem-template-literals.md new file mode 100644 index 00000000..452dcf1f --- /dev/null +++ b/.backlog/tasks/task-9.22.2 - Convert-graph-incremental-deserialize-specs-to-MockFileSystem-template-literals.md @@ -0,0 +1,51 @@ +--- +id: TASK-9.22.2 +title: >- + Convert graph incremental/deserialize specs to MockFileSystem + template + literals +status: Done +assignee: [] +created_date: '2026-07-07 10:13' +updated_date: '2026-07-07 10:46' +labels: + - tests + - platformos-graph +dependencies: [] +modified_files: + - packages/platformos-graph/src/graph/incremental.spec.ts + - packages/platformos-graph/src/graph/deserialize.spec.ts +parent_task_id: TASK-9.22 +priority: medium +--- + +## Description + + +`packages/platformos-graph/src/graph/incremental.spec.ts` and `deserialize.spec.ts` build a real project on disk (`mkdtemp`, `mkdir`, `writeFile`, `rm`, `NodeFileSystem`) and mutate files mid-test. buildAppGraph/applyFileChange/recursiveReadDirectory all run over any `AbstractFileSystem`, and `MockFileSystem` (from platformos-check-common test utils) reads live from its backing object — so a mutable in-memory mock (mutate the backing MockApp object to simulate add/modify/delete) fully covers these scenarios without touching disk. Convert both specs to MockFileSystem; drop the node fs/tmpdir plumbing. + +Also replace the `[...].join('\n')` line-array literals (e.g. GET_POSTS_GRAPHQL and the inline page writes) with template-literal (backtick) strings for readability. + +Scope note: `app-cache.spec.ts` (platformos-check-node) is intentionally excluded — it exercises `getApp`, which globs real disk, so it legitimately needs a real workspace. Supervisor `graph-cache.spec.ts` is excluded — it asserts mtime/size fingerprint rebuild behavior that needs real stat. + + +## Acceptance Criteria + +- [x] #1 incremental.spec.ts and deserialize.spec.ts use MockFileSystem with no node:fs / mkdtemp / tmpdir usage +- [x] #2 In-test file add/modify/delete is simulated by mutating the mock's backing object; all existing scenarios (add/modify/delete, missing-target flips, leaf GC, self-ref, cycles, mixed sequence, round-trip, reconcile-equals-full-build) still pass +- [x] #3 Multi-line fixture strings use template literals instead of [...].join('\n') +- [x] #4 Equivalence-to-full-build assertions remain intact and green +- [x] #5 tests and format:check pass for platformos-graph + + +## Final Summary + + +Converted the two graph specs off real disk onto the in-memory MockFileSystem, and replaced the line-array string idiom with template literals. + +Approach (verified with a throwaway spike before rewriting): `buildAppGraph`, `applyFileChange`, and `recursiveReadDirectory` all run over any `AbstractFileSystem`; `MockFileSystem` (imported as the established cross-package pattern `@platformos/platformos-check-common/dist/test`, same as the LSP specs) reads live from its backing object. So in-test add/modify/delete is a plain mutation of that object (`files[rel] = ...` / `delete files[rel]`) — no `mkdtemp`/`writeFile`/`rm`/`NodeFileSystem`/`afterEach` cleanup. Deps augmentation happens per `buildFull`/`change` call, so each re-reads the current mock state (never a stale parse), preserving the equivalence-to-full-build guarantee. `rootUri = path.normalize('file:/')` and `uri = path.join(rootUri, rel)` follow check-common's own MockFileSystem convention; node keys and edge `source.uri` match it (proven by the passing explicit dependents assertions). + +- incremental.spec.ts: all 12 scenarios preserved (add/modify/delete, missing-target `exists` flip, leaf GC, self-reference, cycle, mixed sequence, unmodeled-file no-op) + the 9.22.4 `.tables` assertion. `GET_POSTS_GRAPHQL` and every inline page/layout write are now template literals. +- deserialize.spec.ts: all 5 scenarios preserved (round-trip identity, reverse-index queryable, restored-graph reconciles-exactly-like-full-build, add+delete reconcile, dangling-edge drop). Page writes → template literals. + +Verification: 17 tests pass (12 + 5), full graph suite 101, graph type-check clean (tsc exit 0), prettier clean. No disk I/O remains (grep for node:fs/mkdtemp/tmpdir/NodeFileSystem/`].join('` is empty). Bonus: the suite is markedly faster with no filesystem round-trips. + diff --git a/.backlog/tasks/task-9.22.3 - Document-AppCache-rationale-in-platformos-check-node-keep-in-place.md b/.backlog/tasks/task-9.22.3 - Document-AppCache-rationale-in-platformos-check-node-keep-in-place.md new file mode 100644 index 00000000..44f56b6c --- /dev/null +++ b/.backlog/tasks/task-9.22.3 - Document-AppCache-rationale-in-platformos-check-node-keep-in-place.md @@ -0,0 +1,41 @@ +--- +id: TASK-9.22.3 +title: Document AppCache rationale in platformos-check-node (keep in place) +status: Done +assignee: [] +created_date: '2026-07-07 10:13' +updated_date: '2026-07-07 10:48' +labels: + - docs + - platformos-check-node +dependencies: [] +modified_files: + - packages/platformos-check-node/src/index.ts +parent_task_id: TASK-9.22 +priority: low +--- + +## Description + + +Review flagged the `AppCache` placement/existence. Investigation: there is NO pre-existing equivalent in platformos-check-node; the LSP's DocumentManager/AppGraphManager are a different runtime and concern (open editor buffers / the app graph, not a getApp parsed-source cache), and the supervisor GraphCache caches the graph, not lint sources. AppCache is correctly co-located with its only caller (`getApp`, which globs real disk — node-only) and belongs in check-node, the package's I/O shell. No code moves. + +Action: add a concise rationale note (JSDoc on the AppCache class in packages/platformos-check-node/src/index.ts, and/or a line in the package CLAUDE.md) stating WHY it exists (dominant per-call cost is the whole-project parse; opt-in, never-stale via per-file fingerprint) and WHY it lives here (tied to getApp/glob; no equivalent elsewhere) so the placement is self-defending on future review. + + +## Acceptance Criteria + +- [x] #1 AppCache carries a short rationale note explaining its purpose and why it lives in platformos-check-node (not common/LSP) +- [x] #2 Note explicitly records that no pre-existing equivalent mechanism was found +- [x] #3 No code moved; existing app-cache.spec.ts remains green + + +## Final Summary + + +Added a PLACEMENT paragraph to the AppCache JSDoc (packages/platformos-check-node/src/index.ts) — the existing doc already covered purpose + never-stale, so this adds only what the review actually asked for and avoids restating. + +The note states: AppCache belongs in check-node (the lint I/O shell), not platformos-common or the LSP, because it caches `getApp`'s output and `getApp` globs the real filesystem — a Node-only concern (common is browser-safe, no glob). It explicitly records that it is the ONLY parsed-project cache in check-node and no pre-existing mechanism duplicates it, distinguishing the nearest neighbours (LSP `DocumentManager` = open editor buffers; supervisor `GraphCache` = the dependency graph) as different runtimes/payloads that are deliberately not shared. + +No code moved. Verified: prettier clean, check-node type-check clean (tsc exit 0), app-cache.spec still green (6/6), dist rebuilt so the .d.ts carries the note. + diff --git a/.backlog/tasks/task-9.22.4 - extractGraphqlTable-returns-ALL-declared-tables-array-and-covers-record_create-mutation-tables.md b/.backlog/tasks/task-9.22.4 - extractGraphqlTable-returns-ALL-declared-tables-array-and-covers-record_create-mutation-tables.md new file mode 100644 index 00000000..0537a5ce --- /dev/null +++ b/.backlog/tasks/task-9.22.4 - extractGraphqlTable-returns-ALL-declared-tables-array-and-covers-record_create-mutation-tables.md @@ -0,0 +1,68 @@ +--- +id: TASK-9.22.4 +title: >- + extractGraphqlTable returns ALL declared tables (array) and covers + record_create/mutation tables +status: Done +assignee: [] +created_date: '2026-07-07 10:13' +updated_date: '2026-07-07 10:38' +labels: + - platformos-common + - platformos-graph +dependencies: + - TASK-9.22.1 +modified_files: + - packages/platformos-common/src/graphql-table.ts + - packages/platformos-common/src/graphql-table.spec.ts + - packages/platformos-common/src/schema-table.ts + - packages/platformos-graph/src/types.ts + - packages/platformos-graph/src/graph/module.ts + - packages/platformos-graph/src/graph/traverse.ts + - packages/platformos-graph/src/graph/incremental.ts + - packages/platformos-graph/src/graph/deserialize.ts + - packages/platformos-graph/src/graph/traverse-edges.spec.ts + - packages/platformos-graph/src/graph/incremental.spec.ts +parent_task_id: TASK-9.22 +priority: high +--- + +## Description + + +`extractGraphqlTable` currently short-circuits on the first `table` field ("first table wins") and returns `string | undefined`. A single GraphQL document can target multiple tables (multiple `records(...)` blocks, aliased queries, and `record_create`/mutation inputs). It must return every distinct declared string table in document order as `string[]` (empty array when none). Mutation/`record_create` table inputs already parse via the generic ObjectField visitor — verify and pin them; the change is dropping the first-wins short-circuit and collecting all. + +Ripple: `GraphQLModule.table?: string` in platformos-graph/src/types.ts becomes `tables: string[]`; update the write sites (traverse.ts:91, incremental.ts:210) and every spec that reads `.table` on a GraphQL module (incremental.spec.ts:181, extract/structural/build specs as applicable). `.table` is not serialized and not read by supervisor/impact logic, so blast radius is confined to graph write-sites + specs. `extractSchemaTable` stays scalar (a schema file declares exactly one `name:`); keep SchemaModule.table as-is. + +Depends on TASK-9.22.1 (file now lives in platformos-common). + + +## Acceptance Criteria + +- [x] #1 extractGraphqlTable returns string[] of all distinct declared tables in document order (empty array when none/unparseable) +- [x] #2 record_create / mutation table inputs are covered by a test asserting the full array +- [x] #3 A multi-table document test asserts every table is returned (not just the first) +- [x] #4 GraphQLModule exposes tables: string[]; all graph write-sites and specs updated; extractSchemaTable/SchemaModule remain scalar +- [x] #5 type-check, tests, and format:check pass for platformos-common and platformos-graph + + +## Final Summary + + +Widened GraphQL table extraction from "first table, scalar" to "all distinct tables, array", and rippled the type through the graph. + +Parser (platformos-common): +- Renamed `extractGraphqlTable` → `extractGraphqlTables`, return type `string | undefined` → `string[]`. Dropped the `first table wins` short-circuit; now collects every distinct string table in document order (dedup via first-occurrence). Empty array for none / dynamic (non-string) / unparseable. record_create / mutation table inputs were already covered by the generic ObjectField visitor — now pinned by tests, including a mixed query+mutation document asserting the full ordered array. Spec rewritten to the array contract (15 tests: +dedup, +mixed-mutation). + +Graph ripple: +- `GraphQLModule.table?: string` → `tables: string[]` (required, always-present; empty = no table declared — matches the "usage arrays always present" convention). SchemaModule.table stays scalar (a schema declares exactly one `name:`); JSDoc cross-links updated. +- `getGraphQLModuleByUri` factory initializes `tables: []`; `deserialize` reconstructs GraphQL nodes with `tables: []` (a re-derived leaf fact, not serialized — the fingerprint-driven incremental reconcile re-reads it). +- Write-sites traverse.ts + incremental.ts assign `module.tables = extractGraphqlTables(...)`; their comments corrected to reference platformos-common (post-9.22.1) instead of check-common. +- Specs updated: traverse-edges.spec `graphqlNode` helper + inline factory assertion + the two table assertions (`toBe`/`toBeUndefined` → `toEqual([...])`/`toEqual([])`); incremental.spec table-fact comparison → `.tables`. + +Blast radius stayed confined to graph write-sites + specs: confirmed no supervisor/LSP code reads `.table`/`.tables`, and `tables` is excluded from serialization (SerializableNode unchanged), so the persisted cache and all equivalence tests are unaffected. + +Verification: graph type-check clean (direct tsc — TDD caught the required-field ripple in deserialize.ts and two inline spec assertions, all fixed); supervisor type-check clean (exit 0); common tests 286 (was 284; +2 new parser cases), graph tests 101, prettier clean on all touched files. + +NOTE flagged to user (not edited): SUPERVISOR-GRAPH-INTEGRATION.md §2.2 model shape (`GraphQLModule { table?: string }`) and §6 doubt #4 ("resolves to the first/none") now describe superseded behavior — a doc-accuracy follow-up, deliberately left for the round-2 doc pass. + diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md index b2c3be9e..2cd85d46 100644 --- a/SUPERVISOR-GRAPH-INTEGRATION.md +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -50,7 +50,7 @@ graph to provide that model and consumes it from `validate_code`. `filters_used`, `tags_used`, `translation_keys`, `doc_params`, `slug`, `layout`, `method`, exposed both as the per-file primitive `extractStructural` _(exported in review — §8/F1)_ and, opt-in, on each module during a full build. -- **Platform facts** — a GraphQL op's `table`, schema/`CustomModelType` nodes. +- **Platform facts** — a GraphQL op's `tables`, schema/`CustomModelType` nodes. - **`'layout'` DocumentType** in `DocumentsLocator` (the canonical resolver), with `.html.liquid`/`.liquid` precedence. - **Two ADRs**: 003 (graph-backed enrichment) resolved; **004** (the @@ -102,9 +102,10 @@ unrelated by a stash baseline; CI does not hit it). ``` liquid-html-parser CST → AST (the parse everything reuses) platformos-common path/URI math, DocumentsLocator (resolution), RouteTable/slug, - frontmatter schemas, AbstractFileSystem + frontmatter schemas, AbstractFileSystem, + extractGraphqlTables / extractSchemaTable (platform-fact parsers) platformos-check-common lint engine, Offense, ReferenceKind/Reference, getPosition, - levenshtein, liquid-doc, graphql parsing, extractGraphqlTable + levenshtein, liquid-doc, graphql parsing platformos-check-node NodeFileSystem, lintBuffer / check() platformos-graph THE project model: AppGraph (nodes + edges), buildAppGraph, extractFileReferences, query API, self-structural @@ -139,8 +140,8 @@ AppModule = LiquidModule | AssetModule | GraphQLModule | SchemaModule } LiquidModule { kind: Page|Partial|Layout, structural?: ModuleStructural } AssetModule { kind: 'unused' } - GraphQLModule { kind: 'graphql', table?: string } - SchemaModule { kind: 'schema', table?: string } // custom_model_type + GraphQLModule { kind: 'graphql', tables: string[] } // all model tables it targets + SchemaModule { kind: 'schema', table?: string } // custom_model_type (single name:) Reference { source: { uri, range? } // call site (0-based offsets) @@ -402,12 +403,16 @@ For as an entry point. Documented in `query.ts` and now in the `buildAppGraph` JSDoc (§8/F4); a convenience "whole-project" build mode may be worth adding. -4. **graphql `table` extraction is path/AST-based, single-style.** It reads the - first `table` object/`table:"x"` filter via the GraphQL AST. Exotic query - shapes (computed table, multiple tables) resolve to the first/none. Adequate for - resource grouping; not a full GraphQL semantic analysis. (The extractor moved - to check-common alongside `extractSchemaTable` — §8/F7 — but its behavior is - unchanged.) +4. ~~**graphql `table` extraction is path/AST-based, single-style.**~~ **RESOLVED + (TASK-9.22.4).** `extractGraphqlTables` now reads the GraphQL AST for **every** + distinct `table` object/`table:"x"` filter — across multiple `records(...)` + blocks, aliased queries, and `record_create`/mutation inputs — and returns them + all as `string[]` in document order (`GraphQLModule.tables`), rather than the + first/none. Still AST-path based (a dynamic, non-string table is skipped), not a + full GraphQL semantic analysis, which remains adequate for resource grouping. + The extractor now lives in **platformos-common** beside `extractSchemaTable` + (moved from check-common — TASK-9.22.1 — since they are platform-fact parsers, + not lint). 5. **Schema table name = the YAML `name:`.** Files with no (or a non-string) `name:` get a node with `table` undefined. The review made the non-string diff --git a/packages/platformos-check-common/src/graphql-table.ts b/packages/platformos-check-common/src/graphql-table.ts deleted file mode 100644 index 97f7c932..00000000 --- a/packages/platformos-check-common/src/graphql-table.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { parse, visit } from 'graphql/language'; - -/** - * Extract the platformOS model table a GraphQL operation targets, if it declares - * one. platformOS queries filter records by table, e.g. - * - * ```graphql - * query { records(filter: { table: { value: "blog_post" } }) { ... } } - * ``` - * - * The table also appears in the shorthand `table: "blog_post"`. This walks the - * parsed GraphQL AST for the first `table` object field and reads its string - * value (either a direct `StringValue`, or an object `{ value: "..." }`), - * reusing the `graphql` parser this package already owns rather than a regex. - * - * Returns `undefined` for operations with no table filter or unparseable input. - */ -export function extractGraphqlTable(content: string): string | undefined { - let ast; - try { - ast = parse(content); - } catch { - return undefined; // not valid GraphQL — nothing to extract - } - - let table: string | undefined; - visit(ast, { - ObjectField(node) { - if (table !== undefined) return; // first table wins - if (node.name.value !== 'table') return; - - // `table: "blog_post"` - if (node.value.kind === 'StringValue') { - table = node.value.value; - return; - } - - // `table: { value: "blog_post" }` - if (node.value.kind === 'ObjectValue') { - const valueField = node.value.fields.find( - (field) => field.name.value === 'value' && field.value.kind === 'StringValue', - ); - if (valueField && valueField.value.kind === 'StringValue') { - table = valueField.value.value; - } - } - }, - }); - - return table; -} diff --git a/packages/platformos-check-common/src/index.ts b/packages/platformos-check-common/src/index.ts index 6b138e30..c897c82d 100644 --- a/packages/platformos-check-common/src/index.ts +++ b/packages/platformos-check-common/src/index.ts @@ -53,16 +53,6 @@ export { getPosition } from './utils'; // re-implementing string-distance. export { levenshtein } from './utils'; -// `extractGraphqlTable` parses the platformOS model table a GraphQL operation -// targets; exported so the graph can model a GraphQL node's table without -// re-implementing GraphQL parsing (the `graphql` dep lives here). -export { extractGraphqlTable } from './graphql-table'; - -// `extractSchemaTable` reads a custom-model-type/schema file's declared `name:` -// (its table); exported alongside `extractGraphqlTable` so the graph and other -// consumers extract this platform fact from the package that owns the parsers. -export { extractSchemaTable } from './schema-table'; - // `isTranslationKeyUsage` is the shared predicate for "a string literal piped // through `t`/`translate`"; exported so the graph's self-structural detects // translation keys with the SAME rule as the `TranslationKeyExists` check. diff --git a/packages/platformos-check-node/src/index.ts b/packages/platformos-check-node/src/index.ts index ffe1b9a8..d54286bc 100644 --- a/packages/platformos-check-node/src/index.ts +++ b/packages/platformos-check-node/src/index.ts @@ -97,6 +97,16 @@ export async function fileFingerprint(absolutePath: string): Promise(); diff --git a/packages/platformos-common/package.json b/packages/platformos-common/package.json index a2c35ada..9e5f03cb 100644 --- a/packages/platformos-common/package.json +++ b/packages/platformos-common/package.json @@ -25,6 +25,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "graphql": "^16.12.0", "js-yaml": "^4.1.1", "vscode-json-languageservice": "^5.7.1", "vscode-uri": "^3.1.0" diff --git a/packages/platformos-check-common/src/graphql-table.spec.ts b/packages/platformos-common/src/graphql-table.spec.ts similarity index 50% rename from packages/platformos-check-common/src/graphql-table.spec.ts rename to packages/platformos-common/src/graphql-table.spec.ts index 176d818d..2c7db3e0 100644 --- a/packages/platformos-check-common/src/graphql-table.spec.ts +++ b/packages/platformos-common/src/graphql-table.spec.ts @@ -1,32 +1,40 @@ import { describe, expect, it } from 'vitest'; -import { extractGraphqlTable } from './graphql-table'; +import { extractGraphqlTables } from './graphql-table'; -describe('extractGraphqlTable', () => { +describe('extractGraphqlTables', () => { it('extracts the table from a `table: { value: "..." }` filter', () => { const content = `query find($id: ID!) { records(per_page: 1, filter: { id: { value: $id }, table: { value: "blog_post" } }) { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('blog_post'); + expect(extractGraphqlTables(content)).toEqual(['blog_post']); }); it('extracts the table from the `table: "..."` shorthand', () => { const content = `query { records(filter: { table: "product" }) { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('product'); + expect(extractGraphqlTables(content)).toEqual(['product']); }); - it('returns the first table when several are present', () => { + it('returns every distinct table when several are present, in document order', () => { const content = `query { a: records(filter: { table: { value: "first" } }) { results { id } } b: records(filter: { table: { value: "second" } }) { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('first'); + expect(extractGraphqlTables(content)).toEqual(['first', 'second']); }); - it('returns undefined when there is no table filter', () => { + it('deduplicates a table declared more than once', () => { + const content = `query { + a: records(filter: { table: { value: "blog_post" } }) { results { id } } + b: records(filter: { table: { value: "blog_post" } }) { results { id } } +}`; + expect(extractGraphqlTables(content)).toEqual(['blog_post']); + }); + + it('returns an empty array when there is no table filter', () => { const content = `query currentUser { current_user { id email } }`; - expect(extractGraphqlTable(content)).toBeUndefined(); + expect(extractGraphqlTables(content)).toEqual([]); }); it('is not confused by a sibling `value` field on a non-table object', () => { @@ -35,24 +43,32 @@ describe('extractGraphqlTable', () => { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('blog_post'); + expect(extractGraphqlTables(content)).toEqual(['blog_post']); }); - it('returns undefined for unparseable GraphQL', () => { - expect(extractGraphqlTable('query { records(filter: {')).toBeUndefined(); + it('returns an empty array for unparseable GraphQL', () => { + expect(extractGraphqlTables('query { records(filter: {')).toEqual([]); }); - it('returns undefined for an empty document', () => { - expect(extractGraphqlTable('')).toBeUndefined(); + it('returns an empty array for an empty document', () => { + expect(extractGraphqlTables('')).toEqual([]); }); - it('extracts the table from a mutation (records_create style)', () => { + it('extracts the table from a mutation (record_create style)', () => { const content = `mutation create($payload: HashObject) { record_create(record: { table: "blog_post", properties: $payload }) { id } }`; - expect(extractGraphqlTable(content)).toBe('blog_post'); + expect(extractGraphqlTables(content)).toEqual(['blog_post']); + }); + + it('extracts every table across a mixed query + record_create mutation, in document order', () => { + const content = `mutation seed($payload: HashObject) { + comment: record_create(record: { table: "comment", properties: $payload }) { id } + existing: records(filter: { table: { value: "blog_post" } }) { results { id } } +}`; + expect(extractGraphqlTables(content)).toEqual(['comment', 'blog_post']); }); it('extracts a table nested deep inside the filter object', () => { @@ -61,27 +77,27 @@ describe('extractGraphqlTable', () => { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('comment'); + expect(extractGraphqlTables(content)).toEqual(['comment']); }); it('extracts the table when it appears before other fields (order-independent)', () => { const content = `query { records(filter: { table: { value: "tag" }, deleted: { exists: false } }) { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('tag'); + expect(extractGraphqlTables(content)).toEqual(['tag']); }); it('handles an underscored/namespaced table name', () => { const content = `query { records(filter: { table: { value: "modules/core/user" } }) { results { id } } }`; - expect(extractGraphqlTable(content)).toBe('modules/core/user'); + expect(extractGraphqlTables(content)).toEqual(['modules/core/user']); }); - it('returns undefined when `table` is a non-string (dynamic) value', () => { + it('ignores a non-string (dynamic) table value', () => { const content = `query q($t: String) { records(filter: { table: { value: $t } }) { results { id } } }`; - expect(extractGraphqlTable(content)).toBeUndefined(); + expect(extractGraphqlTables(content)).toEqual([]); }); it('ignores a `table` used as a GraphQL alias, not a filter field', () => { // `table:` here is a field alias (table: results), not an object field. const content = `query { records(filter: { deleted: { exists: false } }) { table: results { id } } }`; - expect(extractGraphqlTable(content)).toBeUndefined(); + expect(extractGraphqlTables(content)).toEqual([]); }); }); diff --git a/packages/platformos-common/src/graphql-table.ts b/packages/platformos-common/src/graphql-table.ts new file mode 100644 index 00000000..8b1f18da --- /dev/null +++ b/packages/platformos-common/src/graphql-table.ts @@ -0,0 +1,58 @@ +import { parse, visit } from 'graphql/language'; + +/** + * Extract every platformOS model table a GraphQL operation targets. platformOS + * queries and mutations reference a model by table, e.g. + * + * ```graphql + * query { records(filter: { table: { value: "blog_post" } }) { ... } } + * mutation { record_create(record: { table: "blog_post", ... }) { id } } + * ``` + * + * The table appears either as the shorthand `table: "blog_post"` or as an object + * `table: { value: "blog_post" }`. A single document can target several tables + * (multiple `records(...)` blocks, aliased queries, `record_create` inputs), so + * this returns ALL of them — every distinct string table in document order — + * rather than only the first. It walks the parsed GraphQL AST, reusing the + * `graphql` parser this package owns rather than a regex. + * + * Returns an empty array for operations with no table filter, a dynamic + * (non-string) table, or unparseable input. + */ +export function extractGraphqlTables(content: string): string[] { + let ast; + try { + ast = parse(content); + } catch { + return []; // not valid GraphQL — nothing to extract + } + + const tables: string[] = []; + const add = (value: string) => { + if (!tables.includes(value)) tables.push(value); // distinct, first-occurrence order + }; + + visit(ast, { + ObjectField(node) { + if (node.name.value !== 'table') return; + + // `table: "blog_post"` + if (node.value.kind === 'StringValue') { + add(node.value.value); + return; + } + + // `table: { value: "blog_post" }` + if (node.value.kind === 'ObjectValue') { + const valueField = node.value.fields.find( + (field) => field.name.value === 'value' && field.value.kind === 'StringValue', + ); + if (valueField && valueField.value.kind === 'StringValue') { + add(valueField.value.value); + } + } + }, + }); + + return tables; +} diff --git a/packages/platformos-common/src/index.ts b/packages/platformos-common/src/index.ts index d47279a2..a6fe8154 100644 --- a/packages/platformos-common/src/index.ts +++ b/packages/platformos-common/src/index.ts @@ -4,3 +4,9 @@ export * from './route-table'; export * from './AbstractFileSystem'; export * from './path-utils'; export * from './frontmatter'; +// Neutral platformOS platform-fact parsers (no lint/offense use): the model +// table a GraphQL op targets, and a schema file's declared `name:`. They live +// here beside the other structure/resolution facts (frontmatter, RouteTable, +// DocumentsLocator) and are consumed by the graph. +export * from './graphql-table'; +export * from './schema-table'; diff --git a/packages/platformos-check-common/src/schema-table.spec.ts b/packages/platformos-common/src/schema-table.spec.ts similarity index 100% rename from packages/platformos-check-common/src/schema-table.spec.ts rename to packages/platformos-common/src/schema-table.spec.ts diff --git a/packages/platformos-check-common/src/schema-table.ts b/packages/platformos-common/src/schema-table.ts similarity index 91% rename from packages/platformos-check-common/src/schema-table.ts rename to packages/platformos-common/src/schema-table.ts index 538106dc..aeecdc39 100644 --- a/packages/platformos-check-common/src/schema-table.ts +++ b/packages/platformos-common/src/schema-table.ts @@ -11,7 +11,7 @@ import { load } from 'js-yaml'; * type: string * ``` * - * Named to mirror {@link extractGraphqlTable} so a consumer can join a GraphQL + * Named to mirror {@link extractGraphqlTables} so a consumer can join a GraphQL * operation to the schema it targets. Reuses the `js-yaml` parser this package * already owns rather than a regex. * diff --git a/packages/platformos-graph/src/graph/deserialize.spec.ts b/packages/platformos-graph/src/graph/deserialize.spec.ts index 54034db1..4558f8bd 100644 --- a/packages/platformos-graph/src/graph/deserialize.spec.ts +++ b/packages/platformos-graph/src/graph/deserialize.spec.ts @@ -1,8 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import nodePath from 'node:path'; -import { URI } from 'vscode-uri'; +import { beforeEach, describe, expect, it } from 'vitest'; import { isLayout, @@ -12,7 +8,7 @@ import { recursiveReadDirectory, UriString, } from '@platformos/platformos-check-common'; -import { NodeFileSystem } from '@platformos/platformos-check-node'; +import { MockFileSystem, type MockApp } from '@platformos/platformos-check-common/dist/test'; import { applyFileChange, @@ -32,67 +28,53 @@ import { * is seeded, so reconciling a loaded graph converges to the same graph a full * build would produce. This is the guarantee that makes "load a persisted * graph, then reconcile the delta" never-stale. + * + * The project source is an in-memory {@link MockFileSystem}; its backing object + * is mutated to add/modify/delete a file (no disk I/O). */ describe('Unit: deserializeAppGraph (persistence load half)', () => { - let root: string; - let rootUri: UriString; - - const deps = { fs: NodeFileSystem }; - const abs = (rel: string) => nodePath.join(root, ...rel.split('/')); - const uri = (rel: string): UriString => path.normalize(URI.file(abs(rel)).toString()); - - async function write(rel: string, content: string): Promise { - const file = abs(rel); - await mkdir(nodePath.dirname(file), { recursive: true }); - await writeFile(file, content, 'utf8'); - } - - async function entryPoints(): Promise { - return recursiveReadDirectory( - NodeFileSystem, - rootUri, - ([u]) => isLayout(u) || isPage(u) || isPartial(u), - ); - } + const rootUri = path.normalize('file:/'); + + let files: MockApp; + let fs: MockFileSystem; + + const uri = (rel: string): UriString => path.join(rootUri, rel); - async function buildFull(): Promise { - return buildAppGraph(rootUri, deps, await entryPoints()); - } + const write = (rel: string, content: string): void => { + files[rel] = content; + }; + const remove = (rel: string): void => { + delete files[rel]; + }; - function canonical(graph: AppGraph) { + const entryPoints = (): Promise => + recursiveReadDirectory(fs, rootUri, ([u]) => isLayout(u) || isPage(u) || isPartial(u)); + + const buildFull = async (): Promise => + buildAppGraph(rootUri, { fs }, await entryPoints()); + + const canonical = (graph: AppGraph) => { const serialized = serializeAppGraph(graph); return { rootUri: serialized.rootUri, nodes: [...serialized.nodes].sort((a, b) => a.uri.localeCompare(b.uri)), edges: [...serialized.edges].map((edge) => JSON.stringify(edge)).sort(), }; - } - - beforeEach(async () => { - root = await mkdtemp(nodePath.join(tmpdir(), 'pos-graph-deserialize-')); - rootUri = path.normalize(URI.file(root).toString()); - - await write( - 'app/views/pages/index.liquid', - [ - '---', - 'layout: application', - '---', - "{% render 'card' %}", - "{% graphql q = 'get_posts' %}", - ].join('\n'), - ); - await write('app/views/partials/card.liquid', "{% render 'button' %}"); - await write('app/views/partials/button.liquid', ''); - await write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); - await write( - 'app/graphql/get_posts.graphql', - 'query get_posts { records(filter: { table: { value: "blog_post" } }) { results { id } } }', - ); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); + }; + + beforeEach(() => { + files = { + 'app/views/pages/index.liquid': `--- +layout: application +--- +{% render 'card' %} +{% graphql q = 'get_posts' %}`, + 'app/views/partials/card.liquid': `{% render 'button' %}`, + 'app/views/partials/button.liquid': ``, + 'app/views/layouts/application.liquid': `{{ content_for_layout }}`, + 'app/graphql/get_posts.graphql': `query get_posts { records(filter: { table: { value: "blog_post" } }) { results { id } } }`, + }; + fs = new MockFileSystem(files, rootUri); }); it('round-trips serialize → deserialize → serialize identically', async () => { @@ -131,13 +113,15 @@ describe('Unit: deserializeAppGraph (persistence load half)', () => { ); // Edit index so it renders button directly, then apply to the RESTORED graph. - await write( + write( 'app/views/pages/index.liquid', - ['---', 'layout: application', '---', "{% render 'card' %}", "{% render 'button' %}"].join( - '\n', - ), + `--- +layout: application +--- +{% render 'card' %} +{% render 'button' %}`, ); - await applyFileChange(restored, uri('app/views/pages/index.liquid'), 'modified', deps); + await applyFileChange(restored, uri('app/views/pages/index.liquid'), 'modified', { fs }); // If the identity cache were not seeded, the new edge would bind to a // duplicate node and this would diverge from a full build. @@ -156,11 +140,11 @@ describe('Unit: deserializeAppGraph (persistence load half)', () => { original.entryPoints.map((module) => module.uri), ); - await write('app/views/partials/footer.liquid', '
'); - await applyFileChange(restored, uri('app/views/partials/footer.liquid'), 'added', deps); + write('app/views/partials/footer.liquid', '
'); + await applyFileChange(restored, uri('app/views/partials/footer.liquid'), 'added', { fs }); - await rm(abs('app/views/pages/index.liquid')); - await applyFileChange(restored, uri('app/views/pages/index.liquid'), 'deleted', deps); + remove('app/views/pages/index.liquid'); + await applyFileChange(restored, uri('app/views/pages/index.liquid'), 'deleted', { fs }); expect(canonical(restored)).toEqual(canonical(await buildFull())); }); diff --git a/packages/platformos-graph/src/graph/deserialize.ts b/packages/platformos-graph/src/graph/deserialize.ts index 891357bc..a2a25961 100644 --- a/packages/platformos-graph/src/graph/deserialize.ts +++ b/packages/platformos-graph/src/graph/deserialize.ts @@ -72,7 +72,9 @@ function nodeToModule(node: SerializableNode): AppModule { case ModuleType.Asset: return { ...base, type: ModuleType.Asset, kind: 'unused' }; case ModuleType.GraphQL: - return { ...base, type: ModuleType.GraphQL, kind: 'graphql' }; + // `tables` is a re-derived leaf fact (not serialized) — default to empty; + // the fingerprint-driven incremental reconcile re-reads it from disk. + return { ...base, type: ModuleType.GraphQL, kind: 'graphql', tables: [] }; case ModuleType.Schema: return { ...base, type: ModuleType.Schema, kind: 'schema' }; default: diff --git a/packages/platformos-graph/src/graph/incremental.spec.ts b/packages/platformos-graph/src/graph/incremental.spec.ts index c41414a2..e7f643a4 100644 --- a/packages/platformos-graph/src/graph/incremental.spec.ts +++ b/packages/platformos-graph/src/graph/incremental.spec.ts @@ -1,8 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import nodePath from 'node:path'; -import { URI } from 'vscode-uri'; +import { beforeEach, describe, expect, it } from 'vitest'; import { isLayout, @@ -12,7 +8,7 @@ import { recursiveReadDirectory, UriString, } from '@platformos/platformos-check-common'; -import { NodeFileSystem } from '@platformos/platformos-check-node'; +import { MockFileSystem, type MockApp } from '@platformos/platformos-check-common/dist/test'; import { applyFileChange, @@ -29,104 +25,91 @@ import { * * The load-bearing contract (a wrong incremental result would mislead the agent) * is EQUIVALENCE-TO-FULL-BUILD: after applying any change (or sequence), the - * graph must equal a from-scratch `buildAppGraph` of the same final disk state. - * Each scenario mutates a real temp project on disk, applies the change, and + * graph must equal a from-scratch `buildAppGraph` of the same final source state. + * Each scenario mutates an in-memory {@link MockFileSystem} (its backing object + * is mutated to add/modify/delete a file — no disk I/O), applies the change, and * asserts the incrementally-updated graph serializes identically to a fresh full * build — the same guarantee across add / modify / delete, missing targets, * leaf GC, self-reference, and cycles. */ describe('Unit: applyFileChange (incremental graph update)', () => { - let root: string; - let rootUri: UriString; + const rootUri = path.normalize('file:/'); - const deps = { fs: NodeFileSystem }; + let files: MockApp; + let fs: MockFileSystem; - /** Absolute fs path for a project-relative path. */ - const abs = (rel: string) => nodePath.join(root, ...rel.split('/')); /** Normalized `file://` URI for a project-relative path (matches graph node keys). */ - const uri = (rel: string): UriString => path.normalize(URI.file(abs(rel)).toString()); + const uri = (rel: string): UriString => path.join(rootUri, rel); - async function write(rel: string, content: string): Promise { - const file = abs(rel); - await mkdir(nodePath.dirname(file), { recursive: true }); - await writeFile(file, content, 'utf8'); - } + /** Add or overwrite a file in the mock's backing store (reflected live by `fs`). */ + const write = (rel: string, content: string): void => { + files[rel] = content; + }; + /** Remove a file from the mock's backing store. */ + const remove = (rel: string): void => { + delete files[rel]; + }; - /** The edge-source liquid files on disk — the cache's build entry points. */ - async function entryPoints(): Promise { - return recursiveReadDirectory( - NodeFileSystem, - rootUri, - ([u]) => isLayout(u) || isPage(u) || isPartial(u), - ); - } + /** The edge-source liquid files — the cache's build entry points. */ + const entryPoints = (): Promise => + recursiveReadDirectory(fs, rootUri, ([u]) => isLayout(u) || isPage(u) || isPartial(u)); - /** A from-scratch full build over the current disk state (the reference graph). */ - async function buildFull(): Promise { - return buildAppGraph(rootUri, deps, await entryPoints()); - } + /** A from-scratch full build over the current source state (the reference graph). */ + const buildFull = async (): Promise => + buildAppGraph(rootUri, { fs }, await entryPoints()); /** Canonical, order-independent serialization for whole-graph equality. */ - function canonical(graph: AppGraph) { + const canonical = (graph: AppGraph) => { const serialized = serializeAppGraph(graph); return { rootUri: serialized.rootUri, nodes: [...serialized.nodes].sort((a, b) => a.uri.localeCompare(b.uri)), edges: [...serialized.edges].map((edge) => JSON.stringify(edge)).sort(), }; - } + }; /** The incremental graph must serialize identically to a fresh full build. */ - async function expectEquivalentToFullBuild(incremental: AppGraph): Promise { + const expectEquivalentToFullBuild = async (incremental: AppGraph): Promise => { expect(canonical(incremental)).toEqual(canonical(await buildFull())); - } - - const change = (rel: string, kind: FileChangeKind, graph: AppGraph) => - applyFileChange(graph, uri(rel), kind, deps); + }; - const GET_POSTS_GRAPHQL = [ - 'query get_posts {', - ' records(per_page: 20, filter: { table: { value: "blog_post" } }) {', - ' results { id }', - ' }', - '}', - '', - ].join('\n'); + const change = (rel: string, kind: FileChangeKind, graph: AppGraph): Promise => + applyFileChange(graph, uri(rel), kind, { fs }); - beforeEach(async () => { - root = await mkdtemp(nodePath.join(tmpdir(), 'pos-graph-incremental-')); - rootUri = path.normalize(URI.file(root).toString()); + const GET_POSTS_GRAPHQL = `query get_posts { + records(per_page: 20, filter: { table: { value: "blog_post" } }) { + results { id } + } +} +`; + beforeEach(() => { // A small but representative project: a page with a layout, a render chain, // and a graphql leaf. - await write( - 'app/views/pages/index.liquid', - [ - '---', - 'layout: application', - '---', - "{% render 'card' %}", - "{% graphql q = 'get_posts' %}", - ].join('\n'), - ); - await write('app/views/partials/card.liquid', "{% render 'button' %}"); - await write('app/views/partials/button.liquid', ''); - await write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); - await write('app/graphql/get_posts.graphql', GET_POSTS_GRAPHQL); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); + files = { + 'app/views/pages/index.liquid': `--- +layout: application +--- +{% render 'card' %} +{% graphql q = 'get_posts' %}`, + 'app/views/partials/card.liquid': `{% render 'button' %}`, + 'app/views/partials/button.liquid': ``, + 'app/views/layouts/application.liquid': `{{ content_for_layout }}`, + 'app/graphql/get_posts.graphql': GET_POSTS_GRAPHQL, + }; + fs = new MockFileSystem(files, rootUri); }); it('MODIFIED: adds a new edge to an existing partial', async () => { const graph = await buildFull(); - await write( + write( 'app/views/pages/index.liquid', - ['---', 'layout: application', '---', "{% render 'card' %}", "{% render 'button' %}"].join( - '\n', - ), + `--- +layout: application +--- +{% render 'card' %} +{% render 'button' %}`, ); await change('app/views/pages/index.liquid', 'modified', graph); @@ -140,9 +123,12 @@ describe('Unit: applyFileChange (incremental graph update)', () => { const graph = await buildFull(); expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeDefined(); - await write( + write( 'app/views/pages/index.liquid', - ['---', 'layout: application', '---', "{% render 'card' %}"].join('\n'), + `--- +layout: application +--- +{% render 'card' %}`, ); await change('app/views/pages/index.liquid', 'modified', graph); @@ -153,7 +139,7 @@ describe('Unit: applyFileChange (incremental graph update)', () => { it('MODIFIED: an existing partial that loses its only referrer stays (it is an entry point)', async () => { const graph = await buildFull(); - await write('app/views/partials/card.liquid', '
no more button
'); + write('app/views/partials/card.liquid', '
no more button
'); await change('app/views/partials/card.liquid', 'modified', graph); // button.liquid is an edge-source file → an entry point → kept as an orphan. @@ -164,13 +150,14 @@ describe('Unit: applyFileChange (incremental graph update)', () => { it('MODIFIED: refreshes a newly-reached graphql leaf table fact', async () => { // Start with a page that references no graphql, then add the graphql edge. - await write('app/views/pages/index.liquid', "{% render 'card' %}"); + write('app/views/pages/index.liquid', `{% render 'card' %}`); const graph = await buildFull(); expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeUndefined(); - await write( + write( 'app/views/pages/index.liquid', - ["{% render 'card' %}", "{% graphql q = 'get_posts' %}"].join('\n'), + `{% render 'card' %} +{% graphql q = 'get_posts' %}`, ); await change('app/views/pages/index.liquid', 'modified', graph); @@ -178,14 +165,16 @@ describe('Unit: applyFileChange (incremental graph update)', () => { const node = graph.modules[uri('app/graphql/get_posts.graphql')]; const fullNode = full.modules[uri('app/graphql/get_posts.graphql')]; expect(node?.type).toBe(fullNode?.type); - expect((node as { table?: string }).table).toEqual((fullNode as { table?: string }).table); + expect((node as { tables?: string[] }).tables).toEqual( + (fullNode as { tables?: string[] }).tables, + ); await expectEquivalentToFullBuild(graph); }); it('ADDED: a brand-new partial (edge source) becomes a materialized entry point', async () => { const graph = await buildFull(); - await write('app/views/partials/footer.liquid', '
'); + write('app/views/partials/footer.liquid', '
'); await change('app/views/partials/footer.liquid', 'added', graph); expect(graph.modules[uri('app/views/partials/footer.liquid')]?.exists).toBe(true); @@ -194,15 +183,18 @@ describe('Unit: applyFileChange (incremental graph update)', () => { it('ADDED: a previously-missing render target flips exists and resolves its incoming edge', async () => { // index renders a partial that does not exist yet. - await write( + write( 'app/views/pages/index.liquid', - ['---', 'layout: application', '---', "{% render 'ghost' %}"].join('\n'), + `--- +layout: application +--- +{% render 'ghost' %}`, ); const graph = await buildFull(); const ghost = uri('app/views/partials/ghost.liquid'); expect(graph.modules[ghost]?.exists).toBe(false); - await write('app/views/partials/ghost.liquid', '
ghost
'); + write('app/views/partials/ghost.liquid', '
ghost
'); await change('app/views/partials/ghost.liquid', 'added', graph); expect(graph.modules[ghost]?.exists).toBe(true); @@ -216,7 +208,7 @@ describe('Unit: applyFileChange (incremental graph update)', () => { it('DELETED: a still-referenced file survives as a known-missing target', async () => { const graph = await buildFull(); - await rm(abs('app/views/partials/button.liquid')); + remove('app/views/partials/button.liquid'); await change('app/views/partials/button.liquid', 'deleted', graph); const button = graph.modules[uri('app/views/partials/button.liquid')]; @@ -233,7 +225,7 @@ describe('Unit: applyFileChange (incremental graph update)', () => { expect(graph.modules[uri('app/graphql/get_posts.graphql')]).toBeDefined(); // index is the only referrer of the layout and the graphql op; nothing renders index. - await rm(abs('app/views/pages/index.liquid')); + remove('app/views/pages/index.liquid'); await change('app/views/pages/index.liquid', 'deleted', graph); expect(graph.modules[uri('app/views/pages/index.liquid')]).toBeUndefined(); @@ -242,25 +234,25 @@ describe('Unit: applyFileChange (incremental graph update)', () => { }); it('handles a self-referencing file', async () => { - await write('app/views/partials/card.liquid', "{% render 'card' %}"); + write('app/views/partials/card.liquid', `{% render 'card' %}`); const graph = await buildFull(); // Modify it to no longer reference itself, then back — both must stay equivalent. - await write('app/views/partials/card.liquid', '
plain
'); + write('app/views/partials/card.liquid', '
plain
'); await change('app/views/partials/card.liquid', 'modified', graph); await expectEquivalentToFullBuild(graph); - await write('app/views/partials/card.liquid', "{% render 'card' %}"); + write('app/views/partials/card.liquid', `{% render 'card' %}`); await change('app/views/partials/card.liquid', 'modified', graph); await expectEquivalentToFullBuild(graph); }); it('handles a cycle (A renders B, B renders A)', async () => { - await write('app/views/partials/a.liquid', "{% render 'b' %}"); - await write('app/views/partials/b.liquid', "{% render 'a' %}"); + write('app/views/partials/a.liquid', `{% render 'b' %}`); + write('app/views/partials/b.liquid', `{% render 'a' %}`); const graph = await buildFull(); - await write('app/views/partials/a.liquid', '
no more b
'); + write('app/views/partials/a.liquid', '
no more b
'); await change('app/views/partials/a.liquid', 'modified', graph); await expectEquivalentToFullBuild(graph); @@ -270,23 +262,25 @@ describe('Unit: applyFileChange (incremental graph update)', () => { const graph = await buildFull(); // add a partial, wire the page to it, delete another, edit the layout. - await write('app/views/partials/footer.liquid', '
'); + write('app/views/partials/footer.liquid', '
'); await change('app/views/partials/footer.liquid', 'added', graph); - await write( + write( 'app/views/pages/index.liquid', - ['---', 'layout: application', '---', "{% render 'card' %}", "{% render 'footer' %}"].join( - '\n', - ), + `--- +layout: application +--- +{% render 'card' %} +{% render 'footer' %}`, ); await change('app/views/pages/index.liquid', 'modified', graph); - await rm(abs('app/graphql/get_posts.graphql')); + remove('app/graphql/get_posts.graphql'); // get_posts is no longer referenced by index (removed above) so it is already // gone; deleting a file the graph does not model is a safe no-op. await change('app/graphql/get_posts.graphql', 'deleted', graph); - await write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); + write('app/views/layouts/application.liquid', '{{ content_for_layout }}'); await change('app/views/layouts/application.liquid', 'modified', graph); await expectEquivalentToFullBuild(graph); diff --git a/packages/platformos-graph/src/graph/incremental.ts b/packages/platformos-graph/src/graph/incremental.ts index c19a289c..4a88075f 100644 --- a/packages/platformos-graph/src/graph/incremental.ts +++ b/packages/platformos-graph/src/graph/incremental.ts @@ -1,9 +1,5 @@ -import { - extractGraphqlTable, - extractSchemaTable, - path, - UriString, -} from '@platformos/platformos-check-common'; +import { path, UriString } from '@platformos/platformos-check-common'; +import { extractGraphqlTables, extractSchemaTable } from '@platformos/platformos-common'; import { AppGraph, @@ -201,13 +197,14 @@ async function materializeTarget( /** * Record a leaf module's neutral platform table fact — the GraphQL operation's - * `table` filter, or a schema's model `name:` — reusing check-common's parsers, - * exactly as {@link traverseModule} does. A no-op for asset/liquid modules. + * `table` filters, or a schema's model `name:` — reusing platformos-common's + * parsers, exactly as {@link traverseModule} does. A no-op for asset/liquid + * modules. */ async function readLeafTable(module: AppModule, deps: AugmentedDependencies): Promise { if (module.type === ModuleType.GraphQL) { const sourceCode = await deps.getSourceCode(module.uri); - module.table = extractGraphqlTable(sourceCode.source); + module.tables = extractGraphqlTables(sourceCode.source); } else if (module.type === ModuleType.Schema) { const sourceCode = await deps.getSourceCode(module.uri); module.table = extractSchemaTable(sourceCode.source); diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index a1c0b7de..6e265bd7 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -139,6 +139,8 @@ export function getGraphQLModuleByUri(appGraph: AppGraph, uri: string): GraphQLM uri: path.normalize(uri), dependencies: [], references: [], + // Always present (empty = no table declared); populated during traversal. + tables: [], }); } diff --git a/packages/platformos-graph/src/graph/traverse-edges.spec.ts b/packages/platformos-graph/src/graph/traverse-edges.spec.ts index fe43218c..fda25905 100644 --- a/packages/platformos-graph/src/graph/traverse-edges.spec.ts +++ b/packages/platformos-graph/src/graph/traverse-edges.spec.ts @@ -97,6 +97,7 @@ describe('URI normalization in graph node factories', () => { uri: 'file:///d:/a/repo/app/graphql/find.graphql', dependencies: [], references: [], + tables: [], }); }); }); @@ -148,14 +149,19 @@ describe('Graph traversal: {% graphql %} edges', () => { let indexSource: string; let brokenSource: string; - const graphqlNode = (uri: string, exists: boolean, references: Reference[], table?: string) => ({ + const graphqlNode = ( + uri: string, + exists: boolean, + references: Reference[], + tables: string[] = [], + ) => ({ type: ModuleType.GraphQL, kind: 'graphql' as const, uri, exists, dependencies: [], references, - ...(table ? { table } : {}), + tables, }); beforeAll(async () => { @@ -177,7 +183,7 @@ describe('Graph traversal: {% graphql %} edges', () => { // The resolved GraphQL node carries the model `table` it targets (the fixture // operation filters on `table: { value: "blog_post" }`). expect(graph.modules[p('app/graphql/blog_posts/find.graphql')]).toEqual( - graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge], 'blog_post'), + graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge], ['blog_post']), ); }); @@ -194,7 +200,7 @@ describe('Graph traversal: {% graphql %} edges', () => { }); }); -describe('Graph traversal: GraphQL node `table` (build-time, both shapes)', () => { +describe('Graph traversal: GraphQL node `tables` (build-time, both shapes)', () => { const rootUri = pathUtils.join(fixturesRoot, 'graphql-table'); const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); let graph: AppGraph; @@ -203,18 +209,18 @@ describe('Graph traversal: GraphQL node `table` (build-time, both shapes)', () = graph = await buildAppGraph(rootUri, getDependencies()); }, 15000); - it('records the table for an operation that filters on one', () => { + it('records the tables for an operation that filters on one', () => { const node = graph.modules[p('app/graphql/with_table.graphql')]; assert(node); assert(node.type === ModuleType.GraphQL); - expect(node.table).toBe('blog_post'); + expect(node.tables).toEqual(['blog_post']); }); - it('leaves table undefined for an operation with no table filter', () => { + it('leaves tables empty for an operation with no table filter', () => { const node = graph.modules[p('app/graphql/without_table.graphql')]; assert(node); assert(node.type === ModuleType.GraphQL); - expect(node.table).toBeUndefined(); + expect(node.tables).toEqual([]); }); }); diff --git a/packages/platformos-graph/src/graph/traverse.ts b/packages/platformos-graph/src/graph/traverse.ts index a70f02bc..db4691ec 100644 --- a/packages/platformos-graph/src/graph/traverse.ts +++ b/packages/platformos-graph/src/graph/traverse.ts @@ -1,8 +1,6 @@ import yaml from 'js-yaml'; import { LiquidNamedArgument, NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; import { - extractGraphqlTable, - extractSchemaTable, isTranslationKeyUsage, SourceCodeType, UriString, @@ -13,7 +11,9 @@ import { containsLiquid, DocumentsLocator, effectivePageSlug, + extractGraphqlTables, extractRelativePagePath, + extractSchemaTable, } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; import { @@ -85,10 +85,10 @@ export async function traverseModule( case ModuleType.GraphQL: { // Leaf node — GraphQL documents have no platformOS dependencies. We do read - // the source once to record the model `table` it targets (a neutral - // platform fact), reusing check-common's GraphQL parser. + // the source once to record the model tables it targets (a neutral + // platform fact), reusing platformos-common's GraphQL parser. const sourceCode = await deps.getSourceCode(module.uri); - module.table = extractGraphqlTable(sourceCode.source); + module.tables = extractGraphqlTables(sourceCode.source); return; } diff --git a/packages/platformos-graph/src/types.ts b/packages/platformos-graph/src/types.ts index 870a0f46..95dd438d 100644 --- a/packages/platformos-graph/src/types.ts +++ b/packages/platformos-graph/src/types.ts @@ -116,11 +116,12 @@ export interface AssetModule extends IAppModule { export interface GraphQLModule extends IAppModule { kind: 'graphql'; /** - * The platformOS model table this operation targets (from its `table` filter), - * when it declares one. Populated during `buildAppGraph` traversal; absent for - * operations with no table filter or that were never resolved on disk. + * The platformOS model tables this operation targets (from its `table` + * filters / `record_create` inputs) — every distinct table in document order. + * Populated during `buildAppGraph` traversal; always present (empty = the + * operation declares no table, or was never resolved on disk). */ - table?: string; + tables: string[]; } /** @@ -137,10 +138,10 @@ export interface GraphQLModule extends IAppModule { export interface SchemaModule extends IAppModule { kind: 'schema'; /** - * The model table name (the schema's top-level `name:`), when declared. Named - * `table` to align with {@link GraphQLModule.table} so a consumer can join a - * GraphQL op to its schema. Absent when the file declares no `name:` or could - * not be parsed. + * The model table name (the schema's top-level `name:`), when declared. A + * schema declares exactly one, so this is a single value (unlike a GraphQL + * op's {@link GraphQLModule.tables}) that a consumer can join against. Absent + * when the file declares no `name:` or could not be parsed. */ table?: string; } From e83bb6ced5db3b72c76c23bbcc0454ed7e69e9c9 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 7 Jul 2026 14:16:34 +0200 Subject: [PATCH 18/20] refactor(graph): own edge-source enumeration; supervisor is a pure consumer (TASK-9.17) The supervisor's GraphCache hardcoded three pieces of graph-domain knowledge (SOURCE_ROOTS, isEdgeSource, enumerateEdgeSources) that ADR 003 says belong in platformos-graph. Nothing forced them to agree with the file-type classifier, so a new source root / partial location added to the classifier would silently diverge -> the fingerprint scan omits real edge sources -> incomplete entry points -> dependentsOf under-reports -> a wrong "0 dependents, safe to change". - Add platformos-graph/src/graph/edge-sources.ts exporting isEdgeSource and enumerateEdgeSources (scoped SOURCE_ROOTS walk), moved verbatim from the supervisor. isEdgeSource derives from the classifier (isLayout/isPage/ isPartial), so "which files" has one source of truth. - graph-cache.ts drops its local copies + now-unused imports and consumes the graph primitive; still owns fingerprint/cache/persistence/reconcile. - Guardrail edge-sources.spec.ts (MockFileSystem): the scoped walk must equal a whole-tree walk filtered by isEdgeSource, over a fixture spanning every source root + a bundled react-app/ sibling -> drift fails the build. --- ...ervisors-leaked-graph-domain-knowledge.md" | 40 ++++++-- .../src/graph/edge-sources.spec.ts | 91 +++++++++++++++++++ .../src/graph/edge-sources.ts | 70 ++++++++++++++ packages/platformos-graph/src/index.ts | 1 + .../src/graph-cache/graph-cache.ts | 54 ++--------- 5 files changed, 203 insertions(+), 53 deletions(-) create mode 100644 packages/platformos-graph/src/graph/edge-sources.spec.ts create mode 100644 packages/platformos-graph/src/graph/edge-sources.ts diff --git "a/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" "b/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" index d972b15b..77dfa227 100644 --- "a/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" +++ "b/.backlog/tasks/task-9.17 - Move-edge-source-enumeration-fingerprint-domain-into-platformos-graph-ADR-003-\342\200\224-remove-supervisors-leaked-graph-domain-knowledge.md" @@ -3,9 +3,10 @@ id: TASK-9.17 title: >- Move edge-source enumeration + fingerprint domain into platformos-graph (ADR 003 — remove supervisor's leaked graph-domain knowledge) -status: To Do +status: Done assignee: [] created_date: '2026-07-02 13:03' +updated_date: '2026-07-07 12:15' labels: - platformos-graph - mcp-supervisor @@ -18,6 +19,11 @@ references: - packages/platformos-common/src/path-utils.ts - >- docs/mcp-supervisor/decisions/003-graph-backed-structural-enrichment/README.md +modified_files: + - packages/platformos-graph/src/graph/edge-sources.ts + - packages/platformos-graph/src/graph/edge-sources.spec.ts + - packages/platformos-graph/src/index.ts + - packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts parent_task_id: TASK-9 priority: medium --- @@ -43,10 +49,30 @@ Working dir: ~/Work/platformos-tools/platformos-tools. NON-BLOCKING for the curr ## Acceptance Criteria -- [ ] #1 platformos-graph (or check-common) exports a single canonical edge-source enumeration primitive that reuses isLayout/isPage/isPartial + the source-root knowledge; the supervisor GraphCache consumes it and no longer defines its own SOURCE_ROOTS / isEdgeSource / enumerateEdgeSources -- [ ] #2 The enumerated edge-source set stays byte-identical to today's on a real project (TASK-9.15 Phase-3A scoped-walk win preserved; verified on marketplace-dcra: 1921 files, zero diff vs whole-tree walk) -- [ ] #3 A test pins the enumerated set to the file-type classifier so the graph-owned 'edge source' + 'source root' definition cannot silently drift from getFileType/build.ts -- [ ] #4 Never-stale preserved: the fingerprint domain = the enumerated edge-source set = the build's entry points (one definition, one owner); supervisor still fingerprints via check-node fileFingerprint -- [ ] #5 Whether buildAppGraph's full-build discovery and the scoped edge-source enumeration can share one internal walk is evaluated (share or document why not) -- [ ] #6 graph + supervisor suites + type-check + format + frozen-lockfile green; no regression to buildAppGraph / GraphCache behavior +- [x] #1 platformos-graph (or check-common) exports a single canonical edge-source enumeration primitive that reuses isLayout/isPage/isPartial + the source-root knowledge; the supervisor GraphCache consumes it and no longer defines its own SOURCE_ROOTS / isEdgeSource / enumerateEdgeSources +- [x] #2 The enumerated edge-source set stays byte-identical to today's on a real project (TASK-9.15 Phase-3A scoped-walk win preserved; verified on marketplace-dcra: 1921 files, zero diff vs whole-tree walk) +- [x] #3 A test pins the enumerated set to the file-type classifier so the graph-owned 'edge source' + 'source root' definition cannot silently drift from getFileType/build.ts +- [x] #4 Never-stale preserved: the fingerprint domain = the enumerated edge-source set = the build's entry points (one definition, one owner); supervisor still fingerprints via check-node fileFingerprint +- [x] #5 Whether buildAppGraph's full-build discovery and the scoped edge-source enumeration can share one internal walk is evaluated (share or document why not) +- [x] #6 graph + supervisor suites + type-check + format + frozen-lockfile green; no regression to buildAppGraph / GraphCache behavior + +## Final Summary + + +Moved the edge-source enumeration + fingerprint domain out of the supervisor into platformos-graph (ADR 003), so "which files are edge sources" and "where sources live" have ONE owner beside the classifier — the supervisor can no longer silently drift and under-report dependents. + +New platformos-graph/src/graph/edge-sources.ts (exported from the package index): +- `isEdgeSource(uri)` = isLayout || isPage || isPartial — the canonical edge-source predicate, deriving entirely from the file-type classifier (FILE_TYPE_DIRS), so "which files" has a single source of truth. +- `enumerateEdgeSources(fs, rootUri)` — the canonical scoped walk (SOURCE_ROOTS = app/marketplace_builder/modules), moved VERBATIM from the supervisor (byte-identical algorithm → identical set on any project; AC#2). + +Supervisor graph-cache.ts is now a PURE CONSUMER: deleted its local isEdgeSource / SOURCE_ROOTS / enumerateEdgeSources and the now-unused isLayout/isPage/isPartial/recursiveReadDirectory imports; imports enumerateEdgeSources from @platformos/platformos-graph; computeFingerprintFromDisk calls it unchanged. Still owns fingerprinting (check-node fileFingerprint), caching, persistence, reconcile (AC#4). + +Guardrail (AC#3) — edge-sources.spec.ts (MockFileSystem, no disk): the load-bearing test asserts the SCOPED walk equals a WHOLE-TREE walk filtered by isEdgeSource over a fixture with edge sources under every root (app/, app/lib, marketplace_builder/, top-level modules/, nested app/modules/) plus non-edge leaves (graphql/schema/asset) and a bundled react-app/ sibling. If a source root is added to the classifier but not to SOURCE_ROOTS, the whole-tree walk finds files the scoped walk misses → test fails. Also pins isEdgeSource === isLayout||isPage||isPartial directly and asserts the exact expected set. + +AC#5 (shared walk) — evaluated and declined, documented in the edge-sources.ts JSDoc: buildAppGraph's full-build discovery gathers a DIFFERENT domain (render entry points = pages+layouts only, +schema, whole-tree) vs this (page+layout+partial, scoped — the cache needs partials as entry points for complete dependents). They share the isLayout/isPage/isPartial predicate (the part that must not drift); sharing the walk would conflate two entry-point domains. + +Verification (AC#6, all green): no stale graph-domain refs remain in the supervisor; graph type-check + supervisor type-check clean (tsc exit 0); graph suite 105 (+4 edge-sources), supervisor suite 76 UNCHANGED — the existing scoped-walk regression tests (enumerate across all three roots + react-app pruned) pass as-is, proving the consumer swap is behavior-preserving; prettier clean; yarn install --frozen-lockfile clean with zero yarn.lock churn. graph + supervisor dist rebuilt. + +NOTE (not edited, flagged): SUPERVISOR-GRAPH-INTEGRATION.md §10.2 Phase-3A still describes SOURCE_ROOTS as living in the supervisor cache — now stale (moved to graph); fold into the next doc pass. + diff --git a/packages/platformos-graph/src/graph/edge-sources.spec.ts b/packages/platformos-graph/src/graph/edge-sources.spec.ts new file mode 100644 index 00000000..f9dab6a7 --- /dev/null +++ b/packages/platformos-graph/src/graph/edge-sources.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { + isLayout, + isPage, + isPartial, + path, + recursiveReadDirectory, +} from '@platformos/platformos-check-common'; +import { MockFileSystem, type MockApp } from '@platformos/platformos-check-common/dist/test'; + +import { enumerateEdgeSources, isEdgeSource } from '../index'; + +/** + * TASK-9.17: `enumerateEdgeSources` is the SINGLE canonical "what are the + * edge-source liquid files under a project root" primitive. The supervisor's + * GraphCache consumes it for BOTH the fingerprint domain and the build's entry + * points, so its definition must never silently drift from the file-type + * classifier (`isLayout`/`isPage`/`isPartial` ← `FILE_TYPE_DIRS`). + * + * The load-bearing guard is EQUIVALENCE-TO-THE-CLASSIFIER: the SCOPED walk (only + * the platformOS source roots, the TASK-9.15 Phase-3A perf win) must gather the + * exact same set a WHOLE-TREE walk filtered by `isEdgeSource` would. If a new + * source root / partial location is added to the classifier but not to the + * scoped roots, the whole-tree walk finds files the scoped walk misses → this + * test fails, catching the drift before it under-reports dependents. + */ +describe('Unit: enumerateEdgeSources (canonical edge-source enumeration)', () => { + const rootUri = path.normalize('file:/'); + const uri = (rel: string) => path.join(rootUri, rel); + + // Edge sources under every canonical location a Page/Layout/Partial can live. + const EDGE_SOURCES = [ + 'app/views/pages/home.liquid', // Page (modern app/ root) + 'app/views/layouts/application.liquid', // Layout + 'app/views/partials/card.liquid', // Partial (views/partials) + 'app/lib/helper.liquid', // Partial (lib) + 'marketplace_builder/views/pages/legacy.liquid', // Page (legacy root) + 'modules/shop/public/views/partials/widget.liquid', // Partial (top-level module) + 'app/modules/blog/private/views/pages/post.liquid', // Page (nested app/modules) + ]; + + // Files that are NOT edge sources: leaves, non-liquid, and a bundled + // non-platformOS sibling that must never be walked. + const NON_EDGE_SOURCES = { + 'app/graphql/get_posts.graphql': 'query { records { results { id } } }', + 'app/schema/blog_post.yml': 'name: blog_post', + 'app/assets/logo.css': 'body {}', + 'react-app/src/components/Widget.liquid': 'noise that is never a source', + 'README.md': '# project', + }; + + const makeFs = () => { + const files: MockApp = { ...NON_EDGE_SOURCES }; + for (const rel of EDGE_SOURCES) files[rel] = '
'; + return new MockFileSystem(files, rootUri); + }; + + const sorted = (uris: string[]) => [...uris].sort(); + + it('gathers exactly the edge sources across all source roots (nothing else)', async () => { + const fs = makeFs(); + expect(sorted(await enumerateEdgeSources(fs, rootUri))).toEqual(sorted(EDGE_SOURCES.map(uri))); + }); + + it('the scoped walk equals a whole-tree walk filtered by the classifier (no drift)', async () => { + const fs = makeFs(); + const scoped = sorted(await enumerateEdgeSources(fs, rootUri)); + const wholeTree = sorted(await recursiveReadDirectory(fs, rootUri, ([u]) => isEdgeSource(u))); + expect(scoped).toEqual(wholeTree); + }); + + it('isEdgeSource is exactly isLayout || isPage || isPartial', () => { + for (const rel of EDGE_SOURCES) { + const u = uri(rel); + expect(isEdgeSource(u)).toBe(true); + expect(isLayout(u) || isPage(u) || isPartial(u)).toBe(true); + } + for (const rel of Object.keys(NON_EDGE_SOURCES)) { + const u = uri(rel); + expect(isEdgeSource(u)).toBe(false); + expect(isLayout(u) || isPage(u) || isPartial(u)).toBe(false); + } + }); + + it('never yields a non-platformOS sibling (a bundled react-app/ is skipped)', async () => { + const fs = makeFs(); + const result = await enumerateEdgeSources(fs, rootUri); + expect(result).not.toContain(uri('react-app/src/components/Widget.liquid')); + }); +}); diff --git a/packages/platformos-graph/src/graph/edge-sources.ts b/packages/platformos-graph/src/graph/edge-sources.ts new file mode 100644 index 00000000..239736be --- /dev/null +++ b/packages/platformos-graph/src/graph/edge-sources.ts @@ -0,0 +1,70 @@ +import { + isLayout, + isPage, + isPartial, + path, + recursiveReadDirectory, + type UriString, +} from '@platformos/platformos-check-common'; +import type { AbstractFileSystem } from '@platformos/platformos-common'; + +/** + * The canonical definition of an EDGE SOURCE: a liquid file whose own content + * can declare outgoing edges (a Page, Layout, or Partial). Only these files' + * add/remove/modify can change any file's set of dependents — `.graphql`/`.yml`/ + * asset files are leaves. This is exactly the set a caller feeds `buildAppGraph` + * as entry points when it needs COMPLETE dependents (every caller traversed). + * + * "Which files" derives entirely from the file-type classifier + * (`isLayout`/`isPage`/`isPartial` ← `FILE_TYPE_DIRS`), so there is ONE source of + * truth for the classification — this predicate never re-encodes it. + */ +export function isEdgeSource(uri: UriString): boolean { + return isLayout(uri) || isPage(uri) || isPartial(uri); +} + +/** + * The top-level platformOS source roots that can contain an edge-source liquid + * file. Per the file-type classifier, every Page/Layout/Partial lives under the + * modern `app/` root (which also holds `app/modules//…`), the legacy + * `marketplace_builder/` alias, or a top-level `modules//…`. Scoping the walk + * to these — instead of the whole project tree — skips large non-platformOS + * siblings (e.g. a bundled `react-app/`) with NO loss of real sources. + * + * This is a scoping OPTIMISATION, not a second classification: `edge-sources.spec` + * pins the scoped result to a whole-tree walk filtered by {@link isEdgeSource}, + * so a source root added to the classifier but not here fails the test. + */ +const SOURCE_ROOTS = ['app', 'marketplace_builder', 'modules'] as const; + +/** + * Enumerate every edge-source liquid file under a project root — the single + * canonical primitive for "which files are the graph's edge sources / entry + * points / fingerprint domain" (TASK-9.17). Consumers (the supervisor's + * GraphCache) are pure users: they never re-derive the source-root set or the + * `isEdgeSource` predicate. + * + * The walk is SCOPED to {@link SOURCE_ROOTS} (the TASK-9.15 Phase-3A perf win): a + * root absent on disk contributes nothing (`recursiveReadDirectory` returns `[]` + * on ENOENT); the roots are disjoint, so no URI is produced twice. + * + * NOT shared with `buildAppGraph`'s full-build discovery (AC#5, evaluated and + * declined): that walk gathers a DIFFERENT domain — render *entry points* + * (pages + layouts only; partials are edge-reached) plus standalone schema + * nodes — over the WHOLE tree, whereas this gathers page + layout + partial + * (the cache needs partials as entry points for a complete reverse index) and is + * scoped. The two share the `isLayout`/`isPage`/`isPartial` predicate (the single + * classifier), which is the part that must not drift; sharing the walk itself + * would conflate two different entry-point domains. + */ +export async function enumerateEdgeSources( + fs: AbstractFileSystem, + rootUri: UriString, +): Promise { + const perRoot = await Promise.all( + SOURCE_ROOTS.map((dir) => + recursiveReadDirectory(fs, path.join(rootUri, dir), ([uri]) => isEdgeSource(uri)), + ), + ); + return perRoot.flat(); +} diff --git a/packages/platformos-graph/src/index.ts b/packages/platformos-graph/src/index.ts index 76401589..d9f4f9bd 100644 --- a/packages/platformos-graph/src/index.ts +++ b/packages/platformos-graph/src/index.ts @@ -1,4 +1,5 @@ export { buildAppGraph } from './graph/build'; +export { enumerateEdgeSources, isEdgeSource } from './graph/edge-sources'; export { applyFileChange } from './graph/incremental'; export type { FileChangeKind } from './graph/incremental'; export { extractFileReferences, extractStructural } from './graph/traverse'; diff --git a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts index ac1a596d..f6667d22 100644 --- a/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts +++ b/packages/platformos-mcp-supervisor/src/graph-cache/graph-cache.ts @@ -35,29 +35,25 @@ * `buildAppGraph` as entry points, so dependents are COMPLETE (every caller is * traversed) — see the query.ts note on entry-point scope. * - * The enumeration is SCOPED to the platformOS source roots ({@link SOURCE_ROOTS}) - * rather than the whole project tree, so a bundled `react-app/` or other - * non-platformOS sibling is never walked — the edge-source set is identical, just - * cheaper to gather (TASK-9.15 Phase 3, part A). + * The cache is a PURE CONSUMER of the graph's canonical `enumerateEdgeSources` + * (TASK-9.17): the "which files are edge sources" + "where sources live" + * definitions live in platformos-graph beside the classifier, so this cache can + * never drift from them. The enumeration is scoped to the platformOS source roots + * (a bundled `react-app/` is never walked; identical set, cheaper — TASK-9.15 + * Phase 3A). */ import { createHash } from 'node:crypto'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { - isLayout, - isPage, - isPartial, - path, - recursiveReadDirectory, - type UriString, -} from '@platformos/platformos-check-common'; +import { path, type UriString } from '@platformos/platformos-check-common'; import { fileFingerprint, NodeFileSystem } from '@platformos/platformos-check-node'; import type { AbstractFileSystem } from '@platformos/platformos-common'; import { applyFileChange, buildAppGraph, + enumerateEdgeSources, type AppGraph, type FileChangeKind, } from '@platformos/platformos-graph'; @@ -129,40 +125,6 @@ async function writeFileAtomic(filePath: string, contents: string): Promise/…`), the - * legacy `marketplace_builder/` alias, or a top-level `modules//…`. Walking - * only these — instead of the whole project tree — skips large non-platformOS - * siblings (e.g. a bundled `react-app/`) with NO loss of real sources: for a real - * project the enumerated edge-source set is identical, only cheaper to gather. - */ -const SOURCE_ROOTS = ['app', 'marketplace_builder', 'modules'] as const; - -/** - * Enumerate every edge-source liquid file under the platformOS {@link SOURCE_ROOTS}, - * scoping the walk to those subtrees rather than the whole project tree. A root - * absent on disk contributes nothing (`recursiveReadDirectory` returns `[]` on - * ENOENT); the roots are disjoint, so no URI is produced twice. - */ -async function enumerateEdgeSources( - fs: AbstractFileSystem, - rootUri: UriString, -): Promise { - const perRoot = await Promise.all( - SOURCE_ROOTS.map((dir) => - recursiveReadDirectory(fs, path.join(rootUri, dir), ([uri]) => isEdgeSource(uri)), - ), - ); - return perRoot.flat(); -} - /** * Real disk fingerprint: every edge-source liquid file → its per-file identity. * Reuses check-node's exported {@link fileFingerprint} — the SAME `mtimeMs:size` From 2aa186300eabd993db0eb31e845d6a73cd1560b3 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 7 Jul 2026 16:00:28 +0200 Subject: [PATCH 19/20] fix(graph): key partial entry points by URI, not basename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getModule resolved a partial entry point via getPartialModule(graph, path.basename(uri, '.liquid')), which rebuilds the path as app/views/partials/.liquid — dropping the file's directory and forcing the app/views/partials location. Every lib/ or nested partial (e.g. app/lib/commands/execute.liquid, app/lib/can/payment_request.liquid) was mis-keyed to a phantom app/views/partials/ node (exists:false, no edges), split from the SAME file resolved as an edge target (which correctly uses getPartialModuleByUri). Layout/page/asset entry points already key by full URI; partials were the odd one out (assets were fixed in d09ab82, partials left behind). Pre-existing on master; latent until tested against a real project whose lib/ layer holds commands/queries/permission helpers. Impact: the full build dropped these partials' real edges and materialized a phantom exists:false node; incremental applyFileChange re-materialized the real node, so incremental DIVERGED from a full build (a never-stale violation). Dependents/blast-radius/dead-code for the entire lib/ layer were wrong — the tool could report "safe to change" on heavily-depended-on files. Fix: getModule's partial branch keys by the file's own resolved URI via getPartialModuleByUri(graph, uri), consistent with the other factories. Flat app/views/partials/* partials are byte-identical (no behavior change); only lib/nested partials are corrected. Name-based getPartialModule retained (test factory). Added getModule regression tests to module.spec.ts. --- ...plits-node-identity-corrupts-dependents.md | 61 ++++++++ SUPERVISOR-GRAPH-INTEGRATION.md | 136 ++++++++++++++++-- .../data/filters.json | 2 +- .../data/graphql.graphql | 11 +- .../data/latest.json | 2 +- .../platformos-graph/src/graph/module.spec.ts | 40 ++++++ packages/platformos-graph/src/graph/module.ts | 7 +- 7 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 .backlog/tasks/task-9.23 - Fix-getModule-mis-keys-lib-nested-partials-by-basename-splits-node-identity-corrupts-dependents.md diff --git a/.backlog/tasks/task-9.23 - Fix-getModule-mis-keys-lib-nested-partials-by-basename-splits-node-identity-corrupts-dependents.md b/.backlog/tasks/task-9.23 - Fix-getModule-mis-keys-lib-nested-partials-by-basename-splits-node-identity-corrupts-dependents.md new file mode 100644 index 00000000..46067347 --- /dev/null +++ b/.backlog/tasks/task-9.23 - Fix-getModule-mis-keys-lib-nested-partials-by-basename-splits-node-identity-corrupts-dependents.md @@ -0,0 +1,61 @@ +--- +id: TASK-9.23 +title: >- + Fix: getModule mis-keys lib/nested partials by basename (splits node identity, + corrupts dependents) +status: Done +assignee: [] +created_date: '2026-07-07 13:34' +updated_date: '2026-07-07 13:57' +labels: + - bug + - platformos-graph + - correctness +dependencies: [] +modified_files: + - packages/platformos-graph/src/graph/module.ts + - packages/platformos-graph/src/graph/module.spec.ts +parent_task_id: TASK-9 +priority: high +--- + +## Description + + +Found via real-project verification on marketplace-dcra (2170-node graph). + +DEFECT (pre-existing, on master; latent until a real project with `lib/`/nested partials was tested). `getModule` (the entry-point dispatcher) resolved a partial via `getPartialModule(appGraph, path.basename(uri, '.liquid'))`. `getPartialModule` rebuilds the path as `app/views/partials/.liquid`, so it (a) drops the file's directory and (b) forces the `app/views/partials/` location. Any partial NOT flat under `app/views/partials/` — e.g. `app/lib/can/payment_request.liquid`, `app/lib/queries/v2/projects/find.liquid`, `app/lib/commands/...` — was mis-keyed to a PHANTOM `app/views/partials/.liquid` node (exists:false, no edges), split from the SAME file resolved as an edge target (which correctly goes through `getPartialModuleByUri`). + +IMPACT. The full build lost these partials' real outgoing edges and materialized a phantom exists:false node instead; incremental applyFileChange re-materialized the real node, so incremental DIVERGED from a full build (a never-stale violation). Dependents/blast-radius/dead-code for every lib/nested partial were wrong — critical for a tool whose job is correct platformOS guidance. On marketplace-dcra, app/lib/** is where commands/queries/can-helpers live (heavily used). + +FIX. getModule's partial branch keys by the file's own resolved URI via getPartialModuleByUri(appGraph, uri) — identical to how layout/page/asset entry points already key (this branch already fixed the same bug for assets in d09ab82; partials were left behind). For flat app/views/partials/* partials the result is byte-identical (no behavior change); only lib/nested partials are corrected. getPartialModule (name-based) retained as a test factory. + +Root cause: packages/platformos-graph/src/graph/module.ts (getModule ~line 46). Regression tests in module.spec.ts. + + +## Acceptance Criteria + +- [x] #1 getModule keys a lib/nested partial entry point by its own URI (module.spec regression tests) +- [x] #2 Flat app/views/partials partials are unaffected (byte-identical keying) +- [x] #3 On marketplace-dcra, incremental applyFileChange no longer diverges from a full build (A4 harness passes) +- [x] #4 Full graph + supervisor suites, type-check, format green + + +## Final Summary + + +Fixed a severe, pre-existing graph correctness bug surfaced by real-project verification on marketplace-dcra (2266-node graph). + +Root cause: `getModule` (module.ts) resolved a partial entry point via `getPartialModule(appGraph, path.basename(uri, '.liquid'))`, which rebuilds the path as `app/views/partials/.liquid` — dropping the file's directory and forcing the app/views/partials location. Every `lib/` or nested partial (e.g. `app/lib/can/payment_request.liquid`, `app/lib/commands/execute.liquid`) was mis-keyed to a phantom `app/views/partials/.liquid` node (exists:false, no edges), split from the same file resolved as an edge target (which correctly uses getPartialModuleByUri). Layout/page/asset entry points already keyed by full URI; partials were the odd one out (assets were fixed on this branch in d09ab82, partials left behind). Confirmed present on master. + +Fix (one line): getModule's partial branch now calls `getPartialModuleByUri(appGraph, uri)` — key by the file's own resolved URI, consistent with the other factories. Flat app/views/partials/* partials are byte-identical (no change); only lib/nested partials are corrected. Name-based getPartialModule retained (used as a test factory). Added getModule regression tests to module.spec.ts (lib partial, nested lib partial, entry-point≡edge-target identity, flat-partial unaffected). + +Impact measured on marketplace-dcra (before → after fix): +- edges 3287 → 5106 (+1819 real edges were being dropped) +- missing targets 752 → 29 (the 752 were mostly phantom mis-keyed lib partials) +- graphql nodes 237 → 283 (lib→graphql function calls now traversed) +- nodes 2170 → 2266 +- incremental applyFileChange now ≡ a full build (was diverging — a never-stale violation); the busiest partial lib/commands/execute.liquid now correctly reports 150 distinct dependents (was corrupted). + +Verification: module.spec RED→GREEN; full graph suite 109, supervisor 76, language-server 467 (clean run; the one intermittent fail was the documented TypeSystem.spec parallel-load flake, passes on re-run); prettier clean; graph dist rebuilt. Real-project harness (scoped≡whole-tree never-stale, buildAppGraph, extractGraphqlTables, incremental≡full, GraphCache, runImpact, end-to-end runValidateCode): ALL CHECKS PASSED. + diff --git a/SUPERVISOR-GRAPH-INTEGRATION.md b/SUPERVISOR-GRAPH-INTEGRATION.md index 2cd85d46..3603ce9a 100644 --- a/SUPERVISOR-GRAPH-INTEGRATION.md +++ b/SUPERVISOR-GRAPH-INTEGRATION.md @@ -13,6 +13,11 @@ examples, and the open doubts. > Ten were fixed and one deferred with rationale; that section is the place to > understand _what changed as a result of review and why_. The rest of this > document reflects the **post-review** state. +> +> **§11 is a SECOND review round** (post-submission) — the supervisor flagged +> division-of-responsibility + test-hygiene defects, all fixed. Where §11 +> supersedes earlier prose (table-extraction shape/placement, edge-source +> enumeration ownership), the §11 statement is authoritative. --- @@ -60,7 +65,7 @@ graph to provide that model and consumes it from `validate_code`. ### Scope discipline (kept the changes safe) - **Additive everywhere.** New optional fields (`Reference.kind`, `.args`, - `GraphQLModule.table`, `LiquidModule.structural`, `SchemaModule`), new query + `GraphQLModule.tables`, `LiquidModule.structural`, `SchemaModule`), new query functions, new DocumentType. No breaking change to existing consumers. - **The LSP output is unchanged.** The `validate_code` output was reshaped by TASK-9.10 (§9): `dependencies`/`structural` removed, `impact` added — a @@ -180,7 +185,7 @@ paths can never drift: ### 2.4 Resolution is delegated to `DocumentsLocator` -The graph never hand-rolls path logic. Targets resolve through check-common's +The graph never hand-rolls path logic. Targets resolve through platformos-common's `DocumentsLocator`, which owns lib search paths, `modules//public/...` prefixes, and extension handling. This branch added a **`'layout'` DocumentType** there (maps to `PlatformOSFileType.Layout`, tries `.html.liquid` then `.liquid`), @@ -202,7 +207,7 @@ The platform sees only "a `function` edge to a partial." Therefore: the platform model — and the LSP that depends on it — wrong for any app not using `core`.) - **Resource/CRUD completeness** (the old `detectResources`) is split: neutral - **platform facts** (schema tables, graphql `table`, slug) live in the graph + **platform facts** (schema tables, graphql `tables`, slug) live in the graph (TASK-9.6, done); the **commands/queries convention overlay** lives outside it (TASK-9.7, deferred) — a descriptive map in the supervisor domain layer and/or a _configurable_ check (disable-able for non-`core` apps). @@ -459,6 +464,8 @@ For | 9.13 opt-in `AppCache` (parsed-project reuse for lint) | ✅ Done | | **9.14 `applyFileChange` incremental graph API** (§10.1) | ✅ Done | | **9.15 warm/incremental/persisted GraphCache** (§10.2) | ◐ Phases 1–2 + 3A (scoped walk) done; 3B (fs.watch) deferred | +| **9.17 edge-source enumeration → `platformos-graph`** (§11) | ✅ Done | +| **9.22 supervisor⇄graph review round 2** (§11) | ✅ Done | | 8.4 surface `structural` in `validate_code` | ↩︎ Superseded by 9.10 (removed) | **The three-tool graph strategy** (why the graph lives mostly _outside_ @@ -703,10 +710,10 @@ safe-to-change, and signature-impact). Supervisor suite: **55**. ### Open follow-ups -- The fingerprint (and the build's entry-point enumeration) walks the whole tree; - scoping the walk to platformOS dirs (or reusing lint's file list) would cut the - ~400 ms warm cost. Not a 9.10 regression (same walk `buildAppGraph`/lint already - do); worth a follow-up. +- ~~The fingerprint (and the build's entry-point enumeration) walks the whole + tree; scoping the walk to platformOS dirs would cut the ~400 ms warm cost.~~ + **RESOLVED** — Phase 3A (§10.2) scoped the walk, and TASK-9.17 (§11) moved the + enumeration into `platformos-graph` as the canonical `enumerateEdgeSources`. - Cold-start: the first blast-radius request (or the first after a write) returns `computing` until the background build finishes; a bounded await for small projects is a possible future nicety (deliberately omitted to never delay lint). @@ -714,7 +721,7 @@ safe-to-change, and signature-impact). Supervisor suite: **55**. > The first two follow-ups above are resolved by §10 (incremental apply removes the > post-write `computing` gap; persistence removes the cold-build wait). Scoping the -> walk is the remaining Phase-3 item. +> walk is done (Phase 3A) and its ownership moved to `platformos-graph` (§11). --- @@ -761,10 +768,10 @@ applyFileChange(graph, uri, kind: 'added'|'modified'|'deleted', deps, options?) parse. Precondition: the graph was built with every edge-source liquid file as an entry point (the cache's mode). -**Guardrail (the correctness invariant):** `incremental.spec.ts` mutates a real -temp project on disk, applies the change, and asserts -`serializeAppGraph(incremental)` is canonically identical to a fresh -`buildAppGraph` of the same disk state — across add / modify / delete, missing- +**Guardrail (the correctness invariant):** `incremental.spec.ts` mutates an +in-memory `MockFileSystem` (TASK-9.22.2 — no disk I/O), applies the change, and +asserts `serializeAppGraph(incremental)` is canonically identical to a fresh +`buildAppGraph` of the same source state — across add / modify / delete, missing- target `exists` flips, leaf GC, self-reference, cycles, and mixed sequences. ### 10.2 Warm, incremental, persisted `GraphCache` (TASK-9.15) @@ -808,7 +815,10 @@ It now walks only the platformOS source roots — `app/`, `marketplace_builder/` `modules/` (`SOURCE_ROOTS`) — which, per the file-type classifier, are the only places a Page/Layout/Partial can live (including nested `app/modules//…`, reached via `app/`). The enumerated set is therefore **provably identical**, just -cheaper to gather. +cheaper to gather. **(Since TASK-9.17 — §11 — `SOURCE_ROOTS`, the `isEdgeSource` +predicate, and this scoped `enumerateEdgeSources` live in `platformos-graph`; the +cache is a pure consumer, so the enumeration can no longer drift from the +classifier.)** Measured on `marketplace-dcra` (1,921 edge sources): the walk is **4.4×** faster (769 → 175 ms) and the full warm fingerprint (walk + `stat`) **1.7×** (541 → 320 @@ -820,8 +830,9 @@ the browser-safe `AbstractFileSystem` walk here) over a different domain (all fo source types vs edge-source liquid only); the scoped walk already captures the win without coupling the two. -**Tests.** `incremental.spec.ts` (equivalence guardrail), `deserialize.spec.ts` -(round-trip + a restored graph reconciles exactly like a full build), +**Tests.** `incremental.spec.ts` (equivalence guardrail) and `deserialize.spec.ts` +(round-trip + a restored graph reconciles exactly like a full build) — both over +an in-memory `MockFileSystem` (TASK-9.22.2, no disk) — `graph-cache-store.spec.ts` (encode/decode + version/root/corruption invalidation), `graph-cache.spec.ts` (incremental serve, diff kinds, apply- failure fallback, concurrent-reconcile coalescing, warm cold-start from disk, @@ -836,3 +847,98 @@ the actual `validate_code` path the fingerprint scan runs concurrently with lint multi-second parse, so it adds ≈0 wall-clock today — the watcher optimizes a cost that is not on the critical path, at the price of real platform-specific watcher complexity. + +--- + +## 11. Round-2 review remediation (post-submission) + +A second supervisor review of the branch flagged **division-of-responsibility** +and **test-hygiene** defects (the first round is §8). All were fixed. Where the +statements below supersede earlier prose, they are authoritative. Tracked as +**TASK-9.22** (umbrella: 9.22.1–9.22.4) and **TASK-9.17**. + +### 11.1 Platform-fact parsers moved to `platformos-common` (9.22.1) + +`extractGraphqlTables` and `extractSchemaTable` are neutral platform-fact parsers +with **no lint/offense use** — their only consumer is the graph. They moved from +`platformos-check-common` to **`platformos-common`**, beside the other +structure/resolution facts (frontmatter, RouteTable, DocumentsLocator). A +browser-safe `graphql` dep was added to common (`js-yaml` was already there); +check-common dropped the re-exports; the graph imports them from common. Lossless +move — check-common 1057 → 1034 tests, common +23. **This supersedes §8/F7**, +which had placed them in check-common. + +### 11.2 GraphQL table extraction returns ALL tables (9.22.4) + +`extractGraphqlTable` (scalar, first-wins) became **`extractGraphqlTables`**, +returning **every distinct** model table a document targets — across multiple +`records(...)` blocks, aliased queries, and `record_create`/mutation inputs — as +`string[]` in document order (empty for none / dynamic / unparseable). The graph +node field is now **`GraphQLModule.tables: string[]`** (required, always-present; +was `table?: string`). `SchemaModule.table` stays scalar (a schema declares one +`name:`). `tables` is **not serialized** (a re-derived leaf fact) and is read by +no supervisor/LSP consumer, so `validate_code` output is unchanged. **This +supersedes §6 doubt #4 and the §2.2 model shape.** + +### 11.3 Graph specs use an in-memory filesystem (9.22.2) + +`incremental.spec.ts` and `deserialize.spec.ts` no longer build a real project on +disk (`mkdtemp`/`NodeFileSystem`); they run over an in-memory `MockFileSystem` +whose backing object is mutated to simulate add/modify/delete. Every scenario and +the equivalence-to-full-build guarantee are preserved; the suite is faster with +no disk I/O. (Line-array string fixtures also became template literals.) + +### 11.4 `AppCache` placement documented (9.22.3) + +`AppCache` (parsed-project reuse for lint) stays in `platformos-check-node`: it +caches `getApp`'s output and `getApp` globs the real filesystem — a Node-only +concern. A JSDoc note records this and that **no pre-existing mechanism +duplicates it** (the LSP `DocumentManager` caches editor buffers; the supervisor +`GraphCache` caches the graph — different runtimes/payloads). No code moved. + +### 11.5 Edge-source enumeration owned by `platformos-graph` (9.17) + +The supervisor's `GraphCache` had hardcoded three pieces of graph-domain +knowledge — `SOURCE_ROOTS`, `isEdgeSource = isLayout || isPage || isPartial`, and +the scoped `enumerateEdgeSources` walk — that ADR 003 says belong in the graph. +That set does double duty: the **fingerprint domain** _and_ the build's **entry +points**. Nothing forced it to agree with the file-type classifier, so a new +source root / partial location added to the classifier would silently diverge → +the scan omits real edge sources → the graph builds with an **incomplete +entry-point set** → `dependentsOf` under-reports → a wrong _"0 dependents, safe to +change"_. That is a correctness bug from an incomplete domain model, not +staleness — the exact class the never-stale mandate exists to kill. + +**Fix.** `platformos-graph/src/graph/edge-sources.ts` now owns and exports +`enumerateEdgeSources(fs, rootUri)` and `isEdgeSource(uri)` (moved **verbatim**, +so the enumerated set is byte-identical on any project). `isEdgeSource` derives +entirely from the classifier (`isLayout`/`isPage`/`isPartial` ← `FILE_TYPE_DIRS`), +so "which files" has a single source of truth. The supervisor `GraphCache` is now +a **pure consumer** — it imports the primitive and still owns fingerprinting +(`fileFingerprint`), caching, persistence, and reconcile. + +**Guardrail.** `edge-sources.spec.ts` pins the scoped walk to the classifier: it +asserts the scoped enumeration equals a **whole-tree walk filtered by +`isEdgeSource`** over a fixture spanning every source root (`app/`, `app/lib`, +`marketplace_builder/`, top-level `modules/`, nested `app/modules/`) plus a +bundled `react-app/` sibling. Add a root to the classifier but not to +`SOURCE_ROOTS`, and the whole-tree walk finds files the scoped walk misses → the +test fails. It also pins `isEdgeSource === isLayout || isPage || isPartial` +directly. + +**Not shared with the full build (evaluated — AC of 9.17).** `buildAppGraph`'s +full-build discovery gathers a **different domain** — render _entry points_ +(pages + layouts only; partials are edge-reached) plus standalone schema nodes, +over the whole tree — versus this (page + layout + partial, scoped, because the +cache needs partials as entry points for **complete** dependents). They share the +`isLayout`/`isPage`/`isPartial` predicate (the part that must not drift); sharing +the walk itself would conflate two entry-point domains. + +### Verification (round 2) + +Builds + type-checks clean (common / check-common / graph / check-node / +supervisor). Suites: platformos-common **286**, check-common **1034**, graph +**105**, check-node `app-cache` **6**, supervisor **76** (blast-radius + +scoped-walk regression tests unchanged). `format:check` clean; +`yarn install --frozen-lockfile` clean with **zero `yarn.lock` churn**. `dist/` +regenerated for the touched packages. diff --git a/packages/platformos-check-docs-updater/data/filters.json b/packages/platformos-check-docs-updater/data/filters.json index a1e8504e..e943a308 100644 --- a/packages/platformos-check-docs-updater/data/filters.json +++ b/packages/platformos-check-docs-updater/data/filters.json @@ -2,6 +2,6 @@ -[{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ -3 | abs }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the absolute value of a number.","syntax":"number | abs","name":"abs"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | add_to_time","platformOS":true,"name":"add_to_time","aliases":[],"parameters":[{"description":"","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"","name":"number","required":false,"types":["Number"]},{"description":"time unit - allowed options are: y, years, mo, months, w, weeks, d [default], days, h, hours, m, minutes, s, seconds","name":"unit","required":false,"types":["String"]}],"return_type":[{"type":"time","name":"","description":"modified time","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'now' | add_to_time: 1, 'w' }} # =\u003e returns current time plus one week","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'now' | add_to_time: 3, 'mo' }} # =\u003e returns current time plus three months","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | advanced_format","platformOS":true,"name":"advanced_format","aliases":[],"parameters":[{"description":"object you want to format","name":"argument_to_format","required":false,"types":["Untyped"]},{"description":"should look like: %[flags][width][.precision]type. For more examples and information see: https://ruby-doc.org/core-2.5.1/Kernel.html#method-i-sprintf","name":"format","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"formatted string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 3.124 | advanced_format: '%.2f' }} =\u003e 3.12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 3 | advanced_format: '%.2f' }} =\u003e 3.00\nIn the example above flags is not present, width is not present (refers to the total final\nlength of the string), precision \".2\" means 2 digits after the decimal point,\ntype \"f\" means floating point","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Converts amount in given currency to fractional. For example, convert USD to cents.","summary":"returns ","syntax":"numeric | amount_to_fractional","platformOS":true,"name":"amount_to_fractional","aliases":[],"parameters":[{"description":"amount to be changed to fractional","name":"amount","required":false,"types":["Numeric","String"]},{"description":"currency to be used","name":"currency","required":false,"types":["String"]}],"return_type":[{"type":"number","name":"","description":"Amount in fractional, for example cents for USD","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10.50 | amount_to_fractional: 'USD' }} =\u003e 1050","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10.50 | amount_to_fractional: 'JPY' }} =\u003e 11","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{%- assign path = product.url -%}\n\n{{ request.origin | append: path }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Adds a given string to the end of a string.","syntax":"string | append: string","name":"append"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_add","platformOS":true,"name":"array_add","aliases":["add_to_array"],"parameters":[{"description":"array to which you add a new element","name":"array","required":false,"types":["Array"]},{"description":"item you add to the array","name":"item","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"array to which you add the item given as the second parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign array = 'a,b,c' | split: ',' %}\n{{ array | array_add: 'd' }} =\u003e ['a', 'b', 'c', 'd']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_any","platformOS":true,"name":"array_any","aliases":["any"],"parameters":[{"description":"array to search in","name":"array","required":false,"types":["Array"]},{"description":"String/Number compared to each item in the given array","name":"query","required":false,"types":["String","Number"]}],"return_type":[{"type":"boolean","name":"","description":"checks if given array contains at least one of the queried string/number","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign elements = 'foo,bar' | split: ',' %}\n{{ elements | array_any: 'foo' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Removes blank elements from the array","summary":"returns ","syntax":"array | array_compact","platformOS":true,"name":"array_compact","aliases":["compact"],"parameters":[{"description":"array with some blank values","name":"array","required":false,"types":["Array","Hash"]},{"description":"optionally if you provide Hash as argument, you can remove elements which given key is blank","name":"property","required":false,"types":["String"]}],"return_type":[{"type":"array","name":"","description":"array from which blank values are removed","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1,' | split: ',' | array_add: false | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1,' | split: ',' | array_add: null | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json empty_object %}{}{% endparse_json %}\n{{ '1,' | split: ',' | array_add: empty_object | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign empty_array = ',' | split: ',' %}\n{{ '1,' | split: ',' | array_add: empty_array | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json hash %}[{ \"hello\": null }, { \"hello\" =\u003e \"world\" }, { \"hello\" =\u003e \"\" }]{% endparse_json %}\n{{ hash | array_compact: \"hello\" }} =\u003e [{ \"hello\": \"world\" }]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_delete","platformOS":true,"name":"array_delete","aliases":[],"parameters":[{"description":"array to process","name":"array","required":false,"types":["Array"]},{"description":"value to remove","name":"element","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"the initial array that has all occurences of \"element\" removed","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign test = '[\"test\", \"test2\", \"test\", \"test3\"]' | parse_json %}\n{% assign arr = test | array_delete: 'test' %}\n{{ arr }} =\u003e ['test2', 'test3']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_delete_at","platformOS":true,"name":"array_delete_at","aliases":[],"parameters":[{"description":"array to process","name":"array","required":false,"types":["Array"]},{"description":"array index to remove","name":"index","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"the initial array that has the element at index removed","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign test = '[\"test\", \"test2\"]' | parse_json %}\n{% assign arr = test | array_delete_at: 1 %}\n{{ arr }} =\u003e ['test']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_detect","platformOS":true,"name":"array_detect","aliases":["detect"],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"untyped","name":"","description":"first object from the collection that matches the specified conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"}]\n{{ objects | array_detect: foo: 2 }} =\u003e {\"foo\":2,\"bar\":\"b\"}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_find_index","platformOS":true,"name":"array_find_index","aliases":[],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"with indices from collection that matches provided conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"},{\"foo\":2,\"bar\":\"d\"}]\n{{ objects | array_find_index: foo: 2 }} =\u003e [1, 3]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_flatten","platformOS":true,"name":"array_flatten","aliases":["flatten"],"parameters":[{"description":"array of arrays to be processed","name":"array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"with objects","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ array_of_arrays }} =\u003e [[1,2], [3,4], [5,6]]\n{{ array_of_arrays | array_flatten }} =\u003e [1,2,3,4,5,6]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Transforms array into hash, with keys equal to the values of object's method name and value being array containing objects","summary":"returns ","syntax":"array | array_group_by","platformOS":true,"name":"array_group_by","aliases":["group_by"],"parameters":[{"description":"array to be grouped","name":"objects","required":false,"types":["Array"]},{"description":"method name to be used to group Objects","name":"method_name","required":false,"types":["String"]}],"return_type":[{"type":"hash","name":"","description":"the original array grouped by method\nspecified by the second parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json objects %}\n [\n { \"size\": \"xl\", \"color\": \"red\"},\n { \"size\": \"xl\", \"color\": \"yellow\"},\n { \"size\": \"s\", \"color\": \"red\"}\n ]\n{% endparse_json %}\n\n{{ objects | array_group_by: 'size' }} =\u003e {\"xl\" =\u003e [{\"size\" =\u003e \"xl\", \"color\" =\u003e \"red\"}, {\"size\" =\u003e \"xl\", \"color\" =\u003e \"yellow\"}], \"s\" =\u003e [{\"size\" =\u003e \"s\", \"color\" =\u003e \"red\"}]}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Transforms array in array of arrays, each subarray containing exactly N elements","summary":"returns ","syntax":"array | array_in_groups_of","platformOS":true,"name":"array_in_groups_of","aliases":["in_groups_of"],"parameters":[{"description":"array to be split into groups","name":"array","required":false,"types":["Array"]},{"description":"the size of each group the array is to be split into","name":"number_of_elements","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"the original array split into groups of the size\nspecified by the second parameter (an array of arrays)","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign elements = '1,2,3,4' | split: ',' %}\n{{ elements | array_in_groups_of: 3 }} =\u003e [[1, 2, 3], [4, null, null]]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"Checks if array includes element","summary":"returns ","syntax":"array | array_include","platformOS":true,"name":"array_include","aliases":["is_included_in_array"],"parameters":[{"description":"array of elements to look into","name":"array","required":false,"types":["Array"]},{"description":"look for this element inside the array","name":"el","required":false,"types":["Untyped"]}],"return_type":[{"type":"boolean","name":"","description":"whether the array includes the element given","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign elements = 'a,b,c,d' | split: ',' %}\n{{ elements | array_include: 'c' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Finds index of an object in the array","summary":"returns ","syntax":"array | array_index_of","platformOS":true,"name":"array_index_of","aliases":[],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"object to search for","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"","name":"","description":"Integer position of object in array if found or nil otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [1,'abc',3]\n{{ objects | array_index_of: 'abc' }} =\u003e 1","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_intersect","platformOS":true,"name":"array_intersect","aliases":["intersection"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"array of objects to be processed","name":"other_array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"that exists in both arrays","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign array = '1,2,3,4' | split: ','\n assign other_array = '3,4,5,6' | split: ','\n%}\n\n{{ array | array_intersect: other_array }} =\u003e [3,4]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_limit","platformOS":true,"name":"array_limit","aliases":["limit"],"parameters":[{"description":"array to shrink","name":"array","required":false,"types":["Array"]},{"description":"number of elements to be returned","name":"limit","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"parameter; [1,2,3,4] limited to 2 elements gives [1,2]","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"items =\u003e [{ id: 1, name: 'foo', label: 'Foo' }, { id: 2, name: 'bar', label: 'Bar' }]\n{{ items | array_limit: 1 }} =\u003e [{ id: 1, name: 'foo', label: 'Foo' }]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_map","platformOS":true,"name":"array_map","aliases":["map_attributes"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"array of keys to be extracted","name":"attributes","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"array of arrays with values for given keys","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ items }} =\u003e [{ id: 1, name: 'foo', label: 'Foo' }, { id: 2, name: 'bar', label: 'Bar' }]\n{{ items | array_map: 'id', 'name' }} =\u003e [[1, 'foo'], [2, 'bar']]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_prepend","platformOS":true,"name":"array_prepend","aliases":["prepend_to_array"],"parameters":[{"description":"array to which you prepend a new element","name":"array","required":false,"types":["Array"]},{"description":"item you prepend to the array","name":"item","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"array to which you prepend the item given as the second parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign array = 'a,b,c' | split: ',' %}\n{{ array | array_prepend: 'd' }} =\u003e ['d', 'a', 'b', 'c']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_reject","platformOS":true,"name":"array_reject","aliases":["reject"],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"with objects from collection that don't match provided conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"},{\"foo\":2,\"bar\":\"d\"}]\n{{ objects | array_reject: foo: 2 }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":3,\"bar\":\"c\"}]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_rotate","platformOS":true,"name":"array_rotate","aliases":["rotate"],"parameters":[{"description":"array to be rotated","name":"array","required":false,"types":["Array"]},{"description":"number of times to rotate the input array","name":"count","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"the input array rotated by a number of times given as the second\nparameter; [1,2,3,4] rotated by 2 gives [3,4,1,2]","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign numbers = \"1,2,3\" | split: \",\" %}\n{{ numbers | array_rotate }} =\u003e [2,3,1]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_select","platformOS":true,"name":"array_select","aliases":["select"],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"with objects from collection that matches provided conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"},{\"foo\":2,\"bar\":\"d\"}]\n{{ objects | array_select: foo: 2 }} =\u003e [{\"foo\":2,\"bar\":\"b\"},{\"foo\":2,\"bar\":\"d\"}]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_shuffle","platformOS":true,"name":"array_shuffle","aliases":["shuffle_array"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"array with shuffled items","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ items }} =\u003e [1, 2, 3, 4]\n{{ items | array_shuffle }} =\u003e [3, 2, 4, 1]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_sort_by","platformOS":true,"name":"array_sort_by","aliases":["sort_by"],"parameters":[{"description":"Array of Hash to be sorted by a key","name":"input","required":false,"types":["Array"]},{"description":"property by which to sort an Array of Hashes","name":"property","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"Sorted object (Array of Hash)","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"array1 is [{\"title\": \"Tester\", \"value\": 1}, {\"title\": \"And\", \"value\": 2}]\n{{ array1 | array_sort_by: \"title\" }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_subtract","platformOS":true,"name":"array_subtract","aliases":["subtract_array"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"array of objects to be processed","name":"other_array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"that is a difference between two arrays","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign array = '1,2' | split: ','\n assign other_array = '2' | split: ','\n%}\n\n{{ array | array_subtract: other_array }} =\u003e [1]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_sum","platformOS":true,"name":"array_sum","aliases":["sum_array"],"parameters":[{"description":"array with values to be summarised","name":"array","required":false,"types":["Array"]}],"return_type":[{"type":"number","name":"","description":"summarised value of array","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign numbers = '[1,2,3]' | parse_json %}\n{{ numbers | array_sum }} =\u003e 6","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Removes duplicate elements from an array","summary":"returns ","syntax":"array | array_uniq","platformOS":true,"name":"array_uniq","aliases":[],"parameters":[{"description":"array to be processed","name":"input","required":false,"types":["Array"]},{"description":"String property to be used as the comparison key\n{% assign result = \"[[1,2],[1,2],[3,4]]\" | array_uniq %}\n{{ result }} =\u003e \"[[1,2],[3,4]]\"","name":"property","required":false,"types":null}],"return_type":[{"type":"array","name":"","description":"Returns an array with duplicate elements removed","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"Generates relative path to an asset, including `updated` query parameter.\nThe `/assets/` prefix is rewritten to the CDN origin by the reverse proxy at request time,\nso the rendered HTML stays portable across environments (custom domains, previews, on-prem).\nAlways prefer `asset_url`, which points clients straight at the CDN — `asset_path` forces\nevery request through the regional load balancer and reverse proxy before the CDN is reached,\nwhich defeats most of the CDN's benefit.","summary":"returns ","syntax":"string | asset_path","platformOS":true,"name":"asset_path","aliases":[],"parameters":[{"description":"path to the asset, relative to assets directory","name":"file_path","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"relative path to the physical file decorated with updated param to invalidate CDN cache.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ \"valid/file.jpg\" | asset_path }} =\u003e /assets/valid/file.jpg?updated=1565632488","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Generates CDN url to an asset","summary":"returns ","syntax":"string | asset_url","platformOS":true,"name":"asset_url","aliases":[],"parameters":[{"description":"path to the asset, relative to assets directory","name":"file_path","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"CDN URL with the instance's asset cache buster appended.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ \"valid/file.jpg\" | asset_url }} =\u003e https://cdn-server.com/instances/1/assets/valid/file.jpg?updated=1565632488","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 4 | at_least: 5 }}\n{{ 4 | at_least: 3 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Limits a number to a minimum value.","syntax":"number | at_least","name":"at_least"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 6 | at_most: 5 }}\n{{ 4 | at_most: 5 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Limits a number to a maximum value.","syntax":"number | at_most","name":"at_most"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | base64_decode","platformOS":true,"name":"base64_decode","aliases":[],"parameters":[{"description":"Base64 encoded string","name":"base64_string","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"decoded string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'aGVsbG8gYmFzZTY0\\n' | base64_decode }} =\u003e 'hello base64'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | base64_encode","platformOS":true,"name":"base64_encode","aliases":[],"parameters":[{"description":"string to be encoded","name":"bin","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Returns the Base64-encoded version of bin. This method complies with RFC 2045. Line feeds are added to every 60 encoded characters.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'hello base64' | base64_encode }} =\u003e 'aGVsbG8gYmFzZTY0'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 'this sentence should start with a capitalized word.' | capitalize }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Capitalizes the first word in a string and downcases the remaining characters.","syntax":"string | capitalize","name":"capitalize"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 1.2 | ceil }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Rounds a number up to the nearest integer.","syntax":"number | ceil","name":"ceil"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | compute_hmac","platformOS":true,"name":"compute_hmac","aliases":[],"parameters":[{"description":"message to be authenticated","name":"data","required":false,"types":["String"]},{"description":"secret key","name":"secret","required":false,"types":["String"]},{"description":"defaults to SHA256. Supported algorithms are:\nSHA, SHA1, SHA224, SHA256, SHA384, SHA512, MD4, MDC2, MD5, RIPEMD160, DSS1.","name":"algorithm","required":false,"types":["String"]},{"description":"defaults to hex. Supported digest values are hex, none, base64","name":"digest","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Keyed-hash message authentication code (HMAC), that can\nbe used to authenticate requests from third\nparty apps, e.g. Stripe webhooks requests","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'some_data' | compute_hmac: 'some_secret', 'MD4' }} =\u003e 'cabff538af5f97ccc27d481942616492'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"\u003e Note:\n\u003e The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the\n\u003e [`uniq` filter](/docs/api/liquid/filters/uniq).","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{%- assign types_and_vendors = collection.all_types | concat: collection.all_vendors -%}\n\nTypes and vendors:\n\n{% for item in types_and_vendors -%}\n {%- if item != blank -%}\n - {{ item }}\n {%- endif -%}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Concatenates (combines) two arrays.","syntax":"array | concat: array","name":"concat"},{"category":"format","deprecated":false,"deprecation_reason":"","description":"The `date` filter accepts the same parameters as Ruby's strftime method for formatting the date. For a list of shorthand\nformats, refer to the [Ruby documentation](https://ruby-doc.org/core-3.1.1/Time.html#method-i-strftime) or\n[strftime reference and sandbox](http://www.strfti.me/).","parameters":[{"description":"The desired date format.","name":"format","required":false,"types":["string"]}],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.created_at | date: '%B %d, %Y' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"The current date","description":"You can apply the `date` filter to the keywords `'now'` and `'today'` to output the current timestamp.\n\n\u003e Note:\n\u003e The timestamp will reflect the time that the Liquid was last rendered. Because of this, the timestamp might not be updated for every page view, depending on the context and caching.\n","syntax":"","path":"/","raw_liquid":"{{ 'now' | date: '%B %d, %Y' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"format","description":"Specify a locale-aware date format. You can use the following formats:\n\n- `abbreviated_date`\n- `basic`\n- `date`\n- `date_at_time`\n- `default`\n- `on_date`\n- `short` (deprecated)\n- `long` (deprecated)\n\n\u003e Note:\n\u003e You can also [define custom formats](/docs/api/liquid/filters/date-setting-format-options-in-locale-files) in your theme's locale files.\n","syntax":"string | date: format: string","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.created_at | date: format: 'abbreviated_date' }}","parameter":true,"display_type":"text","show_data_tab":true},{"name":"Setting format options in locale files","description":"You can define custom date formats in your [theme's storefront locale files](/themes/architecture/locales/storefront-locale-files). These custom formats should be included in a `date_formats` category:\n\n```json\n\"date_formats\": {\n \"month_day_year\": \"%B %d, %Y\"\n}\n```\n","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.created_at | date: format: 'month_day_year' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Converts a timestamp into another date format.","syntax":"string | date: string","name":"date"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | date_add","platformOS":true,"name":"date_add","aliases":["add_to_date"],"parameters":[{"description":"","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"","name":"number","required":false,"types":["Number"]},{"description":"time unit - allowed options are: y, years, mo, months, w, weeks, d [default], days, h, hours, m, minutes, s, seconds","name":"unit","required":false,"types":["String"]}],"return_type":[{"type":"date","name":"","description":"modified Date","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01' | date_add: 1 }} =\u003e 2010-01-02\n{{ '2010-01-01' | date_add: 1, 'mo' }} =\u003e 2010-02-01","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Filter allowing to decrypt data encrypted with a specified algorithm. See encrypt filter for encryption.","summary":"returns ","syntax":"string | decrypt","platformOS":true,"name":"decrypt","aliases":[],"parameters":[{"description":"string payload to be decrypted - must be a Base64 encoded (RFC 4648) or HEX (if from_hex flag true) string","name":"payload","required":false,"types":["String"]},{"description":"algorithm you want to use for encryption","name":"algorithm","required":false,"types":["String"]},{"description":"a key used for encryption. Key must match the algorithm requirments. For asymmetric algorithms there are public and private keys that need to passed in PEM format.","name":"key","required":false,"types":["String"]},{"description":"- a Hash with additional options to control the decryption algorithm","name":"options","required":false,"types":null}],"return_type":[{"type":"string","name":"","description":"String - decrypted string using the algorithm of your choice. Initialization Vector (iv) is expected to be present in the encrypted payload at the beginning.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ some_payload | decrypt: 'aes-256-cbc', 'ThisPasswordIsReallyHardToGuessA' }} =\u003e decrypted string from payload\n{{ \"43553EEDD9BFE36D10F99E931245CF8826903C00D235DFD300B3CC40BD263A621FC2FB9F5C3743F75D399A912AFABF92371927C6D190E0EFF19EAE9802320391FED79D92009796403EC6B426E901AB981CE53A43557C295F3D6FC9678EE0557F\" | decrypt: 'aes-128-ecb', '4FCD5FAE2AC493C0F8CE8E1E6105D194', true, true, true }} =\u003e iframe=true;customer.firstName=John;customer.lastName=Smith;header.accountNumber=6759370;header.authToken1=12345;header.paymentTypeCode=UTILITY;header.amount=123.45\n\nAES-256-GCM (authenticated decryption, payload format: IV + ciphertext + auth_tag):\n{% assign key = 'ThisIsA32ByteKeyForAES256GCM!!!!' %}\n{% assign encrypted = 'foo bar' | encrypt: 'aes-256-gcm', key %}\n{{ encrypted | decrypt: 'aes-256-gcm', key }} =\u003e foo bar\n\nAES-128-GCM:\n{% assign encrypted = 'secret data' | encrypt: 'aes-128-gcm', '16ByteSecretKey!' %}\n{{ encrypted | decrypt: 'aes-128-gcm', '16ByteSecretKey!' }} =\u003e secret data\n\nchacha20-poly1305 (authenticated decryption):\n{% assign encrypted = 'baz qux' | encrypt: 'chacha20-poly1305', '32ByteKeyForChaCha20Poly1305!!!!' %}\n{{ encrypted | decrypt: 'chacha20-poly1305', '32ByteKeyForChaCha20Poly1305!!!!' }} =\u003e baz qux\n\nAES-256-GCM with hex key:\n{% assign decrypted = encrypted | decrypt: 'aes-256-gcm', hex_key, transform_key_to_hex: true, from_hex: true %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | deep_clone","platformOS":true,"name":"deep_clone","aliases":[],"parameters":[{"description":"object to be duplicated","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"untyped","name":"","description":"returns a copy of the object parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign some_hash_copy = some_hash | deep_clone %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"default","deprecated":false,"deprecation_reason":"","description":"","parameters":[{"description":"Whether to use false values instead of the default.","name":"allow_false","required":false,"types":["boolean"]}],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.selected_variant.url | default: product.url }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"allow_false","description":"By default, the `default` filter's value will be used in place of `false` values. You can use the `allow_false` parameter to allow variables to return `false` instead of the default value.\n","syntax":"variable | default: variable, allow_false: boolean","path":"/products/health-potion","raw_liquid":"{%- assign display_price = false -%}\n\n{{ display_price | default: true, allow_false: true }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Sets a default value for any variable whose value is one of the following:\n\n- [`empty`](/docs/api/liquid/basics#empty)\n- [`false`](/docs/api/liquid/basics#truthy-and-falsy)\n- [`nil`](/docs/api/liquid/basics#nil)","syntax":"variable | default: variable","name":"default"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | digest","platformOS":true,"name":"digest","aliases":[],"parameters":[{"description":"message that you want to obtain a cryptographic hash for","name":"object","required":false,"types":["String"]},{"description":"the hash algorithm to use. Choose from: 'md5', 'sha1', 'sha256', 'sha384', 'sha512'. Default is sha1.","name":"algorithm","required":false,"types":["String"]},{"description":"defaults to hex. Supported digest values are hex, none, base64","name":"digest","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"hexadecimal hash value obtained by applying the selected algorithm to the message","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | digest }} =\u003e '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'\n{{ 'foo' | digest: 'sha256' }} =\u003e '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'\n{{ 'foo' | digest: 'sha256', 'base64' }} =\u003e 'LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564='\n{{ 'foo' | digest: 'sha256', 'none' }} =\u003e ',\u0026\\xB4kh\\xFF\\xC6\\x8F\\xF9\\x9BE\u003c\\x1D0A4\\x13B-pd\\x83\\xBF\\xA0\\xF9\\x8A^\\x88bf\\xE7\\xAE'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 4 | divided_by: 2 }}\n\n# divisor is an integer\n{{ 20 | divided_by: 7 }}\n\n# divisor is a float \n{{ 20 | divided_by: 7.0 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float.","syntax":"number | divided_by: number","name":"divided_by"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.title | downcase }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts a string to all lowercase characters.","syntax":"string | downcase","name":"downcase"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | download_file","platformOS":true,"name":"download_file","aliases":[],"parameters":[{"description":"url to a remote file","name":"url","required":false,"types":["String"]},{"description":"max file size of the file, default 1 megabyte. Can't exceed 50 megabytes.","name":"max_size","required":false,"types":["Number"]}],"return_type":[{"type":"string","name":"","description":"Body of the remote file","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'http://www.example.com/my_file.txt' | download_file }} =\u003e \"Content of a file\"\n{% assign data = 'https://example.com/data.json' | download_file | parse_json %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"The filter returns a string with the encoding changed to the one specified by the 'destination_encoding' parameter. The filter changes the string itself (its representation in memory) by first determining which graphical characters the underlying bytes in the string represent in the 'source_encoding', and then changing the bytes to encode the same graphical characters in 'destination_encoding'.","summary":"returns ","syntax":"string | encode","platformOS":true,"name":"encode","aliases":[],"parameters":[{"description":"input string that we want to reencode","name":"text","required":false,"types":["String"]},{"description":"the encoding of the source string text","name":"source_encoding","required":false,"types":["String"]},{"description":"the encoding we want the source string text converted to","name":"destination_encoding","required":false,"types":["String"]},{"description":"if set to 'replace', invalid byte sequences for the source_encoding will be replaced with the replace parameter","name":"invalid","required":false,"types":["String"]},{"description":"if set to 'replace', characters which do not exist in the destination encoding will be replaced by the 'replace' parameter","name":"undefined","required":false,"types":["String"]},{"description":"used together with the invalid and undefined parameters","name":"replace","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"input string with encoding modified from source_encoding to destination_encoding","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | encode: \"ISO-8859-1\", 'UTF-8', invalid: 'replace', undefined: 'replace', replace: '??' }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"The filter returns the encoding of the string parameter (i.e. how the underlying bytes in the string are interpreted to determine which graphical characters they encode).","summary":"returns ","syntax":"string | encoding","platformOS":true,"name":"encoding","aliases":[],"parameters":[{"description":"input string whose encoding we want to find","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"encoding of the string text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | encoding }} =\u003e 'UTF-8'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Filter allowing to encrypt data with specified algorithm. See decrypt filter for decryption","summary":"returns ","syntax":"string | encrypt","platformOS":true,"name":"encrypt","aliases":[],"parameters":[{"description":"string payload to be encrypted","name":"payload","required":false,"types":["String"]},{"description":"algorithm you want to use for encryption","name":"algorithm","required":false,"types":["String"]},{"description":"a key used for encryption. Key must match the algorithm requirments. For asymmetric algorithms there are public and private keys that need to passed in PEM format.","name":"key","required":false,"types":["String"]},{"description":"- initialization vector, if not provided we will automatically generate one","name":"iv","required":false,"types":["optional"]},{"description":"- a Hash with additional options to control the encrypt algorithm","name":"options","required":false,"types":null}],"return_type":[{"type":"string","name":"","description":"Base64 encoded (RFC 4648) (or HEX if return_hex is true) encrypted string using the algorithm of your choice. Initialization Vector (iv) will be appended","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture payload %}\n {\n \"key\": \"value\",\n \"another_key\": \"another value\"\n }\n{% endcapture %}\n{{ payload | encrypt: 'aes-256-cbc', 'ThisPasswordIsReallyHardToGuessA' }} =\u003e Kkuo2eWEnTbcrtbGjAmQVMTjptS5elsgqQe-5blHpUR-ziHPI45n2wOnY30DVZGldCTNqMT_Ml0ZFiGiupKGD4ZWxVIMkdCHaq4XgiAIUew=\n{{ \"step=2;header.amount=10;header.paymentTypeCode=ONLINE;customer.firstName=John\" | encrypt: 'aes-128-ecb', '4C6C821832AAFFF2749852CEED2FE74F', null, true, true, 32 }} =\u003e 43553EEDD9BFE36D10F99E931245CF8826903C00D235DFD300B3CC40BD263A621FC2FB9F5C3743F75D399A912AFABF92371927C6D190E0EFF19EAE9802320391FED79D92009796403EC6B426E901AB981CE53A43557C295F3D6FC9678EE0557F\n\nAES-256-GCM (authenticated encryption):\n{% assign key = 'ThisIsA32ByteKeyForAES256GCM!!!!' %}\n{% assign encrypted = 'foo bar' | encrypt: 'aes-256-gcm', key %}\n{{ encrypted }} =\u003e Base64 encoded string containing IV + ciphertext + auth_tag\n\nAES-128-GCM:\n{% assign encrypted = 'secret data' | encrypt: 'aes-128-gcm', '16ByteSecretKey!' %}\n\nchacha20-poly1305 (authenticated encryption):\n{% assign encrypted = 'sensitive payload' | encrypt: 'chacha20-poly1305', '32ByteKeyForChaCha20Poly1305!!!!' %}\n\nAES-256-GCM with hex key:\n{% assign encrypted = 'foo bar' | encrypt: 'aes-256-gcm', hex_key, null, transform_key_to_hex: true, return_hex: true %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Check if string ends with given substring(s)","summary":"returns ","syntax":"string | end_with","platformOS":true,"name":"end_with","aliases":[],"parameters":[{"description":"string to check ends with any of the provided suffixes","name":"string","required":false,"types":["String"]},{"description":"suffix to check","name":"suffixes","required":false,"types":["String","Array"]}],"return_type":[{"type":"boolean","name":"","description":"true if string ends with a suffixes","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | end_with: 'example' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | end_with: 'my' } =\u003e false","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign suffixes = '[\"array\", \"example\"]' | parse_json %}\n{{ 'my_example' | end_with: suffixes } =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ '\u003cp\u003eText to be escaped.\u003c/p\u003e' | escape }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Escapes special characters in HTML, such as `\u003c\u003e`, `'`, and `\u0026`, and converts characters into escape sequences. The filter doesn't effect characters within the string that don’t have a corresponding escape sequence.\".","syntax":"string | escape","name":"escape"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | escape_javascript","platformOS":true,"name":"escape_javascript","aliases":[],"parameters":[{"description":"text to be escaped","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"escaped text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture js %}\nvar msg = 'hello world';\nfunction yell(x) {\n if (!x) { return; }\n return x + \"!!!\";\n}\nyell(msg).\n{% endcapture %}\n\n{{ js | escape_javascript }}\n=\u003e \\nvar msg = \\'hello world\\';\\nfunction yell(x) {\\n if (!x) { return; }\\n return x + \\\"!!!\\\";\\n}\\nyell(msg).\\n","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Creates url based on provided named parameters to the template","summary":"returns ","syntax":"string | expand_url_template","platformOS":true,"name":"expand_url_template","aliases":[],"parameters":[{"description":"URL template. Read more at https://tools.ietf.org/html/rfc6570","name":"template","required":false,"types":["String"]},{"description":"hash with data injected into template","name":"params","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"expanded URL","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search/{city}/{street}\" %}\n{{ template | expand_url_template: city: \"Sydney\", street: \"BlueRoad\" }}\n=\u003e /search/Sydney/BlueRoad","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search{?city,street}\" %}\n{{ template | expand_url_template: city: \"Sydney\", street: \"BlueRoad\" }}\n=\u003e /search?city=Sydney\u0026street=BlueRoad","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Extracts named parameters from the url","summary":"returns ","syntax":"string | extract_url_params","platformOS":true,"name":"extract_url_params","aliases":[],"parameters":[{"description":"URL with params to extract","name":"url","required":false,"types":["String"]},{"description":"URL template, works also with array of templates. Read more at https://tools.ietf.org/html/rfc6570","name":"templates","required":false,"types":["String","Array"]}],"return_type":[{"type":"hash","name":"","description":"hash with extracted params","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search/{city}/{street}\" %}\n{{ \"/search/Sydney/BlueRoad\" | extract_url_params: template }} =\u003e {\"city\":\"Sydney\",\"street\":\"BlueRoad\"}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/{first}/{-list|\\/|second}\" %}\n{{ \"/a/b/c/\" | extract_url_params: template }} =\u003e {\"first\":\"a\",\"second\":[\"b\", \"c\"]}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/{first}/{second}{?limit,offset}\" %}\n{{ \"/my/path?limit=10\u0026offset=0\" | extract_url_params: template }} =\u003e {\"first\":\"my\",\"second\":\"path\",\"limit\":\"10\",\"offset\":\"0\"}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search/-list|+|query\" %}\n{{ \"/search/this+is+my+query\" | extract_url_params: template }} =\u003e {\"query\":[\"this\",\"is\",\"my\",\"query\"]}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"{+location}/listings\" %}\n{{ \"/Warsaw/Poland/listings\" | extract_url_params: template }} =\u003e {\"location\":\"/Warsaw/Poland\"}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{%- assign first_product = collection.products | first -%}\n\n{{ first_product.title }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Dot notation","description":"You can use the `first` filter with dot notation when you need to use it inside a tag or object output.\n","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.products.first.title }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the first item in an array.","syntax":"array | first","name":"first"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 1.2 | floor }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Rounds a number down to the nearest integer.","syntax":"number | floor","name":"floor"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"The filter returns a string with the encoding changed to the one specified by the 'encoding' parameter. The filter does not change the string itself (its representation in memory) but changes how the underlying bytes in the string are interpreted to determine which characters they encode","summary":"returns ","syntax":"string | force_encoding","platformOS":true,"name":"force_encoding","aliases":[],"parameters":[{"description":"input string whose encoding we want modified","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"input string with encoding modified to the one specified by the encoding parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | force_encoding: \"ISO-8859-1\" }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | format_number","platformOS":true,"name":"format_number","aliases":[],"parameters":[{"description":"string (numberlike), integer or float to format","name":"number","required":false,"types":["Untyped"]},{"description":"formatting options","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"formatted number","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 111.2345 | format_number }} # =\u003e 111.235","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 111.2345 | format_number: precision: 2 }} # =\u003e 111.23","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 111 | format_number: precision: 2 }} # =\u003e 111.00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1111.2345 | format_number: precision: 2, separator: ',', delimiter: '.' }} # =\u003e 1.111,23","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Converts currency in fractional to whole amount. For example, convert cents to USD.","summary":"returns ","syntax":"integer | fractional_to_amount","platformOS":true,"name":"fractional_to_amount","aliases":[],"parameters":[{"description":"fractional amount","name":"amount","required":false,"types":["Integer","String"]},{"description":"currency to be used","name":"currency","required":false,"types":["String"]}],"return_type":[{"type":"number","name":"","description":"converted fractional amount","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10.50 | fractional_to_amount: 'USD' }} =\u003e 10","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1050 | fractional_to_amount: 'JPY' }} =\u003e 1050","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Gzip compress a string","summary":"returns ","syntax":"string | gzip_compress","platformOS":true,"name":"gzip_compress","aliases":[],"parameters":[{"description":"string to be compressed\n{% assign result = \"Lorem ipsum\" | gzip_compress | gzip_decompress %}\n{{ result }} =\u003e \"Lorem ipsum\"","name":"uncompressed_string","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Gzip-compressed string","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Gzip decompress a string","summary":"returns ","syntax":"string | gzip_decompress","platformOS":true,"name":"gzip_decompress","aliases":[],"parameters":[{"description":"string to be decompressed\n{% assign result = \"Lorem ipsum\" | gzip_compress | gzip_decompress %}\n{{ result }} =\u003e \"Lorem ipsum\"","name":"compressed_string","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Decompressed string","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_add_key","platformOS":true,"name":"hash_add_key","aliases":["add_hash_key","assign_to_hash_key"],"parameters":[{"description":"","name":"hash","required":false,"types":["Hash"]},{"description":"","name":"key","required":false,"types":["String"]},{"description":"","name":"value","required":false,"types":["Untyped"]}],"return_type":[{"type":"hash","name":"","description":"hash with added key","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign accountants = \"Angela,Kevin,Oscar\" | split: \",\"\n assign management = \"David,Jan,Michael\" | split: \",\"\n assign company = {}\n assign company = company | hash_add_key: \"name\", \"Dunder Mifflin\"\n assign company = company | hash_add_key: \"accountants\", accountants\n assign company = company | hash_add_key: \"management\", management\n%}\n{{ company }} =\u003e {\"name\" =\u003e \"Dunder Mifflin\", \"accountants\" =\u003e [\"Angela\", \"Kevin\", \"Oscar\"], \"management\" =\u003e [\"David\", \"Jan\", \"Michael\"]}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_delete_key","platformOS":true,"name":"hash_delete_key","aliases":["delete_hash_key","remove_hash_key"],"parameters":[{"description":"","name":"hash","required":false,"types":["Hash"]},{"description":"","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"untyped","name":"","description":"value which was assigned to a deleted key. If the key did not exist in the first place, null is returned.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign hash = '{ \"a\": \"1\", \"b\": \"2\"}' | parse_json\n assign a_value = hash | hash_delete_key: \"a\"\n%}\n{{ a_value }} =\u003e \"1\"\n{{ hash }} =\u003e { \"b\": \"2\" }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Generates a list of additions (+), deletions (-) and changes (~) from given two ojects.","summary":"returns ","syntax":"variable | hash_diff","platformOS":true,"name":"hash_diff","aliases":[],"parameters":[{"description":"","name":"hash1","required":false,"types":["Hash"]},{"description":"","name":"hash2","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"array containg the difference between two hashes","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign a = '{ \"a\": 1, \"c\": \"5 \", \"d\": 6, \"e\": { \"f\": 5.12 } }' | parse_json\n assign b = '{ \"b\": 2, \"c\": \"5\", \"d\": 5, \"e\": { \"f\": 5.13 } }' | parse_json\n%}\n{{ a | hash_diff: b }} =\u003e [[\"-\",\"a\",1],[\"~\",\"c\",\"5 \",\"5\"],[\"~\",\"d\",6,5],[\"~\",\"e.f\",5.12,5.13],[\"+\",\"b\",2]]\n{{ a | hash_diff: b, strip: true }} =\u003e [[\"-\",\"a\",1],[\"~\",\"d\",6,5],[\"~\",\"e.f\",5.12,5.13],[\"+\",\"b\",2]]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_dig","platformOS":true,"name":"hash_dig","aliases":["dig"],"parameters":[{"description":"","name":"hash","required":false,"types":["Hash"]},{"description":"comma separated sequence of string keys to dig down the hash","name":"keys","required":false,"types":["Array"]}],"return_type":[{"type":"untyped","name":"","description":"Extracted nested value specified by the sequence of keys by calling dig at each step,\nreturning null if any intermediate step is null.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json user_json %}\n{\n \"name\": {\n \"first\": \"John\"\n \"last\": \"Doe\"\n }\n}\n{% endparse_json %}\n{{ user_json | hash_dig: \"name\", \"first\" }} =\u003e \"John\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_except","platformOS":true,"name":"hash_except","aliases":[],"parameters":[{"description":"input hash","name":"hash","required":false,"types":["Hash"]},{"description":"array of keys that should be removed","name":"except","required":false,"types":["Array"]}],"return_type":[{"type":"hash","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign data = null | hash_merge: foo: 'fooval', bar: 'barval', baz: 'bazval'\n%}\n{% assign exc = 'foo,baz' | split: ',' %}\n{{ data | except: exc }} =\u003e { \"foo\": \"fooval\" }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"","summary":"returns ","syntax":"variable | hash_fetch","platformOS":true,"name":"hash_fetch","aliases":["fetch"],"parameters":[{"description":"input hash to be traversed","name":"hash","required":false,"types":["Hash"]},{"description":"key to be fetched from hash branch","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json users %}\n[{\n \"name\": \"Jane\"\n}, {\n \"name\": \"Bob\"\n}]\n{% endparse_json %}\n{{ users | first | hash_fetch: \"name\" }} =\u003e \"Jane\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_keys","platformOS":true,"name":"hash_keys","aliases":[],"parameters":[{"description":"input hash","name":"hash","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign data = null | hash_merge: foo: 'fooval', bar: 'barval'\n%}\n\n{{ data | hash_keys }} =\u003e [\"foo\", \"bar\"]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_merge","platformOS":true,"name":"hash_merge","aliases":[],"parameters":[{"description":"","name":"hash1","required":false,"types":["Hash"]},{"description":"","name":"hash2","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"new hash containing the contents of hash1 and the contents of hash2.\nOn duplicated keys we keep value from hash2","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign a = '{\"a\": 1, \"b\": 2 }' | parse_json\n assign b = '{\"b\": 3, \"c\": 4 }' | parse_json\n assign new_hash = a | hash_merge: b\n%}\n{{ new_hash }} =\u003e { \"a\": 1, \"b\": 3, \"c\": 4 }","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign a = '{\"a\": 1}' | parse_json\n assing a = a | hash_merge: b: 2, c: 3\n %}\n{{ a }} =\u003e { \"a\": 1, \"b\": 2, \"c\": 3 }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_sort","platformOS":true,"name":"hash_sort","aliases":[],"parameters":[{"description":"Hash to be sorted","name":"input","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"Sorted hash","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign hash1 = '{\"key2\": \"value2\", \"key1\": \"value1\"}' | parse_json %}\n{{ hash1 | hash_sort }} =\u003e {\"key1\": \"value1\", \"key2\": \"value2\"}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign hash1 = '{\"a\": 1, \"c\": 2, \"b\": 3}' | parse_json %}\n{{ hash1 | hash_sort }} =\u003e {\"a\": 1, \"b\": 3, \"c\": 2}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_values","platformOS":true,"name":"hash_values","aliases":[],"parameters":[{"description":"input hash","name":"hash","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign data = null | hash_merge: foo: 'fooval', bar: 'barval'\n%}\n\n{{ data | hash_values }} =\u003e [\"fooval\", \"barval\"]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hcaptcha","platformOS":true,"name":"hcaptcha","aliases":[],"parameters":[{"description":"params sent to the server","name":"params","required":false,"types":["Hash"]}],"return_type":[{"type":"boolean","name":"","description":"whether the parameters are valid hcaptcha verification parameters","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ context.params | hcaptcha }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | html_safe","platformOS":true,"name":"html_safe","aliases":[],"parameters":[{"description":"","name":"text","required":false,"types":["String"]},{"description":"set raw_text to true to stop it from unescaping HTML entities","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"string that can be rendered with all HTML tags, by default all variables are escaped.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003ch1\u003eHello\u003c/h1\u003e' }} =\u003e '\u0026lt;h1\u0026gt;Hello\u0026lt;/h1\u003e\u0026gt;'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003ch1\u003eHello\u003c/h1\u003e' | html_safe }} =\u003e '\u003ch1\u003eHello\u003c/h1\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003cscript\u003ealert(\"Hello\")\u003c/script\u003e' }} =\u003e \u003cscript\u003ealert(\"Hello\")\u003c/script\u003e - this will just print text in the source code of the page","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003cscript\u003ealert(\"Hello\")\u003c/script\u003e' | html_safe }} =\u003e \u003cscript\u003ealert(\"Hello\")\u003c/script\u003e - this script will be evaluated when a user enters the page","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'abc \u0026quot; def' | html_safe: raw_text: true }} =\u003e 'abc \u0026quot; def'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'abc \u0026quot; def' | html_safe: raw_text: false }} =\u003e 'abc \" def'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | html_to_text","platformOS":true,"name":"html_to_text","aliases":[],"parameters":[{"description":"html to be converted to text","name":"html","required":false,"types":["String"]},{"description":"optional. Default root_element: null, remove_nodes: 'script, link, style', replace_newline: ' '","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"text without any html tags","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003ch1\u003eHello \u003ca href=\"#\"\u003eworld\u003c/a\u003e\u003c/h1\u003e' }} =\u003e 'Hello world'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | humanize","platformOS":true,"name":"humanize","aliases":[],"parameters":[{"description":"input string to be transformed","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"a human readable string derived from the input; capitalizes the first word, turns\nunderscores into spaces, and strips a trailing '_id' if present. Used for creating a formatted output (e.g. by replacing underscores with spaces, capitalizing the first word, etc.).","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'car_model' | humanize }} =\u003e 'Car model'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'customer_id' | humanize }} =\u003e 'Customer'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_date_before","platformOS":true,"name":"is_date_before","aliases":["date_before"],"parameters":[{"description":"time to compare to the second parameter","name":"first_time","required":false,"types":["String","Integer","Date","Time"]},{"description":"time against which the first parameter is compared to","name":"second_time","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"boolean","name":"","description":"returns true if the first time is lower than the second time","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-02' | date_before: '2010-01-03' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '6 months ago' | date_before: '2010-01-03' }} =\u003e false","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1 day ago' | date_before: 'now' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_date_in_past","platformOS":true,"name":"is_date_in_past","aliases":[],"parameters":[{"description":"time object, can also be a string","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"boolean","name":"","description":"true if time passed is in the past, false otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01' | is_date_in_past }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '3000-01-01' | is_date_in_past }} =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_email_valid","platformOS":true,"name":"is_email_valid","aliases":[],"parameters":[{"description":"String containing potentially valid email","name":"email","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether or not the argument is a valid email","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign valid = 'john@example.com' | is_email_valid %}\nvalid =\u003e true\n\n{% assign valid = 'john@' | is_email_valid %}\nvalid =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Checks if a given string is a valid GPG key","summary":"returns ","syntax":"string | is_gpg_valid","platformOS":true,"name":"is_gpg_valid","aliases":[],"parameters":[{"description":"key to be checked for validity\n{% assign valid_gpg = \"...\" | is_gpg_valid %}\n{{ result }} =\u003e false","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether the input value is a valid GPG key or not","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_json_valid","platformOS":true,"name":"is_json_valid","aliases":[],"parameters":[{"description":"String containing potentially valid JSON","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether or not the argument is a valid JSON","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign valid = '{ \"name\": \"foo\", \"bar\": {} }' | is_json_valid %}\nvalid =\u003e true\n\n{% assign valid = '{ \"foo\" }' | is_json_valid %}\nvalid =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | is_parsable_date","platformOS":true,"name":"is_parsable_date","aliases":[],"parameters":[{"description":"object that can be a date","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"boolean","name":"","description":"whether the parameter can be parsed as a date","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2021/2' | is_parsable_date }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Temporary token is valid for desired number of hours (by default 48), which you can use to authorize the user in third party application. To do it, include it in a header with name UserTemporaryToken. Token will be invalidated on password change.","summary":"returns ","syntax":"string | is_token_valid","platformOS":true,"name":"is_token_valid","aliases":[],"parameters":[{"description":"encrypted token generated via the temporary_token GraphQL property","name":"token","required":false,"types":["String"]},{"description":"id of the user who generated the token","name":"user_id","required":false,"types":["Number"]}],"return_type":[{"type":"boolean","name":"","description":"returns true if the token has not expired and was generated for the given user, false otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% token = '1234' %}\n{{ token | is_token_valid: context.current_user.id }} =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"{{ collection.all_tags | join }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Custom separator","description":"You can specify a custom separator for the joined items.\n","syntax":"array | join: string","path":"/collections/sale-potions","raw_liquid":"{{ collection.all_tags | join: ', ' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Combines all of the items in an array into a single string, separated by a space.","syntax":"array | join","name":"join"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | json","platformOS":true,"name":"json","aliases":["to_json"],"parameters":[{"description":"object you want a JSON representation of","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"string","name":"","description":"JSON formatted string containing a representation of object.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ user | json }} =\u003e {\"name\":\"Mike\",\"email\":\"mike@mail.com\"}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | jwe_encode","platformOS":true,"name":"jwe_encode","aliases":["jwe_encode_rc"],"parameters":[{"description":"JSON body string that will be encypted","name":"json","required":false,"types":["String"]},{"description":"Public key","name":"key","required":false,"types":["String"]},{"description":"- Key Management Algorithm used to encrypt or determine the value of the Content Encryption Key.\nValid options:\n Single Asymmetric Public/Private Key Pair\n RSA1_5\n RSA-OAEP\n RSA-OAEP-256\n Two Asymmetric Public/Private Key Pairs with Key Agreement\n ECDH-ES\n ECDH-ES+A128KW\n ECDH-ES+A192KW\n ECDH-ES+A256KW\n Symmetric Password Based Key Derivation\n PBES2-HS256+A128KW\n PBES2-HS384+A192KW\n PBES2-HS512+A256KW\n Symmetric Key Wrap\n A128GCMKW\n A192GCMKW\n A256GCMKW\n A128KW\n A192KW\n A256KW\n Symmetric Direct Key (known to both sides)\n dir","name":"alg","required":false,"types":["required"]},{"description":"- Encryption Algorithm used to perform authenticated encryption on the plain text using the Content Encryption Key.\nValid options:\n A128CBC-HS256\n A192CBC-HS384\n A256CBC-HS512\n A128GCM\n A192GCM\n A256GCM","name":"enc","required":false,"types":["required"]}],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | jwt_decode","platformOS":true,"name":"jwt_decode","aliases":[],"parameters":[{"description":"encoded JWT token you want to decode","name":"encoded_token","required":false,"types":["String"]},{"description":"the algorithm that was used to encode the token","name":"algorithm","required":false,"types":["String"]},{"description":"either a shared secret or a PUBLIC key for RSA","name":"secret","required":false,"types":["String"]},{"description":"default true, for testing and debugging can remove verifying the signature","name":"verify_signature","required":false,"types":["Boolean"]},{"description":"JWK is a structure representing a cryptographic key. Currently only supports RSA public keys.\nValid options:\n none - unsigned token\n HS256 - SHA-256 hash algorithm\n HS384 - SHA-384 hash algorithm\n HS512 - SHA-512 hash algorithm\n RS256 - RSA using SHA-256 hash algorithm\n RS384 - RSA using SHA-384 hash algorithm\n RS512 - RSA using SHA-512 hash algorithm","name":"jwks","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"result of decoding JWT token","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign original_payload = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJ2YWx1ZSIsImFub3RoZXJfa2V5IjoiYW5vdGhlciB2YWx1ZSJ9.XT8sHXyPTA9DoHzssXh1q6Uv2D1ENosW0F3Ixle85L0' | jwt_decode: 'HS256', 'this-is-secret' %} =\u003e\n[\n {\n \"key\" =\u003e \"value\",\n \"another_key\" =\u003e \"another value\"\n },\n {\n \"typ\" =\u003e \"JWT\",\n \"alg\" =\u003e \"HS256\"\n }\n]","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"RSA:\n{% capture public_key %}\n-----BEGIN PUBLIC KEY-----\nMIIBI...\n-----END PUBLIC KEY-----\n{% endcapture %}\n{% assign original_payload = 'some encoded token' | jwt_decode: 'RS256', public_key %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | jwt_encode","platformOS":true,"name":"jwt_encode","aliases":[],"parameters":[{"description":"payload or message you want to encrypt","name":"payload","required":false,"types":["Hash"]},{"description":"algorithm you want to use for encryption","name":"algorithm","required":false,"types":["String"]},{"description":"either a shared secret or a private key for RSA","name":"secret","required":false,"types":["String"]},{"description":"optional hash of custom headers to be added to default { \"typ\": \"JWT\", \"alg\": \"[algorithm]\" }","name":"header_fields","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"JWT token encrypted using the algorithm of your choice","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json payload %}\n{\n \"key\": \"value\",\n \"another_key\": \"another value\"\n}\n{% endparse_json %}\n\n{{ payload | jwt_encode: 'HS256', 'this-is-secret' }} =\u003e 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJ2YWx1ZSIsImFub3RoZXJfa2V5IjoiYW5vdGhlciB2YWx1ZSJ9.XT8sHXyPTA9DoHzssXh1q6Uv2D1ENosW0F3Ixle85L0'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json payload %}\n{\n \"key\": \"value\",\n \"another_key\": \"another value\"\n}\n{% endparse_json %}\n\n{% parse_json headers %}\n{\n \"cty\": \"custom\"\n}\n{% endparse_json %}\n\n{{ payload | jwt_encode: 'HS256', 'this-is-secret', headers }} =\u003e 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImN0eSI6ImN1c3RvbSJ9.eyJrZXkiOiJ2YWx1ZSIsImFub3RoZXJfa2V5IjoiYW5vdGhlciB2YWx1ZSJ9.5_-LcqcbLMeswMw04UfXqDyqAEk1x-Pwi9nwGMqxHtQ'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"RSA:\n{% capture private_key %}\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpA...\n-----END RSA PRIVATE KEY-----\n{% endcapture %}\n{% assign jwt_token = payload | jwt_encode: 'RS256', private_key %}\n{% comment %} Please note that storing private key as a plain text in a code is not a good idea. We suggest you\n provide the key via Partner Portal and use context.constants.\u003cname of private key constant\u003e instead.{% endcomment %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{%- assign last_product = collection.products | last -%}\n\n{{ last_product.title }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Dot notation","description":"You can use the `last` filter with dot notation when you need to use it inside a tag or object output.\n","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.products.last.title }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the last item in an array.","syntax":"array | last","name":"last"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | localize","platformOS":true,"name":"localize","aliases":["l"],"parameters":[{"description":"parsable time object to be formatted","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"the format to be used for formatting the time; default is 'long'; other values can be used:\nthey are taken from translations, keys are of the form 'time.formats.#!{format_name}'","name":"format","required":false,"types":["String"]},{"description":"the time zone to be used for time","name":"zone","required":false,"types":["String"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"string, nil","name":"","description":"formatted representation of the passed parsable time","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01' | localize }} =\u003e 'January 01, 2010'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'in 14 days' | localize: 'long', '', '2011-03-15' }} =\u003e 'March 14, 2011'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{%- assign text = ' Some potions create whitespace. ' -%}\n\n\"{{ text }}\"\n\"{{ text | lstrip }}\"","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all whitespace from the left of a string.","syntax":"string | lstrip","name":"lstrip"},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"{%- assign product_titles = collection.products | map: 'title' -%}\n\n{{ product_titles | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Creates an array of values from a specific property of the items in an array.","syntax":"array | map: string","name":"map"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | map","platformOS":true,"name":"map","aliases":[],"parameters":[{"description":"array of Hash to be processed. Nulls are skipped.","name":"object","required":false,"types":["Array"]},{"description":"name of the hash key for which all values should be returned\nin array of objects","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"array","name":"","description":"array which includes all values for a given key","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign objects = '[{\"id\":1,\"name\":\"foo\",\"label\":\"Foo\"},{\"id\":2,\"name\":\"bar\",\"label\":\"Bar\"}]' | parse_json %}\n{{ objects | map: 'name' }} =\u003e ['foo', 'bar']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | markdown","platformOS":true,"name":"markdown","aliases":["markdownify"],"parameters":[{"description":"text using markdown syntax","name":"text","required":false,"types":["String"]},{"description":"string representing a JSON object with options for the sanitizer","name":"options","required":false,"types":["String"]},{"description":"string representing a JSON object with additional options for the sanitizer which can override the options param; pass nil for the options param to use the default options which extra_options could then override","name":"extra_options","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"processed text with markdown syntax changed to sanitized HTML.\nWe allow only safe tags and attributes by default. We also automatically add `rel=nofollow` to links. Default configuration is:\n{\n \"elements\": [\"a\",\"abbr\",\"b\",\"blockquote\",\"br\",\"cite\",\"code\",\"dd\",\"dfn\",\"dl\",\"dt\",\"em\",\"i\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"img\",\"kbd\",\"li\",\"mark\",\"ol\",\"p\",\"pre\",\"q\",\"s\",\"samp\",\"small\",\"strike\",\"strong\",\"sub\",\"sup\",\"time\",\"u\",\"ul\",\"var\"],\n \"attributes\":{\n \"a\": [\"href\"],\n \"abbr\":[\"title\"],\n \"blockquote\":[\"cite\"],\n \"img\":[\"align\",\"alt\",\"border\",\"height\",\"src\",\"srcset\",\"width\"],\n \"dfn\":[\"title\"],\n \"q\":[\"cite\"],\n \"time\":[\"datetime\",\"pubdate\"]\n },\n \"add_attributes\": { \"a\" : {\"rel\":\"nofollow\"} },\n \"protocols\": {\n \"a\":{\"href\":[\"ftp\",\"http\",\"https\",\"mailto\",\"relative\"]},\n \"blockquote\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"q\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"img\": {\"src\": [\"http\",\"https\",\"relative\"] }\n }\n}","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '**Foo**' | markdown }} =\u003e '\u003cb\u003eFoo\u003c/b\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '**Foo**' | markdown }} =\u003e '\u003cb\u003eFoo\u003c/b\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '# Foo' | markdown }} =\u003e '\u003ch1\u003eFoo\u003c/h1\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Automatically add rel=nofollow to links\n{{ '[Foo link](https://example.com)' | markdown }} =\u003e '\u003cp\u003e\u003ca href=\"https://example.com\" rel=\"nofollow\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003cb\u003eFoo\u003c/b\u003e' | markdown }} =\u003e '\u003cb\u003eFoo\u003c/b\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Tags not enabled by default are removed\n{{ '\u003cdiv class=\"hello\" style=\"font-color: red; font-size: 99px;\"\u003eFoo\u003c/div\u003e' | markdown }} =\u003e 'Foo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Attributes not enabled by default are removed\n{{ '\u003cdiv class=\"hello\" style=\"font-color: red; font-size: 99px;\"\u003eFoo\u003c/div\u003e' | markdown: '{ \"elements\": [ \"div\" ] }' }} =\u003e '\u003cdiv\u003eFoo\u003c/div\u003e' # @example","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Specify custom tags with attributes","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Using extra_options to override options\n{% assign link1 = '[Foo link](https://example.com)' | markdown: nil, '{ \"add_attributes\": { \"a\": { \"custom_attr\": \"custom_value\", \"rel\": null } } }' %}\n{{ link1 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\" custom_attr=\"custom_value\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e\n\n{% assign link2 = '[Foo link](https://example.com)' | markdown: nil, '{ \"add_attributes\": { \"a\": {} } }' %}\n{{ link2 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e\n\n{% assign link3 = '[Foo link](https://example.com)' | markdown: nil, '{ \"add_attributes\": { \"a\": { \"custom_attr\": \"custom_value\" } } }' %}\n{{ link3 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\" rel=\"nofollow\" custom_attr=\"custom_value\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e\n\n{% assign link4 = '[Foo link](https://example.com)' | markdown %}\n{{ link4 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\" rel=\"nofollow\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | matches","platformOS":true,"name":"matches","aliases":[],"parameters":[{"description":"string to check against the regular expression","name":"text","required":false,"types":["String"]},{"description":"string representing a regular expression pattern against which\nto match the first parameter","name":"regexp","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether the given string matches the given regular expression; returns null if","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | matches: '[a-z]' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 4 | minus: 2 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Subtracts a given number from another number.","syntax":"number | minus: number","name":"minus"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 12 | modulo: 5 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the remainder of dividing a number by a given number.","syntax":"number | modulo: number","name":"modulo"},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"","summary":"returns ","syntax":" | new_line_to_br","platformOS":true,"name":"new_line_to_br","aliases":["nl2br"],"parameters":[],"return_type":[],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.description | newline_to_br }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts newlines (`\\n`) in a string to HTML line breaks (`\u003cbr\u003e`).","syntax":"string | newline_to_br","name":"newline_to_br"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | pad_left","platformOS":true,"name":"pad_left","aliases":[],"parameters":[{"description":"string to pad","name":"str","required":false,"types":["String"]},{"description":"minimum length of output string","name":"count","required":false,"types":["Number"]},{"description":"string to pad with","name":"symbol","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"returns string padded from left to the length of count with the symbol character","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | pad_left: 5 }} =\u003e ' foo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'Y' | pad_left: 3, 'X' }} =\u003e 'XXY'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | parameterize","platformOS":true,"name":"parameterize","aliases":[],"parameters":[{"description":"input string to be 'parameterized'","name":"text","required":false,"types":["String"]},{"description":"string to be used as separator in the output string; default is '-'","name":"separator","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"replaces special characters in a string so that it may be used as part of a 'pretty' URL;\nthe default separator used is '-';","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | parameterize }} =\u003e 'john-arrived_foo'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | parse_csv","platformOS":true,"name":"parse_csv","aliases":["parse_csv_rc"],"parameters":[{"description":"CSV","name":"input","required":false,"types":["String"]},{"description":"parse csv options","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"array of arrays","name":"","description":"Array","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign csv = \"name,description\\nname-1,description-1\\nname-2,description-2\\n\"\n\n {{ csv | parse_csv }} =\u003e [['name', 'description'], ['name-1', 'description-1'], ['name-2', 'description-2']]\n {{ csv | parse_csv: convert_to_hash: true }} =\u003e [{name: 'name-1', description: 'description-1'}, {name: 'name-2', description: 'description-2'}]\n%}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | parse_json","platformOS":true,"name":"parse_json","aliases":["to_hash"],"parameters":[{"description":"String containing valid JSON","name":"object","required":false,"types":["Untyped"]},{"description":"set to raw_text true to stop it from unescaping HTML entities","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"Hash created based on JSON","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign text = '{ \"name\": \"foo\", \"bar\": {} }'\n assign object = text | parse_json\n%}\n{{ object.name }} =\u003e 'foo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '{ \"key\": \"abc \u0026quot; def\" }' | parse_json: raw_text: false }} =\u003e { \"key\": 'abc \" def' }","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '{ \"key\": \"abc \u0026quot; def\" }' | parse_json: raw_text: true }} =\u003e { \"key\": 'abc \u0026quot; def' }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | parse_xml","platformOS":true,"name":"parse_xml","aliases":["xml_to_hash"],"parameters":[{"description":"String containing valid XML","name":"xml","required":false,"types":["String"]},{"description":"attr_prefix: use '@' for element attributes, force_array: always try to use arrays for child elements","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"Hash created based on XML","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign text = '\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cletter\u003e\u003ctitle maxlength=\"10\"\u003e Quote Letter \u003c/title\u003e\u003c/letter\u003e'\n assign object = text | parse_xml\n%}\n{{ object }} =\u003e '{\"letter\":[{\"title\":[{\"maxlength\":\"10\",\"content\":\" Quote Letter \"}]}]}'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Use either singular or plural version of a string, depending on provided count","summary":"returns ","syntax":"string | pluralize","platformOS":true,"name":"pluralize","aliases":[],"parameters":[{"description":"string to be pluralized","name":"string","required":false,"types":["String"]},{"description":"optional count number based on which string will be pluralized or singularized","name":"count","required":false,"types":["Number"]}],"return_type":[{"type":"string","name":"","description":"pluralized version of the input string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'dog' | pluralize: 1 }} =\u003e 'dog'\n{{ 'dog' | pluralize: 2 }} =\u003e 'dogs'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 2 | plus: 2 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Adds two numbers.","syntax":"number | plus: number","name":"plus"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{%- assign origin = request.origin -%}\n\n{{ product.url | prepend: origin }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Adds a given string to the beginning of a string.","syntax":"string | prepend: string","name":"prepend"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"numeric | pricify","platformOS":true,"name":"pricify","aliases":[],"parameters":[{"description":"amount to be formatted","name":"amount","required":false,"types":["Numeric","String"]},{"description":"currency to be used for formatting","name":"currency","required":false,"types":["String"]},{"description":"optional. Default no_cents_if_whole: true","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"formatted price using global price formatting rules","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 0 | pricify }} =\u003e $0","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify }} =\u003e $1","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1.20 | pricify }} =\u003e $1.20","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1000000 | pricify }} =\u003e $1,000,000","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify: \"PLN\" }} =\u003e 1 zł","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify: \"JPY\" }} =\u003e ¥1","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify: \"USD\", no_cents_if_whole: false }} =\u003e $1.00","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Adds currency symbol and proper commas. It is used to showing prices to people.","summary":"returns ","syntax":"numeric | pricify_cents","platformOS":true,"name":"pricify_cents","aliases":[],"parameters":[{"description":"amount in cents to be formatted","name":"amount","required":false,"types":["Numeric","String"]},{"description":"currency to be used for formatting","name":"currency","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"formatted price using the global price formatting rules","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify_cents }} =\u003e $0.01","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 100 | pricify_cents }} =\u003e $1","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1000000 | pricify_cents }} =\u003e $10,000","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify_cents: \"PLN\" }} =\u003e 0.01 zł","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify_cents: \"JPY\" }} =\u003e ¥1","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | querify","platformOS":true,"name":"querify","aliases":[],"parameters":[{"description":"hash to be \"querified\"","name":"hash","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"a query string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ hash }} =\u003e { 'name' =\u003e 'Dan', 'id' =\u003e 1 }","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ hash | querify }} =\u003e 'name=Dan\u0026id=1'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"int | random_string","platformOS":true,"name":"random_string","aliases":[],"parameters":[{"description":"how many random characters should be included; default is 12","name":"length","required":false,"types":["Int"]}],"return_type":[{"type":"string","name":"","description":"returns a random alphanumeric string of given length","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10 | random_string }} =\u003e '6a1ee2629'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | raw_escape_string","platformOS":true,"name":"raw_escape_string","aliases":[],"parameters":[{"description":"input string to be HTML-escaped","name":"value","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"HTML-escaped input string; returns a string with its HTML tags visible in\nthe browser","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo\u003cb\u003ebar\u003c/b\u003e' | raw_escape_string }} =\u003e 'foo\u0026amp;lt;b\u0026amp;gt;bar\u0026amp;lt;/b\u0026amp;gt;'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | regex_matches","platformOS":true,"name":"regex_matches","aliases":[],"parameters":[{"description":"","name":"text","required":false,"types":["String"]},{"description":"regexp to use for matching","name":"regexp","required":false,"types":["String"]},{"description":"can contain 'ixm'; i - ignore case, x - extended, m # - multiline (e.g. 'ix', 'm', 'mi' etc.)","name":"options","required":false,"types":["String"]}],"return_type":[{"type":"array","name":"","description":"matches for the expression in the string;\neach item in the array is an array containing all groups of matches; for example\nfor the regex (.)(.) and the text 'abcdef', the result will look like:\n[[\"a\", \"b\"], [\"c\", \"d\"], [\"e\", \"f\"]]","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"To retrieve the URL from a meta tag see the example below:\n\n{% liquid\n assign text = '\u003chtml\u003e\u003chead\u003e\u003cmeta property=\"og:image\" content=\"http://somehost.com/someimage.jpg\" /\u003e\u003c/head\u003e\u003cbody\u003econtent\u003c/body\u003e\u003c/html\u003e' | html_safe %}\n assign matches = text | regex_matches: '\u003cmeta\\s+property=\"og:image\"\\s+content=\"([^\"]+)\"'\n if matches.size \u003e 0\n assign image_path = matches[0][0]\n echo image_path\n endif\n%}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ \"I can't do it!\" | remove: \"'t\" }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Removes any instance of a substring inside a string.","syntax":"string | remove: string","name":"remove"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ \"I hate it when I accidentally spill my duplication potion accidentally!\" | remove_first: ' accidentally' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Removes the first instance of a substring inside a string.","syntax":"string | remove_first: string","name":"remove_first"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/komodo-dragon-scale","raw_liquid":"{{ product.handle | replace: '-', ' ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Replaces any instance of a substring inside a string with a given string.","syntax":"string | replace: string, string","name":"replace"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/komodo-dragon-scale","raw_liquid":"{{ product.handle | replace_first: '-', ' ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Replaces the first instance of a substring inside a string with a given string.","syntax":"string | replace_first: string, string","name":"replace_first"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | replace_regex","platformOS":true,"name":"replace_regex","aliases":[],"parameters":[{"description":"","name":"text","required":false,"types":["String"]},{"description":"regexp to use for matching","name":"regexp","required":false,"types":["String"]},{"description":"replacement text, or hash; if hash, keys in\nthe hash must be matched texts and values their replacements","name":"replacement","required":false,"types":["String"]},{"description":"can contain 'ixm'; i - ignore case, x - extended, m # - multiline (e.g. 'ix', 'm', 'mi' etc.)","name":"options","required":false,"types":["String"]},{"description":"whether all occurrences should be replaced or just the first","name":"global","required":false,"types":["Boolean"]}],"return_type":[{"type":"string","name":"","description":"string with regexp pattern replaced by replacement text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"Basic example:\n{{ \"fooooo fooo\" | replace_regex: 'o+', 'o' }} =\u003e \"fo fo\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Global set to false:\n{{ \"fooooo fooo\" | replace_regex: 'o+', 'o', '', false }} =\u003e \"fo fooo\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Hash replacement:\n{% liquid\n assign hash = {}\n assign hash = hash | hash_add_key: 'ooooo', 'bbbbb'\n assign hash = hash | hash_add_key: 'ooo', 'ccc'\n %}\n{{ \"fooooo fooo\" | replace_regex: 'o+', hash }} =\u003e \"fbbbbb fccc\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Using options, ignore case:\n{{ \"FOOOOO\" | replace_regex: 'o+', 'a', 'i' }} =\u003e \"Fa\"\n{{ \"FOOOOO\" | replace_regex: 'o+', 'a' }} =\u003e \"FOOOOO\"\nUsing options, extended mode (insert spaces, newlines, and comments in the pattern to make it more readable):\n{{ \"FOOOOO\" | replace_regex: 'o+ #comment', 'a', 'ix' }} =\u003e \"Fa\"\nUsing options, multiline (. matches newline):\n{% capture newLine %}\n{% endcapture %}\n{{ \"abc\" | append: newLine | append: \"def\" | append: newLine | append: \"ghi\" | replace_regex: '.+', 'a', 'im' }} =\u003e \"a\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Matches group:\n{% assign array = \"item 1,item 2,item 3,item 4\" | split: \",\" %}\n{{ array | join: \", \" | replace_regex: \"([^,]+),([^,]+)$\", \"\\1, \u0026\\2\" }} =\u003e item 1, item 2, item 3, \u0026 item 4","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"Original order:\n{{ collection.products | map: 'title' | join: ', ' }}\n\nReverse order:\n{{ collection.products | reverse | map: 'title' | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Reversing strings","description":"You can't use the `reverse` filter on strings directly. However, you can use the [`split` filter](/docs/api/liquid/filters/split) to create an array of characters in the string, reverse that array, and then use the [`join` filter](/docs/api/liquid/filters/join) to combine them again.\n","syntax":"","path":"/collections/sale-potions","raw_liquid":"{{ collection.title | split: '' | reverse | join: '' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Reverses the order of the items in an array.","syntax":"array | reverse","name":"reverse"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 2.7 | round }}\n{{ 1.3 | round }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Round to a specific number of decimal places","description":"You can specify a number of decimal places to round to. If you don't specify a number, then the `round` filter rounds to the nearest integer.\n","syntax":"","path":"/","raw_liquid":"{{ 3.14159 | round: 2 }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Rounds a number to the nearest integer.","syntax":"number | round","name":"round"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{%- assign text = ' Some potions create whitespace. ' -%}\n\n\"{{ text }}\"\n\"{{ text | rstrip }}\"","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all whitespace from the right of a string.","syntax":"string | rstrip","name":"rstrip"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | sanitize","platformOS":true,"name":"sanitize","aliases":[],"parameters":[{"description":"potential malicious html, which you would like to sanitize","name":"input","required":false,"types":["String"]},{"description":"Options to configure which elements and attributes are allowed, example:\n{ \"elements\": [\"a\", \"b\", \"h1\"], \"attributes\": { \"a\": [\"href\"] } }","name":"options","required":false,"types":null},{"description":"deprecated; do not use","name":"whitelist_tags","required":false,"types":["Array"]}],"return_type":[{"type":"string","name":"","description":"Sanitizes HTML input. If you want to allow any HTML, use html_safe filter.\nBy default we allow only safe html tags and attributes. We also automatically add `rel=nofollow` to links. Default configuration is:\n{\n \"elements\": [\"a\",\"abbr\",\"b\",\"blockquote\",\"br\",\"cite\",\"code\",\"dd\",\"dfn\",\"dl\",\"dt\",\"em\",\"i\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"img\",\"kbd\",\"li\",\"mark\",\"ol\",\"p\",\"pre\",\"q\",\"s\",\"samp\",\"small\",\"strike\",\"strong\",\"sub\",\"sup\",\"time\",\"u\",\"ul\",\"var\"],\n \"attributes\":{\n \"a\": [\"href\"],\n \"abbr\":[\"title\"],\n \"blockquote\":[\"cite\"],\n \"img\":[\"align\",\"alt\",\"border\",\"height\",\"src\",\"srcset\",\"width\"],\n \"dfn\":[\"title\"],\n \"q\":[\"cite\"],\n \"time\":[\"datetime\",\"pubdate\"]\n },\n \"add_attributes\": { \"a\" : {\"rel\":\"nofollow\"} },\n \"protocols\": {\n \"a\":{\"href\":[\"ftp\",\"http\",\"https\",\"mailto\",\"relative\"]},\n \"blockquote\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"q\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"img\": {\"src\": [\"http\",\"https\",\"relative\"] }\n }\n}","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture link %}\n \u003ca href=\"javascript:prompt(1)\"\u003eLink\u003c/a\u003e\n{% endcapture %}\n{{ link | sanitize }} =\u003e \u003ca href=\"\"\u003eLink\u003c/a\u003e\n{% assign whitelist_attributes = 'target' | split: '|' %}\n{{ link | sanitize: whitelist_attributes }} =\u003e \u003ca href=\"\"\u003eLink\u003c/a\u003e","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Scrubs invalid characters and sequences from the input string, in the given encoding (by default UTF-8)","summary":"returns ","syntax":"string | scrub","platformOS":true,"name":"scrub","aliases":[],"parameters":[{"description":"string to be scrubbed","name":"text","required":false,"types":["String"]},{"description":"encoding of the input string, default UTF-8","name":"source_encoding","required":false,"types":["String"]},{"description":"encoding of the output string, default UTF-8","name":"final_encoding","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Returns a string scrubbed of invalid characters and sequences; to be used when data is coming from external sources like APIs etc.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign scrubbed = \"Hello W�orld\" | scrub %}\n{{ scrubbed }} =\u003e \"Hello World\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"","summary":"returns ","syntax":"string | sha1","platformOS":true,"name":"sha1","aliases":[],"parameters":[{"description":"input object that you want to obtain the digest for","name":"object","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"SHA1 digest of the input object","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | sha1 }} =\u003e '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"The size of a string is the number of characters that the string includes. The size of an array is the number of items\nin the array.","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"{{ collection.title | size }}\n{{ collection.products | size }}","parameter":false,"display_type":"text","show_data_tab":false},{"name":"Dot notation","description":"You can use the `size` filter with dot notation when you need to use it inside a tag or object output.\n","syntax":"","path":"/collections/sale-potions","raw_liquid":"{% if collection.products.size \u003e= 10 %}\n There are 10 or more products in this collection.\n{% else %}\n There are less than 10 products in this collection.\n{% endif %}","parameter":false,"display_type":"text","show_data_tab":false}],"summary":"Returns the size of a string or array.","syntax":"variable | size","name":"size"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"By default, the substring has a length of one character, and the array series has one array item. However, you can\nprovide a second parameter to specify the number of characters or array items.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.title | slice: 0 }}\n{{ collection.title | slice: 0, 5 }}\n\n{{ collection.all_tags | slice: 1, 2 | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Negative index","description":"You can supply a negative index which will count from the end of the string.\n","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.title | slice: -3, 3 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns a substring or series of array items, starting at a given 0-based index.","syntax":"string | slice","name":"slice"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | slugify","platformOS":true,"name":"slugify","aliases":[],"parameters":[{"description":"input string to be 'slugified'","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"replaces special characters in a string so that it may be used as part of a 'pretty' URL;","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | slugify }} =\u003e 'john-arrived-foo'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{% assign tags = collection.all_tags | sort %}\n\n{% for tag in tags -%}\n {{ tag }}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Sort by an array item property","description":"You can specify an array item property to sort the array items by. You can sort by any property of the object that you're sorting.\n","syntax":"array | sort: string","path":"/collections/all","raw_liquid":"{% assign products = collection.products | sort: 'price' %}\n\n{% for product in products -%}\n {{ product.title }}\n{%- endfor %}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Sorts the items in an array in case-sensitive alphabetical, or numerical, order.","syntax":"array | sort","name":"sort"},{"category":"array","deprecated":false,"deprecation_reason":"","description":"\u003e Caution:\n\u003e You shouldn't use the `sort_natural` filter to sort numerical values. When comparing items an array, each item is converted to a\n\u003e string, so sorting on numerical values can lead to unexpected results.","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{% assign tags = collection.all_tags | sort_natural %}\n\n{% for tag in tags -%}\n {{ tag }}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Sort by an array item property","description":"You can specify an array item property to sort the array items by.\n","syntax":"array | sort_natural: string","path":"/collections/all","raw_liquid":"{% assign products = collection.products | sort_natural: 'title' %}\n\n{% for product in products -%}\n {{ product.title }}\n{%- endfor %}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Sorts the items in an array in case-insensitive alphabetical order.","syntax":"array | sort_natural","name":"sort_natural"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"string"}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{%- assign title_words = product.handle | split: '-' -%}\n\n{% for word in title_words -%}\n {{ word }}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Splits a string into an array of substrings based on a given separator.","syntax":"string | split: string","name":"split"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Check if string starts with given substring(s)","summary":"returns ","syntax":"string | start_with","platformOS":true,"name":"start_with","aliases":[],"parameters":[{"description":"string to check if starts with any of the provided prefixes","name":"string","required":false,"types":["String"]},{"description":"prefix(es) to check","name":"prefixes","required":false,"types":["String","Array"]}],"return_type":[{"type":"boolean","name":"","description":"true if string starts with a prefix","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | start_with: 'my' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | start_with: 'example' } =\u003e false","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign prefixes = '[\"array\", \"example\"]' | parse_json %}\n{{ 'my_example' | start_with: prefixes } =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | strftime","platformOS":true,"name":"strftime","aliases":[],"parameters":[{"description":"parsable time object","name":"time","required":false,"types":["String","Integer","Date","Time","DateTime"]},{"description":"string representing the desired output format\ne.g. '%Y-%m-%d' will result in '2020-12-21'\nCheatsheet: https://devhints.io/strftime","name":"format","required":false,"types":["String"]},{"description":"string representing the time zone","name":"zone","required":false,"types":["String"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"string","name":"","description":"formatted representation of the time object; the formatted representation\nwill be based on what the format parameter specifies","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M' }} =\u003e 2018-05-30 09:12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign time = '2010-01-01 08:00' | to_time %}\n{{ time | strftime: \"%Y-%m-%d\" }} =\u003e '2010-01-01'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'Europe/Warsaw' }} =\u003e 2018-05-30 18:12\n{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'America/New_York' }} =\u003e 2018-05-30 12:12\n{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'Sydney' }} =\u003e 2018-05-31 02:12\n{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'Pacific/Apia' }} =\u003e 2018-05-31 05:12","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{%- assign text = ' Some potions create whitespace. ' -%}\n\n\"{{ text }}\"\n\"{{ text | strip }}\"","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all whitespace from the left and right of a string.","syntax":"string | strip","name":"strip"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"\u003c!-- With HTML --\u003e\n{{ product.description }}\n\n\u003c!-- HTML stripped --\u003e\n{{ product.description | strip_html }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all HTML tags from a string.","syntax":"string | strip_html","name":"strip_html"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | strip_liquid","platformOS":true,"name":"strip_liquid","aliases":[],"parameters":[{"description":"text from which to strip liquid","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"input parameter without liquid","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'Hello! {% comment %}This is a comment!{% endcomment %}' | strip_liquid }} =\u003e \"Hello! This is a comment!\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"\u003c!-- With newlines --\u003e\n{{ product.description }}\n\n\u003c!-- Newlines stripped --\u003e\n{{ product.description | strip_newlines }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all newline characters (line breaks) from a string.","syntax":"string | strip_newlines","name":"strip_newlines"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | time_diff","platformOS":true,"name":"time_diff","aliases":[],"parameters":[{"description":"","name":"start","required":false,"types":["String","Integer","Date","Time"]},{"description":"","name":"finish","required":false,"types":["String","Integer","Date","Time"]},{"description":"time unit - allowed options are: d, days, h, hours, m, minutes, s, seconds, ms, milliseconds [default]","name":"unit","required":false,"types":["String"]},{"description":"defines rounding after comma; default is 3","name":"precision","required":false,"types":["Number"]}],"return_type":[{"type":"number","name":"","description":"duration between start and finish in unit; default is ms (milliseconds)","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign result = 'now' | time_diff: 'in 5 minutes', 'd' %}\n{{ result }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign minutes_until_date = 'now' | time_diff: '2026-10-08 00:00', 'm' %}\n{% comment %}{% background _ = 'commands/foo', delay: minutes_until_date %} %}{% endcomment %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 2 | times: 2 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Multiplies a number by a given number.","syntax":"number | times: number","name":"times"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | titleize","platformOS":true,"name":"titleize","aliases":[],"parameters":[{"description":"string to be processed","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"capitalizes all the words and replaces some characters in the string to create\na string in title-case format","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo bar_zoo-xx' | titleize }} =\u003e 'Foo Bar Zoo Xx'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | to_csv","platformOS":true,"name":"to_csv","aliases":[],"parameters":[{"description":"array you would like to convert to CSV","name":"input","required":false,"types":["Array"]},{"description":"csv options","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"String containing CSV.\nIf one of the array element contains separator, this element will automatically be wrapped in double quotes.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign arr = '' | split: ','\n assign headers = 'id,header1,header2' | split: ','\n assign row1 = '1,example,value' | split: ','\n assign row2 = '2,another,val2' | split: ','\n assign arr = arr | array_add: headers | array_add: row1 | array_add: row2\n%}\n{{ arr | to_csv }} =\u003e \"id\",\"header1\",\"header2\"\\n1,\"example\",\"value\"\\n2,\"another\",\"val2\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ arr | to_csv: force_quotes: true }} =\u003e \"id\",\"header1\",\"header2\"\\n\"1\",\"example\",\"value\"\\n\"2\",\"another\",\"val2\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | to_date","platformOS":true,"name":"to_date","aliases":[],"parameters":[{"description":"parsable time object to be converted to date","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"date","name":"","description":"a Date object obtained/parsed from the input object","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01 8:00:00' | to_date }} =\u003e 2010-01-01","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | to_mobile_number","platformOS":true,"name":"to_mobile_number","aliases":[],"parameters":[{"description":"the base part of mobile number","name":"number","required":false,"types":["String"]},{"description":"country for which country code should be used. Can be anything - full name, iso2, iso3","name":"country","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"returns mobile number in E.164 format; recommended for sending sms notifications","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '500 123 999' | to_mobile_number: 'PL' }} =\u003e '+48500123999'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | to_positive_integer","platformOS":true,"name":"to_positive_integer","aliases":[],"parameters":[{"description":"value to be coerced to positive integer","name":"param","required":false,"types":["Untyped"]},{"description":"default value in case param is not valid positive integer","name":"default","required":false,"types":["Number"]}],"return_type":[{"type":"number","name":"","description":"number that is higher than 0","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1' | to_positive_integer: 2 }} =\u003e 1\n{{ '' | to_positive_integer: 2 }} =\u003e 2","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | to_time","platformOS":true,"name":"to_time","aliases":[],"parameters":[{"description":"a string representation of time ('today', '3 days ago', 'in 10 minutes' etc.) or a number in UNIX time format or time","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"time zone","name":"zone","required":false,"types":["String"]},{"description":"specific format to be used when parsing time","name":"format","required":false,"types":["String"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"datetime","name":"","description":"a time object created from parsing the string representation of time given as input","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'today' | to_time }} =\u003e 2017-04-15 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'today' | to_time: 'UTC' }} =\u003e 2017-04-15 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1 day ago' | to_time }} =\u003e 2017-04-14 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '5 days from now' | to_time }} =\u003e 2017-04-19 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010:01:01' | to_time: '', '%Y:%m:%d' }} =\u003e 2010-01-01 00:00:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '5 days from now' | to_time '', '', '2019-10-01' }} =\u003e 2019-10-06 00:00:00 # equivalent of {{ '2019-10-01' | add_to_time: 5, 'days' }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | to_xml","platformOS":true,"name":"to_xml","aliases":["to_xml_rc"],"parameters":[{"description":"hash object that will be represented as xml","name":"object","required":false,"types":["Hash"]},{"description":"attr_prefix: use '@' for element attributes","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"String containing XML","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign object = '{\"letter\":[{\"title\":[{\"maxlength\":\"10\",\"content\":\" Quote Letter \"}]}]}' | parse_json\n assign xml = object | to_xml\n%}\n{{ object }} =\u003e '\u003cletter\u003e \u003ctitle maxlength=\"10\"\u003e Quote Letter \u003c/title\u003e \u003c/letter\u003e'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | translate","platformOS":true,"name":"translate","aliases":["t"],"parameters":[{"description":"translation key","name":"key","required":false,"types":["String"]},{"description":"values passed to translation string","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"Translation value taken from translations YML file for the key given as parameter. The value is assumed to be html safe,\nplease use `t_escape` if you provide unsafe argument which can potentially include malicious script.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'beer' | translate }} =\u003e 'cerveza'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'beer' | t }} =\u003e 'cerveza'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'drinks.alcoholic.beer' | t }} =\u003e 'piwo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'non_existing_translation' | t: default: 'Missing', fallback: false }} =\u003e 'Missing'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'user-greeting' | t: username: 'Mike' }} =\u003e 'Hello Mike!'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Escapes unsafe arguments passed to the translation and then returns its value","summary":"returns ","syntax":"string | translate_escape","platformOS":true,"name":"translate_escape","aliases":["t_escape"],"parameters":[{"description":"translation key","name":"key","required":false,"types":["String"]},{"description":"values passed to translation string","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"translation value taken from translations YML file for the key given as parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"en.yml\nen:\n user-greeting: Hello %{username}\n\n{{ 'user-greeting' | t_escape: username: '\u003cscript\u003ealert(\"hello\")\u003c/script\u003eMike' }}\n=\u003e will not evaluate the script, it will print out:\nHello \u003cscript\u003ealert(\"hello\")\u003c/script\u003eMike","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"If the specified number of characters is less than the length of the string, then an ellipsis (`...`) is appended to\nthe truncated string. The ellipsis is included in the character count of the truncated string.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.title | truncate: 15 }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Specify a custom ellipsis","description":"You can provide a second parameter to specify a custom ellipsis. If you don't want an ellipsis, then you can supply an empty string.\n","syntax":"string | truncate: number, string","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.title | truncate: 15, '--' }}\n{{ article.title | truncate: 15, '' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Truncates a string down to a given number of characters.","syntax":"string | truncate: number","name":"truncate"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"If the specified number of words is less than the number of words in the string, then an ellipsis (`...`) is appended to\nthe truncated string.\n\n\u003e Caution:\n\u003e HTML tags are treated as words, so you should strip any HTML from truncated content. If you don't strip HTML, then\n\u003e closing HTML tags can be removed, which can result in unexpected behavior.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.content | strip_html | truncatewords: 15 }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Specify a custom ellipsis","description":"You can provide a second parameter to specify a custom ellipsis. If you don't want an ellipsis, then you can supply an empty string.\n","syntax":"string | truncatewords: number, string","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.content | strip_html | truncatewords: 15, '--' }}\n\n{{ article.content | strip_html | truncatewords: 15, '' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Truncates a string down to a given number of words.","syntax":"string | truncatewords: number","name":"truncatewords"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | type_of","platformOS":true,"name":"type_of","aliases":[],"parameters":[{"description":"Variable whose type you want returned","name":"variable","required":false,"types":["Untyped"]}],"return_type":[{"type":"string","name":"","description":"Type of the variable parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign variable_type = '{ \"name\": \"foo\", \"bar\": {} }' | parse_json | type_of %}\n{{ variable_type }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | unescape_javascript","platformOS":true,"name":"unescape_javascript","aliases":[],"parameters":[{"description":"text to be unescaped","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"unescaped javascript text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture 'js' %}\n\u003cscript\u003e\n let variable = \"\\t some text\\n\";\n let variable2 = 'some text 2';\n let variable3 = `some text`;\n let variable4 = `We love ${variable3}.`;\n \u003c/script\u003e\n{% endcapture %}\n\nThis will return the text to its original form:\n{{ js | escape_javascript | unescape_javascript }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{% assign potion_array = 'invisibility, health, love, health, invisibility' | split: ', ' %}\n\n{{ potion_array | uniq | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Removes any duplicate items in an array.","syntax":"array | uniq","name":"uniq"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.title | upcase }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts a string to all uppercase characters.","syntax":"string | upcase","name":"upcase"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 'test%40test.com' | url_decode }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Decodes any [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) characters\nin a string.","syntax":"string | url_decode","name":"url_decode"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"\u003e Note:\n\u003e Spaces are converted to a `+` character, instead of a percent-encoded character.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 'test@test.com' | url_encode }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts any URL-unsafe characters in a string to the\n[percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) equivalent.","syntax":"string | url_encode","name":"url_encode"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | url_to_qrcode_svg","platformOS":true,"name":"url_to_qrcode_svg","aliases":[],"parameters":[{"description":"URL to be encoded as QR code","name":"url","required":false,"types":["String"]},{"description":"optional. Defaults: color: \"000\", module_size: 11, shape_rendering: \"crispEdges\", viewbox: false","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"either `\u003cpath ...\u003e...\u003c/path\u003e` or `\u003csvg ...\u003e...\u003c/svg\u003e` depending on standalone flag","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"\u003csvg width=\"319\" height=\"319\"\u003e{{ 'https://example.com' | url_to_qrcode_svg, color: '000', module_size: 11 }}\u003c/svg\u003e =\u003e \u003csvg width=\"319\" height=\"319\"\u003e\u003cpath\u003e...\u003c/path\u003e\u003c/svg\u003e","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | useragent","platformOS":true,"name":"useragent","aliases":[],"parameters":[{"description":"browser user agent from the request header","name":"useragent_header","required":false,"types":["String"]}],"return_type":[{"type":"hash","name":"","description":"parsed browser user agent information","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ context.headers.HTTP_USER_AGENT | useragent }} =\u003e\n{\n \"device\": {\"family\":\"Other\",\"model\":\"Other\",\"brand\":null},\n \"family\":\"Firefox\",\n \"os\":{\"version\":null,\"family\":\"Windows 7\"},\n \"version\":{\"version\":\"47.0\",\"major\":\"47\",\"minor\":\"0\",\"patch\":null}\n}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | uuid","platformOS":true,"name":"uuid","aliases":[],"parameters":[{"description":"parameter will be ignored","name":"_dummy","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Universally unique identifier v4","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '' | uuid }} =\u003e \"2d931510-d99f-494a-8c67-87feb05e1594\"\n\n{% assign id = '' | uuid %}\n{{ id }} =\u003e \"b12bd15e-4da7-41a7-b673-272221049c01\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | verify_access_key","platformOS":true,"name":"verify_access_key","aliases":[],"parameters":[{"description":"can be obtained in Partner Portal","name":"access_key","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"check if key is valid","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign access_key = '12345' %}\n{{ access_key | verify_access_key }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | video_params","platformOS":true,"name":"video_params","aliases":[],"parameters":[{"description":"URL to a video on the internet","name":"url","required":false,"types":["String"]}],"return_type":[{"type":"hash","name":"","description":"metadata about video","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'https://www.youtube.com/watch?v=8N_tupPBtWQ' | video_params }}\n=\u003e {\"provider\" =\u003e \"YouTube\", \"url\" =\u003e \"https://www.youtube.com/watch?v=8N_tupPBtWQ\", \"video_id\" =\u003e \"8N_tupPBtWQ\", \"embed_url\" =\u003e \"https://www.youtube.com/embed/8N_tupPBtWQ\", \"embed_code\" =\u003e \"\u003ciframe src=\\\"https://www.youtube.com/embed/8N_tupPBtWQ\\\" frameborder=\\\"0\\\" allowfullscreen=\\\"allowfullscreen\\\"\u003e\u003c/iframe\u003e\"},","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | videoify","platformOS":true,"name":"videoify","aliases":[],"parameters":[{"description":"URL to a video on the internet","name":"url","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"if the given URL is supported, an HTML formatted string containing a video player (inside an iframe)\nwhich will play the video at the given URL; otherwise an empty string is returned","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | www_form_encode_rc","platformOS":true,"name":"www_form_encode_rc","aliases":[],"parameters":[{"description":"data object","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"string","name":"","description":"This generates application/x-www-form-urlencoded data defined in HTML5 from given object.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"assign object = '{\"foo\": \"bar\", \"zoo\": [{ \"xoo\": 1 }, {\"xoo\": 2}]}' | parse_json\nassign form_data = object | www_form_encode_rc\n =\u003e \"foo=bar\u0026zoo[0][xoo]=555\u0026zoo[1][xoo]=999\",","parameter":false,"display_type":"text","show_data_tab":true}]}] +[{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ -3 | abs }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the absolute value of a number.","syntax":"number | abs","name":"abs"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | add_to_time","platformOS":true,"name":"add_to_time","aliases":[],"parameters":[{"description":"","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"","name":"number","required":false,"types":["Number"]},{"description":"time unit - allowed options are: y, years, mo, months, w, weeks, d [default], days, h, hours, m, minutes, s, seconds","name":"unit","required":false,"types":["String"]}],"return_type":[{"type":"time","name":"","description":"modified time","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'now' | add_to_time: 1, 'w' }} # =\u003e returns current time plus one week","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'now' | add_to_time: 3, 'mo' }} # =\u003e returns current time plus three months","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | advanced_format","platformOS":true,"name":"advanced_format","aliases":[],"parameters":[{"description":"object you want to format","name":"argument_to_format","required":false,"types":["Untyped"]},{"description":"should look like: %[flags][width][.precision]type. For more examples and information see: https://ruby-doc.org/core-2.5.1/Kernel.html#method-i-sprintf","name":"format","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"formatted string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 3.124 | advanced_format: '%.2f' }} =\u003e 3.12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 3 | advanced_format: '%.2f' }} =\u003e 3.00\nIn the example above flags is not present, width is not present (refers to the total final\nlength of the string), precision \".2\" means 2 digits after the decimal point,\ntype \"f\" means floating point","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Converts amount in given currency to fractional. For example, convert USD to cents.","summary":"returns ","syntax":"numeric | amount_to_fractional","platformOS":true,"name":"amount_to_fractional","aliases":[],"parameters":[{"description":"amount to be changed to fractional","name":"amount","required":false,"types":["Numeric","String"]},{"description":"currency to be used","name":"currency","required":false,"types":["String"]}],"return_type":[{"type":"number","name":"","description":"Amount in fractional, for example cents for USD","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10.50 | amount_to_fractional: 'USD' }} =\u003e 1050","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10.50 | amount_to_fractional: 'JPY' }} =\u003e 11","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{%- assign path = product.url -%}\n\n{{ request.origin | append: path }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Adds a given string to the end of a string.","syntax":"string | append: string","name":"append"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_add","platformOS":true,"name":"array_add","aliases":["add_to_array"],"parameters":[{"description":"array to which you add a new element","name":"array","required":false,"types":["Array"]},{"description":"item you add to the array","name":"item","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"array to which you add the item given as the second parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign array = 'a,b,c' | split: ',' %}\n{{ array | array_add: 'd' }} =\u003e ['a', 'b', 'c', 'd']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_any","platformOS":true,"name":"array_any","aliases":["any"],"parameters":[{"description":"array to search in","name":"array","required":false,"types":["Array"]},{"description":"String/Number compared to each item in the given array","name":"query","required":false,"types":["String","Number"]}],"return_type":[{"type":"boolean","name":"","description":"checks if given array contains at least one of the queried string/number","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign elements = 'foo,bar' | split: ',' %}\n{{ elements | array_any: 'foo' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Removes blank elements from the array","summary":"returns ","syntax":"array | array_compact","platformOS":true,"name":"array_compact","aliases":["compact"],"parameters":[{"description":"array with some blank values","name":"array","required":false,"types":["Array","Hash"]},{"description":"optionally if you provide Hash as argument, you can remove elements which given key is blank","name":"property","required":false,"types":["String"]}],"return_type":[{"type":"array","name":"","description":"array from which blank values are removed","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1,' | split: ',' | array_add: false | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1,' | split: ',' | array_add: null | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json empty_object %}{}{% endparse_json %}\n{{ '1,' | split: ',' | array_add: empty_object | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign empty_array = ',' | split: ',' %}\n{{ '1,' | split: ',' | array_add: empty_array | array_add: '2' | array_compact }} =\u003e 12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json hash %}[{ \"hello\": null }, { \"hello\" =\u003e \"world\" }, { \"hello\" =\u003e \"\" }]{% endparse_json %}\n{{ hash | array_compact: \"hello\" }} =\u003e [{ \"hello\": \"world\" }]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_delete","platformOS":true,"name":"array_delete","aliases":[],"parameters":[{"description":"array to process","name":"array","required":false,"types":["Array"]},{"description":"value to remove","name":"element","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"the initial array that has all occurences of \"element\" removed","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign test = '[\"test\", \"test2\", \"test\", \"test3\"]' | parse_json %}\n{% assign arr = test | array_delete: 'test' %}\n{{ arr }} =\u003e ['test2', 'test3']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_delete_at","platformOS":true,"name":"array_delete_at","aliases":[],"parameters":[{"description":"array to process","name":"array","required":false,"types":["Array"]},{"description":"array index to remove","name":"index","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"the initial array that has the element at index removed","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign test = '[\"test\", \"test2\"]' | parse_json %}\n{% assign arr = test | array_delete_at: 1 %}\n{{ arr }} =\u003e ['test']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_detect","platformOS":true,"name":"array_detect","aliases":["detect"],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"untyped","name":"","description":"first object from the collection that matches the specified conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"}]\n{{ objects | array_detect: foo: 2 }} =\u003e {\"foo\":2,\"bar\":\"b\"}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_find_index","platformOS":true,"name":"array_find_index","aliases":[],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"with indices from collection that matches provided conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"},{\"foo\":2,\"bar\":\"d\"}]\n{{ objects | array_find_index: foo: 2 }} =\u003e [1, 3]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_flatten","platformOS":true,"name":"array_flatten","aliases":["flatten"],"parameters":[{"description":"array of arrays to be processed","name":"array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"with objects","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ array_of_arrays }} =\u003e [[1,2], [3,4], [5,6]]\n{{ array_of_arrays | array_flatten }} =\u003e [1,2,3,4,5,6]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Transforms array into hash, with keys equal to the values of object's method name and value being array containing objects","summary":"returns ","syntax":"array | array_group_by","platformOS":true,"name":"array_group_by","aliases":["group_by"],"parameters":[{"description":"array to be grouped","name":"objects","required":false,"types":["Array"]},{"description":"method name to be used to group Objects","name":"method_name","required":false,"types":["String"]}],"return_type":[{"type":"hash","name":"","description":"the original array grouped by method\nspecified by the second parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json objects %}\n [\n { \"size\": \"xl\", \"color\": \"red\"},\n { \"size\": \"xl\", \"color\": \"yellow\"},\n { \"size\": \"s\", \"color\": \"red\"}\n ]\n{% endparse_json %}\n\n{{ objects | array_group_by: 'size' }} =\u003e {\"xl\" =\u003e [{\"size\" =\u003e \"xl\", \"color\" =\u003e \"red\"}, {\"size\" =\u003e \"xl\", \"color\" =\u003e \"yellow\"}], \"s\" =\u003e [{\"size\" =\u003e \"s\", \"color\" =\u003e \"red\"}]}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Transforms array in array of arrays, each subarray containing exactly N elements","summary":"returns ","syntax":"array | array_in_groups_of","platformOS":true,"name":"array_in_groups_of","aliases":["in_groups_of"],"parameters":[{"description":"array to be split into groups","name":"array","required":false,"types":["Array"]},{"description":"the size of each group the array is to be split into","name":"number_of_elements","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"the original array split into groups of the size\nspecified by the second parameter (an array of arrays)","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign elements = '1,2,3,4' | split: ',' %}\n{{ elements | array_in_groups_of: 3 }} =\u003e [[1, 2, 3], [4, null, null]]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"Checks if array includes element","summary":"returns ","syntax":"array | array_include","platformOS":true,"name":"array_include","aliases":["is_included_in_array"],"parameters":[{"description":"array of elements to look into","name":"array","required":false,"types":["Array"]},{"description":"look for this element inside the array","name":"el","required":false,"types":["Untyped"]}],"return_type":[{"type":"boolean","name":"","description":"whether the array includes the element given","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign elements = 'a,b,c,d' | split: ',' %}\n{{ elements | array_include: 'c' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Finds index of an object in the array","summary":"returns ","syntax":"array | array_index_of","platformOS":true,"name":"array_index_of","aliases":[],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"object to search for","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"","name":"","description":"Integer position of object in array if found or nil otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [1,'abc',3]\n{{ objects | array_index_of: 'abc' }} =\u003e 1","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_intersect","platformOS":true,"name":"array_intersect","aliases":["intersection"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"array of objects to be processed","name":"other_array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"that exists in both arrays","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign array = '1,2,3,4' | split: ','\n assign other_array = '3,4,5,6' | split: ','\n%}\n\n{{ array | array_intersect: other_array }} =\u003e [3,4]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_limit","platformOS":true,"name":"array_limit","aliases":["limit"],"parameters":[{"description":"array to shrink","name":"array","required":false,"types":["Array"]},{"description":"number of elements to be returned","name":"limit","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"parameter; [1,2,3,4] limited to 2 elements gives [1,2]","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"items =\u003e [{ id: 1, name: 'foo', label: 'Foo' }, { id: 2, name: 'bar', label: 'Bar' }]\n{{ items | array_limit: 1 }} =\u003e [{ id: 1, name: 'foo', label: 'Foo' }]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_map","platformOS":true,"name":"array_map","aliases":["map_attributes"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"array of keys to be extracted","name":"attributes","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"array of arrays with values for given keys","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ items }} =\u003e [{ id: 1, name: 'foo', label: 'Foo' }, { id: 2, name: 'bar', label: 'Bar' }]\n{{ items | array_map: 'id', 'name' }} =\u003e [[1, 'foo'], [2, 'bar']]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_prepend","platformOS":true,"name":"array_prepend","aliases":["prepend_to_array"],"parameters":[{"description":"array to which you prepend a new element","name":"array","required":false,"types":["Array"]},{"description":"item you prepend to the array","name":"item","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"array to which you prepend the item given as the second parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign array = 'a,b,c' | split: ',' %}\n{{ array | array_prepend: 'd' }} =\u003e ['d', 'a', 'b', 'c']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_reject","platformOS":true,"name":"array_reject","aliases":["reject"],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"with objects from collection that don't match provided conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"},{\"foo\":2,\"bar\":\"d\"}]\n{{ objects | array_reject: foo: 2 }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":3,\"bar\":\"c\"}]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_rotate","platformOS":true,"name":"array_rotate","aliases":["rotate"],"parameters":[{"description":"array to be rotated","name":"array","required":false,"types":["Array"]},{"description":"number of times to rotate the input array","name":"count","required":false,"types":["Number"]}],"return_type":[{"type":"array","name":"","description":"the input array rotated by a number of times given as the second\nparameter; [1,2,3,4] rotated by 2 gives [3,4,1,2]","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign numbers = \"1,2,3\" | split: \",\" %}\n{{ numbers | array_rotate }} =\u003e [2,3,1]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_select","platformOS":true,"name":"array_select","aliases":["select"],"parameters":[{"description":"array of objects to be processed","name":"objects","required":false,"types":["Array"]},{"description":"hash with conditions { field_name: value }","name":"conditions","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"with objects from collection that matches provided conditions","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ objects }} =\u003e [{\"foo\":1,\"bar\":\"a\"},{\"foo\":2,\"bar\":\"b\"},{\"foo\":3,\"bar\":\"c\"},{\"foo\":2,\"bar\":\"d\"}]\n{{ objects | array_select: foo: 2 }} =\u003e [{\"foo\":2,\"bar\":\"b\"},{\"foo\":2,\"bar\":\"d\"}]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_shuffle","platformOS":true,"name":"array_shuffle","aliases":["shuffle_array"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"array with shuffled items","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ items }} =\u003e [1, 2, 3, 4]\n{{ items | array_shuffle }} =\u003e [3, 2, 4, 1]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_sort_by","platformOS":true,"name":"array_sort_by","aliases":["sort_by"],"parameters":[{"description":"Array of Hash to be sorted by a key","name":"input","required":false,"types":["Array"]},{"description":"property by which to sort an Array of Hashes","name":"property","required":false,"types":["Untyped"]}],"return_type":[{"type":"array","name":"","description":"Sorted object (Array of Hash)","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"array1 is [{\"title\": \"Tester\", \"value\": 1}, {\"title\": \"And\", \"value\": 2}]\n{{ array1 | array_sort_by: \"title\" }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_subtract","platformOS":true,"name":"array_subtract","aliases":["subtract_array"],"parameters":[{"description":"array of objects to be processed","name":"array","required":false,"types":["Array"]},{"description":"array of objects to be processed","name":"other_array","required":false,"types":["Array"]}],"return_type":[{"type":"array","name":"","description":"that is a difference between two arrays","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign array = '1,2' | split: ','\n assign other_array = '2' | split: ','\n%}\n\n{{ array | array_subtract: other_array }} =\u003e [1]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | array_sum","platformOS":true,"name":"array_sum","aliases":["sum_array"],"parameters":[{"description":"array with values to be summarised","name":"array","required":false,"types":["Array"]}],"return_type":[{"type":"number","name":"","description":"summarised value of array","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign numbers = '[1,2,3]' | parse_json %}\n{{ numbers | array_sum }} =\u003e 6","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Removes duplicate elements from an array","summary":"returns ","syntax":"array | array_uniq","platformOS":true,"name":"array_uniq","aliases":[],"parameters":[{"description":"array to be processed","name":"input","required":false,"types":["Array"]},{"description":"String property to be used as the comparison key\n{% assign result = \"[[1,2],[1,2],[3,4]]\" | array_uniq %}\n{{ result }} =\u003e \"[[1,2],[3,4]]\"","name":"property","required":false,"types":null}],"return_type":[{"type":"array","name":"","description":"Returns an array with duplicate elements removed","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"Looks up an asset by name and returns the value stored in its `raw_url`\ncolumn. Intended for migrating legacy data where an explicit URL was persisted on\nthe asset row; new code should derive URLs from the asset name via `asset_url`.","summary":"returns ","syntax":"string | asset_name_to_raw_url","platformOS":true,"name":"asset_name_to_raw_url","aliases":[],"parameters":[{"description":"asset name to look up","name":"name","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"the asset's `raw_url` if the asset exists, nil otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'logo.png' | asset_name_to_raw_url }} =\u003e 'https://example.com/instances/1/assets/logo.png'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"Generates relative path to an asset, including `updated` query parameter.\nThe `/assets/` prefix is rewritten to the CDN origin by the reverse proxy at request time,\nso the rendered HTML stays portable across environments (custom domains, previews, on-prem).\nAlways prefer `asset_url`, which points clients straight at the CDN — `asset_path` forces\nevery request through the regional load balancer and reverse proxy before the CDN is reached,\nwhich defeats most of the CDN's benefit.","summary":"returns ","syntax":"string | asset_path","platformOS":true,"name":"asset_path","aliases":[],"parameters":[{"description":"path to the asset, relative to assets directory","name":"file_path","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"relative path to the physical file decorated with updated param to invalidate CDN cache.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ \"valid/file.jpg\" | asset_path }} =\u003e /assets/valid/file.jpg?updated=1565632488","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Generates CDN url to an asset","summary":"returns ","syntax":"string | asset_url","platformOS":true,"name":"asset_url","aliases":[],"parameters":[{"description":"path to the asset, relative to assets directory","name":"file_path","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"CDN URL with the instance's asset cache buster appended.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ \"valid/file.jpg\" | asset_url }} =\u003e https://cdn-server.com/instances/1/assets/valid/file.jpg?updated=1565632488","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 4 | at_least: 5 }}\n{{ 4 | at_least: 3 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Limits a number to a minimum value.","syntax":"number | at_least","name":"at_least"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 6 | at_most: 5 }}\n{{ 4 | at_most: 5 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Limits a number to a maximum value.","syntax":"number | at_most","name":"at_most"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | base64_decode","platformOS":true,"name":"base64_decode","aliases":[],"parameters":[{"description":"Base64 encoded string","name":"base64_string","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"decoded string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'aGVsbG8gYmFzZTY0\\n' | base64_decode }} =\u003e 'hello base64'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | base64_encode","platformOS":true,"name":"base64_encode","aliases":[],"parameters":[{"description":"string to be encoded","name":"bin","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Returns the Base64-encoded version of bin. This method complies with RFC 2045. Line feeds are added to every 60 encoded characters.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'hello base64' | base64_encode }} =\u003e 'aGVsbG8gYmFzZTY0'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 'this sentence should start with a capitalized word.' | capitalize }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Capitalizes the first word in a string and downcases the remaining characters.","syntax":"string | capitalize","name":"capitalize"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 1.2 | ceil }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Rounds a number up to the nearest integer.","syntax":"number | ceil","name":"ceil"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | compute_hmac","platformOS":true,"name":"compute_hmac","aliases":[],"parameters":[{"description":"message to be authenticated","name":"data","required":false,"types":["String"]},{"description":"secret key","name":"secret","required":false,"types":["String"]},{"description":"defaults to SHA256. Supported algorithms are:\nSHA, SHA1, SHA224, SHA256, SHA384, SHA512, MD4, MDC2, MD5, RIPEMD160, DSS1.","name":"algorithm","required":false,"types":["String"]},{"description":"defaults to hex. Supported digest values are hex, none, base64","name":"digest","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Keyed-hash message authentication code (HMAC), that can\nbe used to authenticate requests from third\nparty apps, e.g. Stripe webhooks requests","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'some_data' | compute_hmac: 'some_secret', 'MD4' }} =\u003e 'cabff538af5f97ccc27d481942616492'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"\u003e Note:\n\u003e The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the\n\u003e [`uniq` filter](/docs/api/liquid/filters/uniq).","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{%- assign types_and_vendors = collection.all_types | concat: collection.all_vendors -%}\n\nTypes and vendors:\n\n{% for item in types_and_vendors -%}\n {%- if item != blank -%}\n - {{ item }}\n {%- endif -%}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Concatenates (combines) two arrays.","syntax":"array | concat: array","name":"concat"},{"category":"format","deprecated":false,"deprecation_reason":"","description":"The `date` filter accepts the same parameters as Ruby's strftime method for formatting the date. For a list of shorthand\nformats, refer to the [Ruby documentation](https://ruby-doc.org/core-3.1.1/Time.html#method-i-strftime) or\n[strftime reference and sandbox](http://www.strfti.me/).","parameters":[{"description":"The desired date format.","name":"format","required":false,"types":["string"]}],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.created_at | date: '%B %d, %Y' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"The current date","description":"You can apply the `date` filter to the keywords `'now'` and `'today'` to output the current timestamp.\n\n\u003e Note:\n\u003e The timestamp will reflect the time that the Liquid was last rendered. Because of this, the timestamp might not be updated for every page view, depending on the context and caching.\n","syntax":"","path":"/","raw_liquid":"{{ 'now' | date: '%B %d, %Y' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"format","description":"Specify a locale-aware date format. You can use the following formats:\n\n- `abbreviated_date`\n- `basic`\n- `date`\n- `date_at_time`\n- `default`\n- `on_date`\n- `short` (deprecated)\n- `long` (deprecated)\n\n\u003e Note:\n\u003e You can also [define custom formats](/docs/api/liquid/filters/date-setting-format-options-in-locale-files) in your theme's locale files.\n","syntax":"string | date: format: string","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.created_at | date: format: 'abbreviated_date' }}","parameter":true,"display_type":"text","show_data_tab":true},{"name":"Setting format options in locale files","description":"You can define custom date formats in your [theme's storefront locale files](/themes/architecture/locales/storefront-locale-files). These custom formats should be included in a `date_formats` category:\n\n```json\n\"date_formats\": {\n \"month_day_year\": \"%B %d, %Y\"\n}\n```\n","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.created_at | date: format: 'month_day_year' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Converts a timestamp into another date format.","syntax":"string | date: string","name":"date"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | date_add","platformOS":true,"name":"date_add","aliases":["add_to_date"],"parameters":[{"description":"","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"","name":"number","required":false,"types":["Number"]},{"description":"time unit - allowed options are: y, years, mo, months, w, weeks, d [default], days, h, hours, m, minutes, s, seconds","name":"unit","required":false,"types":["String"]}],"return_type":[{"type":"date","name":"","description":"modified Date","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01' | date_add: 1 }} =\u003e 2010-01-02\n{{ '2010-01-01' | date_add: 1, 'mo' }} =\u003e 2010-02-01","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Filter allowing to decrypt data encrypted with a specified algorithm. See encrypt filter for encryption.","summary":"returns ","syntax":"string | decrypt","platformOS":true,"name":"decrypt","aliases":[],"parameters":[{"description":"string payload to be decrypted - must be a Base64 encoded (RFC 4648) or HEX (if from_hex flag true) string","name":"payload","required":false,"types":["String"]},{"description":"algorithm you want to use for encryption","name":"algorithm","required":false,"types":["String"]},{"description":"a key used for encryption. Key must match the algorithm requirments. For asymmetric algorithms there are public and private keys that need to passed in PEM format.","name":"key","required":false,"types":["String"]},{"description":"- a Hash with additional options to control the decryption algorithm","name":"options","required":false,"types":null}],"return_type":[{"type":"string","name":"","description":"String - decrypted string using the algorithm of your choice. Initialization Vector (iv) is expected to be present in the encrypted payload at the beginning.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ some_payload | decrypt: 'aes-256-cbc', 'ThisPasswordIsReallyHardToGuessA' }} =\u003e decrypted string from payload\n{{ \"43553EEDD9BFE36D10F99E931245CF8826903C00D235DFD300B3CC40BD263A621FC2FB9F5C3743F75D399A912AFABF92371927C6D190E0EFF19EAE9802320391FED79D92009796403EC6B426E901AB981CE53A43557C295F3D6FC9678EE0557F\" | decrypt: 'aes-128-ecb', '4FCD5FAE2AC493C0F8CE8E1E6105D194', true, true, true }} =\u003e iframe=true;customer.firstName=John;customer.lastName=Smith;header.accountNumber=6759370;header.authToken1=12345;header.paymentTypeCode=UTILITY;header.amount=123.45\n\nAES-256-GCM (authenticated decryption, payload format: IV + ciphertext + auth_tag):\n{% assign key = 'ThisIsA32ByteKeyForAES256GCM!!!!' %}\n{% assign encrypted = 'foo bar' | encrypt: 'aes-256-gcm', key %}\n{{ encrypted | decrypt: 'aes-256-gcm', key }} =\u003e foo bar\n\nAES-128-GCM:\n{% assign encrypted = 'secret data' | encrypt: 'aes-128-gcm', '16ByteSecretKey!' %}\n{{ encrypted | decrypt: 'aes-128-gcm', '16ByteSecretKey!' }} =\u003e secret data\n\nchacha20-poly1305 (authenticated decryption):\n{% assign encrypted = 'baz qux' | encrypt: 'chacha20-poly1305', '32ByteKeyForChaCha20Poly1305!!!!' %}\n{{ encrypted | decrypt: 'chacha20-poly1305', '32ByteKeyForChaCha20Poly1305!!!!' }} =\u003e baz qux\n\nAES-256-GCM with hex key:\n{% assign decrypted = encrypted | decrypt: 'aes-256-gcm', hex_key, transform_key_to_hex: true, from_hex: true %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | deep_clone","platformOS":true,"name":"deep_clone","aliases":[],"parameters":[{"description":"object to be duplicated","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"untyped","name":"","description":"returns a copy of the object parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign some_hash_copy = some_hash | deep_clone %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"default","deprecated":false,"deprecation_reason":"","description":"","parameters":[{"description":"Whether to use false values instead of the default.","name":"allow_false","required":false,"types":["boolean"]}],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.selected_variant.url | default: product.url }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"allow_false","description":"By default, the `default` filter's value will be used in place of `false` values. You can use the `allow_false` parameter to allow variables to return `false` instead of the default value.\n","syntax":"variable | default: variable, allow_false: boolean","path":"/products/health-potion","raw_liquid":"{%- assign display_price = false -%}\n\n{{ display_price | default: true, allow_false: true }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Sets a default value for any variable whose value is one of the following:\n\n- [`empty`](/docs/api/liquid/basics#empty)\n- [`false`](/docs/api/liquid/basics#truthy-and-falsy)\n- [`nil`](/docs/api/liquid/basics#nil)","syntax":"variable | default: variable","name":"default"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | digest","platformOS":true,"name":"digest","aliases":[],"parameters":[{"description":"message that you want to obtain a cryptographic hash for","name":"object","required":false,"types":["String"]},{"description":"the hash algorithm to use. Choose from: 'md5', 'sha1', 'sha256', 'sha384', 'sha512'. Default is sha1.","name":"algorithm","required":false,"types":["String"]},{"description":"defaults to hex. Supported digest values are hex, none, base64","name":"digest","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"hexadecimal hash value obtained by applying the selected algorithm to the message","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | digest }} =\u003e '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'\n{{ 'foo' | digest: 'sha256' }} =\u003e '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'\n{{ 'foo' | digest: 'sha256', 'base64' }} =\u003e 'LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564='\n{{ 'foo' | digest: 'sha256', 'none' }} =\u003e ',\u0026\\xB4kh\\xFF\\xC6\\x8F\\xF9\\x9BE\u003c\\x1D0A4\\x13B-pd\\x83\\xBF\\xA0\\xF9\\x8A^\\x88bf\\xE7\\xAE'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 4 | divided_by: 2 }}\n\n# divisor is an integer\n{{ 20 | divided_by: 7 }}\n\n# divisor is a float \n{{ 20 | divided_by: 7.0 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float.","syntax":"number | divided_by: number","name":"divided_by"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.title | downcase }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts a string to all lowercase characters.","syntax":"string | downcase","name":"downcase"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | download_file","platformOS":true,"name":"download_file","aliases":[],"parameters":[{"description":"url to a remote file","name":"url","required":false,"types":["String"]},{"description":"max file size of the file, default 1 megabyte. Can't exceed 50 megabytes.","name":"max_size","required":false,"types":["Number"]}],"return_type":[{"type":"string","name":"","description":"Body of the remote file","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'http://www.example.com/my_file.txt' | download_file }} =\u003e \"Content of a file\"\n{% assign data = 'https://example.com/data.json' | download_file | parse_json %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"The filter returns a string with the encoding changed to the one specified by the 'destination_encoding' parameter. The filter changes the string itself (its representation in memory) by first determining which graphical characters the underlying bytes in the string represent in the 'source_encoding', and then changing the bytes to encode the same graphical characters in 'destination_encoding'.","summary":"returns ","syntax":"string | encode","platformOS":true,"name":"encode","aliases":[],"parameters":[{"description":"input string that we want to reencode","name":"text","required":false,"types":["String"]},{"description":"the encoding of the source string text","name":"source_encoding","required":false,"types":["String"]},{"description":"the encoding we want the source string text converted to","name":"destination_encoding","required":false,"types":["String"]},{"description":"if set to 'replace', invalid byte sequences for the source_encoding will be replaced with the replace parameter","name":"invalid","required":false,"types":["String"]},{"description":"if set to 'replace', characters which do not exist in the destination encoding will be replaced by the 'replace' parameter","name":"undefined","required":false,"types":["String"]},{"description":"used together with the invalid and undefined parameters","name":"replace","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"input string with encoding modified from source_encoding to destination_encoding","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | encode: \"ISO-8859-1\", 'UTF-8', invalid: 'replace', undefined: 'replace', replace: '??' }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"The filter returns the encoding of the string parameter (i.e. how the underlying bytes in the string are interpreted to determine which graphical characters they encode).","summary":"returns ","syntax":"string | encoding","platformOS":true,"name":"encoding","aliases":[],"parameters":[{"description":"input string whose encoding we want to find","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"encoding of the string text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | encoding }} =\u003e 'UTF-8'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Filter allowing to encrypt data with specified algorithm. See decrypt filter for decryption","summary":"returns ","syntax":"string | encrypt","platformOS":true,"name":"encrypt","aliases":[],"parameters":[{"description":"string payload to be encrypted","name":"payload","required":false,"types":["String"]},{"description":"algorithm you want to use for encryption","name":"algorithm","required":false,"types":["String"]},{"description":"a key used for encryption. Key must match the algorithm requirments. For asymmetric algorithms there are public and private keys that need to passed in PEM format.","name":"key","required":false,"types":["String"]},{"description":"- initialization vector, if not provided we will automatically generate one","name":"iv","required":false,"types":["optional"]},{"description":"- a Hash with additional options to control the encrypt algorithm","name":"options","required":false,"types":null}],"return_type":[{"type":"string","name":"","description":"Base64 encoded (RFC 4648) (or HEX if return_hex is true) encrypted string using the algorithm of your choice. Initialization Vector (iv) will be appended","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture payload %}\n {\n \"key\": \"value\",\n \"another_key\": \"another value\"\n }\n{% endcapture %}\n{{ payload | encrypt: 'aes-256-cbc', 'ThisPasswordIsReallyHardToGuessA' }} =\u003e Kkuo2eWEnTbcrtbGjAmQVMTjptS5elsgqQe-5blHpUR-ziHPI45n2wOnY30DVZGldCTNqMT_Ml0ZFiGiupKGD4ZWxVIMkdCHaq4XgiAIUew=\n{{ \"step=2;header.amount=10;header.paymentTypeCode=ONLINE;customer.firstName=John\" | encrypt: 'aes-128-ecb', '4C6C821832AAFFF2749852CEED2FE74F', null, true, true, 32 }} =\u003e 43553EEDD9BFE36D10F99E931245CF8826903C00D235DFD300B3CC40BD263A621FC2FB9F5C3743F75D399A912AFABF92371927C6D190E0EFF19EAE9802320391FED79D92009796403EC6B426E901AB981CE53A43557C295F3D6FC9678EE0557F\n\nAES-256-GCM (authenticated encryption):\n{% assign key = 'ThisIsA32ByteKeyForAES256GCM!!!!' %}\n{% assign encrypted = 'foo bar' | encrypt: 'aes-256-gcm', key %}\n{{ encrypted }} =\u003e Base64 encoded string containing IV + ciphertext + auth_tag\n\nAES-128-GCM:\n{% assign encrypted = 'secret data' | encrypt: 'aes-128-gcm', '16ByteSecretKey!' %}\n\nchacha20-poly1305 (authenticated encryption):\n{% assign encrypted = 'sensitive payload' | encrypt: 'chacha20-poly1305', '32ByteKeyForChaCha20Poly1305!!!!' %}\n\nAES-256-GCM with hex key:\n{% assign encrypted = 'foo bar' | encrypt: 'aes-256-gcm', hex_key, null, transform_key_to_hex: true, return_hex: true %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Check if string ends with given substring(s)","summary":"returns ","syntax":"string | end_with","platformOS":true,"name":"end_with","aliases":[],"parameters":[{"description":"string to check ends with any of the provided suffixes","name":"string","required":false,"types":["String"]},{"description":"suffix to check","name":"suffixes","required":false,"types":["String","Array"]}],"return_type":[{"type":"boolean","name":"","description":"true if string ends with a suffixes","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | end_with: 'example' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | end_with: 'my' } =\u003e false","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign suffixes = '[\"array\", \"example\"]' | parse_json %}\n{{ 'my_example' | end_with: suffixes } =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ '\u003cp\u003eText to be escaped.\u003c/p\u003e' | escape }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Escapes special characters in HTML, such as `\u003c\u003e`, `'`, and `\u0026`, and converts characters into escape sequences. The filter doesn't effect characters within the string that don’t have a corresponding escape sequence.\".","syntax":"string | escape","name":"escape"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | escape_javascript","platformOS":true,"name":"escape_javascript","aliases":[],"parameters":[{"description":"text to be escaped","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"escaped text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture js %}\nvar msg = 'hello world';\nfunction yell(x) {\n if (!x) { return; }\n return x + \"!!!\";\n}\nyell(msg).\n{% endcapture %}\n\n{{ js | escape_javascript }}\n=\u003e \\nvar msg = \\'hello world\\';\\nfunction yell(x) {\\n if (!x) { return; }\\n return x + \\\"!!!\\\";\\n}\\nyell(msg).\\n","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Creates url based on provided named parameters to the template","summary":"returns ","syntax":"string | expand_url_template","platformOS":true,"name":"expand_url_template","aliases":[],"parameters":[{"description":"URL template. Read more at https://tools.ietf.org/html/rfc6570","name":"template","required":false,"types":["String"]},{"description":"hash with data injected into template","name":"params","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"expanded URL","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search/{city}/{street}\" %}\n{{ template | expand_url_template: city: \"Sydney\", street: \"BlueRoad\" }}\n=\u003e /search/Sydney/BlueRoad","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search{?city,street}\" %}\n{{ template | expand_url_template: city: \"Sydney\", street: \"BlueRoad\" }}\n=\u003e /search?city=Sydney\u0026street=BlueRoad","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Extracts named parameters from the url","summary":"returns ","syntax":"string | extract_url_params","platformOS":true,"name":"extract_url_params","aliases":[],"parameters":[{"description":"URL with params to extract","name":"url","required":false,"types":["String"]},{"description":"URL template, works also with array of templates. Read more at https://tools.ietf.org/html/rfc6570","name":"templates","required":false,"types":["String","Array"]}],"return_type":[{"type":"hash","name":"","description":"hash with extracted params","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search/{city}/{street}\" %}\n{{ \"/search/Sydney/BlueRoad\" | extract_url_params: template }} =\u003e {\"city\":\"Sydney\",\"street\":\"BlueRoad\"}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/{first}/{-list|\\/|second}\" %}\n{{ \"/a/b/c/\" | extract_url_params: template }} =\u003e {\"first\":\"a\",\"second\":[\"b\", \"c\"]}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/{first}/{second}{?limit,offset}\" %}\n{{ \"/my/path?limit=10\u0026offset=0\" | extract_url_params: template }} =\u003e {\"first\":\"my\",\"second\":\"path\",\"limit\":\"10\",\"offset\":\"0\"}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"/search/-list|+|query\" %}\n{{ \"/search/this+is+my+query\" | extract_url_params: template }} =\u003e {\"query\":[\"this\",\"is\",\"my\",\"query\"]}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign template = \"{+location}/listings\" %}\n{{ \"/Warsaw/Poland/listings\" | extract_url_params: template }} =\u003e {\"location\":\"/Warsaw/Poland\"}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{%- assign first_product = collection.products | first -%}\n\n{{ first_product.title }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Dot notation","description":"You can use the `first` filter with dot notation when you need to use it inside a tag or object output.\n","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.products.first.title }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the first item in an array.","syntax":"array | first","name":"first"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 1.2 | floor }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Rounds a number down to the nearest integer.","syntax":"number | floor","name":"floor"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"The filter returns a string with the encoding changed to the one specified by the 'encoding' parameter. The filter does not change the string itself (its representation in memory) but changes how the underlying bytes in the string are interpreted to determine which characters they encode","summary":"returns ","syntax":"string | force_encoding","platformOS":true,"name":"force_encoding","aliases":[],"parameters":[{"description":"input string whose encoding we want modified","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"input string with encoding modified to the one specified by the encoding parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | force_encoding: \"ISO-8859-1\" }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | format_number","platformOS":true,"name":"format_number","aliases":[],"parameters":[{"description":"string (numberlike), integer or float to format","name":"number","required":false,"types":["Untyped"]},{"description":"formatting options","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"formatted number","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 111.2345 | format_number }} # =\u003e 111.235","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 111.2345 | format_number: precision: 2 }} # =\u003e 111.23","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 111 | format_number: precision: 2 }} # =\u003e 111.00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1111.2345 | format_number: precision: 2, separator: ',', delimiter: '.' }} # =\u003e 1.111,23","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Converts currency in fractional to whole amount. For example, convert cents to USD.","summary":"returns ","syntax":"integer | fractional_to_amount","platformOS":true,"name":"fractional_to_amount","aliases":[],"parameters":[{"description":"fractional amount","name":"amount","required":false,"types":["Integer","String"]},{"description":"currency to be used","name":"currency","required":false,"types":["String"]}],"return_type":[{"type":"number","name":"","description":"converted fractional amount","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10.50 | fractional_to_amount: 'USD' }} =\u003e 10","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1050 | fractional_to_amount: 'JPY' }} =\u003e 1050","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Gzip compress a string","summary":"returns ","syntax":"string | gzip_compress","platformOS":true,"name":"gzip_compress","aliases":[],"parameters":[{"description":"string to be compressed\n{% assign result = \"Lorem ipsum\" | gzip_compress | gzip_decompress %}\n{{ result }} =\u003e \"Lorem ipsum\"","name":"uncompressed_string","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Gzip-compressed string","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Gzip decompress a string","summary":"returns ","syntax":"string | gzip_decompress","platformOS":true,"name":"gzip_decompress","aliases":[],"parameters":[{"description":"string to be decompressed\n{% assign result = \"Lorem ipsum\" | gzip_compress | gzip_decompress %}\n{{ result }} =\u003e \"Lorem ipsum\"","name":"compressed_string","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Decompressed string","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_add_key","platformOS":true,"name":"hash_add_key","aliases":["add_hash_key","assign_to_hash_key"],"parameters":[{"description":"","name":"hash","required":false,"types":["Hash"]},{"description":"","name":"key","required":false,"types":["String"]},{"description":"","name":"value","required":false,"types":["Untyped"]}],"return_type":[{"type":"hash","name":"","description":"hash with added key","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign accountants = \"Angela,Kevin,Oscar\" | split: \",\"\n assign management = \"David,Jan,Michael\" | split: \",\"\n assign company = {}\n assign company = company | hash_add_key: \"name\", \"Dunder Mifflin\"\n assign company = company | hash_add_key: \"accountants\", accountants\n assign company = company | hash_add_key: \"management\", management\n%}\n{{ company }} =\u003e {\"name\" =\u003e \"Dunder Mifflin\", \"accountants\" =\u003e [\"Angela\", \"Kevin\", \"Oscar\"], \"management\" =\u003e [\"David\", \"Jan\", \"Michael\"]}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_delete_key","platformOS":true,"name":"hash_delete_key","aliases":["delete_hash_key","remove_hash_key"],"parameters":[{"description":"","name":"hash","required":false,"types":["Hash"]},{"description":"","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"untyped","name":"","description":"value which was assigned to a deleted key. If the key did not exist in the first place, null is returned.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign hash = '{ \"a\": \"1\", \"b\": \"2\"}' | parse_json\n assign a_value = hash | hash_delete_key: \"a\"\n%}\n{{ a_value }} =\u003e \"1\"\n{{ hash }} =\u003e { \"b\": \"2\" }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Generates a list of additions (+), deletions (-) and changes (~) from given two ojects.","summary":"returns ","syntax":"variable | hash_diff","platformOS":true,"name":"hash_diff","aliases":[],"parameters":[{"description":"","name":"hash1","required":false,"types":["Hash"]},{"description":"","name":"hash2","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"array containg the difference between two hashes","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign a = '{ \"a\": 1, \"c\": \"5 \", \"d\": 6, \"e\": { \"f\": 5.12 } }' | parse_json\n assign b = '{ \"b\": 2, \"c\": \"5\", \"d\": 5, \"e\": { \"f\": 5.13 } }' | parse_json\n%}\n{{ a | hash_diff: b }} =\u003e [[\"-\",\"a\",1],[\"~\",\"c\",\"5 \",\"5\"],[\"~\",\"d\",6,5],[\"~\",\"e.f\",5.12,5.13],[\"+\",\"b\",2]]\n{{ a | hash_diff: b, strip: true }} =\u003e [[\"-\",\"a\",1],[\"~\",\"d\",6,5],[\"~\",\"e.f\",5.12,5.13],[\"+\",\"b\",2]]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_dig","platformOS":true,"name":"hash_dig","aliases":["dig"],"parameters":[{"description":"","name":"hash","required":false,"types":["Hash"]},{"description":"comma separated sequence of string keys to dig down the hash","name":"keys","required":false,"types":["Array"]}],"return_type":[{"type":"untyped","name":"","description":"Extracted nested value specified by the sequence of keys by calling dig at each step,\nreturning null if any intermediate step is null.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json user_json %}\n{\n \"name\": {\n \"first\": \"John\"\n \"last\": \"Doe\"\n }\n}\n{% endparse_json %}\n{{ user_json | hash_dig: \"name\", \"first\" }} =\u003e \"John\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_except","platformOS":true,"name":"hash_except","aliases":[],"parameters":[{"description":"input hash","name":"hash","required":false,"types":["Hash"]},{"description":"array of keys that should be removed","name":"except","required":false,"types":["Array"]}],"return_type":[{"type":"hash","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign data = null | hash_merge: foo: 'fooval', bar: 'barval', baz: 'bazval'\n%}\n{% assign exc = 'foo,baz' | split: ',' %}\n{{ data | except: exc }} =\u003e { \"foo\": \"fooval\" }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"","summary":"returns ","syntax":"variable | hash_fetch","platformOS":true,"name":"hash_fetch","aliases":["fetch"],"parameters":[{"description":"input hash to be traversed","name":"hash","required":false,"types":["Hash"]},{"description":"key to be fetched from hash branch","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json users %}\n[{\n \"name\": \"Jane\"\n}, {\n \"name\": \"Bob\"\n}]\n{% endparse_json %}\n{{ users | first | hash_fetch: \"name\" }} =\u003e \"Jane\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_keys","platformOS":true,"name":"hash_keys","aliases":[],"parameters":[{"description":"input hash","name":"hash","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign data = null | hash_merge: foo: 'fooval', bar: 'barval'\n%}\n\n{{ data | hash_keys }} =\u003e [\"foo\", \"bar\"]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_merge","platformOS":true,"name":"hash_merge","aliases":[],"parameters":[{"description":"","name":"hash1","required":false,"types":["Hash"]},{"description":"","name":"hash2","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"new hash containing the contents of hash1 and the contents of hash2.\nOn duplicated keys we keep value from hash2","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign a = '{\"a\": 1, \"b\": 2 }' | parse_json\n assign b = '{\"b\": 3, \"c\": 4 }' | parse_json\n assign new_hash = a | hash_merge: b\n%}\n{{ new_hash }} =\u003e { \"a\": 1, \"b\": 3, \"c\": 4 }","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign a = '{\"a\": 1}' | parse_json\n assing a = a | hash_merge: b: 2, c: 3\n %}\n{{ a }} =\u003e { \"a\": 1, \"b\": 2, \"c\": 3 }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_sort","platformOS":true,"name":"hash_sort","aliases":[],"parameters":[{"description":"Hash to be sorted","name":"input","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"Sorted hash","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign hash1 = '{\"key2\": \"value2\", \"key1\": \"value1\"}' | parse_json %}\n{{ hash1 | hash_sort }} =\u003e {\"key1\": \"value1\", \"key2\": \"value2\"}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign hash1 = '{\"a\": 1, \"c\": 2, \"b\": 3}' | parse_json %}\n{{ hash1 | hash_sort }} =\u003e {\"a\": 1, \"b\": 3, \"c\": 2}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hash_values","platformOS":true,"name":"hash_values","aliases":[],"parameters":[{"description":"input hash","name":"hash","required":false,"types":["Hash"]}],"return_type":[{"type":"array","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign data = null | hash_merge: foo: 'fooval', bar: 'barval'\n%}\n\n{{ data | hash_values }} =\u003e [\"fooval\", \"barval\"]","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | hcaptcha","platformOS":true,"name":"hcaptcha","aliases":[],"parameters":[{"description":"params sent to the server","name":"params","required":false,"types":["Hash"]}],"return_type":[{"type":"boolean","name":"","description":"whether the parameters are valid hcaptcha verification parameters","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ context.params | hcaptcha }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | html_safe","platformOS":true,"name":"html_safe","aliases":[],"parameters":[{"description":"","name":"text","required":false,"types":["String"]},{"description":"set raw_text to true to stop it from unescaping HTML entities","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"string that can be rendered with all HTML tags, by default all variables are escaped.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003ch1\u003eHello\u003c/h1\u003e' }} =\u003e '\u0026lt;h1\u0026gt;Hello\u0026lt;/h1\u003e\u0026gt;'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003ch1\u003eHello\u003c/h1\u003e' | html_safe }} =\u003e '\u003ch1\u003eHello\u003c/h1\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003cscript\u003ealert(\"Hello\")\u003c/script\u003e' }} =\u003e \u003cscript\u003ealert(\"Hello\")\u003c/script\u003e - this will just print text in the source code of the page","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003cscript\u003ealert(\"Hello\")\u003c/script\u003e' | html_safe }} =\u003e \u003cscript\u003ealert(\"Hello\")\u003c/script\u003e - this script will be evaluated when a user enters the page","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'abc \u0026quot; def' | html_safe: raw_text: true }} =\u003e 'abc \u0026quot; def'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'abc \u0026quot; def' | html_safe: raw_text: false }} =\u003e 'abc \" def'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | html_to_text","platformOS":true,"name":"html_to_text","aliases":[],"parameters":[{"description":"html to be converted to text","name":"html","required":false,"types":["String"]},{"description":"optional. Default root_element: null, remove_nodes: 'script, link, style', replace_newline: ' '","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"text without any html tags","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003ch1\u003eHello \u003ca href=\"#\"\u003eworld\u003c/a\u003e\u003c/h1\u003e' }} =\u003e 'Hello world'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | humanize","platformOS":true,"name":"humanize","aliases":[],"parameters":[{"description":"input string to be transformed","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"a human readable string derived from the input; capitalizes the first word, turns\nunderscores into spaces, and strips a trailing '_id' if present. Used for creating a formatted output (e.g. by replacing underscores with spaces, capitalizing the first word, etc.).","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'car_model' | humanize }} =\u003e 'Car model'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'customer_id' | humanize }} =\u003e 'Customer'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_date_before","platformOS":true,"name":"is_date_before","aliases":["date_before"],"parameters":[{"description":"time to compare to the second parameter","name":"first_time","required":false,"types":["String","Integer","Date","Time"]},{"description":"time against which the first parameter is compared to","name":"second_time","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"boolean","name":"","description":"returns true if the first time is lower than the second time","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-02' | date_before: '2010-01-03' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '6 months ago' | date_before: '2010-01-03' }} =\u003e false","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1 day ago' | date_before: 'now' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_date_in_past","platformOS":true,"name":"is_date_in_past","aliases":[],"parameters":[{"description":"time object, can also be a string","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"boolean","name":"","description":"true if time passed is in the past, false otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01' | is_date_in_past }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '3000-01-01' | is_date_in_past }} =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_email_valid","platformOS":true,"name":"is_email_valid","aliases":[],"parameters":[{"description":"String containing potentially valid email","name":"email","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether or not the argument is a valid email","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign valid = 'john@example.com' | is_email_valid %}\nvalid =\u003e true\n\n{% assign valid = 'john@' | is_email_valid %}\nvalid =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Checks if a given string is a valid GPG key","summary":"returns ","syntax":"string | is_gpg_valid","platformOS":true,"name":"is_gpg_valid","aliases":[],"parameters":[{"description":"key to be checked for validity\n{% assign valid_gpg = \"...\" | is_gpg_valid %}\n{{ result }} =\u003e false","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether the input value is a valid GPG key or not","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | is_json_valid","platformOS":true,"name":"is_json_valid","aliases":[],"parameters":[{"description":"String containing potentially valid JSON","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether or not the argument is a valid JSON","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign valid = '{ \"name\": \"foo\", \"bar\": {} }' | is_json_valid %}\nvalid =\u003e true\n\n{% assign valid = '{ \"foo\" }' | is_json_valid %}\nvalid =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | is_parsable_date","platformOS":true,"name":"is_parsable_date","aliases":[],"parameters":[{"description":"object that can be a date","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"boolean","name":"","description":"whether the parameter can be parsed as a date","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2021/2' | is_parsable_date }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Temporary token is valid for desired number of hours (by default 48), which you can use to authorize the user in third party application. To do it, include it in a header with name UserTemporaryToken. Token will be invalidated on password change.","summary":"returns ","syntax":"string | is_token_valid","platformOS":true,"name":"is_token_valid","aliases":[],"parameters":[{"description":"encrypted token generated via the temporary_token GraphQL property","name":"token","required":false,"types":["String"]},{"description":"id of the user who generated the token","name":"user_id","required":false,"types":["Number"]}],"return_type":[{"type":"boolean","name":"","description":"returns true if the token has not expired and was generated for the given user, false otherwise","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% token = '1234' %}\n{{ token | is_token_valid: context.current_user.id }} =\u003e false","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"{{ collection.all_tags | join }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Custom separator","description":"You can specify a custom separator for the joined items.\n","syntax":"array | join: string","path":"/collections/sale-potions","raw_liquid":"{{ collection.all_tags | join: ', ' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Combines all of the items in an array into a single string, separated by a space.","syntax":"array | join","name":"join"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | json","platformOS":true,"name":"json","aliases":["to_json"],"parameters":[{"description":"object you want a JSON representation of","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"string","name":"","description":"JSON formatted string containing a representation of object.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ user | json }} =\u003e {\"name\":\"Mike\",\"email\":\"mike@mail.com\"}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | jwe_encode","platformOS":true,"name":"jwe_encode","aliases":["jwe_encode_rc"],"parameters":[{"description":"JSON body string that will be encypted","name":"json","required":false,"types":["String"]},{"description":"Public key","name":"key","required":false,"types":["String"]},{"description":"- Key Management Algorithm used to encrypt or determine the value of the Content Encryption Key.\nValid options:\n Single Asymmetric Public/Private Key Pair\n RSA1_5\n RSA-OAEP\n RSA-OAEP-256\n Two Asymmetric Public/Private Key Pairs with Key Agreement\n ECDH-ES\n ECDH-ES+A128KW\n ECDH-ES+A192KW\n ECDH-ES+A256KW\n Symmetric Password Based Key Derivation\n PBES2-HS256+A128KW\n PBES2-HS384+A192KW\n PBES2-HS512+A256KW\n Symmetric Key Wrap\n A128GCMKW\n A192GCMKW\n A256GCMKW\n A128KW\n A192KW\n A256KW\n Symmetric Direct Key (known to both sides)\n dir","name":"alg","required":false,"types":["required"]},{"description":"- Encryption Algorithm used to perform authenticated encryption on the plain text using the Content Encryption Key.\nValid options:\n A128CBC-HS256\n A192CBC-HS384\n A256CBC-HS512\n A128GCM\n A192GCM\n A256GCM","name":"enc","required":false,"types":["required"]}],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | jwt_decode","platformOS":true,"name":"jwt_decode","aliases":[],"parameters":[{"description":"encoded JWT token you want to decode","name":"encoded_token","required":false,"types":["String"]},{"description":"the algorithm that was used to encode the token","name":"algorithm","required":false,"types":["String"]},{"description":"either a shared secret or a PUBLIC key for RSA","name":"secret","required":false,"types":["String"]},{"description":"default true, for testing and debugging can remove verifying the signature","name":"verify_signature","required":false,"types":["Boolean"]},{"description":"JWK is a structure representing a cryptographic key. Currently only supports RSA public keys.\nValid options:\n none - unsigned token\n HS256 - SHA-256 hash algorithm\n HS384 - SHA-384 hash algorithm\n HS512 - SHA-512 hash algorithm\n RS256 - RSA using SHA-256 hash algorithm\n RS384 - RSA using SHA-384 hash algorithm\n RS512 - RSA using SHA-512 hash algorithm","name":"jwks","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"result of decoding JWT token","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign original_payload = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJ2YWx1ZSIsImFub3RoZXJfa2V5IjoiYW5vdGhlciB2YWx1ZSJ9.XT8sHXyPTA9DoHzssXh1q6Uv2D1ENosW0F3Ixle85L0' | jwt_decode: 'HS256', 'this-is-secret' %} =\u003e\n[\n {\n \"key\" =\u003e \"value\",\n \"another_key\" =\u003e \"another value\"\n },\n {\n \"typ\" =\u003e \"JWT\",\n \"alg\" =\u003e \"HS256\"\n }\n]","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"RSA:\n{% capture public_key %}\n-----BEGIN PUBLIC KEY-----\nMIIBI...\n-----END PUBLIC KEY-----\n{% endcapture %}\n{% assign original_payload = 'some encoded token' | jwt_decode: 'RS256', public_key %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | jwt_encode","platformOS":true,"name":"jwt_encode","aliases":[],"parameters":[{"description":"payload or message you want to encrypt","name":"payload","required":false,"types":["Hash"]},{"description":"algorithm you want to use for encryption","name":"algorithm","required":false,"types":["String"]},{"description":"either a shared secret or a private key for RSA","name":"secret","required":false,"types":["String"]},{"description":"optional hash of custom headers to be added to default { \"typ\": \"JWT\", \"alg\": \"[algorithm]\" }","name":"header_fields","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"JWT token encrypted using the algorithm of your choice","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json payload %}\n{\n \"key\": \"value\",\n \"another_key\": \"another value\"\n}\n{% endparse_json %}\n\n{{ payload | jwt_encode: 'HS256', 'this-is-secret' }} =\u003e 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJ2YWx1ZSIsImFub3RoZXJfa2V5IjoiYW5vdGhlciB2YWx1ZSJ9.XT8sHXyPTA9DoHzssXh1q6Uv2D1ENosW0F3Ixle85L0'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% parse_json payload %}\n{\n \"key\": \"value\",\n \"another_key\": \"another value\"\n}\n{% endparse_json %}\n\n{% parse_json headers %}\n{\n \"cty\": \"custom\"\n}\n{% endparse_json %}\n\n{{ payload | jwt_encode: 'HS256', 'this-is-secret', headers }} =\u003e 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImN0eSI6ImN1c3RvbSJ9.eyJrZXkiOiJ2YWx1ZSIsImFub3RoZXJfa2V5IjoiYW5vdGhlciB2YWx1ZSJ9.5_-LcqcbLMeswMw04UfXqDyqAEk1x-Pwi9nwGMqxHtQ'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"RSA:\n{% capture private_key %}\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpA...\n-----END RSA PRIVATE KEY-----\n{% endcapture %}\n{% assign jwt_token = payload | jwt_encode: 'RS256', private_key %}\n{% comment %} Please note that storing private key as a plain text in a code is not a good idea. We suggest you\n provide the key via Partner Portal and use context.constants.\u003cname of private key constant\u003e instead.{% endcomment %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"untyped","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{%- assign last_product = collection.products | last -%}\n\n{{ last_product.title }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Dot notation","description":"You can use the `last` filter with dot notation when you need to use it inside a tag or object output.\n","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.products.last.title }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the last item in an array.","syntax":"array | last","name":"last"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | localize","platformOS":true,"name":"localize","aliases":["l"],"parameters":[{"description":"parsable time object to be formatted","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"the format to be used for formatting the time; default is 'long'; other values can be used:\nthey are taken from translations, keys are of the form 'time.formats.#!{format_name}'","name":"format","required":false,"types":["String"]},{"description":"the time zone to be used for time","name":"zone","required":false,"types":["String"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"string, nil","name":"","description":"formatted representation of the passed parsable time","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01' | localize }} =\u003e 'January 01, 2010'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'in 14 days' | localize: 'long', '', '2011-03-15' }} =\u003e 'March 14, 2011'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{%- assign text = ' Some potions create whitespace. ' -%}\n\n\"{{ text }}\"\n\"{{ text | lstrip }}\"","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all whitespace from the left of a string.","syntax":"string | lstrip","name":"lstrip"},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"{%- assign product_titles = collection.products | map: 'title' -%}\n\n{{ product_titles | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Creates an array of values from a specific property of the items in an array.","syntax":"array | map: string","name":"map"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | map","platformOS":true,"name":"map","aliases":[],"parameters":[{"description":"array of Hash to be processed. Nulls are skipped.","name":"object","required":false,"types":["Array"]},{"description":"name of the hash key for which all values should be returned\nin array of objects","name":"key","required":false,"types":["String"]}],"return_type":[{"type":"array","name":"","description":"array which includes all values for a given key","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign objects = '[{\"id\":1,\"name\":\"foo\",\"label\":\"Foo\"},{\"id\":2,\"name\":\"bar\",\"label\":\"Bar\"}]' | parse_json %}\n{{ objects | map: 'name' }} =\u003e ['foo', 'bar']","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | markdown","platformOS":true,"name":"markdown","aliases":["markdownify"],"parameters":[{"description":"text using markdown syntax","name":"text","required":false,"types":["String"]},{"description":"string representing a JSON object with options for the sanitizer","name":"options","required":false,"types":["String"]},{"description":"string representing a JSON object with additional options for the sanitizer which can override the options param; pass nil for the options param to use the default options which extra_options could then override","name":"extra_options","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"processed text with markdown syntax changed to sanitized HTML.\nWe allow only safe tags and attributes by default. We also automatically add `rel=nofollow` to links. Default configuration is:\n{\n \"elements\": [\"a\",\"abbr\",\"b\",\"blockquote\",\"br\",\"cite\",\"code\",\"dd\",\"dfn\",\"dl\",\"dt\",\"em\",\"i\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"img\",\"kbd\",\"li\",\"mark\",\"ol\",\"p\",\"pre\",\"q\",\"s\",\"samp\",\"small\",\"strike\",\"strong\",\"sub\",\"sup\",\"time\",\"u\",\"ul\",\"var\"],\n \"attributes\":{\n \"a\": [\"href\"],\n \"abbr\":[\"title\"],\n \"blockquote\":[\"cite\"],\n \"img\":[\"align\",\"alt\",\"border\",\"height\",\"src\",\"srcset\",\"width\"],\n \"dfn\":[\"title\"],\n \"q\":[\"cite\"],\n \"time\":[\"datetime\",\"pubdate\"]\n },\n \"add_attributes\": { \"a\" : {\"rel\":\"nofollow\"} },\n \"protocols\": {\n \"a\":{\"href\":[\"ftp\",\"http\",\"https\",\"mailto\",\"relative\"]},\n \"blockquote\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"q\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"img\": {\"src\": [\"http\",\"https\",\"relative\"] }\n }\n}","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '**Foo**' | markdown }} =\u003e '\u003cb\u003eFoo\u003c/b\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '**Foo**' | markdown }} =\u003e '\u003cb\u003eFoo\u003c/b\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '# Foo' | markdown }} =\u003e '\u003ch1\u003eFoo\u003c/h1\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Automatically add rel=nofollow to links\n{{ '[Foo link](https://example.com)' | markdown }} =\u003e '\u003cp\u003e\u003ca href=\"https://example.com\" rel=\"nofollow\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '\u003cb\u003eFoo\u003c/b\u003e' | markdown }} =\u003e '\u003cb\u003eFoo\u003c/b\u003e'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Tags not enabled by default are removed\n{{ '\u003cdiv class=\"hello\" style=\"font-color: red; font-size: 99px;\"\u003eFoo\u003c/div\u003e' | markdown }} =\u003e 'Foo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Attributes not enabled by default are removed\n{{ '\u003cdiv class=\"hello\" style=\"font-color: red; font-size: 99px;\"\u003eFoo\u003c/div\u003e' | markdown: '{ \"elements\": [ \"div\" ] }' }} =\u003e '\u003cdiv\u003eFoo\u003c/div\u003e' # @example","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Specify custom tags with attributes","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Using extra_options to override options\n{% assign link1 = '[Foo link](https://example.com)' | markdown: nil, '{ \"add_attributes\": { \"a\": { \"custom_attr\": \"custom_value\", \"rel\": null } } }' %}\n{{ link1 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\" custom_attr=\"custom_value\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e\n\n{% assign link2 = '[Foo link](https://example.com)' | markdown: nil, '{ \"add_attributes\": { \"a\": {} } }' %}\n{{ link2 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e\n\n{% assign link3 = '[Foo link](https://example.com)' | markdown: nil, '{ \"add_attributes\": { \"a\": { \"custom_attr\": \"custom_value\" } } }' %}\n{{ link3 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\" rel=\"nofollow\" custom_attr=\"custom_value\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e\n\n{% assign link4 = '[Foo link](https://example.com)' | markdown %}\n{{ link4 }} =\u003e \u003cp\u003e\u003ca href=\"https://example.com\" rel=\"nofollow\"\u003eFoo link\u003c/a\u003e\u003c/p\u003e","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | matches","platformOS":true,"name":"matches","aliases":[],"parameters":[{"description":"string to check against the regular expression","name":"text","required":false,"types":["String"]},{"description":"string representing a regular expression pattern against which\nto match the first parameter","name":"regexp","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"whether the given string matches the given regular expression; returns null if","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | matches: '[a-z]' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 4 | minus: 2 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Subtracts a given number from another number.","syntax":"number | minus: number","name":"minus"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 12 | modulo: 5 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns the remainder of dividing a number by a given number.","syntax":"number | modulo: number","name":"modulo"},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"","summary":"returns ","syntax":" | new_line_to_br","platformOS":true,"name":"new_line_to_br","aliases":["nl2br"],"parameters":[],"return_type":[],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.description | newline_to_br }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts newlines (`\\n`) in a string to HTML line breaks (`\u003cbr\u003e`).","syntax":"string | newline_to_br","name":"newline_to_br"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | pad_left","platformOS":true,"name":"pad_left","aliases":[],"parameters":[{"description":"string to pad","name":"str","required":false,"types":["String"]},{"description":"minimum length of output string","name":"count","required":false,"types":["Number"]},{"description":"string to pad with","name":"symbol","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"returns string padded from left to the length of count with the symbol character","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | pad_left: 5 }} =\u003e ' foo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'Y' | pad_left: 3, 'X' }} =\u003e 'XXY'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | parameterize","platformOS":true,"name":"parameterize","aliases":[],"parameters":[{"description":"input string to be 'parameterized'","name":"text","required":false,"types":["String"]},{"description":"string to be used as separator in the output string; default is '-'","name":"separator","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"replaces special characters in a string so that it may be used as part of a 'pretty' URL;\nthe default separator used is '-';","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | parameterize }} =\u003e 'john-arrived_foo'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | parse_csv","platformOS":true,"name":"parse_csv","aliases":["parse_csv_rc"],"parameters":[{"description":"CSV","name":"input","required":false,"types":["String"]},{"description":"parse csv options","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"array of arrays","name":"","description":"Array","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign csv = \"name,description\\nname-1,description-1\\nname-2,description-2\\n\"\n\n {{ csv | parse_csv }} =\u003e [['name', 'description'], ['name-1', 'description-1'], ['name-2', 'description-2']]\n {{ csv | parse_csv: convert_to_hash: true }} =\u003e [{name: 'name-1', description: 'description-1'}, {name: 'name-2', description: 'description-2'}]\n%}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | parse_json","platformOS":true,"name":"parse_json","aliases":["to_hash"],"parameters":[{"description":"String containing valid JSON","name":"object","required":false,"types":["Untyped"]},{"description":"set to raw_text true to stop it from unescaping HTML entities","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"Hash created based on JSON","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign text = '{ \"name\": \"foo\", \"bar\": {} }'\n assign object = text | parse_json\n%}\n{{ object.name }} =\u003e 'foo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '{ \"key\": \"abc \u0026quot; def\" }' | parse_json: raw_text: false }} =\u003e { \"key\": 'abc \" def' }","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '{ \"key\": \"abc \u0026quot; def\" }' | parse_json: raw_text: true }} =\u003e { \"key\": 'abc \u0026quot; def' }","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | parse_xml","platformOS":true,"name":"parse_xml","aliases":["xml_to_hash"],"parameters":[{"description":"String containing valid XML","name":"xml","required":false,"types":["String"]},{"description":"attr_prefix: use '@' for element attributes, force_array: always try to use arrays for child elements","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"hash","name":"","description":"Hash created based on XML","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign text = '\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cletter\u003e\u003ctitle maxlength=\"10\"\u003e Quote Letter \u003c/title\u003e\u003c/letter\u003e'\n assign object = text | parse_xml\n%}\n{{ object }} =\u003e '{\"letter\":[{\"title\":[{\"maxlength\":\"10\",\"content\":\" Quote Letter \"}]}]}'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Use either singular or plural version of a string, depending on provided count","summary":"returns ","syntax":"string | pluralize","platformOS":true,"name":"pluralize","aliases":[],"parameters":[{"description":"string to be pluralized","name":"string","required":false,"types":["String"]},{"description":"optional count number based on which string will be pluralized or singularized","name":"count","required":false,"types":["Number"]}],"return_type":[{"type":"string","name":"","description":"pluralized version of the input string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'dog' | pluralize: 1 }} =\u003e 'dog'\n{{ 'dog' | pluralize: 2 }} =\u003e 'dogs'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 2 | plus: 2 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Adds two numbers.","syntax":"number | plus: number","name":"plus"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{%- assign origin = request.origin -%}\n\n{{ product.url | prepend: origin }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Adds a given string to the beginning of a string.","syntax":"string | prepend: string","name":"prepend"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"numeric | pricify","platformOS":true,"name":"pricify","aliases":[],"parameters":[{"description":"amount to be formatted","name":"amount","required":false,"types":["Numeric","String"]},{"description":"currency to be used for formatting","name":"currency","required":false,"types":["String"]},{"description":"optional. Default no_cents_if_whole: true","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"formatted price using global price formatting rules","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 0 | pricify }} =\u003e $0","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify }} =\u003e $1","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1.20 | pricify }} =\u003e $1.20","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1000000 | pricify }} =\u003e $1,000,000","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify: \"PLN\" }} =\u003e 1 zł","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify: \"JPY\" }} =\u003e ¥1","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify: \"USD\", no_cents_if_whole: false }} =\u003e $1.00","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Adds currency symbol and proper commas. It is used to showing prices to people.","summary":"returns ","syntax":"numeric | pricify_cents","platformOS":true,"name":"pricify_cents","aliases":[],"parameters":[{"description":"amount in cents to be formatted","name":"amount","required":false,"types":["Numeric","String"]},{"description":"currency to be used for formatting","name":"currency","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"formatted price using the global price formatting rules","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify_cents }} =\u003e $0.01","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 100 | pricify_cents }} =\u003e $1","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1000000 | pricify_cents }} =\u003e $10,000","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify_cents: \"PLN\" }} =\u003e 0.01 zł","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 1 | pricify_cents: \"JPY\" }} =\u003e ¥1","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | querify","platformOS":true,"name":"querify","aliases":[],"parameters":[{"description":"hash to be \"querified\"","name":"hash","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"a query string","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ hash }} =\u003e { 'name' =\u003e 'Dan', 'id' =\u003e 1 }","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ hash | querify }} =\u003e 'name=Dan\u0026id=1'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"int | random_string","platformOS":true,"name":"random_string","aliases":[],"parameters":[{"description":"how many random characters should be included; default is 12","name":"length","required":false,"types":["Int"]}],"return_type":[{"type":"string","name":"","description":"returns a random alphanumeric string of given length","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 10 | random_string }} =\u003e '6a1ee2629'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | raw_escape_string","platformOS":true,"name":"raw_escape_string","aliases":[],"parameters":[{"description":"input string to be HTML-escaped","name":"value","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"HTML-escaped input string; returns a string with its HTML tags visible in\nthe browser","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo\u003cb\u003ebar\u003c/b\u003e' | raw_escape_string }} =\u003e 'foo\u0026amp;lt;b\u0026amp;gt;bar\u0026amp;lt;/b\u0026amp;gt;'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | regex_matches","platformOS":true,"name":"regex_matches","aliases":[],"parameters":[{"description":"","name":"text","required":false,"types":["String"]},{"description":"regexp to use for matching","name":"regexp","required":false,"types":["String"]},{"description":"can contain 'ixm'; i - ignore case, x - extended, m # - multiline (e.g. 'ix', 'm', 'mi' etc.)","name":"options","required":false,"types":["String"]}],"return_type":[{"type":"array","name":"","description":"matches for the expression in the string;\neach item in the array is an array containing all groups of matches; for example\nfor the regex (.)(.) and the text 'abcdef', the result will look like:\n[[\"a\", \"b\"], [\"c\", \"d\"], [\"e\", \"f\"]]","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"To retrieve the URL from a meta tag see the example below:\n\n{% liquid\n assign text = '\u003chtml\u003e\u003chead\u003e\u003cmeta property=\"og:image\" content=\"http://somehost.com/someimage.jpg\" /\u003e\u003c/head\u003e\u003cbody\u003econtent\u003c/body\u003e\u003c/html\u003e' | html_safe %}\n assign matches = text | regex_matches: '\u003cmeta\\s+property=\"og:image\"\\s+content=\"([^\"]+)\"'\n if matches.size \u003e 0\n assign image_path = matches[0][0]\n echo image_path\n endif\n%}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ \"I can't do it!\" | remove: \"'t\" }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Removes any instance of a substring inside a string.","syntax":"string | remove: string","name":"remove"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ \"I hate it when I accidentally spill my duplication potion accidentally!\" | remove_first: ' accidentally' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Removes the first instance of a substring inside a string.","syntax":"string | remove_first: string","name":"remove_first"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/komodo-dragon-scale","raw_liquid":"{{ product.handle | replace: '-', ' ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Replaces any instance of a substring inside a string with a given string.","syntax":"string | replace: string, string","name":"replace"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/komodo-dragon-scale","raw_liquid":"{{ product.handle | replace_first: '-', ' ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Replaces the first instance of a substring inside a string with a given string.","syntax":"string | replace_first: string, string","name":"replace_first"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | replace_regex","platformOS":true,"name":"replace_regex","aliases":[],"parameters":[{"description":"","name":"text","required":false,"types":["String"]},{"description":"regexp to use for matching","name":"regexp","required":false,"types":["String"]},{"description":"replacement text, or hash; if hash, keys in\nthe hash must be matched texts and values their replacements","name":"replacement","required":false,"types":["String"]},{"description":"can contain 'ixm'; i - ignore case, x - extended, m # - multiline (e.g. 'ix', 'm', 'mi' etc.)","name":"options","required":false,"types":["String"]},{"description":"whether all occurrences should be replaced or just the first","name":"global","required":false,"types":["Boolean"]}],"return_type":[{"type":"string","name":"","description":"string with regexp pattern replaced by replacement text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"Basic example:\n{{ \"fooooo fooo\" | replace_regex: 'o+', 'o' }} =\u003e \"fo fo\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Global set to false:\n{{ \"fooooo fooo\" | replace_regex: 'o+', 'o', '', false }} =\u003e \"fo fooo\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Hash replacement:\n{% liquid\n assign hash = {}\n assign hash = hash | hash_add_key: 'ooooo', 'bbbbb'\n assign hash = hash | hash_add_key: 'ooo', 'ccc'\n %}\n{{ \"fooooo fooo\" | replace_regex: 'o+', hash }} =\u003e \"fbbbbb fccc\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Using options, ignore case:\n{{ \"FOOOOO\" | replace_regex: 'o+', 'a', 'i' }} =\u003e \"Fa\"\n{{ \"FOOOOO\" | replace_regex: 'o+', 'a' }} =\u003e \"FOOOOO\"\nUsing options, extended mode (insert spaces, newlines, and comments in the pattern to make it more readable):\n{{ \"FOOOOO\" | replace_regex: 'o+ #comment', 'a', 'ix' }} =\u003e \"Fa\"\nUsing options, multiline (. matches newline):\n{% capture newLine %}\n{% endcapture %}\n{{ \"abc\" | append: newLine | append: \"def\" | append: newLine | append: \"ghi\" | replace_regex: '.+', 'a', 'im' }} =\u003e \"a\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"Matches group:\n{% assign array = \"item 1,item 2,item 3,item 4\" | split: \",\" %}\n{{ array | join: \", \" | replace_regex: \"([^,]+),([^,]+)$\", \"\\1, \u0026\\2\" }} =\u003e item 1, item 2, item 3, \u0026 item 4","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"Original order:\n{{ collection.products | map: 'title' | join: ', ' }}\n\nReverse order:\n{{ collection.products | reverse | map: 'title' | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Reversing strings","description":"You can't use the `reverse` filter on strings directly. However, you can use the [`split` filter](/docs/api/liquid/filters/split) to create an array of characters in the string, reverse that array, and then use the [`join` filter](/docs/api/liquid/filters/join) to combine them again.\n","syntax":"","path":"/collections/sale-potions","raw_liquid":"{{ collection.title | split: '' | reverse | join: '' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Reverses the order of the items in an array.","syntax":"array | reverse","name":"reverse"},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 2.7 | round }}\n{{ 1.3 | round }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Round to a specific number of decimal places","description":"You can specify a number of decimal places to round to. If you don't specify a number, then the `round` filter rounds to the nearest integer.\n","syntax":"","path":"/","raw_liquid":"{{ 3.14159 | round: 2 }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Rounds a number to the nearest integer.","syntax":"number | round","name":"round"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{%- assign text = ' Some potions create whitespace. ' -%}\n\n\"{{ text }}\"\n\"{{ text | rstrip }}\"","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all whitespace from the right of a string.","syntax":"string | rstrip","name":"rstrip"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | sanitize","platformOS":true,"name":"sanitize","aliases":[],"parameters":[{"description":"potential malicious html, which you would like to sanitize","name":"input","required":false,"types":["String"]},{"description":"Options to configure which elements and attributes are allowed, example:\n{ \"elements\": [\"a\", \"b\", \"h1\"], \"attributes\": { \"a\": [\"href\"] } }","name":"options","required":false,"types":null},{"description":"deprecated; do not use","name":"whitelist_tags","required":false,"types":["Array"]}],"return_type":[{"type":"string","name":"","description":"Sanitizes HTML input. If you want to allow any HTML, use html_safe filter.\nBy default we allow only safe html tags and attributes. We also automatically add `rel=nofollow` to links. Default configuration is:\n{\n \"elements\": [\"a\",\"abbr\",\"b\",\"blockquote\",\"br\",\"cite\",\"code\",\"dd\",\"dfn\",\"dl\",\"dt\",\"em\",\"i\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"img\",\"kbd\",\"li\",\"mark\",\"ol\",\"p\",\"pre\",\"q\",\"s\",\"samp\",\"small\",\"strike\",\"strong\",\"sub\",\"sup\",\"time\",\"u\",\"ul\",\"var\"],\n \"attributes\":{\n \"a\": [\"href\"],\n \"abbr\":[\"title\"],\n \"blockquote\":[\"cite\"],\n \"img\":[\"align\",\"alt\",\"border\",\"height\",\"src\",\"srcset\",\"width\"],\n \"dfn\":[\"title\"],\n \"q\":[\"cite\"],\n \"time\":[\"datetime\",\"pubdate\"]\n },\n \"add_attributes\": { \"a\" : {\"rel\":\"nofollow\"} },\n \"protocols\": {\n \"a\":{\"href\":[\"ftp\",\"http\",\"https\",\"mailto\",\"relative\"]},\n \"blockquote\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"q\": {\"cite\": [\"http\",\"https\",\"relative\"] },\n \"img\": {\"src\": [\"http\",\"https\",\"relative\"] }\n }\n}","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture link %}\n \u003ca href=\"javascript:prompt(1)\"\u003eLink\u003c/a\u003e\n{% endcapture %}\n{{ link | sanitize }} =\u003e \u003ca href=\"\"\u003eLink\u003c/a\u003e\n{% assign whitelist_attributes = 'target' | split: '|' %}\n{{ link | sanitize: whitelist_attributes }} =\u003e \u003ca href=\"\"\u003eLink\u003c/a\u003e","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Scrubs invalid characters and sequences from the input string, in the given encoding (by default UTF-8)","summary":"returns ","syntax":"string | scrub","platformOS":true,"name":"scrub","aliases":[],"parameters":[{"description":"string to be scrubbed","name":"text","required":false,"types":["String"]},{"description":"encoding of the input string, default UTF-8","name":"source_encoding","required":false,"types":["String"]},{"description":"encoding of the output string, default UTF-8","name":"final_encoding","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Returns a string scrubbed of invalid characters and sequences; to be used when data is coming from external sources like APIs etc.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign scrubbed = \"Hello W�orld\" | scrub %}\n{{ scrubbed }} =\u003e \"Hello World\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"true","description":"","summary":"returns ","syntax":"string | sha1","platformOS":true,"name":"sha1","aliases":[],"parameters":[{"description":"input object that you want to obtain the digest for","name":"object","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"SHA1 digest of the input object","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo' | sha1 }} =\u003e '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"The size of a string is the number of characters that the string includes. The size of an array is the number of items\nin the array.","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/sale-potions","raw_liquid":"{{ collection.title | size }}\n{{ collection.products | size }}","parameter":false,"display_type":"text","show_data_tab":false},{"name":"Dot notation","description":"You can use the `size` filter with dot notation when you need to use it inside a tag or object output.\n","syntax":"","path":"/collections/sale-potions","raw_liquid":"{% if collection.products.size \u003e= 10 %}\n There are 10 or more products in this collection.\n{% else %}\n There are less than 10 products in this collection.\n{% endif %}","parameter":false,"display_type":"text","show_data_tab":false}],"summary":"Returns the size of a string or array.","syntax":"variable | size","name":"size"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"By default, the substring has a length of one character, and the array series has one array item. However, you can\nprovide a second parameter to specify the number of characters or array items.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.title | slice: 0 }}\n{{ collection.title | slice: 0, 5 }}\n\n{{ collection.all_tags | slice: 1, 2 | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Negative index","description":"You can supply a negative index which will count from the end of the string.\n","syntax":"","path":"/collections/all","raw_liquid":"{{ collection.title | slice: -3, 3 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Returns a substring or series of array items, starting at a given 0-based index.","syntax":"string | slice","name":"slice"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | slugify","platformOS":true,"name":"slugify","aliases":[],"parameters":[{"description":"input string to be 'slugified'","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"replaces special characters in a string so that it may be used as part of a 'pretty' URL;","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'John arrived_foo' | slugify }} =\u003e 'john-arrived-foo'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{% assign tags = collection.all_tags | sort %}\n\n{% for tag in tags -%}\n {{ tag }}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Sort by an array item property","description":"You can specify an array item property to sort the array items by. You can sort by any property of the object that you're sorting.\n","syntax":"array | sort: string","path":"/collections/all","raw_liquid":"{% assign products = collection.products | sort: 'price' %}\n\n{% for product in products -%}\n {{ product.title }}\n{%- endfor %}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Sorts the items in an array in case-sensitive alphabetical, or numerical, order.","syntax":"array | sort","name":"sort"},{"category":"array","deprecated":false,"deprecation_reason":"","description":"\u003e Caution:\n\u003e You shouldn't use the `sort_natural` filter to sort numerical values. When comparing items an array, each item is converted to a\n\u003e string, so sorting on numerical values can lead to unexpected results.","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/collections/all","raw_liquid":"{% assign tags = collection.all_tags | sort_natural %}\n\n{% for tag in tags -%}\n {{ tag }}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Sort by an array item property","description":"You can specify an array item property to sort the array items by.\n","syntax":"array | sort_natural: string","path":"/collections/all","raw_liquid":"{% assign products = collection.products | sort_natural: 'title' %}\n\n{% for product in products -%}\n {{ product.title }}\n{%- endfor %}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Sorts the items in an array in case-insensitive alphabetical order.","syntax":"array | sort_natural","name":"sort_natural"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"string"}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{%- assign title_words = product.handle | split: '-' -%}\n\n{% for word in title_words -%}\n {{ word }}\n{%- endfor %}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Splits a string into an array of substrings based on a given separator.","syntax":"string | split: string","name":"split"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Check if string starts with given substring(s)","summary":"returns ","syntax":"string | start_with","platformOS":true,"name":"start_with","aliases":[],"parameters":[{"description":"string to check if starts with any of the provided prefixes","name":"string","required":false,"types":["String"]},{"description":"prefix(es) to check","name":"prefixes","required":false,"types":["String","Array"]}],"return_type":[{"type":"boolean","name":"","description":"true if string starts with a prefix","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | start_with: 'my' }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'my_example' | start_with: 'example' } =\u003e false","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign prefixes = '[\"array\", \"example\"]' | parse_json %}\n{{ 'my_example' | start_with: prefixes } =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | strftime","platformOS":true,"name":"strftime","aliases":[],"parameters":[{"description":"parsable time object","name":"time","required":false,"types":["String","Integer","Date","Time","DateTime"]},{"description":"string representing the desired output format\ne.g. '%Y-%m-%d' will result in '2020-12-21'\nCheatsheet: https://devhints.io/strftime","name":"format","required":false,"types":["String"]},{"description":"string representing the time zone","name":"zone","required":false,"types":["String"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"string","name":"","description":"formatted representation of the time object; the formatted representation\nwill be based on what the format parameter specifies","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M' }} =\u003e 2018-05-30 09:12","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign time = '2010-01-01 08:00' | to_time %}\n{{ time | strftime: \"%Y-%m-%d\" }} =\u003e '2010-01-01'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'Europe/Warsaw' }} =\u003e 2018-05-30 18:12\n{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'America/New_York' }} =\u003e 2018-05-30 12:12\n{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'Sydney' }} =\u003e 2018-05-31 02:12\n{{ '2018-05-30T09:12:34.000-07:00' | strftime: '%Y-%m-%d %H:%M', 'Pacific/Apia' }} =\u003e 2018-05-31 05:12","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{%- assign text = ' Some potions create whitespace. ' -%}\n\n\"{{ text }}\"\n\"{{ text | strip }}\"","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all whitespace from the left and right of a string.","syntax":"string | strip","name":"strip"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"\u003c!-- With HTML --\u003e\n{{ product.description }}\n\n\u003c!-- HTML stripped --\u003e\n{{ product.description | strip_html }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all HTML tags from a string.","syntax":"string | strip_html","name":"strip_html"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | strip_liquid","platformOS":true,"name":"strip_liquid","aliases":[],"parameters":[{"description":"text from which to strip liquid","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"input parameter without liquid","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'Hello! {% comment %}This is a comment!{% endcomment %}' | strip_liquid }} =\u003e \"Hello! This is a comment!\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"\u003c!-- With newlines --\u003e\n{{ product.description }}\n\n\u003c!-- Newlines stripped --\u003e\n{{ product.description | strip_newlines }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Strips all newline characters (line breaks) from a string.","syntax":"string | strip_newlines","name":"strip_newlines"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | time_diff","platformOS":true,"name":"time_diff","aliases":[],"parameters":[{"description":"","name":"start","required":false,"types":["String","Integer","Date","Time"]},{"description":"","name":"finish","required":false,"types":["String","Integer","Date","Time"]},{"description":"time unit - allowed options are: d, days, h, hours, m, minutes, s, seconds, ms, milliseconds [default]","name":"unit","required":false,"types":["String"]},{"description":"defines rounding after comma; default is 3","name":"precision","required":false,"types":["Number"]}],"return_type":[{"type":"number","name":"","description":"duration between start and finish in unit; default is ms (milliseconds)","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign result = 'now' | time_diff: 'in 5 minutes', 'd' %}\n{{ result }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign minutes_until_date = 'now' | time_diff: '2026-10-08 00:00', 'm' %}\n{% comment %}{% background _ = 'commands/foo', delay: minutes_until_date %} %}{% endcomment %}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"math","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"number","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 2 | times: 2 }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Multiplies a number by a given number.","syntax":"number | times: number","name":"times"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | titleize","platformOS":true,"name":"titleize","aliases":[],"parameters":[{"description":"string to be processed","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"capitalizes all the words and replaces some characters in the string to create\na string in title-case format","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'foo bar_zoo-xx' | titleize }} =\u003e 'Foo Bar Zoo Xx'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"array | to_csv","platformOS":true,"name":"to_csv","aliases":[],"parameters":[{"description":"array you would like to convert to CSV","name":"input","required":false,"types":["Array"]},{"description":"csv options","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"String containing CSV.\nIf one of the array element contains separator, this element will automatically be wrapped in double quotes.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign arr = '' | split: ','\n assign headers = 'id,header1,header2' | split: ','\n assign row1 = '1,example,value' | split: ','\n assign row2 = '2,another,val2' | split: ','\n assign arr = arr | array_add: headers | array_add: row1 | array_add: row2\n%}\n{{ arr | to_csv }} =\u003e \"id\",\"header1\",\"header2\"\\n1,\"example\",\"value\"\\n2,\"another\",\"val2\"","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ arr | to_csv: force_quotes: true }} =\u003e \"id\",\"header1\",\"header2\"\\n\"1\",\"example\",\"value\"\\n\"2\",\"another\",\"val2\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | to_date","platformOS":true,"name":"to_date","aliases":[],"parameters":[{"description":"parsable time object to be converted to date","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"date","name":"","description":"a Date object obtained/parsed from the input object","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010-01-01 8:00:00' | to_date }} =\u003e 2010-01-01","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | to_mobile_number","platformOS":true,"name":"to_mobile_number","aliases":[],"parameters":[{"description":"the base part of mobile number","name":"number","required":false,"types":["String"]},{"description":"country for which country code should be used. Can be anything - full name, iso2, iso3","name":"country","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"returns mobile number in E.164 format; recommended for sending sms notifications","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '500 123 999' | to_mobile_number: 'PL' }} =\u003e '+48500123999'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | to_positive_integer","platformOS":true,"name":"to_positive_integer","aliases":[],"parameters":[{"description":"value to be coerced to positive integer","name":"param","required":false,"types":["Untyped"]},{"description":"default value in case param is not valid positive integer","name":"default","required":false,"types":["Number"]}],"return_type":[{"type":"number","name":"","description":"number that is higher than 0","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1' | to_positive_integer: 2 }} =\u003e 1\n{{ '' | to_positive_integer: 2 }} =\u003e 2","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | to_time","platformOS":true,"name":"to_time","aliases":[],"parameters":[{"description":"a string representation of time ('today', '3 days ago', 'in 10 minutes' etc.) or a number in UNIX time format or time","name":"time","required":false,"types":["String","Integer","Date","Time"]},{"description":"time zone","name":"zone","required":false,"types":["String"]},{"description":"specific format to be used when parsing time","name":"format","required":false,"types":["String"]},{"description":"sets the time from which operation should be performed","name":"now","required":false,"types":["String","Integer","Date","Time"]}],"return_type":[{"type":"datetime","name":"","description":"a time object created from parsing the string representation of time given as input","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'today' | to_time }} =\u003e 2017-04-15 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'today' | to_time: 'UTC' }} =\u003e 2017-04-15 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '1 day ago' | to_time }} =\u003e 2017-04-14 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '5 days from now' | to_time }} =\u003e 2017-04-19 15:21:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '2010:01:01' | to_time: '', '%Y:%m:%d' }} =\u003e 2010-01-01 00:00:00","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '5 days from now' | to_time '', '', '2019-10-01' }} =\u003e 2019-10-06 00:00:00 # equivalent of {{ '2019-10-01' | add_to_time: 5, 'days' }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"variable | to_xml","platformOS":true,"name":"to_xml","aliases":["to_xml_rc"],"parameters":[{"description":"hash object that will be represented as xml","name":"object","required":false,"types":["Hash"]},{"description":"attr_prefix: use '@' for element attributes","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"String containing XML","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% liquid\n assign object = '{\"letter\":[{\"title\":[{\"maxlength\":\"10\",\"content\":\" Quote Letter \"}]}]}' | parse_json\n assign xml = object | to_xml\n%}\n{{ object }} =\u003e '\u003cletter\u003e \u003ctitle maxlength=\"10\"\u003e Quote Letter \u003c/title\u003e \u003c/letter\u003e'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | translate","platformOS":true,"name":"translate","aliases":["t"],"parameters":[{"description":"translation key","name":"key","required":false,"types":["String"]},{"description":"values passed to translation string","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"Translation value taken from translations YML file for the key given as parameter. The value is assumed to be html safe,\nplease use `t_escape` if you provide unsafe argument which can potentially include malicious script.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'beer' | translate }} =\u003e 'cerveza'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'beer' | t }} =\u003e 'cerveza'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'drinks.alcoholic.beer' | t }} =\u003e 'piwo'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'non_existing_translation' | t: default: 'Missing', fallback: false }} =\u003e 'Missing'","parameter":false,"display_type":"text","show_data_tab":true},{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'user-greeting' | t: username: 'Mike' }} =\u003e 'Hello Mike!'","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"Escapes unsafe arguments passed to the translation and then returns its value","summary":"returns ","syntax":"string | translate_escape","platformOS":true,"name":"translate_escape","aliases":["t_escape"],"parameters":[{"description":"translation key","name":"key","required":false,"types":["String"]},{"description":"values passed to translation string","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"translation value taken from translations YML file for the key given as parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"en.yml\nen:\n user-greeting: Hello %{username}\n\n{{ 'user-greeting' | t_escape: username: '\u003cscript\u003ealert(\"hello\")\u003c/script\u003eMike' }}\n=\u003e will not evaluate the script, it will print out:\nHello \u003cscript\u003ealert(\"hello\")\u003c/script\u003eMike","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"","description":"If the specified number of characters is less than the length of the string, then an ellipsis (`...`) is appended to\nthe truncated string. The ellipsis is included in the character count of the truncated string.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.title | truncate: 15 }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Specify a custom ellipsis","description":"You can provide a second parameter to specify a custom ellipsis. If you don't want an ellipsis, then you can supply an empty string.\n","syntax":"string | truncate: number, string","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.title | truncate: 15, '--' }}\n{{ article.title | truncate: 15, '' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Truncates a string down to a given number of characters.","syntax":"string | truncate: number","name":"truncate"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"If the specified number of words is less than the number of words in the string, then an ellipsis (`...`) is appended to\nthe truncated string.\n\n\u003e Caution:\n\u003e HTML tags are treated as words, so you should strip any HTML from truncated content. If you don't strip HTML, then\n\u003e closing HTML tags can be removed, which can result in unexpected behavior.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.content | strip_html | truncatewords: 15 }}","parameter":false,"display_type":"text","show_data_tab":true},{"name":"Specify a custom ellipsis","description":"You can provide a second parameter to specify a custom ellipsis. If you don't want an ellipsis, then you can supply an empty string.\n","syntax":"string | truncatewords: number, string","path":"/blogs/potion-notions/how-to-tell-if-you-have-run-out-of-invisibility-potion","raw_liquid":"{{ article.content | strip_html | truncatewords: 15, '--' }}\n\n{{ article.content | strip_html | truncatewords: 15, '' }}","parameter":true,"display_type":"text","show_data_tab":true}],"summary":"Truncates a string down to a given number of words.","syntax":"string | truncatewords: number","name":"truncatewords"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | type_of","platformOS":true,"name":"type_of","aliases":[],"parameters":[{"description":"Variable whose type you want returned","name":"variable","required":false,"types":["Untyped"]}],"return_type":[{"type":"string","name":"","description":"Type of the variable parameter","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign variable_type = '{ \"name\": \"foo\", \"bar\": {} }' | parse_json | type_of %}\n{{ variable_type }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | unescape_javascript","platformOS":true,"name":"unescape_javascript","aliases":[],"parameters":[{"description":"text to be unescaped","name":"text","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"unescaped javascript text","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% capture 'js' %}\n\u003cscript\u003e\n let variable = \"\\t some text\\n\";\n let variable2 = 'some text 2';\n let variable3 = `some text`;\n let variable4 = `We love ${variable3}.`;\n \u003c/script\u003e\n{% endcapture %}\n\nThis will return the text to its original form:\n{{ js | escape_javascript | unescape_javascript }}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"array","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"array","name":"","description":"","array_value":"untyped"}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{% assign potion_array = 'invisibility, health, love, health, invisibility' | split: ', ' %}\n\n{{ potion_array | uniq | join: ', ' }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Removes any duplicate items in an array.","syntax":"array | uniq","name":"uniq"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/products/health-potion","raw_liquid":"{{ product.title | upcase }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts a string to all uppercase characters.","syntax":"string | upcase","name":"upcase"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 'test%40test.com' | url_decode }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Decodes any [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) characters\nin a string.","syntax":"string | url_decode","name":"url_decode"},{"category":"string","deprecated":false,"deprecation_reason":"","description":"\u003e Note:\n\u003e Spaces are converted to a `+` character, instead of a percent-encoded character.","parameters":[],"return_type":[{"type":"string","name":"","description":"","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"/","raw_liquid":"{{ 'test@test.com' | url_encode }}","parameter":false,"display_type":"text","show_data_tab":true}],"summary":"Converts any URL-unsafe characters in a string to the\n[percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) equivalent.","syntax":"string | url_encode","name":"url_encode"},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | url_to_qrcode_svg","platformOS":true,"name":"url_to_qrcode_svg","aliases":[],"parameters":[{"description":"URL to be encoded as QR code","name":"url","required":false,"types":["String"]},{"description":"optional. Defaults: color: \"000\", module_size: 11, shape_rendering: \"crispEdges\", viewbox: false","name":"options","required":false,"types":["Hash"]}],"return_type":[{"type":"string","name":"","description":"either `\u003cpath ...\u003e...\u003c/path\u003e` or `\u003csvg ...\u003e...\u003c/svg\u003e` depending on standalone flag","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"\u003csvg width=\"319\" height=\"319\"\u003e{{ 'https://example.com' | url_to_qrcode_svg, color: '000', module_size: 11 }}\u003c/svg\u003e =\u003e \u003csvg width=\"319\" height=\"319\"\u003e\u003cpath\u003e...\u003c/path\u003e\u003c/svg\u003e","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | useragent","platformOS":true,"name":"useragent","aliases":[],"parameters":[{"description":"browser user agent from the request header","name":"useragent_header","required":false,"types":["String"]}],"return_type":[{"type":"hash","name":"","description":"parsed browser user agent information","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ context.headers.HTTP_USER_AGENT | useragent }} =\u003e\n{\n \"device\": {\"family\":\"Other\",\"model\":\"Other\",\"brand\":null},\n \"family\":\"Firefox\",\n \"os\":{\"version\":null,\"family\":\"Windows 7\"},\n \"version\":{\"version\":\"47.0\",\"major\":\"47\",\"minor\":\"0\",\"patch\":null}\n}","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | uuid","platformOS":true,"name":"uuid","aliases":[],"parameters":[{"description":"parameter will be ignored","name":"_dummy","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"Universally unique identifier v4","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ '' | uuid }} =\u003e \"2d931510-d99f-494a-8c67-87feb05e1594\"\n\n{% assign id = '' | uuid %}\n{{ id }} =\u003e \"b12bd15e-4da7-41a7-b673-272221049c01\"","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | verify_access_key","platformOS":true,"name":"verify_access_key","aliases":[],"parameters":[{"description":"can be obtained in Partner Portal","name":"access_key","required":false,"types":["String"]}],"return_type":[{"type":"boolean","name":"","description":"check if key is valid","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{% assign access_key = '12345' %}\n{{ access_key | verify_access_key }} =\u003e true","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | video_params","platformOS":true,"name":"video_params","aliases":[],"parameters":[{"description":"URL to a video on the internet","name":"url","required":false,"types":["String"]}],"return_type":[{"type":"hash","name":"","description":"metadata about video","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"{{ 'https://www.youtube.com/watch?v=8N_tupPBtWQ' | video_params }}\n=\u003e {\"provider\" =\u003e \"YouTube\", \"url\" =\u003e \"https://www.youtube.com/watch?v=8N_tupPBtWQ\", \"video_id\" =\u003e \"8N_tupPBtWQ\", \"embed_url\" =\u003e \"https://www.youtube.com/embed/8N_tupPBtWQ\", \"embed_code\" =\u003e \"\u003ciframe src=\\\"https://www.youtube.com/embed/8N_tupPBtWQ\\\" frameborder=\\\"0\\\" allowfullscreen=\\\"allowfullscreen\\\"\u003e\u003c/iframe\u003e\"},","parameter":false,"display_type":"text","show_data_tab":true}]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"string | videoify","platformOS":true,"name":"videoify","aliases":[],"parameters":[{"description":"URL to a video on the internet","name":"url","required":false,"types":["String"]}],"return_type":[{"type":"string","name":"","description":"if the given URL is supported, an HTML formatted string containing a video player (inside an iframe)\nwhich will play the video at the given URL; otherwise an empty string is returned","array_value":""}],"examples":[]},{"category":"string","deprecated":false,"deprecation_reason":"false","description":"","summary":"returns ","syntax":"untyped | www_form_encode","platformOS":true,"name":"www_form_encode","aliases":["www_form_encode_rc"],"parameters":[{"description":"data object","name":"object","required":false,"types":["Untyped"]}],"return_type":[{"type":"string","name":"","description":"This generates application/x-www-form-urlencoded data defined in HTML5 from given object.","array_value":""}],"examples":[{"name":"","description":"","syntax":"","path":"","raw_liquid":"assign object = '{\"foo\": \"bar\", \"zoo\": [{ \"xoo\": 1 }, {\"xoo\": 2}]}' | parse_json\nassign form_data = object | www_form_encode\n =\u003e \"foo=bar\u0026zoo[0][xoo]=555\u0026zoo[1][xoo]=999\",","parameter":false,"display_type":"text","show_data_tab":true}]}] diff --git a/packages/platformos-check-docs-updater/data/graphql.graphql b/packages/platformos-check-docs-updater/data/graphql.graphql index 601d069f..77dc7e85 100644 --- a/packages/platformos-check-docs-updater/data/graphql.graphql +++ b/packages/platformos-check-docs-updater/data/graphql.graphql @@ -11898,7 +11898,16 @@ type User implements HasModelsInterface & HasRecordsInterface & LegacyCustomAttr """ JWT token that can be used for authentication """ - jwt_token(algorithm: JwtAlgorithm = HS256): String + jwt_token( + algorithm: JwtAlgorithm = HS256 + + """ + Number of seconds after which the token expires, clamped to a maximum of one + year. When omitted, the token expires after the instance session timeout + (same source as the `{% sign_in %}` tag). Expired tokens are rejected on decode. + """ + expires_in: Int = null + ): String """ Used by translations to set the language of currently logged in user diff --git a/packages/platformos-check-docs-updater/data/latest.json b/packages/platformos-check-docs-updater/data/latest.json index 24f77426..7b8f8355 100644 --- a/packages/platformos-check-docs-updater/data/latest.json +++ b/packages/platformos-check-docs-updater/data/latest.json @@ -1,2 +1,2 @@ -{"revision":"d68efda42a0803059f9679ead36383f2e318ee77"} +{"revision":"f523bcc049366660839a15465344da3ef06257a0"} diff --git a/packages/platformos-graph/src/graph/module.spec.ts b/packages/platformos-graph/src/graph/module.spec.ts index fb16205d..47d89651 100644 --- a/packages/platformos-graph/src/graph/module.spec.ts +++ b/packages/platformos-graph/src/graph/module.spec.ts @@ -3,6 +3,7 @@ import { AppGraph } from '../types'; import { getLayoutModule, getLayoutModuleByUri, + getModule, getPageModule, getPartialModuleByUri, } from './module'; @@ -51,3 +52,42 @@ describe('module factories: normalized node identity', () => { expect(a.uri).toEqual('file:///project/app/views/partials/card.liquid'); }); }); + +/** + * `getModule` (the entry-point dispatcher) must key a partial by its OWN + * resolved URI — like it does for layouts/pages/assets — NOT by rebuilding the + * path from the basename. Rebuilding from the basename forced every partial into + * `app/views/partials/.liquid`, which mis-keyed any `lib/` or nested + * partial (e.g. `app/lib/can/payment_request.liquid` → the phantom + * `app/views/partials/payment_request.liquid`), splitting it from the same file + * resolved as an edge target and losing its edges in the full build. + */ +describe('getModule: partial entry point keys by its own URI', () => { + const newGraph = (): AppGraph => ({ rootUri: 'file:///project', entryPoints: [], modules: {} }); + + it('keys a lib partial at its own URI, not app/views/partials/', () => { + const graph = newGraph(); + const uri = 'file:///project/app/lib/can/payment_request.liquid'; + expect(getModule(graph, uri)?.uri).toEqual(uri); + }); + + it('keys a nested lib partial at its own URI', () => { + const graph = newGraph(); + const uri = 'file:///project/app/lib/queries/v2/projects/find.liquid'; + expect(getModule(graph, uri)?.uri).toEqual(uri); + }); + + it('a lib partial entry point and the same file resolved as an edge target are ONE node', () => { + const graph = newGraph(); + const uri = 'file:///project/app/lib/commands/create.liquid'; + const entry = getModule(graph, uri); + const edgeTarget = getPartialModuleByUri(graph, uri); + expect(entry).toBe(edgeTarget); + }); + + it('a flat app/views/partials partial is unaffected', () => { + const graph = newGraph(); + const uri = 'file:///project/app/views/partials/card.liquid'; + expect(getModule(graph, uri)?.uri).toEqual(uri); + }); +}); diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index 6e265bd7..963bd056 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -44,7 +44,12 @@ export function getModule(appGraph: AppGraph, uri: UriString): AppModule | undef return getPageModule(appGraph, uri); case isPartial(uri): - return getPartialModule(appGraph, path.basename(uri, '.liquid')); + // Key by the file's OWN resolved URI (like layout/page/asset entry points), + // NOT a path rebuilt from the basename — the latter forced every partial + // into `app/views/partials/.liquid`, mis-keying `lib/` and nested + // partials (e.g. `app/lib/can/x.liquid`) and splitting them from the same + // file resolved as an edge target (which comes through getPartialModuleByUri). + return getPartialModuleByUri(appGraph, uri); case relativePath.startsWith('assets') || relativePath.startsWith('modules'): // The full URI is already resolved on-disk here, so use it directly rather From 38e3e11c8eaf07a2c10f7643e8f746d94e8e7080 Mon Sep 17 00:00:00 2001 From: Filip Klosowski Date: Tue, 7 Jul 2026 20:19:53 +0200 Subject: [PATCH 20/20] refactor(graph): retire name-based partial keying + dead getModule branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up cleanup to TASK-9.23 (the lib-partial mis-keying fix), removing the rest of the same fragile-assumption class. - Delete getModule's unreachable `relativePath.startsWith('assets') || startsWith('modules')` branch (and its now-unused relativePath). Both callers (buildAppGraph entry points, incremental applyFileChange) only ever pass edge-source URIs, caught by the isLayout/isPage/isPartial cases above; the branch was dead, and startsWith('assets') was already false for real app/assets/ paths anyway. - Delete the name-based getPartialModule factory, which rebuilt every partial path as app/views/partials/.liquid — the exact hardcoded-path assumption behind TASK-9.23. It was internal and only used by two test specs. - Migrate serialize.spec / query.spec to getPartialModuleByUri with explicit URIs (identical node keys; assertions unchanged). - Fix the dangling {@link getPartialModule} references in getPartialModuleByUri's docs. getModule now keys every entry point (layout/page/partial) by its own resolved URI — one rule, no path reconstruction. --- packages/platformos-graph/src/graph/module.ts | 43 ++++++------------- .../platformos-graph/src/graph/query.spec.ts | 16 +++---- .../src/graph/serialize.spec.ts | 8 ++-- 3 files changed, 25 insertions(+), 42 deletions(-) diff --git a/packages/platformos-graph/src/graph/module.ts b/packages/platformos-graph/src/graph/module.ts index 963bd056..18228114 100644 --- a/packages/platformos-graph/src/graph/module.ts +++ b/packages/platformos-graph/src/graph/module.ts @@ -34,8 +34,6 @@ export function getModule(appGraph: AppGraph, uri: UriString): AppModule | undef return cache.get(uri)!; } - const relativePath = path.relative(uri, appGraph.rootUri); - switch (true) { case isLayout(uri): return getLayoutModule(appGraph, uri); @@ -44,17 +42,13 @@ export function getModule(appGraph: AppGraph, uri: UriString): AppModule | undef return getPageModule(appGraph, uri); case isPartial(uri): - // Key by the file's OWN resolved URI (like layout/page/asset entry points), - // NOT a path rebuilt from the basename — the latter forced every partial - // into `app/views/partials/.liquid`, mis-keying `lib/` and nested - // partials (e.g. `app/lib/can/x.liquid`) and splitting them from the same - // file resolved as an edge target (which comes through getPartialModuleByUri). + // Key every entry point by its OWN resolved URI (like the layout/page + // factories above), NOT a path rebuilt from the basename — the latter + // forced every partial into `app/views/partials/.liquid`, + // mis-keying `lib/` and nested partials (e.g. `app/lib/can/x.liquid`) and + // splitting them from the same file resolved as an edge target (which comes + // through getPartialModuleByUri). return getPartialModuleByUri(appGraph, uri); - - case relativePath.startsWith('assets') || relativePath.startsWith('modules'): - // The full URI is already resolved on-disk here, so use it directly rather - // than rebuilding a path from the basename. - return getAssetModuleByUri(appGraph, uri); } } @@ -97,34 +91,23 @@ export function getAssetModuleByUri(appGraph: AppGraph, uri: string): AssetModul }); } -export function getPartialModule(appGraph: AppGraph, partial: string): LiquidModule { - const uri = path.join(appGraph.rootUri, 'app/views/partials', `${partial}.liquid`); - return module(appGraph, { - type: ModuleType.Liquid, - kind: LiquidModuleKind.Partial, - uri: uri, - dependencies: [], - references: [], - }); -} - /** * Create (or fetch the cached) Liquid Partial module for an ALREADY-RESOLVED * full URI — used for `{% function %}` / `{% include %}` targets whose URI is * resolved canonically by `DocumentsLocator` (which handles lib paths, module - * prefixes, and extensions). Unlike {@link getPartialModule}, it does not - * reconstruct the path from a name. Commands/queries/lib helpers are all + * prefixes, and extensions). It keys the node by that URI verbatim (normalized), + * never reconstructing a path from a name. Commands/queries/lib helpers are all * `Partial` kind, consistent with check-common's file-type classification. */ export function getPartialModuleByUri(appGraph: AppGraph, uri: string): LiquidModule { return module(appGraph, { type: ModuleType.Liquid, kind: LiquidModuleKind.Partial, - // Normalize to forward slashes so module keys match the rest of the graph - // (getPartialModule/getAssetModule build URIs via path.join, which - // normalizes). DocumentsLocator returns `Utils.joinPath(...).toString()` - // unnormalized, which on Windows keeps backslashes and breaks key/edge - // matching against the normalized URIs everywhere else. + // Normalize so a node keyed here matches the same file keyed by any other + // factory (they all normalize their stored URI): DocumentsLocator returns + // `Utils.joinPath(...).toString()` unnormalized, which on Windows keeps + // backslashes and would break key/edge matching against the normalized URIs + // used everywhere else. uri: path.normalize(uri), dependencies: [], references: [], diff --git a/packages/platformos-graph/src/graph/query.spec.ts b/packages/platformos-graph/src/graph/query.spec.ts index ebe04148..6856b061 100644 --- a/packages/platformos-graph/src/graph/query.spec.ts +++ b/packages/platformos-graph/src/graph/query.spec.ts @@ -18,7 +18,7 @@ import { getGraphQLModuleByUri, getLayoutModule, getPageModule, - getPartialModule, + getPartialModuleByUri, getSchemaModule, } from './module'; import { bind } from './traverse'; @@ -118,9 +118,9 @@ describe('Graph queries: orphan and missing-target detection (hermetic graph)', beforeAll(() => { graph = { rootUri, entryPoints: [], modules: {} }; const page = getPageModule(graph, p('app/views/pages/index.liquid')); - const used = getPartialModule(graph, 'used'); - const orphan = getPartialModule(graph, 'orphan'); - const missing = getPartialModule(graph, 'missing'); + const used = getPartialModuleByUri(graph, p('app/views/partials/used.liquid')); + const orphan = getPartialModuleByUri(graph, p('app/views/partials/orphan.liquid')); + const missing = getPartialModuleByUri(graph, p('app/views/partials/missing.liquid')); const schema = getSchemaModule(graph, p('app/schema/blog_post.yml')); page.exists = true; @@ -203,12 +203,12 @@ describe('Graph queries: nearest-name candidates (did-you-mean)', () => { beforeAll(() => { graph = { rootUri, entryPoints: [], modules: {} }; const page = getPageModule(graph, p('app/views/pages/index.liquid')); - const header = getPartialModule(graph, 'header'); - const footer = getPartialModule(graph, 'footer'); - const sidebar = getPartialModule(graph, 'sidebar'); + const header = getPartialModuleByUri(graph, p('app/views/partials/header.liquid')); + const footer = getPartialModuleByUri(graph, p('app/views/partials/footer.liquid')); + const sidebar = getPartialModuleByUri(graph, p('app/views/partials/sidebar.liquid')); const headerLayout = getLayoutModule(graph, p('app/views/layouts/header.liquid'))!; const find = getGraphQLModuleByUri(graph, p('app/graphql/blog/find.graphql')); - const headr = getPartialModule(graph, 'headr'); + const headr = getPartialModuleByUri(graph, p('app/views/partials/headr.liquid')); const graphqlTypo = getGraphQLModuleByUri(graph, p('app/graphql/blog/fnd.graphql')); for (const m of [page, header, footer, sidebar, headerLayout, find]) m.exists = true; diff --git a/packages/platformos-graph/src/graph/serialize.spec.ts b/packages/platformos-graph/src/graph/serialize.spec.ts index 03814f12..b695b9d6 100644 --- a/packages/platformos-graph/src/graph/serialize.spec.ts +++ b/packages/platformos-graph/src/graph/serialize.spec.ts @@ -1,7 +1,7 @@ import { path as pathUtils } from '@platformos/platformos-check-common'; import { describe, expect, it } from 'vitest'; import { AppGraph } from '../types'; -import { getLayoutModule, getPartialModule } from './module'; +import { getLayoutModule, getPartialModuleByUri } from './module'; import { serializeAppGraph } from './serialize'; import { bind } from './traverse'; @@ -16,9 +16,9 @@ describe('Unit: serializeAppGraph', () => { }; const layout = getLayoutModule(graph, p('app/views/layouts/application.liquid'))!; - const headerPartial = getPartialModule(graph, 'header'); - const parentPartial = getPartialModule(graph, 'parent'); - const childPartial = getPartialModule(graph, 'child'); + const headerPartial = getPartialModuleByUri(graph, p('app/views/partials/header.liquid')); + const parentPartial = getPartialModuleByUri(graph, p('app/views/partials/parent.liquid')); + const childPartial = getPartialModuleByUri(graph, p('app/views/partials/child.liquid')); bind(layout, headerPartial, { sourceRange: [0, 5] }); bind(layout, parentPartial, { sourceRange: [10, 15] });