@orkestrel/scaffold 0.0.25 → 0.0.26

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.
@@ -12,6 +12,10 @@ does not work. Scaffold makes the shared set data — a vendored data root shipp
12
12
  — and gives it three verbs: create a workspace from it, report how a workspace differs from it, and
13
13
  write the difference back.
14
14
 
15
+ Every code fence below is illustrative. Nothing runs one, so a trailing `// value` comment inside a
16
+ fence is this guide's claim rather than a measured answer; the driven examples are the ones the
17
+ shipped declarations print. Limits states what that leaves unproven and what covers it instead.
18
+
15
19
  ```sh
16
20
  npm install --save-dev @orkestrel/scaffold
17
21
  ```
@@ -98,13 +102,15 @@ Exported from `@orkestrel/scaffold`, and reachable from
98
102
  | `EXTRA_RANGE_PATTERN` | const | The registry-only semver subset accepted for a development extra's range. |
99
103
  | `GLOBAL_SETUP_PATH` | const | The shared Vitest global-setup module whose presence makes a workspace `global`. |
100
104
  | `GUIDES_TEST_PATH` | const | The guide-parity proof whose physical file selects the fixed `guides` project. |
105
+ | `EXECUTABLE_PATHS` | const | The vendored paths a target receives with its executable bit set, frozen. |
101
106
  | `GROUPS` | const | The seven `Group` values in plan order, frozen. |
102
107
  | `HEX_PATTERN` | const | Exact lowercase hexadecimal bytes: two digits per byte, and empty content is valid. |
103
108
  | `HOST_PATHS` | const | The paths byte-copied from the vendored data root, frozen. |
104
109
  | `INTEGRATION_TEST_PATH` | const | The installed-package proof whose presence makes a workspace `integration`. |
105
- | `INVALID_PATH_CHARACTER_PATTERN` | const | Visible characters a portable path and a Markdown path cell both forbid. |
110
+ | `INVALID_PATH_CHARACTER_PATTERN` | const | Visible characters a target-relative path and a Markdown path cell both forbid. |
106
111
  | `MAX_ARTIFACT_BYTES` | const | Maximum bytes accepted for one artifact. |
107
112
  | `MAX_ARTIFACT_HEX_LENGTH` | const | Maximum length of the hexadecimal string carrying one artifact's bytes. |
113
+ | `MAX_AUDIT_FINDINGS` | const | Maximum findings one audit can produce from a bounded plan and snapshot. |
108
114
  | `MAX_COLLECTION_ITEMS` | const | Maximum items accepted in one public collection. |
109
115
  | `MAX_DEPENDENCY_NAME_LENGTH` | const | Maximum dependency package name length, scope included, as the registry caps it. |
110
116
  | `MAX_MANIFEST_BYTES` | const | Maximum bytes accepted for one package or vendored-host manifest. |
@@ -145,7 +151,7 @@ Exported from `@orkestrel/scaffold`, and reachable from
145
151
  | `isHex` | const | Narrow a value to exact lowercase hexadecimal bytes within one artifact's limit. |
146
152
  | `isMirror` | const | Narrow a value to a `Mirror`. |
147
153
  | `isOverride` | const | Narrow a value to an `Override`. |
148
- | `isPath` | function | Narrow a value to a portable target-relative path. |
154
+ | `isPath` | function | Narrow a value to a logical target-relative path. |
149
155
  | `isPlan` | const | Narrow a value to a `Plan`. |
150
156
  | `isQuestion` | const | Narrow a value to a `Question`. |
151
157
  | `isScaffoldError` | function | Narrow a caught value to a `ScaffoldError`. |
@@ -176,6 +182,7 @@ Exported from `@orkestrel/scaffold`, and reachable from
176
182
  | `inferGroup` | function | Infer the `Group` a path belongs to. |
177
183
  | `manifestToDependencies` | function | Project a package manifest's text to the `@orkestrel/*` packages it declares. |
178
184
  | `manifestToName` | function | Project a package manifest's text to its own name. |
185
+ | `matchesDriftReachability` | function | Test whether `inferDrift` could have produced a finding for an ownership. |
179
186
  | `matchesEngines` | function | Test whether a declared engines floor is at or above the supported minimum. |
180
187
  | `matchesOrchestrationPath` | function | Test whether a path instructs or wires an agent rather than the toolchain. |
181
188
  | `matchesRange` | function | Test whether a declared range already admits a published version. |
