@intentius/chant 0.52.1 → 0.53.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 (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. package/src/yaml.ts +24 -3
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The parts of a CDK cloud assembly the carve advisor reads (#1056).
3
+ *
4
+ * A cloud assembly is already JSON, so there is no parser to install and no
5
+ * expression AST to resolve — the Terraform path's two hardest layers do not
6
+ * exist here. Three files carry everything the advisor needs:
7
+ *
8
+ * - `manifest.json` — which artifacts are stacks, which template file each
9
+ * one synthesized to, and (crucially) whether any context lookup went
10
+ * unresolved.
11
+ * - `tree.json` — the construct tree, so CloudFormation resources can be
12
+ * grouped back under the construct that emitted them. Optional: CDK can be
13
+ * told not to write it, and the advisor degrades to a coarser grouping.
14
+ * - `*.template.json` — the CloudFormation templates themselves, already in
15
+ * `AWS::*` type space, which is exactly what the AWS tier map keys on.
16
+ *
17
+ * Everything here is post-jsii, so a Python or Java CDK app reads through the
18
+ * same code path as a TypeScript one. Nothing in this module reads CDK source.
19
+ *
20
+ * The declarations are deliberately loose — every field optional, unknown keys
21
+ * tolerated — because the assembly schema is not chant's to own and gains
22
+ * fields between CDK releases.
23
+ */
24
+
25
+ /** A CloudFormation resource as it appears in a synthesized template. */
26
+ export interface CfnResource {
27
+ Type?: string;
28
+ Properties?: Record<string, unknown>;
29
+ /** `aws:cdk:path` names the emitting construct; `aws:asset:*` marks an asset. */
30
+ Metadata?: Record<string, unknown>;
31
+ DependsOn?: string | string[];
32
+ /** Names a template `Condition` — the CloudFormation analogue of `count`. */
33
+ Condition?: string;
34
+ [k: string]: unknown;
35
+ }
36
+
37
+ /** A CloudFormation `Outputs` entry. */
38
+ export interface CfnOutput {
39
+ Value?: unknown;
40
+ Export?: { Name?: unknown };
41
+ [k: string]: unknown;
42
+ }
43
+
44
+ export interface CfnTemplate {
45
+ Parameters?: Record<string, Record<string, unknown>>;
46
+ Conditions?: Record<string, unknown>;
47
+ Resources?: Record<string, CfnResource>;
48
+ Outputs?: Record<string, CfnOutput>;
49
+ [k: string]: unknown;
50
+ }
51
+
52
+ /** One artifact in `manifest.json`. */
53
+ export interface CdkArtifact {
54
+ type?: string;
55
+ properties?: { templateFile?: string; [k: string]: unknown };
56
+ displayName?: string;
57
+ dependencies?: string[];
58
+ environment?: string;
59
+ [k: string]: unknown;
60
+ }
61
+
62
+ /** One unresolved context lookup, as `cdk synth` records it. */
63
+ export interface CdkMissingContext {
64
+ key?: string;
65
+ provider?: string;
66
+ props?: Record<string, unknown>;
67
+ }
68
+
69
+ export interface CdkManifest {
70
+ version?: string;
71
+ artifacts?: Record<string, CdkArtifact>;
72
+ /**
73
+ * Context queries synthesis could not answer. Non-empty means the templates
74
+ * hold placeholder values rather than the real account's, so nothing
75
+ * synthesized from them describes real infrastructure.
76
+ */
77
+ missing?: CdkMissingContext[];
78
+ [k: string]: unknown;
79
+ }
80
+
81
+ /** A node of `tree.json`. Children are keyed by construct id. */
82
+ export interface CdkTreeNode {
83
+ id?: string;
84
+ path?: string;
85
+ children?: Record<string, CdkTreeNode>;
86
+ /** `fqn` is the construct class — the L1/L2/L3 signal. */
87
+ constructInfo?: { fqn?: string; version?: string };
88
+ attributes?: Record<string, unknown>;
89
+ }
90
+
91
+ export interface CdkTreeFile {
92
+ version?: string;
93
+ tree?: CdkTreeNode;
94
+ }
95
+
96
+ /** One CloudFormation stack artifact, with its template read. */
97
+ export interface CdkStack {
98
+ /** Artifact id in `manifest.json`, e.g. `Stage-AppStack`. */
99
+ id: string;
100
+ /** Construct path of the stack, e.g. `Stage/AppStack`. */
101
+ path: string;
102
+ templateFile: string;
103
+ template: CfnTemplate;
104
+ }
105
+
106
+ /** A read cloud assembly: the manifest, the construct tree, the stacks. */
107
+ export interface CloudAssembly {
108
+ dir: string;
109
+ manifest: CdkManifest;
110
+ /** Absent when the app was synthesized without tree metadata. */
111
+ tree?: CdkTreeNode;
112
+ stacks: CdkStack[];
113
+ /** Everything that went wrong or is worth saying about the read itself. */
114
+ diagnostics: string[];
115
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The command's job is to keep `chant audit --agents` reading like a chant
3
+ * audit report while auditing a different subject, and to be honest about the
4
+ * edges of what it scanned.
5
+ */
6
+
7
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
8
+ import { mkdirSync, writeFileSync, rmSync, readFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { tmpdir } from "os";
12
+ import Ajv from "ajv";
13
+ import { auditAgentsCommand, toAuditFindings, siteSummary } from "./audit-agents";
14
+ import type { AgentConfigSite } from "../../agents/types";
15
+
16
+ let home: string;
17
+ let project: string;
18
+
19
+ function writeIn(root: string, rel: string, content: string): void {
20
+ const full = join(root, rel);
21
+ mkdirSync(join(full, ".."), { recursive: true });
22
+ writeFileSync(full, content, "utf-8");
23
+ }
24
+
25
+ beforeEach(() => {
26
+ home = join(tmpdir(), `chant-aa-home-${Math.random().toString(36).slice(2)}`);
27
+ project = join(tmpdir(), `chant-aa-proj-${Math.random().toString(36).slice(2)}`);
28
+ mkdirSync(home, { recursive: true });
29
+ mkdirSync(project, { recursive: true });
30
+ });
31
+
32
+ afterEach(() => {
33
+ rmSync(home, { recursive: true, force: true });
34
+ rmSync(project, { recursive: true, force: true });
35
+ });
36
+
37
+ const run = (opts: Parameters<typeof auditAgentsCommand>[0] = {}) =>
38
+ auditAgentsCommand({ home, projectRoots: [project], platform: "linux", now: "2026-01-01T00:00:00Z", toolVersion: "1.2.3", ...opts });
39
+
40
+ /** A config with one unpinned MCP server — an AGT001 error. */
41
+ function seedUnpinned(): void {
42
+ writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { a: { command: "npx", args: ["-y", "floating-server"] } } }));
43
+ }
44
+
45
+ describe("toAuditFindings", () => {
46
+ test("projects an agent finding onto the shared audit shape", () => {
47
+ const [f] = toAuditFindings([
48
+ { checkId: "AGT001", severity: "error", message: "m", file: "/f", siteId: "user-claude", scope: "user", runtime: "claude", entity: "srv" },
49
+ ]);
50
+ expect(f).toEqual({ checkId: "AGT001", severity: "error", message: "m", file: "/f", lexicon: "agents", entity: "user-claude › srv" });
51
+ });
52
+
53
+ test("falls back to the site id when a finding names no entity", () => {
54
+ const [f] = toAuditFindings([
55
+ { checkId: "AGT005", severity: "error", message: "m", file: "/f", siteId: "user-claude", scope: "user", runtime: "claude" },
56
+ ]);
57
+ expect(f.entity).toBe("user-claude");
58
+ });
59
+ });
60
+
61
+ describe("siteSummary", () => {
62
+ test("pluralizes and omits empty categories", () => {
63
+ const base: AgentConfigSite = {
64
+ id: "x", scope: "user", runtime: "claude", root: "/", sources: [],
65
+ instructions: [{ path: "/a", content: "x", bytes: 1 }],
66
+ mcpServers: [{ name: "a", transport: "stdio", source: "/c" }],
67
+ skills: [], subagents: [], commands: [], plugins: [], env: {}, settings: {},
68
+ };
69
+ expect(siteSummary(base)).toBe("1 instruction file · 1 MCP server");
70
+ });
71
+
72
+ test("says so when a site carries nothing", () => {
73
+ const empty: AgentConfigSite = {
74
+ id: "x", scope: "user", runtime: "claude", root: "/", sources: [],
75
+ instructions: [], mcpServers: [], skills: [], subagents: [], commands: [], plugins: [], env: {}, settings: {},
76
+ };
77
+ expect(siteSummary(empty)).toBe("no content");
78
+ });
79
+ });
80
+
81
+ describe("stylish output", () => {
82
+ test("leads with the inventory, so a clean machine still reports what is configured", () => {
83
+ writeIn(home, "CLAUDE.md", "be brief");
84
+ const result = run();
85
+ expect(result.output).toContain("Found 1 agent config");
86
+ expect(result.output).toContain("user:");
87
+ expect(result.output).toContain("1 instruction file");
88
+ });
89
+
90
+ test("says plainly when nothing is configured", () => {
91
+ expect(run().output).toContain("No agent configuration found.");
92
+ });
93
+
94
+ test("separates merge-worthy from report-only", () => {
95
+ seedUnpinned();
96
+ const output = run().output;
97
+ expect(output).toContain("Merge-worthy:");
98
+ expect(output).toContain("AGT001");
99
+ });
100
+ });
101
+
102
+ describe("coverage notes", () => {
103
+ test("reports registered projects it did not visit", () => {
104
+ const a = join(project, "a");
105
+ const b = join(project, "b");
106
+ mkdirSync(a, { recursive: true });
107
+ mkdirSync(b, { recursive: true });
108
+ writeIn(home, ".claude.json", JSON.stringify({ projects: { [a]: {}, [b]: {} } }));
109
+ writeIn(home, "CLAUDE.md", "x");
110
+ expect(run().output).toContain("2 more are registered in ~/.claude.json");
111
+ });
112
+
113
+ test("does not count registrations whose directory is gone", () => {
114
+ // A deleted project is not a gap the user can close, so counting it would
115
+ // overstate what the scan missed.
116
+ writeIn(home, ".claude.json", JSON.stringify({ projects: { "/definitely/not/here": {} } }));
117
+ writeIn(home, "CLAUDE.md", "x");
118
+ expect(run().output).not.toContain("registered in ~/.claude.json");
119
+ });
120
+
121
+ test("reports a file it could not parse", () => {
122
+ writeIn(home, ".claude/settings.json", "{ broken");
123
+ writeIn(home, "CLAUDE.md", "x");
124
+ expect(run().output).toContain("could not be parsed");
125
+ });
126
+
127
+ test("names the scopes it was told to skip", () => {
128
+ writeIn(home, "CLAUDE.md", "x");
129
+ expect(run({ scopes: ["user"] }).output).toContain("Scopes not scanned: system, project");
130
+ });
131
+
132
+ test("points at --all-projects when projects were missed", () => {
133
+ writeIn(home, ".claude.json", JSON.stringify({ projects: { [home]: {} } }));
134
+ writeIn(home, "CLAUDE.md", "x");
135
+ expect(run().output).toContain("--all-projects");
136
+ });
137
+
138
+ test("states the breadth when many roots were scanned and none were missed", () => {
139
+ // With --all-projects there is no gap to report, but "5 findings" reads very
140
+ // differently across one project than across sixty-five.
141
+ const a = join(project, "a");
142
+ const b = join(project, "b");
143
+ mkdirSync(a, { recursive: true });
144
+ mkdirSync(b, { recursive: true });
145
+ writeIn(a, "CLAUDE.md", "x");
146
+ const output = run({ projectRoots: [a, b] }).output;
147
+ expect(output).toContain("Scanned 2 project roots");
148
+ expect(output).not.toContain("--all-projects");
149
+ });
150
+ });
151
+
152
+ describe("tier and fail-on", () => {
153
+ test("--tier merge-worthy drops report-only findings", () => {
154
+ seedUnpinned();
155
+ const all = run({ tier: "all" }).findings.length;
156
+ const mw = run({ tier: "merge-worthy" }).findings;
157
+ expect(mw.length).toBeLessThan(all);
158
+ expect(mw.every((f) => f.checkId !== "AGT006")).toBe(true);
159
+ });
160
+
161
+ test("exit code is 0 by default even with findings — the scan is read-only friendly", () => {
162
+ seedUnpinned();
163
+ expect(run().exitCode).toBe(0);
164
+ });
165
+
166
+ test("--fail-on merge-worthy exits 1 when a merge-worthy finding exists", () => {
167
+ seedUnpinned();
168
+ expect(run({ failOn: "merge-worthy" }).exitCode).toBe(1);
169
+ });
170
+
171
+ test("--fail-on merge-worthy exits 0 on a clean machine", () => {
172
+ expect(run({ failOn: "merge-worthy" }).exitCode).toBe(0);
173
+ });
174
+ });
175
+
176
+ describe("formats", () => {
177
+ test("json carries the full inventory alongside the findings", () => {
178
+ seedUnpinned();
179
+ const report = JSON.parse(run({ format: "json" }).output);
180
+ expect(report.subject).toBe("agent-configuration");
181
+ expect(report.tool.version).toBe("1.2.3");
182
+ expect(report.sites[0].id).toBe("user-claude");
183
+ expect(report.sites[0].mcpServers[0].name).toBe("a");
184
+ expect(report.coverage.probed.length).toBeGreaterThan(0);
185
+ });
186
+
187
+ test("json reports env var names, never their values", () => {
188
+ writeIn(home, ".claude/settings.json", JSON.stringify({ env: { SECRET_THING: "hunter2" } }));
189
+ const report = JSON.parse(run({ format: "json" }).output);
190
+ expect(report.sites[0].env).toEqual(["SECRET_THING"]);
191
+ expect(run({ format: "json" }).output).not.toContain("hunter2");
192
+ });
193
+
194
+ test("sarif is valid and links each rule to its docs anchor", () => {
195
+ seedUnpinned();
196
+ const sarif = JSON.parse(run({ format: "sarif" }).output);
197
+ expect(sarif.version).toBe("2.1.0");
198
+ const rule = sarif.runs[0].tool.driver.rules.find((r: { id: string }) => r.id === "AGT001");
199
+ expect(rule.helpUri).toBe("https://intentius.io/chant/lint-rules/audit-rules/#agt001");
200
+ });
201
+
202
+ test("sarif carries the same tier/dimension property bag the repository audit emits (#442, #444)", () => {
203
+ seedUnpinned();
204
+ const sarif = JSON.parse(run({ format: "sarif" }).output) as {
205
+ runs: Array<{
206
+ tool: { driver: { rules: Array<{ id: string; help?: { text: string }; properties?: { category?: string; dimension?: string } }> } };
207
+ results: Array<{ ruleId: string; properties?: { tier?: string } }>;
208
+ }>;
209
+ };
210
+ const one = sarif.runs[0];
211
+ expect(one.results.length).toBeGreaterThan(0);
212
+ for (const r of one.results) expect(r.properties?.tier).toMatch(/^(merge-worthy|report-only)$/);
213
+ expect(one.tool.driver.rules.length).toBeGreaterThan(0);
214
+ for (const rule of one.tool.driver.rules) {
215
+ expect(rule.properties?.category).toMatch(/^(security|correctness|best-practice|efficiency)$/);
216
+ expect(rule.properties?.dimension).toBe(rule.properties?.category);
217
+ expect(rule.help?.text.length).toBeGreaterThan(0);
218
+ }
219
+ });
220
+
221
+ test("sarif validates against the real, vendored SARIF 2.1.0 JSON Schema", () => {
222
+ seedUnpinned();
223
+ const schemaPath = fileURLToPath(new URL("./__fixtures__/schemas/sarif-2.1.0.schema.json", import.meta.url));
224
+ const ajv = new Ajv({ strict: false, allErrors: true });
225
+ const validate = ajv.compile(JSON.parse(readFileSync(schemaPath, "utf-8")) as object);
226
+ const valid = validate(JSON.parse(run({ format: "sarif" }).output));
227
+ if (!valid) throw new Error(ajv.errorsText(validate.errors));
228
+ expect(valid).toBe(true);
229
+ });
230
+
231
+ test("markdown and html render without throwing", () => {
232
+ seedUnpinned();
233
+ expect(run({ format: "markdown" }).output).toContain("# Agent configuration audit");
234
+ expect(run({ format: "html" }).output).toContain("<html");
235
+ });
236
+
237
+ test("markdown is one document — the embedded findings report is a section, not a second H1", () => {
238
+ seedUnpinned();
239
+ const md = run({ format: "markdown" }).output;
240
+ expect(md.split("\n").filter((l) => /^# /.test(l))).toEqual(["# Agent configuration audit"]);
241
+ expect(md).toContain("## Inventory");
242
+ expect(md).toContain("## Findings");
243
+ });
244
+ });
245
+
246
+ describe("--output", () => {
247
+ test("writes the report to a file instead of returning it for stdout", () => {
248
+ writeIn(home, "CLAUDE.md", "x");
249
+ const out = join(project, "report.md");
250
+ const result = run({ format: "markdown", output: out });
251
+ expect(result.wroteTo).toBe(out);
252
+ expect(readFileSync(out, "utf-8")).toContain("Agent configuration audit");
253
+ });
254
+
255
+ test("reports a write failure instead of throwing", () => {
256
+ const result = run({ output: join(project, "no", "such", "dir", "r.txt") });
257
+ expect(result.success).toBe(false);
258
+ expect(result.error).toContain("Failed to write");
259
+ });
260
+ });