diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a2ccc0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +vendor/** linguist-vendored whitespace=-trailing-space,-space-before-tab +vendor/**/*.wasm binary +vendor/**/*.woff2 binary diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000..c07658c --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,4 @@ +name: CodeFlow CodeQL configuration + +paths-ignore: + - vendor/** diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3a63e28..11f6b4a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,6 +24,7 @@ jobs: with: languages: javascript-typescript build-mode: none + config-file: ./.github/codeql-config.yml - name: Perform CodeQL analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ed319c9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Run test suite + run: node --test tests/*.test.mjs tests/*.smoke.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e92213 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Braedon Saunders + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6e9afd2..ff53205 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ git clone https://github.com/braedonsaunders/codeflow.git open index.html ``` -No build process. No npm install. It is a single `index.html` app that loads pinned browser dependencies from CDNs. +No build process. No npm install. Clone the whole repository: `index.html` loads pinned, +checked-in browser dependencies from `vendor/`, so a fresh local launch works without a network connection. ### Option 3: Analyze Local Files You can now analyze code directly from your local machine without uploading to GitHub: @@ -236,6 +237,10 @@ CodeFlow extracts functions and analyzes dependencies for: | Clojure | `.clj`, `.cljs`, `.cljc` | | Elm | `.elm` | | VBA | `.vba`, `.bas`, `.cls`, `.xlsm`, `.xlsb`, `.xlam` | +| Delphi / FreePascal / Object Pascal | `.pas`, `.pp`, `.dpr`, `.dpk`, `.lpr`, `.inc` | + +Pascal support is heuristic. It recognizes routines and `uses` unit dependencies while filtering +strings and Pascal comment forms; unsupported language features fall back gracefully. --- @@ -308,12 +313,19 @@ For larger repositories or team usage, we recommend using GitHub App authenticat │ │ │ │ ┌────────▼────────┐ │ │ │ React App │ │ -│ │ (Single File) │ │ +│ │ (Static Assets) │ │ │ └─────────────────┘ │ └─────────────────────────────────────────────────┘ ``` -**Zero build dependencies to install.** Everything runs from pinned CDNs: +**Zero build dependencies to install.** Browser libraries and required Tree-sitter WASM files are +pinned under `vendor/`, with source URLs, SHA-256 digests, and license notices. To refresh them: + +```bash +node scripts/vendor-browser-deps.mjs +``` + +The vendored runtime includes: - React 18 - D3.js 7 - Babel (for JSX) @@ -329,10 +341,10 @@ We love contributions! Here's how: 3. Test locally (just open in browser) 4. Submit a PR -If you're editing the markdown / wiki-link parser, Node.js unit tests live under `tests/` and run with no dependencies: +Node.js unit tests live under `tests/` and run with no dependencies: ```bash -node --test tests/ +node --test tests/*.test.mjs tests/*.smoke.js ``` `tests/verify-brain-vault.mjs` is an optional end-to-end script that always verifies the bundled fixtures and will also scan a real local vault when you explicitly set `BRAIN_VAULT=/path/to/vault`. @@ -352,10 +364,10 @@ node --test tests/ > CodeFlow runs entirely in your browser. It calls the GitHub API directly from your browser and processes everything client-side. **Q: Is my code safe?** -> Yes. Your code is fetched directly from GitHub to your browser. Nothing is sent to any server we control. Check the source — it's one file! +> Yes. Your code is fetched directly from GitHub to your browser. Nothing is sent to any server we control. The app and its pinned browser dependencies are checked into this repository for inspection. **Q: Can I use it offline?** -> Yes. With the local file analysis feature, you can analyze code from your computer without any internet connection. Click the "Open Folder" button and select your files. All processing happens entirely in your browser. +> Yes. Clone the complete repository, open `index.html`, then use the local file analysis feature. All required browser and parser assets are checked in, so even a cold first load works without internet. GitHub URL analysis still requires access to GitHub's API. **Q: Why is analysis slow?** > We make individual API calls for each file to get content. With a token, you get higher rate limits and faster analysis. @@ -422,7 +434,7 @@ If you find CodeFlow useful, please star the repo. ## License -MIT License — use it however you want. +[MIT License](./LICENSE) — use it however you want. --- diff --git a/card/README.md b/card/README.md index eaf8d43..73c4f18 100644 --- a/card/README.md +++ b/card/README.md @@ -4,6 +4,22 @@ A GitHub Action that drops a slick auto-updating SVG card on your README — hea The card uses the **same analyzer** as the codeflow web app. There's no separate parser, no version drift — the Action reads codeflow's `index.html` and runs its analyzer in a Node `vm`. +## Headless analysis + +Use the same pipeline without rendering a card, writing history, committing files, or posting PR comments: + +```bash +node card/analyze.js --path /path/to/repository --exclude 'vendor/**' --exclude '*.min.js' +``` + +The command writes only JSON to stdout. Its versioned envelope contains the analyzer's full `data` +object and the card-ready `snapshot`; errors go to stderr. It is also available programmatically: + +```js +const { analyze } = require('./card/analyze.js'); +const result = await analyze({ repoRoot: '/path/to/repository', exclude: ['vendor/**'] }); +``` + ## Quick start Drop this file in `.github/workflows/codeflow-card.yml`: diff --git a/card/analyze.js b/card/analyze.js new file mode 100644 index 0000000..ea28148 --- /dev/null +++ b/card/analyze.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +'use strict'; + +const path = require('path'); + +const { analyze } = require('./lib/analysis.js'); + +function usage() { + return [ + 'Usage: node card/analyze.js [--path ] [--exclude ...]', + '', + 'Writes a versioned CodeFlow analysis envelope to stdout as JSON.', + 'Repeat --exclude to omit multiple files or directory patterns.', + ].join('\n'); +} + +function readValue(argv, index, flag) { + if (index + 1 >= argv.length) throw new Error(flag + ' requires a value'); + return argv[index + 1]; +} + +function parseArgs(argv) { + const parsed = { repoRoot: process.cwd(), exclude: [], help: false }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') { + parsed.help = true; + } else if (arg === '--path') { + parsed.repoRoot = readValue(argv, index, '--path'); + index++; + } else if (arg.startsWith('--path=')) { + parsed.repoRoot = arg.slice('--path='.length); + } else if (arg === '--exclude') { + parsed.exclude.push(readValue(argv, index, '--exclude')); + index++; + } else if (arg.startsWith('--exclude=')) { + parsed.exclude.push(arg.slice('--exclude='.length)); + } else { + throw new Error('Unknown argument: ' + arg); + } + } + parsed.repoRoot = path.resolve(parsed.repoRoot); + return parsed; +} + +async function main(argv) { + const parsed = parseArgs(argv || process.argv.slice(2)); + if (parsed.help) { + process.stdout.write(usage() + '\n'); + return; + } + const result = await analyze({ repoRoot: parsed.repoRoot, exclude: parsed.exclude }); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write('[codeflow-analyze] error: ' + (error.stack || error.message || error) + '\n'); + process.exitCode = 1; + }); +} + +module.exports = { analyze, main, parseArgs, usage }; diff --git a/card/index.js b/card/index.js index 46b21ce..11a3c0b 100644 --- a/card/index.js +++ b/card/index.js @@ -9,10 +9,8 @@ const fs = require('fs'); const path = require('path'); const { loadInputs } = require('./lib/inputs.js'); -const { loadAnalyzer, locateIndexHtml } = require('./lib/analyzer.js'); -const { buildAnalyzed } = require('./lib/collect.js'); -const { compileExcludePatterns } = require('./lib/exclude.js'); -const { readState, appendRun, writeState, snapshotFromAnalysis } = require('./lib/state.js'); +const { analyze } = require('./lib/analysis.js'); +const { readState, appendRun, writeState } = require('./lib/state.js'); const { renderCard } = require('./render/card.js'); const { renderReceiptMarkdown } = require('./render/receipt-md.js'); const { commitAndPush } = require('./lib/git.js'); @@ -47,29 +45,6 @@ async function run() { const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd(); log('analyzing ' + repoRoot); - const actionDir = __dirname; - const indexHtmlPath = locateIndexHtml(actionDir, repoRoot); - log('analyzer source: ' + indexHtmlPath); - - const { Parser, buildAnalysisData, calcBlast, calcHealth } = loadAnalyzer(indexHtmlPath); - - const excludePatterns = compileExcludePatterns(inputs.exclude); - if (excludePatterns.length > 0) { - log('exclude patterns: ' + excludePatterns.map((p) => p.raw).join(', ')); - } - - const { analyzed, allFns } = await buildAnalyzed(repoRoot, Parser, excludePatterns); - log('collected ' + analyzed.length + ' files (' + allFns.length + ' functions)'); - - const data = await buildAnalysisData({ - analyzed, - allFns, - excludePatterns: excludePatterns.map((p) => p.raw), - progress: () => {}, - yieldFn: async () => {}, - }); - log('analysis: files=' + data.stats.files + ' fns=' + data.stats.functions + ' loc=' + data.stats.loc); - const event = loadEvent(); const sha = process.env.GITHUB_SHA || null; const actor = process.env.GITHUB_ACTOR || null; @@ -78,9 +53,13 @@ async function run() { (event && event.number) || null; const ctx = { sha, actor, pr: prNumber }; - - const snapshot = snapshotFromAnalysis(data, { calcBlast, calcHealth }, ctx); - log('grade=' + (snapshot.grade || '?') + ' score=' + (snapshot.score == null ? '?' : snapshot.score)); + const { data, snapshot } = await analyze({ + repoRoot, + actionDir: __dirname, + exclude: inputs.exclude, + context: ctx, + progress: log, + }); const stateAbs = path.resolve(repoRoot, inputs.state); const state = readState(stateAbs); diff --git a/card/lib/analysis.js b/card/lib/analysis.js new file mode 100644 index 0000000..0e1b78b --- /dev/null +++ b/card/lib/analysis.js @@ -0,0 +1,62 @@ +// Shared side-effect-free analysis pipeline used by the Action and headless CLI. + +'use strict'; + +const path = require('path'); + +const { loadAnalyzer, locateIndexHtml } = require('./analyzer.js'); +const { buildAnalyzed } = require('./collect.js'); +const { compileExcludePatterns } = require('./exclude.js'); +const { snapshotFromAnalysis } = require('./state.js'); + +const HEADLESS_SCHEMA_VERSION = 1; + +function normalizeExcludeInput(exclude) { + if (Array.isArray(exclude)) return exclude.join(','); + return exclude == null ? '' : String(exclude); +} + +async function analyze(options) { + const opts = options || {}; + const repoRoot = path.resolve(opts.repoRoot || process.cwd()); + const actionDir = path.resolve(opts.actionDir || path.join(__dirname, '..')); + const progress = typeof opts.progress === 'function' ? opts.progress : () => {}; + const indexHtmlPath = opts.indexHtmlPath || locateIndexHtml(actionDir, repoRoot); + + progress('analyzer source: ' + indexHtmlPath); + const { Parser, buildAnalysisData, calcBlast, calcHealth } = loadAnalyzer(indexHtmlPath); + const excludePatterns = compileExcludePatterns(normalizeExcludeInput(opts.exclude)); + if (excludePatterns.length > 0) { + progress('exclude patterns: ' + excludePatterns.map((pattern) => pattern.raw).join(', ')); + } + + const { analyzed, allFns } = await buildAnalyzed(repoRoot, Parser, excludePatterns); + progress('collected ' + analyzed.length + ' files (' + allFns.length + ' functions)'); + + const data = await buildAnalysisData({ + analyzed, + allFns, + excludePatterns: excludePatterns.map((pattern) => pattern.raw), + progress: (message) => progress(message), + yieldFn: async () => {}, + }); + progress( + 'analysis: files=' + data.stats.files + + ' fns=' + data.stats.functions + + ' loc=' + data.stats.loc + ); + + const snapshot = snapshotFromAnalysis( + data, + { calcBlast, calcHealth }, + opts.context || {} + ); + progress( + 'grade=' + (snapshot.grade || '?') + + ' score=' + (snapshot.score == null ? '?' : snapshot.score) + ); + + return { schemaVersion: HEADLESS_SCHEMA_VERSION, data, snapshot }; +} + +module.exports = { analyze, HEADLESS_SCHEMA_VERSION, normalizeExcludeInput }; diff --git a/card/lib/collect.js b/card/lib/collect.js index 848a535..dd65fa4 100644 --- a/card/lib/collect.js +++ b/card/lib/collect.js @@ -11,6 +11,7 @@ const { matchesExcludePattern } = require('./exclude.js'); const DEFAULT_IGNORES = new Set([ '.git', 'node_modules', + 'vendor', '.next', '.nuxt', 'dist', diff --git a/card/lib/exclude.js b/card/lib/exclude.js index a71e506..3acb7c7 100644 --- a/card/lib/exclude.js +++ b/card/lib/exclude.js @@ -1,6 +1,6 @@ // Exclude-pattern support for the card action. Ports the browser app's // pattern helpers (index.html: normalizeExcludePath / parseExcludePatterns / -// globToRegex / matchesExcludePattern) so `exclude:` input patterns behave +// globMatches / matchesExcludePattern) so `exclude:` input patterns behave // exactly like the web UI's custom excludes: `vendor/**`, `**/cache/**`, // `*.min.js`, or a bare name that matches any path segment. // @@ -26,35 +26,58 @@ function parseExcludePatterns(input) { }); } -function escapeRegexChar(ch) { - return /[|\\{}()[\]^$+?.]/.test(ch) ? '\\' + ch : ch; -} +// Match the small glob dialect used by CodeFlow without turning user input +// into executable regular expressions. Memoization keeps adjacent wildcards +// and long paths from causing exponential backtracking. +function globMatches(pattern, value) { + const glob = normalizeExcludePath(pattern).toLowerCase(); + const candidate = normalizeExcludePath(value).toLowerCase(); + const memo = new Map(); + + function match(globIndex, valueIndex) { + const key = globIndex + ':' + valueIndex; + if (memo.has(key)) return memo.get(key); -function globToRegex(pattern) { - const normalized = normalizeExcludePath(pattern).toLowerCase(); - let out = '^'; - for (let i = 0; i < normalized.length; i++) { - const ch = normalized[i]; - if (ch === '*') { - if (normalized[i + 1] === '*') { - if (normalized[i + 2] === '/') { - out += '(?:[^/]+/)*'; - i += 2; + let result; + if (globIndex === glob.length) { + result = valueIndex === candidate.length; + } else if (glob[globIndex] === '*') { + if (glob[globIndex + 1] === '*') { + if (glob[globIndex + 2] === '/') { + result = match(globIndex + 3, valueIndex); + if (!result) { + const slashIndex = candidate.indexOf('/', valueIndex); + result = slashIndex > valueIndex && match(globIndex, slashIndex + 1); + } } else { - out += '.*'; - i++; + result = + match(globIndex + 2, valueIndex) || + (valueIndex < candidate.length && match(globIndex, valueIndex + 1)); } } else { - out += '[^/]*'; + result = + match(globIndex + 1, valueIndex) || + (valueIndex < candidate.length && + candidate[valueIndex] !== '/' && + match(globIndex, valueIndex + 1)); } - } else if (ch === '?') { - out += '[^/]'; + } else if (glob[globIndex] === '?') { + result = + valueIndex < candidate.length && + candidate[valueIndex] !== '/' && + match(globIndex + 1, valueIndex + 1); } else { - out += escapeRegexChar(ch); + result = + valueIndex < candidate.length && + glob[globIndex] === candidate[valueIndex] && + match(globIndex + 1, valueIndex + 1); } + + memo.set(key, result); + return result; } - out += '$'; - return new RegExp(out, 'i'); + + return match(0, 0); } function compileExcludePatterns(input) { @@ -65,7 +88,7 @@ function compileExcludePatterns(input) { return { raw: pattern, lower, - regex: hasGlob || hasPath ? globToRegex(pattern) : null, + useGlob: hasGlob || hasPath, }; }); } @@ -78,13 +101,13 @@ function matchesExcludePattern(compiledPatterns, path, name) { const lowerPathWithSlash = lowerPath ? lowerPath + '/' : ''; const segments = lowerPath.split('/').filter(Boolean); return compiledPatterns.some((pattern) => { - if (!pattern.regex) { + if (!pattern.useGlob) { return lowerName === pattern.lower || segments.includes(pattern.lower); } return ( - pattern.regex.test(lowerPath) || - pattern.regex.test(lowerPathWithSlash) || - pattern.regex.test(lowerName) + globMatches(pattern.lower, lowerPath) || + globMatches(pattern.lower, lowerPathWithSlash) || + globMatches(pattern.lower, lowerName) ); }); } diff --git a/card/package.json b/card/package.json index c953f5d..213930f 100644 --- a/card/package.json +++ b/card/package.json @@ -9,6 +9,7 @@ "node": ">=20" }, "scripts": { + "analyze": "node analyze.js", "dry-run": "node index.js" }, "license": "MIT" diff --git a/index.html b/index.html index edcae0d..1efedb7 100644 --- a/index.html +++ b/index.html @@ -17,20 +17,23 @@ - - - - - - - - - - - - - + + + + + + + + + + + +