@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.
- package/dist/cli/commands/import.d.ts +20 -4
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +184 -0
- package/dist/import/embedded.d.ts.map +1 -0
- package/dist/import/generator.d.ts +13 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts +15 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lint/engine.d.ts +6 -1
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +10 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
- package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
- package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
- package/dist/lint/rules/property-kind.d.ts +7 -0
- package/dist/lint/rules/property-kind.d.ts.map +1 -0
- package/dist/workspace/conformance/index.d.ts +9 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/yaml.d.ts +28 -6
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/import-layout.test.ts +142 -0
- package/src/cli/commands/import-no-parser.test.ts +1 -1
- package/src/cli/commands/import.test.ts +25 -0
- package/src/cli/commands/import.ts +158 -36
- package/src/cli/commands/lint.ts +21 -8
- package/src/deep-observation.test.ts +21 -0
- package/src/deep-observation.ts +9 -0
- package/src/import/embedded.test.ts +153 -0
- package/src/import/embedded.ts +376 -0
- package/src/import/generator.ts +14 -0
- package/src/import/parser.ts +17 -1
- package/src/lexicon.ts +21 -0
- package/src/lint/engine.ts +7 -0
- package/src/lint/rule.ts +10 -0
- package/src/lint/rules/file-declarable-limit.ts +11 -4
- package/src/lint/rules/flat-declarations.ts +6 -1
- package/src/lint/rules/no-unused-declarable.ts +59 -2
- package/src/lint/rules/property-kind.test.ts +99 -0
- package/src/lint/rules/property-kind.ts +56 -0
- package/src/workspace/conformance/index.mjs +1 -0
- package/src/workspace/conformance/index.ts +23 -1
- package/src/yaml.test.ts +192 -1
- package/src/yaml.ts +404 -250
package/src/import/generator.ts
CHANGED
|
@@ -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
|
}
|
package/src/import/parser.ts
CHANGED
|
@@ -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.
|
package/src/lint/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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 (!
|
|
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 = {
|