@sfqa/scar 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import type { Finding, Severity, SeverityCounts } from './types.js';
2
+ export declare const SEVERITY_ORDER: Record<Severity, number>;
3
+ export interface Report {
4
+ tool: 'sfqa-scar';
5
+ ruleSet: string;
6
+ filesScanned: number;
7
+ summary: SeverityCounts;
8
+ findings: Finding[];
9
+ }
10
+ export declare function summarise(findings: readonly Finding[]): SeverityCounts;
11
+ export declare function buildReport(ruleSet: string, filesScanned: number, findings: readonly Finding[]): Report;
12
+ /** Lowest severity that fails the run. `any` means even `low` fails. */
13
+ export type FailOn = Severity | 'any';
14
+ export declare function shouldFail(findings: readonly Finding[], failOn?: FailOn): boolean;
15
+ export declare function formatText(report: Report, cwd: string): string;
16
+ export declare function formatJson(report: Report): string;
17
+ /**
18
+ * Markdown for a pull-request comment or a write-up. Paths are relative to the
19
+ * directory the scan ran in, so the output can be pasted or screenshotted
20
+ * without leaking a home directory. The table is the scan; the fix list is what
21
+ * a reader acts on.
22
+ */
23
+ export declare function formatMarkdown(report: Report): string;
package/dist/report.js ADDED
@@ -0,0 +1,137 @@
1
+ // packages/scar/src/report.ts
2
+ // -----------------------------------------------------------------------------
3
+ // Reporting. Every finding is explainable: the rule that fired, the matching
4
+ // line, the class of incident that produced the rule, and a concrete fix.
5
+ //
6
+ // Three formats, one source of truth: text for a human, JSON for CI or a PR
7
+ // comment, Markdown for a pull request or a write-up. None drops a finding, and
8
+ // none prints an absolute path except the text format - a screenshot or a pasted
9
+ // block should never carry someone's home directory.
10
+ // -----------------------------------------------------------------------------
11
+ export const SEVERITY_ORDER = { high: 3, med: 2, low: 1 };
12
+ export function summarise(findings) {
13
+ const counts = { high: 0, med: 0, low: 0 };
14
+ for (const finding of findings)
15
+ counts[finding.rule.severity]++;
16
+ return counts;
17
+ }
18
+ export function buildReport(ruleSet, filesScanned, findings) {
19
+ return {
20
+ tool: 'sfqa-scar',
21
+ ruleSet,
22
+ filesScanned,
23
+ summary: summarise(findings),
24
+ findings: [...findings],
25
+ };
26
+ }
27
+ export function shouldFail(findings, failOn = 'high') {
28
+ const threshold = failOn === 'any' ? 'low' : failOn;
29
+ return findings.some((finding) => SEVERITY_ORDER[finding.rule.severity] >= SEVERITY_ORDER[threshold]);
30
+ }
31
+ function originText(finding) {
32
+ const origin = finding.rule.origin;
33
+ const reference = origin.commit
34
+ ? `${origin.commit}${origin.pr ? ` (${origin.pr})` : ''}`
35
+ : (origin.pr ?? 'incident');
36
+ return `${reference}\n ${origin.summary}`;
37
+ }
38
+ export function formatText(report, cwd) {
39
+ const lines = [];
40
+ lines.push(`sfqa-scar - ${report.filesScanned} file(s) reviewed`);
41
+ lines.push(`rule set: ${report.ruleSet}`);
42
+ const ordered = orderedFindings(report);
43
+ for (const finding of ordered) {
44
+ const rule = finding.rule;
45
+ lines.push('');
46
+ lines.push(`[${rule.severity.toUpperCase()}] ${rule.id} - ${rule.rule}`);
47
+ if (rule.severity_note)
48
+ lines.push(`note: ${rule.severity_note}`);
49
+ lines.push(` where : ${cwd}/${finding.relPath}${finding.line ? `:${finding.line}` : ''}`);
50
+ lines.push(` matched: ${finding.snippet}${finding.meta ? `\n (${finding.meta})` : ''}`);
51
+ lines.push(` context: ${rule.context}`);
52
+ lines.push(` origin : ${originText(finding)}`);
53
+ lines.push(` do this: ${rule.recommendation}`);
54
+ }
55
+ const { high, med, low } = report.summary;
56
+ lines.push('');
57
+ lines.push(`summary: ${report.findings.length} finding(s) - ${high} high, ${med} med, ${low} low across ${report.filesScanned} file(s)`);
58
+ return lines.join('\n');
59
+ }
60
+ export function formatJson(report) {
61
+ return JSON.stringify({
62
+ tool: report.tool,
63
+ ruleSet: report.ruleSet,
64
+ filesScanned: report.filesScanned,
65
+ summary: report.summary,
66
+ findings: report.findings.map((finding) => ({
67
+ id: finding.rule.id,
68
+ severity: finding.rule.severity,
69
+ rule: finding.rule.rule,
70
+ file: finding.relPath,
71
+ line: finding.line || null,
72
+ matched: finding.snippet,
73
+ detail: finding.meta ?? null,
74
+ originCommit: finding.rule.origin.commit ?? null,
75
+ why: finding.rule.origin.summary,
76
+ recommendation: finding.rule.recommendation,
77
+ })),
78
+ }, null, 2);
79
+ }
80
+ /** Order findings worst-first, then by path and line. Shared by the formatters. */
81
+ function orderedFindings(report) {
82
+ return [...report.findings].sort((a, b) => SEVERITY_ORDER[b.rule.severity] - SEVERITY_ORDER[a.rule.severity] ||
83
+ a.relPath.localeCompare(b.relPath) ||
84
+ a.line - b.line);
85
+ }
86
+ /**
87
+ * One table cell has to stay one line: newlines would end the row, and an
88
+ * unescaped pipe would split it into a new column.
89
+ */
90
+ function cell(text, limit = 120) {
91
+ const flat = text.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
92
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
93
+ }
94
+ /**
95
+ * Markdown for a pull-request comment or a write-up. Paths are relative to the
96
+ * directory the scan ran in, so the output can be pasted or screenshotted
97
+ * without leaking a home directory. The table is the scan; the fix list is what
98
+ * a reader acts on.
99
+ */
100
+ export function formatMarkdown(report) {
101
+ const lines = [];
102
+ const { high, med, low } = report.summary;
103
+ const ordered = orderedFindings(report);
104
+ lines.push('## sfqa-scar');
105
+ lines.push('');
106
+ if (ordered.length === 0) {
107
+ lines.push(`**Clean** - 0 findings across ${report.filesScanned} file(s), at every severity.`);
108
+ return lines.join('\n');
109
+ }
110
+ lines.push(`**${ordered.length} finding(s)** across ${report.filesScanned} file(s) - ${high} high, ${med} med, ${low} low.`);
111
+ lines.push('');
112
+ lines.push('| Rule | Severity | Where | Matched |');
113
+ lines.push('| --- | --- | --- | --- |');
114
+ for (const finding of ordered) {
115
+ const where = `${finding.relPath}${finding.line ? `:${finding.line}` : ''}`;
116
+ const matched = finding.meta ? `${finding.snippet} (${finding.meta})` : finding.snippet;
117
+ lines.push(`| ${finding.rule.id} | ${finding.rule.severity} | \`${cell(where, 80)}\` | \`${cell(matched)}\` |`);
118
+ }
119
+ const seen = new Set();
120
+ const fixes = ordered.filter((finding) => {
121
+ if (seen.has(finding.rule.id))
122
+ return false;
123
+ seen.add(finding.rule.id);
124
+ return true;
125
+ });
126
+ if (fixes.length > 0) {
127
+ lines.push('');
128
+ lines.push('### Fixes');
129
+ for (const finding of fixes) {
130
+ lines.push('');
131
+ lines.push(`**${finding.rule.id}** - ${cell(finding.rule.rule, 400)}`);
132
+ lines.push('');
133
+ lines.push(`> ${finding.rule.recommendation.replace(/\s+/g, ' ').trim()}`);
134
+ }
135
+ }
136
+ return lines.join('\n');
137
+ }
@@ -0,0 +1,38 @@
1
+ import type { FileNameRule, LabelRule, NumericRule, RuleSet, ScarRule, Severity } from './types.js';
2
+ export declare const SEVERITIES: readonly Severity[];
3
+ export declare class RuleSetError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /**
7
+ * Convert a small glob (`**`, `*`, `?`) to an anchored RegExp. Deliberately
8
+ * tiny: a rule set should not need a dependency just to scope a file path.
9
+ */
10
+ export declare function globToRegex(glob: string): RegExp;
11
+ /**
12
+ * JS RegExp has no inline `(?i)`. Convention: a leading `(?i)` in a rule
13
+ * pattern becomes the case-insensitive flag, keeping the JSON readable and
14
+ * portable across engines.
15
+ */
16
+ export declare function jsRegex(pattern: string, extraFlags?: string): RegExp;
17
+ export interface CompiledRule extends ScarRule {
18
+ _include: RegExp[];
19
+ _exclude: RegExp[];
20
+ _line: RegExp[];
21
+ _numeric: Array<NumericRule & {
22
+ re: RegExp;
23
+ }>;
24
+ _fileName: Array<FileNameRule & {
25
+ re: RegExp;
26
+ }>;
27
+ _labels: LabelRule[];
28
+ _required: RegExp[];
29
+ _exempt: RegExp[];
30
+ _exemptLines: RegExp[];
31
+ }
32
+ export declare function compileRule(rule: ScarRule): CompiledRule;
33
+ /** True when the rule has at least one machine detector. */
34
+ export declare function isExecutable(rule: ScarRule): boolean;
35
+ /** Validate a rule set before any file is scanned. */
36
+ export declare function validateRuleSet(raw: unknown): RuleSet;
37
+ export declare function loadRuleSet(path: string): RuleSet;
38
+ export declare function compileRuleSet(ruleSet: RuleSet): CompiledRule[];
package/dist/rules.js ADDED
@@ -0,0 +1,166 @@
1
+ // packages/scar/src/rules.ts
2
+ // -----------------------------------------------------------------------------
3
+ // Rule-set loading and compilation. Every detector is compiled once, up front,
4
+ // so a malformed regex fails loudly at load time instead of silently never
5
+ // matching a file.
6
+ // -----------------------------------------------------------------------------
7
+ import { readFileSync } from 'node:fs';
8
+ export const SEVERITIES = ['high', 'med', 'low'];
9
+ export class RuleSetError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'RuleSetError';
13
+ }
14
+ }
15
+ /**
16
+ * Convert a small glob (`**`, `*`, `?`) to an anchored RegExp. Deliberately
17
+ * tiny: a rule set should not need a dependency just to scope a file path.
18
+ */
19
+ export function globToRegex(glob) {
20
+ let source = '';
21
+ for (let i = 0; i < glob.length; i++) {
22
+ const char = glob[i];
23
+ if (char === '*') {
24
+ if (glob[i + 1] === '*') {
25
+ if (glob[i + 2] === '/') {
26
+ source += '(?:[^/]*/)*';
27
+ i += 2;
28
+ }
29
+ else {
30
+ source += '.*';
31
+ i += 1;
32
+ }
33
+ }
34
+ else {
35
+ source += '[^/]*';
36
+ }
37
+ }
38
+ else if (char === '?') {
39
+ source += '[^/]';
40
+ }
41
+ else {
42
+ source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
43
+ }
44
+ }
45
+ return new RegExp(`^${source}$`);
46
+ }
47
+ /**
48
+ * JS RegExp has no inline `(?i)`. Convention: a leading `(?i)` in a rule
49
+ * pattern becomes the case-insensitive flag, keeping the JSON readable and
50
+ * portable across engines.
51
+ */
52
+ export function jsRegex(pattern, extraFlags = '') {
53
+ let flags = extraFlags;
54
+ let source = pattern;
55
+ if (source.startsWith('(?i)')) {
56
+ flags += 'i';
57
+ source = source.slice(4);
58
+ }
59
+ return new RegExp(source, flags);
60
+ }
61
+ function compilePatterns(patterns = [], flags = '') {
62
+ return patterns.map((pattern) => {
63
+ try {
64
+ return jsRegex(pattern, flags);
65
+ }
66
+ catch (error) {
67
+ throw new RuleSetError(`invalid pattern ${JSON.stringify(pattern)}: ${error instanceof Error ? error.message : String(error)}`);
68
+ }
69
+ });
70
+ }
71
+ export function compileRule(rule) {
72
+ const match = rule.match ?? {};
73
+ return {
74
+ ...rule,
75
+ _include: (match.pathInclude ?? []).map(globToRegex),
76
+ _exclude: (match.pathExclude ?? []).map(globToRegex),
77
+ _line: compilePatterns(match.linePatterns),
78
+ _numeric: (match.numericRules ?? []).map((numeric) => ({
79
+ ...numeric,
80
+ re: jsRegex(numeric.pattern, numeric.flags ?? 'gm'),
81
+ })),
82
+ _fileName: (match.fileNameRules ?? []).map((file) => ({
83
+ ...file,
84
+ re: jsRegex(file.pattern),
85
+ })),
86
+ _labels: match.labelRules ?? [],
87
+ _required: compilePatterns(match.fileRequiredSignals, 'm'),
88
+ _exempt: compilePatterns(match.fileExemptSignals, 'm'),
89
+ _exemptLines: compilePatterns(match.fileExemptLines, 'm'),
90
+ };
91
+ }
92
+ /** True when the rule has at least one machine detector. */
93
+ export function isExecutable(rule) {
94
+ const match = rule.match ?? {};
95
+ return ((match.linePatterns ?? []).length > 0 ||
96
+ (match.numericRules ?? []).length > 0 ||
97
+ (match.fileNameRules ?? []).length > 0 ||
98
+ (match.fileRequiredSignals ?? []).length > 0 ||
99
+ (match.labelRules ?? []).length > 0);
100
+ }
101
+ /** Validate a rule set before any file is scanned. */
102
+ export function validateRuleSet(raw) {
103
+ if (!raw || typeof raw !== 'object') {
104
+ throw new RuleSetError('rule set must be a JSON object');
105
+ }
106
+ const candidate = raw;
107
+ if (!Array.isArray(candidate.rules) || candidate.rules.length === 0) {
108
+ throw new RuleSetError('rule set has no rules');
109
+ }
110
+ const ids = new Set();
111
+ for (const rule of candidate.rules) {
112
+ const where = rule.id ? `rule ${rule.id}` : 'rule without an id';
113
+ if (!rule.id || !/^[A-Z]+-\d+$/.test(rule.id)) {
114
+ throw new RuleSetError(`${where}: id must look like "XX-001"`);
115
+ }
116
+ if (ids.has(rule.id)) {
117
+ throw new RuleSetError(`duplicate rule id ${rule.id}`);
118
+ }
119
+ ids.add(rule.id);
120
+ if (!rule.rule?.trim())
121
+ throw new RuleSetError(`${where}: missing rule text`);
122
+ if (!rule.context?.trim())
123
+ throw new RuleSetError(`${where}: missing context`);
124
+ if (!rule.recommendation?.trim())
125
+ throw new RuleSetError(`${where}: missing recommendation`);
126
+ if (!SEVERITIES.includes(rule.severity)) {
127
+ throw new RuleSetError(`${where}: severity must be one of ${SEVERITIES.join(', ')}`);
128
+ }
129
+ if (!rule.origin?.summary?.trim()) {
130
+ throw new RuleSetError(`${where}: origin.summary is required`);
131
+ }
132
+ const match = rule.match ?? {};
133
+ const hasScope = (match.pathInclude ?? []).length > 0;
134
+ for (const labelRule of match.labelRules ?? []) {
135
+ if (!Array.isArray(labelRule.canonical) || labelRule.canonical.length === 0) {
136
+ throw new RuleSetError(`${where}: labelRules.canonical must be a non-empty array`);
137
+ }
138
+ }
139
+ if (!isExecutable(rule) && !hasScope) {
140
+ throw new RuleSetError(`${where}: rule has neither a detector nor a path scope`);
141
+ }
142
+ // Compile now: a bad regex must fail at load, not at scan time.
143
+ compileRule(rule);
144
+ }
145
+ return candidate;
146
+ }
147
+ export function loadRuleSet(path) {
148
+ let text;
149
+ try {
150
+ text = readFileSync(path, 'utf8');
151
+ }
152
+ catch (error) {
153
+ throw new RuleSetError(`cannot read rule set ${path}: ${error instanceof Error ? error.message : String(error)}`);
154
+ }
155
+ let raw;
156
+ try {
157
+ raw = JSON.parse(text);
158
+ }
159
+ catch (error) {
160
+ throw new RuleSetError(`rule set ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
161
+ }
162
+ return validateRuleSet(raw);
163
+ }
164
+ export function compileRuleSet(ruleSet) {
165
+ return ruleSet.rules.map(compileRule);
166
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { CompiledRule } from './rules.js';
2
+ import type { Finding } from './types.js';
3
+ export declare const SKIP_DIRS: Set<string>;
4
+ /** Normalise to forward slashes so globs behave the same on every platform. */
5
+ export declare function toPosix(path: string): string;
6
+ export declare function relPosix(root: string, path: string): string;
7
+ export declare function walk(dir: string, output?: string[]): string[];
8
+ /** Expand CLI path arguments into absolute file paths. */
9
+ export declare function collectFiles(targets: readonly string[], cwd: string): string[];
10
+ export declare function gitChangedFiles(cwd: string, baseRef?: string | null, staged?: boolean): string[];
11
+ /** True when the path is covered by .gitignore (exit 0 from git check-ignore). */
12
+ export declare function isGitIgnored(cwd: string, relPath: string): boolean;
13
+ /**
14
+ * The same answer for many paths in one process.
15
+ *
16
+ * `isGitIgnored` spawns git per path. On a 1,400-file repository that is the
17
+ * entire runtime of a scan; batching takes it from ~30s to well under a second.
18
+ * Paths are fed NUL-separated so a space or a newline in a filename cannot
19
+ * split the input.
20
+ */
21
+ export declare function gitIgnoredSet(cwd: string, relPaths: readonly string[]): Set<string>;
22
+ export interface LintOptions {
23
+ maxPerRule?: number;
24
+ /** Findings in gitignored files are informational (downgraded to low). */
25
+ ignored?: boolean;
26
+ }
27
+ export declare function lintContent(relPath: string, content: string, rules: readonly CompiledRule[], options?: LintOptions): Finding[];
28
+ export interface ScanResult {
29
+ filesScanned: number;
30
+ findings: Finding[];
31
+ }
32
+ export interface ScanOptions {
33
+ maxPerRule?: number;
34
+ /** Injectable for tests; defaults to `git check-ignore`. */
35
+ isIgnored?: (relPath: string) => boolean;
36
+ }
37
+ /** Read and lint every file, skipping binaries, huge files and ignored dirs. */
38
+ export declare function scanFiles(cwd: string, files: readonly string[], rules: readonly CompiledRule[], options?: ScanOptions): ScanResult;