@conduction/nextcloud-vue 2.2.0-vue3.15 → 2.2.0-vue3.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.2.0-vue3.15",
3
+ "version": "2.2.0-vue3.16",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -13,28 +13,34 @@
13
13
  * HARD half of the gate (exit 1 on failure).
14
14
  *
15
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}.
16
+ * A leaf has two faces. The SERVER face is declared in one of two shapes:
17
+ * - a `new LeafDescriptor( … )` contributed via `RegisterLeafProvidersEvent`
18
+ * (hermiq's agent leaf, procest, …), or
19
+ * - an `IntegrationProvider` CLASS with a `getId()` openregister's 28
20
+ * providers under `lib/Service/Integration/{Providers,BuiltinProviders}/`.
21
+ * The JS face is a `registerIntegration({ id })` or
22
+ * `integrations.register({ id })` call that mounts the tab + widget on
23
+ * `window.OCA.OpenRegister.integrations` or, for a leaf whose JS face this
24
+ * library owns, a `registerBuiltinIntegrations()` / `registerLeafIntegrations()`
25
+ * call in the app's bootstrap. The server id MUST equal the JS registration id
26
+ * (ADR-019 parity). When only one side exists you get a PHANTOM render
27
+ * surface: a render-surface descriptor discoverable server-side whose JS
28
+ * widget never registered (renders nothing), or a JS registration with no
29
+ * server descriptor (invisible to the capability). This gate cross-references
30
+ * the two WITHIN the repo it runs against (`process.cwd()`), flagging orphans
31
+ * both ways. It is WARN-only: it never changes the exit code, matching the
32
+ * fleet's gate-introduction pattern (introduce as a warning, promote to
33
+ * blocking after a bake-in epoch once the fleet is clean). See the deferred
34
+ * follow-up notes in {@link crossReferenceServerLeaves}.
31
35
  *
32
36
  * 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.
37
+ * Code Quality CI workflow and the pre-commit hook). The cross-ref phase can
38
+ * only correlate when the repo it runs against carries a server-side leaf
39
+ * face, so it stays a no-op for this JS-only library's own CI and speaks up
40
+ * inside a consuming app repo (openregister, hermiq, openconnector, …) that
41
+ * ships both faces. WHEN IT CANNOT CORRELATE IT SAYS SO — see
42
+ * {@link reportCrossRef}. A silent cross-ref phase used to be
43
+ * indistinguishable from a clean one, and openregister sat in that state.
38
44
  *
39
45
  * Exit codes:
