diff --git a/changelog.d/manifest-baseline-yaml-anchors.md b/changelog.d/manifest-baseline-yaml-anchors.md new file mode 100644 index 000000000..082982751 --- /dev/null +++ b/changelog.d/manifest-baseline-yaml-anchors.md @@ -0,0 +1,7 @@ +## Fixed + +- Manifest validation and the devkit's `defineTypePack` accept seed upgrade + baselines whose frontmatter uses YAML anchors. They now read only a starter's + top-level `kind`, `name` and `version`, without alias expansion, instead of + rejecting the whole document; TaskNotes' first task starter uses an anchor, + so its `tasknotes.task` manifests failed validation. diff --git a/packages/devkit/src/index.test.ts b/packages/devkit/src/index.test.ts index fe1e87d56..2e6a4d5d3 100644 --- a/packages/devkit/src/index.test.ts +++ b/packages/devkit/src/index.test.ts @@ -382,6 +382,11 @@ describe("canonical developer validation", () => { }); expect(pack.resources).toEqual([{ source: "_types/task.md", document: starter(3) }]); + // A starter that reuses values through YAML anchors still declares its version. + const anchored = starter(1, "schema:\n value:\n properties:\n status: { enum: &a1 [open, done] }\n next: { enum: *a1 }\n"); + expect(defineTypePack(seed([{ document: anchored }])).manifest.resources[0].upgrade_from) + .toEqual([{ digest: digest(anchored), document: anchored, version: 1 }]); + const path = "/manifest/resources/0/upgrade_from"; const rejected = (definition: ReturnType) => { try { diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index 258e22014..ff7a64a2a 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; -import { parseDocument } from "yaml"; +import { isMap, parseDocument } from "yaml"; import { connectError, MdbaseCollectionClient, @@ -186,8 +186,9 @@ function upgradeBaseline({ document, version }: { document: string; version?: nu function declaredTypeVersion(document: string): number | undefined { const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(document)?.[1]; const parsed = frontmatter === undefined ? undefined : parseDocument(frontmatter, { uniqueKeys: true }); - if (!parsed || parsed.errors.length > 0) return undefined; - const version = (parsed.toJS({ maxAliasCount: 0 }) as { version?: unknown } | null)?.version; + if (!parsed || parsed.errors.length > 0 || !isMap(parsed.contents)) return undefined; + // Read only `version`, so anchors elsewhere in a starter need no alias expansion. + const version = parsed.get("version"); return Number.isInteger(version) && Number(version) > 0 ? Number(version) : undefined; } diff --git a/packages/protocol/src/manifest.ts b/packages/protocol/src/manifest.ts index 2ef75a939..1bd3c3c80 100644 --- a/packages/protocol/src/manifest.ts +++ b/packages/protocol/src/manifest.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; import addFormatsModule from "ajv-formats"; -import { parseDocument } from "yaml"; +import { isMap, parseDocument } from "yaml"; import appManifestSchema from "../schemas/mdbase-app.schema.json" with { type: "json" }; import legacyAppManifestSchema from "../schemas/mdbase-app.legacy-v1.schema.json" with { type: "json" }; import type { LegacyMdbaseAppManifest } from "./index.js"; @@ -509,15 +509,15 @@ function validateSeedUpgrade( } /** Top-level frontmatter of a type document, or undefined when it has none. */ +// The identity a type document declares. Only these top-level keys are read, so +// anchors and aliases elsewhere in a starter are harmless and alias expansion +// stays disabled. function typeFrontmatter(document: string): Record | undefined { const source = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(document)?.[1]; if (source === undefined) return undefined; const parsed = parseDocument(source, { uniqueKeys: true }); - if (parsed.errors.length > 0) return undefined; - const value = parsed.toJS({ maxAliasCount: 0 }) as unknown; - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; + if (parsed.errors.length > 0 || !isMap(parsed.contents)) return undefined; + return { kind: parsed.get("kind"), name: parsed.get("name"), version: parsed.get("version") }; } function sha256(document: string): string { diff --git a/packages/protocol/test/manifest.test.mjs b/packages/protocol/test/manifest.test.mjs index 7c636957f..0906ac1dc 100644 --- a/packages/protocol/test/manifest.test.mjs +++ b/packages/protocol/test/manifest.test.mjs @@ -125,6 +125,14 @@ test("seed upgrades accept a non-empty list of distinct earlier starters", () => assert.ok(issuesOf(upgradeManifest([])).some((entry) => entry.startsWith("minItems "))); }); +test("seed upgrade baselines may use YAML anchors without alias expansion", () => { + // TaskNotes' first task starter reuses an enum through an anchor. + const anchored = starter(1, "schema:\n value:\n properties:\n status: { enum: &a1 [open, done] }\n next: { enum: *a1 }\n"); + const value = upgradeManifest([anchored, starter(2)]); + assert.deepEqual(validateAppManifest(value), { valid: true, issues: [] }); + assert.deepEqual(issuesOf(upgradeManifest([{ ...anchored, version: 2 }])), [`seedUpgrade ${upgradePath}/0/version`]); +}); + test("seed upgrade baselines are rejected with the offending baseline's path", () => { const first = starter(1); const second = starter(2);