@intentius/chant 0.98.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cli/commands/import.d.ts +20 -4
  2. package/dist/cli/commands/import.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/deep-observation.d.ts.map +1 -1
  5. package/dist/import/embedded.d.ts +184 -0
  6. package/dist/import/embedded.d.ts.map +1 -0
  7. package/dist/import/generator.d.ts +13 -0
  8. package/dist/import/generator.d.ts.map +1 -1
  9. package/dist/import/parser.d.ts +15 -1
  10. package/dist/import/parser.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +19 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lint/engine.d.ts +6 -1
  14. package/dist/lint/engine.d.ts.map +1 -1
  15. package/dist/lint/rule.d.ts +10 -0
  16. package/dist/lint/rule.d.ts.map +1 -1
  17. package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
  18. package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
  19. package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
  20. package/dist/lint/rules/property-kind.d.ts +7 -0
  21. package/dist/lint/rules/property-kind.d.ts.map +1 -0
  22. package/dist/workspace/conformance/index.d.ts +9 -0
  23. package/dist/workspace/conformance/index.d.ts.map +1 -1
  24. package/dist/yaml.d.ts +28 -6
  25. package/dist/yaml.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/import-layout.test.ts +142 -0
  28. package/src/cli/commands/import-no-parser.test.ts +1 -1
  29. package/src/cli/commands/import.test.ts +25 -0
  30. package/src/cli/commands/import.ts +158 -36
  31. package/src/cli/commands/lint.ts +21 -8
  32. package/src/deep-observation.test.ts +21 -0
  33. package/src/deep-observation.ts +9 -0
  34. package/src/import/embedded.test.ts +153 -0
  35. package/src/import/embedded.ts +376 -0
  36. package/src/import/generator.ts +14 -0
  37. package/src/import/parser.ts +17 -1
  38. package/src/lexicon.ts +21 -0
  39. package/src/lint/engine.ts +7 -0
  40. package/src/lint/rule.ts +10 -0
  41. package/src/lint/rules/file-declarable-limit.ts +11 -4
  42. package/src/lint/rules/flat-declarations.ts +6 -1
  43. package/src/lint/rules/no-unused-declarable.ts +59 -2
  44. package/src/lint/rules/property-kind.test.ts +99 -0
  45. package/src/lint/rules/property-kind.ts +56 -0
  46. package/src/workspace/conformance/index.mjs +1 -0
  47. package/src/workspace/conformance/index.ts +23 -1
  48. package/src/yaml.test.ts +192 -1
  49. package/src/yaml.ts +404 -250
@@ -18,4 +18,18 @@ export interface TypeScriptGenerator {
18
18
  * @returns Array of generated TypeScript files
19
19
  */
20
20
  generate(ir: TemplateIR): GeneratedFile[];
21
+
22
+ /**
23
+ * True when the generator places its own files (#2964). Core then calls
24
+ * `generate()` once with the whole IR and writes exactly the files it
25
+ * returns, at the paths it gives, however many resources the IR holds.
26
+ *
27
+ * Leave it unset for core's default layout: up to three resources are
28
+ * generated in one call, and above three core splits the IR into
29
+ * per-category files (`storage.ts`, `compute.ts`, `network.ts`,
30
+ * `other.ts`) plus an `index.ts` barrel, keeping only the first file of
31
+ * each call. Set it when your resources refer to each other across that
32
+ * split, or when one `generate()` call returns several modules.
33
+ */
34
+ readonly ownsLayout?: boolean;
21
35
  }
@@ -1,3 +1,5 @@
1
+ import type { EmbeddedContentResolver } from "./embedded";
2
+
1
3
  /**
2
4
  * Intermediate representation of a template parameter
3
5
  */
@@ -63,6 +65,19 @@ export interface TemplateIR {
63
65
  readonly warnings?: string[];
64
66
  }
65
67
 
68
+ /**
69
+ * What `chant import` hands a parser besides the content (#2962).
70
+ */
71
+ export interface ParseContext {
72
+ /**
73
+ * Resolves content embedded in the template's resources (a collector
74
+ * config in a ConfigMap) to a reference to declarations the owning
75
+ * lexicon imports. Absent outside `chant import`; a parser then keeps
76
+ * embedded content as written.
77
+ */
78
+ readonly embedded?: EmbeddedContentResolver;
79
+ }
80
+
66
81
  /**
67
82
  * Interface for template parsers that convert external formats to IR
68
83
  */
