diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbca4ee9fbc0..951003ceef7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,10 +53,13 @@ them into each workspace package. On supported macOS volumes, this also lets pnp reuse whole-package APFS clones instead of importing every file separately. Installation also prepares Kysely declarations from the SQL schemas in `src/state`. -The generated files live in ignored `.artifacts/kysely/`; build, typecheck, and -test entrypoints refresh them automatically, including after an install with +The generated files live in ignored `.artifacts/kysely/`; build, typecheck, +type-aware lint, and test entrypoints refresh them automatically, including after an install with `--ignore-scripts`. Unchanged inputs reuse the declarations without rewriting them. `pnpm db:kysely:check` checks schema projection and generator contracts. +Sparse core, script, and root-test lint prepare only the available schemas and +retire declarations for omitted schemas. Normal source generation, verification, +and extension-boundary builds still require complete schemas. Give each source checkout its own physical dependency installation. Tooling does not automatically link a missing `node_modules` to another checkout. Existing diff --git a/scripts/generate-kysely-types.mts b/scripts/generate-kysely-types.mts index f43335b180e4..08060fa786ea 100644 --- a/scripts/generate-kysely-types.mts +++ b/scripts/generate-kysely-types.mts @@ -112,24 +112,42 @@ async function generateKyselyTypes(schemaSource: string): Promise { } } -export async function ensureKyselyTypes(cwd = REPO_ROOT, verify = false): Promise { - // Packaged installs and dependency-only Docker stages have no source schemas. - if ( - !verify && - !SCHEMAS.some((name) => fs.existsSync(path.join(cwd, "src/state", `${name}-schema.sql`))) - ) { - return; - } +export async function ensureKyselyTypes( + cwd = REPO_ROOT, + verify = false, + options: { allowPartialCheckout?: boolean } = {}, +): Promise { const outputDir = path.join(cwd, ".artifacts/kysely"); const stampFile = path.join(outputDir, "inputs.sha256"); - const schemas = SCHEMAS.map((name) => ({ - source: fs.readFileSync(path.join(cwd, "src/state", `${name}-schema.sql`), "utf8"), + const inputs = SCHEMAS.map((name) => ({ + name, + input: path.join(cwd, "src/state", `${name}-schema.sql`), output: path.join(outputDir, `${name}-db.generated.ts`), })); + const available = inputs.filter((schema) => fs.existsSync(schema.input)); + const partial = !verify && options.allowPartialCheckout === true; + if (partial) { + // Sparse lint must not consume a previous projection whose source is now omitted. + for (const schema of inputs.filter((input) => !available.includes(input))) { + fs.rmSync(schema.output, { force: true }); + } + } + // Packaged installs and dependency-only Docker stages have no source schemas. + if (!verify && available.length === 0) { + if (partial) { + fs.rmSync(stampFile, { force: true }); + } + return; + } + const schemas = (partial ? available : inputs).map((schema) => ({ + name: schema.name, + source: fs.readFileSync(schema.input, "utf8"), + output: schema.output, + })); const fingerprint = () => { const hash = createHash("sha256").update(fs.readFileSync(fileURLToPath(import.meta.url))); for (const schema of schemas) { - hash.update(schema.source).update("\0"); + hash.update(schema.name).update("\0").update(schema.source).update("\0"); hash.update(fs.existsSync(schema.output) ? fs.readFileSync(schema.output) : "missing"); } return hash.digest("hex"); diff --git a/scripts/run-oxlint-shards.mts b/scripts/run-oxlint-shards.mts index 462d425fadaa..fbbfc9941fd6 100644 --- a/scripts/run-oxlint-shards.mts +++ b/scripts/run-oxlint-shards.mts @@ -401,7 +401,7 @@ export async function main( shouldPrepareOxlintArtifacts([...shard.args, ...shardArgs.oxlintArgs]), ) ) { - await ensureKyselyTypes(process.cwd()); + await ensureKyselyTypes(process.cwd(), false, { allowPartialCheckout: true }); } if (needsArtifacts) { const code = await runManagedCommand({ diff --git a/scripts/run-oxlint.mts b/scripts/run-oxlint.mts index a70ef9ce996c..64b95f90c3fa 100644 --- a/scripts/run-oxlint.mts +++ b/scripts/run-oxlint.mts @@ -669,7 +669,7 @@ export async function runOxlint( const run = async (ownedDirectory?: string) => { if (shouldPrepareOxlintArtifacts(argv) && env.OPENCLAW_OXLINT_SKIP_PREPARE !== "1") { // Source-backed core lint skips plugin declarations, not generated schema types. - await ensureKyselyTypes(process.cwd()); + await ensureKyselyTypes(process.cwd(), false, { allowPartialCheckout: true }); } if (needsArtifactPreparation) { // Declaration compilation owns its Go policy; lint limits belong to the oxlint child. diff --git a/test/scripts/generate-kysely-types.test.ts b/test/scripts/generate-kysely-types.test.ts index e37e19e75a02..7e0796d140e2 100644 --- a/test/scripts/generate-kysely-types.test.ts +++ b/test/scripts/generate-kysely-types.test.ts @@ -104,6 +104,30 @@ describe("Kysely declarations", () => { expect(missing.stderr).toContain("direct-run.mjs"); }); + it("retires omitted sparse projections and still requires complete generation", async () => { + const { root, schemas, output } = createSchemaFixture(); + const agentOutput = path.join(root, ".artifacts/kysely/openclaw-agent-db.generated.ts"); + await ensureKyselyTypes(root); + fs.unlinkSync(schemas[1]!); + await ensureKyselyTypes(root, false, { allowPartialCheckout: true }); + expect(fs.readFileSync(output, "utf8")).toContain("export interface Records"); + expect(fs.existsSync(agentOutput)).toBe(false); + await expect(ensureKyselyTypes(root)).rejects.toThrow("openclaw-agent-schema.sql"); + await expect(ensureKyselyTypes(root, true, { allowPartialCheckout: true })).rejects.toThrow( + "openclaw-agent-schema.sql", + ); + fs.writeFileSync(schemas[1]!, `${schema}\nALTER TABLE records ADD COLUMN restored TEXT;`); + await ensureKyselyTypes(root); + expect(fs.readFileSync(agentOutput, "utf8")).toContain(" restored: string | null;"); + for (const source of schemas) { + fs.unlinkSync(source); + } + await ensureKyselyTypes(root, false, { allowPartialCheckout: true }); + expect(fs.existsSync(output)).toBe(false); + expect(fs.existsSync(agentOutput)).toBe(false); + expect(fs.existsSync(path.join(root, ".artifacts/kysely/inputs.sha256"))).toBe(false); + }); + it("skips source-less installs but rejects an incomplete schema checkout", async () => { const root = tempDirs.make("kysely-source-less-"); fs.mkdirSync(path.join(root, "src/state"), { recursive: true }); diff --git a/test/scripts/run-oxlint-kysely.test.ts b/test/scripts/run-oxlint-kysely.test.ts index 0f06754554f0..c50e795ce813 100644 --- a/test/scripts/run-oxlint-kysely.test.ts +++ b/test/scripts/run-oxlint-kysely.test.ts @@ -99,18 +99,29 @@ const striped = [ describe("typed lint Kysely prerequisites", () => { it.each([ - { name: "direct", args: direct }, - { name: "striped", args: striped }, - ])("prepares cold declarations before $name core lint without plugin artifacts", ({ args }) => { - const fixture = createLintFixture(); - expect(fs.existsSync(fixture.output)).toBe(false); - const result = fixture.run(args); - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(fs.readFileSync(fixture.output, "utf8")).toContain("title: string;"); - expect(fs.existsSync(path.join(fixture.root, ".artifacts/extension-package-boundary"))).toBe( - false, - ); - }); + { name: "direct", args: direct, sparse: false }, + { name: "striped", args: striped, sparse: false }, + { name: "sparse direct", args: direct, sparse: true }, + { name: "sparse striped", args: striped, sparse: true }, + ])( + "prepares cold declarations before $name core lint without plugin artifacts", + ({ args, sparse }) => { + const fixture = createLintFixture(); + const agentProjection = ".artifacts/kysely/openclaw-agent-db.generated.ts"; + if (sparse) { + fs.unlinkSync(path.join(fixture.root, "src/state/openclaw-agent-schema.sql")); + fixture.write(agentProjection, "export interface Stale {}\n"); + } + expect(fs.existsSync(fixture.output)).toBe(false); + const result = fixture.run(args); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(fs.readFileSync(fixture.output, "utf8")).toContain("title: string;"); + expect(fs.existsSync(path.join(fixture.root, agentProjection))).toBe(!sparse); + expect(fs.existsSync(path.join(fixture.root, ".artifacts/extension-package-boundary"))).toBe( + false, + ); + }, + ); it.each([ { name: "direct", args: direct },