total-recall-brain 3.40.1 → 3.40.2
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 +152 -41
- package/bin/total-recall.mjs +16 -76
- package/docs/reference/cli-reference.md +10 -10
- package/extension/background.js +6 -2
- package/extension/config.js +8 -0
- package/extension/lib/brain-client.js +29 -4
- package/extension/options/options.html +1 -0
- package/extension/sidepanel/sidepanel.html +1 -0
- package/frontend/dist/assets/{index-CvQRdT2j.css → index-AGUes-VG.css} +1 -1
- package/frontend/dist/assets/index-Bt5iVPZj.js +5618 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/src/api/keys.ts +9 -2
- package/frontend/src/api/plugins.ts +4 -0
- package/frontend/src/components/plugins/card/CardEngine.tsx +2 -1
- package/frontend/src/components/plugins/card/card.css +1 -1
- package/frontend/src/pages/IntegrationsPage.tsx +315 -8
- package/frontend/src/pages/PluginsPage.tsx +532 -87
- package/package.json +6 -5
- package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +1 -1
- package/scaffold/.agent/skills/meta-harness/references/optimized/section-09.md +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +10 -10
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +0 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/browser-session.mjs +309 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +44 -34
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/google-oauth.mjs +6 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-rotation-recipes.mjs +118 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/auth.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +24 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +164 -123
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +65 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +76 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +19 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +290 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +446 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +152 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +71 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +36 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +35 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +175 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/README.md +84 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/cli.mjs +79 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/check.mjs +6 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/report.mjs +6 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/run-core.mjs +27 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/snapshot.mjs +163 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +757 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/config.schema.json +86 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/detect.mjs +89 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report-data.mjs +145 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report.mjs +210 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/state.mjs +20 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +2869 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +28 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +186 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +272 -0
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/code-quality-report.mjs +129 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/README.md +73 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/cli.mjs +375 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/examples/commands/echo-tool.mjs +23 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/examples/commands/group.mjs +262 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/examples/commands/project-id.mjs +74 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +2869 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +31 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +221 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/schemas/cli_command.schema.json +107 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/skills/composable-cli/SKILL.md +63 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/command.mjs +313 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/compile.mjs +19 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/compose.mjs +144 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/contract.mjs +110 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/library.mjs +23 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/registry.mjs +192 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/resolve.mjs +221 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/risk.mjs +126 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/ssss-documents.mjs +29 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +273 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/README.md +20 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/cli.mjs +310 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +2826 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +62 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +297 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +43 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/cli.mjs +258 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4005 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +20 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +125 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +278 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +9 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +6 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4721 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -21
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +14 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +5 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +4 -13
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-access-verify.mjs +22 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-files.mjs +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +2 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/test-support/memory-root.mjs +14 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +35 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4097 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +12 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +165 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/ingest.mjs +127 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +279 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +539 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/LICENSE +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/README.md +67 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +932 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3256 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +25 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +297 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/scripts/build-ui.mjs +4 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/skills/security/SKILL.md +37 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/src/android.mjs +21 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/src/devices.mjs +94 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/src/machines.mjs +164 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +41 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/src/windows.mjs +42 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +95 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/panel.js +98 -0
- package/scaffold/.agent/skills/total-recall/references/auth.md +1 -1
- package/src/cli/brief.mjs +33 -9
- package/src/cli/command.mjs +2 -7
- package/src/cli/config.mjs +1 -0
- package/src/cli/connect.mjs +5 -0
- package/src/cli/context.mjs +2 -1
- package/src/cli/daemon.mjs +36 -1
- package/src/cli/doctor.mjs +6 -0
- package/src/cli/harness.mjs +3 -0
- package/src/cli/help.mjs +26 -0
- package/src/cli/init.mjs +4 -4
- package/src/cli/memory-entry.mjs +1 -0
- package/src/cli/memory-init.mjs +2 -0
- package/src/cli/plugin/index.mjs +11 -0
- package/src/cli/plugin/install.mjs +2 -1
- package/src/cli/rebuild.mjs +3 -1
- package/src/cli/startup.mjs +2 -1
- package/src/cli/update.mjs +19 -0
- package/src/core/automatic-brain-updates.mjs +67 -0
- package/src/core/brain-updates.mjs +21 -2
- package/src/core/cli-commands.mjs +71 -0
- package/src/core/cli-maintenance.mjs +45 -0
- package/src/core/cli-quick-reference.mjs +20 -0
- package/src/core/cli-semantic-index.mjs +722 -0
- package/src/core/context-compiler.mjs +27 -1
- package/src/core/crons.mjs +2 -13
- package/src/core/daemon-control.mjs +6 -4
- package/src/core/daemon-loop.mjs +12 -8
- package/src/core/default-plugins.mjs +5 -0
- package/src/core/global-brain-updates.mjs +39 -0
- package/src/core/mesh-brain-updates.mjs +30 -0
- package/src/core/meta-harness.mjs +108 -0
- package/src/core/plugin-dependencies.mjs +2 -1
- package/src/core/plugin-loader.mjs +2 -1
- package/src/core/plugin-runner.mjs +12 -4
- package/src/core/plugin-setup.mjs +20 -0
- package/src/core/plugin-store.mjs +22 -4
- package/src/core/plugin-tasks.mjs +1 -1
- package/src/core/project-brain.mjs +2 -0
- package/src/core/project-status.mjs +64 -0
- package/src/core/required-plugins.mjs +46 -0
- package/src/core/scaffold-plugins.mjs +17 -3
- package/src/core/source-brain-updates.mjs +47 -0
- package/src/core/startup-health.mjs +5 -3
- package/src/core/surface.mjs +44 -10
- package/src/server/routes/extension.mjs +53 -8
- package/templates/onboarding-interview.md +1 -1
- package/frontend/dist/assets/index-SOVUlZqm.js +0 -5596
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* code-quality / core/check.mjs — ONE-SHOT quality check (plugin-owned core).
|
|
4
|
+
*
|
|
5
|
+
* Reads the repo layer's config.json (a projection of the skill_config record
|
|
6
|
+
* that `total-recall skill config code-quality …` maintains) and writes
|
|
7
|
+
* reports/ next to it. Upgrades replace this file; never edit it in a repo.
|
|
8
|
+
*
|
|
9
|
+
* Runs the repo's real typecheck/lint commands exactly once, writes a report,
|
|
10
|
+
* and exits. There is no loop, no PID daemon, and nothing that respawns itself.
|
|
11
|
+
*
|
|
12
|
+
* Designed to be launched as a BACKGROUND job by the agent harness:
|
|
13
|
+
* Bash(command: "node .agent/skills/code-quality/core/check.mjs",
|
|
14
|
+
* run_in_background: true)
|
|
15
|
+
*
|
|
16
|
+
* Why one-shot: a detached forever-loop has no causal relationship to the edit
|
|
17
|
+
* you just made, so its report can only answer "what was true at some point".
|
|
18
|
+
* A run you launched after your edit answers "what is true now". See
|
|
19
|
+
* references/architecture.md.
|
|
20
|
+
*
|
|
21
|
+
* Invariants:
|
|
22
|
+
* - Locking is FAIL-CLOSED. If liveness cannot be verified, the lock is
|
|
23
|
+
* honored and we refuse to start. Refusing is recoverable; two writers
|
|
24
|
+
* competing over one report file are not.
|
|
25
|
+
* - Checks run strictly sequentially, one child process at a time.
|
|
26
|
+
* - Every child is killed on exit, including on SIGINT/SIGTERM.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import {
|
|
32
|
+
readFileSync, writeFileSync, openSync, writeSync, closeSync,
|
|
33
|
+
unlinkSync, existsSync, mkdirSync, statSync, readdirSync, realpathSync
|
|
34
|
+
} from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { stateDir } from './state.mjs';
|
|
39
|
+
|
|
40
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
41
|
+
const __dirname = path.dirname(__filename);
|
|
42
|
+
const SKILL_DIR = stateDir();
|
|
43
|
+
// Lock identity: the tail of the path this run was launched through, so a repo
|
|
44
|
+
// shim (scripts/check.mjs importing core/check.mjs) and a direct core launch
|
|
45
|
+
// are each recognized by the process that holds the lock.
|
|
46
|
+
const ENTRY_TAIL = (() => {
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync(process.argv[1]).split(path.sep).slice(-3).join(path.sep);
|
|
49
|
+
} catch {
|
|
50
|
+
return path.join('code-quality', 'core', 'check.mjs');
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
const argv = process.argv.slice(2);
|
|
55
|
+
const hasFlag = (f) => argv.includes(f);
|
|
56
|
+
const flagValue = (f) => {
|
|
57
|
+
const i = argv.indexOf(f);
|
|
58
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const STEAL_LOCK = hasFlag('--steal-lock');
|
|
62
|
+
const NO_GLOBAL_LOCK = hasFlag('--no-global-lock');
|
|
63
|
+
const ONLY = flagValue('--only');
|
|
64
|
+
const TIER = flagValue('--tier') || 'fast';
|
|
65
|
+
|
|
66
|
+
// Tiers keep the default run laptop-safe. Each repo decides what belongs in
|
|
67
|
+
// `fast` (cheap, local), `full` (conformance suites, test runs), and `remote`
|
|
68
|
+
// (work that must run on a dedicated test machine, never a small laptop).
|
|
69
|
+
const TIER_ORDER = { fast: 1, full: 2, remote: 3 };
|
|
70
|
+
|
|
71
|
+
// 'git' normally; 'filesystem' when the workspace has no .git (e.g. an rsynced
|
|
72
|
+
// remote test copy). grep gates then walk the tree instead of scanning nothing.
|
|
73
|
+
let sourceInventoryMode = 'git';
|
|
74
|
+
|
|
75
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
|
|
78
|
+
|
|
79
|
+
function loadConfig() {
|
|
80
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
81
|
+
fail(
|
|
82
|
+
`No config.json at ${CONFIG_PATH}\n` +
|
|
83
|
+
`Run: total-recall skill config code-quality init`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
let cfg;
|
|
87
|
+
try {
|
|
88
|
+
cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
fail(`config.json is not valid JSON: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(cfg.checks) || cfg.checks.length === 0) {
|
|
93
|
+
fail('config.json defines no checks. Add one: total-recall skill config code-quality gate add …');
|
|
94
|
+
}
|
|
95
|
+
// Validate up front so a malformed check fails loudly at startup rather than
|
|
96
|
+
// halfway through a run that has already burned minutes of compile time.
|
|
97
|
+
const problems = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
cfg.checks.forEach((c, i) => {
|
|
100
|
+
const where = `checks[${i}]${c.id ? ` (${c.id})` : ''}`;
|
|
101
|
+
if (!c.id) problems.push(`${where}: missing "id"`);
|
|
102
|
+
else if (seen.has(c.id)) problems.push(`${where}: duplicate id`);
|
|
103
|
+
else seen.add(c.id);
|
|
104
|
+
if (c.kind === 'grep-forbid') {
|
|
105
|
+
if (!Array.isArray(c.patterns) || c.patterns.length === 0) problems.push(`${where}: grep-forbid needs a non-empty "patterns" array`);
|
|
106
|
+
else c.patterns.forEach((p, j) => {
|
|
107
|
+
if (!p.pattern) problems.push(`${where}.patterns[${j}]: missing "pattern"`);
|
|
108
|
+
else if (/^\(\?[a-z]+\)/.test(p.pattern)) {
|
|
109
|
+
problems.push(`${where}.patterns[${j}]: JS RegExp has no inline (?i) flags — use "flags": "i" instead`);
|
|
110
|
+
} else {
|
|
111
|
+
try { new RegExp(p.pattern, p.flags || ''); }
|
|
112
|
+
catch (e) { problems.push(`${where}.patterns[${j}]: invalid regex — ${e.message}`); }
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
} else if (!Array.isArray(c.cmd) || c.cmd.length === 0) {
|
|
116
|
+
problems.push(`${where}: needs "cmd" (array) or kind:"grep-forbid"`);
|
|
117
|
+
}
|
|
118
|
+
if (c.tier && !['fast', 'full', 'remote'].includes(c.tier)) problems.push(`${where}: unknown tier "${c.tier}"`);
|
|
119
|
+
});
|
|
120
|
+
if (problems.length) fail(`config.json is invalid:\n - ${problems.join('\n - ')}`);
|
|
121
|
+
return cfg;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fail(msg, code = 1) {
|
|
125
|
+
console.error(` [code-quality] ${msg}`);
|
|
126
|
+
process.exit(code);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
const REPO_ROOT = path.resolve(
|
|
131
|
+
flagValue('--repo') || config.repoRoot || path.resolve(SKILL_DIR, '../../..')
|
|
132
|
+
);
|
|
133
|
+
const REPORT_DIR = path.join(SKILL_DIR, 'reports');
|
|
134
|
+
const REPORT_JSON = path.join(REPORT_DIR, 'latest.json');
|
|
135
|
+
const REPORT_TXT = path.join(REPORT_DIR, 'latest.txt');
|
|
136
|
+
const LOCK_PATH = path.join(SKILL_DIR, '.check.lock');
|
|
137
|
+
const GLOBAL_LOCK_PATH = path.join(os.homedir(), '.agent', 'skills', 'code-quality', '.global-check.lock');
|
|
138
|
+
const STALE_AFTER_MS = (config.lock?.staleAfterMinutes ?? 45) * 60_000;
|
|
139
|
+
|
|
140
|
+
// ─── Locking (fail-closed) ────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A lock is honored only when the recorded PID is alive AND its command line
|
|
144
|
+
* still looks like this program. If the command line cannot be read at all,
|
|
145
|
+
* we honor the lock rather than assuming it is stale — the opposite of the
|
|
146
|
+
* v2 daemon, whose `ps` scan failed with EPERM on every run and started anyway.
|
|
147
|
+
*/
|
|
148
|
+
function holderState(meta) {
|
|
149
|
+
if (!meta || typeof meta.pid !== 'number') return 'unverifiable';
|
|
150
|
+
try {
|
|
151
|
+
process.kill(meta.pid, 0);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (err.code === 'ESRCH') return 'dead';
|
|
154
|
+
if (err.code === 'EPERM') return 'alive-foreign'; // exists, owned by someone else
|
|
155
|
+
return 'unverifiable';
|
|
156
|
+
}
|
|
157
|
+
let cmdline;
|
|
158
|
+
try {
|
|
159
|
+
cmdline = execFileSync('ps', ['-p', String(meta.pid), '-o', 'command='], {
|
|
160
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
return 'unverifiable'; // <- fail CLOSED
|
|
164
|
+
}
|
|
165
|
+
return cmdline.includes(meta.entryTail ?? ENTRY_TAIL) ? 'alive' : 'recycled';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function acquireLock(lockPath, label) {
|
|
169
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
170
|
+
const meta = {
|
|
171
|
+
pid: process.pid,
|
|
172
|
+
entryTail: ENTRY_TAIL,
|
|
173
|
+
skillDir: SKILL_DIR,
|
|
174
|
+
repoRoot: REPO_ROOT,
|
|
175
|
+
startedAt: new Date().toISOString(),
|
|
176
|
+
host: os.hostname()
|
|
177
|
+
};
|
|
178
|
+
const payload = JSON.stringify(meta, null, 2);
|
|
179
|
+
|
|
180
|
+
const tryCreate = () => {
|
|
181
|
+
const fd = openSync(lockPath, 'wx'); // O_CREAT|O_EXCL — atomic
|
|
182
|
+
writeSync(fd, payload);
|
|
183
|
+
closeSync(fd);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
tryCreate();
|
|
188
|
+
return meta;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err.code !== 'EEXIST') throw err;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let held = null;
|
|
194
|
+
try {
|
|
195
|
+
held = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
196
|
+
} catch {
|
|
197
|
+
held = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const state = holderState(held);
|
|
201
|
+
const ageMs = held?.startedAt ? Date.now() - Date.parse(held.startedAt) : Infinity;
|
|
202
|
+
const expired = Number.isFinite(ageMs) && ageMs > STALE_AFTER_MS;
|
|
203
|
+
|
|
204
|
+
if (STEAL_LOCK && state === 'alive') {
|
|
205
|
+
// The machine-wide lock is shared by every repo on this host. A verified
|
|
206
|
+
// live holder is another repo's (or this repo's) real run: stealing its
|
|
207
|
+
// lock, or killing it, silently destroys that run's result.
|
|
208
|
+
fail(
|
|
209
|
+
`--steal-lock refused: the ${label} lock is held by a verified running check ` +
|
|
210
|
+
`(pid ${held.pid}, ${held.repoRoot ?? 'unknown repo'}, started ${held.startedAt ?? 'unknown'}).\n` +
|
|
211
|
+
` Wait for it to finish, or stop that process yourself if you own it.`,
|
|
212
|
+
3
|
|
213
|
+
);
|
|
214
|
+
} else if (STEAL_LOCK) {
|
|
215
|
+
console.error(` [code-quality] --steal-lock: taking ${label} lock from pid ${held?.pid ?? '?'} (state=${state}).`);
|
|
216
|
+
} else if (state === 'dead' || state === 'recycled') {
|
|
217
|
+
console.error(` [code-quality] Clearing stale ${label} lock (pid ${held?.pid ?? '?'}, state=${state}).`);
|
|
218
|
+
} else if (state === 'unverifiable' && expired) {
|
|
219
|
+
console.error(` [code-quality] Clearing ${label} lock: unverifiable and older than ${STALE_AFTER_MS / 60000}min.`);
|
|
220
|
+
} else {
|
|
221
|
+
fail(
|
|
222
|
+
`A check is already running (${label} lock, pid ${held?.pid ?? '?'}, state=${state}, ` +
|
|
223
|
+
`started ${held?.startedAt ?? 'unknown'}).\n` +
|
|
224
|
+
` This is deliberate: only one check may run at a time.\n` +
|
|
225
|
+
` If you are certain it is dead: node ${path.relative(process.cwd(), __filename)} --steal-lock`,
|
|
226
|
+
3
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try { unlinkSync(lockPath); } catch { /* raced away */ }
|
|
231
|
+
tryCreate();
|
|
232
|
+
return meta;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const heldLocks = [];
|
|
236
|
+
function releaseLocks() {
|
|
237
|
+
for (const lockPath of heldLocks.splice(0)) {
|
|
238
|
+
try {
|
|
239
|
+
const meta = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
240
|
+
if (meta.pid !== process.pid) continue; // never delete someone else's lock
|
|
241
|
+
} catch { /* unreadable — fall through and remove our own best guess */ }
|
|
242
|
+
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Child process lifecycle ──────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
let currentChild = null;
|
|
249
|
+
let shuttingDown = false;
|
|
250
|
+
|
|
251
|
+
function killCurrentChild(signal = 'SIGTERM') {
|
|
252
|
+
if (!currentChild || currentChild.killed) return;
|
|
253
|
+
try { process.kill(-currentChild.pid, signal); } catch { /* no group */ }
|
|
254
|
+
try { currentChild.kill(signal); } catch { /* already gone */ }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function shutdown(code) {
|
|
258
|
+
if (shuttingDown) return;
|
|
259
|
+
shuttingDown = true;
|
|
260
|
+
killCurrentChild('SIGTERM');
|
|
261
|
+
setTimeout(() => killCurrentChild('SIGKILL'), 2000).unref();
|
|
262
|
+
releaseLocks();
|
|
263
|
+
process.exit(code);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
process.on('exit', releaseLocks);
|
|
267
|
+
process.on('SIGINT', () => shutdown(130));
|
|
268
|
+
process.on('SIGTERM', () => shutdown(143));
|
|
269
|
+
process.on('uncaughtException', (err) => {
|
|
270
|
+
console.error(` [code-quality] ${err.stack || err.message}`);
|
|
271
|
+
shutdown(1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ─── Parsers ──────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const PARSERS = {
|
|
277
|
+
tsc(line) {
|
|
278
|
+
const m = line.match(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.*)$/);
|
|
279
|
+
return m && { file: m[1].trim(), line: +m[2], col: +m[3], code: m[4], message: m[5].trim() };
|
|
280
|
+
},
|
|
281
|
+
'eslint-stylish'(line, state) {
|
|
282
|
+
const header = line.match(/^(\/.*\S)\s*$/);
|
|
283
|
+
if (header && !line.includes(':')) { state.file = header[1]; return null; }
|
|
284
|
+
if (/^\s*\//.test(line) && !/\d+:\d+/.test(line)) { state.file = line.trim(); return null; }
|
|
285
|
+
const m = line.match(/^\s*(\d+):(\d+)\s+(error|warning)\s+(.*?)\s\s+([\w@/-]+)\s*$/);
|
|
286
|
+
if (m && state.file) {
|
|
287
|
+
return { file: state.file, line: +m[1], col: +m[2], code: m[5], message: m[4].trim(), severity: m[3] };
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
},
|
|
291
|
+
'eslint-compact'(line) {
|
|
292
|
+
const m = line.match(/^(.+?):\s*line\s+(\d+),\s*col\s+(\d+),\s*(Error|Warning)\s*-\s*(.*?)(?:\s+\(([\w@/-]+)\))?$/i);
|
|
293
|
+
return m && {
|
|
294
|
+
file: m[1].trim(), line: +m[2], col: +m[3],
|
|
295
|
+
code: m[6] || 'eslint', message: m[5].trim(), severity: m[4].toLowerCase()
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
flake8(line) {
|
|
299
|
+
const m = line.match(/^(.+?):(\d+):(\d+):\s+([A-Z]+\d+)\s+(.*)$/);
|
|
300
|
+
return m && { file: m[1].trim(), line: +m[2], col: +m[3], code: m[4], message: m[5].trim() };
|
|
301
|
+
},
|
|
302
|
+
mypy(line) {
|
|
303
|
+
const m = line.match(/^(.+?):(\d+):(?:(\d+):)?\s+error:\s+(.*?)(?:\s+\[([\w-]+)\])?$/);
|
|
304
|
+
return m && {
|
|
305
|
+
file: m[1].trim(), line: +m[2], col: m[3] ? +m[3] : 0,
|
|
306
|
+
code: m[5] || 'mypy', message: m[4].trim()
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
/**
|
|
310
|
+
* Biome, via `--reporter=github`. Biome's default pretty output is a
|
|
311
|
+
* multi-line box drawing that also truncates at --max-diagnostics, so a gate
|
|
312
|
+
* parsing it would silently under-report. The github reporter is one
|
|
313
|
+
* fully-structured line per diagnostic:
|
|
314
|
+
*
|
|
315
|
+
* ::warning title=lint/style/useConst,file=a.ts,line=3,endLine=3,col=7,endColumn=9::Use const.
|
|
316
|
+
*
|
|
317
|
+
* Always pair it with --max-diagnostics=none.
|
|
318
|
+
*/
|
|
319
|
+
biome(line) {
|
|
320
|
+
const m = line.match(/^::(error|warning|notice)\s+(.+?)::(.*)$/);
|
|
321
|
+
if (!m) return null;
|
|
322
|
+
const attrs = {};
|
|
323
|
+
for (const kv of m[2].split(',')) {
|
|
324
|
+
const i = kv.indexOf('=');
|
|
325
|
+
if (i > 0) attrs[kv.slice(0, i)] = kv.slice(i + 1);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
file: attrs.file || '?',
|
|
329
|
+
line: +(attrs.line || 0),
|
|
330
|
+
col: +(attrs.col || 0),
|
|
331
|
+
code: attrs.title || 'biome',
|
|
332
|
+
message: m[3].trim(),
|
|
333
|
+
severity: m[1] === 'notice' ? 'info' : m[1]
|
|
334
|
+
};
|
|
335
|
+
},
|
|
336
|
+
black(line) {
|
|
337
|
+
const m = line.match(/^would reformat\s+(.+)$/);
|
|
338
|
+
return m && { file: m[1].trim(), line: 1, col: 1, code: 'BLACK-FORMAT', message: 'File needs black formatting.' };
|
|
339
|
+
},
|
|
340
|
+
generic(line) {
|
|
341
|
+
const m = line.match(/^(.+?):(\d+):(?:(\d+):)?\s*(?:error|ERROR)[:\s]+(.*)$/);
|
|
342
|
+
return m && { file: m[1].trim(), line: +m[2], col: m[3] ? +m[3] : 0, code: 'error', message: m[4].trim() };
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
function parseOutput(text, parserName, checkId) {
|
|
347
|
+
const parse = PARSERS[parserName] || PARSERS.generic;
|
|
348
|
+
const state = {};
|
|
349
|
+
const findings = [];
|
|
350
|
+
for (const raw of text.split('\n')) {
|
|
351
|
+
const line = raw.replace(/\[[0-9;]*m/g, '').trimEnd();
|
|
352
|
+
if (!line) continue;
|
|
353
|
+
let hit;
|
|
354
|
+
try { hit = parse(line, state); } catch { hit = null; }
|
|
355
|
+
if (hit) findings.push({ ...hit, check: checkId, severity: hit.severity || 'error' });
|
|
356
|
+
}
|
|
357
|
+
return findings;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Source fingerprint (causality) ───────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The report records when the run STARTED. report.mjs compares that against the
|
|
364
|
+
* mtime of every tracked source file; anything modified after the start is
|
|
365
|
+
* flagged as not-yet-covered. This is what makes "is this report stale?" a
|
|
366
|
+
* question with a real answer instead of a warning banner.
|
|
367
|
+
*/
|
|
368
|
+
/**
|
|
369
|
+
* Tracked files, filtered to an extension set.
|
|
370
|
+
*
|
|
371
|
+
* `extensions` exists because this used to filter by the GLOBAL
|
|
372
|
+
* config.sourceExtensions only, so a check declaring its own extensions could
|
|
373
|
+
* merely narrow that set and never widen it. A grep-forbid rule over .md/.yaml
|
|
374
|
+
* therefore matched zero files and reported a clean pass — a gate that scans
|
|
375
|
+
* nothing reads exactly like a gate that found nothing.
|
|
376
|
+
*/
|
|
377
|
+
function trackedSources(extensions) {
|
|
378
|
+
const exts = new Set(extensions || config.sourceExtensions || ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.vue', '.svelte']);
|
|
379
|
+
try {
|
|
380
|
+
const out = execFileSync('git', ['ls-files', '-z'], {
|
|
381
|
+
cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 20_000,
|
|
382
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
383
|
+
});
|
|
384
|
+
return out.split('\0').filter((f) => f && exts.has(path.extname(f)));
|
|
385
|
+
} catch {
|
|
386
|
+
// No git metadata (an rsynced test copy): walk the tree so grep gates fail
|
|
387
|
+
// closed instead of scanning zero files.
|
|
388
|
+
sourceInventoryMode = 'filesystem';
|
|
389
|
+
const skipped = new Set(['.git', '.next', '.pnpm-store', '.turbo', '.venv', '__pycache__', 'coverage', 'dist', 'node_modules', 'playwright-report', 'test-results']);
|
|
390
|
+
const results = [];
|
|
391
|
+
const walk = (dir, prefix = '') => {
|
|
392
|
+
let entries;
|
|
393
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
394
|
+
for (const entry of entries) {
|
|
395
|
+
if (entry.isDirectory() && skipped.has(entry.name)) continue;
|
|
396
|
+
const rel = prefix ? path.join(prefix, entry.name) : entry.name;
|
|
397
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
|
|
398
|
+
else if (entry.isFile() && exts.has(path.extname(entry.name))) results.push(rel);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
walk(REPO_ROOT);
|
|
402
|
+
return results;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function gitHead() {
|
|
407
|
+
try {
|
|
408
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
409
|
+
cwd: REPO_ROOT, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore']
|
|
410
|
+
}).trim();
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function commandExists(name) {
|
|
417
|
+
return (process.env.PATH || '').split(path.delimiter).some((dir) => {
|
|
418
|
+
try { return dir && statSync(path.join(dir, name)).isFile(); } catch { return false; }
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `${REPO_ROOT}` and `${env:NAME}` in configured env values. */
|
|
423
|
+
function expandEnv(env) {
|
|
424
|
+
if (!env) return {};
|
|
425
|
+
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, String(value)
|
|
426
|
+
.replaceAll('${REPO_ROOT}', REPO_ROOT)
|
|
427
|
+
.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] || '')]));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ─── Runner ───────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* grep-forbid: fails when a forbidden pattern appears in tracked source.
|
|
434
|
+
* This is how the repo's own written invariants become enforceable gates —
|
|
435
|
+
* e.g. "never add @ts-nocheck to silence the typechecker".
|
|
436
|
+
*/
|
|
437
|
+
/**
|
|
438
|
+
* Files this gate should look at.
|
|
439
|
+
*
|
|
440
|
+
* scope: "all" (default) every tracked source file
|
|
441
|
+
* scope: "changed" only files modified vs `baseline` (default origin/HEAD)
|
|
442
|
+
*
|
|
443
|
+
* "changed" exists for going-forward naming/style invariants on codebases with
|
|
444
|
+
* large pre-existing violation sets, where a blanket rename would break a live
|
|
445
|
+
* API or schema. The rule still fails the moment new code violates it; the
|
|
446
|
+
* historical backlog is a tracked migration, not a permanently red gate.
|
|
447
|
+
* Never use it for correctness gates (types, lint, bypass) — those must stay
|
|
448
|
+
* repo-wide.
|
|
449
|
+
*/
|
|
450
|
+
function grepScopeFiles(check) {
|
|
451
|
+
const all = trackedSources(check.extensions);
|
|
452
|
+
if ((check.scope || 'all') !== 'changed') return all;
|
|
453
|
+
// Without git there is no diff: scan everything, then subtract the committed
|
|
454
|
+
// violation baseline (fallbackViolationBaseline) if the check declares one.
|
|
455
|
+
if (sourceInventoryMode === 'filesystem') return all;
|
|
456
|
+
const baseline = check.baseline || 'origin/HEAD';
|
|
457
|
+
let changed;
|
|
458
|
+
try {
|
|
459
|
+
const out = execFileSync('git', ['diff', '--name-only', '--diff-filter=d', baseline], {
|
|
460
|
+
cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 20_000,
|
|
461
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
462
|
+
});
|
|
463
|
+
changed = new Set(out.split('\n').map((s) => s.trim()).filter(Boolean));
|
|
464
|
+
} catch {
|
|
465
|
+
// Cannot resolve the baseline — fail safe by checking everything rather
|
|
466
|
+
// than silently checking nothing.
|
|
467
|
+
console.error(` [code-quality] ${check.id}: baseline "${baseline}" unresolvable; falling back to full scope.`);
|
|
468
|
+
return all;
|
|
469
|
+
}
|
|
470
|
+
return all.filter((f) => changed.has(f));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* scope:"changed" on a workspace without git scans every file. A committed
|
|
475
|
+
* baseline (per-file finding count + snippet hash) then drops files whose
|
|
476
|
+
* violations are unchanged, so only new violations fail. Any mismatch or an
|
|
477
|
+
* unreadable baseline keeps every finding (fail closed).
|
|
478
|
+
*/
|
|
479
|
+
function applyFallbackViolationBaseline(check, findings) {
|
|
480
|
+
if ((check.scope || 'all') !== 'changed' || sourceInventoryMode !== 'filesystem' || !check.fallbackViolationBaseline) {
|
|
481
|
+
return findings;
|
|
482
|
+
}
|
|
483
|
+
let baseline;
|
|
484
|
+
try {
|
|
485
|
+
baseline = JSON.parse(readFileSync(path.resolve(SKILL_DIR, check.fallbackViolationBaseline), 'utf8'));
|
|
486
|
+
} catch (err) {
|
|
487
|
+
console.error(` [code-quality] ${check.id}: fallback violation baseline unreadable (${err.message}); retaining every finding.`);
|
|
488
|
+
return findings;
|
|
489
|
+
}
|
|
490
|
+
if (baseline?.schema !== 1 || baseline?.rule !== check.patterns?.[0]?.code || !baseline?.files || typeof baseline.files !== 'object') {
|
|
491
|
+
console.error(` [code-quality] ${check.id}: fallback violation baseline has an invalid schema; retaining every finding.`);
|
|
492
|
+
return findings;
|
|
493
|
+
}
|
|
494
|
+
const grouped = new Map();
|
|
495
|
+
for (const finding of findings) grouped.set(finding.file, [...(grouped.get(finding.file) || []), finding]);
|
|
496
|
+
const retained = [];
|
|
497
|
+
for (const [file, group] of grouped) {
|
|
498
|
+
const sha256 = createHash('sha256').update(group.map((f) => f.snippet).sort().join('\n')).digest('hex');
|
|
499
|
+
const expected = baseline.files[file];
|
|
500
|
+
if (expected?.count === group.length && expected?.sha256 === sha256) continue;
|
|
501
|
+
retained.push(...group);
|
|
502
|
+
}
|
|
503
|
+
return retained;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function runGrepForbid(check) {
|
|
507
|
+
let findings = [];
|
|
508
|
+
const patterns = (check.patterns || []).map((p) => ({
|
|
509
|
+
re: new RegExp(p.pattern, p.flags || ''), message: p.message, code: p.code || 'FORBIDDEN'
|
|
510
|
+
}));
|
|
511
|
+
const exts = new Set(check.extensions || config.sourceExtensions || ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
|
|
512
|
+
const ignore = (check.ignorePaths || []).map((p) => new RegExp(p));
|
|
513
|
+
// Optional allowlist: when present, ONLY files matching one of these path
|
|
514
|
+
// regexes are scanned. Needed for a rule that applies to one subtree with
|
|
515
|
+
// different extensions than the repo-wide rule — expressing "scaffold/ only"
|
|
516
|
+
// with ignorePaths alone would mean listing every other directory.
|
|
517
|
+
const only = (check.paths || []).map((p) => new RegExp(p));
|
|
518
|
+
|
|
519
|
+
const inScope = (rel) => (!only.length || only.some((re) => re.test(rel)))
|
|
520
|
+
&& exts.has(path.extname(rel)) && !ignore.some((re) => re.test(rel));
|
|
521
|
+
// The empty-scope guard asks whether this rule could EVER match a file. For
|
|
522
|
+
// scope:"changed", an empty diff is a clean result, not a misconfiguration.
|
|
523
|
+
const eligible = trackedSources(check.extensions).filter(inScope).length;
|
|
524
|
+
|
|
525
|
+
let scanned = 0;
|
|
526
|
+
for (const rel of grepScopeFiles(check)) {
|
|
527
|
+
if (!inScope(rel)) continue;
|
|
528
|
+
let text;
|
|
529
|
+
try { text = readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { continue; }
|
|
530
|
+
scanned++;
|
|
531
|
+
const lines = text.split('\n');
|
|
532
|
+
for (const { re, message, code } of patterns) {
|
|
533
|
+
for (let i = 0; i < lines.length; i++) {
|
|
534
|
+
if (re.test(lines[i])) {
|
|
535
|
+
findings.push({
|
|
536
|
+
file: rel, line: i + 1, col: 1, code, message,
|
|
537
|
+
check: check.id, severity: 'error', snippet: lines[i].trim().slice(0, 160)
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (eligible === 0) {
|
|
544
|
+
// Never report clean for a rule that looked at nothing — that is how a
|
|
545
|
+
// misconfigured extension list turns an invariant into decoration.
|
|
546
|
+
findings.push({
|
|
547
|
+
file: '.agent/skills/code-quality/config.json', line: 1, col: 1,
|
|
548
|
+
code: 'CQ-EMPTY-SCOPE', check: check.id, severity: 'error',
|
|
549
|
+
message: `grep-forbid check "${check.id}" matched 0 files — check its extensions/paths/ignorePaths.`,
|
|
550
|
+
snippet: `extensions=${JSON.stringify(check.extensions || null)} paths=${JSON.stringify(check.paths || null)}`
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
findings = applyFallbackViolationBaseline(check, findings);
|
|
555
|
+
|
|
556
|
+
return Promise.resolve({
|
|
557
|
+
id: check.id, ok: findings.length === 0, exitCode: findings.length ? 1 : 0,
|
|
558
|
+
timedOut: false, durationMs: 0, findings, scanned,
|
|
559
|
+
raw: findings.map((f) => `${f.file}:${f.line}: ${f.code} ${f.message}`).join('\n')
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function runCheck(check) {
|
|
564
|
+
if (check.kind === 'grep-forbid') return runGrepForbid(check);
|
|
565
|
+
return new Promise((resolve) => {
|
|
566
|
+
let [cmd, ...args] = check.cmd;
|
|
567
|
+
if (cmd === 'pnpm' && !commandExists('pnpm')) {
|
|
568
|
+
// Test hosts often lack a global pnpm; run the repo's pinned one via npx.
|
|
569
|
+
let packageManager = 'pnpm';
|
|
570
|
+
try {
|
|
571
|
+
const manifest = JSON.parse(readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
|
|
572
|
+
if (typeof manifest.packageManager === 'string' && manifest.packageManager.startsWith('pnpm@')) packageManager = manifest.packageManager;
|
|
573
|
+
} catch { /* default pnpm */ }
|
|
574
|
+
args = ['--yes', packageManager, ...args];
|
|
575
|
+
cmd = 'npx';
|
|
576
|
+
}
|
|
577
|
+
const cwd = path.resolve(REPO_ROOT, check.cwd || '.');
|
|
578
|
+
const timeoutMs = (check.timeoutSeconds ?? config.timeoutSeconds ?? 900) * 1000;
|
|
579
|
+
const heapMb = config.memory?.maxOldSpaceMb;
|
|
580
|
+
|
|
581
|
+
const started = Date.now();
|
|
582
|
+
let output = '';
|
|
583
|
+
let timedOut = false;
|
|
584
|
+
|
|
585
|
+
const child = spawn(cmd, args, {
|
|
586
|
+
cwd,
|
|
587
|
+
detached: process.platform !== 'win32', // own process group => killable as a unit
|
|
588
|
+
env: {
|
|
589
|
+
...process.env,
|
|
590
|
+
...(heapMb ? { NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${heapMb}`.trim() } : {}),
|
|
591
|
+
...expandEnv(config.env), // repo-wide env, e.g. PYTHONPATH=${REPO_ROOT}
|
|
592
|
+
...expandEnv(check.env), // per-check env, e.g. SSSS_ALLOW_AUTO_PROVISION=true
|
|
593
|
+
FORCE_COLOR: '0',
|
|
594
|
+
NO_COLOR: '1'
|
|
595
|
+
},
|
|
596
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
597
|
+
});
|
|
598
|
+
currentChild = child;
|
|
599
|
+
|
|
600
|
+
const timer = setTimeout(() => {
|
|
601
|
+
timedOut = true;
|
|
602
|
+
killCurrentChild('SIGTERM');
|
|
603
|
+
setTimeout(() => killCurrentChild('SIGKILL'), 5000).unref();
|
|
604
|
+
}, timeoutMs);
|
|
605
|
+
timer.unref();
|
|
606
|
+
|
|
607
|
+
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
608
|
+
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
609
|
+
|
|
610
|
+
child.on('error', (err) => {
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
currentChild = null;
|
|
613
|
+
resolve({
|
|
614
|
+
id: check.id, ok: false, spawnError: err.message, durationMs: Date.now() - started,
|
|
615
|
+
findings: [], raw: '', timedOut: false
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
child.on('close', (code) => {
|
|
620
|
+
clearTimeout(timer);
|
|
621
|
+
currentChild = null;
|
|
622
|
+
const findings = parseOutput(output, check.parser, check.id);
|
|
623
|
+
if (code !== 0 && findings.length === 0 && !timedOut) {
|
|
624
|
+
// The tool failed but printed nothing the parser recognizes. Surface it
|
|
625
|
+
// as a finding so the report names it; `opaque` keeps exit code 2.
|
|
626
|
+
findings.push({
|
|
627
|
+
file: path.relative(REPO_ROOT, CONFIG_PATH), line: 1, col: 1,
|
|
628
|
+
code: `${check.id.toUpperCase()}-FAIL`, check: check.id, severity: 'error', opaque: true,
|
|
629
|
+
message: `${check.id} exited ${code} with no parsed findings. Inspect: node ${path.relative(process.cwd(), path.join(__dirname, 'report.mjs'))} raw ${check.id}`,
|
|
630
|
+
snippet: output.trim().split('\n').filter(Boolean).slice(-3).join(' ').slice(0, 160)
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
resolve({
|
|
634
|
+
id: check.id,
|
|
635
|
+
ok: code === 0 && !timedOut,
|
|
636
|
+
exitCode: code,
|
|
637
|
+
timedOut,
|
|
638
|
+
durationMs: Date.now() - started,
|
|
639
|
+
findings,
|
|
640
|
+
raw: output.slice(-200_000)
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
async function main() {
|
|
649
|
+
const wantTier = TIER_ORDER[TIER];
|
|
650
|
+
if (!wantTier) fail(`Unknown --tier ${TIER} (expected: fast | full | remote)`);
|
|
651
|
+
|
|
652
|
+
const checks = ONLY
|
|
653
|
+
? config.checks.filter((c) => c.id === ONLY)
|
|
654
|
+
: config.checks.filter((c) => (TIER_ORDER[c.tier || 'fast'] ?? 1) <= wantTier);
|
|
655
|
+
|
|
656
|
+
if (checks.length === 0) {
|
|
657
|
+
fail(ONLY ? `No check matches --only ${ONLY}` : `No checks at tier "${TIER}" or below.`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const skipped = config.checks.length - checks.length;
|
|
661
|
+
if (skipped > 0 && !ONLY) {
|
|
662
|
+
// Never let a bounded run look like full coverage.
|
|
663
|
+
const names = config.checks
|
|
664
|
+
.filter((c) => (TIER_ORDER[c.tier || 'fast'] ?? 1) > wantTier)
|
|
665
|
+
.map((c) => `${c.id}(${c.tier})`)
|
|
666
|
+
.join(', ');
|
|
667
|
+
console.error(`ℹ [code-quality] tier=${TIER}: skipping ${skipped} higher-tier check(s): ${names}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (config.globalLock !== false && !NO_GLOBAL_LOCK) {
|
|
671
|
+
acquireLock(GLOBAL_LOCK_PATH, 'machine-wide');
|
|
672
|
+
heldLocks.push(GLOBAL_LOCK_PATH);
|
|
673
|
+
}
|
|
674
|
+
acquireLock(LOCK_PATH, 'repo');
|
|
675
|
+
heldLocks.push(LOCK_PATH);
|
|
676
|
+
|
|
677
|
+
const startedAt = new Date().toISOString();
|
|
678
|
+
const startedMs = Date.now();
|
|
679
|
+
console.error(` [code-quality] ${config.toolchain} — ${checks.length} check(s) in ${REPO_ROOT}`);
|
|
680
|
+
|
|
681
|
+
const results = [];
|
|
682
|
+
for (const check of checks) {
|
|
683
|
+
const label = check.kind === 'grep-forbid'
|
|
684
|
+
? `grep-forbid (${(check.patterns || []).length} pattern(s))`
|
|
685
|
+
: (check.cmd || []).join(' ');
|
|
686
|
+
console.error(` → ${check.id}: ${label}`);
|
|
687
|
+
const r = await runCheck(check);
|
|
688
|
+
// Mirror the exit contract: means nothing here blocks a push. Warnings
|
|
689
|
+
// are counted separately so a green gate with warnings does not read as
|
|
690
|
+
// failing, and a failing gate never reads as green.
|
|
691
|
+
const soft = r.findings.filter((f) => f.severity === 'warning' || f.severity === 'info').length;
|
|
692
|
+
const hard = r.findings.length - soft;
|
|
693
|
+
const note = r.spawnError ? `spawn failed: ${r.spawnError}`
|
|
694
|
+
: r.timedOut ? 'TIMED OUT'
|
|
695
|
+
: `${hard} error(s)${soft ? `, ${soft} warning(s)` : ''}, exit ${r.exitCode}`;
|
|
696
|
+
console.error(` ${r.ok && !hard ? '' : ' '} ${note} (${(r.durationMs / 1000).toFixed(1)}s)`);
|
|
697
|
+
results.push(r);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const findings = results.flatMap((r) => r.findings);
|
|
701
|
+
const sources = trackedSources();
|
|
702
|
+
const report = {
|
|
703
|
+
schema: 3,
|
|
704
|
+
toolchain: config.toolchain,
|
|
705
|
+
repoRoot: REPO_ROOT,
|
|
706
|
+
tier: TIER,
|
|
707
|
+
// Coverage is stated explicitly so a partial run can never be mistaken
|
|
708
|
+
// for a clean full run when someone reads the report later.
|
|
709
|
+
ranChecks: checks.map((c) => c.id),
|
|
710
|
+
skippedChecks: config.checks.filter((c) => !checks.includes(c)).map((c) => ({ id: c.id, tier: c.tier || 'fast' })),
|
|
711
|
+
startedAt,
|
|
712
|
+
finishedAt: new Date().toISOString(),
|
|
713
|
+
durationMs: Date.now() - startedMs,
|
|
714
|
+
gitHead: gitHead(),
|
|
715
|
+
sourceFileCount: sources.length,
|
|
716
|
+
checks: results.map(({ raw, findings: _f, ...meta }) => meta),
|
|
717
|
+
infrastructureFailure: results.some((r) => r.spawnError || r.timedOut),
|
|
718
|
+
totalFindings: findings.length,
|
|
719
|
+
findings
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
mkdirSync(REPORT_DIR, { recursive: true });
|
|
723
|
+
writeFileSync(REPORT_JSON, JSON.stringify(report, null, 2));
|
|
724
|
+
writeFileSync(REPORT_TXT, results.map((r) => `# ${r.id}\n${r.raw.trim()}`).join('\n\n'));
|
|
725
|
+
|
|
726
|
+
console.error(
|
|
727
|
+
` [code-quality] ${report.totalFindings} finding(s) in ${(report.durationMs / 1000).toFixed(1)}s ` +
|
|
728
|
+
`— read with: node ${path.relative(process.cwd(), path.join(__dirname, 'report.mjs'))}`
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
// Exit contract — meaningful enough to gate a push on:
|
|
732
|
+
// 0 = every gate that ran passed cleanly
|
|
733
|
+
// 1 = findings present (the normal "there is work to do" case)
|
|
734
|
+
// 2 = infrastructure failure, or a gate failed in a way we could not parse
|
|
735
|
+
// (spawn error, timeout, or non-zero exit with zero findings — that
|
|
736
|
+
// last one is the dangerous case: it must never read as clean)
|
|
737
|
+
//
|
|
738
|
+
// A gate's verdict is the TOOL's verdict. Linters routinely exit 0 while
|
|
739
|
+
// emitting warnings; turning those into a red gate would mean the gate
|
|
740
|
+
// disagrees with the tool it wraps, and a gate nobody can get green stops
|
|
741
|
+
// being a gate. So warnings/infos are reported but do not fail the run on
|
|
742
|
+
// their own — a non-zero exit from the tool still does.
|
|
743
|
+
const opaqueFailure = results.some(
|
|
744
|
+
(r) => !r.ok && !r.spawnError && !r.timedOut && r.findings.every((f) => f.opaque)
|
|
745
|
+
);
|
|
746
|
+
const toolFailed = results.some((r) => !r.ok);
|
|
747
|
+
const blocking = findings.filter((f) => f.severity !== 'warning' && f.severity !== 'info');
|
|
748
|
+
releaseLocks();
|
|
749
|
+
process.exit(
|
|
750
|
+
report.infrastructureFailure || opaqueFailure ? 2 : toolFailed || blocking.length ? 1 : 0
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
main().catch((err) => {
|
|
755
|
+
console.error(` [code-quality] ${err.stack || err.message}`);
|
|
756
|
+
shutdown(1);
|
|
757
|
+
});
|