@x12i/made-easy 0.0.0-stage → 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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/dist/analysis.d.ts +19 -0
  4. package/dist/analysis.d.ts.map +1 -0
  5. package/dist/analysis.js +90 -0
  6. package/dist/analysis.js.map +1 -0
  7. package/dist/build.d.ts +36 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +260 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/contracts/api.d.ts +447 -0
  12. package/dist/contracts/api.d.ts.map +1 -0
  13. package/dist/contracts/api.js +152 -0
  14. package/dist/contracts/api.js.map +1 -0
  15. package/dist/contracts/blocks.d.ts +221 -0
  16. package/dist/contracts/blocks.d.ts.map +1 -0
  17. package/dist/contracts/blocks.js +392 -0
  18. package/dist/contracts/blocks.js.map +1 -0
  19. package/dist/contracts/diagnostics.d.ts +111 -0
  20. package/dist/contracts/diagnostics.d.ts.map +1 -0
  21. package/dist/contracts/diagnostics.js +122 -0
  22. package/dist/contracts/diagnostics.js.map +1 -0
  23. package/dist/contracts/index.d.ts +11 -0
  24. package/dist/contracts/index.d.ts.map +1 -0
  25. package/dist/contracts/index.js +11 -0
  26. package/dist/contracts/index.js.map +1 -0
  27. package/dist/contracts/json-schema.d.ts +5 -0
  28. package/dist/contracts/json-schema.d.ts.map +1 -0
  29. package/dist/contracts/json-schema.js +41 -0
  30. package/dist/contracts/json-schema.js.map +1 -0
  31. package/dist/contracts/recipe.d.ts +535 -0
  32. package/dist/contracts/recipe.d.ts.map +1 -0
  33. package/dist/contracts/recipe.js +380 -0
  34. package/dist/contracts/recipe.js.map +1 -0
  35. package/dist/contracts/schemas.d.ts +413 -0
  36. package/dist/contracts/schemas.d.ts.map +1 -0
  37. package/dist/contracts/schemas.js +205 -0
  38. package/dist/contracts/schemas.js.map +1 -0
  39. package/dist/draft.d.ts +157 -0
  40. package/dist/draft.d.ts.map +1 -0
  41. package/dist/draft.js +254 -0
  42. package/dist/draft.js.map +1 -0
  43. package/dist/emit-assets.d.ts +2 -0
  44. package/dist/emit-assets.d.ts.map +1 -0
  45. package/dist/emit-assets.js +27 -0
  46. package/dist/emit-assets.js.map +1 -0
  47. package/dist/index.d.ts +25 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +30 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/markdown-doc.d.ts +59 -0
  52. package/dist/markdown-doc.d.ts.map +1 -0
  53. package/dist/markdown-doc.js +187 -0
  54. package/dist/markdown-doc.js.map +1 -0
  55. package/dist/model.d.ts +54 -0
  56. package/dist/model.d.ts.map +1 -0
  57. package/dist/model.js +244 -0
  58. package/dist/model.js.map +1 -0
  59. package/dist/normalize.d.ts +63 -0
  60. package/dist/normalize.d.ts.map +1 -0
  61. package/dist/normalize.js +54 -0
  62. package/dist/normalize.js.map +1 -0
  63. package/dist/npm.d.ts +24 -0
  64. package/dist/npm.d.ts.map +1 -0
  65. package/dist/npm.js +51 -0
  66. package/dist/npm.js.map +1 -0
  67. package/dist/render/blocks.d.ts +17 -0
  68. package/dist/render/blocks.d.ts.map +1 -0
  69. package/dist/render/blocks.js +384 -0
  70. package/dist/render/blocks.js.map +1 -0
  71. package/dist/render/brief.d.ts +77 -0
  72. package/dist/render/brief.d.ts.map +1 -0
  73. package/dist/render/brief.js +62 -0
  74. package/dist/render/brief.js.map +1 -0
  75. package/dist/render/context.d.ts +43 -0
  76. package/dist/render/context.d.ts.map +1 -0
  77. package/dist/render/context.js +88 -0
  78. package/dist/render/context.js.map +1 -0
  79. package/dist/render/html.d.ts +19 -0
  80. package/dist/render/html.d.ts.map +1 -0
  81. package/dist/render/html.js +178 -0
  82. package/dist/render/html.js.map +1 -0
  83. package/dist/render/markdown-out.d.ts +11 -0
  84. package/dist/render/markdown-out.d.ts.map +1 -0
  85. package/dist/render/markdown-out.js +121 -0
  86. package/dist/render/markdown-out.js.map +1 -0
  87. package/dist/render/pdf.d.ts +23 -0
  88. package/dist/render/pdf.d.ts.map +1 -0
  89. package/dist/render/pdf.js +138 -0
  90. package/dist/render/pdf.js.map +1 -0
  91. package/dist/render/prose.d.ts +15 -0
  92. package/dist/render/prose.d.ts.map +1 -0
  93. package/dist/render/prose.js +110 -0
  94. package/dist/render/prose.js.map +1 -0
  95. package/dist/scaffold.d.ts +13 -0
  96. package/dist/scaffold.d.ts.map +1 -0
  97. package/dist/scaffold.js +393 -0
  98. package/dist/scaffold.js.map +1 -0
  99. package/dist/sdk.d.ts +56 -0
  100. package/dist/sdk.d.ts.map +1 -0
  101. package/dist/sdk.js +238 -0
  102. package/dist/sdk.js.map +1 -0
  103. package/dist/source.d.ts +57 -0
  104. package/dist/source.d.ts.map +1 -0
  105. package/dist/source.js +289 -0
  106. package/dist/source.js.map +1 -0
  107. package/dist/theme/book.d.ts +10 -0
  108. package/dist/theme/book.d.ts.map +1 -0
  109. package/dist/theme/book.js +198 -0
  110. package/dist/theme/book.js.map +1 -0
  111. package/dist/util.d.ts +14 -0
  112. package/dist/util.d.ts.map +1 -0
  113. package/dist/util.js +69 -0
  114. package/dist/util.js.map +1 -0
  115. package/dist/validate.d.ts +36 -0
  116. package/dist/validate.d.ts.map +1 -0
  117. package/dist/validate.js +986 -0
  118. package/dist/validate.js.map +1 -0
  119. package/dist/verify.d.ts +35 -0
  120. package/dist/verify.d.ts.map +1 -0
  121. package/dist/verify.js +112 -0
  122. package/dist/verify.js.map +1 -0
  123. package/dist/version.d.ts +3 -0
  124. package/dist/version.d.ts.map +1 -0
  125. package/dist/version.js +4 -0
  126. package/dist/version.js.map +1 -0
  127. package/dist/yamlfile.d.ts +22 -0
  128. package/dist/yamlfile.d.ts.map +1 -0
  129. package/dist/yamlfile.js +129 -0
  130. package/dist/yamlfile.js.map +1 -0
  131. package/package.json +64 -4
  132. package/recipes/index.json +282 -0
  133. package/recipes/shared-1.0.0.json +456 -0
  134. package/schemas/artifact.v1.json +36 -0
  135. package/schemas/assets.v1.json +61 -0
  136. package/schemas/block.v1.json +27 -0
  137. package/schemas/blocks/brief.v1.json +20 -0
  138. package/schemas/blocks/callout.v1.json +25 -0
  139. package/schemas/blocks/code.v1.json +21 -0
  140. package/schemas/blocks/decision.v1.json +80 -0
  141. package/schemas/blocks/evidence-comparison.v1.json +33 -0
  142. package/schemas/blocks/example.v1.json +52 -0
  143. package/schemas/blocks/figure.v1.json +22 -0
  144. package/schemas/blocks/human-action.v1.json +42 -0
  145. package/schemas/blocks/job-map.v1.json +170 -0
  146. package/schemas/blocks/pause.v1.json +25 -0
  147. package/schemas/blocks/reference-profile.v1.json +41 -0
  148. package/schemas/blocks/review-checklist.v1.json +19 -0
  149. package/schemas/blocks/table.v1.json +59 -0
  150. package/schemas/brief.v1.json +157 -0
  151. package/schemas/build-request.v1.json +173 -0
  152. package/schemas/build-result.v1.json +171 -0
  153. package/schemas/checks.v1.json +149 -0
  154. package/schemas/create-request.v1.json +47 -0
  155. package/schemas/diagnostic.v1.json +50 -0
  156. package/schemas/draft-request.v1.json +196 -0
  157. package/schemas/guide.v1.json +172 -0
  158. package/schemas/job.v1.json +107 -0
  159. package/schemas/preview-request.v1.json +153 -0
  160. package/schemas/preview-result.v1.json +84 -0
  161. package/schemas/problem.v1.json +78 -0
  162. package/schemas/recipe-definition.v1.json +673 -0
  163. package/schemas/source-ref.v1.json +111 -0
  164. package/schemas/sources.v1.json +158 -0
  165. package/schemas/validate-request.v1.json +135 -0
  166. package/schemas/validate-result.v1.json +110 -0
  167. package/schemas/verification.v1.json +77 -0
  168. package/themes/book/1.0.0/theme.css +190 -0
  169. package/themes/book/1.0.0/theme.json +5 -0
