@cronus-ui/stack 0.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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/catalog.d.ts +39 -0
- package/dist/catalog.js +826 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +78 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.js +5 -0
- package/dist/engine.d.ts +48 -0
- package/dist/engine.js +528 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/kickoff.d.ts +48 -0
- package/dist/kickoff.js +437 -0
- package/dist/schema.d.ts +68 -0
- package/dist/schema.js +133 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.js +14 -0
- package/package.json +84 -0
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Catalog, Selection, StackConfig } from "./types.js";
|
|
2
|
+
export type StackFlagValue = string | string[] | boolean | undefined;
|
|
3
|
+
export type StackFlagValues = Record<string, StackFlagValue>;
|
|
4
|
+
export interface ParseStackFlagsOptions {
|
|
5
|
+
catalog?: Catalog;
|
|
6
|
+
baseSelection?: Selection;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parse CLI-style flag values into a partial Stack Builder selection.
|
|
10
|
+
*
|
|
11
|
+
* Values intentionally use the same short tokens emitted by `generateCommand`
|
|
12
|
+
* (`--web next`, `--db-setup neon`, `--mcp cronus-ui,github`, ...). The resolver
|
|
13
|
+
* still performs the final cascade, so invalid combinations auto-correct through
|
|
14
|
+
* the same rules the UI uses.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseStackFlags(values: StackFlagValues, options?: ParseStackFlagsOptions): Selection;
|
|
17
|
+
export declare function resolveStackFlags(values: StackFlagValues, options?: ParseStackFlagsOptions): StackConfig;
|
|
18
|
+
//# sourceMappingURL=cli.d.ts.map
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { catalog as defaultCatalog } from "./catalog.js";
|
|
2
|
+
import { defaultSelection, resolve } from "./engine.js";
|
|
3
|
+
import { CLI_FLAGS, flagValue } from "./kickoff.js";
|
|
4
|
+
function categoryById(catalog, categoryId) {
|
|
5
|
+
const category = catalog.find((candidate) => candidate.id === categoryId);
|
|
6
|
+
if (!category)
|
|
7
|
+
throw new Error(`Unknown stack category "${categoryId}".`);
|
|
8
|
+
return category;
|
|
9
|
+
}
|
|
10
|
+
function firstString(value) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.find((entry) => typeof entry === "string");
|
|
13
|
+
return typeof value === "string" ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
function tokens(raw) {
|
|
16
|
+
return raw
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((token) => token.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
function isNone(raw) {
|
|
22
|
+
return raw.trim().toLowerCase() === "none";
|
|
23
|
+
}
|
|
24
|
+
function optionIdForFlag(category, value, flag) {
|
|
25
|
+
const normalized = value.trim().toLowerCase();
|
|
26
|
+
const match = category.options.find((option) => option.id === normalized || flagValue(option.id) === normalized);
|
|
27
|
+
if (!match) {
|
|
28
|
+
const allowed = category.options.map((option) => flagValue(option.id)).join(", ");
|
|
29
|
+
throw new Error(`Unknown --${flag} "${value}". Use one of: ${allowed}.`);
|
|
30
|
+
}
|
|
31
|
+
return match.id;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse CLI-style flag values into a partial Stack Builder selection.
|
|
35
|
+
*
|
|
36
|
+
* Values intentionally use the same short tokens emitted by `generateCommand`
|
|
37
|
+
* (`--web next`, `--db-setup neon`, `--mcp cronus-ui,github`, ...). The resolver
|
|
38
|
+
* still performs the final cascade, so invalid combinations auto-correct through
|
|
39
|
+
* the same rules the UI uses.
|
|
40
|
+
*/
|
|
41
|
+
export function parseStackFlags(values, options = {}) {
|
|
42
|
+
const catalog = options.catalog ?? defaultCatalog;
|
|
43
|
+
const selection = { ...(options.baseSelection ?? {}) };
|
|
44
|
+
for (const { catId, flag, kind } of CLI_FLAGS) {
|
|
45
|
+
const raw = firstString(values[flag]);
|
|
46
|
+
if (!raw)
|
|
47
|
+
continue;
|
|
48
|
+
const category = categoryById(catalog, catId);
|
|
49
|
+
if (kind === "single") {
|
|
50
|
+
selection[catId] = optionIdForFlag(category, raw, flag);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
if (isNone(raw)) {
|
|
54
|
+
selection[catId] = [];
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const ids = tokens(raw).map((value) => optionIdForFlag(category, value, flag));
|
|
58
|
+
selection[catId] = [...new Set(ids)];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (values.vibe === true)
|
|
62
|
+
selection.vibe = true;
|
|
63
|
+
if (values.git === true)
|
|
64
|
+
selection.git = true;
|
|
65
|
+
if (values["no-git"] === true)
|
|
66
|
+
selection.git = false;
|
|
67
|
+
if (values.install === true)
|
|
68
|
+
selection.install = true;
|
|
69
|
+
if (values["no-install"] === true)
|
|
70
|
+
selection.install = false;
|
|
71
|
+
return selection;
|
|
72
|
+
}
|
|
73
|
+
export function resolveStackFlags(values, options = {}) {
|
|
74
|
+
const catalog = options.catalog ?? defaultCatalog;
|
|
75
|
+
const base = options.baseSelection ?? defaultSelection(catalog);
|
|
76
|
+
return resolve(catalog, { ...base, ...parseStackFlags(values, { catalog }) }).selection;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const STACK_CREATE_ALIAS = "cronus-stack";
|
|
2
|
+
export declare const STACK_CREATE_PACKAGE = "create-cronus-stack";
|
|
3
|
+
export declare const STACK_CREATE_COMMAND = "bun create cronus-stack@latest";
|
|
4
|
+
export declare const STACK_BUILDER_GENERATOR = "cronus-stack-builder";
|
|
5
|
+
//# sourceMappingURL=constants.d.ts.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const STACK_CREATE_ALIAS = "cronus-stack";
|
|
2
|
+
export const STACK_CREATE_PACKAGE = "create-cronus-stack";
|
|
3
|
+
export const STACK_CREATE_COMMAND = `bun create ${STACK_CREATE_ALIAS}@latest`;
|
|
4
|
+
export const STACK_BUILDER_GENERATOR = "cronus-stack-builder";
|
|
5
|
+
//# sourceMappingURL=constants.js.map
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cronus Stack Builder resolver — PURE and DETERMINISTIC.
|
|
3
|
+
*
|
|
4
|
+
* Given a {@link Catalog} and a raw {@link Selection}, `resolve()` returns a
|
|
5
|
+
* fully resolved {@link Resolution}:
|
|
6
|
+
* 1. expands `implies` (auto-selects implied options);
|
|
7
|
+
* 2. marks every option `available` + a human `reason` when a `requires` is
|
|
8
|
+
* unsatisfied or it `conflicts` with something selected;
|
|
9
|
+
* 3. CASCADES — if a category's current value became unavailable, it falls
|
|
10
|
+
* back to that category's default valid option, iterating to a fixpoint.
|
|
11
|
+
*
|
|
12
|
+
* No I/O, no Date.now (except the seedable randomizer), no module state — the
|
|
13
|
+
* same inputs always produce the same output, so it's trivially unit-testable
|
|
14
|
+
* and safe to run on every keystroke in the UI.
|
|
15
|
+
*/
|
|
16
|
+
import type { Catalog, Resolution, Selection } from "./types.js";
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a raw selection into a fully consistent {@link Resolution}.
|
|
19
|
+
* Deterministic: same (catalog, selection, pinnedCatId) → same output.
|
|
20
|
+
*
|
|
21
|
+
* `pinnedCatId` (optional) marks the category the user just changed so its
|
|
22
|
+
* value wins every conflict during the cascade; omit it for a plain resolve
|
|
23
|
+
* where catalog order decides which side of a conflict gives way.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolve(catalog: Catalog, selection: Selection, pinnedCatId?: string): Resolution;
|
|
26
|
+
/**
|
|
27
|
+
* Set a "single" category's value (or toggle a boolean), then re-resolve so
|
|
28
|
+
* cascades/implies run. Returns the corrected, resolved selection.
|
|
29
|
+
*/
|
|
30
|
+
export declare function select(catalog: Catalog, selection: Selection, categoryId: string, optionId: string | boolean): Selection;
|
|
31
|
+
/**
|
|
32
|
+
* Toggle one option in a "multi" category on/off, then re-resolve.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toggleMulti(catalog: Catalog, selection: Selection, categoryId: string, optionId: string): Selection;
|
|
35
|
+
/**
|
|
36
|
+
* The canonical default selection: every single category at its first option,
|
|
37
|
+
* toggles per {@link TOGGLE_DEFAULTS_ON}, multis per {@link MULTI_DEFAULTS},
|
|
38
|
+
* with `ui` nudged to Cronus UI (the product default) when a React web frontend
|
|
39
|
+
* is present. Always valid.
|
|
40
|
+
*/
|
|
41
|
+
export declare function defaultSelection(catalog?: Catalog): Selection;
|
|
42
|
+
/**
|
|
43
|
+
* Produce a VALID random selection. Picks an available option for each single
|
|
44
|
+
* category and a random subset for each multi, re-resolving after every pick so
|
|
45
|
+
* later choices respect earlier ones. `seed` makes it deterministic for tests.
|
|
46
|
+
*/
|
|
47
|
+
export declare function randomize(catalog: Catalog, seed?: number): Selection;
|
|
48
|
+
//# sourceMappingURL=engine.d.ts.map
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cronus Stack Builder resolver — PURE and DETERMINISTIC.
|
|
3
|
+
*
|
|
4
|
+
* Given a {@link Catalog} and a raw {@link Selection}, `resolve()` returns a
|
|
5
|
+
* fully resolved {@link Resolution}:
|
|
6
|
+
* 1. expands `implies` (auto-selects implied options);
|
|
7
|
+
* 2. marks every option `available` + a human `reason` when a `requires` is
|
|
8
|
+
* unsatisfied or it `conflicts` with something selected;
|
|
9
|
+
* 3. CASCADES — if a category's current value became unavailable, it falls
|
|
10
|
+
* back to that category's default valid option, iterating to a fixpoint.
|
|
11
|
+
*
|
|
12
|
+
* No I/O, no Date.now (except the seedable randomizer), no module state — the
|
|
13
|
+
* same inputs always produce the same output, so it's trivially unit-testable
|
|
14
|
+
* and safe to run on every keystroke in the UI.
|
|
15
|
+
*/
|
|
16
|
+
import { catalog as defaultCatalog, MULTI_DEFAULTS, SYNTHETIC_REQUIREMENTS, TOGGLE_DEFAULTS_ON, } from "./catalog.js";
|
|
17
|
+
/** Max cascade iterations before we bail (defensive — fixpoint is reached fast). */
|
|
18
|
+
const CASCADE_CAP = 20;
|
|
19
|
+
function indexCatalog(catalog) {
|
|
20
|
+
const catById = new Map();
|
|
21
|
+
const catOfOption = new Map();
|
|
22
|
+
const optById = new Map();
|
|
23
|
+
const conflictedBy = new Map();
|
|
24
|
+
for (const cat of catalog) {
|
|
25
|
+
catById.set(cat.id, cat);
|
|
26
|
+
for (const opt of cat.options) {
|
|
27
|
+
catOfOption.set(opt.id, cat);
|
|
28
|
+
optById.set(opt.id, opt);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Second pass: now every option is known, build the symmetric reverse edges.
|
|
32
|
+
for (const cat of catalog) {
|
|
33
|
+
for (const opt of cat.options) {
|
|
34
|
+
for (const conf of opt.conflicts ?? []) {
|
|
35
|
+
let set = conflictedBy.get(conf);
|
|
36
|
+
if (!set) {
|
|
37
|
+
set = new Set();
|
|
38
|
+
conflictedBy.set(conf, set);
|
|
39
|
+
}
|
|
40
|
+
set.add(opt.id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { catById, catOfOption, optById, conflictedBy };
|
|
45
|
+
}
|
|
46
|
+
// --------------------------------------------------------------------------
|
|
47
|
+
// Selection accessors (typed by category kind)
|
|
48
|
+
// --------------------------------------------------------------------------
|
|
49
|
+
function singleValue(selection, catId) {
|
|
50
|
+
const v = selection[catId];
|
|
51
|
+
return typeof v === "string" ? v : undefined;
|
|
52
|
+
}
|
|
53
|
+
function multiValue(selection, catId) {
|
|
54
|
+
const v = selection[catId];
|
|
55
|
+
return Array.isArray(v) ? v : [];
|
|
56
|
+
}
|
|
57
|
+
/** The default option id for a "single" category (its first option). */
|
|
58
|
+
function defaultSingle(cat) {
|
|
59
|
+
return cat.options[0]?.id;
|
|
60
|
+
}
|
|
61
|
+
// --------------------------------------------------------------------------
|
|
62
|
+
// Selected-id set (drives requires/conflicts evaluation)
|
|
63
|
+
// --------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Build the set of currently-selected option ids across all categories, plus
|
|
66
|
+
* the SYNTHETIC group ids ("web-react", "db-sql", ...) that are satisfied.
|
|
67
|
+
*/
|
|
68
|
+
function buildSelectedSet(catalog, selection) {
|
|
69
|
+
const selected = new Set();
|
|
70
|
+
for (const cat of catalog) {
|
|
71
|
+
if (cat.kind === "single") {
|
|
72
|
+
const v = singleValue(selection, cat.id);
|
|
73
|
+
if (v)
|
|
74
|
+
selected.add(v);
|
|
75
|
+
}
|
|
76
|
+
else if (cat.kind === "multi") {
|
|
77
|
+
for (const id of multiValue(selection, cat.id))
|
|
78
|
+
selected.add(id);
|
|
79
|
+
}
|
|
80
|
+
// toggles never contribute option ids to constraints.
|
|
81
|
+
}
|
|
82
|
+
// Expand synthetic group satisfaction.
|
|
83
|
+
for (const [synthetic, members] of Object.entries(SYNTHETIC_REQUIREMENTS)) {
|
|
84
|
+
if (members.some((m) => selected.has(m)))
|
|
85
|
+
selected.add(synthetic);
|
|
86
|
+
}
|
|
87
|
+
return selected;
|
|
88
|
+
}
|
|
89
|
+
// --------------------------------------------------------------------------
|
|
90
|
+
// Availability of a single option given the selected set
|
|
91
|
+
// --------------------------------------------------------------------------
|
|
92
|
+
/** A friendlier label for a (possibly synthetic) requirement id. */
|
|
93
|
+
function requirementLabel(reqId, index) {
|
|
94
|
+
switch (reqId) {
|
|
95
|
+
case "web-react":
|
|
96
|
+
return "a React web frontend";
|
|
97
|
+
case "db-sql":
|
|
98
|
+
return "a SQL database";
|
|
99
|
+
case "db-sql-server":
|
|
100
|
+
return "PostgreSQL or MySQL";
|
|
101
|
+
default:
|
|
102
|
+
return index.optById.get(reqId)?.name ?? reqId;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Curated, human reasons for the most common unavailability cases. Falls back
|
|
107
|
+
* to a generic message built from the requirement/conflict label.
|
|
108
|
+
*/
|
|
109
|
+
function reasonFor(opt, unmetRequire, conflictId, index) {
|
|
110
|
+
// Hand-tuned short reasons for the headline constraints.
|
|
111
|
+
switch (opt.id) {
|
|
112
|
+
case "orm-drizzle":
|
|
113
|
+
case "orm-prisma":
|
|
114
|
+
if (conflictId === "db-mongodb")
|
|
115
|
+
return `${opt.name} is SQL-only — not for MongoDB`;
|
|
116
|
+
if (unmetRequire === "db-sql")
|
|
117
|
+
return "Requires a SQL database";
|
|
118
|
+
break;
|
|
119
|
+
case "orm-mongoose":
|
|
120
|
+
return "Mongoose only works with MongoDB";
|
|
121
|
+
case "ui-cronus":
|
|
122
|
+
case "ui-shadcn":
|
|
123
|
+
case "ui-heroui":
|
|
124
|
+
case "ui-aceternity":
|
|
125
|
+
return "Needs a React web frontend";
|
|
126
|
+
case "backend-elysia":
|
|
127
|
+
return "Elysia requires the Bun runtime";
|
|
128
|
+
case "backend-express":
|
|
129
|
+
if (conflictId === "runtime-cloudflare")
|
|
130
|
+
return "Express can't run on Cloudflare Workers";
|
|
131
|
+
return "Express requires the Node.js runtime";
|
|
132
|
+
case "backend-fullstack-next":
|
|
133
|
+
if (conflictId)
|
|
134
|
+
return "Fullstack Next replaces a dedicated backend";
|
|
135
|
+
return "Requires the Next.js web frontend";
|
|
136
|
+
case "backend-fullstack-tanstack":
|
|
137
|
+
if (conflictId)
|
|
138
|
+
return "Fullstack TanStack replaces a dedicated backend";
|
|
139
|
+
return "Requires the TanStack Start web frontend";
|
|
140
|
+
case "deploy-cloudflare":
|
|
141
|
+
return "Requires the Cloudflare Workers runtime";
|
|
142
|
+
default:
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
// Convex conflicts surface from the OTHER side (db/orm options conflicting).
|
|
146
|
+
if (conflictId === "backend-convex")
|
|
147
|
+
return "Convex is an all-in-one backend";
|
|
148
|
+
// Fullstack meta-frameworks reverse-conflict with dedicated backends.
|
|
149
|
+
if (conflictId === "backend-fullstack-next" || conflictId === "backend-fullstack-tanstack") {
|
|
150
|
+
return "A fullstack backend is selected";
|
|
151
|
+
}
|
|
152
|
+
if (conflictId)
|
|
153
|
+
return `Conflicts with ${requirementLabel(conflictId, index)}`;
|
|
154
|
+
if (unmetRequire)
|
|
155
|
+
return `Requires ${requirementLabel(unmetRequire, index)}`;
|
|
156
|
+
return "Unavailable in this stack";
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Evaluate availability of one option against the currently-selected set.
|
|
160
|
+
* Returns `{ available, reason }`.
|
|
161
|
+
*/
|
|
162
|
+
function evaluateOption(opt, selected, index) {
|
|
163
|
+
// conflicts (checked first — usually the most specific reason): if any id
|
|
164
|
+
// this option declares a conflict with is selected, it's unavailable.
|
|
165
|
+
for (const conf of opt.conflicts ?? []) {
|
|
166
|
+
if (selected.has(conf)) {
|
|
167
|
+
return { available: false, reason: reasonFor(opt, null, conf, index) };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Symmetric reverse conflicts: something selected declared a conflict with us.
|
|
171
|
+
const reverse = index.conflictedBy.get(opt.id);
|
|
172
|
+
if (reverse) {
|
|
173
|
+
for (const other of reverse) {
|
|
174
|
+
if (selected.has(other)) {
|
|
175
|
+
return { available: false, reason: reasonFor(opt, null, other, index) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// requires: every listed id (or synthetic group) must be satisfied.
|
|
180
|
+
for (const req of opt.requires ?? []) {
|
|
181
|
+
if (!selected.has(req)) {
|
|
182
|
+
return { available: false, reason: reasonFor(opt, req, null, index) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { available: true };
|
|
186
|
+
}
|
|
187
|
+
// --------------------------------------------------------------------------
|
|
188
|
+
// implies expansion
|
|
189
|
+
// --------------------------------------------------------------------------
|
|
190
|
+
/**
|
|
191
|
+
* Apply `implies` once: for every selected option, auto-select its implied
|
|
192
|
+
* options (single categories get their value set, multi categories get the id
|
|
193
|
+
* appended). Returns a NEW selection; idempotent at fixpoint.
|
|
194
|
+
*/
|
|
195
|
+
function applyImplies(catalog, selection, index) {
|
|
196
|
+
const next = { ...selection };
|
|
197
|
+
const selected = buildSelectedSet(catalog, next);
|
|
198
|
+
for (const id of selected) {
|
|
199
|
+
const opt = index.optById.get(id);
|
|
200
|
+
if (!opt?.implies)
|
|
201
|
+
continue;
|
|
202
|
+
for (const impliedId of opt.implies) {
|
|
203
|
+
const owner = index.catOfOption.get(impliedId);
|
|
204
|
+
if (!owner)
|
|
205
|
+
continue;
|
|
206
|
+
if (owner.kind === "single") {
|
|
207
|
+
next[owner.id] = impliedId;
|
|
208
|
+
}
|
|
209
|
+
else if (owner.kind === "multi") {
|
|
210
|
+
const arr = multiValue(next, owner.id);
|
|
211
|
+
if (!arr.includes(impliedId))
|
|
212
|
+
next[owner.id] = [...arr, impliedId];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
// --------------------------------------------------------------------------
|
|
219
|
+
// Normalization — make sure every category has a well-typed value
|
|
220
|
+
// --------------------------------------------------------------------------
|
|
221
|
+
function normalize(catalog, selection) {
|
|
222
|
+
const next = { ...selection };
|
|
223
|
+
for (const cat of catalog) {
|
|
224
|
+
const raw = next[cat.id];
|
|
225
|
+
if (cat.kind === "single") {
|
|
226
|
+
if (typeof raw !== "string" || !cat.options.some((o) => o.id === raw)) {
|
|
227
|
+
const def = defaultSingle(cat);
|
|
228
|
+
if (def !== undefined)
|
|
229
|
+
next[cat.id] = def;
|
|
230
|
+
else
|
|
231
|
+
delete next[cat.id];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (cat.kind === "multi") {
|
|
235
|
+
const valid = new Set(cat.options.map((o) => o.id));
|
|
236
|
+
const arr = Array.isArray(raw)
|
|
237
|
+
? raw.filter((x) => typeof x === "string" && valid.has(x))
|
|
238
|
+
: (MULTI_DEFAULTS[cat.id] ?? []);
|
|
239
|
+
next[cat.id] = arr;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// toggle
|
|
243
|
+
if (typeof raw !== "boolean")
|
|
244
|
+
next[cat.id] = TOGGLE_DEFAULTS_ON.has(cat.id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return next;
|
|
248
|
+
}
|
|
249
|
+
// --------------------------------------------------------------------------
|
|
250
|
+
// Conflict priority — decides which side of a symmetric conflict gives way
|
|
251
|
+
// --------------------------------------------------------------------------
|
|
252
|
+
/**
|
|
253
|
+
* Category priority: lower number = higher priority (gets to keep its value).
|
|
254
|
+
* The just-`select()`ed category is pinned to the very top (-1) so the user's
|
|
255
|
+
* explicit choice always wins; everything else uses catalog order, so a
|
|
256
|
+
* higher-up category (e.g. Backend=Convex) forces lower ones (Database/ORM) to
|
|
257
|
+
* give way rather than the reverse.
|
|
258
|
+
*/
|
|
259
|
+
function buildPriority(catalog, pinnedCatId) {
|
|
260
|
+
const prio = new Map();
|
|
261
|
+
for (let i = 0; i < catalog.length; i++) {
|
|
262
|
+
const cat = catalog[i];
|
|
263
|
+
if (cat)
|
|
264
|
+
prio.set(cat.id, cat.id === pinnedCatId ? -1 : i);
|
|
265
|
+
}
|
|
266
|
+
return prio;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether a SELECTED single option must give way in this cascade pass. It gives
|
|
270
|
+
* way if (a) a hard `requires` is unsatisfied, or (b) it conflicts with another
|
|
271
|
+
* selected option owned by a strictly HIGHER-priority category. A conflict with
|
|
272
|
+
* a lower-priority category does NOT force it out — that other category yields.
|
|
273
|
+
*/
|
|
274
|
+
function mustGiveWay(opt, ownerCatId, selected, index, prio) {
|
|
275
|
+
const probe = removeSelf(selected, opt.id);
|
|
276
|
+
// (a) requires — always binding.
|
|
277
|
+
for (const req of opt.requires ?? []) {
|
|
278
|
+
if (!probe.has(req))
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
// (b) conflicts — binding only against higher-priority selected options.
|
|
282
|
+
const myPrio = prio.get(ownerCatId) ?? Number.MAX_SAFE_INTEGER;
|
|
283
|
+
const partners = new Set(opt.conflicts ?? []);
|
|
284
|
+
for (const p of index.conflictedBy.get(opt.id) ?? [])
|
|
285
|
+
partners.add(p);
|
|
286
|
+
for (const partner of partners) {
|
|
287
|
+
if (!probe.has(partner))
|
|
288
|
+
continue;
|
|
289
|
+
const partnerCat = index.catOfOption.get(partner);
|
|
290
|
+
const partnerPrio = partnerCat
|
|
291
|
+
? (prio.get(partnerCat.id) ?? Number.MAX_SAFE_INTEGER)
|
|
292
|
+
: Number.MAX_SAFE_INTEGER;
|
|
293
|
+
// Strictly higher priority (smaller number) wins; on a tie, keep ours.
|
|
294
|
+
if (partnerPrio < myPrio)
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
// --------------------------------------------------------------------------
|
|
300
|
+
// One cascade pass — drop unavailable values, fall back to defaults
|
|
301
|
+
// --------------------------------------------------------------------------
|
|
302
|
+
/** Returns the corrected selection and whether anything changed. */
|
|
303
|
+
function cascadePass(catalog, selection, index, prio) {
|
|
304
|
+
const next = { ...selection };
|
|
305
|
+
let changed = false;
|
|
306
|
+
const selected = buildSelectedSet(catalog, next);
|
|
307
|
+
for (const cat of catalog) {
|
|
308
|
+
if (cat.kind === "single") {
|
|
309
|
+
const current = singleValue(next, cat.id);
|
|
310
|
+
const opt = current ? index.optById.get(current) : undefined;
|
|
311
|
+
if (!opt)
|
|
312
|
+
continue;
|
|
313
|
+
if (mustGiveWay(opt, cat.id, selected, index, prio)) {
|
|
314
|
+
// Fall back to the first AVAILABLE option (default-first ordering).
|
|
315
|
+
const fallback = cat.options.find((o) => evaluateOption(o, removeSelf(selected, current ?? ""), index).available);
|
|
316
|
+
const target = fallback?.id ?? defaultSingle(cat);
|
|
317
|
+
if (target && target !== current) {
|
|
318
|
+
next[cat.id] = target;
|
|
319
|
+
changed = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else if (cat.kind === "multi") {
|
|
324
|
+
const arr = multiValue(next, cat.id);
|
|
325
|
+
const kept = arr.filter((id) => {
|
|
326
|
+
const o = index.optById.get(id);
|
|
327
|
+
if (!o)
|
|
328
|
+
return false;
|
|
329
|
+
// Multi options are independent — drop any that aren't available, but a
|
|
330
|
+
// multi never out-prioritizes a single, so plain availability is right.
|
|
331
|
+
return !mustGiveWay(o, cat.id, selected, index, prio);
|
|
332
|
+
});
|
|
333
|
+
if (kept.length !== arr.length) {
|
|
334
|
+
next[cat.id] = kept;
|
|
335
|
+
changed = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { next, changed };
|
|
340
|
+
}
|
|
341
|
+
/** A copy of `selected` without `self` so an option doesn't conflict with itself. */
|
|
342
|
+
function removeSelf(selected, self) {
|
|
343
|
+
if (!selected.has(self))
|
|
344
|
+
return selected;
|
|
345
|
+
const copy = new Set(selected);
|
|
346
|
+
copy.delete(self);
|
|
347
|
+
// Re-derive synthetics after removal so e.g. dropping the only React web
|
|
348
|
+
// frontend also clears "web-react".
|
|
349
|
+
return copy;
|
|
350
|
+
}
|
|
351
|
+
// --------------------------------------------------------------------------
|
|
352
|
+
// Public API
|
|
353
|
+
// --------------------------------------------------------------------------
|
|
354
|
+
/**
|
|
355
|
+
* Resolve a raw selection into a fully consistent {@link Resolution}.
|
|
356
|
+
* Deterministic: same (catalog, selection, pinnedCatId) → same output.
|
|
357
|
+
*
|
|
358
|
+
* `pinnedCatId` (optional) marks the category the user just changed so its
|
|
359
|
+
* value wins every conflict during the cascade; omit it for a plain resolve
|
|
360
|
+
* where catalog order decides which side of a conflict gives way.
|
|
361
|
+
*/
|
|
362
|
+
export function resolve(catalog, selection, pinnedCatId) {
|
|
363
|
+
const index = indexCatalog(catalog);
|
|
364
|
+
const prio = buildPriority(catalog, pinnedCatId);
|
|
365
|
+
// 1. Normalize + apply implies + cascade to a fixpoint.
|
|
366
|
+
let current = normalize(catalog, selection);
|
|
367
|
+
for (let i = 0; i < CASCADE_CAP; i++) {
|
|
368
|
+
const implied = normalize(catalog, applyImplies(catalog, current, index));
|
|
369
|
+
const { next, changed } = cascadePass(catalog, implied, index, prio);
|
|
370
|
+
if (!changed && shallowEqual(catalog, next, current)) {
|
|
371
|
+
current = next;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
current = next;
|
|
375
|
+
}
|
|
376
|
+
// 2. Build per-category resolved state against the final selected set.
|
|
377
|
+
const finalSelected = buildSelectedSet(catalog, current);
|
|
378
|
+
const categories = {};
|
|
379
|
+
const issues = [];
|
|
380
|
+
for (const cat of catalog) {
|
|
381
|
+
const value = current[cat.id];
|
|
382
|
+
const options = cat.options.map((opt) => {
|
|
383
|
+
const isSelected = cat.kind === "single"
|
|
384
|
+
? value === opt.id
|
|
385
|
+
: cat.kind === "multi"
|
|
386
|
+
? Array.isArray(value) && value.includes(opt.id)
|
|
387
|
+
: false;
|
|
388
|
+
// For availability, an already-selected option shouldn't conflict with itself.
|
|
389
|
+
const probe = isSelected ? removeSelf(finalSelected, opt.id) : finalSelected;
|
|
390
|
+
const { available, reason } = evaluateOption(opt, probe, index);
|
|
391
|
+
return { option: opt, available, reason, selected: isSelected };
|
|
392
|
+
});
|
|
393
|
+
categories[cat.id] = { category: cat, options, value };
|
|
394
|
+
// Validity: a selected single/multi option that is NOT available is an error.
|
|
395
|
+
for (const ro of options) {
|
|
396
|
+
if (ro.selected && !ro.available) {
|
|
397
|
+
issues.push({
|
|
398
|
+
categoryId: cat.id,
|
|
399
|
+
level: "error",
|
|
400
|
+
message: `${cat.title}: ${ro.option.name} — ${ro.reason ?? "unavailable"}`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Advisory recommends.
|
|
405
|
+
if (cat.kind === "single" || cat.kind === "multi") {
|
|
406
|
+
for (const ro of options) {
|
|
407
|
+
if (!ro.selected || !ro.option.recommends)
|
|
408
|
+
continue;
|
|
409
|
+
for (const rec of ro.option.recommends) {
|
|
410
|
+
if (!finalSelected.has(rec)) {
|
|
411
|
+
const recOpt = index.optById.get(rec);
|
|
412
|
+
issues.push({
|
|
413
|
+
categoryId: cat.id,
|
|
414
|
+
level: "info",
|
|
415
|
+
message: `${ro.option.name} works best with ${recOpt?.name ?? rec}`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const valid = issues.every((i) => i.level !== "error");
|
|
423
|
+
return { selection: current, categories, valid, issues };
|
|
424
|
+
}
|
|
425
|
+
/** Shallow per-category equality for fixpoint detection. */
|
|
426
|
+
function shallowEqual(catalog, a, b) {
|
|
427
|
+
for (const cat of catalog) {
|
|
428
|
+
const av = a[cat.id];
|
|
429
|
+
const bv = b[cat.id];
|
|
430
|
+
if (Array.isArray(av) && Array.isArray(bv)) {
|
|
431
|
+
if (av.length !== bv.length || av.some((x, i) => x !== bv[i]))
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
else if (av !== bv) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Set a "single" category's value (or toggle a boolean), then re-resolve so
|
|
442
|
+
* cascades/implies run. Returns the corrected, resolved selection.
|
|
443
|
+
*/
|
|
444
|
+
export function select(catalog, selection, categoryId, optionId) {
|
|
445
|
+
const next = { ...selection, [categoryId]: optionId };
|
|
446
|
+
// Pin the changed category so the user's explicit pick wins every conflict.
|
|
447
|
+
return resolve(catalog, next, categoryId).selection;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Toggle one option in a "multi" category on/off, then re-resolve.
|
|
451
|
+
*/
|
|
452
|
+
export function toggleMulti(catalog, selection, categoryId, optionId) {
|
|
453
|
+
const arr = multiValue(selection, categoryId);
|
|
454
|
+
const nextArr = arr.includes(optionId) ? arr.filter((x) => x !== optionId) : [...arr, optionId];
|
|
455
|
+
const next = { ...selection, [categoryId]: nextArr };
|
|
456
|
+
return resolve(catalog, next, categoryId).selection;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The canonical default selection: every single category at its first option,
|
|
460
|
+
* toggles per {@link TOGGLE_DEFAULTS_ON}, multis per {@link MULTI_DEFAULTS},
|
|
461
|
+
* with `ui` nudged to Cronus UI (the product default) when a React web frontend
|
|
462
|
+
* is present. Always valid.
|
|
463
|
+
*/
|
|
464
|
+
export function defaultSelection(catalog = defaultCatalog) {
|
|
465
|
+
const seed = {};
|
|
466
|
+
for (const cat of catalog) {
|
|
467
|
+
if (cat.kind === "single") {
|
|
468
|
+
const def = defaultSingle(cat);
|
|
469
|
+
if (def !== undefined)
|
|
470
|
+
seed[cat.id] = def;
|
|
471
|
+
}
|
|
472
|
+
else if (cat.kind === "multi") {
|
|
473
|
+
seed[cat.id] = MULTI_DEFAULTS[cat.id] ?? [];
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
seed[cat.id] = TOGGLE_DEFAULTS_ON.has(cat.id);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Product defaults: Next.js + Cronus UI.
|
|
480
|
+
seed.web = "web-next";
|
|
481
|
+
seed.ui = "ui-cronus";
|
|
482
|
+
return resolve(catalog, seed).selection;
|
|
483
|
+
}
|
|
484
|
+
// --------------------------------------------------------------------------
|
|
485
|
+
// Deterministic randomizer
|
|
486
|
+
// --------------------------------------------------------------------------
|
|
487
|
+
/** A tiny seedable PRNG (mulberry32) so `randomize(seed)` is reproducible. */
|
|
488
|
+
function mulberry32(seed) {
|
|
489
|
+
let a = seed >>> 0;
|
|
490
|
+
return () => {
|
|
491
|
+
a |= 0;
|
|
492
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
493
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
494
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
495
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Produce a VALID random selection. Picks an available option for each single
|
|
500
|
+
* category and a random subset for each multi, re-resolving after every pick so
|
|
501
|
+
* later choices respect earlier ones. `seed` makes it deterministic for tests.
|
|
502
|
+
*/
|
|
503
|
+
export function randomize(catalog, seed = 1) {
|
|
504
|
+
const rand = mulberry32(seed);
|
|
505
|
+
const index = indexCatalog(catalog);
|
|
506
|
+
let selection = defaultSelection(catalog);
|
|
507
|
+
for (const cat of catalog) {
|
|
508
|
+
if (cat.kind === "single") {
|
|
509
|
+
const selected = removeSelf(buildSelectedSet(catalog, selection), singleValue(selection, cat.id) ?? "");
|
|
510
|
+
const avail = cat.options.filter((o) => evaluateOption(o, selected, index).available);
|
|
511
|
+
if (avail.length > 0) {
|
|
512
|
+
const pick = avail[Math.floor(rand() * avail.length)];
|
|
513
|
+
if (pick)
|
|
514
|
+
selection = select(catalog, selection, cat.id, pick.id);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else if (cat.kind === "multi") {
|
|
518
|
+
const chosen = cat.options.filter(() => rand() > 0.5).map((o) => o.id);
|
|
519
|
+
selection = resolve(catalog, { ...selection, [cat.id]: chosen }).selection;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
selection = { ...selection, [cat.id]: rand() > 0.5 };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// Final resolve to guarantee validity.
|
|
526
|
+
return resolve(catalog, selection).selection;
|
|
527
|
+
}
|
|
528
|
+
//# sourceMappingURL=engine.js.map
|