eidosmd 0.1.0 → 0.3.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 (107) hide show
  1. package/README.md +50 -29
  2. package/browser/dist/assets/index-Cc3cNWHY.css +1 -0
  3. package/browser/dist/assets/index-DQgCQRa5.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 +10 -4
  11. package/dist/src/commands/configure.js +201 -0
  12. package/dist/src/commands/framework.js +37 -7
  13. package/dist/src/commands/index.js +4 -4
  14. package/dist/src/commands/init.js +5 -0
  15. package/dist/src/commands/instructions.js +1 -1
  16. package/dist/src/commands/list.js +8 -8
  17. package/dist/src/commands/migrate.js +32 -0
  18. package/dist/src/commands/new.js +3 -3
  19. package/dist/src/commands/property.js +125 -0
  20. package/dist/src/commands/seeds.js +5 -5
  21. package/dist/src/commands/setup.js +131 -0
  22. package/dist/src/commands/version.js +44 -0
  23. package/dist/src/commands/whoami.js +4 -4
  24. package/dist/src/context.js +5 -5
  25. package/dist/src/core/blueprint.js +16 -11
  26. package/dist/src/core/canvas-schema.js +148 -0
  27. package/dist/src/core/canvas.js +732 -0
  28. package/dist/src/core/check.js +221 -70
  29. package/dist/src/core/convert.js +7 -6
  30. package/dist/src/core/edits.js +1381 -0
  31. package/dist/src/core/framework-markdown.js +119 -34
  32. package/dist/src/core/framework-model.js +62 -10
  33. package/dist/src/core/framework-structured.js +222 -47
  34. package/dist/src/core/framework.js +13 -13
  35. package/dist/src/core/frontmatter.js +61 -1
  36. package/dist/src/core/git.js +84 -0
  37. package/dist/src/core/index-leaf.js +2 -2
  38. package/dist/src/core/links.js +87 -0
  39. package/dist/src/core/markdown.js +16 -9
  40. package/dist/src/core/me.js +16 -8
  41. package/dist/src/core/migrate.js +319 -0
  42. package/dist/src/core/naming.js +1 -1
  43. package/dist/src/core/regions.js +117 -0
  44. package/dist/src/core/root.js +2 -2
  45. package/dist/src/core/scaffold.js +33 -27
  46. package/dist/src/core/seed.js +187 -71
  47. package/dist/src/core/server.js +1410 -53
  48. package/dist/src/core/settings.js +232 -0
  49. package/dist/src/core/store.js +315 -0
  50. package/dist/src/core/template.js +32 -0
  51. package/dist/src/core/versions.js +84 -0
  52. package/dist/src/output.js +4 -1
  53. package/dist/src/program.js +421 -41
  54. package/instructions/authoring.md +15 -12
  55. package/instructions/configuring.md +81 -34
  56. package/instructions/init-required.md +4 -4
  57. package/instructions/overview.md +21 -9
  58. package/instructions/validating.md +9 -6
  59. package/package.json +21 -12
  60. package/standard/EIDOS.md +142 -259
  61. package/standard/seeds/README.md +12 -16
  62. package/standard/seeds/book/Framework.yaml +61 -0
  63. package/standard/seeds/book/README.md +10 -5
  64. package/standard/seeds/book/_gitignore +9 -3
  65. package/standard/seeds/book/me.md +1 -1
  66. package/standard/seeds/book/roles/README.md +3 -3
  67. package/standard/seeds/book/roles/framework-owner.md +2 -2
  68. package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
  69. package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
  70. package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
  71. package/standard/seeds/book/templates/frame.premise.md +17 -0
  72. package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
  73. package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
  74. package/standard/seeds/research/Framework.yaml +61 -0
  75. package/standard/seeds/research/README.md +10 -5
  76. package/standard/seeds/research/_gitignore +9 -3
  77. package/standard/seeds/research/me.md +1 -1
  78. package/standard/seeds/research/roles/README.md +3 -3
  79. package/standard/seeds/research/roles/framework-owner.md +2 -2
  80. package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
  81. package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
  82. package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
  83. package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
  84. package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
  85. package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
  86. package/standard/seeds/software/Framework.yaml +62 -0
  87. package/standard/seeds/software/README.md +7 -6
  88. package/standard/seeds/software/_gitignore +9 -3
  89. package/standard/seeds/software/me.md +1 -1
  90. package/standard/seeds/software/roles/README.md +3 -3
  91. package/standard/seeds/software/roles/framework-owner.md +2 -2
  92. package/standard/seeds/software/roles/project-manager.md +2 -2
  93. package/standard/seeds/software/roles/stakeholder.md +1 -1
  94. package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
  95. package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
  96. package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
  97. package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
  98. package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
  99. package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
  100. package/browser/index.html +0 -268
  101. package/dist/src/commands/convert.js +0 -30
  102. package/dist/src/core/shape.js +0 -26
  103. package/standard/seeds/book/Framework.md +0 -87
  104. package/standard/seeds/book/shapes/frame.premise.md +0 -24
  105. package/standard/seeds/research/Framework.md +0 -88
  106. package/standard/seeds/software/Framework.md +0 -88
  107. /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
