@query-doctor/core 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/defineProperty.cjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/defineProperty.mjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/toPrimitive.cjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/toPrimitive.mjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/toPropertyKey.cjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/toPropertyKey.mjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/typeof.cjs +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.126.0 → _@oxc-project_runtime@0.127.0}/helpers/typeof.mjs +1 -1
- package/dist/action-plan/build-action-plan.cjs.map +1 -1
- package/dist/action-plan/build-action-plan.mjs.map +1 -1
- package/dist/action-plan/index-coverage.cjs.map +1 -1
- package/dist/action-plan/index-coverage.mjs.map +1 -1
- package/dist/ci/policy.cjs.map +1 -1
- package/dist/ci/policy.mjs.map +1 -1
- package/dist/ci/verdict.cjs +6 -0
- package/dist/ci/verdict.cjs.map +1 -1
- package/dist/ci/verdict.mjs +6 -0
- package/dist/ci/verdict.mjs.map +1 -1
- package/dist/findings/query-findings.cjs.map +1 -1
- package/dist/findings/query-findings.mjs.map +1 -1
- package/dist/optimizer/dump.cjs.map +1 -1
- package/dist/optimizer/dump.mjs.map +1 -1
- package/dist/optimizer/genalgo.cjs +1 -1
- package/dist/optimizer/genalgo.cjs.map +1 -1
- package/dist/optimizer/genalgo.mjs +1 -1
- package/dist/optimizer/genalgo.mjs.map +1 -1
- package/dist/optimizer/pss-rewriter.cjs +1 -1
- package/dist/optimizer/pss-rewriter.cjs.map +1 -1
- package/dist/optimizer/pss-rewriter.mjs +1 -1
- package/dist/optimizer/pss-rewriter.mjs.map +1 -1
- package/dist/optimizer/statistics.cjs +1 -1
- package/dist/optimizer/statistics.cjs.map +1 -1
- package/dist/optimizer/statistics.mjs +1 -1
- package/dist/optimizer/statistics.mjs.map +1 -1
- package/dist/query.cjs +1 -0
- package/dist/query.cjs.map +1 -1
- package/dist/query.d.cts +2 -0
- package/dist/query.d.cts.map +1 -1
- package/dist/query.d.mts +2 -0
- package/dist/query.d.mts.map +1 -1
- package/dist/query.mjs +1 -0
- package/dist/query.mjs.map +1 -1
- package/dist/sql/analyzer.cjs.map +1 -1
- package/dist/sql/analyzer.mjs.map +1 -1
- package/dist/sql/ast-utils.cjs.map +1 -1
- package/dist/sql/ast-utils.mjs.map +1 -1
- package/dist/sql/builder.cjs +1 -1
- package/dist/sql/builder.mjs +1 -1
- package/dist/sql/display-query.cjs.map +1 -1
- package/dist/sql/display-query.mjs.map +1 -1
- package/dist/sql/nudges.cjs.map +1 -1
- package/dist/sql/nudges.mjs.map +1 -1
- package/dist/sql/pg-identifier.cjs +1 -1
- package/dist/sql/pg-identifier.mjs +1 -1
- package/dist/sql/test-origin.cjs +1 -1
- package/dist/sql/test-origin.cjs.map +1 -1
- package/dist/sql/test-origin.d.cts.map +1 -1
- package/dist/sql/test-origin.d.mts.map +1 -1
- package/dist/sql/test-origin.mjs +1 -1
- package/dist/sql/test-origin.mjs.map +1 -1
- package/dist/sql/walker.cjs +1 -1
- package/dist/sql/walker.cjs.map +1 -1
- package/dist/sql/walker.mjs +1 -1
- package/dist/sql/walker.mjs.map +1 -1
- package/dist/websocket-server.d.cts +2 -2
- package/dist/websocket-server.d.cts.map +1 -1
- package/dist/websocket-server.d.mts +2 -2
- package/dist/websocket-server.d.mts.map +1 -1
- package/package.json +7 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
const require_toPropertyKey = require("./toPropertyKey.cjs");
|
|
3
|
-
//#region \0@oxc-project+runtime@0.
|
|
3
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/defineProperty.js
|
|
4
4
|
function _defineProperty(e, r, t) {
|
|
5
5
|
return (r = require_toPropertyKey.toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
6
6
|
value: t,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { toPropertyKey } from "./toPropertyKey.mjs";
|
|
3
|
-
//#region \0@oxc-project+runtime@0.
|
|
3
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/defineProperty.js
|
|
4
4
|
function _defineProperty(e, r, t) {
|
|
5
5
|
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
6
6
|
value: t,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
const require_typeof = require("./typeof.cjs");
|
|
3
|
-
//#region \0@oxc-project+runtime@0.
|
|
3
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/toPrimitive.js
|
|
4
4
|
function toPrimitive(t, r) {
|
|
5
5
|
if ("object" != require_typeof._typeof(t) || !t) return t;
|
|
6
6
|
var e = t[Symbol.toPrimitive];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { _typeof } from "./typeof.mjs";
|
|
3
|
-
//#region \0@oxc-project+runtime@0.
|
|
3
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/toPrimitive.js
|
|
4
4
|
function toPrimitive(t, r) {
|
|
5
5
|
if ("object" != _typeof(t) || !t) return t;
|
|
6
6
|
var e = t[Symbol.toPrimitive];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
const require_typeof = require("./typeof.cjs");
|
|
3
3
|
const require_toPrimitive = require("./toPrimitive.cjs");
|
|
4
|
-
//#region \0@oxc-project+runtime@0.
|
|
4
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/toPropertyKey.js
|
|
5
5
|
function toPropertyKey(t) {
|
|
6
6
|
var i = require_toPrimitive.toPrimitive(t, "string");
|
|
7
7
|
return "symbol" == require_typeof._typeof(i) ? i : i + "";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { _typeof } from "./typeof.mjs";
|
|
3
3
|
import { toPrimitive } from "./toPrimitive.mjs";
|
|
4
|
-
//#region \0@oxc-project+runtime@0.
|
|
4
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/toPropertyKey.js
|
|
5
5
|
function toPropertyKey(t) {
|
|
6
6
|
var i = toPrimitive(t, "string");
|
|
7
7
|
return "symbol" == _typeof(i) ? i : i + "";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
//#region \0@oxc-project+runtime@0.
|
|
2
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/typeof.js
|
|
3
3
|
function _typeof(o) {
|
|
4
4
|
"@babel/helpers - typeof";
|
|
5
5
|
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
//#region \0@oxc-project+runtime@0.
|
|
2
|
+
//#region \0@oxc-project+runtime@0.127.0/helpers/typeof.js
|
|
3
3
|
function _typeof(o) {
|
|
4
4
|
"@babel/helpers - typeof";
|
|
5
5
|
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-action-plan.cjs","names":["aggregateIndexRecommendations","groupIndexesByCoverage"],"sources":["../../src/action-plan/build-action-plan.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { Nudge } from \"../sql/nudges.js\";\nimport {\n aggregateIndexRecommendations,\n type ActionPlanQuery,\n} from \"./aggregate-index-recommendations.js\";\nimport {\n groupIndexesByCoverage,\n type AggregatedIndexRecommendation,\n} from \"./index-coverage.js\";\n\n/**\n * Bundling key for a step. v0.9/v1 = the affected table (`schema.table`);\n * #3100 widens this to a table-cluster.\n */\nexport type DomainLabel = string;\n\ntype IndexActionColumns = IndexRecommendation[\"columns\"];\n\n/**\n * One index change inside a step. A step is a unit of work applied in one go,\n * so it carries 1..N of these. Only `create` ships in #3098; the `drop` variant\n * exists for the type and lands with full-DB unused detection (#3120).\n */\nexport type IndexAction =\n | {\n op: \"create\";\n definition: string;\n columns: IndexActionColumns;\n /** Prefix indexes this one absorbs (covering `(a,b,c)` absorbs `(a,b)`). */\n coveredDefinitions: string[];\n /** Union of query hashes this create helps, including absorbed prefixes. */\n affectedQueryHashes: string[];\n }\n | { op: \"drop\"; definition: string; reason: \"unused\" | \"redundant\" };\n\n/** Before/after planner cost for one query the step affects. */\nexport interface AffectedQueryCost {\n hash: string;\n cost: number;\n optimizedCost: number;\n}\n\nexport interface CostReduction {\n average: number;\n best: number;\n total: number;\n}\n\n/**\n * A query whose cost rose past the configured regression threshold. The caller\n * (which owns the threshold config and the shared cost-delta rounding contract)\n * detects the breach; this module only shapes and ranks it.\n */\nexport interface RegressionBreach {\n queryHash: string;\n /** Current planner cost. */\n cost: number;\n /** Baseline cost the breach is measured against. */\n baselineCost: number;\n /** Increase over baseline, in percent. Pre-rounded by the caller. */\n increasePercentage: number;\n}\n\n/** A single anti-pattern finding shown inside a nudge step. */\nexport type NudgeFinding = Pick<Nudge, \"kind\" | \"severity\" | \"message\">;\n\n/**\n * One analyzed query plus its anti-pattern nudges, scoped current-state by the\n * caller. The two synthetic optimizer nudges (`*_IMPROVEMENT_FOUND`) are not\n * anti-patterns — they restate the index win — and are dropped here, not by the\n * caller.\n */\nexport interface ActionPlanNudgeQuery {\n hash: string;\n nudges: readonly Nudge[];\n}\n\n/**\n * Severity → ranking weight, mirroring `NUDGE_SEVERITY_WEIGHTS` in\n * `apps/app/src/components/nudge-scoring.ts`. Duplicated because core cannot\n * import from the app; keep the two in sync. Drives both a card's placement\n * (its top severity) and the within-tier sort (summed weight).\n */\nconst NUDGE_SEVERITY_WEIGHT: Record<Nudge[\"severity\"], number> = {\n CRITICAL: 16,\n WARNING: 4,\n INFO: 1,\n};\n\n/**\n * Optimizer-emitted nudges that announce an available improvement rather than a\n * query anti-pattern. The index win they describe is already a `index` step, so\n * they never become a nudge action.\n */\nconst NON_ANTIPATTERN_NUDGE_KINDS = new Set<Nudge[\"kind\"]>([\n \"LARGE_IMPROVEMENT_FOUND\",\n \"SMALL_IMPROVEMENT_FOUND\",\n]);\n\n/**\n * A prioritized recommendation in the database-health action plan, a\n * discriminated union over action kinds: `index` (#3098), `regression` (#3099)\n * and `nudge` (#3102).\n *\n * Tiering across kinds is positional, not by `value` — the kinds' `value`\n * scores carry different units and are never compared across tiers. The order\n * is regression → CRITICAL nudge → index → WARNING nudge → INFO nudge: a\n * \"something got worse\" signal and a critical anti-pattern outrank a \"do this\n * to improve\" suggestion, while lower-severity advice falls below it. See\n * {@link buildActionPlan}.\n */\nexport type ActionableStep =\n | {\n kind: \"index\";\n /**\n * Stable, content-derived identity: hash(domain + sorted index ops). Used\n * as the React key, the client freeze-diff key, and future triage identity.\n */\n key: string;\n domain: DomainLabel;\n /** 1..N index actions bundled for this domain. */\n indexes: IndexAction[];\n /** UNION of affected query hashes across the bundle — never a sum. */\n affectedQueryCount: number;\n /** Per-affected-query before/after cost, for display. */\n affectedQueries: AffectedQueryCost[];\n costReduction: CostReduction;\n /** Ranking score = total absolute reduction. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"regression\";\n /** Stable, content-derived identity: hash(\"regression\" + queryHash). */\n key: string;\n queryHash: string;\n cost: number;\n baselineCost: number;\n increasePercentage: number;\n /** Within-tier ranking score = increasePercentage. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"nudge\";\n /** Stable, content-derived identity: hash(\"nudge\" + queryHash). */\n key: string;\n queryHash: string;\n /** The query's anti-pattern findings, highest severity first. */\n nudges: NudgeFinding[];\n /** Highest severity across the findings — sets the card's tier. */\n severity: Nudge[\"severity\"];\n /** Within-tier ranking score = summed severity weight. Hidden. */\n value: number;\n };\n\n/**\n * Consolidate analyzed queries and detected regressions into one prioritized\n * action plan.\n *\n * - Tiering is positional across kinds: regression → CRITICAL nudge → index →\n * WARNING nudge → INFO nudge. A regression or critical anti-pattern (both\n * \"something is wrong\") outranks an index suggestion (\"do this to improve\");\n * lower-severity advice falls below it. Cross-kind `value`s carry different\n * units and are never compared. Within regressions, sort by\n * `increasePercentage` descending; within indexes, by summed absolute cost\n * reduction; within a nudge tier, by summed severity weight.\n * - Index bundling: one step per domain (table). Non-overlapping indexes on the\n * same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.\n * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over\n * the UNION of affected query hashes, each query counted once.\n * - Nudge bundling: one step per query, carrying all its anti-pattern findings;\n * the step's tier is the query's highest severity.\n *\n * Callers are expected to pass current-state, latest-per-hash queries; this\n * module does not gate on age or recency.\n */\nexport function buildActionPlan(\n queries: readonly ActionPlanQuery[],\n regressions: readonly RegressionBreach[] = [],\n nudgeQueries: readonly ActionPlanNudgeQuery[] = [],\n): ActionableStep[] {\n const nudges = buildNudgeSteps(nudgeQueries);\n const bySeverity = (severity: Nudge[\"severity\"]) =>\n nudges.filter((step) => step.severity === severity);\n\n // Positional tiers, each block already internally sorted. Critical nudges sit\n // above indexes; warning/info advice sits below.\n return [\n ...buildRegressionSteps(regressions),\n ...bySeverity(\"CRITICAL\"),\n ...buildIndexSteps(queries),\n ...bySeverity(\"WARNING\"),\n ...bySeverity(\"INFO\"),\n ];\n}\n\nfunction buildRegressionSteps(\n regressions: readonly RegressionBreach[],\n): ActionableStep[] {\n return regressions\n .map(\n (breach): ActionableStep => ({\n kind: \"regression\",\n key: contentHash(`regression|${breach.queryHash}`),\n queryHash: breach.queryHash,\n cost: breach.cost,\n baselineCost: breach.baselineCost,\n increasePercentage: breach.increasePercentage,\n value: breach.increasePercentage,\n }),\n )\n .sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n // Deterministic tie-break by query identity for a stable render order.\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildNudgeSteps(\n nudgeQueries: readonly ActionPlanNudgeQuery[],\n): Extract<ActionableStep, { kind: \"nudge\" }>[] {\n const steps: Extract<ActionableStep, { kind: \"nudge\" }>[] = [];\n for (const { hash, nudges } of nudgeQueries) {\n const findings: NudgeFinding[] = nudges\n .filter((n) => !NON_ANTIPATTERN_NUDGE_KINDS.has(n.kind))\n .map((n) => ({ kind: n.kind, severity: n.severity, message: n.message }))\n // Highest-severity finding first; stable by kind for a deterministic order.\n .sort((a, b) => {\n const weight =\n NUDGE_SEVERITY_WEIGHT[b.severity] - NUDGE_SEVERITY_WEIGHT[a.severity];\n if (weight !== 0) return weight;\n return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;\n });\n\n if (findings.length === 0) continue;\n\n const value = findings.reduce(\n (sum, f) => sum + NUDGE_SEVERITY_WEIGHT[f.severity],\n 0,\n );\n steps.push({\n kind: \"nudge\",\n key: contentHash(`nudge|${hash}`),\n queryHash: hash,\n nudges: findings,\n // Findings are severity-sorted, so the first is the card's top severity.\n severity: findings[0]!.severity,\n value,\n });\n }\n\n // Most severe first; deterministic tie-break by query identity. The caller\n // partitions these into the CRITICAL / WARNING / INFO tiers, preserving order.\n return steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildIndexSteps(\n queries: readonly ActionPlanQuery[],\n): ActionableStep[] {\n // Each query's own before/after cost, used for the absolute ranking. A hash\n // appears once — the first improvements state we see for it wins.\n const queryCost = new Map<string, { cost: number; optimizedCost: number }>();\n for (const query of queries) {\n const o = query.optimization;\n if (\n o.state === \"improvements_available\" &&\n o.cost !== undefined &&\n o.optimizedCost !== undefined &&\n !queryCost.has(query.hash)\n ) {\n queryCost.set(query.hash, {\n cost: o.cost,\n optimizedCost: o.optimizedCost,\n });\n }\n }\n\n // Reuse the shared per-definition consolidation, then bundle by table.\n const byDomain = new Map<DomainLabel, AggregatedIndexRecommendation[]>();\n for (const rec of aggregateIndexRecommendations(queries)) {\n const domain = `${rec.index.schema}.${rec.index.table}`;\n const bucket = byDomain.get(domain);\n if (bucket) bucket.push(rec);\n else byDomain.set(domain, [rec]);\n }\n\n const steps: Extract<ActionableStep, { kind: \"index\" }>[] = [];\n for (const [domain, recs] of byDomain) {\n const indexes: IndexAction[] = groupIndexesByCoverage(recs).map((group) => {\n const hashes = new Set(group.primary.affectedQueryHashes);\n for (const covered of group.covered) {\n for (const hash of covered.affectedQueryHashes) hashes.add(hash);\n }\n return {\n op: \"create\",\n definition: group.primary.index.definition,\n columns: group.primary.index.columns,\n coveredDefinitions: group.covered.map((c) => c.index.definition),\n affectedQueryHashes: [...hashes].sort(),\n };\n });\n\n // Step-level union of scorable queries (those with a known before/after).\n const stepHashes = new Set<string>();\n for (const action of indexes) {\n if (action.op !== \"create\") continue;\n for (const hash of action.affectedQueryHashes) {\n if (queryCost.has(hash)) stepHashes.add(hash);\n }\n }\n\n const affectedQueries: AffectedQueryCost[] = [...stepHashes]\n .sort()\n .map((hash) => {\n const cost = queryCost.get(hash)!;\n return { hash, cost: cost.cost, optimizedCost: cost.optimizedCost };\n });\n\n if (affectedQueries.length === 0) continue;\n\n const reductions = affectedQueries.map((q) => q.cost - q.optimizedCost);\n const total = reductions.reduce((sum, val) => sum + val, 0);\n const best = Math.max(...reductions);\n\n steps.push({\n kind: \"index\",\n key: stepKey(domain, indexes),\n domain,\n indexes,\n affectedQueryCount: affectedQueries.length,\n affectedQueries,\n costReduction: { average: total / reductions.length, best, total },\n value: total,\n });\n }\n\n // Most valuable first; deterministic tie-breaks for a stable render order.\n steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n if (b.affectedQueryCount !== a.affectedQueryCount) {\n return b.affectedQueryCount - a.affectedQueryCount;\n }\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n\n return steps;\n}\n\nfunction stepKey(domain: DomainLabel, indexes: IndexAction[]): string {\n const parts = indexes\n .map((action) => `${action.op}:${action.definition}`)\n .sort();\n return contentHash(`${domain}|${parts.join(\"|\")}`);\n}\n\n/** FNV-1a 32-bit hash → 8-char hex. Deterministic and dependency-free. */\nfunction contentHash(input: string): string {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n return (hash >>> 0).toString(16).padStart(8, \"0\");\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"build-action-plan.cjs","names":["aggregateIndexRecommendations","groupIndexesByCoverage"],"sources":["../../src/action-plan/build-action-plan.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { Nudge } from \"../sql/nudges.js\";\nimport {\n aggregateIndexRecommendations,\n type ActionPlanQuery,\n} from \"./aggregate-index-recommendations.js\";\nimport {\n groupIndexesByCoverage,\n type AggregatedIndexRecommendation,\n} from \"./index-coverage.js\";\n\n/**\n * Bundling key for a step. v0.9/v1 = the affected table (`schema.table`);\n * #3100 widens this to a table-cluster.\n */\nexport type DomainLabel = string;\n\ntype IndexActionColumns = IndexRecommendation[\"columns\"];\n\n/**\n * One index change inside a step. A step is a unit of work applied in one go,\n * so it carries 1..N of these. Only `create` ships in #3098; the `drop` variant\n * exists for the type and lands with full-DB unused detection (#3120).\n */\nexport type IndexAction =\n | {\n op: \"create\";\n definition: string;\n columns: IndexActionColumns;\n /** Prefix indexes this one absorbs (covering `(a,b,c)` absorbs `(a,b)`). */\n coveredDefinitions: string[];\n /** Union of query hashes this create helps, including absorbed prefixes. */\n affectedQueryHashes: string[];\n }\n | { op: \"drop\"; definition: string; reason: \"unused\" | \"redundant\" };\n\n/** Before/after planner cost for one query the step affects. */\nexport interface AffectedQueryCost {\n hash: string;\n cost: number;\n optimizedCost: number;\n}\n\nexport interface CostReduction {\n average: number;\n best: number;\n total: number;\n}\n\n/**\n * A query whose cost rose past the configured regression threshold. The caller\n * (which owns the threshold config and the shared cost-delta rounding contract)\n * detects the breach; this module only shapes and ranks it.\n */\nexport interface RegressionBreach {\n queryHash: string;\n /** Current planner cost. */\n cost: number;\n /** Baseline cost the breach is measured against. */\n baselineCost: number;\n /** Increase over baseline, in percent. Pre-rounded by the caller. */\n increasePercentage: number;\n}\n\n/** A single anti-pattern finding shown inside a nudge step. */\nexport type NudgeFinding = Pick<Nudge, \"kind\" | \"severity\" | \"message\">;\n\n/**\n * One analyzed query plus its anti-pattern nudges, scoped current-state by the\n * caller. The two synthetic optimizer nudges (`*_IMPROVEMENT_FOUND`) are not\n * anti-patterns — they restate the index win — and are dropped here, not by the\n * caller.\n */\nexport interface ActionPlanNudgeQuery {\n hash: string;\n nudges: readonly Nudge[];\n}\n\n/**\n * Severity → ranking weight, mirroring `NUDGE_SEVERITY_WEIGHTS` in\n * `apps/app/src/components/nudge-scoring.ts`. Duplicated because core cannot\n * import from the app; keep the two in sync. Drives both a card's placement\n * (its top severity) and the within-tier sort (summed weight).\n */\nconst NUDGE_SEVERITY_WEIGHT: Record<Nudge[\"severity\"], number> = {\n CRITICAL: 16,\n WARNING: 4,\n INFO: 1,\n};\n\n/**\n * Optimizer-emitted nudges that announce an available improvement rather than a\n * query anti-pattern. The index win they describe is already a `index` step, so\n * they never become a nudge action.\n */\nconst NON_ANTIPATTERN_NUDGE_KINDS = new Set<Nudge[\"kind\"]>([\n \"LARGE_IMPROVEMENT_FOUND\",\n \"SMALL_IMPROVEMENT_FOUND\",\n]);\n\n/**\n * A prioritized recommendation in the database-health action plan, a\n * discriminated union over action kinds: `index` (#3098), `regression` (#3099)\n * and `nudge` (#3102).\n *\n * Tiering across kinds is positional, not by `value` — the kinds' `value`\n * scores carry different units and are never compared across tiers. The order\n * is regression → CRITICAL nudge → index → WARNING nudge → INFO nudge: a\n * \"something got worse\" signal and a critical anti-pattern outrank a \"do this\n * to improve\" suggestion, while lower-severity advice falls below it. See\n * {@link buildActionPlan}.\n */\nexport type ActionableStep =\n | {\n kind: \"index\";\n /**\n * Stable, content-derived identity: hash(domain + sorted index ops). Used\n * as the React key, the client freeze-diff key, and future triage identity.\n */\n key: string;\n domain: DomainLabel;\n /** 1..N index actions bundled for this domain. */\n indexes: IndexAction[];\n /** UNION of affected query hashes across the bundle — never a sum. */\n affectedQueryCount: number;\n /** Per-affected-query before/after cost, for display. */\n affectedQueries: AffectedQueryCost[];\n costReduction: CostReduction;\n /** Ranking score = total absolute reduction. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"regression\";\n /** Stable, content-derived identity: hash(\"regression\" + queryHash). */\n key: string;\n queryHash: string;\n cost: number;\n baselineCost: number;\n increasePercentage: number;\n /** Within-tier ranking score = increasePercentage. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"nudge\";\n /** Stable, content-derived identity: hash(\"nudge\" + queryHash). */\n key: string;\n queryHash: string;\n /** The query's anti-pattern findings, highest severity first. */\n nudges: NudgeFinding[];\n /** Highest severity across the findings — sets the card's tier. */\n severity: Nudge[\"severity\"];\n /** Within-tier ranking score = summed severity weight. Hidden. */\n value: number;\n };\n\n/**\n * Consolidate analyzed queries and detected regressions into one prioritized\n * action plan.\n *\n * - Tiering is positional across kinds: regression → CRITICAL nudge → index →\n * WARNING nudge → INFO nudge. A regression or critical anti-pattern (both\n * \"something is wrong\") outranks an index suggestion (\"do this to improve\");\n * lower-severity advice falls below it. Cross-kind `value`s carry different\n * units and are never compared. Within regressions, sort by\n * `increasePercentage` descending; within indexes, by summed absolute cost\n * reduction; within a nudge tier, by summed severity weight.\n * - Index bundling: one step per domain (table). Non-overlapping indexes on the\n * same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.\n * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over\n * the UNION of affected query hashes, each query counted once.\n * - Nudge bundling: one step per query, carrying all its anti-pattern findings;\n * the step's tier is the query's highest severity.\n *\n * Callers are expected to pass current-state, latest-per-hash queries; this\n * module does not gate on age or recency.\n */\nexport function buildActionPlan(\n queries: readonly ActionPlanQuery[],\n regressions: readonly RegressionBreach[] = [],\n nudgeQueries: readonly ActionPlanNudgeQuery[] = [],\n): ActionableStep[] {\n const nudges = buildNudgeSteps(nudgeQueries);\n const bySeverity = (severity: Nudge[\"severity\"]) =>\n nudges.filter((step) => step.severity === severity);\n\n // Positional tiers, each block already internally sorted. Critical nudges sit\n // above indexes; warning/info advice sits below.\n return [\n ...buildRegressionSteps(regressions),\n ...bySeverity(\"CRITICAL\"),\n ...buildIndexSteps(queries),\n ...bySeverity(\"WARNING\"),\n ...bySeverity(\"INFO\"),\n ];\n}\n\nfunction buildRegressionSteps(\n regressions: readonly RegressionBreach[],\n): ActionableStep[] {\n return regressions\n .map(\n (breach): ActionableStep => ({\n kind: \"regression\",\n key: contentHash(`regression|${breach.queryHash}`),\n queryHash: breach.queryHash,\n cost: breach.cost,\n baselineCost: breach.baselineCost,\n increasePercentage: breach.increasePercentage,\n value: breach.increasePercentage,\n }),\n )\n .sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n // Deterministic tie-break by query identity for a stable render order.\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildNudgeSteps(\n nudgeQueries: readonly ActionPlanNudgeQuery[],\n): Extract<ActionableStep, { kind: \"nudge\" }>[] {\n const steps: Extract<ActionableStep, { kind: \"nudge\" }>[] = [];\n for (const { hash, nudges } of nudgeQueries) {\n const findings: NudgeFinding[] = nudges\n .filter((n) => !NON_ANTIPATTERN_NUDGE_KINDS.has(n.kind))\n .map((n) => ({ kind: n.kind, severity: n.severity, message: n.message }))\n // Highest-severity finding first; stable by kind for a deterministic order.\n .sort((a, b) => {\n const weight =\n NUDGE_SEVERITY_WEIGHT[b.severity] - NUDGE_SEVERITY_WEIGHT[a.severity];\n if (weight !== 0) return weight;\n return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;\n });\n\n if (findings.length === 0) continue;\n\n const value = findings.reduce(\n (sum, f) => sum + NUDGE_SEVERITY_WEIGHT[f.severity],\n 0,\n );\n steps.push({\n kind: \"nudge\",\n key: contentHash(`nudge|${hash}`),\n queryHash: hash,\n nudges: findings,\n // Findings are severity-sorted, so the first is the card's top severity.\n severity: findings[0]!.severity,\n value,\n });\n }\n\n // Most severe first; deterministic tie-break by query identity. The caller\n // partitions these into the CRITICAL / WARNING / INFO tiers, preserving order.\n return steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildIndexSteps(\n queries: readonly ActionPlanQuery[],\n): ActionableStep[] {\n // Each query's own before/after cost, used for the absolute ranking. A hash\n // appears once — the first improvements state we see for it wins.\n const queryCost = new Map<string, { cost: number; optimizedCost: number }>();\n for (const query of queries) {\n const o = query.optimization;\n if (\n o.state === \"improvements_available\" &&\n o.cost !== undefined &&\n o.optimizedCost !== undefined &&\n !queryCost.has(query.hash)\n ) {\n queryCost.set(query.hash, {\n cost: o.cost,\n optimizedCost: o.optimizedCost,\n });\n }\n }\n\n // Reuse the shared per-definition consolidation, then bundle by table.\n const byDomain = new Map<DomainLabel, AggregatedIndexRecommendation[]>();\n for (const rec of aggregateIndexRecommendations(queries)) {\n const domain = `${rec.index.schema}.${rec.index.table}`;\n const bucket = byDomain.get(domain);\n if (bucket) bucket.push(rec);\n else byDomain.set(domain, [rec]);\n }\n\n const steps: Extract<ActionableStep, { kind: \"index\" }>[] = [];\n for (const [domain, recs] of byDomain) {\n const indexes: IndexAction[] = groupIndexesByCoverage(recs).map((group) => {\n const hashes = new Set(group.primary.affectedQueryHashes);\n for (const covered of group.covered) {\n for (const hash of covered.affectedQueryHashes) hashes.add(hash);\n }\n return {\n op: \"create\",\n definition: group.primary.index.definition,\n columns: group.primary.index.columns,\n coveredDefinitions: group.covered.map((c) => c.index.definition),\n affectedQueryHashes: [...hashes].sort(),\n };\n });\n\n // Step-level union of scorable queries (those with a known before/after).\n const stepHashes = new Set<string>();\n for (const action of indexes) {\n if (action.op !== \"create\") continue;\n for (const hash of action.affectedQueryHashes) {\n if (queryCost.has(hash)) stepHashes.add(hash);\n }\n }\n\n const affectedQueries: AffectedQueryCost[] = [...stepHashes]\n .sort()\n .map((hash) => {\n const cost = queryCost.get(hash)!;\n return { hash, cost: cost.cost, optimizedCost: cost.optimizedCost };\n });\n\n if (affectedQueries.length === 0) continue;\n\n const reductions = affectedQueries.map((q) => q.cost - q.optimizedCost);\n const total = reductions.reduce((sum, val) => sum + val, 0);\n const best = Math.max(...reductions);\n\n steps.push({\n kind: \"index\",\n key: stepKey(domain, indexes),\n domain,\n indexes,\n affectedQueryCount: affectedQueries.length,\n affectedQueries,\n costReduction: { average: total / reductions.length, best, total },\n value: total,\n });\n }\n\n // Most valuable first; deterministic tie-breaks for a stable render order.\n steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n if (b.affectedQueryCount !== a.affectedQueryCount) {\n return b.affectedQueryCount - a.affectedQueryCount;\n }\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n\n return steps;\n}\n\nfunction stepKey(domain: DomainLabel, indexes: IndexAction[]): string {\n const parts = indexes\n .map((action) => `${action.op}:${action.definition}`)\n .sort();\n return contentHash(`${domain}|${parts.join(\"|\")}`);\n}\n\n/** FNV-1a 32-bit hash → 8-char hex. Deterministic and dependency-free. */\nfunction contentHash(input: string): string {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n return (hash >>> 0).toString(16).padStart(8, \"0\");\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-action-plan.mjs","names":[],"sources":["../../src/action-plan/build-action-plan.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { Nudge } from \"../sql/nudges.js\";\nimport {\n aggregateIndexRecommendations,\n type ActionPlanQuery,\n} from \"./aggregate-index-recommendations.js\";\nimport {\n groupIndexesByCoverage,\n type AggregatedIndexRecommendation,\n} from \"./index-coverage.js\";\n\n/**\n * Bundling key for a step. v0.9/v1 = the affected table (`schema.table`);\n * #3100 widens this to a table-cluster.\n */\nexport type DomainLabel = string;\n\ntype IndexActionColumns = IndexRecommendation[\"columns\"];\n\n/**\n * One index change inside a step. A step is a unit of work applied in one go,\n * so it carries 1..N of these. Only `create` ships in #3098; the `drop` variant\n * exists for the type and lands with full-DB unused detection (#3120).\n */\nexport type IndexAction =\n | {\n op: \"create\";\n definition: string;\n columns: IndexActionColumns;\n /** Prefix indexes this one absorbs (covering `(a,b,c)` absorbs `(a,b)`). */\n coveredDefinitions: string[];\n /** Union of query hashes this create helps, including absorbed prefixes. */\n affectedQueryHashes: string[];\n }\n | { op: \"drop\"; definition: string; reason: \"unused\" | \"redundant\" };\n\n/** Before/after planner cost for one query the step affects. */\nexport interface AffectedQueryCost {\n hash: string;\n cost: number;\n optimizedCost: number;\n}\n\nexport interface CostReduction {\n average: number;\n best: number;\n total: number;\n}\n\n/**\n * A query whose cost rose past the configured regression threshold. The caller\n * (which owns the threshold config and the shared cost-delta rounding contract)\n * detects the breach; this module only shapes and ranks it.\n */\nexport interface RegressionBreach {\n queryHash: string;\n /** Current planner cost. */\n cost: number;\n /** Baseline cost the breach is measured against. */\n baselineCost: number;\n /** Increase over baseline, in percent. Pre-rounded by the caller. */\n increasePercentage: number;\n}\n\n/** A single anti-pattern finding shown inside a nudge step. */\nexport type NudgeFinding = Pick<Nudge, \"kind\" | \"severity\" | \"message\">;\n\n/**\n * One analyzed query plus its anti-pattern nudges, scoped current-state by the\n * caller. The two synthetic optimizer nudges (`*_IMPROVEMENT_FOUND`) are not\n * anti-patterns — they restate the index win — and are dropped here, not by the\n * caller.\n */\nexport interface ActionPlanNudgeQuery {\n hash: string;\n nudges: readonly Nudge[];\n}\n\n/**\n * Severity → ranking weight, mirroring `NUDGE_SEVERITY_WEIGHTS` in\n * `apps/app/src/components/nudge-scoring.ts`. Duplicated because core cannot\n * import from the app; keep the two in sync. Drives both a card's placement\n * (its top severity) and the within-tier sort (summed weight).\n */\nconst NUDGE_SEVERITY_WEIGHT: Record<Nudge[\"severity\"], number> = {\n CRITICAL: 16,\n WARNING: 4,\n INFO: 1,\n};\n\n/**\n * Optimizer-emitted nudges that announce an available improvement rather than a\n * query anti-pattern. The index win they describe is already a `index` step, so\n * they never become a nudge action.\n */\nconst NON_ANTIPATTERN_NUDGE_KINDS = new Set<Nudge[\"kind\"]>([\n \"LARGE_IMPROVEMENT_FOUND\",\n \"SMALL_IMPROVEMENT_FOUND\",\n]);\n\n/**\n * A prioritized recommendation in the database-health action plan, a\n * discriminated union over action kinds: `index` (#3098), `regression` (#3099)\n * and `nudge` (#3102).\n *\n * Tiering across kinds is positional, not by `value` — the kinds' `value`\n * scores carry different units and are never compared across tiers. The order\n * is regression → CRITICAL nudge → index → WARNING nudge → INFO nudge: a\n * \"something got worse\" signal and a critical anti-pattern outrank a \"do this\n * to improve\" suggestion, while lower-severity advice falls below it. See\n * {@link buildActionPlan}.\n */\nexport type ActionableStep =\n | {\n kind: \"index\";\n /**\n * Stable, content-derived identity: hash(domain + sorted index ops). Used\n * as the React key, the client freeze-diff key, and future triage identity.\n */\n key: string;\n domain: DomainLabel;\n /** 1..N index actions bundled for this domain. */\n indexes: IndexAction[];\n /** UNION of affected query hashes across the bundle — never a sum. */\n affectedQueryCount: number;\n /** Per-affected-query before/after cost, for display. */\n affectedQueries: AffectedQueryCost[];\n costReduction: CostReduction;\n /** Ranking score = total absolute reduction. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"regression\";\n /** Stable, content-derived identity: hash(\"regression\" + queryHash). */\n key: string;\n queryHash: string;\n cost: number;\n baselineCost: number;\n increasePercentage: number;\n /** Within-tier ranking score = increasePercentage. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"nudge\";\n /** Stable, content-derived identity: hash(\"nudge\" + queryHash). */\n key: string;\n queryHash: string;\n /** The query's anti-pattern findings, highest severity first. */\n nudges: NudgeFinding[];\n /** Highest severity across the findings — sets the card's tier. */\n severity: Nudge[\"severity\"];\n /** Within-tier ranking score = summed severity weight. Hidden. */\n value: number;\n };\n\n/**\n * Consolidate analyzed queries and detected regressions into one prioritized\n * action plan.\n *\n * - Tiering is positional across kinds: regression → CRITICAL nudge → index →\n * WARNING nudge → INFO nudge. A regression or critical anti-pattern (both\n * \"something is wrong\") outranks an index suggestion (\"do this to improve\");\n * lower-severity advice falls below it. Cross-kind `value`s carry different\n * units and are never compared. Within regressions, sort by\n * `increasePercentage` descending; within indexes, by summed absolute cost\n * reduction; within a nudge tier, by summed severity weight.\n * - Index bundling: one step per domain (table). Non-overlapping indexes on the\n * same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.\n * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over\n * the UNION of affected query hashes, each query counted once.\n * - Nudge bundling: one step per query, carrying all its anti-pattern findings;\n * the step's tier is the query's highest severity.\n *\n * Callers are expected to pass current-state, latest-per-hash queries; this\n * module does not gate on age or recency.\n */\nexport function buildActionPlan(\n queries: readonly ActionPlanQuery[],\n regressions: readonly RegressionBreach[] = [],\n nudgeQueries: readonly ActionPlanNudgeQuery[] = [],\n): ActionableStep[] {\n const nudges = buildNudgeSteps(nudgeQueries);\n const bySeverity = (severity: Nudge[\"severity\"]) =>\n nudges.filter((step) => step.severity === severity);\n\n // Positional tiers, each block already internally sorted. Critical nudges sit\n // above indexes; warning/info advice sits below.\n return [\n ...buildRegressionSteps(regressions),\n ...bySeverity(\"CRITICAL\"),\n ...buildIndexSteps(queries),\n ...bySeverity(\"WARNING\"),\n ...bySeverity(\"INFO\"),\n ];\n}\n\nfunction buildRegressionSteps(\n regressions: readonly RegressionBreach[],\n): ActionableStep[] {\n return regressions\n .map(\n (breach): ActionableStep => ({\n kind: \"regression\",\n key: contentHash(`regression|${breach.queryHash}`),\n queryHash: breach.queryHash,\n cost: breach.cost,\n baselineCost: breach.baselineCost,\n increasePercentage: breach.increasePercentage,\n value: breach.increasePercentage,\n }),\n )\n .sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n // Deterministic tie-break by query identity for a stable render order.\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildNudgeSteps(\n nudgeQueries: readonly ActionPlanNudgeQuery[],\n): Extract<ActionableStep, { kind: \"nudge\" }>[] {\n const steps: Extract<ActionableStep, { kind: \"nudge\" }>[] = [];\n for (const { hash, nudges } of nudgeQueries) {\n const findings: NudgeFinding[] = nudges\n .filter((n) => !NON_ANTIPATTERN_NUDGE_KINDS.has(n.kind))\n .map((n) => ({ kind: n.kind, severity: n.severity, message: n.message }))\n // Highest-severity finding first; stable by kind for a deterministic order.\n .sort((a, b) => {\n const weight =\n NUDGE_SEVERITY_WEIGHT[b.severity] - NUDGE_SEVERITY_WEIGHT[a.severity];\n if (weight !== 0) return weight;\n return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;\n });\n\n if (findings.length === 0) continue;\n\n const value = findings.reduce(\n (sum, f) => sum + NUDGE_SEVERITY_WEIGHT[f.severity],\n 0,\n );\n steps.push({\n kind: \"nudge\",\n key: contentHash(`nudge|${hash}`),\n queryHash: hash,\n nudges: findings,\n // Findings are severity-sorted, so the first is the card's top severity.\n severity: findings[0]!.severity,\n value,\n });\n }\n\n // Most severe first; deterministic tie-break by query identity. The caller\n // partitions these into the CRITICAL / WARNING / INFO tiers, preserving order.\n return steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildIndexSteps(\n queries: readonly ActionPlanQuery[],\n): ActionableStep[] {\n // Each query's own before/after cost, used for the absolute ranking. A hash\n // appears once — the first improvements state we see for it wins.\n const queryCost = new Map<string, { cost: number; optimizedCost: number }>();\n for (const query of queries) {\n const o = query.optimization;\n if (\n o.state === \"improvements_available\" &&\n o.cost !== undefined &&\n o.optimizedCost !== undefined &&\n !queryCost.has(query.hash)\n ) {\n queryCost.set(query.hash, {\n cost: o.cost,\n optimizedCost: o.optimizedCost,\n });\n }\n }\n\n // Reuse the shared per-definition consolidation, then bundle by table.\n const byDomain = new Map<DomainLabel, AggregatedIndexRecommendation[]>();\n for (const rec of aggregateIndexRecommendations(queries)) {\n const domain = `${rec.index.schema}.${rec.index.table}`;\n const bucket = byDomain.get(domain);\n if (bucket) bucket.push(rec);\n else byDomain.set(domain, [rec]);\n }\n\n const steps: Extract<ActionableStep, { kind: \"index\" }>[] = [];\n for (const [domain, recs] of byDomain) {\n const indexes: IndexAction[] = groupIndexesByCoverage(recs).map((group) => {\n const hashes = new Set(group.primary.affectedQueryHashes);\n for (const covered of group.covered) {\n for (const hash of covered.affectedQueryHashes) hashes.add(hash);\n }\n return {\n op: \"create\",\n definition: group.primary.index.definition,\n columns: group.primary.index.columns,\n coveredDefinitions: group.covered.map((c) => c.index.definition),\n affectedQueryHashes: [...hashes].sort(),\n };\n });\n\n // Step-level union of scorable queries (those with a known before/after).\n const stepHashes = new Set<string>();\n for (const action of indexes) {\n if (action.op !== \"create\") continue;\n for (const hash of action.affectedQueryHashes) {\n if (queryCost.has(hash)) stepHashes.add(hash);\n }\n }\n\n const affectedQueries: AffectedQueryCost[] = [...stepHashes]\n .sort()\n .map((hash) => {\n const cost = queryCost.get(hash)!;\n return { hash, cost: cost.cost, optimizedCost: cost.optimizedCost };\n });\n\n if (affectedQueries.length === 0) continue;\n\n const reductions = affectedQueries.map((q) => q.cost - q.optimizedCost);\n const total = reductions.reduce((sum, val) => sum + val, 0);\n const best = Math.max(...reductions);\n\n steps.push({\n kind: \"index\",\n key: stepKey(domain, indexes),\n domain,\n indexes,\n affectedQueryCount: affectedQueries.length,\n affectedQueries,\n costReduction: { average: total / reductions.length, best, total },\n value: total,\n });\n }\n\n // Most valuable first; deterministic tie-breaks for a stable render order.\n steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n if (b.affectedQueryCount !== a.affectedQueryCount) {\n return b.affectedQueryCount - a.affectedQueryCount;\n }\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n\n return steps;\n}\n\nfunction stepKey(domain: DomainLabel, indexes: IndexAction[]): string {\n const parts = indexes\n .map((action) => `${action.op}:${action.definition}`)\n .sort();\n return contentHash(`${domain}|${parts.join(\"|\")}`);\n}\n\n/** FNV-1a 32-bit hash → 8-char hex. Deterministic and dependency-free. */\nfunction contentHash(input: string): string {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n return (hash >>> 0).toString(16).padStart(8, \"0\");\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"build-action-plan.mjs","names":[],"sources":["../../src/action-plan/build-action-plan.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\nimport type { Nudge } from \"../sql/nudges.js\";\nimport {\n aggregateIndexRecommendations,\n type ActionPlanQuery,\n} from \"./aggregate-index-recommendations.js\";\nimport {\n groupIndexesByCoverage,\n type AggregatedIndexRecommendation,\n} from \"./index-coverage.js\";\n\n/**\n * Bundling key for a step. v0.9/v1 = the affected table (`schema.table`);\n * #3100 widens this to a table-cluster.\n */\nexport type DomainLabel = string;\n\ntype IndexActionColumns = IndexRecommendation[\"columns\"];\n\n/**\n * One index change inside a step. A step is a unit of work applied in one go,\n * so it carries 1..N of these. Only `create` ships in #3098; the `drop` variant\n * exists for the type and lands with full-DB unused detection (#3120).\n */\nexport type IndexAction =\n | {\n op: \"create\";\n definition: string;\n columns: IndexActionColumns;\n /** Prefix indexes this one absorbs (covering `(a,b,c)` absorbs `(a,b)`). */\n coveredDefinitions: string[];\n /** Union of query hashes this create helps, including absorbed prefixes. */\n affectedQueryHashes: string[];\n }\n | { op: \"drop\"; definition: string; reason: \"unused\" | \"redundant\" };\n\n/** Before/after planner cost for one query the step affects. */\nexport interface AffectedQueryCost {\n hash: string;\n cost: number;\n optimizedCost: number;\n}\n\nexport interface CostReduction {\n average: number;\n best: number;\n total: number;\n}\n\n/**\n * A query whose cost rose past the configured regression threshold. The caller\n * (which owns the threshold config and the shared cost-delta rounding contract)\n * detects the breach; this module only shapes and ranks it.\n */\nexport interface RegressionBreach {\n queryHash: string;\n /** Current planner cost. */\n cost: number;\n /** Baseline cost the breach is measured against. */\n baselineCost: number;\n /** Increase over baseline, in percent. Pre-rounded by the caller. */\n increasePercentage: number;\n}\n\n/** A single anti-pattern finding shown inside a nudge step. */\nexport type NudgeFinding = Pick<Nudge, \"kind\" | \"severity\" | \"message\">;\n\n/**\n * One analyzed query plus its anti-pattern nudges, scoped current-state by the\n * caller. The two synthetic optimizer nudges (`*_IMPROVEMENT_FOUND`) are not\n * anti-patterns — they restate the index win — and are dropped here, not by the\n * caller.\n */\nexport interface ActionPlanNudgeQuery {\n hash: string;\n nudges: readonly Nudge[];\n}\n\n/**\n * Severity → ranking weight, mirroring `NUDGE_SEVERITY_WEIGHTS` in\n * `apps/app/src/components/nudge-scoring.ts`. Duplicated because core cannot\n * import from the app; keep the two in sync. Drives both a card's placement\n * (its top severity) and the within-tier sort (summed weight).\n */\nconst NUDGE_SEVERITY_WEIGHT: Record<Nudge[\"severity\"], number> = {\n CRITICAL: 16,\n WARNING: 4,\n INFO: 1,\n};\n\n/**\n * Optimizer-emitted nudges that announce an available improvement rather than a\n * query anti-pattern. The index win they describe is already a `index` step, so\n * they never become a nudge action.\n */\nconst NON_ANTIPATTERN_NUDGE_KINDS = new Set<Nudge[\"kind\"]>([\n \"LARGE_IMPROVEMENT_FOUND\",\n \"SMALL_IMPROVEMENT_FOUND\",\n]);\n\n/**\n * A prioritized recommendation in the database-health action plan, a\n * discriminated union over action kinds: `index` (#3098), `regression` (#3099)\n * and `nudge` (#3102).\n *\n * Tiering across kinds is positional, not by `value` — the kinds' `value`\n * scores carry different units and are never compared across tiers. The order\n * is regression → CRITICAL nudge → index → WARNING nudge → INFO nudge: a\n * \"something got worse\" signal and a critical anti-pattern outrank a \"do this\n * to improve\" suggestion, while lower-severity advice falls below it. See\n * {@link buildActionPlan}.\n */\nexport type ActionableStep =\n | {\n kind: \"index\";\n /**\n * Stable, content-derived identity: hash(domain + sorted index ops). Used\n * as the React key, the client freeze-diff key, and future triage identity.\n */\n key: string;\n domain: DomainLabel;\n /** 1..N index actions bundled for this domain. */\n indexes: IndexAction[];\n /** UNION of affected query hashes across the bundle — never a sum. */\n affectedQueryCount: number;\n /** Per-affected-query before/after cost, for display. */\n affectedQueries: AffectedQueryCost[];\n costReduction: CostReduction;\n /** Ranking score = total absolute reduction. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"regression\";\n /** Stable, content-derived identity: hash(\"regression\" + queryHash). */\n key: string;\n queryHash: string;\n cost: number;\n baselineCost: number;\n increasePercentage: number;\n /** Within-tier ranking score = increasePercentage. Hidden from the user. */\n value: number;\n }\n | {\n kind: \"nudge\";\n /** Stable, content-derived identity: hash(\"nudge\" + queryHash). */\n key: string;\n queryHash: string;\n /** The query's anti-pattern findings, highest severity first. */\n nudges: NudgeFinding[];\n /** Highest severity across the findings — sets the card's tier. */\n severity: Nudge[\"severity\"];\n /** Within-tier ranking score = summed severity weight. Hidden. */\n value: number;\n };\n\n/**\n * Consolidate analyzed queries and detected regressions into one prioritized\n * action plan.\n *\n * - Tiering is positional across kinds: regression → CRITICAL nudge → index →\n * WARNING nudge → INFO nudge. A regression or critical anti-pattern (both\n * \"something is wrong\") outranks an index suggestion (\"do this to improve\");\n * lower-severity advice falls below it. Cross-kind `value`s carry different\n * units and are never compared. Within regressions, sort by\n * `increasePercentage` descending; within indexes, by summed absolute cost\n * reduction; within a nudge tier, by summed severity weight.\n * - Index bundling: one step per domain (table). Non-overlapping indexes on the\n * same table share a step; a covering `(a,b,c)` absorbs `(a,b)`.\n * - Index ranking: summed absolute cost reduction (`cost - optimizedCost`) over\n * the UNION of affected query hashes, each query counted once.\n * - Nudge bundling: one step per query, carrying all its anti-pattern findings;\n * the step's tier is the query's highest severity.\n *\n * Callers are expected to pass current-state, latest-per-hash queries; this\n * module does not gate on age or recency.\n */\nexport function buildActionPlan(\n queries: readonly ActionPlanQuery[],\n regressions: readonly RegressionBreach[] = [],\n nudgeQueries: readonly ActionPlanNudgeQuery[] = [],\n): ActionableStep[] {\n const nudges = buildNudgeSteps(nudgeQueries);\n const bySeverity = (severity: Nudge[\"severity\"]) =>\n nudges.filter((step) => step.severity === severity);\n\n // Positional tiers, each block already internally sorted. Critical nudges sit\n // above indexes; warning/info advice sits below.\n return [\n ...buildRegressionSteps(regressions),\n ...bySeverity(\"CRITICAL\"),\n ...buildIndexSteps(queries),\n ...bySeverity(\"WARNING\"),\n ...bySeverity(\"INFO\"),\n ];\n}\n\nfunction buildRegressionSteps(\n regressions: readonly RegressionBreach[],\n): ActionableStep[] {\n return regressions\n .map(\n (breach): ActionableStep => ({\n kind: \"regression\",\n key: contentHash(`regression|${breach.queryHash}`),\n queryHash: breach.queryHash,\n cost: breach.cost,\n baselineCost: breach.baselineCost,\n increasePercentage: breach.increasePercentage,\n value: breach.increasePercentage,\n }),\n )\n .sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n // Deterministic tie-break by query identity for a stable render order.\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildNudgeSteps(\n nudgeQueries: readonly ActionPlanNudgeQuery[],\n): Extract<ActionableStep, { kind: \"nudge\" }>[] {\n const steps: Extract<ActionableStep, { kind: \"nudge\" }>[] = [];\n for (const { hash, nudges } of nudgeQueries) {\n const findings: NudgeFinding[] = nudges\n .filter((n) => !NON_ANTIPATTERN_NUDGE_KINDS.has(n.kind))\n .map((n) => ({ kind: n.kind, severity: n.severity, message: n.message }))\n // Highest-severity finding first; stable by kind for a deterministic order.\n .sort((a, b) => {\n const weight =\n NUDGE_SEVERITY_WEIGHT[b.severity] - NUDGE_SEVERITY_WEIGHT[a.severity];\n if (weight !== 0) return weight;\n return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;\n });\n\n if (findings.length === 0) continue;\n\n const value = findings.reduce(\n (sum, f) => sum + NUDGE_SEVERITY_WEIGHT[f.severity],\n 0,\n );\n steps.push({\n kind: \"nudge\",\n key: contentHash(`nudge|${hash}`),\n queryHash: hash,\n nudges: findings,\n // Findings are severity-sorted, so the first is the card's top severity.\n severity: findings[0]!.severity,\n value,\n });\n }\n\n // Most severe first; deterministic tie-break by query identity. The caller\n // partitions these into the CRITICAL / WARNING / INFO tiers, preserving order.\n return steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n}\n\nfunction buildIndexSteps(\n queries: readonly ActionPlanQuery[],\n): ActionableStep[] {\n // Each query's own before/after cost, used for the absolute ranking. A hash\n // appears once — the first improvements state we see for it wins.\n const queryCost = new Map<string, { cost: number; optimizedCost: number }>();\n for (const query of queries) {\n const o = query.optimization;\n if (\n o.state === \"improvements_available\" &&\n o.cost !== undefined &&\n o.optimizedCost !== undefined &&\n !queryCost.has(query.hash)\n ) {\n queryCost.set(query.hash, {\n cost: o.cost,\n optimizedCost: o.optimizedCost,\n });\n }\n }\n\n // Reuse the shared per-definition consolidation, then bundle by table.\n const byDomain = new Map<DomainLabel, AggregatedIndexRecommendation[]>();\n for (const rec of aggregateIndexRecommendations(queries)) {\n const domain = `${rec.index.schema}.${rec.index.table}`;\n const bucket = byDomain.get(domain);\n if (bucket) bucket.push(rec);\n else byDomain.set(domain, [rec]);\n }\n\n const steps: Extract<ActionableStep, { kind: \"index\" }>[] = [];\n for (const [domain, recs] of byDomain) {\n const indexes: IndexAction[] = groupIndexesByCoverage(recs).map((group) => {\n const hashes = new Set(group.primary.affectedQueryHashes);\n for (const covered of group.covered) {\n for (const hash of covered.affectedQueryHashes) hashes.add(hash);\n }\n return {\n op: \"create\",\n definition: group.primary.index.definition,\n columns: group.primary.index.columns,\n coveredDefinitions: group.covered.map((c) => c.index.definition),\n affectedQueryHashes: [...hashes].sort(),\n };\n });\n\n // Step-level union of scorable queries (those with a known before/after).\n const stepHashes = new Set<string>();\n for (const action of indexes) {\n if (action.op !== \"create\") continue;\n for (const hash of action.affectedQueryHashes) {\n if (queryCost.has(hash)) stepHashes.add(hash);\n }\n }\n\n const affectedQueries: AffectedQueryCost[] = [...stepHashes]\n .sort()\n .map((hash) => {\n const cost = queryCost.get(hash)!;\n return { hash, cost: cost.cost, optimizedCost: cost.optimizedCost };\n });\n\n if (affectedQueries.length === 0) continue;\n\n const reductions = affectedQueries.map((q) => q.cost - q.optimizedCost);\n const total = reductions.reduce((sum, val) => sum + val, 0);\n const best = Math.max(...reductions);\n\n steps.push({\n kind: \"index\",\n key: stepKey(domain, indexes),\n domain,\n indexes,\n affectedQueryCount: affectedQueries.length,\n affectedQueries,\n costReduction: { average: total / reductions.length, best, total },\n value: total,\n });\n }\n\n // Most valuable first; deterministic tie-breaks for a stable render order.\n steps.sort((a, b) => {\n if (b.value !== a.value) return b.value - a.value;\n if (b.affectedQueryCount !== a.affectedQueryCount) {\n return b.affectedQueryCount - a.affectedQueryCount;\n }\n return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;\n });\n\n return steps;\n}\n\nfunction stepKey(domain: DomainLabel, indexes: IndexAction[]): string {\n const parts = indexes\n .map((action) => `${action.op}:${action.definition}`)\n .sort();\n return contentHash(`${domain}|${parts.join(\"|\")}`);\n}\n\n/** FNV-1a 32-bit hash → 8-char hex. Deterministic and dependency-free. */\nfunction contentHash(input: string): string {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n return (hash >>> 0).toString(16).padStart(8, \"0\");\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index-coverage.cjs","names":[],"sources":["../../src/action-plan/index-coverage.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\n\n/** A unique index recommendation plus how it scored across the queries it helps. */\nexport interface AggregatedIndexRecommendation {\n index: IndexRecommendation;\n affectedQueryCount: number;\n averageCostReduction: number;\n bestCostReduction: number;\n affectedQueryHashes: string[];\n}\n\n/**\n * Check if indexA covers indexB (A is a superset that includes B as a prefix).\n *\n * Rules:\n * - Same schema and table\n * - A's columns start with B's columns (B is a prefix of A)\n * - Sort directions must match for prefix columns\n * - WHERE clauses must be compatible\n *\n * Examples:\n * - (a, b, c) covers (a, b) and (a)\n * - (a DESC, b) covers (a DESC) but NOT (a ASC)\n */\nexport function indexCovers(\n a: IndexRecommendation,\n b: IndexRecommendation,\n): boolean {\n // Must be same schema and table\n if (a.schema !== b.schema || a.table !== b.table) return false;\n\n // A must have more columns than B (not equal - that would be the same index)\n if (a.columns.length <= b.columns.length) return false;\n\n // B's columns must be a prefix of A's columns with matching sort\n for (let i = 0; i < b.columns.length; i++) {\n const colA = a.columns[i];\n const colB = b.columns[i];\n\n if (colA.column !== colB.column) return false;\n\n // Sort directions must match\n // Normalize: treat undefined/null as equivalent to default (ASC)\n const sortA = normalizeSort(colA.sort);\n const sortB = normalizeSort(colB.sort);\n if (sortA !== sortB) return false;\n }\n\n // WHERE clause check: if B has a WHERE, A must have the same WHERE\n if (b.where && a.where !== b.where) return false;\n\n return true;\n}\n\nfunction normalizeSort(sort: unknown): string {\n if (!sort) return \"ASC\";\n if (typeof sort === \"string\") return sort.toUpperCase();\n if (typeof sort === \"object\" && sort !== null && \"dir\" in sort) {\n const dir = (sort as { dir?: string }).dir;\n if (dir === \"SORTBY_DESC\") return \"DESC\";\n return \"ASC\";\n }\n return \"ASC\";\n}\n\nexport interface IndexGroup {\n /** The covering index (largest/most comprehensive) */\n primary: AggregatedIndexRecommendation;\n /** Indexes that are covered by the primary (prefixes of primary) */\n covered: AggregatedIndexRecommendation[];\n}\n\n/**\n * Group indexes by coverage relationships.\n *\n * Returns groups where each group has a \"primary\" index that covers\n * zero or more \"covered\" indexes. Indexes that don't cover or aren't\n * covered by any other index become their own single-item group.\n */\nexport function groupIndexesByCoverage(\n indexes: AggregatedIndexRecommendation[],\n): IndexGroup[] {\n if (indexes.length === 0) return [];\n\n // Track which indexes have been assigned to a group as \"covered\"\n const coveredSet = new Set<string>();\n const groups: IndexGroup[] = [];\n\n // Sort by column count descending so we process larger indexes first\n const sorted = [...indexes].sort(\n (a, b) => b.index.columns.length - a.index.columns.length,\n );\n\n for (const idx of sorted) {\n const key = idx.index.definition;\n\n // Skip if already covered by another index\n if (coveredSet.has(key)) continue;\n\n // Find all indexes this one covers\n const covered: AggregatedIndexRecommendation[] = [];\n for (const other of sorted) {\n if (other.index.definition === key) continue;\n if (coveredSet.has(other.index.definition)) continue;\n\n if (indexCovers(idx.index, other.index)) {\n covered.push(other);\n coveredSet.add(other.index.definition);\n }\n }\n\n groups.push({\n primary: idx,\n covered,\n });\n }\n\n return groups;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAwBA,SAAgB,YACd,GACA,GACS;AAET,KAAI,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAO,QAAO;AAGzD,KAAI,EAAE,QAAQ,UAAU,EAAE,QAAQ,OAAQ,QAAO;AAGjD,MAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,QAAQ,KAAK;EACzC,MAAM,OAAO,EAAE,QAAQ;EACvB,MAAM,OAAO,EAAE,QAAQ;AAEvB,MAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AAMxC,MAFc,cAAc,KAAK,
|
|
1
|
+
{"version":3,"file":"index-coverage.cjs","names":[],"sources":["../../src/action-plan/index-coverage.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\n\n/** A unique index recommendation plus how it scored across the queries it helps. */\nexport interface AggregatedIndexRecommendation {\n index: IndexRecommendation;\n affectedQueryCount: number;\n averageCostReduction: number;\n bestCostReduction: number;\n affectedQueryHashes: string[];\n}\n\n/**\n * Check if indexA covers indexB (A is a superset that includes B as a prefix).\n *\n * Rules:\n * - Same schema and table\n * - A's columns start with B's columns (B is a prefix of A)\n * - Sort directions must match for prefix columns\n * - WHERE clauses must be compatible\n *\n * Examples:\n * - (a, b, c) covers (a, b) and (a)\n * - (a DESC, b) covers (a DESC) but NOT (a ASC)\n */\nexport function indexCovers(\n a: IndexRecommendation,\n b: IndexRecommendation,\n): boolean {\n // Must be same schema and table\n if (a.schema !== b.schema || a.table !== b.table) return false;\n\n // A must have more columns than B (not equal - that would be the same index)\n if (a.columns.length <= b.columns.length) return false;\n\n // B's columns must be a prefix of A's columns with matching sort\n for (let i = 0; i < b.columns.length; i++) {\n const colA = a.columns[i];\n const colB = b.columns[i];\n\n if (colA.column !== colB.column) return false;\n\n // Sort directions must match\n // Normalize: treat undefined/null as equivalent to default (ASC)\n const sortA = normalizeSort(colA.sort);\n const sortB = normalizeSort(colB.sort);\n if (sortA !== sortB) return false;\n }\n\n // WHERE clause check: if B has a WHERE, A must have the same WHERE\n if (b.where && a.where !== b.where) return false;\n\n return true;\n}\n\nfunction normalizeSort(sort: unknown): string {\n if (!sort) return \"ASC\";\n if (typeof sort === \"string\") return sort.toUpperCase();\n if (typeof sort === \"object\" && sort !== null && \"dir\" in sort) {\n const dir = (sort as { dir?: string }).dir;\n if (dir === \"SORTBY_DESC\") return \"DESC\";\n return \"ASC\";\n }\n return \"ASC\";\n}\n\nexport interface IndexGroup {\n /** The covering index (largest/most comprehensive) */\n primary: AggregatedIndexRecommendation;\n /** Indexes that are covered by the primary (prefixes of primary) */\n covered: AggregatedIndexRecommendation[];\n}\n\n/**\n * Group indexes by coverage relationships.\n *\n * Returns groups where each group has a \"primary\" index that covers\n * zero or more \"covered\" indexes. Indexes that don't cover or aren't\n * covered by any other index become their own single-item group.\n */\nexport function groupIndexesByCoverage(\n indexes: AggregatedIndexRecommendation[],\n): IndexGroup[] {\n if (indexes.length === 0) return [];\n\n // Track which indexes have been assigned to a group as \"covered\"\n const coveredSet = new Set<string>();\n const groups: IndexGroup[] = [];\n\n // Sort by column count descending so we process larger indexes first\n const sorted = [...indexes].sort(\n (a, b) => b.index.columns.length - a.index.columns.length,\n );\n\n for (const idx of sorted) {\n const key = idx.index.definition;\n\n // Skip if already covered by another index\n if (coveredSet.has(key)) continue;\n\n // Find all indexes this one covers\n const covered: AggregatedIndexRecommendation[] = [];\n for (const other of sorted) {\n if (other.index.definition === key) continue;\n if (coveredSet.has(other.index.definition)) continue;\n\n if (indexCovers(idx.index, other.index)) {\n covered.push(other);\n coveredSet.add(other.index.definition);\n }\n }\n\n groups.push({\n primary: idx,\n covered,\n });\n }\n\n return groups;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAwBA,SAAgB,YACd,GACA,GACS;AAET,KAAI,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAO,QAAO;AAGzD,KAAI,EAAE,QAAQ,UAAU,EAAE,QAAQ,OAAQ,QAAO;AAGjD,MAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,QAAQ,KAAK;EACzC,MAAM,OAAO,EAAE,QAAQ;EACvB,MAAM,OAAO,EAAE,QAAQ;AAEvB,MAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AAMxC,MAFc,cAAc,KAAK,KAExB,KADK,cAAc,KAAK,KACd,CAAE,QAAO;;AAI9B,KAAI,EAAE,SAAS,EAAE,UAAU,EAAE,MAAO,QAAO;AAE3C,QAAO;;AAGT,SAAS,cAAc,MAAuB;AAC5C,KAAI,CAAC,KAAM,QAAO;AAClB,KAAI,OAAO,SAAS,SAAU,QAAO,KAAK,aAAa;AACvD,KAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,SAAS,MAAM;AAE9D,MADa,KAA0B,QAC3B,cAAe,QAAO;AAClC,SAAO;;AAET,QAAO;;;;;;;;;AAiBT,SAAgB,uBACd,SACc;AACd,KAAI,QAAQ,WAAW,EAAG,QAAO,EAAE;CAGnC,MAAM,6BAAa,IAAI,KAAa;CACpC,MAAM,SAAuB,EAAE;CAG/B,MAAM,SAAS,CAAC,GAAG,QAAQ,CAAC,MACzB,GAAG,MAAM,EAAE,MAAM,QAAQ,SAAS,EAAE,MAAM,QAAQ,OACpD;AAED,MAAK,MAAM,OAAO,QAAQ;EACxB,MAAM,MAAM,IAAI,MAAM;AAGtB,MAAI,WAAW,IAAI,IAAI,CAAE;EAGzB,MAAM,UAA2C,EAAE;AACnD,OAAK,MAAM,SAAS,QAAQ;AAC1B,OAAI,MAAM,MAAM,eAAe,IAAK;AACpC,OAAI,WAAW,IAAI,MAAM,MAAM,WAAW,CAAE;AAE5C,OAAI,YAAY,IAAI,OAAO,MAAM,MAAM,EAAE;AACvC,YAAQ,KAAK,MAAM;AACnB,eAAW,IAAI,MAAM,MAAM,WAAW;;;AAI1C,SAAO,KAAK;GACV,SAAS;GACT;GACD,CAAC;;AAGJ,QAAO"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index-coverage.mjs","names":[],"sources":["../../src/action-plan/index-coverage.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\n\n/** A unique index recommendation plus how it scored across the queries it helps. */\nexport interface AggregatedIndexRecommendation {\n index: IndexRecommendation;\n affectedQueryCount: number;\n averageCostReduction: number;\n bestCostReduction: number;\n affectedQueryHashes: string[];\n}\n\n/**\n * Check if indexA covers indexB (A is a superset that includes B as a prefix).\n *\n * Rules:\n * - Same schema and table\n * - A's columns start with B's columns (B is a prefix of A)\n * - Sort directions must match for prefix columns\n * - WHERE clauses must be compatible\n *\n * Examples:\n * - (a, b, c) covers (a, b) and (a)\n * - (a DESC, b) covers (a DESC) but NOT (a ASC)\n */\nexport function indexCovers(\n a: IndexRecommendation,\n b: IndexRecommendation,\n): boolean {\n // Must be same schema and table\n if (a.schema !== b.schema || a.table !== b.table) return false;\n\n // A must have more columns than B (not equal - that would be the same index)\n if (a.columns.length <= b.columns.length) return false;\n\n // B's columns must be a prefix of A's columns with matching sort\n for (let i = 0; i < b.columns.length; i++) {\n const colA = a.columns[i];\n const colB = b.columns[i];\n\n if (colA.column !== colB.column) return false;\n\n // Sort directions must match\n // Normalize: treat undefined/null as equivalent to default (ASC)\n const sortA = normalizeSort(colA.sort);\n const sortB = normalizeSort(colB.sort);\n if (sortA !== sortB) return false;\n }\n\n // WHERE clause check: if B has a WHERE, A must have the same WHERE\n if (b.where && a.where !== b.where) return false;\n\n return true;\n}\n\nfunction normalizeSort(sort: unknown): string {\n if (!sort) return \"ASC\";\n if (typeof sort === \"string\") return sort.toUpperCase();\n if (typeof sort === \"object\" && sort !== null && \"dir\" in sort) {\n const dir = (sort as { dir?: string }).dir;\n if (dir === \"SORTBY_DESC\") return \"DESC\";\n return \"ASC\";\n }\n return \"ASC\";\n}\n\nexport interface IndexGroup {\n /** The covering index (largest/most comprehensive) */\n primary: AggregatedIndexRecommendation;\n /** Indexes that are covered by the primary (prefixes of primary) */\n covered: AggregatedIndexRecommendation[];\n}\n\n/**\n * Group indexes by coverage relationships.\n *\n * Returns groups where each group has a \"primary\" index that covers\n * zero or more \"covered\" indexes. Indexes that don't cover or aren't\n * covered by any other index become their own single-item group.\n */\nexport function groupIndexesByCoverage(\n indexes: AggregatedIndexRecommendation[],\n): IndexGroup[] {\n if (indexes.length === 0) return [];\n\n // Track which indexes have been assigned to a group as \"covered\"\n const coveredSet = new Set<string>();\n const groups: IndexGroup[] = [];\n\n // Sort by column count descending so we process larger indexes first\n const sorted = [...indexes].sort(\n (a, b) => b.index.columns.length - a.index.columns.length,\n );\n\n for (const idx of sorted) {\n const key = idx.index.definition;\n\n // Skip if already covered by another index\n if (coveredSet.has(key)) continue;\n\n // Find all indexes this one covers\n const covered: AggregatedIndexRecommendation[] = [];\n for (const other of sorted) {\n if (other.index.definition === key) continue;\n if (coveredSet.has(other.index.definition)) continue;\n\n if (indexCovers(idx.index, other.index)) {\n covered.push(other);\n coveredSet.add(other.index.definition);\n }\n }\n\n groups.push({\n primary: idx,\n covered,\n });\n }\n\n return groups;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAwBA,SAAgB,YACd,GACA,GACS;AAET,KAAI,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAO,QAAO;AAGzD,KAAI,EAAE,QAAQ,UAAU,EAAE,QAAQ,OAAQ,QAAO;AAGjD,MAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,QAAQ,KAAK;EACzC,MAAM,OAAO,EAAE,QAAQ;EACvB,MAAM,OAAO,EAAE,QAAQ;AAEvB,MAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AAMxC,MAFc,cAAc,KAAK,
|
|
1
|
+
{"version":3,"file":"index-coverage.mjs","names":[],"sources":["../../src/action-plan/index-coverage.ts"],"sourcesContent":["import type { IndexRecommendation } from \"../query.js\";\n\n/** A unique index recommendation plus how it scored across the queries it helps. */\nexport interface AggregatedIndexRecommendation {\n index: IndexRecommendation;\n affectedQueryCount: number;\n averageCostReduction: number;\n bestCostReduction: number;\n affectedQueryHashes: string[];\n}\n\n/**\n * Check if indexA covers indexB (A is a superset that includes B as a prefix).\n *\n * Rules:\n * - Same schema and table\n * - A's columns start with B's columns (B is a prefix of A)\n * - Sort directions must match for prefix columns\n * - WHERE clauses must be compatible\n *\n * Examples:\n * - (a, b, c) covers (a, b) and (a)\n * - (a DESC, b) covers (a DESC) but NOT (a ASC)\n */\nexport function indexCovers(\n a: IndexRecommendation,\n b: IndexRecommendation,\n): boolean {\n // Must be same schema and table\n if (a.schema !== b.schema || a.table !== b.table) return false;\n\n // A must have more columns than B (not equal - that would be the same index)\n if (a.columns.length <= b.columns.length) return false;\n\n // B's columns must be a prefix of A's columns with matching sort\n for (let i = 0; i < b.columns.length; i++) {\n const colA = a.columns[i];\n const colB = b.columns[i];\n\n if (colA.column !== colB.column) return false;\n\n // Sort directions must match\n // Normalize: treat undefined/null as equivalent to default (ASC)\n const sortA = normalizeSort(colA.sort);\n const sortB = normalizeSort(colB.sort);\n if (sortA !== sortB) return false;\n }\n\n // WHERE clause check: if B has a WHERE, A must have the same WHERE\n if (b.where && a.where !== b.where) return false;\n\n return true;\n}\n\nfunction normalizeSort(sort: unknown): string {\n if (!sort) return \"ASC\";\n if (typeof sort === \"string\") return sort.toUpperCase();\n if (typeof sort === \"object\" && sort !== null && \"dir\" in sort) {\n const dir = (sort as { dir?: string }).dir;\n if (dir === \"SORTBY_DESC\") return \"DESC\";\n return \"ASC\";\n }\n return \"ASC\";\n}\n\nexport interface IndexGroup {\n /** The covering index (largest/most comprehensive) */\n primary: AggregatedIndexRecommendation;\n /** Indexes that are covered by the primary (prefixes of primary) */\n covered: AggregatedIndexRecommendation[];\n}\n\n/**\n * Group indexes by coverage relationships.\n *\n * Returns groups where each group has a \"primary\" index that covers\n * zero or more \"covered\" indexes. Indexes that don't cover or aren't\n * covered by any other index become their own single-item group.\n */\nexport function groupIndexesByCoverage(\n indexes: AggregatedIndexRecommendation[],\n): IndexGroup[] {\n if (indexes.length === 0) return [];\n\n // Track which indexes have been assigned to a group as \"covered\"\n const coveredSet = new Set<string>();\n const groups: IndexGroup[] = [];\n\n // Sort by column count descending so we process larger indexes first\n const sorted = [...indexes].sort(\n (a, b) => b.index.columns.length - a.index.columns.length,\n );\n\n for (const idx of sorted) {\n const key = idx.index.definition;\n\n // Skip if already covered by another index\n if (coveredSet.has(key)) continue;\n\n // Find all indexes this one covers\n const covered: AggregatedIndexRecommendation[] = [];\n for (const other of sorted) {\n if (other.index.definition === key) continue;\n if (coveredSet.has(other.index.definition)) continue;\n\n if (indexCovers(idx.index, other.index)) {\n covered.push(other);\n coveredSet.add(other.index.definition);\n }\n }\n\n groups.push({\n primary: idx,\n covered,\n });\n }\n\n return groups;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAwBA,SAAgB,YACd,GACA,GACS;AAET,KAAI,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAO,QAAO;AAGzD,KAAI,EAAE,QAAQ,UAAU,EAAE,QAAQ,OAAQ,QAAO;AAGjD,MAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,QAAQ,KAAK;EACzC,MAAM,OAAO,EAAE,QAAQ;EACvB,MAAM,OAAO,EAAE,QAAQ;AAEvB,MAAI,KAAK,WAAW,KAAK,OAAQ,QAAO;AAMxC,MAFc,cAAc,KAAK,KAExB,KADK,cAAc,KAAK,KACd,CAAE,QAAO;;AAI9B,KAAI,EAAE,SAAS,EAAE,UAAU,EAAE,MAAO,QAAO;AAE3C,QAAO;;AAGT,SAAS,cAAc,MAAuB;AAC5C,KAAI,CAAC,KAAM,QAAO;AAClB,KAAI,OAAO,SAAS,SAAU,QAAO,KAAK,aAAa;AACvD,KAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,SAAS,MAAM;AAE9D,MADa,KAA0B,QAC3B,cAAe,QAAO;AAClC,SAAO;;AAET,QAAO;;;;;;;;;AAiBT,SAAgB,uBACd,SACc;AACd,KAAI,QAAQ,WAAW,EAAG,QAAO,EAAE;CAGnC,MAAM,6BAAa,IAAI,KAAa;CACpC,MAAM,SAAuB,EAAE;CAG/B,MAAM,SAAS,CAAC,GAAG,QAAQ,CAAC,MACzB,GAAG,MAAM,EAAE,MAAM,QAAQ,SAAS,EAAE,MAAM,QAAQ,OACpD;AAED,MAAK,MAAM,OAAO,QAAQ;EACxB,MAAM,MAAM,IAAI,MAAM;AAGtB,MAAI,WAAW,IAAI,IAAI,CAAE;EAGzB,MAAM,UAA2C,EAAE;AACnD,OAAK,MAAM,SAAS,QAAQ;AAC1B,OAAI,MAAM,MAAM,eAAe,IAAK;AACpC,OAAI,WAAW,IAAI,MAAM,MAAM,WAAW,CAAE;AAE5C,OAAI,YAAY,IAAI,OAAO,MAAM,MAAM,EAAE;AACvC,YAAQ,KAAK,MAAM;AACnB,eAAW,IAAI,MAAM,MAAM,WAAW;;;AAI1C,SAAO,KAAK;GACV,SAAS;GACT;GACD,CAAC;;AAGJ,QAAO"}
|
package/dist/ci/policy.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.cjs","names":["conclusionForVerdict"],"sources":["../../src/ci/policy.ts"],"sourcesContent":["import { conclusionForVerdict, type CiConclusion } from \"./taxonomy.ts\";\nimport type { CiVerdict } from \"./verdict.ts\";\n\n// The policy engine (#3500). Turns the single hard-coded gate into a\n// configurable condition set: a per-repo config gives each condition\n// `fail | warn | off`, and this pure function folds (config, condition results)\n// into one run conclusion plus the per-condition verdicts that produced it.\n//\n// It sits one layer above the taxonomy (#3498): the taxonomy says what a\n// verdict *would* conclude; the policy decides whether this repo lets that\n// condition fail, soften to a warning, or be suppressed entirely.\n\n/** What a repo lets a condition do: fail the run, warn-only, or ignore it. */\nexport type ConditionPolicy = \"fail\" | \"warn\" | \"off\";\n\n/** One condition's evaluated outcome, before policy is applied. */\nexport interface ConditionResult {\n /** Stable condition key, matched against the repo config (e.g. \"untested-data-access\"). */\n condition: string;\n verdict: CiVerdict;\n}\n\n/** A condition after policy resolution — its effective conclusion and the policy that shaped it. */\nexport interface ResolvedCondition {\n condition: string;\n policy: ConditionPolicy;\n verdict: CiVerdict;\n conclusion: CiConclusion;\n}\n\nexport interface RunEvaluation {\n /** The run's overall conclusion — the worst surfaced per-condition conclusion. */\n conclusion: CiConclusion;\n /** Per-condition results that were surfaced (an `off` condition is dropped). */\n conditions: ResolvedCondition[];\n}\n\n/** Per-repo policy: condition key → its policy. Absent keys fall back to defaults. */\nexport type RepoPolicyConfig = Record<string, ConditionPolicy>;\n\n/**\n * Opinionated safe defaults (epic #3493). A captured new query already ran in a\n * test (tested by definition), so it's informational; everything that marks a\n * genuine, diff-introduced problem fails by default. The set is deliberately\n * narrow so the gate under-fires.\n */\nexport const DEFAULT_CONDITION_POLICIES: RepoPolicyConfig = {\n \"untested-data-access\": \"fail\",\n \"schema-drift\": \"fail\", // #3296 owns the signal; this just gates on it\n \"new-query\": \"warn\", // captured ⇒ tested — informational, surfaced, non-blocking\n \"regression-beyond-threshold\": \"fail\", // #3104\n \"high-value-nudge\": \"fail\", // narrow set only\n};\n\n/**\n * Fallback for a condition with neither a repo setting nor a documented default:\n * surface it, don't block. Under-firing on an unrecognised condition is the safe\n * side of the line.\n */\nexport const FALLBACK_POLICY: ConditionPolicy = \"warn\";\n\n/** Resolve the effective policy for a condition: repo config, else default, else fallback. */\nexport function policyFor(\n condition: string,\n config: RepoPolicyConfig = {},\n): ConditionPolicy {\n return (\n config[condition] ??\n DEFAULT_CONDITION_POLICIES[condition] ??\n FALLBACK_POLICY\n );\n}\n\n/**\n * Fold a policy over a condition's base (taxonomy) conclusion. `fail` respects\n * the taxonomy; `warn` caps a failure at `neutral` (still surfaced, never\n * blocking); `off` is handled by dropping the condition before this runs.\n */\nfunction applyPolicy(\n base: CiConclusion,\n policy: ConditionPolicy,\n): CiConclusion {\n if (policy === \"warn\") return base === \"failure\" ? \"neutral\" : base;\n return base;\n}\n\nconst SEVERITY: Record<CiConclusion, number> = {\n success: 0,\n neutral: 1,\n failure: 2,\n};\n\n/**\n * Evaluate a run: apply each condition's policy, then take the run conclusion as\n * the worst surfaced conclusion. Pure — the same (results, config) always yields\n * the same evaluation.\n *\n * Only diff-introduced conditions should reach here: detectors (e.g. the crude\n * gate, #3496) evaluate the PR diff, so pre-existing untouched code never\n * produces a result and can't trip a condition. With no results, the run passes.\n */\nexport function evaluateRun(\n results: ConditionResult[],\n config: RepoPolicyConfig = {},\n): RunEvaluation {\n const conditions: ResolvedCondition[] = [];\n for (const result of results) {\n const policy = policyFor(result.condition, config);\n if (policy === \"off\") continue; // suppressed entirely — not surfaced\n conditions.push({\n condition: result.condition,\n policy,\n verdict: result.verdict,\n conclusion: applyPolicy(conclusionForVerdict(result.verdict), policy),\n });\n }\n const conclusion = conditions.reduce<CiConclusion>(\n (worst, c) =>\n SEVERITY[c.conclusion] > SEVERITY[worst] ? c.conclusion : worst,\n \"success\",\n );\n return { conclusion, conditions };\n}\n"],"mappings":";;;;;;;;;AA8CA,MAAa,6BAA+C;CAC1D,wBAAwB;CACxB,gBAAgB;CAChB,aAAa;CACb,+BAA+B;CAC/B,oBAAoB;CACrB;;;;;;AAOD,MAAa,kBAAmC;;AAGhD,SAAgB,UACd,WACA,SAA2B,EAAE,EACZ;AACjB,QACE,OAAO,cACP,2BAA2B,cAAA;;;;;;;AAU/B,SAAS,YACP,MACA,QACc;AACd,KAAI,WAAW,OAAQ,QAAO,SAAS,YAAY,YAAY;AAC/D,QAAO;;AAGT,MAAM,WAAyC;CAC7C,SAAS;CACT,SAAS;CACT,SAAS;CACV;;;;;;;;;;AAWD,SAAgB,YACd,SACA,SAA2B,EAAE,EACd;CACf,MAAM,aAAkC,EAAE;AAC1C,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,SAAS,UAAU,OAAO,WAAW,OAAO;AAClD,MAAI,WAAW,MAAO;AACtB,aAAW,KAAK;GACd,WAAW,OAAO;GAClB;GACA,SAAS,OAAO;GAChB,YAAY,YAAYA,iBAAAA,qBAAqB,OAAO,QAAQ,EAAE,OAAO;GACtE,CAAC;;AAOJ,QAAO;EAAE,YALU,WAAW,QAC3B,OAAO,MACN,SAAS,EAAE,cAAc,SAAS,SAAS,EAAE,aAAa,OAC5D,
|
|
1
|
+
{"version":3,"file":"policy.cjs","names":["conclusionForVerdict"],"sources":["../../src/ci/policy.ts"],"sourcesContent":["import { conclusionForVerdict, type CiConclusion } from \"./taxonomy.ts\";\nimport type { CiVerdict } from \"./verdict.ts\";\n\n// The policy engine (#3500). Turns the single hard-coded gate into a\n// configurable condition set: a per-repo config gives each condition\n// `fail | warn | off`, and this pure function folds (config, condition results)\n// into one run conclusion plus the per-condition verdicts that produced it.\n//\n// It sits one layer above the taxonomy (#3498): the taxonomy says what a\n// verdict *would* conclude; the policy decides whether this repo lets that\n// condition fail, soften to a warning, or be suppressed entirely.\n\n/** What a repo lets a condition do: fail the run, warn-only, or ignore it. */\nexport type ConditionPolicy = \"fail\" | \"warn\" | \"off\";\n\n/** One condition's evaluated outcome, before policy is applied. */\nexport interface ConditionResult {\n /** Stable condition key, matched against the repo config (e.g. \"untested-data-access\"). */\n condition: string;\n verdict: CiVerdict;\n}\n\n/** A condition after policy resolution — its effective conclusion and the policy that shaped it. */\nexport interface ResolvedCondition {\n condition: string;\n policy: ConditionPolicy;\n verdict: CiVerdict;\n conclusion: CiConclusion;\n}\n\nexport interface RunEvaluation {\n /** The run's overall conclusion — the worst surfaced per-condition conclusion. */\n conclusion: CiConclusion;\n /** Per-condition results that were surfaced (an `off` condition is dropped). */\n conditions: ResolvedCondition[];\n}\n\n/** Per-repo policy: condition key → its policy. Absent keys fall back to defaults. */\nexport type RepoPolicyConfig = Record<string, ConditionPolicy>;\n\n/**\n * Opinionated safe defaults (epic #3493). A captured new query already ran in a\n * test (tested by definition), so it's informational; everything that marks a\n * genuine, diff-introduced problem fails by default. The set is deliberately\n * narrow so the gate under-fires.\n */\nexport const DEFAULT_CONDITION_POLICIES: RepoPolicyConfig = {\n \"untested-data-access\": \"fail\",\n \"schema-drift\": \"fail\", // #3296 owns the signal; this just gates on it\n \"new-query\": \"warn\", // captured ⇒ tested — informational, surfaced, non-blocking\n \"regression-beyond-threshold\": \"fail\", // #3104\n \"high-value-nudge\": \"fail\", // narrow set only\n};\n\n/**\n * Fallback for a condition with neither a repo setting nor a documented default:\n * surface it, don't block. Under-firing on an unrecognised condition is the safe\n * side of the line.\n */\nexport const FALLBACK_POLICY: ConditionPolicy = \"warn\";\n\n/** Resolve the effective policy for a condition: repo config, else default, else fallback. */\nexport function policyFor(\n condition: string,\n config: RepoPolicyConfig = {},\n): ConditionPolicy {\n return (\n config[condition] ??\n DEFAULT_CONDITION_POLICIES[condition] ??\n FALLBACK_POLICY\n );\n}\n\n/**\n * Fold a policy over a condition's base (taxonomy) conclusion. `fail` respects\n * the taxonomy; `warn` caps a failure at `neutral` (still surfaced, never\n * blocking); `off` is handled by dropping the condition before this runs.\n */\nfunction applyPolicy(\n base: CiConclusion,\n policy: ConditionPolicy,\n): CiConclusion {\n if (policy === \"warn\") return base === \"failure\" ? \"neutral\" : base;\n return base;\n}\n\nconst SEVERITY: Record<CiConclusion, number> = {\n success: 0,\n neutral: 1,\n failure: 2,\n};\n\n/**\n * Evaluate a run: apply each condition's policy, then take the run conclusion as\n * the worst surfaced conclusion. Pure — the same (results, config) always yields\n * the same evaluation.\n *\n * Only diff-introduced conditions should reach here: detectors (e.g. the crude\n * gate, #3496) evaluate the PR diff, so pre-existing untouched code never\n * produces a result and can't trip a condition. With no results, the run passes.\n */\nexport function evaluateRun(\n results: ConditionResult[],\n config: RepoPolicyConfig = {},\n): RunEvaluation {\n const conditions: ResolvedCondition[] = [];\n for (const result of results) {\n const policy = policyFor(result.condition, config);\n if (policy === \"off\") continue; // suppressed entirely — not surfaced\n conditions.push({\n condition: result.condition,\n policy,\n verdict: result.verdict,\n conclusion: applyPolicy(conclusionForVerdict(result.verdict), policy),\n });\n }\n const conclusion = conditions.reduce<CiConclusion>(\n (worst, c) =>\n SEVERITY[c.conclusion] > SEVERITY[worst] ? c.conclusion : worst,\n \"success\",\n );\n return { conclusion, conditions };\n}\n"],"mappings":";;;;;;;;;AA8CA,MAAa,6BAA+C;CAC1D,wBAAwB;CACxB,gBAAgB;CAChB,aAAa;CACb,+BAA+B;CAC/B,oBAAoB;CACrB;;;;;;AAOD,MAAa,kBAAmC;;AAGhD,SAAgB,UACd,WACA,SAA2B,EAAE,EACZ;AACjB,QACE,OAAO,cACP,2BAA2B,cAAA;;;;;;;AAU/B,SAAS,YACP,MACA,QACc;AACd,KAAI,WAAW,OAAQ,QAAO,SAAS,YAAY,YAAY;AAC/D,QAAO;;AAGT,MAAM,WAAyC;CAC7C,SAAS;CACT,SAAS;CACT,SAAS;CACV;;;;;;;;;;AAWD,SAAgB,YACd,SACA,SAA2B,EAAE,EACd;CACf,MAAM,aAAkC,EAAE;AAC1C,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,SAAS,UAAU,OAAO,WAAW,OAAO;AAClD,MAAI,WAAW,MAAO;AACtB,aAAW,KAAK;GACd,WAAW,OAAO;GAClB;GACA,SAAS,OAAO;GAChB,YAAY,YAAYA,iBAAAA,qBAAqB,OAAO,QAAQ,EAAE,OAAO;GACtE,CAAC;;AAOJ,QAAO;EAAE,YALU,WAAW,QAC3B,OAAO,MACN,SAAS,EAAE,cAAc,SAAS,SAAS,EAAE,aAAa,OAC5D,UAEiB;EAAE;EAAY"}
|
package/dist/ci/policy.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.mjs","names":[],"sources":["../../src/ci/policy.ts"],"sourcesContent":["import { conclusionForVerdict, type CiConclusion } from \"./taxonomy.ts\";\nimport type { CiVerdict } from \"./verdict.ts\";\n\n// The policy engine (#3500). Turns the single hard-coded gate into a\n// configurable condition set: a per-repo config gives each condition\n// `fail | warn | off`, and this pure function folds (config, condition results)\n// into one run conclusion plus the per-condition verdicts that produced it.\n//\n// It sits one layer above the taxonomy (#3498): the taxonomy says what a\n// verdict *would* conclude; the policy decides whether this repo lets that\n// condition fail, soften to a warning, or be suppressed entirely.\n\n/** What a repo lets a condition do: fail the run, warn-only, or ignore it. */\nexport type ConditionPolicy = \"fail\" | \"warn\" | \"off\";\n\n/** One condition's evaluated outcome, before policy is applied. */\nexport interface ConditionResult {\n /** Stable condition key, matched against the repo config (e.g. \"untested-data-access\"). */\n condition: string;\n verdict: CiVerdict;\n}\n\n/** A condition after policy resolution — its effective conclusion and the policy that shaped it. */\nexport interface ResolvedCondition {\n condition: string;\n policy: ConditionPolicy;\n verdict: CiVerdict;\n conclusion: CiConclusion;\n}\n\nexport interface RunEvaluation {\n /** The run's overall conclusion — the worst surfaced per-condition conclusion. */\n conclusion: CiConclusion;\n /** Per-condition results that were surfaced (an `off` condition is dropped). */\n conditions: ResolvedCondition[];\n}\n\n/** Per-repo policy: condition key → its policy. Absent keys fall back to defaults. */\nexport type RepoPolicyConfig = Record<string, ConditionPolicy>;\n\n/**\n * Opinionated safe defaults (epic #3493). A captured new query already ran in a\n * test (tested by definition), so it's informational; everything that marks a\n * genuine, diff-introduced problem fails by default. The set is deliberately\n * narrow so the gate under-fires.\n */\nexport const DEFAULT_CONDITION_POLICIES: RepoPolicyConfig = {\n \"untested-data-access\": \"fail\",\n \"schema-drift\": \"fail\", // #3296 owns the signal; this just gates on it\n \"new-query\": \"warn\", // captured ⇒ tested — informational, surfaced, non-blocking\n \"regression-beyond-threshold\": \"fail\", // #3104\n \"high-value-nudge\": \"fail\", // narrow set only\n};\n\n/**\n * Fallback for a condition with neither a repo setting nor a documented default:\n * surface it, don't block. Under-firing on an unrecognised condition is the safe\n * side of the line.\n */\nexport const FALLBACK_POLICY: ConditionPolicy = \"warn\";\n\n/** Resolve the effective policy for a condition: repo config, else default, else fallback. */\nexport function policyFor(\n condition: string,\n config: RepoPolicyConfig = {},\n): ConditionPolicy {\n return (\n config[condition] ??\n DEFAULT_CONDITION_POLICIES[condition] ??\n FALLBACK_POLICY\n );\n}\n\n/**\n * Fold a policy over a condition's base (taxonomy) conclusion. `fail` respects\n * the taxonomy; `warn` caps a failure at `neutral` (still surfaced, never\n * blocking); `off` is handled by dropping the condition before this runs.\n */\nfunction applyPolicy(\n base: CiConclusion,\n policy: ConditionPolicy,\n): CiConclusion {\n if (policy === \"warn\") return base === \"failure\" ? \"neutral\" : base;\n return base;\n}\n\nconst SEVERITY: Record<CiConclusion, number> = {\n success: 0,\n neutral: 1,\n failure: 2,\n};\n\n/**\n * Evaluate a run: apply each condition's policy, then take the run conclusion as\n * the worst surfaced conclusion. Pure — the same (results, config) always yields\n * the same evaluation.\n *\n * Only diff-introduced conditions should reach here: detectors (e.g. the crude\n * gate, #3496) evaluate the PR diff, so pre-existing untouched code never\n * produces a result and can't trip a condition. With no results, the run passes.\n */\nexport function evaluateRun(\n results: ConditionResult[],\n config: RepoPolicyConfig = {},\n): RunEvaluation {\n const conditions: ResolvedCondition[] = [];\n for (const result of results) {\n const policy = policyFor(result.condition, config);\n if (policy === \"off\") continue; // suppressed entirely — not surfaced\n conditions.push({\n condition: result.condition,\n policy,\n verdict: result.verdict,\n conclusion: applyPolicy(conclusionForVerdict(result.verdict), policy),\n });\n }\n const conclusion = conditions.reduce<CiConclusion>(\n (worst, c) =>\n SEVERITY[c.conclusion] > SEVERITY[worst] ? c.conclusion : worst,\n \"success\",\n );\n return { conclusion, conditions };\n}\n"],"mappings":";;;;;;;;;AA8CA,MAAa,6BAA+C;CAC1D,wBAAwB;CACxB,gBAAgB;CAChB,aAAa;CACb,+BAA+B;CAC/B,oBAAoB;CACrB;;;;;;AAOD,MAAa,kBAAmC;;AAGhD,SAAgB,UACd,WACA,SAA2B,EAAE,EACZ;AACjB,QACE,OAAO,cACP,2BAA2B,cAAA;;;;;;;AAU/B,SAAS,YACP,MACA,QACc;AACd,KAAI,WAAW,OAAQ,QAAO,SAAS,YAAY,YAAY;AAC/D,QAAO;;AAGT,MAAM,WAAyC;CAC7C,SAAS;CACT,SAAS;CACT,SAAS;CACV;;;;;;;;;;AAWD,SAAgB,YACd,SACA,SAA2B,EAAE,EACd;CACf,MAAM,aAAkC,EAAE;AAC1C,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,SAAS,UAAU,OAAO,WAAW,OAAO;AAClD,MAAI,WAAW,MAAO;AACtB,aAAW,KAAK;GACd,WAAW,OAAO;GAClB;GACA,SAAS,OAAO;GAChB,YAAY,YAAY,qBAAqB,OAAO,QAAQ,EAAE,OAAO;GACtE,CAAC;;AAOJ,QAAO;EAAE,YALU,WAAW,QAC3B,OAAO,MACN,SAAS,EAAE,cAAc,SAAS,SAAS,EAAE,aAAa,OAC5D,
|
|
1
|
+
{"version":3,"file":"policy.mjs","names":[],"sources":["../../src/ci/policy.ts"],"sourcesContent":["import { conclusionForVerdict, type CiConclusion } from \"./taxonomy.ts\";\nimport type { CiVerdict } from \"./verdict.ts\";\n\n// The policy engine (#3500). Turns the single hard-coded gate into a\n// configurable condition set: a per-repo config gives each condition\n// `fail | warn | off`, and this pure function folds (config, condition results)\n// into one run conclusion plus the per-condition verdicts that produced it.\n//\n// It sits one layer above the taxonomy (#3498): the taxonomy says what a\n// verdict *would* conclude; the policy decides whether this repo lets that\n// condition fail, soften to a warning, or be suppressed entirely.\n\n/** What a repo lets a condition do: fail the run, warn-only, or ignore it. */\nexport type ConditionPolicy = \"fail\" | \"warn\" | \"off\";\n\n/** One condition's evaluated outcome, before policy is applied. */\nexport interface ConditionResult {\n /** Stable condition key, matched against the repo config (e.g. \"untested-data-access\"). */\n condition: string;\n verdict: CiVerdict;\n}\n\n/** A condition after policy resolution — its effective conclusion and the policy that shaped it. */\nexport interface ResolvedCondition {\n condition: string;\n policy: ConditionPolicy;\n verdict: CiVerdict;\n conclusion: CiConclusion;\n}\n\nexport interface RunEvaluation {\n /** The run's overall conclusion — the worst surfaced per-condition conclusion. */\n conclusion: CiConclusion;\n /** Per-condition results that were surfaced (an `off` condition is dropped). */\n conditions: ResolvedCondition[];\n}\n\n/** Per-repo policy: condition key → its policy. Absent keys fall back to defaults. */\nexport type RepoPolicyConfig = Record<string, ConditionPolicy>;\n\n/**\n * Opinionated safe defaults (epic #3493). A captured new query already ran in a\n * test (tested by definition), so it's informational; everything that marks a\n * genuine, diff-introduced problem fails by default. The set is deliberately\n * narrow so the gate under-fires.\n */\nexport const DEFAULT_CONDITION_POLICIES: RepoPolicyConfig = {\n \"untested-data-access\": \"fail\",\n \"schema-drift\": \"fail\", // #3296 owns the signal; this just gates on it\n \"new-query\": \"warn\", // captured ⇒ tested — informational, surfaced, non-blocking\n \"regression-beyond-threshold\": \"fail\", // #3104\n \"high-value-nudge\": \"fail\", // narrow set only\n};\n\n/**\n * Fallback for a condition with neither a repo setting nor a documented default:\n * surface it, don't block. Under-firing on an unrecognised condition is the safe\n * side of the line.\n */\nexport const FALLBACK_POLICY: ConditionPolicy = \"warn\";\n\n/** Resolve the effective policy for a condition: repo config, else default, else fallback. */\nexport function policyFor(\n condition: string,\n config: RepoPolicyConfig = {},\n): ConditionPolicy {\n return (\n config[condition] ??\n DEFAULT_CONDITION_POLICIES[condition] ??\n FALLBACK_POLICY\n );\n}\n\n/**\n * Fold a policy over a condition's base (taxonomy) conclusion. `fail` respects\n * the taxonomy; `warn` caps a failure at `neutral` (still surfaced, never\n * blocking); `off` is handled by dropping the condition before this runs.\n */\nfunction applyPolicy(\n base: CiConclusion,\n policy: ConditionPolicy,\n): CiConclusion {\n if (policy === \"warn\") return base === \"failure\" ? \"neutral\" : base;\n return base;\n}\n\nconst SEVERITY: Record<CiConclusion, number> = {\n success: 0,\n neutral: 1,\n failure: 2,\n};\n\n/**\n * Evaluate a run: apply each condition's policy, then take the run conclusion as\n * the worst surfaced conclusion. Pure — the same (results, config) always yields\n * the same evaluation.\n *\n * Only diff-introduced conditions should reach here: detectors (e.g. the crude\n * gate, #3496) evaluate the PR diff, so pre-existing untouched code never\n * produces a result and can't trip a condition. With no results, the run passes.\n */\nexport function evaluateRun(\n results: ConditionResult[],\n config: RepoPolicyConfig = {},\n): RunEvaluation {\n const conditions: ResolvedCondition[] = [];\n for (const result of results) {\n const policy = policyFor(result.condition, config);\n if (policy === \"off\") continue; // suppressed entirely — not surfaced\n conditions.push({\n condition: result.condition,\n policy,\n verdict: result.verdict,\n conclusion: applyPolicy(conclusionForVerdict(result.verdict), policy),\n });\n }\n const conclusion = conditions.reduce<CiConclusion>(\n (worst, c) =>\n SEVERITY[c.conclusion] > SEVERITY[worst] ? c.conclusion : worst,\n \"success\",\n );\n return { conclusion, conditions };\n}\n"],"mappings":";;;;;;;;;AA8CA,MAAa,6BAA+C;CAC1D,wBAAwB;CACxB,gBAAgB;CAChB,aAAa;CACb,+BAA+B;CAC/B,oBAAoB;CACrB;;;;;;AAOD,MAAa,kBAAmC;;AAGhD,SAAgB,UACd,WACA,SAA2B,EAAE,EACZ;AACjB,QACE,OAAO,cACP,2BAA2B,cAAA;;;;;;;AAU/B,SAAS,YACP,MACA,QACc;AACd,KAAI,WAAW,OAAQ,QAAO,SAAS,YAAY,YAAY;AAC/D,QAAO;;AAGT,MAAM,WAAyC;CAC7C,SAAS;CACT,SAAS;CACT,SAAS;CACV;;;;;;;;;;AAWD,SAAgB,YACd,SACA,SAA2B,EAAE,EACd;CACf,MAAM,aAAkC,EAAE;AAC1C,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,SAAS,UAAU,OAAO,WAAW,OAAO;AAClD,MAAI,WAAW,MAAO;AACtB,aAAW,KAAK;GACd,WAAW,OAAO;GAClB;GACA,SAAS,OAAO;GAChB,YAAY,YAAY,qBAAqB,OAAO,QAAQ,EAAE,OAAO;GACtE,CAAC;;AAOJ,QAAO;EAAE,YALU,WAAW,QAC3B,OAAO,MACN,SAAS,EAAE,cAAc,SAAS,SAAS,EAAE,aAAa,OAC5D,UAEiB;EAAE;EAAY"}
|
package/dist/ci/verdict.cjs
CHANGED
|
@@ -78,12 +78,18 @@ const NO_ACTION = {
|
|
|
78
78
|
};
|
|
79
79
|
const CiVerdict = zod.z.object({
|
|
80
80
|
version: zod.z.literal(1),
|
|
81
|
+
/** The condition evaluated, e.g. "untested-data-access", "regression-beyond-threshold". */
|
|
81
82
|
condition: zod.z.string().min(1),
|
|
82
83
|
verdictClass: VerdictClass,
|
|
84
|
+
/** Plain-language why, honest about the tool's epistemic state. */
|
|
83
85
|
reason: zod.z.string().min(1),
|
|
86
|
+
/** The best-available next step; `notify-human` is a valid, well-formed value. */
|
|
84
87
|
nextStep: VerdictAction,
|
|
88
|
+
/** The non-fix exit — triage/acknowledge, or escalate to a human. */
|
|
85
89
|
triageAction: VerdictAction,
|
|
90
|
+
/** Deep link to the relevant guide, when one applies. */
|
|
86
91
|
docsUrl: zod.z.string().url().optional(),
|
|
92
|
+
/** MCP tools an agent should reach for to act on this verdict. */
|
|
87
93
|
suggestedMcpTools: zod.z.array(zod.z.string()).default([])
|
|
88
94
|
});
|
|
89
95
|
const ACTION_LABEL = {
|
package/dist/ci/verdict.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verdict.cjs","names":["z"],"sources":["../../src/ci/verdict.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * The versioned, shared verdict contract (#3497) — the cohesion mechanism for\n * the CI⇄MCP verdict loop (epic #3493). One condition's outcome on a CI run,\n * structured so an agent can *route* it (fix / add a test / escalate) without\n * scraping a build log. Every future condition — coverage gaps, regressions,\n * schema drift, nudges — emits this same shape, so no condition grows its own\n * red/green or its own remediation UX.\n *\n * The same payload is meant to render to the PR comment and to surface in the\n * MCP run object; the surfacing itself is owned by #3106/#3105, so this module\n * defines and validates the contract they consume, plus a reference renderer.\n *\n * Copy rule (ADR-0003): every string a verdict carries — `reason`, the action\n * `detail`s — renders verbatim in the PR comment. Hold it to\n * `.claude/rules/writing.md`: one point per field, no hedging, no\n * self-narration, no restating the condition. The terse-voice test in\n * `verdict.test.ts` is the reference copy the analyzer conforms to.\n */\n\n/**\n * Bump only on a *breaking* change to the shape below, so a consumer can refuse\n * a payload it doesn't understand. Additive, backward-compatible fields do not\n * require a bump.\n */\nexport const VERDICT_CONTRACT_VERSION = 1;\n\n/**\n * How honest the run can be about a condition. `pass` is the only clean\n * outcome; every other class is a non-pass the loop must route.\n * `uncertain-conservative-flag` is the deliberate \"we could not check this, so\n * we're flagging to be safe\" state — kept distinct so the tool's blindness is\n * never dressed up as either a clean pass or a hard failure.\n */\nexport const VerdictClass = z.enum([\n \"pass\",\n \"coverage-gap\",\n \"finding\",\n \"infra-error\",\n \"setup-broken\",\n \"uncertain-conservative-flag\",\n]);\nexport type VerdictClass = z.infer<typeof VerdictClass>;\n\n/**\n * The kind of action a verdict routes to. The ceiling is `auto-fix`; the floor\n * is `notify-human` — escalate-to-human-with-context, a designed success state\n * rather than a gap. `none` is only for a clean pass.\n */\nexport const VerdictActionKind = z.enum([\n \"auto-fix\",\n \"add-test\",\n \"triage\",\n \"notify-human\",\n \"none\",\n]);\nexport type VerdictActionKind = z.infer<typeof VerdictActionKind>;\n\n/**\n * A routable next action. `detail` always carries the human- and\n * machine-readable specifics: the fix instruction, the test to add, or — for\n * `notify-human` — the context handed to the person. Modeling \"notify human\n * (with context)\" as a first-class value, not an absent field, is the point:\n * the escalate-to-human floor is always well-formed, never an absence.\n */\nexport const VerdictAction = z.object({\n kind: VerdictActionKind,\n detail: z.string(),\n});\nexport type VerdictAction = z.infer<typeof VerdictAction>;\n\n/** A next step / triage action that escalates to a human, carrying context. */\nexport function notifyHuman(context: string): VerdictAction {\n return { kind: \"notify-human\", detail: context };\n}\n\n/** The \"nothing to do\" action, for a clean pass. */\nexport const NO_ACTION: VerdictAction = { kind: \"none\", detail: \"\" };\n\nexport const CiVerdict = z.object({\n version: z.literal(VERDICT_CONTRACT_VERSION),\n /** The condition evaluated, e.g. \"untested-data-access\", \"regression-beyond-threshold\". */\n condition: z.string().min(1),\n verdictClass: VerdictClass,\n /** Plain-language why, honest about the tool's epistemic state. */\n reason: z.string().min(1),\n /** The best-available next step; `notify-human` is a valid, well-formed value. */\n nextStep: VerdictAction,\n /** The non-fix exit — triage/acknowledge, or escalate to a human. */\n triageAction: VerdictAction,\n /** Deep link to the relevant guide, when one applies. */\n docsUrl: z.string().url().optional(),\n /** MCP tools an agent should reach for to act on this verdict. */\n suggestedMcpTools: z.array(z.string()).default([]),\n});\nexport type CiVerdict = z.infer<typeof CiVerdict>;\n\nconst ACTION_LABEL: Record<VerdictActionKind, string> = {\n \"auto-fix\": \"Fix\",\n \"add-test\": \"Add a test\",\n triage: \"Triage\",\n \"notify-human\": \"Notify a human\",\n none: \"No action\",\n};\n\n/**\n * Render a verdict to Markdown for a PR comment. Intentionally presentation\n * only: it shows the fields, it does not decide pass/fail (that mapping is the\n * failure taxonomy, #3498). Kept alongside the contract so \"the comment renders\n * from the contract\" is demonstrably true and ready for the analyzer to import.\n */\nexport function renderVerdictMarkdown(verdict: CiVerdict): string {\n const parts: string[] = [`**${verdict.condition}** — ${verdict.reason}`];\n const hasNextStep = verdict.nextStep.kind !== \"none\";\n const hasTriage = verdict.triageAction.kind !== \"none\";\n // The fix path and the triage exit are alternatives, not a sequence — when\n // both exist, render them as one choice.\n if (hasNextStep && hasTriage) {\n parts.push(\n [\n \"**Next step — do one of:**\",\n `- **${ACTION_LABEL[verdict.nextStep.kind]}:** ${verdict.nextStep.detail}`,\n `- **${ACTION_LABEL[verdict.triageAction.kind]}:** ${verdict.triageAction.detail}`,\n ].join(\"\\n\"),\n );\n } else if (hasNextStep) {\n parts.push(\n `**Next step (${ACTION_LABEL[verdict.nextStep.kind]}):** ${verdict.nextStep.detail}`,\n );\n } else if (hasTriage) {\n parts.push(\n `**Triage (${ACTION_LABEL[verdict.triageAction.kind]}):** ${verdict.triageAction.detail}`,\n );\n }\n if (verdict.suggestedMcpTools.length > 0) {\n const tools = verdict.suggestedMcpTools.map((t) => `\\`${t}\\``).join(\", \");\n parts.push(`**MCP tools:** ${tools}`);\n }\n if (verdict.docsUrl) {\n parts.push(`[Learn more](${verdict.docsUrl})`);\n }\n return parts.join(\"\\n\\n\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAa,2BAA2B;;;;;;;;AASxC,MAAa,eAAeA,IAAAA,EAAE,KAAK;CACjC;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;AAQF,MAAa,oBAAoBA,IAAAA,EAAE,KAAK;CACtC;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;;;AAUF,MAAa,gBAAgBA,IAAAA,EAAE,OAAO;CACpC,MAAM;CACN,QAAQA,IAAAA,EAAE,QAAQ;CACnB,CAAC;;AAIF,SAAgB,YAAY,SAAgC;AAC1D,QAAO;EAAE,MAAM;EAAgB,QAAQ;EAAS;;;AAIlD,MAAa,YAA2B;CAAE,MAAM;CAAQ,QAAQ;CAAI;AAEpE,MAAa,YAAYA,IAAAA,EAAE,OAAO;CAChC,SAASA,IAAAA,EAAE,QAAA,EAAiC
|
|
1
|
+
{"version":3,"file":"verdict.cjs","names":["z"],"sources":["../../src/ci/verdict.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * The versioned, shared verdict contract (#3497) — the cohesion mechanism for\n * the CI⇄MCP verdict loop (epic #3493). One condition's outcome on a CI run,\n * structured so an agent can *route* it (fix / add a test / escalate) without\n * scraping a build log. Every future condition — coverage gaps, regressions,\n * schema drift, nudges — emits this same shape, so no condition grows its own\n * red/green or its own remediation UX.\n *\n * The same payload is meant to render to the PR comment and to surface in the\n * MCP run object; the surfacing itself is owned by #3106/#3105, so this module\n * defines and validates the contract they consume, plus a reference renderer.\n *\n * Copy rule (ADR-0003): every string a verdict carries — `reason`, the action\n * `detail`s — renders verbatim in the PR comment. Hold it to\n * `.claude/rules/writing.md`: one point per field, no hedging, no\n * self-narration, no restating the condition. The terse-voice test in\n * `verdict.test.ts` is the reference copy the analyzer conforms to.\n */\n\n/**\n * Bump only on a *breaking* change to the shape below, so a consumer can refuse\n * a payload it doesn't understand. Additive, backward-compatible fields do not\n * require a bump.\n */\nexport const VERDICT_CONTRACT_VERSION = 1;\n\n/**\n * How honest the run can be about a condition. `pass` is the only clean\n * outcome; every other class is a non-pass the loop must route.\n * `uncertain-conservative-flag` is the deliberate \"we could not check this, so\n * we're flagging to be safe\" state — kept distinct so the tool's blindness is\n * never dressed up as either a clean pass or a hard failure.\n */\nexport const VerdictClass = z.enum([\n \"pass\",\n \"coverage-gap\",\n \"finding\",\n \"infra-error\",\n \"setup-broken\",\n \"uncertain-conservative-flag\",\n]);\nexport type VerdictClass = z.infer<typeof VerdictClass>;\n\n/**\n * The kind of action a verdict routes to. The ceiling is `auto-fix`; the floor\n * is `notify-human` — escalate-to-human-with-context, a designed success state\n * rather than a gap. `none` is only for a clean pass.\n */\nexport const VerdictActionKind = z.enum([\n \"auto-fix\",\n \"add-test\",\n \"triage\",\n \"notify-human\",\n \"none\",\n]);\nexport type VerdictActionKind = z.infer<typeof VerdictActionKind>;\n\n/**\n * A routable next action. `detail` always carries the human- and\n * machine-readable specifics: the fix instruction, the test to add, or — for\n * `notify-human` — the context handed to the person. Modeling \"notify human\n * (with context)\" as a first-class value, not an absent field, is the point:\n * the escalate-to-human floor is always well-formed, never an absence.\n */\nexport const VerdictAction = z.object({\n kind: VerdictActionKind,\n detail: z.string(),\n});\nexport type VerdictAction = z.infer<typeof VerdictAction>;\n\n/** A next step / triage action that escalates to a human, carrying context. */\nexport function notifyHuman(context: string): VerdictAction {\n return { kind: \"notify-human\", detail: context };\n}\n\n/** The \"nothing to do\" action, for a clean pass. */\nexport const NO_ACTION: VerdictAction = { kind: \"none\", detail: \"\" };\n\nexport const CiVerdict = z.object({\n version: z.literal(VERDICT_CONTRACT_VERSION),\n /** The condition evaluated, e.g. \"untested-data-access\", \"regression-beyond-threshold\". */\n condition: z.string().min(1),\n verdictClass: VerdictClass,\n /** Plain-language why, honest about the tool's epistemic state. */\n reason: z.string().min(1),\n /** The best-available next step; `notify-human` is a valid, well-formed value. */\n nextStep: VerdictAction,\n /** The non-fix exit — triage/acknowledge, or escalate to a human. */\n triageAction: VerdictAction,\n /** Deep link to the relevant guide, when one applies. */\n docsUrl: z.string().url().optional(),\n /** MCP tools an agent should reach for to act on this verdict. */\n suggestedMcpTools: z.array(z.string()).default([]),\n});\nexport type CiVerdict = z.infer<typeof CiVerdict>;\n\nconst ACTION_LABEL: Record<VerdictActionKind, string> = {\n \"auto-fix\": \"Fix\",\n \"add-test\": \"Add a test\",\n triage: \"Triage\",\n \"notify-human\": \"Notify a human\",\n none: \"No action\",\n};\n\n/**\n * Render a verdict to Markdown for a PR comment. Intentionally presentation\n * only: it shows the fields, it does not decide pass/fail (that mapping is the\n * failure taxonomy, #3498). Kept alongside the contract so \"the comment renders\n * from the contract\" is demonstrably true and ready for the analyzer to import.\n */\nexport function renderVerdictMarkdown(verdict: CiVerdict): string {\n const parts: string[] = [`**${verdict.condition}** — ${verdict.reason}`];\n const hasNextStep = verdict.nextStep.kind !== \"none\";\n const hasTriage = verdict.triageAction.kind !== \"none\";\n // The fix path and the triage exit are alternatives, not a sequence — when\n // both exist, render them as one choice.\n if (hasNextStep && hasTriage) {\n parts.push(\n [\n \"**Next step — do one of:**\",\n `- **${ACTION_LABEL[verdict.nextStep.kind]}:** ${verdict.nextStep.detail}`,\n `- **${ACTION_LABEL[verdict.triageAction.kind]}:** ${verdict.triageAction.detail}`,\n ].join(\"\\n\"),\n );\n } else if (hasNextStep) {\n parts.push(\n `**Next step (${ACTION_LABEL[verdict.nextStep.kind]}):** ${verdict.nextStep.detail}`,\n );\n } else if (hasTriage) {\n parts.push(\n `**Triage (${ACTION_LABEL[verdict.triageAction.kind]}):** ${verdict.triageAction.detail}`,\n );\n }\n if (verdict.suggestedMcpTools.length > 0) {\n const tools = verdict.suggestedMcpTools.map((t) => `\\`${t}\\``).join(\", \");\n parts.push(`**MCP tools:** ${tools}`);\n }\n if (verdict.docsUrl) {\n parts.push(`[Learn more](${verdict.docsUrl})`);\n }\n return parts.join(\"\\n\\n\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAa,2BAA2B;;;;;;;;AASxC,MAAa,eAAeA,IAAAA,EAAE,KAAK;CACjC;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;AAQF,MAAa,oBAAoBA,IAAAA,EAAE,KAAK;CACtC;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;;;AAUF,MAAa,gBAAgBA,IAAAA,EAAE,OAAO;CACpC,MAAM;CACN,QAAQA,IAAAA,EAAE,QAAQ;CACnB,CAAC;;AAIF,SAAgB,YAAY,SAAgC;AAC1D,QAAO;EAAE,MAAM;EAAgB,QAAQ;EAAS;;;AAIlD,MAAa,YAA2B;CAAE,MAAM;CAAQ,QAAQ;CAAI;AAEpE,MAAa,YAAYA,IAAAA,EAAE,OAAO;CAChC,SAASA,IAAAA,EAAE,QAAA,EAAiC;;CAE5C,WAAWA,IAAAA,EAAE,QAAQ,CAAC,IAAI,EAAE;CAC5B,cAAc;;CAEd,QAAQA,IAAAA,EAAE,QAAQ,CAAC,IAAI,EAAE;;CAEzB,UAAU;;CAEV,cAAc;;CAEd,SAASA,IAAAA,EAAE,QAAQ,CAAC,KAAK,CAAC,UAAU;;CAEpC,mBAAmBA,IAAAA,EAAE,MAAMA,IAAAA,EAAE,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC;CACnD,CAAC;AAGF,MAAM,eAAkD;CACtD,YAAY;CACZ,YAAY;CACZ,QAAQ;CACR,gBAAgB;CAChB,MAAM;CACP;;;;;;;AAQD,SAAgB,sBAAsB,SAA4B;CAChE,MAAM,QAAkB,CAAC,KAAK,QAAQ,UAAU,OAAO,QAAQ,SAAS;CACxE,MAAM,cAAc,QAAQ,SAAS,SAAS;CAC9C,MAAM,YAAY,QAAQ,aAAa,SAAS;AAGhD,KAAI,eAAe,UACjB,OAAM,KACJ;EACE;EACA,OAAO,aAAa,QAAQ,SAAS,MAAM,MAAM,QAAQ,SAAS;EAClE,OAAO,aAAa,QAAQ,aAAa,MAAM,MAAM,QAAQ,aAAa;EAC3E,CAAC,KAAK,KAAK,CACb;UACQ,YACT,OAAM,KACJ,gBAAgB,aAAa,QAAQ,SAAS,MAAM,OAAO,QAAQ,SAAS,SAC7E;UACQ,UACT,OAAM,KACJ,aAAa,aAAa,QAAQ,aAAa,MAAM,OAAO,QAAQ,aAAa,SAClF;AAEH,KAAI,QAAQ,kBAAkB,SAAS,GAAG;EACxC,MAAM,QAAQ,QAAQ,kBAAkB,KAAK,MAAM,KAAK,EAAE,IAAI,CAAC,KAAK,KAAK;AACzE,QAAM,KAAK,kBAAkB,QAAQ;;AAEvC,KAAI,QAAQ,QACV,OAAM,KAAK,gBAAgB,QAAQ,QAAQ,GAAG;AAEhD,QAAO,MAAM,KAAK,OAAO"}
|
package/dist/ci/verdict.mjs
CHANGED
|
@@ -77,12 +77,18 @@ const NO_ACTION = {
|
|
|
77
77
|
};
|
|
78
78
|
const CiVerdict = z.object({
|
|
79
79
|
version: z.literal(1),
|
|
80
|
+
/** The condition evaluated, e.g. "untested-data-access", "regression-beyond-threshold". */
|
|
80
81
|
condition: z.string().min(1),
|
|
81
82
|
verdictClass: VerdictClass,
|
|
83
|
+
/** Plain-language why, honest about the tool's epistemic state. */
|
|
82
84
|
reason: z.string().min(1),
|
|
85
|
+
/** The best-available next step; `notify-human` is a valid, well-formed value. */
|
|
83
86
|
nextStep: VerdictAction,
|
|
87
|
+
/** The non-fix exit — triage/acknowledge, or escalate to a human. */
|
|
84
88
|
triageAction: VerdictAction,
|
|
89
|
+
/** Deep link to the relevant guide, when one applies. */
|
|
85
90
|
docsUrl: z.string().url().optional(),
|
|
91
|
+
/** MCP tools an agent should reach for to act on this verdict. */
|
|
86
92
|
suggestedMcpTools: z.array(z.string()).default([])
|
|
87
93
|
});
|
|
88
94
|
const ACTION_LABEL = {
|
package/dist/ci/verdict.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verdict.mjs","names":[],"sources":["../../src/ci/verdict.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * The versioned, shared verdict contract (#3497) — the cohesion mechanism for\n * the CI⇄MCP verdict loop (epic #3493). One condition's outcome on a CI run,\n * structured so an agent can *route* it (fix / add a test / escalate) without\n * scraping a build log. Every future condition — coverage gaps, regressions,\n * schema drift, nudges — emits this same shape, so no condition grows its own\n * red/green or its own remediation UX.\n *\n * The same payload is meant to render to the PR comment and to surface in the\n * MCP run object; the surfacing itself is owned by #3106/#3105, so this module\n * defines and validates the contract they consume, plus a reference renderer.\n *\n * Copy rule (ADR-0003): every string a verdict carries — `reason`, the action\n * `detail`s — renders verbatim in the PR comment. Hold it to\n * `.claude/rules/writing.md`: one point per field, no hedging, no\n * self-narration, no restating the condition. The terse-voice test in\n * `verdict.test.ts` is the reference copy the analyzer conforms to.\n */\n\n/**\n * Bump only on a *breaking* change to the shape below, so a consumer can refuse\n * a payload it doesn't understand. Additive, backward-compatible fields do not\n * require a bump.\n */\nexport const VERDICT_CONTRACT_VERSION = 1;\n\n/**\n * How honest the run can be about a condition. `pass` is the only clean\n * outcome; every other class is a non-pass the loop must route.\n * `uncertain-conservative-flag` is the deliberate \"we could not check this, so\n * we're flagging to be safe\" state — kept distinct so the tool's blindness is\n * never dressed up as either a clean pass or a hard failure.\n */\nexport const VerdictClass = z.enum([\n \"pass\",\n \"coverage-gap\",\n \"finding\",\n \"infra-error\",\n \"setup-broken\",\n \"uncertain-conservative-flag\",\n]);\nexport type VerdictClass = z.infer<typeof VerdictClass>;\n\n/**\n * The kind of action a verdict routes to. The ceiling is `auto-fix`; the floor\n * is `notify-human` — escalate-to-human-with-context, a designed success state\n * rather than a gap. `none` is only for a clean pass.\n */\nexport const VerdictActionKind = z.enum([\n \"auto-fix\",\n \"add-test\",\n \"triage\",\n \"notify-human\",\n \"none\",\n]);\nexport type VerdictActionKind = z.infer<typeof VerdictActionKind>;\n\n/**\n * A routable next action. `detail` always carries the human- and\n * machine-readable specifics: the fix instruction, the test to add, or — for\n * `notify-human` — the context handed to the person. Modeling \"notify human\n * (with context)\" as a first-class value, not an absent field, is the point:\n * the escalate-to-human floor is always well-formed, never an absence.\n */\nexport const VerdictAction = z.object({\n kind: VerdictActionKind,\n detail: z.string(),\n});\nexport type VerdictAction = z.infer<typeof VerdictAction>;\n\n/** A next step / triage action that escalates to a human, carrying context. */\nexport function notifyHuman(context: string): VerdictAction {\n return { kind: \"notify-human\", detail: context };\n}\n\n/** The \"nothing to do\" action, for a clean pass. */\nexport const NO_ACTION: VerdictAction = { kind: \"none\", detail: \"\" };\n\nexport const CiVerdict = z.object({\n version: z.literal(VERDICT_CONTRACT_VERSION),\n /** The condition evaluated, e.g. \"untested-data-access\", \"regression-beyond-threshold\". */\n condition: z.string().min(1),\n verdictClass: VerdictClass,\n /** Plain-language why, honest about the tool's epistemic state. */\n reason: z.string().min(1),\n /** The best-available next step; `notify-human` is a valid, well-formed value. */\n nextStep: VerdictAction,\n /** The non-fix exit — triage/acknowledge, or escalate to a human. */\n triageAction: VerdictAction,\n /** Deep link to the relevant guide, when one applies. */\n docsUrl: z.string().url().optional(),\n /** MCP tools an agent should reach for to act on this verdict. */\n suggestedMcpTools: z.array(z.string()).default([]),\n});\nexport type CiVerdict = z.infer<typeof CiVerdict>;\n\nconst ACTION_LABEL: Record<VerdictActionKind, string> = {\n \"auto-fix\": \"Fix\",\n \"add-test\": \"Add a test\",\n triage: \"Triage\",\n \"notify-human\": \"Notify a human\",\n none: \"No action\",\n};\n\n/**\n * Render a verdict to Markdown for a PR comment. Intentionally presentation\n * only: it shows the fields, it does not decide pass/fail (that mapping is the\n * failure taxonomy, #3498). Kept alongside the contract so \"the comment renders\n * from the contract\" is demonstrably true and ready for the analyzer to import.\n */\nexport function renderVerdictMarkdown(verdict: CiVerdict): string {\n const parts: string[] = [`**${verdict.condition}** — ${verdict.reason}`];\n const hasNextStep = verdict.nextStep.kind !== \"none\";\n const hasTriage = verdict.triageAction.kind !== \"none\";\n // The fix path and the triage exit are alternatives, not a sequence — when\n // both exist, render them as one choice.\n if (hasNextStep && hasTriage) {\n parts.push(\n [\n \"**Next step — do one of:**\",\n `- **${ACTION_LABEL[verdict.nextStep.kind]}:** ${verdict.nextStep.detail}`,\n `- **${ACTION_LABEL[verdict.triageAction.kind]}:** ${verdict.triageAction.detail}`,\n ].join(\"\\n\"),\n );\n } else if (hasNextStep) {\n parts.push(\n `**Next step (${ACTION_LABEL[verdict.nextStep.kind]}):** ${verdict.nextStep.detail}`,\n );\n } else if (hasTriage) {\n parts.push(\n `**Triage (${ACTION_LABEL[verdict.triageAction.kind]}):** ${verdict.triageAction.detail}`,\n );\n }\n if (verdict.suggestedMcpTools.length > 0) {\n const tools = verdict.suggestedMcpTools.map((t) => `\\`${t}\\``).join(\", \");\n parts.push(`**MCP tools:** ${tools}`);\n }\n if (verdict.docsUrl) {\n parts.push(`[Learn more](${verdict.docsUrl})`);\n }\n return parts.join(\"\\n\\n\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAa,2BAA2B;;;;;;;;AASxC,MAAa,eAAe,EAAE,KAAK;CACjC;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;AAQF,MAAa,oBAAoB,EAAE,KAAK;CACtC;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;;;AAUF,MAAa,gBAAgB,EAAE,OAAO;CACpC,MAAM;CACN,QAAQ,EAAE,QAAQ;CACnB,CAAC;;AAIF,SAAgB,YAAY,SAAgC;AAC1D,QAAO;EAAE,MAAM;EAAgB,QAAQ;EAAS;;;AAIlD,MAAa,YAA2B;CAAE,MAAM;CAAQ,QAAQ;CAAI;AAEpE,MAAa,YAAY,EAAE,OAAO;CAChC,SAAS,EAAE,QAAA,EAAiC
|
|
1
|
+
{"version":3,"file":"verdict.mjs","names":[],"sources":["../../src/ci/verdict.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * The versioned, shared verdict contract (#3497) — the cohesion mechanism for\n * the CI⇄MCP verdict loop (epic #3493). One condition's outcome on a CI run,\n * structured so an agent can *route* it (fix / add a test / escalate) without\n * scraping a build log. Every future condition — coverage gaps, regressions,\n * schema drift, nudges — emits this same shape, so no condition grows its own\n * red/green or its own remediation UX.\n *\n * The same payload is meant to render to the PR comment and to surface in the\n * MCP run object; the surfacing itself is owned by #3106/#3105, so this module\n * defines and validates the contract they consume, plus a reference renderer.\n *\n * Copy rule (ADR-0003): every string a verdict carries — `reason`, the action\n * `detail`s — renders verbatim in the PR comment. Hold it to\n * `.claude/rules/writing.md`: one point per field, no hedging, no\n * self-narration, no restating the condition. The terse-voice test in\n * `verdict.test.ts` is the reference copy the analyzer conforms to.\n */\n\n/**\n * Bump only on a *breaking* change to the shape below, so a consumer can refuse\n * a payload it doesn't understand. Additive, backward-compatible fields do not\n * require a bump.\n */\nexport const VERDICT_CONTRACT_VERSION = 1;\n\n/**\n * How honest the run can be about a condition. `pass` is the only clean\n * outcome; every other class is a non-pass the loop must route.\n * `uncertain-conservative-flag` is the deliberate \"we could not check this, so\n * we're flagging to be safe\" state — kept distinct so the tool's blindness is\n * never dressed up as either a clean pass or a hard failure.\n */\nexport const VerdictClass = z.enum([\n \"pass\",\n \"coverage-gap\",\n \"finding\",\n \"infra-error\",\n \"setup-broken\",\n \"uncertain-conservative-flag\",\n]);\nexport type VerdictClass = z.infer<typeof VerdictClass>;\n\n/**\n * The kind of action a verdict routes to. The ceiling is `auto-fix`; the floor\n * is `notify-human` — escalate-to-human-with-context, a designed success state\n * rather than a gap. `none` is only for a clean pass.\n */\nexport const VerdictActionKind = z.enum([\n \"auto-fix\",\n \"add-test\",\n \"triage\",\n \"notify-human\",\n \"none\",\n]);\nexport type VerdictActionKind = z.infer<typeof VerdictActionKind>;\n\n/**\n * A routable next action. `detail` always carries the human- and\n * machine-readable specifics: the fix instruction, the test to add, or — for\n * `notify-human` — the context handed to the person. Modeling \"notify human\n * (with context)\" as a first-class value, not an absent field, is the point:\n * the escalate-to-human floor is always well-formed, never an absence.\n */\nexport const VerdictAction = z.object({\n kind: VerdictActionKind,\n detail: z.string(),\n});\nexport type VerdictAction = z.infer<typeof VerdictAction>;\n\n/** A next step / triage action that escalates to a human, carrying context. */\nexport function notifyHuman(context: string): VerdictAction {\n return { kind: \"notify-human\", detail: context };\n}\n\n/** The \"nothing to do\" action, for a clean pass. */\nexport const NO_ACTION: VerdictAction = { kind: \"none\", detail: \"\" };\n\nexport const CiVerdict = z.object({\n version: z.literal(VERDICT_CONTRACT_VERSION),\n /** The condition evaluated, e.g. \"untested-data-access\", \"regression-beyond-threshold\". */\n condition: z.string().min(1),\n verdictClass: VerdictClass,\n /** Plain-language why, honest about the tool's epistemic state. */\n reason: z.string().min(1),\n /** The best-available next step; `notify-human` is a valid, well-formed value. */\n nextStep: VerdictAction,\n /** The non-fix exit — triage/acknowledge, or escalate to a human. */\n triageAction: VerdictAction,\n /** Deep link to the relevant guide, when one applies. */\n docsUrl: z.string().url().optional(),\n /** MCP tools an agent should reach for to act on this verdict. */\n suggestedMcpTools: z.array(z.string()).default([]),\n});\nexport type CiVerdict = z.infer<typeof CiVerdict>;\n\nconst ACTION_LABEL: Record<VerdictActionKind, string> = {\n \"auto-fix\": \"Fix\",\n \"add-test\": \"Add a test\",\n triage: \"Triage\",\n \"notify-human\": \"Notify a human\",\n none: \"No action\",\n};\n\n/**\n * Render a verdict to Markdown for a PR comment. Intentionally presentation\n * only: it shows the fields, it does not decide pass/fail (that mapping is the\n * failure taxonomy, #3498). Kept alongside the contract so \"the comment renders\n * from the contract\" is demonstrably true and ready for the analyzer to import.\n */\nexport function renderVerdictMarkdown(verdict: CiVerdict): string {\n const parts: string[] = [`**${verdict.condition}** — ${verdict.reason}`];\n const hasNextStep = verdict.nextStep.kind !== \"none\";\n const hasTriage = verdict.triageAction.kind !== \"none\";\n // The fix path and the triage exit are alternatives, not a sequence — when\n // both exist, render them as one choice.\n if (hasNextStep && hasTriage) {\n parts.push(\n [\n \"**Next step — do one of:**\",\n `- **${ACTION_LABEL[verdict.nextStep.kind]}:** ${verdict.nextStep.detail}`,\n `- **${ACTION_LABEL[verdict.triageAction.kind]}:** ${verdict.triageAction.detail}`,\n ].join(\"\\n\"),\n );\n } else if (hasNextStep) {\n parts.push(\n `**Next step (${ACTION_LABEL[verdict.nextStep.kind]}):** ${verdict.nextStep.detail}`,\n );\n } else if (hasTriage) {\n parts.push(\n `**Triage (${ACTION_LABEL[verdict.triageAction.kind]}):** ${verdict.triageAction.detail}`,\n );\n }\n if (verdict.suggestedMcpTools.length > 0) {\n const tools = verdict.suggestedMcpTools.map((t) => `\\`${t}\\``).join(\", \");\n parts.push(`**MCP tools:** ${tools}`);\n }\n if (verdict.docsUrl) {\n parts.push(`[Learn more](${verdict.docsUrl})`);\n }\n return parts.join(\"\\n\\n\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAa,2BAA2B;;;;;;;;AASxC,MAAa,eAAe,EAAE,KAAK;CACjC;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;AAQF,MAAa,oBAAoB,EAAE,KAAK;CACtC;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;;;AAUF,MAAa,gBAAgB,EAAE,OAAO;CACpC,MAAM;CACN,QAAQ,EAAE,QAAQ;CACnB,CAAC;;AAIF,SAAgB,YAAY,SAAgC;AAC1D,QAAO;EAAE,MAAM;EAAgB,QAAQ;EAAS;;;AAIlD,MAAa,YAA2B;CAAE,MAAM;CAAQ,QAAQ;CAAI;AAEpE,MAAa,YAAY,EAAE,OAAO;CAChC,SAAS,EAAE,QAAA,EAAiC;;CAE5C,WAAW,EAAE,QAAQ,CAAC,IAAI,EAAE;CAC5B,cAAc;;CAEd,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE;;CAEzB,UAAU;;CAEV,cAAc;;CAEd,SAAS,EAAE,QAAQ,CAAC,KAAK,CAAC,UAAU;;CAEpC,mBAAmB,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC;CACnD,CAAC;AAGF,MAAM,eAAkD;CACtD,YAAY;CACZ,YAAY;CACZ,QAAQ;CACR,gBAAgB;CAChB,MAAM;CACP;;;;;;;AAQD,SAAgB,sBAAsB,SAA4B;CAChE,MAAM,QAAkB,CAAC,KAAK,QAAQ,UAAU,OAAO,QAAQ,SAAS;CACxE,MAAM,cAAc,QAAQ,SAAS,SAAS;CAC9C,MAAM,YAAY,QAAQ,aAAa,SAAS;AAGhD,KAAI,eAAe,UACjB,OAAM,KACJ;EACE;EACA,OAAO,aAAa,QAAQ,SAAS,MAAM,MAAM,QAAQ,SAAS;EAClE,OAAO,aAAa,QAAQ,aAAa,MAAM,MAAM,QAAQ,aAAa;EAC3E,CAAC,KAAK,KAAK,CACb;UACQ,YACT,OAAM,KACJ,gBAAgB,aAAa,QAAQ,SAAS,MAAM,OAAO,QAAQ,SAAS,SAC7E;UACQ,UACT,OAAM,KACJ,aAAa,aAAa,QAAQ,aAAa,MAAM,OAAO,QAAQ,aAAa,SAClF;AAEH,KAAI,QAAQ,kBAAkB,SAAS,GAAG;EACxC,MAAM,QAAQ,QAAQ,kBAAkB,KAAK,MAAM,KAAK,EAAE,IAAI,CAAC,KAAK,KAAK;AACzE,QAAM,KAAK,kBAAkB,QAAQ;;AAEvC,KAAI,QAAQ,QACV,OAAM,KAAK,gBAAgB,QAAQ,QAAQ,GAAG;AAEhD,QAAO,MAAM,KAAK,OAAO"}
|