From 8544c4a8bf84e16a87f2b42107dbc278d6293d8e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sun, 19 Jul 2026 13:15:46 +0900 Subject: [PATCH 1/2] Move binding diagnostics out of core Drop the Ray\Bindings namespace (Bindings, BindingsMarkdown, BindingsHtml, the bindings-html CLI and its viewer assets) and the deprecated Ray\Di\BindingsMarkdown shim. Rich visualization moves to the ray/object-visual-grapher package; composition provenance (BindingLog) stays in core. Ray\Di\ModuleVisitorInterface replaces Ray\Bindings\ModuleVisitorInterface as the stable route for tools to visit a module's composed container via AbstractModule::accept(). --- README.md | 25 -- bin/bindings-html | 66 --- composer.json | 14 +- docs/bindings/bindings.css | 73 ---- docs/bindings/bindings.js | 188 -------- phpstan.neon | 3 - phpunit.xml.dist | 2 - psalm.xml | 1 - src-bindings/Bindings.php | 38 -- src-bindings/BindingsHtml.php | 308 ------------- src-bindings/BindingsMarkdown.php | 168 ------- .../Exception/BindingsNotCollected.php | 12 - src/di/AbstractModule.php | 1 - src/di/BindingsMarkdown.php | 28 -- .../di}/ModuleVisitorInterface.php | 4 +- tests-bindings/BindingsHtmlTest.php | 412 ------------------ tests-bindings/BindingsMarkdownTest.php | 236 ---------- tests-bindings/BindingsTest.php | 146 ------- tests/di/AbstractModuleTest.php | 17 + 19 files changed, 24 insertions(+), 1718 deletions(-) delete mode 100755 bin/bindings-html delete mode 100644 docs/bindings/bindings.css delete mode 100644 docs/bindings/bindings.js delete mode 100644 src-bindings/Bindings.php delete mode 100644 src-bindings/BindingsHtml.php delete mode 100644 src-bindings/BindingsMarkdown.php delete mode 100644 src-bindings/Exception/BindingsNotCollected.php delete mode 100644 src/di/BindingsMarkdown.php rename {src-bindings => src/di}/ModuleVisitorInterface.php (78%) delete mode 100644 tests-bindings/BindingsHtmlTest.php delete mode 100644 tests-bindings/BindingsMarkdownTest.php delete mode 100644 tests-bindings/BindingsTest.php diff --git a/README.md b/README.md index 0dfd82c58..bb85bd48b 100644 --- a/README.md +++ b/README.md @@ -12,29 +12,4 @@ Ray.Di is DI and AOP framework for PHP inspired by [Google Guice](https://github.com/google/guice/wiki). -## Binding snapshots - -Collect a module's composed bindings explicitly when diagnostics are needed: - -```php -use Ray\Bindings\Bindings; - -$bindings = new Bindings(); -$module->accept($bindings); - -$markdown = $bindings->toMarkdown(); -$html = $bindings->toHtml($composerLock, 'prod-app', $vendorDir); -``` - -The snapshot is captured when `accept()` is called. Later module changes and -Injector processing do not change it; visiting another module with the same -`Bindings` instance replaces the snapshot. It contains bindings composed by -the application module through `bind()`, `install()`, and `override()`, without -Injector built-ins or bindings discovered later during object resolution. - -`Injector` does not create `bindings.md` automatically. Applications that -previously read that generated file should use `toMarkdown()` or `toHtml()` as -shown above. The explicit `Ray\Bindings\BindingsMarkdown` file writer and the -`bindings-html` command remain available for file-based workflows. - https://ray-di.github.io diff --git a/bin/bindings-html b/bin/bindings-html deleted file mode 100755 index 124266fc7..000000000 --- a/bin/bindings-html +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env php - [composer.lock] [message] > bindings.html - * bin/bindings-html - [composer.lock] [message] < bindings.md > bindings.html - * - * The markdown is embedded verbatim in a
 data island; a shared viewer
- * (stylesheet + script served from jsDelivr) decorates it on load: colour-coded
- * events, a type filter, and the discarded side of every collision in red.
- * Without JavaScript the 
 shows the plain log.
