@onlineapps/conn-orch-validator 12.2.0 → 13.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +597 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/README.md +3 -2
  53. package/templates/business-service/config/env-templates/shared.env +1 -0
  54. package/templates/business-service/src/config/index.js +15 -0
  55. package/TESTING_STRATEGY.md +0 -92
  56. package/jest.config.js +0 -37
@@ -54,6 +54,8 @@
54
54
  const fs = require('fs');
55
55
  const path = require('path');
56
56
 
57
+ const { API_PREFIX } = require('../../manifest/workspaceRoot');
58
+
57
59
  /** The five header fields, in the order SCRIPTS-STANDARD §1 writes them. */
58
60
  const ORDER = Object.freeze(['Owns', 'Usage', 'Shell', 'Exit', 'Status']);
59
61
 
@@ -142,8 +144,13 @@ const COMMENT_LINE = /^\s*#/;
142
144
  */
143
145
  const INERT_ASSERTION = Object.freeze([/^\s*!\s/, /\|\|\s*!\s/]);
144
146
 
145
- /** The prefix a citation of the api checkout opens with, as every document writes it. */
146
- const API_PREFIX = 'api/';
147
+ /**
148
+ * The prefix a citation of the api checkout opens with, as every document writes
149
+ * it: the checkout's conventional name from its one owner
150
+ * (`src/manifest/workspaceRoot.js` § `API_PREFIX`), and the separator that makes
151
+ * it a path head rather than a word a file name could start with.
152
+ */
153
+ const API_CITATION_PREFIX = `${API_PREFIX}/`;
147
154
 
148
155
  /**
149
156
  * Every file under `dir` whose name matches, depth first, as `/`-separated
@@ -175,9 +182,9 @@ function walk(dir, root, matches) {
175
182
  * @returns {string|null} the absolute path to test, or null when unresolvable here
176
183
  */
177
184
  function resolveSeeTarget({ target, root, apiRoot }) {
178
- if (!target.startsWith(API_PREFIX)) return path.join(root, target);
185
+ if (!target.startsWith(API_CITATION_PREFIX)) return path.join(root, target);
179
186
  if (apiRoot === null) return null;
180
- return path.join(apiRoot, target.slice(API_PREFIX.length));
187
+ return path.join(apiRoot, target.slice(API_CITATION_PREFIX.length));
181
188
  }
182
189
 
183
190
  /**
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
 
17
17
  const { CHECK_SCOPES } = require('../manifestShape');
18
+ const { workspaceRelativeOf } = require('../workspaceRoot');
18
19
 
19
20
  /** The scopes whose `where` is read from the workspace root — every one but `service`. */
20
21
  const WORKSPACE_RELATIVE_SCOPES = Object.freeze(CHECK_SCOPES.filter((scope) => scope !== 'service'));
@@ -107,6 +108,10 @@ function readPackage(serviceRoot) {
107
108
  * A `scope: workspace` check keeps the strict rule — it reports ABOUT the
108
109
  * workspace, so without one it must not report at all.
109
110
  *
111
+ * How the path is SPELLED is not this function's to decide: a bearer inside the
112
+ * api checkout is written `api/…` whatever that checkout is named, and
113
+ * `workspaceRoot.js` § workspaceRelativeOf owns that rule for every reader of it.
114
+ *
110
115
  * @param {{ scope: string, serviceRoot: string, workspaceRoot: string|null, relative?: string }} params
111
116
  * @returns {string}
112
117
  */
@@ -122,9 +127,7 @@ function whereOf({ scope, serviceRoot, workspaceRoot, relative = '' }) {
122
127
  + 'place it. Fix: such a check runs only when the workspace root resolved.');
123
128
  }
124
129
 
125
- const dir = placeable
126
- ? path.relative(workspaceRoot, path.resolve(serviceRoot)).split(path.sep).join('/')
127
- : '';
130
+ const dir = placeable ? workspaceRelativeOf(workspaceRoot, serviceRoot) : '';
128
131
  if (dir === '' || dir.startsWith('..')) return relative;
129
132
  return relative ? `${dir}/${relative}` : dir;
130
133
  }
@@ -14,6 +14,7 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
 
16
16
  const { readPackage, readText, whereOf, appliesTo } = require('./libraryContext');
17
+ const { README_FILE } = require('../../sync/readmeFile');
17
18
 
18
19
  /**
19
20
  * The node header, in the blockquote shape the infra standard fixes. `Owns:` must
@@ -25,6 +26,32 @@ const OWNS = /^>[ \t]*Owns:[ \t]*\S/m;
25
26
  const STATUS = /^>[ \t]*Status:[ \t]*(\S+)/m;
26
27
  const STATUS_VALUES = Object.freeze(['current', 'draft', 'archived']);
27
28
 
29
+ /**
30
+ * A package version written into a README by hand.
31
+ *
32
+ * A version is a DESCRIPTIVE fact, and a descriptive fact written by hand is a
33
+ * lie with a commencement date (`.claude/rules/doc-code-binding.md` §1). It has
34
+ * ONE owner, `package.json`, and the publish path reads it from there. Measured
35
+ * 2026-09-20: `error-handler-core`'s README footer said `Version: 1.0.0` while
36
+ * the package was `3.0.0` — two majors stale, and no mechanism on the platform
37
+ * said a word.
38
+ *
39
+ * Two shapes, and both are narrow ON PURPOSE:
40
+ * - `Version: 1.2.3` — the digits must follow the label directly, so a
41
+ * configuration example (`serviceVersion: '1.0.0'`, quoted) does not light
42
+ * up. A README teaching an API shows values, and a gate that fires on the
43
+ * teaching is a gate people learn to work around;
44
+ * - a standalone `v1.2.3` — the footer spelling, bounded on both sides, so
45
+ * `@onlineapps/lib-v2` and prose like "serverv1.2.3" are not matches.
46
+ * A dotted triple with no label at all is NOT matched: `docs/biz/…` paths,
47
+ * dates and dependency tables carry those, and the false finding would cost more
48
+ * than the fact is worth.
49
+ */
50
+ const README_VERSION_SHAPES = Object.freeze([
51
+ /[Vv]ersion:[ \t]*\d+\.\d+\.\d+/,
52
+ /(^|[\s(*_])v\d+\.\d+\.\d+([\s)*_.,]|$)/m
53
+ ]);
54
+
28
55
  const filePresent = Object.freeze({
29
56
  scope: 'service',
30
57
  requires: Object.freeze(['path']),
@@ -49,9 +76,13 @@ const libraryReadme = Object.freeze({
49
76
  const { json } = readPackage(serviceRoot);
50
77
  if (json === null || !appliesTo(block, json)) return [];
51
78
 
52
- const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: 'README.md' });
53
- const body = readText(path.join(serviceRoot, 'README.md'));
54
- if (body === null) return [{ where, what: 'README.md is absent — the package documents nothing' }];
79
+ // The file name is `readmeFile.js`'s fact, not this row's: row `L-README`
80
+ // declares no `path` (unlike `L-README-REGION` one row down), so there is
81
+ // nothing here to read it off, and a literal would be the package's fifth
82
+ // copy of one name (`.claude/rules/change-discipline.md` § One rail).
83
+ const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: README_FILE });
84
+ const body = readText(path.join(serviceRoot, README_FILE));
85
+ if (body === null) return [{ where, what: `${README_FILE} is absent — the package documents nothing` }];
55
86
 
56
87
  const problems = [];
57
88
  if (!OWNS.test(body)) problems.push('no "> Owns:" line');
@@ -62,14 +93,153 @@ const libraryReadme = Object.freeze({
62
93
  problems.push(`Status "${status[1]}" is not one of ${STATUS_VALUES.join(', ')}`);
63
94
  }
64
95
 
96
+ for (const shape of README_VERSION_SHAPES) {
97
+ const written = shape.exec(body);
98
+ if (written !== null) {
99
+ problems.push(`writes the package version by hand ("${written[0].trim()}") — package.json owns it`);
100
+ }
101
+ }
102
+
65
103
  if (problems.length === 0) return [];
66
104
  return [{ where, what: `the node header does not hold: ${problems.join('; ')}` }];
67
105
  }
68
106
  });
69
107
 