@@ -1,17 +1,19 @@
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, groupDirs, propertyString, resolveVariant } from './blueprint.js';
9
+ import { appliesTo, isCollection, 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 { parseRegions, stripRegions } from './regions.js';
16
+ import { loadTemplate } from './template.js';
15
17
  const WORK_TRACKING_FIELDS = new Set(['sprint', 'estimate', 'assignee', 'story_points']);
16
18
  const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build']);
17
19
  function compareVersions(a, b) {
@@ -49,6 +51,19 @@ function typeProblem(property, value) {
49
51
  return null;
50
52
  }
51
53
  }
54
+ // Rule 9: a value off a property's declared options, exact, case included:
55
+ // the value (or the List's elements) not on the list, or null when every
56
+ // one is, or when the property declares none. The list rides along so the
57
+ // message can show it.
58
+ export function optionProblem(property, value) {
59
+ if (!property.options || isEmpty(value)) {
60
+ return null;
61
+ }
62
+ const declared = new Set(property.options);
63
+ const held = Array.isArray(value) ? value : [value];
64
+ const off = held.filter((item) => !isEmpty(item) && (typeof item !== 'string' || !declared.has(item))).map((item) => (typeof item === 'string' ? item : JSON.stringify(item)));
65
+ return off.length > 0 ? { off, options: property.options } : null;
66
+ }
52
67
  // Every markdown link target in a piece of text, outside code fences and
53
68
  // inline code. Scheme-bearing targets and pure anchors are not files.
54
69
  function linkTargets(text) {
@@ -99,8 +114,8 @@ function propertyLinks(value) {
99
114
  }
100
115
  class Collector {
101
116
  findings = [];
102
- error(code, file, message) {
103
- this.findings.push({ level: 'error', code, path: file, message });
117
+ error(code, file, message, fix) {
118
+ this.findings.push({ level: 'error', code, path: file, message, ...(fix ? { fix } : {}) });
104
119
  }
105
120
  warn(code, file, message) {
106
121
  this.findings.push({ level: 'warning', code, path: file, message });
@@ -109,6 +124,15 @@ class Collector {
109
124
  function rel(root, file) {
110
125
  return path.relative(root, file).split(path.sep).join('/');
111
126
  }
127
+ // Rule 20: a region's contents are never faulted, and the one thing about a
128
+ // region that is, whichever tool it names, is an opener with no closer.
129
+ function checkRegions(text, file, out) {
130
+ for (const region of parseRegions(text)) {
131
+ if (region.close === null) {
132
+ out.error('region-unclosed', file, `\`<!-- ${region.tool}:${region.region} -->\` opens a region that never closes; add \`<!-- /${region.tool}:${region.region} -->\` after its contents`);
133
+ }
134
+ }
135
+ }
112
136
  function checkFramework(framework, out, options) {
113
137
  const frameworkRel = rel(framework.root, framework.file);
114
138
  for (const problem of framework.problems) {
@@ -123,44 +147,79 @@ function checkFramework(framework, out, options) {
123
147
  else {
124
148
  const diff = compareVersions(framework.eidosVersion, options.standardVersion);
125
149
  if (diff < 0) {
126
- out.warn('version-behind', frameworkRel, `targets Eidos ${framework.eidosVersion}; this CLI carries ${options.standardVersion} (migrate when ready)`);
150
+ out.warn('version-behind', frameworkRel, `targets Eidos ${framework.eidosVersion}; this CLI carries ${options.standardVersion}: run \`eidos migrate\` when ready`);
127
151
  }
128
152
  else if (diff > 0) {
129
153
  out.warn('version-ahead', frameworkRel, `targets Eidos ${framework.eidosVersion}, newer than the ${options.standardVersion} this CLI carries`);
130
154
  }
131
155
  }
132
156
  if (framework.collections.length === 0) {
133
- out.error('no-collections', frameworkRel, 'declares no collections under ## Collections; a framework needs at least a framing collection');
157
+ out.error('no-collections', frameworkRel, 'declares no collections; a framework needs at least one');
134
158
  }
135
- if (!existsSync(path.join(framework.root, 'README.md'))) {
136
- out.warn('readme-missing', null, 'no README.md at the root; the visible front door is missing');
159
+ if (/^versions:/m.test(readFileSync(framework.file, 'utf8'))) {
160
+ 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');
137
161
  }
138
162
  for (const doc of framework.topLevel) {
139
- if (isFileTarget(doc.path) && !linkExists(path.dirname(framework.file), doc.path)) {
163
+ if (!isFileTarget(doc.path)) {
164
+ continue;
165
+ }
166
+ if (!linkExists(path.dirname(framework.file), doc.path)) {
140
167
  out.warn('top-level-missing', frameworkRel, `## Top-Level lists ${doc.title} at ${doc.path}, which does not exist`);
168
+ continue;
169
+ }
170
+ const file = path.resolve(path.dirname(framework.file), doc.path.split('#')[0] ?? '');
171
+ if (file.startsWith(framework.root + path.sep) && /\.md$/i.test(file)) {
172
+ checkRegions(readFileSync(file, 'utf8'), rel(framework.root, file), out);
141
173
  }
142
174
  }
143
- const shapes = new Map();
175
+ // A collection's templates are named for its unit, `<unit>.<variant>.md`;
176
+ // a file named otherwise is faulted, as is one that opens with frontmatter.
177
+ const templates = new Map();
144
178
  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`);
179
+ const unit = unitOf(collection);
180
+ for (const variant of collection.variants) {
181
+ const template = loadTemplate(framework, variant);
182
+ templates.set(`${collection.name}/${variant.name}`, template);
183
+ if (template?.frontmatter) {
184
+ 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');
185
+ }
186
+ if (template) {
187
+ checkRegions(template.text, rel(framework.root, template.path), out);
188
+ }
189
+ if (!template) {
190
+ out.error('template-missing', frameworkRel, `${collection.name}/${variant.name} points at .eidos/${variant.template}, which does not exist`);
191
+ }
192
+ const expected = unit ? `${unit}.${variant.name}.md` : null;
193
+ if (expected && !variant.template.endsWith(`/${expected}`) && variant.template !== expected) {
194
+ 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
195
  }
154
196
  }
155
197
  if (!existsSync(path.join(framework.root, collection.name))) {
156
198
  out.warn('collection-folder-missing', null, `collection ${collection.name} is declared but has no folder`);
157
199
  }
158
200
  }
159
- return shapes;
201
+ for (const folder of framework.folders) {
202
+ if (!isCollection(folder) && !existsSync(path.join(framework.root, folder.name))) {
203
+ out.warn('folder-missing', null, `${folder.name} is declared as ${folder.type === 'assets' ? 'an assets' : 'an other'} folder but has no folder`);
204
+ }
205
+ }
206
+ return templates;
160
207
  }
161
- function checkLayout(framework, blueprints, out) {
162
- const declared = new Set(framework.collections.map((collection) => collection.name));
163
- const embedded = embeddedIndexOf(readFileSync(framework.file, 'utf8'));
208
+ // Rule 21: what sits at the root that the framework document does not
209
+ // declare. A folder is `.eidos/` or one under `folders`; a file is one under
210
+ // `top_level`. Hidden entries are the host's and exempt.
211
+ export function undeclaredAtRoot(framework) {
212
+ const declaredFolders = new Set(framework.folders.map((folder) => folder.name));
213
+ const declaredFiles = new Set();
214
+ for (const doc of framework.topLevel) {
215
+ if (!isFileTarget(doc.path))
216
+ continue;
217
+ const file = path.resolve(path.dirname(framework.file), doc.path.split('#')[0] ?? '');
218
+ if (path.dirname(file) === framework.root)
219
+ declaredFiles.add(path.basename(file));
220
+ }
221
+ const folders = [];
222
+ const files = [];
164
223
  let entries = [];
165
224
  try {
166
225
  entries = readdirSync(framework.root);
@@ -169,19 +228,30 @@ function checkLayout(framework, blueprints, out) {
169
228
  entries = [];
170
229
  }
171
230
  for (const entry of entries.sort()) {
172
- if (entry.startsWith('.') || entry === '_eidos' || SKIPPED_DIRS.has(entry) || declared.has(entry)) {
231
+ if (entry.startsWith('.') || SKIPPED_DIRS.has(entry))
173
232
  continue;
174
- }
175
233
  let isDir = false;
176
234
  try {
177
235
  isDir = statSync(path.join(framework.root, entry)).isDirectory();
178
236
  }
179
237
  catch {
180
- isDir = false;
181
- }
182
- 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');
238
+ continue;
184
239
  }
240
+ if (isDir && !declaredFolders.has(entry))
241
+ folders.push(entry);
242
+ else if (!isDir && !declaredFiles.has(entry))
243
+ files.push(entry);
244
+ }
245
+ return { folders, files };
246
+ }
247
+ function checkLayout(framework, blueprints, out) {
248
+ const embedded = embeddedIndexOf(readFileSync(framework.file, 'utf8'));
249
+ const undeclared = undeclaredAtRoot(framework);
250
+ for (const entry of undeclared.folders) {
251
+ out.warn('folder-undeclared', entry, 'a folder at the root the framework document does not declare; declare it under folders (a collection, assets, or other) or move it');
252
+ }
253
+ for (const entry of undeclared.files) {
254
+ out.warn('file-undeclared', entry, 'a file at the root that top_level does not list; declare it (`eidos configure:doc add`) or move it');
185
255
  }
186
256
  for (const collection of framework.collections) {
187
257
  const folder = path.join(framework.root, collection.name);
@@ -190,13 +260,21 @@ function checkLayout(framework, blueprints, out) {
190
260
  }
191
261
  const own = blueprints.filter((blueprint) => blueprint.collection === collection);
192
262
  const groupsOnDisk = [...new Set(own.map((blueprint) => blueprint.group).filter((group) => group !== null))];
193
- if (collection.grouping && collection.grouping.groups.length > 0) {
194
- const declaredGroups = new Map(collection.grouping.groups.map((group) => [group.name.toLowerCase(), group.name]));
195
- for (const group of groupsOnDisk) {
196
- if (!declaredGroups.has(group.toLowerCase())) {
197
- out.warn('group-undeclared', `${collection.name}/${group}`, `not declared under ${collection.grouping.label} in Framework.md`);
198
- }
263
+ // Rule 10: a collection is flat or grouped; every sub-folder is a declared
264
+ // group, and a group holds blueprints and nothing deeper.
265
+ const declaredGroups = new Map((collection.grouping?.groups ?? []).map((group) => [group.name.toLowerCase(), group.name]));
266
+ for (const group of groupDirs(folder)) {
267
+ if (!collection.grouping) {
268
+ out.warn('group-undeclared', `${collection.name}/${group}`, `a sub-folder of ${collection.name}, which declares no grouping; declare a grouping and the group, or move it`);
199
269
  }
270
+ else if (!declaredGroups.has(group.toLowerCase())) {
271
+ out.warn('group-undeclared', `${collection.name}/${group}`, `not declared under ${collection.grouping.label} in the framework document`);
272
+ }
273
+ for (const nested of groupDirs(path.join(folder, group))) {
274
+ out.warn('folder-nested', `${collection.name}/${group}/${nested}`, 'a folder inside a group; a group holds blueprints and nothing deeper');
275
+ }
276
+ }
277
+ if (collection.grouping) {
200
278
  for (const group of collection.grouping.groups) {
201
279
  if (!existsSync(path.join(folder, group.name))) {
202
280
  out.warn('group-folder-missing', `${collection.name}/${group.name}`, `declared under ${collection.grouping.label} but has no folder`);
@@ -209,10 +287,10 @@ function checkLayout(framework, blueprints, out) {
209
287
  }
210
288
  }
211
289
  if (collection.framing) {
212
- for (const flavor of collection.flavors) {
213
- const written = own.some((blueprint) => resolveFlavor(blueprint).flavor === flavor);
290
+ for (const variant of collection.variants) {
291
+ const written = own.some((blueprint) => resolveVariant(blueprint).variant === variant);
214
292
  if (!written) {
215
- out.warn('frame-unwritten', collection.name, `no ${flavor.name} frame yet; a gap to surface, not a failure`);
293
+ out.warn('frame-unwritten', collection.name, `no ${variant.name} frame yet; a gap to surface, not a failure`);
216
294
  }
217
295
  }
218
296
  }
@@ -223,7 +301,25 @@ function checkLayout(framework, blueprints, out) {
223
301
  }
224
302
  }
225
303
  }
226
- function checkBlueprint(framework, blueprint, shapes, out) {
304
+ // Rule 19: a near-miss the Vocabulary names is flagged with the declared term
305
+ // beside it, never refused. The near-miss is the clause's opening phrase.
306
+ function checkVocabulary(framework, blueprint, out) {
307
+ if (framework.vocabulary.length === 0 || blueprint.body.trim() === '')
308
+ return;
309
+ const body = stripRegions(blueprint.body).toLowerCase();
310
+ for (const entry of framework.vocabulary) {
311
+ for (const clause of entry.not) {
312
+ const phrase = clause.split(/[,;:(]/)[0]?.trim().toLowerCase() ?? '';
313
+ if (phrase === '' || phrase === entry.term.toLowerCase())
314
+ continue;
315
+ const pattern = new RegExp(`(^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`, 'i');
316
+ if (pattern.test(body)) {
317
+ out.warn('term-near-miss', blueprint.rel, `uses "${phrase}"; the Vocabulary declares "${entry.term}"${entry.means ? ` (${entry.means})` : ''}`);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ function checkBlueprint(framework, blueprint, templates, out) {
227
323
  const file = blueprint.rel;
228
324
  if (blueprint.properties === null) {
229
325
  if (blueprint.frontmatterError) {
@@ -241,13 +337,7 @@ function checkBlueprint(framework, blueprint, shapes, out) {
241
337
  const collection = blueprint.collection;
242
338
  const id = propertyString(blueprint, 'id');
243
339
  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`);
340
+ out.error('id-missing', file, 'no `id`; add a permanent identity (a slug, a number, a GUID)');
251
341
  }
252
342
  if (propertyString(blueprint, 'title') === null) {
253
343
  out.error('title-missing', file, 'no `title`');
@@ -255,27 +345,30 @@ function checkBlueprint(framework, blueprint, shapes, out) {
255
345
  if (propertyString(blueprint, 'summary') === null) {
256
346
  out.warn('summary-missing', file, 'no `summary`; the collection index will flag it');
257
347
  }
258
- const { flavor, declared, known } = resolveFlavor(blueprint);
348
+ const { variant, declared, known } = resolveVariant(blueprint);
259
349
  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})`);
350
+ const names = collection.variants.map((candidate) => candidate.name).join(', ');
351
+ out.error('variant-unknown', file, `variant '${declared ?? ''}' is not one of ${collection.name}'s declared variants (${names})`);
262
352
  }
263
353
  const core = new Set(framework.schema.core.map((property) => property.name));
264
- const applicable = framework.schema.custom.filter((property) => appliesTo(property, collection.name));
354
+ const schemaProperties = [...framework.schema.custom, ...Object.values(framework.schema.tools).flat()];
355
+ const applicable = schemaProperties.filter((property) => appliesTo(property, collection.name));
265
356
  const applicableNames = new Set(applicable.map((property) => property.name));
266
- for (const property of applicable) {
357
+ // Rule 4: a required property absent or blank is a gap to surface; an
358
+ // optional one absent is nothing, and present it is checked only by type.
359
+ for (const property of applicable.filter((candidate) => candidate.required)) {
267
360
  if (!(property.name in properties)) {
268
- out.warn('property-missing', file, `no \`${property.name}\` (${property.type}, applies to ${collection.name})`);
361
+ out.warn('property-missing', file, `no \`${property.name}\` (${property.type}, required, applies to ${collection.name})`);
269
362
  }
270
363
  else if (isEmpty(properties[property.name]) && property.type.toLowerCase() !== 'list') {
271
- out.warn('property-empty', file, `\`${property.name}\` has no value`);
364
+ out.warn('property-empty', file, `\`${property.name}\` is required but has no value`);
272
365
  }
273
366
  }
274
367
  for (const key of Object.keys(properties)) {
275
368
  if (core.has(key) || applicableNames.has(key)) {
276
369
  continue;
277
370
  }
278
- const elsewhere = framework.schema.custom.find((property) => property.name === key);
371
+ const elsewhere = schemaProperties.find((property) => property.name === key);
279
372
  if (elsewhere) {
280
373
  const scope = elsewhere.appliesTo === 'all' ? 'all' : elsewhere.appliesTo.join(', ');
281
374
  out.warn('property-scope', file, `\`${key}\` applies to ${scope}, not ${collection.name}`);
@@ -284,7 +377,7 @@ function checkBlueprint(framework, blueprint, shapes, out) {
284
377
  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
378
  }
286
379
  else {
287
- out.warn('property-unknown', file, `\`${key}\` is not in the Schema; declare it under ### Custom Properties or drop it`);
380
+ out.warn('property-unknown', file, `\`${key}\` is in no block of the Properties table; declare it among the custom properties or drop it`);
288
381
  }
289
382
  }
290
383
  for (const property of [...framework.schema.core, ...applicable]) {
@@ -294,6 +387,11 @@ function checkBlueprint(framework, blueprint, shapes, out) {
294
387
  const problem = typeProblem(property, properties[property.name]);
295
388
  if (problem) {
296
389
  out.warn('property-type', file, `\`${property.name}\` (${property.type}): ${problem}`);
390
+ continue;
391
+ }
392
+ const offList = optionProblem(property, properties[property.name]);
393
+ if (offList) {
394
+ out.warn('property-option', file, `\`${property.name}: ${offList.off.join(', ')}\` is not one of its options (${offList.options.join(', ')})`);
297
395
  }
298
396
  }
299
397
  if (collection.grouping?.property) {
@@ -313,8 +411,9 @@ function checkBlueprint(framework, blueprint, shapes, out) {
313
411
  }
314
412
  }
315
413
  }
414
+ checkRegions(blueprint.body, file, out);
316
415
  const brokenInBody = new Map();
317
- for (const target of linkTargets(blueprint.body)) {
416
+ for (const target of linkTargets(stripRegions(blueprint.body))) {
318
417
  if (isFileTarget(target) && !linkExists(dir, target)) {
319
418
  brokenInBody.set(target, (brokenInBody.get(target) ?? 0) + 1);
320
419
  }
@@ -327,38 +426,89 @@ function checkBlueprint(framework, blueprint, shapes, out) {
327
426
  if (h1 && h1.text !== title && kebab(h1.text) !== kebab(title)) {
328
427
  out.warn('title-mismatch', file, `the heading '${h1.text}' differs from title '${title}'`);
329
428
  }
330
- const shape = flavor ? (shapes.get(`${collection.name}/${flavor.name}`) ?? loadShape(framework, flavor)) : null;
331
- if (!shape || !flavor) {
429
+ const template = variant ? (templates.get(`${collection.name}/${variant.name}`) ?? loadTemplate(framework, variant)) : null;
430
+ if (!template || !variant) {
332
431
  return;
333
432
  }
334
433
  const present = blueprint.sections.map((section) => section.heading.text);
335
434
  const presentLower = present.map((text) => text.toLowerCase());
336
- const shapeLower = shape.sections.map((text) => text.toLowerCase());
337
- for (const name of shape.sections) {
435
+ const templateLower = template.sections.map((text) => text.toLowerCase());
436
+ for (const name of template.sections) {
338
437
  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)`);
438
+ out.warn('section-missing', file, `no \`## ${name}\` (${variant.unit}.${variant.name} declares it; leave it out only if it genuinely does not apply)`);
340
439
  }
341
440
  }
342
441
  if (!collection.framing) {
343
442
  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`);
443
+ if (!templateLower.includes(name.toLowerCase())) {
444
+ 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
445
  }
347
446
  }
348
447
  }
349
- const order = presentLower.filter((name) => shapeLower.includes(name)).map((name) => shapeLower.indexOf(name));
448
+ const order = presentLower.filter((name) => templateLower.includes(name)).map((name) => templateLower.indexOf(name));
350
449
  for (let index = 1; index < order.length; index += 1) {
351
450
  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(' → ')})`);
451
+ out.warn('section-order', file, `sections are not in ${variant.unit}.${variant.name}'s order (${template.sections.join(' → ')})`);
353
452
  break;
354
453
  }
355
454
  }
356
455
  }
456
+ function checkCanvases(framework, blueprints, out) {
457
+ const ids = new Set(blueprints.map((blueprint) => blueprintId(blueprint)).filter((id) => id !== null));
458
+ const canvases = listCanvases(framework);
459
+ const canvasIds = new Set(canvases.map((canvas) => canvas.id));
460
+ for (const canvas of canvases) {
461
+ const file = rel(framework.root, canvas.file);
462
+ for (const problem of canvas.problems) {
463
+ out.error('canvas-invalid', file, problem);
464
+ }
465
+ for (const page of canvas.pages) {
466
+ const nodeIds = new Set(page.nodes.map((node) => node.id));
467
+ const frameIds = new Set(page.frames.map((frame) => frame.id));
468
+ for (const node of page.nodes) {
469
+ if (node.blueprint && !ids.has(node.blueprint)) {
470
+ out.error('canvas-node-missing', file, `page ${page.id}, node ${node.id}: no blueprint with id '${node.blueprint}'`);
471
+ }
472
+ if (node.frame && !frameIds.has(node.frame)) {
473
+ out.warn('canvas-frame-missing', file, `page ${page.id}, node ${node.id}: no frame '${node.frame}' on this page`);
474
+ }
475
+ if (node.link?.page && !canvas.pages.some((candidate) => candidate.id === node.link?.page)) {
476
+ out.error('canvas-link-missing', file, `page ${page.id}, node ${node.id}: links to page '${node.link.page}', which this canvas does not have`);
477
+ }
478
+ if (node.link?.canvas && !canvasIds.has(node.link.canvas)) {
479
+ out.error('canvas-link-missing', file, `page ${page.id}, node ${node.id}: links to canvas '${node.link.canvas}', which does not exist`);
480
+ }
481
+ }
482
+ for (const comment of page.comments) {
483
+ if (comment.node && !page.nodes.some((node) => node.id === comment.node)) {
484
+ out.warn('canvas-comment-orphan', file, `page ${page.id}, comment ${comment.id}: pinned to node '${comment.node}', which the page no longer has`);
485
+ }
486
+ }
487
+ const endpoint = (id) => (isFrameRef(id) ? frameIds.has(id.slice(FRAME_REF.length)) : nodeIds.has(id));
488
+ for (const edge of page.edges) {
489
+ if (!endpoint(edge.from) || !endpoint(edge.to)) {
490
+ out.error('canvas-edge-missing', file, `page ${page.id}: edge ${edge.from} → ${edge.to} names a node or frame the page does not have`);
491
+ }
492
+ }
493
+ for (const node of page.nodes) {
494
+ if (node.annotation && !page.edges.some((edge) => edge.from === node.id)) {
495
+ out.warn('canvas-annotation-unpointed', file, `page ${page.id}, annotation ${node.id}: points at nothing; an annotation exists to point`);
496
+ }
497
+ }
498
+ for (const frame of page.frames) {
499
+ if (frame.frame && !frameIds.has(frame.frame)) {
500
+ out.warn('canvas-frame-missing', file, `page ${page.id}, frame ${frame.id}: attached to frame '${frame.frame}', which the page does not have`);
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
357
506
  export function checkRoot(framework, blueprints, options) {
358
507
  const out = new Collector();
359
- const shapes = checkFramework(framework, out, options);
508
+ const templates = checkFramework(framework, out, options);
360
509
  if (!options.only) {
361
510
  checkLayout(framework, blueprints, out);
511
+ checkCanvases(framework, blueprints, out);
362
512
  }
363
513
  const seen = new Map();
364
514
  for (const blueprint of blueprints) {
@@ -381,7 +531,8 @@ export function checkRoot(framework, blueprints, options) {
381
531
  }
382
532
  const targets = options.only ? blueprints.filter((blueprint) => options.only?.has(blueprint.path)) : blueprints;
383
533
  for (const blueprint of targets) {
384
- checkBlueprint(framework, blueprint, shapes, out);
534
+ checkBlueprint(framework, blueprint, templates, out);
535
+ checkVocabulary(framework, blueprint, out);
385
536
  }
386
537
  const findings = out.findings.sort((a, b) => {
387
538
  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));