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,140 @@
1
+ // The runner — distilled from arkitect's: per-check gating, crash≠drift, gating-vs-advisory, and the
2
+ // agent-facing FIX_QUEUE.md + manifest.json with `pass.nextAction`. Splits checks by domain: `code`
3
+ // checks always run; `hosted` checks run only when a vault handle is present (so plain `npx architect`
4
+ // runs the code half with no cloud creds).
5
+ // Phase 2: also writes tmp/architect/findings.sarif (SARIF 2.1.0) and tmp/architect/DECISION_BRIEF.json.
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { countSeverities } from "./finding.mjs";
9
+ import { checkAppliesToProject } from "./project-detect.mjs";
10
+ import { toSarif } from "./sarif.mjs";
11
+
12
+ export async function runAudits(audits, ctxBase, { outDir }) {
13
+ mkdirSync(outDir, { recursive: true });
14
+ const results = [];
15
+ for (const audit of audits) {
16
+ if (!checkAppliesToProject(audit.requires, ctxBase.project)) {
17
+ results.push({ id: audit.id, audit, status: "skipped", reason: "requires-not-met" });
18
+ continue;
19
+ }
20
+ if (audit.domain === "hosted" && !ctxBase.vault) {
21
+ results.push({ id: audit.id, audit, status: "skipped", reason: "no-vault: run via the Connections MCP to enable hosted checks" });
22
+ continue;
23
+ }
24
+ const checkConfig = { ...(audit.defaultConfig || {}), ...((ctxBase.config?.checks || {})[audit.id] || {}) };
25
+ const ctx = { ...ctxBase, checkConfig };
26
+ let out;
27
+ try {
28
+ out = await audit.run(ctx);
29
+ } catch (e) {
30
+ // A check that THROWS always fails the run, separately from findings — never a silent pass.
31
+ results.push({
32
+ id: audit.id,
33
+ audit,
34
+ status: "crashed",
35
+ error: String(e?.stack || e?.message || e).split("\n").slice(0, 3).join("\n"),
36
+ findings: [],
37
+ failed: true,
38
+ });
39
+ continue;
40
+ }
41
+ const findings = out?.findings || [];
42
+ const counts = countSeverities(findings);
43
+ const failed = out?.failed ?? counts.errors > 0;
44
+ if (out?.report && (failed || counts.warnings > 0)) {
45
+ const path = out.outputPath || join(outDir, `${audit.id}.md`);
46
+ try {
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ writeFileSync(path, out.report);
49
+ } catch {
50
+ /* best-effort report write */
51
+ }
52
+ }
53
+ results.push({ id: audit.id, audit, status: failed ? "failed" : counts.warnings ? "warned" : "clean", findings, counts, failed });
54
+ }
55
+ return summarize(results, outDir);
56
+ }
57
+
58
+ function rank(r) {
59
+ if (r.status === "crashed") return 0;
60
+ if (r.status === "failed") return 1;
61
+ return 2;
62
+ }
63
+
64
+ const SEV_ORDER = { error: 0, warning: 1, info: 2 };
65
+
66
+ function summarize(results, outDir) {
67
+ let totalErrors = 0,
68
+ gatingErrors = 0,
69
+ warnings = 0,
70
+ crashed = 0;
71
+ for (const r of results) {
72
+ if (r.status === "crashed") {
73
+ crashed++;
74
+ gatingErrors++;
75
+ continue;
76
+ }
77
+ const c = r.counts || { errors: 0, warnings: 0 };
78
+ totalErrors += c.errors;
79
+ warnings += c.warnings;
80
+ // `gating !== false` ⇒ a check gates the run; advisory checks (gating:false) surface but never block.
81
+ if (r.audit.gating !== false && r.failed) gatingErrors += c.errors || 1;
82
+ }
83
+ const clean = gatingErrors === 0;
84
+ const nextAction = gatingErrors > 0 ? "fix-errors" : warnings > 0 ? "review-warnings" : "done";
85
+
86
+ const offenders = results
87
+ .filter((r) => r.status === "failed" || r.status === "crashed" || r.status === "warned")
88
+ .sort((a, b) => rank(a) - rank(b));
89
+ const queue = offenders.map((r) => {
90
+ const tag = r.status === "crashed" ? "CRASH" : r.audit.gating === false ? "ADVISORY" : r.status === "warned" ? "WARN" : "ERROR";
91
+ const detail = r.status === "crashed" ? ` ${r.error?.split("\n")[0] || ""}` : ` (${r.counts?.errors || 0}e/${r.counts?.warnings || 0}w)`;
92
+ return `- [${tag}] ${r.id} — ${r.audit.title}${detail}`;
93
+ });
94
+ writeFileSync(join(outDir, "FIX_QUEUE.md"), `# Architect — Fix Queue\n\nnextAction: ${nextAction}\n\n${queue.join("\n") || "✅ all clean"}\n`);
95
+
96
+ const manifest = {
97
+ totalErrors,
98
+ gatingErrors,
99
+ warnings,
100
+ crashed,
101
+ checks: results.map((r) => ({
102
+ id: r.id,
103
+ group: r.audit.__group ?? null,
104
+ status: r.status,
105
+ errors: r.counts?.errors || 0,
106
+ warnings: r.counts?.warnings || 0,
107
+ ...(r.reason ? { reason: r.reason } : {}),
108
+ })),
109
+ pass: { clean, nextAction },
110
+ };
111
+ writeFileSync(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
112
+
113
+ // Phase 2: SARIF 2.1.0 output.
114
+ try {
115
+ writeFileSync(join(outDir, "findings.sarif"), JSON.stringify(toSarif(results), null, 2));
116
+ } catch {
117
+ /* best-effort */
118
+ }
119
+
120
+ // Phase 2: DECISION_BRIEF — flat, sorted array of all findings enriched with check metadata.
121
+ try {
122
+ const brief = [];
123
+ for (const r of results) {
124
+ for (const f of r.findings || []) {
125
+ brief.push({ ...f, checkId: r.id, checkTitle: r.audit?.title ?? r.id });
126
+ }
127
+ }
128
+ brief.sort((a, b) => {
129
+ const sa = SEV_ORDER[a.severity] ?? 99;
130
+ const sb = SEV_ORDER[b.severity] ?? 99;
131
+ if (sa !== sb) return sa - sb;
132
+ return (a.checkId ?? "").localeCompare(b.checkId ?? "");
133
+ });
134
+ writeFileSync(join(outDir, "DECISION_BRIEF.json"), JSON.stringify(brief, null, 2));
135
+ } catch {
136
+ /* best-effort */
137
+ }
138
+
139
+ return { results, manifest, outDir };
140
+ }
@@ -0,0 +1,81 @@
1
+ // SARIF 2.1.0 emitter — converts architect run results into the Static Analysis Results Interchange Format
2
+ // understood by GitHub Code Scanning, VS Code, and CI toolchains. Zero external dependencies.
3
+ // Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
4
+
5
+ /**
6
+ * Map an architect finding severity to a SARIF level.
7
+ * SARIF levels: "error" | "warning" | "note" | "none"
8
+ */
9
+ function toSarifLevel(severity) {
10
+ if (severity === "error") return "error";
11
+ if (severity === "warning") return "warning";
12
+ return "note"; // info → note
13
+ }
14
+
15
+ /**
16
+ * Convert a flat array of per-check run results (as returned by runner.runAudits) into a SARIF 2.1.0 object.
17
+ *
18
+ * @param {Array<{id:string, audit:{id:string,title:string}, findings?:Array, status:string}>} results
19
+ * @returns {object} SARIF 2.1.0 document
20
+ */
21
+ export function toSarif(results) {
22
+ // Collect unique rule definitions (one per unique check id that produced findings).
23
+ const rulesById = new Map();
24
+ const sarifResults = [];
25
+
26
+ for (const r of results) {
27
+ const findings = r.findings || [];
28
+ if (!findings.length) continue;
29
+
30
+ const ruleId = r.id;
31
+ if (!rulesById.has(ruleId)) {
32
+ rulesById.set(ruleId, {
33
+ id: ruleId,
34
+ name: r.audit?.title ?? ruleId,
35
+ shortDescription: { text: r.audit?.title ?? ruleId },
36
+ });
37
+ }
38
+
39
+ for (const f of findings) {
40
+ const result = {
41
+ ruleId,
42
+ level: toSarifLevel(f.severity),
43
+ message: { text: f.message || f.title || ruleId },
44
+ };
45
+
46
+ // Physical location — only when we have a file reference.
47
+ if (f.file) {
48
+ const loc = {
49
+ physicalLocation: {
50
+ artifactLocation: {
51
+ // SARIF URIs use forward-slash separators and are typically relative.
52
+ uri: f.file.replace(/\\/g, "/"),
53
+ },
54
+ },
55
+ };
56
+ if (typeof f.line === "number" && f.line > 0) {
57
+ loc.physicalLocation.region = { startLine: f.line };
58
+ }
59
+ result.locations = [loc];
60
+ }
61
+
62
+ sarifResults.push(result);
63
+ }
64
+ }
65
+
66
+ return {
67
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
68
+ version: "2.1.0",
69
+ runs: [
70
+ {
71
+ tool: {
72
+ driver: {
73
+ name: "@connections/architect",
74
+ rules: [...rulesById.values()],
75
+ },
76
+ },
77
+ results: sarifResults,
78
+ },
79
+ ],
80
+ };
81
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // publish-core — RELEASE-TIME publisher for the Architect CORE (mirror of services/studio/local-mcp/
3
+ // publish-impl.mjs). Bundles src/core/ + src/checks/ into a SHA-pinned manifest and uploads it +
4
+ // version.json to a public-read S3 object, so every installed @connections/architect self-updates its
5
+ // core on next run (see self-update.mjs). This is NOT part of the dev/test loop — run it at release.
6
+ // Creds: value-blind from break-glass/sia (same pattern as the rest of the repo's break-glass scripts).
7
+ //
8
+ // node src/update/publish-core.mjs
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import os from "node:os";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join, resolve, relative } from "node:path";
14
+ import { walkFiles } from "../core/fs-walk.mjs";
15
+ import { sha256Hex } from "./self-update.mjs";
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const PKG = resolve(HERE, "..", ".."); // packages/connections-architect
19
+ const SRC = join(PKG, "src");
20
+ const REPO = resolve(PKG, "..", ".."); // repo root
21
+ const ACCT = "637560253023";
22
+ const REGION = "us-east-1";
23
+ const BUCKET = `connections-architect-core-${ACCT}`;
24
+ const AWS = process.env.AWS_CLI || "aws";
25
+ const TMP = os.tmpdir();
26
+
27
+ const version = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
28
+
29
+ // Build the manifest: every core/ + checks/ .mjs as { "core/…": source }. (Posix-normalized paths.)
30
+ const files = {};
31
+ for (const top of ["core", "checks"]) {
32
+ for (const f of walkFiles(join(SRC, top))) {
33
+ if (!f.endsWith(".mjs")) continue;
34
+ files[relative(SRC, f).split("\\").join("/")] = readFileSync(f, "utf8");
35
+ }
36
+ }
37
+ const sha = sha256Hex(JSON.stringify(files));
38
+ const manifest = { version, sha, files };
39
+ console.log(`Architect core v${version}: ${Object.keys(files).length} files, sha ${sha.slice(0, 16)}…`);
40
+
41
+ // ── creds: value-blind from break-glass/sia (split JSON objects, match this account) ───────────────────
42
+ function splitObjects(t) {
43
+ const out = [];
44
+ let d = 0, s = -1, inS = false, esc = false;
45
+ for (let i = 0; i < t.length; i++) {
46
+ const c = t[i];
47
+ if (inS) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inS = false; continue; }
48
+ if (c === '"') inS = true;
49
+ else if (c === "{") { if (d === 0) s = i; d++; }
50
+ else if (c === "}") { d--; if (d === 0 && s >= 0) { out.push(t.slice(s, i + 1)); s = -1; } }
51
+ }
52
+ return out;
53
+ }
54
+ const cred = splitObjects(readFileSync(join(REPO, "break-glass", "sia"), "utf8").replace(/^/, ""))
55
+ .map((s) => { try { return JSON.parse(s); } catch { return null; } })
56
+ .filter(Boolean)
57
+ .find((o) => String(o.accountId) === ACCT && /^AKIA[A-Z0-9]{16}$/.test(o.accessKeyId || ""));
58
+ if (!cred) {
59
+ console.error(`no Studio (${ACCT}) creds in break-glass/sia`);
60
+ process.exit(1);
61
+ }
62
+ const env = { ...process.env, AWS_ACCESS_KEY_ID: cred.accessKeyId, AWS_SECRET_ACCESS_KEY: cred.secretAccessKey, AWS_DEFAULT_REGION: REGION, AWS_PAGER: "" };
63
+ const run = (args, ok) => {
64
+ try { return { ok: true, out: execFileSync(AWS, args, { env, encoding: "utf8", maxBuffer: 1 << 26, shell: true }) }; }
65
+ catch (e) { const m = String(e.stderr || e.message || ""); return ok && ok(m) ? { ok: true, out: m } : { ok: false, out: m }; }
66
+ };
67
+
68
+ // bucket + public-read policy (idempotent), then upload the manifest + a small version.json pointer.
69
+ if (!run(["s3api", "head-bucket", "--bucket", BUCKET]).ok) run(["s3api", "create-bucket", "--bucket", BUCKET, "--region", REGION]);
70
+ run(["s3api", "put-public-access-block", "--bucket", BUCKET, "--public-access-block-configuration", "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"]);
71
+ const polFile = join(TMP, "architect-core-policy.json");
72
+ writeFileSync(polFile, JSON.stringify({ Version: "2012-10-17", Statement: [{ Sid: "PublicReadCore", Effect: "Allow", Principal: "*", Action: "s3:GetObject", Resource: `arn:aws:s3:::${BUCKET}/*` }] }));
73
+ run(["s3api", "put-bucket-policy", "--bucket", BUCKET, "--policy", `file://${polFile}`]);
74
+
75
+ const manFile = join(TMP, "architect-core.json");
76
+ const verFile = join(TMP, "architect-version.json");
77
+ writeFileSync(manFile, JSON.stringify(manifest));
78
+ writeFileSync(verFile, JSON.stringify({ version, sha, fileCount: Object.keys(files).length }));
79
+ const up1 = run(["s3", "cp", manFile, `s3://${BUCKET}/architect-core.json`, "--content-type", "application/json", "--cache-control", "max-age=60", "--metadata-directive", "REPLACE"]);
80
+ const up2 = run(["s3", "cp", verFile, `s3://${BUCKET}/version.json`, "--content-type", "application/json", "--cache-control", "max-age=60", "--metadata-directive", "REPLACE"]);
81
+ if (!up1.ok || !up2.ok) {
82
+ console.error("upload failed:\n" + (up1.out || "") + "\n" + (up2.out || ""));
83
+ process.exit(1);
84
+ }
85
+
86
+ // verify live
87
+ const url = `https://${BUCKET}.s3.${REGION}.amazonaws.com/architect-core.json`;
88
+ const res = await fetch(url, { headers: { accept: "application/json" } });
89
+ const body = await res.json().catch(() => null);
90
+ const valid = res.ok && body?.version === version && body?.sha === sha;
91
+ console.log(`GET ${url} → HTTP ${res.status} · sha ${body?.sha === sha ? "match" : "MISMATCH"}`);
92
+ console.log(valid ? `\n✅ PUBLISHED + verified: Architect core v${version}` : `\n❌ verification failed`);
93
+ process.exit(valid ? 0 : 1);
@@ -0,0 +1,90 @@
1
+ // Self-update — the Architect's pull-and-refresh channel, a direct mirror of the MCP loader.mjs
2
+ // resilience ladder applied to the CORE. On launch (gated by config.update), fetch the published core
3
+ // manifest from Connections, SHA-verify it, and swap the shipped core (src/core + src/checks) in place,
4
+ // keeping a `.prev` for rollback. It NEVER writes outside core/+checks/, so your-checks/ and your config
5
+ // stay yours and the core stays updatable underneath them — exactly the loader's "push to AWS, every
6
+ // machine updates next run" property.
7
+ import { createHash } from "node:crypto";
8
+ import { writeFileSync, mkdirSync, rmSync, cpSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+
11
+ export const DEFAULT_CORE_URL = "https://connections-architect-core-637560253023.s3.us-east-1.amazonaws.com";
12
+
13
+ export function sha256Hex(s) {
14
+ return createHash("sha256").update(s).digest("hex");
15
+ }
16
+
17
+ // Strictly-newer semver-ish compare ("1.4.0" > "1.3.9").
18
+ export function isNewer(a, b) {
19
+ const pa = String(a).split(".").map(Number);
20
+ const pb = String(b).split(".").map(Number);
21
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
22
+ const x = pa[i] || 0;
23
+ const y = pb[i] || 0;
24
+ if (x !== y) return x > y;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ // A core manifest is { version, sha, files: { "core/finding.mjs": "<source>", "checks/code/x.mjs": "…" } }.
30
+ // `sha` is sha256 of JSON.stringify(files). Apply it to <srcDir> (the package's src/), with a .prev backup
31
+ // and rollback on any write error. Only ever writes under core/ + checks/.
32
+ export function applyCoreManifest(manifest, srcDir) {
33
+ if (!manifest || typeof manifest.version !== "string" || !manifest.files || typeof manifest.files !== "object") {
34
+ throw new Error("invalid core manifest");
35
+ }
36
+ const got = sha256Hex(JSON.stringify(manifest.files));
37
+ if (manifest.sha && manifest.sha !== got) {
38
+ throw new Error(`core manifest SHA mismatch (want ${String(manifest.sha).slice(0, 12)}…, got ${got.slice(0, 12)}…)`);
39
+ }
40
+ const prev = srcDir + ".prev";
41
+ try {
42
+ rmSync(prev, { recursive: true, force: true });
43
+ cpSync(srcDir, prev, { recursive: true });
44
+ } catch {
45
+ /* best-effort backup */
46
+ }
47
+ try {
48
+ let applied = 0;
49
+ for (const [rel, content] of Object.entries(manifest.files)) {
50
+ if (!/^(core|checks)\//.test(rel) || rel.includes("..")) continue; // never escape core/+checks/
51
+ const path = join(srcDir, rel);
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, content, "utf8");
54
+ applied++;
55
+ }
56
+ return { applied, version: manifest.version };
57
+ } catch (e) {
58
+ // Roll back to the last-good core — never leave a half-written core (loader.mjs's .prev restore).
59
+ try {
60
+ rmSync(srcDir, { recursive: true, force: true });
61
+ cpSync(prev, srcDir, { recursive: true });
62
+ } catch {
63
+ /* best-effort restore */
64
+ }
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ // Orchestration: version-gate → fetch manifest → SHA-verify → apply. Network-gated and fail-soft: any
70
+ // error (offline, 404, bad SHA) keeps the current cached core so a user's run is NEVER broken by an
71
+ // update attempt. Inert until the core bucket exists (pre-release it simply skips).
72
+ export async function selfUpdate({ installedVersion, srcDir, config = {}, coreUrl = DEFAULT_CORE_URL, fetchImpl = fetch }) {
73
+ const u = config.update || {};
74
+ if (u.autoUpdate === false) return { skipped: "disabled" };
75
+ if (u.pinnedVersion) return { skipped: "pinned", pinned: u.pinnedVersion };
76
+ if (process.env.ARCHITECT_NO_SELF_UPDATE) return { skipped: "env-disabled" };
77
+ try {
78
+ const vRes = await fetchImpl(`${coreUrl}/version.json`, { signal: AbortSignal.timeout(5000) });
79
+ if (!vRes.ok) return { skipped: "no-version" };
80
+ const v = await vRes.json();
81
+ if (!v?.version || !isNewer(v.version, installedVersion)) return { skipped: "up-to-date", remote: v?.version };
82
+ const mRes = await fetchImpl(`${coreUrl}/architect-core.json`, { signal: AbortSignal.timeout(8000) });
83
+ if (!mRes.ok) return { skipped: "no-manifest" };
84
+ const manifest = await mRes.json();
85
+ if (!isNewer(manifest.version, installedVersion)) return { skipped: "stale-manifest" };
86
+ return applyCoreManifest(manifest, srcDir);
87
+ } catch (e) {
88
+ return { skipped: "error", error: String(e?.message || e) };
89
+ }
90
+ }
@@ -0,0 +1,27 @@
1
+ # your-checks/
2
+
3
+ Your own Architect checks live here. **The core never touches this folder** — self-update only ever
4
+ refreshes the shipped core, so your rules stay yours while the core keeps improving underneath them.
5
+
6
+ A check is any `.mjs` exporting `audit`:
7
+
8
+ ```js
9
+ export const audit = {
10
+ id: "my-rule", // unique; SAME id as a core check ⇒ yours SHADOWS the core's
11
+ title: "My rule",
12
+ category: "custom",
13
+ domain: "code", // "code" (your repo) | "hosted" (your cloud, via the MCP vault)
14
+ requires: {}, // {} = any repo; e.g. { ecosystems: ["npm"] }
15
+ gating: false, // true ⇒ blocks `architect --fail-on-drift`
16
+ async run(ctx) {
17
+ // ctx = { root, config, checkConfig, project, vault? }
18
+ return { failed: false, findings: [/* createFinding(...) */], report: "" };
19
+ },
20
+ };
21
+ ```
22
+
23
+ Drop it under `your-checks/code/` (or `your-checks/hosted/`) and it auto-registers — no wiring. Copy
24
+ `code/example-check.mjs` to start. Import helpers with `import { createFinding, walkFiles } from "connections-architect"`.
25
+
26
+ **Forking / pinning:** set `update.autoUpdate: false` (or pin `update.pinnedVersion`) in `architect.config.json`
27
+ to stop pulling the core entirely and own it outright — GitHub-fork style.
@@ -0,0 +1,24 @@
1
+ // ── Your own check. Copy this file, rename it, make it yours. ──
2
+ // The Architect core NEVER overwrites anything under your-checks/ — your rules stay yours while the core
3
+ // keeps improving underneath them. A check is just a module exporting `audit` with an `id` + `run(ctx)`.
4
+ //
5
+ // import { createFinding, walkFiles } from "connections-architect";
6
+ //
7
+ // (Plain { id, title, severity, file, line, message } objects work too — createFinding just fills defaults.)
8
+
9
+ export const audit = {
10
+ id: "example-your-check",
11
+ title: "Example — your own check goes here",
12
+ category: "custom",
13
+ domain: "code", // "code" runs in your repo; "hosted" runs through the MCP vault against your cloud
14
+ requires: {}, // {} = any repo; e.g. { ecosystems: ["npm"] } to scope it
15
+ gating: false, // true ⇒ blocks `architect --fail-on-drift`
16
+ async run(ctx) {
17
+ // ctx = { root, config, checkConfig, project, vault? }
18
+ // Example skeleton:
19
+ // const findings = [];
20
+ // for (const file of walkFiles(ctx.root)) { /* inspect; push createFinding(...) */ }
21
+ // return { failed: findings.some(f => f.severity === "error"), findings, report: "" };
22
+ return { failed: false, findings: [], report: "" };
23
+ },
24
+ };