@@ -313,6 +320,7 @@ Exported from `@orkestrel/scaffold/server`, and reachable from
313
320
  | `listDirectories` | function | List a directory's descendant directories as sorted root-relative paths. |
314
321
  | `listFiles` | function | List a directory's files as sorted root-relative paths. |
315
322
  | `matchesAnchor` | function | Test whether a captured directory is still the same directory. |
323
+ | `matchesExecutablePath` | function | Test whether a vendored path is one a target receives executable. |
316
324
  | `matchesExpectation` | function | Test whether a destination still holds what was captured of it. |
317
325
  | `matchesGitPath` | function | Test whether a path addresses a target's own repository metadata. |
318
326
  | `matchesMissingPath` | function | Test whether a caught filesystem error reports an absent path. |
@@ -388,7 +396,7 @@ no interface and is documented directly.
388
396
  | Method | Summary |
389
397
  | ----------- | ---------------------------------------------------------------------------------- |
390
398
  | `write` | Stage one text file. |
391
- | `copy` | Stage one byte-for-byte copy of a file that already exists on this host. |
399
+ | `copy` | Stage one byte-for-byte copy in executable or non-executable destination mode. |
392
400
  | `directory` | Establish one directory inside the target, one segment at a time. |
393
401
  | `remove` | Mark one file for deletion at commit. |
394
402
  | `commit` | Promote every staged file and take every marked file, or roll the whole call back. |
@@ -510,7 +518,7 @@ standard error, so a piped value is never polluted.
510
518
  | Verb | Value |
511
519
  | ----------- | -------------------------------------------------------------------------- |
512
520
  | `new` | `MaterializeResult` — `target`, `written`, `skipped`, `removed` |
513
- | `audit` | `Audit` — `findings` and `questions` |
521
+ | `audit` | `Audit` — `findings` and `questions`; planned findings carry `ownership` |
514
522
  | `repair` | `MaterializeResult` plus `audit`, the terminal audit taken after the write |
515
523
  | `catalog` | `MaterializeResult` plus `entries`, `mirrors`, and `dropped` |
516
524
  | `overwrite` | The `catalog` value plus `audit`, `releases`, and `note` on a partial run |
@@ -543,10 +551,24 @@ The two axes are independent, so a library-only, an application-only, and a mixe
543
551
  first class. `dependencies` and `peers` are runtime `@orkestrel/*` packages; `extras` are
544
552
  development dependencies and may carry any valid npm name.
545
553
 
554
+ One published environment owns the package root directly. Several published environments require
555
+ `core`, which owns that root while each other environment keeps its subpath. A multi-environment
556
+ `src` selection without `core` therefore emits entry fields naming a `core` build the workspace
557
+ never runs. The gate reports that as a non-blocking `src` question rather than refusing the compile,
558
+ because the shape is chosen once and read afterwards: `new` refuses the advisory, while `audit` and
559
+ `repair` need the plan to describe and restore a target that already has that shape. A library
560
+ caller creating a workspace holds the same refusal, and the Compile section below states it.
561
+
546
562
  `bin`, `integration`, `services`, `global`, and `showcase` are structural facts. Each is set only
547
563
  when the workspace physically ships the directory or exact-case file that defines it, never because
548
564
  of the workspace's name and never because a sibling fact is set.
549
565
 
566
+ An axis-dependent structural fact projects only when its required axis exists. `integration`
567
+ projects a published `src`, and `showcase` projects the browser `app` environment. When that axis is
568
+ absent the flag adds no artifact, configuration, script, or dependency, and the gate reports a
569
+ non-blocking question on that field so the caller who set it learns it emitted nothing. The same
570
+ rule applies to both facts.
571
+
550
572
  `createBlueprint` enforces shape only. Whether the name is a name, the version a version, and the
551
573
  axis combination one this package can generate are the gate's laws, and the gate answers them with
552
574
  questions. A blueprint the gate will refuse is still constructible, so one law lives in one place.
@@ -577,12 +599,40 @@ refused blueprint is answered rather than raised, so a caller reads the refusal
577
599
  asked for. Each stage records its input and its output, a failed stage records the coded reason
578
600
  beside them, and the stages after a failed one never run.
579
601
 
