@intentius/chant 0.52.0 → 0.52.2
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/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/lexicon.d.ts +21 -9
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +2 -3
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/ownership.d.ts +5 -4
- package/dist/ownership.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +4 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +17 -0
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +19 -1
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +43 -20
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +2 -0
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/registry.ts +15 -0
- package/src/discovery/fold-import.test.ts +102 -0
- package/src/discovery/fold-import.ts +54 -4
- package/src/lexicon.ts +22 -9
- package/src/lifecycle/assert-live.ts +2 -3
- package/src/ownership.ts +5 -4
- package/src/terraform/adopt-state.ts +7 -4
- package/src/terraform/aws-resources.test.ts +24 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/tier-map.ts +28 -1
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { loadChantConfig } from "../../config";
|
|
|
5
5
|
import { resolve } from "path";
|
|
6
6
|
import { auditCommand, printAuditResult, type AuditFormat, type AuditTier, type AuditFailOn } from "../commands/audit";
|
|
7
7
|
import type { ReportTheme } from "../../audit/report-html";
|
|
8
|
+
import { AGENT_RUNTIMES, AGENT_SCOPES, type AgentRuntime, type AgentScope } from "../../agents/types";
|
|
9
|
+
import { registeredProjectRoots } from "../../agents/discover";
|
|
10
|
+
import { homedir } from "os";
|
|
8
11
|
import type { ResourceSelector } from "../../lexicon";
|
|
9
12
|
import { formatError, formatSuccess, formatWarning, formatBold } from "../format";
|
|
10
13
|
import type { CommandContext } from "../registry";
|
|
@@ -23,6 +26,44 @@ const AUDIT_FORMATS: AuditFormat[] = ["stylish", "json", "sarif", "markdown", "h
|
|
|
23
26
|
const AUDIT_TIERS: AuditTier[] = ["merge-worthy", "all"];
|
|
24
27
|
const AUDIT_FAIL_ON: AuditFailOn[] = ["merge-worthy", "warning", "none"];
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Parse `--scope system,user,project`. Returns `undefined` (meaning "all") when
|
|
31
|
+
* the flag is absent, or an Error the caller reports — an unrecognized scope is
|
|
32
|
+
* rejected rather than ignored, since silently scanning more than the user
|
|
33
|
+
* asked for is exactly the surprise this command exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
function parseScopes(raw: string | undefined): readonly AgentScope[] | undefined | Error {
|
|
36
|
+
if (!raw) return undefined;
|
|
37
|
+
const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
38
|
+
const bad = parts.filter((p) => !AGENT_SCOPES.includes(p as AgentScope));
|
|
39
|
+
if (bad.length > 0) return new Error(`Invalid --scope: ${bad.join(", ")}. Expected one or more of ${AGENT_SCOPES.join(", ")}.`);
|
|
40
|
+
return parts.length > 0 ? (parts as AgentScope[]) : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse `--runtime claude,codex,...`. Same contract as {@link parseScopes}. */
|
|
44
|
+
function parseRuntimes(raw: string | undefined): readonly AgentRuntime[] | undefined | Error {
|
|
45
|
+
if (!raw) return undefined;
|
|
46
|
+
const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
const bad = parts.filter((p) => !AGENT_RUNTIMES.includes(p as AgentRuntime));
|
|
48
|
+
if (bad.length > 0) return new Error(`Invalid --runtime: ${bad.join(", ")}. Expected one or more of ${AGENT_RUNTIMES.join(", ")}.`);
|
|
49
|
+
return parts.length > 0 ? (parts as AgentRuntime[]) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Project roots for an `--agents` run.
|
|
54
|
+
*
|
|
55
|
+
* Default: the path argument alone. With `--all-projects`, every project root
|
|
56
|
+
* the harness has registered, *unioned* with the path argument rather than
|
|
57
|
+
* replacing it — a flag that silently discarded an argument the user typed
|
|
58
|
+
* would be the wrong kind of surprise, and a cwd that isn't registered yet is
|
|
59
|
+
* still a project the user is plainly interested in.
|
|
60
|
+
*/
|
|
61
|
+
function resolveProjectRoots(args: { path: string; allProjects?: boolean }): string[] {
|
|
62
|
+
const named = resolve(args.path);
|
|
63
|
+
if (!args.allProjects) return [named];
|
|
64
|
+
return [...new Set([...registeredProjectRoots(homedir()), named])].sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
26
67
|
export async function runAudit(ctx: CommandContext): Promise<number> {
|
|
27
68
|
const { args } = ctx;
|
|
28
69
|
|
|
@@ -42,6 +83,37 @@ export async function runAudit(ctx: CommandContext): Promise<number> {
|
|
|
42
83
|
return 1;
|
|
43
84
|
}
|
|
44
85
|
|
|
86
|
+
// `--agents` switches the subject from a repo to this machine's agent
|
|
87
|
+
// configuration. Same formats, tiers, and fail-on policy — a different thing
|
|
88
|
+
// being audited, not a different report.
|
|
89
|
+
if (args.agents) {
|
|
90
|
+
const scopes = parseScopes(args.scope);
|
|
91
|
+
if (scopes instanceof Error) {
|
|
92
|
+
console.error(formatError({ message: scopes.message }));
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const runtimes = parseRuntimes(args.runtime);
|
|
96
|
+
if (runtimes instanceof Error) {
|
|
97
|
+
console.error(formatError({ message: runtimes.message }));
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const { auditAgentsCommand } = await import("../commands/audit-agents");
|
|
101
|
+
const result = auditAgentsCommand({
|
|
102
|
+
format,
|
|
103
|
+
tier,
|
|
104
|
+
failOn,
|
|
105
|
+
scopes,
|
|
106
|
+
runtimes,
|
|
107
|
+
projectRoots: resolveProjectRoots(args),
|
|
108
|
+
output: args.output,
|
|
109
|
+
toolVersion: CHANT_VERSION,
|
|
110
|
+
});
|
|
111
|
+
if (result.error) console.error(formatError({ message: result.error }));
|
|
112
|
+
else if (result.wroteTo) console.log(formatSuccess(`Report written to ${result.wroteTo}`));
|
|
113
|
+
else console.log(result.output);
|
|
114
|
+
return result.exitCode;
|
|
115
|
+
}
|
|
116
|
+
|
|
45
117
|
// HTML report customization: --template <file> (full override) + --theme <file> (JSON knobs).
|
|
46
118
|
let template: string | undefined;
|
|
47
119
|
let theme: ReportTheme | undefined;
|
|
@@ -171,6 +243,45 @@ export async function runDescribe(ctx: CommandContext): Promise<number> {
|
|
|
171
243
|
export async function runImport(ctx: CommandContext): Promise<number> {
|
|
172
244
|
const { args } = ctx;
|
|
173
245
|
|
|
246
|
+
// `--agents` (#chant audit --agents' other half): the source is this
|
|
247
|
+
// machine's agent configuration rather than a template file, so there is
|
|
248
|
+
// nothing to detect — the flag names the subject, like `--kustomize` below
|
|
249
|
+
// names the lexicon.
|
|
250
|
+
if (args.agents) {
|
|
251
|
+
const scopes = parseScopes(args.scope);
|
|
252
|
+
if (scopes instanceof Error) {
|
|
253
|
+
console.error(formatError({ message: scopes.message }));
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
const runtimes = parseRuntimes(args.runtime);
|
|
257
|
+
if (runtimes instanceof Error) {
|
|
258
|
+
console.error(formatError({ message: runtimes.message }));
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
const { importAgentsCommand } = await import("../commands/import-agents");
|
|
262
|
+
const result = await importAgentsCommand({
|
|
263
|
+
lexicon: args.lexicon,
|
|
264
|
+
output: args.output,
|
|
265
|
+
force: args.force,
|
|
266
|
+
scopes,
|
|
267
|
+
runtimes,
|
|
268
|
+
projectRoots: resolveProjectRoots(args),
|
|
269
|
+
});
|
|
270
|
+
for (const warning of result.warnings) console.warn(formatWarning({ message: warning }));
|
|
271
|
+
if (!result.success) {
|
|
272
|
+
console.error(formatError({ message: result.error ?? "Import failed." }));
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
console.log(
|
|
276
|
+
formatSuccess(
|
|
277
|
+
`Re-expressed ${result.summary.mapped} resource${result.summary.mapped === 1 ? "" : "s"} ` +
|
|
278
|
+
`from ${result.summary.discovered} agent config${result.summary.discovered === 1 ? "" : "s"}:`,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
for (const file of result.generatedFiles) console.log(` ${file}`);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
174
285
|
// `--kustomize <dir>` (#1548): render the overlay and import the output
|
|
175
286
|
// through the k8s template parser — the flag NAMES the lexicon, so no JSON
|
|
176
287
|
// detection. `kustomize build`, falling back to kubectl's vendored
|
package/src/cli/main.ts
CHANGED
|
@@ -48,6 +48,8 @@ import type { LexiconPlugin } from "../lexicon";
|
|
|
48
48
|
*/
|
|
49
49
|
const BOOLEAN_FLAGS = new Set([
|
|
50
50
|
"--help",
|
|
51
|
+
"--agents",
|
|
52
|
+
"--all-projects",
|
|
51
53
|
"--force",
|
|
52
54
|
"--fix",
|
|
53
55
|
"--watch",
|
|
@@ -254,6 +256,14 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
254
256
|
result.env = args[++i];
|
|
255
257
|
} else if (arg === "--tier") {
|
|
256
258
|
result.tier = args[++i];
|
|
259
|
+
} else if (arg === "--agents") {
|
|
260
|
+
result.agents = true;
|
|
261
|
+
} else if (arg === "--scope") {
|
|
262
|
+
result.scope = args[++i];
|
|
263
|
+
} else if (arg === "--all-projects") {
|
|
264
|
+
result.allProjects = true;
|
|
265
|
+
} else if (arg === "--runtime") {
|
|
266
|
+
result.runtime = args[++i];
|
|
257
267
|
} else if (arg === "--fail-on") {
|
|
258
268
|
result.failOn = args[++i];
|
|
259
269
|
} else if (arg === "--theme") {
|
|
@@ -438,10 +448,17 @@ Commands:
|
|
|
438
448
|
bundle tree, otherwise JSON path→content on stdout)
|
|
439
449
|
vendor Pull pinned, checksummed patterns into your repo
|
|
440
450
|
import Import external template into TypeScript
|
|
451
|
+
(--agents re-expresses this machine's agent config as
|
|
452
|
+
chant code instead of reading a template file)
|
|
441
453
|
audit [path|url] Audit a repo's CI YAML for security issues
|
|
442
454
|
(--format stylish|json|sarif|markdown|html, -o <file>,
|
|
443
455
|
--tier merge-worthy|all, --fail-on merge-worthy|warning|none,
|
|
444
456
|
--template <file> / --theme <file> for the html report)
|
|
457
|
+
--agents audits this machine's agent configuration —
|
|
458
|
+
instruction files, MCP servers, skills, plugins,
|
|
459
|
+
permissions — instead of a repository
|
|
460
|
+
(--scope system,user,project, --runtime claude,codex,...,
|
|
461
|
+
--all-projects)
|
|
445
462
|
migrate <file> Translate a workflow between lexicons
|
|
446
463
|
(default: --from github --to gitlab)
|
|
447
464
|
carve advise Read-only Terraform peelability advisor: rank which
|
package/src/cli/registry.ts
CHANGED
|
@@ -158,6 +158,21 @@ export interface ParsedArgs {
|
|
|
158
158
|
includeDependents?: boolean;
|
|
159
159
|
/** `chant audit --tier merge-worthy|all` */
|
|
160
160
|
tier?: string;
|
|
161
|
+
/**
|
|
162
|
+
* `chant audit --agents` / `chant import --agents` — switch the subject from
|
|
163
|
+
* a repo's pipeline YAML to the agent configuration installed on this
|
|
164
|
+
* machine (skills, MCP servers, instruction files, permissions).
|
|
165
|
+
*/
|
|
166
|
+
agents?: boolean;
|
|
167
|
+
/** `--agents --scope system,user,project` — which scopes to scan (default all). */
|
|
168
|
+
scope?: string;
|
|
169
|
+
/** `--agents --runtime claude,codex,...` — which agent harnesses to scan (default all). */
|
|
170
|
+
runtime?: string;
|
|
171
|
+
/**
|
|
172
|
+
* `--agents --all-projects` — scan every project root the harness has
|
|
173
|
+
* registered in `~/.claude.json`, not just the path argument.
|
|
174
|
+
*/
|
|
175
|
+
allProjects?: boolean;
|
|
161
176
|
/** `chant audit --fail-on merge-worthy|warning|none` */
|
|
162
177
|
failOn?: string;
|
|
163
178
|
/** `chant audit --theme <file>` — JSON theme knobs for the HTML report */
|
|
@@ -2015,6 +2015,108 @@ describe("tryFoldFile — active lexicon package exports (#1063)", () => {
|
|
|
2015
2015
|
});
|
|
2016
2016
|
});
|
|
2017
2017
|
|
|
2018
|
+
describe("tryFoldFile — sandbox trust boundary, subpath import of an active lexicon package (chant #1995)", () => {
|
|
2019
|
+
let testDir: string;
|
|
2020
|
+
let seq = 0;
|
|
2021
|
+
|
|
2022
|
+
beforeEach(async () => {
|
|
2023
|
+
testDir = join(tmpdir(), `chant-fold-import-lexsubpath-test-${Date.now()}-${Math.random()}`);
|
|
2024
|
+
await mkdir(testDir, { recursive: true });
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
afterEach(async () => {
|
|
2028
|
+
await rm(testDir, { recursive: true, force: true });
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* Install a package into `<testDir>/node_modules/<name>` shaped like a real
|
|
2033
|
+
* lexicon package: a barrel at "." AND a `./generated/index` subpath — the
|
|
2034
|
+
* exact shape `examples/cc-azure-canonical/src/cc-cluster/cluster.ts`
|
|
2035
|
+
* imports `AksCluster` through (`@intentius/chant-lexicon-azure/generated/index`).
|
|
2036
|
+
* The subpath module exports a real resource CLASS with the same runtime
|
|
2037
|
+
* shape `createResource` (chant-core's own factory) produces — plain,
|
|
2038
|
+
* dependency-free JS using `Symbol.for("chant.declarable")` directly (same
|
|
2039
|
+
* global-registry trick `../declarable.ts`'s own `DECLARABLE_MARKER` uses,
|
|
2040
|
+
* and the same convention `installLexiconPackage`'s `LEXICON_SOURCE` above
|
|
2041
|
+
* follows), rather than importing chant-core's runtime module: a real
|
|
2042
|
+
* package's OWN `.js` file is loaded by a genuine `import()`, not run
|
|
2043
|
+
* through this repo's TS loader, so a bare relative import of `runtime.ts`
|
|
2044
|
+
* from inside it would need an extension Node's ESM resolver never gets —
|
|
2045
|
+
* self-contained avoids that entirely and is just as faithful a stand-in.
|
|
2046
|
+
*/
|
|
2047
|
+
async function installSubpathLexiconPackage(): Promise<{ lexicon: string; specifier: string }> {
|
|
2048
|
+
const lexicon = `fold1995x${seq++}${Date.now().toString(36)}`;
|
|
2049
|
+
const specifier = `@intentius/chant-lexicon-${lexicon}`;
|
|
2050
|
+
const dir = join(testDir, "node_modules", specifier);
|
|
2051
|
+
await mkdir(join(dir, "generated"), { recursive: true });
|
|
2052
|
+
await writeFile(
|
|
2053
|
+
join(dir, "package.json"),
|
|
2054
|
+
JSON.stringify({
|
|
2055
|
+
name: specifier,
|
|
2056
|
+
version: "0.0.0",
|
|
2057
|
+
type: "module",
|
|
2058
|
+
exports: { ".": "./index.js", "./generated/index": "./generated/index.js" },
|
|
2059
|
+
}),
|
|
2060
|
+
);
|
|
2061
|
+
await writeFile(join(dir, "index.js"), `export * from "./generated/index.js";`);
|
|
2062
|
+
await writeFile(
|
|
2063
|
+
join(dir, "generated", "index.js"),
|
|
2064
|
+
`
|
|
2065
|
+
const DECLARABLE_MARKER = Symbol.for("chant.declarable");
|
|
2066
|
+
export function Widget(props) {
|
|
2067
|
+
this[DECLARABLE_MARKER] = true;
|
|
2068
|
+
this.lexicon = ${JSON.stringify(lexicon)};
|
|
2069
|
+
this.entityType = "Test::Widget";
|
|
2070
|
+
this.kind = "resource";
|
|
2071
|
+
this.props = props ?? {};
|
|
2072
|
+
}
|
|
2073
|
+
`,
|
|
2074
|
+
);
|
|
2075
|
+
return { lexicon, specifier };
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
test("a subpath import of a declared-active lexicon package is trusted, and folds under --sandbox", async () => {
|
|
2079
|
+
const { lexicon, specifier } = await installSubpathLexiconPackage();
|
|
2080
|
+
const file = join(testDir, "main.ts");
|
|
2081
|
+
await writeFile(
|
|
2082
|
+
file,
|
|
2083
|
+
`
|
|
2084
|
+
import { Widget } from ${JSON.stringify(`${specifier}/generated/index`)};
|
|
2085
|
+
export const widget = new Widget({ name: "test" });
|
|
2086
|
+
`,
|
|
2087
|
+
);
|
|
2088
|
+
|
|
2089
|
+
const sandboxed = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon], true));
|
|
2090
|
+
|
|
2091
|
+
expect(sandboxed.ok, !sandboxed.ok ? sandboxed.reason : "").toBe(true);
|
|
2092
|
+
if (!sandboxed.ok) return;
|
|
2093
|
+
const [, entity] = sandboxed.entities[0];
|
|
2094
|
+
expect(isDeclarable(entity)).toBe(true);
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
test("a subpath import of a lexicon package NOT active for this build stays refused under --sandbox", async () => {
|
|
2098
|
+
// chant #1995's own boundary — the fix must not widen trust beyond the
|
|
2099
|
+
// build's declared-active set: an installed, importable package whose
|
|
2100
|
+
// root the build never listed is still untrusted, subpath or not.
|
|
2101
|
+
const { specifier } = await installSubpathLexiconPackage();
|
|
2102
|
+
const file = join(testDir, "main.ts");
|
|
2103
|
+
await writeFile(
|
|
2104
|
+
file,
|
|
2105
|
+
`
|
|
2106
|
+
import { Widget } from ${JSON.stringify(`${specifier}/generated/index`)};
|
|
2107
|
+
export const widget = new Widget({ name: "test" });
|
|
2108
|
+
`,
|
|
2109
|
+
);
|
|
2110
|
+
|
|
2111
|
+
const sandboxed = await tryFoldFile(file, [], createFoldSession([], undefined, ["aws"], true));
|
|
2112
|
+
|
|
2113
|
+
expect(sandboxed.ok).toBe(false);
|
|
2114
|
+
if (sandboxed.ok) return;
|
|
2115
|
+
expect(sandboxed.reason).toContain("--sandbox");
|
|
2116
|
+
expect(sandboxed.reason).toContain(`constructor "Widget"`);
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2018
2120
|
/**
|
|
2019
2121
|
* chant #1169 — a `new Type(...)` used as a VALUE.
|
|
2020
2122
|
*
|
|
@@ -1078,11 +1078,46 @@ function paramsModulePath(): string | null {
|
|
|
1078
1078
|
* - **Lexicons of THIS build only.** Not "any `@intentius/chant-lexicon-*`
|
|
1079
1079
|
* package on disk", and emphatically not "any bare specifier". A lexicon
|
|
1080
1080
|
* the build did not load is as out of scope as `node:fs`.
|
|
1081
|
+
*
|
|
1082
|
+
* chant #1995 — the "no subpaths" restriction above stays as documented for
|
|
1083
|
+
* THIS function's caller ({@link resolveActiveLexiconExport}, #1063's
|
|
1084
|
+
* data-export resolution): every lexicon's public data surface is reachable
|
|
1085
|
+
* from the barrel, so a subpath buys that caller nothing and would cost the
|
|
1086
|
+
* same cold-resolution risk. {@link isTrustedExecutableBinding} (#1093) asks a
|
|
1087
|
+
* different question — not "does this specifier name a reachable data
|
|
1088
|
+
* export" but "has the CLI already loaded this code" — and a project file
|
|
1089
|
+
* reaching a lexicon's generated module by subpath instead of through the
|
|
1090
|
+
* barrel is code the CLI already imported and executed either way, so that
|
|
1091
|
+
* caller resolves a subpath's package root separately, via
|
|
1092
|
+
* {@link bareSpecifierPackageRoot}, rather than widening this function.
|
|
1081
1093
|
*/
|
|
1082
1094
|
function activeLexiconPackage(specifier: string, lexiconPackages: ReadonlySet<string>): string | undefined {
|
|
1083
1095
|
return lexiconPackages.has(specifier) ? specifier : undefined;
|
|
1084
1096
|
}
|
|
1085
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* chant #1995 — the package-ROOT portion of a bare subpath specifier
|
|
1100
|
+
* (`@intentius/chant-lexicon-azure/generated/index` ->
|
|
1101
|
+
* `@intentius/chant-lexicon-azure`), or `undefined` when `specifier` is a
|
|
1102
|
+
* relative/absolute path, is already a bare root with no subpath, or is
|
|
1103
|
+
* malformed (a lone `@scope` with nothing after it).
|
|
1104
|
+
*
|
|
1105
|
+
* Text only, no resolution — the same discipline {@link activeLexiconPackage}
|
|
1106
|
+
* documents and for the identical reason: resolving an arbitrary bare
|
|
1107
|
+
* specifier to find out what package it belongs to is the pathological cold
|
|
1108
|
+
* `require.resolve` chant#1020 measured. A scoped package (`@scope/name`)
|
|
1109
|
+
* reserves its first two `/`-separated segments for the root; an unscoped
|
|
1110
|
+
* package (`name`) reserves one.
|
|
1111
|
+
*/
|
|
1112
|
+
function bareSpecifierPackageRoot(specifier: string): string | undefined {
|
|
1113
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) return undefined;
|
|
1114
|
+
const segments = specifier.split("/");
|
|
1115
|
+
if (specifier.startsWith("@")) {
|
|
1116
|
+
return segments.length > 2 ? `${segments[0]}/${segments[1]}` : undefined;
|
|
1117
|
+
}
|
|
1118
|
+
return segments.length > 1 ? segments[0] : undefined;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1086
1121
|
/**
|
|
1087
1122
|
* chant #1063 — resolve one named import binding against an active lexicon
|
|
1088
1123
|
* package's REAL exports, for {@link buildExternals}.
|
|
@@ -2706,10 +2741,19 @@ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boo
|
|
|
2706
2741
|
* in the CLI's own process when the #1045 sandbox is active. Exactly two
|
|
2707
2742
|
* arms, and neither of them trusts text the project controls:
|
|
2708
2743
|
*
|
|
2709
|
-
* 1. **An ACTIVE lexicon package of this build** —
|
|
2710
|
-
* {@link FoldSession.lexiconPackages}, a
|
|
2711
|
-
* lexicon names the BUILD resolved and
|
|
2712
|
-
* (../cli/plugins.ts), not from anything
|
|
2744
|
+
* 1. **An ACTIVE lexicon package of this build, root OR subpath** — the
|
|
2745
|
+
* exact specifier matched against {@link FoldSession.lexiconPackages}, a
|
|
2746
|
+
* closed set built from the lexicon names the BUILD resolved and
|
|
2747
|
+
* `loadPlugins` already imported (../cli/plugins.ts), not from anything
|
|
2748
|
+
* the file under fold says; OR (chant #1995) a subpath of one of those
|
|
2749
|
+
* same packages (`@intentius/chant-lexicon-azure/generated/index`), whose
|
|
2750
|
+
* package ROOT is extracted from the specifier TEXT
|
|
2751
|
+
* ({@link bareSpecifierPackageRoot}, no resolution) and checked against
|
|
2752
|
+
* the identical set. A subpath is trusted because the code it names
|
|
2753
|
+
* belongs to a package the CLI already imported and executed to load that
|
|
2754
|
+
* lexicon's serializers/lint rules — reaching one of that package's own
|
|
2755
|
+
* modules by subpath instead of through the barrel names code the process
|
|
2756
|
+
* already trusts, not code the project controls.
|
|
2713
2757
|
* 2. **chant-core's own executing tree** — the specifier is RESOLVED and the
|
|
2714
2758
|
* resulting path checked against {@link chantCoreRoot}. A text match is
|
|
2715
2759
|
* not enough here: `@intentius/chant` and `@intentius/chant-lexicon-evil`
|
|
@@ -2738,6 +2782,12 @@ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boo
|
|
|
2738
2782
|
*/
|
|
2739
2783
|
function isTrustedExecutableBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
|
|
2740
2784
|
if (activeLexiconPackage(binding.specifier, ctx.lexiconPackages) !== undefined) return true;
|
|
2785
|
+
// chant #1995 — arm 1's subpath case: same allowlist, matched against the
|
|
2786
|
+
// specifier's package ROOT (text only, see bareSpecifierPackageRoot) rather
|
|
2787
|
+
// than the specifier itself, so `<activeLexiconPkg>/generated/index` is
|
|
2788
|
+
// trusted exactly like the bare package import already is.
|
|
2789
|
+
const subpathRoot = bareSpecifierPackageRoot(binding.specifier);
|
|
2790
|
+
if (subpathRoot !== undefined && activeLexiconPackage(subpathRoot, ctx.lexiconPackages) !== undefined) return true;
|
|
2741
2791
|
// Only a chant-shaped or project-relative specifier is worth resolving; any
|
|
2742
2792
|
// other bare specifier is untrusted by definition, and resolving it to find
|
|
2743
2793
|
// that out would cost the pathological cold `require.resolve` (chant#1020).
|
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 { AgentConfigImporter } from "./agents/importer";
|
|
8
9
|
import type { ArtifactIntegrity } from "./lexicon-integrity";
|
|
9
10
|
import type { OkfFile } from "./okf";
|
|
10
11
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
|
|
@@ -714,6 +715,17 @@ export interface LexiconPlugin {
|
|
|
714
715
|
/** Return a generator for converting IR to TypeScript */
|
|
715
716
|
templateGenerator?(): TypeScriptGenerator;
|
|
716
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Re-express local agent configuration (skills, MCP servers, instruction
|
|
720
|
+
* files) discovered by `chant audit --agents` as this lexicon's resources.
|
|
721
|
+
*
|
|
722
|
+
* Implement this when the lexicon has types that model an agent's workload —
|
|
723
|
+
* fountain's `Agent`/`Environment` are the motivating case. Core does the
|
|
724
|
+
* harness-neutral discovery; the mapping onto concrete resource types is the
|
|
725
|
+
* lexicon's call, for the same reason `templateParser` is.
|
|
726
|
+
*/
|
|
727
|
+
agentConfigImporter?(): AgentConfigImporter;
|
|
728
|
+
|
|
717
729
|
/** Return skills provided by this lexicon */
|
|
718
730
|
skills?(): SkillDefinition[];
|
|
719
731
|
|
|
@@ -1163,10 +1175,10 @@ export interface LexiconPlugin {
|
|
|
1163
1175
|
* where the wrong delete gets proposed.
|
|
1164
1176
|
*
|
|
1165
1177
|
* Declared per read path, because the answer differs by path. aws stamps tags
|
|
1166
|
-
* at synthesis and reads them on the deep observation and on
|
|
1167
|
-
* while its `describeResources` is sourced from
|
|
1168
|
-
* which returns no tags — so
|
|
1169
|
-
*
|
|
1178
|
+
* at synthesis and reads them per resource on the deep observation and on
|
|
1179
|
+
* live export, while its `describeResources` is sourced from
|
|
1180
|
+
* `describe-stack-resources`, which returns no per-resource tags — so that
|
|
1181
|
+
* path resolves the verdict from the stack's own tags (#1998).
|
|
1170
1182
|
*
|
|
1171
1183
|
* Absent means no channel anywhere: every verdict must be `unknown`.
|
|
1172
1184
|
*/
|
|
@@ -1417,11 +1429,12 @@ export interface ResourceMetadata {
|
|
|
1417
1429
|
*
|
|
1418
1430
|
* Set only when the live model actually carries the channel and the
|
|
1419
1431
|
* managed-by marker is present — an absent channel means an absent field,
|
|
1420
|
-
* never a guess.
|
|
1421
|
-
* `describe-stack-resources`, which returns no
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
* {@link ownership} verdict on paths that do not
|
|
1432
|
+
* never a guess. The granularity is whatever the channel reads at: aws's thin
|
|
1433
|
+
* read is sourced from `describe-stack-resources`, which returns no
|
|
1434
|
+
* per-resource tags, so it reads the STACK's own tags and every member of a
|
|
1435
|
+
* marked stack carries that stack's identity (#1998). A populated `marker`
|
|
1436
|
+
* does not by itself imply an {@link ownership} verdict on paths that do not
|
|
1437
|
+
* declare a marker channel.
|
|
1425
1438
|
*/
|
|
1426
1439
|
marker?: OwnershipMarker;
|
|
1427
1440
|
/**
|
|
@@ -15,9 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Marker verification is best-effort by the same logic {@link
|
|
17
17
|
* ResourceMetadata.marker}'s own contract states: a lexicon with no marker
|
|
18
|
-
* channel on this read path
|
|
19
|
-
* "
|
|
20
|
-
* "foreign". Enforcing a match whenever the channel exists — a present
|
|
18
|
+
* channel on this read path reports no marker at all, which is not the same
|
|
19
|
+
* claim as "foreign". Enforcing a match whenever the channel exists — a present
|
|
21
20
|
* mismatch, or `ownership: "foreign"` with no marker to show — catches the
|
|
22
21
|
* case the harness cares about (a same-named leftover from another env);
|
|
23
22
|
* an absent channel is passed through unverified rather than making every
|
package/src/ownership.ts
CHANGED
|
@@ -133,10 +133,11 @@ export function readOwnership(
|
|
|
133
133
|
* A read path that can resolve an ownership verdict from the marker (#1348).
|
|
134
134
|
*
|
|
135
135
|
* Per-path rather than per-lexicon because the answer genuinely differs by
|
|
136
|
-
* path
|
|
137
|
-
* on
|
|
138
|
-
* `describe-stack-resources`,
|
|
139
|
-
*
|
|
136
|
+
* path — in granularity as much as in availability. aws stamps tags at
|
|
137
|
+
* synthesis and reads them per resource on the deep observation and on live
|
|
138
|
+
* export; its `describeResources` is sourced from `describe-stack-resources`,
|
|
139
|
+
* which returns no per-resource tags, so it resolves the verdict from the
|
|
140
|
+
* STACK's own tags instead (#1998).
|
|
140
141
|
*/
|
|
141
142
|
export type OwnershipReadPath = "describeResources" | "observeResourcesDeep" | "exportResources";
|
|
142
143
|
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
|
-
AWS_CARVE_TYPES,
|
|
23
22
|
AWS_LEXICON_IMPORT,
|
|
24
23
|
awsCarveType,
|
|
25
24
|
applyAwsMapper,
|
|
26
25
|
applyAwsFold,
|
|
27
26
|
unmappedFoldAttrs,
|
|
28
27
|
} from "./aws-resources";
|
|
28
|
+
import { canCarveEmit, carveEmitTypes } from "./tier-map";
|
|
29
29
|
import type { StateResource } from "./state";
|
|
30
30
|
|
|
31
31
|
/** The core subpath the emitted source reads build parameters from. */
|
|
@@ -70,14 +70,17 @@ export interface AdoptedSource {
|
|
|
70
70
|
folded: FoldedContribution[];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Is this Terraform type adoptable from state (has a native constructor)? The
|
|
75
|
+
* same table gates the live path, so both are the shared `canCarveEmit`.
|
|
76
|
+
*/
|
|
74
77
|
export function canAdoptFromState(tfType: string): boolean {
|
|
75
|
-
return
|
|
78
|
+
return canCarveEmit(tfType);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/** Terraform types that can currently be adopted from state, for user-facing hints. */
|
|
79
82
|
export function supportedStateAdoptionTypes(): string[] {
|
|
80
|
-
return
|
|
83
|
+
return carveEmitTypes();
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
/** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
|
|
3
|
-
import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR } from "./tier-map";
|
|
3
|
+
import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
|
|
4
4
|
import { canAdoptFromState } from "./adopt-state";
|
|
5
5
|
|
|
6
6
|
describe("AWS carve-out table", () => {
|
|
@@ -13,6 +13,29 @@ describe("AWS carve-out table", () => {
|
|
|
13
13
|
for (const t of emitAws) expect(canAdoptFromState(t)).toBe(true);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
test("the emit gate is narrower than the tier map, which also ranks k8s (#2015)", () => {
|
|
17
|
+
// `carve advise` bands kubernetes types; emit has no path for them, so the
|
|
18
|
+
// gate both emit paths share must refuse them.
|
|
19
|
+
expect(TIER_MAP.kubernetes_config_map).toBeDefined();
|
|
20
|
+
expect(canCarveEmit("kubernetes_config_map")).toBe(false);
|
|
21
|
+
expect(canCarveEmit("kubernetes_manifest")).toBe(false);
|
|
22
|
+
expect(canCarveEmit("random_pet")).toBe(false);
|
|
23
|
+
expect(canCarveEmit("aws_s3_bucket")).toBe(true);
|
|
24
|
+
expect(carveEmitTypes()).toEqual(AWS_CARVE_TYPES.map((t) => t.tfType).sort());
|
|
25
|
+
// State adoption and live emit gate on the same table.
|
|
26
|
+
for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("canBridge rejects a dotted identity attribute (#2015)", () => {
|
|
30
|
+
// A data source body is flat `attr = value`; a dotted path is not HCL.
|
|
31
|
+
expect(IDENTITY_ATTR.kubernetes_manifest).toContain(".");
|
|
32
|
+
expect(canBridge("kubernetes_manifest")).toBe(false);
|
|
33
|
+
expect(canBridge("aws_s3_bucket")).toBe(true);
|
|
34
|
+
// No identity entry at all is fine — the bridge writes a TODO comment.
|
|
35
|
+
expect(canBridge("random_pet")).toBe(true);
|
|
36
|
+
for (const t of AWS_CARVE_TYPES) expect(canBridge(t.tfType)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
16
39
|
test("covers the common carve targets across families", () => {
|
|
17
40
|
const types = new Set(AWS_CARVE_TYPES.map((t) => t.tfType));
|
|
18
41
|
for (const t of [
|
|
@@ -51,6 +51,18 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
|
|
|
51
51
|
);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test("never renders a dotted identity attribute into the data body (#2015)", () => {
|
|
55
|
+
const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
56
|
+
const dotted = new Map<string, CarvedIdentity>([
|
|
57
|
+
["aws_s3_bucket.assets", { attr: "manifest.metadata.name", value: "demo-config" }],
|
|
58
|
+
]);
|
|
59
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], dotted);
|
|
60
|
+
|
|
61
|
+
// `manifest.metadata.name = "demo-config"` is not valid HCL.
|
|
62
|
+
expect(plan.dataSources[0].hcl).not.toContain("manifest.metadata.name =");
|
|
63
|
+
expect(plan.dataSources[0].hcl).toContain("# TODO: identify the resource");
|
|
64
|
+
});
|
|
65
|
+
|
|
54
66
|
test("rewrites survivor references to the data source", () => {
|
|
55
67
|
const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
56
68
|
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
|
package/src/terraform/bridge.ts
CHANGED
|
@@ -81,8 +81,11 @@ function referenceRegex(address: string): RegExp {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function dataSourceHcl(type: string, name: string, identity: CarvedIdentity): string {
|
|
84
|
+
// A dotted attr is a path into nested blocks; `a.b.c = v` is not valid HCL,
|
|
85
|
+
// so it falls back to the TODO. `carve bridge` refuses such a type outright.
|
|
86
|
+
const flat = identity.attr !== undefined && !identity.attr.includes(".");
|
|
84
87
|
const body =
|
|
85
|
-
|
|
88
|
+
flat && identity.value !== undefined
|
|
86
89
|
? ` ${identity.attr} = ${JSON.stringify(identity.value)}`
|
|
87
90
|
: ` # TODO: identify the resource (its name/id was interpolated in the source)`;
|
|
88
91
|
return `data ${JSON.stringify(type)} ${JSON.stringify(name)} {\n${body}\n}`;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* null — no known native mapping (unsupported provider/type) → score 0
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { AWS_CARVE_TYPES } from "./aws-resources";
|
|
15
|
+
import { AWS_CARVE_TYPES, awsCarveType } from "./aws-resources";
|
|
16
16
|
|
|
17
17
|
export interface TierInfo {
|
|
18
18
|
tier: 1 | 2 | 3;
|
|
@@ -110,3 +110,30 @@ export const IDENTITY_ATTR: Record<string, string> = {
|
|
|
110
110
|
export function resolveTier(tfType: string): TierInfo | null {
|
|
111
111
|
return TIER_MAP[tfType] ?? null;
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Can `chant carve emit` produce chant source for this type? Narrower than
|
|
116
|
+
* `resolveTier`: the tier map also ranks the kubernetes types, which advise
|
|
117
|
+
* bands but no emit path can adopt (#999, blocked on the provider seam #2016).
|
|
118
|
+
* Both emit paths — `--state` and `--env` — gate on this, so a type either
|
|
119
|
+
* command refuses is refused by the other with the same message.
|
|
120
|
+
*/
|
|
121
|
+
export function canCarveEmit(tfType: string): boolean {
|
|
122
|
+
return awsCarveType(tfType) !== undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The types `canCarveEmit` accepts, sorted, for user-facing hints. */
|
|
126
|
+
export function carveEmitTypes(): string[] {
|
|
127
|
+
return AWS_CARVE_TYPES.map((t) => t.tfType).sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Can `chant carve bridge` render a Terraform `data` source for this type? A
|
|
132
|
+
* dotted `IDENTITY_ATTR` is a path into nested blocks, and a data source body
|
|
133
|
+
* is flat `attr = value` — `manifest.metadata.name = "x"` is not valid HCL.
|
|
134
|
+
* An absent entry is fine: the bridge writes a TODO comment for the identity.
|
|
135
|
+
*/
|
|
136
|
+
export function canBridge(tfType: string): boolean {
|
|
137
|
+
const attr = IDENTITY_ATTR[tfType];
|
|
138
|
+
return attr === undefined || !attr.includes(".");
|
|
139
|
+
}
|