@intentius/chant 0.52.1 → 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.
Files changed (65) 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/cli/commands/audit-agents.d.ts +83 -0
  17. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  18. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  19. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  20. package/dist/cli/commands/import-agents.d.ts +64 -0
  21. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  22. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  23. package/dist/cli/handlers/misc.d.ts.map +1 -1
  24. package/dist/cli/main.d.ts.map +1 -1
  25. package/dist/cli/registry.d.ts +15 -0
  26. package/dist/cli/registry.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +11 -0
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/terraform/adopt-state.d.ts +4 -1
  30. package/dist/terraform/adopt-state.d.ts.map +1 -1
  31. package/dist/terraform/bridge.d.ts.map +1 -1
  32. package/dist/terraform/tier-map.d.ts +17 -0
  33. package/dist/terraform/tier-map.d.ts.map +1 -1
  34. package/dist/yaml.d.ts.map +1 -1
  35. package/package.json +6 -1
  36. package/src/agents/checks.test.ts +228 -0
  37. package/src/agents/checks.ts +429 -0
  38. package/src/agents/discover.test.ts +310 -0
  39. package/src/agents/discover.ts +939 -0
  40. package/src/agents/importer.ts +49 -0
  41. package/src/agents/index.ts +29 -0
  42. package/src/agents/types.ts +207 -0
  43. package/src/audit/catalog.ts +90 -1
  44. package/src/audit/report.ts +9 -1
  45. package/src/audit/rules-doc.ts +6 -0
  46. package/src/cli/commands/audit-agents.test.ts +260 -0
  47. package/src/cli/commands/audit-agents.ts +387 -0
  48. package/src/cli/commands/carve-bridge.test.ts +30 -0
  49. package/src/cli/commands/carve-bridge.ts +19 -1
  50. package/src/cli/commands/carve-emit.test.ts +72 -1
  51. package/src/cli/commands/carve-emit.ts +43 -20
  52. package/src/cli/commands/import-agents.test.ts +208 -0
  53. package/src/cli/commands/import-agents.ts +196 -0
  54. package/src/cli/handlers/carve-emit.ts +2 -0
  55. package/src/cli/handlers/misc.ts +111 -0
  56. package/src/cli/main.ts +17 -0
  57. package/src/cli/registry.ts +15 -0
  58. package/src/lexicon.ts +12 -0
  59. package/src/terraform/adopt-state.ts +7 -4
  60. package/src/terraform/aws-resources.test.ts +24 -1
  61. package/src/terraform/bridge.test.ts +12 -0
  62. package/src/terraform/bridge.ts +4 -1
  63. package/src/terraform/tier-map.ts +28 -1
  64. package/src/yaml.test.ts +54 -0
  65. package/src/yaml.ts +24 -3
@@ -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
@@ -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 */
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
 
@@ -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
- /** Is this Terraform type adoptable from state (has a native constructor)? */
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 awsCarveType(tfType) !== undefined;
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 AWS_CARVE_TYPES.map((t) => t.tfType).sort();
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);
@@ -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
- identity.attr && identity.value !== undefined
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
+ }
package/src/yaml.test.ts CHANGED
@@ -484,3 +484,57 @@ describe("parseYAML — sibling keys after a nested block in a sequence item (#1
484
484
  });
485
485
  });
486
486
  });