602
+ A plan says the blueprint can be built. It does not say the blueprint should be created. Every
603
+ question beside the plan is advice the compile could not settle, and the caller that chose the shape
604
+ is the one that answers it. So `new` refuses on any question, blocking or not, before it writes,
605
+ while `audit` and `repair` carry the same questions through, because a target that already has that
606
+ shape still has to be described and restored.
607
+
608
+ A library caller creating a fresh workspace applies `new`'s rule itself:
609
+
610
+ ```ts
611
+ import { createBlueprint, createCompiler } from '@orkestrel/scaffold'
612
+
613
+ const compiler = createCompiler()
614
+ const scaffolding = compiler.compile(createBlueprint('router', { src: ['browser', 'server'] }))
615
+
616
+ scaffolding.plan === undefined || scaffolding.questions.length > 0 // true — do not write this shape
617
+ compiler.destroy()
618
+ ```
619
+
620
+ `materialize` does not apply that rule for you, and it could not: `compile` returns `questions`
621
+ beside `plan`, and the writer receives the plan alone. It refuses what only a writer can see — a
622
+ target that is not vacant — and writes the plan it is given otherwise. Choosing a shape is a policy
623
+ about which workspace to want, the plan has already answered whether that workspace can be built,
624
+ and the questions are where the package says what it thinks of the choice. That is the same line
625
+ `createBlueprint` draws when it constructs a blueprint the gate will refuse: one law lives in one
626
+ place, and the caller that picked the shape is the one holding it.
627
+
580
628
  Off-contract input is different. A value that is not the exact shape raises `ScaffoldError` coded
581
629
  `INVALID`, because it is not a question anyone can answer. Both entry points snapshot the caller's
582
630
  value first and then guard the snapshot, so a property backed by an accessor is refused rather than
583
631
  read.
584
632
 
585
- Overrides replace a drafted artifact's content whole. An override that matches no planned artifact,
633
+ Overrides replace a drafted artifact's content whole. The gate checks each override against the
634
+ blueprint's full draft before a group selection narrows the returned plan, so an override outside a
635
+ selected group does not block that compile. An override that matches no artifact in the full draft,
586
636
  that targets a host-origin artifact, or that targets the manifest is a blocking question rather
587
637
  than a silent no-op.
588
638
 
@@ -627,7 +677,19 @@ selected by their defining paths, as `guides` is. A workspace that needs other l
627
677
  must keep those edits outside a content-owned file; `repair` restores that file to the canonical
628
678
  project set.
629
679
 
630
- An audit reports one `Finding` per compared path.
680
+ An audit reports one `Finding` per planned path, followed by any foreign path beneath the groups
681
+ the plan covers. Every planned finding carries its artifact's `ownership`. A foreign finding has
682
+ no ownership because no artifact was planned for its path. `Ownership` says what scaffold claims at
683
+ a path, not what one run did there. Counting planned findings by `content`, `presence`, and `birth`
684
+ therefore says what audit is entitled to compare and stays the same against a vacant target and a
685
+ repaired one. What one run compared comes from `ownership`, `drift`, and `observed` together. A
686
+ content-owned finding carrying `observed` had its bytes compared. A content-owned `missing` finding
687
+ and every presence-owned finding were decided by existence alone. A birth-owned finding was not
688
+ examined. The foreign findings are exactly the ones no ownership accounts for. The audit stores no
689
+ aggregate tally.
690
+
691
+ The plan and snapshot are each bounded at `MAX_COLLECTION_ITEMS`. An audit may therefore carry one
692
+ finding per planned artifact plus one per unplanned snapshot path, up to `MAX_AUDIT_FINDINGS`.
631
693
 
632
694
  | `Drift` | Means |
633
695
  | --------- | ------------------------------------- |
@@ -642,6 +704,25 @@ removes a foreign file each fail when the destination no longer matches what the
642
704
  The requirement sits in the type rather than in prose, because a deletion that cannot bind to what
643
705
  the audit showed is the one thing the destructive verb must never do.
644
706
 
707
+ The shape a `Finding` admits is wider than the set an audit produces. Which combinations of
708
+ `ownership`, `drift`, and `observed` a real comparison reaches is `inferDrift`'s law — birth is
709
+ always aligned, presence compares existence only, and bytes are recorded only where they were
710
+ read — so the shape admits a birth-owned path reported stale, which no audit produces. That is
711
+ deliberate: restating the comparison's case analysis in the type would be a second copy of it, able
712
+ to disagree with the one that decides. `isFinding` proves the shape a reader may destructure and
713
+ nothing about whether the verdict is one an audit could have reached. `repair` and `remove`
714
+ re-derive every verdict themselves and act only on what they derived, so a verdict the comparison
715
+ could not have produced is refused by name rather than acted on.
716
+
717
+ That shape is versioned, and the guard runs at runtime. `repair` and `remove` guard the whole audit
718
+ before reading any of it, so an audit persisted or built against an earlier version of this package
719
+ is refused with a coded `INVALID` failure rather than accepted and partly understood. A planned
720
+ finding carries `ownership`, which findings made before that field existed do not. `remove` acts
721
+ only on foreign findings, which never carried ownership, but the guard reads every finding, so one
722
+ older planned finding refuses that call too. Take a fresh audit rather than replaying a stored one:
723
+ a stored audit records what a target looked like then, and both verbs bind their writes to what a
724
+ target holds now. The refusal is deliberate at `0.0.x` and there is no migration path.
725
+
645
726
  ## Vendored data root
