@mintlify/cli 4.0.1654 → 4.0.1656

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 (54) hide show
  1. package/bin/cli.js +1 -7
  2. package/bin/tsconfig.build.tsbuildinfo +1 -1
  3. package/package.json +9 -13
  4. package/src/cli.tsx +1 -12
  5. package/__test__/mintTestFilePreview.test.ts +0 -35
  6. package/__test__/mintTestOutput.test.ts +0 -256
  7. package/__test__/runHistory.test.ts +0 -240
  8. package/bin/agent-harness/agentPreflight.js +0 -24
  9. package/bin/agent-harness/buildTaskPrompt.js +0 -86
  10. package/bin/agent-harness/countReport.js +0 -10
  11. package/bin/agent-harness/discoverCodeBlocks.js +0 -101
  12. package/bin/agent-harness/executeCodeBlocks.js +0 -263
  13. package/bin/agent-harness/filterSelectedPages.js +0 -10
  14. package/bin/agent-harness/generateTestCode.js +0 -337
  15. package/bin/agent-harness/index.js +0 -95
  16. package/bin/agent-harness/loadAgentSdk.js +0 -46
  17. package/bin/agent-harness/manifest.js +0 -113
  18. package/bin/agent-harness/mapWithConcurrency.js +0 -26
  19. package/bin/agent-harness/runHistory.js +0 -256
  20. package/bin/agent-harness/runSave.js +0 -47
  21. package/bin/agent-harness/setupFolders.js +0 -73
  22. package/bin/agent-harness/tasks/checkTestability.js +0 -228
  23. package/bin/agent-harness/types.js +0 -66
  24. package/bin/mintTest.js +0 -122
  25. package/bin/mintTestFilePreview.js +0 -39
  26. package/bin/mintTestNav.js +0 -35
  27. package/bin/mintTestTelemetry.js +0 -63
  28. package/bin/mintTestText.js +0 -31
  29. package/bin/mintTestTree.js +0 -94
  30. package/bin/mintTestUi.js +0 -818
  31. package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +0 -64
  32. package/src/agent-harness/agentPreflight.ts +0 -13
  33. package/src/agent-harness/buildTaskPrompt.ts +0 -93
  34. package/src/agent-harness/countReport.ts +0 -17
  35. package/src/agent-harness/discoverCodeBlocks.ts +0 -90
  36. package/src/agent-harness/executeCodeBlocks.ts +0 -351
  37. package/src/agent-harness/filterSelectedPages.ts +0 -14
  38. package/src/agent-harness/generateTestCode.ts +0 -396
  39. package/src/agent-harness/index.ts +0 -127
  40. package/src/agent-harness/loadAgentSdk.ts +0 -35
  41. package/src/agent-harness/manifest.ts +0 -116
  42. package/src/agent-harness/mapWithConcurrency.ts +0 -21
  43. package/src/agent-harness/runHistory.ts +0 -255
  44. package/src/agent-harness/runSave.ts +0 -38
  45. package/src/agent-harness/setupFolders.ts +0 -87
  46. package/src/agent-harness/tasks/checkTestability.ts +0 -256
  47. package/src/agent-harness/types.ts +0 -239
  48. package/src/mintTest.tsx +0 -138
  49. package/src/mintTestFilePreview.ts +0 -32
  50. package/src/mintTestNav.ts +0 -31
  51. package/src/mintTestTelemetry.ts +0 -101
  52. package/src/mintTestText.ts +0 -35
  53. package/src/mintTestTree.ts +0 -134
  54. package/src/mintTestUi.tsx +0 -1378
