Files
worldmonitor/scripts/lint-boundaries.mjs
Elie Habib fe67111dc9 feat: harness engineering P0 - linting, testing, architecture docs (#1587)
* feat: harness engineering P0 - linting, testing, architecture docs

Add foundational infrastructure for agent-first development:

- AGENTS.md: agent entry point with progressive disclosure to deeper docs
- ARCHITECTURE.md: 12-section system reference with source-file refs and ownership rule
- Biome 2.4.7 linter with project-tuned rules, CI workflow (lint-code.yml)
- Architectural boundary lint enforcing forward-only dependency direction (lint-boundaries.mjs)
- Unit test CI workflow (test.yml), all 1083 tests passing
- Fixed 9 pre-existing test failures (bootstrap sync, deploy-config headers, globe parity, redis mocks, geometry URL, import.meta.env null safety)
- Fixed 12 architectural boundary violations (types moved to proper layers)
- Added 3 missing cache tier entries in gateway.ts
- Synced cache-keys.ts with bootstrap.js
- Renamed docs/architecture.mdx to "Design Philosophy" with cross-references
- Deprecated legacy docs/Docs_To_Review/ARCHITECTURE.md
- Harness engineering roadmap tracking doc

* fix: address PR review feedback on harness-engineering-p0

- countries-geojson.test.mjs: skip gracefully when CDN unreachable
  instead of failing CI on network issues
- country-geometry-overrides.test.mts: relax timing assertion
  (250ms -> 2000ms) for constrained CI environments
- lint-boundaries.mjs: implement the documented api/ boundary check
  (was documented but missing, causing false green)

* fix(lint): scan api/ .ts files in boundary check

The api/ boundary check only scanned .js/.mjs files, missing the 25
sebuf RPC .ts edge functions. Now scans .ts files with correct rules:
- Legacy .js: fully self-contained (no server/ or src/ imports)
- RPC .ts: may import server/ and src/generated/ (bundled at deploy),
  but blocks imports from src/ application code

* fix(lint): detect import() type expressions in boundary lint

- Move AppContext back to app/app-context.ts (aggregate type that
  references components/services/utils belongs at the top, not types/)
- Move HappyContentCategory and TechHQ to types/ (simple enums/interfaces)
- Boundary lint now catches import('@/layer') expressions, not just
  from '@/layer' imports
- correlation-engine imports of AppContext marked boundary-ignore
  (type-only imports of top-level aggregate)
2026-03-14 21:29:21 +04:00

184 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Architectural boundary lint.
*
* Enforces forward-only dependency direction:
* types → config → services → components → app → App.ts
*
* Violations are imports that go backwards in this chain.
* Lines with "boundary-ignore" comments are excluded.
*
* Also checks:
* - api/ legacy .js: must not import from src/ or server/ (self-contained)
* - api/ RPC .ts: may import server/ and src/generated/, but not src/ app code
* - server/ must not import from src/components/ or src/app/
*
* Exit code 1 if violations found. Agent-readable output.
*/
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
const SRC = 'src';
const ROOT = process.cwd();
// Layer order (lower index = lower layer, can only import from same or lower)
const LAYERS = ['types', 'config', 'services', 'components', 'app'];
function getLayer(filePath) {
const rel = relative(join(ROOT, SRC), filePath);
for (const layer of LAYERS) {
if (rel.startsWith(layer + '/') || rel.startsWith(layer + '\\')) return layer;
}
return null;
}
function getLayerIndex(layer) {
return LAYERS.indexOf(layer);
}
function walkDir(dir, ext = ['.ts', '.tsx', '.js', '.mjs']) {
const results = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'generated') continue;
results.push(...walkDir(full, ext));
} else if (ext.some(e => entry.name.endsWith(e))) {
results.push(full);
}
}
return results;
}
const violations = [];
// --- Check 1: src/ layer boundaries ---
const srcFiles = walkDir(join(ROOT, SRC));
for (const file of srcFiles) {
const fileLayer = getLayer(file);
if (!fileLayer) continue;
const fileIdx = getLayerIndex(fileLayer);
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('boundary-ignore')) continue;
if (i > 0 && lines[i - 1].includes('boundary-ignore')) continue;
// Check both `from '@/layer'` imports and `import('@/layer')` type expressions
const patterns = [
line.match(/from\s+['"]@\/(\w+)/),
line.match(/import\(['"]@\/(\w+)/),
];
for (const match of patterns) {
if (!match) continue;
const importLayer = match[1];
const importIdx = getLayerIndex(importLayer);
if (importIdx === -1) continue; // not a tracked layer
if (importIdx > fileIdx) {
const rel = relative(ROOT, file);
violations.push({
file: rel,
line: i + 1,
from: fileLayer,
to: importLayer,
text: line.trim(),
remedy: `Move the imported type/function to a lower layer (${fileLayer} or below), or add a "boundary-ignore" comment if this is a pragmatic exception.`,
});
break; // one violation per line is enough
}
}
}
}
// --- Check 2: server/ must not import from src/components/ or src/app/ ---
const serverFiles = walkDir(join(ROOT, 'server'));
for (const file of serverFiles) {
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('boundary-ignore')) continue;
if (i > 0 && lines[i - 1].includes('boundary-ignore')) continue;
if (line.match(/from\s+['"].*\/(components|app)\//)) {
violations.push({
file: relative(ROOT, file),
line: i + 1,
from: 'server',
to: 'src/' + line.match(/(components|app)/)[1],
text: line.trim(),
remedy: 'Server code must not import browser UI code. Extract shared logic into server/_shared/ or src/types/.',
});
}
}
}
// --- Check 3: api/ boundary rules ---
// Legacy api/*.js: fully self-contained (no ../server/ or ../src/ imports)
// Sebuf RPC api/**/*.ts: may import server/ and src/generated/ (bundled at deploy),
// but must NOT import src/ non-generated paths (components, services, config, etc.)
const apiFiles = walkDir(join(ROOT, 'api'), ['.js', '.mjs', '.ts']);
for (const file of apiFiles) {
const basename = file.split('/').pop();
if (basename.startsWith('_') || basename.includes('.test.')) continue;
const isTs = file.endsWith('.ts');
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('boundary-ignore')) continue;
if (i > 0 && lines[i - 1].includes('boundary-ignore')) continue;
if (isTs) {
// RPC .ts files: allow server/ and src/generated/, block src/ non-generated
if (line.match(/from\s+['"]\.\..*\/src\//) && !line.match(/\/src\/generated\//)) {
violations.push({
file: relative(ROOT, file),
line: i + 1,
from: 'api (RPC)',
to: 'src (non-generated)',
text: line.trim(),
remedy: 'RPC Edge Functions may import from server/ and src/generated/, but not from src/ application code (components, services, config).',
});
}
} else {
// Legacy .js files: fully self-contained
if (line.match(/from\s+['"]\.\.\/(?:src|server)\//)) {
violations.push({
file: relative(ROOT, file),
line: i + 1,
from: 'api (legacy)',
to: line.match(/\.\.\/(\w+)/)[1],
text: line.trim(),
remedy: 'Legacy Edge Functions must be self-contained. Only same-directory _*.js helpers and npm packages are allowed.',
});
}
}
}
}
// --- Output ---
if (violations.length === 0) {
console.log('✓ No architectural boundary violations found.');
process.exit(0);
} else {
console.error(`✖ Found ${violations.length} architectural boundary violation(s):\n`);
for (const v of violations) {
console.error(` ${v.file}:${v.line}`);
console.error(` ${v.from}${v.to} (backward import)`);
console.error(` ${v.text}`);
console.error(` Remedy: ${v.remedy}`);
console.error('');
}
process.exit(1);
}