From cf647c03690e44343f345593eeefebf7cdd45e51 Mon Sep 17 00:00:00 2001 From: SigoudisEftimis Date: Thu, 30 Apr 2026 13:54:47 +0300 Subject: [PATCH 1/4] chore: Update beta version to 0.1.0-beta, enhance CI workflows, and add changelog and license files - Bump version to 0.1.0-beta in package.json and related packages. - Update CI configuration to support multiple OS environments and improve testing steps. - Add changelog to document notable changes and features. - Introduce MIT license files across all packages for compliance. - Enhance README with new output formats and examples, including SARIF and HTML reports. --- .github/workflows/ci.yml | 41 +- BETA_TEST_PLAN.md | 73 ++- CHANGELOG.md | 17 + LICENSE | 21 + README.md | 44 +- package.json | 2 +- packages/ast/LICENSE | 21 + packages/ast/package.json | 5 +- packages/cache/LICENSE | 21 + packages/cache/package.json | 5 +- packages/cli/LICENSE | 21 + packages/cli/package.json | 7 +- packages/cli/src/bin/ngcompass.ts | 6 +- packages/cli/src/commands/analyze.ts | 4 +- packages/cli/src/commands/exit.ts | 1 + packages/cli/tests/commands/analyze.test.ts | 2 + packages/common/LICENSE | 21 + packages/common/package.json | 5 +- packages/config/LICENSE | 21 + packages/config/package.json | 5 +- packages/engine/LICENSE | 21 + packages/engine/package.json | 5 +- packages/planner/LICENSE | 21 + packages/planner/package.json | 5 +- packages/reporters/LICENSE | 21 + packages/reporters/package.json | 5 +- packages/reporters/src/factory.ts | 3 + packages/reporters/src/index.ts | 1 + packages/reporters/src/reporters/config.ts | 49 +- .../reporters/src/reporters/html-reporter.ts | 541 +++++++++++++----- .../reporters/src/reporters/sarif-reporter.ts | 204 +++++++ packages/reporters/src/types.ts | 2 +- .../reporters/tests/config-reporter.spec.ts | 39 +- .../reporters/tests/html-reporter.spec.ts | 110 ++++ .../reporters/tests/sarif-reporter.spec.ts | 112 ++++ packages/rules/LICENSE | 21 + packages/rules/package.json | 5 +- packages/rules/src/presets/all.ts | 3 +- packages/rules/src/presets/performance.ts | 2 +- packages/rules/src/presets/reactivity.ts | 1 - packages/rules/src/presets/recommended.ts | 3 +- packages/rules/src/presets/strict.ts | 3 +- packages/rules/src/registry/register-all.ts | 34 +- packages/rules/tests/presets.spec.ts | 37 ++ packages/scanner/LICENSE | 21 + packages/scanner/package.json | 5 +- 46 files changed, 1357 insertions(+), 260 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 packages/ast/LICENSE create mode 100644 packages/cache/LICENSE create mode 100644 packages/cli/LICENSE create mode 100644 packages/common/LICENSE create mode 100644 packages/config/LICENSE create mode 100644 packages/engine/LICENSE create mode 100644 packages/planner/LICENSE create mode 100644 packages/reporters/LICENSE create mode 100644 packages/reporters/src/reporters/sarif-reporter.ts create mode 100644 packages/reporters/tests/html-reporter.spec.ts create mode 100644 packages/reporters/tests/sarif-reporter.spec.ts create mode 100644 packages/rules/LICENSE create mode 100644 packages/rules/tests/presets.spec.ts create mode 100644 packages/scanner/LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 069c63b..d4cb2ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: # Install all tarballs together — npm resolves workspace deps from this set cd "$SMOKE_DIR" + npm init -y npm install --no-save "$PACKS_DIR"/*.tgz # Persist the bin dir across steps via GITHUB_PATH @@ -105,6 +106,35 @@ jobs: || (echo "ERROR: no config file created by ngcompass init" && exit 1) echo "OK: config file created" + - name: Verify analyze output formats + shell: bash + run: | + ANALYZE_DIR="$RUNNER_TEMP/ngcompass-analyze-test" + mkdir -p "$ANALYZE_DIR/src" + ngcompass init --cwd "$ANALYZE_DIR" + + cat > "$ANALYZE_DIR/src/app.component.ts" <<'TS' + import { ChangeDetectionStrategy, Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello

', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + export class AppComponent {} + TS + + cd "$ANALYZE_DIR" + ngcompass analyze --format json --force > report.json + node -e "JSON.parse(require('node:fs').readFileSync('report.json', 'utf8'))" + + ngcompass analyze --format sarif --force > report.sarif + node -e "const r = JSON.parse(require('node:fs').readFileSync('report.sarif', 'utf8')); if (r.version !== '2.1.0') process.exit(1)" + + ngcompass analyze --format html --force --output report.html + test -s report.html || (echo "ERROR: html report was not written" && exit 1) + echo "OK: analyze output formats verified" + - name: Verify exit codes shell: bash run: | @@ -113,14 +143,19 @@ jobs: [ $? -eq 0 ] || (echo "ERROR: --version exited non-zero" && exit 1) # Unknown command must exit non-zero - ngcompass __unknown_command__ 2>/dev/null || true - CODE=$? - [ $CODE -ne 0 ] || (echo "ERROR: unknown command exited 0" && exit 1) + if ngcompass __unknown_command__ 2>/dev/null; then + echo "ERROR: unknown command exited 0" + exit 1 + fi echo "OK: exit codes correct" - name: Publish dry-run (catch file leakage before release) + env: + NODE_ENV: production shell: bash run: | + pnpm exec turbo build:prod + for pkg in packages/ast packages/cache packages/cli packages/common packages/config \ packages/engine packages/planner packages/reporters packages/rules packages/scanner; do echo "=== $pkg ===" diff --git a/BETA_TEST_PLAN.md b/BETA_TEST_PLAN.md index 0f64d6e..d3178a4 100644 --- a/BETA_TEST_PLAN.md +++ b/BETA_TEST_PLAN.md @@ -3,6 +3,7 @@ **Date:** 2026-04-26 **Branch:** `pre_release_v1_beta` **Version under test:** 0.0.1 (pre-beta) +**Checklist status:** 33 done / 72 remaining (105 total). --- @@ -103,8 +104,8 @@ Behavior: `.github/workflows/ci.yml`: - **Triggers:** push to `main`/`develop`; PRs. -- **Matrix:** Node 18 / 20 / 22 on **`ubuntu-latest` only**. -- **Steps:** checkout → pnpm install (frozen) → `turbo typecheck test build` (concurrency 4) → coverage gate on Node 20 only → `pnpm audit --prod` → upload to Codecov on Node 20. +- **Matrix:** Node 20.19.x / 22.x on `ubuntu-latest`, `windows-latest`, and `macos-latest`. +- **Steps:** checkout → pnpm install (frozen) → `turbo typecheck test build` (concurrency 4) → packed tarball install smoke → CLI command/output smoke checks → production build + publish dry-run → coverage gate on Node 20.19.x / Ubuntu only → `pnpm audit --prod` → upload to Codecov on Node 20.19.x / Ubuntu. ### 1.8 Build & distribution @@ -141,11 +142,12 @@ Caching: memory LRU + disk `cacache`; per-task and per-run global cache. CI today is Linux-only; the developer machine is Windows. -- [ ] Add `windows-latest` and `macos-latest` to the CI matrix. +- [x] Add `windows-latest` and `macos-latest` to the CI matrix. - [ ] Path handling on Windows (backslashes vs forward slashes) — scanner, cache, config discovery, `outputPath`. - [ ] Cache directory creation across OSes. - [ ] Worker thread spawn — confirm `packages/rules/dist/execution-worker.js` resolves after a packed install on every OS. -- [ ] ANSI color rendering: Windows Terminal, `cmd.exe`, PowerShell, CI logs; verify `NO_COLOR` / `FORCE_COLOR` honored. +- [x] ANSI color rendering: CI logs; verify `NO_COLOR` honored. +- [ ] ANSI color rendering: Windows Terminal, `cmd.exe`, PowerShell; verify `FORCE_COLOR` honored. **Execution matrix** @@ -158,12 +160,14 @@ CI today is Linux-only; the developer machine is Windows. **Minimum smoke script per OS** -- [ ] `ngcompass --version` -- [ ] `ngcompass --help` -- [ ] `ngcompass init` in a fresh temp dir -- [ ] `ngcompass analyze --format json` +- [x] `ngcompass --version` +- [x] `ngcompass --help` +- [x] `ngcompass init` in a fresh temp dir +- [x] `ngcompass analyze --format json` +- [x] `ngcompass analyze --format sarif` +- [x] `ngcompass analyze --format html --output report.html` - [ ] `ngcompass analyze --format ui --output report.html` -- [ ] `ngcompass cache info` +- [x] `ngcompass cache info` - [ ] `ngcompass cache path` **Windows-specific checks** @@ -171,38 +175,44 @@ CI today is Linux-only; the developer machine is Windows. - [ ] Project path contains spaces, e.g. `C:\Temp\ng beta app\` - [ ] Config uses relative `outputPath` and `cache.location` - [ ] Report path uses backslashes and mixed separators -- [ ] `NO_COLOR=1` disables ANSI escapes in console output +- [x] `NO_COLOR=1` disables ANSI escapes in console output - [ ] Ctrl+C restores cursor and exits cleanly **Pass criteria** - [ ] No OS-specific stack traces -- [ ] Same command returns the same exit code on all supported OSes -- [ ] Packed CLI can resolve worker entry and write reports on all OSes +- [x] Same command returns the same exit code on all supported OSes +- [x] Packed CLI can resolve worker entry and write reports on all CI OSes - [ ] Generated report and cache artifacts land in the expected directories on all OSes ### 2.2 Distribution / install validation Local `pnpm test` does not exercise the published shape. -- [ ] `pnpm pack` each package; install tarballs into a fresh project; run `ngcompass --version` and `ngcompass --help`. -- [ ] Install with **npm**, **pnpm**, **yarn classic**, **yarn berry**. +- [x] `pnpm pack` each package; install tarballs into a fresh project; run `ngcompass --version` and `ngcompass --help`. +- [x] Install packed tarballs with **npm**. +- [ ] Install with **pnpm**, **yarn classic**, **yarn berry**. - [ ] Global install (`npm i -g`) and `npx ngcompass`. - [ ] Resolve correctly from consumer projects with `"type": "module"` and without (ESM + CJS). -- [ ] `dist/` matches `package.json#files` / `exports`; no `src/` leakage. -- [ ] Shebang preserved on `dist/cli.js` after pack. -- [ ] `npm audit --omit=dev` clean of high/critical advisories. +- [x] `dist/` matches `package.json#files` / `exports`; no `src/` leakage. +- [x] Shebang preserved on `dist/cli.js` after pack. +- [x] `npm audit --omit=dev` clean of high/critical advisories in CI. ### 2.3 Command-surface functional matrix - [ ] `analyze` — happy path, plus every flag combination listed in §1.2. -- [ ] `init` — fresh dir; existing-config dir without `--force` must refuse; `--cwd` to a remote directory. +- [x] `init` — fresh dir. +- [ ] `init` — existing-config dir without `--force` must refuse; `--cwd` to a remote directory. - [ ] `config health` — valid, broken, missing config. - [ ] `cache clear` per `--type`; verify on-disk state after. -- [ ] `cache info` — empty cache, populated cache, corrupted entry. -- [ ] `rules` (list) and `rules ` (detail) for each `--preset`. -- [ ] **Exit codes:** 0 success; non-zero on `failOnSeverity` / `maxWarnings` breach; distinct code for fatal vs. analysis-found-issues. -- [ ] Unknown command / unknown flag → helpful error, no stack trace. +- [x] `cache info` — empty cache. +- [ ] `cache info` — populated cache, corrupted entry. +- [x] `rules` (list). +- [ ] `rules ` (detail) for each `--preset`. +- [x] **Exit codes:** 0 success; non-zero on `failOnSeverity` / `maxWarnings` breach. +- [ ] **Exit codes:** distinct code for fatal vs. analysis-found-issues. +- [x] Unknown command → non-zero exit. +- [ ] Unknown flag → helpful error, no stack trace. ### 2.4 Real Angular project dogfooding @@ -249,9 +259,12 @@ Synthetic fixtures alone are not enough. ### 2.8 Output formats - [ ] **JSON** — pin and publish a JSON schema; validate output against it; document forwards-compat policy. +- [x] **HTML** — generated by installed CLI and covered by reporter regression tests. - [ ] **HTML** — open in Chrome / Firefox / Safari; CSS isolated when embedded; 10k-finding report renders without freezing the browser. +- [x] **SARIF** — generated by installed CLI and parsed as SARIF 2.1.0 JSON. - [ ] **SARIF** — validate against the official 2.1.0 schema (consumed by GitHub code scanning). - [ ] **Console / text** — snapshot test plus visual review; check `--compact` and color-disable env vars. +- [x] `--output` to: normal writable path. - [ ] `--output` to: existing path (overwrite policy?), missing parent dir, read-only file. ### 2.9 Error message quality @@ -271,18 +284,20 @@ Synthetic fixtures alone are not enough. ### 2.11 Documentation parity +- [x] README documents primary commands, output formats, and beta usage examples. - [ ] README documents every command, flag, and config key — diff `--help` against README. -- [ ] `CHANGELOG.md` exists, starts at chosen beta version. -- [ ] `LICENSE` present (MIT) and matches `package.json`. -- [ ] Each package's `package.json` has `description`, `repository`, `homepage`, `bugs` populated. -- [ ] Public types: `.d.ts` present in `dist/`; exported types match documented surface. -- [ ] Beta-stability note: warn that config shape may change before 1.0. +- [x] `CHANGELOG.md` exists, starts at chosen beta version. +- [x] `LICENSE` present (MIT) and matches `package.json`. +- [x] Each package's `package.json` has `description`, `repository`, `homepage`, `bugs` populated. +- [x] Public types: `.d.ts` present in `dist/`. +- [ ] Exported types match documented surface. +- [x] Beta-stability note: warn that config shape may change before 1.0. ### 2.12 Telemetry, privacy, security - [ ] Re-confirm zero outbound network calls (run with `--network=none` in Docker). -- [ ] No developer file paths leaked into shipped artifacts (sourcemaps, embedded errors). -- [ ] `npm publish --dry-run` per package; inspect file list for `tests/`, `.env`, `node_modules`. +- [x] No developer file paths leaked into shipped artifacts via sourcemaps in production package dry-run. +- [x] `npm publish --dry-run` per package; inspect file list for `tests/`, `.env`, `node_modules`. - [ ] Worker entry path not user-controlled. ### 2.13 Beta release logistics diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..257bb19 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 0.0.1-beta.0 - Unreleased + +### Added + +- SARIF output for CI code scanning integrations. +- Branded HTML reports with issue, warning, and parse-error iconography. +- HTML reporter regression coverage for branding, escaping, parse errors, and clean scans. +- CI smoke checks for packaged CLI commands, colorless output, initialization, and exit codes. + +### Changed + +- Release dry-run now builds through the production release path before validating packaged files. +- Documented runtime support now matches package engine requirements. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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 b9d0636..f6471da 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Angular static analysis tool for architecture, performance, SSR, and code qualit - Deep AST analysis of Angular components, templates, and services - Pluggable rule system with built-in presets (recommended, strict, performance, reactivity) - Incremental analysis with smart caching -- Multiple output formats: console, JSON, HTML +- Multiple output formats: console, JSON, SARIF, HTML - Fast execution powered by SWC and OXC parsers ## Installation @@ -57,9 +57,9 @@ ngcompass config health |---|---| | `-p, --profile ` | Select a configuration profile from `profiles` in `ngcompass.config.*` | | `--force` | Force re-execution, ignoring cache | -| `--format ` | Output format: `console` (default), `json`, `ui` | +| `--format ` | Output format: `console` (default), `json`, `sarif`, `html`, `ui` | | `--compact` | ESLint-style compact output | -| `--output ` | Write the `ui` HTML report to a file | +| `--output ` | Write the `html` / `ui` report to a file | | `--rule ` | Run a single rule in isolation | ## Configuration @@ -76,6 +76,42 @@ export default defineConfig({ }); ``` +## Output Examples + +Write machine-readable JSON for automation: + +```bash +ngcompass analyze --format json > ngcompass-results.json +``` + +Write SARIF for GitHub Code Scanning or other SARIF consumers: + +```bash +ngcompass analyze --format sarif > ngcompass.sarif +``` + +Write a branded HTML report: + +```bash +ngcompass analyze --format html --output ngcompass-report.html +``` + +Use ngcompass in CI and fail on violations: + +```bash +ngcompass analyze --format console --compact +``` + +The CLI exits with `0` when analysis completes without errors and exits non-zero when errors are found or the command cannot run successfully. + +## Known Limitations + +- Beta releases may change rule names, rule messages, and report layout before `1.0`. +- Angular support should be validated against your project before enforcing ngcompass as a required CI gate. +- Template parsing and static analysis are best-effort; dynamic template construction and highly indirect patterns may not be fully understood. +- SARIF output is intended for code scanning ingestion and may not include every visual detail from the HTML report. +- HTML reports are static files and do not currently include live filtering backed by a server. + ## Packages This is a monorepo. Published packages: @@ -95,7 +131,7 @@ This is a monorepo. Published packages: ## Requirements -- Node.js >= 18 +- Node.js ^20.19.0 or >=22.12.0 - pnpm >= 8 ## License diff --git a/package.json b/package.json index a47928b..8c72159 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "packageManager": "pnpm@10.33.0", "scripts": { "build": "turbo build", - "build:prod": "NODE_ENV=production turbo build:prod", + "build:prod": "turbo build:prod", "build:benchmark": "BENCHMARK=true pnpm run build", "build:profile": "turbo build --profile=profile.json && echo 'Profile saved to profile.json'", "build:trace": "turbo build --trace", diff --git a/packages/ast/LICENSE b/packages/ast/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/ast/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/ast/package.json b/packages/ast/package.json index 68d43f8..ac83a2e 100644 --- a/packages/ast/package.json +++ b/packages/ast/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,7 +22,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/cache/LICENSE b/packages/cache/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/cache/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/cache/package.json b/packages/cache/package.json index c1b1fe6..30f344b 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,7 +22,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run ", "test:watch": "vitest", diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/cli/package.json b/packages/cli/package.json index 6628af1..f06b600 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,6 +10,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -22,7 +25,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests", @@ -41,7 +44,7 @@ "@ngcompass/scanner": "workspace:*", "@ngcompass/planner": "workspace:*", "@ngcompass/ast": "workspace:*", -"commander": "^11.0.0", + "commander": "^11.0.0", "picocolors": "^1.1.1" }, "keywords": [ diff --git a/packages/cli/src/bin/ngcompass.ts b/packages/cli/src/bin/ngcompass.ts index 256a0da..e40e4a5 100644 --- a/packages/cli/src/bin/ngcompass.ts +++ b/packages/cli/src/bin/ngcompass.ts @@ -28,7 +28,10 @@ if (!packageJsonPath) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); -const restoreCursor = () => process.stdout.write('\x1B[?25h'); +const restoreCursor = () => { + if (!process.stdout.isTTY) return; + process.stdout.write('\x1B[?25h'); +}; const FLUSH_TIMEOUT_MS = 10_000; @@ -79,6 +82,7 @@ export async function run() { const actionOpts = actionCommand.opts(); if ( actionOpts.format !== 'json' && + actionOpts.format !== 'sarif' && actionOpts.format !== 'html' && actionOpts.format !== 'ui' ) { diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 463ac81..144b83f 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -38,6 +38,8 @@ function resolveReporterFormat( switch (configFormat) { case 'json': return 'json'; + case 'sarif': + return 'sarif'; case 'html': return 'html'; case 'text': @@ -87,7 +89,7 @@ export function registerAnalyzeCommand(program: Command, cache: CacheContext) { .description('Analyze your project and report rule violations and architecture risks') .option('-p, --profile ', 'Configuration profile to run') .option('--force', 'Ignore cached results and re-run all analysis tasks') - .option('--format ', 'Reporter format: console | json | ui') + .option('--format ', 'Reporter format: console | json | sarif | html | ui') .option('--compact', 'Use compact, ESLint-style output') .option('--output ', 'Output path for UI reports (default: ngcompass-report.html)') .option('--rule ', 'Run only one rule (useful for debugging or focused checks)') diff --git a/packages/cli/src/commands/exit.ts b/packages/cli/src/commands/exit.ts index 9c3745b..dcb1fd3 100644 --- a/packages/cli/src/commands/exit.ts +++ b/packages/cli/src/commands/exit.ts @@ -10,6 +10,7 @@ /** Restore the terminal cursor unconditionally (idempotent ANSI sequence). */ const restoreCursor = (): void => { + if (!process.stdout.isTTY) return; process.stdout.write('\x1B[?25h'); }; diff --git a/packages/cli/tests/commands/analyze.test.ts b/packages/cli/tests/commands/analyze.test.ts index ec81c69..f945683 100644 --- a/packages/cli/tests/commands/analyze.test.ts +++ b/packages/cli/tests/commands/analyze.test.ts @@ -293,11 +293,13 @@ describe('Analyze Command', () => { it('exits with code 1 if analysis yields errors', async () => { setupSuccessfulPipeline({}, { totalErrors: 1 }); + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); registerAnalyzeCommand(program, { flush: vi.fn().mockResolvedValue(undefined) } as any); await program.parseAsync(['node', 'test', 'analyze']); expect(mockExit).toHaveBeenCalledWith(1); + expect(stdoutWrite).not.toHaveBeenCalledWith('\x1B[?25h'); }); it('exits with code 1 when warnings exceed maxWarnings', async () => { diff --git a/packages/common/LICENSE b/packages/common/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/common/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/common/package.json b/packages/common/package.json index f7611e4..e820cdc 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -26,7 +29,7 @@ }, "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests", diff --git a/packages/config/LICENSE b/packages/config/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/config/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/config/package.json b/packages/config/package.json index 10eb96d..a905bc7 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,7 +22,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests", diff --git a/packages/engine/LICENSE b/packages/engine/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/engine/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/engine/package.json b/packages/engine/package.json index 3e8dde4..aa26674 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,7 +22,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests", diff --git a/packages/planner/LICENSE b/packages/planner/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/planner/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/planner/package.json b/packages/planner/package.json index c33bfc7..8ca040b 100644 --- a/packages/planner/package.json +++ b/packages/planner/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,7 +22,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests", diff --git a/packages/reporters/LICENSE b/packages/reporters/LICENSE new file mode 100644 index 0000000..c1c4748 --- /dev/null +++ b/packages/reporters/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ngcompass + +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/packages/reporters/package.json b/packages/reporters/package.json index a883399..58c78e5 100644 --- a/packages/reporters/package.json +++ b/packages/reporters/package.json @@ -7,6 +7,9 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,7 +22,7 @@ ], "scripts": { "build": "tsup", - "build:prod": "NODE_ENV=production tsup --minify", + "build:prod": "tsup --minify", "dev": "tsup --watch", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests", diff --git a/packages/reporters/src/factory.ts b/packages/reporters/src/factory.ts index 2eae47d..054bdf7 100644 --- a/packages/reporters/src/factory.ts +++ b/packages/reporters/src/factory.ts @@ -1,6 +1,7 @@ import { ConsoleReporter } from './reporters/console-reporter.js'; import { JsonReporter } from './reporters/json-reporter.js'; import { HtmlReporter } from './reporters/html-reporter.js'; +import { SarifReporter } from './reporters/sarif-reporter.js'; import { TextConfigReporter } from './reporters/config.js'; import { TextCacheReporter } from './reporters/cache.js'; import { RulesReporter, type RulesReporterOptions } from './reporters/rules-reporter.js'; @@ -25,6 +26,8 @@ export function getReporter( switch (format) { case 'json': return new JsonReporter(); + case 'sarif': + return new SarifReporter(); case 'console': return new ConsoleReporter(undefined, options); case 'html': diff --git a/packages/reporters/src/index.ts b/packages/reporters/src/index.ts index fb728ba..e1e9623 100644 --- a/packages/reporters/src/index.ts +++ b/packages/reporters/src/index.ts @@ -7,5 +7,6 @@ export * from './reporters/cache.js'; export * from './reporters/console-reporter.js'; export * from './reporters/json-reporter.js'; export * from './reporters/html-reporter.js'; +export * from './reporters/sarif-reporter.js'; export * from './reporters/rules-reporter.js'; diff --git a/packages/reporters/src/reporters/config.ts b/packages/reporters/src/reporters/config.ts index 1b3bef8..391f9bb 100644 --- a/packages/reporters/src/reporters/config.ts +++ b/packages/reporters/src/reporters/config.ts @@ -10,13 +10,10 @@ import type { ReporterOutput } from '../output.js'; import { processOutput } from '../output.js'; /** Minimum column width reserved for a `"line:col"` location label. */ -const LOCATION_COLUMN_WIDTH = 8; +const LOCATION_COLUMN_WIDTH = 6; -/** - * Indentation applied to inline suggestions beneath a config issue line. - * Width is chosen to visually align the suggestion arrow with the issue message body. - */ -const SUGGESTION_INDENT = ' '; // 10 spaces +/** Width reserved for the severity label before the issue message. */ +const SEVERITY_COLUMN_WIDTH = 5; // --------------------------------------------------------------------------- // Pure formatting helpers @@ -40,18 +37,21 @@ function formatPath(pathSegments?: readonly (string | number)[]): string { /** * Formats a config issue into a coloured, optionally multi-line string. * - * Single source of truth for issue presentation — used by both `reportConfig()` + * Single source of truth for issue presentation, used by both `reportConfig()` * and `renderIssues()` to avoid duplicated formatting logic. */ -function formatIssue(issue: ConfigIssue): string { - const label = issue.severity === 'error' ? pc.red('error') : pc.yellow('warn'); +function formatIssue(issue: ConfigIssue, suggestionIndent: string): string { + const rawLabel = issue.severity === 'error' ? 'error' : 'warn'; + const label = issue.severity === 'error' + ? pc.red(rawLabel.padEnd(SEVERITY_COLUMN_WIDTH)) + : pc.yellow(rawLabel.padEnd(SEVERITY_COLUMN_WIDTH)); const pathStr = formatPath(issue.path); const pathInfo = pathStr ? ` ${pc.gray(`at ${pathStr}`)}` : ''; const codeInfo = issue.code ? ` ${pc.dim(issue.code)}` : ''; - let output = `${label} ${issue.message}${pathInfo}${codeInfo}`; + let output = `${label} ${issue.message}${pathInfo}${codeInfo}`; if (issue.suggestion) { - output += `\n${SUGGESTION_INDENT}${pc.cyan('→')} ${pc.dim(issue.suggestion)}`; + output += `\n${suggestionIndent}${pc.cyan('::')} ${pc.dim(issue.suggestion)}`; } return output; } @@ -69,7 +69,7 @@ function pluralise(count: number, word: string): string { * Groups a flat array of `ConfigIssue` objects by their source file path. * * Issues without a `file` property are placed under the `'unknown'` key. - * Pure function — no rendering, no side effects. + * Pure function, no rendering, no side effects. */ function groupIssuesByFile(issues: readonly ConfigIssue[]): Map { return issues.reduce>((map, issue) => { @@ -83,7 +83,7 @@ function groupIssuesByFile(issues: readonly ConfigIssue[]): Map>; @@ -60,6 +60,7 @@ function severityColorClass(severity: string): string { case 'high': return 'sev-high'; case 'error': return 'sev-error'; case 'moderate': return 'sev-moderate'; + case 'warn': case 'warning': return 'sev-warning'; case 'low': return 'sev-low'; case 'info': return 'sev-info'; @@ -69,6 +70,7 @@ function severityColorClass(severity: string): string { } function humanSeverity(severity: string): string { + if (severity === 'warn') return 'Warning'; return severity.charAt(0).toUpperCase() + severity.slice(1); } @@ -136,13 +138,74 @@ function summarizeRules(failures: ReadonlyArray): Array<[string, nu return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); } -function angularLogo(): string { +function buildSeverityChart(severityCounts: SeverityCount, total: number): string { + const R = 54; + const CX = 76; + const CY = 76; + const SW = 20; + const circumference = 2 * Math.PI * R; + + const activeSeverities = SEVERITY_ORDER.filter(s => (severityCounts[s] ?? 0) > 0); + + if (total === 0 || activeSeverities.length === 0) { + return ` +
+ + + 0 + issues + +
+
+ + No issues + 0 +
+
+
`; + } + + let accumulated = 0; + const segments = activeSeverities.map(severity => { + const count = severityCounts[severity] ?? 0; + const segLen = (count / total) * circumference; + const dashOffset = circumference - accumulated; + const el = ``; + accumulated += segLen; + return el; + }); + + const legend = activeSeverities.map(severity => { + const count = severityCounts[severity] ?? 0; + const pct = Math.round((count / total) * 100); + return `
+ + ${escapeHtml(humanSeverity(severity))} + ${count.toLocaleString()} ${pct}% +
`; + }).join(''); + return ` -`; +
+ + + ${segments.join('\n ')} + ${total.toLocaleString()} + issues + +
+ ${legend} +
+
`; +} + +function brandLogo(): string { + const src = 'data:image/png;base64,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'; + return ``; } function iconSearch(): string { @@ -158,13 +221,17 @@ function iconFile(): string { } function iconAlert(): string { - return ''; + return ''; } function iconWarning(): string { return ''; } +function iconList(): string { + return ''; +} + function buildStyles(): string { return ` :root { @@ -231,7 +298,7 @@ html { scroll-behavior: smooth; } body { margin: 0; min-height: 100vh; - font: 14px/1.5 var(--font-sans); + font: 14px/1.6 var(--font-sans); color: hsl(var(--foreground)); background: hsl(var(--background)); -webkit-font-smoothing: antialiased; @@ -241,35 +308,44 @@ button, input { font: inherit; color: inherit; } a { color: inherit; } .page { - width: min(1120px, calc(100vw - 32px)); + width: min(1120px, calc(100vw - 48px)); margin: 0 auto; - padding: 24px 0 48px; + padding: 36px 0 72px; } +/* ── Top bar ────────────────────────────────────────────── */ + .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; - padding: 4px 0 24px; + padding: 0 0 36px; } .brand { display: inline-flex; align-items: center; - gap: 10px; + gap: 12px; font-weight: 600; letter-spacing: -0.01em; - font-size: 14px; + font-size: 15px; +} +.brand-logo { + width: 28px; + height: 28px; + flex: 0 0 auto; + display: block; + border-radius: 8px; + object-fit: cover; } -.brand svg { width: 22px; height: 22px; flex: 0 0 auto; } .topbar-meta { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; - gap: 8px; + gap: 10px; color: hsl(var(--muted-foreground)); font-size: 13px; } @@ -278,26 +354,39 @@ a { color: inherit; } background: hsl(var(--muted-foreground) / 0.4); } +/* ── Hero ───────────────────────────────────────────────── */ + .hero { display: grid; - gap: 20px; - margin-bottom: 24px; + gap: 28px; + margin-bottom: 48px; } -.hero-copy { display: grid; gap: 8px; } +.eyebrow { + font-size: 13px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + letter-spacing: 0.01em; + margin-bottom: 4px; +} + +.hero-copy { display: grid; gap: 12px; } .hero-title { margin: 0; - font-size: 30px; - font-weight: 600; + font-size: 32px; + font-weight: 700; line-height: 1.1; - letter-spacing: -0.025em; + letter-spacing: -0.03em; color: hsl(var(--foreground)); } +.hero-title.pass { color: hsl(var(--success)); } +.hero-title.fail { color: hsl(var(--destructive)); } .hero-subtitle { color: hsl(var(--muted-foreground)); font-size: 15px; + line-height: 1.6; max-width: 72ch; } @@ -305,8 +394,8 @@ a { color: inherit; } display: inline-flex; align-items: center; gap: 6px; - height: 22px; - padding: 0 8px; + height: 24px; + padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 500; @@ -329,10 +418,12 @@ a { color: inherit; } background: currentColor; } +/* ── Stat cards ─────────────────────────────────────────── */ + .stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + gap: 16px; } .card { @@ -343,41 +434,78 @@ a { color: inherit; } } .stat-card { - padding: 16px 18px; + padding: 22px 24px; display: grid; - gap: 4px; + gap: 10px; +} + +.stat-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .stat-label { color: hsl(var(--muted-foreground)); font-size: 13px; font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius); + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted)); + flex: 0 0 auto; +} +.stat-icon svg { + width: 17px; + height: 17px; +} +.stat-icon.destructive { + color: hsl(var(--destructive)); + background: hsl(var(--destructive-soft)); +} +.stat-icon.warning { + color: hsl(var(--warning)); + background: hsl(var(--warning-soft)); } .stat-value { - font-size: 24px; - line-height: 1.1; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 32px; + line-height: 1; + font-weight: 700; + letter-spacing: -0.03em; color: hsl(var(--foreground)); } .stat-value.destructive { color: hsl(var(--destructive)); } .stat-value.warning { color: hsl(var(--warning)); } -.content { display: grid; gap: 20px; } +/* ── Content layout ─────────────────────────────────────── */ + +.content { display: grid; gap: 32px; } .summary-strip { display: grid; grid-template-columns: 1.1fr 1fr; - gap: 16px; + gap: 20px; } +/* ── Card primitives ────────────────────────────────────── */ + .card-head { display: flex; align-items: baseline; justify-content: space-between; - gap: 12px; - padding: 16px 18px 0; + gap: 16px; + padding: 20px 24px 0; } .card-title { @@ -393,27 +521,10 @@ a { color: inherit; } } .card-body { - padding: 12px 18px 18px; + padding: 16px 24px 24px; } -.severity-list { display: grid; gap: 2px; } - -.severity-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 10px; - border-radius: var(--radius); -} -.severity-row:hover { background: hsl(var(--muted) / 0.6); } - -.severity-row .left { - display: inline-flex; - align-items: center; - gap: 10px; - min-width: 0; -} +/* ── Severity dot (shared) ──────────────────────────────── */ .dot { width: 8px; @@ -423,23 +534,11 @@ a { color: inherit; } background: currentColor; } -.severity-name { - font-weight: 500; - text-transform: capitalize; - color: hsl(var(--foreground)); - font-size: 13px; -} - -.severity-count { - color: hsl(var(--muted-foreground)); - font-weight: 500; - font-size: 13px; - font-variant-numeric: tabular-nums; -} +/* ── Top rules ──────────────────────────────────────────── */ -.rule-list { display: grid; gap: 12px; } +.rule-list { display: grid; gap: 18px; } -.rule-item { display: grid; gap: 6px; } +.rule-item { display: grid; gap: 8px; } .rule-top { display: flex; align-items: baseline; @@ -459,9 +558,10 @@ a { color: inherit; } color: hsl(var(--muted-foreground)); font-size: 12px; font-variant-numeric: tabular-nums; + white-space: nowrap; } .rule-bar { - height: 4px; + height: 5px; border-radius: 999px; background: hsl(var(--muted)); overflow: hidden; @@ -470,14 +570,18 @@ a { color: inherit; } display: block; height: 100%; border-radius: inherit; - background: hsl(var(--foreground) / 0.6); + background: hsl(var(--foreground) / 0.55); } -.toolbar { +/* ── Controls bar ───────────────────────────────────────── */ + +.controls { padding: 20px 24px; } + +.controls-row { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 16px; flex-wrap: wrap; } @@ -488,7 +592,7 @@ a { color: inherit; } } .searchbox svg { position: absolute; - left: 10px; + left: 12px; top: 50%; width: 15px; height: 15px; @@ -498,8 +602,8 @@ a { color: inherit; } } .searchbox input { width: 100%; - height: 36px; - padding: 0 12px 0 34px; + height: 40px; + padding: 0 14px 0 38px; border-radius: var(--radius); border: 1px solid hsl(var(--input)); background: hsl(var(--background)); @@ -510,13 +614,13 @@ a { color: inherit; } .searchbox input::placeholder { color: hsl(var(--muted-foreground)); } .searchbox input:focus { border-color: hsl(var(--ring)); - box-shadow: 0 0 0 2px hsl(var(--ring) / 0.15); + box-shadow: 0 0 0 3px hsl(var(--ring) / 0.12); } .actions { display: flex; align-items: center; - gap: 6px; + gap: 8px; flex-wrap: wrap; } @@ -528,17 +632,16 @@ a { color: inherit; } border: 1px solid hsl(var(--border)); background: hsl(var(--background)); color: hsl(var(--foreground)); - height: 36px; + height: 40px; border-radius: var(--radius); - padding: 0 12px; + padding: 0 16px; font-size: 13px; font-weight: 500; cursor: pointer; transition: background 0.15s ease, border-color 0.15s ease; + white-space: nowrap; } -.btn:hover { - background: hsl(var(--accent)); -} +.btn:hover { background: hsl(var(--accent)); } .btn.is-active { background: hsl(var(--primary)); border-color: hsl(var(--primary)); @@ -554,54 +657,59 @@ a { color: inherit; } color: hsl(var(--foreground)); } -.section-head { +/* ── Files section header ───────────────────────────────── */ + +.files-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; - padding: 4px 2px 0; + padding: 8px 2px 4px; } -.section-title { +.files-title { color: hsl(var(--foreground)); font-size: 14px; font-weight: 600; letter-spacing: -0.01em; } -.section-sub { +.files-count { color: hsl(var(--muted-foreground)); font-size: 12px; } -.file-list { display: grid; gap: 8px; } +/* ── File cards ─────────────────────────────────────────── */ + +.file-list { display: grid; gap: 12px; } .file-card { border: 1px solid hsl(var(--border)); border-radius: var(--radius-lg); background: hsl(var(--card)); overflow: hidden; - transition: border-color 0.15s ease; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } -.file-card:hover { border-color: hsl(var(--border) / 1.4); } +.file-card:hover { box-shadow: 0 1px 6px hsl(var(--foreground) / 0.06); } .file-header { width: 100%; display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 12px 16px; + gap: 16px; + padding: 16px 20px; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; + transition: background 0.12s ease; } .file-header:hover { background: hsl(var(--muted) / 0.4); } .file-main { display: flex; align-items: center; - gap: 10px; + gap: 12px; min-width: 0; } .file-icon { @@ -623,12 +731,12 @@ a { color: inherit; } min-width: 0; display: flex; align-items: baseline; - gap: 8px; + gap: 10px; flex-wrap: wrap; } .file-name { font-size: 14px; - font-weight: 500; + font-weight: 600; color: hsl(var(--foreground)); } .file-sub { @@ -642,29 +750,33 @@ a { color: inherit; } .file-meta { display: flex; align-items: center; - gap: 6px; + gap: 8px; flex-wrap: wrap; justify-content: flex-end; + flex-shrink: 0; } +/* ── Badges ─────────────────────────────────────────────── */ + .badge { display: inline-flex; align-items: center; - gap: 4px; - height: 22px; - padding: 0 8px; + gap: 5px; + height: 24px; + padding: 0 10px; border-radius: 999px; border: 1px solid transparent; font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; + white-space: nowrap; } .badge svg { width: 12px; height: 12px; } -.badge-secondary { +.badge-secondary, .badge-neutral { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); } -.badge-destructive { +.badge-destructive, .badge-error { background: hsl(var(--destructive-soft)); color: hsl(var(--destructive)); border-color: hsl(var(--destructive-border)); @@ -675,6 +787,8 @@ a { color: inherit; } border-color: hsl(var(--warning-border)); } +/* ── Issue rows ─────────────────────────────────────────── */ + .file-issues { display: none; border-top: 1px solid hsl(var(--border)); @@ -684,24 +798,26 @@ a { color: inherit; } .issue { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; - gap: 12px; + gap: 16px; align-items: start; - padding: 10px 16px; + padding: 16px 20px; border-top: 1px solid hsl(var(--border)); + transition: background 0.12s ease; } .issue:first-child { border-top: 0; } +.issue:hover { background: hsl(var(--muted) / 0.3); } .issue-left { display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 24px; + height: 24px; border-radius: 999px; flex: 0 0 auto; - margin-top: 1px; + margin-top: 2px; } -.issue-left svg { width: 12px; height: 12px; } +.issue-left svg { width: 13px; height: 13px; } .issue.is-error .issue-left { background: hsl(var(--destructive-soft)); color: hsl(var(--destructive)); @@ -714,18 +830,18 @@ a { color: inherit; } .issue-body { min-width: 0; display: grid; - gap: 3px; + gap: 8px; } .issue-message { - font-size: 13.5px; + font-size: 14px; color: hsl(var(--foreground)); - line-height: 1.45; + line-height: 1.5; } .issue-meta { color: hsl(var(--muted-foreground)); display: flex; flex-wrap: wrap; - gap: 6px 10px; + gap: 6px 12px; font-size: 12px; align-items: center; } @@ -743,31 +859,117 @@ a { color: inherit; } align-self: start; margin-top: 4px; font-variant-numeric: tabular-nums; + white-space: nowrap; } -.parse-list { display: grid; gap: 8px; } +/* ── Parse errors ───────────────────────────────────────── */ + +.parse-errors .card-body { padding-top: 20px; } + +.parse-list { display: grid; gap: 10px; } .parse-item { - padding: 12px 14px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + padding: 16px 20px; border-radius: var(--radius); border: 1px solid hsl(var(--destructive-border)); background: hsl(var(--destructive-soft)); } +.parse-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 999px; + color: hsl(var(--destructive)); + background: hsl(var(--background) / 0.68); + flex: 0 0 auto; + margin-top: 1px; +} +.parse-icon svg { + width: 13px; + height: 13px; +} +.parse-copy { min-width: 0; } .parse-path { font: 12px/1.4 var(--font-mono); color: hsl(var(--destructive)); font-weight: 500; } .parse-message { - margin-top: 4px; + margin-top: 8px; + color: hsl(var(--foreground)); + font-size: 13px; + line-height: 1.5; +} + +/* ── Donut chart ────────────────────────────────────────── */ + +.chart-wrap { + display: flex; + align-items: center; + gap: 20px; +} +.donut-svg { + width: 140px; + height: 140px; + flex: 0 0 140px; + overflow: visible; +} +.donut-center-val { + font-size: 20px; + font-weight: 700; + font-family: var(--font-sans); + fill: hsl(var(--foreground)); + letter-spacing: -0.03em; +} +.donut-center-label { + font-size: 10px; + font-weight: 500; + font-family: var(--font-sans); + fill: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.chart-legend { + flex: 1; + min-width: 0; + display: grid; + gap: 9px; +} +.chart-legend-item { + display: flex; + align-items: center; + gap: 10px; +} +.chart-legend-label { + flex: 1; + min-width: 0; + font-size: 13px; + font-weight: 500; color: hsl(var(--foreground)); +} +.chart-legend-count { font-size: 13px; + font-weight: 600; + color: hsl(var(--foreground)); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.chart-legend-pct { + font-weight: 400; + color: hsl(var(--muted-foreground)); } +/* ── Empty state ────────────────────────────────────────── */ + .empty { display: grid; place-items: center; text-align: center; - padding: 48px 24px; + padding: 64px 32px; border: 1px dashed hsl(var(--border)); border-radius: var(--radius-lg); color: hsl(var(--muted-foreground)); @@ -777,26 +979,29 @@ a { color: inherit; } display: inline-flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 48px; + height: 48px; border-radius: 999px; background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); - margin-bottom: 12px; + margin-bottom: 16px; } -.empty-icon svg { width: 18px; height: 18px; } +.empty-icon svg { width: 22px; height: 22px; } .empty-title { - font-size: 16px; + font-size: 17px; font-weight: 600; letter-spacing: -0.01em; color: hsl(var(--foreground)); } .empty-sub { - margin-top: 4px; + margin-top: 6px; color: hsl(var(--muted-foreground)); font-size: 13px; + line-height: 1.5; } +/* ── Footer ─────────────────────────────────────────────── */ + .footer { display: flex; justify-content: space-between; @@ -804,13 +1009,15 @@ a { color: inherit; } flex-wrap: wrap; color: hsl(var(--muted-foreground)); font-size: 12px; - padding: 24px 2px 0; + padding: 32px 2px 0; border-top: 1px solid hsl(var(--border)); - margin-top: 32px; + margin-top: 56px; } .hidden { display: none !important; } +/* ── Severity colours ───────────────────────────────────── */ + .sev-critical { color: hsl(0 72% 51%); } .sev-high { color: hsl(20 90% 48%); } .sev-error { color: hsl(0 72% 51%); } @@ -831,17 +1038,27 @@ a { color: inherit; } .sev-hint { color: hsl(262 83% 70%); } } +/* ── Responsive ─────────────────────────────────────────── */ + @media (max-width: 1024px) { .summary-strip { grid-template-columns: 1fr; } .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 640px) { - .hero-title { font-size: 24px; } - .issue { grid-template-columns: auto minmax(0, 1fr); } + .page { width: calc(100vw - 32px); padding: 24px 0 48px; } + .hero-title { font-size: 26px; } + .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .stat-card { padding: 16px 18px; } + .issue { grid-template-columns: auto minmax(0, 1fr); gap: 12px; padding: 14px 16px; } .issue-loc { grid-column: 2; margin-top: 0; } + .file-header { padding: 14px 16px; } + .controls { padding: 16px; } + .controls-row { flex-direction: column; align-items: stretch; } .topbar { flex-direction: column; align-items: flex-start; } .topbar-meta { justify-content: flex-start; } + .card-head { padding: 16px 18px 0; } + .card-body { padding: 14px 18px 20px; } } `; } @@ -892,7 +1109,7 @@ function buildFileCard(bucket: FileBucket, index: number): string { ${bucket.errorCount > 0 ? `${iconAlert()} ${bucket.errorCount} error${bucket.errorCount === 1 ? '' : 's'}` : ''} ${bucket.warningCount > 0 ? `${iconWarning()} ${bucket.warningCount} warning${bucket.warningCount === 1 ? '' : 's'}` : ''} - ${bucket.failures.length} issue${bucket.failures.length === 1 ? '' : 's'} + ${iconList()} ${bucket.failures.length} issue${bucket.failures.length === 1 ? '' : 's'}
@@ -1012,15 +1229,6 @@ function buildHtml( ? `No violations found across ${totalFiles.toLocaleString()} scanned file${totalFiles === 1 ? '' : 's'}.` : `${totalViolations.toLocaleString()} violation${totalViolations === 1 ? '' : 's'} across ${affectedFiles.toLocaleString()} affected file${affectedFiles === 1 ? '' : 's'}. ${parseErrors.length > 0 ? `${parseErrors.length.toLocaleString()} parse error${parseErrors.length === 1 ? '' : 's'}. ` : ''}${cachedCopy}.`; - const severityRows = SEVERITY_ORDER - .filter((severity) => (severityCounts[severity] ?? 0) > 0) - .map((severity) => ` -
- ${escapeHtml(humanSeverity(severity))} - ${(severityCounts[severity] ?? 0).toLocaleString()} -
`) - .join('\n') || '
No issues0
'; - const rulesHtml = topRules.length > 0 ? topRules.map(([ruleName, count]) => `
@@ -1038,12 +1246,17 @@ function buildHtml(
Parse errors
${parseErrors.length.toLocaleString()}
-
- ${parseErrors.map((error) => ` -
-
${escapeHtml(relativeToRoot(error.filePath))}
-
${escapeHtml(error.message)}
-
`).join('\n')} +
+
+ ${parseErrors.map((error) => ` +
+
${iconAlert()}
+
+
${escapeHtml(relativeToRoot(error.filePath))}
+
${escapeHtml(error.message)}
+
+
`).join('\n')} +
` : ''; @@ -1052,7 +1265,7 @@ function buildHtml( : `
-
${iconFile()}
+
${iconFile()}
No violations found
This scan completed cleanly.
@@ -1070,12 +1283,13 @@ function buildHtml(
- ${angularLogo()} + ${brandLogo()} ngcompass
- ${escapeHtml(timestamp)} - ${escapeHtml(projectName)} + ${escapeHtml(timestamp)} + + ${escapeHtml(projectName)}
@@ -1087,20 +1301,32 @@ function buildHtml(
${escapeHtml(subtitle)}
-
-
Errors
-
${summary.totalErrors.toLocaleString()}
+
+
+
Errors
+
${iconAlert()}
+
+
${summary.totalErrors.toLocaleString()}
-
-
Warnings
-
${summary.totalWarnings.toLocaleString()}
+
+
+
Warnings
+
${iconWarning()}
+
+
${summary.totalWarnings.toLocaleString()}
-
-
Affected files
+
+
+
Affected files
+
${iconFile()}
+
${affectedFiles.toLocaleString()}
-
-
Violations
+
+
+
Violations
+
${iconList()}
+
${totalViolations.toLocaleString()}
@@ -1114,14 +1340,14 @@ function buildHtml(
Severity breakdown
${totalViolations.toLocaleString()} total
-
${severityRows}
+
${buildSeverityChart(severityCounts, totalViolations)}
Top rules
${topRules.length.toLocaleString()}
-
${rulesHtml}
+
${rulesHtml}
` : ''} @@ -1162,7 +1388,6 @@ function buildHtml(
Generated by ngcompass - Compact report UI inspired by shadcn-style primitives
diff --git a/packages/reporters/src/reporters/sarif-reporter.ts b/packages/reporters/src/reporters/sarif-reporter.ts new file mode 100644 index 0000000..6028cdf --- /dev/null +++ b/packages/reporters/src/reporters/sarif-reporter.ts @@ -0,0 +1,204 @@ +import path from 'node:path'; +import process from 'node:process'; +import type { RuleResult, RuleFailure, RuleSeverity } from '@ngcompass/common'; +import type { ParseError, Reporter, ResultSummary } from '../types.js'; +import type { ReporterOutput } from '../output.js'; +import { processOutput } from '../output.js'; +import { compareByPosition, isErrorSeverity } from '../severity-utils.js'; + +const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json'; +const SARIF_VERSION = '2.1.0'; + +type SarifLevel = 'error' | 'warning' | 'note'; + +interface SarifReport { + readonly version: typeof SARIF_VERSION; + readonly $schema: typeof SARIF_SCHEMA; + readonly runs: readonly SarifRun[]; +} + +interface SarifRun { + readonly tool: { + readonly driver: { + readonly name: string; + readonly informationUri: string; + readonly rules: readonly SarifRule[]; + }; + }; + readonly results: readonly SarifResult[]; + readonly invocations?: readonly SarifInvocation[]; +} + +interface SarifRule { + readonly id: string; + readonly shortDescription: { + readonly text: string; + }; +} + +interface SarifResult { + readonly ruleId: string; + readonly level: SarifLevel; + readonly message: { + readonly text: string; + }; + readonly locations: readonly SarifLocation[]; +} + +interface SarifLocation { + readonly physicalLocation: { + readonly artifactLocation: { + readonly uri: string; + }; + readonly region: { + readonly startLine: number; + readonly startColumn: number; + }; + }; +} + +interface SarifInvocation { + readonly executionSuccessful: boolean; + readonly toolExecutionNotifications: readonly SarifNotification[]; +} + +interface SarifNotification { + readonly level: SarifLevel; + readonly message: { + readonly text: string; + }; + readonly locations: readonly SarifLocation[]; +} + +function toSarifLevel(severity: RuleSeverity): SarifLevel { + return isErrorSeverity(severity) ? 'error' : 'warning'; +} + +function toArtifactUri(filePath: string): string { + const relative = path.relative(process.cwd(), filePath); + const artifactPath = relative && !relative.startsWith('..') && !path.isAbsolute(relative) + ? relative + : filePath; + + return artifactPath.replace(/\\/g, '/'); +} + +function toLocation(filePath: string, line: number, column: number): SarifLocation { + return { + physicalLocation: { + artifactLocation: { + uri: toArtifactUri(filePath), + }, + region: { + startLine: Math.max(1, line), + startColumn: Math.max(1, column), + }, + }, + }; +} + +function toSarifResult(failure: RuleFailure): SarifResult { + return { + ruleId: failure.ruleName, + level: toSarifLevel(failure.severity), + message: { + text: failure.message, + }, + locations: [ + toLocation(failure.filePath, failure.line, failure.column), + ], + }; +} + +function collectRules(results: ReadonlyArray): SarifRule[] { + const rules = new Map(); + + for (const result of results) { + const firstFailure = result.failures[0]; + rules.set(result.ruleName, { + id: result.ruleName, + shortDescription: { + text: firstFailure?.message ?? result.ruleName, + }, + }); + } + + return [...rules.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +function collectResults(results: ReadonlyArray): SarifResult[] { + return results + .flatMap((result) => result.failures) + .sort((a, b) => { + const fileDiff = a.filePath.localeCompare(b.filePath); + if (fileDiff !== 0) return fileDiff; + return compareByPosition(a, b); + }) + .map(toSarifResult); +} + +function toParseErrorNotification(error: ParseError): SarifNotification { + return { + level: 'warning', + message: { + text: error.message, + }, + locations: [ + toLocation(error.filePath, 1, 1), + ], + }; +} + +function buildSarifReport( + results: ReadonlyArray, + parseErrors: ReadonlyArray, +): SarifReport { + const notifications = parseErrors.map(toParseErrorNotification); + const invocation = notifications.length > 0 + ? [{ + executionSuccessful: true, + toolExecutionNotifications: notifications, + }] + : undefined; + + return { + version: SARIF_VERSION, + $schema: SARIF_SCHEMA, + runs: [{ + tool: { + driver: { + name: 'ngcompass', + informationUri: 'https://github.com/SigoudisEftimis/ngcompass_', + rules: collectRules(results), + }, + }, + results: collectResults(results), + ...(invocation ? { invocations: invocation } : {}), + }], + }; +} + +export class SarifReporter implements Reporter { + private readonly parseErrorBuffer: ParseError[] = []; + + constructor(private readonly out: ReporterOutput = processOutput) {} + + report(results: ReadonlyArray): void { + this.out.write(JSON.stringify(buildSarifReport(results, this.parseErrorBuffer), null, 2)); + } + + parseErrors(errors: ReadonlyArray): void { + for (const error of errors) { + this.parseErrorBuffer.push(error); + } + } + + error(error: Error): void { + this.out.error(JSON.stringify({ error: error.message }, null, 2)); + } + + summary(_stats: ResultSummary): void {} + step(_message: string): void {} + info(_message: string): void {} + debug(_message: string): void {} +} diff --git a/packages/reporters/src/types.ts b/packages/reporters/src/types.ts index 41f2a80..80d0e99 100644 --- a/packages/reporters/src/types.ts +++ b/packages/reporters/src/types.ts @@ -12,7 +12,7 @@ import type { ConfigReport, HealthReport, InitResult, RuleResult } from '@ngcomp import type { CacheInfo } from '@ngcompass/cache'; /** Supported output encodings for analysis results. */ -export type ReporterFormat = 'console' | 'json' | 'html' | 'ui'; +export type ReporterFormat = 'console' | 'json' | 'html' | 'ui' | 'sarif'; export interface ConsoleReporterOptions { /** Enables additional diagnostic output (verbose/fix recommendations). */ diff --git a/packages/reporters/tests/config-reporter.spec.ts b/packages/reporters/tests/config-reporter.spec.ts index 1fea699..22b3e7b 100644 --- a/packages/reporters/tests/config-reporter.spec.ts +++ b/packages/reporters/tests/config-reporter.spec.ts @@ -11,6 +11,10 @@ function makeConfigReport(overrides: Partial = {}): ConfigReport { return { valid: true, issues: [], ...overrides }; } +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ''); +} + describe('TextConfigReporter', () => { let out: ReturnType; let reporter: TextConfigReporter; @@ -39,23 +43,23 @@ describe('TextConfigReporter', () => { }); describe('renderInitResult()', () => { - it('outputs success checkmark', () => { + it('outputs success marker', () => { const result: InitResult = { success: true, filePath: 'ngcompass.json' }; reporter.renderInitResult(result); expect(out.lines[0]).toContain('ngcompass.json'); - expect(out.lines[0]).toContain('✓'); + expect(out.lines[0]).toContain('OK'); }); - it('outputs middot with (exists) when file already exists', () => { + it('outputs exists marker with (exists) when file already exists', () => { const result: InitResult = { success: false, filePath: 'ngcompass.json', alreadyExists: true }; reporter.renderInitResult(result); expect(out.lines[0]).toContain('(exists)'); }); - it('outputs error cross for a failed initialization', () => { + it('outputs error marker for a failed initialization', () => { const result: InitResult = { success: false, filePath: 'ngcompass.json' }; reporter.renderInitResult(result); - expect(out.errors[0]).toContain('×'); + expect(out.errors[0]).toContain('x'); }); }); @@ -91,5 +95,30 @@ describe('TextConfigReporter', () => { const output = out.lines.join('\n'); expect(output).toContain('ngcompass.json'); }); + + it('left-aligns issue rows and aligns suggestions under the message', () => { + reporter.renderHealthReport(makeHealthReport({ + valid: false, + issues: [ + { + code: 'E1', + message: 'Output directory does not exist', + severity: 'error', + file: 'ngcompass.config.ts', + line: 13, + column: 3, + suggestion: 'Create the directory or update outputPath.', + }, + ], + })); + + const physicalLines = out.lines.flatMap(line => stripAnsi(line).split('\n')); + const issueLine = physicalLines.find(line => line.includes('Output directory does not exist'))!; + const suggestionLine = physicalLines.find(line => line.includes('Create the directory'))!; + + expect(issueLine.startsWith('13:3')).toBe(true); + expect(issueLine).toContain('error'); + expect(suggestionLine.startsWith(' ->')).toBe(true); + }); }); }); diff --git a/packages/reporters/tests/html-reporter.spec.ts b/packages/reporters/tests/html-reporter.spec.ts new file mode 100644 index 0000000..217dd46 --- /dev/null +++ b/packages/reporters/tests/html-reporter.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HtmlReporter } from '../src/reporters/html-reporter.js'; +import { createTestOutput } from '../src/output.js'; +import type { RuleFailure, RuleResult } from '@ngcompass/common'; +import type { ParseError, ResultSummary } from '../src/types.js'; + +function makeFailure(overrides: Partial = {}): RuleFailure { + return { + filePath: join(process.cwd(), 'src/app.component.ts'), + message: 'Unsafe