@orkestrel/scaffold 0.0.25 → 0.0.27

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`. |
@@ -166,6 +172,7 @@ Exported from `@orkestrel/scaffold`, and reachable from
166
172
  | --------------------------- | -------- | ----------------------------------------------------------------------------- |
167
173
  | `artifactToHex` | function | Project an artifact to the exact bytes it claims, as hexadecimal. |
168
174
  | `bytesToHex` | function | Encode bytes as exact lowercase hexadecimal text. |
175
+ | `catalogToLayers` | function | Project a catalog into the layers it publishes in. |
169
176
  | `cloneValue` | function | Snapshot an untrusted value into exact JSON data the caller owns. |
170
177
  | `compareVersions` | function | Compare two versions by their numeric components. |
171
178
  | `computeBytes` | function | Count the UTF-8 bytes text encodes to. |
@@ -176,6 +183,7 @@ Exported from `@orkestrel/scaffold`, and reachable from
176
183
  | `inferGroup` | function | Infer the `Group` a path belongs to. |
177
184
  | `manifestToDependencies` | function | Project a package manifest's text to the `@orkestrel/*` packages it declares. |
178
185
  | `manifestToName` | function | Project a package manifest's text to its own name. |
186
+ | `matchesDriftReachability` | function | Test whether `inferDrift` could have produced a finding for an ownership. |
179
187
  | `matchesEngines` | function | Test whether a declared engines floor is at or above the supported minimum. |
180
188
  | `matchesOrchestrationPath` | function | Test whether a path instructs or wires an agent rather than the toolchain. |
181
189
  | `matchesRange` | function | Test whether a declared range already admits a published version. |
@@ -313,6 +321,7 @@ Exported from `@orkestrel/scaffold/server`, and reachable from
313
321
  | `listDirectories` | function | List a directory's descendant directories as sorted root-relative paths. |
314
322
  | `listFiles` | function | List a directory's files as sorted root-relative paths. |
315
323
  | `matchesAnchor` | function | Test whether a captured directory is still the same directory. |
324
+ | `matchesExecutablePath` | function | Test whether a vendored path is one a target receives executable. |
316
325
  | `matchesExpectation` | function | Test whether a destination still holds what was captured of it. |
317
326
  | `matchesGitPath` | function | Test whether a path addresses a target's own repository metadata. |
318
327
  | `matchesMissingPath` | function | Test whether a caught filesystem error reports an absent path. |
@@ -388,7 +397,7 @@ no interface and is documented directly.
388
397
  | Method | Summary |
389
398
  | ----------- | ---------------------------------------------------------------------------------- |
390
399
  | `write` | Stage one text file. |
391
- | `copy` | Stage one byte-for-byte copy of a file that already exists on this host. |
400
+ | `copy` | Stage one byte-for-byte copy in executable or non-executable destination mode. |
392
401
  | `directory` | Establish one directory inside the target, one segment at a time. |
393
402
  | `remove` | Mark one file for deletion at commit. |
394
403
  | `commit` | Promote every staged file and take every marked file, or roll the whole call back. |
@@ -510,7 +519,7 @@ standard error, so a piped value is never polluted.
510
519
  | Verb | Value |
511
520
  | ----------- | -------------------------------------------------------------------------- |
512
521
  | `new` | `MaterializeResult` — `target`, `written`, `skipped`, `removed` |
513
- | `audit` | `Audit` — `findings` and `questions` |
522
+ | `audit` | `Audit` — `findings` and `questions`; planned findings carry `ownership` |
514
523
  | `repair` | `MaterializeResult` plus `audit`, the terminal audit taken after the write |
515
524
  | `catalog` | `MaterializeResult` plus `entries`, `mirrors`, and `dropped` |
516
525
  | `overwrite` | The `catalog` value plus `audit`, `releases`, and `note` on a partial run |
@@ -543,10 +552,24 @@ The two axes are independent, so a library-only, an application-only, and a mixe
543
552
  first class. `dependencies` and `peers` are runtime `@orkestrel/*` packages; `extras` are
544
553
  development dependencies and may carry any valid npm name.
545
554
 
555
+ One published environment owns the package root directly. Several published environments require
556
+ `core`, which owns that root while each other environment keeps its subpath. A multi-environment
557
+ `src` selection without `core` therefore emits entry fields naming a `core` build the workspace
558
+ never runs. The gate reports that as a non-blocking `src` question rather than refusing the compile,
559
+ because the shape is chosen once and read afterwards: `new` refuses the advisory, while `audit` and
560
+ `repair` need the plan to describe and restore a target that already has that shape. A library
561
+ caller creating a workspace holds the same refusal, and the Compile section below states it.
562
+
546
563
  `bin`, `integration`, `services`, `global`, and `showcase` are structural facts. Each is set only
547
564
  when the workspace physically ships the directory or exact-case file that defines it, never because
548
565
  of the workspace's name and never because a sibling fact is set.
549
566
 
567
+ An axis-dependent structural fact projects only when its required axis exists. `integration`
568
+ projects a published `src`, and `showcase` projects the browser `app` environment. When that axis is
569
+ absent the flag adds no artifact, configuration, script, or dependency, and the gate reports a
570
+ non-blocking question on that field so the caller who set it learns it emitted nothing. The same
571
+ rule applies to both facts.
572
+
550
573
  `createBlueprint` enforces shape only. Whether the name is a name, the version a version, and the
551
574
  axis combination one this package can generate are the gate's laws, and the gate answers them with
552
575
  questions. A blueprint the gate will refuse is still constructible, so one law lives in one place.
@@ -577,12 +600,40 @@ refused blueprint is answered rather than raised, so a caller reads the refusal
577
600
  asked for. Each stage records its input and its output, a failed stage records the coded reason
578
601
  beside them, and the stages after a failed one never run.
579
602
 
603
+ A plan says the blueprint can be built. It does not say the blueprint should be created. Every
604
+ question beside the plan is advice the compile could not settle, and the caller that chose the shape
605
+ is the one that answers it. So `new` refuses on any question, blocking or not, before it writes,
606
+ while `audit` and `repair` carry the same questions through, because a target that already has that
607
+ shape still has to be described and restored.
608
+
609
+ A library caller creating a fresh workspace applies `new`'s rule itself:
610
+
611
+ ```ts
612
+ import { createBlueprint, createCompiler } from '@orkestrel/scaffold'
613
+
614
+ const compiler = createCompiler()
615
+ const scaffolding = compiler.compile(createBlueprint('router', { src: ['browser', 'server'] }))
616
+
617
+ scaffolding.plan === undefined || scaffolding.questions.length > 0 // true — do not write this shape
618
+ compiler.destroy()
619
+ ```
620
+
621
+ `materialize` does not apply that rule for you, and it could not: `compile` returns `questions`
622
+ beside `plan`, and the writer receives the plan alone. It refuses what only a writer can see — a
623
+ target that is not vacant — and writes the plan it is given otherwise. Choosing a shape is a policy
624
+ about which workspace to want, the plan has already answered whether that workspace can be built,
625
+ and the questions are where the package says what it thinks of the choice. That is the same line
626
+ `createBlueprint` draws when it constructs a blueprint the gate will refuse: one law lives in one
627
+ place, and the caller that picked the shape is the one holding it.
628
+
580
629
  Off-contract input is different. A value that is not the exact shape raises `ScaffoldError` coded
581
630
  `INVALID`, because it is not a question anyone can answer. Both entry points snapshot the caller's
582
631
  value first and then guard the snapshot, so a property backed by an accessor is refused rather than
583
632
  read.
584
633
 
585
- Overrides replace a drafted artifact's content whole. An override that matches no planned artifact,
634
+ Overrides replace a drafted artifact's content whole. The gate checks each override against the
635
+ blueprint's full draft before a group selection narrows the returned plan, so an override outside a
636
+ selected group does not block that compile. An override that matches no artifact in the full draft,
586
637
  that targets a host-origin artifact, or that targets the manifest is a blocking question rather
587
638
  than a silent no-op.
588
639
 
@@ -627,7 +678,19 @@ selected by their defining paths, as `guides` is. A workspace that needs other l
627
678
  must keep those edits outside a content-owned file; `repair` restores that file to the canonical
628
679
  project set.
629
680
 
630
- An audit reports one `Finding` per compared path.
681
+ An audit reports one `Finding` per planned path, followed by any foreign path beneath the groups
682
+ the plan covers. Every planned finding carries its artifact's `ownership`. A foreign finding has
683
+ no ownership because no artifact was planned for its path. `Ownership` says what scaffold claims at
684
+ a path, not what one run did there. Counting planned findings by `content`, `presence`, and `birth`
685
+ therefore says what audit is entitled to compare and stays the same against a vacant target and a
686
+ repaired one. What one run compared comes from `ownership`, `drift`, and `observed` together. A
687
+ content-owned finding carrying `observed` had its bytes compared. A content-owned `missing` finding
688
+ and every presence-owned finding were decided by existence alone. A birth-owned finding was not
689
+ examined. The foreign findings are exactly the ones no ownership accounts for. The audit stores no
690
+ aggregate tally.
691
+
692
+ The plan and snapshot are each bounded at `MAX_COLLECTION_ITEMS`. An audit may therefore carry one
693
+ finding per planned artifact plus one per unplanned snapshot path, up to `MAX_AUDIT_FINDINGS`.
631
694
 
632
695
  | `Drift` | Means |
633
696
  | --------- | ------------------------------------- |
@@ -642,6 +705,65 @@ removes a foreign file each fail when the destination no longer matches what the
642
705
  The requirement sits in the type rather than in prose, because a deletion that cannot bind to what
643
706
  the audit showed is the one thing the destructive verb must never do.
644
707
 
708
+ The shape a `Finding` admits is wider than the set an audit produces. Which combinations of
709
+ `ownership`, `drift`, and `observed` a real comparison reaches is `inferDrift`'s law — birth is
710
+ always aligned, presence compares existence only, and bytes are recorded only where they were
711
+ read — so the shape admits a birth-owned path reported stale, which no audit produces. That is
712
+ deliberate: restating the comparison's case analysis in the type would be a second copy of it, able
713
+ to disagree with the one that decides. `isFinding` proves the shape a reader may destructure and
714
+ nothing about whether the verdict is one an audit could have reached. `repair` and `remove`
715
+ re-derive every verdict themselves and act only on what they derived, so a verdict the comparison
716
+ could not have produced is refused by name rather than acted on.
717
+
718
+ That shape is versioned, and the guard runs at runtime. `repair` and `remove` guard the whole audit
719
+ before reading any of it, so an audit persisted or built against an earlier version of this package
720
+ is refused with a coded `INVALID` failure rather than accepted and partly understood. A planned
721
+ finding carries `ownership`, which findings made before that field existed do not. `remove` acts
722
+ only on foreign findings, which never carried ownership, but the guard reads every finding, so one
723
+ older planned finding refuses that call too. Take a fresh audit rather than replaying a stored one:
724
+ a stored audit records what a target looked like then, and both verbs bind their writes to what a
725
+ target holds now. The refusal is deliberate at `0.0.x` and there is no migration path.
726
+
727
+ ## Fleet catalog
728
+
729
+ `catalog` rewrites one marker-bounded region in `CATALOG_AGENT_PATH` and nothing else in that file.
730
+ The region holds a table with four columns:
731
+
732
+ | Column | Content |
733
+ | ---------------------- | -------------------------------------------------------------------------- |
734
+ | `Package` | The published package name |
735
+ | `Version` | The registry's `dist-tags.latest`, or the cause when the lookup found none |
736
+ | `Layer` | The publish round the edges place the package in, as `L0`, `L1`, … |
737
+ | `Runtime dependencies` | Each declared runtime edge, as name and range |
738
+
739
+ Both edge-bearing columns come from the same abbreviated packument the version came from, so a
740
+ catalog costs one request per package and no more. Only `dependencies` is read. `devDependencies`
741
+ reaches no consumer of the published package, so it constrains nothing about publish order, and
742
+ reading it would place packages in rounds that do not exist.
743
+
744
+ The layer is not stored on a row. `catalogToLayers` derives it from the rows' own edges, in the same
745
+ call that writes them, so the two cannot disagree:
746
+
747
+ ```ts
748
+ import { catalogToLayers } from '@orkestrel/scaffold'
749
+
750
+ const layers = catalogToLayers(entries)
751
+ layers[0] // the names that depend on nothing else in the fleet
752
+ ```
753
+
754
+ An edge counts only when it names a package this catalog publishes. An edge leaving the fleet and an
755
+ edge to a row that found no version each constrain nothing, so neither holds its dependent back.
756
+
757
+ The order is load-bearing because these packages are `0.0.x`, where a caret pins one exact release.
758
+ A dependent sees a new dependency version only after the dependent re-pins and republishes, so
759
+ publishing a dependent before its dependency leaves the dependent pinned to the older release. Two
760
+ ranges that disagree install two copies of one package, and the compiler reads those copies as two
761
+ distinct types.
762
+
763
+ A cycle cannot be published in rounds. `catalogToLayers` omits its members rather than placing them
764
+ in an order that would be wrong, and their rows carry no layer cell. An absent name is the report:
765
+ compare the returned names against the catalog to find one.
766
+
645
767
  ## Vendored data root
646
768
 
647
769
  The vendored data root is the shared file set, staged into the published package as plain data. It
@@ -744,6 +866,19 @@ result.written // every path created
744
866
  materializer.destroy()
745
867
  ```
746
868
 
869
+ `resolveContainedPath` refuses a lexical escape, a physical link out of the root, and a dangling
870
+ link whose raw target contains a `..` segment. It returns the lexical join of `root` and `path` — an
871
+ absolute path under `root`, which is what its shipped example prints — after checking the namespace,
872
+ not an open filesystem handle. Its contract therefore excludes a concurrent rename or link swap
873
+ during the check or before the caller finishes using that path. A caller that admits hostile
874
+ concurrent namespace mutation needs a handle-bound operation instead.
875
+
876
+ `resolveRealPath` answers the caller's own text collapsed lexically, then resolved through every link
877
+ in what survives that collapse. A `..` the caller wrote cancels the segment before it as text, so
878
+ `<root>/hop/..` answers `<root>` even where `hop` links elsewhere, rather than the directory holding
879
+ what `hop` points at. The collapse only ever shortens the path, so nothing reaches outside it this
880
+ way; the answer is a lexical location resolved through links, not a physical one.
881
+
747
882
  Read the registry and the guide host:
748
883
 
749
884
  ```ts
@@ -762,6 +897,17 @@ verdict carrying its cause rather than thrown, so one unreachable package never
762
897
  rest of the answer. The organization package list is the exception, because without it there is no
763
898
  fleet to report.
764
899
 
900
+ Both bounds count decoded bytes, and a version lookup asks the registry for the abbreviated
901
+ packument — `dist-tags` and a trimmed version map, rather than the full per-version metadata no
902
+ verdict reads. That is the smallest form the registry publishes, and `limit` is capped at
903
+ `MAX_ARTIFACT_BYTES`, so a package with enough published releases to pass it cannot be looked up at
904
+ all. It comes back as a `failed` verdict naming the limit, which is this reader's bound and not a
905
+ statement about the package.
906
+
907
+ A status that carries no representation — a `204` or a `205` — is a `failed` verdict naming the
908
+ status, never a `found` answer holding no bytes. A genuinely empty file arrives as a `200` and does
909
+ read as found.
910
+
765
911
  Stage and swap a set of files yourself:
766
912
 
767
913
  ```ts
@@ -780,6 +926,9 @@ A transaction owns a private root beside the target on the same volume, so every
780
926
  rename rather than a copy. A failure part way through commit restores every destination it already
781
927
  promoted and removes every directory it created. No destination ever receives half-written bytes.
782
928
  It is not a journal: a process killed between two promotions leaves a mixed target.
929
+ The transaction binds each directory's location rather than its lifetime, so an ancestor swapped
930
+ for another path, a file, a symlink, or nothing is refused, and one deleted and recreated in place
931
+ may not be.
783
932
 
784
933
  Narrow a refusal by its code:
785
934
 
@@ -793,9 +942,16 @@ try {
793
942
  }
794
943
  ```
795
944
 
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.
945
+ `INVALID` is off-contract input, `BLOCKED` is a refused blueprint, `TARGET` is a destination that is
946
+ not what the caller's observation said it was, `WRITE` is a mutation that could not be completed, and
947
+ `FETCH` is an upstream read that produced no answer.
948
+
949
+ `BLOCKED` covers both refusals a blueprint can meet, because they are one fact — this blueprint will
950
+ not be built — and the questions say which. The compiler answers its refusal rather than throwing it:
951
+ the gate fails closed and records `BLOCKED` on its stage, so a caller reads that refusal from the
952
+ value it asked for. A verb that creates a workspace throws it, because it chose the shape and has
953
+ nothing to hand back. A blocking question closed the gate; a non-blocking one is a shape the package
954
+ can describe and declines to create.
799
955
 
800
956
  Every entity publishes an emitter. The compiler emits `compile`, `audit`, `block`, `error`, and
801
957
  `destroy`; the materializer emits `write`, `remove`, `finish`, `error`, and `destroy`; the upstream
@@ -804,13 +960,53 @@ are thrown, so an observer sees a refusal even where the caller catches it.
804
960
 
805
961
  ## Limits
806
962
 
807
- Two things a reader will look for and not find.
963
+ Six things a reader will look for and not find.
964
+
965
+ **A code fence in this guide is unverified.** [`tests/guides.test.ts`](../tests/guides.test.ts)
966
+ proves that every fence imports only real exports of the two barrels, and that every backticked name
967
+ in this file resolves to one. It neither runs a fence nor typechecks one, so a trailing `// value`
968
+ comment inside a fence states what this guide claims rather than what the build answered. The
969
+ verdicts that are measured are the ones a consumer hovers:
970
+ [`tests/integration.test.ts`](../tests/integration.test.ts) drives every `@example` the built
971
+ declarations print against the installed package, scores each verdict it can read as a value, and
972
+ names exactly the ones it cannot. Fences are not added to that instrument, because most of them
973
+ cannot be run: several declare an ambient value that has no runtime, and several write to a
974
+ directory or read the network, so executing them would be a mutation rather than a check.
975
+
976
+ **The library does not enforce the creating verb's policy.** `new` refuses a blueprint carrying any
977
+ question, and `materialize` writes any plan into any vacant target. A workspace of several published
978
+ `src` environments without `core` is therefore constructible, compilable, and writable through the
979
+ library, and its manifest names a `core` build the workspace never runs — which is exactly what the
980
+ advisory said. The refusal lives in the verb that chose the shape because that verb is the only one
981
+ holding the advice: `compile` returns `questions` beside `plan`, and `materialize` receives the plan
982
+ alone, so it has nothing to refuse on. The Compile section states the rule a library caller applies
983
+ in its place.
984
+
985
+ **`isPath` does not prove host portability.** It proves bounded target-relative syntax and rejects
986
+ traversal, separators, controls, and reserved syntax characters. It deliberately admits host-specific
987
+ segment spellings such as a Windows device name, a trailing dot or space, and a segment beyond a
988
+ filesystem's byte ceiling. The compiler emits none of those names. A caller-supplied plan may carry
989
+ one, and the writer reports the host's refusal rather than treating logical path syntax as a promise
990
+ that every filesystem can create it. `isFilesystemPath` is the separate server guard for target and
991
+ vendored-root locations on the host. Both `/` and `\` are separators to every reading in this
992
+ package, on every host, including the server's reading of a raw symbolic-link target. A POSIX
993
+ filename that legally contains a backslash — `weird\..\name` — is therefore refused as three
994
+ segments rather than admitted as one name. That is one separator law with a conservative side, not a
995
+ host-dependent second one.
808
996
 
809
997
  **Scaffold emits no styles axis.** `SRC_MATRIX` is exactly `core`, `browser`, and `server`, and
810
998
  `Blueprint` carries no styles field. A workspace that needs `src/styles/` adds the directory, its
811
999
  configuration, and its Vitest project by hand. `.claude/rules/workspace.md` describes styles as an
812
1000
  environment because the fleet has one; scaffold simply does not generate it.
813
1001
 
1002
+ **No host path is normalized before it is guarded.** `isFilesystemPath` refuses an empty segment, so
1003
+ `packages//router` is off contract. A trailing separator does not produce one: it terminates a
1004
+ directory rather than opening a segment, and every Node path API reads `./packages/router/` and
1005
+ `./packages/router` as one location, so both are admitted. Every server entry point and the
1006
+ `--target` option guard the text they were handed and resolve it afterwards, so a directory taken
1007
+ from a shell completion arrives carrying the separator the shell appended and names the directory it
1008
+ appears to name.
1009
+
814
1010
  **A generated workspace has empty barrels and no starter entity.** Every emitted `index.ts` exports
815
1011
  nothing. This is deliberate: a generated sample entity is repeatedly mistaken for real
816
1012
  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
  }
File without changes
File without changes
File without changes
File without changes
@@ -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
  }