@sous-io/sous 0.2.26 → 0.2.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.
@@ -26,3 +26,4 @@
26
26
  - [0005: Follow-ups from the first project on the hand-off](adrs/0005-first-project-follow-ups.md)
27
27
  - [0006: `sous repo init` follows `sous-recipes`](adrs/0006-repo-init-model-home.md)
28
28
  - [0007: Frozen index entries and tolerant readers](adrs/0007-frozen-index-dependencies.md)
29
+ - [0009: One whole build, everywhere](adrs/0009-whole-build-everywhere.md)
@@ -93,10 +93,21 @@ and the command you want almost always. Takes `--dry-run`.
93
93
 
94
94
  - `--no-prune`, `--no-compile`: skip one half of the run.
95
95
  - `--rebuild`: ignore cached hashes and reprocess every output.
96
- - `--strict`: fail on any compilation error rather than reporting it and continuing.
96
+ - `--strict`: treat compile warnings as errors (a `.tpl.` file copied without being rendered, a git branch the
97
+ runtime context could not read).
97
98
  - `-w, --watch`: rebuild on every change to a source file, a config layer or a linked checkout.
98
99
 
99
- Before it compiles, a build lists each recipe this project uses that has a newer version within the range
100
+ Before it compiles, a build prepares the project's recipes: it seeds the core recipe, pins any subscription the
101
+ lockfile does not pin yet, restores whatever the store on this machine is missing, and checks upstream for the
102
+ repositories that ask for newer versions. Every command that builds (`init`, `build`, `launch`, `prune`,
103
+ `repo remove`, `repo unlink`, `subscription add`, `subscription remove`, `subscription update`) runs this same
104
+ build, so each one prints the same things.
105
+
106
+ A compile error (a missing or circular include, a template that fails to render, a file sous cannot read) fails
107
+ the build. Every target is still compiled and every error is listed, then the build exits `1`. An output whose
108
+ target had an error is not written, so the copy from the last good build stays in place.
109
+
110
+ A build also lists each recipe this project uses that has a newer version within the range
100
111
  declared for it, beside the version pinned. It moves no pin; only always-pull moves one. The build reads upstream
101
112
  for this at most once per freshness window (`store.freshnessSeconds`, five minutes by default), gives a
102
113
  repository three seconds to answer, and otherwise answers from the cached index without a word about the
@@ -106,18 +117,22 @@ Example: `sous build --rebuild`
106
117
 
107
118
  ### `sous compile`
108
119
  Compiles markdown templates into output files, and prunes nothing. Takes `--rebuild`, `--strict`, `--dry-run`
109
- and `-w, --watch`, each meaning what it means on `build`. Example: `sous compile --strict`
120
+ and `-w, --watch`, each meaning what it means on `build`, and exits `1` after a compile error the way `build`
121
+ does. It does not prepare the recipes first. Example: `sous compile --strict`
110
122
 
111
123
  ### `sous prune`
112
- Removes output files no longer in the current config. Takes `--dry-run` only. Example: `sous prune --dry-run`
124
+ Removes output files no longer in the current config. It prepares the recipes first, as `build` does, because
125
+ what counts as current depends on the recipes the lockfile pins. Takes `--dry-run` only. Example:
126
+ `sous prune --dry-run`
113
127
 
114
128
  ### `sous clear`
115
129
  Deletes every file and directory sous has written for the project, and asks first; `-f, --force` answers that
116
130
  confirmation ahead of time. Example: `sous clear --force`
117
131
 
118
132
  ### `sous launch TOOL...`
119
- Builds this project's outputs, then starts a coding agent configured under `tools` in its config. `--no-build`
120
- launches without building; `--continuous` restarts the agent whenever it exits.
133
+ Builds this project's outputs, then starts a coding agent configured under `tools` in its config. A build that
134
+ fails starts nothing and exits `1`. `--no-build` launches without building; `--continuous` restarts the agent
135
+ whenever it exits.
121
136
 
122
137
  Any argument `launch` does not recognize is forwarded to the tool; a flag that collides with one of sous's own
123
138
  goes after a bare `--`: `sous launch claude --resume`, `sous launch claude -- -c`.
@@ -447,8 +462,9 @@ Example: `sous vars ask --namespace workflow --var apiUrl`
447
462
 