40
46
  * 0 — every descriptor is parity-complete (WARN-only cross-ref findings
@@ -147,13 +153,17 @@ function main() {
147
153
  // so the hydra gate — which cd's into the app repo before invoking this
148
154
  // check via the app's wrapper — picks up the app's own PHP + JS.
149
155
  try {
150
- const { warnings, ran } = crossReferenceServerLeaves(process.cwd())
151
- reportCrossRef(warnings, ran)
156
+ reportCrossRef(crossReferenceServerLeaves(process.cwd()))
152
157
  } 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.
158
+ // A cross-ref scan must NEVER break the gate — it is advisory. But a
159
+ // crash means NOTHING was correlated, so say that in the same words the
160
+ // not-run path uses; the previous "(… skipped: …)" note read as a
161
+ // harmless aside next to a `✓` hard-check line.
155
162
  // eslint-disable-next-line no-console
156
- console.error(` (server↔JS leaf cross-ref skipped: ${e && e.message})`)
163
+ console.error(
164
+ '⚠ server↔JS leaf parity (ADR-066): the cross-reference CRASHED and correlated NOTHING '
165
+ + `— this is NOT a pass: ${e && e.message}`,
166
+ )
157
167
  }
158
168
 
159
169
  process.exit(failures.length === 0 ? 0 : 1)
@@ -275,138 +285,612 @@ function collectFiles(root, test, maxDepth = 6) {
275
285
  }
276
286
 
277
287
  /**
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'`).
288
+ * Strip block, line and hash comments from PHP source before matching in it.
289
+ *
290
+ * A COMMENT PUSHED THE DECLARATION OUT OF THE WINDOW.
291
+ * --------------------------------------------------
292
+ * The descriptor scan reads a bounded window after `new LeafDescriptor(`,
293
+ * because it has no PHP parser. hermiq's descriptor carries ~40 lines of
294
+ * explanatory comment INSIDE its argument list, which pushed the trailing
295
+ * `renderMode: LeafDescriptor::RENDER_MODE_MOUNT` past the window — so the
296
+ * scan read `component` (the default) for a descriptor that plainly declares
297
+ * `mount`. It then agreed with a JS side that was ALSO misread as `component`
298
+ * for the same reason, and the renderMode rule printed `✓`. Two wrong reads
299
+ * cancelling is not a correlation.
300
+ *
301
+ * `#` is only treated as a comment at the start of a line, so PHP 8
302
+ * attributes (`#[NoAdminRequired]`) survive; the `[^:]` guard keeps `://`
303
+ * inside a string literal from eating the rest of the line.
304
+ *
305
+ * @param {string} src The PHP source.
306
+ *
307
+ * @return {string} The source with comments blanked out.
308
+ */
309
+ function stripPhpComments(src) {
310
+ return src
311
+ .replace(/\/\*[\s\S]*?\*\//g, '')
312
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
313
+ .replace(/^([ \t]*)#(?!\[).*$/gm, '$1')
314
+ }
315
+
316
+ /**
317
+ * Build the `const NAME = 'value'` table of a PHP source file, used to
318
+ * resolve `self::NAME` / `static::NAME` references back to their literal.
319
+ *
320
+ * @param {string} src The PHP source.
321
+ *
322
+ * @return {{[key: string]: string}} Constant name → literal value.
323
+ */
324
+ function readClassConstants(src) {
325
+ const consts = {}
326
+ const constRe = /\bconst\s+([A-Z0-9_]+)\s*=\s*(?:'([^']+)'|"([^"]+)")/g
327
+ let cm
328
+ while ((cm = constRe.exec(src)) !== null) {
329
+ consts[cm[1]] = cm[2] || cm[3]
330
+ }
331
+ return consts
332
+ }
333
+
334
+ /**
335
+ * Extract the leaf descriptors declared by `new LeafDescriptor( … )` calls in
336
+ * one PHP source. Reads the `id:` argument (a string literal, or a
337
+ * `self::CONST` resolved from the file's own constant table), whether the
338
+ * `kinds:` array contains `KIND_RENDER_SURFACE` (either the
339
+ * `LeafDescriptor::KIND_RENDER_SURFACE` constant or the literal
340
+ * `'render-surface'`), and the declared `renderMode`.
341
+ *
342
+ * @param {string} src The PHP source.
343
+ * @param {string} rel The repo-relative path (for messages).
344
+ * @param {{[key: string]: string}} consts The file's constant table.
345
+ *
346
+ * @return {Array<object>} The discovered descriptors.
347
+ */
348
+ function collectLeafDescriptorFaces(src, rel, consts) {
349
+ const out = []
350
+ if (!src.includes('new LeafDescriptor(')) {
351
+ return out
352
+ }
353
+ // The `id:` value inside a `new LeafDescriptor(` argument list — a
354
+ // single/double-quoted literal or a `self::CONST` / `static::CONST`.
355
+ const idRe = /\bid:\s*(?:'([^']+)'|"([^"]+)"|(?:self|static)::([A-Z0-9_]+))/
356
+ // Walk each constructor call as a bounded window (the argument list
357
+ // up to a reasonable length — descriptors are short value objects).
358
+ let idx = 0
359
+ while ((idx = src.indexOf('new LeafDescriptor(', idx)) !== -1) {
360
+ const window = src.slice(idx, idx + 1200)
361
+ const m = idRe.exec(window)
362
+ let id = null
363
+ if (m) {
364
+ id = m[1] || m[2] || (m[3] ? consts[m[3]] : null)
365
+ }
366
+ const renderSurface = /KIND_RENDER_SURFACE/.test(window)
367
+ || /'render-surface'|"render-surface"/.test(window)
368
+ // renderMode carried on the descriptor (openregister#2127): a
369
+ // `RENDER_MODE_MOUNT` constant or a `renderMode: 'mount'` literal.
370
+ const renderMode = /RENDER_MODE_MOUNT/.test(window)
371
+ || /renderMode\s*:\s*'mount'|renderMode\s*:\s*"mount"/.test(window)
372
+ ? 'mount'
373
+ : 'component'
374
+ if (id) {
375
+ out.push({ id, renderSurface, renderMode, face: 'LeafDescriptor', file: rel })
376
+ }
377
+ idx += 'new LeafDescriptor('.length
378
+ }
379
+ return out
380
+ }
381
+
382
+ /**
383
+ * Extract the leaf descriptors declared as `IntegrationProvider` CLASSES in
384
+ * one PHP source — OpenRegister's other, and by volume dominant, server-side
385
+ * leaf face.
386
+ *
387
+ * THE SECOND FACE THIS GATE COULD NOT SEE.
388
+ * ---------------------------------------
389
+ * `new LeafDescriptor(...)` is not how most server-side leaves are declared.
390
+ * OpenRegister's 28 leaves are CLASSES under
391
+ * `lib/Service/Integration/Providers/` (+ `BuiltinProviders/`) that extend
392
+ * `AbstractIntegrationProvider` (which `implements IntegrationProvider`) and
393
+ * carry their id in a method, not a constructor argument:
394
+ *
395
+ * class XwikiProvider extends AbstractIntegrationProvider
396
+ * {
397
+ * public function getId(): string
398
+ * {
399
+ * return 'xwiki';
400
+ * }//end getId()
401
+ *
402
+ * A `new LeafDescriptor(`-only scan therefore collected ZERO descriptors in
403
+ * the repo that owns the integration registry, `crossReferenceServerLeaves()`
404
+ * returned `{ ran: false }`, and `reportCrossRef()` printed nothing at all —
405
+ * so "correlated nothing" was indistinguishable from "correlated, all good".
406
+ *
407
+ * WHAT THIS SHAPE DOES AND DOES NOT CARRY (measured, not assumed — read
408
+ * `openregister/lib/Service/Integration/IntegrationProvider.php`):
409
+ * - `getId(): string` — the leaf id. All 28 return a literal; one
410
+ * (`lib/Service/Integration/TimeProvider.php`) returns `self::ID`, so
411
+ * the constant table is resolved here too.
412
+ * - The interface has NO `kinds:` and NO `surfaces:` member, so a provider
413
+ * cannot declare itself a render surface the way a `LeafDescriptor` can.
414
+ * Every provider IS one by construction: it is served over
415
+ * `/api/objects/{register}/{schema}/{id}/integrations/{providerId}` and
416
+ * drawn by the generic `CnIntegrationTab` / `CnIntegrationCard`. So
417
+ * `renderSurface` is set `true` and marked `surfaceSource: 'implicit'`
418
+ * — a property of the SHAPE, not a value read from the source.
419
+ * - The interface has NO `renderMode` member. `renderMode` is therefore
420
+ * `null` — NOT `'component'`. Defaulting it to `'component'` would let
421
+ * the cross-layer renderMode rule invent a mismatch against a JS
422
+ * registration that legitimately declares `'mount'`; a null is skipped
423
+ * by that rule instead.
424
+ *
425
+ * Recognised class faces: `extends [Abstract]IntegrationProvider` or
426
+ * `implements … IntegrationProvider`. `abstract class` declarations are not
427
+ * matched (the base class is not a leaf). A class whose base is named
428
+ * something else is NOT recognised — and that is visible, because the
429
+ * descriptor count is printed on every run.
430
+ *
431
+ * @param {string} src The PHP source.
432
+ * @param {string} rel The repo-relative path (for messages).
433
+ * @param {{[key: string]: string}} consts The file's constant table.
434
+ *
435
+ * @return {Array<object>} The discovered descriptors.
436
+ */
437
+ function collectIntegrationProviderFaces(src, rel, consts) {
438
+ const out = []
439
+ // Class declaration line: optional `final`, a name, an optional
440
+ // `extends`, an optional `implements` list. `abstract class` is excluded
441
+ // by omission — `AbstractIntegrationProvider` itself is not a leaf.
442
+ const classRe = /^[ \t]*(?:final[ \t]+)?class[ \t]+(\w+)[ \t]*(?:extends[ \t]+([\w\\]+)[ \t]*)?(?:implements[ \t]+([^{\r\n]+))?/gm
443
+ // The id literal returned by `getId(): string`. Accepts `?string` and a
444
+ // `self::CONST` / `static::CONST` return.
445
+ const getIdRe = /function\s+getId\s*\(\s*\)\s*:\s*\??string[\s\S]{0,400}?\breturn\s+(?:'([^']*)'|"([^"]*)"|(?:self|static)::([A-Z0-9_]+))\s*;/
446
+ let m
447
+ while ((m = classRe.exec(src)) !== null) {
448
+ const parent = m[2] || ''
449
+ const interfaces = (m[3] || '').split(',').map((s) => s.trim())
450
+ const isProvider = /(?:^|\\)(?:Abstract)?IntegrationProvider$/.test(parent)
451
+ || interfaces.some((i) => /(?:^|\\)IntegrationProvider$/.test(i))
452
+ if (!isProvider) {
453
+ continue
454
+ }
455
+ const body = src.slice(m.index)
456
+ const g = getIdRe.exec(body)
457
+ if (g === null) {
458
+ continue
459
+ }
460
+ const id = g[1] || g[2] || (g[3] ? consts[g[3]] : null)
461
+ if (!id) {
462
+ continue
463
+ }
464
+ out.push({
465
+ id,
466
+ // See the docblock: the IntegrationProvider contract carries no
467
+ // kinds/surfaces array. Every provider is exposed on the object
468
+ // detail integration surface, so this is a shape constant.
469
+ renderSurface: true,
470
+ surfaceSource: 'implicit',
471
+ // The contract carries no renderMode. `null` means NOT DECLARED,
472
+ // which the cross-layer renderMode rule skips rather than guesses.
473
+ renderMode: null,
474
+ face: 'IntegrationProvider',
475
+ file: rel,
476
+ })
477
+ }
478
+ return out
479
+ }
480
+
481
+ /**
482
+ * Extract the server-side leaf descriptors declared in a repo's PHP
483
+ * (`lib/**`), across BOTH supported faces:
484
+ *
485
+ * 1. `new LeafDescriptor( … )` constructor calls (hermiq, procest, …).
486
+ * 2. `IntegrationProvider` classes with a `getId()` (openregister's 28
487
+ * providers) — see {@link collectIntegrationProviderFaces}.
285
488
  *
286
489
  * Deliberately regex-based: this must run in a plain Node CI step with no
287
490
  * PHP toolchain. It is a static heuristic, hence WARN-only.
288
491
  *
289
492
  * @param {string} repoRoot The repo root to scan.
290
493
  *
291
- * @return {Array<{id: string, renderSurface: boolean, renderMode: string, file: string}>} The
292
- * discovered descriptors (`renderMode` is `'mount'` or `'component'`).
494
+ * @return {Array<{id: string, renderSurface: boolean, renderMode: ?string, face: string, file: string}>} The
495
+ * discovered descriptors. `renderMode` is `'mount'`, `'component'`, or
496
+ * `null` when the declaring shape does not carry one.
293
497
  */
294
498
  function collectServerDescriptors(repoRoot) {
295
499
  const descriptors = []
296
500
  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
501
  for (const file of phpFiles) {
301
502
  let src
302
503
  try {
303
- src = fs.readFileSync(file, 'utf8')
504
+ src = stripPhpComments(fs.readFileSync(file, 'utf8'))
304
505
  } catch (e) {
305
506
  continue
306
507
  }
307
- if (!src.includes('new LeafDescriptor(')) {
308
- continue
309
- }
508
+ const rel = path.relative(repoRoot, file)
310
509
  // 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
- }
510
+ const consts = readClassConstants(src)
511
+ descriptors.push(...collectLeafDescriptorFaces(src, rel, consts))
512
+ descriptors.push(...collectIntegrationProviderFaces(src, rel, consts))
340
513
  }
341
514
  return descriptors
342
515
  }
