fix(server): render company-export YAML iteratively to stop stack overflow (#10854)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - The company export path writes `.paperclip.yaml` data for large
companies.
> - The YAML renderer used a spread append that can overflow the call
stack on large arrays.
> - That failure turns a normal export into a 500 for large companies.
> - This pull request rewrites the renderer to use an iterative stack
and removes the last spread append.
> - The benefit is that large exports finish without a RangeError and
keep the same output.

## Linked Issues or Issue Description

I searched GitHub for related work.
I found PR #7506.
This pull request closes the last spread site that PR left open.

**What happened?**
The company export failed with `RangeError: Maximum call stack size
exceeded` on large YAML output.

**Expected behavior**
The export should finish without a stack overflow.

**Steps to reproduce**
1. Export a company with a very large YAML payload.
2. Render the export through `renderYamlBlock` or `renderFrontmatter`.
3. Observe that the old spread append can overflow the call stack.

**Paperclip version or commit**
`79f3a216215500e2ec1a928d5eb5c09364c2abf5`

**Deployment mode**
Local dev (`pnpm dev`) or built from source.

**Additional context**
Related public PR: #7506.
This change keeps the YAML shape, scalar format, and key order the same.

## What Changed

- Reworked `renderYamlBlock` to render iteratively.
- Replaced the last spread append in `renderFrontmatter` with a loop.
- Added regression tests for high-volume block and frontmatter arrays.

## Verification

- `node_modules/.bin/vitest run
server/src/__tests__/company-portability.test.ts`
- The two new overflow tests pass.
- The existing round-trip tests still pass.
- `tsc --noEmit` is clean for
`server/src/services/company-portability.ts`.

## Risks

Low risk.
The change keeps exported YAML content and ordering the same.

## Model Used

OpenAI GPT-5, tool-using coding agent.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-04 15:18:04 -07:00 committed by GitHub
parent 678728f650
commit 74416e9cd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 99 additions and 57 deletions

View File

@ -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"),

View File

@ -2340,72 +2340,91 @@ function orderedYamlEntries(value: Record<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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`;