@orkestrel/scaffold 0.0.100 → 0.0.102

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.
Files changed (60) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +36 -12
  39. package/dist/host/configs/helpers.ts +214 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/dotfiles/gitignore +1 -0
  42. package/dist/host/dotfiles/oxlintrc.json +10 -0
  43. package/dist/host/guides/scaffold.md +244 -170
  44. package/dist/host/manifest.json +37 -31
  45. package/dist/host/tests/config.test.ts +1305 -619
  46. package/dist/host/tests/policy.test.ts +19 -0
  47. package/dist/host/tests/setupPolicy.ts +1710 -995
  48. package/dist/src/core/index.cjs +1595 -1686
  49. package/dist/src/core/index.cjs.map +1 -1
  50. package/dist/src/core/index.d.cts +202 -18
  51. package/dist/src/core/index.d.ts +202 -18
  52. package/dist/src/core/index.js +1590 -1687
  53. package/dist/src/core/index.js.map +1 -1
  54. package/dist/src/server/index.cjs +448 -392
  55. package/dist/src/server/index.cjs.map +1 -1
  56. package/dist/src/server/index.d.cts +1182 -1052
  57. package/dist/src/server/index.d.ts +1182 -1052
  58. package/dist/src/server/index.js +447 -395
  59. package/dist/src/server/index.js.map +1 -1
  60. package/package.json +2 -2
package/dist/bin/main.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { align, renderTable, strip, stripControls, width } from "@orkestrel/console";
3
- import { attempt, isArray, isError, isInstance, isRecord, isString, parseJSON } from "@orkestrel/contract";
4
- import { AXES, BIN_ENTRY_PATH, CATALOG_AGENT_PATH, CONFORMANCE_TEST_PATH, Compiler, DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, FRAMEWORKS, GLOBAL_SETUP_PATH, GROUPS, GUIDES_TEST_PATH, HOST_PATHS, INTEGRATION_TEST_PATH, JOURNEY_CONFIG_PATH, MAX_MANIFEST_BYTES, MAX_PATH_LENGTH, RESERVED_SHEET_NAMES, SERVICE_SETUP_PATH, SHEET_ENTRY_NAME, SHOWCASE_CONFIG_PATH, SHOWCASE_DEV_DEPENDENCIES, SHOWCASE_PAGES_PATH, SKILLS_CONFIG_PATH, STYLES_ENTRY_PATH, ScaffoldError, THEMES_BARREL_PATH, THEMES_ENTRY_PATH, blueprintToConfigArtifacts, blueprintToDevDependencies, blueprintToFaces, blueprintToManifestOverrides, blueprintToProjects, blueprintToScripts, blueprintToSheets, blueprintToTestArtifacts, blueprintToWritableScripts, compareVersions, createBlueprint, extractRangeMajor, extractVersion, isFloorPath, isScaffoldError, isSheetName, manifestToDependencies, manifestToName, nameToGuide, parseExtension, replaceManifestOverrides, replaceManifestScripts, replacePlanRanges } from "../src/core/index.js";
3
+ import { attempt, isArray, isDefined, isError, isInstance, isRecord, isString, parseJSON } from "@orkestrel/contract";
4
+ import { AXES, BIN_ENTRY_PATH, CATALOG_AGENT_PATH, CONFORMANCE_TEST_PATH, Compiler, DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, FRAMEWORKS, GLOBAL_SETUP_PATH, GROUPS, GUIDES_TEST_PATH, HOST_PATHS, INTEGRATION_TEST_PATH, JOURNEY_CONFIG_PATH, MAX_MANIFEST_BYTES, MAX_PATH_LENGTH, RESERVED_SHEET_NAMES, SERVICE_SETUP_PATH, SHEET_ENTRY_NAME, SHOWCASE_CONFIG_PATH, SHOWCASE_DEV_DEPENDENCIES, SHOWCASE_PAGES_PATH, SKILLS_CONFIG_PATH, STYLES_ENTRY_PATH, ScaffoldError, THEMES_BARREL_PATH, THEMES_ENTRY_PATH, blueprintToConfigArtifacts, blueprintToDevDependencies, blueprintToFaces, blueprintToManifestOverrides, blueprintToProjects, blueprintToScripts, blueprintToSheets, blueprintToTestArtifacts, blueprintToWritableScripts, compareVersions, createBlueprint, extractRangeMajor, extractVersion, isFloorPath, isScaffoldError, isSheetName, iterateAnswers, manifestToDependencies, manifestToName, nameToGuide, parseExtension, replaceManifestOverrides, replaceManifestScripts, replacePlanRanges } from "../src/core/index.js";
5
5
  import { MAX_INVENTORY_PATHS, Materializer, Upstream, filesToHost, isExactCaseFile, isPhysicalDirectory, isWorktree, listFiles, readFileText, readHostFloor, readHostManifest, readSnapshot, resolveContainedPath } from "../src/server/index.js";
6
- import { createMarkdown, flattenText, isTableNode } from "@orkestrel/markdown";
7
6
  import { createSession } from "@orkestrel/process/server";
7
+ import { createMarkdown, flattenText, isTableNode } from "@orkestrel/markdown";
8
8
  import { readdirSync } from "node:fs";
