@onlineapps/conn-orch-validator 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2582 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +409 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -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 (R1-R7), the installation contract AND
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-deploy-contract Deploy contract only (R1-R7): image pin, deploy sequence,
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 the BASELINE/SEED SQL tree required of every repo
77
- whose contract declares a database.
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> Override gate verdict
113
- --gate-reason <text> Override gate reason
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}) — R1-R7 satisfied\n`);
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
- // Skipping is explicit and visible — a service with no database says so by
206
- // omitting the block, and that is not the same as a failure to find one.
207
- process.stdout.write('[BizCiGate] OK setup-db — no database declared, nothing to build\n');
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 (R1-R7) is part of the same mandatory gate: a service
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
- * Resolve the service URL from the service's OWN config.
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
- * ConfigLoader lives in @onlineapps/service-wrapper (L4) and this package is L3,
305
- * so it is never a dependency here — it is resolved from the service being
306
- * validated, which is where it legitimately exists.
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 resolveServiceUrl(serviceRoot) {
309
- let wrapperPath;
310
- try {
311
- wrapperPath = require.resolve('@onlineapps/service-wrapper', { paths: [serviceRoot] });
312
- } catch {
313
- throw new Error('[PreValidation] Missing dependency - @onlineapps/service-wrapper is not '
314
- + `installed in ${serviceRoot}. Fix: run npm install in the service repository.`);
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
- const { ConfigLoader } = require(wrapperPath);
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
- process.stdout.write(`[BizCiGate] cookbooks — total ${results.total}, `
333
- + `passed ${results.passed}, failed ${results.failed} (${results.duration}ms)\n`);
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
- process.exit(1);
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
+ }