|
7 | 7 | * keyed on the bare namespace (D1) and can check nothing unless the namespace |
8 | 8 | * leaves the artifact. These are the acceptance-face pins for the open side of |
9 | 9 | * that contract: the field exists on both schemas, it is OPTIONAL (§A.2's |
10 | | - * algorithm opens with `if (namespace is absent) -> allow`), it judges values |
11 | | - * exactly as `manifest.namespace` does (§A.7 "two gates, one vocabulary"), and |
12 | | - * the on-disk template descriptor deliberately does NOT declare it. |
| 10 | + * algorithm opens with `if (namespace is absent) -> allow`), and it judges |
| 11 | + * values exactly as `manifest.namespace` does (§A.7 "two gates, one |
| 12 | + * vocabulary"). |
| 13 | + * |
| 14 | + * `TemplateManifestSchema` re-declares the key as a SCAFFOLD-ONLY extra |
| 15 | + * (#6861, ADR-0049 enforce leg): the on-disk `objectstack.manifest.json` |
| 16 | + * really carries it — written by the template author, rewritten by |
| 17 | + * `create-objectstack`, read back by `readTemplateNamespace` — so the schema |
| 18 | + * that claims to describe that file declares it instead of silently stripping |
| 19 | + * it. It shares this vocabulary and is NOT a second publish surface; the pins |
| 20 | + * below hold both halves of that split. |
13 | 21 | */ |
14 | 22 |
|
15 | 23 | import { describe, it, expect } from 'vitest'; |
@@ -54,6 +62,10 @@ const NAMESPACE_CASES: ReadonlyArray<readonly [string, boolean]> = [ |
54 | 62 | ['my_app_2', true], |
55 | 63 | ['base', true], // shareable at the GATE (D3), still a well-formed string |
56 | 64 | ['sys', true], |
| 65 | + // The literal value the bundled blank template ships in |
| 66 | + // `packages/create-objectstack/src/templates/blank/objectstack.manifest.json` |
| 67 | + // — the scaffold surface's only in-repo instance (#6861). |
| 68 | + ['blank', true], |
57 | 69 | ['abcdefghijklmnopqrst', true], // 20 chars — the upper bound |
58 | 70 | ['a', false], // 1 char — under the lower bound |
59 | 71 | ['abcdefghijklmnopqrstu', false], // 21 chars — over the upper bound |
@@ -124,32 +136,98 @@ describe('two gates, one vocabulary (§A.7)', () => { |
124 | 136 | const manifestField = ManifestSchema.shape.namespace; |
125 | 137 | const payloadField = PackageSchema.shape.namespace; |
126 | 138 | const requestField = CreatePackageRequestSchema.shape.namespace; |
| 139 | + // The scaffold surface is a different MEANING but the same VOCABULARY |
| 140 | + // (#6861) — a value legal on one side must be legal on the other, or |
| 141 | + // `create-objectstack` could stamp a namespace publish would later refuse. |
| 142 | + const templateField = TemplateManifestSchema.shape.namespace; |
127 | 143 |
|
128 | 144 | const verdicts = NAMESPACE_CASES.map(([value, expected]) => ({ |
129 | 145 | value, |
130 | 146 | expected, |
131 | 147 | manifest: manifestField.safeParse(value).success, |
132 | 148 | payload: payloadField.safeParse(value).success, |
133 | 149 | request: requestField.safeParse(value).success, |
| 150 | + template: templateField.safeParse(value).success, |
134 | 151 | })); |
135 | 152 |
|
136 | 153 | // One assertion over the whole table so a drift names the offending value. |
137 | 154 | expect(verdicts.filter((v) => |
138 | | - v.manifest !== v.expected || v.payload !== v.expected || v.request !== v.expected, |
| 155 | + v.manifest !== v.expected || v.payload !== v.expected |
| 156 | + || v.request !== v.expected || v.template !== v.expected, |
139 | 157 | )).toEqual([]); |
140 | 158 | }); |
141 | 159 |
|
142 | | - it('both fields are optional, so "absent" means the same thing on both sides', () => { |
| 160 | + it('every field is optional, so "absent" means the same thing on all sides', () => { |
143 | 161 | expect(ManifestSchema.shape.namespace.safeParse(undefined).success).toBe(true); |
144 | 162 | expect(PackageSchema.shape.namespace.safeParse(undefined).success).toBe(true); |
145 | 163 | expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true); |
| 164 | + expect(TemplateManifestSchema.shape.namespace.safeParse(undefined).success).toBe(true); |
146 | 165 | }); |
147 | 166 | }); |
148 | 167 |
|
149 | | -describe('TemplateManifestSchema does not inherit namespace', () => { |
150 | | - it('omits it deliberately — the publish namespace comes from the compiled artifact', () => { |
151 | | - expect(Object.keys(TemplateManifestSchema.shape)).not.toContain('namespace'); |
152 | | - // And the projection still carries the rest of the create-request surface. |
| 168 | +describe('TemplateManifestSchema declares namespace as a scaffold-only extra (#6861)', () => { |
| 169 | + /** A template manifest that is valid except for whatever a case changes. */ |
| 170 | + function templateManifest(overrides: Record<string, unknown> = {}) { |
| 171 | + return { |
| 172 | + manifestId: 'com.acme.blank', |
| 173 | + displayName: 'Blank Starter', |
| 174 | + name: 'blank', |
| 175 | + specVersion: '^6.0.0', |
| 176 | + ...overrides, |
| 177 | + }; |
| 178 | + } |
| 179 | + |
| 180 | + it('declares the field, alongside the rest of the create-request projection', () => { |
| 181 | + expect(Object.keys(TemplateManifestSchema.shape)).toContain('namespace'); |
153 | 182 | expect(Object.keys(TemplateManifestSchema.shape)).toContain('manifestId'); |
154 | 183 | }); |
| 184 | + |
| 185 | + it('SURVIVES the parse — the key is no longer silently stripped', () => { |
| 186 | + // The pin this issue exists for. Before `namespace` was declared, this |
| 187 | + // schema's default strip mode dropped a key the scaffolder writes, rewrites |
| 188 | + // and reads back: `parse()` succeeded and answered without it. |
| 189 | + const parsed = TemplateManifestSchema.parse(templateManifest({ namespace: 'blank' })); |
| 190 | + expect('namespace' in parsed).toBe(true); |
| 191 | + expect(parsed.namespace).toBe('blank'); |
| 192 | + }); |
| 193 | + |
| 194 | + it('is OPTIONAL — a template-registry manifest declaring none still parses', () => { |
| 195 | + // The remote-template shape (#4902): no namespace anywhere on the file, and |
| 196 | + // `readTemplateNamespace` correctly yields undefined for it. |
| 197 | + const parsed = TemplateManifestSchema.parse(templateManifest()); |
| 198 | + expect(parsed.namespace).toBeUndefined(); |
| 199 | + }); |
| 200 | + |
| 201 | + it('REJECTS a malformed namespace with the shared coded issue at the namespace path', () => { |
| 202 | + // The enforce half of ADR-0049: before the declaration a bad value parsed |
| 203 | + // green (stripped), so the schema had no opinion on a key it described. |
| 204 | + const result = TemplateManifestSchema.safeParse(templateManifest({ namespace: 'CRM-App' })); |
| 205 | + expect(result.success).toBe(false); |
| 206 | + const issues = result.success ? [] : result.error.issues; |
| 207 | + expect(issues).toHaveLength(1); |
| 208 | + expect(issues[0].code).toBe('invalid_format'); |
| 209 | + expect(issues[0].path).toEqual(['namespace']); |
| 210 | + expect(issues[0].message).toBe( |
| 211 | + 'Namespace must be 2-20 chars, lowercase alphanumeric + underscore', |
| 212 | + ); |
| 213 | + }); |
| 214 | + |
| 215 | + it('says scaffold-only in its describe, and is NOT the publish field', () => { |
| 216 | + // Two distinct schema instances carrying two distinct meanings. Collapsing |
| 217 | + // them — by dropping the `.omit()` and inheriting the create-request field — |
| 218 | + // would make this on-disk file a second way to reserve a namespace, which |
| 219 | + // is exactly what the ADR-0048 addendum (§A.2 / §A.7) rules out. |
| 220 | + const templateField = TemplateManifestSchema.shape.namespace; |
| 221 | + const publishField = CreatePackageRequestSchema.shape.namespace; |
| 222 | + expect(templateField).not.toBe(publishField); |
| 223 | + |
| 224 | + const templateDoc = templateField.description ?? ''; |
| 225 | + expect(templateDoc).toMatch(/scaffold-only/i); |
| 226 | + expect(templateDoc).toMatch(/NOT the publish namespace/i); |
| 227 | + |
| 228 | + // …and the publish field's own description is untouched by that split. |
| 229 | + expect(publishField.description).toBe( |
| 230 | + 'Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account")', |
| 231 | + ); |
| 232 | + }); |
155 | 233 | }); |
0 commit comments