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.
- package/README.md +104 -0
- package/arkitect.config.example.json +10 -0
- package/bin/arkitect.mjs +135 -0
- package/package.json +42 -0
- package/spine/justify-existence.example.json +8 -0
- package/src/checks/agnostic/rules-docs.mjs +92 -0
- package/src/checks/code/dependency-freshness.mjs +82 -0
- package/src/checks/code/no-secrets-committed.mjs +112 -0
- package/src/checks/code/oversized-files.mjs +74 -0
- package/src/checks/code/sibling-consensus.mjs +69 -0
- package/src/checks/code/tsconfig-conformance.mjs +98 -0
- package/src/checks/code/veteran-would-mock.mjs +699 -0
- package/src/checks/hosted/db-justify-existence.mjs +237 -0
- package/src/checks/hosted/db-required-roles.mjs +76 -0
- package/src/core/config.mjs +50 -0
- package/src/core/discovery.mjs +41 -0
- package/src/core/finding.mjs +40 -0
- package/src/core/fs-walk.mjs +24 -0
- package/src/core/hosted/judge.mjs +42 -0
- package/src/core/hosted/spine.mjs +63 -0
- package/src/core/hosted/vault-exec.mjs +61 -0
- package/src/core/index.mjs +15 -0
- package/src/core/init.mjs +64 -0
- package/src/core/oracle/family-conformance.mjs +77 -0
- package/src/core/project-detect.mjs +126 -0
- package/src/core/runner.mjs +369 -0
- package/src/core/sarif.mjs +63 -0
- package/src/engines/surface/surface-size-coverage-engine.mjs +199 -0
- package/src/update/self-update.mjs +90 -0
- package/your-checks/README.md +33 -0
- package/your-checks/code/example-check.mjs +24 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// initWorkspace — the OPT-IN install: put the Architect into a codebase, under `.arkitect/`.
|
|
2
|
+
// This runs ONLY when explicitly chosen (`architect init`, or the MCP `architect_install` tool on a
|
|
3
|
+
// direct request like "I want the Architect in this codebase"). Nothing in the Architect ever creates
|
|
4
|
+
// `.arkitect/` as a side effect of a normal run — installing is a choice, never a surprise.
|
|
5
|
+
//
|
|
6
|
+
// What it lays down (the core/yours split, physically):
|
|
7
|
+
// .arkitect/arkitect.config.json — your config (update channel, check tuning, hosted targets)
|
|
8
|
+
// .arkitect/your-checks/ — YOUR code checks (core never writes here; same-id shadows core)
|
|
9
|
+
// .arkitect/spines/ — YOUR DB policy (the hosted half's "yours" space)
|
|
10
|
+
// The CORE lives in the package itself (npm + the self-update channel) — pulling a newer core never
|
|
11
|
+
// touches any of the files below, so updating is always safe and always a choice (autoUpdate /
|
|
12
|
+
// pinnedVersion / fork-and-own).
|
|
13
|
+
//
|
|
14
|
+
// Idempotent + additive: every file is written ONLY if absent; re-running installs nothing over you.
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CONFIG = {
|
|
19
|
+
$note:
|
|
20
|
+
"The Architect's per-workspace config. YOUR files are this one + your-checks/ + spines/ — a core update never touches them. hosted.targets take NON-SECRET coordinates only (ARNs, database names); credentials stay in the Connections vault (hosted checks run credential-blind through the MCP).",
|
|
21
|
+
version: 1,
|
|
22
|
+
update: { channel: "stable", pinnedVersion: null, autoUpdate: true },
|
|
23
|
+
checks: {},
|
|
24
|
+
hosted: { failOnBounty: false, targets: [] },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Install the Architect's per-workspace layout under `<root>/.arkitect/`.
|
|
29
|
+
* @param {string} root - target repo root
|
|
30
|
+
* @param {string} pkgRoot - this package's root (templates are copied from the shipped examples)
|
|
31
|
+
* @returns {{ created: string[], skipped: string[] }} repo-relative paths written vs already-present
|
|
32
|
+
*/
|
|
33
|
+
export function initWorkspace(root, pkgRoot) {
|
|
34
|
+
const created = [];
|
|
35
|
+
const skipped = [];
|
|
36
|
+
const put = (rel, content) => {
|
|
37
|
+
const abs = join(root, rel);
|
|
38
|
+
if (existsSync(abs)) {
|
|
39
|
+
skipped.push(rel);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
43
|
+
writeFileSync(abs, content, "utf8");
|
|
44
|
+
created.push(rel);
|
|
45
|
+
};
|
|
46
|
+
const template = (rel, fallback) => {
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(join(pkgRoot, rel), "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Deliberately MINIMAL: the Architect needs no workspace state to RUN (core-only runs create
|
|
55
|
+
// nothing but tmp/arkitect/), so this scaffolds only the extension space — a config and the
|
|
56
|
+
// README that teaches the format. No example check is seeded (a seeded check would show up in
|
|
57
|
+
// `list` as noise that isn't yours); the README carries the copy-paste skeleton instead.
|
|
58
|
+
put(join(".arkitect", "arkitect.config.json"), JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
59
|
+
put(
|
|
60
|
+
join(".arkitect", "your-checks", "README.md"),
|
|
61
|
+
template(join("your-checks", "README.md"), "# your-checks/\n\nYour own Architect checks. The core never touches this folder.\n"),
|
|
62
|
+
);
|
|
63
|
+
return { created, skipped };
|
|
64
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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((p) => p.member.path === referenceId || p.member.id === referenceId);
|
|
44
|
+
if (!ref) {
|
|
45
|
+
// No reference found — cannot judge; return clean.
|
|
46
|
+
return { findings: [], consensusOrReference: null };
|
|
47
|
+
}
|
|
48
|
+
expectedKey = ref.key;
|
|
49
|
+
consensusOrReference = ref.fingerprint;
|
|
50
|
+
} else {
|
|
51
|
+
// Consensus: the most common fingerprint across the family.
|
|
52
|
+
const counts = new Map();
|
|
53
|
+
for (const p of projected) counts.set(p.key, (counts.get(p.key) || 0) + 1);
|
|
54
|
+
const [topKey] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
55
|
+
const topCount = counts.get(topKey);
|
|
56
|
+
// Only meaningful when a clear majority (> half) exists.
|
|
57
|
+
if (topCount <= projected.length / 2) {
|
|
58
|
+
return { findings: [], consensusOrReference: null };
|
|
59
|
+
}
|
|
60
|
+
expectedKey = topKey;
|
|
61
|
+
consensusOrReference = JSON.parse(topKey);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const findings = [];
|
|
65
|
+
for (const p of projected) {
|
|
66
|
+
// In reference mode, skip the reference member itself.
|
|
67
|
+
if (mode === "reference") {
|
|
68
|
+
const isRef = p.member.path === referenceId || p.member.id === referenceId;
|
|
69
|
+
if (isRef) continue;
|
|
70
|
+
}
|
|
71
|
+
if (p.key !== expectedKey) {
|
|
72
|
+
findings.push({ member: p.member, expected: JSON.parse(expectedKey), actual: p.fingerprint });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { findings, consensusOrReference };
|
|
77
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
".codegraph",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// Generated dir-name FAMILIES (dist-consent, dist-login, cdk.out.api, …) — a plain Set can't hold
|
|
54
|
+
// a prefix. `cdk.out.` covers the dotted synth-output variants a plane mints per stack; the bare
|
|
55
|
+
// `cdk.out` stays in IGNORE_DIRS above.
|
|
56
|
+
const IGNORE_DIR_PREFIXES = ["dist-", "cdk.out."];
|
|
57
|
+
|
|
58
|
+
// Generated trees whose leaf names are too generic to ignore by name alone — a real source
|
|
59
|
+
// `public/` exists in every web root; only the Capacitor webDir COPIES are generated.
|
|
60
|
+
// Matched as posix path-suffixes of the DIRECTORY being considered.
|
|
61
|
+
export const IGNORE_PATH_MARKERS = ["android/app/src/main/assets/public", "ios/App/App/public"];
|
|
62
|
+
|
|
63
|
+
/** One canonical answer to "is this directory generated/vendored output?" — name + optional full path. */
|
|
64
|
+
export function isIgnoredDir(name, dirPath = "") {
|
|
65
|
+
if (IGNORE_DIRS.has(name)) return true;
|
|
66
|
+
if (IGNORE_DIR_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
|
|
67
|
+
if (!dirPath) return false;
|
|
68
|
+
const posix = dirPath.replace(/\\/g, "/");
|
|
69
|
+
return IGNORE_PATH_MARKERS.some((marker) => posix.endsWith(`/${marker}`) || posix === marker);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectProject(root) {
|
|
73
|
+
const ecosystems = new Set();
|
|
74
|
+
for (const [f, eco] of Object.entries(MANIFEST_ECOSYSTEM)) if (existsSync(join(root, f))) ecosystems.add(eco);
|
|
75
|
+
|
|
76
|
+
const frameworks = new Set();
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
79
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
80
|
+
if (deps.vue || deps.nuxt) frameworks.add("vue");
|
|
81
|
+
if (deps.react || deps.next) frameworks.add("react");
|
|
82
|
+
if (deps.svelte) frameworks.add("svelte");
|
|
83
|
+
if (deps.express || deps.fastify) frameworks.add("node-server");
|
|
84
|
+
} catch {
|
|
85
|
+
/* no/unreadable package.json — fine */
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sample file extensions with a bounded walk (don't traverse the whole tree).
|
|
89
|
+
const languages = new Set();
|
|
90
|
+
let budget = 4000;
|
|
91
|
+
const walk = (dir) => {
|
|
92
|
+
if (budget <= 0) return;
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const e of entries) {
|
|
100
|
+
if (budget <= 0) return;
|
|
101
|
+
if (e.name.startsWith(".")) continue;
|
|
102
|
+
if (e.isDirectory()) {
|
|
103
|
+
if (!isIgnoredDir(e.name, join(dir, e.name))) walk(join(dir, e.name));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
budget--;
|
|
107
|
+
const lang = EXT_LANG[extname(e.name)];
|
|
108
|
+
if (lang) languages.add(lang);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(root);
|
|
112
|
+
|
|
113
|
+
return { root, languages: [...languages], ecosystems: [...ecosystems], frameworks: [...frameworks] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A check runs only if every dimension it requires is satisfied. Empty/absent `requires` ⇒ always runs.
|
|
117
|
+
export function checkAppliesToProject(requires, project) {
|
|
118
|
+
if (!requires) return true;
|
|
119
|
+
const ok = (key) => {
|
|
120
|
+
const need = requires[key];
|
|
121
|
+
if (!need || need.length === 0) return true;
|
|
122
|
+
const have = project[key] || [];
|
|
123
|
+
return need.some((v) => have.includes(v));
|
|
124
|
+
};
|
|
125
|
+
return ok("languages") && ok("ecosystems") && ok("frameworks");
|
|
126
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// The runner — per-check gating, crash≠drift, gating-vs-advisory, and the agent-facing FIX_QUEUE.md +
|
|
2
|
+
// manifest.json with `pass.nextAction`. Splits checks by domain: `code` checks always run; `hosted`
|
|
3
|
+
// checks run only when a vault handle is present (so plain `npx architect` runs the code half with no
|
|
4
|
+
// cloud creds). Also writes findings.sarif (SARIF 2.1.0) and DECISION_BRIEF.json.
|
|
5
|
+
//
|
|
6
|
+
// NATIVE TELEMETRY (core feature — every check gets it, forever, with zero effort from the check's
|
|
7
|
+
// author): the runner itself records, per check, (a) wall-clock DURATION and (b) BOUNCES — every
|
|
8
|
+
// outbound network round-trip the check made, counted and timed at the two seams the runner controls:
|
|
9
|
+
// the vault handle (hosted DB/cloud calls) and global fetch (HTTP). "It worked" is half a report; the
|
|
10
|
+
// other half is "it went out 87 times at 200ms each" — that's how a green check explains a 30-second
|
|
11
|
+
// login. Aggregates land in the manifest + FIX_QUEUE; the per-hop detail lands in telemetry.json.
|
|
12
|
+
//
|
|
13
|
+
// SAVED REPORTS: when the target repo has a `.arkitect/` (the user opted into workspace state),
|
|
14
|
+
// every run is archived to `.arkitect/reports/<stamp>/` — manifest + FIX_QUEUE + telemetry + each
|
|
15
|
+
// check's full report — so "what did it find last Tuesday" is a folder, not a memory. Pruned to
|
|
16
|
+
// `reports.keep` (default 20). No `.arkitect/` ⇒ no archive (a bare run stays stateless).
|
|
17
|
+
import { mkdirSync, writeFileSync, existsSync, readdirSync, rmSync, copyFileSync } from "node:fs";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
import { countSeverities } from "./finding.mjs";
|
|
20
|
+
import { checkAppliesToProject } from "./project-detect.mjs";
|
|
21
|
+
import { toSarif } from "./sarif.mjs";
|
|
22
|
+
|
|
23
|
+
const now = () => globalThis.performance?.now?.() ?? Date.now();
|
|
24
|
+
const HOP_DETAIL_CAP = 500; // per-hop entries kept for telemetry.json (aggregates are never capped)
|
|
25
|
+
|
|
26
|
+
// REPORT-ONLY checks (legacy style: `{ failed, report }`, no findings[]) still count correctly: derive
|
|
27
|
+
// error/warning totals from the report's own summary lines — every `Errors:`/`Warnings:` line SUMMED
|
|
28
|
+
// (multi-section reports emit one per section), `**bold**` and parenthetical-qualifier forms included,
|
|
29
|
+
// with a `## rule — 3 (error|warning)` heading fallback. Same semantics the legacy manifest used, so a
|
|
30
|
+
// check that never emits findings[] gates and reports identically under this engine.
|
|
31
|
+
const SUM_ERR_RE = /(?:\*\*)?errors(?:\s*\([^)]*\))?\s*:(?:\*\*)?\s*(\d+)/gi;
|
|
32
|
+
const SUM_WARN_RE = /(?:\*\*)?warnings(?:\s*\([^)]*\))?\s*:(?:\*\*)?\s*(\d+)/gi;
|
|
33
|
+
const HEADING_SEV_RE = /^#{2,}[^\n]*?—\s*(\d+)\s*\((error|warning)s?\)/gim;
|
|
34
|
+
function countsFromReport(text) {
|
|
35
|
+
const sum = (re) => {
|
|
36
|
+
re.lastIndex = 0;
|
|
37
|
+
let total = 0,
|
|
38
|
+
matched = false,
|
|
39
|
+
m;
|
|
40
|
+
while ((m = re.exec(text)) !== null) {
|
|
41
|
+
total += Number.parseInt(m[1], 10) || 0;
|
|
42
|
+
matched = true;
|
|
43
|
+
}
|
|
44
|
+
return { total, matched };
|
|
45
|
+
};
|
|
46
|
+
const e = sum(SUM_ERR_RE);
|
|
47
|
+
const w = sum(SUM_WARN_RE);
|
|
48
|
+
if (e.matched || w.matched) return { errors: e.total, warnings: w.total };
|
|
49
|
+
let errors = 0,
|
|
50
|
+
warnings = 0,
|
|
51
|
+
m;
|
|
52
|
+
HEADING_SEV_RE.lastIndex = 0;
|
|
53
|
+
while ((m = HEADING_SEV_RE.exec(text)) !== null) {
|
|
54
|
+
if (m[2].toLowerCase() === "error") errors += Number.parseInt(m[1], 10) || 0;
|
|
55
|
+
else warnings += Number.parseInt(m[1], 10) || 0;
|
|
56
|
+
}
|
|
57
|
+
return { errors, warnings };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Wrap the vault handle so every awsCall is counted + timed. Preserves the handle's shape.
|
|
61
|
+
// Sets tel.inVault during the call so the fetch seam does NOT double-count the HTTP request a vault
|
|
62
|
+
// call rides on — one real round-trip is one bounce, whichever seam it entered through.
|
|
63
|
+
function instrumentVault(vault, tel) {
|
|
64
|
+
if (!vault || typeof vault.awsCall !== "function") return vault;
|
|
65
|
+
return {
|
|
66
|
+
...vault,
|
|
67
|
+
async awsCall(...args) {
|
|
68
|
+
const t0 = now();
|
|
69
|
+
tel.inVault++;
|
|
70
|
+
try {
|
|
71
|
+
return await vault.awsCall(...args);
|
|
72
|
+
} finally {
|
|
73
|
+
tel.inVault--;
|
|
74
|
+
const ms = now() - t0;
|
|
75
|
+
tel.count++;
|
|
76
|
+
tel.totalMs += ms;
|
|
77
|
+
if (ms > tel.maxMs) tel.maxMs = ms;
|
|
78
|
+
if (tel.hops.length < HOP_DETAIL_CAP) tel.hops.push({ seam: "vault", ms: Math.round(ms * 10) / 10 });
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runAudits(audits, ctxBase, { outDir }) {
|
|
85
|
+
mkdirSync(outDir, { recursive: true });
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const audit of audits) {
|
|
88
|
+
if (!checkAppliesToProject(audit.requires, ctxBase.project)) {
|
|
89
|
+
results.push({ id: audit.id, audit, status: "skipped", reason: "requires-not-met" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (audit.domain === "hosted" && !ctxBase.vault) {
|
|
93
|
+
results.push({ id: audit.id, audit, status: "skipped", reason: "no-vault: run via the Connections MCP to enable hosted checks" });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const checkConfig = { ...(audit.defaultConfig || {}), ...((ctxBase.config?.checks || {})[audit.id] || {}) };
|
|
97
|
+
// A workspace may override a check's gating in config (checks.<id>.gating: false ⇒ advisory) —
|
|
98
|
+
// the visible, reviewable way to acknowledge known findings without silencing the check.
|
|
99
|
+
const effectiveGating = typeof checkConfig.gating === "boolean" ? checkConfig.gating : audit.gating;
|
|
100
|
+
|
|
101
|
+
// Telemetry seams for THIS check: instrumented vault + instrumented global fetch. The runner is
|
|
102
|
+
// sequential, so the fetch swap cannot bleed across checks; it is ALWAYS restored.
|
|
103
|
+
const tel = { count: 0, totalMs: 0, maxMs: 0, hops: [], inVault: 0 };
|
|
104
|
+
// checkArgs is ALWAYS an array (single-check CLI invocations may carry extra args; suite runs get
|
|
105
|
+
// []) — checks are entitled to call ctx.checkArgs.filter(...) without guarding.
|
|
106
|
+
const ctx = { ...ctxBase, checkArgs: ctxBase.options?.checkArgs || [], checkConfig, vault: instrumentVault(ctxBase.vault, tel) };
|
|
107
|
+
const realFetch = globalThis.fetch;
|
|
108
|
+
if (typeof realFetch === "function") {
|
|
109
|
+
globalThis.fetch = async (...args) => {
|
|
110
|
+
// A fetch made INSIDE a vault call is that same round-trip's transport — already counted.
|
|
111
|
+
if (tel.inVault > 0) return realFetch(...args);
|
|
112
|
+
const t0 = now();
|
|
113
|
+
try {
|
|
114
|
+
return await realFetch(...args);
|
|
115
|
+
} finally {
|
|
116
|
+
const ms = now() - t0;
|
|
117
|
+
tel.count++;
|
|
118
|
+
tel.totalMs += ms;
|
|
119
|
+
if (ms > tel.maxMs) tel.maxMs = ms;
|
|
120
|
+
if (tel.hops.length < HOP_DETAIL_CAP) tel.hops.push({ seam: "fetch", ms: Math.round(ms * 10) / 10 });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const started = now();
|
|
126
|
+
let out;
|
|
127
|
+
try {
|
|
128
|
+
out = await audit.run(ctx);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// A check that THROWS always fails the run, separately from findings — never a silent pass.
|
|
131
|
+
results.push({
|
|
132
|
+
id: audit.id,
|
|
133
|
+
audit,
|
|
134
|
+
status: "crashed",
|
|
135
|
+
gating: effectiveGating,
|
|
136
|
+
error: String(e?.stack || e?.message || e)
|
|
137
|
+
.split("\n")
|
|
138
|
+
.slice(0, 3)
|
|
139
|
+
.join("\n"),
|
|
140
|
+
findings: [],
|
|
141
|
+
failed: true,
|
|
142
|
+
durationMs: Math.round(now() - started),
|
|
143
|
+
bounces: { count: tel.count, totalMs: Math.round(tel.totalMs), maxMs: Math.round(tel.maxMs) },
|
|
144
|
+
hops: tel.hops,
|
|
145
|
+
});
|
|
146
|
+
if (typeof realFetch === "function") globalThis.fetch = realFetch;
|
|
147
|
+
continue;
|
|
148
|
+
} finally {
|
|
149
|
+
if (typeof realFetch === "function") globalThis.fetch = realFetch;
|
|
150
|
+
}
|
|
151
|
+
const durationMs = Math.round(now() - started);
|
|
152
|
+
const findings = out?.findings || [];
|
|
153
|
+
// Normalize severity dialects ONCE, here — every consumer downstream (counts, gating, SARIF,
|
|
154
|
+
// DECISION_BRIEF ordering) then agrees. "warn"/"review"→warning, "err"/"fatal"→error, "note"→info.
|
|
155
|
+
// (Only adjudicated dialects are mapped — domain scales like axe's minor/serious pass through.)
|
|
156
|
+
for (const f of findings) {
|
|
157
|
+
if (f && typeof f.severity === "string") {
|
|
158
|
+
const s = f.severity.toLowerCase();
|
|
159
|
+
f.severity =
|
|
160
|
+
s === "warn" || s === "review"
|
|
161
|
+
? "warning"
|
|
162
|
+
: s === "err" || s === "fatal"
|
|
163
|
+
? "error"
|
|
164
|
+
: s === "note" || s === "information"
|
|
165
|
+
? "info"
|
|
166
|
+
: s;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const counts = findings.length ? countSeverities(findings) : out?.report ? countsFromReport(out.report) : countSeverities(findings);
|
|
170
|
+
const failed = out?.failed ?? counts.errors > 0;
|
|
171
|
+
// Write the report whenever the check surfaced ANYTHING — including the thermometer shape
|
|
172
|
+
// (error-severity findings from a check that chose not to fail): an advisory finding with no
|
|
173
|
+
// report on disk is invisible to the AI-first reader, which is the disarmed-check failure mode.
|
|
174
|
+
if (out?.report && (failed || counts.errors > 0 || counts.warnings > 0)) {
|
|
175
|
+
const path = out.outputPath || join(outDir, `${audit.id}.md`);
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
178
|
+
writeFileSync(path, out.report);
|
|
179
|
+
} catch {
|
|
180
|
+
/* best-effort report write */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
results.push({
|
|
184
|
+
id: audit.id,
|
|
185
|
+
audit,
|
|
186
|
+
status: failed ? "failed" : counts.warnings ? "warned" : "clean",
|
|
187
|
+
gating: effectiveGating,
|
|
188
|
+
findings,
|
|
189
|
+
counts,
|
|
190
|
+
failed,
|
|
191
|
+
report: out?.report || "",
|
|
192
|
+
durationMs,
|
|
193
|
+
bounces: { count: tel.count, totalMs: Math.round(tel.totalMs), maxMs: Math.round(tel.maxMs) },
|
|
194
|
+
hops: tel.hops,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return summarize(results, outDir, ctxBase);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rank(r) {
|
|
201
|
+
if (r.status === "crashed") return 0;
|
|
202
|
+
if (r.status === "failed") return 1;
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SEV_ORDER = { error: 0, warning: 1, info: 2 };
|
|
207
|
+
const fmtMs = (ms) => (ms >= 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`);
|
|
208
|
+
|
|
209
|
+
function summarize(results, outDir, ctxBase = {}) {
|
|
210
|
+
let totalErrors = 0,
|
|
211
|
+
gatingErrors = 0,
|
|
212
|
+
advisoryFindings = 0,
|
|
213
|
+
warnings = 0,
|
|
214
|
+
crashed = 0;
|
|
215
|
+
for (const r of results) {
|
|
216
|
+
if (r.status === "crashed") {
|
|
217
|
+
crashed++;
|
|
218
|
+
gatingErrors++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const c = r.counts || { errors: 0, warnings: 0 };
|
|
222
|
+
totalErrors += c.errors;
|
|
223
|
+
warnings += c.warnings;
|
|
224
|
+
// `gating !== false` ⇒ a check MAY gate the run; it actually gates only when it also FAILED.
|
|
225
|
+
// Every error-severity finding that does NOT block — from a gating:false check OR from a check
|
|
226
|
+
// that emitted errors but chose not to fail (the thermometer shape: crap-score/complexity emit
|
|
227
|
+
// per-site error findings yet only warn) — is counted SEPARATELY as an advisory. In the roll-up,
|
|
228
|
+
// "error" must only ever mean "fix me" (AI-first legibility, owner directive 2026-07-04): an
|
|
229
|
+
// agent reading the summary should never reverse-engineer which errors block and which are
|
|
230
|
+
// thermometer readings a living codebase never drives to zero.
|
|
231
|
+
if ((r.gating ?? r.audit.gating) !== false && r.failed) gatingErrors += c.errors || 1;
|
|
232
|
+
else advisoryFindings += c.errors;
|
|
233
|
+
}
|
|
234
|
+
const clean = gatingErrors === 0;
|
|
235
|
+
const nextAction = gatingErrors > 0 ? "fix-errors" : warnings > 0 ? "review-warnings" : "done";
|
|
236
|
+
|
|
237
|
+
// Run-level telemetry rollup.
|
|
238
|
+
const ran = results.filter((r) => typeof r.durationMs === "number");
|
|
239
|
+
const totalDurationMs = ran.reduce((s, r) => s + r.durationMs, 0);
|
|
240
|
+
const totalBounces = ran.reduce((s, r) => s + (r.bounces?.count || 0), 0);
|
|
241
|
+
const slowest = [...ran]
|
|
242
|
+
.sort((a, b) => b.durationMs - a.durationMs)
|
|
243
|
+
.slice(0, 3)
|
|
244
|
+
.map((r) => ({ id: r.id, durationMs: r.durationMs, bounces: r.bounces?.count || 0 }));
|
|
245
|
+
|
|
246
|
+
const telLine = (r) =>
|
|
247
|
+
typeof r.durationMs === "number"
|
|
248
|
+
? ` [${fmtMs(r.durationMs)}${r.bounces?.count ? ` · ${r.bounces.count} bounce${r.bounces.count === 1 ? "" : "s"}` : ""}]`
|
|
249
|
+
: "";
|
|
250
|
+
const offenders = results
|
|
251
|
+
.filter((r) => r.status === "failed" || r.status === "crashed" || r.status === "warned")
|
|
252
|
+
.sort((a, b) => rank(a) - rank(b));
|
|
253
|
+
const queue = offenders.map((r) => {
|
|
254
|
+
const configAdvisory = (r.gating ?? r.audit.gating) === false;
|
|
255
|
+
// Non-blocking errors — config-advisory checks AND warned-not-failed thermometer checks —
|
|
256
|
+
// count "a", never "e", so a scan of the queue for `e/` finds only genuinely blocking work
|
|
257
|
+
// (the same AI-first legibility rule as the summary counts).
|
|
258
|
+
const nonBlocking = configAdvisory || (r.status !== "failed" && r.status !== "crashed");
|
|
259
|
+
const tag = r.status === "crashed" ? "CRASH" : configAdvisory ? "ADVISORY" : r.status === "warned" ? "WARN" : "ERROR";
|
|
260
|
+
const detail =
|
|
261
|
+
r.status === "crashed"
|
|
262
|
+
? ` ${r.error?.split("\n")[0] || ""}`
|
|
263
|
+
: ` (${r.counts?.errors || 0}${nonBlocking ? "a" : "e"}/${r.counts?.warnings || 0}w)`;
|
|
264
|
+
return `- [${tag}] ${r.id} — ${r.audit.title}${detail}${telLine(r)}`;
|
|
265
|
+
});
|
|
266
|
+
writeFileSync(
|
|
267
|
+
join(outDir, "FIX_QUEUE.md"),
|
|
268
|
+
`# Architect — Fix Queue\n\nnextAction: ${nextAction}\ntotal: ${fmtMs(totalDurationMs)} · ${totalBounces} bounce${totalBounces === 1 ? "" : "s"} across ${ran.length} check(s)\n\n${queue.join("\n") || "✅ all clean"}\n`,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const manifest = {
|
|
272
|
+
totalErrors,
|
|
273
|
+
gatingErrors,
|
|
274
|
+
// Error-severity findings from ADVISORY (gating:false) checks — thermometer readings
|
|
275
|
+
// (complexity/duplication/churn) to harvest worst-first, never a block-the-run signal.
|
|
276
|
+
advisoryFindings,
|
|
277
|
+
warnings,
|
|
278
|
+
crashed,
|
|
279
|
+
telemetry: { totalDurationMs, totalBounces, slowest },
|
|
280
|
+
checks: results.map((r) => ({
|
|
281
|
+
id: r.id,
|
|
282
|
+
group: r.audit.__group ?? null,
|
|
283
|
+
status: r.status,
|
|
284
|
+
// Per-row: `gating` = the check's configuration; `blocking` = whether THIS run's errors
|
|
285
|
+
// actually gate (config-gating AND failed) — so an agent never cross-references which
|
|
286
|
+
// errors block. A gating:true/blocking:false row is the thermometer shape.
|
|
287
|
+
gating: (r.gating ?? r.audit.gating) !== false,
|
|
288
|
+
blocking: (r.gating ?? r.audit.gating) !== false && (r.status === "failed" || r.status === "crashed"),
|
|
289
|
+
errors: r.counts?.errors || 0,
|
|
290
|
+
warnings: r.counts?.warnings || 0,
|
|
291
|
+
...(typeof r.durationMs === "number" ? { durationMs: r.durationMs, bounces: r.bounces } : {}),
|
|
292
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
293
|
+
})),
|
|
294
|
+
pass: { clean, nextAction },
|
|
295
|
+
};
|
|
296
|
+
writeFileSync(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
297
|
+
|
|
298
|
+
// Per-hop telemetry detail — the "pull the actual trace" file for any check whose aggregate looks off.
|
|
299
|
+
try {
|
|
300
|
+
const telemetry = ran.map((r) => ({ id: r.id, durationMs: r.durationMs, bounces: r.bounces, hops: r.hops || [] }));
|
|
301
|
+
writeFileSync(join(outDir, "telemetry.json"), JSON.stringify({ totalDurationMs, totalBounces, checks: telemetry }, null, 2));
|
|
302
|
+
} catch {
|
|
303
|
+
/* best-effort */
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// SARIF 2.1.0 output.
|
|
307
|
+
try {
|
|
308
|
+
writeFileSync(join(outDir, "findings.sarif"), JSON.stringify(toSarif(results), null, 2));
|
|
309
|
+
} catch {
|
|
310
|
+
/* best-effort */
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// DECISION_BRIEF — flat, sorted array of all findings enriched with check metadata.
|
|
314
|
+
try {
|
|
315
|
+
const brief = [];
|
|
316
|
+
for (const r of results) {
|
|
317
|
+
for (const f of r.findings || []) {
|
|
318
|
+
brief.push({ ...f, checkId: r.id, checkTitle: r.audit?.title ?? r.id });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
brief.sort((a, b) => {
|
|
322
|
+
const sa = SEV_ORDER[a.severity] ?? 99;
|
|
323
|
+
const sb = SEV_ORDER[b.severity] ?? 99;
|
|
324
|
+
if (sa !== sb) return sa - sb;
|
|
325
|
+
return (a.checkId ?? "").localeCompare(b.checkId ?? "");
|
|
326
|
+
});
|
|
327
|
+
writeFileSync(join(outDir, "DECISION_BRIEF.json"), JSON.stringify(brief, null, 2));
|
|
328
|
+
} catch {
|
|
329
|
+
/* best-effort */
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// SAVED REPORTS — archive this run under .arkitect/reports/<stamp>/ when (and only when) the
|
|
333
|
+
// target repo has a .arkitect/ (opted-in state). Every check's FULL report is kept here, even the
|
|
334
|
+
// ones the live outDir omits (it only writes failing/warning reports), so depth is never lost.
|
|
335
|
+
try {
|
|
336
|
+
const root = ctxBase.root;
|
|
337
|
+
if (root && existsSync(join(root, ".arkitect"))) {
|
|
338
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
339
|
+
const dir = join(root, ".arkitect", "reports", stamp);
|
|
340
|
+
mkdirSync(dir, { recursive: true });
|
|
341
|
+
for (const f of ["manifest.json", "FIX_QUEUE.md", "telemetry.json", "DECISION_BRIEF.json"]) {
|
|
342
|
+
try {
|
|
343
|
+
copyFileSync(join(outDir, f), join(dir, f));
|
|
344
|
+
} catch {
|
|
345
|
+
/* file may not exist on this run */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const r of results) {
|
|
349
|
+
if (r.report) {
|
|
350
|
+
try {
|
|
351
|
+
writeFileSync(join(dir, `${r.id}.md`), r.report);
|
|
352
|
+
} catch {
|
|
353
|
+
/* best-effort */
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Prune to reports.keep (default 20), oldest first — an archive that grows forever is a leak.
|
|
358
|
+
const keep = Number(ctxBase.config?.reports?.keep ?? 20);
|
|
359
|
+
const runs = readdirSync(join(root, ".arkitect", "reports")).sort();
|
|
360
|
+
for (const old of runs.slice(0, Math.max(0, runs.length - keep))) {
|
|
361
|
+
rmSync(join(root, ".arkitect", "reports", old), { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
/* archiving must never break a run */
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { results, manifest, outDir };
|
|
369
|
+
}
|