rahman-resources 1.4.0 → 1.6.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/bin/cli.js +39 -6
- package/bin/compose-print.mjs +83 -0
- package/bin/compose-state.mjs +105 -0
- package/bin/compose.mjs +30 -194
- package/bin/graph-render.mjs +179 -0
- package/bin/graph.mjs +3 -182
- package/bin/migrate-load.mjs +189 -0
- package/bin/migrate-print.mjs +75 -0
- package/bin/migrate.mjs +56 -297
- package/bin/update-context.mjs +184 -0
- package/bin/update-output.mjs +110 -0
- package/bin/update.mjs +15 -293
- package/lib/compose-solver-arbitrate.mjs +84 -0
- package/lib/compose-solver-conflicts.mjs +163 -0
- package/lib/compose-solver-loader.mjs +79 -0
- package/lib/compose-solver-resolve.mjs +165 -0
- package/lib/compose-solver.mjs +42 -376
- package/lib/contract-types.ts +184 -0
- package/lib/contract-validate.ts +155 -0
- package/lib/contract.ts +31 -319
- package/lib/dna-graph.mjs +53 -0
- package/lib/dna.mjs +5 -46
- package/lib/env-augment.mjs +116 -0
- package/lib/manifest.json +303 -351
- package/lib/merge3-diff.mjs +187 -0
- package/lib/merge3-snapshot.mjs +108 -0
- package/lib/merge3.mjs +7 -305
- package/lib/migration-plan-render.mjs +111 -0
- package/lib/migration-plan-steps.mjs +144 -0
- package/lib/migration-plan.mjs +17 -258
- package/lib/post-init.mjs +1 -1
- package/lib/skills.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// compose-solver-resolve.mjs — desired-set normalisation + BFS dep resolution.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions extracted from compose-solver.mjs. Operate on candidate
|
|
4
|
+
// state (Sets + arrays) — no I/O, no mutation of the caller's `contracts`
|
|
5
|
+
// map. Returns the new candidate state + an updated `proof`/`notes` log.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fold the user's `desired` list into a candidate set, classifying each entry
|
|
9
|
+
* as contracted (with a known contract) or uncontracted (allowed under
|
|
10
|
+
* `allowUnknownSlices`, otherwise rejected with a `missing-dep` blocker).
|
|
11
|
+
*
|
|
12
|
+
* Side-effects via the `record` callback — caller owns the conflict store.
|
|
13
|
+
*/
|
|
14
|
+
export function buildInitialCandidates({
|
|
15
|
+
desired,
|
|
16
|
+
contracts,
|
|
17
|
+
allowUnknownSlices,
|
|
18
|
+
record,
|
|
19
|
+
proof,
|
|
20
|
+
notes,
|
|
21
|
+
uncontractedDesired,
|
|
22
|
+
}) {
|
|
23
|
+
const candidateOrder = [];
|
|
24
|
+
const candidateSet = new Set();
|
|
25
|
+
|
|
26
|
+
for (const slug of desired) {
|
|
27
|
+
if (candidateSet.has(slug)) continue;
|
|
28
|
+
const contract = contracts.get(slug);
|
|
29
|
+
if (!contract) {
|
|
30
|
+
if (allowUnknownSlices) {
|
|
31
|
+
record(
|
|
32
|
+
{
|
|
33
|
+
type: "uncontracted",
|
|
34
|
+
slug,
|
|
35
|
+
detail: `Slice "${slug}" has no registered slice.contract.ts — accepted under allowUnknownSlices, but conflict checks are skipped for it.`,
|
|
36
|
+
severity: "warning",
|
|
37
|
+
},
|
|
38
|
+
slug,
|
|
39
|
+
);
|
|
40
|
+
uncontractedDesired.add(slug);
|
|
41
|
+
candidateSet.add(slug);
|
|
42
|
+
candidateOrder.push(slug);
|
|
43
|
+
notes.set(slug, "uncontracted");
|
|
44
|
+
proof.push(`! ${slug}: accepted as uncontracted (no slice.contract.ts; skipping conflict checks)`);
|
|
45
|
+
} else {
|
|
46
|
+
record(
|
|
47
|
+
{
|
|
48
|
+
type: "missing-dep",
|
|
49
|
+
slug,
|
|
50
|
+
detail: `Contract not found for "${slug}" — no slice.contract.ts registered (strict mode).`,
|
|
51
|
+
severity: "blocker",
|
|
52
|
+
},
|
|
53
|
+
slug,
|
|
54
|
+
);
|
|
55
|
+
proof.push(`- ${slug}: rejected (no contract found, strict mode)`);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
candidateSet.add(slug);
|
|
60
|
+
candidateOrder.push(slug);
|
|
61
|
+
}
|
|
62
|
+
return { candidateOrder, candidateSet };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* BFS over `requires.deps`, pulling transitive deps into the candidate set.
|
|
67
|
+
* Throws on cycles. Pushes informational lines to `proof`. Returns nothing —
|
|
68
|
+
* mutates `candidateOrder` / `candidateSet` in place.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveDependencies({
|
|
71
|
+
candidateOrder,
|
|
72
|
+
candidateSet,
|
|
73
|
+
contracts,
|
|
74
|
+
installed,
|
|
75
|
+
record,
|
|
76
|
+
proof,
|
|
77
|
+
}) {
|
|
78
|
+
/** @type {Array<{ slug: string; chain: string[] }>} */
|
|
79
|
+
const queue = candidateOrder
|
|
80
|
+
.filter((s) => contracts.has(s))
|
|
81
|
+
.map((slug) => ({ slug, chain: [slug] }));
|
|
82
|
+
while (queue.length > 0) {
|
|
83
|
+
const { slug, chain } = queue.shift();
|
|
84
|
+
const contract = contracts.get(slug);
|
|
85
|
+
const deps = contract?.requires?.deps ?? [];
|
|
86
|
+
for (const dep of deps) {
|
|
87
|
+
if (chain.includes(dep)) {
|
|
88
|
+
const cyclePath = [...chain.slice(chain.indexOf(dep)), dep].join(" → ");
|
|
89
|
+
throw new Error(`dependency cycle detected: ${cyclePath}`);
|
|
90
|
+
}
|
|
91
|
+
if (installed.has(dep)) continue;
|
|
92
|
+
if (candidateSet.has(dep)) continue;
|
|
93
|
+
const depContract = contracts.get(dep);
|
|
94
|
+
if (!depContract) {
|
|
95
|
+
record(
|
|
96
|
+
{
|
|
97
|
+
type: "missing-dep",
|
|
98
|
+
slug,
|
|
99
|
+
withSlug: dep,
|
|
100
|
+
detail: `Slice "${slug}" requires "${dep}" but no contract is registered for it.`,
|
|
101
|
+
severity: "blocker",
|
|
102
|
+
},
|
|
103
|
+
slug,
|
|
104
|
+
);
|
|
105
|
+
proof.push(`- ${slug}: missing dep "${dep}" (not in candidates, not installed)`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
candidateSet.add(dep);
|
|
109
|
+
candidateOrder.push(dep);
|
|
110
|
+
proof.push(`+ ${dep}: pulled in as transitive dep of ${slug}`);
|
|
111
|
+
queue.push({ slug: dep, chain: [...chain, dep] });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Without dependency resolution: surface every unmet `requires.deps` entry
|
|
118
|
+
* as a blocker. Used when the caller passed `resolveDeps: false`.
|
|
119
|
+
*/
|
|
120
|
+
export function reportUnresolvedDeps({
|
|
121
|
+
candidateOrder,
|
|
122
|
+
candidateSet,
|
|
123
|
+
contracts,
|
|
124
|
+
installed,
|
|
125
|
+
record,
|
|
126
|
+
}) {
|
|
127
|
+
for (const slug of candidateOrder) {
|
|
128
|
+
const deps = contracts.get(slug)?.requires?.deps ?? [];
|
|
129
|
+
for (const dep of deps) {
|
|
130
|
+
if (installed.has(dep) || candidateSet.has(dep)) continue;
|
|
131
|
+
record(
|
|
132
|
+
{
|
|
133
|
+
type: "missing-dep",
|
|
134
|
+
slug,
|
|
135
|
+
withSlug: dep,
|
|
136
|
+
detail: `Slice "${slug}" requires "${dep}" (resolveDeps disabled — not auto-pulled).`,
|
|
137
|
+
severity: "blocker",
|
|
138
|
+
},
|
|
139
|
+
slug,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* For each candidate, count how many OTHER candidates list it in their
|
|
147
|
+
* `requires.deps[]`. Used for arbitration tiebreaks.
|
|
148
|
+
*
|
|
149
|
+
* @returns {Map<string, number>}
|
|
150
|
+
*/
|
|
151
|
+
export function computeDependersCount(candidateOrder, contracts) {
|
|
152
|
+
/** @type {Map<string, number>} */
|
|
153
|
+
const dependersCount = new Map();
|
|
154
|
+
for (const slug of candidateOrder) dependersCount.set(slug, 0);
|
|
155
|
+
for (const slug of candidateOrder) {
|
|
156
|
+
const c = contracts.get(slug);
|
|
157
|
+
if (!c) continue;
|
|
158
|
+
for (const d of c.requires?.deps ?? []) {
|
|
159
|
+
if (dependersCount.has(d)) {
|
|
160
|
+
dependersCount.set(d, (dependersCount.get(d) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return dependersCount;
|
|
165
|
+
}
|
package/lib/compose-solver.mjs
CHANGED
|
@@ -12,88 +12,31 @@
|
|
|
12
12
|
// Public API + types live in compose-solver.d.ts.
|
|
13
13
|
//
|
|
14
14
|
// Runtime contract:
|
|
15
|
-
// - `loadAllContracts(repoRoot)`
|
|
16
|
-
//
|
|
15
|
+
// - `loadAllContracts(repoRoot)` (re-exported from compose-solver-loader)
|
|
16
|
+
// is the only I/O entry point.
|
|
17
17
|
// - `compose(req, contracts)` is pure — no fs, no env access, no mutation
|
|
18
18
|
// of inputs. Always returns a fresh result object.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
* @param {string} repoRoot Absolute path to the kitab repo root.
|
|
42
|
-
* @returns {Promise<Map<string, import("./contract").SliceContract>>}
|
|
43
|
-
*/
|
|
44
|
-
export async function loadAllContracts(repoRoot) {
|
|
45
|
-
/** @type {Map<string, import("./contract").SliceContract>} */
|
|
46
|
-
const out = new Map();
|
|
47
|
-
const sliceFiles = await discoverContractFiles(repoRoot);
|
|
48
|
-
for (const filePath of sliceFiles) {
|
|
49
|
-
const contract = loadContractFile(repoRoot, filePath);
|
|
50
|
-
if (contract && typeof contract.id === "string") {
|
|
51
|
-
out.set(contract.id, contract);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function discoverContractFiles(repoRoot) {
|
|
58
|
-
const found = [];
|
|
59
|
-
for (const segs of SLICE_ROOT_GLOBS) {
|
|
60
|
-
const root = path.join(repoRoot, ...segs);
|
|
61
|
-
if (!existsSync(root)) continue;
|
|
62
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
63
|
-
for (const entry of entries) {
|
|
64
|
-
if (!entry.isDirectory()) continue;
|
|
65
|
-
if (entry.name.startsWith("_")) continue;
|
|
66
|
-
const filePath = path.join(root, entry.name, "slice.contract.ts");
|
|
67
|
-
if (existsSync(filePath)) found.push(filePath);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return found;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Dynamic-import a .ts contract file via `npx tsx -e` and JSON.parse the
|
|
75
|
-
* stringified `contract` export. Returns null on any failure.
|
|
76
|
-
*/
|
|
77
|
-
function loadContractFile(repoRoot, filePath) {
|
|
78
|
-
const rel = "./" + path.relative(repoRoot, filePath);
|
|
79
|
-
const code = [
|
|
80
|
-
`import(${JSON.stringify(rel)})`,
|
|
81
|
-
` .then(m => { const c = m.contract || (m.default && m.default.contract); if (!c) { process.exit(2); } process.stdout.write(JSON.stringify(c)); })`,
|
|
82
|
-
` .catch(() => process.exit(3));`,
|
|
83
|
-
].join("\n");
|
|
84
|
-
const res = spawnSync("npx", ["--no-install", "tsx", "-e", code], {
|
|
85
|
-
cwd: repoRoot,
|
|
86
|
-
encoding: "utf8",
|
|
87
|
-
});
|
|
88
|
-
if (res.status === 0 && res.stdout) {
|
|
89
|
-
try {
|
|
90
|
-
return JSON.parse(res.stdout);
|
|
91
|
-
} catch {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
19
|
+
//
|
|
20
|
+
// Module split (kept ≤200 LOC each):
|
|
21
|
+
// - compose-solver-loader.mjs — contract discovery + tsx-eval
|
|
22
|
+
// - compose-solver-resolve.mjs — desired-set + BFS dep resolution
|
|
23
|
+
// - compose-solver-conflicts.mjs — surface checks + pair arbitration
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
buildInitialCandidates,
|
|
27
|
+
resolveDependencies,
|
|
28
|
+
reportUnresolvedDeps,
|
|
29
|
+
computeDependersCount,
|
|
30
|
+
} from "./compose-solver-resolve.mjs";
|
|
31
|
+
import {
|
|
32
|
+
runSurfaceChecks,
|
|
33
|
+
buildProvidesLookup,
|
|
34
|
+
makeArbitrator,
|
|
35
|
+
detectPairwiseCollisions,
|
|
36
|
+
detectExplicitConflicts,
|
|
37
|
+
} from "./compose-solver-conflicts.mjs";
|
|
38
|
+
|
|
39
|
+
export { loadAllContracts } from "./compose-solver-loader.mjs";
|
|
97
40
|
|
|
98
41
|
// ---------------------------------------------------------------------------
|
|
99
42
|
// Public — compose
|
|
@@ -129,6 +72,8 @@ export function compose(req, contracts) {
|
|
|
129
72
|
const notes = new Map();
|
|
130
73
|
/** @type {Set<string>} */
|
|
131
74
|
const uncontractedDesired = new Set();
|
|
75
|
+
/** @type {Set<string>} */
|
|
76
|
+
const userTyped = new Set(desired);
|
|
132
77
|
|
|
133
78
|
// Helper: record a conflict and (if blocker) attribute it to a slug.
|
|
134
79
|
function record(conflict, attributeTo = conflict.slug) {
|
|
@@ -141,310 +86,31 @@ export function compose(req, contracts) {
|
|
|
141
86
|
}
|
|
142
87
|
|
|
143
88
|
// ── Step 1: validate desired entries, then BFS-resolve transitive deps. ──
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
/** @type {Set<string>} */
|
|
148
|
-
const userTyped = new Set(desired);
|
|
149
|
-
|
|
150
|
-
// First, fold in each desired slug. Unknown contracts are blocker-rejected
|
|
151
|
-
// (strict mode) or warning-accepted (default, `allowUnknownSlices: true`).
|
|
152
|
-
for (const slug of desired) {
|
|
153
|
-
if (candidateSet.has(slug)) continue;
|
|
154
|
-
const contract = contracts.get(slug);
|
|
155
|
-
if (!contract) {
|
|
156
|
-
if (allowUnknownSlices) {
|
|
157
|
-
record(
|
|
158
|
-
{
|
|
159
|
-
type: "uncontracted",
|
|
160
|
-
slug,
|
|
161
|
-
detail: `Slice "${slug}" has no registered slice.contract.ts — accepted under allowUnknownSlices, but conflict checks are skipped for it.`,
|
|
162
|
-
severity: "warning",
|
|
163
|
-
},
|
|
164
|
-
slug,
|
|
165
|
-
);
|
|
166
|
-
uncontractedDesired.add(slug);
|
|
167
|
-
candidateSet.add(slug);
|
|
168
|
-
candidateOrder.push(slug);
|
|
169
|
-
notes.set(slug, "uncontracted");
|
|
170
|
-
proof.push(`! ${slug}: accepted as uncontracted (no slice.contract.ts; skipping conflict checks)`);
|
|
171
|
-
} else {
|
|
172
|
-
record(
|
|
173
|
-
{
|
|
174
|
-
type: "missing-dep",
|
|
175
|
-
slug,
|
|
176
|
-
detail: `Contract not found for "${slug}" — no slice.contract.ts registered (strict mode).`,
|
|
177
|
-
severity: "blocker",
|
|
178
|
-
},
|
|
179
|
-
slug,
|
|
180
|
-
);
|
|
181
|
-
proof.push(`- ${slug}: rejected (no contract found, strict mode)`);
|
|
182
|
-
}
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
candidateSet.add(slug);
|
|
186
|
-
candidateOrder.push(slug);
|
|
187
|
-
}
|
|
89
|
+
const { candidateOrder, candidateSet } = buildInitialCandidates({
|
|
90
|
+
desired, contracts, allowUnknownSlices, record, proof, notes, uncontractedDesired,
|
|
91
|
+
});
|
|
188
92
|
|
|
189
93
|
if (resolveDeps) {
|
|
190
|
-
|
|
191
|
-
// root so we can print the full cycle when one is encountered.
|
|
192
|
-
/** @type {Array<{ slug: string; chain: string[] }>} */
|
|
193
|
-
const queue = candidateOrder
|
|
194
|
-
.filter((s) => contracts.has(s))
|
|
195
|
-
.map((slug) => ({ slug, chain: [slug] }));
|
|
196
|
-
while (queue.length > 0) {
|
|
197
|
-
const { slug, chain } = queue.shift();
|
|
198
|
-
const contract = contracts.get(slug);
|
|
199
|
-
const deps = contract?.requires?.deps ?? [];
|
|
200
|
-
for (const dep of deps) {
|
|
201
|
-
if (chain.includes(dep)) {
|
|
202
|
-
const cyclePath = [...chain.slice(chain.indexOf(dep)), dep].join(" → ");
|
|
203
|
-
throw new Error(`dependency cycle detected: ${cyclePath}`);
|
|
204
|
-
}
|
|
205
|
-
if (installed.has(dep)) {
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (candidateSet.has(dep)) continue;
|
|
209
|
-
const depContract = contracts.get(dep);
|
|
210
|
-
if (!depContract) {
|
|
211
|
-
record(
|
|
212
|
-
{
|
|
213
|
-
type: "missing-dep",
|
|
214
|
-
slug,
|
|
215
|
-
withSlug: dep,
|
|
216
|
-
detail: `Slice "${slug}" requires "${dep}" but no contract is registered for it.`,
|
|
217
|
-
severity: "blocker",
|
|
218
|
-
},
|
|
219
|
-
slug,
|
|
220
|
-
);
|
|
221
|
-
proof.push(`- ${slug}: missing dep "${dep}" (not in candidates, not installed)`);
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
candidateSet.add(dep);
|
|
225
|
-
candidateOrder.push(dep);
|
|
226
|
-
proof.push(`+ ${dep}: pulled in as transitive dep of ${slug}`);
|
|
227
|
-
queue.push({ slug: dep, chain: [...chain, dep] });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
94
|
+
resolveDependencies({ candidateOrder, candidateSet, contracts, installed, record, proof });
|
|
230
95
|
} else {
|
|
231
|
-
|
|
232
|
-
const deps = contracts.get(slug)?.requires?.deps ?? [];
|
|
233
|
-
for (const dep of deps) {
|
|
234
|
-
if (installed.has(dep) || candidateSet.has(dep)) continue;
|
|
235
|
-
record(
|
|
236
|
-
{
|
|
237
|
-
type: "missing-dep",
|
|
238
|
-
slug,
|
|
239
|
-
withSlug: dep,
|
|
240
|
-
detail: `Slice "${slug}" requires "${dep}" (resolveDeps disabled — not auto-pulled).`,
|
|
241
|
-
severity: "blocker",
|
|
242
|
-
},
|
|
243
|
-
slug,
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
96
|
+
reportUnresolvedDeps({ candidateOrder, candidateSet, contracts, installed, record });
|
|
247
97
|
}
|
|
248
98
|
|
|
249
|
-
|
|
250
|
-
// candidates list it in their `requires.deps[]`. Used for arbitration
|
|
251
|
-
// ranking in Step 2.
|
|
252
|
-
/** @type {Map<string, number>} */
|
|
253
|
-
const dependersCount = new Map();
|
|
254
|
-
for (const slug of candidateOrder) dependersCount.set(slug, 0);
|
|
255
|
-
for (const slug of candidateOrder) {
|
|
256
|
-
const c = contracts.get(slug);
|
|
257
|
-
if (!c) continue;
|
|
258
|
-
for (const d of c.requires?.deps ?? []) {
|
|
259
|
-
if (dependersCount.has(d)) {
|
|
260
|
-
dependersCount.set(d, (dependersCount.get(d) ?? 0) + 1);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
99
|
+
const dependersCount = computeDependersCount(candidateOrder, contracts);
|
|
264
100
|
|
|
265
101
|
// ── Step 2: conflict checks. ────────────────────────────────────────────
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (!contract) continue;
|
|
270
|
-
|
|
271
|
-
const wantAuth = contract.requires?.auth;
|
|
272
|
-
if (wantAuth && state.auth && wantAuth !== state.auth && wantAuth !== "none") {
|
|
273
|
-
record(
|
|
274
|
-
{
|
|
275
|
-
type: "auth-mismatch",
|
|
276
|
-
slug,
|
|
277
|
-
detail: `Slice requires auth="${wantAuth}" but target rr.json has auth="${state.auth}".`,
|
|
278
|
-
severity: "blocker",
|
|
279
|
-
},
|
|
280
|
-
slug,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const provTables = contract.provides?.tables ?? [];
|
|
285
|
-
for (const t of provTables) {
|
|
286
|
-
if (tablesExisting.has(t)) {
|
|
287
|
-
record(
|
|
288
|
-
{
|
|
289
|
-
type: "table-collision",
|
|
290
|
-
slug,
|
|
291
|
-
detail: `Table "${t}" already exists in target Convex schema.`,
|
|
292
|
-
severity: "blocker",
|
|
293
|
-
},
|
|
294
|
-
slug,
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const reqEnv = contract.requires?.env ?? [];
|
|
300
|
-
for (const e of reqEnv) {
|
|
301
|
-
if (!envExisting.has(e)) {
|
|
302
|
-
record(
|
|
303
|
-
{
|
|
304
|
-
type: "env-missing",
|
|
305
|
-
slug,
|
|
306
|
-
detail: `Env var "${e}" required by ${slug} not present in target.`,
|
|
307
|
-
severity: "warning",
|
|
308
|
-
},
|
|
309
|
-
slug,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Build provides lookup once.
|
|
316
|
-
/** @type {Map<string, { tables: Set<string>; rbac: Set<string> }>} */
|
|
317
|
-
const lookup = new Map();
|
|
318
|
-
for (const slug of candidateOrder) {
|
|
319
|
-
if (uncontractedDesired.has(slug)) continue;
|
|
320
|
-
const c = contracts.get(slug);
|
|
321
|
-
if (!c) continue;
|
|
322
|
-
lookup.set(slug, {
|
|
323
|
-
tables: new Set(c.provides?.tables ?? []),
|
|
324
|
-
rbac: new Set(c.requires?.rbac ?? []),
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Pair-conflict arbitration helper.
|
|
330
|
-
*
|
|
331
|
-
* @param {string} a
|
|
332
|
-
* @param {string} b
|
|
333
|
-
* @param {import("./compose-solver").ConflictType} type
|
|
334
|
-
* @param {string} detailA
|
|
335
|
-
* @param {string} detailB
|
|
336
|
-
*/
|
|
337
|
-
function arbitratePair(a, b, type, detailA, detailB) {
|
|
338
|
-
const ca = /** @type {import("./compose-solver").Conflict} */ ({
|
|
339
|
-
type, slug: a, withSlug: b, detail: detailA, severity: "blocker",
|
|
340
|
-
});
|
|
341
|
-
const cb = /** @type {import("./compose-solver").Conflict} */ ({
|
|
342
|
-
type, slug: b, withSlug: a, detail: detailB, severity: "blocker",
|
|
343
|
-
});
|
|
344
|
-
const bothInstalled = installed.has(a) && installed.has(b);
|
|
345
|
-
if (bothInstalled) {
|
|
346
|
-
allConflicts.push({ ...ca, severity: "warning", type: "both-installed-conflict" });
|
|
347
|
-
allConflicts.push({ ...cb, severity: "warning", type: "both-installed-conflict" });
|
|
348
|
-
notes.set(a, notes.get(a) ?? "both-installed-conflict");
|
|
349
|
-
notes.set(b, notes.get(b) ?? "both-installed-conflict");
|
|
350
|
-
proof.push(`! ${a} ↔ ${b}: both already installed — conflict surfaced as warning, neither dropped`);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
if (installed.has(a) && !installed.has(b)) {
|
|
354
|
-
record(cb, b);
|
|
355
|
-
arbitrations.push({
|
|
356
|
-
conflict: cb, winner: a, loser: b,
|
|
357
|
-
reason: `"${a}" already installed — installed slice wins`,
|
|
358
|
-
});
|
|
359
|
-
proof.push(`- ${b}: arbitrated against ${a} (installed wins)`);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
if (installed.has(b) && !installed.has(a)) {
|
|
363
|
-
record(ca, a);
|
|
364
|
-
arbitrations.push({
|
|
365
|
-
conflict: ca, winner: b, loser: a,
|
|
366
|
-
reason: `"${b}" already installed — installed slice wins`,
|
|
367
|
-
});
|
|
368
|
-
proof.push(`- ${a}: arbitrated against ${b} (installed wins)`);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const depA = dependersCount.get(a) ?? 0;
|
|
372
|
-
const depB = dependersCount.get(b) ?? 0;
|
|
373
|
-
let winner, loser, conflictForLoser, reason;
|
|
374
|
-
if (depA !== depB) {
|
|
375
|
-
if (depA > depB) {
|
|
376
|
-
winner = a; loser = b; conflictForLoser = cb;
|
|
377
|
-
reason = `"${a}" has ${depA} dependers vs "${b}" with ${depB} — most-dependers wins`;
|
|
378
|
-
} else {
|
|
379
|
-
winner = b; loser = a; conflictForLoser = ca;
|
|
380
|
-
reason = `"${b}" has ${depB} dependers vs "${a}" with ${depA} — most-dependers wins`;
|
|
381
|
-
}
|
|
382
|
-
} else {
|
|
383
|
-
const later = a > b ? a : b;
|
|
384
|
-
const earlier = a > b ? b : a;
|
|
385
|
-
winner = earlier;
|
|
386
|
-
loser = later;
|
|
387
|
-
conflictForLoser = later === a ? ca : cb;
|
|
388
|
-
reason = `tie at ${depA} dependers — alphabetical tiebreak drops "${later}"`;
|
|
389
|
-
}
|
|
390
|
-
record(conflictForLoser, loser);
|
|
391
|
-
arbitrations.push({ conflict: conflictForLoser, winner, loser, reason });
|
|
392
|
-
proof.push(`- ${loser}: arbitrated against ${winner} (${reason})`);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
for (let i = 0; i < candidateOrder.length; i++) {
|
|
396
|
-
for (let j = i + 1; j < candidateOrder.length; j++) {
|
|
397
|
-
const a = candidateOrder[i];
|
|
398
|
-
const b = candidateOrder[j];
|
|
399
|
-
const la = lookup.get(a);
|
|
400
|
-
const lb = lookup.get(b);
|
|
401
|
-
if (!la || !lb) continue;
|
|
402
|
-
|
|
403
|
-
const tableHits = [];
|
|
404
|
-
for (const t of la.tables) if (lb.tables.has(t)) tableHits.push(t);
|
|
405
|
-
if (tableHits.length > 0) {
|
|
406
|
-
const detail = `Slices "${a}" and "${b}" both declare table${tableHits.length > 1 ? "s" : ""} ${tableHits.map((t) => `"${t}"`).join(", ")}.`;
|
|
407
|
-
arbitratePair(a, b, "table-collision", detail, detail);
|
|
408
|
-
}
|
|
409
|
-
for (const p of la.rbac) {
|
|
410
|
-
if (lb.rbac.has(p)) {
|
|
411
|
-
allConflicts.push({
|
|
412
|
-
type: "rbac-collision",
|
|
413
|
-
slug: a,
|
|
414
|
-
withSlug: b,
|
|
415
|
-
detail: `Slices "${a}" and "${b}" both declare RBAC permission "${p}".`,
|
|
416
|
-
severity: "warning",
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
102
|
+
runSurfaceChecks({
|
|
103
|
+
candidateOrder, uncontractedDesired, contracts, state, tablesExisting, envExisting, record,
|
|
104
|
+
});
|
|
422
105
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const dot = cf.indexOf(".", colon);
|
|
432
|
-
if (colon < 0 || dot < 0) continue;
|
|
433
|
-
const otherSlug = cf.slice(0, colon);
|
|
434
|
-
const key = cf.slice(colon + 1, dot);
|
|
435
|
-
const value = cf.slice(dot + 1);
|
|
436
|
-
if (!candidateSet.has(otherSlug)) continue;
|
|
437
|
-
if (uncontractedDesired.has(otherSlug)) continue;
|
|
438
|
-
const other = contracts.get(otherSlug);
|
|
439
|
-
if (!other) continue;
|
|
440
|
-
const provided = other.provides?.[key];
|
|
441
|
-
if (Array.isArray(provided) && provided.includes(value)) {
|
|
442
|
-
const detailA = `Slice "${slug}" declares explicit conflict with "${otherSlug}" on ${key}.${value}.`;
|
|
443
|
-
const detailB = `Slice "${otherSlug}" is the target of "${slug}"'s explicit conflict on ${key}.${value}.`;
|
|
444
|
-
arbitratePair(slug, otherSlug, "explicit-conflict", detailA, detailB);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
}
|
|
106
|
+
const lookup = buildProvidesLookup(candidateOrder, uncontractedDesired, contracts);
|
|
107
|
+
const arbitratePair = makeArbitrator({
|
|
108
|
+
installed, dependersCount, allConflicts, arbitrations, notes, proof, record,
|
|
109
|
+
});
|
|
110
|
+
detectPairwiseCollisions({ candidateOrder, lookup, arbitratePair, allConflicts });
|
|
111
|
+
detectExplicitConflicts({
|
|
112
|
+
candidateOrder, candidateSet, uncontractedDesired, contracts, arbitratePair,
|
|
113
|
+
});
|
|
448
114
|
|
|
449
115
|
// ── Step 3: decide accepted / rejected. ─────────────────────────────────
|
|
450
116
|
/** @type {Set<string>} */
|