Sfoglia il codice sorgente

feat(impact): resolve Shopify OS 2.0 JSON template → section references

Shopify OS 2.0 templates are JSON (`templates/*.json`, plus section groups
`sections/*.json`) that reference sections by `"type"`, NOT by a `{% section %}`
Liquid tag — so a section used only from a JSON template looked unused (the JSON
wasn't even indexed). Index Shopify JSON templates/section-groups (path-detected,
incl. nested `templates/customers/*.json`) through the Liquid extractor and emit a
`references` edge from each `sections[].type` to `sections/<type>.liquid` (file
node only, no symbols, so the JSON never enters a symbol-bearing-file metric).

Dawn 39.1% -> 73.8% (sections now covered; residual = preset/theme-editor sections
with no static reference + dynamic `{% render %}`). false edges 0. Regression test
(top-level + nested JSON template) fails without the fix; suite green (1186).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 settimane fa
parent
commit
2f57119
4 ha cambiato i file con 103 aggiunte e 11 eliminazioni
  1. 1 0
      CHANGELOG.md
  2. 38 0
      __tests__/extraction.test.ts
  3. 15 0
      src/extraction/grammars.ts
  4. 49 11
      src/extraction/liquid-extractor.ts

+ 1 - 0
CHANGELOG.md

@@ -31,6 +31,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - SvelteKit pages now connect to their server `load` functions. SvelteKit wires a `+page.server.js` / `+page.js` `load` (and form `actions`) to the sibling `+page.svelte`'s `data` by file path, with no import between them — so editing a `load` previously showed no impact on the page it feeds. Each page is now linked to the `load`/`actions` in its own route directory (and likewise for `+layout`), so editing a loader surfaces the page that renders its data, and tracing a page reaches its server-side data source. (Svelte, SvelteKit)
 - Nuxt nested components are now connected to where they're used. Nuxt auto-imports a component in a subdirectory by a directory-prefixed name — `components/media/Card.vue` is used in templates as `<MediaCard/>` — but it was tracked by its file name (`Card`), so the usage didn't resolve and the component looked unused. PascalCase component tags (`<MediaCard>`, `<NavBar>`) in a `.vue` template are now matched, falling back to the Nuxt directory-prefixed name, so editing a nested component surfaces every page and component that renders it. (Vue, Nuxt)
 - Lua and Luau `require` calls now connect to their module files. A dotted module path (`require("telescope.config")` → `telescope/config.lua` or `.../config/init.lua`) and a Roblox/Luau instance-path require (`require(script.Parent.Signal)` → the `Signal` module) now link to the file they load, so editing a module surfaces every file that requires it. Previously requires resolved to nothing, so a Lua/Luau module looked like it had no dependents. (Lua, Luau)
+- Shopify OS 2.0 sections now connect to the JSON templates that use them. Modern Shopify themes define templates as JSON (`templates/*.json`, plus section groups `sections/*.json`) that list sections by `type` rather than with a `{% section %}` Liquid tag, so a section used only from a JSON template was reported as having no dependents. Those JSON files are now read and each section `type` is linked to its `sections/<type>.liquid`, so editing a section surfaces the templates that render it. (Liquid, Shopify)
 - Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
 - Swift Fluent relationship models are no longer orphaned. A type referenced only through a property-wrapper *argument* — `@Siblings(through: AcronymCategoryPivot.self, …)`, the many-to-many pivot/join model — now records a dependency on that type. Previously only the wrapper itself (`Siblings`) and the property's declared type were captured, so a pivot model reached solely through the relationship looked like nothing depended on it and editing it surfaced no impact. (Swift, Vapor/Fluent)
 - Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.

+ 38 - 0
__tests__/extraction.test.ts

@@ -4210,6 +4210,44 @@ describe('Same-directory include + KMP import resolution', () => {
   });
 });
 
