connections-architect 0.2.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.
@@ -0,0 +1,113 @@
1
+ // HOSTED check — the generic justify-existence governor for a live Postgres DB, distilled from daggar.
2
+ // Runs ONLY through the MCP vault (domain:"hosted" → skipped standalone). Judges every table on the
3
+ // 3-axis model (DATA rows × external APP-CODE refs × internal DB refs) against the user's spine, and
4
+ // emits the 5 verdicts + a review-only remediation block. READ-ONLY — it never mutates the DB.
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { createFinding } from "../../core/finding.mjs";
8
+ import { walkFiles } from "../../core/fs-walk.mjs";
9
+ import { loadSpine, resolveClaim } from "../../core/hosted/spine.mjs";
10
+ import { judgeObject, verdictSeverity, VERDICT_RANK } from "../../core/hosted/judge.mjs";
11
+ import { vaultRdsQuery } from "../../core/hosted/vault-exec.mjs";
12
+
13
+ const TABLES_SQL = "select relname as name, n_live_tup as rows from pg_stat_user_tables";
14
+ const INTERNAL_SRC_SQL =
15
+ "select prosrc as src from pg_proc where pronamespace = 'public'::regnamespace " +
16
+ "union all select definition as src from pg_views where schemaname = 'public'";
17
+
18
+ const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".rb", ".java", ".php", ".sql"]);
19
+
20
+ export const audit = {
21
+ id: "db-justify-existence",
22
+ title: "Database — justify existence (hosted)",
23
+ category: "governance",
24
+ domain: "hosted",
25
+ requires: {},
26
+ gating: false,
27
+ defaultConfig: {},
28
+ async run(ctx) {
29
+ const { root, config, vault } = ctx;
30
+ const targets = (config?.hosted?.targets || []).filter((t) => t.kind === "aurora-postgres-data-api");
31
+ if (!targets.length) return { failed: false, findings: [], report: "" };
32
+
33
+ // The user's spine (the policy — their `your-checks/` for the hosted half). Path is repo-relative.
34
+ let spine = loadSpine({});
35
+ if (config?.hosted?.spine) {
36
+ try {
37
+ spine = loadSpine(JSON.parse(readFileSync(join(root, config.hosted.spine), "utf8")));
38
+ } catch {
39
+ /* no spine → everything is unrecorded (PROVE-OR-DIE) */
40
+ }
41
+ }
42
+ const codeBlob = readCodeBlob(root, spine.appCodeRoots);
43
+
44
+ const findings = [];
45
+ const killOrders = [];
46
+ for (const target of targets) {
47
+ const tables = await vaultRdsQuery(vault, target, TABLES_SQL);
48
+ const internalSrc = (await vaultRdsQuery(vault, target, INTERNAL_SRC_SQL)).map((r) => String(r.src ?? "")).join("\n");
49
+ for (const t of tables) {
50
+ const name = String(t.name);
51
+ const rows = Number(t.rows) || 0;
52
+ const dbRefs = countOccurrences(internalSrc, name);
53
+ const appRefs = countOccurrences(codeBlob, name);
54
+ const alive = rows > 0 || dbRefs > 0 || appRefs > 0;
55
+ const claim = resolveClaim(spine, name);
56
+ const verdict = judgeObject({ declared: claim.declared, alive, investigate: claim.investigate, claimedRemove: claim.claimedRemove });
57
+ const sev = verdictSeverity(verdict);
58
+ if (!sev) continue; // JUSTIFIED
59
+ findings.push(
60
+ createFinding({
61
+ id: "db-object-verdict",
62
+ title: `${verdict}: ${name}`,
63
+ severity: sev,
64
+ resource: `${target.database}.${name}`,
65
+ account: target.vaultInstance,
66
+ verdict,
67
+ message: `${verdict} — rows=${rows}, app-refs=${appRefs}, db-refs=${dbRefs}, declared=${claim.declared}${claim.claimedBy ? ` (by ${claim.claimedBy})` : ""}`,
68
+ fix: verdict === "PROVE-OR-DIE" ? `Add a spine claim for "${name}" or drop it.` : null,
69
+ }),
70
+ );
71
+ if (verdict === "CONDEMNED") {
72
+ killOrders.push(`-- ${target.database}.${name}: CONDEMNED (rows=${rows}, app-refs=${appRefs}, db-refs=${dbRefs})\n-- drop table if exists "${name}"; -- REVIEW + previewed read before running`);
73
+ }
74
+ }
75
+ }
76
+
77
+ findings.sort((a, b) => (VERDICT_RANK[a.verdict] ?? 9) - (VERDICT_RANK[b.verdict] ?? 9));
78
+ const warnings = findings.filter((f) => f.severity === "warning").length;
79
+ const report = findings.length
80
+ ? `# DB justify-existence\n\n${findings.map((f) => `- ${f.verdict} ${f.resource} — ${f.message}`).join("\n")}` +
81
+ (killOrders.length ? `\n\n## Remediation — REVIEW + previewed read before running (never auto-executed)\n\n\`\`\`sql\n${killOrders.join("\n\n")}\n\`\`\`` : "") +
82
+ `\n\nErrors: 0\nWarnings: ${warnings}\n`
83
+ : "";
84
+ return { failed: false, findings, report };
85
+ },
86
+ };
87
+
88
+ // Concatenate the repo's code once so each table name can be counted against it (the external-reference
89
+ // axis). Bounded so a huge monorepo doesn't blow memory; honors the spine's appCodeRoots if set.
90
+ function readCodeBlob(root, appCodeRoots) {
91
+ const roots = appCodeRoots && appCodeRoots.length ? appCodeRoots.map((r) => join(root, r)) : [root];
92
+ let blob = "";
93
+ let budget = 4000;
94
+ for (const r of roots) {
95
+ for (const file of walkFiles(r)) {
96
+ if (budget-- <= 0) break;
97
+ if (!CODE_EXT.has(file.slice(file.lastIndexOf(".")))) continue;
98
+ try {
99
+ blob += "\n" + readFileSync(file, "utf8");
100
+ } catch {
101
+ /* skip unreadable */
102
+ }
103
+ }
104
+ }
105
+ return blob;
106
+ }
107
+
108
+ function countOccurrences(haystack, name) {
109
+ if (!name) return 0;
110
+ const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g");
111
+ const m = haystack.match(re);
112
+ return m ? m.length : 0;
113
+ }
@@ -0,0 +1,36 @@
1
+ // Auto-discovery — the heart of the core/extension model. A check is ANY .mjs that exports `audit`
2
+ // with an `id` + `run()`. No registry to edit; the file IS the registration. We scan MULTIPLE roots in
3
+ // priority order and merge: later roots OVERRIDE earlier by id, so a user's `your-checks/` check shadows
4
+ // a core check of the same id (patch a core check without touching the core). The folder name = the group.
5
+ import { existsSync } from "node:fs";
6
+ import { basename, dirname } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { walkFiles } from "./fs-walk.mjs";
9
+
10
+ export async function discoverAudits(roots) {
11
+ const byId = new Map();
12
+ const loadErrors = [];
13
+ const shadowed = [];
14
+ for (const root of roots) {
15
+ if (!root || !existsSync(root)) continue;
16
+ for (const file of walkFiles(root)) {
17
+ if (!file.endsWith(".mjs") || file.endsWith(".test.mjs")) continue;
18
+ let mod;
19
+ try {
20
+ mod = await import(pathToFileURL(file).href);
21
+ } catch (e) {
22
+ // A check that won't even load must be LOUD (crash≠silent-pass), never silently dropped.
23
+ loadErrors.push({ file, error: String(e?.message || e) });
24
+ continue;
25
+ }
26
+ const audit = mod.audit;
27
+ if (!audit || typeof audit.id !== "string" || typeof audit.run !== "function") continue;
28
+ if (byId.has(audit.id)) shadowed.push({ id: audit.id, by: file });
29
+ audit.__file = file;
30
+ audit.__group = basename(dirname(file));
31
+ audit.__root = root;
32
+ byId.set(audit.id, audit);
33
+ }
34
+ }
35
+ return { audits: [...byId.values()], loadErrors, shadowed };
36
+ }
@@ -0,0 +1,40 @@
1
+ // The one Finding shape every check emits — for code AND hosted/infra checks. Distilled from arkitect's
2
+ // normalized finding + risk scoring. A check may also return plain objects of this shape; createFinding
3
+ // just fills defaults. Carries infra fields (resource/account/region) so the hosted half reuses it verbatim.
4
+
5
+ export const SEVERITY = Object.freeze({ error: "error", warning: "warning", info: "info" });
6
+
7
+ export function createFinding({
8
+ id,
9
+ title,
10
+ severity = "error",
11
+ file = null,
12
+ line = null,
13
+ message = "",
14
+ resource = null, // hosted: an ARN / table / bucket / role
15
+ account = null, // hosted: which cloud account
16
+ region = null,
17
+ verdict = null, // hosted: JUSTIFIED | WATCH | PROVE-OR-DIE | INVESTIGATE | CONDEMNED
18
+ fix = null, // a one-line suggested remediation
19
+ } = {}) {
20
+ return { id, title, severity, file, line, message: message || title, resource, account, region, verdict, fix };
21
+ }
22
+
23
+ export function countSeverities(findings = []) {
24
+ let errors = 0,
25
+ warnings = 0,
26
+ infos = 0;
27
+ for (const f of findings) {
28
+ if (f.severity === "error") errors++;
29
+ else if (f.severity === "warning") warnings++;
30
+ else infos++;
31
+ }
32
+ return { errors, warnings, infos };
33
+ }
34
+
35
+ // Cheap risk ordering for the FIX_QUEUE — errors first, then by reachability hints. Kept deliberately
36
+ // simple in Phase 1; the full arkitect risk axes (exposure × data × reachability × safety-net) land in Phase 2.
37
+ export function riskRank(finding) {
38
+ const sev = finding.severity === "error" ? 0 : finding.severity === "warning" ? 1 : 2;
39
+ return sev;
40
+ }
@@ -0,0 +1,22 @@
1
+ // One filesystem walker, reused by discovery + every check (DRY). Skips the usual build/vendor dirs.
2
+ import { readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { IGNORE_DIRS } from "./project-detect.mjs";
5
+
6
+ export function* walkFiles(dir, { ignore = IGNORE_DIRS, includeDotfiles = false } = {}) {
7
+ let entries;
8
+ try {
9
+ entries = readdirSync(dir, { withFileTypes: true });
10
+ } catch {
11
+ return;
12
+ }
13
+ for (const e of entries) {
14
+ if (!includeDotfiles && e.name.startsWith(".")) continue;
15
+ const p = join(dir, e.name);
16
+ if (e.isDirectory()) {
17
+ if (!ignore.has(e.name)) yield* walkFiles(p, { ignore, includeDotfiles });
18
+ } else {
19
+ yield p;
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,45 @@
1
+ // The generic "justify existence" judge — distilled from daggar's 3-axis verdict system, generalized so
2
+ // it works on ANY live resource (a DB table, an S3 bucket, an IAM role), not just our schema.
3
+ //
4
+ // daggar judged each DB object on DATA (rows) × APP-CODE refs × DB-internal refs, against a per-workspace
5
+ // "justify existence" spine. The generalization: an object is JUSTIFIED only when it is BOTH
6
+ // • DECLARED — a human wrote down why it exists (a spine claim), AND
7
+ // • ALIVE — something actually uses it (holds data, or is referenced by external code, or by another
8
+ // internal object).
9
+ // An explicit spine verdict (INVESTIGATE / REMOVE) overrides the inference — so a squatter can't be
10
+ // auto-justified by a loose prefix-claim, and a human kill-order is honored.
11
+
12
+ // Ranked worst→best, for sorting kill-orders.
13
+ export const VERDICT_RANK = Object.freeze({
14
+ CONDEMNED: 0,
15
+ "PROVE-OR-DIE": 1,
16
+ INVESTIGATE: 2,
17
+ WATCH: 3,
18
+ JUSTIFIED: 4,
19
+ });
20
+
21
+ /**
22
+ * Judge ONE object.
23
+ * @param {object} o
24
+ * @param {boolean} o.declared - the spine claims this object (a recorded reason to exist)
25
+ * @param {boolean} o.alive - anything references/uses it (rows > 0 OR external ref OR internal ref)
26
+ * @param {boolean} [o.investigate] - the spine explicitly flags it for human review (overrides)
27
+ * @param {boolean} [o.claimedRemove] - the spine explicitly orders removal (overrides)
28
+ * @returns {"JUSTIFIED"|"WATCH"|"PROVE-OR-DIE"|"INVESTIGATE"|"CONDEMNED"}
29
+ */
30
+ export function judgeObject({ declared, alive, investigate = false, claimedRemove = false }) {
31
+ if (investigate) return "INVESTIGATE"; // explicit human flag wins
32
+ if (claimedRemove) return "CONDEMNED"; // explicit kill-order wins
33
+ if (declared && alive) return "JUSTIFIED"; // recorded AND used
34
+ if (declared && !alive) return "WATCH"; // recorded but inert — keep an eye on it
35
+ if (!declared && alive) return "PROVE-OR-DIE"; // used but no recorded reason — earn a spine entry or die
36
+ return "CONDEMNED"; // unrecorded AND inert
37
+ }
38
+
39
+ // Verdict → finding severity. This is a read-only GOVERNANCE judgment (recommends, never mutates), so the
40
+ // strongest verdicts surface as warnings, the softer ones as info. JUSTIFIED produces no finding.
41
+ export function verdictSeverity(verdict) {
42
+ if (verdict === "CONDEMNED" || verdict === "PROVE-OR-DIE") return "warning";
43
+ if (verdict === "WATCH" || verdict === "INVESTIGATE") return "info";
44
+ return null; // JUSTIFIED
45
+ }
@@ -0,0 +1,42 @@
1
+ // The "justify existence" SPINE — the user-extension space for the HOSTED half (the daggar spine,
2
+ // generalized + portable). The user declares WHY each live object exists; the framework judges against it.
3
+ // This is the hosted equivalent of `your-checks/`: the policy is the user's, the engine is the core's.
4
+ //
5
+ // Shape (justify-existence.json):
6
+ // {
7
+ // "appCodeRoots": ["src", "services"], // where to grep for external references (default: repo root)
8
+ // "features": [ // each feature CLAIMS the objects it owns
9
+ // { "name": "billing", "claims": ["invoices", "payment_*"] }
10
+ // ],
11
+ // "verdicts": { "legacy_temp": "REMOVE", "weird_table": "INVESTIGATE" } // explicit overrides
12
+ // }
13
+ // A claim ending in `*` is a prefix claim; otherwise it's an exact name.
14
+
15
+ export function loadSpine(json) {
16
+ const s = json && typeof json === "object" ? json : {};
17
+ return {
18
+ appCodeRoots: Array.isArray(s.appCodeRoots) && s.appCodeRoots.length ? s.appCodeRoots : null,
19
+ features: Array.isArray(s.features) ? s.features : [],
20
+ verdicts: s.verdicts && typeof s.verdicts === "object" ? s.verdicts : {},
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Resolve what the spine says about an object by name.
26
+ * Explicit `verdicts[name]` overrides any feature claim (so a prefix-claim can't auto-justify a squatter,
27
+ * and a human REMOVE/INVESTIGATE is honored).
28
+ * @returns {{ declared: boolean, investigate: boolean, claimedRemove: boolean, claimedBy: string|null }}
29
+ */
30
+ export function resolveClaim(spine, name) {
31
+ const override = spine.verdicts?.[name];
32
+ if (override === "INVESTIGATE") return { declared: true, investigate: true, claimedRemove: false, claimedBy: "verdicts" };
33
+ if (override === "REMOVE") return { declared: true, investigate: false, claimedRemove: true, claimedBy: "verdicts" };
34
+
35
+ for (const f of spine.features || []) {
36
+ for (const claim of f.claims || []) {
37
+ const matched = claim.endsWith("*") ? name.startsWith(claim.slice(0, -1)) : claim === name;
38
+ if (matched) return { declared: true, investigate: false, claimedRemove: false, claimedBy: f.name || claim };
39
+ }
40
+ }
41
+ return { declared: false, investigate: false, claimedRemove: false, claimedBy: null };
42
+ }
@@ -0,0 +1,61 @@
1
+ // Run a READ-ONLY query against the user's OWN cloud DB THROUGH the MCP vault — value-blind: the
2
+ // credential never enters the model context; the MCP injects it server-side (the same seam deploy_static
3
+ // uses). The `vault` handle is provided by the MCP-hosted runner. Standalone (`npx architect`, no MCP)
4
+ // there is no vault, so hosted checks are skipped (see runner.mjs) — the code half still runs.
5
+ //
6
+ // target: { kind:"aurora-postgres-data-api", vaultInstance, resourceArn, secretArn, database, region? }
7
+
8
+ export async function vaultRdsQuery(vault, target, sql) {
9
+ if (!vault || typeof vault.awsCall !== "function") {
10
+ throw new Error("hosted checks require the Connections MCP vault (no vault handle present)");
11
+ }
12
+ const res = await vault.awsCall({
13
+ instance: target.vaultInstance,
14
+ service: "rds-data",
15
+ region: target.region || "us-east-1",
16
+ method: "POST",
17
+ path: "/Execute",
18
+ body: JSON.stringify({
19
+ resourceArn: target.resourceArn,
20
+ secretArn: target.secretArn,
21
+ database: target.database,
22
+ sql,
23
+ includeResultMetadata: true,
24
+ }),
25
+ });
26
+ return parseRdsRecords(res);
27
+ }
28
+
29
+ // RDS Data API returns { records: [[{stringValue|longValue|…}, …], …], columnMetadata: [{name},…] }.
30
+ // Flatten to plain {col: value} rows. Mirrors daggar's cell() extraction. Pure → unit-testable.
31
+ export function parseRdsRecords(res) {
32
+ const body = typeof res?.body === "string" ? safeJson(res.body) : (res?.body ?? res);
33
+ if (!body || !Array.isArray(body.records)) return [];
34
+ const cols = (body.columnMetadata || []).map((c) => c.name);
35
+ return body.records.map((rec) => {
36
+ const row = {};
37
+ rec.forEach((cell, i) => {
38
+ row[cols[i] ?? i] = cellValue(cell);
39
+ });
40
+ return row;
41
+ });
42
+ }
43
+
44
+ function cellValue(cell) {
45
+ if (!cell || typeof cell !== "object") return cell;
46
+ if (cell.isNull) return null;
47
+ if ("stringValue" in cell) return cell.stringValue;
48
+ if ("longValue" in cell) return cell.longValue;
49
+ if ("doubleValue" in cell) return cell.doubleValue;
50
+ if ("booleanValue" in cell) return cell.booleanValue;
51
+ if ("blobValue" in cell) return cell.blobValue;
52
+ return null;
53
+ }
54
+
55
+ function safeJson(s) {
56
+ try {
57
+ return JSON.parse(s);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
@@ -0,0 +1,12 @@
1
+ // Public API for check authors: `import { createFinding } from "connections-architect"`.
2
+ export { createFinding, countSeverities, riskRank, SEVERITY } from "./finding.mjs";
3
+ export { detectProject, checkAppliesToProject, IGNORE_DIRS } from "./project-detect.mjs";
4
+ export { walkFiles } from "./fs-walk.mjs";
5
+ export { discoverAudits } from "./discovery.mjs";
6
+ export { runAudits } from "./runner.mjs";
7
+ export { toSarif } from "./sarif.mjs";
8
+ export { runFamilyConformance } from "./oracle/family-conformance.mjs";
9
+ export { judgeObject, verdictSeverity, VERDICT_RANK } from "./hosted/judge.mjs";
10
+ export { loadSpine, resolveClaim } from "./hosted/spine.mjs";
11
+ export { vaultRdsQuery, parseRdsRecords } from "./hosted/vault-exec.mjs";
12
+ export { selfUpdate, applyCoreManifest, isNewer, sha256Hex } from "../update/self-update.mjs";
@@ -0,0 +1,83 @@
1
+ // Generic, project-agnostic conformance oracle — the reference-mode counterpart to sibling-consensus'
2
+ // consensus mode. Distilled from arkitect's `family-conformance-engine`: declare what CORRECT looks like
3
+ // (a reference member OR the majority) and flag any diverger. One oracle replaces dozens of point-checks
4
+ // and catches divergence nobody has named yet.
5
+ //
6
+ // Usage:
7
+ // const { findings } = await runFamilyConformance({ family, project, mode, referenceId });
8
+ // // findings: Array<{ member, expected, actual }> — raw divergences; the CALLER creates Finding objects.
9
+
10
+ /**
11
+ * Run conformance checking over a family of members.
12
+ *
13
+ * @param {object} opts
14
+ * @param {Array<{path:string, [key:string]:any}>} opts.family Members to compare; each must have `path`.
15
+ * @param {function(member): any} opts.project Extracts a comparable fingerprint per member.
16
+ * @param {"reference"|"consensus"} opts.mode How to derive the expected fingerprint.
17
+ * @param {string} [opts.referenceId] (reference mode) path or id to use as truth.
18
+ * @returns {Promise<{findings: Array<{member,expected,actual}>, consensusOrReference: any}>}
19
+ */
20
+ export async function runFamilyConformance({ family, project, mode, referenceId }) {
21
+ if (!Array.isArray(family) || family.length === 0) {
22
+ return { findings: [], consensusOrReference: null };
23
+ }
24
+
25
+ // Compute fingerprints for all members. `project` may be async.
26
+ const projected = await Promise.all(
27
+ family.map(async (member) => {
28
+ let fingerprint;
29
+ try {
30
+ fingerprint = await project(member);
31
+ } catch {
32
+ fingerprint = null;
33
+ }
34
+ return { member, fingerprint, key: JSON.stringify(fingerprint ?? null) };
35
+ }),
36
+ );
37
+
38
+ let expectedKey;
39
+ let consensusOrReference;
40
+
41
+ if (mode === "reference") {
42
+ // Find the reference member by `path` (or `.id` as fallback).
43
+ const ref = projected.find(
44
+ (p) => p.member.path === referenceId || p.member.id === referenceId,
45
+ );
46
+ if (!ref) {
47
+ // No reference found — cannot judge; return clean.
48
+ return { findings: [], consensusOrReference: null };
49
+ }
50
+ expectedKey = ref.key;
51
+ consensusOrReference = ref.fingerprint;
52
+ } else {
53
+ // Consensus: the most common fingerprint across the family.
54
+ const counts = new Map();
55
+ for (const p of projected) counts.set(p.key, (counts.get(p.key) || 0) + 1);
56
+ const [topKey] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
57
+ const topCount = counts.get(topKey);
58
+ // Only meaningful when a clear majority (> half) exists.
59
+ if (topCount <= projected.length / 2) {
60
+ return { findings: [], consensusOrReference: null };
61
+ }
62
+ expectedKey = topKey;
63
+ consensusOrReference = JSON.parse(topKey);
64
+ }
65
+
66
+ const findings = [];
67
+ for (const p of projected) {
68
+ // In reference mode, skip the reference member itself.
69
+ if (mode === "reference") {
70
+ const isRef = p.member.path === referenceId || p.member.id === referenceId;
71
+ if (isRef) continue;
72
+ }
73
+ if (p.key !== expectedKey) {
74
+ findings.push({
75
+ member: p.member,
76
+ expected: JSON.parse(expectedKey),
77
+ actual: p.fingerprint,
78
+ });
79
+ }
80
+ }
81
+
82
+ return { findings, consensusOrReference };
83
+ }
@@ -0,0 +1,106 @@
1
+ // THE portability primitive (distilled from arkitect's project-detect + `requires` gating).
2
+ // Detect what a repo IS (languages / ecosystems / frameworks) from the filesystem, then let each check
3
+ // declare `requires:{…}` and SKIP itself when the repo doesn't match. A generic CORE check declares
4
+ // `requires:{}` (or omits it) → runs on ANY repo. This is what makes a check portable to a stranger's codebase.
5
+
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { join, extname } from "node:path";
8
+
9
+ const MANIFEST_ECOSYSTEM = {
10
+ "package.json": "npm",
11
+ "Cargo.toml": "cargo",
12
+ "go.mod": "go",
13
+ "requirements.txt": "pip",
14
+ "pyproject.toml": "pip",
15
+ "Gemfile": "gem",
16
+ "composer.json": "composer",
17
+ "pom.xml": "maven",
18
+ "build.gradle": "gradle",
19
+ };
20
+ const EXT_LANG = {
21
+ ".ts": "typescript",
22
+ ".tsx": "typescript",
23
+ ".js": "javascript",
24
+ ".jsx": "javascript",
25
+ ".mjs": "javascript",
26
+ ".cjs": "javascript",
27
+ ".py": "python",
28
+ ".rs": "rust",
29
+ ".go": "go",
30
+ ".rb": "ruby",
31
+ ".java": "java",
32
+ ".php": "php",
33
+ ".vue": "vue",
34
+ ".svelte": "svelte",
35
+ };
36
+ export const IGNORE_DIRS = new Set([
37
+ "node_modules",
38
+ ".git",
39
+ "dist",
40
+ "build",
41
+ "out",
42
+ "cdk.out",
43
+ ".next",
44
+ ".nuxt",
45
+ "coverage",
46
+ "tmp",
47
+ ".turbo",
48
+ "vendor",
49
+ "target",
50
+ ]);
51
+
52
+ export function detectProject(root) {
53
+ const ecosystems = new Set();
54
+ for (const [f, eco] of Object.entries(MANIFEST_ECOSYSTEM)) if (existsSync(join(root, f))) ecosystems.add(eco);
55
+
56
+ const frameworks = new Set();
57
+ try {
58
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
59
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
60
+ if (deps.vue || deps.nuxt) frameworks.add("vue");
61
+ if (deps.react || deps.next) frameworks.add("react");
62
+ if (deps.svelte) frameworks.add("svelte");
63
+ if (deps.express || deps.fastify) frameworks.add("node-server");
64
+ } catch {
65
+ /* no/unreadable package.json — fine */
66
+ }
67
+
68
+ // Sample file extensions with a bounded walk (don't traverse the whole tree).
69
+ const languages = new Set();
70
+ let budget = 4000;
71
+ const walk = (dir) => {
72
+ if (budget <= 0) return;
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(dir, { withFileTypes: true });
76
+ } catch {
77
+ return;
78
+ }
79
+ for (const e of entries) {
80
+ if (budget <= 0) return;
81
+ if (e.name.startsWith(".")) continue;
82
+ if (e.isDirectory()) {
83
+ if (!IGNORE_DIRS.has(e.name)) walk(join(dir, e.name));
84
+ continue;
85
+ }
86
+ budget--;
87
+ const lang = EXT_LANG[extname(e.name)];
88
+ if (lang) languages.add(lang);
89
+ }
90
+ };
91
+ walk(root);
92
+
93
+ return { root, languages: [...languages], ecosystems: [...ecosystems], frameworks: [...frameworks] };
94
+ }
95
+
96
+ // A check runs only if every dimension it requires is satisfied. Empty/absent `requires` ⇒ always runs.
97
+ export function checkAppliesToProject(requires, project) {
98
+ if (!requires) return true;
99
+ const ok = (key) => {
100
+ const need = requires[key];
101
+ if (!need || need.length === 0) return true;
102
+ const have = project[key] || [];
103
+ return need.some((v) => have.includes(v));
104
+ };
105
+ return ok("languages") && ok("ecosystems") && ok("frameworks");
106
+ }