@onlineapps/conn-orch-validator 7.0.0 → 8.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 (102) hide show
  1. package/CHANGELOG.md +2582 -2
  2. package/README.md +1038 -4
  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 +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  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 +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  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 +140 -9
  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 +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +409 -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 +22 -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 +123 -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,449 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Running the manifest: rows in, findings out.
5
+ *
6
+ * Pure module — it reads the repository and returns a structured result. It
7
+ * renders nothing and exits nothing; `report.js` owns presentation and the CLI
8
+ * owns the exit code. The same split `deployContract.js` already keeps, and for
9
+ * the same reason: the boot step of d.212 will call this function, not a CLI.
10
+ *
11
+ * Every finding carries the columns confirmation `biz-service-manifest` 001
12
+ * §3.1 names — `id · severity · where · what · fix · owner` — plus the `doc`
13
+ * pointer 002 §16.1 requires the message to cite.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const { verifyManifestShape, rowNeedsWorkspace } = require('./manifestShape');
20
+ const { collectRows } = require('./walk');
21
+ const { discoverBearers, rootOfPattern } = require('./discovery');
22
+ const { resolveWorkspacePath, canonicalRoot } = require('./workspaceRoot');
23
+ const { CHECK_REGISTRY } = require('./checks');
24
+
25
+ /** The three scopes, by the name the runner branches on. */
26
+ const SCOPE_SERVICE = 'service';
27
+ const SCOPE_WORKSPACE = 'workspace';
28
+ const SCOPE_BEARER = 'bearer';
29
+
30
+ /**
31
+ * The two modes, and why a workspace row is filtered in one of them.
32
+ *
33
+ * A `scope: workspace` check looks at the whole workspace, so it can raise a
34
+ * finding about a directory that is not the service being checked — `U-ORPHAN`
35
+ * over `api_biz/*` is exactly that. Putting a neighbour's row in this service's
36
+ * table would give the service a blocker it cannot fix and cannot even see the
37
+ * cause of, so:
38
+ *
39
+ * - **service mode** (`serviceRoot` given): a workspace row reports only the
40
+ * findings whose `where` lies under this service's root. The rest belong to
41
+ * their own service's table, and to the workspace run below. A service root
42
+ * that is NOT under the given workspace root is the one case where filtering
43
+ * would be a lie — every finding would be dropped and the empty table would
44
+ * read as "the row looked and found nothing" — so the row is reported NOT
45
+ * RUN with that reason instead (`automation-gates.md` §5; lead decision
46
+ * 2026-09-09).
47
+ * - **workspace mode** (no `serviceRoot`, `workspaceRoot` given): every
48
+ * workspace row reports in full — that run is the home of those checks —
49
+ * and every `scope: service` row is NOT RUN, because there is no repository
50
+ * to look in. NOT RUN, never a silent pass (`automation-gates.md` §5).
51
+ *
52
+ * Lead decision, `api/shared/TODO.md` §0.2b-8 points 1–2.
53
+ *
54
+ * A workspace check therefore reports `where` relative to the WORKSPACE root; a
55
+ * service check reports it relative to the service root. That is what makes the
56
+ * filter below meaningful, and it is the one convention a new check must keep.
57
+ *
58
+ * **The third scope, `bearer`** (d.223). A row such as `F-JEST` is neither: it
59
+ * is a fact of ONE service, and it needs the workspace only to read the
60
+ * template its `from:` reference names. Made `workspace`, it had to answer "no
61
+ * findings" in workspace mode — there is no service to read — and that answer
62
+ * is indistinguishable from a clean workspace. Measured over the
63
+ * `service-workspace` fixture on 2026-09-09: the workspace run printed
64
+ * `DEPLOYABLE — no findings` while two of its four services violated four of
65
+ * those rows between them.
66
+ *
67
+ * So a `bearer` row runs once per bearer, and which bearers exist is the
68
+ * manifest's own `discovery` block — the same pattern and the same exclusions
69
+ * that decide who wears the uniform, never a second walk
70
+ * (`.claude/rules/change-discipline.md` § One rail per concern):
71
+ *
72
+ * - **service mode**: the one bearer given, exactly as before — the service
73
+ * table does not change, and the `where` stays workspace-relative so a
74
+ * reader can paste it from either report;
75
+ * - **workspace mode**: every discovered bearer, so the run finally IS the
76
+ * cross-service list it always claimed to be.
77
+ *
78
+ * No filter applies to a `bearer` row: every finding it raises is about the
79
+ * bearer it was just run against, so there is nothing belonging to a neighbour
80
+ * to drop.
81
+ *
82
+ * **A checkout that does not carry its siblings.** CI checks out `api/` alone,
83
+ * so `<workspace>/api_biz` is not there at all. A check reading it — `L-CONSUMER`
84
+ * asks who pins this package, `L-TOOLING` which service carries it, `U-ORPHAN`
85
+ * which directory nobody declared — then walks an absent directory, gets the
86
+ * empty set, and reports it as an ANSWER: measured 2026-09-09, `lib-tooling`
87
+ * gained a blocking `L-CONSUMER` ("pinned by nothing") that is false, and lost
88
+ * one of its two `L-TOOLING` findings in silence.
89
+ *
90
+ * So a check declares the directories it will read, `requiresSiblings({ row,
91
+ * block })`, and the runner verifies them BEFORE running it; a missing one is
92
+ * `NOT RUN — sibling root <path> is not present in this checkout`. The runner
93
+ * owns the judgement because it is one judgement: a check deciding it for
94
+ * itself would be the same three lines written once per check, and the tenth
95
+ * check would forget them (`change-discipline.md` § One rail per concern). It
96
+ * is the same rule `lint-biz-docs --allow-missing-siblings` already follows.
97
+ *
98
+ * A `bearer` row in workspace mode inherits the manifest's own discovery roots:
99
+ * enumerating no bearers because `api_biz` is absent would print "no findings"
100
+ * for eight services nobody looked at.
101
+ */
102
+
103
+ /**
104
+ * @param {object} params
105
+ * @param {object} params.manifest parsed manifest (loadManifest)
106
+ * @param {string|null} [params.serviceRoot] the repository being checked; null = workspace mode
107
+ * @param {string|null} params.workspaceRoot resolved workspace root, or null
108
+ * @param {object} [params.checkRegistry] injected registry; defaults to the packaged one
109
+ * @returns {{ uniform: string, mode: string, serviceRoot: string|null, workspaceRoot: string|null,
110
+ * findings: Array<object>, notRun: Array<{id: string, reason: string}>, ok: boolean,
111
+ * blockingSeverities: string[], verdict: {blocked: string, clear: string} }}
112
+ */
113
+ function runManifest({ manifest, serviceRoot = null, workspaceRoot = null, checkRegistry = CHECK_REGISTRY }) {
114
+ const mode = serviceRoot === null || serviceRoot === undefined ? 'workspace' : 'service';
115
+
116
+ if (mode === 'workspace' && workspaceRoot === null) {
117
+ throw new Error('[Manifest] A run without a service root needs a workspace root - runManifest() got '
118
+ + 'neither, so it would check nothing. Fix: pass serviceRoot to check one repository, '
119
+ + 'or workspaceRoot to run the workspace-wide rows.');
120
+ }
121
+
122
+ // Both roots, in one spelling. They arrive by two routes — one derived from
123
+ // this package's own location, one typed by the caller — and everything below
124
+ // compares them as strings (`buildScopeFilter`, `outsideWorkspace`,
125
+ // `path.relative` in every `where`). A symlink in one of the two makes that
126
+ // comparison answer about the spelling instead of about the directory
127
+ // (`workspaceRoot.js` § canonicalRoot).
128
+ const workspace = workspaceRoot === null ? null : canonicalRoot(workspaceRoot);
129
+
130
+ let root = null;
131
+ if (mode === 'service') {
132
+ if (typeof serviceRoot !== 'string' || serviceRoot.length === 0) {
133
+ throw new Error('[Manifest] Service root is required - runManifest({ serviceRoot }) got '
134
+ + `${JSON.stringify(serviceRoot)}. Fix: pass the repository directory to check.`);
135
+ }
136
+
137
+ root = path.resolve(serviceRoot);
138
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
139
+ throw new Error(`[Manifest] Service root not found - ${root}. `
140
+ + 'Fix: pass an existing repository directory.');
141
+ }
142
+ root = canonicalRoot(root);
143
+ }
144
+
145
+ const shape = verifyManifestShape(manifest, { checkRegistry });
146
+ if (!shape.ok) {
147
+ const detail = shape.violations.map((v) => `${v.id}: ${v.message}`).join(' | ');
148
+ const error = new Error(`[Manifest] Manifest failed its own shape check - ${shape.violations.length} `
149
+ + `violation(s): ${detail} Fix: repair manifests/biz-service.manifest.json.`);
150
+ error.violations = shape.violations;
151
+ throw error;
152
+ }
153
+
154
+ const findings = [];
155
+ const notRun = [];
156
+
157
+ const outsideWorkspace = mode === 'service' && workspace !== null
158
+ && path.relative(workspace, root).startsWith('..');
159
+ const underServiceRoot = buildScopeFilter({ mode, root, workspaceRoot: workspace });
160
+
161
+ const bearers = memoize(() => discoverEveryBearer({ manifest, workspaceRoot: workspace }));
162
+
163
+ for (const { row, block } of collectRows(manifest)) {
164
+ const check = checkRegistry[row.check];
165
+ const needsWorkspace = rowNeedsWorkspace({ check, row, block });
166
+
167
+ if (needsWorkspace && workspace === null) {
168
+ notRun.push({
169
+ id: row.id,
170
+ severity: row.severity,
171
+ reason: check.describeNotRun
172
+ ? check.describeNotRun({ row, block })
173
+ : 'the workspace root is not reachable'
174
+ });
175
+ continue;
176
+ }
177
+
178
+ if (needsWorkspace && outsideWorkspace) {
179
+ notRun.push({
180
+ id: row.id,
181
+ severity: row.severity,
182
+ reason: `service root is outside the workspace root ${workspace}`
183
+ });
184
+ continue;
185
+ }
186
+
187
+ if (check.scope === SCOPE_SERVICE && mode === 'workspace') {
188
+ notRun.push({
189
+ id: row.id,
190
+ severity: row.severity,
191
+ reason: 'the run has no service root — this row is checked inside a service repository'
192
+ });
193
+ continue;
194
+ }
195
+
196
+ const missing = missingRoots({
197
+ roots: requiredRoots({ check, row, block, manifest, mode }),
198
+ workspaceRoot: workspace
199
+ });
200
+ if (missing.length > 0) {
201
+ notRun.push({ id: row.id, severity: row.severity, reason: describeMissingRoots(missing) });
202
+ continue;
203
+ }
204
+
205
+ const raised = rowFindings({
206
+ check, row, block, mode, root, workspaceRoot: workspace, bearers, underServiceRoot
207
+ });
208
+
209
+ // A check may also report that it could not DECIDE the row — not because
210
+ // the workspace is unreachable (that is judged above, before it runs) but
211
+ // because the thing it delegates to said so. `docs-lint` is the case: the
212
+ // linter has its own NOT RUN channel, and a ban whose evidence probe reads a
213
+ // sibling checkout is undecidable in a run that has none. Reported as a
214
+ // finding it would be a blocker nobody can clear; dropped it would be a
215
+ // silent pass. It is the same word every other unanswered row gets
216
+ // (`.claude/rules/automation-gates.md` §5).
217
+ if (raised.notRun !== null) {
218
+ notRun.push({ id: row.id, severity: row.severity, reason: raised.notRun });
219
+ }
220
+
221
+ for (const one of raised.findings) {
222
+ findings.push({
223
+ id: row.id,
224
+ severity: row.severity,
225
+ where: one.where,
226
+ what: one.what,
227
+ fix: row.fix,
228
+ owner: row.owner,
229
+ doc: row.doc
230
+ });
231
+ }
232
+ }
233
+
234
+ // Which severities stop this bearer is a property of the UNIFORM, not of the
235
+ // runner: a service raises no `publish` finding and a library no `boot` one,
236
+ // so a global list made every verdict name severities its uniform cannot
237
+ // raise (`api/shared/TODO.md` §0.2b-15). The manifest declares it, the shape
238
+ // check above has already refused a manifest that does not, and the run
239
+ // carries both the list and the two verdict words so no consumer recomputes
240
+ // them (`.claude/rules/change-discipline.md` § One rail per concern).
241
+ const blockingSeverities = manifest.blocking_severities;
242
+ const blocking = findings.filter((finding) => blockingSeverities.includes(finding.severity));
243
+
244
+ const result = {
245
+ uniform: manifest.uniform,
246
+ mode,
247
+ serviceRoot: root,
248
+ workspaceRoot: workspace,
249
+ blockingSeverities,
250
+ verdict: manifest.verdict,
251
+ findings,
252
+ notRun,
253
+ ok: blocking.length === 0
254
+ };
255
+
256
+ // Two different questions, and until d.230 only one of them had an answer:
257
+ // `ok` says whether anything blocked, over the rows that RAN; `complete` says
258
+ // whether every row that CAN block was looked at. Inside a service container
259
+ // the workspace-reading rows cannot run, so a signal is routinely deployable
260
+ // and incomplete at once — and a deploy gate reading only the first was
261
+ // taking a `--skip` nobody had flagged (`automation-gates.md` §1.5).
262
+ return { ...result, complete: incompleteRows(result).length === 0 };
263
+ }
264
+
265
+ /**
266
+ * The rows a run did NOT get to, and whose absence matters: the ones whose
267
+ * severity stops this uniform's bearer. A `warn` row that could not run leaves
268
+ * the run complete — it could not have stopped anything.
269
+ *
270
+ * One owner, two readers: the run itself computes `complete` from it, and
271
+ * `report.js` names the rows in the sentence a human reads
272
+ * (`change-discipline.md` § One rail per concern).
273
+ *
274
+ * @param {{notRun: Array<{id: string, severity: string}>, blockingSeverities: string[]}} result
275
+ * @returns {Array<{id: string, severity: string, reason: string}>}
276
+ */
277
+ function incompleteRows(result) {
278
+ if (!Array.isArray(result.blockingSeverities) || result.blockingSeverities.length === 0) {
279
+ throw new Error('[Manifest] The run does not declare its blocking severities - completeness cannot be '
280
+ + 'judged without them. Fix: pass the value runManifest() returned; it carries blockingSeverities '
281
+ + 'from the manifest.');
282
+ }
283
+ return (result.notRun || []).filter((one) => result.blockingSeverities.includes(one.severity));
284
+ }
285
+
286
+ /**
287
+ * Which directories must exist before this row's check can look.
288
+ *
289
+ * The check names the ones its own rule reads, derived from the row rather than
290
+ * restated beside it — `L-CONSUMER` already carries `consumer_patterns`, and a
291
+ * second list saying "and by the way that means api_biz" is a second owner of
292
+ * one fact. A workspace-mode `bearer` row adds the manifest's discovery roots,
293
+ * because enumerating the bearers is how it runs at all.
294
+ *
295
+ * @param {{ check: object, row: object, block: object, manifest: object, mode: string }} params
296
+ * @returns {string[]} workspace-relative directories, no duplicates
297
+ */
298
+ function requiredRoots({ check, row, block, manifest, mode }) {
299
+ const declared = check.requiresSiblings ? check.requiresSiblings({ row, block }) : [];
300
+ const discovery = check.scope === SCOPE_BEARER && mode === 'workspace'
301
+ ? Object.values(manifest.discovery || {})
302
+ .filter((entry) => typeof entry.pattern === 'string')
303
+ .map((entry) => rootOfPattern(entry.pattern))
304
+ : [];
305
+
306
+ return [...new Set([...declared, ...discovery])].filter((relative) => relative.length > 0);
307
+ }
308
+
309
+ /**
310
+ * @param {{ roots: string[], workspaceRoot: string|null }} params
311
+ * @returns {string[]} those of `roots` that are not directories under the workspace
312
+ */
313
+ function missingRoots({ roots, workspaceRoot }) {
314
+ if (workspaceRoot === null) return [];
315
+
316
+ return roots.filter((relative) => {
317
+ const target = resolveWorkspacePath(workspaceRoot, relative);
318
+ return !fs.existsSync(target) || !fs.statSync(target).isDirectory();
319
+ });
320
+ }
321
+
322
+ /**
323
+ * @param {string[]} missing the roots that are not there
324
+ * @returns {string} the NOT RUN reason, naming every one of them
325
+ */
326
+ function describeMissingRoots(missing) {
327
+ return missing.length === 1
328
+ ? `sibling root ${missing[0]} is not present in this checkout`
329
+ : `sibling roots ${missing.join(', ')} are not present in this checkout`;
330
+ }
331
+
332
+ /**
333
+ * What one check's `run()` returned, in one shape.
334
+ *
335
+ * A check returns either an array of findings — which is what almost all of
336
+ * them do — or `{ findings, notRun }`, where `notRun` is the reason it could not
337
+ * decide the row at all. The second shape exists for a check that DELEGATES:
338
+ * `docs-lint` asks a linter which has its own NOT RUN channel, and an answer of
339
+ * "I could not evaluate this rule" is neither a finding nor a pass.
340
+ *
341
+ * @param {Array|object} returned
342
+ * @returns {{findings: Array<{where: string, what: string}>, notRun: string|null}}
343
+ */
344
+ function readAnswer(returned) {
345
+ if (Array.isArray(returned)) return { findings: returned, notRun: null };
346
+ return {
347
+ findings: Array.isArray(returned.findings) ? returned.findings : [],
348
+ notRun: typeof returned.notRun === 'string' && returned.notRun.length > 0 ? returned.notRun : null
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Run one row's check wherever its scope says it belongs, and return what it
354
+ * raised. The three branches are the three scopes and nothing else — a caller
355
+ * that had to know which rows are per-bearer would be a second owner of the
356
+ * vocabulary.
357
+ *
358
+ * @param {object} params the check, its row and block, and the run's roots
359
+ * @returns {{findings: Array<{where: string, what: string}>, notRun: string|null}}
360
+ */
361
+ function rowFindings({ check, row, block, mode, root, workspaceRoot, bearers, underServiceRoot }) {
362
+ if (check.scope === SCOPE_WORKSPACE) {
363
+ const answer = readAnswer(check.run({ row, block, serviceRoot: root, workspaceRoot }));
364
+ return { ...answer, findings: answer.findings.filter(underServiceRoot) };
365
+ }
366
+
367
+ if (check.scope === SCOPE_BEARER && mode === 'workspace') {
368
+ // One row, several bearers: the findings add up, and the row is NOT RUN as
369
+ // soon as ONE bearer could not be decided — the run did not reach every
370
+ // bearer of it, and saying otherwise is the silence this reports.
371
+ const answers = bearers().map((bearer) => readAnswer(check.run({
372
+ row, block, serviceRoot: bearer.dir, workspaceRoot
373
+ })));
374
+ const undecided = answers.find((answer) => answer.notRun !== null);
375
+ return {
376
+ findings: answers.flatMap((answer) => answer.findings),
377
+ notRun: undecided === undefined ? null : undecided.notRun
378
+ };
379
+ }
380
+
381
+ return readAnswer(check.run({ row, block, serviceRoot: root, workspaceRoot }));
382
+ }
383
+
384
+ /**
385
+ * Every bearer of this uniform, from the manifest's own discovery blocks.
386
+ *
387
+ * A manifest declares one block today, but the shape check allows several and
388
+ * the runner has no business assuming otherwise; a directory found by two
389
+ * blocks is one bearer, so the same row is never run against it twice.
390
+ *
391
+ * @param {{ manifest: object, workspaceRoot: string|null }} params
392
+ * @returns {Array<{name: string, relativeDir: string, dir: string, file: string}>}
393
+ */
394
+ function discoverEveryBearer({ manifest, workspaceRoot }) {
395
+ if (workspaceRoot === null) return [];
396
+
397
+ const seen = new Set();
398
+ const bearers = [];
399
+ for (const block of Object.values(manifest.discovery || {})) {
400
+ for (const bearer of discoverBearers({ block, workspaceRoot }).bearers) {
401
+ if (seen.has(bearer.dir)) continue;
402
+ seen.add(bearer.dir);
403
+ bearers.push(bearer);
404
+ }
405
+ }
406
+ return bearers;
407
+ }
408
+
409
+ /**
410
+ * Call `produce` at most once per run. Discovery walks the disk, and four
411
+ * bearer rows asking for the same list would walk it four times.
412
+ *
413
+ * @param {() => *} produce
414
+ * @returns {() => *}
415
+ */
416
+ function memoize(produce) {
417
+ let value;
418
+ let done = false;
419
+ return () => {
420
+ if (!done) {
421
+ value = produce();
422
+ done = true;
423
+ }
424
+ return value;
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Which findings of a workspace-scoped check belong to THIS run's table.
430
+ *
431
+ * @param {{mode: string, root: string|null, workspaceRoot: string|null}} params
432
+ * @returns {(finding: {where: string}) => boolean}
433
+ */
434
+ function buildScopeFilter({ mode, root, workspaceRoot }) {
435
+ // Workspace mode reports every workspace row in full; and with no workspace
436
+ // root no workspace check runs at all (they are NOT RUN above), so there is
437
+ // nothing to filter and nothing to resolve a relative path against. A service
438
+ // root outside the workspace root never reaches here either — those rows are
439
+ // NOT RUN, not filtered.
440
+ if (mode === 'workspace' || workspaceRoot === null) return () => true;
441
+
442
+ const relative = path.relative(workspaceRoot, root);
443
+ if (relative === '') return () => true;
444
+
445
+ const prefix = relative.split(path.sep).join('/');
446
+ return (finding) => finding.where === prefix || finding.where.startsWith(`${prefix}/`);
447
+ }
448
+
449
+ module.exports = { runManifest, incompleteRows };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WHO the repository under check is — read from the file that DECLARES it,
5
+ * never from the name of the directory it happens to lie in.
6
+ *
7
+ * The directory name is a fact of the WORKSPACE. `api_biz/<name>` is what the
8
+ * discovery pattern walks and what `U-ORPHAN` holds against
9
+ * `api/config/services.json`; there it is the right answer, and it stays there.
10
+ * Inside the image built FROM that repository it means nothing at all: the
11
+ * Dockerfile declares `WORKDIR /app` (`templates/business-service/Dockerfile`),
12
+ * so every service on the platform is called "app" in the one run d.229 was
13
+ * about — the production stage, where the rows finally answer because their
14
+ * reference travels with the package.
15
+ *
16
+ * Measured 2026-09-09 over the conforming fixture copied into a directory named
17
+ * `app`: `F-RUNNER` rendered the template for a service called "app" and
18
+ * reported a conformant file as drifted, quoting a line the substitution had
19
+ * mangled ("It __SERVICE_NAME__lies in dev and CI only"), because "app" is a
20
+ * substring of "applies". The production stage then fails the build of every
21
+ * conformant service. A directory called `My Service` did worse: the derivation
22
+ * threw, and `oa-validate` exited 2 with no table at all.
23
+ *
24
+ * The one thing INSIDE the repository that says who it is, is
25
+ * `config/service/config.json` → `service.name`: the identity the platform
26
+ * registers the service under, and the key row `C-SERVICE` owns. On the eight
27
+ * live services it reads `biz-<name>`, which is exactly what `deriveParams`
28
+ * writes into `registry_name`, so the parameters of every rendered row come back
29
+ * out of it.
30
+ *
31
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §2
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+
37
+ const { deriveParams, PLACEHOLDERS, IDENTITY_PARAMS } = require('../sync/serviceTemplate');
38
+
39
+ /** The file a service declares its platform identity in; row `C-SERVICE` owns it. */
40
+ const IDENTITY_FILE = 'config/service/config.json';
41
+
42
+ /**
43
+ * The declared identity, read back into the short name every per-service path is
44
+ * built from — the exact inverse of what `deriveParams` writes.
45
+ *
46
+ * `deriveParams` only ever ADDS the platform prefix (`registry_name =
47
+ * biz-${name}`), so reading it back means taking that prefix off when it is
48
+ * there. It is not a rule about how a service must be called: whether
49
+ * `service.name` has to read `biz-<name>` is a conformance question with no row
50
+ * behind it today, and this module is not the place a new rule appears as a side
51
+ * effect (`.claude/rules/truth-over-agreement.md` §6). Measured: the eight live
52
+ * services all declare `biz-<name>`, and this package's own conformant fixture
53
+ * declares `v3-cookbook-service`.
54
+ */
55
+ const REGISTERED_NAME = /^(?:biz-)?([a-z][a-z0-9-]*)$/;
56
+
57
+ /**
58
+ * A problem `C-SERVICE` already puts on the table with its own fix. The rendered
59
+ * rows stay silent about it: one defect, one owner, one fix sentence
60
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
61
+ *
62
+ * @param {string} problem what is wrong, as the message says it
63
+ * @returns {{problem: string, undecided: null}}
64
+ */
65
+ const alreadyReported = (problem) => ({ problem: message(problem), undecided: null });
66
+
67
+ /**
68
+ * The fail-fast message, in the one shape `architecture-principles.md` §5 asks
69
+ * for. It is what the generator throws: `oa-sync-template` was asked to render
70
+ * this repository's files and cannot say whose they are.
71
+ *
72
+ * @param {string} problem
73
+ * @returns {string}
74
+ */
75
+ function message(problem) {
76
+ return `[ServiceIdentity] ${problem} - every rendered row derives this repository's parameters from it, `
77
+ + 'and the directory name says nothing inside the image the service is built into (WORKDIR /app). '
78
+ + `Fix: declare service.name as "biz-<name>" in ${IDENTITY_FILE}.`;
79
+ }
80
+
81
+ /**
82
+ * The identity of one repository, or why it cannot be read.
83
+ *
84
+ * @param {string} serviceRoot repository root
85
+ * @returns {{params: object}|{problem: string, undecided: string|null}}
86
+ * `undecided` is the clause a row prints when NOTHING else reports the
87
+ * problem, and null when `C-SERVICE` already does.
88
+ */
89
+ function readIdentity(serviceRoot) {
90
+ const file = path.join(serviceRoot, ...IDENTITY_FILE.split('/'));
91
+ if (!fs.existsSync(file)) return alreadyReported(`${IDENTITY_FILE} is absent`);
92
+
93
+ let declared;
94
+ try {
95
+ declared = JSON.parse(fs.readFileSync(file, 'utf8'));
96
+ } catch (error) {
97
+ return alreadyReported(`${IDENTITY_FILE} is not valid JSON — ${error.message}`);
98
+ }
99
+
100
+ const name = declared && declared.service ? declared.service.name : undefined;
101
+ if (typeof name !== 'string' || name.length === 0) {
102
+ return alreadyReported(`${IDENTITY_FILE} declares no service.name`);
103
+ }
104
+
105
+ // The TEMPLATE is a bearer too, and it declares its identity the only way a
106
+ // template can: as the placeholder. Rendering it with `IDENTITY_PARAMS`
107
+ // substitutes every placeholder by itself, which is precisely the claim
108
+ // confirmation 001 §9 makes about `api/templates/business-service` — so the
109
+ // rows compare the template against itself and it stays DEPLOYABLE.
110
+ if (name === PLACEHOLDERS.registry_name) return { params: IDENTITY_PARAMS };
111
+
112
+ const registered = REGISTERED_NAME.exec(name);
113
+ if (registered === null) {
114
+ // `C-SERVICE` demands a non-empty name and got one, so this is the one
115
+ // problem no other row would say a word about: the row that needed it says
116
+ // so itself, rather than reporting a pass it did not measure.
117
+ return {
118
+ problem: message(`${IDENTITY_FILE} declares service.name ${JSON.stringify(name)}`),
119
+ undecided: `service.name is ${JSON.stringify(name)} here, and the short name every rendered row is `
120
+ + 'built from reads "[biz-]<lower-case letters, digits and hyphens>"'
121
+ };
122
+ }
123
+
124
+ return { params: deriveParams({ name: registered[1] }) };
125
+ }
126
+
127
+ /**
128
+ * The identity, or a fail-fast. What a generator uses: it was asked to write
129
+ * this repository's files and must not guess whose they are.
130
+ *
131
+ * @param {string} serviceRoot repository root
132
+ * @returns {object} the parameters `renderText` substitutes
133
+ */
134
+ function requireIdentity(serviceRoot) {
135
+ const read = readIdentity(serviceRoot);
136
+ if (read.problem !== undefined) throw new Error(read.problem);
137
+ return read.params;
138
+ }
139
+
140
+ module.exports = { IDENTITY_FILE, REGISTERED_NAME, readIdentity, requireIdentity };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * One walk over the manifest tree, used by everything that has to reason about
5
+ * the whole file: the shape check (which arrays are copied lists?) and the
6
+ * runner (which rows are there, and what block do they belong to?).
7
+ *
8
+ * A row is any object carrying an `id` inside an array of objects. Its BLOCK is
9
+ * the object that contains that array — the row is the definition, the block
10
+ * carries the parameters of the concern (a discovery pattern, its `from:`
11
+ * reference). That split is what lets a future row cite an existing checker
12
+ * without either side learning about the other.
13
+ */
14
+
15
+ const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
16
+
17
+ /**
18
+ * @typedef {object} ManifestArray
19
+ * @property {string} at dotted path of the array inside the manifest
20
+ * @property {string} key the key holding it
21
+ * @property {Array} value the array itself
22
+ * @property {object} parent the object that holds it (the block)
23
+ */
24
+
25
+ /**
26
+ * @param {object} manifest parsed manifest
27
+ * @returns {{ arrays: ManifestArray[], scalars: Array<{at: string, key: string, value: *}> }}
28
+ */
29
+ function walkManifest(manifest) {
30
+ const arrays = [];
31
+ const scalars = [];
32
+
33
+ const visit = (node, prefix) => {
34
+ for (const [key, value] of Object.entries(node)) {
35
+ const at = prefix ? `${prefix}.${key}` : key;
36
+ if (Array.isArray(value)) {
37
+ arrays.push({ at, key, value, parent: node });
38
+ value.forEach((element, index) => {
39
+ if (isPlainObject(element)) visit(element, `${at}[${index}]`);
40
+ });
41
+ } else if (isPlainObject(value)) {
42
+ visit(value, at);
43
+ } else {
44
+ scalars.push({ at, key, value });
45
+ }
46
+ }
47
+ };
48
+
49
+ visit(manifest, '');
50
+ return { arrays, scalars };
51
+ }
52
+
53
+ /**
54
+ * Every row in the manifest, in file order, with the block it belongs to.
55
+ *
56
+ * @param {object} manifest parsed manifest
57
+ * @returns {Array<{ row: object, block: object, at: string }>}
58
+ */
59
+ function collectRows(manifest) {
60
+ const { arrays } = walkManifest(manifest);
61
+ const rows = [];
62
+
63
+ for (const entry of arrays) {
64
+ if (entry.value.length === 0) continue;
65
+ if (!entry.value.every((element) => isPlainObject(element) && 'id' in element)) continue;
66
+ entry.value.forEach((row, index) => {
67
+ rows.push({ row, block: entry.parent, at: `${entry.at}[${index}]` });
68
+ });
69
+ }
70
+
71
+ return rows;
72
+ }
73
+
74
+ module.exports = { walkManifest, collectRows, isPlainObject };