Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,33 @@ jobs:
fi
done
echo "OK: WinForms modeless Form.Show() = call-site release (framework-owned); conditional show leaks on the no-show path; modal ShowDialog() leak caught; disposed modal silent"
# Project-file input (the CLI-first project/solution resolution borrowed from the
# roslyn-tools tooling shape): point the extractor at a .csproj instead of a file
# list and assert the same event leak surfaces. Proves ProjectCsFiles resolves the
# SDK-style project to its source set and feeds the core identically to the per-file
# path. Both the positional and the `--project` flag forms are exercised.
- name: Project-file input (.csproj -> source set -> core)
run: |
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
-o "$RUNNER_TEMP/proj-facts.json"
out=$(python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" || true)
echo "$out"
echo "$out" | grep -q "CustomerSubscription.cs" \
|| { echo "FAIL: .csproj input did not resolve CustomerSubscription.cs"; exit 1; }
echo "$out" | grep -qE "CustomerSubscription\.cs:[0-9]+:.*\[OWN001\]" \
|| { echo "FAIL: expected OWN001 via .csproj input"; exit 1; }
# the `--project` flag form must resolve to the same source set as the positional form.
# Assert parity at the FACT boundary (canonicalized OwnIR), not after the Python core —
# diffing rendered diagnostics could pass even if the two paths emit different facts the
# core happens to collapse to the same warnings (CodeRabbit).
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
--project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
-o "$RUNNER_TEMP/proj-facts-flag.json"
diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \
<(jq -S . "$RUNNER_TEMP/proj-facts-flag.json") \
|| { echo "FAIL: --project flag and positional .csproj emit different OwnIR facts"; exit 1; }
echo "OK: .csproj input resolves to its source set and feeds the core identically (positional == --project, fact-level parity)"

# The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
# directory of real C# and prints findings in the host-parseable formats the
Expand Down
36 changes: 29 additions & 7 deletions docs/notes/roslyn-tools-and-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,42 @@ frontend (generics / async / interprocedural dataflow) is explicitly rejected as
It must **not** decide ownership, join states at merges, reason about borrows,
or otherwise produce an alternative truth. One checker; the C# side feeds it.

## Next PR (concrete, no scope creep)
## Project/solution input (landed)

`OwnSharp.Extractor` CLI: turn a `.csproj` into `facts.ownir.json`, then firm up
the contract and a golden until it's presentable.
The first borrowed plyushka is real: the extractor now accepts a `.csproj` or
`.sln` as input (positional or via `--project` / `--solution`), not just a file
list — the CLI-first project resolution the mature tooling repos start from.

```bash
dotnet run --project frontend/roslyn/OwnSharp.Extractor \
--project samples/WpfLeakSample/WpfLeakSample.csproj \
--out artifacts/facts.ownir.json
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
-o artifacts/facts.ownir.json
python -m ownlang ownir artifacts/facts.ownir.json
# -> CustomerViewModel.cs:9: error: [OWN001] event 'bus.CustomerChanged'
# -> CustomerSubscription.cs:11: warning: [OWN001] event 'bus.CustomerChanged'
# is subscribed (handler 'OnCustomerChanged') but never unsubscribed
```

Resolution is **dependency-free** (text/XML, no MSBuild evaluation): a `.csproj`
maps to its directory's `*.cs` the SDK default-compile-items way plus concrete
linked `<Compile>` files; a `.sln` fans out over its member projects (see
`ProjectCsFiles` / `SolutionProjects` in `Program.cs`). It deliberately stops
short of the full project/package/reference graph — that is the
`ProjectDependencies`-category work, parked for DI/solution scans. A
`wpf-extractor` CI step pins the `.csproj` path to a golden (positional ==
`--project`).

## Next PR (concrete, no scope creep)

Firm up the rest of the advertised CLI: `extract` / `check` / `explain`
subcommands (`System.CommandLine`), and `--ref-dir`-from-project-`bin`
auto-derivation once `ProjectDependencies`-style graph reading lands.

```bash
ownsharp extract --project App.csproj --out facts.ownir.json
ownsharp check --solution App.sln
ownsharp explain OWN001 --json diagnostic.json
```

A facts record carries enough to place and explain the finding — kind, resource,
owner, subject, location, and domain-neutral metadata:

Expand Down
229 changes: 219 additions & 10 deletions frontend/roslyn/OwnSharp.Extractor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@
// `.Stop()` call. The IDisposable/pool/local detectors remain syntactic for now
// (P-014 rollout: the event fact goes type-aware first).
//
// Usage: ownsharp-extract <file.cs | dir> [more ...] [-o facts.json]
// Usage: ownsharp-extract <file.cs | dir | *.csproj | *.sln> [more ...] [-o facts.json]
// ownsharp-extract --project App.csproj (flag twin of the positional form)
// ownsharp-extract --solution App.sln
//
// Inputs may be .cs files or directories. A directory is walked recursively for
// *.cs, skipping build output (bin/obj), VCS/vendor dirs (.git, node_modules)
// and generated files (*.g.cs, *.Designer.cs) — so you can point it at a whole
// repo (this is what the `own-check` script / GitHub Action do).

// Inputs may be .cs files, directories, a .csproj, or a .sln. A directory is walked
// recursively for *.cs, skipping build output (bin/obj), VCS/vendor dirs (.git,
// node_modules) and generated files (*.g.cs, *.Designer.cs) — so you can point it at a
// whole repo (this is what the `own-check` script / GitHub Action do). A .csproj resolves
// to its source set (SDK-style directory scan + concrete linked <Compile> files; no full
// MSBuild evaluation — see ProjectCsFiles); a .sln fans out over its member projects.

using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
Expand Down Expand Up @@ -67,6 +73,11 @@
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i];
// `--project <App.csproj>` / `--solution <App.sln>`: the explicit-flag twin of passing the
// project/solution as a positional input (both resolve through Expand). The flag form matches
// the advertised `ownsharp extract --project ...` UX borrowed from the roslyn-tools CLI shape;
// the positional form keeps the command unambiguous next to dotnet's own `run --project`.
else if ((args[i] == "--project" || args[i] == "--solution") && i + 1 < args.Length) rawInputs.Add(args[++i]);
else if (args[i] == "--ref-dir" && i + 1 < args.Length) refDirs.Add(args[++i]);
else if (args[i] == "--no-event-leaks") emitEvents = false;
else if (args[i] == "--flow-locals") flowLocals = true;
Expand All @@ -77,7 +88,7 @@

