@markdstage/markdstage 3.0.0 → 3.2.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.
@@ -9,8 +9,8 @@ fences. It supports editor completion and validation as well as automated CI val
9
9
  | `examples/*.architecture.json` | Working examples with `$schema` |
10
10
 
11
11
  This directory is **intentionally included in the distribution ZIP** so users can
12
- reference the schema locally. The extension does not load it at runtime (see
13
- `.github/RELEASING.md`).
12
+ reference the schema locally. Browser rendering uses a generated, dependency-free
13
+ ES-module contract rather than loading JSON Schema or a validation package.
14
14
 
15
15
  ## Usage
16
16
 
@@ -91,7 +91,7 @@ constraints, so `renderer/architecture.mjs` is authoritative:
91
91
  | Self-referencing connectors are prohibited | Same as above |
92
92
  | Each `id` is unique across the complete tree | Applies to the flattened set of nested elements |
93
93
  | 200 elements / 100 connectors / 20,000 text characters | Aggregated **after flattening**, not expressible by `maxItems` on one array |
94
- | 64 KiB source | String length before parsing |
94
+ | 65,536 source code units (UTF-16) | Existing JavaScript string-length limit before parsing; distinct from UTF-8 guide-response budgets |
95
95
  | Layout fit (`children do not fit`) | Calculated dynamically from child sizes and group interior dimensions |
96
96
  | Child `width` / `height` maximum under `layout` | Maximum depends on `cellWidth` / `cellHeight` |
97
97
  | The `assets/` file referenced by `node.icon` / `image.src` exists | The parser does not access the file system; a missing file renders an empty image region |
@@ -99,6 +99,43 @@ constraints, so `renderer/architecture.mjs` is authoritative:
99
99
  `parseArchitecture` can fail even after schema validation. **The parser always
100
100
  makes the final determination of whether a diagram can render.**
101
101
 
102
+ ### Shared authoring and diagnostic boundary
103
+
104
+ The bundled JSON Schema is the source of structural vocabulary, not a second
105
+ runtime acceptance policy. A reproducible generation step derives browser-safe
106
+ metadata for permitted fields, element types, scalar constraints, and conditional
107
+ requirements. The compact AI reference and runtime vocabulary consume that same
108
+ metadata. No UI or tool maintains its own permitted-field list.
109
+
110
+ Rendering, editing, saving, CLI validation, and unloaded-input validation share
111
+ the existing normalizer and semantic checks. Successfully normalized v1 input
112
+ remains accepted even where the authoring schema is intentionally stricter.
113
+ Such differences are authoring warnings, not new errors. On rejected input,
114
+ the common diagnostic layer explains independently checkable structural issues
115
+ from the derived contract and uses the same ID/reference checks as rendering.
116
+ It neither repairs the input nor reruns the parser with deleted fields or
117
+ invented defaults.
118
+
119
+ Diagnostics carry stable codes, categories, severity, JSON Pointers, human
120
+ messages, and nonautomatic suggestions. A conflicting replacement value is
121
+ reported rather than overwritten. JSON parsing, structural, semantic, and
122
+ layout stages distinguish passed, failed, and skipped work. Bounded diagnostic
123
+ collection reports truncation explicitly; a skipped stage is not evidence that
124
+ its constraints passed. Legacy exception messages and block-level error arrays
125
+ remain available alongside the detailed report.
126
+
127
+ Unloaded validation is a read-only boundary: it accepts explicit DSL text or
128
+ individual Markdown slide fragments and has no authority to open a canvas,
129
+ change its current page, read or write files, or modify editor drafts.
130
+ API execution success is separate from content validity and completeness.
131
+ Diagnostic budgets are not additional DSL v1 restrictions. Image existence,
132
+ slide clipping, and visual clarity still require separate asset/output review.
133
+
134
+ This preserves the existing schema/runtime responsibility split without a
135
+ browser-side schema dependency or a parser replacement. The tradeoff is a
136
+ generated artifact that must be kept in sync; a drift check makes that
137
+ requirement enforceable.
138
+
102
139
  ### Invariant: P ⊆ A, except for documented divergences
103
140
 
104
141
  As a rule, documents accepted by the parser (P) must also be accepted by the
@@ -112,6 +149,8 @@ divergence must make the schema stricter, and every instance is listed below.**
112
149
  | # | Case | Behavior | Reason |
113
150
  | --- | --- | --- | --- |
114
151
  | 1 | A child of a group with `layout` has nonnumeric `x` / `y` | Parser accepts; schema rejects | Placement is calculated automatically under `layout`, so the parser silently discards `x` / `y` without validating their values. This likely indicates an authoring mistake that the schema should report. Tightening the parser would reject previously accepted input and would be a breaking change. |
152
+ | 2 | A layout child has numeric `x` / `y` outside the schema range | Parser accepts; schema rejects | Parent-managed placement ignores these values just as it ignores nonnumeric coordinates. Preflight reports a compatibility warning. |
153
+ | 3 | Root `$schema` is not a string | Parser accepts; schema rejects | v1 ignores this metadata without resolving it. Editor completion requires a string; preflight warns without changing runtime acceptance. |
115
154
 
116
155
  Divergences are recorded as `divergence` entries in `test/schema/corpus.mjs`,
117
156
  and tests fail when no reason string is present. **CI detects silent divergence.**
@@ -224,5 +263,21 @@ Because `layered` calculates hierarchy from connector direction, **do not specif
224
263
  child `x` / `y`**. This matches existing `grid` / `row` / `column` behavior.
225
264
 
226
265
  The validation library (`ajv`) is a **root devDependency**. The extension ships
227
- as a ZIP and must run without `node_modules`, so do not import the schema or ajv
228
- from `renderer/architecture.mjs`.
266
+ as a ZIP and must run without `node_modules`, so do not import JSON Schema or
267
+ ajv from the renderer. Regenerate the browser-safe metadata after a schema
268
+ change with:
269
+
270
+ ```powershell
271
+ node .github\extensions\markdstage\scripts\generate-architecture-contract.mjs
272
+ ```
273
+
274
+ The same command with `--check` fails on drift. The authoring reference, including
275
+ its self-contained example, must remain within 8 KiB UTF-8. Public valid examples
276
+ are checked against both Schema and runtime; intentionally invalid examples
277
+ remain explicitly marked as described above.
278
+
279
+ The generated module has a separate distribution budget: it must stay below
280
+ 1,000,000 bytes, the conservative interpretation of the installer's 1 MB
281
+ single-file limit. An automated size check measures the actual generated file.
282
+ This is independent of both the 8 KiB guide-response budget and the existing
283
+ DSL source-length limit.
@@ -0,0 +1,355 @@
1
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
2
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
3
+ const annotations = new Set(["$schema", "$id", "$anchor", "$comment", "title", "description", "examples"]);
4
+ const schemaMaps = new Set(["properties", "patternProperties", "dependentSchemas", "$defs"]);
5
+ const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
6
+ const schemaValues = new Set([
7
+ "items", "contains", "additionalProperties", "unevaluatedProperties", "unevaluatedItems",
8
+ "propertyNames", "not", "if", "then", "else", "contentSchema",
9
+ ]);
10
+ const scalarKeywords = new Set([
11
+ ...annotations, "type", "enum", "const", "required", "default", "deprecated", "readOnly",
12
+ "writeOnly", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
13
+ "minLength", "maxLength", "pattern", "format", "minItems", "maxItems", "uniqueItems",
14
+ "minContains", "maxContains", "minProperties", "maxProperties", "dependentRequired",
15
+ "contentEncoding", "contentMediaType",
16
+ ]);
17
+
18
+ function unsupported(path, message) {
19
+ throw new Error(`Unsupported Architecture contract derivation at ${path}: ${message}`);
20
+ }
21
+
22
+ function ordered(value) {
23
+ if (Array.isArray(value)) return value.map(ordered);
24
+ if (!object(value)) return value;
25
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])]));
26
+ }
27
+
28
+ function signature(value) {
29
+ return JSON.stringify(ordered(value));
30
+ }
31
+
32
+ function assertions(value) {
33
+ if (!object(value)) return value;
34
+ return Object.fromEntries(Object.entries(value)
35
+ .filter(([key]) => !annotations.has(key))
36
+ .map(([key, entry]) => [
37
+ key,
38
+ schemaMaps.has(key)
39
+ ? Object.fromEntries(Object.entries(entry).map(([name, child]) => [name, assertions(child)]))
40
+ : schemaArrays.has(key) ? entry.map(assertions)
41
+ : schemaValues.has(key) ? assertions(entry) : entry,
42
+ ]));
43
+ }
44
+
45
+ function conjoin(left, right, path) {
46
+ if (left === true) return right;
47
+ if (right === true) return left;
48
+ if (left === false || right === false) return false;
49
+ for (const [closed, other] of [[left, right], [right, left]]) {
50
+ if (closed.additionalProperties === false &&
51
+ Object.keys(other.properties ?? {}).some((key) => !own(closed.properties ?? {}, key))) {
52
+ unsupported(path, "allOf cannot widen a closed object's permitted properties");
53
+ }
54
+ }
55
+ const result = { ...left };
56
+ // Keep complete conditional clauses together; independently merging their if/then/else
57
+ // keywords would change which assertion a branch applies to.
58
+ if (own(left, "if") && own(right, "if")) {
59
+ const condition = Object.fromEntries(["if", "then", "else"]
60
+ .filter((key) => own(right, key)).map((key) => [key, right[key]]));
61
+ right = Object.fromEntries(Object.entries(right)
62
+ .filter(([key]) => !["if", "then", "else"].includes(key)));
63
+ result.allOf = [...(result.allOf ?? []), condition];
64
+ }
65
+ for (const [key, value] of Object.entries(right)) {
66
+ if (!own(result, key) || annotations.has(key)) {
67
+ result[key] = value;
68
+ } else if (key === "properties") {
69
+ result.properties = { ...result.properties };
70
+ for (const [name, property] of Object.entries(value)) {
71
+ result.properties[name] = own(result.properties, name)
72
+ ? conjoin(result.properties[name], property, `${path}/properties/${name}`)
73
+ : property;
74
+ }
75
+ } else if (key === "required") {
76
+ result.required = [...new Set([...result.required, ...value])];
77
+ } else if (key === "allOf") {
78
+ result.allOf = [...result.allOf, ...value];
79
+ } else if (key === "enum") {
80
+ result.enum = result.enum.filter((entry) => value.some((other) => signature(entry) === signature(other)));
81
+ if (!result.enum.length) unsupported(path, "allOf has disjoint enums");
82
+ } else if (key === "type") {
83
+ const types = [result.type].flat().filter((entry) => [value].flat().includes(entry));
84
+ if (!types.length) unsupported(path, "allOf has disjoint types");
85
+ result.type = types.length === 1 ? types[0] : types;
86
+ } else if (/^(minimum|exclusiveMinimum|minLength|minItems|minContains|minProperties)$/.test(key)) {
87
+ result[key] = Math.max(result[key], value);
88
+ } else if (/^(maximum|exclusiveMaximum|maxLength|maxItems|maxContains|maxProperties)$/.test(key)) {
89
+ result[key] = Math.min(result[key], value);
90
+ } else if (signature(result[key]) !== signature(value)) {
91
+ unsupported(path, `cannot flatten conflicting ${key} assertions without losing information`);
92
+ }
93
+ }
94
+ if (own(result, "const") && result.enum) {
95
+ if (!result.enum.some((entry) => signature(entry) === signature(result.const))) {
96
+ unsupported(path, "const is excluded by enum");
97
+ }
98
+ delete result.enum;
99
+ }
100
+ return result;
101
+ }
102
+
103
+ function createResolver(schema) {
104
+ function dereference(ref) {
105
+ if (typeof ref !== "string" || !ref.startsWith("#/")) {
106
+ unsupported(String(ref), "only local JSON Pointer $ref values are supported");
107
+ }
108
+ let target = schema;
109
+ for (const part of ref.slice(2).split("/")) {
110
+ const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
111
+ if (!object(target) || !own(target, key)) unsupported(ref, "unresolved $ref");
112
+ target = target[key];
113
+ }
114
+ return target;
115
+ }
116
+
117
+ function discriminator(input, seen = new Set()) {
118
+ if (!object(input)) return null;
119
+ if (input.$ref) {
120
+ if (seen.has(input.$ref)) unsupported(input.$ref, "recursive discriminator");
121
+ return discriminator(dereference(input.$ref), new Set([...seen, input.$ref]));
122
+ }
123
+ return input.properties?.type?.enum ?? null;
124
+ }
125
+
126
+ function resolve(input, path = "#", stack = []) {
127
+ if (typeof input === "boolean") return input;
128
+ if (!object(input)) unsupported(path, "expected a JSON Schema object or boolean");
129
+ let result = {};
130
+ if (input.$ref) {
131
+ if (stack.includes(input.$ref)) unsupported(path, "recursive $ref outside an element array");
132
+ result = resolve(dereference(input.$ref), input.$ref, [...stack, input.$ref]);
133
+ }
134
+ const local = {};
135
+ for (const [key, value] of Object.entries(input)) {
136
+ if (key === "$ref" || key === "allOf") continue;
137
+ if (schemaMaps.has(key)) {
138
+ local[key] = Object.fromEntries(Object.entries(value)
139
+ .map(([name, child]) => [name, resolve(child, `${path}/${key}/${name}`, stack)]));
140
+ } else if (schemaArrays.has(key)) {
141
+ local[key] = value.map((child, index) => resolve(child, `${path}/${key}/${index}`, stack));
142
+ } else if (schemaValues.has(key)) {
143
+ // Element arrays are the tree boundary. Keep their standard local references rather
144
+ // than unrolling every nesting level (or a future recursive element definition).
145
+ if (key === "items" && object(value) && value.$ref && discriminator(value)) {
146
+ dereference(value.$ref);
147
+ if (Object.keys(value).some((name) => name !== "$ref")) {
148
+ unsupported(`${path}/items`, "element-array $ref siblings need explicit derivation support");
149
+ }
150
+ local[key] = { $ref: value.$ref };
151
+ } else {
152
+ local[key] = resolve(value, `${path}/${key}`, stack);
153
+ }
154
+ } else if (scalarKeywords.has(key)) {
155
+ local[key] = structuredClone(value);
156
+ } else {
157
+ unsupported(`${path}/${key}`, "unrecognized JSON Schema keyword");
158
+ }
159
+ }
160
+ result = conjoin(result, local, path);
161
+ for (const [index, branch] of (input.allOf ?? []).entries()) {
162
+ const resolved = resolve(branch, `${path}/allOf/${index}`, stack);
163
+ if (object(resolved) && ["if", "anyOf", "oneOf", "not"].some((key) => own(resolved, key))) {
164
+ result = conjoin(result, { allOf: [resolved] }, path);
165
+ } else {
166
+ result = conjoin(result, resolved, path);
167
+ }
168
+ }
169
+ return result;
170
+ }
171
+ return { resolve, discriminator };
172
+ }
173
+
174
+ function typeGuard(condition, path) {
175
+ if (!condition?.properties?.type) return null;
176
+ const allowed = new Set(["type", "properties", "required"]);
177
+ if (Object.keys(condition).some((key) => !allowed.has(key))
178
+ || Object.keys(condition.properties).some((key) => key !== "type")
179
+ || condition.required?.length !== 1 || condition.required[0] !== "type"
180
+ || (condition.type !== undefined && condition.type !== "object")) {
181
+ unsupported(path, "element discriminator must test only the required type field");
182
+ }
183
+ const value = condition.properties.type;
184
+ if (Object.keys(value).length !== 1 || (!own(value, "const") && !own(value, "enum"))) {
185
+ unsupported(path, "element discriminator must use const or enum");
186
+ }
187
+ return own(value, "const") ? [value.const] : value.enum;
188
+ }
189
+
190
+ function elementVariants(table, path) {
191
+ const types = table.properties?.type?.enum;
192
+ if (!Array.isArray(types) || !types.length || types.some((type) => typeof type !== "string")) {
193
+ unsupported(path, "element items must declare a nonempty string type enum");
194
+ }
195
+ const selected = {};
196
+ for (const type of types) {
197
+ let matched = false;
198
+ function select(input) {
199
+ if (!object(input)) unsupported(path, "boolean element branches are not supported");
200
+ if (input.anyOf || input.oneOf || input.not) unsupported(path, "ambiguous element object composition");
201
+ let result = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "allOf"));
202
+ if (input.if) {
203
+ const guard = typeGuard(input.if, path);
204
+ if (guard) {
205
+ result = Object.fromEntries(Object.entries(result)
206
+ .filter(([key]) => !["if", "then", "else"].includes(key)));
207
+ const applies = guard.includes(type);
208
+ matched ||= applies && own(input, "then");
209
+ const branch = applies ? input.then : input.else;
210
+ if (branch !== undefined) result = conjoin(result, select(branch), path);
211
+ }
212
+ }
213
+ for (const branch of input.allOf ?? []) result = conjoin(result, select(branch), path);
214
+ return result;
215
+ }
216
+ selected[type] = select(table);
217
+ if (!matched) unsupported(path, `no conditional element branch for ${JSON.stringify(type)}`);
218
+ if (selected[type].additionalProperties !== false) {
219
+ unsupported(path, `${type} must explicitly bound its permitted properties`);
220
+ }
221
+ }
222
+ return selected;
223
+ }
224
+
225
+ function fields(descriptor, path) {
226
+ const result = { ...(descriptor.properties ?? {}) };
227
+ function conditional(input) {
228
+ if (!object(input)) return;
229
+ if (input.anyOf || input.oneOf) unsupported(path, "conditional object unions need explicit field derivation");
230
+ for (const [name, value] of Object.entries(input.properties ?? {})) {
231
+ // A conditional assertion may narrow an existing field (connector.points, for example).
232
+ // The complete condition stays on the element and in definitions; it is not an
233
+ // unconditional restriction on the property descriptor.
234
+ if (!own(result, name)) {
235
+ if (descriptor.additionalProperties === false) {
236
+ unsupported(path, `conditional field ${name} is outside the closed object's permitted properties`);
237
+ }
238
+ result[name] = value;
239
+ }
240
+ }
241
+ for (const branch of input.allOf ?? []) conditional(branch);
242
+ if (input.then) conditional(input.then);
243
+ if (input.else) conditional(input.else);
244
+ }
245
+ conditional(descriptor);
246
+ return result;
247
+ }
248
+
249
+ function elementConditions(descriptor) {
250
+ return Object.fromEntries(["if", "then", "else", "allOf"]
251
+ .filter((key) => own(descriptor, key)).map((key) => [key, descriptor[key]]));
252
+ }
253
+
254
+ function conditionSignature(conditions, resolver) {
255
+ // Child selectors vary by nesting depth; their exact rules remain in definitions.
256
+ // All other element-level conditions must agree across fixed/flow contexts.
257
+ return JSON.stringify(ordered(assertions(conditions)), (key, value) =>
258
+ key === "items" && resolver.discriminator(value) ? {} : value);
259
+ }
260
+
261
+ function childTables(descriptor, resolver, path) {
262
+ const result = [];
263
+ function visit(input, mode) {
264
+ if (!object(input)) return;
265
+ for (const property of Object.values(input.properties ?? {})) {
266
+ if (property.items && resolver.discriminator(property.items)) {
267
+ result.push({ items: property.items, mode });
268
+ }
269
+ }
270
+ for (const branch of input.allOf ?? []) visit(branch, mode);
271
+ if (input.if) {
272
+ const before = result.length;
273
+ visit(input.then, "flow");
274
+ visit(input.else, "fixed");
275
+ if (result.length !== before && (
276
+ input.if.type !== "object" || input.if.required?.length !== 1
277
+ || input.if.required[0] !== "layout"
278
+ || Object.keys(input.if).some((key) => !["type", "required"].includes(key))
279
+ )) {
280
+ unsupported(path, "child layout context must be selected by presence of the parent's layout");
281
+ }
282
+ }
283
+ }
284
+ visit(descriptor, "fixed");
285
+ return result;
286
+ }
287
+
288
+ /**
289
+ * Derive vocabulary from JSON Schema, not an independent validator. The fixed/flow projection
290
+ * follows the schema's parent-layout branches. Element conditions and resolved definitions
291
+ * retain JSON Schema keywords; depth-specific child selectors remain in definitions.
292
+ * Unsupported projections fail rather than silently publishing partial metadata.
293
+ */
294
+ export function deriveArchitectureContract(schema) {
295
+ if (!object(schema) || !object(schema.$defs)) unsupported("#", "expected a schema with $defs");
296
+ const resolver = createResolver(schema);
297
+ const { $defs, ...rootSchema } = schema;
298
+ const root = resolver.resolve(rootSchema);
299
+ if (root.type !== "object" || root.additionalProperties !== false || !root.properties?.elements?.items) {
300
+ unsupported("#", "expected a closed root object with elements.items");
301
+ }
302
+ if (root.if || root.allOf || root.anyOf || root.oneOf) {
303
+ unsupported("#", "conditional root fields need explicit derivation support");
304
+ }
305
+ const elements = {};
306
+ const queue = [{ items: root.properties.elements.items, mode: "fixed" }];
307
+ const visited = new Set();
308
+ while (queue.length) {
309
+ const { items, mode } = queue.shift();
310
+ const key = `${mode}:${signature(items)}`;
311
+ if (visited.has(key)) continue;
312
+ visited.add(key);
313
+ const variants = elementVariants(resolver.resolve(items), key);
314
+ for (const [type, descriptor] of Object.entries(variants)) {
315
+ const properties = fields(descriptor, `${key}/${type}`);
316
+ const required = descriptor.required ?? [];
317
+ const conditions = elementConditions(descriptor);
318
+ if (required.some((name) => !own(properties, name))) unsupported(key, `${type} requires an undeclared field`);
319
+ const existing = elements[type] ??= { properties, required: {}, ...conditions };
320
+ if (signature(assertions({ properties: existing.properties })) !== signature(assertions({ properties }))) {
321
+ unsupported(key, `${type} properties vary by parent context; cannot publish one field record`);
322
+ }
323
+ if (conditionSignature(elementConditions(existing), resolver) !== conditionSignature(conditions, resolver)) {
324
+ unsupported(key, `${type} conditional rules vary by parent context beyond child element arrays`);
325
+ }
326
+ if (existing.required[mode] && signature([...existing.required[mode]].sort()) !== signature([...required].sort())) {
327
+ unsupported(key, `${type} requirements vary within ${mode} context`);
328
+ }
329
+ existing.required[mode] = required;
330
+ queue.push(...childTables(descriptor, resolver, `${key}/${type}`));
331
+ }
332
+ }
333
+ for (const [type, element] of Object.entries(elements)) {
334
+ if (!element.required.fixed || !element.required.flow) {
335
+ unsupported("#/properties/elements", `${type} has no reachable fixed and flow variants`);
336
+ }
337
+ }
338
+ return {
339
+ root: { properties: root.properties, required: root.required ?? [] },
340
+ elements,
341
+ definitions: Object.fromEntries(Object.entries($defs)
342
+ .map(([name, definition]) => [name, resolver.resolve(definition, `#/$defs/${name}`)])),
343
+ };
344
+ }
345
+
346
+ export function architectureContractModule(schema) {
347
+ // Declaration order is also the runtime's diagnostic field/type order.
348
+ return [
349
+ "// Generated from schema/architecture-v1.schema.json. Do not edit.",
350
+ "// Regenerate: node .github/extensions/markdstage/scripts/generate-architecture-contract.mjs",
351
+ "// Structural metadata only; renderer/architecture.mjs remains the semantic authority.",
352
+ `export const architectureContract = ${JSON.stringify(deriveArchitectureContract(schema), null, 2)};`,
353
+ "",
354
+ ].join("\n");
355
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { pathToFileURL } from "node:url";
4
+ import { architectureContractModule } from "../schema/architecture-contract.mjs";
5
+
6
+ const schemaUrl = new URL("../schema/architecture-v1.schema.json", import.meta.url);
7
+ const outputUrl = new URL("../renderer/architecture-contract.mjs", import.meta.url);
8
+
9
+ export async function generateArchitectureContract({
10
+ check = false,
11
+ source = schemaUrl,
12
+ output = outputUrl,
13
+ } = {}) {
14
+ const expected = architectureContractModule(JSON.parse(await readFile(source, "utf8")));
15
+ let current;
16
+ try {
17
+ current = await readFile(output, "utf8");
18
+ } catch (error) {
19
+ if (error.code !== "ENOENT") throw error;
20
+ }
21
+ const changed = expected !== current?.replace(/\r\n/g, "\n");
22
+ if (changed && check) {
23
+ throw new Error("Architecture contract is out of date. Run npm run generate:architecture.");
24
+ }
25
+ if (changed) await writeFile(output, expected, "utf8");
26
+ return { changed, checked: check };
27
+ }
28
+
29
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
30
+ try {
31
+ const args = process.argv.slice(2);
32
+ if (args.some((arg) => arg !== "--check") || args.length > 1) {
33
+ throw new Error("Usage: node generate-architecture-contract.mjs [--check]");
34
+ }
35
+ const result = await generateArchitectureContract({ check: args.includes("--check") });
36
+ console.log(`Architecture contract ${result.checked ? "is current" : result.changed ? "generated" : "unchanged"}.`);
37
+ } catch (error) {
38
+ console.error(error.message);
39
+ process.exitCode = 1;
40
+ }
41
+ }
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  MarkdStageError,
5
5
  architectureValidationErrors,
6
+ architectureValidationReport,
6
7
  createDeckSession,
7
8
  createUrlToken,
8
9
  hasFrontMatter,
@@ -27,10 +28,24 @@ export async function validateCommand(options) {
27
28
  code: error.code,
28
29
  message: error.message,
29
30
  });
30
- return { ok: false, file: options.file, total: 0, errors, warnings };
31
+ return {
32
+ ok: false,
33
+ valid: false,
34
+ complete: false,
35
+ truncated: false,
36
+ file: options.file,
37
+ total: 0,
38
+ errors,
39
+ warnings,
40
+ stages: { json: "skipped", structure: "skipped", semantic: "skipped", layout: "skipped" },
41
+ diagnostics: [],
42
+ diagnosticCount: 0,
43
+ blocks: [],
44
+ };
31
45
  }
32
46
 
33
- for (const issue of architectureValidationErrors(session.slides)) {
47
+ const validation = architectureValidationReport(session.slides);
48
+ for (const issue of architectureValidationErrors(session.slides, { validation })) {
34
49
  errors.push({
35
50
  code: issue.code,
36
51
  page: issue.page,
@@ -38,6 +53,12 @@ export async function validateCommand(options) {
38
53
  message: issue.message,
39
54
  });
40
55
  }
56
+ if (validation.truncated) {
57
+ errors.push({
58
+ code: "validation_incomplete",
59
+ message: `Architecture validation reached inspection limits (${validation.budget.limitsReached.join(", ")}). Validate smaller inputs before treating the deck as valid.`,
60
+ });
61
+ }
41
62
 
42
63
  session.slides.forEach((slide, index) => {
43
64
  if (!hasFrontMatter(slide)) {
@@ -51,7 +72,10 @@ export async function validateCommand(options) {
51
72
  });
52
73
 
53
74
  return {
54
- ok: errors.length === 0,
75
+ ok: errors.length === 0 && validation.valid,
76
+ valid: errors.length === 0 && validation.valid,
77
+ complete: validation.complete,
78
+ truncated: validation.truncated,
55
79
  file: session.file,
56
80
  workspace: session.workspaceRoot,
57
81
  total: session.slides.length,
@@ -59,6 +83,13 @@ export async function validateCommand(options) {
59
83
  themeFile: session.customThemeFile || undefined,
60
84
  errors,
61
85
  warnings,
86
+ stages: validation.stages,
87
+ diagnostics: validation.diagnostics,
88
+ diagnosticCount: validation.diagnosticCount,
89
+ blocks: validation.blocks,
90
+ skipped: validation.skipped,
91
+ limits: validation.limits,
92
+ budget: validation.budget,
62
93
  };
63
94
  }
64
95
 
@@ -74,6 +105,11 @@ export function formatValidateReport(report) {
74
105
  for (const warning of report.warnings) {
75
106
  lines.push(` warn slide ${warning.page}: ${warning.message}`);
76
107
  }
108
+ if (report.complete === false) {
109
+ lines.push(report.truncated
110
+ ? " Validation incomplete: inspection limits were reached; unchecked content is not valid."
111
+ : " Architecture validation incomplete: fix the reported errors and validate again.");
112
+ }
77
113
  lines.push(report.ok ? " OK: the deck is valid." : ` ${report.errors.length} error(s) found.`);
78
114
  return lines.join("\n");
79
115
  }
package/src/runtime.mjs CHANGED
@@ -48,6 +48,7 @@ const [
48
48
  guide,
49
49
  presenterWindow,
50
50
  markdownFiles,
51
+ architectureValidation,
51
52
  ] = await Promise.all([
52
53
  load("runtime/errors.mjs"),
53
54
  load("runtime/deck-session.mjs"),
@@ -58,6 +59,7 @@ const [
58
59
  load("markdstage-guide.mjs"),
59
60
  load("presenter-window.mjs"),
60
61
  load("scripts/markdown-files.mjs"),
62
+ load("architecture-validation.mjs"),
61
63
  ]);
62
64
 
63
65
  export const { MarkdStageError } = errors;
@@ -75,9 +77,11 @@ export const { findChromiumBrowser, terminateProcessTree, isProcessRunning } = b
75
77
  export const { captureDirectoryName, pdfNameForSource, pptxNameForSource } = outputPaths;
76
78
  export const {
77
79
  architectureValidationErrors,
80
+ architectureValidationReport,
78
81
  deckValidationFeedback,
79
82
  hasFrontMatter,
80
83
  readGuide,
81
84
  } = guide;
85
+ export const { validateArchitectureInput, createArchitectureValidationTool } = architectureValidation;
82
86
  export const { buildPresenterBrowserArgs } = presenterWindow;
83
87
  export const { isMarkdownPath, MARKDOWN_MAX_BYTES } = markdownFiles;
package/src/skills.mjs CHANGED
@@ -73,11 +73,15 @@ what the MarkdStage canvas and MarkdStage Desktop render.
73
73
  required diagrams, and output format.
74
74
  2. Read only the relevant guidance. Start with
75
75
  \`markdstage guide slide-format\`, then retrieve \`themes\`,
76
- \`custom-themes\`, or \`architecture-dsl\` when needed.
76
+ \`custom-themes\`, or \`architecture-schema\` when needed. Before drafting
77
+ Architecture DSL, read the compact \`architecture-schema\` contract first;
78
+ use \`architecture-dsl\` for advanced behavior.
77
79
  3. Create the complete deck as one Markdown source file (see
78
80
  \`references/slide-format.md\`).
79
81
  4. Validate structure, themes, and Architecture DSL before visual review:
80
- \`markdstage validate slides.md --json\`.
82
+ \`markdstage validate slides.md --json\`. Review diagnostic codes, JSON Pointers,
83
+ and completeness, fix independent issues together, and preserve the same
84
+ validated content when presenting. Suggestions are never automatic repairs.
81
85
  5. Use \`markdstage preview slides.md --watch\` for live source-backed authoring.
82
86
  It reloads on save without losing the current slide and keeps the last valid
83
87
  deck while a save is incomplete.