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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gaurav2180
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<p align="center"><img src="brand/tecton-banner.png" alt="Tecton: see every shift in your architecture before it becomes a crack" width="720"></p>
|
|
2
|
+
|
|
3
|
+
# Tecton
|
|
4
|
+
|
|
5
|
+
**See every shift in your architecture before it becomes a crack.**
|
|
6
|
+
|
|
7
|
+
Tecton draws your project as a map of **modules** (folders) and the **dependencies** between them,
|
|
8
|
+
compares it with a git branch, and checks **house rules** such as "components must not talk to the
|
|
9
|
+
database directly". You get an interactive HTML page, an optional Markdown summary for pull requests,
|
|
10
|
+
and a non-zero exit code when a change breaks a rule.
|
|
11
|
+
|
|
12
|
+
- No dependencies. Needs Node 18+ and git.
|
|
13
|
+
- Understands `import`, `export … from`, `require()`, `import()`, `tsconfig`/`jsconfig` path aliases
|
|
14
|
+
(like `@/components/...`), `index` files, `./file.js` → `file.ts`, and the `<script>` blocks of Vue and Svelte files.
|
|
15
|
+
- Compares against the point where your branch split off, so work merged to `main` meanwhile
|
|
16
|
+
doesn't show up as your change.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd your-project
|
|
22
|
+
npx tecton
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That's it: no install, no config. Tecton maps your code, compares it with `main` (or `master`) and opens the report in your browser.
|
|
26
|
+
The report goes to a temp folder, so nothing is added to your project. It works in a single app, in a monorepo (`apps/*` and `packages/*` become the parts), and without git (as a snapshot).
|
|
27
|
+
|
|
28
|
+
When you want more:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx tecton serve # the same report, refreshed in your browser every time you save
|
|
32
|
+
npx tecton init # add tecton.config.json with your modules and a first rule, so CI can enforce it
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
In CI, or when output is piped, it writes `./tecton-report.html` and opens nothing (or pass `--out`). Use `--no-open` or `BROWSER=none` to skip the browser anywhere.
|
|
36
|
+
|
|
37
|
+
## The report
|
|
38
|
+
|
|
39
|
+
One self-contained HTML file (fonts included, works offline) laid out like a small SaaS app:
|
|
40
|
+
|
|
41
|
+
- **Overview**: pass/fail status, counters, a breakdown of dependency changes, rule checks, a live map preview and files per module.
|
|
42
|
+
- **Architecture**: a C4 container diagram read from your code. Users sit on top, your repo is a dashed system boundary holding its apps, databases (cylinders) and queues (pipes), and outside systems sit on the right. Every connector is labelled (`HTTP · API_URL`, `reads & writes · SQL`, `adds jobs`), queue traffic is dotted, and connectors from one app travel as a bundle. Pan, zoom, hover an app to trace its connections, and export as SVG. What it finds:
|
|
43
|
+
- **Apps**: every `package.json` (Next.js, Nuxt, SvelteKit, Express, Fastify, NestJS, Hono, Koa, …).
|
|
44
|
+
- **Entry points**:
|
|
45
|
+
- pages and API routes: Next.js app/pages router, Nuxt `pages/` and `server/api/`, SvelteKit `+page.svelte` / `+server.ts`
|
|
46
|
+
- HTTP endpoints, with their full path: Express/Koa/Hono routers mounted with `app.use('/api', router)`, even across files; Fastify `register(plugin, { prefix })` and `route({ method, url })`; Hono `basePath`/`route`; NestJS `@Controller` + `@Get` with `setGlobalPrefix`
|
|
47
|
+
- npm-script jobs
|
|
48
|
+
- **cron schedules** in plain words ("every day at 03:00"), from `node-cron`, `cron`, `node-schedule`, BullMQ repeatable jobs, NestJS `@Cron` and `vercel.json`
|
|
49
|
+
- **Data and queues**: SQLite, Postgres, MySQL, Mongo, Redis, Prisma, Drizzle, S3, …, plus **queues** (BullMQ, Bull and Bee-Queue by queue name; SQS, RabbitMQ, Kafka, Pub/Sub, Service Bus, QStash, Inngest, …). Connection strings in `.env.example` name the real engine (`DATABASE_URL=postgres://…` next to Prisma shows "PostgreSQL via Prisma").
|
|
50
|
+
- **Outside services**: hosts in URLs, SDKs such as Stripe, OpenAI and nodemailer, URLs in `.env.example`, and services that are only named by an env var (`FRAUD_CHECK_URL=` becomes "Fraud Check").
|
|
51
|
+
- App-to-app calls are found through env vars like `BACKEND_URL`. New or removed pieces are highlighted, and clicking anything shows every entry point and the exact lines behind it.
|
|
52
|
+
- **Dependency map**: switch between **Changes / Before / After**. Boxes never move between views, so you can flip back and forth.
|
|
53
|
+
Green = new dependency, red dashed = removed, orange = breaks a rule. Numbers on arrows are how many imports.
|
|
54
|
+
- **Only changes** shows just the dependencies that moved.
|
|
55
|
+
- **Only touched** shows the modules whose files this change edits (marked with an indigo dot) and what they connect to: the PR at a glance.
|
|
56
|
+
- Click a box or arrow for its files and the exact `file:line` imports. Double-click a box to open its files.
|
|
57
|
+
- Also includes a minimap, zoom (scroll or pinch) and SVG export.
|
|
58
|
+
- **Module pages**: open any module (from the Modules table, the map, or ⌘K) to see its **files and how they connect**. Its neighbouring modules appear as rounded boxes, and imports that break a rule are orange.
|
|
59
|
+
Click a file for everything it imports and everything that imports it, with line numbers. Toggle **Only changed files** to focus on the diff.
|
|
60
|
+
- **Rule checks**: every rule with its status. Expand one to see each break and where it happens.
|
|
61
|
+
- **Changes** and **Modules**: tables you can filter, search and sort. Modules can be narrowed to the ones this change touches.
|
|
62
|
+
- **⌘K / Ctrl K** (or `/`): a command palette to jump to any module, file, dependency, rule, entry point or action.
|
|
63
|
+
Keys `1`–`6` switch pages, `F` fits the map, `Esc` closes things.
|
|
64
|
+
- Light, dark and system themes. **Copy PR summary** puts the Markdown summary on your clipboard.
|
|
65
|
+
|
|
66
|
+
The Geist fonts are embedded under the SIL Open Font License (`src/report/fonts/OFL-LICENSE.txt`).
|
|
67
|
+
|
|
68
|
+
## Watch mode
|
|
69
|
+
|
|
70
|
+
`tecton serve [dir]` starts a local server (default `http://127.0.0.1:4321`, or `--port`) and rebuilds the
|
|
71
|
+
report whenever a source file, `package.json`, `tsconfig.json`, `tecton.config.json` or `.env.example` changes, and when you commit or switch branches.
|
|
72
|
+
The open page reloads itself and keeps the page you were on. If something can't be read (a broken config, say), the last good report stays up and a small status pill shows the error.
|
|
73
|
+
|
|
74
|
+
## Config (`tecton.config.json`)
|
|
75
|
+
|
|
76
|
+
Projects set up with the old name keep working: `archdiff.config.json` is read if there's no `tecton.config.json`.
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
{
|
|
80
|
+
"root": "src", // folder to analyse (default: "src" if it exists, else the project folder)
|
|
81
|
+
"depth": 1, // 1 = src/components, src/lib...; 2 = src/features/cart, src/features/auth...
|
|
82
|
+
"cycles": "warn", // flag modules that depend on each other in a loop: "warn" | "error" | "off"
|
|
83
|
+
"rules": [
|
|
84
|
+
// forbidden: "from" may not use anything in "to"
|
|
85
|
+
{ "name": "UI must not touch the database", "from": "components", "to": ["db", "npm:pg"],
|
|
86
|
+
"message": "Call a service instead." },
|
|
87
|
+
// allow-list: "from" may only use these internal modules
|
|
88
|
+
{ "name": "lib stays dependency-free", "from": "lib", "allow": [] },
|
|
89
|
+
// wildcards work, and "severity": "warn" reports without failing CI
|
|
90
|
+
{ "name": "features stay independent", "from": "features/*", "to": ["features/*"], "severity": "warn" }
|
|
91
|
+
],
|
|
92
|
+
|
|
93
|
+
// optional
|
|
94
|
+
"modules": { "ui": ["src/components", "src/app/**/_components/**"] }, // group folders by hand (first match wins)
|
|
95
|
+
"exclude": ["src/generated/**"], // tests, stories and *.config.* files are skipped already
|
|
96
|
+
"aliases": { "~/": "src/" }, // extra import aliases beyond tsconfig "paths"
|
|
97
|
+
"externals": false, // true = draw every npm package; otherwise only ones named in rules
|
|
98
|
+
"base": "develop" // default branch to compare with
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Module names are folder paths under `root` (`components`, `features/cart`). npm packages are `npm:<name>`.
|
|
103
|
+
A module can import itself freely; rules are about arrows between modules.
|
|
104
|
+
|
|
105
|
+
## Command line
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
tecton [dir] [--base <ref>] [--head <ref>] [--out report.html] [--md summary.md] [--json data.json]
|
|
109
|
+
[--config file] [--no-merge-base] [--strict] [--no-open]
|
|
110
|
+
tecton serve [dir] [--port 4321] [--base <ref>] [--no-open]
|
|
111
|
+
tecton init [dir]
|
|
112
|
+
tecton --version
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Exit code: `1` if the change adds a rule break with severity `error` (`--strict`: any rule break), `2` for usage errors.
|
|
116
|
+
Without git (or without a base branch) it shows a snapshot and fails on any rule break.
|
|
117
|
+
|
|
118
|
+
## In CI
|
|
119
|
+
|
|
120
|
+
### GitHub Action
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
# .github/workflows/tecton.yml
|
|
124
|
+
on: pull_request
|
|
125
|
+
permissions: { contents: read, pull-requests: write }
|
|
126
|
+
jobs:
|
|
127
|
+
architecture:
|
|
128
|
+
runs-on: ubuntu-latest
|
|
129
|
+
steps:
|
|
130
|
+
- uses: actions/checkout@v4
|
|
131
|
+
with: { fetch-depth: 0 }
|
|
132
|
+
- uses: actions/setup-node@v4
|
|
133
|
+
with: { node-version: 20 }
|
|
134
|
+
- uses: gaurav2180/tecton@v0.4.0
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The action compares the pull request with its base branch and does four things:
|
|
138
|
+
|
|
139
|
+
1. Writes the Markdown summary (with a Mermaid diagram of only the parts that changed) to the job summary.
|
|
140
|
+
2. Posts it as a PR comment and updates that comment on every push, so the PR doesn't collect a new comment each time.
|
|
141
|
+
3. Uploads the HTML report as an artifact and links it from the comment.
|
|
142
|
+
4. Fails the check if the change adds a rule break.
|
|
143
|
+
|
|
144
|
+
Inputs: `working-directory`, `base`, `config`, `strict`, `comment`, `fail-on-break`, `upload-report`, `report-name`, `github-token`.
|
|
145
|
+
Outputs: `new-breaks`, `failing`, `report-path`, `summary-path`. See [`action.yml`](action.yml) and [`examples/github-workflow.yml`](examples/github-workflow.yml).
|
|
146
|
+
|
|
147
|
+
### Any other CI
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npx tecton --base "origin/$BASE_BRANCH" --head HEAD --out tecton-report.html --md tecton.md
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
The CLI is plain JavaScript on Node built-ins. The report's browser code lives in `src/report/client/` as ES modules, is typed with JSDoc against `src/types.d.ts`, and is bundled with esbuild into `src/report/dist/client.js`. That bundle is committed, so the CLI and the GitHub Action run straight from a checkout.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm install # dev tools only: esbuild, TypeScript, Playwright
|
|
159
|
+
npm run dev # rebuild the client bundle on change (use with: node bin/tecton.js serve <project>)
|
|
160
|
+
npm run build # build the bundle once; commit src/report/dist/client.js with your change
|
|
161
|
+
npm run typecheck # tsc over the CLI and the browser code
|
|
162
|
+
npm test # unit tests (no dependencies needed)
|
|
163
|
+
npm run test:ui # the report in a real browser: every page, light and dark, desktop and phone
|
|
164
|
+
npm run check # everything CI runs
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The UI tests compare screenshots against baselines in `test/ui/__screenshots__/<platform>/`. After a deliberate visual change, run `npm run test:ui -- --update-snapshots` and review the new images before committing them.
|
|
168
|
+
|
|
169
|
+
## Brand
|
|
170
|
+
|
|
171
|
+
The logo files are in [`brand/`](brand/): the mark, lockups for light and dark, app icons, a favicon and a social banner. See `CLAUDE.md` for how to use them.
|
|
172
|
+
|
|
173
|
+
## Limits (v0.4)
|
|
174
|
+
|
|
175
|
+
- JS/TS, plus the `<script>` blocks of Vue and Svelte files. Imports inside templates aren't read.
|
|
176
|
+
- Imports and routes are found with a careful text scan, not a full parser, so exotic code can occasionally be missed.
|
|
177
|
+
Router prefixes are followed through imports, `require()` and local variables, but not through routers built dynamically (for example in a loop).
|
|
178
|
+
- The architecture view is heuristic. Queue names and cron expressions must be written as literals (or a constant in the same file). URLs built at run time from config show up only if they are named in `.env.example`.
|
|
179
|
+
- Module diagrams are drawn for modules with up to 250 files. Bigger modules get the file table only.
|
package/bin/tecton.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tecton — draw a JS/TS codebase as a map of modules, compare it with a git branch, and check house rules.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { workingTreeSource, gitRefSource, gitInfo, refExists, git, parseJsonc, isCode, isExtra } from '../src/scan.js';
|
|
9
|
+
import { buildGraph, makeGrouper } from '../src/graph.js';
|
|
10
|
+
import { evaluate } from '../src/rules.js';
|
|
11
|
+
import { diffGraphs } from '../src/diff.js';
|
|
12
|
+
import { layout } from '../src/layout.js';
|
|
13
|
+
import { detectSystem, diffSystems } from '../src/system.js';
|
|
14
|
+
import { toHtml, toMarkdown } from '../src/report.js';
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../src/types.js').Graph} Graph @typedef {import('../src/types.js').Source} Source @typedef {import('../src/types.js').FileGraph} FileGraph */
|
|
17
|
+
|
|
18
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
19
|
+
|
|
20
|
+
const HELP = `Tecton — see every shift in your architecture before it becomes a crack
|
|
21
|
+
|
|
22
|
+
Usage
|
|
23
|
+
tecton [dir] [options] map the code, compare it with main, check rules and open the report
|
|
24
|
+
tecton serve [dir] open the report in your browser and refresh it whenever a file changes
|
|
25
|
+
tecton init [dir] create tecton.config.json with your modules and an example rule
|
|
26
|
+
|
|
27
|
+
Options
|
|
28
|
+
--base <ref> what to compare against (default: main, then master)
|
|
29
|
+
--head <ref> compare a commit instead of the files on disk (e.g. HEAD in CI)
|
|
30
|
+
--no-merge-base compare with the tip of --base instead of where your branch split off
|
|
31
|
+
--out <file> write the HTML report here (default: a temp file that opens in your browser;
|
|
32
|
+
./tecton-report.html in CI or when output is not a terminal)
|
|
33
|
+
--md <file> also write a Markdown summary with a Mermaid diagram (for PR comments)
|
|
34
|
+
--json <file> also write the raw data as JSON
|
|
35
|
+
--config <file> config path (default: <dir>/tecton.config.json)
|
|
36
|
+
--no-open don't open the report in the browser
|
|
37
|
+
--strict fail on any rule break, not only new ones
|
|
38
|
+
--port <n> serve: port to listen on (default: 4321)
|
|
39
|
+
-v, --version print the version
|
|
40
|
+
-h, --help show this help
|
|
41
|
+
|
|
42
|
+
Exit code is 1 when the change introduces a new rule break (severity "error"), so it can gate CI.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
46
|
+
const c = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
47
|
+
const bold = c(1); const dim = c(2); const red = c(31); const green = c(32); const yellow = c(33); const cyan = c(36);
|
|
48
|
+
|
|
49
|
+
class UsageError extends Error {}
|
|
50
|
+
const fail = (msg) => { throw new UsageError(msg); };
|
|
51
|
+
|
|
52
|
+
function parseArgs(argv) {
|
|
53
|
+
const o = { _: [], mergeBase: true };
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
const val = () => { const v = argv[++i]; if (v === undefined) fail(`${a} needs a value`); return v; };
|
|
57
|
+
if (a === '-h' || a === '--help') o.help = true;
|
|
58
|
+
else if (a === '-v' || a === '--version') o.version = true;
|
|
59
|
+
else if (a === '--base') o.base = val();
|
|
60
|
+
else if (a === '--head') o.head = val();
|
|
61
|
+
else if (a === '--out') o.out = val();
|
|
62
|
+
else if (a === '--md') o.md = val();
|
|
63
|
+
else if (a === '--json') o.json = val();
|
|
64
|
+
else if (a === '--config') o.config = val();
|
|
65
|
+
else if (a === '--port') { o.port = Number(val()); if (!Number.isInteger(o.port) || o.port < 0 || o.port > 65535) fail('--port needs a number'); }
|
|
66
|
+
else if (a === '--open') o.open = true;
|
|
67
|
+
else if (a === '--no-open') o.open = false;
|
|
68
|
+
else if (a === '--strict') o.strict = true;
|
|
69
|
+
else if (a === '--no-merge-base') o.mergeBase = false;
|
|
70
|
+
else if (a.startsWith('-')) fail(`unknown option ${a}`);
|
|
71
|
+
else o._.push(a);
|
|
72
|
+
}
|
|
73
|
+
return o;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadConfig(dir, file) {
|
|
77
|
+
let p = file ? path.resolve(file) : path.join(dir, 'tecton.config.json');
|
|
78
|
+
// projects set up before the rename keep working
|
|
79
|
+
if (!file && !fs.existsSync(p) && fs.existsSync(path.join(dir, 'archdiff.config.json'))) p = path.join(dir, 'archdiff.config.json');
|
|
80
|
+
if (!fs.existsSync(p)) { if (file) fail(`config not found: ${p}`); return { cfg: {}, path: null }; }
|
|
81
|
+
try { return { cfg: parseJsonc(fs.readFileSync(p, 'utf8')), path: p }; } catch (e) { return fail(`could not read ${p}: ${e.message}`); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pickBase(dir, opts, cfg) {
|
|
85
|
+
if (!gitInfo(dir)) return null;
|
|
86
|
+
const candidates = opts.base ? [opts.base] : cfg.base ? [cfg.base] : ['main', 'master', 'origin/main', 'origin/master'];
|
|
87
|
+
const ref = candidates.find((r) => refExists(dir, r));
|
|
88
|
+
if (!ref) { if (opts.base) fail(`git ref not found: ${opts.base}`); return null; }
|
|
89
|
+
let commit = ref;
|
|
90
|
+
let label = ref;
|
|
91
|
+
if (opts.mergeBase) {
|
|
92
|
+
try {
|
|
93
|
+
const head = opts.head || 'HEAD';
|
|
94
|
+
const mb = git(dir, ['merge-base', ref, head]).trim();
|
|
95
|
+
const tip = git(dir, ['rev-parse', ref]).trim();
|
|
96
|
+
commit = mb;
|
|
97
|
+
if (mb !== tip) label = `${ref} (where your branch split off, ${mb.slice(0, 7)})`;
|
|
98
|
+
} catch { /* unrelated histories or no HEAD yet: use the tip */ }
|
|
99
|
+
}
|
|
100
|
+
return { ref: commit, label };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function init(dir) {
|
|
104
|
+
const target = path.join(dir, 'tecton.config.json');
|
|
105
|
+
if (fs.existsSync(target)) fail(`${target} already exists`);
|
|
106
|
+
const src = workingTreeSource(dir);
|
|
107
|
+
const grouper = makeGrouper({}, src.files);
|
|
108
|
+
const mods = [...new Set(src.files.map((f) => grouper.moduleOf(f)).filter(Boolean))].sort();
|
|
109
|
+
const pick = (...names) => names.find((n) => mods.includes(n));
|
|
110
|
+
const ui = pick('components', 'ui', 'app', 'pages', 'views');
|
|
111
|
+
const data = pick('db', 'database', 'prisma', 'models', 'repositories', 'server');
|
|
112
|
+
const rules = [];
|
|
113
|
+
if (ui && data) rules.push({ name: `${ui} must not talk to ${data} directly`, from: ui, to: [data], message: 'Go through a service/API layer instead.' });
|
|
114
|
+
else rules.push({ name: 'example: replace with your own rule', from: mods[0] || 'components', to: [mods[1] || 'db'] });
|
|
115
|
+
const cfg = { root: grouper.root, depth: 1, cycles: 'warn', rules };
|
|
116
|
+
fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n');
|
|
117
|
+
console.log(`${green('created')} ${target}`);
|
|
118
|
+
console.log(`modules found under ${bold(grouper.root || '.')}: ${mods.map(cyan).join(', ') || dim('(none)')}`);
|
|
119
|
+
console.log(dim('Edit "rules" to describe what may depend on what, then run: tecton'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function openFile(target) {
|
|
123
|
+
if (process.env.BROWSER === 'none') return; // the usual way to say "don't open a browser"
|
|
124
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
125
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', target] : [target];
|
|
126
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* ignore */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Files inside modules, with how they changed, plus file → file imports. Feeds the per-module drill-down
|
|
131
|
+
* and the "touched in this change" filter. Kept compact: files = [path, module, status, edited], deps = [from, to, status, line].
|
|
132
|
+
* @param {Graph} prev
|
|
133
|
+
* @param {Graph} cur
|
|
134
|
+
* @param {Source | null} baseSrc
|
|
135
|
+
* @param {Source} curSrc
|
|
136
|
+
* @returns {FileGraph}
|
|
137
|
+
*/
|
|
138
|
+
function fileLevel(prev, cur, baseSrc, curSrc) {
|
|
139
|
+
const same = (a, b) => a != null && b != null && a.replace(/\r\n/g, '\n') === b.replace(/\r\n/g, '\n');
|
|
140
|
+
const all = [...new Set([...prev.moduleOf.keys(), ...cur.moduleOf.keys()])].sort();
|
|
141
|
+
const idx = new Map(all.map((f, i) => [f, i]));
|
|
142
|
+
const files = all.map((f) => {
|
|
143
|
+
const inB = prev.moduleOf.has(f); const inC = cur.moduleOf.has(f);
|
|
144
|
+
const st = inB && inC ? 's' : inC ? 'a' : 'r';
|
|
145
|
+
const edited = baseSrc ? (st !== 's' || !same(baseSrc.read(f), curSrc.read(f))) : false;
|
|
146
|
+
return /** @type {FileGraph['files'][number]} */ ([f, cur.moduleOf.get(f) || prev.moduleOf.get(f), st, edited ? 1 : 0]);
|
|
147
|
+
});
|
|
148
|
+
/** @type {Map<string, FileGraph['deps'][number]>} */
|
|
149
|
+
const deps = new Map();
|
|
150
|
+
const add = (g, st) => {
|
|
151
|
+
for (const [from, targets] of g.fileDeps) {
|
|
152
|
+
for (const [to, line] of targets) {
|
|
153
|
+
if (!idx.has(from) || !idx.has(to)) continue;
|
|
154
|
+
const k = `${from}>${to}`;
|
|
155
|
+
const had = deps.get(k);
|
|
156
|
+
if (had) had[2] = 's'; else deps.set(k, [idx.get(from), idx.get(to), st, line]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
add(cur, prev === cur ? 's' : 'a');
|
|
161
|
+
if (prev !== cur) add(prev, 'r');
|
|
162
|
+
return { files, deps: [...deps.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read the code (and the base), compare, check rules and build the report model. */
|
|
166
|
+
function analyze(dir, opts) {
|
|
167
|
+
const { cfg } = loadConfig(dir, opts.config);
|
|
168
|
+
const t0 = Date.now();
|
|
169
|
+
if (opts.head && !gitInfo(dir)) fail('--head needs a git repository');
|
|
170
|
+
const curSrc = opts.head ? gitRefSource(dir, opts.head) : workingTreeSource(dir);
|
|
171
|
+
const base = pickBase(dir, opts, cfg);
|
|
172
|
+
const baseSrc = base ? gitRefSource(dir, base.ref) : null;
|
|
173
|
+
|
|
174
|
+
const cur = buildGraph(curSrc, cfg);
|
|
175
|
+
const prev = baseSrc ? buildGraph(baseSrc, cfg) : cur;
|
|
176
|
+
if (!cur.modules.size) fail(`no JS/TS files found under ${path.join(dir, cur.root)} (set "root" in tecton.config.json)`);
|
|
177
|
+
const curViol = evaluate(cur, cfg);
|
|
178
|
+
const baseViol = baseSrc ? evaluate(prev, cfg) : curViol;
|
|
179
|
+
const d = diffGraphs(prev, cur, baseViol, curViol, cfg);
|
|
180
|
+
|
|
181
|
+
const sysCur = detectSystem(curSrc, cur, { project: path.basename(dir), aliases: cfg.aliases });
|
|
182
|
+
const sysBase = baseSrc ? detectSystem(baseSrc, prev, { project: path.basename(dir), aliases: cfg.aliases }) : sysCur;
|
|
183
|
+
const system = diffSystems(sysBase, sysCur);
|
|
184
|
+
|
|
185
|
+
const files = fileLevel(prev, cur, baseSrc, curSrc);
|
|
186
|
+
const touched = new Map();
|
|
187
|
+
for (const [, mod, , edited] of files.files) if (edited) touched.set(mod, (touched.get(mod) || 0) + 1);
|
|
188
|
+
|
|
189
|
+
const lay = layout(d.nodes, d.edges);
|
|
190
|
+
/** @type {import('../src/types.js').ReportModel} */
|
|
191
|
+
const model = {
|
|
192
|
+
project: path.basename(dir),
|
|
193
|
+
curLabel: opts.head ? opts.head : 'your working copy',
|
|
194
|
+
baseLabel: base ? base.label : null,
|
|
195
|
+
hasBase: !!base,
|
|
196
|
+
ruleCount: (cfg.rules || []).length,
|
|
197
|
+
generatedAt: new Date().toISOString(),
|
|
198
|
+
width: lay.width, height: lay.height,
|
|
199
|
+
summary: { ...d.summary, touched: { files: files.files.filter((f) => f[3]).length, modules: touched.size } },
|
|
200
|
+
nodes: d.nodes.map((n) => ({ ...n, pos: lay.pos[n.id], touched: touched.get(n.id) || 0 })),
|
|
201
|
+
edges: d.edges.map((e) => ({ ...e, points: lay.routes[e.id] })),
|
|
202
|
+
violations: d.violations,
|
|
203
|
+
rules: [
|
|
204
|
+
...(cfg.rules || []).map((r, i) => ({
|
|
205
|
+
key: r.name || `rule ${i + 1}`, kind: 'rule', from: r.from, to: r.to ? [].concat(r.to) : null,
|
|
206
|
+
allow: r.allow ? [].concat(r.allow) : null, severity: r.severity || 'error', message: r.message || null,
|
|
207
|
+
})),
|
|
208
|
+
...((cfg.cycles ?? 'warn') !== 'off' ? [{ key: 'No circular dependencies', kind: 'cycle', severity: cfg.cycles ?? 'warn', message: 'Modules should not depend on each other in a loop.' }] : []),
|
|
209
|
+
],
|
|
210
|
+
files,
|
|
211
|
+
version: VERSION,
|
|
212
|
+
system,
|
|
213
|
+
};
|
|
214
|
+
model.markdown = toMarkdown(model);
|
|
215
|
+
const failing = d.violations.filter((v) => v.severity === 'error' && v.status !== 'fixed'
|
|
216
|
+
&& (opts.strict || !base || v.status === 'new')).length;
|
|
217
|
+
return { model, d, base, ms: Date.now() - t0, failing };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function printSummary({ d, base, ms }, opts) {
|
|
221
|
+
const S = d.summary;
|
|
222
|
+
console.log(`${bold('tecton')} ${dim(`${S.files.cur} files · ${S.modules.total} modules · ${S.deps.total} dependencies · ${ms}ms`)}`);
|
|
223
|
+
if (base) {
|
|
224
|
+
console.log(`compared with ${cyan(base.label)}: modules ${green(`+${S.modules.added}`)}/${red(`−${S.modules.removed}`)}, dependencies ${green(`+${S.deps.added}`)}/${red(`−${S.deps.removed}`)}`);
|
|
225
|
+
for (const e of d.edges.filter((x) => x.status !== 'same')) {
|
|
226
|
+
console.log(` ${e.status === 'added' ? green('+') : red('−')} ${e.from} → ${e.to}`);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
console.log(dim('no git base found; showing a snapshot only'));
|
|
230
|
+
}
|
|
231
|
+
const relevant = d.violations.filter((v) => (base ? v.status !== 'existing' : true) || opts.strict);
|
|
232
|
+
for (const v of relevant) {
|
|
233
|
+
const where = v.kind === 'cycle' ? v.members.join(' ⇄ ') : `${v.from} → ${v.to}`;
|
|
234
|
+
const tag = v.status === 'fixed' ? green('fixed') : v.severity === 'error' ? red(base ? 'NEW' : 'ERROR') : yellow('warn');
|
|
235
|
+
const ev = v.imports[0] ? dim(` (${v.imports[0].file}:${v.imports[0].line})`) : '';
|
|
236
|
+
console.log(` ${tag} ${v.rule}: ${where}${ev}`);
|
|
237
|
+
}
|
|
238
|
+
const existing = d.violations.filter((v) => v.status === 'existing').length;
|
|
239
|
+
if (base && existing && !opts.strict) console.log(dim(` ${existing} rule break(s) already existed before this change`));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Run by a person (not CI, not piped)? Then the report opens in the browser by default. */
|
|
243
|
+
const interactive = () => !!process.stdout.isTTY && !process.env.CI;
|
|
244
|
+
|
|
245
|
+
function report(dir, opts) {
|
|
246
|
+
const res = analyze(dir, opts);
|
|
247
|
+
// a person at a terminal just wants to see it: open it, and keep it out of their project folder.
|
|
248
|
+
// CI and scripts get a predictable file and no browser.
|
|
249
|
+
const open = opts.open ?? interactive();
|
|
250
|
+
const out = opts.out ? path.resolve(opts.out)
|
|
251
|
+
: open ? path.join(os.tmpdir(), 'tecton', `${res.model.project.replace(/[^\w.-]+/g, '-') || 'report'}.html`)
|
|
252
|
+
: path.resolve('tecton-report.html');
|
|
253
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
254
|
+
fs.writeFileSync(out, toHtml(res.model));
|
|
255
|
+
if (opts.md) fs.writeFileSync(path.resolve(opts.md), res.model.markdown);
|
|
256
|
+
if (opts.json) fs.writeFileSync(path.resolve(opts.json), JSON.stringify(res.model, null, 2));
|
|
257
|
+
printSummary(res, opts);
|
|
258
|
+
console.log(`report: ${out}`);
|
|
259
|
+
if (open) { openFile(out); console.log(dim('opening it in your browser (--no-open to skip)')); }
|
|
260
|
+
if (!res.model.ruleCount && !opts.config) console.log(dim('tip: add rules ("components must not use db") with: tecton init'));
|
|
261
|
+
return res.failing ? 1 : 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ------------------------------------------------------------------ tecton serve
|
|
265
|
+
const WATCHED_NAMES = new Set(['package.json', 'tsconfig.json', 'jsconfig.json', 'tecton.config.json', 'archdiff.config.json']);
|
|
266
|
+
const IGNORED_DIRS = /(^|\/)(node_modules|dist|build|out|\.next|\.nuxt|coverage|\.turbo|\.vercel|\.cache|\.output|\.svelte-kit)(\/|$)/;
|
|
267
|
+
/** Does a change to this path (relative to the project) change the report? Commits and branch switches count too. */
|
|
268
|
+
const relevant = (rel) => {
|
|
269
|
+
if (rel === '.git/HEAD' || rel.startsWith('.git/refs/heads/')) return true;
|
|
270
|
+
if (rel.startsWith('.git/') || IGNORED_DIRS.test(rel)) return false;
|
|
271
|
+
return isCode(rel) || isExtra(rel) || WATCHED_NAMES.has(path.posix.basename(rel));
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Added to the served page only: reloads on change and shows a small "live" pill.
|
|
275
|
+
const LIVE_SNIPPET = `<script>(() => {
|
|
276
|
+
const pill = document.createElement('div');
|
|
277
|
+
pill.setAttribute('role', 'status');
|
|
278
|
+
pill.style.cssText = 'position:fixed;right:14px;bottom:14px;z-index:90;display:flex;align-items:center;gap:7px;padding:6px 11px;border-radius:999px;font:500 12px/1 Geist,system-ui,sans-serif;background:var(--surface,#fff);color:var(--text-2,#444);border:1px solid var(--border,#E6E3DC);box-shadow:0 4px 14px rgba(16,16,24,.08)';
|
|
279
|
+
const dot = document.createElement('i');
|
|
280
|
+
dot.style.cssText = 'width:7px;height:7px;border-radius:50%;background:#16A34A';
|
|
281
|
+
const txt = document.createElement('span');
|
|
282
|
+
pill.append(dot, txt);
|
|
283
|
+
const set = (color, t) => { dot.style.background = color; txt.textContent = t; };
|
|
284
|
+
set('#16A34A', 'Live');
|
|
285
|
+
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(pill));
|
|
286
|
+
const es = new EventSource('/__tecton/events');
|
|
287
|
+
es.addEventListener('building', () => set('#5B5BD6', 'Updating…'));
|
|
288
|
+
es.addEventListener('reload', () => location.reload());
|
|
289
|
+
es.addEventListener('failed', (e) => set('#DC2626', 'Update failed: ' + e.data));
|
|
290
|
+
es.onerror = () => set('#8C8A85', 'Disconnected');
|
|
291
|
+
es.onopen = () => set('#16A34A', 'Live');
|
|
292
|
+
})();</script>`;
|
|
293
|
+
|
|
294
|
+
function serve(dir, opts) {
|
|
295
|
+
const clients = new Set();
|
|
296
|
+
const send = (event, data = '') => { for (const res of clients) res.write(`event: ${event}\ndata: ${String(data).replace(/\n/g, ' ')}\n\n`); };
|
|
297
|
+
let html = null;
|
|
298
|
+
let building = false;
|
|
299
|
+
let again = false;
|
|
300
|
+
|
|
301
|
+
const rebuild = (why) => {
|
|
302
|
+
if (building) { again = true; return; }
|
|
303
|
+
building = true;
|
|
304
|
+
send('building');
|
|
305
|
+
const t = new Date().toTimeString().slice(0, 8);
|
|
306
|
+
try {
|
|
307
|
+
const res = analyze(dir, opts);
|
|
308
|
+
html = toHtml(res.model).replace('</body>', `${LIVE_SNIPPET}\n</body>`);
|
|
309
|
+
const S = res.d.summary;
|
|
310
|
+
const status = res.failing ? red(`${res.failing} rule break${res.failing === 1 ? '' : 's'}`) : green('rules pass');
|
|
311
|
+
console.log(`${dim(t)} ${why ? `${cyan('↻')} ${dim(why)} · ` : ''}${S.modules.total} modules · ${S.deps.total} dependencies · ${status} ${dim(`${res.ms}ms`)}`);
|
|
312
|
+
send('reload');
|
|
313
|
+
} catch (e) {
|
|
314
|
+
console.error(`${dim(t)} ${red('update failed:')} ${e.message}`);
|
|
315
|
+
send('failed', e.message);
|
|
316
|
+
if (!html) html = `<!doctype html><meta charset="utf-8"><title>Tecton</title><body style="font:14px system-ui;padding:40px"><h1>Tecton could not read this project</h1><pre>${e.message.replace(/[<&]/g, (ch) => (ch === '<' ? '<' : '&'))}</pre>${LIVE_SNIPPET}</body>`;
|
|
317
|
+
}
|
|
318
|
+
building = false;
|
|
319
|
+
if (again) { again = false; setTimeout(() => rebuild('more changes'), 50); }
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const server = http.createServer((req, res) => {
|
|
323
|
+
const url = new URL(req.url, 'http://x');
|
|
324
|
+
if (url.pathname === '/__tecton/events') {
|
|
325
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
326
|
+
res.write('retry: 1000\n\n');
|
|
327
|
+
clients.add(res);
|
|
328
|
+
req.on('close', () => clients.delete(res));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
332
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
333
|
+
res.end(html);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
337
|
+
res.end('not found');
|
|
338
|
+
});
|
|
339
|
+
// keep the event streams alive through proxies and sleeping laptops
|
|
340
|
+
setInterval(() => { for (const r of clients) r.write(': ping\n\n'); }, 25000).unref();
|
|
341
|
+
|
|
342
|
+
rebuild(null);
|
|
343
|
+
|
|
344
|
+
// watch the folder; fall back to polling where recursive watching is unsupported
|
|
345
|
+
let timer = null;
|
|
346
|
+
const changed = new Set();
|
|
347
|
+
const schedule = (rel) => {
|
|
348
|
+
changed.add(rel);
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
timer = setTimeout(() => {
|
|
351
|
+
const list = [...changed]; changed.clear();
|
|
352
|
+
rebuild(list.length === 1 ? list[0] : `${list.length} files changed`);
|
|
353
|
+
}, 200);
|
|
354
|
+
};
|
|
355
|
+
const gi = gitInfo(dir);
|
|
356
|
+
try {
|
|
357
|
+
fs.watch(dir, { recursive: true }, (_ev, name) => {
|
|
358
|
+
if (!name) return;
|
|
359
|
+
const rel = name.split(path.sep).join('/');
|
|
360
|
+
if (relevant(rel)) schedule(rel);
|
|
361
|
+
});
|
|
362
|
+
// the repo's .git may sit above the analysed folder
|
|
363
|
+
if (gi && gi.prefix) fs.watch(path.join(gi.top, '.git'), { recursive: true }, (_ev, name) => { if (name && relevant(`.git/${name.split(path.sep).join('/')}`)) schedule('git'); });
|
|
364
|
+
} catch {
|
|
365
|
+
let last = null;
|
|
366
|
+
const signature = () => {
|
|
367
|
+
const src = workingTreeSource(dir);
|
|
368
|
+
const stat = (f) => { try { const s = fs.statSync(path.join(dir, f)); return `${f}:${s.mtimeMs}:${s.size}`; } catch { return f; } };
|
|
369
|
+
const head = gi ? stat(path.relative(dir, path.join(gi.top, '.git', 'HEAD'))) : '';
|
|
370
|
+
return [...src.files, ...src.manifests, ...(src.extras || []), 'tecton.config.json'].map(stat).join('|') + head;
|
|
371
|
+
};
|
|
372
|
+
setInterval(() => { const s = signature(); if (last !== null && s !== last) schedule('files changed'); last = s; }, 1000).unref();
|
|
373
|
+
console.log(dim('(recursive file watching is not available here; checking for changes every second)'));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const host = '127.0.0.1';
|
|
377
|
+
let port = opts.port ?? 4321;
|
|
378
|
+
const tries = opts.port == null ? 10 : 1;
|
|
379
|
+
let attempt = 0;
|
|
380
|
+
server.on('error', (/** @type {NodeJS.ErrnoException} */ e) => {
|
|
381
|
+
if (e.code === 'EADDRINUSE' && ++attempt < tries) { port++; server.listen(port, host); return; }
|
|
382
|
+
console.error(red(`tecton: could not listen on ${host}:${port}: ${e.message}`));
|
|
383
|
+
process.exit(2);
|
|
384
|
+
});
|
|
385
|
+
server.on('listening', () => {
|
|
386
|
+
const link = `http://${host}:${port}/`;
|
|
387
|
+
console.log(`${bold('tecton serve')} ${cyan(link)} ${dim(`watching ${dir} · Ctrl+C to stop`)}`);
|
|
388
|
+
if (opts.open ?? interactive()) openFile(link);
|
|
389
|
+
});
|
|
390
|
+
server.listen(port, host);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function main() {
|
|
394
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
395
|
+
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
396
|
+
if (opts.version) { console.log(VERSION); return 0; }
|
|
397
|
+
const cmd = opts._[0];
|
|
398
|
+
if (cmd === 'init') { init(path.resolve(opts._[1] || '.')); return 0; }
|
|
399
|
+
if (cmd === 'serve') {
|
|
400
|
+
const dir = path.resolve(opts._[1] || '.');
|
|
401
|
+
if (!fs.existsSync(dir)) fail(`folder not found: ${dir}`);
|
|
402
|
+
serve(dir, opts);
|
|
403
|
+
return undefined; // keeps running
|
|
404
|
+
}
|
|
405
|
+
const dir = path.resolve(cmd || '.');
|
|
406
|
+
if (!fs.existsSync(dir)) fail(`folder not found: ${dir}`);
|
|
407
|
+
return report(dir, opts);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const code = main();
|
|
412
|
+
if (code !== undefined) process.exitCode = code;
|
|
413
|
+
} catch (e) {
|
|
414
|
+
if (!(e instanceof UsageError)) throw e;
|
|
415
|
+
console.error(red(`tecton: ${e.message}`));
|
|
416
|
+
process.exitCode = 2;
|
|
417
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.a{fill:#0E0F13}.b{fill:#5B5BD6}@media (prefers-color-scheme:dark){.a{fill:#FAFAF7}.b{fill:#7C7FF2}}</style>
|
|
2
|
+
<rect class="a" x="4" y="13" width="27" height="14" rx="3"/><rect class="b" x="33" y="7" width="27" height="14" rx="3"/><rect class="a" x="24" y="29" width="16" height="31" rx="3"/></svg>
|