448
463
  A command that succeeds exits `0`. A command line sous could not parse (a missing argument, an unknown flag, a
449
464
  value outside a flag's options) exits `2`. Every other failure exits `1`. A broken config halts sous rather than
450
- producing output built on a guess. Compilation is the one place sous reports a failure and carries on;
451
- `--strict` on `build` and `compile` turns those reports into a failed run.
465
+ producing output built on a guess. A build with a compile error carries on long enough to compile every target
466
+ and list every error, then exits `1`, leaving the last good copy of each output that had an error;
467
+ `--strict` on `build` and `compile` makes compile warnings fail the run too.
452
468
 
453
469
  A failure prints one error block, in plain language, and nothing else; a usage mistake gets the command's own
454
470
  help under it, on standard error. An error sous raises names the cause and the cure:
@@ -143,7 +143,7 @@ variables:
143
143
  ## Declare dependencies and co-subscriptions
144
144
 
145
145
  `depends` fetches and pins a recipe whose files stay OUT of a subscriber's output but are addressable from your
146
- own templates. `subscribes` is a co-subscription: its questions run and its files DO enter the output, which is
146
+ own templates, and from the templates of any project whose lockfile pins it. `subscribes` is a co-subscription: its questions run and its files DO enter the output, which is
147
147
  how a curated bundle is built. Both hold plain strings naming their target by where it lives.
148
148
 
149
149
  ```yaml
@@ -331,9 +331,26 @@ published identity and then the path inside it, on a line of its own:
331
331
  ```
332
332
 
333
333
  The `~` is required. A bare `@path` is always a relative path or a declared alias, so an include
334
- line can never quietly stop meaning a file on disk. Inside a recipe, `~<namespace>` resolves only
335
- against that recipe's own `depends` and `subscribes` at the versions the lockfile pins, and the
336
- path after the recipe name may not be absolute or hold a `.` or `..` segment.
334
+ line can never quietly stop meaning a file on disk. In your own templates, `~<namespace>`
335
+ resolves against every recipe the lockfile pins, whatever brought it in: a recipe you subscribe
336
+ to, a member of a set you subscribe to, or a library another recipe depends on. Inside a recipe,
337
+ it resolves only against that recipe's own `depends` and `subscribes` at the versions the
338
+ lockfile pins. The path after the recipe name may not be absolute or hold a `.` or `..` segment.
339
+
340
+ An include naming a recipe the lockfile does not pin fails the build. When sous can tell what
341
+ used to bring the recipe in (a switched-off subscription, or an earlier version of a set that
342
+ listed it), the error names it:
343
+
344
+ ```text
345
+ Error: Include not found: @~workflow/member/memories/member.md
346
+ in file: .sous/prompts/AGENTS.md
347
+ namespace: workflow
348
+ recipe: workflow/member
349
+ The namespace "workflow" holds no recipe named "workflow/member".
350
+ Version 1.0.0 of "omakase/house" brought "workflow/member" in, but the version this project
351
+ pins, 1.1.0, does not, so the lockfile no longer pins it.
352
+ Subscribe to "workflow/member" directly to keep including it, or remove the include.
353
+ ```
337
354
 
338
355
  ## Where to go next
339
356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -148,8 +148,9 @@ written `@~<namespace>/<recipe>/<path inside that recipe>`:
148
148
 
149
149
  Scoping is deliberate. A file inside a recipe may address that recipe itself plus the
150
150
  recipes it declares under `depends` or `subscribes`; a file in the project's own templates
151
- may address the project's subscriptions. Anything else is an error naming what was missing,
152
- so a reference can never quietly pick up a recipe nobody asked for.
151
+ may address every recipe the project's lockfile pins, whatever brought it in. Anything else
152
+ is an error naming what was missing, so a reference can never quietly pick up a recipe
153
+ nobody asked for.
153
154
 
154
155
  Built-in **aliases** are reserved, always begin with `~`, and are consulted before recipe
155
156
  namespaces. There is exactly one:
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.26
14
+ version: 0.2.27
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -1,23 +1,14 @@
1
1
  import { Flags } from "@oclif/core";
2
2
  import { BaseCommand } from "../base-command.js";
3
- import { BuildService } from "../lib/build-service.js";
3
+ import { runProjectBuild } from "../lib/build-service.js";
4
4
  import { PidService } from "../lib/pid-service.js";
5
5
  import { resolveRootScope } from "../lib/settings.js";
6
- import { describeLinkedRepos } from "../lib/repos/links.js";
7
6
  import { resolveStoreSettings } from "../lib/repos/store/settings.js";
8
- import { prepareRepositoriesForBuild } from "../lib/build-preparation.js";
9
7
  import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
10
8
  import { buildReloadWatchConfig, startConfigReloadWatch } from "../lib/watch-loop.js";
11
9
  import type { WatchHandle } from "../lib/watch-service.js";
12
10
  import { WatchService } from "../lib/watch-service.js";
13
- import {
14
- footer,
15
- heading,
16
- log,
17
- paragraph,
18
- showCommandVars,
19
- warning,
20
- } from "../utils/formatting.js";
11
+ import { log, paragraph, showCommandVars } from "../utils/formatting.js";
21
12
 
22
13
  export default class Build extends BaseCommand {
23
14
  static description =
@@ -49,7 +40,7 @@ export default class Build extends BaseCommand {
49
40
  default: false,
50
41
  }),
51
42
  strict: Flags.boolean({
52
- description: "Fail on any compilation error",
43
+ description: "Treat compile warnings as errors",
53
44
  default: false,
54
45
  }),
55
46
  watch: Flags.boolean({
@@ -71,37 +62,20 @@ export default class Build extends BaseCommand {
71
62
  "No Prune": flags["no-prune"],
72
63
  });
73
64
 
74
- // A linked repository is read from a working copy instead of a published
75
- // version, so it is announced every single time; a build that silently
76
- // produced something different would be far worse than a noisy one.
77
- const linked = describeLinkedRepos(this.configContext.sousDir);
78
- if (linked.length > 0) warning(linked.join("\n"));
79
-
80
- const repositories = subscriptionServiceFor({
81
- configContext: this.configContext,
82
- settings: this.settings,
83
- shellEnv: this.shellEnv,
84
- });
85
-
86
- if (!flags["dry-run"] && !flags["no-compile"]) {
87
- await prepareRepositoriesForBuild(repositories);
88
- }
89
-
90
- heading("Building");
91
-
92
65
  const buildOptions = {
93
66
  strict: flags.strict,
94
67
  rebuild: flags.rebuild,
95
68
  dryRun: flags["dry-run"],
96
69
  noCompile: flags["no-compile"],
97
70
  noPrune: flags["no-prune"],
98
- configContext: this.configContext,
99
71
  };
100
72
 
101
- const buildService = new BuildService();
102
- const success = await buildService.build(this.settings, buildOptions);
103
-
104
- footer();
73
+ const success = await runProjectBuild({
74
+ settings: this.settings,
75
+ configContext: this.configContext,
76
+ shellEnv: this.shellEnv,
77
+ options: buildOptions,
78
+ });
105
79
 
106
80
  if (!success && !flags.watch) {
107
81
  this.exit(1);
@@ -129,17 +103,23 @@ export default class Build extends BaseCommand {
129
103
 
130
104
  const watchService = new WatchService();
131
105
 
132
- // Reruns compile + prune with the command's current settings. Called for
106
+ // Reruns the build with the command's current settings. Called for
133
107
  // partial rebuilds (with the changed file) and, after a clean reload, for
134
- // full rebuilds. Owns the "Rebuilding" heading/footer.
108
+ // full rebuilds, which prepare the recipes again the way the first build did.
135
109
  const rebuild = async (changedFile?: string) => {
136
- heading("Rebuilding");
137
- await buildService.build(this.settings, {
138
- ...buildOptions,
139
- // --rebuild means full clean build on every trigger; skip partial optimisation
140
- changedFile: buildOptions.rebuild ? undefined : changedFile,
110
+ // A failed rebuild has already listed its errors, and the watch keeps
111
+ // running so the next save can fix them.
112
+ await runProjectBuild({
113
+ settings: this.settings,
114
+ configContext: this.configContext,
115
+ shellEnv: this.shellEnv,
116
+ heading: "Rebuilding",
117
+ options: {
118
+ ...buildOptions,
119
+ // --rebuild means full clean build on every trigger; skip partial optimisation
120
+ changedFile: buildOptions.rebuild ? undefined : changedFile,
121
+ },
141
122
  });
142
- footer();
143
123
  };
144
124
 
145
125
  const { handle, triggerFullRebuild } = startConfigReloadWatch({
@@ -154,6 +134,11 @@ export default class Build extends BaseCommand {
154
134
  // and a failure never breaks the watch; the last good answer stands.
155
135
  const pollSeconds = resolveStoreSettings(this.settings).watchPollSeconds;
156
136
  if (pollSeconds > 0) {
137
+ const repositories = subscriptionServiceFor({
138
+ configContext: this.configContext,
139
+ settings: this.settings,
140
+ shellEnv: this.shellEnv,
141
+ });
157
142
  const poll = setInterval(() => {
158
143
  void repositories
159
144
  .checkUpstream()
@@ -34,7 +34,7 @@ export default class Compile extends BaseCommand {
34
34
  static flags = {
35
35
  ...BaseCommand.baseFlags,
36
36
  strict: Flags.boolean({
37
- description: "Fail immediately on any error",
37
+ description: "Treat compile warnings as errors",
38
38
  default: false,
39
39
  }),
40
40
  rebuild: Flags.boolean({
@@ -113,7 +113,7 @@ export default class Compile extends BaseCommand {
113
113
 
114
114
  footer();
115
115
 
116
- if (!success && flags.strict && !flags.watch) {
116
+ if (!success && !flags.watch) {
117
117
  this.exit(1);
118
118
  }
119
119
 
@@ -17,8 +17,7 @@ import path from "node:path";
17
17
  import { Args, Flags } from "@oclif/core";
18
18
  import { confirm } from "@inquirer/prompts";
19
19
  import { BaseCommand } from "../base-command.js";
20
- import { prepareRepositoriesForBuild } from "../lib/build-preparation.js";
21
- import { buildProjectOutputs } from "../lib/build-service.js";
20
+ import { runProjectBuild } from "../lib/build-service.js";
22
21
  import {
23
22
  CONFIG_FILE_NAMES,
24
23
  SOUS_DIR_NAME,
@@ -28,7 +27,6 @@ import {
28
27
  } from "../lib/config-discovery.js";
29
28
  import { ConfigError } from "../lib/errors.js";
30
29
  import { nonInteractiveError } from "../lib/interactive.js";
31
- import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
32
30
  import {
33
31
  PROJECT_CONFIG_FORMATS,
34
32
  SOUS_PACKAGE_NAME,
@@ -45,7 +43,6 @@ import {
45
43
  blankLine,
46
44
  dryRunNotice,
47
45
  footer,
48
- heading,
49
46
  log,
50
47
  paragraph,
51
48
  section,
@@ -262,8 +259,8 @@ export default class Init extends BaseCommand {
262
259
  }
263
260
 
264
261
  /**
265
- * Adopts the config just written and builds the project with it, in the
266
- * same two steps `sous build` takes: the repositories are prepared (which
262
+ * Adopts the config just written and builds the project with it, through
263
+ * the same build `sous build` runs: the repositories are prepared (which
267
264
  * seeds the core recipe into the store and pins it in the lockfile), then
268
265
  * the outputs are compiled.
269
266
  *
@@ -274,17 +271,12 @@ export default class Init extends BaseCommand {
274
271
  resolveConfigFlag(configPath, this.configLocator.cwd, this.configLocator.confDirOverride)
275
272
  );
276
273
 
277
- await prepareRepositoriesForBuild(
278
- subscriptionServiceFor({
279
- configContext: this.configContext,
280
- settings: this.settings,
281
- shellEnv: this.shellEnv,
282
- })
283
- );
284
-
285
- heading("Building the project");
286
-
287
- const succeeded = await buildProjectOutputs(this.settings, this.configContext);
274
+ const succeeded = await runProjectBuild({
275
+ settings: this.settings,
276
+ configContext: this.configContext,
277
+ shellEnv: this.shellEnv,
278
+ heading: "Building the project",
279
+ });
288
280
 
289
281
  if (!succeeded) {
290
282
  throw new ConfigError(
@@ -2,7 +2,8 @@ import { Args, Flags } from "@oclif/core";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import { BaseCommand } from "../base-command.js";
5
- import { BuildService } from "../lib/build-service.js";
5
+ import { runProjectBuild } from "../lib/build-service.js";
6
+ import { ConfigError } from "../lib/errors.js";
6
7
  import { resolveRootScope, resolveTools } from "../lib/settings.js";
7
8
  import { displayError, footer, heading, showCommandVars } from "../utils/formatting.js";
8
9
 
@@ -85,16 +86,21 @@ export default class Launch extends BaseCommand {
85
86
  ...(passThroughArgs.length > 0 && { "Tool Args": passThroughArgs.join(" ") }),
86
87
  });
87
88
 
88
- const buildService = new BuildService();
89
-
90
89
  do {
91
- // Build step (unless --no-build)
90
+ // Build step (unless --no-build). A failed build stops here: the agent
91
+ // would otherwise start on outputs the build could not bring up to date.
92
92
  if (!flags["no-build"]) {
93
- heading("Building");
94
- await buildService.build(this.settings, {
93
+ const built = await runProjectBuild({
94
+ settings: this.settings,
95
95
  configContext: this.configContext,
96
+ shellEnv: this.shellEnv,
96
97
  });
97
- footer();
98
+ if (!built) {
99
+ throw new ConfigError(
100
+ `The build failed, so ${args.tool} was not started. Fix the errors listed ` +
101
+ `above, or pass --no-build to start it with the outputs as they are.`
102
+ );
103
+ }
98
104
  }
99
105
 
100
106
  // Config-defined args first, then pass-through, then promptFile content
@@ -1,7 +1,7 @@
1
1
  import { Flags } from "@oclif/core";
2
2
  import { BaseCommand } from "../base-command.js";
3
- import { BuildService, resolveStateFilePath } from "../lib/build-service.js";
4
- import { footer, heading, showCommandVars } from "../utils/formatting.js";
3
+ import { runProjectBuild } from "../lib/build-service.js";
4
+ import { showCommandVars } from "../utils/formatting.js";
5
5
 
6
6
  export default class Prune extends BaseCommand {
7
7
  static description = "Remove output files that are no longer in the current config";
@@ -22,19 +22,22 @@ export default class Prune extends BaseCommand {
22
22
  async run(): Promise<void> {
23
23
  const { flags } = await this.parse(Prune);
24
24
 
25
- const stateFilePath = resolveStateFilePath(this.settings, this.configContext);
26
-
27
25
  showCommandVars({
28
26
  Project: this.projectLabel,
29
27
  Config: this.configContext.configPath,
30
28
  "Dry Run": flags["dry-run"],
31
29
  });
32
30
 
33
- heading("Pruning");
34
-
35
- const buildService = new BuildService();
36
- await buildService.prune(this.settings, stateFilePath, flags["dry-run"], this.configContext);
37
-
38
- footer();
31
+ // Prune goes through the same build path as everything else, with the
32
+ // compile step switched off: what counts as current depends on the recipes
33
+ // the lockfile pins, so they are restored first, or a recipe missing from
34
+ // the store would have its outputs pruned as if it were gone.
35
+ await runProjectBuild({
36
+ settings: this.settings,
37
+ configContext: this.configContext,
38
+ shellEnv: this.shellEnv,
39
+ heading: "Pruning",
40
+ options: { noCompile: true, dryRun: flags["dry-run"] },
41
+ });
39
42
  }
40
43
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { Args, Flags } from "@oclif/core";
16
16
  import { BaseCommand } from "../../base-command.js";
17
- import { buildProjectOutputs } from "../../lib/build-service.js";
17
+ import { runProjectBuild } from "../../lib/build-service.js";
18
18
  import { ConfigError } from "../../lib/errors.js";
19
19
  import { resolveRootScope } from "../../lib/settings.js";
20
20
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
@@ -175,11 +175,12 @@ export default class RepoRemove extends BaseCommand {
175
175
  private async rebuildProject(name: string): Promise<void> {
176
176
  await this.reloadDiscoveredConfig();
177
177
 
178
- heading("Building the project");
179
-
180
- const succeeded = await buildProjectOutputs(this.settings, this.configContext);
181
-
182
- footer();
178
+ const succeeded = await runProjectBuild({
179
+ settings: this.settings,
180
+ configContext: this.configContext,
181
+ shellEnv: this.shellEnv,
182
+ heading: "Building the project",
183
+ });
183
184
 
184
185
  if (!succeeded) {
185
186
  throw new ConfigError(
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Args, Flags } from "@oclif/core";
4
4
  import { BaseCommand } from "../../base-command.js";
5
- import { buildProjectOutputs } from "../../lib/build-service.js";
5
+ import { runProjectBuild } from "../../lib/build-service.js";
6
6
  import { ConfigError, isConfigError } from "../../lib/errors.js";
7
7
  import {
8
8
  NonInteractiveError,
@@ -409,11 +409,12 @@ export default class RepoUnlink extends BaseCommand {
409
409
  private async rebuildProject(name: string): Promise<void> {
410
410
  await this.reloadDiscoveredConfig();
411
411
 
412
- heading("Building the project");
413
-
414
- const succeeded = await buildProjectOutputs(this.settings, this.configContext);
415
-
416
- footer();
412
+ const succeeded = await runProjectBuild({
413
+ settings: this.settings,
414
+ configContext: this.configContext,
415
+ shellEnv: this.shellEnv,
416
+ heading: "Building the project",
417
+ });
417
418
 
418
419
  if (!succeeded) {
419
420
  throw new ConfigError(
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { Args, Flags } from "@oclif/core";
26
26
  import { BaseCommand } from "../../base-command.js";
27
- import { buildProjectOutputs } from "../../lib/build-service.js";
27
+ import { runProjectBuild } from "../../lib/build-service.js";
28
28
  import { ConfigError } from "../../lib/errors.js";
29
29
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
30
30
  import { formatAskReport } from "../../lib/vars/ask.js";
@@ -267,11 +267,12 @@ export default class SubscriptionAdd extends BaseCommand {
267
267
  private async rebuildProject(key: string): Promise<void> {
268
268
  await this.reloadDiscoveredConfig();
269
269
 
270
- heading("Building the project");
271
-
272
- const succeeded = await buildProjectOutputs(this.settings, this.configContext);
273
-
274
- footer();
270
+ const succeeded = await runProjectBuild({
271
+ settings: this.settings,
272
+ configContext: this.configContext,
273
+ shellEnv: this.shellEnv,
274
+ heading: "Building the project",
275
+ });
275
276
 
276
277
  if (!succeeded) {
277
278
  throw new ConfigError(
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { Args, Flags } from "@oclif/core";
14
14
  import { BaseCommand } from "../../base-command.js";
15
- import { buildProjectOutputs } from "../../lib/build-service.js";
15
+ import { runProjectBuild } from "../../lib/build-service.js";
16
16
  import { ConfigError } from "../../lib/errors.js";
17
17
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
18
18
  import { renderTable, type TableColumn } from "../../utils/table.js";
@@ -163,11 +163,12 @@ export default class SubscriptionRemove extends BaseCommand {
163
163
  private async rebuildProject(key: string): Promise<void> {
164
164
  await this.reloadDiscoveredConfig();
165
165
 
166
- heading("Building the project");
167
-
168
- const succeeded = await buildProjectOutputs(this.settings, this.configContext);
169
-
170
- footer();
166
+ const succeeded = await runProjectBuild({
167
+ settings: this.settings,
168
+ configContext: this.configContext,
169
+ shellEnv: this.shellEnv,
170
+ heading: "Building the project",
171
+ });
171
172
 
172
173
  if (!succeeded) {
173
174
  throw new ConfigError(
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { Args, Flags } from "@oclif/core";
21
21
  import { BaseCommand } from "../../base-command.js";
22
- import { buildProjectOutputs } from "../../lib/build-service.js";
22
+ import { runProjectBuild } from "../../lib/build-service.js";
23
23
  import { ConfigError } from "../../lib/errors.js";
24
24
  import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
25
25
  import { describeUpdateScope } from "../../lib/repos/update-plan.js";
@@ -198,11 +198,12 @@ export default class SubscriptionUpdate extends BaseCommand {
198
198
  private async rebuildProject(): Promise<void> {
199
199
  await this.reloadDiscoveredConfig();
200
200
 
201
- heading("Building the project");
202
-
203
- const succeeded = await buildProjectOutputs(this.settings, this.configContext);
204
-
205
- footer();
201
+ const succeeded = await runProjectBuild({
202
+ settings: this.settings,
203
+ configContext: this.configContext,
204
+ shellEnv: this.shellEnv,
205
+ heading: "Building the project",
206
+ });
206
207
 
207
208
  if (!succeeded) {
208
209
  throw new ConfigError(
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * The step that runs before a project's outputs are compiled, and what it says
3
- * while it runs. `sous build` runs it on every build, and `sous init` runs it
4
- * for the first build of a project it has just set up; both must say the same
5
- * things about the same events, so the reporting lives here rather than in
6
- * either command.
3
+ * while it runs. `runProjectBuild` (`build-service.ts`) calls it, and every
4
+ * command that builds goes through that one function, so the step and its
5
+ * reporting are the same whichever command the user typed.
7
6
  */
8
7
 
9
8
  import type { SubscriptionService } from "./repos/subscription-service.js";
@@ -10,14 +10,16 @@ import { CompilationService, resolveOutputPath } from "./markdown-compiler.js";
10
10
  import type { CompilationConfig, CompilationTarget } from "./markdown-compiler.js";
11
11
  import { StateService } from "./state.js";
12
12
  import { isProtectedPath } from "./state.js";
13
- import { protectedRepoPaths } from "./repos/links.js";
13
+ import { describeLinkedRepos, protectedRepoPaths } from "./repos/links.js";
14
+ import { subscriptionServiceFor } from "./repos/subscription-service.js";
15
+ import { prepareRepositoriesForBuild } from "./build-preparation.js";
14
16
  import {
15
17
  noRecipeAnswers,
16
18
  resolveRecipeAnswers,
17
19
  unansweredWarning,
18
20
  type RecipeAnswers,
19
21
  } from "./vars/answers.js";
20
- import { log, warning } from "../utils/formatting.js";
22
+ import { footer, heading, log, warning } from "../utils/formatting.js";
21
23
 
22
24
  export type BuildOptions = {
23
25
  strict?: boolean;
@@ -437,22 +439,64 @@ export class BuildService {
437
439
  }
438
440
  }
439
441
 
442
+ /** What {@link runProjectBuild} is given. */
443
+ export type ProjectBuildInput = {
444
+ /** The merged project config, reloaded after any change a command made. */
445
+ settings: Settings;
446
+ /** Where the active config was discovered. */
447
+ configContext: ConfigContext;
448
+ /** The shell environment as it was before the env files were injected. */
449
+ shellEnv?: NodeJS.ProcessEnv;
450
+ /** The build's own options; every one defaults to what a plain `sous build` uses. */
451
+ options?: Omit<BuildOptions, "configContext">;
452
+ /** The heading printed above the compile and prune step. Defaults to "Building". */
453
+ heading?: string;
454
+ };
455
+
440
456
  /**
441
- * Compiles and prunes a project exactly the way `sous build` does with no flags.
457
+ * The one build every command runs: `build`, `init`, `launch`, `prune`,
458
+ * `repo remove`, `repo unlink`, `subscription add`, `subscription remove` and
459
+ * `subscription update` all come through here, so a project is built the same
460
+ * way whichever of them the user typed.
442
461
  *
443
- * Shared by the subscription commands, which rebuild the project the moment they
444
- * have changed what it subscribes to: a newly subscribed recipe's files appear,
445
- * and a removed one's files are pruned, without anyone having to remember a
446
- * second command. Every option is left at its default on purpose, because the
447
- * point is to run the ordinary build and nothing else.
462
+ * In order, it announces every linked repository, prepares the project's
463
+ * recipes (seeds the core recipe, locks new subscriptions, restores whatever
464
+ * the store is missing, checks upstream and reports newer versions), and then
465
+ * compiles and prunes. A dry run prepares nothing, because preparing writes the
466
+ * lockfile and the store. A partial rebuild in watch mode (one changed source
467
+ * file) prepares nothing either: the config did not change, so neither did the
468
+ * recipes it needs.
448
469
  *
449
- * @param settings - The merged project config, reloaded after the change.
450
- * @param configContext - Where the active config was discovered.
451
- * @returns True when compile and prune both succeeded.
470
+ * @param input - The project, the build options and the heading.
471
+ * @returns True when compile and prune both succeeded; false after any compile error.
452
472
  */
453
- export async function buildProjectOutputs(
454
- settings: Settings,
455
- configContext: ConfigContext
456
- ): Promise<boolean> {
457
- return new BuildService().build(settings, { configContext });
473
+ export async function runProjectBuild(input: ProjectBuildInput): Promise<boolean> {
474
+ const options = input.options ?? {};
475
+ const partial = options.changedFile !== undefined;
476
+
477
+ // A linked repository is read from a working copy instead of a published
478
+ // version, so it is announced on every build; a build that silently produced
479
+ // something different would be far worse than a noisy one.
480
+ if (!partial) {
481
+ const linked = describeLinkedRepos(input.configContext.sousDir);
482
+ if (linked.length > 0) warning(linked.join("\n"));
483
+ }
484
+
485
+ if (!options.dryRun && !partial) {
486
+ await prepareRepositoriesForBuild(
487
+ subscriptionServiceFor({
488
+ configContext: input.configContext,
489
+ settings: input.settings,
490
+ ...(input.shellEnv === undefined ? {} : { shellEnv: input.shellEnv }),
491
+ })
492
+ );
493
+ }
494
+
495
+ heading(input.heading ?? "Building");
496
+ const succeeded = await new BuildService().build(input.settings, {
497
+ ...options,
498
+ configContext: input.configContext,
499
+ });
500
+ footer();
501
+ return succeeded;
458
502
  }
@@ -63,6 +63,11 @@ export type CompilationConfig = {
63
63
  };
64
64
 
65
65
  export type CompilationServiceOptions = {
66
+ /**
67
+ * Treat compile warnings as errors. An error always fails the compile; this
68
+ * only decides whether a warning (a `.tpl.` file copied without being
69
+ * rendered, a git branch that could not be read) does too.
70
+ */
66
71
  strict?: boolean;
67
72
  rebuild?: boolean;
68
73
  dryRun?: boolean;
@@ -184,14 +189,26 @@ export class CompilationService {
184
189
  this.encoder = get_encoding("o200k_base");
185
190
  }
186
191
 
187
- /** Handle errors according to strict mode. */
192
+ /**
193
+ * Records a compile error and prints it. The compile carries on, so every
194
+ * target is compiled and every error is listed; the output the error belongs
195
+ * to is not written, and the compile as a whole reports failure.
196
+ */
188
197
  private handleError(message: string): void {
189
198
  this.errors.push(message);
190
199
  displayError(message);
200
+ }
191
201
 
202
+ /**
203
+ * Records a compile warning: printed as a warning, or, in strict mode,
204
+ * recorded as an error, with everything an error brings.
205
+ */
206
+ private handleWarning(message: string): void {
192
207
  if (this.strict) {
193
- process.exit(1);
208
+ this.handleError(message);
209
+ return;
194
210
  }
211
+ warning(message);
195
212
  }
196
213
 
197
214
  /**
@@ -383,7 +400,10 @@ export class CompilationService {
383
400
  ).trim();
384
401
  } catch (error) {
385
402
  const message = error instanceof Error ? error.message : String(error);
386
- this.handleError(`Failed to resolve current git branch: ${message}`);
403
+ this.handleWarning(
404
+ `Sous could not read the current git branch, so the runtime context says ` +
405
+ `"unknown": ${message}`
406
+ );
387
407
  }
388
408
 
389
409
  const runtimeHeader = `## Runtime Session Context
@@ -427,6 +447,21 @@ ${taskFileContents}
427
447
  fs.writeFileSync(ctx.outputPath, `${runtimeHeader}${taskFileBlock}\n`, "utf8");
428
448
  }
429
449
 
450
+ /**
451
+ * Says that an output was not written because its target had an error, and
452
+ * what that leaves on disk.
453
+ *
454
+ * @param destFile - The output that was not written.
455
+ */
456
+ private reportNotWritten(destFile: string): void {
457
+ const verb = this.dryRun ? "would not be written" : "not written";
458
+ log(
459
+ fs.existsSync(destFile)
460
+ ? ` ✗ ${destFile} (${verb} because of an error; the previous copy stays)`
461
+ : ` ✗ ${destFile} (${verb} because of an error)`
462
+ );
463
+ }
464
+
430
465
  /** Compile a single target, writing to all of its outputs. */
431
466
  private async compileTarget(
432
467
  target: CompilationTarget,
@@ -445,6 +480,8 @@ ${taskFileContents}
445
480
  ? target.includeSourceComments
446
481
  : this.includeSourceComments;
447
482
 
483
+ const errorsBefore = this.errors.length;
484
+
448
485
  if (target.runtimeContext) {
449
486
  this.generateRuntimeSessionContext(target);
450
487
  }
@@ -457,6 +494,11 @@ ${taskFileContents}
457
494
  return false;
458
495
  }
459
496
 
497
+ // An include that failed left a hole in the assembled content (and in strict
498
+ // mode, a warning counts as an error), so none of this target's outputs may
499
+ // be written: the previous copy stays in place.
500
+ const assembled = this.errors.length === errorsBefore;
501
+
460
502
  // Compute source hash once per target from the assembled content
461
503
  const contentHash = hashContent(content);
462
504
 
@@ -473,6 +515,12 @@ ${taskFileContents}
473
515
  if (resolvedDest === undefined) continue;
474
516
  destFile = resolvedDest;
475
517
 
518
+ if (!assembled) {
519
+ this.reportNotWritten(destFile);
520
+ allSucceeded = false;
521
+ continue;
522
+ }
523
+
476
524
  // A rendered output depends on its variables as much as on its source: a
477
525
  // changed answer or `_vars` value with the same template must re-render,
478
526
  // so the variable scope is part of a `.tpl.` output's source hash. A
@@ -508,8 +556,15 @@ ${taskFileContents}
508
556
  // directly, and it stays as a tripwire in case the resolver ever changes.
509
557
  if (isTpl && !output.vars) {
510
558
  this.unrenderedTemplates.push(`${target.rootInputPath} → ${destFile}`);
559
+ // In strict mode this warning is an error, so the output is not written.
560
+ if (this.strict) {
561
+ this.reportNotWritten(destFile);
562
+ allSucceeded = false;
563
+ continue;
564
+ }
511
565
  }
512
566
 
567
+ const errorsBeforeRender = this.errors.length;
513
568
  const resolvedContent = (isTpl && output.vars)
514
569
  ? await this.renderContent(
515
570
  content,
@@ -522,6 +577,14 @@ ${taskFileContents}
522
577
  target.rootInputPath
523
578
  )
524
579
  : content;
580
+
581
+ // A template that failed to render is not written either.
582
+ if (this.errors.length > errorsBeforeRender) {
583
+ this.reportNotWritten(destFile);
584
+ allSucceeded = false;
585
+ continue;
586
+ }
587
+
525
588
  const fileContent = resolvedContent;
526
589
  const outputDir = path.dirname(destFile);
527
590
 
@@ -582,8 +645,11 @@ ${taskFileContents}
582
645
  }
583
646
 
584
647
  /**
585
- * Compile all targets from the given config.
586
- * Returns true if all targets compiled successfully.
648
+ * Compile all targets from the given config. Every target is compiled even
649
+ * after an error, so every error is listed; an output whose target had an
650
+ * error is not written and keeps its previous copy and its state entry.
651
+ *
652
+ * @returns True when nothing reported an error; false after any error.
587
653
  */
588
654
  async compile(config: CompilationConfig, stateFilePath?: string): Promise<boolean> {
589
655
  const stateService = new StateService();
@@ -632,15 +698,7 @@ ${taskFileContents}
632
698
  `\nFix: add a '_vars' block to the output (an empty '_vars: {}' is enough to\n` +
633
699
  `enable rendering), or rename the source so it does not contain '.tpl.'.`;
634
700
 
635
- if (this.strict) {
636
- // Recorded as a failure rather than exiting here, so state still gets
637
- // written and the caller decides the exit code.
638
- this.errors.push(message);
639
- displayError(message);
640
- allSucceeded = false;
641
- } else {
642
- warning(message);
643
- }
701
+ this.handleWarning(message);
644
702
  }
645
703
 
646
704
  if (this.errors.length > 0) {
@@ -665,7 +723,9 @@ ${taskFileContents}
665
723
  await stateService.save(stateFilePath, state);
666
724
  }
667
725
 
668
- return allSucceeded;
726
+ // Any error fails the compile, including one that belongs to no single
727
+ // target (a bad config option).
728
+ return allSucceeded && this.errors.length === 0;
669
729
  } finally {
670
730
  if (this.encoder) {
671
731
  this.encoder.free();
@@ -12,25 +12,37 @@
12
12
  *
13
13
  * - a file inside a recipe may address only that recipe's own declared
14
14
  * dependencies (`depends` plus `subscribes`), at their pinned versions;
15
- * - a file in the project's own templates may address the project's
16
- * subscriptions.
15
+ * - a file in the project's own templates may address every recipe the
16
+ * lockfile pins, whatever holds it: the project, a set it subscribes to,
17
+ * or another recipe's `depends`.
18
+ *
19
+ * When a project template names a recipe the lockfile does not pin, the error
20
+ * says which recipe used to bring it in, when the cached indexes or the config
21
+ * can tell.
17
22
  *
18
23
  * A project with no lockfile entries gets no resolver at all, so a project that
19
24
  * uses no repositories behaves exactly as it did before: `~` in an include line
20
25
  * means an alias and nothing else.
21
26
  */
22
27
 
28
+ import semver from "semver";
23
29
  import type { Settings } from "../settings.js";
30
+ import { resolveStoreRoot } from "../sous-home.js";
24
31
  import {
25
32
  StaticNamespaceResolver,
33
+ normalizeRef,
34
+ type DroppedRecipe,
26
35
  type NamespaceResolver,
27
36
  } from "./namespace-resolver.js";
28
37
  import {
29
38
  listLockedRecipes,
30
- projectSubscriptionRefs,
39
+ readProjectLockfile,
31
40
  readRecipeManifestIn,
32
41
  type LockedRecipeLocation,
33
42
  } from "./locked-recipes.js";
43
+ import { createIndexCache } from "./providers/index.js";
44
+ import type { IndexFile } from "./formats/index-file.js";
45
+ import type { Lockfile } from "./formats/lockfile.js";
34
46
 
35
47
  /** How the project's namespace resolver is built. */
36
48
  export type ProjectNamespaceResolverOptions = {
@@ -82,9 +94,125 @@ export function createProjectNamespaceResolver(
82
94
  if (declared.length > 0) dependencies[recipe.key] = declared;
83
95
  }
84
96
 
97
+ // No `projectScope`: a project template may address every recipe the
98
+ // lockfile pins, and those are exactly the recipes this resolver knows.
85
99
  return new StaticNamespaceResolver({
86
100
  recipes,
87
101
  dependencies,
88
- projectScope: projectSubscriptionRefs(options.settings, locked),
102
+ explainMissing: (recipe) => explainUnpinnedRecipe(recipe, options),
89
103
  });
90
104
  }
105
+
106
+ /**
107
+ * Why the lockfile does not pin a recipe a project template asked for, when
108
+ * that can be known without the network. Two things are checked, in order:
109
+ *
110
+ * - a subscription to it (or to its namespace) that is switched off;
111
+ * - another published version of a pinned recipe that brought it in, read
112
+ * from the cached indexes of the lockfile's repositories. That version may
113
+ * declare it directly, or declare a recipe that no longer is pinned either
114
+ * and that declares it in turn; the shortest such chain is reported, and
115
+ * among the versions of one pinned recipe the newest wins. This is the
116
+ * usual story of a set that dropped one of its members, and the members'
117
+ * own libraries with it.
118
+ *
119
+ * Only ever runs while an error is being reported, so it reads the lockfile
120
+ * and the cached indexes then rather than on every build.
121
+ *
122
+ * @param recipe - The `namespace/recipe` the template asked for.
123
+ * @param options - The project's `.sous/` directory, its config and the environment.
124
+ */
125
+ export function explainUnpinnedRecipe(
126
+ recipe: string,
127
+ options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
128
+ ): DroppedRecipe | undefined {
129
+ const namespace = recipe.slice(0, recipe.indexOf("/"));
130
+ for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
131
+ if (entry?.enabled !== false) continue;
132
+ const ref = normalizeRef(key);
133
+ if (ref === recipe || ref === namespace) {
134
+ return { by: "disabled-subscription", subscription: key };
135
+ }
136
+ }
137
+
138
+ let lock: Lockfile;
139
+ try {
140
+ lock = readProjectLockfile(options.sousDir);
141
+ } catch {
142
+ // The build reports a broken lockfile on its own; an error message is no
143
+ // place to report it a second time.
144
+ return undefined;
145
+ }
146
+
147
+ const published = publishedVersions(lock, options.env ?? process.env);
148
+
149
+ /** What one published version of a recipe declares, as recipe keys. */
150
+ const declaredBy = (key: string, version: string): string[] =>
151
+ Object.keys(published.get(key)?.[version]?.dependencies ?? {});
152
+
153
+ // Breadth first from every other version of every pinned recipe, through
154
+ // recipes the lockfile does not pin, so the shortest chain is found first.
155
+ type Step = { key: string; root: { recipe: string; version: string }; through: string[] };
156
+ const queue: Step[] = [];
157
+ for (const holder of Object.keys(lock.recipes).sort()) {
158
+ const pinned = lock.recipes[holder]!.version;
159
+ const versions = Object.keys(published.get(holder) ?? {})
160
+ .filter((version) => version !== pinned && semver.valid(version) !== null)
161
+ .sort(semver.rcompare);
162
+ for (const version of versions) {
163
+ for (const key of declaredBy(holder, version)) {
164
+ queue.push({ key, root: { recipe: holder, version }, through: [] });
165
+ }
166
+ }
167
+ }
168
+
169
+ const seen = new Set<string>();
170
+ while (queue.length > 0) {
171
+ const step = queue.shift()!;
172
+ if (step.key === recipe) {
173
+ const root = step.root;
174
+ return {
175
+ by: "recipe",
176
+ recipe: root.recipe,
177
+ declaredAt: root.version,
178
+ pinned: lock.recipes[root.recipe]!.version,
179
+ ...(step.through.length > 0 ? { through: step.through } : {}),
180
+ };
181
+ }
182
+ // A pinned recipe is explained by its own entry, and each recipe is
183
+ // walked once.
184
+ if (Object.hasOwn(lock.recipes, step.key) || seen.has(step.key)) continue;
185
+ seen.add(step.key);
186
+ for (const version of Object.keys(published.get(step.key) ?? {})) {
187
+ for (const key of declaredBy(step.key, version)) {
188
+ queue.push({ key, root: step.root, through: [...step.through, step.key] });
189
+ }
190
+ }
191
+ }
192
+
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Every published version of every recipe in the cached indexes of the
198
+ * repositories the lockfile names, keyed by recipe. A repository with no
199
+ * cached index contributes nothing.
200
+ *
201
+ * @param lock - The project's lockfile.
202
+ * @param env - The environment, which decides where the store is.
203
+ */
204
+ function publishedVersions(
205
+ lock: Lockfile,
206
+ env: NodeJS.ProcessEnv
207
+ ): Map<string, IndexFile["recipes"][string]["versions"]> {
208
+ const cache = createIndexCache({ storeRoot: resolveStoreRoot(env) });
209
+ const found = new Map<string, IndexFile["recipes"][string]["versions"]>();
210
+ const identities = new Set(Object.values(lock.repos).map((repo) => repo.identity));
211
+ for (const identity of [...identities].sort()) {
212
+ const index = cache.readCached(identity);
213
+ for (const [key, entry] of Object.entries(index?.recipes ?? {})) {
214
+ if (!found.has(key)) found.set(key, entry.versions);
215
+ }
216
+ }
217
+ return found;
218
+ }
@@ -20,7 +20,6 @@
20
20
 
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
- import type { Settings } from "../settings.js";
24
23
  import { resolveStoreRoot } from "../sous-home.js";
25
24
  import { parseLockfile, type LockKind, type Lockfile } from "./formats/lockfile.js";
26
25
  import { parseRecipeManifest, type RecipeManifest } from "./formats/recipe-manifest.js";
@@ -34,7 +33,6 @@ import {
34
33
  } from "./load-manifest.js";
35
34
  import { readEffectiveLinks } from "./links.js";
36
35
  import { identitySegments } from "./identity.js";
37
- import { enabledSubscriptions } from "./defaults.js";
38
36
  import { PROJECT_HOLDER } from "./formats/lockfile.js";
39
37
 
40
38
  /** One locked recipe, together with the directory its files are read from. */
@@ -233,26 +231,6 @@ export function keysHeldBySubscription(lock: Lockfile, key: string): string[] {
233
231
  .sort();
234
232
  }
235
233
 
236
- /**
237
- * The refs a project's own templates may address: everything it subscribed to
238
- * directly. Both sources are read, because a subscription may be written by
239
- * `sous subscribe` into the managed layer, hand-written in the primary config,
240
- * or (for an older project) recorded only in the lockfile.
241
- *
242
- * @param settings - The merged project config.
243
- * @param locked - The locked recipes, as returned by listLockedRecipes.
244
- */
245
- export function projectSubscriptionRefs(
246
- settings: Settings,
247
- locked: LockedRecipeLocation[]
248
- ): string[] {
249
- const refs = new Set<string>(Object.keys(enabledSubscriptions(settings)));
250
- for (const recipe of locked) {
251
- if (recipe.requestedBy.includes(PROJECT_HOLDER)) refs.add(recipe.key);
252
- }
253
- return [...refs].sort();
254
- }
255
-
256
234
  /**
257
235
  * Every locked recipe reachable from a set of keys through the lockfile's
258
236
  * holder lists: the keys themselves, whatever they hold, and so on.
@@ -26,10 +26,31 @@ import path from "node:path";
26
26
  * the including file:
27
27
  * - a file that lives inside a recipe may address only that recipe's declared
28
28
  * dependencies (`depends` plus `subscribes`) at their pinned versions;
29
- * - a file in the project's own templates may address the project's
30
- * subscriptions.
29
+ * - a file in the project's own templates may address every recipe the
30
+ * project's lockfile pins, whatever holds it.
31
31
  */
32
32
 
33
+ /**
34
+ * Why a recipe a project template asked for is not pinned any more, when that
35
+ * can be known. Only ever attached to an answer for a project template.
36
+ */
37
+ export type DroppedRecipe =
38
+ /**
39
+ * Another version of a pinned recipe declares it (in `depends` or
40
+ * `subscribes`), and the version the lockfile pins does not: the usual story
41
+ * of a set that dropped one of its members.
42
+ */
43
+ | {
44
+ by: "recipe";
45
+ recipe: string;
46
+ declaredAt: string;
47
+ pinned: string;
48
+ /** The recipes, no longer pinned either, that the chain ran through, in order. */
49
+ through?: string[];
50
+ }
51
+ /** The project subscribes to it, but the subscription is switched off. */
52
+ | { by: "disabled-subscription"; subscription: string };
53
+
33
54
  /** A single `~namespace/rest` resolution request. */
34
55
  export type NamespaceRequest = {
35
56
  /** The namespace name, with the leading `~` already stripped (e.g. `workflow`). */
@@ -59,20 +80,24 @@ export type NamespaceRequest = {
59
80
  export type NamespaceResolution =
60
81
  /** Ordered absolute paths to try, most preferred first. An empty list means the lookup produced nothing. */
61
82
  | { kind: "candidates"; candidates: string[] }
62
- /** No such namespace is known at all. `known` lists the namespaces that are. */
63
- | { kind: "unknown-namespace"; known: string[] }
83
+ /**
84
+ * No such namespace is known at all. `known` lists the namespaces that are.
85
+ * `dropped` says why a project template's recipe is no longer pinned, when
86
+ * that can be known.
87
+ */
88
+ | { kind: "unknown-namespace"; known: string[]; dropped?: DroppedRecipe }
64
89
  /**
65
90
  * The namespace exists but holds no such recipe. `recipe` is the fully
66
91
  * qualified ref that was asked for; `known` lists the recipe refs the
67
- * namespace does hold.
92
+ * namespace does hold; `dropped` is as above.
68
93
  */
69
- | { kind: "unknown-recipe"; recipe: string; known: string[] }
94
+ | { kind: "unknown-recipe"; recipe: string; known: string[]; dropped?: DroppedRecipe }
70
95
  /**
71
96
  * The recipe exists but the including file is not allowed to address it.
72
97
  * `recipe` is the fully qualified ref that was asked for. `includingRecipe`
73
98
  * is the ref of the recipe the including file belongs to, or `null` when the
74
- * including file is one of the project's own templates (in which case the
75
- * project simply does not subscribe to the recipe).
99
+ * including file is one of the project's own templates (which only happens
100
+ * when the resolver was given a narrower `projectScope`).
76
101
  */
77
102
  | { kind: "not-a-dependency"; recipe: string; includingRecipe: string | null }
78
103
  /**
@@ -126,6 +151,7 @@ export function formatNamespaceProblem(opts: {
126
151
  ? `Available namespaces: ${resolution.known.join(", ")}.`
127
152
  : "This project has no recipe namespaces available yet."
128
153
  );
154
+ if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
129
155
  } else if (resolution.kind === "unknown-recipe") {
130
156
  lines.push(`recipe: ${resolution.recipe}`);
131
157
  lines.push(`The namespace "${opts.namespace}" holds no recipe named "${resolution.recipe}".`);
@@ -134,6 +160,7 @@ export function formatNamespaceProblem(opts: {
134
160
  ? `Recipes in this namespace: ${resolution.known.join(", ")}.`
135
161
  : `The namespace "${opts.namespace}" currently holds no recipes.`
136
162
  );
163
+ if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
137
164
  } else if (resolution.kind === "not-a-dependency") {
138
165
  lines.push(`recipe: ${resolution.recipe}`);
139
166
  if (resolution.includingRecipe) {
@@ -144,10 +171,7 @@ export function formatNamespaceProblem(opts: {
144
171
  `Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${opts.namespace}".`
145
172
  );
146
173
  } else {
147
- lines.push(`This project does not subscribe to "${resolution.recipe}".`);
148
- lines.push(
149
- `Subscribe to it before addressing it as "~${opts.namespace}" from a project template.`
150
- );
174
+ lines.push(`The project's own templates may not address "${resolution.recipe}".`);
151
175
  }
152
176
  } else if (resolution.kind === "escapes-recipe") {
153
177
  lines.push(`recipe: ${resolution.recipe}`);
@@ -167,6 +191,37 @@ export function formatNamespaceProblem(opts: {
167
191
  return lines.map((line) => ` ${line}`).join("\n");
168
192
  }
169
193
 
194
+ /**
195
+ * The lines that say why a recipe a project template asked for is no longer
196
+ * pinned, and what brings it back.
197
+ *
198
+ * @param dropped - What is known about how the recipe used to be pinned.
199
+ * @param opts - The namespace and the rest of the reference, for the recipe's name.
200
+ */
201
+ function describeDropped(
202
+ dropped: DroppedRecipe,
203
+ opts: { namespace: string; rest: string }
204
+ ): string[] {
205
+ const recipe = `${opts.namespace}/${opts.rest.split("/")[0] ?? ""}`;
206
+ if (dropped.by === "disabled-subscription") {
207
+ return [
208
+ `This project subscribes to "${dropped.subscription}", but that subscription is ` +
209
+ `switched off ("enabled: false"), so the lockfile does not pin "${recipe}".`,
210
+ `Switch the subscription back on, or remove the include.`,
211
+ ];
212
+ }
213
+ const through =
214
+ dropped.through === undefined || dropped.through.length === 0
215
+ ? ""
216
+ : ` (through ${dropped.through.map((key) => `"${key}"`).join(", ")})`;
217
+ return [
218
+ `Version ${dropped.declaredAt} of "${dropped.recipe}" brought "${recipe}" in${through}, but ` +
219
+ `the version this project pins, ${dropped.pinned}, does not, so the lockfile no longer ` +
220
+ `pins it.`,
221
+ `Subscribe to "${recipe}" directly to keep including it, or remove the include.`,
222
+ ];
223
+ }
224
+
170
225
  /** Options for {@link StaticNamespaceResolver}. */
171
226
  export type StaticNamespaceResolverOptions = {
172
227
  /**
@@ -186,9 +241,16 @@ export type StaticNamespaceResolverOptions = {
186
241
  /**
187
242
  * What the project's own templates may address, in the same ref forms as
188
243
  * `dependencies`. Omit it to make every known recipe addressable from
189
- * project templates (the convenient default for tests).
244
+ * project templates, which is the rule for a real project: its templates may
245
+ * address everything its lockfile pins.
190
246
  */
191
247
  projectScope?: string[];
248
+ /**
249
+ * Says why a recipe a project template asked for is not among `recipes`,
250
+ * when that can be known. Consulted only for a project template's reference
251
+ * to a recipe the resolver does not know.
252
+ */
253
+ explainMissing?: (recipe: string) => DroppedRecipe | undefined;
192
254
  };
193
255
 
194
256
  /**
@@ -196,7 +258,7 @@ export type StaticNamespaceResolverOptions = {
196
258
  * recipe refs to directories.
197
259
  *
198
260
  * It implements the full scoping rule (recipe files see their declared
199
- * dependencies; project files see the project's subscriptions) without knowing
261
+ * dependencies; project files see every known recipe, or `projectScope`) without knowing
200
262
  * anything about repositories, versions or the store, which makes it the
201
263
  * resolver used by tests and a usable core for the real implementation to wrap.
202
264
  */
@@ -204,6 +266,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
204
266
  private readonly recipes: Record<string, string>;
205
267
  private readonly dependencies: Record<string, string[]>;
206
268
  private readonly projectScope?: string[];
269
+ private readonly explainMissing?: (recipe: string) => DroppedRecipe | undefined;
207
270
 
208
271
  constructor(options: StaticNamespaceResolverOptions) {
209
272
  this.recipes = {};
@@ -212,6 +275,18 @@ export class StaticNamespaceResolver implements NamespaceResolver {
212
275
  }
213
276
  this.dependencies = options.dependencies ?? {};
214
277
  this.projectScope = options.projectScope;
278
+ this.explainMissing = options.explainMissing;
279
+ }
280
+
281
+ /**
282
+ * Why a project template's reference names a recipe this resolver does not
283
+ * know, or undefined when the including file is a recipe's own or nothing is
284
+ * known.
285
+ */
286
+ private droppedFor(recipe: string, fromFile: string): { dropped?: DroppedRecipe } {
287
+ if (this.explainMissing === undefined || this.includingRecipe(fromFile) !== null) return {};
288
+ const dropped = this.explainMissing(recipe);
289
+ return dropped === undefined ? {} : { dropped };
215
290
  }
216
291
 
217
292
  /** Every namespace this resolver knows about, sorted. */
@@ -254,17 +329,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
254
329
  resolve(request: NamespaceRequest): NamespaceResolution {
255
330
  const { namespace, rest, fromFile } = request;
256
331
 
257
- if (!this.knownNamespaces().includes(namespace)) {
258
- return { kind: "unknown-namespace", known: this.knownNamespaces() };
259
- }
260
-
261
332
  const segments = rest.split("/").filter((segment) => segment.length > 0);
262
333
  const recipeName = segments[0] ?? "";
263
334
  const ref = `${namespace}/${recipeName}`;
335
+
336
+ if (!this.knownNamespaces().includes(namespace)) {
337
+ return {
338
+ kind: "unknown-namespace",
339
+ known: this.knownNamespaces(),
340
+ ...this.droppedFor(ref, fromFile),
341
+ };
342
+ }
343
+
264
344
  const recipeDir = this.recipes[ref];
265
345
 
266
346
  if (!recipeDir) {
267
- return { kind: "unknown-recipe", recipe: ref, known: this.recipesIn(namespace) };
347
+ return {
348
+ kind: "unknown-recipe",
349
+ recipe: ref,
350
+ known: this.recipesIn(namespace),
351
+ ...this.droppedFor(ref, fromFile),
352
+ };
268
353
  }
269
354
 
270
355
  const includingRecipe = this.includingRecipe(fromFile);