mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(plugins): code-import atom repo walker (Phase 7 entry slice)
Plan N2 / spec §10 / §21.3.2 / §16 Phase 7.
apps/daemon/src/plugins/atoms/code-import.ts ships the daemon-side
implementation of the SKILL.md fragment landed in §3.M4. Given a
repo path + a project cwd, the runner walks the tree once, writes
`<cwd>/code/index.json`, and never re-walks until the next
explicit re-import.
The walker:
- Honours OD_CODE_IMPORT_BUDGET_MS (default 60s) and stops cleanly
when the budget is exhausted, recording every unwalked
directory under skipped[] with reason='budget-exceeded'.
- Skips the canonical no-go folders: node_modules, .git, .next,
.svelte-kit, dist, build, out, coverage, .vercel, .vscode,
.turbo, .cache, .pnpm-store, .parcel-cache, .nuxt, .astro.
Each entry lands in skipped[] with reason='directory-skiplist'.
- Marks symlinks as skipped (reason='symlink') without following.
- Records files larger than largeFileBytes (default 1 MiB) in
skipped[] AND in files[] (so the agent sees they exist), but
omits their imports[] to keep memory bounded.
- Detects framework: next (app vs pages router), sveltekit, astro,
remix, vite, cra, custom, unknown.
- Detects package manager from lockfile presence (pnpm-lock.yaml
> yarn.lock > bun.lockb > package-lock.json > unknown).
- Detects style system: tailwind / styled-components / emotion /
css.
- Lightweight import edge extraction (regex) for ts/tsx/js/jsx;
documented as heuristic in the SKILL.md fragment.
Daemon tests: 1528 → 1534 (+6 cases on plugins-code-import:
next-app router + tailwind + pnpm detection, skiplist enforcement,
symlink skip, large-file imports omission, persisted JSON layout,
non-directory repoPath rejection).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
co-authored by
Tom Huang
parent
5b6a0899ce
commit
0ab7ecf403
@@ -0,0 +1,276 @@
|
||||
// Phase 7 entry slice / spec §10 / §21.3.2 — code-import atom runner.
|
||||
//
|
||||
// SKILL.md fragment lives at plugins/_official/atoms/code-import/. The
|
||||
// runner walks an existing repository's tree and writes a normalised
|
||||
// snapshot under `<projectCwd>/code/` so subsequent atoms
|
||||
// (`design-extract`, `rewrite-plan`, `patch-edit`, `build-test`)
|
||||
// don't have to re-walk on every turn.
|
||||
//
|
||||
// The walk respects:
|
||||
// - a budget (`OD_CODE_IMPORT_BUDGET_MS`, default 60s) so monorepos
|
||||
// don't burn an entire run on import;
|
||||
// - the standard skip-list (node_modules, .git, .next, dist, build,
|
||||
// out, .turbo, .pnpm-store) — recorded under
|
||||
// `code/index.json.skipped[]` with a reason so the human can audit;
|
||||
// - the §11.5.1 patch-safety contract via lightweight framework
|
||||
// detection (next / vite / remix / astro / sveltekit / cra /
|
||||
// custom).
|
||||
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
|
||||
export interface CodeImportFileEntry {
|
||||
path: string;
|
||||
size: number;
|
||||
language: 'ts' | 'tsx' | 'js' | 'jsx' | 'css' | 'scss' | 'json' | 'html' | 'md' | 'other';
|
||||
// Lightweight import edges (regex-extracted from `from '…'`). The
|
||||
// pass is heuristic: we accept false positives (commented imports
|
||||
// sneak through) and document the limitation in the SKILL.md
|
||||
// fragment so the agent doesn't treat the list as authoritative.
|
||||
imports?: string[];
|
||||
}
|
||||
|
||||
export interface CodeImportSkipped {
|
||||
path: string;
|
||||
reason: 'directory-skiplist' | 'unsupported-extension' | 'budget-exceeded' | 'symlink' | 'large-file';
|
||||
}
|
||||
|
||||
export interface CodeImportIndex {
|
||||
files: CodeImportFileEntry[];
|
||||
skipped: CodeImportSkipped[];
|
||||
framework: 'next' | 'vite' | 'remix' | 'astro' | 'sveltekit' | 'cra' | 'custom' | 'unknown';
|
||||
packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'unknown';
|
||||
packageJson?: { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
||||
styleSystem: 'tailwind' | 'css' | 'styled-components' | 'emotion' | 'unknown';
|
||||
routes?: { kind: 'next-app' | 'next-pages' | 'react-router' | 'vite-router' | 'sveltekit' | 'unknown' };
|
||||
walkedAt: string;
|
||||
walkBudgetMs: number;
|
||||
}
|
||||
|
||||
export interface CodeImportRunOptions {
|
||||
// Repo to walk. The atom never mutates this directory.
|
||||
repoPath: string;
|
||||
// Project cwd to write the snapshot under.
|
||||
cwd: string;
|
||||
// Walk budget (ms). Default 60s. A larger monorepo aborts gracefully
|
||||
// and records every unwalked entry in `skipped[]` with reason
|
||||
// 'budget-exceeded'.
|
||||
budgetMs?: number;
|
||||
// Per-file size cap (bytes). Files above this are listed but their
|
||||
// imports[] stay empty. Default 1 MiB.
|
||||
largeFileBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BUDGET_MS = 60_000;
|
||||
const DEFAULT_LARGE_FILE = 1 * 1024 * 1024;
|
||||
|
||||
const SKIPLIST = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'.pnpm-store',
|
||||
'.parcel-cache',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'coverage',
|
||||
'.vercel',
|
||||
'.vscode',
|
||||
]);
|
||||
|
||||
const LANG_EXT: Record<string, CodeImportFileEntry['language']> = {
|
||||
'.ts': 'ts',
|
||||
'.tsx': 'tsx',
|
||||
'.js': 'js',
|
||||
'.jsx': 'jsx',
|
||||
'.cjs': 'js',
|
||||
'.mjs': 'js',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.sass': 'scss',
|
||||
'.json': 'json',
|
||||
'.html': 'html',
|
||||
'.htm': 'html',
|
||||
'.md': 'md',
|
||||
'.mdx': 'md',
|
||||
};
|
||||
|
||||
const IMPORT_RE = /^\s*import\s+(?:[^'"`]+\sfrom\s+)?['"]([^'"]+)['"]/gm;
|
||||
|
||||
export async function runCodeImport(opts: CodeImportRunOptions): Promise<CodeImportIndex> {
|
||||
const repoPath = path.resolve(opts.repoPath);
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
const budgetMs = opts.budgetMs ?? DEFAULT_BUDGET_MS;
|
||||
const largeFileBytes = opts.largeFileBytes ?? DEFAULT_LARGE_FILE;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const stats = await fsp.stat(repoPath).catch(() => null);
|
||||
if (!stats || !stats.isDirectory()) {
|
||||
throw new Error(`code-import: repoPath ${repoPath} is not a directory`);
|
||||
}
|
||||
|
||||
const pkg = await readPackageJson(repoPath);
|
||||
const framework = detectFramework(repoPath, pkg);
|
||||
const packageManager = await detectPackageManager(repoPath);
|
||||
const styleSystem = detectStyleSystem(pkg);
|
||||
const routes = await detectRoutes(repoPath, framework);
|
||||
|
||||
const files: CodeImportFileEntry[] = [];
|
||||
const skipped: CodeImportSkipped[] = [];
|
||||
const queue: string[] = [repoPath];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const dir = queue.pop()!;
|
||||
if (Date.now() - startedAt > budgetMs) {
|
||||
skipped.push({ path: path.relative(repoPath, dir) || '.', reason: 'budget-exceeded' });
|
||||
// Drain remaining queue entries as budget-exceeded so the
|
||||
// human can see what we missed.
|
||||
for (const remaining of queue) {
|
||||
skipped.push({ path: path.relative(repoPath, remaining) || '.', reason: 'budget-exceeded' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
const rel = path.relative(repoPath, abs);
|
||||
if (entry.isSymbolicLink()) {
|
||||
skipped.push({ path: rel, reason: 'symlink' });
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIPLIST.has(entry.name)) {
|
||||
skipped.push({ path: rel, reason: 'directory-skiplist' });
|
||||
continue;
|
||||
}
|
||||
queue.push(abs);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const lang = LANG_EXT[path.extname(entry.name).toLowerCase()];
|
||||
if (!lang) {
|
||||
skipped.push({ path: rel, reason: 'unsupported-extension' });
|
||||
continue;
|
||||
}
|
||||
const stat = await fsp.stat(abs);
|
||||
const fileEntry: CodeImportFileEntry = {
|
||||
path: rel.split(path.sep).join('/'),
|
||||
size: stat.size,
|
||||
language: lang,
|
||||
};
|
||||
if (stat.size > largeFileBytes) {
|
||||
skipped.push({ path: rel, reason: 'large-file' });
|
||||
} else if (lang === 'ts' || lang === 'tsx' || lang === 'js' || lang === 'jsx') {
|
||||
try {
|
||||
const text = await fsp.readFile(abs, 'utf8');
|
||||
const imports = extractImports(text);
|
||||
if (imports.length > 0) fileEntry.imports = imports;
|
||||
} catch {
|
||||
// best-effort; skip imports[]
|
||||
}
|
||||
}
|
||||
files.push(fileEntry);
|
||||
}
|
||||
}
|
||||
|
||||
const index: CodeImportIndex = {
|
||||
files,
|
||||
skipped,
|
||||
framework,
|
||||
packageManager,
|
||||
styleSystem,
|
||||
walkedAt: new Date().toISOString(),
|
||||
walkBudgetMs: budgetMs,
|
||||
};
|
||||
if (pkg) index.packageJson = pkg;
|
||||
if (routes) index.routes = routes;
|
||||
|
||||
// Persist under cwd.
|
||||
const dir = path.join(cwd, 'code');
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
await fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(index, null, 2) + '\n', 'utf8');
|
||||
return index;
|
||||
}
|
||||
|
||||
async function readPackageJson(repoPath: string): Promise<CodeImportIndex['packageJson'] | undefined> {
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(repoPath, 'package.json'), 'utf8');
|
||||
const pkg = JSON.parse(raw) as CodeImportIndex['packageJson'];
|
||||
return pkg ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function detectFramework(_repoPath: string, pkg: CodeImportIndex['packageJson']): CodeImportIndex['framework'] {
|
||||
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
||||
if (deps['next']) return 'next';
|
||||
if (deps['@sveltejs/kit']) return 'sveltekit';
|
||||
if (deps['astro']) return 'astro';
|
||||
if (deps['@remix-run/react'] || deps['@remix-run/node']) return 'remix';
|
||||
if (deps['vite']) return 'vite';
|
||||
if (deps['react-scripts']) return 'cra';
|
||||
if (deps['react'] || deps['vue'] || deps['svelte']) return 'custom';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function detectPackageManager(repoPath: string): Promise<CodeImportIndex['packageManager']> {
|
||||
if (await pathExists(path.join(repoPath, 'pnpm-lock.yaml'))) return 'pnpm';
|
||||
if (await pathExists(path.join(repoPath, 'yarn.lock'))) return 'yarn';
|
||||
if (await pathExists(path.join(repoPath, 'bun.lockb'))) return 'bun';
|
||||
if (await pathExists(path.join(repoPath, 'package-lock.json'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function detectStyleSystem(pkg: CodeImportIndex['packageJson']): CodeImportIndex['styleSystem'] {
|
||||
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
||||
if (deps['tailwindcss']) return 'tailwind';
|
||||
if (deps['styled-components']) return 'styled-components';
|
||||
if (deps['@emotion/react']) return 'emotion';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function detectRoutes(
|
||||
repoPath: string,
|
||||
framework: CodeImportIndex['framework'],
|
||||
): Promise<CodeImportIndex['routes']> {
|
||||
if (framework === 'next') {
|
||||
if (await pathExists(path.join(repoPath, 'app'))) return { kind: 'next-app' };
|
||||
if (await pathExists(path.join(repoPath, 'pages'))) return { kind: 'next-pages' };
|
||||
if (await pathExists(path.join(repoPath, 'src', 'app'))) return { kind: 'next-app' };
|
||||
if (await pathExists(path.join(repoPath, 'src', 'pages'))) return { kind: 'next-pages' };
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
if (framework === 'sveltekit') return { kind: 'sveltekit' };
|
||||
if (framework === 'remix' || framework === 'vite') return { kind: 'vite-router' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try { await fsp.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function extractImports(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
IMPORT_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = IMPORT_RE.exec(text)) !== null) {
|
||||
const spec = match[1];
|
||||
if (!spec) continue;
|
||||
if (seen.has(spec)) continue;
|
||||
seen.add(spec);
|
||||
out.push(spec);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
export * from './atoms.js';
|
||||
export * from './apply.js';
|
||||
export * from './atoms/build-test.js';
|
||||
export * from './atoms/code-import.js';
|
||||
export * from './bundled.js';
|
||||
export * from './connector-gate.js';
|
||||
export * from './export.js';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Phase 7 entry slice / spec §10 / §21.3.2 — code-import runner.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, symlink } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runCodeImport } from '../src/plugins/atoms/code-import.js';
|
||||
|
||||
let repo: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), 'od-code-import-'));
|
||||
repo = path.join(tmp, 'repo');
|
||||
cwd = path.join(tmp, 'cwd');
|
||||
await mkdir(repo, { recursive: true });
|
||||
await mkdir(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(path.dirname(repo), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('runCodeImport', () => {
|
||||
it('walks a Next.js app-router repo + records framework / package-manager / style', async () => {
|
||||
await writeFile(path.join(repo, 'package.json'), JSON.stringify({
|
||||
name: 'fixture',
|
||||
dependencies: { next: '15', react: '18' },
|
||||
devDependencies: { tailwindcss: '4', typescript: '5' },
|
||||
}));
|
||||
await writeFile(path.join(repo, 'pnpm-lock.yaml'), '');
|
||||
await mkdir(path.join(repo, 'app'), { recursive: true });
|
||||
await writeFile(path.join(repo, 'app', 'page.tsx'),
|
||||
`import { useState } from 'react';\nimport { Button } from '@/components/Button';\nexport default function Page(){return null}\n`);
|
||||
await mkdir(path.join(repo, 'components'), { recursive: true });
|
||||
await writeFile(path.join(repo, 'components', 'Button.tsx'),
|
||||
`import clsx from 'clsx';\nexport const Button = ({children}: {children: any}) => <button>{children}</button>;\n`);
|
||||
|
||||
const index = await runCodeImport({ repoPath: repo, cwd });
|
||||
expect(index.framework).toBe('next');
|
||||
expect(index.packageManager).toBe('pnpm');
|
||||
expect(index.styleSystem).toBe('tailwind');
|
||||
expect(index.routes).toEqual({ kind: 'next-app' });
|
||||
expect(index.files.map((f) => f.path).sort()).toEqual([
|
||||
'app/page.tsx',
|
||||
'components/Button.tsx',
|
||||
'package.json',
|
||||
]);
|
||||
const page = index.files.find((f) => f.path === 'app/page.tsx');
|
||||
expect(page?.imports).toEqual(['react', '@/components/Button']);
|
||||
});
|
||||
|
||||
it('skips node_modules / .git / dist via the skiplist + records reasons', async () => {
|
||||
await writeFile(path.join(repo, 'package.json'), JSON.stringify({ name: 'f' }));
|
||||
for (const skip of ['node_modules', '.git', 'dist']) {
|
||||
await mkdir(path.join(repo, skip), { recursive: true });
|
||||
await writeFile(path.join(repo, skip, 'noop.ts'), 'export const x = 1;\n');
|
||||
}
|
||||
await writeFile(path.join(repo, 'index.ts'), 'export {};\n');
|
||||
const index = await runCodeImport({ repoPath: repo, cwd });
|
||||
expect(index.files.map((f) => f.path).sort()).toEqual(['index.ts', 'package.json']);
|
||||
const skipped = index.skipped.filter((s) => s.reason === 'directory-skiplist').map((s) => s.path).sort();
|
||||
expect(skipped).toEqual(['.git', 'dist', 'node_modules']);
|
||||
});
|
||||
|
||||
it('marks symlinks as skipped without following them', async () => {
|
||||
await writeFile(path.join(repo, 'real.ts'), 'export const x = 1;\n');
|
||||
await symlink('real.ts', path.join(repo, 'link.ts'));
|
||||
const index = await runCodeImport({ repoPath: repo, cwd });
|
||||
expect(index.files.map((f) => f.path)).toContain('real.ts');
|
||||
expect(index.skipped.some((s) => s.reason === 'symlink' && s.path === 'link.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('records large files but skips their imports[]', async () => {
|
||||
const big = Buffer.alloc(5 * 1024, 0x20).toString('utf8') + "\nimport 'foo';\n";
|
||||
await writeFile(path.join(repo, 'huge.ts'), big);
|
||||
const index = await runCodeImport({ repoPath: repo, cwd, largeFileBytes: 1024 });
|
||||
const huge = index.files.find((f) => f.path === 'huge.ts');
|
||||
expect(huge).toBeDefined();
|
||||
expect(huge?.imports).toBeUndefined();
|
||||
expect(index.skipped.some((s) => s.reason === 'large-file' && s.path === 'huge.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists code/index.json under the project cwd', async () => {
|
||||
await writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
await runCodeImport({ repoPath: repo, cwd });
|
||||
const indexPath = path.join(cwd, 'code', 'index.json');
|
||||
const json = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
expect(json.files.map((f: { path: string }) => f.path)).toEqual(['a.ts']);
|
||||
});
|
||||
|
||||
it('throws when repoPath is not a directory', async () => {
|
||||
await expect(runCodeImport({ repoPath: path.join(repo, 'no-such-dir'), cwd }))
|
||||
.rejects.toThrow(/not a directory/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user