Files
AgentHub/server/__tests__/repo-links.test.js
T
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00

147 lines
6.2 KiB
JavaScript

/**
* @file repo-links.test.js
* @description Guards the two staleness classes that shipped broken commands to
* users for weeks: (1) GitHub-shaped references in a repo that is self-hosted on
* Gitea — the `owner/repo` marketplace shorthand resolves to github.com ONLY
* (verified against the CLI: it rejects `host/owner/repo` outright and ignores
* GH_HOST / GITHUB_HOST / CLAUDE_CODE_MARKETPLACE_URL), plus raw.githubusercontent
* / *.github.io / `/blob/<branch>` URLs that 404 against Gitea; and (2) a stale
* committed `openapi.yaml`, which had silently drifted a whole minor version
* behind package.json. Pure file reads plus one in-process spec render.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const REPO_ROOT = path.join(__dirname, "..", "..");
// Only git-tracked text files: keeps node_modules, client/dist and every
// build artifact out without hand-maintaining an ignore list.
const TRACKED_EXT = /\.(md|json|js|cjs|mjs|ts|tsx|sh|ya?ml|html|css)$/;
// This file states every banned pattern verbatim — in the rules below and in
// the comments explaining them — so it must exclude itself or it fails on its
// own source. Derived from __filename rather than hardcoded so a rename can't
// silently re-arm the self-report.
const SELF = path.relative(REPO_ROOT, __filename);
function trackedFiles() {
// No GIT_* scrub here, deliberately: under this repo's pre-commit hook
// GIT_INDEX_FILE points at the index being committed, so `ls-files` lints
// exactly what is about to land. Scrubbing it would lint the previous commit
// instead and let a newly staged violation through.
const out = execFileSync("git", ["ls-files", "-z"], {
cwd: REPO_ROOT,
encoding: "utf8",
maxBuffer: 20_000_000,
});
return (
out
.split("\0")
.filter((f) => f && TRACKED_EXT.test(f))
.filter((f) => f !== SELF)
// Dated design docs and plans record what was true when written; rewriting
// history to satisfy a lint would be a lie about the decision record.
.filter((f) => !f.startsWith("docs/superpowers/"))
// Generated from server/openapi-extra/* (which IS linted): the drift test
// below covers it, so linting both would only double-report one fix.
.filter((f) => f !== "openapi.yaml" && !f.endsWith("package-lock.json"))
// Third-party files copied unchanged (server/vendor/redoc): their links are upstream's.
.filter((f) => !f.includes("/vendor/"))
);
}
// Each rule: what to look for, and what to do instead. A `skip` pattern is
// tested against the MATCHED TEXT ONLY, never the surrounding line — a skip
// written against nearby prose silently never fires. Add one only when the
// exception is visible inside the match itself.
const RULES = [
{
name: "GitHub owner/repo marketplace shorthand",
// `claude plugin marketplace add Smartgift-AI/AgentHub` — resolves to
// github.com and fails, because this repo lives on Gitea.
re: /plugin marketplace add\s+(?!https?:\/\/)(?!\.{0,2}\/)(?!["'$])[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*/g,
fix: "use the full https:// clone URL — the shorthand is GitHub-only",
// Anthropic's own marketplace really does live on GitHub.
skip: [/anthropics\/claude-plugins-official/],
},
{
name: "GitHub URL for a Smartgift repo",
re: /(?:https?:\/\/)?(?:www\.)?github\.com\/[Ss]martgift[\w-]*\/[\w.-]+/g,
fix: "point at git.smartgift.io.vn",
skip: [],
},
{
name: "raw.githubusercontent.com",
// Our repos are not on GitHub, so every raw URL 404s. Same-origin paths or
// a Gitea /raw/branch/<ref>/ URL instead.
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
// Third-party projects that really are on GitHub: the public model price
// list server/lib/pricing-sync.js reads, and the install script omp.sh
// redirects to (server/data/ai-tools/omp.json `install.redirect`).
skip: [
/raw\.githubusercontent\.com\/BerriAI\/litellm$/,
/raw\.githubusercontent\.com\/can1357\/oh-my-pi$/,
],
},
{
name: "GitHub Pages host for a Smartgift site",
re: /[\w-]*smartgift[\w-]*\.github\.io/gi,
fix: "there is no GitHub Pages site — link the Gitea docs directory",
skip: [],
},
{
name: "GitHub /blob/<branch>/ path syntax on a Gitea URL",
// Gitea serves files at /src/branch/<ref>/, never /blob/<ref>/.
re: /git\.smartgift\.io\.vn\/[^\s"')\]]*\/blob\/[\w.-]+\//g,
fix: "Gitea serves files at /src/branch/<ref>/",
skip: [],
},
{
name: "retired repository name",
// Gitea 301-redirects the old name, so these never fail loudly — they just
// rot until someone turns the redirect off.
re: /Claude-Code-Monitor|Claude-Code-Agent-Monitor/g,
fix: "the repository is Smartgift-AI/AgentHub",
skip: [],
},
];
describe("repo links and generated files", () => {
const files = trackedFiles();
it("finds tracked text files to lint", () => {
assert.ok(files.length > 50, `expected a populated file list, got ${files.length}`);
});
for (const rule of RULES) {
it(`no ${rule.name}`, () => {
const hits = [];
for (const rel of files) {
const text = fs.readFileSync(path.join(REPO_ROOT, rel), "utf8");
// A fresh regex per file: the /g rules carry lastIndex between calls.
for (const m of text.matchAll(new RegExp(rule.re.source, rule.re.flags))) {
if (rule.skip.some((s) => s.test(m[0]))) continue;
const line = text.slice(0, m.index).split("\n").length;
hits.push(`${rel}:${line}: ${m[0].trim()}`);
}
}
assert.deepEqual(hits, [], `${rule.fix}\n ${hits.join("\n ")}`);
});
}
it("committed openapi.yaml matches the generator output", () => {
const { render, OUT } = require("../../scripts/generate-openapi-yaml");
assert.equal(
fs.readFileSync(OUT, "utf8"),
render().text,
"openapi.yaml is stale — run `npm run openapi:yaml` and commit the result"
);
});
});