@codesema/speccov 0.0.0-stage → 0.2.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.
package/src/run.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { Measurement, Rule, Verdict } from './rule';
2
+
3
+ /**
4
+ * Runs each rule and classifies the result.
5
+ *
6
+ * A written rule that is violated is a violation — a bug. A default rule that is violated
7
+ * is a gap — a question, not an accusation: nobody ever wrote that this behaviour was
8
+ * forbidden.
9
+ *
10
+ * And a rule held without ever being exercised is vacuous: it proved nothing.
11
+ */
12
+ export async function run(rules: readonly Rule[]): Promise<readonly Verdict[]> {
13
+ const verdicts: Verdict[] = [];
14
+ for (const rule of rules) {
15
+ const measurements = await rule.measure();
16
+ const { held, detail, magnitude, exercised } = rule.judge(measurements);
17
+
18
+ if (held && !exercised) {
19
+ // Green having seen nothing: the textbook case of vacuity. It cannot sit with
20
+ // the rules held, it held nothing.
21
+ verdicts.push({
22
+ kind: 'vacuous',
23
+ rule,
24
+ measurements,
25
+ magnitude,
26
+ detail,
27
+ question: `${detail}. The rule was never put to the test — what was it meant to check?`,
28
+ });
29
+ } else if (held) {
30
+ verdicts.push(
31
+ atBoundary(rule, measurements)
32
+ ? {
33
+ kind: 'borderline',
34
+ rule,
35
+ measurements,
36
+ magnitude,
37
+ detail,
38
+ question: `${detail}. One notch stricter and the rule would fall — was the threshold chosen, or fitted to what we observed?`,
39
+ }
40
+ : { kind: 'held', rule, measurements, magnitude, detail },
41
+ );
42
+ } else if (rule.origin === 'declared') {
43
+ verdicts.push({ kind: 'infraction', rule, measurements, magnitude, detail });
44
+ } else {
45
+ verdicts.push({
46
+ kind: 'lacuna',
47
+ rule,
48
+ measurements,
49
+ magnitude,
50
+ detail,
51
+ question: `${detail}. No written rule forbids it. Is this intended?`,
52
+ });
53
+ }
54
+ }
55
+ return verdicts;
56
+ }
57
+
58
+ /**
59
+ * Rejudges the same measurements with the rule one notch stricter.
60
+ *
61
+ * Nothing is re-run: `judge()` is pure on its argument, so the probe costs a single call.
62
+ * A rule with no stricter variant has no margin to question.
63
+ */
64
+ function atBoundary(rule: Rule, measurements: readonly Measurement[]): boolean {
65
+ const stricter = rule.tighten?.();
66
+ if (stricter === undefined) {
67
+ return false;
68
+ }
69
+ return !stricter.judge(measurements).held;
70
+ }
@@ -0,0 +1,152 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import type { ShapeKind, Site } from './shapes';
4
+
5
+ /**
6
+ * A human answer to a candidate site, made mechanisable.
7
+ *
8
+ * `scan` is the cheapest entry point, so it is the one that runs in CI and in an agent loop —
9
+ * and until now it was the only one with nowhere to write an answer down. A site ruled out by
10
+ * a human came back identical at the next pass, carrying the same weight as a real N+1. For an
11
+ * agent that is worse than noise: the README's own worst case, "degrading code that was fine",
12
+ * needs exactly this to happen twice.
13
+ *
14
+ * What is remembered is a **shape**, never a line. A site moves down a file every time
15
+ * something is inserted above it, and an answer keyed on a line number would expire on the
16
+ * next commit, which is a way of remembering nothing.
17
+ */
18
+ export type SiteAnswer = {
19
+ /** The file the site sits in, relative to the scanned root. */
20
+ readonly file: string;
21
+ /** Which question was answered: the same code can carry two. */
22
+ readonly kind: ShapeKind;
23
+ /** The code that was ruled on, whitespace normalised. */
24
+ readonly shape: string;
25
+ /** Date of the acceptance. */
26
+ readonly at: string;
27
+ /** Why it is not a defect. Optional, but it is what gets reread in six months. */
28
+ readonly why?: string;
29
+ };
30
+
31
+ export const DEFAULT_SCAN_ANSWERS_PATH = '.speccov/scan-answers.json';
32
+
33
+ type SiteAnswerFile = {
34
+ readonly version: 1;
35
+ readonly sites: readonly SiteAnswer[];
36
+ };
37
+
38
+ /**
39
+ * Loads the answered sites. A missing file is normal — it is the first pass.
40
+ *
41
+ * An unreadable file **throws**, for the same reason `answers.ts` throws: read as empty it
42
+ * makes every pass noisy, read as "all accepted" it makes the pass blind. Both are worse than
43
+ * a halt.
44
+ */
45
+ export async function loadSiteAnswers(path: string): Promise<readonly SiteAnswer[]> {
46
+ let raw: string;
47
+ try {
48
+ raw = await readFile(path, 'utf8');
49
+ } catch (cause) {
50
+ if ((cause as NodeJS.ErrnoException).code === 'ENOENT') {
51
+ return [];
52
+ }
53
+ throw cause;
54
+ }
55
+ return parseSiteAnswers(JSON.parse(raw), path);
56
+ }
57
+
58
+ export async function saveSiteAnswers(path: string, answers: readonly SiteAnswer[]): Promise<void> {
59
+ const sorted = [...answers].sort((left, right) => keyOf(left).localeCompare(keyOf(right)));
60
+ const file: SiteAnswerFile = { version: 1, sites: sorted };
61
+ await mkdir(dirname(path), { recursive: true });
62
+ await writeFile(path, `${JSON.stringify(file, null, 4)}\n`, 'utf8');
63
+ }
64
+
65
+ export function parseSiteAnswers(value: unknown, path: string): readonly SiteAnswer[] {
66
+ const file = value as Partial<SiteAnswerFile> | null;
67
+ if (file === null || typeof file !== 'object' || file.version !== 1 || !Array.isArray(file.sites)) {
68
+ throw new Error(`speccov: ${path} is not a valid scan answers file (expected { version: 1, sites: [] }).`);
69
+ }
70
+ return file.sites.map((answer, index) => parseSiteAnswer(answer, path, index));
71
+ }
72
+
73
+ function parseSiteAnswer(value: unknown, path: string, index: number): SiteAnswer {
74
+ const answer = value as Partial<SiteAnswer> | null;
75
+ const valid =
76
+ answer !== null &&
77
+ typeof answer === 'object' &&
78
+ typeof answer.file === 'string' &&
79
+ typeof answer.kind === 'string' &&
80
+ typeof answer.shape === 'string' &&
81
+ typeof answer.at === 'string';
82
+ if (!valid) {
83
+ throw new Error(`speccov: answered site ${index} is unreadable in ${path}.`);
84
+ }
85
+ const kept: SiteAnswer = { file: answer.file, kind: answer.kind as ShapeKind, shape: answer.shape, at: answer.at };
86
+ return typeof answer.why === 'string' ? { ...kept, why: answer.why } : kept;
87
+ }
88
+
89
+ export type AppliedSites = {
90
+ /** What the report still has to ask about. */
91
+ readonly sites: readonly Site[];
92
+ /** Sites kept quiet because a human already ruled. Counted, never invisible. */
93
+ readonly accepted: readonly Site[];
94
+ };
95
+
96
+ /**
97
+ * Applies the known answers to what this pass found.
98
+ *
99
+ * A site whose shape has not moved goes quiet, and stays counted. A site whose code changed
100
+ * comes back: the answer bore on the code as it read that day, and it is not the same question
101
+ * any more. That is the same refusal as an exceeded threshold in `answers.ts` — an acceptance
102
+ * is a rule the build defends, not a permanent silence.
103
+ */
104
+ export function applySiteAnswers(sites: readonly Site[], answers: readonly SiteAnswer[]): AppliedSites {
105
+ const known = new Set(answers.map(keyOf));
106
+ const kept: Site[] = [];
107
+ const accepted: Site[] = [];
108
+ for (const site of sites) {
109
+ (known.has(keyOf(asAnswer(site))) ? accepted : kept).push(site);
110
+ }
111
+ return { sites: kept, accepted };
112
+ }
113
+
114
+ /** Turns a site into a "this is not a defect" answer, on the code as it reads today. */
115
+ export function acceptSite(site: Site, at: string, why?: string): SiteAnswer {
116
+ const answer = { ...asAnswer(site), at };
117
+ return why === undefined || why.trim() === '' ? answer : { ...answer, why: why.trim() };
118
+ }
119
+
120
+ function asAnswer(site: Site): Omit<SiteAnswer, 'at'> {
121
+ return { file: site.file, kind: site.kind, shape: shapeOf(site.excerpt) };
122
+ }
123
+
124
+ /**
125
+ * What makes two sites the same one.
126
+ *
127
+ * The line is left out on purpose: it moves with every insertion above. What is left is the
128
+ * file, the question asked, and the code itself — which is what a human actually ruled on.
129
+ */
130
+ function keyOf(answer: Omit<SiteAnswer, 'at'>): string {
131
+ return `${answer.file} · ${answer.kind} · ${canonical(answer.shape)}`;
132
+ }
133
+
134
+ /**
135
+ * The code with its layout dropped: reformatting is not a new question.
136
+ *
137
+ * Kept readable on purpose, because the answers file is read in review — the harsher form
138
+ * below is for comparing, never for storing. Note the one thing this cannot absorb: the
139
+ * excerpt is truncated, so a reformat long enough to move the cut asks again. The cautious
140
+ * direction, and the same one as everywhere else.
141
+ */
142
+ export function shapeOf(excerpt: string): string {
143
+ return excerpt.replace(/\s+/gu, ' ').trim();
144
+ }
145
+
146
+ /**
147
+ * What a formatter is allowed to change without it becoming another site: whitespace, and the
148
+ * trailing comma prettier adds when it breaks a call across lines.
149
+ */
150
+ function canonical(shape: string): string {
151
+ return shape.replace(/\s+/gu, '').replace(/,(?=[)\]}])/gu, '');
152
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ /**
5
+ * The nearest file of that name, from `start` upwards.
6
+ *
7
+ * We stop at the first one found, and never above the repository root: a file two levels
8
+ * above the project no longer describes it.
9
+ */
10
+ export function climbFor(name: string, start: string): string | undefined {
11
+ let current = resolve(start);
12
+ for (;;) {
13
+ const candidate = join(current, name);
14
+ if (existsSync(candidate)) {
15
+ return candidate;
16
+ }
17
+ if (existsSync(join(current, '.git'))) {
18
+ return undefined;
19
+ }
20
+ const parent = dirname(current);
21
+ if (parent === current) {
22
+ return undefined;
23
+ }
24
+ current = parent;
25
+ }
26
+ }
@@ -0,0 +1,65 @@
1
+ import ts from 'typescript';
2
+
3
+ export function returnsPromise(call: ts.CallExpression, checker: ts.TypeChecker): boolean {
4
+ const signature = checker.getResolvedSignature(call);
5
+ if (signature === undefined) {
6
+ return false;
7
+ }
8
+ const returned = checker.getReturnTypeOfSignature(signature);
9
+ return checker.typeToString(returned).startsWith('Promise<');
10
+ }
11
+
12
+ /**
13
+ * Does this parameter leave the function through a call that returns a promise?
14
+ *
15
+ * "Exported" was the only definition of a frontier, and in a codebase of ES modules
16
+ * everything is exported: measured on a real monorepo, the question was asked 744 times, six
17
+ * times more than the four others together. A value that reaches an asynchronous call goes
18
+ * somewhere the compiler does not follow, a query, a request, a file. A value that only feeds
19
+ * a computation stays where the types still hold.
20
+ *
21
+ * One hop, and no more: the parameter is read inside the arguments of the call, never traced
22
+ * through a variable or a helper. So this can only take sites away, never invent one. And
23
+ * where there is nothing to read, a declaration without a body, the question stays.
24
+ */
25
+ export function reachesAsyncCall(
26
+ parameter: ts.ParameterDeclaration,
27
+ body: ts.Node | undefined,
28
+ checker: ts.TypeChecker,
29
+ ): boolean {
30
+ const symbol = checker.getSymbolAtLocation(parameter.name);
31
+ if (body === undefined || symbol === undefined) {
32
+ return true;
33
+ }
34
+ let reached = false;
35
+ const visit = (node: ts.Node): void => {
36
+ if (reached) {
37
+ return;
38
+ }
39
+ if (
40
+ ts.isCallExpression(node) &&
41
+ returnsPromise(node, checker) &&
42
+ node.arguments.some((argument) => mentions(argument, symbol, checker))
43
+ ) {
44
+ reached = true;
45
+ return;
46
+ }
47
+ ts.forEachChild(node, visit);
48
+ };
49
+ visit(body);
50
+ return reached;
51
+ }
52
+
53
+ function mentions(node: ts.Node, symbol: ts.Symbol, checker: ts.TypeChecker): boolean {
54
+ if (ts.isIdentifier(node) && symbolOf(node, checker) === symbol) {
55
+ return true;
56
+ }
57
+ return ts.forEachChild(node, (child) => mentions(child, symbol, checker)) ?? false;
58
+ }
59
+
60
+ /** In `{ id }` the identifier names the property: the value it carries is asked for separately. */
61
+ function symbolOf(identifier: ts.Identifier, checker: ts.TypeChecker): ts.Symbol | undefined {
62
+ return ts.isShorthandPropertyAssignment(identifier.parent)
63
+ ? checker.getShorthandAssignmentValueSymbol(identifier.parent)
64
+ : checker.getSymbolAtLocation(identifier);
65
+ }
@@ -0,0 +1,234 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import ts from 'typescript';
4
+
5
+ export type Guarantee = {
6
+ readonly flag: string;
7
+ /** What the flag proves, in one sentence. */
8
+ readonly proves: string;
9
+ readonly enabled: boolean;
10
+ };
11
+
12
+ /**
13
+ * `impliedByStrict` matters: `parseJsonConfigFileContent` keeps `strict: true` as-is and
14
+ * leaves the flags it implies at `undefined`. Read naively, they would be called off.
15
+ */
16
+ const FLAGS: readonly { flag: string; proves: string; impliedByStrict: boolean }[] = [
17
+ { flag: 'strictNullChecks', proves: 'no value is implicitly nullable', impliedByStrict: true },
18
+ { flag: 'noImplicitAny', proves: 'no parameter falls back to the any type', impliedByStrict: true },
19
+ { flag: 'useUnknownInCatchVariables', proves: 'a caught error is never typed any', impliedByStrict: true },
20
+ { flag: 'strictFunctionTypes', proves: 'function types are checked contravariantly', impliedByStrict: true },
21
+ { flag: 'strictPropertyInitialization', proves: 'no class property stays uninitialised', impliedByStrict: true },
22
+ { flag: 'noUncheckedIndexedAccess', proves: 'every indexed access is treated as possibly absent', impliedByStrict: false },
23
+ { flag: 'exactOptionalPropertyTypes', proves: 'an optional property cannot receive undefined', impliedByStrict: false },
24
+ { flag: 'noFallthroughCasesInSwitch', proves: 'no switch case falls through in silence', impliedByStrict: false },
25
+ { flag: 'noImplicitOverride', proves: 'no method overrides a parent by accident', impliedByStrict: false },
26
+ { flag: 'noPropertyAccessFromIndexSignature', proves: 'no dot access on an index signature', impliedByStrict: false },
27
+ ];
28
+
29
+ /**
30
+ * The result of a read. The `unresolved` case exists because an unresolved `extends` makes
31
+ * TypeScript fall back to its defaults **in silence**: concluding "nothing is proven" then
32
+ * fires a broadside of false alarms on a project that is in fact full strict.
33
+ *
34
+ * A tool you trust does not guess. It says it could not.
35
+ */
36
+ /**
37
+ * TS18003, "No inputs were found in config file".
38
+ *
39
+ * The only diagnostic ignored, and by name: it bears on the files to compile, never on
40
+ * the `compilerOptions` we came to read. Every other code stays blocking — an unknown
41
+ * diagnostic must not become a green light.
42
+ */
43
+ const NO_INPUTS_FOUND = 18_003;
44
+
45
+ /**
46
+ * TS6046, "Argument for '--x' option must be: …".
47
+ *
48
+ * A **value** error, not a resolution one: the file was found, the `extends` was followed,
49
+ * the other options are read right. Only the value of one option is unrecognised —
50
+ * typically a `lib` newer than the TypeScript shipped here.
51
+ *
52
+ * The distinction is fundamental. An `extends` that cannot be found makes EVERY option
53
+ * fall back to its default in silence: blocking. An unknown value on an option we do not
54
+ * read touches none of the flags: not blocking — but declared, never swallowed.
55
+ */
56
+ const OPTION_VALUE = 6046;
57
+
58
+ const OPTION_NAMED = /Argument for '--(?<option>[a-zA-Z]+)' option/u;
59
+
60
+ export type Guarantees =
61
+ | {
62
+ readonly kind: 'read';
63
+ readonly flags: readonly Guarantee[];
64
+ /** Options whose value speccov's compiler did not recognise. Declared, never silenced. */
65
+ readonly unrecognised: readonly string[];
66
+ }
67
+ | { readonly kind: 'unresolved'; readonly path: string; readonly reason: string };
68
+
69
+ /**
70
+ * Reads what the compiler already proves, `extends` resolved.
71
+ *
72
+ * These guarantees cost nothing: no rule, no test, no question. Naming them is what
73
+ * makes the rest of the report short.
74
+ */
75
+ export function readGuarantees(tsconfigPath: string): Guarantees {
76
+ return readScope(tsconfigPath, new Set());
77
+ }
78
+
79
+ function readScope(tsconfigPath: string, seen: Set<string>): Guarantees {
80
+ if (seen.has(tsconfigPath)) {
81
+ return { kind: 'unresolved', path: tsconfigPath, reason: 'circular reference between projects' };
82
+ }
83
+ seen.add(tsconfigPath);
84
+
85
+ const { config, error } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
86
+ if (error !== undefined) {
87
+ return { kind: 'unresolved', path: tsconfigPath, reason: flatten(error) };
88
+ }
89
+ const parsed = ts.parseJsonConfigFileContent(config, ts.sys, dirname(tsconfigPath));
90
+ const unrecognised: string[] = [];
91
+ for (const diagnostic of parsed.errors) {
92
+ if (diagnostic.code === NO_INPUTS_FOUND) {
93
+ continue;
94
+ }
95
+ const option = harmless(diagnostic);
96
+ if (option === undefined) {
97
+ return { kind: 'unresolved', path: tsconfigPath, reason: flatten(diagnostic) };
98
+ }
99
+ unrecognised.push(option);
100
+ }
101
+
102
+ const references = parsed.projectReferences ?? [];
103
+ if (references.length > 0 && parsed.fileNames.length === 0) {
104
+ return delegated(tsconfigPath, references, seen, unrecognised);
105
+ }
106
+ return {
107
+ kind: 'read',
108
+ flags: flagsOf(parsed.options, strictByDefault(dirname(tsconfigPath))),
109
+ unrecognised: [...new Set(unrecognised)],
110
+ };
111
+ }
112
+
113
+ /**
114
+ * TypeScript 6 turned `strict` on by default. The default that counts is the one of the
115
+ * compiler the project runs, not the one shipped here: read with ours, a TS 6 project that
116
+ * never writes `strict` was called lukewarm in every scope.
117
+ */
118
+ function strictByDefault(directory: string): boolean {
119
+ return Number(projectTypeScriptVersion(directory).split('.')[0]) >= 6;
120
+ }
121
+
122
+ /** The version of the TypeScript the project resolves, or ours when it resolves none. */
123
+ function projectTypeScriptVersion(directory: string): string {
124
+ try {
125
+ const manifest: unknown = createRequire(join(resolve(directory), 'tsconfig.json'))('typescript/package.json');
126
+ const version = (manifest as { version?: unknown }).version;
127
+ return typeof version === 'string' ? version : ts.version;
128
+ } catch {
129
+ // TODO(ceiling): no TypeScript resolvable from the project, ours stands in and can call a TS 6 project lukewarm: declare it in "Not measured".
130
+ return ts.version;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The first diagnostic that forbids concluding, or `undefined` if all of them are harmless.
136
+ *
137
+ * Shared with building the typed program: two parallel disciplines would have drifted, and
138
+ * that is exactly what happened — type checking accepted a broken `extends` that the flag
139
+ * read refused, then announced its types as sound.
140
+ */
141
+ export function firstBlocking(errors: readonly ts.Diagnostic[]): ts.Diagnostic | undefined {
142
+ return errors.find((diagnostic) => diagnostic.code !== NO_INPUTS_FOUND && harmless(diagnostic) === undefined);
143
+ }
144
+
145
+ /**
146
+ * The option name, if the diagnostic cannot skew what we read. `undefined` otherwise —
147
+ * and `undefined` stays blocking: a diagnostic we cannot classify is not a green light.
148
+ */
149
+ function harmless(diagnostic: ts.Diagnostic): string | undefined {
150
+ if (diagnostic.code !== OPTION_VALUE) {
151
+ return undefined;
152
+ }
153
+ const option = OPTION_NAMED.exec(flatten(diagnostic))?.groups?.['option'];
154
+ // The second term cannot fire today — TS6046 only bears on options with enumerated
155
+ // values, and the ten flags are booleans. It stays on principle: the day the table
156
+ // takes something else, it would be a green light granted on a flag.
157
+ if (option === undefined || FLAGS.some(({ flag }) => flag === option)) {
158
+ return undefined;
159
+ }
160
+ return option;
161
+ }
162
+
163
+ /**
164
+ * A "solution" tsconfig compiles nothing itself: `files: []` plus `references`. It declares
165
+ * no option, so reading it naively would call everything off — while the referenced
166
+ * projects can be full strict. That is the case of every Nuxt project.
167
+ *
168
+ * So we descend into the references. A guarantee holds for the scope only if it holds in
169
+ * **each** delegated project: a single lukewarm link takes it away.
170
+ *
171
+ * An unreadable reference makes the whole scope unreadable. The opposite — ignoring it and
172
+ * concluding on the others — would recreate the silent hole this type exists to close.
173
+ */
174
+ function delegated(
175
+ tsconfigPath: string,
176
+ references: readonly ts.ProjectReference[],
177
+ seen: Set<string>,
178
+ inherited: readonly string[],
179
+ ): Guarantees {
180
+ const parts: Extract<Guarantees, { kind: 'read' }>[] = [];
181
+ for (const reference of references) {
182
+ const resolved = resolveReference(reference.path);
183
+ if (resolved === undefined) {
184
+ return {
185
+ kind: 'unresolved',
186
+ path: tsconfigPath,
187
+ reason: `referenced project ${reference.path} was not found`,
188
+ };
189
+ }
190
+ const part = readScope(resolved, seen);
191
+ if (part.kind === 'unresolved') {
192
+ return { kind: 'unresolved', path: tsconfigPath, reason: `${part.path}: ${part.reason}` };
193
+ }
194
+ parts.push(part);
195
+ }
196
+
197
+ // `parts` holds successful reads only: every other outcome has returned above.
198
+ const [first, ...rest] = parts;
199
+ if (first === undefined) {
200
+ return { kind: 'unresolved', path: tsconfigPath, reason: 'no referenced project could be read' };
201
+ }
202
+ return {
203
+ kind: 'read',
204
+ unrecognised: [...new Set([...inherited, ...parts.flatMap((part) => part.unrecognised)])],
205
+ flags: first.flags.map((guarantee, index) => ({
206
+ ...guarantee,
207
+ enabled: guarantee.enabled && rest.every((part) => part.flags[index]?.enabled === true),
208
+ })),
209
+ };
210
+ }
211
+
212
+ /** A reference points either at the tsconfig, or at the directory holding it. */
213
+ function resolveReference(path: string): string | undefined {
214
+ if (ts.sys.directoryExists(path)) {
215
+ const inside = join(path, 'tsconfig.json');
216
+ return ts.sys.fileExists(inside) ? inside : undefined;
217
+ }
218
+ return ts.sys.fileExists(path) ? path : undefined;
219
+ }
220
+
221
+ function flagsOf(options: ts.CompilerOptions, strictDefault: boolean): readonly Guarantee[] {
222
+ const strict = options.strict ?? strictDefault;
223
+ const read = (flag: string): unknown => (options as Record<string, unknown>)[flag];
224
+
225
+ return FLAGS.map(({ flag, proves, impliedByStrict }) => ({
226
+ flag,
227
+ proves,
228
+ enabled: read(flag) === true || (impliedByStrict && strict && read(flag) !== false),
229
+ }));
230
+ }
231
+
232
+ function flatten(diagnostic: ts.Diagnostic): string {
233
+ return ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
234
+ }
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Filters out the paths the repository itself sets aside.
5
+ *
6
+ * A `.gitignore` already says what is not the project's code: generated directories,
7
+ * working copies, artefacts. Without it, a scan trips over a worktree dropped inside the
8
+ * repository and counts the same code twice — then reports it as two distinct scopes.
9
+ *
10
+ * Nothing is reimplemented: `git check-ignore` answers, with git's exact rules, index
11
+ * included — a tracked file is never called ignored, even if a pattern would target it.
12
+ * Outside a git repository there is no answer to be had: we keep everything.
13
+ */
14
+ export async function dropIgnored(root: string, paths: readonly string[]): Promise<readonly string[]> {
15
+ if (paths.length === 0) {
16
+ return paths;
17
+ }
18
+ const ignored = askGit(root, paths);
19
+ return ignored === undefined ? paths : paths.filter((path) => !ignored.has(path));
20
+ }
21
+
22
+ const NUL = '\0';
23
+
24
+ function askGit(root: string, paths: readonly string[]): Set<string> | undefined {
25
+ const { status, stdout } = spawnSync('git', ['-C', root, 'check-ignore', '--stdin', '-z'], {
26
+ input: paths.join(NUL) + NUL,
27
+ encoding: 'utf8',
28
+ stdio: ['pipe', 'pipe', 'ignore'],
29
+ // The answer grows with the repository: the default megabyte would cut it short.
30
+ maxBuffer: Infinity,
31
+ });
32
+
33
+ // 0: at least one path ignored. 1: none. Everything else (128 outside a repository, no
34
+ // status at all without git) is a refusal to answer, and a refusal to answer must not
35
+ // read as "nothing is ignored".
36
+ if (status !== 0 && status !== 1) {
37
+ return undefined;
38
+ }
39
+ return new Set(stdout.split(NUL).filter((line) => line !== ''));
40
+ }