646
727
 
647
728
  The vendored data root is the shared file set, staged into the published package as plain data. It
@@ -744,6 +825,19 @@ result.written // every path created
744
825
  materializer.destroy()
745
826
  ```
746
827
 
828
+ `resolveContainedPath` refuses a lexical escape, a physical link out of the root, and a dangling
829
+ link whose raw target contains a `..` segment. It returns the lexical join of `root` and `path` — an
830
+ absolute path under `root`, which is what its shipped example prints — after checking the namespace,
831
+ not an open filesystem handle. Its contract therefore excludes a concurrent rename or link swap
832
+ during the check or before the caller finishes using that path. A caller that admits hostile
833
+ concurrent namespace mutation needs a handle-bound operation instead.
834
+
835
+ `resolveRealPath` answers the caller's own text collapsed lexically, then resolved through every link
836
+ in what survives that collapse. A `..` the caller wrote cancels the segment before it as text, so
837
+ `<root>/hop/..` answers `<root>` even where `hop` links elsewhere, rather than the directory holding
838
+ what `hop` points at. The collapse only ever shortens the path, so nothing reaches outside it this
839
+ way; the answer is a lexical location resolved through links, not a physical one.
840
+
747
841
  Read the registry and the guide host:
748
842
 
749
843
  ```ts
@@ -762,6 +856,17 @@ verdict carrying its cause rather than thrown, so one unreachable package never
762
856
  rest of the answer. The organization package list is the exception, because without it there is no
763
857
  fleet to report.
764
858
 
859
+ Both bounds count decoded bytes, and a version lookup asks the registry for the abbreviated
860
+ packument — `dist-tags` and a trimmed version map, rather than the full per-version metadata no
861
+ verdict reads. That is the smallest form the registry publishes, and `limit` is capped at
862
+ `MAX_ARTIFACT_BYTES`, so a package with enough published releases to pass it cannot be looked up at
863
+ all. It comes back as a `failed` verdict naming the limit, which is this reader's bound and not a
864
+ statement about the package.
865
+
866
+ A status that carries no representation — a `204` or a `205` — is a `failed` verdict naming the
867
+ status, never a `found` answer holding no bytes. A genuinely empty file arrives as a `200` and does
868
+ read as found.
869
+
765
870
  Stage and swap a set of files yourself:
766
871
 
767
872
  ```ts
@@ -780,6 +885,9 @@ A transaction owns a private root beside the target on the same volume, so every
780
885
  rename rather than a copy. A failure part way through commit restores every destination it already
781
886
  promoted and removes every directory it created. No destination ever receives half-written bytes.
782
887
  It is not a journal: a process killed between two promotions leaves a mixed target.
888
+ The transaction binds each directory's location rather than its lifetime, so an ancestor swapped
889
+ for another path, a file, a symlink, or nothing is refused, and one deleted and recreated in place
890
+ may not be.
783
891
 
784
892
  Narrow a refusal by its code:
785
893
 
@@ -793,9 +901,16 @@ try {
793
901
  }
794
902
  ```
795
903
 
796
- `INVALID` is off-contract input, `BLOCKED` is a refused blueprint recorded on a compile stage,
797
- `TARGET` is a destination that is not what the caller's observation said it was, `WRITE` is a
798
- mutation that could not be completed, and `FETCH` is an upstream read that produced no answer.
904
+ `INVALID` is off-contract input, `BLOCKED` is a refused blueprint, `TARGET` is a destination that is
905
+ not what the caller's observation said it was, `WRITE` is a mutation that could not be completed, and
906
+ `FETCH` is an upstream read that produced no answer.
907
+
908
+ `BLOCKED` covers both refusals a blueprint can meet, because they are one fact — this blueprint will
909
+ not be built — and the questions say which. The compiler answers its refusal rather than throwing it:
910
+ the gate fails closed and records `BLOCKED` on its stage, so a caller reads that refusal from the
911
+ value it asked for. A verb that creates a workspace throws it, because it chose the shape and has
912
+ nothing to hand back. A blocking question closed the gate; a non-blocking one is a shape the package
913
+ can describe and declines to create.
799
914
 
