From 38d84a680bcb06d054af90343d04f65efaaac257 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:22:20 -0400 Subject: [PATCH 1/2] fix(vendor-graph): rank releases by version, not by ASCII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `newestReleasedVersion` picked the newest release with a bare `.sort()`, which is lexicographic. ASCII orders 0.4.10, 0.4.46, 0.4.5, 0.4.9 and so answers "0.4.9". The engine is at 0.4.46 today, so this is not latent: every repository past a two-digit patch resolved to the wrong newest version, and that value is handed to `blastRadiusOf` as `proposedVersion` — every sealed blast-radius report named a release nobody would cut. Worse, and reachable from the register as written: a pin may name a version the release history does not list, and the ingest builds that artifact with no supersession chain — so it is a HEAD and joins the candidate set. Under ASCII a commit sha, or the literal `unknown`, outranks every real release because letters sort above digits. The report then proposed cutting `unknown`. Replaced with `compareVersions`, exported so the rule is assertable, and with the tie-break DOCUMENTED rather than implicit — an undocumented tie-break is how this class of bug returns. Numeric release components compared as digit strings (exact, no float); non-release strings sort below every release; a prerelease precedes its release; prerelease identifiers follow semver §11, so `rc.2` precedes `rc.10` instead of reintroducing the same defect one level down; build metadata is ignored for precedence; and the order is TOTAL, returning 0 only for identical strings, because an unresolved tie makes a sort depend on input order and this sort is sealed into a receipt. Hand-scanned, not regex-matched: `slug()` in this file already earned a CodeQL polynomial-redos finding on a register-supplied value. Three smaller defects found in the same review: - `supersessionChain`'s docstring claimed "longest path" while the code took the lowest next node id and the comment below said so. The code is right — lowest-id is replayable and a sealed receipt must be re-derivable — so the docstring is what changed. - The pinned-artifact branch built `producedBy` to `vfpId.repository(... ?? '')`, i.e. a dangling edge to `vfp:repo/` whenever the matched source declared no repo. Guarded on non-empty and routed through `ensureRepo` so the target node is guaranteed to exist. - `stats.releases++` was the one counter not gated by a `seen*` set: it counted manifest LINES while every other counter counted NODES CREATED, so a register declaring one release twice made them disagree. Tests: the ordering test is red against the old `.sort()` (asserts 0.4.46, gets 0.4.9) and uses the four-version case, so a single-digit-patch fixture cannot pass in its place. The forked-history test pins the documented lowest-id behaviour — its branches are built so longest-path gives a different `releaseDistance`, so a future "improvement" breaks a test instead of silently rewriting receipts. --- ts/dist/index.d.mts | Bin 229625 -> 226510 bytes ts/dist/index.d.ts | Bin 229625 -> 226510 bytes ts/dist/index.js | Bin 428423 -> 427276 bytes ts/dist/index.mjs | Bin 419455 -> 418416 bytes ts/src/vendor-graph.test.ts | 318 +++++++++++++++++++++++++++++++++++- ts/src/vendor-graph.ts | 187 ++++++++++++++++++++- 6 files changed, 496 insertions(+), 9 deletions(-) diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 9ce25c2a6d38c08d6d4aacd48961bed27afc9e64..747801322d2c2a4295034cea926c0162a42dfa37 100644 GIT binary patch delta 3013 zcmZuz&x;&I6lQn)Ee63uJU&H9b~4lK<`=k%uqGoUgd`-3UKF~zyJn}@?y9z`dS?g0 zj$Xubgj^K_FP=mgK}7Hm5O1E%&3_;u2>QLM?%7>6EM&U7>iziM_rCZ3{P@%_e>`{Z zyR}~*Ew4Ymo`17^@B4MJ_TciT>n}XG_2k+U%j-X^KU#0o#;TNzE*GzppxkJ0MefPU za-sAdK9JDH&!nYvYHTHZN>$@LhV zkh(B-yk|u{OT0^Aw4l_=51whH^Rn3S>Bjw+zC8V)%GRD*KK;}BXTD|2S4>q4E6L9d z9Z2gG;&I+0GDq2h?o4h>cJ5B-_MOR<$<@itE0cuoNZ(lP=#9-0;qT2&Lfn*!lro85 zazyfjMwH~uh&|w&n0ihxOGo4kQli3|+O_ORsJ)pdrM;na$6B#SYHPgli&`emSf3`a z!De=mbRnmrDLtj()7g!bl%t)ySFT+fg?z(LvO@1WN;i{>$!owNGY7zjpDtcb>EXT4 z32>tlvvyH4?p^`Q5kOUr{Ybs1v}D6TI^Ig?8ZMLwJ(_0xOF(atWZLL`ZRR=xF7yHw zt}3MHW}@>gf$IYG7A4z9-KB5w0!E%dp$Wd6Yrp!oAnPyTHNNyh% zkxSrZUxDF}YhY|4nI3$6_tx&tjevY%@}`m+L_=a-UP4m?QuZInD>=>p14!+V%AEb< zS6&L|$V@r#tl4Ay5NRgz{bV?#YdU(w0TMNLf$-qyHcs;e+Dd3RygL)13r_F!D6orR z2f-MnsAVxEHD&JM3nDYMZ()ZkcmP@gF%5|P*h#d5Kptcpa6E=Z_}7;toQwv#`{UZm z&Q)*e#P@Sqqsk&pXu?E5&HzuAPPT!JkuD(@ctNNrdw~G(--=ryK9W`K7hQ9PtDNI7 zlDYE_2)OT=Nsiu{+_?2F1@`skPax73Jz5FvqW!he)xcup65TZ7y zLTKC`pCA*PX>2LZ!*Or*pqvew3Qb56xy)q&o`xG+=|<8kX-FoJWd$Pm1bOd$02NIK zN>H2LY^qG!sETY+1^Hcds#7Jhgy!d_EJe+jpiBk&S84QX*YJRz)l+bPMYJw3TDMoE zq!JhdU=u47Q3O+3OpXdh=;yDuwl=mY-AXo-&9Tjst>X!Xw?T%9F0$pfdI}(zBkDWG z|Cm8i1%g*o1qU5kI+xqWnylq6J31BAm-%y|h?tpWIgWT9I~xkhbL$uZ0`*y;JLZeB z2Go%t7_-v7-j^VLX}eGVXSU}%k8DIgRfnDSXRwe*?+S zfHtA4vQ(&hi!MiTZ{FJNdJChMIX8#QgL%yqUPpLR`HbeKDGREbunjOs!8({4ElY~pRj>besB?UPAb0sW7QMj(#CjkHsncj6v`CB}BE z20AuuQ0%+MmwO??kM{3-jfX&g9qB@U!gTykD`$-?@RWrp9C59Nw zO~xk1ye=Gw43rMi&)(uTb4H+vB*tRc7LOP;TA_dyWG)uGw%&jKug6{lN_}n5itVoC z*f$>h_|@9!<^2nTPtGr2nGSwG_3W{e%fJ2L{lT(69E{hN-!2Bl+Va&82VV@9zkM`# GdHo;xME(c> delta 5998 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 E4^*rJ9{>OV diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index 9ce25c2a6d38c08d6d4aacd48961bed27afc9e64..747801322d2c2a4295034cea926c0162a42dfa37 100644 GIT binary patch delta 3013 zcmZuz&x;&I6lQn)Ee63uJU&H9b~4lK<`=k%uqGoUgd`-3UKF~zyJn}@?y9z`dS?g0 zj$Xubgj^K_FP=mgK}7Hm5O1E%&3_;u2>QLM?%7>6EM&U7>iziM_rCZ3{P@%_e>`{Z zyR}~*Ew4Ymo`17^@B4MJ_TciT>n}XG_2k+U%j-X^KU#0o#;TNzE*GzppxkJ0MefPU za-sAdK9JDH&!nYvYHTHZN>$@LhV zkh(B-yk|u{OT0^Aw4l_=51whH^Rn3S>Bjw+zC8V)%GRD*KK;}BXTD|2S4>q4E6L9d z9Z2gG;&I+0GDq2h?o4h>cJ5B-_MOR<$<@itE0cuoNZ(lP=#9-0;qT2&Lfn*!lro85 zazyfjMwH~uh&|w&n0ihxOGo4kQli3|+O_ORsJ)pdrM;na$6B#SYHPgli&`emSf3`a z!De=mbRnmrDLtj()7g!bl%t)ySFT+fg?z(LvO@1WN;i{>$!owNGY7zjpDtcb>EXT4 z32>tlvvyH4?p^`Q5kOUr{Ybs1v}D6TI^Ig?8ZMLwJ(_0xOF(atWZLL`ZRR=xF7yHw zt}3MHW}@>gf$IYG7A4z9-KB5w0!E%dp$Wd6Yrp!oAnPyTHNNyh% zkxSrZUxDF}YhY|4nI3$6_tx&tjevY%@}`m+L_=a-UP4m?QuZInD>=>p14!+V%AEb< zS6&L|$V@r#tl4Ay5NRgz{bV?#YdU(w0TMNLf$-qyHcs;e+Dd3RygL)13r_F!D6orR z2f-MnsAVxEHD&JM3nDYMZ()ZkcmP@gF%5|P*h#d5Kptcpa6E=Z_}7;toQwv#`{UZm z&Q)*e#P@Sqqsk&pXu?E5&HzuAPPT!JkuD(@ctNNrdw~G(--=ryK9W`K7hQ9PtDNI7 zlDYE_2)OT=Nsiu{+_?2F1@`skPax73Jz5FvqW!he)xcup65TZ7y zLTKC`pCA*PX>2LZ!*Or*pqvew3Qb56xy)q&o`xG+=|<8kX-FoJWd$Pm1bOd$02NIK zN>H2LY^qG!sETY+1^Hcds#7Jhgy!d_EJe+jpiBk&S84QX*YJRz)l+bPMYJw3TDMoE zq!JhdU=u47Q3O+3OpXdh=;yDuwl=mY-AXo-&9Tjst>X!Xw?T%9F0$pfdI}(zBkDWG z|Cm8i1%g*o1qU5kI+xqWnylq6J31BAm-%y|h?tpWIgWT9I~xkhbL$uZ0`*y;JLZeB z2Go%t7_-v7-j^VLX}eGVXSU}%k8DIgRfnDSXRwe*?+S zfHtA4vQ(&hi!MiTZ{FJNdJChMIX8#QgL%yqUPpLR`HbeKDGREbunjOs!8({4ElY~pRj>besB?UPAb0sW7QMj(#CjkHsncj6v`CB}BE z20AuuQ0%+MmwO??kM{3-jfX&g9qB@U!gTykD`$-?@RWrp9C59Nw zO~xk1ye=Gw43rMi&)(uTb4H+vB*tRc7LOP;TA_dyWG)uGw%&jKug6{lN_}n5itVoC z*f$>h_|@9!<^2nTPtGr2nGSwG_3W{e%fJ2L{lT(69E{hN-!2Bl+Va&82VV@9zkM`# GdHo;xME(c> delta 5998 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 E4^*rJ9{>OV diff --git a/ts/dist/index.js b/ts/dist/index.js index baf8e97b2d87db9a5a6a4dc682930df485a2e45a..3783524f75ffd040a93c48859c241234f1f039f6 100644 GIT binary patch delta 3947 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 cmgYcIRyj&K+|cG$L(VOvX+{*<0{RLAZ} z7mcK&n+7KNg0pfAq_TonJ21A0Q?v{m-j9#W&7D4X=7G66yg!Vm7cLUmnY4)J5ZNf^ za>{5&sct5jGY!Jcz)^KeWiaJ$)ofeO81nyMIE0yvCi8h>Ww?1;)&7hIIrc3M253N_!3$1&d}3X-UA|fBNyzx-%pMoQNc1D(^)AHHLKavOU7x- zED+0CQIlFO*I;Ly#S;gU2|Pk?c?I?CcFfZ`O*hV%OENJWODA??M3u<3p3PM`I9XYC zcUN9^|FXFOtMxB^|Ft%$!Oo&)>Doe$*lJ!Y$nv6s#|{Iam8aggx5IZkz8I?f_>H^U z6Qh80Nm16(n+F2)!yN%}dciCjDLjVjFfQmuN;fh91Ll8RBkm9@uNqpOBtSW<8L1qp zD)@(Rz4h}yyuGaqE_w92-?If1%4LW`}Un%omCdnaE@qDr~Eg z@SXN^XnXn%n8;?)QMgixs(t7#mv*8TdIlObAjplp1}0-WzF2okQQX%hbRC7ECJP*C zzlH(mT4fWJtwM!Dy?M){?ic&eXxkPhmkm$m{$?*a67WQhFH-iSg0BU!vF1Qu389Fm zwBRfKHH7YtpcvJrP!GMFKq8eB==!D+%&f=UPZH>l0xU-fI_7C@r{gJvs|s}OpMjpr z>9@Dgatgh=QL&v?2>L-y`(zqDE!9GEvN~i}v$Q-!#(yvd$cjS$oJRe3e~O-YNwsr& zlE_iWUpRxlvc0jzxbK$G=%(RSpLZA#Hf#qQRdVufUvCf?8*$w_&pC+;x-nWAS={B` z@iclkP+992hZ}jlaVsOWoz$QQTXtMes5ML3QF`%Nv@6KA)8}6jx?T5K^rSezy^<~7 zC>lx`2z7>}{U*2yE(6akl7@B%HBXl=qXS#2-H@%(S1zO7oxCCPmIzj#xp5h}H!in5 z-VGSUueOacD1t!w?_6zDQ0$raueQ1GUk!XPSk9T5es-xg-=`2ofgClVt(!SHne`gG zm%5~xA}o7Zl|7v{b;Fr(oFVMC1$EUTg`5T@7@oDl&~gC~OH#@WV#603l2Vt(XA7%GlO_0kG{-*+?DJ^4 zln7^vA?zLDldutg|7^wil0Az$%>(GZRu3SstrFw*k9K?Y@ABVI7`4r4F@jZBe%OiO2UhGSvkqF)cx)J=u6IIy& z$k_e}Bs4Y*vBeHC(EZA5iY4xy){mp$W5X1!+mp6=46=Q;tmk1AZRA!S5)i&1&!$!f zN=iTbj@7@<`oBk7zUA?eS#>sbKpBUpN1u@62l^9Ai;mJ4R>?9+!g0)Bo(x=Ok?=9U z8bDgh*?idZniNmO7@jKT7lSmi$K5H(E|wuHpC7x(>38B7Y+{73m4J$Z8h-_7Y{|a zcqAXcH^I?eH1!m~39!pG)k8NkJ6tu(E-3om7}`Yt_F|xY8|#$Er_Y?4I6HUr!3U0> zn4w?)QSgp3WH~OS0Oo!NV3tA{E~+d^UK+u50>A>)@p1v)x=0E-v1Z`Ond$yRLy;(C zp}G~95+QD_Xz9&%CKh1EgL&+nrMKECYyKKOcVZi)n>ymY{8amzU@vKJL(lfG#PZ@FJEh4L{#4n3Od9m)lkcEA$3KOju+HgFMKs;ghW7Z}pqp2P{$0ROXxv_h zD9!sD8Iwx%u@k{w`uUR3<1VcVzj(@B{8-o_(%%Ke5bY4eVR~SzxRuTbqCy|qBJQBC z3F3bGq#*9BEN$y_KM=%s+S{4m?Ef(I9|5tCR=UJn`xn!N$rKwxOj~zMYei1$B$x93 z(;+c25E4dNNn-DGG!zu?bgy@b-xS=h?G|6!O}{!O4&4sj1kzNk;HEA}|1bs)b>1s> zQR%RF7|pvsKP>*b%Uzoij|4iKhA#KR&x&sc-JhNp9}aXi_bS`n`KTB}+cq>Y_jp{) YqK^9M?zd*emjYcaqn>+WUc9I6KQ%5wd;kCd diff --git a/ts/src/vendor-graph.test.ts b/ts/src/vendor-graph.test.ts index 0ecf502..be55a1b 100644 --- a/ts/src/vendor-graph.test.ts +++ b/ts/src/vendor-graph.test.ts @@ -5,7 +5,7 @@ import { HellGraphStore } from './store' import { loadKkoIntoAtomSpace } from './kko' import { ingestVendorFreshness, stalenessOf, blastRadiusOf, contractCrossingRiskOf, - proposeRevendor, analyzeVendorFreshness, vendorPinIds, vfpId, + proposeRevendor, analyzeVendorFreshness, vendorPinIds, vfpId, compareVersions, VFP, VFP_EDGE, VFP_KKO_TYPES, VFP_NS, EFFECT_REQUEST_CONTRACT, EFFECT_REQUEST_SCHEMA, } from './vendor-graph' import { validateAgainst } from './nlq' @@ -568,3 +568,319 @@ test('slug() is linear — the EffectRequest id builder is not a ReDoS', () => { assert.match(p.effectRequest.id, /^urn:srcos:effect:[A-Za-z0-9._~-]+$/) assert.ok(!p.effectRequest.id.endsWith('-'), 'trailing separators trimmed') }) + +// ─── Version precedence ──────────────────────────────────────────────────────── + +/** + * A repository can produce more than one vendored artifact — this one demonstrably does: the + * package description calls the npm engine the "polyglot sibling of the Rust hellgraph crate", and + * the register keys release history by `source_id` while blast radius is keyed by `repo`. So the + * head set `newestReleasedVersion` picks from is a set of *several* artifacts, one per source, and + * something has to rank them. + * + * Ranking them with `.sort()` is ASCII ranking, and ASCII puts `0.4.9` above `0.4.46`. That is not + * a latent edge case at the time of writing: this engine is at 0.4.46, so every repository past a + * two-digit patch resolves to the wrong "newest", and the wrong version is then handed to + * `blastRadiusOf` as `proposedVersion` — the blast-radius report names a release that is not the + * one anybody would cut. + * + * The four versions below are chosen so a single-digit-patch fixture cannot pass in place of this + * one: ASCII order is 0.4.10, 0.4.46, 0.4.5, 0.4.9 (picking 0.4.9), release order is 0.4.5, 0.4.9, + * 0.4.10, 0.4.46 (picking 0.4.46). They disagree on both the maximum and the whole ordering. + */ +const POLYGLOT = { + manifest_id: 'vendor-freshness-polyglot', + policy: { default_freshness_policy: 'track-latest' }, + sources: [ + { source_id: 'hellgraph-npm', repo: 'SocioProphet/hellgraph', artifact_kind: 'npm-tarball', releases: [{ version: '0.4.5' }] }, + { source_id: 'hellgraph-crate', repo: 'SocioProphet/hellgraph', artifact_kind: 'cargo-crate', releases: [{ version: '0.4.9' }] }, + { source_id: 'hellgraph-wheel', repo: 'SocioProphet/hellgraph', artifact_kind: 'python-wheel', releases: [{ version: '0.4.10' }] }, + { source_id: 'hellgraph-oci', repo: 'SocioProphet/hellgraph', artifact_kind: 'oci-image', releases: [{ version: '0.4.46' }] }, + ], + artifacts: [ + { + artifact_id: 'hellgraph-npm@service', + source_id: 'hellgraph-npm', + consumer_repo: 'SocioProphet/prophet-platform', + consumer_app: 'apps/hellgraph-service', + vendored_version: '0.4.5', + freshness_policy: 'track-latest', + disposition: 'observation-required', + }, + ], +} + +/** + * The comparator's documented rules, pinned one at a time. + * + * `newestReleasedVersion` only ever asks for the maximum, so almost none of this is exercised by + * the graph tests — and an ordering rule that nothing asserts is an ordering rule that the next + * edit is free to change. The docstring on `compareVersions` is the specification; this is the + * enforcement of it. In particular the prerelease case is here because ASCII on the whole suffix + * would rank `rc.10` below `rc.2` and quietly reintroduce the defect one level down. + */ +test('compareVersions implements the documented total order', () => { + const lt = (a: string, b: string): void => { + assert.ok(compareVersions(a, b) < 0, `${a} should precede ${b}`) + assert.ok(compareVersions(b, a) > 0, `${b} should follow ${a}`) + } + const eq = (a: string, b: string): void => { + assert.equal(compareVersions(a, b), 0, `${a} and ${b} should be equal in precedence`) + } + + // (2) numeric components, not ASCII — the bug this comparator exists for. + lt('0.4.5', '0.4.9') + lt('0.4.9', '0.4.10') + lt('0.4.10', '0.4.46') + lt('0.9.0', '0.10.0') + lt('2.0.0', '10.0.0') + + // (2) a missing component is 0. + lt('0.4', '0.4.1') + // ...so these name the same release — but (6) the order is TOTAL, so they are still ordered, + // by raw text, rather than tying. A tie would let the sort's output depend on input order, and + // this sort's result is sealed. + lt('0.4', '0.4.0') + lt('1', '1.0.0') + assert.notEqual(compareVersions('0.4', '0.4.0'), 0, 'same release, still not a tie') + + // (1) non-releases sort below every release, and by ASCII between themselves. + lt('unknown', '0.0.0') + lt('f3a9c21', '0.0.0') + lt('', '0.0.0') + lt('a1b2c3d', 'f3a9c21') + + // (3) a prerelease precedes the release it leads to. + lt('1.0.0-rc.1', '1.0.0') + lt('0.4.46-alpha', '0.4.46') + + // (4) semver §11 between prereleases: numeric identifiers numerically, and BELOW alphanumeric. + lt('1.0.0-rc.2', '1.0.0-rc.10') + lt('1.0.0-alpha', '1.0.0-beta') + lt('1.0.0-1', '1.0.0-alpha') + lt('1.0.0-rc.1', '1.0.0-rc.1.1') + + // (5) build metadata is not part of precedence: it does not make 1.0.0+a newer than 1.0.0. + lt('1.0.0-rc.1', '1.0.0+a') + // ...but (6) the order stays TOTAL: differing only in build metadata still ranks deterministically. + assert.notEqual(compareVersions('1.0.0+a', '1.0.0+b'), 0, 'total order — no unresolved ties') + assert.equal( + compareVersions('1.0.0+a', '1.0.0+b') + compareVersions('1.0.0+b', '1.0.0+a'), 0, + 'and it is antisymmetric', + ) + + // (6) zero is returned for identical strings, and — the contract — for nothing else. + eq('1.0.0', '1.0.0') + eq('unknown', 'unknown') + eq('v1.2.3', 'v1.2.3') + + // A `v` prefix is accepted and does not change the release. + lt('v1.2.3', 'v1.2.4') + lt('v1.2.3', '1.2.4') + + // The whole point, as a sort: the four-version case orders correctly end to end. + assert.deepEqual( + ['0.4.10', '0.4.46', '0.4.5', '0.4.9'].sort(compareVersions), + ['0.4.5', '0.4.9', '0.4.10', '0.4.46'], + ) + // ...which is exactly what the default sort does NOT do. + assert.deepEqual(['0.4.10', '0.4.46', '0.4.5', '0.4.9'].sort(), ['0.4.10', '0.4.46', '0.4.5', '0.4.9']) +}) + +/** + * The register is attacker-influenced text and `slug()` in this same file already earned a CodeQL + * `js/polynomial-redos` finding on it, so the version parser is a hand-written linear scan rather + * than a nested-quantifier regex. This is the assertion that keeps it that way. + */ +test('compareVersions is linear — the version parser is not a ReDoS', () => { + const pathological = '9'.repeat(200_000) + 'x' + const t0 = process.hrtime.bigint() + for (let i = 0; i < 50; i++) compareVersions(pathological, '0.4.46') + const ms = Number(process.hrtime.bigint() - t0) / 1e6 + + assert.ok(ms < 5_000, `version parsing went superlinear: ${ms.toFixed(0)} ms`) + // ...and it still gets the answer right: a 200k-digit non-release is not newer than 0.4.46. + assert.ok(compareVersions(pathological, '0.4.46') < 0) +}) + +test('the newest release is the newest by VERSION, not by ASCII — 0.4.46 outranks 0.4.9', () => { + const store = new HellGraphStore(new AtomSpace('vendor-graph-semver', false)) + ingestVendorFreshness(store, POLYGLOT) + + const a = analyzeVendorFreshness(store) + const report = a.blastRadius.find((b) => b.repository === 'SocioProphet/hellgraph') + assert.ok(report, 'the producing repository has a blast-radius report') + + // The whole point of the report: "what breaks if I cut the next one?" answered about the release + // that would actually be cut. `.sort()` answers it about 0.4.9, a release five patches behind. + assert.equal(report.proposedVersion, '0.4.46') +}) + +/** + * The same defect, one step worse, and reachable from the register as it is written today. + * + * A pin may name a version the source's release history does not list — the ingest says so in + * as many words and builds the artifact anyway, with no supersession chain. Such an artifact is a + * HEAD (nothing supersedes it), so it lands in the same candidate set. A `vendored_commit` pin + * therefore puts a commit sha in there, and an entry with neither version nor commit puts the + * literal string `unknown` in there. + * + * Under ASCII both outrank every real release, because letters sort above digits. The blast-radius + * report then proposes cutting `f3a9c21` — or `unknown`. Non-numeric versions are not releases and + * must not be ranked as though they were. + */ +test('a commit sha and the literal unknown are not "newer" than every release', () => { + const store = new HellGraphStore(new AtomSpace('vendor-graph-nonnumeric', false)) + ingestVendorFreshness(store, { + ...POLYGLOT, + artifacts: [ + ...POLYGLOT.artifacts, + { + artifact_id: 'hellgraph-crate@gateway', + source_id: 'hellgraph-crate', + consumer_repo: 'SocioProphet/prophet-platform', + consumer_app: 'apps/compute-gateway', + vendored_commit: 'f3a9c21', + disposition: 'observation-required', + }, + { + artifact_id: 'hellgraph-wheel@warden', + source_id: 'hellgraph-wheel', + consumer_repo: 'SocioProphet/prophet-platform', + consumer_app: 'apps/lifecycle-warden', + disposition: 'observation-required', + }, + ], + }) + + // Both non-numeric heads really are in the graph — otherwise this test proves nothing. + const heads = store.nodesByLabel(VFP.Artifact) + .filter((n) => store.out(n.id, VFP_EDGE.supersededBy).length === 0) + .map((n) => n.properties['version']) + assert.ok(heads.includes('f3a9c21'), 'the commit-sha artifact is a head') + assert.ok(heads.includes('unknown'), 'the version-less artifact is a head') + + const a = analyzeVendorFreshness(store) + const report = a.blastRadius.find((b) => b.repository === 'SocioProphet/hellgraph') + assert.equal(report?.proposedVersion, '0.4.46') +}) + +// ─── Forked supersession ─────────────────────────────────────────────────────── + +/** + * The register can fork a release history, and does it the ordinary way: two entries for the same + * `source_id` — a re-observation, or a backport branch enumerated separately — whose release lists + * share an ancestor. `0.4.40` then has TWO outgoing `vfp:supersededBy` edges. + * + * `supersessionChain` resolves that fork by taking the LOWEST NEXT NODE ID, which is a deliberate + * choice: it is replayable, so the sealed receipt is reproducible from the graph alone. It is not + * the longest path, and the docstring that claimed it was has been corrected. + * + * The branches below are built so the two rules give different answers and the difference is + * visible in the verdict: the low-id branch (0.4.41) is a dead end at distance 1, the high-id + * branch (0.4.42 → 0.4.43) runs to distance 2. A future "improvement" to longest-path would change + * `releaseDistance`, `latestVersion` and therefore the seal — so it breaks here rather than + * silently rewriting receipts. + */ +const FORKED = { + manifest_id: 'vendor-freshness-forked', + policy: { default_freshness_policy: 'track-latest' }, + sources: [ + { + source_id: 'hellgraph-engine', + repo: 'SocioProphet/hellgraph', + releases: [{ version: '0.4.40' }, { version: '0.4.41' }], + }, + { + source_id: 'hellgraph-engine', + repo: 'SocioProphet/hellgraph', + releases: [{ version: '0.4.40' }, { version: '0.4.42' }, { version: '0.4.43' }], + }, + ], + artifacts: [ + { + artifact_id: 'hellgraph-engine@service', + source_id: 'hellgraph-engine', + consumer_repo: 'SocioProphet/prophet-platform', + consumer_app: 'apps/hellgraph-service', + vendored_version: '0.4.40', + freshness_policy: 'track-latest', + disposition: 'remediation-required', + }, + ], +} + +test('a forked release history follows the LOWEST NEXT NODE ID, not the longest path', () => { + const store = new HellGraphStore(new AtomSpace('vendor-graph-fork', false)) + ingestVendorFreshness(store, FORKED) + + // The fork is real: 0.4.40 is superseded by two different artifacts. + const forkPoint = vfpId.artifact('hellgraph-engine', '0.4.40') + assert.deepEqual( + store.out(forkPoint, VFP_EDGE.supersededBy).map((n) => n.id).sort(), + [vfpId.artifact('hellgraph-engine', '0.4.41'), vfpId.artifact('hellgraph-engine', '0.4.42')], + ) + + const v = stalenessOf(store, vfpId.pin('hellgraph-engine@service')) + + // Lowest id wins, so the chain is the SHORT branch. Longest-path would give ['0.4.42','0.4.43']. + assert.deepEqual(v.intervening.map((i) => i.version), ['0.4.41']) + assert.equal(v.releaseDistance, 1) + assert.equal(v.latestVersion, '0.4.41') + + // Deterministic: the same graph replays to the same chain, which is why the receipt is sealable. + const again = stalenessOf(store, vfpId.pin('hellgraph-engine@service')) + assert.deepEqual(again.intervening, v.intervening) +}) + +/** + * `stats.releases` was the one counter not gated by a `seen*` set, so the fork fixture above — five + * release lines naming four distinct versions, because `0.4.40` is declared by both source + * entries — reported five releases where the graph holds four `vfp:Release` nodes. Every other + * counter reports nodes created; this one reported manifest lines read. Two different questions + * under one name, and the caller cannot tell which answer it got. + */ +test('ingest stats count NODES, not manifest lines — a repeated release is counted once', () => { + const store = new HellGraphStore(new AtomSpace('vendor-graph-stats', false)) + const stats = ingestVendorFreshness(store, FORKED) + + // Content-addressed: both `0.4.40` lines intern to the same vfp:Release atom. + assert.equal(store.nodesByLabel(VFP.Release).length, 4, '0.4.40, 0.4.41, 0.4.42, 0.4.43') + assert.equal(stats.releases, 4, 'the repeated 0.4.40 is counted once, as the artifact would be') + assert.equal(stats.releases, store.nodesByLabel(VFP.Release).length, 'stats.releases matches the graph') + + // The guard must not suppress genuinely distinct releases: same version, different source. + assert.equal(stats.artifacts, 4, 'artifacts were already gated this way — the two agree now') +}) + +/** + * The pinned-artifact branch of the ingest built its `vfp:producedBy` edge straight from the + * matched source's `repo` field, with no check that the field was there. A source entry missing + * `repo` is skipped by the source loop — no repository node is ever created for it — but the + * artifact loop still found it with `sources.find`, so the edge was built to `vfp:repo/`: an edge + * to an empty id, pointing at a node that does not exist. + * + * That is a dangling edge in a graph whose whole claim is that its receipts are derivable from it, + * and `store.in(vfpId.repository(''), ...)` is a live query someone can run. + */ +test('a source with no repo produces no edge to an empty repository id', () => { + const store = new HellGraphStore(new AtomSpace('vendor-graph-emptyrepo', false)) + ingestVendorFreshness(store, { + manifest_id: 'vendor-freshness-norepo', + sources: [{ source_id: 'orphan', releases: [{ version: '0.1.0' }] }], + artifacts: [ + { + artifact_id: 'orphan@service', + source_id: 'orphan', + consumer_repo: 'SocioProphet/prophet-platform', + consumer_app: 'apps/hellgraph-service', + vendored_version: '0.1.0', + disposition: 'observation-required', + }, + ], + }) + + const emptyRepo = vfpId.repository('') + assert.equal(store.getNode(emptyRepo), undefined, 'no node was created for the empty repository id') + assert.deepEqual(store.in(emptyRepo, VFP_EDGE.producedBy), [], 'and nothing points at it') +}) diff --git a/ts/src/vendor-graph.ts b/ts/src/vendor-graph.ts index 8e51f7e..04defd8 100644 --- a/ts/src/vendor-graph.ts +++ b/ts/src/vendor-graph.ts @@ -367,6 +367,7 @@ export function ingestVendorFreshness( const seenArtifact = new Set() const seenApp = new Set() const seenContract = new Set() + const seenRelease = new Set() const ensureRepo = (repo: string, url?: string, workspaceBinding?: string): string => { const id = vfpId.repository(repo) @@ -410,12 +411,21 @@ export function ingestVendorFreshness( stats.artifacts++ } + // Gated like every other counter. `stats.releases++` was unguarded, so it counted manifest + // LINES while `artifacts`, `contracts`, `repositories` and `consumerApps` counted NODES + // CREATED — and a register that declares the same release twice (two entries for one + // `source_id`, the ordinary way a fork is written) made the two disagree with no way for a + // caller to tell which question it had asked. First declaration wins, as it already does for + // artifacts; the `releasedAs` edge is still drawn either way. const releaseId = vfpId.release(sourceId, version) - const rProps: Record = { version, sourceId } - put(rProps, 'observedAt', field(r, 'observedAt')) - store.addNode(releaseId, [VFP.Release], rProps) + if (!seenRelease.has(releaseId)) { + seenRelease.add(releaseId) + const rProps: Record = { version, sourceId } + put(rProps, 'observedAt', field(r, 'observedAt')) + store.addNode(releaseId, [VFP.Release], rProps) + stats.releases++ + } store.addEdge(VFP_EDGE.releasedAs, artifactId, releaseId) - stats.releases++ // Contract changes: DECLARED on the release, and mirrored onto the artifact as a // `changesContract` property so the risk question never has to guess from version numbers. @@ -473,8 +483,17 @@ export function ingestVendorFreshness( const aProps: Record = { artifactId: `${sourceId}@${version}`, version, sourceId } put(aProps, 'digest', field(a, 'vendoredDigest')) store.addNode(pinnedArtifactId, [VFP.Artifact], aProps) + // Only if the source actually declares a repo. `?? ''` built `vfp:repo/` — an edge to the + // empty repository id, whose target node the source loop never created because it skips + // sources with no `repo`. A dangling edge is worse here than a missing one: `store.in()` on + // that id is a query anyone can run, and the graph's claim is that receipts derive from it. + // Routed through `ensureRepo` so the target is guaranteed to exist rather than assumed to. const src = sources.find((s) => field(s, 'sourceId') === sourceId) - if (src) store.addEdge(VFP_EDGE.producedBy, pinnedArtifactId, vfpId.repository(field(src, 'repo') ?? '')) + const srcRepo = src ? field(src, 'repo') ?? '' : '' + if (src && srcRepo) { + const producerId = ensureRepo(srcRepo, field(src, 'url'), field(src, 'workspaceBinding')) + store.addEdge(VFP_EDGE.producedBy, pinnedArtifactId, producerId) + } stats.artifacts++ } else if (field(a, 'vendoredDigest')) { store.setNodeProperty(pinnedArtifactId, 'digest', field(a, 'vendoredDigest')!) @@ -509,11 +528,154 @@ export function ingestVendorFreshness( return stats } +// ─── Version precedence ───────────────────────────────────────────────────────── + +/** + * Compare two version strings by RELEASE PRECEDENCE. Returns <0, 0, >0 for `a` before, equal to, + * or after `b`, so it drops straight into `Array.prototype.sort`. + * + * The default `.sort()` is ASCII, and ASCII ranks `0.4.9` above `0.4.46` — not a hypothetical, this + * engine is at 0.4.46. It also ranks the literal `unknown` and any commit sha above every real + * release, because letters sort above digits, and this graph holds both: a pin may name a version + * the release history does not list, and that artifact is a head like any other. + * + * **The ordering is TOTAL and documented, because an undocumented tie-break is how this class of + * bug comes back.** In order of application: + * + * 1. **Shape.** A version is *release-shaped* if it is an optional `v`, then dot-separated runs of + * digits, then an optional `-prerelease`, then an optional `+build`. Everything else — commit + * shas, dates, `unknown`, the empty string — is **not a release** and sorts BELOW every + * release-shaped version. Two non-release strings compare by ASCII between themselves. This is + * the rule that stops `unknown` from being proposed as the next release to cut. + * 2. **Release components, numerically**, left to right. A missing component is `0`, so `0.4` and + * `0.4.0` name the same release (and `0.4` precedes `0.4.1`). Compared as digit strings rather + * than via `Number`, so precision does not silently collapse two distinct versions into a tie. + * 3. **Prerelease loses to the release** (semver §11): `1.0.0-rc.1` precedes `1.0.0`. + * 4. **Between two prereleases**, semver §11 identifier precedence: dot-separated identifiers + * left to right; all-digit identifiers compare numerically and rank BELOW alphanumeric ones; + * alphanumeric identifiers compare by ASCII; a prefix ranks below a longer identifier list, so + * `rc.1` precedes `rc.1.1`. Deliberately NOT ASCII on the whole suffix, which would put + * `rc.10` below `rc.2` and reintroduce the very defect this function exists to remove. + * 5. **Build metadata is ignored** for precedence (semver §10). + * 6. **Final tie-break: ASCII on the raw string**, so **`compareVersions` returns `0` only for + * two identical strings.** Versions that name the same release but are written differently — + * `0.4` and `0.4.0`, `1.0.0` and `v1.0.0`, `1.0.0+a` and `1.0.0+b` — tie under rules 1–5 and + * are then ordered by their raw text. That is the deliberate choice: an unresolved tie makes a + * sort's output depend on the input order, and the result of this sort is sealed into a + * receipt that has to be re-derivable from the graph alone. Equal precedence, ordered anyway. + */ +export function compareVersions(a: string, b: string): number { + const ascii = (x: string, y: string): number => (x < y ? -1 : x > y ? 1 : 0) + const pa = parseVersion(a) + const pb = parseVersion(b) + + // (1) Non-releases sort below every release. + if (pa === undefined || pb === undefined) { + if (pa === undefined && pb === undefined) return ascii(a, b) + return pa === undefined ? -1 : 1 + } + + // (2) Release components, numerically, missing ⇒ 0. + const components = Math.max(pa.release.length, pb.release.length) + for (let i = 0; i < components; i++) { + const d = compareNumericIds(pa.release[i] ?? '0', pb.release[i] ?? '0') + if (d !== 0) return d + } + + // (3) Same release: a prerelease ranks below the release it leads to. + if (pa.prerelease === undefined || pb.prerelease === undefined) { + if (pa.prerelease === undefined && pb.prerelease === undefined) return ascii(a, b) // (5)(6) + return pa.prerelease === undefined ? 1 : -1 + } + + // (4) Both prereleases: semver §11 identifier precedence. + const ids = Math.max(pa.prerelease.length, pb.prerelease.length) + for (let i = 0; i < ids; i++) { + const x = pa.prerelease[i] + const y = pb.prerelease[i] + if (x === undefined) return -1 // a shorter identifier list ranks below a longer one + if (y === undefined) return 1 + const xNum = isDigits(x) + const yNum = isDigits(y) + if (xNum !== yNum) return xNum ? -1 : 1 // numeric identifiers rank below alphanumeric + const d = xNum ? compareNumericIds(x, y) : ascii(x, y) + if (d !== 0) return d + } + return ascii(a, b) // (6) +} + +/** A version split into the parts that decide precedence. `prerelease` absent ⇒ a plain release. */ +interface ParsedVersion { + /** Dot-separated numeric components, kept as digit strings so comparison stays exact. */ + release: string[] + prerelease: string[] | undefined +} + +const isDigits = (s: string): boolean => { + if (s.length === 0) return false + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i) + if (c < 48 || c > 57) return false + } + return true +} + +/** Compare two all-digit identifiers exactly: leading zeros dropped, then length, then ASCII. */ +function compareNumericIds(x: string, y: string): number { + let i = 0 + let j = 0 + while (i < x.length - 1 && x.charCodeAt(i) === 48) i++ + while (j < y.length - 1 && y.charCodeAt(j) === 48) j++ + const sx = x.slice(i) + const sy = y.slice(j) + if (sx.length !== sy.length) return sx.length < sy.length ? -1 : 1 + return sx < sy ? -1 : sx > sy ? 1 : 0 +} + +/** + * Parse a version into release components and prerelease identifiers, or `undefined` if it is not + * release-shaped. + * + * Hand-scanned rather than matched with a regex, deliberately: these strings come from the vendor + * register, `slug()` in this same file already had a CodeQL `js/polynomial-redos` finding on a + * register-supplied value, and a nested-quantifier version pattern is the textbook way to earn a + * second one. This scan is linear and cannot backtrack. + */ +function parseVersion(raw: string): ParsedVersion | undefined { + let s = raw + if (s.charCodeAt(0) === 118 /* v */) s = s.slice(1) + const plus = s.indexOf('+') + if (plus !== -1) s = s.slice(0, plus) // build metadata is not part of precedence + const dash = s.indexOf('-') + const core = dash === -1 ? s : s.slice(0, dash) + const pre = dash === -1 ? undefined : s.slice(dash + 1) + if (core.length === 0) return undefined + + const release = core.split('.') + for (const component of release) if (!isDigits(component)) return undefined + + if (pre === undefined) return { release, prerelease: undefined } + const prerelease = pre.split('.') + // An empty identifier (`1.0.0-`, `1.0.0-a..b`) is not a legal prerelease, so the string is not + // release-shaped at all — better a documented non-release than a silent partial parse. + for (const id of prerelease) if (id.length === 0) return undefined + return { release, prerelease } +} + // ─── Graph readers (pure) ─────────────────────────────────────────────────────── const str = (v: PropertyValue | undefined): string | undefined => (typeof v === 'string' ? v : undefined) -/** Walk `vfp:supersededBy` forward from `artifactNodeId`, longest path, cycle-safe. */ +/** + * Walk `vfp:supersededBy` forward from `artifactNodeId`, cycle-safe. + * + * On a fork this takes the LOWEST NEXT NODE ID — not the longest path, which is what this + * docstring used to claim while the code two lines below did, and said it did, something else. + * Lowest-id is the correct rule and the deliberate one: the walk has to be replayable from the + * graph alone or the receipt it feeds cannot be re-derived, and "longest" is not decidable in one + * forward pass anyway. The consequence is that the chain is the deterministic branch, not + * necessarily the deepest one, and `releaseDistance` counts hops along it. + */ function supersessionChain(store: HellGraphStore, artifactNodeId: string): string[] { const chain: string[] = [] const seen = new Set([artifactNodeId]) @@ -1049,12 +1211,21 @@ export function analyzeVendorFreshness(store: HellGraphStore, opts: AnalyzeOptio }) } -/** The newest version this repository has released, per the graph. Read-only. */ +/** + * The newest version this repository has released, per the graph. Read-only. + * + * A repository can produce several artifacts — this one produces an npm package and a Rust crate — + * so the head set routinely holds more than one candidate and they have to be RANKED. Ranked by + * `compareVersions`, never by `.sort()`: ASCII put `0.4.9` ahead of `0.4.46`, and put a pin's + * commit sha or the literal `unknown` ahead of every real release. This value is handed to + * `blastRadiusOf` as `proposedVersion`, so getting it wrong misnames the release in every + * blast-radius report the analysis seals. + */ function newestReleasedVersion(store: HellGraphStore, repository: string): string { const repoNodeId = vfpId.repository(repository) const produced = store.in(repoNodeId, VFP_EDGE.producedBy) // The newest artifact is the one nothing supersedes. const heads = produced.filter((a) => store.out(a.id, VFP_EDGE.supersededBy).length === 0) - const versions = heads.map((a) => str(a.properties['version']) ?? '').filter(Boolean).sort() + const versions = heads.map((a) => str(a.properties['version']) ?? '').filter(Boolean).sort(compareVersions) return versions[versions.length - 1] ?? 'unknown' } From 8043934975f9edaaf9f13cae2a9a318eafc1d590 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:23:07 -0400 Subject: [PATCH 2/2] build: regenerate dist after rebase onto main --- ts/dist/index.d.mts | Bin 226510 -> 232421 bytes ts/dist/index.d.ts | Bin 226510 -> 232421 bytes ts/dist/index.js | Bin 427276 -> 431113 bytes ts/dist/index.mjs | Bin 418416 -> 422119 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 747801322d2c2a4295034cea926c0162a42dfa37..0d4b92c2db743820fb1710fc32a17034868d15c6 100644 GIT binary patch delta 5986 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 delta 223 zcmWN}KT88K7=Uq-;GY&0)WP+wQx)py;^b%-zd(n?+-onGOU~py@g@aDadx<^)ZIl8 zA~-m>i(f)Yzd%>L`T6nuHtXN>wVOBgvq-}wu3ysVoh#NHJn*gNWQ8rK;Rj#vYgq*& z!8=16jdb9IqGvT)P+63tI`^6$kw0;S`1oQ z3(*ilKO%y>M3?z67zb76`xIq6%|Hgc=vEjBvm=tbPrc{urZ{D*sq@Kauj#K8$D3(% VD+Vl0?nK7Y#=V$|^z|rq_&?c=Qda-~ diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index 747801322d2c2a4295034cea926c0162a42dfa37..0d4b92c2db743820fb1710fc32a17034868d15c6 100644 GIT binary patch delta 5986 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 delta 223 zcmWN}KT88K7=Uq-;GY&0)WP+wQx)py;^b%-zd(n?+-onGOU~py@g@aDadx<^)ZIl8 zA~-m>i(f)Yzd%>L`T6nuHtXN>wVOBgvq-}wu3ysVoh#NHJn*gNWQ8rK;Rj#vYgq*& z!8=16jdb9IqGvT)P+63tI`^6$kw0;S`1oQ z3(*ilKO%y>M3?z67zb76`xIq6%|Hgc=vEjBvm=tbPrc{urZ{D*sq@Kauj#K8$D3(% VD+Vl0?nK7Y#=V$|^z|rq_&?c=Qda-~ diff --git a/ts/dist/index.js b/ts/dist/index.js index 3783524f75ffd040a93c48859c241234f1f039f6..10b5ec9ca248d578b973dec4e6a4216284075f0b 100644 GIT binary patch delta 5011 zcmbVQU2NOd6~?tpC2^C*ne94retNC^k%>&n@=p^hj`QcVUJ~1jlWdEu*|c;eF_B1x zq!c@js&43-rb*Ei0(SS*Z2`It*w6+r1_Et?G~1rK4eN#i!B7mtUWNc0S_B)ghjqZt zy`&^ccK1*}h~(k<|IRu0(05+$x%JbYdjs!2(-sP0%M6)DB4jEl(w9^dmTZ}Ase+cU zR9(X)m$#Nq0#%goavR15u?mKUL;La3xw+{J=f>ye@cvL`Cwj76s!JuZx<wNuHW%JlF<6sNt7gQ~&YH7d-^FPiJ1E+GbrsPN*m@|r& z%#tdDYlQQy{PmaHTFT&(Q)$N?T_6JW25x!ch_*2=$dI^jVxN=dV^1< zo~_}+-p81eG9-whLW)Y3+2NkcWHE0*vw;8}8>{^2SDjBV0SB`Zz4s#OwBK2e5bAF9 z9t46oCrMGdxfN}*A8kNoKb`ux)lX&WTSwnN?Dx}G?w}1@^2ec#^nxYl3uZ=)rGxZ) z?Wn6IF3~sI(VmSiS%J|Iq~R^7b8p=B+|8@$aCmSML7Qa%w;es`9Q2YQywwE`C5_Ek zF%H3DHdx}4WRLGf4^ap@IL{$>TOAOu4FDpux6sefpw8>4-7TVM%SJ{QHHT&=_M@Y| zc22TfEhE^XuWdow?T`A;x zU71G@*NtGNI%2=9puY(yLa%-e?Vzuu(LNOUzKV9zKc>;vDii&SicVLS?{A<-GU&Ng z%r#~7p^Cm6s0C_eR7kL5C^?9eKQQz~Nut(e)VJ$%`1C8XnN<@+?1yZ=a~b{cN$=#b zW8X&6b%&}|TC3A)k@5qgJ~uGdVybb`sm;8qMJpeU_SkQ|gud>pd@}4m#2os3QC8e$ zLV>|;m@zdj*ED6K^t0QjrP2S@N_uT)H#xJXX7%o7L>O^ zQZNN!(9jJPbYNq3NV0eIa0Tt$#OEZRl%VydSwXg0X}Q$_+5hY(Em639@DNVPxrB-xAD}4Nb=-_4x?YsiiwPKHaKmb(36CvAtST9X>uVW#m+IVZu->om~vz0~lnX z7LqfTVpw7%hy&pO7jy%!$_@_=4GiHi3}S^3TjNE5M{=xlmJlMBv@atIqLix||c-v094H)3-nN?dS+JYlsJE?+ZeA z*;F*uQm>OUdio;Y9k?KjMup)3k=6tCqG>4XxLGsOI+lc&13PtrT{cKQt3c0%W{pr9 zvOGI$1f;Tuthr<@Al0d{+5GZ}LzH+gF(!$1L3CYDjKwNtq7CKk~yKBg;vanOGJSqGRN*63|S}f zM?5+@iX*I%%6va&oOll&j)RTS~_p>xQ!bfmF1EB1Dpwx^qZl=fOWw%F| z;x$37O~MB8ngfGz34|j9%xH}`#ECu*d~NZPHW5iu(>SXFTDyS{xqO!7V9_SYQ@W8;EHT9|PD#7D^TsyA zzPzeTja|{K$`s2DYTVhMG6=a&#F#A0jf!DgGes#bn>rkXu;Y4L4m^XrnGHS$v2$GT zbH^$76jZQBY4_$I|dk37-O@=luwrah^xVUx-S+46B0(xCNw=G!Hmp%-gsGw??pz?!CaEqIGk zW%Q%%W$ppffbl$!-UQn@rJ97@%U_+Dnv|=j_Y!=!mat+$aEeu8X-SeqRxwDD<-^Jl zwgy>-lCbbX0rF+y$*J@IFKy*7ls7zEh=ltGu~Ikhf@WpFIe;ny%ZAfnW(_72!`HV7 zUP510UG3(+nlPB6bvLnUQTv)O+T{jk0>OcbrHMXBqYnC&TWBL4` z5GH*QxA<~a1nSSpWGvu<gV6~b&Y;Z)PW>PB#pvRB delta 1473 zcmZuxe@q)y9M8M<4p!(QaFm7Nd@BmQlpbYF%%}v2@&|)Cy1_6WWxKWydeXag_pZ3v zRkwtNY+;P02`|QxsENsxkj&tGF-8P6G0vE1OkDiK%|FayVob!iZ2qP1+8P=0{&;!! z{eIr}z0c?So3Y_`gkMDBX-0Hry8=NQWg6xZ>3~WI&^%CrY*<8p) zHa~HS?rGK3)Myf631(gSlPxv*`i&-rXE zd9pbU9?h?O-RQJ$FF1dD5qLS4pWnwpI~kY)OaG5?k-;SRkW}j6{4Oh@F~L+*)v;38 z8|akflOkkQ2Z4vZsVNyJwHWfXN#vXRK%^c6%z5n0ugri@*{)*d?XuI%V|LPES~KjF zhvvYjgQTzVJh^ijRKh~?Z5)oN218;+i^%_;Nx)pbXT6p>7r+^TJm}ybYn(`#&{yo4 zEbARsStgo*IhdM&EXzmG1eVDAi@;x=Qkd7SieDGOoj@wYMpUg(udngm5mO`nOZ;xKG|yLiR0FCd2OK%lf1+5bLeH2O?WM(&Mcma&t&{ zs^+171s57*OnbOO>HM>k|sABCz(msvkyNKdt*Cp9>gTEjqL=Q^~N6W-rXH% zcV;^?>qngJN{A{&LQ1OzU4p7gC@LyJAX5tsw3Sc*sZx=?^5tAK=_O zvri`i;UAvabI(2Zob!F>+%up2pFYuy`V1GKvT3)Av!wep8!F%ZfUsEek|7b%hw!5fuXuJ6cO}aI&~!?JvG% z{i(GItBo&z|9cJIDmzQEuE+~%Vn`V|D+o&>9vTKfi=Th@M2q8gWXV_j@w1#QdGr-Uy{UY;1lq(DsBS;a_1_dyQ|xiVv@f z^hJcyPw{9Srv=ga4MOXvUig{|O(Ob-Z=t6GDp@hX*BU1bjg^}X1Y3_VuO!F_z(D`e zhz{;4tvV<$C0mrY_PS9g>a2AS4Ek_Jv`%}_>z?APJfS{IZD{)?M^lz$!-r#9CYv+KXfnxAVOzzhOu!B+J;{5=&ja(Ah+@wn2e3cQpG7zwBGTeN5~H~ndL}3H4H$P zE1O7c6)F_!&0Y>$U+O~PhHXqPsJ6`d)e&^sWs6KlBpyRqM+;(O&4J$cp@6Nl;3)ms zhYkf$h|1%rgWicE4;7;5QPUu1)0l+5ArD7G_dCI4gGB2UqYYKCl~Y5w5bEm_OZT;R zc6P;!k7L*iJcv)5vTh0?ANB^ke4`U|X>WgDUr!$%!LTj2Wm~Vk4G;3=HD?`+d-Gm) zXF^HhOB0_8&7B`Rcj@fh<M-L}ru24jG+b1d-Wu5s*4!FoNqa=8Hz+G!wZsk()?EBdEAs?Nm>n*OG z^gEASP3_)#Gf^)M?nb)`hO8>4a+6%rl2`duz;{E-lo>P-d?og3fOVN&Gmj0v0x#hV zVYe-)OFGG>Whg=atnP=F3xMceu}~#e9kJ>aE7HhpcI`|o5C5;F`Ts2YeIFhL`>l;%yWTna zbbXd+1~ndW5nZ?5M~z)0&(yCA1a8E}&h1i$7>1lA%%GT`r*ng@&fBe{jaKWZ`|WNT zTy{_M;o4GrNCE2;vYf|({hjC;8}fR7x(jwBce;DewWz_)IdWW69``vZ{B z&;Z01I>|tHDz7G%h<#c&4uX$WQ?z1?8QNLM_Dn&^z$jYHtqdfT#eR zNmJqgC%`V0RZl+7>_EvZyP)WYL#T=V^$l0^4%R8pPfT4NotZmx;oO;v)AZ{7gdVHZVciI0bl{@cqI#8T_g^jSXS}a^hEc`{y-42P}+*~Q6IOK z)0J8~qYE(O!8~?FSL*E)WM>UunHuwgi57-Pxm3W1Og2q2Fcp*dc}>sArjXzdn~n~l zYvN*GKqaMGM%SA%1aYAHL(}Qn37wFeM2JX|RBIT9E!Yy33=J+cD*w{eF-?pbuYA~U z?cAzO&IItS)=ZL=gD8<4lz`(l#&n|JvzxB#?zVy*Dn8n^X20c+0xDS5OsrI5!3(SG zRD~finuJd|7>tCf^+C9gs(#x3LLAZ}T>1haD zN#9f553Db0?hhL--r!DqZP&auAj}G@CiW?+CGZmGuGhXEy^Q_`9|!4D@%jhPv5=eS zhVI@`io0v=)6)~D_O*cUmv#?#)K}#&;WXG?ACDjv An*aa+ delta 1479 zcmZuxU2GIp6waC1nXausVYjuVpqGWVGqgLqRMFVA)FzOCsq!z8?Y5oWy}LVQXLgx8 zTZ_viR-^m`BW4q?4?L6@MS~%wO>?agX%l=QzG!0P#RL+g(HAhj7~|ia-5N`E9&XN@ z^PO|g&v)(*cUte=Xr2CmRc7XY63XuErBCLB=5jsaU#egc2ib)eQ6DP1v3h#(sxSE9 zxNaMIGLMOh%Jldo1S@Y|>)?2fi;pKzU*-C>&GhGk5NP0<7&o{ocSTzuI18Tz#jQ4W zO16c{NYk{WVWkZ#+l}H5woT2_O`PaOIo(R+_e7Tbi`CvGGotCyp>;mR*hu3Dj+ z`yFCpO*hf)#Xf;tx;Q0npcxg}ka;K?XoS%Ec$OFMb%DgAiGI!d?e9lc zDW9i@8=?KtxUT8MgQN;(vE<-^Y*x{`s}8(DUcZ;RL9}9t_c?;E3uB=ZSl!;`l?TAUp5m{BiK>KyAkoGv6Mmef{Ho6q0X#|E}LtIZ-6ay!sEhP zODo!iBDNg^llU=wBEf{M@U*zr`H%j&FcgKg`bs^$8G&e9a-|yeqGYWydXYjCeIp1J z_Hh)BM=H0!3(_5<@GT4J@V!J~KWwDs9CX$Ud!e4@v(O?S_wD`gFuk0Gng7Eq=3t&q z8t__+lQVeRbXzw`yeeCG!co)&RmLFPdY`p$Oe1+Cg_Uh;f=!IUJIx*&+9n}Z*HUAH z$4A3{-Xj7k8WyJk`|vaz5$VDe$wvpz!Kxa2Jse5t7Dtk}kTMf4win=)L!Zr|9`hf{#vL7lNm_#Liz5Z*6Cv-WD%+fIH;(VXVcVc@otE0Dcvf1sPPdT0WI%@x>={*Ktm~}w>*5HfH`;S> z@5s)9L%T)}?Rj<=-=fIyP`dn>8|G)v(s`&V_bxk?A#Et#rS>aFPsY2#iDedbOC(mG zDOoAUC{T1%UQc%p%1wd$ZqXb}9ECcAvP!jAB|mllkXF-=2IU=Dq&PY3J&=yAr<_iL zYE9`yY2CqL$1pK6h&Necq%rcaB&XXrjmGu7!7-c-q?F{FiuhaSL9xXnq&OglZSKn- rUaY58I`F5|#C{u;g8{a2R1W*ua83>bn=<4yu)r~SN@Sl~a!B|a>wEEk