- *
- * Pass a composer.lock to link each class to its source — a GitHub URL for
- * humans and the local vendor/ path (data-src) for agents. The optional
- * [message] renders as a subtitle, e.g. the context the bindings were composed
- * in.
- *
- * The rendering lives in Ray\Bindings\BindingsHtml so every consumer — this
- * CLI, a documentation generator — reuses the same viewer.
- */
-
-use Ray\Bindings\BindingsHtml;
-
-$autoload = null;
-foreach ([__DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../../autoload.php'] as $file) {
-    if (file_exists($file)) {
-        $autoload = $file;
-        break;
-    }
-}
-
-if ($autoload === null) {
-    fwrite(STDERR, "bindings-html: Composer autoloader not found; run `composer install`\n");
-    exit(1);
-}
-
-require $autoload;
-
-$path = $argv[1] ?? null;
-if ($path === null || $path === '-h' || $path === '--help') {
-    fwrite(STDERR, "usage: bindings-html  [composer.lock] [message] > out.html\n");
-    exit($path === null ? 1 : 0);
-}
-
-$md = $path === '-' ? file_get_contents('php://stdin') : @file_get_contents($path);
-if ($md === false) {
-    fwrite(STDERR, "bindings-html: cannot read {$path}\n");
-    exit(1);
-}
-
-$lock = '';
-$lockPath = $argv[2] ?? '';
-if ($lockPath !== '') {
-    $lock = @file_get_contents($lockPath);
-    if ($lock === false) {
-        fwrite(STDERR, "bindings-html: cannot read {$lockPath}\n");
-        exit(1);
-    }
-}
-
-echo (new BindingsHtml())->page($md, $lock, $argv[3] ?? '');
diff --git a/composer.json b/composer.json
index 4ded52b17..1b28d1a3f 100644
--- a/composer.json
+++ b/composer.json
@@ -9,7 +9,6 @@
             "email": "akihito.koriyama@gmail.com"
         }
     ],
-    "bin": ["bin/bindings-html"],
     "require": {
         "php": "^8.2",
         "koriym/null-object": "^1.0",
@@ -21,7 +20,8 @@
         "phpunit/phpunit": "^11.0"
     },
     "suggest": {
-        "ray/compiler": "For compiling dependency injection container to improve performance"
+        "ray/compiler": "For compiling dependency injection container to improve performance",
+        "ray/object-visual-grapher": "For visualizing composed bindings and the object graph"
     },
     "config": {
         "sort-packages": true,
@@ -32,14 +32,12 @@
     },
     "autoload": {
         "psr-4": {
-            "Ray\\Di\\": ["src/di", "src-deprecated/di"],
-            "Ray\\Bindings\\": "src-bindings/"
+            "Ray\\Di\\": ["src/di", "src-deprecated/di"]
         }
     },
     "autoload-dev": {
         "psr-4": {
-            "Ray\\Di\\": ["tests/di", "tests/di/Fake/"],
-            "Ray\\Bindings\\": "tests-bindings/"
+            "Ray\\Di\\": ["tests/di", "tests/di/Fake/"]
         }
     },
     "scripts": {
@@ -51,8 +49,8 @@
             "php -dextension=pcov.so -r \"extension_loaded('pcov') || (fwrite(STDERR, 'pcov required for mutation testing (dev-only). Install: pecl install pcov'.PHP_EOL) && exit(1));\"",
             "php -dextension=pcov.so -d pcov.enabled=1 vendor/bin/infection --threads=2 --min-msi=90 --min-covered-msi=90 --no-progress"
         ],
-        "cs": ["vendor-bin/tools/vendor/squizlabs/php_codesniffer/bin/phpcs --standard=./phpcs.xml src tests src-bindings tests-bindings"],
-        "cs-fix": ["vendor-bin/tools/vendor/squizlabs/php_codesniffer/bin/phpcbf src tests src-bindings tests-bindings"],
+        "cs": ["vendor-bin/tools/vendor/squizlabs/php_codesniffer/bin/phpcs --standard=./phpcs.xml src tests"],
+        "cs-fix": ["vendor-bin/tools/vendor/squizlabs/php_codesniffer/bin/phpcbf src tests"],
         "clean": ["vendor-bin/tools/vendor/phpstan/phpstan/phpstan clear-result-cache", "vendor-bin/tools/vendor/vimeo/psalm/psalm --clear-cache", "rm -rf tests/tmp/*.php"],
         "sa": ["vendor-bin/tools/vendor/vimeo/psalm/psalm -m -c psalm.xml --show-info=false", "vendor-bin/tools/vendor/phpstan/phpstan/phpstan analyse -c phpstan.neon --no-progress "],
         "metrics": ["@test", "vendor-bin/tools/vendor/phpmetrics/phpmetrics/bin/phpmetrics --report-html=build/metrics --exclude=Exception --log-junit=build/junit.xml --junit=build/junit.xml src"],
