@telorun/analyzer 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +92 -15
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +71 -97
  5. package/dist/catch-scope.d.ts +72 -0
  6. package/dist/catch-scope.d.ts.map +1 -0
  7. package/dist/catch-scope.js +102 -0
  8. package/dist/cel-scope-query.d.ts.map +1 -1
  9. package/dist/cel-scope-query.js +32 -11
  10. package/dist/cel-scope.d.ts.map +1 -1
  11. package/dist/cel-scope.js +1 -0
  12. package/dist/deprecation.d.ts +21 -0
  13. package/dist/deprecation.d.ts.map +1 -0
  14. package/dist/deprecation.js +26 -0
  15. package/dist/eval-paths.d.ts +36 -5
  16. package/dist/eval-paths.d.ts.map +1 -1
  17. package/dist/eval-paths.js +47 -6
  18. package/dist/extends-resolution.d.ts +12 -0
  19. package/dist/extends-resolution.d.ts.map +1 -1
  20. package/dist/extends-resolution.js +23 -0
  21. package/dist/index.d.ts +4 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -2
  24. package/dist/manifest-visitor.d.ts +17 -1
  25. package/dist/manifest-visitor.d.ts.map +1 -1
  26. package/dist/manifest-visitor.js +5 -1
  27. package/dist/migrations/report.d.ts +1 -1
  28. package/dist/migrations/report.d.ts.map +1 -1
  29. package/dist/migrations/report.js +5 -0
  30. package/dist/nearest-name.d.ts +22 -0
  31. package/dist/nearest-name.d.ts.map +1 -0
  32. package/dist/nearest-name.js +43 -0
  33. package/dist/ref-slot.d.ts +15 -0
  34. package/dist/ref-slot.d.ts.map +1 -1
  35. package/dist/ref-slot.js +7 -0
  36. package/dist/release/destinations.d.ts +59 -0
  37. package/dist/release/destinations.d.ts.map +1 -0
  38. package/dist/release/destinations.js +75 -0
  39. package/dist/release/index.d.ts +6 -2
  40. package/dist/release/index.d.ts.map +1 -1
  41. package/dist/release/index.js +3 -1
  42. package/dist/release/ledger.d.ts +20 -11
  43. package/dist/release/ledger.d.ts.map +1 -1
  44. package/dist/release/ledger.js +43 -14
  45. package/dist/release/release-plan.d.ts +3 -2
  46. package/dist/release/release-plan.d.ts.map +1 -1
  47. package/dist/release/release-plan.js +12 -19
  48. package/dist/release/workspace-config.d.ts +114 -34
  49. package/dist/release/workspace-config.d.ts.map +1 -1
  50. package/dist/release/workspace-config.js +322 -38
  51. package/dist/release/workspace-schema.d.ts +41 -0
  52. package/dist/release/workspace-schema.d.ts.map +1 -0
  53. package/dist/release/workspace-schema.js +77 -0
  54. package/dist/resolve-throws-union.d.ts +29 -1
  55. package/dist/resolve-throws-union.d.ts.map +1 -1
  56. package/dist/resolve-throws-union.js +111 -16
  57. package/dist/schema-compat.d.ts.map +1 -1
  58. package/dist/schema-compat.js +13 -1
  59. package/dist/schema-error-report.d.ts +7 -0
  60. package/dist/schema-error-report.d.ts.map +1 -1
  61. package/dist/schema-error-report.js +57 -8
  62. package/dist/schema-keywords.d.ts.map +1 -1
  63. package/dist/schema-keywords.js +3 -1
  64. package/dist/schema-walk.d.ts +27 -0
  65. package/dist/schema-walk.d.ts.map +1 -1
  66. package/dist/schema-walk.js +44 -0
  67. package/dist/telo-version.d.ts +1 -1
  68. package/dist/telo-version.js +1 -1
  69. package/dist/template-body.d.ts +10 -0
  70. package/dist/template-body.d.ts.map +1 -1
  71. package/dist/template-body.js +25 -0
  72. package/dist/types.d.ts +17 -0
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/types.js +11 -0
  75. package/dist/validate-cel-context.d.ts +23 -8
  76. package/dist/validate-cel-context.d.ts.map +1 -1
  77. package/dist/validate-cel-context.js +48 -16
  78. package/dist/validate-exports.d.ts +28 -0
  79. package/dist/validate-exports.d.ts.map +1 -0
  80. package/dist/validate-exports.js +146 -0
  81. package/dist/validate-extends.d.ts.map +1 -1
  82. package/dist/validate-extends.js +37 -1
  83. package/dist/validate-identifier-names.d.ts +2 -2
  84. package/dist/validate-identifier-names.d.ts.map +1 -1
  85. package/dist/validate-identifier-names.js +22 -7
  86. package/dist/validate-provider-coherence.d.ts +4 -8
  87. package/dist/validate-provider-coherence.d.ts.map +1 -1
  88. package/dist/validate-provider-coherence.js +12 -123
  89. package/dist/validate-ref-slots.d.ts +1 -1
  90. package/dist/validate-ref-slots.d.ts.map +1 -1
  91. package/dist/validate-ref-slots.js +34 -0
  92. package/dist/validate-references.d.ts.map +1 -1
  93. package/dist/validate-references.js +168 -63
  94. package/dist/validate-template-body.d.ts +36 -0
  95. package/dist/validate-template-body.d.ts.map +1 -0
  96. package/dist/validate-template-body.js +246 -0
  97. package/dist/validate-throws-coverage.d.ts.map +1 -1
  98. package/dist/validate-throws-coverage.js +236 -85
  99. package/package.json +3 -2
  100. package/src/analyzer.ts +118 -12
  101. package/src/builtins.ts +85 -97
  102. package/src/catch-scope.ts +157 -0
  103. package/src/cel-scope-query.ts +28 -9
  104. package/src/cel-scope.ts +1 -0
  105. package/src/deprecation.ts +36 -0
  106. package/src/eval-paths.ts +53 -6
  107. package/src/extends-resolution.ts +24 -0
  108. package/src/index.ts +9 -1
  109. package/src/manifest-visitor.ts +19 -2
  110. package/src/migrations/report.ts +5 -1
  111. package/src/nearest-name.ts +47 -0
  112. package/src/ref-slot.ts +19 -0
  113. package/src/release/destinations.ts +105 -0
  114. package/src/release/index.ts +33 -2
  115. package/src/release/ledger.ts +51 -21
  116. package/src/release/release-plan.ts +16 -26
  117. package/src/release/workspace-config.ts +483 -47
  118. package/src/release/workspace-schema.ts +99 -0
  119. package/src/resolve-throws-union.ts +139 -21
  120. package/src/schema-compat.ts +13 -0
  121. package/src/schema-error-report.ts +66 -10
  122. package/src/schema-keywords.ts +4 -1
  123. package/src/schema-walk.ts +56 -0
  124. package/src/telo-version.ts +1 -1
  125. package/src/template-body.ts +25 -0
  126. package/src/types.ts +18 -0
  127. package/src/validate-cel-context.ts +57 -16
  128. package/src/validate-exports.ts +185 -0
  129. package/src/validate-extends.ts +43 -0
  130. package/src/validate-identifier-names.ts +28 -9
  131. package/src/validate-provider-coherence.ts +17 -127
  132. package/src/validate-ref-slots.ts +41 -1
  133. package/src/validate-references.ts +174 -63
  134. package/src/validate-template-body.ts +274 -0
  135. package/src/validate-throws-coverage.ts +333 -92
  136. package/dist/validate-template-dispatch.d.ts +0 -27
  137. package/dist/validate-template-dispatch.d.ts.map +0 -1
  138. package/dist/validate-template-dispatch.js +0 -95
  139. package/src/validate-template-dispatch.ts +0 -99