if (rawInputs.Count == 0)
{
Console.Error.WriteLine("usage: ownsharp-extract <file.cs | dir> [...] [-o facts.json] [--ref-dir <bin-dir>]");
Console.Error.WriteLine("usage: ownsharp-extract <file.cs | dir | *.csproj | *.sln> [...] [-o facts.json] [--ref-dir <bin-dir>]");
return 2;
}

Expand Down Expand Up @@ -113,9 +124,196 @@
return IsGenerated(path);
}

// Expand directories into their .cs files; pass explicit files through as-is.
// IgnoreInaccessible tolerates an unreadable subdir mid-walk (otherwise the
// whole scan would abort with an unhandled exception on a locked directory).
// Translate an MSBuild item spec (a path, optionally with `*`, `?`, `**` globs and either
// separator) into a predicate over a full file path, evaluated RELATIVE to the project dir.
// Enough for the common `<Compile Include>` / `<Compile Remove>` forms (a concrete path, `*.cs`,
// `**/*.cs`, `Folder/**`) without a full MSBuild glob engine: `**` matches across directories,
// `*` / `?` within a single path segment. Match is case-insensitive (MSBuild globbing is).
static Func<string, bool> SpecMatcher(string spec, string dir)
{
var glob = spec.Replace('\\', '/').Trim();
var rx = new StringBuilder("^");
for (int i = 0; i < glob.Length; i++)
{
var c = glob[i];
if (c == '*')
{
if (i + 1 < glob.Length && glob[i + 1] == '*') // `**` -> any chars, including '/'
{
rx.Append(".*");
i++;
if (i + 1 < glob.Length && glob[i + 1] == '/') i++; // swallow the slash after `**`
}
else rx.Append("[^/]*"); // `*` -> within one path segment
}
else if (c == '?') rx.Append("[^/]");
else rx.Append(Regex.Escape(c.ToString()));
}
rx.Append('$');
// `.csproj` content is untrusted in CI, and adjacent wildcards (`**/**`, `**/*`) translate to
// ambiguous adjacent quantifiers the backtracking .NET engine can blow up on (ReDoS). Bound the
// match with a timeout, and treat a timeout as "no match" rather than letting it crash the run.
var regex = new Regex(rx.ToString(), RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
return path =>
{
var rel = Path.GetRelativePath(dir, path).Replace('\\', '/');
try { return regex.IsMatch(rel); }
catch (RegexMatchTimeoutException)
{
Console.Error.WriteLine($"ownsharp-extract: glob match timed out for '{spec}' on '{rel}'; treated as no match");
return false;
}
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Resolve a `.csproj` to its C# source set. Doing this the MSBuild way needs a full
// project evaluation (and the `Microsoft.CodeAnalysis.Workspaces.MSBuild` + MSBuildLocator
// dependency that P-014 / the "ProjectDependencies as a category" note deliberately parks
// for the DI/solution-graph work — not for the v0 leak extractor). The pragmatic resolution
// that covers the SDK-style common case WITHOUT that baggage:
// - candidates = every in-tree *.cs (SDK default-compile-items), with bin/obj and generated
// files already excluded by IsSkipped;
// - but honour the project's explicit compile set: `<EnableDefaultCompileItems>false` turns the
// default OFF (then only files an explicit `<Compile Include>` selects are kept), and
// `<Compile Remove="...">` subtracts excluded files — so `.csproj` input does not emit findings
// from files the project does not actually compile (CodeRabbit: a source-set mismatch, not a
// harmless over-approximation);
// - plus any concrete linked `<Compile Include="..\Shared\Foo.cs" />` that points OUTSIDE the tree.
// Include/Remove globs are matched by SpecMatcher (not a full MSBuild engine, but enough for the
// common forms). Builds a list rather than yielding so the XML read can sit in a try/catch (an
// iterator may not yield from inside one); a malformed project degrades to the plain directory scan.
static List<string> ProjectCsFiles(string csproj, EnumerationOptions opts)
{
var full = Path.GetFullPath(csproj);
var result = new List<string>();
// A missing project file must NOT degrade to "scan its parent directory": a typo'd
// `--project src/Missing.csproj` would otherwise analyse all of src/**/*.cs (an
// unintended source set), or throw if the parent is absent too. Skip the bad input with
// a warning — the directory-scan fallback below is only for a PRESENT-but-malformed
// project (whose <Compile> items we could not read), never an absent one. (Codex P2.)
if (!File.Exists(full))
{
Console.Error.WriteLine($"ownsharp-extract: project not found: {csproj}");
return result;
}
var dir = Path.GetDirectoryName(full) ?? ".";

XDocument? doc = null;
try { doc = XDocument.Load(full); }
catch (Exception ex)
{
Console.Error.WriteLine(
$"ownsharp-extract: {csproj}: reading <Compile> items failed ({ex.Message}); used directory scan only");
}
var elements = (doc?.Descendants() ?? Enumerable.Empty<XElement>()).ToList();

// `<EnableDefaultCompileItems>false</…>` (last value wins, as MSBuild evaluates top-to-bottom)
// turns off the implicit "every *.cs is compiled" default.
var defaultItems = elements
.Where(e => e.Name.LocalName == "EnableDefaultCompileItems")
.Select(e => e.Value.Trim())
.LastOrDefault();
var defaultCompile = !string.Equals(defaultItems, "false", StringComparison.OrdinalIgnoreCase);

var includes = elements.Where(e => e.Name.LocalName == "Compile")
.Select(e => e.Attribute("Include")?.Value)
.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToList();
var removes = elements.Where(e => e.Name.LocalName == "Compile")
.Select(e => e.Attribute("Remove")?.Value)
.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToList();

var candidates = Directory.EnumerateFiles(dir, "*.cs", opts).Where(f => !IsSkipped(f)).ToList();
if (defaultCompile)
result.AddRange(candidates);
else
{
// Explicit-list project: keep only in-tree files an `<Compile Include>` selects.
var incMatch = includes.Select(s => SpecMatcher(s, dir)).ToList();
result.AddRange(candidates.Where(f => incMatch.Any(m => m(f))));
}

// Concrete linked `<Compile Include>` that points OUTSIDE the project tree, in either mode.
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar;
foreach (var inc in includes)
{
if (inc.IndexOfAny(new[] { '*', '?' }) >= 0) continue; // a glob — handled above / by the scan
var path = Path.GetFullPath(Path.Combine(dir, inc.Replace('\\', Path.DirectorySeparatorChar)));
if (path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
&& File.Exists(path) && !IsSkipped(path)
&& !path.StartsWith(dirPrefix, StringComparison.Ordinal)) // in-tree links already added
result.Add(path);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Honour `<Compile Remove="...">`: drop excluded files (concrete or glob, relative to the dir).
if (removes.Count > 0)
{
var rmMatch = removes.Select(s => SpecMatcher(s, dir)).ToList();
result.RemoveAll(f => rmMatch.Any(m => m(f)));
}

return result.Distinct().ToList();
}

// Extract the double-quoted fields from a solution `Project(...)` line tail, in order. The fields
// are `"Name", "relpath", "{guid}"`; splitting on quotes (not raw commas) means a comma INSIDE a
// quoted name or path no longer misreads the line — CodeRabbit flagged the naive `Split(',')`,
// which could skip a valid project or resolve the wrong path. (`.sln` does not use `""` escaping.)
static List<string> QuotedFields(string s)
{
var fields = new List<string>();
int i = 0;
while (true)
{
int a = s.IndexOf('"', i);
if (a < 0) break;
int b = s.IndexOf('"', a + 1);
if (b < 0) break;
fields.Add(s.Substring(a + 1, b - a - 1));
i = b + 1;
}
return fields;
}

// Resolve a classic `.sln` to its member `.csproj` paths. The solution file lists each project
// as `Project("{type-guid}") = "Name", "rel\path.csproj", "{guid}"`; solution folders use the
// same line shape but their path is not a `.csproj`, so filtering on the extension drops them.
// Text parsing (no MSBuild) keeps this dependency-free; a missing member is reported and skipped,
// never fatal — a solution-wide scan should survive one stale project reference.
static List<string> SolutionProjects(string sln)
{
var dir = Path.GetDirectoryName(Path.GetFullPath(sln)) ?? ".";
var projects = new List<string>();
string[] lines;
try { lines = File.ReadAllLines(sln); }
catch (Exception ex)
{
Console.Error.WriteLine($"ownsharp-extract: cannot read solution {sln} ({ex.Message})");
return projects;
}
foreach (var line in lines)
{
var t = line.TrimStart();
if (!t.StartsWith("Project(", StringComparison.Ordinal)) continue;
var eq = t.IndexOf('=');
if (eq < 0) continue;
// The path is the SECOND double-quoted field after '=' ("Name", "relpath", "{guid}");
// quote-aware extraction tolerates a comma inside the name or path.
var fields = QuotedFields(t.Substring(eq + 1));
if (fields.Count < 2) continue;
var rel = fields[1].Trim();
if (!rel.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) continue;
var path = Path.GetFullPath(Path.Combine(dir, rel.Replace('\\', Path.DirectorySeparatorChar)));
if (File.Exists(path)) projects.Add(path);
else Console.Error.WriteLine($"ownsharp-extract: {sln}: project not found: {rel}");
}
return projects;
}

// Expand inputs into their .cs files. A directory is walked recursively; a `.csproj`/`.sln`
// is resolved to its source set (so `ownsharp-extract App.csproj` / `App.sln` works, the
// CLI-first project input borrowed from the roslyn-tools tooling shape); an explicit file
// passes through as-is. IgnoreInaccessible tolerates an unreadable subdir mid-walk (otherwise
// the whole scan would abort with an unhandled exception on a locked directory).
static IEnumerable<string> Expand(IEnumerable<string> roots)
{
var opts = new EnumerationOptions
Expand All @@ -131,6 +329,17 @@
if (!IsSkipped(f))
yield return f;
}
else if (p.EndsWith(".sln", StringComparison.OrdinalIgnoreCase))
{
foreach (var proj in SolutionProjects(p))
foreach (var f in ProjectCsFiles(proj, opts))
yield return f;
}
else if (p.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
{
foreach (var f in ProjectCsFiles(p, opts))
yield return f;
}
else
{
yield return p;
Expand Down Expand Up @@ -2454,7 +2663,7 @@
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
.ToList();
var refNames = new HashSet<string>(tpa.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase);

Check warning on line 2666 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2666 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2666 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2666 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.
var references = tpa.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
// P-004 WPF profile: widen the reference set with assemblies named by the
// OWN_EXTRA_REF_DIRS env var (colon-separated dirs) — e.g. the WindowsDesktop ref
Expand Down Expand Up @@ -2642,7 +2851,7 @@
|| (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv));
subs.Add(new
{
@event = a.Left.ToString(),

Check warning on line 2854 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2854 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2854 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2854 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.
handler = a.Right.ToString(),
line = LineOf(a.Left),
released,
Expand Down
25 changes: 25 additions & 0 deletions frontend/roslyn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@ python -m ownlang ownir facts.json
# (OrdersViewModel unsubscribes in Dispose -> nothing reported)
```

### Inputs: files, directories, `.csproj`, `.sln`

Inputs may be `.cs` files, directories (walked recursively, skipping `bin`/`obj`/
generated), a **`.csproj`**, or a **`.sln`** — so you can hand the extractor a
project or solution the way the borrowed roslyn-tools CLI shape advertises:

```bash
dotnet run --project OwnSharp.Extractor -- App.csproj -o facts.json # positional
dotnet run --project OwnSharp.Extractor -- --project App.csproj -o facts.json
dotnet run --project OwnSharp.Extractor -- --solution App.sln -o facts.json
```

A `.csproj` resolves to its source set by scanning the project's directory for
`*.cs` (the SDK default-compile-items behaviour) plus any concrete linked
`<Compile Include="..\Shared\Foo.cs" />` outside the project tree — while honouring
the project's explicit compile set: `<EnableDefaultCompileItems>false` switches to
include-driven, and `<Compile Remove="...">` subtracts excluded files (so the
extractor doesn't emit findings from files the project doesn't compile). A `.sln`
fans out over its member projects. This is a **dependency-free** resolution
(text/XML glob matching, no MSBuild evaluation) — enough for the common
Include/Remove forms; full MSBuild evaluation (and the project/package/reference
graph) is the `ProjectDependencies`-category work parked for DI/solution scans, not
the v0 leak extractor — see
[`docs/notes/roslyn-tools-and-cli.md`](../../docs/notes/roslyn-tools-and-cli.md).

## Use it on a real repo / in CI (P-013)

The two stages are chained by one orchestrator script, so you don't run them by
Expand Down
Loading
Loading