diff --git a/docs/bindings/bindings.css b/docs/bindings/bindings.css
deleted file mode 100644
index e07a7b912..000000000
--- a/docs/bindings/bindings.css
+++ /dev/null
@@ -1,73 +0,0 @@
-:root{
-  --bg:#faf9f7; --panel:#fff; --ink:#1c2027; --muted:#6b7280; --line:#e7e5e1;
-  --mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
-  --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
-  --bind:#8a919c; --replace:#c07c1e; --keep:#a8850e; --move:#2f80b8;
-  --lost:#c0392b; --at:#8a919c; --accent:#0ea5b7; --keyink:#2a3140;
-}
-@media (prefers-color-scheme:dark){
-  :root{ --bg:#0e1116; --panel:#151a21; --ink:#d5dae2; --muted:#8b93a0; --line:#242b34;
-    --bind:#7d8590; --replace:#e0a13a; --keep:#d4b024; --move:#58a6d8; --lost:#e5615a;
-    --at:#6b7280; --accent:#3ad0e0; --keyink:#c6ccd6; }
-}
-:root[data-theme="light"]{ --bg:#faf9f7;--panel:#fff;--ink:#1c2027;--muted:#6b7280;--line:#e7e5e1;
-  --bind:#8a919c;--replace:#c07c1e;--keep:#a8850e;--move:#2f80b8;--lost:#c0392b;--at:#8a919c;--accent:#0ea5b7;--keyink:#2a3140;}
-:root[data-theme="dark"]{ --bg:#0e1116;--panel:#151a21;--ink:#d5dae2;--muted:#8b93a0;--line:#242b34;
-  --bind:#7d8590;--replace:#e0a13a;--keep:#d4b024;--move:#58a6d8;--lost:#e5615a;--at:#6b7280;--accent:#3ad0e0;--keyink:#c6ccd6;}
-*{box-sizing:border-box}
-body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.5}
-.wrap{max-width:1100px;margin:0 auto;padding:32px 24px 80px}
-h1{font-size:15px;font-weight:600;letter-spacing:.02em;margin:0;color:var(--muted);text-transform:uppercase}
-header .sub{font-family:var(--mono);font-size:12px;color:var(--accent);margin-top:4px}
-.lead{font-family:var(--mono);font-size:12px;color:var(--muted);margin:2px 0 18px}
-.stats{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}
-.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 16px;min-width:104px}
-.stat .n{font-size:26px;font-weight:650;font-variant-numeric:tabular-nums;line-height:1}
-.stat .l{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px}
-.stat.replace .n{color:var(--replace)} .stat.keep .n{color:var(--keep)}
-.bar{position:sticky;top:0;background:linear-gradient(var(--bg),var(--bg) 70%,transparent);padding:10px 0;
-  display:flex;gap:8px;flex-wrap:wrap;align-items:center;z-index:5;border-bottom:1px solid var(--line);margin-bottom:14px}
-.bar .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:2px}
-.chip{font-family:var(--mono);font-size:12px;border:1px solid var(--line);background:var(--panel);color:var(--ink);
-  padding:4px 10px;border-radius:999px;cursor:pointer;user-select:none;display:inline-flex;gap:6px;align-items:center}
-.chip::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--c)}
-.chip[data-off="1"]{opacity:.34;text-decoration:line-through}
-.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
-.chip.bind{--c:var(--bind)} .chip.replace{--c:var(--replace)} .chip.keep{--c:var(--keep)} .chip.move{--c:var(--move)}
-section{margin:26px 0}
-h2{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);
-  margin:0 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
-h2 .hint{text-transform:none;font-weight:400;color:var(--muted)}
-.log{font-family:var(--mono);font-size:12.5px}
-.ev{padding:3px 10px 3px 12px;border-left:3px solid var(--bind);border-radius:0 4px 4px 0;
-  white-space:pre-wrap;word-break:break-word}
-.ev+.ev{border-top:1px solid color-mix(in srgb,var(--line) 55%,transparent)}
-.ev.bind{border-color:var(--bind)}
-.ev.replace{border-color:var(--replace);background:color-mix(in srgb,var(--replace) 7%,transparent)}
-.ev.keep{border-color:var(--keep);background:color-mix(in srgb,var(--keep) 7%,transparent)}
-.ev.move{border-color:var(--move)}
-.ev .t{display:inline-block;min-width:58px;color:var(--muted);text-transform:lowercase}
-.ev.replace .t{color:var(--replace)} .ev.keep .t{color:var(--keep)} .ev.move .t{color:var(--move)}
-.key{color:var(--keyink);font-weight:600} .arrow{color:var(--muted)} .tgt{color:var(--ink)} .nil{color:var(--muted)}
-.at{color:var(--at)} .mod{color:var(--accent)}
-/* source links keep their token colour; only an underline marks them clickable */
-#view a{color:inherit;text-decoration:underline;text-decoration-color:color-mix(in srgb,currentColor 45%,transparent);text-underline-offset:2px}
-#view a:hover{text-decoration-color:currentColor}
-.lost{display:block;padding:1px 0 1px 58px;color:var(--lost)}
-.lost .tag{opacity:.8;text-transform:uppercase;font-size:10.5px;letter-spacing:.05em;margin-right:4px}
-.lost .mod{color:color-mix(in srgb,var(--lost) 70%,var(--accent))}
-.b{font-family:var(--mono);font-size:12px;padding:5px 10px;white-space:pre-wrap;word-break:break-word}
-.b+.b{border-top:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
-.b .aop{margin-top:3px;padding-left:22px;font-size:11.5px;position:relative}
-.b .aop::before{content:"↳";position:absolute;left:6px;color:var(--muted)}
-.b .aop .tag{text-transform:uppercase;font-size:10px;letter-spacing:.06em;color:var(--move);margin-right:5px}
-.b .aop .methods{color:var(--muted)}
-.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(460px,1fr));gap:2px 22px}
-.ml{display:flex;justify-content:space-between;align-items:baseline;gap:12px;font-family:var(--mono);font-size:12px;
-  padding:3px 4px;border-bottom:1px solid color-mix(in srgb,var(--line) 45%,transparent)}
-.ml .mod{color:var(--accent);min-width:0;overflow-wrap:anywhere} .ml .cnt{color:var(--muted);font-variant-numeric:tabular-nums;flex-shrink:0}
-.hide-bind .ev.bind,.hide-replace .ev.replace,.hide-keep .ev.keep,.hide-move .ev.move{display:none}
-pre#src{font-family:var(--mono);font-size:12px;white-space:pre-wrap;word-break:break-word;color:var(--ink);margin:0}
-/* the viewer script shows #view and hides the raw 
 once it has rendered;
-   without JavaScript the 
 stays visible as a plain-text fallback */
