ruvnet-constellation-telemetry 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 rUv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # ruvnet-constellation-telemetry — public package observations and local dashboards
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Collect public package observations only when invoked, then render a standalone HTML dashboard. Joint adoption needs a separate consenting project sample; download correlations cannot establish an attach rate.
4
+
5
+ **Topics:** npm download observations · GitHub activity · consenting adoption samples · offline dashboard.
6
+
7
+ ```sh
8
+ npm install ruvnet-constellation-telemetry@0.1.0
9
+ ```
10
+
11
+ [Package on npm](https://www.npmjs.com/package/ruvnet-constellation-telemetry) · [Core 0.2.0 entrypoint: `ruv-fann/telemetry`](https://github.com/ruvnet/ruv-FANN/tree/main/packages/core#capability-entrypoints) · [Interactive ruv-fann field guide](https://ruvnet.github.io/ruv-FANN/) · [Toolkit API and release guide](https://github.com/ruvnet/ruv-FANN/tree/main/packages/wsp-toolkit#readme)
12
+
13
+ Choose this standalone package for its declared dependency footprint, or use `ruv-fann/telemetry` from the complete `ruv-fann@0.2.0` facade. Package version: **0.1.0**. [Umbrella release and evidence](https://github.com/ruvnet/ruv-FANN/releases/tag/ruv-fann-toolkit-v0.2.0).
14
+
15
+ Dependency-free public package observations, explicitly sampled project attach metrics, and a standalone interactive dashboard. ESM; Node 20.9+; no install hooks, SDKs, provider calls, hidden collection or uploads.
16
+
17
+ Integration sketch: supply a validated constellation `catalog`; the call performs explicit public network reads.
18
+
19
+ ```js
20
+ import { collectPublicMetrics, analyzeConstellation, renderConstellationDashboard }
21
+ from 'ruvnet-constellation-telemetry';
22
+ const observations = await collectPublicMetrics(catalog, {
23
+ start: '2026-09-30', end: '2026-11-10', timeoutMs: 20000, concurrency: 4
24
+ });
25
+ const report = analyzeConstellation(catalog, observations);
26
+ const html = renderConstellationDashboard(report);
27
+ ```
28
+
29
+ `collectPublicMetrics` reads only fixed HTTPS endpoints on `api.npmjs.org`, `registry.npmjs.org`, and `api.github.com`. Package and repository names are validated before URL construction. Redirects, credentials and cookies are excluded. Each response is limited to 2 MiB, concurrency to eight tasks, date windows to 366 completed UTC days, and the whole collection to at most 60 seconds. Its injected fetch capability must honor AbortSignal for transport cleanup. Failures are explicit observations; a failed metadata fetch does not discard independently fetched download data. Candidate packages are never queried. GitHub's `open_issues_count` is labeled **open issues + pull requests**.
30
+
31
+ `analyzeConstellation` returns owned data, complete-window daily/7-day/30-day counts, coverage, the previous four matching weekdays, declared registry dependency links and bounded Pearson download correlations. Missing points and candidate observations remain unknown; incomplete cohort totals are null. Observed partial sums are separately labeled. Correlations examine at most 32 measured packages and retain ten pairs with at least seven shared days. Correlated download counts, dependency links and transitive installs do not establish distinct users, causality or attach rates.
32
+
33
+ Attach is available only with an explicit local adoption input:
34
+
35
+ ```js
36
+ const report = analyzeConstellation(catalog, observations, {
37
+ adoption: {
38
+ schema: 'ruvnet.constellation-adoption/1', consent: true,
39
+ samples: [{sampleId: projectHash, observedAt: '2026-11-10T12:00:00Z', packages: ['metaharness', 'agentdb']}]
40
+ }
41
+ });
42
+ ```
43
+
44
+ The library does not generate identifiers, inspect project files or send these samples anywhere. It keeps the latest snapshot per caller-supplied SHA-256 project identifier within the report window. Conflicting snapshots at the same instant reject. The denominator is **observed consenting project snapshots containing any catalog nucleus**; per-primitive and any-primitive percentages use that same denominator. This is not a percentage of developers or npm users. Sample IDs and raw snapshots never enter the returned report. No supplied sample, or no qualifying denominator, means `attach.status: 'unavailable'` and null percentages.
45
+
46
+ `renderConstellationDashboard` returns a portable HTML artifact with no external scripts, fonts, trackers or network fetches. Controls: `#period-filter` (1/7/30), `#family-filter`, `#role-filter`, `#package-search`, and `#trend-package`. Package rows in `#package-table` carry `data-package`; the chart is `#trend-chart`. `#attach-rate`, `#download-total`, and `#coverage-text` expose summary values. Dynamic text uses textContent, embedded JSON escapes HTML delimiters, and links allow only HTTPS npm/GitHub targets. The page supports local report export.
47
+
48
+ Public metrics are point-in-time observations. Users must supply an accurate catalog and an appropriately consented, representative adoption sample; the library cannot prove consent or correct sample selection. No claim is made that scoped names establish security or that download volume measures unique adoption.
49
+
50
+ <!-- constellation-links:start -->
51
+
52
+ Explore the [ruv-fann field guide](https://ruvnet.github.io/ruv-FANN/), [constellation onramp](https://www.npmjs.com/package/ruvnet-constellation) and [shared contracts](https://www.npmjs.com/package/ruvnet-constellation-contracts). Optional ecosystem primitives: [ruv-fann-wsp](https://www.npmjs.com/package/ruv-fann-wsp), [AgentDB](https://www.npmjs.com/package/agentdb), [RuVector](https://www.npmjs.com/package/ruvector). Optional orchestration: [MetaHarness](https://www.npmjs.com/package/metaharness) and [Ruflo](https://www.npmjs.com/package/ruflo).
53
+
54
+ These links describe integrations; package.json defines install-time dependencies. The [versioned catalog](https://github.com/ruvnet/ruv-FANN/blob/main/packages/wsp-toolkit/packages/constellation/catalog.json) declares release profiles and package versions; registry availability and publication provenance require independent verification.
55
+
56
+ <!-- constellation-links:end -->
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema": "ruvnet.constellation-module/1",
3
+ "name": "ruvnet-constellation-telemetry",
4
+ "version": "0.1.0",
5
+ "role": "satellite",
6
+ "contracts": [
7
+ "ruvnet.constellation-module/1"
8
+ ],
9
+ "integrations": []
10
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "ruvnet-constellation-telemetry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Analyze public npm and GitHub observations, consented project samples and standalone package dashboards.",
6
+ "engines": {
7
+ "node": ">=20.9.0"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE",
19
+ "constellation.json"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.mjs"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/ruvnet/ruv-FANN.git",
28
+ "directory": "packages/wsp-toolkit/packages/telemetry"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "keywords": [
34
+ "ruvnet",
35
+ "constellation",
36
+ "npm",
37
+ "github",
38
+ "package-analytics",
39
+ "dashboard",
40
+ "privacy",
41
+ "local-telemetry",
42
+ "adoption-metrics"
43
+ ],
44
+ "homepage": "https://ruvnet.github.io/ruv-FANN/",
45
+ "bugs": {
46
+ "url": "https://github.com/ruvnet/ruv-FANN/issues"
47
+ }
48
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,159 @@
1
+ import { LIMITS, record, integer, text, array, snapshot, catalogSnapshot, isoDay, isoStamp, addDays, range, names, safeSum } from './data.js';
2
+
3
+ function observationsSnapshot(value, catalog) {
4
+ const raw = record(snapshot(value), ['schema', 'observedAt', 'start', 'end', 'packages', 'repositories'],
5
+ ['schema', 'observedAt', 'start', 'end', 'packages', 'repositories'], 'observations');
6
+ if (raw.schema !== 'ruvnet.constellation-observations/1') throw new TypeError('unsupported observations schema');
7
+ isoStamp(raw.observedAt); range(raw.start, raw.end);
8
+ if (raw.end >= raw.observedAt.slice(0, 10)) throw new RangeError('observations must end before their UTC collection date');
9
+ const catalogByName = new Map(catalog.packages.map(p => [p.name, p])), seen = new Set();
10
+ const packages = array(raw.packages, LIMITS.maxPackages, 'observed packages').map(value => {
11
+ const p = record(value, ['name', 'status', 'days', 'version', 'dependencies', 'peerDependencies', 'optionalDependencies', 'metadataStatus', 'metadataError', 'error', 'repository', 'sources'],
12
+ ['name', 'status', 'days'], 'package observation');
13
+ const declared = catalogByName.get(p.name);
14
+ if (!declared || seen.has(p.name)) throw new TypeError('duplicate or unknown observed package'); seen.add(p.name);
15
+ if (!['ok', 'unavailable', 'candidate'].includes(p.status) || (declared.status === 'candidate') !== (p.status === 'candidate')) throw new TypeError('observation status contradicts catalog');
16
+ const daySeen = new Set();
17
+ const days = array(p.days, LIMITS.maxDays, 'observed days').map(value => {
18
+ const day = record(value, ['day', 'downloads'], ['day', 'downloads'], 'day observation'); isoDay(day.day);
19
+ if (day.day < raw.start || day.day > raw.end || daySeen.has(day.day)) throw new TypeError('duplicate or out-of-range observed day');
20
+ daySeen.add(day.day); return { day: day.day, downloads: integer(day.downloads, 0, Number.MAX_SAFE_INTEGER, 'daily downloads') };
21
+ }).sort((a, b) => a.day.localeCompare(b.day));
22
+ if (p.status !== 'ok' && days.length) throw new TypeError('unavailable or candidate observations cannot carry measured downloads');
23
+ if (p.version !== undefined) text(p.version, 128, 'observed version');
24
+ if (p.metadataStatus !== undefined && !['ok', 'unavailable'].includes(p.metadataStatus)) throw new TypeError('invalid metadata status');
25
+ return { ...p, days,
26
+ dependencies: p.dependencies === undefined ? [] : names(p.dependencies),
27
+ peerDependencies: p.peerDependencies === undefined ? [] : names(p.peerDependencies),
28
+ optionalDependencies: p.optionalDependencies === undefined ? [] : names(p.optionalDependencies) };
29
+ });
30
+ const repositories = [], repoSeen = new Set(), allowedRepos = new Set(catalog.packages.map(p => p.repository));
31
+ for (const value of array(raw.repositories, LIMITS.maxPackages, 'repositories')) {
32
+ const item = record(value, ['repository', 'status', 'stars', 'openIssuesAndPRs', 'error', 'source'], ['repository', 'status'], 'repository observation');
33
+ if (!allowedRepos.has(item.repository) || repoSeen.has(item.repository) || !['ok', 'unavailable'].includes(item.status)) throw new TypeError('invalid repository observation');
34
+ repoSeen.add(item.repository);
35
+ if (item.status === 'ok') {
36
+ integer(item.stars, 0, Number.MAX_SAFE_INTEGER, 'stars'); integer(item.openIssuesAndPRs, 0, Number.MAX_SAFE_INTEGER, 'issues and PRs');
37
+ } else if (item.stars !== undefined || item.openIssuesAndPRs !== undefined) throw new TypeError('unavailable repository cannot carry counts');
38
+ repositories.push({ repository: item.repository, status: item.status,
39
+ stars: item.status === 'ok' ? item.stars : null, openIssuesAndPRs: item.status === 'ok' ? item.openIssuesAndPRs : null,
40
+ source: `https://api.github.com/repos/${item.repository}`, ...(item.error ? { error: text(item.error, 1000, 'repository error') } : {}) });
41
+ }
42
+ return { ...raw, packages, repositories };
43
+ }
44
+ function completeWindow(days, end, length) {
45
+ const values = [];
46
+ for (let offset = 0; offset < length; offset++) {
47
+ const value = days.get(addDays(end, -offset)); if (value === undefined) return null; values.push(value);
48
+ }
49
+ return safeSum(values);
50
+ }
51
+ function attachMetrics(value, catalog, start, end) {
52
+ const primitives = catalog.packages.filter(p => p.role === 'primitive').map(p => p.name);
53
+ const unavailable = { status: 'unavailable', unit: 'consenting-project-snapshots', denominator: 0, numerator: 0, percentage: null,
54
+ projects: 0, perPrimitive: primitives.map(name => ({ name, numerator: 0, denominator: 0, percentage: null })),
55
+ source: { provided: false, samplesReceived: 0, snapshotsInWindow: 0, duplicateSnapshotsRemoved: 0 },
56
+ note: 'Attach requires explicit consenting project snapshots. npm download counts do not identify users or joint installations.' };
57
+ if (value === undefined) return unavailable;
58
+ const raw = record(value, ['schema', 'consent', 'samples'], ['schema', 'consent', 'samples'], 'adoption');
59
+ if (raw.schema !== 'ruvnet.constellation-adoption/1' || raw.consent !== true) throw new TypeError('adoption requires the supported schema and explicit consent:true');
60
+ const samples = array(raw.samples, LIMITS.maxSamples, 'adoption samples'), latest = new Map(), seenSnapshots = new Map(); let inWindow = 0;
61
+ for (const value of samples) {
62
+ const sample = record(value, ['sampleId', 'observedAt', 'packages'], ['sampleId', 'observedAt', 'packages'], 'adoption sample');
63
+ if (typeof sample.sampleId !== 'string' || !/^[a-f0-9]{64}$/.test(sample.sampleId)) throw new TypeError('sampleId must be an anonymized lowercase SHA-256 identifier');
64
+ isoStamp(sample.observedAt); const packages = names(sample.packages, LIMITS.maxSamplePackages);
65
+ const stamp = Date.parse(sample.observedAt), key = `${sample.sampleId}:${stamp}`, content = JSON.stringify(packages);
66
+ if (seenSnapshots.has(key) && seenSnapshots.get(key) !== content) throw new TypeError('conflicting duplicate project snapshot');
67
+ // Check every declared instant, including older snapshots, before selecting a latest in-window observation.
68
+ seenSnapshots.set(key, content);
69
+ const day = sample.observedAt.slice(0, 10); if (day < start || day > end) continue;
70
+ inWindow++; const previous = latest.get(sample.sampleId);
71
+ if (!previous || stamp > previous.stamp) latest.set(sample.sampleId, { stamp, packages });
72
+ }
73
+ const nuclei = new Set(catalog.packages.filter(p => p.role === 'nucleus').map(p => p.name));
74
+ const projects = [...latest.values()], eligible = projects.filter(p => p.packages.some(name => nuclei.has(name)));
75
+ const denominator = eligible.length, numerator = eligible.filter(p => p.packages.some(name => primitives.includes(name))).length;
76
+ return { status: denominator ? 'available' : 'unavailable', unit: 'consenting-project-snapshots', denominator, numerator,
77
+ percentage: denominator ? 100 * numerator / denominator : null, projects: projects.length,
78
+ perPrimitive: primitives.map(name => { const numerator = eligible.filter(p => p.packages.includes(name)).length;
79
+ return { name, numerator, denominator, percentage: denominator ? 100 * numerator / denominator : null }; }),
80
+ source: { provided: true, samplesReceived: samples.length, snapshotsInWindow: inWindow, duplicateSnapshotsRemoved: inWindow - projects.length },
81
+ note: 'Latest consenting snapshot per project in this window. Denominator: sampled projects containing any catalog nucleus. This is not a percentage of developers or npm users.' };
82
+ }
83
+ function correlation(left, right) {
84
+ const r = new Map(right.days.map(d => [d.day, d.downloads]));
85
+ const pairs = left.days.filter(d => r.has(d.day)).map(d => [d.downloads, r.get(d.day)]);
86
+ if (pairs.length < 7) return null;
87
+ const mx = pairs.reduce((sum, p) => sum + p[0], 0) / pairs.length, my = pairs.reduce((sum, p) => sum + p[1], 0) / pairs.length;
88
+ let cross = 0, xx = 0, yy = 0;
89
+ for (const [x, y] of pairs) { cross += (x - mx) * (y - my); xx += (x - mx) ** 2; yy += (y - my) ** 2; }
90
+ if (xx === 0 || yy === 0) return null;
91
+ return { left: left.name, right: right.name, r: Math.max(-1, Math.min(1, cross / Math.sqrt(xx * yy))), samples: pairs.length };
92
+ }
93
+
94
+ /** Download correlation is observational; attach is computed only from an explicit local adoption sample. */
95
+ export function analyzeConstellation(inputCatalog, inputObservations, configuration = {}) {
96
+ const catalog = catalogSnapshot(inputCatalog), observations = observationsSnapshot(inputObservations, catalog);
97
+ const options = record(configuration, ['adoption'], [], 'analysis options');
98
+ const adoption = options.adoption === undefined ? undefined : snapshot(options.adoption);
99
+ const observed = new Map(observations.packages.map(p => [p.name, p])), known = new Set(catalog.packages.map(p => p.name));
100
+ const packages = catalog.packages.map(p => {
101
+ const data = observed.get(p.name) ?? { name: p.name, status: p.status === 'candidate' ? 'candidate' : 'unavailable', days: [], dependencies: [], peerDependencies: [], optionalDependencies: [] };
102
+ const days = new Map(data.days.map(d => [d.day, d.downloads]));
103
+ const baselineValues = [7, 14, 21, 28].map(offset => days.get(addDays(observations.end, -offset)));
104
+ const weekdayBaseline = baselineValues.every(v => v !== undefined) ? safeSum(baselineValues) / 4 : null;
105
+ const latestDayDownloads = days.get(observations.end) ?? null;
106
+ const weekdayDeltaPercent = latestDayDownloads !== null && weekdayBaseline !== null && weekdayBaseline > 0 ? 100 * (latestDayDownloads - weekdayBaseline) / weekdayBaseline : null;
107
+ const edges = [];
108
+ for (const [key, kind] of [['dependencies', 'runtime'], ['peerDependencies', 'peer'], ['optionalDependencies', 'optional']])
109
+ for (const target of data.metadataStatus === 'ok' ? data[key] ?? [] : []) if (known.has(target)) edges.push({ source: p.name, target, kind });
110
+ return { name: p.name, role: p.role, family: p.family, catalogStatus: p.status, status: data.status,
111
+ catalogVersion: p.version, version: data.version ?? null, description: p.description, repository: p.repository,
112
+ npmURL: p.status === 'published' ? `https://www.npmjs.com/package/${p.name}` : null,
113
+ sourceURL: `https://github.com/${p.repository}${p.directory ? `/tree/main/${p.directory}` : ''}`,
114
+ latestDayDownloads, last7Downloads: completeWindow(days, observations.end, 7), last30Downloads: completeWindow(days, observations.end, 30),
115
+ weekdayBaseline, weekdayDeltaPercent, weekdayBaselineDays: baselineValues.filter(v => v !== undefined).length,
116
+ days: data.days, coverage: { observedDays: days.size, expectedDays: range(observations.start, observations.end).length },
117
+ metadataStatus: data.metadataStatus ?? 'unavailable', dependencies: data.dependencies ?? [], peerDependencies: data.peerDependencies ?? [], optionalDependencies: data.optionalDependencies ?? [],
118
+ runtimeDependencyCount: data.metadataStatus === 'ok' ? (data.dependencies ?? []).length : null,
119
+ peerDependencyCount: data.metadataStatus === 'ok' ? (data.peerDependencies ?? []).length : null,
120
+ optionalDependencyCount: data.metadataStatus === 'ok' ? (data.optionalDependencies ?? []).length : null,
121
+ declaredLinks: edges, ...(data.error ? { error: text(data.error, 1000, 'package error') } : {}) };
122
+ });
123
+ const published = packages.filter(p => p.catalogStatus === 'published'), candidates = packages.length - published.length;
124
+ const total = key => published.length && published.every(p => p[key] !== null) ? safeSum(published.map(p => p[key])) : null;
125
+ const coverage = { catalogPackages: packages.length, publishedPackages: published.length, candidatePackages: candidates,
126
+ packagesWithLatestDay: published.filter(p => p.latestDayDownloads !== null).length,
127
+ packagesWithLast7: published.filter(p => p.last7Downloads !== null).length, packagesWithLast30: published.filter(p => p.last30Downloads !== null).length,
128
+ unavailablePackages: published.filter(p => p.status !== 'ok').length };
129
+ const dayMaps = published.map(p => new Map(p.days.map(d => [d.day, d.downloads])));
130
+ const dailySeries = range(observations.start, observations.end).map(day => {
131
+ const values = dayMaps.map(days => days.get(day)).filter(v => v !== undefined);
132
+ return { day, downloads: published.length && values.length === published.length ? safeSum(values) : null,
133
+ observedDownloads: safeSum(values), observedPackages: values.length, expectedPackages: published.length };
134
+ });
135
+ const eligible = published.filter(p => p.latestDayDownloads !== null && p.weekdayBaseline !== null), families = [...new Set(eligible.map(p => p.family))].sort();
136
+ const netChange = eligible.reduce((sum, p) => sum + p.latestDayDownloads - p.weekdayBaseline, 0);
137
+ const weekdaySummary = { eligiblePackages: eligible.length, aboveBaseline: eligible.filter(p => p.latestDayDownloads > p.weekdayBaseline).length, netChange,
138
+ families: families.map(family => { const change = eligible.filter(p => p.family === family).reduce((sum, p) => sum + p.latestDayDownloads - p.weekdayBaseline, 0);
139
+ return { family, netChange: change, shareOfNetChangePercent: netChange > 0 ? 100 * change / netChange : null }; }),
140
+ note: 'Previous four matching weekdays, compared with the final complete UTC day. Synchronized downloads do not prove co-installation or causal cross-pollination.' };
141
+ const considered = published.filter(p => p.status === 'ok').sort((a, b) => (b.last30Downloads ?? -1) - (a.last30Downloads ?? -1) || a.name.localeCompare(b.name)).slice(0, 32);
142
+ const pairs = [];
143
+ for (let i = 0; i < considered.length; i++) for (let j = i + 1; j < considered.length; j++) { const value = correlation(considered[i], considered[j]); if (value) pairs.push(value); }
144
+ pairs.sort((a, b) => Math.abs(b.r) - Math.abs(a.r) || a.left.localeCompare(b.left) || a.right.localeCompare(b.right));
145
+ return { schema: 'ruvnet.constellation-report/1', observedAt: observations.observedAt, start: observations.start, end: observations.end,
146
+ packages, totals: { latestDayDownloads: total('latestDayDownloads'), last7Downloads: total('last7Downloads'), last30Downloads: total('last30Downloads'),
147
+ observedLast7Downloads: safeSum(published.filter(p => p.last7Downloads !== null).map(p => p.last7Downloads)),
148
+ observedLast30Downloads: safeSum(published.filter(p => p.last30Downloads !== null).map(p => p.last30Downloads)), coverage },
149
+ dailySeries, repositories: observations.repositories, weekdaySummary,
150
+ correlations: { kind: 'daily-download-correlation', consideredPackages: considered.length, eligiblePairs: pairs.length, pairs: pairs.slice(0, 10),
151
+ note: 'Pearson correlation on shared observed days, at least seven points; limited to the 32 highest-volume measured packages. Correlation is not attach rate.' },
152
+ declaredLinks: packages.flatMap(p => p.declaredLinks),
153
+ declaredLinksCoverage: { observedManifests: published.filter(p => p.metadataStatus === 'ok').length, expectedManifests: published.length }, attach: attachMetrics(adoption, catalog, observations.start, observations.end),
154
+ definitions: { downloads: 'npm registry download events, including CI and transitive installs; not distinct developers.',
155
+ totals: 'Only complete published-package windows are totaled. Candidate and unavailable observations stay unknown; overlapping cohorts are not unique users.',
156
+ weekday: 'Mean of the previous four matching weekdays; percentage unknown when coverage is incomplete or baseline is zero.',
157
+ repositories: 'GitHub open_issues_count includes open pull requests as well as issues.',
158
+ adoption: 'Explicit local consenting project snapshots only; no automatic collection or upload.' } };
159
+ }
package/src/collect.js ADDED
@@ -0,0 +1,121 @@
1
+ import { LIMITS, record, integer, catalogSnapshot, range, isoDay, npmName } from './data.js';
2
+ class SourceError extends Error {}
3
+ function abortable(promise, signal) {
4
+ if (signal.aborted) return Promise.reject(signal.reason);
5
+ return new Promise((resolve, reject) => {
6
+ const abort = () => { cleanup(); reject(signal.reason); };
7
+ const cleanup = () => signal.removeEventListener('abort', abort);
8
+ signal.addEventListener('abort', abort, { once: true });
9
+ Promise.resolve(promise).then(value => { cleanup(); resolve(value); }, error => { cleanup(); reject(error); });
10
+ });
11
+ }
12
+ async function jsonFrom(response, signal) {
13
+ if (!response || response.status !== 200 || response.redirected || response.type === 'opaqueredirect') {
14
+ response?.body?.cancel().catch(() => {});
15
+ throw new SourceError(`public source returned HTTP ${Number.isInteger(response?.status) ? response.status : 'unknown'} or a redirect`);
16
+ }
17
+ const declared = response.headers?.get('content-length');
18
+ if (declared !== null && declared !== undefined && (!/^\d+$/.test(declared) || Number(declared) > LIMITS.maxBytes)) {
19
+ response.body?.cancel().catch(() => {}); throw new SourceError('public source exceeds declared byte limit');
20
+ }
21
+ if (!response.body?.getReader) throw new SourceError('public source has no readable body');
22
+ const reader = response.body.getReader(); let bytes = new Uint8Array(8192), size = 0;
23
+ try {
24
+ for (;;) {
25
+ const { value, done } = await abortable(reader.read(), signal);
26
+ if (done) break;
27
+ if (!(value instanceof Uint8Array) || value.byteLength > LIMITS.maxBytes - size) throw new SourceError('public source exceeds streamed byte limit');
28
+ if (size + value.byteLength > bytes.length) {
29
+ const grown = new Uint8Array(Math.min(LIMITS.maxBytes, Math.max(size + value.byteLength, bytes.length * 2)));
30
+ grown.set(bytes); bytes = grown;
31
+ }
32
+ bytes.set(value, size); size += value.byteLength;
33
+ }
34
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size))); }
35
+ catch { throw new SourceError('public source is not valid UTF-8 JSON'); }
36
+ } catch (error) { reader.cancel().catch(() => {}); throw error; }
37
+ finally { try { reader.releaseLock(); } catch { /* Abort can leave an uncooperative trusted stream pending. */ } }
38
+ }
39
+ function dependencies(value) {
40
+ if (value === undefined) return [];
41
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new SourceError('invalid package dependency metadata');
42
+ const keys = Object.keys(value); if (keys.length > 4096) throw new SourceError('package dependency metadata exceeds limit');
43
+ return keys.map(npmName).sort();
44
+ }
45
+ function daysFrom(value, name, start, end) {
46
+ if (value?.package !== name || value.start !== start || value.end !== end || !Array.isArray(value.downloads) || value.downloads.length > LIMITS.maxDays)
47
+ throw new SourceError('public download identity or date range mismatch');
48
+ const seen = new Set();
49
+ return value.downloads.map(entry => {
50
+ const day = isoDay(entry?.day);
51
+ if (day < start || day > end || seen.has(day)) throw new SourceError('public source contains duplicate or out-of-range dates');
52
+ seen.add(day);
53
+ return { day, downloads: integer(entry.downloads, 0, Number.MAX_SAFE_INTEGER, 'daily downloads') };
54
+ }).sort((a, b) => a.day.localeCompare(b.day));
55
+ }
56
+ function errorText(error, signal) { return signal.aborted ? 'collection deadline exceeded' : error instanceof SourceError ? error.message.slice(0, 200) : 'public source request or validation failed'; }
57
+
58
+ /** Public read-only collection. No credentials, local scanning or background collection. */
59
+ export async function collectPublicMetrics(input, configuration) {
60
+ const catalog = catalogSnapshot(input);
61
+ const options = record(configuration, ['start', 'end', 'fetch', 'timeoutMs', 'concurrency'], ['start', 'end'], 'collection options');
62
+ const start = options.start, end = options.end; range(start, end);
63
+ if (end >= new Date().toISOString().slice(0, 10)) throw new RangeError('collect only completed UTC days');
64
+ const fetcher = options.fetch === undefined ? globalThis.fetch : options.fetch;
65
+ if (typeof fetcher !== 'function') throw new TypeError('fetch must be a trusted function');
66
+ const timeoutMs = options.timeoutMs === undefined ? 20000 : integer(options.timeoutMs, 1, LIMITS.maxTimeoutMs, 'collection timeout');
67
+ const concurrency = options.concurrency === undefined ? 4 : integer(options.concurrency, 1, LIMITS.maxConcurrency, 'collection concurrency');
68
+ const controller = new AbortController(), observedAt = new Date().toISOString();
69
+ const timer = setTimeout(() => controller.abort(new Error('collection deadline exceeded')), timeoutMs);
70
+ const request = async url => {
71
+ controller.signal.throwIfAborted();
72
+ const parsed = new URL(url);
73
+ if (parsed.protocol !== 'https:' || !['registry.npmjs.org', 'api.npmjs.org', 'api.github.com'].includes(parsed.hostname) || parsed.username || parsed.password)
74
+ throw new SourceError('unsupported public source URL');
75
+ const response = await abortable(fetcher(url, { redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal,
76
+ headers: { Accept: 'application/json' } }), controller.signal);
77
+ if (response.url && new URL(response.url).origin !== parsed.origin) throw new SourceError('public source changed origin');
78
+ return jsonFrom(response, controller.signal);
79
+ };
80
+ const packages = catalog.packages.map(p => ({ name: p.name, status: p.status === 'candidate' ? 'candidate' : 'unavailable', days: [], repository: p.repository }));
81
+ const repositoryNames = [...new Set(catalog.packages.filter(p => p.status === 'published').map(p => p.repository))].sort();
82
+ const repositories = repositoryNames.map(repository => ({ repository, status: 'unavailable' }));
83
+ const tasks = [];
84
+ for (let index = 0; index < catalog.packages.length; index++) {
85
+ const p = catalog.packages[index]; if (p.status === 'candidate') continue;
86
+ tasks.push(async () => {
87
+ const result = packages[index];
88
+ const downloadsURL = `https://api.npmjs.org/downloads/range/${start}:${end}/${encodeURIComponent(p.name)}`;
89
+ const metadataURL = `https://registry.npmjs.org/${encodeURIComponent(p.name)}/latest`;
90
+ result.sources = { downloads: downloadsURL, metadata: metadataURL };
91
+ try { result.days = daysFrom(await request(downloadsURL), p.name, start, end); result.status = 'ok'; }
92
+ catch (error) { result.error = errorText(error, controller.signal); }
93
+ try {
94
+ const data = await request(metadataURL);
95
+ if (data?.name !== p.name || typeof data.version !== 'string' || data.version.length < 1 || data.version.length > 128)
96
+ throw new SourceError('public package metadata identity mismatch');
97
+ result.version = data.version; result.dependencies = dependencies(data.dependencies);
98
+ result.peerDependencies = dependencies(data.peerDependencies); result.optionalDependencies = dependencies(data.optionalDependencies);
99
+ result.metadataStatus = 'ok';
100
+ } catch (error) { result.metadataStatus = 'unavailable'; result.metadataError = errorText(error, controller.signal); }
101
+ });
102
+ }
103
+ for (let index = 0; index < repositoryNames.length; index++) tasks.push(async () => {
104
+ const repository = repositoryNames[index], result = repositories[index];
105
+ result.source = `https://api.github.com/repos/${repository}`;
106
+ try {
107
+ const data = await request(result.source);
108
+ if (typeof data?.full_name !== 'string' || data.full_name.toLowerCase() !== repository.toLowerCase()) throw new SourceError('public repository identity mismatch');
109
+ result.stars = integer(data.stargazers_count, 0, Number.MAX_SAFE_INTEGER, 'repository stars');
110
+ result.openIssuesAndPRs = integer(data.open_issues_count, 0, Number.MAX_SAFE_INTEGER, 'open issues and PRs');
111
+ result.status = 'ok';
112
+ } catch (error) { result.error = errorText(error, controller.signal); }
113
+ });
114
+ let next = 0;
115
+ try {
116
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
117
+ while (next < tasks.length) await tasks[next++]();
118
+ }));
119
+ return { schema: 'ruvnet.constellation-observations/1', observedAt, start, end, packages, repositories };
120
+ } finally { clearTimeout(timer); }
121
+ }
@@ -0,0 +1,56 @@
1
+ import { snapshot, array } from './data.js';
2
+
3
+ /** A portable report artifact. All interaction is local; no external assets or data collection. */
4
+ export function renderConstellationDashboard(input) {
5
+ const report = snapshot(input);
6
+ if (report?.schema !== 'ruvnet.constellation-report/1' || !report.totals?.coverage || !report.attach || !report.definitions || !report.weekdaySummary || !report.correlations)
7
+ throw new TypeError('a constellation report is required');
8
+ array(report.packages, 1000, 'report packages'); array(report.dailySeries, 366, 'daily series'); array(report.repositories, 1000, 'repositories');
9
+ const json = JSON.stringify(report).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
10
+ return `<!doctype html>
11
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'"><title>Growth Constellation — package observatory</title>
12
+ <style>
13
+ :root{color-scheme:dark;--bg:#09111e;--panel:#111d2e;--panel2:#152337;--border:#26374c;--text:#e8eef6;--muted:#a6b5c8;--teal:#79e3ca;--blue:#96baff;--gold:#f1cc83}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:var(--teal);text-decoration:none}a:hover{text-decoration:underline}button,input,select{font:inherit;color:inherit}button,select,input{background:#0c1726;border:1px solid #3c5069;border-radius:8px;padding:9px 12px}button{cursor:pointer}button:hover{background:#22334a}input:focus,select:focus,button:focus-visible{outline:2px solid var(--teal);outline-offset:2px}.wrap{max-width:1480px;margin:auto;padding:32px}header{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:32px}.eyebrow{font-size:11px;font-weight:750;letter-spacing:.2em;text-transform:uppercase;color:var(--teal)}h1{font-size:clamp(26px,3vw,42px);line-height:1.15;margin:8px 0 12px;letter-spacing:-.035em}h2{font-size:19px;margin:0 0 6px;letter-spacing:-.02em}p{margin:0}.muted{color:var(--muted)}.subtitle{max-width:850px}.tag{display:inline-block;border:1px solid var(--border);border-radius:5px;padding:2px 7px;color:var(--muted);font-size:11px;white-space:nowrap}.badge{color:var(--teal);border-color:#366f65}.grid{display:grid;gap:18px}.stats{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:24px}.stat,.panel{background:var(--panel);border:1px solid var(--border);border-radius:13px}.stat{padding:21px}.stat-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.stat-value{font-size:31px;font-weight:700;letter-spacing:-.035em;margin:7px 0}.stat-detail{font-size:12px;color:var(--muted);min-height:36px}.main-grid{grid-template-columns:minmax(0,1.9fr) minmax(300px,1fr);align-items:start}.panel{padding:23px;margin-bottom:20px}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:18px}.panel-head p{font-size:12px;color:var(--muted)}#trend-chart{width:100%;height:230px;display:block;overflow:visible}.chart-grid{stroke:#26374c;stroke-width:1}.chart-line{fill:none;stroke:var(--teal);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}.chart-line.partial{stroke:var(--gold);stroke-dasharray:6 5}.dot.partial{background:var(--gold)}.chart-label{fill:#a6b5c8;font:11px system-ui}.filters{display:grid;grid-template-columns:1.8fr 1fr 1fr;gap:10px;margin:18px 0}.filters label{font-size:11px;color:var(--muted);display:flex;flex-direction:column;gap:5px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:13px;white-space:nowrap}th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:650}th,td{padding:12px 10px;border-bottom:1px solid var(--border)}th:first-child,td:first-child{padding-left:0}td:last-child,th:last-child{text-align:right;padding-right:0}td.num{text-align:right;font-variant-numeric:tabular-nums}tbody tr:hover{background:var(--panel2)}.package-name{font-weight:650;display:block}.package-sub{font-size:10px;color:var(--muted);font-weight:400}.positive{color:var(--teal)}.negative{color:#ffa899}.small{font-size:12px}.signal-list{display:grid;gap:13px;margin-top:16px}.signal{border-top:1px solid var(--border);padding-top:12px}.signal strong{display:block;font-size:13px}.signal span{font-size:12px;color:var(--muted)}.note{background:#102c2d;border:1px solid #28544d;padding:13px;border-radius:8px;color:#bfe9df;font-size:12px;margin-top:15px}.empty{padding:24px;color:var(--muted);text-align:center}.repo{display:flex;justify-content:space-between;align-items:start;gap:12px;border-top:1px solid var(--border);padding:13px 0}.repo a{font-size:13px}.repo-metrics{text-align:right;white-space:nowrap;font-size:12px;color:var(--muted)}footer{border-top:1px solid var(--border);padding:24px 0 5px;margin-top:10px;color:var(--muted);font-size:11px;display:grid;grid-template-columns:1fr 1fr;gap:18px}.legend{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted)}.dot{width:7px;height:7px;display:inline-block;border-radius:50%;background:var(--teal)}.nowrap{white-space:nowrap}@media(max-width:1080px){.main-grid{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,1fr)}header{align-items:start}}@media(max-width:620px){.wrap{padding:20px 14px}.stats{gap:10px}.stat{padding:15px}.stat-value{font-size:25px}.filters{grid-template-columns:1fr}header{flex-direction:column;gap:14px}.panel{padding:17px}.panel-head{align-items:start;flex-direction:column}footer{grid-template-columns:1fr}}
14
+ </style></head><body><div class="wrap">
15
+ <header><div><div class="eyebrow">Growth constellation / Observatory</div><h1>One ecosystem. Clear evidence.</h1><p class="subtitle muted">Public package signals, declared dependency links, and explicitly sampled project adoption. Download volume stays separate from people and joint usage.</p></div><div><span class="tag badge">LOCAL REPORT · NO TRACKERS</span><div style="margin-top:12px"><button id="download-report" type="button">Export report JSON</button></div></div></header>
16
+ <div class="grid stats"><section class="stat"><div class="stat-label">Catalog coverage</div><div class="stat-value" id="package-count">—</div><div class="stat-detail" id="coverage-text"></div></section><section class="stat"><div class="stat-label" id="download-label">30-day downloads</div><div class="stat-value" id="download-total">Unknown</div><div class="stat-detail" id="download-detail"></div></section><section class="stat"><div class="stat-label">Above weekday baseline</div><div class="stat-value" id="weekday-signal">—</div><div class="stat-detail">Final day versus four preceding matching weekdays. An observational signal.</div></section><section class="stat"><div class="stat-label">Observed project attach</div><div class="stat-value" id="attach-rate">Not measured</div><div class="stat-detail" id="attach-denominator"></div></section></div>
17
+ <div class="grid main-grid"><main><section class="panel"><div class="panel-head"><div><h2>Download trajectory</h2><p id="window-label"></p></div><label class="small muted">Series <select id="trend-package" aria-label="Trend series"><option value="cohort">Filtered published cohort</option></select></label></div><svg id="trend-chart" viewBox="0 0 800 230" role="img" aria-label="Daily observed download trend"></svg><div class="legend"><span id="trend-legend-dot" class="dot"></span><span id="trend-caption"></span></div></section>
18
+ <section class="panel"><div class="panel-head"><div><h2>Package constellation</h2><p id="table-count"></p></div><label class="small muted">Window <select id="period-filter" aria-label="Download window"><option value="30">30 complete days</option><option value="7">7 complete days</option><option value="1">Final day</option></select></label></div><div class="filters"><label>Find a package<input id="package-search" type="search" placeholder="Search package or description" autocomplete="off"></label><label>Family<select id="family-filter"><option value="all">All families</option></select></label><label>Role<select id="role-filter"><option value="all">All roles</option><option value="nucleus">Nucleus</option><option value="primitive">Primitive</option><option value="satellite">Satellite</option></select></label></div><div class="table-wrap"><table><thead><tr><th>Package / public version</th><th>Role</th><th class="num">Downloads</th><th class="num">Weekday Δ</th><th class="num">Coverage</th><th>Status</th></tr></thead><tbody id="package-table"></tbody></table></div></section></main>
19
+ <aside><section class="panel"><h2>Attach needs joint evidence</h2><p class="muted small" id="attach-note"></p><div id="attach-primitives" class="signal-list"></div><div class="note">npm counts include CI and transitive activity. Neither synchronized downloads nor declared dependencies measure distinct users or an install attach rate.</div></section><section class="panel"><h2>Repository signals</h2><p class="muted small">Open items include issues and pull requests.</p><div id="repository-list"></div></section><section class="panel"><h2>Shared download patterns</h2><p class="muted small" id="correlation-note"></p><div id="correlation-list" class="signal-list"></div></section><section class="panel"><h2>Declared connections</h2><p class="muted small" id="dependency-summary"></p><div id="dependency-list" class="signal-list"></div></section></aside></div>
20
+ <footer><div id="provenance"></div><div id="definitions"></div></footer></div>
21
+ <script id="report-data" type="application/json">${json}</script>
22
+ <script>
23
+ ` + String.raw`'use strict';
24
+ const report=JSON.parse(document.getElementById('report-data').textContent);
25
+ const byId=id=>document.getElementById(id), number=value=>value===null||value===undefined?'Unknown':new Intl.NumberFormat('en-US').format(value), percent=value=>value===null||value===undefined?'Unknown':value.toFixed(1)+'%';
26
+ const put=(id,value)=>{byId(id).textContent=value;};
27
+ const make=(tag,value,className)=>{const node=document.createElement(tag);if(value!==undefined)node.textContent=value;if(className)node.className=className;return node;};
28
+ function link(value,label){try{const u=new URL(value);if(u.protocol!=='https:'||!['github.com','www.npmjs.com','npmjs.com'].includes(u.hostname)||u.username||u.password)throw Error();const a=make('a',label);a.href=u.href;a.target='_blank';a.rel='noopener noreferrer';return a;}catch{return make('span',label);}}
29
+ const coverage=report.totals.coverage;
30
+ put('package-count',number(coverage.catalogPackages));put('coverage-text',coverage.publishedPackages+' public packages · '+coverage.candidatePackages+' local candidates. '+coverage.unavailablePackages+' public sources unavailable.');
31
+ put('weekday-signal',report.weekdaySummary.aboveBaseline+' / '+report.weekdaySummary.eligiblePackages);put('window-label',report.start+' → '+report.end+' · complete UTC days');
32
+ put('attach-rate',report.attach.status==='available'?percent(report.attach.percentage):'Not measured');put('attach-denominator',report.attach.denominator?report.attach.numerator+' of '+report.attach.denominator+' observed nucleus projects include a primitive.':'No qualifying consenting project sample.');put('attach-note',report.attach.note);
33
+ for(const value of report.attach.perPrimitive){const item=make('div',undefined,'signal');item.append(make('strong',value.name),make('span',value.denominator?percent(value.percentage)+' · '+value.numerator+'/'+value.denominator+' sampled nucleus projects':'Not measured'));byId('attach-primitives').append(item);}
34
+ for(const value of report.repositories){const item=make('div',undefined,'repo');item.append(link('https://github.com/'+value.repository,value.repository));const detail=make('div',undefined,'repo-metrics');detail.append(make('div',value.status==='ok'?number(value.stars)+' stars':'Source unavailable'),make('div',value.status==='ok'?number(value.openIssuesAndPRs)+' open issues + PRs':value.error||'No observation'));item.append(detail);byId('repository-list').append(item);}
35
+ put('correlation-note',report.correlations.note);
36
+ for(const value of report.correlations.pairs.slice(0,5)){const item=make('div',undefined,'signal');item.append(make('strong',value.left+' ↔ '+value.right),make('span','r = '+value.r.toFixed(3)+' · '+value.samples+' shared days'));byId('correlation-list').append(item);}
37
+ if(!report.correlations.pairs.length)byId('correlation-list').append(make('p','No eligible varying series.','muted small'));
38
+ put('dependency-summary',report.declaredLinks.length+' declared links from '+report.declaredLinksCoverage.observedManifests+'/'+report.declaredLinksCoverage.expectedManifests+' observed public manifests. Missing metadata is unknown; these are not measured joint installs.');
39
+ for(const value of report.declaredLinks.slice(0,8)){const item=make('div',undefined,'signal');item.append(make('strong',value.source+' → '+value.target),make('span',value.kind+' dependency'));byId('dependency-list').append(item);}
40
+ put('provenance','Observed '+report.observedAt+'. Sources: npm download API, npm registry package manifests and public GitHub repository metadata. Candidate status describes local source; public package ownership is not inferred.');
41
+ put('definitions',report.definitions.downloads+' '+report.definitions.totals+' '+report.definitions.repositories);
42
+ for(const family of [...new Set(report.packages.map(p=>p.family))].sort()){const option=make('option',family);option.value=family;byId('family-filter').append(option);}
43
+ for(const p of report.packages.filter(p=>p.catalogStatus==='published')){const option=make('option',p.name);option.value=p.name;byId('trend-package').append(option);}
44
+ function selectedRows(){const search=byId('package-search').value.toLowerCase();return report.packages.filter(p=>(byId('family-filter').value==='all'||p.family===byId('family-filter').value)&&(byId('role-filter').value==='all'||p.role===byId('role-filter').value)&&(!search||(p.name+' '+p.description).toLowerCase().includes(search)));}
45
+ function metric(p){const period=byId('period-filter').value;return period==='1'?p.latestDayDownloads:period==='7'?p.last7Downloads:p.last30Downloads;}
46
+ function draw(rows){const ns='http://www.w3.org/2000/svg',svg=byId('trend-chart');svg.replaceChildren();const element=(tag,attrs,label)=>{const e=document.createElementNS(ns,tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,String(v));if(label!==undefined)e.textContent=label;return e;};
47
+ const period=Number(byId('period-filter').value),dates=report.dailySeries.slice(-period).map(d=>d.day);const selected=byId('trend-package').value;const source=selected==='cohort'?rows.filter(p=>p.catalogStatus==='published'):report.packages.filter(p=>p.name===selected);const maps=source.map(p=>new Map(p.days.map(d=>[d.day,d.downloads])));const points=dates.map(day=>{const known=maps.map(m=>m.get(day)).filter(v=>v!==undefined);return {value:known.length?known.reduce((sum,value)=>sum+value,0):null,observed:known.length,expected:maps.length,complete:maps.length>0&&known.length===maps.length};});const measured=points.filter(p=>p.value!==null),maximum=Math.max(1,...measured.map(p=>p.value));const left=55,right=785,top=12,bottom=190;
48
+ for(let i=0;i<4;i++){const y=top+(bottom-top)*i/3;svg.append(element('line',{x1:left,x2:right,y1:y,y2:y,class:'chart-grid'}),element('text',{x:left-9,y:y+4,'text-anchor':'end',class:'chart-label'},number(Math.round(maximum*(1-i/3)))));}
49
+ const coordinates=points.map((p,index)=>({x:left+(right-left)*(points.length<2?0.5:index/(points.length-1)),y:p.value===null?null:bottom-(bottom-top)*p.value/maximum}));let fullPath='',partialPath='';for(let index=1;index<points.length;index++){if(points[index-1].value===null||points[index].value===null)continue;const a=coordinates[index-1],b=coordinates[index],segment='M'+a.x.toFixed(2)+' '+a.y.toFixed(2)+' L'+b.x.toFixed(2)+' '+b.y.toFixed(2)+' ';if(points[index-1].complete&&points[index].complete)fullPath+=segment;else partialPath+=segment;}if(fullPath)svg.append(element('path',{d:fullPath,class:'chart-line'}));if(partialPath)svg.append(element('path',{d:partialPath,class:'chart-line partial'}));points.forEach((p,index)=>{if(p.value===null)return;const point=coordinates[index];svg.append(element('circle',{cx:point.x,cy:point.y,r:points.length===1?4:1.7,fill:p.complete?'#79e3ca':'#f1cc83'}));});
50
+ if(dates.length){svg.append(element('text',{x:left,y:219,class:'chart-label'},dates[0]),element('text',{x:right,y:219,'text-anchor':'end',class:'chart-label'},dates[dates.length-1]));}
51
+ const partial=measured.some(p=>!p.complete),latest=points.at(-1);byId('trend-legend-dot').classList.toggle('partial',partial);put('trend-caption',!measured.length?'No observed daily series for this selection. Unknown values are not zero.':partial?'Gold dashed = observed subtotal; '+(latest?.observed||0)+'/'+source.length+' filtered published packages observed on the final day. '+measured.length+'/'+dates.length+' days have some data. Full-cohort counts remain unknown where coverage is incomplete; changes can reflect coverage.':measured.length+'/'+dates.length+' days have complete coverage for '+(selected==='cohort'?source.length+' filtered published packages':selected)+'. Missing observations are gaps.');}
52
+ function refresh(){const rows=selectedRows();rows.sort((a,b)=>(metric(b)??-1)-(metric(a)??-1)||a.name.localeCompare(b.name));put('table-count',rows.length+' of '+report.packages.length+' catalog packages · select a row for its trend');const body=byId('package-table');body.replaceChildren();for(const p of rows){const tr=make('tr');tr.dataset.package=p.name;const name=make('td');const a=link(p.npmURL||p.sourceURL,p.name);a.className='package-name';name.append(a,make('span','catalog '+p.catalogVersion+(p.version?' · public '+p.version:''),'package-sub'));tr.append(name,make('td',p.role),make('td',number(metric(p)),'num'));const delta=make('td',p.weekdayDeltaPercent===null?'Unknown':(p.weekdayDeltaPercent>0?'+':'')+percent(p.weekdayDeltaPercent),'num '+(p.weekdayDeltaPercent>=0?'positive':'negative'));tr.append(delta,make('td',p.coverage.observedDays+'/'+p.coverage.expectedDays+' days','num'),make('td',p.status==='candidate'?'Local candidate':p.status==='ok'?'Observed':'Unavailable'));tr.addEventListener('click',event=>{if(event.target.closest('a'))return;if(p.catalogStatus==='published'){byId('trend-package').value=p.name;draw(rows);}});body.append(tr);}if(!rows.length){const tr=make('tr');const td=make('td','No packages match these filters.','empty');td.colSpan=6;tr.append(td);body.append(tr);}const period=byId('period-filter').value,key=period==='1'?'latestDayDownloads':period==='7'?'last7Downloads':'last30Downloads',countKey=period==='1'?'packagesWithLatestDay':period==='7'?'packagesWithLast7':'packagesWithLast30';const hasSubtotal=report.totals[key]===null&&coverage[countKey]>0,subtotal=period==='1'?report.packages.filter(p=>p.catalogStatus==='published'&&p.latestDayDownloads!==null).reduce((sum,p)=>sum+p.latestDayDownloads,0):period==='7'?report.totals.observedLast7Downloads:report.totals.observedLast30Downloads;put('download-label',hasSubtotal?(period==='1'?'Final-day observed subtotal':period+'-day observed subtotal'):(period==='1'?'Final-day downloads':period+'-day downloads'));put('download-total',number(hasSubtotal?subtotal:report.totals[key]));put('download-detail',coverage[countKey]+'/'+coverage.publishedPackages+' published packages have complete window coverage. '+(report.totals[key]===null?'Full-cohort total is unknown. ':'')+'Candidate downloads are unmeasured.');draw(rows);}
53
+ for(const id of ['period-filter','family-filter','role-filter','trend-package'])byId(id).addEventListener('change',refresh);byId('package-search').addEventListener('input',refresh);
54
+ byId('download-report').addEventListener('click',()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(report,null,2)],{type:'application/json'}));const a=make('a');a.href=url;a.download='constellation-report.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),0);});refresh();
55
+ ` + '</script></body></html>';
56
+ }
package/src/data.js ADDED
@@ -0,0 +1,127 @@
1
+ export const LIMITS = Object.freeze({ maxPackages: 1000, maxDays: 366, maxBytes: 2 * 1024 * 1024,
2
+ maxInputBytes: 24 * 1024 * 1024, maxConcurrency: 8, maxTimeoutMs: 60000, maxSamples: 10000, maxSamplePackages: 4096 });
3
+ const npmPattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
4
+ export function npmName(value) {
5
+ if (typeof value !== 'string' || value.length > 214 || !npmPattern.test(value)) throw new TypeError('invalid npm package name');
6
+ return value;
7
+ }
8
+ export function repoName(value) {
9
+ if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(value))
10
+ throw new TypeError('invalid GitHub repository name');
11
+ return value;
12
+ }
13
+ export function record(value, allowed, required, name) {
14
+ if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
15
+ throw new TypeError(`${name} must be a plain record`);
16
+ const out = Object.create(null);
17
+ for (const key of Reflect.ownKeys(value)) {
18
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
19
+ if (typeof key !== 'string' || !allowed.includes(key) || !descriptor || !Object.hasOwn(descriptor, 'value'))
20
+ throw new TypeError(`unknown field or accessor in ${name}`);
21
+ out[key] = descriptor.value;
22
+ }
23
+ if (required.some(key => !Object.hasOwn(out, key))) throw new TypeError(`missing ${name} field`);
24
+ return out;
25
+ }
26
+ export function integer(value, min, max, name) {
27
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new RangeError(`${name} must be an integer in ${min}..${max}`);
28
+ return value;
29
+ }
30
+ export function text(value, max, name) {
31
+ if (typeof value !== 'string' || value.length < 1 || value.length > max) throw new TypeError(`invalid ${name}`);
32
+ return value;
33
+ }
34
+ export function array(value, maximum, name) {
35
+ if (!Array.isArray(value) || value.length > maximum) throw new RangeError(`invalid ${name} array`);
36
+ return value;
37
+ }
38
+ export function snapshot(value) {
39
+ const ancestors = new Set(); let nodes = 0, chars = 0;
40
+ function copy(item, depth) {
41
+ if (++nodes > 2_000_000 || depth > 20) throw new RangeError('telemetry input exceeds structural limit');
42
+ if (item === null || typeof item === 'boolean') return item;
43
+ if (typeof item === 'number') {
44
+ if (!Number.isFinite(item)) throw new TypeError('telemetry numbers must be finite');
45
+ return item;
46
+ }
47
+ if (typeof item === 'string') {
48
+ chars += item.length;
49
+ if (chars > LIMITS.maxInputBytes) throw new RangeError('telemetry input exceeds text limit');
50
+ return item;
51
+ }
52
+ if (!item || typeof item !== 'object' || ancestors.has(item)) throw new TypeError('telemetry input must be acyclic JSON data');
53
+ const isArray = Array.isArray(item);
54
+ if (!(isArray ? Object.getPrototypeOf(item) === Array.prototype : [Object.prototype, null].includes(Object.getPrototypeOf(item))))
55
+ throw new TypeError('telemetry input must contain plain data');
56
+ if (isArray && item.length > 1_000_000) throw new RangeError('telemetry array exceeds limit');
57
+ ancestors.add(item); const out = isArray ? [] : Object.create(null); let count = 0;
58
+ for (const key of Reflect.ownKeys(item)) {
59
+ if (isArray && key === 'length') continue;
60
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) ||
61
+ (isArray && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= item.length))) throw new TypeError('unsafe telemetry property');
62
+ chars += key.length;
63
+ if (chars > LIMITS.maxInputBytes) throw new RangeError('telemetry input exceeds text limit');
64
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
65
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw new TypeError('telemetry input accessors are forbidden');
66
+ out[key] = copy(descriptor.value, depth + 1); count++;
67
+ }
68
+ if (isArray && (out.length !== item.length || count !== item.length)) throw new TypeError('telemetry arrays must be dense');
69
+ ancestors.delete(item); return out;
70
+ }
71
+ const out = copy(value, 0);
72
+ if (new TextEncoder().encode(JSON.stringify(out)).length > LIMITS.maxInputBytes) throw new RangeError('telemetry input exceeds byte limit');
73
+ return out;
74
+ }
75
+ export function isoDay(value) {
76
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new TypeError('dates must use YYYY-MM-DD');
77
+ const stamp = Date.parse(`${value}T00:00:00.000Z`);
78
+ if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== value) throw new TypeError('invalid calendar date');
79
+ return value;
80
+ }
81
+ export function isoStamp(value) {
82
+ const parts = typeof value === 'string' && value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/);
83
+ if (!parts || !Number.isFinite(Date.parse(value)) ||
84
+ new Date(Date.parse(value)).toISOString() !== `${parts[1]}.${(parts[2] || '').padEnd(3, '0')}Z`)
85
+ throw new TypeError('observedAt must be a valid UTC ISO timestamp');
86
+ return value;
87
+ }
88
+ export function addDays(day, amount) { return new Date(Date.parse(`${day}T00:00:00Z`) + amount * 86400000).toISOString().slice(0, 10); }
89
+ export function range(start, end) {
90
+ isoDay(start); isoDay(end);
91
+ const count = (Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000 + 1;
92
+ integer(count, 1, LIMITS.maxDays, 'date range length');
93
+ return Array.from({ length: count }, (_, i) => addDays(start, i));
94
+ }
95
+ export function catalogSnapshot(input) {
96
+ const catalog = snapshot(input);
97
+ const raw = record(catalog, ['schema', 'repository', 'packages', 'profiles'], ['schema', 'repository', 'packages', 'profiles'], 'catalog');
98
+ if (raw.schema !== 'ruvnet.constellation-catalog/1') throw new TypeError('unsupported catalog schema');
99
+ repoName(raw.repository); const seen = new Set();
100
+ const packages = array(raw.packages, LIMITS.maxPackages, 'catalog packages').map(value => {
101
+ const p = record(value, ['name', 'role', 'family', 'status', 'version', 'repository', 'directory', 'description', 'contracts', 'links', 'proposedScope'],
102
+ ['name', 'role', 'family', 'status', 'version', 'repository', 'description'], 'package');
103
+ npmName(p.name); if (seen.has(p.name)) throw new TypeError('duplicate catalog package'); seen.add(p.name);
104
+ if (!['nucleus', 'primitive', 'satellite'].includes(p.role) || !['published', 'candidate'].includes(p.status)) throw new TypeError('invalid catalog role or status');
105
+ text(p.family, 64, 'package family'); text(p.version, 128, 'package version'); text(p.description, 2000, 'package description'); repoName(p.repository);
106
+ if (p.directory !== undefined && (typeof p.directory !== 'string' || p.directory.length > 512 || p.directory.split('/').some(v => !/^[A-Za-z0-9_.@-]+$/.test(v) || ['.', '..'].includes(v))))
107
+ throw new TypeError('unsafe package directory');
108
+ return p;
109
+ });
110
+ if (!packages.length) throw new RangeError('catalog requires packages');
111
+ for (const p of array(raw.profiles, 100, 'profiles')) {
112
+ const profile = record(p, ['id', 'description', 'packages'], ['id', 'description', 'packages'], 'profile');
113
+ text(profile.id, 64, 'profile id'); text(profile.description, 2000, 'profile description');
114
+ for (const name of array(profile.packages, LIMITS.maxPackages, 'profile packages')) if (!seen.has(npmName(name))) throw new TypeError('profile names unknown package');
115
+ }
116
+ return { schema: raw.schema, repository: raw.repository, packages, profiles: raw.profiles };
117
+ }
118
+ export function names(value, maximum = 4096) {
119
+ const values = array(value, maximum, 'package names');
120
+ if (new Set(values).size !== values.length) throw new TypeError('duplicate package names');
121
+ return values.map(npmName).sort();
122
+ }
123
+ export function safeSum(values) {
124
+ let sum = 0;
125
+ for (const value of values) sum = integer(sum + value, 0, Number.MAX_SAFE_INTEGER, 'download total');
126
+ return sum;
127
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ export interface CatalogPackage {
2
+ name: string; role: 'nucleus' | 'primitive' | 'satellite'; family: string; status: 'published' | 'candidate';
3
+ version: string; repository: string; description: string; directory?: string;
4
+ contracts?: string[]; links?: {npm?: string; source?: string}; proposedScope?: string;
5
+ }
6
+ export interface ConstellationCatalog {
7
+ schema: 'ruvnet.constellation-catalog/1'; repository: string; packages: CatalogPackage[];
8
+ profiles: {id: string; description: string; packages: string[]}[];
9
+ }
10
+ export interface DownloadDay { day: string; downloads: number }
11
+ export interface PackageObservation {
12
+ name: string; status: 'ok' | 'unavailable' | 'candidate'; days: DownloadDay[];
13
+ version?: string; dependencies?: string[]; peerDependencies?: string[]; optionalDependencies?: string[];
14
+ metadataStatus?: 'ok' | 'unavailable'; metadataError?: string; error?: string; repository?: string;
15
+ sources?: {downloads: string; metadata: string};
16
+ }
17
+ export interface RepositoryObservation {
18
+ repository: string; status: 'ok' | 'unavailable'; stars?: number; openIssuesAndPRs?: number; source?: string; error?: string;
19
+ }
20
+ export interface ConstellationObservations {
21
+ schema: 'ruvnet.constellation-observations/1'; observedAt: string; start: string; end: string;
22
+ packages: PackageObservation[]; repositories: RepositoryObservation[];
23
+ }
24
+ export interface CollectionOptions {
25
+ start: string; end: string;
26
+ /** Trusted fetch capability; defaults to global fetch. Must honor AbortSignal for transport cleanup. */
27
+ fetch?: typeof globalThis.fetch;
28
+ /** Whole-collection deadline, default 20000 ms, maximum 60000. */
29
+ timeoutMs?: number;
30
+ /** 1..8 task runners; default 4. */
31
+ concurrency?: number;
32
+ }
33
+ export interface AdoptionSample {
34
+ /** Caller-supplied pseudonymous SHA-256 project identifier, never copied into the report. */
35
+ sampleId: string;
36
+ observedAt: string;
37
+ packages: string[];
38
+ }
39
+ export interface ConstellationAdoption {
40
+ schema: 'ruvnet.constellation-adoption/1'; consent: true; samples: AdoptionSample[];
41
+ }
42
+ export interface DeclaredLink { source: string; target: string; kind: 'runtime' | 'peer' | 'optional' }
43
+ export interface PackageMetrics {
44
+ name: string; role: CatalogPackage['role']; family: string; catalogStatus: CatalogPackage['status']; status: PackageObservation['status'];
45
+ catalogVersion: string; version: string | null; description: string; repository: string; npmURL: string | null; sourceURL: string;
46
+ latestDayDownloads: number | null; last7Downloads: number | null; last30Downloads: number | null;
47
+ weekdayBaseline: number | null; weekdayDeltaPercent: number | null; weekdayBaselineDays: number;
48
+ days: DownloadDay[]; coverage: {observedDays: number; expectedDays: number}; metadataStatus: 'ok' | 'unavailable';
49
+ dependencies: string[]; peerDependencies: string[]; optionalDependencies: string[];
50
+ runtimeDependencyCount: number | null; peerDependencyCount: number | null; optionalDependencyCount: number | null; declaredLinks: DeclaredLink[]; error?: string;
51
+ }
52
+ export interface AttachMetrics {
53
+ status: 'available' | 'unavailable'; unit: 'consenting-project-snapshots'; denominator: number; numerator: number; percentage: number | null; projects: number;
54
+ perPrimitive: {name: string; numerator: number; denominator: number; percentage: number | null}[];
55
+ source: {provided: boolean; samplesReceived: number; snapshotsInWindow: number; duplicateSnapshotsRemoved: number}; note: string;
56
+ }
57
+ export interface ConstellationReport {
58
+ schema: 'ruvnet.constellation-report/1'; observedAt: string; start: string; end: string; packages: PackageMetrics[];
59
+ totals: {
60
+ latestDayDownloads: number | null; last7Downloads: number | null; last30Downloads: number | null;
61
+ observedLast7Downloads: number; observedLast30Downloads: number;
62
+ coverage: {catalogPackages: number; publishedPackages: number; candidatePackages: number; packagesWithLatestDay: number; packagesWithLast7: number; packagesWithLast30: number; unavailablePackages: number};
63
+ };
64
+ dailySeries: {day: string; downloads: number | null; observedDownloads: number; observedPackages: number; expectedPackages: number}[];
65
+ repositories: {repository: string; status: 'ok' | 'unavailable'; stars: number | null; openIssuesAndPRs: number | null; source: string; error?: string}[];
66
+ weekdaySummary: {eligiblePackages: number; aboveBaseline: number; netChange: number; families: {family: string; netChange: number; shareOfNetChangePercent: number | null}[]; note: string};
67
+ correlations: {kind: 'daily-download-correlation'; consideredPackages: number; eligiblePairs: number; pairs: {left: string; right: string; r: number; samples: number}[]; note: string};
68
+ declaredLinks: DeclaredLink[]; declaredLinksCoverage: {observedManifests: number; expectedManifests: number}; attach: AttachMetrics; definitions: {downloads: string; totals: string; weekday: string; repositories: string; adoption: string};
69
+ }
70
+ /** Read-only explicit collection; candidates are not queried and unknown metrics are never zero-filled. */
71
+ export function collectPublicMetrics(catalog: ConstellationCatalog, options: CollectionOptions): Promise<ConstellationObservations>;
72
+ /** Synchronous, owned report. Aggregate downloads cannot identify install attach or distinct users. */
73
+ export function analyzeConstellation(catalog: ConstellationCatalog, observations: ConstellationObservations, options?: {adoption?: ConstellationAdoption}): ConstellationReport;
74
+ /** Standalone HTML with local controls, safe DOM construction and no trackers or remote assets. */
75
+ export function renderConstellationDashboard(report: ConstellationReport): string;
76
+ export const TELEMETRY_LIMITS: Readonly<{maxPackages: number; maxDays: number; maxBytes: number; maxInputBytes: number; maxConcurrency: number; maxTimeoutMs: number; maxSamples: number; maxSamplePackages: number}>;
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { collectPublicMetrics } from './collect.js';
2
+ export { analyzeConstellation } from './analyze.js';
3
+ export { renderConstellationDashboard } from './dashboard.js';
4
+ export { LIMITS as TELEMETRY_LIMITS } from './data.js';