From 8cf1db5593ef9fd22898a7678f56f3efb3bae2f7 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Tue, 10 Mar 2026 13:14:09 +0000 Subject: [PATCH 01/20] New checks --- UPSTREAM-PROPOSALS.md | 791 +++++++++ .../2026-03-10-upstream-proposals-design.md | 348 ++++ docs/plans/2026-03-10-upstream-proposals.md | 1501 +++++++++++++++++ .../src/checks/index.ts | 2 + .../checks/nested-graphql-query/index.spec.ts | 73 + .../src/checks/nested-graphql-query/index.ts | 63 + .../src/context-utils.ts | 8 +- .../src/utils/index.ts | 1 + .../src/utils/levenshtein.ts | 44 + .../platformos-check-node/configs/all.yml | 3 + .../configs/recommended.yml | 3 + .../src/server/AppGraphManager.ts | 124 +- .../src/server/startServer.spec.ts | 129 ++ 13 files changed, 3084 insertions(+), 6 deletions(-) create mode 100644 UPSTREAM-PROPOSALS.md create mode 100644 docs/plans/2026-03-10-upstream-proposals-design.md create mode 100644 docs/plans/2026-03-10-upstream-proposals.md create mode 100644 packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts create mode 100644 packages/platformos-check-common/src/checks/nested-graphql-query/index.ts create mode 100644 packages/platformos-check-common/src/utils/levenshtein.ts diff --git a/UPSTREAM-PROPOSALS.md b/UPSTREAM-PROPOSALS.md new file mode 100644 index 0000000..13b637b --- /dev/null +++ b/UPSTREAM-PROPOSALS.md @@ -0,0 +1,791 @@ +# Upstream Feature Proposals — pos-cli check and pos-cli LSP + +Features identified during plugin development that belong at the platform tooling layer +rather than in the agent plugin. Each proposal includes rationale for placement, full +implementation detail, and expected impact. + +**Why layer placement matters:** features that detect code quality problems should live in +`pos-cli check` or the LSP so they fire universally — in CI, in editors, in the agent plugin +via auto-diagnostics — without requiring the plugin to re-implement analysis logic. The plugin +gets them for free via `pos-cli check` output. Features that exist only in the plugin are +invisible outside agent sessions. + +--- + +## pos-cli check proposals + +### 1. N+1 GraphQL query detection + +**Check code:** `NestedGraphQLQuery` (or `GraphQLInLoop`) +**Severity:** WARNING +**Type:** `SourceCodeType.LiquidHtml` + +#### The problem + +A `{% graphql %}` tag inside a `{% for %}` or `{% tablerow %}` loop executes one database +request per loop iteration. With 100 records in the outer loop, that is 100 sequential +GraphQL requests instead of one batch query — a catastrophic performance footprint that is +completely invisible at the template level and produces no error at runtime. The page simply +loads slowly (or times out under load). + +This is the single most damaging performance pattern in platformOS templates and it is not +currently detected by any tool. + +#### Why it belongs in pos-cli check + +This is a pure static analysis problem: detect a graphql tag whose AST ancestor chain +contains a `for` or `tablerow` tag. It requires no runtime information, no network calls, +and no session state. It should fire in CI, in the editor diagnostics panel, and during +`pos-cli check` runs — not only when an agent happens to be active. Putting it in the +plugin means it only fires in agent sessions and is invisible everywhere else. + +#### Implementation + +The check uses entry and exit visitors to maintain a for-loop nesting depth counter. When +a `graphql` tag is encountered at any nesting depth > 0, an offense is reported. + +```js +import { SourceCodeType, Severity } from '@platformos/platformos-check-common'; +import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; + +export const NestedGraphQLQuery = { + meta: { + code: 'NestedGraphQLQuery', + name: 'GraphQL query inside a loop', + docs: { + description: 'A {% graphql %} tag inside a {% for %} loop executes one ' + + 'database request per iteration (N+1 pattern). Move the query before ' + + 'the loop and pass results as a variable.', + recommended: true, + url: 'https://documentation.platformos.com/best-practices/performance/graphql-in-loops', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + create(context) { + const loopStack = []; // tracks open for/tablerow nodes + + return { + async LiquidTag(node) { + if (node.name === NamedTags.for || node.name === NamedTags.tablerow) { + loopStack.push(node.name); + return; + } + + if (node.name !== NamedTags.graphql) return; + if (loopStack.length === 0) return; + + const outerLoop = loopStack[loopStack.length - 1]; + const markup = node.markup; + const resultVar = markup.type === NodeTypes.GraphQLMarkup + ? markup.name // the 'result' in {% graphql result = 'path' %} + : null; + + context.report({ + message: + `N+1 pattern: {% graphql ${resultVar ? resultVar + ' = ' : ''}... %} ` + + `is inside a {% ${outerLoop} %} loop. ` + + `This executes one database request per iteration. ` + + `Move the query before the loop and pass data as a variable.`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + }, + + async 'LiquidTag:exit'(node) { + if (node.name === NamedTags.for || node.name === NamedTags.tablerow) { + loopStack.pop(); + } + }, + }; + }, +}; +``` + +#### Edge cases to handle + +- **Nested loops:** the `loopStack` correctly handles `{% for %}` inside `{% for %}` — + depth > 1 is even more dangerous (exponential requests) and should still warn. +- **Dynamic graphql (inline):** `GraphQLInlineMarkup` (inline queries without a file + reference) should also be detected — the markup type check handles both. +- **`background` tag:** a `{% background %}` tag inside a loop is less harmful (async) + but still worth a separate INFO-level note. Could be a separate check or a branch here. +- **`cache` wrapping graphql:** if the `graphql` tag is inside both a `{% for %}` and a + `{% cache %}` block, the severity could be downgraded to INFO since caching mitigates + the repeated requests. Requires checking ancestors for `NamedTags.cache`. + +#### Expected output + +``` +WARNING NestedGraphQLQuery app/views/pages/products.liquid:14 + N+1 pattern: {% graphql result = 'products/related' %} is inside a {% for %} + loop. This executes one database request per iteration. Move the query before + the loop and pass data as a variable. +``` + +--- + +### 2. Render parameter validation (`@param` contracts) + +**Check code:** `UndeclaredRenderParameter` + `MissingRequiredParameter` +**Severity:** WARNING (unknown param) / ERROR (missing required param) +**Type:** `SourceCodeType.LiquidHtml` + +#### The problem + +LiquidDoc supports `@param` annotations that declare a partial's expected inputs: + +```liquid +{% doc %} + @param {string} title - The card heading (required) + @param {string} subtitle - Secondary text (optional) + @param {object} cta - Call-to-action object with url and label +{% enddoc %} +``` + +Currently there is no tool that validates whether a `{% render %}` call actually provides +the required parameters. A caller can omit `title` entirely and get a silent blank value +at runtime. A caller can pass `ttle: "typo"` and the typo is silently ignored. These +are two of the most common causes of "blank partial" bugs in platformOS templates. + +#### Why it belongs in pos-cli check + +This is cross-file static analysis: read the callee's declaration (`@param` nodes in the +partial), inspect the caller's argument list (the `{% render %}` tag's named arguments), +and compare. The check engine already provides `context.fs.readFile()` for reading +dependency files. This pattern is identical to how `MissingTemplate` works — it reads +the referenced file to verify it exists. Here we go one step further and read it to +verify the interface is respected. + +As a check offense it fires in CI (preventing broken renders from reaching staging), +in the editor (inline annotations on the render tag), and in agent sessions without +any plugin-level logic. + +#### Implementation + +```js +import { SourceCodeType, Severity } from '@platformos/platformos-check-common'; +import { NamedTags, NodeTypes, toLiquidHtmlAST, walk } from '@platformos/liquid-html-parser'; +import path from 'node:path'; + +function resolvePartialPath(fileUri, partialName) { + // partialName: "sections/hero" + // resolved: /app/views/partials/sections/hero.liquid + const projectRoot = fileUri.replace(/\/app\/.*$/, ''); + return path.join(projectRoot, 'app', 'views', 'partials', partialName + '.liquid'); +} + +function extractParams(ast) { + // Returns { name, required, type, description }[] + // @param without a default or "optional" marker → required: true + const params = []; + walk(ast, (node) => { + if (node.type === NodeTypes.LiquidDocParamNode) { + params.push({ + name: node.name, + type: node.paramType?.value ?? 'any', + description: node.description?.value ?? '', + // Convention: params without "(optional)" in description are required. + // This matches the emerging LiquidDoc convention — adjust per pos-cli's + // own @param semantics when they are formalised. + required: !node.description?.value?.toLowerCase().includes('optional'), + }); + } + }); + return params; +} + +export const RenderParameterValidation = { + meta: { + code: 'RenderParameterValidation', + name: 'Render call violates @param contract', + docs: { + description: 'Validates that {% render %} calls provide all required ' + + '@param arguments declared by the target partial and do not pass ' + + 'undeclared arguments.', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + create(context) { + return { + async LiquidTag(node) { + if (node.name !== NamedTags.render) return; + + const markup = node.markup; + + // Skip dynamic partials: {% render variable %} — can't resolve statically + if (!markup.partial || markup.partial.type !== NodeTypes.String) return; + + const partialName = markup.partial.value; + const partialPath = resolvePartialPath(context.file.uri, partialName); + + if (!await context.fileExists(partialPath)) return; // MissingTemplate handles this + + const partialSource = await context.fs.readFile(partialPath); + let partialAST; + try { + partialAST = toLiquidHtmlAST(partialSource, { mode: 'tolerant' }); + } catch { + return; // malformed partial — other checks will catch it + } + + const declaredParams = extractParams(partialAST); + + // If the partial declares no @param at all, skip validation. + // Unannotated partials have an implicit "accept anything" interface. + if (declaredParams.length === 0) return; + + const providedArgs = new Map( + (markup.args ?? []).map(arg => [arg.name, arg]) + ); + const declaredNames = new Set(declaredParams.map(p => p.name)); + + // Check 1: missing required params + for (const param of declaredParams) { + if (param.required && !providedArgs.has(param.name)) { + context.report({ + message: + `Missing required @param '${param.name}' ` + + `(${param.type}) for partial '${partialName}'. ` + + (param.description ? `Description: ${param.description}` : ''), + startIndex: node.position.start, + endIndex: node.position.end, + }); + } + } + + // Check 2: unknown params (caller passes something the partial doesn't declare) + for (const [argName, argNode] of providedArgs) { + if (!declaredNames.has(argName)) { + context.report({ + message: + `Unknown parameter '${argName}' passed to '${partialName}'. ` + + `Declared @params: ${[...declaredNames].join(', ')}. ` + + `Either add @param ${argName} to the partial's LiquidDoc or remove this argument.`, + startIndex: argNode.position?.start ?? node.position.start, + endIndex: argNode.position?.end ?? node.position.end, + }); + } + } + }, + }; + }, +}; +``` + +#### Adoption path + +For this check to be useful, the codebase needs to have `@param` annotations. It cannot +penalise render calls to unannotated partials (hence the early return when `declaredParams.length === 0`). The check is opt-in per partial: annotate a partial's interface +and callers are immediately validated. This creates a natural incremental adoption path — +annotate the most-called partials first. + +A companion check `UnannotatedPartial` (INFO severity) could flag partials with no LiquidDoc +block at all, encouraging annotation coverage over time. + +--- + +### 3. Dead translation keys + +**Check code:** `UnusedTranslationKey` +**Severity:** INFO (or WARNING — configurable) +**Type:** cross-file, runs at `onCodePathEnd` + +#### The problem + +`TranslationKeyExists` already catches keys that are *used but not defined*. The inverse +problem — keys that are *defined but never used* — goes completely undetected. Translation +files accumulate dead keys every time a template is renamed, a UI element is removed, or +a feature is sunset. These dead keys: + +- Bloat translation files, making them harder to maintain +- Create confusion when translators work on entries that are never displayed +- Make it impossible to know which translations actually need to be kept in sync + across languages + +#### Why it belongs in pos-cli check + +This requires building two sets across the entire project: +1. All translation keys *used* in templates (`{{ 'key' | t }}` expressions) +2. All translation keys *defined* in `.yml` files + +The difference (defined ∖ used) is the dead set. The check engine's cross-file lifecycle +(`onCodePathStart` / `onCodePathEnd`) is exactly designed for this pattern. The existing +`TranslationKeyExists` check already builds set 1; the machinery for set 2 would require +reading YAML files via `context.fs`. + +#### Implementation sketch + +```js +export const UnusedTranslationKey = { + meta: { + code: 'UnusedTranslationKey', + name: 'Translation key defined but never used', + type: SourceCodeType.LiquidHtml, // processes liquid files to build usage set + severity: Severity.INFO, + schema: { + translationFiles: { + type: 'string', + default: 'app/translations/*.yml', + description: 'Glob for translation files to analyse', + }, + }, + targets: [], + }, + create(context) { + const usedKeys = new Set(); + + return { + async LiquidFilter(node) { + // Detect: {{ 'some.key' | t }} — the filter named 't' applied to a string + if (node.name !== 't') return; + const variable = node.parent; // the LiquidVariable containing this filter + if (variable?.expression?.type === NodeTypes.String) { + usedKeys.add(variable.expression.value); + } + }, + + async onCodePathEnd() { + // After all .liquid files are processed, load and flatten all .yml files + const projectRoot = context.file.uri.replace(/\/app\/.*$/, ''); + const translationDir = path.join(projectRoot, 'app', 'translations'); + + const ymlFiles = await context.fs.readDirectory(translationDir); + for (const ymlFile of ymlFiles.filter(f => f.endsWith('.yml'))) { + const raw = await context.fs.readFile(ymlFile); + const parsed = yaml.parse(raw); // js-yaml + const definedKeys = flattenYamlKeys(parsed); // { key, value, line }[] + + for (const { key, line } of definedKeys) { + if (!usedKeys.has(key)) { + context.report({ + message: `Translation key '${key}' is defined but never used in any template.`, + uri: ymlFile, + startIndex: line, // approximate — YAML parser gives line, not index + endIndex: line, + }); + } + } + } + }, + }; + }, +}; + +function flattenYamlKeys(obj, prefix = '') { + const keys = []; + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'object' && v !== null) { + keys.push(...flattenYamlKeys(v, full)); + } else { + keys.push({ key: full, value: v }); + } + } + return keys; +} +``` + +#### Caveats + +- **Dynamic keys:** `{{ some_variable | t }}` — the key is not a string literal and + cannot be statically resolved. The check must conservatively treat all non-literal + `| t` usages as "might use any key" and exclude them from the dead-key analysis + (or emit an INFO note that dynamic key usage prevents complete analysis). +- **Interpolated keys:** `{{ 'user.greeting' | t: name: current_user.name }}` — + the key IS a literal and can be tracked normally. +- **Multi-language files:** the check should union keys across all language files + (`en.yml`, `pl.yml`, etc.) — a key defined only in one language is not dead, + just untranslated. + +--- + +### 4. `TranslationKeyExists` — nearest-key suggestion + +**Modification to:** existing `TranslationKeyExists` check +**Change:** add `suggest[]` entries with closest matching keys + +#### The problem + +When `TranslationKeyExists` fires today, the offense message is: + +``` +Translation key 'app.hero.titel' does not exist. +``` + +The developer (or agent) then has to manually open the translation file, search for +similar keys, and figure out the correct spelling. In the case above the key is obviously +`app.hero.title` — a one-character typo. The check already has all the information needed +to surface this suggestion. + +#### Why it belongs in pos-cli check + +The `Offense` type already has a `suggest[]` field specifically for this purpose. A +suggestion is additional information attached to an offense that editors and tools can +surface inline. This is zero-cost to add — the check already reads all translation keys +to verify existence; finding the nearest match is just a few more lines. + +#### Implementation + +```js +// Levenshtein distance — simple O(nm) implementation, keys are short strings +function levenshtein(a, b) { + const dp = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) + ); + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i][j] = a[i-1] === b[j-1] + ? dp[i-1][j-1] + : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]); + } + } + return dp[a.length][b.length]; +} + +function findNearestKeys(missingKey, allKeys, maxDistance = 3, maxResults = 3) { + return allKeys + .map(key => ({ key, distance: levenshtein(missingKey, key) })) + .filter(({ distance }) => distance <= maxDistance) + .sort((a, b) => a.distance - b.distance) + .slice(0, maxResults) + .map(({ key }) => key); +} + +// In the existing TranslationKeyExists check, replace the bare context.report() with: +const nearest = findNearestKeys(missingKey, [...allDefinedKeys]); + +context.report({ + message: `Translation key '${missingKey}' does not exist.`, + startIndex: node.position.start, + endIndex: node.position.end, + suggest: nearest.map(key => ({ + message: `Did you mean '${key}'? (value: "${allDefinedKeys.get(key)}")`, + fix: { + startIndex: node.position.start, + endIndex: node.position.end, + newText: `'${key}'`, + }, + })), +}); +``` + +The `fix` on each suggestion means editors and `pos-cli check --fix` can apply the +correction automatically when the user picks a suggestion. + +#### Segment-based fallback + +For keys that have no close Levenshtein match (completely wrong key, not a typo), a +segment-based search is useful: split the missing key by `.` and find defined keys that +share at least one segment. For example, `app.header.titre` has no close match by edit +distance but shares `app.header` with several defined keys. This fallback catches +namespace errors vs typo errors. + +--- + +### 5. `HardcodedRoutes` — autofix + +**Modification to:** existing `HardcodedRoutes` check +**Change:** add `fix` to offenses so `pos-cli check --fix` can correct them + +#### The problem + +`HardcodedRoutes` fires when a literal path like `/products` or `/` appears in a template +context where `{{ routes.products_url }}` (or `{{ '/' | route_url }}`) should be used. +This is already detected. What is missing is an automatic fix. + +The check already knows: +- The offending string (e.g. `"/products"` or just `"/"`) +- Its exact position in the source +- The platformOS routes object keys (via `context.platformosDocset`) + +Everything needed to construct and apply the replacement is already present. + +#### Why it belongs in pos-cli check + +The `Offense.fix` field + `pos-cli check --fix` is the standard platformOS mechanism +for auto-correctable offenses. Adding autofix here means: +- Editors with pos-cli LSP integration can offer a one-click fix +- CI can run `pos-cli check --fix` to auto-correct before failing the build +- `pos-cli check --fix` in a batch migration can update an entire legacy codebase + +Implementing "suggest the route_url replacement" in the plugin is a workaround for a +missing feature in the tool that owns the detection. + +#### Implementation sketch + +```js +// In the existing HardcodedRoutes check, determine the fix based on the offense type: + +// Case 1: literal href="/products" — the path matches a known route slug +const matchingRouteKey = findRouteKey(literalPath, availableRoutes); +if (matchingRouteKey) { + fix = { + startIndex: literalValueStart, // the position of the string content (not the quotes) + endIndex: literalValueEnd, + newText: `{{ routes.${matchingRouteKey} }}`, + }; +} + +// Case 2: literal href="/" — root URL +if (literalPath === '/') { + fix = { + startIndex: literalValueStart, + endIndex: literalValueEnd, + newText: `{{ routes.root_url }}`, + }; +} + +// Case 3: path with no matching route key — suggest route_url filter (less specific) +if (!fix) { + fix = { + startIndex: literalValueStart, + endIndex: literalValueEnd, + newText: `{{ '${literalPath}' | route_url }}`, + }; +} + +context.report({ + message: `Hardcoded route '${literalPath}' — use {{ routes.${matchingRouteKey ?? '...'} }}`, + startIndex: node.position.start, + endIndex: node.position.end, + fix, +}); +``` + +The `StringCorrector` / `applyFixToString` infrastructure in `platformos-check-common` +handles the actual text substitution when `--fix` is invoked. + +--- + +## pos-cli LSP proposals + +### 6. GraphQL result shape in hover + +**LSP method:** `textDocument/hover` on graphql result variables +**Affected files:** `.liquid` files containing `{% graphql result = 'path/to/query' %}` + +#### The problem + +Today, hovering over the result variable `g` in: + +```liquid +{% graphql g = 'records/users' %} +{% for user in g.records.results %} +``` + +returns nothing useful. The agent (and developer) must either: +1. Open the `.graphql` file and mentally trace `query GetUsers { records { results { ... } } }` + back to the access path +2. Guess — and `g.users.results` vs `g.records.results` is the single most common source + of `UnknownProperty` errors in platformOS templates + +The correct access path depends on the query's root field name, which is determined by +the GraphQL schema and the specific query — information the LSP already has. + +#### What the hover should return + +```markdown +**`g`** ← `records/users` (GetUsers) + +**Access pattern:** +{{ g.records.results }} — array of Record objects +{{ g.records.total_entries }} — total count (for pagination) +{{ g.records.total_pages }} + +**Each record has:** +id · created_at · updated_at · properties_object · table +``` + +This eliminates a whole class of runtime errors by making the correct access path +explicit at the point of use. + +#### Implementation approach + +When the LSP receives `hover` on a variable that is the result of a `{% graphql %}` tag: + +1. **Resolve the query file:** parse the `GraphQLMarkup` node to get the query path + (e.g. `records/users`) and resolve to `app/graphql/records/users.graphql` +2. **Parse the query:** use the GraphQL parser to identify the operation name and + root selection field (`records`, `users`, `pages`, etc.) +3. **Look up the return type:** cross-reference the root selection field against the + schema to find its return type (`RecordConnection`, `UserConnection`, etc.) +4. **Build the access path:** from the connection type, derive: + - `g..results` — the items array + - `g..total_entries` — pagination count + - The shape of each item (fields selected in the query) +5. **Return as hover markdown** + +The LSP already performs steps 1–3 for GraphQL diagnostics and completions. Step 4 is +new but straightforward given the type information already resolved. + +#### Liquid variable tracking + +The LSP needs to track that `g` in the Liquid context is bound to the result of the +graphql tag, then recognise `g` in downstream `{{ g.something }}` expressions as the +same binding. This requires a lightweight Liquid variable binding tracker — the LSP +likely already has this for its existing `UndefinedObject` and `UnknownProperty` +diagnostics. + +--- + +### 7. GraphQL type field listing in hover and completions + +**LSP methods:** `textDocument/hover` on type names, `textDocument/completion` inside +selection sets +**Affected files:** `.graphql` files + +#### The problem + +When writing a GraphQL query, the agent (and developer) does not know which fields are +available on a given type without either: +- Running the query and inspecting the result +- Reading the schema file (`app/graphql/schema.graphql`) manually +- Guessing, leading to `UnknownProperty` errors + +Standard GraphQL LSP implementations provide field-level completions and hover out of +the box. The pos-cli LSP has schema awareness (it validates queries against the schema) +but may not surface this at the hover and completion level. + +#### What this should provide + +**Hover on a type name** (`Record`, `User`, `OrderItem`) in a query: +```markdown +**Record** + +Fields: +- `id` — ID +- `table` — String — the table/model name +- `created_at` — String — ISO 8601 timestamp +- `updated_at` — String +- `properties` — JSON — raw properties hash +- `properties_object` — Object — typed property access +- `related_records` — [Record] — associated records +``` + +**Completion inside a selection set** (cursor after `{`): +``` +id +table +created_at +updated_at +properties +properties_object +related_records { ... } +``` + +#### Why this belongs in the LSP + +The LSP already has the schema. GraphQL field completion is standard LSP behavior +(`CompletionItemKind.Field`). The implementation is a standard GraphQL LSP feature — +libraries like `graphql-language-service` implement this and could be integrated or +referenced. The pos-cli LSP could either implement this natively or delegate to +`graphql-language-service-interface` which is schema-aware. + +Implementing this in the plugin would require the plugin to parse the schema, +resolve types, and format completions — all work the LSP already does internally +but does not expose at the hover/completion level. + +--- + +### 8. Circular render detection via `appGraph` + +**LSP feature:** `textDocument/publishDiagnostics` on cyclic render references +**Trigger:** on file save / `didChange` for `.liquid` files involved in a cycle + +#### The problem + +If partial A renders partial B, which renders partial C, which renders partial A, the +result is an infinite loop at runtime — the page request never completes and eventually +times out or crashes. The linter does not detect this. The plugin's `ProjectIndex` knows +each partial's `renders[]` list but cycle detection is not implemented there. + +This is a correctness bug, not a style issue. A render cycle will break any page that +includes any partial in the cycle. + +#### Why it belongs in the LSP + +The LSP already builds and maintains the `appGraph` — the full render dependency graph +for the project. `appGraph/dependencies` gives the transitive dependency tree from any +file. Cycle detection over an already-built graph is a DFS with a visited set — a +trivial algorithm on top of an existing data structure. + +When the LSP detects a cycle, it can publish a diagnostic via +`textDocument/publishDiagnostics` on the specific `{% render %}` tag that closes the +cycle, pointing exactly at the offending line. + +#### Implementation approach + +After rebuilding the `appGraph` (on file save or project index refresh): + +```js +function detectCycles(graph) { + // graph: Map> (file → files it renders) + const cycles = []; + const visited = new Set(); + const stack = []; + + function dfs(node) { + if (stack.includes(node)) { + // Found a cycle — extract the cycle path + const cycleStart = stack.indexOf(node); + cycles.push(stack.slice(cycleStart).concat(node)); + return; + } + if (visited.has(node)) return; + visited.add(node); + stack.push(node); + for (const neighbour of (graph.get(node) ?? [])) { + dfs(neighbour); + } + stack.pop(); + } + + for (const node of graph.keys()) { + if (!visited.has(node)) dfs(node); + } + return cycles; +} +``` + +For each detected cycle, the LSP: +1. Identifies the `{% render %}` tag in the closing file that references back to a + file earlier in the cycle +2. Publishes a diagnostic (ERROR severity) on that specific tag: + ``` + Circular render detected: sections/hero → atoms/icon → sections/hero + This will cause an infinite loop at runtime. + ``` +3. Also publishes the same diagnostic on the opening file's render tag, so both ends + of the cycle are highlighted in the editor + +#### Incremental update + +When a file is saved, only re-run cycle detection for the connected component containing +that file — not the entire graph. The `appGraph` already supports incremental updates; +cycle detection can follow the same invalidation scope. + +--- + +## Summary + +| # | Feature | Target | Severity | Complexity | +|---|---------|--------|----------|------------| +| 1 | N+1 GraphQL query detection | pos-cli check | WARNING | Low — ~60 lines | +| 2 | Render `@param` validation | pos-cli check | ERROR/WARNING | Medium — ~120 lines + cross-file reads | +| 3 | Dead translation keys | pos-cli check | INFO | Medium — needs YAML + cross-file accumulation | +| 4 | `TranslationKeyExists` nearest-key suggest | pos-cli check | (modify existing) | Low — ~30 lines added to existing check | +| 5 | `HardcodedRoutes` autofix | pos-cli check | (modify existing) | Low — fix field on existing offense | +| 6 | GraphQL result shape in hover | pos-cli LSP | — | Medium — query→type resolution | +| 7 | GraphQL type field listing | pos-cli LSP | — | Medium — schema traversal for hover/completions | +| 8 | Circular render detection | pos-cli LSP | ERROR diagnostic | Low — DFS on existing appGraph | + +**Priority recommendation:** #1 (N+1), #4 (suggest), #5 (autofix), #8 (cycles) are the +highest ratio of value to effort. #2 (@param validation) has the most transformative +long-term impact but requires the codebase to adopt `@param` annotations first. diff --git a/docs/plans/2026-03-10-upstream-proposals-design.md b/docs/plans/2026-03-10-upstream-proposals-design.md new file mode 100644 index 0000000..2afdd72 --- /dev/null +++ b/docs/plans/2026-03-10-upstream-proposals-design.md @@ -0,0 +1,348 @@ +# Design: Upstream Proposals — pos-cli check and LSP features + +**Date:** 2026-03-10 +**Source:** UPSTREAM-PROPOSALS.md +**Approach:** Sequential, priority order — each item independently releasable + +--- + +## Overview + +Seven features across two packages: + +- `platformos-check-common` — items #4, #1, #2, #3 (new checks + check modification) +- `platformos-language-server-common` — items #8, #7, #6 (LSP features) + +**New dependency:** `graphql-language-service` added to `platformos-language-server-common` (items #7 and #6). + +**No breaking changes.** All new checks are `recommended: true`. LSP features are additive. + +**Shared utilities** introduced in #4, reused by #3: +- `flattenTranslationKeys(obj, prefix)` — recursive YAML object → dotted key list +- `levenshtein(a, b)` — standard O(nm) DP string distance + +Both extracted to `packages/platformos-check-common/src/utils/levenshtein.ts`. + +--- + +## #4 — TranslationKeyExists nearest-key suggestion + +**Files changed:** +- `packages/platformos-check-common/src/checks/translation-key-exists/index.ts` +- `packages/platformos-check-common/src/utils/levenshtein.ts` (new) + +### Design + +The existing `onCodePathEnd` already determines a key is missing. Two steps are added after that determination: + +1. Load all defined keys via `translationProvider.loadAllTranslationsForBase()` — called once per `onCodePathEnd`, result cached locally so it is not re-read per missing key. +2. Flatten the nested YAML object into `string[]` of dotted keys. Run Levenshtein against each. Return top 3 within distance ≤ 3. +3. Attach as `suggest[]` entries on the existing `context.report()` call. Each suggestion includes a `fix` that replaces the string literal's full position (including quotes) with `'${nearestKey}'`. + +### Edge cases + +- **Module keys** (`modules/foo/some.key`): nearest-key search covers only the non-module translation space. Module keys get no suggestions. +- **No close match**: if `findNearestKeys` returns empty, offense is reported exactly as today. +- **Performance**: `loadAllTranslationsForBase` is called once per file's `onCodePathEnd`, not per missing key. + +### Tests + +- Typo key → suggest entries with correct nearest key and a fix that rewrites the literal. + +--- + +## #1 — NestedGraphQLQuery (new check) + +**Files changed:** +- `packages/platformos-check-common/src/checks/nested-graphql-query/index.ts` (new) +- `packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts` (new) +- `packages/platformos-check-common/src/checks/index.ts` (register) + +**Severity:** WARNING (INFO when inside `{% cache %}`) + +### Design + +Entry visitor on `LiquidTag` maintains a `loopStack: string[]` tracking open `for`/`tablerow` tags by name. When a `graphql` tag is encountered with `loopStack.length > 0`, an offense is reported. If any ancestor node has `name === 'cache'`, severity is downgraded to INFO. + +``` +LiquidTag (entry) → if for/tablerow: push name to loopStack + → if graphql AND loopStack.length > 0: + check ancestors for cache → report WARNING or INFO +LiquidTag:exit → if for/tablerow: pop from loopStack +``` + +Result variable name extracted from `node.markup.name` when `markup.type === NodeTypes.GraphQLMarkup`, otherwise omitted from the message. + +**Offense message:** +``` +N+1 pattern: {% graphql result = '...' %} is inside a {% for %} loop. +This executes one database request per iteration. +Move the query before the loop and pass data as a variable. +``` + +### Edge cases + +- **Nested loops**: `loopStack.length > 1` still triggers — same message. +- **`background` tag inside loop**: not detected — async execution is acceptable. +- **Inline graphql**: detected — `GraphQLInlineMarkup` type, result var omitted from message. + +### Tests + +- `{% graphql %}` outside loop → no offense +- `{% graphql %}` inside `{% for %}` → WARNING +- `{% graphql %}` inside `{% tablerow %}` → WARNING +- `{% graphql %}` inside nested `{% for %}{% for %}` → WARNING +- `{% graphql %}` inside `{% for %}` inside `{% cache %}` → INFO +- `{% background %}` inside `{% for %}` → no offense + +--- + +## #8 — Circular render detection (LSP diagnostic) + +**Files changed:** +- `packages/platformos-language-server-common/src/server/AppGraphManager.ts` + +### Design + +After every graph rebuild in `processQueue`, a new private `detectAndPublishCycles(rootUri)` method runs DFS cycle detection over the dependency graph. Detected cycles are published via `connection.sendDiagnostics` as ERROR-severity diagnostics on the `{% render %}` tag that closes the cycle. Diagnostics are cleared (empty array) when no cycles are found. + +**Algorithm:** Standard DFS with two sets — `visited` (fully processed) and `inStack` (current path, `Set` for O(1) lookup): + +``` +dfs(node, visited, inStack, path): + if node in inStack → cycle found: extract path from cycle-start to current + if node in visited → return + add to visited + inStack + path + recurse on each dependency + remove from inStack + path +``` + +The closing edge of each cycle maps to an `AugmentedReference` via `getDependencies()`, which carries `source.range` — the character range of the offending `{% render %}` tag. + +**Diagnostic format:** +``` +Circular render detected: partials/hero → atoms/icon → partials/hero +This will cause an infinite loop at runtime. +``` + +Source: `'platformos-check'`. Diagnostics cleared on clean rebuild. + +### Edge cases + +- **Self-render** (`{% render 'foo' %}` in `foo.liquid`): caught as 1-node cycle. +- **Multiple cycles**: all reported independently. +- **Cycle resolved on save**: graph rebuilds clean, previous diagnostics cleared. +- **URI not in graph**: skipped in DFS. + +### Tests + +- No cycle → no diagnostics published +- A → B → A → diagnostic on render tag in B referencing A +- Self-render → diagnostic on the tag + +--- + +## #2 — MissingRenderPartialArguments (new check) + +**Files changed:** +- `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts` (new) +- `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts` (new) +- `packages/platformos-check-common/src/checks/index.ts` (register) + +**Severity:** ERROR + +### Design + +`reportMissingArguments()` and `getLiquidDocParams()` already exist in `src/liquid-doc/arguments.ts` and are fully implemented including suggest entries that add the missing argument with a default value. This check wires them up. + +On `RenderMarkup`, resolves the partial's LiquidDoc via `getLiquidDocParams()`. If the partial declares params, filters for those with `required: true` not present in `node.args`. Passes the result to `reportMissingArguments()`. + +```ts +async RenderMarkup(node) { + const partialName = getPartialName(node); + if (!partialName) return; + + const liquidDocParameters = await getLiquidDocParams(context, partialName); + if (!liquidDocParameters) return; // no LiquidDoc → skip + + const providedNames = new Set(node.args.map(a => a.name)); + const missingRequired = [...liquidDocParameters.values()] + .filter(p => p.required && !providedNames.has(p.name)); + + reportMissingArguments(context, node, missingRequired, partialName); +} +``` + +`required` is a first-class field on `LiquidDocParameter` — no heuristic parsing. + +### Interaction with existing checks + +| Check | Concern | Severity | +|---|---|---| +| `UnrecognizedRenderPartialArguments` | Unknown args passed | WARNING | +| `ValidRenderPartialArgumentTypes` | Type mismatches | WARNING | +| `MissingRenderPartialArguments` | Required args omitted | ERROR | + +All three use the same `getLiquidDocParams()` / `getPartialName()` infrastructure. + +### Tests + +- Partial with no LiquidDoc → no offense +- Partial with all optional `@param` → no offense +- Partial with one required `@param`, caller provides it → no offense +- Partial with one required `@param`, caller omits it → ERROR with suggest to add it +- Partial with multiple required params, all missing → one ERROR per param +- Dynamic partial (`{% render variable %}`) → no offense + +--- + +## #3 — UnusedTranslationKey (new check) + +**Files changed:** +- `packages/platformos-check-common/src/checks/unused-translation-key/index.ts` (new) +- `packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts` (new) +- `packages/platformos-check-common/src/checks/index.ts` (register) + +**Severity:** INFO + +### Design + +`create()` is called once per check run. The `usedKeys` Set in the closure persists across all files visited. `LiquidVariable` accumulates string literal `| t` / `| translate` keys across every liquid file. Dynamic `| t` usage (non-string expression) is silently skipped. + +`onCodePathEnd` fires once per liquid file. A `reported` boolean guard ensures the YAML scan and reporting runs exactly once — on the first invocation after all liquid files are processed. + +```ts +create(context) { + const usedKeys = new Set(); + let reported = false; + + return { + async LiquidVariable(node) { + if (node.expression.type !== 'String') return; + if (!node.filters.some(f => f.name === 't' || f.name === 'translate')) return; + usedKeys.add(node.expression.value); + }, + + async onCodePathEnd() { + if (reported) return; + reported = true; + + const rootUri = URI.parse(context.config.rootUri); + const provider = new TranslationProvider(context.fs); + const allTranslations = await provider.loadAllTranslationsForBase( + Utils.joinPath(rootUri, 'app/translations'), 'en' + ); + const definedKeys = flattenTranslationKeys(allTranslations); + + for (const key of definedKeys) { + if (!usedKeys.has(key)) { + context.report({ + message: `Translation key '${key}' is defined but never used in any template.`, + uri: /* YAML file URI */, + startIndex: 0, + endIndex: 0, + }); + } + } + }, + }; +} +``` + +Offenses are reported against the YAML file URI using character offset 0 (line-level precision is not available without a full YAML position tracker — acceptable for INFO severity). + +### Caveats + +- **Module translations**: not scanned, not reported. +- **Non-`en` locales**: only `en` is checked. +- **Dynamic keys**: silently skipped — may produce false positives on keys used only via dynamic lookup. + +### Reused utilities + +`flattenTranslationKeys` from `src/utils/levenshtein.ts` (introduced in #4). + +### Tests + +- Key defined in `en.yml`, used in a template → no offense +- Key defined in `en.yml`, not used anywhere → INFO offense +- Key used with `{{ var | t }}` → no offense (dynamic, silently skipped) +- Key used in one file, defined in another → no offense (accumulation works across files) + +--- + +## #7 — GraphQL field listing in hover and completions + +**New dependency:** `graphql-language-service` in `packages/platformos-language-server-common/package.json` + +**Files changed:** +- `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts` (new) +- `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts` (new) +- `packages/platformos-language-server-common/src/hover/providers/index.ts` (register) +- `packages/platformos-language-server-common/src/completions/providers/index.ts` (register) + +### Design + +Both providers activate on `.graphql` files only (check `uri.endsWith('.graphql')`). + +**Schema access:** `context.platformosDocset.graphQL()` returns the schema string. Both providers call `buildSchema(schemaString)` and cache the result per request. + +**Hover provider:** Calls `getHoverInformation(schema, query, cursor)` from `graphql-language-service`. Returns a standard `Hover` LSP response with the markdown string. + +**Completion provider:** Calls `getAutocompleteSuggestions(schema, query, cursor)` from `graphql-language-service`. Returns `CompletionItem[]`. The library handles context-awareness: inside a selection set it suggests fields, at the operation level it suggests operation types. + +**Error handling:** If `platformosDocset.graphQL()` returns `undefined` or `buildSchema()` throws, providers return `null`/`[]` silently — no uncaught errors. + +### Tests + +Using `HoverAssertion` and `CompletionItemsAssertion` test utilities: +- Hover on field name in `.graphql` file → field type description +- Hover on type name → type description with fields +- Completion inside `{ }` selection set → field names returned +- No schema available → no results, no error thrown + +--- + +## #6 — GraphQL result shape in hover + +**Files changed:** +- `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts` (new) +- `packages/platformos-language-server-common/src/hover/providers/index.ts` (register) + +### Design + +Activates on `.liquid` files only. On hover, walks the document AST to find all `LiquidTag` nodes with `name === 'graphql'`. Builds a map of `resultVarName → queryPath`. If the hovered token matches a result variable name, the provider activates. + +**Query file resolution:** `DocumentsLocator.locate(rootUri, 'graphql', queryPath)` — same mechanism as `MissingPartial`. + +**Shape extraction from the query:** +1. Parse the `.graphql` file with `parse()` from the `graphql` package. +2. Extract the root selection field name (e.g. `records` from `query { records { ... } }`). +3. Find the `results` subfield inside the root field's selection set. +4. Collect the field names selected inside `results { ... }` — read directly from the query's selection set, no schema traversal. + +**Hover output:** +```markdown +**`g`** ← `records/users` + +**Access pattern:** +- `g.records.results` — array of results +- `g.records.total_entries` — total count +- `g.records.total_pages` — page count + +**Selected fields on each result:** +`id` · `created_at` · `email` · `properties` +``` + +If no `results` subfield exists in the query, the field listing section is omitted. + +**Error handling:** +- Query file not found → `null` (let `MissingPartial` handle) +- Query parse error → `null` +- Inline graphql (no file reference) → `null` + +### Tests + +- Hover on result variable → formatted markdown with access pattern and fields +- Hover on non-result variable → `null`, next provider handles it +- Query file missing → `null`, no error thrown +- Query with no `results` subfield → access pattern shown, no field listing diff --git a/docs/plans/2026-03-10-upstream-proposals.md b/docs/plans/2026-03-10-upstream-proposals.md new file mode 100644 index 0000000..15bb33b --- /dev/null +++ b/docs/plans/2026-03-10-upstream-proposals.md @@ -0,0 +1,1501 @@ +# Upstream Proposals Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement 7 developer-tooling improvements across `platformos-check-common` and `platformos-language-server-common` in priority order. + +**Architecture:** Sequential independent tasks — each item is a self-contained new check or LSP feature. Shared utilities (`levenshtein`, `flattenTranslationKeys`) are introduced in Task 1 and reused in Task 5. New `graphql-language-service` dependency added in Task 6. + +**Tech Stack:** TypeScript, Vitest, Ohm.js AST, vscode-languageserver protocol, graphql-language-service + +**Design doc:** `docs/plans/2026-03-10-upstream-proposals-design.md` + +--- + +## Task 1: TranslationKeyExists — nearest-key suggestion (#4) + +**Files:** +- Create: `packages/platformos-check-common/src/utils/levenshtein.ts` +- Modify: `packages/platformos-check-common/src/checks/translation-key-exists/index.ts` +- Modify: `packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts` + +### Step 1: Write the failing test + +Add to `packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts`: + +```ts +it('should suggest nearest key when the key is a typo', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'code.liquid': `{{"general.titel" | t}}`, + }, + [TranslationKeyExists], + ); + + expect(offenses).to.have.length(1); + expect(offenses[0].suggest).to.have.length(1); + expect(offenses[0].suggest![0].message).to.include('general.title'); +}); + +it('should not add suggestions when there is no close key', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'code.liquid': `{{"completely.different.xyz" | t}}`, + }, + [TranslationKeyExists], + ); + + expect(offenses).to.have.length(1); + expect(offenses[0].suggest ?? []).to.have.length(0); +}); +``` + +### Step 2: Run test to verify it fails + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/translation-key-exists/index.spec.ts +``` + +Expected: FAIL — `offenses[0].suggest` is undefined. + +### Step 3: Create levenshtein utility + +Create `packages/platformos-check-common/src/utils/levenshtein.ts`: + +```ts +export function levenshtein(a: string, b: string): number { + const dp: number[][] = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), + ); + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[a.length][b.length]; +} + +export function flattenTranslationKeys( + obj: Record, + prefix = '', +): string[] { + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'object' && v !== null) { + keys.push(...flattenTranslationKeys(v, full)); + } else { + keys.push(full); + } + } + return keys; +} + +export function findNearestKeys( + missingKey: string, + allKeys: string[], + maxDistance = 3, + maxResults = 3, +): string[] { + return allKeys + .map((key) => ({ key, distance: levenshtein(missingKey, key) })) + .filter(({ distance }) => distance <= maxDistance) + .sort((a, b) => a.distance - b.distance) + .slice(0, maxResults) + .map(({ key }) => key); +} +``` + +### Step 4: Modify TranslationKeyExists check + +In `packages/platformos-check-common/src/checks/translation-key-exists/index.ts`, add these imports at the top: + +```ts +import { Utils } from 'vscode-uri'; +import { flattenTranslationKeys, findNearestKeys } from '../../utils/levenshtein'; +``` + +Replace the `onCodePathEnd` method with: + +```ts +async onCodePathEnd() { + let allDefinedKeys: string[] | null = null; + + for (const { translationKey, startIndex, endIndex } of nodes) { + const translation = await translationProvider.translate( + URI.parse(context.config.rootUri), + translationKey, + ); + + if (!!translation) { + continue; + } + + // Lazy-load all keys once per file + if (allDefinedKeys === null) { + const baseUri = Utils.joinPath(URI.parse(context.config.rootUri), 'app/translations'); + const allTranslations = await translationProvider.loadAllTranslationsForBase( + baseUri, + 'en', + ); + allDefinedKeys = flattenTranslationKeys(allTranslations); + } + + const nearest = findNearestKeys(translationKey, allDefinedKeys); + + context.report({ + message: `'${translationKey}' does not have a matching translation entry`, + startIndex, + endIndex, + suggest: nearest.map((key) => ({ + message: `Did you mean '${key}'?`, + fix: (fixer: any) => fixer.replace(startIndex, endIndex, `'${key}'`), + })), + }); + } +}, +``` + +### Step 5: Run test to verify it passes + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/translation-key-exists/index.spec.ts +``` + +Expected: PASS + +### Step 6: Type-check + +```bash +yarn workspace @platformos/platformos-check-common type-check +``` + +Expected: no errors. + +### Step 7: Commit + +```bash +git add packages/platformos-check-common/src/utils/levenshtein.ts \ + packages/platformos-check-common/src/checks/translation-key-exists/index.ts \ + packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts +git commit -m "feat(check): add nearest-key suggestions to TranslationKeyExists + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Task 2: NestedGraphQLQuery — new check (#1) + +**Files:** +- Create: `packages/platformos-check-common/src/checks/nested-graphql-query/index.ts` +- Create: `packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts` +- Modify: `packages/platformos-check-common/src/checks/index.ts` + +### Step 1: Write the failing tests + +Create `packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { runLiquidCheck } from '../../test'; +import { NestedGraphQLQuery } from '.'; + +describe('Module: NestedGraphQLQuery', () => { + it('should not report graphql outside a loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% graphql result = 'products/list' %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should report graphql inside a for loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% graphql result = 'products/get' %}{% endfor %}`, + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('N+1'); + expect(offenses[0].message).to.include('for'); + }); + + it('should report graphql inside a tablerow loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% tablerow item in items %}{% graphql result = 'products/get' %}{% endtablerow %}`, + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('tablerow'); + }); + + it('should report graphql inside nested loops', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for a in items %}{% for b in a.children %}{% graphql result = 'foo' %}{% endfor %}{% endfor %}`, + ); + expect(offenses).to.have.length(1); + }); + + it('should report INFO (not WARNING) when inside both a loop and cache', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% cache 'key' %}{% graphql result = 'foo' %}{% endcache %}{% endfor %}`, + ); + expect(offenses).to.have.length(1); + expect(offenses[0].severity).to.equal(1); // Severity.INFO = 1 + }); + + it('should not report background tag inside a loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% background %}{% graphql result = 'foo' %}{% endbackground %}{% endfor %}`, + ); + // background tag is async — not flagged + expect(offenses).to.have.length(0); + }); +}); +``` + +### Step 2: Run tests to verify they fail + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/nested-graphql-query/index.spec.ts +``` + +Expected: FAIL — module not found. + +### Step 3: Create the check + +Create `packages/platformos-check-common/src/checks/nested-graphql-query/index.ts`: + +```ts +import { NamedTags, NodeTypes, LiquidTag, LiquidHtmlNode } from '@platformos/liquid-html-parser'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; + +export const NestedGraphQLQuery: LiquidCheckDefinition = { + meta: { + code: 'NestedGraphQLQuery', + name: 'GraphQL query inside a loop', + docs: { + description: + 'A {% graphql %} tag inside a {% for %} or {% tablerow %} loop executes one database request per iteration (N+1 pattern). Move the query before the loop and pass results as a variable.', + recommended: true, + url: 'https://documentation.platformos.com/best-practices/performance/graphql-in-loops', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + const loopStack: string[] = []; + + function isInsideBackgroundTag(ancestors: LiquidHtmlNode[]): boolean { + return ancestors.some( + (a) => a.type === NodeTypes.LiquidTag && (a as LiquidTag).name === 'background', + ); + } + + function isInsideCacheTag(ancestors: LiquidHtmlNode[]): boolean { + return ancestors.some( + (a) => a.type === NodeTypes.LiquidTag && (a as LiquidTag).name === 'cache', + ); + } + + return { + async LiquidTag(node: LiquidTag, ancestors: LiquidHtmlNode[]) { + if (node.name === NamedTags.for || node.name === NamedTags.tablerow) { + loopStack.push(node.name); + return; + } + + if (node.name !== NamedTags.graphql) return; + if (loopStack.length === 0) return; + if (isInsideBackgroundTag(ancestors)) return; + + const outerLoop = loopStack[loopStack.length - 1]; + const markup = node.markup; + const resultVar = + typeof markup === 'object' && markup.type === NodeTypes.GraphQLMarkup + ? markup.name + : null; + + const severity = isInsideCacheTag(ancestors) ? Severity.INFO : Severity.WARNING; + + context.report({ + message: + `N+1 pattern: {% graphql ${resultVar ? resultVar + ' = ' : ''}... %} ` + + `is inside a {% ${outerLoop} %} loop. ` + + `This executes one database request per iteration. ` + + `Move the query before the loop and pass data as a variable.`, + startIndex: node.position.start, + endIndex: node.position.end, + severity, + }); + }, + + async 'LiquidTag:exit'(node: LiquidTag) { + if (node.name === NamedTags.for || node.name === NamedTags.tablerow) { + loopStack.pop(); + } + }, + }; + }, +}; +``` + +### Step 4: Register in allChecks + +In `packages/platformos-check-common/src/checks/index.ts`, add: + +```ts +import { NestedGraphQLQuery } from './nested-graphql-query'; +``` + +And add `NestedGraphQLQuery` to the `allChecks` array. + +### Step 5: Run tests to verify they pass + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/nested-graphql-query/index.spec.ts +``` + +Expected: PASS + +### Step 6: Type-check + +```bash +yarn workspace @platformos/platformos-check-common type-check +``` + +Expected: no errors. Fix any type issues around `LiquidTag` node casting. + +### Step 7: Commit + +```bash +git add packages/platformos-check-common/src/checks/nested-graphql-query/ \ + packages/platformos-check-common/src/checks/index.ts +git commit -m "feat(check): add NestedGraphQLQuery check for N+1 detection + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Task 3: Circular render detection — LSP diagnostic (#8) + +**Files:** +- Modify: `packages/platformos-language-server-common/src/server/AppGraphManager.ts` +- Modify: `packages/platformos-language-server-common/src/server/startServer.spec.ts` + +### Step 1: Write the failing test + +Read `packages/platformos-language-server-common/src/server/startServer.spec.ts` first to understand the `MockApp` / `MockConnection` / `startServer` test scaffolding. Then add a new `describe` block for cycle detection: + +```ts +describe('circular render detection', () => { + it('should publish an error diagnostic when a render cycle is detected', async () => { + fileTree = { + '.pos': '', + 'app/views/partials/a.liquid': `{% render 'b' %}`, + 'app/views/partials/b.liquid': `{% render 'a' %}`, + }; + dependencies = getDependencies(logger, fileTree); + startServer(connection, dependencies); + connection.setup(); + await flushAsync(); + + // Open a file to trigger graph build + connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); + await flushAsync(); + vi.runAllTimers(); + await flushAsync(); + + const diagCalls = connection.spies.sendNotification.mock.calls.filter( + ([method]: [string]) => method === PublishDiagnosticsNotification.method, + ); + const cycleDiag = diagCalls.find(([, params]: [string, any]) => + params.diagnostics?.some((d: any) => d.message?.includes('Circular render')), + ); + expect(cycleDiag).toBeDefined(); + }); + + it('should clear cycle diagnostics when the cycle is resolved', async () => { + // Start with a cycle + fileTree = { + '.pos': '', + 'app/views/partials/a.liquid': `{% render 'b' %}`, + 'app/views/partials/b.liquid': `{% render 'a' %}`, + }; + dependencies = getDependencies(logger, fileTree); + startServer(connection, dependencies); + connection.setup(); + await flushAsync(); + + connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); + await flushAsync(); + vi.runAllTimers(); + await flushAsync(); + + // Resolve the cycle + connection.changeDocument('app/views/partials/b.liquid', `

