@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
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The generated regions of the documentation tree.
|
|
5
|
+
*
|
|
6
|
+
* Confirmation `biz-service-manifest` 002 §16.2: the list of required files,
|
|
7
|
+
* scripts, limits, categories and duties that `docs/biz/60-templates/…`,
|
|
8
|
+
* `docs/guides/library-publishing-process.md` and `docs/guides/DEVELOPMENT.md`
|
|
9
|
+
* copy by hand becomes a generated region fed by the manifests, and `--check`
|
|
10
|
+
* fails the run when a region is stale. Three documents were measured copying
|
|
11
|
+
* the same repository tree, and two copying the same script table; a copy is
|
|
12
|
+
* kept true by review only, so it is a descriptive fact written by hand -
|
|
13
|
+
* exactly what `.claude/rules/doc-code-binding.md` §1 forbids.
|
|
14
|
+
*
|
|
15
|
+
* What the region carries and what it deliberately does not:
|
|
16
|
+
*
|
|
17
|
+
* * the row's OBSERVABLE - its id, its check, the paths and values its own
|
|
18
|
+
* parameters name. Those are facts of the manifest, which is their single
|
|
19
|
+
* owner, so rendering them creates no second copy.
|
|
20
|
+
* * NEVER the row's `why` or `fix`. Those are prose with an owner, and a
|
|
21
|
+
* rendered copy of them in five documents is the duplication this mechanism
|
|
22
|
+
* exists to remove (d.216 established the shape in the library READMEs: a
|
|
23
|
+
* region of ids and links, not of paragraphs).
|
|
24
|
+
* * a `from:` reference renders as the PATH THAT OWNS the value, never as the
|
|
25
|
+
* value itself (confirmation 004). "Node major: `api/.nvmrc`" stays true when
|
|
26
|
+
* the platform moves to the next major; "Node 24" does not.
|
|
27
|
+
*
|
|
28
|
+
* Pure text: nothing here opens a file, reads the environment or resolves a
|
|
29
|
+
* workspace. The caller loads both manifests, lists the packaged template and
|
|
30
|
+
* says which document the region is going into
|
|
31
|
+
* (`.claude/rules/architecture-principles.md` §1).
|
|
32
|
+
*
|
|
33
|
+
* Deliberately absent from every region: a date, a count and a version. A count
|
|
34
|
+
* makes the region drift the day a row is added without anything else changing,
|
|
35
|
+
* and a date makes two runs render different bytes - which is precisely what
|
|
36
|
+
* stops a generator from being a gate.
|
|
37
|
+
*
|
|
38
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-002
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const path = require('path');
|
|
42
|
+
|
|
43
|
+
const { markersFor, hasRegion, replaceRegion, checkRegion: checkAgainst } = require('./generatedRegion');
|
|
44
|
+
const { isPackageReference, referenceOwner } = require('../manifest/discovery');
|
|
45
|
+
|
|
46
|
+
/** The five regions the confirmation names. Adding a sixth is a change to it. */
|
|
47
|
+
const REGION_IDS = Object.freeze([
|
|
48
|
+
'biz-service-tree',
|
|
49
|
+
'biz-service-scripts',
|
|
50
|
+
'biz-service-runtime',
|
|
51
|
+
'biz-service-alignment',
|
|
52
|
+
'library-duties'
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** What each region renders from - the one sentence `--list` prints. */
|
|
56
|
+
const DESCRIPTIONS = Object.freeze({
|
|
57
|
+
'biz-service-tree': 'the packaged service template, annotated with the biz-service manifest row owning each path',
|
|
58
|
+
'biz-service-scripts': 'scripts.required and scripts.forbidden of the biz-service manifest, bodies included',
|
|
59
|
+
'biz-service-runtime': 'the runtime rows of the biz-service manifest and every row carrying a memory limit',
|
|
60
|
+
'biz-service-alignment': 'every row of the biz-service manifest - id, severity, what it checks',
|
|
61
|
+
'library-duties': 'the categories and duty sections of the library manifest, guidance marked apart'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Which manifest a region reads, for the diff header of a failed check. */
|
|
65
|
+
const SOURCES = Object.freeze({
|
|
66
|
+
'biz-service-tree': 'manifests/biz-service.manifest.json',
|
|
67
|
+
'biz-service-scripts': 'manifests/biz-service.manifest.json',
|
|
68
|
+
'biz-service-runtime': 'manifests/biz-service.manifest.json',
|
|
69
|
+
'biz-service-alignment': 'manifests/biz-service.manifest.json',
|
|
70
|
+
'library-duties': 'manifests/library.manifest.json'
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The keys every row carries as metadata. Everything else on a row is a
|
|
75
|
+
* PARAMETER - the value that row fixes - and the runtime region renders those
|
|
76
|
+
* without knowing their names in advance, so a parameter added to the manifest
|
|
77
|
+
* appears without this file being edited.
|
|
78
|
+
*/
|
|
79
|
+
const ROW_METADATA = Object.freeze(['id', 'check', 'severity', 'owner', 'why', 'fix', 'doc']);
|
|
80
|
+
|
|
81
|
+
function requireKnownId(id) {
|
|
82
|
+
if (!REGION_IDS.includes(id)) {
|
|
83
|
+
throw new Error(`[DocsRegion] Unknown region id "${id}" - this package renders ${REGION_IDS.join(', ')}. `
|
|
84
|
+
+ 'Fix: name one of those, or add the region here before a document declares it.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What the marker tells the reader to run. Names the file, so it can be copied as it stands. */
|
|
89
|
+
function regenerateCommand(id, targetLabel) {
|
|
90
|
+
if (typeof targetLabel !== 'string' || targetLabel.length === 0) {
|
|
91
|
+
throw new Error('[DocsRegion] targetLabel is required - the marker names the file that regenerates the '
|
|
92
|
+
+ 'region, and a marker with no file in it cannot be run. '
|
|
93
|
+
+ 'Fix: pass the path of the document as the workspace knows it.');
|
|
94
|
+
}
|
|
95
|
+
return `npx oa-sync-template docs-region --id ${id} --file ${targetLabel}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The two markers of one region in one document.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} id
|
|
102
|
+
* @param {string} targetLabel the document, as the workspace root names it
|
|
103
|
+
* @returns {{begin: string, end: string}}
|
|
104
|
+
*/
|
|
105
|
+
function markersForRegion(id, targetLabel) {
|
|
106
|
+
requireKnownId(id);
|
|
107
|
+
return markersFor(id, regenerateCommand(id, targetLabel));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {string} id @returns {string} */
|
|
111
|
+
function describeRegion(id) {
|
|
112
|
+
requireKnownId(id);
|
|
113
|
+
return DESCRIPTIONS[id];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Which regions of this package a document declares, in a fixed order.
|
|
118
|
+
*
|
|
119
|
+
* The question goes through `generatedRegion.hasRegion`, which knows that a
|
|
120
|
+
* marker inside a fence is an example: these documents are the ones that teach
|
|
121
|
+
* the shape, and matching the marker string here would be a second, fence-blind
|
|
122
|
+
* copy of the module's own rule (d.319).
|
|
123
|
+
*/
|
|
124
|
+
function regionIdsIn(text, targetLabel) {
|
|
125
|
+
return REGION_IDS.filter((id) => hasRegion(text, markersForRegion(id, targetLabel)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A markdown link from the document to a file, computed from the two paths. */
|
|
129
|
+
function linkTo(targetPath, filePath) {
|
|
130
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0
|
|
131
|
+
|| typeof filePath !== 'string' || filePath.length === 0) {
|
|
132
|
+
throw new Error('[DocsRegion] targetPath and the manifest path are required - the link is computed from '
|
|
133
|
+
+ 'them and never written by hand. Fix: pass both absolute paths.');
|
|
134
|
+
}
|
|
135
|
+
const relative = path.relative(path.dirname(targetPath), filePath).split(path.sep).join('/');
|
|
136
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const code = (value) => `\`${value}\``;
|
|
140
|
+
|
|
141
|
+
/** A table cell never breaks the table: a pipe in a value is escaped, not dropped. */
|
|
142
|
+
const cell = (value) => String(value).split('|').join('\\|');
|
|
143
|
+
|
|
144
|
+
/** Every row of a manifest, in the order the file declares them. */
|
|
145
|
+
function allRows(manifest) {
|
|
146
|
+
const rows = [];
|
|
147
|
+
const walk = (node) => {
|
|
148
|
+
if (Array.isArray(node)) {
|
|
149
|
+
for (const item of node) {
|
|
150
|
+
if (item !== null && typeof item === 'object' && typeof item.id === 'string') rows.push(item);
|
|
151
|
+
else walk(item);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (node !== null && typeof node === 'object') for (const value of Object.values(node)) walk(value);
|
|
156
|
+
};
|
|
157
|
+
walk(manifest);
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A parameter as the region prints it: a `from` reference names the file that
|
|
163
|
+
* owns the value (004), anything else is the row's own literal.
|
|
164
|
+
*/
|
|
165
|
+
function parameterValue(key, value) {
|
|
166
|
+
if (key === 'from') {
|
|
167
|
+
// A reference into this package (d.229) names its owner the way the package
|
|
168
|
+
// carries it; a reference out of it names the workspace path. One function
|
|
169
|
+
// decides which, in `manifest/discovery.js`.
|
|
170
|
+
if (value === null || typeof value !== 'object'
|
|
171
|
+
|| (typeof value.path !== 'string' && !isPackageReference(value))) {
|
|
172
|
+
throw new Error('[DocsRegion] A "from" reference with no path - the region renders the owner of the value, '
|
|
173
|
+
+ `and this row offers ${JSON.stringify(value)}. Fix: repair the manifest row.`);
|
|
174
|
+
}
|
|
175
|
+
return code(referenceOwner(value));
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(value)) return value.map((item) => code(item)).join(', ');
|
|
178
|
+
if (value !== null && typeof value === 'object') return code(JSON.stringify(value));
|
|
179
|
+
return code(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parametersOf(row) {
|
|
183
|
+
return Object.entries(row)
|
|
184
|
+
.filter(([key]) => !ROW_METADATA.includes(key))
|
|
185
|
+
.map(([key, value]) => `${code(key)}: ${parameterValue(key, value)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The subject a row is about, when it names one; a row can check a whole repository. */
|
|
189
|
+
function subjectOf(row) {
|
|
190
|
+
if (typeof row.path === 'string') return row.path;
|
|
191
|
+
if (typeof row.name === 'string') return row.name;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ------------------------------------------------------------------ regions */
|
|
196
|
+
|
|
197
|
+
function renderAlignment({ serviceManifest, link }) {
|
|
198
|
+
const rows = allRows(serviceManifest);
|
|
199
|
+
return [
|
|
200
|
+
`Every row of the [biz-service uniform](${link}); \`oa-validate\` blocks on severity `
|
|
201
|
+
+ `${serviceManifest.blocking_severities.map((severity) => code(severity)).join(' and ')}.`,
|
|
202
|
+
'',
|
|
203
|
+
'| row | severity | what |',
|
|
204
|
+
'|---|---|---|',
|
|
205
|
+
...rows.map((row) => {
|
|
206
|
+
const subject = subjectOf(row);
|
|
207
|
+
const what = subject === null ? code(row.check) : `${code(row.check)} over ${code(subject)}`;
|
|
208
|
+
return `| ${code(row.id)} | ${cell(row.severity)} | ${cell(what)} |`;
|
|
209
|
+
})
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderScripts({ serviceManifest, link }) {
|
|
214
|
+
const scripts = serviceManifest.scripts;
|
|
215
|
+
if (scripts === undefined) {
|
|
216
|
+
throw new Error('[DocsRegion] The service manifest declares no scripts block - the region renders it and '
|
|
217
|
+
+ 'knows no list of its own. Fix: repair manifests/biz-service.manifest.json.');
|
|
218
|
+
}
|
|
219
|
+
const forbidden = scripts.forbidden.map((row) => {
|
|
220
|
+
const subject = subjectOf(row);
|
|
221
|
+
return `${subject === null ? code(row.check) : code(subject)} (${code(row.id)})`;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return [
|
|
225
|
+
`The \`package.json\` scripts of the [biz-service uniform](${link}). A body is the row's own, `
|
|
226
|
+
+ 'and `${runner}` stays unexpanded: the name of the one-shot test runner is per repository.',
|
|
227
|
+
'',
|
|
228
|
+
'| script | body | only with | row |',
|
|
229
|
+
'|---|---|---|---|',
|
|
230
|
+
...scripts.required.map((row) => `| ${code(row.name)} | ${cell(code(row.body))} | `
|
|
231
|
+
+ `${typeof row.only_with === 'string' ? code(row.only_with) : '—'} | ${code(row.id)} |`),
|
|
232
|
+
'',
|
|
233
|
+
`Never declared: ${forbidden.join(', ')}.`
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderRuntime({ serviceManifest, link }) {
|
|
238
|
+
const isMemory = (row) => Object.keys(row).some((key) => key.endsWith('_memory'));
|
|
239
|
+
const runtimeIds = new Set((serviceManifest.runtime.rows || []).map((row) => row.id));
|
|
240
|
+
const rows = allRows(serviceManifest).filter((row) => runtimeIds.has(row.id) || isMemory(row));
|
|
241
|
+
|
|
242
|
+
if (rows.length === 0) {
|
|
243
|
+
throw new Error('[DocsRegion] The service manifest declares no runtime rows - the region renders what the '
|
|
244
|
+
+ 'platform fixes at run time. Fix: repair manifests/biz-service.manifest.json.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return [
|
|
248
|
+
`What the [biz-service uniform](${link}) fixes at run time. Every value is a parameter of the row `
|
|
249
|
+
+ 'beside it; a `from` parameter names the file that owns the value, which is where it is read.',
|
|
250
|
+
'',
|
|
251
|
+
'| row | check | parameters |',
|
|
252
|
+
'|---|---|---|',
|
|
253
|
+
...rows.map((row) => `| ${code(row.id)} | ${code(row.check)} | ${cell(parametersOf(row).join(', ') || '—')} |`)
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The template's files as a tree, each node annotated with the rows that own it.
|
|
259
|
+
*
|
|
260
|
+
* The unannotated paths are NOT invented here: they are the files of the
|
|
261
|
+
* template that ships inside this package, which is the shape a service is
|
|
262
|
+
* created from. A hard-coded list beside it would be a second owner of the same
|
|
263
|
+
* fact (004 - a bearer's shape is read, never enumerated).
|
|
264
|
+
*/
|
|
265
|
+
function renderTree({ serviceManifest, templateFiles, link }) {
|
|
266
|
+
if (!Array.isArray(templateFiles) || templateFiles.length === 0) {
|
|
267
|
+
throw new Error(`[DocsRegion] templateFiles is required - the tree is the packaged template's own file list, `
|
|
268
|
+
+ 'never a list written here. Fix: pass listTemplateFiles(TEMPLATE_ROOT).map(outputRelative).');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const forbidden = serviceManifest.files.forbidden || [];
|
|
272
|
+
const forbiddenIds = new Set(forbidden.map((row) => row.id));
|
|
273
|
+
|
|
274
|
+
const owners = new Map();
|
|
275
|
+
for (const row of allRows(serviceManifest)) {
|
|
276
|
+
if (typeof row.path !== 'string' || forbiddenIds.has(row.id)) continue;
|
|
277
|
+
const key = row.path.replace(/\/+$/, '');
|
|
278
|
+
if (!owners.has(key)) owners.set(key, { ids: [], declared: row.path });
|
|
279
|
+
owners.get(key).ids.push(row.id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const tree = new Map();
|
|
283
|
+
const insert = (segments, node) => {
|
|
284
|
+
const [head, ...rest] = segments;
|
|
285
|
+
if (!node.has(head)) node.set(head, new Map());
|
|
286
|
+
if (rest.length > 0) insert(rest, node.get(head));
|
|
287
|
+
};
|
|
288
|
+
for (const file of [...templateFiles].sort()) insert(file.split('/'), tree);
|
|
289
|
+
|
|
290
|
+
const covered = new Set();
|
|
291
|
+
const lines = [];
|
|
292
|
+
const walk = (node, prefix, parentPath) => {
|
|
293
|
+
const names = [...node.keys()].sort();
|
|
294
|
+
names.forEach((name, index) => {
|
|
295
|
+
const child = node.get(name);
|
|
296
|
+
const isDirectory = child.size > 0;
|
|
297
|
+
const full = parentPath === '' ? name : `${parentPath}/${name}`;
|
|
298
|
+
const last = index === names.length - 1;
|
|
299
|
+
const owned = owners.get(full);
|
|
300
|
+
if (owned !== undefined) covered.add(full);
|
|
301
|
+
lines.push(`${prefix}${last ? '└── ' : '├── '}${name}${isDirectory ? '/' : ''}`
|
|
302
|
+
+ (owned === undefined ? '' : ` # ${owned.ids.join(', ')}`));
|
|
303
|
+
if (isDirectory) walk(child, `${prefix}${last ? ' ' : '│ '}`, full);
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
walk(tree, '', '');
|
|
307
|
+
|
|
308
|
+
const outside = [...owners.entries()].filter(([key]) => !covered.has(key));
|
|
309
|
+
|
|
310
|
+
return [
|
|
311
|
+
`The shape of a service repository: the files the packaged template carries, each annotated with the `
|
|
312
|
+
+ `row of the [biz-service uniform](${link}) that owns it. An unannotated path is shape, not a check.`,
|
|
313
|
+
'',
|
|
314
|
+
'```text',
|
|
315
|
+
...lines,
|
|
316
|
+
'```',
|
|
317
|
+
'',
|
|
318
|
+
`Never present: ${forbidden.map((row) => `${code(row.path)} (${code(row.id)})`).join(', ')}.`,
|
|
319
|
+
...(outside.length === 0 ? [] : [
|
|
320
|
+
'',
|
|
321
|
+
`Owned by a row, outside the template: ${outside
|
|
322
|
+
.map(([, value]) => `${code(value.declared)} (${value.ids.map((id) => code(id)).join(', ')})`)
|
|
323
|
+
.join(', ')}.`
|
|
324
|
+
])
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function renderLibraryDuties({ libraryManifest, link }) {
|
|
329
|
+
const discovery = libraryManifest.discovery.library;
|
|
330
|
+
const categories = Object.entries(discovery.categories);
|
|
331
|
+
const duties = Object.entries(libraryManifest.duties);
|
|
332
|
+
const guidance = Object.keys(libraryManifest.guidance || {});
|
|
333
|
+
|
|
334
|
+
return [
|
|
335
|
+
`The categories of the [library uniform](${link}) and the duty sections each one wears. `
|
|
336
|
+
+ 'Row ids only: what a row demands and how it is repaired stays in the manifest.',
|
|
337
|
+
'',
|
|
338
|
+
'| category | layer | may depend on |',
|
|
339
|
+
'|---|---|---|',
|
|
340
|
+
...categories.map(([name, block]) => `| ${code(name)} | ${cell(block.layer)} | `
|
|
341
|
+
+ `${block.may_depend_on.length === 0 ? '—' : block.may_depend_on.map((item) => code(item)).join(', ')} |`),
|
|
342
|
+
'',
|
|
343
|
+
'| duty section | applies to | rows |',
|
|
344
|
+
'|---|---|---|',
|
|
345
|
+
...duties.map(([name, block]) => `| ${code(name)} | ${code(block.applies_to)} | `
|
|
346
|
+
+ `${block.rows.map((row) => code(row.id)).join(', ')} |`),
|
|
347
|
+
...(guidance.length === 0 ? [] : [
|
|
348
|
+
'',
|
|
349
|
+
`Guidance — named by the manifest, checked by nothing, so not a rule: `
|
|
350
|
+
+ `${guidance.map((key) => code(key)).join(', ')}.`
|
|
351
|
+
])
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const RENDERERS = Object.freeze({
|
|
356
|
+
'biz-service-tree': renderTree,
|
|
357
|
+
'biz-service-scripts': renderScripts,
|
|
358
|
+
'biz-service-runtime': renderRuntime,
|
|
359
|
+
'biz-service-alignment': renderAlignment,
|
|
360
|
+
'library-duties': renderLibraryDuties
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/** Which manifest a region needs, so a run without it fails by name and not by `undefined`. */
|
|
364
|
+
const NEEDS_LIBRARY = Object.freeze(['library-duties']);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The text of one region, markers included, LF-terminated lines and no trailing
|
|
368
|
+
* newline - the caller places it in the file.
|
|
369
|
+
*
|
|
370
|
+
* @param {string} id one of REGION_IDS
|
|
371
|
+
* @param {object} params the manifests, where they lie, and which document this is
|
|
372
|
+
* @returns {string}
|
|
373
|
+
*/
|
|
374
|
+
function renderRegion(id, {
|
|
375
|
+
serviceManifest,
|
|
376
|
+
libraryManifest,
|
|
377
|
+
serviceManifestPath,
|
|
378
|
+
libraryManifestPath,
|
|
379
|
+
targetPath,
|
|
380
|
+
targetLabel,
|
|
381
|
+
templateFiles
|
|
382
|
+
} = {}) {
|
|
383
|
+
requireKnownId(id);
|
|
384
|
+
|
|
385
|
+
const usesLibrary = NEEDS_LIBRARY.includes(id);
|
|
386
|
+
const manifest = usesLibrary ? libraryManifest : serviceManifest;
|
|
387
|
+
if (manifest === null || typeof manifest !== 'object') {
|
|
388
|
+
throw new Error(`[DocsRegion] Region "${id}" renders from the ${usesLibrary ? 'library' : 'biz-service'} `
|
|
389
|
+
+ `manifest and got ${JSON.stringify(manifest)}. `
|
|
390
|
+
+ 'Fix: load the manifest with loadManifest() and pass it in.');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const link = linkTo(targetPath, usesLibrary ? libraryManifestPath : serviceManifestPath);
|
|
394
|
+
const markers = markersForRegion(id, targetLabel);
|
|
395
|
+
const body = RENDERERS[id]({ serviceManifest, libraryManifest, templateFiles, link });
|
|
396
|
+
|
|
397
|
+
return [markers.begin, ...body, markers.end].join('\n');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The document with the region replaced. Everything outside the markers comes
|
|
402
|
+
* back byte-identical.
|
|
403
|
+
*
|
|
404
|
+
* A document carrying no markers is a FAILURE, never an insertion: these regions
|
|
405
|
+
* land in `api/docs/**`, a tree owned by other threads, and a generator that
|
|
406
|
+
* decides on its own where a section belongs in somebody else's document is
|
|
407
|
+
* exactly the unsafe side effect `.claude/rules/automation-gates.md` §1
|
|
408
|
+
* requirement 3 forbids. The message carries the two lines to paste.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} fileText
|
|
411
|
+
* @param {string} id
|
|
412
|
+
* @param {string} regionText what renderRegion produced
|
|
413
|
+
* @param {{targetLabel: string}} where
|
|
414
|
+
* @returns {string}
|
|
415
|
+
*/
|
|
416
|
+
function applyRegion(fileText, id, regionText, { targetLabel } = {}) {
|
|
417
|
+
if (typeof fileText !== 'string' || typeof regionText !== 'string') {
|
|
418
|
+
throw new Error('[DocsRegion] applyRegion(fileText, id, regionText) needs two strings - got '
|
|
419
|
+
+ `${typeof fileText} and ${typeof regionText}. Fix: read the document before applying the region.`);
|
|
420
|
+
}
|
|
421
|
+
const markers = markersForRegion(id, targetLabel);
|
|
422
|
+
const applied = replaceRegion(fileText, markers, regionText, { file: targetLabel });
|
|
423
|
+
|
|
424
|
+
if (applied === null) {
|
|
425
|
+
throw new Error(`[DocsRegion] ${targetLabel} carries no region "${id}" - this run replaces a region, and `
|
|
426
|
+
+ 'never decides on its own where one belongs in a document it does not own. '
|
|
427
|
+
+ `Fix: put these two lines where the region belongs, then run the command again:\n`
|
|
428
|
+
+ `${markers.begin}\n${markers.end}`);
|
|
429
|
+
}
|
|
430
|
+
return applied;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Whether the region in the document is what the manifests render, and the diff
|
|
435
|
+
* when it is not. Prose outside the markers is never compared.
|
|
436
|
+
*
|
|
437
|
+
* @returns {{ok: boolean, diff: string}}
|
|
438
|
+
*/
|
|
439
|
+
function checkRegion(fileText, id, regionText, { targetLabel } = {}) {
|
|
440
|
+
if (typeof fileText !== 'string' || typeof regionText !== 'string') {
|
|
441
|
+
throw new Error('[DocsRegion] checkRegion(fileText, id, regionText) needs two strings - got '
|
|
442
|
+
+ `${typeof fileText} and ${typeof regionText}. Fix: read the document before checking it.`);
|
|
443
|
+
}
|
|
444
|
+
requireKnownId(id);
|
|
445
|
+
return checkAgainst({
|
|
446
|
+
text: fileText,
|
|
447
|
+
markers: markersForRegion(id, targetLabel),
|
|
448
|
+
regionText,
|
|
449
|
+
source: SOURCES[id]
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = {
|
|
454
|
+
REGION_IDS,
|
|
455
|
+
DESCRIPTIONS,
|
|
456
|
+
describeRegion,
|
|
457
|
+
markersForRegion,
|
|
458
|
+
regenerateCommand,
|
|
459
|
+
regionIdsIn,
|
|
460
|
+
renderRegion,
|
|
461
|
+
applyRegion,
|
|
462
|
+
checkRegion
|
|
463
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One implementation of a generated region in this package.
|
|
5
|
+
*
|
|
6
|
+
* The marker shape is `api/scripts/ci/sync-biz-facts.mjs` (`BEGIN`/`END
|
|
7
|
+
* GENERATED`), taken over verbatim apart from the id and the command. Two
|
|
8
|
+
* renderers now write regions — `readmePointer.js` (the uniform pointer of a
|
|
9
|
+
* library README, d.216) and `docsRegion.js` (the descriptive lists of the
|
|
10
|
+
* documentation tree) — and they find, replace, extract and diff a region
|
|
11
|
+
* through this module rather than each carrying its own copy of the four
|
|
12
|
+
* functions (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
13
|
+
*
|
|
14
|
+
* What is deliberately NOT here: where a region is placed when the file carries
|
|
15
|
+
* no markers. That is the one decision the two callers do not share — a library
|
|
16
|
+
* README gets one inserted after its node header, a document of somebody else's
|
|
17
|
+
* tree never gets one inserted at all — so it stays with each caller.
|
|
18
|
+
*
|
|
19
|
+
* Pure text: nothing here opens a file, reads the environment or knows a path.
|
|
20
|
+
*
|
|
21
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-005
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Character offsets of every fenced code block, so a marker shown as an EXAMPLE
|
|
26
|
+
* is never mistaken for a region.
|
|
27
|
+
*
|
|
28
|
+
* Measured on the api side 2026-09-09, hours after the queue generator landed:
|
|
29
|
+
* `api/docs/standards/INFRA-DOC-STANDARD.md` documents the marker pair inside a
|
|
30
|
+
* fence (still does, at the section on generated regions), the generator read
|
|
31
|
+
* raw text, and it took its own documentation for a region naming a queue family
|
|
32
|
+
* that does not exist — a gate red on the tree it had just been added to
|
|
33
|
+
* (`.claude/rules/automation-gates.md` §3). `api/scripts/ci/lib/generatedRegion.js`
|
|
34
|
+
* has carried the fix since; this module, writing the same marker shape into the
|
|
35
|
+
* same tree, did not, so one input had two answers depending on which module met
|
|
36
|
+
* it (`automation-gates.md` §1.1, predictable). The documents `docsRegion.js`
|
|
37
|
+
* writes into are precisely the ones that TEACH the shape.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} text
|
|
40
|
+
* @returns {Array<[number, number]>}
|
|
41
|
+
*/
|
|
42
|
+
function fencedRanges(text) {
|
|
43
|
+
const ranges = [];
|
|
44
|
+
let offset = 0;
|
|
45
|
+
let openedAt = null;
|
|
46
|
+
for (const line of text.split('\n')) {
|
|
47
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
48
|
+
if (openedAt === null) openedAt = offset;
|
|
49
|
+
else { ranges.push([openedAt, offset + line.length]); openedAt = null; }
|
|
50
|
+
}
|
|
51
|
+
offset += line.length + 1;
|
|
52
|
+
}
|
|
53
|
+
// An unterminated fence swallows the rest of the document, which is what a
|
|
54
|
+
// renderer does too.
|
|
55
|
+
if (openedAt !== null) ranges.push([openedAt, text.length]);
|
|
56
|
+
return ranges;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isFenced(ranges, index) {
|
|
60
|
+
return ranges.some(([from, to]) => index >= from && index < to);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** First occurrence of `needle` that is not inside a fence, or -1. */
|
|
64
|
+
function indexOutsideFence(text, needle, ranges) {
|
|
65
|
+
let at = text.indexOf(needle);
|
|
66
|
+
while (at !== -1 && isFenced(ranges, at)) at = text.indexOf(needle, at + 1);
|
|
67
|
+
return at;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The two marker lines of one region.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} id the region's id, unique within the file it lands in
|
|
74
|
+
* @param {string} regenerate the command that rewrites it, printed in the marker
|
|
75
|
+
* @returns {{begin: string, end: string}}
|
|
76
|
+
*/
|
|
77
|
+
function markersFor(id, regenerate) {
|
|
78
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
79
|
+
throw new Error('[GeneratedRegion] Region id is required - markersFor(id, regenerate) got '
|
|
80
|
+
+ `${JSON.stringify(id)}. Fix: pass the id the document carries in its markers.`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof regenerate !== 'string' || regenerate.length === 0) {
|
|
83
|
+
throw new Error('[GeneratedRegion] Regenerate command is required - a marker that does not say what '
|
|
84
|
+
+ `rewrites the region leaves the reader nothing to run, and markersFor got ${JSON.stringify(regenerate)}. `
|
|
85
|
+
+ 'Fix: pass the exact command.');
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
begin: `<!-- BEGIN GENERATED: ${id} — regenerate: ${regenerate} -->`,
|
|
89
|
+
end: `<!-- END GENERATED: ${id} -->`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where the region sits in the text, or null when the text carries none.
|
|
95
|
+
*
|
|
96
|
+
* Damaged markers throw rather than being worked around: a half-written or
|
|
97
|
+
* inverted pair cannot be replaced in place without eating the file
|
|
98
|
+
* (`.claude/rules/architecture-principles.md` §3 - no fallbacks).
|
|
99
|
+
*
|
|
100
|
+
* The message names THIS module, not the caller: the error-message contract of
|
|
101
|
+
* this package is checked statically over the source
|
|
102
|
+
* (`tests/unit/error-message-contract.test.js`), so a prefix passed in as a
|
|
103
|
+
* parameter would be invisible to it — and `[GeneratedRegion]` is in any case
|
|
104
|
+
* the honest answer to "what rejected this". Which file the damaged markers are
|
|
105
|
+
* in is the caller's to say, and it travels in the message.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} text
|
|
108
|
+
* @param {{begin: string, end: string}} markers
|
|
109
|
+
* @param {{file: string}} where the document the region belongs to, for the fix line
|
|
110
|
+
* @returns {{start: number, endExclusive: number}|null}
|
|
111
|
+
*/
|
|
112
|
+
function findRegion(text, markers, { file } = {}) {
|
|
113
|
+
const ranges = fencedRanges(text);
|
|
114
|
+
// Both markers are searched from the start of the document, NOT the END from
|
|
115
|
+
// the BEGIN onwards: searching forward from BEGIN can never yield an END
|
|
116
|
+
// before it, so the inversion branch below would be dead code and a malformed
|
|
117
|
+
// document would render nothing while the run said nothing
|
|
118
|
+
// (`automation-gates.md` §5).
|
|
119
|
+
const begin = indexOutsideFence(text, markers.begin, ranges);
|
|
120
|
+
const end = indexOutsideFence(text, markers.end, ranges);
|
|
121
|
+
|
|
122
|
+
if (begin === -1 && end === -1) return null;
|
|
123
|
+
if (begin !== -1 && end !== -1 && end < begin) {
|
|
124
|
+
throw new Error('[GeneratedRegion] Generated region has END before BEGIN - the markers are damaged and '
|
|
125
|
+
+ `replacing in place would eat the file. Fix: repair or delete the region in ${file}.`);
|
|
126
|
+
}
|
|
127
|
+
if (begin === -1 || end === -1) {
|
|
128
|
+
throw new Error('[GeneratedRegion] Generated region has only one marker - a half-written region cannot be '
|
|
129
|
+
+ `replaced in place. Fix: remove the stray marker from ${file} and run the generator again.`);
|
|
130
|
+
}
|
|
131
|
+
return { start: begin, endExclusive: end + markers.end.length };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The text with the region replaced, or null when the text carries no region.
|
|
136
|
+
* Everything outside the markers comes back byte-identical.
|
|
137
|
+
*
|
|
138
|
+
* @returns {string|null}
|
|
139
|
+
*/
|
|
140
|
+
function replaceRegion(text, markers, regionText, where) {
|
|
141
|
+
const found = findRegion(text, markers, where);
|
|
142
|
+
if (found === null) return null;
|
|
143
|
+
return text.slice(0, found.start) + regionText + text.slice(found.endExclusive);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The region as it stands in the text, or null when there is none. Tolerant by
|
|
148
|
+
* design: a check reports "no region" rather than throwing, because the caller
|
|
149
|
+
* turns that into a finding with a fix.
|
|
150
|
+
*
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
function extractRegion(text, markers) {
|
|
154
|
+
const ranges = fencedRanges(text);
|
|
155
|
+
const begin = indexOutsideFence(text, markers.begin, ranges);
|
|
156
|
+
const end = indexOutsideFence(text, markers.end, ranges);
|
|
157
|
+
if (begin === -1 || end === -1 || end < begin) return null;
|
|
158
|
+
return text.slice(begin, end + markers.end.length);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether the document DECLARES the region — the question `--all` asks before it
|
|
163
|
+
* renders anything.
|
|
164
|
+
*
|
|
165
|
+
* It is the only place that question is answered: a caller matching the marker
|
|
166
|
+
* string itself would be a second, fence-blind copy of this module's rule
|
|
167
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern), which is what
|
|
168
|
+
* `docsRegion.regionIdsIn` was until d.319.
|
|
169
|
+
*
|
|
170
|
+
* A half-written region answers TRUE: the damage belongs in the report the
|
|
171
|
+
* caller produces, and answering "not declared" would hide it.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} text
|
|
174
|
+
* @param {{begin: string, end: string}} markers
|
|
175
|
+
* @returns {boolean}
|
|
176
|
+
*/
|
|
177
|
+
function hasRegion(text, markers) {
|
|
178
|
+
const ranges = fencedRanges(text);
|
|
179
|
+
return indexOutsideFence(text, markers.begin, ranges) !== -1
|
|
180
|
+
|| indexOutsideFence(text, markers.end, ranges) !== -1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The drift, named line by line, so a failing check says what differs instead of
|
|
185
|
+
* announcing that something does (`.claude/rules/automation-gates.md` §1
|
|
186
|
+
* requirement 4).
|
|
187
|
+
*
|
|
188
|
+
* @param {{expected: string, actual: string|null, source: string}} params
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
function renderDiff({ expected, actual, source }) {
|
|
192
|
+
if (actual === null) {
|
|
193
|
+
return [
|
|
194
|
+
`--- rendered from ${source}`,
|
|
195
|
+
'+++ on disk: no generated region',
|
|
196
|
+
...expected.split('\n').map((line) => `-${line}`)
|
|
197
|
+
].join('\n');
|
|
198
|
+
}
|
|
199
|
+
return [
|
|
200
|
+
`--- rendered from ${source}`,
|
|
201
|
+
'+++ on disk',
|
|
202
|
+
...expected.split('\n').map((line) => `-${line}`),
|
|
203
|
+
...actual.split('\n').map((line) => `+${line}`)
|
|
204
|
+
].join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether the region on disk is what the renderer produces, and the diff when it
|
|
209
|
+
* is not. Only the region is compared; prose outside the markers is nobody's
|
|
210
|
+
* generated output.
|
|
211
|
+
*
|
|
212
|
+
* @returns {{ok: boolean, diff: string}} `diff` is '' exactly when `ok` is true
|
|
213
|
+
*/
|
|
214
|
+
function checkRegion({ text, markers, regionText, source }) {
|
|
215
|
+
const actual = extractRegion(text, markers);
|
|
216
|
+
if (actual === regionText) return { ok: true, diff: '' };
|
|
217
|
+
return { ok: false, diff: renderDiff({ expected: regionText, actual, source }) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
markersFor,
|
|
222
|
+
findRegion,
|
|
223
|
+
hasRegion,
|
|
224
|
+
replaceRegion,
|
|
225
|
+
extractRegion,
|
|
226
|
+
renderDiff,
|
|
227
|
+
checkRegion
|
|
228
|
+
};
|