From fce074dc15cc29e10c556025e77cd71d3f959b9a Mon Sep 17 00:00:00 2001 From: sebi Date: Sat, 10 Oct 2026 16:32:47 -0500 Subject: [PATCH 1/2] fix(cli): x g edits source lists syntax-aware; a feature's second entity; no stray db client; --dry-run lists every file; three-part permissions - A commented role map is no longer corrupted: list edits go through source-list-edit (masked literals, read-back check, refuse rather than write a broken file). The same quote/bracket scan is fixed in the api, handle, i18n and admin registrars. - A permission is declared only where its grant can be placed. - x g entity --feature writes entity-.ts and repo-.ts beside the first. - No packages/db/src/client.ts in a db package that exports no handle. - x g --dry-run runs the follow-up edits against an in-memory plan and lists every file, edit and refresh. - x g admin:page --permission admin:blog:write is accepted. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- packages/cli/src/admin-registration.ts | 29 +- packages/cli/src/api-registration.test.ts | 44 +++ packages/cli/src/api-registration.ts | 85 +++--- packages/cli/src/cmd-generate-entity.test.ts | 28 +- .../src/cmd-generate-second-entity.test.ts | 250 ++++++++++++++++++ packages/cli/src/cmd-generate.ts | 115 ++++---- packages/cli/src/generate-disk.ts | 38 +++ packages/cli/src/generate-files.ts | 7 +- packages/cli/src/generate-follow-ups.ts | 64 +++++ packages/cli/src/generate-format.test.ts | 22 ++ packages/cli/src/generate-grant-findings.ts | 14 +- .../cli/src/generate-grants-commented.test.ts | 131 +++++++++ packages/cli/src/generate-grants.ts | 131 +++++---- packages/cli/src/generate-kinds.test.ts | 18 +- packages/cli/src/generate-kinds.ts | 9 +- packages/cli/src/generate-name-shape.test.ts | 11 + packages/cli/src/generate-plural.ts | 11 + packages/cli/src/generated-imports.ts | 15 +- packages/cli/src/handle-registration.test.ts | 36 +++ packages/cli/src/handle-registration.ts | 100 +++++-- packages/cli/src/i18n-index.test.ts | 22 +- packages/cli/src/i18n-index.ts | 44 ++- packages/cli/src/source-list-edit.test.ts | 160 +++++++++++ packages/cli/src/source-list-edit.ts | 218 +++++++++++++++ packages/cli/src/templates/entity-module.ts | 29 ++ packages/cli/src/templates/entity.ts | 44 ++- packages/cli/src/workspace-dep-edit.ts | 23 +- wiki/CLI-Reference.md | 16 +- wiki/Entities-And-Migrations.md | 2 + 30 files changed, 1475 insertions(+), 253 deletions(-) create mode 100644 packages/cli/src/cmd-generate-second-entity.test.ts create mode 100644 packages/cli/src/generate-disk.ts create mode 100644 packages/cli/src/generate-follow-ups.ts create mode 100644 packages/cli/src/generate-grants-commented.test.ts create mode 100644 packages/cli/src/source-list-edit.test.ts create mode 100644 packages/cli/src/source-list-edit.ts create mode 100644 packages/cli/src/templates/entity-module.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65925e201..0f514fba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,17 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ## [Unreleased] -Nothing yet. +### Fixed + +Tier 5 — cli. + +- **`x g` no longer corrupts a commented role map.** `x g entity` / `x g resource` / `x g policy` add permissions to `apps/web/shared/roles.ts`, and found each list's end with the first `]` in the raw text and its entries with a quote regex: an apostrophe in a comment ("the firm's") was a string delimiter, so the comments between two of them were rewritten into permissions and the file no longer loaded. Every list a generator grows — a role's `grants`, `definePermissions([…])`, a `defineApi` list, the handle's entity set — is now located on the masked source (`source-list-edit.ts`): a list holding a comment gains rows and no existing byte moves, a comment-free one is re-wrapped the way Biome prints it, a spread entry survives, and the result is read back before it is written. An edit that does not read back as "the same list plus the new entries" is `X_PERMISSION_UNGRANTED` / `X_DB_HANDLE_UNREGISTERED` naming the lines to add, never a write. +- **A permission is declared only where it can be granted.** In a role map with no `admin` role, `x g entity` declared `:read|write|delete` in the first `definePermissions([…])` beside a finding that said nothing was granted — three names no role held. It now declares none of them, and one that any `definePermissions([…])` call already declares is not declared again in the first. +- **`x g entity --feature ` writes the feature's second entity.** It was `X_GENERATE_CONFLICT` on the first entity's files, with a `--force` that would have overwritten the first table. A slice whose `entity.ts` declares another table now gets `entity-.ts` + `repo-.ts` (and a test beside each), registered on the handle, in the role map and in the admin labels like a first entity; `data.next` names its own table. The same entity again is still the conflict. +- **No `packages/db/src/client.ts` in a db package of the app's own.** The handle was created in any app that lacked the file — including one whose `packages/db/src/index.ts` exports no handle (a schema and a seed over `@ultimat3/db`'s client), where nothing imports it. It is created only where the index names `./client` or there is no index; otherwise `X_DB_HANDLE_UNREGISTERED` on the index says what the generated repo expects, and nothing is written there. +- **`x g --dry-run` lists every file the run touches.** It listed the files a generator creates and none it edits: the role map, the typed handle, the API index, two manifests and the i18n index changed under a plan that never named them. The dry run now runs the same edits against the plan in memory (`generate-follow-ups.ts` over a disk seam): `data.files` is the run's list, `data.edits` the subset that already exists, `data.refreshes` the committed contracts a real run re-projects, and a finding the run would report (`X_PERMISSION_UNGRANTED`, `X_DB_HANDLE_UNREGISTERED`, `X_ADMIN_RESOURCE_UNWIRED`) makes the dry run `ok: false` too. A real run's list also names `packages/i18n/src/index.ts` when it changed it. +- **`x g admin:page --permission admin:blog:write` is accepted.** The flag required one colon exactly, which refused every namespaced permission (`admin::`) though `Permission` is `${string}:${string}` and `definePermissions()` takes it. Two segments or more; an empty segment, a space or a quote is still `X_CLI_BAD_FLAG` with a runnable example. +- **The same scan, in the other registrars.** `apps/web/api/index.ts`: a comment between two entries of a `defineApi` list was split into entries. `packages/db/src/client.ts`: a `}` in a comment closed the entity set. `packages/i18n/src/index.ts`: a `locales: { … }` in a comment was the one edited. `apps/admin/app/admin/admin.ts`: a brace in a string label miscounted the call. ## 27.6.1 - 2026-10-10 diff --git a/packages/cli/src/admin-registration.ts b/packages/cli/src/admin-registration.ts index aa89301ee..90e87d87c 100644 --- a/packages/cli/src/admin-registration.ts +++ b/packages/cli/src/admin-registration.ts @@ -4,8 +4,9 @@ // Performed here; where the admin is not a `defineAdmin({ … })` this can add to, the finding names // the two lines. -import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core'; -import { containedPath } from './generate-write'; +import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import type { Finding } from './output'; import { holdsKey, withKeyedLine, withSortedImport } from './source-edit'; import type { GeneratedFile } from './templates/naming'; @@ -70,7 +71,8 @@ export function insertAdminResources( let next = source; const missing: AdminResourceEntry[] = []; for (const entry of entries) { - const masked = stripComments(next); + // Literals masked too: a brace inside a string label is not one of the call's. + const masked = maskLiterals(next); const call = /\bdefineAdmin\(\s*\{/.exec(masked); const open = call === null ? -1 : call.index + call[0].length - 1; const close = open === -1 ? -1 : closeOf(masked, open); @@ -140,18 +142,17 @@ export function withAdminResources( /** The overrides among the written paths, each named by what its file and its entity export. */ async function entriesFor( - root: string, + disk: GenerateDisk, written: readonly string[], webModule: string, ): Promise { const entries: AdminResourceEntry[] = []; for (const path of written) { if (!OVERRIDE_PATH.test(path)) continue; - const entity = Bun.file(containedPath(root, entityPathOf(path))); const entry = entryOf( path, - await Bun.file(containedPath(root, path)).text(), - (await entity.exists()) ? await entity.text() : undefined, + (await disk.read(path)) ?? '', + await disk.read(entityPathOf(path)), webModule, ); if (entry !== undefined) entries.push(entry); @@ -169,24 +170,24 @@ export interface AdminRegistration { export async function registerAdminResources( root: string, written: readonly string[], + disk: GenerateDisk = appDisk(root), ): Promise { - const web = await readWorkspace(root, WEB_WORKSPACE); - const entries = web === undefined ? [] : await entriesFor(root, written, web.name); + const web = await readWorkspace(root, WEB_WORKSPACE, disk); + const entries = web === undefined ? [] : await entriesFor(disk, written, web.name); if (entries.length === 0) return { edited: [], findings: [] }; - const file = Bun.file(containedPath(root, ADMIN_FILE)); - if (!(await file.exists())) { + const before = await disk.read(ADMIN_FILE); + if (before === undefined) { return { edited: [], findings: entries.map((entry) => adminResourceUnwiredFinding(entry, 'does not exist')), }; } - const before = await file.text(); const { source, missing } = insertAdminResources(before, entries); const findings = missing.map((entry) => adminResourceUnwiredFinding(entry, 'holds no defineAdmin({ … }) call to add to'), ); if (source === before) return { edited: [], findings }; - await Bun.write(containedPath(root, ADMIN_FILE), source); - const manifest = await declareWorkspaceDependency(root, ADMIN_WORKSPACE, WEB_WORKSPACE); + await disk.write(ADMIN_FILE, source); + const manifest = await declareWorkspaceDependency(root, ADMIN_WORKSPACE, WEB_WORKSPACE, disk); return { edited: manifest === undefined ? [ADMIN_FILE] : [ADMIN_FILE, manifest], findings }; } diff --git a/packages/cli/src/api-registration.test.ts b/packages/cli/src/api-registration.test.ts index 66c293c9b..9d75c45b6 100644 --- a/packages/cli/src/api-registration.test.ts +++ b/packages/cli/src/api-registration.test.ts @@ -215,3 +215,47 @@ describe('unit · a binding the index already holds is a finding, never a silent expect(findings).toHaveLength(1); }); }); + +// The list was closed at the first `]` in the raw text and split at every comma, so a comment +// between two entries became entries: the same scan `generate-grants.ts` made of a role map. +describe('unit · a commented list is added to, and every comment survives', () => { + const COMMENTED = [ + "import { defineApi } from '@ultimat3/action';", + "import * as health from './health';", + '', + 'export const api = defineApi({', + " // jobs: [notThis], — it's prose", + ' actions: [', + " // the app's liveness probe, [never] gated", + ' health,', + ' ],', + ' jobs: [', + " // nothing's queued yet, but see tasks[0]", + ' ],', + '});', + '', + ].join('\n'); + + test('the binding lands after the comment, and the file is otherwise byte-identical', () => { + const entries = apiEntriesFor([ + 'apps/web/app/billing/jobs/purge-drafts-job.ts', + 'apps/web/app/billing/actions/close-period.ts', + ]); + const { source, skipped } = insertApiEntries(COMMENTED, entries); + expect(skipped).toEqual([]); + expect(source).toContain( + " jobs: [\n // nothing's queued yet, but see tasks[0]\n purgeDraftsJob,\n ],", + ); + expect(source).toContain( + " actions: [\n // the app's liveness probe, [never] gated\n health,\n closePeriod,\n ],", + ); + const added = [ + "import * as closePeriod from '../app/billing/actions/close-period';\n", + "import * as purgeDraftsJob from '../app/billing/jobs/purge-drafts-job';\n", + ' purgeDraftsJob,\n', + ' closePeriod,\n', + ]; + expect(added.reduce((text, row) => text.replace(row, ''), source)).toBe(COMMENTED); + expect(insertApiEntries(source, entries).source).toBe(source); + }); +}); diff --git a/packages/cli/src/api-registration.ts b/packages/cli/src/api-registration.ts index bf6b1cba1..7e5330364 100644 --- a/packages/cli/src/api-registration.ts +++ b/packages/cli/src/api-registration.ts @@ -6,10 +6,13 @@ import { ERROR_DOCS_URL } from '@ultimat3/core'; import { API_INDEX } from './app-root'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import { containedPath } from './generate-write'; import type { Finding } from './output'; +import type { SourceList } from './source-list-edit'; +import { appendToList, maskOf, readList, readProperties } from './source-list-edit'; import { camel } from './templates/naming'; -import { wrapList } from './templates/wrap'; /** One module to import as a namespace and list under a `defineApi` key. */ export interface ApiEntry { @@ -56,42 +59,30 @@ export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] { } /** - * Every `[...]` entry of a `key: [...]` list in the `defineApi({ ... })` call, and where it sits: - * `line` is the start of the line holding `key: [`, `start` is just past the `[`, `end` is the `]`. - * - * Searched from `from` (the `defineApi({` call), so a `jobs: [` in a comment or an object above the - * call is never the one edited. `line` is found from the key, never from `start`: in a list already - * wrapped one entry per line the character AT `start` is the newline after `[`, and a backwards - * search from there answered the first ITEM's line — the rewrite then nested a second `jobs: [` - * inside the first and left the old `]` behind. + * The `key: [...]` lists of the `defineApi({ ... })` call, read off the MASKED text: a `jobs: [` in + * a comment or an object above the call is never the one edited, and a `]` or a comma inside a + * comment between two entries ends nothing. (The list was closed at the first `]` in the raw text + * and split at every comma — a commented list was rewritten with its comment as an entry.) */ -const listOf = ( - source: string, - key: string, - from: number, -): { line: number; start: number; end: number; items: string[] } | undefined => { - const open = new RegExp(`\\n([ \\t]*)${key}: \\[`, 'g'); - open.lastIndex = from; - const found = open.exec(source); - if (found === null) return undefined; - const line = found.index + 1; - const start = found.index + found[0].length; - const end = source.indexOf(']', start); - if (end === -1) return undefined; - const items = source - .slice(start, end) - .split(',') - .map((item) => item.trim()) - .filter((item) => item.length > 0); - return { line, start, end, items }; +const listsOf = (source: string): ((key: string) => SourceList | undefined) | undefined => { + const masked = maskOf(source); + const call = /\bdefineApi\(\s*\{/.exec(masked); + if (call === null) return undefined; + const properties = readProperties(source, masked, call.index + call[0].length - 1); + return (key) => { + const property = properties.find((one) => one.key === key); + return property === undefined || masked[property.value] !== '[' + ? undefined + : readList(source, masked, property.value); + }; }; /** * `source` with each entry imported and listed. The import joins the `import * as` block in sorted * position; the binding joins its list, which is created after `actions: [...]` when the call has - * none. An index that is not the scaffold's shape — no `defineApi({`, no `actions:` list — is - * returned untouched with the entries in `skipped`, and the `manifest` step's X_JOB_UNREGISTERED - * names the edit. + * none. An index that is not the scaffold's shape — no `defineApi({`, no `actions:` list — or a + * list that cannot be edited safely is returned untouched with the entries in `skipped`, and the + * `manifest` step's X_JOB_UNREGISTERED names the edit. */ export function insertApiEntries( source: string, @@ -101,28 +92,30 @@ export function insertApiEntries( const skipped: ApiEntry[] = []; for (const entry of entries) { const importLine = `import * as ${entry.binding} from '${entry.specifier}';`; - const call = next.indexOf('defineApi({'); - const actions = call === -1 ? undefined : listOf(next, 'actions', call); - if (call === -1 || actions === undefined) { + const listOf = listsOf(next); + const actions = listOf?.('actions'); + if (listOf === undefined || actions === undefined) { skipped.push(entry); continue; } - const list = listOf(next, entry.key, call); - if (list?.items.includes(entry.binding) === true) continue; + const list = listOf(entry.key); + if (list?.entries.some((one) => one.text === entry.binding) === true) continue; if (list === undefined) { // In the order `x new` writes — actions, queries, jobs, tasks: after the nearest list // before it that the call already holds, which is `actions` at the least. const earlier = LIST_ORDER.slice(0, LIST_ORDER.indexOf(entry.key)) - .map((key) => listOf(next, key, call)) + .map((key) => listOf(key)) .findLast((found) => found !== undefined); - const after = next.indexOf('\n', (earlier ?? actions).end); + const after = next.indexOf('\n', (earlier ?? actions).close); const line = ` ${entry.key}: [${entry.binding}],`; next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`; } else { - const items = [...list.items, entry.binding]; - const indent = /^[ \t]*/.exec(next.slice(list.line))?.[0] ?? ''; - const rewritten = wrapList(indent, `${entry.key}: [`, items, ']'); - next = `${next.slice(0, list.line)}${rewritten}${next.slice(list.end + 1)}`; + const edited = appendToList(next, list.open, [entry.binding]); + if (edited === undefined) { + skipped.push(entry); + continue; + } + next = edited; } if (!next.includes(importLine)) next = withImport(next, importLine); } @@ -236,13 +229,13 @@ export async function indexBindingFindings( export async function registerGeneratedPrimitives( root: string, written: readonly string[], + disk: GenerateDisk = appDisk(root), ): Promise { const entries = apiEntriesFor(written); - const file = containedPath(root, API_INDEX); - if (entries.length === 0 || !(await Bun.file(file).exists())) return []; - const before = await Bun.file(file).text(); + const before = entries.length === 0 ? undefined : await disk.read(API_INDEX); + if (before === undefined) return []; const { source } = insertApiEntries(before, entries); if (source === before) return []; - await Bun.write(file, source); + await disk.write(API_INDEX, source); return [API_INDEX]; } diff --git a/packages/cli/src/cmd-generate-entity.test.ts b/packages/cli/src/cmd-generate-entity.test.ts index c48d00a7c..c2867cd16 100644 --- a/packages/cli/src/cmd-generate-entity.test.ts +++ b/packages/cli/src/cmd-generate-entity.test.ts @@ -16,6 +16,7 @@ import { generateCommand } from './cmd-generate'; import { writeNewApp } from './cmd-new'; import type { CommandContext } from './command'; import { exec } from './exec'; +import { ROLES_FILE } from './permission-grants'; import { HANDLE_FILE } from './templates/scaffold-db-client'; /** This package's own workspace links: `@ultimat3/entity`, `core`, `db`, `testing` and the rest. */ @@ -93,17 +94,35 @@ afterAll(async () => { }); describe('unit · x g entity writes a repo over the typed handle', () => { - test('--dry-run lists the repo with its test, and edits nothing', async () => { + // What the dry run said it would touch, held for the run that follows: the two lists are one. + let plannedFiles: readonly string[] = []; + + test('--dry-run lists every file the run touches — created AND edited — and edits nothing', async () => { const before = await read(HANDLE_FILE); + const roles = await read(ROLES_FILE); const result = await generateCommand.run(contextFor('widget', [['dry-run', true]])); - expect(filesOf(result.data)).toEqual([ + plannedFiles = filesOf(result.data); + expect(plannedFiles).toEqual([ 'apps/web/app/widget/entity.ts', 'apps/web/app/widget/entity.test.ts', 'apps/web/app/widget/repo.ts', 'apps/web/app/widget/repo.test.ts', 'packages/i18n/catalogs/en.json', + // The edits outside the slice: the dry run named none of them, and the run made all four. + ROLES_FILE, + HANDLE_FILE, + 'packages/db/package.json', + 'apps/web/package.json', + ]); + // Which of those already exist and are edited rather than created. + expect((result.data as { readonly edits?: readonly string[] }).edits).toEqual([ + ROLES_FILE, + HANDLE_FILE, + 'packages/db/package.json', + 'apps/web/package.json', ]); expect(await read(HANDLE_FILE)).toBe(before); + expect(await read(ROLES_FILE)).toBe(roles); expect(await Bun.file(join(root, 'apps/web/app/widget/repo.ts')).exists()).toBe(false); }); @@ -112,6 +131,11 @@ describe('unit · x g entity writes a repo over the typed handle', () => { expect(result.findings).toEqual([]); expect(result.ok).toBe(true); expect(filesOf(result.data)).toContain(HANDLE_FILE); + // The plan was the run: same files, same order, the re-projected contracts aside. + const contracts = ['x.manifest.json', 'openapi.json']; + expect(filesOf(result.data).filter((path) => !contracts.includes(path))).toEqual([ + ...plannedFiles, + ]); const handle = await read(HANDLE_FILE); expect(handle).toContain("import { widget } from '@gen/web/app/widget/entity';"); expect(handle).toContain('const entities = {\n widgets: widget,\n};'); diff --git a/packages/cli/src/cmd-generate-second-entity.test.ts b/packages/cli/src/cmd-generate-second-entity.test.ts new file mode 100644 index 000000000..a8218e9df --- /dev/null +++ b/packages/cli/src/cmd-generate-second-entity.test.ts @@ -0,0 +1,250 @@ +// A feature with more than one table, and an app whose db package is its own. `x g entity +// blog-attempt --feature blog` answered X_GENERATE_CONFLICT on the first entity's four files — +// with `--force` as the fix, which would have written the second table OVER the first — so two +// entities were declared by hand beside a generated one. And in an app whose `packages/db` is a +// schema and a seed, the same run wrote a `client.ts` nothing imports. + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +// why: Bun has no mkdtemp, mkdir, symlink or recursive remove — a throwaway app root is node:fs's. +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'; +// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root. +import { tmpdir } from 'node:os'; +// why: Bun exposes no path API — nothing native joins a path. +import { join } from 'node:path'; +import { REQUIRED_BUN } from './app-root'; +import { generateCommand } from './cmd-generate'; +import { writeNewApp } from './cmd-new'; +import type { CommandContext } from './command'; +import { exec } from './exec'; +import { ROLES_FILE } from './permission-grants'; +import { HANDLE_FILE } from './templates/scaffold-db-client'; + +const FRAMEWORK = join(import.meta.dir, '..', 'node_modules', '@ultimat3'); +const SLICE = 'apps/web/app/blog'; + +/** + * Run INSIDE the app: the registry `x db gen` diffs (`describeEntities()`), filled the way the + * handle fills it. A sibling module the handle does not import would be a table no migration has. + */ +const REGISTRY_PROBE = `import { expect, test } from 'bun:test'; +import { describeEntities } from '@ultimat3/entity'; +import { db } from './packages/db/src/client'; + +test('every entity of the feature is on the handle and in the registry x db gen reads', () => { + expect(Object.keys(db)).toEqual(expect.arrayContaining(['blogPosts', 'blogAttempts', 'blogKeywordBatches'])); + const tables = describeEntities().map((entity) => entity.name); + expect(tables).toEqual(expect.arrayContaining(['blog_posts', 'blog_attempts', 'blog_keyword_batches'])); +}); +`; + +const newApp = async (prefix: string): Promise => { + const root = await mkdtemp(join(tmpdir(), prefix)); + await writeNewApp(root, { name: 'gen', example: false }); + await mkdir(join(root, 'node_modules'), { recursive: true }); + await symlink(FRAMEWORK, join(root, 'node_modules', '@ultimat3')); + return root; +}; + +const contextFor = ( + root: string, + name: string, + flags: readonly [string, boolean | string][] = [], +): CommandContext => ({ + args: { + command: 'g', + subcommand: undefined, + positionals: ['entity', name], + flags: new Map([['feature', 'blog'], ...flags]), + json: false, + help: false, + passthrough: [], + }, + cwd: root, + runner: exec, + env: {}, + bunVersion: REQUIRED_BUN, +}); + +const filesOf = (data: unknown): readonly string[] => + (data as { readonly files?: readonly string[] }).files ?? []; +const nextOf = (data: unknown): readonly string[] | undefined => + (data as { readonly next?: readonly string[] }).next; + +describe('unit · x g entity --feature: the first entity and every one after it', () => { + let root = ''; + const read = (path: string): Promise => Bun.file(join(root, path)).text(); + + beforeAll(async () => { + root = await newApp('x-generate-second-entity-'); + }); + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + }); + + test('the first entity of a feature is entity.ts + repo.ts', async () => { + const result = await generateCommand.run(contextFor(root, 'blog-post')); + expect(result.findings).toEqual([]); + expect(filesOf(result.data).slice(0, 4)).toEqual([ + `${SLICE}/entity.ts`, + `${SLICE}/entity.test.ts`, + `${SLICE}/repo.ts`, + `${SLICE}/repo.test.ts`, + ]); + }); + + test('the second lands BESIDE it as entity-.ts + repo-.ts; the first is untouched', async () => { + const first = await read(`${SLICE}/entity.ts`); + const repo = await read(`${SLICE}/repo.ts`); + const planned = await generateCommand.run( + contextFor(root, 'blog-attempt', [['dry-run', true]]), + ); + const result = await generateCommand.run(contextFor(root, 'blog-attempt')); + expect(result.findings).toEqual([]); + expect(result.ok).toBe(true); + expect(filesOf(result.data).slice(0, 4)).toEqual([ + `${SLICE}/entity-blog-attempt.ts`, + `${SLICE}/entity-blog-attempt.test.ts`, + `${SLICE}/repo-blog-attempt.ts`, + `${SLICE}/repo-blog-attempt.test.ts`, + ]); + // The dry run before it named the same files, the edited ones included. + const contracts = ['x.manifest.json', 'openapi.json']; + expect(filesOf(planned.data)).toEqual( + filesOf(result.data).filter((path) => !contracts.includes(path)), + ); + expect(filesOf(planned.data)).toEqual(expect.arrayContaining([ROLES_FILE, HANDLE_FILE])); + expect(await read(`${SLICE}/entity.ts`)).toBe(first); + expect(await read(`${SLICE}/repo.ts`)).toBe(repo); + // Each sibling reads its own module, never the slice's first. + expect(await read(`${SLICE}/repo-blog-attempt.ts`)).toContain( + "import type { BlogAttempt } from './entity-blog-attempt';", + ); + expect(await read(`${SLICE}/repo-blog-attempt.test.ts`)).toContain( + "import * as repo from './repo-blog-attempt';", + ); + expect(await read(`${SLICE}/entity-blog-attempt.ts`)).toContain("entity('blog_attempts', {"); + // The table it owes a migration for is the ENTITY's, not the feature's. + expect(nextOf(result.data)).toEqual([ + 'bunx x db gen "create blog_attempts"', + 'bunx x db migrate', + ]); + }); + + test('it joins the handle, the role map and the catalog exactly as a first entity does', async () => { + const handle = await read(HANDLE_FILE); + expect(handle).toContain( + "import { blogAttempt } from '@gen/web/app/blog/entity-blog-attempt';", + ); + expect(handle).toContain(' blogAttempts: blogAttempt,\n blogPosts: blogPost,\n'); + const roles = await read(ROLES_FILE); + for (const verb of ['read', 'write', 'delete']) { + // Declared once and granted once. + expect(roles.split(`'blog_attempts:${verb}'`)).toHaveLength(3); + } + const catalog = JSON.parse(await read('packages/i18n/catalogs/en.json')) as { + admin: Record; + }; + expect(catalog.admin['blog_attempts']?.title).toBe('Blog attempts'); + }); + + test('the Nth is one more pair of siblings', async () => { + const result = await generateCommand.run(contextFor(root, 'blog-keyword-batch')); + expect(result.findings).toEqual([]); + expect(filesOf(result.data)).toContain(`${SLICE}/entity-blog-keyword-batch.ts`); + expect(await read(HANDLE_FILE)).toContain(' blogKeywordBatches: blogKeywordBatch,\n'); + }); + + test('the SAME entity again is still a conflict — on its own files, never on a sibling’s', async () => { + const first = await generateCommand.run(contextFor(root, 'blog-post')); + expect(first.ok).toBe(false); + expect(first.findings?.map((finding) => finding.at)).toContain(`${SLICE}/entity.ts`); + const sibling = await generateCommand.run(contextFor(root, 'blog-attempt')); + expect(sibling.ok).toBe(false); + expect(sibling.findings?.map((finding) => finding.at)).toEqual([ + `${SLICE}/entity-blog-attempt.ts`, + `${SLICE}/entity-blog-attempt.test.ts`, + `${SLICE}/repo-blog-attempt.ts`, + `${SLICE}/repo-blog-attempt.test.ts`, + ]); + }); + + test('every one passes its own tests, and x db gen’s registry holds all three tables', async () => { + await Bun.write(join(root, 'registry.test.ts'), REGISTRY_PROBE); + const run = await exec( + [ + 'bun', + 'test', + `./${SLICE}/repo.test.ts`, + `./${SLICE}/entity-blog-attempt.test.ts`, + `./${SLICE}/repo-blog-attempt.test.ts`, + `./${SLICE}/repo-blog-keyword-batch.test.ts`, + './registry.test.ts', + ], + { cwd: root }, + ); + expect(`${run.stdout}${run.stderr}`).toContain(' 0 fail'); + expect(run.ok).toBe(true); + }, 30_000); +}); + +/** A role map with comments, apostrophes in them, and no `admin` role: the app's own. */ +const OWN_ROLES = `import { definePermissions, defineRoles } from '@ultimat3/policy'; + +export const appPermissions = definePermissions([ + 'dashboard:read', + // every member reads the firm's terms; owners can't skip it + 'deadline:read', +]); + +export const roles = defineRoles({ + // The firm's own roles — there isn't an admin here. + owner: { + grants: ['dashboard:read', 'deadline:read'], + }, +}); +`; + +describe('unit · an app whose db package and role map are its own', () => { + let root = ''; + const INDEX = 'packages/db/src/index.ts'; + const OWN_INDEX = + "export { db, sql } from '@ultimat3/db';\nexport * as schema from './schema';\n"; + + beforeAll(async () => { + root = await newApp('x-generate-own-db-'); + // The pre-handle layout: an index over `@ultimat3/db`'s client and a schema, no `client.ts`. + await rm(join(root, HANDLE_FILE)); + await rm(join(root, 'packages/db/src/client.test.ts')); + await Bun.write(join(root, INDEX), OWN_INDEX); + await Bun.write(join(root, ROLES_FILE), OWN_ROLES); + }); + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + }); + + for (const dry of [true, false]) { + test(`${dry ? '--dry-run' : 'the run'}: no client.ts is written, the role map is byte-identical, both are said`, async () => { + const result = await generateCommand.run( + contextFor(root, 'blog-post', dry ? [['dry-run', true]] : []), + ); + expect(result.ok).toBe(false); + expect(filesOf(result.data)).not.toContain(HANDLE_FILE); + expect(await Bun.file(join(root, HANDLE_FILE)).exists()).toBe(false); + expect(await Bun.file(join(root, INDEX)).text()).toBe(OWN_INDEX); + // No `admin` role to grant to, so nothing is DECLARED either: three permissions no role + // holds were added to a file whose comments the same edit then corrupted. + expect(await Bun.file(join(root, ROLES_FILE)).text()).toBe(OWN_ROLES); + expect(filesOf(result.data)).not.toContain(ROLES_FILE); + const findings = result.findings ?? []; + expect(findings.map((finding) => [finding.code, finding.at])).toEqual([ + ['X_DB_HANDLE_UNREGISTERED', INDEX], + ['X_PERMISSION_UNGRANTED', ROLES_FILE], + ]); + // Each says exactly what to add by hand. + expect(findings[0]?.fix).toContain("import { blogPost } from '@gen/web/app/blog/entity';"); + expect(findings[1]?.fix).toContain( + "admin: 'blog_posts:read', 'blog_posts:write', 'blog_posts:delete'", + ); + }); + } +}); diff --git a/packages/cli/src/cmd-generate.ts b/packages/cli/src/cmd-generate.ts index a939c2a96..f1f52ed6c 100644 --- a/packages/cli/src/cmd-generate.ts +++ b/packages/cli/src/cmd-generate.ts @@ -3,9 +3,9 @@ // primitive arrives with the test that pins its distant invariants (policy, idempotency, budget). import { existsSync } from 'node:fs'; +import { stripComments } from '@ultimat3/core'; import { MANIFEST_FILENAME } from '@ultimat3/manifest'; -import { registerAdminResources } from './admin-registration'; -import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration'; +import { indexBindingFindings } from './api-registration'; import { writeAppArtifacts } from './app-artifacts'; import { appManifest } from './app-manifest'; import { requireAppRoot } from './app-root'; @@ -13,19 +13,18 @@ import { generateSpec } from './cmd-generate-spec'; import type { CliCommand, CommandContext } from './command'; import { invocationOf } from './command'; import { localiseCatalogs } from './generate-catalog-locales'; +import { appDisk, plannedDisk } from './generate-disk'; import { assertFeatureExists } from './generate-feature'; import { generate, sliceDir } from './generate-files'; -import { ungrantedByGenerator } from './generate-grant-findings'; -import { grantGeneratedPermissions } from './generate-grants'; +import { followUpEdits } from './generate-follow-ups'; import type { Generator } from './generate-kinds'; import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds'; import { refusePluralTable } from './generate-plural'; import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow'; import { containedPath, planWrites, writeFiles } from './generate-write'; -import { declareGeneratedImports } from './generated-imports'; -import { registerGeneratedEntities, resolveDbModule } from './handle-registration'; +import { resolveDbModule } from './handle-registration'; import { resolveCatalogModule } from './i18n-audit'; -import { catalogLocales, syncI18nIndex } from './i18n-index'; +import { catalogLocales } from './i18n-index'; import { reproducedFlags } from './invocation-flags'; import { msg } from './messages'; import type { CommandResult, Finding } from './output'; @@ -33,6 +32,7 @@ import { flagBool, flagList, flagString } from './parse'; import { quoteArg } from './shell-quote'; import type { GeneratedFile } from './templates'; import { kebab, names, resolveLocales } from './templates'; +import { isEntityModule } from './templates/entity-module'; // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold // fixture reach the kinds, the pure file list and the writer through this module, and a second path @@ -94,6 +94,11 @@ export const generateCommand: CliCommand = { // THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume. const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts'); const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts'); + // A feature's SECOND entity: `entity.ts` is another table's, so this one lands beside it. + const sibling = + kind === 'entity' && + featureFlag !== undefined && + (await holdsAnotherEntity(root, slice, name)); const planFor = (planned: string) => generate({ kind, @@ -111,6 +116,7 @@ export const generateCommand: CliCommand = { ...(catalogModule === undefined ? {} : { catalogModule }), ...(dbModule === undefined ? {} : { dbModule }), shell, + sibling, }); // A non-default locale's strings arrive marked, and a locale with no catalog yet is refused // with `x i18n add ` — before a dry run answers, so it answers the same files. @@ -149,14 +155,43 @@ export const generateCommand: CliCommand = { if (flagBool(ctx.args, 'dry-run')) { // The write plan, never the bare file list: what the real run would write, skip and refuse. const plan = await planWrites(root, files, force, invocation); - const findings = [...plan.conflicts, ...bindings]; - const planned = findings.length === 0 ? plan.written : []; + const refused = [...plan.conflicts, ...bindings]; + // And the edits OUTSIDE the slice — role map, handle, API index, manifests, catalog index — + // run for real against the plan held in memory: the same code, so the same list. A dry run + // that named five new files while the run rewrote four the caller owns was no plan. + const planned = new Map( + files.flatMap((file) => + plan.written.includes(file.path) && typeof file.contents === 'string' + ? [[file.path, file.contents] as const] + : [], + ), + ); + const follow = + refused.length > 0 + ? { edited: [], findings: [] } + : await followUpEdits(root, plan.written, dbModule, plannedDisk(root, planned)); + const findings = [...refused, ...follow.findings]; + const touched = refused.length === 0 ? [...plan.written, ...follow.edited] : []; + // Re-projected after a real write, from the loaded app: named, since their bytes are not + // knowable without the files on disk. + const refreshes = + plan.written.length === 0 || refused.length > 0 + ? [] + : [MANIFEST_FILENAME, 'openapi.json'].filter((file) => + existsSync(containedPath(root, file)), + ); return { ok: findings.length === 0, command: 'g', - summary: msg('cli.generate.planned', { count: planned.length, kind, name }), - data: { files: planned, dryRun: true }, - lines: planned.map((file) => msg('cli.file.added', { path: file })), + summary: msg('cli.generate.planned', { count: touched.length, kind, name }), + data: { + files: touched, + // The subset that already exists and would be EDITED, never created. + edits: follow.edited.filter((path) => existsSync(containedPath(root, path))), + ...(refreshes.length === 0 ? {} : { refreshes }), + dryRun: true, + }, + lines: touched.map((file) => msg('cli.file.added', { path: file })), findings, }; } @@ -164,34 +199,11 @@ export const generateCommand: CliCommand = { bindings.length > 0 ? { written: [], conflicts: bindings } : await writeFiles(root, files, force, invocation); - // The three edits a generated primitive needs outside its own slice, performed rather than - // left as findings: a declared permission granted to a role, a job listed in `defineApi`, and - // an entity added to the typed handle its `repo.ts` reads through. Before the manifest load - // below, so the projection sees all three — a repo reading a table the handle lacks would not - // load at all. - const handle = await registerGeneratedEntities(root, report.written, dbModule); - // And a fourth, for `--admin`: the override it wrote, listed where `defineAdmin()` reads it. - const adminWiring = await registerAdminResources(root, report.written); - // And a fifth, last because the others add imports too: every sibling workspace a written file - // imports, declared in the manifest it landed under — or `package-shape` refuses the output. - const edited = [ - ...new Set([ - ...(await grantGeneratedPermissions(root, report.written)), - ...(await registerGeneratedPrimitives(root, report.written)), - ...handle.edited, - ...adminWiring.edited, - ...(await declareGeneratedImports(root, report.written)), - ]), - ]; - // A grant that edit could not place is said so, with the role each permission belongs to: an - // app whose role map is not the scaffold's got no grant and no word, and a 403 on every - // endpoint the run had just written. - const ungranted = await ungrantedByGenerator(root, report.written); - // A locale's catalog existing on disk and the app being able to select it are two different - // facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource - // this same invocation just wrote never gets projected against a stale catalog registration. - const indexSync = - report.written.length > 0 ? await syncI18nIndex(root) : { registered: true, findings: [] }; + // The edits a generated primitive needs outside its own slice, performed rather than left as + // findings (`generate-follow-ups.ts`). Before the manifest load below, so the projection sees + // them all — a repo reading a table the handle lacks would not load at all. + const follow = await followUpEdits(root, report.written, dbModule, appDisk(root)); + const edited = follow.edited; // Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current, // the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never // sees a resource that exists on disk but not in the manifest. @@ -216,14 +228,7 @@ export const generateCommand: CliCommand = { buildId = manifest.buildId; } else loadFailures.push(...findings); } - const findings = [ - ...report.conflicts, - ...handle.findings, - ...ungranted, - ...adminWiring.findings, - ...indexSync.findings, - ...loadFailures, - ]; + const findings = [...report.conflicts, ...follow.findings, ...loadFailures]; // One list behind all three renderings. The manifest was printed as a `+` line while the count // beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three // lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent. @@ -289,7 +294,7 @@ export function nextSteps( edited: readonly string[], table: string, ): readonly string[] { - if (!written.some((path) => path.endsWith('/entity.ts'))) return []; + if (!written.some(isEntityModule)) return []; return [ ...(edited.some((path) => path.endsWith('package.json')) ? ['bun install'] : []), `bunx x db gen "create ${table}"`, @@ -322,6 +327,18 @@ async function readSliceErrors( return existsSync(file) ? await Bun.file(file).text() : undefined; } +/** + * Whether the slice's `entity.ts` is there and declares a table OTHER than `name`'s. The same + * entity again is the conflict it always was (`--force` regenerates it); a different one is a + * second table for the feature, which `--force` would have written OVER the first. + */ +async function holdsAnotherEntity(root: string, slice: string, name: string): Promise { + const file = containedPath(root, `${slice}/entity.ts`); + if (!existsSync(file)) return false; + const declared = new RegExp(`\\bentity\\(\\s*['"]${names(name).table}['"]`); + return !declared.test(stripComments(await Bun.file(file).text())); +} + /** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */ const READS_SLICE: ReadonlySet = new Set([ 'job', diff --git a/packages/cli/src/generate-disk.ts b/packages/cli/src/generate-disk.ts new file mode 100644 index 000000000..7e516c384 --- /dev/null +++ b/packages/cli/src/generate-disk.ts @@ -0,0 +1,38 @@ +// The files a generator's follow-up edits read and write, behind one seam — so `x g --dry-run` can +// run the SAME edits against the plan and answer every path a real run would touch. The dry run +// listed the files a generator creates and none it edits: the role map, the typed handle, the API +// index and two manifests changed under a plan that never named them. + +import { containedPath } from './generate-write'; + +/** App-root-relative reads and writes. A read of a file that is not there answers `undefined`. */ +export interface GenerateDisk { + read(path: string): Promise; + write(path: string, contents: string): Promise; +} + +/** The app's own disk. */ +export const appDisk = (root: string): GenerateDisk => ({ + async read(path) { + const file = Bun.file(containedPath(root, path)); + return (await file.exists()) ? await file.text() : undefined; + }, + async write(path, contents) { + await Bun.write(containedPath(root, path), contents); + }, +}); + +/** + * The disk as it WOULD stand after a run: `planned` is what the run creates, read before the real + * file, and a write lands in memory only. Nothing under `root` changes. + */ +export const plannedDisk = (root: string, planned: ReadonlyMap): GenerateDisk => { + const held = new Map(planned); + const real = appDisk(root); + return { + read: async (path) => held.get(path) ?? (await real.read(path)), + async write(path, contents) { + held.set(path, contents); + }, + }; +}; diff --git a/packages/cli/src/generate-files.ts b/packages/cli/src/generate-files.ts index c82f94c4b..bcadff843 100644 --- a/packages/cli/src/generate-files.ts +++ b/packages/cli/src/generate-files.ts @@ -82,6 +82,11 @@ export interface GenerateOptions { * resource's page renders inside it. Read off the disk by `run`, for `catalogModule`'s reason. */ readonly shell?: boolean; + /** + * `entity` only: the feature's `entity.ts` already declares ANOTHER table, so this entity is + * written beside it as `entity-.ts` + `repo-.ts`. Read off the disk by `run`. + */ + readonly sibling?: boolean; } const DEFAULT_SURFACE_DIR: Record = { @@ -174,7 +179,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] { // With the labels the admin reads for it: the entity is an admin screen once it is in the // handle, whether or not a resource was generated around it. return dedupe([ - ...entityFiles(options.name, target), + ...entityFiles(options.name, target, { sibling: options.sibling === true }), ...adminCatalogFiles(options.name, target, options.locales), ]); case 'policy': diff --git a/packages/cli/src/generate-follow-ups.ts b/packages/cli/src/generate-follow-ups.ts new file mode 100644 index 000000000..d4e0b4aca --- /dev/null +++ b/packages/cli/src/generate-follow-ups.ts @@ -0,0 +1,64 @@ +// Everything `x g` edits OUTSIDE the files it creates, in the one order they have to happen: a +// declared permission granted to a role, a primitive listed in `defineApi`, an entity added to the +// typed handle, an `--admin` override wired, each new workspace edge declared, and every catalog +// made selectable. One function over one disk seam, so a real run and a dry run are the same code +// — and the dry run's list of touched files is what the run touches, not a second guess at it. + +import { registerAdminResources } from './admin-registration'; +import { registerGeneratedPrimitives } from './api-registration'; +import type { GenerateDisk } from './generate-disk'; +import { ungrantedByGenerator } from './generate-grant-findings'; +import { grantGeneratedPermissions } from './generate-grants'; +import { declareGeneratedImports } from './generated-imports'; +import { registerGeneratedEntities } from './handle-registration'; +import { syncI18nIndex } from './i18n-index'; +import type { Finding } from './output'; +import { CATALOG_ROOT } from './templates'; + +export interface FollowUps { + /** App-root-relative paths edited or created beyond the generator's own files, each once. */ + readonly edited: readonly string[]; + readonly findings: readonly Finding[]; +} + +/** The locale each written catalog file is for: a dry run's catalogs are not on the disk yet. */ +const writtenLocales = (written: readonly string[]): readonly string[] => + written.flatMap((path) => { + const stem = path.startsWith(`${CATALOG_ROOT}/`) ? path.slice(CATALOG_ROOT.length + 1) : ''; + return stem.endsWith('.json') && !stem.includes('/') ? [stem.slice(0, -'.json'.length)] : []; + }); + +export async function followUpEdits( + root: string, + written: readonly string[], + dbModule: string | undefined, + disk: GenerateDisk, +): Promise { + const handle = await registerGeneratedEntities(root, written, dbModule, disk); + const adminWiring = await registerAdminResources(root, written, disk); + const granted = await grantGeneratedPermissions(root, written, disk); + const listed = await registerGeneratedPrimitives(root, written, disk); + // Last of the manifest writers, because the others add imports too: every sibling workspace a + // written file imports, declared in the manifest it landed under. + const imports = await declareGeneratedImports(root, written, disk); + // A grant that edit could not place is said so, with the role each permission belongs to. + const ungranted = await ungrantedByGenerator(root, written, disk); + // A catalog on disk and a locale the app can select are two facts — see `syncI18nIndex`. + const indexSync = + written.length > 0 + ? await syncI18nIndex(root, disk, writtenLocales(written)) + : { registered: true, findings: [] }; + return { + edited: [ + ...new Set([ + ...granted, + ...listed, + ...handle.edited, + ...adminWiring.edited, + ...imports, + ...(indexSync.edited ?? []), + ]), + ].filter((path) => !written.includes(path)), + findings: [...handle.findings, ...ungranted, ...adminWiring.findings, ...indexSync.findings], + }; +} diff --git a/packages/cli/src/generate-format.test.ts b/packages/cli/src/generate-format.test.ts index 8398c0064..803edc5e5 100644 --- a/packages/cli/src/generate-format.test.ts +++ b/packages/cli/src/generate-format.test.ts @@ -157,3 +157,25 @@ test('and both are clean for an app whose scope sorts AFTER @ultimat3', async () expect(generatedRun.problems).toBe(''); expect(generatedRun.checked).toBeGreaterThanOrEqual(typescriptPaths(generated)); }, 60_000); + +test('a feature’s second entity — the sibling files — is clean under both scopes too', async () => { + // The sibling's module names are longer than `entity`/`repo`, and every import line that names + // one is wrapped by its length: a long entity name is where a fixed shape goes wrong. + const siblings = ['line', 'credit-note-attachment-revision'].flatMap((name) => + [undefined, `@${AFTER_ULTIMATE}/db`].flatMap((dbModule) => + generate({ + kind: 'entity', + name, + feature: dbModule === undefined ? 'invoice' : 'ledger', + sibling: true, + ...(dbModule === undefined ? {} : { dbModule }), + }), + ), + ); + expect(siblings.map((file) => file.path)).toContain('apps/web/app/invoice/entity-line.ts'); + expect(siblings.map((file) => file.path)).toContain('apps/web/app/ledger/repo-line.test.ts'); + + const run = await runBiome(siblings); + expect(run.problems).toBe(''); + expect(run.checked).toBeGreaterThanOrEqual(typescriptPaths(siblings)); +}, 60_000); diff --git a/packages/cli/src/generate-grant-findings.ts b/packages/cli/src/generate-grant-findings.ts index d5eaf3e36..fe7914957 100644 --- a/packages/cli/src/generate-grant-findings.ts +++ b/packages/cli/src/generate-grant-findings.ts @@ -3,6 +3,8 @@ // every generated endpoint answered 403. Apart from that module because this one only REPORTS: // it re-asks the same pure edits of the file as it now stands and names what they could not place. +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import type { RoleGrant } from './generate-grants'; import { adminGrantsFor, @@ -11,7 +13,6 @@ import { insertPermissions, writtenTables, } from './generate-grants'; -import { containedPath } from './generate-write'; import type { Finding } from './output'; import { ROLES_FILE } from './permission-grants'; @@ -45,15 +46,16 @@ function stillMissing(source: string, grants: readonly RoleGrant[], admin: reado export async function ungrantedByGenerator( root: string, written: readonly string[], + disk: GenerateDisk = appDisk(root), ): Promise { - const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor); + const admin = (await writtenTables(root, written, disk)).flatMap(adminGrantsFor); const grants = [...grantsForWritten(written), ...admin]; if (grants.length === 0) return []; - const file = Bun.file(containedPath(root, ROLES_FILE)); + const source = await disk.read(ROLES_FILE); // The file whose declarations nobody granted: where a reader starts when the map is not ours. const declaredIn = written.find((path) => path.endsWith('/policy.ts')) ?? written[0] ?? ROLES_FILE; - if (!(await file.exists())) { + if (source === undefined) { return [ { code: 'X_PERMISSION_UNGRANTED', @@ -63,12 +65,12 @@ export async function ungrantedByGenerator( }, ]; } - const missing = stillMissing(await file.text(), grants, admin); + const missing = stillMissing(source, grants, admin); if (missing.length === 0) return []; return [ { code: 'X_PERMISSION_UNGRANTED', - cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be added to, so nothing granted: ${linesFor(missing)}`, + cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be safely added to, so nothing granted: ${linesFor(missing)}`, fix: `edit ${ROLES_FILE} — add to each role's grants (and declare any the map does not know in definePermissions([...])) — ${linesFor(missing)} — then x verify --only policy`, at: ROLES_FILE, }, diff --git a/packages/cli/src/generate-grants-commented.test.ts b/packages/cli/src/generate-grants-commented.test.ts new file mode 100644 index 000000000..b2ae19cd4 --- /dev/null +++ b/packages/cli/src/generate-grants-commented.test.ts @@ -0,0 +1,131 @@ +// The role map of an app that documents it: comments between the permissions, apostrophes in the +// comments, two `definePermissions([…])` calls and roles of its own. `x g entity blog-post` took +// every apostrophe for a string delimiter — "the firm's", "the caller's" — and rewrote the +// comments between two of them into permissions, in a file the gate then could not load. The +// fixture is that shape; `source-list-edit.test.ts` is the general proof. + +import { describe, expect, test } from 'bun:test'; +import { adminGrantsFor, insertGrants, insertPermissions } from './generate-grants'; + +const ROLE_MAP = `// Who holds which permission. A permission no role holds is one no actor can exercise, and +// \`x verify\`'s policy step refuses it — \`grants: ['never:this']\` here is prose. + +import { definePermissions, defineRoles } from '@ultimat3/policy'; + +/** What a customer org's members may do. */ +export const customerPermissions = definePermissions([ + 'dashboard:read', + // Lawyers hold it only once KYC is approved — a predicate reading the actor fact, never a + // second grant here: \`actorFact(actor, 'kycApproved')\`. + 'notification:send', + // plan 103/05: read the org's invoices and credit notes. + 'invoicing:read', + // plan 106/04: list, issue and revoke the org's tokens — lawyers and owners; a token never + // outranks its holder's current role. + 'api-access:read', + // plan 104/02: every member reads the firm's terms [see the matrix]; owners can't skip it. + 'deadline:read', +]); + +/** The staff rows. A row is added only with the primitive that needs it. */ +export const staffPermissions = definePermissions([ + 'admin:read', + // The console's own write gate — the operator's, not the firm's. + 'admin:write', +]); + +export const appPermissions = definePermissions([ + ...customerPermissions.all, + ...staffPermissions.all, +]); + +export const roles = defineRoles({ + // Customer roles. A viewer's grants aren't inherited by anybody below. + viewer: { + description: "Reads the firm's cases; can't send.", + grants: [ + 'dashboard:read', + // every member reads the firm's terms + 'deadline:read', + ], + }, + owner: { + description: 'Owns the org.', + grants: ['invoicing:read', 'api-access:read'], + inherits: ['viewer'], + }, + // Staff. The superadmin's grants are the union — it isn't a bypass. + admin: { + description: \`Staff: the console's operator.\`, + grants: [ + 'admin:read', // to look + 'admin:write' // to change — the operator's own + ], + }, +}); +`; + +/** Every comment and template literal, in order. */ +const proseOf = (source: string): readonly string[] => + source.match(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|`[^`]*`/g) ?? []; + +const PERMISSIONS = ['blog_posts:read', 'blog_posts:write', 'blog_posts:delete']; + +describe('unit · a commented role map is edited without touching a comment', () => { + test('the three admin permissions are declared after the last entry, and nothing else moves', () => { + const { source, skipped } = insertPermissions(ROLE_MAP, PERMISSIONS); + expect(skipped).toEqual([]); + expect(proseOf(source)).toEqual(proseOf(ROLE_MAP)); + // Exactly three rows were added, and removing them is the original file. + const rows = " 'blog_posts:read',\n 'blog_posts:write',\n 'blog_posts:delete',\n"; + expect(source).toContain(` 'deadline:read',\n${rows}]);`); + expect(source.replace(rows, '')).toBe(ROLE_MAP); + }); + + test('they are granted to admin: the comment beside its last grant stays beside it', () => { + const declared = insertPermissions(ROLE_MAP, PERMISSIONS).source; + const { source, skipped } = insertGrants(declared, adminGrantsFor('blog_posts')); + expect(skipped).toEqual([]); + expect(proseOf(source)).toEqual(proseOf(ROLE_MAP)); + expect(source).toContain( + " 'admin:write', // to change — the operator's own\n 'blog_posts:read',\n 'blog_posts:write',\n 'blog_posts:delete',\n ],", + ); + // No customer role gained anything, and no other list changed. + expect(source).toContain("grants: ['invoicing:read', 'api-access:read'],"); + expect(source).toContain(" 'deadline:read',\n ],\n },\n owner:"); + }); + + test('a second run declares and grants nothing twice', () => { + const once = insertGrants( + insertPermissions(ROLE_MAP, PERMISSIONS).source, + adminGrantsFor('blog_posts'), + ).source; + expect(insertPermissions(once, PERMISSIONS).source).toBe(once); + expect(insertGrants(once, adminGrantsFor('blog_posts')).source).toBe(once); + }); + + test('a permission ANY definePermissions() call declares is not declared again in the first', () => { + const { source, skipped } = insertPermissions(ROLE_MAP, ['admin:write', 'deadline:read']); + expect(skipped).toEqual([]); + expect(source).toBe(ROLE_MAP); + }); + + test('a role named only in a comment or a string is not a role: skipped, file untouched', () => { + const noAdmin = ROLE_MAP.slice(0, ROLE_MAP.indexOf(' // Staff.')).concat( + " // admin: { grants: ['x:read'] } is what it'd look like\n});\n", + ); + const { source, skipped } = insertGrants(noAdmin, adminGrantsFor('blog_posts')); + expect(skipped).toEqual(adminGrantsFor('blog_posts')); + expect(source).toBe(noAdmin); + }); + + test('a grants list that is not an array literal is skipped, never rewritten', () => { + const spread = ROLE_MAP.replace( + "grants: ['invoicing:read', 'api-access:read'],", + 'grants: OWNER_GRANTS,', + ); + const { source, skipped } = insertGrants(spread, [{ role: 'owner', permission: 'blog:write' }]); + expect(skipped).toEqual([{ role: 'owner', permission: 'blog:write' }]); + expect(source).toBe(spread); + }); +}); diff --git a/packages/cli/src/generate-grants.ts b/packages/cli/src/generate-grants.ts index 855fd820e..9acca5981 100644 --- a/packages/cli/src/generate-grants.ts +++ b/packages/cli/src/generate-grants.ts @@ -7,9 +7,11 @@ // three permissions `defineAdmin()` derives from the table's own name. They are declared in the // role map and granted to `admin`, or the role that runs the admin is refused its newest screen. -import { containedPath } from './generate-write'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import { ROLES_FILE } from './permission-grants'; -import { wrapList } from './templates/wrap'; +import { absentFrom, appendToList, maskOf, readList, readProperties } from './source-list-edit'; +import { isEntityModule } from './templates/entity-module'; /** One permission to add to one role's `grants`. */ export interface RoleGrant { @@ -37,8 +39,6 @@ export function grantsForWritten(written: readonly string[]): readonly RoleGrant }); } -/** A generated entity file: `//entity.ts`. */ -const ENTITY_PATH = /^apps\/[^/]+\/[^/]+\/[a-z0-9-]+\/entity\.ts$/; /** `entity('widgets', {` — the name the admin's permissions are spelled with. */ const ENTITY_NAME = /\bentity\(\s*'([^']+)'/; @@ -56,11 +56,31 @@ export function adminGrantsFor(table: string): readonly RoleGrant[] { const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const quoted = (permission: string): string => `'${permission}'`; + +/** + * Where the object of role `name` opens: a property of the `defineRoles({ … })` call, or — in a + * map handed to it by name — the scaffold's own ` : {` row. Read off the masked text, so a + * role named in a comment or a string is prose and never the one edited. + */ +function roleObject(source: string, masked: string, name: string): number | undefined { + const call = /\bdefineRoles\(\s*\{/.exec(masked); + if (call !== null) { + const role = readProperties(source, masked, call.index + call[0].length - 1).find( + (property) => property.key === name, + ); + return role !== undefined && masked[role.value] === '{' ? role.value : undefined; + } + const row = new RegExp(`\\n ${escapeRegExp(name)}: \\{`).exec(masked); + return row === null ? undefined : row.index + row[0].length - 1; +} + /** - * `source` with each grant added to its role's `grants: [...]`, re-wrapped the way Biome prints - * it. A role or a `grants` array the scaffold's shape does not have is left alone and returned in - * `skipped` — the `policy` step's X_PERMISSION_UNGRANTED names the edit, so a guess here would be - * a second, worse answer. + * `source` with each grant added to its role's `grants: [...]`. A list on one line is re-wrapped + * the way Biome prints it; a list over several lines gains a row and keeps every comment it holds. + * A role or a `grants` array the map does not have — or one that cannot be edited safely — is left + * alone and returned in `skipped`: the finding names the edit, so a guess here would be a second, + * worse answer. */ export function insertGrants( source: string, @@ -69,62 +89,63 @@ export function insertGrants( let next = source; const skipped: RoleGrant[] = []; for (const grant of grants) { - const role = new RegExp(`\\n ${escapeRegExp(grant.role)}: \\{`).exec(next); - const open = role === null ? -1 : next.indexOf('grants: [', role.index); - const close = open === -1 ? -1 : next.indexOf(']', open); - const blockEnd = role === null ? -1 : next.indexOf('\n },', role.index); - if (role === null || open === -1 || close === -1 || (blockEnd !== -1 && open > blockEnd)) { - skipped.push(grant); - continue; - } - const current = [...next.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? ''); - if (current.includes(grant.permission)) continue; - const entries = [...current, grant.permission].map((permission) => `'${permission}'`); - const lineStart = next.lastIndexOf('\n', open) + 1; - const indent = next.slice(lineStart, open); - const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length); - next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`; + const masked = maskOf(next); + const role = roleObject(next, masked, grant.role); + const list = + role === undefined + ? undefined + : readProperties(next, masked, role).find((property) => property.key === 'grants'); + const edited = + list === undefined || masked[list.value] !== '[' + ? undefined + : appendToList(next, list.value, [quoted(grant.permission)]); + if (edited === undefined) skipped.push(grant); + else next = edited; } return { source: next, skipped }; } -const DECLARATION = 'definePermissions(['; +/** Every `definePermissions([` in the masked text: the index of each call's opening `[`. */ +const declarationLists = (masked: string): readonly number[] => + [...masked.matchAll(/\bdefinePermissions\(\s*\[/g)].map( + (found) => found.index + found[0].length - 1, + ); /** - * `source` with each permission added to its first `definePermissions([…])`, re-wrapped the way - * Biome prints it. Declared BEFORE it is granted: `can()` refuses a name no call registered, and - * `defineRoles()` does not — a grant nothing declares is a 500 on the first request that asks. - * A role map with no such call is left alone and every permission returned in `skipped`. + * `source` with each permission added to its first `definePermissions([…])`. Declared BEFORE it is + * granted: `can()` refuses a name no call registered, and `defineRoles()` does not — a grant + * nothing declares is a 500 on the first request that asks. One ANY call already declares is left + * alone. A role map with no such call, or one that cannot be edited safely, is left alone and + * every permission returned in `skipped`. */ export function insertPermissions( source: string, permissions: readonly string[], ): { readonly source: string; readonly skipped: readonly string[] } { - const open = source.indexOf(DECLARATION); - const close = open === -1 ? -1 : source.indexOf('])', open); - if (open === -1 || close === -1) return { source, skipped: [...permissions] }; - const current = [...source.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? ''); - const added = permissions.filter((permission) => !current.includes(permission)); + const masked = maskOf(source); + const lists = declarationLists(masked).flatMap((open) => readList(source, masked, open) ?? []); + const first = lists[0]; + if (first === undefined) return { source, skipped: [...permissions] }; + const added = lists.reduce( + (absent, list) => absentFrom(list, absent), + permissions.map(quoted), + ); if (added.length === 0) return { source, skipped: [] }; - const lineStart = source.lastIndexOf('\n', open) + 1; - const head = source.slice(lineStart, open); - const tail = source.slice(close + 2, source.indexOf('\n', close)); - const entries = [...current, ...added].map((permission) => `'${permission}'`); - const rewritten = wrapList('', `${head}${DECLARATION}`, entries, `])${tail}`); - return { - source: `${source.slice(0, lineStart)}${rewritten}${source.slice(source.indexOf('\n', close))}`, - skipped: [], - }; + const edited = appendToList(source, first.open, added); + return edited === undefined + ? { source, skipped: [...permissions] } + : { source: edited, skipped: [] }; } -/** The entity names the written `entity.ts` files declare — read off each file, never guessed. */ +/** The entity names the written entity files declare — read off each file, never guessed. */ export async function writtenTables( root: string, written: readonly string[], + disk: GenerateDisk = appDisk(root), ): Promise { const tables: string[] = []; - for (const path of written.filter((candidate) => ENTITY_PATH.test(candidate))) { - const table = ENTITY_NAME.exec(await Bun.file(containedPath(root, path)).text())?.[1]; + for (const path of written.filter(isEntityModule)) { + const table = ENTITY_NAME.exec((await disk.read(path)) ?? '')?.[1]; if (table !== undefined) tables.push(table); } return tables; @@ -134,24 +155,30 @@ export async function writtenTables( * Performs both edits on the app's role map: a written policy's grants, and — for each written * entity — the admin's three permissions for its table, declared and granted. Answers the paths * it rewrote. + * + * A permission is declared only where its grant can be placed. An app with no `admin` role got + * three permissions declared that no role held — `X_PERMISSION_UNGRANTED` on the next gate, for + * names the author never asked for — beside the finding that said nothing was granted. */ export async function grantGeneratedPermissions( root: string, written: readonly string[], + disk: GenerateDisk = appDisk(root), ): Promise { - const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor); + const admin = (await writtenTables(root, written, disk)).flatMap(adminGrantsFor); const grants = [...grantsForWritten(written), ...admin]; - const file = containedPath(root, ROLES_FILE); - if (grants.length === 0 || !(await Bun.file(file).exists())) return []; - const before = await Bun.file(file).text(); + const before = grants.length === 0 ? undefined : await disk.read(ROLES_FILE); + if (before === undefined) return []; + const unplaced = insertGrants(before, grants).skipped; + const placeable = grants.filter((grant) => !unplaced.includes(grant)); const declared = insertPermissions( before, - admin.map((grant) => grant.permission), + admin.filter((grant) => placeable.includes(grant)).map((grant) => grant.permission), ); // Granted only where it could be declared: a grant with no declaration is the 500 above. - const grantable = grants.filter((grant) => !declared.skipped.includes(grant.permission)); + const grantable = placeable.filter((grant) => !declared.skipped.includes(grant.permission)); const { source } = insertGrants(declared.source, grantable); if (source === before) return []; - await Bun.write(file, source); + await disk.write(ROLES_FILE, source); return [ROLES_FILE]; } diff --git a/packages/cli/src/generate-kinds.test.ts b/packages/cli/src/generate-kinds.test.ts index e2b1d1ce8..32832bcd5 100644 --- a/packages/cli/src/generate-kinds.test.ts +++ b/packages/cli/src/generate-kinds.test.ts @@ -51,7 +51,17 @@ describe('readKind', () => { * accepted and written, so the failure arrived as a TS2345 in a file the caller had not written. */ test('a --permission that is not a : is refused before anything is written', () => { - for (const raw of ['ops', 'ops:', ':read', 'ops read', "ops:'read", 'ops:re:ad']) { + const malformed = [ + 'ops', + 'ops:', + ':read', + 'ops read', + "ops:'read", + 'ops::read', + 'a:b:', + '*:read', + ]; + for (const raw of malformed) { const thrown = thrownBy(() => readPermission(raw, 'admin:page')); expect([raw, thrown.code]).toEqual([raw, 'X_CLI_BAD_FLAG']); expect(thrown.fix).toBe('x g admin:page ops --permission ops:read'); @@ -62,6 +72,12 @@ describe('readKind', () => { for (const raw of ['ops:read', 'ledger:reconcile', 'admin:*', 'billing.eu:read']) { expect(readPermission(raw, 'admin:page')).toBe(raw); } + // Namespaced, as a staff console declares them: `Permission` is `${string}:${string}` and + // `definePermissions()` takes any such name, so `admin::` is one the runtime + // accepts — and one this refused, so the page was generated under a name the app did not use. + for (const raw of ['admin:blog:write', 'admin:incident:write', 'admin:ops:test-error']) { + expect(readPermission(raw, 'admin:page')).toBe(raw); + } expect(readPermission(undefined, 'admin:page')).toBeUndefined(); }); diff --git a/packages/cli/src/generate-kinds.ts b/packages/cli/src/generate-kinds.ts index d7fa8b96f..5b0cf11f8 100644 --- a/packages/cli/src/generate-kinds.ts +++ b/packages/cli/src/generate-kinds.ts @@ -244,18 +244,23 @@ const exampleFor = (kind: Generator): string => * space is refused for the same reason one step earlier: the value is spliced into a string * literal in emitted source, and neither is a permission any app declares. * + * Two segments or MORE: `admin:blog:write` is a `${string}:${string}` like any other, an app's + * staff console declares its permissions that way, and one colon exactly refused every one of them + * — the page was then generated under `blog:write` and renamed by hand in three places. An empty + * segment (`ops::read`, `ops:`) is still no permission. + * * It cannot ask whether the app DECLARES the permission — `x g` writes files against a root, it * never loads the app, and an app that will not import is exactly when a generator is reached for. * The generated page declaring it is what closes that half (`templates/admin-page.ts`). */ -const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*:[a-z0-9*][a-z0-9_.*-]*$/i; +const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*(?::[a-z0-9*][a-z0-9_.*-]*)+$/i; export function readPermission(raw: string | undefined, kind: Generator): string | undefined { if (raw === undefined || PERMISSION_SHAPE.test(raw)) return raw; throw new BadFlagError({ flag: 'permission', command: 'g', - reason: `expects a permission of the form :, got "${raw}"`, + reason: `expects a permission of the form : — or namespaced, :: — got "${raw}"`, fix: `${exampleFor(kind)} --permission ops:read`, }); } diff --git a/packages/cli/src/generate-name-shape.test.ts b/packages/cli/src/generate-name-shape.test.ts index cf9b5c142..3a5c3a18f 100644 --- a/packages/cli/src/generate-name-shape.test.ts +++ b/packages/cli/src/generate-name-shape.test.ts @@ -72,6 +72,17 @@ describe('unit · a plural name is refused where the plan pluralises it into a t ).not.toThrow(); }); + test('a feature`s second entity is judged on its own name, and fixed on it', () => { + const files = generate({ kind: 'entity', name: 'attempts', feature: 'blog', sibling: true }); + const thrown = refusal(() => + refusePluralTable(files, 'entity', 'attempts', 'blog', nothingOnDisk), + ); + expect(thrown.code).toBe('X_CLI_BAD_FLAG'); + expect(thrown.fix).toBe('x g entity attempt --feature blog'); + const fine = generate({ kind: 'entity', name: 'attempt', feature: 'blog', sibling: true }); + expect(() => refusePluralTable(fine, 'entity', 'attempt', 'blog', nothingOnDisk)).not.toThrow(); + }); + test('a plural that came from --feature is fixed on --feature', () => { const refused = refusal(() => refusePluralTable(planned('query', 'top', 'posts'), 'query', 'top', 'posts', nothingOnDisk), diff --git a/packages/cli/src/generate-plural.ts b/packages/cli/src/generate-plural.ts index a8c81bc38..8907477c3 100644 --- a/packages/cli/src/generate-plural.ts +++ b/packages/cli/src/generate-plural.ts @@ -33,6 +33,17 @@ export function refusePluralTable( feature: string | undefined, exists: (path: string) => boolean, ): void { + // A feature's second entity (`entity-.ts`) is named by the NAME, never by the slice. + const sibling = files.find((file) => /\/entity-[a-z0-9-]+\.ts$/.test(file.path)); + const single = sibling === undefined ? undefined : singularOf(kebab(name)); + if (single !== undefined && feature !== undefined) { + throw new BadFlagError({ + flag: 'name', + command: `g ${kind}`, + reason: `"${name}" is already plural, and the entity this run writes pluralises it again for its table — "${names(name).table}"`, + fix: ['x g', kind, single, '--feature', feature].join(' '), + }); + } for (const slice of writtenEntitySlices(files, exists)) { const singular = singularOf(slice); if (singular === undefined) continue; diff --git a/packages/cli/src/generated-imports.ts b/packages/cli/src/generated-imports.ts index 3ed31efdd..af34291cb 100644 --- a/packages/cli/src/generated-imports.ts +++ b/packages/cli/src/generated-imports.ts @@ -3,7 +3,8 @@ // a generator that emits one hands the author a red gate for the generator's own output — the edit // is mechanical, so the generator makes it. Shipped source only, the checker's own scope. -import { containedPath } from './generate-write'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import { isGenerated, isTest } from './source-files'; import { withDependency } from './workspace-dep-edit'; import { importedPackages, scanWorkspaces, type WorkspaceNode } from './workspace-graph'; @@ -14,6 +15,7 @@ const SOURCE = /\.tsx?$/; async function missingEdges( root: string, written: readonly string[], + disk: GenerateDisk, ): Promise>> { const missing = new Map>(); const sources = written.filter( @@ -27,7 +29,7 @@ async function missingEdges( for (const path of sources) { const owner = owners.find((node) => path.startsWith(`${node.dir}/`)); if (owner === undefined) continue; - const text = await Bun.file(containedPath(root, path)).text(); + const text = (await disk.read(path)) ?? ''; for (const name of importedPackages(text, path)) { const target = byName.get(name); if (target === undefined || target === owner || owner.dependencies.includes(name)) continue; @@ -43,16 +45,17 @@ async function missingEdges( export async function declareGeneratedImports( root: string, written: readonly string[], + disk: GenerateDisk = appDisk(root), ): Promise { const edited: string[] = []; - for (const [owner, edges] of await missingEdges(root, written)) { + for (const [owner, edges] of await missingEdges(root, written, disk)) { const path = `${owner.dir}/package.json`; - const file = Bun.file(containedPath(root, path)); - const before = await file.text(); + // Through the seam, not `owner.dependencies`: an earlier edit of this run may already hold it. + const before = (await disk.read(path)) ?? ''; let after = before; for (const [name, version] of edges) after = withDependency(after, name, version) ?? after; if (after === before) continue; - await Bun.write(containedPath(root, path), after); + await disk.write(path, after); edited.push(path); } return edited; diff --git a/packages/cli/src/handle-registration.test.ts b/packages/cli/src/handle-registration.test.ts index 37ccd119b..d46b53f14 100644 --- a/packages/cli/src/handle-registration.test.ts +++ b/packages/cli/src/handle-registration.test.ts @@ -232,3 +232,39 @@ describe('unit · a registration that cannot be made writes nothing', () => { expect(await read('packages/db/package.json')).toContain('"@shop/web": "0.0.0"'); }); }); + +// The set was closed at the first `}` in the raw text: one in a comment cut the set short and the +// entry was written into the middle of it. +describe('unit · a commented entity set is added to, and every comment survives', () => { + const COMMENTED = [ + "import { database } from '@ultimat3/entity';", + "import { post } from '@shop/web/app/post/entity';", + '', + 'const entities = {', + " // the blog's own table — `database({ … })` reads it; don't inline it", + ' posts: post,', + '};', + '', + 'export const db = database(entities, { driver });', + '', + ].join('\n'); + + test('the entry lands in the set, in key order, below the comment', () => { + const { source, missing } = insertHandleEntries(COMMENTED, [WIDGET]); + expect(missing).toEqual([]); + expect(source).toContain( + " // the blog's own table — `database({ … })` reads it; don't inline it\n posts: post,\n widgets: widget,\n};", + ); + expect(insertHandleEntries(source, [WIDGET]).source).toBe(source); + }); + + test('a set this cannot add a line to safely is refused: the entry comes back as missing', () => { + // An entry whose value spans rows and closes on a row shaped like an entry. + const odd = COMMENTED.replace(' posts: post,', ' posts: wrap({\n zebra: post,\n }),'); + const { source, missing } = insertHandleEntries(odd, [WIDGET]); + // The line edit would have put `widgets` INSIDE `wrap({ … })`; read back, that is not the + // set plus one entry, so nothing is written and the finding names the two lines. + expect(missing).toEqual([WIDGET]); + expect(source).toBe(odd); + }); +}); diff --git a/packages/cli/src/handle-registration.ts b/packages/cli/src/handle-registration.ts index 932608427..90301d212 100644 --- a/packages/cli/src/handle-registration.ts +++ b/packages/cli/src/handle-registration.ts @@ -5,9 +5,12 @@ // the two lines. import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core'; -import { containedPath } from './generate-write'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import type { Finding } from './output'; import { holdsKey, withKeyedLine, withSortedImport } from './source-edit'; +import { closingBracket, maskOf, readList } from './source-list-edit'; +import { entityModuleIn } from './templates/entity-module'; import type { GeneratedFile } from './templates/naming'; import { plural } from './templates/naming'; import type { HandleEntry } from './templates/scaffold-db-client'; @@ -27,7 +30,6 @@ export const DB_WORKSPACE = 'packages/db'; const WEB_WORKSPACE = 'apps/web'; const INDEX_FILE = `${DB_WORKSPACE}/src/index.ts`; -const ENTITY_PATH = /^apps\/web\/(?[^/]+\/[^/]+)\/entity\.ts$/; const ENTITY_EXPORT = /\bexport const ([A-Za-z_$][\w$]*) = entity\(/; const IDENTIFIER = '[A-Za-z_$][\\w$]*'; @@ -44,19 +46,34 @@ export const handleUnregisteredFinding = (entry: HandleEntry, reason: string): F at: HANDLE_FILE, }); -/** The object literal `database(, …)` is called with: where it opens and where it closes. */ +/** + * The object literal `database(, …)` is called with: where it opens and where it closes. + * Found on the MASKED text and closed by bracket depth — the first `}` after the `{` was the end + * of the set, so one in a comment or in an entry's own value cut the set short. + */ function entitySetOf( source: string, ): { readonly open: number; readonly close: number } | undefined { - const call = new RegExp(`\\bdatabase\\(\\s*(${IDENTIFIER})\\s*[,)]`).exec(source); + const masked = maskOf(source); + const call = new RegExp(`\\bdatabase\\(\\s*(${IDENTIFIER})\\s*[,)]`).exec(masked); if (call === null) return undefined; - const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(source); + const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(masked); if (declared === null) return undefined; const open = declared.index + declared[0].length; - const close = source.indexOf('}', open); + const close = closingBracket(masked, open - 1); return close === -1 ? undefined : { open, close }; } +/** Whether the set opening before `open` reads back as what it held plus `line`'s entry. */ +function setGained(before: string, after: string, open: number, line: string): boolean { + const was = readList(before, maskOf(before), open - 1); + const now = readList(after, maskOf(after), open - 1); + if (was === undefined || now === undefined) return false; + const expected = [...was.entries.map((entry) => entry.text), line.trim().replace(/,$/, '')]; + const held = now.entries.map((entry) => entry.text); + return held.length === expected.length && expected.every((text) => held.includes(text)); +} + /** * `source` with each entry imported and listed. An entry whose key the set already holds is left * alone; a file with no `const = { … }` handed to `database()` is returned untouched with @@ -77,21 +94,24 @@ export function insertHandleEntries( const body = next.slice(set.open, set.close); if (holdsKey(stripComments(body), entry.key)) continue; const listed = withKeyedLine(body, entry.key, handleEntryLine(entry), ' '); - next = withSortedImport( - `${next.slice(0, set.open)}${listed}${next.slice(set.close)}`, - handleImportLine(entry), - entry.specifier, - ); + const edited = `${next.slice(0, set.open)}${listed}${next.slice(set.close)}`; + // Read back: a set this line-edit misread (a comment shaped like an entry, a value spanning + // rows) is refused with the two lines named, never written wrong. + if (!setGained(next, edited, set.open, handleEntryLine(entry))) { + missing.push(entry); + continue; + } + next = withSortedImport(edited, handleImportLine(entry), entry.specifier); } return { source: next, missing }; } /** The entry an `entity.ts` implies, named by what the file exports; `undefined` for any other path. */ function entryOf(path: string, source: string, webModule: string): HandleEntry | undefined { - const rest = ENTITY_PATH.exec(path)?.groups?.['rest']; + const module = entityModuleIn(path, WEB_WORKSPACE); const binding = ENTITY_EXPORT.exec(source)?.[1]; - if (rest === undefined || binding === undefined) return undefined; - return { key: plural(binding), binding, specifier: `${webModule}/${rest}/entity` }; + if (module === undefined || binding === undefined) return undefined; + return { key: plural(binding), binding, specifier: `${webModule}/${module}` }; } /** @@ -117,14 +137,14 @@ export function withHandleEntries( /** The entries the written files imply: one per `entity.ts` this run put on disk. */ async function entriesFor( - root: string, + disk: GenerateDisk, written: readonly string[], webModule: string, ): Promise { const entries: HandleEntry[] = []; for (const path of written) { - if (!ENTITY_PATH.test(path)) continue; - const entry = entryOf(path, await Bun.file(containedPath(root, path)).text(), webModule); + if (entityModuleIn(path, WEB_WORKSPACE) === undefined) continue; + const entry = entryOf(path, (await disk.read(path)) ?? '', webModule); if (entry !== undefined) entries.push(entry); } return entries; @@ -136,11 +156,26 @@ export interface HandleRegistration { readonly findings: readonly Finding[]; } +const exportsHandle = (index: string): boolean => /from\s+'\.\/client'/.test(stripComments(index)); + +/** + * The db package is the app's OWN: it has an index, no handle, and exports none. A handle written + * into it is a file nothing imports — `packages/db/src/client.ts` appeared in an app whose db + * package is a schema and a seed, beside a finding that said the index did not export it. Said + * instead, with what the generated repo expects, and nothing is created. + */ +const ownDbPackageFinding = (entry: HandleEntry, dbModule: string): Finding => ({ + code: 'X_DB_HANDLE_UNREGISTERED', + cause: `${DB_WORKSPACE} has its own ${INDEX_FILE} and no typed handle (${HANDLE_FILE}), so "import { db } from '${dbModule}'" in the generated repo is not database()'s and db.${entry.key} does not exist — no handle was created in a package that does not export one`, + // Command first, the edit behind a `#`: the line runs as printed and names what it then proves. + fix: `x verify --only typecheck # after rewriting the generated repo over this app's own client, or adopting the typed handle: create ${HANDLE_FILE} exporting "db = database({ ${entry.key}: ${entry.binding} }, { driver })" with ${handleImportLine(entry)} and add export { db, driver, selectDriver } from './client'; to ${INDEX_FILE}`, + docs: ERROR_DOCS_URL, + at: INDEX_FILE, +}); + /** The handle exists but the package does not export it: the repo's import reaches something else. */ -async function indexFindings(root: string, dbModule: string): Promise { - const file = Bun.file(containedPath(root, INDEX_FILE)); - const source = (await file.exists()) ? await file.text() : ''; - if (/from\s+'\.\/client'/.test(stripComments(source))) return []; +async function indexFindings(disk: GenerateDisk, dbModule: string): Promise { + if (exportsHandle((await disk.read(INDEX_FILE)) ?? '')) return []; return [ { code: 'X_DB_HANDLE_UNREGISTERED', @@ -154,7 +189,10 @@ async function indexFindings(root: string, dbModule: string): Promise = { … }` refuses the * entry, and a run that then went on to edit `packages/db/package.json` left a manifest line for @@ -170,15 +208,19 @@ export async function registerGeneratedEntities( root: string, written: readonly string[], dbModule: string | undefined, + disk: GenerateDisk = appDisk(root), ): Promise { - const web = await readWorkspace(root, WEB_WORKSPACE); + const web = await readWorkspace(root, WEB_WORKSPACE, disk); const module = dbModule ?? PLACEHOLDER_DB_MODULE; const webModule = web?.name ?? webModuleOf(module); - const entries = await entriesFor(root, written, webModule); + const entries = await entriesFor(disk, written, webModule); if (entries.length === 0) return { edited: [], findings: [] }; const planned: PlannedEdit[] = []; - const file = Bun.file(containedPath(root, HANDLE_FILE)); - const before = (await file.exists()) ? await file.text() : undefined; + const before = await disk.read(HANDLE_FILE); + const index = before === undefined ? await disk.read(INDEX_FILE) : undefined; + if (index !== undefined && !exportsHandle(index)) { + return { edited: [], findings: entries.map((entry) => ownDbPackageFinding(entry, module)) }; + } const { source, missing } = before === undefined ? { source: dbClientSource(entries), missing: [] } @@ -202,16 +244,16 @@ export async function registerGeneratedEntities( docs: ERROR_DOCS_URL, at: `${DB_WORKSPACE}/package.json`, }); - } else findings.push(...(await indexFindings(root, dbModule))); + } else findings.push(...(await indexFindings(disk, dbModule))); const importsWeb = stripComments(source).includes(`from '${webModule}/`); const edges = [ ...(importsWeb ? [[DB_WORKSPACE, WEB_WORKSPACE] as const] : []), [WEB_WORKSPACE, DB_WORKSPACE] as const, ]; for (const [from, to] of edges) { - const manifest = await planWorkspaceDependency(root, from, to); + const manifest = await planWorkspaceDependency(root, from, to, disk); if (manifest !== undefined) planned.push(manifest); } - for (const edit of planned) await Bun.write(containedPath(root, edit.path), edit.contents); + for (const edit of planned) await disk.write(edit.path, edit.contents); return { edited: planned.map((edit) => edit.path), findings }; } diff --git a/packages/cli/src/i18n-index.test.ts b/packages/cli/src/i18n-index.test.ts index d7027634b..d22109417 100644 --- a/packages/cli/src/i18n-index.test.ts +++ b/packages/cli/src/i18n-index.test.ts @@ -82,7 +82,7 @@ describe('unit · a hand-edited index is edited, never replaced', () => { const sync = await syncI18nIndex(root); - expect(sync).toEqual({ registered: true, findings: [] }); + expect(sync).toEqual({ registered: true, findings: [], edited: [APP_CATALOGS_PATH] }); const after = await Bun.file(join(root, APP_CATALOGS_PATH)).text(); expect(after).toContain("import fr from '../catalogs/fr.json';"); expect(after).toContain("defineCatalogs({ default: 'es', locales: { es, fr } })"); @@ -90,6 +90,26 @@ describe('unit · a hand-edited index is edited, never replaced', () => { expect(after).not.toContain('en.json'); }); + test('a `locales: { … }` in a comment is prose: the call below it is the one edited', async () => { + const root = await appRoot(); + await Bun.write(join(root, 'packages/i18n/catalogs/fr.json'), '{}\n'); + const noted = [ + "import { defineCatalogs } from '@ultimat3/i18n';", + "import en from '../catalogs/en.json';", + '', + "// it's `locales: { en, es }` once Spanish ships", + "export const catalogs = defineCatalogs({ default: 'en', locales: { en } });", + '', + ].join('\n'); + await Bun.write(join(root, APP_CATALOGS_PATH), noted); + + await syncI18nIndex(root); + + const after = await Bun.file(join(root, APP_CATALOGS_PATH)).text(); + expect(after).toContain("// it's `locales: { en, es }` once Spanish ships"); + expect(after).toContain("defineCatalogs({ default: 'en', locales: { en, fr } })"); + }); + test('a shape this writer cannot edit is refused with the edit named, and left untouched', async () => { const root = await appRoot(); const odd = "export { catalogs } from './elsewhere';\n"; diff --git a/packages/cli/src/i18n-index.ts b/packages/cli/src/i18n-index.ts index b0a310280..78ae8c45a 100644 --- a/packages/cli/src/i18n-index.ts +++ b/packages/cli/src/i18n-index.ts @@ -9,7 +9,10 @@ // why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides // whether to write at all, before any await the caller could interleave with. import { existsSync } from 'node:fs'; +import { stripComments } from '@ultimat3/core'; import { APP_CATALOGS_PATH } from '@ultimat3/i18n/app-catalogs'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; import { containedPath } from './generate-write'; import type { Finding } from './output'; import { CATALOG_ROOT, i18nIndex, localeEntry, localeImport } from './templates'; @@ -30,6 +33,8 @@ export interface IndexSync { /** False for an app with no i18n package, or an index this writer refused to edit. */ readonly registered: boolean; readonly findings: readonly Finding[]; + /** The index, when this changed its bytes — a run's file list names every file it touched. */ + readonly edited?: readonly string[]; } const CATALOG_IMPORT = /^import (\w+) from '\.\.\/catalogs\/([^']+)\.json';$/gm; @@ -48,31 +53,42 @@ const importedLocales = (source: string): readonly string[] => * stay. A shape with no `locales: { … }` object to add to is refused with the edit named, never * overwritten: it was, on every `x g`, with a template hard-coding `default: 'en'`. */ -export async function syncI18nIndex(root: string): Promise { - const indexAbsolute = containedPath(root, APP_CATALOGS_PATH); - if (!existsSync(indexAbsolute)) return { registered: false, findings: [] }; - const current = await Bun.file(indexAbsolute).text(); - const onDisk = await catalogLocales(root); +export async function syncI18nIndex( + root: string, + disk: GenerateDisk = appDisk(root), + /** Locales whose catalog a dry run PLANS: on the real disk only once the run has written. */ + planned: readonly string[] = [], +): Promise { + const current = await disk.read(APP_CATALOGS_PATH); + if (current === undefined) return { registered: false, findings: [] }; + const onDisk = [...new Set([...(await catalogLocales(root)), ...planned])].sort(); const imported = importedLocales(current); - if (current === i18nIndex(imported)) { - await Bun.write(indexAbsolute, i18nIndex(onDisk)); - return { registered: true, findings: [] }; - } + const write = async (next: string): Promise => { + if (next === current) return { registered: true, findings: [] }; + await disk.write(APP_CATALOGS_PATH, next); + return { registered: true, findings: [], edited: [APP_CATALOGS_PATH] }; + }; + if (current === i18nIndex(imported)) return write(i18nIndex(onDisk)); const missing = onDisk.filter((locale) => !imported.includes(locale)); if (missing.length === 0) return { registered: true, findings: [] }; const edited = withLocales(current, missing); if (edited === undefined) return { registered: false, findings: [refusal(missing)] }; - await Bun.write(indexAbsolute, edited); - return { registered: true, findings: [] }; + return write(edited); } /** Each import after the last catalog import (or at the top), each entry at the object's end. */ function withLocales(source: string, locales: readonly string[]): string | undefined { - const object = LOCALES_OBJECT.exec(source); + // Found on the comment-stripped text (same offsets): a `locales: { … }` in a comment above the + // call is prose, and it was the one this edited. + const object = LOCALES_OBJECT.exec(stripComments(source)); if (object === null) return undefined; - const body = (object[2] ?? '').trim().replace(/,$/, ''); + const [whole, head = '', inner = ''] = object; + // A comment INSIDE the object cannot be carried through a one-line rewrite: refused, with the + // edit named, rather than dropped. + if (source.slice(object.index, object.index + whole.length) !== whole) return undefined; + const body = inner.trim().replace(/,$/, ''); const entries = [body, ...locales.map(localeEntry)].filter((part) => part !== '').join(', '); - const withEntries = source.replace(LOCALES_OBJECT, `$1 ${entries} $3`); + const withEntries = `${source.slice(0, object.index)}${head} ${entries} }${source.slice(object.index + whole.length)}`; const imports = locales.map(localeImport).join('\n'); const lastImport = [...withEntries.matchAll(CATALOG_IMPORT)].at(-1); if (lastImport === undefined) return `${imports}\n${withEntries}`; diff --git a/packages/cli/src/source-list-edit.test.ts b/packages/cli/src/source-list-edit.test.ts new file mode 100644 index 000000000..a00112c24 --- /dev/null +++ b/packages/cli/src/source-list-edit.test.ts @@ -0,0 +1,160 @@ +// The list editor every generator that grows an array goes through. What it must never do is read +// a comment or a literal as code: `x g entity` took each apostrophe in a role map's comments for a +// string delimiter and rewrote the comments into permissions. Property-style: every shape below is +// crossed with every hazard, and each result is judged by the same four invariants. + +import { describe, expect, test } from 'bun:test'; +import { + absentFrom, + appendToList, + closingBracket, + maskOf, + readList, + readProperties, +} from './source-list-edit'; + +/** The index of the `[` that follows `anchor` in `source`. */ +const openAfter = (source: string, anchor: string): number => + source.indexOf('[', source.indexOf(anchor)); + +const entriesOf = (source: string, anchor: string): readonly string[] => + readList(source, maskOf(source), openAfter(source, anchor))?.entries.map((one) => one.text) ?? + expect.unreachable('the list does not read back'); + +/** Every comment and template literal in `source`, in order — what an edit may never change. */ +const proseOf = (source: string): readonly string[] => + source.match(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|`[^`]*`/g) ?? []; + +/** Things that are not code and look like it: each lands between two entries of a list. */ +const HAZARDS: readonly (readonly [string, string])[] = [ + ['an apostrophe in a line comment', "// the firm's terms; it can't be granted twice"], + ['two apostrophes that would pair up', "// lawyers' seats and owners' seats"], + ['a closing bracket in a comment', '// see roles[0] and the list above ]'], + ['a comma and quotes in a comment', '// \'not:an-entry\', "nor:this", nor this'], + ['a backtick in a comment', "// `actorFact(actor, 'kycApproved')` decides, not a grant"], + ['a block comment with every delimiter', "/* it's [not] {code}, `at all` */"], + ['a doc block over two rows', "/**\n * The owner's row — see ']' below.\n */"], +]; + +/** Entries whose own text holds what a naive scan splits on. */ +const AWKWARD_ENTRIES: readonly string[] = [ + "'plain:read'", + "'with,comma:read'", + "'with]bracket:read'", + '"double:read"', + "'it\\'s:read'", + '`template:read`', + '...other.all', + "pick(['nested', 'call'])", +]; + +describe('unit · appending to a list never reads a comment or a literal as code', () => { + for (const [name, hazard] of HAZARDS) { + for (const trailing of [true, false]) { + test(`${name} — ${trailing ? 'trailing comma' : 'no trailing comma'}`, () => { + const body = AWKWARD_ENTRIES.flatMap((entry, index) => [ + ` ${hazard}`, + ` ${entry}${index < AWKWARD_ENTRIES.length - 1 || trailing ? ',' : ''}`, + ]).join('\n'); + const source = `// it's the header\nexport const all = define([\n${body}\n]);\n\nconst after = \`it's [kept]\`;\n`; + const next = appendToList(source, openAfter(source, 'define('), [ + "'new:read'", + "'new:write'", + ]); + if (next === undefined) return expect.unreachable('a safe edit was refused'); + // 1. The list is what it was, then the new entries. + expect(entriesOf(next, 'define(')).toEqual([ + ...AWKWARD_ENTRIES, + "'new:read'", + "'new:write'", + ]); + // 2. Every comment and template literal is byte-identical, in order. + expect(proseOf(next)).toEqual(proseOf(source)); + // 3. Nothing was removed: taking the inserted rows back out is the original but for the + // one comma a last entry without one owed. + const removed = next.replace(" 'new:read',\n 'new:write',\n", ''); + expect(removed.length - source.length).toBe(trailing ? 0 : 1); + // 4. A second run changes nothing. + expect(appendToList(next, openAfter(next, 'define('), ["'new:read'", "'new:write'"])).toBe( + next, + ); + }); + } + } + + test('an entry already there is not added again — whatever quote it is written in', () => { + const source = "const a = [\n // it's here\n \"held:read\",\n 'other:read',\n];\n"; + expect(appendToList(source, openAfter(source, 'a ='), ["'held:read'", "'other:read'"])).toBe( + source, + ); + const list = readList(source, maskOf(source), openAfter(source, 'a =')); + if (list === undefined) return expect.unreachable('unread'); + expect(absentFrom(list, ["'held:read'", "'new:read'", "'new:read'"])).toEqual(["'new:read'"]); + }); + + test('a comment-free list is re-wrapped the way Biome prints it: one line while it fits', () => { + const short = " grants: ['a:read'],\n"; + expect(appendToList(short, openAfter(short, 'grants'), ["'b:read'"])).toBe( + " grants: ['a:read', 'b:read'],\n", + ); + const long = ` grants: [${Array.from({ length: 9 }, (_unused, n) => `'table${n}:read'`).join(', ')}],\n`; + const wrapped = appendToList(long, openAfter(long, 'grants'), ["'last:read'"]) ?? ''; + expect(wrapped.split('\n').every((line) => line.length <= 100)).toBe(true); + expect(wrapped).toEndWith(" 'last:read',\n ],\n"); + expect(entriesOf(wrapped, 'grants')).toHaveLength(10); + }); + + test('a spread is an entry like any other, and survives', () => { + const source = 'const all = definePermissions([...a.all, ...b.all]);\n'; + expect(appendToList(source, openAfter(source, 'definePermissions'), ["'x:read'"])).toBe( + "const all = definePermissions([...a.all, ...b.all, 'x:read']);\n", + ); + }); + + test('an empty list, an empty list holding only a comment, and a one-line commented list', () => { + expect(appendToList('const a = [];\n', 10, ["'x:read'"])).toBe("const a = ['x:read'];\n"); + const noted = "const a = [\n // nothing yet — it's coming\n];\n"; + expect(appendToList(noted, 10, ["'x:read'"])).toBe( + "const a = [\n // nothing yet — it's coming\n 'x:read',\n];\n", + ); + const inline = "const a = ['p:read' /* the firm's */];\n"; + expect(appendToList(inline, 10, ["'x:read'"])).toBe( + "const a = ['p:read' /* the firm's */, 'x:read'];\n", + ); + }); + + test('a last entry with a comment beside it gets its comma before the comment', () => { + const source = "const a = [\n 'p:read' // the owner's\n];\n"; + expect(appendToList(source, 10, ["'x:read'"])).toBe( + "const a = [\n 'p:read', // the owner's\n 'x:read',\n];\n", + ); + }); + + test('a bracket that never closes is refused, never guessed at', () => { + const source = "const a = [\n 'p:read',\n"; + expect(appendToList(source, 10, ["'x:read'"])).toBeUndefined(); + expect(closingBracket(maskOf(source), 10)).toBe(-1); + }); +}); + +describe('unit · the properties of an object literal, off the masked text', () => { + test('a key named in a comment or a string is prose; a quoted key is read by its value', () => { + const source = [ + 'defineRoles({', + " // admin: { grants: ['never:this'] } — it's an example", + " note: 'admin: { grants: [] }',", + " 'api-admin': { grants: ['a:read'] },", + ' admin: {', + " grants: ['b:read'],", + ' },', + ' ...rest,', + ' shorthand,', + '});', + ].join('\n'); + const masked = maskOf(source); + const properties = readProperties(source, masked, source.indexOf('{')); + expect(properties.map((one) => one.key)).toEqual(['note', 'api-admin', 'admin']); + const admin = properties.find((one) => one.key === 'admin'); + expect(source.slice(admin?.value ?? 0, (admin?.value ?? 0) + 1)).toBe('{'); + }); +}); diff --git a/packages/cli/src/source-list-edit.ts b/packages/cli/src/source-list-edit.ts new file mode 100644 index 000000000..d08ffdd33 --- /dev/null +++ b/packages/cli/src/source-list-edit.ts @@ -0,0 +1,218 @@ +// Adding an entry to an array literal in a file the app owns, read off the MASKED source. Every +// generator that grows a list — a role's `grants`, `definePermissions([…])`, a `defineApi` list — +// found its `]` with `indexOf` and its entries with a quote regex, so an apostrophe in a comment +// was a string delimiter and a `]` in one closed the list: `x g entity` rewrote a role map's +// comments into permissions. Here a comment and a literal's contents are never read as code, and +// an edit that does not read back as "the same list plus the new entries" is refused, not written. + +import { maskLiterals, stripComments } from '@ultimat3/core'; +import { wrapList } from './templates/wrap'; + +const OPEN = new Set(['(', '[', '{']); +const CLOSE = new Set([')', ']', '}']); + +/** One top-level entry of a bracketed list: where its code starts and ends, and its exact text. */ +export interface ListEntry { + readonly start: number; + readonly end: number; + readonly text: string; +} + +/** A bracketed list as written: the bracket pair and every top-level entry between them. */ +export interface SourceList { + /** Index of the opening bracket. */ + readonly open: number; + /** Index of the bracket that closes it. */ + readonly close: number; + readonly entries: readonly ListEntry[]; + /** Whether a comma follows the last entry. */ + readonly trailingComma: boolean; +} + +/** `maskLiterals(source)`, spelled here so every caller of this module reads one mask. */ +export const maskOf = (source: string): string => maskLiterals(source); + +/** + * Index of the bracket closing the one at `open`, or -1 when nothing closes it. `masked` is + * `maskOf(source)`: a bracket in a comment, a string or a regex is a space there. + */ +export function closingBracket(masked: string, open: number): number { + let depth = 0; + for (let index = open; index < masked.length; index += 1) { + const ch = masked[index] as string; + if (OPEN.has(ch)) depth += 1; + else if (CLOSE.has(ch)) { + depth -= 1; + if (depth === 0) return index; + if (depth < 0) return -1; + } + } + return -1; +} + +/** + * The list whose opening bracket is at `open` — an array's `[` or an object's `{` — split at its + * own commas. `undefined` when the bracket never closes. An entry's span is its CODE: a comment + * above or beside it belongs to nobody, which is what lets an edit leave every comment alone. + */ +export function readList(source: string, masked: string, open: number): SourceList | undefined { + const close = closingBracket(masked, open); + if (close === -1) return undefined; + const entries: ListEntry[] = []; + let trailingComma = false; + const push = (from: number, to: number, comma: boolean): void => { + let start = from; + let end = to; + while (start < end && /\s/.test(masked[start] as string)) start += 1; + while (end > start && /\s/.test(masked[end - 1] as string)) end -= 1; + if (start < end) { + entries.push({ start, end, text: source.slice(start, end) }); + trailingComma = comma; + } + }; + let depth = 0; + let from = open + 1; + for (let index = open + 1; index < close; index += 1) { + const ch = masked[index] as string; + if (OPEN.has(ch)) depth += 1; + else if (CLOSE.has(ch)) depth -= 1; + else if (ch === ',' && depth === 0) { + push(from, index, true); + from = index + 1; + } + } + push(from, close, false); + return { open, close, entries, trailingComma }; +} + +/** What a plain string literal holds, or `undefined` for any other expression. */ +const QUOTED = /^(['"`])((?:(?!\1)[^\\$\n])*)\1$/; +export const literalValue = (text: string): string | undefined => QUOTED.exec(text)?.[2]; + +/** Two entries are the same when their text is, or when both are literals of one value. */ +const sameEntry = (left: string, right: string): boolean => + left === right || + (literalValue(left) !== undefined && literalValue(left) === literalValue(right)); + +/** The entries of `wanted` the list does not hold yet, each once. */ +export function absentFrom(list: SourceList, wanted: readonly string[]): readonly string[] { + const absent: string[] = []; + for (const entry of wanted) { + const held = [...list.entries.map((one) => one.text), ...absent]; + if (!held.some((text) => sameEntry(text, entry))) absent.push(entry); + } + return absent; +} + +const leadingSpace = (text: string): string => /^[ \t]*/.exec(text)?.[0] ?? ''; + +/** A list that holds no comment: re-wrapped whole, on one line when it fits and a row each when not. */ +function rewrapped(source: string, list: SourceList, entries: readonly string[]): string { + const lineStart = source.lastIndexOf('\n', list.open) + 1; + const newline = source.indexOf('\n', list.close); + const lineEnd = newline === -1 ? source.length : newline; + const head = source.slice(lineStart, list.open + 1); + const indent = leadingSpace(head); + const all = [...list.entries.map((entry) => entry.text), ...entries]; + const line = wrapList(indent, head.slice(indent.length), all, source.slice(list.close, lineEnd)); + return `${source.slice(0, lineStart)}${line}${source.slice(lineEnd)}`; +} + +/** A list over several lines: the new entries are INSERTED, and no existing byte moves. */ +function inserted(source: string, list: SourceList, entries: readonly string[]): string { + const closeLine = source.lastIndexOf('\n', list.close) + 1; + const beforeClose = source.slice(closeLine, list.close); + const last = list.entries.at(-1); + if (beforeClose.trim() !== '') { + // The closing bracket shares its line with code: the entries go right before it. + const lead = last === undefined ? '' : list.trailingComma ? ' ' : ', '; + return `${source.slice(0, list.close)}${lead}${entries.join(', ')}${source.slice(list.close)}`; + } + // The comma the last entry owes, placed right after its code — before any comment beside it. + const comma = last !== undefined && !list.trailingComma ? last.end : undefined; + const before = source.slice(0, closeLine); + const head = comma === undefined ? before : `${before.slice(0, comma)},${before.slice(comma)}`; + const first = list.entries[0]; + const firstLine = first === undefined ? -1 : source.lastIndexOf('\n', first.start) + 1; + // An entry's own indent where one starts its line; else one step in from the closing bracket. + const indent = + first !== undefined && source.slice(firstLine, first.start).trim() === '' + ? source.slice(firstLine, first.start) + : `${beforeClose} `; + const rows = entries.map((entry) => `${indent}${entry},\n`).join(''); + return `${head}${rows}${source.slice(closeLine)}`; +} + +/** + * `source` with `wanted` added to the list opening at `open`, or `undefined` when that cannot be + * done safely — the caller then reports the edit instead of making it. + * + * A list that holds NO comment is re-wrapped the way Biome prints it — on one line while it fits, + * a row per entry once it does not — because a formatter would move it there anyway and the app's + * own `lint` step reads the difference. A list with a comment in it, or with an entry that spans + * rows, gains rows before its closing bracket and nothing else changes: every comment, every + * literal and every byte outside the insertion is the author's. + * + * The result is READ BACK before it is answered: the list must hold exactly what it held plus the + * new entries, and the text before and after it must be untouched. A shape this reasoning is wrong + * about fails that check here, never in the app. + */ +export function appendToList( + source: string, + open: number, + wanted: readonly string[], +): string | undefined { + const masked = maskOf(source); + const list = readList(source, masked, open); + if (list === undefined) return undefined; + const entries = absentFrom(list, wanted); + if (entries.length === 0) return source; + const span = source.slice(list.open, list.close + 1); + const commented = stripComments(source).slice(list.open, list.close + 1) !== span; + const simple = list.entries.every((entry) => !entry.text.includes('\n')); + const next = + !commented && simple ? rewrapped(source, list, entries) : inserted(source, list, entries); + // Read back: same prefix, same suffix, the old entries then the new ones. + const reread = readList(next, maskOf(next), open); + const expected = [...list.entries.map((entry) => entry.text), ...entries]; + if ( + reread === undefined || + next.slice(0, open) !== source.slice(0, open) || + next.slice(reread.close) !== source.slice(list.close) || + reread.entries.length !== expected.length || + reread.entries.some((entry, index) => entry.text !== expected[index]) + ) { + return undefined; + } + return next; +} + +/** One `key: value` of an object literal, located on the masked text. */ +export interface SourceProperty { + readonly key: string; + /** Index of the first character of the value. */ + readonly value: number; +} + +/** + * The `key: value` properties of the object opening at `open`. A shorthand, a spread, a method + * and a computed key are not `key: value` and are not answered. + */ +export function readProperties( + source: string, + masked: string, + open: number, +): readonly SourceProperty[] { + const list = readList(source, masked, open); + if (list === undefined) return []; + return list.entries.flatMap((entry) => { + const colon = /^(?:[A-Za-z_$][\w$]*|'[^']*'|"[^"]*")\s*:/.exec( + masked.slice(entry.start, entry.end), + ); + if (colon === null) return []; + const raw = source.slice(entry.start, entry.start + colon[0].length - 1).trim(); + let value = entry.start + colon[0].length; + while (value < entry.end && /\s/.test(masked[value] as string)) value += 1; + return [{ key: literalValue(raw) ?? raw, value }]; + }); +} diff --git a/packages/cli/src/templates/entity-module.ts b/packages/cli/src/templates/entity-module.ts new file mode 100644 index 000000000..bd2780555 --- /dev/null +++ b/packages/cli/src/templates/entity-module.ts @@ -0,0 +1,29 @@ +// Which files of a slice declare an entity. The first is `entity.ts`; every further one is a +// sibling, `entity-.ts`, with its own `repo-.ts` — one file, one table. Every reader of +// "the entity files this run wrote" asks here: three of them matched `/entity.ts` alone, so a +// second entity in a feature could be written by nothing and registered by nothing. + +/** `apps////entity.ts` or `…/entity-.ts` — never a test beside it. */ +const ENTITY_MODULE = + /^apps\/[^/]+\/(?[^/]+\/[a-z0-9-]+)\/(?entity(?:-[a-z0-9-]+)?)\.ts$/; + +/** Whether `path` is a module a generator declares an entity in. */ +export const isEntityModule = (path: string): boolean => ENTITY_MODULE.test(path); + +/** `app/blog/entity-blog-attempt` for a web-workspace entity module; `undefined` for any other. */ +export const entityModuleIn = (path: string, workspace: string): string | undefined => { + if (!path.startsWith(`${workspace}/`)) return undefined; + const groups = ENTITY_MODULE.exec(path)?.groups; + return groups === undefined ? undefined : `${groups['rest']}/${groups['stem']}`; +}; + +/** The two module names an entity's files take: the slice's first, or a named sibling's. */ +export interface EntityStems { + readonly entity: string; + readonly repo: string; +} + +export const entityStems = (kebabName: string, sibling: boolean): EntityStems => + sibling + ? { entity: `entity-${kebabName}`, repo: `repo-${kebabName}` } + : { entity: 'entity', repo: 'repo' }; diff --git a/packages/cli/src/templates/entity.ts b/packages/cli/src/templates/entity.ts index a454176df..d147c1da4 100644 --- a/packages/cli/src/templates/entity.ts +++ b/packages/cli/src/templates/entity.ts @@ -3,6 +3,8 @@ // rather than copied fixture files so the generator output is typed, diffable and testable from a // unit test. +import type { EntityStems } from './entity-module'; +import { entityStems } from './entity-module'; import { sortedImports } from './imports'; import type { GeneratedFile, NameSet } from './naming'; import { names } from './naming'; @@ -28,8 +30,9 @@ const entitySource = ( name: NameSet, snake: string, table: string, + stems: EntityStems, ): string => `// The ${name.camel} table, its domain type and its invariants. No I/O beyond the column -// definitions: repo.ts owns every query that touches this table. +// definitions: ${stems.repo}.ts owns every query that touches this table. import { entity, invariant, money, text, timestamp, uuid } from '@ultimat3/entity'; @@ -39,9 +42,9 @@ export const ${name.camel} = entity('${table}', { // // SINGLE-TENANT APP? There is no flag: \`x g action\`, \`x g query\` and \`x g policy\` decide on // \`orgId\` too, so the edit is per slice and it is this: delete \`tenant: 'orgId'\` and the - // \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. repo.ts needs no edit — - // it names no org. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false, - // its \`row()\` fixture and repo.test.ts's \`draft()\` lose \`orgId\`, and repo.test.ts loses its + // \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. ${stems.repo}.ts needs no edit — + // it names no org. ${stems.entity}.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false, + // its \`row()\` fixture and ${stems.repo}.test.ts's \`draft()\` lose \`orgId\`, and ${stems.repo}.test.ts loses its // last test. Nothing else reads the column. tenant: 'orgId', columns: { @@ -84,7 +87,7 @@ const chain = (indent: string, head: string, calls: readonly string[]): string = return `${indent}${head}\n${calls.map((call) => `${indent} ${call}`).join('\n')};`; }; -const repoSource = (name: NameSet, dbModule: string): string => { +const repoSource = (name: NameSet, dbModule: string, stems: EntityStems): string => { const row = name.pascal; const table = `db.${name.plural}`; const listSignature = wrapList( @@ -124,7 +127,7 @@ const repoSource = (name: NameSet, dbModule: string): string => { // it here only for a statement the handle cannot express — a join, a window, a CTE. import { db } from '${dbModule}'; -import type { ${row} } from './entity'; +import type { ${row} } from './${stems.entity}'; ${byIdSignature} ${byIdBody} @@ -153,6 +156,7 @@ ${insertSignature} const repoTest = ( name: NameSet, dbModule: string, + stems: EntityStems, ): string => `// The ${name.kebab} repo against the in-memory driver: the contract Postgres serves, with no // database. What it pins is what the repo cannot show by being read — whose rows a call reaches. ${sortedImports([ @@ -161,7 +165,7 @@ ${sortedImports([ "import { afterEach, expect, unitTest } from '@ultimat3/testing';", `import { driver } from '${dbModule}';`, ])} -import * as repo from './repo'; +import * as repo from './${stems.repo}'; const orgId = '00000000-0000-4000-8000-000000000002'; const otherOrg = '00000000-0000-4000-8000-000000000009'; @@ -223,11 +227,12 @@ const entityTest = ( name: NameSet, snake: string, table: string, + stems: EntityStems, ): string => `// The ${name.kebab} entity's declaration: the table it maps to and the invariants it names. Both // are what the migration generator and every query read, so both are worth pinning. import { expect, unitTest } from '@ultimat3/testing'; -import type { ${name.pascal} } from './entity'; -${wrapImport([`${name.pascal}View`, name.camel], './entity')} +import type { ${name.pascal} } from './${stems.entity}'; +${wrapImport([`${name.pascal}View`, name.camel], `./${stems.entity}`)} type Over = Partial<${name.pascal}>; @@ -293,14 +298,25 @@ unitTest('${name.camel} parses a row through its own columns', () => { }); `; -export function entityFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] { +/** + * `sibling` is the feature's SECOND entity onward: `entity.ts` is already another table's, so this + * one lands beside it as `entity-.ts` with its own `repo-.ts` — a file per table, and + * nothing the author wrote is edited to make room. + */ +export function entityFiles( + rawName: string, + target: FeatureTarget, + options: { readonly sibling?: boolean } = {}, +): readonly GeneratedFile[] { const name = names(rawName); const dir = `${target.surfaceDir}/${target.feature}`; const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE; + const stems = entityStems(name.kebab, options.sibling === true); + const { snake, table } = name; return [ - { path: `${dir}/entity.ts`, contents: entitySource(name, name.snake, name.table) }, - { path: `${dir}/entity.test.ts`, contents: entityTest(name, name.snake, name.table) }, - { path: `${dir}/repo.ts`, contents: repoSource(name, dbModule) }, - { path: `${dir}/repo.test.ts`, contents: repoTest(name, dbModule) }, + { path: `${dir}/${stems.entity}.ts`, contents: entitySource(name, snake, table, stems) }, + { path: `${dir}/${stems.entity}.test.ts`, contents: entityTest(name, snake, table, stems) }, + { path: `${dir}/${stems.repo}.ts`, contents: repoSource(name, dbModule, stems) }, + { path: `${dir}/${stems.repo}.test.ts`, contents: repoTest(name, dbModule, stems) }, ]; } diff --git a/packages/cli/src/workspace-dep-edit.ts b/packages/cli/src/workspace-dep-edit.ts index 0e5d7cc42..7155674e7 100644 --- a/packages/cli/src/workspace-dep-edit.ts +++ b/packages/cli/src/workspace-dep-edit.ts @@ -3,7 +3,8 @@ // (`X_WORKSPACE_DEP_UNDECLARED`), and re-serialising the whole file would reformat arrays and // spacing the author — or their formatter — chose. -import { containedPath } from './generate-write'; +import type { GenerateDisk } from './generate-disk'; +import { appDisk } from './generate-disk'; const MANIFEST = 'package.json'; @@ -76,10 +77,10 @@ export function withDependency( export async function readWorkspace( root: string, dir: string, + disk: GenerateDisk = appDisk(root), ): Promise<{ readonly name: string; readonly version: string } | undefined> { - const file = Bun.file(containedPath(root, `${dir}/${MANIFEST}`)); - if (!(await file.exists())) return undefined; - const parsed = parseManifest(await file.text()); + const text = await disk.read(`${dir}/${MANIFEST}`); + const parsed = text === undefined ? undefined : parseManifest(text); if (parsed === undefined || typeof parsed.name !== 'string' || parsed.name === '') { return undefined; } @@ -105,12 +106,13 @@ export async function planWorkspaceDependency( root: string, fromDir: string, toDir: string, + disk: GenerateDisk = appDisk(root), ): Promise { - const target = await readWorkspace(root, toDir); + const target = await readWorkspace(root, toDir, disk); const path = `${fromDir}/${MANIFEST}`; - const file = Bun.file(containedPath(root, path)); - if (target === undefined || !(await file.exists())) return undefined; - const contents = withDependency(await file.text(), target.name, target.version); + const text = await disk.read(path); + if (target === undefined || text === undefined) return undefined; + const contents = withDependency(text, target.name, target.version); return contents === undefined ? undefined : { path, contents }; } @@ -119,9 +121,10 @@ export async function declareWorkspaceDependency( root: string, fromDir: string, toDir: string, + disk: GenerateDisk = appDisk(root), ): Promise { - const edit = await planWorkspaceDependency(root, fromDir, toDir); + const edit = await planWorkspaceDependency(root, fromDir, toDir, disk); if (edit === undefined) return undefined; - await Bun.write(containedPath(root, edit.path), edit.contents); + await disk.write(edit.path, edit.contents); return edit.path; } diff --git a/wiki/CLI-Reference.md b/wiki/CLI-Reference.md index 07195ca36..5e9bdbbc7 100644 --- a/wiki/CLI-Reference.md +++ b/wiki/CLI-Reference.md @@ -79,7 +79,7 @@ x new [--dir path] [--no-example] [--no-git] [--dry-run] [--force] [--jso | `--example` / `--no-example` | boolean | `true` | include the example feature slice (default: on; `--no-example` for an empty `app/`) | | `--git` / `--no-git` | boolean | `true` | `git init`, then one commit named `x new`. On by default because three surfaces assume a repository — `x affected`, `x ci` and `x pr`. A git that is absent or fails never fails `x new`: the tree is the verdict, and `data.git` carries `{ initialized, committed, problem }` | | `--dry-run` | boolean | `false` | print the file list, write nothing | -| `--permission` | string | `:read` | `admin:page` only: the permission the page's own work needs, on top of the frame's `admin:read`. A `:`, or `X_CLI_BAD_FLAG` before a file is written — the value reaches the emitted source three times and `Permission` is `${string}:${string}` | +| `--permission` | string | `:read` | `admin:page` only: the permission the page's own work needs, on top of the frame's `admin:read`. A `:`, or namespaced with more segments (`admin:blog:write`, `As of 27.6.2` — one colon exactly was required, which refused every `admin::` a staff console declares); an empty segment, a space or a quote is `X_CLI_BAD_FLAG` before a file is written — the value reaches the emitted source three times and `Permission` is `${string}:${string}` | | `--force` | boolean | `false` | write into a directory that already exists | **The name must be one `app.config.ts` accepts.** `x new` asks core's own `defineConfig` about the slug before anything is planned (`appNameIssue`, `packages/cli/src/app-name.ts`) — it is written verbatim as `name:`. Refused with `X_CLI_BAD_FLAG` and a corrected name in the `fix:`, the rest of your flags kept: @@ -306,7 +306,7 @@ is a word (`a-z`, `0-9`, `-`, `_`) or `[param]` / `[...rest]`; `..`, `.` and emp | `--admin` | boolean | `false` | `resource` only: also emit the per-entity admin override **and list it** under `resources:` in `apps/admin/app/admin/admin.ts`. The screen itself needs no flag — an entity in the handle is an admin screen | | `--locales` | string | every locale with a catalog in `packages/i18n/catalogs/`, else `en` | comma-separated locales; lands each generator's catalog entry in every one. **Never starts a catalog** in an app that has any: a non-default locale with no `packages/i18n/catalogs/.json` is `X_CLI_BAD_FLAG`, `fix: x i18n add `, before a file is planned — a catalog a generator started would hold only its own keys. A non-default locale's new keys land marked `⟦…⟧`, as `x i18n add` seeds them, so `x i18n check` reports them until translated. In an app with no catalogs yet, a first `--locales en,es` writes `en` bare and `es` marked. `As of 2026-10-06` (`localiseCatalogs`, `packages/cli/src/generate-catalog-locales.ts`) | | `--force` | boolean | `false` | overwrite existing files | -| `--dry-run` | boolean | `false` | the write plan, nothing written: the files the run would write (a slice module already on disk is skipped, not listed), and `ok: false` with every conflict the real run would stop on — an existing file, an API-index binding already held | +| `--dry-run` | boolean | `false` | the write plan, nothing written: **every file the run would touch** — the ones it creates (a slice module already on disk is skipped, not listed) and, `As of 27.6.2`, the ones it EDITS outside the slice (role map, typed handle, API index, manifests, catalogs, the i18n index), in the order the run lists them. `--json`: `data.files` is that list, `data.edits` the subset that already exists, `data.refreshes` the committed contracts (`x.manifest.json`, `openapi.json`) a real run re-projects. `ok: false` with every finding the real run would report — an existing file, an API-index binding already held, a grant or a handle entry it cannot place | `resource` emits the whole slice — `entity`, `repo`, `policy`, `errors`, `service`, `actions`, `live`, `jobs`, `ui`, the form island, the plural route and a test beside each declaration — **33 files** (35 with `--admin`; `--live` is refused — a resource already ships a live query, and the flag belongs to `x g query`: `X_CLI_BAD_FLAG`, fix `x g query -feed --feature --live`), and **no migration**: `x db gen` is the only writer of `packages/db/migrations`, so a new slice is `x g resource `, then `x db gen "create
"`, then `x db migrate`, then `x build --target static && x verify --only budgets` — the four commands the run prints, in that order (`data.next` under `--json`). **The plural route is reachable as written**, `As of 2026-10-01`: its `load` reads the list through the slice's query as the request's actor (no org in the URL), `AsyncRegion` renders ready, empty and refused, the form island is mounted, and the route declares `policy: { permission: ':read' }`. Its `budget.js` is MEASURED on `x new`'s app (58,522 B, Bun 1.4.0) and carries the `measured:` / `why:` comment `bun run budget-raises` reads; an app that charges more to every document — `navigation: { client: [...] }` adds its router — is over it on the printed build, and the raise is that edit. The page sits in `apps/web/shared/shell.tsx` when the app has one. `backfill` emits a `backfill()` declaration with its `source()` and `handle()` to fill in — see [Migrations and backfills](Migrations-And-Backfills). Every generator produces code that passes `x verify` unmodified. Errors: `X_GENERATE_CONFLICT`, `X_DB_HANDLE_UNREGISTERED`. @@ -316,11 +316,11 @@ is a word (`a-z`, `0-9`, `-`, `_`) or `[param]` / `[...rest]`; `..`, `.` and emp | Edited | What lands | When it cannot | |---|---|---| -| `packages/db/src/client.ts` | the entity's import, and `
: ,` in `const entities = { … }`, in key order. Created whole in an app that has none | `X_DB_HANDLE_UNREGISTERED`, exit 1, the two lines in the `fix:` — the files are written, the handle is not | +| `packages/db/src/client.ts` | the entity's import, and `
: ,` in `const entities = { … }`, in key order. Created whole only where the db package would export it: its `index.ts` names `./client`, or it has no index. **Never created in a db package of the app's own** (`As of 27.6.2`: an `index.ts` that exports no handle — a schema and a seed over `@ultimat3/db`'s client) | `X_DB_HANDLE_UNREGISTERED`, exit 1, the two lines in the `fix:` — the files are written, the handle is not. In an app with its own db package the finding sits on `index.ts` and says the generated repo expects `database()`'s handle | | `packages/db/src/index.ts` | nothing — it is checked: it must export from `./client` | `X_DB_HANDLE_UNREGISTERED` naming the export line | | `packages/db/package.json`, `apps/web/package.json` | the workspace dependency each new import needs: `@/db` in the app, and `@/web` in the db package only when the handle this run leaves imports an entity from it. Planned with the handle edit and written together — a handle that refuses the entry leaves both manifests untouched | left to `package-shape` (`X_WORKSPACE_DEP_UNDECLARED`), whose `fix:` is the same line | | `packages/i18n/catalogs/.json` | the labels the admin reads for the new screen — `admin.
.title` and one `admin.
.field.` per column — merged in the file's OWN key order (a sorted level stays sorted, a hand-ordered one gains its keys at the end), in every locale the run writes to | — | -| `apps/web/shared/roles.ts` | a written `policy.ts`'s grants (`:read` to `member`, `:write` to `admin`) and the admin's three per-table permissions, declared and granted | `X_PERMISSION_UNGRANTED`, exit 1, naming each role and the permissions its `grants` owes — an app whose role map lives elsewhere is told, not skipped | +| `apps/web/shared/roles.ts` | a written `policy.ts`'s grants (`:read` to `member`, `:write` to `admin`) and the admin's three per-table permissions, declared and granted. A permission is declared only where its grant can be placed, and one any `definePermissions([…])` call already declares is not declared again | `X_PERMISSION_UNGRANTED`, exit 1, naming each role and the permissions its `grants` owes — an app whose role map lives elsewhere, has no such role, or holds a list that cannot be edited safely is told, and the file is left byte-identical | | `apps/web/api/index.ts` | every action, query, job and task module the run wrote, imported and listed under `actions` / `queries` / `jobs` / `tasks` — the lists `Api`, the typed client's shape, is read from | left alone when the file is not `defineApi({ actions: [...] })`; `X_JOB_UNREGISTERED` on the `manifest` step names a job nothing lists. A module whose binding (its file name, camelCased) the index already holds — another feature's `reindex-post`, or `api` / `defineApi` / `health` — is `X_GENERATE_CONFLICT` **before anything is written**, fix: the same run with the feature as a prefix | | `apps/admin/app/admin/admin.ts` (`--admin` only) | the override's import, and `
: AdminResource,` under `resources:` — the block is created, last in the call, when there is none | `X_ADMIN_RESOURCE_UNWIRED`, exit 1, the two lines in the `fix:` | @@ -330,7 +330,13 @@ The summary line ends with what the new table owes, as commands that run as prin ✓ wrote 7 file(s) for entity widget — next: bunx x db gen "create widgets" && bunx x db migrate ``` -`bun install` leads the list when a manifest gained a dependency. `--dry-run` plans the generator's own files only, never the edits above. +`bun install` leads the list when a manifest gained a dependency. `--dry-run` runs the same edits against the plan held in memory, so it lists every file above that the run would change (`As of 27.6.2`; it listed the generator's own files only). + +**An edit to a file the app owns is syntax-aware, and refused rather than guessed**, `As of 27.6.2`. Every list a generator grows — a role's `grants`, `definePermissions([…])`, a `defineApi` list, the handle's entity set — is located on the source with comments and literal contents masked (`packages/cli/src/source-list-edit.ts`), so an apostrophe, a bracket or a comma inside a comment is never code. A list holding a comment gains rows and no existing byte moves; a comment-free one is re-wrapped the way Biome prints it. The result is read back before it is written — the same entries plus the new ones, the text around it untouched — and an edit that does not read back that way is the finding in the table above, never a write. (`x g entity` read each apostrophe in a role map's comments as a string delimiter and rewrote the comments between two of them into permissions.) + +**A feature's second entity is a sibling file**, `As of 27.6.2`. `x g entity --feature ` in a slice whose `entity.ts` declares another table writes `entity-.ts`, `entity-.test.ts`, `repo-.ts` and `repo-.test.ts` beside it — one file per table, nothing the author wrote edited — and registers it exactly as a first entity: handle, role map, admin labels, `data.next` naming its own table. It was `X_GENERATE_CONFLICT` on the first entity's four files, with a `--force` that would have written the second table over the first. The same entity again is still that conflict, on its own files. `x db gen` reads the entity registry, so a sibling is in the next migration because it is declared. The other generators (`action`, `query`, `job`, …) keep reading the slice's `entity.ts` and `repo.ts`: a primitive over a sibling's table imports `./repo-` by hand. + +**Catalog writes are the labels the admin reads**: `x g entity`, `x g resource` and `x g admin:page` merge their keys (`admin.
.title`, `admin.
.field.`; `admin..title` for a page) into every locale the app has a catalog for — the default locale bare, each other locale marked `⟦…⟧` exactly as `x i18n add` seeds it, so `x i18n check` reports them as owed rather than rendering a raw key. Existing keys always win. `--dry-run` lists each catalog it would change; `--locales ` narrows the run to the catalogs named. **`x g admin:page` DECLARES the permission it requires**, `As of 2026-09`. It emitted `permissions: ['ops:read']` and nothing anywhere declared `ops:read`, so `assertPermission` threw diff --git a/wiki/Entities-And-Migrations.md b/wiki/Entities-And-Migrations.md index 33d617b5d..51ce894a3 100644 --- a/wiki/Entities-And-Migrations.md +++ b/wiki/Entities-And-Migrations.md @@ -233,6 +233,8 @@ actor in another org reads nothing with no org named in any call. Row equality i `expect(row).toEqualRow(other)`: a sealed column is a non-enumerable property, which `toEqual` skips. After `x g entity `: `bunx x db gen "create
"`, then `bunx x db migrate`. The generator + +A feature with more than one table: `x g entity --feature ` writes `entity-.ts` + `repo-.ts` (and a test beside each) next to the first entity's `entity.ts`, and registers it on the handle the same way (`As of 27.6.2`). One file per table; `x db gen` picks every one up from the registry. prints both, with `bun install` first when it added a workspace dependency ([CLI reference](CLI-Reference)). From 9c1d25ce4ba5d73a99dc92b88378b9f401ed97c8 Mon Sep 17 00:00:00 2001 From: sebi Date: Sat, 10 Oct 2026 16:52:10 -0500 Subject: [PATCH 2/2] fix(cli): the api registrar reads back a new list before writing it; a same-table entity spelled unusually stays a conflict; docs rows moved CodeRabbit on #754. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- packages/cli/src/api-registration.test.ts | 19 +++++++++ packages/cli/src/api-registration.ts | 10 ++++- packages/cli/src/cmd-generate.ts | 13 +++--- packages/cli/src/generate-sibling.test.ts | 51 +++++++++++++++++++++++ packages/cli/src/generate-sibling.ts | 34 +++++++++++++++ wiki/CLI-Reference.md | 2 +- wiki/Entities-And-Migrations.md | 4 +- 8 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/generate-sibling.test.ts create mode 100644 packages/cli/src/generate-sibling.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f514fba2..450e0e9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,11 @@ Tier 5 — cli. - **`x g` no longer corrupts a commented role map.** `x g entity` / `x g resource` / `x g policy` add permissions to `apps/web/shared/roles.ts`, and found each list's end with the first `]` in the raw text and its entries with a quote regex: an apostrophe in a comment ("the firm's") was a string delimiter, so the comments between two of them were rewritten into permissions and the file no longer loaded. Every list a generator grows — a role's `grants`, `definePermissions([…])`, a `defineApi` list, the handle's entity set — is now located on the masked source (`source-list-edit.ts`): a list holding a comment gains rows and no existing byte moves, a comment-free one is re-wrapped the way Biome prints it, a spread entry survives, and the result is read back before it is written. An edit that does not read back as "the same list plus the new entries" is `X_PERMISSION_UNGRANTED` / `X_DB_HANDLE_UNREGISTERED` naming the lines to add, never a write. - **A permission is declared only where it can be granted.** In a role map with no `admin` role, `x g entity` declared `
:read|write|delete` in the first `definePermissions([…])` beside a finding that said nothing was granted — three names no role held. It now declares none of them, and one that any `definePermissions([…])` call already declares is not declared again in the first. -- **`x g entity --feature ` writes the feature's second entity.** It was `X_GENERATE_CONFLICT` on the first entity's files, with a `--force` that would have overwritten the first table. A slice whose `entity.ts` declares another table now gets `entity-.ts` + `repo-.ts` (and a test beside each), registered on the handle, in the role map and in the admin labels like a first entity; `data.next` names its own table. The same entity again is still the conflict. +- **`x g entity --feature ` writes the feature's second entity.** It was `X_GENERATE_CONFLICT` on the first entity's files, with a `--force` that would have overwritten the first table. A slice whose `entity.ts` declares another table now gets `entity-.ts` + `repo-.ts` (and a test beside each), registered on the handle, in the role map and in the admin labels like a first entity; `data.next` names its own table. The same entity again is still the conflict — as is an `entity.ts` whose table the generator cannot read (a constant, an interpolation): only a file that readably declares other tables hosts a sibling. - **No `packages/db/src/client.ts` in a db package of the app's own.** The handle was created in any app that lacked the file — including one whose `packages/db/src/index.ts` exports no handle (a schema and a seed over `@ultimat3/db`'s client), where nothing imports it. It is created only where the index names `./client` or there is no index; otherwise `X_DB_HANDLE_UNREGISTERED` on the index says what the generated repo expects, and nothing is written there. - **`x g --dry-run` lists every file the run touches.** It listed the files a generator creates and none it edits: the role map, the typed handle, the API index, two manifests and the i18n index changed under a plan that never named them. The dry run now runs the same edits against the plan in memory (`generate-follow-ups.ts` over a disk seam): `data.files` is the run's list, `data.edits` the subset that already exists, `data.refreshes` the committed contracts a real run re-projects, and a finding the run would report (`X_PERMISSION_UNGRANTED`, `X_DB_HANDLE_UNREGISTERED`, `X_ADMIN_RESOURCE_UNWIRED`) makes the dry run `ok: false` too. A real run's list also names `packages/i18n/src/index.ts` when it changed it. - **`x g admin:page --permission admin:blog:write` is accepted.** The flag required one colon exactly, which refused every namespaced permission (`admin::`) though `Permission` is `${string}:${string}` and `definePermissions()` takes it. Two segments or more; an empty segment, a space or a quote is still `X_CLI_BAD_FLAG` with a runnable example. -- **The same scan, in the other registrars.** `apps/web/api/index.ts`: a comment between two entries of a `defineApi` list was split into entries. `packages/db/src/client.ts`: a `}` in a comment closed the entity set. `packages/i18n/src/index.ts`: a `locales: { … }` in a comment was the one edited. `apps/admin/app/admin/admin.ts`: a brace in a string label miscounted the call. +- **The same scan, in the other registrars.** `apps/web/api/index.ts`: a comment between two entries of a `defineApi` list was split into entries, and a new list in a one-line `defineApi({ … });` was written outside the call (now skipped, the file untouched). `packages/db/src/client.ts`: a `}` in a comment closed the entity set. `packages/i18n/src/index.ts`: a `locales: { … }` in a comment was the one edited. `apps/admin/app/admin/admin.ts`: a brace in a string label miscounted the call. ## 27.6.1 - 2026-10-10 diff --git a/packages/cli/src/api-registration.test.ts b/packages/cli/src/api-registration.test.ts index 9d75c45b6..fc24cd346 100644 --- a/packages/cli/src/api-registration.test.ts +++ b/packages/cli/src/api-registration.test.ts @@ -259,3 +259,22 @@ describe('unit · a commented list is added to, and every comment survives', () expect(insertApiEntries(source, entries).source).toBe(source); }); }); + +// A new list is written on the row after the list before it — and when that list shares its row +// with the call's own `});`, the row after it is OUTSIDE the call. +describe('unit · a list that cannot be created inside the call is skipped, never written outside it', () => { + test('a one-line defineApi is left byte-identical and the entry comes back', () => { + const inline = [ + "import { defineApi } from '@ultimat3/action';", + "import * as health from './health';", + '', + 'export const api = defineApi({ actions: [health] });', + 'export type Api = typeof api;', + '', + ].join('\n'); + const entries = apiEntriesFor(['apps/web/app/billing/jobs/purge-drafts-job.ts']); + const { source, skipped } = insertApiEntries(inline, entries); + expect(skipped).toEqual(entries); + expect(source).toBe(inline); + }); +}); diff --git a/packages/cli/src/api-registration.ts b/packages/cli/src/api-registration.ts index 7e5330364..58cc158e0 100644 --- a/packages/cli/src/api-registration.ts +++ b/packages/cli/src/api-registration.ts @@ -108,7 +108,15 @@ export function insertApiEntries( .findLast((found) => found !== undefined); const after = next.indexOf('\n', (earlier ?? actions).close); const line = ` ${entry.key}: [${entry.binding}],`; - next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`; + const candidate = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`; + // Read back, as every other edit here is: when the earlier list shares its row with the + // call's own `});`, "the row after it" is OUTSIDE the call — a property of nothing. + const landed = listsOf(candidate)?.(entry.key); + if (landed?.entries.some((one) => one.text === entry.binding) !== true) { + skipped.push(entry); + continue; + } + next = candidate; } else { const edited = appendToList(next, list.open, [entry.binding]); if (edited === undefined) { diff --git a/packages/cli/src/cmd-generate.ts b/packages/cli/src/cmd-generate.ts index f1f52ed6c..09a3d4c53 100644 --- a/packages/cli/src/cmd-generate.ts +++ b/packages/cli/src/cmd-generate.ts @@ -3,7 +3,6 @@ // primitive arrives with the test that pins its distant invariants (policy, idempotency, budget). import { existsSync } from 'node:fs'; -import { stripComments } from '@ultimat3/core'; import { MANIFEST_FILENAME } from '@ultimat3/manifest'; import { indexBindingFindings } from './api-registration'; import { writeAppArtifacts } from './app-artifacts'; @@ -21,6 +20,7 @@ import type { Generator } from './generate-kinds'; import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds'; import { refusePluralTable } from './generate-plural'; import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow'; +import { declaresOnlyOtherEntities } from './generate-sibling'; import { containedPath, planWrites, writeFiles } from './generate-write'; import { resolveDbModule } from './handle-registration'; import { resolveCatalogModule } from './i18n-audit'; @@ -328,15 +328,14 @@ async function readSliceErrors( } /** - * Whether the slice's `entity.ts` is there and declares a table OTHER than `name`'s. The same - * entity again is the conflict it always was (`--force` regenerates it); a different one is a - * second table for the feature, which `--force` would have written OVER the first. + * Whether the slice's `entity.ts` is there and readably declares only tables OTHER than `name`'s + * (`generate-sibling.ts`). The same entity again — or one this cannot read — is the conflict it + * always was (`--force` regenerates it); a different one is a second table for the feature, which + * `--force` would have written OVER the first. */ async function holdsAnotherEntity(root: string, slice: string, name: string): Promise { const file = containedPath(root, `${slice}/entity.ts`); - if (!existsSync(file)) return false; - const declared = new RegExp(`\\bentity\\(\\s*['"]${names(name).table}['"]`); - return !declared.test(stripComments(await Bun.file(file).text())); + return existsSync(file) && declaresOnlyOtherEntities(await Bun.file(file).text(), name); } /** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */ diff --git a/packages/cli/src/generate-sibling.test.ts b/packages/cli/src/generate-sibling.test.ts new file mode 100644 index 000000000..dd4ffcec6 --- /dev/null +++ b/packages/cli/src/generate-sibling.test.ts @@ -0,0 +1,51 @@ +// When a feature's `entity.ts` makes the next `x g entity --feature` a SIBLING. Only a file that +// readably declares other tables does: the same table spelled any other way — a template literal, +// a constant — read as "another entity", and the rerun wrote a second module for one table +// instead of the conflict it always was. + +import { describe, expect, test } from 'bun:test'; +import { declaresOnlyOtherEntities } from './generate-sibling'; + +const file = (declaration: string): string => + `import { entity } from '@ultimat3/entity';\n\n${declaration}\n`; + +describe('unit · a second entity is a sibling only beside a readably different one', () => { + test('another table, in either quote, makes the next entity a sibling', () => { + for (const quote of ["'", '"', '`']) { + const source = file(`export const blogPost = entity(${quote}blog_posts${quote}, {});`); + expect(declaresOnlyOtherEntities(source, 'blog-attempt')).toBe(true); + } + }); + + test('the same table is the conflict, however it is spelled', () => { + for (const declaration of [ + "export const blogAttempt = entity('blog_attempts', {});", + 'export const blogAttempt = entity(`blog_attempts`, {});', + "export const attempt = entity(\n 'blog_attempts',\n {},\n);", + ]) { + expect(declaresOnlyOtherEntities(file(declaration), 'blog-attempt')).toBe(false); + } + }); + + test('a table this cannot read — a constant, an interpolation — is never taken for another', () => { + for (const declaration of [ + "const TABLE = 'blog_attempts';\nexport const row = entity(TABLE, {});", + // biome-ignore lint/suspicious/noTemplateCurlyInString: the fixture is source text. + "export const row = entity(`blog_${'attempts'}`, {});", + "export const blogPost = entity('blog_posts', {});\nexport const other = entity(NAME, {});", + ]) { + expect(declaresOnlyOtherEntities(file(declaration), 'blog-attempt')).toBe(false); + } + }); + + test('the same BINDING over another table is a conflict too: two modules would export it', () => { + const source = file("export const blogAttempt = entity('attempts_v1', {});"); + expect(declaresOnlyOtherEntities(source, 'blog-attempt')).toBe(false); + }); + + test('a file that declares no entity at all, or names one only in a comment, is no sibling host', () => { + expect(declaresOnlyOtherEntities('export const x = 1;\n', 'blog-attempt')).toBe(false); + const prose = "// entity('blog_posts', {}) is what it'd look like\nexport const x = 1;\n"; + expect(declaresOnlyOtherEntities(prose, 'blog-attempt')).toBe(false); + }); +}); diff --git a/packages/cli/src/generate-sibling.ts b/packages/cli/src/generate-sibling.ts new file mode 100644 index 000000000..a27ea14ba --- /dev/null +++ b/packages/cli/src/generate-sibling.ts @@ -0,0 +1,34 @@ +// Whether a feature's `entity.ts` makes the next `x g entity --feature` a SIBLING +// (`entity-.ts`). Decided on what the file READABLY declares, and conservatively: the +// sibling path skips the conflict on `entity.ts`, so taking the same table for "another one" — +// because it is spelled as a template literal, or held in a constant — wrote a second module for +// one table where the run should have refused. + +import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core'; +import { names } from './templates/naming'; + +/** `entity(` as a call of the imported function — never a member (`schema.entity(`). */ +const ENTITY_CALL = /(? { + const at = call.index + call[0].length; + if (!QUOTES.has(masked[at] ?? '')) return false; + const end = endOfLiteral(source, at); + if (end === at + 1) return false; + const value = source.slice(at + 1, end - 1); + return !value.includes('${') && !value.includes('\\') && value !== table; + }); +} diff --git a/wiki/CLI-Reference.md b/wiki/CLI-Reference.md index 5e9bdbbc7..f5420c7be 100644 --- a/wiki/CLI-Reference.md +++ b/wiki/CLI-Reference.md @@ -79,7 +79,6 @@ x new [--dir path] [--no-example] [--no-git] [--dry-run] [--force] [--jso | `--example` / `--no-example` | boolean | `true` | include the example feature slice (default: on; `--no-example` for an empty `app/`) | | `--git` / `--no-git` | boolean | `true` | `git init`, then one commit named `x new`. On by default because three surfaces assume a repository — `x affected`, `x ci` and `x pr`. A git that is absent or fails never fails `x new`: the tree is the verdict, and `data.git` carries `{ initialized, committed, problem }` | | `--dry-run` | boolean | `false` | print the file list, write nothing | -| `--permission` | string | `:read` | `admin:page` only: the permission the page's own work needs, on top of the frame's `admin:read`. A `:`, or namespaced with more segments (`admin:blog:write`, `As of 27.6.2` — one colon exactly was required, which refused every `admin::` a staff console declares); an empty segment, a space or a quote is `X_CLI_BAD_FLAG` before a file is written — the value reaches the emitted source three times and `Permission` is `${string}:${string}` | | `--force` | boolean | `false` | write into a directory that already exists | **The name must be one `app.config.ts` accepts.** `x new` asks core's own `defineConfig` about the slug before anything is planned (`appNameIssue`, `packages/cli/src/app-name.ts`) — it is written verbatim as `name:`. Refused with `X_CLI_BAD_FLAG` and a corrected name in the `fix:`, the rest of your flags kept: @@ -302,6 +301,7 @@ is a word (`a-z`, `0-9`, `-`, `_`) or `[param]` / `[...rest]`; `..`, `.` and emp |---|---|---|---| | `--feature` | string | derived from the name | feature slice to write into. Not for `resource`: its name IS the slice (`app//`, page at `app//`), and a `--feature` naming anything else is `X_CLI_BAD_FLAG` | | `--surface` | string | `app` | `site` or `app` | +| `--permission` | string | `:read` | `admin:page` only: the permission the page's own work needs, on top of the frame's `admin:read`. A `:`, or namespaced with more segments (`admin:blog:write`, `As of 27.6.2` — one colon exactly was required, which refused every `admin::` a staff console declares); an empty segment, a space or a quote is `X_CLI_BAD_FLAG` before a file is written — the value reaches the emitted source three times and `Permission` is `${string}:${string}` | | `--live` | boolean | `false` | `query` only: make it subscribable. On any other generator it is `X_CLI_BAD_FLAG` naming the `x g query … --live` to run instead | | `--admin` | boolean | `false` | `resource` only: also emit the per-entity admin override **and list it** under `resources:` in `apps/admin/app/admin/admin.ts`. The screen itself needs no flag — an entity in the handle is an admin screen | | `--locales` | string | every locale with a catalog in `packages/i18n/catalogs/`, else `en` | comma-separated locales; lands each generator's catalog entry in every one. **Never starts a catalog** in an app that has any: a non-default locale with no `packages/i18n/catalogs/.json` is `X_CLI_BAD_FLAG`, `fix: x i18n add `, before a file is planned — a catalog a generator started would hold only its own keys. A non-default locale's new keys land marked `⟦…⟧`, as `x i18n add` seeds them, so `x i18n check` reports them until translated. In an app with no catalogs yet, a first `--locales en,es` writes `en` bare and `es` marked. `As of 2026-10-06` (`localiseCatalogs`, `packages/cli/src/generate-catalog-locales.ts`) | diff --git a/wiki/Entities-And-Migrations.md b/wiki/Entities-And-Migrations.md index 51ce894a3..612cee7bd 100644 --- a/wiki/Entities-And-Migrations.md +++ b/wiki/Entities-And-Migrations.md @@ -233,11 +233,11 @@ actor in another org reads nothing with no org named in any call. Row equality i `expect(row).toEqualRow(other)`: a sealed column is a non-enumerable property, which `toEqual` skips. After `x g entity `: `bunx x db gen "create
"`, then `bunx x db migrate`. The generator - -A feature with more than one table: `x g entity --feature ` writes `entity-.ts` + `repo-.ts` (and a test beside each) next to the first entity's `entity.ts`, and registers it on the handle the same way (`As of 27.6.2`). One file per table; `x db gen` picks every one up from the registry. prints both, with `bun install` first when it added a workspace dependency ([CLI reference](CLI-Reference)). +A feature with more than one table: `x g entity --feature ` writes `entity-.ts` + `repo-.ts` (and a test beside each) next to the first entity's `entity.ts`, and registers it on the handle the same way (`As of 27.6.2`). One file per table; `x db gen` picks every one up from the registry. + ## Point lookups batch themselves `findById` called several times in one microtask of one request is **one** statement, `As of 2026-08`: