@conduction/nextcloud-vue 2.2.0-vue3.13 → 2.2.0-vue3.14

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.
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Integration registry parity gate.
5
+ *
6
+ * Per AD-11/AD-13 of the `pluggable-integration-registry` umbrella,
7
+ * every registered integration must declare BOTH a sidebar `tab`
8
+ * component AND a `widget` component (the widget can be a thin shell
9
+ * around the tab's data for MVP parity, but it must exist). The
10
+ * registry throws at `register()` time when one is missing — this
11
+ * gate catches the same condition statically, before merge, for the
12
+ * descriptors shipped in this repo's `src/integrations/`. This is the
13
+ * HARD half of the gate (exit 1 on failure).
14
+ *
15
+ * ADR-066 extension — server↔JS leaf parity (WARN-only, bake-in epoch).
16
+ * A leaf has two faces (see OpenRegister `LeafDescriptor`): a server-side
17
+ * descriptor contributed via `RegisterLeafProvidersEvent` (discoverable in
18
+ * the `openregister.integrations.leaves` capability) and a JS
19
+ * `registerIntegration({ id })` that mounts the tab + widget on
20
+ * `window.OCA.OpenRegister.integrations`. The descriptor `id` MUST equal
21
+ * the JS registration id (ADR-019 parity). When only one side exists you
22
+ * get a PHANTOM render surface: a `render-surface` descriptor discoverable
23
+ * server-side whose JS widget never registered (renders nothing), or a JS
24
+ * registration with no server descriptor (invisible to the capability).
25
+ * This gate cross-references the two WITHIN the repo it runs against
26
+ * (`process.cwd()`), flagging orphans both ways. It is WARN-only: it never
27
+ * changes the exit code, matching the fleet's gate-introduction pattern
28
+ * (introduce as a warning, promote to blocking after a bake-in epoch once
29
+ * the fleet is clean). See the deferred follow-up notes in
30
+ * {@link crossReferenceServerLeaves}.
31
+ *
32
+ * Run via `npm run check:integration-parity` (wired into the
33
+ * Code Quality CI workflow and the pre-commit hook). The cross-ref phase
34
+ * only activates when the repo it runs against carries server-side leaf
35
+ * descriptors (a `lib/**` PHP `new LeafDescriptor(...)`), so it is a no-op
36
+ * for this JS-only library's own CI and only speaks up inside a consuming
37
+ * app repo (hermiq, openconnector, …) that ships both faces.
38
+ *
39
+ * Exit codes:
40
+ * 0 — every descriptor is parity-complete (WARN-only cross-ref findings
41
+ * do NOT change this)
42
+ * 1 — at least one descriptor is missing `tab` or `widget`, or
43
+ * carries a malformed `id` / `label`
44
+ */
45
+
46
+ 'use strict'
47
+
48
+ const path = require('path')
49
+ const fs = require('fs')
50
+
51
+ /**
52
+ * Run the parity gate: the HARD tab/widget check on this repo's built-in
53
+ * descriptors, then the WARN-only ADR-066 server↔JS cross-ref against the
54
+ * target repo (`process.cwd()`). Calls `process.exit()` with the HARD
55
+ * result — the cross-ref findings never change it.
56
+ *
57
+ * @return {void}
58
+ */
59
+ function main() {
60
+ // --- HARD half: tab/widget parity on THIS repo's built-in descriptors. --
61
+ const failures = []
62
+
63
+ // The built-in registrations are the only descriptors that live in
64
+ // this repo. Leaf changes in other repos register their own and run
65
+ // their own copy of this gate (and the hydra quality gate enforces it
66
+ // cross-repo). Importing the module gives us the normalised array
67
+ // without spinning up Vue.
68
+ let builtinIntegrations
69
+ try {
70
+ // eslint-disable-next-line global-require, import/no-unresolved
71
+ builtinIntegrations = require(path.resolve(__dirname, '../src/integrations/builtin/index.js')).builtinIntegrations
72
+ } catch (e) {
73
+ // Fall back to a source scan if the module can't be required in
74
+ // this environment (e.g. ESM-only toolchains). We look for the
75
+ // per-id descriptor files and verify each names a `tab:` and
76
+ // `widget:` key. This is coarser but never throws.
77
+ const dir = path.resolve(__dirname, '../src/integrations/builtin')
78
+ for (const file of fs.readdirSync(dir)) {
79
+ if (file === 'index.js' || !file.endsWith('.js')) {
80
+ continue
81
+ }
82
+ const src = fs.readFileSync(path.join(dir, file), 'utf8')
83
+ // A mount-mode descriptor satisfies parity via mount+unmount
84
+ // instead of tab+widget (openregister#2127).
85
+ if (/renderMode\s*:\s*['"]mount['"]/.test(src)) {
86
+ if (!/\bmount\s*:/.test(src)) {
87
+ failures.push(`${file}: renderMode 'mount' but no \`mount:\` key found`)
88
+ }
89
+ if (!/\bunmount\s*:/.test(src)) {
90
+ failures.push(`${file}: renderMode 'mount' but no \`unmount:\` key found`)
91
+ }
92
+ continue
93
+ }
94
+ if (!/\btab\s*:/.test(src)) {
95
+ failures.push(`${file}: no \`tab:\` key found`)
96
+ }
97
+ if (!/\bwidget\s*:/.test(src)) {
98
+ failures.push(`${file}: no \`widget:\` key found`)
99
+ }
100
+ }
101
+ }
102
+
103
+ if (Array.isArray(builtinIntegrations)) {
104
+ for (const d of builtinIntegrations) {
105
+ const label = d && typeof d.id === 'string' && d.id !== '' ? d.id : '(unknown)'
106
+ if (typeof d.id !== 'string' || d.id === '') {
107
+ failures.push(`${label}: missing or empty \`id\``)
108
+ }
109
+ if (typeof d.label !== 'string' || d.label === '') {
110
+ failures.push(`${label}: missing or empty \`label\``)
111
+ }
112
+ // Parity now accepts either render pair (openregister#2127, ADR-066):
113
+ // a `mount` descriptor is complete with mount+unmount; a `component`
114
+ // (default) descriptor keeps the tab+widget requirement.
115
+ if (d && d.renderMode === 'mount') {
116
+ if (typeof d.mount !== 'function') {
117
+ failures.push(`${label}: renderMode 'mount' missing required \`mount\` function`)
118
+ }
119
+ if (typeof d.unmount !== 'function') {
120
+ failures.push(`${label}: renderMode 'mount' missing required \`unmount\` function`)
121
+ }
122
+ } else {
123
+ if (d.tab === undefined || d.tab === null) {
124
+ failures.push(`${label}: missing required \`tab\` component`)
125
+ }
126
+ if (d.widget === undefined || d.widget === null) {
127
+ failures.push(`${label}: missing required \`widget\` component`)
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // --- HARD half, part 2: barrel-export completeness. --------------------
134
+ // A parity-complete descriptor that no consumer can import is still dead.
135
+ // `flowIntegration` shipped for months in `builtinIntegrations[]`, with a
136
+ // bespoke tab and card, and was exported from neither `src/integrations/
137
+ // index.js` nor `src/index.js` — so `import { flowIntegration } from
138
+ // '@conduction/nextcloud-vue'` yielded `undefined` and registering it was a
139
+ // silent no-op. Nothing threw at any layer. This check makes that state a
140
+ // build failure instead of a support ticket.
141
+ failures.push(...checkBarrelExports())
142
+
143
+ report(failures)
144
+
145
+ // --- WARN half (ADR-066): server↔JS leaf parity on the target repo. -----
146
+ // Never changes the exit code (bake-in epoch). Runs against process.cwd()
147
+ // so the hydra gate — which cd's into the app repo before invoking this
148
+ // check via the app's wrapper — picks up the app's own PHP + JS.
149
+ try {
150
+ const { warnings, ran } = crossReferenceServerLeaves(process.cwd())
151
+ reportCrossRef(warnings, ran)
152
+ } catch (e) {
153
+ // A cross-ref scan must NEVER break the gate — it is advisory. Surface
154
+ // the reason and carry on with the hard-check exit code.
155
+ // eslint-disable-next-line no-console
156
+ console.error(` (server↔JS leaf cross-ref skipped: ${e && e.message})`)
157
+ }
158
+
159
+ process.exit(failures.length === 0 ? 0 : 1)
160
+ }
161
+
162
+ /**
163
+ * Verify that every descriptor listed in `builtinIntegrations[]` is also a
164
+ * named export of BOTH public barrels — `src/integrations/index.js` and the
165
+ * package root `src/index.js`.
166
+ *
167
+ * The identifiers are read from the array literal in
168
+ * `src/integrations/builtin/index.js` rather than from the required module,
169
+ * because the array holds descriptor *objects* at runtime and the binding
170
+ * names (which are what a consumer imports) are only visible in the source.
171
+ *
172
+ * @return {string[]} One failure string per descriptor missing from a barrel.
173
+ */
174
+ function checkBarrelExports() {
175
+ const failures = []
176
+ const root = path.resolve(__dirname, '..')
177
+ const builtinIndex = path.join(root, 'src', 'integrations', 'builtin', 'index.js')
178
+
179
+ let source
180
+ try {
181
+ source = fs.readFileSync(builtinIndex, 'utf8')
182
+ } catch {
183
+ // Nothing to check against — never turn a missing file into a false
184
+ // "everything is fine"; say so and let the tab/widget half stand.
185
+ return ['src/integrations/builtin/index.js is unreadable — barrel-export check skipped']
186
+ }
187
+
188
+ const arrayMatch = source.match(/export\s+const\s+builtinIntegrations\s*=\s*\[([\s\S]*?)\]/)
189
+ if (!arrayMatch) {
190
+ return ['could not locate the `builtinIntegrations` array literal — barrel-export check skipped']
191
+ }
192
+
193
+ const names = arrayMatch[1]
194
+ .split('\n')
195
+ .map((line) => line.replace(/\/\/.*$/, '').trim().replace(/,$/, ''))
196
+ .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name))
197
+
198
+ if (names.length === 0) {
199
+ return ['`builtinIntegrations` array parsed as empty — barrel-export check skipped']
200
+ }
201
+
202
+ const barrels = [
203
+ ['src/integrations/index.js', path.join(root, 'src', 'integrations', 'index.js')],
204
+ ['src/index.js', path.join(root, 'src', 'index.js')],
205
+ ]
206
+
207
+ for (const [label, file] of barrels) {
208
+ let barrel
209
+ try {
210
+ barrel = fs.readFileSync(file, 'utf8')
211
+ } catch {
212
+ failures.push(`${label} is unreadable — cannot verify integration exports`)
213
+ continue
214
+ }
215
+ // Strip comments BEFORE matching. The first cut of this check searched
216
+ // the raw file and passed while `flowIntegration` was absent from every
217
+ // export statement — the docblock explaining the bug mentioned the name,
218
+ // and a bare substring search cannot tell prose from an export. Prose
219
+ // restating a symbol is not the symbol.
220
+ const code = barrel
221
+ .replace(/\/\*[\s\S]*?\*\//g, '')
222
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
223
+ for (const name of names) {
224
+ if (!new RegExp(`\\b${name}\\b`).test(code)) {
225
+ failures.push(
226
+ `${name} is in builtinIntegrations[] but is not exported from ${label} — `
227
+ + 'consumers importing it get `undefined` and register nothing, silently',
228
+ )
229
+ }
230
+ }
231
+ }
232
+
233
+ return failures
234
+ }
235
+
236
+ /**
237
+ * Recursively collect files under `root` that match `test(filename)`, up to
238
+ * `maxDepth` levels deep. Skips `node_modules`, `vendor`, `.git`, and `dist`.
239
+ * Returns [] when `root` does not exist. Never throws on an unreadable dir.
240
+ *
241
+ * @param {string} root The directory to walk.
242
+ * @param {(name: string) => boolean} test Filename predicate.
243
+ * @param {number} [maxDepth] Maximum recursion depth (default 6).
244
+ *
245
+ * @return {string[]} Absolute paths of matching files.
246
+ */
247
+ function collectFiles(root, test, maxDepth = 6) {
248
+ const out = []
249
+ if (!fs.existsSync(root)) {
250
+ return out
251
+ }
252
+ const skip = new Set(['node_modules', 'vendor', '.git', 'dist', 'build'])
253
+ const walk = (dir, depth) => {
254
+ if (depth > maxDepth) {
255
+ return
256
+ }
257
+ let entries
258
+ try {
259
+ entries = fs.readdirSync(dir, { withFileTypes: true })
260
+ } catch (e) {
261
+ return
262
+ }
263
+ for (const ent of entries) {
264
+ if (ent.isDirectory()) {
265
+ if (!skip.has(ent.name)) {
266
+ walk(path.join(dir, ent.name), depth + 1)
267
+ }
268
+ } else if (ent.isFile() && test(ent.name)) {
269
+ out.push(path.join(dir, ent.name))
270
+ }
271
+ }
272
+ }
273
+ walk(root, 0)
274
+ return out
275
+ }
276
+
277
+ /**
278
+ * Extract the server-side render-surface leaf descriptors declared in a
279
+ * repo's PHP (`lib/**`). Finds every `new LeafDescriptor( … )` constructor
280
+ * call, reads its `id:` argument (a string literal, or a `self::CONST`
281
+ * resolved from a `const CONST = '...'` in the same file), and records
282
+ * whether the descriptor's `kinds:` array contains `KIND_RENDER_SURFACE`
283
+ * (either the `LeafDescriptor::KIND_RENDER_SURFACE` constant or the
284
+ * literal `'render-surface'`).
285
+ *
286
+ * Deliberately regex-based: this must run in a plain Node CI step with no
287
+ * PHP toolchain. It is a static heuristic, hence WARN-only.
288
+ *
289
+ * @param {string} repoRoot The repo root to scan.
290
+ *
291
+ * @return {Array<{id: string, renderSurface: boolean, renderMode: string, file: string}>} The
292
+ * discovered descriptors (`renderMode` is `'mount'` or `'component'`).
293
+ */
294
+ function collectServerDescriptors(repoRoot) {
295
+ const descriptors = []
296
+ const phpFiles = collectFiles(path.join(repoRoot, 'lib'), (n) => n.endsWith('.php'))
297
+ // The `id:` value inside a `new LeafDescriptor(` argument list — a
298
+ // single/double-quoted literal or a `self::CONST` / `static::CONST`.
299
+ const idRe = /\bid:\s*(?:'([^']+)'|"([^"]+)"|(?:self|static)::([A-Z0-9_]+))/
300
+ for (const file of phpFiles) {
301
+ let src
302
+ try {
303
+ src = fs.readFileSync(file, 'utf8')
304
+ } catch (e) {
305
+ continue
306
+ }
307
+ if (!src.includes('new LeafDescriptor(')) {
308
+ continue
309
+ }
310
+ // Constant table for `self::CONST` id resolution within the file.
311
+ const consts = {}
312
+ const constRe = /\bconst\s+([A-Z0-9_]+)\s*=\s*(?:'([^']+)'|"([^"]+)")/g
313
+ let cm
314
+ while ((cm = constRe.exec(src)) !== null) {
315
+ consts[cm[1]] = cm[2] || cm[3]
316
+ }
317
+ // Walk each constructor call as a bounded window (the argument list
318
+ // up to a reasonable length — descriptors are short value objects).
319
+ let idx = 0
320
+ while ((idx = src.indexOf('new LeafDescriptor(', idx)) !== -1) {
321
+ const window = src.slice(idx, idx + 1200)
322
+ const m = idRe.exec(window)
323
+ let id = null
324
+ if (m) {
325
+ id = m[1] || m[2] || (m[3] ? consts[m[3]] : null)
326
+ }
327
+ const renderSurface = /KIND_RENDER_SURFACE/.test(window)
328
+ || /'render-surface'|"render-surface"/.test(window)
329
+ // renderMode carried on the descriptor (openregister#2127): a
330
+ // `RENDER_MODE_MOUNT` constant or a `renderMode: 'mount'` literal.
331
+ const renderMode = /RENDER_MODE_MOUNT/.test(window)
332
+ || /renderMode\s*:\s*'mount'|renderMode\s*:\s*"mount"/.test(window)
333
+ ? 'mount'
334
+ : 'component'
335
+ if (id) {
336
+ descriptors.push({ id, renderSurface, renderMode, file: path.relative(repoRoot, file) })
337
+ }
338
+ idx += 'new LeafDescriptor('.length
339
+ }
340
+ }
341
+ return descriptors
342
+ }
343
+
344
+ /**
345
+ * Extract the JS integration registration ids declared in a repo's
346
+ * `src/**` — every `registerIntegration({ id: '...' })` CALL site (the
347
+ * `export function registerIntegration` DEFINITION in the shared library is
348
+ * excluded). These are the ids mounted on `window.OCA.OpenRegister.integrations`.
349
+ *
350
+ * @param {string} repoRoot The repo root to scan.
351
+ *
352
+ * @return {Array<{id: string, renderMode: string, file: string}>} The discovered
353
+ * registrations (`renderMode` is `'mount'` or `'component'`).
354
+ */
355
+ function collectJsRegistrations(repoRoot) {
356
+ const regs = []
357
+ const jsFiles = collectFiles(
358
+ path.join(repoRoot, 'src'),
359
+ (n) => n.endsWith('.js') || n.endsWith('.ts') || n.endsWith('.vue'),
360
+ )
361
+ // `registerIntegration(` followed (within a small window) by `id: '...'`.
362
+ // The negative lookbehind on `function ` excludes the library definition.
363
+ const callRe = /registerIntegration\s*\(/g
364
+ const idRe = /\bid:\s*(?:'([^']+)'|"([^"]+)"|`([^`]+)`)/
365
+ for (const file of jsFiles) {
366
+ let src
367
+ try {
368
+ src = fs.readFileSync(file, 'utf8')
369
+ } catch (e) {
370
+ continue
371
+ }
372
+ let cm
373
+ while ((cm = callRe.exec(src)) !== null) {
374
+ const before = src.slice(Math.max(0, cm.index - 20), cm.index)
375
+ if (/function\s+$/.test(before)) {
376
+ continue // the `export function registerIntegration(` definition
377
+ }
378
+ const window = src.slice(cm.index, cm.index + 400)
379
+ const m = idRe.exec(window)
380
+ const id = m ? (m[1] || m[2] || m[3]) : null
381
+ // renderMode declared on the JS registration (openregister#2127).
382
+ const renderMode = /renderMode\s*:\s*'mount'|renderMode\s*:\s*"mount"|renderMode\s*:\s*`mount`/.test(window)
383
+ ? 'mount'
384
+ : 'component'
385
+ if (id) {
386
+ regs.push({ id, renderMode, file: path.relative(repoRoot, file) })
387
+ }
388
+ }
389
+ }
390
+ return regs
391
+ }
392
+
393
+ /**
394
+ * Cross-reference server-side render-surface leaf descriptors against JS
395
+ * `registerIntegration` ids within one repo, producing advisory warnings
396
+ * for orphans both ways (ADR-066).
397
+ *
398
+ * Scoped pragmatically (WARN-first): the cross-ref only runs when the repo
399
+ * carries at least one server-side `LeafDescriptor` (i.e. it is a consuming
400
+ * app repo that ships both faces, not this JS-only library). This keeps the
401
+ * check silent for the nextcloud-vue library's own CI (whose built-in
402
+ * registrations correlate to PHP descriptors that live in the consuming
403
+ * apps, not here) and avoids false positives on repos that only own one
404
+ * face.
405
+ *
406
+ * DEFERRED (documented follow-up, not implemented in this pass):
407
+ * - Correlating this library's own `builtinIntegrations` against the PHP
408
+ * descriptors that live in EACH consuming app (a true cross-repo join);
409
+ * today each app runs this gate against its own tree.
410
+ * - Reading the `openregister.integrations.leaves` capability payload at
411
+ * runtime and asserting it against the JS registry live (this static
412
+ * pass approximates it from the PHP source).
413
+ * - Promoting the WARN findings to a hard failure once the fleet bakes in
414
+ * clean (flip `reportCrossRef` to push into `failures`).
415
+ *
416
+ * @param {string} repoRoot The repo root to scan (usually `process.cwd()`).
417
+ *
418
+ * @return {{ran: boolean, warnings: string[]}} Whether the cross-ref ran
419
+ * (server descriptors present) and any advisory warnings.
420
+ */
421
+ function crossReferenceServerLeaves(repoRoot) {
422
+ const descriptors = collectServerDescriptors(repoRoot)
423
+ if (descriptors.length === 0) {
424
+ // No server-side leaf face in this repo — nothing to correlate.
425
+ return { ran: false, warnings: [] }
426
+ }
427
+ const registrations = collectJsRegistrations(repoRoot)
428
+ const jsIds = new Set(registrations.map((r) => r.id))
429
+ const phpIds = new Set(descriptors.map((d) => d.id))
430
+ const jsModeById = new Map(registrations.map((r) => [r.id, r.renderMode]))
431
+ const warnings = []
432
+
433
+ // renderMode cross-layer correlation (openregister#2127 / ADR-066): for a
434
+ // render-surface leaf present on both sides, the server descriptor's
435
+ // renderMode MUST equal the JS registration's under the shared id.
436
+ for (const d of descriptors) {
437
+ if (!d.renderSurface || !jsModeById.has(d.id)) {
438
+ continue
439
+ }
440
+ const jsMode = jsModeById.get(d.id)
441
+ if (d.renderMode !== jsMode) {
442
+ warnings.push(
443
+ `render-surface leaf "${d.id}" (${d.file}) declares renderMode `
444
+ + `"${d.renderMode}" server-side but "${jsMode}" in its JS `
445
+ + 'registration — renderMode MUST match across layers (ADR-066).',
446
+ )
447
+ }
448
+ }
449
+
450
+ // Phantom render surface: a render-surface descriptor discoverable in the
451
+ // capability whose JS widget never registered.
452
+ for (const d of descriptors) {
453
+ if (d.renderSurface && !jsIds.has(d.id)) {
454
+ warnings.push(
455
+ `render-surface leaf descriptor "${d.id}" (${d.file}) has NO matching `
456
+ + 'registerIntegration({ id }) in src/** — phantom render surface (the '
457
+ + 'capability advertises a tab/widget that never mounts).',
458
+ )
459
+ }
460
+ }
461
+ // Orphan JS: a registration with no server descriptor of any kind — the
462
+ // widget mounts but the leaf is invisible to the capability.
463
+ for (const r of registrations) {
464
+ if (!phpIds.has(r.id)) {
465
+ warnings.push(
466
+ `registerIntegration id "${r.id}" (${r.file}) has NO matching server-side `
467
+ + 'LeafDescriptor in lib/** — orphan JS registration (mounts on '
468
+ + 'window.OCA.OpenRegister.integrations but is not discoverable via the '
469
+ + 'openregister.integrations.leaves capability).',
470
+ )
471
+ }
472
+ }
473
+ return { ran: true, warnings }
474
+ }
475
+
476
+ /**
477
+ * Print the hard-check result to stdout/stderr.
478
+ *
479
+ * @param {string[]} list Failure messages (empty when all good).
480
+ *
481
+ * @return {void}
482
+ */
483
+ function report(list) {
484
+ if (list.length === 0) {
485
+ // eslint-disable-next-line no-console
486
+ console.log('✓ integration parity: every registered integration has both a tab and a widget')
487
+ return
488
+ }
489
+ // eslint-disable-next-line no-console
490
+ console.error('✗ integration parity gate failed:')
491
+ for (const f of list) {
492
+ // eslint-disable-next-line no-console
493
+ console.error(` - ${f}`)
494
+ }
495
+ // eslint-disable-next-line no-console
496
+ console.error('\nEvery integration registered on window.OCA.OpenRegister.integrations')
497
+ // eslint-disable-next-line no-console
498
+ console.error('must declare BOTH a `tab` and a `widget` component (AD-11/AD-13).')
499
+ }
500
+
501
+ /**
502
+ * Print the WARN-only server↔JS cross-ref result. Never fails the build.
503
+ *
504
+ * @param {string[]} warnings Advisory warning messages.
505
+ * @param {boolean} ran Whether the cross-ref actually ran (server leaves present).
506
+ *
507
+ * @return {void}
508
+ */
509
+ function reportCrossRef(warnings, ran) {
510
+ if (!ran) {
511
+ return
512
+ }
513
+ if (warnings.length === 0) {
514
+ // eslint-disable-next-line no-console
515
+ console.log('✓ server↔JS leaf parity (ADR-066): every render-surface descriptor has a JS registration and vice-versa')
516
+ return
517
+ }
518
+ // eslint-disable-next-line no-console
519
+ console.warn('⚠ server↔JS leaf parity (ADR-066) — advisory (WARN-only, does not fail the gate):')
520
+ for (const w of warnings) {
521
+ // eslint-disable-next-line no-console
522
+ console.warn(` - ${w}`)
523
+ }
524
+ // eslint-disable-next-line no-console
525
+ console.warn('\nThe server LeafDescriptor id MUST equal the JS registerIntegration id (ADR-019 / ADR-066).')
526
+ }
527
+
528
+ // Run the gate only on direct invocation (`node check-integration-parity.js`),
529
+ // never on `require()` — so unit tests can import the helpers below without the
530
+ // CLI calling process.exit() and killing the test runner.
531
+ if (require.main === module) {
532
+ main()
533
+ }
534
+
535
+ // Exported for unit testing.
536
+ module.exports = {
537
+ collectServerDescriptors,
538
+ collectJsRegistrations,
539
+ crossReferenceServerLeaves,
540
+ }