Skip to content

Commit 3bd28e2

Browse files
feat(spec): a package-id segment may open with a digit, and the refusal states the rule the pattern enforces (#20172)
Fixes #19870 Clause-②: yes Implements ruling `5807946372` on this card (maintainer, verbatim: 「19870 去掉」; re-graded p2 in `5808347937`), items 1 to 5, in the package-ID region of `manifest.zod.ts` / `manifest.test.ts` only. The VERSION-grammar hunks that draft PR #19637 carries in the same two files are untouched; a no-driver merge-tree probe of this head against `origin/main` `369bcbe` is clean. ## What changes - **`MANIFEST_ID_PATTERN`** (`packages/spec/src/kernel/manifest.zod.ts`) becomes `/^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/` (item 1). Additive widening: every id legal before stays legal; `com.163.crm`, `com.example.2app` and `local.2024-app` become legal. Still refused: a hyphen-led segment, an underscore, upper case, a single segment, an empty segment. - **The refusal sentence** (`manifestIdRefusal`) now reads `... Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments of letters, digits and inner hyphens; a segment may not open with a hyphen; underscores are not admitted.` The pattern docblock loses 「Each segment starts with a letter」 and says why a digit may open a segment; the `id` TSDoc states the rule (item 2). `MANIFEST_ID_EXAMPLES` unchanged. A digit-led bare word now gets the prefixed repair (`2fa` gets `Did you mean 'com.example.2fa'?`), where it used to get none. - **Pins** (item 3), `manifest.test.ts`: one `ACCEPTED` / `REFUSED` table. `com.example.2app` moved to accepted with `com.163.crm` beside it; each accepted row asserts the id parses to itself. `com.example.-app`, `com.example.my_app` and `Com.Example.App` stay refused, each asserting `invalid_format` and the declaration's own sentence. The `PackageSchema.manifestId` door is judged once on the same table, the expected verdict read from the table and never from the pattern. One more pin: the sentence names digits and the leading-hyphen rule and states no leading-letter clause. - **`manifest.namespace` is untouched** (item 4). Its leading-letter rule stays for the SQL-identifier reason. - **Changeset**: `@objectstack/spec` **minor** (item 5), `.changeset/19870-manifest-id-digit-led-segment.md`. ## Repo-wide pin sweep (card-class clauses ① and ②) Swept by RULE, not by literal: every string literal in `git ls-files` (ts/mts/mjs/js/json/md/mdx/yml, release-owned trees excluded) that the OLD pattern refuses and the NEW one accepts. That gave 586 raw hits, triaged by hand to the package-id ones. Every same-semantic pin is flipped in this PR, and each flipped pin asserts the new behaviour rather than just dropping the old assertion: | where | was | now | |---|---|---| | `packages/metadata-protocol/src/protocol.install-manifest-id.test.ts` | `com.4example.crm` a REFUSED row | an ADMITTED lit control: installs, and the registry receives the id as written. The refused reason is kept by `com.-example.crm` (hyphen-led) at the install and duplicate doors | | `packages/cloud-connection/src/marketplace-install-local-id-gate.test.ts` | `com.1acme.crm` a REFUSED row | a lit control: `200`, `manifestId` echoed, registered and filed as `com.1acme.crm.json`. The refused reason is kept by `com.-acme.crm` | | `packages/cli/test/package-publish-manifest-id.test.ts` | `9foo.bar` in the relaxation table; `2024 App` derived `local.2024-app` and was refused | CLI and spec agree `9foo.bar`, `com.163.crm`, `local.2024-app` are ids. `2024 App` publishes: exit 0, both POSTs, wire `manifest_id` `local.2024-app`, not rewritten. Every slug the derivation can produce, from the name or the filename, parses | | same file, anti-transcription control | the old pattern literal, described as "the schema's own pattern" | the widened literal | Prose that stated the retired rule, made false by this change: - `packages/spec/src/migrations/entries/semantic/18.manifest-id-reverse-domain-required.ts`: the unreleased v18 prescription said "each opening with a letter". Reworded, and `registry.ts` regenerated with `gen:migration-registry` (not hand-edited). - `content/docs/deployment/cli.mdx` and the `publish.ts` docblocks: they said `local.2024-app` is rejected, and listed `9foo.bar` as a shape the control plane refuses. These are comment and docs changes only. ## ⚠ Three PENDING release notes corrected (confirmation requested) `check-empty-changeset` is red on this PR **by design**. This is its DELIBERATE CORRECTION class. Three pending changesets on `main` quote the refusal sentence verbatim in a `text` block, and this PR changes that sentence. Each block now quotes what the door will actually print. Nothing else in those notes moved: their "If you are refused" prose was already true under the widened rule. - `.changeset/19417-install-door-parses-manifest-id.md`: the `pkg-a` refusal quote. - `.changeset/19417-protocol-install-primitive-parses-manifest-id.md`: the `com.example.my_erp` refusal quote. - `.changeset/19576-install-local-id-gate.md`: the `late-app` refusal quote. Restoring them from base would ship a sentence no door prints. `skip-changeset` is deliberately **not** applied. **Please confirm these three edits in writing on this PR.** `Check Changeset` is not a required context. ## PM mechanism hypotheses — measured - **H1, census.** None of the seven named files keeps its own copy of the rule. Each imports `MANIFEST_ID_PATTERN`, `manifestIdRefusal` or `ManifestSchema`, or names them in a comment. Pins that refused a digit-led segment under another spelling: the three consumer rows above. Two producers still assume a letter-led last segment and prefix a digit-led name (`2fa` becomes `app-2fa`): `manifestIdSlug` (`packages/cli/src/commands/init.ts`) and `deriveManifestId` (`packages/create-objectstack/src/rewrite-identity.ts`). Their output stays legal, so they are left as they are (Acceptance notes). - **H2 holds, by reading the running code.** `deriveNamespaceFromPackageId` from the built spec gives: `com.example.2app` → `app`, `com.163.crm` → `crm`, `com.4example.crm` → `crm`, `com.example.123` → `null`. `namespace-prefix.test.ts` is green, and so is the metadata-protocol duplicate-door namespace test. - **H3, objectui.** It has one local copy of the rule: `PACKAGE_ID_RE` in `packages/app-shell/src/views/studio-design/packages-io.ts:244`, the same at the pin `f8a9d0fb05` and at `origin/main` `c3a26cc`. That copy requires a letter first, lets a later segment open with a digit, and admits underscores. The one new drift this PR adds: a digit-led FIRST segment (`9foo.bar`) is now legal in spec, and the Studio duplicate form's client check still refuses it. objectui does not import the spec constant, and nothing is removed or renamed, so the Console Pin Gate is unaffected. - **H4, cloud.** NOT MEASURED: the repository is not attached to this container. The ruling's own reading (no DB-level pattern on `manifest_id`, cloud `b524051`) is carried as a lead. ## Verification, at head `a97a38db0` - `@objectstack/spec`: full suite `vitest run --project local`: **540 files, 15800 passed, 2 todo**. `typecheck` (tsc, scripts, test layer): exit 0. Focused (`manifest`, `namespace-prefix`, `package-namespace`, `template-manifest-id`, `package-api`): 5 files, 188 passed. - Consumer packages, one reading each: - metadata-protocol (`protocol.install-manifest-id`, `protocol.duplicate-package-target-namespace`): 32 passed, `typecheck` 0 errors. - runtime (`packages-install-manifest-id`, `artifact-granted-permissions`, `domain-handler-registry`): 101 passed. - cli (`init-manifest-id`, `nav-contribution-groups.package-id`, `package-publish-manifest-id`): 43 passed, `typecheck` 0 errors. - create-objectstack (`rewrite-identity`): 22 passed. - cloud-connection (`marketplace-install-local-id-gate`): 23 passed. It has no `typecheck` script. - **Ablation**, a one-time proof run from the committed state through `scripts/ablation-replace.mjs`. Both legs landed on disk, and each restore was proven: blob == HEAD blob `f0913fa`, `git diff HEAD` empty. - Leg A put the pattern back to the leading-letter rule: `manifest.test.ts` went **5 red**. That is the two accepted digit rows, the shared-table door test, the `2fa` repair, and the every-suggestion-parses test. - Leg B put the old sentence clause back: **1 red**, the sentence pin. - Direction: turns red, as predicted. - **Generated artifacts:** `pnpm --filter @objectstack/spec check:generated --fix` reports all 15 up to date against a dist it built. The only regenerated file is `registry.ts`. - **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` gives 115 derived, 110 run, 5 NOT MEASURED, 0 unrun. Of the 110 run, 109 exit 0 and `check-empty-changeset` exits 1 by design (above). - NOT MEASURED, prerequisite refusal (exit 3), needing a whole-workspace build: `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. - NOT MEASURED, no verdict: `check:type-check-debt`. Its whole-workspace re-measure was cut off by the batch timeout. - CI owns all five. ## Acceptance notes None of these is filed. Each is an observation with no carrier (承接者:无). - The scaffolders' prefix for a digit-led project name (`2fa` becomes `app-2fa` in `os init`; `123` becomes `com.example.app-123` in create-objectstack) is no longer required by the rule. Their comment "A segment must OPEN with a letter" now states a retired clause. Their output pins stay true as pins of the output. - `os package publish`: the refusal remedy arms for DERIVED sources (`manifestIdRemedy`, `artifact-manifest-name` / `artifact-filename`) are now unreachable. A slug is always one legal segment, so a derived id always parses. The arm for a declared `manifest.id` stays live. - objectui `PACKAGE_ID_RE` (H3): a second transcription of the rule. It was already looser (it admits underscores, which the server refuses loudly) and is now stricter on a digit-led first segment. Not exercised. - `deriveNamespaceFromPackageId` drops a leading digit run, even when that run is part of a word (`com.example.2fa-leave` gives `fa_leave`). This is pre-existing, only a default, and the author can declare `namespace`. - The Studio plugin id (`studio/plugin.zod.ts`, and `content/docs/plugins/development.mdx`) keeps its own leading-letter pattern. It is a different key under a different rule, not a package id. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a243cfb commit 3bd28e2

13 files changed

Lines changed: 196 additions & 91 deletions

‎.changeset/19417-install-door-parses-manifest-id.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ verifies its candidate against the pattern before offering it. Posting
3434

3535
```text
3636
Invalid package id 'pkg-a' on `manifest.id`. Expected reverse-domain notation
37-
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
38-
hyphens allowed inside a segment, underscores are not. Did you mean
39-
'com.example.pkg-a'?
37+
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments
38+
of letters, digits and inner hyphens; a segment may not open with a hyphen;
39+
underscores are not admitted. Did you mean 'com.example.pkg-a'?
4040
```
4141

4242
**What is not affected.** Boot-time and in-process installs reach

‎.changeset/19417-protocol-install-primitive-parses-manifest-id.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ offering it. Installing `id: 'com.example.my_erp'` now throws, with:
4242
```text
4343
Invalid package id 'com.example.my_erp' on `manifest.id`. Expected
4444
reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase
45-
dot-separated segments; hyphens allowed inside a segment, underscores are not.
46-
Did you mean 'com.example.my-erp'?
45+
dot-separated segments of letters, digits and inner hyphens; a segment may not
46+
open with a hyphen; underscores are not admitted. Did you mean
47+
'com.example.my-erp'?
4748
```
4849

4950
**The duplicate door refuses BEFORE it mints anything.** `duplicatePackage`

‎.changeset/19576-install-local-id-gate.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ now answers, in `error.message`:
3131

3232
```text
3333
Invalid package id 'late-app' on `manifest.id`. Expected reverse-domain notation
34-
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
35-
hyphens allowed inside a segment, underscores are not. Did you mean
36-
'com.example.late-app'?
34+
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments
35+
of letters, digits and inner hyphens; a segment may not open with a hyphen;
36+
underscores are not admitted. Did you mean 'com.example.late-app'?
3737
```
3838

3939
**`manifest.name` is no longer read as an id.** `ManifestSchema` declares `id`;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a package-id segment may open with a digit, and the refusal states the rule the pattern enforces (#19870)
6+
7+
Clause-②: yes (widening) — the accept set of `ManifestSchema.id` and `PackageSchema.manifestId` (one shared constant, `MANIFEST_ID_PATTERN`) grows by the ids that carry a digit-led segment. Nothing previously admitted is refused, no key is added, removed or renamed, and no export moves.
8+
9+
**The rule, as the pattern now enforces it:** two or more lowercase dot-separated segments of letters, digits and inner hyphens. A segment may open with a letter or a digit — never with a hyphen — and underscores are not admitted. `com.163.crm`, `com.example.2app` and `local.2024-app` are package ids now; `com.example.-app`, `com.example.my_app` and `Com.Example.App` stay refused.
10+
11+
**Why the leading-letter clause went.** The package id is a registry key (`manifest_id`), a runtime package-map key and a grant-source key — never a table name, a JS identifier, a filesystem path or a hostname — and a DNS label may itself open with a digit. The clause had no downstream reason, so the refusal sentence could not state one: an author who followed the sentence could write `com.example.2app`, satisfy every clause it listed, and still be refused.
12+
13+
**The refusal sentence** is now `… Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments of letters, digits and inner hyphens; a segment may not open with a hyphen; underscores are not admitted.` It was `… — lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not.` The doors that surface it verbatim (`POST /api/v1/packages`, the protocol install and duplicate primitives, `POST /api/v1/marketplace/install-local`, `os package publish`) follow it with no change of their own. A digit-led bare word now gets the prefixed repair (`2fa` → `Did you mean 'com.example.2fa'?`), where it used to get none.
14+
15+
**What moves with it.** `os package publish` derives `local.<slug>` from `manifest.name` or the artifact filename when no id is declared; a slug is lowercase letters, digits and inner hyphens, so a derived id now always parses — a manifest named `2024 App` publishes as `local.2024-app` instead of being refused. A declared `manifest.id` is still used or refused, never replaced by a derived one.
16+
17+
**What does not move.** `manifest.namespace` — the physical object-name prefix (`crm` → `crm_account`) — keeps its own leading-letter rule, for the SQL-identifier reason. A namespace derived from a package id still strips a leading digit run from its last segment (`com.163.crm` → `crm`; `com.example.123` derives none, and the author declares `namespace`).
18+
19+
**If you relied on the refusal:** nothing you stored changes. A consumer that assumed a package id opens each segment with a letter should stop assuming it; parse it through `PackageSchema.shape.manifestId` or `ManifestSchema.shape.id` rather than a copy of the pattern.

‎content/docs/deployment/cli.mdx‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,7 +1900,7 @@ when both files' `url`s agree.
19001900
| `artifact` (positional) | — | Path to the compiled artifact (default `dist/objectstack.json`) |
19011901
| `-s, --server <url>` | `OS_CLOUD_URL` | Control-plane URL. Default `https://cloud.objectos.ai`, or the URL recorded by `os cloud login` |
19021902
| `-t, --token <key>` | `OS_CLOUD_API_KEY` | Bearer token; `$OS_TOKEN` and `~/.objectstack/cloud.json` are the fallbacks |
1903-
| `--manifest-id <id>` | `OS_PACKAGE_MANIFEST_ID` | Reverse-domain package id. Default: `artifact.manifest.id`, else `local.` + a slug of `manifest.name`, else `local.` + a slug of the artifact filename. A derived default is **not guaranteed usable** — see below |
1903+
| `--manifest-id <id>` | `OS_PACKAGE_MANIFEST_ID` | Reverse-domain package id. Default: `artifact.manifest.id`, else `local.` + a slug of `manifest.name`, else `local.` + a slug of the artifact filename. A declared `manifest.id` is used or refused, never replaced by a derived one — see below |
19041904
| `-v, --version <semver>` | — | Version to publish. Default: `artifact.manifest.version`, else `0.0.0-dev.` + a timestamp |
19051905
| `--display-name <name>` | — | Name shown in the Marketplace (default `artifact.manifest.name`) |
19061906
| `--description <text>` | — | Short package description |
@@ -1922,19 +1922,21 @@ when both files' `url`s agree.
19221922
| `-n, --note <markdown>` | — | Release notes |
19231923
| `--timeout <ms>` | `OS_CLOUD_TIMEOUT_MS` | HTTP timeout in milliseconds, default `120000`. `0` disables it |
19241924

1925-
**The derived `local.` id is a candidate, not a guaranteed default.** A derived
1926-
id is held to exactly the rule an explicit one is — `PackageSchema.manifestId`,
1927-
the schema for the very column publish writes — and slugifying has no
1928-
letter-first rule, so a manifest named `2024 App` derives `local.2024-app`,
1929-
which that schema rejects. `os package publish` then **refuses before any
1930-
network call**, quoting the schema and naming which of the three sources above
1931-
the id came from, rather than normalising it into a different permanent
1932-
identifier or forwarding one the control plane would reject: `manifest_id` is
1933-
immutable once published, so an id nobody wrote cannot be renamed afterwards.
1934-
The remedy is the one the refusal prints for that source — pass
1935-
`--manifest-id`, set `manifestId` in `objectstack.manifest.json`, or declare
1936-
`manifest.id` in `objectstack.config.ts` and rebuild. A declared
1937-
`manifest.id` is used or refused, never silently replaced by a derived one.
1925+
**Every id is held to one rule, whichever source it came from.** An explicit,
1926+
declared or derived id is parsed against `PackageSchema.manifestId` — the schema
1927+
for the very column publish writes: lowercase dot-separated segments of letters,
1928+
digits and inner hyphens, a segment never opening with a hyphen, no
1929+
underscores. A slug is lowercase letters, digits and inner hyphens, so a
1930+
derived `local.` id always satisfies it — a manifest named `2024 App` derives
1931+
`local.2024-app` — and it is published exactly as derived, never normalised
1932+
into a different identifier: `manifest_id` is immutable once published, so an
1933+
id nobody wrote cannot be renamed afterwards. A declared `manifest.id` is used
1934+
or refused, never silently replaced by a derived one: an id that fails the rule
1935+
is **refused before any network call**, quoting the schema and naming which of
1936+
the three sources above the id came from. The remedy is the one the refusal
1937+
prints for that source — pass `--manifest-id`, set `manifestId` in
1938+
`objectstack.manifest.json`, or fix `manifest.id` in `objectstack.config.ts`
1939+
and rebuild.
19381940

19391941
**`objectstack.manifest.json` supplies the listing fields.** When that file is
19401942
present in the working directory, publish reads `manifestId`, `displayName`,

‎packages/cli/src/commands/package/publish.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ import { readErrorMessage } from '../../utils/response-envelope.js';
4141
* The one rule for a manifest id is `PackageSchema.manifestId` — the schema for
4242
* the very column this command publishes into (`sys_package.manifest_id`).
4343
* Imported, never transcribed: a second, hand-copied rule here is what let this
44-
* preflight admit six shapes the control plane refuses (`com.acme.repair_desk`,
45-
* `COM.ACME.CRM`, `9foo.bar`, `com..acme`, `com.acme.` and — on the explicit
44+
* preflight admit shapes the control plane refuses (`com.acme.repair_desk`,
45+
* `COM.ACME.CRM`, `com..acme`, `com.acme.` and — on the explicit
4646
* `--manifest-id` path — a bare `crm`), while telling the user the contract was
4747
* `a-z0-9._-`. `CreatePackageRequestSchema` in `cloud/package.zod.ts` reaches
4848
* for the same declaration the same way.
@@ -144,11 +144,12 @@ export interface DerivedManifestId {
144144
*
145145
* ## Steps 2 and 3 are the CLI's own invention
146146
*
147-
* They are **not** guaranteed valid: `slugify` has no letter-first rule, so a
148-
* manifest named `2024 App` derives `local.2024-app`, which the schema rejects.
149-
* That is refused at the same gate with the source named, rather than
150-
* normalised — minting a different permanent identifier than the inputs imply
151-
* is worse than saying what is wrong.
147+
* `slugify` emits lowercase letters, digits and inner hyphens and never opens
148+
* with a hyphen — one legal segment under the declaration, which lets a segment
149+
* open with a digit — so a manifest named `2024 App` derives `local.2024-app`
150+
* and it parses. The derived id still passes through the same gate as every
151+
* other source, and it is never normalised: minting a different permanent
152+
* identifier than the inputs imply is worse than saying what is wrong.
152153
*/
153154
export function deriveManifestId(artifact: any, artifactPath: string): DerivedManifestId {
154155
const explicit = artifact?.manifest?.id;

‎packages/cli/test/package-publish-manifest-id.test.ts‎

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* -> tested against the local rule AND `explicit.includes('.')`.
2222
*
2323
* That extra dot condition is why a bare `crm` was already blocked on the
24-
* derive path while the explicit path let it through: five of the six shapes
24+
* derive path while the explicit path let it through: four of the five shapes
2525
* below held on both paths, `crm` on only one. After the fix both paths ask the
2626
* same schema, and the dot condition is gone because the schema subsumes it
2727
* (its pattern needs at least two segments).
@@ -70,15 +70,19 @@ const PUBLISH_SRC = resolve(HERE, '../src/commands/package/publish.ts');
7070
const MANIFEST_ID = PackageSchema.shape.manifestId;
7171

7272
/**
73-
* The six shapes the retired local rule admitted, with the pre-fix reading of
74-
* WHICH path admitted each — triage's correction to the card, kept here because
75-
* it is the reason every case below is asserted per path rather than once.
73+
* The shapes the retired local rule admitted and the declaration refuses, with
74+
* the pre-fix reading of WHICH path admitted each — triage's correction to the
75+
* card, kept here because it is the reason every case below is asserted per
76+
* path rather than once.
77+
*
78+
* `9foo.bar` (a digit-first segment) was a sixth row until the declaration let a
79+
* segment open with a digit; the two now agree that it is an id, so it sits
80+
* with the accepted cases below.
7681
*/
7782
const RELAXATIONS: ReadonlyArray<{ id: string; why: string; admittedOnDerivePathBefore: boolean }> = [
7883
{ id: 'crm', why: 'single segment', admittedOnDerivePathBefore: false },
7984
{ id: 'com.acme.repair_desk', why: 'underscore', admittedOnDerivePathBefore: true },
8085
{ id: 'COM.ACME.CRM', why: 'upper case', admittedOnDerivePathBefore: true },
81-
{ id: '9foo.bar', why: 'digit-first segment', admittedOnDerivePathBefore: true },
8286
{ id: 'com..acme', why: 'empty segment', admittedOnDerivePathBefore: true },
8387
{ id: 'com.acme.', why: 'trailing dot', admittedOnDerivePathBefore: true },
8488
];
@@ -166,6 +170,9 @@ describe('os package publish — the manifest-id rule is the spec manifest-id ru
166170
['local.acme-crm', true],
167171
['a.b', true],
168172
['com.acme.crm-2', true],
173+
['9foo.bar', true],
174+
['com.163.crm', true],
175+
['local.2024-app', true],
169176
...RELAXATIONS.map(({ id }) => [id, false] as const),
170177
['', false],
171178
['com.acme.crm ', false],
@@ -319,32 +326,44 @@ describe('os package publish — the manifest-id rule is the spec manifest-id ru
319326
});
320327

321328
// -------------------------------------------------------------------------
322-
// The producer half — the CLI also MAKES ids, and slugify has no
323-
// letter-first rule (the filer's addendum).
329+
// The producer half — the CLI also MAKES ids. `slugify` emits lowercase
330+
// letters, digits and inner hyphens and never opens with a hyphen: exactly
331+
// one legal segment, now that a segment may open with a digit. So a derived
332+
// `local.<slug>` id parses, and it is published AS DERIVED.
324333
// -------------------------------------------------------------------------
325334

326-
describe('a derived id the schema rejects is refused, not published and not rewritten', () => {
327-
it("refuses the digit-first id derived from a manifest named '2024 App'", async () => {
335+
describe('a derived id is published as derived, never rewritten', () => {
336+
it("publishes the digit-first id derived from a manifest named '2024 App'", async () => {
337+
// Refused while every segment had to open with a letter; the same bytes
338+
// now reach the wire unchanged.
328339
const derived = deriveManifestId({ manifest: { name: '2024 App' } }, '/nowhere/objectstack.json');
329340
expect(derived).toEqual({ id: 'local.2024-app', source: 'artifact-manifest-name' });
330-
expect(isManifestId(derived.id)).toBe(false);
341+
expect(isManifestId(derived.id)).toBe(true);
331342

332343
const path = await artifactAt({ name: '2024 App', version: '1.2.0' });
333344
const calls = stubCloud();
334345
const { exitCode, output } = await runPublish([path]);
335346

336-
expect(exitCode).toBe(1);
337-
expect(calls).toEqual([]);
338-
expect(output).toContain("Invalid manifest-id 'local.2024-app'");
339-
// The refusal says where the id came from and how to set one, because
340-
// the user never typed this string.
341-
expect(output).toContain('derived from the compiled artifact');
342-
expect(output).toContain('--manifest-id');
347+
expect(exitCode ?? 0).toBe(0);
348+
expect(calls).toHaveLength(2);
349+
expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages');
350+
expect(calls[0].body.manifest_id).toBe('local.2024-app');
351+
expect(output).not.toContain('Invalid manifest-id');
343352
// It is NOT normalised into some other permanent identifier: manifestId
344353
// is immutable once published.
345354
expect(output).not.toContain('local.a2024-app');
346355
});
347356

357+
it('every slug the derivation can produce is a legal id, from the name or from the filename', () => {
358+
const names = ['2024 App', '9', '--x--', 'ünïcödé', 'Acme CRM', `${'a'.repeat(63)} tail`];
359+
for (const name of names) {
360+
const fromName = deriveManifestId({ manifest: { name } }, '/nowhere/objectstack.json');
361+
expect(isManifestId(fromName.id), `${JSON.stringify(name)} -> ${fromName.id}`).toBe(true);
362+
const fromFile = deriveManifestId({ manifest: {} }, `/nowhere/${name}.json`);
363+
expect(isManifestId(fromFile.id), `${JSON.stringify(name)}.json -> ${fromFile.id}`).toBe(true);
364+
}
365+
});
366+
348367
it('publishes when the same derivation lands on a legal id', async () => {
349368
const path = await artifactAt({ name: 'Acme CRM', version: '1.2.0' });
350369
const calls = stubCloud();
@@ -420,7 +439,7 @@ describe('publish.ts keeps no local copy of the manifest-id rule', () => {
420439
expect(manifestIdShapedLiterals(RETIRED_LOCAL_RULE)).toHaveLength(1);
421440
// …and on a transcription of the schema's own pattern, the shape a
422441
// "fix" that re-types the regex would take.
423-
expect(manifestIdShapedLiterals('const X = /^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$/;')).toHaveLength(1);
442+
expect(manifestIdShapedLiterals('const X = /^[a-z0-9][a-z0-9-]*(\\.[a-z0-9][a-z0-9-]*)+$/;')).toHaveLength(1);
424443
});
425444

426445
it('finds none in the command source', () => {

‎packages/cloud-connection/src/marketplace-install-local-id-gate.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ afterEach(() => {
148148
const REFUSED_STRINGS: ReadonlyArray<readonly [string, string]> = [
149149
['a bare word (the card\'s probe)', 'late-app'],
150150
['an underscore inside a segment', 'com.example.my_erp'],
151-
['a digit-initial segment', 'com.1acme.crm'],
151+
['a hyphen-initial segment', 'com.-acme.crm'],
152152
['upper case', 'Com.Acme.Crm'],
153153
['a whitespace-padded conforming id (the RAW value is parsed)', ' com.acme.crm '],
154154
['the empty string', ''],
@@ -222,7 +222,9 @@ describe('install-local parses the inline manifest\'s `id` through its declarati
222222
expectNothingWritten(h);
223223
});
224224

225-
for (const id of ['com.example.crm', 'com.example.my-erp', 'org.apache.superset']) {
225+
// `com.1acme.crm` was a REFUSED row until the declaration let a segment
226+
// open with a digit; it installs now, keyed and filed by the id as written.
227+
for (const id of ['com.example.crm', 'com.example.my-erp', 'org.apache.superset', 'com.1acme.crm']) {
226228
it(`lit control — '${id}' still installs, keyed by the id as written`, async () => {
227229
const h = await mount(dir);
228230
const res = await h.routes.get(`POST ${ROUTE}`)!(

‎packages/metadata-protocol/src/protocol.install-manifest-id.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,15 @@ const REFUSED = [
4343
['a bare word — no reverse-domain prefix at all', 'pkg-a'],
4444
['an underscore inside a segment', 'com.example.my_erp'],
4545
['the empty string', ''],
46-
['a segment opening with a digit', 'com.4example.crm'],
46+
['a segment opening with a hyphen', 'com.-example.crm'],
4747
] as const;
4848

49-
/** Ids the declaration admits — the lit controls. */
50-
const ADMITTED = ['com.example.crm', 'com.example.my-erp', 'org.apache.superset'] as const;
49+
/**
50+
* Ids the declaration admits — the lit controls. `com.4example.crm` was a
51+
* REFUSED row until the declaration let a segment open with a digit; it is
52+
* here so this door is held to the widened rule, not only the spec schema.
53+
*/
54+
const ADMITTED = ['com.example.crm', 'com.example.my-erp', 'org.apache.superset', 'com.4example.crm'] as const;
5155

5256
function makeImpl() {
5357
const registryCalls: Array<{ manifest: any }> = [];

0 commit comments

Comments
 (0)