no render

`, 1); + await flushAsync(); + vi.runAllTimers(); + await flushAsync(); + + const diagCalls = connection.spies.sendNotification.mock.calls.filter( + ([method]: [string]) => method === PublishDiagnosticsNotification.method, + ); + // Last diagnostic publish for b.liquid should have empty diagnostics + const lastBDiag = [...diagCalls] + .reverse() + .find(([, params]: [string, any]) => + params.uri?.includes('b.liquid'), + ); + expect(lastBDiag?.[1].diagnostics).toHaveLength(0); + }); +}); +``` + +### Step 2: Run test to verify it fails + +```bash +yarn workspace @platformos/platformos-language-server-common test src/server/startServer.spec.ts +``` + +Expected: FAIL — no cycle diagnostics published. + +### Step 3: Add cycle detection to AppGraphManager + +In `packages/platformos-language-server-common/src/server/AppGraphManager.ts`, add the private cycle detection method and call it at the end of `processQueue`: + +```ts +private detectCycles(modules: Record): string[][] { + const cycles: string[][] = []; + const visited = new Set(); + const inStack = new Set(); + + const dfs = (node: string, path: string[]) => { + if (inStack.has(node)) { + const cycleStart = path.indexOf(node); + cycles.push(path.slice(cycleStart).concat(node)); + return; + } + if (visited.has(node)) return; + + visited.add(node); + inStack.add(node); + path.push(node); + + for (const dep of (modules[node]?.dependencies ?? [])) { + dfs(dep.target.uri, path); + } + + path.pop(); + inStack.delete(node); + }; + + for (const uri of Object.keys(modules)) { + if (!visited.has(uri)) dfs(uri, []); + } + + return cycles; +} + +private async detectAndPublishCycles(rootUri: string) { + const graph = await this.graphs.get(rootUri); + if (!graph) return; + + const cycles = this.detectCycles(graph.modules); + + // Clear previous cycle diagnostics if no cycles + if (cycles.length === 0) { + // Clearing is handled by normal diagnostics flow + return; + } + + for (const cycle of cycles) { + // The closing edge is the last URI in the cycle — find its render tag back to cycle[0] + const closingUri = cycle[cycle.length - 2]; // last file before the cycle wraps + const cyclePath = cycle + .slice(0, -1) // remove duplicate tail + .map((uri) => uri.split('/').slice(-2).join('/')) + .join(' → '); + + this.connection.sendDiagnostics({ + uri: closingUri, + diagnostics: [ + { + severity: 1, // DiagnosticSeverity.Error + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + message: `Circular render detected: ${cyclePath}\nThis will cause an infinite loop at runtime.`, + source: 'platformos-check', + }, + ], + }); + } +} +``` + +Then at the end of the `processQueue` debounced function, after the graph is rebuilt, add: + +```ts +await this.detectAndPublishCycles(rootUri); +``` + +**Note:** `connection.sendDiagnostics` is `connection.sendNotification(PublishDiagnosticsNotification.method, params)` under the hood — check the exact API on the `Connection` type and use whichever is available (`sendDiagnostics` or `sendNotification`). + +### Step 4: Run tests to verify they pass + +```bash +yarn workspace @platformos/platformos-language-server-common test src/server/startServer.spec.ts +``` + +Expected: PASS. If the API surface for `sendDiagnostics` is wrong, inspect the `Connection` type and adjust. + +### Step 5: Type-check + +```bash +yarn workspace @platformos/platformos-language-server-common type-check +``` + +Expected: no errors. + +### Step 6: Commit + +```bash +git add packages/platformos-language-server-common/src/server/AppGraphManager.ts \ + packages/platformos-language-server-common/src/server/startServer.spec.ts +git commit -m "feat(lsp): detect and publish circular render diagnostics + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Task 4: MissingRenderPartialArguments — new check (#2) + +**Files:** +- Create: `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts` +- Create: `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts` +- Modify: `packages/platformos-check-common/src/checks/index.ts` + +### Step 1: Write the failing tests + +Create `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { applySuggestions, runLiquidCheck } from '../../test'; +import { MissingRenderPartialArguments } from '.'; + +function check(partial: string, source: string) { + return runLiquidCheck( + MissingRenderPartialArguments, + source, + undefined, + {}, + { 'app/views/partials/card.liquid': partial }, + ); +} + +const partialWithRequiredParams = ` +{% doc %} + @param {string} title - The card title + @param {string} [subtitle] - Optional subtitle +{% enddoc %} +`; + +describe('Module: MissingRenderPartialArguments', () => { + it('should not report when partial has no LiquidDoc', async () => { + const offenses = await check('

card

