@onlineapps/conn-orch-validator 9.0.0 → 10.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 +373 -0
- package/README.md +83 -9
- package/docs/DESIGN.md +21 -7
- package/manifests/biz-service.manifest.json +28 -5
- package/package.json +2 -2
- package/src/CookbookTestRunner.js +84 -16
- package/src/ValidationOrchestrator.js +73 -20
- package/src/cli/biz-ci-gate.js +28 -14
- package/src/cli/oa-sync-template.js +23 -8
- package/src/cli/oa-validate.js +7 -1
- package/src/index.js +21 -13
- package/src/lint/scripts/lintScripts.js +65 -18
- package/src/manifest/checks/composeRunnerBlock.js +37 -20
- package/src/manifest/checks/discoveryOrphan.js +2 -1
- package/src/manifest/checks/docsLintBridge.js +79 -21
- package/src/manifest/checks/gitTracked.js +12 -1
- package/src/manifest/checks/libraryPackage.js +3 -1
- package/src/manifest/checks/libraryWorkspace.js +18 -3
- package/src/manifest/checks/readmeRegion.js +9 -1
- package/src/manifest/checks/serviceConfig.js +29 -12
- package/src/manifest/checks/serviceFiles.js +34 -7
- package/src/manifest/checks/serviceIdentityRows.js +3 -1
- package/src/manifest/checks/serviceRuntime.js +3 -1
- package/src/manifest/discovery.js +25 -7
- package/src/manifest/runManifest.js +58 -7
- package/src/manifest/workspaceRoot.js +91 -5
- package/src/sync/serviceTemplate.js +76 -7
- package/src/sync/sharedEnv.js +11 -4
- package/src/sync/uniformFiles.js +91 -21
- package/src/utils/bizCiGateContract.js +25 -1
- package/src/utils/installContract.js +46 -5
- package/src/utils/libCompat.js +39 -19
- package/src/utils/preValidation.js +56 -11
- package/src/utils/stepFailure.js +106 -19
- package/src/utils/testCoverageContract.js +60 -2
- package/src/utils/throwawaySchema.js +92 -7
- package/src/validatorIdentity.js +31 -0
- package/src/validators/ServiceStructureValidator.js +41 -15
- package/src/validators/ValidationProofGenerator.js +73 -34
- package/templates/business-service/.dockerignore +9 -1
- package/templates/business-service/.gitlab-ci.yml +91 -25
- package/templates/business-service/README.md +14 -5
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
- package/templates/business-service/config/env-templates/shared.env +7 -1
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
- package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
- package/templates/business-service/jest.config.js +9 -1
- package/templates/business-service/package.json.template +1 -1
- package/src/mocks/MockStorage.js +0 -188
package/src/utils/stepFailure.js
CHANGED
|
@@ -15,7 +15,27 @@
|
|
|
15
15
|
* `result.validationErrors` / `result.error` and was discarded at both ends,
|
|
16
16
|
* so a failed biz-hello boot could not be diagnosed from its own logs
|
|
17
17
|
* (automation-gates.md §5 — silence is a defect).
|
|
18
|
+
*
|
|
19
|
+
* `results.steps` holds TWO kinds of record and they are not both steps. A step
|
|
20
|
+
* result is one; the entry `CookbookTestRunner.runCookbooks` pushes for a file
|
|
21
|
+
* the format check rejected is the other — a whole cookbook that produced no
|
|
22
|
+
* step at all. Each says which it is in `kind`, written where the record is
|
|
23
|
+
* made, so nothing here infers it from a missing field
|
|
24
|
+
* (`.claude/rules/architecture-principles.md` §8, Explicit Over Implicit).
|
|
25
|
+
* Until d.516b the two were conflated, and a rejected file was reported as
|
|
26
|
+
* `cookbook "broken.json" step "step #1": …` — a step that does not exist, in
|
|
27
|
+
* the one line whose job is to find the case.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** A record that IS a cookbook step: a `step_id` names it, and one must. */
|
|
31
|
+
const KIND_STEP = 'step';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A record standing for a whole cookbook that never produced a step — the file
|
|
35
|
+
* could not be loaded, or the format check rejected it. It carries no
|
|
36
|
+
* `step_id`, because it is no step.
|
|
18
37
|
*/
|
|
38
|
+
const KIND_COOKBOOK_LOAD_FAILURE = 'cookbook-load-failure';
|
|
19
39
|
|
|
20
40
|
/**
|
|
21
41
|
* Which step this is — for a log line, an error list, anything a human reads.
|
|
@@ -26,29 +46,51 @@
|
|
|
26
46
|
* every conforming cookbook logged `Step undefined: FAILED …` — measured in the
|
|
27
47
|
* live biz-converter log on 2026-08-29 against a cookbook that is correct.
|
|
28
48
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
49
|
+
* **A step without `step_id` is a defect, not a step to be labelled some other
|
|
50
|
+
* way.** Until d.516b this fell back to the position, `step #3 (operation:
|
|
51
|
+
* convert)`, which was the right answer while the shape was still settling: the
|
|
52
|
+
* alternative on the day was `undefined`. It is the wrong answer now. Since
|
|
53
|
+
* `@onlineapps/cookbook-core` 5.0.0 the schema requires `step_id` on every task
|
|
54
|
+
* step (`schemas/cookbook.v2.schema.json` definitions.TaskStep.required lists
|
|
55
|
+
* step_id, type, service, operation), `readCookbookSteps` refuses a step
|
|
56
|
+
* spelling it `id`, `CookbookTestRunner.validateCookbook` refuses a cookbook
|
|
57
|
+
* whose step omits it, and the orchestrator will not run a task step that fails
|
|
58
|
+
* to name its operation either (`@onlineapps/conn-orch-orchestrator`
|
|
59
|
+
* § _requireStepOperation, d.460). So the only step that can still arrive here
|
|
60
|
+
* without one is a broken step, and inventing a name for it is the silent
|
|
61
|
+
* substitution `.claude/rules/architecture-principles.md` §3 forbids — it makes
|
|
62
|
+
* the diagnostics read as if the cookbook were fine.
|
|
63
|
+
*
|
|
64
|
+
* This is about STEPS. A cookbook that produced none is a different record and
|
|
65
|
+
* is never handed to this function (`describeStepFailureWithContext` below).
|
|
66
|
+
*
|
|
67
|
+
* The position is still in the message, because it is what finds the offending
|
|
68
|
+
* step in the file; it is now part of saying WHAT IS WRONG rather than a name
|
|
69
|
+
* standing in for one (§5, `[Context] Problem - Expected/Fix`).
|
|
33
70
|
*
|
|
34
71
|
* @param {Object} step - a cookbook step, or a step result carrying the same keys
|
|
35
72
|
* @param {number} index - zero-based position within the cookbook
|
|
36
|
-
* @returns {string} never empty, never "undefined"
|
|
73
|
+
* @returns {string} the step's own `step_id`; never empty, never "undefined"
|
|
74
|
+
* @throws {Error} when the step names no `step_id`
|
|
37
75
|
*/
|
|
38
76
|
function describeStepIdentity(step, index) {
|
|
39
77
|
if (!step || typeof step !== 'object') {
|
|
40
78
|
throw new Error('[stepFailure] step is required - Expected the cookbook step (or its result) to name in a message');
|
|
41
79
|
}
|
|
42
80
|
|
|
43
|
-
// `step_id` only. `
|
|
44
|
-
//
|
|
81
|
+
// `step_id` only. `readCookbookSteps` refuses a step spelling it `id`, so a
|
|
82
|
+
// step reaching here can no longer carry the retired name.
|
|
45
83
|
if (typeof step.step_id === 'string' && step.step_id.length > 0) return step.step_id;
|
|
46
84
|
|
|
47
85
|
const position = Number.isInteger(index) && index >= 0 ? index + 1 : 1;
|
|
48
86
|
const operation = typeof step.operation === 'string' && step.operation.length > 0
|
|
49
87
|
? ` (operation: ${step.operation})`
|
|
50
88
|
: '';
|
|
51
|
-
|
|
89
|
+
throw new Error(`[stepFailure] Step #${position}${operation} has no step_id - every step of a v2.1 `
|
|
90
|
+
+ 'cookbook is addressed by its own step_id, so there is no name to report this step under '
|
|
91
|
+
+ '(@onlineapps/cookbook-core schemas/cookbook.v2.schema.json definitions.TaskStep.required). '
|
|
92
|
+
+ `Fix: add "step_id" to step #${position} of the cookbook `
|
|
93
|
+
+ '(api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
52
94
|
}
|
|
53
95
|
|
|
54
96
|
/**
|
|
@@ -89,21 +131,66 @@ function describeStepFailure(stepResult) {
|
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* `results.
|
|
134
|
+
* How a failed record is NAMED, by what it is.
|
|
135
|
+
*
|
|
136
|
+
* `results.steps` carries two kinds and only one of them is a step (d.516b), so
|
|
137
|
+
* the name of a record is a decision about its `kind` — and one decision has one
|
|
138
|
+
* owner. Until d.523 it was made here for the orchestrator's error list and
|
|
139
|
+
* again, differently, in `utils/preValidation.js` for the CLI's, where the
|
|
140
|
+
* second copy did not make it at all: `src/cli/biz-ci-gate.js` printed
|
|
141
|
+
* `step_id` for both kinds, so a rejected cookbook reached the operator as
|
|
142
|
+
* `undefined` — the very shape d.516b removed from the other channel
|
|
143
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} record a step result, or a cookbook-load failure
|
|
146
|
+
* @returns {string} `cookbook "<file>"` or `step "<step_id>"`
|
|
147
|
+
*/
|
|
148
|
+
function describeRecordIdentity(record) {
|
|
149
|
+
if (!record || typeof record !== 'object') {
|
|
150
|
+
throw new Error('[stepFailure] record is required - Expected a failed entry of results.steps to name '
|
|
151
|
+
+ 'in a message. Fix: pass the record, not its reason.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (record.kind === KIND_COOKBOOK_LOAD_FAILURE) {
|
|
155
|
+
return `cookbook "${record.cookbook || 'unknown cookbook'}"`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return `step "${describeStepIdentity(record, record.stepIndex)}"`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same reason, prefixed with enough context to find the case. WHICH context
|
|
163
|
+
* depends on what the record is, and the record says so in `kind`:
|
|
95
164
|
*
|
|
96
|
-
*
|
|
165
|
+
* - a cookbook that never produced a step is named by the FILE and nothing
|
|
166
|
+
* else. There is no step in it, so there is no step to name;
|
|
167
|
+
* - a step failure is named by cookbook, step and operation, as before.
|
|
168
|
+
*
|
|
169
|
+
* This is what step 4 puts into `results.errors`.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} record - a step result, or a cookbook-load failure, carrying
|
|
172
|
+
* `cookbook` and its own `kind`
|
|
97
173
|
* @returns {string}
|
|
98
174
|
*/
|
|
99
|
-
function describeStepFailureWithContext(
|
|
100
|
-
const reason = describeStepFailure(
|
|
101
|
-
const cookbook =
|
|
175
|
+
function describeStepFailureWithContext(record) {
|
|
176
|
+
const reason = describeStepFailure(record);
|
|
177
|
+
const cookbook = record.cookbook || 'unknown cookbook';
|
|
178
|
+
|
|
179
|
+
if (record.kind === KIND_COOKBOOK_LOAD_FAILURE) {
|
|
180
|
+
return `${describeRecordIdentity(record)} could not be run: ${reason}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
102
183
|
// One owner for "which step": `unnamed step` used to be the answer for every
|
|
103
184
|
// conforming cookbook, because the result carried neither identifier.
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
return `cookbook "${cookbook}" step "${stepId}"${operation}: ${reason}`;
|
|
185
|
+
const operation = record.operation ? ` (${record.operation})` : '';
|
|
186
|
+
return `cookbook "${cookbook}" ${describeRecordIdentity(record)}${operation}: ${reason}`;
|
|
107
187
|
}
|
|
108
188
|
|
|
109
|
-
module.exports = {
|
|
189
|
+
module.exports = {
|
|
190
|
+
describeStepIdentity,
|
|
191
|
+
describeRecordIdentity,
|
|
192
|
+
describeStepFailure,
|
|
193
|
+
describeStepFailureWithContext,
|
|
194
|
+
KIND_STEP,
|
|
195
|
+
KIND_COOKBOOK_LOAD_FAILURE
|
|
196
|
+
};
|
|
@@ -43,6 +43,23 @@
|
|
|
43
43
|
* why the jest binary is the SERVICE's own (resolved from its `node_modules`),
|
|
44
44
|
* not this package's: a different jest version resolves a different `testMatch`.
|
|
45
45
|
*
|
|
46
|
+
* ## Why an empty set is a finding rather than a clean run
|
|
47
|
+
*
|
|
48
|
+
* Every comparison here iterates a set, so over an empty one it finds no
|
|
49
|
+
* counter-example — and an empty match is jest's own exit 0 with no output.
|
|
50
|
+
* A repository whose jest matched nothing therefore passed, and the gate printed
|
|
51
|
+
* `0 file(s) matched, 0 run by the test:all chain` as if that were a
|
|
52
|
+
* measurement. The same held one level down: a `test:all` step aimed at an empty
|
|
53
|
+
* directory listed 0 files, exited 0, and contributed nothing to either side of
|
|
54
|
+
* any comparison (both measured 2026-09-15, finding d.205).
|
|
55
|
+
*
|
|
56
|
+
* So an empty T, and an empty answer from any jest invocation the chain reaches,
|
|
57
|
+
* are named: a gate that reports OK over something it never observed is the
|
|
58
|
+
* false guarantee automation-gates.md §5 calls a defect of the same severity as
|
|
59
|
+
* a wrong result. Each is reported only where it is the ROOT — an empty T
|
|
60
|
+
* silences the per-step check, and so does a file on disk that the config does
|
|
61
|
+
* not match, because there every empty step is that defect's shadow.
|
|
62
|
+
*
|
|
46
63
|
* ## Why a declared tier exists at all
|
|
47
64
|
*
|
|
48
65
|
* `tests/e2e/mq-invocation` needs a live consumer on the service workflow queue.
|
|
@@ -77,7 +94,8 @@ const { spawnSync } = require('child_process');
|
|
|
77
94
|
|
|
78
95
|
const TEST_COVERAGE_SCOPE = 'files the service jest config matches (jest --listTests) vs. the files '
|
|
79
96
|
+ 'the test:all chain runs, decomposed npm run → jest, plus the scripts declared in contract stackTiers; '
|
|
80
|
-
+ 'and that same matched set vs. every platform test file present on disk (**/tests/**/*.test.js)'
|
|
97
|
+
+ 'and that same matched set vs. every platform test file present on disk (**/tests/**/*.test.js); '
|
|
98
|
+
+ 'and that neither the matched set nor any jest invocation the test:all chain reaches is empty';
|
|
81
99
|
|
|
82
100
|
const TEST_COVERAGE_NOT_MEASURED = 'whether a declared stack tier is ever RUN — the declaration says '
|
|
83
101
|
+ 'why it cannot run in test:all, never that something else runs it';
|
|
@@ -437,10 +455,31 @@ function verifyTestCoverage({ serviceRoot, contract, spawn = spawnSync }) {
|
|
|
437
455
|
const relative = (absolutePath) => path.relative(serviceRoot, absolutePath);
|
|
438
456
|
const violations = [];
|
|
439
457
|
const add = (requirement, message) => violations.push({ requirement, message });
|
|
458
|
+
const has = (requirement) => violations.some((violation) => violation.requirement === requirement);
|
|
440
459
|
|
|
441
460
|
// T — everything the service's jest config matches, asked without a path filter.
|
|
442
461
|
const matched = new Set(ask([]));
|
|
443
462
|
|
|
463
|
+
// T = ∅ — the gate ran and measured nothing.
|
|
464
|
+
//
|
|
465
|
+
// An empty match is jest's own exit 0 with no output (§ listTests), so nothing
|
|
466
|
+
// in the run distinguishes it from a healthy one. And every comparison below
|
|
467
|
+
// iterates a set: over an empty T, T \ (A ∪ B) and D \ T find no
|
|
468
|
+
// counter-example for the same reason an empty sum is zero. The gate printed
|
|
469
|
+
// "OK test-coverage — 0 file(s) matched, 0 run by the test:all chain" and the
|
|
470
|
+
// reader took it for a measurement (measured 2026-09-15, finding d.205).
|
|
471
|
+
//
|
|
472
|
+
// Reported here and nowhere else: when the config matches nothing, every step
|
|
473
|
+
// of the chain lists nothing too, and one defect with one fix must not arrive
|
|
474
|
+
// as one violation per step.
|
|
475
|
+
if (matched.size === 0) {
|
|
476
|
+
add('TEST_SET_EMPTY', 'Nothing measured - the service jest config matches 0 test files, '
|
|
477
|
+
+ 'so "jest --listTests" (the service\'s own jest, no path filter) returns an empty set, every '
|
|
478
|
+
+ 'comparison this gate makes is vacuously satisfied, and the run prints OK over a service whose '
|
|
479
|
+
+ `coverage was never observed. Fix: widen testMatch in jest.config.js to the platform pattern `
|
|
480
|
+
+ `(${PLATFORM_TEST_FILE_PATTERN}), or add the test files the service is missing.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
444
483
|
// D \ T — a suite that is on disk and in no jest answer. Checked FIRST because
|
|
445
484
|
// it is the only defect the other three cannot express: a file jest does not
|
|
446
485
|
// match is missing from both sides of every comparison below, so silence there
|
|
@@ -459,7 +498,26 @@ function verifyTestCoverage({ serviceRoot, contract, spawn = spawnSync }) {
|
|
|
459
498
|
for (const problem of chain.problems) add('TEST_ALL_CHAIN', problem.message);
|
|
460
499
|
const inTestAll = new Set();
|
|
461
500
|
for (const command of chain.commands) {
|
|
462
|
-
|
|
501
|
+
const files = ask(command.args);
|
|
502
|
+
|
|
503
|
+
// A step that lists nothing runs nothing — and says so with exit 0, so the
|
|
504
|
+
// script stays green in every pipeline while measuring nothing. It is what a
|
|
505
|
+
// narrowed pattern, a renamed directory or a moved suite leaves behind, and
|
|
506
|
+
// it is invisible to every other check here: a step contributing no file
|
|
507
|
+
// changes neither side of any comparison.
|
|
508
|
+
// Only where the matched set is the whole truth: T non-empty (checked above)
|
|
509
|
+
// and nothing on disk left outside it. A config that misses files on disk is
|
|
510
|
+
// already named, and every step aimed at those files lists 0 as a CONSEQUENCE
|
|
511
|
+
// of it — reporting the shadow beside the thing casting it buries the fix.
|
|
512
|
+
if (matched.size > 0 && !has('TEST_NOT_MATCHED') && files.length === 0) {
|
|
513
|
+
const invocation = ['jest', ...command.args].join(' ').trim();
|
|
514
|
+
add('TEST_SET_EMPTY', `Chain step runs no test - "${CHAIN_ENTRY_SCRIPT}" reaches `
|
|
515
|
+
+ `"${invocation}" (via ${command.viaScript}), and jest lists 0 files for it, so the step exits 0 `
|
|
516
|
+
+ `having measured nothing. Fix: point "${command.viaScript}" at the tests it is meant to run, `
|
|
517
|
+
+ `or remove the step from the ${CHAIN_ENTRY_SCRIPT} chain.`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const file of files) inTestAll.add(file);
|
|
463
521
|
}
|
|
464
522
|
|
|
465
523
|
// B — everything the declared stack tiers run.
|
|
@@ -40,10 +40,21 @@
|
|
|
40
40
|
*
|
|
41
41
|
* THE SCHEMA IS CHOSEN BY THE CALLER, NEVER BY THE FILE. A migration written
|
|
42
42
|
* for the install runner commonly opens with `USE \`oagen_<service>\`;` — the
|
|
43
|
-
* production schema by name
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* production schema by name (measured 2026-09-16: 69 live migrations, in
|
|
44
|
+
* api_biz/meta and api_biz/property). Applied verbatim against a throwaway, that
|
|
45
|
+
* one line sends the whole file into the live schema instead. Those lines are
|
|
46
|
+
* therefore stripped, and a schema select that survives the strip stops the
|
|
47
|
+
* build rather than being applied.
|
|
48
|
+
*
|
|
49
|
+
* Stripping is only half of it, because it moves the statements that carry no
|
|
50
|
+
* schema of their own. A name qualified with a schema — `INSERT INTO
|
|
51
|
+
* \`oagen_meta\`.persons …` — goes where it says whatever the runner selected,
|
|
52
|
+
* so it is refused outright rather than stripped: the rule is "the throwaway, or
|
|
53
|
+
* nothing", and the single exception is `information_schema`, which is read-only
|
|
54
|
+
* and whose `DATABASE()` follows the connection. Refusing is right even for a
|
|
55
|
+
* name that a strip could rewrite: what a test rewrites, it also has to be
|
|
56
|
+
* trusted to rewrite correctly, and a shim over somebody else's SQL is the
|
|
57
|
+
* workaround `change-discipline.md` forbids.
|
|
47
58
|
*
|
|
48
59
|
* @see src/utils/setupDatabase.js — the CI build, and the shared decisions
|
|
49
60
|
* @see src/utils/deployContract.js — R8, which permits a file that imports this
|
|
@@ -66,6 +77,37 @@ const LEADING_SCHEMA_SELECT = /^\s*USE\s+/i;
|
|
|
66
77
|
const SURVIVING_SCHEMA_SELECT = /(?:^|;)\s*USE\s+/i;
|
|
67
78
|
/** An SQL comment line — prose cannot select a schema. */
|
|
68
79
|
const SQL_COMMENT = /^\s*(?:--|#|\/\*|\*)/;
|
|
80
|
+
/** Everything a `--` or `#` opens on a line is prose too, wherever it starts. */
|
|
81
|
+
const TRAILING_COMMENT = /(--|#).*$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The positions where `a.b` is `<schema>.<object>` and not `<alias>.<column>`:
|
|
85
|
+
* a name in the TARGET position of a statement. Aliases live in expressions —
|
|
86
|
+
* after `ON`, after `SET`, in a `WHERE`, in the select list — and never
|
|
87
|
+
* directly after one of these keywords. That is why the check anchors on the
|
|
88
|
+
* keyword instead of matching every dotted token: measured over the live SQL of
|
|
89
|
+
* `api_biz/*` (2026-09-16), adding `ON` to the list turned 19 column references
|
|
90
|
+
* into findings, and matching dotted tokens outright would turn every alias in
|
|
91
|
+
* the tree into one.
|
|
92
|
+
*/
|
|
93
|
+
const QUALIFIED_TARGET = new RegExp(
|
|
94
|
+
'\\b(?:FROM|JOIN|INTO|UPDATE|TABLE|REFERENCES|TRUNCATE|VIEW|TRIGGER|PROCEDURE|FUNCTION|CALL)\\s+'
|
|
95
|
+
+ '(?:IF\\s+(?:NOT\\s+)?EXISTS\\s+)?'
|
|
96
|
+
+ '`?([A-Za-z0-9_$]+)`?\\s*\\.\\s*`?[A-Za-z0-9_$]+`?',
|
|
97
|
+
'gi'
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The one qualifier a file may name besides the throwaway: the server's own
|
|
102
|
+
* catalog. It is read-only — the server refuses a write to it — and reading it
|
|
103
|
+
* is how a portable migration asks about its own shape
|
|
104
|
+
* (`SELECT … FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()`,
|
|
105
|
+
* where `DATABASE()` is the schema the runner selected, i.e. the throwaway).
|
|
106
|
+
* Measured 2026-09-16: 22 live migration files in converter, ingest, meta and
|
|
107
|
+
* property read it that way, and every one of them is correct — a refusal there
|
|
108
|
+
* would be a finding nobody could fix (`automation-gates.md` §3).
|
|
109
|
+
*/
|
|
110
|
+
const CATALOG_SCHEMA = 'information_schema';
|
|
69
111
|
|
|
70
112
|
function require_(value, name, fix) {
|
|
71
113
|
if (value === undefined || value === null || value === '') {
|
|
@@ -74,13 +116,44 @@ function require_(value, name, fix) {
|
|
|
74
116
|
return value;
|
|
75
117
|
}
|
|
76
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The schemas a file names in a statement's target position, other than the one
|
|
121
|
+
* the runner selected. Comments are prose and are removed first, both the lines
|
|
122
|
+
* that are wholly comment and the tail a `--` opens on a line of SQL.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} sql the SQL being applied
|
|
125
|
+
* @param {string} schema the throwaway the runner selected
|
|
126
|
+
* @returns {string[]} distinct qualifiers, in the order they appear
|
|
127
|
+
*/
|
|
128
|
+
function foreignQualifiers(sql, schema) {
|
|
129
|
+
const found = [];
|
|
130
|
+
|
|
131
|
+
for (const line of sql.split('\n')) {
|
|
132
|
+
if (SQL_COMMENT.test(line)) continue;
|
|
133
|
+
const code = line.replace(TRAILING_COMMENT, '');
|
|
134
|
+
|
|
135
|
+
for (const match of code.matchAll(QUALIFIED_TARGET)) {
|
|
136
|
+
const qualifier = match[1];
|
|
137
|
+
if (qualifier === schema) continue;
|
|
138
|
+
if (qualifier.toLowerCase() === CATALOG_SCHEMA) continue;
|
|
139
|
+
if (!found.includes(qualifier)) found.push(qualifier);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
/**
|
|
78
147
|
* The SQL of one migration, with the install runner's schema select removed.
|
|
79
148
|
*
|
|
80
149
|
* @param {string} file absolute path to the .sql file
|
|
150
|
+
* @param {string} schema the throwaway schema the statements are applied to
|
|
81
151
|
* @returns {string}
|
|
82
152
|
*/
|
|
83
|
-
function sqlWithoutSchemaSelect(file) {
|
|
153
|
+
function sqlWithoutSchemaSelect(file, schema) {
|
|
154
|
+
require_(schema, 'schema', 'pass the throwaway schema the statements are applied to; '
|
|
155
|
+
+ 'without it there is nothing to compare a qualified name against.');
|
|
156
|
+
|
|
84
157
|
const stripped = fs.readFileSync(file, 'utf8')
|
|
85
158
|
.split('\n')
|
|
86
159
|
.map((line) => (LEADING_SCHEMA_SELECT.test(line) ? '' : line))
|
|
@@ -99,6 +172,18 @@ function sqlWithoutSchemaSelect(file) {
|
|
|
99
172
|
+ 'schema for the file.');
|
|
100
173
|
}
|
|
101
174
|
|
|
175
|
+
const foreign = foreignQualifiers(stripped, schema);
|
|
176
|
+
if (foreign.length > 0) {
|
|
177
|
+
throw new Error(`[ThrowawaySchema] ${path.basename(file)} names the schema `
|
|
178
|
+
+ `"${foreign.join('", "')}" in a statement, and the throwaway is "${schema}" - refusing to `
|
|
179
|
+
+ 'apply it.\n'
|
|
180
|
+
+ ' Stripping the USE lines only moves the statements that carry no schema of their own; '
|
|
181
|
+
+ 'a qualified name sends its statement somewhere else whatever the runner selected, and in a '
|
|
182
|
+
+ 'migration that somewhere is the live schema.\n'
|
|
183
|
+
+ ' Fix: write the name unqualified - the runner selects the schema for the file. '
|
|
184
|
+
+ `Only ${CATALOG_SCHEMA} may be named, because it is read-only and follows DATABASE().`);
|
|
185
|
+
}
|
|
186
|
+
|
|
102
187
|
return stripped;
|
|
103
188
|
}
|
|
104
189
|
|
|
@@ -171,7 +256,7 @@ async function createThrowawaySchema({
|
|
|
171
256
|
|
|
172
257
|
const apply = async (file, kind) => {
|
|
173
258
|
const result = await exec({
|
|
174
|
-
connection: conn, schema, file, sql: sqlWithoutSchemaSelect(file)
|
|
259
|
+
connection: conn, schema, file, sql: sqlWithoutSchemaSelect(file, schema)
|
|
175
260
|
});
|
|
176
261
|
if (result.status !== 0) {
|
|
177
262
|
throw new Error(`[ThrowawaySchema] ${kind} ${path.basename(file)} failed:\n${result.stderr}\n`
|
|
@@ -198,7 +283,7 @@ async function createThrowawaySchema({
|
|
|
198
283
|
migrationsApplied: toApply.length,
|
|
199
284
|
seedsApplied: seedFiles.length,
|
|
200
285
|
migrations: [...plan.migrations],
|
|
201
|
-
readMigration: (name) => sqlWithoutSchemaSelect(fileOf(name)),
|
|
286
|
+
readMigration: (name) => sqlWithoutSchemaSelect(fileOf(name), schema),
|
|
202
287
|
applyMigration: async (name) => apply(fileOf(name), 'Migration'),
|
|
203
288
|
dispose: async () => run(dropSql)
|
|
204
289
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who this validator IS — its own name and its own version.
|
|
5
|
+
*
|
|
6
|
+
* A validation proof and a deployability signal both NAME their author, and
|
|
7
|
+
* that name is not a caller's to choose: it is this package's identity, and the
|
|
8
|
+
* one place it is written down is this package's `package.json`. Read once,
|
|
9
|
+
* here, so the value cannot differ between the documents that carry it.
|
|
10
|
+
*
|
|
11
|
+
* Before this the same fact stood in four places: twice as
|
|
12
|
+
* `require('../package.json').version` inside `ValidationOrchestrator`, once
|
|
13
|
+
* more in `utils/preValidation.js` beside a hand-written copy of the package
|
|
14
|
+
* NAME, and once in `validators/ValidationProofGenerator` as
|
|
15
|
+
* `options.validatorVersion || '1.0.0'` — an invented number that reached a
|
|
16
|
+
* signed document whenever the caller passed none
|
|
17
|
+
* (`.claude/rules/architecture-principles.md` §3, No Fallbacks).
|
|
18
|
+
*
|
|
19
|
+
* This is the package's own metadata, not configuration: nothing about it is
|
|
20
|
+
* environment- or caller-dependent, so there is nothing to inject.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { name, version } = require('../package.json');
|
|
24
|
+
|
|
25
|
+
/** `@onlineapps/conn-orch-validator` — the author every proof names. */
|
|
26
|
+
const VALIDATOR_NAME = name;
|
|
27
|
+
|
|
28
|
+
/** The published version of this package, as its manifest states it. */
|
|
29
|
+
const VALIDATOR_VERSION = version;
|
|
30
|
+
|
|
31
|
+
module.exports = { VALIDATOR_NAME, VALIDATOR_VERSION };
|
|
@@ -460,10 +460,15 @@ class ServiceStructureValidator {
|
|
|
460
460
|
this.info.push('✓ Found Configuration directory');
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
+
// `tests/cookbooks` is NOT here: it is a boot condition, raised by
|
|
464
|
+
// `validateTestStructure()` as an error (d.584). These two are
|
|
465
|
+
// recommendations — no row of the biz-service uniform demands that either
|
|
466
|
+
// directory exist (`S-TEST` fixes the body of `npm test`, `S-INT-C` carries
|
|
467
|
+
// `only_with: tests/integration`), so this is the only thing that mentions
|
|
468
|
+
// them and it stays a warning.
|
|
463
469
|
const recommendedDirs = [
|
|
464
470
|
{ path: 'tests/unit', description: 'Unit tests' },
|
|
465
|
-
{ path: 'tests/integration', description: 'Integration tests' }
|
|
466
|
-
{ path: 'tests/cookbooks', description: 'Cookbook tests' }
|
|
471
|
+
{ path: 'tests/integration', description: 'Integration tests' }
|
|
467
472
|
];
|
|
468
473
|
|
|
469
474
|
for (const dir of requiredDirs) {
|
|
@@ -871,23 +876,44 @@ class ServiceStructureValidator {
|
|
|
871
876
|
}
|
|
872
877
|
|
|
873
878
|
/**
|
|
874
|
-
* Validate test structure
|
|
879
|
+
* Validate test structure — `tests/cookbooks` with at least one recipe.
|
|
880
|
+
*
|
|
881
|
+
* This is a BOOT CONDITION, not a recommendation (d.584): Tier-1 validates
|
|
882
|
+
* the service's operations BY these recipes at phase 0.2, so a service
|
|
883
|
+
* without them does not start. Until d.584 the absence was reported twice
|
|
884
|
+
* and softly — a `MISSING_RECOMMENDED_DIRECTORY` warning when the directory
|
|
885
|
+
* was gone, a `NO_COOKBOOK_TESTS` warning when it was empty — and the run
|
|
886
|
+
* then died four steps later on the proof, which `ValidationProofGenerator`
|
|
887
|
+
* refuses for `testsRun: 0` under the unrelated name NO_TESTS. Late,
|
|
888
|
+
* indirect, and in two voices; now one error, at the step that checks it
|
|
889
|
+
* (`automation-gates.md` §1 requirement 4).
|
|
890
|
+
*
|
|
891
|
+
* The same fact is already stated from the other side by rule
|
|
892
|
+
* F-DOCKERIGNORE, which excludes the whole test tree from the production
|
|
893
|
+
* image and keeps `tests/cookbooks` (d.560). One rail, two readers.
|
|
894
|
+
*
|
|
895
|
+
* What counts as a recipe is what the runner counts —
|
|
896
|
+
* `CookbookTestRunner.runCookbooks` reads the `.json` files of this
|
|
897
|
+
* directory — so the two never disagree about an empty set.
|
|
875
898
|
*/
|
|
876
899
|
validateTestStructure() {
|
|
877
900
|
const cookbooksPath = path.join(this.serviceRoot, 'tests/cookbooks');
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
}
|
|
901
|
+
const cookbookFiles = fs.existsSync(cookbooksPath)
|
|
902
|
+
? fs.readdirSync(cookbooksPath).filter(f => f.endsWith('.json'))
|
|
903
|
+
: [];
|
|
904
|
+
|
|
905
|
+
if (cookbookFiles.length === 0) {
|
|
906
|
+
this.errors.push({
|
|
907
|
+
type: 'MISSING_COOKBOOKS',
|
|
908
|
+
path: 'tests/cookbooks',
|
|
909
|
+
message: '[ServiceStructure] tests/cookbooks is missing or empty - Tier-1 boot '
|
|
910
|
+
+ 'validates operations by these recipes. Fix: add tests/cookbooks/<op>.json',
|
|
911
|
+
fix: 'add tests/cookbooks/<op>.json'
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
890
914
|
}
|
|
915
|
+
|
|
916
|
+
this.info.push(`✓ Found ${cookbookFiles.length} cookbook test(s)`);
|
|
891
917
|
}
|
|
892
918
|
|
|
893
919
|
/**
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { ValidationProofCodec } = require('@onlineapps/service-validator-core');
|
|
4
|
+
const { VALIDATOR_NAME, VALIDATOR_VERSION } = require('../validatorIdentity');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read one measurement out of the runner's aggregate, or refuse.
|
|
8
|
+
*
|
|
9
|
+
* Every number in a validation proof is a measurement of a run that happened,
|
|
10
|
+
* so a missing one has no substitute value. The four fields below used to be
|
|
11
|
+
* filled with `testResults.<field> || 0`, which turned "nobody measured this"
|
|
12
|
+
* into "measured, and it was zero" — the same number, and no way for a reader
|
|
13
|
+
* of the proof to tell which of the two it is (`architecture-principles.md` §3,
|
|
14
|
+
* No Fallbacks).
|
|
15
|
+
*/
|
|
16
|
+
function readMeasurement(testResults, field, proofField) {
|
|
17
|
+
const value = testResults === null || testResults === undefined ? undefined : testResults[field];
|
|
18
|
+
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
20
|
+
const rendered = typeof value === 'number' ? String(value) : JSON.stringify(value);
|
|
21
|
+
throw new Error(`[ValidationProofGenerator] Missing measurement - testResults.${field} is ${rendered}, `
|
|
22
|
+
+ `and the proof field ${proofField} may not be filled from anything else. `
|
|
23
|
+
+ 'Fix: hand in the aggregate CookbookTestRunner.runCookbooks() returns, which counts every step it ran.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
/**
|
|
6
30
|
* ValidationProofGenerator - Generates validation proof from test results
|
|
@@ -15,10 +39,25 @@ const { ValidationProofCodec } = require('@onlineapps/service-validator-core');
|
|
|
15
39
|
*/
|
|
16
40
|
class ValidationProofGenerator {
|
|
17
41
|
constructor(options = {}) {
|
|
42
|
+
// WHO the validator is is not an option. It used to be one, with
|
|
43
|
+
// `|| '1.0.0'` behind it, so a caller that passed nothing got an invented
|
|
44
|
+
// version inside a signed document — and a caller that passed something got
|
|
45
|
+
// to say this package was whatever it liked. The identity has one owner
|
|
46
|
+
// (`src/validatorIdentity.js`), and an attempt to set it is refused rather
|
|
47
|
+
// than ignored: an option that silently does nothing is the implicit
|
|
48
|
+
// behaviour `.claude/rules/architecture-principles.md` §8 forbids.
|
|
49
|
+
for (const retired of ['validatorName', 'validatorVersion']) {
|
|
50
|
+
if (options[retired] !== undefined) {
|
|
51
|
+
throw new Error(`[ValidationProofGenerator] ${retired} is not an option - the proof names THIS `
|
|
52
|
+
+ `package (${VALIDATOR_NAME} ${VALIDATOR_VERSION}), read from its own package.json. `
|
|
53
|
+
+ `Fix: drop ${retired} from the constructor options.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
18
57
|
this.serviceName = options.serviceName;
|
|
19
58
|
this.serviceVersion = options.serviceVersion;
|
|
20
|
-
this.validatorName =
|
|
21
|
-
this.validatorVersion =
|
|
59
|
+
this.validatorName = VALIDATOR_NAME;
|
|
60
|
+
this.validatorVersion = VALIDATOR_VERSION;
|
|
22
61
|
}
|
|
23
62
|
|
|
24
63
|
/**
|
|
@@ -26,9 +65,14 @@ class ValidationProofGenerator {
|
|
|
26
65
|
*
|
|
27
66
|
* @param {Object} testResults - Results from test execution
|
|
28
67
|
* @param {Object} dependencies - Service dependencies with versions
|
|
68
|
+
* @param {Object} [options] - what the caller MEASURED about the service
|
|
69
|
+
* @param {string} [options.contractFingerprint] - the fingerprint of the
|
|
70
|
+
* contract this run validated, when the caller computed one. Absent means
|
|
71
|
+
* absent: the field is left out rather than filled with a placeholder, and
|
|
72
|
+
* the proof schema marks it optional for exactly that reason.
|
|
29
73
|
* @returns {Object} Validation proof with metadata
|
|
30
74
|
*/
|
|
31
|
-
generateProof(testResults, dependencies = {}) {
|
|
75
|
+
generateProof(testResults, dependencies = {}, { contractFingerprint } = {}) {
|
|
32
76
|
// Transform test results to ValidationProofSchema format
|
|
33
77
|
const validationData = {
|
|
34
78
|
serviceName: this.serviceName,
|
|
@@ -36,44 +80,39 @@ class ValidationProofGenerator {
|
|
|
36
80
|
validator: this.validatorName,
|
|
37
81
|
validatorVersion: this.validatorVersion,
|
|
38
82
|
validatedAt: new Date().toISOString(),
|
|
39
|
-
durationMs: testResults
|
|
40
|
-
testsRun: testResults
|
|
41
|
-
testsPassed: testResults
|
|
42
|
-
testsFailed: testResults
|
|
83
|
+
durationMs: readMeasurement(testResults, 'duration', 'durationMs'),
|
|
84
|
+
testsRun: readMeasurement(testResults, 'total', 'testsRun'),
|
|
85
|
+
testsPassed: readMeasurement(testResults, 'passed', 'testsPassed'),
|
|
86
|
+
testsFailed: readMeasurement(testResults, 'failed', 'testsFailed'),
|
|
43
87
|
dependencies: dependencies || {}
|
|
44
88
|
};
|
|
45
89
|
|
|
90
|
+
if (contractFingerprint !== undefined) {
|
|
91
|
+
validationData.contractFingerprint = contractFingerprint;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A run that executed nothing has nothing to certify. `runPreValidation`
|
|
95
|
+
// writes a proof whenever no step FAILED, and a service whose
|
|
96
|
+
// tests/cookbooks/ holds no .json file has no failing step — so this
|
|
97
|
+
// generator used to author a signed proof claiming a passing validation of
|
|
98
|
+
// zero tests, and the CLI printed `OK run-prevalidation` beside it.
|
|
99
|
+
//
|
|
100
|
+
// The platform already held that such a proof is invalid: the same codec's
|
|
101
|
+
// decode() refuses `testsRun <= 0` as NO_TESTS. Only the party AUTHORING it
|
|
102
|
+
// did not, so the contradiction surfaced at the registry, days later and far
|
|
103
|
+
// from the cause. It is refused here, where it is created
|
|
104
|
+
// (`architecture-principles.md` §4, Fail-Fast).
|
|
105
|
+
if (validationData.testsRun === 0) {
|
|
106
|
+
throw new Error('[ValidationProofGenerator] Refusing a proof for a run that executed nothing - '
|
|
107
|
+
+ 'testsRun is 0, so the proof would claim a passing validation of cookbooks that never ran; '
|
|
108
|
+
+ 'ValidationProofCodec.decode() refuses exactly this proof as NO_TESTS. '
|
|
109
|
+
+ 'Fix: give the service the cookbooks its operations are validated by, under tests/cookbooks/.');
|
|
110
|
+
}
|
|
111
|
+
|
|
46
112
|
// Delegate encoding to centralized codec
|
|
47
113
|
// This ensures Generator and Verifier use EXACTLY the same algorithm
|
|
48
114
|
return ValidationProofCodec.encode(validationData);
|
|
49
115
|
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Extract test results summary
|
|
53
|
-
*/
|
|
54
|
-
extractTestSummary(testResults) {
|
|
55
|
-
return {
|
|
56
|
-
total: testResults.total || 0,
|
|
57
|
-
passed: testResults.passed || 0,
|
|
58
|
-
failed: testResults.failed || 0,
|
|
59
|
-
duration: testResults.duration || 0,
|
|
60
|
-
coverage: testResults.coverage || 0
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Create validation error
|
|
66
|
-
*/
|
|
67
|
-
createValidationError(code, message, details = {}) {
|
|
68
|
-
return {
|
|
69
|
-
error: {
|
|
70
|
-
code,
|
|
71
|
-
message,
|
|
72
|
-
details,
|
|
73
|
-
timestamp: new Date().toISOString()
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
116
|
}
|
|
78
117
|
|
|
79
118
|
module.exports = ValidationProofGenerator;
|