@onlineapps/conn-orch-validator 6.0.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +213 -13
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Deploy contract checks
|
|
4
|
+
* Deploy contract checks for a biz service repository.
|
|
5
5
|
*
|
|
6
6
|
* Requirements: api/docs/operations/biz-rework-deployment-requirements.md
|
|
7
7
|
* Database rules: ADR 0006 (api/docs/biz/80-decisions/)
|
|
8
|
+
* R8 permits and their reasons: api/docs/standards/tenant-allocation.md § Enforcement
|
|
8
9
|
*
|
|
9
10
|
* R1 the production compose pins its image immutably
|
|
10
11
|
* R2 the deploy resets to origin/production rather than merging
|
|
@@ -14,6 +15,11 @@
|
|
|
14
15
|
* R7 the CI database engine is the one the service actually talks to
|
|
15
16
|
* R8 tenant / workspace identity has exactly one source
|
|
16
17
|
*
|
|
18
|
+
* R6 is absent on purpose: the biz x infra library-compatibility gate lives in
|
|
19
|
+
* utils/libCompat.js (`verify-lib-compat`), a different command with a different
|
|
20
|
+
* input. The scope every message prints is rendered from DEPLOY_CONTRACT_SCOPE
|
|
21
|
+
* below, so it can neither omit a requirement nor claim one that is elsewhere.
|
|
22
|
+
*
|
|
17
23
|
* Pure module: reads the repository, returns a structured result. It renders
|
|
18
24
|
* nothing and exits nothing — the CLI owns presentation, this owns the rules.
|
|
19
25
|
* That split is what lets the api-side cross-repo audit reuse the same logic
|
|
@@ -26,6 +32,41 @@
|
|
|
26
32
|
const fs = require('fs');
|
|
27
33
|
const path = require('path');
|
|
28
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The requirements this module raises, and the ONLY place their extent is
|
|
37
|
+
* stated. Every message naming the scope renders it from here.
|
|
38
|
+
*
|
|
39
|
+
* The list had been written by hand into four messages, which then stopped one
|
|
40
|
+
* requirement short for months while R8 was already running: the gate announced
|
|
41
|
+
* less coverage than it enforced, and a reader had no way to tell. `add()` below refuses an
|
|
42
|
+
* id that is not on this list, so the two cannot drift apart again.
|
|
43
|
+
*/
|
|
44
|
+
const DEPLOY_CONTRACT_REQUIREMENTS = Object.freeze(['R1', 'R2', 'R3', 'R4', 'R5', 'R7', 'R8']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render the requirement ids as a scope: consecutive ids collapse into a range,
|
|
48
|
+
* a gap stays visible ("R1-R5, R7-R8"). The gap is the point — writing "R1-R8"
|
|
49
|
+
* would claim R6, which this module does not check.
|
|
50
|
+
*
|
|
51
|
+
* @param {ReadonlyArray<string>} ids requirement ids, ascending
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function formatRequirementScope(ids) {
|
|
55
|
+
const runs = [];
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
const number = Number(id.slice(1));
|
|
58
|
+
const open = runs[runs.length - 1];
|
|
59
|
+
if (open && number === open.end + 1) open.end = number;
|
|
60
|
+
else runs.push({ start: number, end: number });
|
|
61
|
+
}
|
|
62
|
+
return runs
|
|
63
|
+
.map(({ start, end }) => (start === end ? `R${start}` : `R${start}-R${end}`))
|
|
64
|
+
.join(', ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** "R1-R5, R7-R8" — what the CLI prints, derived, never typed. */
|
|
68
|
+
const DEPLOY_CONTRACT_SCOPE = formatRequirementScope(DEPLOY_CONTRACT_REQUIREMENTS);
|
|
69
|
+
|
|
29
70
|
const IDENTIFIER_ONLY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
30
71
|
const REQUIRED_MARKER = /[@:]\$\{[A-Za-z_][A-Za-z0-9_]*:\?[^}]*\}$/;
|
|
31
72
|
const BARE_VARIABLE = /\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}$/;
|
|
@@ -285,9 +326,10 @@ function checkDatabaseEngine(serviceRoot, ci, add) {
|
|
|
285
326
|
// src/, scripts/ no literal — production code and operational
|
|
286
327
|
// scripts take identity from ctx / arguments / env.
|
|
287
328
|
// tests/integration/ must take the namespace from the repo's shared
|
|
288
|
-
// test-namespace module
|
|
289
|
-
//
|
|
290
|
-
//
|
|
329
|
+
// test-namespace module, UNLESS it owns its database
|
|
330
|
+
// (see the third permit below). A positive rule,
|
|
331
|
+
// because the literal hides behind any name a test
|
|
332
|
+
// invents (`const T = 100`) and pattern-hunting loses.
|
|
291
333
|
// config/env-templates/ only shared.env may name the runner namespace.
|
|
292
334
|
// tests/unit/ exempt — pure in-memory mocks reach no database,
|
|
293
335
|
// so a literal there is not a safety boundary, and
|
|
@@ -302,16 +344,37 @@ const LITERAL_NAMESPACE = /\b(tenant_id|workspace_id)\s*(?::|={1,3})\s*\d+/;
|
|
|
302
344
|
const LITERAL_NAMESPACE_CONST =
|
|
303
345
|
/\b(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:tenant|workspace)[A-Za-z0-9_$]*\s*=\s*\d+/i;
|
|
304
346
|
|
|
347
|
+
/** `CREATE DATABASE \`x\``, `create schema x` — the file builds its own schema. */
|
|
348
|
+
const BUILDS_OWN_DATABASE = /\bcreate\s+(?:database|schema)\b/i;
|
|
349
|
+
/** `process.env.DB_NAME = …` and `process.env['DB_NAME'] = …`, assignment only. */
|
|
350
|
+
const REDIRECTS_DB_NAME =
|
|
351
|
+
/\bprocess\.env\s*(?:\.DB_NAME|\[\s*(['"])DB_NAME\1\s*\])\s*=(?!=)/;
|
|
352
|
+
|
|
353
|
+
/** `require('@onlineapps/conn-orch-validator')` — the package, not a path inside it. */
|
|
354
|
+
const REQUIRES_VALIDATOR_PACKAGE =
|
|
355
|
+
/\brequire\(\s*(['"])@onlineapps\/conn-orch-validator\1\s*\)/;
|
|
356
|
+
/** The throwaway-schema build, under the name the package index exports it by. */
|
|
357
|
+
const THROWAWAY_SCHEMA_HELPER = /\bcreateThrowawaySchema\b/;
|
|
358
|
+
|
|
305
359
|
const SCANNED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.json']);
|
|
306
360
|
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'coverage', 'dist', 'build']);
|
|
307
361
|
|
|
362
|
+
/** A call to either library helper: the own namespace, or the foreign one. */
|
|
363
|
+
const NAMESPACE_HELPER_CALL = /\bget(?:Foreign)?TestNamespace\s*\(/;
|
|
364
|
+
|
|
308
365
|
/** True for the repo's shared test-namespace module, whatever it is named. */
|
|
309
366
|
function isNamespaceModule(relativePath) {
|
|
310
367
|
return /(^|\/)(test-?namespace)[^/]*\.js$/i.test(relativePath);
|
|
311
368
|
}
|
|
312
369
|
|
|
313
370
|
/**
|
|
314
|
-
* True when the file actually CALLS the shared
|
|
371
|
+
* True when the file actually CALLS one of the shared helpers, on a line that
|
|
372
|
+
* executes — getTestNamespace() for the namespace the test owns,
|
|
373
|
+
* getForeignTestNamespace() for the one it must not see rows from. Both come
|
|
374
|
+
* from the library and both return an allowed environment class, so both are
|
|
375
|
+
* the fix R8 asks for; a test that needs the second one otherwise falls back to
|
|
376
|
+
* a literal (biz-hello asserted against the live customer tenant 101) or to
|
|
377
|
+
* arithmetic (99 + 1 = 100, LIVE).
|
|
315
378
|
*
|
|
316
379
|
* The permit used to be a whole-file text match, so a comment saying "TODO:
|
|
317
380
|
* switch to getTestNamespace()" excused the file from R8 entirely — a rule that
|
|
@@ -322,7 +385,12 @@ function isNamespaceModule(relativePath) {
|
|
|
322
385
|
function callsNamespaceHelper(text) {
|
|
323
386
|
return text
|
|
324
387
|
.split('\n')
|
|
325
|
-
.some((line) => !isCommentLine(line) &&
|
|
388
|
+
.some((line) => !isCommentLine(line) && NAMESPACE_HELPER_CALL.test(line));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** True when `pattern` matches on a line that executes, not one that describes. */
|
|
392
|
+
function matchesOnExecutingLine(text, pattern) {
|
|
393
|
+
return text.split('\n').some((line) => !isCommentLine(line) && pattern.test(line));
|
|
326
394
|
}
|
|
327
395
|
|
|
328
396
|
/** A literal inside a comment is prose — it cannot reach a database. */
|
|
@@ -331,6 +399,77 @@ function isCommentLine(line) {
|
|
|
331
399
|
return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
332
400
|
}
|
|
333
401
|
|
|
402
|
+
/**
|
|
403
|
+
* The other half of the same distinction. A comment is prose because of WHERE
|
|
404
|
+
* it sits; an error message is prose because of WHAT IT SAYS.
|
|
405
|
+
*
|
|
406
|
+
* "Inside quotes" alone is NOT the test, and must never become it: a query is
|
|
407
|
+
* a string too, and a query is exactly what reaches a database —
|
|
408
|
+
* `SELECT 1 FROM t WHERE tenant_id = 100` has to keep failing. What separates
|
|
409
|
+
* the two is the verb. Prose makes a CLAIM about the identifier ("workspace_id
|
|
410
|
+
* = 0 IS NOT a value"); code assigns a VALUE to it, and nothing follows the
|
|
411
|
+
* number but syntax.
|
|
412
|
+
*
|
|
413
|
+
* So the permit is granted only when the number is followed, inside the same
|
|
414
|
+
* string literal, by a finite verb from the closed list below. Everything else
|
|
415
|
+
* — end of string, `,`, `)`, ` AND `, ` order by ` — stays reported. The list
|
|
416
|
+
* is deliberately short and enumerated rather than clever: a gate whose verdict
|
|
417
|
+
* cannot be predicted from reading it is not a gate (`automation-gates.md` §1).
|
|
418
|
+
*
|
|
419
|
+
* Both conditions fail CLOSED. A string that spans several lines looks to this
|
|
420
|
+
* line-local scanner like no string at all, so its literal keeps its report.
|
|
421
|
+
*/
|
|
422
|
+
const PROSE_PREDICATE =
|
|
423
|
+
/^[\s,;:—-]*\b(is|are|was|were|has|have|had|means|meant|carries|carry|cannot|must|never|does|do|would|will|remains|stays)\b/i;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The end offset of the CLOSED string literal containing `index`, or -1 when
|
|
427
|
+
* that position is code. Line-local and escape-aware; a quote opened on an
|
|
428
|
+
* earlier line is invisible here, which is the safe direction (no permit).
|
|
429
|
+
*/
|
|
430
|
+
function enclosingStringEnd(line, index) {
|
|
431
|
+
let quote = null;
|
|
432
|
+
let start = -1;
|
|
433
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
434
|
+
const ch = line[i];
|
|
435
|
+
if (quote !== null && ch === '\\') { i += 1; continue; }
|
|
436
|
+
if (quote === null) {
|
|
437
|
+
if (ch === "'" || ch === '"' || ch === '`') { quote = ch; start = i; }
|
|
438
|
+
} else if (ch === quote) {
|
|
439
|
+
if (index > start && index < i) return i;
|
|
440
|
+
quote = null;
|
|
441
|
+
start = -1;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return -1;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* True when the literal at `index` is quoted inside an English sentence that
|
|
449
|
+
* talks ABOUT the identifier, rather than handing it a value.
|
|
450
|
+
*/
|
|
451
|
+
function isProseLiteral(line, index, matchText) {
|
|
452
|
+
const end = enclosingStringEnd(line, index);
|
|
453
|
+
if (end === -1) return false;
|
|
454
|
+
return PROSE_PREDICATE.test(line.slice(index + matchText.length, end));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* A tenant / workspace identity frozen into this line of executable code.
|
|
459
|
+
*
|
|
460
|
+
* One rail for both scan sites (src+scripts, and the shared test-namespace
|
|
461
|
+
* module), so the two permits — comment, prose — cannot drift apart.
|
|
462
|
+
*/
|
|
463
|
+
function freezesNamespaceIdentity(line) {
|
|
464
|
+
if (isCommentLine(line)) return false;
|
|
465
|
+
for (const pattern of [LITERAL_NAMESPACE, LITERAL_NAMESPACE_CONST]) {
|
|
466
|
+
const match = pattern.exec(line);
|
|
467
|
+
if (!match) continue;
|
|
468
|
+
if (!isProseLiteral(line, match.index, match[0])) return true;
|
|
469
|
+
}
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
334
473
|
function walkFiles(dir, base, out) {
|
|
335
474
|
let entries;
|
|
336
475
|
try {
|
|
@@ -384,8 +523,7 @@ function checkNamespaceLiterals(serviceRoot, add) {
|
|
|
384
523
|
if (text === null) continue;
|
|
385
524
|
const lines = text.split('\n');
|
|
386
525
|
for (let i = 0; i < lines.length; i += 1) {
|
|
387
|
-
if (
|
|
388
|
-
if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
|
|
526
|
+
if (!freezesNamespaceIdentity(lines[i])) continue;
|
|
389
527
|
add('R8', `${file.relative}:${i + 1} freezes a tenant / workspace id into code:\n`
|
|
390
528
|
+ ` ${lines[i].trim()}\n`
|
|
391
529
|
+ ' Fix: take it from ctx (handlers), from a CLI argument (scripts),\n'
|
|
@@ -409,8 +547,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
|
|
|
409
547
|
if (isNamespaceModule(file.relative)) {
|
|
410
548
|
const lines = text.split('\n');
|
|
411
549
|
for (let i = 0; i < lines.length; i += 1) {
|
|
412
|
-
if (
|
|
413
|
-
if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
|
|
550
|
+
if (!freezesNamespaceIdentity(lines[i])) continue;
|
|
414
551
|
add('R8', `${file.relative}:${i + 1} freezes the test namespace into a literal:\n`
|
|
415
552
|
+ ` ${lines[i].trim()}\n`
|
|
416
553
|
+ ' This module exists so the namespace has one source; that source must\n'
|
|
@@ -426,6 +563,61 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
|
|
|
426
563
|
// that itself reads the platform env (checked above).
|
|
427
564
|
if (callsNamespaceHelper(text)) continue;
|
|
428
565
|
if (/require\([^)]*test-?namespace[^)]*\)/i.test(text)) continue;
|
|
566
|
+
|
|
567
|
+
// Third permit — the file builds its throwaway through the library.
|
|
568
|
+
//
|
|
569
|
+
// `utils/throwawaySchema.js` is the one build for every DB-owning service:
|
|
570
|
+
// it refuses the schema the service declares, drops and recreates the
|
|
571
|
+
// throwaway it was given, names that schema on every statement, and strips
|
|
572
|
+
// the schema selects out of the migrations so a `USE oagen_<service>` line
|
|
573
|
+
// written for the install runner cannot redirect them. Where those
|
|
574
|
+
// statements land is therefore decided by code, not inferred from two
|
|
575
|
+
// strings — which is the whole difference between this permit and the one
|
|
576
|
+
// below it.
|
|
577
|
+
//
|
|
578
|
+
// Both halves are required and both on a line that executes: the package
|
|
579
|
+
// import AND the helper. A repo-local module that happens to export the same
|
|
580
|
+
// name earns nothing, because what is being trusted here is this package's
|
|
581
|
+
// build and nothing else (`automation-gates.md` §5 — a rule a mention
|
|
582
|
+
// satisfies checks nothing).
|
|
583
|
+
if (matchesOnExecutingLine(text, REQUIRES_VALIDATOR_PACKAGE)
|
|
584
|
+
&& matchesOnExecutingLine(text, THROWAWAY_SCHEMA_HELPER)) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Fourth permit — the file owns its database.
|
|
589
|
+
//
|
|
590
|
+
// The shared-namespace rule assumes the test writes into the service's live
|
|
591
|
+
// schema, where the namespace IS the safety boundary. A test that builds a
|
|
592
|
+
// throwaway database and drops it targets no shared namespace, so neither
|
|
593
|
+
// permit above means anything for it: the assertion "my identity does not
|
|
594
|
+
// collide with the test namespace" cannot fail, because getTestNamespace()
|
|
595
|
+
// only ever returns a value from ALLOWED_TENANT_CLASSES. A requirement whose
|
|
596
|
+
// only available compliance is an assertion that cannot fail documents no
|
|
597
|
+
// fix at all, and `automation-gates.md` §3 forbids reporting such a state.
|
|
598
|
+
//
|
|
599
|
+
// What decides where those queries land is DB_NAME, so that is what the
|
|
600
|
+
// permit asks for — positively, like the rest of R8, and not as a deny-list
|
|
601
|
+
// of the live database name: naming one database to avoid protects that one
|
|
602
|
+
// and nothing else (api/docs/standards/tenant-allocation.md § Enforcement).
|
|
603
|
+
// Both halves must be earned on a line that executes; a rule a mention
|
|
604
|
+
// satisfies checks nothing (`automation-gates.md` §5).
|
|
605
|
+
if (matchesOnExecutingLine(text, BUILDS_OWN_DATABASE)) {
|
|
606
|
+
if (matchesOnExecutingLine(text, REDIRECTS_DB_NAME)) continue;
|
|
607
|
+
add('R8', `${file.relative} creates its own database but never redirects DB_NAME to it.\n`
|
|
608
|
+
+ ' The schema it builds is therefore not the schema its queries reach:\n'
|
|
609
|
+
+ ' the service config still points at the live database, so the test\n'
|
|
610
|
+
+ ' writes there while appearing to own a throwaway.\n'
|
|
611
|
+
+ ' Expected: a file that builds its own schema also directs the service\n'
|
|
612
|
+
+ ' at it, so nothing it runs can land in live data.\n'
|
|
613
|
+
+ " Fix: set process.env.DB_NAME to that schema before the first require\n"
|
|
614
|
+
+ ' of the service\'s database config; or, if the file does target the\n'
|
|
615
|
+
+ ' shared namespace after all, take it from the repo\'s\n'
|
|
616
|
+
+ ' tests/integration/testNamespace.js instead.\n'
|
|
617
|
+
+ ' Rule: api/docs/standards/tenant-allocation.md § Enforcement');
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
429
621
|
add('R8', `${file.relative} names a tenant / workspace without taking it from the\n`
|
|
430
622
|
+ ' shared test-namespace module.\n'
|
|
431
623
|
+ ' An integration test writes into a real database, so the namespace it\n'
|
|
@@ -436,7 +628,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
|
|
|
436
628
|
}
|
|
437
629
|
|
|
438
630
|
/**
|
|
439
|
-
* Verify one biz repository against
|
|
631
|
+
* Verify one biz repository against the deploy contract.
|
|
440
632
|
*
|
|
441
633
|
* @param {string} serviceRoot absolute path to the biz repo
|
|
442
634
|
* @returns {{service: string, ok: boolean, violations: Array<{requirement: string, message: string}>}}
|
|
@@ -451,7 +643,15 @@ function verifyDeployContract(serviceRoot) {
|
|
|
451
643
|
}
|
|
452
644
|
|
|
453
645
|
const violations = [];
|
|
454
|
-
const add = (requirement, message) =>
|
|
646
|
+
const add = (requirement, message) => {
|
|
647
|
+
if (!DEPLOY_CONTRACT_REQUIREMENTS.includes(requirement)) {
|
|
648
|
+
throw new Error(`[DeployContract] Unknown requirement "${requirement}" - Expected one of `
|
|
649
|
+
+ `${DEPLOY_CONTRACT_REQUIREMENTS.join(', ')}. Fix: add the id to `
|
|
650
|
+
+ 'DEPLOY_CONTRACT_REQUIREMENTS in utils/deployContract.js, so the scope every message '
|
|
651
|
+
+ 'reports stays this module\'s own list.');
|
|
652
|
+
}
|
|
653
|
+
violations.push({ requirement, message });
|
|
654
|
+
};
|
|
455
655
|
|
|
456
656
|
const compose = readIfExists(path.join(serviceRoot, 'docker-compose.production.yml'));
|
|
457
657
|
if (compose === null) {
|
|
@@ -485,4 +685,4 @@ function verifyDeployContract(serviceRoot) {
|
|
|
485
685
|
};
|
|
486
686
|
}
|
|
487
687
|
|
|
488
|
-
module.exports = { verifyDeployContract };
|
|
688
|
+
module.exports = { verifyDeployContract, DEPLOY_CONTRACT_REQUIREMENTS, DEPLOY_CONTRACT_SCOPE };
|
package/src/utils/envContract.js
CHANGED
|
@@ -331,6 +331,52 @@ function collectEnvReads({ serviceRoot }) {
|
|
|
331
331
|
return { reads, dynamicSites, filesScanned: files.length };
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Principle 4: the completeness gate validates its own input before it judges.
|
|
336
|
+
*
|
|
337
|
+
* Both arguments used to be read on trust. Handed the loader envelope
|
|
338
|
+
* (`{serviceRoot, contractPath, contract}`) in place of the normalized contract,
|
|
339
|
+
* the scan found neither `requiredConnectors` nor `env`, so it compared the
|
|
340
|
+
* repository against an EMPTY declaration with no connector coverage — and over
|
|
341
|
+
* a repository whose reads are all M1-covered it answered `ok: true`. That is a
|
|
342
|
+
* PASS printed over a declaration the gate never read, which is the false
|
|
343
|
+
* guarantee automation-gates.md §5 calls a defect of the same severity as a
|
|
344
|
+
* wrong result (BIZ-pdfgen, 2026-08-28).
|
|
345
|
+
*
|
|
346
|
+
* The shape is not restated here. `requiredConnectors` is judged against the
|
|
347
|
+
* connector set this module already reads for M2 coverage, and the env block
|
|
348
|
+
* against `normalizeEnvDeclaration` (below, in verifyEnvCompleteness) — one
|
|
349
|
+
* definition each, no second copy to drift.
|
|
350
|
+
*/
|
|
351
|
+
function assertNormalizedContract(contract) {
|
|
352
|
+
if (!contract || typeof contract !== 'object' || Array.isArray(contract)) {
|
|
353
|
+
throw new Error('[EnvContract] Invalid contract - Expected the normalized integration contract object, '
|
|
354
|
+
+ `got ${Array.isArray(contract) ? 'an array' : typeof contract}. `
|
|
355
|
+
+ 'Fix: pass loadAndValidateIntegrationContract(serviceRoot).contract, not the envelope around it.');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const connectors = contract.requiredConnectors;
|
|
359
|
+
const missing = !connectors || typeof connectors !== 'object' || Array.isArray(connectors)
|
|
360
|
+
? Object.keys(CONNECTORS)
|
|
361
|
+
: Object.keys(CONNECTORS).filter((name) => typeof connectors[name] !== 'boolean');
|
|
362
|
+
|
|
363
|
+
if (missing.length > 0) {
|
|
364
|
+
throw new Error('[EnvContract] Invalid contract - requiredConnectors does not declare a boolean for '
|
|
365
|
+
+ `${missing.join(', ')}, so this is not a normalized integration contract. `
|
|
366
|
+
+ 'Fix: pass loadAndValidateIntegrationContract(serviceRoot).contract — the loader envelope it is '
|
|
367
|
+
+ 'wrapped in carries the contract under .contract, and checking against the envelope reads an '
|
|
368
|
+
+ 'empty declaration as a satisfied one.');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The scan reads the repository off disk, so the root has to be a path. */
|
|
373
|
+
function assertScannableServiceRoot(serviceRoot) {
|
|
374
|
+
if (typeof serviceRoot !== 'string' || serviceRoot.trim() === '') {
|
|
375
|
+
throw new Error('[EnvContract] Invalid serviceRoot - Expected a non-empty path to the repository root, '
|
|
376
|
+
+ `got ${typeof serviceRoot}. Fix: pass loadAndValidateIntegrationContract(serviceRoot).serviceRoot.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
334
380
|
/**
|
|
335
381
|
* COMPLETENESS: every environment name the repository visibly reads is either
|
|
336
382
|
* declared in the env block or covered by M1/M2.
|
|
@@ -343,14 +389,24 @@ function collectEnvReads({ serviceRoot }) {
|
|
|
343
389
|
* dynamicSites: Array<{file: string, line: number}>}}
|
|
344
390
|
*/
|
|
345
391
|
function verifyEnvCompleteness({ serviceRoot, contract }) {
|
|
392
|
+
assertScannableServiceRoot(serviceRoot);
|
|
393
|
+
assertNormalizedContract(contract);
|
|
394
|
+
|
|
346
395
|
const coverage = collectEnvCoverage({
|
|
347
396
|
serviceRoot,
|
|
348
397
|
requiredConnectors: contract.requiredConnectors
|
|
349
398
|
});
|
|
350
399
|
|
|
400
|
+
// The declaration is re-derived through the function that DEFINES it rather
|
|
401
|
+
// than read field by field: an env block that never went through
|
|
402
|
+
// normalizeEnvDeclaration is refused here instead of being silently counted
|
|
403
|
+
// as whatever `?.[listKey] ?? []` happens to find in it. A normalized block
|
|
404
|
+
// round-trips unchanged — same repository, same coverage, same result.
|
|
405
|
+
const declaration = normalizeEnvDeclaration(contract.env, { coverage });
|
|
406
|
+
|
|
351
407
|
const declared = new Set();
|
|
352
408
|
for (const listKey of ENV_LIST_KEYS) {
|
|
353
|
-
for (const item of
|
|
409
|
+
for (const item of declaration?.[listKey] ?? []) declared.add(item.name);
|
|
354
410
|
}
|
|
355
411
|
|
|
356
412
|
const { reads, dynamicSites, filesScanned } = collectEnvReads({ serviceRoot });
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a v3 handler reference IS, and what resolving one MEANS — one definition
|
|
5
|
+
* for the whole package, and the one the wrapper can adopt.
|
|
6
|
+
*
|
|
7
|
+
* Before this module the rule lived in five places. Four of them were copies of
|
|
8
|
+
* the same regex literal checking only the shape (`ServiceReadinessValidator`,
|
|
9
|
+
* `ValidationOrchestrator`, `ServiceStructureValidator`,
|
|
10
|
+
* `helpers/createServiceReadinessTests`); the fifth,
|
|
11
|
+
* `CookbookTestRunner.resolveOperation`, resolved a ref for real and did it
|
|
12
|
+
* more weakly than production: it split on `#` without counting the separators,
|
|
13
|
+
* so `a#b#c` was silently truncated, and it joined the path onto
|
|
14
|
+
* `<serviceRoot>/src` with no containment check, so `../outside#run` loaded and
|
|
15
|
+
* ran a module from outside the service source root. A second implementation of
|
|
16
|
+
* one concern is a defect by default (`change-discipline.md` § One rail per
|
|
17
|
+
* concern), and the weaker one had been the one dispatching cookbooks.
|
|
18
|
+
*
|
|
19
|
+
* The two exported functions are deliberately separate, and the split is what
|
|
20
|
+
* lets `service-wrapper`'s `HandlerLoader` take this over as an internal swap
|
|
21
|
+
* rather than an API change:
|
|
22
|
+
*
|
|
23
|
+
* - `parseHandlerRef` states the DECLARED FORM. `handlers/<path>#<export>` is
|
|
24
|
+
* owned by api/docs/biz/30-operations/schema-v3.md § handler, and every
|
|
25
|
+
* shape check in this package now reads `HANDLER_REF_PATTERN` instead of
|
|
26
|
+
* restating it.
|
|
27
|
+
* - `resolveHandlerModule` states RESOLUTION: containment inside
|
|
28
|
+
* `<serviceRoot>/src`, `require`, and an export that is a function. It does
|
|
29
|
+
* NOT demand the declared form, because `HandlerLoader.resolve()` accepts
|
|
30
|
+
* any relative path inside the source root today — narrowing that is a
|
|
31
|
+
* contract decision for the owner (`confirmation-triggers.md` §1.2), never
|
|
32
|
+
* a side effect of sharing code.
|
|
33
|
+
*
|
|
34
|
+
* Layer: L3 (Orchestration). Filesystem + require only, no cross-imports.
|
|
35
|
+
*
|
|
36
|
+
* @see api/docs/biz/10-invocation/handler-dispatch.md § `HandlerRegistry` — resolution rules
|
|
37
|
+
* @see api/docs/biz/30-operations/schema-v3.md § handler
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const path = require('path');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The declared form of a handler ref — the single literal in this package.
|
|
44
|
+
* The path charset carries no `.`, which is why a conformant ref can express
|
|
45
|
+
* neither a file extension nor a `..` traversal.
|
|
46
|
+
*/
|
|
47
|
+
const HANDLER_REF_PATTERN = /^handlers\/[a-zA-Z0-9_/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
48
|
+
|
|
49
|
+
/** How the ref is printed back to whoever has to fix it. */
|
|
50
|
+
function printRef(value) {
|
|
51
|
+
return typeof value === 'string' ? `"${value}"` : String(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The arity rule, shared by both exported functions: exactly one `#`, both
|
|
56
|
+
* halves non-empty. Kept private so there is one place that counts separators.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} handlerRef
|
|
59
|
+
* @returns {{relPath: string, exportName: string}}
|
|
60
|
+
*/
|
|
61
|
+
function splitHandlerRef(handlerRef) {
|
|
62
|
+
if (typeof handlerRef !== 'string' || handlerRef.length === 0) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[HandlerRef] handlerRef must be a non-empty string - Got: ${printRef(handlerRef)}. `
|
|
65
|
+
+ 'Fix: declare handler as "handlers/<path>#<exportName>" in config/service/operations.json.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const segments = handlerRef.split('#');
|
|
70
|
+
if (segments.length !== 2) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`[HandlerRef] handlerRef contains ${segments.length - 1} '#' separators - Expected exactly `
|
|
73
|
+
+ `one, as "handlers/<path>#<exportName>". Got: '${handlerRef}'. `
|
|
74
|
+
+ 'Fix: correct the handler ref in config/service/operations.json.'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [relPath, exportName] = segments;
|
|
79
|
+
if (!relPath || !exportName) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'[HandlerRef] handlerRef has an empty half - Expected "<relative/path>#<exportName>" '
|
|
82
|
+
+ `with both sides non-empty. Got: '${handlerRef}'. `
|
|
83
|
+
+ 'Fix: correct the handler ref in config/service/operations.json.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { relPath, exportName };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse a handler ref against the DECLARED FORM.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} handlerRef - as written in `config/service/operations.json`
|
|
94
|
+
* @returns {{relPath: string, exportName: string}} path relative to `src/`, and the named export
|
|
95
|
+
* @throws {Error} when the ref is absent, malformed, or outside the declared form
|
|
96
|
+
*/
|
|
97
|
+
function parseHandlerRef(handlerRef) {
|
|
98
|
+
const parts = splitHandlerRef(handlerRef);
|
|
99
|
+
|
|
100
|
+
if (!HANDLER_REF_PATTERN.test(handlerRef)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'[HandlerRef] handlerRef does not match the declared form - Expected '
|
|
103
|
+
+ '"handlers/<path>#<exportName>" (path relative to src/, no file extension). '
|
|
104
|
+
+ `Got: '${handlerRef}'. Fix: correct the handler ref in config/service/operations.json `
|
|
105
|
+
+ '- see api/docs/biz/30-operations/schema-v3.md § handler.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a handler ref to the exported function, the way the runtime does.
|
|
114
|
+
*
|
|
115
|
+
* The containment check runs on the RESOLVED location, not on the spelling of
|
|
116
|
+
* the reference, so `..` segments and an absolute path are both caught; the
|
|
117
|
+
* prefix carries the separator so a prefix-named sibling (`src-evil` beside
|
|
118
|
+
* `src`) cannot pass as a child. It is lexical — a symlink inside the source
|
|
119
|
+
* root pointing out of it is not detected, exactly as in `HandlerLoader`.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} args
|
|
122
|
+
* @param {string} args.serviceRoot - absolute path to the service root (holds `src/`)
|
|
123
|
+
* @param {string} args.handlerRef - `<relative/path>#<exportName>`
|
|
124
|
+
* @returns {{modulePath: string, exportName: string, handler: Function}}
|
|
125
|
+
* @throws {Error} when the ref escapes the source root, the module does not
|
|
126
|
+
* load, or the named export is not a function
|
|
127
|
+
*/
|
|
128
|
+
function resolveHandlerModule({ serviceRoot, handlerRef } = {}) {
|
|
129
|
+
if (typeof serviceRoot !== 'string' || serviceRoot.length === 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'[HandlerRef] serviceRoot is required - Expected an absolute path to the service root '
|
|
132
|
+
+ `(the directory holding src/). Got: ${serviceRoot}.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!path.isAbsolute(serviceRoot)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[HandlerRef] serviceRoot must be absolute - Got: ${serviceRoot}. `
|
|
138
|
+
+ 'Fix: pass the resolved service root (path.resolve of the directory holding src/).'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { relPath, exportName } = splitHandlerRef(handlerRef);
|
|
143
|
+
|
|
144
|
+
const baseDir = path.resolve(serviceRoot, 'src');
|
|
145
|
+
const baseDirPrefix = path.join(baseDir, path.sep);
|
|
146
|
+
const absModule = path.resolve(baseDir, relPath);
|
|
147
|
+
if (!absModule.startsWith(baseDirPrefix)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[HandlerRef] handlerRef escapes the service source root - '${handlerRef}' resolves to `
|
|
150
|
+
+ `'${absModule}', outside '${baseDir}'. Fix: use a path inside the service source root `
|
|
151
|
+
+ '(no ".." segments, not absolute).'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let modulePath;
|
|
156
|
+
let mod;
|
|
157
|
+
try {
|
|
158
|
+
modulePath = require.resolve(absModule);
|
|
159
|
+
mod = require(modulePath);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`[HandlerRef] Failed to require '${absModule}' resolved from '${handlerRef}' - `
|
|
163
|
+
+ 'Expected a loadable module at that path. Fix: ship the module in the service, or '
|
|
164
|
+
+ `correct the handler ref in config/service/operations.json. Cause: ${error.message}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const handler = mod[exportName];
|
|
169
|
+
if (typeof handler !== 'function') {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`[HandlerRef] Export '${exportName}' is not a function in '${modulePath}' - Expected the `
|
|
172
|
+
+ `module to expose '${exportName}' as a function (the ref '${handlerRef}' names it). `
|
|
173
|
+
+ `Fix: make '${exportName}' a function in that module, or correct the handler ref in `
|
|
174
|
+
+ 'config/service/operations.json.'
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { modulePath, exportName, handler };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { HANDLER_REF_PATTERN, parseHandlerRef, resolveHandlerModule };
|