# Phase 2 — Source scan

Reads `workspace.json`, walks every package's source files, and writes `$W/scan.json`:

| key | content |
|---|---|
| `packages[name]` | `{ sourceFiles, testFiles: [file], loc }` (`sourceFiles`/`loc` exclude tests and generated files: `*.gen.*`, `*.generated.*`, `__generated__/`) |
| `imports` | every import that crosses a package boundary: `{ from, fromSource, target, kind, spec, subpath, file, line, isTest, declared, deep }` |
| `fileEdges[name]` | intra-package `[fromFile, toFile]` edges from value imports (type-only imports excluded) |
| `env` | `{ package, file, line, name, isTest }` for `process.env.X`, `process.env['X']`, `import.meta.env.X` |

`kind`: `relative` (`../../other-pkg/src/x`), `package` (`@scope/pkg/sub`), `alias` (tsconfig `paths`).
`fromSource`: the importing package's `source` from `workspace.json` (`package.json` or `project.json`).
`declared`: the importing package lists the target in any dependency field.
`deep`: the import bypasses the target's public API — any `relative` cross-package import, a package subpath starting with `src/`, `lib/` or `internal/`, or a `src`/`lib`/`internal` package subpath when the target has no `exports`, or any subpath not covered by the target's `exports` keys. Alias imports are resolved to the target package first; aliases to the target entry point stay non-deep, while aliases to any non-entry file are deep.

