tecton 0.4.0
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/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/tecton.js +417 -0
- package/brand/tecton-favicon.svg +2 -0
- package/package.json +60 -0
- package/src/diff.js +93 -0
- package/src/graph.js +130 -0
- package/src/layout.js +191 -0
- package/src/report/dist/client.js +3 -0
- package/src/report/fonts/Geist-Variable.woff2 +0 -0
- package/src/report/fonts/GeistMono-Variable.woff2 +0 -0
- package/src/report/fonts/OFL-LICENSE.txt +92 -0
- package/src/report/template.html +619 -0
- package/src/report.js +110 -0
- package/src/rules.js +82 -0
- package/src/scan.js +306 -0
- package/src/system.js +672 -0
- package/src/types.d.ts +207 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Output: the interactive HTML page, a Markdown summary (for PR comments) and plain JSON.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
/** @param {import('./types.js').ReportModel} model */
|
|
9
|
+
export function toHtml(model) {
|
|
10
|
+
const tpl = fs.readFileSync(path.join(here, 'report', 'template.html'), 'utf8');
|
|
11
|
+
// built from src/report/client/ by scripts/build.js (it also bundles layout.js, for the module file diagrams)
|
|
12
|
+
const client = fs.readFileSync(path.join(here, 'report', 'dist', 'client.js'), 'utf8').replace(/<\/script/gi, '<\\/script');
|
|
13
|
+
const data = JSON.stringify(model).replace(/</g, '\\u003c').replace(/[\u2028\u2029]/g, (ch) => (ch === '\u2028' ? '\\u2028' : '\\u2029'));
|
|
14
|
+
const title = `${model.project} · Tecton`.replace(/[<&]/g, (c) => (c === '<' ? '<' : '&'));
|
|
15
|
+
const font = (file) => fs.readFileSync(path.join(here, 'report', 'fonts', file)).toString('base64');
|
|
16
|
+
const fonts = `@font-face{font-family:"Geist";src:url(data:font/woff2;base64,${font('Geist-Variable.woff2')}) format("woff2");font-weight:100 900;font-display:swap}`
|
|
17
|
+
+ `@font-face{font-family:"Geist Mono";src:url(data:font/woff2;base64,${font('GeistMono-Variable.woff2')}) format("woff2");font-weight:100 900;font-display:swap}`;
|
|
18
|
+
const favicon = fs.readFileSync(path.join(here, '..', 'brand', 'tecton-favicon.svg'), 'utf8');
|
|
19
|
+
const faviconUri = `data:image/svg+xml,${encodeURIComponent(favicon.replace(/\s+/g, ' ').trim())}`;
|
|
20
|
+
return tpl.split('__FAVICON__').join(faviconUri).split('__FONTS__').join(fonts).split('__TITLE__').join(title).split('__DATA__').join(data).split('__CLIENT__').join(client);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// product signals from the brand: green new, red removed, orange rule break
|
|
24
|
+
const COLORS = { add: '#16A34A', rem: '#DC2626', viol: '#EA580C', same: '#8C8A85' };
|
|
25
|
+
const mid = (s) => `m_${s.replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
26
|
+
const q = (s) => s.replace(/"/g, '#quot;');
|
|
27
|
+
|
|
28
|
+
/** A Markdown summary with a Mermaid graph of only the parts that changed (good for PR comments). */
|
|
29
|
+
/** @param {import('./types.js').ReportModel} model */
|
|
30
|
+
export function toMarkdown(model) {
|
|
31
|
+
const { summary: S, violations, edges, nodes } = model;
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push(`### Tecton: ${model.curLabel} vs ${model.baseLabel || '(no base)'}`);
|
|
34
|
+
lines.push('');
|
|
35
|
+
const V = S.violations;
|
|
36
|
+
const status = V.newErrors ? `❌ ${V.newErrors} new rule break${V.newErrors === 1 ? '' : 's'}` : '✅ no new rule breaks';
|
|
37
|
+
lines.push(`${status} · modules +${S.modules.added}/−${S.modules.removed} · dependencies +${S.deps.added}/−${S.deps.removed}${V.fixed ? ` · ${V.fixed} fixed` : ''}`);
|
|
38
|
+
lines.push('');
|
|
39
|
+
if (model.hasBase && S.touched && S.touched.files) {
|
|
40
|
+
const mods = nodes.filter((n) => n.touched).sort((a, b) => b.touched - a.touched || a.id.localeCompare(b.id));
|
|
41
|
+
const names = mods.slice(0, 8).map((n) => `\`${n.id}\``).join(', ') + (mods.length > 8 ? ` and ${mods.length - 8} more` : '');
|
|
42
|
+
lines.push(`Touches ${S.touched.files} file${S.touched.files === 1 ? '' : 's'} in ${mods.length} module${mods.length === 1 ? '' : 's'}: ${names}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const shown = violations.filter((v) => v.status !== 'existing');
|
|
47
|
+
if (shown.length) {
|
|
48
|
+
lines.push('| | Rule | Where |');
|
|
49
|
+
lines.push('|---|---|---|');
|
|
50
|
+
for (const v of shown) {
|
|
51
|
+
const icon = v.status === 'fixed' ? '✅ fixed' : v.severity === 'error' ? '❌ new' : '⚠️ new';
|
|
52
|
+
const where = v.kind === 'cycle' ? v.members.join(' ⇄ ') : `\`${v.from}\` → \`${v.to}\``;
|
|
53
|
+
const ev = v.imports[0] ? ` (e.g. \`${v.imports[0].file}:${v.imports[0].line}\`)` : '';
|
|
54
|
+
lines.push(`| ${icon} | ${v.rule} | ${where}${ev} |`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sys = model.system;
|
|
60
|
+
if (sys && model.hasBase) {
|
|
61
|
+
const bullets = [];
|
|
62
|
+
const mark = (st) => (st === 'added' ? '\u2795' : '\u2796');
|
|
63
|
+
sys.apps.filter((a) => a.status !== 'same').forEach((a) => bullets.push(`${mark(a.status)} app **${a.name}** (${a.framework})`));
|
|
64
|
+
for (const a of sys.apps) {
|
|
65
|
+
for (const [g, label] of [['pages', 'page'], ['api', 'route'], ['endpoints', 'endpoint'], ['jobs', 'job'], ['schedules', 'schedule']]) {
|
|
66
|
+
(a.groups[g] || []).filter((x) => x.status !== 'same').forEach((x) => bullets.push(`${mark(x.status)} ${label} \`${x.methods && x.methods.length ? `${x.methods.join('/')} ` : ''}${x.label}\`${x.human ? ` (${x.human})` : ''} in ${a.name}`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
sys.stores.filter((x) => x.status !== 'same').forEach((x) => bullets.push(`${mark(x.status)} ${x.kind === 'Queue' ? 'queue' : 'data store'} **${x.name}**`));
|
|
70
|
+
sys.outside.filter((x) => x.status !== 'same').forEach((x) => bullets.push(`${mark(x.status)} outside service **${x.name}** (${x.kind})`));
|
|
71
|
+
if (bullets.length) {
|
|
72
|
+
lines.push('<details><summary>System changes (' + bullets.length + ')</summary>', '');
|
|
73
|
+
lines.push(...bullets.slice(0, 40).map((b) => `- ${b}`));
|
|
74
|
+
if (bullets.length > 40) lines.push(`- \u2026and ${bullets.length - 40} more`);
|
|
75
|
+
lines.push('', '</details>', '');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const violEdges = new Set(violations.filter((v) => v.status === 'new').flatMap((v) => v.edges));
|
|
80
|
+
const changed = edges.filter((e) => e.status !== 'same' || e.countBase !== e.countCur || violEdges.has(e.id));
|
|
81
|
+
if (changed.length) {
|
|
82
|
+
const ids = new Set();
|
|
83
|
+
changed.forEach((e) => { ids.add(e.from); ids.add(e.to); });
|
|
84
|
+
lines.push('```mermaid');
|
|
85
|
+
lines.push('graph TD');
|
|
86
|
+
for (const id of ids) {
|
|
87
|
+
const n = nodes.find((x) => x.id === id);
|
|
88
|
+
const label = n.status === 'added' ? `${id} (new)` : n.status === 'removed' ? `${id} (removed)` : id;
|
|
89
|
+
lines.push(` ${mid(id)}["${q(label)}"]`);
|
|
90
|
+
}
|
|
91
|
+
const styles = [];
|
|
92
|
+
changed.forEach((e, i) => {
|
|
93
|
+
const lbl = e.status === 'added' ? `+${e.countCur}` : e.status === 'removed' ? `−${e.countBase}` : `${e.countBase}→${e.countCur}`;
|
|
94
|
+
const arrow = e.status === 'removed' ? '-.->' : violEdges.has(e.id) ? '==>' : '-->';
|
|
95
|
+
lines.push(` ${mid(e.from)} ${arrow}|"${violEdges.has(e.id) ? '⚠ ' : ''}${lbl}"| ${mid(e.to)}`);
|
|
96
|
+
const color = violEdges.has(e.id) ? COLORS.viol : e.status === 'added' ? COLORS.add : e.status === 'removed' ? COLORS.rem : COLORS.same;
|
|
97
|
+
styles.push(` linkStyle ${i} stroke:${color},stroke-width:${violEdges.has(e.id) ? 3 : 2}px`);
|
|
98
|
+
});
|
|
99
|
+
lines.push(...styles);
|
|
100
|
+
for (const id of ids) {
|
|
101
|
+
const n = nodes.find((x) => x.id === id);
|
|
102
|
+
if (n.status === 'added') lines.push(` style ${mid(id)} stroke:${COLORS.add},stroke-width:2px`);
|
|
103
|
+
if (n.status === 'removed') lines.push(` style ${mid(id)} stroke:${COLORS.rem},stroke-dasharray:4 3`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('```');
|
|
106
|
+
} else {
|
|
107
|
+
lines.push('_No architectural changes._');
|
|
108
|
+
}
|
|
109
|
+
return lines.join('\n') + '\n';
|
|
110
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// House rules: "this part of the app must not depend on that part".
|
|
2
|
+
import { nameMatches } from './graph.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rule shapes (in tecton.config.json → "rules"):
|
|
6
|
+
* { "name": "...", "from": "components", "to": ["db", "npm:pg"] } // forbidden targets
|
|
7
|
+
* { "name": "...", "from": "lib", "allow": ["utils", "types"] } // the ONLY internal modules allowed
|
|
8
|
+
* "from"/"to"/"allow" accept module names with * wildcards. npm packages are written "npm:<name>".
|
|
9
|
+
* Optional "severity": "error" (default, fails CI) or "warn".
|
|
10
|
+
* Top-level "cycles": "warn" (default) | "error" | "off" flags modules that depend on each other in a loop.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* @param {Pick<import('./types.js').Graph, 'modules' | 'edges'>} graph
|
|
14
|
+
* @param {import('./types.js').Config} cfg
|
|
15
|
+
* @returns {import('./types.js').Violation[]}
|
|
16
|
+
*/
|
|
17
|
+
export function evaluate(graph, cfg) {
|
|
18
|
+
const rules = (cfg.rules || []).map((r, i) => ({ ...r, key: r.name || `rule ${i + 1}`, severity: r.severity || 'error' }));
|
|
19
|
+
const out = [];
|
|
20
|
+
|
|
21
|
+
for (const e of graph.edges.values()) {
|
|
22
|
+
for (const r of rules) {
|
|
23
|
+
if (!r.from || ![].concat(r.from).some((p) => nameMatches(p, e.from))) continue;
|
|
24
|
+
let broken = false;
|
|
25
|
+
if (r.to && [].concat(r.to).some((p) => nameMatches(p, e.to))) broken = true;
|
|
26
|
+
if (r.allow && !e.external && ![].concat(r.allow).some((p) => nameMatches(p, e.to))) broken = true;
|
|
27
|
+
if (!broken) continue;
|
|
28
|
+
out.push({
|
|
29
|
+
id: `${r.key}|${e.from}|${e.to}`,
|
|
30
|
+
kind: 'rule', rule: r.key, message: r.message || null, severity: r.severity,
|
|
31
|
+
from: e.from, to: e.to, edges: [`${e.from}→${e.to}`], count: e.count, imports: e.imports,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cycleMode = cfg.cycles ?? 'warn';
|
|
37
|
+
if (cycleMode !== 'off') {
|
|
38
|
+
for (const scc of stronglyConnected(graph)) {
|
|
39
|
+
const members = new Set(scc);
|
|
40
|
+
const edgeKeys = [...graph.edges.values()]
|
|
41
|
+
.filter((e) => members.has(e.from) && members.has(e.to))
|
|
42
|
+
.map((e) => `${e.from}→${e.to}`).sort();
|
|
43
|
+
const sorted = [...scc].sort();
|
|
44
|
+
out.push({
|
|
45
|
+
id: `cycle|${sorted.join('|')}`,
|
|
46
|
+
kind: 'cycle', rule: 'No circular dependencies', message: null, severity: cycleMode,
|
|
47
|
+
from: sorted[0], to: sorted[sorted.length - 1], members: sorted, edges: edgeKeys,
|
|
48
|
+
count: edgeKeys.length, imports: edgeKeys.flatMap((k) => graph.edges.get(k).imports.slice(0, 5)),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Tarjan's algorithm over internal modules; returns groups of 2+ modules that form a loop. */
|
|
56
|
+
function stronglyConnected(graph) {
|
|
57
|
+
const adj = new Map();
|
|
58
|
+
for (const m of graph.modules.keys()) adj.set(m, []);
|
|
59
|
+
for (const e of graph.edges.values()) if (!e.external) adj.get(e.from)?.push(e.to);
|
|
60
|
+
let index = 0;
|
|
61
|
+
const idx = new Map(); const low = new Map(); const onStack = new Set(); const stack = []; const res = [];
|
|
62
|
+
const visit = (v) => {
|
|
63
|
+
idx.set(v, index); low.set(v, index); index++;
|
|
64
|
+
stack.push(v); onStack.add(v);
|
|
65
|
+
for (const w of adj.get(v) || []) {
|
|
66
|
+
if (!idx.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); } else if (onStack.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
|
|
67
|
+
}
|
|
68
|
+
if (low.get(v) === idx.get(v)) {
|
|
69
|
+
const comp = [];
|
|
70
|
+
let w;
|
|
71
|
+
do { w = stack.pop(); onStack.delete(w); comp.push(w); } while (w !== v);
|
|
72
|
+
if (comp.length > 1) res.push(comp);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const v of [...adj.keys()].sort()) if (!idx.has(v)) visit(v);
|
|
76
|
+
return res;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Packages that rules mention by name, so they get drawn on the map even if externals are hidden. */
|
|
80
|
+
export function packagesNamedInRules(cfg) {
|
|
81
|
+
return (cfg.rules || []).flatMap((r) => [].concat(r.to || [])).filter((p) => p.startsWith('npm:'));
|
|
82
|
+
}
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Reading source files (from disk or from a git commit) and pulling out their imports.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const CODE_EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
7
|
+
// single-file components: only their <script> blocks are code
|
|
8
|
+
export const SFC_EXT = ['.vue', '.svelte'];
|
|
9
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt',
|
|
10
|
+
'coverage', '.turbo', '.vercel', '.cache', '.output', '.svelte-kit']);
|
|
11
|
+
const CONFIG_FILES = ['tsconfig.json', 'jsconfig.json'];
|
|
12
|
+
// other files the system view reads: example env files (service URLs) and vercel.json (cron schedules)
|
|
13
|
+
const EXTRA_RE = /(^|\/)(\.env\.(example|sample|template|dist|defaults)|vercel\.json)$/;
|
|
14
|
+
|
|
15
|
+
export const isCode = (p) => (CODE_EXT.includes(path.posix.extname(p)) && !p.endsWith('.d.ts')) || SFC_EXT.includes(path.posix.extname(p));
|
|
16
|
+
export const isExtra = (p) => EXTRA_RE.test(p);
|
|
17
|
+
|
|
18
|
+
/** The code part of a file: for .vue/.svelte, the <script> blocks with everything else blanked (line numbers kept). */
|
|
19
|
+
export function codeOf(file, text) {
|
|
20
|
+
if (text == null || !SFC_EXT.includes(path.posix.extname(file))) return text;
|
|
21
|
+
const out = text.replace(/[^\n]/g, ' ').split('');
|
|
22
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
|
23
|
+
let m;
|
|
24
|
+
while ((m = re.exec(text))) {
|
|
25
|
+
const start = m.index + m[0].indexOf('>') + 1;
|
|
26
|
+
for (let i = 0; i < m[1].length; i++) out[start + i] = m[1][i];
|
|
27
|
+
}
|
|
28
|
+
return out.join('');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run git and return its output as text.
|
|
33
|
+
* @overload
|
|
34
|
+
* @param {string} cwd
|
|
35
|
+
* @param {string[]} args
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Run git with `input` on stdin and return the raw bytes (for `cat-file --batch`).
|
|
40
|
+
* @overload
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {string} input
|
|
44
|
+
* @returns {Buffer}
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} cwd
|
|
48
|
+
* @param {string[]} args
|
|
49
|
+
* @param {string} [input]
|
|
50
|
+
* @returns {string | Buffer}
|
|
51
|
+
*/
|
|
52
|
+
export function git(cwd, args, input) {
|
|
53
|
+
return execFileSync('git', args, {
|
|
54
|
+
cwd, input: input === undefined ? undefined : Buffer.from(input, 'utf8'),
|
|
55
|
+
encoding: input === undefined ? 'utf8' : null, // stdin callers get raw bytes back
|
|
56
|
+
maxBuffer: 1 << 30, stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function gitInfo(dir) {
|
|
61
|
+
try {
|
|
62
|
+
const top = git(dir, ['rev-parse', '--show-toplevel']).trim();
|
|
63
|
+
const prefix = path.relative(top, dir).split(path.sep).join('/');
|
|
64
|
+
return { top, prefix };
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function refExists(dir, ref) {
|
|
71
|
+
try { git(dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); return true; } catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The files as they are on disk right now (respecting .gitignore when inside a repo).
|
|
76
|
+
* @param {string} dir
|
|
77
|
+
* @returns {import('./types.js').Source}
|
|
78
|
+
*/
|
|
79
|
+
export function workingTreeSource(dir) {
|
|
80
|
+
let files;
|
|
81
|
+
const gi = gitInfo(dir);
|
|
82
|
+
if (gi) {
|
|
83
|
+
files = git(dir, ['ls-files', '-co', '--exclude-standard', '-z', '--', '.'])
|
|
84
|
+
.split('\0').filter(Boolean)
|
|
85
|
+
.filter((f) => fs.existsSync(path.join(dir, f))); // drop deleted-but-still-tracked files
|
|
86
|
+
} else {
|
|
87
|
+
files = [];
|
|
88
|
+
const walk = (rel) => {
|
|
89
|
+
for (const ent of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
|
|
90
|
+
if (ent.name.startsWith('.') && ent.isDirectory()) continue;
|
|
91
|
+
const r = rel ? `${rel}/${ent.name}` : ent.name;
|
|
92
|
+
if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name)) walk(r); } else files.push(r);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk('');
|
|
96
|
+
}
|
|
97
|
+
files = files.map((f) => f.split(path.sep).join('/'))
|
|
98
|
+
.filter((f) => !f.split('/').some((s) => SKIP_DIRS.has(s)));
|
|
99
|
+
return {
|
|
100
|
+
label: 'working tree',
|
|
101
|
+
files: files.filter(isCode).sort(),
|
|
102
|
+
manifests: files.filter((f) => f === 'package.json' || f.endsWith('/package.json')).sort(),
|
|
103
|
+
extras: files.filter(isExtra).sort(),
|
|
104
|
+
read: (rel) => { try { return fs.readFileSync(path.join(dir, rel), 'utf8'); } catch { return null; } },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The files as they were at a git ref (branch, tag or commit).
|
|
110
|
+
* @param {string} dir
|
|
111
|
+
* @param {string} ref
|
|
112
|
+
* @returns {import('./types.js').Source}
|
|
113
|
+
*/
|
|
114
|
+
export function gitRefSource(dir, ref) {
|
|
115
|
+
const { prefix } = gitInfo(dir);
|
|
116
|
+
const top = git(dir, ['rev-parse', '--show-toplevel']).trim();
|
|
117
|
+
const pre = prefix ? `${prefix}/` : '';
|
|
118
|
+
const all = git(top, ['ls-tree', '-r', '--name-only', '-z', ref, '--', prefix || '.'])
|
|
119
|
+
.split('\0').filter(Boolean)
|
|
120
|
+
.filter((f) => f.startsWith(pre))
|
|
121
|
+
.map((f) => f.slice(pre.length))
|
|
122
|
+
.filter((f) => !f.split('/').some((s) => SKIP_DIRS.has(s)));
|
|
123
|
+
const code = all.filter(isCode).sort();
|
|
124
|
+
const manifests = all.filter((f) => f === 'package.json' || f.endsWith('/package.json')).sort();
|
|
125
|
+
const extras = all.filter(isExtra).sort();
|
|
126
|
+
const wanted = [...code, ...manifests, ...extras, ...CONFIG_FILES.filter((c) => all.includes(c))];
|
|
127
|
+
|
|
128
|
+
// Read every file in one `git cat-file --batch` call instead of one process per file.
|
|
129
|
+
const contents = new Map();
|
|
130
|
+
if (wanted.length) {
|
|
131
|
+
const buf = git(top, ['cat-file', '--batch'], wanted.map((f) => `${ref}:${pre}${f}`).join('\n') + '\n');
|
|
132
|
+
let pos = 0;
|
|
133
|
+
for (const f of wanted) {
|
|
134
|
+
const nl = buf.indexOf(10, pos);
|
|
135
|
+
const header = buf.slice(pos, nl).toString('utf8');
|
|
136
|
+
pos = nl + 1;
|
|
137
|
+
if (header.endsWith(' missing')) continue;
|
|
138
|
+
const size = Number(header.split(' ')[2]);
|
|
139
|
+
contents.set(f, buf.slice(pos, pos + size).toString('utf8'));
|
|
140
|
+
pos += size + 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { label: ref, files: code, manifests, extras, read: (rel) => contents.get(rel) ?? null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Blank out comments (keeping newlines so line numbers stay right) while leaving strings intact.
|
|
148
|
+
* Handles '...', "...", `...${ ... }...` and // and /* comments. Regex literals are not special-cased.
|
|
149
|
+
*/
|
|
150
|
+
export function stripComments(src) {
|
|
151
|
+
const out = src.split('');
|
|
152
|
+
const stack = []; // template-literal nesting: brace depth inside each ${ }
|
|
153
|
+
let i = 0;
|
|
154
|
+
let mode = 'code';
|
|
155
|
+
let braces = 0;
|
|
156
|
+
const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
157
|
+
while (i < src.length) {
|
|
158
|
+
const c = src[i];
|
|
159
|
+
const n = src[i + 1];
|
|
160
|
+
if (mode === 'code') {
|
|
161
|
+
if (c === '/' && n === '/') { const e = src.indexOf('\n', i); const end = e < 0 ? src.length : e; blank(i, end); i = end; continue; }
|
|
162
|
+
if (c === '/' && n === '*') { const e = src.indexOf('*/', i + 2); const end = e < 0 ? src.length : e + 2; blank(i, end); i = end; continue; }
|
|
163
|
+
if (c === "'" || c === '"') { mode = c; i++; continue; }
|
|
164
|
+
if (c === '`') { mode = '`'; i++; continue; }
|
|
165
|
+
if (c === '{') braces++;
|
|
166
|
+
if (c === '}') {
|
|
167
|
+
if (stack.length && braces === stack[stack.length - 1]) { stack.pop(); mode = '`'; i++; continue; }
|
|
168
|
+
braces--;
|
|
169
|
+
}
|
|
170
|
+
i++;
|
|
171
|
+
} else if (mode === "'" || mode === '"') {
|
|
172
|
+
if (c === '\\') { i += 2; continue; }
|
|
173
|
+
if (c === mode || c === '\n') mode = 'code';
|
|
174
|
+
i++;
|
|
175
|
+
} else { // template literal
|
|
176
|
+
if (c === '\\') { i += 2; continue; }
|
|
177
|
+
if (c === '`') { mode = 'code'; i++; continue; }
|
|
178
|
+
if (c === '$' && n === '{') { stack.push(braces); mode = 'code'; i += 2; continue; }
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out.join('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const IMPORT_RES = [
|
|
186
|
+
// import x from 'y' | import { a, type b } from "y" | import 'y' | export * from 'y' | export { a } from 'y'
|
|
187
|
+
/(?:^|[^.\w$])(?:import|export)\s+(?:type\s+)?(?:[\w*{}\s,$]+?\s+from\s*)?['"]([^'"\n]+)['"]/g,
|
|
188
|
+
// require('y')
|
|
189
|
+
/(?:^|[^.\w$])require\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
190
|
+
// import('y')
|
|
191
|
+
/(?:^|[^.\w$])import\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
export function extractImports(src) {
|
|
195
|
+
const code = stripComments(src);
|
|
196
|
+
const lineStarts = [0];
|
|
197
|
+
for (let i = 0; i < code.length; i++) if (code[i] === '\n') lineStarts.push(i + 1);
|
|
198
|
+
const lineOf = (idx) => {
|
|
199
|
+
let lo = 0; let hi = lineStarts.length - 1;
|
|
200
|
+
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1; }
|
|
201
|
+
return lo + 1;
|
|
202
|
+
};
|
|
203
|
+
const found = [];
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
for (const re of IMPORT_RES) {
|
|
206
|
+
re.lastIndex = 0;
|
|
207
|
+
let m;
|
|
208
|
+
while ((m = re.exec(code))) {
|
|
209
|
+
const spec = m[1];
|
|
210
|
+
const line = lineOf(m.index + m[0].indexOf(spec));
|
|
211
|
+
const key = `${spec}@${line}`;
|
|
212
|
+
if (!seen.has(key)) { seen.add(key); found.push({ spec, line }); }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return found.sort((a, b) => a.line - b.line);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------- resolving an import string to a file (or an npm package) ----------
|
|
219
|
+
|
|
220
|
+
const BUILTINS = new Set(['assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants',
|
|
221
|
+
'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'http', 'http2', 'https', 'inspector',
|
|
222
|
+
'module', 'net', 'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream',
|
|
223
|
+
'string_decoder', 'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi',
|
|
224
|
+
'worker_threads', 'zlib']);
|
|
225
|
+
|
|
226
|
+
export function parseJsonc(text) {
|
|
227
|
+
const noComments = stripComments(text).replace(/,(\s*[}\]])/g, '$1');
|
|
228
|
+
return JSON.parse(noComments);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Read "paths" aliases (e.g. "@/*": ["src/*"]) from tsconfig/jsconfig, plus any from tecton config. */
|
|
232
|
+
export function loadAliases(source, cfgAliases = {}) {
|
|
233
|
+
const aliases = [];
|
|
234
|
+
for (const name of CONFIG_FILES) {
|
|
235
|
+
const text = source.readConfig ? source.readConfig(name) : source.read(name);
|
|
236
|
+
if (!text) continue;
|
|
237
|
+
let json;
|
|
238
|
+
try { json = parseJsonc(text); } catch { continue; }
|
|
239
|
+
const co = json.compilerOptions || {};
|
|
240
|
+
const baseUrl = path.posix.normalize(co.baseUrl || '.');
|
|
241
|
+
for (const [pattern, targets] of Object.entries(co.paths || {})) {
|
|
242
|
+
aliases.push({ pattern, targets: targets.map((t) => path.posix.join(baseUrl, t)) });
|
|
243
|
+
}
|
|
244
|
+
if (co.baseUrl) aliases.push({ baseUrl });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
for (const [pattern, target] of Object.entries(cfgAliases)) {
|
|
248
|
+
aliases.unshift({ pattern, targets: [].concat(target) });
|
|
249
|
+
}
|
|
250
|
+
return aliases;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function makeResolver(fileSet, aliases) {
|
|
254
|
+
const exts = CODE_EXT;
|
|
255
|
+
const tryFile = (p) => {
|
|
256
|
+
p = path.posix.normalize(p).replace(/^\.\//, '');
|
|
257
|
+
if (p.startsWith('../')) return null;
|
|
258
|
+
if (fileSet.has(p)) return p;
|
|
259
|
+
for (const e of exts) if (fileSet.has(p + e)) return p + e;
|
|
260
|
+
// TS ESM style: './foo.js' actually points at './foo.ts'
|
|
261
|
+
const m = p.match(/\.(m|c)?jsx?$/);
|
|
262
|
+
if (m) {
|
|
263
|
+
const stem = p.slice(0, -m[0].length);
|
|
264
|
+
for (const e of exts) if (fileSet.has(stem + e)) return stem + e;
|
|
265
|
+
}
|
|
266
|
+
for (const e of exts) if (fileSet.has(`${p}/index${e}`)) return `${p}/index${e}`;
|
|
267
|
+
return null;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return function resolve(fromFile, spec) {
|
|
271
|
+
if (spec.startsWith('.') || spec.startsWith('/')) {
|
|
272
|
+
const base = spec.startsWith('/') ? spec.slice(1) : path.posix.join(path.posix.dirname(fromFile), spec);
|
|
273
|
+
const file = tryFile(base);
|
|
274
|
+
return file ? { kind: 'file', file } : { kind: 'unresolved' };
|
|
275
|
+
}
|
|
276
|
+
for (const a of aliases) {
|
|
277
|
+
if (a.baseUrl) {
|
|
278
|
+
const file = tryFile(path.posix.join(a.baseUrl, spec));
|
|
279
|
+
if (file) return { kind: 'file', file };
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const star = a.pattern.indexOf('*');
|
|
283
|
+
let captured = null;
|
|
284
|
+
if (star < 0) {
|
|
285
|
+
if (spec === a.pattern) captured = '';
|
|
286
|
+
else if (a.pattern.endsWith('/') && spec.startsWith(a.pattern)) captured = spec.slice(a.pattern.length);
|
|
287
|
+
} else {
|
|
288
|
+
const pre = a.pattern.slice(0, star);
|
|
289
|
+
const post = a.pattern.slice(star + 1);
|
|
290
|
+
if (spec.startsWith(pre) && spec.endsWith(post) && spec.length >= pre.length + post.length) {
|
|
291
|
+
captured = spec.slice(pre.length, spec.length - post.length);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (captured === null) continue;
|
|
295
|
+
for (const t of a.targets) {
|
|
296
|
+
const file = tryFile(t.includes('*') ? t.replace('*', captured) : path.posix.join(t, captured));
|
|
297
|
+
if (file) return { kind: 'file', file };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const bare = spec.replace(/^node:/, '');
|
|
301
|
+
if (spec.startsWith('node:') || BUILTINS.has(bare.split('/')[0])) return { kind: 'builtin' };
|
|
302
|
+
const parts = spec.split('/');
|
|
303
|
+
const pkg = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
304
|
+
return { kind: 'package', pkg };
|
|
305
|
+
};
|
|
306
|
+
}
|