Files
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

371 lines
12 KiB
TypeScript

/**
* Regression tests for project architecture invariants
* Guards import graph shape, shared-bucket bans, and polling SDK boundary rules
*/
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, normalize, relative } from "node:path";
import test from "node:test";
const PROJECT_ROOT = process.cwd();
function getProjectTypeScriptFiles(): string[] {
return [
"index.ts",
...readdirSync(join(PROJECT_ROOT, "api"))
.filter((name) => name.endsWith(".ts"))
.map((name) => join("api", name)),
...readdirSync(join(PROJECT_ROOT, "lib"))
.filter((name) => name.endsWith(".ts"))
.map((name) => join("lib", name)),
...readdirSync(join(PROJECT_ROOT, "tests"))
.filter((name) => name.endsWith(".test.ts"))
.map((name) => join("tests", name)),
].sort();
}
function getProjectSourceFiles(): string[] {
return [
"index.ts",
...readdirSync(join(PROJECT_ROOT, "api"))
.filter((name) => name.endsWith(".ts"))
.map((name) => join("api", name)),
...readdirSync(join(PROJECT_ROOT, "lib"))
.filter((name) => name.endsWith(".ts"))
.map((name) => join("lib", name)),
].sort();
}
function getImportSpecifiersFromSource(source: string): string[] {
const specifiers = new Set<string>();
for (const match of source.matchAll(/from\s+["']([^"']+)["']/g)) {
specifiers.add(match[1] ?? "");
}
for (const match of source.matchAll(/import\s+["']([^"']+)["']/g)) {
specifiers.add(match[1] ?? "");
}
for (const match of source.matchAll(/import\s*\(\s*["']([^"']+)["']/g)) {
specifiers.add(match[1] ?? "");
}
return [...specifiers];
}
function getImportSpecifiers(file: string): string[] {
return getImportSpecifiersFromSource(
readFileSync(join(PROJECT_ROOT, file), "utf8"),
);
}
function resolveProjectImport(
fromFile: string,
specifier: string,
): string | undefined {
if (!specifier.startsWith(".")) return undefined;
const resolved = normalize(join(PROJECT_ROOT, fromFile, "..", specifier));
const relativePath = relative(PROJECT_ROOT, resolved);
return relativePath.startsWith("..") ? undefined : relativePath;
}
function buildProjectImportGraph(files: string[]): Map<string, string[]> {
const fileSet = new Set(files.map((file) => normalize(file)));
const graph = new Map<string, string[]>();
for (const file of files) {
const deps: string[] = [];
for (const specifier of getImportSpecifiers(file)) {
const resolved = resolveProjectImport(file, specifier);
if (resolved && fileSet.has(normalize(resolved))) {
deps.push(normalize(resolved));
}
}
graph.set(normalize(file), deps.sort());
}
return graph;
}
function findImportCycles(graph: Map<string, string[]>): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const activeStack: string[] = [];
const visit = (file: string): void => {
const activeIndex = activeStack.indexOf(file);
if (activeIndex !== -1) {
cycles.push([...activeStack.slice(activeIndex), file]);
return;
}
if (visited.has(file)) return;
visited.add(file);
activeStack.push(file);
for (const dep of graph.get(file) ?? []) visit(dep);
activeStack.pop();
};
for (const file of graph.keys()) visit(file);
return cycles;
}
function stripSourceTextAndComments(source: string): string {
const withoutStrings = source.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, "");
return withoutStrings
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "");
}
test("Import parser includes side-effect imports for cycle checks", () => {
assert.deepEqual(
getImportSpecifiersFromSource(
[
"import type { A } from './a.ts';",
"import { b } from './b.ts';",
"import './side-effect.ts';",
"export { c } from './c.ts';",
"const module = await import('./dynamic.ts');",
].join("\n"),
).sort(),
["./a.ts", "./b.ts", "./c.ts", "./dynamic.ts", "./side-effect.ts"],
);
});
test("Source-only invariant scans ignore strings and comments", () => {
assert.equal(
stripSourceTextAndComments(
[
"const text = '=> process.env pi.';",
"// interface Example extends Other {}",
"/* function helper() { return process.env; } */",
"const value = 1;",
].join("\n"),
).trim(),
"const text = ;\n\n\nconst value = 1;",
);
});
test("Domain test filenames mirror their owning lib domain", () => {
const libDomains = new Set(
readdirSync(join(PROJECT_ROOT, "lib"))
.filter((name) => name.endsWith(".ts"))
.map((name) => name.replace(/\.ts$/, "")),
);
const nonLibTestDomains = new Set([
"index",
"integration",
"invariants",
"process-shutdown",
"public-api",
]);
const unmirrored = readdirSync(join(PROJECT_ROOT, "tests"))
.filter((name) => name.endsWith(".test.ts"))
.map((name) => name.replace(/\.test\.ts$/, ""))
.filter(
(domain) => !libDomains.has(domain) && !nonLibTestDomains.has(domain),
);
assert.deepEqual(unmirrored, []);
});
test("Project source imports stay acyclic", () => {
const graph = buildProjectImportGraph(getProjectSourceFiles());
const cycles = findImportCycles(graph);
assert.deepEqual(
cycles,
[],
"Import cycles found:\n" + cycles.map((c) => c.join(" -> ")).join("\n"),
);
});
test("Project no longer has shared constants or transport-type domains", () => {
assert.equal(existsSync(join(PROJECT_ROOT, "lib", "constants.ts")), false);
assert.equal(existsSync(join(PROJECT_ROOT, "lib", "types.ts")), false);
});
test("Preview domain stays independent from UI/compat rendering", () => {
assert.equal(
getImportSpecifiers(join("lib", "preview.ts")).includes("./rendering.ts"),
false,
);
});
test("Package exports expose only stable public domains", () => {
const packageJson = JSON.parse(
readFileSync(join(PROJECT_ROOT, "package.json"), "utf8"),
) as { exports?: Record<string, string> };
assert.deepEqual(packageJson.exports, {
".": "./index.ts",
"./inbound": "./api/inbound.ts",
"./outbound": "./api/outbound.ts",
"./delivery": "./api/delivery.ts",
"./activity": "./api/activity.ts",
"./updates": "./api/updates.ts",
"./commands": "./api/commands.ts",
"./sections": "./api/sections.ts",
"./status": "./api/status.ts",
"./voice": "./api/voice.ts",
"./keyboard": "./api/keyboard.ts",
});
});
test("Project TypeScript files start with responsibility headers", () => {
const filesWithoutHeaders = getProjectTypeScriptFiles().filter((file) => {
return !readFileSync(join(PROJECT_ROOT, file), "utf8").startsWith("/**");
});
assert.deepEqual(filesWithoutHeaders, []);
});
test("Project source module headers include Domain DAG zone tags", () => {
const sourceFilesWithoutZoneTags = getProjectSourceFiles().filter((file) => {
const source = readFileSync(join(PROJECT_ROOT, file), "utf8");
const header = source.match(/^\/\*\*[\s\S]*?\*\//)?.[0] ?? "";
return !/^ \* Zones: .+/m.test(header);
});
assert.deepEqual(sourceFilesWithoutZoneTags, []);
});
test("Project source avoids empty interface-extension shells", () => {
const emptyInterfacePattern =
/export\s+interface\s+\w+(?:<[^>{}]+>)?\s+extends[^{]+\{\s*\}/g;
const emptyInterfaceExtensions = getProjectSourceFiles().flatMap((file) => {
const source = stripSourceTextAndComments(
readFileSync(join(PROJECT_ROOT, file), "utf8"),
);
return [...source.matchAll(emptyInterfacePattern)].map(
(match) => `${file}: ${match[0].replace(/\s+/g, " ")}`,
);
});
assert.deepEqual(emptyInterfaceExtensions, []);
});
test("Pi SDK imports stay centralized in the pi adapter", () => {
const directSdkImportFiles = getProjectSourceFiles().filter((file) => {
if (file === normalize(join("lib", "pi.ts"))) return false;
const source = readFileSync(join(PROJECT_ROOT, file), "utf8");
const piSdkPackages = [
"@mariozechner/pi-coding-agent",
"@earendil-works/pi-coding-agent",
"@earendil-works/pi-agent-core",
"@earendil-works/pi-ai",
];
return piSdkPackages.some((packageName) => source.includes(packageName));
});
assert.deepEqual(directSdkImportFiles, []);
});
test("Entrypoint stays free of direct Node runtime imports", () => {
const nodeImportSpecifiers = getImportSpecifiers("index.ts").filter(
(specifier) => specifier.startsWith("node:"),
);
assert.deepEqual(nodeImportSpecifiers, []);
});
test("Entrypoint stays a composition root without local runtime adapters", () => {
const source = stripSourceTextAndComments(
readFileSync(join(PROJECT_ROOT, "index.ts"), "utf8"),
);
const localFunctionDeclarations = [
...source.matchAll(/(?:^|\n)\s*(?:async\s+)?function\s+\w+/g),
].map((match) => match[0].trim());
assert.deepEqual(localFunctionDeclarations, []);
assert.equal(source.includes("=>"), false);
assert.equal(source.includes("process.env"), false);
assert.equal(/\bpi\./.test(source), false);
});
test("Visible thread identity never falls back directly to bare slot labels", () => {
const forbiddenPatterns: Array<[RegExp, string]> = [
[/\bthreadName\s*\?\?\s*slot\b/g, "threadName ?? slot"],
[
/\brecord\.threadName\s*\?\?\s*record\.slot\b/g,
"record.threadName ?? record.slot",
],
[/\?\s*[\w.]+\.threadName\s*:\s*[\w.]+\.slot\b/g, "ternary slot fallback"],
];
const violations = getProjectSourceFiles().flatMap((file) => {
const source = stripSourceTextAndComments(
readFileSync(join(PROJECT_ROOT, file), "utf8"),
);
return forbiddenPatterns.flatMap(([pattern, label]) =>
[...source.matchAll(pattern)].map(
(match) => `${file}: ${label}: ${match[0].replace(/\s+/g, " ")}`,
),
);
});
assert.deepEqual(violations, []);
});
test("Destructive forum-topic lifecycle cleanup stays in the thread reconciler", () => {
const directCleanupFiles = getProjectSourceFiles().filter((file) => {
if (file === join("lib", "thread-reconciler.ts")) return false;
const source = stripSourceTextAndComments(
readFileSync(join(PROJECT_ROOT, file), "utf8"),
);
return /\b(?:closeForumTopic|deleteForumTopic)\b/.test(source);
});
assert.deepEqual(directCleanupFiles, []);
});
test("Runtime state domain stays free of local domain imports", () => {
const localImportSpecifiers = getImportSpecifiers(
join("lib", "runtime.ts"),
).filter((specifier) => specifier.startsWith("."));
assert.deepEqual(localImportSpecifiers, []);
});
test("Structural leaf domains stay free of local nominal imports", () => {
const leafFiles = ["polling.ts", "setup.ts", "status.ts"];
const localImportsByFile = Object.fromEntries(
leafFiles.map((file) => [
join("lib", file),
getImportSpecifiers(join("lib", file)).filter((specifier) =>
specifier.startsWith("."),
),
]),
);
assert.deepEqual(localImportsByFile, {
[join("lib", "polling.ts")]: [],
[join("lib", "setup.ts")]: [],
[join("lib", "status.ts")]: [],
});
});
test("Menu domain stays on structural ports and does not re-export model", () => {
const menuImports = getImportSpecifiers(join("lib", "menu.ts"));
assert.equal(menuImports.includes("./pi.ts"), false);
const menuSource = readFileSync(join(PROJECT_ROOT, "lib", "menu.ts"), "utf8");
assert.equal(
/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+["']\.\/model\.ts["']/.test(
menuSource,
),
false,
);
});
test("Telegram API transport stays decoupled from persisted config defaults", () => {
const apiImports = getImportSpecifiers(join("lib", "telegram-api.ts"));
assert.equal(apiImports.includes("./config.ts"), false);
});
test("Structural update and media domains stay decoupled from concrete API transport shapes", () => {
const structuralFiles = ["updates.ts", "media.ts"];
const apiImportsByFile = Object.fromEntries(
structuralFiles.map((file) => [
join("lib", file),
getImportSpecifiers(join("lib", file)).includes("./telegram-api.ts"),
]),
);
assert.deepEqual(apiImportsByFile, {
[join("lib", "updates.ts")]: false,
[join("lib", "media.ts")]: false,
});
});
test("Outbound attachment delivery stays decoupled from queue, inbound media, and API helpers", () => {
const attachmentImports = getImportSpecifiers(
join("lib", "outbound-attachments.ts"),
);
assert.equal(attachmentImports.includes("./queue.ts"), false);
assert.equal(attachmentImports.includes("./media.ts"), false);
assert.equal(attachmentImports.includes("./telegram-api.ts"), false);
});