Skip to content

Commit b8a9cc8

Browse files
anandgupta42claude
andcommitted
fix: replace custom YAML parser with js-yaml + sanitize cost prompts
The custom YAML parser could not handle multi-line array map items (e.g., `custom_patterns` with `name`, `pattern`, `message` fields). This broke `.altimate.yml` config for any user with custom patterns. Replaced with `js-yaml` — battle-tested, 6KB gzipped, handles all YAML features correctly. Also: - Sanitize SQL in cost estimation prompts (backtick escaping) - Remove 200 lines of legacy parser code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1305357 commit b8a9cc8

File tree

4 files changed

+17
-195
lines changed

4 files changed

+17
-195
lines changed

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,18 @@
1717
"dependencies": {
1818
"@actions/core": "^1.10.1",
1919
"@actions/github": "^6.0.0",
20+
"@octokit/graphql": "^8.0.0",
2021
"@octokit/rest": "^21.0.0",
21-
"@octokit/graphql": "^8.0.0"
22+
"js-yaml": "^4.1.1"
2223
},
2324
"devDependencies": {
2425
"@types/bun": "^1.1.0",
26+
"@types/js-yaml": "^4.0.9",
27+
"@typescript-eslint/eslint-plugin": "^8.0.0",
28+
"@typescript-eslint/parser": "^8.0.0",
2529
"esbuild": "^0.24.0",
26-
"typescript": "^5.5.0",
2730
"eslint": "^9.0.0",
2831
"prettier": "^3.3.0",
29-
"@typescript-eslint/eslint-plugin": "^8.0.0",
30-
"@typescript-eslint/parser": "^8.0.0"
32+
"typescript": "^5.5.0"
3133
}
3234
}

