diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 47214b5829..63c3a14943 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -154,7 +154,7 @@ vi.mock("../routes/org-chart-svg.js", () => ({ renderOrgChartPng: vi.fn(async () => Buffer.from("png")), })); -const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js"); +const { companyPortabilityService, parseGitHubSourceUrl, renderYamlBlock, renderFrontmatter } = await import("../services/company-portability.js"); function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { expect(typeof entry).toBe("string"); @@ -457,6 +457,29 @@ describe("company portability", () => { })); }); + it("renders high-volume YAML blocks without overflowing the call stack", () => { + const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`); + + const lines = renderYamlBlock({ tasks }, 0); + + expect(lines[0]).toBe("tasks:"); + expect(lines[1]).toBe(' - "issue-0"'); + expect(lines.at(-1)).toBe(' - "issue-129999"'); + }); + + it("renders high-volume frontmatter arrays without overflowing the call stack", () => { + const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`); + + const rendered = renderFrontmatter({ tasks }); + const lines = rendered.split("\n"); + + expect(lines[0]).toBe("---"); + expect(lines[1]).toBe("tasks:"); + expect(lines[2]).toBe(' - "issue-0"'); + expect(lines[130_001]).toBe(' - "issue-129999"'); + expect(lines[130_002]).toBe("---"); + }); + it("parses canonical GitHub import URLs with explicit ref and package path", () => { expect( parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"), diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index eb7a448132..8818598ec4 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -2340,72 +2340,91 @@ function orderedYamlEntries(value: Record) { return Object.entries(value).sort(([leftKey], [rightKey]) => compareYamlKeys(leftKey, rightKey)); } -function renderYamlBlock(value: unknown, indentLevel: number): string[] { - const indent = " ".repeat(indentLevel); - - if (Array.isArray(value)) { - if (value.length === 0) return [`${indent}[]`]; - const lines: string[] = []; - for (const entry of value) { - const scalar = - entry === null || - typeof entry === "string" || - typeof entry === "boolean" || - typeof entry === "number" || - Array.isArray(entry) && entry.length === 0 || - isEmptyObject(entry); - if (scalar) { - lines.push(`${indent}- ${renderYamlScalar(entry)}`); - continue; - } - lines.push(`${indent}-`); - lines.push(...renderYamlBlock(entry, indentLevel + 1)); - } - return lines; - } - - if (isPlainRecord(value)) { - const entries = orderedYamlEntries(value); - if (entries.length === 0) return [`${indent}{}`]; - const lines: string[] = []; - for (const [key, entry] of entries) { - const scalar = - entry === null || - typeof entry === "string" || - typeof entry === "boolean" || - typeof entry === "number" || - Array.isArray(entry) && entry.length === 0 || - isEmptyObject(entry); - if (scalar) { - lines.push(`${indent}${key}: ${renderYamlScalar(entry)}`); - continue; - } - lines.push(`${indent}${key}:`); - lines.push(...renderYamlBlock(entry, indentLevel + 1)); - } - return lines; - } - - return [`${indent}${renderYamlScalar(value)}`]; +function isYamlScalarValue(value: unknown) { + return ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" || + (Array.isArray(value) && value.length === 0) || + isEmptyObject(value) + ); } -function renderFrontmatter(frontmatter: Record) { +type YamlRenderFrame = + | { kind: "line"; text: string } + | { kind: "value"; value: unknown; indentLevel: number }; + +export function renderYamlBlock(value: unknown, indentLevel: number): string[] { + const lines: string[] = []; + const stack: YamlRenderFrame[] = [{ kind: "value", value, indentLevel }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.kind === "line") { + lines.push(frame.text); + continue; + } + + const indent = " ".repeat(frame.indentLevel); + const current = frame.value; + + if (Array.isArray(current)) { + if (current.length === 0) { + lines.push(`${indent}[]`); + continue; + } + + for (let index = current.length - 1; index >= 0; index -= 1) { + const entry = current[index]; + if (isYamlScalarValue(entry)) { + stack.push({ kind: "line", text: `${indent}- ${renderYamlScalar(entry)}` }); + continue; + } + stack.push({ kind: "value", value: entry, indentLevel: frame.indentLevel + 1 }); + stack.push({ kind: "line", text: `${indent}-` }); + } + continue; + } + + if (isPlainRecord(current)) { + const entries = orderedYamlEntries(current); + if (entries.length === 0) { + lines.push(`${indent}{}`); + continue; + } + + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, entry] = entries[index]!; + if (isYamlScalarValue(entry)) { + stack.push({ kind: "line", text: `${indent}${key}: ${renderYamlScalar(entry)}` }); + continue; + } + stack.push({ kind: "value", value: entry, indentLevel: frame.indentLevel + 1 }); + stack.push({ kind: "line", text: `${indent}${key}:` }); + } + continue; + } + + lines.push(`${indent}${renderYamlScalar(current)}`); + } + + return lines; +} + +export function renderFrontmatter(frontmatter: Record) { const lines: string[] = ["---"]; for (const [key, value] of orderedYamlEntries(frontmatter)) { // Skip null/undefined values — don't export empty fields if (value === null || value === undefined) continue; - const scalar = - typeof value === "string" || - typeof value === "boolean" || - typeof value === "number" || - Array.isArray(value) && value.length === 0 || - isEmptyObject(value); - if (scalar) { + if (isYamlScalarValue(value)) { lines.push(`${key}: ${renderYamlScalar(value)}`); continue; } lines.push(`${key}:`); - lines.push(...renderYamlBlock(value, 1)); + // Append each rendered line without a spread. A spread of a large array as + // function arguments overflows the argument limit and throws RangeError. + for (const line of renderYamlBlock(value, 1)) lines.push(line); } lines.push("---"); return `${lines.join("\n")}\n`;