Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified archify.zip
Binary file not shown.
2 changes: 1 addition & 1 deletion archify/bin/archify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2193,7 +2193,7 @@ const COMMAND_USAGE = {
start: '<repo-root> --ir <architecture.json> [--out <dir>] [--language ts|py|java] [--map file] [--config file] [--baseline file] [--quality standard|showcase]',
'code-analysis': [
'serve <repo-root> --ir <architecture.json> --out <dir> [--language ts|py|java] [--map file] [--config file] [--baseline file] [--quality standard|showcase]',
'check <repo-root> [--language ts|py|java] [--config file] [--baseline file] [--compare findings.json] [--out dir] [--fail-on error|warning|info|never] [--new-only] [--update-baseline] [--reason text] [--json]',
'check <repo-root> [--language ts|py|java|go|rust] [--config file] [--baseline file] [--compare findings.json] [--out dir] [--fail-on error|warning|info|never] [--new-only] [--update-baseline] [--reason text] [--json]',
'task <analysis-dir> --finding <id> [--finding <id> …] [--root <repo-root>] [--command cmd] [--format md|json] [--out file] [--require-valid]',
'task --verify <task.json> --root <repo-root> [--format md|json] [--out file] [--require-valid]',
],
Expand Down
10 changes: 8 additions & 2 deletions archify/modules/code-analysis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ It has two entry points that share the same pipeline:
optimization task (JSON or Markdown) and checks its evidence against the current
working tree; the page uses the same logic through the server.

`check --json` also returns an `investigation` object for a first look: its
`leads` repeat the ranked priorities with the message, `path:line`, symbol and a
runnable `task` command, and `ruleCounts` shows how findings divide by rule. Run
`check` with `--out` so each lead is drillable. See
[First look at an unfamiliar repository](docs/USAGE.md#first-look-at-an-unfamiliar-repository).

`task --question` prepares source orientation without requiring a finding: source
identity, captured function ranges and direct imports. JS/TS implementation entries
and fresh Python/Java captures can also include bounded lexical call evidence.
Expand All @@ -51,7 +57,7 @@ No separate setup: the first run installs the module's parser and schema depende
(typescript, java-parser, ajv) into `modules/code-analysis/node_modules`; `npm ci` in
`archify/` does the same through its `postinstall`. Open the URL the command
prints and keep the process running. Options:
`--language ts|py|java|go` (required when the scan matches both JS/TS and Python, or Java beside them under a root Maven/Gradle build file), `--map overlay-map.json`
`--language ts|py|java|go|rust` (required when the scan matches more than one adapter), `--map overlay-map.json`
(`{"componentId": ["moduleId", …]}` when the IR's `sources` do not say which
modules a component stands for), `--config file.json` (thresholds, include /
exclude, roles), `--quality standard|showcase` (passed to `archify deliver`).
Expand Down Expand Up @@ -83,4 +89,4 @@ potential import-time risks without claiming that execution is safe or that a ru
npm run test:code-analysis
```

Go source investigation supports root-module packages, function definitions and conservative direct function bindings. It requires Go 1.18+; build conditions, exclusions and incomplete typing are explicit. See [Go source scope and limitations](docs/GO.md). Rust is not supported yet.
Go source investigation supports root-module packages, function definitions and conservative direct function bindings. It requires Go 1.18+; build conditions, exclusions and incomplete typing are explicit. See [Go source scope and limitations](docs/GO.md). Rust source investigation uses a source-only `rust-syn` profile and reports cfg/feature limits, opaque macros and unknown dispatch explicitly; see [Rust source investigation](docs/RUST.md).
53 changes: 41 additions & 12 deletions archify/modules/code-analysis/bin/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export async function runCheck(argv) {
await ensureDependencies();
const { extractFacts, buildGraph, evaluateGraph, loadBaseline, loadPrevious, stableJson, reviewSummary } = await import('../lib/analysis.mjs');
const { reviewFindings, baselineFrom, isOpen } = await import('../lib/review.mjs');
const { buildOverview } = await import('../overlay/overview.mjs');
const { buildOverview, findingWhere } = await import('../overlay/overview.mjs');
const { findingModules } = await import('../overlay/inject.mjs');

const root = path.resolve(repo);
Expand Down Expand Up @@ -98,11 +98,24 @@ export async function runCheck(argv) {
command: 'check', root, out,
language: facts.repository.language, files: facts.files.length, rules: findings.rules,
verdict: overview.verdict, counts: overview.counts,
gate: { failOn, newOnly, ...(newOnly && !review.comparison ? { note: 'no previous run to compare with: every open finding counts as new' } : {}), failing: failing.map((d) => ({ id: d.id, code: d.code, severity: d.severity, where: where(d), message: d.message })) },
gate: { failOn, newOnly, ...(newOnly && !review.comparison ? { note: 'no previous run to compare with: every open finding counts as new' } : {}), failing: failing.map((d) => ({ id: d.id, code: d.code, severity: d.severity, where: findingWhere(d), message: d.message })) },
review: reviewSummary(review),
comparisonReasons: review.comparison ? review.comparison.reasons : [],
staleBaseline: review.baseline ? review.baseline.stale : [],
priorities: overview.priorities,
// How the open findings divide by rule. The overview groups keep every
// id, including accepted ones; the receipt needs only open sizes, so they
// agree with `counts` and a dominant rule is visible before choosing a lead.
ruleCounts: openRuleCounts(overview.findings),
// The same three leads as `priorities`, with message, location and a
// ready-to-run task command attached. The data already exists in the
// overview and the diagnostics; this only saves a manual join.
investigation: {
leads: overview.investigation.leads.map((lead) => ({ ...lead, task: taskCommand(lead.id, root, out) })),
drillDown: out
? `node ${shellQuote(CLI)} code-analysis task ${shellQuote(out)} --finding <id> --root ${shellQuote(root)} --format json`
: 'Re-run with --out <dir> to drill down with the task command.',
},
// Already computed for the overview. Repeated here so a reader of the CLI
// output sees a file that failed to parse, or files left out because a
// language was selected, without opening the output directory. The gate
Expand All @@ -116,16 +129,27 @@ export async function runCheck(argv) {
};
}

// The most useful single location for a finding, as path:line when known.
export function where(d) {
const s = d.subject || {};
if (s.file) return s.line ? `${s.file}:${s.line}` : s.file;
const first = d.evidence?.imports?.[0];
if (first) return `${first.file}:${first.line}`;
if (Array.isArray(s.files) && s.files.length) return s.files[0];
if (s.module) return `module ${s.module}`;
if (Array.isArray(s.modules)) return `modules ${s.modules.join(', ')}`;
return d.code;
function openRuleCounts(findings) {
const byCode = new Map();
for (const f of findings) {
if (f.status === 'accepted') continue;
const entry = byCode.get(f.code) || { code: f.code, severity: f.severity, count: 0 };
entry.count += 1;
if (SEVERITY[f.severity] < SEVERITY[entry.severity]) entry.severity = f.severity;
byCode.set(f.code, entry);
}
return [...byCode.values()].sort((a, b) => SEVERITY[a.severity] - SEVERITY[b.severity] || b.count - a.count || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
}

const CLI = fileURLToPath(new URL('../../../bin/archify.mjs', import.meta.url));

function taskCommand(id, root, out) {
return out ? `node ${shellQuote(CLI)} code-analysis task ${shellQuote(out)} --finding ${shellQuote(id)} --root ${shellQuote(root)} --format json` : null;
}

function shellQuote(value) {
const text = String(value);
return /^[A-Za-z0-9_./:=@+-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`;
}

export function formatText(r) {
Expand All @@ -150,6 +174,11 @@ export function formatText(r) {
const uncovered = r.coverage || { parseErrors: [], otherLanguages: [] };
if (uncovered.parseErrors.length) lines.push(` Not analyzed, parse failed: ${uncovered.parseErrors.map(formatTerminalText).join(', ')}`);
if (uncovered.otherLanguages.length) lines.push(` Not analyzed, other language: ${uncovered.otherLanguages.map((l) => `${l.files} ${l.adapter}`).join(', ')}`);
if (r.investigation?.leads?.length) {
lines.push(`Start here: ${r.investigation.leads.length} ranked lead${r.investigation.leads.length === 1 ? '' : 's'}. Each is static evidence, not a proven defect; read the cited source first.`);
r.investigation.leads.forEach((lead, i) => lines.push(` ${i + 1}. ${lead.severity.padEnd(7)} ${lead.code} ${lead.where ?? lead.id} [${lead.id}]`));
if (r.investigation.drillDown) lines.push(` Drill down: ${r.investigation.drillDown}`);
}
if (r.review.comparison) {
const c = r.review.comparison;
lines.push(` Since the previous run: ${c.new} new, ${c.notDetected} no longer detected, ${c.evidenceChanged} with changed evidence`);
Expand Down
6 changes: 5 additions & 1 deletion archify/modules/code-analysis/config/defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@
],
"generated": [
"**/generated-*",
"**/*.generated.*"
"**/*.generated.*",
"**/vendor/**",
"**/vendored/**",
"**/third_party/**",
"**/third-party/**"
]
}
}
22 changes: 20 additions & 2 deletions archify/modules/code-analysis/docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,32 @@ The source panel marks captured changes against the base commit: green gutters f

If the architecture JSON changes or becomes unavailable after startup, the analysis request is rejected and the page asks you to regenerate the diagram. Restart with the updated JSON and open the new URL. The server never rewrites your input JSON or silently combines a new architecture with the previous diagram.

Python, JS/TS and Java are supported. For mixed-language projects, add `--language py`, `--language ts` or `--language java`; each run analyzes one language. A JS/TS or Python tree that also holds `.java` files keeps its language unless its root has a Maven/Gradle build file (see the [upgrade note](JAVA.md)). Add `--map overlay-map.json` when you need an explicit mapping between diagram components and code modules.
Python, JS/TS and Java are supported in the diagram view. For mixed-language projects, add `--language py`, `--language ts` or `--language java`; each run analyzes one language. A JS/TS or Python tree that also holds `.java` files keeps its language unless its root has a Maven/Gradle build file (see the [upgrade note](JAVA.md)). Add `--map overlay-map.json` when you need an explicit mapping between diagram components and code modules. For a terminal first look, `check` also supports Go and Rust; see [Go](GO.md) and [Rust](RUST.md) for their source-only profiles.

For a Java analysis without a diagram, run `node bin/archify.mjs code-analysis check /path/to/java-repo --language java --out out/java`. No JDK or project build is required. See [Java support and limitations](JAVA.md) for conservative dependency resolution, complexity measurements and syntax coverage.

Output defaults to a system temporary directory. Add `--out out/my-project` to choose a persistent output directory. Use the printed URL: double-clicking a static HTML file cannot start local code analysis.

You can also run `node bin/archify.mjs start "/path/to/project" --ir architecture.json` directly. The legacy `code-analysis serve` command remains supported.

## First look at an unfamiliar repository

Run `check` without a diagram and write the artifacts to a directory:

```sh
node bin/archify.mjs code-analysis check /path/to/repo --language ts --out out/first-look --json
```

Choose `--language` when the repository contains more than one language; without it, `check` stops with `extract/adapter-ambiguous` and lists the detected adapters. Each run analyzes one language and reports the others under `coverage.otherLanguages`.

Start from the JSON receipt, not the full finding list:

- `investigation.leads` repeats the three ranked `priorities` with `message`, `where` (`path:line`), `symbol`, `impact`, and a runnable `task` command. The text output prints the same list under **Start here**.
- `ruleCounts` shows how findings divide by rule, so a dominant rule is visible before reading every finding.
- `coverage` lists parse failures and other languages left out. `overview.json` also lists files excluded by role.

For each lead you keep, run its `task` command (or `archify code-analysis task out/first-look --finding <id> --root /path/to/repo --format json`) to get source excerpts, facts, suggestions, acceptance and evidence digests. Then read the cited source before changing code. A finding is static evidence, not a proven defect: a complexity warning says the function is harder to read and test, not that it must be split, and a coupling signal may be an intentional design. If the branching or dependency is inherent, record that and leave the code as it is.

## Architecture boundaries

To check that layers only depend on what they should, pass a config file with `--config analysis.config.json`:
Expand Down Expand Up @@ -100,7 +118,7 @@ Nested named functions are measured on their own:
- in Python: methods and nested `def`s;
- in JS/TS: methods, constructors, accessors, and arrow or function expressions assigned to a name.

Lambdas and anonymous callbacks count toward the function that contains them. A callback at module level has no such function, so it is measured on its own, named after the call it is passed to, and its first literal argument, such as `<callback of server.on("request")>`. Test and generated files are skipped.
Lambdas and anonymous callbacks count toward the function that contains them. A callback at module level has no such function, so it is measured on its own, named after the call it is passed to, and its first literal argument, such as `<callback of server.on("request")>`. Test, generated and vendored files are skipped: `roles.generated` includes `vendor/`, `vendored/`, `third_party/` and `third-party/` by default, because bundles there are not first-party source. Override `roles.generated` in a config to measure those folders and keep them in the module graph.

A function is reported as info from complexity 15 or nesting 5, and as a warning from complexity 25 or nesting 6. Function length is shown as context but never reported on its own. A high count is a maintenance signal, not a defect. The problem page links to the function's first line and its deepest nested block.

Expand Down
38 changes: 38 additions & 0 deletions archify/modules/code-analysis/overlay/overview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,49 @@ export function buildOverview({ findings = [], graph, facts = null, unmapped = [
review: reviewOf(run.review),
groups: groupsOf(ranked),
priorities,
investigation: investigationOf(findings, entries, priorities),
findings: ranked,
coverage: coverageOf({ graph, facts, unmapped, run }),
};
}

// The priorities above are ids only. `investigation.leads` repeats the same
// ranking with the message and source location already attached, so an agent
// can start reading without joining the ids against another list. It adds no
// new finding and no new judgment; `message` and `subject` are the diagnostic's
// own fields.
function investigationOf(findings, entries, priorities) {
const byId = new Map(findings.map((f) => [f.id, f]));
const entryById = new Map(entries.map((e) => [e.id, e]));
return {
leads: priorities.map((id) => {
const f = byId.get(id);
const e = entryById.get(id);
return {
id,
code: f?.code ?? e?.code ?? null,
severity: f?.severity ?? e?.severity ?? null,
message: f?.message ?? null,
where: f ? findingWhere(f) : null,
symbol: f?.subject?.function ?? f?.subject?.symbol ?? null,
impact: e?.impact ?? null,
};
}),
};
}

// The most useful single location for a finding, as path:line when known.
export function findingWhere(f) {
const s = f?.subject || {};
if (s.file) return s.line ? `${s.file}:${s.line}` : s.file;
const first = f?.evidence?.imports?.[0];
if (first) return `${first.file}:${first.line}`;
if (Array.isArray(s.files) && s.files.length) return s.files[0];
if (s.module) return `module ${s.module}`;
if (Array.isArray(s.modules)) return `modules ${s.modules.join(', ')}`;
return f?.code ?? null;
}

function reviewOf(review) {
if (!review || (!review.baseline && !review.comparison)) return null;
return {
Expand Down
30 changes: 30 additions & 0 deletions archify/modules/code-analysis/test/check-coverage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,36 @@ test('check: threshold errors stay errors and partial coverage does not change t
}
});

test('check: investigation leads carry the message, location and a task command', (t) => {
const root = fixture(t, { 'src/app.py': 'def f(x):\n if x:\n return 1\n return 0\n' });
const out = path.join(root, 'out');
const config = path.join(root, 'analysis.json');
fs.writeFileSync(config, JSON.stringify({ rules: { complexity: { function: { warning: { complexity: 0 } } } } }));

const json = check(root, ['--language', 'py', '--config', config, '--out', out, '--json']);
const receipt = JSON.parse(json.stdout);
assert.equal(receipt.investigation.leads.length, receipt.priorities.length);
const lead = receipt.investigation.leads[0];
assert.equal(lead.id, receipt.priorities[0]);
assert.equal(lead.where, 'src/app.py:1');
assert.match(lead.message, /maintenance signal, not a defect/);
assert.match(lead.task, /--finding COMP-[0-9a-f]{12} --root /);
assert.match(lead.task, /--format json$/);
assert.match(receipt.investigation.drillDown, /--finding <id>/);
assert.deepEqual(receipt.ruleCounts, [{ code: 'complexity/function', severity: 'warning', count: 1 }]);

const overview = JSON.parse(fs.readFileSync(path.join(out, 'overview.json'), 'utf8'));
assert.deepEqual(overview.investigation.leads.map((l) => l.id), receipt.priorities);
assert.equal(overview.investigation.leads[0].where, 'src/app.py:1');

const text = check(root, ['--language', 'py', '--config', config, '--out', path.join(root, 'text-out')]);
assert.match(text.stdout, /Start here: 1 ranked lead\./);
assert.match(text.stdout, /Drill down: node .*code-analysis task /);

const noOut = check(root, ['--language', 'py', '--config', config]);
assert.match(noOut.stdout, /Re-run with --out <dir>/);
});

function textWithParseError(parseError) {
return formatText({
verdict: 'ok',
Expand Down
17 changes: 17 additions & 0 deletions archify/modules/code-analysis/test/complexity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ test('python extraction: functions carry complexity, nesting and location; neste
}
});

test('complexity: vendored bundles are generated by default and do not produce findings', () => {
const dir = pyTree({
'src/app.ts': 'export function app(x: number) { if (x) return 1; if (x > 1) return 2; return 0; }\n',
'src/vendor/lib.js': 'function bundled(x){if(x){return 1}if(x>1){return 2}return 0}\n',
});
try {
const defaults = loadConfig(null);
const { facts } = extractFacts(dir, { language: 'ts', config: defaults });
assert.equal(facts.files.find((f) => f.path === 'src/vendor/lib.js').role, 'generated');
const config = { rules: { complexity: { function: { info: { complexity: 1, nesting: 1 }, warning: { complexity: 1, nesting: 1 } } } } };
const out = evaluateGraph(buildGraph(facts, defaults), config, facts);
assert.deepEqual(out.diagnostics.filter((d) => d.code === 'complexity/function').map((d) => d.subject.file), ['src/app.ts']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

const graph = {
schema_version: 1, repository: { root: '.', revision: null, url: null, language: 'py', adapter: 'python@test' },
grouping: { depth: 2, packageBoundaries: true, groups: [] }, excluded: { roles: ['test'], files: 0 },
Expand Down
Loading
Loading