@@ -1,64 +0,0 @@
1
- # `mint test` system design
2
-
3
- `packages/cli` owns command parsing and terminal interaction. `packages/cli/src/agent-harness` collects code blocks, runs agents, validates generated projects, executes commands, and writes reports so other callers can use the same test engine without the CLI.
4
-
5
- ```mermaid
6
- flowchart TD
7
- User[Developer] --> Command["mint test [path]"]
8
-
9
- subgraph CLI["packages/cli"]
10
- Command --> Args["Parse path, agent, scope, and TUI flags"]
11
- Mode{"Interactive TTY?"}
12
- UI["Ink flow: agent, code blocks, scope, progress, results"]
13
- Plain["Non-interactive runner with Claude default"]
14
- Exit["Print summary and set exit code"]
15
- end
16
-
17
- subgraph Harness["packages/cli/src/agent-harness"]
18
- Collect["collectCodeBlocks"]
19
- Collection["CodeBlockCollection"]
20
- Run["runCodeTests<br/>tasks and inline preflight, progress, and report writes"]
21
- Prompt["AgentRunner.prompt()<br/>ClaudeRunner or CodexRunner"]
22
- Validate["Manifest validation inside runCodeTests<br/>schema, paths, block coverage, and executables"]
23
- Execute["executeCodeTestCommand<br/>setup and test commands without a shell"]
24
- Report["CodeTestReport<br/>persisted as report.json"]
25
- end
26
-
27
- Docs["Documentation source, read only"] --> Collect
28
- Args --> Collect
29
- Collect --> Collection
30
- Collection --> Mode
31
- Mode -->|yes| UI
32
- Mode -->|no| Plain
33
- UI -->|selected agents and files| Run
34
- Plain -->|default agent and selected files| Run
35
- Collection -->|collected blocks| Run
36
- Run --> Prompt --> Validate --> Execute --> Report
37
- Docs -. "read-only context" .-> Prompt
38
- Run -. "inline progress callbacks" .-> UI
39
- Report --> Exit
40
- ```
41
-
42
- ## Type contracts
43
-
44
- The harness exports five types. Status unions and nested block, environment, summary, command, and task fields live on the contract that owns them.
45
-
46
- | Exported type | Contract |
47
- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
- | `HarnessAgent` | The supported runner names, `claude` and `codex`. |
49
- | `AgentRunner` | One `prompt(prompt)` method returning the final response text. `ClaudeRunner` and `CodexRunner` implement it. |
50
- | `CodeBlockCollection` | The docs root and fenced block locations grouped by markdown file. `collectCodeBlocks` returns it. |
51
- | `GeneratedTestManifest` | The normalized output of the runtime `mint-test.json` schema: generated files plus setup and test commands. Command ids are optional in the file; `manifestSchema` assigns `setup-N` / `test-N` when an agent omits them. |
52
- | `CodeTestReport` | The complete persisted `report.json` shape. Task, block, command, environment, summary, and status fields are nested here. |
53
-
54
- `GeneratedTestManifest` is produced by the runtime schema in `packages/cli/src/agent-harness/types.ts` (`manifestFileSchema.transform(assignCommandIds)`), so validation and TypeScript cannot drift apart. Contract changes still require a schema test and an update to this table.
55
-
56
- `packages/cli/src/agent-harness/manifest.ts` owns reading the manifest. It turns a missing file, invalid JSON, or a schema mismatch into one actionable message per problem; that message is what the UI, the non-interactive summary, and `report.json` show for the page. Each task directory also keeps `mint-test-agent.md` (the agent's final summary per attempt) and, after a failed generation, `mint-test-error.txt` (every attempt's failure reason).
57
-
58
- `runCodeTests` loads each selected agent SDK before any page is checked (`agentPreflight.ts`), so a missing optional dependency fails the run once with an install hint instead of failing every page minutes later.
59
-
60
- `runCodeTests` runs the pipeline in order: `setupFolders` picks the run id, creates the run directory, and builds one task per agent and page without creating task directories; `checkTestability` asks a small model whether each page is testable, running from the run directory, and only once a page is proven testable creates its task directory and writes `mint-test-check.json` there; `scaffoldTaskDirectories` then adds `tests/` and `package.json` to those same tasks; `generateTestCode` and `executeCodeBlocks` skip every other task. A page that is not testable leaves nothing on disk. A resumed run reuses the check file for testable pages, re-checks the others, and re-applies the scaffold, which is idempotent.
61
-
62
- `runCodeTests` declares its input object and progress events on the function. `executeCodeTestCommand` does the same for command input. Neither API has a separate options, request, progress, preflight, command-runner, or command-result type to keep in sync.
63
-
64
- The manifest is the boundary between generated agent output and trusted execution. The harness rejects commands outside the allowlist or task workspace, runs each command with `shell: false`, applies timeouts and cancellation, redacts secrets from captured output, and records every state change in the report.
@@ -1,13 +0,0 @@
1
- import { loadClaudeAgentSdk, loadCodexSdk } from './loadAgentSdk.js';
2
- import type { HarnessAgent } from './types.js';
3
-
4
- /**
5
- * Fail fast, before any page is checked, when an agent SDK is not installed. Without this every
6
- * page reports the same missing-dependency error minutes later as "test could not be generated".
7
- */
8
- export async function ensureAgentSdks(agents: Iterable<HarnessAgent>): Promise<void> {
9
- for (const agent of new Set(agents)) {
10
- if (agent === 'claude') await loadClaudeAgentSdk();
11
- else await loadCodexSdk();
12
- }
13
- }
@@ -1,93 +0,0 @@
1
- import path from 'node:path';
2
-
3
- import { SAFE_EXECUTABLES } from './manifest.js';
4
-
5
- export const SYSTEM_PROMPT = `# Mint test generator
6
-
7
- You will be given a markdown documentation page that contains code blocks. Follow the page and
8
- implement the code as the page instructs, inside the given output directory.
9
-
10
- Once finished, create a mint-test.json file in the current working directory with this shape:
11
-
12
- {
13
- "version": 1,
14
- "generatedFiles": ["tests/example.test.js", "tests/test_example.py"],
15
- "setupCommands": [],
16
- "testCommands": [
17
- {
18
- "id": "node-tests",
19
- "executable": "node",
20
- "args": ["--test"],
21
- "cwd": ".",
22
- "timeoutMs": 120000
23
- },
24
- {
25
- "id": "python-tests",
26
- "executable": "python3",
27
- "args": ["-m", "unittest", "discover", "-s", "tests"],
28
- "cwd": ".",
29
- "timeoutMs": 120000
30
- }
31
- ]
32
- }
33
-
34
- Manifest rules:
35
- - "generatedFiles" lists every file you wrote, as paths relative to the current working directory. Each one must exist.
36
- - Every command has a unique "id" (letters, digits, dashes). Command "cwd" values are relative to the current working directory.
37
- - "executable" must be one of: ${[...SAFE_EXECUTABLES].join(', ')}, or a "./" script inside the current working directory. Do not use a shell, pipes, or "&&".
38
- - Do not add any other keys to the manifest.
39
-
40
- Finish with a short plain-text summary of what you tested and anything you could not test.
41
-
42
- Rules:
43
- - Read only the page named in the task. You may also read docs.json and an OpenAPI file referenced by that page.
44
- - The output directory is already scaffolded: package.json exists with "npm test" wired to "node --test", and a tests/ directory is ready. Do not recreate or rewrite this scaffolding.
45
- - Run JavaScript tests with "node" and args ["--test"] from the task directory (no directory argument: on Node 21+ a path like "tests/" is treated as a glob and finds nothing). Node discovers tests/*.test.js on its own.
46
- - Write JavaScript and TypeScript tests as ES modules named tests/*.test.js using the Node built-in test runner (node:test and node:assert). Write Python tests as tests/test_*.py using the standard library unittest module, run with python3 -m unittest discover -s tests.
47
- - Prefer the standard library over installing dependencies; add a setup command only when a test truly cannot run without one.
48
- - Never write README, docs, or explanation files.
49
- - Keep helpers inline in the test file; do not create separate helper modules.
50
- - Write one test file per page unless the page genuinely needs more.
51
- - Create every additional source file and fixture inside the current working directory.
52
- - Never modify the documentation source. Do not commit, push, or open a pull request.
53
- - Cover fenced code blocks inside Mintlify components such as CodeGroup, Tabs, Steps, RequestExample, and ResponseExample.
54
- - If the page has openapi frontmatter, you may resolve its spec relative to the docs root and add tests for request snippets.
55
- - Do not install dependencies or run the generated tests. The Mint CLI handles setup and execution.
56
- - Never copy secrets into a file. In your code pull secrets from .env
57
- `;
58
-
59
- export function buildTaskPrompt(docsRoot: string, file: string, directory: string): string {
60
- return `
61
- Use the guide at "${JSON.stringify(path.join(docsRoot, file))}"
62
-
63
- Write the required tests and mint-test.json output in the "${JSON.stringify(directory)}" directory.
64
- Record your report here "${JSON.stringify(file)}"`;
65
- }
66
-
67
- export const CHECK_SYSTEM_PROMPT = `# Mint test triage
68
- You are given a markdown page guide. Decide if the given file is able to be tested.
69
-
70
- Testable pages are pages that include any runnable code (python, typescript, bash) and any logic that could fail if the documented code was wrong
71
-
72
- Code blocks are not testable when it is used for formatting, showing logs, configuration, pseudo-code or a diff, an illustrative request or response shape, or a UI-only example.
73
-
74
- When unsure, mark it not testable.
75
-
76
- Include "reason" only when the page is not testable; omit it entirely when testable is true.`;
77
-
78
- export const CHECK_TOOL_INSTRUCTION = `
79
-
80
- Report your classification by calling the report_testability tool`;
81
-
82
- export const CHECK_JSON_INSTRUCTION = `
83
-
84
- Respond with only this JSON object and nothing else:
85
- { "version": 1, "testable": false, "reason": "expected output, not runnable code" }`;
86
-
87
- export function buildCheckPrompt(file: string, fileContent: string): string {
88
- return `Report if the file at filePath ${JSON.stringify(file)} is testable
89
-
90
- <file>
91
- ${fileContent}
92
- </file>`;
93
- }
@@ -1,17 +0,0 @@
1
- import type { TaskResult } from './types.js';
2
-
3
- export interface ReportCounts {
4
- passed: number;
5
- failed: number;
6
- cancelled: number;
7
- agentErrors: number;
8
- }
9
-
10
- export function countReport(tasks: TaskResult[]): ReportCounts {
11
- const counts: ReportCounts = { passed: 0, failed: 0, cancelled: 0, agentErrors: 0 };
12
- for (const task of tasks) {
13
- if (task.status === 'agent_error') counts.agentErrors++;
14
- else counts[task.status]++;
15
- }
16
- return counts;
17
- }
@@ -1,90 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
- import type { DocsPage } from './types.js';
5
-
6
- const PAGE_EXTENSIONS = new Set(['.md', '.mdx']);
7
- const IGNORED_DIRECTORIES = new Set(['.git', '.next', 'build', 'dist', 'node_modules']);
8
- const OPENING_FENCE_PATTERN = /^\s*(`{3,}|~{3,})/;
9
-
10
- function countFencedBlocks(content: string): number {
11
- const lines = content.split(/\r?\n/);
12
- let count = 0;
13
- for (let index = 0; index < lines.length; index++) {
14
- const opening = OPENING_FENCE_PATTERN.exec(lines[index] ?? '');
15
- const marker = opening?.[1];
16
- if (!marker) continue;
17
- const closingPattern = new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`);
18
- let closingIndex = index + 1;
19
- while (closingIndex < lines.length && !closingPattern.test(lines[closingIndex] ?? '')) {
20
- closingIndex++;
21
- }
22
- if (closingIndex >= lines.length) break;
23
- count++;
24
- index = closingIndex;
25
- }
26
- return count;
27
- }
28
-
29
- async function findSiteRoot(startDirectory: string): Promise<string | null> {
30
- const { root } = path.parse(startDirectory);
31
- let directory = startDirectory;
32
- let previous: string | null = null;
33
- while (directory !== previous) {
34
- for (const configName of ['docs.json', 'mint.json']) {
35
- const stats = await fs.stat(path.join(directory, configName)).catch(() => null);
36
- if (stats?.isFile()) return directory;
37
- }
38
- if (directory === root) return null;
39
- previous = directory;
40
- directory = path.dirname(directory);
41
- }
42
- return null;
43
- }
44
-
45
- export async function discoverCodeBlocks(
46
- inputPath: string
47
- ): Promise<{ docsRoot: string; pages: DocsPage[] }> {
48
- const resolvedPath = path.resolve(inputPath);
49
- const stats = await fs.stat(resolvedPath);
50
- const baseDirectory = stats.isDirectory() ? resolvedPath : path.dirname(resolvedPath);
51
- const docsRoot = (await findSiteRoot(baseDirectory)) ?? baseDirectory;
52
-
53
- const filePaths: string[] = [];
54
- if (stats.isDirectory()) {
55
- const directories = [resolvedPath];
56
- while (directories.length > 0) {
57
- const directory = directories.pop();
58
- if (!directory) break;
59
- for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
60
- const entryPath = path.join(directory, entry.name);
61
- if (
62
- entry.isDirectory() &&
63
- !IGNORED_DIRECTORIES.has(entry.name) &&
64
- !(entry.name === 'mint-test' && path.basename(directory) === 'tests')
65
- ) {
66
- directories.push(entryPath);
67
- } else if (entry.isFile() && PAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
68
- filePaths.push(entryPath);
69
- }
70
- }
71
- }
72
- } else if (stats.isFile() && PAGE_EXTENSIONS.has(path.extname(resolvedPath).toLowerCase())) {
73
- filePaths.push(resolvedPath);
74
- } else {
75
- throw new Error(`Expected a docs directory or an MD/MDX file: ${resolvedPath}`);
76
- }
77
-
78
- const pages: DocsPage[] = [];
79
- for (const filePath of filePaths.sort()) {
80
- const content = await fs.readFile(filePath, 'utf8');
81
- const blockCount = countFencedBlocks(content);
82
- if (blockCount > 0) {
83
- pages.push({
84
- file: path.relative(docsRoot, filePath).split(path.sep).join('/'),
85
- blockCount,
86
- });
87
- }
88
- }
89
- return { docsRoot, pages };
90
- }
@@ -1,351 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import path from 'node:path';
3
-
4
- import { mapWithConcurrency } from './mapWithConcurrency.js';
5
- import type {
6
- CommandResult,
7
- GeneratedTask,
8
- TaskUpdate,
9
- ManifestCommand,
10
- TaskResult,
11
- } from './types.js';
12
-
13
- const MAX_STREAM_BYTES = 128 * 1024;
14
- const KILL_GRACE_MS = 2_000;
15
- const FAILURE_PRECEDENCE = ['failed', 'cancelled'] as const;
16
- const SECRET_NAME_PATTERN = /(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i;
17
-
18
- function redactSecrets(value: string): string {
19
- let redacted = value;
20
- const secrets = Object.entries(process.env)
21
- .filter(
22
- ([name, secret]) =>
23
- SECRET_NAME_PATTERN.test(name) && typeof secret === 'string' && secret.length >= 8
24
- )
25
- .map(([, secret]) => secret as string)
26
- .sort((left, right) => right.length - left.length);
27
- for (const secret of secrets) redacted = redacted.split(secret).join('[REDACTED]');
28
- return redacted
29
- .replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]')
30
- .replace(/\b(?:sk|sess)-[A-Za-z0-9._-]{12,}\b/gi, '[REDACTED]');
31
- }
32
-
33
- function outputCollector(): { append: (chunk: Buffer | string) => void; value: () => string } {
34
- let output = '';
35
- let bytes = 0;
36
- let truncated = false;
37
- return {
38
- append(chunk) {
39
- if (truncated) return;
40
- const value = chunk.toString();
41
- const remaining = MAX_STREAM_BYTES - bytes;
42
- if (Buffer.byteLength(value) <= remaining) {
43
- output += value;
44
- bytes += Buffer.byteLength(value);
45
- return;
46
- }
47
- output += Buffer.from(value).subarray(0, Math.max(0, remaining)).toString();
48
- output += '\n[mint test: output truncated]\n';
49
- truncated = true;
50
- },
51
- value: () => output,
52
- };
53
- }
54
-
55
- const MAX_FAILURE_LINE_LENGTH = 200;
56
- const TAP_SUMMARY_PATTERN =
57
- /^#\s*(tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b|^1\.\.\d+$|^TAP version/;
58
- const ERROR_LINE_PATTERN = /\b(?:[A-Za-z]*Error|Traceback|FAILED|ModuleNotFoundError)\b/;
59
- const TAP_METADATA_PATTERN = /^(name|code|failureType|location|stack)\s*:/;
60
-
61
- /**
62
- * The most useful single line from a failed command's output: the first thrown error, else the
63
- * first failing TAP assertion, else the last line that is not a TAP summary.
64
- */
65
- export function pickFailureLine(text: string): string {
66
- const lines = text
67
- .split(/\r?\n/)
68
- .map((raw) => raw.trim())
69
- .filter((raw) => raw.length > 0 && !raw.startsWith('[mint test:'))
70
- .map((raw) => ({ raw, text: raw.replace(/^#\s?/, '').trim() }))
71
- .filter((line) => line.text.length > 0);
72
- const thrown = lines.find(
73
- ({ text: candidate }) =>
74
- ERROR_LINE_PATTERN.test(candidate) &&
75
- !candidate.startsWith('at ') &&
76
- !/^(ok|Traceback)\b/.test(candidate) &&
77
- !TAP_METADATA_PATTERN.test(candidate)
78
- );
79
- const assertion = lines.find(({ text: candidate }) => candidate.startsWith('not ok'));
80
- const informative = lines.filter(({ raw }) => !TAP_SUMMARY_PATTERN.test(raw));
81
- const line =
82
- thrown?.text ??
83
- assertion?.text ??
84
- informative[informative.length - 1]?.text ??
85
- lines[lines.length - 1]?.text ??
86
- '';
87
- return line.length > MAX_FAILURE_LINE_LENGTH
88
- ? `${line.slice(0, MAX_FAILURE_LINE_LENGTH - 1)}…`
89
- : line;
90
- }
91
-
92
- /** One line explaining the first command that did not pass, for the UI and the report. */
93
- export function summarizeCommandFailure(commands: CommandResult[]): string | undefined {
94
- const failed = commands.find((command) => command.status !== 'passed');
95
- if (!failed) return undefined;
96
- const invocation = [failed.executable, ...failed.args].join(' ');
97
- const reason =
98
- failed.error ??
99
- (failed.exitCode === null ? 'did not exit cleanly' : `exited with code ${failed.exitCode}`);
100
- const detail = pickFailureLine(failed.stderr) || pickFailureLine(failed.stdout);
101
- return `${failed.phase} command \`${invocation}\` ${reason}${detail ? `: ${detail}` : ''}`;
102
- }
103
-
104
- function unrunCommandResult(
105
- command: ManifestCommand,
106
- phase: 'setup' | 'test',
107
- cwd: string,
108
- status: 'failed' | 'cancelled',
109
- error: string
110
- ): CommandResult {
111
- return {
112
- id: command.id,
113
- phase,
114
- status,
115
- executable: command.executable,
116
- args: command.args,
117
- cwd,
118
- durationMs: 0,
119
- exitCode: null,
120
- stdout: '',
121
- stderr: '',
122
- error,
123
- };
124
- }
125
-
126
- function spawnCommand({
127
- command,
128
- phase,
129
- cwd,
130
- timeoutMs,
131
- signal,
132
- docsRootForCommands,
133
- }: {
134
- command: ManifestCommand;
135
- phase: 'setup' | 'test';
136
- cwd: string;
137
- timeoutMs: number;
138
- signal: AbortSignal;
139
- docsRootForCommands: string;
140
- }): Promise<CommandResult> {
141
- const startedAt = Date.now();
142
- const base = {
143
- id: command.id,
144
- phase,
145
- executable: command.executable,
146
- args: command.args,
147
- cwd,
148
- };
149
- const commandTimeoutMs = command.timeoutMs ?? timeoutMs;
150
-
151
- return new Promise((resolve) => {
152
- const stdout = outputCollector();
153
- const stderr = outputCollector();
154
- let completed = false;
155
- let timedOut = false;
156
- let cancelled = false;
157
- let forceKillTimer: NodeJS.Timeout | undefined;
158
-
159
- const child = spawn(command.executable, command.args, {
160
- cwd,
161
- detached: true,
162
- env: { ...process.env, MINT_DOCS_ROOT: docsRootForCommands },
163
- shell: false,
164
- stdio: ['ignore', 'pipe', 'pipe'],
165
- });
166
- child.stdout.on('data', stdout.append);
167
- child.stderr.on('data', stderr.append);
168
-
169
- const killTree = (killSignal: NodeJS.Signals) => {
170
- if (child.pid) {
171
- try {
172
- process.kill(-child.pid, killSignal);
173
- return;
174
- } catch {
175
- /* noop */
176
- }
177
- }
178
- child.kill(killSignal);
179
- };
180
- const kill = () => {
181
- killTree('SIGTERM');
182
- forceKillTimer = setTimeout(() => killTree('SIGKILL'), KILL_GRACE_MS);
183
- };
184
- const timeout = setTimeout(() => {
185
- timedOut = true;
186
- kill();
187
- }, commandTimeoutMs);
188
- const abort = () => {
189
- cancelled = true;
190
- kill();
191
- };
192
- signal.addEventListener('abort', abort, { once: true });
193
-
194
- const finish = (exitCode: number | null, spawnError?: string) => {
195
- if (completed) return;
196
- completed = true;
197
- clearTimeout(timeout);
198
- if (forceKillTimer) clearTimeout(forceKillTimer);
199
- signal.removeEventListener('abort', abort);
200
- const error = cancelled
201
- ? 'run cancelled'
202
- : timedOut
203
- ? `command timed out after ${commandTimeoutMs}ms`
204
- : spawnError;
205
- resolve({
206
- ...base,
207
- status: cancelled ? 'cancelled' : exitCode === 0 && !error ? 'passed' : 'failed',
208
- durationMs: Date.now() - startedAt,
209
- exitCode,
210
- stdout: redactSecrets(stdout.value()),
211
- stderr: redactSecrets(stderr.value()),
212
- ...(error ? { error } : {}),
213
- });
214
- };
215
- child.once('error', (error) => finish(null, error.message));
216
- child.once('close', (exitCode) => finish(exitCode));
217
- });
218
- }
219
-
220
- export async function executeCodeBlocks({
221
- tasks,
222
- docsRoot,
223
- concurrency,
224
- commandTimeoutMs,
225
- signal,
226
- onTaskUpdate,
227
- }: {
228
- tasks: GeneratedTask[];
229
- docsRoot: string;
230
- concurrency: number;
231
- commandTimeoutMs: number;
232
- signal: AbortSignal;
233
- onTaskUpdate: (update: TaskUpdate) => void;
234
- }): Promise<TaskResult[]> {
235
- return mapWithConcurrency(tasks, concurrency, async (task) => {
236
- const generatedFiles =
237
- task.manifest?.generatedFiles.map((file) => path.resolve(task.directory, file)) ??
238
- task.generatedFiles ??
239
- [];
240
- const base = {
241
- id: task.id,
242
- agent: task.agent,
243
- file: task.file,
244
- directory: task.directory,
245
- generatedFiles,
246
- testable: task.testable === true,
247
- attempts: task.attempts,
248
- tokens: task.tokens,
249
- };
250
- const manifest = task.manifest;
251
- if (!manifest) {
252
- if (!task.error) {
253
- onTaskUpdate({
254
- id: task.id,
255
- agent: task.agent,
256
- file: task.file,
257
- directory: task.directory,
258
- generatedFiles,
259
- phase: 'not_testable',
260
- tokens: task.tokens,
261
- });
262
- return { ...base, status: 'passed' as const, commands: [] };
263
- }
264
- const status = signal.aborted ? ('cancelled' as const) : ('agent_error' as const);
265
- onTaskUpdate({
266
- id: task.id,
267
- agent: task.agent,
268
- file: task.file,
269
- directory: task.directory,
270
- generatedFiles,
271
- phase: status,
272
- tokens: task.tokens,
273
- error: task.error,
274
- });
275
- return {
276
- ...base,
277
- status,
278
- error: task.error,
279
- commands: [],
280
- };
281
- }
282
- onTaskUpdate({
283
- id: task.id,
284
- agent: task.agent,
285
- file: task.file,
286
- directory: task.directory,
287
- generatedFiles,
288
- phase: 'running',
289
- tokens: task.tokens,
290
- });
291
-
292
- const commands: CommandResult[] = [];
293
- let setupFailure: 'failed' | 'cancelled' | undefined;
294
-
295
- const run = async (
296
- command: ManifestCommand,
297
- phase: 'setup' | 'test'
298
- ): Promise<CommandResult> => {
299
- const cwd = path.resolve(task.directory, command.cwd ?? '.');
300
- if (signal.aborted) {
301
- return unrunCommandResult(command, phase, cwd, 'cancelled', 'run cancelled');
302
- }
303
- if (phase === 'test' && setupFailure) {
304
- return unrunCommandResult(
305
- command,
306
- phase,
307
- cwd,
308
- setupFailure,
309
- `a setup command ${setupFailure === 'cancelled' ? 'was cancelled' : 'failed'}`
310
- );
311
- }
312
- return spawnCommand({
313
- command,
314
- phase,
315
- cwd,
316
- timeoutMs: commandTimeoutMs,
317
- signal,
318
- docsRootForCommands: docsRoot,
319
- });
320
- };
321
-
322
- for (const command of manifest.setupCommands) {
323
- const result = await run(command, 'setup');
324
- commands.push(result);
325
- if (result.status !== 'passed') {
326
- setupFailure = result.status;
327
- break;
328
- }
329
- }
330
- for (const command of manifest.testCommands) {
331
- commands.push(await run(command, 'test'));
332
- }
333
-
334
- const status =
335
- FAILURE_PRECEDENCE.find((candidate) =>
336
- commands.some((command) => command.status === candidate)
337
- ) ?? 'passed';
338
- const error = status === 'passed' ? undefined : summarizeCommandFailure(commands);
339
- onTaskUpdate({
340
- id: task.id,
341
- agent: task.agent,
342
- file: task.file,
343
- directory: task.directory,
344
- generatedFiles,
345
- phase: status,
346
- tokens: task.tokens,
347
- ...(error ? { error } : {}),
348
- });
349
- return { ...base, status, ...(error ? { error } : {}), commands };
350
- });
351
- }
@@ -1,14 +0,0 @@
1
- import path from 'node:path';
2
-
3
- import type { DocsPage } from './types.js';
4
-
5
- export function filterSelectedPages(pages: DocsPage[], selectedFiles: string[]): DocsPage[] {
6
- const selected = new Set(
7
- selectedFiles.map((file) => file.split(path.sep).join('/').replace(/^\.\//, ''))
8
- );
9
- const known = new Set(pages.map((page) => page.file));
10
- for (const file of selected) {
11
- if (!known.has(file)) throw new Error(`Selected docs file was not found: ${file}`);
12
- }
13
- return pages.filter((page) => selected.has(page.file));
14
- }