-#view{display:none}
diff --git a/docs/bindings/bindings.js b/docs/bindings/bindings.js
deleted file mode 100644
index ee3abf5bf..000000000
--- a/docs/bindings/bindings.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * Ray.Di bindings viewer.
- *
- * Decorates a bindings.md embedded as 
 into the mount point
- * 
: colour-coded provenance events, a type filter, and the - * discarded side of every collision shown in red. Shared across consumers - * (the bin/bindings-html CLI, documentation generators) via jsDelivr, so the - * rendering lives in one place. Without this script the
 stays visible as
- * a plain-text fallback.
- */
-(function () {
-  function run() {
-    var src = document.getElementById('src');
-    var view = document.getElementById('view');
-    if (!src || !view) { return; }
-    var text = src.textContent;
-
-    var srcmap = [];
-    var mapEl = document.getElementById('srcmap');
-    if (mapEl) { try { srcmap = JSON.parse(mapEl.textContent) || []; } catch (e) { srcmap = []; } }
-
-    function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
-
-    // esc for a double-quoted attribute value (esc leaves quotes untouched)
-    function attr(s) { return esc(s).replace(/"/g, '"'); }
-
-    function resolve(fqcn) {
-      // Exact-class overrides (x=1) first — used when two packages share a PSR-4
-      // prefix (e.g. bear/package vs bear/aura-router-module both claim BEAR\Package\).
-      for (var i = 0; i < srcmap.length; i++) {
-        var ex = srcmap[i];
-        if (ex.x === 1 && ex.p === fqcn && ex.path && /^https?:\/\//.test(ex.u)) {
-          return { gh: ex.u + (ex.u.indexOf('github.com') !== -1 ? '/blob/' + ex.r + '/' + ex.path : ''), local: 'vendor/' + ex.n + '/' + ex.path };
-        }
-      }
-      // Longest PSR-4 prefix wins; equal length keeps the first entry.
-      var best = null;
-      for (var j = 0; j < srcmap.length; j++) {
-        var e = srcmap[j];
-        if (e.x === 1) { continue; }
-        if (fqcn.indexOf(e.p) === 0 && (!best || e.p.length > best.p.length)) { best = e; }
-      }
-      // only link http(s) repositories, so a hostile source URL can't become a javascript: href
-      if (!best || !/^https?:\/\//.test(best.u)) { return null; }
-      var file = (best.d ? best.d + '/' : '') + fqcn.slice(best.p.length).replace(/\\/g, '/') + '.php';
-      var gh = best.u.indexOf('github.com') !== -1 ? best.u + '/blob/' + best.r + '/' + file : best.u;
-      return { gh: gh, local: 'vendor/' + best.n + '/' + file };
-    }
-
-    // link a class to its source (href for humans, data-src for agents) showing
-    // the given already-escaped text; returns the text unchanged if unresolved
-    function link(fqcn, text) {
-      var r = resolve(fqcn);
-      return r ? '' + text + '' : text;
-    }
-
-    // esc, but wrap any resolvable class name in a source link
-    function escLink(s) {
-      var out = '', last = 0, re = /[A-Za-z_][A-Za-z0-9_]*(?:\\[A-Za-z0-9_]+)+/g, m;
-      while ((m = re.exec(s)) !== null) {
-        out += esc(s.slice(last, m.index)) + link(m[0], esc(m[0]));
-        last = m.index + m[0].length;
-      }
-      return out + esc(s.slice(last));
-    }
-
-    function section(name) {
-      var m = text.match(new RegExp('(?:^|\\n)## ' + name + '\\n([\\s\\S]*?)(?:\\n## |$)'));
-      if (!m) { return []; }
-      return m[1].split('\n').filter(function (l) { return l.trim() !== ''; });
-    }
-
-    var summary = '';
-    text.split('\n').slice(0, 8).forEach(function (l) {
-      if (l.indexOf('bindings ·') !== -1 && summary === '') { summary = l.trim(); }
-    });
-    var cm = summary.match(/(\d+) bindings . (\d+) modules . (\d+) replaced . (\d+) discarded/);
-    var counts = cm ? [cm[1], cm[2], cm[3], cm[4]] : ['?', '?', '?', '?'];
-
-    function renderEvent(line) {
-      var m = line.match(/^(bind|replace|keep|move)\s+(.*)$/);
-      if (!m) { return '
' + esc(line) + '
'; } - var typ = m[1], body = m[2], extra = ''; - var dm = body.match(/\s\((discarded|replaced) (.*)\)\s*$/); - if (dm) { - var kind = dm[1], inner = dm[2]; - body = body.slice(0, dm.index); - var im = inner.match(/^(.*) @([^ ]+)$/); - extra = im - ? '' + kind + ' ' + escLink(im[1]) - + ' @' + escLink(im[2]) + '' - : '' + escLink(inner) + ''; - } - var bm = body.match(/^(.*?) => (.*) @([^ ]+)$/); - var core = bm - ? '' + escLink(bm[1]) + ' => ' - + '' + escLink(bm[2]) + ' @' - + '' + escLink(bm[3]) + '' - : '' + esc(body) + ''; - return '
' + typ + '' - + core + extra + '
'; - } - - function renderBinding(line) { - var m = line.match(/^(.*?) => (.*)$/); - if (!m) { return '
' + esc(line) + '
'; } - var target = m[2], aop = ''; - // peel off a trailing " (aop) +method(Interceptor) ..." and group methods by interceptor - var am = target.match(/ \(aop\)((?: \+\w+\([^)]*\))+)\s*$/); - if (am) { - target = target.slice(0, am.index); - var by = {}, order = [], pair, re = /\+(\w+)\(([^)]*)\)/g; - while ((pair = re.exec(am[1])) !== null) { - if (!by[pair[2]]) { by[pair[2]] = []; order.push(pair[2]); } - by[pair[2]].push(pair[1]); - } - aop = '
aop ' + order.map(function (i) { - return '' + link(i, esc(i.split('\\').pop())) + ' ' + esc(by[i].join(', ')) + ''; - }).join(' · ') + '
'; - } - // collapse noise: a compiled null object, or a class bound to itself (untargeted) - var tgt; - if (/[0-9a-f]{8}Null$/.test(target)) { - tgt = '(null object)'; - } else if (target.indexOf('(dependency) ') === 0 && m[1] === target.slice(13) + '-') { - tgt = '(untargeted)'; - } else { - tgt = escLink(target); - } - return '
' + escLink(m[1]) + '' - + ' => ' + tgt + '
' + aop + '
'; - } - - function renderModule(line) { - var m = line.match(/^- (.*) \((\d+)\)$/); - return m - ? '
' + escLink(m[1]) + '' + m[2] + '
' - : '
' + esc(line) + '
'; - } - - var prov = section('Provenance').map(renderEvent).join(''); - var mods = section('Modules').map(renderModule).join(''); - var binds = section('Bindings').map(renderBinding).join(''); - - view.innerHTML = - '
' - + '
' + counts[0] + '
bindings
' - + '
' + counts[1] + '
modules
' - + '
' + counts[2] + '
replaced
' - + '
' + counts[3] + '
discarded
' - + '
' - + '

Provenance — who bound what, who was discarded

' - + '
filter' - + 'bind' - + 'replace' - + 'keep' - + 'move
' - + '
' + prov + '
' - + '

Modules — composing modules, by binding count

' - + '
' + mods + '
' - + '

Bindings — resolved state

' - + '
' + binds + '
'; - - var provEl = document.getElementById('prov'); - view.querySelectorAll('.chip').forEach(function (c) { - function toggle() { - var off = c.getAttribute('data-off') === '1'; - c.setAttribute('data-off', off ? '0' : '1'); - provEl.classList.toggle('hide-' + c.getAttribute('data-t'), !off); - } - - c.addEventListener('click', toggle); - c.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } - }); - }); - - // reveal the decorated view, retire the raw data island - src.style.display = 'none'; - view.style.display = 'block'; - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', run); - } else { - run(); - } -})(); diff --git a/phpstan.neon b/phpstan.neon index a969ca106..281d6e3d6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,9 +3,7 @@ parameters: level: max paths: - src/di - - src-bindings - tests/di - - tests-bindings - tests/type excludePaths: - tests/tmp/* @@ -17,5 +15,4 @@ parameters: ignoreErrors: - '#contains generic class ReflectionAttribute but does not specify its types:#' - '#generic class ReflectionAttribute but does not specify its types:#' - - '#Offset 1 might not exist on array\{\}\|array\{non-falsy-string, string\}#' - '#will always evaluate to true#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 65d808d4f..32321c12c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,13 +8,11 @@ src/di - src-bindings tests/di/ - tests-bindings tests-php8 diff --git a/psalm.xml b/psalm.xml index 47611cfb6..4e1ca574e 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,7 +8,6 @@ > - diff --git a/src-bindings/Bindings.php b/src-bindings/Bindings.php deleted file mode 100644 index 57b2f14fe..000000000 --- a/src-bindings/Bindings.php +++ /dev/null @@ -1,38 +0,0 @@ -markdown = (new BindingsMarkdown())->render($container); - } - - public function toMarkdown(): string - { - return $this->getMarkdown(); - } - - public function toHtml(string $composerLock = '', string $message = '', string $vendorDir = ''): string - { - return (new BindingsHtml())->page($this->getMarkdown(), $composerLock, $message, $vendorDir); - } - - private function getMarkdown(): string - { - if ($this->markdown === null) { - throw new BindingsNotCollected(); - } - - return $this->markdown; - } -} diff --git a/src-bindings/BindingsHtml.php b/src-bindings/BindingsHtml.php deleted file mode 100644 index 95240a249..000000000 --- a/src-bindings/BindingsHtml.php +++ /dev/null @@ -1,308 +0,0 @@ - data island; a small viewer - * script decorates it on load — colour-coded events, a type filter, and the - * discarded side of every collision shown in red. The stylesheet and script - * are shared assets served from a CDN, so every consumer — the bin/bindings-html - * CLI, a documentation generator, a hand-written page — reuses the same viewer - * instead of embedding its own copy. - * - * Given a composer.lock the viewer also links each class to its source: the - * lock's per-package source URL + reference resolve to a GitHub blob URL (for - * humans), and the package name resolves to the local vendor/ path (carried in - * data-src, so an agent working in the project reads the file directly instead - * of fetching over the network). Resolution is pure string work — no reflection, - * no vendor/ access is required for the basic map; when $vendorDir is given, - * shared PSR-4 prefixes (e.g. bear/package and bear/aura-router-module both - * claim BEAR\Package\) are disambiguated by checking which package actually - * contains each class file mentioned in the markdown. - * - * {@see fragment()} is the reusable core (the data island, the mount point, and - * the optional source map) for embedding in a page that owns its own ; - * {@see page()} wraps it in a standalone document that links the CDN assets. - * Without JavaScript the
 shows the plain log.
- *
- * @psalm-type ComposerPackage = array{
- *     name?: string,
- *     source?: array{url?: string, reference?: string},
- *     autoload?: array{psr-4?: array>}
- * }
- * @psalm-type SourceMapEntry = array{
- *     p: string,
- *     u: string,
- *     r: string,
- *     n: string,
- *     d: string,
- *     path?: string,
- *     x?: 1
- * }
- */
-final class BindingsHtml
-{
-    private const VERSION = '2.x';
-    public const CSS_URL = 'https://cdn.jsdelivr.net/gh/ray-di/Ray.Di@' . self::VERSION . '/docs/bindings/bindings.css';
-    public const JS_URL = 'https://cdn.jsdelivr.net/gh/ray-di/Ray.Di@' . self::VERSION . '/docs/bindings/bindings.js';
-
-    /**
-     * The reusable core: the markdown as a 
 data island, the mount point
-     * the viewer decorates, and — when a composer.lock is given — the source
-     * map it links classes with. Embed this in a page that references
-     * {@see CSS_URL} and {@see JS_URL}.
-     *
-     * @param non-empty-string|'' $vendorDir Absolute path to composer vendor/ for prefix disambiguation
-     */
-    public function fragment(string $bindingsMarkdown, string $composerLock = '', string $vendorDir = ''): string
-    {
-        $md = htmlspecialchars($bindingsMarkdown, ENT_QUOTES, 'UTF-8');
-
-        return '
' . $md . '
' . "\n" . '
' - . $this->sourceMap($bindingsMarkdown, $composerLock, $vendorDir); - } - - /** - * A standalone page around {@see fragment()}, linking the shared viewer - * assets from the CDN — used by bin/bindings-html. - * - * The optional $message renders as a subtitle, e.g. the context the - * bindings were composed in ("prod-app"). - * - * @param non-empty-string|'' $vendorDir Absolute path to composer vendor/ for prefix disambiguation - */ - public function page(string $bindingsMarkdown, string $composerLock = '', string $message = '', string $vendorDir = ''): string - { - $fragment = $this->fragment($bindingsMarkdown, $composerLock, $vendorDir); - $sub = $message === '' - ? '' - : "\n
" . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '
'; - $css = self::CSS_URL; - $js = self::JS_URL; - - return << - - - - - Ray.Di bindings - - - -
-
-

Ray.Di bindings

$sub -
- $fragment -
- - - - - HTML; - } - - /** - * A '; - } - - /** - * When two packages share a PSR-4 prefix, add exact-class entries (longer "p") - * with an explicit path for each FQCN in the markdown that exists on disk. - * - * @param list $map - * - * @return list - */ - private function disambiguateSharedPrefixes(array $map, string $markdown, string $vendorDir): array - { - $byPrefix = []; - foreach ($map as $entry) { - $byPrefix[$entry['p']][] = $entry; - } - - $ambiguous = []; - foreach ($byPrefix as $prefix => $entries) { - if (count($entries) > 1) { - $ambiguous[$prefix] = $entries; - } - } - - if ($ambiguous === []) { - return $map; - } - - preg_match_all('/[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z0-9_]+)+/', $markdown, $matches); - $fqcns = array_unique($matches[0]); - foreach ($fqcns as $fqcn) { - $bestLen = -1; - /** @var list $candidates */ - $candidates = []; - foreach ($map as $entry) { - // Ignore exact-class overrides already added (path/x) — a shorter class - // name must not act as a prefix of a longer one (AuraRouter vs Module). - if (isset($entry['path']) || isset($entry['x'])) { - continue; - } - - if (! str_starts_with($fqcn, $entry['p'])) { - continue; - } - - $len = strlen($entry['p']); - if ($len > $bestLen) { - $bestLen = $len; - $candidates = [$entry]; - continue; - } - - if ($len === $bestLen) { - $candidates[] = $entry; - } - } - - if (count($candidates) < 2 || ! isset($ambiguous[$candidates[0]['p']])) { - continue; - } - - $rel = str_replace('\\', '/', substr($fqcn, $bestLen)) . '.php'; - foreach ($candidates as $candidate) { - $path = $candidate['d'] === '' ? $rel : $candidate['d'] . '/' . $rel; - $full = $vendorDir . '/' . $candidate['n'] . '/' . $path; - if (! is_file($full)) { - continue; - } - - // Exact class match (x=1) so shorter class names are not prefixes of longer ones - // (e.g. AuraRouter must not steal AuraRouterModule). - $map[] = [ - 'p' => $fqcn, - 'u' => $candidate['u'], - 'r' => $candidate['r'], - 'n' => $candidate['n'], - 'd' => $candidate['d'], - 'path' => $path, - 'x' => 1, - ]; - break; - } - } - - return $map; - } - - /** Normalise a composer source URL to its web repository URL. */ - private function repositoryUrl(string $url): string - { - // git@github.com:owner/repo.git -> https://github.com/owner/repo - if (str_starts_with($url, 'git@')) { - $url = 'https://' . str_replace(':', '/', substr($url, 4)); - } - - return (string) preg_replace('#\.git$#', '', $url); - } -} diff --git a/src-bindings/BindingsMarkdown.php b/src-bindings/BindingsMarkdown.php deleted file mode 100644 index a00ed36ae..000000000 --- a/src-bindings/BindingsMarkdown.php +++ /dev/null @@ -1,168 +0,0 @@ -signature($container); - if ($signature !== null && $this->isCached($markdownFile, $signatureFile, $signature)) { - return; - } - - $written = file_put_contents($markdownFile, $this->render($container)); - if ($written !== false && $signature !== null) { - file_put_contents($signatureFile, $signature); - } - } catch (Throwable) { // @codeCoverageIgnoreStart - // best-effort: never break the caller for a diagnostics file - } // @codeCoverageIgnoreEnd - } - - /** - * Return a signature of inputs that affect resolved bindings - * - * Composition provenance is intentionally excluded: if the final bindings - * and pointcuts are unchanged, the existing diagnostics file is reused. - */ - private function signature(Container $container): ?string - { - try { - $bindings = []; - foreach ($container->getContainer() as $index => $dependency) { - $bindings[] = $index . "\0" . (string) $dependency; - } - - sort($bindings); - - return hash( - 'sha256', - serialize([self::SIGNATURE_VERSION, $bindings, $this->pointcutSignature($container)]), - ); - } catch (Throwable) { // @codeCoverageIgnoreStart - return null; - } // @codeCoverageIgnoreEnd - } - - /** @return list}> */ - private function pointcutSignature(Container $container): array - { - $signature = []; - foreach ($container->getPointcuts() as $pointcut) { - $interceptors = []; - foreach ($pointcut->interceptors as $interceptor) { - $interceptors[] = is_object($interceptor) ? $interceptor::class : $interceptor; - } - - $signature[] = [ - $pointcut::class, - $this->matcherSignature($pointcut->classMatcher), - $this->matcherSignature($pointcut->methodMatcher), - $interceptors, - ]; - } - - return $signature; - } - - private function matcherSignature(AbstractMatcher $matcher): string - { - if ($matcher instanceof BuiltinMatcher) { - return serialize($matcher); - } - - return serialize([$matcher::class, $matcher->getArguments()]); - } - - private function isCached(string $markdownFile, string $signatureFile, string $signature): bool - { - if (! is_file($markdownFile) || ! is_file($signatureFile)) { - return false; - } - - $cachedSignature = file_get_contents($signatureFile); - if (! is_string($cachedSignature)) { - return false; // @codeCoverageIgnore - } - - return hash_equals($signature, trim($cachedSignature)); - } - - public function render(Container $container): string - { - $log = $container->log; - $replaced = 0; - $discarded = 0; - foreach ($log->getEvents() as $event) { - if ($event->type === BindingEvent::REPLACE) { - $replaced++; - } - - if ($event->type === BindingEvent::KEEP) { - $discarded++; - } - } - - /** @var array $moduleCounts */ - $moduleCounts = []; - foreach ($log->getSources() as $module) { - $moduleCounts[$module] = ($moduleCounts[$module] ?? 0) + 1; - } - - ksort($moduleCounts); - $moduleLines = []; - foreach ($moduleCounts as $module => $count) { - $moduleLines[] = sprintf('- %s (%d)', $module, $count); - } - - return sprintf( - "# Ray.Di bindings\n\n%d bindings · %d modules · %d replaced · %d discarded\n\n" - . "## Bindings\n\n%s\n\n## Modules\n\n%s\n\n## Provenance\n\n%s\n", - count($container->getContainer()), - count($moduleCounts), - $replaced, - $discarded, - (new ModuleString())($container, $container->getPointcuts()), - implode("\n", $moduleLines), - $log - ); - } -} diff --git a/src-bindings/Exception/BindingsNotCollected.php b/src-bindings/Exception/BindingsNotCollected.php deleted file mode 100644 index 0438ba854..000000000 --- a/src-bindings/Exception/BindingsNotCollected.php +++ /dev/null @@ -1,12 +0,0 @@ -renderer = new BindingsMarkdownRenderer(); - } - - public function __invoke(Container $container, string $classDir): void - { - ($this->renderer)($container, $classDir); - } - - public function render(Container $container): string - { - return $this->renderer->render($container); - } -} diff --git a/src-bindings/ModuleVisitorInterface.php b/src/di/ModuleVisitorInterface.php similarity index 78% rename from src-bindings/ModuleVisitorInterface.php rename to src/di/ModuleVisitorInterface.php index 68e1e7f62..656207e7e 100644 --- a/src-bindings/ModuleVisitorInterface.php +++ b/src/di/ModuleVisitorInterface.php @@ -2,9 +2,7 @@ declare(strict_types=1); -namespace Ray\Bindings; - -use Ray\Di\Container; +namespace Ray\Di; /** Visits the composed container of a module. */ interface ModuleVisitorInterface diff --git a/tests-bindings/BindingsHtmlTest.php b/tests-bindings/BindingsHtmlTest.php deleted file mode 100644 index 3c27f1966..000000000 --- a/tests-bindings/BindingsHtmlTest.php +++ /dev/null @@ -1,412 +0,0 @@ - data - * island and references a shared viewer from the CDN; the class is unit-tested - * directly and the CLI is exercised as a subprocess so argument handling, - * stdin, and exit codes are covered too. - */ -class BindingsHtmlTest extends TestCase -{ - /** A minimal bindings.md whose namespaces match {@see COMPOSER_LOCK}. */ - private const MARKDOWN = <<<'MD' - # Ray.Di bindings - - 3 bindings · 2 modules · 0 replaced · 0 discarded - - ## Bindings - - Ray\Di\FakeEngineInterface- => (dependency) Ray\Di\FakeEngine - Ray\Di\FakeRobotInterface- => (dependency) Ray\Di\FakeRobot - Acme\Shop\CartInterface- => (dependency) Acme\Shop\Cart - - ## Modules - - - Ray\Di\FakeModule (2) - - Acme\Shop\ShopModule (1) - - ## Provenance - - bind Ray\Di\FakeEngineInterface- => (dependency) Ray\Di\FakeEngine @Ray\Di\FakeModule - bind Ray\Di\FakeRobotInterface- => (dependency) Ray\Di\FakeRobot @Ray\Di\FakeModule - bind Acme\Shop\CartInterface- => (dependency) Acme\Shop\Cart @Acme\Shop\ShopModule - MD; - - private string $bin; - private string $dir; - private string $md; - - protected function setUp(): void - { - $this->bin = dirname(__DIR__) . '/bin/bindings-html'; - $this->dir = sys_get_temp_dir() . '/ray-bindings-html-' . uniqid('', true); - if (! mkdir($this->dir) && ! is_dir($this->dir)) { - throw new RuntimeException('Cannot create ' . $this->dir); - } - - $this->md = $this->dir . '/bindings.md'; - file_put_contents($this->md, self::MARKDOWN); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->dir); - } - - public function testFragmentEmbedsTheMarkdownVerbatim(): void - { - $fragment = (new BindingsHtml())->fragment(self::MARKDOWN); - - $this->assertSame(1, preg_match('#
(.*)
#s', $fragment, $matches)); - $this->assertSame(self::MARKDOWN, html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8')); - $this->assertStringContainsString('
', $fragment); - } - - public function testPageLinksTheSharedCdnAssets(): void - { - $page = (new BindingsHtml())->page(self::MARKDOWN); - - $this->assertStringContainsString('', $page); - $this->assertStringContainsString('