9
9
  import { normalize } from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
  var package_default = {
12
12
  name: "@orkestrel/scaffold",
13
- version: "0.0.100",
13
+ version: "0.0.102",
14
14
  description: "Scaffold workspaces with commands: new, audit, repair, catalog, and overwrite.",
15
15
  keywords: [
16
16
  "audit",
@@ -114,7 +114,7 @@ var package_default = {
114
114
  "@orkestrel/guide": "^0.0.24",
115
115
  "@orkestrel/html": "^0.0.12",
116
116
  "@orkestrel/probe": "^0.0.23",
117
- "@orkestrel/test": "^0.0.25",
117
+ "@orkestrel/test": "^0.0.26",
118
118
  "@types/node": "^26.6.4",
119
119
  "@vitest/browser-playwright": "^4.1.11",
120
120
  "oxfmt": "^0.72.0",
@@ -643,34 +643,28 @@ function auditToExit(audit) {
643
643
  */
644
644
  function manifestToWritableDependencies(manifest, blueprint) {
645
645
  const declared = manifestToDependencies(manifest);
646
- const runtime = [...dependenciesToFleet(declared.runtime)];
647
- const development = [...dependenciesToFleet(declared.development)];
648
646
  const parsed = parseJSON(manifest);
649
647
  if (!isRecord(parsed)) return {
650
- runtime,
651
- development
648
+ runtime: dependenciesToFleet(declared.runtime),
649
+ development: dependenciesToFleet(declared.development)
652
650
  };
653
651
  const runtimeRecord = isRecord(parsed.dependencies) ? parsed.dependencies : {};
654
652
  const developmentRecord = isRecord(parsed.devDependencies) ? parsed.devDependencies : {};
655
- for (const name of Object.keys(blueprintToDevDependencies(blueprint)).sort()) {
656
- if (name.startsWith("@orkestrel/")) continue;
657
- const runtimeRange = runtimeRecord[name];
658
- if (isString(runtimeRange)) {
659
- runtime.push({
660
- name,
661
- range: runtimeRange
662
- });
663
- if (!(blueprint.showcase && blueprint.app.includes("browser") && Object.hasOwn(SHOWCASE_DEV_DEPENDENCIES, name))) continue;
664
- }
665
- const developmentRange = developmentRecord[name];
666
- if (isString(developmentRange)) development.push({
653
+ const rows = Object.keys(blueprintToDevDependencies(blueprint)).sort().filter((name) => !name.startsWith("@orkestrel/")).map((name) => ({
654
+ name,
655
+ runtimeRange: runtimeRecord[name],
656
+ developmentRange: developmentRecord[name],
657
+ dual: blueprint.showcase && blueprint.app.includes("browser") && Object.hasOwn(SHOWCASE_DEV_DEPENDENCIES, name)
658
+ }));
659
+ return {
660
+ runtime: [...dependenciesToFleet(declared.runtime), ...rows.flatMap(({ name, runtimeRange }) => isString(runtimeRange) ? [{
661
+ name,
662
+ range: runtimeRange
663
+ }] : [])],
664
+ development: [...dependenciesToFleet(declared.development), ...rows.flatMap(({ name, runtimeRange, developmentRange, dual }) => isString(runtimeRange) && !dual || !isString(developmentRange) ? [] : [{
667
665
  name,
668
666
  range: developmentRange
669
- });
670
- }
671
- return {
672
- runtime,
673
- development
667
+ }])]
674
668
  };
675
669
  }
676
670
  /**
@@ -787,12 +781,8 @@ function manifestToWritableScripts(manifest, blueprint) {
787
781
  const steps = declared.split("&&").map((step) => step.trim());
788
782
  const expected = planned.split("&&").map((step) => step.trim());
789
783
  if (steps.length >= expected.length) return writable;
790
- let cursor = 0;
791
- for (const step of steps) {
792
- while (cursor < expected.length && expected[cursor] !== step) cursor += 1;
793
- if (cursor === expected.length) return writable;
794
- cursor += 1;
795
- }
784
+ const resumed = expected.values();
785
+ if (!steps.every((step) => resumed.some((entry) => entry === step))) return writable;
796
786
  const supplied = new Set(writable.map(({ name }) => name));
797
787
  for (const step of expected) {
798
788
  if (steps.includes(step)) continue;
@@ -822,17 +812,15 @@ function dependenciesToFleet(dependencies) {
822
812
  * no concrete version.
823
813
  */
824
814
  function dependenciesToFloors(dependencies) {
825
- const releases = [];
826
- for (const dependency of dependencies) {
815
+ const floors = [...iterateAnswers(dependencies, (dependency) => {
827
816
  const version = dependency.range.startsWith("^") || dependency.range.startsWith("~") ? dependency.range.slice(1) : dependency.range;
828
- if (extractVersion(version) === void 0) return void 0;
829
- releases.push({
817
+ return extractVersion(version) === void 0 ? void 0 : {
830
818
  ...dependency,
831
819
  lookup: "found",
832
820
  latest: version
833
- });
834
- }
835
- return releases;
821
+ };
822
+ })];
823
+ return floors.every(isDefined) ? floors : void 0;
836
824
  }
837
825
  /**
838
826
  * Reads the exit code registry release evidence earns.
@@ -851,27 +839,24 @@ function releasesToExit(releases) {
851
839
  * @returns One non-blocking question for each stale floor and each crossed major.
852
840
  */
853
841
  function releasesToQuestions(releases, served = releases) {
854
- const questions = [];
855
- for (const release of releases) {
856
- if (release.name.startsWith("@orkestrel/")) continue;
842
+ return Iterator.from(releases).flatMap((release) => {
843
+ if (release.name.startsWith("@orkestrel/")) return [];
857
844
  const declared = extractRangeMajor(release.range);
858
845
  const current = release.lookup === "found" ? release.latest : void 0;
859
846
  const resolved = current === void 0 ? void 0 : extractVersion(current);
860
847
  const floor = extractVersion(release.range.startsWith("^") || release.range.startsWith("~") ? release.range.slice(1) : release.range);
861
- if (declared !== void 0 && current !== void 0 && resolved !== void 0 && floor !== void 0 && resolved[0] === declared && compareVersions(current, floor.join(".")) > 0) questions.push({
848
+ const alternate = served.find((candidate) => candidate.name === release.name);
849
+ const published = release.major ?? (alternate?.lookup === "found" ? extractVersion(alternate.latest)?.[0] : void 0);
850
+ return [...declared !== void 0 && current !== void 0 && resolved !== void 0 && floor !== void 0 && resolved[0] === declared && compareVersions(current, floor.join(".")) > 0 ? [{
862
851
  field: "dependencies",
863
852
  message: `${release.name} declares the floor ${release.range}, while the registry serves ${current} within major ${String(declared)}.`,
864
853
  blocking: false
865
- });
866
- const alternate = served.find((candidate) => candidate.name === release.name);
867
- const published = release.major ?? (alternate?.lookup === "found" ? extractVersion(alternate.latest)?.[0] : void 0);
868
- if (declared !== void 0 && published !== void 0 && declared < published) questions.push({
854
+ }] : [], ...declared !== void 0 && published !== void 0 && declared < published ? [{
869
855
  field: "dependencies",
870
856
  message: `${release.name} declares major ${String(declared)}, while the registry serves major ${String(published)}.`,
871
857
  blocking: false
872
- });
873
- }
874
- return questions;
858
+ }] : []];
859
+ }).toArray();
875
860
  }
876
861
  /**
877
862
  * Projects an audit into the human summary of its outcome.
@@ -1122,17 +1107,16 @@ function releasesToPins(releases, declared) {
1122
1107
  const refused = releases.filter((release) => release.lookup !== "found");
1123
1108
  if (refused.length > 0) throw new ScaffoldError("FETCH", `The registry named no release for ${refused.map((release) => release.name).join(", ")}.`, { names: refused.length });
1124
1109
  const dependencies = [...declared.runtime, ...declared.development];
1125
- const pins = [];
1126
- for (let index = 0; index < releases.length; index += 1) {
1110
+ const pins = Array.from(releases.keys(), (index) => {
1127
1111
  const release = releases[index];
1128
1112
  const dependency = dependencies[index];
1129
1113
  if (release === void 0 || dependency === void 0) throw new ScaffoldError("FETCH", "The release answer has no matching declaration.");
1130
1114
  if (release.lookup !== "found") throw new ScaffoldError("FETCH", `The registry named no release for ${release.name}.`);
1131
- pins.push({
1115
+ return {
1132
1116
  name: dependency.name,
1133
1117
  range: `^${release.latest}`
1134
- });
1135
- }
1118
+ };
1119
+ });
1136
1120
  if (pins.length !== dependencies.length) throw new ScaffoldError("FETCH", "The release answer does not match the declaration set.");
1137
1121
  return {
1138
1122
  runtime: pins.slice(0, declared.runtime.length),
@@ -1140,6 +1124,60 @@ function releasesToPins(releases, declared) {
1140
1124
  };
1141
1125
  }
1142
1126
  /**
1127
+ * Scans a shell command for the words and operators it splits into.
1128
+ *
1129
+ * @param script - The shell command text to split.
1130
+ * @returns The words and the `&&`, `||`, `;`, `|`, `&`, `(`, and `)` operators in command order, or
1131
+ * `undefined` when a quote is never closed or the command ends in a backslash.
1132
+ *
1133
+ * @remarks
1134
+ * Quotes group a word without hiding it and a backslash outside single quotes escapes the next
1135
+ * character, so the word value carries neither. A `$`, a backtick, or a `%` that no backslash
1136
+ * escapes and no single quote encloses marks its word unresolved, because a shell expansion
1137
+ * decides what the word names. Operators are always resolved.
1138
+ *
1139
+ * @example
1140
+ * ```ts
1141
+ * import { scanScriptTokens } from './helpers.js'
1142
+ *
1143
+ * scanScriptTokens('vitest run --project "src:core"')?.map((token) => token.value)
1144
+ * // ['vitest', 'run', '--project', 'src:core']
1145
+ * ```
1146
+ */
1147
+ function scanScriptTokens(script) {
1148
+ const matches = Array.from(script.matchAll(/(?<space>\s+)|(?<operator>&&|\|\||[;&|()])|(?<word>(?:[^\s;&|()"'\\]|\\[\s\S]|"(?:\\[\s\S]|[^"\\])*"|'[^']*')+)|[\s\S]/g), ({ groups }) => groups ?? {});
1149
+ if (matches.some(({ space, operator, word }) => space === void 0 && operator === void 0 && word === void 0)) return;
1150
+ return matches.flatMap(({ space, operator, word }) => {
1151
+ if (space !== void 0) return [];
1152
+ if (operator !== void 0) return [{
1153
+ value: operator,
1154
+ resolved: true
1155
+ }];
1156
+ const parts = Array.from((word ?? "").matchAll(/\\([\s\S])|"((?:\\[\s\S]|[^"\\])*)"|'([^']*)'|([\s\S])/g), ([, escaped, double, single, plain]) => {
1157
+ if (escaped !== void 0) return {
1158
+ text: escaped,
1159
+ resolved: true
1160
+ };
1161
+ if (single !== void 0) return {
1162
+ text: single,
1163
+ resolved: true
1164
+ };
1165
+ if (double !== void 0) return {
1166
+ text: double.replaceAll(/\\([\s\S])/g, "$1"),
1167
+ resolved: Array.from(double.matchAll(/\\[\s\S]|[$`%]/g)).every((found) => found[0].startsWith("\\"))
1168
+ };
1169
+ return {
1170
+ text: plain ?? "",
1171
+ resolved: plain === void 0 || !"$`%".includes(plain)
1172
+ };
1173
+ });
1174
+ return [{
1175
+ value: parts.map((part) => part.text).join(""),
1176
+ resolved: parts.every((part) => part.resolved)
1177
+ }];
1178
+ });
1179
+ }
1180
+ /**
1143
1181
  * Reads the literal Vitest projects, configuration paths, and npm run scripts a shell command names.
1144
1182
  *
1145
1183
  * @param script - The manifest script text to read.
@@ -1163,95 +1201,21 @@ function releasesToPins(releases, declared) {
1163
1201
  * ```
1164
1202
  */
1165
1203
  function scriptToInvocations(script, wrappers = /* @__PURE__ */ new Map()) {
1166
- const tokens = [];
1167
- let value = "";
1168
- let resolved = true;
1169
- let started = false;
1170
- let quote;
1171
- for (let index = 0; index < script.length; index += 1) {
1172
- const character = script[index];
1173
- if (character === void 0) return void 0;
1174
- if (quote === void 0) {
1175
- if (/\s/.test(character)) {
1176
- if (started) tokens.push({
1177
- value,
1178
- resolved
1179
- });
1180
- value = "";
1181
- resolved = true;
1182
- started = false;
1183
- continue;
1184
- }
1185
- if (";&|()".includes(character)) {
1186
- if (started) tokens.push({
1187
- value,
1188
- resolved
1189
- });
1190
- const paired = script[index + 1] === character && (character === "&" || character === "|");
1191
- tokens.push({
1192
- value: paired ? `${character}${character}` : character,
1193
- resolved: true
1194
- });
1195
- value = "";
1196
- resolved = true;
1197
- started = false;
1198
- if (paired) index += 1;
1199
- continue;
1200
- }
1201
- if (character === "\"" || character === "'") {
1202
- quote = character;
1203
- started = true;
1204
- continue;
1205
- }
1206
- if (character === "\\") {
1207
- const escaped = script[index + 1];
1208
- if (escaped === void 0) return void 0;
1209
- value += escaped;
1210
- started = true;
1211
- index += 1;
1212
- continue;
1213
- }
1214
- if (character === "$" || character === "`" || character === "%") resolved = false;
1215
- value += character;
1216
- started = true;
1217
- continue;
1218
- }
1219
- if (character === quote) {
1220
- quote = void 0;
1221
- continue;
1222
- }
1223
- if (character === "\\" && quote === "\"") {
1224
- const escaped = script[index + 1];
1225
- if (escaped === void 0) return void 0;
1226
- value += escaped;
1227
- index += 1;
1228
- continue;
1229
- }
1230
- if (quote === "\"" && (character === "$" || character === "`" || character === "%")) resolved = false;
1231
- value += character;
1232
- started = true;
1233
- }
1234
- if (quote !== void 0) return void 0;
1235
- if (started) tokens.push({
1236
- value,
1237
- resolved
1238
- });
1239
- const projects = [];
1240
- const configs = [];
1241
- const scripts = [];
1204
+ const tokens = scanScriptTokens(script);
1205
+ if (tokens === void 0) return void 0;
1242
1206
  const configurations = new Map([...wrappers].map(([path, project]) => [normalize(path), project]));
1243
- let testing = false;
1244
- for (let index = 0; index < tokens.length; index += 1) {
1245
- const token = tokens[index];
1246
- if (token === void 0) return void 0;
1247
- if (index === 0 || [
1207
+ const steps = [];
1208
+ for (const [index, token] of tokens.entries()) {
1209
+ const previous = steps.at(-1);
1210
+ if (previous !== void 0 && index <= previous.through) continue;
1211
+ const running = index === 0 || [
1248
1212
  "&&",
1249
1213
  "||",
1250
1214
  ";",
1251
1215
  "|",
1252
1216
  "&",
1253
1217
  "("
1254
- ].includes(tokens[index - 1]?.value ?? "")) testing = token.resolved && token.value === "vitest" && tokens[index + 1]?.value === "run";
1218
+ ].includes(tokens[index - 1]?.value ?? "") ? token.resolved && token.value === "vitest" && tokens[index + 1]?.value === "run" : previous?.running ?? false;
1255
1219
  if (token.value === "npm" && tokens[index + 1]?.value === "run") {
1256
1220
  const name = tokens[index + 2];
1257
1221
  if (name === void 0 || !name.resolved || name.value.length === 0 || [
@@ -1263,8 +1227,13 @@ function scriptToInvocations(script, wrappers = /* @__PURE__ */ new Map()) {
1263
1227
  "(",
1264
1228
  ")"
1265
1229
  ].includes(name.value)) return void 0;
1266
- scripts.push(name.value);
1267
- index += 2;
1230
+ steps.push({
1231
+ projects: [],
1232
+ configs: [],
1233
+ scripts: [name.value],
1234
+ through: index + 2,
1235
+ running
1236
+ });
1268
1237
  continue;
1269
1238
  }
1270
1239
  if (token.value === "--project" || token.value === "--config" || token.value === "-c") {
@@ -1278,28 +1247,88 @@ function scriptToInvocations(script, wrappers = /* @__PURE__ */ new Map()) {
1278
1247
  "(",
1279
1248
  ")"
1280
1249
  ].includes(project.value)) return void 0;
1281
- const names = token.value === "--project" ? projects : configs;
1282
- names.push(project.value);
1283
- const face = testing && names === configs ? configurations.get(normalize(project.value)) : void 0;
1284
- if (face !== void 0) projects.push(face);
1285
- index += 1;
1250
+ const named = token.value === "--project";
1251
+ const face = running && !named ? configurations.get(normalize(project.value)) : void 0;
1252
+ steps.push({
1253
+ projects: [...named ? [project.value] : [], ...face === void 0 ? [] : [face]],
1254
+ configs: named ? [] : [project.value],
1255
+ scripts: [],
1256
+ through: index + 1,
1257
+ running
1258
+ });
1286
1259
  continue;
1287
1260
  }
1288
1261
  if (token.value.startsWith("--project=") || token.value.startsWith("--config=")) {
1289
1262
  const project = token.value.slice(token.value.indexOf("=") + 1);
1290
1263
  if (!token.resolved || project.length === 0) return void 0;
1291
- const names = token.value.startsWith("--project=") ? projects : configs;
1292
- names.push(project);
1293
- const face = testing && names === configs ? configurations.get(normalize(project)) : void 0;
1294
- if (face !== void 0) projects.push(face);
1264
+ const named = token.value.startsWith("--project=");
1265
+ const face = running && !named ? configurations.get(normalize(project)) : void 0;
1266
+ steps.push({
1267
+ projects: [...named ? [project] : [], ...face === void 0 ? [] : [face]],
1268
+ configs: named ? [] : [project],
1269
+ scripts: [],
1270
+ through: index,
1271
+ running
1272
+ });
1295
1273
  continue;
1296
1274
  }
1297
1275
  if (!token.resolved && /--project|--config/.test(token.value)) return void 0;
1276
+ steps.push({
1277
+ projects: [],
1278
+ configs: [],
1279
+ scripts: [],
1280
+ through: index,
1281
+ running
1282
+ });
1298
1283
  }
1299
1284
  return {
1300
- projects,
1301
- configs,
1302
- scripts
1285
+ projects: steps.flatMap(({ projects }) => projects),
1286
+ configs: steps.flatMap(({ configs }) => configs),
1287
+ scripts: steps.flatMap(({ scripts }) => scripts)
1288
+ };
1289
+ }
1290
+ /**
1291
+ * Collects the scripts, Vitest projects, and unreadable scripts one gate reaches through `npm run`.
1292
+ *
1293
+ * @param gate - The script name the walk starts at.
1294
+ * @param scripts - The manifest's scripts by name; a value that is not a string names no command.
1295
+ * @param wrappers - Planned wrapper paths mapped to the face projects they run.
1296
+ * @returns The script names the walk visited, the Vitest projects those scripts name, and the
1297
+ * visited scripts whose command cannot be read literally.
1298
+ *
1299
+ * @remarks
1300
+ * The walk is breadth first and reads each script once, so a cycle ends where it closes. A script
1301
+ * the manifest does not declare as a string is visited and names nothing. The projects and the
1302
+ * unreadable names derive from one log of the reads, never from sets the walk fills.
1303
+ *
1304
+ * @example
1305
+ * ```ts
1306
+ * import { collectScriptChain } from './helpers.js'
1307
+ *
1308
+ * collectScriptChain('test', { test: 'npm run test:src', 'test:src': 'vitest run --project src:core' }, new Map())
1309
+ * // { visited: Set { 'test', 'test:src' }, projects: Set { 'src:core' }, unreadable: Set {} }
1310
+ * ```
1311
+ */
1312
+ function collectScriptChain(gate, scripts, wrappers) {
1313
+ const queue = [gate];
1314
+ const visited = /* @__PURE__ */ new Set();
1315
+ const reads = [];
1316
+ for (const name of queue) {
1317
+ if (visited.has(name)) continue;
1318
+ visited.add(name);
1319
+ const script = scripts[name];
1320
+ if (!isString(script)) continue;
1321
+ const invoked = scriptToInvocations(script, wrappers);
1322
+ reads.push({
1323
+ name,
1324
+ invoked
1325
+ });
1326
+ for (const called of invoked?.scripts ?? []) if (!visited.has(called)) queue.push(called);
1327
+ }
1328
+ return {
1329
+ visited,
1330
+ projects: new Set(reads.flatMap(({ invoked }) => invoked?.projects ?? [])),
1331
+ unreadable: new Set(reads.flatMap(({ name, invoked }) => invoked === void 0 ? [name] : []))
1303
1332
  };
1304
1333
  }
1305
1334
  /**
@@ -1350,42 +1379,36 @@ function targetToFacts(target) {
1350
1379
  * @throws `ScaffoldError` when sheet markers name an invalid or case-colliding face, or the directory cannot be read.
1351
1380
  */
1352
1381
  function targetToExtensions(target) {
1353
- const extensions = [];
1354
- for (const name of FRAMEWORKS) {
1382
+ const browsers = FRAMEWORKS.flatMap((name) => {
1355
1383
  const axes = AXES.filter((axis) => {
1356
1384
  const path = resolveContainedPath(target, `${axis}/${name}`);
1357
1385
  return path !== void 0 && isPhysicalDirectory(path);
1358
1386
  });
1359
- if (axes.length > 0) extensions.push({
1387
+ return axes.length > 0 ? [{
1360
1388
  surface: "browser",
1361
1389
  name,
1362
1390
  axes
1363
- });
1364
- }
1391
+ }] : [];
1392
+ });
1365
1393
  const source = resolveContainedPath(target, "src");
1366
- if (source === void 0 || !isPhysicalDirectory(source)) return extensions;
1394
+ if (source === void 0 || !isPhysicalDirectory(source)) return browsers;
1367
1395
  const entries = readdirSync(source, { withFileTypes: true });
1368
1396
  if (entries.length > MAX_INVENTORY_PATHS) throw new ScaffoldError("TARGET", "The source directory exceeds the inventory limit.");
1369
- const names = [];
1370
- const seen = /* @__PURE__ */ new Set();
1371
- for (const entry of entries) {
1372
- if (!entry.isDirectory() || RESERVED_SHEET_NAMES.includes(entry.name)) continue;
1397
+ const faces = entries.filter((entry) => {
1398
+ if (!entry.isDirectory() || RESERVED_SHEET_NAMES.includes(entry.name)) return false;
1373
1399
  const sheet = resolveContainedPath(target, `src/${entry.name}/index.scss`);
1374
1400
  const marker = resolveContainedPath(target, `src/${entry.name}/${SHEET_ENTRY_NAME}`);
1375
- if (sheet === void 0 || marker === void 0 || !isExactCaseFile(sheet) || !isExactCaseFile(marker)) continue;
1376
- const folded = entry.name.toLowerCase();
1377
- if (seen.has(folded)) throw new ScaffoldError("TARGET", `Case-colliding sheet face '${entry.name}'.`);
1378
- seen.add(folded);
1379
- names.push(entry.name);
1380
- }
1381
- for (const name of names.sort()) {
1401
+ return sheet !== void 0 && marker !== void 0 && isExactCaseFile(sheet) && isExactCaseFile(marker);
1402
+ });
1403
+ const colliding = faces.find((face, index) => faces.findIndex((other) => other.name.toLowerCase() === face.name.toLowerCase()) < index);
1404
+ if (colliding !== void 0) throw new ScaffoldError("TARGET", `Case-colliding sheet face '${colliding.name}'.`);
1405
+ return [...browsers, ...faces.map((face) => face.name).sort().map((name) => {
1382
1406
  if (!isSheetName(name)) throw new ScaffoldError("TARGET", `Invalid sheet face '${name}'.`);
1383
- extensions.push({
1407
+ return {
1384
1408
  surface: "styles",
1385
1409
  name
1386
- });
1387
- }
1388
- return extensions;
1410
+ };
1411
+ })];
1389
1412
  }
1390
1413
  /**
1391
1414
  * Reads a creation extension list and assigns its selected browser axes.
@@ -1397,24 +1420,19 @@ function targetToExtensions(target) {
1397
1420
  */
1398
1421
  function selectionToExtensions(selection, axes, styles) {
1399
1422
  if (selection === void 0) return [];
1400
- const extensions = [];
1401
- const seen = /* @__PURE__ */ new Set();
1402
- for (const entry of selection.split(",")) {
1423
+ return selection.split(",").map((entry, index, entries) => {
1403
1424
  const extension = parseExtension(entry);
1404
- if (extension === void 0 || seen.has(entry)) throw new UsageError(`'--extend' refuses '${entry}': use distinct supported surface:name entries.`);
1405
- seen.add(entry);
1425
+ if (extension === void 0 || entries.indexOf(entry) < index) throw new UsageError(`'--extend' refuses '${entry}': use distinct supported surface:name entries.`);
1406
1426
  if (extension.surface === "browser") {
1407
1427
  if (axes.length === 0) throw new UsageError("A browser extension requires browser in --src or --app.");
1408
- extensions.push({
1428
+ return {
1409
1429
  ...extension,
1410
1430
  axes
1411
- });
1412
- } else {
1413
- if (!styles) throw new UsageError("A styles extension requires --styles.");
1414
- extensions.push(extension);
1431
+ };
1415
1432
  }
1416
- }
1417
- return extensions;
1433
+ if (!styles) throw new UsageError("A styles extension requires --styles.");
1434
+ return extension;
1435
+ });
1418
1436
  }
1419
1437
  /**
1420
1438
  * Reads the packages a target's catalog table listed before this run.
@@ -1438,118 +1456,10 @@ function catalogToNames(target) {
1438
1456
  const hex = isString(target) ? void 0 : target.bytes[CATALOG_AGENT_PATH];
1439
1457
  const text = isString(target) ? readFileText(target, CATALOG_AGENT_PATH) : hex === void 0 ? void 0 : Buffer.from(hex, "hex").toString("utf8");
1440
1458
  if (text === void 0) return [];
1441
- const names = [];
1442
- for (const table of createMarkdown(text).filter(isTableNode)) for (const row of table.rows) {
1443
- const [cell] = row;
1444
- if (cell === void 0) continue;
1445
- const name = cell.map(flattenText).join("").trim();
1446
- if (DEPENDENCY_NAME_PATTERN.test(name)) names.push(name);
1447
- }
1448
- return names;
1449
- }
1450
- /**
1451
- * Collects the complete NUL-delimited records from one raw git output chunk.
1452
- *
1453
- * @param records - The complete records retained in arrival order.
1454
- * @param pending - The mutable cell carrying the unfinished record between chunks.
1455
- * @param decoder - The streaming UTF-8 decoder shared by the query.
1456
- * @param controller - The query controller aborted on a bound refusal.
1457
- * @param target - The repository the query addresses.
1458
- * @param chunk - The next owned output bytes.
1459
- * @returns Nothing.
1460
- */
1461
- function collectGitRecords(records, pending, decoder, controller, target, chunk) {
1462
- if (controller.signal.aborted) return;
1463
- const parts = `${pending[0] ?? ""}${decoder.decode(chunk, { stream: true })}`.split("\0");
1464
- pending[0] = parts.pop() ?? "";
1465
- for (const record of parts) {
1466
- if (record.length === 0) continue;
1467
- if (records.length >= MAX_INVENTORY_PATHS) {
1468
- refuseGitRecords(controller, target, `The git query for target at ${target} exceeded the working-tree inventory limit.`);
1469
- return;
1470
- }
1471
- records.push(record);
1472
- }
1473
- if ((pending[0] ?? "").length > MAX_PATH_LENGTH + 3) refuseGitRecords(controller, target, `The git query for target at ${target} returned an unfinished record beyond the supported path limit.`);
1474
- }
1475
- /**
1476
- * Aborts a git query with its first target refusal.
1477
- *
1478
- * @param controller - The query controller whose reason retains the refusal.
1479
- * @param target - The repository the query addresses.
1480
- * @param message - The refusal message.
1481
- * @returns Nothing.
1482
- */
1483
- function refuseGitRecords(controller, target, message) {
1484
- if (!controller.signal.aborted) controller.abort(new ScaffoldError("TARGET", message, { target }));
1485
- }
1486
- /**
1487
- * Reads one git query as its NUL-separated records.
1488
- *
1489
- * @param target - The repository the query runs in.
1490
- * @param args - The git arguments, without the program name.
1491
- * @returns The non-empty records git wrote, in git's own order.
1492
- * @throws `ScaffoldError('TARGET', …)` when the query fails or its output is
1493
- * incomplete or outside the working-tree inventory bounds.
1494
- *
1495
- * @remarks
1496
- * Git is asked rather than reimplemented, because the tracked set and the dirty
1497
- * set are git's own answers and nothing else can give them. The raw session
1498
- * streams complete NUL-delimited records without retaining the whole query as a
1499
- * manifest-sized text buffer. Its initial hooks are installed before the eager
1500
- * spawn, so no output or child fault can precede its observer.
1501
- *
1502
- * @example
1503
- * ```ts
1504
- * import { readGitRecords } from './helpers.js'
1505
- *
1506
- * await readGitRecords('./packages/router', ['ls-files', '-z']) // ['package.json', 'src/core/index.ts']
1507
- * ```
1508
- */
1509
- async function readGitRecords(target, args) {
1510
- const records = [];
1511
- const pending = [""];
1512
- const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
1513
- const controller = new AbortController();
1514
- let session;
1515
- try {
1516
- session = createSession({
1517
- command: {
1518
- file: "git",
1519
- arguments: [
1520
- "-C",
1521
- target,
1522
- ...args
1523
- ]
1524
- },
1525
- workspace: ".",
1526
- signal: controller.signal,
1527
- on: {
1528
- stdout: collectGitRecords.bind(void 0, records, pending, decoder, controller, target),
1529
- error: refuseGitRecords.bind(void 0, controller, target, `The git query for target at ${target} failed before it completed.`)
1530
- },
1531
- error: refuseGitRecords.bind(void 0, controller, target, `The git query for target at ${target} failed while reading its output.`)
1532
- });
1533
- const exit = await session.exit;
1534
- pending[0] = `${pending[0] ?? ""}${decoder.decode()}`;
1535
- if (controller.signal.aborted) throw controller.signal.reason;
1536
- if (exit.signal !== null) throw new ScaffoldError("TARGET", `The git query for target at ${target} ended from signal ${exit.signal}.`, {
1537
- target,
1538
- signal: exit.signal
1539
- });
1540
- if (exit.code !== 0) throw new ScaffoldError("TARGET", `The git query for target at ${target} failed with exit code ${String(exit.code)}.`, {
1541
- target,
1542
- code: exit.code
1543
- });
1544
- if (!exit.drained) throw new ScaffoldError("TARGET", `The git query for target at ${target} ended before its output drained.`, { target });
1545
- if ((pending[0] ?? "").length > 0) throw new ScaffoldError("TARGET", `The git query for target at ${target} returned an incomplete record.`, { target });
1546
- return records;
1547
- } catch (error) {
1548
- if (isScaffoldError(error)) throw error;
1549
- throw new ScaffoldError("TARGET", `The git query for target at ${target} could not start.`, { target });
1550
- } finally {
1551
- if (session !== void 0) await session.destroy();
1552
- }
1459
+ return createMarkdown(text).filter(isTableNode).flatMap((table) => table.rows.flatMap(([cell]) => {
1460
+ const name = cell?.map(flattenText).join("").trim();
1461
+ return name !== void 0 && DEPENDENCY_NAME_PATTERN.test(name) ? [name] : [];
1462
+ }));
1553
1463
  }
1554
1464
  /**
1555
1465
  * Reads the environments a comma-separated selection names.
@@ -1665,6 +1575,158 @@ function mergeResults(first, second) {
1665
1575
  function resultToTally(result) {
1666
1576
  return `${String(result.written.length)} written, ${String(result.skipped.length)} unchanged, ${String(result.removed.length)} removed in ${result.target}.`;
1667
1577
  }
1578
+ /**
1579
+ * Splits decoded git output into its complete records and its unfinished tail.
1580
+ *
1581
+ * @param text - The decoded output, led by the tail the previous chunk left.
1582
+ * @returns The non-empty records ended by a NUL, and the text after the last NUL.
1583
+ *
1584
+ * @example
1585
+ * ```ts
1586
+ * import { splitGitRecords } from './helpers.js'
1587
+ *
1588
+ * splitGitRecords('alpha\0\0br') // { records: ['alpha'], pending: 'br' }
1589
+ * ```
1590
+ */
1591
+ function splitGitRecords(text) {
1592
+ const parts = text.split("\0");
1593
+ return {
1594
+ records: parts.slice(0, -1).filter((record) => record.length > 0),
1595
+ pending: parts.at(-1) ?? ""
1596
+ };
1597
+ }
1598
+ /**
1599
+ * Finds the refusal one git output chunk earns.
1600
+ *
1601
+ * @param target - The repository the query runs in.
1602
+ * @param count - The records collected before the chunk.
1603
+ * @param chunk - The next split output.
1604
+ * @returns The inventory message when the records pass the working-tree inventory limit, else the
1605
+ * unfinished-record message when the tail passes the supported path limit, else `undefined`.
1606
+ *
1607
+ * @example
1608
+ * ```ts
1609
+ * import { findGitRefusal } from './helpers.js'
1610
+ *
1611
+ * findGitRefusal('./packages/router', 0, { records: ['package.json'], pending: '' }) // undefined
1612
+ * ```
1613
+ */
1614
+ function findGitRefusal(target, count, chunk) {
1615
+ if (count + chunk.records.length > MAX_INVENTORY_PATHS) return `The git query for target at ${target} exceeded the working-tree inventory limit.`;
1616
+ return chunk.pending.length > MAX_PATH_LENGTH + 3 ? `The git query for target at ${target} returned an unfinished record beyond the supported path limit.` : void 0;
1617
+ }
1618
+ //#endregion
1619
+ //#region src/bin/GitQuery.ts
1620
+ /**
1621
+ * Represents one git query read as its NUL-separated records.
1622
+ *
1623
+ * @remarks
1624
+ * Git is asked rather than reimplemented, because the tracked set and the dirty set are git's own
1625
+ * answers and nothing else can give them. The raw session streams complete NUL-delimited records
1626
+ * without retaining the whole query as a manifest-sized text buffer. Its hooks are installed
1627
+ * before the eager spawn, so no output or child fault can precede its observer.
1628
+ *
1629
+ * A query is created for one run: the unfinished record, the retained records, and the abort
1630
+ * signal all belong to that run, and a second `read` is refused rather than reset. Only `read`
1631
+ * drives the intake, so no caller feeds a run its output.
1632
+ *
1633
+ * @example
1634
+ * ```ts
1635
+ * import { GitQuery } from './GitQuery.js'
1636
+ *
1637
+ * await new GitQuery('./packages/router', ['ls-files', '-z']).read()
1638
+ * // ['package.json', 'src/core/index.ts']
1639
+ * ```
1640
+ */
1641
+ var GitQuery = class {
1642
+ #target;
1643
+ #args;
1644
+ #records = [];
1645
+ #decoder = new TextDecoder("utf-8", { ignoreBOM: true });
1646
+ #controller = new AbortController();
1647
+ #pending = "";
1648
+ #started = false;
1649
+ /**
1650
+ * Creates a query over one repository.
1651
+ *
1652
+ * @param target - The repository the query runs in.
1653
+ * @param args - The git arguments, without the program name.
1654
+ */
1655
+ constructor(target, args) {
1656
+ this.#target = target;
1657
+ this.#args = [...args];
1658
+ }
1659
+ /**
1660
+ * Runs the query and reads its records.
1661
+ *
1662
+ * @returns The non-empty records git wrote, in git's own order.
1663
+ * @throws {@link ScaffoldError} coded `TARGET` when the query fails, cannot start, or its output
1664
+ * is incomplete or outside the working-tree inventory bounds, and `INVALID` when the query
1665
+ * already ran.
1666
+ */
1667
+ async read() {
1668
+ if (this.#started) throw new ScaffoldError("INVALID", "A git query reads once.");
1669
+ this.#started = true;
1670
+ const target = this.#target;
1671
+ const created = attempt(() => createSession({
1672
+ command: {
1673
+ file: "git",
1674
+ arguments: [
1675
+ "-C",
1676
+ target,
1677
+ ...this.#args
1678
+ ]
1679
+ },
1680
+ workspace: ".",
1681
+ signal: this.#controller.signal,
1682
+ on: {
1683
+ stdout: (chunk) => this.#collect(chunk),
1684
+ error: () => this.#refuse(`The git query for target at ${target} failed before it completed.`)
1685
+ },
1686
+ error: () => this.#refuse(`The git query for target at ${target} failed while reading its output.`)
1687
+ }));
1688
+ if (!created.success) {
1689
+ if (isScaffoldError(created.error)) throw created.error;
1690
+ throw new ScaffoldError("TARGET", `The git query for target at ${target} could not start.`, { target });
1691
+ }
1692
+ const session = created.value;
1693
+ try {
1694
+ const exit = await session.exit;
1695
+ this.#pending = `${this.#pending}${this.#decoder.decode()}`;
1696
+ if (this.#controller.signal.aborted) throw this.#controller.signal.reason;
1697
+ if (exit.signal !== null) throw new ScaffoldError("TARGET", `The git query for target at ${target} ended from signal ${exit.signal}.`, {
1698
+ target,
1699
+ signal: exit.signal
1700
+ });
1701
+ if (exit.code !== 0) throw new ScaffoldError("TARGET", `The git query for target at ${target} failed with exit code ${String(exit.code)}.`, {
1702
+ target,
1703
+ code: exit.code
1704
+ });
1705
+ if (!exit.drained) throw new ScaffoldError("TARGET", `The git query for target at ${target} ended before its output drained.`, { target });
1706
+ if (this.#pending.length > 0) throw new ScaffoldError("TARGET", `The git query for target at ${target} returned an incomplete record.`, { target });
1707
+ return [...this.#records];
1708
+ } catch (error) {
1709
+ if (isScaffoldError(error)) throw error;
1710
+ throw new ScaffoldError("TARGET", `The git query for target at ${target} could not start.`, { target });
1711
+ } finally {
1712
+ await session.destroy();
1713
+ }
1714
+ }
1715
+ #collect(bytes) {
1716
+ if (this.#controller.signal.aborted) return;
1717
+ const chunk = splitGitRecords(`${this.#pending}${this.#decoder.decode(bytes, { stream: true })}`);
1718
+ const refusal = findGitRefusal(this.#target, this.#records.length, chunk);
1719
+ if (refusal !== void 0) {
1720
+ this.#refuse(refusal);
1721
+ return;
1722
+ }
1723
+ for (const record of chunk.records) this.#records.push(record);
1724
+ this.#pending = chunk.pending;
1725
+ }
1726
+ #refuse(message) {
1727
+ if (!this.#controller.signal.aborted) this.#controller.abort(new ScaffoldError("TARGET", message, { target: this.#target }));
1728
+ }
1729
+ };
1668
1730
  //#endregion
1669
1731
  //#region src/bin/CLI.ts
1670
1732
  /**
@@ -1904,50 +1966,50 @@ var CLI = class {
1904
1966
  const previous = catalogToNames(target);
1905
1967
  const floor = host === void 0 ? readHostFloor() : readHostManifest(host) === void 0 ? host : readHostFloor(host);
1906
1968
  const materializer = new Materializer({ host: floor });
1907
- let outcome;
1969
+ const outcome = await this.#assemble(command, target, floor, materializer, previous);
1970
+ if (command.json === true) this.#report(outcome);
1971
+ else this.#recount(outcome);
1972
+ return outcome.membership === void 0 || outcome.provenance.guides === "floor" ? 1 : 0;
1973
+ }
1974
+ async #assemble(command, target, floor, materializer, previous) {
1908
1975
  try {
1909
1976
  const fetched = await this.#fetch(target, command.all === true, floor);
1910
1977
  const guides = fetched.mirrors.length === 0 ? void 0 : fetched.mirrors.some((mirror) => mirror.lookup !== "found") ? "floor" : "live";
1911
- if (fetched.entries === void 0) outcome = {
1978
+ if (fetched.entries === void 0) return {
1912
1979
  ...materializer.mirror(fetched.mirrors, target),
1913
1980
  mirrors: fetched.mirrors,
1914
1981
  provenance: { ...guides === void 0 ? {} : { guides } },
1915
1982
  ...fetched.note === void 0 ? {} : { note: fetched.note }
1916
1983
  };
1917
- else {
1918
- const entries = fetched.entries;
1919
- const declarations = manifestToDependencies(this.#manifest(target));
1920
- const writable = {
1921
- runtime: dependenciesToFleet(declarations.runtime),
1922
- development: dependenciesToFleet(declarations.development)
1923
- };
1924
- const releases = entriesToReleases([...writable.runtime, ...writable.development], fetched.entries);
1925
- const pins = releasesToPins(releases, writable);
1926
- const refusal = fetchToRefusal(fetched.entries, fetched.mirrors);
1927
- if (refusal !== void 0) throw refusal;
1928
- outcome = {
1929
- ...mergeResults(this.#publish(materializer, target, fetched.entries, fetched.mirrors), materializer.declare({
1930
- pins,
1931
- scripts: []
1932
- }, target)),
1933
- membership: {
1934
- entries,
1935
- dropped: previous.filter((name) => !entries.some((entry) => entry.name === name)),
1936
- releases
1937
- },
1938
- mirrors: fetched.mirrors,
1939
- provenance: {
1940
- versions: "live",
1941
- ...guides === void 0 ? {} : { guides }
1942
- }
1943
- };
1944
- }
1984
+ const entries = fetched.entries;
1985
+ const declarations = manifestToDependencies(this.#manifest(target));
1986
+ const writable = {
1987
+ runtime: dependenciesToFleet(declarations.runtime),
1988
+ development: dependenciesToFleet(declarations.development)
1989
+ };
1990
+ const releases = entriesToReleases([...writable.runtime, ...writable.development], fetched.entries);
1991
+ const pins = releasesToPins(releases, writable);
1992
+ const refusal = fetchToRefusal(fetched.entries, fetched.mirrors);
1993
+ if (refusal !== void 0) throw refusal;
1994
+ return {
1995
+ ...mergeResults(this.#publish(materializer, target, fetched.entries, fetched.mirrors), materializer.declare({
1996
+ pins,
1997
+ scripts: []
1998
+ }, target)),
1999
+ membership: {
2000
+ entries,
2001
+ dropped: previous.filter((name) => !entries.some((entry) => entry.name === name)),
2002
+ releases
2003
+ },
2004
+ mirrors: fetched.mirrors,
2005
+ provenance: {
2006
+ versions: "live",
2007
+ ...guides === void 0 ? {} : { guides }
2008
+ }
2009
+ };
1945
2010
  } finally {
1946
2011
  materializer.destroy();
1947
2012
  }
1948
- if (command.json === true) this.#report(outcome);
1949
- else this.#recount(outcome);
1950
- return outcome.membership === void 0 || outcome.provenance.guides === "floor" ? 1 : 0;
1951
2013
  }
1952
2014
  async #replace(command) {
1953
2015
  const target = command.target ?? ".";
@@ -2047,15 +2109,18 @@ var CLI = class {
2047
2109
  }
2048
2110
  async #reconcile(materializer, target, declared, host, hostForced, blueprint) {
2049
2111
  const previous = catalogToNames(target);
2050
- let releases = [];
2051
- let provenance = { ...host === void 0 ? {} : { host } };
2052
2112
  try {
2053
2113
  const versions = await this.#versions(declared.pins, false, blueprint);
2054
- releases = versions.releases;
2055
- provenance = {
2056
- ...versions.baseline === void 0 ? {} : { versions: versions.baseline },
2057
- ...host === void 0 ? {} : { host }
2058
- };
2114
+ return await this.#confirm(materializer, target, declared, {
2115
+ baseline: host,
2116
+ forced: hostForced
2117
+ }, versions, previous);
2118
+ } catch (error) {
2119
+ return this.#unfinished(target, [], { ...host === void 0 ? {} : { host } }, error);
2120
+ }
2121
+ }
2122
+ async #confirm(materializer, target, declared, host, versions, previous) {
2123
+ try {
2059
2124
  const refused = versionsToRefusal(versions);
2060
2125
  if (refused !== void 0) throw refused;
2061
2126
  const fetched = await this.#fetch(target, false);
@@ -2064,44 +2129,53 @@ var CLI = class {
2064
2129
  const incomplete = fetchToRefusal(fetched.entries, fetched.mirrors);
2065
2130
  if (incomplete !== void 0) throw incomplete;
2066
2131
  const guides = fetched.mirrors.length === 0 ? void 0 : fetched.mirrors.some((mirror) => mirror.lookup === "failed") ? "floor" : "live";
2067
- provenance = {
2132
+ const provenance = {
2068
2133
  ...versions.baseline === void 0 ? {} : { versions: versions.baseline },
2069
2134
  ...guides === void 0 ? {} : { guides },
2070
- ...host === void 0 ? {} : { host }
2071
- };
2072
- const written = mergeResults(this.#publish(materializer, target, fetched.entries, fetched.mirrors), materializer.declare({
2073
- ...declared,
2074
- pins: versions.pins
2075
- }, target));
2076
- const floors = [];
2077
- if (hostForced) floors.push("host");
2078
- if (versions.forced) floors.push("versions");
2079
- if (guides === "floor") floors.push("guides");
2080
- return {
2081
- ...written,
2082
- membership: {
2083
- entries,
2084
- dropped: previous.filter((name) => !entries.some((entry) => entry.name === name)),
2085
- releases: entriesToReleases([...dependenciesToFleet(declared.pins.runtime), ...dependenciesToFleet(declared.pins.development)], entries)
2086
- },
2087
- mirrors: fetched.mirrors,
2088
- releases,
2089
- provenance,
2090
- ...floors.length === 0 ? {} : { note: `The ${floors.join(", ")} step used the distributed floor after its upstream read did not complete.` }
2135
+ ...host.baseline === void 0 ? {} : { host: host.baseline }
2091
2136
  };
2137
+ const landed = attempt(() => {
2138
+ const written = mergeResults(this.#publish(materializer, target, entries, fetched.mirrors), materializer.declare({
2139
+ ...declared,
2140
+ pins: versions.pins
2141
+ }, target));
2142
+ const floors = [];
2143
+ if (host.forced) floors.push("host");
2144
+ if (versions.forced) floors.push("versions");
2145
+ if (guides === "floor") floors.push("guides");
2146
+ return {
2147
+ ...written,
2148
+ membership: {
2149
+ entries,
2150
+ dropped: previous.filter((name) => !entries.some((entry) => entry.name === name)),
2151
+ releases: entriesToReleases([...dependenciesToFleet(declared.pins.runtime), ...dependenciesToFleet(declared.pins.development)], entries)
2152
+ },
2153
+ mirrors: fetched.mirrors,
2154
+ releases: versions.releases,
2155
+ provenance,
2156
+ ...floors.length === 0 ? {} : { note: `The ${floors.join(", ")} step used the distributed floor after its upstream read did not complete.` }
2157
+ };
2158
+ });
2159
+ return landed.success ? landed.value : this.#unfinished(target, versions.releases, provenance, landed.error);
2092
2160
  } catch (error) {
2093
- return {
2094
- target,
2095
- written: [],
2096
- skipped: [],
2097
- removed: [],
2098
- mirrors: [],
2099
- releases,
2100
- provenance,
2101
- note: `The catalog step did not complete: ${errorToEnvelope(error).error.message}`
2102
- };
2161
+ return this.#unfinished(target, versions.releases, {
2162
+ ...versions.baseline === void 0 ? {} : { versions: versions.baseline },
2163
+ ...host.baseline === void 0 ? {} : { host: host.baseline }
2164
+ }, error);
2103
2165
  }
2104
2166
  }
2167
+ #unfinished(target, releases, provenance, error) {
2168
+ return {
2169
+ target,
2170
+ written: [],
2171
+ skipped: [],
2172
+ removed: [],
2173
+ mirrors: [],
2174
+ releases,
2175
+ provenance,
2176
+ note: `The catalog step did not complete: ${errorToEnvelope(error).error.message}`
2177
+ };
2178
+ }
2105
2179
  async #host(from, target, offline) {
2106
2180
  if (from !== void 0) return {
2107
2181
  materializer: new Materializer({ host: from }),
@@ -2264,14 +2338,10 @@ var CLI = class {
2264
2338
  ].map((dependency) => dependency.name))];
2265
2339
  const upstream = new Upstream(this.#upstream);
2266
2340
  try {
2267
- let entries;
2268
- let note;
2269
- try {
2270
- entries = await upstream.catalog();
2271
- } catch (error) {
2341
+ const { entries, note } = await upstream.catalog().then((entries) => ({ entries }), (error) => {
2272
2342
  if (floor === void 0) throw error;
2273
- note = `The catalog membership read did not complete; performed a guide-only refresh: ${errorToEnvelope(error).error.message}`;
2274
- }
2343
+ return { note: `The catalog membership read did not complete; performed a guide-only refresh: ${errorToEnvelope(error).error.message}` };
2344
+ });
2275
2345
  const names = (all ? entries === void 0 ? catalogToNames(floor ?? target) : entries.map((entry) => entry.name) : declared).filter((name) => name !== own);
2276
2346
  const mirrors = await upstream.fetch(names, readSnapshot(target, names.map(nameToGuide)));
2277
2347
  return {
@@ -2323,12 +2393,18 @@ var CLI = class {
2323
2393
  const global = resolveContainedPath(target, GLOBAL_SETUP_PATH);
2324
2394
  const journey = resolveContainedPath(target, JOURNEY_CONFIG_PATH);
2325
2395
  const skills = resolveContainedPath(target, SKILLS_CONFIG_PATH);
2326
- const setup = /* @__PURE__ */ new Set();
2327
- if (tests !== void 0) for (const path of listFiles(tests)) {
2328
- if (path.includes("/") || !path.startsWith("setup") || !path.endsWith(".test.ts")) continue;
2396
+ const files = tests === void 0 ? [] : listFiles(tests);
2397
+ const studies = files.some((path) => {
2398
+ if (tests === void 0 || !path.startsWith("studies/") || !path.endsWith(".test.ts")) return false;
2399
+ if (path.split("/").some((segment) => segment === "node_modules" || segment === ".git")) return false;
2400
+ const study = resolveContainedPath(tests, path);
2401
+ return study !== void 0 && isExactCaseFile(study);
2402
+ });
2403
+ const setup = new Set(files.flatMap((path) => {
2404
+ if (tests === void 0 || path.includes("/") || !path.startsWith("setup") || !path.endsWith(".test.ts")) return [];
2329
2405
  const proof = resolveContainedPath(tests, path);
2330
- if (proof !== void 0 && isExactCaseFile(proof)) setup.add(path === "setupBrowser.test.ts" || path === "setupStyles.test.ts" ? "browser" : "node");
2331
- }
2406
+ return proof !== void 0 && isExactCaseFile(proof) ? [path === "setupBrowser.test.ts" || path === "setupStyles.test.ts" ? "browser" : "node"] : [];
2407
+ }));
2332
2408
  return createBlueprint(declared.slice(declared.lastIndexOf("/") + 1), {
2333
2409
  ...targetToFacts(target),
2334
2410
  extensions: targetToExtensions(target),
@@ -2341,6 +2417,7 @@ var CLI = class {
2341
2417
  integration: integration !== void 0 && isExactCaseFile(integration),
2342
2418
  conformance: conformance !== void 0 && isExactCaseFile(conformance),
2343
2419
  service: service !== void 0 && isExactCaseFile(service),
2420
+ studies,
2344
2421
  global: global !== void 0 && isExactCaseFile(global),
2345
2422
  journey: journey !== void 0 && isExactCaseFile(journey),
2346
2423
  skills: skills !== void 0 && isExactCaseFile(skills)
@@ -2355,18 +2432,9 @@ var CLI = class {
2355
2432
  const scripts = isRecord(parsed) && isRecord(parsed.scripts) ? parsed.scripts : {};
2356
2433
  const gates = !isRecord(parsed) || parsed.private !== true ? ["test", "prepublishOnly"] : ["test"];
2357
2434
  const gateNames = gates.join(" or ");
2358
- const absent = /* @__PURE__ */ new Set();
2359
- let unresolved = false;
2360
- for (const script of Object.values(scripts)) {
2361
- if (!isString(script) || !script.includes("vitest")) continue;
2362
- const invoked = scriptToInvocations(script);
2363
- if (invoked === void 0) {
2364
- unresolved = true;
2365
- continue;
2366
- }
2367
- for (const project of invoked.projects) if (!planned.has(project)) absent.add(project);
2368
- }
2369
- const projects = [...absent].sort();
2435
+ const readings = Object.values(scripts).filter((script) => isString(script) && script.includes("vitest")).map((script) => scriptToInvocations(script));
2436
+ const unresolved = readings.some((invocations) => invocations === void 0);
2437
+ const projects = [...new Set(readings.flatMap((invocations) => invocations?.projects ?? []).filter((project) => !planned.has(project)))].sort();
2370
2438
  if (unresolved) return {
2371
2439
  field: "projects",
2372
2440
  message: `The manifest at ${target} contains a Vitest project expression that cannot be resolved statically.${projects.length === 0 ? "" : ` It also names projects the planned vite.config.ts does not register: ${projects.join(", ")}.`} ${writing ? "The configs group is blocked. Replace the expression with a literal --project value before selecting configs, or exclude configs from --groups." : "Replace it with a literal --project value before relying on the planned configuration."}`,
@@ -2381,17 +2449,13 @@ var CLI = class {
2381
2449
  };
2382
2450
  if (!writing) {
2383
2451
  const paths = new Set(blueprintToConfigArtifacts(blueprint).map((artifact) => resolveContainedPath(target, artifact.path)));
2384
- const configurations = [];
2385
- for (const [name, script] of Object.entries(scripts)) {
2386
- if (!name.startsWith("test:") || !isString(script) || !script.includes("vitest")) continue;
2387
- const invoked = scriptToInvocations(script);
2388
- if (invoked === void 0) continue;
2389
- for (const config of invoked.configs) {
2452
+ const configurations = Object.entries(scripts).flatMap(([name, script]) => {
2453
+ if (!name.startsWith("test:") || !isString(script) || !script.includes("vitest")) return [];
2454
+ return (scriptToInvocations(script)?.configs ?? []).flatMap((config) => {
2390
2455
  const path = resolveContainedPath(target, config);
2391
- if (path !== void 0 && (paths.has(path) || isExactCaseFile(path))) continue;
2392
- configurations.push(`${name} --config ${config}`);
2393
- }
2394
- }
2456
+ return path !== void 0 && (paths.has(path) || isExactCaseFile(path)) ? [] : [`${name} --config ${config}`];
2457
+ });
2458
+ });
2395
2459
  if (configurations.length > 0) return {
2396
2460
  field: "projects",
2397
2461
  message: `The manifest at ${target} names a Vitest configuration the plan does not emit and the target does not hold: ${configurations.join(", ")}. Add the configuration, or remove the script that names it and its invocation from the test chain.`,
@@ -2400,48 +2464,23 @@ var CLI = class {
2400
2464
  };
2401
2465
  }
2402
2466
  const expected = blueprintToScripts(blueprint);
2403
- const expectedLines = /* @__PURE__ */ new Map();
2404
- for (const [name, script] of Object.entries(expected)) {
2405
- if (!name.startsWith("test:")) continue;
2406
- const invoked = scriptToInvocations(script);
2407
- if (invoked === void 0) continue;
2408
- for (const project of invoked.projects) {
2409
- if (project === "probe") continue;
2467
+ const expectedLines = new Map(Object.entries(expected).flatMap(([name, script]) => {
2468
+ if (!name.startsWith("test:")) return [];
2469
+ return (scriptToInvocations(script)?.projects ?? []).flatMap((project) => {
2410
2470
  const direct = `test:${project}`;
2411
2471
  const command = expected[direct];
2412
- if (command !== void 0) expectedLines.set(project, `"${direct}": ${JSON.stringify(command)},`);
2413
- }
2414
- }
2472
+ return project === "probe" || command === void 0 ? [] : [[project, `"${direct}": ${JSON.stringify(command)},`]];
2473
+ });
2474
+ }));
2415
2475
  const faces = /* @__PURE__ */ new Set([...blueprintToSheets(blueprint).map((name) => `src:${name}`), ...blueprintToFaces(blueprint).flatMap(({ name, axes }) => axes.map((axis) => `${axis}:${name}`))]);
2416
- const wrappers = /* @__PURE__ */ new Map();
2417
- for (const artifact of blueprintToConfigArtifacts(blueprint)) {
2476
+ const wrappers = new Map(blueprintToConfigArtifacts(blueprint).flatMap((artifact) => {
2418
2477
  const match = /^configs\/(src|app)\/vite\.([^.]+)\.config\.ts$/u.exec(artifact.path);
2419
- if (match === null) continue;
2420
- const project = `${match[1]}:${match[2]}`;
2421
- if (faces.has(project)) wrappers.set(artifact.path, project);
2422
- }
2423
- const reachable = /* @__PURE__ */ new Set();
2424
- const chains = /* @__PURE__ */ new Map();
2425
- for (const gate of gates) {
2426
- const visited = /* @__PURE__ */ new Set();
2427
- chains.set(gate, visited);
2428
- const pending = [gate];
2429
- while (pending.length > 0) {
2430
- const name = pending.shift();
2431
- if (name === void 0 || visited.has(name)) continue;
2432
- visited.add(name);
2433
- const script = scripts[name];
2434
- if (!isString(script)) continue;
2435
- const invoked = scriptToInvocations(script, wrappers);
2436
- if (invoked === void 0) {
2437
- unresolved = true;
2438
- continue;
2439
- }
2440
- for (const project of invoked.projects) reachable.add(project);
2441
- for (const called of invoked.scripts) if (!visited.has(called)) pending.push(called);
2442
- }
2443
- }
2444
- if (unresolved) return {
2478
+ const project = match === null ? void 0 : `${match[1]}:${match[2]}`;
2479
+ return project !== void 0 && faces.has(project) ? [[artifact.path, project]] : [];
2480
+ }));
2481
+ const chains = new Map(gates.map((gate) => [gate, collectScriptChain(gate, scripts, wrappers)]));
2482
+ const reachable = new Set([...chains.values()].flatMap((chain) => [...chain.projects]));
2483
+ if (new Set([...chains.values()].flatMap((chain) => [...chain.unreadable])).size > 0) return {
2445
2484
  field: "projects",
2446
2485
  message: `The manifest at ${target} contains a ${gateNames} chain that cannot be resolved statically. ${writing ? "The configs group is blocked. Replace it with literal npm run script names before selecting configs, or exclude configs from --groups." : "Replace it with literal npm run script names before relying on the planned configuration."}`,
2447
2486
  blocking: false,
@@ -2449,7 +2488,7 @@ var CLI = class {
2449
2488
  };
2450
2489
  const ungated = [...expectedLines].filter(([project]) => !reachable.has(project)).sort(([left], [right]) => left.localeCompare(right)).filter(([project]) => isString(scripts[`test:${project}`]));
2451
2490
  if (ungated.length === 0) {
2452
- if (!writing && expected["test:journey"] !== void 0 && !chains.get("test")?.has("test:journey")) return {
2491
+ if (!writing && expected["test:journey"] !== void 0 && !chains.get("test")?.visited.has("test:journey")) return {
2453
2492
  field: "projects",
2454
2493
  message: `The manifest at ${target} does not invoke npm run test:journey from its test chain. Add npm run test:journey after npm run test:app.`,
2455
2494
  blocking: false,
@@ -2643,13 +2682,13 @@ var CLI = class {
2643
2682
  return manifest;
2644
2683
  }
2645
2684
  async #worktree(target) {
2646
- const tracked = await readGitRecords(target, ["ls-files", "-z"]);
2647
- const dirty = (await readGitRecords(target, [
2685
+ const tracked = await new GitQuery(target, ["ls-files", "-z"]).read();
2686
+ const dirty = (await new GitQuery(target, [
2648
2687
  "status",
2649
2688
  "--porcelain=v1",
2650
2689
  "--untracked-files=all",
2651
2690
  "-z"
2652
- ])).map((record) => record.length > 3 && record[2] === " " ? record.slice(3) : record);
2691
+ ]).read()).map((record) => record.length > 3 && record[2] === " " ? record.slice(3) : record);
2653
2692
  const state = {
2654
2693
  tracked,
2655
2694
  dirty