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 +43 -4
- package/package.json +1 -1
- package/src/checks/code/dependency-freshness.mjs +31 -17
- package/src/checks/code/lockfile-manifest-parity.mjs +190 -0
- package/src/checks/code/tsconfig-conformance.mjs +16 -3
- package/src/checks/code/veteran-would-mock.mjs +206 -2
- package/src/core/discovery.mjs +7 -1
- package/src/core/fs-walk.mjs +21 -1
- package/src/core/init.mjs +2 -1
- package/src/core/runner.mjs +87 -13
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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;
|
package/src/core/discovery.mjs
CHANGED
|
@@ -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))
|
|
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;
|
package/src/core/fs-walk.mjs
CHANGED
|
@@ -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
|
};
|
package/src/core/runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
178
|
-
writeFileSync(
|
|
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 =
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
434
|
+
if (protectedFull.has(old)) continue;
|
|
435
|
+
rmSync(join(reportsDir, old), { recursive: true, force: true });
|
|
362
436
|
}
|
|
363
437
|
}
|
|
364
438
|
} catch {
|