connections-arkitect 0.3.4 → 0.3.5

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/bin/arkitect.mjs CHANGED
@@ -90,7 +90,38 @@ if (config.update?.autoUpdate === true && !config.update?.pinnedVersion) {
90
90
 
91
91
  const { audits, loadErrors, shadowed } = await discoverAudits([coreRoot, ...userRoots]);
92
92
  for (const le of loadErrors) console.error(`[architect] FAILED TO LOAD check ${le.file}: ${le.error}`); // load failures are NEVER quiet
93
- if (!quiet) for (const s of shadowed) console.error(`[architect] your-checks override: '${s.id}' shadows the core check`);
93
+ // Same-root duplicate ids are corpus corruption (two files fight over one id, one silently
94
+ // wins) — NEVER quiet, and gated below exactly like a load error. Cross-root shadowing
95
+ // (your-checks patching a core check) is the designed seam — informational only.
96
+ const sameRootDupes = shadowed.filter((s) => s.prevRoot === s.root);
97
+ for (const s of sameRootDupes) console.error(`[architect] DUPLICATE CHECK ID '${s.id}': ${s.by} silently shadows ${s.prevFile}`);
98
+ if (!quiet)
99
+ for (const s of shadowed.filter((x) => x.prevRoot !== x.root))
100
+ console.error(`[architect] your-checks override: '${s.id}' shadows the core check`);
101
+
102
+ // Corpus-integrity failures run as synthetic CRASHED checks: they ride the normal
103
+ // runner machinery (manifest row, FIX_QUEUE [CRASH] line, gatingErrors++, non-zero exit
104
+ // under --fail-on-drift) instead of being a stderr note a green exit code contradicts.
105
+ // They are appended to EVERY selection — a corrupted corpus fails single-check runs too;
106
+ // that is the "crash ≠ silent pass" doctrine, not collateral damage.
107
+ const corpusIntegrityAudits = [
108
+ ...loadErrors.map((le) => ({
109
+ id: `corpus-load-error:${le.file.replace(/[\\/]/g, "_").slice(-60)}`,
110
+ title: `Check file failed to load: ${le.file}`,
111
+ gating: true,
112
+ async run() {
113
+ throw new Error(`FAILED TO LOAD ${le.file}: ${le.error}`);
114
+ },
115
+ })),
116
+ ...sameRootDupes.map((s) => ({
117
+ id: `corpus-duplicate-id:${s.id}`,
118
+ title: `Duplicate check id '${s.id}' inside one corpus`,
119
+ gating: true,
120
+ async run() {
121
+ throw new Error(`DUPLICATE CHECK ID '${s.id}': ${s.by} silently shadows ${s.prevFile} — rename one.`);
122
+ },
123
+ })),
124
+ ];
94
125
 
95
126
  if (wantList) {
96
127
  for (const a of audits.sort((x, y) => x.id.localeCompare(y.id))) {
@@ -113,23 +144,31 @@ let selected = onlyIds ? audits.filter((a) => onlyIds.has(a.id)) : audits;
113
144
  if (group) selected = selected.filter((a) => a.__group === group);
114
145
  if (excludeGroups.length) selected = selected.filter((a) => !excludeGroups.includes(a.__group));
115
146
  if (onlyIds && selected.length < onlyIds.size) {
147
+ // An explicitly named check that doesn't exist is a HARD failure, not a note: a
148
+ // renamed check would otherwise silently drop out of its gate lane forever while
149
+ // the lane kept exiting 0 (verified live 2026-07-05).
116
150
  const found = new Set(selected.map((a) => a.id));
117
151
  for (const id of onlyIds) if (!found.has(id)) console.error(`[architect] no check with id '${id}'. Try: architect list`);
152
+ process.exit(2);
118
153
  }
119
154
  if ((only || group) && selected.length === 0) {
120
155
  console.error(`[architect] no check matching ${only ? `id(s) '${only}'` : `group '${group}'`}. Try: architect list`);
121
156
  process.exit(2);
122
157
  }
158
+ selected = [...selected, ...corpusIntegrityAudits]; // corpus corruption fails EVERY run, loudly
123
159
 
124
160
  const project = detectProject(root);
125
- const ctxBase = { root, config, project, options: { failOnDrift, failOnBounty, checkArgs }, vault: null };
161
+ // selectedCount lets umbrella checks (whose sections ALSO run standalone) self-skip in
162
+ // suite runs instead of literally double-executing their members (design-token-compliance
163
+ // alone was ~22s of pure duplication per --all).
164
+ const ctxBase = { root, config, project, options: { failOnDrift, failOnBounty, checkArgs, selectedCount: selected.length }, vault: null };
126
165
  const outDir = resolve(root, opt("--out", join("tmp", "arkitect"))); // --out: shard-safe output dir (harness/CI)
127
166
  const { manifest } = await runAudits(selected, ctxBase, { outDir });
128
167
 
129
168
  const t = manifest.telemetry || {};
130
169
  const fmtMs = (ms) => (ms >= 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms || 0)}ms`);
131
- const line = `Architect · ${manifest.checks.length} checks · ${manifest.gatingErrors} gating error(s) · ${manifest.advisoryFindings ?? 0} advisory finding(s) · ${manifest.warnings} warning(s) · ${manifest.crashed} crash(es) · ${fmtMs(t.totalDurationMs)} · ${t.totalBounces ?? 0} bounce(s) · nextAction=${manifest.pass.nextAction}`;
170
+ const line = `Architect · ${manifest.checks.length} checks · ${manifest.gatingErrors} gating error(s) · ${manifest.advisoryFindings ?? 0} advisory finding(s) · ${manifest.warnings} warning(s) · ${manifest.crashed} crash(es)${manifest.noData ? ` · ${manifest.noData} no-data` : ""} · ${fmtMs(t.totalDurationMs)} · ${t.totalBounces ?? 0} bounce(s) · nextAction=${manifest.pass.nextAction}`;
132
171
  console.log(manifest.pass.clean ? `✅ ${line}` : `⚠️ ${line}`);
133
- console.log("tmp/arkitect/FIX_QUEUE.md · tmp/arkitect/manifest.json");
172
+ console.log(`${join(outDir, "FIX_QUEUE.md")} · ${join(outDir, "manifest.json")}`);
134
173
 
135
174
  process.exit(failOnDrift && manifest.gatingErrors > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "connections-arkitect",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "The Connections Arkitect — a portable, self-updating checks-and-balances framework. Ships a generic core of checks; you add your own in your-checks/ (the core never touches them); it keeps your codebase (and, via the Connections MCP, your hosted cloud) from drifting. Zero runtime dependencies.",
6
6
  "bin": {
@@ -3,15 +3,32 @@
3
3
  // your build is not reproducible; missing lockfiles mean you can't prove it either. Advisory by default
4
4
  // so it never blocks a fork whose own dep conventions differ.
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { relative, basename, dirname, join } from "node:path";
6
+ import { relative, basename, dirname, join, resolve } from "node:path";
7
7
  import { createFinding } from "../../core/finding.mjs";
8
8
  import { walkFiles } from "../../core/fs-walk.mjs";
9
9
 
10
10
  /** Unpinned range patterns — `*`, `latest`, `>=x`, `>x`. Does NOT flag `^` / `~` (those are pinned ranges). */
11
11
  const UNPINNED_RE = /^(\*|latest|>=?)/;
12
12
 
13
- /** Lockfile names we recognize. */
14
- const LOCKFILES = ["package-lock.json", "bun.lockb", "yarn.lock", "pnpm-lock.yaml"];
13
+ /** Lockfile names we recognize (bun.lock is Bun ≥1.2's text lockfile; bun.lockb the older binary). */
14
+ const LOCKFILES = ["package-lock.json", "bun.lock", "bun.lockb", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json"];
15
+
16
+ /**
17
+ * A workspace member's installs are locked by the ROOT lockfile — a lockfile anywhere on the
18
+ * ancestor chain up to the scanned root covers this package.json (same-dir-only was a false
19
+ * positive on every bun/npm/pnpm workspace member, found 2026-07-05 auditing external repos).
20
+ */
21
+ function lockfileCovers(dir, root) {
22
+ const rootAbs = resolve(root);
23
+ let d = resolve(dir);
24
+ for (;;) {
25
+ if (LOCKFILES.some((lf) => existsSync(join(d, lf)))) return true;
26
+ if (d === rootAbs) return false;
27
+ const parent = dirname(d);
28
+ if (parent === d) return false; // filesystem root — package.json outside the scanned root's chain
29
+ d = parent;
30
+ }
31
+ }
15
32
 
16
33
  export const audit = {
17
34
  id: "dependency-freshness",
@@ -57,20 +74,17 @@ export const audit = {
57
74
 
58
75
  // 2. Check for missing lockfile when any deps are declared.
59
76
  const hasDeps = Object.keys(pkg.dependencies || {}).length > 0 || Object.keys(pkg.devDependencies || {}).length > 0;
60
- if (hasDeps) {
61
- const hasLockfile = LOCKFILES.some((lf) => existsSync(join(dir, lf)));
62
- if (!hasLockfile) {
63
- findings.push(
64
- createFinding({
65
- id: "missing-lockfile",
66
- title: "No lockfile found alongside package.json",
67
- severity: "warning",
68
- file: relFile,
69
- message: `${relFile} declares dependencies but has no lockfile (${LOCKFILES.join(" / ")}) — installs are not reproducible`,
70
- fix: "Run your package manager (npm install / bun install / yarn / pnpm install) to generate a lockfile and commit it.",
71
- }),
72
- );
73
- }
77
+ if (hasDeps && !lockfileCovers(dir, root)) {
78
+ findings.push(
79
+ createFinding({
80
+ id: "missing-lockfile",
81
+ title: "No lockfile found for package.json",
82
+ severity: "warning",
83
+ file: relFile,
84
+ message: `${relFile} declares dependencies but no lockfile (${LOCKFILES.join(" / ")}) exists beside it or in any ancestor up to the repo root — installs are not reproducible`,
85
+ fix: "Run your package manager (npm install / bun install / yarn / pnpm install) to generate a lockfile and commit it.",
86
+ }),
87
+ );
74
88
  }
75
89
  }
76
90
 
@@ -0,0 +1,190 @@
1
+ // lockfile-manifest-parity — a bun.lock must be IN SYNC with its package.json.
2
+ //
3
+ // The incident this generalizes (Connections, 2026-07-05): a plane web app's package.json
4
+ // pinned vite 8.1.0, but the plane's own bun.lock was never reinstalled after the bump — its
5
+ // node_modules silently resolved a pre-Rolldown vite 6.4.3, the fleet's `rolldownOptions`
6
+ // vendor-splitting was silently ignored, and the plane shipped a 267.8 KB gz boot entry
7
+ // (2× the fleet budget) for twelve days with every offline gate green. Version drift between
8
+ // what the manifest DECLARES and what the lockfile RECORDED is invisible to typecheck, lint,
9
+ // and bundlers — the build "works", it just isn't the build you pinned.
10
+ //
11
+ // The invariant is purely offline: bun.lock's `workspaces` section mirrors each workspace
12
+ // manifest's dependency sections AS OF THE LAST INSTALL. If a manifest declares a (name,
13
+ // specifier) the mirror doesn't record identically — or the mirror records one the manifest
14
+ // no longer declares — the lockfile is stale and `bun install` in that directory is owed.
15
+ // (This is the same contract `bun install --frozen-lockfile` enforces, checked without
16
+ // running installs or touching the network.)
17
+ //
18
+ // Scope: every directory in the project that holds BOTH a bun.lock and a package.json
19
+ // (standalone-lockfile apps like Connections' `services/<plane>/web` trees, plus the repo
20
+ // root). Multi-workspace lockfiles are compared per workspace entry.
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import { createFinding } from "../../core/finding.mjs";
25
+ import { isIgnoredDir } from "../../core/project-detect.mjs";
26
+
27
+ const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
28
+
29
+ /** bun.lock is JSONC-ish (trailing commas). Tolerant parse (incl. UTF-8 BOM); null on failure. */
30
+ export function parseBunLock(text) {
31
+ try {
32
+ return JSON.parse(text.replace(/^\uFEFF/, "").replace(/,\s*([}\]])/g, "$1"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Pure comparison — exported for tests. Returns mismatch records:
40
+ * { section, name, manifest: spec|null, lock: spec|null } where null = absent on that side.
41
+ */
42
+ export function compareManifestToLockMirror(manifest, lockWorkspaceEntry) {
43
+ const mismatches = [];
44
+ for (const section of SECTIONS) {
45
+ const m = manifest?.[section] ?? {};
46
+ const l = lockWorkspaceEntry?.[section] ?? {};
47
+ for (const [name, spec] of Object.entries(m)) {
48
+ if (!(name in l)) mismatches.push({ section, name, manifest: String(spec), lock: null });
49
+ else if (String(l[name]) !== String(spec)) mismatches.push({ section, name, manifest: String(spec), lock: String(l[name]) });
50
+ }
51
+ for (const [name, spec] of Object.entries(l)) {
52
+ if (!(name in m)) mismatches.push({ section, name, manifest: null, lock: String(spec) });
53
+ }
54
+ }
55
+ return mismatches;
56
+ }
57
+
58
+ function walkForLockfiles(rootAbs) {
59
+ const out = [];
60
+ const stack = [rootAbs];
61
+ while (stack.length > 0) {
62
+ const current = stack.pop();
63
+ let entries;
64
+ try {
65
+ entries = fs.readdirSync(current, { withFileTypes: true });
66
+ } catch {
67
+ continue;
68
+ }
69
+ for (const entry of entries) {
70
+ if (entry.name.startsWith(".")) continue;
71
+ const child = path.join(current, entry.name);
72
+ if (entry.isDirectory()) {
73
+ // Canonical generated/ignored gate (Architect-internal idiom) — never
74
+ // treat node_modules/build output as source.
75
+ if (!isIgnoredDir(entry.name, child)) stack.push(child);
76
+ continue;
77
+ }
78
+ if (entry.name === "bun.lock") out.push(current);
79
+ }
80
+ }
81
+ return out.sort((a, b) => a.localeCompare(b));
82
+ }
83
+
84
+ // "missing" and "unparseable" are DIFFERENT findings (a BOM'd-but-present manifest
85
+ // mis-reported as absent cost a debugging round on 2026-07-05) — so this returns a
86
+ // tagged result, and parsing tolerates a UTF-8 BOM (Windows tooling writes them).
87
+ function readJsonFile(file) {
88
+ let text;
89
+ try {
90
+ text = fs.readFileSync(file, "utf8");
91
+ } catch {
92
+ return { state: "missing", value: null };
93
+ }
94
+ try {
95
+ return { state: "ok", value: JSON.parse(text.replace(/^\uFEFF/, "")) };
96
+ } catch {
97
+ return { state: "unparseable", value: null };
98
+ }
99
+ }
100
+
101
+ export const audit = {
102
+ id: "lockfile-manifest-parity",
103
+ title: "Lockfile ↔ manifest parity (a stale bun.lock ships versions you didn't pin)",
104
+ category: "code",
105
+ defaultConfig: { includeInAll: true },
106
+ async run({ root, checkConfig = {} }) {
107
+ const rootAbs = path.resolve(root ?? process.cwd());
108
+ const findings = [];
109
+ const rows = [];
110
+
111
+ for (const dirAbs of walkForLockfiles(rootAbs)) {
112
+ const relDir = path.relative(rootAbs, dirAbs).replace(/\\/g, "/") || ".";
113
+ const lockRel = relDir === "." ? "bun.lock" : `${relDir}/bun.lock`;
114
+ const lock = parseBunLock(fs.readFileSync(path.join(dirAbs, "bun.lock"), "utf8"));
115
+ if (!lock || typeof lock.workspaces !== "object" || lock.workspaces === null) {
116
+ findings.push(
117
+ createFinding({
118
+ ruleId: "lockfile-unparseable",
119
+ severity: "error",
120
+ filePath: lockRel,
121
+ line: 1,
122
+ message: `bun.lock could not be parsed (or has no workspaces section) — regenerate it with \`bun install\` in ${relDir}.`,
123
+ }),
124
+ );
125
+ continue;
126
+ }
127
+
128
+ for (const [wsKey, wsEntry] of Object.entries(lock.workspaces)) {
129
+ const wsDirAbs = wsKey ? path.join(dirAbs, wsKey) : dirAbs;
130
+ const wsRel = path.relative(rootAbs, wsDirAbs).replace(/\\/g, "/") || ".";
131
+ const manifestRead = readJsonFile(path.join(wsDirAbs, "package.json"));
132
+ if (manifestRead.state !== "ok") {
133
+ findings.push(
134
+ createFinding({
135
+ ruleId: "lockfile-orphan-workspace",
136
+ severity: "error",
137
+ filePath: lockRel,
138
+ line: 1,
139
+ message:
140
+ manifestRead.state === "missing"
141
+ ? `bun.lock records workspace "${wsKey || "."}" but ${wsRel}/package.json does not exist — the lockfile is stale; run \`bun install\` in ${relDir} (or delete the orphan lock).`
142
+ : `${wsRel}/package.json exists but is not valid JSON — fix the manifest (check for a UTF-16/mangled encoding), then \`bun install\` in ${relDir}.`,
143
+ }),
144
+ );
145
+ continue;
146
+ }
147
+ const manifest = manifestRead.value;
148
+ const mismatches = compareManifestToLockMirror(manifest, wsEntry);
149
+ rows.push({ dir: wsRel, mismatches: mismatches.length });
150
+ for (const mm of mismatches) {
151
+ const detail =
152
+ mm.lock === null
153
+ ? `declares ${mm.name}@${mm.manifest} (${mm.section}) but the lockfile never recorded it`
154
+ : mm.manifest === null
155
+ ? `no longer declares ${mm.name} (${mm.section}) but the lockfile still records ${mm.name}@${mm.lock}`
156
+ : `declares ${mm.name}@${mm.manifest} (${mm.section}) but the lockfile recorded ${mm.name}@${mm.lock} at its last install`;
157
+ findings.push(
158
+ createFinding({
159
+ ruleId: "lockfile-manifest-drift",
160
+ severity: "error",
161
+ filePath: `${wsRel === "." ? "" : `${wsRel}/`}package.json`,
162
+ line: 1,
163
+ message:
164
+ `${wsRel}/package.json ${detail} — node_modules is resolving versions you did not pin ` +
165
+ `(the silent-vite-6 class). Run \`bun install\` in ${relDir} and commit the lockfile.`,
166
+ metadata: mm,
167
+ }),
168
+ );
169
+ }
170
+ }
171
+ }
172
+
173
+ const report = [
174
+ "# Lockfile ↔ manifest parity",
175
+ "",
176
+ "Every dir holding both bun.lock + package.json, compared per workspace entry (offline `--frozen-lockfile` contract).",
177
+ "",
178
+ ...rows.map((r) => `- \`${r.dir}\` — ${r.mismatches === 0 ? "in sync" : `**${r.mismatches} drift(s)**`}`),
179
+ "",
180
+ ].join("\n");
181
+
182
+ return {
183
+ failed: findings.length > 0,
184
+ findings,
185
+ jsonPayload: { workspaces: rows, findingCount: findings.length },
186
+ outputPath: checkConfig.outputPath ?? "tmp/audits/LOCKFILE_MANIFEST_PARITY.md",
187
+ report,
188
+ };
189
+ },
190
+ };
@@ -26,7 +26,10 @@ export const audit = {
26
26
  domain: "code",
27
27
  requires: { ecosystems: ["npm"] },
28
28
  gating: false,
29
- defaultConfig: { referenceFile: "tsconfig.json" }, // relative to root
29
+ // referenceFile is relative to root. ignoreKeys: compilerOptions keys deliberately NOT judged —
30
+ // for axes that legitimately differ per sub-project (e.g. `types` between a Bun daemon and a DOM
31
+ // SPA) the knob beats removing the key from the reference (which would stop enforcing it locally).
32
+ defaultConfig: { referenceFile: "tsconfig.json", ignoreKeys: [] },
30
33
  async run(ctx) {
31
34
  const { root, checkConfig } = ctx;
32
35
  const refPath = join(root, checkConfig.referenceFile);
@@ -34,16 +37,26 @@ export const audit = {
34
37
  // Parse the reference to extract its compilerOptions keys — these are the axes we judge.
35
38
  const refParsed = parseTsconfig(refPath);
36
39
  const refCompilerOptions = refParsed?.compilerOptions || {};
37
- const refKeys = Object.keys(refCompilerOptions);
40
+ const ignoreKeys = new Set(checkConfig.ignoreKeys || []);
41
+ const refKeys = Object.keys(refCompilerOptions).filter((k) => !ignoreKeys.has(k));
38
42
  if (refKeys.length === 0) {
39
43
  // No compilerOptions in reference — nothing to judge.
40
44
  return { failed: false, findings: [], report: "" };
41
45
  }
42
46
 
47
+ // A solution-style SHELL (`files: []` + project references) compiles nothing itself — its
48
+ // compilerOptions are residual/organizational, and judging it against the reference produces
49
+ // one false "got undefined" per reference key (hit live 2026-07-05). The referenced projects
50
+ // are judged directly instead.
51
+ const isSolutionShell = (parsed) =>
52
+ Array.isArray(parsed?.files) && parsed.files.length === 0 && Array.isArray(parsed?.references) && parsed.references.length > 0;
53
+
43
54
  // Collect all tsconfig.json files (including the reference — the oracle skips it).
44
55
  const members = [];
45
56
  for (const file of walkFiles(root)) {
46
- if (basename(file) === "tsconfig.json") members.push({ path: file });
57
+ if (basename(file) !== "tsconfig.json") continue;
58
+ if (isSolutionShell(parseTsconfig(file))) continue;
59
+ members.push({ path: file });
47
60
  }
48
61
  if (members.length === 0) return { failed: false, findings: [], report: "" };
49
62
 
@@ -21,6 +21,23 @@
21
21
  // same line, or attached directly above the statement — is a documented decision; only the
22
22
  // wordless swallow is flagged.
23
23
  //
24
+ // The 2026-07-05 expansion (web-research pass: Reddit/HN "AI code tells" threads + the platform
25
+ // release notes an AI trained before them keeps missing) added two families:
26
+ // - AI-slop signatures: chat narration committed as comments ("rest of the code remains the
27
+ // same", "here's the updated…", "as an AI model", "in a real application"), emoji-decorated
28
+ // comments (a documented LLM signature — netcraft.com/blog/excessive-emojis-as-an-ai-indicator),
29
+ // rewrite-artifact identifiers (fooImproved/fooEnhanced), and inference-redundant literal
30
+ // annotations (`const x: string = "…"`).
31
+ // - platform fossils — old idioms whose modern replacement clears this repo's floor (Node 24
32
+ // Lambdas, Bun 1.3 tooling, Vue 3.5, Tailwind 4, evergreen browsers): indexOf-sentinels
33
+ // (includes, ES2016), arr[arr.length-1] (.at(-1), ES2022), hasOwnProperty (Object.hasOwn,
34
+ // ES2022), slice().sort() ( toSorted/toReversed, ES2023), hand-rolled deferreds
35
+ // (Promise.withResolvers, ES2024), Object.assign({},…) (spread), TS `namespace`,
36
+ // ref<HTMLElement>(null) (useTemplateRef, Vue 3.5), and Tailwind utilities REMOVED in v4
37
+ // (the *-opacity-* family, flex-shrink/grow-*, overflow-ellipsis, decoration-slice/clone —
38
+ // silently inert classes, i.e. live rendering bugs; the official removed table at
39
+ // tailwindcss.com/docs/upgrade-guide).
40
+ //
24
41
  // Considered and deliberately NOT rules (so nobody re-proposes them):
25
42
  // - TODO/FIXME/HACK — owned by the `stale-todos` check.
26
43
  // - `await new Promise(r => setTimeout(r, n))` — legitimate backoff/polling is mechanically
@@ -28,6 +45,17 @@
28
45
  // - `setTimeout(fn, 0)` — sometimes the correct macrotask deferral in UI code.
29
46
  // - `x == null` — the one idiomatic loose equality (null-or-undefined).
30
47
  // - complexity / file size / duplication — owned by the codeRisk group.
48
+ // - markdown fences (```) — legitimate in JSDoc @example blocks and md-generating template
49
+ // literals; a fence at CODE position breaks the build and typecheck already catches it.
50
+ // - reduce-as-groupBy → Object.groupBy — the reduce shape is too polymorphic to match without
51
+ // an AST; mostly false positives.
52
+ // - Temporal / Uint8Array.fromBase64 — not baseline yet (Temporal absent from Node 24).
53
+ // - Bun.* API pushes (Bun.$, Bun.SQL, Bun.Glob) — Lambdas run Node, and Node-API code in
54
+ // dual-context files is correct, not a fossil.
55
+ // - Vue defineModel / Tailwind v4 RENAMED utilities (shadow-sm, rounded, ring, outline-none) —
56
+ // the old forms still work (renames changed meaning, so flagging them would assert false
57
+ // semantics); only utilities v4 REMOVED are flagged.
58
+ // - unused imports / docstring-overload / naming inconsistency — eslint's lane or semantic.
31
59
  //
32
60
  // bun packages/connections-arkitect/bin/arkitect.mjs --root . --check veteran-would-mock
33
61
  import { readFileSync } from "node:fs";
@@ -276,6 +304,9 @@ const CODE_RULES = [
276
304
  id: "redundant-boolean-ternary",
277
305
  severity: "error",
278
306
  re: /\?\s*true\s*:\s*false\b|\?\s*false\s*:\s*true\b/g,
307
+ // TS conditional TYPES (`T extends U ? true : false`) are the idiomatic type-level boolean —
308
+ // required syntax, not a launderable value ternary. Guarded via `extends` on/near the match line.
309
+ typeContextGuard: true,
279
310
  what: "`cond ? true : false` — a boolean laundered through a ternary",
280
311
  fix: "Use the condition itself (negate it for the inverted form).",
281
312
  },
@@ -362,8 +393,132 @@ const CODE_RULES = [
362
393
  what: "`as unknown as` double-cast — the type system asked a question and was told to shut up",
363
394
  fix: "Prefer a real type guard or a narrower seam type; keep only genuinely unavoidable boundary casts.",
364
395
  },
396
+ // ── 2026-07-05 expansion: AI-slop signatures ────────────────────────────────
397
+ {
398
+ id: "redundant-literal-annotation",
399
+ severity: "error",
400
+ extKey: "tsExtensions",
401
+ re: /\b(?:const|let)\s+[\w$]+\s*:\s*(?:string|number|boolean)\s*=/g,
402
+ rawLineRe: /=\s*(?:["'`]|-?\d|true\b|false\b)/,
403
+ what: '`const x: string = "…"` — annotating what inference already knows; the classic AI-generated tell',
404
+ fix: "Drop the annotation (use `satisfies` when you genuinely need to check against a wider type).",
405
+ },
406
+ {
407
+ id: "rewrite-suffix-identifier",
408
+ severity: "error",
409
+ re: /\b(?:function|const|let)\s+[\w$]*(?:Improved|Enhanced|Refactored|Optimized)\b/g,
410
+ what: "an `…Improved`/`…Enhanced` identifier — the AI-rewrite artifact whose name still argues with a predecessor",
411
+ fix: "Name the thing for what it does; delete the predecessor the name implies.",
412
+ },
413
+ {
414
+ id: "redundant-null-undefined-check",
415
+ severity: "error",
416
+ re: /([A-Za-z_$][\w$]*)\s*!==\s*null\s*&&\s*\1\s*!==\s*undefined|([A-Za-z_$][\w$]*)\s*!==\s*undefined\s*&&\s*\2\s*!==\s*null/g,
417
+ what: "`x !== null && x !== undefined` — over-defensive AI boilerplate; `x != null` says exactly this in one clause",
418
+ fix: "Use `x != null` (the one idiomatic loose equality) or `??`.",
419
+ },
420
+ // ── 2026-07-05 expansion: platform fossils (repo floor: Node 24 / Vue 3.5 / evergreen) ──────
421
+ {
422
+ id: "ts-namespace-fossil",
423
+ severity: "error",
424
+ extKey: "tsExtensions",
425
+ re: /(?<![.\w$])(?:export\s+)?namespace\s+[A-Za-z]/g,
426
+ what: "TS `namespace` — the pre-ES-modules module system",
427
+ fix: "Use ES module files and exports.",
428
+ },
429
+ {
430
+ id: "indexof-sentinel",
431
+ severity: "error",
432
+ re: /\.indexOf\((?:[^()]|\([^()]*\))*\)\s*(?:!==?\s*-1|>\s*-1|>=\s*0|===?\s*0(?![.\d]))/g,
433
+ what: "`indexOf(…) !== -1` / `=== 0` — `.includes()` and `.startsWith()` shipped in 2016",
434
+ fix: "Use `.includes(…)` for the sentinel form, `.startsWith(…)` for `=== 0` (position-arithmetic uses of indexOf are fine and not matched).",
435
+ },
436
+ {
437
+ // INFO, not error: under this repo's TS config `arr[arr.length - 1]` types as T while
438
+ // `.at(-1)` types as T | undefined, so the swap is not mechanically safe — harvest
439
+ // opportunistically where the undefined-widening is absorbed anyway.
440
+ id: "last-index-fossil",
441
+ severity: "info",
442
+ re: /([A-Za-z_$][\w$]*)\[\1\.length\s*-\s*1\]/g,
443
+ what: "`arr[arr.length - 1]` — `.at(-1)` has existed since ES2022",
444
+ fix: "Use `.at(-1)` where the T|undefined return type is acceptable.",
445
+ },
446
+ {
447
+ id: "object-assign-empty-fossil",
448
+ severity: "error",
449
+ re: /Object\.assign\(\s*\{\s*\}\s*,/g,
450
+ what: "`Object.assign({}, …)` — the pre-spread shallow copy",
451
+ fix: "Use spread: `{ ...source }`.",
452
+ },
453
+ {
454
+ id: "deferred-promise-fossil",
455
+ severity: "error",
456
+ re: /new\s+Promise\s*(?:<[^>]*>)?\s*\(\s*\(?([\w$]+)(?:\s*,\s*[\w$]+)?\)?\s*=>\s*\{?\s*[\w$][\w$.]*\s*=\s*\1\b/g,
457
+ what: "a hand-rolled deferred (executor param captured to an outer variable) — `Promise.withResolvers()` is ES2024",
458
+ fix: "const { promise, resolve, reject } = Promise.withResolvers() (Node 22+ / evergreen; this repo's floor is Node 24).",
459
+ },
460
+ {
461
+ id: "hasownproperty-fossil",
462
+ severity: "info",
463
+ re: /\.hasOwnProperty\b/g,
464
+ what: "hasOwnProperty gymnastics (including the `Object.prototype.hasOwnProperty.call` dance) — `Object.hasOwn()` is ES2022",
465
+ fix: "Object.hasOwn(obj, key).",
466
+ },
467
+ {
468
+ // Deliberately NOT matching `[...x].sort()`: the spread already made the copy (sorting it
469
+ // in place is a legitimate single-copy idiom) and the spread source may be a Set/iterable
470
+ // with no .toSorted at all. Only the two-step `.slice().sort()` chain is the fossil.
471
+ id: "slice-sort-fossil",
472
+ severity: "info",
473
+ re: /\.slice\(\)\s*\.\s*(?:sort|reverse)\s*\(/g,
474
+ what: "`.slice().sort()` copy-then-mutate ordering — `.toSorted()`/`.toReversed()` are ES2023",
475
+ fix: "Use .toSorted(…) / .toReversed().",
476
+ },
477
+ {
478
+ // .vue SFCs only: a NAME-bound template ref in <script setup> is the convertible class.
479
+ // Element refs held in .ts composables are REFERENCE-bound (the component wires them via
480
+ // :ref binding), where useTemplateRef's string lookup does not apply — legit, not fossil.
481
+ id: "vue-element-ref-fossil",
482
+ severity: "info",
483
+ extKey: "vueExtensions",
484
+ re: /\bref<(?:HTML|SVG)\w*\s*(?:\|\s*null\s*)?>\s*\(\s*null\s*\)/g,
485
+ what: "element ref via `ref<HTMLElement…>(null)` — Vue 3.5's `useTemplateRef()` is the purpose-built API",
486
+ fix: "useTemplateRef('name') (Vue ≥ 3.5) when the ref is name-bound in this SFC's template.",
487
+ },
365
488
  ];
366
489
 
490
+ // Comment-view rules — chat narration and LLM decoration committed as comments.
491
+ const COMMENT_RULES = [
492
+ {
493
+ id: "ai-paste-artifact",
494
+ severity: "error",
495
+ re: /rest of (?:the )?(?:code|file|function|component|implementation) (?:remains|stays|is) (?:the same|unchanged)|\.\.\. ?existing code|existing code (?:remains|unchanged)|as an ai (?:language )?model|here'?s the (?:updated|corrected|complete|fixed) (?:code|version|file|implementation)|in a real (?:app|application|production system)\b|implementation goes here|rest of your code/gi,
496
+ what: "an AI paste artifact — chat narration committed as a comment",
497
+ fix: "Delete the narration (and make sure the code it elides actually made it into the file).",
498
+ },
499
+ {
500
+ id: "emoji-comment",
501
+ severity: "info",
502
+ oncePerLine: true,
503
+ re: /[\u{1F000}-\u{1FAFF}\u{2705}\u{274C}\u{2728}\u{2757}\u{2764}]/gu,
504
+ what: "emoji in a code comment — a documented LLM signature (engineers annotate; models decorate)",
505
+ fix: "Say it in words. (CLI status output and product copy are out of scope; doctrine markers like ⚠/⛔ are deliberately not matched.)",
506
+ },
507
+ ];
508
+
509
+ // Tailwind utilities REMOVED in v4 — in a v4 build these classes are silently inert, i.e. live
510
+ // rendering bugs. Token-anchored (a repo-own `gc-flex-shrink` class must never match) after
511
+ // stripping variant prefixes (`md:`, `hover:`). Renamed-but-still-valid utilities are deliberately
512
+ // NOT here (they changed meaning; flagging them would assert false semantics).
513
+ const TAILWIND_V4_DEAD =
514
+ /^(?:(?:bg|text|border|divide|placeholder|ring)-opacity-\d+|flex-(?:shrink|grow)(?:-\d+)?|overflow-ellipsis|decoration-(?:slice|clone))$/;
515
+ const TAILWIND_DEAD_RULE = {
516
+ id: "tailwind-v4-dead-utility",
517
+ severity: "error",
518
+ what: "a Tailwind utility REMOVED in v4 sits in a class attribute — it compiles to nothing, the element silently mis-renders",
519
+ fix: "Per the v4 upgrade guide: *-opacity-N → color/N modifiers (bg-black/50), flex-shrink/grow → shrink/grow, overflow-ellipsis → text-ellipsis, decoration-slice/clone → box-decoration-*.",
520
+ };
521
+
367
522
  const RAW_RULES = [
368
523
  {
369
524
  id: "merge-conflict-marker",
@@ -450,9 +605,11 @@ export const audit = {
450
605
  domain: "code",
451
606
  requires: {},
452
607
  defaultConfig: {
453
- extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue"],
608
+ extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".html"],
454
609
  varDeclarationExtensions: [".ts", ".tsx", ".vue"],
455
610
  doubleCastExtensions: [".ts", ".tsx", ".vue"],
611
+ tsExtensions: [".ts", ".tsx", ".vue"],
612
+ vueExtensions: [".vue"],
456
613
  // Rules that only make sense in browser-shipped code fire only under these path markers.
457
614
  frontendPathMarkers: ["/web/src/"],
458
615
  // Per-rule canonical homes (declared, not silenced): the ONE shared wrapper allowed to fall
@@ -553,7 +710,7 @@ export const audit = {
553
710
  while ((m = rule.re.exec(raw))) push(rule, m.index);
554
711
  }
555
712
 
556
- const views = buildViews(raw, ext === ".vue");
713
+ const views = buildViews(raw, ext === ".vue" || ext === ".html");
557
714
  const sanctionedHomes = cfg.sanctionedHomes || {};
558
715
  for (const rule of CODE_RULES) {
559
716
  if (rule.extKey && !cfg[rule.extKey].includes(ext)) continue;
@@ -562,6 +719,24 @@ export const audit = {
562
719
  rule.re.lastIndex = 0;
563
720
  let m;
564
721
  while ((m = rule.re.exec(views.code))) {
722
+ // Some rules confirm against the RAW line (the code view blanks string content, so a
723
+ // literal-RHS test must look at the original text).
724
+ if (rule.rawLineRe && !rule.rawLineRe.test(rawLines[lineAt(starts, m.index) - 1] || "")) continue;
725
+ // typeContextGuard: a bare `extends` on the match line or up to 3 lines above marks a
726
+ // TS conditional TYPE (`T extends U ? true : false`) — required type-level syntax, not a
727
+ // value ternary. Bias toward false negatives: a suppressed real ternary costs one nitpick;
728
+ // a flagged conditional type is a gating false positive (hit live 2026-07-05).
729
+ if (rule.typeContextGuard) {
730
+ const matchLine = lineAt(starts, m.index);
731
+ let typeCtx = false;
732
+ for (let k = matchLine; k >= Math.max(1, matchLine - 3); k--) {
733
+ if (/(?<![.\w$])extends\b/.test(rawLines[k - 1] || "")) {
734
+ typeCtx = true;
735
+ break;
736
+ }
737
+ }
738
+ if (typeCtx) continue;
739
+ }
565
740
  // A guarded rule is skipped when a comment documents the decision: in the RAW match
566
741
  // span, on the rest of its line, or attached directly above the statement (up to 3
567
742
  // lines up, a blank line breaks attachment) — `catch { /* best-effort */ }`,
@@ -588,6 +763,35 @@ export const audit = {
588
763
  }
589
764
  }
590
765
 
766
+ // Comment-view rules: chat narration + LLM decoration committed as comments.
767
+ for (const rule of COMMENT_RULES) {
768
+ rule.re.lastIndex = 0;
769
+ let m;
770
+ let lastFlaggedLine = -1;
771
+ while ((m = rule.re.exec(views.comment))) {
772
+ const line = lineAt(starts, m.index);
773
+ if (rule.oncePerLine && line === lastFlaggedLine) continue;
774
+ lastFlaggedLine = line;
775
+ push(rule, m.index);
776
+ }
777
+ }
778
+
779
+ // Tailwind v4 REMOVED utilities in class attributes (.vue/.html templates): token-anchored
780
+ // after variant-prefix strip, so a repo-own `gc-flex-shrink` class never matches.
781
+ if (ext === ".vue" || ext === ".html") {
782
+ const CLASS_ATTR = /\bclass\s*=\s*(["'])([\s\S]*?)\1/g;
783
+ let cm;
784
+ while ((cm = CLASS_ATTR.exec(raw))) {
785
+ for (const tokenRaw of cm[2].split(/\s+/)) {
786
+ const token = tokenRaw.replace(/^(?:[\w-]+:)+/, "");
787
+ if (TAILWIND_V4_DEAD.test(token)) {
788
+ push(TAILWIND_DEAD_RULE, cm.index);
789
+ break;
790
+ }
791
+ }
792
+ }
793
+ }
794
+
591
795
  // Commented-out code: runs of ≥ N consecutive code-shaped comment lines.
592
796
  const commentLines = views.comment.split("\n");
593
797
  let runStart = -1;
@@ -29,7 +29,13 @@ export async function discoverAudits(roots) {
29
29
  for (const audit of candidates) {
30
30
  if (!audit || typeof audit !== "object" || typeof audit.id !== "string" || typeof audit.run !== "function") continue;
31
31
  if (byId.get(audit.id) === audit) continue; // same object exported under two names
32
- if (byId.has(audit.id)) shadowed.push({ id: audit.id, by: file });
32
+ if (byId.has(audit.id)) {
33
+ const prev = byId.get(audit.id);
34
+ // Cross-root shadowing (your-checks over core) is the designed patch seam;
35
+ // SAME-root duplication is corpus corruption — two files fighting over one id,
36
+ // one silently winning. Record the roots so the CLI can gate the latter.
37
+ shadowed.push({ id: audit.id, by: file, prevFile: prev.__file, prevRoot: prev.__root, root });
38
+ }
33
39
  audit.__file = file;
34
40
  audit.__group = basename(dirname(file));
35
41
  audit.__root = root;
@@ -1,8 +1,27 @@
1
1
  // One filesystem walker, reused by discovery + every check (DRY). Skips the usual build/vendor dirs.
2
2
  import { readdirSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { IGNORE_DIRS, isIgnoredDir } from "./project-detect.mjs";
5
5
 
6
+ // Workspace-level scan exclusions (config `scan.excludeDirs`): repo-relative paths the TARGET
7
+ // workspace declares as non-source — generated run output (an `output/` of AI-produced HTML),
8
+ // imported data, vendored-in trees (a generated shadcn `components/ui/`) that the generic
9
+ // IGNORE set can't know by name. Set once per run by the runner from the workspace config, and
10
+ // scoped by ABSOLUTE path prefix — so walks over other roots (e.g. check discovery inside the
11
+ // core package itself) are never affected.
12
+ let SCAN_EXCLUDE_PREFIXES = [];
13
+ export function setScanExcludes(root, dirs = []) {
14
+ const rootPosix = resolve(root).replace(/\\/g, "/").replace(/\/+$/, "");
15
+ SCAN_EXCLUDE_PREFIXES = (Array.isArray(dirs) ? dirs : [])
16
+ .filter((d) => typeof d === "string" && d.trim())
17
+ .map((d) => `${rootPosix}/${d.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")}`);
18
+ }
19
+ function isScanExcluded(p) {
20
+ if (SCAN_EXCLUDE_PREFIXES.length === 0) return false;
21
+ const posix = p.replace(/\\/g, "/");
22
+ return SCAN_EXCLUDE_PREFIXES.some((pre) => posix === pre || posix.startsWith(`${pre}/`));
23
+ }
24
+
6
25
  export function* walkFiles(dir, { ignore = IGNORE_DIRS, includeDotfiles = false } = {}) {
7
26
  let entries;
8
27
  try {
@@ -13,6 +32,7 @@ export function* walkFiles(dir, { ignore = IGNORE_DIRS, includeDotfiles = false
13
32
  for (const e of entries) {
14
33
  if (!includeDotfiles && e.name.startsWith(".")) continue;
15
34
  const p = join(dir, e.name);
35
+ if (isScanExcluded(p)) continue; // workspace-declared non-source (files and dirs alike)
16
36
  if (e.isDirectory()) {
17
37
  // isIgnoredDir is the canonical generated-output matcher (names + dist-* family + Capacitor
18
38
  // webDir copies); a custom `ignore` set ADDS to it, never re-admits build output.
package/src/core/init.mjs CHANGED
@@ -17,9 +17,10 @@ import { join, dirname } from "node:path";
17
17
 
18
18
  const DEFAULT_CONFIG = {
19
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).",
20
+ "The Architect's per-workspace config. YOUR files are this one + your-checks/ + spines/ — a core update never touches them. scan.excludeDirs lists repo-relative dirs that are data/generated/vendored, not source — every check's file walk skips them. checks.<id> tunes one check (its defaultConfig keys, plus gating/enabled/includeInAll). 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
21
  version: 1,
22
22
  update: { channel: "stable", pinnedVersion: null, autoUpdate: true },
23
+ scan: { excludeDirs: [] },
23
24
  checks: {},
24
25
  hosted: { failOnBounty: false, targets: [] },
25
26
  };
@@ -14,10 +14,11 @@
14
14
  // every run is archived to `.arkitect/reports/<stamp>/` — manifest + FIX_QUEUE + telemetry + each
15
15
  // check's full report — so "what did it find last Tuesday" is a folder, not a memory. Pruned to
16
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";
17
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, rmSync, copyFileSync } from "node:fs";
18
18
  import { join, dirname } from "node:path";
19
19
  import { countSeverities } from "./finding.mjs";
20
20
  import { checkAppliesToProject } from "./project-detect.mjs";
21
+ import { setScanExcludes } from "./fs-walk.mjs";
21
22
  import { toSarif } from "./sarif.mjs";
22
23
 
23
24
  const now = () => globalThis.performance?.now?.() ?? Date.now();
@@ -83,6 +84,9 @@ function instrumentVault(vault, tel) {
83
84
 
84
85
  export async function runAudits(audits, ctxBase, { outDir }) {
85
86
  mkdirSync(outDir, { recursive: true });
87
+ // Workspace scan exclusions (config `scan.excludeDirs`) apply to EVERY check's walk of this
88
+ // root — set here so both doors (CLI + the MCP in-process runner) honor them identically.
89
+ setScanExcludes(ctxBase.root, ctxBase.config?.scan?.excludeDirs);
86
90
  const results = [];
87
91
  for (const audit of audits) {
88
92
  if (!checkAppliesToProject(audit.requires, ctxBase.project)) {
@@ -96,7 +100,15 @@ export async function runAudits(audits, ctxBase, { outDir }) {
96
100
  const checkConfig = { ...(audit.defaultConfig || {}), ...((ctxBase.config?.checks || {})[audit.id] || {}) };
97
101
  // A workspace may override a check's gating in config (checks.<id>.gating: false ⇒ advisory) —
98
102
  // the visible, reviewable way to acknowledge known findings without silencing the check.
99
- const effectiveGating = typeof checkConfig.gating === "boolean" ? checkConfig.gating : audit.gating;
103
+ // A check also declares its own posture via `enabled: false` / `includeInAll: false` in
104
+ // defaultConfig/config: it still RUNS (visibility), but never gates. This is the corpus's
105
+ // long-standing contract (~150 checks declare it; the check-gating-integrity oracle governs
106
+ // it via the advisory-allowlist) — this runner ignoring those keys made every one of those
107
+ // declarations silently dead until 2026-07-05.
108
+ const effectiveGating =
109
+ (typeof checkConfig.gating === "boolean" ? checkConfig.gating : audit.gating) !== false &&
110
+ checkConfig.enabled !== false &&
111
+ checkConfig.includeInAll !== false;
100
112
 
101
113
  // Telemetry seams for THIS check: instrumented vault + instrumented global fetch. The runner is
102
114
  // sequential, so the fetch swap cannot bleed across checks; it is ALWAYS restored.
@@ -153,6 +165,7 @@ export async function runAudits(audits, ctxBase, { outDir }) {
153
165
  // Normalize severity dialects ONCE, here — every consumer downstream (counts, gating, SARIF,
154
166
  // DECISION_BRIEF ordering) then agrees. "warn"/"review"→warning, "err"/"fatal"→error, "note"→info.
155
167
  // (Only adjudicated dialects are mapped — domain scales like axe's minor/serious pass through.)
168
+ const unknownSeverities = new Set();
156
169
  for (const f of findings) {
157
170
  if (f && typeof f.severity === "string") {
158
171
  const s = f.severity.toLowerCase();
@@ -164,26 +177,49 @@ export async function runAudits(audits, ctxBase, { outDir }) {
164
177
  : s === "note" || s === "information"
165
178
  ? "info"
166
179
  : s;
180
+ if (f.severity !== "error" && f.severity !== "warning" && f.severity !== "info") unknownSeverities.add(f.severity);
167
181
  }
168
182
  }
183
+ // A non-canonical severity silently counts as info (i.e. as nothing) downstream —
184
+ // that may be a domain scale (axe) or a typo'd "error"; either way say so once.
185
+ if (unknownSeverities.size) {
186
+ console.error(`[architect] ${audit.id}: non-canonical severity value(s) counted as info: ${[...unknownSeverities].join(", ")}`);
187
+ }
169
188
  const counts = findings.length ? countSeverities(findings) : out?.report ? countsFromReport(out.report) : countSeverities(findings);
170
189
  const failed = out?.failed ?? counts.errors > 0;
171
190
  // Write the report whenever the check surfaced ANYTHING — including the thermometer shape
172
191
  // (error-severity findings from a check that chose not to fail): an advisory finding with no
173
192
  // report on disk is invisible to the AI-first reader, which is the disarmed-check failure mode.
193
+ const reportPath = out?.outputPath || join(outDir, `${audit.id}.md`);
174
194
  if (out?.report && (failed || counts.errors > 0 || counts.warnings > 0)) {
175
- const path = out.outputPath || join(outDir, `${audit.id}.md`);
176
195
  try {
177
- mkdirSync(dirname(path), { recursive: true });
178
- writeFileSync(path, out.report);
196
+ mkdirSync(dirname(reportPath), { recursive: true });
197
+ writeFileSync(reportPath, out.report);
179
198
  } catch {
180
199
  /* best-effort report write */
181
200
  }
201
+ } else if (out) {
202
+ // Clean run: actively remove the PRIOR run's report at this check's path so a
203
+ // stale failing report can't be mistaken for current findings.
204
+ try {
205
+ rmSync(reportPath, { force: true });
206
+ } catch {
207
+ /* best-effort stale-report cleanup */
208
+ }
182
209
  }
210
+ // A check whose INPUT is absent (no capture/build/registry to audit) returns
211
+ // { status: "no-data", reason } — first-class, never "clean": a gate must not
212
+ // report green for a check that verified nothing (the vacuous-green class,
213
+ // 13 checks found 2026-07-05). Visible in the manifest + FIX_QUEUE, non-gating.
214
+ // A check may also return { status: "skipped", reason } to self-skip (e.g. an
215
+ // umbrella whose sections run standalone in the same suite) — recorded exactly
216
+ // like a requires-not-met skip.
217
+ const selfStatus = out?.status === "no-data" || out?.status === "skipped" ? out.status : null;
183
218
  results.push({
184
219
  id: audit.id,
185
220
  audit,
186
- status: failed ? "failed" : counts.warnings ? "warned" : "clean",
221
+ status: selfStatus ?? (failed ? "failed" : counts.warnings ? "warned" : "clean"),
222
+ ...(selfStatus && out?.reason ? { reason: out.reason } : {}),
187
223
  gating: effectiveGating,
188
224
  findings,
189
225
  counts,
@@ -200,7 +236,8 @@ export async function runAudits(audits, ctxBase, { outDir }) {
200
236
  function rank(r) {
201
237
  if (r.status === "crashed") return 0;
202
238
  if (r.status === "failed") return 1;
203
- return 2;
239
+ if (r.status === "warned") return 2;
240
+ return 3; // no-data last — visible, but below actionable findings
204
241
  }
205
242
 
206
243
  const SEV_ORDER = { error: 0, warning: 1, info: 2 };
@@ -211,13 +248,21 @@ function summarize(results, outDir, ctxBase = {}) {
211
248
  gatingErrors = 0,
212
249
  advisoryFindings = 0,
213
250
  warnings = 0,
214
- crashed = 0;
251
+ crashed = 0,
252
+ noData = 0;
215
253
  for (const r of results) {
216
254
  if (r.status === "crashed") {
217
255
  crashed++;
218
256
  gatingErrors++;
219
257
  continue;
220
258
  }
259
+ if (r.status === "no-data") {
260
+ // Input absent — the check verified NOTHING. Never counted clean, never gates;
261
+ // surfaced in the manifest + FIX_QUEUE so absent coverage is visible, not green.
262
+ noData++;
263
+ continue;
264
+ }
265
+ if (r.status === "skipped") continue; // requires-not-met or self-skip — no counts to add
221
266
  const c = r.counts || { errors: 0, warnings: 0 };
222
267
  totalErrors += c.errors;
223
268
  warnings += c.warnings;
@@ -248,7 +293,7 @@ function summarize(results, outDir, ctxBase = {}) {
248
293
  ? ` [${fmtMs(r.durationMs)}${r.bounces?.count ? ` · ${r.bounces.count} bounce${r.bounces.count === 1 ? "" : "s"}` : ""}]`
249
294
  : "";
250
295
  const offenders = results
251
- .filter((r) => r.status === "failed" || r.status === "crashed" || r.status === "warned")
296
+ .filter((r) => r.status === "failed" || r.status === "crashed" || r.status === "warned" || r.status === "no-data")
252
297
  .sort((a, b) => rank(a) - rank(b));
253
298
  const queue = offenders.map((r) => {
254
299
  const configAdvisory = (r.gating ?? r.audit.gating) === false;
@@ -256,11 +301,22 @@ function summarize(results, outDir, ctxBase = {}) {
256
301
  // count "a", never "e", so a scan of the queue for `e/` finds only genuinely blocking work
257
302
  // (the same AI-first legibility rule as the summary counts).
258
303
  const nonBlocking = configAdvisory || (r.status !== "failed" && r.status !== "crashed");
259
- const tag = r.status === "crashed" ? "CRASH" : configAdvisory ? "ADVISORY" : r.status === "warned" ? "WARN" : "ERROR";
304
+ const tag =
305
+ r.status === "crashed"
306
+ ? "CRASH"
307
+ : r.status === "no-data"
308
+ ? "NO-DATA"
309
+ : configAdvisory
310
+ ? "ADVISORY"
311
+ : r.status === "warned"
312
+ ? "WARN"
313
+ : "ERROR";
260
314
  const detail =
261
315
  r.status === "crashed"
262
316
  ? ` ${r.error?.split("\n")[0] || ""}`
263
- : ` (${r.counts?.errors || 0}${nonBlocking ? "a" : "e"}/${r.counts?.warnings || 0}w)`;
317
+ : r.status === "no-data"
318
+ ? ` ${r.reason || "input artifact absent — nothing verified"}`
319
+ : ` (${r.counts?.errors || 0}${nonBlocking ? "a" : "e"}/${r.counts?.warnings || 0}w)`;
264
320
  return `- [${tag}] ${r.id} — ${r.audit.title}${detail}${telLine(r)}`;
265
321
  });
266
322
  writeFileSync(
@@ -276,6 +332,8 @@ function summarize(results, outDir, ctxBase = {}) {
276
332
  advisoryFindings,
277
333
  warnings,
278
334
  crashed,
335
+ // Checks whose input artifact was absent — they verified nothing (never "clean").
336
+ noData,
279
337
  telemetry: { totalDurationMs, totalBounces, slowest },
280
338
  checks: results.map((r) => ({
281
339
  id: r.id,
@@ -355,10 +413,26 @@ function summarize(results, outDir, ctxBase = {}) {
355
413
  }
356
414
  }
357
415
  // Prune to reports.keep (default 20), oldest first — an archive that grows forever is a leak.
416
+ // COMPREHENSIVE (~full-suite) archives get extra protection: in a multi-agent repo a
417
+ // flood of 1-check runs evicts the one archive that matters within seconds (observed
418
+ // live 2026-07-05: a fresh --all archive gone in ~45s). The newest reports.keepFull
419
+ // (default 3) archives with ≥100 checks survive the window regardless.
358
420
  const keep = Number(ctxBase.config?.reports?.keep ?? 20);
359
- const runs = readdirSync(join(root, ".arkitect", "reports")).sort();
421
+ const keepFull = Number(ctxBase.config?.reports?.keepFull ?? 3);
422
+ const reportsDir = join(root, ".arkitect", "reports");
423
+ const runs = readdirSync(reportsDir).sort();
424
+ const isComprehensive = (d) => {
425
+ try {
426
+ return (JSON.parse(readFileSync(join(reportsDir, d, "manifest.json"), "utf8")).checks?.length ?? 0) >= 100;
427
+ } catch {
428
+ return false;
429
+ }
430
+ };
431
+ const fullRuns = runs.filter(isComprehensive);
432
+ const protectedFull = new Set(fullRuns.slice(Math.max(0, fullRuns.length - keepFull)));
360
433
  for (const old of runs.slice(0, Math.max(0, runs.length - keep))) {
361
- rmSync(join(root, ".arkitect", "reports", old), { recursive: true, force: true });
434
+ if (protectedFull.has(old)) continue;
435
+ rmSync(join(reportsDir, old), { recursive: true, force: true });
362
436
  }
363
437
  }
364
438
  } catch {