343
516
 
517
+ /**
518
+ * Strip block and line comments from JS/TS/Vue source before matching call
519
+ * sites in it.
520
+ *
521
+ * PROSE RESTATING A SYMBOL IS NOT THE SYMBOL — the same trap `checkBarrelExports`
522
+ * already documents. Measured while building this pass: the library's own
523
+ * `src/integrations/builtin/files.js` carries a docblock reading
524
+ * "`files` integration descriptor — pass to `integrations.register()`."
525
+ * directly above `export const filesIntegration = { id: 'files', … }`. Without
526
+ * this strip, the registration probe matched the SENTENCE, then found the
527
+ * `id:` of the descriptor below it, and reported a JS registration of `files`
528
+ * that no code performs.
529
+ *
530
+ * The `[^:]` guard on the line-comment form keeps `https://…` inside a string
531
+ * from eating the rest of the line.
532
+ *
533
+ * @param {string} src The source text.
534
+ *
535
+ * @return {string} The source with comments blanked out.
536
+ */
537
+ function stripJsComments(src) {
538
+ return src
539
+ .replace(/\/\*[\s\S]*?\*\//g, '')
540
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
541
+ }
542
+
344
543
  /**
345
544
  * Extract the JS integration registration ids declared in a repo's
346
- * `src/**` — every `registerIntegration({ id: '...' })` CALL site (the
545
+ * `src/**` — every CALL site of EITHER supported registration API (the
347
546
  * `export function registerIntegration` DEFINITION in the shared library is
348
547
  * excluded). These are the ids mounted on `window.OCA.OpenRegister.integrations`.
349
548
  *
549
+ * THE JS PROBE MUST MATCH BOTH SUPPORTED REGISTRATION APIs, NOT ONE.
550
+ * -----------------------------------------------------------------
551
+ * `registerIntegration(descriptor)` is the convenience wrapper exported from
552
+ * this package (`src/integrations/registry.js`). The registry object it wraps
553
+ * is equally canonical and is called directly as
554
+ * `window.OCA.OpenRegister.integrations.register(descriptor)` — which is what
555
+ * openregister's own `src/main.js` uses for `xwiki`, and decidesk for
556
+ * `decidesk-decisions`.
557
+ *
558
+ * Probing for only the wrapper made this collector produce a FALSE ABSENCE:
559
+ * an id registered through the direct form was reported as a PHANTOM render
560
+ * surface (server descriptor with "no" JS registration) while that very id
561
+ * was live in the registry. The hydra gate-24 shell probe was fixed for
562
+ * exactly this; this copy had not been.
563
+ *
564
+ * `integrations\s*\.\s*register` cannot collide with `unregister(` (`.register`
565
+ * requires the dot immediately before `register`) nor with
566
+ * `registerIntegrationIcons(` (the `\(` anchors the wrapper form).
567
+ *
568
+ * KNOWN GAP, deliberately not widened: a third spelling exists in the wild —
569
+ * `<localVar>.register({ id })`, e.g. openregister's
570
+ * `src/integrations/builtin/bookmarks.js` calling `registry.register(...)` on
571
+ * a registry handed in as a parameter. Matching any `.register(` would sweep
572
+ * in unrelated registries (routers, icon maps, widget registries) and
573
+ * manufacture ORPHAN findings, so only the two named APIs are matched — the
574
+ * same two the hydra gate probes.
575
+ *
350
576
  * @param {string} repoRoot The repo root to scan.
351
577
  *
352
- * @return {Array<{id: string, renderMode: string, file: string}>} The discovered
353
- * registrations (`renderMode` is `'mount'` or `'component'`).
578
+ * @return {{resolved: Array<{id: string, renderMode: string, file: string}>, unresolved: Array<{file: string, snippet: string}>}}
579
+ * The registrations whose id could be read (`renderMode` is `'mount'` or
580
+ * `'component'`), and the call sites whose id could not be — those are
581
+ * REPORTED, never silently dropped.
354
582
  */
355
- function collectJsRegistrations(repoRoot) {
356
- const regs = []
583
+ function collectJsRegistrationSites(repoRoot) {
584
+ const resolved = []
585
+ const unresolved = []
357
586
  const jsFiles = collectFiles(
358
587
  path.join(repoRoot, 'src'),
359
588
  (n) => n.endsWith('.js') || n.endsWith('.ts') || n.endsWith('.vue'),
360
589
  )
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*(?:'([^']+)'|"([^"]+)"|`([^`]+)`)/
590
+ const idRe = /\bid:\s*(?:'([^']+)'|"([^"]+)"|`([^`]+)`|([A-Za-z_$][\w$]*))/
365
591
  for (const file of jsFiles) {
366
592
  let src
367
593
  try {
368
- src = fs.readFileSync(file, 'utf8')
594
+ src = stripJsComments(fs.readFileSync(file, 'utf8'))
369
595
  } catch (e) {
370
596
  continue
371
597
  }
598
+ const rel = path.relative(repoRoot, file)
599
+ // Skip the registry IMPLEMENTATION module. Its internal
600
+ // `integrations.register(descriptor)` / `(queued)` forwards are the API
601
+ // itself, not registrations of any particular leaf — counting them
602
+ // would report three permanently-unresolvable "registrations" in this
603
+ // library's own CI, every run, forever. Same reasoning as the existing
604
+ // `function registerIntegration(` definition-site exclusion, widened
605
+ // from the call site to the module that exports the API.
606
+ if (/export\s+function\s+(?:registerIntegration|createIntegrationRegistry|installIntegrationRegistry)\s*\(/.test(src)) {
607
+ continue
608
+ }
609
+ // Module-level `const NAME = 'literal'` table, so an `id:` written as a
610
+ // named constant still resolves — decidesk declares
611
+ // `const DECISIONS_INTEGRATION_ID = 'decidesk-decisions'` and its
612
+ // descriptor says `id: DECISIONS_INTEGRATION_ID`.
613
+ const jsConsts = {}
614
+ const jsConstRe = /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:'([^']*)'|"([^"]*)"|`([^`]*)`)/g
615
+ let jc
616
+ while ((jc = jsConstRe.exec(src)) !== null) {
617
+ jsConsts[jc[1]] = jc[2] !== undefined ? jc[2] : (jc[3] !== undefined ? jc[3] : jc[4])
618
+ }
619
+ // Either registration API, followed (within a small window) by `id: …`.
620
+ // Constructed per file: a `g`-flagged regex hoisted out of the loop
621
+ // carries `lastIndex` from the previous file and silently skips matches
622
+ // that sit before it.
623
+ const callRe = /\bregisterIntegration\s*\(|\bintegrations\s*\.\s*register\s*\(/g
372
624
  let cm
373
625
  while ((cm = callRe.exec(src)) !== null) {
374
626
  const before = src.slice(Math.max(0, cm.index - 20), cm.index)
375
627
  if (/function\s+$/.test(before)) {
376
628
  continue // the `export function registerIntegration(` definition
377
629
  }
378
- const window = src.slice(cm.index, cm.index + 400)
630
+ let window = src.slice(cm.index, cm.index + 400)
631
+ // A DESCRIPTOR PASSED BY NAME IS STILL A REGISTRATION.
632
+ //
633
+ // decidesk writes
634
+ // target.OCA.OpenRegister.integrations.register(decisionsLeafDescriptor)
635
+ // with the object declared above as `export const
636
+ // decisionsLeafDescriptor = { id: DECISIONS_INTEGRATION_ID, … }`. An
637
+ // inline-literal-only probe finds no `id:` in the call window, drops
638
+ // the registration, and the repo then reports "no JS integration
639
+ // registration at all" — a false ABSENCE claim about a leaf that is
640
+ // live in the registry. Resolve a bare identifier argument against
641
+ // its declaration in the same file.
642
+ const byName = /^\(\s*([A-Za-z_$][\w$]*)\s*[),]/.exec(
643
+ src.slice(cm.index + cm[0].length - 1, cm.index + cm[0].length + 80),
644
+ )
645
+ if (byName !== null) {
646
+ const declRe = new RegExp(`\\b(?:const|let|var)\\s+${byName[1]}\\s*=\\s*\\{`)
647
+ const dm = declRe.exec(src)
648
+ if (dm !== null) {
649
+ window = src.slice(dm.index, dm.index + 800)
650
+ }
651
+ }
379
652
  const m = idRe.exec(window)
380
- const id = m ? (m[1] || m[2] || m[3]) : null
653
+ let id = null
654
+ if (m !== null) {
655
+ id = m[1] || m[2] || m[3] || (m[4] !== undefined ? jsConsts[m[4]] : undefined) || null
656
+ }
381
657
  // renderMode declared on the JS registration (openregister#2127).
382
658
  const renderMode = /renderMode\s*:\s*'mount'|renderMode\s*:\s*"mount"|renderMode\s*:\s*`mount`/.test(window)
383
659
  ? 'mount'
384
660
  : 'component'
385
661
  if (id) {
386
- regs.push({ id, renderMode, file: path.relative(repoRoot, file) })
662
+ resolved.push({ id, renderMode, file: rel })
663
+ continue
664
+ }
665
+ // A CALL SITE WHOSE ID CANNOT BE READ IS NOT AN ABSENT REGISTRATION.
666
+ //
667
+ // procest registers with `registerIntegration({ ...fieldInspectionIntegration,
668
+ // offlineConfig: { … } })` — the id arrives by object spread from an
669
+ // imported library descriptor and no `id:` appears at the call site at
670
+ // all. Dropping it made the repo report "no JS integration registration
671
+ // under src/**", which is false. It cannot be correlated, but it CAN be
672
+ // counted and named, and the reporter says so.
673
+ unresolved.push({ file: rel, snippet: window.split('\n')[0].trim().slice(0, 80) })
674
+ }
675
+ }
676
+ return { resolved, unresolved }
677
+ }
678
+
679
+ /**
680
+ * The JS registrations whose id could be resolved statically.
681
+ *
682
+ * @param {string} repoRoot The repo root to scan.
683
+ *
684
+ * @return {Array<{id: string, renderMode: string, file: string}>} The registrations.
685
+ */
686
+ function collectJsRegistrations(repoRoot) {
687
+ return collectJsRegistrationSites(repoRoot).resolved
688
+ }
689
+
690
+ /**
691
+ * Parse the identifiers listed inside an exported array literal, e.g. the
692
+ * `builtinIntegrations` list in `src/integrations/builtin/index.js`.
693
+ *
694
+ * @param {string} source The module source.
695
+ * @param {string} name The exported array's name.
696
+ *
697
+ * @return {?string[]} The identifiers, or `null` when the array is not found.
698
+ */
699
+ function parseArrayIdentifiers(source, name) {
700
+ const match = source.match(new RegExp(`export\\s+const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\n\\]`))
701
+ if (match === null) {
702
+ return null
703
+ }
704
+ return match[1]
705
+ .split('\n')
706
+ .map((line) => line.replace(/\/\/.*$/, '').trim().replace(/,$/, ''))
707
+ .filter((entry) => /^[A-Za-z_$][\w$]*$/.test(entry))
708
+ }
709
+
710
+ /**
711
+ * Resolve the leaf ids that @conduction/nextcloud-vue registers on a
712
+ * consuming app's behalf.
713
+ *
714
+ * WHY THIS EXISTS — WITHOUT IT, THE FIX WOULD HAVE MANUFACTURED ~24 PHANTOMS.
715
+ * --------------------------------------------------------------------------
716
+ * A leaf's JS face does not have to live in the app repo. openregister's
717
+ * bootstrap (`src/integrations/bootstrap.js`) registers its leaves by calling
718
+ * TWO helpers exported from this library:
719
+ *
720
+ * registerBuiltinIntegrations(registry) // builtin/index.js
721
+ * registerLeafIntegrations(registry) // builtin/leaves.js
722
+ *
723
+ * Those helpers install ~26 + 18 descriptors — real, mounted tab/widget pairs
724
+ * — for ids whose `id:` literal appears nowhere in openregister's own `src/`.
725
+ * A strictly within-repo correlation would call every one of them a PHANTOM
726
+ * render surface: a large, confident, WRONG finding set on the repo that owns
727
+ * the registry. So a library-contributed registration counts as a JS face.
728
+ *
729
+ * Deliberately ASYMMETRIC, and this is the judgement call:
730
+ * - Library ids DO satisfy the phantom direction (server descriptor → a JS
731
+ * face exists, therefore it is not a phantom). That is simply true at
732
+ * runtime.
733
+ * - Library ids are NOT reported in the orphan direction. An orphan finding
734
+ * names a defect its repo can fix; a library descriptor with no server
735
+ * provider (`version-history`, `field-inspection` today) is this library's
736
+ * business, and reporting it in all 18 consuming app repos would be noise
737
+ * pointing at a file the app does not own.
738
+ * The counts for both are printed on every run, so neither is silent.
739
+ *
740
+ * The library source is read from THIS script's own package (`../src/...`),
741
+ * which resolves whether the script is run from the library checkout or from
742
+ * `node_modules/@conduction/nextcloud-vue/scripts/` in a consuming app —
743
+ * `package.json#files` ships both `src/` and `scripts/`.
744
+ *
745
+ * @param {string} repoRoot The target repo root (to see whether it calls the
746
+ * helpers at all).
747
+ *
748
+ * @return {{ids: string[], helpers: string[], problems: string[]}} The ids the
749
+ * library contributes to THIS repo (empty when it calls neither helper), the
750
+ * helper names it calls, and any resolution problems worth printing.
751
+ */
752
+ function collectLibraryRegistrations(repoRoot) {
753
+ // Which helpers does the target repo actually call? A repo that never
754
+ // calls them gets nothing from the library (hermiq, openconnector, …), so
755
+ // their behaviour is completely unchanged by this collector.
756
+ const helperSources = [
757
+ ['registerBuiltinIntegrations', 'index.js', 'builtinIntegrations'],
758
+ ['registerLeafIntegrations', 'leaves.js', 'leafIntegrations'],
759
+ ]
760
+ const jsFiles = collectFiles(
761
+ path.join(repoRoot, 'src'),
762
+ (n) => n.endsWith('.js') || n.endsWith('.ts') || n.endsWith('.vue'),
763
+ )
764
+ const called = new Set()
765
+ for (const file of jsFiles) {
766
+ let src
767
+ try {
768
+ // Comments stripped: several library docblocks name these helpers
769
+ // with parentheses ("registerLeafIntegrations() in OpenRegister's
770
+ // bootstrap"), and a mention is not a call.
771
+ src = stripJsComments(fs.readFileSync(file, 'utf8'))
772
+ } catch (e) {
773
+ continue
774
+ }
775
+ for (const [helper] of helperSources) {
776
+ const callRe = new RegExp(`\\b${helper}\\s*\\(`, 'g')
777
+ let cm
778
+ while ((cm = callRe.exec(src)) !== null) {
779
+ const before = src.slice(Math.max(0, cm.index - 20), cm.index)
780
+ if (/function\s+$/.test(before)) {
781
+ continue // the library's own `export function` definition
782
+ }
783
+ called.add(helper)
387
784
  }
388
785
  }
389
786
  }
390
- return regs
787
+ if (called.size === 0) {
788
+ return { ids: [], helpers: [], problems: [] }
789
+ }
790
+
791
+ const builtinDir = path.resolve(__dirname, '..', 'src', 'integrations', 'builtin')
792
+ const ids = new Set()
793
+ const problems = []
794
+
795
+ // Descriptor-name → id map, read from `export const xIntegration = { id: … }`
796
+ // across the library's builtin descriptor modules.
797
+ const nameToId = {}
798
+ for (const file of collectFiles(builtinDir, (n) => n.endsWith('.js'), 2)) {
799
+ let src
800
+ try {
801
+ src = fs.readFileSync(file, 'utf8')
802
+ } catch (e) {
803
+ continue
804
+ }
805
+ const declRe = /export\s+const\s+(\w+)\s*=\s*\{/g
806
+ let dm
807
+ while ((dm = declRe.exec(src)) !== null) {
808
+ const window = src.slice(dm.index, dm.index + 400)
809
+ const im = /\bid:\s*(?:'([^']+)'|"([^"]+)")/.exec(window)
810
+ if (im !== null) {
811
+ nameToId[dm[1]] = im[1] || im[2]
812
+ }
813
+ }
814
+ }
815
+
816
+ for (const [helper, fileName, arrayName] of helperSources) {
817
+ if (!called.has(helper)) {
818
+ continue
819
+ }
820
+ const abs = path.join(builtinDir, fileName)
821
+ let source
822
+ try {
823
+ source = fs.readFileSync(abs, 'utf8')
824
+ } catch (e) {
825
+ problems.push(
826
+ `this repo calls ${helper}() but the library's src/integrations/builtin/${fileName} `
827
+ + 'could not be read, so the ids it registers are UNKNOWN — every server descriptor '
828
+ + 'whose only JS face comes from that helper will be reported as a phantom below.',
829
+ )
830
+ continue
831
+ }
832
+ if (fileName === 'leaves.js') {
833
+ // leaves.js declares its descriptors inline: `leaf({ id: '…', … })`.
834
+ const arrayMatch = source.match(/export\s+const\s+leafIntegrations\s*=\s*\[([\s\S]*?)\n\]/)
835
+ if (arrayMatch === null) {
836
+ problems.push(`could not locate the library's \`leafIntegrations\` array — ${helper}() ids are UNKNOWN.`)
837
+ continue
838
+ }
839
+ const idRe = /\bid:\s*(?:'([^']+)'|"([^"]+)")/g
840
+ let lm
841
+ let found = 0
842
+ while ((lm = idRe.exec(arrayMatch[1])) !== null) {
843
+ ids.add(lm[1] || lm[2])
844
+ found += 1
845
+ }
846
+ if (found === 0) {
847
+ problems.push(`the library's \`leafIntegrations\` array parsed as empty — ${helper}() ids are UNKNOWN.`)
848
+ }
849
+ continue
850
+ }
851
+ // index.js lists descriptor BINDINGS; resolve each to its id.
852
+ const names = parseArrayIdentifiers(source, arrayName)
853
+ if (names === null || names.length === 0) {
854
+ problems.push(`could not parse the library's \`${arrayName}\` array — ${helper}() ids are UNKNOWN.`)
855
+ continue
856
+ }
857
+ const unresolved = []
858
+ for (const name of names) {
859
+ if (nameToId[name] === undefined) {
860
+ unresolved.push(name)
861
+ continue
862
+ }
863
+ ids.add(nameToId[name])
864
+ }
865
+ if (unresolved.length > 0) {
866
+ problems.push(
867
+ `${unresolved.length} descriptor(s) in the library's \`${arrayName}\` could not be resolved to an id `
868
+ + `(${unresolved.join(', ')}) — a server descriptor matching one of them may be reported as a phantom below.`,
869
+ )
870
+ }
871
+ }
872
+
873
+ return { ids: [...ids], helpers: [...called].sort(), problems }
391
874
  }
