@onlineapps/conn-orch-validator 7.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2582 -2
- package/README.md +1038 -4
- 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 +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- 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 +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- 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 +140 -9
- 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 +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +409 -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 +22 -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 +123 -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
package/src/cli/biz-ci-gate.js
CHANGED
|
@@ -19,13 +19,19 @@ const {
|
|
|
19
19
|
DEFAULT_REPORT_DIR_RELATIVE_PATH,
|
|
20
20
|
runIntegrationSuite,
|
|
21
21
|
} = require('../utils/integrationRun');
|
|
22
|
-
const { verifyDeployContract } = require('../utils/deployContract');
|
|
22
|
+
const { verifyDeployContract, DEPLOY_CONTRACT_SCOPE } = require('../utils/deployContract');
|
|
23
23
|
const { verifyInstallContract } = require('../utils/installContract');
|
|
24
24
|
const {
|
|
25
25
|
verifyEnvCompleteness,
|
|
26
26
|
ENV_SCAN_SCOPE,
|
|
27
27
|
ENV_SCAN_BLIND_SPOTS,
|
|
28
28
|
} = require('../utils/envContract');
|
|
29
|
+
const {
|
|
30
|
+
verifyTestCoverage,
|
|
31
|
+
TEST_COVERAGE_SCOPE,
|
|
32
|
+
TEST_COVERAGE_NOT_MEASURED,
|
|
33
|
+
PLATFORM_TEST_FILE_PATTERN,
|
|
34
|
+
} = require('../utils/testCoverageContract');
|
|
29
35
|
const { runPreValidation } = require('../utils/preValidation');
|
|
30
36
|
const { checkLibCompat, loadLibrarySet } = require('../utils/libCompat');
|
|
31
37
|
const { buildSchema } = require('../utils/setupDatabase');
|
|
@@ -63,18 +69,26 @@ Usage:
|
|
|
63
69
|
|
|
64
70
|
Commands:
|
|
65
71
|
verify-contract Validate integration-contract.json schema, connector flags,
|
|
66
|
-
the deploy contract (
|
|
67
|
-
the env contract.
|
|
72
|
+
the deploy contract (${DEPLOY_CONTRACT_SCOPE}), the installation contract,
|
|
73
|
+
the env contract AND the test-coverage contract.
|
|
68
74
|
verify-env-contract Env contract only: every environment variable the service
|
|
69
75
|
repository visibly reads is declared in the contract's env
|
|
70
76
|
block or covered by a config placeholder / required connector.
|
|
71
77
|
Prints its measurement boundary on every run.
|
|
72
|
-
verify-
|
|
78
|
+
verify-test-coverage Test-coverage contract only: every test file the service jest
|
|
79
|
+
config matches is run by the test:all chain or declared in the
|
|
80
|
+
contract's stackTiers with the reason it cannot be — and every
|
|
81
|
+
platform test file on disk (${PLATFORM_TEST_FILE_PATTERN}) is
|
|
82
|
+
matched by that config in the first place.
|
|
83
|
+
verify-deploy-contract Deploy contract only (${DEPLOY_CONTRACT_SCOPE}): image pin, deploy sequence,
|
|
73
84
|
Node major agreement, no published ports, full commit SHA,
|
|
74
85
|
CI database engine.
|
|
75
|
-
verify-install-contract Installation contract only: the docs/setup package every repo
|
|
76
|
-
carries, plus
|
|
77
|
-
|
|
86
|
+
verify-install-contract Installation contract only: the docs/80-setup package every repo
|
|
87
|
+
carries, plus — for every repo whose contract declares a database —
|
|
88
|
+
the BASELINE/SEED SQL tree, the four headers with VALUES from the
|
|
89
|
+
contract's closed vocabularies on every live migrations/**/*.sql
|
|
90
|
+
(archive/ and superseded/ excluded), and each BASELINE manifest
|
|
91
|
+
naming exactly the files the installer applies, in that order.
|
|
78
92
|
run-prevalidation Run the service's cookbooks offline against mocked infrastructure
|
|
79
93
|
and write conn-runtime/validation-proof.json. Replaces the
|
|
80
94
|
per-service scripts/run-pre-validation.js.
|
|
@@ -95,6 +109,15 @@ Common options:
|
|
|
95
109
|
--contract <path> Contract path (absolute or relative to service root).
|
|
96
110
|
Default: ${DEFAULT_CONTRACT_RELATIVE_PATH}
|
|
97
111
|
|
|
112
|
+
run-prevalidation options:
|
|
113
|
+
--operation <regex> Run only the cookbooks holding a step whose operation
|
|
114
|
+
matches this JavaScript regular expression. The matching
|
|
115
|
+
cookbooks run WHOLE — a later step reads what an earlier
|
|
116
|
+
one wrote. A filtered run writes NO validation proof: a
|
|
117
|
+
proof is the claim that the whole cookbook set passed.
|
|
118
|
+
An expression matching nothing is an error, never an
|
|
119
|
+
empty pass.
|
|
120
|
+
|
|
98
121
|
verify-lib-compat options:
|
|
99
122
|
--libraries <path|url> Library SSOT source. Default: $LIBRARIES_SSOT_URL
|
|
100
123
|
Auth for an https source: $LIBRARIES_SSOT_TOKEN
|
|
@@ -109,8 +132,16 @@ wait-connectors options:
|
|
|
109
132
|
|
|
110
133
|
write-summary options:
|
|
111
134
|
--output <path> Summary artifact path. Default: <service-root>/ci/integration-signal.json
|
|
112
|
-
--gate-verdict <pass|fail>
|
|
113
|
-
|
|
135
|
+
--gate-verdict <pass|fail> Report a failure this summary cannot see for itself -
|
|
136
|
+
a step further up the chain that exited non-zero. It may
|
|
137
|
+
only make the verdict WORSE: "pass" over a check measured
|
|
138
|
+
as failing is refused, the verdict stays fail, and the
|
|
139
|
+
artefact's reason names the refusal.
|
|
140
|
+
--gate-reason <text> The step that failed. The job watched the chain of
|
|
141
|
+
ci:gate:* steps run, so it is the only party that knows
|
|
142
|
+
which one exited non-zero. Declaring a failing verdict
|
|
143
|
+
without it writes "unknown step failed" - never the name
|
|
144
|
+
of the last check in the row.
|
|
114
145
|
|
|
115
146
|
Executed test counts are read from ci/integration-run.json, written by
|
|
116
147
|
run-integration. Without that artifact the summary reports them as unknown —
|
|
@@ -138,7 +169,7 @@ function resolveContractOptions(options) {
|
|
|
138
169
|
*/
|
|
139
170
|
function reportDeployContract(result) {
|
|
140
171
|
if (result.ok) {
|
|
141
|
-
process.stdout.write(`[BizCiGate] OK deploy-contract (${result.service}) —
|
|
172
|
+
process.stdout.write(`[BizCiGate] OK deploy-contract (${result.service}) — ${DEPLOY_CONTRACT_SCOPE} satisfied\n`);
|
|
142
173
|
return true;
|
|
143
174
|
}
|
|
144
175
|
for (const violation of result.violations) {
|
|
@@ -180,6 +211,39 @@ function reportEnvContract(serviceName, contract, result) {
|
|
|
180
211
|
return false;
|
|
181
212
|
}
|
|
182
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Render the test-coverage result and signal whether it passed.
|
|
216
|
+
*
|
|
217
|
+
* The scope line is printed on every run, pass or fail, for the same reason the
|
|
218
|
+
* env contract prints one: a gate that says "OK" without saying what it looked
|
|
219
|
+
* at invites the reader to assume it looked at everything. This one compares the
|
|
220
|
+
* files jest matches against the files the test:all chain and the declared stack
|
|
221
|
+
* tiers run — and it says out loud that it never checks whether a declared tier
|
|
222
|
+
* is actually run somewhere.
|
|
223
|
+
*/
|
|
224
|
+
function reportTestCoverage(serviceName, result) {
|
|
225
|
+
process.stdout.write(`[BizCiGate] test-coverage scope: ${TEST_COVERAGE_SCOPE}\n`);
|
|
226
|
+
process.stdout.write(`[BizCiGate] test-coverage NOT measured: ${TEST_COVERAGE_NOT_MEASURED}\n`);
|
|
227
|
+
|
|
228
|
+
if (result.ok) {
|
|
229
|
+
const tiers = result.stackTiers.length === 0
|
|
230
|
+
? 'no stack tier declared'
|
|
231
|
+
: result.stackTiers
|
|
232
|
+
.map((tier) => `${tier.script} (${tier.files.length} file(s)): ${tier.requires}`)
|
|
233
|
+
.join('; ');
|
|
234
|
+
process.stdout.write(`[BizCiGate] OK test-coverage (${serviceName}) — `
|
|
235
|
+
+ `${result.totals.matched} file(s) matched, ${result.totals.inTestAll} run by the test:all chain, `
|
|
236
|
+
+ `${result.totals.inStackTiers} in ${result.stackTiers.length} declared stack tier(s) — ${tiers}\n`);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const violation of result.violations) {
|
|
241
|
+
process.stderr.write(`[BizCiGate] FAIL ${serviceName} — ${violation.requirement} — ${violation.message}\n`);
|
|
242
|
+
}
|
|
243
|
+
process.stderr.write(`[BizCiGate] FAIL ${serviceName} — ${result.violations.length} test-coverage violation(s)\n`);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
183
247
|
function reportInstallContract(result) {
|
|
184
248
|
if (result.ok) {
|
|
185
249
|
const half = result.databaseChecked ? 'docs + SQL package' : 'docs (no database declared)';
|
|
@@ -202,9 +266,18 @@ function runSetupDb(options) {
|
|
|
202
266
|
const database = contractInfo.contract.database;
|
|
203
267
|
|
|
204
268
|
if (!database) {
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
269
|
+
// NOT APPLICABLE, not OK. They are different facts for whoever reads the job
|
|
270
|
+
// log after a deploy went wrong: OK says the schema was built, this says
|
|
271
|
+
// there is no schema to build and names the file that decided so. Reporting
|
|
272
|
+
// a success for work that never happened is the false guarantee
|
|
273
|
+
// `.claude/rules/automation-gates.md` §5 names, and it is why the step read
|
|
274
|
+
// as "does nothing" (BIZ-META 2026-09-08).
|
|
275
|
+
//
|
|
276
|
+
// Exit stays 0: a service without a database owes no schema, so there is
|
|
277
|
+
// nothing here to fail on.
|
|
278
|
+
process.stdout.write(`[BizCiGate] NOT APPLICABLE setup-db — ${contractInfo.contractPath} declares no `
|
|
279
|
+
+ '"database" block, so this service has no schema to build. Nothing was done, and nothing was '
|
|
280
|
+
+ 'expected to be.\n');
|
|
208
281
|
return;
|
|
209
282
|
}
|
|
210
283
|
|
|
@@ -250,7 +323,7 @@ async function runVerifyLibCompat(options) {
|
|
|
250
323
|
async function runVerifyContract(options) {
|
|
251
324
|
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
252
325
|
|
|
253
|
-
// The deploy contract
|
|
326
|
+
// The deploy contract is part of the same mandatory gate: a service
|
|
254
327
|
// whose compose, deploy sequence, Node major or database engine breaks the
|
|
255
328
|
// platform contract must not reach the build stage. Folding it in here means
|
|
256
329
|
// a version bump activates it — no per-repo CI edit, nothing to forget.
|
|
@@ -282,6 +355,17 @@ async function runVerifyContract(options) {
|
|
|
282
355
|
process.exit(1);
|
|
283
356
|
}
|
|
284
357
|
|
|
358
|
+
// The test-coverage contract joins on the same argument a third time: a suite
|
|
359
|
+
// that no job runs fails nowhere, so it looks like coverage and proves nothing.
|
|
360
|
+
// hello-service carried three such suites for months (measured 2026-09-07,
|
|
361
|
+
// 17 files matched, 14 run). Folding it in means a version bump activates it.
|
|
362
|
+
if (!reportTestCoverage(
|
|
363
|
+
contractInfo.contract.serviceName || path.basename(contractInfo.serviceRoot),
|
|
364
|
+
verifyTestCoverage({ serviceRoot: contractInfo.serviceRoot, contract: contractInfo.contract })
|
|
365
|
+
)) {
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
|
|
285
369
|
// R6 belongs to the same gate for the same reason. It needs LIBRARIES_SSOT_URL;
|
|
286
370
|
// an unconfigured gate fails loudly rather than passing, so the CI variable
|
|
287
371
|
// must exist before any repo pins a version carrying this check.
|
|
@@ -299,38 +383,71 @@ async function runVerifyContract(options) {
|
|
|
299
383
|
|
|
300
384
|
|
|
301
385
|
/**
|
|
302
|
-
*
|
|
386
|
+
* Running cookbooks needs `tests/cookbooks/`, `operations.json` and the handler
|
|
387
|
+
* modules — nothing else. This command used to resolve the service's own
|
|
388
|
+
* `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()` and pass
|
|
389
|
+
* `service.url` to the runner, which never read it (`this.serviceUrl` appears
|
|
390
|
+
* nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer returns that
|
|
391
|
+
* field either — the owner removed `port` and `url` from the service shape
|
|
392
|
+
* (docs/governance/confirmations/biz-service-port-url.md 001), so the value had
|
|
393
|
+
* been `undefined` for every service on the platform. What the load still did
|
|
394
|
+
* was make a readable config, an installed wrapper and a resolvable `${ENV}`
|
|
395
|
+
* placeholder preconditions of a cookbook run that depends on none of them.
|
|
396
|
+
*/
|
|
397
|
+
/**
|
|
398
|
+
* End the process with this code, once everything written has left the buffers.
|
|
399
|
+
*
|
|
400
|
+
* `run-prevalidation` dispatches the service's own v3 handlers in THIS process,
|
|
401
|
+
* and a handler reaches its schema through a module-level Sequelize pool
|
|
402
|
+
* (`@onlineapps/conn-base-db` → `createSequelize`). That pool is the service's,
|
|
403
|
+
* not the runner's: at boot the very same dispatch happens inside
|
|
404
|
+
* `ServiceWrapper` phase 0.2 and the pool then serves the service for the rest
|
|
405
|
+
* of its life, so closing connectors after the cookbooks would break the boot
|
|
406
|
+
* this runner is part of. What differs is the LIFETIME, not the code — a
|
|
407
|
+
* one-shot command has nothing to serve afterwards.
|
|
303
408
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
409
|
+
* Left to end by itself, the command therefore hung: measured 2026-09-14 with a
|
|
410
|
+
* fixture handler leaving one socket open, every line of output printed, the
|
|
411
|
+
* proof written, and then nothing until a 15 s kill
|
|
412
|
+
* (`tests/unit/preValidationCliExit.integration.test.js`). In CI that is a job
|
|
413
|
+
* that runs to its timeout after the work succeeded.
|
|
414
|
+
*
|
|
415
|
+
* `process.exit()` can truncate a write to a pipe, which is how a summary line
|
|
416
|
+
* disappears from a CI log, so the exit waits for both streams to drain first.
|
|
417
|
+
*
|
|
418
|
+
* @param {number} code the exit status
|
|
307
419
|
*/
|
|
308
|
-
function
|
|
309
|
-
let
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
420
|
+
function exitWhenFlushed(code) {
|
|
421
|
+
let pending = 1;
|
|
422
|
+
const done = () => {
|
|
423
|
+
pending -= 1;
|
|
424
|
+
if (pending === 0) process.exit(code);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
428
|
+
if (stream.writableLength > 0) {
|
|
429
|
+
pending += 1;
|
|
430
|
+
stream.write('', done);
|
|
431
|
+
}
|
|
315
432
|
}
|
|
316
433
|
|
|
317
|
-
|
|
318
|
-
const config = ConfigLoader.loadAll({ basePath: serviceRoot, env: process.env });
|
|
319
|
-
return config.service.url;
|
|
434
|
+
done();
|
|
320
435
|
}
|
|
321
436
|
|
|
322
437
|
async function runRunPreValidation(options) {
|
|
323
438
|
const outcome = await runPreValidation({
|
|
324
439
|
serviceRoot: path.resolve(options.serviceRoot),
|
|
325
|
-
serviceUrl: resolveServiceUrl(path.resolve(options.serviceRoot)),
|
|
326
440
|
RunnerClass: require('../CookbookTestRunner'),
|
|
327
441
|
ProofGeneratorClass: require('../validators/ValidationProofGenerator'),
|
|
328
|
-
logger: console
|
|
442
|
+
logger: console,
|
|
443
|
+
operationFilter: options.operation
|
|
329
444
|
});
|
|
330
445
|
|
|
331
446
|
const { results } = outcome;
|
|
332
|
-
|
|
333
|
-
|
|
447
|
+
// Cookbooks and steps, each under its own name: the line used to print step
|
|
448
|
+
// counts under the word "cookbooks" (BIZ-converter 2026-08-27).
|
|
449
|
+
process.stdout.write(`[BizCiGate] cookbooks — ${results.cookbooks.passed}/${results.cookbooks.total} passed, `
|
|
450
|
+
+ `steps ${results.passed}/${results.total} passed, ${results.failed} failed (${results.duration}ms)\n`);
|
|
334
451
|
|
|
335
452
|
if (!outcome.ok) {
|
|
336
453
|
for (const step of outcome.failedSteps) {
|
|
@@ -341,11 +458,21 @@ async function runRunPreValidation(options) {
|
|
|
341
458
|
}
|
|
342
459
|
process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${results.failed} failed step(s); `
|
|
343
460
|
+ 'no validation proof written\n');
|
|
344
|
-
|
|
461
|
+
exitWhenFlushed(1);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (outcome.filteredBy !== null) {
|
|
466
|
+
process.stdout.write(`[BizCiGate] OK run-prevalidation — filtered by --operation `
|
|
467
|
+
+ `${JSON.stringify(outcome.filteredBy)}, no validation proof written `
|
|
468
|
+
+ `(a proof claims the whole cookbook set passed)\n`);
|
|
469
|
+
exitWhenFlushed(0);
|
|
470
|
+
return;
|
|
345
471
|
}
|
|
346
472
|
|
|
347
473
|
process.stdout.write(`[BizCiGate] OK run-prevalidation — proof ${outcome.proof.validationProof.slice(0, 16)}... `
|
|
348
474
|
+ `-> ${outcome.proofPath}\n`);
|
|
475
|
+
exitWhenFlushed(0);
|
|
349
476
|
}
|
|
350
477
|
|
|
351
478
|
/** Same checks, runnable on their own — for the cross-repo audit and local use. */
|
|
@@ -378,6 +505,17 @@ function runVerifyEnvContract(options) {
|
|
|
378
505
|
}
|
|
379
506
|
}
|
|
380
507
|
|
|
508
|
+
/** Same check, runnable on its own — for the cross-repo audit and local use. */
|
|
509
|
+
function runVerifyTestCoverage(options) {
|
|
510
|
+
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
511
|
+
if (!reportTestCoverage(
|
|
512
|
+
contractInfo.contract.serviceName || path.basename(contractInfo.serviceRoot),
|
|
513
|
+
verifyTestCoverage({ serviceRoot: contractInfo.serviceRoot, contract: contractInfo.contract })
|
|
514
|
+
)) {
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
381
519
|
function runVerifyIntegrationMinimum(options) {
|
|
382
520
|
const result = verifyIntegrationMinimum(options.serviceRoot, options.contractPath);
|
|
383
521
|
process.stdout.write(`[BizCiGate] OK verify-integration-minimum\n`);
|
|
@@ -526,6 +664,11 @@ async function main() {
|
|
|
526
664
|
return;
|
|
527
665
|
}
|
|
528
666
|
|
|
667
|
+
if (command === 'verify-test-coverage') {
|
|
668
|
+
runVerifyTestCoverage(options);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
529
672
|
if (command === 'verify-lib-compat') {
|
|
530
673
|
await runVerifyLibCompat(options);
|
|
531
674
|
return;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `npx oa-lint-scripts [--root <dir>] [--api-root <dir>] [--list-scope]
|
|
6
|
+
* [--files <path...>]` — the SCRIPTS-STANDARD header rules over one repository,
|
|
7
|
+
* or over the files a caller names.
|
|
8
|
+
*
|
|
9
|
+
* The rules are `src/lint/scripts/lintScripts.js`; this file owns the argument
|
|
10
|
+
* parsing, the report and the exit code, and nothing else. `api/scripts/ci/
|
|
11
|
+
* lint-scripts.mjs` calls `runCli` rather than re-printing the same lines, so
|
|
12
|
+
* the api run and a service run cannot grow two dialects of one report
|
|
13
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
14
|
+
*
|
|
15
|
+
* No baseline, no skip flag (`automation-gates.md` §3, §1.5).
|
|
16
|
+
*
|
|
17
|
+
* @see api/docs/standards/SCRIPTS-STANDARD.md
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const { lintScripts, SCRIPT_SCOPE, CITATION_SCOPE } = require('../lint/scripts/lintScripts');
|
|
23
|
+
const { API_CHECKOUT_ROOT } = require('../manifest/workspaceRoot');
|
|
24
|
+
|
|
25
|
+
/** The name every line of this report opens with; `scripts-lint.bats` reads it. */
|
|
26
|
+
const TOOL = 'lint-scripts';
|
|
27
|
+
|
|
28
|
+
/** Exit code for a run that could not start at all — distinct from a finding. */
|
|
29
|
+
const USAGE_EXIT = 2;
|
|
30
|
+
|
|
31
|
+
const USAGE = `${TOOL}.mjs [--root <dir>] [--api-root <dir>] [--list-scope] [--files <path...>]`;
|
|
32
|
+
|
|
33
|
+
/** Thrown instead of calling `process.exit()`; see `runCli`. */
|
|
34
|
+
class LintAbort extends Error {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} message
|
|
38
|
+
* @throws {LintAbort} always
|
|
39
|
+
*/
|
|
40
|
+
function die(message) {
|
|
41
|
+
console.error(`[${TOOL}] ERROR: ${message}`);
|
|
42
|
+
throw new LintAbort(message);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* TWO ROOTS, NEVER ONE. `root` is the repository being scanned; `apiRoot` is the
|
|
47
|
+
* api checkout an `api/…` citation names, which is a convention of the declaring
|
|
48
|
+
* text rather than a directory of the scanned tree. They coincide only when the
|
|
49
|
+
* tree being scanned IS the api checkout, and until d.401 the api wrapper
|
|
50
|
+
* asserted exactly that by handing `--api-root` the scanned root — so a run over
|
|
51
|
+
* a biz repository turned every `@see api/docs/…` into an S006 about a document
|
|
52
|
+
* that was there all along (BIZ-PROPERTY 2026-09-12).
|
|
53
|
+
*
|
|
54
|
+
* So the default comes from where this package LIES, the same derivation
|
|
55
|
+
* `workspaceRoot.js` already owns for the manifest's `api/…` paths: a source
|
|
56
|
+
* checkout answers with its own root, an installed copy answers `null` and the
|
|
57
|
+
* citation is reported UNRESOLVED rather than judged (`automation-gates.md` §5).
|
|
58
|
+
* An explicit `--api-root` still wins — explicit over implicit
|
|
59
|
+
* (`architecture-principles.md` §8).
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} argv the arguments after the script name
|
|
62
|
+
* @param {string} cwd the directory a bare run reads
|
|
63
|
+
* @returns {{root: string, apiRoot: string|null, listScope: boolean, files: string[]|null}}
|
|
64
|
+
*/
|
|
65
|
+
function parse(argv, cwd) {
|
|
66
|
+
let root = cwd;
|
|
67
|
+
let apiRoot = API_CHECKOUT_ROOT;
|
|
68
|
+
let listScope = false;
|
|
69
|
+
let files = null;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
72
|
+
if (argv[i] === '--root') root = path.resolve(argv[i + 1] === undefined ? '' : argv[++i]);
|
|
73
|
+
else if (argv[i] === '--api-root') apiRoot = path.resolve(argv[i + 1] === undefined ? '' : argv[++i]);
|
|
74
|
+
else if (argv[i] === '--list-scope') listScope = true;
|
|
75
|
+
else if (argv[i] === '--files') {
|
|
76
|
+
files = [];
|
|
77
|
+
while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) files.push(argv[++i]);
|
|
78
|
+
} else die(`Unknown option "${argv[i]}" - Usage: ${USAGE}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// An empty list is refused rather than treated as "no files": filtering a run
|
|
82
|
+
// down to nothing prints `0 finding(s)` while judging nothing, which is the
|
|
83
|
+
// false guarantee `automation-gates.md` §5 is about.
|
|
84
|
+
if (files !== null && files.length === 0) {
|
|
85
|
+
die(`Missing value - --files needs at least one path. Fix: name the files to judge `
|
|
86
|
+
+ `(--files scripts/x.sh tests/scripts/y.bats), or drop the option to run over the whole `
|
|
87
|
+
+ `scope. Usage: ${USAGE}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Two different questions: one asks which files this lint owns, the other
|
|
91
|
+
// asks about named files. Answering both in one run would make the exit code
|
|
92
|
+
// mean two things.
|
|
93
|
+
if (files !== null && listScope) {
|
|
94
|
+
die(`--list-scope and --files ask two different questions - pass one. Fix: run --list-scope `
|
|
95
|
+
+ `to learn the scope, then a second run with --files over the paths you picked. `
|
|
96
|
+
+ `Usage: ${USAGE}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { root, apiRoot, listScope, files };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One run, as a function, so the api wrapper is a call and not a copy.
|
|
104
|
+
*
|
|
105
|
+
* It returns the exit code rather than calling `process.exit()`: on a pipe Node
|
|
106
|
+
* writes stdout asynchronously, so exiting right after the report ends the
|
|
107
|
+
* process with the tail still buffered — measured as exactly 65536 bytes
|
|
108
|
+
* arriving of a larger payload (`api/tests/scripts/scripts-lint.bats`, "survives
|
|
109
|
+
* a pipe"). The caller sets `process.exitCode` and lets the process drain.
|
|
110
|
+
*
|
|
111
|
+
* @param {{argv?: string[], cwd?: string}} params
|
|
112
|
+
* @returns {number} 0 clean · 1 findings · 2 the tool itself could not run
|
|
113
|
+
*/
|
|
114
|
+
function runCli({ argv = [], cwd = process.cwd() } = {}) {
|
|
115
|
+
let options;
|
|
116
|
+
try {
|
|
117
|
+
options = parse(argv, cwd);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof LintAbort) return USAGE_EXIT;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { root, apiRoot, listScope, files } = options;
|
|
124
|
+
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = lintScripts({ root, apiRoot });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`[${TOOL}] ERROR: ${error.stack === undefined ? error.message : error.stack}`);
|
|
130
|
+
return USAGE_EXIT;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (listScope) {
|
|
134
|
+
for (const relative of [...result.scanned, ...result.citationScanned]) console.log(relative);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A scope root that is not there is a usage error HERE and an empty scope in
|
|
139
|
+
// the module: this CLI is pointed at a repository by a human who believes it
|
|
140
|
+
// has scripts, and answering "0 findings" to a typo is the silence
|
|
141
|
+
// `automation-gates.md` §5 is about.
|
|
142
|
+
if (result.scanned.length === 0 && !require('fs').existsSync(path.join(root, SCRIPT_SCOPE))) {
|
|
143
|
+
try {
|
|
144
|
+
die(`Scope root not found - ${path.join(root, SCRIPT_SCOPE)} does not exist. `
|
|
145
|
+
+ 'Fix: run from the repository root, or pass --root.');
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof LintAbort) return USAGE_EXIT;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `--files` NARROWS THE REPORT of a full run, and narrowing loses nothing
|
|
153
|
+
// here: every rule in `lintScripts` reads one file's own lines — `lintHeader`
|
|
154
|
+
// is handed the `lines` of a single script, S009 walks the comment lines of a
|
|
155
|
+
// single test — and the only thing read outside the file is whether an `@see`
|
|
156
|
+
// TARGET exists, which is a path rather than another script's content. So the
|
|
157
|
+
// findings of a named file are computable from that file alone, and the
|
|
158
|
+
// citation scope narrows together with the header scope.
|
|
159
|
+
//
|
|
160
|
+
// The scope itself is NOT re-decided here: the lists come from the rules
|
|
161
|
+
// module, which stays their single owner, and this is the same shape
|
|
162
|
+
// `api/scripts/ci/lint-infra-docs.mjs` has carried since it grew `--files`
|
|
163
|
+
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
164
|
+
let findings = result.findings;
|
|
165
|
+
if (files !== null) {
|
|
166
|
+
const inScope = new Set([...result.scanned, ...result.citationScanned]);
|
|
167
|
+
const named = new Set();
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
const relative = path.relative(root, path.resolve(root, file)).split(path.sep).join('/');
|
|
170
|
+
// A path this lint does not own — or one that is not on disk — would
|
|
171
|
+
// otherwise be answered with `0 finding(s)` about a file nobody read.
|
|
172
|
+
if (!inScope.has(relative)) {
|
|
173
|
+
try {
|
|
174
|
+
die(`--files path is not part of the lint scope - "${file}". Expected: an existing file under `
|
|
175
|
+
+ `${SCRIPT_SCOPE}/ or ${CITATION_SCOPE}/ of ${root}. Fix: name a path this lint owns `
|
|
176
|
+
+ `(--list-scope prints them), or drop --files; a run that reported "0 finding(s)" for a `
|
|
177
|
+
+ 'file it never read would be a false pass.');
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof LintAbort) return USAGE_EXIT;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
named.add(relative);
|
|
184
|
+
}
|
|
185
|
+
findings = findings.filter((finding) => named.has(finding.file));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const finding of findings) {
|
|
189
|
+
console.log(`ERROR ${finding.id} ${finding.file}:${finding.line} ${finding.message}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (result.unresolved.length > 0) {
|
|
193
|
+
// Counted over the whole run even under `--files`: an unresolved target is
|
|
194
|
+
// a path, and it carries no file to narrow it by. Said rather than dropped —
|
|
195
|
+
// a NOT RUN that disappears reads as a pass. Run from a source checkout the
|
|
196
|
+
// api root is known, so this line is empty there by construction; an
|
|
197
|
+
// installed copy lies in no checkout and says so.
|
|
198
|
+
console.log(`[${TOOL}] NOT RUN: ${result.unresolved.length} @see target(s) into the api checkout could `
|
|
199
|
+
+ `not be resolved (${result.unresolved.join(', ')})${files === null ? '' : ', counted over this run\'s '
|
|
200
|
+
+ 'whole scope rather than the named files'}. Fix: pass --api-root <api checkout>.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (files !== null) {
|
|
204
|
+
console.log(`[${TOOL}] ${findings.length} finding(s) across ${new Set(files.map((file) => path
|
|
205
|
+
.relative(root, path.resolve(root, file)))).size} file(s) named with --files, of `
|
|
206
|
+
+ `${result.scanned.length} file(s) in scope and `
|
|
207
|
+
+ `${result.citationScanned.length} file(s) in the ${CITATION_SCOPE} citation scope.`);
|
|
208
|
+
return findings.length > 0 ? 1 : 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(`[${TOOL}] ${result.findings.length} finding(s) across ${result.scanned.length} file(s) in scope, `
|
|
212
|
+
+ `${result.citationScanned.length} file(s) in the ${CITATION_SCOPE} citation scope.`);
|
|
213
|
+
|
|
214
|
+
return result.findings.length > 0 ? 1 : 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { runCli, TOOL, USAGE };
|
|
218
|
+
|
|
219
|
+
if (require.main === module) {
|
|
220
|
+
process.exitCode = runCli({ argv: process.argv.slice(2), cwd: process.cwd() });
|
|
221
|
+
}
|