@orkestrel/scaffold 0.0.84 → 0.0.86

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/README.md CHANGED
@@ -27,8 +27,8 @@ options, its defaults, and the exit codes.
27
27
  and script regions.
28
28
  - `catalog` rewrites the package table in the target's catalog agent file, and refetches the guide
29
29
  mirrors.
30
- - `overwrite` does everything `repair` and `catalog` do, then deletes what the plan does not own and
31
- re-declares the dependency ranges.
30
+ - `overwrite` does everything `repair` and `catalog` do, then deletes what the plan does not own,
31
+ re-declares the dependency ranges, and declares each planned dependency the manifest lacks.
32
32
 
33
33
  ## Library
34
34
 
@@ -89,9 +89,23 @@ function readReleases(text) {
89
89
  function describeCommand(file, args) {
90
90
  return [file === process.execPath ? 'node' : file, ...args].join(' ');
91
91
  }
92
+ // A JSON verb writes its refusal envelope or its partial-run note on stdout, so the stderr tail
93
+ // alone reads empty for exactly the failures the operator needs named.
92
94
  function describeFailure(result) {
93
95
  if (result.status === 0)
94
96
  return undefined;
97
+ const value = readJsonObject(result.stdout);
98
+ const error = value?.error;
99
+ if (typeof error === 'object' && error !== null && !Array.isArray(error)) {
100
+ const envelope = Object.fromEntries(Object.entries(error));
101
+ const code = readString(envelope, 'code');
102
+ const message = readString(envelope, 'message');
103
+ if (code !== undefined && message !== undefined)
104
+ return `${code}: ${message}`;
105
+ }
106
+ const note = readString(value, 'note');
107
+ if (note !== undefined)
108
+ return note;
95
109
  return result.stderr
96
110
  .trim()
97
111
  .split(/\r\n|\n/)
@@ -214,12 +228,12 @@ function runCommit(runner) {
214
228
  function runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline) {
215
229
  const written = runStep(runner, 'overwrite', scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline], runner.target);
216
230
  if (runner.offline && written !== undefined && written.status === 1) {
217
- const note = readString(readJsonObject(written.stdout) ?? {}, 'note');
231
+ const note = readString(readJsonObject(written.stdout), 'note');
218
232
  if (note !== undefined && note.includes(OFFLINE_REFUSAL)) {
219
233
  replaceLastStep(runner, 0, 'offline overwrite skipped the catalog step by design');
220
234
  }
221
235
  else {
222
- replaceLastStep(runner, 1, note ?? describeFailure(written));
236
+ replaceLastStep(runner, 1, describeFailure(written));
223
237
  }
224
238
  }
225
239
  if (!failed(runner)) {
package/dist/bin/main.js CHANGED
@@ -10,7 +10,7 @@ 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.84",
13
+ version: "0.0.86",
14
14
  description: "Scaffold workspaces with commands: new, audit, repair, catalog, and overwrite.",
15
15
  keywords: [
16
16
  "audit",
@@ -322,8 +322,25 @@ var VERB_SUMMARY = Object.freeze({
322
322
  audit: "report how the target compares to its plan, writing nothing",
323
323
  repair: "write each planned path the target is missing or has let drift",
324
324
  catalog: "regenerate the package table and refresh the guide mirrors",
325
- overwrite: "do everything repair and catalog do, then delete what the plan does not own and re-declare the dependency ranges"
325
+ overwrite: "do everything repair and catalog do, then delete what the plan does not own, re-declare the dependency ranges, and declare each planned dependency the manifest lacks"
326
326
  });
327
+ /**
328
+ * Matches a module specifier that names a built sheet's CSS as raw text.
329
+ *
330
+ * @remarks
331
+ * The planned sheet test is the one birth-owned test whose text imports a built
332
+ * sheet, so this is what tells it apart from a face's entry test at the same
333
+ * `tests/src/<face>/index.test.ts` shape.
334
+ */
335
+ var SHEET_IMPORT_PATTERN = /from\s+(['"])[^'"]*dist\/src\/[^'"]+\.css\?raw\1/u;
336
+ /**
337
+ * Matches each import declaration whose module specifier is root-relative, capturing the specifier.
338
+ *
339
+ * @remarks
340
+ * A root-relative specifier opens with one `/`. Oxlint's `import/no-absolute-path` rule
341
+ * refuses it, while a protocol-relative `//` specifier is outside this pattern.
342
+ */
343
+ var ROOT_IMPORT_PATTERN = /^\s*import\s[^'"]*?(['"])(\/(?!\/)[^'"]*)\1/gmu;
327
344
  //#endregion
328
345
  //#region src/bin/errors.ts
329
346
  /**
@@ -653,6 +670,89 @@ function manifestToWritableDependencies(manifest, blueprint) {
653
670
  };
654
671
  }
655
672
  /**
673
+ * Reads the planned dependencies a target manifest declares in neither writable section.
674
+ *
675
+ * @param manifest - The target manifest text.
676
+ * @param blueprint - The workspace shape that supplies the planned dependencies.
677
+ * @returns The undeclared planned dependencies at their planned ranges, in name order, each in
678
+ * the list naming the section the plan assigns it.
679
+ *
680
+ * @remarks
681
+ * The plan declares its tooling under `devDependencies`, so every row lands in `development` and
682
+ * `runtime` stays empty: the blueprint's runtime packages are read from the manifest's own
683
+ * `dependencies`, so none of them is ever missing. A planned package either section declares is
684
+ * present, whatever its range. A manifest that is not an object, or whose sections are not
685
+ * objects, yields what its readable sections leave undeclared, and the caller's refusal of that
686
+ * malformed shape decides whether a write may follow.
687
+ *
688
+ * @example
689
+ * ```ts
690
+ * import { createBlueprint } from '@src/core'
691
+ * import { manifestToAdditions } from './helpers.js'
692
+ *
693
+ * const blueprint = createBlueprint('sample', { src: ['core'] })
694
+ * manifestToAdditions('{"devDependencies": {}}', blueprint).development.some(
695
+ * ({ name }) => name === 'typescript',
696
+ * ) // true
697
+ * ```
698
+ */
699
+ function manifestToAdditions(manifest, blueprint) {
700
+ const parsed = parseJSON(manifest);
701
+ const runtime = isRecord(parsed) && isRecord(parsed.dependencies) ? parsed.dependencies : {};
702
+ const development = isRecord(parsed) && isRecord(parsed.devDependencies) ? parsed.devDependencies : {};
703
+ return {
704
+ runtime: [],
705
+ development: Object.entries(blueprintToDevDependencies(blueprint)).filter(([name]) => !Object.hasOwn(runtime, name) && !Object.hasOwn(development, name)).map(([name, range]) => ({
706
+ name,
707
+ range
708
+ }))
709
+ };
710
+ }
711
+ /**
712
+ * Reads the root-relative imports a target keeps in a planned birth-owned sheet test.
713
+ *
714
+ * @param artifact - The planned artifact at the path the target holds.
715
+ * @param present - The text the target holds at that path.
716
+ * @returns A non-blocking `tests` question naming the file and each root-relative specifier beside
717
+ * the relative specifier to write, or `undefined` when the artifact is not a birth-owned sheet
718
+ * test or the text imports nothing by a root-relative specifier.
719
+ *
720
+ * @remarks
721
+ * Birth ownership never rewrites a present file, so a target born before the sheet test imported
722
+ * the built sheet by a relative path keeps the root-relative import, and the content-owned lint
723
+ * configuration refuses it. The relative specifier climbs one `../` per directory of the
724
+ * artifact's path to the workspace root, which is the specifier the planned test itself imports.
725
+ *
726
+ * @example
727
+ * ```ts
728
+ * import { sheetTestToQuestion } from './helpers.js'
729
+ *
730
+ * const artifact = {
731
+ * path: 'tests/src/styles/index.test.ts',
732
+ * group: 'tests',
733
+ * ownership: 'birth',
734
+ * origin: 'template',
735
+ * content: "import sheet from '../../../dist/src/styles/index.css?raw'\n",
736
+ * } as const
737
+ * sheetTestToQuestion(artifact, "import sheet from '/dist/src/styles/index.css?raw'\n")?.field // 'tests'
738
+ * sheetTestToQuestion(artifact, artifact.content) // undefined
739
+ * ```
740
+ */
741
+ function sheetTestToQuestion(artifact, present) {
742
+ if (artifact.origin === "host" || artifact.ownership !== "birth") return void 0;
743
+ if (!SHEET_IMPORT_PATTERN.test(artifact.content)) return void 0;
744
+ const root = "../".repeat(artifact.path.split("/").length - 1);
745
+ const replacements = [...present.matchAll(ROOT_IMPORT_PATTERN)].map((match) => `'${match[2] ?? ""}' with '${root}${(match[2] ?? "").slice(1)}'`);
746
+ if (replacements.length === 0) return void 0;
747
+ const single = replacements.length === 1;
748
+ return {
749
+ field: "tests",
750
+ message: `The birth-owned sheet test ${artifact.path} imports by ${single ? "a root-relative specifier" : "root-relative specifiers"}, which oxlint's import/no-absolute-path rule refuses under --deny-warnings. Replace ${replacements.join(" and ")}. Scaffold does not rewrite a birth-owned file.`,
751
+ blocking: false,
752
+ groups: ["tests"]
753
+ };
754
+ }
755
+ /**
656
756
  * Reads the manifest scripts a writing verb may write.
657
757
  *
658
758
  * @param manifest - The target manifest text.
@@ -1517,11 +1617,14 @@ function selectionToPackages(selection) {
1517
1617
  *
1518
1618
  * @param first - The earlier result, which fixes the reported target.
1519
1619
  * @param second - The later result.
1520
- * @returns One result carrying both path lists, first's paths ahead of second's.
1620
+ * @returns One result carrying both path lists, first's paths ahead of second's, each list
1621
+ * naming a path once.
1521
1622
  *
1522
1623
  * @remarks
1523
- * Written and skipped never overlap across the calls a verb makes, because each
1524
- * call answers for its own paths.
1624
+ * Two calls can answer for one path: `overwrite` declares a missing dependency
1625
+ * and later rewrites the ranges in the same manifest, so each list keeps a path's
1626
+ * first entry. A path one call skipped and another wrote stays in both lists, as
1627
+ * the manifest a repair skips by birth and its range write rewrites does.
1525
1628
  *
1526
1629
  * @example
1527
1630
  * ```ts
@@ -1536,9 +1639,9 @@ function selectionToPackages(selection) {
1536
1639
  function mergeResults(first, second) {
1537
1640
  return {
1538
1641
  target: first.target,
1539
- written: [...first.written, ...second.written],
1540
- skipped: [...first.skipped, ...second.skipped],
1541
- removed: [...first.removed, ...second.removed]
1642
+ written: [.../* @__PURE__ */ new Set([...first.written, ...second.written])],
1643
+ skipped: [.../* @__PURE__ */ new Set([...first.skipped, ...second.skipped])],
1644
+ removed: [.../* @__PURE__ */ new Set([...first.removed, ...second.removed])]
1542
1645
  };
1543
1646
  }
1544
1647
  /**
@@ -1845,7 +1948,7 @@ var CLI = class {
1845
1948
  const target = command.target ?? ".";
1846
1949
  const groups = selectionToGroups(command.groups);
1847
1950
  const blueprint = this.#derive(target);
1848
- this.#assertTarget(target, blueprint, groups);
1951
+ this.#assertTarget(target, blueprint, groups, true);
1849
1952
  const worktree = await this.#worktree(target);
1850
1953
  if (worktree.dirty.length > 0 && command.dirty !== true) throw new ScaffoldError("TARGET", `The target at ${target} carries ${String(worktree.dirty.length)} uncommitted change${worktree.dirty.length === 1 ? "" : "s"}. Commit them, or pass --dirty to waive the refusal.`, {
1851
1954
  target,
@@ -1866,23 +1969,42 @@ var CLI = class {
1866
1969
  pins: manifestToWritableDependencies(this.#manifest(target), blueprint),
1867
1970
  scripts: manifestToWritableScripts(this.#manifest(target), blueprint)
1868
1971
  };
1869
- const local = mergeResults(host.materializer.repair(plan, audit, target), host.materializer.remove(plan, audit, command.dirty === true ? {
1972
+ const additions = manifestToAdditions(this.#manifest(target), blueprint);
1973
+ const repaired = host.materializer.repair(plan, audit, target);
1974
+ const added = additions.runtime.length === 0 && additions.development.length === 0 ? void 0 : host.materializer.declare({
1975
+ pins: {
1976
+ runtime: [],
1977
+ development: []
1978
+ },
1979
+ scripts: [],
1980
+ additions
1981
+ }, target);
1982
+ const removed = host.materializer.remove(plan, audit, command.dirty === true ? {
1870
1983
  tracked: worktree.tracked,
1871
1984
  dirty: []
1872
- } : worktree, target));
1985
+ } : worktree, target);
1986
+ const local = mergeResults(added === void 0 ? repaired : mergeResults(repaired, added), removed);
1873
1987
  const remainder = command.offline === true ? await this.#declare(host.materializer, target, declared, host.baseline) : await this.#reconcile(host.materializer, target, declared, host.baseline, host.forced);
1874
1988
  const [measured] = this.#survey(host.materializer, blueprint, target, groups);
1875
1989
  const terminal = this.#appendQuestions(measured, target, blueprint, groups);
1876
1990
  const outcome = {
1877
1991
  ...remainder,
1878
1992
  ...mergeResults(local, remainder),
1879
- audit: terminal
1993
+ audit: terminal,
1994
+ additions
1880
1995
  };
1881
1996
  if (command.json === true) this.#report(outcome);
1882
1997
  else {
1883
1998
  this.#present(terminal);
1884
1999
  this.#reportReplacements(audit, terminal);
1885
2000
  this.#recount(outcome);
2001
+ for (const { section, dependencies } of [{
2002
+ section: "dependencies",
2003
+ dependencies: additions.runtime
2004
+ }, {
2005
+ section: "devDependencies",
2006
+ dependencies: additions.development
2007
+ }]) for (const { name, range } of dependencies) this.#say(`Declared ${JSON.stringify(name)}: ${JSON.stringify(range)} in ${section}. Run npm install to install it.`);
1886
2008
  if (remainder.note !== void 0) this.#warn(remainder.note);
1887
2009
  }
1888
2010
  if (remainder.note !== void 0) return 1;
@@ -2341,9 +2463,32 @@ var CLI = class {
2341
2463
  groups: ["manifest"]
2342
2464
  };
2343
2465
  }
2344
- #dependencyQuestion(target, blueprint, writing = false) {
2466
+ #dependencyQuestion(target, blueprint, writing = false, declaring = false) {
2345
2467
  const parsed = parseJSON(this.#manifest(target));
2346
2468
  if (!isRecord(parsed)) return void 0;
2469
+ const dependencies = isRecord(parsed.dependencies) ? parsed.dependencies : {};
2470
+ const development = isRecord(parsed.devDependencies) ? parsed.devDependencies : {};
2471
+ const missing = Object.entries(blueprintToDevDependencies(blueprint)).filter(([name]) => !Object.hasOwn(dependencies, name) && !Object.hasOwn(development, name)).sort(([left], [right]) => left.localeCompare(right));
2472
+ const names = missing.map(([name]) => name);
2473
+ if (declaring && missing.length > 0) {
2474
+ const planned = `${names.length === 1 ? "the planned dependency" : "the planned dependencies"} ${names.join(", ")}`;
2475
+ if (parsed.devDependencies !== void 0 && !isRecord(parsed.devDependencies)) return {
2476
+ field: "dependencies",
2477
+ message: `The manifest at ${target} declares devDependencies as a value that is not an object, so overwrite cannot declare ${planned} in it. Replace it with an object in package.json before running overwrite.`,
2478
+ blocking: true,
2479
+ groups: ["configs", "tests"]
2480
+ };
2481
+ const entries = Object.entries(development).filter(([, value]) => !isString(value)).map(([name]) => JSON.stringify(name));
2482
+ if (entries.length > 0) {
2483
+ const single = entries.length === 1;
2484
+ return {
2485
+ field: "dependencies",
2486
+ message: `The manifest at ${target} declares the devDependencies ${single ? "entry" : "entries"} ${entries.join(", ")} as ${single ? "a value that is not a version string" : "values that are not version strings"}, so overwrite cannot declare ${planned} beside ${single ? "it" : "them"}. Replace ${single ? "it with a version range" : "them with version ranges"} in package.json before running overwrite.`,
2487
+ blocking: true,
2488
+ groups: ["configs", "tests"]
2489
+ };
2490
+ }
2491
+ }
2347
2492
  const malformed = ["dependencies", "devDependencies"].filter((section) => parsed[section] !== void 0 && !isRecord(parsed[section]));
2348
2493
  if (malformed.length > 0) return {
2349
2494
  field: "dependencies",
@@ -2351,11 +2496,7 @@ var CLI = class {
2351
2496
  blocking: false,
2352
2497
  groups: ["configs", "tests"]
2353
2498
  };
2354
- const dependencies = isRecord(parsed.dependencies) ? parsed.dependencies : {};
2355
- const development = isRecord(parsed.devDependencies) ? parsed.devDependencies : {};
2356
- const missing = Object.entries(blueprintToDevDependencies(blueprint)).filter(([name]) => !Object.hasOwn(dependencies, name) && !Object.hasOwn(development, name)).sort(([left], [right]) => left.localeCompare(right));
2357
- if (missing.length === 0) return void 0;
2358
- const names = missing.map(([name]) => name);
2499
+ if (missing.length === 0 || declaring) return void 0;
2359
2500
  const lines = missing.map(([name, range]) => `${JSON.stringify(name)}: ${JSON.stringify(range)},`);
2360
2501
  return {
2361
2502
  field: "dependencies",
@@ -2390,7 +2531,15 @@ var CLI = class {
2390
2531
  groups: ["tests"]
2391
2532
  };
2392
2533
  }
2393
- #targetQuestions(target, blueprint, groups, writing = false) {
2534
+ #sheetQuestions(target, blueprint) {
2535
+ return blueprintToTestArtifacts(blueprint).flatMap((artifact) => {
2536
+ const path = resolveContainedPath(target, artifact.path);
2537
+ if (path === void 0 || !isExactCaseFile(path)) return [];
2538
+ const question = sheetTestToQuestion(artifact, readFileText(target, artifact.path) ?? "");
2539
+ return question === void 0 ? [] : [question];
2540
+ });
2541
+ }
2542
+ #targetQuestions(target, blueprint, groups, writing = false, declaring = false) {
2394
2543
  const questions = [];
2395
2544
  const browser = resolveContainedPath(target, "app/browser");
2396
2545
  if (browser !== void 0 && listFiles(browser).some((path) => path.endsWith(".vue"))) questions.push({
@@ -2405,11 +2554,12 @@ var CLI = class {
2405
2554
  }
2406
2555
  const project = this.#projectQuestion(target, blueprint, writing);
2407
2556
  if (project !== void 0) questions.push(project);
2408
- const dependency = this.#dependencyQuestion(target, blueprint, writing);
2557
+ const dependency = this.#dependencyQuestion(target, blueprint, writing, declaring);
2409
2558
  if (dependency !== void 0) questions.push(dependency);
2410
2559
  if (!writing) {
2411
2560
  const setup = this.#setupQuestion(target, blueprint);
2412
2561
  if (setup !== void 0) questions.push(setup);
2562
+ questions.push(...this.#sheetQuestions(target, blueprint));
2413
2563
  }
2414
2564
  return questions.filter((question) => question.blocking || groups === void 0 || question.groups.some((group) => groups.includes(group))).map(({ field, message, blocking, candidates }) => ({
2415
2565
  field,
@@ -2425,9 +2575,9 @@ var CLI = class {
2425
2575
  questions: [...audit.questions, ...questions]
2426
2576
  };
2427
2577
  }
2428
- #assertTarget(target, blueprint, groups) {
2578
+ #assertTarget(target, blueprint, groups, declaring = false) {
2429
2579
  if (manifestToName(this.#manifest(target)) === PACKAGE_NAME) throw new ScaffoldError("TARGET", `The target at ${target} is the ${PACKAGE_NAME} package, whose canon the vendored host is staged from. Run repair and overwrite against a workspace that consumes it.`, { target });
2430
- const questions = this.#targetQuestions(target, blueprint, groups, true);
2580
+ const questions = this.#targetQuestions(target, blueprint, groups, true, declaring);
2431
2581
  if (questions.length === 0) return;
2432
2582
  throw new ScaffoldError("TARGET", questions.map((question) => question.message).join(" "), { target });
2433
2583
  }