@onlineapps/conn-orch-validator 6.0.1 → 8.0.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 (102) hide show
  1. package/CHANGELOG.md +2591 -2
  2. package/README.md +1075 -7
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +422 -104
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +78 -42
  10. package/src/ValidationOrchestrator.js +298 -75
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +12 -2
  16. package/src/helpers/createServiceReadinessTests.js +75 -6
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +213 -13
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +21 -20
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The manifest's test on itself.
5
+ *
6
+ * Confirmation `biz-service-manifest` 004:
7
+ * - a row may carry only facts the manifest owns — categories, duties, row
8
+ * ids, severities, `fix` commands, discovery patterns, `doc` pointers;
9
+ * - every other fact is a `from:` reference to the file that owns it;
10
+ * - bearers are discovered, never enumerated;
11
+ * - the version of the manifest is the validator pin, never a field;
12
+ * - point 5: every array of names is either a row/category definition or a
13
+ * `from:` reference — a copied list cannot be committed.
14
+ * 002 §16.1: a row without `doc` is a finding of the manifest itself.
15
+ *
16
+ * The result is data, not a throw: `manifestShape.test.js` asserts the exact
17
+ * violations, and `runManifest` turns them into a fail-fast error.
18
+ *
19
+ * @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-004
20
+ */
21
+
22
+ const { walkManifest, collectRows, isPlainObject } = require('./walk');
23
+
24
+ /**
25
+ * The consequences a row may carry, and no others. `boot` and `deploy` are the
26
+ * service uniform's (001 §4); `publish` is the library uniform's — a library has
27
+ * no boot, and the choke point nothing reaches a service past is
28
+ * `scripts/publish-library.sh` (002 §10).
29
+ */
30
+ const SEVERITIES = Object.freeze(['boot', 'deploy', 'publish', 'warn']);
31
+
32
+ /** The one severity that stops nothing: it is printed in the table and nowhere else. */
33
+ const WARN_SEVERITY = 'warn';
34
+
35
+ /** Which severities a uniform may declare as blocking — every consequence except `warn`. */
36
+ const BLOCKING_CANDIDATES = Object.freeze(SEVERITIES.filter((severity) => severity !== WARN_SEVERITY));
37
+
38
+ /**
39
+ * The three sentences every verdict is printed with, whichever uniform prints
40
+ * it: the word for a run something blocked, the word for a clear one, and — the
41
+ * third since d.230 — what a clear run says when it could not look at every row
42
+ * that CAN block. That last one is a sentence rather than a word because it
43
+ * names a count and a list, so it carries the two placeholders below.
44
+ *
45
+ * It belongs to the uniform for the same reason the first two do: a service
46
+ * skips `deploy` rows and a library `publish` ones, and the renderer knows
47
+ * neither word (`api/shared/TODO.md` §0.2b-15).
48
+ */
49
+ const VERDICT_WORDS = Object.freeze(['blocked', 'clear', 'incomplete']);
50
+
51
+ /** What the `incomplete` sentence must be able to say: how many rows, and which. */
52
+ const VERDICT_PLACEHOLDERS = Object.freeze(['{count}', '{ids}']);
53
+
54
+ /** Every row states these, whatever its check. */
55
+ const REQUIRED_ROW_FIELDS = Object.freeze(['id', 'check', 'severity', 'owner', 'fix', 'doc']);
56
+
57
+ /**
58
+ * What a check needs in order to look, and therefore where it runs. Three
59
+ * words, because there are three genuinely different needs — and the third one
60
+ * was missing until d.223, with a measured cost.
61
+ *
62
+ * - `service` — the bearer's own root is enough. Inside a container that is
63
+ * all there is, and these rows are the ones that still work
64
+ * there.
65
+ * - `workspace` — the check reports ABOUT the workspace: `U-ORPHAN` names a
66
+ * directory that is nobody's service. It has no bearer, so a
67
+ * run without a service root is its HOME, not its handicap.
68
+ * - `bearer` — the check reports about ONE bearer, and needs the workspace
69
+ * only to READ the file its `from:` reference names (the
70
+ * template, `config/shared-env.json`, `api/.nvmrc`). It runs
71
+ * once per bearer: for the one given in service mode, for
72
+ * every discovered one in workspace mode.
73
+ *
74
+ * Before the third word existed, `F-INIT`, `F-JEST`, `G-SHARED-ENV`, `R-NODE`,
75
+ * `L-ENGINES` and `L-PINS` had to borrow `workspace`, whose contract is "there
76
+ * is no service to read" — so each of them opened with `if (serviceRoot ===
77
+ * null) return []` and the workspace run printed "no findings" over a workspace
78
+ * with five real violations in it (measured over the `service-workspace`
79
+ * fixture, 2026-09-09). A silent pass is the false guarantee of
80
+ * `automation-gates.md` §5, and it came from a vocabulary that could not say
81
+ * what those rows needed.
82
+ */
83
+ const CHECK_SCOPES = Object.freeze(['service', 'workspace', 'bearer']);
84
+
85
+ /** The scopes whose check cannot look without the workspace root resolved. */
86
+ const WORKSPACE_DEPENDENT_SCOPES = Object.freeze(['workspace', 'bearer']);
87
+
88
+ /**
89
+ * Whether THIS row can be answered without the workspace.
90
+ *
91
+ * The scope says it for most rows: a `workspace` check reports about the
92
+ * workspace, and a `bearer` check needs it to READ the file its `from:`
93
+ * reference names. But which file that is, is the row's business, and since
94
+ * d.229 a row may reference a file this package carries itself
95
+ * (`manifestShape.js` § describeFromProblem, the fourth shape). Such a row needs
96
+ * no workspace at all: the reference travels with the pin.
97
+ *
98
+ * The check answers, because the check is what reads — a rule written here
99
+ * would have to know which rows reference what, which is the manifest's fact,
100
+ * not the runner's (`change-discipline.md` § One rail per concern). A check that
101
+ * does not answer keeps the scope's default, so nothing had to change for the
102
+ * rows whose reference is still a workspace file (`R-NODE` reads `api/.nvmrc`)
103
+ * or which read the workspace for something other than a reference (the `D-*`
104
+ * rows run the documentation lint out of `api/scripts`).
105
+ *
106
+ * @param {{check: object, row: object, block: object}} params
107
+ * @returns {boolean}
108
+ */
109
+ function rowNeedsWorkspace({ check, row, block }) {
110
+ if (!WORKSPACE_DEPENDENT_SCOPES.includes(check.scope)) return false;
111
+ return check.needsWorkspace ? check.needsWorkspace({ row, block }) : true;
112
+ }
113
+
114
+ /**
115
+ * The only keys under which an array of strings is a fact the manifest OWNS
116
+ * (004 point 1: categories, duties, discovery patterns). An array of strings
117
+ * anywhere else is a copied list, whoever wrote it and however true it is today.
118
+ *
119
+ * - `exclude`, `consumer_patterns` — discovery patterns;
120
+ * - `may_depend_on` — the category graph, which is the duty itself;
121
+ * - `forbidden_packages` — the packages a duty forbids by name. These have no
122
+ * SSOT in this repository to reference: the rule is prose
123
+ * (`api/.claude/rules/package-management.md` § Storage Architecture) and the
124
+ * row is its only machine-readable form;
125
+ * - `blocking_severities` — which consequences stop this uniform's bearer. The
126
+ * uniform is the only thing that knows: a service raises no `publish`
127
+ * finding and a library no `boot` one;
128
+ * - `allowed` — the paths a duty exempts, each either a directory prefix
129
+ * ending in `/` or one file. The duty IS the exemption, so nothing else owns
130
+ * the list.
131
+ */
132
+ const OWNED_STRING_ARRAY_KEYS = Object.freeze([
133
+ 'exclude',
134
+ 'may_depend_on',
135
+ 'consumer_patterns',
136
+ 'forbidden_packages',
137
+ 'blocking_severities',
138
+ 'allowed'
139
+ ]);
140
+
141
+ /** A version written into the manifest would be a second owner of the pin (004 point 2). */
142
+ const VERSION_KEY = /^(manifest_)?version$/;
143
+
144
+ /** The `applies_to` value that means every bearer of the uniform. */
145
+ const ALL_CATEGORIES = '*';
146
+
147
+ /**
148
+ * The four shapes a `from:` reference may take.
149
+ *
150
+ * The first three point OUT of this package, at files owned by other concerns
151
+ * that are not going to be rewritten for the manifest:
152
+ *
153
+ * - `{ path, list, key }` an array of objects (api/config/services.json)
154
+ * - `{ path, list, names: "keys" }` an object map (api/config/libraries.json)
155
+ * - `{ path, text: true }` the whole file as one value (api/.nvmrc)
156
+ *
157
+ * The fourth points INTO it:
158
+ *
159
+ * - `{ package, text: true }` a file this package carries itself
160
+ * (templates/business-service/jest.config.js)
161
+ *
162
+ * It exists because for some facts the owner IS this package. The template moved
163
+ * inside it in d.215a — `api/templates/business-service` is the generator's
164
+ * OUTPUT, committed for reading (001 §5) — so a row referencing that directory
165
+ * referenced the output rather than the owner, which 004 point 2 forbids. The
166
+ * practical consequence was measured on 2026-09-09: inside a service container
167
+ * and in the service's own CI there is no workspace, so every row whose
168
+ * reference was `api/templates/…` reported NOT RUN — precisely the rows the
169
+ * uniform exists for. A packaged reference travels with the pin, so those rows
170
+ * run wherever the package is installed.
171
+ *
172
+ * A reference names one owner: `path` or `package`, never both.
173
+ *
174
+ * @param {*} from the reference as written
175
+ * @returns {string|null} what is wrong with it, or null when it is usable
176
+ */
177
+ function describeFromProblem(from) {
178
+ if (!isPlainObject(from)) return 'it is not an object.';
179
+
180
+ const packaged = typeof from.package === 'string' && from.package.length > 0;
181
+ if (packaged && typeof from.path === 'string') {
182
+ return 'it names both a workspace "path" and a "package" file, so the fact would have two owners.';
183
+ }
184
+ if (packaged) {
185
+ return from.text === true
186
+ ? null
187
+ : 'it names a "package" file without text: true — a file this package carries is read whole.';
188
+ }
189
+
190
+ if (typeof from.path !== 'string' || from.path.length === 0) {
191
+ return 'it names neither a workspace "path" nor a "package" file.';
192
+ }
193
+ if (from.text === true) return null;
194
+ if (typeof from.list !== 'string' || from.list.length === 0) {
195
+ return 'it names no "list", and is not a { path, text: true } reference.';
196
+ }
197
+ if (from.names === 'keys') return null;
198
+ if (typeof from.key === 'string' && from.key.length > 0) return null;
199
+ return 'it names a "list" but neither a "key" (array of objects) nor names: "keys" (object map).';
200
+ }
201
+
202
+ /**
203
+ * Every category any discovery block of this manifest defines.
204
+ *
205
+ * @param {object} manifest parsed manifest
206
+ * @returns {Set<string>}
207
+ */
208
+ function collectCategories(manifest) {
209
+ const names = new Set();
210
+ for (const block of Object.values(manifest.discovery || {})) {
211
+ for (const category of Object.keys(block.categories || {})) names.add(category);
212
+ }
213
+ return names;
214
+ }
215
+
216
+
217
+ /**
218
+ * The consequence of the uniform, which only the uniform knows.
219
+ *
220
+ * `boot` and `deploy` stop a service; `publish` stops a library (002 §10). A
221
+ * single global list made every verdict name all three, so a service run offered
222
+ * `publish` as a reason it was not deployable — a consequence no service row can
223
+ * raise (`api/shared/TODO.md` §0.2b-15). The severities and the two words the
224
+ * verdict is printed with are therefore declared by the manifest, and required
225
+ * here: a manifest that does not say what stops its bearer cannot be run at all.
226
+ *
227
+ * @param {object} manifest parsed manifest
228
+ * @param {(id: string, message: string) => void} add collector of violations
229
+ * @returns {void}
230
+ */
231
+ function verifyConsequence(manifest, add) {
232
+ const blocking = manifest.blocking_severities;
233
+ if (!Array.isArray(blocking) || blocking.length === 0) {
234
+ add('M-BLOCKING', 'The manifest does not declare "blocking_severities". '
235
+ + `Fix: state which severities stop this uniform's bearer, e.g. ["boot", "deploy"] for a service `
236
+ + 'and ["publish"] for a library (confirmation biz-service-manifest 001 §4, 002 §10).');
237
+ } else {
238
+ const unusable = blocking.filter((severity) => !BLOCKING_CANDIDATES.includes(severity));
239
+ if (unusable.length > 0) {
240
+ add('M-BLOCKING', `"blocking_severities" names ${unusable.join(', ')}. `
241
+ + `Fix: use one or more of ${BLOCKING_CANDIDATES.join(', ')} — "${WARN_SEVERITY}" is table-only and `
242
+ + 'stops nothing (confirmation biz-service-manifest 001 §4).');
243
+ }
244
+ }
245
+
246
+ const verdict = manifest.verdict;
247
+ if (!isPlainObject(verdict)) {
248
+ add('M-VERDICT', 'The manifest does not declare "verdict". '
249
+ + 'Fix: state the two words this uniform is judged with, e.g. '
250
+ + '{ "blocked": "NOT DEPLOYABLE", "clear": "DEPLOYABLE" } — the word belongs to the uniform, '
251
+ + 'never to an if in the renderer.');
252
+ return;
253
+ }
254
+
255
+ const missing = VERDICT_WORDS.filter((word) => typeof verdict[word] !== 'string' || verdict[word].length === 0);
256
+ if (missing.length > 0) {
257
+ add('M-VERDICT', `"verdict" is missing: ${missing.join(', ')}. `
258
+ + `Fix: every uniform states all three — ${VERDICT_WORDS.join(', ')}; "incomplete" is the sentence a `
259
+ + 'clear run adds when a row that can block it did not run (d.230).');
260
+ return;
261
+ }
262
+
263
+ const withoutPlaceholder = VERDICT_PLACEHOLDERS.filter((token) => !verdict.incomplete.includes(token));
264
+ if (withoutPlaceholder.length > 0) {
265
+ add('M-VERDICT', `"verdict"."incomplete" carries no ${withoutPlaceholder.join(', ')}. `
266
+ + 'Fix: the sentence names HOW MANY rows did not run and WHICH — a reader who cannot see the ids '
267
+ + 'cannot run the missing half.');
268
+ }
269
+ }
270
+
271
+ /**
272
+ * @param {object} manifest parsed manifest
273
+ * @param {{ checkRegistry: object }} deps the registered checks, injected
274
+ * @returns {{ ok: boolean, violations: Array<{id: string, message: string}>, rowIds: string[] }}
275
+ */
276
+ function verifyManifestShape(manifest, { checkRegistry } = {}) {
277
+ if (!isPlainObject(manifest)) {
278
+ throw new Error('[ManifestShape] Manifest object is required - verifyManifestShape(manifest, deps) '
279
+ + `got ${JSON.stringify(manifest)}. Fix: pass the result of loadManifest().`);
280
+ }
281
+ if (!isPlainObject(checkRegistry)) {
282
+ throw new Error('[ManifestShape] Check registry is required - verifyManifestShape(manifest, { checkRegistry }) '
283
+ + 'got none. Fix: pass CHECK_REGISTRY from src/manifest/checks.');
284
+ }
285
+
286
+ const violations = [];
287
+ const add = (id, message) => violations.push({ id, message });
288
+
289
+ const { arrays, scalars } = walkManifest(manifest);
290
+
291
+ for (const scalar of scalars) {
292
+ if (VERSION_KEY.test(scalar.key)) {
293
+ add('M-VERSION', `"${scalar.at}" states a version. The manifest version IS the validator pin `
294
+ + '(SSOT api/config/libraries.json, gate R6). Fix: remove the field.');
295
+ }
296
+ }
297
+
298
+ for (const entry of arrays) {
299
+ const rowArray = entry.value.length > 0
300
+ && entry.value.every((element) => isPlainObject(element) && 'id' in element);
301
+ if (rowArray) continue;
302
+
303
+ const objectArray = entry.value.length > 0 && entry.value.every(isPlainObject);
304
+ if (objectArray) {
305
+ add('M-ROW-FIELD', `"${entry.at}" is an array of objects that are not rows — an element has no "id". `
306
+ + 'Fix: give every row an id, or move the data to the file that owns it and reference it with "from".');
307
+ continue;
308
+ }
309
+
310
+ if (OWNED_STRING_ARRAY_KEYS.includes(entry.key) && entry.value.every((e) => typeof e === 'string')) {
311
+ continue;
312
+ }
313
+
314
+ add('M-COPY', `"${entry.at}" is a list of names the manifest does not own. `
315
+ + 'Fix: replace it with a "from" reference to the file that owns the list, or make it a row definition.');
316
+ }
317
+
318
+ verifyConsequence(manifest, add);
319
+
320
+ const rows = collectRows(manifest);
321
+ const seen = new Map();
322
+
323
+ for (const { row, at } of rows) {
324
+ const missing = REQUIRED_ROW_FIELDS.filter((field) => typeof row[field] !== 'string' || row[field].length === 0);
325
+ if (missing.length > 0) {
326
+ add('M-ROW-FIELD', `Row ${row.id || at} is missing: ${missing.join(', ')}. `
327
+ + `Fix: every row states ${REQUIRED_ROW_FIELDS.join(', ')} — "doc" is where its "why" lives.`);
328
+ continue;
329
+ }
330
+
331
+ if (seen.has(row.id)) {
332
+ add('M-ROW-ID', `Row id ${row.id} is used twice (${seen.get(row.id)} and ${at}). `
333
+ + 'Fix: a finding is named by its row; give the second row its own id.');
334
+ }
335
+ seen.set(row.id, at);
336
+
337
+ if (!SEVERITIES.includes(row.severity)) {
338
+ add('M-SEVERITY', `Row ${row.id} has severity "${row.severity}". `
339
+ + `Fix: use one of ${SEVERITIES.join(', ')} (confirmation biz-service-manifest 001 §4).`);
340
+ continue;
341
+ }
342
+
343
+ const check = checkRegistry[row.check];
344
+ if (!check) {
345
+ add('M-CHECK', `Row ${row.id} names check "${row.check}", which nothing registers. `
346
+ + `Fix: register it in src/manifest/checks, or use one of: ${Object.keys(checkRegistry).join(', ')}.`);
347
+ continue;
348
+ }
349
+
350
+ if (!CHECK_SCOPES.includes(check.scope)) {
351
+ add('M-SCOPE', `Row ${row.id} names check "${row.check}", which declares scope `
352
+ + `${JSON.stringify(check.scope)}. `
353
+ + `Fix: a check declares one of ${CHECK_SCOPES.join(', ')} — the runner decides where to run it `
354
+ + 'from that word alone, and an unknown one would run nowhere and say nothing.');
355
+ continue;
356
+ }
357
+
358
+ const missingParams = (check.requires || []).filter((param) => row[param] === undefined);
359
+ if (missingParams.length > 0) {
360
+ add('M-PARAM', `Row ${row.id} does not give check "${row.check}" its parameters: ${missingParams.join(', ')}. `
361
+ + 'Fix: add them to the row.');
362
+ }
363
+ }
364
+
365
+ for (const { row, at } of rows) {
366
+ if (row.from === undefined) continue;
367
+ const problem = describeFromProblem(row.from);
368
+ if (problem !== null) add('M-FROM', `Row ${row.id || at} has an unusable "from" reference — ${problem} `
369
+ + 'Fix: reference the file that owns the fact; the manifest never copies it.');
370
+ }
371
+
372
+ const categories = collectCategories(manifest);
373
+
374
+ for (const [name, block] of Object.entries(manifest.discovery || {})) {
375
+ if (typeof block.pattern !== 'string' || block.pattern.length === 0) {
376
+ add('M-DISCOVERY', `Discovery block "${name}" has no "pattern". `
377
+ + 'Fix: bearers are discovered, never enumerated — state the pattern that finds them.');
378
+ }
379
+ const problem = describeFromProblem(block.from);
380
+ if (problem !== null) {
381
+ add('M-DISCOVERY', `Discovery block "${name}" has no usable "from" reference — ${problem} `
382
+ + 'Fix: name the file that owns the list of bearers; the manifest never copies it.');
383
+ }
384
+
385
+ for (const [category, definition] of Object.entries(block.categories || {})) {
386
+ const missing = ['layer', 'concern', 'doc'].filter((field) => typeof definition[field] !== 'string');
387
+ if (missing.length > 0) {
388
+ add('M-CATEGORY', `Category "${category}" is missing: ${missing.join(', ')}. `
389
+ + 'Fix: a category states which layer it is, what it is for, and where its why lives.');
390
+ }
391
+ if (!Array.isArray(definition.may_depend_on)) {
392
+ add('M-CATEGORY', `Category "${category}" has no "may_depend_on" list. `
393
+ + 'Fix: state the categories it may depend on — an empty list is a decision, an absent one is not.');
394
+ continue;
395
+ }
396
+ for (const target of definition.may_depend_on) {
397
+ if (!categories.has(target)) {
398
+ add('M-CATEGORY', `Category "${category}" may_depend_on "${target}", which this manifest does not define. `
399
+ + `Fix: use one of ${[...categories].join(', ')}.`);
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ for (const [name, block] of Object.entries(manifest.duties || {})) {
406
+ const target = block.applies_to;
407
+ if (target === ALL_CATEGORIES) continue;
408
+ if (typeof target !== 'string' || !categories.has(target)) {
409
+ add('M-DUTY', `Duty section "${name}" applies_to ${JSON.stringify(target)}, which is neither `
410
+ + `"${ALL_CATEGORIES}" nor a defined category. Fix: use one of ${[...categories].join(', ')}.`);
411
+ }
412
+ }
413
+
414
+ for (const [id, entry] of Object.entries(manifest.guidance || {})) {
415
+ if (!isPlainObject(entry)) {
416
+ add('M-GUIDANCE', `Guidance "${id}" is not an object. Fix: state why it is not a row, and where its why lives.`);
417
+ continue;
418
+ }
419
+ const missing = ['why', 'doc'].filter((field) => typeof entry[field] !== 'string' || entry[field].length === 0);
420
+ if (missing.length > 0) {
421
+ add('M-GUIDANCE', `Guidance "${id}" is missing: ${missing.join(', ')}. `
422
+ + 'Fix: guidance says WHY there is no row and points at the decision — that is the whole of it.');
423
+ }
424
+ if (entry.check !== undefined || entry.severity !== undefined) {
425
+ add('M-GUIDANCE', `Guidance "${id}" carries "check" or "severity". `
426
+ + 'Fix: guidance never produces a finding; if it can be decided, make it a row.');
427
+ }
428
+ }
429
+
430
+ return { ok: violations.length === 0, violations, rowIds: rows.map(({ row }) => row.id) };
431
+ }
432
+
433
+ module.exports = {
434
+ verifyManifestShape,
435
+ rowNeedsWorkspace,
436
+ describeFromProblem,
437
+ SEVERITIES,
438
+ CHECK_SCOPES,
439
+ WORKSPACE_DEPENDENT_SCOPES,
440
+ BLOCKING_CANDIDATES,
441
+ VERDICT_WORDS,
442
+ VERDICT_PLACEHOLDERS,
443
+ REQUIRED_ROW_FIELDS,
444
+ OWNED_STRING_ARRAY_KEYS,
445
+ ALL_CATEGORIES
446
+ };
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Presentation of a manifest run: the table a human reads and the JSON a gate
5
+ * reads. One table, the columns confirmation `biz-service-manifest` 001 §3.1
6
+ * fixes: `id · severity · where · what · fix · owner`.
7
+ *
8
+ * Two things this module refuses to hide:
9
+ * - the `doc` pointer of every reported row, so the reader reaches the *why*
10
+ * without asking anybody (002 §16.1);
11
+ * - a row that did NOT run, printed above the table. A check that could not
12
+ * look is never silence and never a pass (`automation-gates.md` §5).
13
+ */
14
+
15
+ const { incompleteRows } = require('./runManifest');
16
+
17
+ const COLUMNS = Object.freeze(['id', 'severity', 'where', 'what', 'fix', 'owner']);
18
+
19
+ /**
20
+ * The blocking findings of a run, judged by the severities THIS run's uniform
21
+ * declared. Nothing here knows which those are — a service has no `publish` row
22
+ * and a library no `boot` one, so a constant in this module would print a
23
+ * consequence the uniform cannot raise (`api/shared/TODO.md` §0.2b-15).
24
+ *
25
+ * A result that does not carry them is named, never guessed at
26
+ * (`.claude/rules/architecture-principles.md` §3, §5).
27
+ *
28
+ * @param {object} result the value `runManifest` returned
29
+ * @returns {object[]} the findings whose severity stops the bearer
30
+ */
31
+ function blockingOf(result) {
32
+ if (!Array.isArray(result.blockingSeverities) || result.blockingSeverities.length === 0) {
33
+ throw new Error('[Report] The run does not declare its blocking severities - a verdict would have to '
34
+ + 'guess what stops this bearer. Fix: render the value runManifest() returned; it carries '
35
+ + 'blockingSeverities from the manifest.');
36
+ }
37
+ return result.findings.filter((finding) => result.blockingSeverities.includes(finding.severity));
38
+ }
39
+
40
+ /**
41
+ * The two words this uniform is judged with — `NOT DEPLOYABLE`/`DEPLOYABLE` for
42
+ * a service, `NOT PUBLISHABLE`/`PUBLISHABLE` for a library. The word is a
43
+ * property of the uniform, never an `if` in the renderer.
44
+ *
45
+ * @param {object} result the value `runManifest` returned
46
+ * @returns {{blocked: string, clear: string}}
47
+ */
48
+ function verdictOf(result) {
49
+ const verdict = result.verdict;
50
+ if (verdict === null || typeof verdict !== 'object'
51
+ || typeof verdict.blocked !== 'string' || typeof verdict.clear !== 'string'
52
+ || typeof verdict.incomplete !== 'string') {
53
+ throw new Error('[Report] The run does not declare its verdict words - the sentence a human reads '
54
+ + 'belongs to the uniform. Fix: render the value runManifest() returned; it carries verdict '
55
+ + '{ blocked, clear, incomplete } from the manifest.');
56
+ }
57
+ return verdict;
58
+ }
59
+
60
+ /**
61
+ * What a CLEAR run adds when it could not look at every row that can block: the
62
+ * uniform's own sentence, with the count and the ids of those rows in it.
63
+ *
64
+ * The sentence is the manifest's (`manifestShape.js` § VERDICT_WORDS) and the
65
+ * rows are `runManifest`'s (§ incompleteRows); this function only fills the two
66
+ * placeholders. Nothing here knows the word `deploy` — a library says `publish`,
67
+ * and a renderer that knew either would be a second owner of the consequence
68
+ * (`api/shared/TODO.md` §0.2b-15).
69
+ *
70
+ * @param {object} result the value `runManifest` returned
71
+ * @returns {string}
72
+ */
73
+ function describeIncomplete(result) {
74
+ const rows = incompleteRows(result);
75
+ return verdictOf(result).incomplete
76
+ .split('{count}').join(String(rows.length))
77
+ .split('{ids}').join(rows.map((one) => one.id).join(', '));
78
+ }
79
+
80
+ /**
81
+ * The outcome half of a CLEAR verdict — what was found, and always what could
82
+ * not be looked at.
83
+ *
84
+ * One owner, three callers: the banner an init log ends with, the report
85
+ * `oa-validate` prints, and the library report beside it. Before this, the three
86
+ * described the same run three ways, and two of them stopped at
87
+ * `— no findings`. A reader believes that sentence: it reads as a proven clean
88
+ * bill of health, and over a run where five rows could not look it is not one
89
+ * (`.claude/rules/automation-gates.md` §5 — a silent NOT RUN is a false
90
+ * guarantee; `change-discipline.md` § One rail per concern — one sentence, one
91
+ * owner). Lead decision, d.223 follow-up.
92
+ *
93
+ * The count is appended only when there IS one: a run that looked at everything
94
+ * keeps the short sentence, so the parenthesis always means something.
95
+ *
96
+ * @param {object} result the value `runManifest` returned
97
+ * @returns {string} e.g. `no findings`, `no findings (5 row(s) not run)`,
98
+ * `0 blocking finding(s), 1 warning(s) (1 row(s) not run)`
99
+ */
100
+ function describeClearOutcome(result) {
101
+ const warnings = result.findings.length - blockingOf(result).length;
102
+ const found = warnings > 0
103
+ ? `0 blocking finding(s), ${warnings} warning(s)`
104
+ : 'no findings';
105
+
106
+ if (result.notRun.length === 0) return found;
107
+
108
+ // Three outcomes, because there are three: everything was looked at; a row
109
+ // that could not have blocked anything was skipped (the count says so, and
110
+ // nothing more is owed); a row that CAN block was skipped, and then the
111
+ // verdict is only true of the half that ran (d.230).
112
+ return incompleteRows(result).length > 0
113
+ ? `${found}, ${describeIncomplete(result)}`
114
+ : `${found} (${result.notRun.length} row(s) not run)`;
115
+ }
116
+
117
+ function renderTable(findings) {
118
+ const widths = COLUMNS.map((column) => Math.max(
119
+ column.length,
120
+ ...findings.map((finding) => String(finding[column]).length)
121
+ ));
122
+ const line = (cells) => cells
123
+ .map((cell, index) => (index === cells.length - 1 ? String(cell) : String(cell).padEnd(widths[index])))
124
+ .join(' ')
125
+ .trimEnd();
126
+
127
+ return [line(COLUMNS), ...findings.map((finding) => line(COLUMNS.map((column) => finding[column])))];
128
+ }
129
+
130
+ const notRunLine = (skipped) => `NOT RUN ${skipped.id} — ${skipped.reason}`;
131
+
132
+ /**
133
+ * The banner of confirmation `biz-service-manifest` 001 §4 — what the service
134
+ * prints at the end of its init log, and what `oa-validate` could print without
135
+ * the surrounding report.
136
+ *
137
+ * A clean run says so in one line INCLUDING how many rows did not run: silence
138
+ * is the one outcome a reader cannot interpret, and a row that could not look is
139
+ * not a pass (`.claude/rules/automation-gates.md` §5).
140
+ *
141
+ * The table is `renderTable` — the same one `renderReport` prints, because one
142
+ * concern gets one rail (`.claude/rules/change-discipline.md`).
143
+ *
144
+ * @param {object} result the value `runManifest` returned
145
+ * @returns {string[]} the banner, line by line, unterminated
146
+ */
147
+ function renderBanner(result) {
148
+ const blocking = blockingOf(result);
149
+ const verdict = verdictOf(result);
150
+ const lines = [];
151
+
152
+ if (blocking.length > 0) {
153
+ lines.push(`${verdict.blocked} — ${blocking.length} finding(s)`);
154
+ } else {
155
+ // The banner keeps its subject — it is one line in a long init log — and
156
+ // takes the outcome from the one place that owns it.
157
+ lines.push(`${verdict.clear} — manifest conformance: ${describeClearOutcome(result)}`);
158
+ }
159
+
160
+ if (result.findings.length > 0) lines.push(...renderTable(result.findings));
161
+ lines.push(...result.notRun.map(notRunLine));
162
+
163
+ return lines;
164
+ }
165
+
166
+ /**
167
+ * One finding as a single sentence in the `[Context] Problem - Expected/Fix`
168
+ * shape of `architecture-principles.md` §5 — what a `boot` finding reads like in
169
+ * `results.errors`, where there is no table to carry the columns.
170
+ *
171
+ * @param {object} finding one entry of `result.findings`
172
+ * @returns {string}
173
+ */
174
+ function describeFinding(finding) {
175
+ return `[Manifest] ${finding.id} ${finding.where} - ${finding.what}. `
176
+ + `Fix: ${finding.fix} (owner: ${finding.owner}, doc: ${finding.doc})`;
177
+ }
178
+
179
+ /**
180
+ * @param {object} result the value `runManifest` returned
181
+ * @returns {string} the whole report, newline-terminated
182
+ */
183
+ function renderReport(result) {
184
+ const blocking = blockingOf(result);
185
+ const verdict = verdictOf(result);
186
+
187
+ const lines = [
188
+ `Manifest conformance — uniform ${result.uniform}`,
189
+ `Service root: ${result.serviceRoot === null ? 'NOT SET — this is a workspace run' : result.serviceRoot}`,
190
+ `Workspace root: ${result.workspaceRoot === null ? 'NOT RESOLVED' : result.workspaceRoot}`,
191
+ ''
192
+ ];
193
+
194
+ for (const skipped of result.notRun) {
195
+ lines.push(notRunLine(skipped));
196
+ }
197
+ if (result.notRun.length > 0) lines.push('');
198
+
199
+ if (result.findings.length > 0) {
200
+ lines.push(...renderTable(result.findings), '');
201
+ lines.push('Doc:');
202
+ const seen = new Set();
203
+ for (const finding of result.findings) {
204
+ if (seen.has(finding.id)) continue;
205
+ seen.add(finding.id);
206
+ lines.push(` ${finding.id} ${finding.doc}`);
207
+ }
208
+ lines.push('');
209
+ }
210
+
211
+ if (blocking.length > 0) {
212
+ lines.push(`${verdict.blocked} — ${blocking.length} finding(s) of severity `
213
+ + `${result.blockingSeverities.join('|')}`);
214
+ } else {
215
+ lines.push(`${verdict.clear} — ${describeClearOutcome(result)}`);
216
+ }
217
+
218
+ return `${lines.join('\n')}\n`;
219
+ }
220
+
221
+ /**
222
+ * @param {object} result the value `runManifest` returned
223
+ * @returns {string} the same run as data, newline-terminated
224
+ */
225
+ function renderJson(result) {
226
+ return `${JSON.stringify(result, null, 2)}\n`;
227
+ }
228
+
229
+ /** 001 §3.1: exit 0 or 1. A blocking finding is the only thing that fails a run. */
230
+ function exitCodeFor(result) {
231
+ return result.ok ? 0 : 1;
232
+ }
233
+
234
+ module.exports = {
235
+ renderReport,
236
+ renderBanner,
237
+ renderJson,
238
+ describeFinding,
239
+ describeClearOutcome,
240
+ describeIncomplete,
241
+ exitCodeFor,
242
+ blockingOf,
243
+ verdictOf,
244
+ COLUMNS
245
+ };