@rikalabs/logpoint 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ import { Effect } from "effect";
2
+ import { InjectionError, SecretVarBlocked } from "./errors.js";
3
+ import { detectLanguage, type Language } from "./language.js";
4
+ import { isSecretVariable } from "./secrets.js";
5
+ import type { LogpointDef } from "./schema.js";
6
+ import {
7
+ generateTemplate,
8
+ goImportPaths,
9
+ type GoTemplateRefs,
10
+ } from "./templates.js";
11
+
12
+ const markerStartPattern = /LOGPOINT_START\s*\[([^\]]+)\]/;
13
+ const markerEndPattern = /LOGPOINT_END\s*\[([^\]]+)\]/;
14
+
15
+ const parseMarkerId = (line: string): string | undefined => {
16
+ const matched = line.match(markerStartPattern) ?? line.match(markerEndPattern);
17
+ return matched?.[1];
18
+ };
19
+
20
+ const sanitizeGoAlias = (name: string): string => `__lp_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
21
+
22
+ type GoImportState = {
23
+ readonly lines: readonly string[];
24
+ readonly refs: GoTemplateRefs;
25
+ };
26
+
27
+ type ImportSpec = {
28
+ readonly alias?: string;
29
+ readonly path: string;
30
+ readonly raw: string;
31
+ };
32
+
33
+ const importLinePattern = /^\s*(?:(?<alias>[_A-Za-z][A-Za-z0-9_]*)\s+)?"(?<path>[^"]+)"/;
34
+
35
+ const parseImportLine = (line: string): ImportSpec | undefined => {
36
+ const match = line.match(importLinePattern);
37
+ if (match === null) {
38
+ return undefined;
39
+ }
40
+ const alias = match.groups?.["alias"];
41
+ const path = match.groups?.["path"];
42
+ if (path === undefined) {
43
+ return undefined;
44
+ }
45
+ if (alias === undefined) {
46
+ return { path, raw: line.trim() };
47
+ }
48
+ return { alias, path, raw: line.trim() };
49
+ };
50
+
51
+ const parseSingleImport = (line: string): ImportSpec | undefined => {
52
+ const statement = line.trim();
53
+ if (!statement.startsWith("import ")) {
54
+ return undefined;
55
+ }
56
+ return parseImportLine(statement.slice("import ".length));
57
+ };
58
+
59
+ const defaultImportName = (path: string): string => {
60
+ const parts = path.split("/");
61
+ return parts[parts.length - 1] ?? path;
62
+ };
63
+
64
+ const importNameFromSpec = (spec: ImportSpec): string => {
65
+ if (spec.alias === undefined) {
66
+ return defaultImportName(spec.path);
67
+ }
68
+ if (spec.alias === "_" || spec.alias === ".") {
69
+ return defaultImportName(spec.path);
70
+ }
71
+ return spec.alias;
72
+ };
73
+
74
+ const ensureGoImports = (content: string): GoImportState => {
75
+ const required = {
76
+ bytes: goImportPaths.bytes,
77
+ json: goImportPaths.json,
78
+ http: goImportPaths.http,
79
+ time: goImportPaths.time,
80
+ } as const;
81
+
82
+ const lines = content.split("\n");
83
+ const importsByPath: Record<string, ImportSpec> = {};
84
+
85
+ let importStart = -1;
86
+ let importEnd = -1;
87
+ let isBlock = false;
88
+
89
+ for (let index = 0; index < lines.length; index += 1) {
90
+ const line = lines[index]?.trim() ?? "";
91
+ if (line === "import (") {
92
+ importStart = index;
93
+ isBlock = true;
94
+ for (let inner = index + 1; inner < lines.length; inner += 1) {
95
+ const innerLine = lines[inner] ?? "";
96
+ if (innerLine.trim() === ")") {
97
+ importEnd = inner;
98
+ break;
99
+ }
100
+ const spec = parseImportLine(innerLine);
101
+ if (spec !== undefined) {
102
+ importsByPath[spec.path] = spec;
103
+ }
104
+ }
105
+ break;
106
+ }
107
+
108
+ if (line.startsWith("import ")) {
109
+ importStart = index;
110
+ importEnd = index;
111
+ isBlock = false;
112
+ const spec = parseSingleImport(line);
113
+ if (spec !== undefined) {
114
+ importsByPath[spec.path] = spec;
115
+ }
116
+ break;
117
+ }
118
+ }
119
+
120
+ const refs: GoTemplateRefs = {
121
+ bytesRef: importNameFromSpec(importsByPath[required.bytes] ?? { path: required.bytes, raw: "" }),
122
+ jsonRef: importNameFromSpec(importsByPath[required.json] ?? { path: required.json, raw: "" }),
123
+ httpRef: importNameFromSpec(importsByPath[required.http] ?? { path: required.http, raw: "" }),
124
+ timeRef: importNameFromSpec(importsByPath[required.time] ?? { path: required.time, raw: "" }),
125
+ };
126
+
127
+ const missing: Array<{ readonly path: string; readonly alias: string; readonly key: keyof GoTemplateRefs }> = [];
128
+
129
+ if (importsByPath[required.bytes] === undefined) {
130
+ const alias = sanitizeGoAlias("bytes");
131
+ refs.bytesRef = alias;
132
+ missing.push({ path: required.bytes, alias, key: "bytesRef" });
133
+ }
134
+ if (importsByPath[required.json] === undefined) {
135
+ const alias = sanitizeGoAlias("json");
136
+ refs.jsonRef = alias;
137
+ missing.push({ path: required.json, alias, key: "jsonRef" });
138
+ }
139
+ if (importsByPath[required.http] === undefined) {
140
+ const alias = sanitizeGoAlias("http");
141
+ refs.httpRef = alias;
142
+ missing.push({ path: required.http, alias, key: "httpRef" });
143
+ }
144
+ if (importsByPath[required.time] === undefined) {
145
+ const alias = sanitizeGoAlias("time");
146
+ refs.timeRef = alias;
147
+ missing.push({ path: required.time, alias, key: "timeRef" });
148
+ }
149
+
150
+ if (missing.length === 0) {
151
+ return { lines, refs };
152
+ }
153
+
154
+ const importLines = missing.map((item) => `\t${item.alias} "${item.path}" // LOGPOINT_IMPORT`);
155
+
156
+ if (importStart >= 0 && importEnd >= importStart && isBlock) {
157
+ const updated = [...lines];
158
+ updated.splice(importEnd, 0, ...importLines);
159
+ return { lines: updated, refs };
160
+ }
161
+
162
+ if (importStart >= 0 && importEnd === importStart && !isBlock) {
163
+ const single = lines[importStart] ?? "";
164
+ const spec = parseSingleImport(single.trim());
165
+ const existing = spec === undefined ? [] : [`\t${spec.raw}`];
166
+ const block = ["import (", ...existing, ...importLines, ")"];
167
+ const updated = [...lines];
168
+ updated.splice(importStart, 1, ...block);
169
+ return { lines: updated, refs };
170
+ }
171
+
172
+ const packageIndex = lines.findIndex((line) => line.trim().startsWith("package "));
173
+ const updated = [...lines];
174
+ const insertionIndex = packageIndex >= 0 ? packageIndex + 1 : 0;
175
+ updated.splice(insertionIndex, 0, "", "import (", ...importLines, ")", "");
176
+ return { lines: updated, refs };
177
+ };
178
+
179
+ export type InjectContentResult = {
180
+ readonly content: string;
181
+ readonly inserted: number;
182
+ readonly blocked: readonly SecretVarBlocked[];
183
+ };
184
+
185
+ const hasLogpointId = (content: string, id: string): boolean =>
186
+ content.includes(`LOGPOINT_START [${id}]`) || content.includes(`LOGPOINT_END [${id}]`);
187
+
188
+ const sortDescendingByLine = (defs: readonly LogpointDef[]): readonly LogpointDef[] =>
189
+ [...defs].sort((a, b) => b.line - a.line);
190
+
191
+ const safeCaptureVars = (
192
+ capture: readonly string[],
193
+ logpointId: string,
194
+ ): {
195
+ readonly safe: readonly string[];
196
+ readonly blocked: readonly SecretVarBlocked[];
197
+ } => {
198
+ const safe: string[] = [];
199
+ const blocked: SecretVarBlocked[] = [];
200
+
201
+ for (const variable of capture) {
202
+ if (isSecretVariable(variable)) {
203
+ blocked.push(new SecretVarBlocked({ logpointId, variable }));
204
+ continue;
205
+ }
206
+ safe.push(variable);
207
+ }
208
+
209
+ return { safe, blocked };
210
+ };
211
+
212
+ export const injectContent = (
213
+ content: string,
214
+ defs: readonly LogpointDef[],
215
+ filePath: string,
216
+ port: number,
217
+ explicitLanguage?: Language,
218
+ ): Effect.Effect<InjectContentResult, InjectionError, never> =>
219
+ Effect.gen(function* () {
220
+ const language = explicitLanguage ?? detectLanguage(filePath);
221
+ let lines = content.split("\n");
222
+ let goRefs: GoTemplateRefs | undefined;
223
+ const blocked: SecretVarBlocked[] = [];
224
+ let inserted = 0;
225
+
226
+ if (language === "go") {
227
+ const goImportState = ensureGoImports(lines.join("\n"));
228
+ lines = [...goImportState.lines];
229
+ goRefs = goImportState.refs;
230
+ }
231
+
232
+ for (const def of sortDescendingByLine(defs)) {
233
+ if (hasLogpointId(lines.join("\n"), def.id)) {
234
+ continue;
235
+ }
236
+
237
+ if (def.line < 1 || def.line > lines.length + 1) {
238
+ return yield* Effect.fail(
239
+ new InjectionError({
240
+ logpointId: def.id,
241
+ file: filePath,
242
+ line: def.line,
243
+ reason: `Line ${def.line} out of range (1-${lines.length + 1})`,
244
+ }),
245
+ );
246
+ }
247
+
248
+ const capture = safeCaptureVars(def.capture, def.id);
249
+ blocked.push(...capture.blocked);
250
+
251
+ const template = generateTemplate(
252
+ {
253
+ ...def,
254
+ capture: capture.safe,
255
+ port,
256
+ ...(goRefs === undefined ? {} : { goRefs }),
257
+ },
258
+ language,
259
+ );
260
+
261
+ goRefs = template.goRefs ?? goRefs;
262
+
263
+ lines.splice(def.line - 1, 0, ...template.lines);
264
+ inserted += 1;
265
+ }
266
+
267
+ return {
268
+ content: lines.join("\n"),
269
+ inserted,
270
+ blocked,
271
+ };
272
+ });
273
+
274
+ export type CleanupContentResult = {
275
+ readonly cleaned: string;
276
+ readonly removed: number;
277
+ };
278
+
279
+ export const cleanupContent = (
280
+ content: string,
281
+ ids?: readonly string[],
282
+ ): CleanupContentResult => {
283
+ const lines = content.split("\n");
284
+ const filteredIds = ids === undefined ? undefined : new Set(ids);
285
+ const out: string[] = [];
286
+
287
+ let inside = false;
288
+ let activeId: string | undefined;
289
+ let removed = 0;
290
+
291
+ for (const line of lines) {
292
+ if (inside) {
293
+ if (line.includes("LOGPOINT_END")) {
294
+ const endId = parseMarkerId(line);
295
+ if (activeId === undefined || endId === undefined || endId === activeId) {
296
+ inside = false;
297
+ activeId = undefined;
298
+ continue;
299
+ }
300
+ }
301
+ continue;
302
+ }
303
+
304
+ if (line.includes("LOGPOINT_START")) {
305
+ const id = parseMarkerId(line);
306
+ if (id === undefined) {
307
+ continue;
308
+ }
309
+
310
+ if (filteredIds === undefined || filteredIds.has(id)) {
311
+ inside = true;
312
+ activeId = id;
313
+ removed += 1;
314
+ continue;
315
+ }
316
+ }
317
+
318
+ if (filteredIds === undefined && line.includes("LOGPOINT_IMPORT")) {
319
+ continue;
320
+ }
321
+
322
+ out.push(line);
323
+ }
324
+
325
+ return { cleaned: out.join("\n"), removed };
326
+ };
327
+
328
+ export const countMarkers = (content: string): number =>
329
+ content.split("\n").reduce((count, line) => count + (line.includes("LOGPOINT_START") ? 1 : 0), 0);
@@ -0,0 +1,40 @@
1
+ import { appendFile, readFile } from "node:fs/promises";
2
+ import { Effect } from "effect";
3
+ import { FileReadError, FileWriteError, ParseError } from "./errors.js";
4
+
5
+ export const appendJsonLine = (
6
+ path: string,
7
+ payload: unknown,
8
+ ): Effect.Effect<void, FileWriteError | ParseError, never> =>
9
+ Effect.try({
10
+ try: () => JSON.stringify(payload),
11
+ catch: (cause) => new ParseError({ input: String(payload), cause }),
12
+ }).pipe(
13
+ Effect.flatMap((line) =>
14
+ Effect.tryPromise({
15
+ try: async () => {
16
+ await appendFile(path, `${line}\n`, "utf8");
17
+ },
18
+ catch: (cause) => new FileWriteError({ path, cause }),
19
+ }),
20
+ ),
21
+ );
22
+
23
+ export const readJsonLines = (path: string): Effect.Effect<readonly unknown[], FileReadError | ParseError, never> =>
24
+ Effect.tryPromise({
25
+ try: () => readFile(path, "utf8"),
26
+ catch: (cause) => new FileReadError({ path, cause }),
27
+ }).pipe(
28
+ Effect.flatMap((content) => {
29
+ const lines = content
30
+ .split("\n")
31
+ .map((line) => line.trim())
32
+ .filter((line) => line.length > 0);
33
+ return Effect.forEach(lines, (line) =>
34
+ Effect.try({
35
+ try: () => JSON.parse(line) as unknown,
36
+ catch: (cause) => new ParseError({ input: line, cause }),
37
+ }),
38
+ );
39
+ }),
40
+ );
@@ -0,0 +1,49 @@
1
+ import { extname } from "node:path";
2
+
3
+ export const SupportedLanguages = [
4
+ "javascript",
5
+ "typescript",
6
+ "python",
7
+ "go",
8
+ "ruby",
9
+ "shell",
10
+ "java",
11
+ "csharp",
12
+ "php",
13
+ "rust",
14
+ "kotlin",
15
+ ] as const;
16
+
17
+ export type Language = (typeof SupportedLanguages)[number];
18
+
19
+ const extensionMap: Readonly<Record<string, Language>> = {
20
+ ".js": "javascript",
21
+ ".cjs": "javascript",
22
+ ".mjs": "javascript",
23
+ ".jsx": "javascript",
24
+ ".ts": "typescript",
25
+ ".mts": "typescript",
26
+ ".cts": "typescript",
27
+ ".tsx": "typescript",
28
+ ".py": "python",
29
+ ".go": "go",
30
+ ".rb": "ruby",
31
+ ".sh": "shell",
32
+ ".bash": "shell",
33
+ ".zsh": "shell",
34
+ ".ksh": "shell",
35
+ ".java": "java",
36
+ ".cs": "csharp",
37
+ ".php": "php",
38
+ ".rs": "rust",
39
+ ".kt": "kotlin",
40
+ ".kts": "kotlin",
41
+ };
42
+
43
+ export const isLanguage = (value: string): value is Language =>
44
+ (SupportedLanguages as readonly string[]).includes(value);
45
+
46
+ export const detectLanguage = (filePath: string): Language => {
47
+ const extension = extname(filePath).toLowerCase();
48
+ return extensionMap[extension] ?? "javascript";
49
+ };
@@ -0,0 +1,173 @@
1
+ import type { Snapshot } from "./schema.js";
2
+
3
+ export type SnapshotGroups = Readonly<Record<string, readonly Snapshot[]>>;
4
+
5
+ export type Anomaly = {
6
+ readonly id: string;
7
+ readonly variable: string;
8
+ readonly message: string;
9
+ };
10
+
11
+ const stableStringify = (value: unknown): string => {
12
+ try {
13
+ return JSON.stringify(value);
14
+ } catch {
15
+ return String(value);
16
+ }
17
+ };
18
+
19
+ const formatCell = (value: unknown): string => {
20
+ const rendered = stableStringify(value);
21
+ if (rendered.length <= 40) {
22
+ return rendered;
23
+ }
24
+ return `${rendered.slice(0, 37)}...`;
25
+ };
26
+
27
+ const collectVariables = (snapshots: readonly Snapshot[]): readonly string[] => {
28
+ const keys = new Set<string>();
29
+ for (const snapshot of snapshots) {
30
+ for (const key of Object.keys(snapshot.vars)) {
31
+ keys.add(key);
32
+ }
33
+ }
34
+ return [...keys].sort();
35
+ };
36
+
37
+ export const groupSnapshots = (snapshots: readonly Snapshot[]): SnapshotGroups => {
38
+ const grouped: Record<string, Snapshot[]> = {};
39
+ for (const snapshot of snapshots) {
40
+ const current = grouped[snapshot.id] ?? [];
41
+ current.push(snapshot);
42
+ grouped[snapshot.id] = current;
43
+ }
44
+
45
+ const normalized: Record<string, readonly Snapshot[]> = {};
46
+ for (const [id, entries] of Object.entries(grouped)) {
47
+ normalized[id] = [...entries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
48
+ }
49
+ return normalized;
50
+ };
51
+
52
+ const hasStableOtherVariables = (
53
+ snapshots: readonly Snapshot[],
54
+ targetVariable: string,
55
+ variables: readonly string[],
56
+ ): boolean => {
57
+ for (const variable of variables) {
58
+ if (variable === targetVariable) {
59
+ continue;
60
+ }
61
+
62
+ const values = snapshots.map((snapshot) => stableStringify(snapshot.vars[variable]));
63
+ if (new Set(values).size > 1) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ return true;
69
+ };
70
+
71
+ export const detectAnomalies = (groups: SnapshotGroups): readonly Anomaly[] => {
72
+ const anomalies: Anomaly[] = [];
73
+
74
+ for (const [id, snapshots] of Object.entries(groups)) {
75
+ const variables = collectVariables(snapshots);
76
+
77
+ for (const variable of variables) {
78
+ const values = snapshots.map((snapshot) => snapshot.vars[variable]);
79
+ const serialized = values.map((value) => stableStringify(value));
80
+ const uniqueSerialized = new Set(serialized);
81
+
82
+ if (values.some((value) => value === null || value === undefined)) {
83
+ anomalies.push({
84
+ id,
85
+ variable,
86
+ message: `Value is null or undefined in at least one hit`,
87
+ });
88
+ }
89
+
90
+ const typeSet = new Set(values.map((value) => typeof value));
91
+ if (typeSet.size > 1) {
92
+ anomalies.push({
93
+ id,
94
+ variable,
95
+ message: `Type changed across hits (${[...typeSet].join(" -> ")})`,
96
+ });
97
+ }
98
+
99
+ const numericValues = values.filter((value): value is number => typeof value === "number");
100
+ if (numericValues.length >= 2) {
101
+ const minValue = Math.min(...numericValues);
102
+ const maxValue = Math.max(...numericValues);
103
+ if (minValue > 0 && maxValue / minValue >= 2) {
104
+ anomalies.push({
105
+ id,
106
+ variable,
107
+ message: `Value grew significantly (${minValue} -> ${maxValue})`,
108
+ });
109
+ }
110
+ }
111
+
112
+ if (uniqueSerialized.size > 1 && hasStableOtherVariables(snapshots, variable, variables)) {
113
+ const first = serialized[0] ?? "unknown";
114
+ const second = serialized.find((value) => value !== first) ?? "unknown";
115
+ anomalies.push({
116
+ id,
117
+ variable,
118
+ message: `Value changed while other variables stayed constant (${first} -> ${second})`,
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ return anomalies;
125
+ };
126
+
127
+ export const renderMarkdown = (
128
+ groups: SnapshotGroups,
129
+ anomalies: readonly Anomaly[],
130
+ ): string => {
131
+ const sections: string[] = ["## Logpoint Results", ""];
132
+
133
+ const ids = Object.keys(groups).sort();
134
+ for (const id of ids) {
135
+ const snapshots = groups[id] ?? [];
136
+ if (snapshots.length === 0) {
137
+ continue;
138
+ }
139
+
140
+ const first = snapshots[0];
141
+ const variables = collectVariables(snapshots);
142
+
143
+ sections.push(`### ${id} - ${first?.label ?? "unknown"} (${snapshots.length} hits)`);
144
+
145
+ const header = ["Hit", ...variables];
146
+ const separator = header.map(() => "---");
147
+ sections.push(`| ${header.join(" | ")} |`);
148
+ sections.push(`| ${separator.join(" | ")} |`);
149
+
150
+ snapshots.forEach((snapshot, index) => {
151
+ const cells = [String(index + 1), ...variables.map((key) => formatCell(snapshot.vars[key]))];
152
+ sections.push(`| ${cells.join(" | ")} |`);
153
+ });
154
+
155
+ sections.push("");
156
+ }
157
+
158
+ sections.push("## Anomalies");
159
+ if (anomalies.length === 0) {
160
+ sections.push("- None detected");
161
+ } else {
162
+ for (const anomaly of anomalies) {
163
+ sections.push(`- **${anomaly.id}.${anomaly.variable}**: ${anomaly.message}`);
164
+ }
165
+ }
166
+
167
+ return sections.join("\n");
168
+ };
169
+
170
+ export const renderJson = (
171
+ groups: SnapshotGroups,
172
+ anomalies: readonly Anomaly[],
173
+ ): string => JSON.stringify({ groups, anomalies }, null, 2);