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 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.16.1] - 2026-11-07
10
+ ## [0.17.1] - 2026-11-07
11
11
 
12
12
  ### Added
13
13
 
14
- - (describe changes for 0.16.1)
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
- ## [0.16.0] - 2026-11-07
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, MAX_IDENTICAL_STEERS, steerFingerprint } from "./lib/gates/settle.mjs";
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 || (await import("./run.mjs")).listSourceFiles(root, null);
378
+ const list = files || listSourceFiles(root, null);
375
379
  const findings = [];
376
380
  const errors = [];
377
381
  let scanned = 0;
@@ -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
- /** Recursively list source files under root, skipping ignored dirs/files. */
38
- export function listSourceFiles(root, ignorePatterns) {
39
- const ignoreDirs = new Set(["node_modules", ".git", "dist", "build", "coverage", "test", "tests", "__tests__", "fixtures", ".next", ".cache"]);
40
- const out = [];
41
- const walk = (dir) => {
42
- let entries;
43
- try {
44
- entries = readdirSync(dir, { withFileTypes: true });
45
- } catch {
46
- return;
47
- }
48
- for (const e of entries) {
49
- const abs = join(dir, e.name);
50
- const rel = relative(root, abs);
51
- if (e.isDirectory()) {
52
- if (ignoreDirs.has(e.name) || rel.split(sep).some((part) => ignoreDirs.has(part))) continue;
53
- walk(abs);
54
- } else if (e.isFile()) {
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
- /** Detect which metric tools are available in the project. */
66
- export function detectTools(root, { execFn = exec, timeoutMs = 30_000 } = {}) {
67
- const bin = (name) => existsSync(join(root, "node_modules", ".bin", name));
68
- const pkg = (name) => existsSync(join(root, "node_modules", name));
69
- const eslint = bin("eslint");
70
- const eslintSonarjs = eslint && pkg("eslint-plugin-sonarjs");
71
- const jscpd = bin("jscpd") || pkg("jscpd");
72
- const madge = bin("madge") || pkg("madge");
73
- const dependencyCruiser = bin("depcruise") || pkg("dependency-cruiser");
74
- const typescriptParser = pkg("@typescript-eslint/parser");
75
- return { eslint, eslintSonarjs, jscpd, madge, dependencyCruiser, typescriptParser };
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
- const args = ["--no-eslintrc", "--format", "json"];
104
- if (hasTs) {
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
- const rule = ESLINT_RULES[p.key](p.threshold);
114
- args.push("--rule", JSON.stringify({ [ruleId]: rule }));
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
- const ruleId = ESLINT_RULE_IDS[p.key];
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 res = await execFn("npx", ["--no-install", "jscpd", ...args], { cwd: root, timeoutMs });
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 res = await execFn("npx", ["--no-install", "madge", "--circular", "--extensions", "js,jsx,ts,tsx,mjs,cjs", root], { cwd: root, timeoutMs });
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 res = await execFn("npx", ["--no-install", "depcruise", "--config", cfg, "--output-type", "err", "--fail-on", "error", ...files], { cwd: root, timeoutMs });
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
+ }
@@ -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
- * Absolute ceiling on distinct steers per session. Beyond it the gaps are
35
- * reported and the run is allowed to settle — this is a reporting boundary, not
36
- * a stop: the work is never halted, only the asking.
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.16.1",
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
  }