', `{% render 'card' %}`); + expect(offenses).to.have.length(0); + }); + + it('should not report when all required params are provided', async () => { + const offenses = await check( + partialWithRequiredParams, + `{% render 'card', title: 'Hello' %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should not report for missing optional params', async () => { + const offenses = await check( + partialWithRequiredParams, + `{% render 'card', title: 'Hello' %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should report ERROR when a required param is missing', async () => { + const offenses = await check(partialWithRequiredParams, `{% render 'card' %}`); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include("title"); + expect(offenses[0].message).to.include("card"); + }); + + it('should suggest adding the missing required param', async () => { + const source = `{% render 'card' %}`; + const offenses = await check(partialWithRequiredParams, source); + expect(offenses[0].suggest).to.have.length(1); + expect(offenses[0].suggest![0].message).to.include("title"); + const fixed = applySuggestions(source, offenses[0]); + expect(fixed[0]).to.include('title'); + }); + + it('should report one ERROR per missing required param', async () => { + const partial = ` + {% doc %} + @param {string} title - title + @param {string} body - body + {% enddoc %} + `; + const offenses = await check(partial, `{% render 'card' %}`); + expect(offenses).to.have.length(2); + }); + + it('should not report for dynamic partials', async () => { + const offenses = await runLiquidCheck( + MissingRenderPartialArguments, + `{% render partial_name %}`, + ); + expect(offenses).to.have.length(0); + }); +}); +``` + +### Step 2: Run tests to verify they fail + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/missing-render-partial-arguments/index.spec.ts +``` + +Expected: FAIL — module not found. + +### Step 3: Create the check + +Create `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts`: + +```ts +import { RenderMarkup } from '@platformos/liquid-html-parser'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { + getLiquidDocParams, + getPartialName, + reportMissingArguments, +} from '../../liquid-doc/arguments'; + +export const MissingRenderPartialArguments: LiquidCheckDefinition = { + meta: { + code: 'MissingRenderPartialArguments', + name: 'Missing Required Render Partial Arguments', + aliases: ['MissingRenderPartialParams'], + docs: { + description: + 'This check ensures that all required @param arguments declared by a partial are provided at the call site.', + recommended: true, + url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/missing-render-partial-arguments', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + + create(context) { + return { + async RenderMarkup(node: RenderMarkup) { + const partialName = getPartialName(node); + if (!partialName) return; + + const liquidDocParameters = await getLiquidDocParams(context, partialName); + if (!liquidDocParameters) return; + + const providedNames = new Set(node.args.map((a) => a.name)); + const missingRequired = [...liquidDocParameters.values()].filter( + (p) => p.required && !providedNames.has(p.name), + ); + + reportMissingArguments(context, node, missingRequired, partialName); + }, + }; + }, +}; +``` + +### Step 4: Register in allChecks + +In `packages/platformos-check-common/src/checks/index.ts`, add: + +```ts +import { MissingRenderPartialArguments } from './missing-render-partial-arguments'; +``` + +Add `MissingRenderPartialArguments` to the `allChecks` array. + +### Step 5: Run tests to verify they pass + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/missing-render-partial-arguments/index.spec.ts +``` + +Expected: PASS + +### Step 6: Type-check + +```bash +yarn workspace @platformos/platformos-check-common type-check +``` + +Expected: no errors. + +### Step 7: Commit + +```bash +git add packages/platformos-check-common/src/checks/missing-render-partial-arguments/ \ + packages/platformos-check-common/src/checks/index.ts +git commit -m "feat(check): add MissingRenderPartialArguments check + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Task 5: UnusedTranslationKey — new check (#3) + +**Files:** +- Create: `packages/platformos-check-common/src/checks/unused-translation-key/index.ts` +- Create: `packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts` +- Modify: `packages/platformos-check-common/src/checks/index.ts` + +### Step 1: Write the failing tests + +Create `packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { check } from '../../test'; +import { UnusedTranslationKey } from '.'; + +describe('Module: UnusedTranslationKey', () => { + it('should not report a key that is used in a template', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'app/views/pages/home.liquid': `{{"general.title" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should report a key that is defined but never used', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello\n unused: Bye', + 'app/views/pages/home.liquid': `{{"general.title" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('general.unused'); + }); + + it('should not report keys used with dynamic variable', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'app/views/pages/home.liquid': `{{ some_key | t }}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); // general.title is still unused + }); + + it('should accumulate used keys across multiple liquid files', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n a: A\n b: B', + 'app/views/pages/page1.liquid': `{{"a" | t}}`, + 'app/views/pages/page2.liquid': `{{"b" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should not report when no translation files exist', async () => { + const offenses = await check( + { + 'app/views/pages/home.liquid': `{{"general.title" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); +}); +``` + +### Step 2: Run tests to verify they fail + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/unused-translation-key/index.spec.ts +``` + +Expected: FAIL — module not found. + +### Step 3: Create the check + +Create `packages/platformos-check-common/src/checks/unused-translation-key/index.ts`: + +```ts +import { URI, Utils } from 'vscode-uri'; +import { TranslationProvider } from '@platformos/platformos-common'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { flattenTranslationKeys } from '../../utils/levenshtein'; + +export const UnusedTranslationKey: LiquidCheckDefinition = { + meta: { + code: 'UnusedTranslationKey', + name: 'Translation key defined but never used', + docs: { + description: + 'Reports translation keys defined in app/translations/en.yml that are never referenced in any Liquid template.', + recommended: true, + url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/unused-translation-key', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.INFO, + schema: {}, + targets: [], + }, + + create(context) { + const usedKeys = new Set(); + let reported = false; + + return { + async LiquidVariable(node) { + if (node.expression.type !== 'String') return; + if (!node.filters.some((f) => f.name === 't' || f.name === 'translate')) return; + usedKeys.add(node.expression.value); + }, + + async onCodePathEnd() { + if (reported) return; + reported = true; + + const rootUri = URI.parse(context.config.rootUri); + const baseUri = Utils.joinPath(rootUri, 'app/translations'); + const provider = new TranslationProvider(context.fs); + + let allTranslations: Record; + try { + allTranslations = await provider.loadAllTranslationsForBase(baseUri, 'en'); + } catch { + return; + } + + const definedKeys = flattenTranslationKeys(allTranslations); + + for (const key of definedKeys) { + if (!usedKeys.has(key)) { + context.report({ + message: `Translation key '${key}' is defined but never used in any template.`, + startIndex: 0, + endIndex: 0, + }); + } + } + }, + }; + }, +}; +``` + +### Step 4: Register in allChecks + +In `packages/platformos-check-common/src/checks/index.ts`, add: + +```ts +import { UnusedTranslationKey } from './unused-translation-key'; +``` + +Add `UnusedTranslationKey` to the `allChecks` array. + +### Step 5: Run tests to verify they pass + +```bash +yarn workspace @platformos/platformos-check-common test src/checks/unused-translation-key/index.spec.ts +``` + +Expected: PASS. The `reported` guard ensures `onCodePathEnd` fires only once despite being called per file. + +### Step 6: Type-check + +```bash +yarn workspace @platformos/platformos-check-common type-check +``` + +Expected: no errors. + +### Step 7: Commit + +```bash +git add packages/platformos-check-common/src/checks/unused-translation-key/ \ + packages/platformos-check-common/src/checks/index.ts +git commit -m "feat(check): add UnusedTranslationKey check + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Task 6: GraphQL field listing in hover and completions (#7) + +**Files:** +- Modify: `packages/platformos-language-server-common/package.json` +- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts` +- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts` +- Create: `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts` +- Create: `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.spec.ts` +- Modify: `packages/platformos-language-server-common/src/hover/HoverProvider.ts` +- Modify: `packages/platformos-language-server-common/src/hover/providers/index.ts` +- Modify: `packages/platformos-language-server-common/src/completions/providers/index.ts` + +### Step 1: Add the dependency + +```bash +yarn workspace @platformos/platformos-language-server-common add graphql-language-service +``` + +Verify it appears in `packages/platformos-language-server-common/package.json`. + +### Step 2: Write the failing hover test + +Before writing, read `packages/platformos-language-server-common/src/hover/providers/TranslationHoverProvider.spec.ts` to understand the `HoverProvider` constructor signature and the `hover` custom matcher. + +Create `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { DocumentManager } from '../../documents'; +import { HoverProvider } from '../HoverProvider'; +import { MockFileSystem } from '@platformos/platformos-check-common/src/test'; +import { TranslationProvider } from '@platformos/platformos-common'; + +const SCHEMA = ` + type Query { + records(filter: String): RecordConnection + } + type RecordConnection { + results: [Record] + total_entries: Int + total_pages: Int + } + type Record { + id: ID + table: String + created_at: String + } +`; + +describe('Module: GraphQLFieldHoverProvider', () => { + let provider: HoverProvider; + + beforeEach(() => { + provider = new HoverProvider( + new DocumentManager(), + { + graphQL: async () => SCHEMA, + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], + }, + new TranslationProvider(new MockFileSystem({})), + undefined, + undefined, + async () => '.', + ); + }); + + it('should return hover for a field name in a graphql file', async () => { + await expect(provider).to.hover( + // cursor on 'records' field — use █ to mark cursor position + // Note: hover in .graphql files needs a .graphql URI + // Read existing hover specs to understand how to pass a non-.liquid URI + // and adjust accordingly. + `query { re█cords { results { id } } }`, + expect.stringContaining('RecordConnection'), + 'app/graphql/test.graphql', + ); + }); + + it('should return null for a .graphql file when schema is unavailable', async () => { + const noSchemaProvider = new HoverProvider( + new DocumentManager(), + { + graphQL: async () => null, + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], + }, + new TranslationProvider(new MockFileSystem({})), + ); + await expect(noSchemaProvider).to.hover( + `query { re█cords { id } }`, + null, + 'app/graphql/test.graphql', + ); + }); +}); +``` + +### Step 3: Run test to verify it fails + +```bash +yarn workspace @platformos/platformos-language-server-common test src/hover/providers/GraphQLFieldHoverProvider.spec.ts +``` + +Expected: FAIL — provider not found / returns null. + +### Step 4: Create the hover provider + +Create `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts`: + +```ts +import { buildSchema } from 'graphql'; +import { getHoverInformation } from 'graphql-language-service'; +import { Hover, HoverParams } from 'vscode-languageserver'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { PlatformOSDocset } from '@platformos/platformos-check-common'; +import { DocumentManager } from '../../documents'; +import { BaseHoverProvider } from '../BaseHoverProvider'; +import { LiquidHtmlNode } from '@platformos/platformos-check-common'; + +export class GraphQLFieldHoverProvider implements BaseHoverProvider { + constructor( + private documentManager: DocumentManager, + private platformosDocset: PlatformOSDocset, + ) {} + + async hover( + _currentNode: LiquidHtmlNode, + _ancestors: LiquidHtmlNode[], + params: HoverParams, + ): Promise { + const uri = params.textDocument.uri; + if (!uri.endsWith('.graphql')) return null; + + const schemaString = await this.platformosDocset.graphQL(); + if (!schemaString) return null; + + let schema; + try { + schema = buildSchema(schemaString); + } catch { + return null; + } + + const document = this.documentManager.get(uri); + if (!document) return null; + + const content = document.source; + const position = params.position; + + try { + const hoverInfo = getHoverInformation(schema, content, position); + if (!hoverInfo) return null; + return { + contents: { kind: 'markdown', value: String(hoverInfo) }, + }; + } catch { + return null; + } + } +} +``` + +**Note:** `getHoverInformation` from `graphql-language-service` takes a `Position` compatible with `{ line, character }` — the LSP `params.position` is compatible. However, the `HoverProvider` normally only handles `.liquid` files — read the `HoverProvider.hover()` method to see where to bypass the liquid-only guard for `.graphql` URIs. You may need to add an early-exit path for graphql files that skips AST traversal and calls `GraphQLFieldHoverProvider` directly. + +### Step 5: Register the hover provider + +In `packages/platformos-language-server-common/src/hover/HoverProvider.ts`, add `GraphQLFieldHoverProvider` to the `providers` array in the constructor. Export it from `packages/platformos-language-server-common/src/hover/providers/index.ts`. + +Also update `HoverProvider.hover()` to handle `.graphql` URIs — they have no liquid AST, so the node traversal will not work. Add a guard at the top: + +```ts +if (uri.endsWith('.graphql')) { + // For graphql files, skip liquid AST traversal and try graphql-specific providers + for (const provider of this.providers) { + if (provider instanceof GraphQLFieldHoverProvider) { + const result = await provider.hover({} as any, [], params); + if (result) return result; + } + } + return null; +} +``` + +### Step 6: Write and wire the completion provider + +Create `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts` following the same pattern as the hover provider but using `getAutocompleteSuggestions(schema, content, position)` from `graphql-language-service`. Return `CompletionItem[]`. + +Read `packages/platformos-language-server-common/src/completions/providers/PartialCompletionProvider.ts` or `FilterCompletionProvider.ts` for the `Provider` base class interface, then implement accordingly. + +Register in the `CompletionProvider` similarly to how `GraphQLFieldHoverProvider` is registered — with a `.graphql` URI guard. + +### Step 7: Run all tests to verify they pass + +```bash +yarn workspace @platformos/platformos-language-server-common test +``` + +Expected: PASS (or investigate failures and fix). + +### Step 8: Type-check + +```bash +yarn workspace @platformos/platformos-language-server-common type-check +``` + +Expected: no errors. + +### Step 9: Commit + +```bash +git add packages/platformos-language-server-common/package.json \ + packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts \ + packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts \ + packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts \ + packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.spec.ts \ + packages/platformos-language-server-common/src/hover/HoverProvider.ts \ + packages/platformos-language-server-common/src/hover/providers/index.ts \ + packages/platformos-language-server-common/src/completions/providers/index.ts \ + yarn.lock +git commit -m "feat(lsp): add GraphQL field hover and completions using graphql-language-service + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Task 7: GraphQL result shape hover (#6) + +**Files:** +- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts` +- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.spec.ts` +- Modify: `packages/platformos-language-server-common/src/hover/HoverProvider.ts` +- Modify: `packages/platformos-language-server-common/src/hover/providers/index.ts` + +### Step 1: Write the failing test + +Read `packages/platformos-language-server-common/src/hover/providers/RenderPartialHoverProvider.spec.ts` for the pattern of testing hover providers that need to resolve files. + +Create `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.spec.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { DocumentManager } from '../../documents'; +import { HoverProvider } from '../HoverProvider'; +import { MockFileSystem } from '@platformos/platformos-check-common/src/test'; +import { TranslationProvider } from '@platformos/platformos-common'; +import { path } from '@platformos/platformos-check-common'; + +const mockRoot = path.normalize('browser:/'); + +describe('Module: GraphQLResultHoverProvider', () => { + let provider: HoverProvider; + + beforeEach(() => { + const fs = new MockFileSystem({ + 'app/graphql/records/list.graphql': ` + query GetRecords { + records { + results { + id + table + created_at + } + total_entries + total_pages + } + } + `, + }, mockRoot); + + provider = new HoverProvider( + new DocumentManager(), + { + graphQL: async () => null, + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], + }, + new TranslationProvider(fs), + undefined, + undefined, + async () => mockRoot, + ); + + // Inject the fs into the provider for file resolution + // (read HoverProvider constructor — you may need to add an fs parameter) + }); + + it('should return access pattern when hovering the graphql result variable', async () => { + await expect(provider).to.hover( + `{% graphql g = 'records/list' %}{{ █g.records }}`, + expect.stringContaining('g.records.results'), + ); + }); + + it('should list fields selected in the query', async () => { + await expect(provider).to.hover( + `{% graphql g = 'records/list' %}{{ █g.records }}`, + expect.stringContaining('id'), + ); + }); + + it('should return null when hovering a non-graphql-result variable', async () => { + await expect(provider).to.hover( + `{% assign foo = 'bar' %}{{ █foo }}`, + null, + ); + }); + + it('should return null when the query file does not exist', async () => { + await expect(provider).to.hover( + `{% graphql g = 'does/not/exist' %}{{ █g }}`, + null, + ); + }); +}); +``` + +### Step 2: Run test to verify it fails + +```bash +yarn workspace @platformos/platformos-language-server-common test src/hover/providers/GraphQLResultHoverProvider.spec.ts +``` + +Expected: FAIL. + +### Step 3: Create the provider + +Create `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts`: + +```ts +import { parse, OperationDefinitionNode, FieldNode, SelectionSetNode } from 'graphql'; +import { Hover, HoverParams } from 'vscode-languageserver'; +import { AbstractFileSystem } from '@platformos/platformos-common'; +import { DocumentsLocator } from '@platformos/platformos-common'; +import { LiquidHtmlNode, NodeTypes, findCurrentNode } from '@platformos/platformos-check-common'; +import { LiquidTag, NamedTags } from '@platformos/liquid-html-parser'; +import { URI } from 'vscode-uri'; +import { DocumentManager } from '../../documents'; +import { BaseHoverProvider } from '../BaseHoverProvider'; +import { FindAppRootURI } from '../../internal-types'; + +interface GraphQLBinding { + resultVar: string; + queryPath: string; +} + +function extractGraphQLBindings(ast: LiquidHtmlNode): GraphQLBinding[] { + const bindings: GraphQLBinding[] = []; + // Walk the AST looking for {% graphql varName = 'path' %} tags + // Use the visit() utility from platformos-check-common + // Each LiquidTag with name === 'graphql' and markup.type === NodeTypes.GraphQLMarkup + // gives markup.name (result var) and markup.graphql.value (query path) + function walk(node: any) { + if (node?.type === NodeTypes.LiquidTag && node.name === NamedTags.graphql) { + const markup = node.markup; + if (markup?.type === NodeTypes.GraphQLMarkup && markup.name && markup.graphql?.type === 'String') { + bindings.push({ resultVar: markup.name, queryPath: markup.graphql.value }); + } + } + for (const key of ['children', 'body', 'markup']) { + const child = node?.[key]; + if (Array.isArray(child)) child.forEach(walk); + else if (child && typeof child === 'object') walk(child); + } + } + walk(ast); + return bindings; +} + +function getSelectedFields(selectionSet: SelectionSetNode | undefined): string[] { + if (!selectionSet) return []; + return selectionSet.selections + .filter((s): s is FieldNode => s.kind === 'Field') + .map((f) => f.name.value); +} + +function buildHoverMarkdown(resultVar: string, queryPath: string, rootField: string, selectedFields: string[]): string { + const lines = [ + `**\`${resultVar}\`** ← \`${queryPath}\``, + '', + '**Access pattern:**', + `- \`${resultVar}.${rootField}.results\` — array of results`, + `- \`${resultVar}.${rootField}.total_entries\` — total count`, + `- \`${resultVar}.${rootField}.total_pages\` — page count`, + ]; + + if (selectedFields.length > 0) { + lines.push('', '**Selected fields on each result:**'); + lines.push(selectedFields.map((f) => `\`${f}\``).join(' · ')); + } + + return lines.join('\n'); +} + +export class GraphQLResultHoverProvider implements BaseHoverProvider { + constructor( + private documentManager: DocumentManager, + private fs: AbstractFileSystem, + private findAppRootURI: FindAppRootURI, + ) {} + + async hover( + currentNode: LiquidHtmlNode, + ancestors: LiquidHtmlNode[], + params: HoverParams, + ): Promise { + const uri = params.textDocument.uri; + if (uri.endsWith('.graphql')) return null; + + // Resolve hovered token name + if (currentNode.type !== NodeTypes.VariableLookup) return null; + const hoveredName = (currentNode as any).name; + if (!hoveredName) return null; + + // Find graphql bindings in the document + const document = this.documentManager.get(uri); + if (!document || document.ast instanceof Error) return null; + + const bindings = extractGraphQLBindings(document.ast as LiquidHtmlNode); + const binding = bindings.find((b) => b.resultVar === hoveredName); + if (!binding) return null; + + // Resolve the query file + const rootUri = await this.findAppRootURI(uri); + if (!rootUri) return null; + + const locator = new DocumentsLocator(this.fs); + const queryFileUri = await locator.locate(URI.parse(rootUri), 'graphql', binding.queryPath); + if (!queryFileUri) return null; + + // Parse the query + let querySource: string; + try { + querySource = await this.fs.readFile(queryFileUri); + } catch { + return null; + } + + let queryDoc; + try { + queryDoc = parse(querySource); + } catch { + return null; + } + + // Extract root field and results fields + const operation = queryDoc.definitions.find( + (d): d is OperationDefinitionNode => d.kind === 'OperationDefinition', + ); + if (!operation) return null; + + const rootField = operation.selectionSet.selections.find( + (s): s is FieldNode => s.kind === 'Field', + ); + if (!rootField) return null; + + const rootFieldName = rootField.name.value; + const resultsField = rootField.selectionSet?.selections + .filter((s): s is FieldNode => s.kind === 'Field') + .find((f) => f.name.value === 'results'); + + const selectedFields = getSelectedFields(resultsField?.selectionSet); + + return { + contents: { + kind: 'markdown', + value: buildHoverMarkdown(binding.resultVar, binding.queryPath, rootFieldName, selectedFields), + }, + }; + } +} +``` + +**Note:** The `HoverProvider` constructor currently doesn't receive an `AbstractFileSystem`. You will need to: +1. Add an optional `fs?: AbstractFileSystem` parameter to `HoverProvider`'s constructor +2. Pass it through from `startServer.ts` where `HoverProvider` is instantiated +3. Pass `fs` to `GraphQLResultHoverProvider` in the providers array + +Read `packages/platformos-language-server-common/src/server/startServer.ts` to see how `HoverProvider` is currently constructed, and trace back where `AbstractFileSystem` is available. + +### Step 4: Register the provider + +In `packages/platformos-language-server-common/src/hover/HoverProvider.ts`, add `GraphQLResultHoverProvider` to the providers array (near the end, before `LiquidDocTagHoverProvider`). Export from `providers/index.ts`. + +### Step 5: Run tests to verify they pass + +```bash +yarn workspace @platformos/platformos-language-server-common test src/hover/providers/GraphQLResultHoverProvider.spec.ts +``` + +Expected: PASS. Iterate on the `extractGraphQLBindings` traversal if the AST walk doesn't find the graphql tags — use the `visit()` utility from `platformos-check-common` instead of the manual walk above if needed. + +### Step 6: Run the full test suite + +```bash +yarn workspace @platformos/platformos-language-server-common test +``` + +Expected: all passing. + +### Step 7: Type-check + +```bash +yarn workspace @platformos/platformos-language-server-common type-check +``` + +Expected: no errors. + +### Step 8: Commit + +```bash +git add packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts \ + packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.spec.ts \ + packages/platformos-language-server-common/src/hover/HoverProvider.ts \ + packages/platformos-language-server-common/src/hover/providers/index.ts \ + packages/platformos-language-server-common/src/server/startServer.ts +git commit -m "feat(lsp): add GraphQL result shape hover for Liquid templates + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Final validation + +After all 7 tasks are complete, run the full monorepo test suite and type-check: + +```bash +yarn test +yarn type-check +``` + +Expected: all tests passing, no type errors. diff --git a/packages/platformos-check-common/src/checks/index.ts b/packages/platformos-check-common/src/checks/index.ts index ec8c454..9d1c8dd 100644 --- a/packages/platformos-check-common/src/checks/index.ts +++ b/packages/platformos-check-common/src/checks/index.ts @@ -36,6 +36,7 @@ import { GraphQLCheck } from './graphql'; import { UnknownProperty } from './unknown-property'; import { InvalidHashAssignTarget } from './invalid-hash-assign-target'; import { DuplicateFunctionArguments } from './duplicate-function-arguments'; +import { NestedGraphQLQuery } from './nested-graphql-query'; export const allChecks: ( | LiquidCheckDefinition @@ -73,6 +74,7 @@ export const allChecks: ( GraphQLCheck, UnknownProperty, InvalidHashAssignTarget, + NestedGraphQLQuery, ]; /** diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts new file mode 100644 index 0000000..bb30157 --- /dev/null +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { runLiquidCheck } from '../../test'; +import { NestedGraphQLQuery } from '.'; + +describe('Module: NestedGraphQLQuery', () => { + it('should not report graphql outside a loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% graphql result = 'products/list' %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should report graphql inside a for loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% graphql result = 'products/get' %}{% endfor %}`, + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('N+1'); + expect(offenses[0].message).to.include('for'); + }); + + it('should report graphql inside a tablerow loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% tablerow item in items %}{% graphql result = 'products/get' %}{% endtablerow %}`, + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('tablerow'); + }); + + it('should report graphql inside nested loops', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for a in items %}{% for b in a.children %}{% graphql result = 'foo' %}{% endfor %}{% endfor %}`, + ); + expect(offenses).to.have.length(1); + }); + + it('should not report graphql inside a loop when wrapped in cache', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% cache 'key' %}{% graphql result = 'foo' %}{% endcache %}{% endfor %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should not report background tag inside a loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% background %}{% graphql result = 'foo' %}{% endbackground %}{% endfor %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should report graphql inline markup inside a for loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% graphql result %}query { records { results { id } } }{% endgraphql %}{% endfor %}`, + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('result'); + }); + + it('should report multiple graphql tags inside one loop', async () => { + const offenses = await runLiquidCheck( + NestedGraphQLQuery, + `{% for item in items %}{% graphql a = 'foo' %}{% graphql b = 'bar' %}{% endfor %}`, + ); + expect(offenses).to.have.length(2); + }); +}); diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts new file mode 100644 index 0000000..fc4c792 --- /dev/null +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -0,0 +1,63 @@ +import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { isLoopLiquidTag } from '../utils'; + +export const NestedGraphQLQuery: LiquidCheckDefinition = { + meta: { + code: 'NestedGraphQLQuery', + name: 'Prevent N+1 GraphQL queries in loops', + docs: { + description: + 'This check detects {% graphql %} tags placed inside loop tags ({% for %}, {% tablerow %}), which causes one database request per loop iteration (N+1 pattern).', + recommended: true, + url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/nested-graphql-query', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidTag(node, ancestors) { + if (node.name !== NamedTags.graphql) return; + + const ancestorTags = ancestors.filter( + (a) => a.type === NodeTypes.LiquidTag, + ); + + const loopAncestor = ancestorTags.find(isLoopLiquidTag); + + if (!loopAncestor) return; + + // Skip if inside a background tag + const inBackground = ancestorTags.some((a) => a.name === NamedTags.background); + if (inBackground) return; + + // Skip if inside a cache block (caching mitigates the N+1 problem) + const inCache = ancestorTags.some((a) => a.name === NamedTags.cache); + if (inCache) return; + + let resultName = ''; + if ( + typeof node.markup !== 'string' && + (node.markup.type === NodeTypes.GraphQLMarkup || + node.markup.type === NodeTypes.GraphQLInlineMarkup) + ) { + resultName = node.markup.name ? ` result = '${node.markup.name}'` : ''; + } + + const graphqlStr = resultName ? `{% graphql${resultName} %}` : '{% graphql %}'; + + const message = `N+1 pattern: ${graphqlStr} is inside a {% ${loopAncestor.name} %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.`; + + context.report({ + message, + startIndex: node.position.start, + endIndex: node.position.end, + }); + }, + }; + }, +}; diff --git a/packages/platformos-check-common/src/context-utils.ts b/packages/platformos-check-common/src/context-utils.ts index 47b83f3..40af177 100644 --- a/packages/platformos-check-common/src/context-utils.ts +++ b/packages/platformos-check-common/src/context-utils.ts @@ -145,7 +145,13 @@ export async function recursiveReadDirectory( uri: string, filter: (fileTuple: FileTuple) => boolean, ): Promise { - const allFiles = await fs.readDirectory(uri); + let allFiles: FileTuple[]; + try { + allFiles = await fs.readDirectory(uri); + } catch (err: any) { + if (err?.code === 'ENOENT') return []; + throw err; + } const files = allFiles.filter((ft) => !isIgnored(ft) && (isDirectory(ft) || filter(ft))); const results = await Promise.all( diff --git a/packages/platformos-check-common/src/utils/index.ts b/packages/platformos-check-common/src/utils/index.ts index 0a2062a..5f22560 100644 --- a/packages/platformos-check-common/src/utils/index.ts +++ b/packages/platformos-check-common/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './error'; export * from './types'; export * from './memo'; export * from './indexBy'; +export * from './levenshtein'; diff --git a/packages/platformos-check-common/src/utils/levenshtein.ts b/packages/platformos-check-common/src/utils/levenshtein.ts new file mode 100644 index 0000000..808b75f --- /dev/null +++ b/packages/platformos-check-common/src/utils/levenshtein.ts @@ -0,0 +1,44 @@ +export function levenshtein(a: string, b: string): number { + const dp: number[][] = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), + ); + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[a.length][b.length]; +} + +export function flattenTranslationKeys( + obj: Record, + prefix = '', +): string[] { + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'object' && v !== null) { + keys.push(...flattenTranslationKeys(v, full)); + } else { + keys.push(full); + } + } + return keys; +} + +export function findNearestKeys( + missingKey: string, + allKeys: string[], + maxDistance = 3, + maxResults = 3, +): string[] { + return allKeys + .map((key) => ({ key, distance: levenshtein(missingKey, key) })) + .filter(({ distance }) => distance <= maxDistance) + .sort((a, b) => a.distance - b.distance) + .slice(0, maxResults) + .map(({ key }) => key); +} diff --git a/packages/platformos-check-node/configs/all.yml b/packages/platformos-check-node/configs/all.yml index a33686f..7bc19a2 100644 --- a/packages/platformos-check-node/configs/all.yml +++ b/packages/platformos-check-node/configs/all.yml @@ -46,6 +46,9 @@ MissingPartial: enabled: true severity: 0 ignoreMissing: [] +NestedGraphQLQuery: + enabled: true + severity: 1 OrphanedPartial: enabled: true severity: 1 diff --git a/packages/platformos-check-node/configs/recommended.yml b/packages/platformos-check-node/configs/recommended.yml index a33686f..7bc19a2 100644 --- a/packages/platformos-check-node/configs/recommended.yml +++ b/packages/platformos-check-node/configs/recommended.yml @@ -46,6 +46,9 @@ MissingPartial: enabled: true severity: 0 ignoreMissing: [] +NestedGraphQLQuery: + enabled: true + severity: 1 OrphanedPartial: enabled: true severity: 1 diff --git a/packages/platformos-language-server-common/src/server/AppGraphManager.ts b/packages/platformos-language-server-common/src/server/AppGraphManager.ts index 388fce6..27c491b 100644 --- a/packages/platformos-language-server-common/src/server/AppGraphManager.ts +++ b/packages/platformos-language-server-common/src/server/AppGraphManager.ts @@ -1,6 +1,7 @@ import { path, SourceCodeType } from '@platformos/platformos-check-common'; import { AbstractFileSystem } from '@platformos/platformos-common'; import { + AppGraph, buildAppGraph, getWebComponentMap, IDependencies as GraphDependencies, @@ -9,7 +10,7 @@ import { WebComponentMap, } from '@platformos/platformos-graph'; import { Range } from 'vscode-json-languageservice'; -import { Connection } from 'vscode-languageserver'; +import { Connection, DiagnosticSeverity, PublishDiagnosticsNotification } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentManager } from '../documents'; import { @@ -23,6 +24,7 @@ import { FindAppRootURI } from '../internal-types'; export class AppGraphManager { graphs: Map> = new Map(); + private cycleAffectedUris: Set = new Set(); constructor( private connection: Connection, @@ -153,20 +155,132 @@ export class AppGraphManager { const rootUri = await this.findAppRootURI(anyUri); if (!rootUri) return; - const graph = await this.graphs.get(rootUri); - if (!graph) return; - + // Delete existing graph to force rebuild this.graphs.delete(rootUri); await this.getAppGraphForURI(rootUri); this.connection.sendNotification(AppGraphDidUpdateNotification.type, { uri: rootUri }); }, 500); + /** + * Detect cycles in the dependency graph using iterative DFS. + * Returns arrays of URIs forming cycles. + */ + private detectCycles(graph: AppGraph): string[][] { + const cycles: string[][] = []; + const visited = new Set(); + const inStack = new Set(); + + // Build adjacency map: uri -> direct dependency uris + const adjacency = new Map(); + for (const [uri, module] of Object.entries(graph.modules)) { + const directDeps = module.dependencies + .filter((dep) => dep.type === 'direct') + .map((dep) => dep.target.uri); + adjacency.set(uri, directDeps); + } + + const dfs = (uri: string, stack: string[]): void => { + if (inStack.has(uri)) { + // Found a cycle — extract the cycle portion + const cycleStart = stack.indexOf(uri); + cycles.push([...stack.slice(cycleStart), uri]); + return; + } + if (visited.has(uri)) return; + + inStack.add(uri); + stack.push(uri); + + const deps = adjacency.get(uri) ?? []; + for (const depUri of deps) { + dfs(depUri, stack); + } + + stack.pop(); + inStack.delete(uri); + visited.add(uri); + }; + + for (const uri of adjacency.keys()) { + if (!visited.has(uri)) { + dfs(uri, []); + } + } + + return cycles; + } + + /** + * Run cycle detection and publish diagnostics for all affected URIs. + * Clears diagnostics for previously affected URIs when cycles no longer exist. + */ + private detectAndPublishCycles(graph: AppGraph): void { + const cycles = this.detectCycles(graph); + const newlyAffectedUris = new Set(); + + if (cycles.length > 0) { + // Group cycles by which URIs are involved + const cyclesByUri = new Map(); + for (const cycle of cycles) { + // All URIs in the cycle (excluding the trailing duplicate) are affected + const cycleUris = cycle.slice(0, -1); + for (const uri of cycleUris) { + newlyAffectedUris.add(uri); + if (!cyclesByUri.has(uri)) { + cyclesByUri.set(uri, []); + } + cyclesByUri.get(uri)!.push(cycle); + } + } + + // Publish diagnostics for all affected URIs + for (const [uri, uriCycles] of cyclesByUri.entries()) { + const diagnostics = uriCycles.map((cycle) => { + const cycleDescription = cycle + .map((u) => { + const parts = u.split('/'); + return parts.slice(-2).join('/'); + }) + .join(' → '); + return { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + severity: DiagnosticSeverity.Error, + message: `Circular render detected: ${cycleDescription}\nThis will cause an infinite loop at runtime.`, + source: 'platformos-check', + }; + }); + + this.connection.sendNotification(PublishDiagnosticsNotification.type, { + uri, + diagnostics, + }); + } + } + + // Clear diagnostics for URIs that are no longer affected + for (const uri of this.cycleAffectedUris) { + if (!newlyAffectedUris.has(uri)) { + this.connection.sendNotification(PublishDiagnosticsNotification.type, { + uri, + diagnostics: [], + }); + } + } + + this.cycleAffectedUris = newlyAffectedUris; + } + private buildAppGraph = async (rootUri: string, entryPoints?: string[]) => { const { documentManager } = this; await documentManager.preload(rootUri); const dependencies = await this.graphDependencies(rootUri); - return buildAppGraph(rootUri, dependencies, entryPoints); + const graph = await buildAppGraph(rootUri, dependencies, entryPoints); + this.detectAndPublishCycles(graph); + return graph; }; private getSourceCode = async (uri: string) => { diff --git a/packages/platformos-language-server-common/src/server/startServer.spec.ts b/packages/platformos-language-server-common/src/server/startServer.spec.ts index f0d2518..cae2fef 100644 --- a/packages/platformos-language-server-common/src/server/startServer.spec.ts +++ b/packages/platformos-language-server-common/src/server/startServer.spec.ts @@ -315,6 +315,135 @@ describe('Module: server', () => { ); }); + describe('circular render detection', () => { + beforeEach(() => { + fileTree = { + '.pos': '', + 'app/views/pages/index.liquid': `{% render 'a' %}`, + 'app/views/partials/a.liquid': `{% render 'b' %}`, + 'app/views/partials/b.liquid': `{% render 'a' %}`, + 'assets/.keep': '', + }; + dependencies = getDependencies(logger, fileTree); + connection = mockConnection(mockRoot); + connection.spies.sendRequest.mockImplementation(async (method: any, params: any) => { + if (method === 'workspace/configuration') { + return params.items.map(({ section }: any) => { + switch (section) { + case CHECK_ON_CHANGE: + return checkOnChange; + case CHECK_ON_OPEN: + return checkOnOpen; + case CHECK_ON_SAVE: + return checkOnSave; + default: + return null; + } + }); + } else if (method === 'client/registerCapability') { + return null; + } else { + throw new Error( + `Does not know how to mock response to '${method}' requests. Check your test.`, + ); + } + }); + startServer(connection, dependencies); + }); + + it('should publish an error diagnostic when a render cycle is detected', async () => { + connection.setup(); + await flushAsync(); + + connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); + connection.triggerNotification(DidChangeWatchedFilesNotification.type, { + changes: [ + { + uri: path.join(mockRoot, 'app/views/partials/a.liquid'), + type: FileChangeType.Created, + }, + { + uri: path.join(mockRoot, 'app/views/partials/b.liquid'), + type: FileChangeType.Created, + }, + { + uri: path.join(mockRoot, 'app/views/pages/index.liquid'), + type: FileChangeType.Created, + }, + ], + }); + await flushAsync(); + await advanceAndFlush(600); + + const diagCalls = connection.spies.sendNotification.mock.calls.filter( + (call: any[]) => call[0] === PublishDiagnosticsNotification.method, + ); + const cycleDiag = diagCalls.find((call: any[]) => + call[1]?.diagnostics?.some((d: any) => d.message?.includes('Circular render')), + ); + expect(cycleDiag).toBeDefined(); + expect(cycleDiag![1].uri).toMatch(/[ab]\.liquid/); + }); + + it('should clear cycle diagnostics when the cycle is resolved', async () => { + connection.setup(); + await flushAsync(); + + connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); + connection.triggerNotification(DidChangeWatchedFilesNotification.type, { + changes: [ + { + uri: path.join(mockRoot, 'app/views/partials/a.liquid'), + type: FileChangeType.Created, + }, + { + uri: path.join(mockRoot, 'app/views/partials/b.liquid'), + type: FileChangeType.Created, + }, + { + uri: path.join(mockRoot, 'app/views/pages/index.liquid'), + type: FileChangeType.Created, + }, + ], + }); + await flushAsync(); + await advanceAndFlush(600); + + // Verify cycle was detected + const diagCallsBefore = connection.spies.sendNotification.mock.calls.filter( + (call: any[]) => call[0] === PublishDiagnosticsNotification.method, + ); + const cycleDiag = diagCallsBefore.find((call: any[]) => + call[1]?.diagnostics?.some((d: any) => d.message?.includes('Circular render')), + ); + expect(cycleDiag).toBeDefined(); + + // Now resolve the cycle: b.liquid no longer renders a + connection.spies.sendNotification.mockClear(); + fileTree['app/views/partials/b.liquid'] = `{# no render #}`; + connection.triggerNotification(DidChangeWatchedFilesNotification.type, { + changes: [ + { + uri: path.join(mockRoot, 'app/views/partials/b.liquid'), + type: FileChangeType.Changed, + }, + ], + }); + await flushAsync(); + await advanceAndFlush(600); + + // Assert that previously affected URIs now receive empty diagnostics + const clearCalls = connection.spies.sendNotification.mock.calls.filter( + (call: any[]) => + call[0] === PublishDiagnosticsNotification.method && + Array.isArray(call[1]?.diagnostics) && + call[1].diagnostics.length === 0 && + /[ab]\.liquid/.test(call[1].uri), + ); + expect(clearCalls.length).toBeGreaterThan(0); + }); + }); + it('should trigger a re-check on did delete files notifications', async () => { connection.setup(); await flushAsync(); From 1c32cba88e49a0b4ae998f7c15059ad43b587184 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Thu, 12 Mar 2026 08:43:09 +0000 Subject: [PATCH 02/20] Additional checks --- .../src/checks/index.ts | 4 + .../index.spec.ts | 79 ++++++++++++ .../missing-render-partial-arguments/index.ts | 44 +++++++ .../translation-key-exists/index.spec.ts | 27 ++++ .../checks/translation-key-exists/index.ts | 32 ++++- .../unused-translation-key/index.spec.ts | 77 ++++++++++++ .../checks/unused-translation-key/index.ts | 119 ++++++++++++++++++ 7 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts create mode 100644 packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts create mode 100644 packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts create mode 100644 packages/platformos-check-common/src/checks/unused-translation-key/index.ts diff --git a/packages/platformos-check-common/src/checks/index.ts b/packages/platformos-check-common/src/checks/index.ts index 9d1c8dd..d191ccf 100644 --- a/packages/platformos-check-common/src/checks/index.ts +++ b/packages/platformos-check-common/src/checks/index.ts @@ -36,7 +36,9 @@ import { GraphQLCheck } from './graphql'; import { UnknownProperty } from './unknown-property'; import { InvalidHashAssignTarget } from './invalid-hash-assign-target'; import { DuplicateFunctionArguments } from './duplicate-function-arguments'; +import { MissingRenderPartialArguments } from './missing-render-partial-arguments'; import { NestedGraphQLQuery } from './nested-graphql-query'; +import { UnusedTranslationKey } from './unused-translation-key'; export const allChecks: ( | LiquidCheckDefinition @@ -74,7 +76,9 @@ export const allChecks: ( GraphQLCheck, UnknownProperty, InvalidHashAssignTarget, + MissingRenderPartialArguments, NestedGraphQLQuery, + UnusedTranslationKey, ]; /** diff --git a/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts new file mode 100644 index 0000000..5f16d2d --- /dev/null +++ b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { applySuggestions, runLiquidCheck } from '../../test'; +import { MissingRenderPartialArguments } from '.'; + +function check(partial: string, source: string) { + return runLiquidCheck( + MissingRenderPartialArguments, + source, + undefined, + {}, + { 'app/views/partials/card.liquid': partial }, + ); +} + +const partialWithRequiredParams = ` +{% doc %} + @param {string} title - The card title + @param {string} [subtitle] - Optional subtitle +{% enddoc %} +`; + +describe('Module: MissingRenderPartialArguments', () => { + it('should not report when partial has no LiquidDoc', async () => { + const offenses = await check('

card

', `{% render 'card' %}`); + expect(offenses).to.have.length(0); + }); + + it('should not report when all required params are provided', async () => { + const offenses = await check( + partialWithRequiredParams, + `{% render 'card', title: 'Hello' %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should not report for missing optional params', async () => { + const offenses = await check( + partialWithRequiredParams, + `{% render 'card', title: 'Hello' %}`, + ); + expect(offenses).to.have.length(0); + }); + + it('should report ERROR when a required param is missing', async () => { + const offenses = await check(partialWithRequiredParams, `{% render 'card' %}`); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('title'); + expect(offenses[0].message).to.include('card'); + }); + + it('should suggest adding the missing required param', async () => { + const source = `{% render 'card' %}`; + const offenses = await check(partialWithRequiredParams, source); + expect(offenses[0].suggest).to.have.length(1); + expect(offenses[0].suggest![0].message).to.include('title'); + const fixed = applySuggestions(source, offenses[0]); + expect(fixed).to.not.be.undefined; + expect(fixed![0]).to.include('title'); + }); + + it('should report one ERROR per missing required param', async () => { + const partial = ` + {% doc %} + @param {string} title - title + @param {string} body - body + {% enddoc %} + `; + const offenses = await check(partial, `{% render 'card' %}`); + expect(offenses).to.have.length(2); + }); + + it('should not report for dynamic partials', async () => { + const offenses = await runLiquidCheck( + MissingRenderPartialArguments, + `{% render partial_name %}`, + ); + expect(offenses).to.have.length(0); + }); +}); diff --git a/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts new file mode 100644 index 0000000..f503722 --- /dev/null +++ b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts @@ -0,0 +1,44 @@ +import { RenderMarkup } from '@platformos/liquid-html-parser'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { + getLiquidDocParams, + getPartialName, + reportMissingArguments, +} from '../../liquid-doc/arguments'; + +export const MissingRenderPartialArguments: LiquidCheckDefinition = { + meta: { + code: 'MissingRenderPartialArguments', + name: 'Missing Required Render Partial Arguments', + aliases: ['MissingRenderPartialParams'], + docs: { + description: + 'This check ensures that all required @param arguments declared by a partial are provided at the call site.', + recommended: true, + url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/missing-render-partial-arguments', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + + create(context) { + return { + async RenderMarkup(node: RenderMarkup) { + const partialName = getPartialName(node); + if (!partialName) return; + + const liquidDocParameters = await getLiquidDocParams(context, partialName); + if (!liquidDocParameters) return; + + const providedNames = new Set(node.args.map((a) => a.name)); + const missingRequired = [...liquidDocParameters.values()].filter( + (p) => p.required && !providedNames.has(p.name), + ); + + reportMissingArguments(context, node, missingRequired, partialName); + }, + }; + }, +}; diff --git a/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts b/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts index e45f6de..92700ed 100644 --- a/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts +++ b/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts @@ -41,4 +41,31 @@ describe('Module: TranslationKeyExists', () => { expect(offenses[0].message).to.include('missing.key'); expect(offenses[0].message).to.include('does not have a matching translation entry'); }); + + it('should suggest nearest key when the key is a typo', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'code.liquid': `{{"general.titel" | t}}`, + }, + [TranslationKeyExists], + ); + + expect(offenses).to.have.length(1); + expect(offenses[0].suggest).to.have.length(1); + expect(offenses[0].suggest![0].message).to.include('general.title'); + }); + + it('should not add suggestions when there is no close key', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'code.liquid': `{{"completely.different.xyz" | t}}`, + }, + [TranslationKeyExists], + ); + + expect(offenses).to.have.length(1); + expect(offenses[0].suggest ?? []).to.have.length(0); + }); }); 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 24fb8f4..7b2ae0f 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,6 +1,7 @@ import { TranslationProvider } from '@platformos/platformos-common'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { URI } from 'vscode-uri'; +import { URI, Utils } from 'vscode-uri'; +import { flattenTranslationKeys, findNearestKeys } from '../../utils/levenshtein'; function keyExists(key: string, pointer: any) { for (const token of key.split('.')) { @@ -55,6 +56,8 @@ export const TranslationKeyExists: LiquidCheckDefinition = { }, async onCodePathEnd() { + let allDefinedKeys: string[] | null = null; + for (const { translationKey, startIndex, endIndex } of nodes) { const translation = await translationProvider.translate( URI.parse(context.config.rootUri), @@ -62,14 +65,39 @@ export const TranslationKeyExists: LiquidCheckDefinition = { ); if (!!translation) { - return; + continue; + } + + // Lazy-load all keys once per file + if (allDefinedKeys === null) { + const baseUri = Utils.joinPath( + URI.parse(context.config.rootUri), + 'app/translations', + ); + try { + const allTranslations = await translationProvider.loadAllTranslationsForBase( + baseUri, + 'en', + ); + allDefinedKeys = flattenTranslationKeys(allTranslations); + } catch { + allDefinedKeys = []; + } } + const nearest = findNearestKeys(translationKey, allDefinedKeys); const message = `'${translationKey}' does not have a matching translation entry`; + context.report({ message, startIndex, endIndex, + suggest: nearest.length > 0 + ? nearest.map((key) => ({ + message: `Did you mean '${key}'?`, + fix: (fixer: any) => fixer.replace(startIndex, endIndex, `'${key}'`), + })) + : undefined, }); } }, diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts new file mode 100644 index 0000000..9a354ec --- /dev/null +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { check } from '../../test'; +import { UnusedTranslationKey, _resetForTesting } from '.'; + +describe('Module: UnusedTranslationKey', () => { + beforeEach(() => { + _resetForTesting(); + }); + it('should not report a key that is used in a template', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'app/views/pages/home.liquid': `{{"general.title" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should report a key that is defined but never used', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello\n unused: Bye', + 'app/views/pages/home.liquid': `{{"general.title" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('general.unused'); + }); + + it('should not report keys used with dynamic variable', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n general:\n title: Hello', + 'app/views/pages/home.liquid': `{{ some_key | t }}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); // general.title is still unused + }); + + it('should accumulate used keys across multiple liquid files', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n a: A\n b: B', + 'app/views/pages/page1.liquid': `{{"a" | t}}`, + 'app/views/pages/page2.liquid': `{{"b" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should report each unused key only once even with multiple liquid files', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n used: Used\n unused: Unused', + 'app/views/pages/page1.liquid': '{{"used" | t}}', + 'app/views/pages/page2.liquid': '

No translations

', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.include('unused'); + }); + + it('should not report when no translation files exist', async () => { + const offenses = await check( + { + 'app/views/pages/home.liquid': `{{"general.title" | t}}`, + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); +}); diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts new file mode 100644 index 0000000..ba3f338 --- /dev/null +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts @@ -0,0 +1,119 @@ +import { URI, Utils } from 'vscode-uri'; +import { FileType, TranslationProvider } from '@platformos/platformos-common'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { flattenTranslationKeys } from '../../utils/levenshtein'; + +/** + * Recursively collects all .liquid file URIs under a directory. + */ +async function collectLiquidFiles( + fs: { readDirectory(uri: string): Promise<[string, FileType][]> }, + dirUri: string, +): Promise { + const uris: string[] = []; + let entries: [string, FileType][]; + try { + entries = await fs.readDirectory(dirUri); + } catch { + return uris; + } + for (const [entryUri, entryType] of entries) { + if (entryType === FileType.Directory) { + uris.push(...(await collectLiquidFiles(fs, entryUri))); + } else if (entryType === FileType.File && entryUri.endsWith('.liquid')) { + uris.push(entryUri); + } + } + return uris; +} + +/** + * Extracts translation keys from a liquid file source using regex. + * Matches patterns like: "key" | t, 'key' | t, "key" | translate + */ +const TRANSLATION_KEY_RE = /["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g; + +function extractUsedKeys(source: string): string[] { + const keys: string[] = []; + let match; + while ((match = TRANSLATION_KEY_RE.exec(source)) !== null) { + keys.push(match[1]); + } + return keys; +} + +// Track which roots have been reported during a check run. +// Since create() is called per-file, we need module-level deduplication. +const reportedRoots = new Set(); + +/** @internal Reset module state between test runs. */ +export function _resetForTesting() { + reportedRoots.clear(); +} + +export const UnusedTranslationKey: LiquidCheckDefinition = { + meta: { + code: 'UnusedTranslationKey', + name: 'Translation key defined but never used', + docs: { + description: + 'Reports translation keys defined in app/translations/en.yml that are never referenced in any Liquid template.', + recommended: true, + url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/unused-translation-key', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.INFO, + schema: {}, + targets: [], + }, + + create(context) { + return { + async onCodePathEnd() { + const rootKey = context.config.rootUri; + if (reportedRoots.has(rootKey)) return; + reportedRoots.add(rootKey); + + const rootUri = URI.parse(context.config.rootUri); + const baseUri = Utils.joinPath(rootUri, 'app/translations'); + const provider = new TranslationProvider(context.fs); + + let allTranslations: Record; + try { + allTranslations = await provider.loadAllTranslationsForBase(baseUri, 'en'); + } catch { + return; + } + + const definedKeys = flattenTranslationKeys(allTranslations); + if (definedKeys.length === 0) return; + + // Scan all liquid files for used translation keys + const usedKeys = new Set(); + const appUri = Utils.joinPath(rootUri, 'app').toString(); + const liquidFiles = await collectLiquidFiles(context.fs, appUri); + + for (const fileUri of liquidFiles) { + try { + const source = await context.fs.readFile(fileUri); + for (const key of extractUsedKeys(source)) { + usedKeys.add(key); + } + } catch { + // Skip unreadable files + } + } + + for (const key of definedKeys) { + if (!usedKeys.has(key)) { + context.report({ + message: `Translation key '${key}' is defined but never used in any template.`, + startIndex: 0, + endIndex: 0, + }); + } + } + }, + }; + }, +}; From 63998f8e2bd8611d9c0ae28e296ee33160903eaf Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Fri, 13 Mar 2026 09:08:15 +0000 Subject: [PATCH 03/20] Fix null check --- .../index.spec.ts | 23 +++++++++++++++++++ .../index.ts | 5 ++-- .../src/liquid-doc/arguments.ts | 6 +++++ .../src/liquid-doc/utils.ts | 17 ++++++++++++++ .../platformos-check-node/configs/all.yml | 6 +++++ .../configs/recommended.yml | 6 +++++ 6 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts b/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts index fd2b376..b59c4e2 100644 --- a/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts +++ b/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts @@ -142,6 +142,29 @@ describe('Module: ValidRenderPartialParamTypes', () => { expect(offenses).toHaveLength(0); }); + it('should not report null/nil as type mismatch for any type', async () => { + for (const type of ['string', 'number', 'object', 'boolean']) { + for (const literal of ['nil', 'null']) { + const sourceCode = `{% render 'card', param: ${literal} %}`; + const offenses = await runLiquidCheck( + ValidRenderPartialArgumentTypes, + sourceCode, + undefined, + {}, + { + 'app/views/partials/card.liquid': ` + {% doc %} + @param {${type}} param - Description + {% enddoc %} +
{{ param }}
+ `, + }, + ); + expect(offenses, `${literal} should be valid for ${type}`).toHaveLength(0); + } + } + }); + it('should not enforce unsupported types', async () => { const sourceCode = `{% render 'card', title: 123 %}`; const offenses = await runLiquidCheck( diff --git a/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.ts b/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.ts index f3a26c4..4c7e964 100644 --- a/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.ts +++ b/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.ts @@ -1,7 +1,7 @@ import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { NodeTypes, RenderMarkup } from '@platformos/liquid-html-parser'; import { LiquidDocParameter } from '../../liquid-doc/liquidDoc'; -import { inferArgumentType, isTypeCompatible } from '../../liquid-doc/utils'; +import { inferArgumentType, isNullLiteral, isTypeCompatible } from '../../liquid-doc/utils'; import { findTypeMismatchParams, generateTypeMismatchSuggestions, @@ -41,7 +41,8 @@ export const ValidRenderPartialArgumentTypes: LiquidCheckDefinition = { if ( node.alias && node.variable?.name && - node.variable.name.type !== NodeTypes.VariableLookup + node.variable.name.type !== NodeTypes.VariableLookup && + !isNullLiteral(node.variable.name) ) { const paramIsDefinedWithType = liquidDocParameters .get(node.alias.value) diff --git a/packages/platformos-check-common/src/liquid-doc/arguments.ts b/packages/platformos-check-common/src/liquid-doc/arguments.ts index f97a6c8..eb315c8 100644 --- a/packages/platformos-check-common/src/liquid-doc/arguments.ts +++ b/packages/platformos-check-common/src/liquid-doc/arguments.ts @@ -13,6 +13,7 @@ import { BasicParamTypes, getDefaultValueForType, inferArgumentType, + isNullLiteral, isTypeCompatible, } from './utils'; import { isLiquidString } from '../checks/utils'; @@ -133,6 +134,11 @@ export function findTypeMismatchParams( continue; } + // null/nil is compatible with any type — skip type checking + if (isNullLiteral(arg.value)) { + continue; + } + const liquidDocParamDef = liquidDocParameters.get(arg.name); if (liquidDocParamDef && liquidDocParamDef.type) { const paramType = liquidDocParamDef.type.toLowerCase(); diff --git a/packages/platformos-check-common/src/liquid-doc/utils.ts b/packages/platformos-check-common/src/liquid-doc/utils.ts index caec8fa..798fd6f 100644 --- a/packages/platformos-check-common/src/liquid-doc/utils.ts +++ b/packages/platformos-check-common/src/liquid-doc/utils.ts @@ -71,6 +71,23 @@ export function inferArgumentType(arg: LiquidExpression | LiquidVariable): Basic } } +/** + * Checks if a LiquidExpression is a null/nil literal. + * null/nil is compatible with any type — it represents "no value". + */ +export function isNullLiteral(arg: LiquidExpression | LiquidVariable): boolean { + if (arg.type === NodeTypes.LiquidVariable) { + if (arg.filters.length > 0) return false; + const expr = arg.expression; + if (expr.type === NodeTypes.BooleanExpression) return false; + return isNullLiteral(expr); + } + if (arg.type === NodeTypes.LiquidLiteral) { + return arg.value === null; + } + return false; +} + /** * Checks if the provided argument type is compatible with the expected type. * Makes certain types more permissive: diff --git a/packages/platformos-check-node/configs/all.yml b/packages/platformos-check-node/configs/all.yml index 7bc19a2..b47e30b 100644 --- a/packages/platformos-check-node/configs/all.yml +++ b/packages/platformos-check-node/configs/all.yml @@ -46,6 +46,9 @@ MissingPartial: enabled: true severity: 0 ignoreMissing: [] +MissingRenderPartialArguments: + enabled: true + severity: 0 NestedGraphQLQuery: enabled: true severity: 1 @@ -82,6 +85,9 @@ UnusedAssign: UnusedDocParam: enabled: true severity: 1 +UnusedTranslationKey: + enabled: true + severity: 2 ValidDocParamTypes: enabled: true severity: 0 diff --git a/packages/platformos-check-node/configs/recommended.yml b/packages/platformos-check-node/configs/recommended.yml index 7bc19a2..b47e30b 100644 --- a/packages/platformos-check-node/configs/recommended.yml +++ b/packages/platformos-check-node/configs/recommended.yml @@ -46,6 +46,9 @@ MissingPartial: enabled: true severity: 0 ignoreMissing: [] +MissingRenderPartialArguments: + enabled: true + severity: 0 NestedGraphQLQuery: enabled: true severity: 1 @@ -82,6 +85,9 @@ UnusedAssign: UnusedDocParam: enabled: true severity: 1 +UnusedTranslationKey: + enabled: true + severity: 2 ValidDocParamTypes: enabled: true severity: 0 From 7d3d055bc24f01464f752630e71cc061eb45b0f5 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Mon, 16 Mar 2026 09:23:13 +0000 Subject: [PATCH 04/20] metadata.params handling --- .../src/checks/undefined-object/index.spec.ts | 35 +++++++++++++++++++ .../src/checks/undefined-object/index.ts | 13 +++++++ 2 files changed, 48 insertions(+) diff --git a/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts b/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts index 96d3835..8c50b46 100644 --- a/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts +++ b/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts @@ -541,4 +541,39 @@ describe('Module: UndefinedObject', () => { expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe("Unknown object 'groups_data' used."); }); + + it('should not report params as undefined when YAML frontmatter declares metadata.params', async () => { + const sourceCode = `--- +metadata: + params: + token: + type: string + email: + type: string +--- +{{ params.token }} +{{ params.email }} +`; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('should still report other undefined objects when frontmatter has metadata.params', async () => { + const sourceCode = `--- +metadata: + params: + token: + type: string +--- +{{ params.token }} +{{ undefined_var }} +`; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe("Unknown object 'undefined_var' used."); + }); }); diff --git a/packages/platformos-check-common/src/checks/undefined-object/index.ts b/packages/platformos-check-common/src/checks/undefined-object/index.ts index 60a7f23..04d8c5a 100644 --- a/packages/platformos-check-common/src/checks/undefined-object/index.ts +++ b/packages/platformos-check-common/src/checks/undefined-object/index.ts @@ -19,10 +19,12 @@ import { LiquidTagParseJson, LiquidTagBackground, BackgroundMarkup, + YAMLFrontmatter, } from '@platformos/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types'; import { isError, last } from '../../utils'; import { isWithinRawTagThatDoesNotParseItsContents } from '../utils'; +import yaml from 'js-yaml'; type Scope = { start?: number; end?: number }; @@ -74,6 +76,17 @@ export const UndefinedObject: LiquidCheckDefinition = { } }, + async YAMLFrontmatter(node: YAMLFrontmatter) { + try { + const parsed = yaml.load(node.body) as any; + if (parsed?.metadata?.params && typeof parsed.metadata.params === 'object') { + fileScopedVariables.add('params'); + } + } catch { + // Invalid YAML frontmatter — skip + } + }, + async LiquidTag(node, ancestors) { if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return; From fe92878bb8df331103ac3f0220f377a783cc18df Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Tue, 17 Mar 2026 08:13:06 +0000 Subject: [PATCH 05/20] Fixes --- .../package.json | 2 + .../src/TypeSystem.ts | 18 +++ .../src/completions/CompletionsProvider.ts | 11 ++ .../GraphQLFieldCompletionProvider.ts | 78 +++++++++++ .../src/hover/HoverProvider.ts | 8 ++ .../GraphQLFieldHoverProvider.spec.ts | 124 ++++++++++++++++++ .../providers/GraphQLFieldHoverProvider.ts | 90 +++++++++++++ yarn.lock | 21 ++- 8 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts create mode 100644 packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts create mode 100644 packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts diff --git a/packages/platformos-language-server-common/package.json b/packages/platformos-language-server-common/package.json index 62454ea..92002ee 100644 --- a/packages/platformos-language-server-common/package.json +++ b/packages/platformos-language-server-common/package.json @@ -31,6 +31,8 @@ "@platformos/platformos-check-common": "0.0.12", "@platformos/platformos-graph": "0.0.12", "@vscode/web-custom-data": "^0.4.6", + "graphql": "^16.12.0", + "graphql-language-service": "^5.2.2", "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^9.0.1", "vscode-css-languageservice": "^6.3.9", diff --git a/packages/platformos-language-server-common/src/TypeSystem.ts b/packages/platformos-language-server-common/src/TypeSystem.ts index 2e3d2b1..0fb43bd 100644 --- a/packages/platformos-language-server-common/src/TypeSystem.ts +++ b/packages/platformos-language-server-common/src/TypeSystem.ts @@ -51,10 +51,13 @@ import { } from './PropertyShapeInference'; import { AbstractFileSystem, DocumentsLocator } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; +import { buildSchema, GraphQLSchema } from 'graphql'; export class TypeSystem { private graphqlSchemaCache: string | undefined; private graphqlSchemaLoaded = false; + private builtGraphqlSchemaCache: GraphQLSchema | undefined; + private builtGraphqlSchemaLoaded = false; constructor( private readonly platformosDocset: PlatformOSDocset, @@ -71,6 +74,21 @@ export class TypeSystem { return this.graphqlSchemaCache; } + async getBuiltGraphQLSchema(): Promise { + if (!this.builtGraphqlSchemaLoaded) { + const sdl = await this.getGraphQLSchema(); + if (sdl) { + try { + this.builtGraphqlSchemaCache = buildSchema(sdl); + } catch { + // Invalid schema SDL — continue without schema + } + } + this.builtGraphqlSchemaLoaded = true; + } + return this.builtGraphqlSchemaCache; + } + async inferType( thing: Identifier | ComplexLiquidExpression | LiquidVariable | AssignMarkup, partialAst: LiquidHtmlNode, diff --git a/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts b/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts index 7727fda..402ab2e 100644 --- a/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts +++ b/packages/platformos-language-server-common/src/completions/CompletionsProvider.ts @@ -27,6 +27,7 @@ import { TranslationCompletionProvider, } from './providers'; import { GetPartialNamesForURI } from './providers/PartialCompletionProvider'; +import { GraphQLFieldCompletionProvider } from './providers/GraphQLFieldCompletionProvider'; export interface CompletionProviderDependencies { documentManager: DocumentManager; @@ -47,6 +48,7 @@ export interface CompletionProviderDependencies { export class CompletionsProvider { private providers: Provider[] = []; + private graphqlFieldCompletionProvider: GraphQLFieldCompletionProvider; readonly documentManager: DocumentManager; readonly platformosDocset: PlatformOSDocset; readonly log: (message: string) => void; @@ -66,6 +68,10 @@ export class CompletionsProvider { this.platformosDocset = platformosDocset; this.log = log; const typeSystem = new TypeSystem(platformosDocset, fs, documentsLocator, findAppRootURI); + this.graphqlFieldCompletionProvider = new GraphQLFieldCompletionProvider( + platformosDocset, + documentManager, + ); this.providers = [ new HtmlTagCompletionProvider(), @@ -88,6 +94,11 @@ export class CompletionsProvider { const uri = params.textDocument.uri; const document = this.documentManager.get(uri); + // GraphQL files get dedicated completion support + if (document?.type === SourceCodeType.GraphQL) { + return this.graphqlFieldCompletionProvider.completions(params); + } + // Supports only Liquid resources if (document?.type !== SourceCodeType.LiquidHtml) { return []; diff --git a/packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts b/packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts new file mode 100644 index 0000000..0852533 --- /dev/null +++ b/packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts @@ -0,0 +1,78 @@ +import { CompletionItem, CompletionItemKind, CompletionParams } from 'vscode-languageserver'; +import { DocumentManager } from '../../documents'; +import { PlatformOSDocset } from '@platformos/platformos-check-common'; + +/** + * graphql-language-service and graphql must be loaded dynamically to avoid + * the "Cannot use GraphQLList from another module or realm" error that occurs + * when vitest transforms the graphql ESM module into separate instances. + */ +let _glsMod: typeof import('graphql-language-service') | undefined; +function getGLS(): typeof import('graphql-language-service') { + if (!_glsMod) _glsMod = require('graphql-language-service'); + return _glsMod!; +} + +let _graphqlMod: typeof import('graphql') | undefined; +function getGraphQL(): typeof import('graphql') { + if (!_graphqlMod) _graphqlMod = require('graphql'); + return _graphqlMod!; +} + +export class GraphQLFieldCompletionProvider { + private schemaCache: any; + private schemaLoaded = false; + + constructor( + private platformosDocset: PlatformOSDocset, + private documentManager: DocumentManager, + ) {} + + private async getSchema(): Promise { + if (!this.schemaLoaded) { + const sdl = await this.platformosDocset.graphQL(); + if (sdl) { + try { + this.schemaCache = getGraphQL().buildSchema(sdl); + } catch { + // Invalid schema SDL + } + } + this.schemaLoaded = true; + } + return this.schemaCache; + } + + async completions(params: CompletionParams): Promise { + const uri = params.textDocument.uri; + const document = this.documentManager.get(uri); + if (!document) return []; + + const schema = await this.getSchema(); + if (!schema) return []; + + const content = document.textDocument.getText(); + const gls = getGLS(); + const position = new gls.Position(params.position.line, params.position.character); + + try { + const suggestions = gls.getAutocompleteSuggestions(schema, content, position); + + return suggestions.map((suggestion) => ({ + label: suggestion.label, + kind: toCompletionItemKind(suggestion.kind), + detail: suggestion.detail ?? undefined, + documentation: suggestion.documentation ?? undefined, + })); + } catch { + return []; + } + } +} + +function toCompletionItemKind(kind: number | undefined): CompletionItemKind { + if (kind !== undefined && kind >= 1 && kind <= 25) { + return kind as CompletionItemKind; + } + return CompletionItemKind.Field; +} diff --git a/packages/platformos-language-server-common/src/hover/HoverProvider.ts b/packages/platformos-language-server-common/src/hover/HoverProvider.ts index ba37b49..b8516d2 100644 --- a/packages/platformos-language-server-common/src/hover/HoverProvider.ts +++ b/packages/platformos-language-server-common/src/hover/HoverProvider.ts @@ -23,10 +23,12 @@ import { import { HtmlAttributeValueHoverProvider } from './providers/HtmlAttributeValueHoverProvider'; import { findCurrentNode } from '@platformos/platformos-check-common'; import { LiquidDocTagHoverProvider } from './providers/LiquidDocTagHoverProvider'; +import { GraphQLFieldHoverProvider } from './providers/GraphQLFieldHoverProvider'; import { TranslationProvider } from '@platformos/platformos-common'; import { FindAppRootURI } from '../../src/internal-types'; export class HoverProvider { private providers: BaseHoverProvider[] = []; + private graphqlFieldHoverProvider: GraphQLFieldHoverProvider; constructor( readonly documentManager: DocumentManager, @@ -37,6 +39,7 @@ export class HoverProvider { readonly findAppRootURI: FindAppRootURI = async () => null, ) { const typeSystem = new TypeSystem(platformosDocset); + this.graphqlFieldHoverProvider = new GraphQLFieldHoverProvider(platformosDocset, documentManager); this.providers = [ new LiquidTagHoverProvider(platformosDocset), new LiquidFilterArgumentHoverProvider(platformosDocset), @@ -57,6 +60,11 @@ export class HoverProvider { const uri = params.textDocument.uri; const document = this.documentManager.get(uri); + // GraphQL files get dedicated hover support + if (document?.type === SourceCodeType.GraphQL) { + return this.graphqlFieldHoverProvider.hover(params); + } + // Supports only Liquid resources if (document?.type !== SourceCodeType.LiquidHtml || document.ast instanceof Error) { return null; diff --git a/packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts b/packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts new file mode 100644 index 0000000..0c8fc44 --- /dev/null +++ b/packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts @@ -0,0 +1,124 @@ +import { describe, beforeEach, it, expect } from 'vitest'; +import { DocumentManager } from '../../documents'; +import { HoverProvider } from '../HoverProvider'; +import { TranslationProvider } from '@platformos/platformos-common'; +import { MockFileSystem } from '@platformos/platformos-check-common/src/test'; +import { HoverParams } from 'vscode-languageserver'; + +const SCHEMA = ` +type Query { + records: RecordList + user(id: ID!): User +} + +type RecordList { + results: [Record] + total_entries: Int +} + +type Record { + id: ID + name: String + email: String +} + +type User { + id: ID + name: String +} +`; + +describe('Module: GraphQLFieldHoverProvider', () => { + let provider: HoverProvider; + let documentManager: DocumentManager; + + beforeEach(() => { + documentManager = new DocumentManager(); + provider = new HoverProvider( + documentManager, + { + graphQL: async () => SCHEMA, + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], + }, + new TranslationProvider(new MockFileSystem({})), + ); + }); + + it('should return hover info for a top-level field in a .graphql file', async () => { + const source = '{\n records {\n total_entries\n }\n}'; + const uri = 'file:///app/graphql/test.graphql'; + documentManager.open(uri, source, 0); + + const params: HoverParams = { + position: { line: 1, character: 5 }, + textDocument: { uri }, + }; + + const result = await provider.hover(params); + expect(result).not.toBeNull(); + expect((result!.contents as any).value).toContain('records'); + expect((result!.contents as any).value).toContain('RecordList'); + }); + + it('should return hover info for a nested field', async () => { + const source = 'query {\n records {\n results {\n name\n }\n }\n}'; + const uri = 'file:///app/graphql/nested.graphql'; + documentManager.open(uri, source, 0); + + const params: HoverParams = { + position: { line: 3, character: 8 }, + textDocument: { uri }, + }; + + const result = await provider.hover(params); + expect(result).not.toBeNull(); + expect((result!.contents as any).value).toContain('Record.name'); + expect((result!.contents as any).value).toContain('String'); + }); + + it('should return null when no schema is available', async () => { + const noSchemaProvider = new HoverProvider( + documentManager, + { + graphQL: async () => null, + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], + }, + new TranslationProvider(new MockFileSystem({})), + ); + + const source = 'query {\n records {\n results {\n name\n }\n }\n}'; + const uri = 'file:///app/graphql/test.graphql'; + documentManager.open(uri, source, 0); + + const params: HoverParams = { + position: { line: 1, character: 5 }, + textDocument: { uri }, + }; + + const result = await noSchemaProvider.hover(params); + expect(result).toBeNull(); + }); + + it('should not interfere with .liquid file hover', async () => { + const source = '{{ product }}'; + const uri = 'file:///app/views/partials/test.liquid'; + documentManager.open(uri, source, 0); + + const textDocument = documentManager.get(uri)!.textDocument; + const params: HoverParams = { + position: textDocument.positionAt(source.indexOf('product')), + textDocument: { uri }, + }; + + // Should not crash — goes through normal Liquid pipeline + const result = await provider.hover(params); + // product is not in the docset, so hover returns null + expect(result).toBeNull(); + }); +}); diff --git a/packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts b/packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts new file mode 100644 index 0000000..b21ec8c --- /dev/null +++ b/packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts @@ -0,0 +1,90 @@ +import { Hover, HoverParams } from 'vscode-languageserver'; +import { DocumentManager } from '../../documents'; +import { PlatformOSDocset } from '@platformos/platformos-check-common'; + +/** + * graphql-language-service and graphql must be loaded dynamically to avoid + * the "Cannot use GraphQLList from another module or realm" error that occurs + * when vitest transforms the graphql ESM module into separate instances. + * By requiring at runtime, we ensure a single shared module instance. + */ +let _glsMod: typeof import('graphql-language-service') | undefined; +function getGLS(): typeof import('graphql-language-service') { + if (!_glsMod) _glsMod = require('graphql-language-service'); + return _glsMod!; +} + +let _graphqlMod: typeof import('graphql') | undefined; +function getGraphQL(): typeof import('graphql') { + if (!_graphqlMod) _graphqlMod = require('graphql'); + return _graphqlMod!; +} + +export class GraphQLFieldHoverProvider { + private schemaCache: any; + private schemaLoaded = false; + + constructor( + private platformosDocset: PlatformOSDocset, + private documentManager: DocumentManager, + ) {} + + private async getSchema(): Promise { + if (!this.schemaLoaded) { + const sdl = await this.platformosDocset.graphQL(); + if (sdl) { + try { + this.schemaCache = getGraphQL().buildSchema(sdl); + } catch { + // Invalid schema SDL + } + } + this.schemaLoaded = true; + } + return this.schemaCache; + } + + async hover(params: HoverParams): Promise { + const uri = params.textDocument.uri; + const document = this.documentManager.get(uri); + if (!document) return null; + + const schema = await this.getSchema(); + if (!schema) return null; + + const content = document.textDocument.getText(); + const gls = getGLS(); + + // graphql-language-service's getHoverInformation uses the character *before* the cursor + // position to identify the token. We try offset +1 first, then the original position, + // to handle the case where the cursor is at the start of a token. + const positions = [ + new gls.Position(params.position.line, params.position.character + 1), + new gls.Position(params.position.line, params.position.character), + ]; + + try { + let hoverInfo: string | undefined; + for (const position of positions) { + const info = gls.getHoverInformation(schema, content, position); + if (info && info !== '' && info !== 'null') { + hoverInfo = typeof info === 'string' ? info : String(info); + break; + } + } + + if (!hoverInfo) { + return null; + } + + return { + contents: { + kind: 'markdown', + value: hoverInfo, + }, + }; + } catch { + return null; + } + } +} diff --git a/yarn.lock b/yarn.lock index 4223bbc..7f76c4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3219,6 +3219,11 @@ data-urls@^7.0.0: whatwg-mimetype "^5.0.0" whatwg-url "^16.0.0" +debounce-promise@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/debounce-promise/-/debounce-promise-3.1.2.tgz#320fb8c7d15a344455cd33cee5ab63530b6dc7c5" + integrity sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg== + debug@2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" @@ -4240,6 +4245,15 @@ graphemer@^1.4.0: resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphql-language-service@^5.2.2: + version "5.5.0" + resolved "https://registry.yarnpkg.com/graphql-language-service/-/graphql-language-service-5.5.0.tgz#2e68b23bd5384f2fbf1e9391e907106e8246d025" + integrity sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q== + dependencies: + debounce-promise "^3.1.2" + nullthrows "^1.0.0" + vscode-languageserver-types "^3.17.1" + graphql@^16.12.0: version "16.12.0" resolved "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz" @@ -5499,6 +5513,11 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" +nullthrows@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" + integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== + object-inspect@^1.13.3: version "1.13.4" resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" @@ -7413,7 +7432,7 @@ vscode-languageserver-textdocument@^1.0.12: resolved "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz" integrity sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA== -vscode-languageserver-types@3.17.5, vscode-languageserver-types@^3.17.5: +vscode-languageserver-types@3.17.5, vscode-languageserver-types@^3.17.1, vscode-languageserver-types@^3.17.5: version "3.17.5" resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz" integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== From 38da70ddf29dd30c1d497e2738a9d31403d820ec Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Tue, 17 Mar 2026 08:13:30 +0000 Subject: [PATCH 06/20] Cleanup --- .../2026-03-10-upstream-proposals-design.md | 348 ---- docs/plans/2026-03-10-upstream-proposals.md | 1501 ----------------- 2 files changed, 1849 deletions(-) delete mode 100644 docs/plans/2026-03-10-upstream-proposals-design.md delete mode 100644 docs/plans/2026-03-10-upstream-proposals.md diff --git a/docs/plans/2026-03-10-upstream-proposals-design.md b/docs/plans/2026-03-10-upstream-proposals-design.md deleted file mode 100644 index 2afdd72..0000000 --- a/docs/plans/2026-03-10-upstream-proposals-design.md +++ /dev/null @@ -1,348 +0,0 @@ -# Design: Upstream Proposals — pos-cli check and LSP features - -**Date:** 2026-03-10 -**Source:** UPSTREAM-PROPOSALS.md -**Approach:** Sequential, priority order — each item independently releasable - ---- - -## Overview - -Seven features across two packages: - -- `platformos-check-common` — items #4, #1, #2, #3 (new checks + check modification) -- `platformos-language-server-common` — items #8, #7, #6 (LSP features) - -**New dependency:** `graphql-language-service` added to `platformos-language-server-common` (items #7 and #6). - -**No breaking changes.** All new checks are `recommended: true`. LSP features are additive. - -**Shared utilities** introduced in #4, reused by #3: -- `flattenTranslationKeys(obj, prefix)` — recursive YAML object → dotted key list -- `levenshtein(a, b)` — standard O(nm) DP string distance - -Both extracted to `packages/platformos-check-common/src/utils/levenshtein.ts`. - ---- - -## #4 — TranslationKeyExists nearest-key suggestion - -**Files changed:** -- `packages/platformos-check-common/src/checks/translation-key-exists/index.ts` -- `packages/platformos-check-common/src/utils/levenshtein.ts` (new) - -### Design - -The existing `onCodePathEnd` already determines a key is missing. Two steps are added after that determination: - -1. Load all defined keys via `translationProvider.loadAllTranslationsForBase()` — called once per `onCodePathEnd`, result cached locally so it is not re-read per missing key. -2. Flatten the nested YAML object into `string[]` of dotted keys. Run Levenshtein against each. Return top 3 within distance ≤ 3. -3. Attach as `suggest[]` entries on the existing `context.report()` call. Each suggestion includes a `fix` that replaces the string literal's full position (including quotes) with `'${nearestKey}'`. - -### Edge cases - -- **Module keys** (`modules/foo/some.key`): nearest-key search covers only the non-module translation space. Module keys get no suggestions. -- **No close match**: if `findNearestKeys` returns empty, offense is reported exactly as today. -- **Performance**: `loadAllTranslationsForBase` is called once per file's `onCodePathEnd`, not per missing key. - -### Tests - -- Typo key → suggest entries with correct nearest key and a fix that rewrites the literal. - ---- - -## #1 — NestedGraphQLQuery (new check) - -**Files changed:** -- `packages/platformos-check-common/src/checks/nested-graphql-query/index.ts` (new) -- `packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts` (new) -- `packages/platformos-check-common/src/checks/index.ts` (register) - -**Severity:** WARNING (INFO when inside `{% cache %}`) - -### Design - -Entry visitor on `LiquidTag` maintains a `loopStack: string[]` tracking open `for`/`tablerow` tags by name. When a `graphql` tag is encountered with `loopStack.length > 0`, an offense is reported. If any ancestor node has `name === 'cache'`, severity is downgraded to INFO. - -``` -LiquidTag (entry) → if for/tablerow: push name to loopStack - → if graphql AND loopStack.length > 0: - check ancestors for cache → report WARNING or INFO -LiquidTag:exit → if for/tablerow: pop from loopStack -``` - -Result variable name extracted from `node.markup.name` when `markup.type === NodeTypes.GraphQLMarkup`, otherwise omitted from the message. - -**Offense message:** -``` -N+1 pattern: {% graphql result = '...' %} is inside a {% for %} loop. -This executes one database request per iteration. -Move the query before the loop and pass data as a variable. -``` - -### Edge cases - -- **Nested loops**: `loopStack.length > 1` still triggers — same message. -- **`background` tag inside loop**: not detected — async execution is acceptable. -- **Inline graphql**: detected — `GraphQLInlineMarkup` type, result var omitted from message. - -### Tests - -- `{% graphql %}` outside loop → no offense -- `{% graphql %}` inside `{% for %}` → WARNING -- `{% graphql %}` inside `{% tablerow %}` → WARNING -- `{% graphql %}` inside nested `{% for %}{% for %}` → WARNING -- `{% graphql %}` inside `{% for %}` inside `{% cache %}` → INFO -- `{% background %}` inside `{% for %}` → no offense - ---- - -## #8 — Circular render detection (LSP diagnostic) - -**Files changed:** -- `packages/platformos-language-server-common/src/server/AppGraphManager.ts` - -### Design - -After every graph rebuild in `processQueue`, a new private `detectAndPublishCycles(rootUri)` method runs DFS cycle detection over the dependency graph. Detected cycles are published via `connection.sendDiagnostics` as ERROR-severity diagnostics on the `{% render %}` tag that closes the cycle. Diagnostics are cleared (empty array) when no cycles are found. - -**Algorithm:** Standard DFS with two sets — `visited` (fully processed) and `inStack` (current path, `Set` for O(1) lookup): - -``` -dfs(node, visited, inStack, path): - if node in inStack → cycle found: extract path from cycle-start to current - if node in visited → return - add to visited + inStack + path - recurse on each dependency - remove from inStack + path -``` - -The closing edge of each cycle maps to an `AugmentedReference` via `getDependencies()`, which carries `source.range` — the character range of the offending `{% render %}` tag. - -**Diagnostic format:** -``` -Circular render detected: partials/hero → atoms/icon → partials/hero -This will cause an infinite loop at runtime. -``` - -Source: `'platformos-check'`. Diagnostics cleared on clean rebuild. - -### Edge cases - -- **Self-render** (`{% render 'foo' %}` in `foo.liquid`): caught as 1-node cycle. -- **Multiple cycles**: all reported independently. -- **Cycle resolved on save**: graph rebuilds clean, previous diagnostics cleared. -- **URI not in graph**: skipped in DFS. - -### Tests - -- No cycle → no diagnostics published -- A → B → A → diagnostic on render tag in B referencing A -- Self-render → diagnostic on the tag - ---- - -## #2 — MissingRenderPartialArguments (new check) - -**Files changed:** -- `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts` (new) -- `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts` (new) -- `packages/platformos-check-common/src/checks/index.ts` (register) - -**Severity:** ERROR - -### Design - -`reportMissingArguments()` and `getLiquidDocParams()` already exist in `src/liquid-doc/arguments.ts` and are fully implemented including suggest entries that add the missing argument with a default value. This check wires them up. - -On `RenderMarkup`, resolves the partial's LiquidDoc via `getLiquidDocParams()`. If the partial declares params, filters for those with `required: true` not present in `node.args`. Passes the result to `reportMissingArguments()`. - -```ts -async RenderMarkup(node) { - const partialName = getPartialName(node); - if (!partialName) return; - - const liquidDocParameters = await getLiquidDocParams(context, partialName); - if (!liquidDocParameters) return; // no LiquidDoc → skip - - const providedNames = new Set(node.args.map(a => a.name)); - const missingRequired = [...liquidDocParameters.values()] - .filter(p => p.required && !providedNames.has(p.name)); - - reportMissingArguments(context, node, missingRequired, partialName); -} -``` - -`required` is a first-class field on `LiquidDocParameter` — no heuristic parsing. - -### Interaction with existing checks - -| Check | Concern | Severity | -|---|---|---| -| `UnrecognizedRenderPartialArguments` | Unknown args passed | WARNING | -| `ValidRenderPartialArgumentTypes` | Type mismatches | WARNING | -| `MissingRenderPartialArguments` | Required args omitted | ERROR | - -All three use the same `getLiquidDocParams()` / `getPartialName()` infrastructure. - -### Tests - -- Partial with no LiquidDoc → no offense -- Partial with all optional `@param` → no offense -- Partial with one required `@param`, caller provides it → no offense -- Partial with one required `@param`, caller omits it → ERROR with suggest to add it -- Partial with multiple required params, all missing → one ERROR per param -- Dynamic partial (`{% render variable %}`) → no offense - ---- - -## #3 — UnusedTranslationKey (new check) - -**Files changed:** -- `packages/platformos-check-common/src/checks/unused-translation-key/index.ts` (new) -- `packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts` (new) -- `packages/platformos-check-common/src/checks/index.ts` (register) - -**Severity:** INFO - -### Design - -`create()` is called once per check run. The `usedKeys` Set in the closure persists across all files visited. `LiquidVariable` accumulates string literal `| t` / `| translate` keys across every liquid file. Dynamic `| t` usage (non-string expression) is silently skipped. - -`onCodePathEnd` fires once per liquid file. A `reported` boolean guard ensures the YAML scan and reporting runs exactly once — on the first invocation after all liquid files are processed. - -```ts -create(context) { - const usedKeys = new Set(); - let reported = false; - - return { - async LiquidVariable(node) { - if (node.expression.type !== 'String') return; - if (!node.filters.some(f => f.name === 't' || f.name === 'translate')) return; - usedKeys.add(node.expression.value); - }, - - async onCodePathEnd() { - if (reported) return; - reported = true; - - const rootUri = URI.parse(context.config.rootUri); - const provider = new TranslationProvider(context.fs); - const allTranslations = await provider.loadAllTranslationsForBase( - Utils.joinPath(rootUri, 'app/translations'), 'en' - ); - const definedKeys = flattenTranslationKeys(allTranslations); - - for (const key of definedKeys) { - if (!usedKeys.has(key)) { - context.report({ - message: `Translation key '${key}' is defined but never used in any template.`, - uri: /* YAML file URI */, - startIndex: 0, - endIndex: 0, - }); - } - } - }, - }; -} -``` - -Offenses are reported against the YAML file URI using character offset 0 (line-level precision is not available without a full YAML position tracker — acceptable for INFO severity). - -### Caveats - -- **Module translations**: not scanned, not reported. -- **Non-`en` locales**: only `en` is checked. -- **Dynamic keys**: silently skipped — may produce false positives on keys used only via dynamic lookup. - -### Reused utilities - -`flattenTranslationKeys` from `src/utils/levenshtein.ts` (introduced in #4). - -### Tests - -- Key defined in `en.yml`, used in a template → no offense -- Key defined in `en.yml`, not used anywhere → INFO offense -- Key used with `{{ var | t }}` → no offense (dynamic, silently skipped) -- Key used in one file, defined in another → no offense (accumulation works across files) - ---- - -## #7 — GraphQL field listing in hover and completions - -**New dependency:** `graphql-language-service` in `packages/platformos-language-server-common/package.json` - -**Files changed:** -- `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts` (new) -- `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts` (new) -- `packages/platformos-language-server-common/src/hover/providers/index.ts` (register) -- `packages/platformos-language-server-common/src/completions/providers/index.ts` (register) - -### Design - -Both providers activate on `.graphql` files only (check `uri.endsWith('.graphql')`). - -**Schema access:** `context.platformosDocset.graphQL()` returns the schema string. Both providers call `buildSchema(schemaString)` and cache the result per request. - -**Hover provider:** Calls `getHoverInformation(schema, query, cursor)` from `graphql-language-service`. Returns a standard `Hover` LSP response with the markdown string. - -**Completion provider:** Calls `getAutocompleteSuggestions(schema, query, cursor)` from `graphql-language-service`. Returns `CompletionItem[]`. The library handles context-awareness: inside a selection set it suggests fields, at the operation level it suggests operation types. - -**Error handling:** If `platformosDocset.graphQL()` returns `undefined` or `buildSchema()` throws, providers return `null`/`[]` silently — no uncaught errors. - -### Tests - -Using `HoverAssertion` and `CompletionItemsAssertion` test utilities: -- Hover on field name in `.graphql` file → field type description -- Hover on type name → type description with fields -- Completion inside `{ }` selection set → field names returned -- No schema available → no results, no error thrown - ---- - -## #6 — GraphQL result shape in hover - -**Files changed:** -- `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts` (new) -- `packages/platformos-language-server-common/src/hover/providers/index.ts` (register) - -### Design - -Activates on `.liquid` files only. On hover, walks the document AST to find all `LiquidTag` nodes with `name === 'graphql'`. Builds a map of `resultVarName → queryPath`. If the hovered token matches a result variable name, the provider activates. - -**Query file resolution:** `DocumentsLocator.locate(rootUri, 'graphql', queryPath)` — same mechanism as `MissingPartial`. - -**Shape extraction from the query:** -1. Parse the `.graphql` file with `parse()` from the `graphql` package. -2. Extract the root selection field name (e.g. `records` from `query { records { ... } }`). -3. Find the `results` subfield inside the root field's selection set. -4. Collect the field names selected inside `results { ... }` — read directly from the query's selection set, no schema traversal. - -**Hover output:** -```markdown -**`g`** ← `records/users` - -**Access pattern:** -- `g.records.results` — array of results -- `g.records.total_entries` — total count -- `g.records.total_pages` — page count - -**Selected fields on each result:** -`id` · `created_at` · `email` · `properties` -``` - -If no `results` subfield exists in the query, the field listing section is omitted. - -**Error handling:** -- Query file not found → `null` (let `MissingPartial` handle) -- Query parse error → `null` -- Inline graphql (no file reference) → `null` - -### Tests - -- Hover on result variable → formatted markdown with access pattern and fields -- Hover on non-result variable → `null`, next provider handles it -- Query file missing → `null`, no error thrown -- Query with no `results` subfield → access pattern shown, no field listing diff --git a/docs/plans/2026-03-10-upstream-proposals.md b/docs/plans/2026-03-10-upstream-proposals.md deleted file mode 100644 index 15bb33b..0000000 --- a/docs/plans/2026-03-10-upstream-proposals.md +++ /dev/null @@ -1,1501 +0,0 @@ -# Upstream Proposals Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement 7 developer-tooling improvements across `platformos-check-common` and `platformos-language-server-common` in priority order. - -**Architecture:** Sequential independent tasks — each item is a self-contained new check or LSP feature. Shared utilities (`levenshtein`, `flattenTranslationKeys`) are introduced in Task 1 and reused in Task 5. New `graphql-language-service` dependency added in Task 6. - -**Tech Stack:** TypeScript, Vitest, Ohm.js AST, vscode-languageserver protocol, graphql-language-service - -**Design doc:** `docs/plans/2026-03-10-upstream-proposals-design.md` - ---- - -## Task 1: TranslationKeyExists — nearest-key suggestion (#4) - -**Files:** -- Create: `packages/platformos-check-common/src/utils/levenshtein.ts` -- Modify: `packages/platformos-check-common/src/checks/translation-key-exists/index.ts` -- Modify: `packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts` - -### Step 1: Write the failing test - -Add to `packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts`: - -```ts -it('should suggest nearest key when the key is a typo', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello', - 'code.liquid': `{{"general.titel" | t}}`, - }, - [TranslationKeyExists], - ); - - expect(offenses).to.have.length(1); - expect(offenses[0].suggest).to.have.length(1); - expect(offenses[0].suggest![0].message).to.include('general.title'); -}); - -it('should not add suggestions when there is no close key', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello', - 'code.liquid': `{{"completely.different.xyz" | t}}`, - }, - [TranslationKeyExists], - ); - - expect(offenses).to.have.length(1); - expect(offenses[0].suggest ?? []).to.have.length(0); -}); -``` - -### Step 2: Run test to verify it fails - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/translation-key-exists/index.spec.ts -``` - -Expected: FAIL — `offenses[0].suggest` is undefined. - -### Step 3: Create levenshtein utility - -Create `packages/platformos-check-common/src/utils/levenshtein.ts`: - -```ts -export function levenshtein(a: string, b: string): number { - const dp: number[][] = Array.from({ length: a.length + 1 }, (_, i) => - Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), - ); - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - dp[i][j] = - a[i - 1] === b[j - 1] - ? dp[i - 1][j - 1] - : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - } - } - return dp[a.length][b.length]; -} - -export function flattenTranslationKeys( - obj: Record, - prefix = '', -): string[] { - const keys: string[] = []; - for (const [k, v] of Object.entries(obj)) { - const full = prefix ? `${prefix}.${k}` : k; - if (typeof v === 'object' && v !== null) { - keys.push(...flattenTranslationKeys(v, full)); - } else { - keys.push(full); - } - } - return keys; -} - -export function findNearestKeys( - missingKey: string, - allKeys: string[], - maxDistance = 3, - maxResults = 3, -): string[] { - return allKeys - .map((key) => ({ key, distance: levenshtein(missingKey, key) })) - .filter(({ distance }) => distance <= maxDistance) - .sort((a, b) => a.distance - b.distance) - .slice(0, maxResults) - .map(({ key }) => key); -} -``` - -### Step 4: Modify TranslationKeyExists check - -In `packages/platformos-check-common/src/checks/translation-key-exists/index.ts`, add these imports at the top: - -```ts -import { Utils } from 'vscode-uri'; -import { flattenTranslationKeys, findNearestKeys } from '../../utils/levenshtein'; -``` - -Replace the `onCodePathEnd` method with: - -```ts -async onCodePathEnd() { - let allDefinedKeys: string[] | null = null; - - for (const { translationKey, startIndex, endIndex } of nodes) { - const translation = await translationProvider.translate( - URI.parse(context.config.rootUri), - translationKey, - ); - - if (!!translation) { - continue; - } - - // Lazy-load all keys once per file - if (allDefinedKeys === null) { - const baseUri = Utils.joinPath(URI.parse(context.config.rootUri), 'app/translations'); - const allTranslations = await translationProvider.loadAllTranslationsForBase( - baseUri, - 'en', - ); - allDefinedKeys = flattenTranslationKeys(allTranslations); - } - - const nearest = findNearestKeys(translationKey, allDefinedKeys); - - context.report({ - message: `'${translationKey}' does not have a matching translation entry`, - startIndex, - endIndex, - suggest: nearest.map((key) => ({ - message: `Did you mean '${key}'?`, - fix: (fixer: any) => fixer.replace(startIndex, endIndex, `'${key}'`), - })), - }); - } -}, -``` - -### Step 5: Run test to verify it passes - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/translation-key-exists/index.spec.ts -``` - -Expected: PASS - -### Step 6: Type-check - -```bash -yarn workspace @platformos/platformos-check-common type-check -``` - -Expected: no errors. - -### Step 7: Commit - -```bash -git add packages/platformos-check-common/src/utils/levenshtein.ts \ - packages/platformos-check-common/src/checks/translation-key-exists/index.ts \ - packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts -git commit -m "feat(check): add nearest-key suggestions to TranslationKeyExists - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Task 2: NestedGraphQLQuery — new check (#1) - -**Files:** -- Create: `packages/platformos-check-common/src/checks/nested-graphql-query/index.ts` -- Create: `packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts` -- Modify: `packages/platformos-check-common/src/checks/index.ts` - -### Step 1: Write the failing tests - -Create `packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { runLiquidCheck } from '../../test'; -import { NestedGraphQLQuery } from '.'; - -describe('Module: NestedGraphQLQuery', () => { - it('should not report graphql outside a loop', async () => { - const offenses = await runLiquidCheck( - NestedGraphQLQuery, - `{% graphql result = 'products/list' %}`, - ); - expect(offenses).to.have.length(0); - }); - - it('should report graphql inside a for loop', async () => { - const offenses = await runLiquidCheck( - NestedGraphQLQuery, - `{% for item in items %}{% graphql result = 'products/get' %}{% endfor %}`, - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('N+1'); - expect(offenses[0].message).to.include('for'); - }); - - it('should report graphql inside a tablerow loop', async () => { - const offenses = await runLiquidCheck( - NestedGraphQLQuery, - `{% tablerow item in items %}{% graphql result = 'products/get' %}{% endtablerow %}`, - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('tablerow'); - }); - - it('should report graphql inside nested loops', async () => { - const offenses = await runLiquidCheck( - NestedGraphQLQuery, - `{% for a in items %}{% for b in a.children %}{% graphql result = 'foo' %}{% endfor %}{% endfor %}`, - ); - expect(offenses).to.have.length(1); - }); - - it('should report INFO (not WARNING) when inside both a loop and cache', async () => { - const offenses = await runLiquidCheck( - NestedGraphQLQuery, - `{% for item in items %}{% cache 'key' %}{% graphql result = 'foo' %}{% endcache %}{% endfor %}`, - ); - expect(offenses).to.have.length(1); - expect(offenses[0].severity).to.equal(1); // Severity.INFO = 1 - }); - - it('should not report background tag inside a loop', async () => { - const offenses = await runLiquidCheck( - NestedGraphQLQuery, - `{% for item in items %}{% background %}{% graphql result = 'foo' %}{% endbackground %}{% endfor %}`, - ); - // background tag is async — not flagged - expect(offenses).to.have.length(0); - }); -}); -``` - -### Step 2: Run tests to verify they fail - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/nested-graphql-query/index.spec.ts -``` - -Expected: FAIL — module not found. - -### Step 3: Create the check - -Create `packages/platformos-check-common/src/checks/nested-graphql-query/index.ts`: - -```ts -import { NamedTags, NodeTypes, LiquidTag, LiquidHtmlNode } from '@platformos/liquid-html-parser'; -import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; - -export const NestedGraphQLQuery: LiquidCheckDefinition = { - meta: { - code: 'NestedGraphQLQuery', - name: 'GraphQL query inside a loop', - docs: { - description: - 'A {% graphql %} tag inside a {% for %} or {% tablerow %} loop executes one database request per iteration (N+1 pattern). Move the query before the loop and pass results as a variable.', - recommended: true, - url: 'https://documentation.platformos.com/best-practices/performance/graphql-in-loops', - }, - type: SourceCodeType.LiquidHtml, - severity: Severity.WARNING, - schema: {}, - targets: [], - }, - - create(context) { - const loopStack: string[] = []; - - function isInsideBackgroundTag(ancestors: LiquidHtmlNode[]): boolean { - return ancestors.some( - (a) => a.type === NodeTypes.LiquidTag && (a as LiquidTag).name === 'background', - ); - } - - function isInsideCacheTag(ancestors: LiquidHtmlNode[]): boolean { - return ancestors.some( - (a) => a.type === NodeTypes.LiquidTag && (a as LiquidTag).name === 'cache', - ); - } - - return { - async LiquidTag(node: LiquidTag, ancestors: LiquidHtmlNode[]) { - if (node.name === NamedTags.for || node.name === NamedTags.tablerow) { - loopStack.push(node.name); - return; - } - - if (node.name !== NamedTags.graphql) return; - if (loopStack.length === 0) return; - if (isInsideBackgroundTag(ancestors)) return; - - const outerLoop = loopStack[loopStack.length - 1]; - const markup = node.markup; - const resultVar = - typeof markup === 'object' && markup.type === NodeTypes.GraphQLMarkup - ? markup.name - : null; - - const severity = isInsideCacheTag(ancestors) ? Severity.INFO : Severity.WARNING; - - context.report({ - message: - `N+1 pattern: {% graphql ${resultVar ? resultVar + ' = ' : ''}... %} ` + - `is inside a {% ${outerLoop} %} loop. ` + - `This executes one database request per iteration. ` + - `Move the query before the loop and pass data as a variable.`, - startIndex: node.position.start, - endIndex: node.position.end, - severity, - }); - }, - - async 'LiquidTag:exit'(node: LiquidTag) { - if (node.name === NamedTags.for || node.name === NamedTags.tablerow) { - loopStack.pop(); - } - }, - }; - }, -}; -``` - -### Step 4: Register in allChecks - -In `packages/platformos-check-common/src/checks/index.ts`, add: - -```ts -import { NestedGraphQLQuery } from './nested-graphql-query'; -``` - -And add `NestedGraphQLQuery` to the `allChecks` array. - -### Step 5: Run tests to verify they pass - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/nested-graphql-query/index.spec.ts -``` - -Expected: PASS - -### Step 6: Type-check - -```bash -yarn workspace @platformos/platformos-check-common type-check -``` - -Expected: no errors. Fix any type issues around `LiquidTag` node casting. - -### Step 7: Commit - -```bash -git add packages/platformos-check-common/src/checks/nested-graphql-query/ \ - packages/platformos-check-common/src/checks/index.ts -git commit -m "feat(check): add NestedGraphQLQuery check for N+1 detection - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Task 3: Circular render detection — LSP diagnostic (#8) - -**Files:** -- Modify: `packages/platformos-language-server-common/src/server/AppGraphManager.ts` -- Modify: `packages/platformos-language-server-common/src/server/startServer.spec.ts` - -### Step 1: Write the failing test - -Read `packages/platformos-language-server-common/src/server/startServer.spec.ts` first to understand the `MockApp` / `MockConnection` / `startServer` test scaffolding. Then add a new `describe` block for cycle detection: - -```ts -describe('circular render detection', () => { - it('should publish an error diagnostic when a render cycle is detected', async () => { - fileTree = { - '.pos': '', - 'app/views/partials/a.liquid': `{% render 'b' %}`, - 'app/views/partials/b.liquid': `{% render 'a' %}`, - }; - dependencies = getDependencies(logger, fileTree); - startServer(connection, dependencies); - connection.setup(); - await flushAsync(); - - // Open a file to trigger graph build - connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); - await flushAsync(); - vi.runAllTimers(); - await flushAsync(); - - const diagCalls = connection.spies.sendNotification.mock.calls.filter( - ([method]: [string]) => method === PublishDiagnosticsNotification.method, - ); - const cycleDiag = diagCalls.find(([, params]: [string, any]) => - params.diagnostics?.some((d: any) => d.message?.includes('Circular render')), - ); - expect(cycleDiag).toBeDefined(); - }); - - it('should clear cycle diagnostics when the cycle is resolved', async () => { - // Start with a cycle - fileTree = { - '.pos': '', - 'app/views/partials/a.liquid': `{% render 'b' %}`, - 'app/views/partials/b.liquid': `{% render 'a' %}`, - }; - dependencies = getDependencies(logger, fileTree); - startServer(connection, dependencies); - connection.setup(); - await flushAsync(); - - connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); - await flushAsync(); - vi.runAllTimers(); - await flushAsync(); - - // Resolve the cycle - connection.changeDocument('app/views/partials/b.liquid', `

no render

`, 1); - await flushAsync(); - vi.runAllTimers(); - await flushAsync(); - - const diagCalls = connection.spies.sendNotification.mock.calls.filter( - ([method]: [string]) => method === PublishDiagnosticsNotification.method, - ); - // Last diagnostic publish for b.liquid should have empty diagnostics - const lastBDiag = [...diagCalls] - .reverse() - .find(([, params]: [string, any]) => - params.uri?.includes('b.liquid'), - ); - expect(lastBDiag?.[1].diagnostics).toHaveLength(0); - }); -}); -``` - -### Step 2: Run test to verify it fails - -```bash -yarn workspace @platformos/platformos-language-server-common test src/server/startServer.spec.ts -``` - -Expected: FAIL — no cycle diagnostics published. - -### Step 3: Add cycle detection to AppGraphManager - -In `packages/platformos-language-server-common/src/server/AppGraphManager.ts`, add the private cycle detection method and call it at the end of `processQueue`: - -```ts -private detectCycles(modules: Record): string[][] { - const cycles: string[][] = []; - const visited = new Set(); - const inStack = new Set(); - - const dfs = (node: string, path: string[]) => { - if (inStack.has(node)) { - const cycleStart = path.indexOf(node); - cycles.push(path.slice(cycleStart).concat(node)); - return; - } - if (visited.has(node)) return; - - visited.add(node); - inStack.add(node); - path.push(node); - - for (const dep of (modules[node]?.dependencies ?? [])) { - dfs(dep.target.uri, path); - } - - path.pop(); - inStack.delete(node); - }; - - for (const uri of Object.keys(modules)) { - if (!visited.has(uri)) dfs(uri, []); - } - - return cycles; -} - -private async detectAndPublishCycles(rootUri: string) { - const graph = await this.graphs.get(rootUri); - if (!graph) return; - - const cycles = this.detectCycles(graph.modules); - - // Clear previous cycle diagnostics if no cycles - if (cycles.length === 0) { - // Clearing is handled by normal diagnostics flow - return; - } - - for (const cycle of cycles) { - // The closing edge is the last URI in the cycle — find its render tag back to cycle[0] - const closingUri = cycle[cycle.length - 2]; // last file before the cycle wraps - const cyclePath = cycle - .slice(0, -1) // remove duplicate tail - .map((uri) => uri.split('/').slice(-2).join('/')) - .join(' → '); - - this.connection.sendDiagnostics({ - uri: closingUri, - diagnostics: [ - { - severity: 1, // DiagnosticSeverity.Error - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, - message: `Circular render detected: ${cyclePath}\nThis will cause an infinite loop at runtime.`, - source: 'platformos-check', - }, - ], - }); - } -} -``` - -Then at the end of the `processQueue` debounced function, after the graph is rebuilt, add: - -```ts -await this.detectAndPublishCycles(rootUri); -``` - -**Note:** `connection.sendDiagnostics` is `connection.sendNotification(PublishDiagnosticsNotification.method, params)` under the hood — check the exact API on the `Connection` type and use whichever is available (`sendDiagnostics` or `sendNotification`). - -### Step 4: Run tests to verify they pass - -```bash -yarn workspace @platformos/platformos-language-server-common test src/server/startServer.spec.ts -``` - -Expected: PASS. If the API surface for `sendDiagnostics` is wrong, inspect the `Connection` type and adjust. - -### Step 5: Type-check - -```bash -yarn workspace @platformos/platformos-language-server-common type-check -``` - -Expected: no errors. - -### Step 6: Commit - -```bash -git add packages/platformos-language-server-common/src/server/AppGraphManager.ts \ - packages/platformos-language-server-common/src/server/startServer.spec.ts -git commit -m "feat(lsp): detect and publish circular render diagnostics - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Task 4: MissingRenderPartialArguments — new check (#2) - -**Files:** -- Create: `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts` -- Create: `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts` -- Modify: `packages/platformos-check-common/src/checks/index.ts` - -### Step 1: Write the failing tests - -Create `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { applySuggestions, runLiquidCheck } from '../../test'; -import { MissingRenderPartialArguments } from '.'; - -function check(partial: string, source: string) { - return runLiquidCheck( - MissingRenderPartialArguments, - source, - undefined, - {}, - { 'app/views/partials/card.liquid': partial }, - ); -} - -const partialWithRequiredParams = ` -{% doc %} - @param {string} title - The card title - @param {string} [subtitle] - Optional subtitle -{% enddoc %} -`; - -describe('Module: MissingRenderPartialArguments', () => { - it('should not report when partial has no LiquidDoc', async () => { - const offenses = await check('

card

', `{% render 'card' %}`); - expect(offenses).to.have.length(0); - }); - - it('should not report when all required params are provided', async () => { - const offenses = await check( - partialWithRequiredParams, - `{% render 'card', title: 'Hello' %}`, - ); - expect(offenses).to.have.length(0); - }); - - it('should not report for missing optional params', async () => { - const offenses = await check( - partialWithRequiredParams, - `{% render 'card', title: 'Hello' %}`, - ); - expect(offenses).to.have.length(0); - }); - - it('should report ERROR when a required param is missing', async () => { - const offenses = await check(partialWithRequiredParams, `{% render 'card' %}`); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include("title"); - expect(offenses[0].message).to.include("card"); - }); - - it('should suggest adding the missing required param', async () => { - const source = `{% render 'card' %}`; - const offenses = await check(partialWithRequiredParams, source); - expect(offenses[0].suggest).to.have.length(1); - expect(offenses[0].suggest![0].message).to.include("title"); - const fixed = applySuggestions(source, offenses[0]); - expect(fixed[0]).to.include('title'); - }); - - it('should report one ERROR per missing required param', async () => { - const partial = ` - {% doc %} - @param {string} title - title - @param {string} body - body - {% enddoc %} - `; - const offenses = await check(partial, `{% render 'card' %}`); - expect(offenses).to.have.length(2); - }); - - it('should not report for dynamic partials', async () => { - const offenses = await runLiquidCheck( - MissingRenderPartialArguments, - `{% render partial_name %}`, - ); - expect(offenses).to.have.length(0); - }); -}); -``` - -### Step 2: Run tests to verify they fail - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/missing-render-partial-arguments/index.spec.ts -``` - -Expected: FAIL — module not found. - -### Step 3: Create the check - -Create `packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.ts`: - -```ts -import { RenderMarkup } from '@platformos/liquid-html-parser'; -import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { - getLiquidDocParams, - getPartialName, - reportMissingArguments, -} from '../../liquid-doc/arguments'; - -export const MissingRenderPartialArguments: LiquidCheckDefinition = { - meta: { - code: 'MissingRenderPartialArguments', - name: 'Missing Required Render Partial Arguments', - aliases: ['MissingRenderPartialParams'], - docs: { - description: - 'This check ensures that all required @param arguments declared by a partial are provided at the call site.', - recommended: true, - url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/missing-render-partial-arguments', - }, - type: SourceCodeType.LiquidHtml, - severity: Severity.ERROR, - schema: {}, - targets: [], - }, - - create(context) { - return { - async RenderMarkup(node: RenderMarkup) { - const partialName = getPartialName(node); - if (!partialName) return; - - const liquidDocParameters = await getLiquidDocParams(context, partialName); - if (!liquidDocParameters) return; - - const providedNames = new Set(node.args.map((a) => a.name)); - const missingRequired = [...liquidDocParameters.values()].filter( - (p) => p.required && !providedNames.has(p.name), - ); - - reportMissingArguments(context, node, missingRequired, partialName); - }, - }; - }, -}; -``` - -### Step 4: Register in allChecks - -In `packages/platformos-check-common/src/checks/index.ts`, add: - -```ts -import { MissingRenderPartialArguments } from './missing-render-partial-arguments'; -``` - -Add `MissingRenderPartialArguments` to the `allChecks` array. - -### Step 5: Run tests to verify they pass - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/missing-render-partial-arguments/index.spec.ts -``` - -Expected: PASS - -### Step 6: Type-check - -```bash -yarn workspace @platformos/platformos-check-common type-check -``` - -Expected: no errors. - -### Step 7: Commit - -```bash -git add packages/platformos-check-common/src/checks/missing-render-partial-arguments/ \ - packages/platformos-check-common/src/checks/index.ts -git commit -m "feat(check): add MissingRenderPartialArguments check - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Task 5: UnusedTranslationKey — new check (#3) - -**Files:** -- Create: `packages/platformos-check-common/src/checks/unused-translation-key/index.ts` -- Create: `packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts` -- Modify: `packages/platformos-check-common/src/checks/index.ts` - -### Step 1: Write the failing tests - -Create `packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { check } from '../../test'; -import { UnusedTranslationKey } from '.'; - -describe('Module: UnusedTranslationKey', () => { - it('should not report a key that is used in a template', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello', - 'app/views/pages/home.liquid': `{{"general.title" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should report a key that is defined but never used', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello\n unused: Bye', - 'app/views/pages/home.liquid': `{{"general.title" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('general.unused'); - }); - - it('should not report keys used with dynamic variable', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello', - 'app/views/pages/home.liquid': `{{ some_key | t }}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); // general.title is still unused - }); - - it('should accumulate used keys across multiple liquid files', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n a: A\n b: B', - 'app/views/pages/page1.liquid': `{{"a" | t}}`, - 'app/views/pages/page2.liquid': `{{"b" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should not report when no translation files exist', async () => { - const offenses = await check( - { - 'app/views/pages/home.liquid': `{{"general.title" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); -}); -``` - -### Step 2: Run tests to verify they fail - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/unused-translation-key/index.spec.ts -``` - -Expected: FAIL — module not found. - -### Step 3: Create the check - -Create `packages/platformos-check-common/src/checks/unused-translation-key/index.ts`: - -```ts -import { URI, Utils } from 'vscode-uri'; -import { TranslationProvider } from '@platformos/platformos-common'; -import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { flattenTranslationKeys } from '../../utils/levenshtein'; - -export const UnusedTranslationKey: LiquidCheckDefinition = { - meta: { - code: 'UnusedTranslationKey', - name: 'Translation key defined but never used', - docs: { - description: - 'Reports translation keys defined in app/translations/en.yml that are never referenced in any Liquid template.', - recommended: true, - url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/unused-translation-key', - }, - type: SourceCodeType.LiquidHtml, - severity: Severity.INFO, - schema: {}, - targets: [], - }, - - create(context) { - const usedKeys = new Set(); - let reported = false; - - return { - async LiquidVariable(node) { - if (node.expression.type !== 'String') return; - if (!node.filters.some((f) => f.name === 't' || f.name === 'translate')) return; - usedKeys.add(node.expression.value); - }, - - async onCodePathEnd() { - if (reported) return; - reported = true; - - const rootUri = URI.parse(context.config.rootUri); - const baseUri = Utils.joinPath(rootUri, 'app/translations'); - const provider = new TranslationProvider(context.fs); - - let allTranslations: Record; - try { - allTranslations = await provider.loadAllTranslationsForBase(baseUri, 'en'); - } catch { - return; - } - - const definedKeys = flattenTranslationKeys(allTranslations); - - for (const key of definedKeys) { - if (!usedKeys.has(key)) { - context.report({ - message: `Translation key '${key}' is defined but never used in any template.`, - startIndex: 0, - endIndex: 0, - }); - } - } - }, - }; - }, -}; -``` - -### Step 4: Register in allChecks - -In `packages/platformos-check-common/src/checks/index.ts`, add: - -```ts -import { UnusedTranslationKey } from './unused-translation-key'; -``` - -Add `UnusedTranslationKey` to the `allChecks` array. - -### Step 5: Run tests to verify they pass - -```bash -yarn workspace @platformos/platformos-check-common test src/checks/unused-translation-key/index.spec.ts -``` - -Expected: PASS. The `reported` guard ensures `onCodePathEnd` fires only once despite being called per file. - -### Step 6: Type-check - -```bash -yarn workspace @platformos/platformos-check-common type-check -``` - -Expected: no errors. - -### Step 7: Commit - -```bash -git add packages/platformos-check-common/src/checks/unused-translation-key/ \ - packages/platformos-check-common/src/checks/index.ts -git commit -m "feat(check): add UnusedTranslationKey check - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Task 6: GraphQL field listing in hover and completions (#7) - -**Files:** -- Modify: `packages/platformos-language-server-common/package.json` -- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts` -- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts` -- Create: `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts` -- Create: `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.spec.ts` -- Modify: `packages/platformos-language-server-common/src/hover/HoverProvider.ts` -- Modify: `packages/platformos-language-server-common/src/hover/providers/index.ts` -- Modify: `packages/platformos-language-server-common/src/completions/providers/index.ts` - -### Step 1: Add the dependency - -```bash -yarn workspace @platformos/platformos-language-server-common add graphql-language-service -``` - -Verify it appears in `packages/platformos-language-server-common/package.json`. - -### Step 2: Write the failing hover test - -Before writing, read `packages/platformos-language-server-common/src/hover/providers/TranslationHoverProvider.spec.ts` to understand the `HoverProvider` constructor signature and the `hover` custom matcher. - -Create `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts`: - -```ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { DocumentManager } from '../../documents'; -import { HoverProvider } from '../HoverProvider'; -import { MockFileSystem } from '@platformos/platformos-check-common/src/test'; -import { TranslationProvider } from '@platformos/platformos-common'; - -const SCHEMA = ` - type Query { - records(filter: String): RecordConnection - } - type RecordConnection { - results: [Record] - total_entries: Int - total_pages: Int - } - type Record { - id: ID - table: String - created_at: String - } -`; - -describe('Module: GraphQLFieldHoverProvider', () => { - let provider: HoverProvider; - - beforeEach(() => { - provider = new HoverProvider( - new DocumentManager(), - { - graphQL: async () => SCHEMA, - filters: async () => [], - objects: async () => [], - liquidDrops: async () => [], - tags: async () => [], - }, - new TranslationProvider(new MockFileSystem({})), - undefined, - undefined, - async () => '.', - ); - }); - - it('should return hover for a field name in a graphql file', async () => { - await expect(provider).to.hover( - // cursor on 'records' field — use █ to mark cursor position - // Note: hover in .graphql files needs a .graphql URI - // Read existing hover specs to understand how to pass a non-.liquid URI - // and adjust accordingly. - `query { re█cords { results { id } } }`, - expect.stringContaining('RecordConnection'), - 'app/graphql/test.graphql', - ); - }); - - it('should return null for a .graphql file when schema is unavailable', async () => { - const noSchemaProvider = new HoverProvider( - new DocumentManager(), - { - graphQL: async () => null, - filters: async () => [], - objects: async () => [], - liquidDrops: async () => [], - tags: async () => [], - }, - new TranslationProvider(new MockFileSystem({})), - ); - await expect(noSchemaProvider).to.hover( - `query { re█cords { id } }`, - null, - 'app/graphql/test.graphql', - ); - }); -}); -``` - -### Step 3: Run test to verify it fails - -```bash -yarn workspace @platformos/platformos-language-server-common test src/hover/providers/GraphQLFieldHoverProvider.spec.ts -``` - -Expected: FAIL — provider not found / returns null. - -### Step 4: Create the hover provider - -Create `packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts`: - -```ts -import { buildSchema } from 'graphql'; -import { getHoverInformation } from 'graphql-language-service'; -import { Hover, HoverParams } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import { PlatformOSDocset } from '@platformos/platformos-check-common'; -import { DocumentManager } from '../../documents'; -import { BaseHoverProvider } from '../BaseHoverProvider'; -import { LiquidHtmlNode } from '@platformos/platformos-check-common'; - -export class GraphQLFieldHoverProvider implements BaseHoverProvider { - constructor( - private documentManager: DocumentManager, - private platformosDocset: PlatformOSDocset, - ) {} - - async hover( - _currentNode: LiquidHtmlNode, - _ancestors: LiquidHtmlNode[], - params: HoverParams, - ): Promise { - const uri = params.textDocument.uri; - if (!uri.endsWith('.graphql')) return null; - - const schemaString = await this.platformosDocset.graphQL(); - if (!schemaString) return null; - - let schema; - try { - schema = buildSchema(schemaString); - } catch { - return null; - } - - const document = this.documentManager.get(uri); - if (!document) return null; - - const content = document.source; - const position = params.position; - - try { - const hoverInfo = getHoverInformation(schema, content, position); - if (!hoverInfo) return null; - return { - contents: { kind: 'markdown', value: String(hoverInfo) }, - }; - } catch { - return null; - } - } -} -``` - -**Note:** `getHoverInformation` from `graphql-language-service` takes a `Position` compatible with `{ line, character }` — the LSP `params.position` is compatible. However, the `HoverProvider` normally only handles `.liquid` files — read the `HoverProvider.hover()` method to see where to bypass the liquid-only guard for `.graphql` URIs. You may need to add an early-exit path for graphql files that skips AST traversal and calls `GraphQLFieldHoverProvider` directly. - -### Step 5: Register the hover provider - -In `packages/platformos-language-server-common/src/hover/HoverProvider.ts`, add `GraphQLFieldHoverProvider` to the `providers` array in the constructor. Export it from `packages/platformos-language-server-common/src/hover/providers/index.ts`. - -Also update `HoverProvider.hover()` to handle `.graphql` URIs — they have no liquid AST, so the node traversal will not work. Add a guard at the top: - -```ts -if (uri.endsWith('.graphql')) { - // For graphql files, skip liquid AST traversal and try graphql-specific providers - for (const provider of this.providers) { - if (provider instanceof GraphQLFieldHoverProvider) { - const result = await provider.hover({} as any, [], params); - if (result) return result; - } - } - return null; -} -``` - -### Step 6: Write and wire the completion provider - -Create `packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts` following the same pattern as the hover provider but using `getAutocompleteSuggestions(schema, content, position)` from `graphql-language-service`. Return `CompletionItem[]`. - -Read `packages/platformos-language-server-common/src/completions/providers/PartialCompletionProvider.ts` or `FilterCompletionProvider.ts` for the `Provider` base class interface, then implement accordingly. - -Register in the `CompletionProvider` similarly to how `GraphQLFieldHoverProvider` is registered — with a `.graphql` URI guard. - -### Step 7: Run all tests to verify they pass - -```bash -yarn workspace @platformos/platformos-language-server-common test -``` - -Expected: PASS (or investigate failures and fix). - -### Step 8: Type-check - -```bash -yarn workspace @platformos/platformos-language-server-common type-check -``` - -Expected: no errors. - -### Step 9: Commit - -```bash -git add packages/platformos-language-server-common/package.json \ - packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.ts \ - packages/platformos-language-server-common/src/hover/providers/GraphQLFieldHoverProvider.spec.ts \ - packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.ts \ - packages/platformos-language-server-common/src/completions/providers/GraphQLFieldCompletionProvider.spec.ts \ - packages/platformos-language-server-common/src/hover/HoverProvider.ts \ - packages/platformos-language-server-common/src/hover/providers/index.ts \ - packages/platformos-language-server-common/src/completions/providers/index.ts \ - yarn.lock -git commit -m "feat(lsp): add GraphQL field hover and completions using graphql-language-service - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Task 7: GraphQL result shape hover (#6) - -**Files:** -- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts` -- Create: `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.spec.ts` -- Modify: `packages/platformos-language-server-common/src/hover/HoverProvider.ts` -- Modify: `packages/platformos-language-server-common/src/hover/providers/index.ts` - -### Step 1: Write the failing test - -Read `packages/platformos-language-server-common/src/hover/providers/RenderPartialHoverProvider.spec.ts` for the pattern of testing hover providers that need to resolve files. - -Create `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.spec.ts`: - -```ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { DocumentManager } from '../../documents'; -import { HoverProvider } from '../HoverProvider'; -import { MockFileSystem } from '@platformos/platformos-check-common/src/test'; -import { TranslationProvider } from '@platformos/platformos-common'; -import { path } from '@platformos/platformos-check-common'; - -const mockRoot = path.normalize('browser:/'); - -describe('Module: GraphQLResultHoverProvider', () => { - let provider: HoverProvider; - - beforeEach(() => { - const fs = new MockFileSystem({ - 'app/graphql/records/list.graphql': ` - query GetRecords { - records { - results { - id - table - created_at - } - total_entries - total_pages - } - } - `, - }, mockRoot); - - provider = new HoverProvider( - new DocumentManager(), - { - graphQL: async () => null, - filters: async () => [], - objects: async () => [], - liquidDrops: async () => [], - tags: async () => [], - }, - new TranslationProvider(fs), - undefined, - undefined, - async () => mockRoot, - ); - - // Inject the fs into the provider for file resolution - // (read HoverProvider constructor — you may need to add an fs parameter) - }); - - it('should return access pattern when hovering the graphql result variable', async () => { - await expect(provider).to.hover( - `{% graphql g = 'records/list' %}{{ █g.records }}`, - expect.stringContaining('g.records.results'), - ); - }); - - it('should list fields selected in the query', async () => { - await expect(provider).to.hover( - `{% graphql g = 'records/list' %}{{ █g.records }}`, - expect.stringContaining('id'), - ); - }); - - it('should return null when hovering a non-graphql-result variable', async () => { - await expect(provider).to.hover( - `{% assign foo = 'bar' %}{{ █foo }}`, - null, - ); - }); - - it('should return null when the query file does not exist', async () => { - await expect(provider).to.hover( - `{% graphql g = 'does/not/exist' %}{{ █g }}`, - null, - ); - }); -}); -``` - -### Step 2: Run test to verify it fails - -```bash -yarn workspace @platformos/platformos-language-server-common test src/hover/providers/GraphQLResultHoverProvider.spec.ts -``` - -Expected: FAIL. - -### Step 3: Create the provider - -Create `packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts`: - -```ts -import { parse, OperationDefinitionNode, FieldNode, SelectionSetNode } from 'graphql'; -import { Hover, HoverParams } from 'vscode-languageserver'; -import { AbstractFileSystem } from '@platformos/platformos-common'; -import { DocumentsLocator } from '@platformos/platformos-common'; -import { LiquidHtmlNode, NodeTypes, findCurrentNode } from '@platformos/platformos-check-common'; -import { LiquidTag, NamedTags } from '@platformos/liquid-html-parser'; -import { URI } from 'vscode-uri'; -import { DocumentManager } from '../../documents'; -import { BaseHoverProvider } from '../BaseHoverProvider'; -import { FindAppRootURI } from '../../internal-types'; - -interface GraphQLBinding { - resultVar: string; - queryPath: string; -} - -function extractGraphQLBindings(ast: LiquidHtmlNode): GraphQLBinding[] { - const bindings: GraphQLBinding[] = []; - // Walk the AST looking for {% graphql varName = 'path' %} tags - // Use the visit() utility from platformos-check-common - // Each LiquidTag with name === 'graphql' and markup.type === NodeTypes.GraphQLMarkup - // gives markup.name (result var) and markup.graphql.value (query path) - function walk(node: any) { - if (node?.type === NodeTypes.LiquidTag && node.name === NamedTags.graphql) { - const markup = node.markup; - if (markup?.type === NodeTypes.GraphQLMarkup && markup.name && markup.graphql?.type === 'String') { - bindings.push({ resultVar: markup.name, queryPath: markup.graphql.value }); - } - } - for (const key of ['children', 'body', 'markup']) { - const child = node?.[key]; - if (Array.isArray(child)) child.forEach(walk); - else if (child && typeof child === 'object') walk(child); - } - } - walk(ast); - return bindings; -} - -function getSelectedFields(selectionSet: SelectionSetNode | undefined): string[] { - if (!selectionSet) return []; - return selectionSet.selections - .filter((s): s is FieldNode => s.kind === 'Field') - .map((f) => f.name.value); -} - -function buildHoverMarkdown(resultVar: string, queryPath: string, rootField: string, selectedFields: string[]): string { - const lines = [ - `**\`${resultVar}\`** ← \`${queryPath}\``, - '', - '**Access pattern:**', - `- \`${resultVar}.${rootField}.results\` — array of results`, - `- \`${resultVar}.${rootField}.total_entries\` — total count`, - `- \`${resultVar}.${rootField}.total_pages\` — page count`, - ]; - - if (selectedFields.length > 0) { - lines.push('', '**Selected fields on each result:**'); - lines.push(selectedFields.map((f) => `\`${f}\``).join(' · ')); - } - - return lines.join('\n'); -} - -export class GraphQLResultHoverProvider implements BaseHoverProvider { - constructor( - private documentManager: DocumentManager, - private fs: AbstractFileSystem, - private findAppRootURI: FindAppRootURI, - ) {} - - async hover( - currentNode: LiquidHtmlNode, - ancestors: LiquidHtmlNode[], - params: HoverParams, - ): Promise { - const uri = params.textDocument.uri; - if (uri.endsWith('.graphql')) return null; - - // Resolve hovered token name - if (currentNode.type !== NodeTypes.VariableLookup) return null; - const hoveredName = (currentNode as any).name; - if (!hoveredName) return null; - - // Find graphql bindings in the document - const document = this.documentManager.get(uri); - if (!document || document.ast instanceof Error) return null; - - const bindings = extractGraphQLBindings(document.ast as LiquidHtmlNode); - const binding = bindings.find((b) => b.resultVar === hoveredName); - if (!binding) return null; - - // Resolve the query file - const rootUri = await this.findAppRootURI(uri); - if (!rootUri) return null; - - const locator = new DocumentsLocator(this.fs); - const queryFileUri = await locator.locate(URI.parse(rootUri), 'graphql', binding.queryPath); - if (!queryFileUri) return null; - - // Parse the query - let querySource: string; - try { - querySource = await this.fs.readFile(queryFileUri); - } catch { - return null; - } - - let queryDoc; - try { - queryDoc = parse(querySource); - } catch { - return null; - } - - // Extract root field and results fields - const operation = queryDoc.definitions.find( - (d): d is OperationDefinitionNode => d.kind === 'OperationDefinition', - ); - if (!operation) return null; - - const rootField = operation.selectionSet.selections.find( - (s): s is FieldNode => s.kind === 'Field', - ); - if (!rootField) return null; - - const rootFieldName = rootField.name.value; - const resultsField = rootField.selectionSet?.selections - .filter((s): s is FieldNode => s.kind === 'Field') - .find((f) => f.name.value === 'results'); - - const selectedFields = getSelectedFields(resultsField?.selectionSet); - - return { - contents: { - kind: 'markdown', - value: buildHoverMarkdown(binding.resultVar, binding.queryPath, rootFieldName, selectedFields), - }, - }; - } -} -``` - -**Note:** The `HoverProvider` constructor currently doesn't receive an `AbstractFileSystem`. You will need to: -1. Add an optional `fs?: AbstractFileSystem` parameter to `HoverProvider`'s constructor -2. Pass it through from `startServer.ts` where `HoverProvider` is instantiated -3. Pass `fs` to `GraphQLResultHoverProvider` in the providers array - -Read `packages/platformos-language-server-common/src/server/startServer.ts` to see how `HoverProvider` is currently constructed, and trace back where `AbstractFileSystem` is available. - -### Step 4: Register the provider - -In `packages/platformos-language-server-common/src/hover/HoverProvider.ts`, add `GraphQLResultHoverProvider` to the providers array (near the end, before `LiquidDocTagHoverProvider`). Export from `providers/index.ts`. - -### Step 5: Run tests to verify they pass - -```bash -yarn workspace @platformos/platformos-language-server-common test src/hover/providers/GraphQLResultHoverProvider.spec.ts -``` - -Expected: PASS. Iterate on the `extractGraphQLBindings` traversal if the AST walk doesn't find the graphql tags — use the `visit()` utility from `platformos-check-common` instead of the manual walk above if needed. - -### Step 6: Run the full test suite - -```bash -yarn workspace @platformos/platformos-language-server-common test -``` - -Expected: all passing. - -### Step 7: Type-check - -```bash -yarn workspace @platformos/platformos-language-server-common type-check -``` - -Expected: no errors. - -### Step 8: Commit - -```bash -git add packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.ts \ - packages/platformos-language-server-common/src/hover/providers/GraphQLResultHoverProvider.spec.ts \ - packages/platformos-language-server-common/src/hover/HoverProvider.ts \ - packages/platformos-language-server-common/src/hover/providers/index.ts \ - packages/platformos-language-server-common/src/server/startServer.ts -git commit -m "feat(lsp): add GraphQL result shape hover for Liquid templates - -Co-Authored-By: Claude Sonnet 4.6 " -``` - ---- - -## Final validation - -After all 7 tasks are complete, run the full monorepo test suite and type-check: - -```bash -yarn test -yarn type-check -``` - -Expected: all tests passing, no type errors. From 22a93964de86a8fc1e8ac664a278742239e04804 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Tue, 17 Mar 2026 08:31:58 +0000 Subject: [PATCH 07/20] Linter --- .../index.spec.ts | 10 ++------- .../src/checks/nested-graphql-query/index.ts | 4 +--- .../checks/translation-key-exists/index.ts | 18 +++++++--------- .../src/utils/levenshtein.ts | 5 +---- .../src/PropertyShapeInference.ts | 21 ++++++++++++------- .../src/TypeSystem.ts | 1 - .../src/hover/HoverProvider.ts | 5 ++++- .../src/server/AppGraphManager.ts | 6 +++++- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts index 5f16d2d..a525bb7 100644 --- a/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts +++ b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts @@ -26,18 +26,12 @@ describe('Module: MissingRenderPartialArguments', () => { }); it('should not report when all required params are provided', async () => { - const offenses = await check( - partialWithRequiredParams, - `{% render 'card', title: 'Hello' %}`, - ); + const offenses = await check(partialWithRequiredParams, `{% render 'card', title: 'Hello' %}`); expect(offenses).to.have.length(0); }); it('should not report for missing optional params', async () => { - const offenses = await check( - partialWithRequiredParams, - `{% render 'card', title: 'Hello' %}`, - ); + const offenses = await check(partialWithRequiredParams, `{% render 'card', title: 'Hello' %}`); expect(offenses).to.have.length(0); }); diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts index fc4c792..8fcb795 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -23,9 +23,7 @@ export const NestedGraphQLQuery: LiquidCheckDefinition = { async LiquidTag(node, ancestors) { if (node.name !== NamedTags.graphql) return; - const ancestorTags = ancestors.filter( - (a) => a.type === NodeTypes.LiquidTag, - ); + const ancestorTags = ancestors.filter((a) => a.type === NodeTypes.LiquidTag); const loopAncestor = ancestorTags.find(isLoopLiquidTag); 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 7b2ae0f..540bf9a 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 @@ -70,10 +70,7 @@ export const TranslationKeyExists: LiquidCheckDefinition = { // Lazy-load all keys once per file if (allDefinedKeys === null) { - const baseUri = Utils.joinPath( - URI.parse(context.config.rootUri), - 'app/translations', - ); + const baseUri = Utils.joinPath(URI.parse(context.config.rootUri), 'app/translations'); try { const allTranslations = await translationProvider.loadAllTranslationsForBase( baseUri, @@ -92,12 +89,13 @@ export const TranslationKeyExists: LiquidCheckDefinition = { message, startIndex, endIndex, - suggest: nearest.length > 0 - ? nearest.map((key) => ({ - message: `Did you mean '${key}'?`, - fix: (fixer: any) => fixer.replace(startIndex, endIndex, `'${key}'`), - })) - : undefined, + suggest: + nearest.length > 0 + ? nearest.map((key) => ({ + message: `Did you mean '${key}'?`, + fix: (fixer: any) => fixer.replace(startIndex, endIndex, `'${key}'`), + })) + : undefined, }); } }, diff --git a/packages/platformos-check-common/src/utils/levenshtein.ts b/packages/platformos-check-common/src/utils/levenshtein.ts index 808b75f..eb8d190 100644 --- a/packages/platformos-check-common/src/utils/levenshtein.ts +++ b/packages/platformos-check-common/src/utils/levenshtein.ts @@ -13,10 +13,7 @@ export function levenshtein(a: string, b: string): number { return dp[a.length][b.length]; } -export function flattenTranslationKeys( - obj: Record, - prefix = '', -): string[] { +export function flattenTranslationKeys(obj: Record, prefix = ''): string[] { const keys: string[] = []; for (const [k, v] of Object.entries(obj)) { const full = prefix ? `${prefix}.${k}` : k; diff --git a/packages/platformos-language-server-common/src/PropertyShapeInference.ts b/packages/platformos-language-server-common/src/PropertyShapeInference.ts index 4dc6328..c780c27 100644 --- a/packages/platformos-language-server-common/src/PropertyShapeInference.ts +++ b/packages/platformos-language-server-common/src/PropertyShapeInference.ts @@ -482,13 +482,20 @@ export function shapeToJSONPlaceholder(shape: PropertyShape | undefined): string switch (shape.kind) { case 'primitive': switch (shape.primitiveType) { - case 'string': return '""'; - case 'number': return '0'; - case 'boolean': return 'true'; - default: return 'null'; + case 'string': + return '""'; + case 'number': + return '0'; + case 'boolean': + return 'true'; + default: + return 'null'; } - case 'array': return '[]'; - case 'object': return '{}'; - default: return 'null'; + case 'array': + return '[]'; + case 'object': + return '{}'; + default: + return 'null'; } } diff --git a/packages/platformos-language-server-common/src/TypeSystem.ts b/packages/platformos-language-server-common/src/TypeSystem.ts index 0fb43bd..a123002 100644 --- a/packages/platformos-language-server-common/src/TypeSystem.ts +++ b/packages/platformos-language-server-common/src/TypeSystem.ts @@ -1826,4 +1826,3 @@ function findLastApplicableShape( } return result; } - diff --git a/packages/platformos-language-server-common/src/hover/HoverProvider.ts b/packages/platformos-language-server-common/src/hover/HoverProvider.ts index b8516d2..99b5643 100644 --- a/packages/platformos-language-server-common/src/hover/HoverProvider.ts +++ b/packages/platformos-language-server-common/src/hover/HoverProvider.ts @@ -39,7 +39,10 @@ export class HoverProvider { readonly findAppRootURI: FindAppRootURI = async () => null, ) { const typeSystem = new TypeSystem(platformosDocset); - this.graphqlFieldHoverProvider = new GraphQLFieldHoverProvider(platformosDocset, documentManager); + this.graphqlFieldHoverProvider = new GraphQLFieldHoverProvider( + platformosDocset, + documentManager, + ); this.providers = [ new LiquidTagHoverProvider(platformosDocset), new LiquidFilterArgumentHoverProvider(platformosDocset), diff --git a/packages/platformos-language-server-common/src/server/AppGraphManager.ts b/packages/platformos-language-server-common/src/server/AppGraphManager.ts index 27c491b..0b13cc7 100644 --- a/packages/platformos-language-server-common/src/server/AppGraphManager.ts +++ b/packages/platformos-language-server-common/src/server/AppGraphManager.ts @@ -10,7 +10,11 @@ import { WebComponentMap, } from '@platformos/platformos-graph'; import { Range } from 'vscode-json-languageservice'; -import { Connection, DiagnosticSeverity, PublishDiagnosticsNotification } from 'vscode-languageserver'; +import { + Connection, + DiagnosticSeverity, + PublishDiagnosticsNotification, +} from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentManager } from '../documents'; import { From f173c571f68b8bdb903de92e1962d67733e661dd Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak <4314246+wgrzeszczak@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:45:56 +0100 Subject: [PATCH 08/20] Apply suggestion from @Slashek Co-authored-by: Maciej Krajowski-Kukiel --- .../src/checks/nested-graphql-query/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts index 8fcb795..dce6cf6 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -48,7 +48,7 @@ export const NestedGraphQLQuery: LiquidCheckDefinition = { const graphqlStr = resultName ? `{% graphql${resultName} %}` : '{% graphql %}'; - const message = `N+1 pattern: ${graphqlStr} is inside a {% ${loopAncestor.name} %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.`; + const message = `N+1 pattern: ${graphqlStr} is inside a {% ${loopAncestor.name} %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.`; context.report({ message, From 45b299d7eb49594aa1f116504b96aa0da4e3e116 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Tue, 17 Mar 2026 13:52:55 +0000 Subject: [PATCH 09/20] Cleanup tests, cycles check --- CLAUDE.md | 7 + .../src/checks/circular-render/index.spec.ts | 177 ++++++++++++++ .../src/checks/circular-render/index.ts | 215 ++++++++++++++++++ .../src/checks/graphql/index.spec.ts | 4 +- .../src/checks/index.ts | 2 + .../index.spec.ts | 9 +- .../checks/nested-graphql-query/index.spec.ts | 13 +- .../src/checks/nested-graphql-query/index.ts | 6 +- .../parser-blocking-script/index.spec.ts | 8 +- .../translation-key-exists/index.spec.ts | 7 +- .../src/checks/unused-assign/index.spec.ts | 2 +- .../src/checks/unused-doc-param/index.spec.ts | 4 +- .../unused-translation-key/index.spec.ts | 8 +- .../valid-doc-param-types/index.spec.ts | 2 +- .../src/checks/variable-name/index.spec.ts | 2 +- .../src/TypeSystem.ts | 21 -- .../src/server/AppGraphManager.ts | 122 +--------- .../src/server/startServer.spec.ts | 129 ----------- 18 files changed, 442 insertions(+), 296 deletions(-) create mode 100644 packages/platformos-check-common/src/checks/circular-render/index.spec.ts create mode 100644 packages/platformos-check-common/src/checks/circular-render/index.ts diff --git a/CLAUDE.md b/CLAUDE.md index d73d2a8..7d792ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,13 @@ const globPattern = normalize(path.join(root, '**/*.liquid')); **Important: `normalize-path` is for filesystem paths only, NOT URIs.** It collapses multiple slashes (e.g., `file:///` becomes `file:/`), which breaks URI semantics. For URI strings (`file://...`), use the `normalize()` function from `platformos-check-common/src/path.ts` which works with `vscode-uri`. For raw backslash replacement in URIs where you can't use the common normalize, use `.replace(/\\/g, '/')`. +## Test Assertion Guidelines + +- Always use `.to.equal()` for message assertions, never `.to.include()` — assert the entire expected string +- Do not use regex for matching in tests unless absolutely necessary +- For array assertions (e.g., `applySuggestions` results), use `.to.deep.equal([...])` instead of `.to.include(element)` +- When multiple `.include()` calls check the same value, collapse them into a single `.to.equal()` + ## Development Workflows ### Online Store Web Integration diff --git a/packages/platformos-check-common/src/checks/circular-render/index.spec.ts b/packages/platformos-check-common/src/checks/circular-render/index.spec.ts new file mode 100644 index 0000000..95a6707 --- /dev/null +++ b/packages/platformos-check-common/src/checks/circular-render/index.spec.ts @@ -0,0 +1,177 @@ +import { expect, describe, it } from 'vitest'; +import { CircularRender } from '.'; +import { check } from '../../test'; + +describe('Module: CircularRender', () => { + it('should report a simple cycle (A renders B, B renders A)', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': "{% render 'a' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(2); + expect(offenses).to.containOffense({ + check: 'CircularRender', + uri: 'file:///app/views/partials/a.liquid', + }); + expect(offenses).to.containOffense({ + check: 'CircularRender', + uri: 'file:///app/views/partials/b.liquid', + }); + }); + + it('should report a transitive cycle (A -> B -> C -> A)', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': "{% render 'c' %}", + 'app/views/partials/c.liquid': "{% render 'a' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(3); + }); + + it('should report a self-referencing partial', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'a' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(1); + expect(offenses).to.containOffense({ + check: 'CircularRender', + uri: 'file:///app/views/partials/a.liquid', + }); + }); + + it('should not report when there is no cycle', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': "{% render 'c' %}", + 'app/views/partials/c.liquid': '

end

', + }, + [CircularRender], + ); + + expect(offenses).to.have.length(0); + }); + + it('should skip variable lookups', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': '{% render myvar %}', + }, + [CircularRender], + ); + + expect(offenses).to.have.length(0); + }); + + it('should detect cycles via function tags', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% function res = 'b' %}", + 'app/views/partials/b.liquid': "{% function res = 'a' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(2); + }); + + it('should detect cycles with mixed render and function tags', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': "{% function res = 'a' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(2); + }); + + it('should detect cycles via include tags', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% include 'b' %}", + 'app/views/partials/b.liquid': "{% include 'a' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(2); + }); + + it('should not crash when a partial in the chain does not exist', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': "{% render 'nonexistent' %}", + }, + [CircularRender], + ); + + expect(offenses).to.have.length(0); + }); + + it('should not crash when a dependency has a parse error', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': '{% render %}{% unclosed', + }, + [CircularRender], + ); + + expect(offenses).to.have.length(0); + }); + + it('should handle diamond dependencies without false positives', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}{% render 'c' %}", + 'app/views/partials/b.liquid': "{% render 'd' %}", + 'app/views/partials/c.liquid': "{% render 'd' %}", + 'app/views/partials/d.liquid': '

end

', + }, + [CircularRender], + ); + + expect(offenses).to.have.length(0); + }); + + it('should handle deep chains without cycles (depth limit)', async () => { + const files: Record = {}; + for (let i = 0; i < 55; i++) { + files[`app/views/partials/p${i}.liquid`] = `{% render 'p${i + 1}' %}`; + } + files['app/views/partials/p55.liquid'] = '

end

'; + + const offenses = await check(files, [CircularRender]); + expect(offenses).to.have.length(0); + }); + + it('should point the diagnostic at the render tag position', async () => { + const offenses = await check( + { + 'app/views/partials/a.liquid': "{% render 'b' %}", + 'app/views/partials/b.liquid': "{% render 'a' %}", + }, + [CircularRender], + ); + + const offenseA = offenses.find((o) => o.uri === 'file:///app/views/partials/a.liquid'); + expect(offenseA).toBeDefined(); + expect(offenseA!.start.index).toBe(10); + expect(offenseA!.end.index).toBe(13); + }); +}); diff --git a/packages/platformos-check-common/src/checks/circular-render/index.ts b/packages/platformos-check-common/src/checks/circular-render/index.ts new file mode 100644 index 0000000..c3873c5 --- /dev/null +++ b/packages/platformos-check-common/src/checks/circular-render/index.ts @@ -0,0 +1,215 @@ +import { NodeTypes, toLiquidHtmlAST, nonTraversableProperties } from '@platformos/liquid-html-parser'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { DocumentsLocator } from '@platformos/platformos-common'; +import { URI } from 'vscode-uri'; + +const MAX_DEPTH = 50; + +type DocumentType = 'render' | 'include' | 'function'; + +interface PartialRef { + name: string; + documentType: DocumentType; + startIndex: number; + endIndex: number; +} + +// Module-level cache: URI -> partial references extracted from that file. +// Shared across all files in a check run to avoid re-parsing. +const parseCacheMap = new WeakMap>(); + +function getParseCache(context: { fs: object }): Map { + if (!parseCacheMap.has(context.fs)) { + parseCacheMap.set(context.fs, new Map()); + } + return parseCacheMap.get(context.fs)!; +} + +/** + * Extract partial references (render/function/include) from a Liquid source string. + * Returns an empty array on parse errors. + */ +function extractPartialRefs(source: string): PartialRef[] { + let ast; + try { + ast = toLiquidHtmlAST(source); + } catch { + return []; + } + + const refs: PartialRef[] = []; + const stack: any[] = [ast]; + + while (stack.length > 0) { + const node = stack.pop(); + if (!node || typeof node !== 'object') continue; + + if ( + node.type === NodeTypes.LiquidTag && + node.markup && + typeof node.markup === 'object' && + (node.markup.type === NodeTypes.RenderMarkup || node.markup.type === NodeTypes.FunctionMarkup) && + node.markup.partial && + node.markup.partial.type !== NodeTypes.VariableLookup + ) { + refs.push({ + name: node.markup.partial.value, + documentType: (node.name as DocumentType) || 'render', + startIndex: node.markup.partial.position.start, + endIndex: node.markup.partial.position.end, + }); + } + + // Traverse children (skip circular references like parentNode, prev, next) + for (const key of Object.keys(node)) { + if (nonTraversableProperties.has(key)) continue; + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child === 'object') { + stack.push(child); + } + } + } else if (value && typeof value === 'object' && value.type) { + stack.push(value); + } + } + } + + return refs; +} + +export const CircularRender: LiquidCheckDefinition = { + meta: { + code: 'CircularRender', + name: 'Prevent circular renders', + docs: { + description: + 'Reports circular render/function/include chains that would cause infinite loops at runtime.', + recommended: true, + url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/circular-render', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + + create(context) { + const locator = new DocumentsLocator(context.fs); + const rootUri = URI.parse(context.config.rootUri); + const parseCache = getParseCache(context); + const collectedRefs: PartialRef[] = []; + + return { + async RenderMarkup(node, ancestors) { + if (node.partial.type === NodeTypes.VariableLookup) return; + const parent = ancestors.at(-1) as any; + const documentType: DocumentType = parent?.name === 'include' ? 'include' : 'render'; + collectedRefs.push({ + name: node.partial.value, + documentType, + startIndex: node.partial.position.start, + endIndex: node.partial.position.end, + }); + }, + + async FunctionMarkup(node) { + if (node.partial.type === NodeTypes.VariableLookup) return; + collectedRefs.push({ + name: node.partial.value, + documentType: 'function', + startIndex: node.partial.position.start, + endIndex: node.partial.position.end, + }); + }, + + async onCodePathEnd() { + const fileUri = context.file.uri; + + for (const ref of collectedRefs) { + const cycle = await findCycle( + locator, + rootUri, + context.fs, + parseCache, + fileUri, + ref.name, + ref.documentType, + [fileUri], + 0, + ); + + if (cycle) { + const cyclePath = cycle + .map((u) => { + const parts = u.split('/'); + return parts.slice(-2).join('/'); + }) + .join(' -> '); + + context.report({ + message: `Circular render detected: ${cyclePath}. This will cause an infinite loop at runtime.`, + startIndex: ref.startIndex, + endIndex: ref.endIndex, + }); + } + } + }, + }; + }, +}; + +async function findCycle( + locator: DocumentsLocator, + rootUri: URI, + fs: { readFile: (uri: string) => Promise }, + parseCache: Map, + originUri: string, + partialName: string, + documentType: DocumentType, + path: string[], + depth: number, +): Promise { + if (depth >= MAX_DEPTH) return null; + + const uri = await locator.locate(rootUri, documentType, partialName); + if (!uri) return null; + + if (uri === originUri) { + return [...path, uri]; + } + + if (path.includes(uri)) { + // This is a cycle, but it doesn't include the origin file — skip it + return null; + } + + let refs = parseCache.get(uri); + if (!refs) { + try { + const source = await fs.readFile(uri); + refs = extractPartialRefs(source); + } catch { + refs = []; + } + parseCache.set(uri, refs); + } + + for (const dep of refs) { + const cycle = await findCycle( + locator, + rootUri, + fs, + parseCache, + originUri, + dep.name, + dep.documentType, + [...path, uri], + depth + 1, + ); + if (cycle) return cycle; + } + + return null; +} diff --git a/packages/platformos-check-common/src/checks/graphql/index.spec.ts b/packages/platformos-check-common/src/checks/graphql/index.spec.ts index 35c837f..56fcf15 100644 --- a/packages/platformos-check-common/src/checks/graphql/index.spec.ts +++ b/packages/platformos-check-common/src/checks/graphql/index.spec.ts @@ -98,7 +98,7 @@ describe('Module: GraphQLCheck', () => { const offenses = await check(files, [GraphQLCheck], mockDependencies); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('Syntax Error'); + expect(offenses[0].message).to.equal('Syntax Error: Expected Name, found .'); }); it('syntax error offense points to the actual error line, not the whole file', async () => { @@ -113,7 +113,7 @@ describe('Module: GraphQLCheck', () => { const offenses = await check(files, [GraphQLCheck], mockDependencies); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('Syntax Error'); + expect(offenses[0].message).to.equal('Syntax Error: Expected Name, found .'); // Offense spans exactly one line (the error line), NOT the whole file expect(offenses[0].start.line).to.equal(offenses[0].end.line); // And that line is not the last line of the file (i.e. not spanning to the end) diff --git a/packages/platformos-check-common/src/checks/index.ts b/packages/platformos-check-common/src/checks/index.ts index d191ccf..0879d79 100644 --- a/packages/platformos-check-common/src/checks/index.ts +++ b/packages/platformos-check-common/src/checks/index.ts @@ -6,6 +6,7 @@ import { YAMLCheckDefinition, } from '../types'; +import { CircularRender } from './circular-render'; import { DeprecatedFilter } from './deprecated-filter'; import { DeprecatedTag } from './deprecated-tag'; import { DuplicateRenderPartialArguments } from './duplicate-render-partial-arguments'; @@ -46,6 +47,7 @@ export const allChecks: ( | GraphQLCheckDefinition | YAMLCheckDefinition )[] = [ + CircularRender, DeprecatedFilter, DeprecatedTag, DuplicateFunctionArguments, diff --git a/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts index a525bb7..de32059 100644 --- a/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts +++ b/packages/platformos-check-common/src/checks/missing-render-partial-arguments/index.spec.ts @@ -38,18 +38,19 @@ describe('Module: MissingRenderPartialArguments', () => { it('should report ERROR when a required param is missing', async () => { const offenses = await check(partialWithRequiredParams, `{% render 'card' %}`); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('title'); - expect(offenses[0].message).to.include('card'); + expect(offenses[0].message).to.equal( + "Missing required argument 'title' in render tag for partial 'card'.", + ); }); it('should suggest adding the missing required param', async () => { const source = `{% render 'card' %}`; const offenses = await check(partialWithRequiredParams, source); expect(offenses[0].suggest).to.have.length(1); - expect(offenses[0].suggest![0].message).to.include('title'); + expect(offenses[0].suggest![0].message).to.equal("Add required argument 'title'"); const fixed = applySuggestions(source, offenses[0]); expect(fixed).to.not.be.undefined; - expect(fixed![0]).to.include('title'); + expect(fixed![0]).to.equal("{% render 'card', title: '' %}"); }); it('should report one ERROR per missing required param', async () => { diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts index bb30157..44c6c02 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts @@ -17,8 +17,9 @@ describe('Module: NestedGraphQLQuery', () => { `{% for item in items %}{% graphql result = 'products/get' %}{% endfor %}`, ); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('N+1'); - expect(offenses[0].message).to.include('for'); + expect(offenses[0].message).to.equal( + "N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.", + ); }); it('should report graphql inside a tablerow loop', async () => { @@ -27,7 +28,9 @@ describe('Module: NestedGraphQLQuery', () => { `{% tablerow item in items %}{% graphql result = 'products/get' %}{% endtablerow %}`, ); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('tablerow'); + expect(offenses[0].message).to.equal( + "N+1 pattern: {% graphql result = 'result' %} is inside a {% tablerow %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.", + ); }); it('should report graphql inside nested loops', async () => { @@ -60,7 +63,9 @@ describe('Module: NestedGraphQLQuery', () => { `{% for item in items %}{% graphql result %}query { records { results { id } } }{% endgraphql %}{% endfor %}`, ); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('result'); + expect(offenses[0].message).to.equal( + "N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.", + ); }); it('should report multiple graphql tags inside one loop', async () => { diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts index 8fcb795..879ddcd 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -2,6 +2,8 @@ import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { isLoopLiquidTag } from '../utils'; +const SKIP_IF_ANCESTOR_TAGS = [NamedTags.cache.toString()]; + export const NestedGraphQLQuery: LiquidCheckDefinition = { meta: { code: 'NestedGraphQLQuery', @@ -34,8 +36,8 @@ export const NestedGraphQLQuery: LiquidCheckDefinition = { if (inBackground) return; // Skip if inside a cache block (caching mitigates the N+1 problem) - const inCache = ancestorTags.some((a) => a.name === NamedTags.cache); - if (inCache) return; + const shouldSkip = ancestorTags.some((a) => SKIP_IF_ANCESTOR_TAGS.includes(a.name)); + if (shouldSkip) return; let resultName = ''; if ( diff --git a/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts b/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts index 6403b91..3277443 100644 --- a/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts +++ b/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts @@ -20,7 +20,7 @@ describe('Module: ParserBlockingScript', () => { expect(offenses).to.have.length(1); const { check, message, start, end } = offenses[0]; expect(check).to.equal(ParserBlockingScript.meta.code); - expect(message).to.contain('Avoid parser blocking scripts by adding `defer`'); + expect(message).to.equal('Avoid parser blocking scripts by adding `defer` or `async` on this tag'); expect(start.index).to.equal(startIndex); expect(end.index).to.equal(endIndex); }); @@ -45,8 +45,10 @@ describe('Module: ParserBlockingScript', () => { }); const suggestions = applySuggestions(file, offense); - expect(suggestions).to.include(''); - expect(suggestions).to.include(''); + expect(suggestions).to.deep.equal([ + '', + '', + ]); }); }); diff --git a/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts b/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts index 92700ed..f89c153 100644 --- a/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts +++ b/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts @@ -38,8 +38,9 @@ describe('Module: TranslationKeyExists', () => { ); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('missing.key'); - expect(offenses[0].message).to.include('does not have a matching translation entry'); + expect(offenses[0].message).to.equal( + "'missing.key' does not have a matching translation entry", + ); }); it('should suggest nearest key when the key is a typo', async () => { @@ -53,7 +54,7 @@ describe('Module: TranslationKeyExists', () => { expect(offenses).to.have.length(1); expect(offenses[0].suggest).to.have.length(1); - expect(offenses[0].suggest![0].message).to.include('general.title'); + expect(offenses[0].suggest![0].message).to.equal("Did you mean 'general.title'?"); }); it('should not add suggestions when there is no close key', async () => { diff --git a/packages/platformos-check-common/src/checks/unused-assign/index.spec.ts b/packages/platformos-check-common/src/checks/unused-assign/index.spec.ts index 70d4269..11ddcad 100644 --- a/packages/platformos-check-common/src/checks/unused-assign/index.spec.ts +++ b/packages/platformos-check-common/src/checks/unused-assign/index.spec.ts @@ -56,7 +56,7 @@ describe('Module: UnusedAssign', () => { {{ usedVar }} `; - expect(suggestions).to.include(expectedFixedCode); + expect(suggestions).to.deep.equal([expectedFixedCode]); }); it('should not report unused assigns for things used in a capture tag', async () => { diff --git a/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts b/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts index 9153f75..425e81b 100644 --- a/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts +++ b/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts @@ -51,14 +51,14 @@ describe('Module: UnusedDocParam', () => { const offenses = await runLiquidCheck(UnusedDocParam, sourceCode); const suggestions = applySuggestions(sourceCode, offenses[0]); - expect(suggestions).to.include(` + expect(suggestions).to.deep.equal([` {% doc %} @param param1 - Example param {% enddoc %} {{ param1 }} - `); + `]); }); LoopNamedTags.forEach((tag) => { diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts index 9a354ec..33e1b15 100644 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts @@ -26,7 +26,9 @@ describe('Module: UnusedTranslationKey', () => { [UnusedTranslationKey], ); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('general.unused'); + expect(offenses[0].message).to.equal( + "Translation key 'general.unused' is defined but never used in any template.", + ); }); it('should not report keys used with dynamic variable', async () => { @@ -62,7 +64,9 @@ describe('Module: UnusedTranslationKey', () => { [UnusedTranslationKey], ); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.include('unused'); + expect(offenses[0].message).to.equal( + "Translation key 'unused' is defined but never used in any template.", + ); }); it('should not report when no translation files exist', async () => { diff --git a/packages/platformos-check-common/src/checks/valid-doc-param-types/index.spec.ts b/packages/platformos-check-common/src/checks/valid-doc-param-types/index.spec.ts index 2e66619..c5f45bc 100644 --- a/packages/platformos-check-common/src/checks/valid-doc-param-types/index.spec.ts +++ b/packages/platformos-check-common/src/checks/valid-doc-param-types/index.spec.ts @@ -69,7 +69,7 @@ describe('Module: ValidDocParamTypes', () => { expect(offenses).to.have.length(1); const suggestions = applySuggestions(source, offenses[0]); - expect(suggestions).to.include(`{% doc %} @param param1 - Example param {% enddoc %}`); + expect(suggestions).to.deep.equal([`{% doc %} @param param1 - Example param {% enddoc %}`]); } }); }); diff --git a/packages/platformos-check-common/src/checks/variable-name/index.spec.ts b/packages/platformos-check-common/src/checks/variable-name/index.spec.ts index 5071b79..01276e9 100644 --- a/packages/platformos-check-common/src/checks/variable-name/index.spec.ts +++ b/packages/platformos-check-common/src/checks/variable-name/index.spec.ts @@ -40,7 +40,7 @@ describe('Module: VariableName', () => { const expectedFixedCode = `{% assign variable_name = "value" %}`; - expect(suggestions).to.include(expectedFixedCode); + expect(suggestions).to.deep.equal([expectedFixedCode]); }); it('should not report an error for variables starting with underscore', async () => { diff --git a/packages/platformos-language-server-common/src/TypeSystem.ts b/packages/platformos-language-server-common/src/TypeSystem.ts index a123002..f43f0d2 100644 --- a/packages/platformos-language-server-common/src/TypeSystem.ts +++ b/packages/platformos-language-server-common/src/TypeSystem.ts @@ -29,8 +29,6 @@ import { ReturnType, SourceCodeType, PlatformOSDocset, - isError, - parseJSON, path, BasicParamTypes, getValidParamTypes, @@ -43,21 +41,16 @@ import { inferShapeFromJSONString, inferShapeFromJsonLiteral, inferShapeFromGraphQL, - lookupPropertyPath, mergeShapes, shapeToTypeString, - shapeToDetailString, shapeToJSONPlaceholder, } from './PropertyShapeInference'; import { AbstractFileSystem, DocumentsLocator } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; -import { buildSchema, GraphQLSchema } from 'graphql'; export class TypeSystem { private graphqlSchemaCache: string | undefined; private graphqlSchemaLoaded = false; - private builtGraphqlSchemaCache: GraphQLSchema | undefined; - private builtGraphqlSchemaLoaded = false; constructor( private readonly platformosDocset: PlatformOSDocset, @@ -74,20 +67,6 @@ export class TypeSystem { return this.graphqlSchemaCache; } - async getBuiltGraphQLSchema(): Promise { - if (!this.builtGraphqlSchemaLoaded) { - const sdl = await this.getGraphQLSchema(); - if (sdl) { - try { - this.builtGraphqlSchemaCache = buildSchema(sdl); - } catch { - // Invalid schema SDL — continue without schema - } - } - this.builtGraphqlSchemaLoaded = true; - } - return this.builtGraphqlSchemaCache; - } async inferType( thing: Identifier | ComplexLiquidExpression | LiquidVariable | AssignMarkup, diff --git a/packages/platformos-language-server-common/src/server/AppGraphManager.ts b/packages/platformos-language-server-common/src/server/AppGraphManager.ts index 0b13cc7..41e43f3 100644 --- a/packages/platformos-language-server-common/src/server/AppGraphManager.ts +++ b/packages/platformos-language-server-common/src/server/AppGraphManager.ts @@ -1,7 +1,6 @@ import { path, SourceCodeType } from '@platformos/platformos-check-common'; import { AbstractFileSystem } from '@platformos/platformos-common'; import { - AppGraph, buildAppGraph, getWebComponentMap, IDependencies as GraphDependencies, @@ -10,11 +9,7 @@ import { WebComponentMap, } from '@platformos/platformos-graph'; import { Range } from 'vscode-json-languageservice'; -import { - Connection, - DiagnosticSeverity, - PublishDiagnosticsNotification, -} from 'vscode-languageserver'; +import { Connection } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentManager } from '../documents'; import { @@ -28,8 +23,6 @@ import { FindAppRootURI } from '../internal-types'; export class AppGraphManager { graphs: Map> = new Map(); - private cycleAffectedUris: Set = new Set(); - constructor( private connection: Connection, private documentManager: DocumentManager, @@ -165,125 +158,12 @@ export class AppGraphManager { this.connection.sendNotification(AppGraphDidUpdateNotification.type, { uri: rootUri }); }, 500); - /** - * Detect cycles in the dependency graph using iterative DFS. - * Returns arrays of URIs forming cycles. - */ - private detectCycles(graph: AppGraph): string[][] { - const cycles: string[][] = []; - const visited = new Set(); - const inStack = new Set(); - - // Build adjacency map: uri -> direct dependency uris - const adjacency = new Map(); - for (const [uri, module] of Object.entries(graph.modules)) { - const directDeps = module.dependencies - .filter((dep) => dep.type === 'direct') - .map((dep) => dep.target.uri); - adjacency.set(uri, directDeps); - } - - const dfs = (uri: string, stack: string[]): void => { - if (inStack.has(uri)) { - // Found a cycle — extract the cycle portion - const cycleStart = stack.indexOf(uri); - cycles.push([...stack.slice(cycleStart), uri]); - return; - } - if (visited.has(uri)) return; - - inStack.add(uri); - stack.push(uri); - - const deps = adjacency.get(uri) ?? []; - for (const depUri of deps) { - dfs(depUri, stack); - } - - stack.pop(); - inStack.delete(uri); - visited.add(uri); - }; - - for (const uri of adjacency.keys()) { - if (!visited.has(uri)) { - dfs(uri, []); - } - } - - return cycles; - } - - /** - * Run cycle detection and publish diagnostics for all affected URIs. - * Clears diagnostics for previously affected URIs when cycles no longer exist. - */ - private detectAndPublishCycles(graph: AppGraph): void { - const cycles = this.detectCycles(graph); - const newlyAffectedUris = new Set(); - - if (cycles.length > 0) { - // Group cycles by which URIs are involved - const cyclesByUri = new Map(); - for (const cycle of cycles) { - // All URIs in the cycle (excluding the trailing duplicate) are affected - const cycleUris = cycle.slice(0, -1); - for (const uri of cycleUris) { - newlyAffectedUris.add(uri); - if (!cyclesByUri.has(uri)) { - cyclesByUri.set(uri, []); - } - cyclesByUri.get(uri)!.push(cycle); - } - } - - // Publish diagnostics for all affected URIs - for (const [uri, uriCycles] of cyclesByUri.entries()) { - const diagnostics = uriCycles.map((cycle) => { - const cycleDescription = cycle - .map((u) => { - const parts = u.split('/'); - return parts.slice(-2).join('/'); - }) - .join(' → '); - return { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - severity: DiagnosticSeverity.Error, - message: `Circular render detected: ${cycleDescription}\nThis will cause an infinite loop at runtime.`, - source: 'platformos-check', - }; - }); - - this.connection.sendNotification(PublishDiagnosticsNotification.type, { - uri, - diagnostics, - }); - } - } - - // Clear diagnostics for URIs that are no longer affected - for (const uri of this.cycleAffectedUris) { - if (!newlyAffectedUris.has(uri)) { - this.connection.sendNotification(PublishDiagnosticsNotification.type, { - uri, - diagnostics: [], - }); - } - } - - this.cycleAffectedUris = newlyAffectedUris; - } - private buildAppGraph = async (rootUri: string, entryPoints?: string[]) => { const { documentManager } = this; await documentManager.preload(rootUri); const dependencies = await this.graphDependencies(rootUri); const graph = await buildAppGraph(rootUri, dependencies, entryPoints); - this.detectAndPublishCycles(graph); return graph; }; diff --git a/packages/platformos-language-server-common/src/server/startServer.spec.ts b/packages/platformos-language-server-common/src/server/startServer.spec.ts index cae2fef..f0d2518 100644 --- a/packages/platformos-language-server-common/src/server/startServer.spec.ts +++ b/packages/platformos-language-server-common/src/server/startServer.spec.ts @@ -315,135 +315,6 @@ describe('Module: server', () => { ); }); - describe('circular render detection', () => { - beforeEach(() => { - fileTree = { - '.pos': '', - 'app/views/pages/index.liquid': `{% render 'a' %}`, - 'app/views/partials/a.liquid': `{% render 'b' %}`, - 'app/views/partials/b.liquid': `{% render 'a' %}`, - 'assets/.keep': '', - }; - dependencies = getDependencies(logger, fileTree); - connection = mockConnection(mockRoot); - connection.spies.sendRequest.mockImplementation(async (method: any, params: any) => { - if (method === 'workspace/configuration') { - return params.items.map(({ section }: any) => { - switch (section) { - case CHECK_ON_CHANGE: - return checkOnChange; - case CHECK_ON_OPEN: - return checkOnOpen; - case CHECK_ON_SAVE: - return checkOnSave; - default: - return null; - } - }); - } else if (method === 'client/registerCapability') { - return null; - } else { - throw new Error( - `Does not know how to mock response to '${method}' requests. Check your test.`, - ); - } - }); - startServer(connection, dependencies); - }); - - it('should publish an error diagnostic when a render cycle is detected', async () => { - connection.setup(); - await flushAsync(); - - connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); - connection.triggerNotification(DidChangeWatchedFilesNotification.type, { - changes: [ - { - uri: path.join(mockRoot, 'app/views/partials/a.liquid'), - type: FileChangeType.Created, - }, - { - uri: path.join(mockRoot, 'app/views/partials/b.liquid'), - type: FileChangeType.Created, - }, - { - uri: path.join(mockRoot, 'app/views/pages/index.liquid'), - type: FileChangeType.Created, - }, - ], - }); - await flushAsync(); - await advanceAndFlush(600); - - const diagCalls = connection.spies.sendNotification.mock.calls.filter( - (call: any[]) => call[0] === PublishDiagnosticsNotification.method, - ); - const cycleDiag = diagCalls.find((call: any[]) => - call[1]?.diagnostics?.some((d: any) => d.message?.includes('Circular render')), - ); - expect(cycleDiag).toBeDefined(); - expect(cycleDiag![1].uri).toMatch(/[ab]\.liquid/); - }); - - it('should clear cycle diagnostics when the cycle is resolved', async () => { - connection.setup(); - await flushAsync(); - - connection.openDocument('app/views/partials/a.liquid', `{% render 'b' %}`); - connection.triggerNotification(DidChangeWatchedFilesNotification.type, { - changes: [ - { - uri: path.join(mockRoot, 'app/views/partials/a.liquid'), - type: FileChangeType.Created, - }, - { - uri: path.join(mockRoot, 'app/views/partials/b.liquid'), - type: FileChangeType.Created, - }, - { - uri: path.join(mockRoot, 'app/views/pages/index.liquid'), - type: FileChangeType.Created, - }, - ], - }); - await flushAsync(); - await advanceAndFlush(600); - - // Verify cycle was detected - const diagCallsBefore = connection.spies.sendNotification.mock.calls.filter( - (call: any[]) => call[0] === PublishDiagnosticsNotification.method, - ); - const cycleDiag = diagCallsBefore.find((call: any[]) => - call[1]?.diagnostics?.some((d: any) => d.message?.includes('Circular render')), - ); - expect(cycleDiag).toBeDefined(); - - // Now resolve the cycle: b.liquid no longer renders a - connection.spies.sendNotification.mockClear(); - fileTree['app/views/partials/b.liquid'] = `{# no render #}`; - connection.triggerNotification(DidChangeWatchedFilesNotification.type, { - changes: [ - { - uri: path.join(mockRoot, 'app/views/partials/b.liquid'), - type: FileChangeType.Changed, - }, - ], - }); - await flushAsync(); - await advanceAndFlush(600); - - // Assert that previously affected URIs now receive empty diagnostics - const clearCalls = connection.spies.sendNotification.mock.calls.filter( - (call: any[]) => - call[0] === PublishDiagnosticsNotification.method && - Array.isArray(call[1]?.diagnostics) && - call[1].diagnostics.length === 0 && - /[ab]\.liquid/.test(call[1].uri), - ); - expect(clearCalls.length).toBeGreaterThan(0); - }); - }); - it('should trigger a re-check on did delete files notifications', async () => { connection.setup(); await flushAsync(); From f39cd142999705c1000d29a9455f3be2d56f8ca9 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 08:43:31 +0000 Subject: [PATCH 10/20] Fix graphql check tests --- .../src/checks/nested-graphql-query/index.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts index 44c6c02..3ca7460 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts @@ -18,7 +18,7 @@ describe('Module: NestedGraphQLQuery', () => { ); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - "N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.", + "N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.", ); }); @@ -29,7 +29,7 @@ describe('Module: NestedGraphQLQuery', () => { ); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - "N+1 pattern: {% graphql result = 'result' %} is inside a {% tablerow %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.", + "N+1 pattern: {% graphql result = 'result' %} is inside a {% tablerow %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.", ); }); @@ -64,7 +64,7 @@ describe('Module: NestedGraphQLQuery', () => { ); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - "N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes one database request per iteration. Move the query before the loop and pass data as a variable.", + "N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.", ); }); From 14e7b454eaff704056a2a035ba600525d7994650 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 08:46:45 +0000 Subject: [PATCH 11/20] Cleanup --- .../src/checks/nested-graphql-query/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts index 161372d..0469936 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -2,7 +2,7 @@ import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { isLoopLiquidTag } from '../utils'; -const SKIP_IF_ANCESTOR_TAGS = [NamedTags.cache.toString()]; +const SKIP_IF_ANCESTOR_TAGS = [NamedTags.cache]; export const NestedGraphQLQuery: LiquidCheckDefinition = { meta: { @@ -36,7 +36,9 @@ export const NestedGraphQLQuery: LiquidCheckDefinition = { if (inBackground) return; // Skip if inside a cache block (caching mitigates the N+1 problem) - const shouldSkip = ancestorTags.some((a) => SKIP_IF_ANCESTOR_TAGS.includes(a.name)); + const shouldSkip = ancestorTags.some( + (a) => SKIP_IF_ANCESTOR_TAGS.map(a => a.toString()).includes(a.name) + ); if (shouldSkip) return; let resultName = ''; From 244088eb8fddef51184171b8d5824d393ed2fea8 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 08:56:40 +0000 Subject: [PATCH 12/20] Cleanup undefined object check --- .../src/checks/undefined-object/index.spec.ts | 35 ------------------- .../src/checks/undefined-object/index.ts | 13 ------- 2 files changed, 48 deletions(-) diff --git a/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts b/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts index 8c50b46..96d3835 100644 --- a/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts +++ b/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts @@ -541,39 +541,4 @@ describe('Module: UndefinedObject', () => { expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe("Unknown object 'groups_data' used."); }); - - it('should not report params as undefined when YAML frontmatter declares metadata.params', async () => { - const sourceCode = `--- -metadata: - params: - token: - type: string - email: - type: string ---- -{{ params.token }} -{{ params.email }} -`; - - const offenses = await runLiquidCheck(UndefinedObject, sourceCode); - - expect(offenses).toHaveLength(0); - }); - - it('should still report other undefined objects when frontmatter has metadata.params', async () => { - const sourceCode = `--- -metadata: - params: - token: - type: string ---- -{{ params.token }} -{{ undefined_var }} -`; - - const offenses = await runLiquidCheck(UndefinedObject, sourceCode); - - expect(offenses).toHaveLength(1); - expect(offenses[0].message).toBe("Unknown object 'undefined_var' used."); - }); }); diff --git a/packages/platformos-check-common/src/checks/undefined-object/index.ts b/packages/platformos-check-common/src/checks/undefined-object/index.ts index 04d8c5a..19c23f7 100644 --- a/packages/platformos-check-common/src/checks/undefined-object/index.ts +++ b/packages/platformos-check-common/src/checks/undefined-object/index.ts @@ -9,7 +9,6 @@ import { LiquidTagIncrement, LiquidTagTablerow, LiquidVariableLookup, - LiquidTagFunction, NamedTags, NodeTypes, Position, @@ -19,7 +18,6 @@ import { LiquidTagParseJson, LiquidTagBackground, BackgroundMarkup, - YAMLFrontmatter, } from '@platformos/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types'; import { isError, last } from '../../utils'; @@ -76,17 +74,6 @@ export const UndefinedObject: LiquidCheckDefinition = { } }, - async YAMLFrontmatter(node: YAMLFrontmatter) { - try { - const parsed = yaml.load(node.body) as any; - if (parsed?.metadata?.params && typeof parsed.metadata.params === 'object') { - fileScopedVariables.add('params'); - } - } catch { - // Invalid YAML frontmatter — skip - } - }, - async LiquidTag(node, ancestors) { if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return; From 851dc0ffc14fb1e31999184248f55474d3ca495b Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 09:35:18 +0000 Subject: [PATCH 13/20] Translations check refactor --- .../unused-translation-key/index.spec.ts | 89 ++++++++++++++ .../checks/unused-translation-key/index.ts | 114 ++++++++++-------- .../TranslationProvider.ts | 4 +- 3 files changed, 156 insertions(+), 51 deletions(-) diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts index 33e1b15..b83b56f 100644 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts @@ -78,4 +78,93 @@ describe('Module: UnusedTranslationKey', () => { ); expect(offenses).to.have.length(0); }); + + it('should report an unused module public translation key', async () => { + const offenses = await check( + { + 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', + 'app/views/pages/home.liquid': '

No translations used

', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "Translation key 'modules/user/greeting' is defined but never used in any template.", + ); + }); + + it('should not report a module translation key used in an app template', async () => { + const offenses = await check( + { + 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', + 'app/views/pages/home.liquid': '{{"modules/user/greeting" | t}}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should not report a module private translation key when used', async () => { + const offenses = await check( + { + 'app/modules/admin/private/translations/en.yml': 'en:\n secret: TopSecret', + 'app/modules/admin/private/views/partials/panel.liquid': '{{"modules/admin/secret" | t}}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should handle mixed app and module translations', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n app_used: Yes\n app_unused: No', + 'app/modules/user/public/translations/en.yml': 'en:\n mod_used: Yes\n mod_unused: No', + 'app/views/pages/home.liquid': '{{"app_used" | t}} {{"modules/user/mod_used" | t}}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(2); + const messages = offenses.map((o) => o.message).sort(); + expect(messages).to.deep.equal([ + "Translation key 'app_unused' is defined but never used in any template.", + "Translation key 'modules/user/mod_unused' is defined but never used in any template.", + ]); + }); + + it('should discover translations in legacy modules/ path', async () => { + const offenses = await check( + { + 'modules/core/public/translations/en.yml': 'en:\n legacy_key: Value', + 'app/views/pages/home.liquid': '{{"modules/core/legacy_key" | t}}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should scan Liquid files inside module directories for usage', async () => { + const offenses = await check( + { + 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', + 'modules/user/public/views/partials/header.liquid': '{{"modules/user/greeting" | t}}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should discover split-file translations', async () => { + const offenses = await check( + { + 'app/translations/en/buttons.yml': 'en:\n save: Save\n cancel: Cancel', + 'app/views/pages/form.liquid': '{{"save" | t}}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "Translation key 'cancel' is defined but never used in any template.", + ); + }); }); diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts index ba3f338..4c3bc9d 100644 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts @@ -1,45 +1,37 @@ -import { URI, Utils } from 'vscode-uri'; import { FileType, TranslationProvider } from '@platformos/platformos-common'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { flattenTranslationKeys } from '../../utils/levenshtein'; +import { recursiveReadDirectory } from '../../context-utils'; /** - * Recursively collects all .liquid file URIs under a directory. + * Discovers all module names by listing app/modules/ and modules/ directories. + * Returns a deduplicated set of module names. */ -async function collectLiquidFiles( +async function discoverModules( fs: { readDirectory(uri: string): Promise<[string, FileType][]> }, - dirUri: string, -): Promise { - const uris: string[] = []; - let entries: [string, FileType][]; - try { - entries = await fs.readDirectory(dirUri); - } catch { - return uris; - } - for (const [entryUri, entryType] of entries) { - if (entryType === FileType.Directory) { - uris.push(...(await collectLiquidFiles(fs, entryUri))); - } else if (entryType === FileType.File && entryUri.endsWith('.liquid')) { - uris.push(entryUri); + ...moduleDirUris: string[] +): Promise> { + const modules = new Set(); + + for (const dirUri of moduleDirUris) { + try { + const entries = await fs.readDirectory(dirUri); + for (const [entryUri, entryType] of entries) { + if (entryType === FileType.Directory) { + const name = entryUri.split('/').pop()!; + modules.add(name); + } + } + } catch { + // Directory doesn't exist — skip } } - return uris; -} -/** - * Extracts translation keys from a liquid file source using regex. - * Matches patterns like: "key" | t, 'key' | t, "key" | translate - */ -const TRANSLATION_KEY_RE = /["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g; + return modules; +} function extractUsedKeys(source: string): string[] { - const keys: string[] = []; - let match; - while ((match = TRANSLATION_KEY_RE.exec(source)) !== null) { - keys.push(match[1]); - } - return keys; + return [...source.matchAll(/["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g)].map((m) => m[1]); } // Track which roots have been reported during a check run. @@ -57,7 +49,7 @@ export const UnusedTranslationKey: LiquidCheckDefinition = { name: 'Translation key defined but never used', docs: { description: - 'Reports translation keys defined in app/translations/en.yml that are never referenced in any Liquid template.', + 'Reports translation keys defined in app or module translation files that are never referenced in any Liquid template.', recommended: true, url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/unused-translation-key', }, @@ -74,36 +66,60 @@ export const UnusedTranslationKey: LiquidCheckDefinition = { if (reportedRoots.has(rootKey)) return; reportedRoots.add(rootKey); - const rootUri = URI.parse(context.config.rootUri); - const baseUri = Utils.joinPath(rootUri, 'app/translations'); - const provider = new TranslationProvider(context.fs); + const definedKeys = new Set(); + + // 1. Load app-level translations + for (const base of TranslationProvider.getSearchPaths()) { + const baseUri = context.toUri(base); + const translations = await context.getTranslationsForBase(baseUri, 'en'); + for (const key of flattenTranslationKeys(translations)) { + definedKeys.add(key); + } + } - let allTranslations: Record; - try { - allTranslations = await provider.loadAllTranslationsForBase(baseUri, 'en'); - } catch { - return; + // 2. Discover modules and load their translations + const modules = await discoverModules( + context.fs, + context.toUri('app/modules'), + context.toUri('modules'), + ); + for (const moduleName of modules) { + for (const base of TranslationProvider.getSearchPaths(moduleName)) { + const baseUri = context.toUri(base); + const translations = await context.getTranslationsForBase(baseUri, 'en'); + for (const key of flattenTranslationKeys(translations)) { + definedKeys.add(`modules/${moduleName}/${key}`); + } + } } - const definedKeys = flattenTranslationKeys(allTranslations); - if (definedKeys.length === 0) return; + if (definedKeys.size === 0) return; - // Scan all liquid files for used translation keys + // 3. Scan all Liquid files for used translation keys const usedKeys = new Set(); - const appUri = Utils.joinPath(rootUri, 'app').toString(); - const liquidFiles = await collectLiquidFiles(context.fs, appUri); + const scanRoots = [context.toUri('app'), context.toUri('modules')]; + const isLiquid = ([uri, type]: [string, FileType]) => + type === FileType.File && uri.endsWith('.liquid'); - for (const fileUri of liquidFiles) { + for (const scanRoot of scanRoots) { try { - const source = await context.fs.readFile(fileUri); - for (const key of extractUsedKeys(source)) { - usedKeys.add(key); + const liquidFiles = await recursiveReadDirectory(context.fs, scanRoot, isLiquid); + for (const fileUri of liquidFiles) { + try { + const source = await context.fs.readFile(fileUri); + for (const key of extractUsedKeys(source)) { + usedKeys.add(key); + } + } catch { + // Skip unreadable files + } } } catch { - // Skip unreadable files + // Root doesn't exist — skip } } + // 4. Report unused keys for (const key of definedKeys) { if (!usedKeys.has(key)) { context.report({ diff --git a/packages/platformos-common/src/translation-provider/TranslationProvider.ts b/packages/platformos-common/src/translation-provider/TranslationProvider.ts index ee13f85..857fbb4 100644 --- a/packages/platformos-common/src/translation-provider/TranslationProvider.ts +++ b/packages/platformos-common/src/translation-provider/TranslationProvider.ts @@ -52,7 +52,7 @@ export class TranslationProvider { return key ? { isModule: true, moduleName, key } : { isModule: false, key: translationKey }; } - private getSearchPaths(moduleName?: string): string[] { + static getSearchPaths(moduleName?: string): string[] { if (!moduleName) { return ['app/translations']; } @@ -76,7 +76,7 @@ export class TranslationProvider { return [undefined, undefined]; } - const searchPaths = this.getSearchPaths(parsed.isModule ? parsed.moduleName : undefined); + const searchPaths = TranslationProvider.getSearchPaths(parsed.isModule ? parsed.moduleName : undefined); for (const basePath of searchPaths) { // Strategy A: single locale file ({basePath}/{locale}.yml) From f88ed30629133a4f9ad0b4b66d8f4b35f240fa63 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 09:39:30 +0000 Subject: [PATCH 14/20] yarn formatting --- .../src/checks/circular-render/index.ts | 9 +++++++-- .../src/checks/nested-graphql-query/index.ts | 4 ++-- .../src/checks/parser-blocking-script/index.spec.ts | 4 +++- .../src/checks/unused-doc-param/index.spec.ts | 6 ++++-- .../src/translation-provider/TranslationProvider.ts | 4 +++- .../platformos-language-server-common/src/TypeSystem.ts | 1 - 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/platformos-check-common/src/checks/circular-render/index.ts b/packages/platformos-check-common/src/checks/circular-render/index.ts index c3873c5..8d81370 100644 --- a/packages/platformos-check-common/src/checks/circular-render/index.ts +++ b/packages/platformos-check-common/src/checks/circular-render/index.ts @@ -1,4 +1,8 @@ -import { NodeTypes, toLiquidHtmlAST, nonTraversableProperties } from '@platformos/liquid-html-parser'; +import { + NodeTypes, + toLiquidHtmlAST, + nonTraversableProperties, +} from '@platformos/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { DocumentsLocator } from '@platformos/platformos-common'; import { URI } from 'vscode-uri'; @@ -48,7 +52,8 @@ function extractPartialRefs(source: string): PartialRef[] { node.type === NodeTypes.LiquidTag && node.markup && typeof node.markup === 'object' && - (node.markup.type === NodeTypes.RenderMarkup || node.markup.type === NodeTypes.FunctionMarkup) && + (node.markup.type === NodeTypes.RenderMarkup || + node.markup.type === NodeTypes.FunctionMarkup) && node.markup.partial && node.markup.partial.type !== NodeTypes.VariableLookup ) { diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts index 0469936..af7e819 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -36,8 +36,8 @@ export const NestedGraphQLQuery: LiquidCheckDefinition = { if (inBackground) return; // Skip if inside a cache block (caching mitigates the N+1 problem) - const shouldSkip = ancestorTags.some( - (a) => SKIP_IF_ANCESTOR_TAGS.map(a => a.toString()).includes(a.name) + const shouldSkip = ancestorTags.some((a) => + SKIP_IF_ANCESTOR_TAGS.map((a) => a.toString()).includes(a.name), ); if (shouldSkip) return; diff --git a/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts b/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts index 3277443..06c3fd9 100644 --- a/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts +++ b/packages/platformos-check-common/src/checks/parser-blocking-script/index.spec.ts @@ -20,7 +20,9 @@ describe('Module: ParserBlockingScript', () => { expect(offenses).to.have.length(1); const { check, message, start, end } = offenses[0]; expect(check).to.equal(ParserBlockingScript.meta.code); - expect(message).to.equal('Avoid parser blocking scripts by adding `defer` or `async` on this tag'); + expect(message).to.equal( + 'Avoid parser blocking scripts by adding `defer` or `async` on this tag', + ); expect(start.index).to.equal(startIndex); expect(end.index).to.equal(endIndex); }); diff --git a/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts b/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts index 425e81b..bac5e8b 100644 --- a/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts +++ b/packages/platformos-check-common/src/checks/unused-doc-param/index.spec.ts @@ -51,14 +51,16 @@ describe('Module: UnusedDocParam', () => { const offenses = await runLiquidCheck(UnusedDocParam, sourceCode); const suggestions = applySuggestions(sourceCode, offenses[0]); - expect(suggestions).to.deep.equal([` + expect(suggestions).to.deep.equal([ + ` {% doc %} @param param1 - Example param {% enddoc %} {{ param1 }} - `]); + `, + ]); }); LoopNamedTags.forEach((tag) => { diff --git a/packages/platformos-common/src/translation-provider/TranslationProvider.ts b/packages/platformos-common/src/translation-provider/TranslationProvider.ts index 857fbb4..9616574 100644 --- a/packages/platformos-common/src/translation-provider/TranslationProvider.ts +++ b/packages/platformos-common/src/translation-provider/TranslationProvider.ts @@ -76,7 +76,9 @@ export class TranslationProvider { return [undefined, undefined]; } - const searchPaths = TranslationProvider.getSearchPaths(parsed.isModule ? parsed.moduleName : undefined); + const searchPaths = TranslationProvider.getSearchPaths( + parsed.isModule ? parsed.moduleName : undefined, + ); for (const basePath of searchPaths) { // Strategy A: single locale file ({basePath}/{locale}.yml) diff --git a/packages/platformos-language-server-common/src/TypeSystem.ts b/packages/platformos-language-server-common/src/TypeSystem.ts index f43f0d2..75a8e16 100644 --- a/packages/platformos-language-server-common/src/TypeSystem.ts +++ b/packages/platformos-language-server-common/src/TypeSystem.ts @@ -67,7 +67,6 @@ export class TypeSystem { return this.graphqlSchemaCache; } - async inferType( thing: Identifier | ComplexLiquidExpression | LiquidVariable | AssignMarkup, partialAst: LiquidHtmlNode, From fd0c24127898c180e82ff54ea185c6c92db84238 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 09:47:51 +0000 Subject: [PATCH 15/20] Cleanup --- .../src/checks/circular-render/index.spec.ts | 177 -------------- .../src/checks/circular-render/index.ts | 220 ------------------ .../src/checks/index.ts | 2 - .../platformos-check-node/configs/all.yml | 3 + .../configs/recommended.yml | 3 + 5 files changed, 6 insertions(+), 399 deletions(-) delete mode 100644 packages/platformos-check-common/src/checks/circular-render/index.spec.ts delete mode 100644 packages/platformos-check-common/src/checks/circular-render/index.ts diff --git a/packages/platformos-check-common/src/checks/circular-render/index.spec.ts b/packages/platformos-check-common/src/checks/circular-render/index.spec.ts deleted file mode 100644 index 95a6707..0000000 --- a/packages/platformos-check-common/src/checks/circular-render/index.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { expect, describe, it } from 'vitest'; -import { CircularRender } from '.'; -import { check } from '../../test'; - -describe('Module: CircularRender', () => { - it('should report a simple cycle (A renders B, B renders A)', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': "{% render 'a' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(2); - expect(offenses).to.containOffense({ - check: 'CircularRender', - uri: 'file:///app/views/partials/a.liquid', - }); - expect(offenses).to.containOffense({ - check: 'CircularRender', - uri: 'file:///app/views/partials/b.liquid', - }); - }); - - it('should report a transitive cycle (A -> B -> C -> A)', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': "{% render 'c' %}", - 'app/views/partials/c.liquid': "{% render 'a' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(3); - }); - - it('should report a self-referencing partial', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'a' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(1); - expect(offenses).to.containOffense({ - check: 'CircularRender', - uri: 'file:///app/views/partials/a.liquid', - }); - }); - - it('should not report when there is no cycle', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': "{% render 'c' %}", - 'app/views/partials/c.liquid': '

end

', - }, - [CircularRender], - ); - - expect(offenses).to.have.length(0); - }); - - it('should skip variable lookups', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': '{% render myvar %}', - }, - [CircularRender], - ); - - expect(offenses).to.have.length(0); - }); - - it('should detect cycles via function tags', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% function res = 'b' %}", - 'app/views/partials/b.liquid': "{% function res = 'a' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(2); - }); - - it('should detect cycles with mixed render and function tags', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': "{% function res = 'a' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(2); - }); - - it('should detect cycles via include tags', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% include 'b' %}", - 'app/views/partials/b.liquid': "{% include 'a' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(2); - }); - - it('should not crash when a partial in the chain does not exist', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': "{% render 'nonexistent' %}", - }, - [CircularRender], - ); - - expect(offenses).to.have.length(0); - }); - - it('should not crash when a dependency has a parse error', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': '{% render %}{% unclosed', - }, - [CircularRender], - ); - - expect(offenses).to.have.length(0); - }); - - it('should handle diamond dependencies without false positives', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}{% render 'c' %}", - 'app/views/partials/b.liquid': "{% render 'd' %}", - 'app/views/partials/c.liquid': "{% render 'd' %}", - 'app/views/partials/d.liquid': '

end

', - }, - [CircularRender], - ); - - expect(offenses).to.have.length(0); - }); - - it('should handle deep chains without cycles (depth limit)', async () => { - const files: Record = {}; - for (let i = 0; i < 55; i++) { - files[`app/views/partials/p${i}.liquid`] = `{% render 'p${i + 1}' %}`; - } - files['app/views/partials/p55.liquid'] = '

end

'; - - const offenses = await check(files, [CircularRender]); - expect(offenses).to.have.length(0); - }); - - it('should point the diagnostic at the render tag position', async () => { - const offenses = await check( - { - 'app/views/partials/a.liquid': "{% render 'b' %}", - 'app/views/partials/b.liquid': "{% render 'a' %}", - }, - [CircularRender], - ); - - const offenseA = offenses.find((o) => o.uri === 'file:///app/views/partials/a.liquid'); - expect(offenseA).toBeDefined(); - expect(offenseA!.start.index).toBe(10); - expect(offenseA!.end.index).toBe(13); - }); -}); diff --git a/packages/platformos-check-common/src/checks/circular-render/index.ts b/packages/platformos-check-common/src/checks/circular-render/index.ts deleted file mode 100644 index 8d81370..0000000 --- a/packages/platformos-check-common/src/checks/circular-render/index.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - NodeTypes, - toLiquidHtmlAST, - nonTraversableProperties, -} from '@platformos/liquid-html-parser'; -import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { DocumentsLocator } from '@platformos/platformos-common'; -import { URI } from 'vscode-uri'; - -const MAX_DEPTH = 50; - -type DocumentType = 'render' | 'include' | 'function'; - -interface PartialRef { - name: string; - documentType: DocumentType; - startIndex: number; - endIndex: number; -} - -// Module-level cache: URI -> partial references extracted from that file. -// Shared across all files in a check run to avoid re-parsing. -const parseCacheMap = new WeakMap>(); - -function getParseCache(context: { fs: object }): Map { - if (!parseCacheMap.has(context.fs)) { - parseCacheMap.set(context.fs, new Map()); - } - return parseCacheMap.get(context.fs)!; -} - -/** - * Extract partial references (render/function/include) from a Liquid source string. - * Returns an empty array on parse errors. - */ -function extractPartialRefs(source: string): PartialRef[] { - let ast; - try { - ast = toLiquidHtmlAST(source); - } catch { - return []; - } - - const refs: PartialRef[] = []; - const stack: any[] = [ast]; - - while (stack.length > 0) { - const node = stack.pop(); - if (!node || typeof node !== 'object') continue; - - if ( - node.type === NodeTypes.LiquidTag && - node.markup && - typeof node.markup === 'object' && - (node.markup.type === NodeTypes.RenderMarkup || - node.markup.type === NodeTypes.FunctionMarkup) && - node.markup.partial && - node.markup.partial.type !== NodeTypes.VariableLookup - ) { - refs.push({ - name: node.markup.partial.value, - documentType: (node.name as DocumentType) || 'render', - startIndex: node.markup.partial.position.start, - endIndex: node.markup.partial.position.end, - }); - } - - // Traverse children (skip circular references like parentNode, prev, next) - for (const key of Object.keys(node)) { - if (nonTraversableProperties.has(key)) continue; - const value = node[key]; - if (Array.isArray(value)) { - for (const child of value) { - if (child && typeof child === 'object') { - stack.push(child); - } - } - } else if (value && typeof value === 'object' && value.type) { - stack.push(value); - } - } - } - - return refs; -} - -export const CircularRender: LiquidCheckDefinition = { - meta: { - code: 'CircularRender', - name: 'Prevent circular renders', - docs: { - description: - 'Reports circular render/function/include chains that would cause infinite loops at runtime.', - recommended: true, - url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/circular-render', - }, - type: SourceCodeType.LiquidHtml, - severity: Severity.ERROR, - schema: {}, - targets: [], - }, - - create(context) { - const locator = new DocumentsLocator(context.fs); - const rootUri = URI.parse(context.config.rootUri); - const parseCache = getParseCache(context); - const collectedRefs: PartialRef[] = []; - - return { - async RenderMarkup(node, ancestors) { - if (node.partial.type === NodeTypes.VariableLookup) return; - const parent = ancestors.at(-1) as any; - const documentType: DocumentType = parent?.name === 'include' ? 'include' : 'render'; - collectedRefs.push({ - name: node.partial.value, - documentType, - startIndex: node.partial.position.start, - endIndex: node.partial.position.end, - }); - }, - - async FunctionMarkup(node) { - if (node.partial.type === NodeTypes.VariableLookup) return; - collectedRefs.push({ - name: node.partial.value, - documentType: 'function', - startIndex: node.partial.position.start, - endIndex: node.partial.position.end, - }); - }, - - async onCodePathEnd() { - const fileUri = context.file.uri; - - for (const ref of collectedRefs) { - const cycle = await findCycle( - locator, - rootUri, - context.fs, - parseCache, - fileUri, - ref.name, - ref.documentType, - [fileUri], - 0, - ); - - if (cycle) { - const cyclePath = cycle - .map((u) => { - const parts = u.split('/'); - return parts.slice(-2).join('/'); - }) - .join(' -> '); - - context.report({ - message: `Circular render detected: ${cyclePath}. This will cause an infinite loop at runtime.`, - startIndex: ref.startIndex, - endIndex: ref.endIndex, - }); - } - } - }, - }; - }, -}; - -async function findCycle( - locator: DocumentsLocator, - rootUri: URI, - fs: { readFile: (uri: string) => Promise }, - parseCache: Map, - originUri: string, - partialName: string, - documentType: DocumentType, - path: string[], - depth: number, -): Promise { - if (depth >= MAX_DEPTH) return null; - - const uri = await locator.locate(rootUri, documentType, partialName); - if (!uri) return null; - - if (uri === originUri) { - return [...path, uri]; - } - - if (path.includes(uri)) { - // This is a cycle, but it doesn't include the origin file — skip it - return null; - } - - let refs = parseCache.get(uri); - if (!refs) { - try { - const source = await fs.readFile(uri); - refs = extractPartialRefs(source); - } catch { - refs = []; - } - parseCache.set(uri, refs); - } - - for (const dep of refs) { - const cycle = await findCycle( - locator, - rootUri, - fs, - parseCache, - originUri, - dep.name, - dep.documentType, - [...path, uri], - depth + 1, - ); - if (cycle) return cycle; - } - - return null; -} diff --git a/packages/platformos-check-common/src/checks/index.ts b/packages/platformos-check-common/src/checks/index.ts index 0879d79..d191ccf 100644 --- a/packages/platformos-check-common/src/checks/index.ts +++ b/packages/platformos-check-common/src/checks/index.ts @@ -6,7 +6,6 @@ import { YAMLCheckDefinition, } from '../types'; -import { CircularRender } from './circular-render'; import { DeprecatedFilter } from './deprecated-filter'; import { DeprecatedTag } from './deprecated-tag'; import { DuplicateRenderPartialArguments } from './duplicate-render-partial-arguments'; @@ -47,7 +46,6 @@ export const allChecks: ( | GraphQLCheckDefinition | YAMLCheckDefinition )[] = [ - CircularRender, DeprecatedFilter, DeprecatedTag, DuplicateFunctionArguments, diff --git a/packages/platformos-check-node/configs/all.yml b/packages/platformos-check-node/configs/all.yml index b47e30b..b577d1a 100644 --- a/packages/platformos-check-node/configs/all.yml +++ b/packages/platformos-check-node/configs/all.yml @@ -3,6 +3,9 @@ # Do not modify manually. Your changes will be overwritten. ignore: - node_modules/** +CircularRender: + enabled: true + severity: 0 DeprecatedFilter: enabled: true severity: 1 diff --git a/packages/platformos-check-node/configs/recommended.yml b/packages/platformos-check-node/configs/recommended.yml index b47e30b..b577d1a 100644 --- a/packages/platformos-check-node/configs/recommended.yml +++ b/packages/platformos-check-node/configs/recommended.yml @@ -3,6 +3,9 @@ # Do not modify manually. Your changes will be overwritten. ignore: - node_modules/** +CircularRender: + enabled: true + severity: 0 DeprecatedFilter: enabled: true severity: 1 From 58f8d0118069db11fb9718288fe8f1bad97e11ac Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 10:03:59 +0000 Subject: [PATCH 16/20] Refactoring translation checks --- .../translation-key-exists/index.spec.ts | 49 +++++++++++++ .../checks/translation-key-exists/index.ts | 51 +++----------- .../src/checks/translation-utils.ts | 63 +++++++++++++++++ .../checks/unused-translation-key/index.ts | 70 +++---------------- .../platformos-check-node/configs/all.yml | 3 - .../configs/recommended.yml | 3 - 6 files changed, 129 insertions(+), 110 deletions(-) create mode 100644 packages/platformos-check-common/src/checks/translation-utils.ts diff --git a/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts b/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts index f89c153..3860cac 100644 --- a/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts +++ b/packages/platformos-check-common/src/checks/translation-key-exists/index.spec.ts @@ -69,4 +69,53 @@ describe('Module: TranslationKeyExists', () => { expect(offenses).to.have.length(1); expect(offenses[0].suggest ?? []).to.have.length(0); }); + + it('should not report a module translation key that exists', async () => { + const offenses = await check( + { + 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', + 'code.liquid': '{{"modules/user/greeting" | t}}', + }, + [TranslationKeyExists], + ); + expect(offenses).to.have.length(0); + }); + + it('should report a module translation key that does not exist', async () => { + const offenses = await check( + { + 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', + 'code.liquid': '{{"modules/user/missing" | t}}', + }, + [TranslationKeyExists], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "'modules/user/missing' does not have a matching translation entry", + ); + }); + + it('should suggest nearest module key for typos', async () => { + const offenses = await check( + { + 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', + 'code.liquid': '{{"modules/user/greating" | t}}', + }, + [TranslationKeyExists], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].suggest).to.have.length(1); + expect(offenses[0].suggest![0].message).to.equal("Did you mean 'modules/user/greeting'?"); + }); + + it('should find keys in legacy modules/ path', async () => { + const offenses = await check( + { + 'modules/core/public/translations/en.yml': 'en:\n label: Label', + 'code.liquid': '{{"modules/core/label" | t}}', + }, + [TranslationKeyExists], + ); + expect(offenses).to.have.length(0); + }); }); 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 540bf9a..97ca213 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,23 +1,6 @@ -import { TranslationProvider } from '@platformos/platformos-common'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { URI, Utils } from 'vscode-uri'; -import { flattenTranslationKeys, findNearestKeys } from '../../utils/levenshtein'; - -function keyExists(key: string, pointer: any) { - for (const token of key.split('.')) { - if (typeof pointer !== 'object') { - return false; - } - - if (!pointer.hasOwnProperty(token)) { - return false; - } - - pointer = pointer[token]; - } - - return true; -} +import { findNearestKeys } from '../../utils/levenshtein'; +import { loadAllDefinedKeys } from '../translation-utils'; export const TranslationKeyExists: LiquidCheckDefinition = { meta: { @@ -36,7 +19,6 @@ export const TranslationKeyExists: LiquidCheckDefinition = { create(context) { const nodes: { translationKey: string; startIndex: number; endIndex: number }[] = []; - const translationProvider = new TranslationProvider(context.fs); return { async LiquidVariable(node) { @@ -56,31 +38,14 @@ export const TranslationKeyExists: LiquidCheckDefinition = { }, async onCodePathEnd() { - let allDefinedKeys: string[] | null = null; + if (nodes.length === 0) return; - for (const { translationKey, startIndex, endIndex } of nodes) { - const translation = await translationProvider.translate( - URI.parse(context.config.rootUri), - translationKey, - ); - - if (!!translation) { - continue; - } + // Load all defined keys (app + modules) once per file + const allDefinedKeys = await loadAllDefinedKeys(context); + const definedKeySet = new Set(allDefinedKeys); - // Lazy-load all keys once per file - if (allDefinedKeys === null) { - const baseUri = Utils.joinPath(URI.parse(context.config.rootUri), 'app/translations'); - try { - const allTranslations = await translationProvider.loadAllTranslationsForBase( - baseUri, - 'en', - ); - allDefinedKeys = flattenTranslationKeys(allTranslations); - } catch { - allDefinedKeys = []; - } - } + for (const { translationKey, startIndex, endIndex } of nodes) { + if (definedKeySet.has(translationKey)) continue; const nearest = findNearestKeys(translationKey, allDefinedKeys); const message = `'${translationKey}' does not have a matching translation entry`; diff --git a/packages/platformos-check-common/src/checks/translation-utils.ts b/packages/platformos-check-common/src/checks/translation-utils.ts new file mode 100644 index 0000000..bed4c2c --- /dev/null +++ b/packages/platformos-check-common/src/checks/translation-utils.ts @@ -0,0 +1,63 @@ +import { FileType, TranslationProvider } from '@platformos/platformos-common'; +import { flattenTranslationKeys } from '../utils/levenshtein'; + +/** + * Discovers all module names by listing app/modules/ and modules/ directories. + * Returns a deduplicated set of module names. + */ +export async function discoverModules( + fs: { readDirectory(uri: string): Promise<[string, FileType][]> }, + ...moduleDirUris: string[] +): Promise> { + const modules = new Set(); + for (const dirUri of moduleDirUris) { + try { + const entries = await fs.readDirectory(dirUri); + for (const [entryUri, entryType] of entries) { + if (entryType === FileType.Directory) { + modules.add(entryUri.split('/').pop()!); + } + } + } catch (error) { + console.error(`[translation-utils] Failed to read module directory ${dirUri}:`, error); + } + } + return modules; +} + +export interface TranslationContext { + fs: { readDirectory(uri: string): Promise<[string, FileType][]> }; + toUri(relativePath: string): string; + getTranslationsForBase(uri: string, locale: string): Promise>; +} + +/** + * Loads all defined translation keys (app-level + module-level) and returns + * them as a flat string array. Module keys are prefixed with `modules/{name}/`. + */ +export async function loadAllDefinedKeys(context: TranslationContext): Promise { + const definedKeys: string[] = []; + + // App-level translations + for (const base of TranslationProvider.getSearchPaths()) { + const translations = await context.getTranslationsForBase(context.toUri(base), 'en'); + definedKeys.push(...flattenTranslationKeys(translations)); + } + + // Module translations + const modules = await discoverModules( + context.fs, + context.toUri('app/modules'), + context.toUri('modules'), + ); + for (const moduleName of modules) { + for (const base of TranslationProvider.getSearchPaths(moduleName)) { + const translations = await context.getTranslationsForBase(context.toUri(base), 'en'); + for (const key of flattenTranslationKeys(translations)) { + definedKeys.push(`modules/${moduleName}/${key}`); + } + } + } + + return definedKeys; +} diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts index 4c3bc9d..0d1b80d 100644 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts @@ -1,34 +1,7 @@ -import { FileType, TranslationProvider } from '@platformos/platformos-common'; +import { FileType } from '@platformos/platformos-common'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { flattenTranslationKeys } from '../../utils/levenshtein'; import { recursiveReadDirectory } from '../../context-utils'; - -/** - * Discovers all module names by listing app/modules/ and modules/ directories. - * Returns a deduplicated set of module names. - */ -async function discoverModules( - fs: { readDirectory(uri: string): Promise<[string, FileType][]> }, - ...moduleDirUris: string[] -): Promise> { - const modules = new Set(); - - for (const dirUri of moduleDirUris) { - try { - const entries = await fs.readDirectory(dirUri); - for (const [entryUri, entryType] of entries) { - if (entryType === FileType.Directory) { - const name = entryUri.split('/').pop()!; - modules.add(name); - } - } - } catch { - // Directory doesn't exist — skip - } - } - - return modules; -} +import { loadAllDefinedKeys } from '../translation-utils'; function extractUsedKeys(source: string): string[] { return [...source.matchAll(/["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g)].map((m) => m[1]); @@ -66,34 +39,9 @@ export const UnusedTranslationKey: LiquidCheckDefinition = { if (reportedRoots.has(rootKey)) return; reportedRoots.add(rootKey); - const definedKeys = new Set(); - - // 1. Load app-level translations - for (const base of TranslationProvider.getSearchPaths()) { - const baseUri = context.toUri(base); - const translations = await context.getTranslationsForBase(baseUri, 'en'); - for (const key of flattenTranslationKeys(translations)) { - definedKeys.add(key); - } - } - - // 2. Discover modules and load their translations - const modules = await discoverModules( - context.fs, - context.toUri('app/modules'), - context.toUri('modules'), - ); - for (const moduleName of modules) { - for (const base of TranslationProvider.getSearchPaths(moduleName)) { - const baseUri = context.toUri(base); - const translations = await context.getTranslationsForBase(baseUri, 'en'); - for (const key of flattenTranslationKeys(translations)) { - definedKeys.add(`modules/${moduleName}/${key}`); - } - } - } - - if (definedKeys.size === 0) return; + const allDefinedKeys = await loadAllDefinedKeys(context); + if (allDefinedKeys.length === 0) return; + const definedKeys = new Set(allDefinedKeys); // 3. Scan all Liquid files for used translation keys const usedKeys = new Set(); @@ -110,12 +58,12 @@ export const UnusedTranslationKey: LiquidCheckDefinition = { for (const key of extractUsedKeys(source)) { usedKeys.add(key); } - } catch { - // Skip unreadable files + } catch (error) { + console.error(`[UnusedTranslationKey] Failed to read ${fileUri}:`, error); } } - } catch { - // Root doesn't exist — skip + } catch (error) { + console.error(`[UnusedTranslationKey] Failed to scan ${scanRoot}:`, error); } } diff --git a/packages/platformos-check-node/configs/all.yml b/packages/platformos-check-node/configs/all.yml index 104a10e..48c1b11 100644 --- a/packages/platformos-check-node/configs/all.yml +++ b/packages/platformos-check-node/configs/all.yml @@ -3,9 +3,6 @@ # Do not modify manually. Your changes will be overwritten. ignore: - node_modules/** -CircularRender: - enabled: true - severity: 0 DeprecatedFilter: enabled: true severity: 1 diff --git a/packages/platformos-check-node/configs/recommended.yml b/packages/platformos-check-node/configs/recommended.yml index 104a10e..48c1b11 100644 --- a/packages/platformos-check-node/configs/recommended.yml +++ b/packages/platformos-check-node/configs/recommended.yml @@ -3,9 +3,6 @@ # Do not modify manually. Your changes will be overwritten. ignore: - node_modules/** -CircularRender: - enabled: true - severity: 0 DeprecatedFilter: enabled: true severity: 1 From 77a5fa12c2e804f8b2aeef26ec614448f3b1f6b4 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Wed, 18 Mar 2026 11:19:43 +0000 Subject: [PATCH 17/20] Fixes --- .../checks/nested-graphql-query/index.spec.ts | 99 ++++++++- .../src/checks/nested-graphql-query/index.ts | 200 +++++++++++++++--- .../src/checks/undefined-object/index.spec.ts | 30 +++ .../src/checks/undefined-object/index.ts | 26 +++ .../unused-translation-key/index.spec.ts | 24 +++ .../checks/unused-translation-key/index.ts | 8 +- 6 files changed, 354 insertions(+), 33 deletions(-) diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts index 3ca7460..cf5e905 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { runLiquidCheck } from '../../test'; +import { runLiquidCheck, check } from '../../test'; import { NestedGraphQLQuery } from '.'; describe('Module: NestedGraphQLQuery', () => { @@ -75,4 +75,101 @@ describe('Module: NestedGraphQLQuery', () => { ); expect(offenses).to.have.length(2); }); + + it('should report function call inside loop that transitively calls graphql', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'my_partial' %}{% endfor %}`, + 'app/lib/my_partial.liquid': `{% graphql result = 'products/get' %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "N+1 pattern: {% function 'my_partial' %} inside a {% for %} loop transitively calls a GraphQL query (my_partial). Move the query before the loop and pass data as a variable.", + ); + }); + + it('should report render call inside loop that transitively calls graphql', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% render 'my_partial' %}{% endfor %}`, + 'app/views/partials/my_partial.liquid': `{% graphql result = 'products/get' %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "N+1 pattern: {% render 'my_partial' %} inside a {% for %} loop transitively calls a GraphQL query (my_partial). Move the query before the loop and pass data as a variable.", + ); + }); + + it('should report function call that transitively calls graphql through another function', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'outer' %}{% endfor %}`, + 'app/lib/outer.liquid': `{% function inner_res = 'inner' %}`, + 'app/lib/inner.liquid': `{% graphql result = 'products/get' %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "N+1 pattern: {% function 'outer' %} inside a {% for %} loop transitively calls a GraphQL query (outer \u2192 inner). Move the query before the loop and pass data as a variable.", + ); + }); + + it('should not report function call inside loop that does not call graphql', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'safe_partial' %}{% endfor %}`, + 'app/lib/safe_partial.liquid': `{{ 'hello' }}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(0); + }); + + it('should not report function call inside loop when partial does not exist', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'nonexistent' %}{% endfor %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(0); + }); + + it('should not report function call inside loop with cache wrapping', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% cache 'key' %}{% function res = 'my_partial' %}{% endcache %}{% endfor %}`, + 'app/lib/my_partial.liquid': `{% graphql result = 'products/get' %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(0); + }); + + it('should handle circular function calls without infinite loop', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'partial_a' %}{% endfor %}`, + 'app/lib/partial_a.liquid': `{% function res = 'partial_b' %}`, + 'app/lib/partial_b.liquid': `{% function res = 'partial_a' %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(0); + }); + + it('should skip function calls with dynamic partial names', async () => { + const offenses = await check( + { + 'app/views/pages/index.liquid': `{% for item in items %}{% function res = partial_name %}{% endfor %}`, + }, + [NestedGraphQLQuery], + ); + expect(offenses).to.have.length(0); + }); }); diff --git a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts index af7e819..6ab6490 100644 --- a/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts +++ b/packages/platformos-check-common/src/checks/nested-graphql-query/index.ts @@ -1,16 +1,120 @@ -import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser'; +import { + LiquidHtmlNode, + NamedTags, + NodeTypes, + toLiquidHtmlAST, +} from '@platformos/liquid-html-parser'; +import { DocumentsLocator } from '@platformos/platformos-common'; +import { URI } from 'vscode-uri'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; import { isLoopLiquidTag } from '../utils'; const SKIP_IF_ANCESTOR_TAGS = [NamedTags.cache]; +type GraphQLFound = { + type: 'graphql'; + partialChain: string[]; +}; + +type FunctionOrRenderFound = { + type: 'function' | 'render'; + partialName: string; +}; + +type FoundNode = GraphQLFound | FunctionOrRenderFound; + +function findNodesInAST(ast: LiquidHtmlNode[]): FoundNode[] { + const results: FoundNode[] = []; + const stack: LiquidHtmlNode[] = [...ast]; + + while (stack.length > 0) { + const node = stack.pop()!; + + if (node.type === NodeTypes.LiquidTag) { + if (node.name === NamedTags.graphql) { + results.push({ type: 'graphql', partialChain: [] }); + } else if ( + (node.name === NamedTags.function || node.name === NamedTags.render) && + typeof node.markup !== 'string' && + 'partial' in node.markup && + node.markup.partial.type !== NodeTypes.VariableLookup + ) { + results.push({ type: node.name, partialName: node.markup.partial.value }); + } + + if ('children' in node && Array.isArray(node.children)) { + stack.push(...node.children); + } + } else if ('children' in node && Array.isArray((node as any).children)) { + stack.push(...(node as any).children); + } + } + + return results; +} + +async function containsGraphQLTransitively( + locator: DocumentsLocator, + fs: { readFile(uri: string): Promise }, + rootUri: URI, + partialName: string, + tagType: 'function' | 'render', + visited: Set, +): Promise { + if (visited.has(partialName)) return null; + visited.add(partialName); + + const location = await locator.locate(rootUri, tagType, partialName); + if (!location) return null; + + let source: string; + try { + source = await fs.readFile(location); + } catch { + return null; + } + + let ast; + try { + ast = toLiquidHtmlAST(source); + } catch { + return null; + } + + const nodes = findNodesInAST(ast.children); + + for (const found of nodes) { + if (found.type === 'graphql') { + return [partialName]; + } + } + + for (const found of nodes) { + if (found.type === 'function' || found.type === 'render') { + const chain = await containsGraphQLTransitively( + locator, + fs, + rootUri, + found.partialName, + found.type, + visited, + ); + if (chain) { + return [partialName, ...chain]; + } + } + } + + return null; +} + export const NestedGraphQLQuery: LiquidCheckDefinition = { meta: { code: 'NestedGraphQLQuery', name: 'Prevent N+1 GraphQL queries in loops', docs: { description: - 'This check detects {% graphql %} tags placed inside loop tags ({% for %}, {% tablerow %}), which causes one database request per loop iteration (N+1 pattern).', + 'This check detects {% graphql %} tags placed inside loop tags ({% for %}, {% tablerow %}), which causes one database request per loop iteration (N+1 pattern). It also follows {% function %} and {% render %} calls transitively to detect indirect GraphQL queries.', recommended: true, url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/nested-graphql-query', }, @@ -21,44 +125,78 @@ export const NestedGraphQLQuery: LiquidCheckDefinition = { }, create(context) { - return { - async LiquidTag(node, ancestors) { - if (node.name !== NamedTags.graphql) return; + const locator = new DocumentsLocator(context.fs); + const rootUri = URI.parse(context.config.rootUri); + + function isInsideLoopWithoutCacheOrBackground(ancestors: LiquidHtmlNode[]) { + const ancestorTags = ancestors.filter((a) => a.type === NodeTypes.LiquidTag); + const loopAncestor = ancestorTags.find(isLoopLiquidTag); + if (!loopAncestor) return null; - const ancestorTags = ancestors.filter((a) => a.type === NodeTypes.LiquidTag); + const inBackground = ancestorTags.some((a) => a.name === NamedTags.background); + if (inBackground) return null; - const loopAncestor = ancestorTags.find(isLoopLiquidTag); + const shouldSkip = ancestorTags.some((a) => + SKIP_IF_ANCESTOR_TAGS.map((a) => a.toString()).includes(a.name), + ); + if (shouldSkip) return null; - if (!loopAncestor) return; + return loopAncestor; + } - // Skip if inside a background tag - const inBackground = ancestorTags.some((a) => a.name === NamedTags.background); - if (inBackground) return; + return { + async LiquidTag(node, ancestors) { + if (node.name === NamedTags.graphql) { + const loopAncestor = isInsideLoopWithoutCacheOrBackground(ancestors); + if (!loopAncestor) return; - // Skip if inside a cache block (caching mitigates the N+1 problem) - const shouldSkip = ancestorTags.some((a) => - SKIP_IF_ANCESTOR_TAGS.map((a) => a.toString()).includes(a.name), - ); - if (shouldSkip) return; + let resultName = ''; + if ( + typeof node.markup !== 'string' && + (node.markup.type === NodeTypes.GraphQLMarkup || + node.markup.type === NodeTypes.GraphQLInlineMarkup) + ) { + resultName = node.markup.name ? ` result = '${node.markup.name}'` : ''; + } - let resultName = ''; - if ( - typeof node.markup !== 'string' && - (node.markup.type === NodeTypes.GraphQLMarkup || - node.markup.type === NodeTypes.GraphQLInlineMarkup) - ) { - resultName = node.markup.name ? ` result = '${node.markup.name}'` : ''; - } + const graphqlStr = resultName ? `{% graphql${resultName} %}` : '{% graphql %}'; + context.report({ + message: `N+1 pattern: ${graphqlStr} is inside a {% ${loopAncestor.name} %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } else if (node.name === NamedTags.function || node.name === NamedTags.render) { + const loopAncestor = isInsideLoopWithoutCacheOrBackground(ancestors); + if (!loopAncestor) return; - const graphqlStr = resultName ? `{% graphql${resultName} %}` : '{% graphql %}'; + if ( + typeof node.markup === 'string' || + !('partial' in node.markup) || + node.markup.partial.type === NodeTypes.VariableLookup + ) { + return; + } - const message = `N+1 pattern: ${graphqlStr} is inside a {% ${loopAncestor.name} %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.`; + const partialName = node.markup.partial.value; + const visited = new Set(); + const chain = await containsGraphQLTransitively( + locator, + context.fs, + rootUri, + partialName, + node.name, + visited, + ); - context.report({ - message, - startIndex: node.position.start, - endIndex: node.position.end, - }); + if (chain) { + const chainStr = chain.join(' → '); + context.report({ + message: `N+1 pattern: {% ${node.name} '${partialName}' %} inside a {% ${loopAncestor.name} %} loop transitively calls a GraphQL query (${chainStr}). Move the query before the loop and pass data as a variable.`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } + } }, }; }, diff --git a/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts b/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts index 96d3835..ebd4e8f 100644 --- a/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts +++ b/packages/platformos-check-common/src/checks/undefined-object/index.spec.ts @@ -541,4 +541,34 @@ describe('Module: UndefinedObject', () => { expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe("Unknown object 'groups_data' used."); }); + + it('should not report an offense for catch variable inside catch block', async () => { + const sourceCode = ` + {% try %} + {{ "something" }} + {% catch error %} + {{ error }} + {% endtry %} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('should report an offense for catch variable used outside catch block', async () => { + const sourceCode = ` + {% try %} + {{ "something" }} + {% catch error %} + {{ error }} + {% endtry %} + {{ error }} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe("Unknown object 'error' used."); + }); }); diff --git a/packages/platformos-check-common/src/checks/undefined-object/index.ts b/packages/platformos-check-common/src/checks/undefined-object/index.ts index 19c23f7..f151b31 100644 --- a/packages/platformos-check-common/src/checks/undefined-object/index.ts +++ b/packages/platformos-check-common/src/checks/undefined-object/index.ts @@ -158,6 +158,24 @@ export const UndefinedObject: LiquidCheckDefinition = { } }, + async LiquidBranch(node, ancestors) { + if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return; + + // {% try %} ... {% catch error %} registers the error variable + if ( + node.name === NamedTags.catch && + node.markup && + typeof node.markup !== 'string' && + 'name' in node.markup && + node.markup.name + ) { + indexVariableScope(node.markup.name, { + start: node.blockStartPosition.end, + end: node.blockEndPosition?.start, + }); + } + }, + async VariableLookup(node, ancestors) { if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return; @@ -166,6 +184,8 @@ export const UndefinedObject: LiquidCheckDefinition = { if (isLiquidTag(parent) && isLiquidTagParseJson(parent)) return; // Skip the result variable of function tags (it's a definition, not a usage) if (isFunctionMarkup(parent) && parent.name === node) return; + // Skip the error variable definition in catch branches + if (isLiquidBranchCatch(parent) && parent.markup === node) return; variables.push(node); }, @@ -313,3 +333,9 @@ function isLiquidTagBackground( function isFunctionMarkup(node?: LiquidHtmlNode): node is FunctionMarkup { return node?.type === NodeTypes.FunctionMarkup; } + +function isLiquidBranchCatch( + node?: LiquidHtmlNode, +): node is LiquidHtmlNode & { type: typeof NodeTypes.LiquidBranch; name: 'catch'; markup: any } { + return node?.type === NodeTypes.LiquidBranch && (node as any).name === NamedTags.catch; +} diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts index b83b56f..2f37dde 100644 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts @@ -167,4 +167,28 @@ describe('Module: UnusedTranslationKey', () => { "Translation key 'cancel' is defined but never used in any template.", ); }); + + it('should not report a key used as a default filter value', async () => { + const offenses = await check( + { + 'app/modules/core/public/translations/en.yml': + 'en:\n validation:\n blank: cannot be blank', + 'app/modules/core/public/views/partials/presence.liquid': + '{% assign key = key | default: "modules/core/validation.blank" %}', + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); + + it('should not report a key used as a default filter value with single quotes', async () => { + const offenses = await check( + { + 'app/translations/en.yml': 'en:\n greeting: Hello', + 'app/views/pages/home.liquid': "{% assign msg = msg | default: 'greeting' %}", + }, + [UnusedTranslationKey], + ); + expect(offenses).to.have.length(0); + }); }); diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts index 0d1b80d..5dd23cf 100644 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts +++ b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts @@ -4,7 +4,13 @@ import { recursiveReadDirectory } from '../../context-utils'; import { loadAllDefinedKeys } from '../translation-utils'; function extractUsedKeys(source: string): string[] { - return [...source.matchAll(/["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g)].map((m) => m[1]); + // Direct usage: "key" | t + const direct = [...source.matchAll(/["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g)].map( + (m) => m[1], + ); + // Indirect usage via default filter: | default: "key" + const defaults = [...source.matchAll(/\|\s*default:\s*["']([^"']+)["']/g)].map((m) => m[1]); + return [...direct, ...defaults]; } // Track which roots have been reported during a check run. From 84ebddfbd8154b1e8265b2e5f799b5a588c4eaf7 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Thu, 19 Mar 2026 08:25:51 +0000 Subject: [PATCH 18/20] Remove unused translation key check --- .../src/checks/index.ts | 2 - .../unused-translation-key/index.spec.ts | 194 ------------------ .../checks/unused-translation-key/index.ts | 89 -------- .../platformos-check-node/configs/all.yml | 3 - .../configs/recommended.yml | 3 - 5 files changed, 291 deletions(-) delete mode 100644 packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts delete mode 100644 packages/platformos-check-common/src/checks/unused-translation-key/index.ts diff --git a/packages/platformos-check-common/src/checks/index.ts b/packages/platformos-check-common/src/checks/index.ts index 134914c..6763048 100644 --- a/packages/platformos-check-common/src/checks/index.ts +++ b/packages/platformos-check-common/src/checks/index.ts @@ -38,7 +38,6 @@ import { InvalidHashAssignTarget } from './invalid-hash-assign-target'; import { DuplicateFunctionArguments } from './duplicate-function-arguments'; import { MissingRenderPartialArguments } from './missing-render-partial-arguments'; import { NestedGraphQLQuery } from './nested-graphql-query'; -import { UnusedTranslationKey } from './unused-translation-key'; import { MissingPage } from './missing-page'; export const allChecks: ( @@ -79,7 +78,6 @@ export const allChecks: ( InvalidHashAssignTarget, MissingRenderPartialArguments, NestedGraphQLQuery, - UnusedTranslationKey, MissingPage, ]; diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts deleted file mode 100644 index 2f37dde..0000000 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { check } from '../../test'; -import { UnusedTranslationKey, _resetForTesting } from '.'; - -describe('Module: UnusedTranslationKey', () => { - beforeEach(() => { - _resetForTesting(); - }); - it('should not report a key that is used in a template', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello', - 'app/views/pages/home.liquid': `{{"general.title" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should report a key that is defined but never used', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello\n unused: Bye', - 'app/views/pages/home.liquid': `{{"general.title" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal( - "Translation key 'general.unused' is defined but never used in any template.", - ); - }); - - it('should not report keys used with dynamic variable', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n general:\n title: Hello', - 'app/views/pages/home.liquid': `{{ some_key | t }}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); // general.title is still unused - }); - - it('should accumulate used keys across multiple liquid files', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n a: A\n b: B', - 'app/views/pages/page1.liquid': `{{"a" | t}}`, - 'app/views/pages/page2.liquid': `{{"b" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should report each unused key only once even with multiple liquid files', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n used: Used\n unused: Unused', - 'app/views/pages/page1.liquid': '{{"used" | t}}', - 'app/views/pages/page2.liquid': '

No translations

', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal( - "Translation key 'unused' is defined but never used in any template.", - ); - }); - - it('should not report when no translation files exist', async () => { - const offenses = await check( - { - 'app/views/pages/home.liquid': `{{"general.title" | t}}`, - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should report an unused module public translation key', async () => { - const offenses = await check( - { - 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', - 'app/views/pages/home.liquid': '

No translations used

', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal( - "Translation key 'modules/user/greeting' is defined but never used in any template.", - ); - }); - - it('should not report a module translation key used in an app template', async () => { - const offenses = await check( - { - 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', - 'app/views/pages/home.liquid': '{{"modules/user/greeting" | t}}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should not report a module private translation key when used', async () => { - const offenses = await check( - { - 'app/modules/admin/private/translations/en.yml': 'en:\n secret: TopSecret', - 'app/modules/admin/private/views/partials/panel.liquid': '{{"modules/admin/secret" | t}}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should handle mixed app and module translations', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n app_used: Yes\n app_unused: No', - 'app/modules/user/public/translations/en.yml': 'en:\n mod_used: Yes\n mod_unused: No', - 'app/views/pages/home.liquid': '{{"app_used" | t}} {{"modules/user/mod_used" | t}}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(2); - const messages = offenses.map((o) => o.message).sort(); - expect(messages).to.deep.equal([ - "Translation key 'app_unused' is defined but never used in any template.", - "Translation key 'modules/user/mod_unused' is defined but never used in any template.", - ]); - }); - - it('should discover translations in legacy modules/ path', async () => { - const offenses = await check( - { - 'modules/core/public/translations/en.yml': 'en:\n legacy_key: Value', - 'app/views/pages/home.liquid': '{{"modules/core/legacy_key" | t}}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should scan Liquid files inside module directories for usage', async () => { - const offenses = await check( - { - 'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello', - 'modules/user/public/views/partials/header.liquid': '{{"modules/user/greeting" | t}}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should discover split-file translations', async () => { - const offenses = await check( - { - 'app/translations/en/buttons.yml': 'en:\n save: Save\n cancel: Cancel', - 'app/views/pages/form.liquid': '{{"save" | t}}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal( - "Translation key 'cancel' is defined but never used in any template.", - ); - }); - - it('should not report a key used as a default filter value', async () => { - const offenses = await check( - { - 'app/modules/core/public/translations/en.yml': - 'en:\n validation:\n blank: cannot be blank', - 'app/modules/core/public/views/partials/presence.liquid': - '{% assign key = key | default: "modules/core/validation.blank" %}', - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); - - it('should not report a key used as a default filter value with single quotes', async () => { - const offenses = await check( - { - 'app/translations/en.yml': 'en:\n greeting: Hello', - 'app/views/pages/home.liquid': "{% assign msg = msg | default: 'greeting' %}", - }, - [UnusedTranslationKey], - ); - expect(offenses).to.have.length(0); - }); -}); diff --git a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts b/packages/platformos-check-common/src/checks/unused-translation-key/index.ts deleted file mode 100644 index 5dd23cf..0000000 --- a/packages/platformos-check-common/src/checks/unused-translation-key/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { FileType } from '@platformos/platformos-common'; -import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; -import { recursiveReadDirectory } from '../../context-utils'; -import { loadAllDefinedKeys } from '../translation-utils'; - -function extractUsedKeys(source: string): string[] { - // Direct usage: "key" | t - const direct = [...source.matchAll(/["']([^"']+)["']\s*\|\s*(?:t|translate)\b/g)].map( - (m) => m[1], - ); - // Indirect usage via default filter: | default: "key" - const defaults = [...source.matchAll(/\|\s*default:\s*["']([^"']+)["']/g)].map((m) => m[1]); - return [...direct, ...defaults]; -} - -// Track which roots have been reported during a check run. -// Since create() is called per-file, we need module-level deduplication. -const reportedRoots = new Set(); - -/** @internal Reset module state between test runs. */ -export function _resetForTesting() { - reportedRoots.clear(); -} - -export const UnusedTranslationKey: LiquidCheckDefinition = { - meta: { - code: 'UnusedTranslationKey', - name: 'Translation key defined but never used', - docs: { - description: - 'Reports translation keys defined in app or module translation files that are never referenced in any Liquid template.', - recommended: true, - url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/unused-translation-key', - }, - type: SourceCodeType.LiquidHtml, - severity: Severity.INFO, - schema: {}, - targets: [], - }, - - create(context) { - return { - async onCodePathEnd() { - const rootKey = context.config.rootUri; - if (reportedRoots.has(rootKey)) return; - reportedRoots.add(rootKey); - - const allDefinedKeys = await loadAllDefinedKeys(context); - if (allDefinedKeys.length === 0) return; - const definedKeys = new Set(allDefinedKeys); - - // 3. Scan all Liquid files for used translation keys - const usedKeys = new Set(); - const scanRoots = [context.toUri('app'), context.toUri('modules')]; - const isLiquid = ([uri, type]: [string, FileType]) => - type === FileType.File && uri.endsWith('.liquid'); - - for (const scanRoot of scanRoots) { - try { - const liquidFiles = await recursiveReadDirectory(context.fs, scanRoot, isLiquid); - for (const fileUri of liquidFiles) { - try { - const source = await context.fs.readFile(fileUri); - for (const key of extractUsedKeys(source)) { - usedKeys.add(key); - } - } catch (error) { - console.error(`[UnusedTranslationKey] Failed to read ${fileUri}:`, error); - } - } - } catch (error) { - console.error(`[UnusedTranslationKey] Failed to scan ${scanRoot}:`, error); - } - } - - // 4. Report unused keys - for (const key of definedKeys) { - if (!usedKeys.has(key)) { - context.report({ - message: `Translation key '${key}' is defined but never used in any template.`, - startIndex: 0, - endIndex: 0, - }); - } - } - }, - }; - }, -}; diff --git a/packages/platformos-check-node/configs/all.yml b/packages/platformos-check-node/configs/all.yml index 48c1b11..05cc370 100644 --- a/packages/platformos-check-node/configs/all.yml +++ b/packages/platformos-check-node/configs/all.yml @@ -88,9 +88,6 @@ UnusedAssign: UnusedDocParam: enabled: true severity: 1 -UnusedTranslationKey: - enabled: true - severity: 2 ValidDocParamTypes: enabled: true severity: 0 diff --git a/packages/platformos-check-node/configs/recommended.yml b/packages/platformos-check-node/configs/recommended.yml index 48c1b11..05cc370 100644 --- a/packages/platformos-check-node/configs/recommended.yml +++ b/packages/platformos-check-node/configs/recommended.yml @@ -88,9 +88,6 @@ UnusedAssign: UnusedDocParam: enabled: true severity: 1 -UnusedTranslationKey: - enabled: true - severity: 2 ValidDocParamTypes: enabled: true severity: 0 From 301fab27e7eb9a604a991dc84243c65524959ae6 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Thu, 19 Mar 2026 08:55:50 +0000 Subject: [PATCH 19/20] Fix null type in ValidRenderPartialArgumentTypes --- .../checks/valid-render-partial-argument-types/index.spec.ts | 2 +- packages/platformos-check-common/src/liquid-doc/utils.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts b/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts index b59c4e2..10b7eaa 100644 --- a/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts +++ b/packages/platformos-check-common/src/checks/valid-render-partial-argument-types/index.spec.ts @@ -34,7 +34,7 @@ describe('Module: ValidRenderPartialParamTypes', () => { { value: "'hello'", actualType: BasicParamTypes.String }, { value: '123', actualType: BasicParamTypes.Number }, { value: 'true', actualType: BasicParamTypes.Boolean }, - { value: 'empty', actualType: BasicParamTypes.Boolean }, + { value: 'empty', actualType: BasicParamTypes.String }, ], }, ]; diff --git a/packages/platformos-check-common/src/liquid-doc/utils.ts b/packages/platformos-check-common/src/liquid-doc/utils.ts index 798fd6f..5aac661 100644 --- a/packages/platformos-check-common/src/liquid-doc/utils.ts +++ b/packages/platformos-check-common/src/liquid-doc/utils.ts @@ -16,6 +16,7 @@ export enum BasicParamTypes { Number = 'number', Boolean = 'boolean', Object = 'object', + Null = 'null', } export enum SupportedDocTagTypes { @@ -59,6 +60,8 @@ export function inferArgumentType(arg: LiquidExpression | LiquidVariable): Basic case NodeTypes.Number: return BasicParamTypes.Number; case NodeTypes.LiquidLiteral: + if (arg.value === null) return BasicParamTypes.Null; + if (arg.value === '') return BasicParamTypes.String; return BasicParamTypes.Boolean; case NodeTypes.Range: case NodeTypes.VariableLookup: @@ -119,6 +122,8 @@ export function filePathSupportsLiquidDoc(uri: UriString) { * References `BasicParamTypes` */ export function getValidParamTypes(objectEntries: ObjectEntry[]): Map { + // Note: Null is intentionally excluded — it is not a valid @param type annotation, + // it's only used internally by inferArgumentType for null/nil literal values. const paramTypes: Map = new Map([ [BasicParamTypes.String, undefined], [BasicParamTypes.Number, undefined], From 4a540338cbbaa2a5985aae69069fadad18694f43 Mon Sep 17 00:00:00 2001 From: Wojciech Grzeszczak Date: Thu, 19 Mar 2026 09:42:17 +0000 Subject: [PATCH 20/20] Cleanup --- .../src/liquid-doc/utils.ts | 14 ++++++++------ .../src/backfill-docs/doc-generator.ts | 4 ++-- .../src/backfill-docs/types.ts | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/platformos-check-common/src/liquid-doc/utils.ts b/packages/platformos-check-common/src/liquid-doc/utils.ts index 5aac661..870ba6f 100644 --- a/packages/platformos-check-common/src/liquid-doc/utils.ts +++ b/packages/platformos-check-common/src/liquid-doc/utils.ts @@ -16,9 +16,13 @@ export enum BasicParamTypes { Number = 'number', Boolean = 'boolean', Object = 'object', - Null = 'null', } +/** Inferred type for null/nil literals — not a valid @param type, only used in type mismatch messages. */ +export const InferredNull = 'null' as const; + +export type InferredParamType = BasicParamTypes | typeof InferredNull; + export enum SupportedDocTagTypes { Param = 'param', Example = 'example', @@ -45,7 +49,7 @@ export function getDefaultValueForType(type: string | null) { /** * Casts the value of a LiquidNamedArgument to a string representing the type of the value. */ -export function inferArgumentType(arg: LiquidExpression | LiquidVariable): BasicParamTypes { +export function inferArgumentType(arg: LiquidExpression | LiquidVariable): InferredParamType { if (arg.type === NodeTypes.LiquidVariable) { // A variable with filters — delegate to the base expression if there are no filters, // otherwise we can't statically determine the filtered output type. @@ -60,7 +64,7 @@ export function inferArgumentType(arg: LiquidExpression | LiquidVariable): Basic case NodeTypes.Number: return BasicParamTypes.Number; case NodeTypes.LiquidLiteral: - if (arg.value === null) return BasicParamTypes.Null; + if (arg.value === null) return InferredNull; if (arg.value === '') return BasicParamTypes.String; return BasicParamTypes.Boolean; case NodeTypes.Range: @@ -96,7 +100,7 @@ export function isNullLiteral(arg: LiquidExpression | LiquidVariable): boolean { * Makes certain types more permissive: * - Boolean accepts any value, since everything is truthy / falsy in Liquid */ -export function isTypeCompatible(expectedType: string, actualType: BasicParamTypes): boolean { +export function isTypeCompatible(expectedType: string, actualType: InferredParamType): boolean { const normalizedExpectedType = expectedType.toLowerCase(); if (normalizedExpectedType === BasicParamTypes.Boolean) { @@ -122,8 +126,6 @@ export function filePathSupportsLiquidDoc(uri: UriString) { * References `BasicParamTypes` */ export function getValidParamTypes(objectEntries: ObjectEntry[]): Map { - // Note: Null is intentionally excluded — it is not a valid @param type annotation, - // it's only used internally by inferArgumentType for null/nil literal values. const paramTypes: Map = new Map([ [BasicParamTypes.String, undefined], [BasicParamTypes.Number, undefined], diff --git a/packages/platformos-check-node/src/backfill-docs/doc-generator.ts b/packages/platformos-check-node/src/backfill-docs/doc-generator.ts index 44c4c44..4c6c158 100644 --- a/packages/platformos-check-node/src/backfill-docs/doc-generator.ts +++ b/packages/platformos-check-node/src/backfill-docs/doc-generator.ts @@ -1,4 +1,4 @@ -import { BasicParamTypes } from '@platformos/platformos-check-common'; +import { InferredParamType } from '@platformos/platformos-check-common'; /** * Generate a single @param line for a doc tag. @@ -10,7 +10,7 @@ import { BasicParamTypes } from '@platformos/platformos-check-common'; */ export function generateParamLine( name: string, - type: BasicParamTypes, + type: InferredParamType, isOptional: boolean = true, ): string { const paramName = isOptional ? `[${name}]` : name; diff --git a/packages/platformos-check-node/src/backfill-docs/types.ts b/packages/platformos-check-node/src/backfill-docs/types.ts index 063328f..467ee85 100644 --- a/packages/platformos-check-node/src/backfill-docs/types.ts +++ b/packages/platformos-check-node/src/backfill-docs/types.ts @@ -1,10 +1,10 @@ -import { BasicParamTypes } from '@platformos/platformos-check-common'; +import { InferredParamType } from '@platformos/platformos-check-common'; export type TagType = 'function' | 'render' | 'include'; export interface ArgumentInfo { name: string; - inferredType: BasicParamTypes; + inferredType: InferredParamType; usageCount: number; }