The tool list sits in the AI's context every round and is at its budget (issue #27). The address may now name a shorter one: ?tools=core (drawing in the AI app's own chat: no workbench, no file moves, scans or downloads, no voice; the ways, skills, zoom_image and stop_run stay) or ?tools=mine (the user's checklist by stage, kept in user/anomalous/ai_tool_sets.json). Only the list shrinks: tools/call takes every tool, an unknown or empty set offers all, and the address as it is answers exactly as before. The instructions are split into parts and a part naming a tool outside the set is dropped; without the workbench the AI is told to ask in its own chat. Connect an AI picks the set above the address (not under a waiting task, which needs the workbench); the address, the sentence and each app's line follow. Users pick from Anomalous's tools and never add their own (AD-027). The structure check passed absolute paths for every web file on one command line, which grew past what Windows takes; it now passes them relative to the plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
6.2 KiB
JavaScript
116 lines
6.2 KiB
JavaScript
// Structure check, run before every commit: node tools/check_structure.mjs
|
|
// Fails when a source file is missing from the architecture map, a web module is
|
|
// no longer imported from an entry, a new file starts over the size threshold, a
|
|
// file already over it grew since the last commit (AGENTS.md section 2), or a web file
|
|
// does not parse as the ES module the browser loads, a CSS :has() matches the style
|
|
// attribute, or the UI map's hand-written part (docs/architecture/ui-flows.md) names words,
|
|
// files or routes the code no longer has (tools/ui_map.mjs).
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
const list = (dir, ext) => fs.readdirSync(path.join(ROOT, dir))
|
|
.filter((f) => f.endsWith(ext))
|
|
.map((f) => path.posix.join(dir, f));
|
|
|
|
const ENTRIES = ['web/main.js', 'web/hash_resolver.js'];
|
|
// Language catalogs and data tables are exempt from the size threshold (AGENTS.md section 2).
|
|
const SIZE_EXEMPT = new Set(['web/modules/locales.js']);
|
|
const SIZE_LIMIT = 600;
|
|
|
|
const pythonFiles = [...list('.', '.py'), ...list('api', '.py')].filter((f) => !f.endsWith('__init__.py'));
|
|
const webFiles = [...list('web', '.js'), ...list('web/modules', '.js')];
|
|
const failures = [];
|
|
|
|
// 1. Every source file has a line in ARCHITECTURE.md or docs/architecture/.
|
|
const docs = ['ARCHITECTURE.md', ...list('docs/architecture', '.md')].map(read).join('\n');
|
|
for (const file of [...pythonFiles, ...webFiles]) {
|
|
if (!docs.includes(path.posix.basename(file))) failures.push(`${file}: no line in the architecture map`);
|
|
}
|
|
|
|
// 2. Every web module is reachable by imports from an entry.
|
|
const IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+\.js)['"]/g;
|
|
const reached = new Set();
|
|
const walk = (file) => {
|
|
if (reached.has(file) || !fs.existsSync(path.join(ROOT, file))) return;
|
|
reached.add(file);
|
|
for (const [, spec] of read(file).matchAll(IMPORT)) walk(path.posix.join(path.posix.dirname(file), spec));
|
|
};
|
|
ENTRIES.forEach(walk);
|
|
for (const file of webFiles) {
|
|
if (!reached.has(file)) failures.push(`${file}: not imported from ${ENTRIES.join(' or ')}; remove it or wire it in`);
|
|
}
|
|
|
|
// 3. Size threshold: files over it are listed; a new file may not start over it, and a
|
|
// file already over it may only shrink or stay the same (compared with the last commit).
|
|
const lineCount = (text) => text.replace(/\r\n/g, '\n').split('\n').length;
|
|
const committedLines = (file) => {
|
|
try {
|
|
return lineCount(execFileSync('git', ['show', `HEAD:${file}`], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
} catch (_) {
|
|
return null; // not committed yet (a new file), or no git here
|
|
}
|
|
};
|
|
const large = [...pythonFiles, ...webFiles]
|
|
.filter((f) => !SIZE_EXEMPT.has(f))
|
|
.map((f) => [f, lineCount(read(f))])
|
|
.filter(([, lines]) => lines > SIZE_LIMIT)
|
|
.sort((a, b) => b[1] - a[1]);
|
|
|
|
if (large.length) {
|
|
console.log(`Over ${SIZE_LIMIT} lines (may only shrink, AGENTS.md section 2):`);
|
|
for (const [file, lines] of large) {
|
|
const before = committedLines(file);
|
|
const note = before === null ? ' NEW' : lines > before ? ` grew from ${before}` : '';
|
|
console.log(` ${lines}\t${file}${note}`);
|
|
if (before === null) failures.push(`${file}: new file over ${SIZE_LIMIT} lines; split it by responsibility`);
|
|
else if (lines > before) failures.push(`${file}: over ${SIZE_LIMIT} lines and grew from ${before} to ${lines}; move a responsibility out first`);
|
|
}
|
|
}
|
|
// 4. Syntax: every web file must parse as an ES module. `node --check` on a .js file can
|
|
// pass what the browser rejects (a raw line break inside a string did), so compile them
|
|
// the way the browser does, in one child process. Paths go relative to the plugin: absolute ones
|
|
// made the command line longer than Windows takes (32,767 characters) once the files grew past ~260.
|
|
const PARSE = "const vm = require('node:vm'); const fs = require('node:fs');"
|
|
+ " for (const f of process.argv.slice(1)) { try { new vm.SourceTextModule(fs.readFileSync(f, 'utf8')); }"
|
|
+ " catch (e) { console.log(f + ' | ' + e.message); } }";
|
|
const syntax = execFileSync(process.execPath, ['--experimental-vm-modules', '--no-warnings', '-e', PARSE,
|
|
...webFiles], { encoding: 'utf8', cwd: ROOT });
|
|
for (const line of syntax.split(/\r?\n/).filter(Boolean)) {
|
|
const [file, message] = line.split(' | ');
|
|
failures.push(`${file.split(path.sep).join('/')}: does not parse (${message})`);
|
|
}
|
|
|
|
// 5. No `:has()` that matches the style attribute. Chrome then treats every inline-style
|
|
// write on the page as a possible change and recalculates styles for the whole page; with
|
|
// extensions that move overlays every frame, canvas dragging fell to a few fps (v1.57.2,
|
|
// issue #22). Mirror the state as a class on an ancestor instead.
|
|
for (const file of [...list('web', '.css'), ...list('web/styles', '.css')]) {
|
|
const css = read(file).replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' '));
|
|
for (let start = css.indexOf(':has('); start !== -1; start = css.indexOf(':has(', start + 1)) {
|
|
let depth = 0;
|
|
let end = start + 4;
|
|
for (; end < css.length; end++) {
|
|
if (css[end] === '(') depth++;
|
|
else if (css[end] === ')' && --depth === 0) break;
|
|
}
|
|
if (/\[\s*style\b/i.test(css.slice(start, end))) {
|
|
const line = css.slice(0, start).split('\n').length;
|
|
failures.push(`${file}:${line}: :has() matches the style attribute, which makes every inline-style change restyle the whole page; toggle a class instead`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. The UI map's hand-written part says what the screen and the code have now.
|
|
const { check: uiFlowFailures } = await import('./ui_map.mjs');
|
|
failures.push(...await uiFlowFailures());
|
|
|
|
if (failures.length) {
|
|
console.error(`\nStructure check failed:\n ${failures.join('\n ')}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`\nStructure check passed: ${pythonFiles.length + webFiles.length} source files mapped, ${reached.size} web modules reachable.`);
|