connections-arkitect 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # connections-arkitect
2
+
3
+ The **Architect** — a portable checks-and-balances framework. A generic CORE of checks ships from
4
+ Connections; you add your OWN under `your-checks/` (the core never touches them); together they form a
5
+ **cast** on your codebase that catches breakage and drift and gets tighter as you add rules.
6
+
7
+ > One product, two halves: static **code** checks in your repo, and **hosted** checks against your live
8
+ > cloud (your database on AWS RDS/Aurora — Supabase/generic Postgres transports are on the roadmap) —
9
+ > distilled from Connections' internal code-audit and live-DB governance engines. Zero runtime
10
+ > dependencies.
11
+
12
+ ## Use it
13
+
14
+ ```sh
15
+ npx -y connections-arkitect init # OPT-IN install: lay .arkitect/ (config + your-checks + spines)
16
+ npx -y connections-arkitect # run every applicable check
17
+ npx -y connections-arkitect --check no-secrets-committed
18
+ npx -y connections-arkitect list # what's discovered (core + your-checks)
19
+ npx -y connections-arkitect --fail-on-drift # exit 1 if a gating check fails (CI)
20
+ ```
21
+
22
+ Output: `tmp/arkitect/FIX_QUEUE.md` (ranked) + `tmp/arkitect/manifest.json` whose `pass.nextAction`
23
+ (`fix-errors` → `review-warnings` → `done`) is the one field an agent reads to know if it's finished.
24
+
25
+ **Native telemetry — every check, always, no opt-in.** The runner itself records, per check, its
26
+ **duration** and its **bounces** — every outbound round-trip the check made (vault DB/cloud calls and
27
+ `fetch` HTTP calls), counted and timed at the seams, so a check's author writes zero telemetry code and
28
+ can't forget to. "Login worked" is half a report; "login worked, 87 bounces at ~200ms each" is why it
29
+ took 30 seconds. Aggregates ride the manifest + FIX_QUEUE lines (`[1.2s · 14 bounces]`); the per-hop
30
+ trace is `tmp/arkitect/telemetry.json`.
31
+
32
+ **Saved reports.** A bare repo stays stateless (outputs only in `tmp/arkitect/`, overwritten each run).
33
+ But when the repo has a `.arkitect/`, every run is archived to `.arkitect/reports/<stamp>/` —
34
+ manifest, fix queue, telemetry, and every check's **full** report — so you can pull any past run in
35
+ depth later. Pruned to `reports.keep` (config, default 20).
36
+
37
+ Installing is a **choice**: nothing but an explicit `init` (or the MCP `architect_install` tool, on a direct
38
+ request) ever creates `.arkitect/` — a normal run writes only its `tmp/arkitect/` outputs. The split it
39
+ lays down is the whole model: the **Architect Core** (this package: engine + generalist base checks) updates
40
+ from Connections; **your-checks/** (your code checks) and **spines/** (your DB policy) are YOURS — a core
41
+ update never touches them, so pulling the newest core never breaks your additions. Updating is also a
42
+ choice: `update.autoUpdate: false` or `update.pinnedVersion` stops the pulls, and forking the core to own it
43
+ outright is legitimate.
44
+
45
+ ## Two halves, one shape
46
+
47
+ - **code** — checks that run statically in your repo. Work anywhere, offline, no account needed.
48
+ - **hosted** — checks that run through the **Connections MCP** against _your own_ cloud, **credential-blind**:
49
+ you never paste a key. The MCP signs you in (browser sign-in), binds your workspace to your company, and
50
+ your cloud calls are signed **server-side** in the Connections vault — the standalone CLI physically
51
+ cannot reach a hosted check (no vault handle exists outside the MCP). Ships the `db-justify-existence`
52
+ governor: every table judged on the 3-axis model (exact live rows × app-code references × in-DB
53
+ references) against your **spine** — strictly read-only; it emits verdicts + a review-only
54
+ `remediation` block a human executes.
55
+
56
+ To run hosted checks: connect the [Connections MCP](https://studio.connections.icu/mcp), sign in when it
57
+ prompts (no key to paste), and invoke the `arkitect_run` tool with `scope: "hosted"` (or `"all"`). Declare
58
+ your DB targets in `arkitect.config.json` → `hosted.targets` — non-secret coordinates only (ARNs, database
59
+ name); the credential stays in the vault. Set `hosted.failOnBounty: true` to make CONDEMNED / PROVE-OR-DIE
60
+ verdicts gate the run (CI-style), or leave it off for advisory reports.
61
+
62
+ ## Make it yours
63
+
64
+ Add `.mjs` files under `your-checks/` — each exports an `audit` and **auto-registers, no wiring**. A user
65
+ check whose `id` matches a core check **shadows** it (patch the core without editing it). Pin or fork via
66
+ `arkitect.config.json` (`update.pinnedVersion` / `update.autoUpdate: false`) to stop pulling the core and
67
+ own it outright.
68
+
69
+ Config + checks can live at the repo root (`arkitect.config.json`, `your-checks/`) **or** tucked under
70
+ `.arkitect/` (`.arkitect/arkitect.config.json`, `.arkitect/your-checks/`) if you prefer to keep
71
+ the Architect's footprint in one folder — the `.arkitect/` location wins when both exist.
72
+
73
+ ```js
74
+ export const audit = {
75
+ id: "my-rule",
76
+ title: "My rule",
77
+ category: "custom",
78
+ domain: "code", // or "hosted"
79
+ requires: {}, // {} = any repo
80
+ gating: false, // true ⇒ blocks --fail-on-drift
81
+ async run(ctx) {
82
+ return { failed: false, findings: [], report: "" };
83
+ },
84
+ };
85
+ ```
86
+
87
+ For the hosted half, your policy is the **spine** (`spine/justify-existence.json` or per-target via
88
+ `hosted.targets[].spine`): each feature claims the tables it owns (`"payment_*"` prefix claims or exact
89
+ names — `{ "tables": [...], "tablePrefixes": [...] }` is accepted too), and explicit
90
+ `verdicts: { "table": "REMOVE" | "INVESTIGATE" }` (or `{ "verdict": "...", "reason": "..." }`) override
91
+ any claim.
92
+
93
+ Node ≥ 18. `node test/smoke.mjs` proves the engine end to end.
94
+
95
+ ## Roadmap
96
+
97
+ | Phase | What |
98
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
99
+ | **1 (shipped)** | portable core engine (runner + two-root discovery + `requires` gating + FIX_QUEUE/manifest) + 3 generic checks + the `your-checks/` extension layer |
100
+ | **2 (shipped)** | conformance-oracle reference mode (`runFamilyConformance` — reference + consensus), SARIF 2.1.0 (`findings.sarif`), DECISION_BRIEF flat finding list, `tsconfig-conformance` check, `dependency-freshness` check |
101
+ | **3 (shipped)** | the hosted/DB half — generic 5-verdict justify-existence judge + spine (claims/overrides) + read-only vault seam + the `db-justify-existence` governor (runs through the MCP vault against your own DB; review-only remediation) |
102
+ | **4 (shipped)** | self-update machinery — version-gated manifest pull + SHA-pinned apply with `.prev` rollback (mirrors the MCP loader) + the release publisher |
103
+ | **5 (shipped 2026-06-30)** | public release: npm (`connections-arkitect`) + the S3 core-update channel + the MCP `arkitect_run` tool (code half + hosted-via-vault) |
104
+ | **6 (next)** | grow the core, breadth-first: a **generalist base that does a good job checking everything, in-depth on nothing** — distill every concept from Connections' internal engines (generic code-check classes: dead code, duplication, circular deps, complexity, css, perf, security; generalized DB-governance blades: required roles, migration-chain gaps, TTL backstops) + the full conformance-oracle projection tier + more hosted target kinds (Supabase / generic Postgres). Depth stays in your-checks/ — the core is the floor, yours is the specialist tier |
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "extensionDir": "your-checks",
4
+ "update": { "channel": "stable", "pinnedVersion": null, "autoUpdate": true },
5
+ "checks": {
6
+ "oversized-files": { "warnLines": 800, "errorLines": 2500 },
7
+ "sibling-consensus": { "family": "package.json", "field": "license" }
8
+ },
9
+ "hosted": { "spine": "spine/justify-existence.json", "targets": [] }
10
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // The Architect CLI. `architect` / `architect --all` runs every applicable check; `architect --check <id>`
3
+ // runs one; `architect list` shows what's discovered. It discovers the shipped CORE (this package's
4
+ // src/checks/) AND the target repo's your-checks/ — the user's checks shadow core checks of the same id.
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { discoverAudits } from "../src/core/discovery.mjs";
9
+ import { detectProject } from "../src/core/project-detect.mjs";
10
+ import { runAudits } from "../src/core/runner.mjs";
11
+ import { initWorkspace } from "../src/core/init.mjs";
12
+ import { loadWorkspaceConfig } from "../src/core/config.mjs";
13
+ import { selfUpdate } from "../src/update/self-update.mjs";
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const PKG = resolve(HERE, "..");
17
+ const argv = process.argv.slice(2);
18
+ const flag = (f) => argv.includes(f);
19
+ const opt = (f, d) => {
20
+ const i = argv.indexOf(f);
21
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
22
+ };
23
+
24
+ const root = resolve(opt("--root", process.cwd()));
25
+
26
+ // `architect init` — the explicit OPT-IN install: lay the Architect's per-workspace layout under
27
+ // <root>/.arkitect/ (config + your-checks/ + spines/). Runs ONLY when asked; a normal run never
28
+ // creates .arkitect as a side effect. Idempotent — existing files are never overwritten.
29
+ if (argv.includes("init")) {
30
+ const { created, skipped } = initWorkspace(root, PKG);
31
+ for (const p of created) console.log(` + ${p}`);
32
+ for (const p of skipped) console.log(` = ${p} (already yours — untouched)`);
33
+ console.log(
34
+ created.length
35
+ ? `\n✅ Architect installed. Your files: .arkitect/ (config + your-checks + spines) — a core update never touches them.`
36
+ : `\n✅ Already installed — nothing overwritten.`,
37
+ );
38
+ process.exit(0);
39
+ }
40
+
41
+ const failOnDrift = flag("--fail-on-drift");
42
+ const failOnBounty = flag("--fail-on-bounty");
43
+ const only = opt("--check", null); // accepts a comma list: --check a,b,c
44
+ const wantList = argv.includes("list") || flag("--list");
45
+ const quiet = flag("--quiet"); // suppress discovery chatter (shadow/load notes); the summary line always prints
46
+ flag("--all"); // accepted for gate-script compatibility — running everything IS the default
47
+ const excludeGroups = (opt("--exclude-group", "") || "").split(",").filter(Boolean); // e.g. --exclude-group infra
48
+
49
+ // Anything the CLI itself doesn't consume is forwarded to the checks as ctx.checkArgs — specialized
50
+ // scripts pass check-specific flags (--section, --live, --format …) exactly as the legacy runner did.
51
+ const CONSUMED = new Set(["--root", "--check", "--group", "--out", "--exclude-group"]);
52
+ const checkArgs = [];
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a === "list" || a === "init" || ["--fail-on-drift", "--fail-on-bounty", "--list", "--quiet", "--all"].includes(a)) continue;
56
+ if (CONSUMED.has(a)) {
57
+ i++; // skip its value
58
+ continue;
59
+ }
60
+ checkArgs.push(a);
61
+ }
62
+
63
+ // Config (optional) lives in the TARGET repo — never in the core. One loader for every door:
64
+ // .arkitect/arkitect.config.json first, then arkitect.config.json, with `extends` resolution
65
+ // (inherit other config files — objects deep-merge, the extending file wins).
66
+ const { config, errors: cfgErrors } = loadWorkspaceConfig(root);
67
+ for (const ce of cfgErrors) console.error(`[architect] bad config ${ce.file}: ${ce.error}`);
68
+ // Extension checks mirror the config placement: an explicit config.extensionDirs (array) or
69
+ // config.extensionDir (string) wins; otherwise <root>/.arkitect/your-checks when it exists, else
70
+ // <root>/your-checks. Multiple extension roots let a workspace mount MORE check corpora alongside its
71
+ // own hand (e.g. a legacy check tree being converged) — later roots shadow earlier ones by id.
72
+ const extensionDirs = Array.isArray(config.extensionDirs)
73
+ ? config.extensionDirs
74
+ : [config.extensionDir || (existsSync(join(root, ".arkitect", "your-checks")) ? join(".arkitect", "your-checks") : "your-checks")];
75
+ const coreRoot = join(PKG, "src", "checks"); // shipped CORE — what self-update refreshes
76
+ const userRoots = extensionDirs.map((d) => join(root, d)); // the user's own checks — never touched by self-update
77
+
78
+ // Pull the freshest CORE from Connections before discovery — opt-in via config.update.autoUpdate (the
79
+ // shipped default config sets it true). Fail-soft: an update attempt NEVER breaks the run. No config
80
+ // opt-in ⇒ no network (so dev + CI stay offline). Inert until the core bucket exists at release.
81
+ if (config.update?.autoUpdate === true && !config.update?.pinnedVersion) {
82
+ try {
83
+ const installedVersion = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
84
+ const r = await selfUpdate({ installedVersion, srcDir: join(PKG, "src"), config });
85
+ if (r?.applied) console.error(`[architect] self-updated core → v${r.version} (${r.applied} files)`);
86
+ } catch {
87
+ /* an update attempt must never break a run */
88
+ }
89
+ }
90
+
91
+ const { audits, loadErrors, shadowed } = await discoverAudits([coreRoot, ...userRoots]);
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`);
94
+
95
+ if (wantList) {
96
+ for (const a of audits.sort((x, y) => x.id.localeCompare(y.id))) {
97
+ const where = a.__root === coreRoot ? "core" : "your-checks";
98
+ console.log(`${a.id.padEnd(28)} [${a.__group}/${where}] ${a.title}`);
99
+ }
100
+ process.exit(0);
101
+ }
102
+
103
+ const group = opt("--group", null); // run one discovery folder (e.g. architecture, css) — harness/CI sharding
104
+ const onlyIds = only
105
+ ? new Set(
106
+ only
107
+ .split(",")
108
+ .map((s) => s.trim())
109
+ .filter(Boolean),
110
+ )
111
+ : null;
112
+ let selected = onlyIds ? audits.filter((a) => onlyIds.has(a.id)) : audits;
113
+ if (group) selected = selected.filter((a) => a.__group === group);
114
+ if (excludeGroups.length) selected = selected.filter((a) => !excludeGroups.includes(a.__group));
115
+ if (onlyIds && selected.length < onlyIds.size) {
116
+ const found = new Set(selected.map((a) => a.id));
117
+ for (const id of onlyIds) if (!found.has(id)) console.error(`[architect] no check with id '${id}'. Try: architect list`);
118
+ }
119
+ if ((only || group) && selected.length === 0) {
120
+ console.error(`[architect] no check matching ${only ? `id(s) '${only}'` : `group '${group}'`}. Try: architect list`);
121
+ process.exit(2);
122
+ }
123
+
124
+ const project = detectProject(root);
125
+ const ctxBase = { root, config, project, options: { failOnDrift, failOnBounty, checkArgs }, vault: null };
126
+ const outDir = resolve(root, opt("--out", join("tmp", "arkitect"))); // --out: shard-safe output dir (harness/CI)
127
+ const { manifest } = await runAudits(selected, ctxBase, { outDir });
128
+
129
+ const t = manifest.telemetry || {};
130
+ 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}`;
132
+ console.log(manifest.pass.clean ? `✅ ${line}` : `⚠️ ${line}`);
133
+ console.log(" → tmp/arkitect/FIX_QUEUE.md · tmp/arkitect/manifest.json");
134
+
135
+ process.exit(failOnDrift && manifest.gatingErrors > 0 ? 1 : 0);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "connections-arkitect",
3
+ "version": "0.3.4",
4
+ "type": "module",
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
+ "bin": {
7
+ "arkitect": "./bin/arkitect.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/core/index.mjs",
11
+ "./finding": "./src/core/finding.mjs",
12
+ "./oracle": "./src/core/oracle/family-conformance.mjs",
13
+ "./package.json": "./package.json",
14
+ "./bin/arkitect.mjs": "./bin/arkitect.mjs",
15
+ "./src/core/init.mjs": "./src/core/init.mjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "your-checks",
21
+ "spine",
22
+ "arkitect.config.example.json",
23
+ "README.md"
24
+ ],
25
+ "keywords": [
26
+ "lint",
27
+ "audit",
28
+ "checks",
29
+ "governance",
30
+ "drift",
31
+ "connections",
32
+ "self-updating"
33
+ ],
34
+ "homepage": "https://studio.connections.icu/mcp",
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "license": "UNLICENSED"
42
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "appCodeRoots": ["src", "services", "lib"],
3
+ "features": [
4
+ { "name": "billing", "claims": ["invoices", "payments", "payment_*"] },
5
+ { "name": "identity", "claims": ["users", "sessions", "profile_*"] }
6
+ ],
7
+ "verdicts": { "legacy_temp_import": "REMOVE", "mystery_table": "INVESTIGATE" }
8
+ }
@@ -0,0 +1,92 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { rulesDocsDir as RULES_DOCS_DIR } from "@lunawerx/arkitect-core/paths";
5
+
6
+ export const audit = {
7
+ id: "rules-docs",
8
+ title: "Rules-Docs Catalog (codebase-agnostic)",
9
+ category: "meta",
10
+ defaultConfig: { enabled: true, includeInAll: false, outputPath: "tmp/audits/RULES_DOCS_CATALOG.md" },
11
+ async run(context) {
12
+ let entries;
13
+ try {
14
+ const files = (await fs.readdir(RULES_DOCS_DIR)).filter((name) => name.endsWith(".md")).sort();
15
+ entries = await Promise.all(
16
+ files.map(async (file) => {
17
+ const absolute = path.join(RULES_DOCS_DIR, file);
18
+ const content = await fs.readFile(absolute, "utf8");
19
+ return {
20
+ file,
21
+ relativePath: path.relative(context.root, absolute).replace(/\\/g, "/"),
22
+ sections: extractSections(content),
23
+ bytes: content.length,
24
+ };
25
+ }),
26
+ );
27
+ } catch (error) {
28
+ return {
29
+ failed: false,
30
+ report: `# Rules-Docs Catalog\n\nFailed to read ${RULES_DOCS_DIR}: ${error.message}\n`,
31
+ outputPath: context.checkConfig.outputPath,
32
+ jsonPayload: { entries: [], error: error.message },
33
+ };
34
+ }
35
+
36
+ const report = renderMarkdown(entries);
37
+
38
+ return {
39
+ failed: false,
40
+ report,
41
+ outputPath: context.checkConfig.outputPath,
42
+ jsonPayload: { totalDecks: entries.length, totalSections: entries.reduce((sum, entry) => sum + entry.sections.length, 0), entries },
43
+ };
44
+ },
45
+ };
46
+
47
+ function extractSections(markdown) {
48
+ return markdown
49
+ .split("\n")
50
+ .filter((line) => /^##\s+/.test(line))
51
+ .map((line) => line.replace(/^##\s+/, "").trim());
52
+ }
53
+
54
+ function renderMarkdown(entries) {
55
+ const totalSections = entries.reduce((sum, entry) => sum + entry.sections.length, 0);
56
+ const lines = [
57
+ "# Rules-Docs Catalog",
58
+ "",
59
+ "Cross-cutting engineering rule decks shipped with `arkitect-core/rules-docs/`.",
60
+ "These are codebase-agnostic markdown references; project policies can opt in to",
61
+ "any subset by importing them directly.",
62
+ "",
63
+ `- Decks: ${entries.length}`,
64
+ `- Sections (\`## ...\`): ${totalSections}`,
65
+ "",
66
+ "## Decks",
67
+ "",
68
+ "| Deck | Sections | Path |",
69
+ "| --- | ---: | --- |",
70
+ ];
71
+
72
+ for (const entry of entries) {
73
+ const title = entry.file.replace(/\.md$/, "");
74
+ lines.push(`| ${title} | ${entry.sections.length} | \`${entry.relativePath}\` |`);
75
+ }
76
+
77
+ lines.push("", "## Section index", "");
78
+ for (const entry of entries) {
79
+ const title = entry.file.replace(/\.md$/, "");
80
+ lines.push(`### ${title}`, "");
81
+ if (entry.sections.length === 0) {
82
+ lines.push("_no `##` sections detected_", "");
83
+ continue;
84
+ }
85
+ for (const section of entry.sections) {
86
+ lines.push(`- ${section}`);
87
+ }
88
+ lines.push("");
89
+ }
90
+
91
+ return `${lines.join("\n")}\n`;
92
+ }
@@ -0,0 +1,82 @@
1
+ // CORE check — advisory. Flags package.json files that depend on unpinned version ranges (`*`, `latest`,
2
+ // `>=x`) or that have dependencies declared but no lockfile in the same directory. Unpinned ranges mean
3
+ // your build is not reproducible; missing lockfiles mean you can't prove it either. Advisory by default
4
+ // so it never blocks a fork whose own dep conventions differ.
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { relative, basename, dirname, join } from "node:path";
7
+ import { createFinding } from "../../core/finding.mjs";
8
+ import { walkFiles } from "../../core/fs-walk.mjs";
9
+
10
+ /** Unpinned range patterns — `*`, `latest`, `>=x`, `>x`. Does NOT flag `^` / `~` (those are pinned ranges). */
11
+ const UNPINNED_RE = /^(\*|latest|>=?)/;
12
+
13
+ /** Lockfile names we recognize. */
14
+ const LOCKFILES = ["package-lock.json", "bun.lockb", "yarn.lock", "pnpm-lock.yaml"];
15
+
16
+ export const audit = {
17
+ id: "dependency-freshness",
18
+ title: "Dependencies use pinned version ranges",
19
+ category: "dependencies",
20
+ domain: "code",
21
+ requires: { ecosystems: ["npm"] },
22
+ gating: false,
23
+ defaultConfig: {},
24
+ async run(ctx) {
25
+ const { root } = ctx;
26
+ const findings = [];
27
+
28
+ for (const file of walkFiles(root)) {
29
+ if (basename(file) !== "package.json") continue;
30
+
31
+ let pkg;
32
+ try {
33
+ pkg = JSON.parse(readFileSync(file, "utf8"));
34
+ } catch {
35
+ continue;
36
+ }
37
+
38
+ const relFile = relative(root, file);
39
+ const dir = dirname(file);
40
+
41
+ // 1. Check for unpinned ranges in dependencies + devDependencies.
42
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
43
+ for (const [name, version] of Object.entries(allDeps)) {
44
+ if (typeof version === "string" && UNPINNED_RE.test(version.trim())) {
45
+ findings.push(
46
+ createFinding({
47
+ id: "unpinned-dependency",
48
+ title: `Unpinned dependency: ${name}`,
49
+ severity: "warning",
50
+ file: relFile,
51
+ message: `'${name}': "${version}" is unpinned (use an exact version or a caret/tilde range for reproducibility)`,
52
+ fix: `Replace "${version}" with a pinned version such as "1.2.3" or "^1.2.3".`,
53
+ }),
54
+ );
55
+ }
56
+ }
57
+
58
+ // 2. Check for missing lockfile when any deps are declared.
59
+ 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
+ }
74
+ }
75
+ }
76
+
77
+ const report = findings.length
78
+ ? `# Dependency freshness\n\n${findings.map((f) => `- WARN ${f.file} — ${f.message}`).join("\n")}\n\nErrors: 0\nWarnings: ${findings.length}\n`
79
+ : "";
80
+ return { failed: false, findings, report };
81
+ },
82
+ };
@@ -0,0 +1,112 @@
1
+ // CORE check — generic, runs on any repo. High-confidence committed-secret patterns only (low false
2
+ // positives by design; broader entropy scanning is a Phase-2 refinement).
3
+ import { readFileSync } from "node:fs";
4
+ import { relative, extname } from "node:path";
5
+ import { createFinding } from "../../core/finding.mjs";
6
+ import { walkFiles } from "../../core/fs-walk.mjs";
7
+
8
+ const SCAN_EXT = new Set([
9
+ ".ts",
10
+ ".tsx",
11
+ ".js",
12
+ ".jsx",
13
+ ".mjs",
14
+ ".cjs",
15
+ ".py",
16
+ ".rs",
17
+ ".go",
18
+ ".rb",
19
+ ".java",
20
+ ".php",
21
+ ".vue",
22
+ ".json",
23
+ ".yaml",
24
+ ".yml",
25
+ ".env",
26
+ ".sh",
27
+ ".toml",
28
+ ".ini",
29
+ ".cfg",
30
+ ".tf",
31
+ ]);
32
+ // A PEM BEGIN marker alone is not a leak — UI hint/placeholder copy legitimately shows the format
33
+ // ("-----BEGIN … KEY-----\n…paste the whole key file…\n-----END … KEY-----"). Only flag when actual
34
+ // key MATERIAL is present: a run of 40+ base64 chars between the BEGIN and END markers (same source
35
+ // line for embedded/escaped string literals, or within the next 50 physical lines for real PEM
36
+ // bodies). Fails toward FLAGGING: any parse surprise returns true so the rule can never be disarmed.
37
+ function confirmKeyMaterial(lines, i) {
38
+ const BASE64_RUN = /[A-Za-z0-9+/=]{40,}/;
39
+ const BEGIN = /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/;
40
+ const END = /-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/;
41
+ const bm = lines[i].match(BEGIN);
42
+ if (!bm) return true;
43
+ const rest = lines[i].slice(bm.index + bm[0].length);
44
+ const endSame = rest.match(END);
45
+ if (endSame) return BASE64_RUN.test(rest.slice(0, endSame.index));
46
+ if (BASE64_RUN.test(rest)) return true;
47
+ for (let j = i + 1; j < lines.length && j <= i + 50; j++) {
48
+ const em = lines[j].match(END);
49
+ if (em) return BASE64_RUN.test(lines[j].slice(0, em.index));
50
+ if (BASE64_RUN.test(lines[j])) return true;
51
+ }
52
+ return false;
53
+ }
54
+ const PATTERNS = [
55
+ { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/, title: "AWS access key id committed" },
56
+ {
57
+ id: "private-key-block",
58
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
59
+ title: "Private key block committed",
60
+ confirm: confirmKeyMaterial,
61
+ },
62
+ { id: "aws-secret-key", re: /aws_secret_access_key["'\s:=]+["'][A-Za-z0-9/+=]{40}["']/i, title: "AWS secret access key committed" },
63
+ { id: "slack-token", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/, title: "Slack token committed" },
64
+ { id: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/, title: "Google API key committed" },
65
+ ];
66
+
67
+ export const audit = {
68
+ id: "no-secrets-committed",
69
+ title: "No committed secrets",
70
+ category: "security",
71
+ domain: "code",
72
+ requires: {}, // any repo
73
+ gating: true,
74
+ defaultConfig: { maxFileBytes: 2_000_000 },
75
+ async run(ctx) {
76
+ const { root, checkConfig } = ctx;
77
+ const findings = [];
78
+ for (const file of walkFiles(root)) {
79
+ if (!SCAN_EXT.has(extname(file))) continue;
80
+ let text;
81
+ try {
82
+ const buf = readFileSync(file);
83
+ if (buf.length > checkConfig.maxFileBytes) continue;
84
+ text = buf.toString("utf8");
85
+ } catch {
86
+ continue;
87
+ }
88
+ const lines = text.split("\n");
89
+ for (let i = 0; i < lines.length; i++) {
90
+ for (const p of PATTERNS) {
91
+ if (p.re.test(lines[i]) && (!p.confirm || p.confirm(lines, i))) {
92
+ findings.push(
93
+ createFinding({
94
+ id: p.id,
95
+ title: p.title,
96
+ severity: "error",
97
+ file: relative(root, file),
98
+ line: i + 1,
99
+ message: `${p.title} at ${relative(root, file)}:${i + 1}`,
100
+ fix: "Remove it, rotate the credential, and load it from an env var / secret manager.",
101
+ }),
102
+ );
103
+ }
104
+ }
105
+ }
106
+ }
107
+ const report = findings.length
108
+ ? `# No committed secrets\n\n${findings.map((f) => `- ERROR ${f.file}:${f.line} — ${f.title}`).join("\n")}\n\nErrors: ${findings.length}\nWarnings: 0\n`
109
+ : "";
110
+ return { failed: findings.length > 0, findings, report };
111
+ },
112
+ };