Skip to content
Merged
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
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,13 @@ as native skills through the plugin's `skills` directory.
`atlas.json` is the single source the impact, reuse-revalidation, and hallucination-flag
stages all read. There is no SQLite database and no `.forge/atlas.db`.

Import specifiers resolve through **one** resolver, `src/scope.js` (`resolveSpec`), which
the file graph (`scope`, `rank`, `collide`) and the symbol graph (`atlas`, `impact`) both
call, so they never disagree about what a specifier points at. It follows relative paths
and the repo-root tsconfig/jsconfig path aliases (`loadPathAliases`: `paths`, `baseUrl`,
relative `extends`, JSONC). A spec under a local alias that misses is counted
`unresolved`, not `external`.

The `RULES` table (`src/atlas.js`) is the ONE language registry — JS/TS, Python, Go,
Rust, Java, Ruby, C#, PHP, Kotlin, Swift, C/C++ as regex grammars (zero-dep; a real
parser would need tree-sitter, which the no-runtime-deps rule forbids). `CODE_EXTS =
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **`forge impact`, `forge atlas`, `forge scope` and `forge rank` now follow tsconfig/jsconfig path aliases.**
Before, every `@/…` import in a Next.js-style repo was filed as an external package, and
the stats reported `unresolved: 0`. On a real Next.js app, 1,004 local import pairs went
from 81 found (8%) to 1,004 (100%). The resolver now reads `compilerOptions.paths` and
`baseUrl` from the root `tsconfig.json` (else `jsconfig.json`). It parses JSONC without
mangling `"**/*.ts"` and follows relative `extends`. An aliased import that misses now
counts as unresolved, not external. The atlas format version is bumped, so existing
graphs rebuild.

## [1.3.1] - 2026-09-24

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ The boundaries in the table below are part of each result.
| --- | ---: | --- |
| Warm impact query | 0.40 ms median | 30 runs on one JavaScript repository with a memoized adjacency index; not model latency |
| Deterministic substrate check | 886 ms median | 3 runs on one repository, warm graph, LLM disabled, on a 4-core Windows VM — wall-clock rows are machine-bound and were ~150 ms on the Linux host that produced the pre-2026-09-22 snapshot; re-run `npm run bench` on your own hardware |
| Impact quality | precision 0.17, recall 1.00, F1 0.29 (the precision 0.90 / F1 0.92 reported before 2026-09-21 do not reproduce) | 6 hand-labelled symbols in this repository, scored by `evalImpact` against labels re-derived by `git grep`; `impact` walks reverse dependencies transitively by default, so precision measures the transitive closure against direct-only labels; edited-file-only baseline recall 0.27 |
| Impact quality | precision 0.17, recall 1.00, F1 0.29 (the precision 0.90 / F1 0.92 reported before 2026-09-21 do not reproduce) | 6 hand-labelled symbols in this repository (which imports only by relative path, so it does not exercise tsconfig path aliases), scored by `evalImpact` against labels re-derived by `git grep`; `impact` walks reverse dependencies transitively by default, so precision measures the transitive closure against direct-only labels; edited-file-only baseline recall 0.27 |
| Ledger replica merge | 4308 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared, on the same 4-core Windows VM (I/O-bound: 4–6x the Linux host's figure) |
| Python router live demonstration | 62.1% calculated cost reduction versus always-premium | 30 hand-labelled tasks, thresholds tuned to the set, real measured LLM tokens, approximate public prices; demonstration, not field benchmark |
| Python router, held-out evaluation | total spend 20.2% **higher** than always-premium; gate F1 0.37 | 80 tasks from real GitHub issues and PRs, thresholds frozen, pre-registered; refutes the row above |
Expand Down
12 changes: 7 additions & 5 deletions bench/impact_cases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@
// - src/eval.js defines it (:28) (no other same-file caller)
// - test/eval.test.js imports { evalImpact } (:7) and calls it (:34) — the only referencer
//
// isStale (src/atlas.js) — 6 files
// - src/atlas.js defines it (:1026)
// isStale (src/atlas.js) — 7 files
// - src/atlas.js defines it (:1033)
// - src/verify.js imports { isStale } (:11) and calls it (:456)
// - src/doctor.js imports { isStale } (:18) and calls it (:249)
// - src/substrate.js imports it ALIASED (`isStale as atlasIsStale`, :11) and calls
// it twice (:177, :269) — an aliased import is still a reference
// - test/atlas.test.js imports { isStale } (:6) and calls it
// - test/atlas_resolve.test.js imports { isStale } (:11) and calls it (:187, :190)
// - test/path_aliases.test.js imports { isStale } (:10) and calls it (:332, :334)
//
// mergeStates (src/ledger.js) — 4 files
// - src/ledger.js defines it (:796)
Expand All @@ -63,13 +64,13 @@
//
// contentHash (src/util.js) — 11 files. The widest fan-out in the set, and the case that
// used to carry a documented FALSE NEGATIVE: src/atlas.js binds it to an alias,
// `const hash = contentHash;` at :187, with no call parentheses, and the old import regex
// `const hash = contentHash;` at :190, with no call parentheses, and the old import regex
// captured module paths rather than named bindings, so no edge reached atlas.js. That is
// FIXED — a named import now resolves to the exact symbol node
// (`src/atlas.js:17 imports → src/util.js:contentHash:65`), and atlas.js is predicted at
// (`src/atlas.js:19 imports → src/util.js:contentHash:65`), and atlas.js is predicted at
// one hop. The case is kept for its fan-out, not for the miss.
// - src/util.js defines it (:65); slug() calls it (:28)
// - src/atlas.js imports { contentHash } (:17), aliases it (:187)
// - src/atlas.js imports { contentHash } (:19), aliases it (:190)
// - src/cortex_hook.js imports it (:9) and calls it (:98)
// - src/cost_report.js imports it (:14); routeRef() calls it (:212)
// - src/diagnose.js imports it (:15); failureSignature() calls it (:57)
Expand Down Expand Up @@ -102,6 +103,7 @@ export const IMPACT_CASES = [
"src/substrate.js",
"test/atlas.test.js",
"test/atlas_resolve.test.js",
"test/path_aliases.test.js",
],
editedFile: "src/atlas.js",
},
Expand Down
12 changes: 12 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ instrument, not an everyday view: on forgekit itself the median answer goes from
to 78 of ~450 (recall 1.00, precision 0.09), so reach for it when you need "what could
conceivably be affected", not "what references this".

Imports resolve the way TypeScript resolves them: relative specifiers, and the path
aliases in the repo-root `tsconfig.json` (or `jsconfig.json` when there is no tsconfig)
— `compilerOptions.paths` such as `@/*` or `~/*`, the `baseUrl` lookup, and relative
`extends`. So an app whose root tsconfig declares `@/*` (the Next.js scaffold does) and
imports through `@/lib/utils` gets the same edges as one that writes `../lib/utils`. An
aliased import whose file does not exist counts as an unresolved local import (the
`local import(s) … did not resolve` line, and `unresolvedImports` in `--json`), not as a
package. Not read: package bases (`@tsconfig/next`), nested per-package tsconfigs,
project references (a solution-style root with `files: []` and `references` to
`tsconfig.app.json`, as the current Vite template ships, so its aliases are not seen),
package.json `imports` (`#…`) and workspace packages.

```console
$ forge impact verifyToken
Forge impact — blast radius (hazard-aware)
Expand Down
25 changes: 16 additions & 9 deletions src/atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { CALL_RE } from "./extract.js";
import {
jsImports,
lexOf,
loadPathAliases,
maskCode,
matchPathAlias,
pyImports,
pyModuleIndex,
resolvePyImport,
Expand All @@ -17,8 +19,9 @@ import {
import { contentHash, IGNORE_DIRS, toPosix } from "./util.js";

// Bumped whenever extraction or resolution changes shape: an atlas.json or per-file cache
// from an older version is rebuilt, never trusted (v2 stored unresolved import specifiers).
export const ATLAS_VERSION = 3;
// from an older version is rebuilt, never trusted (v2 stored unresolved import specifiers;
// v3 filed every tsconfig path-alias import as an external package).
export const ATLAS_VERSION = 4;

const JS_RULES = [
{
Expand Down Expand Up @@ -779,18 +782,21 @@ function extractFile(path, root, preRead) {
/**
* Resolve raw edges against the whole graph.
* - imports: STRUCTURALLY — a JS/TS specifier through scope.resolveSpec (exact, NodeNext
* `.js`→`.ts`, extensionless, `index.*`), a Python module through package-root qnames
* (scope.pyModuleIndex). Never a bare-name guess: an import that does not resolve to a
* file stays unresolved (counted), it is not pinned to whatever shares its last segment.
* `.js`→`.ts`, extensionless, `index.*`; relative, or through a tsconfig/jsconfig path
* alias), a Python module through package-root qnames (scope.pyModuleIndex). Never a
* bare-name guess: an import that does not resolve to a file stays unresolved (counted),
* it is not pinned to whatever shares its last segment. A spec under a local alias
* (`@/…`) that misses is unresolved, not external — it names a repo file that is absent.
* - calls/inherits: a definition in the same file, else a name this file imported, else a
* unique definition in the same LANGUAGE FAMILY. More than one candidate is ambiguous:
* the edge is dropped from traversal but marked and counted, never silently lost.
* - doc references: exact module ids, or a unique symbol name in any language.
* @param {any[]} nodes
* @param {any[]} rawEdges
* @param {string[]} files repo-relative POSIX paths of every walked file
* @param {import("./scope.js").PathAlias[]} [aliases] scope.loadPathAliases(root)
*/
function resolveEdges(nodes, rawEdges, files) {
function resolveEdges(nodes, rawEdges, files, aliases = []) {
const fileSet = new Set(files);
const pyIndex = pyModuleIndex(files);
const localPyTops = new Set([...pyIndex.canonical.keys()].map((n) => n.split(".")[0]));
Expand Down Expand Up @@ -850,9 +856,10 @@ function resolveEdges(nodes, rawEdges, files) {
);
local = e.level > 0 || localPyTops.has(String(e.module).split(".")[0]);
} else {
const file = resolveSpec(from, e.target, fileSet);
const file = resolveSpec(from, e.target, fileSet, aliases);
if (file) hits = [{ file, names: e.names || [] }];
local = /^\.\.?(\/|$)/.test(e.target);
local =
/^\.\.?(\/|$)/.test(e.target) || Boolean(matchPathAlias(e.target, aliases)?.alias.local);
}
const base = {
source: e.source,
Expand Down Expand Up @@ -993,7 +1000,7 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) {
fileHashes[rel] = h;
rels.push(rel);
}
const { edges, stats } = resolveEdges(nodes, rawEdges, rels);
const { edges, stats } = resolveEdges(nodes, rawEdges, rels, loadPathAliases(root));
const atlas = {
version: ATLAS_VERSION,
files: inv.files.length,
Expand Down
Loading
Loading