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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ node_modules/
# The demo's AGENTS.md is rewritten by every run.
examples/demo/loopfinder/AGENTS.md
__pycache__/
# The VS Code extension's copy of the CLI and the licenses (vscode/scripts/prepare.js), and its packages.
vscode/core/
vscode/LICENSE*
vscode/*.vsix
63 changes: 49 additions & 14 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ let flowPaths = new Map(); // "from\u0000to" -> drawn path d (the track dots mov
let dotLayer = null;
let dotsFor; // which loop the moving dots belong to (picking a node does not redraw them)

// Set by the VS Code extension (vscode/media/host.js): it hands over the graph, sends a new one after
// every build, and opens a file in the editor. In a browser it is absent and flow.json is fetched.
const host = window.loopfinderHost || null;

// Dots moving along edges. Away from the picked loop they are faint and sparse, so the screen stays calm.
const DOT_SPEED = 40; // px per second
const DOT_TRAVEL = [1.5, 5]; // seconds to cross one edge (min, max), so long arcs are not always busy
Expand Down Expand Up @@ -291,16 +295,24 @@ function renderDetail() {
k.textContent = (n.kind === 'step' ? t('stepOf', { actor: n.actor }) : t(`kind_${n.kind}`)) + ((n.originIn || []).includes(current) ? t('originSuffix') : '') + (n.deadEnd ? t('deadEndSuffix') : '') + (n.intendedEnd ? t('intendedEndSuffix', { why: n.intendedEnd }) : '');
box.appendChild(k);
const dl = document.createElement('dl');
const add = (term, values) => {
// With a host that can open files, paths become buttons; the host decides whether the path is a file.
const value = (parent, v, openable) => {
if (!openable || !host?.open) { parent.textContent = v; return; }
const b = document.createElement('button');
b.type = 'button'; b.className = 'open-file'; b.textContent = v; b.title = t('openFile');
b.addEventListener('click', () => host.open(v));
parent.appendChild(b);
};
const add = (term, values, openable = false) => {
if (!values || values.length === 0) return;
const dt = document.createElement('dt'); dt.textContent = term; dl.appendChild(dt);
const dd = document.createElement('dd');
if (values.length === 1) dd.textContent = values[0];
else { const ul = document.createElement('ul'); for (const v of values) { const li = document.createElement('li'); li.textContent = v; ul.appendChild(li); } dd.appendChild(ul); }
if (values.length === 1) value(dd, values[0], openable);
else { const ul = document.createElement('ul'); for (const v of values) { const li = document.createElement('li'); value(li, v, openable); ul.appendChild(li); } dd.appendChild(ul); }
dl.appendChild(dd);
};
add(t('d_location'), n.path ? [n.path] : null);
add(t('d_files'), n.files);
add(t('d_location'), n.path ? [n.path] : null, n.kind === 'script' || n.kind === 'file');
add(t('d_files'), n.files, true);
add(t('d_frame'), n.groupBy?.[current] ? [labelOf(n.groupBy[current])] : null);
// Reads folded into the scan node: only counts per top-level folder are kept.
add(t('d_folded'), n.foldedBy?.[current] ? Object.entries(n.foldedBy[current]).sort((a, b) => b[1] - a[1]).map(([f, c]) => `${f} ${t('d_count', { n: c })}`) : null);
Expand Down Expand Up @@ -336,14 +348,10 @@ function fillIcons() {

async function main() {
fillIcons();
graph = await (await fetch('flow.json', { cache: 'no-store' })).json();
graph = host ? await host.load() : await (await fetch('flow.json', { cache: 'no-store' })).json();
pickLang(graph.lang);
const select = document.getElementById('flow');
for (const f of graph.flows) {
const o = document.createElement('option');
o.value = f.name; o.textContent = f.name;
select.appendChild(o);
}
fillFlows(select);
// #flow=<name> opens that flow
const fm = /flow=([^&]+)/.exec(location.hash);
const want = fm && decodeURIComponent(fm[1]);
Expand All @@ -354,6 +362,30 @@ async function main() {
const m = /loop=(\d+)/.exec(location.hash);
if (m && graph.loops.some(l => l.id === Number(m[1]) && l.flow === current)) selectedLoop = Number(m[1]);
document.getElementById('lang-toggle').addEventListener('click', () => { setLang(LANG === 'ja' ? 'en' : 'ja'); renderAll(); });
host?.onGraph(replaceGraph);
renderAll();
}

function fillFlows(select) {
select.textContent = '';
for (const f of graph.flows) {
const o = document.createElement('option');
o.value = f.name; o.textContent = f.name;
select.appendChild(o);
}
}

// A rebuilt graph keeps the flow on screen. Loop ids are renumbered by every build, so the picked loop
// is kept only if a loop with the same name is still there; the picked node is kept if it still exists.
function replaceGraph(next) {
const loopName = graph.loops.find(l => l.id === selectedLoop)?.name;
graph = next;
const select = document.getElementById('flow');
fillFlows(select);
if (!graph.flows.some(f => f.name === current)) current = graph.flows[0]?.name;
select.value = current;
selectedLoop = loopName ? graph.loops.find(l => l.flow === current && l.name === loopName)?.id ?? null : null;
if (!graph.nodes.some(n => n.id === selectedNode)) selectedNode = null;
renderAll();
}

Expand Down Expand Up @@ -470,17 +502,20 @@ const zoom = (() => {
})();

// Theme: auto (follow the system) -> light -> dark -> auto. The choice is kept per browser.
// Auto removes the attribute, so the CSS media query decides.
// Auto removes the attribute, so the CSS media query decides. Inside VS Code, auto follows the editor's
// theme instead, which the media query does not see.
const themeToggle = (() => {
const KEY = 'loopfinder:theme';
const ORDER = ['auto', 'light', 'dark'];
const button = document.getElementById('theme-toggle');
let theme = 'auto';
try { theme = ORDER.includes(localStorage.getItem(KEY)) ? localStorage.getItem(KEY) : 'auto'; } catch { /* storage unavailable */ }
const apply = () => {
if (theme === 'auto') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', theme);
const shown = theme === 'auto' ? host?.theme?.() : theme;
if (shown) document.documentElement.setAttribute('data-theme', shown);
else document.documentElement.removeAttribute('data-theme');
};
host?.onTheme?.(apply);
const ICON = { auto: 'sun-moon', light: 'sun', dark: 'moon' };
const refresh = () => {
const name = t(`theme_${theme}`);
Expand Down
2 changes: 2 additions & 0 deletions public/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const STRINGS = {
staleEnds: 'Declared as an end, but read now or no longer written: {list}',
d_location: 'Location',
d_files: 'Files read or written',
openFile: 'Open in the editor',
d_frame: 'Frame',
d_folded: 'Folded reads',
d_urls: 'Fetched from (examples)',
Expand Down Expand Up @@ -101,6 +102,7 @@ const STRINGS = {
staleEnds: '末端と宣言したのに、読まれている・もう書かれていないもの: {list}',
d_location: '場所',
d_files: '読み書きしたファイル',
openFile: 'エディタで開く',
d_frame: '枠',
d_folded: '畳んだ読み',
d_urls: '取得先の例',
Expand Down
6 changes: 6 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ body {
.detail dt { color: var(--fg-faint); font-size: 12px; margin-top: 8px; }
.detail dd { margin: 2px 0 0; color: var(--fg-muted); word-break: break-all; }
.detail ul { margin: 2px 0 0; padding-left: 16px; }
.detail .open-file {
font: inherit; color: inherit; text-align: left; word-break: break-all;
background: none; border: 0; padding: 0; cursor: pointer;
text-decoration: underline; text-decoration-color: var(--fg-faint); text-underline-offset: 2px;
}
.detail .open-file:hover { color: var(--fg); }

/* ---- graph ---- */
.edge { stroke: var(--line-strong); stroke-width: 1.2; fill: none; }
Expand Down
3 changes: 3 additions & 0 deletions vscode/.vscodeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
scripts/**
**/*.vsix
**/__pycache__/**
33 changes: 33 additions & 0 deletions vscode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# loopfinder for VS Code

**Find the feedback loops in your workspace — including the ones you never meant to build.**

The [loopfinder](https://github.com/MotimotiNotch/loopfinder) CLI and viewer, inside the editor. No Node install
needed: it runs on the editor's own runtime (Python is needed only to trace `.py` scripts).

## Use

From the Command Palette:

1. **loopfinder: Set up this workspace** — makes `loopfinder/` (config.json, flows.json, AGENTS.md).
2. Ask your AI agent (Claude Code, Codex, Cursor, Copilot, ...):
*"Read loopfinder/AGENTS.md and survey this workspace."*
(**loopfinder: Copy the request for your AI agent** puts it on the clipboard.) It writes `loopfinder/flows.json`.
3. **loopfinder: Build the graph** — runs the traced scripts with writes blocked and draws the graph.
4. **loopfinder: Show the loops** — opens the viewer. It redraws by itself after every build, from the
palette or from `loopfinder build` in a terminal. Click a path in the side pane to open the file.

## Safety

Building **really runs your scripts**. Writes are recorded and dropped; network and subprocesses are blocked
unless you turn on `loopfinder.allowNetwork` / `loopfinder.allowSubprocess`. It is a recorder, not a sandbox:
do not trace code you would not run.

In a workspace you have not [trusted](https://code.visualstudio.com/docs/editor/workspace-trust), set up and
build are off; you can still view a graph that is already built.

`.loopfinder/` (the build output) lists your file paths. Keep it out of version control.

## Support

[Ko-fi](https://ko-fi.com/motimotinotch) · MIT License · bundles dagre (MIT) and Lucide icons (ISC)
196 changes: 196 additions & 0 deletions vscode/extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// loopfinder inside VS Code: the same CLI and the same viewer, with the viewer in a webview.
// Init and build run the CLI as a child process (build really runs the workspace's scripts, so both are
// off in an untrusted workspace). Show only reads the built graph, and works anywhere.
'use strict';
const vscode = require('vscode');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { fork } = require('child_process');

// The packaged extension carries a copy of the CLI in core/ (scripts/prepare.js).
// Run from the repository (Extension Development Host), it uses the repository itself.
const CORE = fs.existsSync(path.join(__dirname, 'core', 'bin')) ? path.join(__dirname, 'core') : path.join(__dirname, '..');
const CLI = path.join(CORE, 'bin', 'loopfinder.js');
const PUBLIC = path.join(CORE, 'public');
const { loadConfig } = require(path.join(CORE, 'src', 'config'));

const PROMPT = 'Read loopfinder/AGENTS.md and survey this workspace.';
const configOf = folder => path.join(folder, 'loopfinder', 'config.json');

let output;
let viewer = null; // { panel, folder, config, watcher }

// The workspace folder to work on. Folders without loopfinder/ are left out unless setting one up.
async function pickFolder(needConfig) {
const folders = (vscode.workspace.workspaceFolders || []).filter(f => f.uri.scheme === 'file');
const candidates = needConfig ? folders.filter(f => fs.existsSync(configOf(f.uri.fsPath))) : folders;
if (!candidates.length) {
if (!folders.length) vscode.window.showWarningMessage('loopfinder: open a folder first.');
else {
const init = 'Set up';
const choice = await vscode.window.showWarningMessage('loopfinder: no loopfinder/config.json in this workspace.', ...(vscode.workspace.isTrusted ? [init] : []));
if (choice === init) vscode.commands.executeCommand('loopfinder.init');
}
return null;
}
if (candidates.length === 1) return candidates[0].uri.fsPath;
const pick = await vscode.window.showQuickPick(candidates.map(f => ({ label: f.name, description: f.uri.fsPath })), { placeHolder: 'Which folder?' });
return pick ? pick.description : null;
}

// Runs the CLI with the editor's own Node (ELECTRON_RUN_AS_NODE), so no Node install is needed.
// The traced scripts inherit it the same way.
function runCli(folder, args, token) {
return new Promise(resolve => {
output.appendLine(`> loopfinder ${args.join(' ')} (${folder})`);
const child = fork(CLI, args, { cwd: folder, silent: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } });
child.stdout.on('data', d => output.append(d.toString()));
child.stderr.on('data', d => output.append(d.toString()));
const cancel = token?.onCancellationRequested(() => child.kill());
child.on('error', e => { output.appendLine(e.message); });
child.on('close', code => { cancel?.dispose(); output.appendLine(`(exit ${code})`); resolve(code); });
});
}

// `enablement` only greys out the menus; a command run from code still arrives here.
function trusted() {
if (vscode.workspace.isTrusted) return true;
vscode.window.showWarningMessage('loopfinder: trust this workspace first. Building runs its scripts.');
return false;
}

async function cmdInit() {
if (!trusted()) return;
const folder = await pickFolder(false);
if (!folder) return;
const code = await runCli(folder, ['init']);
if (code !== 0) { showFailure('init'); return; }
const copy = 'Copy the request';
const choice = await vscode.window.showInformationMessage(
`loopfinder: set up. Next, ask your AI agent: "${PROMPT}" It writes loopfinder/flows.json; then build.`, copy);
if (choice === copy) cmdCopyPrompt();
}

function cmdCopyPrompt() {
vscode.env.clipboard.writeText(PROMPT);
vscode.window.setStatusBarMessage('loopfinder: copied the request for your AI agent', 4000);
}

async function cmdBuild() {
if (!trusted()) return;
const folder = await pickFolder(true);
if (!folder) return;
const settings = vscode.workspace.getConfiguration('loopfinder', vscode.Uri.file(folder));
const args = ['build', '--config', configOf(folder), '--budget', String(settings.get('layoutBudget', 15))];
if (settings.get('allowNetwork')) args.push('--allow-network');
if (settings.get('allowSubprocess')) args.push('--allow-subprocess');
const code = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'loopfinder: tracing the scripts and building the graph', cancellable: true },
(_, token) => runCli(folder, args, token));
if (code !== 0) { showFailure('build'); return; }
// An open viewer picks the new graph up through its watcher.
if (!viewer || viewer.folder !== folder) show(folder);
}

async function showFailure(what) {
const open = 'Show output';
if (await vscode.window.showErrorMessage(`loopfinder: ${what} failed.`, open) === open) output.show();
}

async function cmdShow() {
const folder = await pickFolder(true);
if (folder) show(folder);
}

function readGraph(config) {
try { return JSON.parse(fs.readFileSync(config.output, 'utf8')); } catch { return null; }
}

async function show(folder) {
const config = loadConfig(configOf(folder));
if (!fs.existsSync(config.output)) {
const build = 'Build';
const choice = await vscode.window.showInformationMessage('loopfinder: no graph yet. Build it first.', ...(vscode.workspace.isTrusted ? [build] : []));
if (choice === build) cmdBuild();
return;
}
if (viewer && viewer.folder === folder) { viewer.panel.reveal(); return; }
viewer?.panel.dispose();

const panel = vscode.window.createWebviewPanel('loopfinder', `loopfinder: ${path.basename(folder)}`, vscode.ViewColumn.Active, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.file(PUBLIC), vscode.Uri.file(path.join(__dirname, 'media'))],
});
panel.webview.html = viewerHtml(panel.webview);
const send = () => {
const graph = readGraph(config);
// A half-written file fails to parse; the watcher fires again when the write finishes.
if (graph) panel.webview.postMessage({ type: 'graph', graph });
};
panel.webview.onDidReceiveMessage(m => {
if (m?.type === 'ready') send();
else if (m?.type === 'open' && typeof m.path === 'string') openPath(config, m.path);
});

const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(path.dirname(config.output)), path.basename(config.output)));
let timer;
const later = () => { clearTimeout(timer); timer = setTimeout(send, 300); };
watcher.onDidChange(later);
watcher.onDidCreate(later);

viewer = { panel, folder, config, watcher };
panel.onDidDispose(() => {
watcher.dispose();
clearTimeout(timer);
if (viewer?.panel === panel) viewer = null;
});
}

// Opens a node's path in the editor. Paths in the graph are relative to the root, or start with ~/.
// Folded names ({date}, the scan node) are not one file and are said so.
function openPath(config, p) {
const file = p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(config.root, p);
let stat = null;
try { stat = fs.statSync(file); } catch { /* not there */ }
if (stat?.isFile()) vscode.commands.executeCommand('vscode.open', vscode.Uri.file(file), { preview: true, viewColumn: vscode.ViewColumn.Beside });
else if (stat?.isDirectory()) vscode.commands.executeCommand('revealInExplorer', vscode.Uri.file(file));
else vscode.window.showInformationMessage(`loopfinder: not a single file: ${p}`);
}

// The viewer's own index.html, with its files served through the webview and a CSP that allows only them.
function viewerHtml(webview) {
const nonce = crypto.randomBytes(16).toString('base64');
const uri = (...parts) => webview.asWebviewUri(vscode.Uri.file(path.join(...parts))).toString();
const csp = [
"default-src 'none'",
`style-src ${webview.cspSource}`,
`img-src ${webview.cspSource} data:`,
`script-src 'nonce-${nonce}'`,
].join('; ');
return fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8')
.replace(/(href|src)="(?!https?:|#)([^"]+)"/g, (_, attr, file) => `${attr}="${uri(PUBLIC, file)}"`)
.replace(/<script /g, `<script nonce="${nonce}" `)
.replace('<meta charset="utf-8">', `<meta charset="utf-8">\n <meta http-equiv="Content-Security-Policy" content="${csp}">`)
// The host goes first: app.js looks for it when it starts.
.replace(/<script /, `<script nonce="${nonce}" src="${uri(__dirname, 'media', 'host.js')}"></script>\n <script `);
}

function activate(context) {
output = vscode.window.createOutputChannel('loopfinder');
context.subscriptions.push(
output,
vscode.commands.registerCommand('loopfinder.show', cmdShow),
vscode.commands.registerCommand('loopfinder.build', cmdBuild),
vscode.commands.registerCommand('loopfinder.init', cmdInit),
vscode.commands.registerCommand('loopfinder.copyPrompt', cmdCopyPrompt),
{ dispose: () => viewer?.panel.dispose() },
);
}

function deactivate() {}

module.exports = { activate, deactivate, viewerHtml, openPath };
Loading
Loading