eidosmd 0.1.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 (95) hide show
  1. package/LICENSE +133 -0
  2. package/NOTICE +15 -0
  3. package/README.md +146 -0
  4. package/browser/index.html +268 -0
  5. package/dist/src/cli.js +5 -0
  6. package/dist/src/commands/agents.js +47 -0
  7. package/dist/src/commands/browser.js +40 -0
  8. package/dist/src/commands/check.js +61 -0
  9. package/dist/src/commands/convert.js +30 -0
  10. package/dist/src/commands/framework.js +76 -0
  11. package/dist/src/commands/index.js +64 -0
  12. package/dist/src/commands/init.js +66 -0
  13. package/dist/src/commands/instructions.js +40 -0
  14. package/dist/src/commands/list.js +73 -0
  15. package/dist/src/commands/new.js +68 -0
  16. package/dist/src/commands/seeds.js +40 -0
  17. package/dist/src/commands/show.js +24 -0
  18. package/dist/src/commands/whoami.js +84 -0
  19. package/dist/src/context.js +35 -0
  20. package/dist/src/core/blueprint.js +129 -0
  21. package/dist/src/core/check.js +407 -0
  22. package/dist/src/core/convert.js +37 -0
  23. package/dist/src/core/framework-markdown.js +233 -0
  24. package/dist/src/core/framework-model.js +48 -0
  25. package/dist/src/core/framework-structured.js +334 -0
  26. package/dist/src/core/framework.js +56 -0
  27. package/dist/src/core/frontmatter.js +116 -0
  28. package/dist/src/core/index-leaf.js +83 -0
  29. package/dist/src/core/markdown.js +100 -0
  30. package/dist/src/core/me.js +89 -0
  31. package/dist/src/core/naming.js +46 -0
  32. package/dist/src/core/root.js +88 -0
  33. package/dist/src/core/scaffold.js +118 -0
  34. package/dist/src/core/seed.js +223 -0
  35. package/dist/src/core/server.js +275 -0
  36. package/dist/src/core/shape.js +26 -0
  37. package/dist/src/output.js +38 -0
  38. package/dist/src/paths.js +28 -0
  39. package/dist/src/program.js +242 -0
  40. package/instructions/authoring.md +50 -0
  41. package/instructions/configuring.md +65 -0
  42. package/instructions/init-required.md +32 -0
  43. package/instructions/overview.md +37 -0
  44. package/instructions/validating.md +33 -0
  45. package/package.json +53 -0
  46. package/standard/EIDOS.md +324 -0
  47. package/standard/seeds/README.md +25 -0
  48. package/standard/seeds/book/Framework.md +87 -0
  49. package/standard/seeds/book/README.md +22 -0
  50. package/standard/seeds/book/_gitignore +4 -0
  51. package/standard/seeds/book/me.md +18 -0
  52. package/standard/seeds/book/roles/README.md +12 -0
  53. package/standard/seeds/book/roles/collaborator.md +16 -0
  54. package/standard/seeds/book/roles/editor.md +16 -0
  55. package/standard/seeds/book/roles/framework-owner.md +16 -0
  56. package/standard/seeds/book/roles/reader.md +16 -0
  57. package/standard/seeds/book/shapes/chapter.full.md +45 -0
  58. package/standard/seeds/book/shapes/chapter.sketch.md +26 -0
  59. package/standard/seeds/book/shapes/frame.market.md +23 -0
  60. package/standard/seeds/book/shapes/frame.premise.md +24 -0
  61. package/standard/seeds/book/shapes/frame.reader.md +23 -0
  62. package/standard/seeds/book/shapes/frame.voice.md +24 -0
  63. package/standard/seeds/research/Framework.md +88 -0
  64. package/standard/seeds/research/README.md +23 -0
  65. package/standard/seeds/research/_gitignore +4 -0
  66. package/standard/seeds/research/me.md +18 -0
  67. package/standard/seeds/research/roles/README.md +12 -0
  68. package/standard/seeds/research/roles/framework-owner.md +16 -0
  69. package/standard/seeds/research/roles/researcher.md +16 -0
  70. package/standard/seeds/research/roles/reviewer.md +16 -0
  71. package/standard/seeds/research/roles/sponsor.md +16 -0
  72. package/standard/seeds/research/shapes/frame.ethics.md +23 -0
  73. package/standard/seeds/research/shapes/frame.method.md +24 -0
  74. package/standard/seeds/research/shapes/frame.prior-work.md +23 -0
  75. package/standard/seeds/research/shapes/frame.question.md +24 -0
  76. package/standard/seeds/research/shapes/investigation.full.md +49 -0
  77. package/standard/seeds/research/shapes/investigation.note.md +26 -0
  78. package/standard/seeds/software/Framework.md +88 -0
  79. package/standard/seeds/software/README.md +24 -0
  80. package/standard/seeds/software/_gitignore +4 -0
  81. package/standard/seeds/software/me.md +18 -0
  82. package/standard/seeds/software/roles/.gitkeep +0 -0
  83. package/standard/seeds/software/roles/README.md +13 -0
  84. package/standard/seeds/software/roles/designer.md +16 -0
  85. package/standard/seeds/software/roles/developer.md +16 -0
  86. package/standard/seeds/software/roles/framework-owner.md +16 -0
  87. package/standard/seeds/software/roles/project-manager.md +18 -0
  88. package/standard/seeds/software/roles/stakeholder.md +16 -0
  89. package/standard/seeds/software/shapes/.gitkeep +0 -0
  90. package/standard/seeds/software/shapes/frame.architecture.md +26 -0
  91. package/standard/seeds/software/shapes/frame.audience.md +26 -0
  92. package/standard/seeds/software/shapes/frame.criteria.md +27 -0
  93. package/standard/seeds/software/shapes/frame.market.md +31 -0
  94. package/standard/seeds/software/shapes/spec.full.md +67 -0
  95. package/standard/seeds/software/shapes/spec.micro.md +32 -0