392
875
 
393
876
  /**
394
877
  * 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).
878
+ * registration ids within one repo, producing advisory warnings for orphans
879
+ * both ways (ADR-066).
397
880
  *
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.
881
+ * Scoped pragmatically (WARN-first): the correlation only runs when the repo
882
+ * carries at least one server-side leaf face — a `new LeafDescriptor(` OR an
883
+ * `IntegrationProvider` class. When it cannot run, the result carries a
884
+ * `notRun` explanation and {@link reportCrossRef} SAYS SO OUT LOUD; the
885
+ * caller must be able to tell "correlated, all good" from "correlated
886
+ * nothing". Every result also carries a `summary` with the denominators, so
887
+ * "no warnings" is never reported without the coverage behind it.
405
888
  *
406
889
  * DEFERRED (documented follow-up, not implemented in this pass):
407
890
  * - Correlating this library's own `builtinIntegrations` against the PHP
408
891
  * descriptors that live in EACH consuming app (a true cross-repo join);
409
- * today each app runs this gate against its own tree.
892
+ * today each app runs this gate against its own tree, with the library's
893
+ * contribution folded in via {@link collectLibraryRegistrations}.
410
894
  * - Reading the `openregister.integrations.leaves` capability payload at
411
895
  * runtime and asserting it against the JS registry live (this static
412
896
  * pass approximates it from the PHP source).
@@ -415,26 +899,65 @@ function collectJsRegistrations(repoRoot) {
415
899
  *
416
900
  * @param {string} repoRoot The repo root to scan (usually `process.cwd()`).
417
901
  *
418
- * @return {{ran: boolean, warnings: string[]}} Whether the cross-ref ran
419
- * (server descriptors present) and any advisory warnings.
902
+ * @return {{ran: boolean, warnings: string[], summary: object, notRun: ?object}}
903
+ * Whether the correlation ran, the advisory warnings, the coverage summary,
904
+ * and — when it did not run — why, with the numbers behind that claim.
420
905
  */