800
915
  Every entity publishes an emitter. The compiler emits `compile`, `audit`, `block`, `error`, and
801
916
  `destroy`; the materializer emits `write`, `remove`, `finish`, `error`, and `destroy`; the upstream
@@ -804,13 +919,53 @@ are thrown, so an observer sees a refusal even where the caller catches it.
804
919
 
805
920
  ## Limits
806
921
 
807
- Two things a reader will look for and not find.
922
+ Six things a reader will look for and not find.
923
+
924
+ **A code fence in this guide is unverified.** [`tests/guides.test.ts`](../tests/guides.test.ts)
925
+ proves that every fence imports only real exports of the two barrels, and that every backticked name
926
+ in this file resolves to one. It neither runs a fence nor typechecks one, so a trailing `// value`
927
+ comment inside a fence states what this guide claims rather than what the build answered. The
928
+ verdicts that are measured are the ones a consumer hovers:
929
+ [`tests/integration.test.ts`](../tests/integration.test.ts) drives every `@example` the built
930
+ declarations print against the installed package, scores each verdict it can read as a value, and
931
+ names exactly the ones it cannot. Fences are not added to that instrument, because most of them
932
+ cannot be run: several declare an ambient value that has no runtime, and several write to a
933
+ directory or read the network, so executing them would be a mutation rather than a check.
934
+
935
+ **The library does not enforce the creating verb's policy.** `new` refuses a blueprint carrying any
936
+ question, and `materialize` writes any plan into any vacant target. A workspace of several published
937
+ `src` environments without `core` is therefore constructible, compilable, and writable through the
938
+ library, and its manifest names a `core` build the workspace never runs — which is exactly what the
939
+ advisory said. The refusal lives in the verb that chose the shape because that verb is the only one
940
+ holding the advice: `compile` returns `questions` beside `plan`, and `materialize` receives the plan
941
+ alone, so it has nothing to refuse on. The Compile section states the rule a library caller applies
942
+ in its place.
943
+
944
+ **`isPath` does not prove host portability.** It proves bounded target-relative syntax and rejects
945
+ traversal, separators, controls, and reserved syntax characters. It deliberately admits host-specific
946
+ segment spellings such as a Windows device name, a trailing dot or space, and a segment beyond a
947
+ filesystem's byte ceiling. The compiler emits none of those names. A caller-supplied plan may carry
948
+ one, and the writer reports the host's refusal rather than treating logical path syntax as a promise
949
+ that every filesystem can create it. `isFilesystemPath` is the separate server guard for target and
950
+ vendored-root locations on the host. Both `/` and `\` are separators to every reading in this
951
+ package, on every host, including the server's reading of a raw symbolic-link target. A POSIX
952
+ filename that legally contains a backslash — `weird\..\name` — is therefore refused as three
953
+ segments rather than admitted as one name. That is one separator law with a conservative side, not a
954
+ host-dependent second one.
808
955
 
809
956
  **Scaffold emits no styles axis.** `SRC_MATRIX` is exactly `core`, `browser`, and `server`, and
810
957
  `Blueprint` carries no styles field. A workspace that needs `src/styles/` adds the directory, its
811
958
  configuration, and its Vitest project by hand. `.claude/rules/workspace.md` describes styles as an
812
959
  environment because the fleet has one; scaffold simply does not generate it.
813
960
 
961
+ **No host path is normalized before it is guarded.** `isFilesystemPath` refuses an empty segment, so
962
+ `packages//router` is off contract. A trailing separator does not produce one: it terminates a
963
+ directory rather than opening a segment, and every Node path API reads `./packages/router/` and
964
+ `./packages/router` as one location, so both are admitted. Every server entry point and the
965
+ `--target` option guard the text they were handed and resolve it afterwards, so a directory taken
966
+ from a shell completion arrives carrying the separator the shell appended and names the directory it
967
+ appears to name.
968
+
814
969
  **A generated workspace has empty barrels and no starter entity.** Every emitted `index.ts` exports
815
970
  nothing. This is deliberate: a generated sample entity is repeatedly mistaken for real
816
971
  implementation. What a consumer does first is write the module's `types.ts`, then the