108
+ /**
109
+ * A version heading of a CHANGELOG, in either shape this tree writes:
110
+ * `## [1.0.3] — 2026-09-15`, `## [Unreleased]`, or the bare `## 1.0.0`.
111
+ *
112
+ * The shape is NOT what this row is about, so both are read. Demanding brackets
113
+ * would smuggle a second rule — a formatting one — into a row whose `why` is
114
+ * about content, and a rule nobody decided is not a rule a gate may enforce.
115
+ */
116
+ const CHANGELOG_HEADING = /^##[ \t]+(\S.*?)[ \t]*$/;
117
+ /** A prerelease version (`1.2.3-rc.4`): its entry may still stand under `[Unreleased]`. */
118
+ const PRERELEASE = /^\d+\.\d+\.\d+-[0-9A-Za-z.]+$/;
119
+ const BRACKETED_NAME = /^\[([^\]]+)\]/;
120
+ /** ` — `, ` – ` or ` - ` separating the version from the date behind it. */
121
+ const NAME_AND_DATE = /[ \t]+[—–-][ \t]+/;
122
+
123
+ /** @returns {string|null} the version a heading line names, or `null` if it is not one */
124
+ function headingName(line) {
125
+ const heading = CHANGELOG_HEADING.exec(line);
126
+ if (heading === null) return null;
127
+
128
+ const bracketed = BRACKETED_NAME.exec(heading[1]);
129
+ if (bracketed !== null) return bracketed[1].trim();
130
+
131
+ return heading[1].split(NAME_AND_DATE)[0].trim();
132
+ }
133
+
134
+ /**
135
+ * The sections of a CHANGELOG in file order: `{ name, body }`, where `body` is
136
+ * every line up to the next version heading.
137
+ */
138
+ function changelogSections(text) {
139
+ const sections = [];
140
+ let current = null;
141
+
142
+ for (const line of text.split('\n')) {
143
+ const name = headingName(line);
144
+ if (name !== null) {
145
+ if (current !== null) sections.push(current);
146
+ current = { name, body: [] };
147
+ } else if (current !== null) {
148
+ current.body.push(line);
149
+ }
150
+ }
151
+ if (current !== null) sections.push(current);
152
+
153
+ return sections;
154
+ }
155
+
156
+ /**
157
+ * Whether a section SAYS anything. One non-blank line that is not itself a
158
+ * sub-heading is enough — a pin-only release writes a sentence about the pin and
159
+ * no `### Changed` above it, and that sentence IS the entry. A lone `### Changed`
160
+ * with nothing under it is not.
161
+ */
162
+ function hasContent(section) {
163
+ return section.body.some((line) => {
164
+ const trimmed = line.trim();
165
+ return trimmed !== '' && !/^#{1,6}[ \t]/.test(trimmed);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * `L-CHANGELOG` measures what its `why` says: not that the file exists, but that
171
+ * the version being published has an entry, and that the entry is not an empty
172
+ * heading.
173
+ *
174
+ * `file-present` was the check here until d.731b, and a file carrying a bare
175
+ * `## [2.0.1] — 2026-09-15` with nothing under it passed it — the version was
176
+ * still one nobody can reason about afterwards, which is the row's own reason
177
+ * for existing. Measured 2026-09-20 over the 28 platform packages: three were in
178
+ * exactly that state while the row was green (`automation-gates.md` §5 — a
179
+ * mechanism that checks less than it announces is a false guarantee). The row is
180
+ * sharpened only now, after those three were written (§3 — a gate lands with
181
+ * compliance, never ahead of it).
182
+ *
183
+ * A prerelease is the one case where the entry may live elsewhere: an rc wave
184
+ * publishes `X.Y.Z-rc.N` without moving the `[Unreleased]` body into a section of
185
+ * its own (`.claude/rules/architecture-principles.md` § Release channels), so a
186
+ * non-empty `[Unreleased]` answers for it.
187
+ *
188
+ * WHICH VERSION IS JUDGED — two legitimate questions, not a value and its fallback
189
+ * (d.1040). A publish asks about the version it is ABOUT to write: the gate's dry
190
+ * run hands it as `asVersion` (`oa-validate --as-version`), because package.json
191
+ * still carries the old one until the wave bumps it — judged by that, the row let a
192
+ * missing `## [NEW]` through and refused a package for an old version nobody was
193
+ * publishing (d.1033, d.1038). An audit of the tree as it stands asks about the
194
+ * version package.json names, and passes no `asVersion`. The finding says which of
195
+ * the two it answered.
196
+ */
197
+ const libraryChangelogEntry = Object.freeze({
198
+ scope: 'service',
199
+ requires: Object.freeze(['path']),
200
+
201
+ run({ row, block, serviceRoot, workspaceRoot, asVersion = null }) {
202
+ const { json } = readPackage(serviceRoot);
203
+ if (json === null || !appliesTo(block, json)) return [];
204
+
205
+ const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: row.path });
206
+ const body = readText(path.join(serviceRoot, ...row.path.split('/')));
207
+ if (body === null) {
208
+ const because = row.why ? `: ${row.why}` : '';
209
+ return [{ where, what: `file absent — this uniform requires it${because}` }];
210
+ }
211
+
212
+ const version = asVersion === null ? json.version : asVersion;
213
+ if (typeof version !== 'string' || version.trim() === '') {
214
+ return [{ where, what: 'package.json names no version, so there is no entry to look for' }];
215
+ }
216
+ // Where the judged version came from, said only for a publish, so nobody reads
217
+ // the finding against package.json.
218
+ const source = asVersion === null ? null : `given by --as-version; package.json says ${json.version}`;
219
+
220
+ const sections = changelogSections(body);
221
+ const own = sections.find((section) => section.name === version);
222
+
223
+ if (own !== undefined) {
224
+ if (hasContent(own)) return [];
225
+ const which = source === null ? version : `${version} (${source})`;
226
+ return [{ where, what: `the entry for ${which} is an empty heading — the published version says nothing` }];
227
+ }
228
+
229
+ if (PRERELEASE.test(version)) {
230
+ const unreleased = sections.find((section) => section.name === 'Unreleased');
231
+ if (unreleased !== undefined && hasContent(unreleased)) return [];
232
+ }
233
+
234
+ const named = source === null ? version : `${version}, ${source}`;
235
+ return [{ where, what: `no entry for the version being published (${named})` }];
236
+ }
237
+ });
238
+
70
239
  module.exports = {
71
240
  checks: [
72
241
  { name: 'file-present', check: filePresent },
73
- { name: 'library-readme', check: libraryReadme }
242
+ { name: 'library-readme', check: libraryReadme },
243
+ { name: 'library-changelog-entry', check: libraryChangelogEntry }
74
244
  ]
75
245
  };
@@ -11,6 +11,14 @@
11
11
  * miss the ninth that has the file and lists nothing. So the check reproduces
12
12
  * npm's own precedence — `files` allowlist first, then `.npmignore`, then
13
13
  * `.gitignore` — and asks one question: does `tests/` end up in the tarball.
14
+ * An ignore file is read as npm walks it, negations included (d.1006,
15
+ * `testsShippedBy`), not as a list of excluding shapes.
16
+ *
17
+ * WHICH `tests/`: the package's own tier, at the package root. A `tests/` under
18
+ * `templates/**` is not a tier of the package but content of a template it ships
19
+ * on purpose — `templates/business-service/tests/**` is what `oa-sync-template
20
+ * --new` renders into a new service — so it must travel, and this row never
21
+ * looks at it (d.1002).
14
22
  *
15
23
  * @see .claude/rules/architecture-principles.md § Shared Packages
16
24
  */
@@ -28,30 +36,196 @@ const UNIT_DIR = path.join('tests', 'unit');
28
36
  const TEST_FILE = /\.(test|spec)\.js$/;
29
37
 
30
38
  /**
31
- * Does an ignore-file line take `tests/` out of the tarball? npm reads these
32
- * with gitignore semantics; the shapes that actually occur are the bare name,
33
- * the anchored name, and the name with a trailing slash or glob.
39
+ * One line of an ignore file, read the way npm reads it (`ignore-walk`, gitignore
40
+ * syntax): a leading `!` negates, a trailing `/` matches directories only, a
41
+ * pattern with a `/` in it is anchored at the package root, and one without is
42
+ * matched against the last path segment at any depth.
43
+ *
44
+ * @param {string} line a trimmed, non-comment line
45
+ * @param {number} index its position in the file — the last matching line wins
46
+ * @returns {{index: number, line: string, negate: boolean, dirOnly: boolean, anchored: boolean, segments: string[]}}
47
+ */
48
+ function parseIgnoreLine(line, index) {
49
+ const negate = line.startsWith('!');
50
+ let pattern = negate ? line.slice(1) : line;
51
+ const dirOnly = pattern.endsWith('/');
52
+ if (dirOnly) pattern = pattern.slice(0, -1);
53
+ const anchored = pattern.includes('/');
54
+ pattern = pattern.replace(/^\//, '');
55
+ return { index, line, negate, dirOnly, anchored, segments: pattern.split('/') };
56
+ }
57
+
58
+ /**
59
+ * Do `patternSegments` match all of `pathSegments`? `**` matches any number of
60
+ * segments, except as the LAST segment, where it matches at least one — `tests/**`
61
+ * is everything inside `tests`, not `tests` itself.
62
+ *
63
+ * @param {string[]} patternSegments
64
+ * @param {string[]} pathSegments
65
+ * @returns {boolean}
66
+ */
67
+ function segmentsMatch(patternSegments, pathSegments) {
68
+ if (patternSegments.length === 0) return pathSegments.length === 0;
69
+ const [head, ...rest] = patternSegments;
70
+ if (head === '**') {
71
+ const from = rest.length === 0 ? 1 : 0;
72
+ for (let skip = from; skip <= pathSegments.length; skip += 1) {
73
+ if (segmentsMatch(rest, pathSegments.slice(skip))) return true;
74
+ }
75
+ return false;
76
+ }
77
+ if (pathSegments.length === 0 || !segmentMatches(head, pathSegments[0])) return false;
78
+ return segmentsMatch(rest, pathSegments.slice(1));
79
+ }
80
+
81
+ /**
82
+ * Could an anchored pattern match something strictly INSIDE the directory
83
+ * `dirSegments`? This is the question npm asks before it walks into a directory an
84
+ * earlier line excluded: a later `!` pattern that starts with the directory's name
85
+ * — or with `**` — reopens it, and a pattern without a `/` never does (measured,
86
+ * d.1006: `/tests/` then `!*.js` ships nothing, `/tests/` then `!**\/*.test.js`
87
+ * ships the tier).
88
+ *
89
+ * @param {string[]} patternSegments
90
+ * @param {string[]} dirSegments
91
+ * @returns {boolean}
92
+ */
93
+ function couldMatchInside(patternSegments, dirSegments) {
94
+ if (patternSegments.length === 0) return false;
95
+ const [head, ...rest] = patternSegments;
96
+ if (head === '**') return true;
97
+ if (dirSegments.length === 0) return true;
98
+ if (!segmentMatches(head, dirSegments[0])) return false;
99
+ return couldMatchInside(rest, dirSegments.slice(1));
100
+ }
101
+
102
+ /**
103
+ * Does `rule` match the path `segments` (a directory when `isDir`)?
34
104
  *
35
- * @param {string} body the contents of .npmignore or .gitignore
36
105
  * @returns {boolean}
37
106
  */
38
- function ignoresTests(body) {
39
- return body
107
+ function ruleMatches(rule, segments, isDir) {
108
+ if (rule.dirOnly && !isDir) return false;
109
+ if (!rule.anchored) return segmentMatches(rule.segments[0], segments[segments.length - 1]);
110
+ return segmentsMatch(rule.segments, segments);
111
+ }
112
+
113
+ /** The last rule matching the path, or `null` — the last matching line wins. */
114
+ function lastMatch(rules, segments, isDir) {
115
+ let found = null;
116
+ for (const rule of rules) {
117
+ if (ruleMatches(rule, segments, isDir)) found = rule;
118
+ }
119
+ return found;
120
+ }
121
+
122
+ /**
123
+ * Which of the tier's files an ignore file lets into the tarball, reproducing
124
+ * npm's walk (d.1006, measured against `npm pack --dry-run --json`, npm 11):
125
+ * every directory on the way down is judged by the last line matching it; an
126
+ * EXCLUDED directory is walked into anyway when a later `!` line could match
127
+ * inside it (`couldMatchInside`), and is otherwise dropped whole; a file is
128
+ * judged by the last line matching it, and ships when none excludes it.
129
+ *
130
+ * Until d.1006 the row read only the excluding shapes and none of the negations,
131
+ * so `/tests/` followed by `!tests/**` — which npm packs — passed as excluded.
132
+ *
133
+ * @param {string} body the contents of .npmignore or .gitignore
134
+ * @param {string[]} testFiles the tier's files, relative, `/`-separated (`tests/unit/a.test.js`)
135
+ * @returns {{shipped: string[], reincludedBy: string|null}} what ships, and the
136
+ * `!` line that brought the tier back after a line had excluded it
137
+ */
138
+ function testsShippedBy(body, testFiles) {
139
+ const rules = body
40
140
  .split('\n')
41
141
  .map((line) => line.trim())
42
142
  .filter((line) => line.length > 0 && !line.startsWith('#'))
43
- .some((line) => /^\/?tests(\/(\*\*?)?)?$/.test(line));
143
+ .map(parseIgnoreLine);
144
+
145
+ const shipped = [];
146
+ let reincludedBy = null;
147
+ for (const file of testFiles) {
148
+ const segments = file.split('/');
149
+ let dropped = false;
150
+ for (let depth = 1; depth < segments.length && !dropped; depth += 1) {
151
+ const dir = segments.slice(0, depth);
152
+ const last = lastMatch(rules, dir, true);
153
+ if (last === null) continue;
154
+ if (last.negate) {
155
+ reincludedBy = reincludedBy || last.line;
156
+ continue;
157
+ }
158
+ const opener = rules.find((rule) => rule.negate && rule.index > last.index && rule.anchored
159
+ && couldMatchInside(rule.segments, dir));
160
+ if (opener === undefined) dropped = true;
161
+ else reincludedBy = reincludedBy || opener.line;
162
+ }
163
+ if (dropped) continue;
164
+ const last = lastMatch(rules, segments, false);
165
+ if (last !== null && !last.negate) continue;
166
+ if (last !== null) reincludedBy = reincludedBy || last.line;
167
+ shipped.push(file);
168
+ }
169
+ return { shipped, reincludedBy };
44
170
  }
45
171
 
46
172
  /**
47
- * Does a `files` allowlist entry let `tests/` in? An entry is a path or a glob;
48
- * it reaches the directory only when its first segment is the directory itself.
173
+ * Every file under the tier, relative to the package root, `/`-separated.
49
174
  *
50
- * @param {string[]} files the allowlist
175
+ * @param {string} serviceRoot
176
+ * @returns {string[]}
177
+ */
178
+ function listTestFiles(serviceRoot) {
179
+ const out = [];
180
+ const walk = (relative) => {
181
+ for (const entry of fs.readdirSync(path.join(serviceRoot, relative), { withFileTypes: true })) {
182
+ const child = `${relative}/${entry.name}`;
183
+ if (entry.isDirectory()) walk(child);
184
+ else out.push(child);
185
+ }
186
+ };
187
+ walk(TEST_DIR);
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Does ONE segment of an allowlist glob match `name`? `*` and `?` stay inside a
193
+ * segment, as npm's globbing has them; `**` matches any segment.
194
+ *
195
+ * @param {string} segment one `/`-separated part of an allowlist entry
196
+ * @param {string} name the directory name asked about
51
197
  * @returns {boolean}
52
198
  */
53
- function allowsTests(files) {
54
- return files.some((entry) => String(entry).replace(/^\.?\//, '').split('/')[0] === TEST_DIR);
199
+ function segmentMatches(segment, name) {
200
+ if (segment === '**') return true;
201
+ const pattern = segment
202
+ .split('')
203
+ .map((ch) => {
204
+ if (ch === '*') return '[^/]*';
205
+ if (ch === '?') return '[^/]';
206
+ return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
207
+ })
208
+ .join('');
209
+ return new RegExp(`^${pattern}$`).test(name);
210
+ }
211
+
212
+ /**
213
+ * The allowlist entry that lets `tests/` in, or `null`. An entry reaches the
214
+ * directory when its FIRST segment matches `tests` — the name itself, a glob
215
+ * such as `*` or `t*`, or `**`, which matches at any depth. Until d.1002 only the
216
+ * literal name counted, so `["**\/*.js"]` and `["*"]` shipped the tier (measured
217
+ * with `npm pack --dry-run`) and the row said nothing. A glob confined to the
218
+ * root (`*.js`) cannot match a directory and does not reach it.
219
+ *
220
+ * @param {string[]} files the allowlist
221
+ * @returns {string|null}
222
+ */
223
+ function allowlistEntryReachingTests(files) {
224
+ const reaching = files.find((entry) => {
225
+ const firstSegment = String(entry).replace(/^\.?\//, '').split('/')[0];
226
+ return segmentMatches(firstSegment, TEST_DIR);
227
+ });
228
+ return reaching === undefined ? null : String(reaching);
55
229
  }
56
230
 
57
231
  const libraryTests = Object.freeze({
@@ -90,16 +264,25 @@ const libraryPackTests = Object.freeze({
90
264
  if (!fs.existsSync(path.join(serviceRoot, TEST_DIR))) return [];
91
265
 
92
266
  if (Array.isArray(json.files)) {
93
- if (!allowsTests(json.files)) return [];
94
- return [{ where, what: `the files allowlist names ${TEST_DIR} — the tier would ship to every consumer` }];
267
+ const entry = allowlistEntryReachingTests(json.files);
268
+ if (entry === null) return [];
269
+ return [{ where, what: `the files allowlist entry ${JSON.stringify(entry)} reaches ${TEST_DIR} — the tier would ship to every consumer` }];
95
270
  }
96
271
 
97
272
  for (const name of ['.npmignore', '.gitignore']) {
98
273
  const body = readText(path.join(serviceRoot, name));
99
274
  if (body === null) continue;
100
- if (ignoresTests(body)) return [];
275
+ const { shipped, reincludedBy } = testsShippedBy(body, listTestFiles(serviceRoot));
276
+ if (shipped.length === 0) return [];
277
+ const ignoreFile = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: name });
278
+ if (reincludedBy !== null) {
279
+ return [{
280
+ where: ignoreFile,
281
+ what: `excludes ${TEST_DIR}/, then re-includes it with ${JSON.stringify(reincludedBy)} — the tier would ship to every consumer`
282
+ }];
283
+ }
101
284
  return [{
102
- where: whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: name }),
285
+ where: ignoreFile,
103
286
  what: `does not exclude ${TEST_DIR}/, and no files allowlist does either — the tier would ship to every consumer`
104
287
  }];
105
288
  }
@@ -115,7 +298,5 @@ module.exports = {
115
298
  checks: [
116
299
  { name: 'library-tests', check: libraryTests },
117
300
  { name: 'library-pack-tests', check: libraryPackTests }
118
- ],
119
- ignoresTests,
120
- allowsTests
301
+ ]
121
302
  };
@@ -31,15 +31,8 @@
31
31
 
32
32
  const fs = require('fs');
33
33
 
34
- const { lintScripts, SCRIPT_SCOPE } = require('../../lint/scripts/lintScripts');
35
- const { resolveWorkspacePath, WORKSPACE_MARKER } = require('../workspaceRoot');
36
-
37
- /**
38
- * The api checkout, by the name the workspace marker already spells. Written
39
- * once, from the marker, so "which directory is the api one" has one owner
40
- * (`api/.claude/rules/single-source-of-truth.md`).
41
- */
42
- const API_CHECKOUT = WORKSPACE_MARKER.split('/')[0];
34
+ const { lintScripts } = require('../../lint/scripts/lintScripts');
35
+ const { resolveWorkspacePath, API_PREFIX } = require('../workspaceRoot');
43
36
 
44
37
  /**
45
38
  * Where the citations of one repository can be resolved, or null.
@@ -49,7 +42,7 @@ const API_CHECKOUT = WORKSPACE_MARKER.split('/')[0];
49
42
  */
50
43
  function apiCheckout(workspaceRoot) {
51
44
  if (workspaceRoot === null || workspaceRoot === undefined) return null;
52
- const target = resolveWorkspacePath(workspaceRoot, API_CHECKOUT);
45
+ const target = resolveWorkspacePath(workspaceRoot, API_PREFIX);
53
46
  return fs.existsSync(target) && fs.statSync(target).isDirectory() ? target : null;
54
47
  }
55
48
 
@@ -69,11 +62,11 @@ const scriptsLint = Object.freeze({
69
62
 
70
63
  return {
71
64
  findings,
72
- notRun: `${result.unresolved.length} @see target(s) name the ${API_CHECKOUT} checkout, which this run `
65
+ notRun: `${result.unresolved.length} @see target(s) name the ${API_PREFIX} checkout, which this run `
73
66
  + `cannot reach: ${result.unresolved.join(', ')}. Run the uniform from a workspace that carries `
74
- + `${API_CHECKOUT}/.`
67
+ + `${API_PREFIX}/.`
75
68
  };
76
69
  }
77
70
  });
78
71
 
79
- module.exports = { checks: [{ name: 'scripts-lint', check: scriptsLint }], SCRIPT_SCOPE, API_CHECKOUT };
72
+ module.exports = { checks: [{ name: 'scripts-lint', check: scriptsLint }] };
@@ -3,16 +3,22 @@
3
3
  /**
4
4
  * The files a service is configured by.
5
5
  *
6
- * Two of these rows overlap deliberately with the boot validation: step 2 of
7
- * `ValidationOrchestrator` already refuses a `config.json` without
8
- * `service.name` and an `operations.json` with no operations, and it does that
9
- * INSIDE a boot. The rows exist because `npx oa-validate` is asked the same
10
- * question outside one — in CI, in the image build, in a repository nobody has
11
- * started — and because the manifest is where a rule of service shape is
12
- * declared (003 §20: an `Enforced by` cell names a row id). The honest end state
13
- * is one implementation: step 2 extracting its rule into a pure module that both
14
- * call. That refactor touches `ValidationOrchestrator.js`, which d.210a does not
15
- * own, so it is recorded for the lead rather than done here.
6
+ * Three of these rows ARE the boot's step 2: `ValidationOrchestrator` names them
7
+ * in `CONFIG_STEP_ROWS` and reports what they found. The split between step 2
8
+ * and step 7 is stated at that symbol and nowhere else, this comment included.
9
+ *
10
+ * The rows are asked outside a boot as well — `npx oa-validate` in CI, in the
11
+ * image build, in a repository nobody has started — and the manifest is where a
12
+ * rule of service shape is declared (003 §20: an `Enforced by` cell names a row
13
+ * id).
14
+ *
15
+ * Until d.720 this paragraph said something else: that step 2 kept hand-written
16
+ * sentences of its own, that the rows therefore "overlap deliberately" with it,
17
+ * and that unifying the two was work still owed and "recorded for the lead". The
18
+ * unification had already landed — step 2 runs the manifest — so the sentence
19
+ * outlived what it described, and a promise of work that was done read as a gap
20
+ * that was open (`.claude/rules/doc-code-binding.md` §5). Kept as a line about
21
+ * THIS file's own history, which is the only thing a comment here can keep true.
16
22
  *
17
23
  * `G-SHARED-ENV` is a different kind: `shared.env` is GENERATED from the
18
24
  * platform manifest `api/config/shared-env.json` (003 §18), so the row does not
@@ -30,6 +36,8 @@ const { describeWorkspaceFix } = require('../workspaceRoot');
30
36
  const { whereOf } = require('./libraryContext');
31
37
  const { diffAgainst, renderSharedEnv } = require('../../sync/sharedEnv');
32
38
  const { loadAndValidateIntegrationContract } = require('../../utils/bizCiGateContract');
39
+ const { operationsRuleSentences } = require('../../utils/operationsRules');
40
+ const { operationsDocumentFindings } = require('../../utils/operationsDocumentRules');
33
41
 
34
42
  /** The generated platform file every service carries a copy of. */
35
43
  const SHARED_ENV = 'shared.env';
@@ -281,13 +289,25 @@ const configOperations = Object.freeze({
281
289
  if (read.absent) return [{ where: row.path, what: 'absent — this uniform requires it' }];
282
290
  if (read.broken) return [{ where: row.path, what: `is not valid JSON — ${read.broken}` }];
283
291
 
292
+ // The rules about the document — `schema_version`, and an operations map
293
+ // with nothing in it — are not decided here either: they are
294
+ // `src/utils/operationsDocumentRules.js`, the one definition every rail of
295
+ // this package asks. The row's `what` carries the problem; the row's own
296
+ // `fix` column carries the remedy, so the sentence is not repeated into it.
297
+ const documentFindings = operationsDocumentFindings(read.json)
298
+ .map((finding) => ({ where: row.path, what: `${finding.field}: ${finding.what}` }));
299
+ if (documentFindings.length > 0) return documentFindings;
300
+
284
301
  const operations = read.json.operations;
285
- const declared = operations && typeof operations === 'object' ? Object.keys(operations) : [];
286
- if (declared.length > 0) return [];
287
- return [{
288
- where: row.path,
289
- what: 'declares no operations — a service that dispatches nothing has no reason to boot'
290
- }];
302
+
303
+ // What each declared operation must BE is not decided here: it is decided by
304
+ // `@onlineapps/service-validator-core`, which mirrors the rules the Registry
305
+ // applies on registration (owner decision
306
+ // `api/docs/governance/confirmations/operations-schema-mirror.md` 001). The
307
+ // row asks that one definition, so `oa-validate` refuses outside a boot
308
+ // exactly what a registration refuses inside one.
309
+ return operationsRuleSentences(operations).sentences
310
+ .map((sentence) => ({ where: row.path, what: sentence }));
291
311
  }
292
312
  });
293
313