@onlineapps/conn-orch-validator 7.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2582 -2
- package/README.md +1038 -4
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +140 -9
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +409 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two facts a compose file answers for this uniform — which services it
|
|
5
|
+
* declares, and what each of them says about itself — read straight from the
|
|
6
|
+
* text.
|
|
7
|
+
*
|
|
8
|
+
* There is no YAML parser here on purpose. `@onlineapps/conn-orch-validator`
|
|
9
|
+
* declares two dependencies, both `@onlineapps`, and a package sees only what it
|
|
10
|
+
* declares (`architecture-principles.md` § Shared Packages, gate G6): pulling a
|
|
11
|
+
* parser in to read four keys would change what every biz service installs.
|
|
12
|
+
* `utils/deployContract.js` reads the same files by line for the same reason,
|
|
13
|
+
* so this is that rail, not a second one.
|
|
14
|
+
*
|
|
15
|
+
* The subset it accepts is the one every compose file in this workspace is
|
|
16
|
+
* written in: two-space indentation, `key: value`, `key:` with a nested block,
|
|
17
|
+
* and `- item` sequences. A tab makes it fail loudly rather than mis-read a
|
|
18
|
+
* limit (`automation-gates.md` §1 requirement 4).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** How deep one level of nesting is, in spaces, in every compose file here. */
|
|
22
|
+
const INDENT = 2;
|
|
23
|
+
|
|
24
|
+
const indentOf = (line) => line.length - line.replace(/^ +/, '').length;
|
|
25
|
+
const isBlank = (line) => line.trim() === '' || line.trim().startsWith('#');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} ComposeNode
|
|
29
|
+
* @property {Map<string,string>} scalars `key: value` pairs at this level
|
|
30
|
+
* @property {Map<string,ComposeNode>} blocks nested mappings
|
|
31
|
+
* @property {Map<string,string[]>} sequences `- item` lists, items verbatim
|
|
32
|
+
* @property {string[]} keys every key at this level, in file order
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** @returns {ComposeNode} */
|
|
36
|
+
const emptyNode = () => ({ scalars: new Map(), blocks: new Map(), sequences: new Map(), keys: [] });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse the lines at one indentation level into a node.
|
|
40
|
+
*
|
|
41
|
+
* @param {string[]} lines the whole file, split
|
|
42
|
+
* @param {number} start index of the first line of this level
|
|
43
|
+
* @param {number} indent the indentation this level sits at
|
|
44
|
+
* @returns {{ node: ComposeNode, next: number }}
|
|
45
|
+
*/
|
|
46
|
+
function parseLevel(lines, start, indent) {
|
|
47
|
+
const node = emptyNode();
|
|
48
|
+
let i = start;
|
|
49
|
+
|
|
50
|
+
while (i < lines.length) {
|
|
51
|
+
const line = lines[i];
|
|
52
|
+
if (isBlank(line)) { i += 1; continue; }
|
|
53
|
+
if (indentOf(line) < indent) break;
|
|
54
|
+
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
|
|
57
|
+
if (trimmed.startsWith('- ') || trimmed === '-') { i += 1; continue; }
|
|
58
|
+
|
|
59
|
+
const colon = trimmed.indexOf(':');
|
|
60
|
+
if (colon === -1) { i += 1; continue; }
|
|
61
|
+
|
|
62
|
+
const key = trimmed.slice(0, colon).trim();
|
|
63
|
+
const value = trimmed.slice(colon + 1).trim();
|
|
64
|
+
node.keys.push(key);
|
|
65
|
+
|
|
66
|
+
if (value !== '') {
|
|
67
|
+
node.scalars.set(key, value);
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A key with nothing after the colon opens either a nested mapping or a
|
|
73
|
+
// sequence; which one is decided by the first non-blank line under it.
|
|
74
|
+
let j = i + 1;
|
|
75
|
+
while (j < lines.length && isBlank(lines[j])) j += 1;
|
|
76
|
+
|
|
77
|
+
if (j >= lines.length || indentOf(lines[j]) <= indent) {
|
|
78
|
+
node.scalars.set(key, '');
|
|
79
|
+
i = j;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (lines[j].trim().startsWith('-')) {
|
|
84
|
+
const items = [];
|
|
85
|
+
while (j < lines.length && (isBlank(lines[j]) || indentOf(lines[j]) > indent)) {
|
|
86
|
+
if (!isBlank(lines[j])) items.push(lines[j].trim().replace(/^-\s*/, ''));
|
|
87
|
+
j += 1;
|
|
88
|
+
}
|
|
89
|
+
node.sequences.set(key, items);
|
|
90
|
+
i = j;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const nested = parseLevel(lines, j, indentOf(lines[j]));
|
|
95
|
+
node.blocks.set(key, nested.node);
|
|
96
|
+
i = nested.next;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { node, next: i };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The services a compose file declares, by name.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} text the file's content
|
|
106
|
+
* @returns {Map<string, ComposeNode>} empty when the file declares no `services:`
|
|
107
|
+
*/
|
|
108
|
+
function readComposeServices(text) {
|
|
109
|
+
if (/^\t/m.test(text)) {
|
|
110
|
+
throw new Error('[ComposeShape] Compose file is indented with tabs - this reader accepts the two-space '
|
|
111
|
+
+ 'indentation every compose file in this workspace uses. Fix: re-indent the file with spaces.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lines = text.split('\n');
|
|
115
|
+
const top = parseLevel(lines, 0, 0).node;
|
|
116
|
+
const services = top.blocks.get('services');
|
|
117
|
+
return services ? services.blocks : new Map();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Follow a dotted path of nested keys and return the scalar at its end.
|
|
122
|
+
*
|
|
123
|
+
* @param {ComposeNode} node where to start
|
|
124
|
+
* @param {string} dotted e.g. `deploy.resources.limits.memory`
|
|
125
|
+
* @returns {string|null} the value, or null when any step is absent
|
|
126
|
+
*/
|
|
127
|
+
function scalarAt(node, dotted) {
|
|
128
|
+
const parts = dotted.split('.');
|
|
129
|
+
const last = parts.pop();
|
|
130
|
+
let current = node;
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
current = current && current.blocks.get(part);
|
|
133
|
+
if (!current) return null;
|
|
134
|
+
}
|
|
135
|
+
const value = current.scalars.get(last);
|
|
136
|
+
return value === undefined ? null : value.replace(/^["']|["']$/g, '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Is this key declared at all — as a scalar, a block or a sequence?
|
|
141
|
+
*
|
|
142
|
+
* @param {ComposeNode} node the service block
|
|
143
|
+
* @param {string} key the key
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
const declares = (node, key) => node.keys.includes(key);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One declaration as comparable text, whatever shape it takes. Two services
|
|
150
|
+
* that say the same thing compare equal even when one writes a sequence and the
|
|
151
|
+
* other a scalar.
|
|
152
|
+
*
|
|
153
|
+
* @param {ComposeNode} node the service block
|
|
154
|
+
* @param {string} key the key
|
|
155
|
+
* @returns {string} '' when the key is absent
|
|
156
|
+
*/
|
|
157
|
+
function declarationOf(node, key) {
|
|
158
|
+
if (node.sequences.has(key)) return node.sequences.get(key).join('\n');
|
|
159
|
+
if (node.scalars.has(key)) return node.scalars.get(key);
|
|
160
|
+
const block = node.blocks.get(key);
|
|
161
|
+
if (!block) return '';
|
|
162
|
+
return block.keys.map((child) => `${child}=${block.scalars.get(child) ?? declarationOf(block, child)}`).join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { readComposeServices, scalarAt, declares, declarationOf, INDENT };
|