mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
60
scripts/acquire-public-benchmark-fixtures.mjs
Normal file
60
scripts/acquire-public-benchmark-fixtures.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const fixturesRoot = path.join(repoRoot, 'packages', 'context', 'test', 'fixtures', 'relationship-benchmarks');
|
||||
const manifestPath = path.join(scriptDir, 'public-benchmark-manifest.json');
|
||||
|
||||
export async function acquirePublicBenchmarkFixtures(options = {}) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const writeFile = options.writeFile ?? writeFileSync;
|
||||
const readFile = options.readFile ?? readFileSync;
|
||||
const fileExists = options.fileExists ?? existsSync;
|
||||
const ensureDir = options.ensureDir ?? ((dir) => mkdirSync(dir, { recursive: true }));
|
||||
const manifestPathOverride = options.manifestPath ?? manifestPath;
|
||||
const fixturesRootOverride = options.fixturesRoot ?? fixturesRoot;
|
||||
const log = options.log ?? console.log;
|
||||
|
||||
const manifest = JSON.parse(readFile(manifestPathOverride, 'utf8'));
|
||||
const results = [];
|
||||
for (const fixture of manifest.fixtures) {
|
||||
const fixtureDir = path.join(fixturesRootOverride, fixture.id);
|
||||
const dest = path.join(fixtureDir, 'data.sqlite');
|
||||
ensureDir(fixtureDir);
|
||||
if (fileExists(dest)) {
|
||||
const existingHash = createHash('sha256').update(readFile(dest)).digest('hex');
|
||||
if (fixture.sha256 && existingHash === fixture.sha256) {
|
||||
log(`[skip] ${fixture.id}: hash matches`);
|
||||
results.push({ id: fixture.id, action: 'skip', sha256: existingHash });
|
||||
continue;
|
||||
}
|
||||
log(`[refresh] ${fixture.id}: hash mismatch (${existingHash}), re-downloading from ${fixture.url}`);
|
||||
} else {
|
||||
log(`[download] ${fixture.id} from ${fixture.url}`);
|
||||
}
|
||||
const res = await fetchImpl(fixture.url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download ${fixture.id} from ${fixture.url}: HTTP ${res.status}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const hash = createHash('sha256').update(buf).digest('hex');
|
||||
if (fixture.sha256 && hash !== fixture.sha256) {
|
||||
throw new Error(`Hash mismatch for ${fixture.id}: expected ${fixture.sha256}, got ${hash}`);
|
||||
}
|
||||
writeFile(dest, buf);
|
||||
log(`[done] ${fixture.id}: sha256=${hash} bytes=${buf.length}`);
|
||||
results.push({ id: fixture.id, action: 'downloaded', sha256: hash, bytes: buf.length });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
acquirePublicBenchmarkFixtures().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
168
scripts/acquire-public-benchmark-fixtures.test.mjs
Normal file
168
scripts/acquire-public-benchmark-fixtures.test.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import { acquirePublicBenchmarkFixtures } from './acquire-public-benchmark-fixtures.mjs';
|
||||
|
||||
function tempRoot() {
|
||||
return mkdtempSync(path.join(tmpdir(), 'klo-acquire-'));
|
||||
}
|
||||
|
||||
function writeManifest(dir, fixtures) {
|
||||
const p = path.join(dir, 'manifest.json');
|
||||
writeFileSync(p, JSON.stringify({ fixtures }), 'utf8');
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('acquirePublicBenchmarkFixtures', () => {
|
||||
it('downloads, hashes, and writes data.sqlite for each manifest entry', async () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
const fixturesRoot = path.join(root, 'fixtures');
|
||||
const manifestPath = writeManifest(root, [
|
||||
{ id: 'foo_fixture', url: 'https://example.invalid/foo', sha256: '' },
|
||||
]);
|
||||
const calls = [];
|
||||
const result = await acquirePublicBenchmarkFixtures({
|
||||
manifestPath,
|
||||
fixturesRoot,
|
||||
fetch: async (url) => {
|
||||
calls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async arrayBuffer() {
|
||||
return Buffer.from('hello-sqlite');
|
||||
},
|
||||
};
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0], 'https://example.invalid/foo');
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].action, 'downloaded');
|
||||
const dest = path.join(fixturesRoot, 'foo_fixture', 'data.sqlite');
|
||||
assert.ok(existsSync(dest));
|
||||
assert.equal(readFileSync(dest, 'utf8'), 'hello-sqlite');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips when existing file matches the manifest sha256', async () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
const fixturesRoot = path.join(root, 'fixtures');
|
||||
const fixtureDir = path.join(fixturesRoot, 'foo_fixture');
|
||||
const dest = path.join(fixtureDir, 'data.sqlite');
|
||||
const { mkdirSync } = await import('node:fs');
|
||||
mkdirSync(fixtureDir, { recursive: true });
|
||||
writeFileSync(dest, Buffer.from('hello-sqlite'));
|
||||
const expectedHash = '52a3e2d435cdf97a44eca3dd4882d008b9ef73b63bc75476d320fdd665c812c0'; // pragma: allowlist secret
|
||||
const manifestPath = writeManifest(root, [
|
||||
{ id: 'foo_fixture', url: 'https://example.invalid/foo', sha256: expectedHash },
|
||||
]);
|
||||
let fetchCalls = 0;
|
||||
const result = await acquirePublicBenchmarkFixtures({
|
||||
manifestPath,
|
||||
fixturesRoot,
|
||||
fetch: async () => {
|
||||
fetchCalls += 1;
|
||||
throw new Error('should not fetch');
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
assert.equal(result[0].action, 'skip');
|
||||
assert.equal(fetchCalls, 0);
|
||||
assert.equal(readFileSync(dest, 'utf8'), 'hello-sqlite');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the downloaded payload sha256 does not match the manifest', async () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
const fixturesRoot = path.join(root, 'fixtures');
|
||||
const manifestPath = writeManifest(root, [
|
||||
{
|
||||
id: 'foo_fixture',
|
||||
url: 'https://example.invalid/foo',
|
||||
sha256: '0000000000000000000000000000000000000000000000000000000000000000',
|
||||
},
|
||||
]);
|
||||
await assert.rejects(
|
||||
acquirePublicBenchmarkFixtures({
|
||||
manifestPath,
|
||||
fixturesRoot,
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
async arrayBuffer() {
|
||||
return Buffer.from('different-payload');
|
||||
},
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/Hash mismatch/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces non-OK HTTP statuses with the fixture id', async () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
const fixturesRoot = path.join(root, 'fixtures');
|
||||
const manifestPath = writeManifest(root, [
|
||||
{ id: 'foo_fixture', url: 'https://example.invalid/foo', sha256: '' },
|
||||
]);
|
||||
await assert.rejects(
|
||||
acquirePublicBenchmarkFixtures({
|
||||
manifestPath,
|
||||
fixturesRoot,
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
async arrayBuffer() {
|
||||
return Buffer.alloc(0);
|
||||
},
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/foo_fixture .* HTTP 404/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('pins every checked-in public benchmark fixture download in the manifest', () => {
|
||||
const manifestPath = new URL('./public-benchmark-manifest.json', import.meta.url);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
const fixtureIds = manifest.fixtures.map((fixture) => fixture.id).sort();
|
||||
|
||||
assert.deepEqual(fixtureIds, [
|
||||
'adventureworkslt_with_declared_metadata',
|
||||
'chinook_with_declared_metadata',
|
||||
'northwind_with_declared_metadata',
|
||||
'sakila_with_declared_metadata',
|
||||
]);
|
||||
|
||||
const adventureWorks = manifest.fixtures.find(
|
||||
(fixture) => fixture.id === 'adventureworkslt_with_declared_metadata',
|
||||
);
|
||||
assert.ok(adventureWorks);
|
||||
assert.equal(adventureWorks.displayName, 'AdventureWorksLT (SQLite, declared metadata)');
|
||||
assert.equal(
|
||||
adventureWorks.url,
|
||||
'https://github.com/nuitsjp/AdventureWorks-for-SQLite/releases/download/Release-1_0_0/AdventureWorksLT.db',
|
||||
);
|
||||
assert.equal(adventureWorks.sha256, 'f1a87a31f4efb5654f57a3b1ca47fac338972ceb7553673d66ea0bd9d55a7008'); // pragma: allowlist secret
|
||||
assert.equal(adventureWorks.license, 'MIT');
|
||||
assert.equal(adventureWorks.source, 'https://github.com/nuitsjp/AdventureWorks-for-SQLite');
|
||||
});
|
||||
});
|
||||
13
scripts/adventureworks-oltp-source.json
Normal file
13
scripts/adventureworks-oltp-source.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"id": "adventureworks_oltp_with_declared_metadata",
|
||||
"displayName": "AdventureWorks OLTP (SQL Server 2022, declared metadata)",
|
||||
"installScriptUrl": "https://github.com/microsoft/sql-server-samples/releases/download/adventureworks/AdventureWorks-oltp-install-script.zip",
|
||||
"installScriptSha256": "58962e94ea386ef7cd3d8a08211bfd42a79d9b81bdd68fd4b6b0051de6c5bd42", "_allowlist": "// pragma: allowlist secret",
|
||||
"source": "https://github.com/microsoft/sql-server-samples/tree/master/samples/databases/adventure-works",
|
||||
"license": "MIT",
|
||||
"expectedTables": 71,
|
||||
"expectedPrimaryKeys": 71,
|
||||
"expectedForeignKeys": 90,
|
||||
"expectedCsvFiles": 69,
|
||||
"notes": "Full OLTP AdventureWorks corpus. Do not replace with AdventureWorksLT; the LT SQLite source is already covered by adventureworkslt_with_declared_metadata."
|
||||
}
|
||||
25
scripts/adventureworks-oltp-source.test.mjs
Normal file
25
scripts/adventureworks-oltp-source.test.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
describe('AdventureWorks OLTP benchmark source metadata', () => {
|
||||
it('pins the full OLTP source instead of the lightweight LT source', () => {
|
||||
const source = JSON.parse(readFileSync(new URL('./adventureworks-oltp-source.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.equal(source.id, 'adventureworks_oltp_with_declared_metadata');
|
||||
assert.equal(source.displayName, 'AdventureWorks OLTP (SQL Server 2022, declared metadata)');
|
||||
assert.equal(
|
||||
source.installScriptUrl,
|
||||
'https://github.com/microsoft/sql-server-samples/releases/download/adventureworks/AdventureWorks-oltp-install-script.zip',
|
||||
);
|
||||
assert.equal(source.installScriptSha256, '58962e94ea386ef7cd3d8a08211bfd42a79d9b81bdd68fd4b6b0051de6c5bd42'); // pragma: allowlist secret
|
||||
assert.equal(source.license, 'MIT');
|
||||
assert.equal(source.source, 'https://github.com/microsoft/sql-server-samples/tree/master/samples/databases/adventure-works');
|
||||
assert.equal(source.expectedTables, 71);
|
||||
assert.equal(source.expectedPrimaryKeys, 71);
|
||||
assert.equal(source.expectedForeignKeys, 90);
|
||||
assert.equal(source.expectedCsvFiles, 69);
|
||||
assert.match(source.notes, /full OLTP/i);
|
||||
assert.doesNotMatch(JSON.stringify(source), /AdventureWorksLT\.db|Release-1_0_0|nuitsjp/);
|
||||
});
|
||||
});
|
||||
66
scripts/anti-fixture-conditional.test.mjs
Normal file
66
scripts/anti-fixture-conditional.test.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const KLO_ROOT = new URL('../', import.meta.url);
|
||||
|
||||
const RELATIONSHIP_RUNTIME_SOURCES = Object.freeze([
|
||||
'packages/context/src/scan/relationship-benchmarks.ts',
|
||||
'packages/context/src/scan/relationship-budget.ts',
|
||||
'packages/context/src/scan/relationship-candidates.ts',
|
||||
'packages/context/src/scan/relationship-composite-candidates.ts',
|
||||
'packages/context/src/scan/relationship-graph-resolver.ts',
|
||||
'packages/context/src/scan/relationship-locality.ts',
|
||||
'packages/context/src/scan/relationship-name-similarity.ts',
|
||||
'packages/context/src/scan/relationship-discovery.ts',
|
||||
'packages/context/src/scan/relationship-profiling.ts',
|
||||
'packages/context/src/scan/relationship-scoring.ts',
|
||||
'packages/context/src/scan/relationship-validation.ts',
|
||||
]);
|
||||
|
||||
async function checkedInFixtureIds() {
|
||||
const fixtureRoot = new URL('packages/context/test/fixtures/relationship-benchmarks/', KLO_ROOT);
|
||||
const entries = await readdir(fixtureRoot, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function readRuntimeSources() {
|
||||
return Promise.all(
|
||||
RELATIONSHIP_RUNTIME_SOURCES.map(async (relativePath) => ({
|
||||
relativePath,
|
||||
source: await readFile(new URL(relativePath, KLO_ROOT), 'utf8'),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
describe('relationship evidence-fusion source guardrails', () => {
|
||||
it('keeps runtime relationship modules free of fixture-id conditionals', async () => {
|
||||
const fixtureIds = await checkedInFixtureIds();
|
||||
const sources = await readRuntimeSources();
|
||||
const hits = [];
|
||||
|
||||
for (const { relativePath, source } of sources) {
|
||||
for (const fixtureId of fixtureIds) {
|
||||
if (source.includes(fixtureId)) {
|
||||
hits.push(`${relativePath}: ${fixtureId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
|
||||
it('keeps runtime relationship modules free of length-threshold drop-all cliffs', async () => {
|
||||
const sources = await readRuntimeSources();
|
||||
const dropAllPattern = /if\s*\([^)]*\.length\s*>\s*\d+[^)]*\)\s*(?:\{\s*)?return\s*\[\];/gs;
|
||||
const hits = sources.flatMap(({ relativePath, source }) => {
|
||||
const matches = Array.from(source.matchAll(dropAllPattern));
|
||||
return matches.map((match) => `${relativePath}: ${match[0].replace(/\s+/g, ' ').trim()}`);
|
||||
});
|
||||
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
});
|
||||
260
scripts/build-adventureworks-oltp-fixture.mjs
Normal file
260
scripts/build-adventureworks-oltp-fixture.mjs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env node
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expectedLinksFromSnapshot, normalizeSqliteType } from './build-benchmark-snapshot.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const require = createRequire(new URL('../packages/context/package.json', import.meta.url));
|
||||
const Database = require('better-sqlite3');
|
||||
const { stringify: yamlStringify } = require('yaml');
|
||||
|
||||
const fixtureId = 'adventureworks_oltp_with_declared_metadata';
|
||||
const defaultFixtureDir = path.join(
|
||||
repoRoot,
|
||||
'packages',
|
||||
'context',
|
||||
'test',
|
||||
'fixtures',
|
||||
'relationship-benchmarks',
|
||||
fixtureId,
|
||||
);
|
||||
|
||||
function quoteSqliteIdentifier(value) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function quoteSqlServerIdentifier(value) {
|
||||
return `[${String(value).replaceAll(']', ']]')}]`;
|
||||
}
|
||||
|
||||
function flattenTableName(table) {
|
||||
return `${table.db}.${table.name}`;
|
||||
}
|
||||
|
||||
function sqliteDimensionType(nativeType, columnName) {
|
||||
const type = normalizeSqliteType(nativeType);
|
||||
const name = columnName.toLowerCase();
|
||||
if (/date|time/.test(name) || /date|time/.test(String(nativeType).toLowerCase())) {
|
||||
return 'time';
|
||||
}
|
||||
if (type === 'integer' || type === 'real') {
|
||||
return 'number';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function sqliteValue(value) {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function snapshotForSqliteBenchmark(sqlServerSnapshot) {
|
||||
const tableNameByOriginal = new Map(
|
||||
sqlServerSnapshot.tables
|
||||
.filter((table) => table.kind === 'table')
|
||||
.map((table) => [`${table.db}.${table.name}`, flattenTableName(table)]),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionId: fixtureId,
|
||||
driver: 'sqlite',
|
||||
extractedAt: sqlServerSnapshot.extractedAt,
|
||||
scope: { catalogs: ['main'], schemas: ['main'] },
|
||||
metadata: {
|
||||
...sqlServerSnapshot.metadata,
|
||||
source_driver: 'sqlserver',
|
||||
source_connection_id: sqlServerSnapshot.connectionId,
|
||||
source_database: sqlServerSnapshot.metadata?.database ?? null,
|
||||
},
|
||||
tables: sqlServerSnapshot.tables
|
||||
.filter((table) => table.kind === 'table')
|
||||
.map((table) => ({
|
||||
catalog: null,
|
||||
db: 'main',
|
||||
name: flattenTableName(table),
|
||||
kind: 'table',
|
||||
comment: table.comment ?? null,
|
||||
estimatedRows: table.estimatedRows ?? 0,
|
||||
columns: table.columns.map((column) => ({
|
||||
name: column.name,
|
||||
nativeType: column.nativeType,
|
||||
normalizedType: normalizeSqliteType(column.nativeType),
|
||||
dimensionType: sqliteDimensionType(column.nativeType, column.name),
|
||||
nullable: column.nullable,
|
||||
primaryKey: column.primaryKey,
|
||||
comment: column.comment ?? null,
|
||||
})),
|
||||
foreignKeys: (table.foreignKeys ?? []).flatMap((fk) => {
|
||||
const originalTarget = `${fk.toDb}.${fk.toTable}`;
|
||||
const targetName = tableNameByOriginal.get(originalTarget);
|
||||
if (!targetName) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
fromColumn: fk.fromColumn,
|
||||
toCatalog: null,
|
||||
toDb: 'main',
|
||||
toTable: targetName,
|
||||
toColumn: fk.toColumn,
|
||||
constraintName: fk.constraintName,
|
||||
},
|
||||
];
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeAdventureWorksFixtureConfig(fixtureDir) {
|
||||
const fixture = {
|
||||
id: fixtureId,
|
||||
name: 'AdventureWorks OLTP (SQL Server 2022, declared metadata)',
|
||||
tier: 'row_bearing',
|
||||
thresholdEligible: true,
|
||||
defaultModes: [
|
||||
'metadata_present',
|
||||
'declared_pks_and_declared_fks_removed',
|
||||
'declared_pks_removed',
|
||||
'declared_fks_removed',
|
||||
'profiling_disabled',
|
||||
'validation_disabled',
|
||||
'llm_disabled',
|
||||
'embeddings_disabled',
|
||||
],
|
||||
};
|
||||
writeFileSync(path.join(fixtureDir, 'fixture.yaml'), yamlStringify(fixture), 'utf8');
|
||||
}
|
||||
|
||||
export function writeAdventureWorksSnapshotAndLabels(fixtureDir, sqliteSnapshot) {
|
||||
writeFileSync(path.join(fixtureDir, 'snapshot.json'), `${JSON.stringify(sqliteSnapshot, null, 2)}\n`, 'utf8');
|
||||
writeFileSync(path.join(fixtureDir, 'expected-links.yaml'), yamlStringify(expectedLinksFromSnapshot(sqliteSnapshot)), 'utf8');
|
||||
}
|
||||
|
||||
export async function copySqlServerRowsToSqlite(input) {
|
||||
const { connector, sourceSnapshot, sqliteSnapshot, fixtureDir } = input;
|
||||
const sqlitePath = path.join(fixtureDir, 'data.sqlite');
|
||||
rmSync(sqlitePath, { force: true });
|
||||
const db = new Database(sqlitePath);
|
||||
try {
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec('BEGIN');
|
||||
for (const sourceTable of sourceSnapshot.tables.filter((table) => table.kind === 'table')) {
|
||||
const sqliteTable = sqliteSnapshot.tables.find((table) => table.name === flattenTableName(sourceTable));
|
||||
if (!sqliteTable) {
|
||||
continue;
|
||||
}
|
||||
const columns = sqliteTable.columns;
|
||||
const createColumns = columns
|
||||
.map((column) => `${quoteSqliteIdentifier(column.name)} ${normalizeSqliteType(column.nativeType).toUpperCase()}`)
|
||||
.join(', ');
|
||||
db.exec(`CREATE TABLE ${quoteSqliteIdentifier(sqliteTable.name)} (${createColumns})`);
|
||||
|
||||
const selectSql = `SELECT * FROM ${quoteSqlServerIdentifier(sourceTable.db)}.${quoteSqlServerIdentifier(sourceTable.name)}`;
|
||||
const result = await connector.executeReadOnly(
|
||||
{
|
||||
connectionId: sourceSnapshot.connectionId,
|
||||
sql: selectSql,
|
||||
maxRows: Math.max(sourceTable.estimatedRows ?? 0, 1000000),
|
||||
},
|
||||
{ runId: `adventureworks-oltp-copy:${sqliteTable.name}` },
|
||||
);
|
||||
const bindSlots = columns.map(() => '?').join(', ');
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO ${quoteSqliteIdentifier(sqliteTable.name)} (${columns
|
||||
.map((column) => quoteSqliteIdentifier(column.name))
|
||||
.join(', ')}) VALUES (${bindSlots})`,
|
||||
);
|
||||
for (const row of result.rows) {
|
||||
insert.run(row.map(sqliteValue));
|
||||
}
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAdventureWorksOltpFixture(input) {
|
||||
const fixtureDir = input.fixtureDir ?? defaultFixtureDir;
|
||||
mkdirSync(fixtureDir, { recursive: true });
|
||||
|
||||
const sourceSnapshot = await input.connector.introspect(
|
||||
{ connectionId: input.connectionId, driver: 'sqlserver' },
|
||||
{ runId: 'adventureworks-oltp-fixture:introspect' },
|
||||
);
|
||||
const sqliteSnapshot = snapshotForSqliteBenchmark(sourceSnapshot);
|
||||
|
||||
writeAdventureWorksFixtureConfig(fixtureDir);
|
||||
writeAdventureWorksSnapshotAndLabels(fixtureDir, sqliteSnapshot);
|
||||
await copySqlServerRowsToSqlite({ connector: input.connector, sourceSnapshot, sqliteSnapshot, fixtureDir });
|
||||
|
||||
return {
|
||||
fixtureDir,
|
||||
tableCount: sqliteSnapshot.tables.length,
|
||||
expected: expectedLinksFromSnapshot(sqliteSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = process.env.KLO_ADVENTUREWORKS_SQLSERVER_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'Set KLO_ADVENTUREWORKS_SQLSERVER_URL to a read-only SQL Server URL for a full AdventureWorks OLTP database before running this script.',
|
||||
);
|
||||
}
|
||||
|
||||
const source = JSON.parse(readFileSync(path.join(scriptDir, 'adventureworks-oltp-source.json'), 'utf8'));
|
||||
const { KloSqlServerScanConnector } = await import('../packages/connector-sqlserver/dist/index.js');
|
||||
const connector = new KloSqlServerScanConnector({
|
||||
connectionId: fixtureId,
|
||||
connection: {
|
||||
driver: 'sqlserver',
|
||||
url,
|
||||
schemas: ['dbo', 'HumanResources', 'Person', 'Production', 'Purchasing', 'Sales'],
|
||||
readonly: true,
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
now: () => new Date('2026-05-07T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await buildAdventureWorksOltpFixture({ connector, connectionId: fixtureId });
|
||||
if (result.tableCount !== source.expectedTables) {
|
||||
throw new Error(`Expected ${source.expectedTables} tables, generated ${result.tableCount}`);
|
||||
}
|
||||
if (result.expected.expectedPks.length !== source.expectedPrimaryKeys) {
|
||||
throw new Error(`Expected ${source.expectedPrimaryKeys} PK entries, generated ${result.expected.expectedPks.length}`);
|
||||
}
|
||||
if (result.expected.expectedLinks.length !== source.expectedForeignKeys) {
|
||||
throw new Error(`Expected ${source.expectedForeignKeys} FK links, generated ${result.expected.expectedLinks.length}`);
|
||||
}
|
||||
console.log(
|
||||
`[built] ${fixtureId}: ${result.tableCount} tables, ${result.expected.expectedPks.length} PKs, ${result.expected.expectedLinks.length} FKs`,
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
267
scripts/build-benchmark-snapshot.mjs
Normal file
267
scripts/build-benchmark-snapshot.mjs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(new URL('../packages/context/package.json', import.meta.url));
|
||||
const Database = require('better-sqlite3');
|
||||
const { stringify: yamlStringify } = require('yaml');
|
||||
|
||||
const TIME_PATTERNS = /(_at$|_date$|^date_|_time$|^timestamp_)/i;
|
||||
const TIME_TYPES = /(date|time|timestamp)/i;
|
||||
|
||||
function quoteIdentifier(value) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function normalizeSqliteType(rawType) {
|
||||
const t = (rawType || '').toLowerCase().trim();
|
||||
if (!t) {
|
||||
return 'text';
|
||||
}
|
||||
if (/int/.test(t)) {
|
||||
return 'integer';
|
||||
}
|
||||
if (/char|text|clob/.test(t)) {
|
||||
return 'text';
|
||||
}
|
||||
if (/real|float|double|numeric|decimal/.test(t)) {
|
||||
return 'real';
|
||||
}
|
||||
if (/blob/.test(t)) {
|
||||
return 'blob';
|
||||
}
|
||||
if (/bool/.test(t)) {
|
||||
return 'integer';
|
||||
}
|
||||
if (/date|time/.test(t)) {
|
||||
return 'text';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export function dimensionTypeFor(rawType, columnName) {
|
||||
const t = (rawType || '').toLowerCase();
|
||||
const n = (columnName || '').toLowerCase();
|
||||
if (TIME_PATTERNS.test(n) || TIME_TYPES.test(t)) {
|
||||
return 'time';
|
||||
}
|
||||
if (/bool/.test(t)) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (/int|real|float|double|numeric|decimal/.test(t)) {
|
||||
return 'number';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function tableNames(db) {
|
||||
return db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
}
|
||||
|
||||
function columnsFor(db, table) {
|
||||
return db
|
||||
.prepare(`PRAGMA table_info(${quoteIdentifier(table)})`)
|
||||
.all()
|
||||
.map((c) => ({
|
||||
cid: c.cid,
|
||||
name: c.name,
|
||||
nativeType: c.type ?? '',
|
||||
nullable: !c.notnull,
|
||||
primaryKey: c.pk > 0,
|
||||
pkOrdinal: c.pk > 0 ? c.pk : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function rawForeignKeys(db, table) {
|
||||
return db.prepare(`PRAGMA foreign_key_list(${quoteIdentifier(table)})`).all();
|
||||
}
|
||||
|
||||
function rowCount(db, table) {
|
||||
const row = db.prepare(`SELECT COUNT(*) AS c FROM ${quoteIdentifier(table)}`).get();
|
||||
return Number(row?.c ?? 0);
|
||||
}
|
||||
|
||||
function groupedForeignKeys(rawFks, table) {
|
||||
const byId = new Map();
|
||||
for (const row of rawFks) {
|
||||
const list = byId.get(row.id) ?? [];
|
||||
list.push(row);
|
||||
byId.set(row.id, list);
|
||||
}
|
||||
const out = [];
|
||||
for (const rows of byId.values()) {
|
||||
rows.sort((a, b) => a.seq - b.seq);
|
||||
out.push({
|
||||
from: rows.map((r) => r.from),
|
||||
toTable: rows[0].table,
|
||||
to: rows.map((r) => r.to),
|
||||
constraintName: `${table}_${rows.map((r) => r.from).join('_')}_fkey`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function groupedSnapshotForeignKeys(table) {
|
||||
const byKey = new Map();
|
||||
for (const fk of table.foreignKeys ?? []) {
|
||||
const key = fk.constraintName ?? `${table.name}:${fk.toTable}:${fk.toColumn}`;
|
||||
const rows = byKey.get(key) ?? [];
|
||||
rows.push(fk);
|
||||
byKey.set(key, rows);
|
||||
}
|
||||
return [...byKey.values()].map((rows) => ({
|
||||
fromTable: table.name,
|
||||
fromColumns: rows.map((row) => row.fromColumn),
|
||||
toTable: rows[0].toTable,
|
||||
toColumns: rows.map((row) => row.toColumn),
|
||||
relationship: 'many_to_one',
|
||||
}));
|
||||
}
|
||||
|
||||
export function expectedLinksFromSnapshot(snapshot) {
|
||||
const expectedPks = [];
|
||||
const expectedLinks = [];
|
||||
|
||||
for (const table of snapshot.tables ?? []) {
|
||||
if (table.kind !== 'table') {
|
||||
continue;
|
||||
}
|
||||
const pkColumns = (table.columns ?? []).filter((column) => column.primaryKey).map((column) => column.name);
|
||||
if (pkColumns.length) {
|
||||
expectedPks.push({ table: table.name, columns: pkColumns });
|
||||
}
|
||||
expectedLinks.push(...groupedSnapshotForeignKeys(table));
|
||||
}
|
||||
|
||||
expectedPks.sort((left, right) => left.table.localeCompare(right.table));
|
||||
expectedLinks.sort((left, right) => {
|
||||
const leftKey = `${left.fromTable}.${left.fromColumns.join(',')}->${left.toTable}.${left.toColumns.join(',')}`;
|
||||
const rightKey = `${right.fromTable}.${right.fromColumns.join(',')}->${right.toTable}.${right.toColumns.join(',')}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
});
|
||||
|
||||
return { expectedPks, expectedLinks };
|
||||
}
|
||||
|
||||
export function buildBenchmarkSnapshot(input) {
|
||||
const { db, fixtureId, extractedAt } = input;
|
||||
const names = tableNames(db);
|
||||
const tables = [];
|
||||
|
||||
for (const name of names) {
|
||||
const cols = columnsFor(db, name);
|
||||
const grouped = groupedForeignKeys(rawForeignKeys(db, name), name);
|
||||
const estimatedRows = rowCount(db, name);
|
||||
|
||||
const columns = cols.map((c) => ({
|
||||
name: c.name,
|
||||
nativeType: c.nativeType,
|
||||
normalizedType: normalizeSqliteType(c.nativeType),
|
||||
dimensionType: dimensionTypeFor(c.nativeType, c.name),
|
||||
nullable: c.nullable,
|
||||
primaryKey: c.primaryKey,
|
||||
comment: null,
|
||||
}));
|
||||
|
||||
const foreignKeys = grouped.flatMap((g) =>
|
||||
g.from.map((fromColumn, index) => ({
|
||||
fromColumn,
|
||||
toCatalog: null,
|
||||
toDb: 'main',
|
||||
toTable: g.toTable,
|
||||
toColumn: g.to[index],
|
||||
constraintName: g.constraintName,
|
||||
})),
|
||||
);
|
||||
|
||||
tables.push({
|
||||
catalog: null,
|
||||
db: 'main',
|
||||
name,
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows,
|
||||
columns,
|
||||
foreignKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
connectionId: fixtureId,
|
||||
driver: 'sqlite',
|
||||
extractedAt: extractedAt ?? '2026-05-07T00:00:00.000Z',
|
||||
scope: {},
|
||||
metadata: {},
|
||||
tables,
|
||||
},
|
||||
expected: expectedLinksFromSnapshot({
|
||||
connectionId: fixtureId,
|
||||
driver: 'sqlite',
|
||||
extractedAt: extractedAt ?? '2026-05-07T00:00:00.000Z',
|
||||
scope: {},
|
||||
metadata: {},
|
||||
tables,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeFixtureFiles(input) {
|
||||
const { fixtureDir, snapshot, expected } = input;
|
||||
writeFileSync(path.join(fixtureDir, 'snapshot.json'), `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
writeFileSync(path.join(fixtureDir, 'expected-links.yaml'), yamlStringify(expected), 'utf8');
|
||||
}
|
||||
|
||||
export function rebuildAllPublicSnapshots(options = {}) {
|
||||
const repoRoot = options.repoRoot ?? path.resolve(scriptDir, '..');
|
||||
const fixturesRoot =
|
||||
options.fixturesRoot ?? path.join(repoRoot, 'packages', 'context', 'test', 'fixtures', 'relationship-benchmarks');
|
||||
const manifestPath = options.manifestPath ?? path.join(scriptDir, 'public-benchmark-manifest.json');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
for (const fixture of manifest.fixtures) {
|
||||
const fixtureDir = path.join(fixturesRoot, fixture.id);
|
||||
const dataPath = path.join(fixtureDir, 'data.sqlite');
|
||||
if (!existsSync(dataPath)) {
|
||||
console.log(`[skip] ${fixture.id}: data.sqlite missing (run relationships:acquire-public-fixtures first)`);
|
||||
continue;
|
||||
}
|
||||
const db = new Database(dataPath, { readonly: true });
|
||||
try {
|
||||
const result = buildBenchmarkSnapshot({ db, fixtureId: fixture.id });
|
||||
writeFixtureFiles({ fixtureDir, snapshot: result.snapshot, expected: result.expected });
|
||||
console.log(
|
||||
`[built] ${fixture.id}: ${result.snapshot.tables.length} tables, ${result.expected.expectedLinks.length} expected links`,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--rebuild-all') {
|
||||
rebuildAllPublicSnapshots();
|
||||
} else if (args.length === 2) {
|
||||
const [dataPath, fixtureDir] = args;
|
||||
const db = new Database(dataPath, { readonly: true });
|
||||
try {
|
||||
const fixtureId = path.basename(fixtureDir);
|
||||
const result = buildBenchmarkSnapshot({ db, fixtureId });
|
||||
writeFixtureFiles({ fixtureDir, snapshot: result.snapshot, expected: result.expected });
|
||||
console.log(`[built] ${fixtureId}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} else {
|
||||
console.error('Usage: build-benchmark-snapshot.mjs <data.sqlite> <fixtureDir> | --rebuild-all');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
253
scripts/build-benchmark-snapshot.test.mjs
Normal file
253
scripts/build-benchmark-snapshot.test.mjs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { describe, it } from 'node:test';
|
||||
import { buildBenchmarkSnapshot } from './build-benchmark-snapshot.mjs';
|
||||
|
||||
const require = createRequire(new URL('../packages/context/package.json', import.meta.url));
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
describe('buildBenchmarkSnapshot', () => {
|
||||
it('emits a KloSchemaSnapshot-shaped object plus expected-links from declared FKs', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE orders (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
||||
total REAL,
|
||||
created_at TEXT
|
||||
);
|
||||
INSERT INTO accounts (id, name) VALUES (1, 'a'), (2, 'b');
|
||||
INSERT INTO orders (id, account_id, total, created_at) VALUES
|
||||
(1, 1, 10.0, '2024-01-01'), (2, 1, 20.0, '2024-01-02'), (3, 2, 30.0, '2024-01-03');
|
||||
`);
|
||||
|
||||
const result = buildBenchmarkSnapshot({ db, fixtureId: 'fixture_x' });
|
||||
db.close();
|
||||
|
||||
assert.equal(result.snapshot.connectionId, 'fixture_x');
|
||||
assert.equal(result.snapshot.driver, 'sqlite');
|
||||
assert.equal(result.snapshot.tables.length, 2);
|
||||
|
||||
const accounts = result.snapshot.tables.find((t) => t.name === 'accounts');
|
||||
assert.ok(accounts);
|
||||
assert.equal(accounts.estimatedRows, 2);
|
||||
assert.deepEqual(accounts.foreignKeys, []);
|
||||
const idCol = accounts.columns.find((c) => c.name === 'id');
|
||||
assert.equal(idCol.primaryKey, true);
|
||||
assert.equal(idCol.normalizedType, 'integer');
|
||||
assert.equal(idCol.dimensionType, 'number');
|
||||
|
||||
const orders = result.snapshot.tables.find((t) => t.name === 'orders');
|
||||
assert.equal(orders.foreignKeys.length, 1);
|
||||
assert.equal(orders.foreignKeys[0].fromColumn, 'account_id');
|
||||
assert.equal(orders.foreignKeys[0].toTable, 'accounts');
|
||||
assert.equal(orders.foreignKeys[0].toColumn, 'id');
|
||||
|
||||
const createdAt = orders.columns.find((c) => c.name === 'created_at');
|
||||
assert.equal(createdAt.dimensionType, 'time');
|
||||
|
||||
const total = orders.columns.find((c) => c.name === 'total');
|
||||
assert.equal(total.dimensionType, 'number');
|
||||
assert.equal(total.nullable, true);
|
||||
|
||||
assert.deepEqual(
|
||||
result.expected.expectedPks.sort((a, b) => a.table.localeCompare(b.table)),
|
||||
[
|
||||
{ table: 'accounts', columns: ['id'] },
|
||||
{ table: 'orders', columns: ['id'] },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(result.expected.expectedLinks, [
|
||||
{
|
||||
fromTable: 'orders',
|
||||
fromColumns: ['account_id'],
|
||||
toTable: 'accounts',
|
||||
toColumns: ['id'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips internal SQLite tables (sqlite_*) and views', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE keep_me (id INTEGER PRIMARY KEY);
|
||||
CREATE VIEW keep_me_view AS SELECT id FROM keep_me;
|
||||
INSERT INTO keep_me (id) VALUES (1);
|
||||
`);
|
||||
const result = buildBenchmarkSnapshot({ db, fixtureId: 'fx' });
|
||||
db.close();
|
||||
assert.equal(result.snapshot.tables.length, 1);
|
||||
assert.equal(result.snapshot.tables[0].name, 'keep_me');
|
||||
});
|
||||
|
||||
it('groups composite foreign keys into a single ordered link', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE order_lines (
|
||||
order_id INTEGER NOT NULL,
|
||||
line_number INTEGER NOT NULL,
|
||||
sku TEXT NOT NULL,
|
||||
PRIMARY KEY (order_id, line_number)
|
||||
);
|
||||
CREATE TABLE allocations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL,
|
||||
line_number INTEGER NOT NULL,
|
||||
FOREIGN KEY (order_id, line_number) REFERENCES order_lines(order_id, line_number)
|
||||
);
|
||||
`);
|
||||
const result = buildBenchmarkSnapshot({ db, fixtureId: 'fx' });
|
||||
db.close();
|
||||
|
||||
const composite = result.expected.expectedLinks.find((l) => l.fromTable === 'allocations');
|
||||
assert.deepEqual(composite, {
|
||||
fromTable: 'allocations',
|
||||
fromColumns: ['order_id', 'line_number'],
|
||||
toTable: 'order_lines',
|
||||
toColumns: ['order_id', 'line_number'],
|
||||
relationship: 'many_to_one',
|
||||
});
|
||||
|
||||
const compositePk = result.expected.expectedPks.find((p) => p.table === 'order_lines');
|
||||
assert.deepEqual(compositePk.columns, ['order_id', 'line_number']);
|
||||
});
|
||||
|
||||
it('derives expected PKs and grouped FKs from an existing snapshot', async () => {
|
||||
const { expectedLinksFromSnapshot } = await import('./build-benchmark-snapshot.mjs');
|
||||
|
||||
const expected = expectedLinksFromSnapshot({
|
||||
connectionId: 'fixture',
|
||||
driver: 'sqlite',
|
||||
extractedAt: '2026-05-07T00:00:00.000Z',
|
||||
scope: {},
|
||||
metadata: {},
|
||||
tables: [
|
||||
{
|
||||
catalog: null,
|
||||
db: 'main',
|
||||
name: 'Sales.SalesOrderHeader',
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows: 3,
|
||||
columns: [
|
||||
{
|
||||
name: 'SalesOrderID',
|
||||
nativeType: 'int',
|
||||
normalizedType: 'integer',
|
||||
dimensionType: 'number',
|
||||
nullable: false,
|
||||
primaryKey: true,
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
name: 'CustomerID',
|
||||
nativeType: 'int',
|
||||
normalizedType: 'integer',
|
||||
dimensionType: 'number',
|
||||
nullable: false,
|
||||
primaryKey: false,
|
||||
comment: null,
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
fromColumn: 'CustomerID',
|
||||
toCatalog: null,
|
||||
toDb: 'main',
|
||||
toTable: 'Sales.Customer',
|
||||
toColumn: 'CustomerID',
|
||||
constraintName: 'FK_SalesOrderHeader_Customer_CustomerID',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
catalog: null,
|
||||
db: 'main',
|
||||
name: 'Sales.Customer',
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows: 2,
|
||||
columns: [
|
||||
{
|
||||
name: 'CustomerID',
|
||||
nativeType: 'int',
|
||||
normalizedType: 'integer',
|
||||
dimensionType: 'number',
|
||||
nullable: false,
|
||||
primaryKey: true,
|
||||
comment: null,
|
||||
},
|
||||
],
|
||||
foreignKeys: [],
|
||||
},
|
||||
{
|
||||
catalog: null,
|
||||
db: 'main',
|
||||
name: 'Sales.SalesOrderDetail',
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows: 6,
|
||||
columns: [
|
||||
{
|
||||
name: 'SalesOrderID',
|
||||
nativeType: 'int',
|
||||
normalizedType: 'integer',
|
||||
dimensionType: 'number',
|
||||
nullable: false,
|
||||
primaryKey: true,
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
name: 'SalesOrderDetailID',
|
||||
nativeType: 'int',
|
||||
normalizedType: 'integer',
|
||||
dimensionType: 'number',
|
||||
nullable: false,
|
||||
primaryKey: true,
|
||||
comment: null,
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
fromColumn: 'SalesOrderID',
|
||||
toCatalog: null,
|
||||
toDb: 'main',
|
||||
toTable: 'Sales.SalesOrderHeader',
|
||||
toColumn: 'SalesOrderID',
|
||||
constraintName: 'FK_SalesOrderDetail_SalesOrderHeader_SalesOrderID',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(expected.expectedPks, [
|
||||
{ table: 'Sales.Customer', columns: ['CustomerID'] },
|
||||
{ table: 'Sales.SalesOrderDetail', columns: ['SalesOrderID', 'SalesOrderDetailID'] },
|
||||
{ table: 'Sales.SalesOrderHeader', columns: ['SalesOrderID'] },
|
||||
]);
|
||||
assert.deepEqual(expected.expectedLinks, [
|
||||
{
|
||||
fromTable: 'Sales.SalesOrderDetail',
|
||||
fromColumns: ['SalesOrderID'],
|
||||
toTable: 'Sales.SalesOrderHeader',
|
||||
toColumns: ['SalesOrderID'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'Sales.SalesOrderHeader',
|
||||
fromColumns: ['CustomerID'],
|
||||
toTable: 'Sales.Customer',
|
||||
toColumns: ['CustomerID'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
492
scripts/build-evidence-fusion-adversarial-fixtures.mjs
Normal file
492
scripts/build-evidence-fusion-adversarial-fixtures.mjs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
#!/usr/bin/env node
|
||||
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { buildBenchmarkSnapshot, writeFixtureFiles } from './build-benchmark-snapshot.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const kloRoot = path.resolve(scriptDir, '..');
|
||||
const fixtureRoot = path.join(kloRoot, 'packages', 'context', 'test', 'fixtures', 'relationship-benchmarks');
|
||||
const require = createRequire(new URL('../packages/context/package.json', import.meta.url));
|
||||
const Database = require('better-sqlite3');
|
||||
const { stringify: yamlStringify } = require('yaml');
|
||||
|
||||
function q(value) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function sqlValue(value) {
|
||||
if (value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function insertSql(table, columns, rows) {
|
||||
return `INSERT INTO ${q(table)} (${columns.map(q).join(', ')}) VALUES\n${rows
|
||||
.map((row) => ` (${row.map(sqlValue).join(', ')})`)
|
||||
.join(',\n')};`;
|
||||
}
|
||||
|
||||
function fixtureYaml(config) {
|
||||
return yamlStringify({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
tier: config.tier,
|
||||
origin: 'synthetic',
|
||||
thresholdEligible: false,
|
||||
...(config.validationBudget === undefined ? {} : { validationBudget: config.validationBudget }),
|
||||
defaultModes: ['declared_pks_and_declared_fks_removed'],
|
||||
});
|
||||
}
|
||||
|
||||
function writeFixture(config) {
|
||||
const fixtureDir = path.join(fixtureRoot, config.id);
|
||||
rmSync(fixtureDir, { recursive: true, force: true });
|
||||
mkdirSync(fixtureDir, { recursive: true });
|
||||
writeFileSync(path.join(fixtureDir, 'fixture.yaml'), fixtureYaml(config), 'utf8');
|
||||
|
||||
const dataPath = path.join(fixtureDir, 'data.sqlite');
|
||||
const db = new Database(dataPath);
|
||||
try {
|
||||
db.pragma('foreign_keys = OFF');
|
||||
db.exec(config.sql);
|
||||
const { snapshot } = buildBenchmarkSnapshot({ db, fixtureId: config.id });
|
||||
writeFixtureFiles({ fixtureDir, snapshot, expected: config.expected });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
if (config.compressArtifacts) {
|
||||
for (const fileName of ['snapshot.json', 'data.sqlite']) {
|
||||
const rawPath = path.join(fixtureDir, fileName);
|
||||
writeFileSync(`${rawPath}.gz`, gzipSync(readFileSync(rawPath)), 'utf8');
|
||||
unlinkSync(rawPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[built] ${config.id}: ${config.expected.expectedPks.length} PKs, ${config.expected.expectedLinks.length} links`);
|
||||
}
|
||||
|
||||
function nonEnglishFixture() {
|
||||
return {
|
||||
id: 'non_english_naming_no_declared_constraints',
|
||||
name: 'Non-English naming fixture with no declared constraints',
|
||||
tier: 'row_bearing',
|
||||
sql: [
|
||||
'CREATE TABLE kundenstamm (kundennummer TEXT NOT NULL, firmenname TEXT NOT NULL, stadt TEXT NOT NULL);',
|
||||
insertSql('kundenstamm', ['kundennummer', 'firmenname', 'stadt'], [
|
||||
['K-001', 'Baeckerei Mueller', 'Muenchen'],
|
||||
['K-002', 'Cafe Sakura', 'Berlin'],
|
||||
['K-003', 'Nord Handel', 'Hamburg'],
|
||||
]),
|
||||
'CREATE TABLE bestellungen (bestellnummer TEXT NOT NULL, "kaeufer_nummer" TEXT NOT NULL, betrag INTEGER NOT NULL);',
|
||||
insertSql('bestellungen', ['bestellnummer', 'kaeufer_nummer', 'betrag'], [
|
||||
['B-100', 'K-001', 420],
|
||||
['B-101', 'K-002', 300],
|
||||
['B-102', 'K-001', 125],
|
||||
]),
|
||||
'CREATE TABLE seihin (seihin_bango TEXT NOT NULL, bezeichnung TEXT NOT NULL, kategorie TEXT NOT NULL);',
|
||||
insertSql('seihin', ['seihin_bango', 'bezeichnung', 'kategorie'], [
|
||||
['S-01', 'ocha', 'drink'],
|
||||
['S-02', 'pan', 'food'],
|
||||
['S-03', 'miso', 'food'],
|
||||
]),
|
||||
'CREATE TABLE uriage (verkauf_nr TEXT NOT NULL, hinban TEXT NOT NULL, menge INTEGER NOT NULL);',
|
||||
insertSql('uriage', ['verkauf_nr', 'hinban', 'menge'], [
|
||||
['U-1', 'S-01', 7],
|
||||
['U-2', 'S-02', 3],
|
||||
['U-3', 'S-01', 5],
|
||||
]),
|
||||
].join('\n'),
|
||||
expected: {
|
||||
expectedPks: [
|
||||
{ table: 'kundenstamm', columns: ['kundennummer'] },
|
||||
{ table: 'seihin', columns: ['seihin_bango'] },
|
||||
],
|
||||
expectedLinks: [
|
||||
{
|
||||
fromTable: 'bestellungen',
|
||||
fromColumns: ['kaeufer_nummer'],
|
||||
toTable: 'kundenstamm',
|
||||
toColumns: ['kundennummer'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'uriage',
|
||||
fromColumns: ['hinban'],
|
||||
toTable: 'seihin',
|
||||
toColumns: ['seihin_bango'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function abbreviatedLegacyFixture() {
|
||||
return {
|
||||
id: 'abbreviated_legacy_no_declared_constraints',
|
||||
name: 'Abbreviated legacy naming fixture with no declared constraints',
|
||||
tier: 'row_bearing',
|
||||
sql: [
|
||||
'CREATE TABLE cust (cust_id TEXT NOT NULL, nm TEXT NOT NULL, stat_cd TEXT NOT NULL);',
|
||||
insertSql('cust', ['cust_id', 'nm', 'stat_cd'], [
|
||||
['C001', 'Acme', 'A'],
|
||||
['C002', 'Globex', 'A'],
|
||||
['C003', 'Initech', 'I'],
|
||||
]),
|
||||
'CREATE TABLE prod (prod_cd TEXT NOT NULL, prod_nm TEXT NOT NULL, cat_cd TEXT NOT NULL);',
|
||||
insertSql('prod', ['prod_cd', 'prod_nm', 'cat_cd'], [
|
||||
['P10', 'Seat', 'FURN'],
|
||||
['P11', 'Desk', 'FURN'],
|
||||
['P12', 'Lamp', 'HOME'],
|
||||
]),
|
||||
'CREATE TABLE ord_hdr (ord_id TEXT NOT NULL, cust_id TEXT NOT NULL, ord_dt TEXT NOT NULL);',
|
||||
insertSql('ord_hdr', ['ord_id', 'cust_id', 'ord_dt'], [
|
||||
['O900', 'C001', '2026-01-01'],
|
||||
['O901', 'C001', '2026-01-02'],
|
||||
['O902', 'C002', '2026-01-03'],
|
||||
]),
|
||||
'CREATE TABLE ord_ln (ln_id TEXT NOT NULL, ord_id TEXT NOT NULL, prod_cd TEXT NOT NULL, qty INTEGER NOT NULL);',
|
||||
insertSql('ord_ln', ['ln_id', 'ord_id', 'prod_cd', 'qty'], [
|
||||
['L1', 'O900', 'P10', 2],
|
||||
['L2', 'O900', 'P12', 1],
|
||||
['L3', 'O901', 'P11', 4],
|
||||
]),
|
||||
].join('\n'),
|
||||
expected: {
|
||||
expectedPks: [
|
||||
{ table: 'cust', columns: ['cust_id'] },
|
||||
{ table: 'ord_hdr', columns: ['ord_id'] },
|
||||
{ table: 'prod', columns: ['prod_cd'] },
|
||||
],
|
||||
expectedLinks: [
|
||||
{
|
||||
fromTable: 'ord_hdr',
|
||||
fromColumns: ['cust_id'],
|
||||
toTable: 'cust',
|
||||
toColumns: ['cust_id'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'ord_ln',
|
||||
fromColumns: ['ord_id'],
|
||||
toTable: 'ord_hdr',
|
||||
toColumns: ['ord_id'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'ord_ln',
|
||||
fromColumns: ['prod_cd'],
|
||||
toTable: 'prod',
|
||||
toColumns: ['prod_cd'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function analyticalWarehouseFixture() {
|
||||
return {
|
||||
id: 'analytical_warehouse_no_naming_convention',
|
||||
name: 'Analytical warehouse fixture with no naming convention',
|
||||
tier: 'row_bearing',
|
||||
sql: [
|
||||
'CREATE TABLE dim_signup_country (country_code TEXT NOT NULL, country_name TEXT NOT NULL, region_name TEXT NOT NULL);',
|
||||
insertSql('dim_signup_country', ['country_code', 'country_name', 'region_name'], [
|
||||
['US', 'United States', 'americas'],
|
||||
['DE', 'Germany', 'emea'],
|
||||
['JP', 'Japan', 'apac'],
|
||||
]),
|
||||
'CREATE TABLE dim_commercial_plan (plan_code TEXT NOT NULL, plan_family TEXT NOT NULL, sales_motion TEXT NOT NULL);',
|
||||
insertSql('dim_commercial_plan', ['plan_code', 'plan_family', 'sales_motion'], [
|
||||
['FREE', 'free', 'self_serve'],
|
||||
['TEAM', 'team', 'sales_assisted'],
|
||||
['ENT', 'enterprise', 'sales_led'],
|
||||
]),
|
||||
'CREATE TABLE mart_revenue_daily (revenue_event_key TEXT NOT NULL, signup_country_code TEXT NOT NULL, commercial_plan_code TEXT NOT NULL, booked_revenue INTEGER NOT NULL);',
|
||||
insertSql(
|
||||
'mart_revenue_daily',
|
||||
['revenue_event_key', 'signup_country_code', 'commercial_plan_code', 'booked_revenue'],
|
||||
[
|
||||
['R1', 'US', 'TEAM', 200],
|
||||
['R2', 'DE', 'ENT', 900],
|
||||
['R3', 'US', 'FREE', 0],
|
||||
],
|
||||
),
|
||||
'CREATE TABLE mart_activation_cohort (cohort_key TEXT NOT NULL, first_touch_country TEXT NOT NULL, purchased_plan TEXT NOT NULL, activated_accounts INTEGER NOT NULL);',
|
||||
insertSql(
|
||||
'mart_activation_cohort',
|
||||
['cohort_key', 'first_touch_country', 'purchased_plan', 'activated_accounts'],
|
||||
[
|
||||
['C1', 'JP', 'TEAM', 7],
|
||||
['C2', 'DE', 'ENT', 2],
|
||||
['C3', 'US', 'FREE', 30],
|
||||
],
|
||||
),
|
||||
].join('\n'),
|
||||
expected: {
|
||||
expectedPks: [
|
||||
{ table: 'dim_commercial_plan', columns: ['plan_code'] },
|
||||
{ table: 'dim_signup_country', columns: ['country_code'] },
|
||||
],
|
||||
expectedLinks: [
|
||||
{
|
||||
fromTable: 'mart_activation_cohort',
|
||||
fromColumns: ['first_touch_country'],
|
||||
toTable: 'dim_signup_country',
|
||||
toColumns: ['country_code'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'mart_activation_cohort',
|
||||
fromColumns: ['purchased_plan'],
|
||||
toTable: 'dim_commercial_plan',
|
||||
toColumns: ['plan_code'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'mart_revenue_daily',
|
||||
fromColumns: ['commercial_plan_code'],
|
||||
toTable: 'dim_commercial_plan',
|
||||
toColumns: ['plan_code'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'mart_revenue_daily',
|
||||
fromColumns: ['signup_country_code'],
|
||||
toTable: 'dim_signup_country',
|
||||
toColumns: ['country_code'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mixedCaseFixture() {
|
||||
return {
|
||||
id: 'mixed_case_within_schema_no_declared_constraints',
|
||||
name: 'Mixed case within schema fixture with no declared constraints',
|
||||
tier: 'row_bearing',
|
||||
sql: [
|
||||
'CREATE TABLE CustomerAccount (AccountID TEXT NOT NULL, AccountName TEXT NOT NULL, accountTier TEXT NOT NULL);',
|
||||
insertSql('CustomerAccount', ['AccountID', 'AccountName', 'accountTier'], [
|
||||
['A-1', 'Acme', 'team'],
|
||||
['A-2', 'Globex', 'enterprise'],
|
||||
['A-3', 'Initech', 'free'],
|
||||
]),
|
||||
'CREATE TABLE subscriptionPlans (planId TEXT NOT NULL, display_name TEXT NOT NULL, BillingCadence TEXT NOT NULL);',
|
||||
insertSql('subscriptionPlans', ['planId', 'display_name', 'BillingCadence'], [
|
||||
['P-free', 'Free', 'none'],
|
||||
['P-team', 'Team', 'monthly'],
|
||||
['P-ent', 'Enterprise', 'annual'],
|
||||
]),
|
||||
'CREATE TABLE order_events (event_id TEXT NOT NULL, accountId TEXT NOT NULL, plan_id TEXT NOT NULL, amount INTEGER NOT NULL);',
|
||||
insertSql('order_events', ['event_id', 'accountId', 'plan_id', 'amount'], [
|
||||
['E1', 'A-1', 'P-team', 120],
|
||||
['E2', 'A-2', 'P-ent', 1000],
|
||||
['E3', 'A-1', 'P-free', 0],
|
||||
]),
|
||||
'CREATE TABLE InvoiceHeader (InvoiceID TEXT NOT NULL, CustomerAccountID TEXT NOT NULL, invoice_total INTEGER NOT NULL);',
|
||||
insertSql('InvoiceHeader', ['InvoiceID', 'CustomerAccountID', 'invoice_total'], [
|
||||
['I1', 'A-1', 120],
|
||||
['I2', 'A-2', 1000],
|
||||
['I3', 'A-1', 20],
|
||||
]),
|
||||
'CREATE TABLE line_items (line_item_id TEXT NOT NULL, invoice_id TEXT NOT NULL, skuCode TEXT NOT NULL);',
|
||||
insertSql('line_items', ['line_item_id', 'invoice_id', 'skuCode'], [
|
||||
['L1', 'I1', 'SKU1'],
|
||||
['L2', 'I1', 'SKU2'],
|
||||
['L3', 'I2', 'SKU3'],
|
||||
]),
|
||||
].join('\n'),
|
||||
expected: {
|
||||
expectedPks: [
|
||||
{ table: 'CustomerAccount', columns: ['AccountID'] },
|
||||
{ table: 'InvoiceHeader', columns: ['InvoiceID'] },
|
||||
{ table: 'subscriptionPlans', columns: ['planId'] },
|
||||
],
|
||||
expectedLinks: [
|
||||
{
|
||||
fromTable: 'InvoiceHeader',
|
||||
fromColumns: ['CustomerAccountID'],
|
||||
toTable: 'CustomerAccount',
|
||||
toColumns: ['AccountID'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'line_items',
|
||||
fromColumns: ['invoice_id'],
|
||||
toTable: 'InvoiceHeader',
|
||||
toColumns: ['InvoiceID'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'order_events',
|
||||
fromColumns: ['accountId'],
|
||||
toTable: 'CustomerAccount',
|
||||
toColumns: ['AccountID'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'order_events',
|
||||
fromColumns: ['plan_id'],
|
||||
toTable: 'subscriptionPlans',
|
||||
toColumns: ['planId'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function polymorphicFixture() {
|
||||
return {
|
||||
id: 'polymorphic_partial_overlap_no_declared_constraints',
|
||||
name: 'Polymorphic partial-overlap fixture with no declared constraints',
|
||||
tier: 'row_bearing',
|
||||
sql: [
|
||||
'CREATE TABLE users (user_id TEXT NOT NULL, email TEXT NOT NULL, lifecycle TEXT NOT NULL);',
|
||||
insertSql('users', ['user_id', 'email', 'lifecycle'], [
|
||||
['U1', 'ada@example.com', 'active'],
|
||||
['U2', 'grace@example.com', 'active'],
|
||||
['U3', 'alan@example.com', 'inactive'],
|
||||
]),
|
||||
'CREATE TABLE organizations (organization_id TEXT NOT NULL, organization_name TEXT NOT NULL, market TEXT NOT NULL);',
|
||||
insertSql('organizations', ['organization_id', 'organization_name', 'market'], [
|
||||
['O1', 'Acme', 'midmarket'],
|
||||
['O2', 'Globex', 'enterprise'],
|
||||
['O3', 'Initech', 'smb'],
|
||||
]),
|
||||
'CREATE TABLE activity_events (event_id TEXT NOT NULL, entity_id TEXT NOT NULL, entity_type TEXT NOT NULL, action_name TEXT NOT NULL);',
|
||||
insertSql('activity_events', ['event_id', 'entity_id', 'entity_type', 'action_name'], [
|
||||
['E1', 'U1', 'user', 'login'],
|
||||
['E2', 'O1', 'organization', 'workspace_created'],
|
||||
['E3', 'U2', 'user', 'invite_sent'],
|
||||
['E4', 'O2', 'organization', 'billing_updated'],
|
||||
]),
|
||||
].join('\n'),
|
||||
expected: {
|
||||
expectedPks: [
|
||||
{ table: 'organizations', columns: ['organization_id'] },
|
||||
{ table: 'users', columns: ['user_id'] },
|
||||
],
|
||||
expectedLinks: [
|
||||
{
|
||||
fromTable: 'activity_events',
|
||||
fromColumns: ['entity_id'],
|
||||
toTable: 'organizations',
|
||||
toColumns: ['organization_id'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
{
|
||||
fromTable: 'activity_events',
|
||||
fromColumns: ['entity_id'],
|
||||
toTable: 'users',
|
||||
toColumns: ['user_id'],
|
||||
relationship: 'many_to_one',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function padded(value, width) {
|
||||
return String(value).padStart(width, '0');
|
||||
}
|
||||
|
||||
function scaleFixture() {
|
||||
const statements = [];
|
||||
const expectedPks = [];
|
||||
const expectedLinks = [];
|
||||
const dimensionCount = 20;
|
||||
const factCount = 380;
|
||||
|
||||
for (let dim = 0; dim < dimensionCount; dim += 1) {
|
||||
const dimId = padded(dim, 2);
|
||||
const table = `dim_entity_${dimId}`;
|
||||
const key = `entity_${dimId}_key`;
|
||||
const columns = [key, ...Array.from({ length: 49 }, (_, index) => `attribute_${padded(index, 2)}`)];
|
||||
statements.push(`CREATE TABLE ${q(table)} (${columns.map((column) => `${q(column)} TEXT NOT NULL`).join(', ')});`);
|
||||
statements.push(
|
||||
insertSql(
|
||||
table,
|
||||
columns,
|
||||
Array.from({ length: 3 }, (_, rowIndex) => [
|
||||
`D${dimId}-${rowIndex}`,
|
||||
...Array.from({ length: 49 }, (_, attrIndex) => `dim${dimId}_attr${attrIndex}_${rowIndex}`),
|
||||
]),
|
||||
),
|
||||
);
|
||||
expectedPks.push({ table, columns: [key] });
|
||||
}
|
||||
|
||||
for (let fact = 0; fact < factCount; fact += 1) {
|
||||
const factId = padded(fact, 3);
|
||||
const table = `fact_activity_${factId}`;
|
||||
const referencedDims = Array.from({ length: 5 }, (_, offset) => (fact + offset) % dimensionCount);
|
||||
const referenceColumns = referencedDims.map((dim) => `entity_${padded(dim, 2)}_key`);
|
||||
const metricColumns = Array.from({ length: 44 }, (_, index) => `metric_${padded(index, 2)}`);
|
||||
const columns = ['event_id', ...referenceColumns, ...metricColumns];
|
||||
statements.push(
|
||||
`CREATE TABLE ${q(table)} (${[
|
||||
`${q('event_id')} TEXT NOT NULL`,
|
||||
...referenceColumns.map((column) => `${q(column)} TEXT NOT NULL`),
|
||||
...metricColumns.map((column) => `${q(column)} INTEGER NOT NULL`),
|
||||
].join(', ')});`,
|
||||
);
|
||||
statements.push(
|
||||
insertSql(
|
||||
table,
|
||||
columns,
|
||||
Array.from({ length: 3 }, (_, rowIndex) => [
|
||||
`F${factId}-${rowIndex}`,
|
||||
...referencedDims.map((dim) => `D${padded(dim, 2)}-${rowIndex}`),
|
||||
...metricColumns.map((_, metricIndex) => fact * 1000 + metricIndex * 10 + rowIndex),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const dim of referencedDims) {
|
||||
const dimId = padded(dim, 2);
|
||||
expectedLinks.push({
|
||||
fromTable: table,
|
||||
fromColumns: [`entity_${dimId}_key`],
|
||||
toTable: `dim_entity_${dimId}`,
|
||||
toColumns: [`entity_${dimId}_key`],
|
||||
relationship: 'many_to_one',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'scale_stress_no_declared_constraints',
|
||||
name: 'Scale stress fixture with no declared constraints',
|
||||
tier: 'row_bearing',
|
||||
validationBudget: 800,
|
||||
compressArtifacts: true,
|
||||
sql: statements.join('\n'),
|
||||
expected: { expectedPks, expectedLinks },
|
||||
};
|
||||
}
|
||||
|
||||
const fixtures = [
|
||||
nonEnglishFixture(),
|
||||
abbreviatedLegacyFixture(),
|
||||
analyticalWarehouseFixture(),
|
||||
mixedCaseFixture(),
|
||||
polymorphicFixture(),
|
||||
scaleFixture(),
|
||||
];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
writeFixture(fixture);
|
||||
}
|
||||
213
scripts/check-boundaries.mjs
Normal file
213
scripts/check-boundaries.mjs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
|
||||
const runtimeAssetPatterns = [/^packages\/[^/]+\/prompts\/.+\.md$/, /^packages\/[^/]+\/skills\/.+\.md$/];
|
||||
const identifierSkipPrefixes = ['docs/', 'examples/', 'python/klo-sl/plans/', 'python/klo-sl/openspec/'];
|
||||
const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_'];
|
||||
|
||||
const appImportPatterns = [
|
||||
{
|
||||
label: 'server source import',
|
||||
pattern: /(?:from\s+['"][^'"]*|import\s*\(\s*['"][^'"]*|import\s+['"][^'"]*)(?:@server\/|server\/src|(?:\.\.\/)+server\/src)/,
|
||||
},
|
||||
{
|
||||
label: 'frontend source import',
|
||||
pattern: /(?:from\s+['"][^'"]*|import\s*\(\s*['"][^'"]*|import\s+['"][^'"]*)(?:@frontend\/|frontend\/src|(?:\.\.\/)+frontend\/src)/,
|
||||
},
|
||||
{
|
||||
label: 'python service app import',
|
||||
pattern: /(?:from\s+['"][^'"]*|import\s*\(\s*['"][^'"]*|import\s+['"][^'"]*|from\s+)(?:python-service\/app|python_service\.app|app\.)/,
|
||||
},
|
||||
];
|
||||
|
||||
const llmBoundaryPatterns = [
|
||||
{
|
||||
label: 'direct Anthropic provider construction',
|
||||
pattern: /\bcreateAnthropic\b/,
|
||||
},
|
||||
{
|
||||
label: 'direct Vertex Anthropic provider construction',
|
||||
pattern: /\bcreateVertexAnthropic\b/,
|
||||
},
|
||||
{
|
||||
label: 'direct AI SDK gateway construction',
|
||||
pattern: /\bcreateGateway\b/,
|
||||
},
|
||||
{
|
||||
label: 'direct AI SDK embedding execution',
|
||||
pattern: /\bembedMany\b/,
|
||||
},
|
||||
{
|
||||
label: 'legacy context LLM provider port',
|
||||
pattern: /\bLlmProviderPort\b/,
|
||||
},
|
||||
{
|
||||
label: 'legacy scan LLM provider port',
|
||||
pattern: /\bKloScanLlmPort\b/,
|
||||
},
|
||||
{
|
||||
label: 'legacy gateway LLM provider helper',
|
||||
pattern: /\bcreateGatewayLlmProvider\b/,
|
||||
},
|
||||
];
|
||||
|
||||
const contextProductionLlmBoundaryPatterns = [
|
||||
{
|
||||
label: 'context getModelByName call',
|
||||
pattern: /\.\s*getModelByName\s*\(/,
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function isCodeSource(relativePath) {
|
||||
return codeExtensions.has(path.extname(relativePath));
|
||||
}
|
||||
|
||||
function isRuntimeAsset(relativePath) {
|
||||
return runtimeAssetPatterns.some((pattern) => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
function scansForAppImports(relativePath) {
|
||||
return isCodeSource(relativePath);
|
||||
}
|
||||
|
||||
function scansForLlmBoundaries(relativePath) {
|
||||
return isCodeSource(relativePath) && relativePath.startsWith('packages/context/src/');
|
||||
}
|
||||
|
||||
function isTestSource(relativePath) {
|
||||
return /(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(relativePath);
|
||||
}
|
||||
|
||||
function scansForContextProductionLlmBoundaries(relativePath) {
|
||||
return scansForLlmBoundaries(relativePath) && !isTestSource(relativePath);
|
||||
}
|
||||
|
||||
function scansForForbiddenIdentifiers(relativePath) {
|
||||
return isCodeSource(relativePath) || isRuntimeAsset(relativePath);
|
||||
}
|
||||
|
||||
function skipsIdentifierScan(relativePath) {
|
||||
return identifierSkipPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function scanFileContent(relativePath, content) {
|
||||
const normalizedPath = normalizePath(relativePath);
|
||||
const violations = [];
|
||||
|
||||
if (scansForAppImports(normalizedPath)) {
|
||||
for (const appImportPattern of appImportPatterns) {
|
||||
if (appImportPattern.pattern.test(content)) {
|
||||
violations.push({
|
||||
file: normalizedPath,
|
||||
kind: 'app-import',
|
||||
message: `Forbidden ${appImportPattern.label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scansForLlmBoundaries(normalizedPath)) {
|
||||
for (const llmBoundaryPattern of llmBoundaryPatterns) {
|
||||
if (llmBoundaryPattern.pattern.test(content)) {
|
||||
violations.push({
|
||||
file: normalizedPath,
|
||||
kind: 'llm-boundary',
|
||||
message: `Forbidden ${llmBoundaryPattern.label}; use @klo/llm`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scansForContextProductionLlmBoundaries(normalizedPath)) {
|
||||
for (const llmBoundaryPattern of contextProductionLlmBoundaryPatterns) {
|
||||
if (llmBoundaryPattern.pattern.test(content)) {
|
||||
violations.push({
|
||||
file: normalizedPath,
|
||||
kind: 'llm-boundary',
|
||||
message: `Forbidden ${llmBoundaryPattern.label}; use getModel(role) inside @klo/context`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scansForForbiddenIdentifiers(normalizedPath) && !skipsIdentifierScan(normalizedPath)) {
|
||||
for (const term of forbiddenIdentifierTerms) {
|
||||
if (content.includes(term)) {
|
||||
violations.push({
|
||||
file: normalizedPath,
|
||||
kind: 'identifier',
|
||||
message: `Forbidden product identifier "${term}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function collectFiles(rootDir, currentDir = rootDir) {
|
||||
const entries = await readdir(currentDir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.venv') {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(...(await collectFiles(rootDir, fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function collectViolations(rootDir) {
|
||||
const files = await collectFiles(rootDir);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = normalizePath(path.relative(rootDir, file));
|
||||
const content = await readFile(file, 'utf8');
|
||||
|
||||
violations.push(...scanFileContent(relativePath, content));
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, '..');
|
||||
const violations = await collectViolations(rootDir);
|
||||
|
||||
if (violations.length === 0) {
|
||||
process.stdout.write('klo boundary check passed\n');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const violation of violations) {
|
||||
process.stderr.write(`${violation.file}: ${violation.message}\n`);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
await main();
|
||||
}
|
||||
147
scripts/check-boundaries.test.mjs
Normal file
147
scripts/check-boundaries.test.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { scanFileContent } from './check-boundaries.mjs';
|
||||
|
||||
function productName() {
|
||||
return ['Kae', 'lio'].join('');
|
||||
}
|
||||
|
||||
function lowerProductName() {
|
||||
return ['kae', 'lio'].join('');
|
||||
}
|
||||
|
||||
describe('scanFileContent', () => {
|
||||
it('rejects source imports from application directories', () => {
|
||||
const serverAlias = '@' + 'server/contracts';
|
||||
const pythonAppPath = 'python-service/' + 'app/api/endpoints/semantic_layer.py';
|
||||
|
||||
const violations = [
|
||||
...scanFileContent('packages/context/src/index.ts', `import { orpc } from '${serverAlias}';`),
|
||||
...scanFileContent('packages/context/src/index.ts', `import "${pythonAppPath}";`),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
violations.map((violation) => violation.kind),
|
||||
['app-import', 'app-import'],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects forbidden product identifiers in code source files', () => {
|
||||
const violations = scanFileContent('packages/context/src/index.ts', `export const owner = '${lowerProductName()}';`);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0]?.kind, 'identifier');
|
||||
});
|
||||
|
||||
it('rejects forbidden product identifiers in shipped runtime prompt assets', () => {
|
||||
const violations = scanFileContent(
|
||||
'packages/context/prompts/memory_agent_bundle_ingest_work_unit.md',
|
||||
`Write output for ${productName()}.`,
|
||||
);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0]?.kind, 'identifier');
|
||||
assert.equal(violations[0]?.file, 'packages/context/prompts/memory_agent_bundle_ingest_work_unit.md');
|
||||
});
|
||||
|
||||
it('rejects forbidden product identifiers in shipped runtime skill assets', () => {
|
||||
const violations = scanFileContent(
|
||||
'packages/context/skills/metabase_ingest/SKILL.md',
|
||||
`Use ${productName()} project conventions.`,
|
||||
);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0]?.kind, 'identifier');
|
||||
assert.equal(violations[0]?.file, 'packages/context/skills/metabase_ingest/SKILL.md');
|
||||
});
|
||||
|
||||
it('allows product identifiers in docs, examples, and transition metadata', () => {
|
||||
const name = productName();
|
||||
|
||||
assert.equal(scanFileContent('docs/transition.md', name).length, 0);
|
||||
assert.equal(scanFileContent('examples/transition.md', name).length, 0);
|
||||
assert.equal(scanFileContent('python/klo-sl/plans/brainstorm.md', name).length, 0);
|
||||
assert.equal(scanFileContent('python/klo-sl/openspec/specs/semantic-layer/spec.md', name).length, 0);
|
||||
});
|
||||
|
||||
it('allows clean source files and clean runtime prompt assets', () => {
|
||||
assert.deepEqual(
|
||||
scanFileContent('packages/context/src/index.ts', "export const packageName = '@klo/context';"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
scanFileContent('packages/context/prompts/memory_agent_bundle_ingest_work_unit.md', 'Write output for KLO.'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects context-owned LLM provider construction after @klo/llm migration', () => {
|
||||
const violations = [
|
||||
...scanFileContent(
|
||||
'packages/context/src/agent/local-llm-provider.ts',
|
||||
"import { createAnthropic } from '@ai-sdk/anthropic';",
|
||||
),
|
||||
...scanFileContent('packages/context/src/scan/local-ai-gateway-enrichment.ts', "import { createGateway } from 'ai';"),
|
||||
...scanFileContent('packages/context/src/core/local-embedding-provider.ts', "import { embedMany } from 'ai';"),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
violations.map((violation) => violation.kind),
|
||||
['llm-boundary', 'llm-boundary', 'llm-boundary'],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects old KLO LLM port declarations in context', () => {
|
||||
const violations = [
|
||||
...scanFileContent('packages/context/src/agent/agent-runner.service.ts', 'export interface LlmProviderPort {}'),
|
||||
...scanFileContent('packages/context/src/scan/types.ts', 'export interface KloScanLlmPort {}'),
|
||||
...scanFileContent('packages/context/src/agent/gateway-llm-provider.ts', 'export function createGatewayLlmProvider() {}'),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
violations.map((violation) => violation.kind),
|
||||
['llm-boundary', 'llm-boundary', 'llm-boundary'],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects getModelByName calls in context production source', () => {
|
||||
const violations = scanFileContent(
|
||||
'packages/context/src/ingest/page-triage/page-triage.service.ts',
|
||||
"const model = this.deps.llmProvider.getModelByName('claude-sonnet-4-6');",
|
||||
);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0]?.kind, 'llm-boundary');
|
||||
assert.equal(
|
||||
violations[0]?.message,
|
||||
'Forbidden context getModelByName call; use getModel(role) inside @klo/context',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows role-driven getModel calls, test calls, and provider shape declarations', () => {
|
||||
assert.deepEqual(
|
||||
scanFileContent(
|
||||
'packages/context/src/ingest/page-triage/page-triage.service.ts',
|
||||
"const model = this.deps.llmProvider.getModel('triage');",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
scanFileContent(
|
||||
'packages/context/src/ingest/page-triage/page-triage.service.test.ts',
|
||||
"const model = this.deps.llmProvider.getModelByName('test-model');",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
scanFileContent(
|
||||
'packages/context/src/scan/local-enrichment.ts',
|
||||
'return { getModel() { return model; }, getModelByName() { return model; } };',
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
70
scripts/ci-artifact-upload.test.mjs
Normal file
70
scripts/ci-artifact-upload.test.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const ciWorkflowPath = resolve(repoRoot, '.github', 'workflows', 'ci.yml');
|
||||
|
||||
async function readCiWorkflowOrSkip(testContext) {
|
||||
try {
|
||||
await access(ciWorkflowPath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
testContext.skip('root CI workflow is absent from sparse klo checkout');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readFile(ciWorkflowPath, 'utf-8');
|
||||
}
|
||||
|
||||
describe('KLO CI artifact upload contract', () => {
|
||||
it('uploads verified KLO package artifacts from check-klo-subtree', async (testContext) => {
|
||||
const workflow = await readCiWorkflowOrSkip(testContext);
|
||||
if (workflow === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/name: Build klo package artifacts and verify public smoke\s+run: cd klo && pnpm run artifacts:build && pnpm run artifacts:verify-manifest && pnpm run artifacts:verify-demo\s+- name: Upload klo package artifacts/s,
|
||||
);
|
||||
assert.match(workflow, /uses: actions\/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f/);
|
||||
assert.match(workflow, /name: klo-package-artifacts-\$\{\{ github\.sha \}\}/);
|
||||
assert.match(workflow, /klo\/dist\/artifacts\/manifest\.json/);
|
||||
assert.match(workflow, /klo\/dist\/artifacts\/npm\/\*\.tgz/);
|
||||
assert.match(workflow, /klo\/dist\/artifacts\/python\/\*\.whl/);
|
||||
assert.match(workflow, /klo\/dist\/artifacts\/python\/\*\.tar\.gz/);
|
||||
assert.match(workflow, /if-no-files-found: error/);
|
||||
assert.match(workflow, /retention-days: 7/);
|
||||
});
|
||||
|
||||
it('runs packed demo artifact smoke on Linux and macOS', async (testContext) => {
|
||||
const workflow = await readCiWorkflowOrSkip(testContext);
|
||||
if (workflow === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.match(workflow, /check-klo-packed-demo:/);
|
||||
assert.match(workflow, /matrix:\s+os: \[ubuntu-latest, macos-latest\]/s);
|
||||
assert.match(workflow, /name: Download klo package artifacts/);
|
||||
assert.match(workflow, /path: klo\/dist\/artifacts/);
|
||||
assert.match(workflow, /run: cd klo && pnpm run artifacts:verify-demo/);
|
||||
});
|
||||
|
||||
it('includes packed demo artifact smoke in ci-success', async (testContext) => {
|
||||
const workflow = await readCiWorkflowOrSkip(testContext);
|
||||
if (workflow === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/needs: \[check-klo-subtree, check-klo-packed-demo, build-python-service, test-server, build-frontend, run-pre-commit, build-docker-images\]/,
|
||||
);
|
||||
assert.match(workflow, /needs\.check-klo-packed-demo\.result.*== "failure"/);
|
||||
assert.match(workflow, /needs\.check-klo-packed-demo\.result.*== "cancelled"/);
|
||||
});
|
||||
});
|
||||
174
scripts/examples-docs.test.mjs
Normal file
174
scripts/examples-docs.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
async function readText(relativePath) {
|
||||
return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
describe('standalone example docs', () => {
|
||||
it('documents the local warehouse example from the examples index', async () => {
|
||||
const examples = await readText('examples/README.md');
|
||||
|
||||
assert.match(examples, /local-warehouse/);
|
||||
assert.match(examples, /fake ingest adapter/);
|
||||
assert.doesNotMatch(examples, /will contain standalone examples/);
|
||||
});
|
||||
|
||||
it('documents the Orbit relationship verification example project', async () => {
|
||||
const examples = await readText('examples/README.md');
|
||||
const readme = await readText('examples/orbit-relationship-verification/README.md');
|
||||
const config = await readText('examples/orbit-relationship-verification/klo.yaml');
|
||||
|
||||
assert.match(examples, /orbit-relationship-verification/);
|
||||
assert.match(examples, /relationships:verify-orbit/);
|
||||
assert.match(readme, /Orbit-style relationship discovery verification/);
|
||||
assert.match(readme, /pnpm run relationships:verify-orbit/);
|
||||
assert.match(readme, /Accepted: 9/);
|
||||
assert.match(readme, /Review: 0/);
|
||||
assert.match(readme, /Rejected: 0/);
|
||||
assert.match(config, /project: orbit-relationship-verification/);
|
||||
assert.match(config, /orbit:/);
|
||||
assert.match(config, /driver: sqlite/);
|
||||
assert.match(
|
||||
config,
|
||||
/path: \.\.\/\.\.\/packages\/context\/test\/fixtures\/relationship-benchmarks\/orbit_style_product_no_declared_constraints\/data\.sqlite/,
|
||||
);
|
||||
assert.match(config, /readonly: true/);
|
||||
assert.match(config, /llm_proposals: false/);
|
||||
assert.match(config, /validation_required_for_manifest: true/);
|
||||
});
|
||||
|
||||
it('documents the Postgres historic SQL smoke example', async () => {
|
||||
const examples = await readText('examples/README.md');
|
||||
const readme = await readText('examples/postgres-historic/README.md');
|
||||
const compose = await readText('examples/postgres-historic/docker-compose.yml');
|
||||
const initSql = await readText('examples/postgres-historic/init/001-schema.sql');
|
||||
const workload = await readText('examples/postgres-historic/scripts/generate-workload.sh');
|
||||
const smoke = await readText('examples/postgres-historic/scripts/smoke.sh');
|
||||
|
||||
assert.match(examples, /postgres-historic/);
|
||||
assert.match(examples, /pg_stat_statements/);
|
||||
assert.match(readme, /--enable-historic-sql/);
|
||||
assert.match(readme, /--historic-sql-min-calls 2/);
|
||||
assert.match(readme, /klo dev doctor --project-dir/);
|
||||
assert.match(readme, /Postgres Historic SQL/);
|
||||
assert.match(readme, /dev ingest run/);
|
||||
assert.match(compose, /postgres:14/);
|
||||
assert.match(compose, /shared_preload_libraries=pg_stat_statements/);
|
||||
assert.match(compose, /pg_stat_statements.track=top/);
|
||||
assert.match(initSql, /CREATE EXTENSION IF NOT EXISTS pg_stat_statements/);
|
||||
assert.match(initSql, /GRANT pg_read_all_stats TO klo_reader/);
|
||||
assert.match(workload, /JOIN customers/);
|
||||
assert.match(workload, /app_user/);
|
||||
assert.match(workload, /etl_user/);
|
||||
assert.match(smoke, /pg_stat_statements_reset/);
|
||||
assert.match(smoke, /assert_manifest "\$FIRST_MANIFEST" true/);
|
||||
assert.match(smoke, /assert_manifest "\$SECOND_MANIFEST" false/);
|
||||
assert.match(smoke, /assert_manifest "\$RESET_MANIFEST" true/);
|
||||
});
|
||||
|
||||
it('lists every published TypeScript package in the package root README', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
assert.match(rootReadme, /`packages\/context`/);
|
||||
assert.match(rootReadme, /`packages\/cli`/);
|
||||
assert.match(rootReadme, /`packages\/connector-bigquery`/);
|
||||
assert.match(rootReadme, /`packages\/connector-clickhouse`/);
|
||||
assert.match(rootReadme, /`packages\/connector-mysql`/);
|
||||
assert.match(rootReadme, /`packages\/connector-postgres`/);
|
||||
assert.match(rootReadme, /`packages\/connector-posthog`/);
|
||||
assert.match(rootReadme, /`packages\/connector-snowflake`/);
|
||||
assert.match(rootReadme, /`packages\/connector-sqlite`/);
|
||||
assert.match(rootReadme, /`packages\/connector-sqlserver`/);
|
||||
assert.match(rootReadme, /`python\/klo-sl`/);
|
||||
assert.match(rootReadme, /`python\/klo-daemon`/);
|
||||
});
|
||||
|
||||
it('documents every standalone MCP tool that the CLI server exposes', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
assert.match(rootReadme, /`connection_list`/);
|
||||
assert.match(rootReadme, /`knowledge_search`/);
|
||||
assert.match(rootReadme, /`knowledge_read`/);
|
||||
assert.match(rootReadme, /`knowledge_write`/);
|
||||
assert.match(rootReadme, /`sl_list_sources`/);
|
||||
assert.match(rootReadme, /`sl_read_source`/);
|
||||
assert.match(rootReadme, /`sl_write_source`/);
|
||||
assert.match(rootReadme, /`sl_validate`/);
|
||||
assert.match(rootReadme, /`sl_query`/);
|
||||
assert.match(rootReadme, /`ingest_trigger`/);
|
||||
assert.match(rootReadme, /`ingest_status`/);
|
||||
assert.match(rootReadme, /`ingest_report`/);
|
||||
assert.match(rootReadme, /`ingest_replay`/);
|
||||
});
|
||||
|
||||
it('walks through klo connection list and klo connection test in the README quickstart', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
assert.match(rootReadme, /connection list --project-dir/);
|
||||
assert.match(rootReadme, /connection test warehouse --project-dir/);
|
||||
assert.match(rootReadme, /Driver: sqlite/);
|
||||
assert.match(rootReadme, /Tables: 1/);
|
||||
});
|
||||
|
||||
it('replaces the fake-ingest smoke with a klo scan walkthrough in the README', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
assert.match(rootReadme, /### Scan the demo warehouse/);
|
||||
assert.match(rootReadme, /scan warehouse --project-dir/);
|
||||
assert.match(rootReadme, /scan status --project-dir/);
|
||||
assert.match(rootReadme, /scan report --project-dir/);
|
||||
assert.match(rootReadme, /raw-sources\/warehouse\/live-database/);
|
||||
assert.doesNotMatch(rootReadme, /Run a local ingest smoke test/);
|
||||
assert.doesNotMatch(rootReadme, /klo dev ingest run --project-dir/);
|
||||
assert.doesNotMatch(rootReadme, /klo ingest status --project-dir/);
|
||||
});
|
||||
|
||||
it('documents pnpm setup as a prerequisite when optional dev linking fails', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
assert.match(rootReadme, /pnpm run link:dev/);
|
||||
assert.match(rootReadme, /klo-dev --help/);
|
||||
assert.doesNotMatch(
|
||||
rootReadme,
|
||||
/If the setup command reports that pnpm's global bin directory is not on your\n`PATH`, add the printed directory to your shell profile/,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the example smoke in the cli smoke script', async () => {
|
||||
const packageJson = JSON.parse(await readText('packages/cli/package.json'));
|
||||
|
||||
assert.match(packageJson.scripts.smoke, /src\/standalone-smoke\.test\.ts/);
|
||||
assert.match(packageJson.scripts.smoke, /src\/example-smoke\.test\.ts/);
|
||||
});
|
||||
|
||||
it('documents daemon HTTP database, source generation, LookML, embedding, and code execution support', async () => {
|
||||
const readme = await readText('python/klo-daemon/README.md');
|
||||
|
||||
assert.match(readme, /semantic-generate-sources/);
|
||||
assert.match(readme, /database-introspect/);
|
||||
assert.match(readme, /POST \/database\/introspect/);
|
||||
assert.match(readme, /Introspect a Postgres database schema/);
|
||||
assert.match(readme, /lookml-parse/);
|
||||
assert.match(readme, /embedding-compute/);
|
||||
assert.match(readme, /embedding-compute-bulk/);
|
||||
assert.match(readme, /code-execute/);
|
||||
assert.match(readme, /--enable-code-execution/);
|
||||
assert.match(readme, /POST \/semantic-layer\/generate-sources/);
|
||||
assert.match(readme, /POST \/lookml\/parse/);
|
||||
assert.match(readme, /POST \/embeddings\/compute/);
|
||||
assert.match(readme, /POST \/embeddings\/compute-bulk/);
|
||||
assert.match(readme, /POST \/code\/execute/);
|
||||
assert.match(readme, /Generate semantic-layer sources from schema scan data/);
|
||||
assert.match(readme, /Parse LookML projects into resolved, KSL-ready structures/);
|
||||
assert.match(readme, /Compute text embeddings locally/);
|
||||
assert.match(readme, /Execute Python code with the current in-process boundary/);
|
||||
assert.match(readme, /Code execution is off by default/);
|
||||
assert.match(readme, /does not provide OS-level sandboxing/);
|
||||
assert.doesNotMatch(readme, /source generation are not exposed through this/);
|
||||
assert.doesNotMatch(readme, /LookML parsing are not exposed through this/);
|
||||
assert.doesNotMatch(readme, /embeddings are not exposed through this server mode/);
|
||||
assert.doesNotMatch(readme, /Code execution is not exposed through this server mode/);
|
||||
});
|
||||
});
|
||||
432
scripts/installed-live-database-smoke.mjs
Normal file
432
scripts/installed-live-database-smoke.mjs
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
findPythonArtifacts,
|
||||
npmSmokePackageJson,
|
||||
npmSmokePythonEnv,
|
||||
packageArtifactLayout,
|
||||
pythonArtifactInstallArgs,
|
||||
} from './package-artifacts.mjs';
|
||||
|
||||
const POSTGRES_IMAGE = process.env.KLO_ARTIFACT_POSTGRES_IMAGE ?? 'postgres:16-alpine';
|
||||
const POSTGRES_USER = 'klo';
|
||||
const POSTGRES_PASSWORD = 'postgres'; // pragma: allowlist secret
|
||||
const POSTGRES_DB = 'warehouse';
|
||||
|
||||
export function smokeContainerName(pid = process.pid, now = Date.now()) {
|
||||
return `klo-live-db-smoke-${pid}-${now}`;
|
||||
}
|
||||
|
||||
export function buildPostgresUrl(hostPort) {
|
||||
return `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${hostPort}/${POSTGRES_DB}`; // pragma: allowlist secret
|
||||
}
|
||||
|
||||
export function buildDockerRunArgs({ containerName, hostPort, image = POSTGRES_IMAGE }) {
|
||||
return [
|
||||
'run',
|
||||
'--rm',
|
||||
'-d',
|
||||
'--name',
|
||||
containerName,
|
||||
'-e',
|
||||
`POSTGRES_PASSWORD=${POSTGRES_PASSWORD}`,
|
||||
'-e',
|
||||
`POSTGRES_USER=${POSTGRES_USER}`,
|
||||
'-e',
|
||||
`POSTGRES_DB=${POSTGRES_DB}`,
|
||||
'-p',
|
||||
`127.0.0.1:${hostPort}:5432`,
|
||||
image,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildPostgresReadyArgs(containerName) {
|
||||
return [
|
||||
'exec',
|
||||
containerName,
|
||||
'psql',
|
||||
'-U',
|
||||
POSTGRES_USER,
|
||||
'-d',
|
||||
POSTGRES_DB,
|
||||
'-v',
|
||||
'ON_ERROR_STOP=1',
|
||||
'-c',
|
||||
'SELECT 1;',
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSeedSql() {
|
||||
return [
|
||||
'DROP TABLE IF EXISTS orders;',
|
||||
'DROP TABLE IF EXISTS customers;',
|
||||
'CREATE TABLE customers (',
|
||||
' id integer PRIMARY KEY,',
|
||||
' name text NOT NULL',
|
||||
');',
|
||||
"COMMENT ON TABLE customers IS 'Customers captured by the artifact smoke';",
|
||||
"COMMENT ON COLUMN customers.name IS 'Customer display name';",
|
||||
'CREATE TABLE orders (',
|
||||
' id integer PRIMARY KEY,',
|
||||
' customer_id integer NOT NULL REFERENCES customers(id),',
|
||||
' status text NOT NULL,',
|
||||
' amount integer NOT NULL',
|
||||
');',
|
||||
"COMMENT ON TABLE orders IS 'Orders captured by the artifact smoke';",
|
||||
"COMMENT ON COLUMN orders.amount IS 'Order amount in cents';",
|
||||
"INSERT INTO customers (id, name) VALUES (1, 'Acme'), (2, 'Globex');",
|
||||
"INSERT INTO orders (id, customer_id, status, amount) VALUES (10, 1, 'paid', 2000), (11, 2, 'open', 3500);",
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildKloYaml(postgresUrl) {
|
||||
return [
|
||||
'project: artifact-live-database',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
` url: "${postgresUrl}"`,
|
||||
' readonly: true',
|
||||
'storage:',
|
||||
' state: sqlite',
|
||||
' search: sqlite-fts5',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl) {
|
||||
return [
|
||||
'exec',
|
||||
'klo',
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--adapter',
|
||||
'live-database',
|
||||
'--database-introspection-url',
|
||||
databaseIntrospectionUrl,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildLiveDatabaseStatusArgs(projectDir, runId) {
|
||||
return ['exec', 'klo', 'ingest', 'status', '--project-dir', projectDir, runId];
|
||||
}
|
||||
|
||||
async function run(command, args, options = {}) {
|
||||
process.stdout.write(`$ ${command} ${args.join(' ')}\n`);
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
timeout: options.timeout ?? 60_000,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (stdout) {
|
||||
process.stdout.write(stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
resolve({
|
||||
code: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
|
||||
stdout,
|
||||
stderr: stderr || (error instanceof Error ? error.message : ''),
|
||||
});
|
||||
},
|
||||
);
|
||||
if (options.input !== undefined) {
|
||||
child.stdin?.end(options.input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requireSuccess(label, result) {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`${label} failed with code ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireOutput(label, result, pattern) {
|
||||
if (!pattern.test(result.stdout)) {
|
||||
throw new Error(`${label} output did not match ${pattern}\nstdout:\n${result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getRunId(stdout) {
|
||||
const match = stdout.match(/^Run: (.+)$/m);
|
||||
if (!match) {
|
||||
throw new Error(`ingest run output did not include a run id\nstdout:\n${stdout}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function requireDocker() {
|
||||
const result = await run('docker', ['info'], { timeout: 20_000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
'Docker is required for the installed live-database artifact smoke. Start Docker and rerun `pnpm run artifacts:live-db-smoke`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailablePort() {
|
||||
const server = createServer();
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
throw new Error('expected TCP server address');
|
||||
}
|
||||
const port = address.port;
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
return port;
|
||||
}
|
||||
|
||||
async function startPostgresContainer(containerName, hostPort) {
|
||||
await requireDocker();
|
||||
const result = await run('docker', buildDockerRunArgs({ containerName, hostPort }), { timeout: 120_000 });
|
||||
requireSuccess('docker run postgres', result);
|
||||
}
|
||||
|
||||
async function stopPostgresContainer(containerName) {
|
||||
await run('docker', ['rm', '-f', containerName], { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function waitForPostgres(containerName) {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await run('docker', buildPostgresReadyArgs(containerName), { timeout: 10_000 });
|
||||
if (result.code === 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Postgres container ${containerName}`);
|
||||
}
|
||||
|
||||
async function seedPostgres(containerName) {
|
||||
const result = await run(
|
||||
'docker',
|
||||
['exec', '-i', containerName, 'psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-v', 'ON_ERROR_STOP=1'],
|
||||
{ input: buildSeedSql(), timeout: 30_000 },
|
||||
);
|
||||
requireSuccess('seed postgres catalog', result);
|
||||
}
|
||||
|
||||
function httpGetOk(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(url, { method: 'GET' }, (response) => {
|
||||
response.resume();
|
||||
response.on('end', () => resolve((response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300));
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function spawnLogged(command, args, options = {}) {
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
let spawnError;
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.on('error', (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
return {
|
||||
child,
|
||||
error() {
|
||||
return spawnError;
|
||||
},
|
||||
output() {
|
||||
return {
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHttpHealth(url, daemon) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (daemon.error()) {
|
||||
const output = daemon.output();
|
||||
throw new Error(
|
||||
`Failed to start klo-daemon: ${daemon.error().message}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`,
|
||||
);
|
||||
}
|
||||
if (daemon.child.exitCode !== null || daemon.child.signalCode !== null) {
|
||||
const output = daemon.output();
|
||||
throw new Error(`klo-daemon exited before health check passed\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`);
|
||||
}
|
||||
try {
|
||||
if (await httpGetOk(url)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
const output = daemon.output();
|
||||
throw new Error(`Timed out waiting for ${url}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`);
|
||||
}
|
||||
|
||||
async function startDaemon(port, cleanInstallDir) {
|
||||
const daemon = spawnLogged(
|
||||
'klo-daemon',
|
||||
['serve-http', '--host', '127.0.0.1', '--port', String(port), '--log-level', 'warning'],
|
||||
{ cwd: cleanInstallDir, env: npmSmokePythonEnv(cleanInstallDir) },
|
||||
);
|
||||
await waitForHttpHealth(`http://127.0.0.1:${port}/health`, daemon);
|
||||
return daemon;
|
||||
}
|
||||
|
||||
async function stopDaemon(daemon) {
|
||||
if (daemon.child.exitCode !== null || daemon.child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
daemon.child.kill('SIGTERM');
|
||||
const closed = once(daemon.child, 'close').then(() => true);
|
||||
const timedOut = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
|
||||
if (!(await Promise.race([closed, timedOut]))) {
|
||||
daemon.child.kill('SIGKILL');
|
||||
await once(daemon.child, 'close');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPathExists(path, label) {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
throw new Error(`Missing ${label}: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareCleanInstall(layout, cleanInstallDir) {
|
||||
const pythonArtifacts = await findPythonArtifacts(layout.pythonDir);
|
||||
await assertPathExists(layout.contextTarball, '@klo/context tarball');
|
||||
await assertPathExists(layout.cliTarball, '@klo/cli tarball');
|
||||
await mkdir(cleanInstallDir, { recursive: true });
|
||||
await writeFile(join(cleanInstallDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
|
||||
await run('pnpm', ['install'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
|
||||
requireSuccess('pnpm install clean artifact project', result),
|
||||
);
|
||||
await run('uv', ['venv', '.venv'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
|
||||
requireSuccess('uv venv clean artifact project', result),
|
||||
);
|
||||
await run(
|
||||
'uv',
|
||||
pythonArtifactInstallArgs(
|
||||
join(cleanInstallDir, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'),
|
||||
pythonArtifacts,
|
||||
),
|
||||
{
|
||||
cwd: cleanInstallDir,
|
||||
timeout: 120_000,
|
||||
},
|
||||
).then((result) => requireSuccess('install Python artifacts', result));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const layout = packageArtifactLayout();
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-live-db-artifact-smoke-'));
|
||||
const containerName = smokeContainerName();
|
||||
let daemon;
|
||||
try {
|
||||
const postgresPort = await getAvailablePort();
|
||||
const daemonPort = await getAvailablePort();
|
||||
const postgresUrl = buildPostgresUrl(postgresPort);
|
||||
const cleanInstallDir = join(root, 'npm-clean-install');
|
||||
const projectDir = join(root, 'project');
|
||||
const databaseIntrospectionUrl = `http://127.0.0.1:${daemonPort}`;
|
||||
|
||||
await startPostgresContainer(containerName, postgresPort);
|
||||
await waitForPostgres(containerName);
|
||||
await seedPostgres(containerName);
|
||||
await prepareCleanInstall(layout, cleanInstallDir);
|
||||
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
const init = await run('pnpm', ['exec', 'klo', 'init', projectDir, '--name', 'artifact-live-database'], {
|
||||
cwd: cleanInstallDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
requireSuccess('klo init', init);
|
||||
await writeFile(join(projectDir, 'klo.yaml'), buildKloYaml(postgresUrl), 'utf8');
|
||||
|
||||
daemon = await startDaemon(daemonPort, cleanInstallDir);
|
||||
|
||||
const ingestRun = await run('pnpm', buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl), {
|
||||
cwd: cleanInstallDir,
|
||||
env: npmSmokePythonEnv(cleanInstallDir),
|
||||
timeout: 120_000,
|
||||
});
|
||||
requireSuccess('klo dev ingest run live-database', ingestRun);
|
||||
requireOutput('klo dev ingest run live-database', ingestRun, /Status: done/);
|
||||
requireOutput('klo dev ingest run live-database', ingestRun, /Adapter: live-database/);
|
||||
requireOutput('klo dev ingest run live-database', ingestRun, /Diff: \+4\/~0\/-0\/=0/);
|
||||
requireOutput('klo dev ingest run live-database', ingestRun, /Raw files: 4/);
|
||||
requireOutput('klo dev ingest run live-database', ingestRun, /Work units: 2/);
|
||||
|
||||
const runId = getRunId(ingestRun.stdout);
|
||||
const ingestStatus = await run('pnpm', buildLiveDatabaseStatusArgs(projectDir, runId), {
|
||||
cwd: cleanInstallDir,
|
||||
env: npmSmokePythonEnv(cleanInstallDir),
|
||||
timeout: 30_000,
|
||||
});
|
||||
requireSuccess('klo ingest status live-database', ingestStatus);
|
||||
requireOutput('klo ingest status live-database', ingestStatus, new RegExp(`Run: ${runId}`));
|
||||
requireOutput('klo ingest status live-database', ingestStatus, /Status: done/);
|
||||
requireOutput('klo ingest status live-database', ingestStatus, /Raw files: 4/);
|
||||
requireOutput('klo ingest status live-database', ingestStatus, /Work units: 2/);
|
||||
await assertPathExists(join(projectDir, '.klo', 'db.sqlite'), 'SQLite local ingest state');
|
||||
process.stdout.write(`Installed live-database artifact smoke passed: ${runId}\n`);
|
||||
} finally {
|
||||
if (daemon) {
|
||||
await stopDaemon(daemon);
|
||||
}
|
||||
await stopPostgresContainer(containerName);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
128
scripts/installed-live-database-smoke.test.mjs
Normal file
128
scripts/installed-live-database-smoke.test.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildDockerRunArgs,
|
||||
buildKloYaml,
|
||||
buildLiveDatabaseIngestArgs,
|
||||
buildLiveDatabaseStatusArgs,
|
||||
buildPostgresUrl,
|
||||
buildPostgresReadyArgs,
|
||||
buildSeedSql,
|
||||
smokeContainerName,
|
||||
} from './installed-live-database-smoke.mjs';
|
||||
|
||||
describe('installed live-database artifact smoke helpers', () => {
|
||||
it('builds a deterministic disposable Postgres container command', () => {
|
||||
assert.deepEqual(
|
||||
buildDockerRunArgs({
|
||||
containerName: 'klo-live-db-smoke-test',
|
||||
hostPort: 15432,
|
||||
image: 'postgres:16-alpine',
|
||||
}),
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-d',
|
||||
'--name',
|
||||
'klo-live-db-smoke-test',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=postgres', // pragma: allowlist secret
|
||||
'-e',
|
||||
'POSTGRES_USER=klo',
|
||||
'-e',
|
||||
'POSTGRES_DB=warehouse',
|
||||
'-p',
|
||||
'127.0.0.1:15432:5432',
|
||||
'postgres:16-alpine',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a collision-resistant Docker container name prefix', () => {
|
||||
assert.match(smokeContainerName(1234, 5678), /^klo-live-db-smoke-1234-5678$/);
|
||||
});
|
||||
|
||||
it('builds the Postgres URL used by klo.yaml and daemon introspection', () => {
|
||||
assert.equal(
|
||||
buildPostgresUrl(15432),
|
||||
'postgresql://klo:postgres@127.0.0.1:15432/warehouse', // pragma: allowlist secret
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a live-database-only KLO project config with SQLite local state', () => {
|
||||
assert.equal(
|
||||
buildKloYaml('postgresql://klo:postgres@127.0.0.1:15432/warehouse'), // pragma: allowlist secret
|
||||
[
|
||||
'project: artifact-live-database',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
' url: "postgresql://klo:postgres@127.0.0.1:15432/warehouse"', // pragma: allowlist secret
|
||||
' readonly: true',
|
||||
'storage:',
|
||||
' state: sqlite',
|
||||
' search: sqlite-fts5',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds comments and a foreign key for daemon catalog introspection', () => {
|
||||
const sql = buildSeedSql();
|
||||
|
||||
assert.match(sql, /CREATE TABLE customers/);
|
||||
assert.match(sql, /CREATE TABLE orders/);
|
||||
assert.match(sql, /REFERENCES customers\(id\)/);
|
||||
assert.match(sql, /COMMENT ON TABLE orders IS 'Orders captured by the artifact smoke'/);
|
||||
assert.match(sql, /COMMENT ON COLUMN orders.amount IS 'Order amount in cents'/);
|
||||
assert.match(sql, /INSERT INTO orders/);
|
||||
});
|
||||
|
||||
it('waits for a real SQL connection to the target Postgres database', () => {
|
||||
assert.deepEqual(buildPostgresReadyArgs('klo-live-db-smoke-test'), [
|
||||
'exec',
|
||||
'klo-live-db-smoke-test',
|
||||
'psql',
|
||||
'-U',
|
||||
'klo',
|
||||
'-d',
|
||||
'warehouse',
|
||||
'-v',
|
||||
'ON_ERROR_STOP=1',
|
||||
'-c',
|
||||
'SELECT 1;',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds installed CLI live-database ingest and status commands', () => {
|
||||
assert.deepEqual(buildLiveDatabaseIngestArgs('/tmp/project', 'http://127.0.0.1:8765'), [
|
||||
'exec',
|
||||
'klo',
|
||||
'dev',
|
||||
'ingest',
|
||||
'run',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--adapter',
|
||||
'live-database',
|
||||
'--database-introspection-url',
|
||||
'http://127.0.0.1:8765',
|
||||
]);
|
||||
|
||||
assert.deepEqual(buildLiveDatabaseStatusArgs('/tmp/project', 'local-run-1'), [
|
||||
'exec',
|
||||
'klo',
|
||||
'ingest',
|
||||
'status',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'local-run-1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
197
scripts/link-dev-cli.mjs
Normal file
197
scripts/link-dev-cli.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import { access as fsAccess, chmod as fsChmod, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { ensureCliBinExecutable, kloRootDir } from './prepare-cli-bin.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function hasFlag(flag) {
|
||||
return process.argv.includes(flag);
|
||||
}
|
||||
|
||||
function optionValue(flag, fallback) {
|
||||
const index = process.argv.indexOf(flag);
|
||||
if (index === -1) {
|
||||
return fallback;
|
||||
}
|
||||
const value = process.argv[index + 1];
|
||||
if (!value || value.startsWith('-')) {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function commandEnv(extraPath) {
|
||||
if (!extraPath) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
PATH: `${extraPath}${delimiter}${process.env.PATH ?? ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function execText(command, args, options = {}) {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return `${result.stdout}${result.stderr}`.trim();
|
||||
}
|
||||
|
||||
async function optionalText(command, args, options = {}) {
|
||||
try {
|
||||
return await execText(command, args, options);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function findPnpmGlobalBin() {
|
||||
const output = await optionalText('pnpm', ['bin', '--global']);
|
||||
return output.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? '';
|
||||
}
|
||||
|
||||
function shellDoubleQuote(value) {
|
||||
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"`;
|
||||
}
|
||||
|
||||
function assertBinaryName(binaryName) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(binaryName)) {
|
||||
throw new Error(`Invalid binary name: ${binaryName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writePinnedPosixLauncher(globalBin, binPath, binaryName, writeFile, chmod) {
|
||||
const launcherPath = join(globalBin, binaryName);
|
||||
const script = [
|
||||
'#!/bin/sh',
|
||||
'# Generated by `pnpm run link:dev` in the KLO workspace.',
|
||||
'# Keep this launcher pinned to the Node binary that built native dependencies.',
|
||||
`exec ${shellDoubleQuote(process.execPath)} ${shellDoubleQuote(binPath)} "$@"`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await writeFile(launcherPath, script, 'utf-8');
|
||||
await chmod(launcherPath, 0o755);
|
||||
return launcherPath;
|
||||
}
|
||||
|
||||
async function writePinnedWindowsLauncher(globalBin, binPath, binaryName, writeFile) {
|
||||
const launcherPath = join(globalBin, `${binaryName}.cmd`);
|
||||
const script = [
|
||||
'@echo off',
|
||||
'REM Generated by `pnpm run link:dev` in the KLO workspace.',
|
||||
`"${process.execPath}" "${binPath}" %*`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
await writeFile(launcherPath, script, 'utf-8');
|
||||
return launcherPath;
|
||||
}
|
||||
|
||||
async function writePinnedLauncher(globalBin, binPath, binaryName, deps) {
|
||||
if (!globalBin) {
|
||||
throw new Error('Could not find pnpm global bin directory. Run `pnpm setup`, restart your shell, then retry.');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return writePinnedWindowsLauncher(globalBin, binPath, binaryName, deps.writeFile);
|
||||
}
|
||||
|
||||
return writePinnedPosixLauncher(globalBin, binPath, binaryName, deps.writeFile, deps.chmod);
|
||||
}
|
||||
|
||||
async function verifyBinaryOnPath(binaryName, globalBin, execTextFn) {
|
||||
try {
|
||||
const output = await execTextFn(binaryName, ['--version']);
|
||||
return { ok: true, output };
|
||||
} catch (error) {
|
||||
if (!globalBin) {
|
||||
return { ok: false, output: '', error };
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await execTextFn(binaryName, ['--version'], { env: commandEnv(globalBin) });
|
||||
return { ok: false, output, globalBin, error };
|
||||
} catch {
|
||||
return { ok: false, output: '', error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBuiltCli(rootDir, access, binPathOverride) {
|
||||
const binPath = binPathOverride ?? (await ensureCliBinExecutable(rootDir));
|
||||
await access(binPath, constants.X_OK);
|
||||
return binPath;
|
||||
}
|
||||
|
||||
export async function linkDevCli(options = {}) {
|
||||
const rootDir = options.rootDir ?? kloRootDir();
|
||||
const binaryName = options.binaryName ?? 'klo-dev';
|
||||
const access = options.access ?? fsAccess;
|
||||
const chmod = options.chmod ?? fsChmod;
|
||||
const writeFile = options.writeFile ?? fsWriteFile;
|
||||
const execTextFn = options.execText ?? execText;
|
||||
assertBinaryName(binaryName);
|
||||
|
||||
const binPath = await assertBuiltCli(rootDir, access, options.binPath);
|
||||
const globalBin = options.globalBin ?? (await findPnpmGlobalBin());
|
||||
|
||||
if (options.checkOnly) {
|
||||
return {
|
||||
binaryName,
|
||||
binPath,
|
||||
linked: false,
|
||||
verification: await verifyBinaryOnPath(binaryName, globalBin, execTextFn),
|
||||
};
|
||||
}
|
||||
|
||||
const launcherPath = await writePinnedLauncher(globalBin, binPath, binaryName, { writeFile, chmod });
|
||||
const verification = await verifyBinaryOnPath(binaryName, globalBin, execTextFn);
|
||||
if (!verification.ok) {
|
||||
const pathHint = verification.globalBin
|
||||
? `\nAdd pnpm's global bin directory to PATH, then retry:\n\n export PATH="${verification.globalBin}:$PATH"\n\n`
|
||||
: '\nRun `pnpm setup`, restart your shell, then rerun `pnpm run link:dev`.\n\n';
|
||||
|
||||
throw new Error(`${binaryName} was linked at ${launcherPath}, but it is not available on PATH.${pathHint}`);
|
||||
}
|
||||
|
||||
return {
|
||||
binaryName,
|
||||
binPath,
|
||||
launcherPath,
|
||||
linked: true,
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const result = await linkDevCli({
|
||||
checkOnly: hasFlag('--check-only'),
|
||||
binaryName: optionValue('--name', 'klo-dev'),
|
||||
});
|
||||
process.stdout.write(`KLO CLI bin: ${result.binPath}\n`);
|
||||
if (result.linked) {
|
||||
process.stdout.write(`Linked binary: ${result.binaryName}\n`);
|
||||
process.stdout.write(`Verified: ${result.verification.output}\n`);
|
||||
process.stdout.write(`Pinned Node: ${process.execPath} ${process.version} ABI ${process.versions.modules}\n`);
|
||||
process.stdout.write(`You can now run \`${result.binaryName} --help\` from any directory.\n`);
|
||||
} else if (result.verification.ok) {
|
||||
process.stdout.write(`Already available: ${result.verification.output}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${result.binaryName} is not linked on PATH yet.\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
45
scripts/link-dev-cli.test.mjs
Normal file
45
scripts/link-dev-cli.test.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { linkDevCli } from './link-dev-cli.mjs';
|
||||
|
||||
test('linkDevCli writes a klo-dev launcher by default', async () => {
|
||||
const writes = [];
|
||||
const chmods = [];
|
||||
|
||||
const result = await linkDevCli({
|
||||
rootDir: '/workspace/klo',
|
||||
globalBin: '/pnpm/bin',
|
||||
binPath: '/workspace/klo/packages/cli/dist/bin.js',
|
||||
execText: async (command, args) => {
|
||||
assert.equal(command, 'klo-dev');
|
||||
assert.deepEqual(args, ['--version']);
|
||||
return '@klo/cli 0.0.0-private';
|
||||
},
|
||||
writeFile: async (path, content) => writes.push({ path, content }),
|
||||
chmod: async (path, mode) => chmods.push({ path, mode }),
|
||||
access: async () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.binaryName, 'klo-dev');
|
||||
assert.equal(writes[0].path, '/pnpm/bin/klo-dev');
|
||||
assert.match(writes[0].content, /packages\/cli\/dist\/bin.js/);
|
||||
assert.deepEqual(chmods, [{ path: '/pnpm/bin/klo-dev', mode: 0o755 }]);
|
||||
});
|
||||
|
||||
test('linkDevCli can explicitly write klo when requested', async () => {
|
||||
const writes = [];
|
||||
|
||||
const result = await linkDevCli({
|
||||
rootDir: '/workspace/klo',
|
||||
binaryName: 'klo',
|
||||
globalBin: '/pnpm/bin',
|
||||
binPath: '/workspace/klo/packages/cli/dist/bin.js',
|
||||
execText: async () => '@klo/cli 0.0.0-private',
|
||||
writeFile: async (path, content) => writes.push({ path, content }),
|
||||
chmod: async () => undefined,
|
||||
access: async () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.binaryName, 'klo');
|
||||
assert.equal(writes[0].path, '/pnpm/bin/klo');
|
||||
});
|
||||
1686
scripts/package-artifacts.mjs
Normal file
1686
scripts/package-artifacts.mjs
Normal file
File diff suppressed because it is too large
Load diff
655
scripts/package-artifacts.test.mjs
Normal file
655
scripts/package-artifacts.test.mjs
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
artifactManifestPath,
|
||||
buildArtifactCommands,
|
||||
findPythonArtifacts,
|
||||
NPM_ARTIFACT_PACKAGES,
|
||||
npmDemoSmokeSource,
|
||||
npmRuntimeSmokeSource,
|
||||
npmSmokePackageJson,
|
||||
npmSmokePythonEnv,
|
||||
npmVerifySource,
|
||||
packageArtifactLayout,
|
||||
packageReleaseMetadata,
|
||||
pythonArtifactInstallArgs,
|
||||
pythonVerifySource,
|
||||
verifyArtifactManifest,
|
||||
writeArtifactManifest,
|
||||
} from './package-artifacts.mjs';
|
||||
|
||||
const STALE_METABASE_UNSUPPORTED = ['Standalone Metabase scheduled fetch', 'is intentionally unsupported'].join(' ');
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const CONNECTOR_PACKAGE_NAMES = [
|
||||
'@klo/connector-bigquery',
|
||||
'@klo/connector-clickhouse',
|
||||
'@klo/connector-mysql',
|
||||
'@klo/connector-postgres',
|
||||
'@klo/connector-posthog',
|
||||
'@klo/connector-snowflake',
|
||||
'@klo/connector-sqlite',
|
||||
'@klo/connector-sqlserver',
|
||||
];
|
||||
|
||||
function packageRootForName(packageName) {
|
||||
return `packages/${packageName.replace('@klo/', '')}`;
|
||||
}
|
||||
|
||||
function expectedNpmArtifactPath(packageName) {
|
||||
return `npm/${packageName.replace('@klo/', 'klo-')}-0.0.0-private.tgz`;
|
||||
}
|
||||
|
||||
async function writeReleaseMetadataInputs(root) {
|
||||
const npmPackages = ['@klo/context', '@klo/llm', ...CONNECTOR_PACKAGE_NAMES, '@klo/cli'];
|
||||
|
||||
for (const packageName of npmPackages) {
|
||||
const packageRoot = packageName === '@klo/context' ? 'packages/context' : packageRootForName(packageName);
|
||||
await mkdir(join(root, packageRoot), { recursive: true });
|
||||
await writeJson(join(root, packageRoot, 'package.json'), {
|
||||
name: packageName,
|
||||
version: '0.0.0-private',
|
||||
private: true,
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(join(root, 'python', 'klo-sl'), { recursive: true });
|
||||
await mkdir(join(root, 'python', 'klo-daemon'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'python', 'klo-sl', 'pyproject.toml'),
|
||||
['[project]', 'name = "klo-sl"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, 'python', 'klo-daemon', 'pyproject.toml'),
|
||||
['[project]', 'name = "klo-daemon"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeUploadableArtifactFixtures(layout) {
|
||||
await mkdir(layout.npmDir, { recursive: true });
|
||||
await mkdir(layout.pythonDir, { recursive: true });
|
||||
|
||||
const fileContents = new Map([
|
||||
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
|
||||
layout.npmTarballs[packageInfo.name],
|
||||
`${packageInfo.name}-tarball`,
|
||||
]),
|
||||
[join(layout.pythonDir, 'klo_sl-0.1.0-py3-none-any.whl'), 'klo-sl-wheel'],
|
||||
[join(layout.pythonDir, 'klo_sl-0.1.0.tar.gz'), 'klo-sl-sdist'],
|
||||
[join(layout.pythonDir, 'klo_daemon-0.1.0-py3-none-any.whl'), 'klo-daemon-wheel'],
|
||||
[join(layout.pythonDir, 'klo_daemon-0.1.0.tar.gz'), 'klo-daemon-sdist'],
|
||||
]);
|
||||
|
||||
for (const [path, contents] of fileContents) {
|
||||
await writeFile(path, contents);
|
||||
}
|
||||
}
|
||||
|
||||
describe('packageArtifactLayout', () => {
|
||||
it('uses stable artifact paths under klo/dist/artifacts', () => {
|
||||
const layout = packageArtifactLayout('/repo/klo');
|
||||
|
||||
assert.equal(layout.artifactDir, '/repo/klo/dist/artifacts');
|
||||
assert.equal(layout.npmDir, '/repo/klo/dist/artifacts/npm');
|
||||
assert.equal(layout.pythonDir, '/repo/klo/dist/artifacts/python');
|
||||
assert.equal(layout.contextTarball, '/repo/klo/dist/artifacts/npm/klo-context-0.0.0-private.tgz');
|
||||
assert.equal(layout.cliTarball, '/repo/klo/dist/artifacts/npm/klo-cli-0.0.0-private.tgz');
|
||||
assert.equal(
|
||||
layout.connectorTarballs['@klo/connector-sqlite'],
|
||||
'/repo/klo/dist/artifacts/npm/klo-connector-sqlite-0.0.0-private.tgz',
|
||||
);
|
||||
assert.equal(
|
||||
layout.connectorTarballs['@klo/connector-postgres'],
|
||||
'/repo/klo/dist/artifacts/npm/klo-connector-postgres-0.0.0-private.tgz',
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(layout.npmTarballs),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildArtifactCommands', () => {
|
||||
it('builds all TypeScript packages before packing npm artifacts and builds both Python packages', () => {
|
||||
const layout = packageArtifactLayout('/repo/klo');
|
||||
const commands = buildArtifactCommands(layout);
|
||||
|
||||
assert.deepEqual(
|
||||
commands.slice(0, NPM_ARTIFACT_PACKAGES.length).map((command) => [command.command, command.args]),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ['pnpm', ['--filter', packageInfo.name, 'run', 'build']]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands
|
||||
.slice(NPM_ARTIFACT_PACKAGES.length, NPM_ARTIFACT_PACKAGES.length * 2)
|
||||
.map((command) => [command.command, command.args]),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
|
||||
'pnpm',
|
||||
['--filter', packageInfo.name, 'pack', '--out', layout.npmTarballs[packageInfo.name]],
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands.slice(NPM_ARTIFACT_PACKAGES.length * 2).map((command) => [command.command, command.args]),
|
||||
[
|
||||
['uv', ['build', '--package', 'klo-sl', '--out-dir', '/repo/klo/dist/artifacts/python']],
|
||||
['uv', ['build', '--package', 'klo-daemon', '--out-dir', '/repo/klo/dist/artifacts/python']],
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packageReleaseMetadata', () => {
|
||||
it('reads package identities and versions from package manifests', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-metadata-test-'));
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
|
||||
assert.deepEqual(await packageReleaseMetadata(root), [
|
||||
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
||||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageRoot: packageInfo.packageRoot,
|
||||
packageVersion: '0.0.0-private',
|
||||
private: true,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
})),
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-sl',
|
||||
packageRoot: 'python/klo-sl',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-daemon',
|
||||
packageRoot: 'python/klo-daemon',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPythonArtifacts', () => {
|
||||
it('finds one wheel and one source distribution for each Python package', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-test-'));
|
||||
try {
|
||||
await writeFile(join(root, 'klo_sl-0.1.0-py3-none-any.whl'), '');
|
||||
await writeFile(join(root, 'klo_sl-0.1.0.tar.gz'), '');
|
||||
await writeFile(join(root, 'klo_daemon-0.1.0-py3-none-any.whl'), '');
|
||||
await writeFile(join(root, 'klo_daemon-0.1.0.tar.gz'), '');
|
||||
|
||||
assert.deepEqual(await findPythonArtifacts(root), {
|
||||
kloSlWheel: join(root, 'klo_sl-0.1.0-py3-none-any.whl'),
|
||||
kloSlSdist: join(root, 'klo_sl-0.1.0.tar.gz'),
|
||||
kloDaemonWheel: join(root, 'klo_daemon-0.1.0-py3-none-any.whl'),
|
||||
kloDaemonSdist: join(root, 'klo_daemon-0.1.0.tar.gz'),
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when a required Python artifact is missing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-test-'));
|
||||
try {
|
||||
await assert.rejects(() => findPythonArtifacts(root), /Missing Python artifact: klo-sl wheel/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact manifest', () => {
|
||||
it('writes release metadata, source revision, checksums, and byte counts for every uploadable artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-manifest-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
||||
const manifest = await writeArtifactManifest(layout, new Date('2026-04-28T12:00:00.000Z'), {
|
||||
sourceRevision: 'abc123',
|
||||
});
|
||||
|
||||
assert.equal(artifactManifestPath(layout), join(root, 'dist', 'artifacts', 'manifest.json'));
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.equal(manifest.generatedAt, '2026-04-28T12:00:00.000Z');
|
||||
assert.equal(manifest.sourceRevision, 'abc123');
|
||||
assert.deepEqual(
|
||||
manifest.packages.filter((entry) => entry.ecosystem === 'npm'),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
||||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageRoot: packageInfo.packageRoot,
|
||||
packageVersion: '0.0.0-private',
|
||||
private: true,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.packages.filter((entry) => entry.ecosystem === 'python'),
|
||||
[
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-sl',
|
||||
packageRoot: 'python/klo-sl',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-daemon',
|
||||
packageRoot: 'python/klo-daemon',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.files
|
||||
.filter((file) => file.ecosystem === 'npm')
|
||||
.map((file) => ({
|
||||
artifactKind: file.artifactKind,
|
||||
ecosystem: file.ecosystem,
|
||||
packageName: file.packageName,
|
||||
packageVersion: file.packageVersion,
|
||||
path: file.path,
|
||||
}))
|
||||
.sort((left, right) => left.packageName.localeCompare(right.packageName)),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
||||
artifactKind: 'tarball',
|
||||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageVersion: '0.0.0-private',
|
||||
path: expectedNpmArtifactPath(packageInfo.name),
|
||||
})).sort((left, right) => left.packageName.localeCompare(right.packageName)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.files
|
||||
.filter((file) => file.ecosystem === 'python')
|
||||
.map((file) => ({
|
||||
artifactKind: file.artifactKind,
|
||||
ecosystem: file.ecosystem,
|
||||
packageName: file.packageName,
|
||||
packageVersion: file.packageVersion,
|
||||
path: file.path,
|
||||
})),
|
||||
[
|
||||
{
|
||||
artifactKind: 'wheel',
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-daemon',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/klo_daemon-0.1.0-py3-none-any.whl',
|
||||
},
|
||||
{
|
||||
artifactKind: 'sdist',
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-daemon',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/klo_daemon-0.1.0.tar.gz',
|
||||
},
|
||||
{
|
||||
artifactKind: 'wheel',
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-sl',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/klo_sl-0.1.0-py3-none-any.whl',
|
||||
},
|
||||
{
|
||||
artifactKind: 'sdist',
|
||||
ecosystem: 'python',
|
||||
packageName: 'klo-sl',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/klo_sl-0.1.0.tar.gz',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const sqliteEntry = manifest.files.find((file) => file.path === 'npm/klo-connector-sqlite-0.0.0-private.tgz');
|
||||
assert.ok(sqliteEntry);
|
||||
assert.equal(sqliteEntry.bytes, Buffer.byteLength('@klo/connector-sqlite-tarball'));
|
||||
assert.equal(sqliteEntry.sha256, createHash('sha256').update('@klo/connector-sqlite-tarball').digest('hex'));
|
||||
|
||||
const writtenManifest = JSON.parse(await readFile(artifactManifestPath(layout), 'utf-8'));
|
||||
assert.deepEqual(writtenManifest, manifest);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyArtifactManifest', () => {
|
||||
it('accepts a schema version 2 manifest that matches the artifact directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-verify-manifest-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
await writeArtifactManifest(layout, new Date('2026-04-28T12:00:00.000Z'), {
|
||||
sourceRevision: 'abc123',
|
||||
});
|
||||
|
||||
const manifest = await verifyArtifactManifest(layout, {
|
||||
expectedSourceRevision: 'abc123',
|
||||
});
|
||||
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.equal(manifest.sourceRevision, 'abc123');
|
||||
assert.equal(manifest.files.length, NPM_ARTIFACT_PACKAGES.length + 4);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a manifest when a file checksum has drifted', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-checksum-drift-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
await writeArtifactManifest(layout, new Date('2026-04-28T12:00:00.000Z'), {
|
||||
sourceRevision: 'abc123',
|
||||
});
|
||||
await writeFile(layout.contextTarball, 'changed-context-tarball');
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyArtifactManifest(layout),
|
||||
/Artifact manifest files do not match artifact contents/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a manifest with an unsafe artifact path', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-path-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
const manifest = await writeArtifactManifest(layout, new Date('2026-04-28T12:00:00.000Z'), {
|
||||
sourceRevision: 'abc123',
|
||||
});
|
||||
manifest.files[0].path = '../outside.tgz';
|
||||
await writeFile(artifactManifestPath(layout), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
await assert.rejects(() => verifyArtifactManifest(layout), /Unsafe artifact manifest path: \.\.\/outside\.tgz/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a manifest from the wrong source revision when one is required', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-artifacts-revision-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
await writeArtifactManifest(layout, new Date('2026-04-28T12:00:00.000Z'), {
|
||||
sourceRevision: 'abc123',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
verifyArtifactManifest(layout, {
|
||||
expectedSourceRevision: 'def456',
|
||||
}),
|
||||
/Artifact manifest sourceRevision mismatch: expected def456, got abc123/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pythonArtifactInstallArgs', () => {
|
||||
it('installs the built Python wheels by artifact path', () => {
|
||||
const args = pythonArtifactInstallArgs('/tmp/smoke/.venv/bin/python', {
|
||||
kloSlWheel: '/repo/klo/dist/artifacts/python/klo_sl-0.1.0-py3-none-any.whl',
|
||||
kloSlSdist: '/repo/klo/dist/artifacts/python/klo_sl-0.1.0.tar.gz',
|
||||
kloDaemonWheel: '/repo/klo/dist/artifacts/python/klo_daemon-0.1.0-py3-none-any.whl',
|
||||
kloDaemonSdist: '/repo/klo/dist/artifacts/python/klo_daemon-0.1.0.tar.gz',
|
||||
});
|
||||
|
||||
assert.deepEqual(args, [
|
||||
'pip',
|
||||
'install',
|
||||
'--python',
|
||||
'/tmp/smoke/.venv/bin/python',
|
||||
'/repo/klo/dist/artifacts/python/klo_sl-0.1.0-py3-none-any.whl',
|
||||
'/repo/klo/dist/artifacts/python/klo_daemon-0.1.0-py3-none-any.whl',
|
||||
]);
|
||||
assert.equal(args.includes('klo-daemon'), false);
|
||||
assert.equal(args.includes('--find-links'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('npmSmokePythonEnv', () => {
|
||||
it('prepends the npm smoke virtualenv bin directory to PATH', () => {
|
||||
const env = npmSmokePythonEnv('/tmp/klo-npm-smoke', { PATH: '/usr/bin' });
|
||||
|
||||
assert.match(env.PATH, /^\/tmp\/klo-npm-smoke\/\.venv\/(bin|Scripts)/);
|
||||
assert.match(env.PATH, /\/usr\/bin$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verification snippets', () => {
|
||||
it('pins smoke dependencies and connector packages to clean-install-safe artifacts', () => {
|
||||
const layout = packageArtifactLayout('/repo/klo');
|
||||
const packageJson = npmSmokePackageJson(layout);
|
||||
|
||||
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
||||
assert.equal(packageJson.dependencies[packageInfo.name], `file:${layout.npmTarballs[packageInfo.name]}`);
|
||||
assert.equal(packageJson.pnpm.overrides[packageInfo.name], `file:${layout.npmTarballs[packageInfo.name]}`);
|
||||
}
|
||||
assert.equal(packageJson.dependencies['@modelcontextprotocol/sdk'], '^1.27.1');
|
||||
assert.deepEqual(packageJson.pnpm.onlyBuiltDependencies, ['better-sqlite3']);
|
||||
});
|
||||
|
||||
it('exposes manifest verification as a package artifact command', async () => {
|
||||
const source = await readFile(new URL('./package-artifacts.mjs', import.meta.url), 'utf8');
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.match(source, /if \(command === 'verify-manifest'\)/);
|
||||
assert.match(source, /await verifyArtifactManifest\(layout\)/);
|
||||
assert.equal(packageJson.scripts['artifacts:verify-demo'], 'node scripts/package-artifacts.mjs verify-demo');
|
||||
assert.equal(packageJson.scripts['artifacts:verify-manifest'], 'node scripts/package-artifacts.mjs verify-manifest');
|
||||
});
|
||||
|
||||
it('verifies installed dbt extraction exports from @klo/context/ingest', () => {
|
||||
const source = npmVerifySource();
|
||||
|
||||
assert.match(source, /const ingest = await import\('@klo\/context\/ingest'\);/);
|
||||
assert.match(source, /const dbtExtractionExports = \[/);
|
||||
assert.match(source, /throw new Error\('Missing dbt extraction export: ' \+ exportName\);/);
|
||||
|
||||
for (const exportName of [
|
||||
'parseMetricflowFiles',
|
||||
'parseMetricflowPullConfig',
|
||||
'importMetricflowSemanticModels',
|
||||
'parseDbtSchemaFiles',
|
||||
'toDescriptionUpdates',
|
||||
'toRelationshipUpdates',
|
||||
'mergeSemanticModelTables',
|
||||
'loadProjectInfo',
|
||||
'loadDbtSchemaFiles',
|
||||
]) {
|
||||
assert.match(source, new RegExp(`\\['${exportName}', ingest\\.${exportName}\\]`));
|
||||
}
|
||||
});
|
||||
|
||||
it('asserts the public npm and connector entry points that clean installs must expose', () => {
|
||||
const source = npmVerifySource();
|
||||
|
||||
assert.match(source, /@klo\/context/);
|
||||
assert.match(source, /@klo\/context\/project/);
|
||||
assert.match(source, /@klo\/context\/mcp/);
|
||||
assert.match(source, /@klo\/context\/memory/);
|
||||
assert.match(source, /@klo\/context\/daemon/);
|
||||
assert.match(source, /@klo\/cli/);
|
||||
assert.match(source, /@klo\/llm/);
|
||||
assert.match(source, /createKloLlmProvider/);
|
||||
assert.match(source, /KloMessageBuilder/);
|
||||
assert.match(source, /createKloEmbeddingProvider/);
|
||||
assert.doesNotMatch(source, /createGatewayLlmProvider/);
|
||||
assert.match(source, /createLocalProjectMemoryCapture/);
|
||||
for (const packageName of CONNECTOR_PACKAGE_NAMES) {
|
||||
assert.match(source, new RegExp(packageName.replace('/', '\\/')));
|
||||
}
|
||||
assert.match(source, /KloSqliteScanConnector/);
|
||||
assert.match(source, /KloPostgresScanConnector/);
|
||||
assert.match(source, /KloBigQueryScanConnector/);
|
||||
assert.match(source, /KloSnowflakeScanConnector/);
|
||||
assert.match(source, /KloPostHogScanConnector/);
|
||||
});
|
||||
|
||||
it('asserts installed hybrid search exports and CLI smoke coverage', () => {
|
||||
const verifySource = npmVerifySource();
|
||||
const runtimeSource = npmRuntimeSmokeSource();
|
||||
const demoSource = npmDemoSmokeSource();
|
||||
|
||||
assert.match(verifySource, /const search = await import\('@klo\/context\/search'\);/);
|
||||
assert.match(verifySource, /HybridSearchCore/);
|
||||
assert.match(verifySource, /assertSearchBackendConformanceCase/);
|
||||
assert.match(verifySource, /assertSearchBackendCapabilities/);
|
||||
|
||||
assert.match(runtimeSource, /klo agent wiki search hybrid metadata verified/);
|
||||
assert.match(runtimeSource, /klo agent sl list hybrid metadata verified/);
|
||||
assert.match(runtimeSource, /agent_sl_search_missing_project/);
|
||||
assert.match(runtimeSource, /agent_sl_search_no_connections/);
|
||||
assert.match(runtimeSource, /agent_sl_search_no_indexed_sources/);
|
||||
|
||||
assert.match(demoSource, /klo seeded demo agent wiki search verified/);
|
||||
assert.match(demoSource, /klo seeded demo agent sl search verified/);
|
||||
});
|
||||
|
||||
it('runs installed CLI commands and MCP through an installed daemon HTTP server', () => {
|
||||
const source = npmRuntimeSmokeSource();
|
||||
|
||||
assert.match(source, /@modelcontextprotocol\/sdk\/client\/index\.js/);
|
||||
assert.match(source, /@modelcontextprotocol\/sdk\/client\/stdio\.js/);
|
||||
assert.match(source, /spawn\(command, args/);
|
||||
assert.match(source, /createServer/);
|
||||
assert.match(source, /request as httpRequest/);
|
||||
assert.match(source, /getAvailablePort/);
|
||||
assert.match(source, /startSemanticDaemon/);
|
||||
assert.match(source, /waitForHttpHealth/);
|
||||
assert.match(source, /stopSemanticDaemon/);
|
||||
assert.match(source, /'klo-daemon'/);
|
||||
assert.match(source, /'serve-http'/);
|
||||
assert.match(source, /'--host'/);
|
||||
assert.match(source, /'127\.0\.0\.1'/);
|
||||
assert.match(source, /'--port'/);
|
||||
assert.match(source, /\/health/);
|
||||
assert.match(source, /--semantic-compute-url/);
|
||||
assert.match(source, /createDaemonLookerTableIdentifierParser/);
|
||||
assert.match(source, /LocalLookerRuntimeStore/);
|
||||
assert.match(source, /Looker daemon table identifier parser verified/);
|
||||
assert.match(source, /Looker local runtime store verified/);
|
||||
assert.match(source, /semanticComputeUrl/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'klo',\s*'setup'/);
|
||||
assert.match(source, /knowledge', 'global', 'revenue\.md'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'klo',\s*'agent',\s*'wiki',\s*'search'/);
|
||||
assert.match(source, /semantic-layer', 'warehouse', 'orders\.yaml'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'klo',\s*'agent',\s*'sl',\s*'list'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'klo',\s*'agent',\s*'sl',\s*'query'/);
|
||||
assert.match(source, /orders\.order_count/);
|
||||
assert.match(source, /sqlite3/);
|
||||
assert.match(source, /driver: sqlite/);
|
||||
assert.match(source, /path: warehouse\.db/);
|
||||
assert.match(source, /live-database/);
|
||||
assert.match(source, /'--execute'/);
|
||||
assert.match(source, /'--execute-queries'/);
|
||||
assert.match(source, /slValidateResult\.success, true/);
|
||||
assert.match(source, /slQueryResult\.dialect, 'sqlite'/);
|
||||
assert.match(source, /slQueryResult\.plan\.execution\.driver, 'sqlite'/);
|
||||
assert.match(source, /"mode": "compile_only"/);
|
||||
assert.match(source, /"mode": "executed"/);
|
||||
assert.match(source, /klo agent sl query sqlite execute/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'klo',\s*'dev',\s*'scan',\s*'warehouse'/);
|
||||
assert.match(source, /'--mode',\s*'enriched'/);
|
||||
assert.doesNotMatch(source, /'--enrich'/);
|
||||
assert.match(source, /klo scan structural verified/);
|
||||
assert.match(source, /klo scan enriched verified/);
|
||||
assert.match(source, /scanReportJson\.artifactPaths\.manifestShards/);
|
||||
assert.match(source, /scanReportJson\.artifactPaths\.enrichmentArtifacts/);
|
||||
assert.match(source, /enrichment:/);
|
||||
assert.match(source, /mode: deterministic/);
|
||||
assert.match(source, /backend: gateway/);
|
||||
assert.match(source, /models:/);
|
||||
assert.match(source, /default: smoke\/provider/);
|
||||
assert.match(source, /api_key: env:AI_GATEWAY_API_KEY/);
|
||||
assert.match(source, /run\('pnpm', \['exec', 'klo', 'dev', 'ingest', 'run'/);
|
||||
assert.match(source, /'serve', '--mcp', 'stdio'/);
|
||||
assert.doesNotMatch(source, /'--semantic-compute',\n\s*'--execute-queries'/);
|
||||
assert.match(source, /'--memory-capture', '--memory-model', 'smoke\/provider'/);
|
||||
assert.match(source, /mcpServerStderr/);
|
||||
assert.match(source, /klo serve stderr/);
|
||||
assert.match(source, /sl_validate/);
|
||||
assert.match(source, /sl_query/);
|
||||
assert.match(source, /memory_capture/);
|
||||
assert.match(source, /memory_capture_status/);
|
||||
assert.match(source, /connection_test/);
|
||||
assert.match(source, /scan_trigger/);
|
||||
assert.match(source, /scan_status/);
|
||||
assert.match(source, /scan_report/);
|
||||
assert.match(source, /scan_list_artifacts/);
|
||||
assert.match(source, /scan_read_artifact/);
|
||||
assert.match(source, /mcpScanArtifacts\.artifacts\.find/);
|
||||
assert.match(source, /AI_GATEWAY_API_KEY/);
|
||||
assert.match(source, /access\(join\(projectDir, '\.klo', 'db\.sqlite'\)\)/);
|
||||
assert.match(source, /SQLite knowledge index/);
|
||||
assert.match(source, /klo dev ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway/);
|
||||
assert.match(source, /klo dev ingest provider guard verified/);
|
||||
});
|
||||
|
||||
describe('npmDemoSmokeSource', () => {
|
||||
it('exercises the public packed-demo first-run contract', () => {
|
||||
const source = npmDemoSmokeSource();
|
||||
|
||||
assert.match(source, /pnpm', \['exec', 'klo', '--help'\]/);
|
||||
assert.match(source, /'demo', '--project-dir', projectDir, '--no-input', '--plain'/);
|
||||
assert.match(source, /Mode: seeded/);
|
||||
assert.match(source, /Source: packaged demo project/);
|
||||
assert.match(source, /LLM calls: none/);
|
||||
assert.match(source, /klo serve --mcp stdio/);
|
||||
assert.doesNotMatch(source, new RegExp(["'demo'", "'--mode'", "'deterministic'"].join(', ')));
|
||||
assert.match(source, /'dev', 'doctor', 'setup', '--no-input'/);
|
||||
assert.match(source, /'--plain'/);
|
||||
assert.match(source, /klo setup demo seeded wrote unexpected stderr/);
|
||||
});
|
||||
});
|
||||
|
||||
it('checks packaged ingest runtime assets in the installed npm smoke', () => {
|
||||
const source = npmRuntimeSmokeSource();
|
||||
|
||||
assert.match(source, /notion_synthesize\/SKILL\.md/);
|
||||
assert.match(source, /skills\/page_triage_classifier\.md/);
|
||||
assert.match(source, /skills\/light_extraction\.md/);
|
||||
});
|
||||
|
||||
it('asserts the Python modules that clean installs must expose', () => {
|
||||
const source = pythonVerifySource();
|
||||
|
||||
assert.match(source, /semantic_layer/);
|
||||
assert.match(source, /klo_daemon/);
|
||||
assert.match(source, /importlib.metadata/);
|
||||
});
|
||||
});
|
||||
195
scripts/precommit-check.mjs
Normal file
195
scripts/precommit-check.mjs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const kloRoot = dirname(dirname(scriptPath));
|
||||
const repoRoot = dirname(kloRoot);
|
||||
|
||||
const packageNameByDir = new Map(
|
||||
[
|
||||
'cli',
|
||||
'connector-bigquery',
|
||||
'connector-clickhouse',
|
||||
'connector-mysql',
|
||||
'connector-postgres',
|
||||
'connector-posthog',
|
||||
'connector-snowflake',
|
||||
'connector-sqlite',
|
||||
'connector-sqlserver',
|
||||
'context',
|
||||
'llm',
|
||||
].map((packageDir) => {
|
||||
const manifestPath = join(kloRoot, 'packages', packageDir, 'package.json');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
return [packageDir, manifest.name];
|
||||
}),
|
||||
);
|
||||
|
||||
const packageCodePattern = /\.(?:ts|tsx|js|jsx|json)$/;
|
||||
const scriptPattern = /\.(?:mjs|js|json)$/;
|
||||
const pythonPackageTests = new Map([
|
||||
['klo-sl', 'python/klo-sl/tests'],
|
||||
['klo-daemon', 'python/klo-daemon/tests'],
|
||||
]);
|
||||
|
||||
function normalizeFilePath(filePath) {
|
||||
return filePath.replaceAll('\\', '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function stablePush(commands, key, cmd, args) {
|
||||
if (commands.some((command) => command.key === key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
commands.push({ key, cmd, args });
|
||||
}
|
||||
|
||||
function maybeScriptTest(scriptFile) {
|
||||
if (scriptFile.endsWith('.test.mjs')) {
|
||||
return scriptFile;
|
||||
}
|
||||
|
||||
if (!scriptFile.endsWith('.mjs')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testFile = scriptFile.replace(/\.mjs$/, '.test.mjs');
|
||||
return existsSync(join(kloRoot, testFile)) ? testFile : null;
|
||||
}
|
||||
|
||||
export function planChecks(files) {
|
||||
const commands = [];
|
||||
const packageNames = new Set();
|
||||
const pythonPackages = new Set();
|
||||
let runBoundaryCheck = false;
|
||||
let runAllTypeChecks = false;
|
||||
let runAllPythonTests = false;
|
||||
|
||||
for (const rawFile of files) {
|
||||
const file = normalizeFilePath(rawFile);
|
||||
|
||||
if (!file.startsWith('klo/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kloFile = file.slice('klo/'.length);
|
||||
|
||||
if (kloFile.startsWith('packages/')) {
|
||||
const [, packageDir, ...rest] = kloFile.split('/');
|
||||
const packageName = packageNameByDir.get(packageDir);
|
||||
const packageFile = rest.join('/');
|
||||
|
||||
if (packageName && packageCodePattern.test(packageFile)) {
|
||||
packageNames.add(packageName);
|
||||
runBoundaryCheck = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kloFile.startsWith('scripts/') && scriptPattern.test(kloFile)) {
|
||||
const testFile = maybeScriptTest(kloFile);
|
||||
|
||||
if (testFile) {
|
||||
stablePush(commands, `script-test:${testFile}`, 'node', ['--test', testFile]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kloFile.startsWith('python/')) {
|
||||
const [, packageDir] = kloFile.split('/');
|
||||
|
||||
if (pythonPackageTests.has(packageDir)) {
|
||||
pythonPackages.add(packageDir);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'release-policy.json', 'tsconfig.base.json'].includes(
|
||||
kloFile,
|
||||
)
|
||||
) {
|
||||
runBoundaryCheck = true;
|
||||
runAllTypeChecks = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['pyproject.toml', 'uv.lock', 'uv.toml'].includes(kloFile)) {
|
||||
runAllPythonTests = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (runBoundaryCheck) {
|
||||
stablePush(commands, 'boundary-check', 'node', ['scripts/check-boundaries.mjs']);
|
||||
}
|
||||
|
||||
if (runAllTypeChecks) {
|
||||
stablePush(commands, 'type-check:all', 'pnpm', ['--filter', './packages/*', 'run', 'type-check']);
|
||||
} else {
|
||||
for (const packageName of [...packageNames].sort()) {
|
||||
stablePush(commands, `type-check:${packageName}`, 'pnpm', ['--filter', packageName, 'run', 'type-check']);
|
||||
stablePush(commands, `build:${packageName}`, 'pnpm', ['--filter', `${packageName}...`, 'run', 'build']);
|
||||
stablePush(commands, `test:${packageName}`, 'pnpm', ['--filter', packageName, 'run', 'test']);
|
||||
}
|
||||
}
|
||||
|
||||
if (runAllPythonTests) {
|
||||
stablePush(commands, 'pytest:all', 'uv', ['run', 'pytest']);
|
||||
} else {
|
||||
for (const packageDir of [...pythonPackages].sort()) {
|
||||
stablePush(commands, `pytest:${packageDir}`, 'uv', [
|
||||
'run',
|
||||
'--package',
|
||||
packageDir,
|
||||
'pytest',
|
||||
pythonPackageTests.get(packageDir),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function printCommand(command) {
|
||||
console.log(`\n$ ${command.cmd} ${command.args.join(' ')}`);
|
||||
}
|
||||
|
||||
export function runChecks(files) {
|
||||
const commands = planChecks(files);
|
||||
|
||||
if (commands.length === 0) {
|
||||
console.log('No KLO package checks needed for these files.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
printCommand(command);
|
||||
|
||||
const result = spawnSync(command.cmd, command.args, {
|
||||
cwd: kloRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return result.status ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && relative(repoRoot, process.argv[1]).split(sep).join('/') === 'klo/scripts/precommit-check.mjs') {
|
||||
process.exitCode = runChecks(process.argv.slice(2));
|
||||
}
|
||||
33
scripts/precommit-check.test.mjs
Normal file
33
scripts/precommit-check.test.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { planChecks } from './precommit-check.mjs';
|
||||
|
||||
function commandKeys(files) {
|
||||
return planChecks(files).map((command) => command.key);
|
||||
}
|
||||
|
||||
describe('precommit-check', () => {
|
||||
it('skips files outside klo', () => {
|
||||
assert.deepEqual(commandKeys(['server/src/app.ts']), []);
|
||||
});
|
||||
|
||||
it('runs only the touched package checks for package code', () => {
|
||||
assert.deepEqual(commandKeys(['klo/packages/cli/src/index.ts']), [
|
||||
'boundary-check',
|
||||
'type-check:@klo/cli',
|
||||
'build:@klo/cli',
|
||||
'test:@klo/cli',
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs the matching script test when a script changes', () => {
|
||||
assert.deepEqual(commandKeys(['klo/scripts/check-boundaries.mjs']), [
|
||||
'script-test:scripts/check-boundaries.test.mjs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs the touched python package tests', () => {
|
||||
assert.deepEqual(commandKeys(['klo/python/klo-sl/semantic_layer/parser.py']), ['pytest:klo-sl']);
|
||||
});
|
||||
});
|
||||
44
scripts/prepare-cli-bin.mjs
Normal file
44
scripts/prepare-cli-bin.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, chmod } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export function kloRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
export function cliBinPath(rootDir = kloRootDir()) {
|
||||
return resolve(rootDir, 'packages', 'cli', 'dist', 'bin.js');
|
||||
}
|
||||
|
||||
async function canExecute(path) {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCliBinExecutable(rootDir = kloRootDir()) {
|
||||
const binPath = cliBinPath(rootDir);
|
||||
await access(binPath, constants.R_OK);
|
||||
|
||||
if (process.platform !== 'win32' && !(await canExecute(binPath))) {
|
||||
await chmod(binPath, 0o755);
|
||||
}
|
||||
|
||||
return binPath;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const binPath = await ensureCliBinExecutable();
|
||||
process.stdout.write(`Prepared KLO CLI bin: ${binPath}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
36
scripts/public-benchmark-manifest.json
Normal file
36
scripts/public-benchmark-manifest.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"fixtures": [
|
||||
{
|
||||
"id": "chinook_with_declared_metadata",
|
||||
"displayName": "Chinook (SQLite, declared metadata)",
|
||||
"url": "https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite",
|
||||
"sha256": "7651ba378ac2fcd0dfc3c66fb101f7a7eed3ba39a612ec642b96e20702061f15",
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/lerocha/chinook-database"
|
||||
},
|
||||
{
|
||||
"id": "northwind_with_declared_metadata",
|
||||
"displayName": "Northwind (SQLite, declared metadata)",
|
||||
"url": "https://github.com/jpwhite3/northwind-SQLite3/raw/main/dist/northwind.db",
|
||||
"sha256": "2f4f5c68dfcd33ba27373eae48c7a4869800c68095ee0f9f0da494f83382a877",
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/jpwhite3/northwind-SQLite3"
|
||||
},
|
||||
{
|
||||
"id": "sakila_with_declared_metadata",
|
||||
"displayName": "Sakila (SQLite, declared metadata)",
|
||||
"url": "https://raw.githubusercontent.com/bradleygrant/sakila-sqlite3/master/sakila_master.db",
|
||||
"sha256": "88c91a4a1a6b61f9d3f35904c0a173c887b25e73f20c3c2fdb073818c06f4268",
|
||||
"license": "BSD-2-Clause",
|
||||
"source": "https://github.com/bradleygrant/sakila-sqlite3"
|
||||
},
|
||||
{
|
||||
"id": "adventureworkslt_with_declared_metadata",
|
||||
"displayName": "AdventureWorksLT (SQLite, declared metadata)",
|
||||
"url": "https://github.com/nuitsjp/AdventureWorks-for-SQLite/releases/download/Release-1_0_0/AdventureWorksLT.db",
|
||||
"sha256": "f1a87a31f4efb5654f57a3b1ca47fac338972ceb7553673d66ea0bd9d55a7008", "_allowlist": "// pragma: allowlist secret",
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/nuitsjp/AdventureWorks-for-SQLite"
|
||||
}
|
||||
]
|
||||
}
|
||||
152
scripts/published-package-smoke-config.mjs
Normal file
152
scripts/published-package-smoke-config.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
export const DEFAULT_VERSION_TAG = 'latest';
|
||||
export const NO_PACKAGE_REASON =
|
||||
'Set KLO_PUBLISHED_KLO_PACKAGE or release-policy.json publishedPackageSmoke.packageName to the published npm package name after the release decision.';
|
||||
|
||||
function optionalTrimmedString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function assertSafePackageName(packageName, label) {
|
||||
if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(packageName)) {
|
||||
throw new Error(`Invalid ${label}: ${packageName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeVersionTag(version, label) {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/.test(version)) {
|
||||
throw new Error(`Invalid ${label}: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHttpRegistry(registry, label) {
|
||||
const parsed = new URL(registry);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error(`${label} must be an http(s) URL`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePolicyConfig(policyConfig = {}) {
|
||||
if (policyConfig === null || policyConfig === undefined) {
|
||||
return { packageName: null, version: DEFAULT_VERSION_TAG, registry: null };
|
||||
}
|
||||
|
||||
if (typeof policyConfig !== 'object' || Array.isArray(policyConfig)) {
|
||||
throw new Error('release-policy.json publishedPackageSmoke must be a JSON object');
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
packageName: optionalTrimmedString(policyConfig.packageName),
|
||||
version: optionalTrimmedString(policyConfig.version) ?? DEFAULT_VERSION_TAG,
|
||||
registry: optionalTrimmedString(policyConfig.registry),
|
||||
};
|
||||
assertSafeVersionTag(normalized.version, 'release-policy.json publishedPackageSmoke.version');
|
||||
if (normalized.registry) {
|
||||
assertHttpRegistry(normalized.registry, 'release-policy.json publishedPackageSmoke.registry');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readPublishedPackageSmokeConfig(env = process.env, args = process.argv.slice(2), policyConfig = {}) {
|
||||
const requireConfig = args.includes('--require-config');
|
||||
const policy = normalizePolicyConfig(policyConfig);
|
||||
|
||||
const envPackageName = optionalTrimmedString(env.KLO_PUBLISHED_KLO_PACKAGE);
|
||||
const packageName = envPackageName ?? policy.packageName;
|
||||
|
||||
if (!packageName) {
|
||||
return {
|
||||
enabled: false,
|
||||
requireConfig,
|
||||
reason: NO_PACKAGE_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
const configSource = envPackageName ? 'environment' : 'release-policy';
|
||||
assertSafePackageName(
|
||||
packageName,
|
||||
configSource === 'environment'
|
||||
? 'KLO_PUBLISHED_KLO_PACKAGE'
|
||||
: 'release-policy.json publishedPackageSmoke.packageName',
|
||||
);
|
||||
|
||||
const packageVersion = optionalTrimmedString(env.KLO_PUBLISHED_KLO_VERSION) ?? policy.version;
|
||||
assertSafeVersionTag(
|
||||
packageVersion,
|
||||
optionalTrimmedString(env.KLO_PUBLISHED_KLO_VERSION)
|
||||
? 'KLO_PUBLISHED_KLO_VERSION'
|
||||
: 'release-policy.json publishedPackageSmoke.version',
|
||||
);
|
||||
|
||||
const registry = optionalTrimmedString(env.KLO_PUBLISHED_KLO_REGISTRY) ?? policy.registry;
|
||||
if (registry) {
|
||||
assertHttpRegistry(
|
||||
registry,
|
||||
optionalTrimmedString(env.KLO_PUBLISHED_KLO_REGISTRY)
|
||||
? 'KLO_PUBLISHED_KLO_REGISTRY'
|
||||
: 'release-policy.json publishedPackageSmoke.registry',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
requireConfig,
|
||||
configSource,
|
||||
packageName,
|
||||
packageVersion,
|
||||
registry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readPublishedPackageSmokeConfigFromPolicyFile(
|
||||
policyPath,
|
||||
env = process.env,
|
||||
args = process.argv.slice(2),
|
||||
) {
|
||||
const policy = JSON.parse(await readFile(policyPath, 'utf8'));
|
||||
return readPublishedPackageSmokeConfig(env, args, policy.publishedPackageSmoke ?? {});
|
||||
}
|
||||
|
||||
export function publishedPackageSpec(config) {
|
||||
assert.equal(config.enabled, true, 'publishedPackageSpec requires an enabled smoke config');
|
||||
return `${config.packageName}@${config.packageVersion}`;
|
||||
}
|
||||
|
||||
export function buildPublishedPackageNpxCommand(config, args, label = 'published package command') {
|
||||
const env = config.registry ? { npm_config_registry: config.registry } : {};
|
||||
|
||||
return {
|
||||
label,
|
||||
command: 'npx',
|
||||
args: ['--yes', publishedPackageSpec(config), ...args],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPublishedPackageSmokeCommands(config, projectDir, emptyProjectDir) {
|
||||
return [
|
||||
buildPublishedPackageNpxCommand(config, ['--version'], 'published package version'),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['demo', '--project-dir', projectDir, '--no-input', '--plain'],
|
||||
'published package demo',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['agent', 'wiki', 'search', 'ARR contract', '--json', '--limit', '5', '--project-dir', projectDir],
|
||||
'published package wiki hybrid search',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['agent', 'sl', 'list', '--json', '--query', 'ARR', '--project-dir', projectDir],
|
||||
'published package semantic-layer hybrid search',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['agent', 'sl', 'list', '--json', '--query', 'revenue', '--project-dir', emptyProjectDir],
|
||||
'published package missing-project readiness',
|
||||
),
|
||||
];
|
||||
}
|
||||
164
scripts/published-package-smoke.mjs
Normal file
164
scripts/published-package-smoke.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
buildPublishedPackageSmokeCommands,
|
||||
readPublishedPackageSmokeConfigFromPolicyFile,
|
||||
} from './published-package-smoke-config.mjs';
|
||||
|
||||
export {
|
||||
buildPublishedPackageNpxCommand,
|
||||
buildPublishedPackageSmokeCommands,
|
||||
publishedPackageSpec,
|
||||
readPublishedPackageSmokeConfig,
|
||||
} from './published-package-smoke-config.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SMOKE_TIMEOUT_MS = 180_000;
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
function releasePolicyPath(rootDir = scriptRootDir()) {
|
||||
return join(rootDir, 'release-policy.json');
|
||||
}
|
||||
|
||||
async function runCommand(command, args, options = {}) {
|
||||
process.stdout.write(`$ ${command} ${args.join(' ')}\n`);
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: Object.assign({}, process.env, options.env ?? {}),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: SMOKE_TIMEOUT_MS,
|
||||
});
|
||||
return { code: 0, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: typeof error.code === 'number' ? error.code : 1,
|
||||
stdout: error.stdout ?? '',
|
||||
stderr: error.stderr ?? error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function requireSuccess(label, result) {
|
||||
assert.equal(
|
||||
result.code,
|
||||
0,
|
||||
`${label} failed with code ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseJson(label, text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} did not produce JSON: ${error instanceof Error ? error.message : String(error)}\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHybridWikiSearch(result) {
|
||||
const payload = parseJson('published package wiki search', result.stdout);
|
||||
assert.ok(payload.totalFound > 0, 'published package wiki search should return results');
|
||||
assert.ok(
|
||||
payload.results.some((entry) => Array.isArray(entry.matchReasons) && entry.matchReasons.length > 0),
|
||||
'published package wiki search should expose match reasons',
|
||||
);
|
||||
}
|
||||
|
||||
function assertHybridSlSearch(result) {
|
||||
const payload = parseJson('published package semantic-layer search', result.stdout);
|
||||
assert.ok(payload.totalSources > 0, 'published package semantic-layer search should return sources');
|
||||
assert.ok(
|
||||
payload.sources.some((entry) => Array.isArray(entry.matchReasons) && entry.matchReasons.length > 0),
|
||||
'published package semantic-layer search should expose match reasons',
|
||||
);
|
||||
}
|
||||
|
||||
function assertMissingProjectReadiness(result, emptyProjectDir) {
|
||||
assert.equal(result.code, 1, 'missing-project semantic-layer search should exit 1');
|
||||
assert.equal(result.stdout, '', 'missing-project semantic-layer search should not write JSON errors to stdout');
|
||||
|
||||
const payload = parseJson('published package missing-project semantic-layer search', result.stderr);
|
||||
assert.deepEqual(payload, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KLO project at ${emptyProjectDir}.`,
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${emptyProjectDir}`,
|
||||
'klo ingest <connection>',
|
||||
`klo agent sl list --json --query "revenue" --project-dir ${emptyProjectDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPublishedPackageSmoke(config) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-published-package-smoke-'));
|
||||
try {
|
||||
const projectDir = join(root, 'demo-project');
|
||||
const emptyProjectDir = join(root, 'empty-project');
|
||||
await mkdir(emptyProjectDir, { recursive: true });
|
||||
|
||||
const commands = buildPublishedPackageSmokeCommands(config, projectDir, emptyProjectDir);
|
||||
for (const command of commands.slice(0, 4)) {
|
||||
const result = await runCommand(command.command, command.args, { env: command.env });
|
||||
requireSuccess(command.label, result);
|
||||
if (command.label === 'published package wiki hybrid search') {
|
||||
assertHybridWikiSearch(result);
|
||||
}
|
||||
if (command.label === 'published package semantic-layer hybrid search') {
|
||||
assertHybridSlSearch(result);
|
||||
}
|
||||
}
|
||||
|
||||
const missingProjectCommand = commands[4];
|
||||
const missingProject = await runCommand(missingProjectCommand.command, missingProjectCommand.args, {
|
||||
env: missingProjectCommand.env,
|
||||
});
|
||||
assertMissingProjectReadiness(missingProject, emptyProjectDir);
|
||||
|
||||
process.stdout.write('published package hybrid search smoke verified\n');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = await readPublishedPackageSmokeConfigFromPolicyFile(
|
||||
releasePolicyPath(),
|
||||
process.env,
|
||||
process.argv.slice(2),
|
||||
);
|
||||
|
||||
if (!config.enabled) {
|
||||
if (config.requireConfig) {
|
||||
throw new Error(config.reason);
|
||||
}
|
||||
process.stdout.write(`Published KLO package smoke skipped: ${config.reason}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await runPublishedPackageSmoke(config);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
256
scripts/published-package-smoke.test.mjs
Normal file
256
scripts/published-package-smoke.test.mjs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildPublishedPackageNpxCommand,
|
||||
buildPublishedPackageSmokeCommands,
|
||||
publishedPackageSpec,
|
||||
readPublishedPackageSmokeConfig,
|
||||
} from './published-package-smoke.mjs';
|
||||
|
||||
describe('published package smoke config', () => {
|
||||
it('skips by default until a published package name is supplied', () => {
|
||||
assert.deepEqual(readPublishedPackageSmokeConfig({}, []), {
|
||||
enabled: false,
|
||||
requireConfig: false,
|
||||
reason:
|
||||
'Set KLO_PUBLISHED_KLO_PACKAGE or release-policy.json publishedPackageSmoke.packageName to the published npm package name after the release decision.',
|
||||
});
|
||||
});
|
||||
|
||||
it('can require the published package config for post-publication CI', () => {
|
||||
assert.deepEqual(readPublishedPackageSmokeConfig({}, ['--require-config']), {
|
||||
enabled: false,
|
||||
requireConfig: true,
|
||||
reason:
|
||||
'Set KLO_PUBLISHED_KLO_PACKAGE or release-policy.json publishedPackageSmoke.packageName to the published npm package name after the release decision.',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the package, version, and registry from environment variables', () => {
|
||||
assert.deepEqual(
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KLO_PUBLISHED_KLO_PACKAGE: '@klo/cli-public',
|
||||
KLO_PUBLISHED_KLO_VERSION: 'latest',
|
||||
KLO_PUBLISHED_KLO_REGISTRY: 'https://registry.npmjs.org/',
|
||||
},
|
||||
[],
|
||||
),
|
||||
{
|
||||
enabled: true,
|
||||
requireConfig: false,
|
||||
configSource: 'environment',
|
||||
packageName: '@klo/cli-public',
|
||||
packageVersion: 'latest',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the package, version, and registry from release policy when env vars are absent', () => {
|
||||
assert.deepEqual(
|
||||
readPublishedPackageSmokeConfig(
|
||||
{},
|
||||
[],
|
||||
{
|
||||
packageName: '@klo/cli-public',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
),
|
||||
{
|
||||
enabled: true,
|
||||
requireConfig: false,
|
||||
configSource: 'release-policy',
|
||||
packageName: '@klo/cli-public',
|
||||
packageVersion: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('lets environment variables override release policy values', () => {
|
||||
assert.deepEqual(
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KLO_PUBLISHED_KLO_PACKAGE: '@klo/cli-from-env',
|
||||
KLO_PUBLISHED_KLO_VERSION: 'latest',
|
||||
},
|
||||
[],
|
||||
{
|
||||
packageName: '@klo/cli-from-policy',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
),
|
||||
{
|
||||
enabled: true,
|
||||
requireConfig: false,
|
||||
configSource: 'environment',
|
||||
packageName: '@klo/cli-from-env',
|
||||
packageVersion: 'latest',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects package names that would be unsafe as npx package specs', () => {
|
||||
assert.throws(
|
||||
() => readPublishedPackageSmokeConfig({ KLO_PUBLISHED_KLO_PACKAGE: '--package=@evil/pkg' }, []),
|
||||
/Invalid KLO_PUBLISHED_KLO_PACKAGE/,
|
||||
);
|
||||
assert.throws(
|
||||
() => readPublishedPackageSmokeConfig({ KLO_PUBLISHED_KLO_PACKAGE: '@klo/cli public' }, []),
|
||||
/Invalid KLO_PUBLISHED_KLO_PACKAGE/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readPublishedPackageSmokeConfig(
|
||||
{},
|
||||
[],
|
||||
{
|
||||
packageName: '@klo/cli public',
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
),
|
||||
/Invalid release-policy\.json publishedPackageSmoke\.packageName/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe version tags and non-HTTP registries', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KLO_PUBLISHED_KLO_PACKAGE: '@klo/cli-public',
|
||||
KLO_PUBLISHED_KLO_VERSION: '--tag latest',
|
||||
},
|
||||
[],
|
||||
),
|
||||
/Invalid KLO_PUBLISHED_KLO_VERSION/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KLO_PUBLISHED_KLO_PACKAGE: '@klo/cli-public',
|
||||
KLO_PUBLISHED_KLO_REGISTRY: 'file:///tmp/npm',
|
||||
},
|
||||
[],
|
||||
),
|
||||
/KLO_PUBLISHED_KLO_REGISTRY must be an http\(s\) URL/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('published package smoke command construction', () => {
|
||||
const config = {
|
||||
enabled: true,
|
||||
requireConfig: false,
|
||||
packageName: '@klo/cli-public',
|
||||
packageVersion: 'latest',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
};
|
||||
|
||||
it('builds the npx package spec from package name and version tag', () => {
|
||||
assert.equal(publishedPackageSpec(config), '@klo/cli-public@latest');
|
||||
});
|
||||
|
||||
it('builds npx commands with a registry env patch instead of shell interpolation', () => {
|
||||
assert.deepEqual(buildPublishedPackageNpxCommand(config, ['--version']), {
|
||||
label: 'published package command',
|
||||
command: 'npx',
|
||||
args: ['--yes', '@klo/cli-public@latest', '--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the full hybrid-search smoke command list', () => {
|
||||
assert.deepEqual(buildPublishedPackageSmokeCommands(config, '/tmp/klo-smoke/demo', '/tmp/klo-smoke/empty'), [
|
||||
{
|
||||
label: 'published package version',
|
||||
command: 'npx',
|
||||
args: ['--yes', '@klo/cli-public@latest', '--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package demo',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@klo/cli-public@latest',
|
||||
'demo',
|
||||
'--project-dir',
|
||||
'/tmp/klo-smoke/demo',
|
||||
'--no-input',
|
||||
'--plain',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package wiki hybrid search',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@klo/cli-public@latest',
|
||||
'agent',
|
||||
'wiki',
|
||||
'search',
|
||||
'ARR contract',
|
||||
'--json',
|
||||
'--limit',
|
||||
'5',
|
||||
'--project-dir',
|
||||
'/tmp/klo-smoke/demo',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package semantic-layer hybrid search',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@klo/cli-public@latest',
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'ARR',
|
||||
'--project-dir',
|
||||
'/tmp/klo-smoke/demo',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package missing-project readiness',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@klo/cli-public@latest',
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'revenue',
|
||||
'--project-dir',
|
||||
'/tmp/klo-smoke/empty',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the smoke through the package release script', async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.equal(
|
||||
packageJson.scripts['release:published-smoke'],
|
||||
'node scripts/published-package-smoke.mjs --require-config',
|
||||
);
|
||||
});
|
||||
});
|
||||
330
scripts/relationship-orbit-verification.mjs
Normal file
330
scripts/relationship-orbit-verification.mjs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir as fsMkdir, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import { execFile as childExecFile } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { runWorkspaceKlo } from './run-klo.mjs';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const kloRootDir = resolve(scriptDir, '..');
|
||||
const repoRootDir = resolve(kloRootDir, '..');
|
||||
const defaultProjectDir = resolve(kloRootDir, 'examples/orbit-relationship-verification');
|
||||
const defaultReportPath = resolve(
|
||||
kloRootDir,
|
||||
'examples/orbit-relationship-verification/reports/orbit-verification.md',
|
||||
);
|
||||
const defaultExecFile = promisify(childExecFile);
|
||||
|
||||
class BufferWriter {
|
||||
chunks = [];
|
||||
|
||||
write(chunk) {
|
||||
this.chunks.push(String(chunk));
|
||||
}
|
||||
|
||||
text() {
|
||||
return this.chunks.join('');
|
||||
}
|
||||
}
|
||||
|
||||
function dateOnly(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function trimForReport(value) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : 'none';
|
||||
}
|
||||
|
||||
export function defaultOrbitVerificationProjectDir() {
|
||||
return defaultProjectDir;
|
||||
}
|
||||
|
||||
function shellCommand(argv) {
|
||||
return ['pnpm', 'run', 'klo', '--', ...argv].join(' ');
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(...values) {
|
||||
for (const value of values) {
|
||||
const line = value
|
||||
.split('\n')
|
||||
.map((candidate) => candidate.trim())
|
||||
.find((candidate) => candidate.length > 0);
|
||||
if (line) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return 'Orbit scan command failed before producing diagnostic output';
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
connectionId: process.env.KLO_ORBIT_CONNECTION_ID ?? 'orbit',
|
||||
projectDir: process.env.KLO_ORBIT_PROJECT_DIR ?? defaultProjectDir,
|
||||
reportPath: defaultReportPath,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--connection-id' || arg === '--connection') {
|
||||
options.connectionId = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--project-dir') {
|
||||
options.projectDir = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--report-path') {
|
||||
options.reportPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildOrbitScanArgv(input) {
|
||||
return ['dev', 'scan', input.connectionId, '--enrich', '--project-dir', input.projectDir];
|
||||
}
|
||||
|
||||
export function buildOrbitReportArgv(input) {
|
||||
return ['dev', 'scan', 'report', '--json', '--project-dir', input.projectDir, input.runId];
|
||||
}
|
||||
|
||||
export function extractRunId(stdout) {
|
||||
const match = stdout.match(/^Run:\s*(\S+)/m);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function listLines(values) {
|
||||
if (!values || values.length === 0) {
|
||||
return ['- none'];
|
||||
}
|
||||
return values.map((value) => `- \`${value}\``);
|
||||
}
|
||||
|
||||
function warningLines(report) {
|
||||
if (!Array.isArray(report.warnings) || report.warnings.length === 0) {
|
||||
return ['- none'];
|
||||
}
|
||||
return report.warnings.map((warning) => `- \`${warning.code}\`: ${warning.message}`);
|
||||
}
|
||||
|
||||
function formatSuccess(result) {
|
||||
const relationships = result.report.relationships ?? { accepted: 0, review: 0, rejected: 0, skipped: 0 };
|
||||
const enrichment = result.report.enrichment ?? {};
|
||||
const artifactPaths = result.report.artifactPaths ?? {};
|
||||
|
||||
return [
|
||||
'## Outcome',
|
||||
'',
|
||||
'- Exit code: 0',
|
||||
`- Run: \`${result.report.runId ?? 'unknown'}\``,
|
||||
`- Connection: \`${result.report.connectionId ?? result.connectionId}\``,
|
||||
`- Mode: \`${result.report.mode ?? 'unknown'}\``,
|
||||
`- Sync: \`${result.report.syncId ?? 'unknown'}\``,
|
||||
'',
|
||||
'## Relationship Summary',
|
||||
'',
|
||||
`- Accepted: ${relationships.accepted ?? 0}`,
|
||||
`- Review: ${relationships.review ?? 0}`,
|
||||
`- Rejected: ${relationships.rejected ?? 0}`,
|
||||
`- Skipped: ${relationships.skipped ?? 0}`,
|
||||
'',
|
||||
'## Enrichment Summary',
|
||||
'',
|
||||
`- Deterministic relationships: \`${enrichment.deterministicRelationships ?? 'unknown'}\``,
|
||||
`- Statistical validation: \`${enrichment.statisticalValidation ?? 'unknown'}\``,
|
||||
`- LLM relationship validation: \`${enrichment.llmRelationshipValidation ?? 'unknown'}\``,
|
||||
'',
|
||||
'## Artifacts',
|
||||
'',
|
||||
`- Report: \`${artifactPaths.reportPath ?? 'none'}\``,
|
||||
`- Raw sources: \`${artifactPaths.rawSourcesDir ?? 'none'}\``,
|
||||
'',
|
||||
'Manifest shards:',
|
||||
'',
|
||||
...listLines(artifactPaths.manifestShards),
|
||||
'',
|
||||
'Enrichment artifacts:',
|
||||
'',
|
||||
...listLines(artifactPaths.enrichmentArtifacts),
|
||||
'',
|
||||
'Warnings:',
|
||||
'',
|
||||
...warningLines(result.report),
|
||||
];
|
||||
}
|
||||
|
||||
function formatBlocked(result) {
|
||||
return [
|
||||
'## Outcome',
|
||||
'',
|
||||
`- Exit code: ${result.scanExitCode}`,
|
||||
`- Blocker: \`${result.blocker}\``,
|
||||
'',
|
||||
'## Evidence',
|
||||
'',
|
||||
'- Orbit verification was not executed because the current local Orbit scan command failed.',
|
||||
'- Re-run with `--report-path` to write verification evidence to a custom location.',
|
||||
'',
|
||||
'Scan stdout:',
|
||||
'',
|
||||
'```text',
|
||||
trimForReport(result.scanStdout),
|
||||
'```',
|
||||
'',
|
||||
'Scan stderr:',
|
||||
'',
|
||||
'```text',
|
||||
trimForReport(result.scanStderr),
|
||||
'```',
|
||||
];
|
||||
}
|
||||
|
||||
export function formatOrbitVerificationMarkdown(result) {
|
||||
const lines = [
|
||||
'# KLO Relationship Discovery Orbit Verification',
|
||||
'',
|
||||
`Date: ${result.date}`,
|
||||
'',
|
||||
'## Command',
|
||||
'',
|
||||
'```bash',
|
||||
result.scanCommand,
|
||||
'```',
|
||||
'',
|
||||
];
|
||||
|
||||
if (result.status === 'success') {
|
||||
lines.push(
|
||||
'## JSON Report Command',
|
||||
'',
|
||||
'```bash',
|
||||
result.reportCommand,
|
||||
'```',
|
||||
'',
|
||||
...formatSuccess(result),
|
||||
);
|
||||
} else {
|
||||
lines.push(...formatBlocked(result));
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function runBufferedWorkspaceKlo(runner, argv, rootDir, execFile) {
|
||||
const stdout = new BufferWriter();
|
||||
const stderr = new BufferWriter();
|
||||
const exitCode = await runner(argv, { rootDir, execFile, stdout, stderr });
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdout.text(),
|
||||
stderr: stderr.text(),
|
||||
};
|
||||
}
|
||||
|
||||
function orbitVerificationEnv(projectDir) {
|
||||
if (projectDir !== defaultProjectDir) {
|
||||
return process.env;
|
||||
}
|
||||
return {
|
||||
...process.env,
|
||||
GIT_CEILING_DIRECTORIES: dirname(defaultProjectDir),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runOrbitVerification(options = {}) {
|
||||
const connectionId = options.connectionId ?? process.env.KLO_ORBIT_CONNECTION_ID ?? 'orbit';
|
||||
const projectDir = options.projectDir ?? process.env.KLO_ORBIT_PROJECT_DIR ?? defaultProjectDir;
|
||||
const reportPath = options.reportPath ?? defaultReportPath;
|
||||
const rootDir = options.rootDir ?? kloRootDir;
|
||||
const runner = options.runWorkspaceKlo ?? runWorkspaceKlo;
|
||||
const execFile = options.execFile ?? defaultExecFile;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const mkdir = options.mkdir ?? fsMkdir;
|
||||
const writeFile = options.writeFile ?? fsWriteFile;
|
||||
const date = dateOnly(now());
|
||||
const env = options.env ?? orbitVerificationEnv(projectDir);
|
||||
const runWithEnv = (argv, runnerOptions) => runner(argv, { ...runnerOptions, env });
|
||||
|
||||
const scanArgv = buildOrbitScanArgv({ connectionId, projectDir });
|
||||
const scan = await runBufferedWorkspaceKlo(runWithEnv, scanArgv, rootDir, execFile);
|
||||
let result;
|
||||
|
||||
if (scan.exitCode !== 0) {
|
||||
result = {
|
||||
status: 'blocked',
|
||||
date,
|
||||
connectionId,
|
||||
projectDir,
|
||||
scanCommand: shellCommand(scanArgv),
|
||||
scanExitCode: scan.exitCode,
|
||||
blocker: firstNonEmptyLine(scan.stderr, scan.stdout),
|
||||
scanStdout: scan.stdout,
|
||||
scanStderr: scan.stderr,
|
||||
};
|
||||
} else {
|
||||
const runId = extractRunId(scan.stdout);
|
||||
if (!runId) {
|
||||
result = {
|
||||
status: 'blocked',
|
||||
date,
|
||||
connectionId,
|
||||
projectDir,
|
||||
scanCommand: shellCommand(scanArgv),
|
||||
scanExitCode: scan.exitCode,
|
||||
blocker: 'KLO scan completed without printing a Run id',
|
||||
scanStdout: scan.stdout,
|
||||
scanStderr: scan.stderr,
|
||||
};
|
||||
} else {
|
||||
const reportArgv = buildOrbitReportArgv({ projectDir, runId });
|
||||
const reportOutput = await runBufferedWorkspaceKlo(runWithEnv, reportArgv, rootDir, execFile);
|
||||
if (reportOutput.exitCode !== 0) {
|
||||
result = {
|
||||
status: 'blocked',
|
||||
date,
|
||||
connectionId,
|
||||
projectDir,
|
||||
scanCommand: shellCommand(scanArgv),
|
||||
scanExitCode: reportOutput.exitCode,
|
||||
blocker: firstNonEmptyLine(reportOutput.stderr, reportOutput.stdout),
|
||||
scanStdout: `${scan.stdout}\n${reportOutput.stdout}`.trim(),
|
||||
scanStderr: `${scan.stderr}\n${reportOutput.stderr}`.trim(),
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
status: 'success',
|
||||
date,
|
||||
connectionId,
|
||||
projectDir,
|
||||
scanCommand: shellCommand(scanArgv),
|
||||
reportCommand: shellCommand(reportArgv),
|
||||
scanExitCode: scan.exitCode,
|
||||
reportExitCode: reportOutput.exitCode,
|
||||
scanStdout: scan.stdout,
|
||||
scanStderr: scan.stderr,
|
||||
report: JSON.parse(reportOutput.stdout),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(dirname(reportPath), { recursive: true });
|
||||
await writeFile(reportPath, formatOrbitVerificationMarkdown(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = await runOrbitVerification(options);
|
||||
process.stdout.write(`Wrote ${options.reportPath}\n`);
|
||||
process.stdout.write(`Outcome: ${result.status}\n`);
|
||||
}
|
||||
244
scripts/relationship-orbit-verification.test.mjs
Normal file
244
scripts/relationship-orbit-verification.test.mjs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
buildOrbitReportArgv,
|
||||
buildOrbitScanArgv,
|
||||
defaultOrbitVerificationProjectDir,
|
||||
extractRunId,
|
||||
formatOrbitVerificationMarkdown,
|
||||
runOrbitVerification,
|
||||
} from './relationship-orbit-verification.mjs';
|
||||
|
||||
function successReportJson() {
|
||||
return JSON.stringify({
|
||||
runId: 'scan-orbit-1',
|
||||
connectionId: 'orbit',
|
||||
mode: 'enriched',
|
||||
syncId: '2026-05-07-100000-scan-enriched-1',
|
||||
relationships: {
|
||||
accepted: 14,
|
||||
review: 8,
|
||||
rejected: 91,
|
||||
skipped: 0,
|
||||
},
|
||||
enrichment: {
|
||||
deterministicRelationships: 'completed',
|
||||
statisticalValidation: 'completed',
|
||||
llmRelationshipValidation: 'skipped',
|
||||
},
|
||||
warnings: [
|
||||
{
|
||||
code: 'scan_enrichment_backend_not_configured',
|
||||
message:
|
||||
'Skipping description and embedding enrichment because scan.enrichment.mode is not configured; relationship discovery still ran.',
|
||||
recoverable: true,
|
||||
},
|
||||
],
|
||||
artifactPaths: {
|
||||
reportPath: 'raw-sources/orbit/live-database/2026-05-07-100000-scan-enriched-1/reports/scan-report.json',
|
||||
rawSourcesDir: 'raw-sources/orbit/live-database/2026-05-07-100000-scan-enriched-1',
|
||||
manifestShards: ['semantic-layer/orbit/_schema/orbit_analytics.yaml'],
|
||||
enrichmentArtifacts: [
|
||||
'raw-sources/orbit/live-database/2026-05-07-100000-scan-enriched-1/enrichment/relationships.json',
|
||||
'raw-sources/orbit/live-database/2026-05-07-100000-scan-enriched-1/enrichment/relationship-profile.json',
|
||||
'raw-sources/orbit/live-database/2026-05-07-100000-scan-enriched-1/enrichment/relationship-diagnostics.json',
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('relationship Orbit verification helper', () => {
|
||||
it('exposes the Orbit verification command from the KLO workspace package', async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.equal(
|
||||
packageJson.scripts['relationships:verify-orbit'],
|
||||
'node scripts/relationship-orbit-verification.mjs',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the current KLO launcher arguments for scan and JSON report commands', () => {
|
||||
assert.deepEqual(buildOrbitScanArgv({ connectionId: 'orbit', projectDir: '/tmp/orbit-project' }), [
|
||||
'dev',
|
||||
'scan',
|
||||
'orbit',
|
||||
'--enrich',
|
||||
'--project-dir',
|
||||
'/tmp/orbit-project',
|
||||
]);
|
||||
assert.deepEqual(buildOrbitReportArgv({ projectDir: '/tmp/orbit-project', runId: 'scan-orbit-1' }), [
|
||||
'dev',
|
||||
'scan',
|
||||
'report',
|
||||
'--json',
|
||||
'--project-dir',
|
||||
'/tmp/orbit-project',
|
||||
'scan-orbit-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the checked-in Orbit verification project by default', async () => {
|
||||
const calls = [];
|
||||
const envs = [];
|
||||
const writes = [];
|
||||
const defaultProjectDir = defaultOrbitVerificationProjectDir();
|
||||
|
||||
const result = await runOrbitVerification({
|
||||
reportPath: '/tmp/orbit-report.md',
|
||||
now: () => new Date('2026-05-07T10:00:00.000Z'),
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
writes.push({ path, content });
|
||||
},
|
||||
runWorkspaceKlo: async (argv, options) => {
|
||||
calls.push(argv);
|
||||
envs.push(options.env);
|
||||
if (argv[2] === 'report') {
|
||||
options.stdout.write(successReportJson());
|
||||
return 0;
|
||||
}
|
||||
options.stdout.write('KLO scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'success');
|
||||
assert.deepEqual(calls, [
|
||||
['dev', 'scan', 'orbit', '--enrich', '--project-dir', defaultProjectDir],
|
||||
['dev', 'scan', 'report', '--json', '--project-dir', defaultProjectDir, 'scan-orbit-1'],
|
||||
]);
|
||||
assert.equal(envs[0].GIT_CEILING_DIRECTORIES, dirname(defaultProjectDir));
|
||||
assert.equal(envs[1].GIT_CEILING_DIRECTORIES, dirname(defaultProjectDir));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.match(writes[0].content, new RegExp(defaultProjectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
});
|
||||
|
||||
it('extracts the run id from human scan output', () => {
|
||||
assert.equal(extractRunId(`KLO scan completed\nStatus: done\nRun: scan-orbit-1\nConnection: orbit\n`), 'scan-orbit-1');
|
||||
assert.equal(extractRunId('KLO scan completed without a run line\n'), null);
|
||||
});
|
||||
|
||||
it('formats successful Orbit verification evidence from the JSON report', () => {
|
||||
const markdown = formatOrbitVerificationMarkdown({
|
||||
status: 'success',
|
||||
date: '2026-05-07',
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
scanCommand: 'pnpm run klo -- dev scan orbit --enrich --project-dir /tmp/orbit-project',
|
||||
reportCommand: 'pnpm run klo -- dev scan report --json --project-dir /tmp/orbit-project scan-orbit-1',
|
||||
scanExitCode: 0,
|
||||
reportExitCode: 0,
|
||||
scanStdout: 'KLO scan completed\nRun: scan-orbit-1\n',
|
||||
scanStderr: '',
|
||||
report: JSON.parse(successReportJson()),
|
||||
});
|
||||
|
||||
assert.match(markdown, /# KLO Relationship Discovery Orbit Verification/);
|
||||
assert.match(markdown, /Outcome/);
|
||||
assert.match(markdown, /Exit code: 0/);
|
||||
assert.match(markdown, /Accepted: 14/);
|
||||
assert.match(markdown, /Review: 8/);
|
||||
assert.match(markdown, /Rejected: 91/);
|
||||
assert.match(markdown, /semantic-layer\/orbit\/_schema\/orbit_analytics\.yaml/);
|
||||
assert.match(markdown, /relationship-diagnostics\.json/);
|
||||
assert.match(markdown, /scan_enrichment_backend_not_configured/);
|
||||
});
|
||||
|
||||
it('formats blocked Orbit verification evidence from the current failing command', () => {
|
||||
const markdown = formatOrbitVerificationMarkdown({
|
||||
status: 'blocked',
|
||||
date: '2026-05-07',
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
scanCommand: 'pnpm run klo -- dev scan orbit --enrich --project-dir /tmp/orbit-project',
|
||||
scanExitCode: 1,
|
||||
blocker: 'Connection "orbit" was not found',
|
||||
scanStdout: '',
|
||||
scanStderr: 'Connection "orbit" was not found\n',
|
||||
});
|
||||
|
||||
assert.match(markdown, /Exit code: 1/);
|
||||
assert.match(markdown, /Connection "orbit" was not found/);
|
||||
assert.match(markdown, /Orbit verification was not executed because the current local Orbit scan command failed/);
|
||||
assert.doesNotMatch(markdown, /scan\.enrichment\.mode is required/);
|
||||
});
|
||||
|
||||
it('runs scan then JSON report and writes success Markdown', async () => {
|
||||
const calls = [];
|
||||
const writes = [];
|
||||
const result = await runOrbitVerification({
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
reportPath: '/tmp/orbit-report.md',
|
||||
now: () => new Date('2026-05-07T10:00:00.000Z'),
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
writes.push({ path, content });
|
||||
},
|
||||
runWorkspaceKlo: async (argv, options) => {
|
||||
calls.push(argv);
|
||||
if (argv[2] === 'report') {
|
||||
options.stdout.write(successReportJson());
|
||||
return 0;
|
||||
}
|
||||
options.stdout.write('KLO scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'success');
|
||||
assert.deepEqual(calls, [
|
||||
['dev', 'scan', 'orbit', '--enrich', '--project-dir', '/tmp/orbit-project'],
|
||||
['dev', 'scan', 'report', '--json', '--project-dir', '/tmp/orbit-project', 'scan-orbit-1'],
|
||||
]);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].path, '/tmp/orbit-report.md');
|
||||
assert.match(writes[0].content, /Accepted: 14/);
|
||||
});
|
||||
|
||||
it('writes blocked Markdown when the scan command fails before a run id exists', async () => {
|
||||
const writes = [];
|
||||
const result = await runOrbitVerification({
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
reportPath: '/tmp/orbit-report.md',
|
||||
now: () => new Date('2026-05-07T10:00:00.000Z'),
|
||||
mkdir: async () => {},
|
||||
writeFile: async (path, content) => {
|
||||
writes.push({ path, content });
|
||||
},
|
||||
runWorkspaceKlo: async (_argv, options) => {
|
||||
options.stderr.write('Connection "orbit" was not found\n');
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'blocked');
|
||||
assert.equal(result.scanExitCode, 1);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.match(writes[0].content, /Connection "orbit" was not found/);
|
||||
});
|
||||
|
||||
it('runs the workspace launcher in buffered mode so real scan errors are captured', async () => {
|
||||
let sawExecFile = false;
|
||||
const result = await runOrbitVerification({
|
||||
connectionId: 'orbit',
|
||||
projectDir: '/tmp/orbit-project',
|
||||
reportPath: '/tmp/orbit-report.md',
|
||||
now: () => new Date('2026-05-07T10:00:00.000Z'),
|
||||
mkdir: async () => {},
|
||||
writeFile: async () => {},
|
||||
execFile: async () => ({ stdout: '', stderr: '' }),
|
||||
runWorkspaceKlo: async (_argv, options) => {
|
||||
sawExecFile = typeof options.execFile === 'function';
|
||||
options.stderr.write('ENOENT: no such file or directory, open \'/tmp/orbit-project/klo.yaml\'\n');
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sawExecFile, true);
|
||||
assert.equal(result.blocker, "ENOENT: no such file or directory, open '/tmp/orbit-project/klo.yaml'");
|
||||
});
|
||||
});
|
||||
246
scripts/release-readiness.mjs
Normal file
246
scripts/release-readiness.mjs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs';
|
||||
import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs';
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
export function releasePolicyPath(rootDir = scriptRootDir()) {
|
||||
return join(rootDir, 'release-policy.json');
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf-8'));
|
||||
}
|
||||
|
||||
const CI_ARTIFACT_ONLY_RELEASE_MODE = 'ci-artifact-only';
|
||||
const PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE = 'published-package-smoke-required';
|
||||
const SUPPORTED_RELEASE_MODES = new Set([
|
||||
CI_ARTIFACT_ONLY_RELEASE_MODE,
|
||||
PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE,
|
||||
]);
|
||||
|
||||
export async function readReleasePolicy(rootDir = scriptRootDir()) {
|
||||
return readJson(releasePolicyPath(rootDir));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertPlainObject(value, label) {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBoolean(value, label) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${label} must be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertString(value, label) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNullableString(value, label) {
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string or null`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStringArray(value, label) {
|
||||
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
|
||||
throw new Error(`${label} must be an array of strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupportedReleaseMode(releaseMode) {
|
||||
assertString(releaseMode, 'Release policy releaseMode');
|
||||
if (!SUPPORTED_RELEASE_MODES.has(releaseMode)) {
|
||||
throw new Error(`Unsupported release policy releaseMode: ${releaseMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRequiredBeforePublishing(policy) {
|
||||
assertStringArray(policy.requiredBeforePublishing, 'Release policy requiredBeforePublishing');
|
||||
|
||||
if (policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE && policy.requiredBeforePublishing.length === 0) {
|
||||
throw new Error('Release policy requiredBeforePublishing must list the remaining publishing decisions');
|
||||
}
|
||||
|
||||
if (
|
||||
policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE &&
|
||||
policy.requiredBeforePublishing.length > 0
|
||||
) {
|
||||
throw new Error('published-package-smoke-required release mode requires requiredBeforePublishing to be empty');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameMembers(actual, expected, label) {
|
||||
const sortedActual = [...actual].sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (JSON.stringify(sortedActual) !== JSON.stringify(sortedExpected)) {
|
||||
throw new Error(`${label} mismatch: expected ${sortedExpected.join(', ')}, got ${sortedActual.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateReleasePolicy(policy) {
|
||||
assertPlainObject(policy, 'Release policy');
|
||||
|
||||
if (policy.schemaVersion !== 1) {
|
||||
throw new Error(`Unsupported release policy schemaVersion: ${policy.schemaVersion}`);
|
||||
}
|
||||
assertSupportedReleaseMode(policy.releaseMode);
|
||||
assertPlainObject(policy.npm, 'Release policy npm');
|
||||
assertPlainObject(policy.python, 'Release policy python');
|
||||
assertPlainObject(policy.publishedPackageSmoke, 'Release policy publishedPackageSmoke');
|
||||
|
||||
assertBoolean(policy.npm.publish, 'Release policy npm.publish');
|
||||
assertNullableString(policy.npm.registry, 'Release policy npm.registry');
|
||||
assertStringArray(policy.npm.packages, 'Release policy npm.packages');
|
||||
|
||||
assertBoolean(policy.python.publish, 'Release policy python.publish');
|
||||
assertNullableString(policy.python.repository, 'Release policy python.repository');
|
||||
assertStringArray(policy.python.packages, 'Release policy python.packages');
|
||||
assertNullableString(policy.publishedPackageSmoke.packageName, 'Release policy publishedPackageSmoke.packageName');
|
||||
assertString(policy.publishedPackageSmoke.version, 'Release policy publishedPackageSmoke.version');
|
||||
assertNullableString(policy.publishedPackageSmoke.registry, 'Release policy publishedPackageSmoke.registry');
|
||||
readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke);
|
||||
assertRequiredBeforePublishing(policy);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
function metadataNames(metadata, ecosystem) {
|
||||
return metadata.filter((entry) => entry.ecosystem === ecosystem).map((entry) => entry.packageName);
|
||||
}
|
||||
|
||||
function publishedPackageSmokeGate(policy) {
|
||||
const config = readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke);
|
||||
|
||||
if (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE && !config.enabled) {
|
||||
throw new Error(
|
||||
'published-package-smoke-required release mode requires release-policy.json publishedPackageSmoke.packageName',
|
||||
);
|
||||
}
|
||||
|
||||
const base =
|
||||
policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE
|
||||
? {
|
||||
status: 'not_required',
|
||||
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
|
||||
}
|
||||
: {
|
||||
status: 'required',
|
||||
reason: 'Run the published package smoke before accepting the hybrid-search release.',
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
script: 'pnpm run release:published-smoke',
|
||||
configSource: config.enabled ? config.configSource : null,
|
||||
packageName: config.enabled ? config.packageName : null,
|
||||
version: config.enabled ? config.packageVersion : policy.publishedPackageSmoke.version,
|
||||
registry: config.enabled ? (config.registry ?? null) : policy.publishedPackageSmoke.registry,
|
||||
};
|
||||
}
|
||||
|
||||
function assertNonPublishingArtifactPolicy(policy, metadata) {
|
||||
const policyLabel =
|
||||
policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE ? 'ci-artifact-only policy' : `${policy.releaseMode} policy`;
|
||||
|
||||
if (policy.npm.publish !== false) {
|
||||
throw new Error(`${policyLabel} must keep npm.publish false`);
|
||||
}
|
||||
if (policy.python.publish !== false) {
|
||||
throw new Error(`${policyLabel} must keep python.publish false`);
|
||||
}
|
||||
if (policy.npm.registry !== null) {
|
||||
throw new Error(`${policyLabel} must keep npm.registry null`);
|
||||
}
|
||||
if (policy.python.repository !== null) {
|
||||
throw new Error(`${policyLabel} must keep python.repository null`);
|
||||
}
|
||||
|
||||
assertSameMembers(policy.npm.packages, metadataNames(metadata, 'npm'), 'Release policy npm.packages');
|
||||
assertSameMembers(policy.python.packages, metadataNames(metadata, 'python'), 'Release policy python.packages');
|
||||
|
||||
for (const entry of metadata) {
|
||||
if (entry.releaseMode !== CI_ARTIFACT_ONLY_RELEASE_MODE) {
|
||||
throw new Error(`Package ${entry.packageName} releaseMode must remain ci-artifact-only`);
|
||||
}
|
||||
if (entry.ecosystem === 'npm') {
|
||||
if (entry.private !== true) {
|
||||
throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`);
|
||||
}
|
||||
if (!entry.packageVersion.endsWith('-private')) {
|
||||
throw new Error(`${policyLabel} npm package ${entry.packageName} must use a private version suffix`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseReadinessReport(rootDir = scriptRootDir()) {
|
||||
const policy = validateReleasePolicy(await readReleasePolicy(rootDir));
|
||||
const layout = packageArtifactLayout(rootDir);
|
||||
const manifest = await verifyArtifactManifest(layout);
|
||||
const metadata = await packageReleaseMetadata(rootDir);
|
||||
|
||||
assertNonPublishingArtifactPolicy(policy, metadata);
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
releaseMode: policy.releaseMode,
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
npmPublishEnabled: policy.npm.publish,
|
||||
pythonPublishEnabled: policy.python.publish,
|
||||
packageNames: metadata.map((entry) => entry.packageName),
|
||||
publishedPackageSmokeGate: publishedPackageSmokeGate(policy),
|
||||
blockedPublishingDecisions: policy.requiredBeforePublishing,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const report = await releaseReadinessReport();
|
||||
|
||||
if (process.argv.includes('--json')) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`KLO release mode: ${report.releaseMode}\n`);
|
||||
process.stdout.write(`KLO source revision: ${report.sourceRevision ?? 'local'}\n`);
|
||||
process.stdout.write(`KLO packages: ${report.packageNames.join(', ')}\n`);
|
||||
process.stdout.write(`Published package smoke: ${report.publishedPackageSmokeGate.status}\n`);
|
||||
process.stdout.write(`Published package smoke script: ${report.publishedPackageSmokeGate.script}\n`);
|
||||
process.stdout.write(`Published package smoke reason: ${report.publishedPackageSmokeGate.reason}\n`);
|
||||
process.stdout.write(`Published package smoke package: ${report.publishedPackageSmokeGate.packageName ?? 'not configured'}\n`);
|
||||
process.stdout.write(`Published package smoke version: ${report.publishedPackageSmokeGate.version}\n`);
|
||||
process.stdout.write(
|
||||
`Published package smoke registry: ${report.publishedPackageSmokeGate.registry ?? 'default npm registry'}\n`,
|
||||
);
|
||||
process.stdout.write('Registry publishing remains disabled by release-policy.json.\n');
|
||||
process.stdout.write('Required decisions before publishing:\n');
|
||||
for (const decision of report.blockedPublishingDecisions) {
|
||||
process.stdout.write(`- ${decision}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
376
scripts/release-readiness.test.mjs
Normal file
376
scripts/release-readiness.test.mjs
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { NPM_ARTIFACT_PACKAGES, packageArtifactLayout, writeArtifactManifest } from './package-artifacts.mjs';
|
||||
import { readReleasePolicy, releasePolicyPath, releaseReadinessReport } from './release-readiness.mjs';
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writeReleaseMetadataInputs(root, options = {}) {
|
||||
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
||||
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
|
||||
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
|
||||
name: packageInfo.name,
|
||||
version: '0.0.0-private',
|
||||
private:
|
||||
packageInfo.name === '@klo/context'
|
||||
? (options.contextPrivate ?? true)
|
||||
: packageInfo.name === '@klo/cli'
|
||||
? (options.cliPrivate ?? true)
|
||||
: true,
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(join(root, 'python', 'klo-sl'), { recursive: true });
|
||||
await mkdir(join(root, 'python', 'klo-daemon'), { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(root, 'python', 'klo-sl', 'pyproject.toml'),
|
||||
['[project]', 'name = "klo-sl"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, 'python', 'klo-daemon', 'pyproject.toml'),
|
||||
['[project]', 'name = "klo-daemon"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeUploadableArtifactFixtures(layout) {
|
||||
await mkdir(layout.npmDir, { recursive: true });
|
||||
await mkdir(layout.pythonDir, { recursive: true });
|
||||
|
||||
const fileContents = new Map([
|
||||
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
|
||||
layout.npmTarballs[packageInfo.name],
|
||||
`${packageInfo.name}-tarball`,
|
||||
]),
|
||||
[join(layout.pythonDir, 'klo_sl-0.1.0-py3-none-any.whl'), 'klo-sl-wheel'],
|
||||
[join(layout.pythonDir, 'klo_sl-0.1.0.tar.gz'), 'klo-sl-sdist'],
|
||||
[join(layout.pythonDir, 'klo_daemon-0.1.0-py3-none-any.whl'), 'klo-daemon-wheel'],
|
||||
[join(layout.pythonDir, 'klo_daemon-0.1.0.tar.gz'), 'klo-daemon-sdist'],
|
||||
]);
|
||||
|
||||
for (const [path, contents] of fileContents) {
|
||||
await writeFile(path, contents);
|
||||
}
|
||||
}
|
||||
|
||||
function releasePolicy(overrides = {}) {
|
||||
const { npm: npmOverrides = {}, python: pythonOverrides = {}, ...policyOverrides } = overrides;
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
npm: {
|
||||
publish: false,
|
||||
registry: null,
|
||||
packages: NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
|
||||
...npmOverrides,
|
||||
},
|
||||
python: {
|
||||
publish: false,
|
||||
repository: null,
|
||||
packages: ['klo-sl', 'klo-daemon'],
|
||||
...pythonOverrides,
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: null,
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
requiredBeforePublishing: [
|
||||
'Choose npm registry and package visibility.',
|
||||
'Choose Python package repository.',
|
||||
'Choose public release versions.',
|
||||
'Configure registry credentials outside source control.',
|
||||
'Choose release tag and provenance policy.',
|
||||
],
|
||||
...policyOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function writePolicy(root, policy = releasePolicy()) {
|
||||
await writeJson(releasePolicyPath(root), policy);
|
||||
}
|
||||
|
||||
async function writeReadyFixture(root, options = {}) {
|
||||
await writeReleaseMetadataInputs(root, options);
|
||||
await writePolicy(root, options.policy ?? releasePolicy());
|
||||
const layout = packageArtifactLayout(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
await writeArtifactManifest(layout, new Date('2026-04-28T12:00:00.000Z'), {
|
||||
sourceRevision: 'abc123',
|
||||
});
|
||||
return layout;
|
||||
}
|
||||
|
||||
describe('release readiness policy', () => {
|
||||
it('reads the checked release policy path from the KLO root', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-policy-test-'));
|
||||
try {
|
||||
const policy = releasePolicy();
|
||||
await writePolicy(root, policy);
|
||||
|
||||
assert.equal(releasePolicyPath(root), join(root, 'release-policy.json'));
|
||||
assert.deepEqual(await readReleasePolicy(root), policy);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the current ci-artifact-only policy, package metadata, and artifact manifest', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-ready-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root);
|
||||
|
||||
const report = await releaseReadinessReport(root);
|
||||
|
||||
assert.deepEqual(report, {
|
||||
schemaVersion: 1,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
sourceRevision: 'abc123',
|
||||
npmPublishEnabled: false,
|
||||
pythonPublishEnabled: false,
|
||||
packageNames: [...NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name), 'klo-sl', 'klo-daemon'],
|
||||
publishedPackageSmokeGate: {
|
||||
status: 'not_required',
|
||||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
|
||||
configSource: null,
|
||||
packageName: null,
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
blockedPublishingDecisions: [
|
||||
'Choose npm registry and package visibility.',
|
||||
'Choose Python package repository.',
|
||||
'Choose public release versions.',
|
||||
'Configure registry credentials outside source control.',
|
||||
'Choose release tag and provenance policy.',
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports policy-controlled published package smoke config when present', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-smoke-config-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@klo/cli-public',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await releaseReadinessReport(root);
|
||||
|
||||
assert.deepEqual(report.publishedPackageSmokeGate, {
|
||||
status: 'not_required',
|
||||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
|
||||
configSource: 'release-policy',
|
||||
packageName: '@klo/cli-public',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports required published package smoke when release mode requires it', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-smoke-required-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@klo/cli-public',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await releaseReadinessReport(root);
|
||||
|
||||
assert.deepEqual(report, {
|
||||
schemaVersion: 1,
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
sourceRevision: 'abc123',
|
||||
npmPublishEnabled: false,
|
||||
pythonPublishEnabled: false,
|
||||
packageNames: [...NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name), 'klo-sl', 'klo-daemon'],
|
||||
publishedPackageSmokeGate: {
|
||||
status: 'required',
|
||||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Run the published package smoke before accepting the hybrid-search release.',
|
||||
configSource: 'release-policy',
|
||||
packageName: '@klo/cli-public',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
blockedPublishingDecisions: [],
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects required published smoke mode without a package name', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-smoke-required-missing-config-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/published-package-smoke-required release mode requires release-policy\.json publishedPackageSmoke\.packageName/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects required published smoke mode while publishing decisions remain', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-smoke-required-blocked-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@klo/cli-public',
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/published-package-smoke-required release mode requires requiredBeforePublishing to be empty/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported release modes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-unsupported-mode-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'experimental-publish',
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/Unsupported release policy releaseMode: experimental-publish/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects publish-enabled npm policy while releaseMode is ci-artifact-only', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-npm-publish-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
npm: { publish: true, registry: 'https://registry.npmjs.org/' },
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/ci-artifact-only policy must keep npm.publish false/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects publish-enabled Python policy while releaseMode is ci-artifact-only', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-python-publish-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
python: { publish: true, repository: 'pypi' },
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/ci-artifact-only policy must keep python.publish false/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe release-policy published package smoke config', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-smoke-invalid-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@klo/cli public',
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/Invalid release-policy\.json publishedPackageSmoke\.packageName/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a public npm package while releaseMode is ci-artifact-only', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-public-npm-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, { contextPrivate: false });
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/ci-artifact-only policy npm package @klo\/context must remain private/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects stale artifacts before reporting release readiness', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'klo-release-stale-artifact-test-'));
|
||||
try {
|
||||
const layout = await writeReadyFixture(root);
|
||||
await writeFile(layout.cliTarball, 'changed-cli-tarball');
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/Artifact manifest files do not match artifact contents/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
175
scripts/run-klo.mjs
Normal file
175
scripts/run-klo.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
function kloRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
function cliBinPath(rootDir) {
|
||||
return resolve(rootDir, 'packages', 'cli', 'dist', 'bin.js');
|
||||
}
|
||||
|
||||
async function fileExists(path, access) {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function packageBuildInputPaths(rootDir, readdir) {
|
||||
const paths = [resolve(rootDir, 'package.json'), resolve(rootDir, 'tsconfig.base.json')];
|
||||
let packageEntries = [];
|
||||
try {
|
||||
packageEntries = await readdir(resolve(rootDir, 'packages'), { withFileTypes: true });
|
||||
} catch {
|
||||
return paths;
|
||||
}
|
||||
|
||||
for (const entry of packageEntries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const packageDir = resolve(rootDir, 'packages', entry.name);
|
||||
paths.push(resolve(packageDir, 'package.json'), resolve(packageDir, 'tsconfig.json'), resolve(packageDir, 'src'));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function newestMtimeMs(path, fs) {
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(path);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return stats.mtimeMs;
|
||||
}
|
||||
|
||||
let newest = stats.mtimeMs;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(path, { withFileTypes: true });
|
||||
} catch {
|
||||
return newest;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
newest = Math.max(newest, await newestMtimeMs(resolve(path, entry.name), fs));
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
async function isBuildStale(rootDir, binPath, fs) {
|
||||
let binStats;
|
||||
try {
|
||||
binStats = await fs.stat(binPath);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const inputPaths = await packageBuildInputPaths(rootDir, fs.readdir);
|
||||
for (const inputPath of inputPaths) {
|
||||
if ((await newestMtimeMs(inputPath, fs)) > binStats.mtimeMs) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isShellCompletionRequest(argv) {
|
||||
return argv[0] === '__complete' || (argv[0] === 'dev' && argv[1] === '__complete');
|
||||
}
|
||||
|
||||
async function runBuffered(execFile, stdout, stderr, command, args, options) {
|
||||
try {
|
||||
const result = await execFile(command, args, { cwd: options.cwd, env: options.env, maxBuffer: 1024 * 1024 * 16 });
|
||||
if (result.stdout) {
|
||||
stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
stderr.write(result.stderr);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (typeof error?.stdout === 'string' && error.stdout.length > 0) {
|
||||
stdout.write(error.stdout);
|
||||
}
|
||||
if (typeof error?.stderr === 'string' && error.stderr.length > 0) {
|
||||
stderr.write(error.stderr);
|
||||
}
|
||||
return typeof error?.code === 'number' ? error.code : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function runInherited(command, args, options) {
|
||||
return new Promise((resolveExitCode) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
stdio: 'inherit',
|
||||
env: options.env ?? process.env,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
resolveExitCode(1);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (code !== null) {
|
||||
resolveExitCode(code);
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`Command terminated by signal ${signal ?? 'unknown'}\n`);
|
||||
resolveExitCode(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWorkspaceKlo(argv, options = {}) {
|
||||
const cliArgv = argv[0] === '--' ? argv.slice(1) : argv;
|
||||
const rootDir = options.rootDir ?? kloRootDir();
|
||||
const stdout = options.stdout ?? process.stdout;
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
const access = options.access ?? fsAccess;
|
||||
const fs = {
|
||||
stat: options.stat ?? fsStat,
|
||||
readdir: options.readdir ?? fsReaddir,
|
||||
};
|
||||
const binPath = cliBinPath(rootDir);
|
||||
const runCommand =
|
||||
options.runCommand ??
|
||||
(options.execFile
|
||||
? (command, args, commandOptions) => runBuffered(options.execFile, stdout, stderr, command, args, commandOptions)
|
||||
: (command, args, commandOptions) => runInherited(command, args, commandOptions));
|
||||
const commandEnv = options.env;
|
||||
|
||||
const binExists = await fileExists(binPath, access);
|
||||
const skipStaleBuildCheck = binExists && isShellCompletionRequest(cliArgv);
|
||||
const needsBuild = !binExists || (!skipStaleBuildCheck && (await isBuildStale(rootDir, binPath, fs)));
|
||||
if (needsBuild) {
|
||||
stderr.write(
|
||||
binExists
|
||||
? 'KLO CLI build output is stale. Rebuilding it now with `pnpm run build`...\n'
|
||||
: 'KLO CLI build output is missing. Building it now with `pnpm run build`...\n',
|
||||
);
|
||||
const buildExitCode = await runCommand('pnpm', ['run', 'build'], { cwd: rootDir, env: commandEnv });
|
||||
if (buildExitCode !== 0) {
|
||||
stderr.write(
|
||||
'\nKLO CLI build failed. Run `pnpm run setup:dev` from the KLO directory, then retry this command.\n',
|
||||
);
|
||||
return buildExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
return await runCommand(process.execPath, [binPath, ...cliArgv], { cwd: rootDir, env: commandEnv });
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exitCode = await runWorkspaceKlo(process.argv.slice(2));
|
||||
}
|
||||
243
scripts/run-klo.test.mjs
Normal file
243
scripts/run-klo.test.mjs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { runWorkspaceKlo } from './run-klo.mjs';
|
||||
|
||||
function freshBuildFs() {
|
||||
return {
|
||||
stat: async (path) => ({
|
||||
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : 1000,
|
||||
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
|
||||
}),
|
||||
readdir: async (path) => {
|
||||
if (path.endsWith('/packages')) {
|
||||
return [{ name: 'cli', isDirectory: () => true }];
|
||||
}
|
||||
if (path.endsWith('/src')) {
|
||||
return [{ name: 'bin.ts', isDirectory: () => false }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('runWorkspaceKlo runs the built CLI when it already exists', async () => {
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
const fs = freshBuildFs();
|
||||
|
||||
const exitCode = await runWorkspaceKlo(['--version'], {
|
||||
rootDir: '/workspace/klo',
|
||||
access: async () => undefined,
|
||||
stat: fs.stat,
|
||||
readdir: fs.readdir,
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
return { stdout: '@klo/cli 0.0.0-private\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
|
||||
stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['/workspace/klo/packages/cli/dist/bin.js', '--version'],
|
||||
cwd: '/workspace/klo',
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(logs, [['stdout', '@klo/cli 0.0.0-private\n']]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKlo forwards a caller-provided environment to buffered commands', async () => {
|
||||
const calls = [];
|
||||
const fs = freshBuildFs();
|
||||
|
||||
const exitCode = await runWorkspaceKlo(['--version'], {
|
||||
rootDir: '/workspace/klo',
|
||||
access: async () => undefined,
|
||||
stat: fs.stat,
|
||||
readdir: fs.readdir,
|
||||
env: { PATH: '/bin', GIT_CEILING_DIRECTORIES: '/workspace/klo/examples' },
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd, env: options.env });
|
||||
return { stdout: '@klo/cli 0.0.0-private\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: () => undefined },
|
||||
stderr: { write: () => undefined },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['/workspace/klo/packages/cli/dist/bin.js', '--version'],
|
||||
cwd: '/workspace/klo',
|
||||
env: { PATH: '/bin', GIT_CEILING_DIRECTORIES: '/workspace/klo/examples' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKlo drops a leading npm argument separator', async () => {
|
||||
const calls = [];
|
||||
const fs = freshBuildFs();
|
||||
|
||||
const exitCode = await runWorkspaceKlo(['--', 'connection', 'test', 'warehouse', '--help'], {
|
||||
rootDir: '/workspace/klo',
|
||||
access: async () => undefined,
|
||||
stat: fs.stat,
|
||||
readdir: fs.readdir,
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
return { stdout: 'Usage: klo connection test\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: () => undefined },
|
||||
stderr: { write: () => undefined },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['/workspace/klo/packages/cli/dist/bin.js', 'connection', 'test', 'warehouse', '--help'],
|
||||
cwd: '/workspace/klo',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKlo skips stale-build checks for shell completion when dist exists', async () => {
|
||||
const calls = [];
|
||||
let statCalls = 0;
|
||||
|
||||
const exitCode = await runWorkspaceKlo(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'klo', ''], {
|
||||
rootDir: '/workspace/klo',
|
||||
access: async () => undefined,
|
||||
stat: async (path) => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : 3000,
|
||||
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
|
||||
};
|
||||
},
|
||||
readdir: async () => {
|
||||
throw new Error('completion should not scan source directories');
|
||||
},
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
return { stdout: 'connect:Add, list, test, and map data sources\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: () => undefined },
|
||||
stderr: { write: () => undefined },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.equal(statCalls, 0);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'/workspace/klo/packages/cli/dist/bin.js',
|
||||
'dev',
|
||||
'__complete',
|
||||
'--shell',
|
||||
'zsh',
|
||||
'--position',
|
||||
'2',
|
||||
'--',
|
||||
'klo',
|
||||
'',
|
||||
],
|
||||
cwd: '/workspace/klo',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKlo builds the workspace CLI before running it when dist is missing', async () => {
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
let binExists = false;
|
||||
|
||||
const exitCode = await runWorkspaceKlo(['setup', 'demo', '--mode', 'replay', '--no-input', '--viz'], {
|
||||
rootDir: '/workspace/klo',
|
||||
access: async () => {
|
||||
if (!binExists) {
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
}
|
||||
},
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
if (command === 'pnpm') {
|
||||
binExists = true;
|
||||
return { stdout: 'build ok\n', stderr: '' };
|
||||
}
|
||||
return { stdout: 'Replay complete\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
|
||||
stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => [call.command, call.args]),
|
||||
[
|
||||
['pnpm', ['run', 'build']],
|
||||
[
|
||||
process.execPath,
|
||||
['/workspace/klo/packages/cli/dist/bin.js', 'setup', 'demo', '--mode', 'replay', '--no-input', '--viz'],
|
||||
],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(logs, [
|
||||
['stderr', 'KLO CLI build output is missing. Building it now with `pnpm run build`...\n'],
|
||||
['stdout', 'build ok\n'],
|
||||
['stdout', 'Replay complete\n'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runWorkspaceKlo rebuilds before running when workspace sources are newer than dist', async () => {
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
let sourceMtimeMs = 3000;
|
||||
|
||||
const exitCode = await runWorkspaceKlo(['dev', 'scan', 'orbit', '--enrich'], {
|
||||
rootDir: '/workspace/klo',
|
||||
access: async () => undefined,
|
||||
stat: async (path) => ({
|
||||
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : sourceMtimeMs,
|
||||
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
|
||||
}),
|
||||
readdir: async (path) => {
|
||||
if (path.endsWith('/packages')) {
|
||||
return [{ name: 'context', isDirectory: () => true }];
|
||||
}
|
||||
if (path.endsWith('/src')) {
|
||||
return [{ name: 'scan.ts', isDirectory: () => false }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
if (command === 'pnpm') {
|
||||
sourceMtimeMs = 1000;
|
||||
return { stdout: 'build ok\n', stderr: '' };
|
||||
}
|
||||
return { stdout: 'scan ok\n', stderr: '' };
|
||||
},
|
||||
stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
|
||||
stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => [call.command, call.args]),
|
||||
[
|
||||
['pnpm', ['run', 'build']],
|
||||
[process.execPath, ['/workspace/klo/packages/cli/dist/bin.js', 'dev', 'scan', 'orbit', '--enrich']],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(logs, [
|
||||
['stderr', 'KLO CLI build output is stale. Rebuilding it now with `pnpm run build`...\n'],
|
||||
['stdout', 'build ok\n'],
|
||||
['stdout', 'scan ok\n'],
|
||||
]);
|
||||
});
|
||||
74
scripts/setup-dev.mjs
Normal file
74
scripts/setup-dev.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile as execFileCallback } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFileCallback);
|
||||
|
||||
function kloRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
function failureText(error) {
|
||||
const stdout = typeof error?.stdout === 'string' ? error.stdout.trim() : '';
|
||||
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
|
||||
const message = error instanceof Error ? error.message.trim() : String(error);
|
||||
return [stderr, stdout, message].find((line) => line.length > 0) ?? 'Command failed';
|
||||
}
|
||||
|
||||
export async function runSetupDev(options = {}) {
|
||||
const rootDir = options.rootDir ?? kloRootDir();
|
||||
const execFile = options.execFile ?? execFileAsync;
|
||||
const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
|
||||
const phases = [
|
||||
{
|
||||
name: 'dependency install',
|
||||
command: 'pnpm',
|
||||
args: ['install', '--frozen-lockfile'],
|
||||
retry: 'pnpm install --frozen-lockfile',
|
||||
},
|
||||
{
|
||||
name: 'native SQLite rebuild',
|
||||
command: 'pnpm',
|
||||
args: ['run', 'native:rebuild'],
|
||||
retry: 'pnpm run native:rebuild',
|
||||
},
|
||||
{
|
||||
name: 'TypeScript package build',
|
||||
command: 'pnpm',
|
||||
args: ['run', 'build'],
|
||||
retry: 'pnpm run build',
|
||||
},
|
||||
{
|
||||
name: 'doctor setup',
|
||||
command: process.execPath,
|
||||
args: ['packages/cli/dist/bin.js', 'dev', 'doctor', 'setup', '--no-input'],
|
||||
retry: 'pnpm run klo -- dev doctor setup --no-input',
|
||||
},
|
||||
];
|
||||
|
||||
for (const phase of phases) {
|
||||
log(`RUN ${phase.name}: ${phase.command} ${phase.args.join(' ')}`);
|
||||
try {
|
||||
await execFile(phase.command, phase.args, { cwd: rootDir, maxBuffer: 1024 * 1024 });
|
||||
log(`PASS ${phase.name}`);
|
||||
} catch (error) {
|
||||
log(`FAIL ${phase.name}: ${failureText(error)}`);
|
||||
log(`Retry: ${phase.retry}`);
|
||||
return { ok: false, failedPhase: phase };
|
||||
}
|
||||
}
|
||||
|
||||
log('Workspace CLI: pnpm run klo -- --help');
|
||||
log('Optional global dev link: pnpm run link:dev');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const result = await runSetupDev();
|
||||
if (!result.ok) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
56
scripts/setup-dev.test.mjs
Normal file
56
scripts/setup-dev.test.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { runSetupDev } from './setup-dev.mjs';
|
||||
|
||||
test('runSetupDev runs phased setup without global linking', async () => {
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
|
||||
const result = await runSetupDev({
|
||||
rootDir: '/workspace/klo',
|
||||
execFile: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
return { stdout: `${command} ${args.join(' ')}`, stderr: '' };
|
||||
},
|
||||
log: (line) => logs.push(line),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => [call.command, call.args]),
|
||||
[
|
||||
['pnpm', ['install', '--frozen-lockfile']],
|
||||
['pnpm', ['run', 'native:rebuild']],
|
||||
['pnpm', ['run', 'build']],
|
||||
[process.execPath, ['packages/cli/dist/bin.js', 'dev', 'doctor', 'setup', '--no-input']],
|
||||
],
|
||||
);
|
||||
assert.equal(calls.some((call) => call.args.includes('link')), false);
|
||||
assert.equal(logs.some((line) => line.includes('PASS doctor setup')), true);
|
||||
});
|
||||
|
||||
test('runSetupDev stops at the failed phase and prints a retry command', async () => {
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
|
||||
const result = await runSetupDev({
|
||||
rootDir: '/workspace/klo',
|
||||
execFile: async (command, args) => {
|
||||
calls.push({ command, args });
|
||||
if (args.includes('native:rebuild')) {
|
||||
const error = new Error('native rebuild failed');
|
||||
error.stdout = '';
|
||||
error.stderr = 'better-sqlite3 rebuild failed';
|
||||
throw error;
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
},
|
||||
log: (line) => logs.push(line),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.failedPhase.name, 'native SQLite rebuild');
|
||||
assert.equal(result.failedPhase.retry, 'pnpm run native:rebuild');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(logs.some((line) => line.includes('Retry: pnpm run native:rebuild')), true);
|
||||
});
|
||||
67
scripts/standalone-ci-workflow.test.mjs
Normal file
67
scripts/standalone-ci-workflow.test.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
async function readText(relativePath) {
|
||||
return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
function assertIncludesAll(text, values) {
|
||||
for (const value of values) {
|
||||
assert.match(text, new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
}
|
||||
|
||||
describe('standalone KLO CI workflow', () => {
|
||||
it('runs the package checks from a filtered repository root', async () => {
|
||||
const workflow = await readText('.github/workflows/ci.yml');
|
||||
|
||||
assert.match(workflow, /^name: KLO CI/m);
|
||||
assertIncludesAll(workflow, [
|
||||
'permissions:',
|
||||
'contents: read',
|
||||
'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd',
|
||||
'pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061',
|
||||
'actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238',
|
||||
'node-version: "24"',
|
||||
'cache-dependency-path: "pnpm-lock.yaml"',
|
||||
'pnpm install --frozen-lockfile',
|
||||
'pnpm run check',
|
||||
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405',
|
||||
'python-version: "3.13"',
|
||||
'astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b',
|
||||
'cache-dependency-glob: "uv.lock"',
|
||||
'uv sync --all-packages',
|
||||
'uv run pytest',
|
||||
'pnpm run artifacts:check',
|
||||
]);
|
||||
|
||||
assert.doesNotMatch(workflow, /sparse-checkout/);
|
||||
assert.doesNotMatch(workflow, /cd klo/);
|
||||
assert.doesNotMatch(workflow, /klo\/pnpm-lock\.yaml/);
|
||||
assert.doesNotMatch(workflow, /klo\/uv\.lock/);
|
||||
});
|
||||
|
||||
it('uploads verified artifacts from root-relative paths', async () => {
|
||||
const workflow = await readText('.github/workflows/ci.yml');
|
||||
|
||||
assertIncludesAll(workflow, [
|
||||
'actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f',
|
||||
'name: klo-package-artifacts-${{ github.sha }}',
|
||||
'dist/artifacts/manifest.json',
|
||||
'dist/artifacts/npm/*.tgz',
|
||||
'dist/artifacts/python/*.whl',
|
||||
'dist/artifacts/python/*.tar.gz',
|
||||
'if-no-files-found: error',
|
||||
'retention-days: 7',
|
||||
]);
|
||||
|
||||
assert.doesNotMatch(workflow, /klo\/dist\/artifacts/);
|
||||
});
|
||||
|
||||
it('syncs injected workspace packages after package builds', async () => {
|
||||
const workspace = await readText('pnpm-workspace.yaml');
|
||||
|
||||
assert.match(workspace, /syncInjectedDepsAfterScripts:\n\s+- build/);
|
||||
});
|
||||
});
|
||||
98
scripts/validate-llm-debug-jsonl.mjs
Normal file
98
scripts/validate-llm-debug-jsonl.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const [backend, filePath] = process.argv.slice(2);
|
||||
|
||||
function usage() {
|
||||
process.stderr.write('Usage: node klo/scripts/validate-llm-debug-jsonl.mjs anthropic|vertex /path/to/debug.jsonl\n');
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!['anthropic', 'vertex'].includes(backend) || !filePath) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(filePath, 'utf8').trim();
|
||||
if (!raw) {
|
||||
fail(`debug JSONL is empty: ${filePath}`);
|
||||
}
|
||||
|
||||
const records = raw.split(/\n+/).map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw new Error(`line ${index + 1} is not valid JSON: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(records);
|
||||
const bannedKeyPattern = /"(content|text|prompt|toolSchema|parameters|apiKey|api_key|password|token)"\s*:/i;
|
||||
if (bannedKeyPattern.test(serialized)) {
|
||||
fail('debug JSONL contains a prompt, schema, credential, or token-shaped field');
|
||||
}
|
||||
|
||||
const providerOptionEntries = records.flatMap((record) => {
|
||||
if (!Array.isArray(record.providerOptions)) {
|
||||
throw new Error(`record ${record.operationName ?? '<unknown>'} is missing providerOptions array`);
|
||||
}
|
||||
return record.providerOptions;
|
||||
});
|
||||
|
||||
const cacheMarkerEntries = providerOptionEntries.filter((entry) => {
|
||||
return JSON.stringify(entry.providerOptions).includes('"cacheControl"');
|
||||
});
|
||||
|
||||
if (cacheMarkerEntries.length === 0) {
|
||||
fail('no cacheControl providerOptions were recorded');
|
||||
}
|
||||
|
||||
const requiredMarkerTargets = ['message', 'message-part', 'tool'];
|
||||
const markerTargets = new Set(cacheMarkerEntries.map((entry) => entry.target));
|
||||
for (const target of requiredMarkerTargets) {
|
||||
if (!markerTargets.has(target)) {
|
||||
fail(`missing cacheControl marker target: ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ttlValues = new Set();
|
||||
for (const marker of cacheMarkerEntries) {
|
||||
const markerJson = JSON.stringify(marker.providerOptions);
|
||||
for (const match of markerJson.matchAll(/"ttl":"([^"]+)"/g)) {
|
||||
ttlValues.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ttlValues.size === 0) {
|
||||
fail('cacheControl markers did not expose ttl values');
|
||||
}
|
||||
|
||||
for (const ttl of ttlValues) {
|
||||
if (ttl !== '1h' && ttl !== '5m') {
|
||||
fail(`unexpected cache ttl: ${ttl}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (backend === 'vertex' && !ttlValues.has('1h')) {
|
||||
fail('vertex debug capture did not include a default 1h cache marker');
|
||||
}
|
||||
|
||||
if (backend === 'vertex' && serialized.includes('extended-cache-ttl-2025-04-11')) {
|
||||
fail('vertex debug capture included the direct-Anthropic extended cache TTL beta header');
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
backend,
|
||||
records: records.length,
|
||||
providerOptionEntries: providerOptionEntries.length,
|
||||
cacheMarkerEntries: cacheMarkerEntries.length,
|
||||
markerTargets: [...markerTargets].sort(),
|
||||
ttlValues: [...ttlValues].sort(),
|
||||
})}\n`,
|
||||
);
|
||||
112
scripts/validate-llm-debug-jsonl.test.mjs
Normal file
112
scripts/validate-llm-debug-jsonl.test.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const scriptPath = new URL('./validate-llm-debug-jsonl.mjs', import.meta.url).pathname;
|
||||
|
||||
function runValidator(args) {
|
||||
return spawnSync(process.execPath, [scriptPath, ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function writeDebugJsonl(records) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'klo-llm-debug-validator-'));
|
||||
const filePath = join(dir, 'debug.jsonl');
|
||||
writeFileSync(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const validRecord = {
|
||||
operationName: 'ingest-bundle-wu',
|
||||
modelRole: 'candidateExtraction',
|
||||
modelId: 'claude-sonnet-4-6',
|
||||
messageCount: 2,
|
||||
toolNames: ['emit_candidate'],
|
||||
providerOptions: [
|
||||
{
|
||||
target: 'message',
|
||||
index: 0,
|
||||
role: 'system',
|
||||
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } },
|
||||
},
|
||||
{
|
||||
target: 'message-part',
|
||||
index: 1,
|
||||
role: 'user',
|
||||
partIndex: 0,
|
||||
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '5m' } } },
|
||||
},
|
||||
{
|
||||
target: 'tool',
|
||||
name: 'emit_candidate',
|
||||
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('prints usage and exits 2 when required arguments are missing', () => {
|
||||
const result = runValidator([]);
|
||||
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Usage: node klo\/scripts\/validate-llm-debug-jsonl\.mjs anthropic\|vertex/);
|
||||
});
|
||||
|
||||
test('accepts sanitized debug JSONL with message, message-part, and tool cache markers', () => {
|
||||
const filePath = writeDebugJsonl([validRecord]);
|
||||
const result = runValidator(['anthropic', filePath]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.backend, 'anthropic');
|
||||
assert.equal(parsed.records, 1);
|
||||
assert.equal(parsed.providerOptionEntries, 3);
|
||||
assert.equal(parsed.cacheMarkerEntries, 3);
|
||||
assert.deepEqual(parsed.markerTargets, ['message', 'message-part', 'tool']);
|
||||
assert.deepEqual(parsed.ttlValues, ['1h', '5m']);
|
||||
});
|
||||
|
||||
test('rejects debug JSONL that lacks nested message-part cache marker evidence', () => {
|
||||
const filePath = writeDebugJsonl([
|
||||
{
|
||||
...validRecord,
|
||||
providerOptions: validRecord.providerOptions.filter((entry) => entry.target !== 'message-part'),
|
||||
},
|
||||
]);
|
||||
const result = runValidator(['anthropic', filePath]);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /missing cacheControl marker target: message-part/);
|
||||
});
|
||||
|
||||
test('rejects prompt-shaped fields in debug JSONL', () => {
|
||||
const filePath = writeDebugJsonl([{ ...validRecord, text: 'SECRET PROMPT' }]);
|
||||
const result = runValidator(['anthropic', filePath]);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /prompt, schema, credential, or token-shaped field/);
|
||||
});
|
||||
|
||||
test('rejects direct-Anthropic extended cache beta header in Vertex debug summaries', () => {
|
||||
const filePath = writeDebugJsonl([
|
||||
{
|
||||
...validRecord,
|
||||
providerOptions: [
|
||||
...validRecord.providerOptions,
|
||||
{
|
||||
target: 'message',
|
||||
index: 0,
|
||||
role: 'system',
|
||||
providerOptions: { header: 'extended-cache-ttl-2025-04-11' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const result = runValidator(['vertex', filePath]);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /direct-Anthropic extended cache TTL beta header/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue