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),
|
|
|
|
|
};
|
|
|
|
|
}
|