+describe('Liquid Shopify JSON template section resolution', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('links a Shopify JSON template section `type` to its sections/<type>.liquid', async () => {
+    // Shopify OS 2.0 templates are JSON, referencing sections by `type` — not
+    // a `{% section %}` Liquid tag — so a section used only from a JSON template
+    // looked unused. The JSON is now indexed and its `type`s linked.
+    fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true });
+    fs.mkdirSync(path.join(tempDir, 'templates/customers'), { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `<div>{{ product.title }}</div>\n`);
+    fs.writeFileSync(path.join(tempDir, 'sections/main-login.liquid'), `<form>{{ 'customer.login' | t }}</form>\n`);
+    fs.writeFileSync(path.join(tempDir, 'templates/product.json'), JSON.stringify({ sections: { main: { type: 'main-product' } }, order: ['main'] }));
+    // Nested template dir (templates/customers/login.json) must resolve too.
+    fs.writeFileSync(path.join(tempDir, 'templates/customers/login.json'), JSON.stringify({ sections: { main: { type: 'main-login' } }, order: ['main'] }));
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const product = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-product.liquid'));
+    const login = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/main-login.liquid'));
+    expect(product, 'main-product section').toBeDefined();
+    expect(login, 'main-login section').toBeDefined();
+    expect(cg.getFileDependents(product!.filePath).some((p) => p.endsWith('templates/product.json')), 'top-level JSON template links its section').toBe(true);
+    expect(cg.getFileDependents(login!.filePath).some((p) => p.endsWith('customers/login.json')), 'nested JSON template links its section').toBe(true);
+  });
+});
+
 describe('Lua/Luau require resolution', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 15 - 0
src/extraction/grammars.ts

@@ -121,11 +121,23 @@ export const EXTENSION_MAP: Record<string, Language> = {
  */
 export function isSourceFile(filePath: string): boolean {
   if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
+  if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups
   const dot = filePath.lastIndexOf('.');
   if (dot < 0) return false;
   return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
 }
 
+/**
+ * Shopify OS 2.0 JSON template (`templates/*.json`) or section group
+ * (`sections/*.json`) — these reference sections by `"type"`, so the Liquid
+ * extractor links them. (config/ + locales/ JSON have no section refs.)
+ */
+export function isShopifyLiquidJson(filePath: string): boolean {
+  // Allow nested template dirs (`templates/customers/login.json`), not just
+  // top-level (`templates/product.json`).
+  return /(^|\/)(templates|sections)\/.+\.json$/i.test(filePath);
+}
+
 /**
  * Play Framework routes file: the extensionless `conf/routes` (and included
  * `conf/*.routes`). No grammar — route extraction is done by the Play framework
@@ -246,6 +258,9 @@ export function detectLanguage(filePath: string, source?: string): Language {
   // Play framework resolver extracts route nodes from it.
   if (isPlayRoutesFile(filePath)) return 'yaml';
   const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
+  // Shopify OS 2.0 JSON templates / section groups → the Liquid extractor (it
+  // links each section `"type"` to its `sections/<type>.liquid`).
+  if (isShopifyLiquidJson(filePath)) return 'liquid';
   const lang = EXTENSION_MAP[ext] || 'unknown';
 
   // .h files could be C, C++, or Objective-C — check source content

+ 49 - 11
src/extraction/liquid-extractor.ts

@@ -33,17 +33,25 @@ export class LiquidExtractor {
       // Create file node
       const fileNode = this.createFileNode();
 
-      // Extract render/include statements (snippet references)
-      this.extractSnippetReferences(fileNode.id);
-
-      // Extract section references
-      this.extractSectionReferences(fileNode.id);
-
-      // Extract schema block
-      this.extractSchema(fileNode.id);
-
-      // Extract assign statements as variables
-      this.extractAssignments(fileNode.id);
+      // Shopify OS 2.0 JSON template / section group: link each section `type`
+      // to its `sections/<type>.liquid` file. (No symbol nodes are emitted — the
+      // JSON file just carries the references — so it stays out of any
+      // symbol-bearing-file metric while its sections still get their dependents.)
+      if (this.filePath.endsWith('.json')) {
+        this.extractShopifyJsonSections(fileNode.id);
+      } else {
+        // Extract render/include statements (snippet references)
+        this.extractSnippetReferences(fileNode.id);
+
+        // Extract section references
+        this.extractSectionReferences(fileNode.id);
+
+        // Extract schema block
+        this.extractSchema(fileNode.id);
+
+        // Extract assign statements as variables
+        this.extractAssignments(fileNode.id);
+      }
     } catch (error) {
       this.errors.push({
         message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
@@ -86,6 +94,36 @@ export class LiquidExtractor {
     return fileNode;
   }
 
+  /**
+   * Shopify OS 2.0 JSON template / section group. Both have a `sections` object
+   * mapping an id → `{ "type": "<section-name>", ... }`; the `type` names a
+   * `sections/<type>.liquid` file. Emit a `references` edge to each, so a section
+   * used only from a JSON template (the OS 2.0 norm) is no longer orphaned.
+   */
+  private extractShopifyJsonSections(fromNodeId: string): void {
+    let parsed: unknown;
+    try {
+      parsed = JSON.parse(this.source);
+    } catch {
+      return; // not valid JSON (or a partial) — nothing to link
+    }
+    const sections = (parsed as { sections?: Record<string, { type?: unknown }> })?.sections;
+    if (!sections || typeof sections !== 'object') return;
+    const seen = new Set<string>();
+    for (const key of Object.keys(sections)) {
+      const type = sections[key]?.type;
+      if (typeof type !== 'string' || seen.has(type)) continue;
+      seen.add(type);
+      this.unresolvedReferences.push({
+        fromNodeId,
+        referenceName: `sections/${type}.liquid`,
+        referenceKind: 'references',
+        line: 1,
+        column: 0,
+      });
+    }
+  }
+
   /**
    * Extract {% render 'snippet' %} and {% include 'snippet' %} references
    */