@ultimat3/cli 28.0.0 → 29.1.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.
@@ -1,9 +1,9 @@
1
- // The public origin and the crawler rules out of `app.config.ts` — `site.origin`,
2
- // `seo.robots.disallow` and `seo.robots.groups` — and the one resolution of "which origin is this site served on" every
1
+ // The public origin, the app's favicon and the crawler rules out of `app.config.ts` — `site.origin`,
2
+ // `site.favicon`, `seo.robots.disallow` and `seo.robots.groups` — and the one resolution of "which origin is this site served on" every
3
3
  // absolute URL a document, a sitemap or a robots file carries is built against. Read off the one
4
4
  // loader (`app-config-load.ts`).
5
5
 
6
- import type { Environment, SeoRobotsGroup, SeoSitemapConfig } from '@ultimat3/core';
6
+ import type { Environment, SeoRobotsGroup, SeoSitemapConfig, SiteFavicon } from '@ultimat3/core';
7
7
  import { loadAppConfig } from './app-config-load';
8
8
 
9
9
  export interface SiteSettings {
@@ -18,6 +18,14 @@ export interface SiteSettings {
18
18
  readonly robotsGroups?: readonly SeoRobotsGroup[] | undefined;
19
19
  /** `seo.sitemap` — public `app/` pages to list, where `<lastmod>` comes from, sections, page size. */
20
20
  readonly sitemap: SeoSitemapConfig;
21
+ /**
22
+ * `site.favicon` — the app's SVG favicon, named as a site asset path (`assets/brand/icon.svg`).
23
+ * `undefined` when the app declares none. The boot resolves it ONCE, through the site asset
24
+ * table (`siteAssetTable(root).resolve(path).url` — the table `/assets/*` serves and `asset()`
25
+ * answers from), into the content-hashed URL every document carries. Optional so a
26
+ * `SiteSettings` literal written before it existed still compiles.
27
+ */
28
+ readonly favicon?: SiteFavicon | undefined;
21
29
  }
22
30
 
23
31
  export const NO_SITE_SETTINGS: SiteSettings = {
@@ -25,6 +33,7 @@ export const NO_SITE_SETTINGS: SiteSettings = {
25
33
  disallow: [],
26
34
  robotsGroups: [],
27
35
  sitemap: { extra: [], lastmod: 'none', sections: [], pageSize: 50_000 },
36
+ favicon: undefined,
28
37
  };
29
38
 
30
39
  const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
@@ -32,12 +41,13 @@ const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
32
41
  export async function loadSiteSettings(root: string): Promise<SiteSettings> {
33
42
  const config = await loadAppConfig(root);
34
43
  if (config === undefined) return NO_SITE_SETTINGS;
35
- const { origin } = config.site;
44
+ const { origin, favicon } = config.site;
36
45
  return {
37
46
  origin: origin === null ? null : trimSlash(origin),
38
47
  disallow: [...config.seo.robots.disallow],
39
48
  robotsGroups: [...(config.seo.robots.groups ?? [])],
40
49
  sitemap: config.seo.sitemap,
50
+ favicon,
41
51
  };
42
52
  }
43
53
 
@@ -32,16 +32,16 @@ const dockerfile = (
32
32
  # from the file itself, and without it the frozen install cannot find what the lockfile names.
33
33
  # The tag names the series for the reader; the digest is what builds — a tag is a pointer its
34
34
  # publisher can move. Dependabot's docker ecosystem moves the digest together with the tag.
35
- # The digest is \`oven/bun:1.4.3-alpine\`'s, the patch the framework that wrote this file was
35
+ # The digest is \`oven/bun:1.4.4-alpine\`'s, the patch the framework that wrote this file was
36
36
  # tested on — the same patch as \`engines.bun\` in package.json.
37
- FROM oven/bun:1.4-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS manifests
37
+ FROM oven/bun:1.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a AS manifests
38
38
  WORKDIR /app
39
39
  COPY . .
40
40
  RUN find . ! -type d ! -name package.json ! -name bunfig.toml ! -name '*.tgz' -delete \\
41
41
  && find . -type d -empty -delete
42
42
 
43
43
  # ---------- deps: runtime dependencies only, cached on the lockfile and the manifests ----------
44
- FROM oven/bun:1.4-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS deps
44
+ FROM oven/bun:1.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a AS deps
45
45
  WORKDIR /app
46
46
  COPY --from=manifests /app ./
47
47
  COPY bun.lock ./
@@ -45,11 +45,11 @@ const TYPESCRIPT_VERSION = '^7.0.2';
45
45
 
46
46
  /**
47
47
  * The Solid runtime every island and `@ultimat3/ui` component renders with, exact for the reason
48
- * `BIOME_VERSION` is: the CLI compiles islands with `babel-preset-solid ^1.9.15`, and a scaffold
48
+ * `BIOME_VERSION` is: the CLI compiles islands with `babel-preset-solid ^1.9.16`, and a scaffold
49
49
  * that installed an older runtime than the compiler targets drifted as a buried literal (1.9.14
50
50
  * beside the repository's 1.9.15). `scaffold-repo.test.ts` holds it equal to the root manifest's.
51
51
  */
52
- const SOLID_VERSION = '1.9.15';
52
+ const SOLID_VERSION = '1.9.17';
53
53
 
54
54
  // `engines.bun` is `REQUIRED_BUN`, the floor the SHIPPED `x` enforces
55
55
  // (`packages/cli/src/app-root.ts`), and not a second literal. It was one: `>=1.3.0`, a whole minor
@@ -1,13 +1,16 @@
1
1
  // The workspace dependency graph, in the two forms that must agree: the edges every manifest
2
2
  // DECLARES, the edges every shipped source file actually IMPORTS, and the gate rule that the
3
- // second is a subset of the first. An edge that lives only in a tsconfig `paths` entry resolves
4
- // for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
3
+ // second is covered by the first, with no unused framework sibling declarations. A tsconfig-only
4
+ // edge resolves for `tsc` but is invisible to bun, `--filter` ordering and change-detection tools.
5
5
 
6
6
  import { join } from 'node:path';
7
7
  import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
8
+ import { CATALOG_PACKAGES } from './error-catalog';
8
9
  import { scanAllImports } from './import-scan';
9
10
  import type { Finding } from './output';
10
- import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
11
+ import { FIXTURE_SUFFIX } from './publish-closure';
12
+ import { quoteArg } from './shell-quote';
13
+ import { eachSourceFile, isGenerated, isTest, isVendored, SOURCE_GLOBS } from './source-files';
11
14
 
12
15
  export interface WorkspaceNode {
13
16
  /** package.json "name". */
@@ -176,7 +179,7 @@ export const undeclaredWorkspaceDepFinding = (
176
179
  cause: `${at} imports ${to.name}, which ${from.dir}/package.json does not declare`,
177
180
  // The exact line to paste, at the version the target really carries: a `workspace:*` range would
178
181
  // resolve and then fail `checkLockstep`, which compares a sibling pin against the version.
179
- fix: `add "${to.name}": "${to.version ?? '0.0.0'}" to "dependencies" in ${from.dir}/package.json`,
182
+ fix: `add "${to.name}": "${to.version ?? '0.0.0'}" to "${at.endsWith(FIXTURE_SUFFIX) ? 'devDependencies' : 'dependencies'}" in ${from.dir}/package.json`,
180
183
  docs: ERROR_DOCS_URL,
181
184
  at: `${from.dir}/package.json`,
182
185
  });
@@ -186,10 +189,9 @@ export const undeclaredWorkspaceDepFinding = (
186
189
  * declared none of it — the imports resolved through the root tsconfig's `paths`, so the edges
187
190
  * existed only inside `tsc` and every tool that asks what a change affects answered short.
188
191
  *
189
- * Shipped source only. A test file is excluded from the tarball by every package here
190
- * (`TEST_EXCLUSION`) and resolves its imports through the ROOT manifest's hoisted devDependencies,
191
- * which is why no `packages/*` in this repo carries a `devDependencies` block of its own —
192
- * judging test imports would demand 29 of them to describe a resolution that already works.
192
+ * Tests resolve through the root's hoisted devDependencies and are excluded here. Fixtures still
193
+ * need forward declarations, but their imports justify only development dependencies: they are
194
+ * excluded from tarballs and must not keep unused shipped dependency fields alive.
193
195
  *
194
196
  * One finding per (workspace, dependency) pair, never one per import site: the fix is one line in
195
197
  * one manifest, and repeating it per file is noise an author has to deduplicate by hand.
@@ -202,6 +204,8 @@ export async function checkWorkspaceDependencies(root: string): Promise<readonly
202
204
  // Deepest directory first: a nested workspace's files are its own, never its parent's.
203
205
  const owners = [...graph].sort((left, right) => right.dir.length - left.dir.length);
204
206
  const reported = new Set<string>();
207
+ const used = new Set<string>();
208
+ const fixtureUsed = new Set<string>();
205
209
  for await (const path of eachSourceFile(root)) {
206
210
  if (isTest(path) || isGenerated(path)) continue;
207
211
  const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
@@ -209,12 +213,55 @@ export async function checkWorkspaceDependencies(root: string): Promise<readonly
209
213
  for (const name of importedPackages(await Bun.file(join(root, path)).text(), path)) {
210
214
  const target = byName.get(name);
211
215
  if (target === undefined || target.name === owner.name) continue;
212
- if (owner.dependencies.includes(target.name)) continue;
213
216
  const pair = `${owner.dir} ${target.name}`;
217
+ // Fixtures still owe forward declarations, but never justify a shipped dependency.
218
+ (path.endsWith(FIXTURE_SUFFIX) ? fixtureUsed : used).add(pair);
219
+ if (owner.dependencies.includes(target.name)) continue;
214
220
  if (reported.has(pair)) continue;
215
221
  reported.add(pair);
216
222
  findings.push(undeclaredWorkspaceDepFinding(owner, target, path));
217
223
  }
218
224
  }
225
+ for (const owner of graph) {
226
+ // Only infer absence where the source contract scans. Match directory prefixes rather than
227
+ // observed files so a covered workspace with no source still owes removal of unused siblings.
228
+ const covered = SOURCE_GLOBS.some((pattern) => {
229
+ const directories = pattern.split('/').slice(0, -1);
230
+ return directories.some((_, index) =>
231
+ new Bun.Glob(directories.slice(0, index + 1).join('/')).match(owner.dir),
232
+ );
233
+ });
234
+ if (!covered || isVendored(owner.dir)) continue;
235
+ const unused = owner.dependencies.filter(
236
+ (name) =>
237
+ name.startsWith('@ultimat3/') &&
238
+ !used.has(`${owner.dir} ${name}`) &&
239
+ !(owner.name === '@ultimat3/cli' && CATALOG_PACKAGES.some((root) => root === name)),
240
+ );
241
+ if (unused.length === 0) continue;
242
+ const path = `${owner.dir}/package.json`;
243
+ const manifest = await readManifest(join(root, path));
244
+ for (const name of unused) {
245
+ const fields = DEPENDENCY_FIELDS.filter(
246
+ (field) =>
247
+ Object.hasOwn(asRecord(manifest?.[field]) ?? {}, name) &&
248
+ !(field === 'devDependencies' && fixtureUsed.has(`${owner.dir} ${name}`)),
249
+ );
250
+ if (fields.length === 0) continue;
251
+ // A package-wide remove would also delete a valid fixture-only development declaration.
252
+ const script =
253
+ 'const [path, name, fields] = Bun.argv.slice(-3); ' +
254
+ 'const manifest = await Bun.file(path).json(); ' +
255
+ 'for (const field of JSON.parse(fields)) delete manifest[field]?.[name]; ' +
256
+ 'await Bun.write(path, JSON.stringify(manifest, null, 2) + "\\n");';
257
+ findings.push({
258
+ code: 'X_PACKAGE_SHAPE',
259
+ cause: `${path} declares ${name} in ${fields.join(', ')}, but no eligible source in ${owner.dir} imports it`,
260
+ fix: `bun -e ${quoteArg(script)} -- ${quoteArg(path)} ${quoteArg(name)} ${quoteArg(JSON.stringify(fields))}`,
261
+ docs: ERROR_DOCS_URL,
262
+ at: path,
263
+ });
264
+ }
265
+ }
219
266
  return findings;
220
267
  }