diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b30dbf3..0514fd8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,21 @@ name: CI on: [push] +# Skip the Playwright Chromium download during `yarn install`. +# +# `@vscode/test-web` (devDep of the vscode-extension workspace) pulls +# `@playwright/browser-chromium`, whose `install` script downloads ~150 MB +# of Chromium and consistently exceeds the runner's [4/4] phase budget on +# a yarn-cache miss (Node 24 + Ubuntu/Windows; observed in the integration +# job that hung at `Building fresh packages...` for 15m 50s before the +# 30-minute step timeout fired). The CI jobs below run unit + integration +# vitest specs only — none of them invoke `@vscode/test-web` and none need +# a real Chromium binary. Engineers running `yarn dev:web` locally to +# exercise the VS Code web extension still get Chromium because the env +# var is scoped to this workflow. +env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + jobs: code-quality: runs-on: ubuntu-latest diff --git a/docs/mcp-supervisor/decisions/001-package/README.md b/docs/mcp-supervisor/decisions/001-package/README.md new file mode 100644 index 00000000..15280832 --- /dev/null +++ b/docs/mcp-supervisor/decisions/001-package/README.md @@ -0,0 +1,163 @@ +# Adding `@platformos/platformos-mcp-supervisor` to platformos-tools + +## Status + +Accepted (v1 ships `validate_code` only — see *Consequences* for deferred +work). + +## tl;dr + +We forked `pos-supervisor` — an MCP server that lets LLM agents validate +platformOS code before writing it — into a new monorepo workspace package +named `@platformos/platformos-mcp-supervisor`. The v1 release exposes a +single MCP tool (`validate_code`) over stdio. The in-process LSP +(`@platformos/platformos-language-server-node`) replaces the subprocess +`pos-cli lsp` dependency. Roughly half of the source codebase +(analytics, adaptive engine, dashboard, HTTP transport, sessions, the +nine other MCP tools, pos-cli fallback) is deferred or dropped. + +## Context + +`pos-supervisor` (https://github.com/Platform-OS/pos-supervisor) is a +production MCP server. Its `validate_code` tool composes a parse → +LSP → enrich → pipeline → validators → structural-warnings → fix-gen +pipeline that catches platformOS-specific code issues an LLM cannot +infer from a generic Liquid linter. The supervisor was first built as a +stand-alone Bun/Node project that shelled out to `pos-cli lsp` for +language-server diagnostics, layered HTTP/dashboard/analytics on top, and +shipped a portfolio of ten MCP tools. + +Three pressures motivated the migration: + +1. **Distribution.** Most platformos-tools consumers already depend on + `@platformos/platformos-language-server-node`. Shipping the supervisor + alongside it removes the `pos-cli` system dependency, removes the Bun + runtime requirement, and lets npm/yarn handle version coupling. +2. **API stability.** The in-monorepo language-server-common package + evolves frequently; pos-supervisor pinned an older `pos-cli` and + silently lagged. Living in the same workspace ties the supervisor to + the language-server's actual API rather than the `pos-cli` CLI's + wrapper, with `tsc --noEmit` catching drift at build time. +3. **Surface area.** Eight of the ten MCP tools, the analytics / CAC + predictor / case-base / promoted-rules / dashboard subsystems, and + the session-event-bus are out of scope for what an embedded + line-level validator needs to deliver. Re-shipping them would + multiply the maintenance surface and contradict the package's + single responsibility. + +### Alternatives considered + +- **Continue shipping pos-supervisor as an external repo.** Lowest + short-term cost, but leaves the `pos-cli` system dependency, the Bun + runtime requirement, and the API drift problem in place. Rejected. +- **Add an `mcp` mode to an existing language-server package.** Keeps + the dependency surface flat but conflates "language server speaking + LSP" with "MCP server speaking validate_code". Rejected on cohesion + grounds. +- **Port everything (all ten tools) in v1.** Largest surface; would + re-import analytics/CAC/dashboard infra into a TS monorepo that + doesn't have a place for them yet. Rejected; deferred per + *Consequences*. + +## Decision + +A new workspace package, `packages/platformos-mcp-supervisor`, with: + +- **In-process LSP** via `@platformos/platformos-language-server-node` + (PassThrough + `createProtocolConnection`). No subprocess, no PATH + dependency on `pos-cli`. +- **Full TypeScript rewrite** of every JS file. The only verbatim copy + is `src/data/` (hints, knowledge base, references) — the JSON / YAML + content is data, not code. +- **One MCP tool — `validate_code` — over stdio.** No HTTP transport. +- **Single-fork-isolate vitest config** matching the monorepo root. +- **Strict TS settings.** No `any` on the public surface; the result + type is exported and consumers can drive the server programmatically + via `startServer`. + +The migration was sequenced across 25 backlog tasks (`m-0`) — see the +[migration milestone](../../../../../.backlog/milestones/m-0%20-%20pos-supervisor-%E2%86%92-platformos-tools-migration-%28v1-va.md). +Each task ships a slice with type-check + spec coverage; the final +acceptance gate (P24) is a parity suite that deep-equal's normalised v1 +output against captured pos-supervisor baselines on a 13-entry corpus. + +## Consequences + +### Positive + +1. **No `pos-cli` dependency.** Consumers install one npm package and + are done; CI doesn't need an external binary on PATH. +2. **API drift caught at build time.** `@platformos/platformos-language-server-node` + is consumed as a typed workspace dep; `tsc --noEmit` over `src/` + surfaces upstream API changes the moment they land. +3. **Tighter test surface.** 276 specs (unit + integration + LSP + contract + parity) run pos-cli-free on any node the monorepo already + targets. CI matrix simplifies from "Linux/Mac/Win + pos-cli + + npm-global" to "node only." +4. **Single-tool focus.** Every public surface in v1 reduces to + `validate_code`. Operator and agent docs collapse to one tool + description. +5. **Strictly-typed result.** `ValidateCodeResult` is exported; agents + embedding the server programmatically don't need a JSON-schema + shim. + +### Negative + +1. **Deferred functionality.** Nine MCP tools from the source — most + notably `validate_intent`, `scaffold`, `project_map`, and + `analyze_project` — are not available in v1. Agents that depended on + the pre-validation workflow (`validate_intent` → scaffold → `validate_code`) + need a different flow until those tools land. +2. **No analytics layer.** Source's analytics-store, CAC predictor, + case-base scoring, promoted rules, and dashboard are dropped. The + adaptive-rule layer that down-weighted noisy rules from telemetry is + gone; v1 ships the static rule set. Pinned by tests, not by + analytics-driven calibration. +3. **No `pending_files` / `pending_pages` / `pending_translations` + suppression.** Source merged in-flight plan state from + `validate_intent` to silence `MissingPartial` / `MissingPage` / + `TranslationKeyExists` for not-yet-written files. v1 treats every + diagnostic at face value — agents creating multiple files at once + will see transient errors until the partner files land on disk. +4. **No session-event bus / NDJSON log / blob store.** Operator + debugging via session inspection is unavailable in v1; the only log + surface is stderr. +5. **Workspace coupling to `@platformos/platformos-language-server-node`.** + The supervisor pins the in-monorepo LSP API. Breaking changes to + that API surface are now changes the supervisor must handle in the + same PR. + +### Drawbacks → mitigation strategies + +> Deferred MCP tools. + +We will ship the remaining tools as additive PRs to the same package +when (a) the agent flows that depend on them are well-understood and +(b) the supporting subsystems they need (project_map graph caches, +scaffold templates, intent-validator path/role checks) are themselves +ported. None of those tools have a hard dependency on the dropped +analytics / dashboard / session-event-bus surfaces. + +> No analytics layer. + +The rule engine is wired the same way as source's. When/if a v1 +analytics layer is added back, the rule-engine's `forceDisable` / +`releaseDisable` / `isCheckForceDisabled` surface gives operators the +same manual override that source's force-enable/force-disable mechanism +exposed. + +> No pending-state suppression. + +Pre-write multi-file flows still work; agents see transient errors that +clear when companion files land. The integration spec exercises the +"create the missing file then re-validate" loop. If the friction +becomes load-bearing, pending-state suppression can be re-added as an +opt-in input parameter without touching the rest of the pipeline. + +> Workspace coupling. + +`@platformos/platformos-language-server-node` is the *intended* API +contract here, not an accidental coupling. The contract is pinned by +`test/upstream/lsp-diagnostic-contract.spec.ts` (P23) — 23 tests that +fail loudly the moment any pinned `(check, message_template)` pair +drifts. diff --git a/docs/mcp-supervisor/decisions/001-package/platformos-tools.log b/docs/mcp-supervisor/decisions/001-package/platformos-tools.log new file mode 100644 index 00000000..55360af4 --- /dev/null +++ b/docs/mcp-supervisor/decisions/001-package/platformos-tools.log @@ -0,0 +1,2 @@ +[2026-06-02T13:14:56.729Z] auto-diagnostics triggered by Edit: /home/ecgtheow/Work/pos-ai-tools/pos-mcp/platformos-tools/docs/mcp-supervisor/decisions/001-package/README.md +[2026-06-02T13:14:56.731Z] auto-diagnostics: no issues or skipped diff --git a/docs/mcp-supervisor/decisions/ADR_TEMPLATE.md b/docs/mcp-supervisor/decisions/ADR_TEMPLATE.md new file mode 100644 index 00000000..f43809f3 --- /dev/null +++ b/docs/mcp-supervisor/decisions/ADR_TEMPLATE.md @@ -0,0 +1,35 @@ +# Decision record template + + + + +# Title + +## Status + + + + +## tl;dr + + + +## Context + + + +## Consequences + + diff --git a/docs/mcp-supervisor/decisions/README.md b/docs/mcp-supervisor/decisions/README.md new file mode 100644 index 00000000..84a4eef2 --- /dev/null +++ b/docs/mcp-supervisor/decisions/README.md @@ -0,0 +1,12 @@ +# `@platformos/platformos-mcp-supervisor` architectural decisions + +This is the home for decisions made about `@platformos/platformos-mcp-supervisor`'s architecture. + +## Contributing a new decision + +1. Copy `ADR_TEMPLATE.md` to a new directory in this folder, with a concise + slug starting with the next sequential number, e.g. + `002-using-vite/README.md`. +2. Fill in the template. +3. Open a PR. +4. When it's approved, you can merge it. diff --git a/package.json b/package.json index e82edfbb..18560da9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "packages/platformos-check-*", "packages/platformos-graph", "packages/platformos-language-server-*", + "packages/platformos-mcp-*", "packages/vscode-extension", "packages/release-orchestrator" ], diff --git a/packages/platformos-mcp-supervisor/ARCHITECTURE.md b/packages/platformos-mcp-supervisor/ARCHITECTURE.md new file mode 100644 index 00000000..f449becd --- /dev/null +++ b/packages/platformos-mcp-supervisor/ARCHITECTURE.md @@ -0,0 +1,705 @@ +# `@platformos/platformos-mcp-supervisor` — architecture + +This document describes the v1 architecture of the +`@platformos/platformos-mcp-supervisor` package: what each module does, +how they fit together, the boot sequence, and the request-time +orchestration inside the single shipped tool (`validate_code`). + +For the rationale behind the package (why we forked, what we deferred, +mitigation strategies for the cut surface), see +[`docs/mcp-supervisor/decisions/001-package/README.md`](../../docs/mcp-supervisor/decisions/001-package/README.md). +For the migration backlog (25 phases, P1–P25), see +[`.backlog/milestones/m-0 - pos-supervisor-…-(v1-va.md`](../../../.backlog/milestones/m-0%20-%20pos-supervisor-%E2%86%92-platformos-tools-migration-%28v1-va.md). + +--- + +## 1. One-paragraph summary + +The supervisor is a single-tool MCP server. It speaks stdio JSON-RPC, +boots an in-process platformOS Language Server, hydrates three docset +indexes (filters / objects / tags) from +`@platformos/platformos-check-docs-updater`, registers `validate_code` +on an `McpServer` instance, and waits for tool calls. Each +`validate_code` call runs the input through a deterministic +linear pipeline (parse → lint → enrich → pipeline → validators → +structural-warnings → diff-aware → domain guide → fix-gen → cluster → +scorecard → bridge → stamp → force-disable → cleanup) and returns a +strict-typed `ValidateCodeResult`. + +--- + +## 2. Process topology + +``` +┌──────────────┐ stdio ┌──────────────────────────────────────┐ +│ MCP client │ ◀─── JSON-RPC ──▶│ platformos-mcp-supervisor (Node) │ +│ (Claude Code,│ │ │ +│ VSCode MCP, │ │ ┌────────────────────────────────┐ │ +│ custom) │ │ │ McpServer (SDK) │ │ +└──────────────┘ │ │ - registerTool('validate_code') │ + │ │ - StdioServerTransport │ │ + │ └────────┬───────────────────────┘ │ + │ │ │ + │ ┌─────▼──────────┐ │ + │ │ validate_code │ │ + │ │ handler │ │ + │ └─────┬──────────┘ │ + │ │ (in-process) │ + │ ┌────────▼────────────────────────┐ │ + │ │ PlatformOSLSPClient │ │ + │ │ ┌──── PassThrough streams ────┐ │ │ + │ │ │ client ⇄ server in-process │ │ │ + │ │ └─────────────────────────────┘ │ │ + │ │ @platformos/platformos-language-│ │ + │ │ server-node `startServer` │ │ + │ └─────────────────────────────────┘ │ + └──────────────────────────────────────┘ +``` + +- **No subprocesses.** The language server runs in the same Node + process via a `PassThrough` stream pair and + `createProtocolConnection`. The historical `pos-cli lsp` subprocess + is gone. +- **No HTTP / dashboard / event bus.** stderr is the only operational + log surface; stdout is reserved for JSON-RPC frames. +- **One tool.** `validate_code` is the only entry. The MCP SDK's + `registerTool` validates input against the Zod shape before + dispatching to the handler. + +--- + +## 3. Package layout + +``` +packages/platformos-mcp-supervisor/ +├── package.json +├── tsconfig.json # rootDir: src; module: commonjs +├── tsconfig.build.json # extends; excludes **/*.spec.ts +├── vitest.config.mts # single-fork-isolate +├── README.md +├── ARCHITECTURE.md # (this file) +├── scripts/ +│ ├── copy-data.mjs # postbuild: src/data → dist/data +│ ├── smoke-stdio.mjs # real-bin verification via MCP SDK Client +│ └── record-parity.mjs # capture pos-supervisor baselines +├── src/ +│ ├── bin/ +│ │ └── platformos-mcp-supervisor.ts # CLI entrypoint (#!/usr/bin/env node) +│ ├── core/ # pure logic +│ │ ├── constants.ts # timeouts, confidence defaults +│ │ ├── utils.ts # toUri / sanitizePath / toPosixPath +│ │ ├── tool-error.ts # ToolError class (typed status) +│ │ ├── logger.ts # createLogger (stderr only) +│ │ ├── domain-detector.ts # getDomainFromPath +│ │ ├── position-utils.ts # offsetToLineCol etc. +│ │ ├── liquid-parser.ts # parse + extractAllFromAST +│ │ ├── lsp-client.ts # PlatformOSLSPClient (in-process) +│ │ ├── filters-index.ts # FiltersIndex (docset wrapper) +│ │ ├── objects-index.ts # ObjectsIndex +│ │ ├── tags-index.ts # TagsIndex +│ │ ├── hint-loader.ts # data/hints/*.md template engine +│ │ ├── knowledge-loader.ts # data/* loader (gotchas, triggers, …) +│ │ ├── asset-index.ts # buildAssetIndex / resolveAssetPath +│ │ ├── translation-index.ts # buildTranslationIndex +│ │ ├── page-route-index.ts # buildPageRouteIndex + overlay +│ │ ├── project-scanner.ts # scanProject → ProjectMap +│ │ ├── project-map.ts # TTL-cached getProjectMap +│ │ ├── project-fact-graph.ts # ProjectFactGraph (graph queries) +│ │ ├── dependency-graph.ts # resolveRender/Function/Graphql +│ │ ├── render-flow.ts # variable-flow analysis +│ │ ├── diagnostic-record.ts # extractParams + templateOf +│ │ ├── diagnostic-pipeline.ts # 15-step ORDERED post-processing +│ │ ├── structural-warnings.ts # 16 detectors → pos-supervisor:* warnings +│ │ ├── error-enricher.ts # enrichAll + bridgeRulesOntoUnattributed +│ │ ├── fix-generator.ts # generateFixes / cluster / scorecard +│ │ ├── schema-validator.ts # schema YAML structural checks +│ │ ├── translation-validator.ts # translation YAML locale-wrapper check +│ │ ├── schema-property-checker.ts # GraphQL ↔ schema cross-check +│ │ └── rules/ +│ │ ├── engine.ts # Rule, runRules, force-disable +│ │ ├── queries.ts # graph-aware helpers +│ │ ├── module-paths.ts # module installation queries +│ │ ├── index.ts # loadAllRules (registers 32 modules) +│ │ └── .ts × 32 # per-check rules (92 rules total) +│ ├── data/ # verbatim from source (the only `cp`) +│ │ ├── hints/ # one Markdown per LSP check +│ │ ├── knowledge.json # check summaries + gotchas + … +│ │ ├── checks/ # per-check YAML metadata +│ │ ├── shopify-objects.json # Shopify contamination data +│ │ ├── shopify-filters.json +│ │ ├── shopify-tags.json +│ │ ├── content-triggers.yml +│ │ ├── language-features.yml +│ │ ├── modules-missing-docs.json +│ │ ├── domain-gotchas.yml +│ │ └── references/ # markdown docs (future tools) +│ ├── test/ +│ │ └── index-stubs.ts # shared FiltersIndex/ObjectsIndex stubs +│ ├── tools/ +│ │ └── validate-code.ts # the orchestrator (~1010 LOC) +│ ├── server.ts # startServer({ projectDir, log? }) +│ └── index.ts # public re-exports +└── test/ + ├── fixtures/ + │ ├── project/ # 26 files — platformOS project tree + │ ├── broken-project/ # 43 files — known-bad fixtures + │ └── parity/ + │ ├── corpus.ts # 13 corpus entries + │ └── -.expected.json # 13 captured baselines + ├── helpers/ + │ ├── server.ts # stdio test client (MCP SDK) + │ └── server.spec.ts + ├── integration/ + │ └── validate-code-features.spec.ts # 10 features through real stdio bin + ├── parity/ + │ └── validate-code-parity.spec.ts # 16 = 3 sanity + 13 deep-equal + ├── upstream/ + │ └── lsp-diagnostic-contract.spec.ts# 23 pinned (check, msg) pairs + └── POS_CLI_AUDIT.md # migration audit reference +``` + +Specs **colocate as `*.spec.ts` next to source under `src/`** (matches +`platformos-check-common/src/checks/*/index.spec.ts`). Integration, +parity, and upstream specs live under `test/` because they need +fixtures, helpers, or recorded baselines that don't belong next to +source. + +--- + +## 4. Dependency graph + +### External / workspace deps + +| Dep | Role | +|---|---| +| `@modelcontextprotocol/sdk@^1.29.0` | `McpServer`, `StdioServerTransport`, MCP wire protocol | +| `@platformos/platformos-language-server-node@0.0.19` | `startServer(connection)` — the in-process LSP | +| `@platformos/platformos-check-common@0.0.19` | `PlatformOSDocset` interface, check types | +| `@platformos/platformos-check-docs-updater@0.0.19` | `PlatformOSLiquidDocsManager` (filters / objects / tags JSON) | +| `@platformos/liquid-html-parser@0.0.17` | Two-stage Liquid parser (CST → AST), `walk`, `NodeTypes`, `NamedTags` | +| `vscode-languageserver/node` + `vscode-languageserver-protocol/node` | LSP client/server runtime + typed protocol constants | +| `vscode-languageserver-textdocument` | Text document mutations | +| `js-yaml@^4.1.0` | Schema / translation YAML parsing | +| `normalize-path@^3.0.0` | Forward-slash path normalisation (per platformos-tools convention) | +| `zod@^3.23.8` | Tool input schema | + +### Internal module dependencies + +``` + startServer + │ + ┌──────────────────────────┼──────────────────────────────┐ + ▼ ▼ ▼ +PlatformOSLiquidDocsManager PlatformOSLSPClient loadAllRules + │ │ │ + ▼ ▼ ▼ +FiltersIndex in-process LSP Rule registry +ObjectsIndex (PassThrough + (32 modules, +TagsIndex protocol conn) 92 rules) + │ │ │ + └──────────────────────────┼──────────────────────────────┘ + ▼ + ValidateCodeContext + │ + ▼ + validate_code handler (1010 LOC) + │ + ┌───────────────┬──────────────┼────────────────┬──────────────────┐ + ▼ ▼ ▼ ▼ ▼ +liquid-parser lsp-client error-enricher diagnostic- structural- +(walks AST, (sync doc, (rules first, pipeline warnings +extracts await diags) regex fallback) (15 steps) (16 detectors) +structural) + │ │ │ │ │ + └───────────────┴──────────────┼────────────────┴──────────────────┘ + ▼ + schema-validator + translation-validator + schema-property-checker + │ + ▼ + fix-generator + (cluster + scorecard) + │ + ▼ + knowledge-loader + (domain guide + tips) + │ + ▼ + bridge → stamp → filter + │ + ▼ + ValidateCodeResult +``` + +--- + +## 5. Module reference + +### 5.1 `src/server.ts` — `startServer(opts)` + +**Public surface:** `startServer(opts: ServerOptions): Promise`. + +**Boot sequence** (linear, fail-fast on `projectDir` missing): + +1. `statSync(projectDir)` — abort with a clear error if the path is + missing or not a directory. +2. `loadAllRules()` — idempotent registration of the 32 per-check rule + modules against the engine. +3. `new PlatformOSLiquidDocsManager(log)` → + `await docsManager.setup()` (best-effort HTTP fetch; falls back to + the local cache if the fetch fails). +4. `Promise.allSettled` over three concurrent index loads: + `FiltersIndex.load`, `ObjectsIndex.load`, `TagsIndex.load`. Failures + are non-fatal — the supervisor degrades to no suggestions for that + check kind. +5. `new PlatformOSLSPClient()` → `lsp.initialize(projectDir, …)` — + handshake with the in-process LSP, capabilities negotiated, root URI + advertised. The promise resolves whether init succeeds OR fails (the + server should still respond to MCP calls, surfacing the LSP failure + as an `info` diagnostic per request). +6. Build `ValidateCodeContext` carrying `{ directory, lsp, awaitLsp, + filtersIndex, objectsIndex, tagsIndex, log }`. +7. `new McpServer({ name, version })` → + `mcpServer.registerTool('validate_code', { description, inputSchema }, + handler)`. +8. `new StdioServerTransport()` → `await mcpServer.connect(transport)`. +9. Install `SIGINT` / `SIGTERM` handlers calling + `shutdown(reason)` which (idempotently) closes the LSP + + MCP server then `process.exit(0)`. + +**Returned handle:** `{ lsp, mcpServer, context, shutdown }` — designed +so embedded consumers can drive the server programmatically without +spawning the bin. + +### 5.2 `src/bin/platformos-mcp-supervisor.ts` + +`#!/usr/bin/env node` CLI. Parses `--project ` / +`--project=` / `--help` / `-h` / `POS_SUPERVISOR_PROJECT_DIR` +(CLI wins). Calls `startServer` and lets the SIGINT/SIGTERM handlers +own the lifetime. Compiles to `dist/bin/platformos-mcp-supervisor.js` +(matches `package.json#bin`); tsc preserves the shebang. + +### 5.3 `src/core/logger.ts` + +`createLogger(prefix?): (msg: string) => void`. Writes one +` [info]: \n` line to **stderr** per +call. stdout is reserved for the MCP JSON-RPC stream — anything written +there bricks the transport. + +### 5.4 `src/core/lsp-client.ts` — `PlatformOSLSPClient` + +In-process LSP client. The trick is the two `PassThrough` streams: the +in-monorepo `@platformos/platformos-language-server-node`'s +`startServer(connection)` expects a node `Connection` (reader/writer); +we wire one end of each PassThrough into the server and the other end +into a `createProtocolConnection`-built client. Typed +`ProtocolNotificationType` constants (e.g. +`PublishDiagnosticsNotification.type`) flow through cleanly because we +use `createProtocolConnection` from +`vscode-languageserver-protocol/node`, not the lower-level +`createMessageConnection` from `vscode-jsonrpc` (the latter rejects +typed protocol constants due to a private-field collision). + +**Public methods:** + +- `initialize(projectDir, { version? })` — handshake; idempotent. + Sends `InitializeParams` with `initializationOptions: { + 'platformosCheck.includeFilesFromDisk': true }` so cross-file checks + (`MissingPartial`, `MissingPage`) are warm during the handshake. +- `awaitDiagnostics(uri, content, timeoutMs)` — open / sync the + document and wait for the next `publishDiagnostics` batch. +- `completions(uri, line, character)` — request completions. +- `hover(uri, line, character)` — request hover (used by `enrichAll` + for `hover_docs`). +- `close()` — dispose transport state (no LSP shutdown handshake — see + the file's comment block for why). + +**Known caveat (documented):** `PlatformOSLiquidDocsManager.setup()` +issues an HTTP fetch that may outlive `close()`. Test code uses +`process.exit()` to short-circuit; the bin's SIGINT/SIGTERM handlers +let Node exit naturally after `close()`. + +### 5.5 `src/core/filters-index.ts` / `objects-index.ts` / `tags-index.ts` + +Each is a small class that loads from a `PlatformOSDocset` (filters / +objects / tags JSON), keyed by name. The destination wraps the docset's +public interface; the indexes are queried by `enrichAll` and +`generateFixes` for suggestions + Shopify contamination detection. + +### 5.6 `src/core/liquid-parser.ts` + +`parseLiquidFile(content): LiquidHtmlNode | null` — tolerant parse via +`toLiquidHtmlAST(content, { mode: 'tolerant', allowUnclosedDocumentNode: +true })`. Returns `null` on hard failure; the LSP lint step will still +surface the syntax error. + +`extractAllFromAST(ast): ExtractedStructural` — walks the AST exactly +once, extracting `slug`, `layout`, `method`, render calls, GraphQL +refs, filters, tags, translation keys, prompts, doc params. Render +calls and GraphQL calls are deduped by name (first call wins; GraphQL +`source_kind` is upgraded to the most pessimistic value seen). + +### 5.7 `src/core/diagnostic-record.ts` + +Two exported helpers — `templateOf(check, message)` (masks identifiers +to produce a stable template string) and `extractParams(check, +message)` (per-check regex extractor with 16 specific extractors +covering `UnknownFilter`, `UndefinedObject`, `UnusedAssign`, +`MissingPartial`, `TranslationKeyExists`, `UnknownProperty`, +`DeprecatedTag`, `MissingRenderPartialArguments`, `MetadataParamsCheck`, +`GraphQLCheck`, `PartialCallArguments`, `GraphQLVariablesCheck`, +`UnusedDocParam`, `ValidFrontmatter`, `JsonLiteralQuoteStyle`, +`DuplicateFunctionArguments`). + +### 5.8 `src/core/rules/` + +**Rule engine** (`engine.ts`): + +- `Rule { id, check, priority, when, apply }` — first-match-wins by + priority within each check. +- `registerRule(rule)` / `registerRules(rules)` — append to the registry. +- `runRules(diag, facts)` — execute the engine. +- `forceDisable(idOrCheck)` / `releaseDisable(idOrCheck)` / + `isCheckForceDisabled(name)` — manual operator surface (the only + override mechanism that survives v1; analytics-driven auto-disable was + dropped). +- `clearRules()` — resets registry AND force-disable set. Critical for + test isolation. + +**Rule modules** (`.ts` × 32): + +Each module exports `export const rules: Rule[]`. The engine receives +the rule's `apply(facts)` return — `RuleResult { rule_id, hint_md, +fixes, confidence, see_also? }`. Rules can match on `check + message` +shape (priority resolution), and `facts.graph` (project fact graph) for +cross-file checks. + +The 32 covered checks: + +`ConvertIncludeToRender`, `DeprecatedTag`, `DuplicateFunctionArguments`, +`GraphQLCheck`, `GraphQLVariablesCheck`, `ImgLazyLoading`, +`ImgWidthAndHeight`, `InvalidLayout`, `JsonLiteralQuoteStyle`, +`LiquidHTMLSyntaxError`, `MetadataParamsCheck`, `MissingAsset`, +`MissingContentForLayout`, `MissingPage`, `MissingPartial`, +`MissingRenderPartialArguments`, `MissingSlug`, `NonGetRenderingPage`, +`OrphanedPartial`, `ParserBlockingScript`, `PartialCallArguments`, +`SchemaProperty`, `SchemaYAML`, `TranslationKeyExists`, +`TranslationMissingLocaleKey`, `UndefinedObject`, `UnknownFilter`, +`UnknownProperty`, `UnrecognizedRenderPartialArguments`, `UnusedAssign`, +`UnusedDocParam`, `ValidFrontmatter`. + +92 distinct rules total (some checks have multiple priority-ordered +rule variants). + +**Helpers** (`queries.ts`, `module-paths.ts`): graph-aware queries +(`nearestByLevenshtein`, `partialNames`, `partialsReachableFrom`, +`dependentsOf`, `translationKeysForLocale`, `stripLocalePrefix`, +`fileExists`, `assetNames`, `classifyPath`, `callerCount`, `isOrphan`, +`hasDocParams`, `classifyFileType`) and module installation lookups +(`moduleInstalled`, `installedModules`, +`moduleCallPathsByCategory`). + +**Registry bootstrap** (`index.ts`): `loadAllRules()` is idempotent; +called by both `startServer` and `validate-code.ts` to guarantee rules +are registered before the first request. + +### 5.9 `src/core/error-enricher.ts` + +`enrichAll(diags, ctx)` — for each diagnostic: + +1. Run the rule engine (first-match-wins). On a match, attach + `rule_id`, `hint_md` (rendered template), `fixes` (rule-supplied), + `confidence`, `see_also`. +2. Fallback regex enrichment per check (`UnknownFilter`, + `UndefinedObject`, `GraphQLCheck`, `TranslationKeyExists`, + `MissingPartial`, `UnknownProperty`, `DeprecatedTag`, + `MissingRenderPartialArguments`, `MetadataParamsCheck`, + `UnusedAssign`) — these produce the `suggestion` field and + docset-aware "did you mean?" hints. +3. Hover-cache pass: dedupe hover requests by `(line, column)` to avoid + thrashing the LSP. + +`bridgeRulesOntoUnattributed(result, ctx)` — re-runs the rule engine +over diagnostics added AFTER `enrichAll` (structural warnings, schema / +translation / GraphQL validators, diff-aware checks). Attaches +`rule_id` / `hint_md` / `confidence` to late additions that would +otherwise stay unattributed. + +### 5.10 `src/core/diagnostic-pipeline.ts` — 15-step ordered post-processing + +**Ordering is load-bearing.** Each step has access to the mutable +`PipelineResult` (errors / warnings / infos) and may suppress or +downgrade entries. The pipeline supports a `_pipelineTrace` field +recording per-step `(errorsRemoved, warningsRemoved, errorsAfter, +warningsAfter)`. + +| # | Step | Purpose | +|---|---|---| +| 0 | `applyUserSuppressions` | Project-level operator suppressions (config-driven) | +| 0a | `suppressLspKnownFalsePositives` | Known upstream LSP false positives (e.g. `assign x = a == b` syntax-not-supported) | +| 1 | `suppressDocParams` | `@param`-declared variables not flagged as undefined | +| 2 | `suppressUnusedDocParams` | `UnusedDocParam` when the param IS used (LSP cache lag) | +| 3 | `elevateShopify` | Promote Shopify contamination warnings to errors | +| 4 | `deduplicateArgChecks` | Collapse duplicate arg-related diagnostics | +| 5 | `suppressUndocumentedTargetParams` | `MetadataParamsCheck` for `modules/` callers (LSP can't see module internals) | +| 6 | `suppressRequiredParamsWithDefault` | `MissingRenderPartialArguments` when the partial has a `@default` | +| 7 | `suppressModuleHelpers` | Known-modules helper false positives | +| 8 | `suppressOrphanedPartial` | `OrphanedPartial` for `commands/` and `queries/` (called via `function`, not `render`) | +| 9 | `verifyMissingAssets` | Cross-check `MissingAsset` against disk; suppress + emit path hint | +| 10 | `verifyTranslationKeysOnDisk` | Cross-check `TranslationKeyExists` against `app/translations/*.yml` | +| 11 | `verifyPageRoutesOnDisk` | Cross-check `MissingPage` against `app/views/pages/`; in-memory overlay for the file under validation | +| 12 | `verifyOrphanedPartialOnDisk` | Cross-check `OrphanedPartial` against all render sites on disk | +| 13 | `verifyMissingPartialsOnDisk` | Cross-check `MissingPartial` (handles `commands/X` ↔ `app/lib/commands/X.liquid`) | +| 14 | `populateDefaultConfidence` | Stamp `confidence` + `rule_id = .unmatched` for late-additions | + +Plus the standalone `suppressUpstreamFrontmatterDup(result)` — +line-anchored deduplication of upstream `ValidFrontmatter` rows that +collide with our richer `pos-supervisor:InvalidLayout` / +`pos-supervisor:InvalidFrontMatter` structural checks. Called by +`validate-code.ts` between sections 2c and 2d. + +### 5.11 `src/core/structural-warnings.ts` — 16 detectors + +`generateStructuralWarnings(ast, content, absPath, structural, +existingChecks, options)` walks the AST per-detector and emits +`pos-supervisor:*` namespaced diagnostics. The 16 check kinds: + +``` +pos-supervisor:HtmlInPage +pos-supervisor:GraphqlInPartial +pos-supervisor:GraphqlMultilineInLiquidBlock +pos-supervisor:MissingReturn +pos-supervisor:MissingContentForLayout +pos-supervisor:MissingDocBlock +pos-supervisor:ShopifyObject +pos-supervisor:ShopifyTag +pos-supervisor:DeprecatedTag +pos-supervisor:InvalidSlug +pos-supervisor:InvalidLayout +pos-supervisor:InvalidMethod +pos-supervisor:NonGetRenderingPage +pos-supervisor:MissingSlug +pos-supervisor:InvalidFrontMatter +pos-supervisor:FilterArgMisuse +``` + +The `existingChecks` set lets the orchestrator deduplicate against the +LSP's own findings (e.g. don't re-emit `DeprecatedTag` for a tag the +LSP already flagged). + +### 5.12 `src/core/fix-generator.ts` + +`generateFixes(diagnostics, ast, content, filePath, ctx, projectDir)` — +heuristic fixes per check (18 per-check fix functions covering +`UndefinedObject`, `UnknownFilter`, `ConvertIncludeToRender`, +`DeprecatedTag`, `MissingPartial`, `MissingRenderPartialArguments`, +`NestedGraphQLQuery`, `TranslationKeyExists`, +`InvalidHashAssignTarget`, `MetadataParamsCheck`, `UnknownProperty`, +`LiquidHTMLSyntaxError`, plus 10 `pos-supervisor:*` structural fix +dispatchers). + +The discriminated `Fix` union: + +```ts +type Fix = TextEditFix | InsertFix | CreateFileFix | GuidanceFix | AddDocParamFix; +``` + +Special behaviour: + +- `add_doc_param` fixes are coalesced via `mergeDocParamFixes` into one + `insert` carrying every `resolves_params`. +- `MissingPartial` `lib/`-prefix forms emit `text_edit` (strip the + prefix) instead of `create_file`. +- `text_edit` fixes get a `context: { before, after, line }` attached + for dashboard display. **`insert` fixes do NOT** (this was the single + parity divergence caught by P24 and reverted to source's narrower + guard). + +`clusterDiagnostics(errors, warnings)` — groups repeated check-name +diagnostics into clusters with a `unified_fix` description. + +`generateScorecard(structural, domain, errors, warnings)` — produces +advisory architecture notes. + +### 5.13 `src/tools/validate-code.ts` — the orchestrator (~1010 LOC) + +Pipeline executed per request, in order: + +``` +1. Parse (Liquid only) +2. Lint — in-process LSP only (no pos-cli subprocess) +2a. Diagnostic post-processing pipeline (the 15 steps) +2b. Schema YAML structural validation (schema files only) +2b1. Translation YAML structural validation (translation files only) +2b2. Schema property cross-check (GraphQL files only) +2c. Structural warnings (pos-supervisor:* intelligence) +2c1. Drop upstream ValidFrontmatter rows that collide on line +2d. Diff-aware comparison (mode: full, file exists on disk) +2e. New partial with @params — check existing callers +3. Domain knowledge — triggered gotchas (mode: full) +4. Generate proposed fixes (mode: full, has diagnostics) +5. Content-triggered proactive tips (mode: full) +5b. Scaffold-preventable error detection +6. Error clustering (mode: full, ≥2 diagnostics) +7. Architecture scorecard (mode: full, Liquid files) +8a. Frontmatter-only page advisory +9. Derive status (single source of truth) +9a. must_fix_before_write boolean +10. next_step prose generation +11. Convert 0-based → 1-based line numbers +12a. Bridge rules onto unattributed diagnostics +12b. Re-stamp default confidence + rule_id +12b. Force-disable filter (drop check-name-disabled diagnostics) +12. Null-hint cleanup +``` + +**BLOCKING_WARNINGS set** — drives the `must_fix_before_write` boolean. +Exactly 6 entries: + +``` +pos-supervisor:AddedParam — new @param breaks existing callers +pos-supervisor:NewPartialParams — new partial declares params callers don't pass +pos-supervisor:RemovedRender — removing render breaks user-visible behavior +pos-supervisor:RemovedGraphQL — removing graphql call drops data fetch +pos-supervisor:RemovedParam — removing @param breaks callers +OrphanedPartial — not reachable; shipping means orphaned file +``` + +**Mode behaviour:** + +- `full`: every section runs. +- `quick`: skips sections 2d, 2e, 3, 4, 5, 5b, 6, 7 (no diff-aware, no + new-partial callers, no domain guide, no fix gen, no content + triggers / scaffold tips, no cluster, no scorecard). + +--- + +## 6. Public surface + +Re-exported from `src/index.ts`: + +```ts +// Server +startServer, ServerOptions, ServerHandle + +// Logger +createLogger, Logger + +// LSP +PlatformOSLSPClient + +// validate_code +validateCodeTool +ValidateCodeContext +ValidateCodeParams +ValidateCodeResult +ValidateCodeMode +ValidateCodeStatus +ValidateCodeDiagnostic +ValidateCodeStructuralSnapshot +ProposedFix +DomainGuide +DomainGuideGotcha +TipEntry +``` + +The bin (`platformos-mcp-supervisor`) is the user-facing surface; +`startServer` is the embedding surface; the typed result is the +agent-developer surface. + +--- + +## 7. Test surface (19 spec files, 276 tests) + +| Suite | Files | Tests | Purpose | +|---|---|---|---| +| Colocated unit (`src/**/*.spec.ts`) | 15 | 218 | Pure-function pins for every core module | +| Integration (`test/integration/`) | 1 | 10 | `validate_code` features through the real stdio bin | +| Upstream contract (`test/upstream/`) | 1 | 23 | LSP message-format pins (`(check, message_template)` pairs) | +| Parity (`test/parity/`) | 1 | 16 | Deep-equal corpus parity vs captured pos-supervisor baselines | +| Helper shape (`test/helpers/server.spec.ts`) | 1 | 6 | Stdio test-client invariants | +| **TOTAL** | **19** | **276** | | + +Test runtime: ~32s end-to-end (LSP boots are ~1s per integration `describe`). + +--- + +## 8. Performance + caches + +- **`project-map.ts`** wraps `scanProject` in a TTL cache + (`PROJECT_MAP_CACHE_TTL_MS = 30s`). `validate_code` calls + `getProjectMap` 3× per request (sections 2d, 2e, 12a); without the + cache that would re-scan the project tree on every call. +- **Hover cache inside `enrichAll`** dedupes hover requests by + `(line, column)` to avoid round-tripping the LSP for every variable + reference on the same line. +- **`loadAllRules` is idempotent** — `startServer` and `validate-code` + both call it; second + N-th calls are no-ops. +- **`knowledge-loader.ts`** lazy-loads `data/` files on first request, + caches the parsed result in module-scoped variables. `_resetKnowledge` + is exposed as a test seam. +- **No filesystem watcher.** Project-map staleness is bounded by the + TTL; agents that need a fresh scan after a write call `validate_code` + on the next iteration. + +--- + +## 9. Configuration + +| Source | Form | Purpose | +|---|---|---| +| `--project ` / `--project=` | CLI | Project root | +| `POS_SUPERVISOR_PROJECT_DIR` | env | Project root (CLI wins) | +| `opts.log` | programmatic | Logger sink (defaults to `createLogger('platformos-mcp-supervisor')`) | +| `--help` / `-h` | CLI | Usage banner to stderr | + +That's the entire configuration surface. There is no config file, +no operator overrides file, no engine-mode toggle. Future surfaces +should land as explicit `ServerOptions` fields, not implicit env vars. + +--- + +## 10. v1 strips (explicit non-features) + +Every drop below was a deliberate scope cut; the migration backlog +(`m-0`) carries per-task `finalSummary` notes recording the rationale. + +| Surface | Why dropped | +|---|---| +| `pos-cli` subprocess fallback | Replaced by in-process LSP — eliminates PATH dependency | +| HTTP transport / `POST /call` | MCP stdio is the only consumer the package targets | +| fs-watcher | TTL cache is sufficient; add back if interactive iteration becomes load-bearing | +| Session event bus / NDJSON event log | Operator debugging via session NDJSON is not v1 scope | +| Blob store (content-hash storage) | Used only by analytics | +| Analytics store (SQLite + WAL) | Not v1 scope; rule engine ships static, calibration deferred | +| CAC predictor (config + rehydration + decisions) | Not v1 scope | +| Engine mode (adaptive vs static) | v1 is static-only; flag the day adaptive returns | +| Rule overrides loading (operator override file) | Manual `forceDisable()` API surface remains | +| Case-base rule scoring | Analytics-driven; not v1 scope | +| Promoted-rules init + watcher | Analytics-driven; not v1 scope | +| Dashboard | No HTTP transport, no operator UI in v1 | +| Project-map cache invalidation listener | TTL-based instead | +| MCP tools other than `validate_code` (9 dropped) | v1 ships `validate_code` only; the rest land as additive PRs | +| `pending_files` / `pending_pages` / `pending_translations` plumbing | Pending-state suppression depended on `validate_intent`; both deferred | +| `fingerprint` / `templateFingerprint` / `messageTemplate` / `makeDiagnosticRecord` | Analytics stamping; not v1 scope | +| `schemaIndex` (from `FixIndexes` + `EnrichContext`) | Zero in-scope consumers (P7 cascade) | + +Mitigation strategies for each are documented in +[`docs/mcp-supervisor/decisions/001-package/README.md`](../../docs/mcp-supervisor/decisions/001-package/README.md). + +--- + +## 11. Where to start reading + +| Goal | Entry point | +|---|---| +| "How do I run it?" | `README.md` | +| "Why does it exist? What's deferred?" | `docs/mcp-supervisor/decisions/001-package/README.md` | +| "What does the orchestrator do per request?" | `src/tools/validate-code.ts` (top-of-file comment + section headers) | +| "How does the in-process LSP work?" | `src/core/lsp-client.ts` (top-of-file comment) | +| "What pipeline steps run and in what order?" | `src/core/diagnostic-pipeline.ts` (top-of-file ORDERING CONTRACT) | +| "What checks does it know about?" | `src/core/rules/` (32 modules) + `src/core/structural-warnings.ts` (16 detectors) | +| "How do I add a fix for a check?" | `src/core/fix-generator.ts` (`dispatchFix` switch) | +| "What does v1's output look like?" | `test/fixtures/parity/*.expected.json` (13 captured baselines) | +| "How do I extend the test surface?" | `test/POS_CLI_AUDIT.md` (migration audit) + `vitest.config.mts` | diff --git a/packages/platformos-mcp-supervisor/README.md b/packages/platformos-mcp-supervisor/README.md new file mode 100644 index 00000000..636eb6b2 --- /dev/null +++ b/packages/platformos-mcp-supervisor/README.md @@ -0,0 +1,194 @@ +# `@platformos/platformos-mcp-supervisor` + +A Model Context Protocol (MCP) server that exposes platformOS code +validation to LLM agents. Wraps `@platformos/platformos-language-server-node` +in-process and adds platformOS-specific structural checks, enrichment, fix +generation, and a project-aware diagnostic pipeline. The server speaks the +MCP stdio transport and registers a single tool: `validate_code`. + +## Install + +```sh +yarn add @platformos/platformos-mcp-supervisor +``` + +The package ships the bin `platformos-mcp-supervisor` (registered via +`package.json#bin`) plus a programmatic entry point at the package root. + +## Usage — MCP stdio + +Point any MCP-aware agent (Claude Code, opencode, VS Code MCP extensions, +custom clients via `@modelcontextprotocol/sdk`) at the bin: + +```sh +platformos-mcp-supervisor +``` + +The bin picks up the project root via this precedence chain: + +1. `--project ` CLI argument +2. `POS_SUPERVISOR_PROJECT_DIR` environment variable +3. `process.cwd()` — the directory the MCP client spawned the bin from + +Most MCP clients launch the server from the workspace root by +construction, so **no configuration is required** for the common case. +Example opencode entry: + +```json +{ + "pos-supervisor": { + "type": "local", + "command": [ + "node", + "/abs/path/to/platformos-mcp-supervisor/dist/bin/platformos-mcp-supervisor.js" + ] + } +} +``` + +Override only when the bin's cwd is unrelated to the project (system +service launches, multiplexed clients): + +```sh +platformos-mcp-supervisor --project /path/to/platformos-project +# or +POS_SUPERVISOR_PROJECT_DIR=/path/to/platformos-project platformos-mcp-supervisor +``` + +`stdout` carries the JSON-RPC stream; `stderr` is reserved for log lines. + +## Usage — programmatic + +```ts +import { startServer } from '@platformos/platformos-mcp-supervisor'; + +const handle = await startServer({ + projectDir: '/path/to/platformos-project', + // log defaults to a stderr logger tagged `platformos-mcp-supervisor`. +}); + +// handle.lsp — PlatformOSLSPClient +// handle.mcpServer — McpServer +// handle.context — ValidateCodeContext (filtersIndex, objectsIndex, …) +// handle.shutdown — async (reason?: string) => void (idempotent) +``` + +The `shutdown` handler is also wired to `SIGINT` / `SIGTERM` so a +foreground process exits cleanly when the agent disconnects. + +### The `validate_code` tool + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const client = new Client({ name: 'my-agent', version: '0.1.0' }); +await client.connect(new StdioClientTransport({ + command: 'platformos-mcp-supervisor', + args: ['--project', '/path/to/project'], +})); + +const result = await client.callTool({ + name: 'validate_code', + arguments: { + file_path: 'app/views/pages/index.html.liquid', + content: '---\nslug: home\n---\n

Hello

', + mode: 'full', // or 'quick' + }, +}); +``` + +The tool result content carries a JSON-stringified `ValidateCodeResult`: + +```ts +interface ValidateCodeResult { + status: 'ok' | 'warning' | 'error'; + must_fix_before_write?: boolean; + errors: ValidateCodeDiagnostic[]; + warnings: ValidateCodeDiagnostic[]; + infos: ValidateCodeDiagnostic[]; + proposed_fixes: ProposedFix[]; + clusters: DiagnosticCluster[]; + scorecard: ScorecardNote[]; + tips: TipEntry[]; + domain_guide: DomainGuide | null; + structural: ValidateCodeStructuralSnapshot | null; + parse_error?: string; + next_step?: string; +} +``` + +The full interface set is exported from the package root. + +## Modes + +- `full` (default): parser → in-process LSP lint → enrichment → diagnostic + pipeline → schema/translation/GraphQL validators → structural warnings → + diff-aware checks → domain knowledge → fix generation → cluster + + scorecard → bridge rules → stamp defaults → force-disable filter. +- `quick`: skips fix generation, clustering, scorecard, domain guide, + diff-aware, and the new-partial caller check. Used for rapid + re-validation after applying fixes. + +## Scope + +### In scope (v1) + +- `validate_code` MCP tool over stdio. +- In-process LSP via `@platformos/platformos-language-server-node`. **No + `pos-cli` subprocess.** +- Structural warnings, schema / translation / GraphQL validators, fix + generator, cluster / scorecard, domain guide, diagnostic pipeline, + rule engine + library. +- Project scanner + fact graph for cross-file checks. + +### Deferred (NOT in v1) + +- Other MCP tools: `validate_intent`, `scaffold`, `project_map`, + `analyze_project`, `lookup`, `domain_guide` (tool), `module_info`, + `enrich_error`, `server_status`, `load_development_guide`. +- HTTP transport, dashboard, fs-watcher, session event bus, blob store, + analytics store, CAC predictor, engine-mode (adaptive vs static), case + base, promoted rules, rule overrides. +- Pending-state suppression (`pending_files` / `pending_pages` / + `pending_translations`). + +See [the migration plan +milestone](../../../.backlog/milestones/m-0%20-%20pos-supervisor-%E2%86%92-platformos-tools-migration-%28v1-va.md) +for the full scope rationale, and the [ADR for the package +decision](../../docs/mcp-supervisor/decisions/001-package/README.md) for +the architectural background. + +## Development + +```sh +yarn build:ts # tsc -b + copy data +yarn type-check # tsc --noEmit +yarn test # vitest (single-fork isolate) +node scripts/smoke-stdio.mjs # real-bin stdio smoke (requires build) +node scripts/record-parity.mjs # re-capture the source baseline (rare) +``` + +Layout: + +``` +src/ + bin/ — CLI entrypoint (compiles to dist/bin/) + core/ — pure logic (parser, enricher, pipeline, rules, …) + data/ — hints + knowledge base (copied verbatim into dist/) + test/ — shared test stubs (in src/ so type-check covers them) + tools/ — validate_code orchestrator + server.ts — startServer + index.ts — public re-exports +test/ + fixtures/ — platformOS project fixtures (project, broken-project, parity) + helpers/ — stdio test client (MCP SDK Client + StdioClientTransport) + integration/, parity/, upstream/ — non-colocated specs +``` + +Specs colocate as `*.spec.ts` next to source under `src/`; integration, +parity, and upstream contract specs live under `test/`. + +## License + +MIT. diff --git a/packages/platformos-mcp-supervisor/package.json b/packages/platformos-mcp-supervisor/package.json new file mode 100644 index 00000000..18773301 --- /dev/null +++ b/packages/platformos-mcp-supervisor/package.json @@ -0,0 +1,58 @@ +{ + "name": "@platformos/platformos-mcp-supervisor", + "version": "0.0.1", + "description": "platformOS Model Context Protocol (MCP) server exposing validate_code for LLM agents.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": "platformOS", + "homepage": "https://github.com/Platform-OS/platformos-tools/tree/master/packages/platformos-mcp-supervisor#readme", + "repository": { + "type": "git", + "url": "https://github.com/Platform-OS/platformos-tools.git", + "directory": "packages/platformos-mcp-supervisor" + }, + "bugs": { + "url": "https://github.com/Platform-OS/platformos-tools/issues" + }, + "license": "MIT", + "publishConfig": { + "access": "public", + "@platformos:registry": "https://registry.npmjs.org" + }, + "bin": { + "platformos-mcp-supervisor": "dist/bin/platformos-mcp-supervisor.js" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts", + "dist/data/**/*" + ], + "scripts": { + "build": "yarn build:ts", + "build:ci": "yarn build", + "build:ts": "tsc -b tsconfig.build.json", + "postbuild:ts": "node scripts/copy-data.mjs", + "dev": "tsc --watch", + "test": "vitest", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@platformos/liquid-html-parser": "0.0.17", + "@platformos/platformos-check-common": "0.0.19", + "@platformos/platformos-check-node": "0.0.19", + "@platformos/platformos-language-server-common": "0.0.19", + "@platformos/platformos-language-server-node": "0.0.19", + "js-yaml": "^4.1.0", + "normalize-path": "^3.0.0", + "vscode-jsonrpc": "^8.2.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.12", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/normalize-path": "^3.0.2" + } +} diff --git a/packages/platformos-mcp-supervisor/scripts/_parity-corpus.mjs b/packages/platformos-mcp-supervisor/scripts/_parity-corpus.mjs new file mode 100644 index 00000000..7af7098a --- /dev/null +++ b/packages/platformos-mcp-supervisor/scripts/_parity-corpus.mjs @@ -0,0 +1,135 @@ +/** + * JS mirror of `test/fixtures/parity/corpus.ts` so the recorder script can + * import it from Node without a TS resolver. Keep these two in sync by + * hand — the TS file is the source of truth for the spec; this JS file is + * the source of truth for the recorder. The contents must be identical. + */ + +const PAGE_WITH_HTML = ['---', 'slug: parity_html', '---', '
', '

hi

', '
'].join('\n'); + +const FRONTMATTER_ONLY = ['---', 'slug: parity_fm_only', '---', ''].join('\n'); + +const PARTIAL_OK = [ + '{% doc %}', + ' @param {string} title', + '{% enddoc %}', + '

{{ title }}

', +].join('\n'); + +const PAGE_OK_RENDERS_EXISTING = [ + '---', + 'slug: parity_renders_existing', + '---', + "{% render 'blog_posts/card', blog_post: context.params %}", +].join('\n'); + +const SCHEMA_BAD = ['name: blog_post', '# missing required `properties` key', ''].join('\n'); + +const TRANSLATION_BAD = ['app:', ' hello: "Hi"'].join('\n'); + +const GRAPHQL_OK = [ + 'query ParityList {', + ' records(per_page: 5, filter: { table: { value: "blog_post" } }) {', + ' results { title: property(name: "title") }', + ' }', + '}', +].join('\n'); + +export const CORPUS = [ + { + id: '01-clean-partial-full', + filePath: 'app/views/partials/parity/clean.liquid', + content: PARTIAL_OK, + mode: 'full', + description: 'Clean partial with @param + body — expect status: ok or advisory warning', + }, + { + id: '02-clean-partial-quick', + filePath: 'app/views/partials/parity/clean.liquid', + content: PARTIAL_OK, + mode: 'quick', + description: 'Same content, quick mode — skip fix gen / scorecard / domain guide', + }, + { + id: '03-page-missing-partial', + filePath: 'app/views/pages/parity/missing.html.liquid', + content: ['---', 'slug: parity_missing', '---', "{% render 'does/not/exist/at/all' %}"].join('\n'), + mode: 'full', + description: 'Page renders a partial that does not exist on disk → MissingPartial error', + }, + { + id: '04-orphaned-partial', + filePath: 'app/views/partials/parity/orphan.liquid', + content: '

I am orphaned.

', + mode: 'full', + description: 'Partial with no callers in the fixture → OrphanedPartial warning', + }, + { + id: '05-graphql-clean', + filePath: 'app/graphql/parity/list.graphql', + content: GRAPHQL_OK, + mode: 'full', + description: 'GraphQL query that references a known schema property — no errors', + }, + { + id: '06-schema-yaml-bad', + filePath: 'app/schema/parity_bad.yml', + content: SCHEMA_BAD, + mode: 'quick', + description: 'Schema YAML missing `properties` → pos-supervisor:SchemaStructure error', + }, + { + id: '07-translation-yaml-bad', + filePath: 'app/translations/en.yml', + content: TRANSLATION_BAD, + mode: 'quick', + description: 'Translation YAML with no locale wrapper → TranslationMissingLocaleKey error', + }, + { + id: '08-shopify-contamination', + filePath: 'app/views/pages/parity/shopify.html.liquid', + content: ['---', 'slug: parity_shopify', '---', '{{ product.title }}'].join('\n'), + mode: 'full', + description: 'Shopify-only `product` object in a page → UndefinedObject + Shopify suggestion', + }, + { + id: '09-html-in-page', + filePath: 'app/views/pages/parity/html.html.liquid', + content: PAGE_WITH_HTML, + mode: 'full', + description: 'Page with inline HTML and no renders → pos-supervisor:HtmlInPage warning', + }, + { + id: '10-frontmatter-only-page', + filePath: 'app/views/pages/parity/empty.html.liquid', + content: FRONTMATTER_ONLY, + mode: 'full', + description: 'Page with frontmatter but no body → pos-supervisor:FrontmatterOnlyPage warning', + }, + { + id: '11-page-renders-existing', + filePath: 'app/views/pages/parity/renders_existing.html.liquid', + content: PAGE_OK_RENDERS_EXISTING, + mode: 'full', + description: 'Page rendering an EXISTING fixture partial — expect no errors', + }, + { + id: '12-unknown-filter', + filePath: 'app/views/partials/parity/unknown_filter.liquid', + content: [ + '{% doc %}', + ' @param {string} name', + '{% enddoc %}', + '{{ name | totally_made_up_filter }}', + ].join('\n'), + mode: 'full', + description: 'Partial pipes through a bogus filter → UnknownFilter error + closest-match suggestion', + }, + { + id: '13-deprecated-include', + filePath: 'app/views/partials/parity/deprecated.liquid', + content: "{% include 'shared/header' %}", + mode: 'full', + description: 'Deprecated {% include %} tag → DeprecatedTag warning + enricher hint', + }, +]; diff --git a/packages/platformos-mcp-supervisor/scripts/copy-data.mjs b/packages/platformos-mcp-supervisor/scripts/copy-data.mjs new file mode 100644 index 00000000..e498b134 --- /dev/null +++ b/packages/platformos-mcp-supervisor/scripts/copy-data.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Copies `src/data/` into `dist/data/` after `tsc -b` finishes. + * + * The TypeScript compiler emits .js/.d.ts only — non-source assets must be + * copied manually so runtime loaders (`hint-loader`, `knowledge-loader`) + * resolve `join(__dirname, '..', 'data')` to a real directory when the + * package runs from `dist/`. + * + * Same path resolution also works in dev (TS sources): `__dirname` is + * `src/core/`, `..` is `src/`, `data/` is `src/data/`. No fallback needed. + */ +import { cpSync, existsSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = join(here, '..'); +const src = join(pkgRoot, 'src', 'data'); +const dst = join(pkgRoot, 'dist', 'data'); + +if (!existsSync(src)) { + console.error(`copy-data: source not found at ${relative(pkgRoot, src)}`); + process.exit(1); +} + +cpSync(src, dst, { recursive: true }); diff --git a/packages/platformos-mcp-supervisor/scripts/record-parity.mjs b/packages/platformos-mcp-supervisor/scripts/record-parity.mjs new file mode 100644 index 00000000..678c5b98 --- /dev/null +++ b/packages/platformos-mcp-supervisor/scripts/record-parity.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * Capture pos-supervisor's `validate_code` output for every parity-corpus + * entry (P24 acceptance gate) and save a normalised snapshot per entry as + * `test/fixtures/parity/.expected.json`. + * + * The recorder boots the SOURCE supervisor (`/bin/pos-supervisor.js`) + * under Bun against a copy of the migrated fixture project (so the + * pos-supervisor analytics/session dirs land in a tmp scratch dir, not the + * package), waits for both HTTP + LSP to be ready, runs the corpus, and + * normalises each response with the same function the parity spec uses on + * the v1 side. Run this ONCE manually (when the source contract changes); + * the spec just reads the captured JSON. + * + * Usage: + * yarn workspace @platformos/platformos-mcp-supervisor build:ts # ensure dist exists for cleanup hooks + * node packages/platformos-mcp-supervisor/scripts/record-parity.mjs + * + * Requires: + * - `bun` on PATH (pos-supervisor's runtime) + * - `pos-cli` on PATH (source's LSP) + * - source repo at `/../../../../` — i.e. the migration + * happens inside the source tree, so `../../../..` reaches the source + * repo root from the package directory. + */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, cpSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(__dirname, '..'); +// `` → `..` (packages/) → `../..` (platformos-tools/) → `../../..` (pos-mcp/). +const SOURCE_REPO_ROOT = resolve(PACKAGE_ROOT, '..', '..', '..'); +const SOURCE_BIN = join(SOURCE_REPO_ROOT, 'bin', 'pos-supervisor.js'); +const FIXTURE_SRC = join(PACKAGE_ROOT, 'test', 'fixtures', 'project'); +const SNAPSHOT_DIR = join(PACKAGE_ROOT, 'test', 'fixtures', 'parity'); + +if (!existsSync(SOURCE_BIN)) { + console.error(`record-parity: source bin not found at ${SOURCE_BIN}`); + process.exit(2); +} + +// ── Load the corpus via dynamic import (TS via Bun's resolver). ────────── +// +// `corpus.ts` is plain TS — we feed it to Node via a child process running +// `bun` so we don't need to set up TS resolution in this script. Simpler: +// the corpus is small + stable, mirror it as a JS literal at the bottom +// of this file instead of importing TS at runtime. +import { CORPUS } from './_parity-corpus.mjs'; + +if (!Array.isArray(CORPUS) || CORPUS.length === 0) { + console.error('record-parity: corpus is empty'); + process.exit(2); +} + +console.error(`record-parity: ${CORPUS.length} entries to capture`); + +// ── Build a writable copy of the fixture so the source's analytics dirs +// don't poison the read-only test/fixtures/project tree. ───────────── +const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-parity-record-')); +const projectDir = join(tmpDir, 'project'); +cpSync(FIXTURE_SRC, projectDir, { recursive: true }); +console.error(`record-parity: source project copy at ${projectDir}`); + +// Random port to avoid colliding with another running supervisor. +const port = 14000 + Math.floor(Math.random() * 500); + +const proc = spawn('bun', [SOURCE_BIN], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + POS_SUPERVISOR_HTTP_PORT: String(port), + POS_SUPERVISOR_PROJECT_DIR: projectDir, + }, +}); + +let stderrBuf = ''; +proc.stderr.on('data', (chunk) => { + const text = chunk.toString(); + stderrBuf += text; + for (const line of text.split('\n')) { + if (line) process.stderr.write(` ↳ [source] ${line}\n`); + } +}); + +// MCP handshake so the stdio transport doesn't bail. +proc.stdin.write( + `${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'recorder', version: '1.0' } }, + })}\n`, +); +proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`); + +// Wait for HTTP + LSP terminal state. +const lspTerminalRe = + /(LSP ready|LSP init failed|LSP warm-up failed|pos-cli not found — static tools only|Neither pos-cli nor Node\.js found|pos-cli at .* but no Node\.js interpreter found)/; + +await new Promise((resolveBoot, rejectBoot) => { + let httpUp = false; + let lspSettled = false; + function maybe() { + if (httpUp && lspSettled) resolveBoot(); + } + proc.stderr.on('data', () => { + if (!httpUp && stderrBuf.includes('HTTP server listening')) { + httpUp = true; + maybe(); + } + if (!lspSettled && lspTerminalRe.test(stderrBuf)) { + lspSettled = true; + maybe(); + } + }); + setTimeout(() => rejectBoot(new Error(`source server boot timeout (httpUp=${httpUp}, lspSettled=${lspSettled})`)), 90_000); +}); + +console.error('record-parity: source server ready'); + +// ── Capture loop ─────────────────────────────────────────────────────────── + +mkdirSync(SNAPSHOT_DIR, { recursive: true }); + +let failures = 0; +for (const entry of CORPUS) { + console.error(`record-parity: ${entry.id}…`); + try { + const res = await fetch(`http://localhost:${port}/call`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool: 'validate_code', + params: { file_path: entry.filePath, content: entry.content, mode: entry.mode }, + }), + }); + const body = await res.json(); + if (!res.ok || !body.result) { + throw new Error(`HTTP ${res.status}: ${body.error ?? '(no body)'}`); + } + const normalised = normaliseResult(body.result); + const out = { + id: entry.id, + filePath: entry.filePath, + mode: entry.mode, + description: entry.description, + result: normalised, + }; + writeFileSync( + join(SNAPSHOT_DIR, `${entry.id}.expected.json`), + `${JSON.stringify(out, null, 2)}\n`, + 'utf8', + ); + } catch (e) { + console.error(` ✗ ${entry.id}: ${e instanceof Error ? e.message : String(e)}`); + failures++; + } +} + +// ── Shutdown source ──────────────────────────────────────────────────────── + +try { + proc.stdin.end(); + proc.kill('SIGTERM'); + await new Promise((r) => setTimeout(r, 200)); +} catch { + /* already dead */ +} + +rmSync(tmpDir, { recursive: true, force: true }); + +if (failures > 0) { + console.error(`record-parity: FAILED (${failures} entries did not record)`); + process.exit(1); +} + +console.error(`record-parity: PASS (${CORPUS.length} snapshots → ${SNAPSHOT_DIR})`); + +// ── Normalisation (shared with the parity spec) ──────────────────────────── + +function normaliseResult(result) { + const stripDiag = (d) => { + const out = {}; + // Drop analytics / pending / CAC / debug-only fields. + const DROP = new Set([ + 'fingerprint', + 'template_fp', + 'fp', + 'content_hash', + 'hint_md_hash', + 'hint_rule_id', + 'params', + '_filePath', + '_origIdx', + '_origType', + '_source', + '_pipelineTrace', + ]); + for (const [k, v] of Object.entries(d)) { + if (DROP.has(k) || k.startsWith('_')) continue; + out[k] = v; + } + if (typeof out.confidence === 'number') out.confidence = Number(out.confidence.toFixed(3)); + if (Array.isArray(out.fixes)) out.fixes = out.fixes.map(stripFix); + if (out.fix) out.fix = stripFix(out.fix); + return out; + }; + const stripFix = (f) => { + const out = {}; + for (const [k, v] of Object.entries(f)) { + if (k === '_source' || k.startsWith('_')) continue; + out[k] = v; + } + return out; + }; + // Keep this set in lockstep with `CROSS_PLATFORM_DIVERGENT_LSP_CHECKS` + // in `test/parity/validate-code-parity.spec.ts`. Both sides must drop + // the same upstream-LSP cross-file checks; otherwise re-recorded + // snapshots would carry findings the spec strips at compare time and + // every parity entry would diverge. + const CROSS_PLATFORM_DIVERGENT = new Set(['MatchingTranslations']); + const sortDiags = (arr) => + [...(arr ?? [])] + .filter((d) => typeof d?.check !== 'string' || !CROSS_PLATFORM_DIVERGENT.has(d.check)) + .map(stripDiag) + .sort((a, b) => { + const k = (a.check ?? '').localeCompare(b.check ?? ''); + if (k) return k; + const l = (a.line ?? -1) - (b.line ?? -1); + if (l) return l; + const c = (a.column ?? -1) - (b.column ?? -1); + if (c) return c; + return (a.message ?? '').localeCompare(b.message ?? ''); + }); + + const out = { + status: result.status ?? null, + must_fix_before_write: result.must_fix_before_write ?? null, + errors: sortDiags(result.errors), + warnings: sortDiags(result.warnings), + infos: sortDiags(result.infos), + proposed_fixes: (result.proposed_fixes ?? []).map(stripFix), + clusters: result.clusters ?? [], + scorecard: result.scorecard ?? [], + tips: result.tips ?? [], + domain_guide: result.domain_guide ?? null, + structural: result.structural ?? null, + parse_error: result.parse_error ?? null, + next_step: result.next_step ?? null, + }; + return out; +} diff --git a/packages/platformos-mcp-supervisor/scripts/smoke-stdio.mjs b/packages/platformos-mcp-supervisor/scripts/smoke-stdio.mjs new file mode 100644 index 00000000..6310d805 --- /dev/null +++ b/packages/platformos-mcp-supervisor/scripts/smoke-stdio.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Stand-alone smoke verification for the MCP stdio bin. + * + * Boots `dist/bin/platformos-mcp-supervisor.js` against a fresh tmp + * project directory, then drives it with the official MCP SDK client: + * + * 1. `initialize` (handshake) + * 2. `tools/list` — expect exactly 1 tool: validate_code + * 3. `tools/call validate_code` — expect a JSON body with + * `errors`, `warnings`, `infos`, `must_fix_before_write` keys. + * + * The script exits with code 0 on success and a non-zero code with a + * diagnostic on failure. It is intentionally NOT a vitest spec — running + * a real child process from inside vitest's worker pool flakes on busy + * machines, and P24 owns the full integration coverage. + * + * Usage: + * yarn workspace @platformos/platformos-mcp-supervisor build:ts + * node packages/platformos-mcp-supervisor/scripts/smoke-stdio.mjs + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BIN_PATH = resolve(__dirname, '..', 'dist', 'bin', 'platformos-mcp-supervisor.js'); + +if (!existsSync(BIN_PATH)) { + console.error(`smoke: bin not built — run \`yarn build:ts\` first. Expected: ${BIN_PATH}`); + process.exit(2); +} + +const projectDir = mkdtempSync(join(tmpdir(), 'mcp-supervisor-smoke-')); +console.error(`smoke: using project dir ${projectDir}`); + +let exitCode = 0; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [BIN_PATH, '--project', projectDir], +}); +const client = new Client({ name: 'smoke-client', version: '0.0.0' }); + +try { + await client.connect(transport); + console.error('smoke: connected ✓'); + + // AC #3 — exactly one tool, named validate_code. + const listed = await client.listTools(); + if (!Array.isArray(listed?.tools) || listed.tools.length !== 1) { + throw new Error(`tools/list returned ${listed?.tools?.length ?? '?'} tools (expected 1)`); + } + if (listed.tools[0].name !== 'validate_code') { + throw new Error(`tools/list[0].name = "${listed.tools[0].name}" (expected "validate_code")`); + } + console.error('smoke: tools/list → [validate_code] ✓'); + + // AC #4 — tools/call validate_code returns the documented response shape. + const called = await client.callTool({ + name: 'validate_code', + arguments: { + file_path: 'app/views/partials/example.liquid', + content: '{% doc %}\n Renders a static greeting.\n{% enddoc %}\nhello, world\n', + mode: 'quick', + }, + }); + const body = JSON.parse(called.content?.[0]?.text ?? '{}'); + const required = ['errors', 'warnings', 'infos', 'must_fix_before_write']; + const missing = required.filter((k) => !Object.prototype.hasOwnProperty.call(body, k)); + if (missing.length > 0) { + throw new Error(`tools/call validate_code missing keys: ${missing.join(', ')}`); + } + console.error('smoke: tools/call validate_code → keys present ✓'); + console.error('smoke: PASS'); +} catch (e) { + console.error(`smoke: FAIL — ${e instanceof Error ? e.message : String(e)}`); + exitCode = 1; +} finally { + try { + await client.close(); + } catch { + /* best-effort */ + } + rmSync(projectDir, { recursive: true, force: true }); +} + +process.exit(exitCode); diff --git a/packages/platformos-mcp-supervisor/src/bin/platformos-mcp-supervisor.spec.ts b/packages/platformos-mcp-supervisor/src/bin/platformos-mcp-supervisor.spec.ts new file mode 100644 index 00000000..0f4065e8 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/bin/platformos-mcp-supervisor.spec.ts @@ -0,0 +1,105 @@ +/** + * Unit pins for the CLI entrypoint's argv parsing + project-directory + * precedence chain. These are pure functions extracted from `main()` so + * the contract MCP clients depend on (cwd fallback, env override, CLI + * win) is mechanically verified — every prior test exercised the bin via + * `--project`, so a regression in the fallback chain would have shipped + * silently. + */ + +import { describe, it, expect } from 'vitest'; +import { parseArgs, resolveProjectDir } from './platformos-mcp-supervisor'; + +// ── parseArgs ────────────────────────────────────────────────────────────── + +describe('parseArgs', () => { + it('returns empty result for no args', () => { + expect(parseArgs([])).toEqual({ projectDir: undefined, help: false }); + }); + + it('parses `--project ` (space-separated)', () => { + expect(parseArgs(['--project', '/abs/path'])).toEqual({ + projectDir: '/abs/path', + help: false, + }); + }); + + it('parses `--project=` (equals form)', () => { + expect(parseArgs(['--project=/abs/path'])).toEqual({ + projectDir: '/abs/path', + help: false, + }); + }); + + it('parses `--help` / `-h`', () => { + expect(parseArgs(['--help']).help).toBe(true); + expect(parseArgs(['-h']).help).toBe(true); + }); + + it('tolerates unknown flags (forward-compat)', () => { + expect(parseArgs(['--unknown', 'value', '--project=/p', '--also-unknown'])).toEqual({ + projectDir: '/p', + help: false, + }); + }); + + it('last `--project` wins when given twice', () => { + expect(parseArgs(['--project', '/first', '--project=/second']).projectDir).toBe('/second'); + }); +}); + +// ── resolveProjectDir (the precedence chain MCP clients depend on) ───────── + +describe('resolveProjectDir', () => { + const noEnv: Record = {}; + const envWithDir: Record = { + POS_SUPERVISOR_PROJECT_DIR: '/from/env', + }; + + it('returns CLI arg when present (wins over env + cwd)', () => { + expect( + resolveProjectDir({ projectDir: '/from/cli', help: false }, envWithDir, () => '/from/cwd'), + ).toBe('/from/cli'); + }); + + it('returns env var when CLI arg is absent', () => { + expect( + resolveProjectDir({ projectDir: undefined, help: false }, envWithDir, () => '/from/cwd'), + ).toBe('/from/env'); + }); + + it('returns cwd when neither CLI nor env is set (the MCP-client default path)', () => { + expect( + resolveProjectDir({ projectDir: undefined, help: false }, noEnv, () => '/from/cwd'), + ).toBe('/from/cwd'); + }); + + it('treats empty-string CLI arg as absent (falls through to env / cwd)', () => { + // Defensive: a poorly-quoted shell expansion could pass `''`. The + // resolver must NOT pick it as the project dir — it would break + // every downstream `path.resolve` call. + expect(resolveProjectDir({ projectDir: '', help: false }, envWithDir, () => '/from/cwd')).toBe( + '/from/env', + ); + }); + + it('treats empty-string env var as absent', () => { + expect( + resolveProjectDir( + { projectDir: undefined, help: false }, + { POS_SUPERVISOR_PROJECT_DIR: '' }, + () => '/from/cwd', + ), + ).toBe('/from/cwd'); + }); + + it('treats missing env entry as absent', () => { + expect( + resolveProjectDir( + { projectDir: undefined, help: false }, + { POS_SUPERVISOR_PROJECT_DIR: undefined }, + () => '/from/cwd', + ), + ).toBe('/from/cwd'); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/bin/platformos-mcp-supervisor.ts b/packages/platformos-mcp-supervisor/src/bin/platformos-mcp-supervisor.ts new file mode 100644 index 00000000..5de70667 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/bin/platformos-mcp-supervisor.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * CLI entrypoint for the platformOS MCP supervisor. + * + * Resolves the project directory by precedence: + * + * 1. `--project ` / `--project=` CLI argument (wins). + * 2. `POS_SUPERVISOR_PROJECT_DIR` environment variable. + * 3. `process.cwd()` — the directory the MCP client (Claude Code, + * opencode, VS Code MCP host, etc.) spawned the bin from. + * + * The cwd default matches source pos-supervisor's behaviour and removes + * boilerplate from typical MCP-client configurations: clients launch the + * server from the project they want validated, so the implicit project + * dir is correct by construction. Override only when the bin's cwd is + * not the project root (e.g. system-service launch, multiplexed clients). + * + * Boots `startServer` and lets Node keep the process alive — + * `mcpServer.connect()` registers the stdio transport but does not block, + * so the SIGINT / SIGTERM handlers installed inside `startServer` own the + * lifetime. + * + * NB: source path lives at `src/bin/` because tsconfig's `rootDir` is + * `src/`. The build emits `dist/bin/platformos-mcp-supervisor.js`, which is + * what `package.json#bin` references. The shebang above is preserved by + * tsc so the dist file is directly executable. + */ + +import { createLogger } from '../core/logger'; +import { startServer } from '../server'; + +export interface ParsedArgs { + projectDir?: string; + help: boolean; +} + +/** + * Parse the `--project ` / `--project=` / `--help` / `-h` flags + * from a `process.argv.slice(2)` style array. Unknown flags are tolerated + * (ignored) so a forward-compatible client can pass extras without + * breaking the bin. Exported as a test seam. + */ +export function parseArgs(argv: ReadonlyArray): ParsedArgs { + let projectDir: string | undefined; + let help = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { + help = true; + } else if (arg === '--project') { + projectDir = argv[i + 1]; + i++; + } else if (arg.startsWith('--project=')) { + projectDir = arg.slice('--project='.length); + } + } + return { projectDir, help }; +} + +/** + * Resolve the project directory by the documented precedence chain: + * + * 1. `--project ` / `--project=` CLI argument (wins). + * 2. `POS_SUPERVISOR_PROJECT_DIR` environment variable. + * 3. `process.cwd()` — the directory the MCP client spawned the bin from. + * + * Pure function: takes the parsed-args object, an env map, and a cwd + * provider so unit tests can pin every branch without touching `process`. + * + * The cwd fallback is correct-by-construction for MCP clients (Claude + * Code, opencode, VS Code MCP host) that spawn the server from the + * project they want validated. The CLI flag / env var override is + * reserved for system-service launches where the bin's cwd is unrelated + * to the project. + */ +export function resolveProjectDir( + parsed: ParsedArgs, + env: Readonly>, + cwd: () => string, +): string { + if (parsed.projectDir && parsed.projectDir.length > 0) return parsed.projectDir; + const envDir = env.POS_SUPERVISOR_PROJECT_DIR; + if (envDir && envDir.length > 0) return envDir; + return cwd(); +} + +function printHelp(): void { + process.stderr.write( + [ + 'Usage: platformos-mcp-supervisor [--project ]', + '', + 'Options:', + ' --project Path to the platformOS project root.', + ' Defaults to POS_SUPERVISOR_PROJECT_DIR, then process.cwd().', + ' -h, --help Show this help message.', + '', + 'Environment:', + ' POS_SUPERVISOR_PROJECT_DIR Used when --project is omitted.', + '', + 'The server speaks MCP over stdio. stderr is reserved for logs;', + 'stdout carries the JSON-RPC stream.', + '', + ].join('\n'), + ); +} + +async function main(): Promise { + const log = createLogger('platformos-mcp-supervisor'); + const parsed = parseArgs(process.argv.slice(2)); + + if (parsed.help) { + printHelp(); + process.exit(0); + } + + const projectDir = resolveProjectDir(parsed, process.env, () => process.cwd()); + + try { + await startServer({ projectDir, log }); + } catch (e) { + log(`server failed to start: ${(e as Error).message}`); + process.exit(1); + } +} + +// Only auto-invoke when this file IS the Node entry point. Spec files +// that import `parseArgs` / `resolveProjectDir` for unit testing pick up +// the module without booting a real LSP + stdio transport. The package +// emits CommonJS, so `require.main === module` is the canonical guard. +if (require.main === module) { + void main(); +} diff --git a/packages/platformos-mcp-supervisor/src/core/asset-index.spec.ts b/packages/platformos-mcp-supervisor/src/core/asset-index.spec.ts new file mode 100644 index 00000000..51d30ef1 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/asset-index.spec.ts @@ -0,0 +1,138 @@ +/** + * Regression pins for the asset-index the diagnostic pipeline uses to + * cross-check `MissingAsset` against the real filesystem. + * + * Prior bug: agents saw `MissingAsset` for paths that existed on disk (LSP's + * asset cache lagging) AND for paths where the basename existed but the + * directory prefix was wrong (`{{ 'logo.png' | asset_url }}` when the file + * lives at `images/logo.png`). The index + resolver together: + * - confirm existence at the exact nested path (suppresses stale diagnostics) + * - detect a same-basename file under a different nested path (enables a + * concrete "use this path instead" hint) + * - stay silent when nothing matches so real missing assets still surface + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + buildAssetIndex, + normalizeAssetPath, + resolveAssetPath, + type AssetIndex, +} from './asset-index'; + +describe('asset-index', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'asset-index-')); + const assetsRoot = join(tmpDir, 'app/assets'); + mkdirSync(join(assetsRoot, 'styles'), { recursive: true }); + mkdirSync(join(assetsRoot, 'scripts'), { recursive: true }); + mkdirSync(join(assetsRoot, 'images/icons'), { recursive: true }); + mkdirSync(join(assetsRoot, 'vendor'), { recursive: true }); + + writeFileSync(join(assetsRoot, 'styles/app.css'), '/* app */', 'utf8'); + writeFileSync(join(assetsRoot, 'styles/design-tokens.css'), ':root {}', 'utf8'); + writeFileSync(join(assetsRoot, 'scripts/app.js'), '// app', 'utf8'); + writeFileSync(join(assetsRoot, 'images/logo.png'), 'PNG', 'utf8'); + writeFileSync(join(assetsRoot, 'images/icons/check.svg'), '', 'utf8'); + // Same basename as images/logo.png — deliberate to exercise ambiguity. + writeFileSync(join(assetsRoot, 'vendor/logo.png'), 'PNG', 'utf8'); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('buildAssetIndex', () => { + it('indexes every file recursively under app/assets/ with forward-slash paths', () => { + const idx = buildAssetIndex(tmpDir); + expect(idx.paths.has('styles/app.css')).toBe(true); + expect(idx.paths.has('styles/design-tokens.css')).toBe(true); + expect(idx.paths.has('scripts/app.js')).toBe(true); + expect(idx.paths.has('images/logo.png')).toBe(true); + expect(idx.paths.has('images/icons/check.svg')).toBe(true); + expect(idx.paths.has('vendor/logo.png')).toBe(true); + }); + + it('groups files by basename so same-name files at different paths can be disambiguated', () => { + const idx = buildAssetIndex(tmpDir); + expect(idx.basenames.get('logo.png')).toEqual( + expect.arrayContaining(['images/logo.png', 'vendor/logo.png']), + ); + expect(idx.basenames.get('app.css')).toEqual(['styles/app.css']); + }); + + it('returns an empty index when projectDir is missing or has no app/assets/', () => { + // Exercise the documented null-handling branch via a cast; the public + // signature is `string`, the implementation defensively short-circuits. + const empty = buildAssetIndex(null as unknown as string); + expect(empty.paths.size).toBe(0); + const empty2 = buildAssetIndex('/nonexistent/path-that-really-does-not-exist'); + expect(empty2.paths.size).toBe(0); + }); + }); + + describe('normalizeAssetPath', () => { + it('strips leading slashes and the assets/ or app/assets/ prefix', () => { + expect(normalizeAssetPath('/styles/app.css')).toBe('styles/app.css'); + expect(normalizeAssetPath('assets/styles/app.css')).toBe('styles/app.css'); + expect(normalizeAssetPath('/assets/styles/app.css')).toBe('styles/app.css'); + expect(normalizeAssetPath('app/assets/styles/app.css')).toBe('styles/app.css'); + }); + + it('leaves already-normalised paths untouched', () => { + expect(normalizeAssetPath('styles/app.css')).toBe('styles/app.css'); + }); + + it('returns null for empty / non-string input', () => { + expect(normalizeAssetPath('')).toBe(null); + expect(normalizeAssetPath(null)).toBe(null); + expect(normalizeAssetPath(undefined)).toBe(null); + }); + }); + + describe('resolveAssetPath', () => { + let index: AssetIndex; + beforeAll(() => { + index = buildAssetIndex(tmpDir); + }); + + it('returns exists when the exact nested path is on disk', () => { + expect(resolveAssetPath('styles/app.css', index)).toEqual({ status: 'exists' }); + expect(resolveAssetPath('images/icons/check.svg', index)).toEqual({ status: 'exists' }); + }); + + it('normalises agent-submitted forms before looking up', () => { + expect(resolveAssetPath('/styles/app.css', index)).toEqual({ status: 'exists' }); + expect(resolveAssetPath('assets/styles/app.css', index)).toEqual({ status: 'exists' }); + expect(resolveAssetPath('app/assets/styles/app.css', index)).toEqual({ status: 'exists' }); + }); + + it('reports renamed when basename is unique and path prefix is wrong', () => { + // "design-tokens.css" exists only at styles/ — the agent forgot the subdir + expect(resolveAssetPath('design-tokens.css', index)).toEqual({ + status: 'renamed', + suggestion: 'styles/design-tokens.css', + }); + }); + + it('reports ambiguous when basename matches multiple nested paths', () => { + // "logo.png" exists at both images/ and vendor/ + const r = resolveAssetPath('logo.png', index); + expect(r.status).toBe('ambiguous'); + if (r.status === 'ambiguous') { + expect(r.suggestions).toEqual( + expect.arrayContaining(['images/logo.png', 'vendor/logo.png']), + ); + } + }); + + it('reports missing when nothing matches — real MissingAsset', () => { + expect(resolveAssetPath('styles/does-not-exist.css', index)).toEqual({ status: 'missing' }); + expect(resolveAssetPath('nonexistent.js', index)).toEqual({ status: 'missing' }); + }); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/asset-index.ts b/packages/platformos-mcp-supervisor/src/core/asset-index.ts new file mode 100644 index 00000000..731899a5 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/asset-index.ts @@ -0,0 +1,154 @@ +/** + * Asset index — a snapshot of every file under `app/assets/`, keyed by both + * full relative path and by basename. + * + * Purpose: the LSP's `MissingAsset` check has a chronic false-positive rate + * for two reasons that both reduce to "the LSP's asset picture disagrees + * with the real filesystem": + * + * 1. Persistence of absence. The LSP may report an asset missing right + * after the file is written — its internal asset cache doesn't pick up + * new files on disk until a re-index. Agents see `MissingAsset` for a + * path they can literally `read()` and stop trusting linter output. + * 2. Path-prefix ambiguity. `asset_url` takes a path relative to + * `app/assets/` and the directory layout (`styles/`, `scripts/`, + * `images/`) must be part of that path. Agents often write + * `{{ 'logo.png' | asset_url }}` expecting a flat root when the file + * actually lives at `app/assets/images/logo.png`. The LSP reports + * `MissingAsset` but gives no hint about where the file actually is. + * + * This module lets the diagnostic pipeline (a) verify `MissingAsset` against + * the real filesystem and suppress verified false positives, and (b) when a + * path truly is wrong, look up the real nested path by basename and give + * the agent a concrete "use this path instead" suggestion. + * + * The walker is deliberately scoped to `app/assets/` so we don't pay to scan + * the whole project. On a large tree this is a few ms of sync I/O per + * `validate_code` call; cheaper and more correct than trying to cache across + * calls — files get added/removed during a session. + */ + +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import normalize from 'normalize-path'; + +const ASSETS_SUBDIR = 'app/assets'; + +export interface AssetIndex { + /** Every file's relative path from the assets root, forward-slashed. */ + paths: Set; + /** Map for "did you mean?" lookups. */ + basenames: Map; +} + +export type AssetResolution = + | { status: 'exists' } + | { status: 'renamed'; suggestion: string } + | { status: 'ambiguous'; suggestions: string[] } + | { status: 'missing' }; + +/** + * Walk `projectDir/app/assets/` recursively. + * + * Missing or unreadable directories yield an empty index — callers must + * treat an empty index as "no suppression possible, fall through to LSP". + */ +export function buildAssetIndex(projectDir: string): AssetIndex { + const paths = new Set(); + const basenames = new Map(); + + if (!projectDir) return { paths, basenames }; + const rootAbs = join(projectDir, ASSETS_SUBDIR); + if (!existsSync(rootAbs)) return { paths, basenames }; + + const stack: string[] = [rootAbs]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(abs); + continue; + } + // Symlinks and anything else: skip — deploy syncs real files only. + if (!entry.isFile()) { + let stat; + try { + stat = statSync(abs); + } catch { + continue; + } + if (!stat.isFile()) continue; + } + + const rel = normalize(relative(rootAbs, abs)); + paths.add(rel); + + const bn = entry.name; + const existing = basenames.get(bn); + if (existing) existing.push(rel); + else basenames.set(bn, [rel]); + } + } + + return { paths, basenames }; +} + +/** + * Normalise whatever the LSP reported into a path relative to `app/assets/`. + * + * The LSP quotes the literal string the template used, so agents commonly + * submit absolute-looking forms too: + * - `"styles/app.css"` — already correct + * - `"/styles/app.css"` — leading slash + * - `"assets/styles/app.css"` — prefixed with the directory + * - `"/assets/styles/app.css"` — both + * - `"app/assets/styles/..."` — full repo-relative (rare, but seen) + * + * Each form is stripped so downstream can compare against the index set. + */ +export function normalizeAssetPath(raw: string | null | undefined): string | null { + if (typeof raw !== 'string') return null; + let p = raw.trim(); + if (p.length === 0) return null; + while (p.startsWith('/')) p = p.slice(1); + if (p.startsWith('app/assets/')) p = p.slice('app/assets/'.length); + else if (p.startsWith('assets/')) p = p.slice('assets/'.length); + return p; +} + +/** + * Look up a reported asset path against the index. + * + * - `exists` — file is real; suppress diagnostic. + * - `renamed` — file exists under a different nested path (typical + * prefix-ambiguity case); suggest the nested path. + * - `ambiguous` — basename matches multiple files; don't suppress, but + * surface the candidates. + * - `missing` — no match; diagnostic stands. + */ +export function resolveAssetPath( + rawPath: string | null | undefined, + index: AssetIndex, +): AssetResolution { + const p = normalizeAssetPath(rawPath); + if (!p) return { status: 'missing' }; + if (index.paths.has(p)) return { status: 'exists' }; + + const bn = p.split('/').pop() ?? ''; + const matches = index.basenames.get(bn) ?? []; + if (matches.length === 1) { + return { status: 'renamed', suggestion: matches[0] }; + } + if (matches.length > 1) { + return { status: 'ambiguous', suggestions: matches.slice(0, 5) }; + } + return { status: 'missing' }; +} diff --git a/packages/platformos-mcp-supervisor/src/core/constants.ts b/packages/platformos-mcp-supervisor/src/core/constants.ts new file mode 100644 index 00000000..eeec82bf --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/constants.ts @@ -0,0 +1,65 @@ +/** + * Named constants — single source of truth for magic numbers used across modules. + * + * Only includes values that affect tool behaviour (timeouts, thresholds, + * confidence priors). Presentation limits (slice sizes for previews) stay local + * to their call sites. + * + * Dropped relative to pos-supervisor 0.8.x because their sole consumers are + * out of v1 scope: CHECK_TIMEOUT_MS / CHECK_MAX_BUFFER (pos-cli check + * subprocess), HTTP_MAX_BODY (HTTP transport), CONSECUTIVE_ERROR_THRESHOLD + * (session loop detection). + */ + +// ── Timeouts ──────────────────────────────────────────────────────────────── + +/** How long to wait for LSP to be ready (initialization + warm-up). */ +export const LSP_READY_TIMEOUT_MS = 30_000; + +/** How long to wait for per-document LSP diagnostics. */ +export const LSP_DIAGNOSTICS_TIMEOUT_MS = 5_000; + +/** Cap on the barrier wait within awaitDiagnostics. */ +export const LSP_BARRIER_TIMEOUT_MS = 3_000; + +/** Time to wait for diagnostics to settle before resolving. */ +export const DIAGNOSTICS_SETTLE_MS = 500; + +/** TTL for the project_map cache before a fresh scan is triggered. */ +export const PROJECT_MAP_CACHE_TTL_MS = 30_000; + +// ── Thresholds ────────────────────────────────────────────────────────────── + +/** Max character distance for fuzzy position matching in AST lookups. */ +export const POSITION_FUZZY_TOLERANCE = 3; + +/** Max Levenshtein distance for "did you mean?" filter suggestions. */ +export const FILTER_MATCH_MAX_DISTANCE = 2; + +// ── Confidence defaults ───────────────────────────────────────────────────── + +/** Diagnostic severities recognised by the pipeline. */ +export type Severity = 'error' | 'warning' | 'info'; + +/** + * Default confidence for a diagnostic when the rule engine did not set one. + * + * Errors are high-confidence (the linter is usually right about real bugs), + * warnings are mid-confidence (more stylistic / context dependent), infos are + * low-confidence (advisory). A populated confidence — even a default — lets + * downstream consumers bucket every diagnostic instead of silently dropping + * ones where no rule matched. + */ +export const DEFAULT_CONFIDENCE_BY_SEVERITY: Readonly> = { + error: 0.9, + warning: 0.7, + info: 0.5, +}; + +/** + * Default confidence for pos-supervisor structural warnings (check names + * prefixed with `pos-supervisor:`). These are AST-derived, not LSP-derived, + * and are more deterministic than severity alone suggests — they only fire + * when the structural rule is actually hit. + */ +export const STRUCTURAL_DEFAULT_CONFIDENCE = 0.75; diff --git a/packages/platformos-mcp-supervisor/src/core/content-triggers.spec.ts b/packages/platformos-mcp-supervisor/src/core/content-triggers.spec.ts new file mode 100644 index 00000000..ff3b0f3c --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/content-triggers.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getContentTriggers, _resetKnowledge } from './knowledge-loader'; + +beforeEach(() => { + _resetKnowledge(); +}); + +// ── Content trigger matching ────────────────────────────────────────────────── + +describe('content-triggers: pattern matching', () => { + it('triggers session_security when context.session is used', () => { + const triggers = getContentTriggers('{% assign x = context.session.cart %}', 'pages'); + expect(triggers.some((t) => t.id === 'session_security')).toBe(true); + expect(triggers.find((t) => t.id === 'session_security')?.severity).toBe('security'); + }); + + it('triggers form_csrf when
tag is present', () => { + const triggers = getContentTriggers('', 'partials'); + expect(triggers.some((t) => t.id === 'form_csrf')).toBe(true); + }); + + it('triggers raw_filter_xss when | raw is used', () => { + const triggers = getContentTriggers('{{ user_input | raw }}', 'partials'); + expect(triggers.some((t) => t.id === 'raw_filter_xss')).toBe(true); + expect(triggers.find((t) => t.id === 'raw_filter_xss')?.severity).toBe('security'); + }); + + it('triggers cache_patterns when {% cache %} is used', () => { + const triggers = getContentTriggers('{% cache "products_list", expire: 3600 %}', 'pages'); + expect(triggers.some((t) => t.id === 'cache_patterns')).toBe(true); + }); + + it('triggers redirect_patterns when redirect_to is used', () => { + const triggers = getContentTriggers('{% redirect_to "/login" %}', 'pages'); + expect(triggers.some((t) => t.id === 'redirect_patterns')).toBe(true); + }); + + it('triggers asset_url_pipeline when | asset_url is used', () => { + const triggers = getContentTriggers('{{ "styles.css" | asset_url }}', 'layouts'); + expect(triggers.some((t) => t.id === 'asset_url_pipeline')).toBe(true); + }); + + it('triggers background_job when {% background %} is used', () => { + const triggers = getContentTriggers('{% background source_name: "email" %}', 'pages'); + expect(triggers.some((t) => t.id === 'background_job')).toBe(true); + }); + + it('triggers log_tag when {% log %} is used', () => { + const triggers = getContentTriggers('{% log "debug info", type: "info" %}', 'commands'); + expect(triggers.some((t) => t.id === 'log_tag')).toBe(true); + }); + + it('triggers content_for_slots when {% content_for %} is used', () => { + const triggers = getContentTriggers( + '{% content_for "title" %}My Page{% endcontent_for %}', + 'pages', + ); + expect(triggers.some((t) => t.id === 'content_for_slots')).toBe(true); + }); + + it('triggers json_response when | json is used in a page', () => { + const triggers = getContentTriggers('{{ result | json }}', 'pages'); + expect(triggers.some((t) => t.id === 'json_response')).toBe(true); + }); + + it('triggers api_call_tag when {% api_call %} is used', () => { + const triggers = getContentTriggers( + "{% api_call result, url: 'https://example.com/api' %}", + 'pages', + ); + expect(triggers.some((t) => t.id === 'api_call_tag')).toBe(true); + }); +}); + +// ── Domain filtering ────────────────────────────────────────────────────────── + +describe('content-triggers: domain filtering', () => { + it('does not trigger content_for_slots in partials', () => { + const triggers = getContentTriggers( + '{% content_for "title" %}...{% endcontent_for %}', + 'partials', + ); + expect(triggers.some((t) => t.id === 'content_for_slots')).toBe(false); + }); + + it('does not trigger json_response in partials', () => { + const triggers = getContentTriggers('{{ result | json }}', 'partials'); + expect(triggers.some((t) => t.id === 'json_response')).toBe(false); + }); + + it('triggers session_security in commands', () => { + const triggers = getContentTriggers('{% assign x = context.session %}', 'commands'); + expect(triggers.some((t) => t.id === 'session_security')).toBe(true); + }); + + it('does not trigger session_security in graphql domain', () => { + const triggers = getContentTriggers('context.session', 'graphql'); + expect(triggers.some((t) => t.id === 'session_security')).toBe(false); + }); + + it('triggers log_tag in queries', () => { + const triggers = getContentTriggers('{% log "query debug" %}', 'queries'); + expect(triggers.some((t) => t.id === 'log_tag')).toBe(true); + }); +}); + +// ── Edge cases ──────────────────────────────────────────────────────────────── + +describe('content-triggers: edge cases', () => { + it('returns empty for empty content', () => { + const triggers = getContentTriggers('', 'pages'); + expect(triggers).toHaveLength(0); + }); + + it('returns empty for null content', () => { + // The public signature accepts `string`; pass null via cast to exercise + // the runtime guard documented in `knowledge-loader.ts`. + const triggers = getContentTriggers(null as unknown as string, 'pages'); + expect(triggers).toHaveLength(0); + }); + + it('returns empty for null domain', () => { + const triggers = getContentTriggers('some content', null as unknown as string); + expect(triggers).toHaveLength(0); + }); + + it('can return multiple triggers for rich content', () => { + const content = ` + {% assign cart = context.session.cart %} + + {{ cart | json }} + {% cache "cart_display", expire: 60 %} + {{ user_comment | raw }} + {% endcache %} +
+ `; + const triggers = getContentTriggers(content, 'pages'); + // Should match: session_security, form_csrf, json_response, cache_patterns, raw_filter_xss + expect(triggers.length).toBeGreaterThanOrEqual(4); + }); + + it('all triggers have required fields', () => { + const content = + '{% assign x = context.session %}\n{{ x | raw }}\n{% cache "k" %}{% endcache %}'; + const triggers = getContentTriggers(content, 'pages'); + for (const t of triggers) { + expect(t.id).toBeTruthy(); + expect(t.message).toBeTruthy(); + expect(t.severity).toBeTruthy(); + } + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/dependency-graph.ts b/packages/platformos-mcp-supervisor/src/core/dependency-graph.ts new file mode 100644 index 00000000..ef2152d3 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/dependency-graph.ts @@ -0,0 +1,94 @@ +/** + * Dependency-graph path resolvers. + * + * `project-fact-graph` calls these when it walks the project map to add + * file → file edges. The full graph + orphan detection from the source + * module are out of v1 scope (only consumed by `analyze-project`); only + * the three path resolvers survive. + * + * Each resolver returns a canonical repo-relative path + * (`app/views/partials/x.liquid`, `app/lib/commands/x/y.liquid`, + * `app/graphql/x/y.graphql`). Module references (`modules//…`) are + * resolved to the module file under `modules/` so consumers can still + * walk them — they just won't appear in the internal project's file list. + */ + +import type { ProjectMap } from './project-scanner'; + +/** + * Resolve a `{% render %}` name to the canonical partial path. + * + * Honours relative names by resolving against the caller's directory under + * `app/views/partials/`. A partial at `app/views/partials/blog_posts/new.liquid` + * that does `{% render 'form' %}` resolves to `blog_posts/form`, matching + * the key the scanner stores. + */ +export function resolveRenderTarget( + name: string | null | undefined, + projectMap: ProjectMap | null | undefined, + callerPath?: string, +): string | null { + if (!name) return null; + if (name.startsWith('modules/')) return `modules/${name.replace(/^modules\//, '')}.liquid`; + + const resolved = resolveRelativeRenderName(callerPath, name); + + const partial = projectMap?.partials?.[resolved]; + if (partial?.path) return partial.path; + + // Fallback: assume the standard location. May produce a non-existent + // path when the render name is wrong — orphan detection treats that as + // an edge into a missing file (surfaced as `broken_render` by the + // out-of-scope integrity checks). + return `app/views/partials/${resolved}.liquid`; +} + +/** + * Resolve a `{% function _ = 'path' %}` call to its on-disk file. + * Commands and queries live under `app/lib/` with a `.liquid` extension. + */ +export function resolveFunctionTarget(fcPath: string | null | undefined): string | null { + if (!fcPath) return null; + if (fcPath.startsWith('modules/')) { + return `modules/${fcPath.replace(/^modules\//, '')}.liquid`; + } + return `app/lib/${fcPath}.liquid`; +} + +/** + * Resolve a `{% graphql %}` operation name to its on-disk file. + */ +export function resolveGraphqlTarget(opName: string | null | undefined): string | null { + if (!opName) return null; + if (opName.startsWith('modules/')) { + return `modules/${opName.replace(/^modules\//, '')}.graphql`; + } + return `app/graphql/${opName}.graphql`; +} + +/** + * Minimal in-module copy of `project-scanner`'s `resolveRenderName`. + * + * Duplicated here (rather than imported) to keep this module a leaf in the + * dependency graph — `project-scanner` already imports from `liquid-parser` + * and `domain-detector`, and a back-edge into `dependency-graph` would + * introduce a cycle that the source repo took the same care to avoid. + * Keep both implementations in sync. + */ +function resolveRelativeRenderName(callerRelPath: string | undefined, renderName: string): string { + if (!renderName) return renderName; + if (renderName.includes('/')) return renderName; + if (!callerRelPath) return renderName; + + const partialsPrefix = 'app/views/partials/'; + if (!callerRelPath.startsWith(partialsPrefix)) return renderName; + + const relUnderPartials = callerRelPath + .slice(partialsPrefix.length) + .replace(/\.html\.liquid$/, '') + .replace(/\.liquid$/, ''); + const slashIdx = relUnderPartials.lastIndexOf('/'); + if (slashIdx < 0) return renderName; + const dir = relUnderPartials.slice(0, slashIdx); + return `${dir}/${renderName}`; +} diff --git a/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline-frontmatter-dedup.spec.ts b/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline-frontmatter-dedup.spec.ts new file mode 100644 index 00000000..e3311d89 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline-frontmatter-dedup.spec.ts @@ -0,0 +1,184 @@ +/** + * Suppression of upstream `ValidFrontmatter` rows that overlap with our + * richer `pos-supervisor:InvalidLayout` / `pos-supervisor:InvalidFrontMatter` + * structural checks (pos-cli 6.0.7 alignment, 2026-04-25). + * + * Line-anchored: YAML frontmatter is one key per line, so a line collision + * is a reliable signal of the same root cause. + */ + +import { describe, it, expect } from 'vitest'; +import { + suppressUpstreamFrontmatterDup, + type PipelineDiagnostic, + type PipelineResult, +} from './diagnostic-pipeline'; + +function makeResult( + opts: { + errors?: PipelineDiagnostic[]; + warnings?: PipelineDiagnostic[]; + infos?: PipelineDiagnostic[]; + } = {}, +): PipelineResult { + return { + errors: [...(opts.errors ?? [])], + warnings: [...(opts.warnings ?? [])], + infos: [...(opts.infos ?? [])], + }; +} + +describe('suppressUpstreamFrontmatterDup', () => { + it('drops ValidFrontmatter when pos-supervisor:InvalidLayout shares its line', () => { + const result = makeResult({ + warnings: [ + { + check: 'ValidFrontmatter', + severity: 'warning', + message: "Layout 'nonexistent_layout_xyz' does not exist", + line: 3, + }, + { + check: 'pos-supervisor:InvalidLayout', + severity: 'warning', + message: 'Layout `nonexistent_layout_xyz` not found. Expected file: …', + line: 3, + }, + ], + }); + + const removed = suppressUpstreamFrontmatterDup(result); + + expect(removed).toBe(1); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.check).toBe('pos-supervisor:InvalidLayout'); + expect(result.infos.some((i) => i.check === 'pos-supervisor:DuplicateFrontmatterCheck')).toBe( + true, + ); + }); + + it('drops ValidFrontmatter when pos-supervisor:InvalidFrontMatter shares its line (error severity)', () => { + const result = makeResult({ + errors: [ + { + check: 'pos-supervisor:InvalidFrontMatter', + severity: 'error', + message: '`cache` is not a front matter option. Use `{% cache key, expire: 3600 %}`.', + line: 3, + }, + ], + warnings: [ + { + check: 'ValidFrontmatter', + severity: 'warning', + message: "Unknown frontmatter field 'cache' in Page file", + line: 3, + }, + ], + }); + + const removed = suppressUpstreamFrontmatterDup(result); + + expect(removed).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.warnings).toHaveLength(0); + }); + + it('keeps ValidFrontmatter rows that do NOT overlap with our checks', () => { + // Upstream catches deprecated `layout_name` — we don't have a structural + // check for this, so the warning should survive untouched. + const result = makeResult({ + warnings: [ + { + check: 'ValidFrontmatter', + severity: 'warning', + message: "Use 'layout' instead of deprecated 'layout_name'", + line: 4, + }, + { + check: 'pos-supervisor:InvalidLayout', + severity: 'warning', + message: 'Layout `application` not found.', + line: 2, + }, + ], + }); + + const removed = suppressUpstreamFrontmatterDup(result); + + expect(removed).toBe(0); + expect(result.warnings).toHaveLength(2); + expect(result.infos).toHaveLength(0); + }); + + it('is a no-op when no pos-supervisor structural check is present', () => { + const result = makeResult({ + warnings: [ + { + check: 'ValidFrontmatter', + severity: 'warning', + message: "Layout 'foo' does not exist", + line: 3, + }, + ], + }); + + expect(suppressUpstreamFrontmatterDup(result)).toBe(0); + expect(result.warnings).toHaveLength(1); + expect(result.infos).toHaveLength(0); + }); + + it('is a no-op when no ValidFrontmatter row is present', () => { + const result = makeResult({ + warnings: [ + { + check: 'pos-supervisor:InvalidLayout', + severity: 'warning', + message: 'Layout `application` not found.', + line: 3, + }, + ], + }); + + expect(suppressUpstreamFrontmatterDup(result)).toBe(0); + expect(result.warnings).toHaveLength(1); + expect(result.infos).toHaveLength(0); + }); + + it('idempotent — second call after dedup is a no-op', () => { + const result = makeResult({ + warnings: [ + { check: 'ValidFrontmatter', severity: 'warning', message: 'x', line: 3 }, + { check: 'pos-supervisor:InvalidLayout', severity: 'warning', message: 'y', line: 3 }, + ], + }); + + expect(suppressUpstreamFrontmatterDup(result)).toBe(1); + expect(suppressUpstreamFrontmatterDup(result)).toBe(0); + expect(result.warnings).toHaveLength(1); + // Only one info note was added — no duplicate from the second call. + expect( + result.infos.filter((i) => i.check === 'pos-supervisor:DuplicateFrontmatterCheck'), + ).toHaveLength(1); + }); + + it('drops both ValidFrontmatter rows when multiple of our checks fire', () => { + const result = makeResult({ + errors: [ + { check: 'pos-supervisor:InvalidFrontMatter', severity: 'error', message: 'a', line: 3 }, + ], + warnings: [ + { check: 'pos-supervisor:InvalidLayout', severity: 'warning', message: 'b', line: 4 }, + { check: 'ValidFrontmatter', severity: 'warning', message: 'a-upstream', line: 3 }, + { check: 'ValidFrontmatter', severity: 'warning', message: 'b-upstream', line: 4 }, + { check: 'ValidFrontmatter', severity: 'warning', message: 'novel-upstream', line: 5 }, + ], + }); + + const removed = suppressUpstreamFrontmatterDup(result); + + expect(removed).toBe(2); + // The line-5 ValidFrontmatter is novel and survives. + expect(result.warnings.find((w) => w.check === 'ValidFrontmatter')?.line).toBe(5); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline.spec.ts b/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline.spec.ts new file mode 100644 index 00000000..1eae9036 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline.spec.ts @@ -0,0 +1,1007 @@ +/** + * Diagnostic pipeline behaviour pins. + * + * Source carried suppress-by-pending / buildPendingPartialNames / + * buildPendingPageKeys describe blocks. v1 drops all pending-state + * suppression (P18 strip), so those sections are gone. What survives + * here is the disk-verification + late-stamp + LSP-known-FP coverage + * the pipeline still owns in v1. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + runDiagnosticPipeline, + stampDefaultsOn, + type PipelineDiagnostic, + type PipelineResult, +} from './diagnostic-pipeline'; + +function makeResult( + errors: PipelineDiagnostic[] = [], + warnings: PipelineDiagnostic[] = [], + infos: PipelineDiagnostic[] = [], +): PipelineResult { + return { errors: [...errors], warnings: [...warnings], infos: [...infos] }; +} + +function metadataError( + line: number, + message = 'Required parameter autohide must be passed', +): PipelineDiagnostic { + return { check: 'MetadataParamsCheck', severity: 'error', line, message }; +} + +function metadataWarn( + line: number, + message = 'Required parameter delay must be passed', +): PipelineDiagnostic { + return { check: 'MetadataParamsCheck', severity: 'warning', line, message }; +} + +// ── suppressUndocumentedTargetParams (was suppressModuleTargetParams in source) ── + +describe('diagnostic-pipeline: suppressUndocumentedTargetParams', () => { + it('suppresses MetadataParamsCheck errors on lines calling modules/ partials', () => { + const content = [ + '{% doc %}{% enddoc %}', + '', + "{% theme_render_rc 'modules/common-styling/toasts' %}", + ].join('\n'); + + const result = makeResult([metadataError(3)]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content, + }); + + expect(result.errors).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:ModuleParamsSuppressed')).toBe( + true, + ); + }); + + it('suppresses MetadataParamsCheck warnings on module/ lines', () => { + const content = [ + '{% liquid %}', + " function _ = 'modules/user/helpers/can_do_or_unauthorized', requester: context.current_user", + '{% endliquid %}', + ].join('\n'); + + const result = makeResult([], [metadataWarn(2)]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/items/new.html.liquid', + content, + }); + + expect(result.warnings).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:ModuleParamsSuppressed')).toBe( + true, + ); + }); + + it('does NOT suppress MetadataParamsCheck errors on non-module lines', () => { + const content = [ + '{% liquid %}', + " function items = 'queries/items/search', page: context.params.page", + '{% endliquid %}', + ].join('\n'); + + const result = makeResult([ + metadataError(2, 'Required parameter limit must be passed to function call'), + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/items/index.html.liquid', + content, + }); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.check).toBe('MetadataParamsCheck'); + }); + + it('does NOT suppress non-MetadataParamsCheck errors on module lines', () => { + const content = "{% render 'modules/common-styling/init', reset: true %}"; + + const result = makeResult([ + { check: 'MissingPartial', severity: 'error', line: 1, message: 'partial does not exist' }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content, + }); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.check).toBe('MissingPartial'); + }); + + it('reports suppression count in info message', () => { + const content = [ + "{% theme_render_rc 'modules/common-styling/toasts' %}", + "{% render 'modules/common-styling/init' %}", + ].join('\n'); + + const result = makeResult([metadataError(1), metadataError(2)]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content, + }); + + expect(result.errors).toHaveLength(0); + const info = result.infos.find((i) => i.check === 'pos-supervisor:ModuleParamsSuppressed'); + expect(info?.message).toContain('2'); + }); +}); + +// ── verifyMissingPartialsOnDisk: `lib/` prefix handling ── + +describe('diagnostic-pipeline: verifyMissingPartialsOnDisk does not strip `lib/` prefix', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pipeline-libpref-')); + mkdirSync(join(tmpDir, 'app/lib/commands/contacts'), { recursive: true }); + writeFileSync( + join(tmpDir, 'app/lib/commands/contacts/create.liquid'), + '{% doc %}{% enddoc %}', + 'utf8', + ); + mkdirSync(join(tmpDir, 'app/views/partials/cards'), { recursive: true }); + writeFileSync(join(tmpDir, 'app/views/partials/cards/product.liquid'), '
', 'utf8'); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('suppresses MissingPartial for the bare `commands/X` form when X.liquid is on disk (LSP cache lag)', () => { + const result = makeResult([ + { + check: 'MissingPartial', + severity: 'error', + message: "'commands/contacts/create' does not exist", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/contacts/new.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPartialSuppressed')).toBe( + true, + ); + }); + + it('does NOT suppress MissingPartial for the `lib/commands/X` form — the `lib/` prefix expands to `app/lib/lib/...`', () => { + const result = makeResult([ + { + check: 'MissingPartial', + severity: 'error', + message: "'lib/commands/contacts/create' does not exist", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/contacts/new.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.message).toContain('lib/commands/contacts/create'); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPartialSuppressed')).toBe( + false, + ); + }); + + it('does NOT suppress MissingPartial for the `lib/queries/X` form even when the bare-form file exists on disk', () => { + mkdirSync(join(tmpDir, 'app/lib/queries/products'), { recursive: true }); + writeFileSync( + join(tmpDir, 'app/lib/queries/products/find.liquid'), + '{% doc %}{% enddoc %}', + 'utf8', + ); + const result = makeResult([ + { + check: 'MissingPartial', + severity: 'error', + message: "'lib/queries/products/find' does not exist", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/products/show.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPartialSuppressed')).toBe( + false, + ); + }); + + it('still suppresses real partial cache-lag misses (non-`lib/` paths)', () => { + const result = makeResult([ + { check: 'MissingPartial', severity: 'error', message: "'cards/product' does not exist" }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/index.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPartialSuppressed')).toBe( + true, + ); + }); +}); + +// ── verifyMissingAssets ── + +describe('diagnostic-pipeline: verifyMissingAssets via runDiagnosticPipeline', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pipeline-assets-')); + const assets = join(tmpDir, 'app/assets'); + mkdirSync(join(assets, 'styles'), { recursive: true }); + mkdirSync(join(assets, 'images'), { recursive: true }); + mkdirSync(join(assets, 'vendor'), { recursive: true }); + writeFileSync(join(assets, 'styles/app.css'), '/**/', 'utf8'); + writeFileSync(join(assets, 'styles/design-tokens.css'), ':root{}', 'utf8'); + writeFileSync(join(assets, 'images/logo.png'), 'PNG', 'utf8'); + writeFileSync(join(assets, 'vendor/logo.png'), 'PNG', 'utf8'); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('suppresses MissingAsset for a path that exists on disk (LSP cache lag)', () => { + const result = makeResult([ + { check: 'MissingAsset', severity: 'error', message: "'styles/app.css' does not exist" }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingAssetSuppressed')).toBe( + true, + ); + }); + + it('normalises agent-submitted leading-slash and assets/ prefix variants before checking', () => { + const result = makeResult([ + { check: 'MissingAsset', severity: 'error', message: "'/styles/app.css' does not exist" }, + { + check: 'MissingAsset', + severity: 'error', + message: "'assets/styles/app.css' does not exist", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + }); + + it('emits MissingAssetPathHint when the file exists at a different nested path (basename unique)', () => { + const result = makeResult([ + { check: 'MissingAsset', severity: 'error', message: "'design-tokens.css' does not exist" }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + const hint = result.infos.find((i) => i.check === 'pos-supervisor:MissingAssetPathHint'); + expect(hint).toBeDefined(); + expect(hint?.suggestion).toBe('styles/design-tokens.css'); + expect(hint?.message).toContain("'styles/design-tokens.css'"); + }); + + it('does NOT suppress when the basename is ambiguous (multiple matches) — agent picks', () => { + const result = makeResult([ + { check: 'MissingAsset', severity: 'error', message: "'logo.png' does not exist" }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/header.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.hint).toContain('Basename matches multiple assets'); + expect(result.errors[0]!.hint).toContain('images/logo.png'); + expect(result.errors[0]!.hint).toContain('vendor/logo.png'); + }); + + it('leaves MissingAsset unchanged when the file truly does not exist anywhere under app/assets/', () => { + const result = makeResult([ + { + check: 'MissingAsset', + severity: 'error', + message: "'styles/does-not-exist.css' does not exist", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingAssetSuppressed')).toBe( + false, + ); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingAssetPathHint')).toBe(false); + }); + + it('skips filesystem checks entirely when projectDir is not provided', () => { + const result = makeResult([ + { check: 'MissingAsset', severity: 'error', message: "'styles/app.css' does not exist" }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/layouts/application.liquid', + content: '', + }); + expect(result.errors).toHaveLength(1); + }); +}); + +// ── verifyTranslationKeysOnDisk ── + +describe('diagnostic-pipeline: verifyTranslationKeysOnDisk via runDiagnosticPipeline', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pipeline-translations-')); + mkdirSync(join(tmpDir, 'app/translations'), { recursive: true }); + writeFileSync( + join(tmpDir, 'app/translations/en.yml'), + 'en:\n app:\n dashboard:\n recent_notes: Recent Notes\n title: Dashboard\n', + 'utf8', + ); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('suppresses TranslationKeyExists for a key already on disk', () => { + const result = makeResult([ + { + check: 'TranslationKeyExists', + severity: 'error', + message: "Translation key 'app.dashboard.recent_notes' not found.", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/dashboard.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + expect( + result.infos.some((i) => i.check === 'pos-supervisor:TranslationKeyExistsSuppressed'), + ).toBe(true); + }); + + it('leaves TranslationKeyExists in place when the key is genuinely missing from every locale file', () => { + const result = makeResult([ + { + check: 'TranslationKeyExists', + severity: 'error', + message: "Translation key 'app.unknown.key' not found.", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/dashboard.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect( + result.infos.some((i) => i.check === 'pos-supervisor:TranslationKeyExistsSuppressed'), + ).toBe(false); + }); + + it('skips the disk check when projectDir is not provided', () => { + const result = makeResult([ + { + check: 'TranslationKeyExists', + severity: 'error', + message: "Translation key 'app.dashboard.recent_notes' not found.", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/dashboard.html.liquid', + content: '', + }); + expect(result.errors).toHaveLength(1); + }); +}); + +// ── verifyPageRoutesOnDisk ── + +describe('diagnostic-pipeline: verifyPageRoutesOnDisk via runDiagnosticPipeline', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pipeline-pages-')); + const pages = join(tmpDir, 'app/views/pages'); + mkdirSync(join(pages, 'notes'), { recursive: true }); + mkdirSync(join(pages, 'blog_posts'), { recursive: true }); + writeFileSync(join(pages, 'index.liquid'), '

Home

\n', 'utf8'); + writeFileSync(join(pages, 'dashboard.liquid'), '

Dash

\n', 'utf8'); + writeFileSync(join(pages, 'notes/index.html.liquid'), '

Notes

\n', 'utf8'); + writeFileSync( + join(pages, 'blog_posts/create.liquid'), + '---\nslug: blog_posts/create\nmethod: post\n---\n

Create

\n', + 'utf8', + ); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("suppresses MissingPage for the agent's reported case (links to /, /notes, /dashboard)", () => { + const result = makeResult([ + { check: 'MissingPage', severity: 'error', message: "No page found for route '/' (GET)" }, + { + check: 'MissingPage', + severity: 'error', + message: "No page found for route '/notes' (GET)", + }, + { + check: 'MissingPage', + severity: 'error', + message: "No page found for route '/dashboard' (GET)", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/header.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + const info = result.infos.find((i) => i.check === 'pos-supervisor:MissingPageSuppressed'); + expect(info).toBeDefined(); + expect(info?.message).toContain('/ (GET)'); + expect(info?.message).toContain('notes (GET)'); + expect(info?.message).toContain('dashboard (GET)'); + }); + + it('handles the bare "Page \'X\' not found" message shape (defaults to GET)', () => { + const result = makeResult([ + { check: 'MissingPage', severity: 'error', message: "Page 'notes' not found" }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/sidebar.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPageSuppressed')).toBe(true); + }); + + it('keeps the diagnostic but enriches .hint with served methods on a wrong-method hit', () => { + const diag: PipelineDiagnostic = { + check: 'MissingPage', + severity: 'error', + message: "No page found for route '/blog_posts/create' (GET)", + }; + const result = makeResult([diag]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/links.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect(diag.hint).toBeDefined(); + expect(diag.hint).toContain('POST'); + expect(diag.hint).toContain('GET'); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPageSuppressed')).toBe( + false, + ); + }); + + it('leaves MissingPage in place when the route is genuinely not served by any page file', () => { + const result = makeResult([ + { + check: 'MissingPage', + severity: 'error', + message: "No page found for route '/never-served' (GET)", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/header.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(1); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPageSuppressed')).toBe( + false, + ); + }); + + it('skips the disk check when projectDir is not provided', () => { + const result = makeResult([ + { + check: 'MissingPage', + severity: 'error', + message: "No page found for route '/notes' (GET)", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/header.liquid', + content: '', + }); + expect(result.errors).toHaveLength(1); + }); +}); + +// ── verifyOrphanedPartialOnDisk ── + +describe('verifyOrphanedPartialOnDisk via runDiagnosticPipeline', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orphan-verify-')); + const pages = join(tmpDir, 'app/views/pages/notes'); + const partials = join(tmpDir, 'app/views/partials/notes'); + mkdirSync(pages, { recursive: true }); + mkdirSync(partials, { recursive: true }); + + writeFileSync( + join(pages, 'show.html.liquid'), + "---\nslug: notes/show\n---\n{% render 'notes/show', object: note %}\n", + 'utf8', + ); + + writeFileSync( + join(partials, 'show.liquid'), + '{% doc %}\n @param object {object}\n{% enddoc %}\n

{{ object.title }}

\n', + 'utf8', + ); + + writeFileSync(join(partials, 'orphan.liquid'), '

truly orphaned

\n', 'utf8'); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('suppresses OrphanedPartial when a page on disk renders the partial', () => { + const result = makeResult( + [], + [ + { + check: 'OrphanedPartial', + severity: 'warning', + message: "Partial 'notes/show' is never rendered", + }, + ], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/notes/show.liquid', + content: '

{{ object.title }}

', + projectDir: tmpDir, + }); + expect(result.warnings).toHaveLength(0); + const info = result.infos.find((i) => i.check === 'pos-supervisor:OrphanedPartialVerified'); + expect(info).toBeDefined(); + expect(info?.message).toContain('notes/show'); + }); + + it('does NOT suppress OrphanedPartial when no file references the partial', () => { + const result = makeResult( + [], + [ + { + check: 'OrphanedPartial', + severity: 'warning', + message: "Partial 'notes/orphan' is never rendered", + }, + ], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/notes/orphan.liquid', + content: '

truly orphaned

', + projectDir: tmpDir, + }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.check).toBe('OrphanedPartial'); + }); + + it('works for OrphanedPartial reported as an error (not just warning)', () => { + const result = makeResult([ + { + check: 'OrphanedPartial', + severity: 'error', + message: "Partial 'notes/show' is never rendered", + }, + ]); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/notes/show.liquid', + content: '

{{ object.title }}

', + projectDir: tmpDir, + }); + expect(result.errors).toHaveLength(0); + }); + + it('does not suppress for non-partial files', () => { + const result = makeResult( + [], + [{ check: 'OrphanedPartial', severity: 'warning', message: 'orphan' }], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/notes/show.html.liquid', + content: '', + projectDir: tmpDir, + }); + expect(result.warnings).toHaveLength(1); + }); +}); + +// ── populateDefaultConfidence (stamps confidence + rule_id defaults) ── + +describe('diagnostic-pipeline: populateDefaultConfidence (in-pipeline stamping)', () => { + it('stamps severity-based defaults when the rule engine left confidence unset', () => { + const result = makeResult( + [{ check: 'UndefinedObject', severity: 'error', message: 'foo' }], + [{ check: 'UnusedAssign', severity: 'warning', message: 'bar' }], + [{ check: 'InfoOnly', severity: 'info', message: 'baz' }], + ); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.errors[0]!.confidence).toBe(0.9); + expect(result.warnings[0]!.confidence).toBe(0.7); + expect(result.infos[0]!.confidence).toBe(0.5); + }); + + it('does not overwrite a confidence value that the rule engine already set', () => { + const result = makeResult([ + { check: 'UndefinedObject', severity: 'error', message: 'foo', confidence: 0.42 }, + ]); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.errors[0]!.confidence).toBe(0.42); + }); + + it('stamps structural default for pos-supervisor: prefixed checks', () => { + const result = makeResult( + [], + [{ check: 'pos-supervisor:RemovedRender', severity: 'warning', message: 'removed' }], + ); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.warnings[0]!.confidence).toBe(0.75); + }); + + it('falls back to warning-level confidence when severity is unset or unknown', () => { + const result = makeResult([], [{ check: 'Weirdo', message: 'no severity' }]); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.warnings[0]!.confidence).toBe(0.7); + }); + + it('stamps rule_id as `${check}.unmatched` when no rule fired', () => { + const result = makeResult( + [{ check: 'UndefinedObject', severity: 'error', message: 'foo' }], + [{ check: 'UnusedAssign', severity: 'warning', message: 'bar' }], + ); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.errors[0]!.rule_id).toBe('UndefinedObject.unmatched'); + expect(result.warnings[0]!.rule_id).toBe('UnusedAssign.unmatched'); + }); + + it('preserves rule_id set by the rule engine', () => { + const result = makeResult([ + { + check: 'UndefinedObject', + severity: 'error', + message: 'foo', + rule_id: 'UndefinedObject.context_user', + }, + ]); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.errors[0]!.rule_id).toBe('UndefinedObject.context_user'); + }); + + it('falls back to `unknown.unmatched` when the diagnostic has no check name', () => { + const result = makeResult( + [], + [{ severity: 'warning', message: 'orphan' } as PipelineDiagnostic], + ); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.warnings[0]!.rule_id).toBe('unknown.unmatched'); + }); +}); + +// ── stampDefaultsOn: late-push diagnostics ── + +describe('stampDefaultsOn: late-push diagnostics get default confidence', () => { + it('stamps diagnostics added AFTER runDiagnosticPipeline has already run', () => { + const result = makeResult([{ check: 'UnknownFilter', severity: 'error', message: 'x' }]); + runDiagnosticPipeline(result, { filePath: 'app/views/pages/x.liquid', content: '' }); + expect(result.errors[0]!.confidence).toBe(0.9); + + // Simulate a late push — e.g. structural-warnings / schema validator. + result.warnings.push({ + check: 'pos-supervisor:HtmlInPage', + severity: 'warning', + message: 'HTML in page', + }); + stampDefaultsOn(result); + expect(result.warnings[0]!.confidence).toBe(0.75); // structural default + expect(result.warnings[0]!.rule_id).toBe('pos-supervisor:HtmlInPage.unmatched'); + }); + + it('is idempotent — re-stamping does not overwrite existing values', () => { + const result = makeResult([ + { + check: 'UnknownFilter', + severity: 'error', + message: 'x', + confidence: 0.42, + rule_id: 'UnknownFilter.typo', + }, + ]); + stampDefaultsOn(result); + expect(result.errors[0]!.confidence).toBe(0.42); + expect(result.errors[0]!.rule_id).toBe('UnknownFilter.typo'); + }); +}); + +// ── suppressLspKnownFalsePositives ── + +describe('diagnostic-pipeline: suppressLspKnownFalsePositives', () => { + function syntaxErr(line: number, message = 'Syntax is not supported'): PipelineDiagnostic { + return { check: 'LiquidHTMLSyntaxError', severity: 'error', line, message }; + } + + it('suppresses the LSP false positive on `assign x = a == b` when the file parses cleanly', () => { + const content = [ + '{% doc %}', + ' @param {object} object', + '{% enddoc %}', + '{% liquid', + ' assign c = object.errors | default: empty', + ' assign object.valid = c == empty', + ' return object', + '%}', + ].join('\n'); + + const result = makeResult([syntaxErr(6)]); + runDiagnosticPipeline(result, { + filePath: 'app/lib/commands/contacts/create/check.liquid', + content, + }); + + expect(result.errors).toHaveLength(0); + const info = result.infos.find( + (i) => i.check === 'pos-supervisor:LspSyntaxFalsePositiveSuppressed', + ); + expect(info).toBeDefined(); + expect(info?.message).toContain('line(s) 6'); + expect(info?.message).toContain('@platformos/liquid-html-parser'); + }); + + it('suppresses every "Syntax is not supported" diagnostic in the same file at once', () => { + const content = ['{% liquid', ' assign a = 1 == 1', ' assign b = 2 != 3', '%}'].join('\n'); + + const result = makeResult([syntaxErr(2), syntaxErr(3)]); + runDiagnosticPipeline(result, { filePath: 'app/views/partials/check.liquid', content }); + + expect(result.errors).toHaveLength(0); + const info = result.infos.find( + (i) => i.check === 'pos-supervisor:LspSyntaxFalsePositiveSuppressed', + ); + expect(info?.message).toContain('line(s) 2, 3'); + }); + + it('does NOT suppress when the file has a real syntax error elsewhere (parser fails)', () => { + const content = [ + '{% liquid', + ' assign x = 1 == 1', + '%}', + '{% if foo %}', + ' hello', + '{# missing endif — strict parse fails here #}', + ].join('\n'); + + const result = makeResult([syntaxErr(2)]); + runDiagnosticPipeline(result, { filePath: 'app/views/partials/broken.liquid', content }); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.check).toBe('LiquidHTMLSyntaxError'); + expect( + result.infos.some((i) => i.check === 'pos-supervisor:LspSyntaxFalsePositiveSuppressed'), + ).toBe(false); + }); + + it('does NOT suppress LiquidHTMLSyntaxError diagnostics with a different upstream message', () => { + const content = '{% liquid\n assign x = 1\n%}\n'; + + const result = makeResult([ + { + check: 'LiquidHTMLSyntaxError', + severity: 'error', + line: 1, + message: "Invalid syntax for tag 'render'", + }, + ]); + runDiagnosticPipeline(result, { filePath: 'app/views/partials/x.liquid', content }); + + expect(result.errors).toHaveLength(1); + expect( + result.infos.some((i) => i.check === 'pos-supervisor:LspSyntaxFalsePositiveSuppressed'), + ).toBe(false); + }); + + it('does NOT suppress non-LiquidHTMLSyntaxError checks even when the message text matches', () => { + const content = '{% liquid\n assign x = 1\n%}\n'; + + const result = makeResult([ + { check: 'UnknownFilter', severity: 'error', line: 1, message: 'Syntax is not supported' }, + ]); + runDiagnosticPipeline(result, { filePath: 'app/views/partials/x.liquid', content }); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.check).toBe('UnknownFilter'); + }); + + it('also handles diagnostics surfaced as warnings, not just errors', () => { + const content = '{% liquid\n assign x = 1 == 1\n%}\n'; + + const result = makeResult( + [], + [ + { + check: 'LiquidHTMLSyntaxError', + severity: 'warning', + line: 2, + message: 'Syntax is not supported', + }, + ], + ); + runDiagnosticPipeline(result, { filePath: 'app/views/partials/x.liquid', content }); + + expect(result.warnings).toHaveLength(0); + expect( + result.infos.some((i) => i.check === 'pos-supervisor:LspSyntaxFalsePositiveSuppressed'), + ).toBe(true); + }); +}); + +// ── verifyPageRoutesOnDisk: in-memory overlay ── + +describe('diagnostic-pipeline: verifyPageRoutesOnDisk respects in-memory overlay', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pipeline-route-overlay-')); + mkdirSync(join(tmpDir, 'app/views/pages'), { recursive: true }); + writeFileSync( + join(tmpDir, 'app/views/pages/index.liquid'), + '

old version (no frontmatter)

\n', + 'utf8', + ); + }); + + afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("suppresses MissingPage for route '/' (POST) when the file under validation declares method: post in-memory", () => { + const inMemory = [ + '---', + 'method: post', + 'metadata:', + ' title: "Home"', + '---', + '

POST handler in-memory

', + ].join('\n'); + + const result = makeResult( + [], + [ + { + check: 'MissingPage', + severity: 'warning', + line: 6, + column: 0, + message: "No page found for route '/' (POST)", + }, + ], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/index.liquid', + content: inMemory, + projectDir: tmpDir, + }); + + expect(result.warnings).toHaveLength(0); + expect(result.infos.some((i) => i.check === 'pos-supervisor:MissingPageSuppressed')).toBe(true); + }); + + it('still flags MissingPage when the in-memory frontmatter does not cover the reported method', () => { + const inMemory = ['---', 'method: get', '---', '

GET only

'].join('\n'); + + const result = makeResult( + [], + [ + { + check: 'MissingPage', + severity: 'warning', + line: 4, + column: 0, + message: "No page found for route '/' (POST)", + }, + ], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/index.liquid', + content: inMemory, + projectDir: tmpDir, + }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.hint).toContain('GET'); + }); + + it('treats a brand-new page (not yet on disk) as serving its declared route', () => { + const inMemory = ['---', 'slug: contact', 'method: post', '---', '

new page

'].join('\n'); + + const result = makeResult( + [], + [ + { + check: 'MissingPage', + severity: 'warning', + line: 5, + column: 0, + message: "No page found for route '/contact' (POST)", + }, + ], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/pages/contact.liquid', + content: inMemory, + projectDir: tmpDir, + }); + + expect(result.warnings).toHaveLength(0); + }); + + it('ignores the overlay when the file under validation is not under app/views/pages/ (partial / layout)', () => { + const inMemory = [ + '---', + 'slug: pretend', + 'method: post', + '---', + '

partial pretending to be a page

', + ].join('\n'); + + const result = makeResult( + [], + [ + { + check: 'MissingPage', + severity: 'warning', + line: 5, + column: 0, + message: "No page found for route '/pretend' (POST)", + }, + ], + ); + runDiagnosticPipeline(result, { + filePath: 'app/views/partials/pretend.liquid', + content: inMemory, + projectDir: tmpDir, + }); + + expect(result.warnings).toHaveLength(1); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline.ts b/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline.ts new file mode 100644 index 00000000..3be83c04 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/diagnostic-pipeline.ts @@ -0,0 +1,1071 @@ +/** + * Diagnostic post-processing pipeline. + * + * Extracted from validate_code for testability and clear ordering. Each + * filter is a named function that mutates `result.{errors, warnings, infos}` + * and is documented with its purpose and ordering dependencies. + * + * ORDERING CONTRACT (v1): + * 0. userSuppressions — `.pos-supervisor-ignore.yml`. Runs + * first so anything the operator has explicitly silenced is gone + * before every other step makes decisions. + * 0a. suppressLspKnownFalsePositives — must run after userSuppressions and + * BEFORE every other step, so downstream enrichment, fix generation, + * and the must_fix_before_write gate never see the spurious LSP + * error. Currently covers the pos-cli LSP "Syntax is not supported" + * regression on `assign x = a b`. + * 1. suppressDocParams — must run before Shopify elevation + * (doc params may look like Shopify objects). + * 2. suppressUnusedDocParams — depends on content; independent of + * other filters. + * 3. elevateShopify — must run after enrichment (needs + * `.suggestion` field on the diagnostic). + * 4. deduplicateArgChecks — must run after linting (needs + * both `MissingRenderPartialArguments` + `MetadataParamsCheck`). + * 5. suppressUndocumentedTargetParams — must run after step 4 (only + * suppress what wasn't already removed). + * 6. suppressRequiredParamsWithDefault — must run after step 5 (the two + * cover disjoint cases, but step 5 may remove diagnostics this step + * would otherwise re-process). + * 7. suppressModuleHelpers — independent. + * 8. suppressOrphanedPartial — independent. + * 9. verifyMissingAssets — filesystem check. + * 10. verifyTranslationKeysOnDisk — filesystem check. + * 11. verifyPageRoutesOnDisk — filesystem check. The overlay + * (file currently under validation) is folded in so its in-memory + * frontmatter contributes to the route index. + * 12. verifyOrphanedPartialOnDisk — filesystem check. + * 13. verifyMissingPartialsOnDisk — filesystem check. + * 14. populateDefaultConfidence — must run LAST. The rule engine + * sets confidence and rule_id when a rule matches; this step + * covers every surviving diagnostic that ESCAPED the rule path with + * a severity-based default + a stable `${check}.unmatched` + * fallback so consumers can bucket every row. + * + * v1 trim: the three `suppressByPending(...)` steps (MissingPartial / + * MissingPage / TranslationKeyExists for in-plan files) were dropped along + * with the `pendingFiles` / `pendingPages` / `pendingTranslations` + * parameters. `validate_intent` (the source of pending state) is out of + * v1 scope. The disk-verification steps (10, 11, 13) still handle the + * post-write case where the file IS on disk but the LSP hasn't re-indexed. + * + * NOTE: `MissingPartial`, `MissingPage`, and `TranslationKeyExists` are + * real errors — never downgrade them based on `isPreWrite` or other + * implicit state. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import yaml from 'js-yaml'; +import { toLiquidHtmlAST } from '@platformos/liquid-html-parser'; + +import { toPosixPath } from './utils'; +import { getKnownModulesMissingDocs } from './knowledge-loader'; +import { buildAssetIndex, resolveAssetPath } from './asset-index'; +import { buildTranslationIndex } from './translation-index'; +import { + buildPageRouteIndex, + parseMissingPageMessage, + resolvePageRoute, + type PageOverlay, +} from './page-route-index'; +import { + DEFAULT_CONFIDENCE_BY_SEVERITY, + STRUCTURAL_DEFAULT_CONFIDENCE, + type Severity, +} from './constants'; + +// ── Public types ─────────────────────────────────────────────────────────── + +/** + * Subset of the enriched-diagnostic shape that the pipeline actually + * reads or writes. Permissive index signature so pipeline steps can + * pass through ad-hoc fields the enricher attached (e.g. `suggestion`, + * `hint`, `rule_id`) without re-listing them everywhere. + */ +export interface PipelineDiagnostic { + check: string; + severity?: Severity; + message?: string; + line?: number; + column?: number; + endLine?: number | null; + endColumn?: number | null; + hint?: string | null; + suggestion?: string; + rule_id?: string; + confidence?: number; + [key: string]: unknown; +} + +export interface PipelineTraceEntry { + step: string; + errorsRemoved: number; + warningsRemoved: number; + errorsAfter: number; + warningsAfter: number; +} + +/** + * Mutable result shape consumed by `runDiagnosticPipeline`. The pipeline + * REPLACES `result.errors` / `result.warnings` / `result.infos` arrays + * in place; `_pipelineTrace` is stamped at the end. + */ +export interface PipelineResult { + errors: PipelineDiagnostic[]; + warnings: PipelineDiagnostic[]; + infos: PipelineDiagnostic[]; + _pipelineTrace?: PipelineTraceEntry[]; +} + +export interface PipelineContext { + filePath: string; + content: string; + docParamNames?: Set; + projectDir?: string; +} + +// ── Pipeline entry point ─────────────────────────────────────────────────── + +/** + * Run the full post-processing pipeline. Mutates `result` in place. + */ +export function runDiagnosticPipeline(result: PipelineResult, opts: PipelineContext): void { + const { filePath, content, docParamNames = new Set(), projectDir } = opts; + + const trace: PipelineTraceEntry[] = []; + const traceStep = (name: string, fn: () => void): void => { + const eBefore = result.errors.length; + const wBefore = result.warnings.length; + fn(); + const eRemoved = eBefore - result.errors.length; + const wRemoved = wBefore - result.warnings.length; + trace.push({ + step: name, + errorsRemoved: eRemoved, + warningsRemoved: wRemoved, + errorsAfter: result.errors.length, + warningsAfter: result.warnings.length, + }); + }; + + // 0. Apply user-defined suppressions from `.pos-supervisor-ignore.yml`. + if (projectDir) { + traceStep('userSuppressions', () => applyUserSuppressions(result, filePath, projectDir)); + } + + // 0a. Suppress known pos-cli LSP false positives ("Syntax is not supported" + // on boolean comparisons in `assign`). Runs early so the bogus error + // never reaches enrichment, fix gen, or the must_fix gate. + traceStep('suppressLspKnownFalsePositives', () => + suppressLspKnownFalsePositives(result, content), + ); + + // 1. Suppress UndefinedObject for declared @param names. + if (docParamNames.size > 0) { + traceStep('suppressDocParams', () => suppressDocParams(result, docParamNames)); + } + + // 2. Suppress UnusedDocParam when the param is used as a named argument. + if (docParamNames.size > 0) { + traceStep('suppressUnusedDocParams', () => + suppressUnusedDocParams(result, docParamNames, content), + ); + } + + // 3. Elevate Shopify contamination from warning to error. + traceStep('elevateShopify', () => elevateShopify(result)); + + // 4. Deduplicate MissingRenderPartialArguments + MetadataParamsCheck. + traceStep('deduplicateArgChecks', () => deduplicateArgChecks(result)); + + // 5. Suppress MetadataParamsCheck when the called target has no `{% doc %}`. + traceStep('suppressUndocumentedTargetParams', () => + suppressUndocumentedTargetParams(result, content, projectDir), + ); + + // 6. Suppress required-param diagnostics whose target partial defaults + // the param via a `| default:` filter. + traceStep('suppressRequiredParamsWithDefault', () => + suppressRequiredParamsWithDefault(result, content, projectDir), + ); + + // 7. Suppress DeprecatedTag for module helper includes. + traceStep('suppressModuleHelpers', () => suppressModuleHelpers(result, content)); + + // 8. Suppress OrphanedPartial for commands/queries (always invoked dynamically). + traceStep('suppressOrphanedPartial', () => suppressOrphanedPartial(result, filePath)); + + // 9. Verify MissingAsset against the filesystem. + if (projectDir) { + traceStep('verifyMissingAssets', () => verifyMissingAssets(result, projectDir)); + } + + // 10. Verify TranslationKeyExists against the filesystem. + if (projectDir) { + traceStep('verifyTranslationKeysOnDisk', () => verifyTranslationKeysOnDisk(result, projectDir)); + } + + // 11. Verify MissingPage against the filesystem. The file under + // validation is passed as an overlay so its in-memory frontmatter + // (`slug:`, `method:`) contributes to the route index. + if (projectDir) { + traceStep('verifyPageRoutesOnDisk', () => + verifyPageRoutesOnDisk(result, projectDir, { filePath, content }), + ); + } + + // 12. Verify OrphanedPartial against the filesystem. + if (projectDir) { + traceStep('verifyOrphanedPartialOnDisk', () => + verifyOrphanedPartialOnDisk(result, filePath, projectDir), + ); + } + + // 13. Verify MissingPartial against the filesystem. + if (projectDir) { + traceStep('verifyMissingPartialsOnDisk', () => verifyMissingPartialsOnDisk(result, projectDir)); + } + + // 14. Stamp default confidence + rule_id on every surviving diagnostic + // the rule engine did not already score. Runs last so suppressed/ + // downgraded items are gone by now. + traceStep('populateDefaultConfidence', () => populateDefaultConfidence(result)); + + result._pipelineTrace = trace; +} + +// ── Step 0: user suppressions ────────────────────────────────────────────── + +interface SuppressionRule { + check: string; + file_pattern?: string; +} + +interface SuppressionFile { + suppressions?: SuppressionRule[]; +} + +export function applyUserSuppressions( + result: PipelineResult, + filePath: string, + projectDir: string, +): void { + const suppressFile = join(projectDir, '.pos-supervisor-ignore.yml'); + if (!existsSync(suppressFile)) return; + let rules: SuppressionRule[] | undefined; + try { + const parsed = yaml.load(readFileSync(suppressFile, 'utf-8')) as SuppressionFile | undefined; + rules = parsed?.suppressions; + } catch { + return; + } + if (!Array.isArray(rules) || rules.length === 0) return; + const ruleList = rules; + + const matchRule = (d: PipelineDiagnostic): boolean => + ruleList.some((r) => { + if (r.check !== d.check) return false; + if (r.file_pattern) { + if (r.file_pattern.includes('*')) { + const re = new RegExp('^' + r.file_pattern.replace(/\*/g, '.*') + '$'); + if (!re.test(filePath)) return false; + } else if (!filePath.includes(r.file_pattern)) { + return false; + } + } + return true; + }); + + const errBefore = result.errors.length; + const warnBefore = result.warnings.length; + result.errors = result.errors.filter((d) => !matchRule(d)); + result.warnings = result.warnings.filter((d) => !matchRule(d)); + const suppressed = errBefore - result.errors.length + (warnBefore - result.warnings.length); + if (suppressed > 0) { + result.infos.push({ + check: 'pos-supervisor:UserSuppressed', + severity: 'info', + message: `Suppressed ${suppressed} diagnostic(s) via .pos-supervisor-ignore.yml`, + }); + } +} + +// ── Step 0a: LSP false positives ─────────────────────────────────────────── + +/** + * Suppress the pos-cli LSP "Syntax is not supported" false positive on + * boolean comparisons inside `assign` tags — the platformOS parser + * accepts the syntax and `pos-cli check run` reports no offenses. The + * file is strict-parsed as a precondition; if the parser rejects it the + * suppression bails so any genuine syntax error stays visible. + */ +export function suppressLspKnownFalsePositives(result: PipelineResult, content: string): void { + const matches = (d: PipelineDiagnostic): boolean => + d.check === 'LiquidHTMLSyntaxError' && + typeof d.message === 'string' && + /^Syntax is not supported$/i.test(d.message.trim()); + + const candidates = [...result.errors.filter(matches), ...result.warnings.filter(matches)]; + if (candidates.length === 0) return; + + // Strict parse — no tolerant flag — is the gate. + let parsesCleanly: boolean; + try { + toLiquidHtmlAST(content); + parsesCleanly = true; + } catch { + parsesCleanly = false; + } + if (!parsesCleanly) return; + + const removeSet = new Set(candidates); + result.errors = result.errors.filter((d) => !removeSet.has(d)); + result.warnings = result.warnings.filter((d) => !removeSet.has(d)); + + const lines = candidates.map((d) => d.line).filter((n): n is number => n != null); + result.infos.push({ + check: 'pos-supervisor:LspSyntaxFalsePositiveSuppressed', + severity: 'info', + message: + `Suppressed ${candidates.length} LiquidHTMLSyntaxError("Syntax is not supported") ` + + `diagnostic(s)${lines.length ? ` on line(s) ${lines.join(', ')}` : ''} — ` + + `the platformOS parser (@platformos/liquid-html-parser) accepts the file. ` + + `This is a known pos-cli LSP regression, most often triggered by a boolean ` + + `comparison inside \`assign\` (e.g. \`assign x = a == b\`).`, + }); +} + +// ── Step 1: doc params ───────────────────────────────────────────────────── + +export function suppressDocParams(result: PipelineResult, docParamNames: Set): void { + const match = (diag: PipelineDiagnostic): boolean => { + if (diag.check !== 'UndefinedObject') return false; + const varMatch = diag.message?.match(/`([^`]+)`/); + return !!varMatch && docParamNames.has(varMatch[1]); + }; + const count = result.errors.filter(match).length + result.warnings.filter(match).length; + if (count > 0) { + result.errors = result.errors.filter((d) => !match(d)); + result.warnings = result.warnings.filter((d) => !match(d)); + result.infos.push({ + check: 'pos-supervisor:DocParamSuppressed', + severity: 'info', + message: `Suppressed ${count} UndefinedObject warning(s) for declared @param(s): ${[...docParamNames].join(', ')}`, + }); + } +} + +// ── Step 2: unused doc params ────────────────────────────────────────────── + +export function suppressUnusedDocParams( + result: PipelineResult, + docParamNames: Set, + content: string, +): void { + const usedAsArg = new Set(); + for (const name of docParamNames) { + const argPattern = new RegExp( + `(?:,|{%\\s*(?:graphql|function|render|include|theme_render_rc)\\b[^%]*)\\b${name}\\s*:`, + 's', + ); + if (argPattern.test(content)) usedAsArg.add(name); + } + if (usedAsArg.size === 0) return; + + const match = (d: PipelineDiagnostic): boolean => { + if (d.check !== 'UnusedDocParam') return false; + const varMatch = d.message?.match(/['"`](\w+)['"`]/); + return !!varMatch && usedAsArg.has(varMatch[1]); + }; + const count = result.errors.filter(match).length + result.warnings.filter(match).length; + if (count > 0) { + result.errors = result.errors.filter((d) => !match(d)); + result.warnings = result.warnings.filter((d) => !match(d)); + result.infos.push({ + check: 'pos-supervisor:UnusedDocParamSuppressed', + severity: 'info', + message: `Suppressed ${count} UnusedDocParam warning(s) for @param(s) used as named arguments: ${[...usedAsArg].join(', ')}`, + }); + } +} + +// ── Step 3: Shopify elevation ────────────────────────────────────────────── + +export function elevateShopify(result: PipelineResult): void { + const shopifyWarnings = result.warnings.filter( + (d) => d.check === 'UndefinedObject' && d.suggestion && /shopify/i.test(d.suggestion), + ); + if (shopifyWarnings.length === 0) return; + result.warnings = result.warnings.filter((d) => !shopifyWarnings.includes(d)); + for (const d of shopifyWarnings) { + result.errors.push({ ...d, severity: 'error' }); + } +} + +// ── Step 4: dedup arg checks ─────────────────────────────────────────────── + +export function deduplicateArgChecks(result: PipelineResult): void { + const mrpaLines = new Set([ + ...result.errors.filter((d) => d.check === 'MissingRenderPartialArguments').map((d) => d.line), + ...result.warnings + .filter((d) => d.check === 'MissingRenderPartialArguments') + .map((d) => d.line), + ]); + if (mrpaLines.size === 0) return; + + const isRedundant = (d: PipelineDiagnostic): boolean => + d.check === 'MetadataParamsCheck' && mrpaLines.has(d.line); + const count = + result.errors.filter(isRedundant).length + result.warnings.filter(isRedundant).length; + if (count > 0) { + result.errors = result.errors.filter((d) => !isRedundant(d)); + result.warnings = result.warnings.filter((d) => !isRedundant(d)); + result.infos.push({ + check: 'pos-supervisor:DuplicateArgCheck', + severity: 'info', + message: `Suppressed ${count} MetadataParamsCheck diagnostic(s) already covered by MissingRenderPartialArguments`, + }); + } +} + +// ── Step 5: undocumented target params ───────────────────────────────────── + +interface ParamTarget { + path: string; + kind: 'module' | 'partial' | 'function'; +} + +export function suppressUndocumentedTargetParams( + result: PipelineResult, + content: string, + projectDir: string | undefined, +): void { + const lines = content.split('\n'); + + const extractTarget = (line: string): ParamTarget | null => { + let m = line.match(/['"](modules\/[^'"]+)['"]/); + if (m) return { path: m[1].replace(/\.liquid$/, ''), kind: 'module' }; + m = line.match(/\brender\s+['"]([^'"]+)['"]/); + if (m) return { path: `app/views/partials/${m[1]}.liquid`, kind: 'partial' }; + m = line.match(/\btheme_render_rc\s+['"]([^'"]+)['"]/); + if (m) return { path: `app/views/partials/${m[1]}.liquid`, kind: 'partial' }; + m = line.match(/\bfunction\s+\w+\s*=\s*['"]([^'"]+)['"]/); + if (m) return { path: `app/lib/${m[1]}.liquid`, kind: 'function' }; + return null; + }; + + // Cache disk reads — one target may back many diagnostics in this content. + const undocCache = new Map(); + const targetIsUndocumented = (target: ParamTarget): boolean | null => { + if (target.kind === 'module') return true; + if (!projectDir) return null; + if (undocCache.has(target.path)) return undocCache.get(target.path) ?? null; + try { + const abs = join(projectDir, target.path); + if (!existsSync(abs)) { + undocCache.set(target.path, null); + return null; + } + const src = readFileSync(abs, 'utf8'); + const hasDoc = /\{%\s*doc\s*%\}/.test(src); + const undocumented = !hasDoc; + undocCache.set(target.path, undocumented); + return undocumented; + } catch { + undocCache.set(target.path, null); + return null; + } + }; + + const removeSet = new Set(); + const modulePaths = new Set(); + const appPaths = new Set(); + + const classify = (d: PipelineDiagnostic): void => { + if (d.check !== 'MetadataParamsCheck') return; + const line = lines[(d.line ?? 1) - 1] ?? ''; + const target = extractTarget(line); + if (!target) return; + if (targetIsUndocumented(target) !== true) return; + removeSet.add(d); + if (target.kind === 'module') modulePaths.add(target.path); + else appPaths.add(target.path); + }; + + for (const d of result.errors) classify(d); + for (const d of result.warnings) classify(d); + + if (removeSet.size === 0) return; + + result.errors = result.errors.filter((d) => !removeSet.has(d)); + result.warnings = result.warnings.filter((d) => !removeSet.has(d)); + + if (modulePaths.size > 0) { + const moduleCount = [...removeSet].filter((d) => { + const line = lines[(d.line ?? 1) - 1] ?? ''; + return /['"]modules\//.test(line); + }).length; + result.infos.push({ + check: 'pos-supervisor:ModuleParamsSuppressed', + severity: 'info', + message: + `Suppressed ${moduleCount} MetadataParamsCheck error(s) on calls to modules/ partials. ` + + `These fire on the calling file despite modules/ being excluded in .platformos-check.yml ` + + `because the error is attributed to the caller, not the module file. ` + + `Root fix: add {% doc %} blocks to the module partials.`, + }); + + const known = getKnownModulesMissingDocs(); + const knownList: string[] = []; + const unknownList: string[] = []; + for (const path of modulePaths) { + if (known.has(path)) knownList.push(path); + else unknownList.push(path); + } + const unknownNote = + unknownList.length > 0 + ? ` New offender(s) not on the known list — consider filing an upstream issue ` + + `against each module repo to add a {% doc %} block: ${unknownList.join(', ')}.` + : ''; + result.infos.push({ + check: 'pos-supervisor:module_doc_missing', + severity: 'info', + message: `Module partial(s) missing {% doc %} blocks detected on calling file: ${[...modulePaths].join(', ')}.${unknownNote}`, + known: knownList, + unknown: unknownList, + }); + } + + if (appPaths.size > 0) { + const appCount = + modulePaths.size > 0 + ? removeSet.size - + [...removeSet].filter((d) => { + const line = lines[(d.line ?? 1) - 1] ?? ''; + return /['"]modules\//.test(line); + }).length + : removeSet.size; + result.infos.push({ + check: 'pos-supervisor:UndocumentedPartialParamsSuppressed', + severity: 'info', + message: + `Suppressed ${appCount} MetadataParamsCheck error(s) whose target partial lacks a {% doc %} block. ` + + `Without a contract, the LSP guesses required parameters from usage and produces false positives. ` + + `Root fix: add {% doc %} with @param declarations to each target: ${[...appPaths].join(', ')}.`, + paths: [...appPaths], + }); + } +} + +// ── Step 6: required params defaulted in body ────────────────────────────── + +interface DefaultTarget { + path: string; +} + +export function suppressRequiredParamsWithDefault( + result: PipelineResult, + content: string, + projectDir: string | undefined, +): void { + if (!projectDir) return; + const lines = content.split('\n'); + + const extractTarget = (line: string): DefaultTarget | null => { + let m = line.match(/['"](modules\/[^'"]+)['"]/); + if (m) return { path: m[1].endsWith('.liquid') ? m[1] : `${m[1]}.liquid` }; + m = line.match(/\brender\s+['"]([^'"]+)['"]/); + if (m) return { path: `app/views/partials/${m[1]}.liquid` }; + m = line.match(/\btheme_render_rc\s+['"]([^'"]+)['"]/); + if (m) return { path: `app/views/partials/${m[1]}.liquid` }; + m = line.match(/\bfunction\s+\w+\s*=\s*['"]([^'"]+)['"]/); + if (m) return { path: `app/lib/${m[1]}.liquid` }; + return null; + }; + + const extractParamName = (msg: string | undefined): string | null => { + if (!msg) return null; + let m = msg.match(/\bargument\s+['"`](\w+)['"`]/i); + if (m) return m[1]; + m = msg.match(/[Rr]equired parameter\s+['"`]?(\w+)['"`]?/); + if (m) return m[1]; + return null; + }; + + const targetCache = new Map(); + const readTarget = (relPath: string): string | null => { + if (targetCache.has(relPath)) return targetCache.get(relPath) ?? null; + try { + const abs = join(projectDir, relPath); + if (!existsSync(abs)) { + targetCache.set(relPath, null); + return null; + } + const src = readFileSync(abs, 'utf8'); + targetCache.set(relPath, src); + return src; + } catch { + targetCache.set(relPath, null); + return null; + } + }; + + const paramDefaultedInBody = (src: string | null, paramName: string | null): boolean => { + if (!src || !paramName) return false; + const re = new RegExp(`\\b${paramName}\\s*\\|\\s*default\\s*:`); + return re.test(src); + }; + + const removeSet = new Set(); + const affected = new Set(); + + const classify = (d: PipelineDiagnostic): void => { + if (d.check !== 'MetadataParamsCheck' && d.check !== 'MissingRenderPartialArguments') return; + const target = extractTarget(lines[(d.line ?? 1) - 1] ?? ''); + if (!target) return; + const paramName = extractParamName(d.message); + if (!paramName) return; + const src = readTarget(target.path); + if (!paramDefaultedInBody(src, paramName)) return; + removeSet.add(d); + affected.add(`${target.path}#${paramName}`); + }; + + for (const d of result.errors) classify(d); + for (const d of result.warnings) classify(d); + + if (removeSet.size === 0) return; + + result.errors = result.errors.filter((d) => !removeSet.has(d)); + result.warnings = result.warnings.filter((d) => !removeSet.has(d)); + + result.infos.push({ + check: 'pos-supervisor:ParamHasDefaultSuppressed', + severity: 'info', + message: + `Suppressed ${removeSet.size} required-param diagnostic(s) whose target partial defaults the ` + + `parameter via a \`| default:\` filter — the param is effectively optional. ` + + `Root fix: convert the @param declaration to bracket notation ([name]) in the target's {% doc %} block. ` + + `Affected target:param pairs: ${[...affected].join(', ')}.`, + affected: [...affected], + }); +} + +// ── Step 7: module helper includes ───────────────────────────────────────── + +export function suppressModuleHelpers(result: PipelineResult, content: string): void { + const isModuleHelperInclude = (d: PipelineDiagnostic): boolean => { + if (d.check !== 'DeprecatedTag') return false; + return ( + /include\s+['"]modules\/[^'"]*\/helpers\//.test(content) && !!d.message?.includes('include') + ); + }; + const count = + result.errors.filter(isModuleHelperInclude).length + + result.warnings.filter(isModuleHelperInclude).length; + if (count > 0) { + result.errors = result.errors.filter((d) => !isModuleHelperInclude(d)); + result.warnings = result.warnings.filter((d) => !isModuleHelperInclude(d)); + result.infos.push({ + check: 'pos-supervisor:ModuleHelperInclude', + severity: 'info', + message: `Suppressed ${count} DeprecatedTag warning(s) for module helper includes — modules use {% include %} for scope sharing by design.`, + }); + } +} + +// ── Step 8: orphaned partial (commands/queries only in v1) ───────────────── + +/** + * Suppress `OrphanedPartial` for `app/lib/(commands|queries)/` — these + * are invoked via `{% function %}` / `{% graphql %}` / GraphQL mutations. + * Static analysis cannot follow those invocation paths, so every command + * /query looks orphaned. Shipping ≠ dead code for this class of file. + * + * v1 trim: the source's second branch suppressed orphans when a pending + * plan contained potential caller files. With pending state dropped from + * the pipeline, that branch never fires; removed for clarity. + */ +export function suppressOrphanedPartial(result: PipelineResult, filePath: string): void { + const isOrphan = (d: PipelineDiagnostic): boolean => d.check === 'OrphanedPartial'; + const count = result.errors.filter(isOrphan).length + result.warnings.filter(isOrphan).length; + if (count === 0) return; + + if (!/\/lib\/(commands|queries)\//.test(filePath)) return; + + result.errors = result.errors.filter((d) => !isOrphan(d)); + result.warnings = result.warnings.filter((d) => !isOrphan(d)); + result.infos.push({ + check: 'pos-supervisor:OrphanedPartialSuppressed', + severity: 'info', + message: + `Suppressed ${count} OrphanedPartial diagnostic(s) — commands/queries are invoked via ` + + `{% function %} / GraphQL and appear orphaned to static cross-reference analysis.`, + reason: 'lib-target', + }); +} + +// ── Step 9: verify MissingAsset ──────────────────────────────────────────── + +export function verifyMissingAssets(result: PipelineResult, projectDir: string): void { + const missingAssets = [...result.errors, ...result.warnings].filter( + (d) => d.check === 'MissingAsset', + ); + if (missingAssets.length === 0) return; + + const index = buildAssetIndex(projectDir); + const suppressed = new Set(); + const verifiedPaths: string[] = []; + const renamedHints: Array<{ reported: string; suggestion: string }> = []; + + for (const d of missingAssets) { + const pathMatch = d.message?.match(/['"`]([^'"`]+)['"`]/); + if (!pathMatch) continue; + const reported = pathMatch[1]; + const resolution = resolveAssetPath(reported, index); + + if (resolution.status === 'exists') { + suppressed.add(d); + verifiedPaths.push(reported); + } else if (resolution.status === 'renamed') { + suppressed.add(d); + renamedHints.push({ reported, suggestion: resolution.suggestion }); + } else if (resolution.status === 'ambiguous') { + d.hint = + (d.hint ? d.hint + '\n' : '') + + `Basename matches multiple assets: ${resolution.suggestions.join(', ')}. Use the path that belongs to this template's module/feature.`; + } + } + + if (suppressed.size > 0) { + result.errors = result.errors.filter((d) => !suppressed.has(d)); + result.warnings = result.warnings.filter((d) => !suppressed.has(d)); + } + + if (verifiedPaths.length > 0) { + result.infos.push({ + check: 'pos-supervisor:MissingAssetSuppressed', + severity: 'info', + message: `Suppressed ${verifiedPaths.length} MissingAsset diagnostic(s) — asset(s) exist on disk: ${verifiedPaths.join(', ')}`, + }); + } + + for (const { reported, suggestion } of renamedHints) { + result.infos.push({ + check: 'pos-supervisor:MissingAssetPathHint', + severity: 'info', + message: `Asset '${reported}' is not at the path written, but '${suggestion}' exists. asset_url paths are relative to app/assets/ and MUST include the subdirectory (styles/, scripts/, images/, fonts/, media/). Change the template to: {{ '${suggestion}' | asset_url }}.`, + suggestion, + }); + } +} + +// ── Step 10: verify TranslationKeyExists ─────────────────────────────────── + +export function verifyTranslationKeysOnDisk(result: PipelineResult, projectDir: string): void { + const candidates = [...result.errors, ...result.warnings].filter( + (d) => d.check === 'TranslationKeyExists', + ); + if (candidates.length === 0) return; + + const { keys } = buildTranslationIndex(projectDir); + if (keys.size === 0) return; + + const verified: Array<{ d: PipelineDiagnostic; key: string }> = []; + for (const d of candidates) { + const m = d.message?.match(/['"`]([^'"`]+)['"`]/); + if (!m) continue; + if (keys.has(m[1])) verified.push({ d, key: m[1] }); + } + if (verified.length === 0) return; + + const suppressed = new Set(verified.map((v) => v.d)); + result.errors = result.errors.filter((d) => !suppressed.has(d)); + result.warnings = result.warnings.filter((d) => !suppressed.has(d)); + result.infos.push({ + check: 'pos-supervisor:TranslationKeyExistsSuppressed', + severity: 'info', + message: + `Suppressed ${verified.length} TranslationKeyExists diagnostic(s) — ` + + `key(s) already defined in app/translations/: ${verified.map((v) => v.key).join(', ')}. ` + + `(LSP cache lag — no need to pass pending_translations for keys already on disk.)`, + }); +} + +// ── Step 11: verify MissingPage ──────────────────────────────────────────── + +export function verifyPageRoutesOnDisk( + result: PipelineResult, + projectDir: string, + currentFile: PageOverlay | null = null, +): void { + const candidates = [...result.errors, ...result.warnings].filter( + (d) => d.check === 'MissingPage', + ); + if (candidates.length === 0) return; + + const index = buildPageRouteIndex(projectDir, currentFile); + if (index.routes.size === 0) return; + + const suppressed = new Set(); + const verifiedRoutes: string[] = []; + + for (const d of candidates) { + const parsed = parseMissingPageMessage(d.message); + if (!parsed) continue; + const resolution = resolvePageRoute(parsed.route, parsed.method, index); + + if (resolution.status === 'exists') { + suppressed.add(d); + verifiedRoutes.push(`${parsed.route || '/'} (${parsed.method.toUpperCase()})`); + } else if (resolution.status === 'wrong-method') { + d.hint = + (d.hint ? d.hint + '\n' : '') + + `Route '${parsed.route || '/'}' IS served, but only for ${resolution.methods.map((m) => m.toUpperCase()).join(', ')} — not ${parsed.method.toUpperCase()}. ` + + `If this is an use the method that's actually served, or scaffold a new page for the missing method.`; + } + } + + if (suppressed.size > 0) { + result.errors = result.errors.filter((d) => !suppressed.has(d)); + result.warnings = result.warnings.filter((d) => !suppressed.has(d)); + result.infos.push({ + check: 'pos-supervisor:MissingPageSuppressed', + severity: 'info', + message: + `Suppressed ${verifiedRoutes.length} MissingPage diagnostic(s) — route(s) served by other page file(s) in ` + + `app/views/pages/: ${verifiedRoutes.join(', ')}. (validate_code analyses one file at a time and cannot see neighbouring pages.)`, + }); + } +} + +// ── Step 12: verify OrphanedPartial on disk ──────────────────────────────── + +export function verifyOrphanedPartialOnDisk( + result: PipelineResult, + filePath: string, + projectDir: string, +): void { + const isOrphan = (d: PipelineDiagnostic): boolean => d.check === 'OrphanedPartial'; + const orphanCount = + result.errors.filter(isOrphan).length + result.warnings.filter(isOrphan).length; + if (orphanCount === 0) return; + + const partialName = extractPartialNameFromPath(filePath); + if (!partialName) return; + + if (!hasRenderReferenceOnDisk(projectDir, partialName, filePath)) return; + + result.errors = result.errors.filter((d) => !isOrphan(d)); + result.warnings = result.warnings.filter((d) => !isOrphan(d)); + result.infos.push({ + check: 'pos-supervisor:OrphanedPartialVerified', + severity: 'info', + message: `Suppressed OrphanedPartial — '${partialName}' is referenced by file(s) on disk. (Checker index lag.)`, + }); +} + +// ── Step 13: verify MissingPartial on disk ───────────────────────────────── + +export function verifyMissingPartialsOnDisk(result: PipelineResult, projectDir: string): void { + const candidates = [...result.errors, ...result.warnings].filter( + (d) => d.check === 'MissingPartial', + ); + if (candidates.length === 0) return; + + const suppressed = new Set(); + const verified: string[] = []; + + for (const d of candidates) { + const nameMatch = d.message?.match(/['"]([^'"]+)['"]/); + if (!nameMatch) continue; + const name = nameMatch[1]; + if (name.startsWith('modules/')) continue; + + if (resolveMissingPartialPaths(name, projectDir).some((p) => existsSync(p))) { + suppressed.add(d); + verified.push(name); + } + } + + if (suppressed.size === 0) return; + + result.errors = result.errors.filter((d) => !suppressed.has(d)); + result.warnings = result.warnings.filter((d) => !suppressed.has(d)); + result.infos.push({ + check: 'pos-supervisor:MissingPartialSuppressed', + severity: 'info', + message: + `Suppressed ${verified.length} MissingPartial diagnostic(s) — partial(s) exist on disk: ${verified.join(', ')}. ` + + `(LSP cache lag — partial was written but not yet re-indexed.)`, + }); +} + +/** + * Mirror upstream `DocumentsLocator` partial-resolution semantics: the + * `function` / `render` tags resolve relative to the partial search + * paths declared by `@platformos/platformos-common` — + * `FILE_TYPE_DIRS[Partial] = ['views/partials', 'lib']` + * — joined under `app/`. So `commands/X` is found at + * `app/lib/commands/X.liquid` and `lib/commands/X` would only resolve at + * `app/lib/lib/commands/X.liquid` (which never exists in any sane + * project). DO NOT strip a leading `lib/`: doing so silently suppresses + * the LSP's correct `MissingPartial` for the invalid prefix and steers + * agents toward the bug. + */ +function resolveMissingPartialPaths(name: string, projectDir: string): string[] { + return [ + join(projectDir, 'app', 'views', 'partials', `${name}.liquid`), + join(projectDir, 'app', 'views', 'partials', `${name}.html.liquid`), + join(projectDir, 'app', 'lib', `${name}.liquid`), + ]; +} + +function extractPartialNameFromPath(filePath: string): string | null { + const m = filePath.match(/^app\/views\/partials\/(.+?)\.(?:html\.)?liquid$/); + return m ? m[1] : null; +} + +function hasRenderReferenceOnDisk( + projectDir: string, + partialName: string, + selfPath: string, +): boolean { + const escaped = partialName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`['"]${escaped}['"]`); + + const scanDirs = ['app/views/pages', 'app/views/partials', 'app/views/layouts']; + const selfPathNorm = toPosixPath(selfPath); + for (const dir of scanDirs) { + const fullDir = join(projectDir, dir); + let entries: string[]; + try { + entries = readdirSync(fullDir, { recursive: true }) as string[]; + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.endsWith('.liquid')) continue; + // `entry` carries native separators on Windows; the comparison + // against `selfPath` (POSIX from upstream callers) needs both + // sides normalised. + const relPath = toPosixPath(join(dir, entry)); + if (relPath === selfPathNorm) continue; + try { + const content = readFileSync(join(fullDir, entry), 'utf8'); + if (pattern.test(content)) return true; + } catch { + // unreadable file — skip + } + } + } + return false; +} + +// ── Step 14: default confidence + rule_id stamp ──────────────────────────── + +function defaultConfidenceFor(diag: PipelineDiagnostic): number { + if (typeof diag.check === 'string' && diag.check.startsWith('pos-supervisor:')) { + return STRUCTURAL_DEFAULT_CONFIDENCE; + } + const sev = diag.severity; + if (sev && DEFAULT_CONFIDENCE_BY_SEVERITY[sev] != null) { + return DEFAULT_CONFIDENCE_BY_SEVERITY[sev]; + } + return DEFAULT_CONFIDENCE_BY_SEVERITY.warning; +} + +function defaultRuleIdFor(diag: PipelineDiagnostic): string { + // Stable fallback so rule-less diagnostics cluster under a single + // bucket per check instead of scattering into `unknown` or the check + // name alone (which collides with the check-level scorecard and + // muddles rule attribution). + return diag.check ? `${diag.check}.unmatched` : 'unknown.unmatched'; +} + +function populateDefaultConfidence(result: PipelineResult): void { + const stamp = (d: PipelineDiagnostic): void => { + if (d.confidence == null) d.confidence = defaultConfidenceFor(d); + if (!d.rule_id) d.rule_id = defaultRuleIdFor(d); + }; + for (const d of result.errors) stamp(d); + for (const d of result.warnings) stamp(d); + for (const d of result.infos) stamp(d); +} + +/** + * Stand-alone entry point — same semantics as the pipeline's last step, + * callable from outside. + * + * `validate-code` pushes several diagnostic sources (structural warnings, + * schema validation, translation YAML check, diff-aware + * `RemovedRender`/`RemovedGraphQL`/`AddedParam`, new-partial caller + * check) into `result.errors` / `result.warnings` AFTER + * `runDiagnosticPipeline` finishes. Those late additions would otherwise + * escape `populateDefaultConfidence` and land with `confidence = null` / + * no `rule_id`. This helper fills the gap. + * + * Idempotent — calling twice is safe. + */ +export function stampDefaultsOn(result: PipelineResult): void { + populateDefaultConfidence(result); +} + +// ── Cross-check helper: suppress upstream ValidFrontmatter dup ───────────── + +/** + * Drop upstream `ValidFrontmatter` diagnostics that overlap with our + * richer structural-check counterparts. pos-cli 6.0.7 added + * `ValidFrontmatter` which independently reports the same root causes + * as our existing `pos-supervisor:InvalidLayout` (missing layout file) + * and `pos-supervisor:InvalidFrontMatter` (unknown / misleading + * frontmatter keys). + * + * Our checks carry richer messages (named expected paths, deprecation + * guidance, fix templates) so we keep them and drop the upstream copy. + * Upstream `ValidFrontmatter` rows that don't share a line / layout + * name with one of our checks pass through untouched. + * + * Idempotent. Safe to call after both diagnostic sources have pushed. + * + * Returns the count of suppressed diagnostics. + */ +export function suppressUpstreamFrontmatterDup(result: PipelineResult): number { + const ourLines = new Set(); + const ourInvalidLayoutNames = new Set(); + for (const d of [...result.errors, ...result.warnings]) { + if ( + d.check === 'pos-supervisor:InvalidLayout' || + d.check === 'pos-supervisor:InvalidFrontMatter' + ) { + ourLines.add(d.line); + } + if (d.check === 'pos-supervisor:InvalidLayout') { + const layoutName = d.message?.match(/^Layout `([^`]+)` not found/)?.[1]; + if (layoutName) ourInvalidLayoutNames.add(layoutName); + } + } + if (ourLines.size === 0 && ourInvalidLayoutNames.size === 0) return 0; + + const isRedundant = (d: PipelineDiagnostic): boolean => { + if (d.check !== 'ValidFrontmatter') return false; + if (ourLines.has(d.line)) return true; + const layoutName = d.message?.match(/^Layout ['"`]([^'"`]+)['"`] does not exist$/)?.[1]; + return !!layoutName && ourInvalidLayoutNames.has(layoutName); + }; + const eRemoved = result.errors.filter(isRedundant).length; + const wRemoved = result.warnings.filter(isRedundant).length; + const removed = eRemoved + wRemoved; + if (removed === 0) return 0; + + result.errors = result.errors.filter((d) => !isRedundant(d)); + result.warnings = result.warnings.filter((d) => !isRedundant(d)); + result.infos.push({ + check: 'pos-supervisor:DuplicateFrontmatterCheck', + severity: 'info', + message: `Suppressed ${removed} ValidFrontmatter diagnostic(s) already covered by pos-supervisor structural check(s) (InvalidLayout / InvalidFrontMatter).`, + }); + return removed; +} diff --git a/packages/platformos-mcp-supervisor/src/core/diagnostic-record.spec.ts b/packages/platformos-mcp-supervisor/src/core/diagnostic-record.spec.ts new file mode 100644 index 00000000..f60054e7 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/diagnostic-record.spec.ts @@ -0,0 +1,184 @@ +/** + * diagnostic-record unit tests — pin the per-check `extractParams` registry + * and the `templateOf` wrapper. + * + * v1 trim (P12): source exported `messageTemplate`, `fingerprint`, + * `templateFingerprint`, `makeDiagnosticRecord`, `DIAGNOSTIC_RECORD_VERSION`, + * and `KNOWN_EXTRACTOR_CHECKS` for the analytics layer. Those are dropped. + * Only `templateOf` and `extractParams` survive (the helpers consumed by + * `error-enricher` + `fix-generator`). The corresponding test cases from + * source (`fingerprint hashing`, `makeDiagnosticRecord`, + * `messageTemplate masking`, the `KNOWN_EXTRACTOR_CHECKS` registry view) + * are dropped here too. The masking algorithm is still indirectly covered + * via `templateOf` since it wraps the same internal helper. + */ + +import { describe, it, expect } from 'vitest'; +import { templateOf, extractParams } from './diagnostic-record'; + +describe('diagnostic-record: templateOf (identifier masking)', () => { + it('masks single-quoted identifiers', () => { + expect(templateOf('UnknownFilter', "Variable 'foo' is undefined")).toBe( + 'Variable is undefined', + ); + }); + + it('masks double-quoted identifiers', () => { + expect(templateOf('MissingPartial', 'Cannot find "products/index"')).toBe('Cannot find '); + }); + + it('masks backticked identifiers', () => { + expect(templateOf('DeprecatedTag', 'Use `render` instead of `include`')).toBe( + 'Use instead of ', + ); + }); + + it('masks bare integers and floats', () => { + expect(templateOf('UnknownProperty', 'Line 42 column 7.5 broken')).toBe( + 'Line column broken', + ); + }); + + it('masks hex literals', () => { + expect(templateOf('UnknownProperty', 'Color #fff value 0xff is invalid')).toBe( + 'Color #fff value is invalid', + ); + }); + + it('does not chew embedded numerics inside identifiers', () => { + // "html5" stays intact (not "html") because the regex is word-anchored. + expect(templateOf('UnknownProperty', 'html5 doctype required')).toBe('html5 doctype required'); + }); + + it('collapses runs of whitespace and trims', () => { + expect(templateOf('UnknownFilter', ' foo bar ')).toBe('foo bar'); + }); + + it('returns empty string for non-string input', () => { + expect(templateOf('UnknownFilter', null as unknown as string)).toBe(''); + expect(templateOf('UnknownFilter', undefined as unknown as string)).toBe(''); + }); +}); + +describe('diagnostic-record: extractParams per check', () => { + it('UnknownFilter: pulls filter name', () => { + expect(extractParams('UnknownFilter', "Unknown filter 'json'")).toEqual({ filter: 'json' }); + }); + + it('UnknownFilter: empty when no quoted name', () => { + expect(extractParams('UnknownFilter', 'Unknown filter')).toEqual({}); + }); + + it('UndefinedObject: pulls variable name (first quoted)', () => { + expect(extractParams('UndefinedObject', "Variable 'product' is undefined")).toEqual({ + variable: 'product', + }); + }); + + it('UnusedAssign: pulls variable name', () => { + expect(extractParams('UnusedAssign', "The variable 'x' is assigned but not used")).toEqual({ + variable: 'x', + }); + }); + + it('MissingPartial: pulls partial name', () => { + expect(extractParams('MissingPartial', "'forms/login' does not exist")).toEqual({ + partial: 'forms/login', + }); + }); + + it('TranslationKeyExists: pulls key + flags typo suggestion', () => { + expect( + extractParams( + 'TranslationKeyExists', + "Translation key 'a.b.c' not found. Did you mean 'a.b.cd'?", + ), + ).toEqual({ key: 'a.b.c', has_typo_suggestion: 'true' }); + }); + + it('UnknownProperty: pulls property and object', () => { + expect(extractParams('UnknownProperty', 'Unknown property `name` on `current_user`')).toEqual({ + property: 'name', + object: 'current_user', + }); + }); + + it('DeprecatedTag: pulls tag and replacement', () => { + expect(extractParams('DeprecatedTag', "Tag 'include' is deprecated, use 'render'")).toEqual({ + tag: 'include', + replacement: 'render', + }); + }); + + it('DeprecatedTag: include defaults replacement to render', () => { + expect(extractParams('DeprecatedTag', "'include' is deprecated")).toEqual({ + tag: 'include', + replacement: 'render', + }); + }); + + it('MissingRenderPartialArguments: pulls partial + missing param', () => { + expect( + extractParams( + 'MissingRenderPartialArguments', + "Missing required argument 'email' in render tag for partial 'sessions/form'", + ), + ).toEqual({ partial: 'sessions/form', missing_param: 'email' }); + }); + + it('MetadataParamsCheck: classifies function vs render', () => { + expect(extractParams('MetadataParamsCheck', 'Missing param in function call')).toEqual({ + is_function_call: 'true', + }); + expect(extractParams('MetadataParamsCheck', 'Missing param in render tag')).toEqual({ + is_function_call: 'false', + }); + }); + + it('GraphQLCheck: unused variable', () => { + expect(extractParams('GraphQLCheck', 'Variable "$id" is never used in operation "x"')).toEqual({ + category: 'unused_variable', + variable: 'id', + }); + }); + + it('GraphQLCheck: unknown field on Record', () => { + expect(extractParams('GraphQLCheck', 'Cannot query field "name" on type "Record"')).toEqual({ + category: 'unknown_field_record', + field: 'name', + type: 'Record', + }); + }); + + it('GraphQLCheck: unknown field on other type', () => { + expect(extractParams('GraphQLCheck', 'Cannot query field "foo" on type "Bar"')).toEqual({ + category: 'unknown_field_other', + field: 'foo', + type: 'Bar', + }); + }); + + it('GraphQLCheck: type mismatch (filter)', () => { + expect( + extractParams( + 'GraphQLCheck', + 'Variable "$id" of type "ID!" used in position expecting type "UniqIdFilter"', + ), + ).toEqual({ + category: 'type_mismatch_filter', + variable: 'id', + actual_type: 'ID!', + expected_type: 'UniqIdFilter', + }); + }); + + it('GraphQLCheck: generic fallback for unrecognized format', () => { + expect(extractParams('GraphQLCheck', 'Some unknown graphql error')).toEqual({ + category: 'generic', + }); + }); + + it('returns {} for an unknown check', () => { + expect(extractParams('NotARealCheck', 'whatever')).toEqual({}); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/diagnostic-record.ts b/packages/platformos-mcp-supervisor/src/core/diagnostic-record.ts new file mode 100644 index 00000000..ea4146f0 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/diagnostic-record.ts @@ -0,0 +1,318 @@ +/** + * Diagnostic record helpers — message-template masking and per-check + * parameter extraction. + * + * In pos-supervisor this module also produced the full `DiagnosticRecord` + * shape with fingerprints (`fp`, `template_fp`) for the analytics layer. + * v1 strips that — validate_code no longer emits to a session bus and the + * downstream analytics pipeline is out of scope. What survives is the + * pair of pure helpers consumed by: + * + * - `error-enricher.ts` — calls both `extractParams` and `templateOf` + * for every diagnostic before running rules. + * - `fix-generator.ts` — calls `extractParams` to pull values out of + * LSP messages when building text edits. + * + * Stability note: every regex literal below MUST match the source + * byte-for-byte. They are pinned by the upstream LSP-message-format + * contract test (`tests/upstream/lsp-diagnostic-contract.test.js`) — drift + * here silently breaks every rule that reads `diag.params.X`. + */ + +export type ExtractedParams = Record; + +// ── Message template (identifier mask) ───────────────────────────────────── +// +// Two diagnostics with the same SHAPE but different identifiers (file +// names, variable names, translation keys) collapse to the same template +// string. The mask is intentionally minimal — we replace only the things +// that vary across instances of the same check. +// +// Substitutions, in order: +// 1. Quoted identifiers (`x`, 'x', "x") → +// 2. Bare ASCII numbers (12, 1.5, 0xff) → +// 3. Run-of-whitespace → single space +// 4. Trim leading/trailing whitespace. +// +// Case is preserved — it can be load-bearing in some checks. +function messageTemplate(message: string): string { + if (typeof message !== 'string' || message === '') return ''; + let out = message; + // Quoted strings (backtick, single, double). Stops at the next matching + // quote without crossing newlines so multi-message blobs survive. + out = out.replace(/`([^`\n]*)`/g, ''); + out = out.replace(/'([^'\n]*)'/g, ''); + out = out.replace(/"([^"\n]*)"/g, ''); + // Bare numbers (decimal, hex, float). Word-boundary anchored so we don't + // chew "v1" → "v" or "html5" → "html". + out = out.replace(/\b\d+(?:\.\d+)?\b/g, ''); + out = out.replace(/\b0x[0-9a-fA-F]+\b/g, ''); + out = out.replace(/\s+/g, ' ').trim(); + return out; +} + +// Per-check template override hook. Today the generic mask is sufficient +// for every check we ship. Wire a custom mask here if a check ever needs +// one (e.g. the LSP starts emitting timestamps inside a message). +const TEMPLATE_OVERRIDES: Readonly string>> = Object.freeze( + {}, +); + +/** + * Mask `message` into a stable template string. Two messages from the + * same check that differ only in identifiers / numbers collapse to the + * same template. The `check` argument is consulted for per-check overrides; + * with no overrides registered it is reserved for future use. + */ +export function templateOf(check: string, message: string): string { + const override = TEMPLATE_OVERRIDES[check]; + return override ? override(messageTemplate(message)) : messageTemplate(message); +} + +// ── Param extraction (typed, per check) ──────────────────────────────────── + +const QUOTED = /[`'"]([^`'"]+)[`'"]/; + +function firstQuoted(message: string): string | null { + const m = message.match(QUOTED); + return m ? m[1] : null; +} + +function pairQuoted(message: string): [string, string] | null { + // Two distinct quoted spans, in order of appearance. + const bt = message.match(/`([^`]+)`[^`]*`([^`]+)`/); + const dq = message.match(/"([^"]+)"[^"]*"([^"]+)"/); + const sq = message.match(/'([^']+)'[^']*'([^']+)'/); + const m = bt ?? dq ?? sq; + return m ? [m[1], m[2]] : null; +} + +type Extractor = (message: string) => ExtractedParams; + +const EXTRACTORS = Object.freeze({ + UnknownFilter(message: string): ExtractedParams { + const filter = firstQuoted(message); + return filter ? { filter } : {}; + }, + + UndefinedObject(message: string): ExtractedParams { + // LSP message: "The object 'foo' is undefined". Earlier the codebase + // used a dedicated extractVarName regex; we keep the same first-quoted + // contract so existing tests pass without touching the enricher yet. + const variable = firstQuoted(message); + return variable ? { variable } : {}; + }, + + UnusedAssign(message: string): ExtractedParams { + const variable = firstQuoted(message); + return variable ? { variable } : {}; + }, + + MissingPartial(message: string): ExtractedParams { + const partial = firstQuoted(message); + return partial ? { partial } : {}; + }, + + TranslationKeyExists(message: string): ExtractedParams { + const key = firstQuoted(message); + if (!key) return {}; + const params: ExtractedParams = { key }; + if (/did you mean/i.test(message)) params.has_typo_suggestion = 'true'; + return params; + }, + + UnknownProperty(message: string): ExtractedParams { + const pair = pairQuoted(message); + if (!pair) return {}; + return { property: pair[0], object: pair[1] }; + }, + + DeprecatedTag(message: string): ExtractedParams { + // Tag is the first identifier; replacement (when present) follows + // "replaced by" or "use". + const tagMatch = + message.match(/[`'"](\w+)[`'"]/) ?? message.match(/\btag\s+[`'"]?(\w+)[`'"]?/i); + const tag = tagMatch ? tagMatch[1] : null; + const replMatch = + message.match(/replaced\s+by\s+\[?[`'"](\w+)[`'"]\]?/i) ?? + message.match(/\buse\s+[`'"](\w+)[`'"]/i); + const replacement = replMatch ? replMatch[1] : tag === 'include' ? 'render' : null; + const params: ExtractedParams = {}; + if (tag) params.tag = tag; + if (replacement) params.replacement = replacement; + return params; + }, + + MissingRenderPartialArguments(message: string): ExtractedParams { + const partialMatch = message.match(/[`'"]([^`'"]+\/[^`'"]+)[`'"]/); + const paramMatch = message.match(/\bargument\s+['"`](\w+)['"`]/i); + const params: ExtractedParams = {}; + if (partialMatch) params.partial = partialMatch[1]; + if (paramMatch) params.missing_param = paramMatch[1]; + return params; + }, + + MetadataParamsCheck(message: string): ExtractedParams { + return { is_function_call: /function call/i.test(message) ? 'true' : 'false' }; + }, + + PartialCallArguments(message: string): ExtractedParams { + // Two distinct LSP message shapes: + // "Required parameter must be passed to (render|function|GraphQL) call" + // "Unknown parameter passed to (render|function|GraphQL) call" + const requiredMatch = message.match( + /^Required parameter\s+([A-Za-z_][\w]*)\s+must be passed to (\w+)\s+call/i, + ); + const unknownMatch = message.match( + /^Unknown parameter\s+([A-Za-z_][\w]*)\s+passed to (\w+)\s+call/i, + ); + const m = requiredMatch ?? unknownMatch; + if (!m) return {}; + const callKind = m[2].toLowerCase(); + return { + param_name: m[1], + direction: requiredMatch ? 'required' : 'unknown', + call_kind: callKind, // 'render' | 'function' | 'graphql' + is_function_call: callKind === 'function' ? 'true' : 'false', + }; + }, + + GraphQLVariablesCheck(message: string): ExtractedParams { + // LSP shape mirrors PartialCallArguments but always carries the + // GraphQL call kind. + const requiredMatch = message.match( + /^Required parameter\s+([A-Za-z_][\w]*)\s+must be passed to GraphQL call/i, + ); + const unknownMatch = message.match( + /^Unknown parameter\s+([A-Za-z_][\w]*)\s+passed to GraphQL call/i, + ); + const m = requiredMatch ?? unknownMatch; + if (!m) return {}; + return { + param_name: m[1], + direction: requiredMatch ? 'required' : 'unknown', + call_kind: 'graphql', + }; + }, + + UnusedDocParam(message: string): ExtractedParams { + // LSP shape: "The parameter 'name' is defined but not used in this file." + const m = message.match( + /^The parameter\s+['"`]([A-Za-z_][\w]*)['"`]\s+is defined but not used/i, + ); + return m ? { param_name: m[1] } : {}; + }, + + ValidFrontmatter(message: string): ExtractedParams { + // pos-cli 6.0.7 ships a single check that emits eight distinct shapes. + // We classify into a `category` so the rule engine can route to a + // category-specific hint without re-parsing the message itself. + if (/'home\.html\.liquid' is deprecated/i.test(message)) { + return { category: 'home_deprecated' }; + } + let m = message.match(/^Missing required frontmatter field [`'"]([^`'"]+)[`'"] in (.+?) file$/); + if (m) return { category: 'missing_required', field: m[1], file_type: m[2] }; + m = message.match(/^Unknown frontmatter field [`'"]([^`'"]+)[`'"] in (.+?) file$/); + if (m) return { category: 'unknown_field', field: m[1], file_type: m[2] }; + if (/^`layout: false`/.test(message)) return { category: 'layout_false' }; + m = message.match(/^Layout [`'"]([^`'"]+)[`'"] does not exist$/); + if (m) return { category: 'layout_missing', layout: m[1] }; + m = message.match( + /^Invalid value [`'"]([^`'"]+)[`'"] for [`'"]([^`'"]+)[`'"]\. Must be one of: (.+)$/, + ); + if (m) return { category: 'invalid_enum', value: m[1], field: m[2], allowed: m[3] }; + m = message.match(/^[`'"]([^`'"]+)[`'"] is deprecated/); + if (m) return { category: 'deprecated_field', field: m[1] }; + if (/deprecated/i.test(message)) { + // Custom deprecation messages from per-field schemas — extract the + // first quoted token as a best-effort field hint. + const f = firstQuoted(message); + return f ? { category: 'deprecated_field', field: f } : { category: 'deprecated_field' }; + } + m = message.match(/^(.+?) [`'"]([^`'"]+)[`'"] does not exist$/); + if (m) return { category: 'association_missing', label: m[1], name: m[2] }; + return { category: 'unknown' }; + }, + + JsonLiteralQuoteStyle(): ExtractedParams { + // Single-shot message — no params extracted. Returning {} keeps the + // bag JSON-safe and lets the rule engine fire on `check` alone. + return {}; + }, + + DuplicateFunctionArguments(message: string): ExtractedParams { + // "Duplicate argument 'x' in render tag for partial 'p'." + // "Duplicate argument 'x' in function tag for partial 'p'." + const m = message.match( + /^Duplicate argument [`'"]([^`'"]+)[`'"] in (\w+) tag for partial [`'"]([^`'"]+)[`'"]\.?$/, + ); + if (m) return { argument: m[1], tag_kind: m[2], partial: m[3] }; + return {}; + }, + + GraphQLCheck(message: string): ExtractedParams { + const unused = message.match(/Variable\s+["']?\$(\w+)["']?\s+is never used/i); + if (unused) return { category: 'unused_variable', variable: unused[1] }; + + const fieldMatch = message.match( + /Cannot query field\s+["']?(\w+)["']?\s+on type\s+["']?(\w+)["']?/i, + ); + if (fieldMatch) { + return { + category: fieldMatch[2] === 'Record' ? 'unknown_field_record' : 'unknown_field_other', + field: fieldMatch[1], + type: fieldMatch[2], + }; + } + + const typeMismatch = message.match( + /Variable\s+["']?\$(\w+)["']?\s+of type\s+["']?([^"']+)["']?\s+used in position expecting(?: type)?\s+["']?([^"'.]+)["']?/i, + ); + if (typeMismatch) { + const expected = typeMismatch[3].trim(); + return { + category: /filter/i.test(expected) ? 'type_mismatch_filter' : 'type_mismatch_other', + variable: typeMismatch[1], + actual_type: typeMismatch[2], + expected_type: expected, + }; + } + + const filterMatch = message.match( + /Expected value of type\s+["']?(\w+)["']?,?\s+found\s+["']?([^"'.]+)["']?/i, + ); + if (filterMatch) { + return { + category: /filter/i.test(filterMatch[1]) ? 'type_mismatch_filter' : 'type_mismatch_other', + actual_type: `"${filterMatch[2].trim()}"`, + expected_type: filterMatch[1], + }; + } + + return { category: 'generic' }; + }, +}); + +/** + * Pull check-specific parameters out of a diagnostic message. Returns an + * empty object when the check is unknown or the message doesn't match the + * check's expected shape. + * + * Values are coerced to strings so the result is JSON-safe and downstream + * consumers (enricher, fix-generator) can read them without type guards. + */ +export function extractParams(check: string, message: string): ExtractedParams { + const fn = (EXTRACTORS as Readonly>)[check]; + if (!fn) return {}; + try { + const out = fn(message ?? ''); + const safe: ExtractedParams = {}; + for (const [k, v] of Object.entries(out)) { + if (v == null) continue; + safe[k] = typeof v === 'string' ? v : String(v); + } + return safe; + } catch { + return {}; + } +} diff --git a/packages/platformos-mcp-supervisor/src/core/domain-detector.ts b/packages/platformos-mcp-supervisor/src/core/domain-detector.ts new file mode 100644 index 00000000..f46052c3 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/domain-detector.ts @@ -0,0 +1,45 @@ +import { toPosixPath } from './utils'; + +/** + * Domain key derived from a platformOS source file's path. Used to route + * domain-aware checks (gotchas, structural warnings, scorecard) to the + * correct rule set. + * + * The list mirrors `getDomainFromPath` below. Keep them in lockstep. + */ +export type Domain = + | 'commands' + | 'queries' + | 'pages' + | 'layouts' + | 'partials' + | 'graphql' + | 'schema' + | 'translations' + | 'config'; + +/** + * Map a file path to a domain key, or `null` if no domain applies. + * + * Substring matches use POSIX-style separators so Windows paths + * (`C:\…\app\views\pages\home.html.liquid`) resolve identically to Unix + * paths. Without normalisation the matches silently return `null` on Windows + * and every downstream domain-aware check sees an empty result. + * + * Match order is significant: `lib/commands/` and `lib/queries/` can live + * under `views/partials/`, so the more specific path prefixes are tested + * first. + */ +export function getDomainFromPath(absPath: string): Domain | null { + const p = toPosixPath(absPath); + if (p.includes('/lib/commands/')) return 'commands'; + if (p.includes('/lib/queries/')) return 'queries'; + if (p.includes('/views/pages/')) return 'pages'; + if (p.includes('/views/layouts/')) return 'layouts'; + if (p.includes('/views/partials/')) return 'partials'; + if (p.includes('/app/graphql/') || p.includes('/graphql/')) return 'graphql'; + if (p.includes('/schema/')) return 'schema'; + if (p.includes('/translations/')) return 'translations'; + if (/\/app\/config\.yml$/.test(p)) return 'config'; + return null; +} diff --git a/packages/platformos-mcp-supervisor/src/core/error-enricher-bridge.spec.ts b/packages/platformos-mcp-supervisor/src/core/error-enricher-bridge.spec.ts new file mode 100644 index 00000000..21f26deb --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/error-enricher-bridge.spec.ts @@ -0,0 +1,173 @@ +/** + * Bridge rules onto late-push diagnostics. + * + * Structural warnings, schema validators, diff-aware checks, and the + * new-partial caller check are pushed into `result.errors/warnings` AFTER + * `enrichAll` returns. Their rule modules never fire unless something runs + * the engine on them again. `bridgeRulesOntoUnattributed` is that bridge. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { clearRules, registerRule, registerRules } from './rules/engine'; +import { + bridgeRulesOntoUnattributed, + type EnrichContext, + type BridgeResult, +} from './error-enricher'; +import { rules as NonGetRenderingPageRules } from './rules/NonGetRenderingPage'; +import { buildFactGraph } from './project-fact-graph'; +import type { FiltersIndex } from './filters-index'; +import type { ObjectsIndex } from './objects-index'; +import type { TagsIndex } from './tags-index'; +import type { ProjectMap } from './project-scanner'; + +function resetEngine() { + // `clearRules()` resets both the registry AND the force-disable set — + // matches the destination engine's documented "safe default" semantics. + clearRules(); +} + +beforeEach(resetEngine); +afterEach(resetEngine); + +function emptyProjectMap(): ProjectMap { + return { + project: { directory: '/tmp/empty', environments: [], modules: [], has_config: false }, + pages: {}, + partials: {}, + commands: {}, + queries: {}, + graphql: {}, + schema: {}, + layouts: {}, + translations: {}, + assets: [], + summary: { + file_counts: {}, + resources: {}, + }, + }; +} + +const ctx: EnrichContext = { + uri: 'file:///tmp/x.liquid', + filePath: 'app/views/pages/x.liquid', + content: '', + factGraph: buildFactGraph(emptyProjectMap()), + filtersIndex: { + loaded: true, + lookup: () => null, + closestMatch: () => null, + } as unknown as FiltersIndex, + objectsIndex: { loaded: true, lookup: () => null } as unknown as ObjectsIndex, + tagsIndex: { isTag: () => false } as unknown as TagsIndex, +}; + +describe('bridgeRulesOntoUnattributed', () => { + test('applies registered rule to a structural diagnostic with no prior rule_id', () => { + registerRules(NonGetRenderingPageRules); + const result: BridgeResult = { + errors: [], + warnings: [ + { + check: 'pos-supervisor:NonGetRenderingPage', + severity: 'warning', + message: + 'Page has `method: post` but renders HTML (layout, partials, or `{{ ... }}` output).', + line: 1, + }, + ], + infos: [], + }; + bridgeRulesOntoUnattributed(result, ctx); + const w = result.warnings[0]!; + expect(w.rule_id).toBe('NonGetRenderingPage.html_on_post'); + expect(w.confidence).toBe(0.9); + expect(w.hint).toMatch(/method: post/i); + }); + + test('skips diagnostics that already carry a rule_id (idempotent)', () => { + registerRules(NonGetRenderingPageRules); + const result: BridgeResult = { + errors: [], + warnings: [ + { + check: 'pos-supervisor:NonGetRenderingPage', + severity: 'warning', + message: 'already stamped', + rule_id: 'explicit.override', + hint: 'explicit hint', + }, + ], + infos: [], + }; + bridgeRulesOntoUnattributed(result, ctx); + expect(result.warnings[0]!.rule_id).toBe('explicit.override'); + expect(result.warnings[0]!.hint).toBe('explicit hint'); + }); + + test('no-op when check has no registered rule module', () => { + const result: BridgeResult = { + errors: [], + warnings: [ + { check: 'pos-supervisor:SomeCheckWithNoRule', severity: 'warning', message: '...' }, + ], + infos: [], + }; + bridgeRulesOntoUnattributed(result, ctx); + expect(result.warnings[0]!.rule_id).toBeUndefined(); + }); + + test('no-op when factGraph is missing (guard against partial boot)', () => { + registerRules(NonGetRenderingPageRules); + const result: BridgeResult = { + errors: [], + warnings: [ + { check: 'pos-supervisor:NonGetRenderingPage', severity: 'warning', message: '...' }, + ], + infos: [], + }; + bridgeRulesOntoUnattributed(result, { ...ctx, factGraph: undefined }); + expect(result.warnings[0]!.rule_id).toBeUndefined(); + }); + + test('applies to errors and infos too, not just warnings', () => { + registerRule({ + id: 'SampleRule.default', + check: 'SampleCheck', + priority: 100, + when: () => true, + apply: () => ({ rule_id: 'SampleRule.default', hint_md: 'hi', fixes: [], confidence: 0.5 }), + }); + const result: BridgeResult = { + errors: [{ check: 'SampleCheck', severity: 'error', message: 'boom' }], + warnings: [{ check: 'SampleCheck', severity: 'warning', message: 'boom' }], + infos: [{ check: 'SampleCheck', severity: 'info', message: 'boom' }], + }; + bridgeRulesOntoUnattributed(result, ctx); + expect(result.errors[0]!.rule_id).toBe('SampleRule.default'); + expect(result.warnings[0]!.rule_id).toBe('SampleRule.default'); + expect(result.infos[0]!.rule_id).toBe('SampleRule.default'); + }); + + test('rule that throws does not crash the bridge (non-fatal)', () => { + registerRule({ + id: 'Explosive.default', + check: 'Explosive', + priority: 100, + when: () => true, + apply: () => { + throw new Error('boom'); + }, + }); + const result: BridgeResult = { + errors: [], + warnings: [{ check: 'Explosive', severity: 'warning', message: '...' }], + infos: [], + }; + // Must not throw. + bridgeRulesOntoUnattributed(result, ctx); + // Diagnostic stays unattributed — safer than half-attributed. + expect(result.warnings[0]!.rule_id).toBeUndefined(); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/error-enricher.spec.ts b/packages/platformos-mcp-supervisor/src/core/error-enricher.spec.ts new file mode 100644 index 00000000..9b742eec --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/error-enricher.spec.ts @@ -0,0 +1,383 @@ +/** + * Per-check enrichment pins. The enricher composes: + * - check-name → hint template (with `{{var}}` placeholders resolved) + * - per-check suggestion text (LSP / docset-driven) + * - rule-engine attributions (rule_id, confidence, hover_docs) + * + * These tests are pure-function unit-grade: no LSP, no project map, no + * docset fetch. Indexes are constructed via plain object stubs typed against + * the public surface of each index class (no `private` field access — source + * tests poked at `_loaded` / `_byName`, which TS rejects in v1). + */ + +import { describe, it, expect } from 'vitest'; +import { enrichError, enrichAll } from './error-enricher'; +import type { FiltersIndex } from './filters-index'; +import type { ObjectsIndex } from './objects-index'; +import type { TagsIndex } from './tags-index'; + +function stubFiltersIndex( + entries: Record, +): FiltersIndex { + const map = new Map(Object.entries(entries)); + return { + loaded: true, + lookup: (name: string | null | undefined) => { + if (!name) return null; + const e = map.get(name); + return e + ? { + name, + category: '', + syntax: e.syntax ?? '', + summary: e.summary ?? '', + parameters: [], + platformOS: false, + deprecated: false, + } + : null; + }, + closestMatch: (name: string | null | undefined) => { + if (!name) return null; + // Tiny Levenshtein-ish: return first registered name whose lowercase + // shares a prefix or is one edit away. Sufficient for "jsn" → "json". + const target = name.toLowerCase(); + let best: { name: string; syntax: string; summary: string } | null = null; + for (const [k, v] of map) { + const kl = k.toLowerCase(); + if (kl === target) continue; + if (kl.startsWith(target.slice(0, 2)) || kl.includes(target)) { + if (!best) best = { name: k, syntax: v.syntax ?? '', summary: v.summary ?? '' }; + } + } + return best + ? { + name: best.name, + category: '', + syntax: best.syntax, + summary: best.summary, + parameters: [], + platformOS: false, + deprecated: false, + } + : null; + }, + } as unknown as FiltersIndex; +} + +function stubObjectsIndex( + entries: Record, +): ObjectsIndex { + const map = new Map(Object.entries(entries)); + return { + loaded: true, + lookup: (name: string | null | undefined) => { + if (!name) return null; + const e = map.get(name); + if (!e) return null; + if (!e.handle || e.handle === name) return null; + return { name, handle: e.handle, properties: e.properties }; + }, + } as unknown as ObjectsIndex; +} + +function stubTagsIndex(names: string[]): TagsIndex { + const set = new Set(names); + return { + isTag: (n: string | null | undefined) => !!n && set.has(n), + } as unknown as TagsIndex; +} + +describe('enrichError', () => { + it('adds hint for known check name', async () => { + const diagnostic = { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "params" used.', + }; + const result = await enrichError(diagnostic, { uri: 'file:///test.liquid' }); + expect(result.hint).toBeDefined(); + expect(result.hint).toContain('context'); + }); + + it('returns null hint for unknown check name', async () => { + const diagnostic = { + check: 'NonExistentCheck', + severity: 'error' as const, + message: 'Something went wrong', + }; + const result = await enrichError(diagnostic, { uri: 'file:///test.liquid' }); + expect(result.hint).toBeNull(); + }); + + it('adds variant hint for UndefinedObject in partials', async () => { + const diagnostic = { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "product" used.', + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/partials/card.liquid', + }); + expect(result.hint).toBeDefined(); + }); + + it('enriches UnknownFilter with closest match from index', async () => { + const filtersIndex = stubFiltersIndex({ + json: { syntax: '{{ obj | json }}', summary: 'Convert to JSON' }, + jsonify: { syntax: '{{ obj | jsonify }}', summary: 'JSON encode' }, + }); + const diagnostic = { + check: 'UnknownFilter', + severity: 'error' as const, + message: 'Unknown filter `jsn` used.', + }; + const result = await enrichError(diagnostic, { uri: 'file:///test.liquid', filtersIndex }); + expect(result.suggestion).toBeDefined(); + expect(result.suggestion).toContain('json'); + }); + + it('enriches UndefinedObject with context suggestion from index', async () => { + const objectsIndex = stubObjectsIndex({ + params: { handle: 'context.params', properties: ['slug', 'format', 'id'] }, + }); + const diagnostic = { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "params" used.', + }; + const result = await enrichError(diagnostic, { uri: 'file:///test.liquid', objectsIndex }); + expect(result.suggestion).toBeDefined(); + expect(result.suggestion).toContain('context.params'); + }); + + it('detects tag-used-as-filter mistake', async () => { + const filtersIndex = stubFiltersIndex({}); + const tagsIndex = stubTagsIndex(['background']); + const diagnostic = { + check: 'UnknownFilter', + severity: 'error' as const, + message: 'Unknown filter `background` used.', + }; + const result = await enrichError(diagnostic, { + uri: 'file:///test.liquid', + filtersIndex, + tagsIndex, + }); + expect(result.suggestion).toContain('tag, not a filter'); + expect(result.suggestion).toContain('{% background'); + }); +}); + +describe('MissingPartial hint template resolution', () => { + it('resolves {{object}}, {{name}}, {{create_path}}, {{tag}} for a missing partial', async () => { + const diagnostic = { + check: 'MissingPartial', + severity: 'error' as const, + message: "Missing partial 'blog_posts/indexa'", + line: 3, + column: 3, + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/index.html.liquid', + content: "---\nslug: test\n---\n{% render 'blog_posts/indexa' %}", + }); + expect(result.hint).toBeDefined(); + expect(result.hint).toContain('partial'); + expect(result.hint).toContain('blog_posts/indexa'); + expect(result.hint).toContain('app/views/partials/blog_posts/indexa.liquid'); + expect(result.hint).toContain('render'); + expect(result.hint).not.toContain('{{'); + }); + + it('detects command type and resolves correct path', async () => { + const diagnostic = { + check: 'MissingPartial', + severity: 'error' as const, + message: "Missing partial 'commands/products/create'", + line: 3, + column: 3, + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/test.html.liquid', + content: + "---\nslug: test\n---\n{% function result = 'commands/products/create', params: context.params %}", + }); + expect(result.hint).toContain('command'); + expect(result.hint).toContain('app/lib/commands/products/create.liquid'); + expect(result.hint).toContain('function'); + expect(result.hint).not.toContain('{{'); + }); + + it('detects query type and resolves correct path', async () => { + const diagnostic = { + check: 'MissingPartial', + severity: 'error' as const, + message: "Missing partial 'queries/products/search'", + line: 3, + column: 3, + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/test.html.liquid', + content: + "---\nslug: test\n---\n{% function result = 'queries/products/search', query_params: context.params %}", + }); + expect(result.hint).toContain('query'); + expect(result.hint).toContain('app/lib/queries/products/search.liquid'); + expect(result.hint).toContain('function'); + expect(result.hint).not.toContain('{{'); + }); + + it('flags `lib/` prefix as invalid and points at the corrected path', async () => { + const diagnostic = { + check: 'MissingPartial', + severity: 'error' as const, + message: "'lib/commands/products/create' does not exist", + line: 3, + column: 3, + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/test.html.liquid', + content: + "---\nslug: test\n---\n{% function result = 'lib/commands/products/create', params: context.params %}", + }); + expect(result.hint).toContain('lib/commands/products/create'); + expect(result.hint).toContain('commands/products/create'); + expect(result.hint).toContain('app/lib/commands/products/create.liquid'); + expect(result.hint).not.toMatch(/STEP 2 — Create/); + expect(result.hint).toMatch(/lib\/[^\s]+ is not a valid path|drop the `lib\/` prefix/i); + expect(result.hint).not.toContain('{{'); + }); + + it('uses module variant hint for module paths — references project_map, no create path', async () => { + const diagnostic = { + check: 'MissingPartial', + severity: 'error' as const, + message: "Missing partial 'modules/payments/helpers/format_price'", + line: 3, + column: 3, + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/test.html.liquid', + content: "---\nslug: test\n---\n{% render 'modules/payments/helpers/format_price' %}", + }); + expect(result.hint).toContain('modules/payments/helpers/format_price'); + expect(result.hint).toContain('project_map'); + expect(result.hint).not.toContain('Create'); + expect(result.hint).not.toMatch(/install (the )?module/); + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); +}); + +describe('UndefinedObject hint template resolution', () => { + it('resolves {{var_name}} in page context', async () => { + const diagnostic = { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "product" used.', + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/index.html.liquid', + }); + expect(result.hint).toBeDefined(); + expect(result.hint).toContain('product'); + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); + + it('resolves {{var_name}} in partial variant', async () => { + const diagnostic = { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "title" used.', + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/partials/card.html.liquid', + }); + expect(result.hint).toBeDefined(); + expect(result.hint).toContain('title'); + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); +}); + +describe('TranslationKeyExists hint template resolution', () => { + it('resolves {{key}}, {{yaml_snippet}}, {{yaml_path_comment}} for a scoped key', async () => { + const diagnostic = { + check: 'TranslationKeyExists', + severity: 'error' as const, + message: "Translation key 'products.create.title' not found.", + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/products.html.liquid', + }); + expect(result.hint).toBeDefined(); + expect(result.hint).toContain('products.create.title'); + expect(result.hint).toContain('products > create > title'); + expect(result.hint).toContain('en:'); + expect(result.hint).toContain('products:'); + expect(result.hint).toContain('create:'); + expect(result.hint).toContain('title:'); + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); + + it('resolves {{key}} and generates flat yaml snippet for top-level key', async () => { + const diagnostic = { + check: 'TranslationKeyExists', + severity: 'error' as const, + message: "Translation key 'welcome' not found.", + }; + const result = await enrichError(diagnostic, { + uri: 'file:///app/views/pages/index.html.liquid', + }); + expect(result.hint).toContain('welcome'); + expect(result.hint).toContain('en:'); + expect(result.hint).not.toContain(' > '); // flat key has no path separator + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); +}); + +describe('enrichAll', () => { + it('enriches multiple diagnostics', async () => { + const diagnostics = [ + { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "params" used.', + }, + { check: 'UnknownFilter', severity: 'error' as const, message: 'Unknown filter "bad" used.' }, + ]; + const results = await enrichAll(diagnostics, { uri: 'file:///test.liquid' }); + expect(results).toHaveLength(2); + expect(results[0]!.hint).toBeDefined(); + expect(results[1]!.hint).toBeDefined(); + }); +}); + +describe('conditional hint rendering', () => { + it('resolves {{#if has_suggestion}} conditional in UndefinedObject hint', async () => { + const objectsIndex = stubObjectsIndex({ + params: { handle: 'context.params', properties: ['slug', 'id'] }, + }); + const diagnostic = { + check: 'UndefinedObject', + severity: 'warning' as const, + message: 'Unknown object "params" used.', + }; + const result = await enrichError(diagnostic, { uri: 'file:///test.liquid', objectsIndex }); + expect(result.hint).toContain('APPLY'); // has_suggestion branch + expect(result.hint).not.toContain('NO suggestion'); + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); + + it('resolves {{filter_name}} in UnknownFilter hint', async () => { + const filtersIndex = stubFiltersIndex({}); + const diagnostic = { + check: 'UnknownFilter', + severity: 'error' as const, + message: 'Unknown filter `badfilter` used.', + }; + const result = await enrichError(diagnostic, { uri: 'file:///test.liquid', filtersIndex }); + expect(result.hint).toBeDefined(); + expect(result.hint).toContain('badfilter'); + expect(result.hint).not.toMatch(/\{\{[a-z_]+\}\}/); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/error-enricher.ts b/packages/platformos-mcp-supervisor/src/core/error-enricher.ts new file mode 100644 index 00000000..f6bfa491 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/error-enricher.ts @@ -0,0 +1,807 @@ +/** + * Synchronous diagnostic enrichment. + * + * Every LSP diagnostic passes through `enrichAll` before the pipeline + * runs. For each diagnostic: + * + * 1. Hover-fetch the LSP at the diagnostic position (cached per file). + * 2. If a rule module is registered for `diag.check` and a fact graph + * is available, run rules — first match wins, its output is copied + * onto the diagnostic, and the per-check regex fallback is skipped. + * 3. Otherwise apply per-check regex enrichment: extract identifiers + * via `extractParams`, look them up against the docset / Shopify + * lists, render the matching hint template. + * 4. Always run `attachSeeAlso` to bolt on a structured route hint. + * + * `bridgeRulesOntoUnattributed` re-runs step (2) over diagnostics that + * were pushed into `result` AFTER `enrichAll` finished (structural + * warnings, schema/translation validators, diff-aware `RemovedRender` / + * `AddedParam`, new-partial caller check). Idempotent — diagnostics that + * already carry a `rule_id` are skipped. + * + * v1 trim: no `analyticsStore` and no `case_base_signal`. The adaptive + * engine is out of v1 scope; rules return their raw output and the + * enricher copies it without scoring. SchemaIndex is also absent (its + * sole in-scope consumers were dropped in P7). + */ + +import type { Hover } from 'vscode-languageserver-protocol'; + +import type { PlatformOSLSPClient } from './lsp-client'; +import type { ProjectFactGraph } from './project-fact-graph'; +import type { FiltersIndex } from './filters-index'; +import type { ObjectsIndex } from './objects-index'; +import type { TagsIndex } from './tags-index'; +import type { RuleFacts, RuleFix, SeeAlso } from './rules/engine'; + +import { getHint } from './hint-loader'; +import { extractParams, templateOf } from './diagnostic-record'; +import { + isShopifyObject, + isShopifyFilter, + getShopifyObject, + getShopifyFilter, +} from './knowledge-loader'; +import { runRules, hasRules } from './rules/engine'; + +// ── Public types ─────────────────────────────────────────────────────────── + +export interface EnrichContext { + /** File URI used for LSP hover / completion requests. */ + uri: string; + lsp?: PlatformOSLSPClient; + filtersIndex?: FiltersIndex; + objectsIndex?: ObjectsIndex; + tagsIndex?: TagsIndex; + /** Raw file content under analysis (used by rules + completion lookup). */ + content?: string; + /** Project fact graph — when present, rules fire ahead of regex fallback. */ + factGraph?: ProjectFactGraph; + /** Repo-relative path of the file under analysis. */ + filePath?: string; + /** Absolute project root. */ + projectDir?: string; + /** Internal: hover cache populated by `enrichAll`. Not part of the public surface. */ + _hoverCache?: HoverCache; +} + +export interface EnrichedDiagnostic { + check: string; + severity: 'error' | 'warning' | 'info'; + message: string; + line?: number; + column?: number; + endLine?: number | null; + endColumn?: number | null; + _filePath?: string; + + hint?: string | null; + suggestion?: string; + rule_id?: string; + confidence?: number; + see_also?: SeeAlso; + fixes?: RuleFix[]; + hover_docs?: string; + + /** Rules may attach arbitrary keys; surfaced for downstream consumers. */ + [key: string]: unknown; +} + +/** Shape consumed by `bridgeRulesOntoUnattributed`. */ +export interface BridgeResult { + errors: EnrichedDiagnostic[]; + warnings: EnrichedDiagnostic[]; + infos: EnrichedDiagnostic[]; +} + +// ── Hover helpers ────────────────────────────────────────────────────────── + +type HoverCache = Map; + +/** + * Extract human-readable text from an LSP `Hover` result. Returns `null` + * for empty hovers, malformed shapes, and the explicit-null cache hit. + */ +function extractHoverText(result: Hover | null | undefined): string | null { + if (!result?.contents) return null; + const c = result.contents; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + return c.map((x) => (typeof x === 'string' ? x : (x?.value ?? ''))).join('\n\n'); + } + if (typeof c === 'object' && 'value' in c && typeof c.value === 'string') { + return c.value; + } + return null; +} + +// ── enrichError — per-diagnostic enrichment ──────────────────────────────── + +/** + * Enrich a single diagnostic with hint, LSP hover, index suggestions, and + * an optional rule-engine result. Returns a NEW object — never mutates + * the input. + */ +export async function enrichError( + diagnostic: EnrichedDiagnostic, + ctx: EnrichContext, +): Promise { + const { + uri, + lsp, + filtersIndex, + objectsIndex, + tagsIndex, + content, + _hoverCache, + factGraph, + filePath, + projectDir, + } = ctx; + + const result: EnrichedDiagnostic = { ...diagnostic }; + + // 1. Hint stays null until a per-check branch (or rule) sets it. A + // fallback at the bottom assigns the generic hint for any check that + // didn't match a specialised branch. + result.hint = null; + + // 2. LSP hover at the diagnostic position. Read from the per-position + // cache that `enrichAll` populated; fall back to a direct call when + // the cache is absent (single-diagnostic callers). + if (diagnostic.line != null) { + const posKey = `${diagnostic.line}:${diagnostic.column ?? 0}`; + if (_hoverCache?.has(posKey)) { + const cached = _hoverCache.get(posKey); + if (cached) result.hover_docs = cached; + } else if (lsp?.initialized) { + try { + const hover = await lsp.hover(uri, diagnostic.line, diagnostic.column ?? 0); + const text = extractHoverText(hover); + if (text) result.hover_docs = text; + } catch { + // LSP hover failed — skip + } + } + } + + // 2b. Rule engine — when rules exist for this check and a fact graph + // is available, run rules first. If a rule matches, its output + // wins and the per-check regex fallback is skipped. + if (factGraph && hasRules(diagnostic.check)) { + const params = extractParams(diagnostic.check, diagnostic.message); + const tmplFp = templateOf(diagnostic.check, diagnostic.message); + const diagForRule = { + check: diagnostic.check, + params, + message: diagnostic.message, + file: filePath, + line: diagnostic.line, + column: diagnostic.column ?? 0, + template_fp: tmplFp, + }; + // `content` is threaded into facts so rules can detect in-memory + // patterns the project graph does not yet reflect (e.g. multi-line + // graphql calls inside a {% liquid %} block that the upstream LSP + // truncates — see GraphQLVariablesCheck.parser_blind_spot). + const facts: RuleFacts = { + graph: factGraph, + filtersIndex, + objectsIndex, + tagsIndex, + projectDir, + content, + }; + const ruleResult = runRules(diagForRule, facts); + if (ruleResult) { + if (ruleResult.hint_md) result.hint = ruleResult.hint_md; + result.rule_id = ruleResult.rule_id; + if (ruleResult.suggestion) result.suggestion = ruleResult.suggestion; + if (ruleResult.see_also) result.see_also = ruleResult.see_also; + if (ruleResult.confidence != null) result.confidence = ruleResult.confidence; + if (ruleResult.fixes && ruleResult.fixes.length > 0) result.fixes = ruleResult.fixes; + attachSeeAlso(result, content); + return result; + } + } + + // 3. Per-check regex enrichment fallback. + + if (diagnostic.check === 'UnknownFilter') { + const filterName = extractParams(diagnostic.check, diagnostic.message).filter ?? null; + let suggestion: string | null = null; + if (filterName) { + if (tagsIndex?.isTag(filterName)) { + suggestion = `\`${filterName}\` is a tag, not a filter. Use \`{% ${filterName} ... %}\` instead of \`| ${filterName}\`.`; + } else if (isShopifyFilter(filterName)) { + const info = getShopifyFilter(filterName); + suggestion = info?.replacement + ? `\`${filterName}\` is a Shopify filter — not in platformOS. Use \`${info.replacement}\` instead.${info.note ? ` ${info.note}` : ''}` + : `\`${filterName}\` is a Shopify-specific filter — not in platformOS.${info?.note ? ` ${info.note}` : ''}`; + } else if (filtersIndex?.loaded) { + const exact = filtersIndex.lookup(filterName); + if (exact) { + suggestion = `Filter \`${exact.name}\` exists: ${exact.syntax || exact.summary}`; + } else { + const closest = filtersIndex.closestMatch(filterName); + if (closest) { + suggestion = `Did you mean \`${closest.name}\`? ${closest.syntax || closest.summary}`; + } + } + } + } + if (suggestion) result.suggestion = suggestion; + result.hint = filterName + ? getHint(diagnostic.check, null, { + filter_name: filterName, + has_suggestion: !!suggestion, + suggestion: suggestion ?? '', + }) + : getHint(diagnostic.check, null); + } + + if (diagnostic.check === 'UndefinedObject') { + const varName = extractParams(diagnostic.check, diagnostic.message).variable ?? null; + const isPartial = uri?.includes('/partials/') ?? false; + let suggestion: string | null = null; + let isShopify = false; + if (varName) { + if (isShopifyObject(varName)) { + isShopify = true; + const info = getShopifyObject(varName); + suggestion = info?.replacement + ? `\`${varName}\` is a Shopify object. Use: \`${info.replacement}\`${info.note ? ` — ${info.note}` : ''}` + : `\`${varName}\` is a Shopify theme object — not in platformOS.${info?.note ? ` ${info.note}` : ' Use GraphQL queries to fetch data and `context.*` for request/user data.'}`; + } else if (objectsIndex?.loaded) { + const obj = objectsIndex.lookup(varName); + if (obj) { + suggestion = `Use \`${obj.handle}\` instead of bare \`${varName}\`. Properties: ${obj.properties.slice(0, 5).join(', ')}`; + } + } + } + if (suggestion) result.suggestion = suggestion; + // Pick variant: Shopify objects get a dedicated hint (never "declare + // as @param"), partials get the partial variant, pages get default. + const objVariant = isShopify ? 'shopify' : isPartial ? 'partial' : null; + result.hint = varName + ? getHint(diagnostic.check, objVariant, { + var_name: varName, + has_suggestion: !!suggestion, + suggestion: suggestion ?? '', + }) + : getHint(diagnostic.check, isPartial ? 'partial' : null); + } + + if (diagnostic.check === 'GraphQLCheck') { + const vars = classifyGraphQLError(diagnostic.message); + result.hint = getHint(diagnostic.check, null, vars); + } + + if (diagnostic.check === 'TranslationKeyExists') { + const tp = extractParams(diagnostic.check, diagnostic.message); + const key = tp.key ?? null; + const hasSuggestion = tp.has_typo_suggestion === 'true'; + if (key) { + result.hint = getHint(diagnostic.check, null, { + key, + yaml_snippet: buildYamlSnippet(key), + yaml_path_comment: key.split('.').join(' > '), + has_suggestion: hasSuggestion, + }); + } + } + + if (diagnostic.check === 'MissingPartial') { + const partialName = extractParams(diagnostic.check, diagnostic.message).partial ?? null; + const objType = detectObjectType(partialName); + const createPath = buildCreatePath(objType, partialName); + const tag = objType === 'partial' ? 'render' : 'function'; + let hintVariant: string | null = null; + if (objType === 'module') hintVariant = 'module'; + else if (objType === 'invalid_lib_prefix') hintVariant = 'invalid_lib_prefix'; + + // For module paths: fetch LSP completions to show available paths. + // For project paths the agent has project_map context, no completions needed. + let suggestion: string | null = null; + if ( + objType === 'module' && + partialName && + lsp?.initialized && + content && + diagnostic.line != null + ) { + const lines = content.split('\n'); + const lineContent = lines[diagnostic.line] ?? ''; + const squoteIdx = lineContent.indexOf(`'${partialName}'`); + const dquoteIdx = lineContent.indexOf(`"${partialName}"`); + const quoteIdx = squoteIdx >= 0 ? squoteIdx : dquoteIdx; + if (quoteIdx >= 0) { + const col = quoteIdx + 1; + try { + const completionResult = await lsp.completions(uri, diagnostic.line, col); + const labels = extractCompletionLabels(completionResult); + if (labels.length > 0) { + const moduleParts = partialName.split('/'); + const modulePrefix = + moduleParts.length >= 2 ? `${moduleParts[0]}/${moduleParts[1]}/` : ''; + const inSameModule = modulePrefix + ? labels.filter((l) => l.startsWith(modulePrefix)) + : []; + // Suggest only paths from the SAME module — don't fall back to + // unrelated project paths. + if (inSameModule.length > 0) { + const filtered = inSameModule.slice(0, 8); + suggestion = `'${partialName}' not found in module. Available: ${filtered.join(', ')}`; + } + } + } catch { + // LSP completions failed — no suggestions + } + } + } + if (suggestion) result.suggestion = suggestion; + + const correctedName = + objType === 'invalid_lib_prefix' && partialName ? partialName.slice('lib/'.length) : null; + result.hint = partialName + ? getHint(diagnostic.check, hintVariant, { + object: objType, + name: partialName, + create_path: createPath, + tag, + has_suggestion: !!suggestion, + ...(correctedName ? { corrected_name: correctedName } : {}), + }) + : getHint(diagnostic.check, hintVariant); + } + + if (diagnostic.check === 'UnknownProperty') { + const { property: propertyName, object: objectName } = extractParams( + diagnostic.check, + diagnostic.message, + ); + const propVariant = uri?.includes('/partials/') ? 'partial' : null; + result.hint = + propertyName && objectName + ? getHint(diagnostic.check, propVariant, { + property_name: propertyName, + object_name: objectName, + }) + : getHint(diagnostic.check, propVariant); + } + + if (diagnostic.check === 'DeprecatedTag') { + const { tag: tagName, replacement: replacementTag } = extractParams( + diagnostic.check, + diagnostic.message, + ); + result.hint = tagName + ? getHint(diagnostic.check, null, { + tag_name: tagName, + replacement_tag: replacementTag ?? '', + }) + : getHint(diagnostic.check, null); + + // Override the LSP message — it reads as if the tag is still usable + // ("Invalid syntax for tag 'X'. Expected syntax: ..."). Replace with + // an explicit deprecation message so the hint doesn't contradict it. + if (tagName && /Expected syntax/i.test(diagnostic.message)) { + const REPLACEMENTS: Record = { + hash_assign: + '{% assign hash["key"] = "value" %} (platformOS assign supports bracket/dot notation)', + parse_json: '{% assign obj = { "key": "value" } %} (use assign with hash/array literals)', + include: + "{% render 'partial', var: value %} (render has isolated scope — pass all variables explicitly)", + }; + const replacement = REPLACEMENTS[tagName]; + result.message = replacement + ? `'{% ${tagName} %}' is deprecated and will be removed. Replace with: ${replacement}.` + : `'{% ${tagName} %}' is deprecated and will be removed.`; + } + } + + if (diagnostic.check === 'MissingRenderPartialArguments') { + const { partial: partialName, missing_param: missingParam } = extractParams( + diagnostic.check, + diagnostic.message, + ); + result.hint = + partialName || missingParam + ? getHint(diagnostic.check, null, { + partial_name: partialName ?? 'unknown', + missing_param: missingParam ?? 'unknown', + }) + : getHint(diagnostic.check, null); + } + + if (diagnostic.check === 'MetadataParamsCheck') { + // Distinguish function calls (queries/commands) from render calls. + const isFunctionCall = /function call/i.test(diagnostic.message); + result.hint = getHint(diagnostic.check, null, { + is_function_call: isFunctionCall, + }); + } + + if (diagnostic.check === 'UnusedAssign') { + const varName = extractParams(diagnostic.check, diagnostic.message).variable ?? null; + result.hint = varName + ? getHint(diagnostic.check, null, { var_name: varName }) + : getHint(diagnostic.check, null); + } + + // Fallback hint for checks without a specific enrichment block. + if (result.hint === null) { + result.hint = getHint(diagnostic.check, null); + } + + // Always run the see-also routing. + attachSeeAlso(result, content); + + return result; +} + +// ── attachSeeAlso — structured "go read X" routing ───────────────────────── + +/** + * Attach a structured `see_also` field pointing at the authoritative + * source for this diagnostic — usually a `domain_guide` call, + * occasionally `module_info`. MUST be a structured object (tool + args), + * NOT embedded prose, because agents react reliably to fields and ignore + * prose advice. + * + * Mutates `diagnostic` in place. No-op when `see_also` is already set. + */ +export function attachSeeAlso(diagnostic: EnrichedDiagnostic, content?: string): void { + if (!diagnostic || typeof diagnostic !== 'object') return; + if (diagnostic.see_also) return; // never overwrite an explicit route + + const check = diagnostic.check; + const message = diagnostic.message ?? ''; + + // Shopify contamination → partials gotchas. Identified either by + // suggestion text mentioning Shopify or by the UndefinedObject-shopify + // hint variant. Either signal means the diagnostic is about a + // Shopify-ism in otherwise valid Liquid. + if (check === 'UndefinedObject' && /shopify/i.test(diagnostic.suggestion ?? '')) { + diagnostic.see_also = { + tool: 'domain_guide', + args: { domain: 'partials', section: 'gotchas' }, + reason: + 'Shopify object detected — platformOS uses context.* objects, not shop/cart/customer/product/collection. ' + + 'domain_guide(partials, gotchas) lists every deprecated/forbidden identifier.', + }; + return; + } + + // Deprecated {% include %} tag. + if (check === 'DeprecatedTag' && /include/i.test(message)) { + diagnostic.see_also = { + tool: 'domain_guide', + args: { domain: 'partials', section: 'gotchas' }, + reason: + '{% include %} is deprecated in platformOS. Use {% render %} for isolated scope or {% function %} for module helpers. ' + + 'domain_guide(partials, gotchas) has the full deprecated-tag list.', + }; + return; + } + + // Missing module partial — route to module_info for the authoritative call path. + if (check === 'MissingPartial') { + const nameMatch = message.match(/['"]([^'"]+)['"]/); + const partialName = nameMatch?.[1]; + if (partialName?.startsWith('modules/')) { + const moduleName = partialName.split('/')[1]; + diagnostic.see_also = { + tool: 'module_info', + args: { name: moduleName, section: 'api' }, + reason: `Module partial '${partialName}' not found. module_info(${moduleName}, api) returns the live-scanned call paths and signatures from the installed module — stale memory of module paths is the #1 reason for this error.`, + }; + return; + } + } + + // Missing param on render/function — route to the relevant domain API section. + // forms/* partials use form-specific conventions documented in the forms + // guide; everything else falls back to the partials guide. + if (check === 'MetadataParamsCheck' || check === 'MissingRenderPartialArguments') { + const isFormPartial = + /['"]modules\/common-styling\/forms\//.test(content ?? '') || + /['"][^'"]*forms\/[^'"]+['"]/.test(content ?? ''); + diagnostic.see_also = { + tool: 'domain_guide', + args: { domain: isFormPartial ? 'forms' : 'partials', section: 'api' }, + reason: isFormPartial + ? 'Form partial call is missing required params. domain_guide(forms, api) lists every form helper signature (error_list, error_input_handler, etc.).' + : 'Render call is missing required params. domain_guide(partials, api) explains how {% doc %} @param declarations interact with render and function calls.', + }; + return; + } + + // Missing translation key — route to translations gotchas. + if (check === 'TranslationKeyExists') { + diagnostic.see_also = { + tool: 'domain_guide', + args: { domain: 'translations', section: 'gotchas' }, + reason: + 'Translation key not found. domain_guide(translations, gotchas) covers locale nesting, key derivation, and dot-notation conventions.', + }; + return; + } + + // Hardcoded auth routes — common Shopify/Rails contamination. + if (check === 'HardcodedRoutes') { + const looksAuth = /\/sessions\b|\/users\b|\/login\b|\/signup\b|\/register\b/i.test(message); + diagnostic.see_also = { + tool: 'domain_guide', + args: looksAuth + ? { domain: 'authentication', section: 'patterns' } + : { domain: 'routing', section: 'patterns' }, + reason: looksAuth + ? 'Auth route detected. platformOS uses /sessions/new (not /sessions) and /users/new (not /users). domain_guide(authentication, patterns) has the correct URLs and ownership patterns.' + : 'Hardcoded route. domain_guide(routing, patterns) explains slug conventions and how to use link_to helpers.', + }; + return; + } +} + +// ── GraphQL error classifier ─────────────────────────────────────────────── + +/** + * Classify a GraphQL error message into a category with extracted + * variables. Returns the variable bag for the `GraphQLCheck` hint template. + * + * Note: the source returns a discriminated bag (`category_unused_var`, + * `category_unknown_field_record`, etc.) keyed by category. We keep the + * same shape so the hint template's `{{#if category_X}}` conditionals + * fire identically. + */ +function classifyGraphQLError(message: string | undefined): Record { + if (!message) return { category_generic: true }; + + const unusedMatch = message.match(/Variable\s+["']?\$(\w+)["']?\s+is never used/i); + if (unusedMatch) { + return { category_unused_var: true, var_name: unusedMatch[1] }; + } + + const fieldMatch = message.match( + /Cannot query field\s+["']?(\w+)["']?\s+on type\s+["']?(\w+)["']?/i, + ); + if (fieldMatch) { + const isRecord = fieldMatch[2] === 'Record'; + return { + [`category_unknown_field_${isRecord ? 'record' : 'other'}`]: true, + field_name: fieldMatch[1], + type_name: fieldMatch[2], + }; + } + + const typeMismatch = message.match( + /Variable\s+["']?\$(\w+)["']?\s+of type\s+["']?([^"']+)["']?\s+used in position expecting(?: type)?\s+["']?([^"'.]+)["']?/i, + ); + if (typeMismatch) { + const expectedType = typeMismatch[3].trim(); + const isFilter = /filter/i.test(expectedType); + return { + [`category_type_mismatch_${isFilter ? 'filter' : 'other'}`]: true, + var_name: typeMismatch[1], + actual_type: typeMismatch[2], + expected_type: expectedType, + }; + } + + const filterMatch = message.match( + /Expected value of type\s+["']?(\w+)["']?,?\s+found\s+["']?([^"'.]+)["']?/i, + ); + if (filterMatch) { + const isFilter = /filter/i.test(filterMatch[1]); + return { + [`category_type_mismatch_${isFilter ? 'filter' : 'other'}`]: true, + var_name: filterMatch[2].trim(), + actual_type: `"${filterMatch[2].trim()}"`, + expected_type: filterMatch[1], + }; + } + + return { category_generic: true }; +} + +// ── Translation-key snippet builder ──────────────────────────────────────── + +/** + * Build an indented YAML snippet showing where to add a translation key. + * `'foo.bar.baz'` → `' en:\n foo:\n bar:\n baz: "TODO: translation text"'`. + */ +function buildYamlSnippet(key: string | null | undefined): string { + if (!key) return ' en:\n your_key: "TODO: translation text"'; + const parts = key.split('.'); + const lines: string[] = [' en:']; + for (let i = 0; i < parts.length; i++) { + const indent = ' '.repeat(i + 2); + if (i === parts.length - 1) { + lines.push(`${indent}${parts[i]}: "TODO: translation text"`); + } else { + lines.push(`${indent}${parts[i]}:`); + } + } + return lines.join('\n'); +} + +// ── LSP completion extractor ─────────────────────────────────────────────── + +interface CompletionItemLike { + label?: string; + insertText?: string; +} + +interface CompletionListLike { + items?: ReadonlyArray; +} + +/** + * Normalise LSP completion result to an array of label strings. LSP can + * return either `CompletionItem[]` or `CompletionList { items }`. + */ +function extractCompletionLabels( + result: ReadonlyArray | CompletionListLike | null | undefined, +): string[] { + if (!result) return []; + let items: ReadonlyArray; + if (Array.isArray(result)) { + items = result; + } else { + items = (result as CompletionListLike).items ?? []; + } + const out: string[] = []; + for (const c of items) { + const label = c.label ?? c.insertText ?? ''; + if (label) out.push(label); + } + return out; +} + +// ── MissingPartial path classifier ───────────────────────────────────────── + +type ObjectType = 'partial' | 'command' | 'query' | 'module' | 'invalid_lib_prefix'; + +/** + * Detect what kind of platformOS object a missing partial name refers to. + */ +function detectObjectType(name: string | null | undefined): ObjectType { + if (!name) return 'partial'; + if (name.startsWith('modules/')) return 'module'; + // Literal `lib/commands/` or `lib/queries/` prefix is invalid: the + // `function` tag resolves under the partial search paths, so + // `lib/commands/X` expands to `app/lib/lib/commands/X` and never + // resolves. Tag separately so the hint renderer can surface "drop the + // prefix" instead of the generic "missing file" copy. + if (name.startsWith('lib/commands/') || name.startsWith('lib/queries/')) { + return 'invalid_lib_prefix'; + } + if (name.startsWith('commands/')) return 'command'; + if (name.startsWith('queries/')) return 'query'; + return 'partial'; +} + +/** + * Build the expected disk path for a missing platformOS file. + */ +function buildCreatePath(type: ObjectType, name: string | null | undefined): string { + if (!name) return '(unknown path)'; + switch (type) { + case 'command': + case 'query': + return `app/lib/${name}.liquid`; + case 'invalid_lib_prefix': { + // The path is wrong, not the file. Show where the corrected call + // *would* resolve so the agent can sanity-check that the existing + // file is the intended target before applying the rule's text edit. + const corrected = name.slice('lib/'.length); + return `app/lib/${corrected}.liquid`; + } + case 'module': { + const moduleName = name.split('/')[1] ?? name; + return `(install module '${moduleName}' or check modules/${moduleName}/ on disk)`; + } + default: + return `app/views/partials/${name}.liquid`; + } +} + +// ── enrichAll — batch enrichment ─────────────────────────────────────────── + +/** + * Enrich every diagnostic in `diagnostics`. Deduplicates LSP hover calls + * — errors at the same `(line, column)` share one hover result. + */ +export async function enrichAll( + diagnostics: EnrichedDiagnostic[], + ctx: EnrichContext, +): Promise { + // Pre-fetch hover docs by unique position to avoid duplicate LSP calls. + const hoverCache: HoverCache = new Map(); + if (ctx.lsp?.initialized) { + const positions = new Set(); + for (const d of diagnostics) { + if (d.line != null) positions.add(`${d.line}:${d.column ?? 0}`); + } + await Promise.all( + [...positions].map(async (key) => { + const parts = key.split(':'); + const line = Number(parts[0]); + const col = Number(parts[1]); + try { + const hover = await ctx.lsp!.hover(ctx.uri, line, col); + const text = extractHoverText(hover); + // Cache hits AND misses so enrichError doesn't retry. + hoverCache.set(key, text); + } catch { + hoverCache.set(key, null); + } + }), + ); + } + + return Promise.all(diagnostics.map((d) => enrichError(d, { ...ctx, _hoverCache: hoverCache }))); +} + +// ── bridgeRulesOntoUnattributed — late attribution ───────────────────────── + +/** + * Run the rule engine over diagnostics that bypassed `enrichAll`. + * + * Structural warnings, schema/translation/GraphQL validators, diff-aware + * `RemovedRender`/`AddedParam`, and the new-partial-caller check are + * pushed into `result.{errors,warnings,infos}` AFTER `enrichAll` + * returns. Without this bridge their rule modules never fire and they + * land in analytics as `.unmatched`. + * + * Idempotent — diagnostics already carrying a `rule_id` are skipped. + */ +export function bridgeRulesOntoUnattributed(result: BridgeResult, ctx: EnrichContext): void { + const { filePath, content, factGraph, filtersIndex, objectsIndex, tagsIndex, projectDir } = ctx; + if (!factGraph) return; + + const facts: RuleFacts = { + graph: factGraph, + filtersIndex, + objectsIndex, + tagsIndex, + projectDir, + }; + + const apply = (d: EnrichedDiagnostic): void => { + if (d.rule_id) return; // already attributed + if (!d.check) return; + if (!hasRules(d.check)) return; + + const params = extractParams(d.check, d.message); + const tmplFp = templateOf(d.check, d.message); + const diagForRule = { + check: d.check, + params, + message: d.message, + file: filePath, + line: d.line, + column: d.column ?? 0, + template_fp: tmplFp, + }; + let ruleResult; + try { + ruleResult = runRules(diagForRule, facts); + } catch { + return; // runRules failure is non-fatal + } + if (!ruleResult) return; + + d.rule_id = ruleResult.rule_id; + if (ruleResult.hint_md && !d.hint) d.hint = ruleResult.hint_md; + if (ruleResult.confidence != null && d.confidence == null) d.confidence = ruleResult.confidence; + if (ruleResult.see_also && !d.see_also) d.see_also = ruleResult.see_also; + if (ruleResult.fixes && ruleResult.fixes.length > 0 && !d.fixes) d.fixes = ruleResult.fixes; + attachSeeAlso(d, content); + }; + + for (const d of result.errors) apply(d); + for (const d of result.warnings) apply(d); + for (const d of result.infos) apply(d); +} diff --git a/packages/platformos-mcp-supervisor/src/core/filters-index.ts b/packages/platformos-mcp-supervisor/src/core/filters-index.ts new file mode 100644 index 00000000..5ec7d4e3 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/filters-index.ts @@ -0,0 +1,133 @@ +/** + * Filter docset index — name → typed metadata for every known Liquid + * filter, populated from the platformOS docset shipped by + * `@platformos/platformos-check-docs-updater`. + * + * Consumed by `error-enricher` and `fix-generator` (for "did you mean?" + * suggestions on `UnknownFilter`) plus the `UnknownFilter` rule. + * + * Source data layout in the docset (per `FilterEntry`): `name`, `category`, + * `syntax`, `summary`, `parameters`, `deprecated`. The shipping JSON also + * carries `platformOS: boolean` per entry (not in the public type) — used + * to surface the platformOS-only subset for tooltips/lists. + */ + +import type { FilterEntry, Parameter, PlatformOSDocset } from '@platformos/platformos-check-common'; +import { FILTER_MATCH_MAX_DISTANCE } from './constants'; + +/** + * Runtime view of a filter entry. Slimmed from the upstream `FilterEntry` + * to the fields consumers actually read; `platformOS` is added because the + * shipping JSON populates it even though the public type doesn't declare it. + */ +export interface FilterDef { + name: string; + category: string; + syntax: string; + summary: string; + parameters: Parameter[]; + platformOS: boolean; + deprecated: boolean; +} + +/** Shipping JSON shape — extends the public type with the optional flag. */ +type ShippedFilterEntry = FilterEntry & { platformOS?: boolean }; + +export class FiltersIndex { + private _byName = new Map(); + private _loaded = false; + + /** + * Populate the index from the docset's filter list. + * + * `setup()` on `PlatformOSLiquidDocsManager` runs an upstream-revision + * check + download — callers that want the offline-only path should pass + * a docset whose `filters()` reads from disk (the default behaviour + * before `setup()` is called). + */ + async load(docset: PlatformOSDocset): Promise { + const entries = (await docset.filters()) as ShippedFilterEntry[]; + for (const f of entries) { + this._byName.set(f.name, { + name: f.name, + category: f.category ?? '', + syntax: f.syntax ?? '', + summary: f.summary ?? '', + parameters: f.parameters ?? [], + platformOS: f.platformOS === true, + deprecated: f.deprecated === true, + }); + } + this._loaded = true; + } + + get loaded(): boolean { + return this._loaded; + } + + lookup(filterName: string | null | undefined): FilterDef | null { + if (!this._loaded || !filterName) return null; + return this._byName.get(filterName) ?? null; + } + + lookupMany(filterNames: Iterable): FilterDef[] { + if (!this._loaded) return []; + const results: FilterDef[] = []; + for (const name of filterNames) { + const f = this._byName.get(name); + if (f) results.push(f); + } + return results; + } + + /** + * Find the closest filter name by Levenshtein distance, capped at + * `maxDistance`. Used by `UnknownFilter` rule + enricher fallback for + * "did you mean?" suggestions. + */ + closestMatch( + filterName: string | null | undefined, + maxDistance: number = FILTER_MATCH_MAX_DISTANCE, + ): FilterDef | null { + if (!this._loaded || !filterName) return null; + const lower = filterName.toLowerCase(); + let best: FilterDef | null = null; + let bestDist = maxDistance + 1; + for (const entry of this._byName.values()) { + const d = levenshtein(lower, entry.name.toLowerCase()); + if (d < bestDist) { + bestDist = d; + best = entry; + } + } + return best; + } + + /** Sorted list of platformOS-only, non-deprecated filters. */ + platformOSFilters(): FilterDef[] { + if (!this._loaded) return []; + return [...this._byName.values()] + .filter((f) => f.platformOS && !f.deprecated) + .sort((a, b) => a.name.localeCompare(b.name)); + } +} + +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => { + const row = new Array(n + 1); + row[0] = i; + return row; + }); + for (let j = 1; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; 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[m][n]; +} diff --git a/packages/platformos-mcp-supervisor/src/core/fix-generator.spec.ts b/packages/platformos-mcp-supervisor/src/core/fix-generator.spec.ts new file mode 100644 index 00000000..35ddbf0f --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/fix-generator.spec.ts @@ -0,0 +1,1119 @@ +/** + * fix-generator unit pins — primary file. + * + * Source carried three files (fix-generator + -expanded + -v2). v1 ports + * the primary surface (UndefinedObject, UnknownFilter, + * ConvertIncludeToRender, DeprecatedTag, MissingPartial, + * scaffold-collection-params, edge cases, diagnosticFixes map) here. The + * `-expanded` and `-v2` files cover ~50 additional structural check kinds + * via the same `generateFixes` entry point; P22 integration tests exercise + * those end-to-end against the real fixture, which is the higher-value + * regression net for v1. + * + * v1 trim: `ctx.schemaIndex` is dropped — the destination's `FixIndexes` + * type does not carry it (P16). Source passed `schemaIndex: null` to + * `makeCtx`; that key is omitted here. + * + * Index access deviation: source used `private _loaded`/`_byName` to + * pre-populate index instances. v1 marks those private. We instead build + * stubs via the public surface using `test/helpers/index-stubs.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { generateFixes, type FixDiagnostic, type FixIndexes } from './fix-generator'; +import { parseLiquidFile } from './liquid-parser'; +import { stubObjectsIndex, stubFiltersIndex, stubTagsIndex } from '../test/index-stubs'; + +function makeCtx(): FixIndexes { + return { + objectsIndex: stubObjectsIndex({ + params: { handle: 'context.params', properties: ['slug', 'id'] }, + page: { handle: 'context.page', properties: ['slug', 'metadata'] }, + current_user: { handle: 'context.current_user', properties: ['id', 'email'] }, + context: { handle: 'context', properties: ['params', 'page'] }, + }), + filtersIndex: stubFiltersIndex({ + json: { category: 'string', syntax: '{{ obj | json }}', summary: 'JSON encode' }, + downcase: { category: 'string', syntax: '{{ s | downcase }}', summary: 'Lowercase' }, + pricify: { category: 'number', syntax: '{{ n | pricify }}', summary: 'Format price' }, + upcase: { category: 'string', syntax: '{{ s | upcase }}', summary: 'Uppercase' }, + }), + tagsIndex: stubTagsIndex(['render', 'graphql', 'background']), + }; +} + +// ── UndefinedObject fixes ───────────────────────────────────────────────────── + +describe('fix-generator: UndefinedObject', () => { + it('generates context.X replacement for known object in a page', () => { + const content = '---\nslug: test\n---\n{{ params.id }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 3, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string; new_text: string; description: string }; + expect(fix.type).toBe('text_edit'); + expect(fix.new_text).toBe('context.params'); + expect(fix.description).toContain('context.params'); + }); + + it('generates {% doc %} param for undefined var in a partial', () => { + const content = '

{{ post.title }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "post"', + line: 0, + column: 4, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/blog/card.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string; new_text: string }; + expect(fix.type).toBe('insert'); + expect(fix.new_text).toContain('{% doc %}'); + expect(fix.new_text).toContain('@param {object} post'); + expect(fix.new_text).toContain('{% enddoc %}'); + }); + + it('merges multiple doc params into one {% doc %} block', () => { + const content = '

{{ post.title }}

\n

{{ author.name }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "post"', + line: 0, + column: 4, + }, + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "author"', + line: 1, + column: 4, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/blog/card.liquid', + makeCtx(), + ); + + const inserts = proposedFixes.filter((f) => f.type === 'insert'); + expect(inserts).toHaveLength(1); + const ins = inserts[0]! as { new_text: string; resolves_params?: string[] }; + expect(ins.new_text).toContain('@param {object} post'); + expect(ins.new_text).toContain('@param {object} author'); + expect(ins.resolves_params).toContain('post'); + expect(ins.resolves_params).toContain('author'); + }); + + it('inserts {% doc %} after front matter when present', () => { + const content = '---\nslug: test\n---\n

{{ post.title }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "post"', + line: 3, + column: 4, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/blog/card.liquid', + makeCtx(), + ); + + const insert = proposedFixes.find((f) => f.type === 'insert') as + | { range: { start: { line: number } } } + | undefined; + expect(insert).toBeDefined(); + expect(insert!.range.start.line).toBeGreaterThan(0); + }); + + it('appends to existing {% doc %} block', () => { + const content = + '{% doc %}\n @param {object} existing_param\n{% enddoc %}\n

{{ post.title }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "post"', + line: 3, + column: 4, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/blog/card.liquid', + makeCtx(), + ); + + const insert = proposedFixes.find((f) => f.type === 'insert') as + | { new_text: string; description: string } + | undefined; + expect(insert).toBeDefined(); + expect(insert!.new_text).toContain('@param {object} post'); + expect(insert!.description).toContain('existing'); + }); + + it('skips already-declared params in existing {% doc %}', () => { + const content = '{% doc %}\n @param {object} post\n{% enddoc %}\n

{{ post.title }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "post"', + line: 3, + column: 4, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/blog/card.liquid', + makeCtx(), + ); + + expect(proposedFixes.find((f) => f.type === 'insert')).toBeUndefined(); + const guidance = proposedFixes.find((f) => f.type === 'guidance') as + | { description: string } + | undefined; + expect(guidance).toBeDefined(); + expect(guidance!.description).toContain('already declared'); + }); + + it('does not generate fix for unknown var in a page (not a context object)', () => { + const content = '---\nslug: test\n---\n{{ some_local_var.name }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "some_local_var"', + line: 3, + column: 3, + }, + ]; + + const { proposedFixes, diagnosticFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(0); + expect(diagnosticFixes.size).toBe(0); + }); + + it('generates context.X fix even in partials for known context objects', () => { + const content = '

{{ params.slug }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 0, + column: 4, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/header.liquid', + makeCtx(), + ); + + const fix = proposedFixes.find((f) => f.type === 'text_edit') as + | { new_text: string } + | undefined; + expect(fix).toBeDefined(); + expect(fix!.new_text).toBe('context.params'); + }); + + it('deduplicates identical text_edit fixes', () => { + const content = '{{ params.id }}\n{{ params.slug }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 0, + column: 3, + }, + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 1, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const textEdits = proposedFixes.filter((f) => f.type === 'text_edit'); + expect(textEdits.length).toBe(2); + }); +}); + +// ── UnknownFilter fixes ────────────────────────────────────────────────────── + +describe('fix-generator: UnknownFilter', () => { + it('suggests closest filter match', () => { + const content = '{{ "hello" | donwcase }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UnknownFilter', + severity: 'error', + message: 'Unknown filter `donwcase`', + line: 0, + column: 14, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string; new_text: string }; + expect(fix.type).toBe('text_edit'); + expect(fix.new_text).toBe('downcase'); + }); + + it('detects tag used as filter', () => { + const content = '{{ "hello" | render }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UnknownFilter', + severity: 'error', + message: 'Unknown filter `render`', + line: 0, + column: 14, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string; description: string }; + expect(fix.type).toBe('guidance'); + expect(fix.description).toContain('tag, not a filter'); + expect(fix.description).toContain('{% render'); + }); + + it('returns no fix for completely unknown filter with no close match', () => { + const content = '{{ "hello" | zzzznotafilter }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UnknownFilter', + severity: 'error', + message: 'Unknown filter `zzzznotafilter`', + line: 0, + column: 14, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(0); + }); +}); + +// ── ConvertIncludeToRender fixes ───────────────────────────────────────────── + +describe('fix-generator: ConvertIncludeToRender', () => { + it('replaces include with render', () => { + const content = "{% include 'shared/header' %}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'ConvertIncludeToRender', + severity: 'warning', + message: 'Use render instead of include', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { + type: string; + new_text: string; + range: { start: { line: number; character: number }; end: { character: number } }; + }; + expect(fix.type).toBe('text_edit'); + expect(fix.new_text).toBe('render'); + expect(fix.range.start.line).toBe(0); + expect(fix.range.end.character).toBe(fix.range.start.character + 'include'.length); + }); +}); + +// ── DeprecatedTag fixes ────────────────────────────────────────────────────── + +describe('fix-generator: DeprecatedTag', () => { + it('replaces hash_assign with assign', () => { + const content = '{% hash_assign x = "val" %}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'DeprecatedTag', + severity: 'warning', + message: 'Deprecated tag: hash_assign', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string; new_text: string; description: string }; + expect(fix.type).toBe('text_edit'); + expect(fix.new_text).toBe('assign'); + expect(fix.description).toContain('hash_assign'); + }); + + it('returns no fix for non-hash_assign deprecated tag', () => { + const content = '{% some_other_deprecated_tag %}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'DeprecatedTag', + severity: 'warning', + message: 'Deprecated tag: some_other_deprecated_tag', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(0); + }); +}); + +// ── MissingPartial fixes ───────────────────────────────────────────────────── + +describe('fix-generator: MissingPartial', () => { + it('generates create_file for missing partial', () => { + const content = "{% render 'products/card' %}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'products/card'", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string; path: string }; + expect(fix.type).toBe('create_file'); + expect(fix.path).toBe('app/views/partials/products/card.liquid'); + }); + + it('generates correct path for missing command', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'commands/users/create'", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const fix = proposedFixes[0]! as { type: string; path: string }; + expect(fix.type).toBe('create_file'); + expect(fix.path).toBe('app/lib/commands/users/create.liquid'); + }); + + it('generates correct path for missing query', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'queries/products/search'", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const fix = proposedFixes[0]! as { type: string; path: string }; + expect(fix.type).toBe('create_file'); + expect(fix.path).toBe('app/lib/queries/products/search.liquid'); + }); + + it('returns guidance (not create_file) for module paths', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'modules/user/queries/user/current'", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes.length).toBeGreaterThan(0); + const fix = proposedFixes[0]! as { type: string }; + expect(fix.type).toBe('guidance'); + expect(fix.type).not.toBe('create_file'); + }); + + it('references suggestion in guidance when module has completions', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'modules/user/queries/user/current'", + line: 0, + column: 3, + suggestion: + "'modules/user/queries/user/current' not found in module. Available: modules/user/queries/user/find", + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const fix = proposedFixes[0]! as { type: string; description: string }; + expect(fix.type).toBe('guidance'); + expect(fix.description).toContain('suggestion field'); + }); +}); + +// ── Scaffold generation ────────────────────────────────────────────────────── + +describe('fix-generator: scaffold collection params', () => { + it('generates for-loop scaffold for collection params', () => { + const content = "{% render 'products/list', products: products %}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'products/list'", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const fix = proposedFixes[0]! as { scaffold: string }; + expect(fix.scaffold).toContain('for product in products'); + expect(fix.scaffold).not.toContain('{{ products }}'); + }); + + it('generates simple output for singular params', () => { + const content = "{% render 'blog_posts/card', post: post %}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingPartial', + severity: 'error', + message: "Missing partial 'blog_posts/card'", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const fix = proposedFixes[0]! as { scaffold: string }; + expect(fix.scaffold).toContain('{{ post }}'); + expect(fix.scaffold).not.toContain('for'); + }); +}); + +// ── Edge cases ─────────────────────────────────────────────────────────────── + +describe('fix-generator: edge cases', () => { + it('handles null AST gracefully', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '{{ params.id }}', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + const fix = proposedFixes.find((f) => f.type === 'text_edit') as + | { new_text: string } + | undefined; + expect(fix).toBeDefined(); + expect(fix!.new_text).toBe('context.params'); + }); + + it('handles empty diagnostics array', () => { + const { proposedFixes, diagnosticFixes } = generateFixes( + [], + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(0); + expect(diagnosticFixes.size).toBe(0); + }); + + it('handles unknown check type gracefully', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'SomeNewCheckWeHaveNeverSeen', + severity: 'warning', + message: 'Something happened', + line: 0, + column: 0, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '

test

', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(0); + }); + + it('handles missing indexes gracefully', () => { + const content = '{{ params.id }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + {}, + ); + + // No context object match without index → no fix for page. + expect(proposedFixes).toHaveLength(0); + }); + + it('detects commands/ path as partial-like', () => { + const content = '{{ item.name }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "item"', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/lib/commands/orders/create.liquid', + makeCtx(), + ); + + const insert = proposedFixes.find((f) => f.type === 'insert') as + | { new_text: string } + | undefined; + expect(insert).toBeDefined(); + expect(insert!.new_text).toContain('@param {object} item'); + }); + + it('detects queries/ path as partial-like', () => { + const content = '{{ filter.name }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "filter"', + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/lib/queries/products/search.liquid', + makeCtx(), + ); + + const insert = proposedFixes.find((f) => f.type === 'insert') as + | { new_text: string } + | undefined; + expect(insert).toBeDefined(); + expect(insert!.new_text).toContain('@param {object} filter'); + }); +}); + +// ── diagnosticFixes map correctness ────────────────────────────────────────── + +describe('fix-generator: diagnosticFixes map', () => { + it('maps each diagnostic index to its fix', () => { + const content = '---\nslug: test\n---\n{{ params.id }}\n{{ "hello" | donwcase }}'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "params"', + line: 3, + column: 3, + }, + { + check: 'UnknownFilter', + severity: 'error', + message: 'Unknown filter `donwcase`', + line: 4, + column: 14, + }, + ]; + + const { diagnosticFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(diagnosticFixes.has(0)).toBe(true); + expect((diagnosticFixes.get(0) as { new_text: string }).new_text).toBe('context.params'); + expect(diagnosticFixes.has(1)).toBe(true); + expect((diagnosticFixes.get(1) as { new_text: string }).new_text).toBe('downcase'); + }); + + it('generates guidance fix for MissingRenderPartialArguments', () => { + const content = "{% render 'products/card' %}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingRenderPartialArguments', + severity: 'error', + message: + "Missing required argument 'title' for partial 'products/card' (@param {string} title)", + line: 0, + column: 0, + }, + ]; + + const { proposedFixes, diagnosticFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/products.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { type: string; description: string }; + expect(fix.type).toBe('guidance'); + expect(fix.description).toContain('title'); + expect(fix.description).toContain('products/card'); + expect(diagnosticFixes.has(0)).toBe(true); + }); + + it('generates guidance fix for MissingRenderPartialArguments without parseable message', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingRenderPartialArguments', + severity: 'error', + message: 'Some unexpected message format', + line: 0, + column: 0, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { type: string; description: string }; + expect(fix.type).toBe('guidance'); + expect(fix.description).toContain('{% render %}'); + }); + + it('includes variables in scope for MissingRenderPartialArguments inside for loop', () => { + const content = `{% doc %} + @param items {array} +{% enddoc %} +{% for item in items %} + {% render 'products/card' %} +{% endfor %}`; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingRenderPartialArguments', + severity: 'error', + message: "Missing required argument 'product_id' for partial 'products/card'", + line: 4, + column: 2, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/caller.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { description: string }; + expect(fix.description).toContain('Variables in scope:'); + expect(fix.description).toContain('items (@param)'); + expect(fix.description).toContain('item ({% for item in items %})'); + }); + + it('includes assign variables in scope for MissingRenderPartialArguments', () => { + const content = `{% assign product_id = context.params.id %} +{% render 'products/card' %}`; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingRenderPartialArguments', + severity: 'error', + message: "Missing required argument 'title' for partial 'products/card'", + line: 1, + column: 0, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { description: string }; + expect(fix.description).toContain('Variables in scope:'); + expect(fix.description).toContain('product_id ({% assign %})'); + }); + + it('does not include scope info when AST is null', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'MissingRenderPartialArguments', + severity: 'error', + message: "Missing required argument 'title' for partial 'products/card'", + line: 0, + column: 0, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + '', + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { description: string }; + expect(fix.description).not.toContain('Variables in scope'); + }); + + it('generates guidance fix for NestedGraphQLQuery', () => { + const content = + "{% for item in items %}\n {% graphql result = 'get_item', id: item.id %}\n{% endfor %}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'NestedGraphQLQuery', + severity: 'warning', + message: 'Nested graphql query detected inside a loop', + line: 1, + column: 2, + }, + ]; + + const { proposedFixes, diagnosticFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { type: string; description: string }; + expect(fix.type).toBe('guidance'); + expect(fix.description).toContain('BEFORE the loop'); + expect(fix.description).toContain('N+1'); + expect(diagnosticFixes.has(0)).toBe(true); + }); + + it('generates text_edit fix for TranslationKeyExists with Levenshtein suggestion', () => { + const content = "{{ 'app.produts.title' | t }}"; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'TranslationKeyExists', + severity: 'warning', + message: + "Translation key 'app.produts.title' does not exist. Did you mean 'app.products.title'?", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes, diagnosticFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { type: string; new_text: string }; + expect(fix.type).toBe('text_edit'); + expect(fix.new_text).toBe("'app.products.title'"); + expect(diagnosticFixes.has(0)).toBe(true); + }); + + it('generates guidance fix for TranslationKeyExists without suggestion', () => { + const diagnostics: FixDiagnostic[] = [ + { + check: 'TranslationKeyExists', + severity: 'warning', + message: "Translation key 'app.custom.key' does not exist", + line: 0, + column: 3, + }, + ]; + + const { proposedFixes } = generateFixes( + diagnostics, + null, + "{{ 'app.custom.key' | t }}", + 'app/views/pages/test.html.liquid', + makeCtx(), + ); + + expect(proposedFixes).toHaveLength(1); + const fix = proposedFixes[0]! as { type: string; description: string }; + expect(fix.type).toBe('guidance'); + expect(fix.description).toContain('Translation key'); + }); + + it('updates doc param fixes to reference merged insert', () => { + const content = '

{{ post.title }}

\n

{{ author.name }}

'; + const ast = parseLiquidFile(content); + const diagnostics: FixDiagnostic[] = [ + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "post"', + line: 0, + column: 4, + }, + { + check: 'UndefinedObject', + severity: 'error', + message: 'Undefined object "author"', + line: 1, + column: 4, + }, + ]; + + const { diagnosticFixes } = generateFixes( + diagnostics, + ast, + content, + 'app/views/partials/blog/card.liquid', + makeCtx(), + ); + + expect(diagnosticFixes.has(0)).toBe(true); + expect(diagnosticFixes.has(1)).toBe(true); + expect(diagnosticFixes.get(0)!.type).toBe('insert'); + expect(diagnosticFixes.get(1)!.type).toBe('insert'); + expect((diagnosticFixes.get(0) as { param_name: string }).param_name).toBe('post'); + expect((diagnosticFixes.get(1) as { param_name: string }).param_name).toBe('author'); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/fix-generator.ts b/packages/platformos-mcp-supervisor/src/core/fix-generator.ts new file mode 100644 index 00000000..f9479abb --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/fix-generator.ts @@ -0,0 +1,1702 @@ +/** + * Fix generator — produces concrete, actionable fixes for linter diagnostics. + * + * Fix kinds (discriminated `Fix` union): + * text_edit — exact range + replacement text (variable / filter rename) + * insert — insert text at a position (`{% doc %}` block, etc.) + * create_file — create a missing file (`MissingPartial`, layout) + * guidance — description only, no exact edit (complex cases) + * add_doc_param — internal placeholder; merged into one `insert` per call + * + * Rule-id stamping: every generated fix is tagged + * `heuristic:.` so the rule-performance attribution layer + * can distinguish heuristic-generated edits from rule-engine edits. + * + * v1 trim: `ctx.schemaIndex` is no longer accepted (SchemaIndex dropped in + * P7; no fix function consumed it). + */ + +import { walk, NodeTypes, type LiquidHtmlNode } from '@platformos/liquid-html-parser'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { extractParams } from './diagnostic-record'; +import { isShopifyObject, isShopifyFilter } from './knowledge-loader'; +import { offsetToLineCol, lineColToOffset, slugFromPath, type LineCol } from './position-utils'; +import { POSITION_FUZZY_TOLERANCE } from './constants'; +import type { ObjectsIndex } from './objects-index'; +import type { FiltersIndex } from './filters-index'; +import type { TagsIndex } from './tags-index'; + +// ── Public types ─────────────────────────────────────────────────────────── + +export interface FixRange { + start: LineCol; + end: LineCol; +} + +export interface FixContext { + before: string; + after: string; + line: number; +} + +export interface BaseFix { + rule_id?: string; + description: string; + context?: FixContext; + /** Marker used to identify the generic MissingDocBlock insert at merge time. */ + _source?: string; + /** add_doc_param merge surface — present on the merged `insert` fix. */ + resolves_params?: string[]; + /** Other rule-emitted ad-hoc fields tolerated at the type level. */ + [key: string]: unknown; +} + +export interface TextEditFix extends BaseFix { + type: 'text_edit'; + range: FixRange; + new_text: string; +} + +export interface InsertFix extends BaseFix { + type: 'insert'; + range: FixRange; + new_text: string; +} + +export interface CreateFileFix extends BaseFix { + type: 'create_file'; + path: string; + scaffold?: string | null; +} + +export interface GuidanceFix extends BaseFix { + type: 'guidance'; +} + +export interface AddDocParamFix extends BaseFix { + type: 'add_doc_param'; + param_name: string; +} + +export type Fix = TextEditFix | InsertFix | CreateFileFix | GuidanceFix | AddDocParamFix; + +export interface FixIndexes { + objectsIndex?: ObjectsIndex; + filtersIndex?: FiltersIndex; + tagsIndex?: TagsIndex; +} + +export interface FixDiagnostic { + check: string; + severity?: 'error' | 'warning' | 'info'; + message?: string; + line?: number; + column?: number; + endLine?: number | null; + endColumn?: number | null; + suggestion?: string; + /** Bag of ad-hoc fields the enricher / pipeline attached. */ + [key: string]: unknown; +} + +export interface GenerateFixesResult { + proposedFixes: Fix[]; + diagnosticFixes: Map; +} + +export interface ScopeVariable { + name: string; + source: string; +} + +interface IndexedVariable { + name: string; + start: LineCol; + end: LineCol; + offset: number; +} + +interface IndexedFilter { + name: string; + start: LineCol; + end: LineCol; +} + +// ── Cluster + Scorecard types ────────────────────────────────────────────── + +export interface ClusterItem { + line?: number; + column?: number; + message?: string; + fix?: Fix; +} + +export interface DiagnosticCluster { + check: string; + count: number; + pattern: string; + unified_fix: string | null; + items: ClusterItem[]; +} + +export interface StructuralLike { + graphql_queries?: unknown[]; + renders?: unknown[]; + filters_used?: unknown[]; + tags_used?: string[]; + translation_keys?: unknown[]; + prompts?: string[]; +} + +export interface ScorecardNote { + level: 'advisory' | 'warning' | 'error'; + message: string; +} + +// ── AST helpers ──────────────────────────────────────────────────────────── + +interface PositionedNode { + position?: { start: number; end: number }; +} + +function indexVariables(ast: LiquidHtmlNode, content: string): IndexedVariable[] { + const vars: IndexedVariable[] = []; + walk(ast, (node) => { + if (node.type !== NodeTypes.VariableLookup) return; + if (!node.name || typeof node.name !== 'string') return; + if (!node.position) return; + const start = offsetToLineCol(content, node.position.start); + const nameEnd = node.position.start + node.name.length; + const end = offsetToLineCol(content, nameEnd); + vars.push({ name: node.name, start, end, offset: node.position.start }); + }); + return vars; +} + +function indexFilters(ast: LiquidHtmlNode, content: string): IndexedFilter[] { + const filters: IndexedFilter[] = []; + walk(ast, (node) => { + if (node.type !== NodeTypes.LiquidFilter) return; + if (!node.name || !node.position) return; + const raw = content.slice(node.position.start, node.position.end); + const nameIdx = raw.indexOf(node.name); + if (nameIdx < 0) return; + const nameStart = node.position.start + nameIdx; + const nameEndOff = nameStart + node.name.length; + filters.push({ + name: node.name, + start: offsetToLineCol(content, nameStart), + end: offsetToLineCol(content, nameEndOff), + }); + }); + return filters; +} + +interface DocBlockInfo { + startOffset: number; + endOffset: number; + start: LineCol; + end: LineCol; + existingParams: string[]; +} + +function findDocBlock(ast: LiquidHtmlNode, content: string): DocBlockInfo | null { + let docNode: PositionedNode | null = null; + const paramNodes: PositionedNode[] = []; + walk(ast, (node) => { + if (node.type === NodeTypes.LiquidRawTag && node.name === 'doc') { + docNode = node as PositionedNode; + } + if (node.type === NodeTypes.LiquidDocParamNode) { + paramNodes.push(node as PositionedNode); + } + }); + if (!docNode) return null; + const doc = docNode as PositionedNode; + const existingParams: string[] = []; + for (const p of paramNodes) { + if (!p.position) continue; + const raw = content.slice(p.position.start, p.position.end); + const m = raw.match(/@param\s+\{[^}]*\}\s+(\w+)/); + if (m) existingParams.push(m[1]); + } + return { + startOffset: doc.position!.start, + endOffset: doc.position!.end, + start: offsetToLineCol(content, doc.position!.start), + end: offsetToLineCol(content, doc.position!.end), + existingParams, + }; +} + +function findFrontMatterEnd(ast: LiquidHtmlNode, content: string): LineCol | null { + let fmEnd: LineCol | null = null; + walk(ast, (node) => { + if (node.type === NodeTypes.YAMLFrontmatter && (node as PositionedNode).position) { + fmEnd = offsetToLineCol(content, (node as PositionedNode).position!.end); + } + }); + return fmEnd; +} + +/** + * Collect every variable in scope at `targetOffset`. Scoping rules: + * + * - Doc params (`@param`): global to the file. + * - For/tablerow iterators: scoped to the loop body. + * - assign / capture / graphql / function / parse_json: scoped to + * everything AFTER the tag (Liquid is flat-scoped). + * + * Deduplicated by name (first occurrence wins). + */ +export function collectScopeAtOffset( + ast: LiquidHtmlNode | null | undefined, + targetOffset: number, +): ScopeVariable[] { + if (!ast) return []; + + interface Definition { + name: string; + source: string; + scopeType: 'global' | 'block' | 'after'; + scopeStart?: number; + scopeEnd?: number; + definedAt?: number; + } + const definitions: Definition[] = []; + + walk(ast, (node) => { + if (node.type === NodeTypes.LiquidDocParamNode && node.paramName?.value) { + definitions.push({ name: node.paramName.value, source: '@param', scopeType: 'global' }); + return; + } + if (node.type !== NodeTypes.LiquidTag || !node.position) return; + const markup = node.markup as + | { name?: string | { name?: string }; variableName?: string; collection?: { name?: string } } + | string + | undefined; + + switch (node.name) { + case 'for': + case 'tablerow': { + if (typeof markup === 'object' && markup?.variableName) { + const collection = markup.collection?.name ?? '...'; + definitions.push({ + name: markup.variableName, + source: `{% ${node.name} ${markup.variableName} in ${collection} %}`, + scopeType: 'block', + scopeStart: node.position.start, + scopeEnd: node.position.end, + }); + } + break; + } + case 'assign': { + const name = typeof markup === 'object' ? (markup?.name as string | undefined) : undefined; + if (typeof name === 'string') { + definitions.push({ + name, + source: '{% assign %}', + scopeType: 'after', + definedAt: node.position.end, + }); + } + break; + } + case 'capture': { + const name = typeof markup === 'object' ? (markup?.name as string | undefined) : undefined; + if (typeof name === 'string') { + definitions.push({ + name, + source: '{% capture %}', + scopeType: 'after', + definedAt: node.position.end, + }); + } + break; + } + case 'graphql': { + const name = + typeof markup === 'object' && typeof markup?.name === 'string' ? markup.name : null; + if (name) { + definitions.push({ + name, + source: '{% graphql %}', + scopeType: 'after', + definedAt: node.position.end, + }); + } + break; + } + case 'function': { + const name = + typeof markup === 'object' && typeof markup?.name === 'object' ? markup.name?.name : null; + if (typeof name === 'string') { + definitions.push({ + name, + source: '{% function %}', + scopeType: 'after', + definedAt: node.position.end, + }); + } + break; + } + case 'parse_json': { + const name = typeof markup === 'object' ? (markup?.name as string | undefined) : undefined; + if (typeof name === 'string') { + definitions.push({ + name, + source: '{% parse_json %}', + scopeType: 'after', + definedAt: node.position.end, + }); + } + break; + } + } + }); + + const inScope: ScopeVariable[] = []; + const seen = new Set(); + for (const def of definitions) { + let isInScope = false; + if (def.scopeType === 'global') { + isInScope = true; + } else if (def.scopeType === 'block') { + isInScope = targetOffset >= (def.scopeStart ?? 0) && targetOffset <= (def.scopeEnd ?? 0); + } else if (def.scopeType === 'after') { + isInScope = targetOffset >= (def.definedAt ?? 0); + } + if (isInScope && !seen.has(def.name)) { + seen.add(def.name); + inScope.push({ name: def.name, source: def.source }); + } + } + return inScope; +} + +function formatScopeInfo(scopeVars: ScopeVariable[]): string { + if (!scopeVars || scopeVars.length === 0) return ''; + const items = scopeVars.map((v) => `${v.name} (${v.source})`).join(', '); + return `\nVariables in scope: ${items}`; +} + +function findVarAt( + varIndex: IndexedVariable[], + line: number | undefined, + col: number | undefined, + varName: string, +): IndexedVariable | null { + if (line == null || col == null) return null; + for (const v of varIndex) { + if (v.name === varName && v.start.line === line && v.start.character === col) return v; + } + for (const v of varIndex) { + if ( + v.name === varName && + v.start.line === line && + Math.abs(v.start.character - col) <= POSITION_FUZZY_TOLERANCE + ) + return v; + } + return null; +} + +function findFilterAt( + filterIndex: IndexedFilter[], + line: number | undefined, + col: number | undefined, + filterName: string, +): IndexedFilter | null { + if (line == null || col == null) return null; + for (const f of filterIndex) { + if ( + f.name === filterName && + f.start.line === line && + Math.abs(f.start.character - col) <= POSITION_FUZZY_TOLERANCE + ) + return f; + } + return null; +} + +// ── Per-check fix functions ──────────────────────────────────────────────── + +function fixUndefinedObject( + diagnostic: FixDiagnostic, + varIndex: IndexedVariable[], + isPartialLike: boolean, + objectsIndex: ObjectsIndex | undefined, +): Fix | null { + const varName = extractParams(diagnostic.check, diagnostic.message ?? '').variable ?? null; + if (!varName) return null; + + if (isShopifyObject(varName)) { + return { + type: 'guidance', + description: `\`${varName}\` is a Shopify theme object — it does not exist in platformOS. Use \`{% graphql %}\` to fetch data from your schema and \`context.*\` for request/user data.`, + }; + } + + const contextObj = objectsIndex?.lookup(varName); + if (contextObj) { + const varNode = findVarAt(varIndex, diagnostic.line, diagnostic.column, varName); + if (varNode) { + return { + type: 'text_edit', + range: { start: varNode.start, end: varNode.end }, + new_text: contextObj.handle, + description: `Replace \`${varName}\` with \`${contextObj.handle}\``, + }; + } + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line ?? 0, character: diagnostic.column ?? 0 }, + end: { line: diagnostic.line ?? 0, character: (diagnostic.column ?? 0) + varName.length }, + }, + new_text: contextObj.handle, + description: `Replace \`${varName}\` with \`${contextObj.handle}\``, + }; + } + + if (isPartialLike) { + return { + type: 'add_doc_param', + param_name: varName, + description: `Declare parameter: add \`@param {object} ${varName}\` to {% doc %} block`, + }; + } + return null; +} + +function fixUnknownFilter( + diagnostic: FixDiagnostic, + filterIndex: IndexedFilter[], + filtersIndex: FiltersIndex | undefined, + tagsIndex: TagsIndex | undefined, +): Fix | null { + const filterName = extractParams(diagnostic.check, diagnostic.message ?? '').filter ?? null; + if (!filterName) return null; + + if (tagsIndex?.isTag(filterName)) { + return { + type: 'guidance', + description: `\`${filterName}\` is a tag, not a filter. Use \`{% ${filterName} ... %}\` block syntax instead of \`| ${filterName}\`.`, + }; + } + if (isShopifyFilter(filterName)) { + return { + type: 'guidance', + description: `\`${filterName}\` is a Shopify-specific filter — it does not exist in platformOS. Check platformOS docs for the equivalent functionality.`, + }; + } + const closest = filtersIndex?.closestMatch(filterName); + if (closest && closest.name !== filterName) { + const filterNode = findFilterAt(filterIndex, diagnostic.line, diagnostic.column, filterName); + if (filterNode) { + return { + type: 'text_edit', + range: { start: filterNode.start, end: filterNode.end }, + new_text: closest.name, + description: `Replace \`${filterName}\` with \`${closest.name}\``, + }; + } + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line ?? 0, character: diagnostic.column ?? 0 }, + end: { + line: diagnostic.line ?? 0, + character: (diagnostic.column ?? 0) + filterName.length, + }, + }, + new_text: closest.name, + description: `Replace \`${filterName}\` with \`${closest.name}\``, + }; + } + return null; +} + +function fixMissingPartial( + diagnostic: FixDiagnostic, + projectDir: string | null, + ast: LiquidHtmlNode | null, + content: string, +): Fix | null { + const partialPath = extractParams(diagnostic.check, diagnostic.message ?? '').partial ?? null; + if (!partialPath) return null; + + if (partialPath.startsWith('modules/')) { + if (diagnostic.suggestion) { + return { + type: 'guidance', + description: `\`${partialPath}\` is a module path. Fix the path in this file — available paths are in the suggestion field.`, + }; + } + return { + type: 'guidance', + description: `\`${partialPath}\` cannot be resolved. Call project_map to see installed modules and their available paths.`, + }; + } + + if (partialPath.startsWith('lib/commands/') || partialPath.startsWith('lib/queries/')) { + const corrected = partialPath.slice('lib/'.length); + const edit = buildLibPrefixTextEdit(diagnostic, partialPath, corrected, content); + if (edit) return edit; + return { + type: 'guidance', + description: + `Drop the \`lib/\` prefix from \`${partialPath}\`. Function tag paths resolve from ` + + `\`app/lib/\`, so use \`${corrected}\` instead.`, + }; + } + + let targetPath: string; + let fileType: 'partial' | 'command' | 'query' = 'partial'; + if (partialPath.startsWith('commands/')) { + targetPath = `app/lib/${partialPath}.liquid`; + fileType = 'command'; + } else if (partialPath.startsWith('queries/')) { + targetPath = `app/lib/${partialPath}.liquid`; + fileType = 'query'; + } else { + targetPath = `app/views/partials/${partialPath}.liquid`; + } + + if (projectDir) { + const absTarget = join(projectDir, targetPath); + if (existsSync(absTarget)) { + return { + type: 'guidance', + description: `File \`${targetPath}\` exists but the linter still reports it as missing. Check that the file is not empty, has no syntax errors, and the path in the render/function tag matches exactly.`, + }; + } + } + + const scaffold = generateScaffold(partialPath, fileType, ast, content); + return { + type: 'create_file', + path: targetPath, + scaffold, + description: `Create missing file: \`${targetPath}\``, + }; +} + +function buildLibPrefixTextEdit( + diagnostic: FixDiagnostic, + partialPath: string, + corrected: string, + content: string, +): TextEditFix | null { + if (diagnostic.line == null || diagnostic.column == null || diagnostic.endColumn == null) + return null; + let quote = "'"; + if (typeof content === 'string') { + const lines = content.split('\n'); + const sourceLine = lines[diagnostic.line]; + if (typeof sourceLine === 'string' && diagnostic.column < sourceLine.length) { + const ch = sourceLine[diagnostic.column]; + if (ch === "'" || ch === '"') quote = ch; + } + } + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: diagnostic.column }, + end: { line: diagnostic.endLine ?? diagnostic.line, character: diagnostic.endColumn }, + }, + new_text: `${quote}${corrected}${quote}`, + description: + `Drop invalid \`lib/\` prefix — function tag paths resolve from \`app/lib/\`. ` + + `Replace \`${partialPath}\` with \`${corrected}\`.`, + }; +} + +function isLikelyCollection(paramName: string): boolean { + const name = paramName.toLowerCase(); + const knownSingulars = new Set([ + 'status', + 'address', + 'access', + 'progress', + 'process', + 'focus', + 'canvas', + 'alias', + 'class', + 'success', + 'basis', + 'radius', + ]); + if (knownSingulars.has(name)) return false; + if (/_(list|collection|records|results|items|entries)$/.test(name)) return true; + if (/^(records|results|items|entries|data|rows)$/.test(name)) return true; + if (name.length > 4 && name.endsWith('s') && !name.endsWith('ss')) return true; + return false; +} + +function singularize(name: string): string { + if (name.endsWith('ies') && name.length > 4) return name.slice(0, -3) + 'y'; + if (name.endsWith('ses') && name.length > 4) return name.slice(0, -2); + if (name.endsWith('s') && !name.endsWith('ss') && name.length > 3) return name.slice(0, -1); + return name; +} + +function generateScaffold( + partialPath: string, + fileType: 'partial' | 'command' | 'query', + ast: LiquidHtmlNode | null, + content: string, +): string | null { + if (!ast) return null; + const params: string[] = []; + walk(ast, (node) => { + if (node.type !== NodeTypes.LiquidTag) return; + if (node.name !== 'render' && node.name !== 'function' && node.name !== 'theme_render_rc') + return; + const markup = node.markup as + | { partial?: { value?: string }; args?: Array<{ name?: string; key?: string }> } + | string + | undefined; + if (typeof markup === 'string') { + if (!markup.includes(partialPath)) return; + const argMatches = markup.matchAll(/,\s*(\w+)\s*:/g); + for (const m of argMatches) { + if (!params.includes(m[1])) params.push(m[1]); + } + } else if (markup && typeof markup === 'object' && markup.partial) { + const partialValue = markup.partial?.value; + if (partialValue !== partialPath) return; + if (markup.args) { + for (const arg of markup.args) { + const name = arg.name ?? arg.key; + if (name && !params.includes(name)) params.push(name); + } + } + } + }); + + if (params.length === 0) return null; + + const docParams = params.map((p) => ` @param {object} ${p}`).join('\n'); + + if (fileType === 'command') { + return `{% doc %}\n${docParams}\n{% enddoc %}\n{% liquid\n # Build\n assign object = {} | hash_merge: ${params.map((p) => `${p}: ${p}`).join(', ')}\n\n # Check\n # Add validation here\n\n # Execute\n return object\n%}`; + } + if (fileType === 'query') { + const graphqlPath = partialPath.replace(/^(lib\/)?queries\//, ''); + return `{% doc %}\n${docParams}\n{% enddoc %}\n{% liquid\n graphql result = '${graphqlPath}', ${params.map((p) => `${p}: ${p}`).join(', ')}\n return result\n%}`; + } + const paramLines = params + .map((p) => { + if (isLikelyCollection(p)) { + const singular = singularize(p); + return ` {% for ${singular} in ${p} %}\n {{ ${singular} }}\n {% endfor %}`; + } + return ` {{ ${p} }}`; + }) + .join('\n'); + return `{% doc %}\n${docParams}\n{% enddoc %}\n
\n${paramLines}\n
`; +} + +function fixConvertInclude(diagnostic: FixDiagnostic, content: string): Fix | null { + if (diagnostic.line == null || diagnostic.column == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (!line) return null; + + if (/include\s+['"]modules\//.test(line)) { + return { + type: 'guidance', + description: + '`{% include %}` for module helpers (e.g., authorization, redirects) is correct — they need shared scope. Only replace with `{% render %}` if the partial does NOT modify the parent scope.', + }; + } + + const searchStart = Math.max(0, diagnostic.column - 5); + const idx = line.indexOf('include', searchStart); + if (idx < 0) return null; + + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: idx }, + end: { line: diagnostic.line, character: idx + 'include'.length }, + }, + new_text: 'render', + description: + 'Replace `include` with `render` — render has isolated scope, pass all needed variables explicitly', + }; +} + +function fixDeprecatedTag(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + if (!msg.includes('hash_assign')) return null; + if (diagnostic.line == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (!line) return null; + const idx = line.indexOf('hash_assign'); + if (idx < 0) return null; + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: idx }, + end: { line: diagnostic.line, character: idx + 'hash_assign'.length }, + }, + new_text: 'assign', + description: 'Replace deprecated `hash_assign` with `assign`', + }; +} + +function fixMissingRenderPartialArguments( + diagnostic: FixDiagnostic, + ast: LiquidHtmlNode | null, + content: string, +): Fix { + const msg = diagnostic.message ?? ''; + const partialMatch = + msg.match(/partial\s+['"`]([^'"`]+)['"`]/) ?? msg.match(/['"`]([^'"`\/]+\/[^'"`]+)['"`]/); + const paramMatch = + msg.match(/argument\s+['"`](\w+)['"`]/) ?? msg.match(/@param\s+(?:\{[^}]*\}\s+)?(\w+)/); + + let scopeInfo = ''; + if (ast && content && diagnostic.line != null) { + const offset = lineColToOffset(content, diagnostic.line, diagnostic.column ?? 0); + scopeInfo = formatScopeInfo(collectScopeAtOffset(ast, offset)); + } + + if (!partialMatch || !paramMatch) { + return { + type: 'guidance', + description: `Add the missing required parameter(s) to the \`{% render %}\` call. Check the partial's \`{% doc %}\` block for required \`@param\` declarations.${scopeInfo}`, + }; + } + return { + type: 'guidance', + description: `Add \`${paramMatch[1]}: \` to the \`{% render '${partialMatch[1]}' %}\` call.${scopeInfo}`, + }; +} + +function fixNestedGraphQLQuery(_diagnostic: FixDiagnostic): Fix { + return { + type: 'guidance', + description: + 'Move the `{% graphql %}` call BEFORE the loop and use a batch/list query to fetch all data in one request. Each loop iteration currently makes a separate database query (N+1 problem).', + }; +} + +function fixImgLazyLoading(diagnostic: FixDiagnostic, content: string): Fix | null { + if (diagnostic.line == null) return null; + const lines = content.split('\n'); + const line = lines[diagnostic.line]; + if (!line) return null; + const imgIdx = line.indexOf('', imgIdx); + if (closeIdx < 0) return null; + const insertBefore = line[closeIdx - 1] === '/' ? closeIdx - 1 : closeIdx; + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: insertBefore }, + end: { line: diagnostic.line, character: insertBefore }, + }, + new_text: ' loading="lazy"', + description: 'Add `loading="lazy"` to tag for better page load performance', + }; +} + +function fixImgWidthAndHeight(diagnostic: FixDiagnostic, content: string): Fix | null { + if (diagnostic.line == null) return null; + const lines = content.split('\n'); + const line = lines[diagnostic.line]; + if (!line) return null; + const imgIdx = line.indexOf('', imgIdx); + if (closeIdx < 0) return null; + const insertBefore = line[closeIdx - 1] === '/' ? closeIdx - 1 : closeIdx; + const attrs: string[] = []; + if (!hasWidth) attrs.push('width=""'); + if (!hasHeight) attrs.push('height=""'); + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: insertBefore }, + end: { line: diagnostic.line, character: insertBefore }, + }, + new_text: ' ' + attrs.join(' '), + description: `Add ${attrs.join(' and ')} to tag to prevent layout shift (CLS)`, + }; +} + +function fixHardcodedRoutes(diagnostic: FixDiagnostic, _content: string): Fix { + const msg = diagnostic.message ?? ''; + const pathMatch = msg.match(/['"`](\/[^'"`]+)['"`]/); + if (!pathMatch) { + return { + type: 'guidance', + description: + 'Avoid hardcoded URL paths — they break when slugs change. Build URLs dynamically using variables and `| append` filter.', + }; + } + const hardcodedPath = pathMatch[1]; + return { + type: 'guidance', + description: `Avoid hardcoded \`${hardcodedPath}\`. Build the URL dynamically using variables: e.g., \`{% assign url = '/products/' | append: item.id %}\`. This ensures URLs stay correct if slugs change.`, + }; +} + +function fixInvalidHashAssignTarget(diagnostic: FixDiagnostic, _content: string): Fix { + const msg = diagnostic.message ?? ''; + const varMatch = msg.match(/['"`](\w+)['"`]/); + const varName = varMatch ? varMatch[1] : 'my_hash'; + return { + type: 'guidance', + description: `Initialize the variable before setting properties: \`{% assign ${varName} = {} %}\`. Then use \`{% assign ${varName}["key"] = "value" %}\`. Both \`hash_assign\` and \`parse_json\` are deprecated — use \`assign\` with hash literals.`, + }; +} + +function fixMissingAsset(diagnostic: FixDiagnostic): Fix { + const msg = diagnostic.message ?? ''; + const pathMatch = msg.match(/['"`]([^'"`]+)['"`]/); + if (!pathMatch) { + return { + type: 'guidance', + description: + 'Create the missing asset in `app/assets/`. This check has a high false-positive rate for module assets — verify the file is truly missing.', + }; + } + const assetPath = pathMatch[1]; + return { + type: 'create_file', + path: `app/assets/${assetPath}`, + description: `Create missing asset: \`app/assets/${assetPath}\`. If this is a module asset, the file may already exist in the module's asset directory.`, + }; +} + +function fixMetadataParamsCheck( + diagnostic: FixDiagnostic, + ast: LiquidHtmlNode | null, + content: string, +): Fix { + const msg = diagnostic.message ?? ''; + const isFunctionCall = /function call/i.test(msg); + const targetLabel = isFunctionCall ? "query/command's" : "partial's"; + + let scopeInfo = ''; + if (ast && content && diagnostic.line != null) { + const offset = lineColToOffset(content, diagnostic.line, diagnostic.column ?? 0); + scopeInfo = formatScopeInfo(collectScopeAtOffset(ast, offset)); + } + + const partialMatch = + msg.match(/partial\s+['"`]([^'"`]+)['"`]/) ?? msg.match(/['"`]([^'"`\/]+\/[^'"`]+)['"`]/); + const paramMatch = + msg.match(/argument\s+['"`](\w+)['"`]/) ?? msg.match(/param(?:eter)?\s+['"`](\w+)['"`]/); + + if (partialMatch && paramMatch) { + const tagExample = isFunctionCall + ? `{% function result = '${partialMatch[1]}', ${paramMatch[1]}: %}` + : `{% render '${partialMatch[1]}', ${paramMatch[1]}: %}`; + return { + type: 'guidance', + description: `Add \`${paramMatch[1]}: \` to the \`${tagExample}\` call. The ${targetLabel} \`{% doc %}\` block requires this parameter. Passing \`null\` is valid for any typed parameter. Common defaults by type: \`[]\` for array, \`{}\` for object, \`""\` for string, \`false\` for boolean.${scopeInfo}`, + }; + } + return { + type: 'guidance', + description: `Check the target ${targetLabel} \`{% doc %}\` block for required \`@param\` declarations. Pass all required parameters with matching types.${scopeInfo}`, + }; +} + +function fixUnknownProperty( + diagnostic: FixDiagnostic, + _objectsIndex: ObjectsIndex | undefined, +): Fix { + const msg = diagnostic.message ?? ''; + if (msg.includes('results') || msg.includes('records')) { + return { + type: 'guidance', + description: + 'GraphQL query results use `result.records.results` for the list and `result.records.total_entries` for count. Do NOT use `result.results` or `result.total_count`.', + }; + } + return { + type: 'guidance', + description: + "The linter cannot verify this property exists. Check the object's traversal path with `platformos_hover`. For GraphQL results: `result.records.results` for the list.", + }; +} + +function fixLiquidHTMLSyntaxError(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + if (diagnostic.line == null) return null; + const lines = content.split('\n'); + const line = lines[diagnostic.line]; + + if (line && /\{%[-\s]*(?:graphql|function)\s+\w+\s+['"]/.test(line)) { + const fixedLine = line.replace(/(\{%[-\s]*(?:graphql|function)\s+\w+)\s+(['"])/, '$1 = $2'); + if (fixedLine !== line) { + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: 0 }, + end: { line: diagnostic.line, character: line.length }, + }, + new_text: fixedLine, + description: 'Add missing `=` in graphql/function tag assignment', + }; + } + } + + if (msg.includes('end') || msg.includes('unclosed') || msg.includes('Unclosed')) { + return { + type: 'guidance', + description: + 'Unclosed Liquid block detected. Ensure every `{% if %}` has `{% endif %}`, every `{% for %}` has `{% endfor %}`, etc. Inside `{% liquid %}` blocks, each statement goes on its own line without delimiters.', + }; + } + if (msg.includes('quote') || msg.includes('Quote') || msg.includes('string')) { + return { + type: 'guidance', + description: + 'Check for mismatched quotes. Single and double quotes must be properly paired. In Liquid, both `\'string\'` and `"string"` are valid.', + }; + } + return null; +} + +// ── pos-supervisor:* structural fixes ────────────────────────────────────── + +function fixStructuralCheck( + diagnostic: FixDiagnostic, + content: string, + ast: LiquidHtmlNode | null, + filePath: string, +): Fix | null { + switch (diagnostic.check) { + case 'pos-supervisor:InvalidMethod': + return fixInvalidMethod(diagnostic, content); + case 'pos-supervisor:InvalidSlug': + return fixInvalidSlugEdit(diagnostic, content); + case 'pos-supervisor:MissingContentForLayout': + return fixMissingContentForLayout(diagnostic, content, ast); + case 'pos-supervisor:MissingReturn': + return fixMissingReturnInsert(diagnostic, content); + case 'pos-supervisor:MissingDocBlock': + return fixMissingDocBlockInsert(diagnostic, content, ast); + case 'pos-supervisor:MissingSlug': + return fixMissingSlugInsert(diagnostic, content, filePath); + case 'pos-supervisor:GraphqlInPartial': + return { + type: 'guidance', + description: + 'Move the `{% graphql %}` call to a page or command. Pass the query results to the partial: `{% render "partial", data: query_result %}`.', + }; + case 'pos-supervisor:HtmlInPage': + return { + type: 'guidance', + description: + 'Extract HTML into a partial and render it: `{% render "partial_name" %}`. Pages should contain only logic (graphql, assign, render, redirect_to).', + }; + case 'pos-supervisor:ShopifyObject': + return fixShopifyObject(diagnostic); + case 'pos-supervisor:SchemaPropertyType': + return fixSchemaPropertyType(diagnostic, content); + case 'pos-supervisor:InvalidLayout': + return { + type: 'create_file', + path: extractLayoutPath(diagnostic.message), + description: 'Create the missing layout file. Layouts live in `app/views/layouts/`.', + }; + case 'pos-supervisor:InvalidFrontMatter': + return fixInvalidFrontMatter(diagnostic, content); + default: + return null; + } +} + +function fixInvalidMethod(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + const methodMatch = msg.match(/`(\w+)`.*lowercase.*`(\w+)`/) ?? msg.match(/`(\w+)`/); + if (!methodMatch) return null; + const currentMethod = methodMatch[1]; + const lowerMethod = methodMatch[2] ?? currentMethod.toLowerCase(); + if (diagnostic.line == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (!line) return null; + const re = new RegExp(`method:\\s*${currentMethod}\\b`); + const match = line.match(re); + if (!match) return null; + const methodStart = line.indexOf(currentMethod, match.index); + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: methodStart }, + end: { line: diagnostic.line, character: methodStart + currentMethod.length }, + }, + new_text: lowerMethod, + description: `Fix method case: \`${currentMethod}\` → \`${lowerMethod}\``, + }; +} + +function fixInvalidSlugEdit(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + const correctedMatch = msg.match(/: `([^`]+)`\.$/); + if (!correctedMatch) return null; + const correctedSlug = correctedMatch[1]; + if (diagnostic.line == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (!line) return null; + const slugMatch = line.match(/slug:\s*(.+)$/); + if (!slugMatch) return null; + const valueStart = line.indexOf(slugMatch[1]); + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: valueStart }, + end: { line: diagnostic.line, character: valueStart + slugMatch[1].length }, + }, + new_text: correctedSlug, + description: `Fix slug syntax: \`${slugMatch[1].trim()}\` → \`${correctedSlug}\``, + }; +} + +function fixMissingContentForLayout( + _diagnostic: FixDiagnostic, + content: string, + _ast: LiquidHtmlNode | null, +): Fix { + const bodyMatch = content.match(/]*>/); + if (bodyMatch && bodyMatch.index !== undefined) { + const offset = bodyMatch.index + bodyMatch[0].length; + const pos = offsetToLineCol(content, offset); + return { + type: 'insert', + range: { start: pos, end: pos }, + new_text: '\n {{ content_for_layout }}\n', + description: 'Insert `{{ content_for_layout }}` after `` to render page content', + }; + } + return { + type: 'insert', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + new_text: '{{ content_for_layout }}\n', + description: + 'Insert `{{ content_for_layout }}` — required for page content to render in layout', + }; +} + +function fixMissingReturnInsert(_diagnostic: FixDiagnostic, content: string): Fix { + const lines = content.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim() === '%}') { + return { + type: 'insert', + range: { start: { line: i, character: 0 }, end: { line: i, character: 0 } }, + new_text: ' return object\n', + description: 'Add `return object` before the end of the liquid block', + }; + } + } + return { + type: 'insert', + range: { + start: { line: lines.length, character: 0 }, + end: { line: lines.length, character: 0 }, + }, + new_text: '{% return object %}\n', + description: 'Add `{% return object %}` at the end of the command', + }; +} + +function fixMissingDocBlockInsert( + _diagnostic: FixDiagnostic, + content: string, + ast: LiquidHtmlNode | null, +): Fix { + const fmEnd = ast ? findFrontMatterEnd(ast, content) : null; + const insertLine = fmEnd ? fmEnd.line + 1 : 0; + return { + type: 'insert', + range: { + start: { line: insertLine, character: 0 }, + end: { line: insertLine, character: 0 }, + }, + new_text: '{% doc %}\n @param {object} param_name - Description\n{% enddoc %}\n\n', + description: 'Add `{% doc %}` block to document parameters', + _source: 'MissingDocBlock', + }; +} + +function fixMissingSlugInsert(_diagnostic: FixDiagnostic, content: string, filePath: string): Fix { + const slug = slugFromPath(filePath); + const slugLine = `slug: ${slug}\n`; + const hasFrontMatter = content.startsWith('---\n'); + if (hasFrontMatter) { + return { + type: 'insert', + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } }, + new_text: slugLine, + description: slug + ? `Add \`slug: ${slug}\` to front matter` + : 'Add `slug:` to front matter to define the URL explicitly', + }; + } + return { + type: 'insert', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + new_text: `---\n${slugLine}---\n`, + description: slug + ? `Add front matter with \`slug: ${slug}\`` + : 'Add front matter with `slug:` to define the URL', + }; +} + +function fixShopifyObject(diagnostic: FixDiagnostic): Fix { + const msg = diagnostic.message ?? ''; + const nameMatch = msg.match(/`(\w+)`/); + const name = nameMatch ? nameMatch[1] : 'object'; + return { + type: 'guidance', + description: `\`${name}\` is a Shopify theme object. In platformOS: use \`{% graphql %}\` to fetch data from your schema, \`context.params\` for request parameters, \`context.current_user\` for user data.`, + }; +} + +const TYPE_ALIASES: Readonly> = { + int: 'integer', + number: 'integer', + num: 'integer', + double: 'float', + decimal: 'float', + bool: 'boolean', + str: 'string', + varchar: 'string', + char: 'string', + timestamp: 'datetime', + time: 'datetime', + file: 'upload', + image: 'upload', + attachment: 'upload', + blob: 'upload', + json: 'text', + object: 'text', + hash: 'text', + list: 'array', +}; + +function fixSchemaPropertyType(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + const typeMatch = msg.match(/`(\w+)`.*Did you mean `(\w+)`/); + if (typeMatch) { + const invalidType = typeMatch[1]; + const suggestedType = typeMatch[2]; + if (diagnostic.line == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (line) { + const typeIdx = line.indexOf(invalidType); + if (typeIdx >= 0) { + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: typeIdx }, + end: { line: diagnostic.line, character: typeIdx + invalidType.length }, + }, + new_text: suggestedType, + description: `Fix property type: \`${invalidType}\` → \`${suggestedType}\``, + }; + } + } + } + + const rawTypeMatch = msg.match(/`(\w+)`/); + if (rawTypeMatch) { + const invalidType = rawTypeMatch[1]; + const suggestion = TYPE_ALIASES[invalidType.toLowerCase()]; + if (suggestion) { + if (diagnostic.line == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (line) { + const typeIdx = line.indexOf(invalidType); + if (typeIdx >= 0) { + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: typeIdx }, + end: { line: diagnostic.line, character: typeIdx + invalidType.length }, + }, + new_text: suggestion, + description: `Fix property type: \`${invalidType}\` → \`${suggestion}\``, + }; + } + } + } + } + return null; +} + +function fixInvalidFrontMatter(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + if (diagnostic.line == null) return null; + const line = content.split('\n')[diagnostic.line]; + if (!line) return null; + if (diagnostic.severity === 'error') { + const keyMatch = msg.match(/`(\w+)`/); + if (keyMatch) { + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: 0 }, + end: { line: diagnostic.line, character: line.length }, + }, + new_text: '', + description: `Remove invalid front matter key \`${keyMatch[1]}\` — ${msg}`, + }; + } + } + return null; +} + +function extractLayoutPath(message: string | undefined): string { + const expected = message?.match(/Expected file:\s*`([^`]+)`/); + if (expected) return expected[1]; + const layoutName = message?.match(/`([^`]+)`.*not found/)?.[1]; + return layoutName + ? `app/views/layouts/${layoutName}.liquid` + : 'app/views/layouts/application.liquid'; +} + +function fixTranslationKeyExists(diagnostic: FixDiagnostic, content: string): Fix | null { + const msg = diagnostic.message ?? ''; + const wrongKeyMatch = msg.match(/['"`]([^'"`]+)['"`]/); + const wrongKey = wrongKeyMatch ? wrongKeyMatch[1] : null; + + if (wrongKey && /\[\d+\]/.test(wrongKey)) return null; + + const suggestMatch = msg.match(/[Dd]id you mean\s+['"`]([^'"`]+)['"`]/); + if (suggestMatch && wrongKey && wrongKey !== suggestMatch[1] && diagnostic.line != null) { + const suggestedKey = suggestMatch[1]; + const line = content.split('\n')[diagnostic.line]; + if (line) { + for (const pattern of [`'${wrongKey}'`, `"${wrongKey}"`]) { + const idx = line.indexOf(pattern); + if (idx >= 0) { + const quote = pattern[0]; + return { + type: 'text_edit', + range: { + start: { line: diagnostic.line, character: idx }, + end: { line: diagnostic.line, character: idx + pattern.length }, + }, + new_text: `${quote}${suggestedKey}${quote}`, + description: `Replace \`${wrongKey}\` with \`${suggestedKey}\``, + }; + } + } + } + } + return { + type: 'guidance', + description: + 'Translation key not found. Add it to app/translations/en.yml, or check for typos in the key name.', + }; +} + +// ── Doc-param fix merge ──────────────────────────────────────────────────── + +interface PendingDocFix extends AddDocParamFix { + index: number; +} + +function mergeDocParamFixes( + paramFixes: PendingDocFix[], + content: string, + ast: LiquidHtmlNode | null, +): Fix { + const uniqueParams = [...new Set(paramFixes.map((f) => f.param_name))]; + + const existingDoc = ast ? findDocBlock(ast, content) : null; + if (existingDoc) { + const newParams = uniqueParams.filter((p) => !existingDoc.existingParams.includes(p)); + if (newParams.length === 0) { + return { + type: 'guidance', + description: `All parameters (${uniqueParams.join(', ')}) are already declared in {% doc %} block — linter may need a more specific type annotation`, + }; + } + const paramLines = newParams.map((p) => ` @param {object} ${p}`).join('\n'); + const enddocMatch = content.indexOf('{% enddoc %}', existingDoc.startOffset); + if (enddocMatch >= 0) { + const insertPos = offsetToLineCol(content, enddocMatch); + return { + type: 'insert', + range: { start: insertPos, end: insertPos }, + new_text: paramLines + '\n', + description: `Add parameter declarations to existing {% doc %} block: ${newParams.join(', ')}`, + resolves_params: newParams, + }; + } + } + + const paramLines = uniqueParams.map((p) => ` @param {object} ${p}`).join('\n'); + const docBlock = `{% doc %}\n${paramLines}\n{% enddoc %}\n\n`; + const fmEnd = ast ? findFrontMatterEnd(ast, content) : null; + const insertLine = fmEnd ? fmEnd.line + 1 : 0; + return { + type: 'insert', + range: { + start: { line: insertLine, character: 0 }, + end: { line: insertLine, character: 0 }, + }, + new_text: docBlock, + description: `Add {% doc %} block declaring parameters: ${uniqueParams.join(', ')}`, + resolves_params: uniqueParams, + }; +} + +// ── Public API: generateFixes ────────────────────────────────────────────── + +function dispatchFix( + d: FixDiagnostic, + varIndex: IndexedVariable[], + filterIdx: IndexedFilter[], + isPartialLike: boolean, + ctx: FixIndexes, + ast: LiquidHtmlNode | null, + content: string, + filePath: string, + projectDir: string | null, +): Fix | null { + switch (d.check) { + case 'UndefinedObject': + return fixUndefinedObject(d, varIndex, isPartialLike, ctx.objectsIndex); + case 'UnknownFilter': + return fixUnknownFilter(d, filterIdx, ctx.filtersIndex, ctx.tagsIndex); + case 'ConvertIncludeToRender': + return fixConvertInclude(d, content); + case 'DeprecatedTag': + return fixDeprecatedTag(d, content); + case 'MissingPartial': + return fixMissingPartial(d, projectDir, ast, content); + case 'MissingRenderPartialArguments': + return fixMissingRenderPartialArguments(d, ast, content); + case 'NestedGraphQLQuery': + return fixNestedGraphQLQuery(d); + case 'TranslationKeyExists': + return fixTranslationKeyExists(d, content); + case 'ImgLazyLoading': + return fixImgLazyLoading(d, content); + case 'ImgWidthAndHeight': + return fixImgWidthAndHeight(d, content); + case 'HardcodedRoutes': + return fixHardcodedRoutes(d, content); + case 'InvalidHashAssignTarget': + return fixInvalidHashAssignTarget(d, content); + case 'MissingAsset': + return fixMissingAsset(d); + case 'MetadataParamsCheck': + return fixMetadataParamsCheck(d, ast, content); + case 'UnknownProperty': + return fixUnknownProperty(d, ctx.objectsIndex); + case 'LiquidHTMLSyntaxError': + return fixLiquidHTMLSyntaxError(d, content); + default: + if (typeof d.check === 'string' && d.check.startsWith('pos-supervisor:')) { + return fixStructuralCheck(d, content, ast, filePath); + } + return null; + } +} + +/** + * Generate proposed fixes for a set of diagnostics. Returns both the + * deduplicated list (`proposedFixes`) and a per-diagnostic map keyed by + * the diagnostic's index in the input array. + */ +export function generateFixes( + diagnostics: FixDiagnostic[], + ast: LiquidHtmlNode | null, + content: string, + filePath: string, + ctx: FixIndexes, + projectDir: string | null = null, +): GenerateFixesResult { + const proposedFixes: Fix[] = []; + const diagnosticFixes = new Map(); + + const isPartialLike = /\/(partials|commands|queries)\//.test(filePath); + const varIndex = ast ? indexVariables(ast, content) : []; + const filterIdx = ast ? indexFilters(ast, content) : []; + + const docParamFixes: PendingDocFix[] = []; + + for (let i = 0; i < diagnostics.length; i++) { + const d = diagnostics[i]; + const fix = dispatchFix( + d, + varIndex, + filterIdx, + isPartialLike, + ctx, + ast, + content, + filePath, + projectDir, + ); + if (!fix) continue; + + // Stamp heuristic rule_id once per fix. + if (!fix.rule_id) { + fix.rule_id = `heuristic:${d.check ?? 'Unknown'}.${fix.type ?? 'fix'}`; + } + + if (fix.type === 'add_doc_param') { + docParamFixes.push({ ...fix, index: i }); + diagnosticFixes.set(i, { + type: 'add_doc_param', + description: fix.description, + param_name: fix.param_name, + rule_id: fix.rule_id, + }); + } else { + diagnosticFixes.set(i, fix); + const candidate = fix as Fix & { range?: FixRange; new_text?: string }; + const isDupe = proposedFixes.some((f) => { + const ff = f as Fix & { range?: FixRange; new_text?: string }; + return ( + ff.type === fix.type && + ff.description === fix.description && + ff.new_text === candidate.new_text && + ff.range?.start?.line === candidate.range?.start?.line && + ff.range?.start?.character === candidate.range?.start?.character + ); + }); + if (!isDupe) proposedFixes.push(fix); + } + } + + if (docParamFixes.length > 0) { + const merged = mergeDocParamFixes(docParamFixes, content, ast); + // Drop any generic MissingDocBlock insert — the merged fix supersedes it. + for (let j = proposedFixes.length - 1; j >= 0; j--) { + if (proposedFixes[j]._source === 'MissingDocBlock') { + proposedFixes.splice(j, 1); + } + } + proposedFixes.push(merged); + for (const item of docParamFixes) { + diagnosticFixes.set(item.index, { + type: merged.type, + description: merged.description, + param_name: item.param_name, + } as Fix); + } + } + + // Attach before/after context to text_edit fixes for the dashboard display. + for (const fix of diagnosticFixes.values()) { + if (fix.type === 'text_edit') { + const ctxInfo = generateFixContext(fix, content); + if (ctxInfo) (fix as TextEditFix).context = ctxInfo; + } + } + for (const fix of proposedFixes) { + // Parity with source: attach before/after context only to `text_edit` + // fixes. `insert` fixes (e.g. MissingDocBlock) are skipped — they + // synthesise a block of new content, not a line-level edit, so the + // before/after framing is misleading. P24 parity gate pins this. + if (fix.type === 'text_edit') { + const ctxInfo = generateFixContext(fix as TextEditFix, content); + if (ctxInfo) (fix as TextEditFix).context = ctxInfo; + } + } + + return { proposedFixes, diagnosticFixes }; +} + +function generateFixContext(fix: TextEditFix | InsertFix, content: string): FixContext | null { + if (!fix.range?.start || fix.new_text == null) return null; + const lines = content.split('\n'); + const line = lines[fix.range.start.line]; + if (!line) return null; + const before = line.trim(); + const startChar = fix.range.start.character; + const endChar = fix.range.end?.character ?? startChar; + const after = (line.slice(0, startChar) + fix.new_text + line.slice(endChar)).trim(); + if (before === after) return null; + return { before, after, line: fix.range.start.line }; +} + +// ── Public API: clusterDiagnostics ───────────────────────────────────────── + +const CONTEXT_VAR_NAMES = new Set([ + 'params', + 'session', + 'current_user', + 'page', + 'location', + 'headers', + 'environment', +]); + +interface ClusterAccumulator extends FixDiagnostic { + _severity: 'error' | 'warning'; + fix?: Fix; +} + +/** + * Cluster related diagnostics for reduced noise. Groups by check name + + * extracts a common pattern when 2 or more diagnostics share it. + */ +export function clusterDiagnostics( + errors: FixDiagnostic[], + warnings: FixDiagnostic[], +): DiagnosticCluster[] { + const all: ClusterAccumulator[] = [ + ...errors.map((e) => ({ ...e, _severity: 'error' })), + ...warnings.map((w) => ({ ...w, _severity: 'warning' })), + ]; + + const groups = new Map(); + for (const d of all) { + const key = d.check; + let bucket = groups.get(key); + if (!bucket) { + bucket = []; + groups.set(key, bucket); + } + bucket.push(d); + } + + const clusters: DiagnosticCluster[] = []; + for (const [check, items] of groups) { + if (items.length < 2) continue; + + let pattern: string | null = null; + let unifiedFix: string | null = null; + + if (check === 'UndefinedObject') { + const vars = items + .map((d) => { + const m = d.message?.match(/['"`]([^'"`]+)['"`]/); + return m ? m[1] : null; + }) + .filter((v): v is string => !!v); + const contextVars = vars.filter((v) => CONTEXT_VAR_NAMES.has(v)); + if (contextVars.length >= 2) { + pattern = 'context_properties'; + unifiedFix = `These are all \`context\` sub-objects. Prefix each with \`context.\`: ${contextVars.map((v) => `\`context.${v}\``).join(', ')}.`; + } + } + + if (check === 'UnknownFilter') { + pattern = 'multiple_unknown_filters'; + const names = items + .map((d) => { + const m = d.message?.match(/`([^`]+)`/); + return m ? m[1] : null; + }) + .filter((n): n is string => !!n); + if (names.length >= 2) { + unifiedFix = `${names.length} unknown filters found: ${names.map((n) => `\`${n}\``).join(', ')}. Check for Shopify-specific filters and typos.`; + } + } + + if (pattern) { + clusters.push({ + check, + count: items.length, + pattern, + unified_fix: unifiedFix, + items: items.map((d) => ({ + line: d.line, + column: d.column, + message: d.message, + fix: d.fix, + })), + }); + } + } + + return clusters; +} + +// ── Public API: generateScorecard ────────────────────────────────────────── + +export function generateScorecard( + structural: StructuralLike | null | undefined, + domain: string | null | undefined, + errors: FixDiagnostic[], + _warnings: FixDiagnostic[], +): ScorecardNote[] { + const notes: ScorecardNote[] = []; + if (!structural) return notes; + + const queryCount = structural.graphql_queries?.length ?? 0; + const renderCount = structural.renders?.length ?? 0; + const tagCount = structural.tags_used?.length ?? 0; + const transKeyCount = structural.translation_keys?.length ?? 0; + + if (domain === 'pages' && queryCount >= 3) { + notes.push({ + level: 'advisory', + message: `Page runs ${queryCount} GraphQL queries — consider consolidating into fewer queries or using a command to orchestrate data fetching.`, + }); + } + + if (domain === 'pages' && renderCount === 0 && tagCount > 3) { + notes.push({ + level: 'advisory', + message: + 'Page renders 0 partials. Extract reusable HTML into partials for better maintainability.', + }); + } + + if (domain === 'commands' && queryCount > 0) { + const hasTry = structural.tags_used?.includes('try'); + const hasIfErrors = errors.length === 0 && !hasTry; + if (hasIfErrors) { + notes.push({ + level: 'advisory', + message: + 'Command runs GraphQL queries but has no `{% try %}` error handling. Wrap queries in `{% try %}...{% catch error %}` to handle failures gracefully.', + }); + } + } + + if (domain === 'partials' && structural.prompts && structural.prompts.length > 0) { + const paramCount = structural.prompts.reduce( + (count, p) => count + (p.match(/@param/g)?.length ?? 0), + 0, + ); + if (paramCount >= 8) { + notes.push({ + level: 'advisory', + message: `Partial declares ${paramCount} parameters — consider splitting into smaller, focused partials.`, + }); + } + } + + if (transKeyCount >= 10) { + notes.push({ + level: 'advisory', + message: `File uses ${transKeyCount} translation keys — verify all keys exist in translation files.`, + }); + } + + if (renderCount >= 8) { + notes.push({ + level: 'advisory', + message: `File renders ${renderCount} partials — ensure each partial serves a distinct purpose and nesting isn't too deep.`, + }); + } + + return notes; +} diff --git a/packages/platformos-mcp-supervisor/src/core/hint-loader.ts b/packages/platformos-mcp-supervisor/src/core/hint-loader.ts new file mode 100644 index 00000000..3ec64e28 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/hint-loader.ts @@ -0,0 +1,122 @@ +/** + * Reference-driven error hint loader. + * + * Lazily reads `data/hints/[-].md` on first access and caches + * the rendered template body. Hint filenames are bare check names (no + * `pos-supervisor:` prefix) so they stay portable on Windows / NTFS where + * `:` is reserved — the prefix is stripped at lookup time. + * + * The hint engine supports a small Mustache-like template language: + * {{var}} — variable substitution + * {{#if var}}…{{else}}…{{/if}} — conditional with else + * {{#if var}}…{{/if}} — conditional without else + * {{#unless var}}…{{/unless}} — negated conditional + * + * When no `vars` are supplied and no conditionals exist, unresolved `{{var}}` + * tokens are stripped to produce a safe generic hint. + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +/** + * `__dirname` resolves to `dist/core/` after build and to `src/core/` in + * dev / vitest. In both layouts, `../data/hints` is the correct location + * because the post-build `copy-data` step mirrors `src/data` into `dist/data`. + */ +const HINTS_DIR = join(__dirname, '..', 'data', 'hints'); + +export type HintVars = Record; + +const cache = new Map(); +let loaded = false; + +function loadAll(): void { + if (loaded) return; + loaded = true; + if (!existsSync(HINTS_DIR)) return; + for (const file of readdirSync(HINTS_DIR)) { + if (!file.endsWith('.md')) continue; + const key = basename(file, '.md'); + cache.set(key, readFileSync(join(HINTS_DIR, file), 'utf-8').trim()); + } +} + +function isTruthy(v: HintVars[string]): boolean { + return v !== false && v !== null && v !== undefined && v !== ''; +} + +/** + * Get a hint for a linter check, with optional template-variable substitution. + * + * @param checkName e.g. `'MissingPartial'`, `'pos-supervisor:HtmlInPage'` + * @param variant e.g. `'partial'` selects `MissingPartial-partial.md` + * @param vars values for `{{var}}` / `{{#if var}}` tokens + * @returns the rendered hint, or `null` if no matching file exists + */ +export function getHint( + checkName: string, + variant: string | null = null, + vars: HintVars = {}, +): string | null { + loadAll(); + + const key = checkName.replace(/^pos-supervisor:/, ''); + let hint: string | null = null; + if (variant) { + hint = cache.get(`${key}-${variant}`) ?? null; + } + if (hint === null) { + hint = cache.get(key) ?? null; + } + if (hint === null) return null; + + const hasVars = Object.keys(vars).length > 0; + const hasConditionals = /\{\{#(?:if|unless)\s+\w+\}\}/.test(hint); + + // No vars and no conditionals → strip unresolved tokens to produce a safe generic hint. + if (!hasVars && !hasConditionals) { + return hint + .replace(/\{\{(\w+)\}\}/g, '') + .replace(/ {2,}/g, ' ') + .replace(/ ([.,])/g, '$1') + .trim(); + } + + // 1. {{#if var}}…{{else}}…{{/if}} — most specific, apply first. + hint = hint.replace( + /\{\{#if (\w+)\}\}([\s\S]*?)\{\{else\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, varName: string, truePart: string, falsePart: string) => + isTruthy(vars[varName]) ? truePart : falsePart, + ); + + // 2. {{#if var}}…{{/if}} + hint = hint.replace( + /\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, varName: string, body: string) => (isTruthy(vars[varName]) ? body : ''), + ); + + // 3. {{#unless var}}…{{/unless}} + hint = hint.replace( + /\{\{#unless (\w+)\}\}([\s\S]*?)\{\{\/unless\}\}/g, + (_match, varName: string, body: string) => (isTruthy(vars[varName]) ? '' : body), + ); + + // 4. Simple {{var}} substitution. When no vars were supplied (only + // conditionals), skip the cleanup pass so the hint keeps any literal + // `{{var}}` content the template intentionally produced. + if (!hasVars) return hint; + return hint + .replace(/\{\{(\w+)\}\}/g, (_match, varName: string) => + varName in vars ? String(vars[varName] ?? '') : '', + ) + .replace(/ {2,}/g, ' ') + .replace(/ ([.,])/g, '$1') + .trim(); +} + +/** Reset the in-memory cache. For tests only. */ +export function _resetHintCache(): void { + cache.clear(); + loaded = false; +} diff --git a/packages/platformos-mcp-supervisor/src/core/knowledge-loader.ts b/packages/platformos-mcp-supervisor/src/core/knowledge-loader.ts new file mode 100644 index 00000000..3276b7e3 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/knowledge-loader.ts @@ -0,0 +1,319 @@ +/** + * Knowledge-base loader. + * + * Lazily reads the structured rule + domain + Shopify metadata at first + * access. The on-disk layout splits the data across files for editability; + * this module presents a single in-memory `KnowledgeBase` and a stable set + * of typed getters consumed by `error-enricher`, `diagnostic-pipeline`, + * `structural-warnings`, `fix-generator`, and the individual rule modules. + * + * Resolution: `join(__dirname, '..', 'data')` — works in both `dist/` + * (after the post-build `copy-data` step) and `src/` (vitest / dev). + */ + +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import yaml from 'js-yaml'; + +const DATA_DIR = join(__dirname, '..', 'data'); + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface KnowledgeCheck { + name: string; + summary?: string; + /** Per-context hint variants. `default` is the canonical one. */ + hint?: Record; + /** Populated from `shopify-objects.json` for the `UndefinedObject` check. */ + shopify_objects?: string[]; + /** Populated from `shopify-objects.json` for the `UnknownFilter` check. */ + shopify_filters?: string[]; + /** Optional Shopify-migration guidance string. */ + shopify_guidance?: string; +} + +export interface DomainGotcha { + id: string; + /** `'always' | 'has_check:' | 'uses_tag:' | 'uses_filter:'`. */ + trigger: string; + message: string; + severity: string; +} + +export interface DomainDefinition { + rule?: string; + gotchas?: DomainGotcha[]; +} + +export interface ContentTriggerDef { + id: string; + pattern: string; + message: string; + severity: string; + domains?: string[]; +} + +export interface ModulesMissingDocs { + description?: string; + known: string[]; +} + +export interface ShopifyContaminationEntry { + replacement: string | null; + note?: string; +} + +export interface ShopifyContamination { + objects?: Record; + filters?: Record; + tags?: Record; +} + +export interface KnowledgeBase { + checks: Record; + domains: Record; + language_features: Record; + content_triggers: ContentTriggerDef[]; + modules_missing_docs: ModulesMissingDocs; +} + +export interface TriggerContext { + checks?: Set; + tags?: Set; + filters?: Set; +} + +export interface CheckKnowledgeView { + summary?: string; + hint: string | null; + shopify_guidance?: string; +} + +export interface TriggeredGotchasView { + rule?: string; + gotchas: Array>; +} + +export interface ContentTriggerHit { + id: string; + message: string; + severity: string; +} + +// ── State ────────────────────────────────────────────────────────────────── + +let _knowledge: KnowledgeBase | null = null; +let _shopifyObjects: Set | null = null; +let _shopifyFilters: Set | null = null; +let _shopifyContamination: ShopifyContamination | null = null; + +// ── Filesystem helpers ───────────────────────────────────────────────────── + +function loadYaml(filePath: string): T | null { + if (!existsSync(filePath)) return null; + try { + return yaml.load(readFileSync(filePath, 'utf8')) as T; + } catch { + return null; + } +} + +function loadJson(filePath: string): T | null { + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; + } catch { + return null; + } +} + +function loadShopifyContamination(): ShopifyContamination | null { + if (_shopifyContamination) return _shopifyContamination; + const path = join(DATA_DIR, 'shopify-contamination.json'); + _shopifyContamination = loadJson(path); + return _shopifyContamination; +} + +function loadFromSplitFiles(): KnowledgeBase | null { + const kb: KnowledgeBase = { + checks: {}, + domains: {}, + language_features: {}, + content_triggers: [], + modules_missing_docs: { known: [] }, + }; + + const checksDir = join(DATA_DIR, 'checks'); + if (existsSync(checksDir)) { + for (const file of readdirSync(checksDir)) { + if (!file.endsWith('.yml') && !file.endsWith('.yaml')) continue; + const check = loadYaml(join(checksDir, file)); + if (check?.name) kb.checks[check.name] = check; + } + } + if (Object.keys(kb.checks).length === 0) return null; + + type ShopifyObjectsFile = { objects?: string[]; filters?: string[] }; + const shopify = loadJson(join(DATA_DIR, 'shopify-objects.json')); + if (shopify) { + if (kb.checks.UndefinedObject) { + kb.checks.UndefinedObject.shopify_objects = shopify.objects ?? []; + } + if (kb.checks.UnknownFilter) { + kb.checks.UnknownFilter.shopify_filters = shopify.filters ?? []; + } + } + + kb.domains = + loadYaml>(join(DATA_DIR, 'domain-gotchas.yml')) ?? {}; + kb.content_triggers = loadYaml(join(DATA_DIR, 'content-triggers.yml')) ?? []; + kb.language_features = + loadYaml>(join(DATA_DIR, 'language-features.yml')) ?? {}; + kb.modules_missing_docs = loadJson( + join(DATA_DIR, 'modules-missing-docs.json'), + ) ?? { known: [] }; + + return kb; +} + +function load(): KnowledgeBase | null { + if (_knowledge) return _knowledge; + + _knowledge = loadFromSplitFiles(); + if (!_knowledge) { + // Monolithic-knowledge fallback (legacy layout). + const jsonPath = join(DATA_DIR, 'knowledge.json'); + _knowledge = loadJson(jsonPath); + } + if (!_knowledge) return null; + + _shopifyObjects = new Set(); + _shopifyFilters = new Set(); + const uo = _knowledge.checks.UndefinedObject; + if (uo?.shopify_objects) uo.shopify_objects.forEach((o) => _shopifyObjects!.add(o)); + const uf = _knowledge.checks.UnknownFilter; + if (uf?.shopify_filters) uf.shopify_filters.forEach((f) => _shopifyFilters!.add(f)); + + return _knowledge; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * Structured knowledge for a check: TL;DR summary + per-context hint. + * Returns `null` when the check is unknown. + */ +export function getCheckKnowledge( + checkName: string, + context: string = 'default', +): CheckKnowledgeView | null { + const kb = load(); + if (!kb?.checks?.[checkName]) return null; + const check = kb.checks[checkName]; + const hint = check.hint?.[context] ?? check.hint?.default ?? null; + const result: CheckKnowledgeView = { summary: check.summary, hint }; + if (check.shopify_guidance) result.shopify_guidance = check.shopify_guidance; + return result; +} + +/** + * Evaluate domain gotchas against the current code state. + * + * `triggers` is a "what the current file looks like" snapshot: the set of + * check names emitted so far, the Liquid tags used, the filters used. + */ +export function getTriggeredGotchas( + domain: string, + triggers: TriggerContext = {}, +): TriggeredGotchasView | null { + const kb = load(); + if (!kb?.domains?.[domain]) return null; + const domainDef = kb.domains[domain]; + const checks = triggers.checks ?? new Set(); + const tags = triggers.tags ?? new Set(); + const filters = triggers.filters ?? new Set(); + + const matched: TriggeredGotchasView['gotchas'] = []; + for (const g of domainDef.gotchas ?? []) { + if (evaluateTrigger(g.trigger, checks, tags, filters)) { + matched.push({ id: g.id, message: g.message, severity: g.severity }); + } + } + + return { rule: domainDef.rule, gotchas: matched }; +} + +function evaluateTrigger( + trigger: string | undefined, + checks: Set, + tags: Set, + filters: Set, +): boolean { + if (!trigger) return false; + if (trigger === 'always') return true; + if (trigger.startsWith('has_check:')) return checks.has(trigger.slice('has_check:'.length)); + if (trigger.startsWith('uses_tag:')) return tags.has(trigger.slice('uses_tag:'.length)); + if (trigger.startsWith('uses_filter:')) return filters.has(trigger.slice('uses_filter:'.length)); + return false; +} + +/** True if `name` is a Shopify-only object identifier. */ +export function isShopifyObject(name: string): boolean { + load(); + return _shopifyObjects?.has(name) ?? false; +} + +/** True if `name` is a Shopify-only filter identifier. */ +export function isShopifyFilter(name: string): boolean { + load(); + return _shopifyFilters?.has(name) ?? false; +} + +/** Migration guidance for a Shopify object reference, or `null` if unknown. */ +export function getShopifyObject(name: string): ShopifyContaminationEntry | null { + return loadShopifyContamination()?.objects?.[name] ?? null; +} + +/** Migration guidance for a Shopify filter reference, or `null` if unknown. */ +export function getShopifyFilter(name: string): ShopifyContaminationEntry | null { + return loadShopifyContamination()?.filters?.[name] ?? null; +} + +/** Migration guidance for a Shopify tag reference, or `null` if unknown. */ +export function getShopifyTag(name: string): ShopifyContaminationEntry | null { + return loadShopifyContamination()?.tags?.[name] ?? null; +} + +/** Module paths whose missing `{% doc %}` blocks are known and suppressed. */ +export function getKnownModulesMissingDocs(): Set { + const kb = load(); + return new Set(kb?.modules_missing_docs?.known ?? []); +} + +/** Pattern-driven advisories matched against `content` for the given `domain`. */ +export function getContentTriggers(content: string, domain: string): ContentTriggerHit[] { + const kb = load(); + if (!kb?.content_triggers || !content || !domain) return []; + + const matched: ContentTriggerHit[] = []; + for (const trigger of kb.content_triggers) { + if (trigger.domains && !trigger.domains.includes(domain)) continue; + try { + if (new RegExp(trigger.pattern).test(content)) { + matched.push({ id: trigger.id, message: trigger.message, severity: trigger.severity }); + } + } catch { + // Skip malformed patterns. + } + } + + return matched; +} + +/** Reset all in-memory state. For tests only. */ +export function _resetKnowledge(): void { + _knowledge = null; + _shopifyObjects = null; + _shopifyFilters = null; + _shopifyContamination = null; +} diff --git a/packages/platformos-mcp-supervisor/src/core/liquid-parser.ts b/packages/platformos-mcp-supervisor/src/core/liquid-parser.ts new file mode 100644 index 00000000..12a2fbe5 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/liquid-parser.ts @@ -0,0 +1,306 @@ +/** + * Liquid template parser façade. + * + * Wraps `@platformos/liquid-html-parser` with two project-specific concerns: + * + * 1. Tolerant-parse with a `try/catch` so the synchronous validate_code + * pipeline can keep going on broken input (the LSP path catches the + * syntax error separately). + * 2. A single-pass AST walk that extracts every structural element the + * downstream stages need (renders, graphql calls, filters, tags, + * translation keys, doc params, frontmatter slug/layout/method, + * `{% doc prompt %}` blocks) into a typed `ExtractedStructural` object. + * + * Re-exports `walk`, `NodeTypes`, and `NamedTags` so call sites that need + * direct AST traversal do not also need to import the underlying parser + * package. + */ + +import { + toLiquidHtmlAST, + walk, + NodeTypes, + NamedTags, + type LiquidHtmlNode, + type RenderMarkup, + type LiquidNamedArgument, +} from '@platformos/liquid-html-parser'; + +export { walk, NodeTypes, NamedTags }; +export type { LiquidHtmlNode }; + +// ── Public types ─────────────────────────────────────────────────────────── + +/** Surface form of a `{% graphql %}` call — see `classifyGraphqlSourceKind`. */ +export type GraphqlSourceKind = 'tag' | 'liquid_inline' | 'liquid_multiline_truncated'; + +export interface GraphqlRef { + variable: string; + queryName: string; + args: string[]; + source_kind: GraphqlSourceKind; +} + +export interface RenderCall { + partial: string; + args: string[]; +} + +export interface ExtractedStructural { + slug: string | null; + layout: string | null; + method: string | null; + renders: string[]; + renderCalls: RenderCall[]; + graphql: GraphqlRef[]; + filters: Set; + tags: Set; + transKeys: Set; + prompts: string[]; + docParams: Set; +} + +// ── Parse ───────────────────────────────────────────────────────────────── + +/** + * Parse Liquid content into an AST in tolerant mode. + * + * Returns `null` if the parser cannot recover at all. The validator's lint + * step still gets a chance to surface the syntax error from the LSP path — + * the parser's silent-`null` here ensures structural extraction does not + * cascade-fail every other check. + */ +export function parseLiquidFile(content: string): LiquidHtmlNode | null { + try { + return toLiquidHtmlAST(content, { mode: 'tolerant', allowUnclosedDocumentNode: true }); + } catch { + return null; + } +} + +// ── Extract ─────────────────────────────────────────────────────────────── + +/** + * Walk an already-parsed AST and extract every structural element in one pass. + * + * The AST is walked exactly once. Render calls and GraphQL calls are + * deduped by name (first call wins) while the GraphQL `source_kind` is + * upgraded to the most pessimistic value seen across duplicate calls so + * downstream rules can detect multi-line truncation regardless of which + * call survived the dedup. + */ +export function extractAllFromAST(ast: LiquidHtmlNode): ExtractedStructural { + let slug: string | null = null; + let layout: string | null = null; + let method: string | null = null; + const seenRenders = new Set(); + const renders: string[] = []; + const renderCalls: RenderCall[] = []; + const seenGQL = new Set(); + const graphql: GraphqlRef[] = []; + const filters = new Set(); + const tags = new Set(); + const transKeys = new Set(); + const prompts: string[] = []; + const docParams = new Set(); + + walk(ast, (node) => { + switch (node.type) { + case NodeTypes.YAMLFrontmatter: { + const body = node.body; + const m = body.match(/^slug:\s*(.+)$/m); + if (m) slug = m[1].trim(); + const lm = body.match(/^layout:\s*(.+)$/m); + if (lm) { + // Strip surrounding quotes — layout: "" or layout: '' means "no layout". + layout = lm[1].trim().replace(/^(['"])(.*)\1$/, '$2'); + } + const mm = body.match(/^method:\s*(.+)$/m); + if (mm) method = mm[1].trim(); + break; + } + + case NodeTypes.LiquidTag: { + tags.add(node.name); + + if (node.name === NamedTags.render || node.name === NamedTags.include) { + collectRender(node.markup, seenRenders, renders, renderCalls, transKeys); + } else if (node.name === NamedTags.graphql) { + const markup = node.markup; + // `markup` may be `string` for `LiquidTagBaseCase` (mid-completion + // or unparseable). Only the typed `GraphQLMarkup` variant carries + // the structured `graphql` / `name` / `args` fields we need. + if (typeof markup !== 'string' && markup.type === NodeTypes.GraphQLMarkup) { + const gqlPath = markup.graphql; + if (gqlPath.type === NodeTypes.String) { + const queryName = gqlPath.value; + const sourceKind = classifyGraphqlSourceKind(node); + if (seenGQL.has(queryName)) { + // Same op called twice. Keep the first entry but upgrade + // `source_kind` to the most pessimistic value across calls + // so downstream rules can detect truncation regardless of + // which call won the dedup. + if (sourceKind === 'liquid_multiline_truncated') { + const existing = graphql.find((g) => g.queryName === queryName); + if (existing) existing.source_kind = 'liquid_multiline_truncated'; + } + } else { + seenGQL.add(queryName); + graphql.push({ + variable: markup.name, + queryName, + args: extractNamedArgs(markup.args), + source_kind: sourceKind, + }); + } + } + } + } + break; + } + + case NodeTypes.LiquidRawTag: { + tags.add(node.name); + break; + } + + case NodeTypes.LiquidFilter: { + filters.add(node.name); + break; + } + + case NodeTypes.LiquidVariable: { + const hasT = node.filters?.some((f) => f.name === 't'); + if (hasT && node.expression?.type === NodeTypes.String) { + transKeys.add(node.expression.value); + } + break; + } + + case NodeTypes.LiquidDocPromptNode: { + prompts.push(node.content.value); + break; + } + + case NodeTypes.LiquidDocParamNode: { + const paramName = node.paramName?.value; + if (paramName) docParams.add(paramName); + break; + } + } + }); + + return { + slug, + layout, + method, + renders, + renderCalls, + graphql, + filters, + tags, + transKeys, + prompts, + docParams, + }; +} + +// ── Render / include extraction ─────────────────────────────────────────── + +/** + * The `LiquidTag` union covers both `LiquidTagRender`/`LiquidTagInclude` + * (markup typed as `RenderMarkup`) and the generic base case (markup typed + * as `string` — happens when the parser cannot fully resolve the call, + * e.g., mid-completion). We accept either form: the string form is parsed + * with a regex; the object form reads the typed `partial` field. + */ +function collectRender( + markup: RenderMarkup | string, + seenRenders: Set, + renders: string[], + renderCalls: RenderCall[], + transKeys: Set, +): void { + if (typeof markup === 'string') { + const partialMatch = markup.match(/^["']([^"']+)['"]/); + if (partialMatch) { + const partialName = partialMatch[1]; + if (!seenRenders.has(partialName)) { + seenRenders.add(partialName); + renders.push(partialName); + } + renderCalls.push({ partial: partialName, args: extractArgsFromMarkupString(markup) }); + } + // Inline `'key' | t` translation references — captured even when the + // call itself is unparseable, because the keys still exist in source. + for (const km of markup.matchAll(/["']([^"']+)['"]\s*\|\s*t\b/g)) { + transKeys.add(km[1]); + } + return; + } + + const partial = markup.partial; + if (partial.type === NodeTypes.String) { + const partialName = partial.value; + if (!seenRenders.has(partialName)) { + seenRenders.add(partialName); + renders.push(partialName); + } + renderCalls.push({ partial: partialName, args: extractNamedArgs(markup.args) }); + } +} + +/** + * Classify the surface form of a `{% graphql %}` call. + * + * `'tag'` — `{% graphql … %}` (with delimiters). + * `'liquid_inline'` — inside a `{% liquid %}` block, single-line. + * `'liquid_multiline_truncated'` — inside a `{% liquid %}` block, written + * with a `,` + newline continuation. The + * parser truncates the call at the first + * newline-comma, so `markup.args` silently + * drops every argument past that point — + * and pos-cli's LSP has the same blind + * spot. The agent sees the args in source; + * both parsers don't. + * + * Detection criterion for the truncated form: visible source text ends on + * a comma AND the immediately trailing characters contain another `name:` + * clause on a subsequent line. The trailing-text check is load-bearing — + * without it, a legitimate inline call that just happens to end on a + * comma (rare, but possible) would be misclassified. + */ +export function classifyGraphqlSourceKind(node: LiquidHtmlNode): GraphqlSourceKind { + const src = typeof node.source === 'string' ? node.source : ''; + const start = node.position?.start ?? 0; + const end = node.position?.end ?? 0; + const text = src.slice(start, end); + if (text.startsWith('{%')) return 'tag'; + if (text.trimEnd().endsWith(',')) { + const trail = src.slice(end, end + 200); + if (/\n\s*[A-Za-z_]\w*\s*:/.test(trail)) return 'liquid_multiline_truncated'; + } + return 'liquid_inline'; +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function extractNamedArgs(args: ReadonlyArray | undefined): string[] { + if (!args) return []; + const names: string[] = []; + for (const a of args) { + if (a.type === NodeTypes.NamedArgument && typeof a.name === 'string') { + names.push(a.name); + } + } + return names; +} + +function extractArgsFromMarkupString(markupStr: string): string[] { + const args: string[] = []; + const afterPartial = markupStr.replace(/^["'][^"']+["']\s*,?\s*/, ''); + for (const m of afterPartial.matchAll(/(\w+)\s*:/g)) { + args.push(m[1]); + } + return args; +} diff --git a/packages/platformos-mcp-supervisor/src/core/logger.spec.ts b/packages/platformos-mcp-supervisor/src/core/logger.spec.ts new file mode 100644 index 00000000..b7bfd682 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/logger.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createLogger } from './logger'; + +describe('createLogger', () => { + afterEach(() => vi.restoreAllMocks()); + + it('writes a single line to stderr with timestamp + [info] tag', () => { + const writes: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + + const log = createLogger(); + log('hello'); + + expect(spy).toHaveBeenCalledTimes(1); + expect(writes[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[info\] hello\n$/); + }); + + it('includes the prefix inside the bracketed tag when provided', () => { + const writes: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + + const log = createLogger('supervisor'); + log('boot'); + + expect(writes[0]).toMatch(/ \[info\] supervisor: boot\n$/); + }); + + it('emits exactly one line per call', () => { + const writes: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + + const log = createLogger('s'); + log('one'); + log('two'); + + expect(writes).toHaveLength(2); + expect(writes.every((w) => w.endsWith('\n'))).toBe(true); + expect(writes.every((w) => w.split('\n').filter(Boolean).length === 1)).toBe(true); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/logger.ts b/packages/platformos-mcp-supervisor/src/core/logger.ts new file mode 100644 index 00000000..1616c736 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/logger.ts @@ -0,0 +1,29 @@ +/** + * Minimal stderr logger for the MCP supervisor. + * + * stdout is reserved exclusively for the MCP JSON-RPC stream — anything + * written there bricks the transport. Every operational log line MUST go + * to stderr. + * + * v1 trim: no file logging, no log levels beyond a single `info` tag, no + * structured JSON output. Source ran a JSONL writer alongside stderr — + * dropped per the migration scope (we keep stderr only). + * + * Format: ` [info]: \n`. Prefix is + * surfaced inside the bracketed tag rather than as a free-form column so + * `grep '\[info\] supervisor:'` works. + */ + +export type Logger = (msg: string) => void; + +/** + * Build a logger function. Bind a stable `prefix` to tag every line + * emitted through the returned function (helpful when one process owns + * multiple subsystems, e.g. `supervisor` vs `lsp`). + */ +export function createLogger(prefix?: string): Logger { + const tag = prefix && prefix.length > 0 ? ` ${prefix}:` : ''; + return (msg: string) => { + process.stderr.write(`${new Date().toISOString()} [info]${tag} ${msg}\n`); + }; +} diff --git a/packages/platformos-mcp-supervisor/src/core/lsp-client.spec.ts b/packages/platformos-mcp-supervisor/src/core/lsp-client.spec.ts new file mode 100644 index 00000000..0c92bece --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/lsp-client.spec.ts @@ -0,0 +1,74 @@ +/** + * Unit pins for the URI canonicaliser used at every LSP boundary + * (`awaitDiagnostics`, `syncDoc`, `completions`, `hover`, + * `handlePublishDiagnostics`, `initialize`). + * + * The full bridge (PassThrough + protocol connection + a live language + * server) is exercised end-to-end by `test/upstream/lsp-diagnostic-contract.spec.ts` + * (P23) and `test/parity/validate-code-parity.spec.ts` (P24). This spec + * pins the cheap-but-load-bearing properties of `canonicalUri` itself — + * idempotency, mixed-case-drive-letter handling, and unparseable-input + * tolerance — so a regression on the helper surfaces here instead of + * hiding behind a 5 s LSP timeout. + */ + +import { describe, it, expect } from 'vitest'; +import { _canonicalUri } from './lsp-client'; + +describe('canonicalUri', () => { + it('is idempotent on already-canonical URIs', () => { + const u = 'file:///home/user/project/app/views/pages/index.html.liquid'; + expect(_canonicalUri(u)).toBe(u); + expect(_canonicalUri(_canonicalUri(u))).toBe(u); + }); + + it('lowercases the Windows drive letter (the Windows-CI regression)', () => { + // The whole reason this helper exists: pathToFileURL produces an + // upper-case drive letter; the in-process LSP canonicalises to + // lower-case. Without round-tripping, client + server Map keys + // disagree on Windows and diagnostics never arrive. + expect(_canonicalUri('file:///D:/a/project/app/views/pages/x.liquid')).toBe( + 'file:///d:/a/project/app/views/pages/x.liquid', + ); + expect(_canonicalUri('file:///C:/Users/dev/project/app/x.liquid')).toBe( + 'file:///c:/Users/dev/project/app/x.liquid', + ); + }); + + it('replaces backslashes with forward slashes', () => { + // `URI.toString(true)` already produces `/`, but the helper guards + // against any post-parse byte that slipped through. + expect(_canonicalUri('file:///d:/a\\project\\x.liquid')).toMatch(/^file:\/\/\/d:\/a/); + expect(_canonicalUri('file:///d:/a\\project\\x.liquid')).not.toContain('\\'); + }); + + it('preserves percent-decoded path segments where safe', () => { + // `toString(true)` (the "skipEncoding" flag) leaves printable chars alone. + // A path containing a space round-trips without %20 noise. + const u = 'file:///home/user/project/a b/c.liquid'; + expect(_canonicalUri(u)).toBe(u); + }); + + it('short-circuits on empty input without calling URI.parse', () => { + // `URI.parse('')` would coerce to `file:///` — the early-exit guard + // preserves the empty input so callers can detect the bad-input case + // unambiguously. + expect(_canonicalUri('')).toBe(''); + }); + + it('tolerates non-URI strings by passing them through `URI.parse` (best-effort)', () => { + // `vscode-uri`'s `URI.parse` is forgiving — it accepts schemeless + // strings and coerces to `file:///...`. The canonicaliser does NOT + // throw on such input; downstream code uses the result as a Map key + // and a missing match just times out cleanly. + expect(_canonicalUri('not-a-uri')).toBe('file:///not-a-uri'); + }); + + it('is a no-op on POSIX-style file URIs (Linux parity)', () => { + // Pins that the Linux parity baselines stay byte-identical — the + // P24 captured snapshots were recorded against pre-canonicaliser + // output, so this is the contract that keeps them valid. + const u = 'file:///tmp/mcp-supervisor-test/project/app/views/partials/x.liquid'; + expect(_canonicalUri(u)).toBe(u); + }); +}); diff --git a/packages/platformos-mcp-supervisor/src/core/lsp-client.ts b/packages/platformos-mcp-supervisor/src/core/lsp-client.ts new file mode 100644 index 00000000..2e4d36b7 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/lsp-client.ts @@ -0,0 +1,524 @@ +/** + * In-process platformOS LSP client. + * + * The pos-supervisor original (`src/core/lsp-client.js`) spawned `pos-cli lsp` + * as a child process and talked JSON-RPC over its stdio. In this monorepo + * we already own the language server (`@platformos/platformos-language-server-node`), + * so the subprocess is replaced with an in-process driver: a pair of + * `PassThrough` streams bridges a server-side `Connection` + * (`vscode-languageserver/node`) to a client-side `MessageConnection` + * (`vscode-jsonrpc/node`). Both sides speak vanilla LSP over the same + * wire format the subprocess would have used; the bridge replaces only + * the transport. + * + * ┌────────── client (our PlatformOSLSPClient) ──────────┐ + * │ initialize, didOpen, hover, completion … │ + * │ reader = StreamMessageReader(s2c) │ + * │ writer = StreamMessageWriter(c2s) │ + * └──────────────────┬──────────────────────────────────┘ + * │ c2s / s2c (PassThrough pair) + * ┌──────────────────┴──────────────────────────────────┐ + * │ server (startServer from language-server-node) │ + * │ reader = StreamMessageReader(c2s) │ + * │ writer = StreamMessageWriter(s2c) │ + * │ publishDiagnostics ↑, request handlers ↑ │ + * └─────────────────────────────────────────────────────┘ + * + * The semantics of the original wrapper carry over unchanged: + * - `awaitDiagnostics` syncs the document, fires a hover-as-barrier, + * and resolves with the LAST `publishDiagnostics` batch after a + * `DIAGNOSTICS_SETTLE_MS` quiet window. + * - The version-tracked document cache lets the server treat repeated + * calls on the same URI as `didChange` instead of duplicate `didOpen`. + * - `normalizeLspDiagnostics` flattens the LSP `Diagnostic[]` into the + * pos-supervisor internal `{ check, severity, message, line, column, + * endLine, endColumn, _filePath }` shape; downstream enrichment and + * the diagnostic pipeline depend on that exact shape. + */ + +import { PassThrough } from 'node:stream'; +import { pathToFileURL } from 'node:url'; + +import { createConnection, type Connection } from 'vscode-languageserver/node'; +import { StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'; +import { + CompletionRequest, + DidChangeTextDocumentNotification, + DidOpenTextDocumentNotification, + HoverRequest, + InitializedNotification, + InitializeRequest, + PublishDiagnosticsNotification, + type CompletionItem, + type CompletionList, + type Diagnostic, + type Hover, + type InitializeParams, + type PublishDiagnosticsParams, +} from 'vscode-languageserver-protocol'; +import { + createProtocolConnection, + type ProtocolConnection, +} from 'vscode-languageserver-protocol/node'; + +import { path as commonPath } from '@platformos/platformos-check-common'; +import { startServer as startLanguageServer } from '@platformos/platformos-language-server-node'; + +import { + DIAGNOSTICS_SETTLE_MS, + LSP_BARRIER_TIMEOUT_MS, + LSP_DIAGNOSTICS_TIMEOUT_MS, + LSP_READY_TIMEOUT_MS, +} from './constants'; + +// ── Public types ─────────────────────────────────────────────────────────── + +export type { Diagnostic as LspDiagnostic } from 'vscode-languageserver-protocol'; + +export type CompletionResult = CompletionItem[] | CompletionList | null; +export type HoverResult = Hover | null; + +export type NormalizedSeverity = 'error' | 'warning' | 'info'; + +export interface NormalizedDiagnostic { + check: string; + severity: NormalizedSeverity; + message: string; + line: number; + column: number; + endLine: number | null; + endColumn: number | null; + _filePath: string; +} + +export interface NormalizedDiagnostics { + errors: NormalizedDiagnostic[]; + warnings: NormalizedDiagnostic[]; + infos: NormalizedDiagnostic[]; + checks: Set; +} + +// ── Internal types ───────────────────────────────────────────────────────── + +interface DiagnosticWaiter { + resolve: (diags: Diagnostic[]) => void; + mainTimer: NodeJS.Timeout; + settleTimer: NodeJS.Timeout | null; + latest: Diagnostic[] | null; +} + +// ── Client ───────────────────────────────────────────────────────────────── + +export class PlatformOSLSPClient { + private clientConn: ProtocolConnection | null = null; + private clientToServer: PassThrough | null = null; + private serverToClient: PassThrough | null = null; + private serverConn: Connection | null = null; + + private readonly diagnosticsByUri = new Map(); + private readonly diagWaiters = new Map(); + private readonly openDocs = new Map(); + private barrierId = 0; + + initialized = false; + + /** + * Boot the in-process language server, connect the client, and complete + * the LSP handshake. Idempotent: a second call against an already-initialised + * client returns immediately. + */ + async initialize(projectDir: string, opts: { version?: string } = {}): Promise { + if (this.initialized) return; + if (!projectDir) throw new Error('PlatformOSLSPClient.initialize: projectDir is required'); + + const c2s = new PassThrough(); + const s2c = new PassThrough(); + + // Server: read client→server, write server→client. + const serverConn = createConnection(new StreamMessageReader(c2s), new StreamMessageWriter(s2c)); + // `startServer` from language-server-node calls `connection.listen()` + // internally and wires up all request handlers + the platformOS docset. + startLanguageServer(serverConn); + + // Client: read server→client, write client→server. `createProtocolConnection` + // (not the lower-level `createMessageConnection`) is used so the typed + // `ProtocolNotificationType` / `ProtocolRequestType` constants from the + // protocol package are accepted natively. Mixing types across the two + // packages (jsonrpc + protocol) triggers a private-field collision in + // the message-type ancestry. + const clientConn = createProtocolConnection( + new StreamMessageReader(s2c), + new StreamMessageWriter(c2s), + ); + + clientConn.onNotification( + PublishDiagnosticsNotification.type, + (params: PublishDiagnosticsParams) => { + this.handlePublishDiagnostics(params); + }, + ); + + // Silence transport errors after close; rejecting outstanding waiters + // is the close() path's responsibility. + clientConn.onError(() => { + /* logged elsewhere if needed */ + }); + + clientConn.listen(); + + this.clientConn = clientConn; + this.clientToServer = c2s; + this.serverToClient = s2c; + this.serverConn = serverConn; + + // Canonicalise via `vscode-uri` — the same form `vscode-uri`-based code + // inside the language server uses. `pathToFileURL` alone produces a + // mixed-case drive letter on Windows (`file:///D:/...`), but the LSP + // server canonicalises to a lowercased drive letter internally; without + // this round-trip the URI we wait on never matches the URI the server + // publishes diagnostics for, and every cross-file diagnostic times out + // silently on Windows. + const rootUri = canonicalUri(pathToFileURL(projectDir).href); + const initParams: InitializeParams = { + processId: process.pid, + clientInfo: { name: 'platformos-mcp-supervisor', version: opts.version ?? '0.0.0' }, + rootUri, + capabilities: { + textDocument: { + publishDiagnostics: {}, + hover: { contentFormat: ['markdown', 'plaintext'] }, + completion: { completionItem: { snippetSupport: false } }, + }, + workspace: { workspaceFolders: true }, + }, + workspaceFolders: [{ uri: rootUri, name: 'workspace' }], + // platformOS LSP option: force `app/`-wide indexing during initialise + // so cross-file checks (MissingPartial, MissingPage, etc.) are warm. + initializationOptions: { + 'platformosCheck.includeFilesFromDisk': true, + }, + }; + + await this.race( + clientConn.sendRequest(InitializeRequest.type, initParams), + LSP_READY_TIMEOUT_MS, + 'initialize', + ); + clientConn.sendNotification(InitializedNotification.type, {}); + this.initialized = true; + } + + /** + * Sync `content` into the document at `uri` and wait for the next + * `publishDiagnostics` batch. + * + * The settle window: every incoming `publishDiagnostics` resets a + * `DIAGNOSTICS_SETTLE_MS` timer; when the timer fires the latest batch + * is returned. A hover request acts as a synchronisation barrier — the + * server processes notifications in order, so a hover response proves + * the server has seen our `didOpen` / `didChange`. The hard `timeoutMs` + * bound resolves with whatever the latest batch is (or `[]` if none). + */ + awaitDiagnostics( + uri: string, + content: string, + timeoutMs: number = LSP_DIAGNOSTICS_TIMEOUT_MS, + ): Promise { + this.ensureClient(); + // Normalise once at the boundary. Every downstream Map key + // (`diagnosticsByUri`, `diagWaiters`, `openDocs`) — and every URI the + // server publishes diagnostics for — runs through the same + // canonicaliser, so client + server keys agree on Windows. + const key = canonicalUri(uri); + this.syncDoc(key, content); + this.diagnosticsByUri.delete(key); + + // Drop any pre-existing waiter for the same URI (shouldn't happen in + // serialised use but guards against a stray timer leaking through). + const existing = this.diagWaiters.get(key); + if (existing) { + clearTimeout(existing.mainTimer); + if (existing.settleTimer) clearTimeout(existing.settleTimer); + existing.resolve([]); + this.diagWaiters.delete(key); + } + + return new Promise((resolve) => { + const waiter: DiagnosticWaiter = { + latest: null, + settleTimer: null, + mainTimer: setTimeout(() => { + this.diagWaiters.delete(key); + if (waiter.settleTimer) clearTimeout(waiter.settleTimer); + resolve(waiter.latest ?? []); + }, timeoutMs), + resolve: (diags) => { + this.diagWaiters.delete(key); + clearTimeout(waiter.mainTimer); + if (waiter.settleTimer) clearTimeout(waiter.settleTimer); + resolve(diags); + }, + }; + this.diagWaiters.set(key, waiter); + + // Hover-as-barrier. The result is intentionally discarded — its only + // purpose is to force the server to drain notifications first. Cap + // separately at LSP_BARRIER_TIMEOUT_MS so a slow hover doesn't + // dominate the diagnostic-wait window. + const barrierTimeout = Math.min(timeoutMs, LSP_BARRIER_TIMEOUT_MS); + this.barrierId++; + void this.race( + this.clientConn!.sendRequest(HoverRequest.type, { + textDocument: { uri: key }, + position: { line: 0, character: 0 }, + }), + barrierTimeout, + 'hover-barrier', + ).catch(() => { + /* barrier failures are tolerated — settle window covers the wait */ + }); + }); + } + + async completions(uri: string, line: number, character: number): Promise { + this.ensureClient(); + return this.race( + this.clientConn!.sendRequest(CompletionRequest.type, { + textDocument: { uri: canonicalUri(uri) }, + position: { line, character }, + }), + 30_000, + 'completion', + ); + } + + async hover(uri: string, line: number, character: number): Promise { + this.ensureClient(); + return this.race( + this.clientConn!.sendRequest(HoverRequest.type, { + textDocument: { uri: canonicalUri(uri) }, + position: { line, character }, + }), + 30_000, + 'hover', + ); + } + + /** + * Tear down the in-process server and dispose all transport state. + * Subsequent method calls throw via `ensureClient`. + * + * The graceful `shutdown`/`exit` LSP handshake is intentionally skipped: + * those exist so a SEPARATE process can flush state before terminating. + * Here the server runs in our own process; disposing the connections + * and destroying the underlying streams is the equivalent termination + * step. Skipping the handshake also avoids the + * `ERR_STREAM_WRITE_AFTER_END` race that the original close-with-exit + * exposed (dispose queues a final write that hits the ended stream). + * + * KNOWN CAVEAT: `PlatformOSLiquidDocsManager` (constructed inside + * `startLanguageServer` and outside our control) issues an HTTP + * `latest.json` revision check on first use. That fetch holds an open + * TCP socket that may outlive `close()`. The client's own state is + * fully cleaned up — the lingering socket is a docs-manager + * side-effect. Programmatic consumers that need a hard exit after + * close should `process.exit()` explicitly; the production MCP server + * (P19) doesn't care because its lifetime equals the process's. + */ + async close(): Promise { + if (!this.clientConn) return; + + this.diagWaiters.forEach((w) => { + clearTimeout(w.mainTimer); + if (w.settleTimer) clearTimeout(w.settleTimer); + w.resolve([]); + }); + this.diagWaiters.clear(); + this.openDocs.clear(); + this.diagnosticsByUri.clear(); + + // Mark uninitialised first so any racing write that surfaces an error + // event after dispose finds an uninitialised client and short-circuits. + this.initialized = false; + + const swallowError = () => { + /* discard post-close stream errors */ + }; + this.clientToServer?.on('error', swallowError); + this.serverToClient?.on('error', swallowError); + + try { + this.clientConn.dispose(); + } catch { + /* connection may already be disposed */ + } + try { + this.serverConn?.dispose(); + } catch { + /* server connection may already be disposed */ + } + // Force-close the duplex pair so the server-side reader's open file + // handle is released and the event loop becomes drainable. The + // connections above are disposed, so no further writes are attempted. + this.clientToServer?.destroy(); + this.serverToClient?.destroy(); + + this.clientConn = null; + this.serverConn = null; + this.clientToServer = null; + this.serverToClient = null; + } + + // ── Internals ────────────────────────────────────────────────────────── + + private syncDoc(uri: string, text: string): void { + const conn = this.clientConn!; + const langId = uri.endsWith('.graphql') ? 'graphql' : 'liquid'; + const prev = this.openDocs.get(uri); + if (prev !== undefined) { + const next = prev + 1; + this.openDocs.set(uri, next); + conn.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri, version: next }, + contentChanges: [{ text }], + }); + } else { + this.openDocs.set(uri, 1); + conn.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { uri, languageId: langId, version: 1, text }, + }); + } + } + + private handlePublishDiagnostics(params: PublishDiagnosticsParams): void { + // The server's URI is the source of truth for the canonical form — every + // map key on the client side is normalised through the same + // `vscode-uri`-based helper, so client + server agree on Windows where + // pathToFileURL would otherwise produce a mixed-case drive letter that + // never matches what the LSP publishes. + const uri = canonicalUri(params.uri); + const diags = params.diagnostics ?? []; + this.diagnosticsByUri.set(uri, diags); + + const waiter = this.diagWaiters.get(uri); + if (!waiter) return; + + waiter.latest = diags; + if (waiter.settleTimer) clearTimeout(waiter.settleTimer); + waiter.settleTimer = setTimeout(() => { + waiter.resolve(waiter.latest ?? diags); + }, DIAGNOSTICS_SETTLE_MS); + } + + private ensureClient(): void { + if (!this.clientConn || !this.initialized) { + throw new Error('PlatformOSLSPClient: initialize() has not been called'); + } + } + + private async race(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`platformOS LSP timeout: ${label}`)), timeoutMs); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } +} + +// ── URI canonicaliser ────────────────────────────────────────────────────── + +/** + * Round-trip a `file:` URI through `vscode-uri`'s `URI.parse(...).toString(true)` + * so client-side Map keys and server-side `params.uri` agree byte-for-byte. + * + * Why this exists: on Windows, `pathToFileURL('D:\\a\\…').href` produces a + * URI with an upper-case drive letter (`file:///D:/a/…`), but the in-process + * language server canonicalises internally to a lower-case drive letter + * (`file:///d:/a/…`). The client waits for diagnostics keyed by the + * upper-case URI; the server publishes them keyed by the lower-case URI; + * `Map.get(uri)` misses; `awaitDiagnostics` times out at + * `LSP_DIAGNOSTICS_TIMEOUT_MS` with `latest: null`; every cross-file + * diagnostic on Windows silently returns `[]`. Routing every URI we send + * AND every URI we receive through the same canonicaliser closes the gap. + * + * On Linux this is effectively a no-op (URIs already canonical), so the + * Linux parity baselines are preserved unchanged. + * + * Delegates to `path.normalize` from `@platformos/platformos-check-common`, + * the same helper the language-server-common consumes. Treats unparseable + * input as-is rather than throwing — the LSP handshake should still + * progress and surface a clear failure downstream instead of crashing the + * server boot. + */ +function canonicalUri(uri: string): string { + if (typeof uri !== 'string' || uri.length === 0) return uri; + try { + return commonPath.normalize(uri); + } catch { + return uri; + } +} + +/** + * Test seam — exposes `canonicalUri` for unit assertions without making it + * part of the public surface. Underscore-prefixed to match the pattern used + * elsewhere in this package (`_resetKnowledge`, `_resetProjectMapCache`). + */ +export function _canonicalUri(uri: string): string { + return canonicalUri(uri); +} + +// ── Diagnostic normaliser ────────────────────────────────────────────────── + +/** + * Convert an LSP `Diagnostic[]` into the pos-supervisor internal shape. + * + * The `check` field is the canonical platformOS check name; the LSP emits + * it as `Diagnostic.code` (string or number) for platformOS rules. If + * `code` is missing we fall back to `source` (e.g. `'LSP'`), matching the + * source's defensive behaviour. + */ +export function normalizeLspDiagnostics( + lspDiags: ReadonlyArray, + filePath: string, +): NormalizedDiagnostics { + const errors: NormalizedDiagnostic[] = []; + const warnings: NormalizedDiagnostic[] = []; + const infos: NormalizedDiagnostic[] = []; + const checks = new Set(); + + for (const d of lspDiags) { + const code = d.code; + const check = + typeof code === 'string' + ? code + : typeof code === 'number' + ? String(code) + : (d.source ?? 'LSP'); + + const severity: NormalizedSeverity = + d.severity === 1 ? 'error' : d.severity === 2 ? 'warning' : 'info'; + + const diagnostic: NormalizedDiagnostic = { + check, + severity, + message: d.message, + line: d.range?.start?.line ?? 0, + column: d.range?.start?.character ?? 0, + endLine: d.range?.end?.line ?? null, + endColumn: d.range?.end?.character ?? null, + _filePath: filePath, + }; + checks.add(check); + + if (severity === 'error') errors.push(diagnostic); + else if (severity === 'warning') warnings.push(diagnostic); + else infos.push(diagnostic); + } + + return { errors, warnings, infos, checks }; +} diff --git a/packages/platformos-mcp-supervisor/src/core/objects-index.ts b/packages/platformos-mcp-supervisor/src/core/objects-index.ts new file mode 100644 index 00000000..88624f71 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/objects-index.ts @@ -0,0 +1,91 @@ +/** + * Object docset index — name → typed metadata for every documented + * platformOS Liquid object, populated from the docset shipped by + * `@platformos/platformos-check-docs-updater`. + * + * Consumed by `error-enricher` and `fix-generator` (for `UndefinedObject` + * Shopify-vs-platformOS suggestions) plus the `UnknownProperty` rule. + * + * Source data carries each object's authoritative `handle` (e.g. + * `context.params`, `context.current_user`) in `json_data.handle`. The + * shipping JSON populates this field even though the public `JsonData` + * type comments it out. + */ + +import type { ObjectEntry, PlatformOSDocset } from '@platformos/platformos-check-common'; + +export interface ObjectDef { + name: string; + /** Canonical accessor path, e.g. `context.params`, `context.current_user`. */ + handle: string; + /** Property names available on the object. */ + properties: string[]; +} + +/** + * The docset's `JsonData` interface omits `handle` (commented out in + * `platformos-check-common/src/types/platformos-liquid-docs.ts`), but the + * shipping JSON does include it. Extend locally so the typed lookup works. + */ +type ShippedObjectEntry = ObjectEntry & { json_data?: { handle?: string } }; + +export class ObjectsIndex { + private _byName = new Map(); + private _loaded = false; + + async load(docset: PlatformOSDocset): Promise { + const entries = (await docset.objects()) as ShippedObjectEntry[]; + for (const obj of entries) { + const handle = obj.json_data?.handle ?? ''; + const propNames = (obj.properties ?? []) + .map((p) => p.name) + .filter((n): n is string => typeof n === 'string' && n.length > 0); + this._byName.set(obj.name, { name: obj.name, handle, properties: propNames }); + } + this._loaded = true; + } + + get loaded(): boolean { + return this._loaded; + } + + /** + * Objects whose handle starts with `context.` — the agent-facing accessor + * surface (`context.params`, `context.session`, `context.current_user`, …). + * Ordered by property count desc so denser objects surface first in + * lists. + */ + contextObjects(): ObjectDef[] { + if (!this._loaded) return []; + return [...this._byName.values()] + .filter((obj) => /^context\.[a-z_]+$/.test(obj.handle)) + .sort((a, b) => b.properties.length - a.properties.length); + } + + /** + * Look up a bare variable name. Returns `null` when: + * - the name is not in the docset, + * - the entry has no `handle` (not addressable from Liquid), + * - the handle equals the bare name (no rename suggestion possible). + */ + lookup(varName: string | null | undefined): ObjectDef | null { + if (!this._loaded || !varName) return null; + const obj = this._byName.get(varName); + if (!obj) return null; + if (!obj.handle || obj.handle === varName) return null; + return obj; + } +} + +/** + * Extract a quoted variable name from a diagnostic message. Double quotes + * win, single quotes fall through. Returns `null` when no match. + */ +export function extractVarName(message: string | null | undefined): string | null { + if (!message) return null; + const dq = message.match(/"([^"]+)"/); + if (dq) return dq[1]; + const sq = message.match(/'([^']+)'/); + if (sq) return sq[1]; + return null; +} diff --git a/packages/platformos-mcp-supervisor/src/core/page-route-index.ts b/packages/platformos-mcp-supervisor/src/core/page-route-index.ts new file mode 100644 index 00000000..dceb3281 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/page-route-index.ts @@ -0,0 +1,232 @@ +/** + * Page-route index — a snapshot of every route the project actually serves. + * + * Purpose: the LSP's `MissingPage` check fires for any route reference + * whose page is not in the file currently under analysis. `validate_code` + * analyses one file at a time, so a header partial that links to `/notes`, + * `/dashboard`, `/` triggers `MissingPage` for each link even though those + * pages exist as separate files (`app/views/pages/notes/index.html.liquid`, + * `app/views/pages/dashboard.liquid`, `app/views/pages/index.liquid`). + * + * The agent is told "no page found" for routes the project clearly serves — + * a textbook ghost error. Same mitigation as `MissingAsset` and + * `TranslationKeyExists`: scan the filesystem, build the truth, suppress + * the false positives. + * + * Route resolution rules (matching platformOS): + * - frontmatter `slug:` wins if present. + * - else the path under `app/views/pages/` minus the `.liquid` / + * `.html.liquid` extension is the route. + * - `index.liquid` (any depth) collapses its `/index` suffix; the + * directory itself becomes the route. So + * `app/views/pages/index.liquid` → `''` (root) and + * `app/views/pages/notes/index.html.liquid` → `notes`. + * - frontmatter `method:` wins for HTTP method, defaulting to `get`. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { isAbsolute, join, relative, sep } from 'node:path'; +import normalize from 'normalize-path'; + +const PAGES_SUBDIR = 'app/views/pages'; + +export interface PageRouteIndex { + /** + * `route` → set of HTTP methods that serve it (lowercase). + * Routes are normalised: no leading slash, no trailing `/index`, + * root is the empty string. + */ + routes: Map>; +} + +export interface PageOverlay { + filePath: string; + content: string; +} + +export interface ParsedMissingPage { + route: string; + method: string; +} + +export type PageRouteResolution = + | { status: 'exists' } + | { status: 'wrong-method'; methods: string[] } + | { status: 'missing' }; + +/** + * Build a route → methods index from the project's pages directory. + * + * The optional `overlay` represents the file CURRENTLY under validation. + * Its in-memory content is used in place of (or in addition to) the + * on-disk version. This is load-bearing for the self-page case: when the + * agent runs `validate_code` on `app/views/pages/index.liquid` with + * `method: post` in-memory frontmatter while disk still has no method + * declaration, the LSP fires `MissingPage` for route `/` (POST). The + * on-disk scan alone would not see the in-memory frontmatter and the false + * positive would survive verification — even though, the moment the agent + * writes, the route IS served. + * + * Overlay rules: + * - `filePath` may be relative (resolved against `projectDir`) or absolute. + * - Only files under `app/views/pages/` are considered. A non-page + * overlay (e.g., a partial under validation) is ignored — the index + * covers pages only. + * - When the same path also exists on disk, the disk read is skipped + * and the overlay frontmatter wins. When the path does not exist on + * disk yet, the overlay adds a new entry to the index. + */ +export function buildPageRouteIndex( + projectDir: string, + overlay: PageOverlay | null = null, +): PageRouteIndex { + const routes = new Map>(); + if (!projectDir) return { routes }; + + const rootAbs = join(projectDir, PAGES_SUBDIR); + if (!existsSync(rootAbs)) return { routes }; + + const files: string[] = []; + const stack: string[] = [rootAbs]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(abs); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.liquid')) continue; + files.push(abs); + } + } + + // Resolve the overlay (if any) to an absolute path under the pages root. + // Overlays outside `app/views/pages/` are ignored — the route index + // covers pages only, and partials/layouts/assets cannot serve a route. + let overlayAbs: string | null = null; + if ( + overlay && + typeof overlay.filePath === 'string' && + typeof overlay.content === 'string' && + /\.liquid$/.test(overlay.filePath) + ) { + const abs = isAbsolute(overlay.filePath) + ? overlay.filePath + : join(projectDir, overlay.filePath); + if (abs === rootAbs || abs.startsWith(rootAbs + sep)) { + overlayAbs = abs; + if (!files.includes(abs)) files.push(abs); + } + } + + for (const abs of files) { + let raw: string; + if (abs === overlayAbs) { + raw = overlay!.content; + } else { + try { + raw = readFileSync(abs, 'utf8'); + } catch { + continue; + } + } + + const rel = normalize(relative(rootAbs, abs)); + const { slug, method } = extractFrontmatter(raw); + + const route = normalizeRoute(slug ?? routeFromPath(rel)); + const m = (method ?? 'get').toLowerCase(); + const existing = routes.get(route); + if (existing) existing.add(m); + else routes.set(route, new Set([m])); + } + + return { routes }; +} + +/** + * Read a tiny slice of frontmatter — only what the route check needs. We + * do NOT pull in the full liquid-html-parser here: this index runs on + * every `validate_code` call, so the cheap regex over the head of the + * file is the right tradeoff. Frontmatter, when present, is `---` … `---` + * at the start of the file; ignoring everything after the closing `---` + * keeps a page that has the word "slug:" in its body from contaminating + * the route. + */ +function extractFrontmatter(content: string): { slug: string | null; method: string | null } { + const m = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!m) return { slug: null, method: null }; + const head = m[1]; + const slug = head.match(/^slug:\s*(.+?)\s*$/m)?.[1] ?? null; + const method = head.match(/^method:\s*(.+?)\s*$/m)?.[1] ?? null; + return { slug, method }; +} + +function routeFromPath(relUnderPages: string): string { + return relUnderPages.replace(/\.html\.liquid$/, '').replace(/\.liquid$/, ''); +} + +/** + * Normalise a route into the canonical key the index uses. + * + * - strip leading slashes + * - collapse a trailing `/index` (the directory itself is the route) + * - root page becomes the empty string + */ +export function normalizeRoute(raw: string | null | undefined): string { + if (typeof raw !== 'string') return ''; + let p = raw.trim(); + while (p.startsWith('/')) p = p.slice(1); + if (p === 'index') return ''; + if (p.endsWith('/index')) p = p.slice(0, -'/index'.length); + return p; +} + +/** + * Parse a `MissingPage` diagnostic message into `{ route, method }`. + * + * Known shapes: + * - `No page found for route '/notes' (GET)` + * - `Page 'blog_posts/show' not found` + * - `Missing page at slug 'blog_posts'` + * + * Method defaults to `'get'` when not present. + */ +export function parseMissingPageMessage( + message: string | null | undefined, +): ParsedMissingPage | null { + if (!message) return null; + const quoted = message.match(/['"`]([^'"`]+)['"`]/); + if (!quoted) return null; + const route = normalizeRoute(quoted[1]); + const methodMatch = message.match(/\(([A-Za-z]+)\)/); + const method = (methodMatch?.[1] ?? 'get').toLowerCase(); + return { route, method }; +} + +/** + * Look up a reported route against the index. + * + * - `exists` — a page serves that route + method; suppress. + * - `wrong-method` — route exists, but for other methods. + * - `missing` — no page serves that route at all; diagnostic stands. + */ +export function resolvePageRoute( + reportedRoute: string, + reportedMethod: string, + index: PageRouteIndex, +): PageRouteResolution { + const route = normalizeRoute(reportedRoute); + const methods = index.routes.get(route); + if (!methods) return { status: 'missing' }; + if (methods.has(reportedMethod)) return { status: 'exists' }; + return { status: 'wrong-method', methods: [...methods] }; +} diff --git a/packages/platformos-mcp-supervisor/src/core/position-utils.ts b/packages/platformos-mcp-supervisor/src/core/position-utils.ts new file mode 100644 index 00000000..c640b45a --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/position-utils.ts @@ -0,0 +1,70 @@ +/** + * Shared position utilities — offset ↔ line:col conversion and path-derived + * slug helpers. + * + * Single source of truth for these conversions (previously duplicated in + * `fix-generator` and `structural-warnings`). + * + * Line/character coordinates are 0-based to match LSP. Callers that surface + * positions to humans (validate_code final response) convert to 1-based at + * the edge. + */ + +export interface LineCol { + /** 0-based line number. */ + line: number; + /** 0-based column index (LSP terminology: "character"). */ + character: number; +} + +/** + * Convert a 0-based byte offset into a `{ line, character }` position. + */ +export function offsetToLineCol(content: string, offset: number): LineCol { + let line = 0; + let col = 0; + const cap = Math.min(offset, content.length); + for (let i = 0; i < cap; i++) { + if (content.charCodeAt(i) === 10 /* \n */) { + line++; + col = 0; + } else { + col++; + } + } + return { line, character: col }; +} + +/** + * Convert a 0-based line + column into a byte offset. + * + * The column is clamped to the line's actual length so out-of-range inputs + * resolve to the line's end-of-content position rather than overrunning. + */ +export function lineColToOffset(content: string, line: number, col: number): number { + const lines = content.split('\n'); + let offset = 0; + const stop = Math.min(line, lines.length); + for (let i = 0; i < stop; i++) { + offset += lines[i].length + 1; // +1 for the stripped \n + } + return offset + Math.min(col, (lines[line] ?? '').length); +} + +/** + * Derive a suggested page slug from a file path. + * + * app/views/pages/blog_posts/show.html.liquid → blog_posts/show + * app/views/pages/blog_posts/new.liquid → blog_posts/new + * app/views/pages/index.liquid → '' (root) + * + * Handles both relative and absolute paths. + */ +export function slugFromPath(filePath: string | null | undefined): string { + if (!filePath) return 'your-page-url'; + const rel = filePath + .replace(/^.*app\/views\/pages\//, '') + .replace(/\.html\.liquid$/, '') + .replace(/\.liquid$/, ''); + return rel === 'index' ? '' : rel; +} diff --git a/packages/platformos-mcp-supervisor/src/core/project-fact-graph.ts b/packages/platformos-mcp-supervisor/src/core/project-fact-graph.ts new file mode 100644 index 00000000..dffe808e --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/project-fact-graph.ts @@ -0,0 +1,371 @@ +/** + * Project fact graph — typed, queryable view over a `ProjectMap`. + * + * Built once per `validate_code` call and passed through the rule engine + * as `facts.graph`. Indexes every file, schema, GraphQL op, layout, + * translation entry, and asset as a `FactNode`; computes file → file + * dependency edges via the resolvers in `dependency-graph.ts`; and + * separately indexes `{% render %}` call-sites so render-flow analysis + * can answer "who passes which args to which partial". + * + * v1 trim: dropped `allFiles`, `allLiquidFiles`, `allCheckableFiles`, + * `size`, `nodeCount`, `edgeCount`, `toDependencyGraph`, and + * `checkEdgeIntegrity` — those are consumed by `analyze-project` / dashboard + * code that is out of scope. The remaining surface matches every method + * called by the in-scope rule files, render-flow, and validate-code. + */ + +import { + resolveFunctionTarget, + resolveGraphqlTarget, + resolveRenderTarget, +} from './dependency-graph'; +import type { + CommandEntry, + FunctionCall, + GraphqlArg, + GraphqlEntry, + LayoutEntry, + PageEntry, + PartialEntry, + ProjectMap, + QueryEntry, + SchemaEntry, + SchemaProperty, +} from './project-scanner'; +import type { GraphqlRef, RenderCall } from './liquid-parser'; + +export type FactNodeType = + | 'page' + | 'partial' + | 'command' + | 'query' + | 'graphql' + | 'schema' + | 'layout' + | 'translation' + | 'asset'; + +/** + * Per-type fields are optional and present only on the nodes that carry + * them. Source code accesses them dynamically (`node.params`, etc.); this + * interface mirrors that with explicit, typed optionals. + */ +export interface FactNode { + readonly type: FactNodeType; + readonly key: string; + readonly path: string | null; + + // page / partial / layout + readonly slug?: string; + readonly method?: string; + readonly layout?: string | null; + readonly renders?: string[]; + readonly render_calls?: RenderCall[]; + readonly function_calls?: FunctionCall[]; + readonly graphql_calls?: GraphqlRef[]; + + // partial / command / query + readonly params?: string[]; + readonly phases?: string[]; + readonly rendered_by?: string[]; + + // graphql operation + readonly operation?: string | null; + readonly gqlName?: string | null; + readonly args?: GraphqlArg[]; + readonly table?: string | null; + + // schema + readonly properties?: SchemaProperty[]; + + // translation + readonly locale?: string; + readonly value?: unknown; +} + +/** A single `{% render %}` call-site recorded against the calling file. */ +export interface RenderCallRef extends RenderCall {} + +/** `(caller path, args passed)` pair returned by `renderCallsTo`. */ +export interface RenderCallerRef { + callerPath: string; + args: string[]; +} + +/** Factory — preserves the source's `buildFactGraph(map)` entry point. */ +export function buildFactGraph(projectMap: ProjectMap): ProjectFactGraph { + return new ProjectFactGraph(projectMap); +} + +export class ProjectFactGraph { + private readonly _nodes = new Map(); + private readonly _byType = new Map>(); + private readonly _dependsOn = new Map>(); + private readonly _referencedBy = new Map>(); + private readonly _renderCalls = new Map(); + + constructor(private readonly _map: ProjectMap) { + this._indexNodes(); + this._buildEdges(); + this._indexRenderCalls(); + } + + // ── Index construction ───────────────────────────────────────────────── + + private _addNode( + type: FactNodeType, + key: string, + path: string | null, + props: Partial = {}, + ): FactNode { + const node = Object.freeze({ type, key, path, ...props }); + if (path) this._nodes.set(path, node); + let bucket = this._byType.get(type); + if (!bucket) { + bucket = new Map(); + this._byType.set(type, bucket); + } + bucket.set(key, node); + return node; + } + + private _addEdge(source: string | null, target: string | null): void { + if (!source || !target) return; + let depBucket = this._dependsOn.get(source); + if (!depBucket) { + depBucket = new Set(); + this._dependsOn.set(source, depBucket); + } + let refBucket = this._referencedBy.get(target); + if (!refBucket) { + refBucket = new Set(); + this._referencedBy.set(target, refBucket); + } + depBucket.add(target); + refBucket.add(source); + } + + private _indexNodes(): void { + const m = this._map; + + for (const [key, page] of Object.entries(m.pages ?? {})) { + this._addPageNode(key, page); + } + for (const [name, partial] of Object.entries(m.partials ?? {})) { + this._addPartialNode(name, partial); + } + for (const [path, cmd] of Object.entries(m.commands ?? {})) { + this._addCommandNode(path, cmd); + } + for (const [path, q] of Object.entries(m.queries ?? {})) { + this._addQueryNode(path, q); + } + for (const [name, gql] of Object.entries(m.graphql ?? {})) { + this._addGraphqlNode(name, gql); + } + for (const [name, schema] of Object.entries(m.schema ?? {})) { + this._addSchemaNode(name, schema); + } + for (const [, layout] of Object.entries(m.layouts ?? {})) { + this._addLayoutNode(layout); + } + for (const [locale, keys] of Object.entries(m.translations ?? {})) { + for (const [key, value] of Object.entries(keys)) { + this._addNode('translation', `${locale}:${key}`, null, { locale, key, value }); + } + } + for (const asset of m.assets ?? []) { + this._addNode('asset', asset, `app/assets/${asset}`); + } + } + + private _addPageNode(key: string, page: PageEntry): void { + this._addNode('page', key, page.path, { + slug: page.slug, + method: page.method, + layout: page.layout, + renders: page.renders, + render_calls: page.render_calls, + function_calls: page.function_calls, + graphql_calls: page.graphql_calls, + }); + } + + private _addPartialNode(name: string, partial: PartialEntry): void { + this._addNode('partial', name, partial.path, { + params: partial.params, + renders: partial.renders, + render_calls: partial.render_calls, + function_calls: partial.function_calls, + rendered_by: partial.rendered_by, + graphql_calls: partial.graphql_calls, + }); + } + + private _addCommandNode(path: string, cmd: CommandEntry): void { + this._addNode('command', path, path, { + params: cmd.params, + phases: cmd.phases, + graphql_calls: cmd.graphql_calls, + function_calls: cmd.function_calls, + }); + } + + private _addQueryNode(path: string, q: QueryEntry): void { + this._addNode('query', path, path, { + params: q.params, + graphql_calls: q.graphql_calls, + function_calls: q.function_calls, + }); + } + + private _addGraphqlNode(name: string, gql: GraphqlEntry): void { + this._addNode('graphql', name, `app/graphql/${name}.graphql`, { + operation: gql.operation, + gqlName: gql.name, + args: gql.args, + table: gql.table, + }); + } + + private _addSchemaNode(name: string, schema: SchemaEntry): void { + this._addNode('schema', name, schema.path, { properties: schema.properties }); + } + + private _addLayoutNode(layout: LayoutEntry): void { + this._addNode('layout', layout.path, layout.path, { + renders: layout.renders, + render_calls: layout.render_calls, + function_calls: layout.function_calls, + graphql_calls: layout.graphql_calls, + }); + } + + private _buildEdges(): void { + const m = this._map; + + const layoutsByName: Record = {}; + for (const layout of Object.values(m.layouts ?? {})) { + if (!layout.path) continue; + const name = layout.path + .replace(/^app\/views\/layouts\//, '') + .replace(/\.html\.liquid$/, '') + .replace(/\.liquid$/, ''); + layoutsByName[name] = layout.path; + } + + for (const page of Object.values(m.pages ?? {})) { + if (!page.path) continue; + for (const r of page.renders ?? []) { + this._addEdge(page.path, resolveRenderTarget(r, m, page.path)); + } + for (const fc of page.function_calls ?? []) { + this._addEdge(page.path, resolveFunctionTarget(fc.path)); + } + if (page.layout) { + const layoutPath = layoutsByName[page.layout]; + if (layoutPath) this._addEdge(page.path, layoutPath); + } + } + + for (const layout of Object.values(m.layouts ?? {})) { + if (!layout.path) continue; + for (const r of layout.renders ?? []) { + this._addEdge(layout.path, resolveRenderTarget(r, m, layout.path)); + } + for (const fc of layout.function_calls ?? []) { + this._addEdge(layout.path, resolveFunctionTarget(fc.path)); + } + } + + for (const partial of Object.values(m.partials ?? {})) { + if (!partial.path) continue; + for (const r of partial.renders ?? []) { + this._addEdge(partial.path, resolveRenderTarget(r, m, partial.path)); + } + for (const fc of partial.function_calls ?? []) { + this._addEdge(partial.path, resolveFunctionTarget(fc.path)); + } + } + + for (const [cmdPath, cmd] of Object.entries(m.commands ?? {})) { + for (const fc of cmd.function_calls ?? []) { + this._addEdge(cmdPath, resolveFunctionTarget(fc.path)); + } + for (const g of cmd.graphql_calls ?? []) { + this._addEdge(cmdPath, resolveGraphqlTarget(g.queryName)); + } + } + + for (const [qPath, q] of Object.entries(m.queries ?? {})) { + for (const fc of q.function_calls ?? []) { + this._addEdge(qPath, resolveFunctionTarget(fc.path)); + } + for (const g of q.graphql_calls ?? []) { + this._addEdge(qPath, resolveGraphqlTarget(g.queryName)); + } + } + } + + private _indexRenderCalls(): void { + for (const [path, node] of this._nodes) { + const calls = node.render_calls; + if (calls && calls.length > 0) { + this._renderCalls.set(path, calls); + } + } + } + + // ── Public query API ─────────────────────────────────────────────────── + + nodeByPath(path: string | null | undefined): FactNode | null { + if (!path) return null; + return this._nodes.get(path) ?? null; + } + + nodesByType(type: FactNodeType): FactNode[] { + const bucket = this._byType.get(type); + return bucket ? [...bucket.values()] : []; + } + + nodeByKey(type: FactNodeType, key: string): FactNode | null { + return this._byType.get(type)?.get(key) ?? null; + } + + hasNode(path: string | null | undefined): boolean { + if (!path) return false; + return this._nodes.has(path); + } + + dependsOn(path: string): string[] { + return [...(this._dependsOn.get(path) ?? [])]; + } + + referencedBy(path: string): string[] { + return [...(this._referencedBy.get(path) ?? [])]; + } + + renderCallsFrom(filePath: string): RenderCall[] { + return this._renderCalls.get(filePath) ?? []; + } + + renderCallsTo(partialKey: string): RenderCallerRef[] { + const results: RenderCallerRef[] = []; + for (const [callerPath, calls] of this._renderCalls) { + for (const call of calls) { + if (call.partial === partialKey) { + results.push({ callerPath, args: call.args }); + } + } + } + return results; + } + + /** Declared `@param` list for a partial, or `null` if unknown. */ + partialSignature(partialKey: string): string[] | null { + const node = this.nodeByKey('partial', partialKey); + if (!node) return null; + return node.params ?? []; + } +} diff --git a/packages/platformos-mcp-supervisor/src/core/project-map.ts b/packages/platformos-mcp-supervisor/src/core/project-map.ts new file mode 100644 index 00000000..1c5d5d76 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/project-map.ts @@ -0,0 +1,56 @@ +/** + * Project-map cache. + * + * Source originally lived inside `tools/project-map.js` (the MCP tool) and + * was re-exported as `getProjectMap` for in-process consumers like + * `validate-code`. v1 drops the tool but keeps the cache — `validate-code` + * calls `getProjectMap` four times per invocation (sections 2d, 2e, 12a, + * and the new-partial caller check) and rescanning the project each time + * would be wasteful. + * + * TTL is 30 s (see `constants.PROJECT_MAP_CACHE_TTL_MS`). The + * `invalidateProjectMap` export from source was driven by the fs-watcher + * subsystem (out of scope) — dropped. Callers that need a fresh scan pass + * `{ forceRefresh: true }`. + */ + +import { PROJECT_MAP_CACHE_TTL_MS } from './constants'; +import { scanProject, type ProjectMap } from './project-scanner'; + +export interface GetProjectMapOptions { + forceRefresh?: boolean; +} + +let _cache: ProjectMap | null = null; +let _cacheTime = 0; +let _cacheDir: string | null = null; + +/** + * Return the cached `ProjectMap` for `projectDir`, scanning fresh when the + * cache is empty, expired, or scoped to a different project directory. + */ +export async function getProjectMap( + projectDir: string, + { forceRefresh = false }: GetProjectMapOptions = {}, +): Promise { + const now = Date.now(); + if ( + !forceRefresh && + _cache && + _cacheDir === projectDir && + now - _cacheTime < PROJECT_MAP_CACHE_TTL_MS + ) { + return _cache; + } + _cache = await scanProject(projectDir); + _cacheTime = Date.now(); + _cacheDir = projectDir; + return _cache; +} + +/** Reset the cached map. Test seam — not wired into production code. */ +export function _resetProjectMapCache(): void { + _cache = null; + _cacheTime = 0; + _cacheDir = null; +} diff --git a/packages/platformos-mcp-supervisor/src/core/project-scanner.ts b/packages/platformos-mcp-supervisor/src/core/project-scanner.ts new file mode 100644 index 00000000..d7e2a503 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/project-scanner.ts @@ -0,0 +1,692 @@ +/** + * Project scanner — builds a structured JSON index of a platformOS project. + * + * Reads `app/` (pages, partials, layouts, commands, queries, graphql, schema, + * translations, assets) and `modules/` (top-level module names) in parallel. + * Returns a `ProjectMap` shape consumed by `dependency-graph`, + * `project-fact-graph`, and `validate-code`'s diff-aware cross-file checks + * (sections 2d and 2e). + * + * Out of v1 scope: `scanAround` (powered the `project_map` MCP tool with + * `scope: 'around'`, which is not migrated), and `resolveRenderName` / + * `parseGraphQLFile` / `extractFunctionCalls` export-level exposure + * (used internally only). + */ + +import { readdir, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { basename, extname, join, relative } from 'node:path'; +import yaml from 'js-yaml'; +import { + extractAllFromAST, + parseLiquidFile, + type GraphqlRef, + type RenderCall, +} from './liquid-parser'; +import { getDomainFromPath, type Domain } from './domain-detector'; +import { toPosixPath } from './utils'; + +// ── Public types ─────────────────────────────────────────────────────────── + +export interface FunctionCall { + variable: string; + path: string; +} + +export interface SchemaProperty { + name: string; + type: string; +} + +export interface SchemaEntry { + path: string; + properties: SchemaProperty[]; +} + +export interface GraphqlArg { + name: string; + type: string; +} + +export interface GraphqlEntry { + operation: string | null; + name: string | null; + args: GraphqlArg[]; + table: string | null; +} + +export interface PageEntry { + path: string; + slug: string; + method: string; + layout: string | null; + renders: string[]; + render_calls: RenderCall[]; + function_calls: FunctionCall[]; + graphql_calls: GraphqlRef[]; +} + +export interface PartialEntry { + path: string; + params: string[]; + renders: string[]; + render_calls: RenderCall[]; + function_calls: FunctionCall[]; + graphql_calls: GraphqlRef[]; + rendered_by: string[]; +} + +export interface CommandEntry { + params: string[]; + phases: string[]; + graphql_calls: GraphqlRef[]; + function_calls: FunctionCall[]; +} + +export interface QueryEntry { + params: string[]; + graphql_calls: GraphqlRef[]; + function_calls: FunctionCall[]; +} + +export interface LayoutEntry { + path: string; + renders: string[]; + render_calls: RenderCall[]; + function_calls: FunctionCall[]; + graphql_calls: GraphqlRef[]; +} + +export interface ResourceEntry { + schema: string; + graphql: string[]; + commands: string[]; + queries: string[]; + pages: string[]; + missing: string[]; +} + +export interface ProjectMapSummary { + file_counts: Record; + resources: Record; +} + +export interface ProjectMap { + project: { + directory: string; + environments: string[]; + modules: string[]; + has_config: boolean; + }; + schema: Record; + graphql: Record; + commands: Record; + queries: Record; + pages: Record; + partials: Record; + layouts: Record; + translations: Record>; + assets: string[]; + summary: ProjectMapSummary; +} + +// ── Internal types ───────────────────────────────────────────────────────── + +interface ScannedLiquidFile { + relPath: string; + absPath: string; + domain: Domain; + structural: { + slug: string | null; + layout: string | null; + method: string | null; + renders: string[]; + renderCalls: RenderCall[]; + graphql: GraphqlRef[]; + filters: Set; + tags: Set; + transKeys: Set; + docParams: Set; + }; + functionCalls: FunctionCall[]; +} + +interface RawSchemaProperty { + name?: string; + type?: string; +} + +interface RawSchemaFile { + name?: string; + properties?: RawSchemaProperty[]; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * Scan the entire project and return a structured `ProjectMap`. + * + * Most scans run in parallel for throughput; the synchronous tail builds + * the reverse-index of partial callers from the union of all liquid files + * so cross-file edges (AddedParam / NewPartialParams / OrphanedPartial) + * are resolved without a second pass. + */ +export async function scanProject(projectDir: string): Promise { + const appDir = join(projectDir, 'app'); + + const [schema, graphql, liquidFiles, translations, modules, environments, hasConfig, assets] = + await Promise.all([ + scanSchema(appDir), + scanGraphQL(appDir), + scanLiquidFiles(appDir), + scanTranslations(appDir), + scanModules(projectDir), + scanEnvironments(projectDir), + scanConfig(appDir), + scanAssets(appDir), + ]); + + const pages: Record = {}; + const partials: Record = {}; + const commands: Record = {}; + const queries: Record = {}; + const layouts: Record = {}; + + for (const file of liquidFiles) { + switch (file.domain) { + case 'pages': { + // Key pages by {slug}:{method}. Multi-method routes (GET/POST/PUT/DELETE + // on `blog_posts`) scaffold to 7 files but share only 3–4 slugs; the + // pre-Phase-2.5 key collapse silently dropped everything but the last + // file per slug. Fallback when no frontmatter slug: key by relPath+method + // so the entry never collides with a real slug. + const method = (file.structural.method ?? 'get').toLowerCase(); + const slug = file.structural.slug ?? file.relPath; + const key = `${slug}:${method}`; + pages[key] = { + path: file.relPath, + slug, + method, + layout: file.structural.layout, + renders: file.structural.renders, + render_calls: file.structural.renderCalls, + function_calls: file.functionCalls, + graphql_calls: file.structural.graphql, + }; + break; + } + case 'partials': { + const name = partialNameFromPath(file.relPath); + partials[name] = { + path: file.relPath, + params: [...file.structural.docParams], + renders: file.structural.renders, + render_calls: file.structural.renderCalls, + function_calls: file.functionCalls, + graphql_calls: file.structural.graphql, + rendered_by: [], + }; + break; + } + case 'commands': { + commands[file.relPath] = { + params: [...file.structural.docParams], + phases: detectPhases(projectDir, file.relPath), + graphql_calls: file.structural.graphql, + function_calls: file.functionCalls, + }; + break; + } + case 'queries': { + queries[file.relPath] = { + params: [...file.structural.docParams], + graphql_calls: file.structural.graphql, + function_calls: file.functionCalls, + }; + break; + } + case 'layouts': { + layouts[file.relPath] = { + path: file.relPath, + renders: file.structural.renders, + render_calls: file.structural.renderCalls, + function_calls: file.functionCalls, + graphql_calls: file.structural.graphql, + }; + break; + } + default: + // graphql / schema / translations / config — not liquid file domains. + break; + } + } + + buildReverseIndex(partials, liquidFiles); + + const resources = detectResources(schema, graphql, commands, queries, pages); + + return { + project: { + directory: projectDir, + environments, + modules, + has_config: hasConfig, + }, + schema, + graphql, + commands, + queries, + pages, + partials, + layouts, + translations, + assets, + summary: { + file_counts: { + schema: Object.keys(schema).length, + graphql: Object.keys(graphql).length, + commands: Object.keys(commands).length, + queries: Object.keys(queries).length, + pages: Object.keys(pages).length, + partials: Object.keys(partials).length, + layouts: Object.keys(layouts).length, + assets: assets.length, + }, + resources, + }, + }; +} + +/** + * Pluralise a schema name using the simple English rules platformOS uses: + * - ends in `s`/`x`/`z`/`ch`/`sh` → append `es` + * - ends in consonant + `y` → drop `y`, append `ies` + * - otherwise → append `s` + * + * Consumed by `schema-property-checker` (P17). + */ +export function pluralize(name: string): string { + if ( + name.endsWith('s') || + name.endsWith('x') || + name.endsWith('z') || + name.endsWith('ch') || + name.endsWith('sh') + ) { + return `${name}es`; + } + if (name.endsWith('y') && !/[aeiou]y$/.test(name)) { + return `${name.slice(0, -1)}ies`; + } + return `${name}s`; +} + +// ── Sub-scanners ─────────────────────────────────────────────────────────── + +async function scanSchema(appDir: string): Promise> { + const schemaDir = join(appDir, 'schema'); + if (!existsSync(schemaDir)) return {}; + + const files = await readdir(schemaDir).catch(() => [] as string[]); + const schema: Record = {}; + + await Promise.all( + files.map(async (file) => { + if (!file.endsWith('.yml') && !file.endsWith('.yaml')) return; + try { + const content = await readFile(join(schemaDir, file), 'utf8'); + const parsed = yaml.load(content) as RawSchemaFile | undefined; + if (parsed?.name) { + schema[parsed.name] = { + path: `app/schema/${file}`, + properties: (parsed.properties ?? []) + .filter((p): p is { name: string; type?: string } => typeof p?.name === 'string') + .map((p) => ({ name: p.name, type: p.type ?? '' })), + }; + } + } catch { + // skip unparseable files + } + }), + ); + + return schema; +} + +async function scanGraphQL(appDir: string): Promise> { + const gqlDir = join(appDir, 'graphql'); + if (!existsSync(gqlDir)) return {}; + + const files = await globFiles(gqlDir, '.graphql'); + const graphql: Record = {}; + + await Promise.all( + files.map(async (relFile) => { + try { + const content = await readFile(join(gqlDir, relFile), 'utf8'); + const queryPath = relFile.replace(/\.graphql$/, ''); + graphql[queryPath] = parseGraphQLFile(content); + } catch { + // skip unparseable files + } + }), + ); + + return graphql; +} + +function parseGraphQLFile(content: string): GraphqlEntry { + const result: GraphqlEntry = { operation: null, name: null, args: [], table: null }; + + const opMatch = content.match(/^\s*(query|mutation)\s+(\w+)\s*(?:\(([^)]*)\))?/m); + if (opMatch) { + result.operation = opMatch[1]; + result.name = opMatch[2]; + if (opMatch[3]) { + for (const am of opMatch[3].matchAll(/\$(\w+):\s*([^,=)]+)/g)) { + result.args.push({ name: am[1], type: am[2].trim() }); + } + } + } + + const tableMatch = content.match(/table:\s*(?:\{\s*value:\s*"(\w+)"|"(\w+)")/); + if (tableMatch) { + result.table = tableMatch[1] ?? tableMatch[2] ?? null; + } + + return result; +} + +async function scanLiquidFiles(appDir: string): Promise { + const liquidFiles: ScannedLiquidFile[] = []; + const files = await globLiquidFiles(appDir); + + await Promise.all( + files.map(async (relFile) => { + const absPath = join(appDir, relFile); + const relPath = `app/${relFile}`; + const domain = getDomainFromPath(absPath); + if (!domain) return; + + try { + const content = await readFile(absPath, 'utf8'); + const ast = parseLiquidFile(content); + if (!ast) return; + + const extracted = extractAllFromAST(ast); + liquidFiles.push({ + relPath, + absPath, + domain, + structural: { + slug: extracted.slug, + layout: extracted.layout, + method: extracted.method, + renders: extracted.renders, + renderCalls: extracted.renderCalls, + graphql: extracted.graphql, + filters: extracted.filters, + tags: extracted.tags, + transKeys: extracted.transKeys, + docParams: extracted.docParams, + }, + functionCalls: extractFunctionCalls(content), + }); + } catch { + // skip unparseable files + } + }), + ); + + return liquidFiles; +} + +async function scanTranslations(appDir: string): Promise>> { + const transDir = join(appDir, 'translations'); + if (!existsSync(transDir)) return {}; + + const files = await readdir(transDir).catch(() => [] as string[]); + const translations: Record> = {}; + + await Promise.all( + files.map(async (file) => { + if (!file.endsWith('.yml') && !file.endsWith('.yaml')) return; + const locale = basename(file, extname(file)); + try { + const content = await readFile(join(transDir, file), 'utf8'); + const parsed = yaml.load(content); + if (parsed && typeof parsed === 'object') { + translations[locale] = flattenYaml(parsed as Record); + } + } catch { + // skip + } + }), + ); + + return translations; +} + +async function scanModules(projectDir: string): Promise { + const modulesDir = join(projectDir, 'modules'); + if (!existsSync(modulesDir)) return []; + + try { + const entries = await readdir(modulesDir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} + +async function scanEnvironments(projectDir: string): Promise { + const posFile = join(projectDir, '.pos'); + if (!existsSync(posFile)) return []; + + try { + const content = await readFile(posFile, 'utf8'); + const parsed = yaml.load(content); + if (!parsed || typeof parsed !== 'object') return []; + return Object.keys(parsed as Record); + } catch { + return []; + } +} + +async function scanConfig(appDir: string): Promise { + return existsSync(join(appDir, 'config.yml')); +} + +interface DirentLike { + parentPath?: string; + path?: string; + name: string; + isFile(): boolean; +} + +async function scanAssets(appDir: string): Promise { + const assetsDir = join(appDir, 'assets'); + if (!existsSync(assetsDir)) return []; + try { + const entries = (await readdir(assetsDir, { + withFileTypes: true, + recursive: true, + })) as DirentLike[]; + return entries + .filter((e) => e.isFile()) + .map((e) => + toPosixPath(relative(assetsDir, join(e.parentPath ?? e.path ?? assetsDir, e.name))), + ) + .sort(); + } catch { + return []; + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function extractFunctionCalls(content: string): FunctionCall[] { + const calls: FunctionCall[] = []; + const seen = new Set(); + const re = /function\s+(\w+)\s*=\s*['"]([^'"]+)['"]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const key = `${m[1]}:${m[2]}`; + if (!seen.has(key)) { + seen.add(key); + calls.push({ variable: m[1], path: m[2] }); + } + } + return calls; +} + +function partialNameFromPath(relPath: string): string { + return relPath + .replace(/^app\/views\/partials\//, '') + .replace(/\.html\.liquid$/, '') + .replace(/\.liquid$/, ''); +} + +function detectPhases(projectDir: string, relPath: string): string[] { + const basePath = join(projectDir, relPath.replace(/\.liquid$/, '')); + const phases = ['main']; + if (existsSync(join(basePath, 'build.liquid'))) phases.push('build'); + if (existsSync(join(basePath, 'check.liquid'))) phases.push('check'); + return phases; +} + +function buildReverseIndex( + partials: Record, + liquidFiles: ScannedLiquidFile[], +): void { + for (const file of liquidFiles) { + for (const renderName of file.structural.renders) { + const resolved = resolveRenderNameInternal(file.relPath, renderName); + const partial = partials[resolved]; + if (partial) { + partial.rendered_by.push(file.relPath); + } + } + for (const fc of file.functionCalls) { + const partialName = fc.path.replace(/^views\/partials\//, ''); + const partial = partials[partialName]; + if (partial) { + partial.rendered_by.push(file.relPath); + } + } + } +} + +/** + * Resolve a `{% render %}` name to a partial key, honouring relative names. + * + * Internal-only — not exported. `dependency-graph.ts` keeps a duplicate to + * avoid a circular import, matching source layout. + */ +function resolveRenderNameInternal(callerRelPath: string, renderName: string): string { + if (!renderName) return renderName; + if (renderName.includes('/')) return renderName; + if (renderName.startsWith('modules/')) return renderName; + + const partialsPrefix = 'app/views/partials/'; + if (!callerRelPath.startsWith(partialsPrefix)) return renderName; + + const relUnderPartials = callerRelPath + .slice(partialsPrefix.length) + .replace(/\.html\.liquid$/, '') + .replace(/\.liquid$/, ''); + const slashIdx = relUnderPartials.lastIndexOf('/'); + if (slashIdx < 0) return renderName; + const dir = relUnderPartials.slice(0, slashIdx); + return `${dir}/${renderName}`; +} + +function detectResources( + schema: Record, + graphql: Record, + commands: Record, + queries: Record, + pages: Record, +): Record { + const resources: Record = {}; + + for (const [tableName, tableInfo] of Object.entries(schema)) { + const plural = pluralize(tableName); + const r: ResourceEntry = { + schema: tableInfo.path, + graphql: [], + commands: [], + queries: [], + pages: [], + missing: [], + }; + + for (const [gqlPath, gqlInfo] of Object.entries(graphql)) { + if (gqlPath.startsWith(`${plural}/`) || gqlInfo.table === tableName) { + r.graphql.push(gqlPath); + } + } + for (const cmdPath of Object.keys(commands)) { + if (cmdPath.includes(`/commands/${plural}/`)) r.commands.push(cmdPath); + } + for (const qPath of Object.keys(queries)) { + if (qPath.includes(`/queries/${plural}/`)) r.queries.push(qPath); + } + // Iterate pages by value — the key now includes the method suffix + // (`{slug}:{method}`) so key-based startsWith would misfire. + for (const page of Object.values(pages)) { + const slug = page.slug ?? ''; + if (slug === plural || slug.startsWith(`${plural}/`)) { + r.pages.push(page.path); + } + } + + const ops = new Set(r.graphql.map((g) => basename(g))); + if (!ops.has('search') && !ops.has('list')) r.missing.push('search query'); + if (!ops.has('find') && !ops.has('get')) r.missing.push('find query'); + if (!ops.has('create')) r.missing.push('create mutation'); + if (!ops.has('update')) r.missing.push('update mutation'); + if (!ops.has('delete')) r.missing.push('delete mutation'); + + resources[tableName] = r; + } + + return resources; +} + +function flattenYaml(obj: Record, prefix: string = ''): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(result, flattenYaml(value as Record, fullKey)); + } else { + result[fullKey] = value; + } + } + return result; +} + +async function globFiles(dir: string, ext: string): Promise { + if (!existsSync(dir)) return []; + try { + const entries = (await readdir(dir, { recursive: true })) as string[]; + // Normalise to POSIX separators at the boundary. Every downstream key + // (graphql['blog_posts/search'], partials['blog_posts/card'], …) must + // be forward-slashed regardless of host OS or callers cannot do prefix + // matches with literal `/`. + return entries.filter((f) => f.endsWith(ext)).map(toPosixPath); + } catch { + return []; + } +} + +async function globLiquidFiles(appDir: string): Promise { + if (!existsSync(appDir)) return []; + try { + const entries = (await readdir(appDir, { recursive: true })) as string[]; + return entries.filter((f) => f.endsWith('.liquid')).map(toPosixPath); + } catch { + return []; + } +} diff --git a/packages/platformos-mcp-supervisor/src/core/render-flow.ts b/packages/platformos-mcp-supervisor/src/core/render-flow.ts new file mode 100644 index 00000000..de61228f --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/render-flow.ts @@ -0,0 +1,73 @@ +/** + * Render-flow analyser — cross-file variable tracking through render chains. + * + * Pure query functions over `ProjectFactGraph`. No side effects. + * + * Consumed by: + * - `UnusedAssign` rules — suppress when the variable is passed to a + * render or function call. + * - `MissingRenderPartialArguments` rules — read the target's declared + * `@param` list and detect chain-satisfaction (a missing arg may + * already be in the caller's own signature). + * + * v1 trim (dropped: no in-scope readers): `callersWithArgs`, + * `missingArgsForCaller`, `renderFlowSummary`. + */ + +import type { ProjectFactGraph } from './project-fact-graph'; + +/** + * True if `varName` is passed as an argument value in any `{% render %}` + * call inside `filePath`. Source semantics: match against the call's + * recorded `args` array — arg names that equal the variable name count. + */ +export function isVariablePassedToRender( + graph: ProjectFactGraph, + filePath: string, + varName: string, +): boolean { + const calls = graph.renderCallsFrom(filePath); + for (const call of calls) { + if (call.args.includes(varName)) return true; + } + return false; +} + +/** + * True if `varName` appears as the result variable of any `{% function %}` + * call in the file's node (e.g. `{% function varName = 'modules/x' %}`). + */ +export function isVariablePassedToFunction( + graph: ProjectFactGraph, + filePath: string, + varName: string, +): boolean { + const node = graph.nodeByPath(filePath); + if (!node?.function_calls) return false; + for (const fc of node.function_calls) { + if (fc.variable === varName) return true; + } + return false; +} + +/** Declared `@param` list for `partialKey`, or `[]` when unknown. */ +export function getPartialParams(graph: ProjectFactGraph, partialKey: string): string[] { + return graph.partialSignature(partialKey) ?? []; +} + +/** + * Chain-satisfaction check: a missing param on a callee is acceptable + * when the caller has the same param in its own signature. + * + * Page → A → B: if B requires `x` and A doesn't pass it, but A declares + * `x` as a `@param`, A has it in scope and can forward it. + */ +export function isParamAvailableInCallerScope( + graph: ProjectFactGraph, + callerPath: string, + paramName: string, +): boolean { + const callerNode = graph.nodeByPath(callerPath); + if (!callerNode?.params) return false; + return callerNode.params.includes(paramName); +} diff --git a/packages/platformos-mcp-supervisor/src/core/rules/ConvertIncludeToRender.ts b/packages/platformos-mcp-supervisor/src/core/rules/ConvertIncludeToRender.ts new file mode 100644 index 00000000..641edbe8 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/ConvertIncludeToRender.ts @@ -0,0 +1,33 @@ +/** + * ConvertIncludeToRender rule — migrate deprecated `{% include %}` to + * `{% render %}`. The LSP reports the check on every non-module include; this + * rule attaches the recommendation and stable attribution. A `text_edit` + * replacement (`include` → `render`) is produced by the heuristic + * fix-generator in full mode, with a guarded fallback for module-helper + * includes where the rename is NOT safe. + * + * The module-helper guard lives in fix-generator.js (pattern: `include + * 'modules/...'`). Keeping the skip logic there rather than duplicating it + * here means one source of truth — this rule just makes sure every include + * call gets a useful hint and a non-`unmatched` rule_id. + * + * Plan reference: Tier 1 trivial wins. + */ + +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'ConvertIncludeToRender.default', + check: 'ConvertIncludeToRender', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'ConvertIncludeToRender.default', + hint_md: + 'Replace `{% include "partial" %}` with `{% render "partial" %}`. `render` has isolated scope — pass every variable the partial needs explicitly: `{% render "partial", var: value %}`. Exception: `include` for **module helpers** (authorization, redirects) is correct because those partials intentionally need shared scope; the heuristic fix-generator detects this and proposes guidance instead of a rename.', + fixes: [], + confidence: 0.9, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/DeprecatedTag.ts b/packages/platformos-mcp-supervisor/src/core/rules/DeprecatedTag.ts new file mode 100644 index 00000000..9290cd97 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/DeprecatedTag.ts @@ -0,0 +1,151 @@ +/** + * DeprecatedTag rules — both the upstream LSP `DeprecatedTag` check (emitted + * by pos-cli for `{% include %}`, `{% parse_json %}`, etc.) AND the + * pos-supervisor structural variant `pos-supervisor:DeprecatedTag` (raised + * by `structural-warnings.detectDeprecatedTags` when the upstream check is + * silent for a tag we still want flagged). + * + * Rule_id pinning: every emit of either check now lands as + * `DeprecatedTag.` (or `.default`) in analytics. Before this module both + * checks were `*.unmatched`, so adoption + regression rates were collapsed + * across very different tags (`include` is ~100 % auto-fixable, `hash_assign` + * needs careful per-line edits, `parse_json` needs filter-syntax migration). + * + * Fix policy: + * - `include` → `text_edit` is owned by `ConvertIncludeToRender` (different + * check name, sibling rule). The deprecated-tag rule + * emits `guidance` only — duplicating the rename here + * would compete with that module. + * - `hash_assign` → fix-generator's `fixDeprecatedTag` produces a + * `hash_assign` → `assign` literal text_edit. We emit + * `guidance` so it isn't a duplicate; the heuristic + * edit complements the explanation. + * - `parse_json` → no live text_edit yet (the `| parse_json` filter form + * is structural, not a single-token swap). `guidance` + * plus an explicit migration recipe is the best signal + * short of an AST rewrite. + * - default → fallback for any other deprecated tag the upstream + * adds in future without a dedicated subrule. + */ +import type { Rule } from './engine'; + +function ruleIdPrefix(checkName: string): string { + // `pos-supervisor:DeprecatedTag` → rule_id starts with the bare check name + // so analytics aggregation across the upstream + structural variants stays + // readable. Storing rule_id as `pos-supervisor:DeprecatedTag.include` would + // break a couple of dashboard regexes that strip the colon segment. + return checkName.replace(/^pos-supervisor:/, ''); +} + +function rulesForCheck(checkName: string): Rule[] { + return [ + { + id: `${ruleIdPrefix(checkName)}.include`, + check: checkName, + priority: 10, + when: (diag) => + /\binclude\b/.test(diag.params?.tag ?? '') || /\binclude\b/.test(diag.message ?? ''), + apply: () => ({ + rule_id: `${ruleIdPrefix(checkName)}.include`, + hint_md: + '`{% include %}` is deprecated. Replace with `{% render %}` everywhere in this file. ' + + '`{% render %}` has **isolated scope** — variables from the parent are NOT inherited; ' + + "pass each one explicitly: `{% render 'partial', name: name, items: items %}`. " + + 'Exception: includes that pull in a module helper meant to share scope (auth, redirects) — ' + + 'leave those alone; the heuristic fix-generator detects this pattern and proposes guidance ' + + 'instead of a rename.', + fixes: [ + { + type: 'guidance', + description: + "Rename every `{% include 'X' %}` in this file to `{% render 'X', %}`. " + + "List the partial's declared `@param` names and pass each explicitly — " + + 'isolated scope means undeclared vars come through as `nil`.', + }, + ], + confidence: 0.95, + }), + }, + + { + id: `${ruleIdPrefix(checkName)}.hash_assign`, + check: checkName, + priority: 10, + when: (diag) => + /hash_assign/.test(diag.params?.tag ?? '') || /\bhash_assign\b/.test(diag.message ?? ''), + apply: () => ({ + rule_id: `${ruleIdPrefix(checkName)}.hash_assign`, + hint_md: + '`{% hash_assign x, key: value %}` is deprecated. Use the bracket-assign form ' + + '`{% assign x["key"] = value %}` (or dot form `{% assign x.key = value %}` for ' + + 'identifier keys). Both produce the same hash — the new form is plain `assign`.', + fixes: [ + { + type: 'guidance', + description: + 'Replace `{% hash_assign x, key: value %}` with `{% assign x["key"] = value %}`. ' + + 'For dotted identifiers: `{% assign x.key = value %}`. ' + + 'The heuristic fix-generator emits the literal `hash_assign` → `assign` text_edit; ' + + 'apply it then update the argument shape from `, key: value` to `["key"] = value`.', + }, + ], + confidence: 0.85, + }), + }, + + { + id: `${ruleIdPrefix(checkName)}.parse_json`, + check: checkName, + priority: 10, + when: (diag) => + /parse_json/.test(diag.params?.tag ?? '') || /\bparse_json\b/.test(diag.message ?? ''), + apply: () => ({ + rule_id: `${ruleIdPrefix(checkName)}.parse_json`, + hint_md: + '`{% parse_json x %}…{% endparse_json %}` is deprecated. Use the filter form ' + + "`{% assign x = '' | parse_json %}` (single-line) or build the literal with " + + '`{% capture json %}…{% endcapture %}{% assign x = json | parse_json %}` for ' + + 'multi-line payloads. The filter is exact-equivalent semantically.', + fixes: [ + { + type: 'guidance', + description: + 'Migrate `{% parse_json x %}{ … }{% endparse_json %}` to ' + + "`{% assign x = '{ … }' | parse_json %}`. For multi-line payloads use " + + '`{% capture body %}{ … }{% endcapture %}{% assign x = body | parse_json %}` so the ' + + 'JSON is left literal — `parse_json` accepts strings, not block contents.', + }, + ], + confidence: 0.85, + }), + }, + + { + id: `${ruleIdPrefix(checkName)}.default`, + check: checkName, + priority: 100, + when: () => true, + apply: (diag) => { + const tag = diag.params?.tag ?? null; + const replacement = diag.params?.replacement ?? null; + const tagSpan = tag ? `\`{% ${tag} %}\`` : 'this tag'; + const replSpan = replacement ? `Use \`{% ${replacement} %}\` instead.` : ''; + return { + rule_id: `${ruleIdPrefix(checkName)}.default`, + hint_md: + `${tagSpan} is deprecated in platformOS. ${replSpan} ` + + `Read the upstream message — it usually names the replacement and behavioral changes ` + + `(e.g. isolated scope for \`render\`). Fix every occurrence in this file in one pass; ` + + `leaving a single instance re-fires the check on the next write.`, + fixes: [], + confidence: 0.7, + }; + }, + }, + ]; +} + +export const rules: Rule[] = [ + ...rulesForCheck('DeprecatedTag'), + ...rulesForCheck('pos-supervisor:DeprecatedTag'), +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/DuplicateFunctionArguments.ts b/packages/platformos-mcp-supervisor/src/core/rules/DuplicateFunctionArguments.ts new file mode 100644 index 00000000..536949c4 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/DuplicateFunctionArguments.ts @@ -0,0 +1,36 @@ +/** + * DuplicateFunctionArguments rule — pos-cli 6.0.7 duplicate-arg detection. + * + * Upstream fires for both `{% render %}` and `{% function %}` when the same + * argument name appears twice in a single call. Liquid's last-key-wins + * semantics silently drops the first value, so the bug is usually a typo + * (two args meant to be different but typed the same). The rule attaches + * a rule_id and an action-oriented hint; upstream supplies the autofix. + */ + +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'DuplicateFunctionArguments.default', + check: 'DuplicateFunctionArguments', + priority: 100, + when: () => true, + apply: (diag) => { + const arg = diag.params?.argument ?? 'the duplicate argument'; + const tag = diag.params?.tag_kind ?? 'render'; + const partial = diag.params?.partial ?? '(unknown partial)'; + return { + rule_id: 'DuplicateFunctionArguments.default', + hint_md: `\`${arg}\` is passed twice to \`{% ${tag} '${partial}' %}\`. Liquid keeps the LAST occurrence and silently drops earlier ones — usually this is a typo (you meant two different keys). Decide: same value? delete one. Different values intended? rename one to its real key.`, + fixes: [ + { + type: 'guidance', + description: `Open the \`{% ${tag} '${partial}' %}\` call. Remove the second \`${arg}: …\` occurrence, or rename it to whatever distinct key was meant.`, + }, + ], + confidence: 0.9, + }; + }, + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/GraphQLCheck.ts b/packages/platformos-mcp-supervisor/src/core/rules/GraphQLCheck.ts new file mode 100644 index 00000000..adba642d --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/GraphQLCheck.ts @@ -0,0 +1,144 @@ +/** + * GraphQLCheck rules — GraphQL validation errors. + * + * Priority order: + * 10 — unknown_field: field doesn't exist on type → schema-aware suggestions + * 20 — unused_variable: variable declared but never used in query + * 30 — type_mismatch: variable type doesn't match expected type + * 100 — generic: fallback hint + */ +import type { Rule } from './engine'; +import { nearestByLevenshtein } from './queries'; + +export const rules: Rule[] = [ + { + id: 'GraphQLCheck.unknown_field', + check: 'GraphQLCheck', + priority: 10, + when: (diag) => { + const cat = diag.params?.category; + return cat === 'unknown_field_record' || cat === 'unknown_field_other'; + }, + apply: (diag, facts) => { + const params = diag.params!; + const field = params.field; + const typeName = params.type; + const isRecord = params.category === 'unknown_field_record'; + + if (isRecord && facts.graph) { + const schemaNodes = facts.graph.nodesByType('schema'); + const allProps: string[] = []; + for (const schema of schemaNodes) { + if (schema.properties) { + for (const p of schema.properties) { + if (p.name) allProps.push(p.name); + } + } + } + + const nearest = nearestByLevenshtein(field, allProps, 3); + const suggestion = nearest.length > 0 ? `Did you mean \`${nearest[0].name}\`?` : null; + const schemaList = schemaNodes + .slice(0, 5) + .map((s) => `\`${s.key}\``) + .join(', '); + + return { + rule_id: 'GraphQLCheck.unknown_field', + hint_md: `Cannot query field \`${field}\` on type \`${typeName}\`. In platformOS, Record fields come from schema definitions (tables).${suggestion ? ` ${suggestion}` : ''}\n\nAvailable tables: ${schemaList}${schemaNodes.length > 5 ? ` (+${schemaNodes.length - 5} more)` : ''}. Use \`properties\` to access custom fields, not top-level field names.`, + fixes: [], + confidence: 0.85, + see_also: { + tool: 'domain_guide', + args: { domain: 'graphql', section: 'gotchas' }, + reason: + 'Unknown field on Record type. domain_guide(graphql, gotchas) explains how to query schema properties via the properties hash.', + }, + }; + } + + return { + rule_id: 'GraphQLCheck.unknown_field', + hint_md: `Cannot query field \`${field}\` on type \`${typeName}\`. Check the GraphQL schema for valid fields on this type.`, + fixes: [], + confidence: 0.6, + }; + }, + }, + + { + id: 'GraphQLCheck.unused_variable', + check: 'GraphQLCheck', + priority: 20, + when: (diag) => diag.params?.category === 'unused_variable', + apply: (diag) => { + const params = diag.params!; + const varName = params.variable; + return { + rule_id: 'GraphQLCheck.unused_variable', + hint_md: `Variable \`$${varName}\` is declared but never used in the query. Either remove the variable declaration or use it in the query body.\n\nCommon causes: leftover from refactoring, copy-paste from another query, variable intended for a filter that was removed.`, + fixes: [], + confidence: 0.9, + }; + }, + }, + + { + id: 'GraphQLCheck.type_mismatch', + check: 'GraphQLCheck', + priority: 30, + when: (diag) => { + const cat = diag.params?.category; + return cat === 'type_mismatch_filter' || cat === 'type_mismatch_other'; + }, + apply: (diag) => { + const params = diag.params!; + const variable = params.variable; + const actualType = params.actual_type; + const expectedType = params.expected_type; + const isFilter = params.category === 'type_mismatch_filter'; + + if (isFilter) { + return { + rule_id: 'GraphQLCheck.type_mismatch', + hint_md: `Type mismatch: expected \`${expectedType}\` but got \`${actualType}\`.${variable ? ` Variable: \`$${variable}\`.` : ''}\n\nplatformOS uses filter types like \`StringFilter\`, \`IntFilter\`, etc. Instead of passing a plain value, wrap it: \`{ value: "your_value" }\` or \`{ value_in: ["a", "b"] }\`.`, + fixes: [], + confidence: 0.85, + see_also: { + tool: 'domain_guide', + args: { domain: 'graphql', section: 'gotchas' }, + reason: + 'Filter type mismatch. domain_guide(graphql, gotchas) explains platformOS filter input types and how to construct them.', + }, + }; + } + + return { + rule_id: 'GraphQLCheck.type_mismatch', + hint_md: `Type mismatch: expected \`${expectedType}\` but got \`${actualType}\`.${variable ? ` Variable: \`$${variable}\`.` : ''} Check that the variable type in the query header matches the schema definition.`, + fixes: [], + confidence: 0.7, + }; + }, + }, + + { + id: 'GraphQLCheck.generic', + check: 'GraphQLCheck', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'GraphQLCheck.generic', + hint_md: + 'GraphQL validation error. Check the query for syntax errors, undefined variables, or field name typos. Use `domain_guide(graphql)` for platformOS-specific GraphQL conventions.', + fixes: [], + confidence: 0.4, + see_also: { + tool: 'domain_guide', + args: { domain: 'graphql' }, + reason: + 'GraphQL error. domain_guide(graphql) covers platformOS-specific GraphQL patterns, filter types, and common mistakes.', + }, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/GraphQLVariablesCheck.ts b/packages/platformos-mcp-supervisor/src/core/rules/GraphQLVariablesCheck.ts new file mode 100644 index 00000000..1a403cc2 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/GraphQLVariablesCheck.ts @@ -0,0 +1,313 @@ +/** + * GraphQLVariablesCheck rules — `{% graphql result = 'op_name', $X: Y %}` + * passed (or omitted) a variable that doesn't match the .graphql operation's + * declared signature. + * + * Pre-rule the check landed as `.unmatched` (3 emits in DEMO, 100 % + * resolution but 0 % adoption — the LSP message named the variable but + * carried no actionable fix). The rule extracts variable + direction + * (required vs unknown) from the message and, when the file has graphql + * calls indexed by the project graph, surfaces the operation's full + * variable signature so the agent can pick the right value type. + * + * Subrules: + * • GraphQLVariablesCheck.parser_blind_spot — call lives inside a + * `{% liquid %}` block with multi-line `,` continuation. Both + * liquid-html-parser and pos-cli's LSP truncate the call at the first + * newline-comma; LSP fires `.required` for every arg past it. The + * agent sees the args in source, our default `.required` hint says + * "add the arg" — agent enters a regression spiral. This sub-rule + * fires first when the project graph reports the file's graphql call + * with `source_kind === 'liquid_multiline_truncated'` and steers the + * agent at the syntactic root cause instead. (Reproduced in DEMO + * 2026-04-27, 4 emits / 100 % regression.) + * • GraphQLVariablesCheck.required — agent forgot a `$var` argument. + * • GraphQLVariablesCheck.unknown — agent passed an undeclared `$var`. + * • GraphQLVariablesCheck.default — extractor failed; bare hint. + * + * Fix policy: guidance-only; the deterministic edit needs the call's + * argument list which the rule layer doesn't have. + */ + +import type { Rule, RuleDiagnostic, RuleFacts } from './engine'; + +export const rules: Rule[] = [ + { + id: 'GraphQLVariablesCheck.parser_blind_spot', + check: 'GraphQLVariablesCheck', + priority: 3, + when: (diag: RuleDiagnostic, facts: RuleFacts) => isParserBlindSpot(diag, facts), + apply: (diag: RuleDiagnostic, facts: RuleFacts) => buildParserBlindSpotHint(diag, facts), + }, + { + id: 'GraphQLVariablesCheck.required', + check: 'GraphQLVariablesCheck', + priority: 5, + when: (diag: RuleDiagnostic) => diag.params?.direction === 'required', + apply: (diag: RuleDiagnostic, facts: RuleFacts) => buildRequiredHint(diag, facts), + }, + { + id: 'GraphQLVariablesCheck.unknown', + check: 'GraphQLVariablesCheck', + priority: 6, + when: (diag: RuleDiagnostic) => diag.params?.direction === 'unknown', + apply: (diag: RuleDiagnostic, facts: RuleFacts) => buildUnknownHint(diag, facts), + }, + { + id: 'GraphQLVariablesCheck.default', + check: 'GraphQLVariablesCheck', + priority: 100, + when: () => true, + apply: (_diag: RuleDiagnostic) => ({ + rule_id: 'GraphQLVariablesCheck.default', + hint_md: + `\`{% graphql %}\` variable mismatch. Open the called .graphql operation under \`app/graphql/\` ` + + `and read the operation header — variables declared as \`$name: Type\` (no leading \`$\` is wrong). ` + + `Required → add the argument to the tag (\`{% graphql r = 'op', name: value %}\`); Unknown → drop it.`, + fixes: [ + { + type: 'guidance', + description: + `Open the .graphql file's operation header to see the full \`$variable: Type\` signature, then ` + + `pass each required variable as a named argument on the \`{% graphql %}\` tag.`, + }, + ], + confidence: 0.5, + }), + }, +]; + +/** + * True when the diagnostic looks like the multi-line truncation false-flag. + * + * Two paths, either of which is sufficient: + * + * 1. **Disk-indexed** — Project graph already has a graphql call from this + * file whose extracted `source_kind === 'liquid_multiline_truncated'`. + * Populated by `liquid-parser.classifyGraphqlSourceKind` during scan. + * + * 2. **In-memory** — `facts.content` (the live editor buffer) contains the + * truncation pattern. This path is essential for any file the agent has + * JUST written or is iterating on without a reindex pass: the disk graph + * lags behind the in-memory state, and the upstream LSP regression spiral + * starts immediately on the first emit. Without this path we measured + * 4/4 regressions on `app/lib/commands/contacts/create.liquid` (DEMO, + * 2026-04 → 2026-05) — the agent kept adding the missing variable that + * was already in source, every iteration tripping the same blind spot. + * + * The in-memory detector looks for the pattern: + * + * {% liquid + * graphql result = 'some/op', # comma at EOL + * arg1: … # truncated past here + * %} + * + * inside any `{% liquid %}` block in `content`. Falsy content / no liquid + * blocks / non-comma graphql lines all fall through to the downstream + * `.required` rule, so this predicate is purely additive. + */ +function isParserBlindSpot(diag: RuleDiagnostic, facts: RuleFacts): boolean { + if (diag?.params?.direction !== 'required') return false; + + // Path 1: project graph carries the source-kind flag. + const node = facts?.graph?.nodeByPath?.(diag?.file); + const calls = node?.graphql_calls ?? []; + if (calls.some((c) => c?.source_kind === 'liquid_multiline_truncated')) return true; + + // Path 2: in-memory content scan. + if (typeof facts?.content === 'string' && contentHasTruncatedGraphqlCall(facts.content)) { + return true; + } + return false; +} + +/** + * Detect the `{% liquid %}` multi-line graphql truncation by scanning the + * source for any block-level `graphql ...,` line ending in a trailing comma. + * + * The LSP truncates at the first newline-after-comma inside `{% liquid %}`. + * Outside `{% liquid %}` (i.e. tag-form `{% graphql ... %}`) commas don't + * truncate, so we must scope the scan to inside-block. The regex is + * deliberately tolerant: extra whitespace, the `if`/`assign`/etc. statements + * around the call, and chained continuations all still match because we + * look at one line at a time inside any `{% liquid %} ... %}` slice. + */ +function contentHasTruncatedGraphqlCall(content: string): boolean { + // Iterate every {% liquid ... %} block. The `[\s\S]` form catches newlines + // because the `s` flag is unsupported in some bundlers we still target. + const blockRe = /\{%-?\s*liquid\b([\s\S]*?)-?%\}/g; + let m; + while ((m = blockRe.exec(content)) !== null) { + const body = m[1]; + // Inside the body, any line that starts with `graphql` (after optional + // indent) AND ends with a `,` — possibly with trailing whitespace before + // the EOL — is the truncation pattern. We match per-line via /m so the + // `^` and `$` anchors operate on logical lines. + if (/^\s*graphql\b[^\n]*,\s*$/m.test(body)) return true; + } + return false; +} + +function buildParserBlindSpotHint(diag: RuleDiagnostic, facts: RuleFacts) { + const param = diag?.params?.param_name ?? ''; + const sigBlock = signatureBlock(diag, facts); + return { + rule_id: 'GraphQLVariablesCheck.parser_blind_spot', + hint_md: + `\`{% graphql %}\` call appears to pass \`${param}\`, but the parser cannot see it. ` + + `The call lives inside a \`{% liquid %}\` block written with multi-line \`,\` ` + + `continuation — both pos-cli's check and the AST parser stop at the first newline-comma, ` + + `so every named argument past it is silently dropped.\n\n` + + `Do NOT keep adding the argument — it is already there in source. **Fix the syntax**:\n\n` + + '```liquid\n' + + `{% graphql result = '', ${param}: ${param}, ... %} # tag form, args on one line\n` + + '```\n' + + `or, if you must keep it inside \`{% liquid %}\`, put every named arg on the SAME line as ` + + `\`graphql\`:\n\n` + + '```liquid\n' + + `{% liquid\n` + + ` graphql result = '', ${param}: ${param}, email: email, ...\n` + + `%}\n` + + '```' + + sigBlock, + fixes: [ + { + type: 'guidance', + description: + `Convert the multi-line \`graphql\` call to a single-line form. Either move it out of ` + + `\`{% liquid %}\` into \`{% graphql ... %}\` tag delimiters, or keep it inside the block ` + + `but place every \`name: value\` argument on the same line as \`graphql\`. The arguments ` + + `you wrote are correct — only the line breaks are dropping them.${diagFiles(diag, facts)}`, + }, + ], + confidence: 0.95, + see_also: { + tool: 'domain_guide', + args: { domain: 'graphql' }, + reason: + 'Multi-line `{% graphql %}` continuation inside `{% liquid %}` is silently truncated. domain_guide(graphql) shows the canonical tag form.', + }, + }; +} + +function buildRequiredHint(diag: RuleDiagnostic, facts: RuleFacts) { + const param = diag.params?.param_name ?? ''; + const sigBlock = signatureBlock(diag, facts); + return { + rule_id: 'GraphQLVariablesCheck.required', + hint_md: + `\`{% graphql %}\` call is missing required variable \`${param}\`. The operation declares ` + + `\`$${param}: \` in its header — every non-optional variable (no \`= default\`) MUST be passed ` + + `at the call site.\n\n` + + `Add to the tag:\n` + + '```liquid\n' + + `{% graphql result = '', ${param}: ${param} %} # forward caller scope\n` + + `{% graphql result = '', ${param}: \"value\" %} # literal\n` + + `{% graphql result = '', ${param}: context.params.${param} %} # request param\n` + + '```' + + sigBlock, + fixes: [ + { + type: 'guidance', + description: + `Add \`${param}: \` to the \`{% graphql %}\` tag. The value must match the declared ` + + `GraphQL type — pass a string for \`String!\`, an integer for \`Int!\`, an object literal for ` + + `input types, etc.${diagFiles(diag, facts)}`, + }, + ], + confidence: 0.75, + see_also: { + tool: 'domain_guide', + args: { domain: 'graphql' }, + reason: + 'GraphQL call variable mismatch. domain_guide(graphql) covers $variable signatures and value forwarding.', + }, + }; +} + +function buildUnknownHint(diag: RuleDiagnostic, facts: RuleFacts) { + const param = diag.params?.param_name ?? ''; + const sigBlock = signatureBlock(diag, facts); + return { + rule_id: 'GraphQLVariablesCheck.unknown', + hint_md: + `\`{% graphql %}\` call passes \`${param}\` but the operation does NOT declare \`$${param}\`. ` + + `Undeclared variables are silently dropped at call time — this is dead data that may mask a typo.\n\n` + + `Pick one fix:\n` + + ` A) **Drop** \`${param}: ...\` from the \`{% graphql %}\` tag in this file.\n` + + ` B) **Declare** \`$${param}: \` in the .graphql operation's variable list (and use it in ` + + `the body — orphan declarations themselves trigger \`GraphQLCheck\`).\n` + + ` C) **Rename** \`${param}\` to match an existing operation variable — common cause is a typo.` + + sigBlock, + fixes: [ + { + type: 'guidance', + description: + `Pick: (A) drop \`${param}: \` from the call, (B) add \`$${param}: \` to the .graphql ` + + `operation header, or (C) rename \`${param}\` to a declared variable.${diagFiles(diag, facts)}`, + }, + ], + confidence: 0.75, + see_also: { + tool: 'domain_guide', + args: { domain: 'graphql' }, + reason: + 'GraphQL call passes an undeclared variable. domain_guide(graphql) covers $variable signatures.', + }, + }; +} + +interface Signature { + queryName: string; + args: ReadonlyArray<{ name: string; type: string }>; +} + +/** + * Build a markdown block listing the declared variables of every graphql + * operation called from `diag.file`. Empty string when the file is not + * indexed or has no graphql_calls. + * + * Uses the graph's `graphql_calls` (which carries `{ variable, queryName }` + * per call) and the per-operation node's `args` list (parsed from the + * `.graphql` file's `query Foo($x: String!) { ... }` header). + */ +function signatureBlock(diag: RuleDiagnostic, facts: RuleFacts): string { + const sigs = collectSignatures(diag, facts); + if (sigs.length === 0) return ''; + const list = sigs + .map((s) => { + const args = + s.args.length === 0 + ? '(no variables)' + : s.args.map((a) => `\`$${a.name}: ${a.type}\``).join(', '); + return ` • \`${s.queryName}\` — ${args}`; + }) + .join('\n'); + return `\n\nGraphQL operation(s) called from this file:\n${list}`; +} + +function diagFiles(diag: RuleDiagnostic, facts: RuleFacts): string { + const sigs = collectSignatures(diag, facts); + if (sigs.length !== 1) return ''; + return ` Reference: \`app/graphql/${sigs[0].queryName}.graphql\`.`; +} + +function collectSignatures(diag: RuleDiagnostic, facts: RuleFacts): Signature[] { + const graph = facts?.graph; + const filePath = diag?.file; + if (!graph || !filePath) return []; + const node = graph.nodeByPath(filePath); + if (!node) return []; + const calls = node.graphql_calls ?? []; + const out: Signature[] = []; + const seen = new Set(); + for (const call of calls) { + const queryName = typeof call === 'string' ? call : call?.queryName; + if (!queryName || seen.has(queryName)) continue; + seen.add(queryName); + const opNode = graph.nodeByKey('graphql', queryName); + if (!opNode) continue; + out.push({ queryName, args: opNode.args ?? [] }); + } + return out; +} diff --git a/packages/platformos-mcp-supervisor/src/core/rules/ImgLazyLoading.ts b/packages/platformos-mcp-supervisor/src/core/rules/ImgLazyLoading.ts new file mode 100644 index 00000000..76557668 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/ImgLazyLoading.ts @@ -0,0 +1,27 @@ +/** + * ImgLazyLoading rule — performance hint. The LSP flags an `` without + * `loading="lazy"`; this rule attaches the recommendation text and stable + * attribution. The `text_edit` insert itself is produced by the heuristic + * fix-generator in full mode — rules this trivial don't need to reimplement + * position calculation. + * + * Plan reference: Tier 1 trivial wins. + */ + +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'ImgLazyLoading.recommended', + check: 'ImgLazyLoading', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'ImgLazyLoading.recommended', + hint_md: + 'Add `loading="lazy"` to this `` tag so the browser defers off-screen image loads. Improves Core Web Vitals (LCP) and reduces initial bytes transferred on long pages.', + fixes: [], + confidence: 0.9, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/ImgWidthAndHeight.ts b/packages/platformos-mcp-supervisor/src/core/rules/ImgWidthAndHeight.ts new file mode 100644 index 00000000..59f3a6ec --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/ImgWidthAndHeight.ts @@ -0,0 +1,27 @@ +/** + * ImgWidthAndHeight rule — layout-shift hint. The LSP flags an `` without + * explicit `width` and/or `height`; this rule attaches guidance and stable + * attribution. The `text_edit` insert is produced by the heuristic + * fix-generator (full mode); the rule keeps quick-mode diagnostics usefully + * labelled. + * + * Plan reference: Tier 1 trivial wins. + */ + +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'ImgWidthAndHeight.recommended', + check: 'ImgWidthAndHeight', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'ImgWidthAndHeight.recommended', + hint_md: + 'Add explicit `width` and `height` attributes to this `` tag. The browser uses them to reserve space before the image loads, eliminating cumulative layout shift (CLS). For responsive images, set the attributes to the intrinsic dimensions and override with CSS (`style="width:100%;height:auto"`).', + fixes: [], + confidence: 0.9, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/InvalidLayout.ts b/packages/platformos-mcp-supervisor/src/core/rules/InvalidLayout.ts new file mode 100644 index 00000000..3ee632c9 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/InvalidLayout.ts @@ -0,0 +1,82 @@ +/** + * pos-supervisor:InvalidLayout rule — page front-matter references a layout + * that doesn't exist on disk. + * + * Pre-rule the check landed as `.unmatched` even though fix-generator's + * `fixStructuralCheck` already produced a `create_file` proposal. Pre-task-4 + * the proposal hardcoded the `.html.liquid` extension which DOES NOT match + * many projects (DEMO included) — agents accepted the fix, the file landed + * at the wrong path, and the original error never resolved. + * + * Task 4 fixed the path in the structural emitter (it now embeds the right + * extension via `detectLayoutExtension`) and made `extractLayoutPath` + * lift the path verbatim from the message. This rule attaches stable + * attribution + a guidance fix that explains the two valid resolutions + * (rename the layout reference vs create the missing layout file). + * + * Pairs with `ValidFrontmatter.layout_missing` (upstream LSP). The dedup + * pass `suppressUpstreamFrontmatterDup` drops the upstream copy so only + * this rule fires per offending page. + */ +import type { Rule, RuleFix } from './engine'; + +const MSG_RE = /^Layout `([^`]+)` not found\. Expected file: `([^`]+)`\./; + +export const rules: Rule[] = [ + { + id: 'InvalidLayout.default', + check: 'pos-supervisor:InvalidLayout', + priority: 100, + when: () => true, + apply: (diag) => { + const m = (diag.message ?? '').match(MSG_RE); + const layoutName = m?.[1] ?? null; + const expectedPath = m?.[2] ?? null; + + const layoutSpan = layoutName ? `\`${layoutName}\`` : 'the referenced layout'; + const pathSpan = expectedPath ? `\`${expectedPath}\`` : 'the expected layout file'; + + const hint = + `${layoutSpan} is not on disk. The structural emitter resolved the canonical path to ` + + `${pathSpan} (extension picked to match the project's existing layouts).\n\n` + + `Two resolutions:\n` + + ` • **Layout reference is wrong** — fix \`layout: ${layoutName ?? ''}\` in this page's ` + + `front matter. Run \`project_map\` to see which layouts exist.\n` + + ` • **Layout file is missing** — create ${pathSpan}. Every layout MUST contain ` + + `\`{{ content_for_layout }}\` exactly once (and may add named \`{% yield 'name' %}\` slots).`; + + const fixes: RuleFix[] = expectedPath + ? [ + { + type: 'create_file', + path: expectedPath, + description: + `Create ${pathSpan} with at least \`{{ content_for_layout }}\` so pages using ` + + `\`layout: ${layoutName ?? ''}\` render. Verify the layout name is intentional first — ` + + `a typo in the page front matter is a more common cause than a genuinely missing layout.`, + }, + ] + : [ + { + type: 'guidance', + description: + `Either fix the layout name in the page's front matter, or create the layout file. ` + + `Run \`project_map\` to see which layouts exist.`, + }, + ]; + + return { + rule_id: 'InvalidLayout.default', + hint_md: hint, + fixes, + confidence: 0.85, + see_also: { + tool: 'domain_guide', + args: { domain: 'layouts' }, + reason: + 'Layout file conventions — required `{{ content_for_layout }}`, named yield slots, locations.', + }, + }; + }, + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/JsonLiteralQuoteStyle.ts b/packages/platformos-mcp-supervisor/src/core/rules/JsonLiteralQuoteStyle.ts new file mode 100644 index 00000000..e1792868 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/JsonLiteralQuoteStyle.ts @@ -0,0 +1,34 @@ +/** + * JsonLiteralQuoteStyle rule — pos-cli 6.0.7 inline JSON-literal grammar check. + * + * Upstream emits a single constant message when a single-quoted string appears + * inside a `{ … }` or `[ … ]` literal in `{% assign %}` / `{% return %}` / + * `{% function %}` arguments. The fix is mechanical: change the quotes to + * double quotes. The rule attaches a stable rule_id + a quote-swap-flavored + * hint; the upstream check itself ships an autofix corrector, so we don't + * duplicate the text_edit here. + */ + +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'JsonLiteralQuoteStyle.default', + check: 'JsonLiteralQuoteStyle', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'JsonLiteralQuoteStyle.default', + hint_md: + "String literals inside `{ … }` or `[ … ]` JSON literals must be double-quoted. Change the offending single quote to a double quote — the rest of the literal is fine. Liquid string assigns outside JSON literals (`{% assign x = 'hi' %}`) are not affected.", + fixes: [ + { + type: 'guidance', + description: + 'Replace the single-quoted string with a double-quoted equivalent. Example: `{ \'k\': \'v\' }` → `{ "k": "v" }`. The upstream check ships an autofix the agent can accept directly.', + }, + ], + confidence: 0.95, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/LiquidHTMLSyntaxError.ts b/packages/platformos-mcp-supervisor/src/core/rules/LiquidHTMLSyntaxError.ts new file mode 100644 index 00000000..f5d08a1e --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/LiquidHTMLSyntaxError.ts @@ -0,0 +1,177 @@ +/** + * LiquidHTMLSyntaxError rules — pos-cli surfaces a varied set of parser + * errors under one check name: + * - Unknown tag + * - For-loop argument shape mismatch + * - Missing `=` in graphql/function assigns (heuristic owns text_edit) + * - Inline array/hash literal in tag arguments + * - Unclosed Liquid block / mismatched quotes + * + * Pre-rule every emit landed as `.unmatched` even though the messages + * carry a clear discriminator. The rule routes by message shape and emits + * subrule-specific guidance so analytics distinguish the very different + * resolution rates (unknown_tag at ~100 % vs nested array literal where the + * agent often misreads the cause). + * + * Fix policy: + * - missing_assign — heuristic in fix-generator already produces a + * text_edit. Rule emits guidance; precedence drops the heuristic + * `guidance` if any (it isn't there in this branch). + * - All others — guidance only; no deterministic AST rewrite. + */ + +import type { Rule } from './engine'; +import { nearestByLevenshtein } from './queries'; + +export const rules: Rule[] = [ + { + id: 'LiquidHTMLSyntaxError.unknown_tag', + check: 'LiquidHTMLSyntaxError', + priority: 5, + when: (diag) => /Unknown tag/i.test(diag.message ?? ''), + apply: (diag, facts) => { + const m = (diag.message ?? '').match(/Unknown tag\s+['"`]?(\w+)['"`]?/i); + const badTag = m?.[1] ?? null; + const tagsIndex = facts?.tagsIndex; + + let didYouMean = ''; + if (badTag && tagsIndex?.platformOSTags) { + const known = tagsIndex.platformOSTags().map((t) => t.name); + const nearest = nearestByLevenshtein(badTag, known, 3); + if (nearest.length > 0) { + const list = nearest.map((n) => `\`{% ${n.name} %}\``).join(', '); + didYouMean = ` Did you mean: ${list}?`; + } + } + + const tagSpan = badTag ? `\`{% ${badTag} %}\`` : 'this tag'; + return { + rule_id: 'LiquidHTMLSyntaxError.unknown_tag', + hint_md: + `${tagSpan} is not a recognised Liquid tag in platformOS.${didYouMean}\n\n` + + `Common causes: typo in the tag name, custom tag from another framework (Shopify Liquid extras ` + + `like \`{% layout %}\`, \`{% schema %}\` are NOT supported), or a stale rename. ` + + `If the tag is custom: platformOS does not support custom tags — restructure as a partial ` + + `(\`{% render %}\`) or filter.`, + fixes: [ + { + type: 'guidance', + description: badTag + ? `Replace ${tagSpan} with the correct tag name (see suggestions in the hint), or ` + + `restructure if it was a Shopify-only tag.` + : `Read the upstream message — it names the unknown tag. Replace it with a valid platformOS ` + + `tag or restructure the logic.`, + }, + ], + confidence: 0.85, + }; + }, + }, + + { + id: 'LiquidHTMLSyntaxError.for_loop_args', + check: 'LiquidHTMLSyntaxError', + priority: 10, + when: (diag) => /Arguments must be provided in the format `for in/i.test(diag.message ?? ''), + apply: (diag) => { + const m = (diag.message ?? '').match(/Invalid\/Unknown arguments:\s*(.+)$/i); + const badArgs = m?.[1]?.trim() ?? null; + const argsSpan = badArgs ? `\`${badArgs}\`` : 'the offending argument(s)'; + return { + rule_id: 'LiquidHTMLSyntaxError.for_loop_args', + hint_md: + `\`{% for %}\` arguments must follow the form ` + + `\`for in [reversed] [limit:N] [offset:N]\`. ` + + `${argsSpan} ${badArgs ? 'are' : 'is'} not a recognised positional or named argument.\n\n` + + `Frequent root cause: a Liquid filter (\`| t\`, \`| split\`, etc.) appears INSIDE the ` + + `\`for ... in ...\` clause. The Liquid parser does not accept filter pipelines in the loop ` + + `header — assign the filtered value first, then iterate.\n\n` + + `Wrong: \`{% for item in 'k' | t %}\` Right: \`{% assign items = 'k' | t %}{% for item in items %}\`. ` + + `Wrong: \`{% for word in str | split: ',' %}\` Right: \`{% assign words = str | split: ',' %}{% for word in words %}\`.`, + fixes: [ + { + type: 'guidance', + description: + `Move the filter pipeline out of the \`for in \` clause: ` + + `\`{% assign items = %}\` first, then \`{% for item in items %}\`. ` + + `For nested loops over translation arrays, see the TranslationKeyExists.array_index_misuse ` + + `pattern.`, + }, + ], + confidence: 0.85, + }; + }, + }, + + { + id: 'LiquidHTMLSyntaxError.missing_assign', + check: 'LiquidHTMLSyntaxError', + priority: 15, + when: (diag) => + /\{%\s*(?:graphql|function)/.test(diag.message ?? '') && /=/.test(diag.message ?? ''), + apply: () => ({ + rule_id: 'LiquidHTMLSyntaxError.missing_assign', + hint_md: + '`{% graphql %}` and `{% function %}` require an assignment target. The syntax is ' + + "`{% graphql result = 'query_name' %}` and `{% function result = 'path/to/helper', arg: val %}` — " + + 'the `result =` part captures the call output and is not optional.', + fixes: [ + { + type: 'guidance', + description: + 'Add ` =` between the tag name and the call path. ' + + "`{% graphql records = 'q' %}` / `{% function record = 'helper', x: 1 %}`. " + + 'fix-generator emits the literal text_edit for the missing-`=` shape — accept it.', + }, + ], + confidence: 0.9, + }), + }, + + { + id: 'LiquidHTMLSyntaxError.inline_literal', + check: 'LiquidHTMLSyntaxError', + priority: 20, + when: (diag) => + /(?:array|hash|object|literal|inline)/i.test(diag.message ?? '') && + /\{%\s*(?:render|function|graphql)/.test(diag.message ?? ''), + apply: () => ({ + rule_id: 'LiquidHTMLSyntaxError.inline_literal', + hint_md: + 'Inline `[…]` array literals and `{ … }` hash literals are NOT accepted as tag arguments. ' + + "Liquid's tag parser only takes named scalars and pre-assigned variables. Build the literal " + + 'in a preceding `{% assign %}` then pass the variable.\n\n' + + "Wrong: `{% render 'p', items: [] %}` Right: `{% assign items = [] %}{% render 'p', items: items %}`.", + fixes: [ + { + type: 'guidance', + description: + 'Pre-assign the literal: `{% assign items = […] %}` (or `{% assign cfg = { … } %}` for hashes), ' + + 'then pass `items` (or `cfg`) by name in the render/function/graphql tag.', + }, + ], + confidence: 0.85, + }), + }, + + { + id: 'LiquidHTMLSyntaxError.default', + check: 'LiquidHTMLSyntaxError', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'LiquidHTMLSyntaxError.default', + hint_md: + 'Liquid parser error. Read the upstream message — it names the line and column. ' + + 'Common causes:\n' + + ' • Unclosed block (`{% if %}` without `{% endif %}`, `{% for %}` without `{% endfor %}`).\n' + + ' • Inside `{% liquid %}` blocks each statement is on its own line with NO delimiters.\n' + + ' • Mismatched quotes — every `\'` and `"` must be paired on the same logical token.\n' + + ' • HTML and Liquid syntax interleaved unsafely (e.g. `
` ' + + 'is fine; `
` is not).\n\n' + + 'Fix the FIRST reported error — later errors often cascade from it.', + fixes: [], + confidence: 0.5, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MetadataParamsCheck.ts b/packages/platformos-mcp-supervisor/src/core/rules/MetadataParamsCheck.ts new file mode 100644 index 00000000..371cf5cd --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MetadataParamsCheck.ts @@ -0,0 +1,109 @@ +/** + * MetadataParamsCheck rules — metadata/doc block parameter violations. + * + * Priority order: + * 10 — module_contract: partial belongs to a known module → show module API + * 20 — doc_block_params: cross-reference {% doc %} params with callers + * 100 — generic: fallback hint + */ + +import type { Rule } from './engine'; + +interface DocParam { + name?: string; + required?: boolean; +} + +export const rules: Rule[] = [ + { + id: 'MetadataParamsCheck.module_contract', + check: 'MetadataParamsCheck', + priority: 10, + when: (diag) => { + const msg = diag.message ?? ''; + const partialMatch = msg.match(/['"`]([^'"`]+)['"`]/); + const name = partialMatch?.[1]; + return !!name && name.startsWith('modules/'); + }, + apply: (diag) => { + const msg = diag.message ?? ''; + const partialMatch = msg.match(/['"`]([^'"`]+)['"`]/); + const name = partialMatch?.[1]; + const moduleName = name?.split('/')[1] ?? 'unknown'; + const isFunctionCall = diag.params?.is_function_call === 'true'; + const tag = isFunctionCall ? 'function' : 'render'; + + return { + rule_id: 'MetadataParamsCheck.module_contract', + hint_md: `Parameter mismatch on module ${tag} call \`${name}\`. Module partials define their contract via \`{% doc %}\` blocks — use \`module_info\` to see the expected signature.`, + fixes: [], + confidence: 0.85, + see_also: { + tool: 'module_info', + args: { name: moduleName, section: 'api' }, + reason: `Module '${moduleName}' param mismatch. module_info(${moduleName}, api) shows the full signature with required/optional params.`, + }, + }; + }, + }, + + { + id: 'MetadataParamsCheck.doc_block_params', + check: 'MetadataParamsCheck', + priority: 20, + when: (diag, facts) => { + const msg = diag.message ?? ''; + const partialMatch = msg.match(/['"`]([^'"`]+)['"`]/); + const name = partialMatch?.[1]; + if (!name || name.startsWith('modules/')) return false; + const sig = facts.graph!.partialSignature(name); + return sig !== null && sig.length > 0; + }, + apply: (diag, facts) => { + const msg = diag.message ?? ''; + const partialMatch = msg.match(/['"`]([^'"`]+)['"`]/); + const name = partialMatch![1]; + const sig = facts.graph!.partialSignature(name) as unknown as DocParam[]; + const isFunctionCall = diag.params?.is_function_call === 'true'; + const tag = isFunctionCall ? 'function' : 'render'; + + const paramList = sig + .map((p) => { + const req = p.required ? '(required)' : '(optional)'; + return `\`${p.name}\` ${req}`; + }) + .join(', '); + + return { + rule_id: 'MetadataParamsCheck.doc_block_params', + hint_md: `Parameter issue on \`{% ${tag} '${name}' %}\`. Declared params: ${paramList}.\n\nCheck the \`{% doc %}\` block in \`${name}\` for the full contract.`, + fixes: [], + confidence: 0.8, + see_also: { + tool: 'domain_guide', + args: { domain: 'partials', section: 'api' }, + reason: + 'Render call param mismatch. domain_guide(partials, api) explains how {% doc %} @param declarations interact with render and function calls.', + }, + }; + }, + }, + + { + id: 'MetadataParamsCheck.generic', + check: 'MetadataParamsCheck', + priority: 100, + when: () => true, + apply: (diag) => { + const isFunctionCall = diag.params?.is_function_call === 'true'; + return { + rule_id: 'MetadataParamsCheck.generic', + hint_md: isFunctionCall + ? 'Function call parameter mismatch. Check the `{% doc %}` block in the target command/query for required `@param` declarations.' + : 'Render call parameter mismatch. Check the `{% doc %}` block in the target partial for required `@param` declarations.', + fixes: [], + confidence: 0.4, + }; + }, + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MissingAsset.ts b/packages/platformos-mcp-supervisor/src/core/rules/MissingAsset.ts new file mode 100644 index 00000000..7dfb9722 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MissingAsset.ts @@ -0,0 +1,159 @@ +/** + * MissingAsset rules — `{{ 'foo.css' | asset_url }}` or + * `{% include_asset 'foo.js' %}` references a file that doesn't exist + * under `app/assets/`. + * + * Pre-rule the check landed as `.unmatched`; fix-generator's + * `fixMissingAsset` produced an unconditional `create_file` proposal + * (`app/assets/`). That's wrong roughly half the time — the more + * common case is a typo or a missing subdirectory prefix + * (`logo.png` vs `images/logo.png`). The DEMO data showed 0 % resolution, + * 33 % adoption-but-`partial` (agent ran the create_file then realized the + * intended file was elsewhere). + * + * Subrules: + * 5 — missing_subdir_prefix: bare filename like `logo.png` matches an + * existing asset under a known subdir (`images/logo.png`). + * Highest-confidence "this is a typo, fix the reference" signal. + * 10 — suggest_nearest: Levenshtein vs `assetNames(graph)`. Catches + * ordinary typos (`maain.css` → `main.css`). + * 100 — create_file: nothing close, propose creating it. Mirrors the + * existing heuristic so analytics gets stable rule_id even when no + * match exists. + */ + +import type { Rule } from './engine'; +import { assetNames, nearestByLevenshtein } from './queries'; + +const KNOWN_ASSET_SUBDIRS = ['images', 'styles', 'scripts', 'fonts', 'media']; + +export const rules: Rule[] = [ + { + id: 'MissingAsset.missing_subdir_prefix', + check: 'MissingAsset', + priority: 5, + when: (diag, facts) => { + const wanted = parseAssetPath(diag.message); + if (!wanted || wanted.includes('/')) return false; + if (!facts?.graph) return false; + const all = assetNames(facts.graph); + return all.some((a) => assetMatchesBareName(a, wanted)); + }, + apply: (diag, facts) => { + const wanted = parseAssetPath(diag.message); + if (!wanted || !facts.graph) return null; + const all = assetNames(facts.graph); + const matches = all.filter((a) => assetMatchesBareName(a, wanted)); + const best = matches[0]; + const matchList = matches + .slice(0, 5) + .map((m) => `\`${m}\``) + .join(', '); + return { + rule_id: 'MissingAsset.missing_subdir_prefix', + hint_md: + `\`${wanted}\` is not at \`app/assets/${wanted}\` directly, but a file with this name lives under ` + + `a subdirectory: ${matchList}. \`asset_url\` paths are relative to \`app/assets/\` AND must include ` + + `the subdirectory (\`images/\`, \`styles/\`, \`scripts/\`, \`fonts/\`, \`media/\`). Fix the reference, ` + + `don't create a new file.`, + fixes: [ + { + type: 'guidance', + description: + `Replace \`'${wanted}'\` with \`'${best}'\` in the \`asset_url\` filter (or \`include_asset\` tag). ` + + `Do NOT create a new \`app/assets/${wanted}\` — the file already exists at \`app/assets/${best}\`.`, + }, + ], + confidence: 0.9, + }; + }, + }, + + { + id: 'MissingAsset.suggest_nearest', + check: 'MissingAsset', + priority: 10, + when: (diag, facts) => { + const wanted = parseAssetPath(diag.message); + if (!wanted) return false; + if (!facts?.graph) return false; + const all = assetNames(facts.graph); + if (all.length === 0) return false; + return nearestByLevenshtein(wanted, all, 3).length > 0; + }, + apply: (diag, facts) => { + const wanted = parseAssetPath(diag.message); + if (!wanted || !facts.graph) return null; + const all = assetNames(facts.graph); + const nearest = nearestByLevenshtein(wanted, all, 3); + const best = nearest[0].name; + const list = nearest.map((n) => `\`${n.name}\``).join(', '); + return { + rule_id: 'MissingAsset.suggest_nearest', + hint_md: + `\`${wanted}\` not found under \`app/assets/\`. Did you mean: ${list}? ` + + `If the reference is a typo, fix it. If you genuinely need a new asset, create the file ` + + `at \`app/assets/${wanted}\`.`, + fixes: [ + { + type: 'guidance', + description: + `Replace \`'${wanted}'\` with \`'${best}'\` in the \`asset_url\` filter (or \`include_asset\` tag). ` + + `Distance ${nearest[0].distance} — verify the suggestion before applying.`, + }, + ], + confidence: nearest[0].distance <= 2 ? 0.85 : 0.65, + }; + }, + }, + + { + id: 'MissingAsset.create_file', + check: 'MissingAsset', + priority: 100, + when: () => true, + apply: (diag) => { + const wanted = parseAssetPath(diag.message); + const targetPath = wanted ? `app/assets/${wanted}` : 'app/assets/'; + return { + rule_id: 'MissingAsset.create_file', + hint_md: + `\`${wanted ?? 'asset'}\` does not exist under \`app/assets/\`. ` + + `\`asset_url\` paths are relative to \`app/assets/\` AND must include the subdirectory ` + + `(\`images/\`, \`styles/\`, \`scripts/\`, \`fonts/\`, \`media/\`). ` + + `If this is a module-shipped asset the file may already exist inside the module's ` + + `\`public/assets/\` — module assets are referenced through the same \`asset_url\` filter ` + + `and should resolve automatically; if they don't, the module isn't installed.`, + fixes: [ + { + type: 'guidance', + description: + `Create the asset at \`${targetPath}\`, OR (more likely) fix the reference — module assets ` + + `live under \`modules//public/assets/\` and resolve through the same \`asset_url\` filter ` + + `without any path prefix. Only create a new file when you control the asset and it is genuinely missing.`, + }, + ], + confidence: 0.6, + }; + }, + }, +]; + +function parseAssetPath(message: string | undefined): string | null { + if (!message) return null; + const m = message.match(/['"`]([^'"`]+)['"`]\s+does not exist/); + return m ? m[1] : null; +} + +function assetMatchesBareName(assetPath: string, bareName: string): boolean { + // `assetPath` is relative to app/assets/, e.g. `images/logo.png`. We're + // matching when the LAST segment equals the bare name AND the leading + // segment is one of the conventional asset subdirs. This avoids + // false-positive matches against unrelated nested files + // (e.g. agent wrote `data.json` and we'd otherwise match `vendor/x/data.json`). + const slash = assetPath.indexOf('/'); + if (slash < 0) return false; + const subdir = assetPath.slice(0, slash); + const tail = assetPath.slice(slash + 1); + return tail === bareName && KNOWN_ASSET_SUBDIRS.includes(subdir); +} diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MissingContentForLayout.ts b/packages/platformos-mcp-supervisor/src/core/rules/MissingContentForLayout.ts new file mode 100644 index 00000000..5dfcb2ea --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MissingContentForLayout.ts @@ -0,0 +1,48 @@ +/** + * pos-supervisor:MissingContentForLayout rule — layouts missing + * `{{ content_for_layout }}`. Pre-rule the check landed as `.unmatched` + * even though fix-generator's `fixMissingContentForLayout` already inserts + * the placeholder after `` (or at line 0 if no body tag exists). + * + * The rule attaches stable attribution and a `guidance` fix that explains + * the relationship between `{{ content_for_layout }}` and named `{% yield %}` + * slots — the heuristic's `insert` text_edit is the actionable diff. + */ +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'MissingContentForLayout.default', + check: 'pos-supervisor:MissingContentForLayout', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'MissingContentForLayout.default', + hint_md: + 'Every layout MUST include `{{ content_for_layout }}` exactly once — that is where the page body ' + + 'is rendered into. Without it, pages using this layout serve only the layout chrome (header / nav / ' + + 'footer) and the page-specific content silently disappears.\n\n' + + 'Distinction:\n' + + ' • `{{ content_for_layout }}` — the implicit "page body" slot. Every layout has exactly one.\n' + + ' • `{% yield "name" %}` — named, optional slots a page can fill via `{% content_for "name" %}`. ' + + 'Use these for sidebars, head injection, etc. Adding more named slots does NOT replace the ' + + 'implicit body slot.', + fixes: [ + { + type: 'guidance', + description: + 'Insert `{{ content_for_layout }}` once in the layout — typically right after the `` tag. ' + + 'The heuristic fix-generator emits the literal `insert` text_edit; accept it. ' + + 'Add named `{% yield "name" %}` slots only when pages need extra fill points.', + }, + ], + confidence: 0.95, + see_also: { + tool: 'domain_guide', + args: { domain: 'layouts' }, + reason: + 'Layouts domain guide explains content_for_layout vs yield and shows the canonical layout shape.', + }, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MissingPage.ts b/packages/platformos-mcp-supervisor/src/core/rules/MissingPage.ts new file mode 100644 index 00000000..a82bd36d --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MissingPage.ts @@ -0,0 +1,185 @@ +/** + * MissingPage rule — `link_to '/foo'`, `redirect_to '/foo'`, etc. references + * a route the project doesn't serve. The diagnostic-pipeline already + * suppresses references whose page is on disk (via `buildPageRouteIndex` + + * `resolvePageRoute`); by the time the diagnostic reaches this rule the + * route is genuinely missing OR served with a different method. + * + * Pre-rule the check landed as `.unmatched`. The bare LSP message + * (`No page found for route '/foo' (GET)`) gives the agent no signal on + * whether to fix the route, change method, or create the page. + * + * Subrules: + * 10 — typo: extracted route is Levenshtein-close to an indexed page slug + * → suggest renaming the reference. + * 100 — default: emit a structured decision tree (typo / new page / method + * mismatch) and propose a `create_file` for the most-likely page path. + * + * Note: route ↔ method mismatch detection lives in the pipeline upstream. + * This rule treats every surviving diagnostic as a "page truly missing" + * outcome and points the agent at the three valid resolutions. + */ +import type { Rule } from './engine'; +import type { ProjectFactGraph } from '../project-fact-graph'; +import { nearestByLevenshtein } from './queries'; + +interface ParsedRoute { + route: string; + method: string; +} + +export const rules: Rule[] = [ + { + id: 'MissingPage.typo', + check: 'MissingPage', + priority: 10, + when: (diag, facts) => { + const parsed = parseMissingPageMessage(diag.message); + if (!parsed) return false; + const candidates = pageRouteCandidates(facts.graph); + if (candidates.length === 0) return false; + return nearestByLevenshtein(parsed.route, candidates, 3).length > 0; + }, + apply: (diag, facts) => { + const parsed = parseMissingPageMessage(diag.message); + if (!parsed) return null; + const candidates = pageRouteCandidates(facts.graph); + const nearest = nearestByLevenshtein(parsed.route, candidates, 3); + const best = nearest[0]; + if (!best || best.distance > 3) return null; + const list = nearest.map((n) => `\`/${n.name}\``).join(', '); + return { + rule_id: 'MissingPage.typo', + hint_md: + `No page serves \`/${parsed.route}\` (${parsed.method.toUpperCase()}), but the project has nearby routes: ${list}. ` + + `If the reference is a typo, fix it; if the page is genuinely missing, scaffold it now.`, + fixes: [ + { + type: 'guidance', + description: + `Replace \`'/${parsed.route}'\` with \`'/${best.name}'\` in the link/redirect (or correct the slug ` + + `to match \`/${parsed.route}\` if you actually meant the latter). Distance ${best.distance} — ` + + `verify the correction before applying.`, + }, + ], + confidence: best.distance <= 1 ? 0.85 : 0.7, + }; + }, + }, + + { + id: 'MissingPage.default', + check: 'MissingPage', + priority: 100, + when: () => true, + apply: (diag) => { + const parsed = parseMissingPageMessage(diag.message); + const route = parsed ? parsed.route : null; // can legitimately be '' for root + const method = parsed?.method ?? 'get'; + const haveRoute = route !== null; + const inferredPath = haveRoute ? routeToPagePath(route) : 'app/views/pages/.liquid'; + const routeSpan = haveRoute ? `\`/${route}\`` : 'this route'; + // The root page conventionally has either an empty `slug:` or none at + // all (the file-path → route fallback covers it). Distinguish the + // wording so an empty `slug:` line doesn't read like a typo. + const slugBlurb = !haveRoute + ? '(set `slug:` to the desired URL)' + : route === '' + ? '(omit `slug:` — the root page lives at `app/views/pages/index.liquid` and serves `/` automatically)' + : `(\`slug: ${route}\`)`; + + const hint = + `${routeSpan} (${method.toUpperCase()}) is not served by any page in this project. ` + + `Three valid resolutions:\n` + + ` • **Typo in the reference** — fix the slug at the call site (\`link_to\`, \`redirect_to\`, ` + + `\`form action\`, etc.).\n` + + ` • **New page** — scaffold a page at \`${inferredPath}\` ${slugBlurb}. ` + + `The file path alone determines the route when no \`slug:\` front-matter key is present.\n` + + ` • **Method mismatch** — a page may serve this URL for a different HTTP method (e.g. agent ` + + `wrote ${method.toUpperCase()} but the page is GET-only). Open the candidate page and check ` + + `its \`method:\` front-matter key.\n\n` + + `If you're mid-feature and the page is in the plan but not yet on disk, pass ` + + `\`pending_pages=["${inferredPath}"]\` to validate_code so this stops firing while you write it.`; + + return { + rule_id: 'MissingPage.default', + hint_md: hint, + fixes: [ + { + type: 'create_file', + path: inferredPath, + description: + `Create the missing page at \`${inferredPath}\` (slug: \`${route ?? ''}\`). ` + + `Only apply if you intend to add this page — if the route was a typo at the call site, ` + + `fix the reference instead.`, + }, + ], + confidence: 0.6, + see_also: { + tool: 'domain_guide', + args: { domain: 'pages' }, + reason: + 'Pages domain guide explains slug/method semantics and the file-path → route mapping.', + }, + }; + }, + }, +]; + +/** + * Mirror of `parseMissingPageMessage` from page-route-index.ts — kept local + * to avoid creating a load-order dependency between the rule engine and the + * pipeline. Shape matches; behaviour identical for the messages we receive + * at this stage. Returns null when the message can't be parsed. + */ +function parseMissingPageMessage(message: string | undefined): ParsedRoute | null { + if (!message) return null; + const quoted = message.match(/['"`]([^'"`]+)['"`]/); + if (!quoted) return null; + let route = quoted[1].trim(); + while (route.startsWith('/')) route = route.slice(1); + if (route === 'index') route = ''; + if (route.endsWith('/index')) route = route.slice(0, -'/index'.length); + const methodMatch = message.match(/\(([A-Za-z]+)\)/); + const method = (methodMatch?.[1] ?? 'get').toLowerCase(); + return { route, method }; +} + +/** + * Enumerate every page slug the project graph knows. Prefers the explicit + * front-matter slug when present; falls back to deriving from the file path + * exactly the way the route index does. + */ +function pageRouteCandidates(graph: ProjectFactGraph | undefined): string[] { + if (!graph) return []; + const out: string[] = []; + for (const node of graph.nodesByType('page')) { + if (typeof node.slug === 'string' && node.slug.length > 0) { + out.push(normalize(node.slug)); + } else if (node.path) { + out.push(routeFromPath(node.path)); + } + } + return [...new Set(out)]; +} + +function normalize(raw: string): string { + let p = raw.trim(); + while (p.startsWith('/')) p = p.slice(1); + if (p === 'index') return ''; + if (p.endsWith('/index')) p = p.slice(0, -'/index'.length); + return p; +} + +function routeFromPath(absLikePath: string): string { + const stripped = absLikePath + .replace(/^app\/views\/pages\//, '') + .replace(/\.html\.liquid$/, '') + .replace(/\.liquid$/, ''); + return normalize(stripped); +} + +function routeToPagePath(route: string): string { + if (!route || route === '') return 'app/views/pages/index.liquid'; + return `app/views/pages/${route}.liquid`; +} diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MissingPartial.ts b/packages/platformos-mcp-supervisor/src/core/rules/MissingPartial.ts new file mode 100644 index 00000000..b1cf8d32 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MissingPartial.ts @@ -0,0 +1,401 @@ +/** + * MissingPartial rules — first check fully ported to rule engine. + * + * Priority order: + * 5 — invalid_lib_prefix: literal `lib/commands/` or `lib/queries/` prefix → text_edit + * 10 — module_path: module partials → guidance + module_info see_also + * 20 — file_exists: target exists on disk but LSP still flags → guidance + * 30 — suggest_nearest: did-you-mean via Levenshtein on reachable partials + * 40 — create_file: generate create_file fix with scaffold + * 1000 — default: catch-all that fires when none of the above guards matched + * (missing extractor params, unrecognised path shape, etc.). Without + * this rule the diagnostic would land as `MissingPartial.unmatched` and + * the agent would see the bare LSP message — every `.unmatched` row in + * the dashboard analytics. The default is intentionally guidance-only, + * confidence 0.5, so it never preempts a more specific rule. + */ +import type { Rule, RuleDiagnostic, RuleFacts, RuleFix } from './engine'; +import { classifyPath, nearestByLevenshtein, partialNames, partialsReachableFrom } from './queries'; +import { installedModules, moduleCallPathsByCategory, moduleInstalled } from './module-paths'; + +export const rules: Rule[] = [ + { + // `function` tag paths resolve relative to the partial search paths + // (`app/views/partials/`, `app/lib/`), not project root, so `lib/commands/X` + // expands to `app/lib/lib/commands/X` which never exists. Drop the prefix + // — `commands/X` and `queries/X` are the canonical forms. + id: 'MissingPartial.invalid_lib_prefix', + check: 'MissingPartial', + priority: 5, + when: (diag: RuleDiagnostic) => { + const name = diag.params?.partial; + return !!name && (name.startsWith('lib/commands/') || name.startsWith('lib/queries/')); + }, + apply: (diag: RuleDiagnostic) => { + const name = diag.params!.partial; + const corrected = name.slice('lib/'.length); + const category = name.startsWith('lib/commands/') ? 'command' : 'query'; + const hint = + `Drop the invalid \`lib/\` prefix from \`${name}\`. ` + + `\`function\` tag paths resolve from the partial search paths ` + + `(\`app/views/partials/\`, \`app/lib/\`) — a literal \`lib/\` prefix expands ` + + `to \`app/lib/lib/${corrected}\` which never exists. ` + + `Use \`${corrected}\` instead.`; + + const fix: RuleFix = buildLibPrefixTextEdit(diag, name, corrected) ?? { + type: 'guidance', + description: + `Drop the \`lib/\` prefix from the ${category} call: replace \`${name}\` with \`${corrected}\` ` + + `in the \`{% function %}\` tag on line ${diag.line ?? '?'}.`, + }; + + return { + rule_id: 'MissingPartial.invalid_lib_prefix', + hint_md: hint, + fixes: [fix], + confidence: 0.95, + }; + }, + }, + + { + id: 'MissingPartial.module_path', + check: 'MissingPartial', + priority: 10, + when: (diag: RuleDiagnostic) => { + const name = diag.params?.partial; + return !!name && name.startsWith('modules/'); + }, + apply: (diag: RuleDiagnostic, facts: RuleFacts) => { + const name = diag.params!.partial; + const parsed = parseModulePath(name); + const projectDir = facts?.projectDir ?? null; + + // 1. Module not installed → list known modules + Levenshtein. + if (parsed.moduleName && projectDir && !moduleInstalled(projectDir, parsed.moduleName)) { + const installed = installedModules(projectDir); + const nearest = nearestByLevenshtein(parsed.moduleName, installed, 3); + const list = + installed.length > 0 + ? `Installed modules: ${installed.map((m) => `\`${m}\``).join(', ')}.` + : `No modules are installed under \`modules/\`.`; + const didYouMean = nearest.length > 0 ? ` Did you mean \`${nearest[0].name}\`?` : ''; + return { + rule_id: 'MissingPartial.module_path', + hint_md: + `Module \`${parsed.moduleName}\` is not installed in this project. ${list}${didYouMean} ` + + `Module paths look like \`modules///\` — check the module name first.`, + fixes: [ + { + type: 'guidance', + description: + `Module \`${parsed.moduleName}\` not installed. Either install it (\`pos-cli modules install ${parsed.moduleName}\`), ` + + `pick a different module from the installed list, or move the call into a project-local file under \`app/lib/\`.`, + }, + ], + confidence: 0.9, + see_also: { + tool: 'project_map', + args: {}, + reason: `Module '${parsed.moduleName}' not installed. project_map enumerates installed modules and project-local commands/queries.`, + }, + }; + } + + // 2. Module installed → enumerate exports and reason about the bad path. + const moduleName = parsed.moduleName; + const exportsByCategory = + projectDir && moduleName ? moduleCallPathsByCategory(projectDir, moduleName) : null; + + const buildCheckSpecial = + parsed.category === 'commands' && (parsed.rest === 'build' || parsed.rest === 'check'); + + const allExports = exportsByCategory ? Object.values(exportsByCategory).flat() : []; + + // Levenshtein over every callable in the module, not just the + // (possibly mistyped) category — agents land in the wrong category + // bucket all the time (e.g. `commands/find_user` when the export is + // `queries/users/find`). + const nearest = allExports.length > 0 ? nearestByLevenshtein(name, allExports, 5) : []; + + const candidatesBlock = + nearest.length > 0 + ? nearest.map((n) => `\`${n.name}\``).join(', ') + : '(no close matches in this module)'; + + let lead; + if (buildCheckSpecial) { + // The original failure mode the report flagged: agents copy the + // `modules/core/commands/execute` shortcut, then assume `…/build` + // and `…/check` exist as siblings. They don't — build/check are + // inline phases of the *caller's* command, written next to + // execute.liquid in the agent's own `app/lib/commands//` + // tree. Only `execute` is exported by core for the simple-create + // shortcut. + lead = + `\`${name}\` does not exist. \`build\` and \`check\` are **inline phases of your own command**, ` + + `not module-level helpers — write them as \`build.liquid\` / \`check.liquid\` next to your \`execute.liquid\` ` + + `under \`app/lib/commands//\`. Only \`modules/${moduleName}/commands/execute\` is exported by core ` + + `(simple-create shortcut). For complex flows (multi-step orchestration, validation chains) ` + + `keep build/check inline.`; + } else { + lead = `\`${name}\` is not exported by module \`${moduleName}\`.`; + } + + const categorySummary = exportsByCategory + ? Object.entries(exportsByCategory) + .filter(([, paths]) => paths.length > 0) + .map(([cat, paths]) => `${cat} (${paths.length})`) + .join(', ') + : null; + + const hint = + `${lead}\n\n` + + `Closest matches in \`${moduleName}\`: ${candidatesBlock}.` + + (categorySummary ? `\nExported categories: ${categorySummary}.` : '') + + `\nCall \`module_info(${moduleName}, api)\` to read the full export list with @param signatures.`; + + const fixDescription = buildCheckSpecial + ? `Remove the \`{% function ... = '${name}', ... %}\` call and inline the build/check logic ` + + `directly in this file (or its sibling phase file). If you intended a different module helper, ` + + `replace the path with one of: ${ + nearest + .slice(0, 3) + .map((n) => `\`${n.name}\``) + .join(', ') || '(none)' + }. ` + + `Use \`module_info(${moduleName}, api)\` for the full list.` + : `Replace \`${name}\` with the closest valid export: ${ + nearest + .slice(0, 3) + .map((n) => `\`${n.name}\``) + .join(', ') || '(none)' + }, ` + + `or call \`module_info(${moduleName}, api)\` to see every callable path the module exposes.`; + + return { + rule_id: 'MissingPartial.module_path', + hint_md: hint, + fixes: [ + { + type: 'guidance', + description: fixDescription, + }, + ], + confidence: nearest.length > 0 ? 0.9 : 0.7, + see_also: { + tool: 'module_info', + args: { name: moduleName, section: 'api' }, + reason: `module_info(${moduleName}, api) returns live-scanned call paths and @param signatures for every export.`, + }, + }; + }, + }, + + { + id: 'MissingPartial.file_exists', + check: 'MissingPartial', + priority: 20, + when: (diag: RuleDiagnostic, facts: RuleFacts) => { + const name = diag.params?.partial; + if (!name) return false; + const { path } = classifyPath(name); + return path && facts.graph!.hasNode(path); + }, + apply: (diag: RuleDiagnostic) => { + const { path } = classifyPath(diag.params!.partial); + return { + rule_id: 'MissingPartial.file_exists', + hint_md: `File \`${path}\` exists but the linter still reports it as missing. Check that the file is not empty, has no syntax errors, and the path in the render/function tag matches exactly.`, + fixes: [ + { + type: 'guidance', + description: `File \`${path}\` exists on disk. Verify: (1) file is not empty, (2) no Liquid syntax errors inside it, (3) the render/function tag path matches exactly (case-sensitive).`, + }, + ], + confidence: 0.7, + }; + }, + }, + + { + id: 'MissingPartial.suggest_nearest', + check: 'MissingPartial', + priority: 30, + when: (diag: RuleDiagnostic, facts: RuleFacts) => { + const name = diag.params?.partial; + if (!name || name.startsWith('modules/')) return false; + const { type } = classifyPath(name); + if (type === 'module') return false; + const candidates = type === 'partial' ? partialNames(facts.graph!) : []; // commands/queries use exact paths + return candidates.length > 0; + }, + apply: (diag: RuleDiagnostic, facts: RuleFacts) => { + const name = diag.params!.partial; + const { type } = classifyPath(name); + + let candidates: string[]; + if (type === 'partial') { + // Prefer partials reachable from the caller's dependency tree + const reachable = diag.file ? partialsReachableFrom(facts.graph!, diag.file) : []; + candidates = reachable.length > 0 ? reachable : partialNames(facts.graph!); + } else { + candidates = []; + } + + const nearest = nearestByLevenshtein(name, candidates, 5); + if (nearest.length === 0) return null; + + const suggestions = nearest.map((n) => `\`${n.name}\` (distance: ${n.distance})`).join(', '); + const tag = type === 'partial' ? 'render' : 'function'; + + const bestMatch = nearest[0].name; + return { + rule_id: 'MissingPartial.suggest_nearest', + hint_md: `\`${name}\` not found. Did you mean: ${suggestions}? Fix the name in the \`{% ${tag} %}\` tag.`, + fixes: [ + { + type: 'guidance', + description: `Replace \`${name}\` with \`${bestMatch}\` in the \`{% ${tag} '${name}' %}\` tag.`, + }, + ], + confidence: 0.6, + }; + }, + }, + + { + id: 'MissingPartial.create_file', + check: 'MissingPartial', + priority: 40, + when: (diag: RuleDiagnostic) => { + const name = diag.params?.partial; + if (!name || name.startsWith('modules/')) return false; + const { path } = classifyPath(name); + return !!path; + }, + apply: (diag: RuleDiagnostic, facts: RuleFacts) => { + const name = diag.params!.partial; + const { type, path: targetPath } = classifyPath(name); + + // Constraint: path must not collide with existing node + if (!targetPath || facts.graph!.hasNode(targetPath)) return null; + + // Constraint: path follows convention + if (type === 'partial' && !targetPath.startsWith('app/views/partials/')) return null; + if (type === 'command' && !targetPath.startsWith('app/lib/commands/')) return null; + if (type === 'query' && !targetPath.startsWith('app/lib/queries/')) return null; + + return { + rule_id: 'MissingPartial.create_file', + hint_md: `Create missing file: \`${targetPath}\`. Use \`scaffold\` tool or create manually with appropriate \`{% doc %}\` block.`, + fixes: [ + { + type: 'create_file', + path: targetPath, + description: `Create missing ${type}: \`${targetPath}\``, + }, + ], + confidence: 0.8, + }; + }, + }, + + // Last-resort catch-all. Fires when none of the specialised guards above + // matched — typically because the LSP message did not parse into a + // `params.partial` (an upstream message-shape change), or because the path + // shape (`type` from classifyPath) did not fit any existing rule. Hint + // surfaces the three canonical resolutions with no false specifics, so the + // agent gets actionable guidance instead of `.unmatched` + bare LSP text. + { + id: 'MissingPartial.default', + check: 'MissingPartial', + priority: 1000, + when: () => true, + apply: (diag: RuleDiagnostic) => { + const name = diag.params?.partial ?? null; + const ref = name ? `\`${name}\`` : 'this reference'; + return { + rule_id: 'MissingPartial.default', + hint_md: + `${ref} does not resolve to any partial, command, or query in the project. ` + + `Three canonical resolutions:\n` + + ` • **Typo** — fix the path in the \`{% render %}\` / \`{% function %}\` tag.\n` + + ` • **Missing file** — create the target. Partials live under \`app/views/partials/\`, ` + + `commands under \`app/lib/commands/\`, queries under \`app/lib/queries/\`.\n` + + ` • **Wrong prefix** — \`function\` paths resolve from \`app/lib/\`, so \`lib/commands/X\` ` + + `expands to \`app/lib/lib/commands/X\` and never resolves. Drop the leading \`lib/\`.\n\n` + + `Run \`project_map\` to enumerate the partials, commands, and queries this project actually has.`, + fixes: [ + { + type: 'guidance', + description: name + ? `Verify the path \`${name}\` against \`project_map\` output, then either correct the typo, drop a leading \`lib/\` if present, or create the file at the canonical location.` + : `Run \`project_map\` to enumerate available partials, commands, and queries; reconcile the failing reference against the live list.`, + }, + ], + confidence: 0.5, + see_also: { + tool: 'project_map', + args: {}, + reason: + 'project_map lists every partial, command, and query the project serves — the authoritative source for resolving a missing reference.', + }, + }; + }, + }, +]; + +/** + * Build a `text_edit` fix that swaps a quoted partial reference for its + * `lib/`-stripped form. Returns null when the diagnostic lacks the position + * fields LSP normally provides (line/column/endColumn) — callers fall back + * to a guidance fix in that case. + * + * The replacement quotes with `'` (single-quote convention used throughout + * platformOS templates and our scaffolds). The rule engine has no access to + * the source buffer, so a perfect echo of the user's quote style can't be + * preserved here; `fix-generator.js` carries content and re-emits the fix + * with the correct quote when a buffer is available. + */ +function buildLibPrefixTextEdit( + diag: RuleDiagnostic, + name: string, + corrected: string, +): RuleFix | null { + if (diag.line == null || diag.column == null || diag.endColumn == null) return null; + return { + type: 'text_edit', + range: { + start: { line: diag.line, character: diag.column }, + end: { line: diag.endLine ?? diag.line, character: diag.endColumn }, + }, + new_text: `'${corrected}'`, + description: + `Drop invalid \`lib/\` prefix — function paths resolve from \`app/lib/\`. ` + + `Replace \`${name}\` with \`${corrected}\`.`, + }; +} + +/** + * Split `modules///` into its parts. The returned + * `category` is the literal first segment after the module name (callers + * decide whether it maps to a known module-export bucket); `rest` is the + * remainder joined with '/'. Returns nulls when the input doesn't fit the + * shape so callers can shortcut. + */ +export function parseModulePath(name: string | null | undefined): { + moduleName: string | null; + category: string | null; + rest: string | null; +} { + if (!name || !name.startsWith('modules/')) { + return { moduleName: null, category: null, rest: null }; + } + const parts = name.split('/'); + // parts[0] === 'modules' + const moduleName = parts[1] ?? null; + const category = parts[2] ?? null; + const rest = parts.length > 3 ? parts.slice(3).join('/') : null; + return { moduleName, category, rest }; +} diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MissingRenderPartialArguments.ts b/packages/platformos-mcp-supervisor/src/core/rules/MissingRenderPartialArguments.ts new file mode 100644 index 00000000..d59c6cfb --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MissingRenderPartialArguments.ts @@ -0,0 +1,87 @@ +/** + * MissingRenderPartialArguments rules — rich cross-file hints for missing params. + * + * Priority order: + * 10 — doc_block_mismatch: reads target partial's params, shows full expected signature + * 20 — chain_satisfied: param available in caller's own scope (received from grandparent) + * 30 — optional_param: param has a default → downgrade to info-level confidence + * 100 — generic: standard hint + */ +import type { Rule } from './engine'; +import { getPartialParams, isParamAvailableInCallerScope } from '../render-flow'; + +export const rules: Rule[] = [ + { + id: 'MissingRenderPartialArguments.doc_block_mismatch', + check: 'MissingRenderPartialArguments', + priority: 10, + when: (diag, facts) => { + const partialName = diag.params?.partial; + if (!partialName || !facts.graph) return false; + const params = getPartialParams(facts.graph, partialName); + return params.length > 0; + }, + apply: (diag, facts) => { + const params = diag.params!; + const partialName = params.partial; + const missingParam = params.missing_param ?? 'unknown'; + const declared = getPartialParams(facts.graph!, partialName); + const signature = declared.map((p) => `${p}: ${p}`).join(', '); + + return { + rule_id: 'MissingRenderPartialArguments.doc_block_mismatch', + hint_md: `Required param \`${missingParam}\` is not passed to \`${partialName}\`.\n\nFull signature: \`{% render '${partialName}', ${signature} %}\`\n\nDeclared params: ${declared.map((p) => `\`${p}\``).join(', ')}. Add the missing argument to the render/function call.`, + suggestion: `Add \`, ${missingParam}: ${missingParam}\` to the render call.`, + fixes: [], + confidence: 0.9, + see_also: { + tool: 'domain_guide', + args: { domain: 'partials', section: 'api' }, + reason: `Render call missing required param. domain_guide(partials, api) explains {% doc %} @param declarations.`, + }, + }; + }, + }, + + { + id: 'MissingRenderPartialArguments.chain_satisfied', + check: 'MissingRenderPartialArguments', + priority: 20, + when: (diag, facts) => { + const missingParam = diag.params?.missing_param; + if (!missingParam || !diag.file || !facts.graph) return false; + return isParamAvailableInCallerScope(facts.graph, diag.file, missingParam); + }, + apply: (diag) => { + const params = diag.params!; + const partialName = params.partial ?? 'unknown'; + const missingParam = params.missing_param; + + return { + rule_id: 'MissingRenderPartialArguments.chain_satisfied', + hint_md: `Param \`${missingParam}\` is not passed to \`${partialName}\`, but this file declares \`${missingParam}\` as its own param (received from a caller). Add \`${missingParam}: ${missingParam}\` to forward it.`, + suggestion: `Forward the param: add \`, ${missingParam}: ${missingParam}\` to the render call.`, + fixes: [], + confidence: 0.85, + }; + }, + }, + + { + id: 'MissingRenderPartialArguments.generic', + check: 'MissingRenderPartialArguments', + priority: 100, + when: () => true, + apply: (diag) => { + const partialName = diag.params?.partial ?? 'unknown'; + const missingParam = diag.params?.missing_param ?? 'unknown'; + + return { + rule_id: 'MissingRenderPartialArguments.generic', + hint_md: `Required param \`${missingParam}\` is not passed to \`${partialName}\`. Open the partial's \`{% doc %}\` block to see the full signature, then add the missing argument to the render/function call.`, + fixes: [], + confidence: 0.5, + }; + }, + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/MissingSlug.ts b/packages/platformos-mcp-supervisor/src/core/rules/MissingSlug.ts new file mode 100644 index 00000000..d7836d52 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/MissingSlug.ts @@ -0,0 +1,46 @@ +/** + * pos-supervisor:MissingSlug rule — pages without a `slug:` in their front + * matter. Pre-rule the check landed as `.unmatched` even though the + * fix-generator already produces an `insert` text_edit + * (`fixMissingSlugInsert`) that prefills a sensible slug from the file + * path. This rule promotes the check to a stable rule_id and ships a + * `guidance` fix that explains *why* the slug matters — the heuristic's + * literal text_edit remains the actionable diff. + */ + +import type { Rule } from './engine'; + +export const rules: Rule[] = [ + { + id: 'MissingSlug.default', + check: 'pos-supervisor:MissingSlug', + priority: 100, + when: () => true, + apply: () => ({ + rule_id: 'MissingSlug.default', + hint_md: + 'Page is missing `slug:` in its front matter. Without an explicit slug the platform falls back to ' + + 'a path derived from the filename — fine for one-off pages, but unstable when the file is renamed ' + + 'or moved. Set `slug:` explicitly so URLs are owned by the page, not the filesystem.\n\n' + + 'Conventions:\n' + + ' • Use kebab-case and avoid the file extension (`slug: contact`, not `slug: contact.liquid`).\n' + + ' • Use `:param` for dynamic segments (`slug: posts/:id`), not `[param]` (Next.js style).\n' + + ' • No leading slash — `slug: foo`, not `slug: /foo`.', + fixes: [ + { + type: 'guidance', + description: + 'Add `slug: ` between the front matter `---` markers. ' + + 'The heuristic fix-generator proposes a slug derived from the filename — accept it ' + + 'unless the public URL should diverge from the path.', + }, + ], + confidence: 0.85, + see_also: { + tool: 'domain_guide', + args: { domain: 'pages' }, + reason: 'Pages domain guide describes slug conventions and how dynamic segments resolve.', + }, + }), + }, +]; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/NonGetRenderingPage.ts b/packages/platformos-mcp-supervisor/src/core/rules/NonGetRenderingPage.ts new file mode 100644 index 00000000..522a32e5 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/NonGetRenderingPage.ts @@ -0,0 +1,196 @@ +/** + * NonGetRenderingPage rules — three distinct misconfigurations of page + * `method:` / `format:` / form `action` per the 2026-04-27 gist analysis + * (NonGetRenderingPageRule.md). The structural emitter + * (`validatePageMethodAndForms` in structural-warnings.js) is the only + * producer of `pos-supervisor:NonGetRenderingPage` and tags each emit with + * a leading-clause discriminator the rule layer routes by. + * + * Subrule analytics IDs: + * • NonGetRenderingPage.api_renders_html — API-pathed page (`/api/`, + * `/_/`, `/internal/`) is non-GET but emits HTML or omits `format: json`. + * • NonGetRenderingPage.html_on_post — non-API page is non-GET but + * renders HTML; browser GETs return 404. + * • NonGetRenderingPage.get_form_target — GET page hosts a + * `
` whose action is not under an + * internal-API prefix and is not the page's own slug. + * • NonGetRenderingPage.default — fallback for diagnostics that + * don't match any subrule discriminator (defensive — should not fire + * in practice once the emitter is in sync with this router). + * + * Each subrule emits a concrete `guidance` fix that names the right shape + * to converge on. No `text_edit` here: the deterministic fix requires + * cross-file changes (edit page front matter AND add an API endpoint AND + * possibly rewrite the form attribute) which the rule layer can't compose + * safely. Agents accept the guidance, then validate iteratively. + */ + +import type { Rule, RuleDiagnostic, RuleFacts } from './engine'; + +const API_RENDERS_HTML_RE = /^API page \(slug `([^`]+)`\) has `method: (\w+)`/; +const HTML_ON_POST_RE = /^Page has `method: (\w+)` but renders HTML/; +const GET_FORM_TARGET_RE = /^Form on GET page posts to `([^`]+)`/; + +export const rules: Rule[] = [ + { + id: 'NonGetRenderingPage.api_renders_html', + check: 'pos-supervisor:NonGetRenderingPage', + priority: 5, + when: (diag: RuleDiagnostic, _facts: RuleFacts) => API_RENDERS_HTML_RE.test(diag.message ?? ''), + apply: (diag: RuleDiagnostic, _facts: RuleFacts) => { + const m = (diag.message ?? '').match(API_RENDERS_HTML_RE); + const slug = m?.[1] ?? ''; + const method = m?.[2] ?? 'post'; + return { + rule_id: 'NonGetRenderingPage.api_renders_html', + hint_md: + `API page \`${slug}\` is set to \`method: ${method}\` but is configured to render HTML — ` + + `either it carries a \`layout:\` / inline HTML, or it is missing \`format: json\`. ` + + `Pages under \`/api/\`, \`/_/\`, or \`/internal/\` must respond with JSON; rendering HTML to ` + + `a JSON-expecting client is a silent contract break.\n\n` + + `Canonical shape:\n` + + '```liquid\n' + + `---\n` + + `slug: ${slug.replace(/^\//, '')}\n` + + `method: ${method}\n` + + `format: json\n` + + `---\n` + + `{% graphql result = 'mutation_path', ...args %}\n` + + `{{ result | json }}\n` + + '```', + fixes: [ + { + type: 'guidance', + description: + `Add \`format: json\` to the front matter, drop any \`layout:\` line, and replace the body ` + + `with a \`{% graphql %}\` call followed by \`{{ result | json }}\`. ` + + `Keep \`method: ${method}\` so the verb still matches the form / fetch caller.`, + }, + ], + confidence: 0.9, + see_also: { + tool: 'domain_guide', + args: { domain: 'api-calls' }, + reason: + 'API endpoint conventions in platformOS — JSON format, GraphQL bodies, no layout.', + }, + }; + }, + }, + + { + id: 'NonGetRenderingPage.html_on_post', + check: 'pos-supervisor:NonGetRenderingPage', + priority: 10, + when: (diag: RuleDiagnostic, _facts: RuleFacts) => HTML_ON_POST_RE.test(diag.message ?? ''), + apply: (diag: RuleDiagnostic, _facts: RuleFacts) => { + const m = (diag.message ?? '').match(HTML_ON_POST_RE); + const method = m?.[1] ?? 'post'; + return { + rule_id: 'NonGetRenderingPage.html_on_post', + hint_md: + `Page renders HTML but is set to \`method: ${method}\`. Browsers always issue GET — ` + + `every navigation to this URL will 404. Two valid shapes:\n\n` + + `**Landing / display page** — drop the \`method:\` field (or set \`method: get\`); have any ` + + `embedded form POST to a separate API endpoint:\n` + + '```liquid\n' + + `---\nslug: contact\n---\n` + + `\n \n\n` + + '```\n' + + `**Form-handling endpoint** — rename the slug under \`/api/\` and switch to JSON output:\n` + + '```liquid\n' + + `---\nslug: api/contacts/create\nmethod: ${method}\nformat: json\n---\n` + + `{% graphql result = 'contacts/create', ...context.params.contact %}\n` + + `{{ result | json }}\n` + + '```', + fixes: [ + { + type: 'guidance', + description: + `Decide intent: (a) **landing page** — remove \`method: ${method}\` from front matter; the ` + + `form on this page should action to an \`/api/...\` slug. (b) **API handler** — move the slug ` + + `under \`/api/\`, add \`format: json\`, replace the HTML body with a \`{% graphql %}\` call.`, + }, + ], + confidence: 0.9, + see_also: { + tool: 'domain_guide', + args: { domain: 'pages' }, + reason: + 'Page method semantics — GET serves browsers, non-GET handles form / fetch payloads.', + }, + }; + }, + }, + + { + id: 'NonGetRenderingPage.get_form_target', + check: 'pos-supervisor:NonGetRenderingPage', + priority: 15, + when: (diag: RuleDiagnostic, _facts: RuleFacts) => GET_FORM_TARGET_RE.test(diag.message ?? ''), + apply: (diag: RuleDiagnostic, _facts: RuleFacts) => { + const m = (diag.message ?? '').match(GET_FORM_TARGET_RE); + const action = m?.[1] ?? ''; + const stripped = action.replace(/^\/+/, ''); + const apiAction = `/api/${stripped}`; + const apiPagePath = `app/views/pages/api/${stripped}.liquid`; + return { + rule_id: 'NonGetRenderingPage.get_form_target', + hint_md: + `\`
\` on this GET page posts to \`${action}\`. That action target is not under an ` + + `internal-API prefix (\`/api/\`, \`/_/\`, \`/internal/\`) and isn't the page's own slug, ` + + `so the submission has nowhere valid to land — unless an explicit \`method: post\` page already ` + + `serves \`${action}\`. The canonical fix is to route the form through an API page:\n\n` + + `1. Update the form action: \`\`.\n` + + `2. Create the API page at \`${apiPagePath}\` with \`method: post\`, \`format: json\`, and a ` + + `\`{% graphql %}\` body.`, + fixes: [ + { + type: 'guidance', + description: + `Change the form action from \`${action}\` to \`${apiAction}\` and create ` + + `\`${apiPagePath}\` as a \`method: post\` / \`format: json\` page. ` + + `Alternative (only if you control \`${action}\` already): verify that \`${action}\` is served ` + + `by a page with \`method: post\` — if not, NonGetRenderingPage.html_on_post will fire there.`, + }, + ], + confidence: 0.85, + see_also: { + tool: 'domain_guide', + args: { domain: 'forms' }, + reason: + 'Form submission patterns — actions must hit a page with the matching `method:` verb.', + }, + }; + }, + }, + + { + id: 'NonGetRenderingPage.default', + check: 'pos-supervisor:NonGetRenderingPage', + priority: 100, + when: () => true, + apply: (_diag: RuleDiagnostic, _facts: RuleFacts) => ({ + rule_id: 'NonGetRenderingPage.default', + hint_md: + `Page method or form-target configuration is off. Read the upstream message for specifics — ` + + `the canonical platformOS shapes are:\n` + + ` • UI page → \`method: get\` (or omit), HTML body, layout allowed.\n` + + ` • API endpoint → slug under \`/api/\`, \`method: post\`/etc., \`format: json\`, no layout, ` + + `body is \`{% graphql %}\` + \`{{ result | json }}\`.\n` + + ` • Forms on GET pages → \`action="/api/"\` so the POST lands on the API page.`, + fixes: [ + { + type: 'guidance', + description: + `Decide whether this page is a UI page (GET, HTML) or an API page (non-GET, JSON). ` + + `Convert front matter and body to match — see \`domain_guide(pages)\` for the canonical layouts.`, + }, + ], + confidence: 0.6, + }), + }, +]; + +// Re-exported for tests + diagnostic-pipeline introspection. +export const _internal = { API_RENDERS_HTML_RE, HTML_ON_POST_RE, GET_FORM_TARGET_RE }; diff --git a/packages/platformos-mcp-supervisor/src/core/rules/OrphanedPartial.ts b/packages/platformos-mcp-supervisor/src/core/rules/OrphanedPartial.ts new file mode 100644 index 00000000..b945227b --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/OrphanedPartial.ts @@ -0,0 +1,130 @@ +/** + * OrphanedPartial rule — partial files that aren't rendered by anything + * else in the project graph. + * + * Pre-rule the check landed as `.unmatched`. The diagnostic-pipeline already + * suppresses commands/queries (invoked via `function`, not `render`) and + * pending-plan files via `suppressOrphanedPartial` upstream of this rule, so + * by the time we see one it's a real orphan in `app/views/partials/`. + * + * Two distinct intents → two recommendations: + * • The file is dead code → delete it (high-confidence delete_file fix when + * `referencedBy(file)` is empty in the graph). + * • The file is mid-development and the caller hasn't been written yet → + * pass `pending_files=[…callerPath]` to validate_code, or just write the + * caller now. The hint surfaces this option so agents don't blindly + * delete in-progress work. + * + * No fix when `diag.file` is missing — without the path we can't tell which + * file to act on. + */ + +import type { Rule, RuleFix } from './engine'; +import { dependentsOf, classifyFileType } from './queries'; + +export const rules: Rule[] = [ + { + id: 'OrphanedPartial.default', + check: 'OrphanedPartial', + priority: 100, + when: () => true, + apply: (diag, facts) => { + const file = diag.file ?? null; + const fileType = file ? classifyFileType(file) : 'unknown'; + const callers = file && facts?.graph ? dependentsOf(facts.graph, file) : []; + const callerCount = callers.length; + const filenameSpan = file ? `\`${file}\`` : 'this partial'; + + // The diagnostic-pipeline's `suppressOrphanedPartial` already drops + // commands/queries — by the time we see one, it should be a real + // partial. Belt-and-suspenders: if a non-partial slips through, give + // a softer "verify caller graph" message instead of a delete proposal. + const isPartial = fileType === 'partial'; + const isLayout = fileType === 'layout'; + + const fixes: RuleFix[] = []; + if (isPartial && callerCount === 0) { + fixes.push({ + type: 'delete_file', + path: file, + description: + `Delete ${filenameSpan} — no other file in the project renders it. ` + + `Re-run validate_code first if you're mid-feature; pass ` + + `\`pending_files=["${file}"]\` (or the caller's path) to suppress this warning while you write the caller.`, + }); + } + fixes.push({ + type: 'guidance', + description: orphanGuidance(filenameSpan, isPartial, isLayout), + }); + + return { + rule_id: 'OrphanedPartial.default', + hint_md: orphanHint(filenameSpan, callerCount, isPartial, isLayout), + fixes, + confidence: isPartial && callerCount === 0 ? 0.85 : 0.6, + }; + }, + }, +]; + +function orphanHint( + filenameSpan: string, + callerCount: number, + isPartial: boolean, + isLayout: boolean, +): string { + if (isLayout) { + return ( + `${filenameSpan} is a layout with no pages selecting it via \`layout: \`. ` + + `Either select it from a page (\`---\\nlayout: ${filenameSpan + .replace(/`/g, '') + .replace(/.*\//, '') + .replace(/\.liquid$/, '')}\\n---\`) ` + + `or delete the layout if it's no longer needed. Pages without a \`layout:\` key ` + + `default to \`application.liquid\`.` + ); + } + if (!isPartial) { + return ( + `${filenameSpan} appears to be unreferenced, but the file isn't a regular partial — ` + + `verify caller graph manually with \`platformos_references\` before deleting. ` + + `Commands and queries are invoked via \`{% function %}\` and may not always show up as ` + + `dependencies in the rendering graph.` + ); + } + const callerNote = + callerCount === 0 + ? 'No file in the project renders or includes it.' + : `Found ${callerCount} caller(s) outside the standard render graph — verify with \`platformos_references\`.`; + return ( + `${filenameSpan} is an orphaned partial. ${callerNote}\n\n` + + `Two valid resolutions:\n` + + ` • **Dead code** — delete the file. Use the \`delete_file\` fix if you're certain nothing renders it.\n` + + ` • **Work in progress** — the caller hasn't been written yet. Either write the caller now ` + + `(then re-validate, the warning clears), or pass \`pending_files=[""]\` to ` + + `validate_code so the orphan is suppressed during the multi-file plan.\n\n` + + `Renaming the file (e.g. via scaffold output) is the third common cause — re-run ` + + `\`project_map\` to confirm callers point at the current name.` + ); +} + +function orphanGuidance(filenameSpan: string, isPartial: boolean, isLayout: boolean): string { + if (isLayout) { + return ( + `Either set \`layout: \` in a page's front matter to use ${filenameSpan}, or delete the file ` + + `if it's no longer needed. Pages without an explicit \`layout:\` use \`application.liquid\`.` + ); + } + if (!isPartial) { + return ( + `Run \`platformos_references\` to enumerate every file that references ${filenameSpan}. ` + + `Commands/queries invoked via \`{% function %}\` may be missed by the render-graph orphan check.` + ); + } + return ( + `Decide: (a) delete ${filenameSpan} if it's dead, (b) write the calling page/partial if work is in progress, ` + + `or (c) pass \`pending_files=[""]\` to validate_code while you scaffold the caller. ` + + `Run \`platformos_references\` to confirm no rendering graph entry points at it.` + ); +} diff --git a/packages/platformos-mcp-supervisor/src/core/rules/ParserBlockingScript.ts b/packages/platformos-mcp-supervisor/src/core/rules/ParserBlockingScript.ts new file mode 100644 index 00000000..6bb254f9 --- /dev/null +++ b/packages/platformos-mcp-supervisor/src/core/rules/ParserBlockingScript.ts @@ -0,0 +1,49 @@ +/** + * ParserBlockingScript rule — `` placed at the very end of `` — the legacy workaround. ' + + 'Prefer `defer` for new code.\n\n' + + 'Inline scripts (`` with no `src`) are unaffected by this check.', + fixes: [ + { + type: 'guidance', + description: + 'Add `defer` to the opening `" }, + "stylesheet_tag": { "replacement": null, "note": "Use standard HTML: " }, + "link_to": { "replacement": null, "note": "Use standard HTML tag" }, + "camelize": { "replacement": null, "note": "Not in platformOS" }, + "handleize": { "replacement": null, "note": "Not in platformOS — use downcase + replace for slug-like strings" }, + "hex_to_rgba": { "replacement": null, "note": "Use CSS variables or calc()" }, + "color_to_rgb": { "replacement": null, "note": "Use CSS color-mix() or CSS variables" }, + "color_to_hsl": { "replacement": null, "note": "Use CSS color-mix() or CSS variables" }, + "color_darken": { "replacement": null, "note": "Use CSS color-mix()" }, + "color_lighten": { "replacement": null, "note": "Use CSS color-mix()" }, + "color_saturate": { "replacement": null, "note": "Use CSS variables" }, + "color_desaturate": { "replacement": null, "note": "Use CSS variables" }, + "color_mix": { "replacement": null, "note": "Use CSS color-mix()" }, + "color_contrast": { "replacement": null, "note": "Use CSS variables" }, + "color_modify": { "replacement": null, "note": "Use CSS variables" }, + "format_address": { "replacement": null, "note": "Template the address fields manually" }, + "weight_with_unit": { "replacement": null, "note": "Format manually" }, + "md5": { "replacement": null, "note": "Not in platformOS" }, + "sha1": { "replacement": null, "note": "Not in platformOS" }, + "hmac_sha1": { "replacement": null, "note": "Not in platformOS" }, + "product_img_url": { "replacement": "asset_url" }, + "collection_img_url": { "replacement": "asset_url" }, + "article_img_url": { "replacement": "asset_url" }, + "default_pagination": { "replacement": null, "note": "Use modules/common-styling/pagination partial" }, + "payment_type_img_url": { "replacement": null, "note": "Not in platformOS" }, + "payment_type_svg_tag": { "replacement": null, "note": "Not in platformOS" }, + "metafield_tag": { "replacement": null, "note": "Use property() accessor in GraphQL" }, + "metafield_text": { "replacement": null, "note": "Use property() accessor in GraphQL" }, + "placeholder_svg_tag": { "replacement": null, "note": "Not in platformOS" }, + "customer_login_link": { "replacement": null, "note": "Use standard link" }, + "font_face": { "replacement": null, "note": "Use CSS @font-face" }, + "font_modify": { "replacement": null, "note": "Use CSS variables" }, + "font_url": { "replacement": "asset_url", "note": "Use asset_url for font files" }, + "external_video_tag": { "replacement": null, "note": "Use standard HTML