421
906
  function crossReferenceServerLeaves(repoRoot) {
422
907
  const descriptors = collectServerDescriptors(repoRoot)
908
+ const sites = collectJsRegistrationSites(repoRoot)
909
+ const registrations = sites.resolved
910
+ const library = collectLibraryRegistrations(repoRoot)
911
+
912
+ const summary = {
913
+ serverDescriptors: descriptors.length,
914
+ leafDescriptorFaces: descriptors.filter((d) => d.face === 'LeafDescriptor').length,
915
+ integrationProviderFaces: descriptors.filter((d) => d.face === 'IntegrationProvider').length,
916
+ jsRegistrations: registrations.length,
917
+ unresolvedJsSites: sites.unresolved,
918
+ libraryRegistrations: library.ids.length,
919
+ libraryHelpers: library.helpers,
920
+ libraryProblems: library.problems,
921
+ correlatedIds: 0,
922
+ }
923
+
423
924
  if (descriptors.length === 0) {
424
- // No server-side leaf face in this repo nothing to correlate.
425
- return { ran: false, warnings: [] }
925
+ // NOT A PASS this used to `return { ran: false }` and print nothing,
926
+ // which read exactly like a clean correlation. Hand the reporter the
927
+ // numbers so it can say what went uncorrelated.
928
+ return {
929
+ ran: false,
930
+ warnings: [],
931
+ summary,
932
+ notRun: {
933
+ reason: 'no server-side leaf face found under lib/**',
934
+ jsIds: registrations.map((r) => r.id),
935
+ },
936
+ }
426
937
  }
427
- const registrations = collectJsRegistrations(repoRoot)
938
+
428
939
  const jsIds = new Set(registrations.map((r) => r.id))
940
+ // A leaf's JS face may be contributed by @conduction/nextcloud-vue on this
941
+ // repo's behalf — see collectLibraryRegistrations for why that counts.
942
+ const allJsIds = new Set([...jsIds, ...library.ids])
429
943
  const phpIds = new Set(descriptors.map((d) => d.id))
430
944
  const jsModeById = new Map(registrations.map((r) => [r.id, r.renderMode]))
431
945
  const warnings = []
432
946
 
947
+ summary.correlatedIds = [...phpIds].filter((id) => allJsIds.has(id)).length
948
+
433
949
  // renderMode cross-layer correlation (openregister#2127 / ADR-066): for a
434
950
  // render-surface leaf present on both sides, the server descriptor's
435
951
  // renderMode MUST equal the JS registration's under the shared id.
952
+ //
953
+ // A descriptor whose shape carries NO renderMode (`null` — every
954
+ // `IntegrationProvider` class, whose interface has no such member) is
955
+ // skipped: asserting a value nobody declared would invent a mismatch
956
+ // against a JS registration that legitimately says 'mount'. Library-
957
+ // contributed faces are skipped for the same reason — their renderMode is
958
+ // not read here.
436
959
  for (const d of descriptors) {
437
- if (!d.renderSurface || !jsModeById.has(d.id)) {
960
+ if (!d.renderSurface || d.renderMode === null || !jsModeById.has(d.id)) {
438
961
  continue
439
962
  }
440
963
  const jsMode = jsModeById.get(d.id)
@@ -448,29 +971,36 @@ function crossReferenceServerLeaves(repoRoot) {
448
971
  }
449
972
 
450
973
  // Phantom render surface: a render-surface descriptor discoverable in the
451
- // capability whose JS widget never registered.
974
+ // capability whose JS widget never registered — in this repo's src/** OR
975
+ // via a library helper this repo calls.
452
976
  for (const d of descriptors) {
453
- if (d.renderSurface && !jsIds.has(d.id)) {
977
+ if (d.renderSurface && !allJsIds.has(d.id)) {
454
978
  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).',
979
+ `render-surface leaf ${d.face} "${d.id}" (${d.file}) has NO matching JS `
980
+ + 'registration — neither registerIntegration({ id }) nor '
981
+ + 'integrations.register({ id }) in src/**, and no descriptor of that id '
982
+ + 'contributed by @conduction/nextcloud-vue. Phantom render surface: the '
983
+ + 'capability advertises a tab/widget that never mounts.',
458
984
  )
459
985
  }
460
986
  }
461
987
  // Orphan JS: a registration with no server descriptor of any kind — the
462
- // widget mounts but the leaf is invisible to the capability.
988
+ // widget mounts but the leaf is invisible to the capability. Only THIS
989
+ // repo's own registrations are reported; a library-contributed id with no
990
+ // server face is the library's defect, not this repo's (see
991
+ // collectLibraryRegistrations).
463
992
  for (const r of registrations) {
464
993
  if (!phpIds.has(r.id)) {
465
994
  warnings.push(
466
- `registerIntegration id "${r.id}" (${r.file}) has NO matching server-side `
467
- + 'LeafDescriptor in lib/** — orphan JS registration (mounts on '
995
+ `JS registration id "${r.id}" (${r.file}) has NO matching server-side leaf `
996
+ + 'face in lib/** — neither a `new LeafDescriptor(` nor an IntegrationProvider '
997
+ + 'class with that getId(). Orphan JS registration (mounts on '
468
998
  + 'window.OCA.OpenRegister.integrations but is not discoverable via the '
469
999
  + 'openregister.integrations.leaves capability).',
470
1000
  )
471
1001
  }
472
1002
  }
473
- return { ran: true, warnings }
1003
+ return { ran: true, warnings, summary, notRun: null }
474
1004
  }
475
1005
 
476
1006
  /**
@@ -499,20 +1029,116 @@ function report(list) {
499
1029
  }
500
1030
 
501
1031
  /**
502
- * Print the WARN-only server↔JS cross-ref result. Never fails the build.
1032
+ * Render the one-line coverage statement behind a cross-ref verdict the
1033
+ * denominators, always. "No warnings" without this line is the shape that let
1034
+ * a correlation over nothing read as a pass.
1035
+ *
1036
+ * @param {object} summary The summary block from {@link crossReferenceServerLeaves}.
1037
+ *
1038
+ * @return {string} The coverage line.
1039
+ */
1040
+ function coverageLine(summary) {
1041
+ const lib = summary.libraryRegistrations > 0
1042
+ ? ` + ${summary.libraryRegistrations} contributed by @conduction/nextcloud-vue `
1043
+ + `(${summary.libraryHelpers.join(', ')})`
1044
+ : ''
1045
+ return ` correlated ${summary.serverDescriptors} server-side leaf face(s) `
1046
+ + `[${summary.leafDescriptorFaces} new LeafDescriptor(, ${summary.integrationProviderFaces} IntegrationProvider class] `
1047
+ + `against ${summary.jsRegistrations} JS registration(s) in src/**${lib}; `
1048
+ + `${summary.correlatedIds} server id(s) matched a JS face.`
1049
+ }
1050
+
1051
+ /**
1052
+ * Print the WARN-only server↔JS cross-ref result. Never fails the build — but
1053
+ * it is never SILENT either.
503
1054
  *
504
- * @param {string[]} warnings Advisory warning messages.
505
- * @param {boolean} ran Whether the cross-ref actually ran (server leaves present).
1055
+ * A CORRELATION THAT DID NOT RUN LOOKS EXACTLY LIKE ONE THAT PASSED.
1056
+ * -----------------------------------------------------------------
1057
+ * This function used to open with `if (!ran) { return }`, so on a repo where
1058
+ * the descriptor scan found nothing it printed neither a `✓` nor a warning.
1059
+ * openregister — which owns the integration registry and ships 28 server-side
1060
+ * leaves — landed in exactly that branch, because the descriptor scan only
1061
+ * knew `new LeafDescriptor(`. The gate above it read the missing output as a
1062
+ * pass. Now the not-run path says what could not be correlated and why, and
1063
+ * the ran path always prints its coverage denominators.
1064
+ *
1065
+ * @param {object} result The result of {@link crossReferenceServerLeaves}.
506
1066
  *
507
1067
  * @return {void}
508
1068
  */
509
- function reportCrossRef(warnings, ran) {
1069
+ function reportCrossRef(result) {
1070
+ const { warnings, ran, summary, notRun } = result
1071
+
1072
+ // Call sites that ARE registrations but whose id no static read can
1073
+ // recover. Printed on every path — a correlation that skipped one of these
1074
+ // has a hole in it, and the hole is named.
1075
+ if (summary.unresolvedJsSites.length > 0) {
1076
+ // eslint-disable-next-line no-console
1077
+ console.warn(
1078
+ `⚠ server↔JS leaf parity (ADR-066): ${summary.unresolvedJsSites.length} JS registration call site(s) `
1079
+ + 'whose id could not be read statically (spread from an imported descriptor, or a computed id). '
1080
+ + 'They are registrations, they are NOT correlated by this run, and they are NOT counted as absent:',
1081
+ )
1082
+ for (const u of summary.unresolvedJsSites) {
1083
+ // eslint-disable-next-line no-console
1084
+ console.warn(` - ${u.file}: ${u.snippet}`)
1085
+ }
1086
+ }
1087
+
510
1088
  if (!ran) {
1089
+ if (summary.jsRegistrations === 0 && summary.unresolvedJsSites.length === 0) {
1090
+ // Both sides empty: genuinely not applicable, and said so rather
1091
+ // than left blank. Mirrors hydra gate-24's `na` classification.
1092
+ // eslint-disable-next-line no-console
1093
+ console.log(
1094
+ 'i server↔JS leaf parity (ADR-066): NOT APPLICABLE — this repo declares no server-side '
1095
+ + 'leaf face under lib/** (no `new LeafDescriptor(` and no IntegrationProvider class with '
1096
+ + 'a getId()) and no JS integration registration under src/**. There is no server↔JS pair '
1097
+ + 'to correlate.',
1098
+ )
1099
+ if (path.resolve(process.cwd()) === path.resolve(__dirname, '..')) {
1100
+ // Do not let the library's own CI read that as "the descriptors
1101
+ // in src/integrations/ are verified". They are not verified
1102
+ // HERE — their server faces live in the consuming app repos.
1103
+ // eslint-disable-next-line no-console
1104
+ console.log(
1105
+ ' This is @conduction/nextcloud-vue itself. The built-in and leaf descriptors it '
1106
+ + 'DEFINES are correlated against a server face inside each consuming app repo, not '
1107
+ + 'here; the cross-repo join is a documented ADR-066 follow-up (see '
1108
+ + 'crossReferenceServerLeaves).',
1109
+ )
1110
+ }
1111
+ return
1112
+ }
1113
+ // One side present, the other empty: NOTHING was correlated, and that
1114
+ // is not a pass. Mirrors hydra gate-24's `structural` classification.
1115
+ // eslint-disable-next-line no-console
1116
+ console.warn('⚠ server↔JS leaf parity (ADR-066): NOTHING was correlated — this is NOT a pass.')
1117
+ // eslint-disable-next-line no-console
1118
+ console.warn(` - ${notRun.reason}: 0 found under lib/** (looked for \`new LeafDescriptor(\` and for classes extending/implementing IntegrationProvider with a getId()).`)
1119
+ const named = notRun.jsIds.length > 0 ? `: ${notRun.jsIds.join(', ')}` : ''
1120
+ // eslint-disable-next-line no-console
1121
+ console.warn(
1122
+ ` - ${summary.jsRegistrations} JS registration(s) with a readable id${named}`
1123
+ + `, plus ${summary.unresolvedJsSites.length} call site(s) listed above, went UNVERIFIED.`,
1124
+ )
1125
+ // eslint-disable-next-line no-console
1126
+ console.warn(' An orphan JS registration (a widget that mounts with no server face) is invisible to this run, and no phantom render surface can be detected either.')
511
1127
  return
512
1128
  }
1129
+
1130
+ if (summary.libraryProblems.length > 0) {
1131
+ for (const p of summary.libraryProblems) {
1132
+ // eslint-disable-next-line no-console
1133
+ console.warn(`⚠ server↔JS leaf parity (ADR-066): ${p}`)
1134
+ }
1135
+ }
1136
+
513
1137
  if (warnings.length === 0) {
514
1138
  // eslint-disable-next-line no-console
515
1139
  console.log('✓ server↔JS leaf parity (ADR-066): every render-surface descriptor has a JS registration and vice-versa')
1140
+ // eslint-disable-next-line no-console
1141
+ console.log(coverageLine(summary))
516
1142
  return
517
1143
  }
518
1144
  // eslint-disable-next-line no-console
@@ -522,7 +1148,9 @@ function reportCrossRef(warnings, ran) {
522
1148
  console.warn(` - ${w}`)
523
1149
  }
524
1150
  // eslint-disable-next-line no-console
525
- console.warn('\nThe server LeafDescriptor id MUST equal the JS registerIntegration id (ADR-019 / ADR-066).')
1151
+ console.warn(coverageLine(summary))
1152
+ // eslint-disable-next-line no-console
1153
+ console.warn('\nThe server leaf id (LeafDescriptor `id:` or IntegrationProvider `getId()`) MUST equal the JS registration id (ADR-019 / ADR-066).')
526
1154
  }
527
1155
 
528
1156
  // Run the gate only on direct invocation (`node check-integration-parity.js`),
@@ -536,5 +1164,8 @@ if (require.main === module) {
536
1164
  module.exports = {
537
1165
  collectServerDescriptors,
538
1166
  collectJsRegistrations,
1167
+ collectJsRegistrationSites,
1168
+ collectLibraryRegistrations,
539
1169
  crossReferenceServerLeaves,
1170
+ reportCrossRef,
540
1171
  }