@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
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @module @onlineapps/conn-orch-validator
|
|
5
5
|
* @description Service validation framework using the operations.json contract.
|
|
6
6
|
*
|
|
7
|
-
* Production entry point: {@link ValidationOrchestrator} (
|
|
7
|
+
* Production entry point: {@link ValidationOrchestrator} (7-step pre-validation
|
|
8
8
|
* driven by operations.json — OpenAPI path iteration is NOT supported).
|
|
9
9
|
*
|
|
10
10
|
* @see /api/docs/biz/30-operations/registration-wire.md §3 (operations.json)
|
|
@@ -27,7 +27,6 @@ const ValidationProofGenerator = require('./validators/ValidationProofGenerator'
|
|
|
27
27
|
// ./ValidationOrchestrator below requires the same module unguarded and took
|
|
28
28
|
// the load down anyway. All it added was a misleading line before the real one.
|
|
29
29
|
const CookbookTestRunner = require('./CookbookTestRunner');
|
|
30
|
-
const WorkflowTestRunner = require('./WorkflowTestRunner');
|
|
31
30
|
const BizCiGateContract = require('./utils/bizCiGateContract');
|
|
32
31
|
|
|
33
32
|
const ServiceReadinessValidator = require('./ServiceReadinessValidator');
|
|
@@ -40,7 +39,6 @@ module.exports = {
|
|
|
40
39
|
get MockRegistry() { return MockRegistry; },
|
|
41
40
|
get MockStorage() { return MockStorage; },
|
|
42
41
|
|
|
43
|
-
get WorkflowTestRunner() { return WorkflowTestRunner; },
|
|
44
42
|
get CookbookTestUtils() { return CookbookTestUtils; },
|
|
45
43
|
get ServiceReadinessValidator() { return ServiceReadinessValidator; },
|
|
46
44
|
get CookbookTestRunner() { return CookbookTestRunner; },
|
|
@@ -52,6 +50,38 @@ module.exports = {
|
|
|
52
50
|
// env, never chosen per test. Enforced by deploy-contract R8.
|
|
53
51
|
get getTestNamespace() { return require('./utils/testNamespace').getTestNamespace; },
|
|
54
52
|
|
|
53
|
+
// The isolation half of the same boundary: a namespace the test must NOT see
|
|
54
|
+
// rows from, taken from the allowed classes rather than from a literal or
|
|
55
|
+
// from `tenant + 1` (99 + 1 = 100 is the LIVE tenant). Accepted by R8 exactly
|
|
56
|
+
// like getTestNamespace().
|
|
57
|
+
get getForeignTestNamespace() { return require('./utils/testNamespace').getForeignTestNamespace; },
|
|
58
|
+
|
|
59
|
+
// The same boundary asked about an id the caller already holds: an
|
|
60
|
+
// operational script is handed a tenant on the command line and must know
|
|
61
|
+
// whether it may touch it at all. Throws the refusal getTestNamespace()
|
|
62
|
+
// throws, rendered from one place with the caller's own name for the value,
|
|
63
|
+
// and returns the id normalized. The allowed classes are NOT exported — a
|
|
64
|
+
// copy of the boundary is a second boundary, and the refusal names them.
|
|
65
|
+
get assertAllowedTenant() { return require('./utils/testNamespace').assertAllowedTenant; },
|
|
66
|
+
|
|
67
|
+
// The throwaway schema an integration suite builds from the service's own
|
|
68
|
+
// declaration: one build for every DB-owning service, sharing every decision
|
|
69
|
+
// with the CI gate's `buildSchema` except the one that differs — the test's
|
|
70
|
+
// schema is dropped and recreated, the service's is never touched. A file
|
|
71
|
+
// that imports it is what deploy-contract R8 permits, in place of the
|
|
72
|
+
// `CREATE DATABASE` text heuristic it used to read.
|
|
73
|
+
get createThrowawaySchema() { return require('./utils/throwawaySchema').createThrowawaySchema; },
|
|
74
|
+
|
|
75
|
+
// What a v3 handler reference is, and what resolving one means — one
|
|
76
|
+
// definition for the package, and the one `service-wrapper`'s HandlerLoader
|
|
77
|
+
// can take over as an internal swap: `resolveHandlerModule` states exactly
|
|
78
|
+
// the rule it implements today (containment, require, export is a function),
|
|
79
|
+
// while the stricter declared form stays in `parseHandlerRef`, so adopting it
|
|
80
|
+
// narrows nothing the wrapper accepts.
|
|
81
|
+
get HANDLER_REF_PATTERN() { return require('./utils/handlerRef').HANDLER_REF_PATTERN; },
|
|
82
|
+
get parseHandlerRef() { return require('./utils/handlerRef').parseHandlerRef; },
|
|
83
|
+
get resolveHandlerModule() { return require('./utils/handlerRef').resolveHandlerModule; },
|
|
84
|
+
|
|
55
85
|
get createServiceReadinessTests() { return createServiceReadinessTests; },
|
|
56
86
|
get BizCiGateContract() { return BizCiGateContract; },
|
|
57
87
|
get IntegrationRun() { return require('./utils/integrationRun'); },
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SCRIPTS-STANDARD §1-§3, as a module of the package that ships the uniform.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT LIVES HERE. Confirmation `biz-service-manifest` 003 §19 makes
|
|
7
|
+
* `scripts/**` of every bearer a duty of the uniform, checked by "lint-scripts,
|
|
8
|
+
* shipped in the validator package". Until d.218 the rules were a file of the
|
|
9
|
+
* `api` checkout, and a file of one checkout cannot decide a rule about eight
|
|
10
|
+
* repositories: a biz service has no `api/` directory in its own CI and none at
|
|
11
|
+
* all inside its image, so the row would have been NOT RUN exactly where it is
|
|
12
|
+
* meant to answer. The rules moved; `api/scripts/ci/lint-scripts.mjs` calls
|
|
13
|
+
* this module and holds no rule of its own, so there is one implementation
|
|
14
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
15
|
+
*
|
|
16
|
+
* WHAT DID NOT CHANGE. Every id, every message and every vocabulary is the one
|
|
17
|
+
* `api/tests/scripts/scripts-lint.bats` already asserts. The header probe stays
|
|
18
|
+
* anchored to the header SHAPE in the first lines of a file rather than to a
|
|
19
|
+
* token appearing anywhere: the unanchored probe missed the header linter
|
|
20
|
+
* itself.
|
|
21
|
+
*
|
|
22
|
+
* WHAT DID. An `@see api/…` target used to be resolved by stripping the `api/`
|
|
23
|
+
* prefix and looking under the run's own root — correct exactly once, when the
|
|
24
|
+
* run IS the api checkout. A biz script citing `api/docs/standards/…` would
|
|
25
|
+
* then have been measured against `<service>/docs/standards/…` and reported as
|
|
26
|
+
* a dead pointer it is not. So the api checkout is a PARAMETER: given, such a
|
|
27
|
+
* target is resolved against it; absent, the target is returned as UNRESOLVED
|
|
28
|
+
* and raises nothing, because a run that could not look must not report a pass
|
|
29
|
+
* (`.claude/rules/automation-gates.md` §5). Measured 2026-09-10 over the eight
|
|
30
|
+
* biz repositories: 47 `@see` lines under their scripts directories, of which 4
|
|
31
|
+
* open with the `api/` prefix.
|
|
32
|
+
*
|
|
33
|
+
* Two scopes, two rules. S001-S008 read the HEADER of every script under
|
|
34
|
+
* `scripts/`. S009 reads the COMMENTS of every test under `tests/scripts/` and
|
|
35
|
+
* nothing else in them: a `<path>.<ext>:<line>` citation in prose has no
|
|
36
|
+
* mechanism keeping it true (`doc-code-binding.md` §1), while the same shape on
|
|
37
|
+
* a CODE line is a fixture assertion whose target the test creates itself and
|
|
38
|
+
* which therefore cannot rot.
|
|
39
|
+
*
|
|
40
|
+
* @see api/docs/standards/SCRIPTS-STANDARD.md
|
|
41
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
|
|
47
|
+
/** The five header fields, in the order SCRIPTS-STANDARD §1 writes them. */
|
|
48
|
+
const ORDER = Object.freeze(['Owns', 'Usage', 'Shell', 'Exit', 'Status']);
|
|
49
|
+
|
|
50
|
+
/** SCRIPTS-STANDARD §2: both vocabularies are closed, and a foreign value is a finding. */
|
|
51
|
+
const STATUS_VOCAB = new Set(['current', 'draft', 'archived']);
|
|
52
|
+
const SHELL_VOCAB = new Set(['bash>=4', 'bash>=3.2', 'sh', 'node>=18', 'node>=24']);
|
|
53
|
+
|
|
54
|
+
/** The directory whose scripts carry the header. */
|
|
55
|
+
const SCRIPT_SCOPE = 'scripts';
|
|
56
|
+
|
|
57
|
+
/** The directory whose COMMENTS S009 reads, and nothing else in it. */
|
|
58
|
+
const CITATION_SCOPE = 'tests/scripts';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The directories whose files are libraries rather than entry points.
|
|
62
|
+
*
|
|
63
|
+
* This is the half of S007 the DIRECTORY decides, and the only half: a library
|
|
64
|
+
* is not invoked, so instead of a command line its `Usage:` carries the word by
|
|
65
|
+
* which it is brought into another file. WHICH word that is, is decided by the
|
|
66
|
+
* language (`usageWordFor`), never by the directory — d.249, on the BIZ-ingest
|
|
67
|
+
* finding of 2026-09-11: the rule used to read `scripts/lib/` as "shell" and
|
|
68
|
+
* `scripts/ci/lib/` as "Node", so a Node library under the first could not go
|
|
69
|
+
* green without writing something false about itself — ingest 1 file, meta 7,
|
|
70
|
+
* both red on this one rule and on nothing else (measured 2026-09-11, before
|
|
71
|
+
* and after). Making a file pass by writing a word that is not true of it is
|
|
72
|
+
* exactly what `architecture-principles.md` §10 forbids, so the rule moved
|
|
73
|
+
* rather than the files.
|
|
74
|
+
*/
|
|
75
|
+
const LIBRARY_SCOPES = Object.freeze(['scripts/lib/', 'scripts/ci/lib/']);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Shell is sourced; everything else this lint reads is a Node module, and a
|
|
79
|
+
* Node module is required. The test is the extension, because that is what
|
|
80
|
+
* decides how the file can be brought in at all — `.` on a `.js` is not a thing
|
|
81
|
+
* a shell can do, and `require` on a `.sh` is not a thing Node can do.
|
|
82
|
+
*
|
|
83
|
+
* Written as "shell, or else Node" rather than as a list of Node extensions on
|
|
84
|
+
* purpose: the extensions this lint reads are `SCRIPT_EXTENSIONS`, and a second
|
|
85
|
+
* list here would be a copy of it that drifts (`change-discipline.md` § One rail
|
|
86
|
+
* per concern). A `.cjs` is outside the scan today; the day it enters, it needs
|
|
87
|
+
* no edit here.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} relative path inside the repository
|
|
90
|
+
* @returns {'sourced'|'required'}
|
|
91
|
+
*/
|
|
92
|
+
const usageWordFor = (relative) => (relative.endsWith('.sh') ? 'sourced' : 'required');
|
|
93
|
+
|
|
94
|
+
/** What the message calls the file, from the same test. */
|
|
95
|
+
const languageOf = (relative) => (relative.endsWith('.sh') ? 'shell' : 'Node');
|
|
96
|
+
|
|
97
|
+
/** What a script may be written in. */
|
|
98
|
+
const SCRIPT_EXTENSIONS = /\.(sh|mjs|js)$/;
|
|
99
|
+
|
|
100
|
+
/** What a test under the citation scope may be written in. */
|
|
101
|
+
const CITATION_EXTENSIONS = /\.(bats|bash|sh)$/;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A line number written into a comment. The extension list carries every module
|
|
105
|
+
* extension this platform writes, `.env` included: a generated env template
|
|
106
|
+
* renumbers every citation of it without one edit to the cited file, which is
|
|
107
|
+
* the shape a line citation can least survive.
|
|
108
|
+
*/
|
|
109
|
+
const LINE_CITATION = /[A-Za-z0-9_/.-]+\.(?:js|mjs|cjs|ts|mts|sh|bash|bats|yml|json|md|env):\d+/;
|
|
110
|
+
|
|
111
|
+
/** A comment line of a shell-family test. */
|
|
112
|
+
const COMMENT_LINE = /^\s*#/;
|
|
113
|
+
|
|
114
|
+
/** The prefix a citation of the api checkout opens with, as every document writes it. */
|
|
115
|
+
const API_PREFIX = 'api/';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every file under `dir` whose name matches, depth first, as `/`-separated
|
|
119
|
+
* paths relative to `root`.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} dir absolute directory
|
|
122
|
+
* @param {string} root the root paths are reported against
|
|
123
|
+
* @param {RegExp} matches which file names belong to the scope
|
|
124
|
+
* @returns {string[]} sorted
|
|
125
|
+
*/
|
|
126
|
+
function walk(dir, root, matches) {
|
|
127
|
+
if (!fs.existsSync(dir)) return [];
|
|
128
|
+
const found = [];
|
|
129
|
+
const descend = (current) => {
|
|
130
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
131
|
+
const full = path.join(current, entry.name);
|
|
132
|
+
if (entry.isDirectory()) descend(full);
|
|
133
|
+
else if (matches.test(entry.name)) found.push(path.relative(root, full).split(path.sep).join('/'));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
descend(dir);
|
|
137
|
+
return found.sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where an `@see` target lies, or null when this run cannot say.
|
|
142
|
+
*
|
|
143
|
+
* @param {{target: string, root: string, apiRoot: string|null}} params
|
|
144
|
+
* @returns {string|null} the absolute path to test, or null when unresolvable here
|
|
145
|
+
*/
|
|
146
|
+
function resolveSeeTarget({ target, root, apiRoot }) {
|
|
147
|
+
if (!target.startsWith(API_PREFIX)) return path.join(root, target);
|
|
148
|
+
if (apiRoot === null) return null;
|
|
149
|
+
return path.join(apiRoot, target.slice(API_PREFIX.length));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The header findings of one script.
|
|
154
|
+
*
|
|
155
|
+
* @param {{relative: string, lines: string[], root: string, apiRoot: string|null}} params
|
|
156
|
+
* @param {(id: string, line: number, message: string) => void} report
|
|
157
|
+
* @param {(target: string) => void} unresolved
|
|
158
|
+
*/
|
|
159
|
+
function lintHeader({ relative, lines, root, apiRoot }, report, unresolved) {
|
|
160
|
+
const marker = relative.endsWith('.sh') ? '#' : '//';
|
|
161
|
+
let i = lines[0] !== undefined && lines[0].startsWith('#!') ? 1 : 0;
|
|
162
|
+
|
|
163
|
+
// S001: five fields, exact order, contiguous from the top · S003: the comment
|
|
164
|
+
// marker matches the file type. (There is no S002 — the ids are the standard's,
|
|
165
|
+
// and it never defined one.)
|
|
166
|
+
const fields = {};
|
|
167
|
+
for (const name of ORDER) {
|
|
168
|
+
const line = lines[i] === undefined ? '' : lines[i];
|
|
169
|
+
const parsed = line.match(/^(#|\/\/) ([A-Za-z]+): (.*)$/);
|
|
170
|
+
if (parsed === null || parsed[2] !== name) {
|
|
171
|
+
report('S001', i + 1,
|
|
172
|
+
`Header field "${marker} ${name}: " expected here, found "${line.slice(0, 60)}". `
|
|
173
|
+
+ `The five fields (${ORDER.join(', ')}) sit in this order immediately after the shebang `
|
|
174
|
+
+ '(SCRIPTS-STANDARD §1). Fix: write the missing/misplaced field.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (parsed[1] !== marker) {
|
|
178
|
+
report('S003', i + 1, `Comment marker "${parsed[1]}" does not match the file type - `
|
|
179
|
+
+ `${relative.endsWith('.sh') ? 'shell scripts use "# "' : 'Node scripts use "// "'} (SCRIPTS-STANDARD §1).`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
fields[name] = { value: parsed[3].trim(), line: i + 1 };
|
|
183
|
+
i += 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// S004 / S008: the two closed vocabularies. A parseable-but-foreign value is a
|
|
187
|
+
// lie with a green checkmark.
|
|
188
|
+
if (!STATUS_VOCAB.has(fields.Status.value)) {
|
|
189
|
+
report('S004', fields.Status.line,
|
|
190
|
+
`Status "${fields.Status.value}" is not in the closed vocabulary current|draft|archived `
|
|
191
|
+
+ '(SCRIPTS-STANDARD §2). A parseable-but-foreign value is a lie with a green checkmark. '
|
|
192
|
+
+ 'Fix: use one of the three words; extra prose goes below the blank comment line.');
|
|
193
|
+
}
|
|
194
|
+
if (!SHELL_VOCAB.has(fields.Shell.value)) {
|
|
195
|
+
report('S008', fields.Shell.line,
|
|
196
|
+
`Shell "${fields.Shell.value}" is not in the closed vocabulary `
|
|
197
|
+
+ 'bash>=4|bash>=3.2|sh|node>=18|node>=24 (SCRIPTS-STANDARD §2).');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// S005: blank comment line after Status
|
|
201
|
+
if ((lines[i] === undefined ? '' : lines[i]).trim() !== marker) {
|
|
202
|
+
report('S005', i + 1,
|
|
203
|
+
`A blank comment line ("${marker}") must follow Status before any free text or @see (SCRIPTS-STANDARD §1).`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
i += 1;
|
|
207
|
+
|
|
208
|
+
// S006: every @see immediately following points at a file that is there. The
|
|
209
|
+
// target is the first whitespace-delimited token after "@see" — section
|
|
210
|
+
// fragments (§…), em-dash annotations and parenthetical notes follow it.
|
|
211
|
+
while ((lines[i] === undefined ? '' : lines[i]).startsWith(`${marker} @see `)) {
|
|
212
|
+
const target = lines[i].slice(marker.length + 6).trim().split(/\s+/)[0];
|
|
213
|
+
const resolved = resolveSeeTarget({ target, root, apiRoot });
|
|
214
|
+
if (resolved === null) unresolved(target);
|
|
215
|
+
else if (!fs.existsSync(resolved)) {
|
|
216
|
+
report('S006', i + 1, `@see target "${target}" does not exist in the repo. Fix the path, or remove `
|
|
217
|
+
+ 'the line; a dead pointer teaches the reader a wrong home (SCRIPTS-STANDARD §3).');
|
|
218
|
+
}
|
|
219
|
+
i += 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// S007: a library declares the usage word of ITS LANGUAGE.
|
|
223
|
+
if (LIBRARY_SCOPES.some((scope) => relative.startsWith(scope))) {
|
|
224
|
+
const expected = usageWordFor(relative);
|
|
225
|
+
if (fields.Usage.value !== expected) {
|
|
226
|
+
report('S007', fields.Usage.line, `A ${languageOf(relative)} library declares Usage: ${expected}, `
|
|
227
|
+
+ `found "${fields.Usage.value}" (SCRIPTS-STANDARD §2).`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The rules over one repository.
|
|
234
|
+
*
|
|
235
|
+
* A repository with no `scripts/` is an EMPTY scope, not an error: the caller
|
|
236
|
+
* that needs "there must be scripts here" is the api CLI, which says so itself,
|
|
237
|
+
* and the manifest row asks the question of eight repositories of which one may
|
|
238
|
+
* legitimately carry none. The returned `scanned` is what makes the difference
|
|
239
|
+
* visible — an empty scope and a clean scope do not look alike.
|
|
240
|
+
*
|
|
241
|
+
* @param {{root: string, apiRoot?: string|null}} params
|
|
242
|
+
* `root` — the repository to read; `apiRoot` — the api checkout an `api/…`
|
|
243
|
+
* citation is resolved against, or null when this run has none.
|
|
244
|
+
* @returns {{findings: Array<{id: string, file: string, line: number, message: string}>,
|
|
245
|
+
* scanned: string[], citationScanned: string[], unresolved: string[]}}
|
|
246
|
+
*/
|
|
247
|
+
function lintScripts({ root, apiRoot = null }) {
|
|
248
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
249
|
+
throw new Error('[LintScripts] Repository root is required - lintScripts({ root }) got '
|
|
250
|
+
+ `${JSON.stringify(root)}. Fix: pass the directory whose scripts/ is to be read.`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const scanned = walk(path.join(root, SCRIPT_SCOPE), root, SCRIPT_EXTENSIONS);
|
|
254
|
+
const citationScanned = walk(path.join(root, ...CITATION_SCOPE.split('/')), root, CITATION_EXTENSIONS);
|
|
255
|
+
|
|
256
|
+
const findings = [];
|
|
257
|
+
const unresolved = [];
|
|
258
|
+
|
|
259
|
+
for (const relative of scanned) {
|
|
260
|
+
const lines = fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8').split('\n');
|
|
261
|
+
lintHeader(
|
|
262
|
+
{ relative, lines, root, apiRoot },
|
|
263
|
+
(id, line, message) => findings.push({ id, file: relative, line, message }),
|
|
264
|
+
(target) => unresolved.push(target)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// S009: a comment under tests/scripts/ cites a symbol or a literal string, and
|
|
269
|
+
// a covered defect cites the commit hash — never a line number.
|
|
270
|
+
for (const relative of citationScanned) {
|
|
271
|
+
const lines = fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8').split('\n');
|
|
272
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
273
|
+
if (!COMMENT_LINE.test(lines[n])) continue;
|
|
274
|
+
const hit = lines[n].match(LINE_CITATION);
|
|
275
|
+
if (hit === null) continue;
|
|
276
|
+
findings.push({
|
|
277
|
+
id: 'S009',
|
|
278
|
+
file: relative,
|
|
279
|
+
line: n + 1,
|
|
280
|
+
message: `Comment cites a line number - "${hit[0]}". A line number has no mechanism keeping it true: `
|
|
281
|
+
+ 'the file moves and the number stays (doc-code-binding.md §1). Fix: cite a symbol or a literal '
|
|
282
|
+
+ 'string; a covered defect cites the commit hash that introduced or repaired it '
|
|
283
|
+
+ "(git log -S'<string>' --format=%h -- <file>) - DOC-STANDARD rule 11."
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { findings, scanned, citationScanned, unresolved };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
lintScripts,
|
|
293
|
+
SCRIPT_SCOPE,
|
|
294
|
+
CITATION_SCOPE,
|
|
295
|
+
ORDER,
|
|
296
|
+
STATUS_VOCAB,
|
|
297
|
+
SHELL_VOCAB
|
|
298
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one-shot test runner as a DELIMITED BLOCK — the single definition the
|
|
5
|
+
* conformance check and the generator both read.
|
|
6
|
+
*
|
|
7
|
+
* Why a block rather than a whole file: a compose file cannot be byte-equal
|
|
8
|
+
* across repositories, because the service it declares IS the repository. But
|
|
9
|
+
* the runner beside that service is a platform decision end to end (owner
|
|
10
|
+
* decision `api/docs/governance/confirmations/biz-test-container.md` 001) — its
|
|
11
|
+
* profile, its user, its own memory budget, its entrypoint, and the paragraph
|
|
12
|
+
* explaining why the budget is separate. Measured over the eight biz
|
|
13
|
+
* repositories on 2026-09-09, seven declare a runner that is semantically the
|
|
14
|
+
* same and differs only in three places, all of them named below.
|
|
15
|
+
*
|
|
16
|
+
* ## What "normalized" means, and why it is not a loosening
|
|
17
|
+
*
|
|
18
|
+
* Two of the runner's declarations are NOT the block's to fix, because another
|
|
19
|
+
* rule already fixes them: `serviceFiles.js` § `composeRunner` requires the
|
|
20
|
+
* runner's `build`, `env_file` and `networks` to equal **the service it tests**.
|
|
21
|
+
* A service that reaches a second network reaches it in both nodes or its suite
|
|
22
|
+
* cannot see what the service sees. So those three are compared against the
|
|
23
|
+
* service (there), and removed before the block is compared against the template
|
|
24
|
+
* (here) — one concern, one rail (`.claude/rules/change-discipline.md` § One rail
|
|
25
|
+
* per concern). Everything else in the block is compared byte for byte.
|
|
26
|
+
*
|
|
27
|
+
* The service's own identity is the other normalization: the runner's key is
|
|
28
|
+
* `<container>_tests`, and the container name is the one thing that is per
|
|
29
|
+
* repository by construction. It is substituted back to the template's
|
|
30
|
+
* placeholders before the comparison, so the block is read as the template
|
|
31
|
+
* wrote it.
|
|
32
|
+
*
|
|
33
|
+
* ## The same definition renders
|
|
34
|
+
*
|
|
35
|
+
* `renderRunnerBlock` performs exactly the inverse: the template's block with
|
|
36
|
+
* this repository's identity in it and this repository's three shared
|
|
37
|
+
* declarations grafted in. That is what makes `npx oa-sync-template
|
|
38
|
+
* docker-compose.yml` produce a block the check then finds clean — a generator
|
|
39
|
+
* whose output its own check rejects is the defect this module exists to avoid.
|
|
40
|
+
*
|
|
41
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §2, §3.2
|
|
42
|
+
* @see api/docs/governance/confirmations/biz-test-container.md
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The declarations a runner shares with the service, or it is not testing the same thing. */
|
|
46
|
+
const SHARED_DECLARATIONS = Object.freeze(['build', 'env_file', 'networks']);
|
|
47
|
+
|
|
48
|
+
/** How deep the keys of one compose service sit, in the two-space indentation every compose file here uses. */
|
|
49
|
+
const MEMBER_INDENT = 4;
|
|
50
|
+
|
|
51
|
+
/** How deep a compose service's own name sits. */
|
|
52
|
+
const SERVICE_INDENT = 2;
|
|
53
|
+
|
|
54
|
+
/** The template's placeholders this module substitutes in both directions. */
|
|
55
|
+
const CONTAINER_PLACEHOLDER = '__CONTAINER_NAME__';
|
|
56
|
+
const SERVICE_PLACEHOLDER = '__SERVICE_NAME__';
|
|
57
|
+
|
|
58
|
+
const indentOf = (line) => line.length - line.replace(/^ +/, '').length;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The lines of `key:` and everything nested under it, within one node's lines.
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} lines the node's lines, its own name line included
|
|
64
|
+
* @param {string} key the declaration
|
|
65
|
+
* @param {number} indent the indentation the node's keys sit at
|
|
66
|
+
* @returns {{start: number, end: number}|null} null when the node does not declare it
|
|
67
|
+
*/
|
|
68
|
+
function declarationSpan(lines, key, indent = MEMBER_INDENT) {
|
|
69
|
+
const start = lines.findIndex((line) => indentOf(line) === indent && line.trim().startsWith(`${key}:`));
|
|
70
|
+
if (start === -1) return null;
|
|
71
|
+
|
|
72
|
+
let end = start + 1;
|
|
73
|
+
while (end < lines.length && lines[end].trim() !== '' && indentOf(lines[end]) > indent) end += 1;
|
|
74
|
+
return { start, end };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where one compose service's lines begin and end, its name line first.
|
|
79
|
+
*
|
|
80
|
+
* The span starts at the KEY and not at whatever comment sits above it: those
|
|
81
|
+
* lines are the repository's own prose — in `api_biz/converter` they are the
|
|
82
|
+
* memory measurement taken inside that container — and a run that replaces the
|
|
83
|
+
* declaration has no business deleting them.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} text the compose file
|
|
86
|
+
* @param {string} name the compose service name
|
|
87
|
+
* @returns {{start: number, end: number}|null} null when the file declares no such service
|
|
88
|
+
*/
|
|
89
|
+
function serviceSpan(text, name) {
|
|
90
|
+
const lines = text.split('\n');
|
|
91
|
+
const start = lines.findIndex((line) => indentOf(line) === SERVICE_INDENT && line.trim() === `${name}:`);
|
|
92
|
+
if (start === -1) return null;
|
|
93
|
+
|
|
94
|
+
let end = start + 1;
|
|
95
|
+
while (end < lines.length && (lines[end].trim() === '' || indentOf(lines[end]) > SERVICE_INDENT)) end += 1;
|
|
96
|
+
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
|
|
97
|
+
return { start, end };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The lines of one compose service, its name line first.
|
|
102
|
+
*
|
|
103
|
+
* Read from the raw text rather than from the parsed node, because the graft
|
|
104
|
+
* writes the service's declaration back into a file VERBATIM — a re-serialized
|
|
105
|
+
* copy would rewrite formatting nobody asked to change
|
|
106
|
+
* (`.claude/rules/automation-gates.md` §1 requirement 3).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text the compose file
|
|
109
|
+
* @param {string} name the compose service name
|
|
110
|
+
* @returns {string[]} [] when the file declares no such service
|
|
111
|
+
*/
|
|
112
|
+
function serviceLines(text, name) {
|
|
113
|
+
const span = serviceSpan(text, name);
|
|
114
|
+
return span === null ? [] : text.split('\n').slice(span.start, span.end);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A compose file with ONE service's declaration replaced, and every other line
|
|
119
|
+
* of it left exactly as it was.
|
|
120
|
+
*
|
|
121
|
+
* The case it exists for was measured on 2026-09-10: seven of the eight biz
|
|
122
|
+
* repositories carry a runner that predates the block markers (d.220), so a run
|
|
123
|
+
* that only knew how to INSERT gave those files a second
|
|
124
|
+
* `<container>_tests:` key — a duplicate mapping key, which compose either
|
|
125
|
+
* refuses or resolves by taking the last one. Replacing it in place keeps one
|
|
126
|
+
* key, at the position the repository already chose for it.
|
|
127
|
+
*
|
|
128
|
+
* @param {{text: string, name: string, replacement: string[]}} args
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function replaceServiceNode({ text, name, replacement }) {
|
|
132
|
+
const span = serviceSpan(text, name);
|
|
133
|
+
if (span === null) {
|
|
134
|
+
throw new Error(`[ComposeRunnerBlock] No compose service "${name}" to replace - the file declares no `
|
|
135
|
+
+ `line reading " ${name}:". Fix: name a service the file declares.`);
|
|
136
|
+
}
|
|
137
|
+
const lines = text.split('\n');
|
|
138
|
+
return [...lines.slice(0, span.start), ...replacement, ...lines.slice(span.end)].join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The block without the declarations another rule owns.
|
|
143
|
+
*
|
|
144
|
+
* @param {string[]} lines
|
|
145
|
+
* @param {ReadonlyArray<string>} keys
|
|
146
|
+
* @returns {string[]}
|
|
147
|
+
*/
|
|
148
|
+
function stripDeclarations(lines, keys) {
|
|
149
|
+
let remaining = lines.slice();
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
const span = declarationSpan(remaining, key);
|
|
152
|
+
if (span === null) continue;
|
|
153
|
+
remaining = [...remaining.slice(0, span.start), ...remaining.slice(span.end)];
|
|
154
|
+
}
|
|
155
|
+
return remaining;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A repository's runner block as the template wrote it: its identity replaced by
|
|
160
|
+
* the placeholders, and the three shared declarations removed.
|
|
161
|
+
*
|
|
162
|
+
* The container name is substituted BEFORE the service name on purpose: the
|
|
163
|
+
* container name normally contains the service name (`api_service_converter`
|
|
164
|
+
* carries `converter`), so the wider match has to go first or it is destroyed by
|
|
165
|
+
* the narrower one.
|
|
166
|
+
*
|
|
167
|
+
* @param {{lines: string[], containerName: string, serviceName: string}} args
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
function normalizeRunnerBlock({ lines, containerName, serviceName }) {
|
|
171
|
+
return stripDeclarations(lines, SHARED_DECLARATIONS).map((line) => line
|
|
172
|
+
.split(containerName).join(CONTAINER_PLACEHOLDER)
|
|
173
|
+
.split(serviceName).join(SERVICE_PLACEHOLDER));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The template's runner block for one repository: its identity substituted in,
|
|
178
|
+
* and its `build`, `env_file` and `networks` taken from the service this runner
|
|
179
|
+
* tests.
|
|
180
|
+
*
|
|
181
|
+
* A declaration the service does not make is REMOVED from the rendered block
|
|
182
|
+
* rather than left at the template's value: "the same as the service" is the
|
|
183
|
+
* rule, and a runner that reaches a network its service does not is exactly what
|
|
184
|
+
* the check would then report (`.claude/rules/architecture-principles.md` §3 —
|
|
185
|
+
* no fallback to a default nobody chose).
|
|
186
|
+
*
|
|
187
|
+
* @param {{reference: string[], composeText: string, containerName: string, serviceName: string}} args
|
|
188
|
+
* @returns {string[]} the block's lines, markers included
|
|
189
|
+
*/
|
|
190
|
+
function renderRunnerBlock({ reference, composeText, containerName, serviceName }) {
|
|
191
|
+
let rendered = reference.map((line) => line
|
|
192
|
+
.split(CONTAINER_PLACEHOLDER).join(containerName)
|
|
193
|
+
.split(SERVICE_PLACEHOLDER).join(serviceName));
|
|
194
|
+
|
|
195
|
+
const service = serviceLines(composeText, containerName);
|
|
196
|
+
|
|
197
|
+
for (const key of SHARED_DECLARATIONS) {
|
|
198
|
+
const mine = declarationSpan(rendered, key);
|
|
199
|
+
if (mine === null) continue;
|
|
200
|
+
|
|
201
|
+
const own = declarationSpan(service, key);
|
|
202
|
+
const replacement = own === null ? [] : service.slice(own.start, own.end);
|
|
203
|
+
rendered = [...rendered.slice(0, mine.start), ...replacement, ...rendered.slice(mine.end)];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return rendered;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
SHARED_DECLARATIONS,
|
|
211
|
+
CONTAINER_PLACEHOLDER,
|
|
212
|
+
SERVICE_PLACEHOLDER,
|
|
213
|
+
MEMBER_INDENT,
|
|
214
|
+
SERVICE_INDENT,
|
|
215
|
+
declarationSpan,
|
|
216
|
+
serviceSpan,
|
|
217
|
+
serviceLines,
|
|
218
|
+
replaceServiceNode,
|
|
219
|
+
stripDeclarations,
|
|
220
|
+
normalizeRunnerBlock,
|
|
221
|
+
renderRunnerBlock
|
|
222
|
+
};
|