pi-aia-asf 0.16.1 → 0.17.1
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/CHANGELOG.md +39 -3
- package/index.ts +1 -1
- package/lib/code-health/files.mjs +117 -0
- package/lib/code-health/model.mjs +5 -1
- package/lib/code-health/run.mjs +100 -50
- package/lib/code-health/tools.mjs +140 -0
- package/lib/gates/settle.mjs +3 -9
- package/package.json +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.17.1] - 2026-11-07
|
|
11
11
|
|
|
12
12
|
### Added
|
|
13
13
|
|
|
14
|
-
- (describe changes for 0.
|
|
14
|
+
- (describe changes for 0.17.1)
|
|
15
|
+
|
|
16
|
+
## [0.17.0] - 2026-11-07
|
|
17
|
+
|
|
18
|
+
### Added — the health gate ships its own toolchain
|
|
19
|
+
|
|
20
|
+
The gate could not measure. `detectTools()` looked only in the **project's**
|
|
21
|
+
`node_modules`, so on any repo without eslint/jscpd/madge it produced eight
|
|
22
|
+
"tool not installed" skips and reported a level it never examined — and a run
|
|
23
|
+
that changed source could be judged by metrics that were all missing.
|
|
24
|
+
|
|
25
|
+
- **The tools are now ASF's own dependencies**: `eslint`, `eslint-plugin-sonarjs`,
|
|
26
|
+
`@typescript-eslint/parser`, `jscpd`, `madge`. Installing pi-aia-asf installs
|
|
27
|
+
them, so a fresh clone with an empty `node_modules` is still measured.
|
|
28
|
+
- **ASF-first resolution** (`lib/code-health/tools.mjs`): ASF's copy, then the
|
|
29
|
+
project's, then PATH. The same metric means the same thing in every repo, and a
|
|
30
|
+
project pinning its own eslint still wins.
|
|
31
|
+
- **No more `npx --no-install`.** Every metric tool is spawned by absolute path
|
|
32
|
+
from the resolved install; `npx` could silently run a different version or none.
|
|
33
|
+
- **Deterministic eslint.** The gate writes its own flat config to a temp file and
|
|
34
|
+
runs with `--no-config-lookup`, so a project's eslint config cannot change what
|
|
35
|
+
is measured. The sonarjs plugin and TS parser are imported by absolute file URL
|
|
36
|
+
from ASF's install.
|
|
37
|
+
- **Missing tools name the package** ("jscpd is not available — it ships with
|
|
38
|
+
pi-aia-asf; reinstall the package") instead of a bare "tool not installed".
|
|
15
39
|
|
|
16
|
-
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **A sonarjs metric counted the wrong rule id** (`cognitive-complexity` instead
|
|
43
|
+
of `sonarjs/cognitive-complexity`), so it always reported zero violations.
|
|
44
|
+
- **Vendored trees were scanned as project source.** The model level walked
|
|
45
|
+
`.local`, `site-packages`, `.venv`, `vendor`, `third_party`, `target`, `Pods`
|
|
46
|
+
and every other dot-directory: from a home directory it produced 958 findings
|
|
47
|
+
from Python packages belonging to nobody's project. Those trees are skipped now.
|
|
48
|
+
- **A circular dependency in ASF itself** (`code-health/model.mjs` ↔ `run.mjs`,
|
|
49
|
+
via a dynamic import) — found by madge the moment it could actually run. The
|
|
50
|
+
file walker moved to `lib/code-health/files.mjs`, imported by both.
|
|
51
|
+
|
|
52
|
+
## [0.16.1] - 2026-11-07
|
|
17
53
|
|
|
18
54
|
### Fixed — the 2026-11-07 endless completion-gate loop
|
|
19
55
|
|
package/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { execFileSync } from "node:child_process";
|
|
|
26
26
|
import { createLedger, deriveSuiteRun, renderLedger } from "./lib/gates/ledger.mjs";
|
|
27
27
|
import { evaluateBlockers, BLOCKERS, RULE_TEXT, refusalStage, __resetRefusals } from "./lib/gates/blockers.mjs";
|
|
28
28
|
import { createAuthority, renderAuthority } from "./lib/gates/authority.mjs";
|
|
29
|
-
import { decideSettlement, renderCompletionGate, MAX_STEERS,
|
|
29
|
+
import { decideSettlement, renderCompletionGate, MAX_STEERS, steerFingerprint } from "./lib/gates/settle.mjs";
|
|
30
30
|
import { projectKey as resolveProjectKey, __clearProjectCache } from "./lib/project.mjs";
|
|
31
31
|
import { classifyToolCall, activationDecision, isSourcePath, bashWritesSource } from "./lib/activation.mjs";
|
|
32
32
|
import { renderRunCard, cardSummary, CARD_CEILING_BYTES } from "./lib/run-card.mjs";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-file discovery for the health gate.
|
|
3
|
+
*
|
|
4
|
+
* Its own module so model.mjs can use it without importing run.mjs, which
|
|
5
|
+
* imports model.mjs — that pair was a real circular dependency the gate found
|
|
6
|
+
* in ASF itself once madge was actually installed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdirSync } from "node:fs";
|
|
10
|
+
import { join, relative, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Recursively list source files under root, skipping ignored dirs/files. */
|
|
13
|
+
export function listSourceFiles(root, ignorePatterns) {
|
|
14
|
+
const ignoreDirs = new Set([
|
|
15
|
+
"node_modules", ".git", "dist", "build", "coverage", "test", "tests", "__tests__", "fixtures",
|
|
16
|
+
".next", ".cache",
|
|
17
|
+
// vendored / generated trees: measuring a dependency's code says nothing
|
|
18
|
+
// about this project, and on a home directory it produced 958 findings from
|
|
19
|
+
// Python site-packages.
|
|
20
|
+
"site-packages", "dist-packages", "venv", ".venv", "env", ".tox", "__pycache__",
|
|
21
|
+
"vendor", "third_party", "bower_components", "target", "Pods", ".gradle", ".m2",
|
|
22
|
+
]);
|
|
23
|
+
const out = [];
|
|
24
|
+
const walk = (dir) => {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const e of entries) {
|
|
32
|
+
const abs = join(dir, e.name);
|
|
33
|
+
const rel = relative(root, abs);
|
|
34
|
+
if (e.isDirectory()) {
|
|
35
|
+
// Dot-directories (.git, .local, .cache, .venv …) are tool state, not
|
|
36
|
+
// project source.
|
|
37
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
38
|
+
if (ignoreDirs.has(e.name) || rel.split(sep).some((part) => ignoreDirs.has(part))) continue;
|
|
39
|
+
walk(abs);
|
|
40
|
+
} else if (e.isFile()) {
|
|
41
|
+
if (/\.(test|spec)\.[^.]+$/.test(e.name)) continue;
|
|
42
|
+
if (ignorePatterns && ignorePatterns.some((p) => p.includes(e.name))) continue;
|
|
43
|
+
out.push(rel);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(root);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect which metric tools are available. Resolution is ASF-first (see
|
|
53
|
+
* tools.mjs): the gate ships its own toolchain, so a project without eslint is
|
|
54
|
+
* still measured rather than skipped.
|
|
55
|
+
*/
|
|
56
|
+
export function detectTools(root) {
|
|
57
|
+
const tools = resolveTools(root);
|
|
58
|
+
return {
|
|
59
|
+
eslint: tools.eslint,
|
|
60
|
+
eslintSonarjs: tools.eslintSonarjs,
|
|
61
|
+
jscpd: tools.jscpd,
|
|
62
|
+
madge: tools.madge,
|
|
63
|
+
dependencyCruiser: tools.dependencyCruiser,
|
|
64
|
+
typescriptParser: tools.typescriptParser,
|
|
65
|
+
bins: tools.bins,
|
|
66
|
+
pkgs: tools.pkgs,
|
|
67
|
+
sources: tools.sources,
|
|
68
|
+
missing: missingTools(tools),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a flat-config eslint config for the requested rules and write it to a
|
|
74
|
+
* temp file. Deterministic on purpose: `--no-config-lookup` means the project's
|
|
75
|
+
* own config cannot change what the gate measures, and the plugin/parser are
|
|
76
|
+
* imported by absolute file URL so they resolve from ASF's install.
|
|
77
|
+
*/
|
|
78
|
+
function writeEslintConfig(rules, tools, root) {
|
|
79
|
+
const imports = [];
|
|
80
|
+
// flat config wants plugin OBJECTS, not names
|
|
81
|
+
let pluginsSrc = "{}";
|
|
82
|
+
const langOpts = ['ecmaVersion: "latest"', 'sourceType: "module"'];
|
|
83
|
+
// Resolve each package's real entry point instead of guessing a path: the
|
|
84
|
+
// plugin's main file moves between major versions (lib/index.js → cjs/plugin.js).
|
|
85
|
+
const entry = (name) => {
|
|
86
|
+
for (const base of [ASF_ROOT, root]) {
|
|
87
|
+
try {
|
|
88
|
+
return pathToFileURL(createRequire(join(base, "package.json")).resolve(name)).href;
|
|
89
|
+
} catch { /* try the next base */ }
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
const sonarjsEntry = tools.pkgs.sonarjs && entry("eslint-plugin-sonarjs");
|
|
94
|
+
if (sonarjsEntry) {
|
|
95
|
+
// CJS plugin with __esModule: the real object is a nested default.
|
|
96
|
+
imports.push(`import sonarjsMod from ${JSON.stringify(sonarjsEntry)};\nconst sonarjs = sonarjsMod.default || sonarjsMod;`);
|
|
97
|
+
pluginsSrc = "{ sonarjs }";
|
|
98
|
+
}
|
|
99
|
+
const tsParserEntry = tools.pkgs.tsParser && entry("@typescript-eslint/parser");
|
|
100
|
+
if (tsParserEntry) {
|
|
101
|
+
imports.push(`import tsParserMod from ${JSON.stringify(tsParserEntry)};\nconst tsParser = tsParserMod.default || tsParserMod;`);
|
|
102
|
+
langOpts.push("parser: tsParser");
|
|
103
|
+
langOpts.push('parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } }');
|
|
104
|
+
}
|
|
105
|
+
const body = `${imports.join("\n")}
|
|
106
|
+
export default [{
|
|
107
|
+
files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
|
108
|
+
languageOptions: { ${langOpts.join(", ")} },
|
|
109
|
+
plugins: ${pluginsSrc},
|
|
110
|
+
rules: ${JSON.stringify(rules, null, 2)},
|
|
111
|
+
}];
|
|
112
|
+
`;
|
|
113
|
+
const file = join(tmpdir(), `asf-eslint-${process.pid}-${Math.random().toString(36).slice(2, 8)}.mjs`);
|
|
114
|
+
writeFileSync(file, body, "utf-8");
|
|
115
|
+
return file;
|
|
116
|
+
}
|
|
117
|
+
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
* exception stays visible instead of becoming a silent pass.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
// The walker lives in files.mjs: importing it from run.mjs made the pair circular.
|
|
29
|
+
import { listSourceFiles } from "./files.mjs";
|
|
30
|
+
|
|
31
|
+
|
|
28
32
|
/**
|
|
29
33
|
* Declarations whose documented meaning is "deleting the parent takes the
|
|
30
34
|
* children". Relational, ORM-mapped and object-store alike — the *concept* is
|
|
@@ -371,7 +375,7 @@ export function hasPersistenceFiles(files) {
|
|
|
371
375
|
*/
|
|
372
376
|
export async function scanModel(root, { files, readFile, config } = {}) {
|
|
373
377
|
const fs = readFile || (await import("node:fs/promises")).readFile;
|
|
374
|
-
const list = files ||
|
|
378
|
+
const list = files || listSourceFiles(root, null);
|
|
375
379
|
const findings = [];
|
|
376
380
|
const errors = [];
|
|
377
381
|
let scanned = 0;
|
package/lib/code-health/run.mjs
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
9
9
|
import { join, relative, sep } from "node:path";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
+
import { writeFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
11
15
|
import { scanModel } from "./model.mjs";
|
|
16
|
+
import { resolveTools, missingTools, ASF_ROOT } from "./tools.mjs";
|
|
17
|
+
import { listSourceFiles } from "./files.mjs";
|
|
18
|
+
|
|
19
|
+
export { listSourceFiles };
|
|
12
20
|
|
|
13
21
|
/** Run a command with a hard timeout; resolve on exit. */
|
|
14
22
|
export function exec(cmd, args, { cwd, timeoutMs = 120_000, env } = {}) {
|
|
@@ -34,45 +42,70 @@ export function exec(cmd, args, { cwd, timeoutMs = 120_000, env } = {}) {
|
|
|
34
42
|
});
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (/\.(test|spec)\.[^.]+$/.test(e.name)) continue;
|
|
56
|
-
if (ignorePatterns && ignorePatterns.some((p) => p.includes(e.name))) continue;
|
|
57
|
-
out.push(rel);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
45
|
+
/**
|
|
46
|
+
* Detect which metric tools are available. Resolution is ASF-first (see
|
|
47
|
+
* tools.mjs): the gate ships its own toolchain, so a project without eslint is
|
|
48
|
+
* still measured rather than skipped.
|
|
49
|
+
*/
|
|
50
|
+
export function detectTools(root) {
|
|
51
|
+
const tools = resolveTools(root);
|
|
52
|
+
return {
|
|
53
|
+
eslint: tools.eslint,
|
|
54
|
+
eslintSonarjs: tools.eslintSonarjs,
|
|
55
|
+
jscpd: tools.jscpd,
|
|
56
|
+
madge: tools.madge,
|
|
57
|
+
dependencyCruiser: tools.dependencyCruiser,
|
|
58
|
+
typescriptParser: tools.typescriptParser,
|
|
59
|
+
bins: tools.bins,
|
|
60
|
+
pkgs: tools.pkgs,
|
|
61
|
+
sources: tools.sources,
|
|
62
|
+
missing: missingTools(tools),
|
|
60
63
|
};
|
|
61
|
-
walk(root);
|
|
62
|
-
return out;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Build a flat-config eslint config for the requested rules and write it to a
|
|
68
|
+
* temp file. Deterministic on purpose: `--no-config-lookup` means the project's
|
|
69
|
+
* own config cannot change what the gate measures, and the plugin/parser are
|
|
70
|
+
* imported by absolute file URL so they resolve from ASF's install.
|
|
71
|
+
*/
|
|
72
|
+
function writeEslintConfig(rules, tools, root) {
|
|
73
|
+
const imports = [];
|
|
74
|
+
let pluginsSrc = "{}";
|
|
75
|
+
const langOpts = ['ecmaVersion: "latest"', 'sourceType: "module"'];
|
|
76
|
+
// Resolve each package's real entry point instead of guessing a path: the
|
|
77
|
+
// plugin's main file moves between major versions (lib/index.js → cjs/plugin.js).
|
|
78
|
+
const entry = (name) => {
|
|
79
|
+
for (const base of [ASF_ROOT, root]) {
|
|
80
|
+
try {
|
|
81
|
+
return pathToFileURL(createRequire(join(base, "package.json")).resolve(name)).href;
|
|
82
|
+
} catch { /* try the next base */ }
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
86
|
+
const sonarjsEntry = tools.pkgs.sonarjs && entry("eslint-plugin-sonarjs");
|
|
87
|
+
if (sonarjsEntry) {
|
|
88
|
+
// CJS plugin with __esModule: the real plugin object is a nested default.
|
|
89
|
+
imports.push(`import sonarjsMod from ${JSON.stringify(sonarjsEntry)};\nconst sonarjs = sonarjsMod.default || sonarjsMod;`);
|
|
90
|
+
pluginsSrc = "{ sonarjs }";
|
|
91
|
+
}
|
|
92
|
+
const tsParserEntry = tools.pkgs.tsParser && entry("@typescript-eslint/parser");
|
|
93
|
+
if (tsParserEntry) {
|
|
94
|
+
imports.push(`import tsParserMod from ${JSON.stringify(tsParserEntry)};\nconst tsParser = tsParserMod.default || tsParserMod;`);
|
|
95
|
+
langOpts.push("parser: tsParser");
|
|
96
|
+
langOpts.push('parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } }');
|
|
97
|
+
}
|
|
98
|
+
const body = `${imports.join("\n")}
|
|
99
|
+
export default [{
|
|
100
|
+
files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
|
101
|
+
languageOptions: { ${langOpts.join(", ")} },
|
|
102
|
+
plugins: ${pluginsSrc},
|
|
103
|
+
rules: ${JSON.stringify(rules, null, 2)},
|
|
104
|
+
}];
|
|
105
|
+
`;
|
|
106
|
+
const file = join(tmpdir(), `asf-eslint-${process.pid}-${Math.random().toString(36).slice(2, 8)}.mjs`);
|
|
107
|
+
writeFileSync(file, body, "utf-8");
|
|
108
|
+
return file;
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
const ESLINT_RULES = {
|
|
@@ -99,22 +132,30 @@ export async function runEslint(plans, root, { execFn = exec, timeoutMs = 120_00
|
|
|
99
132
|
if (eslintPlans.length === 0) return {};
|
|
100
133
|
const files = listSourceFiles(root, null).filter((f) => /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(f));
|
|
101
134
|
if (files.length === 0) return {};
|
|
135
|
+
const tools = resolveTools(root);
|
|
136
|
+
if (!tools.bins.eslint) {
|
|
137
|
+
return { __error: "eslint is not available — it ships with pi-aia-asf; reinstall the package" };
|
|
138
|
+
}
|
|
102
139
|
const hasTs = files.some((f) => /\.tsx?$/.test(f));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!detectTools(root).typescriptParser) {
|
|
106
|
-
return { __error: "TypeScript parser (@typescript-eslint/parser) not installed — cannot lint .ts files" };
|
|
107
|
-
}
|
|
108
|
-
args.push("--parser", "@typescript-eslint/parser");
|
|
140
|
+
if (hasTs && !tools.typescriptParser) {
|
|
141
|
+
return { __error: "@typescript-eslint/parser is not available — it ships with pi-aia-asf; reinstall the package" };
|
|
109
142
|
}
|
|
143
|
+
const rules = {};
|
|
110
144
|
for (const p of eslintPlans) {
|
|
111
145
|
const ruleId = ESLINT_RULE_IDS[p.key];
|
|
112
146
|
if (!ruleId) continue;
|
|
113
|
-
|
|
114
|
-
|
|
147
|
+
// eslint 9 flat config uses the bare rule name; the plugin namespace is
|
|
148
|
+
// only for sonarjs.
|
|
149
|
+
rules[p.tool === "eslint-sonarjs" ? `sonarjs/${ruleId}` : ruleId] = ESLINT_RULES[p.key](p.threshold);
|
|
150
|
+
}
|
|
151
|
+
const configFile = writeEslintConfig(rules, tools, root);
|
|
152
|
+
const args = ["--no-config-lookup", "--config", configFile, "--format", "json", ...files];
|
|
153
|
+
let res;
|
|
154
|
+
try {
|
|
155
|
+
res = await execFn(tools.bins.eslint, args, { cwd: root, timeoutMs });
|
|
156
|
+
} finally {
|
|
157
|
+
try { rmSync(configFile, { force: true }); } catch { /* best effort */ }
|
|
115
158
|
}
|
|
116
|
-
args.push(...files);
|
|
117
|
-
const res = await execFn("npx", ["--no-install", "eslint", ...args], { cwd: root, timeoutMs });
|
|
118
159
|
if (res.timedOut) return { __error: `eslint timed out after ${timeoutMs / 1000}s` };
|
|
119
160
|
if (res.code !== 0 && !res.stdout) return { __error: `eslint failed: ${(res.stderr || "").slice(0, 300)}` };
|
|
120
161
|
let data;
|
|
@@ -135,7 +176,10 @@ export async function runEslint(plans, root, { execFn = exec, timeoutMs = 120_00
|
|
|
135
176
|
}
|
|
136
177
|
const out = {};
|
|
137
178
|
for (const p of eslintPlans) {
|
|
138
|
-
|
|
179
|
+
// The reported id carries the plugin namespace for sonarjs rules; counting
|
|
180
|
+
// the bare name silently reported 0 violations for every sonarjs metric.
|
|
181
|
+
const bare = ESLINT_RULE_IDS[p.key];
|
|
182
|
+
const ruleId = p.tool === "eslint-sonarjs" ? `sonarjs/${bare}` : bare;
|
|
139
183
|
if (unavailable.has(ruleId)) {
|
|
140
184
|
out[p.key] = { value: 0, unavailable: true };
|
|
141
185
|
} else {
|
|
@@ -158,7 +202,9 @@ export async function runJscpd(plan, root, { execFn = exec, timeoutMs = 120_000,
|
|
|
158
202
|
"--ignore", "**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/test/**,**/tests/**,**/__tests__/**,**/fixtures/**,**/*.test.*,**/*.spec.*",
|
|
159
203
|
root,
|
|
160
204
|
];
|
|
161
|
-
const
|
|
205
|
+
const bin = resolveTools(root).bins.jscpd;
|
|
206
|
+
if (!bin) return { error: "jscpd is not available — it ships with pi-aia-asf; reinstall the package" };
|
|
207
|
+
const res = await execFn(bin, args, { cwd: root, timeoutMs });
|
|
162
208
|
if (res.timedOut) return { error: `jscpd timed out after ${timeoutMs / 1000}s` };
|
|
163
209
|
const reportPath = join(outDir, "jscpd-report.json");
|
|
164
210
|
try {
|
|
@@ -174,7 +220,9 @@ export async function runJscpd(plan, root, { execFn = exec, timeoutMs = 120_000,
|
|
|
174
220
|
|
|
175
221
|
/** Run madge --circular; return { value: cycle count } or { error }. */
|
|
176
222
|
export async function runMadge(plan, root, { execFn = exec, timeoutMs = 120_000 } = {}) {
|
|
177
|
-
const
|
|
223
|
+
const bin = resolveTools(root).bins.madge;
|
|
224
|
+
if (!bin) return { error: "madge is not available — it ships with pi-aia-asf; reinstall the package" };
|
|
225
|
+
const res = await execFn(bin, ["--circular", "--extensions", "js,jsx,ts,tsx,mjs,cjs", root], { cwd: root, timeoutMs });
|
|
178
226
|
if (res.timedOut) return { error: `madge timed out after ${timeoutMs / 1000}s` };
|
|
179
227
|
const text = res.stdout + res.stderr;
|
|
180
228
|
if (res.code === 0) return { value: 0 };
|
|
@@ -187,7 +235,9 @@ export async function runDepcruise(plan, root, { execFn = exec, timeoutMs = 120_
|
|
|
187
235
|
const cfg = plan.args.config;
|
|
188
236
|
const files = listSourceFiles(root, null).filter((f) => /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(f));
|
|
189
237
|
if (files.length === 0) return { value: 0 };
|
|
190
|
-
const
|
|
238
|
+
const bin = resolveTools(root).bins.depcruise;
|
|
239
|
+
if (!bin) return { error: "dependency-cruiser is not available — install it in the project to use this metric" };
|
|
240
|
+
const res = await execFn(bin, ["--config", cfg, "--output-type", "err", "--fail-on", "error", ...files], { cwd: root, timeoutMs });
|
|
191
241
|
if (res.timedOut) return { error: `dependency-cruiser timed out after ${timeoutMs / 1000}s` };
|
|
192
242
|
return { value: res.code === 0 ? 0 : 1 };
|
|
193
243
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Health Gate — tool resolution.
|
|
3
|
+
*
|
|
4
|
+
* The gate measures nothing without its tools, and a project that happens not to
|
|
5
|
+
* ship eslint/jscpd/madge is not a project without complexity problems. So the
|
|
6
|
+
* tools are ASF's OWN dependencies (package.json) and are resolved from ASF
|
|
7
|
+
* first: the same metric means the same thing in every repo, and a fresh clone
|
|
8
|
+
* with an empty node_modules still gets a real measurement instead of eight
|
|
9
|
+
* "tool not installed" skips.
|
|
10
|
+
*
|
|
11
|
+
* Resolution order, per tool:
|
|
12
|
+
* 1. ASF's own node_modules — deterministic, ships with the package
|
|
13
|
+
* 2. the project's node_modules — a project may pin its own version
|
|
14
|
+
* 3. PATH — a global install
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, statSync } from "node:fs";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
/** ASF's package root (this file is <root>/lib/code-health/tools.mjs). */
|
|
22
|
+
export const ASF_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
23
|
+
|
|
24
|
+
const TOOLS = {
|
|
25
|
+
eslint: { bin: "eslint", pkg: "eslint" },
|
|
26
|
+
sonarjs: { pkg: "eslint-plugin-sonarjs" },
|
|
27
|
+
tsParser: { pkg: "@typescript-eslint/parser" },
|
|
28
|
+
jscpd: { bin: "jscpd", pkg: "jscpd" },
|
|
29
|
+
madge: { bin: "madge", pkg: "madge" },
|
|
30
|
+
depcruise: { bin: "depcruise", pkg: "dependency-cruiser" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function isExecutable(p) {
|
|
34
|
+
try {
|
|
35
|
+
return statSync(p).isFile();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findBin(base, name) {
|
|
42
|
+
const p = join(base, "node_modules", ".bin", name);
|
|
43
|
+
return isExecutable(p) ? p : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findPkg(base, name) {
|
|
47
|
+
const p = join(base, "node_modules", name);
|
|
48
|
+
return existsSync(p) ? p : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findOnPath(name) {
|
|
52
|
+
for (const dir of String(process.env.PATH || "").split(":")) {
|
|
53
|
+
if (!dir) continue;
|
|
54
|
+
const p = join(dir, name);
|
|
55
|
+
if (isExecutable(p)) return p;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve every tool. `bins` holds absolute paths to spawn (never `npx`, which
|
|
62
|
+
* silently installs a different version or nothing at all).
|
|
63
|
+
*
|
|
64
|
+
* @param {string} projectRoot
|
|
65
|
+
* @returns {{eslint:boolean, eslintSonarjs:boolean, jscpd:boolean, madge:boolean,
|
|
66
|
+
* dependencyCruiser:boolean, typescriptParser:boolean,
|
|
67
|
+
* bins:Record<string,string|null>, pkgs:Record<string,string|null>,
|
|
68
|
+
* sources:Record<string,string>}}
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTools(projectRoot) {
|
|
71
|
+
const bases = [
|
|
72
|
+
{ dir: ASF_ROOT, source: "asf" },
|
|
73
|
+
{ dir: projectRoot, source: "project" },
|
|
74
|
+
];
|
|
75
|
+
const bins = {};
|
|
76
|
+
const pkgs = {};
|
|
77
|
+
const sources = {};
|
|
78
|
+
|
|
79
|
+
for (const [key, spec] of Object.entries(TOOLS)) {
|
|
80
|
+
if (spec.bin) {
|
|
81
|
+
bins[key] = null;
|
|
82
|
+
for (const b of bases) {
|
|
83
|
+
const found = findBin(b.dir, spec.bin);
|
|
84
|
+
if (found) {
|
|
85
|
+
bins[key] = found;
|
|
86
|
+
sources[key] = b.source;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!bins[key]) {
|
|
91
|
+
const onPath = findOnPath(spec.bin);
|
|
92
|
+
if (onPath) {
|
|
93
|
+
bins[key] = onPath;
|
|
94
|
+
sources[key] = "path";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (spec.pkg) {
|
|
99
|
+
pkgs[key] = null;
|
|
100
|
+
for (const b of bases) {
|
|
101
|
+
const found = findPkg(b.dir, spec.pkg);
|
|
102
|
+
if (found) {
|
|
103
|
+
pkgs[key] = found;
|
|
104
|
+
if (!sources[key]) sources[key] = b.source;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// eslint is usable when its binary exists; the sonarjs rules only when the
|
|
112
|
+
// plugin package is there too. A missing plugin must not disable the six
|
|
113
|
+
// eslint metrics that do not need it.
|
|
114
|
+
const eslint = !!bins.eslint;
|
|
115
|
+
return {
|
|
116
|
+
eslint,
|
|
117
|
+
eslintSonarjs: eslint && !!pkgs.sonarjs,
|
|
118
|
+
jscpd: !!bins.jscpd,
|
|
119
|
+
madge: !!bins.madge,
|
|
120
|
+
dependencyCruiser: !!bins.depcruise,
|
|
121
|
+
typescriptParser: !!pkgs.tsParser,
|
|
122
|
+
bins,
|
|
123
|
+
pkgs,
|
|
124
|
+
sources,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Human-readable list of what is missing, for the "make them requirements"
|
|
130
|
+
* contract: the gate names the package, not just "tool not installed".
|
|
131
|
+
*/
|
|
132
|
+
export function missingTools(tools) {
|
|
133
|
+
const out = [];
|
|
134
|
+
if (!tools.eslint) out.push("eslint");
|
|
135
|
+
if (!tools.eslintSonarjs) out.push("eslint-plugin-sonarjs");
|
|
136
|
+
if (!tools.jscpd) out.push("jscpd");
|
|
137
|
+
if (!tools.madge) out.push("madge");
|
|
138
|
+
if (!tools.typescriptParser) out.push("@typescript-eslint/parser");
|
|
139
|
+
return out;
|
|
140
|
+
}
|
package/lib/gates/settle.mjs
CHANGED
|
@@ -28,16 +28,10 @@
|
|
|
28
28
|
|
|
29
29
|
import { sessionFacts } from "./ledger.mjs";
|
|
30
30
|
|
|
31
|
-
/** An identical steer is never issued twice. */
|
|
32
|
-
export const MAX_IDENTICAL_STEERS = 1;
|
|
33
31
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*/
|
|
38
|
-
/**
|
|
39
|
-
* A REPORTING number only — never a stop. It exists so the steer text can say
|
|
40
|
-
* "steer N" without inventing a bound; there is no cap on warranted work.
|
|
32
|
+
* A REPORTING number only — never a stop, and no longer referenced by any
|
|
33
|
+
* decision. It is kept so tooling that prints "steer N" has a number to name;
|
|
34
|
+
* there is no cap on warranted work, and a real gap is re-raised forever.
|
|
41
35
|
*/
|
|
42
36
|
export const MAX_STEERS = 12;
|
|
43
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-aia-asf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Ai Applied Agentic Software Factory — codifies the full software development flow: intake, research, spec capture, adversarial analysis, planning with approval gates, test-first implementation, and release. Requires pi-vigilant, pi-smart-web-search, pi-smart-fetch, and pi-aia-browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -72,5 +72,12 @@
|
|
|
72
72
|
"jiti": "^2.7.0",
|
|
73
73
|
"typebox": "1.3.27",
|
|
74
74
|
"typescript": "5.9.3"
|
|
75
|
+
},
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@typescript-eslint/parser": "^8.72.1",
|
|
78
|
+
"eslint": "^9.39.5",
|
|
79
|
+
"eslint-plugin-sonarjs": "^4.2.2",
|
|
80
|
+
"jscpd": "^5.4.2",
|
|
81
|
+
"madge": "^8.0.0"
|
|
75
82
|
}
|
|
76
83
|
}
|