ktx/packages/cli/src/context/scan/relationship-graph-resolver.ts

509 lines
17 KiB
TypeScript
Raw Normal View History

2026-05-10 23:12:26 +02:00
import type {
2026-05-10 23:51:24 +02:00
KtxEnrichedColumn,
KtxEnrichedSchema,
KtxEnrichedTable,
KtxRelationshipEndpoint,
2026-05-10 23:12:26 +02:00
} from './enrichment-types.js';
2026-05-10 23:51:24 +02:00
import { normalizeKtxRelationshipName } from './relationship-candidates.js';
import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js';
import { scoreKtxRelationshipCandidate } from './relationship-scoring.js';
import type { KtxValidatedRelationshipDiscoveryCandidate } from './relationship-validation.js';
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
export type KtxResolvedRelationshipStatus = 'accepted' | 'review' | 'rejected';
2026-05-10 23:12:26 +02:00
chore(workspace): gate dead-code with knip production mode (#196) * refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
interface KtxRelationshipGraphResolverSettings {
2026-05-10 23:12:26 +02:00
acceptThreshold: number;
reviewThreshold: number;
minTargetPkScoreForAcceptance: number;
validationRequiredForManifest: boolean;
}
chore(workspace): gate dead-code with knip production mode (#196) * refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
interface KtxResolvedRelationshipPkEvidence {
2026-05-10 23:12:26 +02:00
declaredPrimaryKey: boolean;
targetUniqueness: number;
incomingAcceptedCount: number;
incomingReviewCount: number;
reasons: string[];
}
chore(workspace): gate dead-code with knip production mode (#196) * refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
interface KtxResolvedRelationshipPk {
2026-05-10 23:12:26 +02:00
table: string;
columns: string[];
pkScore: number;
2026-05-10 23:51:24 +02:00
status: KtxResolvedRelationshipStatus;
2026-05-10 23:12:26 +02:00
incomingCandidateCount: number;
2026-05-10 23:51:24 +02:00
evidence: KtxResolvedRelationshipPkEvidence;
2026-05-10 23:12:26 +02:00
}
chore(workspace): gate dead-code with knip production mode (#196) * refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
interface KtxResolvedRelationshipGraphEvidence {
2026-05-10 23:12:26 +02:00
targetPkScore: number;
incomingCandidateCount: number;
conflictRank: number;
reasons: string[];
}
2026-05-10 23:51:24 +02:00
export interface KtxResolvedRelationshipDiscoveryCandidate
extends Omit<KtxValidatedRelationshipDiscoveryCandidate, 'status'> {
status: KtxResolvedRelationshipStatus;
2026-05-10 23:12:26 +02:00
pkScore: number;
fkScore: number;
2026-05-10 23:51:24 +02:00
graph: KtxResolvedRelationshipGraphEvidence;
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
export interface KtxRelationshipGraphResolutionResult {
pks: KtxResolvedRelationshipPk[];
relationships: KtxResolvedRelationshipDiscoveryCandidate[];
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
export interface ResolveKtxRelationshipGraphInput {
schema: KtxEnrichedSchema;
profiles: KtxRelationshipProfileArtifact;
candidates: readonly KtxValidatedRelationshipDiscoveryCandidate[];
settings?: Partial<KtxRelationshipGraphResolverSettings>;
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
const DEFAULT_SETTINGS: KtxRelationshipGraphResolverSettings = {
2026-05-10 23:12:26 +02:00
acceptThreshold: 0.85,
reviewThreshold: 0.55,
minTargetPkScoreForAcceptance: 0.78,
validationRequiredForManifest: true,
};
const PROFILE_ONLY_PK_MEASURE_NAME_TOKENS = new Set(['amount', 'count', 'price', 'quantity', 'subtotal', 'total']);
function mergeSettings(
2026-05-10 23:51:24 +02:00
settings: Partial<KtxRelationshipGraphResolverSettings> | undefined,
): KtxRelationshipGraphResolverSettings {
2026-05-10 23:12:26 +02:00
return { ...DEFAULT_SETTINGS, ...settings };
}
function roundScore(value: number): number {
return Number(Math.max(0, Math.min(1, value)).toFixed(3));
}
2026-05-10 23:51:24 +02:00
function endpointKey(endpoint: KtxRelationshipEndpoint): string {
2026-05-10 23:12:26 +02:00
return `${endpoint.table.name}.${singleRelationshipColumn(endpoint)}`;
}
2026-05-10 23:51:24 +02:00
function sourceKey(endpoint: KtxRelationshipEndpoint): string {
2026-05-10 23:12:26 +02:00
return `${endpoint.tableId}:${endpoint.columnIds.join(',')}`;
}
2026-05-10 23:51:24 +02:00
function singleRelationshipColumn(endpoint: KtxRelationshipEndpoint): string {
2026-05-10 23:12:26 +02:00
const column = endpoint.columns[0];
if (!column) {
throw new Error(`Expected relationship endpoint ${endpoint.table.name} to contain one column`);
}
return column;
}
2026-05-10 23:51:24 +02:00
function pkKey(pk: Pick<KtxResolvedRelationshipPk, 'table' | 'columns'>): string {
2026-05-10 23:12:26 +02:00
return `${pk.table}.(${pk.columns.join(',')})`;
}
2026-05-10 23:51:24 +02:00
function candidateSortKey(candidate: Pick<KtxValidatedRelationshipDiscoveryCandidate, 'from' | 'to'>): string {
2026-05-10 23:12:26 +02:00
return `${candidate.from.table.name}.${singleRelationshipColumn(candidate.from)}->${candidate.to.table.name}.${singleRelationshipColumn(candidate.to)}`;
}
function statusForScore(
score: number,
2026-05-10 23:51:24 +02:00
settings: KtxRelationshipGraphResolverSettings,
2026-05-10 23:12:26 +02:00
acceptedAllowed: boolean,
2026-05-10 23:51:24 +02:00
): KtxResolvedRelationshipStatus {
2026-05-10 23:12:26 +02:00
if (acceptedAllowed && score >= settings.acceptThreshold) {
return 'accepted';
}
if (score >= settings.reviewThreshold) {
return 'review';
}
return 'rejected';
}
2026-05-10 23:51:24 +02:00
function candidateHasValidationPassed(candidate: KtxValidatedRelationshipDiscoveryCandidate): boolean {
2026-05-10 23:12:26 +02:00
return candidate.validation.reasons.includes('validation_passed');
}
2026-05-10 23:51:24 +02:00
function candidateIsValidationUnavailable(candidate: KtxValidatedRelationshipDiscoveryCandidate): boolean {
2026-05-10 23:12:26 +02:00
return (
candidate.validation.reasons.includes('validation_unavailable') ||
candidate.validation.reasons.includes('profile_unavailable')
);
}
2026-05-10 23:51:24 +02:00
function declaredPrimaryKeys(schema: KtxEnrichedSchema): KtxResolvedRelationshipPk[] {
const pks: KtxResolvedRelationshipPk[] = [];
2026-05-10 23:12:26 +02:00
for (const table of schema.tables.filter((candidate) => candidate.enabled)) {
for (const column of table.columns.filter((candidate) => candidate.primaryKey)) {
pks.push({
table: table.ref.name,
columns: [column.name],
pkScore: 1,
status: 'accepted',
incomingCandidateCount: 0,
evidence: {
declaredPrimaryKey: true,
targetUniqueness: 1,
incomingAcceptedCount: 0,
incomingReviewCount: 0,
reasons: ['declared_primary_key'],
},
});
}
}
return pks;
}
2026-05-10 23:51:24 +02:00
function schemaTargetColumns(schema: KtxEnrichedSchema): Array<{ table: KtxEnrichedTable; column: KtxEnrichedColumn }> {
2026-05-10 23:12:26 +02:00
return schema.tables
.filter((table) => table.enabled)
.flatMap((table) => table.columns.map((column) => ({ table, column })));
}
2026-05-10 23:51:24 +02:00
function profileUniqueness(profiles: KtxRelationshipProfileArtifact, tableName: string, columnName: string): number {
2026-05-10 23:12:26 +02:00
return profiles.columns[`${tableName}.${columnName}`]?.uniquenessRatio ?? 0;
}
2026-05-10 23:51:24 +02:00
function profileNullRate(profiles: KtxRelationshipProfileArtifact, tableName: string, columnName: string): number {
2026-05-10 23:12:26 +02:00
return profiles.columns[`${tableName}.${columnName}`]?.nullRate ?? 1;
}
2026-05-10 23:51:24 +02:00
function profileColumnExists(profiles: KtxRelationshipProfileArtifact, tableName: string, columnName: string): boolean {
2026-05-10 23:12:26 +02:00
return Boolean(profiles.columns[`${tableName}.${columnName}`]);
}
function profileOnlyPkNameScore(tableName: string, columnName: string): number {
2026-05-10 23:51:24 +02:00
const table = normalizeKtxRelationshipName(tableName).singular;
const column = normalizeKtxRelationshipName(columnName).normalized;
2026-05-10 23:12:26 +02:00
if (column === 'id') {
return 1;
}
if (column === `${table}_id`) {
return 0.96;
}
if (column === `${table}_key`) {
return 0.88;
}
if (column === 'key' || column === 'uuid') {
return 0.76;
}
return 0;
}
function profileOnlyPkTypeCompatibility(columnName: string): number {
2026-05-10 23:51:24 +02:00
const tokens = normalizeKtxRelationshipName(columnName).normalized.split('_').filter(Boolean);
2026-05-10 23:12:26 +02:00
return tokens.some((token) => PROFILE_ONLY_PK_MEASURE_NAME_TOKENS.has(token)) ? 0 : 1;
}
function profileOnlyPkEvidence(input: {
2026-05-10 23:51:24 +02:00
profiles: KtxRelationshipProfileArtifact;
2026-05-10 23:12:26 +02:00
tableName: string;
columnName: string;
}): { nameScore: number; nullRate: number; uniqueness: number; pkScore: number; weakName: boolean } | null {
if (!profileColumnExists(input.profiles, input.tableName, input.columnName)) {
return null;
}
const uniqueness = profileUniqueness(input.profiles, input.tableName, input.columnName);
const nullRate = profileNullRate(input.profiles, input.tableName, input.columnName);
const nameScore = profileOnlyPkNameScore(input.tableName, input.columnName);
if (uniqueness < 0.98 || nullRate > 0.05) {
return null;
}
const typeCompatibility = profileOnlyPkTypeCompatibility(input.columnName);
2026-05-10 23:51:24 +02:00
const scoreBreakdown = scoreKtxRelationshipCandidate(
2026-05-10 23:12:26 +02:00
{
nameSimilarity: nameScore,
typeCompatibility,
valueOverlap: 0,
embeddingSimilarity: 0,
profileUniqueness: uniqueness,
profileNullRate: 1 - nullRate,
structuralPrior: 0.65,
},
{
nameSimilarity: 0.2,
typeCompatibility: 0.08,
valueOverlap: 0,
embeddingSimilarity: 0,
profileUniqueness: 0.48,
profileNullRate: 0.2,
structuralPrior: 0.04,
},
);
if (scoreBreakdown.score < DEFAULT_SETTINGS.reviewThreshold) {
return null;
}
return { nameScore, nullRate, uniqueness, pkScore: scoreBreakdown.score, weakName: nameScore < 0.74 };
}
function resolveTargetPk(input: {
table: string;
column: string;
2026-05-10 23:51:24 +02:00
declared: KtxResolvedRelationshipPk | undefined;
profiles: KtxRelationshipProfileArtifact;
incoming: readonly KtxValidatedRelationshipDiscoveryCandidate[];
settings: KtxRelationshipGraphResolverSettings;
2026-05-10 23:12:26 +02:00
profileOnly?: { nameScore: number; nullRate: number; uniqueness: number; pkScore: number; weakName: boolean } | null;
2026-05-10 23:51:24 +02:00
}): KtxResolvedRelationshipPk {
2026-05-10 23:12:26 +02:00
if (input.declared) {
return input.declared;
}
const targetUniqueness = profileUniqueness(input.profiles, input.table, input.column);
const incomingAccepted = input.incoming.filter((candidate) => candidate.status === 'accepted');
const incomingReview = input.incoming.filter((candidate) => candidate.status === 'review');
const incomingQuality = Math.max(0, ...input.incoming.map((candidate) => candidate.score));
const incomingVolume = Math.min(1, incomingAccepted.length * 0.3 + incomingReview.length * 0.15);
const keyEvidence = Math.max(0, ...input.incoming.map((candidate) => candidate.evidence.targetKeyScore));
const reasons: string[] = [];
if (targetUniqueness >= 0.9) {
reasons.push('unique_target_column');
}
if (incomingAccepted.length > 0) {
reasons.push('incoming_validated_reference');
}
if (incomingReview.length > 0) {
reasons.push('incoming_review_reference');
}
if (keyEvidence >= 0.8) {
reasons.push('target_key_like');
}
if (input.incoming.length === 0) {
reasons.push('no_incoming_references');
}
if (input.profileOnly) {
reasons.push('not_null_profile', 'profile_only_primary_key');
if (input.profileOnly.weakName) {
reasons.push('weak_name_profile_key');
} else {
reasons.push('profile_key_name');
}
const pkScore = input.profileOnly.pkScore;
return {
table: input.table,
columns: [input.column],
pkScore,
status: statusForScore(pkScore, input.settings, !input.profileOnly.weakName),
incomingCandidateCount: 0,
evidence: {
declaredPrimaryKey: false,
targetUniqueness,
incomingAcceptedCount: 0,
incomingReviewCount: 0,
reasons,
},
};
}
const pkScore = roundScore(0.52 * targetUniqueness + 0.28 * incomingQuality + 0.12 * keyEvidence + 0.08 * incomingVolume);
const acceptedAllowed = incomingAccepted.length > 0 && targetUniqueness >= 0.9;
const status =
incomingReview.length > 0 && pkScore < input.settings.reviewThreshold
? 'review'
: statusForScore(pkScore, input.settings, acceptedAllowed);
return {
table: input.table,
columns: [input.column],
pkScore,
status,
incomingCandidateCount: input.incoming.length,
evidence: {
declaredPrimaryKey: false,
targetUniqueness,
incomingAcceptedCount: incomingAccepted.length,
incomingReviewCount: incomingReview.length,
reasons,
},
};
}
function baseRelationshipResolution(input: {
2026-05-10 23:51:24 +02:00
candidate: KtxValidatedRelationshipDiscoveryCandidate;
pk: KtxResolvedRelationshipPk;
settings: KtxRelationshipGraphResolverSettings;
}): KtxResolvedRelationshipDiscoveryCandidate {
2026-05-10 23:12:26 +02:00
const reasons: string[] = [];
if (input.candidate.status === 'rejected') {
reasons.push('candidate_validation_rejected');
}
if (candidateIsValidationUnavailable(input.candidate)) {
reasons.push('validation_unavailable_review_only');
}
if (input.pk.pkScore >= input.settings.minTargetPkScoreForAcceptance) {
reasons.push('target_pk_score_passed');
} else {
reasons.push('target_pk_score_low');
}
if (candidateHasValidationPassed(input.candidate)) {
reasons.push('validation_passed');
}
const validationPassBonus = candidateHasValidationPassed(input.candidate) ? 1 : 0;
let fkScore = roundScore(
0.48 * input.candidate.score +
0.3 * input.pk.pkScore +
0.14 * input.candidate.confidence +
0.08 * validationPassBonus,
);
2026-05-10 23:51:24 +02:00
let status: KtxResolvedRelationshipStatus;
2026-05-10 23:12:26 +02:00
if (input.candidate.status === 'rejected') {
status = 'rejected';
} else if (candidateIsValidationUnavailable(input.candidate)) {
status = 'review';
fkScore = Math.max(fkScore, input.settings.reviewThreshold);
} else {
const acceptedAllowed =
input.candidate.status === 'accepted' &&
input.pk.pkScore >= input.settings.minTargetPkScoreForAcceptance &&
(!input.settings.validationRequiredForManifest || candidateHasValidationPassed(input.candidate));
status = statusForScore(fkScore, input.settings, acceptedAllowed);
}
if (status === 'accepted') {
reasons.push('fk_score_passed');
} else if (status === 'review') {
reasons.push('fk_score_review');
} else {
reasons.push('fk_score_rejected');
}
return {
...input.candidate,
status,
pkScore: input.pk.pkScore,
fkScore,
graph: {
targetPkScore: input.pk.pkScore,
incomingCandidateCount: input.pk.incomingCandidateCount,
conflictRank: 1,
reasons,
},
};
}
function relationshipRank(
2026-05-10 23:51:24 +02:00
left: KtxResolvedRelationshipDiscoveryCandidate,
right: KtxResolvedRelationshipDiscoveryCandidate,
2026-05-10 23:12:26 +02:00
): number {
return (
right.fkScore - left.fkScore ||
right.validation.sourceCoverage - left.validation.sourceCoverage ||
right.pkScore - left.pkScore ||
candidateSortKey(left).localeCompare(candidateSortKey(right))
);
}
function applySourceConflicts(
2026-05-10 23:51:24 +02:00
relationships: readonly KtxResolvedRelationshipDiscoveryCandidate[],
): KtxResolvedRelationshipDiscoveryCandidate[] {
const bySource = new Map<string, KtxResolvedRelationshipDiscoveryCandidate[]>();
2026-05-10 23:12:26 +02:00
for (const relationship of relationships) {
const key = sourceKey(relationship.from);
bySource.set(key, [...(bySource.get(key) ?? []), relationship]);
}
2026-05-10 23:51:24 +02:00
const resolved: KtxResolvedRelationshipDiscoveryCandidate[] = [];
2026-05-10 23:12:26 +02:00
for (const group of bySource.values()) {
const ranked = [...group].sort(relationshipRank);
let acceptedSeen = false;
ranked.forEach((relationship, index) => {
const conflictRank = index + 1;
if (relationship.status === 'accepted' && acceptedSeen) {
resolved.push({
...relationship,
status: 'rejected',
graph: {
...relationship.graph,
conflictRank,
reasons: [...relationship.graph.reasons.filter((reason) => reason !== 'fk_score_passed'), 'conflict_lost'],
},
});
return;
}
if (relationship.status === 'accepted') {
acceptedSeen = true;
}
resolved.push({
...relationship,
graph: {
...relationship.graph,
conflictRank,
},
});
});
}
return resolved.sort(relationshipRank);
}
2026-05-10 23:51:24 +02:00
export function resolveKtxRelationshipGraph(
input: ResolveKtxRelationshipGraphInput,
): KtxRelationshipGraphResolutionResult {
2026-05-10 23:12:26 +02:00
const settings = mergeSettings(input.settings);
const declared = declaredPrimaryKeys(input.schema);
const declaredByKey = new Map(declared.map((pk) => [pkKey(pk), pk]));
2026-05-10 23:51:24 +02:00
const incomingByTarget = new Map<string, KtxValidatedRelationshipDiscoveryCandidate[]>();
2026-05-10 23:12:26 +02:00
for (const candidate of input.candidates) {
const key = endpointKey(candidate.to);
incomingByTarget.set(key, [...(incomingByTarget.get(key) ?? []), candidate]);
}
2026-05-10 23:51:24 +02:00
const pkCandidates = new Map<string, KtxResolvedRelationshipPk>();
2026-05-10 23:12:26 +02:00
for (const item of schemaTargetColumns(input.schema)) {
const key = `${item.table.ref.name}.(${item.column.name})`;
const incoming = incomingByTarget.get(`${item.table.ref.name}.${item.column.name}`) ?? [];
const profileOnly =
incoming.length === 0 && !item.column.primaryKey
? profileOnlyPkEvidence({
profiles: input.profiles,
tableName: item.table.ref.name,
columnName: item.column.name,
})
: null;
if (incoming.length === 0 && !item.column.primaryKey && !profileOnly) {
continue;
}
const pk = resolveTargetPk({
table: item.table.ref.name,
column: item.column.name,
declared: declaredByKey.get(key),
profiles: input.profiles,
incoming,
settings,
profileOnly,
});
pkCandidates.set(key, pk);
}
const relationships = input.candidates.map((candidate) => {
const toColumn = singleRelationshipColumn(candidate.to);
const key = `${candidate.to.table.name}.(${toColumn})`;
const pk =
pkCandidates.get(key) ??
resolveTargetPk({
table: candidate.to.table.name,
column: toColumn,
declared: undefined,
profiles: input.profiles,
incoming: incomingByTarget.get(endpointKey(candidate.to)) ?? [],
settings,
profileOnly: null,
});
pkCandidates.set(key, pk);
return baseRelationshipResolution({ candidate, pk, settings });
});
return {
pks: Array.from(pkCandidates.values()).sort(
(left, right) => right.pkScore - left.pkScore || pkKey(left).localeCompare(pkKey(right)),
),
relationships: applySourceConflicts(relationships),
};
}