@onlineapps/conn-orch-validator 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2558 -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 +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -0,0 +1,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 };