@netgreener/runtime 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.
Files changed (138) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +28 -0
  3. package/TUTORIAL.md +495 -0
  4. package/dist/aggregator.d.ts +43 -0
  5. package/dist/aggregator.js +270 -0
  6. package/dist/analyze/analyzeGate.d.ts +44 -0
  7. package/dist/analyze/analyzeGate.js +114 -0
  8. package/dist/analyze/apiOverconsumptionDetector.d.ts +13 -0
  9. package/dist/analyze/apiOverconsumptionDetector.js +75 -0
  10. package/dist/analyze/astDetectors.d.ts +38 -0
  11. package/dist/analyze/astDetectors.js +566 -0
  12. package/dist/analyze/buildResourceFindings.d.ts +34 -0
  13. package/dist/analyze/buildResourceFindings.js +86 -0
  14. package/dist/analyze/cli.d.ts +20 -0
  15. package/dist/analyze/cli.js +186 -0
  16. package/dist/analyze/cpuResourceDetectors.d.ts +35 -0
  17. package/dist/analyze/cpuResourceDetectors.js +163 -0
  18. package/dist/analyze/heuristicScan.d.ts +61 -0
  19. package/dist/analyze/heuristicScan.js +91 -0
  20. package/dist/analyze/index.d.ts +15 -0
  21. package/dist/analyze/index.js +15 -0
  22. package/dist/analyze/loadTypescript.d.ts +8 -0
  23. package/dist/analyze/loadTypescript.js +23 -0
  24. package/dist/analyze/memoryIoDetectors.d.ts +15 -0
  25. package/dist/analyze/memoryIoDetectors.js +68 -0
  26. package/dist/analyze/preferAstDetectors.d.ts +18 -0
  27. package/dist/analyze/preferAstDetectors.js +91 -0
  28. package/dist/analyze/projectScan.d.ts +19 -0
  29. package/dist/analyze/projectScan.js +115 -0
  30. package/dist/analyze/resourceAdmission.d.ts +33 -0
  31. package/dist/analyze/resourceAdmission.js +91 -0
  32. package/dist/analyze/resourceFindingCandidate.d.ts +61 -0
  33. package/dist/analyze/resourceFindingCandidate.js +75 -0
  34. package/dist/analyze/retryAmplificationDetector.d.ts +11 -0
  35. package/dist/analyze/retryAmplificationDetector.js +55 -0
  36. package/dist/analyze/schemas/resource-finding.schema.json +352 -0
  37. package/dist/analyze/serviceDiscovery.d.ts +66 -0
  38. package/dist/analyze/serviceDiscovery.js +210 -0
  39. package/dist/analyze/unboundedParallelismDetector.d.ts +11 -0
  40. package/dist/analyze/unboundedParallelismDetector.js +57 -0
  41. package/dist/analyze/validateResourceFinding.d.ts +19 -0
  42. package/dist/analyze/validateResourceFinding.js +46 -0
  43. package/dist/bullmq.d.ts +33 -0
  44. package/dist/bullmq.js +106 -0
  45. package/dist/cli/helpText.d.ts +8 -0
  46. package/dist/cli/helpText.js +99 -0
  47. package/dist/cli/netgreener.d.ts +12 -0
  48. package/dist/cli/netgreener.js +65 -0
  49. package/dist/collectorIpc.d.ts +28 -0
  50. package/dist/collectorIpc.js +189 -0
  51. package/dist/collectorMetadata.d.ts +15 -0
  52. package/dist/collectorMetadata.js +33 -0
  53. package/dist/config.d.ts +41 -0
  54. package/dist/config.js +97 -0
  55. package/dist/contract/index.d.ts +1 -0
  56. package/dist/contract/index.js +1 -0
  57. package/dist/contract/observation-envelope.schema.json +331 -0
  58. package/dist/contract/observation-protobuf-view.schema.json +773 -0
  59. package/dist/contract/observation-v1.d.mts +42 -0
  60. package/dist/contract/observation-v1.mjs +555 -0
  61. package/dist/contract/observationIdentity.d.ts +11 -0
  62. package/dist/contract/observationIdentity.js +48 -0
  63. package/dist/contract/observationProjection.d.ts +3 -0
  64. package/dist/contract/observationProjection.js +29 -0
  65. package/dist/contract/validate.d.ts +20 -0
  66. package/dist/contract/validate.js +227 -0
  67. package/dist/dogfood/mp3WorkerGates.d.ts +54 -0
  68. package/dist/dogfood/mp3WorkerGates.js +86 -0
  69. package/dist/dogfood/retainDryRunArtifact.d.ts +45 -0
  70. package/dist/dogfood/retainDryRunArtifact.js +103 -0
  71. package/dist/dogfood/tenantDogfoodGates.d.ts +76 -0
  72. package/dist/dogfood/tenantDogfoodGates.js +206 -0
  73. package/dist/exporter.d.ts +56 -0
  74. package/dist/exporter.js +304 -0
  75. package/dist/express.d.ts +44 -0
  76. package/dist/express.js +118 -0
  77. package/dist/externalApiMeter.d.ts +72 -0
  78. package/dist/externalApiMeter.js +1167 -0
  79. package/dist/fastify.d.ts +53 -0
  80. package/dist/fastify.js +148 -0
  81. package/dist/index.d.ts +32 -0
  82. package/dist/index.js +31 -0
  83. package/dist/n4/deploymentMatrix.d.ts +22 -0
  84. package/dist/n4/deploymentMatrix.js +57 -0
  85. package/dist/n4/missingnessInventory.d.ts +22 -0
  86. package/dist/n4/missingnessInventory.js +72 -0
  87. package/dist/nest.d.ts +42 -0
  88. package/dist/nest.js +87 -0
  89. package/dist/processResources.d.ts +28 -0
  90. package/dist/processResources.js +31 -0
  91. package/dist/processRuntime.d.ts +35 -0
  92. package/dist/processRuntime.js +96 -0
  93. package/dist/requestContext.d.ts +21 -0
  94. package/dist/requestContext.js +33 -0
  95. package/dist/runtime.d.ts +60 -0
  96. package/dist/runtime.js +289 -0
  97. package/dist/runtimeHealth.d.ts +35 -0
  98. package/dist/runtimeHealth.js +121 -0
  99. package/dist/serverlessHints.d.ts +26 -0
  100. package/dist/serverlessHints.js +52 -0
  101. package/dist/tenantContext.d.ts +78 -0
  102. package/dist/tenantContext.js +226 -0
  103. package/dist/tenantDefaults.d.ts +9 -0
  104. package/dist/tenantDefaults.js +9 -0
  105. package/dist/types.d.ts +131 -0
  106. package/dist/types.js +2 -0
  107. package/dist/uploader.d.ts +23 -0
  108. package/dist/uploader.js +52 -0
  109. package/dist/windowId.d.ts +2 -0
  110. package/dist/windowId.js +7 -0
  111. package/examples/analyze-manifest.mjs +30 -0
  112. package/examples/bullmq-live-smoke.mjs +178 -0
  113. package/examples/bullmq-mp3-worker-gate.mjs +237 -0
  114. package/examples/express-dry-run.mjs +72 -0
  115. package/examples/process-mp3-restart-gate.mjs +135 -0
  116. package/examples/retain-dry-run.mjs +110 -0
  117. package/examples/tenant-dogfood.mjs +269 -0
  118. package/fixtures/analyze-sample/architectureNoise.ts +13 -0
  119. package/fixtures/analyze-sample/cpuHotPaths.ts +27 -0
  120. package/fixtures/analyze-sample/fanOutClient.ts +15 -0
  121. package/fixtures/analyze-sample/inferenceHotPaths.ts +22 -0
  122. package/fixtures/analyze-sample/memoryIoHotPaths.ts +21 -0
  123. package/fixtures/analyze-sample/modelLoadHotPaths.ts +11 -0
  124. package/fixtures/analyze-sample/nestedLookup.ts +15 -0
  125. package/fixtures/analyze-sample/retryClient.ts +10 -0
  126. package/fixtures/analyze-sample/securitySmell.ts +8 -0
  127. package/fixtures/analyze-sample/server.ts +11 -0
  128. package/fixtures/analyze-sample/styleOnly.ts +7 -0
  129. package/fixtures/analyze-sample/worker.ts +4 -0
  130. package/fixtures/dual-view-projection.v1.json +53 -0
  131. package/fixtures/identity-preimages.v1.json +42 -0
  132. package/fixtures/mp2_shadow_digest_golden.v1.json +47 -0
  133. package/fixtures/projection-batch-document.v1.json +170 -0
  134. package/fixtures/projection-batch.v1.json +9 -0
  135. package/fixtures/python_reference_shapes.md +24 -0
  136. package/fixtures/session_metadata_runtime_v0.json +120 -0
  137. package/fixtures/session_metadata_with_tenant.json +85 -0
  138. package/package.json +137 -0
