Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/manifest-baseline-yaml-anchors.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/devkit/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof seed>) => {
try {
Expand Down
7 changes: 4 additions & 3 deletions packages/devkit/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
12 changes: 6 additions & 6 deletions packages/protocol/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown>
: 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 {
Expand Down
8 changes: 8 additions & 0 deletions packages/protocol/test/manifest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading