eidosmd 0.1.0 → 0.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.
Files changed (100) hide show
  1. package/README.md +50 -29
  2. package/browser/dist/assets/index-C2NMN_D4.css +1 -0
  3. package/browser/dist/assets/index-C65k1ihb.js +46 -0
  4. package/browser/dist/favicon.svg +5 -0
  5. package/browser/dist/index.html +15 -0
  6. package/browser/dist/mark.svg +4 -0
  7. package/dist/src/cli.js +7 -0
  8. package/dist/src/commands/agents.js +1 -1
  9. package/dist/src/commands/canvas.js +77 -0
  10. package/dist/src/commands/check.js +1 -1
  11. package/dist/src/commands/framework.js +24 -5
  12. package/dist/src/commands/index.js +4 -4
  13. package/dist/src/commands/init.js +1 -0
  14. package/dist/src/commands/instructions.js +1 -1
  15. package/dist/src/commands/list.js +8 -8
  16. package/dist/src/commands/migrate.js +32 -0
  17. package/dist/src/commands/new.js +3 -3
  18. package/dist/src/commands/seeds.js +5 -5
  19. package/dist/src/commands/setup.js +119 -0
  20. package/dist/src/commands/version.js +44 -0
  21. package/dist/src/commands/whoami.js +4 -4
  22. package/dist/src/context.js +5 -5
  23. package/dist/src/core/blueprint.js +16 -11
  24. package/dist/src/core/canvas-schema.js +148 -0
  25. package/dist/src/core/canvas.js +722 -0
  26. package/dist/src/core/check.js +127 -51
  27. package/dist/src/core/convert.js +7 -6
  28. package/dist/src/core/framework-markdown.js +113 -34
  29. package/dist/src/core/framework-model.js +27 -10
  30. package/dist/src/core/framework-structured.js +132 -33
  31. package/dist/src/core/framework.js +13 -13
  32. package/dist/src/core/git.js +59 -0
  33. package/dist/src/core/index-leaf.js +2 -2
  34. package/dist/src/core/me.js +16 -8
  35. package/dist/src/core/migrate.js +243 -0
  36. package/dist/src/core/naming.js +1 -1
  37. package/dist/src/core/root.js +2 -2
  38. package/dist/src/core/scaffold.js +19 -19
  39. package/dist/src/core/seed.js +151 -60
  40. package/dist/src/core/server.js +1236 -52
  41. package/dist/src/core/settings.js +181 -0
  42. package/dist/src/core/store.js +259 -0
  43. package/dist/src/core/template.js +32 -0
  44. package/dist/src/core/versions.js +79 -0
  45. package/dist/src/output.js +4 -1
  46. package/dist/src/program.js +134 -39
  47. package/instructions/authoring.md +13 -12
  48. package/instructions/configuring.md +65 -34
  49. package/instructions/init-required.md +4 -4
  50. package/instructions/overview.md +17 -7
  51. package/instructions/validating.md +5 -4
  52. package/package.json +21 -12
  53. package/standard/EIDOS.md +129 -188
  54. package/standard/seeds/README.md +6 -6
  55. package/standard/seeds/book/Framework.yaml +81 -0
  56. package/standard/seeds/book/README.md +9 -5
  57. package/standard/seeds/book/_gitignore +3 -3
  58. package/standard/seeds/book/me.md +1 -1
  59. package/standard/seeds/book/roles/README.md +3 -3
  60. package/standard/seeds/book/roles/framework-owner.md +2 -2
  61. package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
  62. package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
  63. package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
  64. package/standard/seeds/book/templates/frame.premise.md +17 -0
  65. package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
  66. package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
  67. package/standard/seeds/research/Framework.yaml +81 -0
  68. package/standard/seeds/research/README.md +9 -5
  69. package/standard/seeds/research/_gitignore +3 -3
  70. package/standard/seeds/research/me.md +1 -1
  71. package/standard/seeds/research/roles/README.md +3 -3
  72. package/standard/seeds/research/roles/framework-owner.md +2 -2
  73. package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
  74. package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
  75. package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
  76. package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
  77. package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
  78. package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
  79. package/standard/seeds/software/Framework.yaml +82 -0
  80. package/standard/seeds/software/README.md +5 -5
  81. package/standard/seeds/software/_gitignore +3 -3
  82. package/standard/seeds/software/me.md +1 -1
  83. package/standard/seeds/software/roles/README.md +3 -3
  84. package/standard/seeds/software/roles/framework-owner.md +2 -2
  85. package/standard/seeds/software/roles/project-manager.md +2 -2
  86. package/standard/seeds/software/roles/stakeholder.md +1 -1
  87. package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
  88. package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
  89. package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
  90. package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
  91. package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
  92. package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
  93. package/browser/index.html +0 -268
  94. package/dist/src/commands/convert.js +0 -30
  95. package/dist/src/core/shape.js +0 -26
  96. package/standard/seeds/book/Framework.md +0 -87
  97. package/standard/seeds/book/shapes/frame.premise.md +0 -24
  98. package/standard/seeds/research/Framework.md +0 -88
  99. package/standard/seeds/software/Framework.md +0 -88
  100. /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
@@ -1,17 +1,18 @@
1
- // Validation, framework-defined: every rule reads the root's own Framework.md
2
- // and shapes, never a contract hardcoded here. Findings surface; a human acts
1
+ // Validation, framework-defined: every rule reads the root's own Framework.yaml
2
+ // and templates, never a contract hardcoded here. Findings surface; a human acts
3
3
  // on them. Errors are things that are wrong on any reading (a malformed file,
4
4
  // an id used twice, a link to nowhere); warnings are gaps the standard says to
5
5
  // note and offer, never refuse.
6
6
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
7
7
  import path from 'node:path';
8
- import { blueprintId, blueprintTitle, propertyString, resolveFlavor } from './blueprint.js';
9
- import { appliesTo } from './framework.js';
8
+ import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
9
+ import { appliesTo, unitOf } from './framework.js';
10
10
  import { renderEmbeddedEntries } from './index-leaf.js';
11
11
  import { embeddedIndexOf } from './framework-structured.js';
12
+ import { FRAME_REF, isFrameRef, listCanvases } from './canvas.js';
12
13
  import { normalizeNewlines } from './markdown.js';
13
- import { isKebab, kebab } from './naming.js';
14
- import { loadShape } from './shape.js';
14
+ import { kebab } from './naming.js';
15
+ import { loadTemplate } from './template.js';
15
16
  const WORK_TRACKING_FIELDS = new Set(['sprint', 'estimate', 'assignee', 'story_points']);
16
17
  const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build']);
17
18
  function compareVersions(a, b) {
@@ -99,8 +100,8 @@ function propertyLinks(value) {
99
100
  }
100
101
  class Collector {
101
102
  findings = [];
102
- error(code, file, message) {
103
- this.findings.push({ level: 'error', code, path: file, message });
103
+ error(code, file, message, fix) {
104
+ this.findings.push({ level: 'error', code, path: file, message, ...(fix ? { fix } : {}) });
104
105
  }
105
106
  warn(code, file, message) {
106
107
  this.findings.push({ level: 'warning', code, path: file, message });
@@ -123,14 +124,17 @@ function checkFramework(framework, out, options) {
123
124
  else {
124
125
  const diff = compareVersions(framework.eidosVersion, options.standardVersion);
125
126
  if (diff < 0) {
126
- out.warn('version-behind', frameworkRel, `targets Eidos ${framework.eidosVersion}; this CLI carries ${options.standardVersion} (migrate when ready)`);
127
+ out.warn('version-behind', frameworkRel, `targets Eidos ${framework.eidosVersion}; this CLI carries ${options.standardVersion}: run \`eidos migrate\` when ready`);
127
128
  }
128
129
  else if (diff > 0) {
129
130
  out.warn('version-ahead', frameworkRel, `targets Eidos ${framework.eidosVersion}, newer than the ${options.standardVersion} this CLI carries`);
130
131
  }
131
132
  }
132
133
  if (framework.collections.length === 0) {
133
- out.error('no-collections', frameworkRel, 'declares no collections under ## Collections; a framework needs at least a framing collection');
134
+ out.error('no-collections', frameworkRel, 'declares no collections; a framework needs at least one');
135
+ }
136
+ if (/^versions:/m.test(readFileSync(framework.file, 'utf8'))) {
137
+ out.warn('versions-legacy', frameworkRel, 'carries a versions key; since Eidos 5.0.0 a root\'s versions are a tool\'s own, and `eidos migrate` moves them to .eidos/plugins/eidosmd/versions.yaml');
134
138
  }
135
139
  if (!existsSync(path.join(framework.root, 'README.md'))) {
136
140
  out.warn('readme-missing', null, 'no README.md at the root; the visible front door is missing');
@@ -140,23 +144,30 @@ function checkFramework(framework, out, options) {
140
144
  out.warn('top-level-missing', frameworkRel, `## Top-Level lists ${doc.title} at ${doc.path}, which does not exist`);
141
145
  }
142
146
  }
143
- const shapes = new Map();
147
+ // A collection's templates are named for its unit, `<unit>.<variant>.md`;
148
+ // a file named otherwise is faulted, as is one that opens with frontmatter.
149
+ const templates = new Map();
144
150
  for (const collection of framework.collections) {
145
- if (collection.flavors.length === 0) {
146
- out.error('flavor-none', frameworkRel, `collection ${collection.name} declares no flavors`);
147
- }
148
- for (const flavor of collection.flavors) {
149
- const shape = loadShape(framework, flavor);
150
- shapes.set(`${collection.name}/${flavor.name}`, shape);
151
- if (!shape) {
152
- out.error('shape-missing', frameworkRel, `${collection.name}/${flavor.name} points at _eidos/${flavor.shape}, which does not exist`);
151
+ const unit = unitOf(collection);
152
+ for (const variant of collection.variants) {
153
+ const template = loadTemplate(framework, variant);
154
+ templates.set(`${collection.name}/${variant.name}`, template);
155
+ if (template?.frontmatter) {
156
+ out.error('template-frontmatter', rel(framework.root, template.path), 'carries a frontmatter block; a template is body only, its frontmatter is generated from the Properties table', 'Remove the block');
157
+ }
158
+ if (!template) {
159
+ out.error('template-missing', frameworkRel, `${collection.name}/${variant.name} points at .eidos/${variant.template}, which does not exist`);
160
+ }
161
+ const expected = unit ? `${unit}.${variant.name}.md` : null;
162
+ if (expected && !variant.template.endsWith(`/${expected}`) && variant.template !== expected) {
163
+ out.error('template-name', frameworkRel, `${collection.name}/${variant.name} is at .eidos/${variant.template}; a template file is templates/${expected} (its unit, then its variant)`);
153
164
  }
154
165
  }
155
166
  if (!existsSync(path.join(framework.root, collection.name))) {
156
167
  out.warn('collection-folder-missing', null, `collection ${collection.name} is declared but has no folder`);
157
168
  }
158
169
  }
159
- return shapes;
170
+ return templates;
160
171
  }
161
172
  function checkLayout(framework, blueprints, out) {
162
173
  const declared = new Set(framework.collections.map((collection) => collection.name));
@@ -169,7 +180,7 @@ function checkLayout(framework, blueprints, out) {
169
180
  entries = [];
170
181
  }
171
182
  for (const entry of entries.sort()) {
172
- if (entry.startsWith('.') || entry === '_eidos' || SKIPPED_DIRS.has(entry) || declared.has(entry)) {
183
+ if (entry.startsWith('.') || entry === '.eidos' || SKIPPED_DIRS.has(entry) || declared.has(entry)) {
173
184
  continue;
174
185
  }
175
186
  let isDir = false;
@@ -180,7 +191,7 @@ function checkLayout(framework, blueprints, out) {
180
191
  isDir = false;
181
192
  }
182
193
  if (isDir) {
183
- out.warn('folder-undeclared', entry, 'a folder at the root that no collection declares; declare it in Framework.md or move it');
194
+ out.warn('folder-undeclared', entry, 'a folder at the root that no collection declares; declare it in the framework document or move it');
184
195
  }
185
196
  }
186
197
  for (const collection of framework.collections) {
@@ -194,7 +205,7 @@ function checkLayout(framework, blueprints, out) {
194
205
  const declaredGroups = new Map(collection.grouping.groups.map((group) => [group.name.toLowerCase(), group.name]));
195
206
  for (const group of groupsOnDisk) {
196
207
  if (!declaredGroups.has(group.toLowerCase())) {
197
- out.warn('group-undeclared', `${collection.name}/${group}`, `not declared under ${collection.grouping.label} in Framework.md`);
208
+ out.warn('group-undeclared', `${collection.name}/${group}`, `not declared under ${collection.grouping.label} in the framework document`);
198
209
  }
199
210
  }
200
211
  for (const group of collection.grouping.groups) {
@@ -209,10 +220,10 @@ function checkLayout(framework, blueprints, out) {
209
220
  }
210
221
  }
211
222
  if (collection.framing) {
212
- for (const flavor of collection.flavors) {
213
- const written = own.some((blueprint) => resolveFlavor(blueprint).flavor === flavor);
223
+ for (const variant of collection.variants) {
224
+ const written = own.some((blueprint) => resolveVariant(blueprint).variant === variant);
214
225
  if (!written) {
215
- out.warn('frame-unwritten', collection.name, `no ${flavor.name} frame yet; a gap to surface, not a failure`);
226
+ out.warn('frame-unwritten', collection.name, `no ${variant.name} frame yet; a gap to surface, not a failure`);
216
227
  }
217
228
  }
218
229
  }
@@ -223,7 +234,25 @@ function checkLayout(framework, blueprints, out) {
223
234
  }
224
235
  }
225
236
  }
226
- function checkBlueprint(framework, blueprint, shapes, out) {
237
+ // Rule 19: a near-miss the Vocabulary names is flagged with the declared term
238
+ // beside it, never refused. The near-miss is the clause's opening phrase.
239
+ function checkVocabulary(framework, blueprint, out) {
240
+ if (framework.vocabulary.length === 0 || blueprint.body.trim() === '')
241
+ return;
242
+ const body = blueprint.body.toLowerCase();
243
+ for (const entry of framework.vocabulary) {
244
+ for (const clause of entry.not) {
245
+ const phrase = clause.split(/[,;:(]/)[0]?.trim().toLowerCase() ?? '';
246
+ if (phrase === '' || phrase === entry.term.toLowerCase())
247
+ continue;
248
+ const pattern = new RegExp(`(^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`, 'i');
249
+ if (pattern.test(body)) {
250
+ out.warn('term-near-miss', blueprint.rel, `uses "${phrase}"; the Vocabulary declares "${entry.term}"${entry.means ? ` (${entry.means})` : ''}`);
251
+ }
252
+ }
253
+ }
254
+ }
255
+ function checkBlueprint(framework, blueprint, templates, out) {
227
256
  const file = blueprint.rel;
228
257
  if (blueprint.properties === null) {
229
258
  if (blueprint.frontmatterError) {
@@ -241,13 +270,7 @@ function checkBlueprint(framework, blueprint, shapes, out) {
241
270
  const collection = blueprint.collection;
242
271
  const id = propertyString(blueprint, 'id');
243
272
  if (id === null) {
244
- out.error('id-missing', file, 'no `id`; add a permanent kebab-case identity');
245
- }
246
- else if (!isKebab(id)) {
247
- out.error('id-format', file, `id '${id}' is not kebab-case`);
248
- }
249
- else if (framework.naming === 'kebab-case' && blueprint.stem !== id) {
250
- out.warn('id-filename', file, `in a kebab-case root the filename is the id; expected ${id}.md`);
273
+ out.error('id-missing', file, 'no `id`; add a permanent identity (a slug, a number, a GUID)');
251
274
  }
252
275
  if (propertyString(blueprint, 'title') === null) {
253
276
  out.error('title-missing', file, 'no `title`');
@@ -255,13 +278,14 @@ function checkBlueprint(framework, blueprint, shapes, out) {
255
278
  if (propertyString(blueprint, 'summary') === null) {
256
279
  out.warn('summary-missing', file, 'no `summary`; the collection index will flag it');
257
280
  }
258
- const { flavor, declared, known } = resolveFlavor(blueprint);
281
+ const { variant, declared, known } = resolveVariant(blueprint);
259
282
  if (!known) {
260
- const names = collection.flavors.map((candidate) => candidate.name).join(', ');
261
- out.error('flavor-unknown', file, `flavor '${declared ?? ''}' is not one of ${collection.name}'s declared flavors (${names})`);
283
+ const names = collection.variants.map((candidate) => candidate.name).join(', ');
284
+ out.error('variant-unknown', file, `variant '${declared ?? ''}' is not one of ${collection.name}'s declared variants (${names})`);
262
285
  }
263
286
  const core = new Set(framework.schema.core.map((property) => property.name));
264
- const applicable = framework.schema.custom.filter((property) => appliesTo(property, collection.name));
287
+ const schemaProperties = [...framework.schema.custom, ...Object.values(framework.schema.tools).flat()];
288
+ const applicable = schemaProperties.filter((property) => appliesTo(property, collection.name));
265
289
  const applicableNames = new Set(applicable.map((property) => property.name));
266
290
  for (const property of applicable) {
267
291
  if (!(property.name in properties)) {
@@ -275,7 +299,7 @@ function checkBlueprint(framework, blueprint, shapes, out) {
275
299
  if (core.has(key) || applicableNames.has(key)) {
276
300
  continue;
277
301
  }
278
- const elsewhere = framework.schema.custom.find((property) => property.name === key);
302
+ const elsewhere = schemaProperties.find((property) => property.name === key);
279
303
  if (elsewhere) {
280
304
  const scope = elsewhere.appliesTo === 'all' ? 'all' : elsewhere.appliesTo.join(', ');
281
305
  out.warn('property-scope', file, `\`${key}\` applies to ${scope}, not ${collection.name}`);
@@ -284,7 +308,7 @@ function checkBlueprint(framework, blueprint, shapes, out) {
284
308
  out.warn('work-tracking-field', file, `\`${key}\` is a work-tracking field; a blueprint captures state and intent, not work (link to a tracker instead)`);
285
309
  }
286
310
  else {
287
- out.warn('property-unknown', file, `\`${key}\` is not in the Schema; declare it under ### Custom Properties or drop it`);
311
+ out.warn('property-unknown', file, `\`${key}\` is in no block of the Properties table; declare it among the custom properties or drop it`);
288
312
  }
289
313
  }
290
314
  for (const property of [...framework.schema.core, ...applicable]) {
@@ -327,38 +351,89 @@ function checkBlueprint(framework, blueprint, shapes, out) {
327
351
  if (h1 && h1.text !== title && kebab(h1.text) !== kebab(title)) {
328
352
  out.warn('title-mismatch', file, `the heading '${h1.text}' differs from title '${title}'`);
329
353
  }
330
- const shape = flavor ? (shapes.get(`${collection.name}/${flavor.name}`) ?? loadShape(framework, flavor)) : null;
331
- if (!shape || !flavor) {
354
+ const template = variant ? (templates.get(`${collection.name}/${variant.name}`) ?? loadTemplate(framework, variant)) : null;
355
+ if (!template || !variant) {
332
356
  return;
333
357
  }
334
358
  const present = blueprint.sections.map((section) => section.heading.text);
335
359
  const presentLower = present.map((text) => text.toLowerCase());
336
- const shapeLower = shape.sections.map((text) => text.toLowerCase());
337
- for (const name of shape.sections) {
360
+ const templateLower = template.sections.map((text) => text.toLowerCase());
361
+ for (const name of template.sections) {
338
362
  if (!presentLower.includes(name.toLowerCase())) {
339
- out.warn('section-missing', file, `no \`## ${name}\` (${flavor.kind}.${flavor.name} declares it; leave it out only if it genuinely does not apply)`);
363
+ out.warn('section-missing', file, `no \`## ${name}\` (${variant.unit}.${variant.name} declares it; leave it out only if it genuinely does not apply)`);
340
364
  }
341
365
  }
342
366
  if (!collection.framing) {
343
367
  for (const name of present) {
344
- if (!shapeLower.includes(name.toLowerCase())) {
345
- out.warn('section-unknown', file, `\`## ${name}\` is not a section of ${flavor.kind}.${flavor.name}; nest it under one that is, or add it to the shape`);
368
+ if (!templateLower.includes(name.toLowerCase())) {
369
+ out.warn('section-unknown', file, `\`## ${name}\` is not a section of ${variant.unit}.${variant.name}; nest it under one that is, or add it to the template`);
346
370
  }
347
371
  }
348
372
  }
349
- const order = presentLower.filter((name) => shapeLower.includes(name)).map((name) => shapeLower.indexOf(name));
373
+ const order = presentLower.filter((name) => templateLower.includes(name)).map((name) => templateLower.indexOf(name));
350
374
  for (let index = 1; index < order.length; index += 1) {
351
375
  if ((order[index] ?? 0) < (order[index - 1] ?? 0)) {
352
- out.warn('section-order', file, `sections are not in ${flavor.kind}.${flavor.name}'s order (${shape.sections.join(' → ')})`);
376
+ out.warn('section-order', file, `sections are not in ${variant.unit}.${variant.name}'s order (${template.sections.join(' → ')})`);
353
377
  break;
354
378
  }
355
379
  }
356
380
  }
381
+ function checkCanvases(framework, blueprints, out) {
382
+ const ids = new Set(blueprints.map((blueprint) => blueprintId(blueprint)).filter((id) => id !== null));
383
+ const canvases = listCanvases(framework);
384
+ const canvasIds = new Set(canvases.map((canvas) => canvas.id));
385
+ for (const canvas of canvases) {
386
+ const file = rel(framework.root, canvas.file);
387
+ for (const problem of canvas.problems) {
388
+ out.error('canvas-invalid', file, problem);
389
+ }
390
+ for (const page of canvas.pages) {
391
+ const nodeIds = new Set(page.nodes.map((node) => node.id));
392
+ const frameIds = new Set(page.frames.map((frame) => frame.id));
393
+ for (const node of page.nodes) {
394
+ if (node.blueprint && !ids.has(node.blueprint)) {
395
+ out.error('canvas-node-missing', file, `page ${page.id}, node ${node.id}: no blueprint with id '${node.blueprint}'`);
396
+ }
397
+ if (node.frame && !frameIds.has(node.frame)) {
398
+ out.warn('canvas-frame-missing', file, `page ${page.id}, node ${node.id}: no frame '${node.frame}' on this page`);
399
+ }
400
+ if (node.link?.page && !canvas.pages.some((candidate) => candidate.id === node.link?.page)) {
401
+ out.error('canvas-link-missing', file, `page ${page.id}, node ${node.id}: links to page '${node.link.page}', which this canvas does not have`);
402
+ }
403
+ if (node.link?.canvas && !canvasIds.has(node.link.canvas)) {
404
+ out.error('canvas-link-missing', file, `page ${page.id}, node ${node.id}: links to canvas '${node.link.canvas}', which does not exist`);
405
+ }
406
+ }
407
+ for (const comment of page.comments) {
408
+ if (comment.node && !page.nodes.some((node) => node.id === comment.node)) {
409
+ out.warn('canvas-comment-orphan', file, `page ${page.id}, comment ${comment.id}: pinned to node '${comment.node}', which the page no longer has`);
410
+ }
411
+ }
412
+ const endpoint = (id) => (isFrameRef(id) ? frameIds.has(id.slice(FRAME_REF.length)) : nodeIds.has(id));
413
+ for (const edge of page.edges) {
414
+ if (!endpoint(edge.from) || !endpoint(edge.to)) {
415
+ out.error('canvas-edge-missing', file, `page ${page.id}: edge ${edge.from} → ${edge.to} names a node or frame the page does not have`);
416
+ }
417
+ }
418
+ for (const node of page.nodes) {
419
+ if (node.annotation && !page.edges.some((edge) => edge.from === node.id)) {
420
+ out.warn('canvas-annotation-unpointed', file, `page ${page.id}, annotation ${node.id}: points at nothing; an annotation exists to point`);
421
+ }
422
+ }
423
+ for (const frame of page.frames) {
424
+ if (frame.frame && !frameIds.has(frame.frame)) {
425
+ out.warn('canvas-frame-missing', file, `page ${page.id}, frame ${frame.id}: attached to frame '${frame.frame}', which the page does not have`);
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
357
431
  export function checkRoot(framework, blueprints, options) {
358
432
  const out = new Collector();
359
- const shapes = checkFramework(framework, out, options);
433
+ const templates = checkFramework(framework, out, options);
360
434
  if (!options.only) {
361
435
  checkLayout(framework, blueprints, out);
436
+ checkCanvases(framework, blueprints, out);
362
437
  }
363
438
  const seen = new Map();
364
439
  for (const blueprint of blueprints) {
@@ -381,7 +456,8 @@ export function checkRoot(framework, blueprints, options) {
381
456
  }
382
457
  const targets = options.only ? blueprints.filter((blueprint) => options.only?.has(blueprint.path)) : blueprints;
383
458
  for (const blueprint of targets) {
384
- checkBlueprint(framework, blueprint, shapes, out);
459
+ checkBlueprint(framework, blueprint, templates, out);
460
+ checkVocabulary(framework, blueprint, out);
385
461
  }
386
462
  const findings = out.findings.sort((a, b) => {
387
463
  const left = a.path ?? '';
@@ -1,8 +1,9 @@
1
- // Converting a markdown root to the form the CLI works in: the same framework
2
- // written as `Framework.yaml` with the guidance as comments and the index
3
- // inside it, `Framework.md` and each collection's `index.md` removed. The
4
- // markdown form's prose (section intros, HTML comments) has no field to land
5
- // in and is left behind; each collection's own description travels.
1
+ // Converting a 4.x markdown root to Framework.yaml, the framework document
2
+ // since 5.0.0: the same framework written as data with this CLI's guidance
3
+ // as comments and the index inside it, `Framework.md` and each collection's
4
+ // `index.md` removed. `migrate` runs this on the way up. The markdown form's
5
+ // prose (section intros, HTML comments) has no field to land in and is left
6
+ // behind; each collection's own description travels.
6
7
  import { existsSync, rmSync, writeFileSync } from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { listBlueprints } from './blueprint.js';
@@ -10,7 +11,7 @@ import { loadFramework } from './framework.js';
10
11
  import { frameworkToDocument, serializeDocument } from './framework-structured.js';
11
12
  import { buildIndexes } from './index-leaf.js';
12
13
  export function convertToYaml(framework, dryRun) {
13
- const yamlFile = path.join(framework.root, '_eidos', 'Framework.yaml');
14
+ const yamlFile = path.join(framework.root, '.eidos', 'Framework.yaml');
14
15
  const actions = [];
15
16
  const removed = [];
16
17
  const text = serializeDocument(frameworkToDocument(framework));
@@ -1,13 +1,15 @@
1
- // The markdown framework document, `_eidos/Framework.md`: frontmatter for the
1
+ // The markdown framework document, `.eidos/Framework.md`: frontmatter for the
2
2
  // version and naming convention, and a body indexing the Top-Level docs, the
3
- // Collections (each a `###` with Leaf, Flavors, Canvas, and grouping bullets),
4
- // and the property Schema as two tables.
3
+ // Collections (each a `###` with Leaf, Variants, and grouping bullets), the
4
+ // Properties table per owner, the Vocabulary, and the Versions. The 4.x
5
+ // spellings (Schema, Flavors, shape) still read, so `migrate` can load a root
6
+ // it is about to move.
5
7
  import { parseFrontmatter } from './frontmatter.js';
6
- import { groupingProperty, STANDARD_CORE } from './framework-model.js';
8
+ import { groupingProperty, STANDARD_CORE, unitOf } from './framework-model.js';
7
9
  import { DEFAULT_NAMING, isNaming } from './naming.js';
8
10
  import { section, sections, stripInlineCode } from './markdown.js';
9
11
  import path from 'node:path';
10
- const DECLARED_BULLETS = new Set(['leaf', 'flavors', 'canvas']);
12
+ const DECLARED_BULLETS = new Set(['leaf', 'unit', 'variants', 'flavors', 'canvas']);
11
13
  function cleanText(text) {
12
14
  return text.replace(/^_+|_+$/g, '').trim();
13
15
  }
@@ -46,13 +48,13 @@ function parseCanvas(value) {
46
48
  }
47
49
  return { mode: 'card', section: null };
48
50
  }
49
- function flavorFromShape(label, shapeRel) {
50
- const file = path.posix.basename(shapeRel.replace(/\\/g, '/')).replace(/\.md$/i, '');
51
+ function variantFromTemplate(label, templateRel) {
52
+ const file = path.posix.basename(templateRel.replace(/\\/g, '/')).replace(/\.md$/i, '');
51
53
  const dot = file.indexOf('.');
52
- const kind = dot === -1 ? file : file.slice(0, dot);
54
+ const unit = dot === -1 ? file : file.slice(0, dot);
53
55
  const fromFile = dot === -1 ? file : file.slice(dot + 1);
54
56
  const name = /\.md$/i.test(label) ? fromFile : label;
55
- return { name, kind };
57
+ return { name, unit };
56
58
  }
57
59
  function parseCollections(body) {
58
60
  const block = section(body, 2, 'Collections');
@@ -64,20 +66,20 @@ function parseCollections(body) {
64
66
  const collection = {
65
67
  name: heading.text,
66
68
  description: firstParagraph(text),
67
- framing: out.length === 0,
69
+ framing: false,
68
70
  leaf: null,
69
- flavors: [],
71
+ variants: [],
70
72
  canvas: null,
71
73
  grouping: null,
72
74
  };
73
- let inFlavors = false;
75
+ let inVariants = false;
74
76
  let inGrouping = false;
75
77
  for (const line of text.split('\n')) {
76
78
  const bullet = /^-\s+\*\*(.+?):\*\*\s*(.*)$/.exec(line);
77
79
  if (bullet && bullet[1] !== undefined) {
78
80
  const label = bullet[1].trim();
79
81
  const rest = (bullet[2] ?? '').trim();
80
- inFlavors = label.toLowerCase() === 'flavors';
82
+ inVariants = label.toLowerCase() === 'variants' || label.toLowerCase() === 'flavors';
81
83
  inGrouping = false;
82
84
  if (label.toLowerCase() === 'leaf') {
83
85
  collection.leaf = parseLink(rest)?.target ?? null;
@@ -96,18 +98,18 @@ function parseCollections(body) {
96
98
  continue;
97
99
  }
98
100
  const item = nested[1].trim();
99
- if (inFlavors) {
100
- const flavorLine = /^\[(.+?)\]\((.+?)\)\s*(?:—\s*(.*))?$/.exec(item);
101
- if (flavorLine && flavorLine[1] && flavorLine[2]) {
102
- // `(default)` in the description marks the default flavor; it is a flag, not prose.
103
- const described = (flavorLine[3] ?? '').trim();
101
+ if (inVariants) {
102
+ const variantLine = /^\[(.+?)\]\((.+?)\)\s*(?:—\s*(.*))?$/.exec(item);
103
+ if (variantLine && variantLine[1] && variantLine[2]) {
104
+ // `(default)` in the description marks the default variant; it is a flag, not prose.
105
+ const described = (variantLine[3] ?? '').trim();
104
106
  const isDefault = /\(default\)/i.test(described);
105
107
  const description = described.replace(/\s*\(default\)\.?$/i, '').replace(/\s*\(default\)/i, '').trim();
106
- const { name, kind } = flavorFromShape(flavorLine[1].trim(), flavorLine[2].trim());
107
- collection.flavors.push({
108
+ const { name, unit } = variantFromTemplate(variantLine[1].trim(), variantLine[2].trim());
109
+ collection.variants.push({
108
110
  name,
109
- kind,
110
- shape: flavorLine[2].trim(),
111
+ unit,
112
+ template: variantLine[2].trim(),
111
113
  description,
112
114
  isDefault,
113
115
  });
@@ -120,12 +122,13 @@ function parseCollections(body) {
120
122
  }
121
123
  }
122
124
  }
123
- if (collection.flavors.length > 0 && !collection.flavors.some((flavor) => flavor.isDefault)) {
124
- const first = collection.flavors[0];
125
+ if (collection.variants.length > 0 && !collection.variants.some((variant) => variant.isDefault)) {
126
+ const first = collection.variants[0];
125
127
  if (first) {
126
128
  first.isDefault = true;
127
129
  }
128
130
  }
131
+ collection.framing = unitOf(collection) === 'frame';
129
132
  out.push(collection);
130
133
  }
131
134
  return out;
@@ -152,23 +155,86 @@ function parseAppliesTo(value) {
152
155
  }
153
156
  return plain.split(/[,/]/).map((name) => name.trim()).filter((name) => name !== '');
154
157
  }
155
- function parseProperties(block, core) {
158
+ // The standard's columns come first (Name, Type, Meaning for the core; Name,
159
+ // Type, Applies To, Meaning for custom properties); any column past them is a
160
+ // tool's, headed with the tool's name, and rides along as that tool's field.
161
+ function parseProperties(block, core, owner = core ? 'eidos' : 'custom') {
156
162
  if (block === null) {
157
163
  return [];
158
164
  }
159
165
  const out = [];
166
+ const standard = core ? 3 : 4;
167
+ let header = [];
160
168
  for (const cells of parseTable(block)) {
161
169
  const name = stripInlineCode(cells[0] ?? '').trim();
162
- if (name === '' || name.toLowerCase() === 'name') {
170
+ if (name === '') {
171
+ continue;
172
+ }
173
+ if (name.toLowerCase() === 'name') {
174
+ header = cells.map((cell) => stripInlineCode(cell).trim());
163
175
  continue;
164
176
  }
165
177
  const type = (cells[1] ?? 'Text').trim();
166
- if (core || cells.length < 4) {
167
- out.push({ name, type, appliesTo: 'all', meaning: (cells[cells.length - 1] ?? '').trim(), core });
178
+ const property = core || cells.length < 4
179
+ ? { name, type, appliesTo: 'all', meaning: (cells[Math.min(cells.length, standard) - 1] ?? '').trim(), core, owner }
180
+ : { name, type, appliesTo: parseAppliesTo(cells[2] ?? ''), meaning: (cells[3] ?? '').trim(), core, owner };
181
+ const tools = {};
182
+ for (let index = standard; index < cells.length; index += 1) {
183
+ const tool = header[index];
184
+ const cell = (cells[index] ?? '').trim();
185
+ if (tool && cell !== '') {
186
+ tools[tool] = cell;
187
+ }
168
188
  }
169
- else {
170
- out.push({ name, type, appliesTo: parseAppliesTo(cells[2] ?? ''), meaning: (cells[3] ?? '').trim(), core });
189
+ if (Object.keys(tools).length > 0) {
190
+ property.tools = tools;
171
191
  }
192
+ out.push(property);
193
+ }
194
+ return out;
195
+ }
196
+ // `## Vocabulary`: Term · Means · Not, one row per term. A Term cell may be a
197
+ // link to the blueprint that defines it; the Not cell lists near-misses, `;` apart.
198
+ function parseVocabulary(body) {
199
+ const block = section(body, 2, 'Vocabulary');
200
+ if (block === null)
201
+ return [];
202
+ const out = [];
203
+ for (const row of parseTable(block)) {
204
+ const [termCell = '', means = '', notCell = ''] = row;
205
+ const link = parseLink(termCell.trim());
206
+ const term = (link ? link.title : stripInlineCode(termCell.replace(/\*\*/g, ''))).trim();
207
+ // the header row, a placeholder row, and a blank row are not terms
208
+ if (term === '' || term.toLowerCase() === 'term' || /^<.*>$/.test(term) || /^-+$/.test(term))
209
+ continue;
210
+ out.push({
211
+ term,
212
+ means: cleanText(means),
213
+ not: notCell
214
+ .split(';')
215
+ .map((entry) => cleanText(entry))
216
+ .filter((entry) => entry !== ''),
217
+ see: link ? link.target : null,
218
+ });
219
+ }
220
+ return out;
221
+ }
222
+ // `## Versions`: Version · Commit · Tag, newest first, as written. The key
223
+ // left the standard in 5.0.0; `migrate` moves what a 4.x root recorded.
224
+ export function parseVersions(body) {
225
+ const block = section(body, 2, 'Versions');
226
+ if (block === null) {
227
+ return [];
228
+ }
229
+ const out = [];
230
+ for (const cells of parseTable(block)) {
231
+ const version = stripInlineCode(cells[0] ?? '').replace(/^\*\*|\*\*$/g, '').trim();
232
+ if (version === '' || version.toLowerCase() === 'version') {
233
+ continue;
234
+ }
235
+ const commit = stripInlineCode(cells[1] ?? '').trim();
236
+ const tag = stripInlineCode(cells[2] ?? '').trim();
237
+ out.push({ version, commit, tag: tag === '' || tag === '—' || tag === '-' ? null : tag });
172
238
  }
173
239
  return out;
174
240
  }
@@ -207,11 +273,23 @@ export function parseFrameworkMarkdown(text, root, file) {
207
273
  }
208
274
  }
209
275
  const version = properties?.['eidos_version'];
210
- const schemaBlock = section(body, 2, 'Schema') ?? '';
211
- const coreBlock = section(schemaBlock, 3, 'Eidos Core');
212
- const customBlock = section(schemaBlock, 3, 'Custom Properties');
276
+ const schemaBlock = section(body, 2, 'Properties') ?? section(body, 2, 'Schema') ?? '';
277
+ const coreBlock = section(schemaBlock, 3, 'Eidos') ?? section(schemaBlock, 3, 'Eidos Core');
278
+ const customBlock = section(schemaBlock, 3, 'Custom') ?? section(schemaBlock, 3, 'Custom Properties');
213
279
  const core = parseProperties(coreBlock, true);
214
280
  const custom = parseProperties(customBlock, false);
281
+ // `### <tool>` (`### <tool> Properties` before 5.0.0): a block a tool declared and alone writes.
282
+ const tools = {};
283
+ for (const { heading, body: block } of sections(schemaBlock, 3)) {
284
+ const name = heading.text.trim();
285
+ if (/^(eidos( core)?|custom( properties)?)$/i.test(name))
286
+ continue;
287
+ const match = /^(.+?)(?:\s+Properties)?$/i.exec(name);
288
+ const tool = match?.[1]?.trim() ?? '';
289
+ if (!match || tool === '' || tool.toLowerCase() === 'custom' || /^<.*>$/.test(tool))
290
+ continue;
291
+ tools[tool] = parseProperties(block, false, tool);
292
+ }
215
293
  const collections = parseCollections(body);
216
294
  for (const collection of collections) {
217
295
  if (collection.grouping) {
@@ -227,7 +305,8 @@ export function parseFrameworkMarkdown(text, root, file) {
227
305
  namingError,
228
306
  topLevel: parseTopLevel(body),
229
307
  collections,
230
- schema: { core: core.length > 0 ? core : STANDARD_CORE, custom },
308
+ schema: { core: core.length > 0 ? core : STANDARD_CORE, custom, tools },
309
+ vocabulary: parseVocabulary(body),
231
310
  problems: [],
232
311
  };
233
312
  }