@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,553 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bridge from a manifest row to the documentation lint that ALREADY owns
|
|
5
|
+
* the rules of a documentation tree.
|
|
6
|
+
*
|
|
7
|
+
* Confirmation `biz-service-manifest` 004 point 4: a rule that exists is cited,
|
|
8
|
+
* never restated. The rules of `<service>/docs/**` exist and have an owner —
|
|
9
|
+
* `api/scripts/ci/lint-biz-docs.mjs`, held by BIZ-DOCS — so nothing here decides
|
|
10
|
+
* whether a document is wrong. The row names the lint rule it stands for, this
|
|
11
|
+
* module runs the lint ONCE per service and hands each row the findings of the
|
|
12
|
+
* rules it cites, with the lint's own `file:line` and message.
|
|
13
|
+
*
|
|
14
|
+
* The same shape `contractBridge.js` keeps for `deployContract.js` and friends,
|
|
15
|
+
* with one difference that is forced by where the checker lives: those are
|
|
16
|
+
* modules of this package, and this one is a script of the `api` checkout. So:
|
|
17
|
+
*
|
|
18
|
+
* - the rows are `bearer`-scoped. They speak about ONE service and need the
|
|
19
|
+
* workspace only to reach the script, which is exactly what that scope is
|
|
20
|
+
* for (`manifestShape.js` § CHECK_SCOPES). Inside a service image, and in a
|
|
21
|
+
* service's own CI, there is no api checkout, so `requiresSiblings` makes
|
|
22
|
+
* the runner report them NOT RUN — never a silent pass
|
|
23
|
+
* (`.claude/rules/automation-gates.md` §5);
|
|
24
|
+
* - the citation is a rule id of the lint, in the lint's OWN `--rules`
|
|
25
|
+
* syntax: `S001,S002` names two rules, `F002` names every banned token and
|
|
26
|
+
* `F002:http-ports` names one of them (the lint reads both forms, its
|
|
27
|
+
* `severityOf`: `options.rules.has(ruleId) || options.rules.has(ruleId.split(':')[0])`).
|
|
28
|
+
* Writing the citation in the linter's vocabulary is what keeps the row and
|
|
29
|
+
* the tool from growing a private dialect between them.
|
|
30
|
+
*
|
|
31
|
+
* **One finding lands on exactly one row.** `D-PORT` cites `F002:http-ports`
|
|
32
|
+
* and `D-RETIRED` cites `F002`, which the ban on localhost ports is one of — so
|
|
33
|
+
* without a rule the port finding would appear twice, under two ids with two
|
|
34
|
+
* different `fix` sentences. The most SPECIFIC citation wins, and two rows
|
|
35
|
+
* citing the same id are a fail-fast, because then nobody can say which fix a
|
|
36
|
+
* reader should follow.
|
|
37
|
+
*
|
|
38
|
+
* **A rule no row cites lands on the catch-all row, `D-LINT`.** Until d.303 it
|
|
39
|
+
* landed nowhere: the lint raises far more than the citing rows claim (measured
|
|
40
|
+
* 2026-09-09 over the eight repositories: 80 findings, of which 16 fell to a row
|
|
41
|
+
* here), so a tree the documentation gate called broken came back DEPLOYABLE
|
|
42
|
+
* from this uniform — coverage implied and not held (`automation-gates.md` §5).
|
|
43
|
+
* `docsLintClean` below closes that, and what the citing rows still buy is their
|
|
44
|
+
* own `fix` sentence, not coverage. The documentation gate itself is unchanged
|
|
45
|
+
* and still lands in each repository's CI at zero findings, repo by repo
|
|
46
|
+
* (`biz-docs-foreign-tree-gate` 001).
|
|
47
|
+
*
|
|
48
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §2
|
|
49
|
+
* @see api/docs/governance/confirmations/biz-docs-foreign-tree-gate.md
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const fs = require('fs');
|
|
53
|
+
const path = require('path');
|
|
54
|
+
const { spawnSync } = require('child_process');
|
|
55
|
+
|
|
56
|
+
const { whereOf } = require('./libraryContext');
|
|
57
|
+
const { resolveWorkspacePath } = require('../workspaceRoot');
|
|
58
|
+
|
|
59
|
+
/** The documentation lint, by the path the manifest writes every api path in. */
|
|
60
|
+
const LINT_SCRIPT = 'api/scripts/ci/lint-biz-docs.mjs';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The directory holding it. The runner verifies THIS before running the check
|
|
64
|
+
* and reports NOT RUN when it is absent, which is the whole of "no api checkout
|
|
65
|
+
* here" — a container, or a service's own CI.
|
|
66
|
+
*/
|
|
67
|
+
const LINT_DIR = 'api/scripts/ci';
|
|
68
|
+
|
|
69
|
+
/** Where a service's own documentation tree lives, by DOC-STANDARD § Scope. */
|
|
70
|
+
const DOCS_DIR = 'docs';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The invocation, and why each option is on it.
|
|
74
|
+
*
|
|
75
|
+
* `--root` / `--tree-config` the foreign tree and the budget it declares of
|
|
76
|
+
* its own; the lint refuses the tree without them
|
|
77
|
+
* (`biz-docs-foreign-tree-gate` 001).
|
|
78
|
+
* `--skip-code-rules` L008 and L009 scan code and rules of the api
|
|
79
|
+
* checkout, which are not this service's tree and
|
|
80
|
+
* not this uniform's subject. It is the
|
|
81
|
+
* invocation the packaged tree config's own note
|
|
82
|
+
* writes.
|
|
83
|
+
* `--allow-missing-siblings` a probe into a checkout this run does not have
|
|
84
|
+
* is reported as NOT RUN rather than answered.
|
|
85
|
+
* Without it the same absence reads as "the
|
|
86
|
+
* concept is gone" and the ban silently switches
|
|
87
|
+
* off — the failure `automation-gates.md` §5 is
|
|
88
|
+
* about, and the reason the skipped list below is
|
|
89
|
+
* turned into a finding rather than dropped.
|
|
90
|
+
* `--format json` the machine-readable half; the human half goes
|
|
91
|
+
* to stderr and is never parsed.
|
|
92
|
+
*
|
|
93
|
+
* @param {{ docsDir: string, treeConfig: string }} params
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
const lintArgumentsFor = ({ docsDir, treeConfig }) => [
|
|
97
|
+
'--root', docsDir,
|
|
98
|
+
'--tree-config', treeConfig,
|
|
99
|
+
'--skip-code-rules',
|
|
100
|
+
'--allow-missing-siblings',
|
|
101
|
+
'--format', 'json'
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** How long one tree may take before the run is called undecided, in ms. */
|
|
105
|
+
const LINT_TIMEOUT_MS = 120000;
|
|
106
|
+
|
|
107
|
+
/** Room for the JSON of a large tree; the biggest measured today is 54 findings. */
|
|
108
|
+
const LINT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The lint's answers, keyed by what the run was about. One service is linted
|
|
112
|
+
* ONCE however many rows cite it: five rows spawning five processes over the
|
|
113
|
+
* same tree would be the same answer bought five times.
|
|
114
|
+
*
|
|
115
|
+
* The lifetime is the process, which is the lifetime of a run — `oa-validate`
|
|
116
|
+
* is one process, and so is a jest worker over static fixtures. Nothing here
|
|
117
|
+
* watches the disk, so a tree edited mid-process keeps the answer it gave.
|
|
118
|
+
*/
|
|
119
|
+
const answers = new Map();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One violation as one table cell, without the `Fix:` sentence the row carries
|
|
123
|
+
* in its own column. The same summarising `contractBridge.js` does, for the same
|
|
124
|
+
* reason.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} message the lint's message, as it writes it
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function summarise(message) {
|
|
130
|
+
return String(message).split('\n')[0].split(' Fix:')[0].trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The rule ids a row cites, in the lint's `--rules` syntax.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} row the manifest row
|
|
137
|
+
* @returns {string[]}
|
|
138
|
+
*/
|
|
139
|
+
function citationsOf(row) {
|
|
140
|
+
const cited = String(row.rule).split(',').map((one) => one.trim()).filter((one) => one.length > 0);
|
|
141
|
+
if (cited.length === 0) {
|
|
142
|
+
throw new Error(`[DocsLintBridge] Row ${row.id} cites no lint rule - "rule" is ${JSON.stringify(row.rule)}. `
|
|
143
|
+
+ 'Fix: name the rule ids of api/scripts/ci/lint-biz-docs.mjs this row stands for, comma-separated '
|
|
144
|
+
+ '(the --rules syntax); a row citing nothing reports nothing and reads as a pass.');
|
|
145
|
+
}
|
|
146
|
+
return cited;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Does this citation cover this rule id? The lint's own answer: a citation is
|
|
151
|
+
* either the whole id or the part before the colon, so `F002` covers every
|
|
152
|
+
* banned token and `F002:http-ports` covers one.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} citation
|
|
155
|
+
* @param {string} ruleId
|
|
156
|
+
* @returns {boolean}
|
|
157
|
+
*/
|
|
158
|
+
const covers = (citation, ruleId) => ruleId === citation || String(ruleId).split(':')[0] === citation;
|
|
159
|
+
|
|
160
|
+
/** The check of a row that CITES rules; the catch-all below cites none. */
|
|
161
|
+
const CITING_CHECK = 'docs-lint';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The rows of the block competing for a finding: the ones that cite rules.
|
|
165
|
+
*
|
|
166
|
+
* The catch-all row (`docs-lint-clean`) is deliberately not among them. It
|
|
167
|
+
* claims what nobody else claims, so letting it compete would make every
|
|
168
|
+
* finding ambiguous at specificity 0 — and `citationsOf` would read its absent
|
|
169
|
+
* `rule` as a citation.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} block the manifest block holding the rows
|
|
172
|
+
* @returns {object[]}
|
|
173
|
+
*/
|
|
174
|
+
function rowsOf(block) {
|
|
175
|
+
const rows = block && block.rules;
|
|
176
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
177
|
+
throw new Error('[DocsLintBridge] The documentation block declares no "rules" array - a finding is '
|
|
178
|
+
+ 'placed on the row with the most specific citation, so the check has to see every row of the '
|
|
179
|
+
+ 'block. Fix: repair manifests/biz-service.manifest.json.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const citing = rows.filter((row) => row.check === CITING_CHECK);
|
|
183
|
+
if (citing.length === 0) {
|
|
184
|
+
throw new Error(`[DocsLintBridge] The documentation block declares no row of check "${CITING_CHECK}" - `
|
|
185
|
+
+ 'a finding is placed on the row with the most specific citation, and with no citing row every '
|
|
186
|
+
+ 'finding would fall to the catch-all with no fix sentence of its own. '
|
|
187
|
+
+ 'Fix: repair manifests/biz-service.manifest.json.');
|
|
188
|
+
}
|
|
189
|
+
return citing;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The grade that makes a documentation finding block a deploy (`--severity error`). */
|
|
193
|
+
const ERROR_SEVERITY = 'error';
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The findings the LINT graded `error` — the set its own `--severity error`
|
|
197
|
+
* prints, read from the one run the block already made rather than bought with
|
|
198
|
+
* a second process.
|
|
199
|
+
*
|
|
200
|
+
* This is the whole of the safeguard that lets `D-LINT` exist before every tree
|
|
201
|
+
* is clean: grading belongs to BIZ-DOCS and to nobody else
|
|
202
|
+
* (`api/config/biz-docs-lint.json` § severities), so a rule they add at `warn`
|
|
203
|
+
* is visible in their run and blocks no deploy until they raise it on their own
|
|
204
|
+
* dated transition (`automation-gates.md` §3 — a gate lands with compliance,
|
|
205
|
+
* never ahead of it). Nothing here decides a grade; it reads the one the lint
|
|
206
|
+
* stamped.
|
|
207
|
+
*
|
|
208
|
+
* @param {Array<object>} findings the lint's findings, as it wrote them
|
|
209
|
+
* @returns {Array<object>} the error-graded ones
|
|
210
|
+
*/
|
|
211
|
+
const errorGraded = (findings) => findings.filter((finding) => finding.severity === ERROR_SEVERITY);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Which row owns a lint finding: the one whose citation is the most specific.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} ruleId the lint's rule id
|
|
217
|
+
* @param {object[]} rows every row of the block
|
|
218
|
+
* @returns {string|null} the row id, or null when no row cites this rule
|
|
219
|
+
*/
|
|
220
|
+
function claimantOf(ruleId, rows) {
|
|
221
|
+
let claimant = null;
|
|
222
|
+
let specificity = -1;
|
|
223
|
+
let ambiguous = null;
|
|
224
|
+
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
for (const citation of citationsOf(row)) {
|
|
227
|
+
if (!covers(citation, ruleId)) continue;
|
|
228
|
+
if (citation.length > specificity) {
|
|
229
|
+
claimant = row.id;
|
|
230
|
+
specificity = citation.length;
|
|
231
|
+
ambiguous = null;
|
|
232
|
+
} else if (citation.length === specificity && row.id !== claimant) {
|
|
233
|
+
ambiguous = row.id;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (ambiguous !== null) {
|
|
239
|
+
throw new Error(`[DocsLintBridge] Two rows claim lint rule ${ruleId} equally - ${claimant} and `
|
|
240
|
+
+ `${ambiguous}. Fix: a finding is named by one row with one fix; make one citation more specific, `
|
|
241
|
+
+ 'or delete the second row.');
|
|
242
|
+
}
|
|
243
|
+
return claimant;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Run the lint over one tree, once.
|
|
248
|
+
*
|
|
249
|
+
* @param {{ lintScript: string, docsDir: string, treeConfig: string }} params absolute paths
|
|
250
|
+
* @returns {{findings: Array<object>, skipped: Array<object>}|{problem: string}}
|
|
251
|
+
*/
|
|
252
|
+
function lintOnce({ lintScript, docsDir, treeConfig }) {
|
|
253
|
+
const key = [lintScript, docsDir, treeConfig].join('');
|
|
254
|
+
if (answers.has(key)) return answers.get(key);
|
|
255
|
+
|
|
256
|
+
const answer = runLint({ lintScript, docsDir, treeConfig });
|
|
257
|
+
answers.set(key, answer);
|
|
258
|
+
return answer;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {{ lintScript: string, docsDir: string, treeConfig: string }} params
|
|
263
|
+
* @returns {{findings: Array<object>, skipped: Array<object>}|{problem: string}}
|
|
264
|
+
*/
|
|
265
|
+
function runLint({ lintScript, docsDir, treeConfig }) {
|
|
266
|
+
const run = spawnSync(process.execPath, [lintScript, ...lintArgumentsFor({ docsDir, treeConfig })], {
|
|
267
|
+
// The lint derives its own roots from its location, never from the caller's
|
|
268
|
+
// directory; the checkout root is passed anyway so two runs from two shells
|
|
269
|
+
// are the same run (`automation-gates.md` §1.1).
|
|
270
|
+
cwd: path.resolve(path.dirname(lintScript), '..', '..'),
|
|
271
|
+
encoding: 'utf8',
|
|
272
|
+
timeout: LINT_TIMEOUT_MS,
|
|
273
|
+
maxBuffer: LINT_MAX_BUFFER
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (run.error) return { problem: `${run.error.message} (${lintScript})` };
|
|
277
|
+
|
|
278
|
+
// 0 = clean, 1 = findings; anything else is the lint refusing to run, and its
|
|
279
|
+
// reason is on stderr. A run that ended there is NOT an empty finding list.
|
|
280
|
+
if (run.status !== 0 && run.status !== 1) {
|
|
281
|
+
const said = String(run.stderr || run.stdout || '').trim().split('\n').filter(Boolean).pop();
|
|
282
|
+
return { problem: `the lint exited ${run.status === null ? 'on a signal' : run.status}: ${said || 'no output'}` };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(run.stdout);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return { problem: `its JSON output could not be read: ${error.message}` };
|
|
290
|
+
}
|
|
291
|
+
if (!Array.isArray(parsed.findings) || !Array.isArray(parsed.skipped)) {
|
|
292
|
+
return { problem: 'its JSON output carries no "findings"/"skipped" arrays' };
|
|
293
|
+
}
|
|
294
|
+
return { findings: parsed.findings, skipped: parsed.skipped };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Is there a tree to lint at all?
|
|
299
|
+
*
|
|
300
|
+
* A service with no `docs/` is already a finding — `G-SETUP` demands the three
|
|
301
|
+
* installation documents — so a second row saying the same thing would be a
|
|
302
|
+
* duplicate with a different fix. Over a tree that exists and holds no document
|
|
303
|
+
* the lint has nothing to say, and neither has this row: PASS, not NOT RUN,
|
|
304
|
+
* because the question WAS asked and answered.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} docsDir absolute
|
|
307
|
+
* @returns {boolean}
|
|
308
|
+
*/
|
|
309
|
+
function carriesDocuments(docsDir) {
|
|
310
|
+
if (!fs.existsSync(docsDir) || !fs.statSync(docsDir).isDirectory()) return false;
|
|
311
|
+
|
|
312
|
+
const pending = [docsDir];
|
|
313
|
+
while (pending.length > 0) {
|
|
314
|
+
const current = pending.pop();
|
|
315
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
316
|
+
if (entry.isDirectory()) pending.push(path.join(current, entry.name));
|
|
317
|
+
else if (entry.name.endsWith('.md')) return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The tree-bound configuration path this uniform's documentation block is
|
|
325
|
+
* invoked with — read from the ROW that owns the file, never written a second
|
|
326
|
+
* time beside it.
|
|
327
|
+
*
|
|
328
|
+
* The block names the row (`tree_config_row`) and the row names the path
|
|
329
|
+
* (`C-LINT.path`). Until d.215d the literal `config/biz-docs-lint.tree.json`
|
|
330
|
+
* stood in both places, and a rename would have had to find both
|
|
331
|
+
* (`api/.claude/rules/single-source-of-truth.md`).
|
|
332
|
+
*
|
|
333
|
+
* @param {object} block the documentation block
|
|
334
|
+
* @returns {string} repository-relative path
|
|
335
|
+
*/
|
|
336
|
+
function treeConfigOf(block) {
|
|
337
|
+
const declared = block && block.tree_config_row;
|
|
338
|
+
if (typeof declared !== 'string' || declared.length === 0) {
|
|
339
|
+
throw new Error('[DocsLintBridge] The documentation block names no "tree_config_row" - the lint refuses '
|
|
340
|
+
+ 'a foreign tree that declares no budget of its own, so the check cannot be invoked without one. '
|
|
341
|
+
+ 'Fix: declare tree_config_row in manifests/biz-service.manifest.json, naming the row whose path '
|
|
342
|
+
+ 'is that budget.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const owner = (Array.isArray(block.required) ? block.required : []).find((row) => row.id === declared);
|
|
346
|
+
if (owner === undefined || typeof owner.path !== 'string' || owner.path.length === 0) {
|
|
347
|
+
throw new Error(`[DocsLintBridge] The documentation block names row "${declared}" as the owner of the `
|
|
348
|
+
+ 'tree budget, and no row of its "required" list carries that id with a "path". '
|
|
349
|
+
+ 'Fix: name a row that declares the tree config file, or correct tree_config_row in '
|
|
350
|
+
+ 'manifests/biz-service.manifest.json.');
|
|
351
|
+
}
|
|
352
|
+
return owner.path;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Ask the lint about one service tree, once, for whichever row is asking.
|
|
357
|
+
*
|
|
358
|
+
* Three answers, and every caller words them in its own sentence: the tree has
|
|
359
|
+
* nothing to lint, the question could not be decided (with the reason), or the
|
|
360
|
+
* lint's own payload.
|
|
361
|
+
*
|
|
362
|
+
* @param {{ block: object, serviceRoot: string, workspaceRoot: string }} params
|
|
363
|
+
* @returns {{nothingToLint: true}
|
|
364
|
+
* |{undecidable: {relative: string, reason: string}}
|
|
365
|
+
* |{answer: {findings: Array<object>, skipped: Array<object>}}}
|
|
366
|
+
*/
|
|
367
|
+
function askLint({ block, serviceRoot, workspaceRoot }) {
|
|
368
|
+
const docsDir = path.join(serviceRoot, DOCS_DIR);
|
|
369
|
+
if (!carriesDocuments(docsDir)) return { nothingToLint: true };
|
|
370
|
+
|
|
371
|
+
// The absent tree config is `C-LINT`'s finding, with its own fix; the
|
|
372
|
+
// sentence a caller builds from this is about the question that could not be
|
|
373
|
+
// decided because of it. The shape `contractBridge.js` uses for the
|
|
374
|
+
// unreadable contract.
|
|
375
|
+
const treeConfigRelative = treeConfigOf(block);
|
|
376
|
+
const treeConfig = path.join(serviceRoot, ...treeConfigRelative.split('/'));
|
|
377
|
+
if (!fs.existsSync(treeConfig)) {
|
|
378
|
+
return {
|
|
379
|
+
undecidable: {
|
|
380
|
+
relative: treeConfigRelative,
|
|
381
|
+
reason: 'the documentation lint refuses a tree that declares no budget of its own, '
|
|
382
|
+
+ 'and this file is not there'
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const answer = lintOnce({
|
|
388
|
+
lintScript: resolveWorkspacePath(workspaceRoot, LINT_SCRIPT),
|
|
389
|
+
docsDir,
|
|
390
|
+
treeConfig
|
|
391
|
+
});
|
|
392
|
+
if (answer.problem !== undefined) return { undecidable: { relative: DOCS_DIR, reason: answer.problem } };
|
|
393
|
+
|
|
394
|
+
return { answer };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const docsLint = Object.freeze({
|
|
398
|
+
scope: 'bearer',
|
|
399
|
+
requires: Object.freeze(['rule']),
|
|
400
|
+
|
|
401
|
+
requiresSiblings: () => [LINT_DIR],
|
|
402
|
+
|
|
403
|
+
describeNotRun() {
|
|
404
|
+
return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run`;
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @param {{ row: object, block: object, serviceRoot: string, workspaceRoot: string }} params
|
|
409
|
+
* @returns {Array<{where: string, what: string}>}
|
|
410
|
+
*/
|
|
411
|
+
run({ row, block, serviceRoot, workspaceRoot }) {
|
|
412
|
+
const rows = rowsOf(block);
|
|
413
|
+
const citations = citationsOf(row);
|
|
414
|
+
const place = (relative) => whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative });
|
|
415
|
+
|
|
416
|
+
const asked = askLint({ block, serviceRoot, workspaceRoot });
|
|
417
|
+
if (asked.nothingToLint === true) return [];
|
|
418
|
+
if (asked.undecidable !== undefined) {
|
|
419
|
+
return [{
|
|
420
|
+
where: place(asked.undecidable.relative),
|
|
421
|
+
what: `${row.rule} could not be decided — ${asked.undecidable.reason}`
|
|
422
|
+
}];
|
|
423
|
+
}
|
|
424
|
+
const answer = asked.answer;
|
|
425
|
+
|
|
426
|
+
const found = answer.findings
|
|
427
|
+
.filter((finding) => claimantOf(finding.rule, rows) === row.id)
|
|
428
|
+
.map((finding) => ({
|
|
429
|
+
where: `${place(`${DOCS_DIR}/${finding.file}`)}:${finding.line}`,
|
|
430
|
+
what: `${finding.rule}: ${summarise(finding.message)}`
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
// A rule the lint could not evaluate is not a rule that passed — and it is
|
|
434
|
+
// not a FINDING either, which is what it used to be reported as. Measured
|
|
435
|
+
// 2026-09-10 over `git archive HEAD` into a checkout with no `api_biz`
|
|
436
|
+
// beside it (the shape of the CI job): the template mirror came back NOT
|
|
437
|
+
// DEPLOYABLE on two rows whose bans nobody had violated, because the
|
|
438
|
+
// evidence probe of `F002:http-ports` reads `api_biz/*/docker-compose.yml`
|
|
439
|
+
// and there was none to read. `U-ORPHAN` in the same run said the honest
|
|
440
|
+
// thing — NOT RUN, sibling root absent — and so does this now.
|
|
441
|
+
//
|
|
442
|
+
// The mapping is the linter's own two channels onto this uniform's two: its
|
|
443
|
+
// `findings` are findings, its `skipped` list is the NOT RUN one (that is
|
|
444
|
+
// what `--allow-missing-siblings` fills, and what its own text output prints
|
|
445
|
+
// as NOT RUN lines). A tree the linter REFUSES outright is neither — that
|
|
446
|
+
// is `answer.problem` above, and the absent tree config before it, both of
|
|
447
|
+
// which stay findings with a fix somebody can carry out.
|
|
448
|
+
//
|
|
449
|
+
// Collapsed into one reason per row: a checkout without siblings makes a
|
|
450
|
+
// dozen bans undecidable at once, and twelve identical sentences say nothing
|
|
451
|
+
// the first one does not.
|
|
452
|
+
const undecided = answer.skipped
|
|
453
|
+
.filter((entry) => citations.some((citation) => covers(citation, entry.rule)))
|
|
454
|
+
.filter((entry) => claimantOf(entry.rule, rows) === row.id);
|
|
455
|
+
|
|
456
|
+
if (undecided.length === 0) return found;
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
findings: found,
|
|
460
|
+
notRun: `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
|
|
461
|
+
+ `${undecided[0].reason}`
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* `D-LINT` — the row that says the tree is clean, rather than clean of the five
|
|
468
|
+
* classes the rows above name.
|
|
469
|
+
*
|
|
470
|
+
* The rows above exist for their `fix` sentences: a localhost port, a dead
|
|
471
|
+
* script citation and a missing header are three different repairs, and a
|
|
472
|
+
* reader told only "the lint objects" would have to go and ask which. What they
|
|
473
|
+
* do NOT buy is coverage. Measured 2026-09-09 over the eight repositories: 80
|
|
474
|
+
* lint findings, of which 16 fell to a row — the other 64 were raised by rules
|
|
475
|
+
* this uniform stayed silent about, so a tree the documentation gate called
|
|
476
|
+
* broken came back DEPLOYABLE here. That silence is the defect
|
|
477
|
+
* `automation-gates.md` §5 names, and this row closes it: every error-graded
|
|
478
|
+
* finding the rows above do not already claim lands here, with the lint's own
|
|
479
|
+
* file, line and message.
|
|
480
|
+
*
|
|
481
|
+
* Two properties keep it honest and keep it from becoming a second rail:
|
|
482
|
+
*
|
|
483
|
+
* - **it decides nothing.** The rules are BIZ-DOCS's, the grading is
|
|
484
|
+
* BIZ-DOCS's, and this row cites `DOC-STANDARD.md` rather than restating a
|
|
485
|
+
* single rule (confirmation `biz-service-manifest` 004 point 2). It cites no
|
|
486
|
+
* rule id at all, which is why it is a check of its own: a citation list
|
|
487
|
+
* here would have to be kept equal to the linter's, and would fall behind it
|
|
488
|
+
* the first time BIZ-DOCS wrote a rule;
|
|
489
|
+
* - **one finding still lands on exactly one row.** A finding a citing row
|
|
490
|
+
* claims is that row's, with that row's fix; this one takes what is left.
|
|
491
|
+
*
|
|
492
|
+
* @see api/docs/biz/DOC-STANDARD.md
|
|
493
|
+
*/
|
|
494
|
+
const docsLintClean = Object.freeze({
|
|
495
|
+
scope: 'bearer',
|
|
496
|
+
requires: Object.freeze([]),
|
|
497
|
+
|
|
498
|
+
requiresSiblings: () => [LINT_DIR],
|
|
499
|
+
|
|
500
|
+
describeNotRun() {
|
|
501
|
+
return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run`;
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* @param {{ block: object, serviceRoot: string, workspaceRoot: string }} params
|
|
506
|
+
* @returns {Array<{where: string, what: string}>|{findings: Array<object>, notRun: string}}
|
|
507
|
+
*/
|
|
508
|
+
run({ block, serviceRoot, workspaceRoot }) {
|
|
509
|
+
const rows = rowsOf(block);
|
|
510
|
+
const place = (relative) => whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative });
|
|
511
|
+
|
|
512
|
+
const asked = askLint({ block, serviceRoot, workspaceRoot });
|
|
513
|
+
if (asked.nothingToLint === true) return [];
|
|
514
|
+
if (asked.undecidable !== undefined) {
|
|
515
|
+
return [{
|
|
516
|
+
where: place(asked.undecidable.relative),
|
|
517
|
+
what: `no finding could be decided — ${asked.undecidable.reason}`
|
|
518
|
+
}];
|
|
519
|
+
}
|
|
520
|
+
const answer = asked.answer;
|
|
521
|
+
|
|
522
|
+
const found = errorGraded(answer.findings)
|
|
523
|
+
.filter((finding) => claimantOf(finding.rule, rows) === null)
|
|
524
|
+
.map((finding) => ({
|
|
525
|
+
where: `${place(`${DOCS_DIR}/${finding.file}`)}:${finding.line}`,
|
|
526
|
+
what: `${finding.rule}: ${summarise(finding.message)}`
|
|
527
|
+
}));
|
|
528
|
+
|
|
529
|
+
// Same two channels as the citing rows: a rule the lint could not evaluate
|
|
530
|
+
// is NOT RUN, never a silent pass and never a finding nobody can fix.
|
|
531
|
+
const undecided = answer.skipped.filter((entry) => claimantOf(entry.rule, rows) === null);
|
|
532
|
+
if (undecided.length === 0) return found;
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
findings: found,
|
|
536
|
+
notRun: `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
|
|
537
|
+
+ `${undecided[0].reason}`
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
module.exports = {
|
|
543
|
+
checks: [
|
|
544
|
+
{ name: 'docs-lint', check: docsLint },
|
|
545
|
+
{ name: 'docs-lint-clean', check: docsLintClean }
|
|
546
|
+
],
|
|
547
|
+
claimantOf,
|
|
548
|
+
citationsOf,
|
|
549
|
+
covers,
|
|
550
|
+
errorGraded,
|
|
551
|
+
LINT_SCRIPT,
|
|
552
|
+
LINT_DIR
|
|
553
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `file-absent` — the file the uniform forbids must not be in the repository.
|
|
5
|
+
*
|
|
6
|
+
* The whole rule is the row: which path, why it is forbidden, and the command
|
|
7
|
+
* that removes it. This module only looks.
|
|
8
|
+
*
|
|
9
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §5
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const check = Object.freeze({
|
|
16
|
+
scope: 'service',
|
|
17
|
+
requires: Object.freeze(['path']),
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ row: object, serviceRoot: string }} params
|
|
21
|
+
* @returns {Array<{where: string, what: string}>}
|
|
22
|
+
*/
|
|
23
|
+
run({ row, serviceRoot }) {
|
|
24
|
+
const target = path.join(serviceRoot, ...row.path.split('/'));
|
|
25
|
+
if (!fs.existsSync(target)) return [];
|
|
26
|
+
|
|
27
|
+
const because = row.why ? `: ${row.why}` : '';
|
|
28
|
+
return [{
|
|
29
|
+
where: row.path,
|
|
30
|
+
what: `file present — this uniform forbids it${because}`
|
|
31
|
+
}];
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
module.exports = { name: 'file-absent', check };
|