From a54f39eb97bb099492d1095e78764167bb92414d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 12:02:44 -0400 Subject: [PATCH 1/2] feat(artifacts): view templates get the view-template role (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template files landed in `application.artifacts{}` with `roles: ["unknown"]`, so "what are this application's views?" had to be re-derived by the consumer from extensions the analyzer had already inspected. Adds rules rows for `.ejs`, `.hbs`/`.handlebars`, `.pug`, `.njk`, `.liquid`, `.vue`, `.svelte`, `.astro`, and for `.html`/`.htm` under a `views/` or `templates/` directory. The role name `view-template` is codeanalyzer-java's, from its JSP/JSF/Thymeleaf rows, adopted verbatim under the parity clause — along with its policy that a bare `*.html` stays `unknown`, since a static page and a rendered template are not distinguishable by name. No contract move: `roles[]` is an existing string[] gaining new values. `schema.neo4j.json` byte-identical, SCHEMA_VERSION unmoved. --- .claude/SCHEMA_DECISIONS.md | 14 +++++ src/artifacts/rules.ts | 27 +++++++++ test/artifact-view-templates.test.ts | 83 ++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 test/artifact-view-templates.test.ts diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index 1d08e43..1ceae7a 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -216,3 +216,17 @@ Spec: `docs/design/specs/declaration-merging-ids.md`. Analyzer 1.5.0; Neo4j cont | D1 | **Value facet keeps the bare id** | a type whose bare id is already minted by a callable/field of the same scope becomes `#type` (`src/schema/assignIds.ts`) | callables anchor call edges, body-node ids, `@formal_in:N`, `parameters[i].id`; a type's id is referenced only through `extends_ids`/`implements_ids`, resolved by the analyzer's own map | | D2 | **Type/type merging** | first type facet (builder order class → interface → enum → alias → namespace) keeps the bare key and id; each later facet is keyed `Name#` in `types{}` (`builders.ts::putType`) and gets `#` | both facets must survive — "later kind wins" dropped one from JSON entirely; the kind suffix is self-describing where `#type` would not tell two types apart | | D3 | **Only on collision; signatures unchanged** | suffixes appear only when the bare id is already used this run; `signature` stays the dotted name on every facet; `idBySig` keeps the value facet, `typeIdBySig` the first type facet, heritage resolves through the union with the type winning; a split is not an L1 collision | a merge-free project is byte-identical; the resolver computes signatures from the AST (`new X()` → `X.constructor`), so a signature change would break call resolution for every merged class | + +## View-template artifact roles: `view-template` ADOPTED, not coined (2026-09-11, #208) + +`src/artifacts/rules.ts`; analyzer 1.6.2. No contract move — `roles[]` is an existing `string[]` +and this adds new *values*, the same class as a new `framework` value in the entrypoint pass. +`schema.neo4j.json` byte-identical, `SCHEMA_VERSION` unmoved. + +| # | Concept | Decision | Rationale | +|---|---|---|---| +| D1 | **The role name comes from java** | `view-template`, taken verbatim from `codeanalyzer-java`'s `ArtifactDiscovery.java` (its JSP/JSPX/JSPF/tag/xhtml/Thymeleaf rows, its spec dated 2026-09-11 D1). TS coins nothing here; python adopts the same string when it grows a template row | the parity clause: a term coined twice is permanently wrong. Java shipped the name first, so this is an adoption decision, not a naming decision — checking before merging is what kept `view-template` from colliding with a TS-local `template` | +| D2 | **One `format` per template language** | `ejs`, `handlebars` (`.hbs` + `.handlebars`), `pug`, `nunjucks` (`.njk`), `liquid`, `vue`, `svelte`, `astro`, and `html` for the convention-directory rows | mirrors java's per-family `jsp`/`xhtml`/`html`. `format` only dispatches `extractConfigKeys`/`deploymentEnvKeys`, whose `switch` default is `[]` — a new value is inert until someone writes a parser for it | +| D3 | **`.html`/`.htm` only under `views/` or `templates/`** | `**/views/**/*.{html,htm}` and `**/templates/**/*.{html,htm}`; a bare `*.html` matches no rule and stays `roles: ["unknown"]`. No bare `*.html` catch row at all | java's reasoning, transferable unchanged: a static page and a rendered template are not distinguishable by file name, and `public/index.html` is an asset. A catch row would also be actively harmful here — `matchRules` unions roles across every matching rule, so it would ride `unknown` along on the convention-directory rows | +| D4 | **Single-file components are views** | `.vue`, `.svelte`, `.astro` carry the role, and remain ARTIFACTS only | they are server-rendered by the same test as an `.ejs`. Landing the role while the file is still artifact-only is what makes this independent of #209: artifacts and modules are disjoint by construction (`artifacts/index.ts` skips `SOURCE_EXTS`, which these are not), so a `.vue` that later also yields a `symbol_table` module needs no re-decision of its role | +| D5 | **Extension-based, therefore best-effort** | stated in the code comment rather than smoothed over: a hand-written `.html` fixture under a `views/` directory reads as a view | `roles[]` is classification, not proof; the artifact record (source, `sha256`, `size_bytes`) is identical either way, so a misclassification costs a consumer a filter, not data | diff --git a/src/artifacts/rules.ts b/src/artifacts/rules.ts index 1e692d8..3a93891 100644 --- a/src/artifacts/rules.ts +++ b/src/artifacts/rules.ts @@ -75,6 +75,33 @@ export const RULES: ArtifactRule[] = [ R("LICENSE*", "text", ["legal"]), R("COPYRIGHT*", "text", ["legal"]), R("NOTICE*", "text", ["legal"]), + // View templates (#208). The role name is codeanalyzer-java's — its JSP/JSF/Thymeleaf rows + // (ArtifactDiscovery.java, spec 2026-09-11 D1) coined `view-template` first, so it is ADOPTED + // verbatim here rather than re-coined; see .claude/SCHEMA_DECISIONS.md. `format` names the + // template language, one value per family. Classification is by extension and therefore + // best-effort: a hand-written `.html` test input under a views/ directory reads as a view. The + // artifact record itself (source, sha256, size) is identical either way. + R("*.ejs", "ejs", ["view-template"]), + R("*.hbs", "handlebars", ["view-template"]), + R("*.handlebars", "handlebars", ["view-template"]), + R("*.pug", "pug", ["view-template"]), + R("*.njk", "nunjucks", ["view-template"]), + R("*.liquid", "liquid", ["view-template"]), + // Single-file components. Rendered server-side too, so they are views by the same test; a file + // here staying an ARTIFACT is what makes this safe to land ahead of #209 — if a `.vue` later + // also yields a symbol_table module, the role it already carries needs no re-decision. + R("*.vue", "vue", ["view-template"]), + R("*.svelte", "svelte", ["view-template"]), + R("*.astro", "astro", ["view-template"]), + // `.html` gets the role only under a convention directory, java's policy adopted with the name: + // a static page and a rendered template are not distinguishable by file name, and `public/index.html` + // is an asset. `views/` is Express' default, `templates/` the cross-framework spelling. `**/` + // matches zero directories, so a repo-root `views/` matches too. Deliberately NO bare `*.html` + // catch row — one would union `unknown` into these rows' roles. + R("**/views/**/*.html", "html", ["view-template"]), + R("**/views/**/*.htm", "html", ["view-template"]), + R("**/templates/**/*.html", "html", ["view-template"]), + R("**/templates/**/*.htm", "html", ["view-template"]), // config-shaped catch rows (python's `unknown` rows) R("*.toml", "toml", ["unknown"]), R("*.ini", "ini", ["unknown"]), diff --git a/test/artifact-view-templates.test.ts b/test/artifact-view-templates.test.ts new file mode 100644 index 0000000..18b6324 --- /dev/null +++ b/test/artifact-view-templates.test.ts @@ -0,0 +1,83 @@ +/** + * View-template artifact roles (#208). The role name `view-template` is NOT coined here: it is + * codeanalyzer-java's, from its JSP/JSF/Thymeleaf rows (ArtifactDiscovery.java, spec 2026-09-11 D1), + * adopted verbatim under the cross-language parity clause. The `.html`-stays-unknown policy is + * adopted with it — see the comment on the rules rows. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { analyze } from "../src/core"; +import { matchRules } from "../src/artifacts/rules"; +import type { AnalysisOptions } from "../src/options"; +import type { TSApplication } from "../src/schema"; + +describe("matchRules: template extensions carry the view-template role (#208)", () => { + test("each shipped template extension, with its own format", () => { + const cases: Array<[string, string]> = [ + ["views/page.ejs", "ejs"], + ["views/mail.hbs", "handlebars"], + ["src/emails/welcome.handlebars", "handlebars"], + ["views/layout.pug", "pug"], + ["templates/index.njk", "nunjucks"], + ["theme/product.liquid", "liquid"], + ["src/Widget.vue", "vue"], + ["src/App.svelte", "svelte"], + ["src/pages/index.astro", "astro"], + ]; + for (const [rel, format] of cases) expect(matchRules(rel), rel).toEqual({ format, roles: ["view-template"] }); + }); + + test("`.html`/`.htm` only under a views/templates convention directory, at any depth", () => { + for (const rel of ["views/page.html", "templates/mail.htm", "src/server/views/admin/user.html", "app/templates/a/b.html"]) { + expect(matchRules(rel), rel).toEqual({ format: "html", roles: ["view-template"] }); + } + // A bare page is a static asset, not a rendered view, and the two are indistinguishable by + // name — java's own reasoning. No rule matches, so the walk files it as `unknown`. + for (const rel of ["public/index.html", "index.html", "docs/api.html"]) expect(matchRules(rel), rel).toBeNull(); + }); + + test("non-templates keep the roles they had", () => { + expect(matchRules("package.json")?.roles).toEqual(["dependency-manifest", "tool-config"]); + expect(matchRules("README.md")?.roles).toEqual(["docs"]); + expect(matchRules("tsconfig.json")?.roles).toEqual(["tool-config"]); + // The role never unions in from a second row: `views/x.html` matches ONE rule, not the + // catch rows, so no `unknown` rides along. + expect(matchRules("views/x.html")?.roles).not.toContain("unknown"); + }); +}); + +describe("end to end: templates reach application.artifacts with the role (#208)", () => { + test("a project of one template per shape", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-208-")); + const files: Record = { + "package.json": JSON.stringify({ name: "views-app", dependencies: {} }), + "tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2020" }, include: ["**/*.ts"] }), + "src/real.ts": "export const x = 1;\n", + "views/page.ejs": "

<%= title %>

\n", + "src/Widget.vue": "\n", + "src/pages/index.astro": "---\nconst t = 1;\n---\n

{t}

\n", + "views/shell.html": "\n", + "public/index.html": "\n", + "README.md": "# views-app\n", + }; + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + const opts = { input: dir, output: null, emit: "json", appName: "views-app", neo4jUri: null, neo4jUser: "neo4j", + neo4jPassword: "", neo4jDatabase: null, analysisLevel: 1, graphs: [], graphFieldDepth: 3, jobs: 1, + targetFiles: null, skipTests: true, eager: true, noBuild: true, phantoms: true, + cacheDir: fs.mkdtempSync(path.join(os.tmpdir(), "cants-208-cache-")), verbosity: 0 } as unknown as AnalysisOptions; + const root = (await analyze(opts)).application.application as TSApplication; + const arts = root.artifacts; + for (const key of ["views/page.ejs", "src/Widget.vue", "src/pages/index.astro", "views/shell.html"]) { + expect(arts[key]?.roles, key).toEqual(["view-template"]); + } + expect(arts["public/index.html"]?.roles).toEqual(["unknown"]); + expect(arts["README.md"]?.roles).toEqual(["docs"]); + // A template file is an artifact, not a module — unchanged by this issue (#209 owns that). + expect(Object.keys(root.symbol_table)).toEqual(["src/real.ts"]); + }); +}); From 039961d2a70cdf271310db401727d9a04c97c186 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 12:04:33 -0400 Subject: [PATCH 2/2] docs(artifacts): document the view-template role and its two traps (#208) Also bumps package.json and ANALYZER_VERSION to 1.6.2 in lockstep. --- .../references/vocabulary.md | 14 +++++++++++--- package.json | 2 +- src/utils/version.ts | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/skills/analyzing-cants-graphs/references/vocabulary.md b/docs/skills/analyzing-cants-graphs/references/vocabulary.md index d249f93..1302025 100644 --- a/docs/skills/analyzing-cants-graphs/references/vocabulary.md +++ b/docs/skills/analyzing-cants-graphs/references/vocabulary.md @@ -30,9 +30,17 @@ assuming the graph is empty. `config_access` nodes never carry `callee` — they are reads, not calls (see SKILL.md's traps). `Artifact.format`: `json` \| `jsonc` \| `yaml` \| `toml` \| `ini` \| `dockerfile` \| `yarnlock` \| -`env` \| `text` \| `binary` (`src/artifacts/rules.ts`). `Artifact.roles` (list, unioned across every -matching rule): `dependency-manifest`, `tool-config`, `container-image`, `service-topology`, `ci`, -`env`, `packaging`, `legal`, `docs`, `script`, `unknown`. `Artifact.extraction`: `none` \| `partial` +`env` \| `text` \| `binary`, plus one per template language — `ejs` \| `handlebars` \| `pug` \| +`nunjucks` \| `liquid` \| `vue` \| `svelte` \| `astro` \| `html` (`src/artifacts/rules.ts`). +`Artifact.roles` (list, unioned across every matching rule): `dependency-manifest`, `tool-config`, +`container-image`, `service-topology`, `ci`, `env`, `packaging`, `legal`, `docs`, `script`, +`view-template`, `unknown`. + +`view-template` is the cross-language role for a rendered view (#208; the name is +`codeanalyzer-java`'s, adopted verbatim). It is assigned by extension, so it is best-effort. Two +traps: a `.html`/`.htm` page gets it ONLY under a `views/` or `templates/` directory — a bare +`public/index.html` is an asset and stays `unknown` — and a `.vue`/`.svelte`/`.astro` file is an +`:Artifact` only, never a `:TSModule`, so its markup and script are not in the symbol table. `Artifact.extraction`: `none` \| `partial` \| `full`. `ConfigKey.namespace`: `env` \| `json` \| `yaml` \| `toml` \| `ini` \| `properties` \| `dockerfile`. diff --git a/package.json b/package.json index 92bb546..26a2ada 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeanalyzer-typescript", - "version": "1.6.1", + "version": "1.6.2", "description": "CLDK TypeScript analyzer \u2014 emits the canonical schema v2 (additive CPG: symbol table \u2192 call graph \u2192 intraprocedural dataflow \u2192 interprocedural SDG) as analysis.json and Neo4j, via ts-morph.", "type": "module", "module": "src/index.ts", diff --git a/src/utils/version.ts b/src/utils/version.ts index 75a6d40..f1ac42a 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -3,4 +3,4 @@ * the analyzer invalidates stale per-file Modules (whose source is unchanged but whose extracted * shape may differ across analyzer versions). */ -export const ANALYZER_VERSION = "1.6.1"; +export const ANALYZER_VERSION = "1.6.2";