@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.
- package/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +213 -13
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- 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 };
|