@@ -463,22 +463,22 @@
463
463
  {
464
464
  "storage": "scripts/codex.sh",
465
465
  "destination": "scripts/codex.sh",
466
- "executable": false
466
+ "executable": true
467
467
  },
468
468
  {
469
469
  "storage": "scripts/cursor.sh",
470
470
  "destination": "scripts/cursor.sh",
471
- "executable": false
471
+ "executable": true
472
472
  },
473
473
  {
474
474
  "storage": "scripts/deps.sh",
475
475
  "destination": "scripts/deps.sh",
476
- "executable": false
476
+ "executable": true
477
477
  },
478
478
  {
479
479
  "storage": "scripts/ollama.sh",
480
480
  "destination": "scripts/ollama.sh",
481
- "executable": false
481
+ "executable": true
482
482
  },
483
483
  {
484
484
  "storage": "tests/config.test.ts",
@@ -531,5 +531,5 @@
531
531
  ".codex/agents",
532
532
  ".cursor/rules"
533
533
  ],
534
- "digest": "b852817ada9fa3b30ce2ee47c7bf786e46dfbc1c5531e4d208b0bf8edcc2482c"
534
+ "digest": "ae9a32c599f4ca43278496001c659731504b3e669252ca231aaaf1c61d84810c"
535
535
  }
@@ -110,6 +110,12 @@ describe('root configuration', () => {
110
110
  })
111
111
  }
112
112
  expected.set('probe', { include: 'tmp/probe/**/*.test.ts', setup: ['./tests/setup.ts'] })
113
+ // A row that is a configuration rather than a factory. A workspace with a
114
+ // browser application emits one, because that factory refuses overrides and
115
+ // so is not a value Vitest may call. It is required here, in a workspace that
116
+ // has no browser application, so this proof exercises that resolution
117
+ // wherever it runs instead of only where the shape happens to occur.
118
+ expected.set('concrete', { include: 'tests/concrete.test.ts', setup: ['./tests/setup.ts'] })
113
119
 
114
120
  const projects = configuration.test?.projects
115
121
  if (!Array.isArray(projects)) throw new Error('The root configuration carries no projects')
@@ -128,7 +134,14 @@ describe('root configuration', () => {
128
134
  'name',
129
135
  { value: 'control' },
130
136
  )
131
- const controlled = projects.concat(control)
137
+ const concrete = {
138
+ test: {
139
+ name: { label: 'concrete' },
140
+ include: ['tests/concrete.test.ts'],
141
+ setupFiles: ['./tests/setup.ts'],
142
+ },
143
+ }
144
+ const controlled = projects.concat(control, concrete)
132
145
  const configured = new Map<
133
146
  string,
134
147
  { readonly include: string; readonly setup: readonly string[] }
@@ -137,13 +150,23 @@ describe('root configuration', () => {
137
150
  const factoryName = requiredLabel.replace(/:([a-z])/gu, (_match, letter: string) =>
138
151
  letter.toUpperCase(),
139
152
  )
140
- const factory = controlled.find(
141
- (candidate) => typeof candidate === 'function' && candidate.name === factoryName,
142
- )
143
- if (typeof factory !== 'function') {
144
- throw new Error(`${requiredLabel} has no project factory`)
153
+ // A row is either the factory named for the project or the configuration
154
+ // that project resolves to, and a required project is found as whichever
155
+ // it is. Only the required row is read, so an extra factory is still
156
+ // selected by name and never called.
157
+ const row = controlled.find((candidate) => {
158
+ if (typeof candidate === 'function') return candidate.name === factoryName
159
+ if (typeof candidate !== 'object' || candidate === null) return false
160
+ const block: unknown = Object.getOwnPropertyDescriptor(candidate, 'test')?.value
161
+ if (typeof block !== 'object' || block === null) return false
162
+ const named: unknown = Object.getOwnPropertyDescriptor(block, 'name')?.value
163
+ if (typeof named !== 'object' || named === null) return false
164
+ return Object.getOwnPropertyDescriptor(named, 'label')?.value === requiredLabel
165
+ })
166
+ if (row === undefined) {
167
+ throw new Error(`${requiredLabel} has no project factory or configuration`)
145
168
  }
146
- const project: unknown = Reflect.apply(factory, undefined, [])
169
+ const project: unknown = typeof row === 'function' ? Reflect.apply(row, undefined, []) : row
147
170
  if (typeof project !== 'object' || project === null) {
148
171
  throw new Error('A project factory returned no configuration')
149
172
  }