diffninja 0.3.0 → 0.3.2
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/README.md +6 -3
- package/dist/review/call-flow-html.d.ts +3 -1
- package/dist/review/call-flow-html.js +12 -11
- package/dist/review/connected-html.js +14 -2
- package/dist/review/html.d.ts +3 -2
- package/dist/review/html.js +8 -15
- package/dist/review/mcp.js +6 -8
- package/dist/review/process-html.d.ts +1 -5
- package/dist/review/process-html.js +3 -12
- package/dist/review/report-pages.js +1 -1
- package/dist/review/setup.d.ts +24 -6
- package/dist/review/setup.js +98 -30
- package/dist/review/types.d.ts +4 -0
- package/dist/review/types.js +2 -1
- package/dist/review/version.d.ts +14 -0
- package/dist/review/version.js +44 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,9 +48,12 @@ npx -y diffninja@latest setup
|
|
|
48
48
|
It installs diffninja and adds it to every agent CLI it finds on your machine.
|
|
49
49
|
Then restart your agent CLI.
|
|
50
50
|
|
|
51
|
-
To update later, run the same command again
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
To update later, run the same command again: setup brings an older global
|
|
52
|
+
install up to its own version, then restart your agent CLI. (Setup from
|
|
53
|
+
0.3.0 or earlier does not update an existing install; if yours reports an
|
|
54
|
+
older version, run `npm install -g diffninja@latest` once.) To remove
|
|
55
|
+
diffninja from your agent CLIs, run `npx -y diffninja@latest setup --uninstall`.
|
|
56
|
+
Other options: `npx -y diffninja@latest setup --help`.
|
|
54
57
|
|
|
55
58
|
## How to use it
|
|
56
59
|
|
|
@@ -21,7 +21,9 @@ import type { ReviewReport } from "./types.js";
|
|
|
21
21
|
* reaches the reviewer as a glyph plus color for a call that was added,
|
|
22
22
|
* removed, or contains a change, and plain dimmed text for an unchanged call.
|
|
23
23
|
*/
|
|
24
|
-
export declare function renderCallFlows(report: ReviewReport
|
|
24
|
+
export declare function renderCallFlows(report: ReviewReport, options?: {
|
|
25
|
+
readonly treeOnly?: boolean;
|
|
26
|
+
}): string;
|
|
25
27
|
export declare const CALL_FLOW_STYLES = "\n.cf {\n display: flex; flex-direction: column; gap: 12px; margin-top: 18px;\n --cf-add: #1a7f37; --cf-add-soft: #dafbe1;\n --cf-del: #cf222e; --cf-del-soft: #ffebe9;\n --cf-chg: #9a6700; --cf-chg-soft: #fff8c5;\n --cf-quiet: var(--ink-soft);\n}\n@media (prefers-color-scheme: dark) {\n .cf {\n --cf-add: #3fb950; --cf-add-soft: rgba(46, 160, 67, 0.15);\n --cf-del: #f85149; --cf-del-soft: rgba(248, 81, 73, 0.12);\n --cf-chg: #d29922; --cf-chg-soft: rgba(187, 128, 9, 0.15);\n }\n}\n/* Mode sections, folded files and the breadcrumb are toggled with the hidden\n attribute, so the module carries that rule itself instead of relying on the page. */\n.cf [hidden] { display: none !important; }\n.cf-head { display: flex; flex-direction: column; gap: 4px; }\n.cf-prov { font-size: 12px; color: var(--ink-soft); }\n.cf-note { font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-absence { padding: 16px 0; }\n.cf-bounds {\n margin: 10px 0 0;\n padding: 2px 0 2px 10px;\n border-left: 2px solid var(--cf-chg);\n color: var(--ink-soft);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.cf-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }\n.cf-ready .cf-controls { position: sticky; top: var(--toolbar-height, 0px); z-index: 4; background: var(--bg); padding: 8px 0; }\n.cf, .cf-files, .cf-file-body, .cf-mode { min-width: 0; }\n.cf-modes {\n display: inline-flex;\n align-items: stretch;\n border: 1px solid var(--line);\n border-radius: 6px;\n overflow: hidden;\n background: var(--panel);\n}\n.cf-mode-link {\n display: inline-flex;\n align-items: center;\n padding: 5px 14px;\n font-size: 12px;\n color: var(--ink-soft);\n text-decoration: none;\n border-right: 1px solid var(--line);\n}\n.cf-mode-link:last-child { border-right: 0; }\n.cf-mode-link:hover, .cf-mode-link:focus-visible { color: var(--ink); background: var(--sunken); }\n.js .cf-mode-link[aria-current] { color: var(--ink); background: var(--sunken); font-weight: 600; }\n.cf-jump ul {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n max-height: 22vh;\n overflow-y: auto;\n}\n.cf-jump-link {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n max-width: 100%;\n min-width: 0;\n padding: 4px 10px 4px 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--panel);\n color: var(--ink);\n font-size: 12px;\n text-decoration: none;\n}\n.cf-jump-link:hover, .cf-jump-link:focus-visible { border-color: var(--cursor); }\n.cf-jump-path { min-width: 0; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.cf-jump-count { color: var(--ink-soft); white-space: nowrap; }\n.cf-crumbs {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n gap: 6px;\n font-size: 12.5px;\n padding-bottom: 3px;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n}\n.cf-crumb {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 34ch;\n font: inherit;\n font-size: 12px;\n padding: 3px 10px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: var(--panel);\n color: var(--ink);\n cursor: pointer;\n white-space: nowrap;\n}\n.cf-crumb[aria-current] { border-color: var(--cursor); font-weight: 600; }\n.cf-crumb-label { overflow: hidden; text-overflow: ellipsis; }\n.cf-crumb-file {\n flex: 0 0 auto;\n max-width: 22ch;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 11px;\n color: var(--ink-soft);\n}\n.cf-sep { flex: 0 0 auto; color: var(--ink-soft); }\n.cf-files { display: flex; flex-direction: column; gap: 12px; }\n.cf-file {\n background: var(--panel);\n border: 1px solid var(--line);\n border-left: 3px solid var(--line-strong);\n border-radius: 6px;\n overflow: hidden;\n}\n.cf-file { --cf-color: var(--ink-soft); --cf-fill: var(--sunken); }\n.cf-file-attention { border-left-color: var(--alarm); --cf-color: var(--alarm); --cf-fill: var(--alarm-bg); }\n.cf-file-uncertain { border-left-color: var(--warn); --cf-color: var(--warn); --cf-fill: var(--warn-bg); }\n.cf-file-low { border-left-color: var(--line-strong); }\n.cf-file-passed { border-left-color: var(--teal); --cf-color: var(--teal); --cf-fill: var(--teal-bg); }\n.cf-file-head {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 10px;\n padding: 8px 12px;\n background: var(--sunken);\n cursor: pointer;\n list-style: none;\n}\n.cf-file-head::-webkit-details-marker { display: none; }\n.cf-file-head::after {\n content: \"\u25B8\";\n margin-left: auto;\n font-size: 11px;\n color: var(--ink-soft);\n transition: transform 0.15s ease;\n}\n.cf-file[open] > .cf-file-head::after { transform: rotate(90deg); }\n.cf-file-head:focus-visible { outline: 2px solid var(--cursor); outline-offset: -2px; }\n.cf-file-path { flex: 1 1 260px; min-width: 0; font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }\n.cf-file-count { font-size: 12px; color: var(--ink-soft); white-space: nowrap; }\n.cf-diff-link { font-size: 12px; white-space: nowrap; }\n.cf-file-body { border-top: 1px solid var(--line); padding: 0 12px 12px; }\n.cf-mode { padding-top: 12px; }\n.cf-noscript { display: block; }\n.cf-noscript .cf-note { margin: 6px 0 0; }\n.cf-mode + .cf-mode { margin-top: 12px; border-top: 1px dashed var(--line); }\n.cf-mode-head {\n margin: 0 0 8px;\n font-size: 11.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-soft);\n}\n.cf-ready .cf-mode-head { display: none; }\n.cf-ready .cf-mode + .cf-mode { margin-top: 0; border-top: 0; }\n.cf-tree, .cf-children { list-style: none; margin: 0; padding: 0; }\n.cf-children { margin-left: 15px; padding-left: 10px; border-left: 1px dashed var(--line); }\n.cf-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0 8px; padding: 2px 6px; border-radius: 6px; min-width: 0; }\n.cf-row:hover { background: var(--sunken); }\n.cf-fold > .cf-row { cursor: pointer; list-style: none; }\n.cf-fold > .cf-row::-webkit-details-marker { display: none; }\n.cf-fold > .cf-row::before { content: \"\u25B8\"; font-size: 10px; color: var(--ink-soft); transition: transform 0.15s ease; }\n.cf-fold[open] > .cf-row::before { transform: rotate(90deg); }\n.cf-badge, .cf-chip-badge {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n min-width: 16px;\n height: 16px;\n border: 1px solid transparent;\n border-radius: 4px;\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 700;\n}\n.cf-badge, .cf-chip-badge { color: var(--cf-node-color); background: var(--cf-node-fill); }\n.cf-label {\n font: 400 12.5px/1.5 var(--mono);\n padding: 0 2px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: none;\n text-align: left;\n /* Capped so a long call never pushes its row onto a second line; the\n location takes what is left and truncates first. */\n flex: 0 1 auto;\n min-width: 0;\n max-width: 62%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.js .cf-label { cursor: pointer; }\n.js .cf-label:hover, .cf-label:focus-visible { text-decoration: underline; }\n.cf-label:focus-visible { outline: 2px solid var(--cursor); outline-offset: 1px; }\n.cf-loc { flex: 1 1 0; min-width: 0; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-loc-none { font-style: italic; }\n.cf-node, .cf-gnode, .cf-chip { --cf-node-color: var(--cf-chg); --cf-node-fill: var(--cf-chg-soft); --cf-node-weight: 600; --cf-node-decoration: none; }\n.cf-st-added { --cf-node-color: var(--cf-add); --cf-node-fill: var(--cf-add-soft); }\n.cf-st-removed { --cf-node-color: var(--cf-del); --cf-node-fill: var(--cf-del-soft); --cf-node-decoration: line-through; }\n.cf-node.cf-st-same, .cf-gnode.cf-st-same, .cf-chip.cf-st-same { --cf-node-color: var(--ink-soft); --cf-node-fill: var(--sunken); --cf-node-weight: 400; }\n.cf-label { color: var(--cf-node-color); font-weight: var(--cf-node-weight); text-decoration: var(--cf-node-decoration); }\n.cf-node.cf-ancestor > .cf-fold > .cf-row { display: none; }\n.cf-node.cf-ancestor > .cf-fold > .cf-children { margin: 0; padding: 0; border: 0; }\n.cf-svg-wrap {\n max-width: 100%;\n overflow: auto;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--sunken);\n overscroll-behavior: contain;\n}\n.cf-svg { display: block; max-width: none; }\n.cf-ready .cf-svg-wrap { height: clamp(220px, 42vh, 400px); overflow: hidden; touch-action: none; cursor: grab; }\n.cf-diagram-btn, .cf-diagram-close {\n display: inline-flex; align-items: center; gap: 6px; height: 29px; padding: 0 12px;\n border: 1px solid var(--line); border-radius: 6px; background: var(--panel);\n font: 500 12px/1 var(--sans, inherit); color: var(--ink); cursor: pointer;\n}\n.cf-diagram-btn { color: var(--cursor); border-color: var(--cursor); }\n.cf-diagram-btn::after { content: \"\\2197\"; font-size: 12px; }\n.cf-diagram-btn:hover, .cf-diagram-close:hover { background: var(--sunken); }\n.cf-diagram-close { margin-left: auto; }\n.cf-diagram-hint { margin: 0 0 0 auto; font-size: 12px; color: var(--ink-soft); }\n.cf-diagram-hint + .cf-diagram-close { margin-left: 12px; }\n.cf.cf-diagram-open .cf-head { display: none; }\n/* The diagram view fills the browser window, and the graph gets nearly all of its height. */\n.cf.cf-diagram-open {\n position: fixed; inset: 0; z-index: 50; margin: 0; padding: 12px 20px 20px;\n background: var(--bg); overflow: auto; overscroll-behavior: contain;\n}\n.cf.cf-diagram-open .cf-controls { position: sticky; top: -12px; z-index: 5; background: var(--bg); padding: 10px 0; }\n.cf.cf-diagram-open .cf-modes, .cf.cf-diagram-open .cf-diagram-btn { display: none !important; }\n.cf.cf-diagram-open { padding-left: 292px; }\n.cf.cf-diagram-open .cf-svg-wrap { height: calc(100vh - 150px); min-height: 320px; }\n/* One canvas at a time: the entry point picked in the list, in its own file. */\n.cf.cf-diagram-open .cf-jump, .cf.cf-diagram-open .cf-bounds, .cf.cf-diagram-open .cf-omitted { display: none !important; }\n.cf.cf-diagram-open .cf-file:not(.cf-file-current) { display: none; }\n.cf.cf-diagram-open .cf-graph-frame:not(.cf-frame-current) { display: none; }\n.cf.cf-diagram-open .cf-file-body { padding-bottom: 0; }\n.cf-diagram-nav {\n position: fixed; top: 0; bottom: 0; left: 0; width: 276px; overflow-y: auto; z-index: 6;\n padding: 16px 12px; background: var(--sunken); border-right: 1px solid var(--line);\n}\n.cf-diagram-nav-head { margin: 0 0 10px; font-size: 13px; font-weight: 600; color: var(--ink); }\n.cf-diagram-nav-file {\n margin: 14px 0 4px; font: 600 12px var(--mono); color: var(--ink-soft);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.cf-diagram-nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }\n.cf-diagram-nav button {\n display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%;\n padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--ink); text-align: left; cursor: pointer;\n}\n.cf-diagram-nav button:hover { background: var(--panel); }\n.cf-diagram-nav button[aria-current] { background: var(--panel); box-shadow: inset 3px 0 0 var(--cursor); }\n.cf-diagram-nav-name { max-width: 100%; font: 600 12px/1.35 var(--mono); overflow-wrap: anywhere; }\n.cf-diagram-nav-count { max-width: 100%; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n@media (max-width: 760px) {\n .cf.cf-diagram-open { padding-left: 12px; padding-top: 12px; }\n .cf-diagram-nav { position: static; width: auto; max-height: 30vh; margin: 0 0 8px; border: 1px solid var(--line); border-radius: 6px; }\n}\n.cf-ready .cf-svg-wrap:focus-visible { outline: 2px solid var(--cursor); outline-offset: 2px; }\n.cf-ready .cf-svg-wrap.cf-dragging { cursor: grabbing; user-select: none; }\n.cf-ready .cf-svg { width: 100%; height: 100%; }\n.cf-graph-frame + .cf-graph-frame { margin-top: 14px; }\n.cf-camera-tools { align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; }\n.cf-camera-tools button { min-height: 28px; padding: 0 10px; font-size: 12px; }\n.cf-scale { min-width: 4ch; text-align: center; font: 11px var(--mono); color: var(--ink-soft); }\n.cf-omitted { display: none; }\n.cf-ready .cf-omitted { display: block; }\n.cf-node.cf-selected > .cf-row, .cf-node.cf-selected > .cf-fold > .cf-row,\n.cf-chip.cf-selected { outline: 2px solid var(--cursor); outline-offset: 1px; border-radius: 5px; }\n.cf-edge { fill: none; stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-gnode rect { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-gnode:focus-visible { outline: none; }\n.cf-gzoom circle { fill: var(--panel); stroke: var(--line-strong); }\n.cf-gzoom text { fill: var(--ink); font: 14px var(--mono); }\n.cf-gzoom:hover circle, .cf-gzoom:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }\n.cf-gzoom:focus-visible { outline: none; }\n.cf-glabel { font-family: var(--mono); font-size: 12px; fill: var(--ink); }\n.cf-gloc { font-family: var(--mono); font-size: 10px; fill: var(--ink-soft); }\n.cf-gnode.cf-st-same rect { stroke: var(--line); fill: transparent; }\n.cf-gnode.cf-st-same .cf-glabel { fill: var(--ink-soft); }\n.cf-gnode:not(.cf-st-same) rect { stroke: var(--cf-node-color); fill: var(--panel); }\n.cf-gnode:not(.cf-st-same) .cf-glabel { fill: var(--cf-node-color); font-weight: 600; }\n.cf-gnode:hover rect, .cf-gnode:focus-visible rect { stroke: var(--cursor); stroke-width: 2.5; }\n.cf-gnode.cf-selected rect { stroke: var(--cursor); stroke-width: 3; }\n.cf-paths { display: flex; flex-direction: column; gap: 6px; list-style: none; margin: 0; padding: 0; }\n.cf-path {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px 6px;\n padding: 6px 8px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--panel);\n}\n.cf-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n min-width: 0;\n padding: 2px 8px 2px 4px;\n border: 1px solid var(--line);\n border-left: 3px solid var(--cf-node-color);\n border-radius: 6px;\n background: var(--panel);\n color: var(--ink);\n}\n.cf-chip:hover, .cf-chip:focus-within { border-color: var(--cursor); }\n.cf-chip-zoom {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n min-width: 0;\n color: inherit;\n text-decoration: none;\n}\n.cf-chip-label { min-width: 0; max-width: 36ch; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-chip-loc { min-width: 0; max-width: 28ch; font-size: 10.5px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-chip.cf-st-same .cf-chip-label { color: var(--ink-soft); }\n.cf-chip:not(.cf-st-same) .cf-chip-label { color: var(--cf-node-color); font-weight: 600; }\n.cf-chip .cf-desc { max-width: 28ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-arrow { color: var(--ink-soft); font-size: 12px; }\n/* One description line per call, only when the backend attached one. */\n.cf-desc { font-size: 11.5px; color: var(--ink-soft); font-style: italic; overflow-wrap: anywhere; }\n.cf-business { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 10px; }\n.cf-business-note { font-size: 13px; color: var(--ink-soft); }\n.cf-plumbing-toggle { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--ink-soft); cursor: pointer; width: fit-content; }\n.cf:has(.cf-show-plumbing:not(:checked)) .cf-node.cf-plumbing { display: none; }\n.cf-purpose { font-family: var(--sans); font-size: 13.5px; font-weight: 550; color: var(--ink); overflow-wrap: anywhere; }\n.cf-row > .cf-purpose { flex: 1 0 100%; order: -1; padding-left: 26px; }\n.cf-fold > .cf-row > .cf-purpose { padding-left: 16px; }\n.cf-node.cf-explained > .cf-row .cf-label, .cf-node.cf-explained > .cf-fold > .cf-row .cf-label { font-size: 11.5px; }\n.cf-chip .cf-purpose { font-size: 12.5px; max-width: 34ch; }\n.cf-gpurpose { font-family: var(--sans); font-size: 12px; font-weight: 600; fill: var(--ink); }\n.cf-diagram-nav-name.cf-diagram-nav-purpose { font-family: var(--sans); white-space: normal; }\n.cf-row > .cf-desc { flex: 1 0 100%; order: 5; padding-left: 26px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-row > .cf-src-link { order: 4; }\n.cf-depth { align-items: center; gap: 4px; }\n.cf-depth-label { font-size: 12px; color: var(--ink-soft); }\n.cf-depth-btn { padding: 3px 9px; font-size: 12px; }\n.cf-depth-btn[aria-pressed=\"true\"] { border-color: var(--cursor); color: var(--cursor); font-weight: 600; }\n/* Numbered, clickable call order on the graph edges. */\n.cf-edge-num circle { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-edge-num text { font-family: var(--mono); font-size: 10.5px; fill: var(--ink-soft); }\n.cf-edge-num:hover circle, .cf-edge-num:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }\n.cf-edge-num:hover text, .cf-edge-num:focus-visible text { fill: var(--cursor); font-weight: 700; }\n.cf-edge-num:focus-visible { outline: none; }\n.cf-gdesc { font-family: var(--mono); font-size: 10px; font-style: italic; fill: var(--ink-soft); }\n/* Resolved definition of one call: a native disclosure without the script, and\n the body the script clones into its single shared panel. */\n.cf-source { margin: 3px 0 3px 24px; }\n.cf-ready .cf-source { display: none; }\n.cf-src-sum { width: fit-content; cursor: pointer; list-style: none; font-size: 11.5px; color: var(--ink-soft); }\n.cf-src-sum::-webkit-details-marker { display: none; }\n.cf-src-sum::before { content: \"\u25B8\"; margin-right: 6px; font-size: 10px; transition: transform 0.15s ease; display: inline-block; }\n.cf-source[open] > .cf-src-sum::before { transform: rotate(90deg); }\n.cf-src-sum:hover, .cf-src-sum:focus-visible { color: var(--cursor); }\n.cf-src-body { padding: 6px 0 4px; }\n.cf-src-ref { margin: 0 0 5px; font-size: 11.5px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-src-code {\n margin: 0;\n padding: 6px 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--sunken);\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.5;\n overflow-x: auto;\n}\n.cf-src-line { display: block; white-space: pre; }\n.cf-src-no {\n display: inline-block;\n width: 4ch;\n margin-right: 10px;\n text-align: right;\n color: var(--ink-soft);\n user-select: none;\n}\n.cf-src-note { margin: 0; font-size: 11.5px; color: var(--ink-soft); font-style: italic; }\n.cf-src-panel {\n padding: 8px 12px 10px;\n border: 1px solid var(--line-strong);\n border-radius: 6px;\n background: var(--panel);\n}\n.cf-src-panel-sum { width: fit-content; cursor: pointer; font-size: 12.5px; font-weight: 600; }\n.cf-src-panel-body { padding-top: 6px; }\n.cf-src-panel-body { max-height: 40vh; overflow: auto; }\n.cf-src-head-line { margin: 0 0 6px; font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-src-missing { margin: 0; }\n.cf-src-link {\n flex: 0 0 auto;\n font-size: 11px;\n color: var(--ink-soft);\n text-decoration: none;\n white-space: nowrap;\n padding: 0 6px;\n border: 1px solid var(--line);\n border-radius: 999px;\n}\n.cf-src-link:hover { text-decoration: none; }\n.cf-src-link:hover, .cf-src-link:focus-visible { color: var(--cursor); }\n@media (max-width: 680px) {\n .cf-jump ul { max-height: 38vh; }\n .cf-jump-link { width: 100%; }\n .cf-jump-path { max-width: none; flex: 1 1 auto; }\n .cf-file-body { padding: 0 11px 12px; }\n .cf-crumb { max-width: 24ch; }\n .cf-crumb-file { max-width: 12ch; }\n .cf-source { margin-left: 10px; }\n .cf-src-no { width: 3ch; margin-right: 7px; }\n .cf-chip, .cf-chip-zoom { min-width: 0; flex-wrap: wrap; }\n .cf-chip-loc { overflow-wrap: anywhere; }\n}\n";
|
|
26
28
|
/**
|
|
27
29
|
* Progressive enhancement for the rendered call flow. Nothing here is
|
|
@@ -24,7 +24,8 @@ import { nodeFunctionId, purposesOf, wrapPurpose } from "./explanation.js";
|
|
|
24
24
|
* reaches the reviewer as a glyph plus color for a call that was added,
|
|
25
25
|
* removed, or contains a change, and plain dimmed text for an unchanged call.
|
|
26
26
|
*/
|
|
27
|
-
export function renderCallFlows(report) {
|
|
27
|
+
export function renderCallFlows(report, options = {}) {
|
|
28
|
+
const treeOnly = options.treeOnly === true;
|
|
28
29
|
const purposes = purposesOf(report);
|
|
29
30
|
const files = collectFiles(report.callFlows, report.items, purposes);
|
|
30
31
|
if (files.length === 0) {
|
|
@@ -35,7 +36,7 @@ export function renderCallFlows(report) {
|
|
|
35
36
|
'<div class="cf">',
|
|
36
37
|
renderSummary(files, new Set(report.items.map(item => item.file)).size),
|
|
37
38
|
renderPlumbingToggle(plumbing, report.agentExplanation?.explainedBy),
|
|
38
|
-
renderControls(),
|
|
39
|
+
treeOnly ? "" : renderControls(),
|
|
39
40
|
renderJump(files),
|
|
40
41
|
'<nav class="cf-crumbs" id="cf-crumbs" aria-label="Visited call trail" hidden></nav>',
|
|
41
42
|
// One panel the script fills with the focused call's definition; without the
|
|
@@ -45,7 +46,7 @@ export function renderCallFlows(report) {
|
|
|
45
46
|
'<div class="cf-src-panel-body" id="cf-src-panel-body"></div>',
|
|
46
47
|
"</details>",
|
|
47
48
|
'<div class="cf-files">',
|
|
48
|
-
files.map((view, index) => renderFile(view, index + 1)).join("\n"),
|
|
49
|
+
files.map((view, index) => renderFile(view, index + 1, treeOnly)).join("\n"),
|
|
49
50
|
"</div>",
|
|
50
51
|
"</div>",
|
|
51
52
|
].join("\n");
|
|
@@ -267,7 +268,7 @@ function renderJump(files) {
|
|
|
267
268
|
].join("\n");
|
|
268
269
|
}
|
|
269
270
|
/** One file section: severity, path, call count and its diff link. */
|
|
270
|
-
function renderFile(view, at) {
|
|
271
|
+
function renderFile(view, at, treeOnly) {
|
|
271
272
|
const { dot, tone, count } = fileFacts(view);
|
|
272
273
|
const status = view.status;
|
|
273
274
|
const diff = `<a class="cf-diff-link" data-open-hunk href="#item-${view.rank}">View diff #${view.rank}</a>`;
|
|
@@ -284,8 +285,8 @@ function renderFile(view, at) {
|
|
|
284
285
|
view.truncated
|
|
285
286
|
? `<p class="cf-bounds">Bounds reached for this file: ${escapeHtml(BOUNDS_TEXT)}. Calls cut at a bound are omitted.</p>`
|
|
286
287
|
: "",
|
|
287
|
-
modesNote(),
|
|
288
|
-
renderModes(view, at),
|
|
288
|
+
treeOnly ? "" : modesNote(),
|
|
289
|
+
treeOnly ? renderTreeMode(view, at, false) : renderModes(view, at),
|
|
289
290
|
"</div>",
|
|
290
291
|
"</details>",
|
|
291
292
|
]
|
|
@@ -303,7 +304,7 @@ function renderFile(view, at) {
|
|
|
303
304
|
*/
|
|
304
305
|
function renderModes(view, at) {
|
|
305
306
|
return [
|
|
306
|
-
renderTreeMode(view, at),
|
|
307
|
+
renderTreeMode(view, at, true),
|
|
307
308
|
lazyMode("graph", at, renderGraphMode(view, at)),
|
|
308
309
|
lazyMode("sequence", at, renderSequenceMode(view, at)),
|
|
309
310
|
].join("\n");
|
|
@@ -321,10 +322,10 @@ function modesNote() {
|
|
|
321
322
|
`resolved definition opens on its own. The diff holds every changed line.` +
|
|
322
323
|
`</p></noscript>`);
|
|
323
324
|
}
|
|
324
|
-
function modeSection(mode, at, body) {
|
|
325
|
+
function modeSection(mode, at, body, headed = true) {
|
|
325
326
|
return [
|
|
326
327
|
`<section class="cf-mode cf-mode-${mode}" id="cf-f${at}-${mode}" data-cf-mode-body="${mode}">`,
|
|
327
|
-
`<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3
|
|
328
|
+
headed ? `<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3>` : "",
|
|
328
329
|
body,
|
|
329
330
|
"</section>",
|
|
330
331
|
].join("\n");
|
|
@@ -333,11 +334,11 @@ function modeSection(mode, at, body) {
|
|
|
333
334
|
function lazyMode(mode, at, section) {
|
|
334
335
|
return `<template data-cf-lazy-mode="${mode}" data-cf-file="${at}">${section}</template>`;
|
|
335
336
|
}
|
|
336
|
-
function renderTreeMode(view, at) {
|
|
337
|
+
function renderTreeMode(view, at, headed) {
|
|
337
338
|
const trees = view.trees
|
|
338
339
|
.map((tree, root) => renderTreeNode(tree, [root], at, view.file, view.purposes))
|
|
339
340
|
.join("\n");
|
|
340
|
-
return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul
|
|
341
|
+
return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul>`, headed);
|
|
341
342
|
}
|
|
342
343
|
/** The resolved definition of one call. It is a native disclosure, so the actual
|
|
343
344
|
* source is readable without the script, and the script clones this body into
|
|
@@ -1843,7 +1843,8 @@ function script(csrf) {
|
|
|
1843
1843
|
for (var i = 0; i < list.length; i += 1) {
|
|
1844
1844
|
var s = list[i];
|
|
1845
1845
|
var shaped = s && typeof s === 'object' && typeof s.path === 'string' && typeof s.line === 'number'
|
|
1846
|
-
&& (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
|
|
1846
|
+
&& (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
|
|
1847
|
+
&& SEVERITY_LABELS[s.severity] !== undefined;
|
|
1847
1848
|
if (!shaped || !currentLine(s) || settled[suggestionKey(current.snapshotId, s)]) continue;
|
|
1848
1849
|
out.push(s);
|
|
1849
1850
|
}
|
|
@@ -1910,12 +1911,17 @@ function script(csrf) {
|
|
|
1910
1911
|
render();
|
|
1911
1912
|
}
|
|
1912
1913
|
|
|
1914
|
+
var SEVERITY_LABELS = { critical: 'Critical', major: 'Major', minor: 'Minor' };
|
|
1915
|
+
|
|
1913
1916
|
function suggestionRow(suggestion, disabled) {
|
|
1914
1917
|
var wrap = make('div', 'suggestion');
|
|
1915
1918
|
var head = make('p', 'suggestion-by');
|
|
1916
1919
|
head.appendChild(make('span', 'agent-dot', ''));
|
|
1917
1920
|
head.appendChild(make('strong', '', suggestedBy()));
|
|
1918
1921
|
head.appendChild(document.createTextNode(' suggests'));
|
|
1922
|
+
var severity = make('span', 'severity severity-' + suggestion.severity, SEVERITY_LABELS[suggestion.severity]);
|
|
1923
|
+
severity.title = 'How much the agent thinks this matters';
|
|
1924
|
+
head.appendChild(severity);
|
|
1919
1925
|
wrap.appendChild(head);
|
|
1920
1926
|
wrap.appendChild(make('p', 'suggestion-body', suggestion.body));
|
|
1921
1927
|
var actions = make('div', 'suggestion-actions');
|
|
@@ -3071,8 +3077,14 @@ kbd {
|
|
|
3071
3077
|
.editor { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-soft); }
|
|
3072
3078
|
.editor-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
3073
3079
|
.editor-input { flex: 1 1 240px; min-width: 0; }
|
|
3074
|
-
.suggestion-by { font-size: 12px; color: var(--ink-soft); }
|
|
3080
|
+
.suggestion-by { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-soft); }
|
|
3075
3081
|
.suggestion-by strong { color: var(--ink); font-weight: 600; }
|
|
3082
|
+
.severity {
|
|
3083
|
+
margin-left: 6px; padding: 1px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
|
|
3084
|
+
color: var(--ink-soft); background: var(--neutral-soft);
|
|
3085
|
+
}
|
|
3086
|
+
.severity-critical { color: var(--alarm); background: var(--alarm-bg); }
|
|
3087
|
+
.severity-major { color: var(--warn); background: var(--warn-soft); }
|
|
3076
3088
|
.suggestion-body { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
3077
3089
|
.suggestion-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
3078
3090
|
|
package/dist/review/html.d.ts
CHANGED
|
@@ -35,7 +35,8 @@ export declare function renderBusinessPage(report: ReviewReport): string;
|
|
|
35
35
|
/**
|
|
36
36
|
* The call flows of one review as a page of their own, for the connected pull
|
|
37
37
|
* request page to show beside its diff: every changed file with call flows, or
|
|
38
|
-
* only `file` when given.
|
|
39
|
-
*
|
|
38
|
+
* only `file` when given. Only the Tree view is offered, and the business view
|
|
39
|
+
* stays out of it: the page around it already shows that view. Links into the
|
|
40
|
+
* report's diff are dropped, since the page showing this has its own.
|
|
40
41
|
*/
|
|
41
42
|
export declare function renderCallFlowPage(report: ReviewReport, file?: string): string;
|
package/dist/review/html.js
CHANGED
|
@@ -110,7 +110,6 @@ const BUSINESS_VIEW_STYLES = `
|
|
|
110
110
|
.business { padding-top: 14px; }
|
|
111
111
|
.business-title { font-size: 20px; }
|
|
112
112
|
.business-lede { margin-top: 4px; color: var(--ink-soft); font-size: 14px; max-width: 90ch; }
|
|
113
|
-
.flow-section-title { margin: 22px 0 0; font-size: 18px; }
|
|
114
113
|
`;
|
|
115
114
|
/**
|
|
116
115
|
* Posts the document's height to the page that frames it, so the pull request
|
|
@@ -122,7 +121,9 @@ const BUSINESS_FRAME_SCRIPT = `
|
|
|
122
121
|
'use strict';
|
|
123
122
|
function post() {
|
|
124
123
|
if (window.parent === window) return;
|
|
125
|
-
|
|
124
|
+
// The html element's own box, not scrollHeight: scrollHeight never drops below the frame's
|
|
125
|
+
// height, so the frame could grow but never shrink back around its content.
|
|
126
|
+
window.parent.postMessage({ type: 'diffninja-business-height', height: Math.ceil(document.documentElement.getBoundingClientRect().height) }, window.location.origin);
|
|
126
127
|
}
|
|
127
128
|
window.addEventListener('load', post);
|
|
128
129
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(post).observe(document.body);
|
|
@@ -173,8 +174,9 @@ body { background: var(--bg); font-family: var(--sans); }
|
|
|
173
174
|
/**
|
|
174
175
|
* The call flows of one review as a page of their own, for the connected pull
|
|
175
176
|
* request page to show beside its diff: every changed file with call flows, or
|
|
176
|
-
* only `file` when given.
|
|
177
|
-
*
|
|
177
|
+
* only `file` when given. Only the Tree view is offered, and the business view
|
|
178
|
+
* stays out of it: the page around it already shows that view. Links into the
|
|
179
|
+
* report's diff are dropped, since the page showing this has its own.
|
|
178
180
|
*/
|
|
179
181
|
export function renderCallFlowPage(report, file) {
|
|
180
182
|
const scoped = file === undefined ? report : { ...report, callFlows: report.callFlows.filter((entry) => entry.file === file) };
|
|
@@ -186,20 +188,11 @@ export function renderCallFlowPage(report, file) {
|
|
|
186
188
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
187
189
|
'<meta name="color-scheme" content="light dark">',
|
|
188
190
|
`<title>${escapeHtml(file === undefined ? "Call flows" : `Call flow: ${file}`)}</title>`,
|
|
189
|
-
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${
|
|
191
|
+
`<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
|
|
190
192
|
"</head>",
|
|
191
193
|
"<body>",
|
|
192
|
-
report.agentExplanation === undefined
|
|
193
|
-
? ""
|
|
194
|
-
: [
|
|
195
|
-
'<section class="flow-embed business" aria-labelledby="flow-business-title">',
|
|
196
|
-
`<h2 class="business-title" id="flow-business-title">${file === undefined ? "How it works" : "How this file fits the process"}</h2>`,
|
|
197
|
-
renderBusinessView(report, { file, glossary: false }),
|
|
198
|
-
"</section>",
|
|
199
|
-
].join("\n"),
|
|
200
194
|
`<section id="view-call-flow" class="flow-embed${file === undefined ? "" : " flow-single"}" aria-label="Call flow">`,
|
|
201
|
-
|
|
202
|
-
renderCallFlows(scoped),
|
|
195
|
+
renderCallFlows(scoped, { treeOnly: true }),
|
|
203
196
|
"</section>",
|
|
204
197
|
`<script>document.documentElement.classList.add('js');\n${CALL_FLOW_SCRIPT}</script>`,
|
|
205
198
|
"</body>",
|
package/dist/review/mcp.js
CHANGED
|
@@ -9,7 +9,9 @@ import { detectPullRequest } from "./pr-input.js";
|
|
|
9
9
|
import { renderBusinessPage, renderCallFlowPage, renderReview } from "./html.js";
|
|
10
10
|
import { MAX_SUGGESTED_COMMENTS, ReportPages } from "./report-pages.js";
|
|
11
11
|
import { reviewDiff } from "./service.js";
|
|
12
|
+
import { COMMENT_SEVERITIES } from "./types.js";
|
|
12
13
|
import { MAX_BRANCH_CHARS, MAX_DETAIL_CHARS, MAX_EXPLAINED_FUNCTIONS, MAX_PROCESSES, MAX_PROCESS_STEPS, MAX_PURPOSE_CHARS, MAX_RULES, MAX_RULE_CHARS, MAX_STEP_CHARS, MAX_STEP_EXITS, MAX_TITLE_CHARS, MIN_PROCESS_STEPS, } from "./explanation.js";
|
|
14
|
+
import { packageVersion } from "./version.js";
|
|
13
15
|
const PR_LINK_ERROR = "A pull request review needs exactly one full github.com pull request URL, for example https://github.com/OWNER/REPO/pull/123. Ask the user for their link; do not guess, search, or invent one.";
|
|
14
16
|
const STATIC_MODE_ERROR = "mode static reviews a diff or git range and accepts no pr or input. Use mode connected to review a pull request link.";
|
|
15
17
|
/**
|
|
@@ -220,7 +222,7 @@ class ReviewServer extends McpServer {
|
|
|
220
222
|
sessions;
|
|
221
223
|
reports;
|
|
222
224
|
constructor(sessions, reports) {
|
|
223
|
-
super({ name: "diffninja", version:
|
|
225
|
+
super({ name: "diffninja", version: packageVersion() });
|
|
224
226
|
this.sessions = sessions;
|
|
225
227
|
this.reports = reports;
|
|
226
228
|
this.server.onclose = () => {
|
|
@@ -244,8 +246,9 @@ const commentSchema = z.object({
|
|
|
244
246
|
line: z.number().int().positive().describe("The line number on that side."),
|
|
245
247
|
side: z.enum(["LEFT", "RIGHT"]).describe("RIGHT for an added or context line (new side), LEFT for a removed line (old side)."),
|
|
246
248
|
body: z.string().max(1000).describe("The comment, as the reviewer would write it: one short line, no labels or formatting."),
|
|
249
|
+
severity: z.enum(COMMENT_SEVERITIES).describe("How much it matters, so the human can triage: critical for a bug, data loss, security or contract break that should block the merge; major for a real risk or missing case worth fixing before merge; minor for a nit, naming or style point. Give it here, never inside the body."),
|
|
247
250
|
}).strict();
|
|
248
|
-
const COMMENT_RULES = "Only comment where a maintainer would actually ask for something or point something out: a bug, a risk, a missing case, a confusing name, a missing test; never pad. Write each one as the reviewer would type it on GitHub, in their own voice: short (one line, at most 280 characters), concrete, conversational, e.g. \"This drops the error from Close(); should we return it?\" or \"nit: could this reuse parseVersion?\". No report scaffolding: no headings, bold, list markers, numbering, or labels such as Finding, Issue, Attention, Error, Severity. Each names a line of the diff: path, line, and side RIGHT for an added or context line, LEFT for a removed line; at most one per line and 30 in all.";
|
|
251
|
+
const COMMENT_RULES = "Only comment where a maintainer would actually ask for something or point something out: a bug, a risk, a missing case, a confusing name, a missing test; never pad. Write each one as the reviewer would type it on GitHub, in their own voice: short (one line, at most 280 characters), concrete, conversational, e.g. \"This drops the error from Close(); should we return it?\" or \"nit: could this reuse parseVersion?\". No report scaffolding: no headings, bold, list markers, numbering, or labels such as Finding, Issue, Attention, Error, Severity. Each names a line of the diff: path, line, and side RIGHT for an added or context line, LEFT for a removed line; at most one per line and 30 in all. Each also carries a severity (critical, major or minor) in its own field, which the page shows next to the comment so the human reviews the important ones first; the words critical, major and minor do not belong in the body.";
|
|
249
252
|
/**
|
|
250
253
|
* What the goal summary is for. It is the agent's own paragraph for the human
|
|
251
254
|
* reading the pull request, written from the author's own title and
|
|
@@ -461,12 +464,7 @@ export function createReviewServer() {
|
|
|
461
464
|
description: "Update the line comments suggested for a pull request review after finish_review, or before it. " + COMMENT_RULES + " The whole call is refused, and the previous suggestions kept, if any comment breaks these rules. A later call replaces the earlier suggestions; an empty list clears them. Nothing is posted: the page shows each suggestion under its line, attributed to this MCP client, and the human adds it to their own review, edits it, or dismisses it. This returns no page link: only finish_review does.",
|
|
462
465
|
inputSchema: z.object({
|
|
463
466
|
reviewId: z.string().regex(/^[a-f0-9]{32}$/).describe("The reviewId a review_diff result returned on this connection."),
|
|
464
|
-
comments: z.array(
|
|
465
|
-
path: z.string().min(1).max(1024).describe("The file's path in the diff."),
|
|
466
|
-
line: z.number().int().positive().describe("The line number on that side."),
|
|
467
|
-
side: z.enum(["LEFT", "RIGHT"]).describe("RIGHT for an added or context line (new side), LEFT for a removed line (old side)."),
|
|
468
|
-
body: z.string().max(1000).describe("The comment, as the reviewer would write it: one short line, no labels or formatting."),
|
|
469
|
-
}).strict()).max(MAX_SUGGESTED_COMMENTS),
|
|
467
|
+
comments: z.array(commentSchema).max(MAX_SUGGESTED_COMMENTS),
|
|
470
468
|
}).strict(),
|
|
471
469
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
472
470
|
}, async ({ reviewId, comments }) => {
|
|
@@ -22,8 +22,6 @@ import type { ReviewReport } from "./types.js";
|
|
|
22
22
|
export interface BusinessViewOptions {
|
|
23
23
|
/** Link for a hunk id, or undefined when this page has no diff to link to. */
|
|
24
24
|
readonly hunkHref?: (itemId: string) => string | undefined;
|
|
25
|
-
/** Draw only the processes and rules that touch this changed file. */
|
|
26
|
-
readonly file?: string;
|
|
27
25
|
/** Leave out the function glossary (the call-flow view shows the purposes). */
|
|
28
26
|
readonly glossary?: boolean;
|
|
29
27
|
/** Leave out the attribution line when the page around it already says who explained it. */
|
|
@@ -32,9 +30,7 @@ export interface BusinessViewOptions {
|
|
|
32
30
|
readonly stepsOpen?: boolean;
|
|
33
31
|
}
|
|
34
32
|
/**
|
|
35
|
-
* The whole business view, or a note saying why there is none.
|
|
36
|
-
* scopes it to one changed file: the processes whose steps name a hunk or a
|
|
37
|
-
* function of that file, and the rules that name one of its hunks.
|
|
33
|
+
* The whole business view, or a note saying why there is none.
|
|
38
34
|
*/
|
|
39
35
|
export declare function renderBusinessView(report: ReviewReport, options?: BusinessViewOptions): string;
|
|
40
36
|
interface StepBox {
|
|
@@ -49,9 +49,7 @@ const LANE_GAP = 18;
|
|
|
49
49
|
const CHART_PAD = 14;
|
|
50
50
|
const DECISION_INSET = 16;
|
|
51
51
|
/**
|
|
52
|
-
* The whole business view, or a note saying why there is none.
|
|
53
|
-
* scopes it to one changed file: the processes whose steps name a hunk or a
|
|
54
|
-
* function of that file, and the rules that name one of its hunks.
|
|
52
|
+
* The whole business view, or a note saying why there is none.
|
|
55
53
|
*/
|
|
56
54
|
export function renderBusinessView(report, options = {}) {
|
|
57
55
|
const explanation = report.agentExplanation;
|
|
@@ -62,11 +60,7 @@ export function renderBusinessView(report, options = {}) {
|
|
|
62
60
|
const purposes = purposesOf(report);
|
|
63
61
|
const ranks = new Map(report.items.map((item, index) => [item.id, index + 1]));
|
|
64
62
|
const itemFiles = new Map(report.items.map((item) => [item.id, item.file]));
|
|
65
|
-
const
|
|
66
|
-
(hunks ?? []).some((id) => itemFiles.get(id) === options.file) ||
|
|
67
|
-
(fns ?? []).some((id) => functions.get(id)?.file === options.file);
|
|
68
|
-
const processes = explanation.processes.filter((process) => process.steps.some((step) => touches(step.hunks, step.functions)));
|
|
69
|
-
const rules = explanation.rules.filter((rule) => touches(rule.hunks, undefined));
|
|
63
|
+
const { processes, rules } = explanation;
|
|
70
64
|
const context = { functions, purposes, ranks, itemFiles, hunkHref: options.hunkHref, stepsOpen: options.stepsOpen !== false };
|
|
71
65
|
const parts = [
|
|
72
66
|
'<div class="bp">',
|
|
@@ -77,13 +71,10 @@ export function renderBusinessView(report, options = {}) {
|
|
|
77
71
|
renderLegend(),
|
|
78
72
|
"</div>",
|
|
79
73
|
].filter((part) => part !== "");
|
|
80
|
-
if (processes.length === 0 && rules.length === 0) {
|
|
81
|
-
parts.push(`<p class="bp-note">No process or rule in the explanation names this file's hunks or functions.</p>`);
|
|
82
|
-
}
|
|
83
74
|
processes.forEach((process, index) => parts.push(renderProcess(process, index + 1, context)));
|
|
84
75
|
if (rules.length > 0)
|
|
85
76
|
parts.push(renderRules(rules, context));
|
|
86
|
-
if (options.glossary !== false
|
|
77
|
+
if (options.glossary !== false)
|
|
87
78
|
parts.push(renderGlossary(report.functions ?? [], purposes));
|
|
88
79
|
parts.push("</div>");
|
|
89
80
|
return parts.join("\n");
|
|
@@ -117,7 +117,7 @@ function checkComments(report, comments) {
|
|
|
117
117
|
}
|
|
118
118
|
function applyComments(report, comments, suggestedBy) {
|
|
119
119
|
report.agentComments = {
|
|
120
|
-
comments: comments.map(({ path, line, side, body }) => ({ path, line, side, body: body.trim() })),
|
|
120
|
+
comments: comments.map(({ path, line, side, body, severity }) => ({ path, line, side, body: body.trim(), severity })),
|
|
121
121
|
suggestedBy,
|
|
122
122
|
suggestedAt: new Date().toISOString(),
|
|
123
123
|
};
|
package/dist/review/setup.d.ts
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
* agent CLI (Claude Code, Codex, OMP, pi) with one command.
|
|
4
4
|
*
|
|
5
5
|
* The setup installs the package globally first so the registration points
|
|
6
|
-
* at a permanent binary instead of the npx cache
|
|
7
|
-
*
|
|
6
|
+
* at a permanent binary instead of the npx cache, and brings a global install
|
|
7
|
+
* older than itself up to its own version, so running the newest setup is how
|
|
8
|
+
* a user updates. When the global install is unavailable it falls back to an
|
|
9
|
+
* npx-based entry pinned to its own version and says so. The server
|
|
8
10
|
* needs no key or environment: reviews are local, and connected reviews reuse
|
|
9
11
|
* the `gh` session.
|
|
10
12
|
*/
|
|
11
13
|
import { type Stats } from "node:fs";
|
|
12
|
-
export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja
|
|
14
|
+
export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja@<this version>`) so the registration keeps\nworking, and updates a global install older than this setup, so running\n`npx -y diffninja@latest setup` again is how you update. When that install\nfails it registers an npx-based entry pinned to this version and says so.\n\nOptions:\n --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.\n --uninstall Remove the diffninja server from every detected CLI.\n --dry-run Show what would change, without installing or writing.\n --no-install Skip installing or updating the global package; without one,\n register npx-based entries.\n --help Show this help.\n\nThe server needs no API key: static reviews run locally, and pull request\nreviews reuse your authenticated gh session.\n";
|
|
13
15
|
export declare const CLI_NAMES: readonly ["claude", "codex", "omp", "pi"];
|
|
14
16
|
export type CliName = (typeof CLI_NAMES)[number];
|
|
15
17
|
export interface McpEntry {
|
|
@@ -29,7 +31,8 @@ export interface SetupOptions {
|
|
|
29
31
|
}
|
|
30
32
|
export interface Npm {
|
|
31
33
|
rootG(): Promise<string>;
|
|
32
|
-
|
|
34
|
+
/** `npm install -g <spec>`, e.g. `diffninja@0.3.1`; true on success. */
|
|
35
|
+
installG(spec: string): Promise<boolean>;
|
|
33
36
|
}
|
|
34
37
|
export interface CliReport {
|
|
35
38
|
cli: CliName;
|
|
@@ -44,9 +47,16 @@ export interface SetupReport {
|
|
|
44
47
|
}
|
|
45
48
|
export interface SetupDeps {
|
|
46
49
|
npm?: Npm;
|
|
50
|
+
/** Version this setup installs and pins; defaults to the running package's. */
|
|
51
|
+
version?: string;
|
|
47
52
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Entry that runs the published package on demand, without an install. It
|
|
55
|
+
* names this setup's version: an unversioned package lets npx keep running
|
|
56
|
+
* whichever copy it cached first, while a pinned one changes with each newer
|
|
57
|
+
* setup, which rewrites the entry.
|
|
58
|
+
*/
|
|
59
|
+
export declare function npxEntry(platform?: NodeJS.Platform, version?: string): McpEntry;
|
|
50
60
|
/**
|
|
51
61
|
* Entry for a command that Windows ships only as a `.cmd` shim, which a client
|
|
52
62
|
* that spawns without a shell cannot launch. Running npm's JS entry point with
|
|
@@ -60,6 +70,14 @@ export declare function windowsCliEntry(name: string, args: readonly string[], c
|
|
|
60
70
|
/** Entry that runs the globally installed package. `globalRoot` is `npm root -g`. */
|
|
61
71
|
export declare function globalEntry(globalRoot: string): McpEntry;
|
|
62
72
|
export declare function globalEntryExists(globalRoot: string): boolean;
|
|
73
|
+
/** Version of the globally installed package, or undefined when its manifest is missing or unreadable. */
|
|
74
|
+
export declare function globalVersion(globalRoot: string): string | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a global install at `installed` should be brought up to `version`.
|
|
77
|
+
* A manifest that cannot be read or compared counts as older, so a broken
|
|
78
|
+
* install is repaired; a newer one is never downgraded.
|
|
79
|
+
*/
|
|
80
|
+
export declare function globalIsOlder(installed: string | undefined, version: string): boolean;
|
|
63
81
|
/**
|
|
64
82
|
* npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
|
|
65
83
|
* go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
|
package/dist/review/setup.js
CHANGED
|
@@ -3,37 +3,43 @@
|
|
|
3
3
|
* agent CLI (Claude Code, Codex, OMP, pi) with one command.
|
|
4
4
|
*
|
|
5
5
|
* The setup installs the package globally first so the registration points
|
|
6
|
-
* at a permanent binary instead of the npx cache
|
|
7
|
-
*
|
|
6
|
+
* at a permanent binary instead of the npx cache, and brings a global install
|
|
7
|
+
* older than itself up to its own version, so running the newest setup is how
|
|
8
|
+
* a user updates. When the global install is unavailable it falls back to an
|
|
9
|
+
* npx-based entry pinned to its own version and says so. The server
|
|
8
10
|
* needs no key or environment: reviews are local, and connected reviews reuse
|
|
9
11
|
* the `gh` session.
|
|
10
12
|
*/
|
|
11
13
|
import { spawn } from "node:child_process";
|
|
12
14
|
import { randomBytes } from "node:crypto";
|
|
13
15
|
import { once } from "node:events";
|
|
14
|
-
import { existsSync } from "node:fs";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
17
|
import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, stat } from "node:fs/promises";
|
|
16
18
|
import { homedir } from "node:os";
|
|
17
19
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
18
20
|
import { z } from "zod";
|
|
19
21
|
import { npmCliPath, npmSpawnSpec } from "../languages/grammars.js";
|
|
20
22
|
import { removeTomlTable, upsertTomlTable } from "./toml.js";
|
|
23
|
+
import { compareVersions, packageVersion } from "./version.js";
|
|
21
24
|
export const setupHelp = `diffninja setup. Register the diffninja MCP server on every detected agent CLI.
|
|
22
25
|
|
|
23
|
-
npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]
|
|
26
|
+
npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]
|
|
24
27
|
diffninja setup --uninstall [--cli codex]
|
|
25
28
|
|
|
26
29
|
Detects Claude Code, Codex, OMP, and pi from their config files or binaries
|
|
27
30
|
and registers the diffninja MCP server in each user config, pointing at the
|
|
28
31
|
globally installed package. Installs the package globally first
|
|
29
|
-
(\`npm install -g diffninja
|
|
30
|
-
|
|
32
|
+
(\`npm install -g diffninja@<this version>\`) so the registration keeps
|
|
33
|
+
working, and updates a global install older than this setup, so running
|
|
34
|
+
\`npx -y diffninja@latest setup\` again is how you update. When that install
|
|
35
|
+
fails it registers an npx-based entry pinned to this version and says so.
|
|
31
36
|
|
|
32
37
|
Options:
|
|
33
38
|
--cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.
|
|
34
39
|
--uninstall Remove the diffninja server from every detected CLI.
|
|
35
40
|
--dry-run Show what would change, without installing or writing.
|
|
36
|
-
--no-install Skip the global
|
|
41
|
+
--no-install Skip installing or updating the global package; without one,
|
|
42
|
+
register npx-based entries.
|
|
37
43
|
--help Show this help.
|
|
38
44
|
|
|
39
45
|
The server needs no API key: static reviews run locally, and pull request
|
|
@@ -42,9 +48,14 @@ reviews reuse your authenticated gh session.
|
|
|
42
48
|
export const CLI_NAMES = ["claude", "codex", "omp", "pi"];
|
|
43
49
|
const jsonValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(jsonValueSchema), z.record(z.string(), jsonValueSchema)]));
|
|
44
50
|
const jsonObjectSchema = z.object({}).catchall(jsonValueSchema);
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Entry that runs the published package on demand, without an install. It
|
|
53
|
+
* names this setup's version: an unversioned package lets npx keep running
|
|
54
|
+
* whichever copy it cached first, while a pinned one changes with each newer
|
|
55
|
+
* setup, which rewrites the entry.
|
|
56
|
+
*/
|
|
57
|
+
export function npxEntry(platform = process.platform, version = packageVersion()) {
|
|
58
|
+
const args = ["-y", "-p", `diffninja@${version}`, "diffninja-mcp"];
|
|
48
59
|
return platform === "win32" ? windowsCliEntry("npx", args) : { command: "npx", args };
|
|
49
60
|
}
|
|
50
61
|
/**
|
|
@@ -70,6 +81,27 @@ export function globalEntry(globalRoot) {
|
|
|
70
81
|
export function globalEntryExists(globalRoot) {
|
|
71
82
|
return existsSync(join(globalRoot, "diffninja", "dist", "review", "mcp-cli.js"));
|
|
72
83
|
}
|
|
84
|
+
const installedManifestSchema = z.object({ version: z.string() });
|
|
85
|
+
/** Version of the globally installed package, or undefined when its manifest is missing or unreadable. */
|
|
86
|
+
export function globalVersion(globalRoot) {
|
|
87
|
+
try {
|
|
88
|
+
const manifest = installedManifestSchema.safeParse(JSON.parse(readFileSync(join(globalRoot, "diffninja", "package.json"), "utf8")));
|
|
89
|
+
return manifest.success ? manifest.data.version : undefined;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether a global install at `installed` should be brought up to `version`.
|
|
97
|
+
* A manifest that cannot be read or compared counts as older, so a broken
|
|
98
|
+
* install is repaired; a newer one is never downgraded.
|
|
99
|
+
*/
|
|
100
|
+
export function globalIsOlder(installed, version) {
|
|
101
|
+
if (installed === undefined)
|
|
102
|
+
return true;
|
|
103
|
+
return (compareVersions(installed, version) ?? -1) < 0;
|
|
104
|
+
}
|
|
73
105
|
/**
|
|
74
106
|
* npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
|
|
75
107
|
* go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
|
|
@@ -90,8 +122,8 @@ export function createNpm(overrides = {}) {
|
|
|
90
122
|
});
|
|
91
123
|
return (await exitCode(child)) === 0 ? out.trim() : "";
|
|
92
124
|
},
|
|
93
|
-
async installG() {
|
|
94
|
-
return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`,
|
|
125
|
+
async installG(spec) {
|
|
126
|
+
return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`, spec], true))) === 0;
|
|
95
127
|
},
|
|
96
128
|
};
|
|
97
129
|
}
|
|
@@ -123,35 +155,70 @@ async function globalRoot(npm) {
|
|
|
123
155
|
return undefined;
|
|
124
156
|
}
|
|
125
157
|
}
|
|
158
|
+
/** Run one global install; a throw counts as a failed install. */
|
|
159
|
+
async function installGlobal(npm, spec) {
|
|
160
|
+
try {
|
|
161
|
+
return await npm.installG(spec);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
126
167
|
/**
|
|
127
|
-
* Resolve the registration entry.
|
|
128
|
-
*
|
|
168
|
+
* Resolve the registration entry. A global install older than `version` is
|
|
169
|
+
* updated to it first; one that cannot be updated keeps its entry, with a
|
|
170
|
+
* warning naming the command that updates it. `--dry-run` never installs: it
|
|
171
|
+
* reports the install it would have run and the entry that install would have
|
|
172
|
+
* produced.
|
|
129
173
|
*/
|
|
130
|
-
async function resolveEntry(noInstall, npm, quiet, dryRun) {
|
|
174
|
+
async function resolveEntry(noInstall, npm, quiet, dryRun, version) {
|
|
175
|
+
const spec = `diffninja@${version}`;
|
|
131
176
|
const root = await globalRoot(npm);
|
|
132
|
-
if (root !== undefined && globalEntryExists(root))
|
|
177
|
+
if (root !== undefined && globalEntryExists(root)) {
|
|
178
|
+
const installed = globalVersion(root);
|
|
179
|
+
if (!globalIsOlder(installed, version))
|
|
180
|
+
return { entry: globalEntry(root), viaNpx: false };
|
|
181
|
+
const from = installed ?? "an unknown version";
|
|
182
|
+
const update = `npm install -g ${spec}`;
|
|
183
|
+
if (noInstall) {
|
|
184
|
+
if (!quiet)
|
|
185
|
+
console.error(`diffninja: the global install is ${from}, older than this setup (${version}); update it with ${update}.`);
|
|
186
|
+
}
|
|
187
|
+
else if (dryRun) {
|
|
188
|
+
if (!quiet)
|
|
189
|
+
console.log(`diffninja: dry run: would update the global install from ${from} to ${version} (${update}).`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
if (!quiet)
|
|
193
|
+
console.log(`diffninja: updating the global install from ${from} to ${version} (${update})...`);
|
|
194
|
+
const updated = await installGlobal(npm, spec);
|
|
195
|
+
const updatedRoot = updated ? await globalRoot(npm) : undefined;
|
|
196
|
+
if (updatedRoot !== undefined && globalEntryExists(updatedRoot))
|
|
197
|
+
return { entry: globalEntry(updatedRoot), viaNpx: false };
|
|
198
|
+
if (!globalEntryExists(root)) {
|
|
199
|
+
if (!quiet)
|
|
200
|
+
console.error("diffninja: global update failed and left no install; registering npx-based entries instead.");
|
|
201
|
+
return { entry: npxEntry(process.platform, version), viaNpx: true };
|
|
202
|
+
}
|
|
203
|
+
if (!quiet)
|
|
204
|
+
console.error(`diffninja: update failed; the global install is still ${globalVersion(root) ?? from}. Update it with ${update}.`);
|
|
205
|
+
}
|
|
133
206
|
return { entry: globalEntry(root), viaNpx: false };
|
|
207
|
+
}
|
|
134
208
|
if (dryRun) {
|
|
135
209
|
if (!noInstall && root !== undefined) {
|
|
136
210
|
if (!quiet)
|
|
137
|
-
console.log(
|
|
211
|
+
console.log(`diffninja: dry run: would install the package globally (npm install -g ${spec}).`);
|
|
138
212
|
return { entry: globalEntry(root), viaNpx: false };
|
|
139
213
|
}
|
|
140
214
|
if (!quiet)
|
|
141
215
|
console.error("diffninja: dry run: global install unavailable; would register npx-based entries instead.");
|
|
142
|
-
return { entry: npxEntry(), viaNpx: true };
|
|
216
|
+
return { entry: npxEntry(process.platform, version), viaNpx: true };
|
|
143
217
|
}
|
|
144
218
|
if (!noInstall) {
|
|
145
219
|
if (!quiet)
|
|
146
|
-
console.log(
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
installed = await npm.installG();
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
installed = false;
|
|
153
|
-
}
|
|
154
|
-
if (installed) {
|
|
220
|
+
console.log(`diffninja: installing the package globally (npm install -g ${spec})...`);
|
|
221
|
+
if (await installGlobal(npm, spec)) {
|
|
155
222
|
const installedRoot = await globalRoot(npm);
|
|
156
223
|
if (installedRoot !== undefined && globalEntryExists(installedRoot)) {
|
|
157
224
|
return { entry: globalEntry(installedRoot), viaNpx: false };
|
|
@@ -160,7 +227,7 @@ async function resolveEntry(noInstall, npm, quiet, dryRun) {
|
|
|
160
227
|
}
|
|
161
228
|
if (!quiet)
|
|
162
229
|
console.error("diffninja: global install unavailable; registering npx-based entries instead.");
|
|
163
|
-
return { entry: npxEntry(), viaNpx: true };
|
|
230
|
+
return { entry: npxEntry(process.platform, version), viaNpx: true };
|
|
164
231
|
}
|
|
165
232
|
function findOnPath(name, pathDirs) {
|
|
166
233
|
const candidates = process.platform === "win32" ? [name, `${name}.cmd`, `${name}.exe`] : [name];
|
|
@@ -486,10 +553,11 @@ export async function runSetup(options = {}, deps = {}) {
|
|
|
486
553
|
const quiet = options.quiet === true;
|
|
487
554
|
const uninstall = options.uninstall === true;
|
|
488
555
|
const dryRun = options.dryRun === true;
|
|
556
|
+
const version = deps.version ?? packageVersion();
|
|
489
557
|
let entry;
|
|
490
558
|
let viaNpx = false;
|
|
491
559
|
if (!uninstall) {
|
|
492
|
-
const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun);
|
|
560
|
+
const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun, version);
|
|
493
561
|
entry = resolved.entry;
|
|
494
562
|
viaNpx = resolved.viaNpx;
|
|
495
563
|
}
|
|
@@ -545,5 +613,5 @@ export async function runSetup(options = {}, deps = {}) {
|
|
|
545
613
|
console.log("note: pi needs the pi-mcp-extension for MCP support (pi install npm:pi-mcp-extension)");
|
|
546
614
|
}
|
|
547
615
|
}
|
|
548
|
-
return { entry: entry ?? npxEntry(), viaNpx, clis: reports };
|
|
616
|
+
return { entry: entry ?? npxEntry(process.platform, version), viaNpx, clis: reports };
|
|
549
617
|
}
|
package/dist/review/types.d.ts
CHANGED
|
@@ -132,12 +132,16 @@ export interface AgentOrder {
|
|
|
132
132
|
/** diffninja's own order of the same items, kept so the page can still offer it. */
|
|
133
133
|
diffninjaIds: string[];
|
|
134
134
|
}
|
|
135
|
+
/** How much a suggested comment matters, most to least: what a reviewer triages by. */
|
|
136
|
+
export declare const COMMENT_SEVERITIES: readonly ["critical", "major", "minor"];
|
|
137
|
+
export type CommentSeverity = (typeof COMMENT_SEVERITIES)[number];
|
|
135
138
|
/** One line comment the reviewing agent suggests; the human adds it to their review or not. */
|
|
136
139
|
export interface SuggestedComment {
|
|
137
140
|
path: string;
|
|
138
141
|
line: number;
|
|
139
142
|
side: "LEFT" | "RIGHT";
|
|
140
143
|
body: string;
|
|
144
|
+
severity: CommentSeverity;
|
|
141
145
|
}
|
|
142
146
|
export interface AgentComments {
|
|
143
147
|
comments: SuggestedComment[];
|
package/dist/review/types.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
/** How much a suggested comment matters, most to least: what a reviewer triages by. */
|
|
2
|
+
export const COMMENT_SEVERITIES = ["critical", "major", "minor"];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the running diffninja package, read from its own
|
|
3
|
+
* `package.json`. Both layouts put it two levels up: `src/review/` in a
|
|
4
|
+
* checkout and `dist/review/` in the published package, which always ships
|
|
5
|
+
* `package.json`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function packageVersion(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Compare two `major.minor.patch` versions: negative when `left` is older,
|
|
10
|
+
* zero when equal, positive when newer. A prerelease sorts before its release;
|
|
11
|
+
* two prereleases of one release compare by their tags as text. Undefined when
|
|
12
|
+
* either is not a version.
|
|
13
|
+
*/
|
|
14
|
+
export declare function compareVersions(left: string, right: string): number | undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the running diffninja package, read from its own
|
|
3
|
+
* `package.json`. Both layouts put it two levels up: `src/review/` in a
|
|
4
|
+
* checkout and `dist/review/` in the published package, which always ships
|
|
5
|
+
* `package.json`.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
const manifestSchema = z.object({ version: z.string() });
|
|
10
|
+
let cached;
|
|
11
|
+
export function packageVersion() {
|
|
12
|
+
cached ??= manifestSchema.parse(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))).version;
|
|
13
|
+
return cached;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Compare two `major.minor.patch` versions: negative when `left` is older,
|
|
17
|
+
* zero when equal, positive when newer. A prerelease sorts before its release;
|
|
18
|
+
* two prereleases of one release compare by their tags as text. Undefined when
|
|
19
|
+
* either is not a version.
|
|
20
|
+
*/
|
|
21
|
+
export function compareVersions(left, right) {
|
|
22
|
+
const a = parseVersion(left);
|
|
23
|
+
const b = parseVersion(right);
|
|
24
|
+
if (a === undefined || b === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
for (let index = 0; index < 3; index += 1) {
|
|
27
|
+
const difference = a.core[index] - b.core[index];
|
|
28
|
+
if (difference !== 0)
|
|
29
|
+
return difference;
|
|
30
|
+
}
|
|
31
|
+
if (a.prerelease === b.prerelease)
|
|
32
|
+
return 0;
|
|
33
|
+
if (a.prerelease === undefined)
|
|
34
|
+
return 1;
|
|
35
|
+
if (b.prerelease === undefined)
|
|
36
|
+
return -1;
|
|
37
|
+
return a.prerelease < b.prerelease ? -1 : 1;
|
|
38
|
+
}
|
|
39
|
+
function parseVersion(text) {
|
|
40
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text.trim());
|
|
41
|
+
if (match === null)
|
|
42
|
+
return undefined;
|
|
43
|
+
return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] };
|
|
44
|
+
}
|