ucode-agent 1.6.0 → 1.7.0
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.
- package/README.md +399 -379
- package/package.json +4 -1
- package/src/core/livelog.js +113 -0
- package/src/core/loop.js +2105 -1948
- package/src/core/tests.js +86 -0
- package/src/tools/blocks.js +117 -0
- package/src/tools/cache.js +105 -0
- package/src/tools/index.js +634 -529
- package/src/tools/rename.js +157 -0
- package/src/tools/scaffold.js +24 -5
- package/src/tools/shell.js +799 -789
- package/src/tools/symbols.js +218 -0
- package/src/tools/types.js +179 -0
- package/src/ui/theme.js +5 -1
- package/templates/blocks/app-shell.tsx +81 -0
- package/templates/blocks/data-table.tsx +117 -0
- package/templates/blocks/empty-state.tsx +41 -0
- package/templates/blocks/page-header.tsx +27 -0
- package/templates/blocks/stat-cards.tsx +46 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests.js — running the tests a change actually affects.
|
|
3
|
+
*
|
|
4
|
+
* A project's whole suite is too slow to run after every edit, and running
|
|
5
|
+
* nothing means the model learns a change was wrong from the user rather than
|
|
6
|
+
* from the code. Both vitest and jest can be asked which tests reach a given
|
|
7
|
+
* file and run only those, which is usually a second or two.
|
|
8
|
+
*
|
|
9
|
+
* Nothing is installed to make this work. If the project has no test runner,
|
|
10
|
+
* or has one that cannot answer "which tests cover this file", the checks
|
|
11
|
+
* stay as they were: this adds a signal where one is available, and is silent
|
|
12
|
+
* where it is not.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const has = (deps, name) => Boolean(deps[name]);
|
|
19
|
+
|
|
20
|
+
/** A file that is itself a test, and so is its own related test. */
|
|
21
|
+
export const isTestFile = (rel) =>
|
|
22
|
+
/(?:^|[\\/])(?:__tests__|tests?)[\\/]/.test(rel) || /\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(rel) ||
|
|
23
|
+
/(?:^|[\\/])test_[^\\/]+\.py$/i.test(rel) || /_test\.py$/i.test(rel);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Which runner this project uses, read from its package.json. Only runners
|
|
27
|
+
* that can select tests by the file they cover are worth naming here.
|
|
28
|
+
*/
|
|
29
|
+
export async function testRunnerFor(dir) {
|
|
30
|
+
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8').catch(() => null);
|
|
31
|
+
if (raw) {
|
|
32
|
+
let pkg;
|
|
33
|
+
try { pkg = JSON.parse(raw); } catch { pkg = null; }
|
|
34
|
+
if (pkg) {
|
|
35
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
36
|
+
const script = String(pkg.scripts?.test ?? '');
|
|
37
|
+
if (has(deps, 'vitest') || /\bvitest\b/.test(script)) return 'vitest';
|
|
38
|
+
if (has(deps, 'jest') || /\bjest\b/.test(script)) return 'jest';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const py = await Promise.all(
|
|
42
|
+
['pytest.ini', 'pyproject.toml', 'setup.cfg', 'tox.ini'].map((f) =>
|
|
43
|
+
fs.access(path.join(dir, f)).then(() => true, () => false))
|
|
44
|
+
);
|
|
45
|
+
return py.some(Boolean) ? 'pytest' : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Quote a path for a shell, and use forward slashes so Windows agrees. */
|
|
49
|
+
const arg = (p) => `"${p.replace(/\\/g, '/')}"`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The command that runs only the tests reaching these files, or null when
|
|
53
|
+
* this runner cannot narrow it down.
|
|
54
|
+
*
|
|
55
|
+
* pytest has no "which tests cover this file", so it gets the test files from
|
|
56
|
+
* among those changed — enough to catch a test edited into failing, and
|
|
57
|
+
* honest about being less than the others.
|
|
58
|
+
*/
|
|
59
|
+
export function relatedCommand(runner, files) {
|
|
60
|
+
const list = files.filter(Boolean);
|
|
61
|
+
if (!list.length) return null;
|
|
62
|
+
|
|
63
|
+
if (runner === 'vitest') {
|
|
64
|
+
return `npx --no-install vitest related --run --passWithNoTests ${list.map(arg).join(' ')}`;
|
|
65
|
+
}
|
|
66
|
+
if (runner === 'jest') {
|
|
67
|
+
return `npx --no-install jest --findRelatedTests --passWithNoTests --silent ${list.map(arg).join(' ')}`;
|
|
68
|
+
}
|
|
69
|
+
if (runner === 'pytest') {
|
|
70
|
+
const tests = list.filter(isTestFile);
|
|
71
|
+
if (!tests.length) return null;
|
|
72
|
+
return `python -m pytest -q ${tests.map(arg).join(' ')}`;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The failing part of a test run, kept to what a model can act on. */
|
|
78
|
+
export function summariseFailures(runner, output, limit = 40) {
|
|
79
|
+
const lines = String(output ?? '').split('\n');
|
|
80
|
+
const interesting = lines.filter((l) =>
|
|
81
|
+
/^\s*(?:✗|×|✕|FAIL|●|E\s|_{3,}|AssertionError|Expected|Received|at\s)/.test(l) ||
|
|
82
|
+
/\b\d+ failed\b/i.test(l) || /^FAILED /.test(l)
|
|
83
|
+
);
|
|
84
|
+
const kept = (interesting.length ? interesting : lines.filter((l) => l.trim())).slice(0, limit);
|
|
85
|
+
return kept.join('\n');
|
|
86
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blocks.js — pieces of an app that are already right.
|
|
3
|
+
*
|
|
4
|
+
* A model writing a table from scratch writes a passable one: no empty state,
|
|
5
|
+
* no sort, numbers left-aligned, and nothing that works on a phone. It is not
|
|
6
|
+
* that it cannot do better, it is that doing better costs steps and attention
|
|
7
|
+
* that belong to the thing being built.
|
|
8
|
+
*
|
|
9
|
+
* These are the parts every app needs, written once and carefully: a shell, a
|
|
10
|
+
* page header, an empty state, a table, a row of stats. They are copied into
|
|
11
|
+
* the app as ordinary source files for the model to edit, not imported from a
|
|
12
|
+
* library it cannot change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { ToolFailure } from '../core/failure.js';
|
|
19
|
+
import { resolveIn, guard, result } from './shared.js';
|
|
20
|
+
|
|
21
|
+
const BLOCKS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'blocks');
|
|
22
|
+
|
|
23
|
+
/** What each block is for, and what it needs to be handed. */
|
|
24
|
+
export const CATALOGUE = {
|
|
25
|
+
'app-shell': {
|
|
26
|
+
what: 'The frame every page sits in: sidebar on desktop, the same nav behind a button on a phone.',
|
|
27
|
+
exports: 'AppShell',
|
|
28
|
+
use: '<AppShell title="Stride" nav={[{ href: "/", label: "Home" }]} current="/">…</AppShell>',
|
|
29
|
+
},
|
|
30
|
+
'page-header': {
|
|
31
|
+
what: 'The top of a page: title, a line about it, and the actions available here.',
|
|
32
|
+
exports: 'PageHeader',
|
|
33
|
+
use: '<PageHeader title="Invoices" description="Everything you have billed." actions={<Button>New</Button>} />',
|
|
34
|
+
},
|
|
35
|
+
'empty-state': {
|
|
36
|
+
what: 'What a list looks like before anything is in it, with the one action that fills it.',
|
|
37
|
+
exports: 'EmptyState',
|
|
38
|
+
use: '<EmptyState title="No invoices yet" description="They will appear here." actionLabel="New invoice" />',
|
|
39
|
+
},
|
|
40
|
+
'data-table': {
|
|
41
|
+
what: 'A table you can search and sort, with an empty state and numbers aligned right.',
|
|
42
|
+
exports: 'DataTable, Column',
|
|
43
|
+
use: '<DataTable rows={rows} columns={[{ key: "name", header: "Name" }, { key: "total", header: "Total", numeric: true }]} />',
|
|
44
|
+
},
|
|
45
|
+
'stat-cards': {
|
|
46
|
+
what: 'The row of numbers at the top of a dashboard, each with what it is measured against.',
|
|
47
|
+
exports: 'StatCards, Stat',
|
|
48
|
+
use: '<StatCards stats={[{ label: "Revenue", value: "£12,400", change: 8 }]} />',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const BLOCK_NAMES = Object.keys(CATALOGUE);
|
|
53
|
+
|
|
54
|
+
const listing = () =>
|
|
55
|
+
BLOCK_NAMES.map((name) => ` ${name} — ${CATALOGUE[name].what}`).join('\n');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Copy a block into an app, or list what there is. The file lands in
|
|
59
|
+
* src/components/blocks/ and is the app's to edit from then on.
|
|
60
|
+
*/
|
|
61
|
+
export async function addBlock({ name, folder = '.' }) {
|
|
62
|
+
const wanted = String(name ?? '').trim();
|
|
63
|
+
|
|
64
|
+
if (!wanted) {
|
|
65
|
+
return result(
|
|
66
|
+
`Blocks you can add, with add_block({ name, folder }):\n\n${listing()}\n\n` +
|
|
67
|
+
'Each one is copied into the app as a source file you can then edit.',
|
|
68
|
+
`${BLOCK_NAMES.length} blocks`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!CATALOGUE[wanted]) {
|
|
73
|
+
throw new ToolFailure({
|
|
74
|
+
kind: 'no_such_block',
|
|
75
|
+
attempted: `adding the "${wanted}" block`,
|
|
76
|
+
failed: `There is no block called "${wanted}".`,
|
|
77
|
+
fix: `Pick one of: ${BLOCK_NAMES.join(', ')}. Call add_block with no name to see what each is for.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const target = resolveIn(folder || '.', 'add_block', 'folder');
|
|
82
|
+
await guard(target, `add the ${wanted} block to ${target.abs}`);
|
|
83
|
+
|
|
84
|
+
const source = await fs.readFile(path.join(BLOCKS, `${wanted}.tsx`), 'utf8').catch(() => null);
|
|
85
|
+
if (source === null) {
|
|
86
|
+
throw new ToolFailure({
|
|
87
|
+
kind: 'block_missing',
|
|
88
|
+
attempted: `adding the "${wanted}" block`,
|
|
89
|
+
failed: `The ${wanted} block is listed but its file is not installed.`,
|
|
90
|
+
fix: 'Write the component by hand, or reinstall ucode.',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rel = path.join('src', 'components', 'blocks', `${wanted}.tsx`);
|
|
95
|
+
const dest = path.join(target.abs, rel);
|
|
96
|
+
|
|
97
|
+
if (await fs.stat(dest).catch(() => null)) {
|
|
98
|
+
return result(
|
|
99
|
+
`${rel} is already in ${target.show}; it has been left as it is so your edits survive.\n` +
|
|
100
|
+
`Import: import { ${CATALOGUE[wanted].exports.split(',')[0].trim()} } from "@/components/blocks/${wanted}";`,
|
|
101
|
+
'already there'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
106
|
+
await fs.writeFile(dest, source, 'utf8');
|
|
107
|
+
|
|
108
|
+
const first = CATALOGUE[wanted].exports.split(',')[0].trim();
|
|
109
|
+
return result(
|
|
110
|
+
`Added ${rel} to ${target.show}.\n\n` +
|
|
111
|
+
`import { ${CATALOGUE[wanted].exports} } from "@/components/blocks/${wanted}";\n\n` +
|
|
112
|
+
`${CATALOGUE[wanted].use}\n\n` +
|
|
113
|
+
`It is an ordinary file now — edit it to suit the app rather than working around it. ` +
|
|
114
|
+
`It uses the shadcn components already in the starter, so nothing needs installing.`,
|
|
115
|
+
`${first} added`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache.js — the starter's packages, installed once and reused.
|
|
3
|
+
*
|
|
4
|
+
* A new app's `npm install` is a minute of the model waiting on the network
|
|
5
|
+
* for a tree it has installed a hundred times before. The first install of a
|
|
6
|
+
* starter is kept in ~/.ucode/cache, keyed by the lockfile, and every app
|
|
7
|
+
* after that gets it as hard links: the same files on disk under a new name,
|
|
8
|
+
* so it costs no extra space and finishes in seconds.
|
|
9
|
+
*
|
|
10
|
+
* Hard links rather than a junction or a symlink, because Turbopack refuses a
|
|
11
|
+
* node_modules that points outside the project. Anything that cannot be linked
|
|
12
|
+
* — another drive, a filesystem without links — is copied instead, and if even
|
|
13
|
+
* that fails the normal install runs as before.
|
|
14
|
+
*
|
|
15
|
+
* A cache entry is only used once its marker file is there, which is written
|
|
16
|
+
* last: a half-written cache from an interrupted copy is ignored, not used.
|
|
17
|
+
*
|
|
18
|
+
* The key is the *starter's* lockfile, never the app's. npm rewrites an app's
|
|
19
|
+
* package-lock.json as it installs, so keying on that would file the cache
|
|
20
|
+
* under one name and look it up under another — a cache that never hits and
|
|
21
|
+
* never says why.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { promises as fs } from 'node:fs';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
const MARKER = '.ucode-complete';
|
|
30
|
+
|
|
31
|
+
/** Written into node_modules by build tools; never worth carrying between apps. */
|
|
32
|
+
const NOT_WORTH_KEEPING = new Set(['.cache', '.vite', '.turbo']);
|
|
33
|
+
|
|
34
|
+
export const cacheRoot = (home = os.homedir()) => path.join(home, '.ucode', 'cache');
|
|
35
|
+
|
|
36
|
+
/** Where this starter's install lives, given its lockfile text. */
|
|
37
|
+
export function slotFor(template, lockText, home) {
|
|
38
|
+
const hash = createHash('sha1').update(String(lockText ?? '')).digest('hex').slice(0, 12);
|
|
39
|
+
return path.join(cacheRoot(home), `${template}-${hash}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Copy a tree as hard links, falling back to real copies where linking fails.
|
|
44
|
+
* `skip` names top-level entries to leave behind.
|
|
45
|
+
*/
|
|
46
|
+
export async function linkTree(from, to, skip = null) {
|
|
47
|
+
let files = 0;
|
|
48
|
+
const walk = async (src, dst, top) => {
|
|
49
|
+
await fs.mkdir(dst, { recursive: true });
|
|
50
|
+
for (const entry of await fs.readdir(src, { withFileTypes: true })) {
|
|
51
|
+
if (top && skip?.has(entry.name)) continue;
|
|
52
|
+
const a = path.join(src, entry.name);
|
|
53
|
+
const b = path.join(dst, entry.name);
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
await walk(a, b, false);
|
|
56
|
+
} else if (entry.isSymbolicLink()) {
|
|
57
|
+
const target = await fs.readlink(a).catch(() => null);
|
|
58
|
+
if (target) await fs.symlink(target, b).catch(() => fs.copyFile(a, b).catch(() => {}));
|
|
59
|
+
} else {
|
|
60
|
+
await fs.link(a, b).catch(() => fs.copyFile(a, b));
|
|
61
|
+
files++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
await walk(from, to, true);
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Put the cached packages into the app, if this starter has been installed
|
|
71
|
+
* before. Returns how many files were linked, or 0 when there is no cache.
|
|
72
|
+
*/
|
|
73
|
+
export async function restore(appDir, template, lockText, { home } = {}) {
|
|
74
|
+
if (!lockText) return 0;
|
|
75
|
+
const slot = slotFor(template, lockText, home);
|
|
76
|
+
if (!(await fs.stat(path.join(slot, MARKER)).catch(() => null))) return 0;
|
|
77
|
+
const target = path.join(appDir, 'node_modules');
|
|
78
|
+
if (await fs.stat(target).catch(() => null)) return 0; // already installed
|
|
79
|
+
try {
|
|
80
|
+
return await linkTree(path.join(slot, 'node_modules'), target);
|
|
81
|
+
} catch {
|
|
82
|
+
await fs.rm(target, { recursive: true, force: true }).catch(() => {});
|
|
83
|
+
return 0; // a partial restore is worse than none: let npm do it
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Keep this app's node_modules as the cache for its starter, so the next app
|
|
89
|
+
* starts from it. Runs after a successful install and never throws.
|
|
90
|
+
*/
|
|
91
|
+
export async function populate(appDir, template, lockText, { home } = {}) {
|
|
92
|
+
try {
|
|
93
|
+
if (!lockText) return false;
|
|
94
|
+
const slot = slotFor(template, lockText, home);
|
|
95
|
+
if (await fs.stat(path.join(slot, MARKER)).catch(() => null)) return false;
|
|
96
|
+
const from = path.join(appDir, 'node_modules');
|
|
97
|
+
if (!(await fs.stat(from).catch(() => null))) return false;
|
|
98
|
+
await fs.rm(slot, { recursive: true, force: true }).catch(() => {});
|
|
99
|
+
await linkTree(from, path.join(slot, 'node_modules'), NOT_WORTH_KEEPING);
|
|
100
|
+
await fs.writeFile(path.join(slot, MARKER), `${template}\n${new Date().toISOString()}\n`);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false; // the cache is an optimisation; never let it break a build
|
|
104
|
+
}
|
|
105
|
+
}
|