@@ -0,0 +1,986 @@
1
+ import { BUNDLE_FILES, HUMAN_ROLES, ruleSeverity, summarizeDiagnostics, sortDiagnostics, dedupeDiagnostics, } from './contracts/index.js';
2
+ import { computeUsage, parseRef } from './analysis.js';
3
+ import { PLACEHOLDER_PATTERN, extractInlineRefs } from './markdown-doc.js';
4
+ import { allBlocks, blocksOf, entriesOf, fileDigest } from './model.js';
5
+ import { isPlainObject, sha256Digest, canonicalJson } from './util.js';
6
+ import { pointerFromPath } from './yamlfile.js';
7
+ import { resolveRelative } from './source.js';
8
+ export const SUPPORTED_THEMES = ['book@1.0.0'];
9
+ /* --------------------------------- helpers -------------------------------- */
10
+ export function isFilled(value) {
11
+ if (value === undefined || value === null)
12
+ return false;
13
+ if (typeof value === 'string')
14
+ return value.trim() !== '';
15
+ if (Array.isArray(value))
16
+ return value.length > 0;
17
+ if (isPlainObject(value))
18
+ return Object.keys(value).length > 0;
19
+ return true;
20
+ }
21
+ /** Return pointers (relative to `root`) of locations that fail the dotted field path. */
22
+ export function failingPointers(root, path, base = '') {
23
+ const walk = (node, segs, ptr) => {
24
+ if (!segs.length)
25
+ return isFilled(node) ? [] : [ptr];
26
+ const [seg, ...rest] = segs;
27
+ const isList = seg.endsWith('[]');
28
+ const key = isList ? seg.slice(0, -2) : seg;
29
+ const child = isPlainObject(node) ? node[key] : undefined;
30
+ const childPtr = `${ptr}/${key}`;
31
+ if (isList) {
32
+ if (!Array.isArray(child) || child.length === 0)
33
+ return [childPtr];
34
+ return child.flatMap((el, i) => walk(el, rest, `${childPtr}/${i}`));
35
+ }
36
+ return walk(child, rest, childPtr);
37
+ };
38
+ return walk(root, path.split('.'), base);
39
+ }
40
+ const SECRET_PATTERNS = [
41
+ ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
42
+ ['AWS access key ID', /\bAKIA[0-9A-Z]{16}\b/],
43
+ ['GitHub token', /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b/],
44
+ ['npm token', /\bnpm_[A-Za-z0-9]{30,}\b/],
45
+ ['API key', /\bsk-[A-Za-z0-9_-]{24,}\b/],
46
+ ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
47
+ ['npm auth line', /_authToken\s*=\s*(?!\$\{)[^\s]{8,}/],
48
+ ['bearer token', /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/],
49
+ ];
50
+ const TEXT_FILE = /\.(md|ya?ml|json|txt|csv|js|mjs|cjs|ts|sh|ps1|toml|env|ini|cfg|html?|css)$/i;
51
+ const MEDIA_BY_EXT = {
52
+ png: ['image/png'],
53
+ jpg: ['image/jpeg'],
54
+ jpeg: ['image/jpeg'],
55
+ gif: ['image/gif'],
56
+ webp: ['image/webp'],
57
+ svg: ['image/svg+xml'],
58
+ pdf: ['application/pdf'],
59
+ csv: ['text/csv'],
60
+ json: ['application/json'],
61
+ txt: ['text/plain'],
62
+ md: ['text/markdown'],
63
+ };
64
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
65
+ /** Digest of an executable example: its definition plus every fixture file. */
66
+ export function exampleDigest(model, props) {
67
+ const fixture = typeof props.fixture === 'string' ? props.fixture.replace(/\/$/, '') : '';
68
+ const fixtureFiles = fixture
69
+ ? model.files
70
+ .paths()
71
+ .filter((p) => p.startsWith(`${fixture}/`))
72
+ .map((p) => ({ path: p, digest: fileDigest(p, model.files.bytes(p)) }))
73
+ : [];
74
+ return sha256Digest(canonicalJson({
75
+ setup: props.setup ?? [],
76
+ run: props.run ?? [],
77
+ expected: props.expected ?? '',
78
+ runtime: props.runtime ?? '',
79
+ files: fixtureFiles,
80
+ }));
81
+ }
82
+ export function exampleBlocks(model) {
83
+ return allBlocks(model).filter(({ block }) => block.type === 'example' && block.props.executable !== false);
84
+ }
85
+ /* --------------------------------- validate ------------------------------- */
86
+ export function validateModel(model, options) {
87
+ const { mode } = options;
88
+ const out = [...model.diagnostics];
89
+ const recipe = model.recipe;
90
+ const severityOf = (rule) => {
91
+ if (!recipe)
92
+ return mode === 'release' ? 'error' : 'warning';
93
+ let s = ruleSeverity(recipe, mode, rule);
94
+ // An explicit audience decides privacy findings; with none given the mode's default applies.
95
+ if (rule === 'privateReferences' && options.audience === 'public')
96
+ s = 'error';
97
+ else if (rule === 'privateReferences' && options.audience === 'private')
98
+ s = 'info';
99
+ return s;
100
+ };
101
+ const emit = (rule, d) => {
102
+ const s = severityOf(rule);
103
+ if (s !== 'off')
104
+ out.push({ ...d, severity: s });
105
+ };
106
+ const err = (d) => out.push({ ...d, severity: 'error' });
107
+ const lineIn = (file, pointer) => model.lines[file]?.(pointer);
108
+ const guide = model.guide;
109
+ /* ------------------------------ pins & profile ----------------------------- */
110
+ if (guide) {
111
+ if (!SUPPORTED_THEMES.includes(`${guide.theme.id}@${guide.theme.version}`)) {
112
+ err({
113
+ code: 'THEME_UNSUPPORTED',
114
+ path: BUNDLE_FILES.guide,
115
+ line: lineIn(BUNDLE_FILES.guide, '/theme'),
116
+ pointer: '/theme',
117
+ message: `Theme ${guide.theme.id}@${guide.theme.version} is not available. Available: ${SUPPORTED_THEMES.join(', ')}.`,
118
+ });
119
+ }
120
+ if (recipe && recipe.profile !== guide.subject.type) {
121
+ err({
122
+ code: 'SUBJECT_RECIPE_MISMATCH',
123
+ path: BUNDLE_FILES.guide,
124
+ line: lineIn(BUNDLE_FILES.guide, '/subject/type'),
125
+ pointer: '/subject/type',
126
+ message: `Subject type "${guide.subject.type}" does not match recipe ${recipe.id} (profile "${recipe.profile}").`,
127
+ });
128
+ }
129
+ }
130
+ /* --------------------------------- roles ---------------------------------- */
131
+ if (guide && recipe) {
132
+ const human = entriesOf(model, 'human');
133
+ let lastIndex = -1;
134
+ for (const def of recipe.roleDetails) {
135
+ const matches = human.filter((e) => e.role === def.role);
136
+ if (!matches.length) {
137
+ emit('missingBlocks', {
138
+ code: 'ROLE_MISSING',
139
+ path: BUNDLE_FILES.guide,
140
+ line: lineIn(BUNDLE_FILES.guide, '/tracks/human'),
141
+ pointer: '/tracks/human',
142
+ message: `The human track needs a "${def.role}" entry (${def.title}).`,
143
+ });
144
+ continue;
145
+ }
146
+ if (matches.length > 1) {
147
+ err({
148
+ code: 'ROLE_DUPLICATE',
149
+ path: BUNDLE_FILES.guide,
150
+ line: lineIn(BUNDLE_FILES.guide, matches[1].pointer),
151
+ pointer: matches[1].pointer,
152
+ message: `The "${def.role}" role appears ${matches.length} times; each required role appears once.`,
153
+ });
154
+ }
155
+ const idx = human.indexOf(matches[0]);
156
+ if (idx < lastIndex) {
157
+ err({
158
+ code: 'ROLE_ORDER',
159
+ path: BUNDLE_FILES.guide,
160
+ line: lineIn(BUNDLE_FILES.guide, matches[0].pointer),
161
+ pointer: matches[0].pointer,
162
+ message: `Role "${def.role}" is out of order. Required order: ${HUMAN_ROLES.join(' → ')}.`,
163
+ });
164
+ }
165
+ lastIndex = Math.max(lastIndex, idx);
166
+ }
167
+ const lastRequired = Math.max(-1, ...human.map((e, i) => (e.role === 'appendix' ? -1 : i)));
168
+ human.forEach((e, i) => {
169
+ if (e.role === 'appendix' && i < lastRequired) {
170
+ err({
171
+ code: 'ROLE_ORDER',
172
+ path: BUNDLE_FILES.guide,
173
+ line: lineIn(BUNDLE_FILES.guide, e.pointer),
174
+ pointer: e.pointer,
175
+ message: 'Appendix entries come after the six required roles.',
176
+ });
177
+ }
178
+ });
179
+ const operator = entriesOf(model, 'operator');
180
+ for (const role of recipe.profileDefinition.requiredOperatorRoles) {
181
+ if (!operator.some((e) => e.role === role)) {
182
+ emit('missingBlocks', {
183
+ code: 'ROLE_MISSING',
184
+ path: BUNDLE_FILES.guide,
185
+ line: lineIn(BUNDLE_FILES.guide, '/tracks/operator'),
186
+ pointer: '/tracks/operator',
187
+ message: `The operator track needs a "${role}" entry for the ${recipe.profile} profile.`,
188
+ });
189
+ }
190
+ }
191
+ }
192
+ /* ----------------------------- recipe requirements -------------------------- */
193
+ if (recipe) {
194
+ for (const def of recipe.roleDetails) {
195
+ const entry = entriesOf(model, 'human').find((e) => e.role === def.role);
196
+ if (!entry)
197
+ continue;
198
+ const blocks = blocksOf(entry);
199
+ for (const req of def.requirements)
200
+ checkRequirement(req, def.role, entry, blocks, emit);
201
+ if (entry.words < def.minWords) {
202
+ emit('shortContent', {
203
+ code: 'CONTENT_TOO_SHORT',
204
+ path: entry.path,
205
+ line: 1,
206
+ message: `The "${def.role}" section has ${entry.words} words of prose; the ${recipe.preset} preset needs at least ${def.minWords}.`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ /* --------------------------------- data files ------------------------------ */
212
+ const brief = model.brief;
213
+ const checks = model.checks;
214
+ const sources = model.sources;
215
+ const checkIds = new Set((checks?.checks ?? []).map((c) => c.id));
216
+ const resultIds = new Set((checks?.results ?? []).map((r) => r.id));
217
+ const sourceIds = new Set((sources?.sources ?? []).map((s) => s.id));
218
+ const claimIds = new Set((sources?.claims ?? []).map((c) => c.id));
219
+ const assetIds = new Set((model.assets?.assets ?? []).map((a) => a.id));
220
+ const pauseIds = new Set((brief?.pauseRules ?? []).map((p) => p.id));
221
+ const inputIds = new Set((brief?.inputs ?? []).map((i) => i.id));
222
+ const entryById = new Map(model.entries.map((e) => [e.id, e]));
223
+ const blockById = new Map(allBlocks(model).map((b) => [b.block.id, b]));
224
+ const dupes = (file, base, ids, what) => {
225
+ const seen = new Set();
226
+ ids.forEach((id, i) => {
227
+ if (!id)
228
+ return;
229
+ if (seen.has(id)) {
230
+ err({
231
+ code: 'ID_DUPLICATE',
232
+ path: file,
233
+ line: lineIn(file, `${base}/${i}/id`),
234
+ pointer: `${base}/${i}/id`,
235
+ message: `Duplicate ${what} id "${id}".`,
236
+ });
237
+ }
238
+ seen.add(id);
239
+ });
240
+ };
241
+ dupes(BUNDLE_FILES.brief, '/inputs', (brief?.inputs ?? []).map((x) => x.id), 'input');
242
+ dupes(BUNDLE_FILES.brief, '/pauseRules', (brief?.pauseRules ?? []).map((x) => x.id), 'pause rule');
243
+ dupes(BUNDLE_FILES.brief, '/reportBack', (brief?.reportBack ?? []).map((x) => x.id), 'report-back field');
244
+ dupes(BUNDLE_FILES.checks, '/checks', (checks?.checks ?? []).map((x) => x.id), 'check');
245
+ dupes(BUNDLE_FILES.checks, '/results', (checks?.results ?? []).map((x) => x.id), 'result');
246
+ dupes(BUNDLE_FILES.sources, '/sources', (sources?.sources ?? []).map((x) => x.id), 'source');
247
+ dupes(BUNDLE_FILES.sources, '/claims', (sources?.claims ?? []).map((x) => x.id), 'claim');
248
+ dupes(BUNDLE_FILES.assets, '/assets', (model.assets?.assets ?? []).map((x) => x.id), 'asset');
249
+ const unresolved = (file, pointer, what, id) => err({
250
+ code: 'REF_UNRESOLVED',
251
+ path: file,
252
+ line: lineIn(file, pointer),
253
+ pointer,
254
+ message: `${what} "${id}" does not exist.`,
255
+ });
256
+ // brief references
257
+ (brief?.acceptanceChecks ?? []).forEach((id, i) => {
258
+ if (!checkIds.has(id))
259
+ unresolved(BUNDLE_FILES.brief, `/acceptanceChecks/${i}`, 'Acceptance check', id);
260
+ });
261
+ (brief?.operatorReference?.entries ?? []).forEach((pin, i) => {
262
+ const entry = entryById.get(pin.id);
263
+ const base = `/operatorReference/entries/${i}`;
264
+ if (!entry || entry.track !== 'operator') {
265
+ emit('stalePins', {
266
+ code: 'PIN_UNRESOLVED',
267
+ path: BUNDLE_FILES.brief,
268
+ line: lineIn(BUNDLE_FILES.brief, base),
269
+ pointer: base,
270
+ message: `Pinned operator-reference entry "${pin.id}" does not exist.`,
271
+ });
272
+ return;
273
+ }
274
+ if (!pin.digest) {
275
+ emit('stalePins', {
276
+ code: 'PIN_MISSING',
277
+ path: BUNDLE_FILES.brief,
278
+ line: lineIn(BUNDLE_FILES.brief, base),
279
+ pointer: base,
280
+ message: `Entry "${pin.id}" is listed but has no digest. Run "made-easy pin" to record ${entry.digest}.`,
281
+ });
282
+ }
283
+ else if (pin.digest !== entry.digest) {
284
+ emit('stalePins', {
285
+ code: 'PIN_STALE',
286
+ path: BUNDLE_FILES.brief,
287
+ line: lineIn(BUNDLE_FILES.brief, `${base}/digest`),
288
+ pointer: `${base}/digest`,
289
+ message: `Operator-reference entry "${pin.id}" (${entry.path}) changed since it was pinned. Review it, then run "made-easy pin".`,
290
+ hint: `pinned ${pin.digest}; current ${entry.digest}`,
291
+ });
292
+ }
293
+ });
294
+ // results and evidence
295
+ const refExists = (ref) => {
296
+ const p = parseRef(ref);
297
+ if (!p)
298
+ return false;
299
+ switch (p.kind) {
300
+ case 'check': return checkIds.has(p.id);
301
+ case 'claim': return claimIds.has(p.id);
302
+ case 'source': return sourceIds.has(p.id);
303
+ case 'result': return resultIds.has(p.id);
304
+ case 'pause': return pauseIds.has(p.id);
305
+ case 'asset': return assetIds.has(p.id);
306
+ case 'input': return inputIds.has(p.id);
307
+ case 'block': return blockById.has(p.id);
308
+ case 'entry': {
309
+ const e = entryById.get(p.id);
310
+ return Boolean(e) && (!p.anchor || e.headings.some((h) => h.anchor === p.anchor));
311
+ }
312
+ }
313
+ };
314
+ (checks?.results ?? []).forEach((r, ri) => {
315
+ (r.checks ?? []).forEach((rc, ci) => {
316
+ if (!checkIds.has(rc.check))
317
+ unresolved(BUNDLE_FILES.checks, `/results/${ri}/checks/${ci}/check`, 'Check', rc.check);
318
+ (rc.evidence ?? []).forEach((ref, ei) => {
319
+ if (!refExists(ref))
320
+ unresolved(BUNDLE_FILES.checks, `/results/${ri}/checks/${ci}/evidence/${ei}`, 'Evidence reference', ref);
321
+ });
322
+ });
323
+ // same checks, consistent verdict
324
+ const accept = brief?.acceptanceChecks ?? [];
325
+ const recorded = (r.checks ?? []).map((c) => c.check);
326
+ if (brief && (accept.some((c) => !recorded.includes(c)) || recorded.some((c) => !accept.includes(c)))) {
327
+ err({
328
+ code: 'RESULT_CHECK_MISMATCH',
329
+ path: BUNDLE_FILES.checks,
330
+ line: lineIn(BUNDLE_FILES.checks, `/results/${ri}/checks`),
331
+ pointer: `/results/${ri}/checks`,
332
+ message: `Result "${r.id}" must be recorded against exactly the acceptance checks in brief.yaml (${accept.join(', ') || 'none'}).`,
333
+ });
334
+ }
335
+ const outcomes = (r.checks ?? []).map((c) => c.outcome);
336
+ const bad = outcomes.some((o) => o === 'fail' || o === 'contradictory');
337
+ const missing = outcomes.some((o) => o === 'missing');
338
+ const expected = bad ? 'invalid' : missing ? 'partial' : 'usable';
339
+ if (outcomes.length && r.verdict !== expected) {
340
+ err({
341
+ code: 'RESULT_VERDICT_INCONSISTENT',
342
+ path: BUNDLE_FILES.checks,
343
+ line: lineIn(BUNDLE_FILES.checks, `/results/${ri}/verdict`),
344
+ pointer: `/results/${ri}/verdict`,
345
+ message: `Result "${r.id}" is marked ${r.verdict} but its check outcomes imply ${expected} (fail or contradictory → invalid; missing → partial; all pass → usable).`,
346
+ });
347
+ }
348
+ if (r.kind === 'usable' && r.verdict !== 'usable') {
349
+ err({
350
+ code: 'RESULT_VERDICT_INCONSISTENT',
351
+ path: BUNDLE_FILES.checks,
352
+ line: lineIn(BUNDLE_FILES.checks, `/results/${ri}/kind`),
353
+ pointer: `/results/${ri}/kind`,
354
+ message: `Result "${r.id}" is a usable example but its verdict is ${r.verdict}.`,
355
+ });
356
+ }
357
+ if (r.kind === 'flawed' && r.verdict === 'usable') {
358
+ err({
359
+ code: 'RESULT_VERDICT_INCONSISTENT',
360
+ path: BUNDLE_FILES.checks,
361
+ line: lineIn(BUNDLE_FILES.checks, `/results/${ri}/kind`),
362
+ pointer: `/results/${ri}/kind`,
363
+ message: `Result "${r.id}" is a flawed example but its verdict is usable.`,
364
+ });
365
+ }
366
+ });
367
+ (sources?.claims ?? []).forEach((c, i) => {
368
+ (c.evidence ?? []).forEach((e, ei) => {
369
+ if (!sourceIds.has(e.source))
370
+ unresolved(BUNDLE_FILES.sources, `/claims/${i}/evidence/${ei}/source`, 'Source', e.source);
371
+ });
372
+ if (c.status === 'unresolved') {
373
+ emit('unresolvedClaims', {
374
+ code: 'CLAIM_UNRESOLVED',
375
+ path: BUNDLE_FILES.sources,
376
+ line: lineIn(BUNDLE_FILES.sources, `/claims/${i}`),
377
+ pointer: `/claims/${i}`,
378
+ message: `Claim "${c.id}" is recorded as unresolved: ${c.text}`,
379
+ });
380
+ }
381
+ else if (!(c.evidence ?? []).length) {
382
+ emit('unresolvedClaims', {
383
+ code: 'CLAIM_NO_EVIDENCE',
384
+ path: BUNDLE_FILES.sources,
385
+ line: lineIn(BUNDLE_FILES.sources, `/claims/${i}`),
386
+ pointer: `/claims/${i}`,
387
+ message: `Claim "${c.id}" is marked supported but cites no evidence.`,
388
+ });
389
+ }
390
+ });
391
+ (sources?.checkEvidence ?? []).forEach((ce, i) => {
392
+ if (!checkIds.has(ce.check))
393
+ unresolved(BUNDLE_FILES.sources, `/checkEvidence/${i}/check`, 'Check', ce.check);
394
+ (ce.evidence ?? []).forEach((e, ei) => {
395
+ if (!sourceIds.has(e.source))
396
+ unresolved(BUNDLE_FILES.sources, `/checkEvidence/${i}/evidence/${ei}/source`, 'Source', e.source);
397
+ });
398
+ });
399
+ for (const id of brief?.acceptanceChecks ?? []) {
400
+ const mapped = (sources?.checkEvidence ?? []).find((c) => c.check === id && (c.evidence ?? []).length);
401
+ if (!mapped && checkIds.has(id)) {
402
+ emit('incompleteData', {
403
+ code: 'CHECK_NOT_COVERED',
404
+ path: BUNDLE_FILES.sources,
405
+ line: lineIn(BUNDLE_FILES.sources, '/checkEvidence') ?? 1,
406
+ pointer: '/checkEvidence',
407
+ message: `Acceptance check "${id}" is not mapped to evidence in sources.yaml (checkEvidence).`,
408
+ });
409
+ }
410
+ }
411
+ (sources?.sources ?? []).forEach((s, i) => {
412
+ if (s.synthetic) {
413
+ out.push({
414
+ code: 'SOURCE_SYNTHETIC',
415
+ severity: 'info',
416
+ path: BUNDLE_FILES.sources,
417
+ line: lineIn(BUNDLE_FILES.sources, `/sources/${i}`),
418
+ pointer: `/sources/${i}`,
419
+ message: `Source "${s.id}" is synthetic evidence and is labelled as such in every output.`,
420
+ });
421
+ }
422
+ });
423
+ /* ---------------------------- brief / check completeness ------------------- */
424
+ if (recipe && brief) {
425
+ for (const path of recipe.data.brief) {
426
+ for (const ptr of failingPointers(brief, path)) {
427
+ emit('incompleteData', {
428
+ code: 'FIELD_REQUIRED_MISSING',
429
+ path: BUNDLE_FILES.brief,
430
+ line: lineIn(BUNDLE_FILES.brief, ptr),
431
+ pointer: ptr,
432
+ message: `brief.yaml requires "${path}".`,
433
+ });
434
+ }
435
+ }
436
+ (brief.inputs ?? []).forEach((input, i) => {
437
+ for (const f of recipe.data.input) {
438
+ for (const ptr of failingPointers(input, f, `/inputs/${i}`)) {
439
+ emit('incompleteData', {
440
+ code: 'FIELD_REQUIRED_MISSING',
441
+ path: BUNDLE_FILES.brief,
442
+ line: lineIn(BUNDLE_FILES.brief, ptr),
443
+ pointer: ptr,
444
+ message: `Input "${input.id}" needs "${f}".`,
445
+ });
446
+ }
447
+ }
448
+ });
449
+ (brief.pauseRules ?? []).forEach((rule, i) => {
450
+ for (const f of recipe.data.pauseRule) {
451
+ for (const ptr of failingPointers(rule, f, `/pauseRules/${i}`)) {
452
+ emit('incompleteData', {
453
+ code: 'FIELD_REQUIRED_MISSING',
454
+ path: BUNDLE_FILES.brief,
455
+ line: lineIn(BUNDLE_FILES.brief, ptr),
456
+ pointer: ptr,
457
+ message: `Pause rule "${rule.id}" needs "${f}" (trigger, decision owner, work that may continue, resume condition).`,
458
+ });
459
+ }
460
+ }
461
+ });
462
+ (brief.reportBack ?? []).forEach((rb, i) => {
463
+ for (const f of recipe.data.reportBackField) {
464
+ for (const ptr of failingPointers(rb, f, `/reportBack/${i}`)) {
465
+ emit('incompleteData', {
466
+ code: 'FIELD_REQUIRED_MISSING',
467
+ path: BUNDLE_FILES.brief,
468
+ line: lineIn(BUNDLE_FILES.brief, ptr),
469
+ pointer: ptr,
470
+ message: `Report-back field "${rb.id}" needs "${f}".`,
471
+ });
472
+ }
473
+ }
474
+ });
475
+ }
476
+ if (recipe && checks) {
477
+ if (!(checks.checks ?? []).length) {
478
+ emit('incompleteData', {
479
+ code: 'FIELD_REQUIRED_MISSING',
480
+ path: BUNDLE_FILES.checks,
481
+ line: 1,
482
+ message: 'checks.yaml needs at least one acceptance check.',
483
+ });
484
+ }
485
+ (checks.checks ?? []).forEach((c, i) => {
486
+ for (const f of recipe.data.check) {
487
+ for (const ptr of failingPointers(c, f, `/checks/${i}`)) {
488
+ emit('incompleteData', {
489
+ code: 'FIELD_REQUIRED_MISSING',
490
+ path: BUNDLE_FILES.checks,
491
+ line: lineIn(BUNDLE_FILES.checks, ptr),
492
+ pointer: ptr,
493
+ message: `Check "${c.id}" needs "${f}".`,
494
+ });
495
+ }
496
+ }
497
+ });
498
+ for (const kind of ['usable', 'flawed']) {
499
+ if (!(checks.results ?? []).some((r) => r.kind === kind)) {
500
+ emit('incompleteData', {
501
+ code: 'FIELD_REQUIRED_MISSING',
502
+ path: BUNDLE_FILES.checks,
503
+ line: lineIn(BUNDLE_FILES.checks, '/results') ?? 1,
504
+ pointer: '/results',
505
+ message: `checks.yaml needs a worked ${kind} result recorded against the acceptance checks.`,
506
+ });
507
+ }
508
+ }
509
+ (checks.results ?? []).forEach((r, i) => {
510
+ for (const f of recipe.data.resultPresent) {
511
+ if (r[f] === undefined) {
512
+ emit('incompleteData', {
513
+ code: 'FIELD_REQUIRED_MISSING',
514
+ path: BUNDLE_FILES.checks,
515
+ line: lineIn(BUNDLE_FILES.checks, `/results/${i}`),
516
+ pointer: `/results/${i}`,
517
+ message: `Result "${r.id}" must state "${f}" (completed actions, observations, gaps and next steps are kept distinct).`,
518
+ });
519
+ }
520
+ }
521
+ });
522
+ }
523
+ /* ----------------------------------- blocks -------------------------------- */
524
+ for (const { entry, block } of allBlocks(model)) {
525
+ const p = block.props;
526
+ const at = (pointer) => block.lineOf(`/props${pointer}`) ?? block.line;
527
+ const bad = (pointer, what, id) => err({
528
+ code: 'REF_UNRESOLVED',
529
+ path: entry.path,
530
+ line: at(pointer),
531
+ blockId: block.id,
532
+ pointer: `/props${pointer}`,
533
+ message: `${what} "${id}" does not exist.`,
534
+ });
535
+ const checkRefList = (key, set, what) => {
536
+ const list = p[key];
537
+ if (Array.isArray(list))
538
+ list.forEach((id, i) => typeof id === 'string' && !set.has(id) && bad(`/${key}/${i}`, what, id));
539
+ };
540
+ switch (block.type) {
541
+ case 'decision': {
542
+ const evidenceRefs = (list, base) => {
543
+ if (Array.isArray(list))
544
+ list.forEach((r, i) => typeof r === 'string' && !refExists(r) && bad(`${base}/${i}`, 'Evidence reference', r));
545
+ };
546
+ evidenceRefs(p.evidence, '/evidence');
547
+ if (Array.isArray(p.options))
548
+ p.options.forEach((o, i) => evidenceRefs(o?.evidence, `/options/${i}/evidence`));
549
+ break;
550
+ }
551
+ case 'pause':
552
+ if (typeof p.rule === 'string' && !pauseIds.has(p.rule))
553
+ bad('/rule', 'Pause rule', p.rule);
554
+ break;
555
+ case 'human-action':
556
+ if (typeof p.pause === 'string' && !pauseIds.has(p.pause))
557
+ bad('/pause', 'Pause rule', p.pause);
558
+ break;
559
+ case 'evidence-comparison':
560
+ if (typeof p.usable === 'string' && !resultIds.has(p.usable))
561
+ bad('/usable', 'Result', p.usable);
562
+ if (typeof p.flawed === 'string' && !resultIds.has(p.flawed))
563
+ bad('/flawed', 'Result', p.flawed);
564
+ checkRefList('decisiveChecks', checkIds, 'Check');
565
+ break;
566
+ case 'review-checklist':
567
+ checkRefList('checks', checkIds, 'Check');
568
+ break;
569
+ case 'figure':
570
+ if (typeof p.asset === 'string' && !assetIds.has(p.asset))
571
+ bad('/asset', 'Asset', p.asset);
572
+ break;
573
+ case 'job-map': {
574
+ const stageIds = new Set((p.stages ?? []).map((s) => s.id));
575
+ (p.failurePoints ?? []).forEach((f, i) => {
576
+ if (f.stage && !stageIds.has(f.stage))
577
+ bad(`/failurePoints/${i}/stage`, 'Stage', f.stage);
578
+ });
579
+ break;
580
+ }
581
+ default:
582
+ break;
583
+ }
584
+ }
585
+ // pause coverage in the ownership section
586
+ const ownership = entriesOf(model, 'human').find((e) => e.role === 'ownership');
587
+ if (ownership) {
588
+ const covered = new Set(blocksOf(ownership)
589
+ .filter((b) => b.type === 'pause')
590
+ .map((b) => b.props.rule));
591
+ (brief?.pauseRules ?? []).forEach((r, i) => {
592
+ if (!covered.has(r.id)) {
593
+ emit('missingBlocks', {
594
+ code: 'PAUSE_NOT_COVERED',
595
+ path: BUNDLE_FILES.brief,
596
+ line: lineIn(BUNDLE_FILES.brief, `/pauseRules/${i}`),
597
+ pointer: `/pauseRules/${i}`,
598
+ message: `Pause rule "${r.id}" is not shown in the ownership section (${ownership.path}).`,
599
+ });
600
+ }
601
+ });
602
+ }
603
+ /* --------------------------------- inline refs ----------------------------- */
604
+ for (const entry of model.entries) {
605
+ for (const node of entry.nodes) {
606
+ if (node.kind !== 'markdown')
607
+ continue;
608
+ for (const r of extractInlineRefs(node)) {
609
+ const ref = `${r.kind}:${r.id}${r.anchor ? `#${r.anchor}` : ''}`;
610
+ if (!refExists(ref)) {
611
+ err({
612
+ code: 'REF_UNRESOLVED',
613
+ path: entry.path,
614
+ line: r.line,
615
+ message: `Reference ${r.raw} does not resolve${r.kind === 'entry' && r.anchor ? ' (check the entry id and heading anchor)' : ''}.`,
616
+ });
617
+ }
618
+ }
619
+ }
620
+ }
621
+ /* ---------------------------- profile & examples --------------------------- */
622
+ if (recipe)
623
+ validateProfile(model, recipe, emit, err);
624
+ validateExamples(model, emit, err);
625
+ /* ----------------------------------- assets -------------------------------- */
626
+ (model.assets?.assets ?? []).forEach((a, i) => {
627
+ const base = `/assets/${i}`;
628
+ const line = lineIn(BUNDLE_FILES.assets, base);
629
+ if (!a.path.startsWith('assets/')) {
630
+ err({ code: 'BUNDLE_PATH_UNSAFE', path: BUNDLE_FILES.assets, line, pointer: `${base}/path`, message: `Asset path "${a.path}" must be under assets/.` });
631
+ return;
632
+ }
633
+ const bytes = model.files.bytes(a.path);
634
+ if (!bytes) {
635
+ err({ code: 'ASSET_FILE_MISSING', path: BUNDLE_FILES.assets, line, pointer: `${base}/path`, message: `Asset file ${a.path} does not exist.` });
636
+ }
637
+ else {
638
+ const digest = sha256Digest(bytes);
639
+ if (digest !== a.digest) {
640
+ err({
641
+ code: 'ASSET_DIGEST_MISMATCH',
642
+ path: BUNDLE_FILES.assets,
643
+ line: lineIn(BUNDLE_FILES.assets, `${base}/digest`),
644
+ pointer: `${base}/digest`,
645
+ message: `Asset "${a.id}" does not match its recorded digest.`,
646
+ hint: `recorded ${a.digest}; actual ${digest}`,
647
+ });
648
+ }
649
+ }
650
+ const ext = a.path.split('.').pop()?.toLowerCase() ?? '';
651
+ const allowed = MEDIA_BY_EXT[ext];
652
+ if (allowed && !allowed.includes(a.mediaType)) {
653
+ err({
654
+ code: 'ASSET_MEDIA_TYPE_MISMATCH',
655
+ path: BUNDLE_FILES.assets,
656
+ line: lineIn(BUNDLE_FILES.assets, `${base}/mediaType`),
657
+ pointer: `${base}/mediaType`,
658
+ message: `Asset "${a.id}" is .${ext} but declares ${a.mediaType}; expected ${allowed.join(' or ')}.`,
659
+ });
660
+ }
661
+ if (a.decorative && a.alt) {
662
+ err({ code: 'ASSET_ALT_MISSING', path: BUNDLE_FILES.assets, line, pointer: base, message: `Asset "${a.id}" is both decorative and has alternative text; choose one.` });
663
+ }
664
+ else if (!a.decorative && !(a.alt ?? '').trim()) {
665
+ emit('missingFields', {
666
+ code: 'ASSET_ALT_MISSING',
667
+ path: BUNDLE_FILES.assets,
668
+ line,
669
+ pointer: base,
670
+ message: `Asset "${a.id}" needs alternative text or decorative: true.`,
671
+ });
672
+ }
673
+ });
674
+ /* ------------------------------- publication ------------------------------- */
675
+ const usage = computeUsage(model);
676
+ const sourceVisibility = new Map((sources?.sources ?? []).map((s) => [s.id, s.visibility ?? 'private']));
677
+ (sources?.sources ?? []).forEach((s, i) => {
678
+ if (usage.sources.has(s.id) && (s.visibility ?? 'private') !== 'public') {
679
+ emit('privateReferences', {
680
+ code: 'PUBLICATION_PRIVATE_REFERENCE',
681
+ path: BUNDLE_FILES.sources,
682
+ line: lineIn(BUNDLE_FILES.sources, `/sources/${i}`),
683
+ pointer: `/sources/${i}`,
684
+ message: `Source "${s.id}" is cited by published content but is private. Set visibility: public once it may be published.`,
685
+ });
686
+ }
687
+ });
688
+ (model.assets?.assets ?? []).forEach((a, i) => {
689
+ if (usage.assets.has(a.id) && (a.visibility ?? 'private') !== 'public') {
690
+ emit('privateReferences', {
691
+ code: 'PUBLICATION_PRIVATE_REFERENCE',
692
+ path: BUNDLE_FILES.assets,
693
+ line: lineIn(BUNDLE_FILES.assets, `/assets/${i}`),
694
+ pointer: `/assets/${i}`,
695
+ message: `Asset "${a.id}" is used by published content but is private. Set visibility: public once it may be published.`,
696
+ });
697
+ }
698
+ });
699
+ void sourceVisibility;
700
+ /* ------------------------- anchors, placeholders, secrets ------------------- */
701
+ for (const entry of model.entries) {
702
+ for (const h of entry.headings) {
703
+ if (!h.explicit) {
704
+ emit('unstableAnchors', {
705
+ code: 'ANCHOR_UNSTABLE',
706
+ path: entry.path,
707
+ line: h.line,
708
+ message: `Heading "${h.text}" has no explicit anchor; editing it changes its link. Add {#${h.anchor}} (made-easy anchors --fix).`,
709
+ });
710
+ }
711
+ }
712
+ }
713
+ for (const path of model.files.paths()) {
714
+ const isEntry = model.entries.some((e) => e.path === path);
715
+ const isYaml = /\.ya?ml$/i.test(path) && !path.startsWith('operator/fixtures/');
716
+ const text = TEXT_FILE.test(path) ? model.files.text(path) : undefined;
717
+ if (text === undefined)
718
+ continue;
719
+ if (isEntry || isYaml) {
720
+ for (const hit of scanPlaceholders(text, isEntry)) {
721
+ emit('placeholders', {
722
+ code: 'PLACEHOLDER_PRESENT',
723
+ path,
724
+ line: hit.line,
725
+ message: `Placeholder remains: ${hit.text}`,
726
+ });
727
+ }
728
+ }
729
+ const lines = text.split(/\r?\n/);
730
+ lines.forEach((l, i) => {
731
+ for (const [name, re] of SECRET_PATTERNS) {
732
+ if (re.test(l)) {
733
+ err({
734
+ code: 'SECRET_DETECTED',
735
+ path,
736
+ line: i + 1,
737
+ message: `This line looks like a ${name}. Keep secrets and job-specific credentials out of the bundle; use an input placeholder instead.`,
738
+ });
739
+ }
740
+ }
741
+ });
742
+ }
743
+ const diagnostics = sortDiagnostics(dedupeDiagnostics(out));
744
+ const summary = summarizeDiagnostics(diagnostics);
745
+ return {
746
+ ok: summary.errors === 0,
747
+ mode,
748
+ guideId: guide?.guideId,
749
+ revision: model.revision,
750
+ referenceRevision: model.referenceRevision,
751
+ summary,
752
+ diagnostics,
753
+ };
754
+ }
755
+ /* ------------------------------ requirement check ------------------------- */
756
+ function checkRequirement(req, role, entry, blocks, emit) {
757
+ const matching = blocks.filter((b) => b.type === req.type &&
758
+ Object.entries(req.where ?? {}).every(([k, v]) => b.props[k] === v));
759
+ const missingOf = (b) => (req.fields ?? []).flatMap((f) => failingPointers(b.props, f, '').map((ptr) => ({ field: f, pointer: ptr })));
760
+ const scored = matching.map((b) => ({ b, missing: missingOf(b) })).sort((a, b) => a.missing.length - b.missing.length);
761
+ const satisfied = scored.filter((s) => s.missing.length === 0).length;
762
+ if (satisfied >= req.min)
763
+ return;
764
+ const label = req.label ?? req.type;
765
+ const whereText = req.where ? ` (${Object.entries(req.where).map(([k, v]) => `${k}: ${v}`).join(', ')})` : '';
766
+ const shortfall = req.min - satisfied;
767
+ const partial = scored.filter((s) => s.missing.length > 0).slice(0, shortfall);
768
+ for (const { b, missing } of partial) {
769
+ for (const m of missing) {
770
+ emit('missingFields', {
771
+ code: 'FIELD_REQUIRED_MISSING',
772
+ path: entry.path,
773
+ line: b.lineOf(`/props${m.pointer}`) ?? b.line,
774
+ blockId: b.id,
775
+ pointer: `/props${m.pointer}`,
776
+ message: `The "${role}" section's ${label}${whereText} needs "${m.field}".`,
777
+ });
778
+ }
779
+ }
780
+ const absent = shortfall - partial.length;
781
+ if (absent > 0) {
782
+ emit('missingBlocks', {
783
+ code: 'BLOCK_REQUIRED_MISSING',
784
+ path: entry.path,
785
+ line: 1,
786
+ message: `The "${role}" section needs ${absent > 1 ? `${absent} more` : 'a'} ${req.type} block${whereText}${req.fields?.length ? ` with ${req.fields.join(', ')}` : ''}.`,
787
+ });
788
+ }
789
+ }
790
+ /** Scan for `[[TODO: ...]]` in prose and YAML values; fenced code is ignored in Markdown. */
791
+ export function scanPlaceholders(text, markdown) {
792
+ const out = [];
793
+ const lines = text.split(/\r?\n/);
794
+ let fence;
795
+ lines.forEach((l, i) => {
796
+ if (markdown) {
797
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/.exec(l);
798
+ if (m) {
799
+ const marks = m[1];
800
+ if (!fence) {
801
+ fence = { char: marks[0], length: marks.length, structured: /^made-easy(\s|$)/.test(m[2] ?? '') };
802
+ return;
803
+ }
804
+ if (marks[0] === fence.char && marks.length >= fence.length && (m[2] ?? '').trim() === '') {
805
+ fence = undefined;
806
+ return;
807
+ }
808
+ }
809
+ // Ordinary code fences are literal; structured made-easy blocks are scanned.
810
+ if (fence && !fence.structured)
811
+ return;
812
+ }
813
+ const masked = markdown && !fence ? l.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (x) => ' '.repeat(x.length)) : l;
814
+ for (const m of masked.matchAll(new RegExp(PLACEHOLDER_PATTERN.source, 'g')))
815
+ out.push({ line: i + 1, text: m[0] });
816
+ });
817
+ return out;
818
+ }
819
+ /* ------------------------------- profile checks --------------------------- */
820
+ function validateProfile(model, recipe, emit, err) {
821
+ void err;
822
+ const profile = recipe.profileDefinition;
823
+ const scope = entriesOf(model, 'operator').find((e) => e.role === 'scope');
824
+ if (!scope)
825
+ return;
826
+ const block = blocksOf(scope).find((b) => b.type === 'reference-profile');
827
+ if (!block) {
828
+ emit('profileFields', {
829
+ code: 'PROFILE_FIELD_MISSING',
830
+ path: scope.path,
831
+ line: 1,
832
+ message: `The scope entry needs a reference-profile block for the ${recipe.profile} profile (${profile.referenceFields.filter((f) => !f.optional).map((f) => f.key).join(', ')}).`,
833
+ });
834
+ return;
835
+ }
836
+ const props = block.props;
837
+ const fields = props.fields ?? {};
838
+ if (props.profile && props.profile !== recipe.profile) {
839
+ emit('profileFields', {
840
+ code: 'SUBJECT_RECIPE_MISMATCH',
841
+ path: scope.path,
842
+ line: block.lineOf('/props/profile') ?? block.line,
843
+ blockId: block.id,
844
+ message: `reference-profile declares "${props.profile}" but the recipe profile is "${recipe.profile}".`,
845
+ });
846
+ }
847
+ const need = [...profile.referenceFields.filter((f) => !f.optional)];
848
+ const distribution = String(fields.distribution ?? '').toLowerCase();
849
+ if (recipe.profile === 'sdk' && /\bnpm\b/.test(distribution))
850
+ need.push(...(profile.npmFields ?? []));
851
+ for (const f of need) {
852
+ if (!isFilled(fields[f.key])) {
853
+ emit('profileFields', {
854
+ code: 'PROFILE_FIELD_MISSING',
855
+ path: scope.path,
856
+ line: block.lineOf(`/props/fields/${f.key}`) ?? block.line,
857
+ blockId: block.id,
858
+ pointer: `/props/fields/${f.key}`,
859
+ message: `The ${recipe.profile} profile requires "${f.key}" (${f.label}).`,
860
+ });
861
+ }
862
+ }
863
+ if (profile.requiresExactVersion && isFilled(fields.version) && !EXACT_VERSION.test(String(fields.version))) {
864
+ emit('sdkExactness', {
865
+ code: 'SDK_VERSION_NOT_EXACT',
866
+ path: scope.path,
867
+ line: block.lineOf('/props/fields/version') ?? block.line,
868
+ blockId: block.id,
869
+ pointer: '/props/fields/version',
870
+ message: `Version "${String(fields.version)}" is not exact. Resolve the requested range to one registry version (made-easy resolve npm <package>).`,
871
+ });
872
+ }
873
+ if (recipe.profile === 'sdk' && /\bnpm\b/.test(distribution) && isFilled(fields.integrity)) {
874
+ if (!/^sha(1|256|384|512)-[A-Za-z0-9+/=]+$/.test(String(fields.integrity))) {
875
+ emit('sdkExactness', {
876
+ code: 'SDK_INTEGRITY_MISSING',
877
+ path: scope.path,
878
+ line: block.lineOf('/props/fields/integrity') ?? block.line,
879
+ blockId: block.id,
880
+ pointer: '/props/fields/integrity',
881
+ message: 'The integrity value is not a valid SRI string (for example sha512-...).',
882
+ });
883
+ }
884
+ }
885
+ if (profile.requiresRunnableExample) {
886
+ const runnable = allBlocks(model).some(({ block: b }) => {
887
+ if (b.type !== 'example')
888
+ return false;
889
+ const p = b.props;
890
+ return p.executable !== false && isFilled(p.fixture) && isFilled(p.run) && isFilled(p.expected);
891
+ });
892
+ if (!runnable) {
893
+ emit('profileFields', {
894
+ code: 'SDK_EXAMPLE_MISSING',
895
+ path: scope.path,
896
+ line: 1,
897
+ message: 'The sdk profile requires a complete runnable example block (fixture, run commands and expected output).',
898
+ });
899
+ }
900
+ }
901
+ }
902
+ /* ------------------------------ example checks ---------------------------- */
903
+ function validateExamples(model, emit, err) {
904
+ const records = model.verification?.records ?? [];
905
+ for (const { entry, block } of exampleBlocks(model)) {
906
+ const p = block.props;
907
+ const fixture = typeof p.fixture === 'string' ? p.fixture.replace(/\/$/, '') : '';
908
+ const at = (ptr) => block.lineOf(`/props${ptr}`) ?? block.line;
909
+ if (!fixture || !fixture.startsWith('operator/fixtures/') || fixture.includes('..')) {
910
+ emit('missingFields', {
911
+ code: 'EXAMPLE_FIXTURE_MISSING',
912
+ path: entry.path,
913
+ line: at('/fixture'),
914
+ blockId: block.id,
915
+ message: `Example "${block.id}" needs a fixture directory under operator/fixtures/.`,
916
+ });
917
+ continue;
918
+ }
919
+ const fixtureFiles = model.files.paths().filter((x) => x.startsWith(`${fixture}/`));
920
+ if (!fixtureFiles.length) {
921
+ emit('missingFields', {
922
+ code: 'EXAMPLE_FIXTURE_MISSING',
923
+ path: entry.path,
924
+ line: at('/fixture'),
925
+ blockId: block.id,
926
+ message: `Fixture directory ${fixture}/ has no files.`,
927
+ });
928
+ continue;
929
+ }
930
+ p.files?.forEach((f, i) => {
931
+ const resolved = resolveRelative(`${fixture}/x`, f);
932
+ if (!resolved || !resolved.startsWith(`${fixture}/`) || !model.files.has(resolved)) {
933
+ err({
934
+ code: 'REF_UNRESOLVED',
935
+ path: entry.path,
936
+ line: at(`/files/${i}`),
937
+ blockId: block.id,
938
+ pointer: `/props/files/${i}`,
939
+ message: `Example file "${f}" is not inside ${fixture}/.`,
940
+ });
941
+ }
942
+ });
943
+ if (!isFilled(p.run) || !isFilled(p.expected)) {
944
+ emit('missingFields', {
945
+ code: 'FIELD_REQUIRED_MISSING',
946
+ path: entry.path,
947
+ line: at(''),
948
+ blockId: block.id,
949
+ message: `Example "${block.id}" needs run commands and expected output.`,
950
+ });
951
+ }
952
+ const record = records.find((r) => r.example === block.id);
953
+ if (!record) {
954
+ emit('unverifiedExamples', {
955
+ code: 'EXAMPLE_UNVERIFIED',
956
+ path: entry.path,
957
+ line: block.line,
958
+ blockId: block.id,
959
+ message: `Executable example "${block.id}" has no verification record (made-easy verify, or a matching CI record in operator/fixtures/verification.yaml).`,
960
+ });
961
+ continue;
962
+ }
963
+ const digest = exampleDigest(model, p);
964
+ if (record.digest !== digest) {
965
+ emit('unverifiedExamples', {
966
+ code: 'EXAMPLE_VERIFICATION_STALE',
967
+ path: 'operator/fixtures/verification.yaml',
968
+ line: model.lines['operator/fixtures/verification.yaml']?.(`/records/${records.indexOf(record)}/digest`),
969
+ blockId: block.id,
970
+ message: `The verification record for "${block.id}" does not match the current example or fixture files. Re-verify it.`,
971
+ hint: `recorded ${record.digest}; current ${digest}`,
972
+ });
973
+ }
974
+ else if (record.result !== 'pass') {
975
+ emit('unverifiedExamples', {
976
+ code: 'EXAMPLE_VERIFICATION_FAILED',
977
+ path: 'operator/fixtures/verification.yaml',
978
+ line: model.lines['operator/fixtures/verification.yaml']?.(`/records/${records.indexOf(record)}/result`),
979
+ blockId: block.id,
980
+ message: `The verification record for "${block.id}" reports a failing example.`,
981
+ });
982
+ }
983
+ }
984
+ }
985
+ export { pointerFromPath };
986
+ //# sourceMappingURL=validate.js.map