@@ -0,0 +1,270 @@
1
+ const MAX_UNITS = 500;
2
+ const MAX_SERVICE_UNIT_LEN = 256;
3
+ function normalizeServiceUnit(raw) {
4
+ const trimmed = raw.trim();
5
+ if (!trimmed)
6
+ return 'UNKNOWN';
7
+ if (trimmed.length <= MAX_SERVICE_UNIT_LEN)
8
+ return trimmed;
9
+ return `${trimmed.slice(0, MAX_SERVICE_UNIT_LEN - 1)}…`;
10
+ }
11
+ /** Contain NaN / ±Infinity / negatives so session_metadata numerics stay finite. */
12
+ function nonNegativeFinite(value) {
13
+ return Number.isFinite(value) && value >= 0 ? value : 0;
14
+ }
15
+ export class ServiceRuntimeAggregator {
16
+ units = new Map();
17
+ byTenant = new Map();
18
+ windowStartedAt = Date.now();
19
+ resetWindow() {
20
+ this.units.clear();
21
+ this.byTenant.clear();
22
+ this.windowStartedAt = Date.now();
23
+ }
24
+ windowStartedMs() {
25
+ return this.windowStartedAt;
26
+ }
27
+ record(sample) {
28
+ const key = normalizeServiceUnit(sample.serviceUnit);
29
+ this.recordIntoMap(this.units, key, sample);
30
+ if (sample.tenantId) {
31
+ let tenantUnits = this.byTenant.get(sample.tenantId);
32
+ if (!tenantUnits) {
33
+ tenantUnits = new Map();
34
+ this.byTenant.set(sample.tenantId, tenantUnits);
35
+ }
36
+ this.recordIntoMap(tenantUnits, key, sample);
37
+ }
38
+ }
39
+ recordIntoMap(target, key, sample) {
40
+ let unit = target.get(key);
41
+ if (!unit) {
42
+ const needsOverflow = key !== '__other__' && target.size >= MAX_UNITS - 1 && !target.has(key);
43
+ if (needsOverflow) {
44
+ key = '__other__';
45
+ unit = target.get(key);
46
+ }
47
+ if (!unit) {
48
+ unit = {
49
+ serviceUnit: key,
50
+ unitType: sample.unitType || 'http_route',
51
+ calls: 0,
52
+ errors: 0,
53
+ durationMsTotal: 0,
54
+ durationMsMax: 0,
55
+ cpuMsTotal: 0,
56
+ cpuMsMeasured: false,
57
+ peakRssKbMax: 0,
58
+ };
59
+ target.set(key, unit);
60
+ }
61
+ }
62
+ unit.calls += 1;
63
+ if (sample.error)
64
+ unit.errors += 1;
65
+ // Math.max(0, NaN) === NaN — must reject non-finite before accumulate.
66
+ const ms = nonNegativeFinite(sample.durationMs);
67
+ unit.durationMsTotal += ms;
68
+ unit.durationMsMax = Math.max(unit.durationMsMax, ms);
69
+ if (sample.cpuTimeMs !== undefined) {
70
+ unit.cpuMsTotal += nonNegativeFinite(sample.cpuTimeMs);
71
+ unit.cpuMsMeasured = true;
72
+ }
73
+ if (sample.peakRssKb !== undefined) {
74
+ const rss = nonNegativeFinite(sample.peakRssKb);
75
+ if (rss > 0)
76
+ unit.peakRssKbMax = Math.max(unit.peakRssKbMax, rss);
77
+ }
78
+ }
79
+ buildUnitsList(source, windowEnergyKwh, equalShare, totalDuration) {
80
+ const list = [...source.values()].filter((u) => u.calls > 0);
81
+ if (!list.length)
82
+ return [];
83
+ const energyBudget = nonNegativeFinite(windowEnergyKwh);
84
+ const sorted = list.sort((a, b) => b.durationMsTotal - a.durationMsTotal);
85
+ const units = sorted.map((u) => {
86
+ const share = equalShare ? 1 / list.length : u.durationMsTotal / totalDuration;
87
+ const energy = energyBudget * share;
88
+ const avg = u.calls ? u.durationMsTotal / u.calls : 0;
89
+ const cpuSeconds = u.cpuMsMeasured
90
+ ? u.cpuMsTotal / 1000
91
+ : u.durationMsTotal / 1000;
92
+ const unit = {
93
+ service_unit: u.serviceUnit,
94
+ unit_type: u.unitType,
95
+ calls: u.calls,
96
+ errors: u.errors,
97
+ cpu_seconds_total: Math.round(cpuSeconds * 10000) / 10000,
98
+ energy_kwh: energy,
99
+ carbon_g: energy * 0.4 * 1000,
100
+ duration_ms: {
101
+ avg: Math.round(avg * 100) / 100,
102
+ max: Math.round(u.durationMsMax * 100) / 100,
103
+ },
104
+ };
105
+ if (u.peakRssKbMax > 0) {
106
+ unit.peak_rss_kb_max = Math.round(u.peakRssKbMax);
107
+ }
108
+ return unit;
109
+ });
110
+ const allocatedRaw = units.reduce((s, u) => s + u.energy_kwh, 0);
111
+ const drift = energyBudget - allocatedRaw;
112
+ if (units.length && Math.abs(drift) > 0) {
113
+ units[0].energy_kwh += drift;
114
+ units[0].carbon_g = units[0].energy_kwh * 0.4 * 1000;
115
+ }
116
+ for (const u of units) {
117
+ u.energy_kwh = Math.round(u.energy_kwh * 1e12) / 1e12;
118
+ u.carbon_g = Math.round(u.carbon_g * 1e6) / 1e6;
119
+ }
120
+ return units;
121
+ }
122
+ hasData() {
123
+ for (const u of this.units.values()) {
124
+ if (u.calls > 0)
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ totalDurationMs() {
130
+ let total = 0;
131
+ for (const u of this.units.values()) {
132
+ if (u.calls > 0)
133
+ total += u.durationMsTotal;
134
+ }
135
+ return total;
136
+ }
137
+ /** Sum of measured process CPU ms in this window (0 if none measured). */
138
+ totalMeasuredCpuMs() {
139
+ let total = 0;
140
+ for (const u of this.units.values()) {
141
+ if (u.calls > 0 && u.cpuMsMeasured)
142
+ total += u.cpuMsTotal;
143
+ }
144
+ return total;
145
+ }
146
+ /** True when any unit recorded measured process CPU (not duration proxy). */
147
+ hasMeasuredCpu() {
148
+ for (const u of this.units.values()) {
149
+ if (u.calls > 0 && u.cpuMsMeasured)
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ /** True when any unit recorded peak RSS. */
155
+ hasMeasuredRss() {
156
+ for (const u of this.units.values()) {
157
+ if (u.calls > 0 && u.peakRssKbMax > 0)
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ /**
163
+ * Build service_runtime_v0. N1 uses duration-share energy attribution (trend).
164
+ * CPU-accurate share can land later without changing the schema.
165
+ */
166
+ buildV0(opts) {
167
+ const list = [...this.units.values()].filter((u) => u.calls > 0);
168
+ if (!list.length)
169
+ return null;
170
+ const windowEnergyKwh = nonNegativeFinite(opts.windowEnergyKwh);
171
+ const windowSeconds = nonNegativeFinite(opts.windowSeconds);
172
+ const totalDuration = list.reduce((s, u) => s + u.durationMsTotal, 0);
173
+ const equalShare = totalDuration <= 0;
174
+ const units = this.buildUnitsList(this.units, windowEnergyKwh, equalShare, totalDuration);
175
+ const byTenant = {};
176
+ const totalBusy = list.reduce((s, u) => s + u.durationMsTotal, 0);
177
+ for (const [tenantId, tenantMap] of this.byTenant.entries()) {
178
+ const tenantList = [...tenantMap.values()].filter((u) => u.calls > 0);
179
+ if (!tenantList.length)
180
+ continue;
181
+ const tenantBusy = tenantList.reduce((s, u) => s + u.durationMsTotal, 0);
182
+ const tenantEnergy = totalBusy > 0
183
+ ? windowEnergyKwh * (tenantBusy / totalBusy)
184
+ : windowEnergyKwh / Math.max(1, this.byTenant.size);
185
+ const tenantDuration = tenantList.reduce((s, u) => s + u.durationMsTotal, 0);
186
+ const tenantEqual = tenantDuration <= 0;
187
+ const tenantUnits = this.buildUnitsList(tenantMap, tenantEnergy, tenantEqual, tenantDuration);
188
+ if (!tenantUnits.length)
189
+ continue;
190
+ byTenant[tenantId] = {
191
+ tenant_id: tenantId,
192
+ calls: tenantUnits.reduce((s, u) => s + u.calls, 0),
193
+ errors: tenantUnits.reduce((s, u) => s + u.errors, 0),
194
+ units: tenantUnits,
195
+ };
196
+ }
197
+ const allocated = units.reduce((s, u) => s + u.energy_kwh, 0);
198
+ const residual = windowEnergyKwh - allocated;
199
+ const tolerance = Math.max(1e-12, Math.abs(windowEnergyKwh) * 1e-6);
200
+ return {
201
+ framework: opts.framework,
202
+ collector: opts.collector,
203
+ window_seconds: Math.round(windowSeconds * 100) / 100,
204
+ attribution: equalShare ? 'equal_share_no_cpu_signal' : 'duration_share',
205
+ accuracy: 'trend',
206
+ units,
207
+ ...(Object.keys(byTenant).length ? { by_tenant: byTenant } : {}),
208
+ allocation_reconciliation_v0: {
209
+ schema_version: 1,
210
+ window_energy_kwh: windowEnergyKwh,
211
+ allocated_energy_kwh: allocated,
212
+ residual_energy_kwh: residual,
213
+ tolerance_kwh: tolerance,
214
+ passed: Math.abs(residual) <= tolerance,
215
+ scope: 'endpoint_allocation_arithmetic_only',
216
+ endpoint_capture_coverage: null,
217
+ capture_completeness: 'unknown',
218
+ },
219
+ };
220
+ }
221
+ /** Take a snapshot and reset live window (Python take_snapshot analogue). */
222
+ takeSnapshot() {
223
+ const snap = new ServiceRuntimeAggregator();
224
+ snap.units = this.units;
225
+ snap.byTenant = this.byTenant;
226
+ snap.windowStartedAt = this.windowStartedAt;
227
+ this.units = new Map();
228
+ this.byTenant = new Map();
229
+ this.windowStartedAt = Date.now();
230
+ return snap;
231
+ }
232
+ mergeSnapshot(snapshot) {
233
+ if (snapshot.hasData()) {
234
+ this.windowStartedAt = Math.min(this.windowStartedAt, snapshot.windowStartedAt);
235
+ }
236
+ this.mergeMap(this.units, snapshot.units);
237
+ for (const [tenantId, sourceMap] of snapshot.byTenant.entries()) {
238
+ let targetMap = this.byTenant.get(tenantId);
239
+ if (!targetMap) {
240
+ targetMap = new Map();
241
+ this.byTenant.set(tenantId, targetMap);
242
+ }
243
+ this.mergeMap(targetMap, sourceMap);
244
+ }
245
+ }
246
+ mergeMap(target, source) {
247
+ for (const [rawKey, src] of source) {
248
+ let key = rawKey;
249
+ let dst = target.get(key);
250
+ if (!dst) {
251
+ const needsOverflow = key !== '__other__' && target.size >= MAX_UNITS - 1 && !target.has(key);
252
+ if (needsOverflow) {
253
+ key = '__other__';
254
+ dst = target.get(key);
255
+ }
256
+ }
257
+ if (!dst) {
258
+ target.set(key, { ...src, serviceUnit: key });
259
+ continue;
260
+ }
261
+ dst.calls += src.calls;
262
+ dst.errors += src.errors;
263
+ dst.durationMsTotal += src.durationMsTotal;
264
+ dst.durationMsMax = Math.max(dst.durationMsMax, src.durationMsMax);
265
+ dst.cpuMsTotal += src.cpuMsTotal;
266
+ dst.cpuMsMeasured = dst.cpuMsMeasured || src.cpuMsMeasured;
267
+ dst.peakRssKbMax = Math.max(dst.peakRssKbMax, src.peakRssKbMax);
268
+ }
269
+ }
270
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * CI gate helpers for Analyze resource candidates (Node twin of Python
3
+ * ``findings_bridge`` fail-on, adapted to ``resource_finding_v1``).
4
+ *
5
+ * Resource candidates have no severity rank — gates match ``mechanism_id``
6
+ * or the aliases ``any`` / ``candidate``.
7
+ */
8
+ import type { ResourceFindingCandidateV1 } from './resourceFindingCandidate.js';
9
+ export type AnalyzeGateLimit = number | null;
10
+ /** Effective count cap when ``--fail-on`` is set (default 0 = any match fails). */
11
+ export declare function resolveAnalyzeGateLimit(failOn: string | null | undefined, maxAtOrAbove: number | null | undefined): AnalyzeGateLimit;
12
+ /**
13
+ * Count candidates that match ``failOn``.
14
+ * - ``any`` / ``candidate`` → all findings
15
+ * - comma-separated mechanism IDs → exact mechanism_id match
16
+ */
17
+ export declare function countGateFindings(findings: Iterable<ResourceFindingCandidateV1>, failOn: string): number;
18
+ export declare function findingsExceedFailThreshold(findings: Iterable<ResourceFindingCandidateV1>, failOn: string, maxAtOrAbove?: number): boolean;
19
+ export declare function formatAnalyzeGateFailureMessage(findings: Iterable<ResourceFindingCandidateV1>, failOn: string, maxAtOrAbove?: number): string;
20
+ export type AnalyzeGateSummaryV0 = {
21
+ schema: 'netgreener_analyze_gate_summary_v0';
22
+ upload: 'never';
23
+ ok: boolean;
24
+ exit_code: 0 | 1;
25
+ project_id: string;
26
+ files_scanned: number;
27
+ findings_total: number;
28
+ by_mechanism: Record<string, number>;
29
+ gate: null | {
30
+ fail_on: string;
31
+ max_at_or_above: number;
32
+ matching: number;
33
+ exceeded: boolean;
34
+ message: string | null;
35
+ };
36
+ };
37
+ /** Machine-readable CI annotation payload (no upload). */
38
+ export declare function buildAnalyzeGateSummary(input: {
39
+ projectId: string;
40
+ filesScanned: number;
41
+ findings: ResourceFindingCandidateV1[];
42
+ failOn?: string | null;
43
+ maxAtOrAbove?: number | null;
44
+ }): AnalyzeGateSummaryV0;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * CI gate helpers for Analyze resource candidates (Node twin of Python
3
+ * ``findings_bridge`` fail-on, adapted to ``resource_finding_v1``).
4
+ *
5
+ * Resource candidates have no severity rank — gates match ``mechanism_id``
6
+ * or the aliases ``any`` / ``candidate``.
7
+ */
8
+ /** Effective count cap when ``--fail-on`` is set (default 0 = any match fails). */
9
+ export function resolveAnalyzeGateLimit(failOn, maxAtOrAbove) {
10
+ if (!failOn)
11
+ return null;
12
+ return maxAtOrAbove == null ? 0 : maxAtOrAbove;
13
+ }
14
+ function normalizeFailOn(failOn) {
15
+ return String(failOn || '')
16
+ .trim()
17
+ .toLowerCase();
18
+ }
19
+ /**
20
+ * Count candidates that match ``failOn``.
21
+ * - ``any`` / ``candidate`` → all findings
22
+ * - comma-separated mechanism IDs → exact mechanism_id match
23
+ */
24
+ export function countGateFindings(findings, failOn) {
25
+ const key = normalizeFailOn(failOn);
26
+ if (!key || key === 'none' || key === 'off')
27
+ return 0;
28
+ const list = [...findings];
29
+ if (key === 'any' || key === 'candidate')
30
+ return list.length;
31
+ const wanted = new Set(key
32
+ .split(',')
33
+ .map((s) => s.trim())
34
+ .filter(Boolean));
35
+ if (wanted.size === 0)
36
+ return 0;
37
+ return list.filter((f) => wanted.has(String(f.mechanism_id || '').toLowerCase())).length;
38
+ }
39
+ export function findingsExceedFailThreshold(findings, failOn, maxAtOrAbove = 0) {
40
+ return countGateFindings(findings, failOn) > maxAtOrAbove;
41
+ }
42
+ export function formatAnalyzeGateFailureMessage(findings, failOn, maxAtOrAbove = 0) {
43
+ const list = [...findings];
44
+ const bad = countGateFindings(list, failOn);
45
+ const matching = listMatchingFindings(list, failOn);
46
+ const top = new Map();
47
+ for (const f of matching) {
48
+ const id = f.mechanism_id || 'unknown';
49
+ top.set(id, (top.get(id) || 0) + 1);
50
+ }
51
+ const topStr = [...top.entries()]
52
+ .sort((a, b) => b[1] - a[1])
53
+ .slice(0, 4)
54
+ .map(([name, count]) => `${name} x${count}`)
55
+ .join(', ') || 'n/a';
56
+ const limit = maxAtOrAbove > 0 ? ` (max ${maxAtOrAbove})` : '';
57
+ return (`NetGreener gate failed: ${bad} candidate(s) matching '${failOn}'${limit} ` +
58
+ `(${list.length} total). Top: ${topStr}`);
59
+ }
60
+ function listMatchingFindings(findings, failOn) {
61
+ const key = normalizeFailOn(failOn);
62
+ if (!key || key === 'none' || key === 'off')
63
+ return [];
64
+ if (key === 'any' || key === 'candidate')
65
+ return findings;
66
+ const wanted = new Set(key
67
+ .split(',')
68
+ .map((s) => s.trim())
69
+ .filter(Boolean));
70
+ return findings.filter((f) => wanted.has(String(f.mechanism_id || '').toLowerCase()));
71
+ }
72
+ /** Machine-readable CI annotation payload (no upload). */
73
+ export function buildAnalyzeGateSummary(input) {
74
+ const findings = [...input.findings];
75
+ const by_mechanism = {};
76
+ for (const f of findings) {
77
+ by_mechanism[f.mechanism_id] = (by_mechanism[f.mechanism_id] || 0) + 1;
78
+ }
79
+ const limit = resolveAnalyzeGateLimit(input.failOn, input.maxAtOrAbove);
80
+ if (!input.failOn || limit == null) {
81
+ return {
82
+ schema: 'netgreener_analyze_gate_summary_v0',
83
+ upload: 'never',
84
+ ok: true,
85
+ exit_code: 0,
86
+ project_id: input.projectId,
87
+ files_scanned: input.filesScanned,
88
+ findings_total: findings.length,
89
+ by_mechanism,
90
+ gate: null,
91
+ };
92
+ }
93
+ const matching = countGateFindings(findings, input.failOn);
94
+ const exceeded = matching > limit;
95
+ return {
96
+ schema: 'netgreener_analyze_gate_summary_v0',
97
+ upload: 'never',
98
+ ok: !exceeded,
99
+ exit_code: exceeded ? 1 : 0,
100
+ project_id: input.projectId,
101
+ files_scanned: input.filesScanned,
102
+ findings_total: findings.length,
103
+ by_mechanism,
104
+ gate: {
105
+ fail_on: input.failOn,
106
+ max_at_or_above: limit,
107
+ matching,
108
+ exceeded,
109
+ message: exceeded
110
+ ? formatAnalyzeGateFailureMessage(findings, input.failOn, limit)
111
+ : null,
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Heuristic JS/TS twins of Python API overconsumption rules:
3
+ * - api_call_in_comprehension
4
+ * - network_call_in_loop / paid_api_in_loop (non-retry loops)
5
+ *
6
+ * RD0 disposition → mechanism ``external_api_overconsumption`` / ``external_api``.
7
+ * Skips retry loops (owned by retry_amplification detector).
8
+ */
9
+ import { type ResourceCandidateHit } from './resourceFindingCandidate.js';
10
+ /**
11
+ * Detect API calls inside array comprehensions (.map / .flatMap / …) or plain loops.
12
+ */
13
+ export declare function detectApiOverconsumptionHits(source: string, relPath: string): ResourceCandidateHit[];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Heuristic JS/TS twins of Python API overconsumption rules:
3
+ * - api_call_in_comprehension
4
+ * - network_call_in_loop / paid_api_in_loop (non-retry loops)
5
+ *
6
+ * RD0 disposition → mechanism ``external_api_overconsumption`` / ``external_api``.
7
+ * Skips retry loops (owned by retry_amplification detector).
8
+ */
9
+ import { advanceBraceDepth, COMPREHENSION_RE, hasApiCall, LOOP_HEADER_RE, PROMISE_ALL_RE, RETRY_HEADER_RE, stripLineComment, } from './heuristicScan.js';
10
+ import { languageForPath, } from './resourceFindingCandidate.js';
11
+ const DETECTOR_VERSION = '0.1.0';
12
+ const MECHANISM = 'external_api_overconsumption';
13
+ const TITLE_COMP = 'API call inside map/filter may multiply billed attempts';
14
+ const TITLE_LOOP = 'API call inside loop may multiply billed attempts';
15
+ function hitBase(relPath, lineNo, language, legacy, detectorId, title) {
16
+ return {
17
+ mechanism_id: MECHANISM,
18
+ primary_domain: 'external_api',
19
+ admission_domain: 'external_api',
20
+ title,
21
+ rel_path: relPath,
22
+ start_line: lineNo,
23
+ end_line: lineNo,
24
+ language,
25
+ detector_id: detectorId,
26
+ detector_version: DETECTOR_VERSION,
27
+ legacy_issue_type: legacy,
28
+ };
29
+ }
30
+ /**
31
+ * Detect API calls inside array comprehensions (.map / .flatMap / …) or plain loops.
32
+ */
33
+ export function detectApiOverconsumptionHits(source, relPath) {
34
+ const lines = source.split(/\r?\n/);
35
+ const hits = [];
36
+ let braceDepth = 0;
37
+ let loopAtDepth = null;
38
+ let inRetryLoop = false;
39
+ const language = languageForPath(relPath);
40
+ const path = relPath.replace(/\\/g, '/');
41
+ for (let i = 0; i < lines.length; i += 1) {
42
+ const line = stripLineComment(lines[i] ?? '');
43
+ const lineNo = i + 1;
44
+ const isRetryHeader = RETRY_HEADER_RE.test(line);
45
+ const isLoopHeader = LOOP_HEADER_RE.test(line);
46
+ const before = braceDepth;
47
+ const { braceDepth: next, openedBrace } = advanceBraceDepth(line, braceDepth);
48
+ braceDepth = next;
49
+ if (isRetryHeader) {
50
+ inRetryLoop = true;
51
+ loopAtDepth = openedBrace ? before : braceDepth;
52
+ }
53
+ else if (isLoopHeader && loopAtDepth === null) {
54
+ inRetryLoop = false;
55
+ loopAtDepth = openedBrace ? before : braceDepth;
56
+ }
57
+ if (loopAtDepth !== null && braceDepth <= loopAtDepth) {
58
+ loopAtDepth = null;
59
+ inRetryLoop = false;
60
+ }
61
+ // Same-line comprehension + API — skip Promise.all (owned by unbounded detector)
62
+ if (COMPREHENSION_RE.test(line) && hasApiCall(line) && !PROMISE_ALL_RE.test(line)) {
63
+ hits.push(hitBase(path, lineNo, language, 'api_call_in_comprehension', 'js.static.api_in_comprehension', TITLE_COMP));
64
+ continue;
65
+ }
66
+ // Nested body of a non-retry loop
67
+ if (loopAtDepth !== null &&
68
+ !inRetryLoop &&
69
+ braceDepth > loopAtDepth &&
70
+ hasApiCall(line)) {
71
+ hits.push(hitBase(path, lineNo, language, 'network_call_in_loop', 'js.static.api_in_loop', TITLE_LOOP));
72
+ }
73
+ }
74
+ return hits;
75
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * TypeScript AST detectors for Analyze (Python AST parity path).
3
+ *
4
+ * Returns ``null`` when typescript is unavailable or the file cannot be parsed —
5
+ * callers must fall back to heuristic detectors.
6
+ */
7
+ import { type ResourceCandidateHit } from './resourceFindingCandidate.js';
8
+ export type AstHitBundle = {
9
+ retry: ResourceCandidateHit[];
10
+ fileReads: ResourceCandidateHit[];
11
+ overconsumption: ResourceCandidateHit[];
12
+ unbounded: ResourceCandidateHit[];
13
+ polling: ResourceCandidateHit[];
14
+ repeatedWork: ResourceCandidateHit[];
15
+ processChurn: ResourceCandidateHit[];
16
+ fullDataset: ResourceCandidateHit[];
17
+ perItem: ResourceCandidateHit[];
18
+ modelLoad: ResourceCandidateHit[];
19
+ inference: ResourceCandidateHit[];
20
+ };
21
+ /** Full AST hit bundle, or ``null`` to signal heuristic fallback. */
22
+ export declare function detectAllHitsAst(source: string, relPath: string): AstHitBundle | null;
23
+ /** @deprecated Prefer detectAllHitsAst — kept for callers. */
24
+ export declare function detectHotPathHitsAst(source: string, relPath: string): {
25
+ retry: ResourceCandidateHit[];
26
+ fileReads: ResourceCandidateHit[];
27
+ } | null;
28
+ export declare function detectRetryAmplificationHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
29
+ export declare function detectRepeatedFileReadHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
30
+ export declare function detectApiOverconsumptionHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
31
+ export declare function detectUnboundedParallelismHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
32
+ export declare function detectPollingIdleHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
33
+ export declare function detectRepeatedWorkHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
34
+ export declare function detectProcessChurnHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
35
+ export declare function detectFullDatasetInMemoryHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
36
+ export declare function detectPerItemInsteadOfBatchHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
37
+ export declare function detectModelLoadInLoopHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;
38
+ export declare function detectInferenceInsideLoopHitsAst(source: string, relPath: string): ResourceCandidateHit[] | null;