@@ -67,14 +67,15 @@ export interface ModuleEvidence {
67
67
  /** Whether a file under this module's own directory that reaches the artifact
68
68
  * changed. Decides only whether a changelog line is requested. */
69
69
  readonly ownFilesChanged: boolean;
70
+ /** The publish destination base THIS module's payload was built against —
71
+ * per module, because a workspace may declare one per subtree. */
72
+ readonly registry: string;
70
73
  }
71
74
 
72
75
  export interface ReleaseEvidence {
73
76
  readonly modules: readonly ModuleEvidence[];
74
77
  readonly ledger: Ledger;
75
78
  readonly fragments: readonly ReleaseFragment[];
76
- /** The publish destination base the digests above were built against. */
77
- readonly registry?: string;
78
79
  }
79
80
 
80
81
  /** Why a module is in the plan. A module usually carries several. */
@@ -123,14 +124,25 @@ export function planRelease(evidence: ReleaseEvidence): ReleasePlan {
123
124
  const diagnostics: ReleaseDiagnostic[] = [];
124
125
  const byKey = new Map(evidence.modules.map((module) => [module.key, module]));
125
126
 
126
- checkRegistryAgreement(evidence, diagnostics);
127
-
128
127
  // What the digest says, per module. A module with no ledger entry has never
129
128
  // been published, which is not drift — there is nothing to differ from.
130
129
  const drift = new Map<ModuleKey, LayerChange[]>();
131
130
  for (const module of evidence.modules) {
132
131
  const recorded = evidence.ledger.modules.get(module.key);
133
132
  if (!recorded) continue;
133
+ if (recorded.registry && recorded.registry !== module.registry) {
134
+ diagnostics.push({
135
+ severity: "error",
136
+ code: "LEDGER_REGISTRY_MISMATCH",
137
+ message:
138
+ `${module.key}: its ledger digests were taken against '${recorded.registry}', but this ` +
139
+ `run built against '${module.registry}'. Publishing rewrites each relative import to ` +
140
+ `'<base>/<sibling>@<version>', so the manifest layers of the two are different bytes ` +
141
+ `and comparing them would report the module as changed. Publish to the recorded base, ` +
142
+ `or re-record with \`telo release verify --write\`.`,
143
+ });
144
+ continue;
145
+ }
134
146
  if (recorded.version !== module.version) {
135
147
  diagnostics.push({
136
148
  severity: "error",
@@ -340,28 +352,6 @@ function requestMissingChangelogEntries(
340
352
  });
341
353
  }
342
354
 
343
- /**
344
- * The base the digests were built against has to be the base they were recorded
345
- * against, or the manifest layers are not comparable: canonicalization writes
346
- * the destination into them.
347
- */
348
- function checkRegistryAgreement(
349
- evidence: ReleaseEvidence,
350
- diagnostics: ReleaseDiagnostic[],
351
- ): void {
352
- const recorded = evidence.ledger.registry;
353
- if (!recorded || !evidence.registry || recorded === evidence.registry) return;
354
- diagnostics.push({
355
- severity: "error",
356
- code: "LEDGER_REGISTRY_MISMATCH",
357
- message:
358
- `The ledger's digests were taken against '${recorded}', but this run built against ` +
359
- `'${evidence.registry}'. Publishing rewrites each relative import to ` +
360
- `'<base>/<sibling>@<version>', so the manifest layers of the two are different bytes ` +
361
- `and comparing them would report every module as changed.`,
362
- });
363
- }
364
-
365
355
  /**
366
356
  * Dependency order over in-repo imports — a dependency before its dependents.
367
357
  *
@@ -1,85 +1,521 @@
1
1
  /**
2
- * `telo-workspace.yaml` — the release anchor.
2
+ * `telo-workspace.yaml` — the anchor.
3
3
  *
4
- * Every path the release system names a module key, a ledger entry, a
5
- * fragment's `modules:` is relative to this file's directory. That is the
6
- * whole of its job: **its location is the anchor**, and its one field names the
7
- * subtrees that may hold modules, which is not derivable (a whole-tree scan
8
- * would read every example and every cached `.telo/manifests` copy as a released
9
- * module).
4
+ * **Its location is the anchor, and every field lives in a block scoped to what
5
+ * it governs.** Every path the release system names a module key, a ledger
6
+ * entry, a fragment's `modules:` is relative to this file's directory, and so
7
+ * is the `.telo` cache. `release:` is what `telo release` reads; `env:` is what
8
+ * `telo run` reads when it resolves a manifest's environment.
10
9
  *
11
- * The file is **optional**, and everything reading it is harmless without one.
12
- * `telo release` requires one. `telo run` uses its LOCATION never `modules:`,
13
- * which is release scope as the bound on the walk-up that collects `.env` /
14
- * `.env.local`, and with no marker reads the manifest's own directory alone, as
15
- * it did before this file existed; a bound is what keeps that walk out of a
16
- * user's home directory. Nothing else not `check`, `publish`, `install`,
17
- * `upgrade`, `migrate`, `module`, and not the kernel — looks for it, so a
18
- * single-manifest repo, a bare `examples/` directory and a third-party module
19
- * checkout keep working with nothing added. Any field added later must be true
20
- * of the whole tree, not derivable from it, and harmless by its absence.
10
+ * That rule is why `modules:` sits under `release:` rather than at the top
11
+ * level. It is a release inventory, not an inventory of manifests: it exists so
12
+ * a whole-tree scan does not read every example and every cached
13
+ * `.telo/manifests` copy as something to version and publish, and nothing else
14
+ * consults it the env walk refuses to, which is why a manifest under
15
+ * `examples/`, in no release subtree, still gets the full walk.
21
16
  *
22
- * Parsing lives here, in the browser-safe half, because the editor answers
23
- * "what does changing this library bump?" from the same model. Finding the file
24
- * on disk is the CLI's half this side takes text.
17
+ * The file is **optional and every block in it is optional**. A marker whose
18
+ * whole content is comments is valid: it anchors the cache and bounds the env
19
+ * walk, which is exactly what a runner seeds one for.
20
+ *
21
+ * **The reader is lenient; the diagnostics are the strict half.** It returns
22
+ * everything it could read plus everything it found wrong, each anchored at a
23
+ * key path, because an editor wants all of them and a command wants to exit on
24
+ * the first. Which of them are fatal is the CALLER's policy, not the file's:
25
+ * `telo release` refuses on any error, while `telo run` consumes `env:` alone
26
+ * and prints the rest — aborting every app in a workspace over a release typo is
27
+ * not a trade to make silently.
28
+ *
29
+ * Parsing lives here, in the browser-safe half, because the editor answers "what
30
+ * does changing this library bump?" from the same model. Finding the file on
31
+ * disk is the CLI's half — this side takes text. Gitignore matching is INJECTED
32
+ * (`PatternMatch`) for the same reason: the grammar belongs to the glob package,
33
+ * which this half does not depend on.
25
34
  */
26
35
 
27
36
  import { parseDocument } from "yaml";
37
+ import {
38
+ DEFAULT_ENV_FILES,
39
+ DEFAULT_RELEASE_IGNORE,
40
+ MODULE_ENTRY_KEYS,
41
+ WORKSPACE_BLOCKS,
42
+ WORKSPACE_SCHEMA,
43
+ type WorkspaceKeySchema,
44
+ } from "./workspace-schema.js";
28
45
 
29
46
  export const WORKSPACE_FILENAME = "telo-workspace.yaml";
30
47
 
48
+ export { DEFAULT_ENV_FILES, DEFAULT_RELEASE_IGNORE };
49
+
50
+ /** One `release.modules` entry. A bare string in the file normalizes to this
51
+ * with no overrides. */
52
+ export interface ModuleEntry {
53
+ readonly path: string;
54
+ readonly registry?: string;
55
+ readonly ignore?: readonly string[];
56
+ }
57
+
58
+ export interface ReleaseSettings {
59
+ readonly registry?: string;
60
+ readonly ignore?: readonly string[];
61
+ readonly modules: readonly ModuleEntry[];
62
+ }
63
+
64
+ export interface EnvSettings {
65
+ readonly roots?: readonly string[];
66
+ readonly files?: readonly string[];
67
+ }
68
+
31
69
  export interface WorkspaceConfig {
32
- /**
33
- * Gitignore-style patterns, workspace-relative, naming the subtrees that may
34
- * hold modules (`modules/*`, `apps/*`). A pattern names a place to look, never
35
- * a module: what makes a directory a module is its `telo.yaml`.
36
- */
37
- readonly modules: readonly string[];
70
+ readonly release?: ReleaseSettings;
71
+ readonly env?: EnvSettings;
72
+ }
73
+
74
+ export type WorkspaceDiagnosticCode =
75
+ | "WORKSPACE_UNKNOWN_KEY"
76
+ | "WORKSPACE_MODULES_MOVED"
77
+ | "WORKSPACE_INVALID_VALUE"
78
+ | "WORKSPACE_ENTRY_MATCHES_NOTHING"
79
+ | "WORKSPACE_ENTRY_SHADOWED"
80
+ | "WORKSPACE_MARKER_SHADOWED";
81
+
82
+ export interface WorkspaceDiagnostic {
83
+ readonly code: WorkspaceDiagnosticCode;
84
+ readonly severity: "error" | "warning";
85
+ readonly message: string;
86
+ /** Key path from the document root (`["release", "modules", 2, "registry"]`),
87
+ * so a host can anchor a squiggle without re-deriving where it came from. */
88
+ readonly path: readonly (string | number)[];
89
+ }
90
+
91
+ export interface WorkspaceRead {
92
+ readonly config: WorkspaceConfig;
93
+ readonly diagnostics: readonly WorkspaceDiagnostic[];
38
94
  }
39
95
 
40
96
  export class WorkspaceConfigError extends Error {}
41
97
 
98
+ const EMPTY: WorkspaceConfig = {};
99
+
42
100
  /**
43
- * Parse the marker file's text.
101
+ * Read the marker's text.
44
102
  *
45
- * Strict about its one field, because there is nothing here to be lenient with:
46
- * an empty or absent `modules:` names no subtree, so discovery would find no
47
- * module and every gate would silently pass over a whole repo.
103
+ * Never throws: unreadable YAML is itself a diagnostic, because the one caller
104
+ * that must not abort on it is the run path.
48
105
  */
49
- export function parseWorkspaceConfig(text: string, where: string): WorkspaceConfig {
106
+ export function readWorkspaceConfig(text: string, where: string): WorkspaceRead {
107
+ const diagnostics: WorkspaceDiagnostic[] = [];
108
+ const report = (
109
+ code: WorkspaceDiagnosticCode,
110
+ message: string,
111
+ path: (string | number)[],
112
+ severity: "error" | "warning" = "error",
113
+ ): void => {
114
+ diagnostics.push({ code, severity, message, path });
115
+ };
116
+
50
117
  let value: unknown;
51
118
  try {
52
119
  value = parseDocument(text).toJSON();
53
120
  } catch (err) {
54
- throw new WorkspaceConfigError(
121
+ report(
122
+ "WORKSPACE_INVALID_VALUE",
55
123
  `${where} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
124
+ [],
56
125
  );
126
+ return { config: EMPTY, diagnostics };
57
127
  }
58
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
59
- throw new WorkspaceConfigError(`${where} must be a YAML mapping.`);
128
+ if (value === null || value === undefined) return { config: EMPTY, diagnostics };
129
+ if (typeof value !== "object" || Array.isArray(value)) {
130
+ report("WORKSPACE_INVALID_VALUE", `${where} must be a YAML mapping.`, []);
131
+ return { config: EMPTY, diagnostics };
60
132
  }
61
- const record = value as Record<string, unknown>;
62
133
 
134
+ const record = value as Record<string, unknown>;
63
135
  for (const key of Object.keys(record)) {
64
- if (key !== "modules") {
65
- throw new WorkspaceConfigError(
66
- `${where}: unknown field '${key}'. The workspace marker carries only 'modules:' — ` +
67
- `the subtrees that may hold modules.`,
136
+ if (WORKSPACE_BLOCKS.includes(key)) continue;
137
+ if (key === "modules") {
138
+ report(
139
+ "WORKSPACE_MODULES_MOVED",
140
+ `${where}: 'modules' moved under 'release:'. It is release scope — where modules live, ` +
141
+ `for versioning and publishing — and every field of this file now sits in the block ` +
142
+ `that governs it. Indent this list under a 'release:' key.`,
143
+ ["modules"],
144
+ );
145
+ continue;
146
+ }
147
+ // A near-miss of a block name is the typo whose settings would otherwise go
148
+ // silently unapplied; an unrecognized block is a version skew, so the next
149
+ // one ships without breaking today's runs.
150
+ const near = nearMiss(key, WORKSPACE_BLOCKS);
151
+ if (near) {
152
+ report("WORKSPACE_UNKNOWN_KEY", `${where}: unknown block '${key}'. Did you mean '${near}'?`, [key]);
153
+ } else {
154
+ report(
155
+ "WORKSPACE_UNKNOWN_KEY",
156
+ `${where}: unknown block '${key}', which nothing reads. This telo knows ${WORKSPACE_BLOCKS.map((b) => `'${b}'`).join(" and ")}.`,
157
+ [key],
158
+ "warning",
68
159
  );
69
160
  }
70
161
  }
71
162
 
72
- const modules = record.modules;
73
- if (!Array.isArray(modules) || modules.some((entry) => typeof entry !== "string")) {
74
- throw new WorkspaceConfigError(
75
- `${where}: 'modules' must be a list of path patterns, e.g. [modules/*, apps/*].`,
163
+ const release = readRelease(record.release, where, report);
164
+ const env = readEnv(record.env, where, report);
165
+ return {
166
+ config: { ...(release ? { release } : {}), ...(env ? { env } : {}) },
167
+ diagnostics,
168
+ };
169
+ }
170
+
171
+ type Report = (
172
+ code: WorkspaceDiagnosticCode,
173
+ message: string,
174
+ path: (string | number)[],
175
+ severity?: "error" | "warning",
176
+ ) => void;
177
+
178
+ function readRelease(raw: unknown, where: string, report: Report): ReleaseSettings | undefined {
179
+ if (raw === undefined || raw === null) return undefined;
180
+ if (typeof raw !== "object" || Array.isArray(raw)) {
181
+ report("WORKSPACE_INVALID_VALUE", `${where}: 'release' must be a mapping.`, ["release"]);
182
+ return undefined;
183
+ }
184
+ const block = raw as Record<string, unknown>;
185
+ const read = readBlock(block, WORKSPACE_SCHEMA.release.properties, where, ["release"], report);
186
+ return {
187
+ ...(read.registry !== undefined ? { registry: read.registry as string } : {}),
188
+ ...(read.ignore !== undefined ? { ignore: read.ignore as readonly string[] } : {}),
189
+ modules: (read.modules as readonly ModuleEntry[]) ?? [],
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Read one block against its declared shape.
195
+ *
196
+ * The declared `type` is what DISPATCHES, so a new key is one entry in the
197
+ * schema and no change here. Reading the key names from the data while
198
+ * hand-writing every type check beside it would leave the half nothing reads
199
+ * free to drift from the half that decides — with the data half being the one no
200
+ * test would catch.
201
+ */
202
+ function readBlock(
203
+ block: Record<string, unknown>,
204
+ properties: Readonly<Record<string, WorkspaceKeySchema>>,
205
+ where: string,
206
+ at: (string | number)[],
207
+ report: Report,
208
+ ): Record<string, unknown> {
209
+ reportUnknownKeys(block, properties, where, at, report);
210
+ const out: Record<string, unknown> = {};
211
+ for (const [name, schema] of Object.entries(properties)) {
212
+ const value =
213
+ schema.type === "string"
214
+ ? readString(block[name], where, [...at, name], report)
215
+ : schema.type === "string[]"
216
+ ? readStringList(block[name], where, [...at, name], report)
217
+ : readModuleEntries(block[name], where, report);
218
+ if (value !== undefined) out[name] = value;
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function readModuleEntries(raw: unknown, where: string, report: Report): readonly ModuleEntry[] {
224
+ if (raw === undefined || raw === null) return [];
225
+ if (!Array.isArray(raw)) {
226
+ report(
227
+ "WORKSPACE_INVALID_VALUE",
228
+ `${where}: 'release.modules' must be a list of path patterns, e.g. [modules/*, apps/*].`,
229
+ ["release", "modules"],
76
230
  );
231
+ return [];
77
232
  }
78
- if (modules.length === 0) {
79
- throw new WorkspaceConfigError(
80
- `${where}: 'modules' is empty, so no directory can ever be discovered as a module. ` +
233
+ if (raw.length === 0) {
234
+ report(
235
+ "WORKSPACE_INVALID_VALUE",
236
+ `${where}: 'release.modules' is empty, so no directory can ever be discovered as a module. ` +
81
237
  `List the subtrees that hold them, e.g. [modules/*, apps/*].`,
238
+ ["release", "modules"],
239
+ );
240
+ return [];
241
+ }
242
+
243
+ const entries: ModuleEntry[] = [];
244
+ for (const [index, item] of raw.entries()) {
245
+ const at = ["release", "modules", index];
246
+ if (typeof item === "string") {
247
+ entries.push({ path: item });
248
+ continue;
249
+ }
250
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
251
+ report(
252
+ "WORKSPACE_INVALID_VALUE",
253
+ `${where}: 'release.modules' entry ${index} must be a pattern string, or a mapping ` +
254
+ `carrying 'path:' plus the keys it overrides.`,
255
+ at,
256
+ );
257
+ continue;
258
+ }
259
+ const entry = item as Record<string, unknown>;
260
+ const read = readBlock(entry, MODULE_ENTRY_KEYS, where, at, report);
261
+ const path = read.path as string | undefined;
262
+ if (path === undefined) {
263
+ report(
264
+ "WORKSPACE_INVALID_VALUE",
265
+ `${where}: 'release.modules' entry ${index} declares no 'path:', so it matches nothing.`,
266
+ at,
267
+ );
268
+ continue;
269
+ }
270
+ entries.push({
271
+ path,
272
+ ...(read.registry !== undefined ? { registry: read.registry as string } : {}),
273
+ ...(read.ignore !== undefined ? { ignore: read.ignore as readonly string[] } : {}),
274
+ });
275
+ }
276
+ return entries;
277
+ }
278
+
279
+ function readEnv(raw: unknown, where: string, report: Report): EnvSettings | undefined {
280
+ if (raw === undefined || raw === null) return undefined;
281
+ if (typeof raw !== "object" || Array.isArray(raw)) {
282
+ report("WORKSPACE_INVALID_VALUE", `${where}: 'env' must be a mapping.`, ["env"]);
283
+ return undefined;
284
+ }
285
+ const block = raw as Record<string, unknown>;
286
+ const read = readBlock(block, WORKSPACE_SCHEMA.env.properties, where, ["env"], report);
287
+ const roots = read.roots as readonly string[] | undefined;
288
+ const files = read.files as readonly string[] | undefined;
289
+ if (files) {
290
+ for (const [index, name] of files.entries()) {
291
+ // A path would let one entry reach outside the bound `roots:` exists to
292
+ // state, so the list is filenames and the refusal is structural.
293
+ if (name.includes("/") || name.includes("\\") || name.includes("*")) {
294
+ report(
295
+ "WORKSPACE_INVALID_VALUE",
296
+ `${where}: 'env.files' entry '${name}' is a path or a glob. It names a file collected in ` +
297
+ `each directory the walk visits, so it must be a bare filename — the walk's reach is ` +
298
+ `'env.roots'.`,
299
+ ["env", "files", index],
300
+ );
301
+ }
302
+ }
303
+ }
304
+ return {
305
+ ...(roots !== undefined ? { roots } : {}),
306
+ ...(files !== undefined ? { files } : {}),
307
+ };
308
+ }
309
+
310
+ function reportUnknownKeys(
311
+ block: Record<string, unknown>,
312
+ known: Readonly<Record<string, unknown>>,
313
+ where: string,
314
+ at: (string | number)[],
315
+ report: Report,
316
+ ): void {
317
+ const names = Object.keys(known);
318
+ for (const key of Object.keys(block)) {
319
+ if (names.includes(key)) continue;
320
+ const near = nearMiss(key, names);
321
+ report(
322
+ "WORKSPACE_UNKNOWN_KEY",
323
+ `${where}: unknown key '${[...at, key].join(".")}'.` +
324
+ (near ? ` Did you mean '${near}'?` : ` Known keys: ${names.map((n) => `'${n}'`).join(", ")}.`),
325
+ [...at, key],
326
+ );
327
+ }
328
+ }
329
+
330
+ function readString(
331
+ raw: unknown,
332
+ where: string,
333
+ at: (string | number)[],
334
+ report: Report,
335
+ ): string | undefined {
336
+ if (raw === undefined || raw === null) return undefined;
337
+ if (typeof raw !== "string") {
338
+ report("WORKSPACE_INVALID_VALUE", `${where}: '${at.join(".")}' must be a string.`, at);
339
+ return undefined;
340
+ }
341
+ if (raw === "") {
342
+ // Reported rather than read as an absence: an empty value would fall
343
+ // through to the next rung of the cascade and publish somewhere the author
344
+ // did not name, which is the one thing a declared destination must not do.
345
+ report("WORKSPACE_INVALID_VALUE", `${where}: '${at.join(".")}' is empty.`, at);
346
+ return undefined;
347
+ }
348
+ return raw;
349
+ }
350
+
351
+ function readStringList(
352
+ raw: unknown,
353
+ where: string,
354
+ at: (string | number)[],
355
+ report: Report,
356
+ ): readonly string[] | undefined {
357
+ if (raw === undefined || raw === null) return undefined;
358
+ if (!Array.isArray(raw)) {
359
+ report("WORKSPACE_INVALID_VALUE", `${where}: '${at.join(".")}' must be a list of strings.`, at);
360
+ return undefined;
361
+ }
362
+ const out: string[] = [];
363
+ for (const [index, item] of raw.entries()) {
364
+ if (typeof item !== "string") {
365
+ report("WORKSPACE_INVALID_VALUE", `${where}: '${at.join(".")}' entry ${index} must be a string.`, [
366
+ ...at,
367
+ index,
368
+ ]);
369
+ continue;
370
+ }
371
+ out.push(item);
372
+ }
373
+ return out;
374
+ }
375
+
376
+ /** One edit apart, case-insensitively — enough to separate a typo from a key
377
+ * this reader is simply too old to know. */
378
+ function nearMiss(key: string, known: readonly string[]): string | undefined {
379
+ const lower = key.toLowerCase();
380
+ return known.find((name) => name !== key && editDistanceAtMostOne(lower, name.toLowerCase()));
381
+ }
382
+
383
+ function editDistanceAtMostOne(a: string, b: string): boolean {
384
+ if (a === b) return true;
385
+ if (Math.abs(a.length - b.length) > 1) return false;
386
+ let i = 0;
387
+ let j = 0;
388
+ let edits = 0;
389
+ while (i < a.length && j < b.length) {
390
+ if (a[i] === b[j]) {
391
+ i++;
392
+ j++;
393
+ continue;
394
+ }
395
+ if (++edits > 1) return false;
396
+ if (a.length > b.length) i++;
397
+ else if (a.length < b.length) j++;
398
+ else {
399
+ i++;
400
+ j++;
401
+ }
402
+ }
403
+ return edits + (a.length - i) + (b.length - j) <= 1;
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Diagnostics as a caller's policy
408
+ // ---------------------------------------------------------------------------
409
+
410
+ /** The diagnostics anchored inside one top-level block, plus the ones anchored
411
+ * at the document itself (unreadable YAML, a non-mapping root) — those are
412
+ * every block's problem. */
413
+ export function diagnosticsFor(
414
+ diagnostics: readonly WorkspaceDiagnostic[],
415
+ block: string,
416
+ ): readonly WorkspaceDiagnostic[] {
417
+ return diagnostics.filter((d) => d.path.length === 0 || d.path[0] === block);
418
+ }
419
+
420
+ export function hasError(diagnostics: readonly WorkspaceDiagnostic[]): boolean {
421
+ return diagnostics.some((d) => d.severity === "error");
422
+ }
423
+
424
+ /**
425
+ * The release settings, or a throw naming what is missing.
426
+ *
427
+ * `telo release` is the one caller for which any error is fatal, and for which
428
+ * an absent block is itself one: a marker with no `release:` anchors a cache and
429
+ * declares no release scope, which is exactly the state a runner's seeded marker
430
+ * is in.
431
+ */
432
+ export function requireReleaseSettings(
433
+ read: WorkspaceRead,
434
+ where: string,
435
+ ): ReleaseSettings {
436
+ const first = read.diagnostics.find((d) => d.severity === "error");
437
+ if (first) throw new WorkspaceConfigError(first.message);
438
+ if (!read.config.release || read.config.release.modules.length === 0) {
439
+ throw new WorkspaceConfigError(
440
+ `${where} declares no 'release.modules', so no directory can be discovered as a module. ` +
441
+ `List the subtrees that hold them:\n\n release:\n modules:\n - modules/*\n - apps/*\n`,
82
442
  );
83
443
  }
84
- return { modules: modules as string[] };
444
+ return read.config.release;
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Resolution
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Gitignore-style matching over one authored list.
453
+ *
454
+ * Injected rather than imported: the grammar is the glob package's, and this
455
+ * half is browser-safe. Returns the index of the LAST pattern matching `path`,
456
+ * or `-1` — which is what makes a decision attributable to the entry that made
457
+ * it (the `buildImportUpgrades` environment precedent).
458
+ */
459
+ export type PatternMatch = (path: string, patterns: readonly string[]) => number;
460
+
461
+ export interface ModuleSettings {
462
+ /** Index into `release.modules` of the entry that decided this module. */
463
+ readonly entry: number;
464
+ readonly registry?: string;
465
+ readonly ignore: readonly string[];
466
+ }
467
+
468
+ /**
469
+ * Which entry claims `key`, and what it therefore settles.
470
+ *
471
+ * **The last matching entry supplies them** — the rule the list already has,
472
+ * since it is one gitignore-style list evaluated last-match-wins. A negation
473
+ * entry is an exclusion and therefore supplies nothing: a module the last match
474
+ * excludes is not a module of this workspace at all, which is why this returns
475
+ * `undefined` rather than a settings object for it.
476
+ *
477
+ * The cascade is key-wise and each key's value replaces whole, so an entry
478
+ * naming only `registry:` keeps the block's `ignore:`.
479
+ */
480
+ export function settingsForModule(
481
+ release: ReleaseSettings,
482
+ key: string,
483
+ match: PatternMatch,
484
+ ): ModuleSettings | undefined {
485
+ const index = decidingIndex(key, release.modules.map((entry) => entry.path), match);
486
+ if (index < 0) return undefined;
487
+ const entry = release.modules[index]!;
488
+ return {
489
+ entry: index,
490
+ ...(entry.registry ?? release.registry
491
+ ? { registry: entry.registry ?? release.registry }
492
+ : {}),
493
+ ignore: entry.ignore ?? release.ignore ?? DEFAULT_RELEASE_IGNORE,
494
+ };
495
+ }
496
+
497
+ /** Whether `path` is matched by an authored pattern list, negations honoured.
498
+ * `env.roots` and a resolved `ignore` list are both read through this. */
499
+ export function matchesPatterns(
500
+ path: string,
501
+ patterns: readonly string[],
502
+ match: PatternMatch,
503
+ ): boolean {
504
+ return decidingIndex(path, patterns, match) >= 0;
505
+ }
506
+
507
+ /**
508
+ * The index of the entry that CLAIMS `path`, or `-1`.
509
+ *
510
+ * The one place last-match-wins and the negation reading live: every pattern
511
+ * list in this file rests on that rule, and two derivations of it would be two
512
+ * answers to what a marker means.
513
+ */
514
+ function decidingIndex(
515
+ path: string,
516
+ patterns: readonly string[],
517
+ match: PatternMatch,
518
+ ): number {
519
+ const index = match(path, patterns);
520
+ return index >= 0 && patterns[index]!.startsWith("!") ? -1 : index;
85
521
  }