487
+
488
+ // #2013 — a colon opens a mapping only when whitespace or the end of the line
489
+ // follows it (YAML 1.2 §7.4.2). The item/key disambiguation used to accept a
490
+ // colon anywhere, which turned a bare URL in a sequence into a single-key
491
+ // object and made the CRD-schema check reject a string list the emitter here
492
+ // had written itself.
493
+ describe("parseYAML — a colon without following space is not a mapping (#2013)", () => {
494
+ test("a bare URL in a sequence is a string", () => {
495
+ expect(parseYAML("sourceRepos:\n- https://github.com/INTENTIUS/behold\n")).toEqual({
496
+ sourceRepos: ["https://github.com/INTENTIUS/behold"],
497
+ });
498
+ });
499
+
500
+ test("an AppProject's sourceRepos round-trips through emit and parse", () => {
501
+ const repos = ["https://github.com/INTENTIUS/behold", "ssh://git@github.com:22/INTENTIUS/behold.git"];
502
+ expect(parseYAML(`spec:${emitYAML({ sourceRepos: repos }, 1)}\n`)).toEqual({ spec: { sourceRepos: repos } });
503
+ });
504
+
505
+ test("a real `- key: value` item is still a mapping", () => {
506
+ expect(parseYAML("items:\n- name: x\n url: https://example.com/a\n")).toEqual({
507
+ items: [{ name: "x", url: "https://example.com/a" }],
508
+ });
509
+ });
510
+
511
+ test("a mapping item whose first value is a URL keeps its siblings", () => {
512
+ expect(parseYAML("sources:\n- repoURL: https://github.com/o/r\n path: ./chart\n")).toEqual({
513
+ sources: [{ repoURL: "https://github.com/o/r", path: "./chart" }],
514
+ });
515
+ });
516
+
517
+ test("a quoted URL in a sequence still works", () => {
518
+ expect(parseYAML("sourceRepos:\n- 'https://github.com/o/r'\n- \"https://github.com/o/s\"\n")).toEqual({
519
+ sourceRepos: ["https://github.com/o/r", "https://github.com/o/s"],
520
+ });
521
+ });
522
+
523
+ test("`- key:value` with no space after the colon is a scalar, per the spec", () => {
524
+ expect(parseYAML("items:\n- key:value\n")).toEqual({ items: ["key:value"] });
525
+ });
526
+
527
+ test("`- key:` with nothing after the colon is still a mapping with a null value", () => {
528
+ expect(parseYAML("items:\n- key:\n")).toEqual({ items: [{ key: null }] });
529
+ });
530
+
531
+ test("a URL on a mapping line, not a sequence item, is unaffected", () => {
532
+ expect(parseYAML("url: https://example.com/a\n")).toEqual({ url: "https://example.com/a" });
533
+ });
534
+
535
+ test("a bare URL among mapping-shaped siblings inside one item", () => {
536
+ expect(parseYAML("items:\n- name: x\n args:\n - --endpoint\n - https://example.com/a\n image: nginx\n")).toEqual({
537
+ items: [{ name: "x", args: ["--endpoint", "https://example.com/a"], image: "nginx" }],
538
+ });
539
+ });
540
+ });
package/src/yaml.ts CHANGED
@@ -155,6 +155,27 @@ export interface ParseResult {
155
155
  endIndex: number;
156
156
  }
157
157
 
158
+ /**
159
+ * `key: value` on a line of its own (`KEY_LINE`, capturing the indent) and the
160
+ * same after a sequence item's `- ` (`ITEM_KEY`).
161
+ *
162
+ * The lookahead is the rule both patterns used to miss: a `:` opens a mapping
163
+ * only when what follows it is whitespace or the end of the line, and a plain
164
+ * scalar may carry any other colon (YAML 1.2 §7.4.2). Written as `:\s*`, which
165
+ * matches zero whitespace, EVERY colon opened one — so a bare URL in a
166
+ * sequence,
167
+ *
168
+ * sourceRepos:
169
+ * - https://github.com/INTENTIUS/behold
170
+ *
171
+ * came back as `{ https: "//github.com/INTENTIUS/behold" }`, and the CRD-schema
172
+ * post-synth check (#1372) re-reading emitted YAML reported the string list
173
+ * this file's own emitter had just written as a list of objects (#2013). By the
174
+ * same rule `- key:value` is the scalar `"key:value"`, not a mapping.
175
+ */
176
+ const KEY_LINE = /^(\s*)([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
177
+ const ITEM_KEY = /^([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
178
+
158
179
  /**
159
180
  * Parse a YAML document (or JSON document) into a plain object.
160
181
  *
@@ -274,7 +295,7 @@ export function parseYAMLLines(
274
295
  if (indent < baseIndent) break; // Dedented — done with this block
275
296
  if (indent > baseIndent && startIndex > 0) break; // Unexpected indent
276
297
 
277
- const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
298
+ const keyMatch = line.match(KEY_LINE);
278
299
  if (keyMatch) {
279
300
  const key = keyMatch[2].trim();
280
301
  const inlineValue = keyMatch[3].trim();
@@ -472,7 +493,7 @@ export function parseYAMLArray(
472
493
  const isQuotedScalar =
473
494
  (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
474
495
  (itemValue.startsWith("'") && itemValue.endsWith("'"));
475
- const kvMatch = !isQuotedScalar && itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
496
+ const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
476
497
  if (kvMatch) {
477
498
  const obj: Record<string, unknown> = {};
478
499
  obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
@@ -506,7 +527,7 @@ export function parseYAMLArray(
506
527
  const ni = nextLine.search(/\S/);
507
528
  if (ni < nextIndent) break;
508
529
  if (ni > nextIndent) break; // belongs to a nested block already consumed
509
- const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
530
+ const nextKV = nextLine.match(KEY_LINE);
510
531
  if (nextKV) {
511
532
  const nextVal = nextKV[3].trim();
512
533
  obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);