mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(plugins): rewrite-plan atom impl (Phase 7 entry slice)
Plan O3 / spec §20.3 / §21.3.2.
apps/daemon/src/plugins/atoms/rewrite-plan.ts produces the four
files the SKILL.md fragment promises:
<cwd>/plan/plan.md — narrative (heuristic baseline; LLM
step overwrites)
<cwd>/plan/ownership.json — { file, layer } per file across the
imported repo, sorted lexicographically
<cwd>/plan/steps.json — ordered { id, files[], rationale, risk }
<cwd>/plan/meta.json — generatedAt + atomDigest +
tokenMapDigest + intent
Ownership classifier (spec §11.5.1 / §20.3 tiers):
shell layout.tsx / _app.tsx / _document.tsx / providers.tsx /
theme.ts / globals.css / tokens.css / design-tokens.css
route app/.../page.tsx, pages/index.tsx, route.ts entries
shared hooks/ lib/ utils/ providers/ context/ store/ stores/
services/ api/ shared/ common/, plus repo-root config files
leaf everything else (default)
The classifier keeps shell rare so patch-edit's safety gate doesn't
lock the agent out of plain component edits.
Step generator:
- tokens-alignment (low risk) when design-extract surfaced
inline literals; files[] is the leaf set
carrying those literals.
- rewrite-<slug> (low risk) one per leaf component file;
sibling stylesheets (Button.tsx + Button.css)
fold into the same step.
- shared-and-route-touchups (medium risk) when shared/route files
exist, marks the cross-cutting boundary.
- build-test (low risk) closes every plan; rewrite-plan
refuses to drop this step.
atomDigest is over a canonicalised view of code/index.json so re-walks
that don't change the file roster don't invalidate the plan; intent is
not in the digest. tokenMapDigest is over code/tokens.json or 'none'.
Daemon tests: 1564 → 1571 (+7 cases on plugins-rewrite-plan: tier
classification across leaf/shared/route/shell, tokens-alignment
emission, per-leaf rewrite step + sibling stylesheet bundling,
build-test always last, persisted file layout, digest stability
across runs, missing code/index.json error path).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
c17d4ab952
commit
f2b62c0303
3 changed files with 437 additions and 0 deletions
311
apps/daemon/src/plugins/atoms/rewrite-plan.ts
Normal file
311
apps/daemon/src/plugins/atoms/rewrite-plan.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// Phase 7 entry slice / spec §20.3 / §21.3.2 — rewrite-plan atom.
|
||||
//
|
||||
// SKILL.md fragment ships at plugins/_official/atoms/rewrite-plan/.
|
||||
// Given a project cwd that already has code/index.json (from
|
||||
// code-import) + an optional code/tokens.json (from design-extract),
|
||||
// the runner produces a heuristic ownership classification and a
|
||||
// per-component step list. The narrative `plan.md` is intentionally
|
||||
// short — it's a scaffold the agent overwrites once the LLM-driven
|
||||
// stage runs; the heuristic baseline gives subsequent stages
|
||||
// (`patch-edit` / `diff-review` / `build-test`) an audit trail to
|
||||
// reference even if the LLM step is skipped or fails.
|
||||
//
|
||||
// Ownership tiers (spec §11.5.1 / §20.3):
|
||||
// leaf — single-component leaf files (Button.tsx, Card.css)
|
||||
// shared — shared infrastructure (hooks/, lib/, utils/)
|
||||
// route — page-level / route entry points (app/page.tsx,
|
||||
// pages/index.tsx, src/app/(group)/page.tsx)
|
||||
// shell — top-level layout / framework boundaries
|
||||
// (layout.tsx, _app.tsx, providers/, root css)
|
||||
//
|
||||
// The classifier keeps false-positive `shell` rare: only files that
|
||||
// match a strict allowlist (layout|root|provider|theme|tokens|globals)
|
||||
// are tagged `shell`. Everything else collapses to `leaf` so the
|
||||
// patch-edit safety gate doesn't lock the agent out of plain
|
||||
// component edits.
|
||||
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { CodeImportIndex } from './code-import.js';
|
||||
import type { DesignExtractReport } from './design-extract.js';
|
||||
|
||||
export type OwnershipTier = 'leaf' | 'shared' | 'route' | 'shell';
|
||||
|
||||
export interface OwnershipEntry {
|
||||
file: string;
|
||||
layer: OwnershipTier;
|
||||
}
|
||||
|
||||
export interface RewriteStep {
|
||||
id: string;
|
||||
files: string[];
|
||||
rationale: string;
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface RewritePlanReport {
|
||||
steps: RewriteStep[];
|
||||
ownership: OwnershipEntry[];
|
||||
// SHA-1 digests of the inputs we relied on. The handoff atom
|
||||
// promotes these into ArtifactManifest provenance so a reviewer
|
||||
// can prove "this plan was generated against this snapshot of
|
||||
// code-import / token-map".
|
||||
meta: {
|
||||
generatedAt: string;
|
||||
atomDigest: string; // hash of canonicalised code/index.json
|
||||
tokenMapDigest: string; // hash of code/tokens.json (or 'none')
|
||||
intent: string; // user-supplied intent string (echoed)
|
||||
};
|
||||
// The narrative plan.md scaffold the runner emits. The agent is
|
||||
// expected to overwrite this with its own narrative; we ship a
|
||||
// baseline so downstream stages always see a non-empty file.
|
||||
planMarkdown: string;
|
||||
}
|
||||
|
||||
export interface RewritePlanOptions {
|
||||
cwd: string;
|
||||
// The user's brief, copied into plan.md and into steps[].rationale
|
||||
// when the heuristic can't think of anything better.
|
||||
intent?: string;
|
||||
// Override the build-test step id name (default 'build-test').
|
||||
buildTestStepId?: string;
|
||||
}
|
||||
|
||||
const SHELL_BASENAMES = new Set([
|
||||
'layout.tsx', 'layout.jsx', 'layout.ts', 'layout.js',
|
||||
'_app.tsx', '_app.jsx', '_app.ts', '_app.js',
|
||||
'_document.tsx', '_document.jsx',
|
||||
'providers.tsx', 'providers.tsx', 'providers.ts',
|
||||
'theme.ts', 'theme.tsx',
|
||||
'globals.css', 'global.css',
|
||||
'tokens.css', 'design-tokens.css',
|
||||
]);
|
||||
|
||||
const ROUTE_DIR_HINT = /(?:^|\/)(?:app|pages)\//;
|
||||
const ROUTE_BASENAME = /^(?:page|index|route)\.[tj]sx?$/;
|
||||
|
||||
const SHARED_DIR_HINT = /(?:^|\/)(?:hooks|lib|utils|providers|context|store|stores|services|api|shared|common)\//;
|
||||
|
||||
const COMPONENT_DIR_HINT = /(?:^|\/)components?\//;
|
||||
|
||||
export async function runRewritePlan(opts: RewritePlanOptions): Promise<RewritePlanReport> {
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
const indexPath = path.join(cwd, 'code', 'index.json');
|
||||
const tokensPath = path.join(cwd, 'code', 'tokens.json');
|
||||
const intent = (opts.intent ?? '').trim();
|
||||
const buildTestStepId = opts.buildTestStepId ?? 'build-test';
|
||||
|
||||
let index: CodeImportIndex;
|
||||
try {
|
||||
index = JSON.parse(await fsp.readFile(indexPath, 'utf8')) as CodeImportIndex;
|
||||
} catch (err) {
|
||||
throw new Error(`rewrite-plan: missing or unreadable code/index.json (run code-import first): ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
let tokens: DesignExtractReport | undefined;
|
||||
try {
|
||||
tokens = JSON.parse(await fsp.readFile(tokensPath, 'utf8')) as DesignExtractReport;
|
||||
} catch {
|
||||
tokens = undefined;
|
||||
}
|
||||
|
||||
const ownership = classifyOwnership(index);
|
||||
const steps = composeSteps({ index, ownership, tokens, intent, buildTestStepId });
|
||||
const meta = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
atomDigest: digestObject(canonicaliseIndex(index)),
|
||||
tokenMapDigest: tokens ? digestObject(tokens) : 'none',
|
||||
intent: intent || '',
|
||||
};
|
||||
const planMarkdown = renderNarrative({ intent, ownership, steps, tokens });
|
||||
|
||||
// Persist all four files under <cwd>/plan/.
|
||||
const planDir = path.join(cwd, 'plan');
|
||||
await fsp.mkdir(planDir, { recursive: true });
|
||||
await fsp.writeFile(path.join(planDir, 'plan.md'), planMarkdown, 'utf8');
|
||||
await fsp.writeFile(path.join(planDir, 'ownership.json'), JSON.stringify(ownership, null, 2) + '\n', 'utf8');
|
||||
await fsp.writeFile(path.join(planDir, 'steps.json'), JSON.stringify(steps, null, 2) + '\n', 'utf8');
|
||||
await fsp.writeFile(path.join(planDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
|
||||
return { steps, ownership, meta, planMarkdown };
|
||||
}
|
||||
|
||||
function classifyOwnership(index: CodeImportIndex): OwnershipEntry[] {
|
||||
const out: OwnershipEntry[] = [];
|
||||
for (const f of index.files) {
|
||||
out.push({ file: f.path, layer: classifyOne(f.path) });
|
||||
}
|
||||
// Ownership is sorted lexicographically so the JSON output is
|
||||
// diff-friendly across runs.
|
||||
out.sort((a, b) => a.file.localeCompare(b.file));
|
||||
return out;
|
||||
}
|
||||
|
||||
function classifyOne(file: string): OwnershipTier {
|
||||
const base = path.posix.basename(file);
|
||||
if (SHELL_BASENAMES.has(base)) return 'shell';
|
||||
if (ROUTE_DIR_HINT.test(file) && ROUTE_BASENAME.test(base)) return 'route';
|
||||
// Layout files in the App Router that aren't in SHELL_BASENAMES list:
|
||||
if (ROUTE_DIR_HINT.test(file) && /^layout\.[tj]sx?$/.test(base)) return 'shell';
|
||||
if (SHARED_DIR_HINT.test(file)) return 'shared';
|
||||
if (COMPONENT_DIR_HINT.test(file)) return 'leaf';
|
||||
// Files at the repo root that aren't shell are usually config — keep them
|
||||
// as `shared` so the patch-edit gate insists on `risk: 'medium'+'.
|
||||
if (!file.includes('/')) return 'shared';
|
||||
return 'leaf';
|
||||
}
|
||||
|
||||
function composeSteps(args: {
|
||||
index: CodeImportIndex;
|
||||
ownership: OwnershipEntry[];
|
||||
tokens: DesignExtractReport | undefined;
|
||||
intent: string;
|
||||
buildTestStepId: string;
|
||||
}): RewriteStep[] {
|
||||
const steps: RewriteStep[] = [];
|
||||
|
||||
// Step 0: token alignment when design-extract found anything. The
|
||||
// step's files[] enumerates leaf files that contain hex literals;
|
||||
// patch-edit replaces them with the active DS token references.
|
||||
if (args.tokens && (args.tokens.colors.length > 0 || args.tokens.spacing.length > 0)) {
|
||||
const filesWithTokens = new Set<string>();
|
||||
for (const t of args.tokens.colors) for (const s of t.sources) filesWithTokens.add(s.split(':')[0]!);
|
||||
for (const t of args.tokens.spacing) for (const s of t.sources) filesWithTokens.add(s.split(':')[0]!);
|
||||
if (filesWithTokens.size > 0) {
|
||||
steps.push({
|
||||
id: 'tokens-alignment',
|
||||
files: [...filesWithTokens].sort(),
|
||||
rationale: 'Replace inline literal colours / spacing with active design-system tokens; keep semantic shape unchanged.',
|
||||
risk: 'low',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1..N: per-leaf-component step (one step per leaf file in
|
||||
// the components/ tree). Each step bundles the leaf file with any
|
||||
// sibling stylesheet of the same basename.
|
||||
const leafFiles = args.ownership.filter((o) => o.layer === 'leaf' && /\.(?:tsx|jsx|ts|js|vue|svelte)$/.test(o.file));
|
||||
for (const f of leafFiles) {
|
||||
const sibling = findSiblingStylesheet(args.index, f.file);
|
||||
const files = sibling ? [f.file, sibling] : [f.file];
|
||||
steps.push({
|
||||
id: `rewrite-${slug(f.file)}`,
|
||||
files,
|
||||
rationale: `Rewrite ${f.file} per the user's intent${args.intent ? `: ${args.intent}` : ''}.`,
|
||||
risk: 'low',
|
||||
});
|
||||
}
|
||||
|
||||
// Step N+1: shared / route refactors when present, marked medium.
|
||||
const sharedRouteFiles = args.ownership
|
||||
.filter((o) => o.layer === 'shared' || o.layer === 'route')
|
||||
.map((o) => o.file);
|
||||
if (sharedRouteFiles.length > 0) {
|
||||
steps.push({
|
||||
id: 'shared-and-route-touchups',
|
||||
files: sharedRouteFiles,
|
||||
rationale: 'Update shared infrastructure / route entry points to reflect leaf rewrites; cross-cutting changes only.',
|
||||
risk: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Final step: build-test gate. patch-edit refuses to mark the
|
||||
// pipeline converged without this step's file list reaching
|
||||
// build.passing && tests.passing.
|
||||
steps.push({
|
||||
id: args.buildTestStepId,
|
||||
files: [],
|
||||
rationale: 'Run typecheck + tests; iterate until build.passing && tests.passing.',
|
||||
risk: 'low',
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function findSiblingStylesheet(index: CodeImportIndex, file: string): string | undefined {
|
||||
const dir = path.posix.dirname(file);
|
||||
const base = path.posix.basename(file).replace(/\.[^.]+$/, '');
|
||||
const candidates = [`${base}.css`, `${base}.scss`, `${base}.module.css`];
|
||||
for (const f of index.files) {
|
||||
const fdir = path.posix.dirname(f.path);
|
||||
if (fdir !== dir) continue;
|
||||
if (candidates.includes(path.posix.basename(f.path))) return f.path;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function slug(file: string): string {
|
||||
return file
|
||||
.replace(/^.*\//, '')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function canonicaliseIndex(index: CodeImportIndex): unknown {
|
||||
// Strip the walk-time fields (walkedAt / walkBudgetMs) and
|
||||
// skipped[] so the digest only changes when the file roster
|
||||
// changes. Otherwise re-walks would invalidate every plan even
|
||||
// when the source tree is identical.
|
||||
return {
|
||||
framework: index.framework,
|
||||
packageManager: index.packageManager,
|
||||
styleSystem: index.styleSystem,
|
||||
routes: index.routes,
|
||||
files: index.files
|
||||
.map((f) => ({ path: f.path, language: f.language, size: f.size, imports: f.imports ?? [] }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
};
|
||||
}
|
||||
|
||||
function digestObject(obj: unknown): string {
|
||||
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
|
||||
}
|
||||
|
||||
function renderNarrative(args: {
|
||||
intent: string;
|
||||
ownership: OwnershipEntry[];
|
||||
steps: RewriteStep[];
|
||||
tokens: DesignExtractReport | undefined;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('# Rewrite plan');
|
||||
lines.push('');
|
||||
if (args.intent) {
|
||||
lines.push(`**Intent**: ${args.intent}`);
|
||||
lines.push('');
|
||||
}
|
||||
const tierCount = (tier: OwnershipTier) => args.ownership.filter((o) => o.layer === tier).length;
|
||||
lines.push('## Ownership snapshot');
|
||||
lines.push('');
|
||||
lines.push(`- shell: ${tierCount('shell')} files`);
|
||||
lines.push(`- route: ${tierCount('route')} files`);
|
||||
lines.push(`- shared: ${tierCount('shared')} files`);
|
||||
lines.push(`- leaf: ${tierCount('leaf')} files`);
|
||||
lines.push('');
|
||||
if (args.tokens) {
|
||||
lines.push('## Design tokens detected');
|
||||
lines.push('');
|
||||
lines.push(`- colors: ${args.tokens.colors.length}`);
|
||||
lines.push(`- typography: ${args.tokens.typography.length}`);
|
||||
lines.push(`- spacing: ${args.tokens.spacing.length}`);
|
||||
lines.push(`- radius: ${args.tokens.radius.length}`);
|
||||
lines.push(`- shadow: ${args.tokens.shadow.length}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('## Steps');
|
||||
lines.push('');
|
||||
for (const step of args.steps) {
|
||||
lines.push(`### ${step.id} — risk: ${step.risk}`);
|
||||
lines.push('');
|
||||
lines.push(step.rationale);
|
||||
if (step.files.length > 0) {
|
||||
lines.push('');
|
||||
for (const f of step.files) lines.push(`- \`${f}\``);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export * from './atoms/build-test.js';
|
|||
export * from './atoms/code-import.js';
|
||||
export * from './atoms/design-extract.js';
|
||||
export * from './atoms/handoff.js';
|
||||
export * from './atoms/rewrite-plan.js';
|
||||
export * from './bundled.js';
|
||||
export * from './connector-gate.js';
|
||||
export * from './export.js';
|
||||
|
|
|
|||
125
apps/daemon/tests/plugins-rewrite-plan.test.ts
Normal file
125
apps/daemon/tests/plugins-rewrite-plan.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Phase 7 entry slice — rewrite-plan atom impl.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runCodeImport } from '../src/plugins/atoms/code-import.js';
|
||||
import { runDesignExtract } from '../src/plugins/atoms/design-extract.js';
|
||||
import { runRewritePlan } from '../src/plugins/atoms/rewrite-plan.js';
|
||||
|
||||
let repo: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), 'od-rewrite-plan-'));
|
||||
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 });
|
||||
});
|
||||
|
||||
async function setupNextRepo() {
|
||||
await writeFile(path.join(repo, 'package.json'), JSON.stringify({
|
||||
name: 'fixture',
|
||||
dependencies: { next: '15', react: '18' },
|
||||
devDependencies: { tailwindcss: '4' },
|
||||
}));
|
||||
await writeFile(path.join(repo, 'pnpm-lock.yaml'), '');
|
||||
await mkdir(path.join(repo, 'app'), { recursive: true });
|
||||
await mkdir(path.join(repo, 'components'),{ recursive: true });
|
||||
await mkdir(path.join(repo, 'lib'), { recursive: true });
|
||||
await writeFile(path.join(repo, 'app', 'layout.tsx'),
|
||||
`export default function Layout({ children }: any) { return <html><body>{children}</body></html>; }\n`);
|
||||
await writeFile(path.join(repo, 'app', 'page.tsx'),
|
||||
`import Button from '@/components/Button';\nexport default function Page() { return <Button />; }\n`);
|
||||
await writeFile(path.join(repo, 'components', 'Button.tsx'),
|
||||
`export default function Button() { return <button style={{ color: '#5b8def' }} />; }\n`);
|
||||
await writeFile(path.join(repo, 'components', 'Button.css'),
|
||||
`.btn { padding: 16px; color: #5b8def; }`);
|
||||
await writeFile(path.join(repo, 'lib', 'fetcher.ts'),
|
||||
`export const fetcher = (u: string) => fetch(u).then((r) => r.json());\n`);
|
||||
await runCodeImport({ repoPath: repo, cwd });
|
||||
await runDesignExtract({ cwd, repoPath: repo });
|
||||
}
|
||||
|
||||
describe('runRewritePlan', () => {
|
||||
it('classifies ownership across leaf / shared / route / shell', async () => {
|
||||
await setupNextRepo();
|
||||
const report = await runRewritePlan({ cwd, intent: 'tighten the brand' });
|
||||
const own = new Map(report.ownership.map((o) => [o.file, o.layer]));
|
||||
expect(own.get('app/layout.tsx')).toBe('shell');
|
||||
expect(own.get('app/page.tsx')).toBe('route');
|
||||
expect(own.get('components/Button.tsx')).toBe('leaf');
|
||||
expect(own.get('lib/fetcher.ts')).toBe('shared');
|
||||
// Root-level package.json defaults to shared (config) per the
|
||||
// safety contract.
|
||||
expect(own.get('package.json')).toBe('shared');
|
||||
});
|
||||
|
||||
it('produces a tokens-alignment step when design-extract found inline literals', async () => {
|
||||
await setupNextRepo();
|
||||
const report = await runRewritePlan({ cwd });
|
||||
const ids = report.steps.map((s) => s.id);
|
||||
expect(ids).toContain('tokens-alignment');
|
||||
const step = report.steps.find((s) => s.id === 'tokens-alignment');
|
||||
expect(step?.risk).toBe('low');
|
||||
expect(step?.files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('emits one rewrite-<slug> step per leaf component file', async () => {
|
||||
await setupNextRepo();
|
||||
const report = await runRewritePlan({ cwd });
|
||||
const rewriteIds = report.steps.map((s) => s.id).filter((id) => id.startsWith('rewrite-'));
|
||||
expect(rewriteIds.length).toBeGreaterThanOrEqual(1);
|
||||
const buttonStep = report.steps.find((s) => s.id === 'rewrite-button');
|
||||
expect(buttonStep).toBeDefined();
|
||||
// Sibling stylesheet bundled into the same step.
|
||||
expect(buttonStep?.files).toEqual(expect.arrayContaining([
|
||||
'components/Button.tsx',
|
||||
'components/Button.css',
|
||||
]));
|
||||
});
|
||||
|
||||
it('always ends with a build-test step', async () => {
|
||||
await setupNextRepo();
|
||||
const report = await runRewritePlan({ cwd });
|
||||
const last = report.steps[report.steps.length - 1];
|
||||
expect(last?.id).toBe('build-test');
|
||||
expect(last?.risk).toBe('low');
|
||||
});
|
||||
|
||||
it('persists plan/{plan.md, ownership.json, steps.json, meta.json} under cwd', async () => {
|
||||
await setupNextRepo();
|
||||
await runRewritePlan({ cwd, intent: 'mvp polish' });
|
||||
const planMd = await readFile(path.join(cwd, 'plan', 'plan.md'), 'utf8');
|
||||
const ownJson = JSON.parse(await readFile(path.join(cwd, 'plan', 'ownership.json'), 'utf8'));
|
||||
const stepsJson = JSON.parse(await readFile(path.join(cwd, 'plan', 'steps.json'), 'utf8'));
|
||||
const metaJson = JSON.parse(await readFile(path.join(cwd, 'plan', 'meta.json'), 'utf8'));
|
||||
expect(planMd).toContain('# Rewrite plan');
|
||||
expect(planMd).toContain('mvp polish');
|
||||
expect(Array.isArray(ownJson)).toBe(true);
|
||||
expect(stepsJson.some((s: { id: string }) => s.id === 'build-test')).toBe(true);
|
||||
expect(typeof metaJson.atomDigest).toBe('string');
|
||||
expect(metaJson.atomDigest.length).toBe(40);
|
||||
});
|
||||
|
||||
it('produces stable atomDigest across two runs over the same code/index.json', async () => {
|
||||
await setupNextRepo();
|
||||
const a = await runRewritePlan({ cwd, intent: 'x' });
|
||||
const b = await runRewritePlan({ cwd, intent: 'y' });
|
||||
// Intent does not contribute to atomDigest; only the canonicalised
|
||||
// code/index.json roster does.
|
||||
expect(a.meta.atomDigest).toBe(b.meta.atomDigest);
|
||||
// Different intents produce different plan.md though.
|
||||
expect(a.planMarkdown).not.toBe(b.planMarkdown);
|
||||
});
|
||||
|
||||
it('throws a clear error when code/index.json is missing', async () => {
|
||||
await expect(runRewritePlan({ cwd })).rejects.toThrow(/code-import first/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue