Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/SCHEMA_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>#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#<kind>` in `types{}` (`builders.ts::putType`) and gets `<id>#<kind>` | 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 |
14 changes: 11 additions & 3 deletions docs/skills/analyzing-cants-graphs/references/vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/artifacts/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
2 changes: 1 addition & 1 deletion src/utils/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
83 changes: 83 additions & 0 deletions test/artifact-view-templates.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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": "<h1><%= title %></h1>\n",
"src/Widget.vue": "<template><p>{{ msg }}</p></template>\n",
"src/pages/index.astro": "---\nconst t = 1;\n---\n<h1>{t}</h1>\n",
"views/shell.html": "<html><body></body></html>\n",
"public/index.html": "<html><body></body></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"]);
});
});
Loading