```bash
W="$(node -p 'require("os").tmpdir()')/monorepo-doctor"
cat > "$W/scan.mjs" <<'EOF'
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const work = path.join(os.tmpdir(), 'monorepo-doctor');
const ws = JSON.parse(fs.readFileSync(path.join(work, 'workspace.json'), 'utf8'));
const root = ws.root;
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '.turbo', '.next', '.nx', '.git', 'lib-cov', '.svelte-kit', '.docusaurus', '.output', 'storybook-static', '.vercel', '.cache']);
const SRC = /\.(?:[cm]?[jt]sx?)$/;
const GENERATED = /(\.gen|\.generated)\.[cm]?[jt]sx?$|(^|\/)__generated__\//;
const TEST = /\.(spec|test)\.[cm]?[jt]sx?$|(^|\/)(__tests__|__mocks__|test|tests|e2e|cypress)(\/|$)/;
const EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
const toPosix = (p) => p.split(path.sep).join('/');
const byDir = [...ws.packages].sort((a, b) => b.dir.length - a.dir.length);
const owner = (relFile) => byDir.find((p) => relFile === p.dir || relFile.startsWith(p.dir + '/'));
const byName = new Map(ws.packages.map((p) => [p.name, p]));

const stripJsonc = (s) => s
  .replace(/"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m) => m[0] === '"' ? m : '')
  .replace(/"(?:[^"\\]|\\.)*"|,(\s*[}\]])/g, (m, g) => g !== undefined ? g : m);
const readJsonc = (p) => {
  if (!fs.existsSync(p)) return null;
  try { return JSON.parse(stripJsonc(fs.readFileSync(p, 'utf8'))); }
  catch (e) { console.error(`WARN: failed to parse ${toPosix(path.relative(root, p))}: ${e.message}`); return null; }
};
const tsBasePath = path.join(root, 'tsconfig.base.json');
const tsConfigPath = path.join(root, 'tsconfig.json');
const tsBaseJson = readJsonc(tsBasePath);
const tsConfigJson = readJsonc(tsConfigPath);
const tsBase = tsBaseJson?.compilerOptions?.paths ? tsBaseJson : (tsConfigJson?.compilerOptions?.paths ? tsConfigJson : (tsBaseJson ?? tsConfigJson ?? {}));
const baseUrl = path.join(root, tsBase.compilerOptions?.baseUrl ?? '.');
const aliases = Object.entries(tsBase.compilerOptions?.paths ?? {})
  .map(([k, v]) => ({ key: k, target: Array.isArray(v) ? v[0] : v }))
  .filter((a) => typeof a.target === 'string')
  .sort((a, b) => (a.key.includes('*') - b.key.includes('*')) || b.key.replace('*', '').length - a.key.replace('*', '').length);
function matchAlias(spec) {
  return aliases.find((a) => a.key === spec || (a.key.endsWith('*') && spec.startsWith(a.key.slice(0, -1))));
}

function walk(dir, out) {
  let entries = [];
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
  for (const e of entries) {
    if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), out); }
    else if (SRC.test(e.name) && !e.name.endsWith('.d.ts')) out.push(path.join(dir, e.name));
  }
  return out;
}
function resolveFile(abs) {
  const cands = [abs, ...EXTS.map((e) => abs + e), ...EXTS.map((e) => path.join(abs, 'index' + e))];
  if (/\.[cm]?js$/.test(abs)) cands.push(...['.ts', '.tsx', '.mts', '.cts'].map((e) => abs.replace(/\.[cm]?js$/, e)));
  if (/\.jsx$/.test(abs)) cands.push(abs.replace(/\.jsx$/, '.tsx'));
  return cands.find((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } }) ?? null;
}
function exportEntries(pkg) {
  if (typeof pkg.exports === 'string') return [['.', pkg.exports]];
  if (pkg.exports && typeof pkg.exports === 'object') return Object.entries(pkg.exports).filter(([k, v]) => k.startsWith('.') && v !== null);
  return (pkg.exportKeys ?? []).map((k) => [k, true]);
}
function covered(pkg, subpath) {
  const entries = exportEntries(pkg);
  if (!entries.length) return true;
  const want = './' + subpath;
  return entries.some(([k]) => k === want || (k.endsWith('/') && want.startsWith(k)) || (k.includes('*') && new RegExp('^' + k.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$').test(want)));
}
const entryValues = (value) => {
  if (typeof value === 'string') return [value];
  if (value && typeof value === 'object') return Object.values(value).flatMap(entryValues);
  return [];
};
function isEntrySubpath(pkg, subpath) {
  const norm = (s) => s.replace(/^\.\//, '').replace(/\.[cm]?[jt]sx?$/, '').replace(/\/(?:index)$/, '/index');
  const bare = norm(subpath);
  const entries = new Set(['index', 'src/index']);
  if (pkg.main) entries.add(norm(pkg.main));
  if (typeof pkg.exports === 'string') entries.add(norm(pkg.exports));
  else if (pkg.exports && typeof pkg.exports === 'object' && pkg.exports['.'] !== undefined && pkg.exports['.'] !== null) {
    for (const v of entryValues(pkg.exports['.'])) entries.add(norm(v));
  }
  return entries.has(bare);
}
function lineStarts(text) {
  const starts = [0];
  for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
  return starts;
}
function lineAt(starts, idx) {
  let lo = 0, hi = starts.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (starts[mid] <= idx) lo = mid + 1; else hi = mid - 1;
  }
  return hi + 1;
}
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
function allNamedSpecifiersAreTypeOnly(clause = '') {
  const m = clause.match(/\{([\s\S]*?)\}/);
  if (!m) return false;
  const beforeBrace = stripComments(clause.slice(0, m.index)).replace(/[\s,]/g, '');
  if (beforeBrace.length > 0) return false;
  const specs = stripComments(m[1]).split(',').map((s) => s.trim()).filter(Boolean);
  return specs.length > 0 && specs.every((s) => /^type\s+[$\w]+(?:\s+as\s+[$\w]+)?$/.test(s));
}

const scan = { packages: {}, imports: [], fileEdges: {}, env: [] };
for (const pkg of ws.packages) {
  const files = walk(path.join(root, pkg.dir), []).filter((f) => owner(toPosix(path.relative(root, f)))?.name === pkg.name);
  const info = { sourceFiles: 0, testFiles: [], loc: 0 };
  const edges = [];
  for (const abs of files) {
    const file = toPosix(path.relative(root, abs));
    const packageRel = pkg.dir === '.' ? file : file.slice(pkg.dir.length + 1);
    const isTest = TEST.test(packageRel);
    const text = fs.readFileSync(abs, 'utf8');
    const starts = lineStarts(text);
    if (isTest) info.testFiles.push(file); else if (!GENERATED.test(packageRel)) { info.sourceFiles++; info.loc += text.split('\n').length; }
    const specs = [];
    const importRe = /^[ \t]*(import|export)\s+(type\s+)?((?:(?!\n[ \t]*(?:import|export)\b)[\w$*{},\s]|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*?\bfrom\s*)?['"]([^'"\n]+)['"]/gm;
    for (const m of text.matchAll(importRe)) {
      if (m[2]) continue;
      if (m[1] === 'export' && !m[3]) continue;
      if (allNamedSpecifiersAreTypeOnly(m[3])) continue;
      specs.push({ spec: m[4], line: lineAt(starts, m.index + m[0].search(/\S/)) });
    }
    for (const m of text.matchAll(/\b(?:import|require)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) {
      const line = lineAt(starts, m.index);
      if (/\bimport\s+type\s+[\w$]+\s*=\s*$/.test(text.slice(Math.max(0, m.index - 200), m.index))) continue;
      specs.push({ spec: m[1], line });
    }
    for (const m of text.matchAll(/\bprocess\.env\.([A-Za-z_][\w]*)|\bprocess\.env\[\s*['"]([^'"]+)['"]\s*\]|\bimport\.meta\.env\.([A-Za-z_][\w]*)/g)) {
      scan.env.push({ package: pkg.name, file, line: lineAt(starts, m.index), name: m[1] ?? m[2] ?? m[3], isTest });
    }
    for (const { spec, line } of specs) {
      let kind, target, subpath = '', resolved = null;
      if (spec.startsWith('.')) {
        resolved = resolveFile(path.resolve(path.dirname(abs), spec));
        const relTarget = toPosix(path.relative(root, resolved ?? path.resolve(path.dirname(abs), spec)));
        const t = owner(relTarget);
        if (!t) continue;
        if (t.name === pkg.name) { if (resolved) edges.push([file, toPosix(path.relative(root, resolved))]); continue; }
        kind = 'relative'; target = t.name; subpath = relTarget.slice(t.dir.length + 1);
      } else {
        const parts = spec.split('/');
        const name = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
        if (byName.has(name)) { kind = 'package'; target = name; subpath = parts.slice(spec.startsWith('@') ? 2 : 1).join('/'); }
        else {
          const a = matchAlias(spec);
          if (!a) continue;
          const mapped = a.key.endsWith('*') ? a.target.replace('*', spec.slice(a.key.length - 1)) : a.target;
          resolved = resolveFile(path.resolve(baseUrl, mapped)) ?? path.resolve(baseUrl, mapped);
          const relResolved = toPosix(path.relative(root, resolved));
          const t = owner(relResolved);
          if (!t || t.name === pkg.name) continue;
          kind = 'alias'; target = t.name; subpath = relResolved.slice(t.dir.length + 1);
        }
      }
      const tp = byName.get(target);
      const entry = isEntrySubpath(tp, subpath);
      const hasExports = exportEntries(tp).length > 0;
      const deep = kind === 'relative'
        || (kind === 'alias' ? !entry
          : (subpath !== '' && (hasExports ? !covered(tp, subpath) : /^(src|lib|internal)(\/|$)/.test(subpath))));
      scan.imports.push({ from: pkg.name, fromSource: pkg.source, target, kind, spec, subpath, file, line, isTest, declared: pkg.workspaceDeps.includes(target), deep });
    }
  }
  scan.packages[pkg.name] = info;
  scan.fileEdges[pkg.name] = edges;
}
fs.writeFileSync(path.join(work, 'scan.json'), JSON.stringify(scan, null, 2));
console.log(JSON.stringify({
  packages: Object.keys(scan.packages).length,
  crossPackageImports: scan.imports.length,
  undeclared: scan.imports.filter((i) => !i.declared).length,
  deep: scan.imports.filter((i) => i.deep).length,
  envReads: scan.env.length,
}));
EOF
node "$W/scan.mjs"
```

Notes for the agent:
- The scan is regex-based. Commented-out imports can produce false positives — open the file at `line` to confirm before reporting.
- Type-only imports (`import type …`, `export type … from`, imports whose named specifiers are all `type X`, and `import type X = require(…)`) are excluded because they are erased at compile time.
- `.vue`, `.svelte`, and `.astro` files are not scanned.
- Same-package aliases such as `@/x` produce no file edges; package-boundary checks are unaffected.
- Environment reads using destructuring, such as `const { API_URL } = process.env`, are not detected.
- Huge repos: the scan is linear in file count and import/env match count, with line numbers resolved by precomputed offsets; if it takes > 2 minutes, tell the user and proceed.