src/analysis/cost.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function buildCostPrompt(
110110
);
111111
lines.push(`File: ${filename}`);
112112
lines.push("```sql");
113-
lines.push(content);
113+
lines.push(content.replace(/```/g, "\\`\\`\\`"));
114114
lines.push("```");
115115

116116
return lines.join("\n");

src/config/loader.ts

Lines changed: 6 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFileSync, existsSync } from "node:fs";
22
import { resolve } from "node:path";
33
import * as core from "@actions/core";
4+
import yaml from "js-yaml";
45
import { Severity } from "../analysis/types.js";
56
import type { CommentMode } from "../analysis/types.js";
67
import { DEFAULT_CONFIG } from "./defaults.js";
@@ -10,198 +11,13 @@ import type {
1011
} from "./schema.js";
1112

1213
/**
13-
* Minimal YAML parser — handles the flat/nested key-value structures we need
14-
* without pulling in a full YAML library. Supports:
15-
* - Scalars (strings, numbers, booleans)
16-
* - Nested objects via indentation
17-
* - Arrays with `- item` syntax
18-
* - Comments with `#`
19-
*
20-
* For production use this should be replaced with `yaml` or `js-yaml`, but
21-
* keeping zero runtime deps for the action is preferable.
14+
* Parse YAML text into a plain object using js-yaml.
2215
*/
2316
function parseYAML(text: string): Record<string, unknown> {
24-
const rawLines = text.split("\n");
25-
const root: Record<string, unknown> = {};
26-
27-
// Stack tracks nested objects: { indent, obj, key (if this obj is a value under a key) }
28-
const stack: Array<{
29-
indent: number;
30-
obj: Record<string, unknown>;
31-
}> = [{ indent: -2, obj: root }];
32-
33-
// Track current array context: which parent obj, which key, and at what indent
34-
let arrayCtx: {
35-
parent: Record<string, unknown>;
36-
key: string;
37-
indent: number;
38-
} | null = null;
39-
40-
for (const rawLine of rawLines) {
41-
// Strip inline comments (not inside quotes — good enough for config)
42-
let line = rawLine;
43-
if (!line.trimStart().startsWith("#")) {
44-
const commentIdx = line.indexOf(" #");
45-
if (commentIdx >= 0) {
46-
line = line.slice(0, commentIdx);
47-
}
48-
}
49-
50-
// Skip blank lines and full-line comments
51-
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
52-
53-
const indent = line.length - line.trimStart().length;
54-
const trimmed = line.trimStart();
55-
56-
// If indent drops below current array context, clear it
57-
if (arrayCtx && indent < arrayCtx.indent) {
58-
arrayCtx = null;
59-
}
60-
61-
// ── Array item: "- value" or "- key: value, key: value" ──
62-
if (trimmed.startsWith("- ")) {
63-
const itemContent = trimmed.slice(2).trim();
64-
65-
// If we have no array context yet, we need to create one.
66-
// The array should be the value of the last key set on the parent at
67-
// the indentation level just above this one.
68-
if (!arrayCtx || indent !== arrayCtx.indent) {
69-
// Pop stack so the top is the correct parent for this indent
70-
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
71-
stack.pop();
72-
}
73-
74-
// Strategy: look at the current top of stack. If it's an empty object
75-
// placeholder (created by "key:" with no value), then the PARENT of
76-
// this stack entry owns the key. Pop one more and convert that key's
77-
// value from {} to [].
78-
let found = false;
79-
const topEntry = stack[stack.length - 1];
80-
81-
if (
82-
stack.length > 1 &&
83-
Object.keys(topEntry.obj).length === 0
84-
) {
85-
// This empty object is the value of some key in the parent
86-
const parentObj = stack[stack.length - 2].obj;
87-
const keys = Object.keys(parentObj);
88-
for (let k = keys.length - 1; k >= 0; k--) {
89-
if (parentObj[keys[k]] === topEntry.obj) {
90-
parentObj[keys[k]] = [];
91-
// Pop the empty obj from the stack since it's now an array
92-
stack.pop();
93-
arrayCtx = { parent: parentObj, key: keys[k], indent };
94-
found = true;
95-
break;
96-
}
97-
}
98-
}
99-
100-
if (!found) {
101-
// Look for an empty object placeholder among the top's own keys
102-
const parentObj = topEntry.obj;
103-
const keys = Object.keys(parentObj);
104-
for (let k = keys.length - 1; k >= 0; k--) {
105-
const val = parentObj[keys[k]];
106-
if (
107-
typeof val === "object" &&
108-
val !== null &&
109-
!Array.isArray(val) &&
110-
Object.keys(val).length === 0
111-
) {
112-
parentObj[keys[k]] = [];
113-
arrayCtx = { parent: parentObj, key: keys[k], indent };
114-
found = true;
115-
break;
116-
}
117-
}
118-
}
119-
120-
if (!found) {
121-
// Cannot determine which key this array belongs to — skip
122-
continue;
123-
}
124-
}
125-
126-
const arr = arrayCtx!.parent[arrayCtx!.key] as unknown[];
127-
128-
// Check if it's a map item (has colon with a key-like prefix)
129-
const mapMatch = itemContent.match(
130-
/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/,
131-
);
132-
if (mapMatch) {
133-
const mapObj: Record<string, unknown> = {};
134-
parseInlineMap(itemContent, mapObj);
135-
arr.push(mapObj);
136-
} else {
137-
arr.push(parseScalar(itemContent));
138-
}
139-
continue;
140-
}
141-
142-
// ── Key-value pair: "key: value" or "key:" ──
143-
const kvMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
144-
if (kvMatch) {
145-
const key = kvMatch[1];
146-
const rawValue = kvMatch[2].trim();
147-
148-
// Clear array context when we encounter a non-array line
149-
arrayCtx = null;
150-
151-
// Pop stack to find the right parent for this indent level
152-
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
153-
stack.pop();
154-
}
155-
const parent = stack[stack.length - 1].obj;
156-
157-
if (rawValue === "" || rawValue === "|" || rawValue === ">") {
158-
// Nested object or block scalar — create object placeholder
159-
// (may be converted to array if "- " items follow)
160-
const child: Record<string, unknown> = {};
161-
parent[key] = child;
162-
stack.push({ indent, obj: child });
163-
} else if (rawValue.startsWith("[") || rawValue.startsWith("{")) {
164-
// Inline JSON array or object
165-
try {
166-
parent[key] = JSON.parse(rawValue);
167-
} catch {
168-
parent[key] = rawValue;
169-
}
170-
} else {
171-
parent[key] = parseScalar(rawValue);
172-
}
173-
continue;
174-
}
175-
}
176-
177-
return root;
178-
}
179-
180-
function parseInlineMap(text: string, target: Record<string, unknown>): void {
181-
// Parse "key: value" pairs separated by newlines or within a single line
182-
const parts = text.split(/,\s*/);
183-
for (const part of parts) {
184-
const m = part.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
185-
if (m) {
186-
target[m[1]] = parseScalar(m[2].trim());
187-
}
188-
}
189-
}
190-
191-
function parseScalar(value: string): string | number | boolean {
192-
if (value === "true") return true;
193-
if (value === "false") return false;
194-
if (value === "null" || value === "~") return "";
195-
// Strip surrounding quotes
196-
if (
197-
(value.startsWith('"') && value.endsWith('"')) ||
198-
(value.startsWith("'") && value.endsWith("'"))
199-
) {
200-
return value.slice(1, -1);
201-
}
202-
const num = Number(value);
203-
if (!isNaN(num) && value !== "") return num;
204-
return value;
17+
const result = yaml.load(text);
18+
if (result === null || result === undefined) return {};
19+
if (typeof result !== "object" || Array.isArray(result)) return {};
20+
return result as Record<string, unknown>;
20521
}
20622

20723
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)