connections-arkitect 0.3.4

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,63 @@
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, { id: ruleId, name: r.audit?.title ?? ruleId, shortDescription: { text: r.audit?.title ?? ruleId } });
33
+ }
34
+
35
+ for (const f of findings) {
36
+ const result = { ruleId, level: toSarifLevel(f.severity), message: { text: f.message || f.title || ruleId } };
37
+
38
+ // Physical location — only when we have a file reference.
39
+ if (f.file) {
40
+ const loc = {
41
+ physicalLocation: {
42
+ artifactLocation: {
43
+ // SARIF URIs use forward-slash separators and are typically relative.
44
+ uri: f.file.replace(/\\/g, "/"),
45
+ },
46
+ },
47
+ };
48
+ if (typeof f.line === "number" && f.line > 0) {
49
+ loc.physicalLocation.region = { startLine: f.line };
50
+ }
51
+ result.locations = [loc];
52
+ }
53
+
54
+ sarifResults.push(result);
55
+ }
56
+ }
57
+
58
+ return {
59
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
60
+ version: "2.1.0",
61
+ runs: [{ tool: { driver: { name: "@connections/architect", rules: [...rulesById.values()] } }, results: sarifResults }],
62
+ };
63
+ }
@@ -0,0 +1,199 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { countFileLines, walkFiles } from "@lunawerx/arkitect-core/core/files";
4
+
5
+ export const SURFACE_SIZE_COVERAGE_DEFAULTS = {
6
+ title: "Surface Size And Coverage Audit",
7
+ roots: ["services/main/web/src"],
8
+ extensions: [".css", ".ts", ".tsx", ".vue"],
9
+ warnLines: 700,
10
+ reviewLines: 1200,
11
+ highRiskLines: 2000,
12
+ includeSizeQueue: true,
13
+ failOnSizeRegressions: true,
14
+ growthLines: 100,
15
+ top: 35,
16
+ outputPath: "tmp/audits/SURFACE_SIZE_COVERAGE_AUDIT.md",
17
+ coverageTerms: [],
18
+ coverageColumnLabel: "Covered by tests",
19
+ testFilePattern: "\\.spec\\.ts$",
20
+ largestFilesHeading: "Largest Surface Files",
21
+ oversizedLabel: "Surface files",
22
+ attentionQueueLabel: "Files in attention queue",
23
+ policyNote:
24
+ "Line count is a maintainability heuristic, not a universal safety rule. Use the queue to find files that may mix responsibilities; strict failures come from size regressions or missing coverage terms.",
25
+ };
26
+
27
+ export async function runSurfaceSizeCoverageAudit(context) {
28
+ const checkConfig = applyLegacyArgs(context.checkConfig, context.checkArgs);
29
+ const files = (
30
+ await Promise.all(
31
+ (
32
+ await walkFiles({
33
+ root: context.root,
34
+ roots: checkConfig.roots,
35
+ extensions: checkConfig.extensions,
36
+ skipSegments: checkConfig.skipSegments,
37
+ })
38
+ ).map(async (filePath) => ({ path: filePath, lines: await countFileLines(context.root, filePath) })),
39
+ )
40
+ ).sort((left, right) => right.lines - left.lines);
41
+
42
+ const oversized = checkConfig.includeSizeQueue ? files.filter((file) => file.lines >= checkConfig.warnLines) : [];
43
+ const testFilePattern = new RegExp(checkConfig.testFilePattern);
44
+ const testFiles = files.filter((file) => testFilePattern.test(file.path));
45
+ const testText = (await Promise.all(testFiles.map((file) => fs.readFile(path.resolve(context.root, file.path), "utf8")))).join("\n");
46
+
47
+ const coverage = checkConfig.coverageTerms.map((term) => ({
48
+ label: term.label,
49
+ covered: new RegExp(term.pattern, term.flags ?? "i").test(testText),
50
+ }));
51
+ const uncovered = coverage.filter((item) => !item.covered);
52
+ const regressions =
53
+ context.baseline?.files && checkConfig.includeSizeQueue && checkConfig.failOnSizeRegressions
54
+ ? oversized.filter((file) => {
55
+ const baselineLines = context.baseline.files?.[file.path]?.lines;
56
+ if (baselineLines === undefined) {
57
+ return true;
58
+ }
59
+ return file.lines - baselineLines >= checkConfig.growthLines;
60
+ })
61
+ : [];
62
+ const sizeBands = buildSizeBands(oversized, checkConfig);
63
+
64
+ const baselineDocument = {
65
+ version: 1,
66
+ generatedAt: new Date().toISOString(),
67
+ includeSizeQueue: checkConfig.includeSizeQueue,
68
+ failOnSizeRegressions: checkConfig.failOnSizeRegressions,
69
+ warnLines: checkConfig.warnLines,
70
+ reviewLines: checkConfig.reviewLines,
71
+ highRiskLines: checkConfig.highRiskLines,
72
+ growthLines: checkConfig.growthLines,
73
+ files: Object.fromEntries(oversized.map((file) => [file.path, { lines: file.lines }])),
74
+ coverage: Object.fromEntries(coverage.map((item) => [item.label, item.covered])),
75
+ };
76
+
77
+ const report = renderMarkdown({
78
+ baseline: context.baseline,
79
+ checkConfig,
80
+ coverage,
81
+ files,
82
+ oversized,
83
+ regressions,
84
+ sizeBands,
85
+ testFiles,
86
+ uncovered,
87
+ });
88
+
89
+ return {
90
+ baselineDocument,
91
+ failed: regressions.length > 0 || uncovered.length > 0,
92
+ jsonPayload: { files, oversized, testFiles, coverage, uncovered, regressions },
93
+ outputPath: checkConfig.outputPath,
94
+ report,
95
+ };
96
+ }
97
+
98
+ function readArg(args, name, fallback) {
99
+ const prefix = `${name}=`;
100
+ const value = args.find((arg) => arg.startsWith(prefix));
101
+ return value ? value.slice(prefix.length) : fallback;
102
+ }
103
+
104
+ function readBooleanArg(args, name, fallback) {
105
+ const rawValue = readArg(args, name, fallback);
106
+ if (typeof rawValue === "boolean") {
107
+ return rawValue;
108
+ }
109
+
110
+ return String(rawValue).toLowerCase() !== "false";
111
+ }
112
+
113
+ function applyLegacyArgs(config, args) {
114
+ return {
115
+ ...config,
116
+ includeSizeQueue: readBooleanArg(args, "--include-size-queue", config.includeSizeQueue),
117
+ failOnSizeRegressions: readBooleanArg(args, "--fail-on-size-regressions", config.failOnSizeRegressions),
118
+ warnLines: Number(readArg(args, "--warn-lines", config.warnLines)),
119
+ reviewLines: Number(readArg(args, "--review-lines", config.reviewLines)),
120
+ highRiskLines: Number(readArg(args, "--high-risk-lines", config.highRiskLines)),
121
+ growthLines: Number(readArg(args, "--growth-lines", config.growthLines)),
122
+ top: Number(readArg(args, "--top", config.top)),
123
+ outputPath: readArg(args, "--output", config.outputPath),
124
+ };
125
+ }
126
+
127
+ function buildSizeBands(oversized, checkConfig) {
128
+ const highRisk = oversized.filter((file) => file.lines >= checkConfig.highRiskLines);
129
+ const review = oversized.filter((file) => file.lines >= checkConfig.reviewLines && file.lines < checkConfig.highRiskLines);
130
+ const attention = oversized.filter((file) => file.lines >= checkConfig.warnLines && file.lines < checkConfig.reviewLines);
131
+
132
+ return { attention, review, highRisk };
133
+ }
134
+
135
+ function sizeBandFor(lines, checkConfig) {
136
+ if (lines >= checkConfig.highRiskLines) {
137
+ return "high-risk";
138
+ }
139
+
140
+ if (lines >= checkConfig.reviewLines) {
141
+ return "review";
142
+ }
143
+
144
+ if (lines >= checkConfig.warnLines) {
145
+ return "attention";
146
+ }
147
+
148
+ return "below threshold";
149
+ }
150
+
151
+ function renderMarkdown({ baseline, checkConfig, coverage, files, oversized, regressions, sizeBands, testFiles, uncovered }) {
152
+ const lines = [
153
+ `# ${checkConfig.title}`,
154
+ "",
155
+ `- Files scanned: ${files.length}`,
156
+ `- Test files scanned: ${testFiles.length}`,
157
+ `- Coverage terms checked: ${coverage.length}`,
158
+ `- Missing coverage terms: ${uncovered.length}`,
159
+ ];
160
+
161
+ if (checkConfig.includeSizeQueue) {
162
+ lines.push(
163
+ `- ${checkConfig.attentionQueueLabel} (>= ${checkConfig.warnLines} lines): ${oversized.length}`,
164
+ `- Attention band (${checkConfig.warnLines}-${checkConfig.reviewLines - 1} lines): ${sizeBands.attention.length}`,
165
+ `- Review band (${checkConfig.reviewLines}-${checkConfig.highRiskLines - 1} lines): ${sizeBands.review.length}`,
166
+ `- High-risk band (>= ${checkConfig.highRiskLines} lines): ${sizeBands.highRisk.length}`,
167
+ `- Size regressions: ${regressions.length}`,
168
+ );
169
+ }
170
+
171
+ lines.push("");
172
+
173
+ if (checkConfig.policyNote) {
174
+ lines.push("## Policy", "", checkConfig.policyNote, "");
175
+ }
176
+
177
+ if (checkConfig.includeSizeQueue && regressions.length > 0) {
178
+ lines.push("## Size Regressions", "");
179
+ for (const file of regressions) {
180
+ const baselineLines = baseline.files?.[file.path]?.lines ?? "new";
181
+ lines.push(`- ${file.path}: ${file.lines} lines (baseline: ${baselineLines})`);
182
+ }
183
+ lines.push("");
184
+ }
185
+
186
+ lines.push("## Coverage Terms", "", `| Term | ${checkConfig.coverageColumnLabel} |`, "| --- | --- |");
187
+ for (const item of coverage) {
188
+ lines.push(`| ${item.label} | ${item.covered ? "yes" : "no"} |`);
189
+ }
190
+
191
+ if (checkConfig.includeSizeQueue) {
192
+ lines.push("", `## ${checkConfig.largestFilesHeading}`, "", "| Lines | Band | File |", "| ---: | --- | --- |");
193
+ for (const file of files.slice(0, checkConfig.top)) {
194
+ lines.push(`| ${file.lines} | ${sizeBandFor(file.lines, checkConfig)} | \`${file.path}\` |`);
195
+ }
196
+ }
197
+
198
+ return `${lines.join("\n")}\n`;
199
+ }
@@ -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,33 @@
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 {
19
+ failed: false,
20
+ findings: [
21
+ /* createFinding(...) */
22
+ ],
23
+ report: "",
24
+ };
25
+ },
26
+ };
27
+ ```
28
+
29
+ Drop it under `your-checks/code/` (or `your-checks/hosted/`) and it auto-registers — no wiring. Copy
30
+ `code/example-check.mjs` to start. Import helpers with `import { createFinding, walkFiles } from "connections-arkitect"`.
31
+
32
+ **Forking / pinning:** set `update.autoUpdate: false` (or pin `update.pinnedVersion`) in `arkitect.config.json`
33
+ 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-arkitect";
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
+ // rename _ctx → ctx when you use it: { 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
+ };