@@ -0,0 +1,84 @@
1
+ // `eidos roles` and `eidos whoami`: the cast a framework installed, and the
2
+ // actor in the seat.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { findRole, listRoles, meFile, readActor, renderActor, writeActor } from '../core/me.js';
6
+ import { CliError, EXIT_OK, print, printJson } from '../output.js';
7
+ export function runRoles(context, options) {
8
+ const roles = listRoles(context.root);
9
+ if (options.json) {
10
+ printJson(roles.map((role) => ({ name: role.name, title: role.title, path: path.relative(context.root, role.file).split(path.sep).join('/'), summary: role.summary })));
11
+ return EXIT_OK;
12
+ }
13
+ if (roles.length === 0) {
14
+ print('no roles installed under _eidos/roles/; an older framework, or one that declares none');
15
+ return EXIT_OK;
16
+ }
17
+ for (const role of roles) {
18
+ print(`${role.name} — ${role.title}`);
19
+ if (role.summary) {
20
+ print(` ${role.summary}`);
21
+ }
22
+ }
23
+ print('', 'set yours: eidos whoami --role <name> [--ownership "..."] [--experience "..."] [--capacity "..."]');
24
+ return EXIT_OK;
25
+ }
26
+ function actorJson(file, exists, actor) {
27
+ return { path: file, exists, role: actor.role, role_file: actor.roleFile, note: actor.note, ownership: actor.ownership, experience: actor.experience, capacity: actor.capacity };
28
+ }
29
+ export function runWhoami(context, options) {
30
+ const setting = options.role !== undefined || options.ownership !== undefined || options.experience !== undefined || options.capacity !== undefined;
31
+ if (options.clear) {
32
+ const file = writeActor(context.root, renderActor(null, null, { ownership: null, experience: null, capacity: null }));
33
+ print(`cleared ${path.relative(context.root, file)}; the agent defaults to full facilitation`);
34
+ return EXIT_OK;
35
+ }
36
+ if (setting) {
37
+ const current = readActor(context.root).actor;
38
+ let role = null;
39
+ let custom = null;
40
+ const roleName = options.role ?? current.role;
41
+ if (roleName !== null && roleName !== undefined) {
42
+ role = findRole(context.root, roleName);
43
+ if (!role) {
44
+ if (options.role !== undefined && listRoles(context.root).length > 0) {
45
+ const known = listRoles(context.root).map((candidate) => candidate.name).join(', ');
46
+ throw new CliError(`no role '${options.role}' under _eidos/roles/ (installed: ${known}); a custom role is set by editing _eidos/me.md`);
47
+ }
48
+ custom = roleName;
49
+ }
50
+ }
51
+ const text = renderActor(role, custom, {
52
+ ownership: options.ownership ?? current.ownership,
53
+ experience: options.experience ?? current.experience,
54
+ capacity: options.capacity ?? current.capacity,
55
+ });
56
+ const file = writeActor(context.root, text);
57
+ if (options.json) {
58
+ printJson(actorJson(path.relative(context.root, file), true, readActor(context.root).actor));
59
+ return EXIT_OK;
60
+ }
61
+ print(`✓ ${path.relative(context.root, file)}`, '', text.trimEnd());
62
+ return EXIT_OK;
63
+ }
64
+ const { exists, actor } = readActor(context.root);
65
+ if (options.json) {
66
+ printJson(actorJson(path.relative(context.root, meFile(context.root)), exists, actor));
67
+ return EXIT_OK;
68
+ }
69
+ if (!exists) {
70
+ print('no _eidos/me.md: the actor is unset, so the agent defaults to full facilitation', 'set it: eidos whoami --role <name>');
71
+ return EXIT_OK;
72
+ }
73
+ if (actor.role === null) {
74
+ print('the actor is blank (full facilitation); roles installed here:', '');
75
+ return runRoles(context, { json: false });
76
+ }
77
+ print(`you are: ${actor.role}`);
78
+ const roleFile = actor.roleFile ? path.resolve(context.root, '_eidos', actor.roleFile) : null;
79
+ if (roleFile && existsSync(roleFile)) {
80
+ print(`role file: ${path.relative(context.root, roleFile).split(path.sep).join('/')}`, '', readFileSync(roleFile, 'utf8').trimEnd(), '');
81
+ }
82
+ print('calibration:', ` ownership: ${actor.ownership ?? '(unset)'}`, ` experience with the scope: ${actor.experience ?? '(unset)'}`, ` technical capacity: ${actor.capacity ?? '(unset)'}`);
83
+ return EXIT_OK;
84
+ }
@@ -0,0 +1,35 @@
1
+ // What every root-bound command needs: the root, its framework, and its
2
+ // blueprints, resolved once from the working directory or --root.
3
+ import { listBlueprints } from './core/blueprint.js';
4
+ import { FrameworkError, loadFramework } from './core/framework.js';
5
+ import { findRoot, RootError } from './core/root.js';
6
+ import { CliError, EXIT_USAGE } from './output.js';
7
+ // The CLI works on Framework.yaml. A root that keeps Framework.md is read only
8
+ // to convert it (or to print it), so every other command points at `convert`.
9
+ export function loadRoot(globals, cwd = process.cwd(), options = {}) {
10
+ let root;
11
+ try {
12
+ root = findRoot(cwd, globals.root);
13
+ }
14
+ catch (cause) {
15
+ if (cause instanceof RootError) {
16
+ const hint = cause.candidates.length === 0 ? '\n\nFor the way in, run: eidos instructions init-required' : '';
17
+ throw new CliError(cause.message + hint, EXIT_USAGE);
18
+ }
19
+ throw cause;
20
+ }
21
+ let framework;
22
+ try {
23
+ framework = loadFramework(root);
24
+ }
25
+ catch (cause) {
26
+ if (cause instanceof FrameworkError) {
27
+ throw new CliError(cause.message, EXIT_USAGE);
28
+ }
29
+ throw cause;
30
+ }
31
+ if (framework.format === 'md' && !options.allowMarkdown) {
32
+ throw new CliError(`${root} keeps its framework as _eidos/Framework.md. The eidos CLI works on Framework.yaml: run \`eidos convert\` to move it (Framework.md and each collection's index.md are removed; the markdown form's prose has no field to land in). \`eidos convert --dry-run\` shows the writes, and \`eidos framework --as yaml\` prints the result without touching anything.`, EXIT_USAGE);
33
+ }
34
+ return { cwd, root, framework, blueprints: listBlueprints(framework) };
35
+ }
@@ -0,0 +1,129 @@
1
+ // Blueprints on disk: one markdown file per unit, directly in a collection
2
+ // folder or one level of sub-folders beneath it. Loading reads the frontmatter
3
+ // and the body's sections; nothing here judges the file (that is `check`).
4
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parseFrontmatter } from './frontmatter.js';
7
+ import { defaultFlavor, findFlavor } from './framework.js';
8
+ import { headings, sections } from './markdown.js';
9
+ import { kebab } from './naming.js';
10
+ export function blueprintFiles(dir) {
11
+ let entries;
12
+ try {
13
+ entries = readdirSync(dir);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ return entries
19
+ .filter((entry) => /\.md$/i.test(entry) && entry.toLowerCase() !== 'index.md' && !entry.startsWith('.'))
20
+ .filter((entry) => {
21
+ try {
22
+ return statSync(path.join(dir, entry)).isFile();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ })
28
+ .sort();
29
+ }
30
+ export function groupDirs(dir) {
31
+ let entries;
32
+ try {
33
+ entries = readdirSync(dir);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ return entries
39
+ .filter((entry) => !entry.startsWith('.'))
40
+ .filter((entry) => {
41
+ try {
42
+ return statSync(path.join(dir, entry)).isDirectory();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ })
48
+ .sort();
49
+ }
50
+ export function loadBlueprint(root, collection, group, file) {
51
+ const full = group === null ? path.join(root, collection.name, file) : path.join(root, collection.name, group, file);
52
+ const text = readFileSync(full, 'utf8');
53
+ const { properties, body, error } = parseFrontmatter(text);
54
+ return {
55
+ path: full,
56
+ rel: path.relative(root, full).split(path.sep).join('/'),
57
+ collection,
58
+ group,
59
+ file,
60
+ stem: file.replace(/\.md$/i, ''),
61
+ properties,
62
+ frontmatterError: error,
63
+ body,
64
+ headings: headings(body),
65
+ sections: sections(body, 2),
66
+ };
67
+ }
68
+ export function listBlueprints(framework, collections = framework.collections) {
69
+ const out = [];
70
+ for (const collection of collections) {
71
+ const dir = path.join(framework.root, collection.name);
72
+ for (const file of blueprintFiles(dir)) {
73
+ out.push(loadBlueprint(framework.root, collection, null, file));
74
+ }
75
+ for (const group of groupDirs(dir)) {
76
+ for (const file of blueprintFiles(path.join(dir, group))) {
77
+ out.push(loadBlueprint(framework.root, collection, group, file));
78
+ }
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ export function propertyString(blueprint, key) {
84
+ const value = blueprint.properties?.[key];
85
+ if (value === undefined || value === null) {
86
+ return null;
87
+ }
88
+ if (typeof value === 'string') {
89
+ return value.trim() === '' ? null : value;
90
+ }
91
+ if (typeof value === 'number' || typeof value === 'boolean') {
92
+ return String(value);
93
+ }
94
+ return null;
95
+ }
96
+ export function blueprintId(blueprint) {
97
+ return propertyString(blueprint, 'id');
98
+ }
99
+ export function blueprintTitle(blueprint) {
100
+ return propertyString(blueprint, 'title') ?? blueprint.stem;
101
+ }
102
+ // The flavor a blueprint follows: the one it names, else its collection's default.
103
+ export function resolveFlavor(blueprint) {
104
+ const declared = propertyString(blueprint, 'flavor');
105
+ if (declared === null) {
106
+ return { flavor: defaultFlavor(blueprint.collection), declared: null, known: true };
107
+ }
108
+ const flavor = findFlavor(blueprint.collection, declared);
109
+ return { flavor, declared, known: flavor !== null };
110
+ }
111
+ // A blueprint by path, by id, or by filename stem, in that order.
112
+ export function resolveBlueprint(ref, blueprints, cwd) {
113
+ const asPath = path.resolve(cwd, ref);
114
+ const byPath = blueprints.find((blueprint) => path.resolve(blueprint.path) === asPath);
115
+ if (byPath) {
116
+ return byPath;
117
+ }
118
+ const wanted = ref.trim().toLowerCase();
119
+ const byId = blueprints.find((blueprint) => blueprintId(blueprint)?.toLowerCase() === wanted);
120
+ if (byId) {
121
+ return byId;
122
+ }
123
+ const byKebab = blueprints.find((blueprint) => blueprintId(blueprint) === kebab(ref));
124
+ if (byKebab) {
125
+ return byKebab;
126
+ }
127
+ const byStem = blueprints.filter((blueprint) => blueprint.stem.toLowerCase() === wanted || kebab(blueprint.stem) === kebab(ref));
128
+ return byStem.length === 1 ? (byStem[0] ?? null) : null;
129
+ }
@@ -0,0 +1,407 @@
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
3
+ // on them. Errors are things that are wrong on any reading (a malformed file,
4
+ // an id used twice, a link to nowhere); warnings are gaps the standard says to
5
+ // note and offer, never refuse.
6
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { blueprintId, blueprintTitle, propertyString, resolveFlavor } from './blueprint.js';
9
+ import { appliesTo } from './framework.js';
10
+ import { renderEmbeddedEntries } from './index-leaf.js';
11
+ import { embeddedIndexOf } from './framework-structured.js';
12
+ import { normalizeNewlines } from './markdown.js';
13
+ import { isKebab, kebab } from './naming.js';
14
+ import { loadShape } from './shape.js';
15
+ const WORK_TRACKING_FIELDS = new Set(['sprint', 'estimate', 'assignee', 'story_points']);
16
+ const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build']);
17
+ function compareVersions(a, b) {
18
+ const left = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
19
+ const right = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
20
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
21
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
22
+ if (diff !== 0) {
23
+ return diff < 0 ? -1 : 1;
24
+ }
25
+ }
26
+ return 0;
27
+ }
28
+ function isEmpty(value) {
29
+ return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
30
+ }
31
+ function typeProblem(property, value) {
32
+ if (isEmpty(value)) {
33
+ return null;
34
+ }
35
+ switch (property.type.toLowerCase()) {
36
+ case 'text':
37
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? null : 'expected a single text value';
38
+ case 'list':
39
+ return Array.isArray(value) ? null : 'expected a list (`[]` or `- item` lines)';
40
+ case 'number':
41
+ return typeof value === 'number' ? null : 'expected a number';
42
+ case 'checkbox':
43
+ return typeof value === 'boolean' ? null : 'expected true or false';
44
+ case 'date':
45
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? null : 'expected a YYYY-MM-DD date';
46
+ case 'date & time':
47
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value) ? null : 'expected a YYYY-MM-DD HH:MM date and time';
48
+ default:
49
+ return null;
50
+ }
51
+ }
52
+ // Every markdown link target in a piece of text, outside code fences and
53
+ // inline code. Scheme-bearing targets and pure anchors are not files.
54
+ function linkTargets(text) {
55
+ const out = [];
56
+ const stripped = normalizeNewlines(text)
57
+ .replace(/```[\s\S]*?```/g, '')
58
+ .replace(/`[^`\n]*`/g, '');
59
+ const pattern = /\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
60
+ let match = pattern.exec(stripped);
61
+ while (match !== null) {
62
+ if (match[1] !== undefined) {
63
+ out.push(match[1]);
64
+ }
65
+ match = pattern.exec(stripped);
66
+ }
67
+ return out;
68
+ }
69
+ function isFileTarget(target) {
70
+ return !/^[a-z][a-z0-9+.-]*:/i.test(target) && !target.startsWith('#') && !target.startsWith('//');
71
+ }
72
+ function linkExists(fromDir, target) {
73
+ const withoutAnchor = target.split('#')[0] ?? '';
74
+ if (withoutAnchor === '') {
75
+ return true;
76
+ }
77
+ let decoded = withoutAnchor;
78
+ try {
79
+ decoded = decodeURIComponent(withoutAnchor);
80
+ }
81
+ catch {
82
+ // an undecodable target is checked as written
83
+ }
84
+ return existsSync(path.resolve(fromDir, decoded));
85
+ }
86
+ function propertyLinks(value) {
87
+ const values = Array.isArray(value) ? value : [value];
88
+ const out = [];
89
+ for (const item of values) {
90
+ if (typeof item !== 'string') {
91
+ continue;
92
+ }
93
+ const match = /^\s*\[[^\]]*\]\(\s*<?([^)\s>]+)>?\s*\)\s*$/.exec(item);
94
+ if (match && match[1] !== undefined) {
95
+ out.push(match[1]);
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ class Collector {
101
+ findings = [];
102
+ error(code, file, message) {
103
+ this.findings.push({ level: 'error', code, path: file, message });
104
+ }
105
+ warn(code, file, message) {
106
+ this.findings.push({ level: 'warning', code, path: file, message });
107
+ }
108
+ }
109
+ function rel(root, file) {
110
+ return path.relative(root, file).split(path.sep).join('/');
111
+ }
112
+ function checkFramework(framework, out, options) {
113
+ const frameworkRel = rel(framework.root, framework.file);
114
+ for (const problem of framework.problems) {
115
+ out.error('framework-invalid', frameworkRel, problem);
116
+ }
117
+ if (framework.namingError) {
118
+ out.error('naming-invalid', frameworkRel, framework.namingError);
119
+ }
120
+ if (framework.eidosVersion === null) {
121
+ out.warn('version-missing', frameworkRel, `no eidos_version; this CLI carries the standard at ${options.standardVersion}`);
122
+ }
123
+ else {
124
+ const diff = compareVersions(framework.eidosVersion, options.standardVersion);
125
+ if (diff < 0) {
126
+ out.warn('version-behind', frameworkRel, `targets Eidos ${framework.eidosVersion}; this CLI carries ${options.standardVersion} (migrate when ready)`);
127
+ }
128
+ else if (diff > 0) {
129
+ out.warn('version-ahead', frameworkRel, `targets Eidos ${framework.eidosVersion}, newer than the ${options.standardVersion} this CLI carries`);
130
+ }
131
+ }
132
+ 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
+ }
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');
137
+ }
138
+ for (const doc of framework.topLevel) {
139
+ if (isFileTarget(doc.path) && !linkExists(path.dirname(framework.file), doc.path)) {
140
+ out.warn('top-level-missing', frameworkRel, `## Top-Level lists ${doc.title} at ${doc.path}, which does not exist`);
141
+ }
142
+ }
143
+ const shapes = new Map();
144
+ 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`);
153
+ }
154
+ }
155
+ if (!existsSync(path.join(framework.root, collection.name))) {
156
+ out.warn('collection-folder-missing', null, `collection ${collection.name} is declared but has no folder`);
157
+ }
158
+ }
159
+ return shapes;
160
+ }
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'));
164
+ let entries = [];
165
+ try {
166
+ entries = readdirSync(framework.root);
167
+ }
168
+ catch {
169
+ entries = [];
170
+ }
171
+ for (const entry of entries.sort()) {
172
+ if (entry.startsWith('.') || entry === '_eidos' || SKIPPED_DIRS.has(entry) || declared.has(entry)) {
173
+ continue;
174
+ }
175
+ let isDir = false;
176
+ try {
177
+ isDir = statSync(path.join(framework.root, entry)).isDirectory();
178
+ }
179
+ 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');
184
+ }
185
+ }
186
+ for (const collection of framework.collections) {
187
+ const folder = path.join(framework.root, collection.name);
188
+ if (!existsSync(folder)) {
189
+ continue;
190
+ }
191
+ const own = blueprints.filter((blueprint) => blueprint.collection === collection);
192
+ 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
+ }
199
+ }
200
+ for (const group of collection.grouping.groups) {
201
+ if (!existsSync(path.join(folder, group.name))) {
202
+ out.warn('group-folder-missing', `${collection.name}/${group.name}`, `declared under ${collection.grouping.label} but has no folder`);
203
+ }
204
+ }
205
+ }
206
+ if (collection.grouping && groupsOnDisk.length > 0) {
207
+ for (const blueprint of own.filter((candidate) => candidate.group === null)) {
208
+ out.warn('blueprint-ungrouped', blueprint.rel, `sits at the top of ${collection.name}, which groups by ${collection.grouping.label}`);
209
+ }
210
+ }
211
+ if (collection.framing) {
212
+ for (const flavor of collection.flavors) {
213
+ const written = own.some((blueprint) => resolveFlavor(blueprint).flavor === flavor);
214
+ if (!written) {
215
+ out.warn('frame-unwritten', collection.name, `no ${flavor.name} frame yet; a gap to surface, not a failure`);
216
+ }
217
+ }
218
+ }
219
+ const wanted = JSON.stringify(renderEmbeddedEntries(collection, blueprints));
220
+ const held = embedded?.[collection.name];
221
+ if (JSON.stringify(held ?? null) !== wanted) {
222
+ out.warn('index-stale', rel(framework.root, framework.file), `index.${collection.name} is ${held === undefined ? 'missing' : 'out of date'}; run \`eidos index\``);
223
+ }
224
+ }
225
+ }
226
+ function checkBlueprint(framework, blueprint, shapes, out) {
227
+ const file = blueprint.rel;
228
+ if (blueprint.properties === null) {
229
+ if (blueprint.frontmatterError) {
230
+ out.error('frontmatter-invalid', file, blueprint.frontmatterError);
231
+ }
232
+ else {
233
+ out.error('frontmatter-missing', file, 'no frontmatter block; every blueprint opens with the properties its collection declares');
234
+ }
235
+ return;
236
+ }
237
+ if (blueprint.frontmatterError) {
238
+ out.warn('frontmatter-yaml', file, blueprint.frontmatterError);
239
+ }
240
+ const properties = blueprint.properties;
241
+ const collection = blueprint.collection;
242
+ const id = propertyString(blueprint, 'id');
243
+ 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`);
251
+ }
252
+ if (propertyString(blueprint, 'title') === null) {
253
+ out.error('title-missing', file, 'no `title`');
254
+ }
255
+ if (propertyString(blueprint, 'summary') === null) {
256
+ out.warn('summary-missing', file, 'no `summary`; the collection index will flag it');
257
+ }
258
+ const { flavor, declared, known } = resolveFlavor(blueprint);
259
+ 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})`);
262
+ }
263
+ const core = new Set(framework.schema.core.map((property) => property.name));
264
+ const applicable = framework.schema.custom.filter((property) => appliesTo(property, collection.name));
265
+ const applicableNames = new Set(applicable.map((property) => property.name));
266
+ for (const property of applicable) {
267
+ if (!(property.name in properties)) {
268
+ out.warn('property-missing', file, `no \`${property.name}\` (${property.type}, applies to ${collection.name})`);
269
+ }
270
+ else if (isEmpty(properties[property.name]) && property.type.toLowerCase() !== 'list') {
271
+ out.warn('property-empty', file, `\`${property.name}\` has no value`);
272
+ }
273
+ }
274
+ for (const key of Object.keys(properties)) {
275
+ if (core.has(key) || applicableNames.has(key)) {
276
+ continue;
277
+ }
278
+ const elsewhere = framework.schema.custom.find((property) => property.name === key);
279
+ if (elsewhere) {
280
+ const scope = elsewhere.appliesTo === 'all' ? 'all' : elsewhere.appliesTo.join(', ');
281
+ out.warn('property-scope', file, `\`${key}\` applies to ${scope}, not ${collection.name}`);
282
+ }
283
+ else if (WORK_TRACKING_FIELDS.has(key.toLowerCase())) {
284
+ 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
+ }
286
+ else {
287
+ out.warn('property-unknown', file, `\`${key}\` is not in the Schema; declare it under ### Custom Properties or drop it`);
288
+ }
289
+ }
290
+ for (const property of [...framework.schema.core, ...applicable]) {
291
+ if (!(property.name in properties)) {
292
+ continue;
293
+ }
294
+ const problem = typeProblem(property, properties[property.name]);
295
+ if (problem) {
296
+ out.warn('property-type', file, `\`${property.name}\` (${property.type}): ${problem}`);
297
+ }
298
+ }
299
+ if (collection.grouping?.property) {
300
+ const value = propertyString(blueprint, collection.grouping.property);
301
+ if (value !== null && blueprint.group === null) {
302
+ out.warn('group-mismatch', file, `\`${collection.grouping.property}: ${value}\` but the blueprint is not in a sub-folder`);
303
+ }
304
+ else if (value !== null && blueprint.group !== null && value !== blueprint.group) {
305
+ out.warn('group-mismatch', file, `\`${collection.grouping.property}: ${value}\` does not match its folder ${blueprint.group}/`);
306
+ }
307
+ }
308
+ const dir = path.dirname(blueprint.path);
309
+ for (const [key, value] of Object.entries(properties)) {
310
+ for (const target of propertyLinks(value)) {
311
+ if (isFileTarget(target) && !linkExists(dir, target)) {
312
+ out.error('link-broken', file, `\`${key}\` links to ${target}, which does not exist`);
313
+ }
314
+ }
315
+ }
316
+ const brokenInBody = new Map();
317
+ for (const target of linkTargets(blueprint.body)) {
318
+ if (isFileTarget(target) && !linkExists(dir, target)) {
319
+ brokenInBody.set(target, (brokenInBody.get(target) ?? 0) + 1);
320
+ }
321
+ }
322
+ for (const [target, count] of brokenInBody) {
323
+ out.error('link-broken', file, `the body links to ${target}, which does not exist${count > 1 ? ` (${count} times)` : ''}`);
324
+ }
325
+ const title = blueprintTitle(blueprint);
326
+ const h1 = blueprint.headings.find((heading) => heading.level === 1);
327
+ if (h1 && h1.text !== title && kebab(h1.text) !== kebab(title)) {
328
+ out.warn('title-mismatch', file, `the heading '${h1.text}' differs from title '${title}'`);
329
+ }
330
+ const shape = flavor ? (shapes.get(`${collection.name}/${flavor.name}`) ?? loadShape(framework, flavor)) : null;
331
+ if (!shape || !flavor) {
332
+ return;
333
+ }
334
+ const present = blueprint.sections.map((section) => section.heading.text);
335
+ const presentLower = present.map((text) => text.toLowerCase());
336
+ const shapeLower = shape.sections.map((text) => text.toLowerCase());
337
+ for (const name of shape.sections) {
338
+ 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)`);
340
+ }
341
+ }
342
+ if (!collection.framing) {
343
+ 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`);
346
+ }
347
+ }
348
+ }
349
+ const order = presentLower.filter((name) => shapeLower.includes(name)).map((name) => shapeLower.indexOf(name));
350
+ for (let index = 1; index < order.length; index += 1) {
351
+ 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(' → ')})`);
353
+ break;
354
+ }
355
+ }
356
+ }
357
+ export function checkRoot(framework, blueprints, options) {
358
+ const out = new Collector();
359
+ const shapes = checkFramework(framework, out, options);
360
+ if (!options.only) {
361
+ checkLayout(framework, blueprints, out);
362
+ }
363
+ const seen = new Map();
364
+ for (const blueprint of blueprints) {
365
+ const id = blueprintId(blueprint);
366
+ if (id === null) {
367
+ continue;
368
+ }
369
+ const first = seen.get(id);
370
+ if (!first) {
371
+ seen.set(id, blueprint);
372
+ continue;
373
+ }
374
+ const inScope = (candidate) => !options.only || options.only.has(candidate.path);
375
+ if (inScope(blueprint)) {
376
+ out.error('id-duplicate', blueprint.rel, `id '${id}' is also used by ${first.rel}`);
377
+ }
378
+ else if (inScope(first)) {
379
+ out.error('id-duplicate', first.rel, `id '${id}' is also used by ${blueprint.rel}`);
380
+ }
381
+ }
382
+ const targets = options.only ? blueprints.filter((blueprint) => options.only?.has(blueprint.path)) : blueprints;
383
+ for (const blueprint of targets) {
384
+ checkBlueprint(framework, blueprint, shapes, out);
385
+ }
386
+ const findings = out.findings.sort((a, b) => {
387
+ const left = a.path ?? '';
388
+ const right = b.path ?? '';
389
+ if (left !== right) {
390
+ return left < right ? -1 : 1;
391
+ }
392
+ if (a.level !== b.level) {
393
+ return a.level === 'error' ? -1 : 1;
394
+ }
395
+ return 0;
396
+ });
397
+ return {
398
+ root: framework.root,
399
+ eidosVersion: framework.eidosVersion,
400
+ naming: framework.naming,
401
+ blueprints: targets.length,
402
+ collections: framework.collections.length,
403
+ errors: findings.filter((finding) => finding.level === 'error').length,
404
+ warnings: findings.filter((finding) => finding.level === 'warning').length,
405
+ findings,
406
+ };
407
+ }