- ${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}
+
` : ''}
@@ -1162,7 +1388,6 @@ function buildHtml(
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..bdcedcc 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 & binding',
+ line: 7,
+ column: 3,
+ severity: 'error',
+ ruleName: 'test-rule',
+ ...overrides,
+ };
+}
+
+function makeResult(failure: RuleFailure = makeFailure()): RuleResult {
+ return {
+ ruleName: failure.ruleName,
+ failures: [failure],
+ };
+}
+
+describe('HtmlReporter', () => {
+ let tempDir: string;
+
+ beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), 'ngcompass-html-'));
+ });
+
+ afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ async function renderHtml(
+ results: ReadonlyArray,
+ parseErrors: ReadonlyArray = [],
+ summaryOverrides: Partial = {},
+ ): Promise<{ html: string; out: ReturnType }> {
+ const outputPath = join(tempDir, 'report.html');
+ const out = createTestOutput();
+ const reporter = new HtmlReporter(outputPath, out.output);
+
+ reporter.parseErrors(parseErrors);
+ reporter.report(results);
+ reporter.summary({
+ totalFiles: 1,
+ totalTasks: 1,
+ totalErrors: 1,
+ totalWarnings: 0,
+ duration: 42,
+ ...summaryOverrides,
+ });
+
+ return {
+ html: await readFile(outputPath, 'utf8'),
+ out,
+ };
+ }
+
+ it('renders the embedded brand logo and issue summary cards', async () => {
+ const { html, out } = await renderHtml([makeResult()]);
+
+ expect(html).toContain('class="brand-logo"');
+ expect(html).toContain('data:image/png;base64,');
+ expect(html).toContain('Errors');
+ expect(html).toContain('Warnings');
+ expect(html).toContain('Violations');
+ expect(html).toContain('badge badge-error');
+ expect(html).toContain('src/app.component.ts');
+ expect(html).toContain('test-rule');
+ expect(html).toContain('Unsafe <template> & binding');
+ expect(out.errors[0]).toContain('Report saved:');
+ });
+
+ it('renders parse errors with escaped content', async () => {
+ const { html } = await renderHtml([], [
+ {
+ filePath: join(process.cwd(), 'src/broken.component.ts'),
+ message: 'Unexpected & EOF',
+ },
+ ], {
+ totalErrors: 0,
+ totalWarnings: 0,
+ });
+
+ expect(html).toContain('Parse errors');
+ expect(html).toContain('parse-item');
+ expect(html).toContain('src/broken.component.ts');
+ expect(html).toContain('Unexpected <token> & EOF');
+ });
+
+ it('renders the clean-state report when there are no violations', async () => {
+ const { html } = await renderHtml([], [], {
+ totalFiles: 3,
+ totalTasks: 3,
+ totalErrors: 0,
+ totalWarnings: 0,
+ });
+
+ expect(html).toContain('Analysis Passed');
+ expect(html).toContain('No violations found');
+ expect(html).toContain('No violations found across 3 scanned files.');
+ });
+});
diff --git a/packages/reporters/tests/sarif-reporter.spec.ts b/packages/reporters/tests/sarif-reporter.spec.ts
new file mode 100644
index 0000000..f7ddf8d
--- /dev/null
+++ b/packages/reporters/tests/sarif-reporter.spec.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { SarifReporter } from '../src/reporters/sarif-reporter.js';
+import { createTestOutput } from '../src/output.js';
+import type { RuleResult } from '@ngcompass/common';
+
+function makeResult(filePath: string, overrides: Partial = {}): RuleResult {
+ const ruleName = overrides.ruleName ?? 'test-rule';
+
+ return {
+ ruleName,
+ failures: [{
+ filePath,
+ message: 'Test violation',
+ line: 3,
+ column: 5,
+ severity: 'error',
+ ruleName,
+ ...overrides,
+ }],
+ };
+}
+
+describe('SarifReporter', () => {
+ let out: ReturnType;
+ let reporter: SarifReporter;
+
+ beforeEach(() => {
+ out = createTestOutput();
+ reporter = new SarifReporter(out.output);
+ });
+
+ describe('report()', () => {
+ it('emits a SARIF 2.1.0 document for zero results', () => {
+ reporter.report([]);
+ const parsed = JSON.parse(out.lines[0]);
+
+ expect(parsed.version).toBe('2.1.0');
+ expect(parsed.$schema).toBe('https://json.schemastore.org/sarif-2.1.0.json');
+ expect(parsed.runs[0].tool.driver.name).toBe('ngcompass');
+ expect(parsed.runs[0].results).toEqual([]);
+ });
+
+ it('maps rule failures to SARIF results and rule metadata', () => {
+ reporter.report([makeResult('src/app.component.ts', { ruleName: 'prefer-on-push' })]);
+ const parsed = JSON.parse(out.lines[0]);
+
+ expect(parsed.runs[0].tool.driver.rules[0].id).toBe('prefer-on-push');
+ expect(parsed.runs[0].results[0]).toMatchObject({
+ ruleId: 'prefer-on-push',
+ level: 'error',
+ message: { text: 'Test violation' },
+ });
+ expect(parsed.runs[0].results[0].locations[0].physicalLocation).toMatchObject({
+ artifactLocation: { uri: 'src/app.component.ts' },
+ region: { startLine: 3, startColumn: 5 },
+ });
+ });
+
+ it('maps warn severity to SARIF warning level', () => {
+ reporter.report([makeResult('src/app.component.ts', { severity: 'warn' })]);
+ const parsed = JSON.parse(out.lines[0]);
+
+ expect(parsed.runs[0].results[0].level).toBe('warning');
+ });
+
+ it('sorts results by file path then source position', () => {
+ reporter.report([
+ {
+ ruleName: 'rule',
+ failures: [
+ { filePath: 'src/z.ts', message: 'z', line: 1, column: 1, severity: 'error', ruleName: 'rule' },
+ { filePath: 'src/a.ts', message: 'b', line: 10, column: 1, severity: 'error', ruleName: 'rule' },
+ { filePath: 'src/a.ts', message: 'a', line: 1, column: 1, severity: 'error', ruleName: 'rule' },
+ ],
+ },
+ ]);
+ const parsed = JSON.parse(out.lines[0]);
+
+ expect(parsed.runs[0].results.map((result: any) => result.message.text)).toEqual(['a', 'b', 'z']);
+ });
+
+ it('includes parse errors as tool execution notifications', () => {
+ reporter.parseErrors([{ filePath: 'src/broken.ts', message: 'Unexpected token' }]);
+ reporter.report([]);
+ const parsed = JSON.parse(out.lines[0]);
+
+ expect(parsed.runs[0].invocations[0].executionSuccessful).toBe(true);
+ expect(parsed.runs[0].invocations[0].toolExecutionNotifications[0]).toMatchObject({
+ level: 'warning',
+ message: { text: 'Unexpected token' },
+ });
+ expect(parsed.runs[0].invocations[0].toolExecutionNotifications[0].locations[0].physicalLocation.artifactLocation.uri)
+ .toBe('src/broken.ts');
+ });
+ });
+
+ describe('summary()', () => {
+ it('produces no output', () => {
+ reporter.summary({ totalFiles: 1, totalTasks: 1, totalErrors: 0, totalWarnings: 0, duration: 0 });
+ expect(out.lines).toHaveLength(0);
+ });
+ });
+
+ describe('error()', () => {
+ it('emits valid JSON error object to stderr', () => {
+ reporter.error(new Error('analysis failed'));
+ const parsed = JSON.parse(out.errors[0]);
+
+ expect(parsed.error).toBe('analysis failed');
+ });
+ });
+});
diff --git a/packages/rules/LICENSE b/packages/rules/LICENSE
new file mode 100644
index 0000000..c1c4748
--- /dev/null
+++ b/packages/rules/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/rules/package.json b/packages/rules/package.json
index 944f5b3..af6d23f 100644
--- a/packages/rules/package.json
+++ b/packages/rules/package.json
@@ -1,12 +1,15 @@
{
"name": "@ngcompass/rules",
- "version": "0.0.1",
+ "version": "0.1.0-beta",
"description": "Rules collection for ngcompass",
"sideEffects": false,
"main": "./dist/index.cjs",
"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",
@@ -23,7 +26,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/rules/src/presets/all.ts b/packages/rules/src/presets/all.ts
index 9861896..9012448 100644
--- a/packages/rules/src/presets/all.ts
+++ b/packages/rules/src/presets/all.ts
@@ -29,7 +29,6 @@ export const allPreset: PresetConfig = {
'signal-no-side-effects-in-computed': 'error',
'signal-prefer-computed-over-sync-effect': 'warn',
'signal-effect-must-be-destroy-scoped': 'error',
- 'signal-no-effect-in-constructor': 'warn',
'signal-avoid-untracked-overuse': 'warn',
'signal-prefer-input-signal': 'warn',
'signal-prefer-output-function': 'warn',
@@ -37,7 +36,7 @@ export const allPreset: PresetConfig = {
// Template performance & features
'template-no-call-expression': 'error',
- 'template-trackby-required-for-ngfor': 'error',
+ 'template-trackby-required': 'error',
'template-no-object-literal-binding': 'warn',
'template-no-array-literal-binding': 'warn',
'template-no-async-pipe-duplication': 'warn',
diff --git a/packages/rules/src/presets/performance.ts b/packages/rules/src/presets/performance.ts
index 4cf35cd..1473db4 100644
--- a/packages/rules/src/presets/performance.ts
+++ b/packages/rules/src/presets/performance.ts
@@ -17,7 +17,7 @@ export const performancePreset: PresetConfig = {
// Template performance
'template-no-call-expression': 'error',
- 'template-trackby-required-for-ngfor': 'error',
+ 'template-trackby-required': 'error',
'template-no-object-literal-binding': 'warn',
'template-no-array-literal-binding': 'warn',
'template-no-async-pipe-duplication': 'warn',
diff --git a/packages/rules/src/presets/reactivity.ts b/packages/rules/src/presets/reactivity.ts
index 5266771..4be2259 100644
--- a/packages/rules/src/presets/reactivity.ts
+++ b/packages/rules/src/presets/reactivity.ts
@@ -22,7 +22,6 @@ export const reactivityPreset: PresetConfig = {
'signal-no-side-effects-in-computed': 'error',
'signal-prefer-computed-over-sync-effect': 'warn',
'signal-effect-must-be-destroy-scoped': 'error',
- 'signal-no-effect-in-constructor': 'warn',
'signal-avoid-untracked-overuse': 'warn',
},
};
diff --git a/packages/rules/src/presets/recommended.ts b/packages/rules/src/presets/recommended.ts
index ee7cf08..0cd79da 100644
--- a/packages/rules/src/presets/recommended.ts
+++ b/packages/rules/src/presets/recommended.ts
@@ -25,12 +25,11 @@ export const recommendedPreset: PresetConfig = {
'signal-no-side-effects-in-computed': 'error',
'signal-prefer-computed-over-sync-effect': 'warn',
'signal-effect-must-be-destroy-scoped': 'error',
- 'signal-no-effect-in-constructor': 'warn',
'signal-avoid-untracked-overuse': 'warn',
// Template performance
'template-no-call-expression': 'error',
- 'template-trackby-required-for-ngfor': 'error',
+ 'template-trackby-required': 'error',
'template-no-object-literal-binding': 'warn',
'template-no-array-literal-binding': 'warn',
'template-no-async-pipe-duplication': 'warn',
diff --git a/packages/rules/src/presets/strict.ts b/packages/rules/src/presets/strict.ts
index 686b50c..579fa5b 100644
--- a/packages/rules/src/presets/strict.ts
+++ b/packages/rules/src/presets/strict.ts
@@ -28,12 +28,11 @@ export const strictPreset: PresetConfig = {
'signal-no-side-effects-in-computed': 'error',
'signal-prefer-computed-over-sync-effect': 'error',
'signal-effect-must-be-destroy-scoped': 'error',
- 'signal-no-effect-in-constructor': 'error',
'signal-avoid-untracked-overuse': 'error',
// Template performance
'template-no-call-expression': 'error',
- 'template-trackby-required-for-ngfor': 'error',
+ 'template-trackby-required': 'error',
'template-no-object-literal-binding': 'error',
'template-no-array-literal-binding': 'error',
'template-no-async-pipe-duplication': 'error',
diff --git a/packages/rules/src/registry/register-all.ts b/packages/rules/src/registry/register-all.ts
index 550987a..77798d8 100644
--- a/packages/rules/src/registry/register-all.ts
+++ b/packages/rules/src/registry/register-all.ts
@@ -1,87 +1,89 @@
import { registerNewEngineRule } from '../engine/adapter.js';
-// ─── Correctness ─────────────────────────────────────────────────────────────
+// Correctness
import { componentNoManualDetectChangesRule } from '../rules/correctness/component-no-manual-detect-changes.rule.js';
import { signalNoSideEffectsInComputedRule } from '../rules/correctness/signal-no-side-effects-in-computed.rule.js';
import { signalEffectDestroyScopedRule } from '../rules/correctness/signal-effect-must-be-destroy-scoped.rule.js';
import { rxjsNoNestedSubscribeRule } from '../rules/correctness/rxjs-no-nested-subscribe.rule.js';
-// ─── Performance ─────────────────────────────────────────────────────────────
+// Performance
import { preferOnPushRule } from '../rules/performance/prefer-on-push.rule.js';
import { templateNoCallExpressionRule } from '../rules/performance/template-no-call-expression.rule.js';
import { templateTrackByRequiredRule } from '../rules/performance/template-trackby-required.rule.js';
import { templateNoObjectLiteralBindingRule } from '../rules/performance/template-no-object-literal-binding.rule.js';
import { templateNoArrayLiteralBindingRule } from '../rules/performance/template-no-array-literal-binding.rule.js';
-// ─── Security ────────────────────────────────────────────────────────────────
+// Security
import { noBypassSanitizationRule } from '../rules/security/no-bypass-sanitization.rule.js';
import { templateNoUnsafeBindingsRule } from '../rules/security/template-no-unsafe-bindings.rule.js';
-// ─── SSR ─────────────────────────────────────────────────────────────────────
+// SSR
import { noDocumentAccessRule } from '../rules/ssr/no-document-access.rule.js';
import { preferAfterRenderOverAfterViewInitRule } from '../rules/ssr/prefer-after-render-over-after-view-init.rule.js';
-// ─── Reactivity ──────────────────────────────────────────────────────────────
+// Reactivity
import { rxjsNoSubscribeInComponentRule } from '../rules/reactivity/rxjs-no-subscribe-in-component.rule.js';
import { rxjsRequireTakeUntilDestroyedRule } from '../rules/reactivity/rxjs-require-take-until-destroyed.rule.js';
import { rxjsAvoidSubjectRule } from '../rules/reactivity/rxjs-avoid-subject-as-event-bus.rule.js';
import { rxjsPreferToSignalRule } from '../rules/reactivity/rxjs-prefer-to-signal-for-template-state.rule.js';
import { toSignalRequireInitialValueRule } from '../rules/reactivity/to-signal-require-initial-value.rule.js';
import { signalPreferComputedRule } from '../rules/reactivity/signal-prefer-computed-over-sync-effect.rule.js';
+import { signalAvoidUntrackedRule } from '../rules/reactivity/signal-avoid-untracked-overuse.rule.js';
-// ─── Modern API ──────────────────────────────────────────────────────────────
+// Modern API
import { preferInjectRule } from '../rules/modern-api/prefer-inject.rule.js';
import { signalPreferInputSignalRule } from '../rules/modern-api/signal-prefer-input-signal.rule.js';
import { signalPreferOutputFunctionRule } from '../rules/modern-api/signal-prefer-output-function.rule.js';
import { signalPreferModelRule } from '../rules/modern-api/signal-prefer-model.rule.js';
-// ─── Template ────────────────────────────────────────────────────────────────
+// Template
import { templatePreferControlFlowRule } from '../rules/template/template-prefer-control-flow.rule.js';
import { templateNoAsyncPipeDuplicationRule } from '../rules/template/template-no-async-pipe-duplication.rule.js';
-// ─── Testing ─────────────────────────────────────────────────────────────────
+// Testing
import { specNoFocusedTestRule } from '../rules/testing/spec-no-focused-test.rule.js';
export function registerAllBuiltinRules() {
- // Correctness — bugs, memory leaks, lifecycle violations
+ // Correctness: bugs, memory leaks, lifecycle violations
registerNewEngineRule(componentNoManualDetectChangesRule, 'correctness');
registerNewEngineRule(signalNoSideEffectsInComputedRule, 'correctness');
registerNewEngineRule(signalEffectDestroyScopedRule, 'correctness');
registerNewEngineRule(rxjsNoNestedSubscribeRule, 'correctness');
- // Performance — rendering bottlenecks, change-detection overhead
+ // Performance: rendering bottlenecks, change-detection overhead
registerNewEngineRule(preferOnPushRule, 'performance');
registerNewEngineRule(templateNoCallExpressionRule, 'performance');
registerNewEngineRule(templateTrackByRequiredRule, 'performance');
registerNewEngineRule(templateNoObjectLiteralBindingRule, 'performance');
registerNewEngineRule(templateNoArrayLiteralBindingRule, 'performance');
- // Security — XSS, injection, sanitization bypass
+ // Security: XSS, injection, sanitization bypass
registerNewEngineRule(noBypassSanitizationRule, 'security');
registerNewEngineRule(templateNoUnsafeBindingsRule, 'security');
- // SSR — platform safety for Angular Universal / @angular/ssr
+ // SSR: platform safety for Angular Universal / @angular/ssr
registerNewEngineRule(noDocumentAccessRule, 'ssr');
registerNewEngineRule(preferAfterRenderOverAfterViewInitRule, 'ssr');
- // Reactivity — RxJS patterns, signal reactivity, observable lifecycle
+ // Reactivity: RxJS patterns, signal reactivity, observable lifecycle
registerNewEngineRule(rxjsNoSubscribeInComponentRule, 'reactivity');
registerNewEngineRule(rxjsRequireTakeUntilDestroyedRule, 'reactivity');
registerNewEngineRule(rxjsAvoidSubjectRule, 'reactivity');
registerNewEngineRule(rxjsPreferToSignalRule, 'reactivity');
registerNewEngineRule(toSignalRequireInitialValueRule, 'reactivity');
registerNewEngineRule(signalPreferComputedRule, 'reactivity');
+ registerNewEngineRule(signalAvoidUntrackedRule, 'reactivity');
- // Modern API — idiomatic Angular 17+ APIs
+ // Modern API: idiomatic Angular 17+ APIs
registerNewEngineRule(preferInjectRule, 'modern-api');
registerNewEngineRule(signalPreferInputSignalRule, 'modern-api');
registerNewEngineRule(signalPreferOutputFunctionRule, 'modern-api');
registerNewEngineRule(signalPreferModelRule, 'modern-api');
- // Template — structure, syntax, and template patterns
+ // Template: structure, syntax, and template patterns
registerNewEngineRule(templatePreferControlFlowRule, 'template');
registerNewEngineRule(templateNoAsyncPipeDuplicationRule, 'template');
- // Testing — spec quality, CI blind spots
+ // Testing: spec quality, CI blind spots
registerNewEngineRule(specNoFocusedTestRule, 'testing');
}
diff --git a/packages/rules/tests/presets.spec.ts b/packages/rules/tests/presets.spec.ts
new file mode 100644
index 0000000..2044cbc
--- /dev/null
+++ b/packages/rules/tests/presets.spec.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { allPreset } from '../src/presets/all.js';
+import { builtinPresets } from '../src/presets/index.js';
+import { registerAllBuiltinRules } from '../src/registry/register-all.js';
+import { getGlobalRegistry, resetGlobalRegistry } from '../src/registry/rule-registry.js';
+
+describe('builtin presets', () => {
+ it('only references registered builtin rules', () => {
+ resetGlobalRegistry();
+ registerAllBuiltinRules();
+
+ const registeredRules = new Set(getGlobalRegistry().getRuleNames());
+ const unknownRules: string[] = [];
+
+ for (const [presetName, preset] of builtinPresets) {
+ for (const ruleName of Object.keys(preset.rules)) {
+ if (!registeredRules.has(ruleName)) {
+ unknownRules.push(`${presetName}:${ruleName}`);
+ }
+ }
+ }
+
+ expect(unknownRules).toEqual([]);
+ });
+
+ it('keeps the all preset in sync with registered builtin rules', () => {
+ resetGlobalRegistry();
+ registerAllBuiltinRules();
+
+ const allPresetRules = new Set(Object.keys(allPreset.rules));
+ const missingFromAll = getGlobalRegistry()
+ .getRuleNames()
+ .filter((ruleName) => !allPresetRules.has(ruleName));
+
+ expect(missingFromAll).toEqual([]);
+ });
+});
diff --git a/packages/scanner/LICENSE b/packages/scanner/LICENSE
new file mode 100644
index 0000000..c1c4748
--- /dev/null
+++ b/packages/scanner/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/scanner/package.json b/packages/scanner/package.json
index 84131be..d46162c 100644
--- a/packages/scanner/package.json
+++ b/packages/scanner/package.json
@@ -1,12 +1,15 @@
{
"name": "@ngcompass/scanner",
- "version": "0.0.1",
+ "version": "0.1.0-beta",
"description": "File system scanning (glob, git, gitignore) for ngcompass",
"sideEffects": false,
"main": "./dist/index.cjs",
"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",