@conduction/nextcloud-vue 2.2.0-vue3.14 → 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 +2 -1
- package/scripts/check-integration-parity.js +745 -114
- package/stylelint/index.js +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "2.2.0-vue3.
|
|
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>",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"src/",
|
|
25
25
|
"css/",
|
|
26
26
|
"eslint/",
|
|
27
|
+
"stylelint/",
|
|
27
28
|
"l10n/",
|
|
28
29
|
"scripts/",
|
|
29
30
|
"testing/",
|
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* (
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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.
|
|
154
|
-
//
|
|
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(
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
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}>}
|
|
353
|
-
* registrations (`renderMode` is `'mount'` or
|
|
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
|
|
356
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
396
|
-
*
|
|
878
|
+
* registration ids within one repo, producing advisory warnings for orphans
|
|
879
|
+
* both ways (ADR-066).
|
|
397
880
|
*
|
|
398
|
-
* Scoped pragmatically (WARN-first): the
|
|
399
|
-
* carries at least one server-side `LeafDescriptor`
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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[]
|
|
419
|
-
*
|
|
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
|
-
//
|
|
425
|
-
|
|
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
|
-
|
|
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 && !
|
|
977
|
+
if (d.renderSurface && !allJsIds.has(d.id)) {
|
|
454
978
|
warnings.push(
|
|
455
|
-
`render-surface leaf
|
|
456
|
-
+ 'registerIntegration({ id })
|
|
457
|
-
+ '
|
|
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
|
-
`
|
|
467
|
-
+ '
|
|
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
|
-
*
|
|
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
|
-
*
|
|
505
|
-
*
|
|
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(
|
|
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(
|
|
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
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
3
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
4
|
+
*
|
|
5
|
+
* `@conduction/nextcloud-vue/stylelint` — the shared Stylelint preset for every
|
|
6
|
+
* Conduction Nextcloud app.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS LIVES HERE
|
|
9
|
+
* -------------------
|
|
10
|
+
* The same reason the ESLint preset does, and the same reason the PHP ruleset
|
|
11
|
+
* moved to `conduction/hydra-gates`: a copied config is not a shared config.
|
|
12
|
+
*
|
|
13
|
+
* Measured across the 18 core apps on 2026-08-12, `stylelint.config.js` existed
|
|
14
|
+
* in six variants. Ten apps were byte-identical to this file; the other eight
|
|
15
|
+
* had each drifted separately. None of that drift was a decision.
|
|
16
|
+
*
|
|
17
|
+
* Stylelint cannot be homed the way PHPCS was, through a path into `vendor/`,
|
|
18
|
+
* because a Stylelint config resolves `extends` against `node_modules` relative
|
|
19
|
+
* to itself. An npm package is the only channel that works — and every app
|
|
20
|
+
* already depends on this one.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT IS
|
|
23
|
+
* ----------
|
|
24
|
+
* `@nextcloud/stylelint-config`, plus exactly one addition.
|
|
25
|
+
*
|
|
26
|
+
* That is deliberate and it mirrors `conduction/coding-standard` on the PHP
|
|
27
|
+
* side: Conduction code must pass Nextcloud's own checks unchanged. We may be
|
|
28
|
+
* STRICTER than Nextcloud; we may not be DIFFERENT from it. Anything here that
|
|
29
|
+
* contradicted `@nextcloud/stylelint-config` would put an app in the position
|
|
30
|
+
* the PHP toolchain was in until this week — two tools with overlapping
|
|
31
|
+
* jurisdiction demanding opposite things, and no way to satisfy both.
|
|
32
|
+
*
|
|
33
|
+
* THE ONE ADDITION
|
|
34
|
+
* ----------------
|
|
35
|
+
* `::v-deep` is a Vue SFC scoped-style selector, not a CSS pseudo-element.
|
|
36
|
+
* Stylelint's `selector-pseudo-element-no-unknown` does not know it and flags
|
|
37
|
+
* every use. Nextcloud's config does not carry the exception because Nextcloud
|
|
38
|
+
* core does not use `::v-deep`; this fleet does, in every app that restyles a
|
|
39
|
+
* child component's internals.
|
|
40
|
+
*
|
|
41
|
+
* This is additive in the strict sense — it relaxes a rule on a token Nextcloud
|
|
42
|
+
* never emits — so a file that satisfies this preset still satisfies theirs.
|
|
43
|
+
*
|
|
44
|
+
* USAGE
|
|
45
|
+
* -----
|
|
46
|
+
* // stylelint.config.js
|
|
47
|
+
* module.exports = require('@conduction/nextcloud-vue/stylelint')
|
|
48
|
+
*
|
|
49
|
+
* To add an app-specific rule, spread it — do not redefine `extends`:
|
|
50
|
+
*
|
|
51
|
+
* const base = require('@conduction/nextcloud-vue/stylelint')
|
|
52
|
+
* module.exports = { ...base, rules: { ...base.rules, 'my/rule': true } }
|
|
53
|
+
*/
|
|
54
|
+
module.exports = {
|
|
55
|
+
extends: '@nextcloud/stylelint-config',
|
|
56
|
+
rules: {
|
|
57
|
+
'selector-pseudo-element-no-unknown': [true, {
|
|
58
|
+
ignorePseudoElements: ['v-deep'],
|
|
59
|
+
}],
|
|
60
|
+
},
|
|
61
|
+
}
|