@@ -70,7 +85,8 @@ export interface TemplateParser {
70
85
  /**
71
86
  * Parse template content into intermediate representation
72
87
  * @param content - Raw template content (JSON, YAML, etc.)
88
+ * @param context - What `chant import` provides beyond the content; parsers may ignore it
73
89
  * @returns Intermediate representation of the template
74
90
  */
75
- parse(content: string): TemplateIR;
91
+ parse(content: string, context?: ParseContext): TemplateIR;
76
92
  }
package/src/lexicon.ts CHANGED
@@ -5,6 +5,7 @@ import type { RuleSpec } from "./lint/declarative";
5
5
  import type { PostSynthCheck } from "./lint/post-synth";
6
6
  import type { TemplateParser, TemplateIR } from "./import/parser";
7
7
  import type { TypeScriptGenerator } from "./import/generator";
8
+ import type { EmbeddedContentImporter } from "./import/embedded";
8
9
  import type { AgentConfigImporter } from "./agents/importer";
9
10
  import type { ArtifactIntegrity } from "./lexicon-integrity";
10
11
  import type { OkfFile } from "./okf";
@@ -1047,6 +1048,15 @@ export interface LexiconPlugin {
1047
1048
  /** Return declarative rule specs for compilation via rule() */
1048
1049
  declarativeRules?(): RuleSpec[];
1049
1050
 
1051
+ /**
1052
+ * Class names this lexicon exports whose instances are property-kind
1053
+ * declarables (`createProperty`), such as Grafana's panels and queries.
1054
+ * The core COR001, COR004 and COR009 heuristics leave them out, since a
1055
+ * property-kind declarable lives inside the resource that holds it
1056
+ * (chant #2957). A lexicon that leaves this out gets the rules unchanged.
1057
+ */
1058
+ propertyClassNames?(): string[];
1059
+
1050
1060
  /** Return post-synthesis checks for build validation */
1051
1061
  postSynthChecks?(): PostSynthCheck[];
1052
1062
 
@@ -1156,6 +1166,17 @@ export interface LexiconPlugin {
1156
1166
  /** Return a generator for converting IR to TypeScript */
1157
1167
  templateGenerator?(): TypeScriptGenerator;
1158
1168
 
1169
+ /**
1170
+ * Importers for this lexicon's content when it is embedded in another
1171
+ * lexicon's resources (#2962): a collector config in a k8s ConfigMap, rule
1172
+ * groups in a `PrometheusRule`, dashboard JSON in a ConfigMap. The host's
1173
+ * parser offers the content through `ParseContext.embedded`, and core finds
1174
+ * the owner at run time among the project's lexicons and the installed
1175
+ * ones whose `detectTemplate` recognizes the content, so the host does not
1176
+ * depend on the owner. See `packages/core/src/import/embedded.ts`.
1177
+ */
1178
+ embeddedImporters?(): EmbeddedContentImporter[];
1179
+
1159
1180
  /**
1160
1181
  * Re-express local agent configuration (skills, MCP servers, instruction
1161
1182
  * files) discovered by `chant audit --agents` as this lexicon's resources.
@@ -207,6 +207,11 @@ function isDiagnosticDisabled(
207
207
  * config-aware rules (COR021 reads `environments` + `ownership`), put on
208
208
  * every file's `LintContext.projectConfig`. Optional; without it those
209
209
  * rules stay silent.
210
+ * @param propertyClasses - chant #2957 — the class names the active
211
+ * lexicons declare property-kind (`LexiconPlugin.propertyClassNames()`),
212
+ * put on every file's `LintContext.propertyClasses` so COR001, COR004 and
213
+ * COR009 leave those declarables out. Optional; without it every
214
+ * declarable counts.
210
215
  * @returns LintRunResult with diagnostics and suppressed items
211
216
  */
212
217
  export async function runLint(
@@ -215,6 +220,7 @@ export async function runLint(
215
220
  ruleOptions?: Map<string, Record<string, unknown>>,
216
221
  intrinsics?: readonly IntrinsicDef[],
217
222
  projectConfig?: LintProjectConfig,
223
+ propertyClasses?: ReadonlySet<string>,
218
224
  ): Promise<LintRunResult> {
219
225
  const allDiagnostics: LintDiagnostic[] = [];
220
226
  const allSuppressed: Array<LintDiagnostic & { reason?: string }> = [];
@@ -237,6 +243,7 @@ export async function runLint(
237
243
  lexicon: undefined,
238
244
  intrinsics,
239
245
  projectConfig,
246
+ propertyClasses,
240
247
  };
241
248
 
242
249
  // Execute each rule
package/src/lint/rule.ts CHANGED
@@ -97,6 +97,16 @@ export interface LintContext {
97
97
  * case config-aware rules stay silent.
98
98
  */
99
99
  projectConfig?: LintProjectConfig;
100
+ /**
101
+ * chant #2957 — class names the active lexicons declare property-kind
102
+ * (`LexiconPlugin.propertyClassNames()`), such as Grafana's panels,
103
+ * queries and variables. COR001, COR004 and COR009 leave these out: a
104
+ * property-kind declarable lives inside the resource that holds it, so it
105
+ * is neither a resource to count nor dead code on its own. Undefined when
106
+ * no active lexicon names any, and those rules then treat every
107
+ * declarable alike.
108
+ */
109
+ propertyClasses?: ReadonlySet<string>;
100
110
  }
101
111
 
102
112
  /**
@@ -1,5 +1,6 @@
1
1
  import * as ts from "typescript";
2
2
  import type { LintRule, LintContext, LintDiagnostic } from "../rule";
3
+ import { isPropertyKindNew } from "./property-kind";
3
4
 
4
5
  const DECLARABLE_LIMIT = 8;
5
6
 
@@ -57,15 +58,21 @@ function isDeclarableConstructor(node: ts.NewExpression): boolean {
57
58
  return false;
58
59
  }
59
60
 
61
+ /**
62
+ * Collect the declarable `new` expressions this rule counts. Property-kind
63
+ * declarables (chant #2957) are left out: a dashboard with twenty panels,
64
+ * queries and variables is one resource, not twenty-one.
65
+ */
60
66
  function collectDeclarableNewExpressions(
61
67
  node: ts.Node,
68
+ context: LintContext,
62
69
  results: ts.NewExpression[],
63
70
  ): void {
64
- if (ts.isNewExpression(node) && isDeclarableConstructor(node)) {
71
+ if (ts.isNewExpression(node) && isDeclarableConstructor(node) && !isPropertyKindNew(node, context)) {
65
72
  results.push(node);
66
73
  }
67
74
  ts.forEachChild(node, (child) =>
68
- collectDeclarableNewExpressions(child, results),
75
+ collectDeclarableNewExpressions(child, context, results),
69
76
  );
70
77
  }
71
78
 
@@ -73,11 +80,11 @@ export const fileDeclarableLimitRule: LintRule = {
73
80
  id: "COR009",
74
81
  severity: "warning",
75
82
  category: "style",
76
- description: "Limits the number of Declarable instances per file to encourage splitting by concern",
83
+ description: "Limits the number of resource Declarable instances per file to encourage splitting by concern; property-kind declarables are not counted",
77
84
  check(context: LintContext, options?: Record<string, unknown>): LintDiagnostic[] {
78
85
  const limit = (typeof options?.max === "number" ? options.max : null) ?? DECLARABLE_LIMIT;
79
86
  const instances: ts.NewExpression[] = [];
80
- collectDeclarableNewExpressions(context.sourceFile, instances);
87
+ collectDeclarableNewExpressions(context.sourceFile, context, instances);
81
88
 
82
89
  if (instances.length > limit) {
83
90
  return [
@@ -1,5 +1,6 @@
1
1
  import * as ts from "typescript";
2
2
  import type { LintRule, LintContext, LintDiagnostic } from "../rule";
3
+ import { isPropertyKindNew } from "./property-kind";
3
4
 
4
5
  /**
5
6
  * COR001: No inline objects in Declarable constructors
@@ -12,11 +13,15 @@ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
12
13
  * Triggers on: new Bucket({ tags: [{ key: "env", value: "prod" }] })
13
14
  * OK: new Bucket({ bucketName: "my-bucket", accessControl: "Private" })
14
15
  * OK: new Bucket({ encryption: dataEncryption })
16
+ * OK: new TimeSeriesPanel({ fieldConfig: { defaults: { unit: "ms" } } }) when
17
+ * the lexicon declares TimeSeriesPanel property-kind (chant #2957). A
18
+ * property-kind declarable is itself a nested value inside a resource, so
19
+ * the object literals it holds are already at the depth this rule asks for.
15
20
  */
16
21
 
17
22
  function checkNode(node: ts.Node, context: LintContext, diagnostics: LintDiagnostic[]): void {
18
23
  // Check for NewExpression nodes (constructor calls)
19
- if (ts.isNewExpression(node)) {
24
+ if (ts.isNewExpression(node) && !isPropertyKindNew(node, context)) {
20
25
  // Check if the first argument is an object literal
21
26
  if (node.arguments && node.arguments.length > 0) {
22
27
  const firstArg = node.arguments[0];
@@ -1,5 +1,6 @@
1
1
  import * as ts from "typescript";
2
2
  import type { LintRule, LintContext, LintDiagnostic } from "../rule";
3
+ import { isPropertyKindNew } from "./property-kind";
3
4
 
4
5
  /**
5
6
  * COR004: no-unused-declarable
@@ -10,6 +11,13 @@ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
10
11
  *
11
12
  * Triggers on: export const bucket = new Bucket({...}) when bucket is never referenced
12
13
  * OK: export const bucket = new Bucket({...}); export const fn = new Function({ bucket: bucket.arn })
14
+ *
15
+ * Property-kind declarables (chant #2957), such as Grafana panels, are left
16
+ * out on both sides. One is never flagged itself: it only means something
17
+ * inside a resource, and a file of them is usually assembled into that
18
+ * resource from another file. A resource that holds one, inline or through
19
+ * a const declared in this file, is the root that emits it, so it is not
20
+ * flagged either: nothing ever references a dashboard, and that is fine.
13
21
  */
14
22
 
15
23
  interface DeclarableInfo {
@@ -31,8 +39,52 @@ function getNewExpressionClassName(expr: ts.NewExpression): string | undefined {
31
39
  return undefined;
32
40
  }
33
41
 
34
- function collectExportedDeclarables(sourceFile: ts.SourceFile): DeclarableInfo[] {
42
+ /** Names of this file's top-level consts initialised with a property-kind `new`. */
43
+ function collectPropertyKindConsts(context: LintContext): Set<string> {
44
+ const names = new Set<string>();
45
+ for (const stmt of context.sourceFile.statements) {
46
+ if (!ts.isVariableStatement(stmt)) continue;
47
+ for (const decl of stmt.declarationList.declarations) {
48
+ if (
49
+ ts.isIdentifier(decl.name) &&
50
+ decl.initializer &&
51
+ ts.isNewExpression(decl.initializer) &&
52
+ isPropertyKindNew(decl.initializer, context)
53
+ ) {
54
+ names.add(decl.name.text);
55
+ }
56
+ }
57
+ }
58
+ return names;
59
+ }
60
+
61
+ /**
62
+ * True when the constructor arguments hold a property-kind declarable,
63
+ * either inline (`panels: [new Row(…)]`) or through a const from
64
+ * `propertyConsts` (`panels: [red]`).
65
+ */
66
+ function holdsPropertyKind(expr: ts.NewExpression, context: LintContext, propertyConsts: Set<string>): boolean {
67
+ let found = false;
68
+ function visit(node: ts.Node): void {
69
+ if (found) return;
70
+ if (ts.isNewExpression(node) && isPropertyKindNew(node, context)) {
71
+ found = true;
72
+ return;
73
+ }
74
+ if (ts.isIdentifier(node) && propertyConsts.has(node.text)) {
75
+ found = true;
76
+ return;
77
+ }
78
+ ts.forEachChild(node, visit);
79
+ }
80
+ for (const arg of expr.arguments ?? []) visit(arg);
81
+ return found;
82
+ }
83
+
84
+ function collectExportedDeclarables(context: LintContext): DeclarableInfo[] {
35
85
  const declarables: DeclarableInfo[] = [];
86
+ const sourceFile = context.sourceFile;
87
+ const propertyConsts = collectPropertyKindConsts(context);
36
88
 
37
89
  ts.forEachChild(sourceFile, (node) => {
38
90
  if (!ts.isVariableStatement(node)) return;
@@ -54,6 +106,11 @@ function collectExportedDeclarables(sourceFile: ts.SourceFile): DeclarableInfo[]
54
106
  // Parameters are inherently cross-file (declared in params.ts, consumed via Ref() elsewhere)
55
107
  if (className === "Parameter") continue;
56
108
 
109
+ // chant #2957: a property-kind declarable is part of a resource, and a
110
+ // resource holding one is the root that emits it.
111
+ if (isPropertyKindNew(decl.initializer, context)) continue;
112
+ if (holdsPropertyKind(decl.initializer, context, propertyConsts)) continue;
113
+
57
114
  declarables.push({
58
115
  name: decl.name.text,
59
116
  node,
@@ -98,7 +155,7 @@ export const noUnusedDeclarableRule: LintRule = {
98
155
  description: "Detects exported declarables that are never referenced in the same file",
99
156
  check(context: LintContext): LintDiagnostic[] {
100
157
  const diagnostics: LintDiagnostic[] = [];
101
- const declarables = collectExportedDeclarables(context.sourceFile);
158
+ const declarables = collectExportedDeclarables(context);
102
159
 
103
160
  for (const decl of declarables) {
104
161
  if (!collectReferences(decl.name, context.sourceFile, decl.node)) {
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import { flatDeclarationsRule } from "./flat-declarations";
4
+ import { noUnusedDeclarableRule } from "./no-unused-declarable";
5
+ import { fileDeclarableLimitRule } from "./file-declarable-limit";
6
+ import type { LintContext } from "../rule";
7
+
8
+ // chant #2957: a dashboard-shaped file whose panels, rows, queries and
9
+ // variables are property-kind declarables, as the grafana lexicon declares them.
10
+ const PROPERTY = new Set(["Row", "StatPanel", "TimeSeriesPanel", "PromQuery", "QueryVariable"]);
11
+
12
+ const DASHBOARD = `
13
+ import { Dashboard, Row, TimeSeriesPanel as Series, StatPanel, PromQuery, QueryVariable } from "@intentius/chant-lexicon-grafana";
14
+
15
+ export const service = new QueryVariable({ name: "service", query: "label_values(up, job)" });
16
+
17
+ export const overview = new Dashboard({
18
+ title: "Overview",
19
+ variables: [service],
20
+ panels: [
21
+ new Row({ title: "RED", panels: [
22
+ new StatPanel({ title: "Errors", targets: [new PromQuery({ expr: "a" })], fieldConfig: { defaults: { unit: "percentunit" } }, options: { graphMode: "area" } }),
23
+ new Series({ title: "Rate", targets: [new PromQuery({ expr: "b" }), new PromQuery({ expr: "c" })], fieldConfig: { defaults: { unit: "reqps" } } }),
24
+ new Series({ title: "Latency", targets: [new PromQuery({ expr: "d" })], fieldConfig: { defaults: { unit: "ms" } } }),
25
+ new Series({ title: "Saturation", targets: [new PromQuery({ expr: "e" })] }),
26
+ ] }),
27
+ ],
28
+ });
29
+ `;
30
+
31
+ function context(code: string, propertyClasses?: ReadonlySet<string>): LintContext {
32
+ return {
33
+ sourceFile: ts.createSourceFile("dashboard.ts", code, ts.ScriptTarget.Latest, true),
34
+ entities: [],
35
+ filePath: "dashboard.ts",
36
+ propertyClasses,
37
+ };
38
+ }
39
+
40
+ describe("property-kind declarables in COR001, COR004 and COR009 (chant #2957)", () => {
41
+ test("without property classes the rules count every declarable, as before", () => {
42
+ const ctx = context(DASHBOARD);
43
+ expect(fileDeclarableLimitRule.check(ctx)).toHaveLength(1);
44
+ expect(fileDeclarableLimitRule.check(ctx)[0].message).toContain("12 Declarable instances");
45
+ expect(flatDeclarationsRule.check(ctx)).toHaveLength(4);
46
+ expect(noUnusedDeclarableRule.check(ctx).map((d) => d.message)).toEqual([
47
+ expect.stringContaining("'overview'"),
48
+ ]);
49
+ });
50
+
51
+ test("with them, the dashboard file is clean", () => {
52
+ const ctx = context(DASHBOARD, PROPERTY);
53
+ expect(fileDeclarableLimitRule.check(ctx)).toEqual([]);
54
+ expect(flatDeclarationsRule.check(ctx)).toEqual([]);
55
+ expect(noUnusedDeclarableRule.check(ctx)).toEqual([]);
56
+ });
57
+
58
+ test("COR009 still counts resources, and only resources", () => {
59
+ const resources = Array.from({ length: 9 }, (_, i) => `export const d${i} = new Dashboard({ panels: [new Row({})] });`).join("\n");
60
+ const [diag] = fileDeclarableLimitRule.check(context(resources, PROPERTY));
61
+ expect(diag.message).toContain("9 Declarable instances");
62
+ });
63
+
64
+ test("COR001 still flags inline objects on a resource", () => {
65
+ const code = `export const d = new Dashboard({ time: { from: "now-1h", to: "now" }, panels: [new StatPanel({ options: { a: 1 } })] });`;
66
+ const diags = flatDeclarationsRule.check(context(code, PROPERTY));
67
+ expect(diags).toHaveLength(1);
68
+ expect(diags[0].column).toBe(code.indexOf("{ from") + 1);
69
+ });
70
+
71
+ test("COR004 skips an exported property-kind declarable used from another file", () => {
72
+ const code = `export const latency = new TimeSeriesPanel({ title: "Latency" });`;
73
+ expect(noUnusedDeclarableRule.check(context(code, PROPERTY))).toEqual([]);
74
+ expect(noUnusedDeclarableRule.check(context(code))).toHaveLength(1);
75
+ });
76
+
77
+ test("COR004 treats a resource holding a property-kind const as the root", () => {
78
+ const code = [
79
+ `const red = new Row({ title: "RED" });`,
80
+ `export const overview = new Dashboard({ panels: [red] });`,
81
+ ].join("\n");
82
+ expect(noUnusedDeclarableRule.check(context(code, PROPERTY))).toEqual([]);
83
+ });
84
+
85
+ test("COR004 still flags an unreferenced resource holding no property-kind declarable", () => {
86
+ const code = [
87
+ `export const logs = new Bucket({ bucketName: "logs" });`,
88
+ `export const overview = new Dashboard({ panels: [new Row({})] });`,
89
+ ].join("\n");
90
+ expect(noUnusedDeclarableRule.check(context(code, PROPERTY)).map((d) => d.message)).toEqual([
91
+ expect.stringContaining("'logs'"),
92
+ ]);
93
+ });
94
+
95
+ test("a namespace-qualified constructor resolves by its member name", () => {
96
+ const code = `export const d = new g.Dashboard({ panels: [new g.StatPanel({ options: { a: 1 } })] });`;
97
+ expect(flatDeclarationsRule.check(context(code, PROPERTY))).toEqual([]);
98
+ });
99
+ });
@@ -0,0 +1,56 @@
1
+ import * as ts from "typescript";
2
+ import type { LintContext } from "../rule";
3
+
4
+ /**
5
+ * Property-kind declarables for the COR001, COR004 and COR009 heuristics
6
+ * (chant #2957).
7
+ *
8
+ * Those three rules were written for cloud resources, where every `new X(…)`
9
+ * is something the build emits. A property-kind declarable (`createProperty`)
10
+ * is not: it only exists inside the resource that holds it, like a Grafana
11
+ * panel inside its dashboard. The active lexicons name their property-kind
12
+ * classes through `LexiconPlugin.propertyClassNames()`, and `runLint` puts
13
+ * them on `LintContext.propertyClasses`. Without that set (a unit test, or a
14
+ * lexicon that declares none) nothing is property-kind and the rules behave
15
+ * as they always have.
16
+ */
17
+
18
+ /**
19
+ * Map each locally bound import name to the name it was exported under, so
20
+ * `import { TimeSeriesPanel as Series }` still resolves `new Series(…)`.
21
+ */
22
+ function importAliases(sourceFile: ts.SourceFile): Map<string, string> {
23
+ const aliases = new Map<string, string>();
24
+ for (const stmt of sourceFile.statements) {
25
+ if (!ts.isImportDeclaration(stmt)) continue;
26
+ const bindings = stmt.importClause?.namedBindings;
27
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
28
+ for (const el of bindings.elements) {
29
+ if (el.propertyName) aliases.set(el.name.text, el.propertyName.text);
30
+ }
31
+ }
32
+ return aliases;
33
+ }
34
+
35
+ const aliasCache = new WeakMap<ts.SourceFile, Map<string, string>>();
36
+
37
+ /** The exported class name a `new` expression constructs, if it has one. */
38
+ export function constructedClassName(node: ts.NewExpression, sourceFile: ts.SourceFile): string | undefined {
39
+ const expr = node.expression;
40
+ if (ts.isPropertyAccessExpression(expr)) return expr.name.text;
41
+ if (!ts.isIdentifier(expr)) return undefined;
42
+ let aliases = aliasCache.get(sourceFile);
43
+ if (!aliases) {
44
+ aliases = importAliases(sourceFile);
45
+ aliasCache.set(sourceFile, aliases);
46
+ }
47
+ return aliases.get(expr.text) ?? expr.text;
48
+ }
49
+
50
+ /** True when `node` constructs a class an active lexicon declares property-kind. */
51
+ export function isPropertyKindNew(node: ts.NewExpression, context: LintContext): boolean {
52
+ const classes = context.propertyClasses;
53
+ if (!classes || classes.size === 0) return false;
54
+ const name = constructedClassName(node, context.sourceFile);
55
+ return name !== undefined && classes.has(name);
56
+ }
@@ -25,6 +25,7 @@ export const {
25
25
  selectCommands,
26
26
  MCP_READ_TOOLS,
27
27
  mcpToolCall,
28
+ sameReadDocument,
28
29
  startMcpSession,
29
30
  createConformanceWorkspace,
30
31
  conformanceTarget,
@@ -527,6 +527,28 @@ function runChant(command: string[], argv: string[], cwd: string, timeoutMs: num
527
527
  });
528
528
  }
529
529
 
530
+ /**
531
+ * Whether an MCP tool's document and the one its command printed are the same
532
+ * read (#2707). Each graph member's `cached` flag (#2876) is left out: it says
533
+ * whether chant's per-member cache answered that one read, not what the graph
534
+ * is. The tool is called first, so its read can store the cache entry the
535
+ * command's read is then answered from, and the flag differs while the graph
536
+ * does not (#3019). Everything else must be equal.
537
+ */
538
+ export function sameReadDocument(tool: unknown, printed: unknown): boolean {
539
+ return isDeepStrictEqual(withoutCacheFlags(tool), withoutCacheFlags(printed));
540
+ }
541
+
542
+ function withoutCacheFlags(doc: unknown): unknown {
543
+ if (typeof doc !== "object" || doc === null || !Array.isArray((doc as { members?: unknown }).members)) return doc;
544
+ const members = (doc as { members: unknown[] }).members.map((m) => {
545
+ if (typeof m !== "object" || m === null || !("cached" in m)) return m;
546
+ const { cached: _cached, ...rest } = m as Record<string, unknown>;
547
+ return rest;
548
+ });
549
+ return { ...doc, members };
550
+ }
551
+
530
552
  /**
531
553
  * A transport that runs chant in the workspace `target()` names and records
532
554
  * every call and what it printed. `reset()` clears the record before a read.
@@ -556,7 +578,7 @@ export function recordingTransport(target: () => { workspaceDir: string; chantCo
556
578
  } catch {
557
579
  printedDoc = undefined;
558
580
  }
559
- if (!isDeepStrictEqual(doc, printedDoc)) problems.push(`${argv.slice(1).join(" ")}: the MCP tool ${call.name} returned a document other than chant ${argv.join(" ")} printed`);
581
+ if (!sameReadDocument(doc, printedDoc)) problems.push(`${argv.slice(1).join(" ")}: the MCP tool ${call.name} returned a document other than chant ${argv.join(" ")} printed`);
560
582
  return { argv: [...argv], status: 0, stdout: JSON.stringify(doc), stderr: "" };
561
583
  };
562
584
  const transport: ChantTransport = {