diff --git a/CodeyBox.slnx b/CodeyBox.slnx index 1df05687..74e582fd 100644 --- a/CodeyBox.slnx +++ b/CodeyBox.slnx @@ -61,6 +61,7 @@ + diff --git a/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/CodeyBox.ImportLinterAuditorPlugin.csproj b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/CodeyBox.ImportLinterAuditorPlugin.csproj new file mode 100644 index 00000000..696fccf9 --- /dev/null +++ b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/CodeyBox.ImportLinterAuditorPlugin.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/ImportLinterAuditor.cs b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/ImportLinterAuditor.cs new file mode 100644 index 00000000..70b50707 --- /dev/null +++ b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/ImportLinterAuditor.cs @@ -0,0 +1,257 @@ +using CodeyBox.Core; +using CodeyBox.PluginSdk; +using CodeyBox.PluginSdk.Tools; +using Microsoft.Extensions.Logging; + +namespace CodeyBox.ImportLinterAuditorPlugin; + +/// +/// Architecture auditor wrapping lint-imports (Import Linter — Python +/// architecture/import-boundary contracts) on the shared +/// : the base supplies sandboxed +/// invocation with a bounded timeout, per-stream output caps, exit-code +/// classification, severity mapping, finding identity, and per-auditor +/// configuration. This class adds the report parser +/// ( — lint-imports emits text +/// only; there is no structured-output flag), the pinned tool-version +/// declaration via , and the +/// defaults below. +/// +/// Gate behaviour: blocking for broken contracts; advisory for +/// warnings. lint-imports has no severity scale — a contract either holds +/// or is broken — so every entry under Broken contracts is reported at +/// tool level "error" → and fails the +/// audit, while entries under Warnings (e.g. unmatched +/// ignore_imports with unmatched_ignore_imports_alerting = warn) +/// are advisory findings. The declared map +/// covers the wider level vocabulary anyway so nothing raw passes through. +/// MinimumSeverity can only lower this posture (dropping warnings or +/// errors), never raise it. +/// +/// Exit-code convention (verified against import-linter 2.15 — +/// deliberately NOT the usual linter table). lint-imports has exactly two +/// exits: 0 = all contracts kept, and 1 = either broken +/// contracts or could not run — every failure path (missing or +/// unreadable config, invalid contract options rendered as a could-not-run +/// report, unknown --contract id, caught exception) is swallowed to +/// exit 1 by use_cases.lint_imports. Click usage errors exit 2; +/// 126/127 = cannot execute / not found. Because exit 1 is ambiguous, the +/// parser — not the exit code — carries the classification: only a report +/// ending in Contracts: N kept, M broken. counts as a verdict, and a +/// non-zero exit whose report shows zero broken contracts fails closed. +/// Missing output on a verdict exit (crash, foreign stdout) is likewise +/// infrastructure, never a pass. +/// +/// Version pin. Contract types and the rendered report change +/// between releases, so findings are only meaningful from the build the +/// auditor was verified against. The auditor probes +/// lint-imports --version before the scan; a missing binary, an +/// unrecognised version string, or a version other than +/// ExpectedVersion is an infrastructure failure naming the tool — +/// never a pass, never a finding. import-linter versions are PEP 440 +/// (2.15), while the shared pin compares a three-component token — +/// zero-pads the reported release +/// (2.15 → 2.15.0, equal under PEP 440), so +/// ExpectedVersion is always the three-part form. +/// +/// Repository-controlled suppression. The contract set is +/// entirely repo-authored — import-linter reads pyproject.toml +/// [tool.importlinter], setup.cfg [importlinter], or +/// .importlinter, and contract ignore_imports suppress +/// individual violations. The project's own declared contracts are the +/// meaningful check (changes to them are visible in the audited diff), and +/// the auditor runs with . Operators who +/// need an operator-owned contract set pin an out-of-repo file via +/// ConfigPath — a repository without any import-linter configuration +/// is an infrastructure failure ("Could not read any configuration."), not a +/// pass. +/// +/// Scope and defaults. The scan is the whole repository: +/// lint-imports builds an import graph over the configured +/// root_packages. --no-cache keeps it from writing +/// .import_linter_cache into the audited tree and --no-logo +/// keeps the report parseable text. TERM=dumb in the tool environment +/// keeps a baseline FORCE_COLOR from injecting Rich ANSI escapes and +/// live-progress control codes into stdout (verified: rich renders plain, +/// unstyled text on a dumb terminal even when FORCE_COLOR is set). +/// Findings under vendored (vendor/, third_party/, +/// node_modules/) and generated (dist/, build/, +/// out/, coverage/) module paths are dropped by default: +/// violations there belong to upstream packages or build output, not the +/// change under audit. +/// +[CodeyBoxPlugin( + id: PluginId, + displayName: "CodeyBox: Import Linter Architecture Contracts", + minHostApiVersion: "1.0")] +[CodeyBoxPluginRequiresTool( + "lint-imports", + InstallHint = "provision the pinned import-linter release (see ExpectedVersion, default " + + DefaultExpectedVersion + ") into the sandbox baseline via pip or pipx (pip install " + + "'import-linter==2.15.0' — pip resolves the two-part 2.15 release by PEP 440 zero-padding) " + + "together with a Python 3 interpreter — no distro apt package " + + "carries a version pin — through " + + "CodeyBox:MultipassExtraRuncmd / CodeyBox:Incus:ExtraRuncmd or ExecutableProvisions")] +public sealed class ImportLinterAuditor : ExternalToolAuditorBase, IPluginInitializer +{ + /// Plugin id used in Plugins:Enabled and the scoped-config section. + public const string PluginId = "codeybox.import-linter"; + + /// + /// import-linter release the invocation and its report are verified + /// against, in the three-component form the shared pin expects — + /// lint-imports --version prints import-linter 2.15 and + /// 2.15 pads to 2.15.0 under PEP 440. Operators running a + /// different pinned build set ExpectedVersion in the plugin's + /// scoped config to match what they provisioned. + /// + public const string DefaultExpectedVersion = "2.15.0"; + + /// Scoped-config key for an explicit import-linter configuration file path. + public const string ConfigPathKey = "ConfigPath"; + + private static readonly System.Text.RegularExpressions.Regex ReportedVersionPattern = new( + @"\d+\.\d+(?:\.\d+)*", + System.Text.RegularExpressions.RegexOptions.Compiled + | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static readonly ExternalToolAuditorOptions AuditorDefaults = new() + { + // 0 = contracts kept; 1 = broken contracts OR could not run — the + // parser separates the two via the report summary line. 2 (click + // usage error) and everything else is infrastructure. + FindingsExitCodes = new HashSet { 0, 1 }, + // Findings under vendored/dependency and generated module paths + // describe code that is not the change under audit — noise that + // trains operators to ignore the auditor. Paths here are module + // names rendered slash-separated ("vendor/foo"); operators + // re-include a path by overriding ExcludePaths in scoped config. + ExcludePaths = ["vendor/", "third_party/", "node_modules/", "dist/", "build/", "out/", "coverage/", ".venv/", "venv/", ".tox/"], + }; + + private Func _optionsAccessor = () => AuditorDefaults; + private Func _expectedVersion = static () => DefaultExpectedVersion; + private Func _configPath = static () => null; + + /// + public override string Name => "codeybox:import-linter"; + + /// + protected override string ToolName => "lint-imports"; + + /// + protected override IExternalToolOutputParser OutputParser { get; } = new ImportLinterTextOutputParser(); + + /// + /// Declared mapping from the levels the parser emits (and the wider + /// vocabulary a future report shape might carry) to CodeyBox's + /// : broken-contract entries are + /// "error" → and block; report + /// warnings are "warning" → and + /// stay advisory. Raw levels never reach findings. + /// + protected override ExternalToolSeverityMapping SeverityMapping { get; } = + new(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["error"] = AuditSeverity.Error, + ["fatal"] = AuditSeverity.Error, + ["high"] = AuditSeverity.Error, + ["fail"] = AuditSeverity.Error, + ["failure"] = AuditSeverity.Error, + ["critical"] = AuditSeverity.Error, + ["warning"] = AuditSeverity.Warning, + ["warn"] = AuditSeverity.Warning, + ["medium"] = AuditSeverity.Warning, + ["moderate"] = AuditSeverity.Warning, + ["info"] = AuditSeverity.Info, + ["information"] = AuditSeverity.Info, + ["hint"] = AuditSeverity.Info, + ["low"] = AuditSeverity.Info, + ["note"] = AuditSeverity.Info, + }, AuditSeverity.Warning); + + /// + protected override Func OptionsAccessor => _optionsAccessor; + + /// + protected override ToolVersionPin? VersionPin => + new(PluginId, _expectedVersion, DefaultExpectedVersion, ["--version"], ExtractImportLinterVersion); + + /// + protected override IReadOnlyDictionary? BuildToolEnvironment( + ExternalToolAuditorOptions options) + => new Dictionary(StringComparer.Ordinal) + { + // lint-imports renders through Rich: a baseline FORCE_COLOR (or a + // provider-allocated pty) would inject ANSI escapes and + // live-progress cursor codes into stdout and corrupt the report + // the parser reads. Verified on 2.15: a dumb TERM renders plain, + // unwrapped-at-80 text even with FORCE_COLOR set. + ["TERM"] = "dumb", + }; + + /// + protected override IReadOnlyList BuildToolArguments(ExternalToolAuditorOptions options) + { + var args = new List(); + + // Skip the ASCII logo so the report stays plain text. + if (!ExtraArgumentsSupplyFlag(options, "--no-logo")) + args.Add("--no-logo"); + + // Never write .import_linter_cache into the audited tree; the audit + // must not mutate its subject. An operator --cache-dir or --no-cache + // defers the setting. + if (!ExtraArgumentsSupplyFlag(options, "--no-cache") + && !ExtraArgumentsSupplyFlag(options, "--cache-dir")) + args.Add("--no-cache"); + + var configPath = _configPath(); + if (!string.IsNullOrWhiteSpace(configPath) + && !ExtraArgumentsSupplyFlag(options, "--config")) + { + args.Add("--config"); + args.Add(configPath.Trim()); + } + + return args; + } + + /// + /// Extracts the reported version from lint-imports --version + /// output (import-linter 2.15) and zero-pads it to a + /// three-component release token — import-linter releases are PEP 440 + /// (2.15, occasionally 2.5.1), while the shared pin + /// compares a major.minor.patch string. Returns null when the + /// output carries no version token so the pin fails closed. + /// + internal static string? ExtractImportLinterVersion(string output) + { + var match = ReportedVersionPattern.Match(output ?? string.Empty); + if (!match.Success) + return null; + var version = match.Value.TrimEnd('.'); + if (version.Length == 0) + return null; + var components = version.Split('.').Length; + return components switch + { + 2 => version + ".0", + >= 3 => version, + _ => null, + }; + } + + /// + public Task InitializeAsync(PluginContext context, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + var scoped = context.ScopedConfig; + _optionsAccessor = () => ExternalToolAuditorOptions.Bind(scoped, AuditorDefaults); + _expectedVersion = () => scoped[ToolVersionPin.ExpectedVersionKey]; + _configPath = () => scoped[ConfigPathKey]; + context.Logger.LogInformation( + "ImportLinterAuditor initialized: pluginId={PluginId}", context.PluginId); + return Task.CompletedTask; + } +} diff --git a/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/ImportLinterTextOutputParser.cs b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/ImportLinterTextOutputParser.cs new file mode 100644 index 00000000..d5d1b7d5 --- /dev/null +++ b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/ImportLinterTextOutputParser.cs @@ -0,0 +1,427 @@ +using System.Text; +using System.Text.RegularExpressions; +using CodeyBox.PluginSdk.Tools; + +namespace CodeyBox.ImportLinterAuditorPlugin; + +/// +/// Parses the plain-text report lint-imports writes to stdout into +/// records. Verified against import-linter +/// 2.15, whose rendered report looks like: +/// +/// +/// --------- +/// Contracts +/// --------- +/// +/// Analyzed 3 files, 2 dependencies. +/// --------------------------------- +/// +/// Layering KEPT +/// No low in high BROKEN +/// +/// Contracts: 1 kept, 1 broken. +/// +/// ---------------- +/// Broken contracts +/// ---------------- +/// +/// No low in high +/// -------------- +/// +/// mypkg.high is not allowed to import mypkg.low: +/// +/// - mypkg.high -> mypkg.low (l.1) +/// +/// +/// The exit code alone cannot classify the run. lint-imports has +/// only two exits: 0 (all contracts kept) and 1 — which covers +/// broken contracts and every "could not run" outcome (missing or +/// unreadable config, invalid contract options rendered as a could-not-run +/// report, unknown --contract id, crashes). The reliable verdict marker +/// is the summary line Contracts: N kept, M broken., printed by +/// render_report only after checks complete; every failure path skips +/// it. A finished run without that line — or a contradictory one (non-zero +/// exit with 0 broken) — throws , +/// which the base reports as infrastructure, never as a pass. +/// +/// Findings come from the Broken contracts section: one per +/// rendered import link (importer -> imported (l.N)), one per +/// undeclared-layer module bullet, plus a per-contract fallback so a broken +/// contract whose details render in an unrecognised shape still produces a +/// finding rather than vanishing. The tool reports Python module +/// names — never file paths — so finding paths carry the module in +/// slash-separated form (mypkg.low → mypkg/low, which may be +/// mypkg/low.py or mypkg/low/__init__.py); the first +/// l.N in the detail is the line. Entries under the +/// Warnings section become advisory warning-level findings. +/// Raw levels stay in the tool's vocabulary — +/// maps them through the auditor's +/// declared . +/// +internal sealed class ImportLinterTextOutputParser : IExternalToolOutputParser +{ + // Same per-report result bound the shared SARIF parser applies. + private const int MaxResults = SarifToolOutputParser.DefaultMaxResults; + private const int FailureTailMaxChars = 512; + private const int FallbackDetailMaxChars = 300; + + // "Contracts: 1 kept, 1 broken." — the only line lint-imports prints that + // proves the checks ran to completion and produced a verdict. + private static readonly Regex SummaryLine = new( + @"^Contracts:\s+(?\d+)\s+kept,\s+(?\d+)\s+broken\.$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // Rendered import links inside a "Broken contracts" block. Real shapes + // (2.15): "- mypkg.a -> mypkg.b (l.4)" (forbidden), "- mypkg.a -> + // mypkg.b (l.4)" and indented continuations " a -> b (l.4)" + // (layers/independence/protected chains, "&" prefixes for multi-hop + // heads/tails), and acyclic_siblings' "- .a -> .b (2 imports)". + private static readonly Regex ImportLinkLine = new( + @"^[\s\-&]*(?\S+?)\s*->\s*(?\S+?)\s*\((?[^()]*)\)\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // Arrow-less rendered links with line numbers — chain endpoints rendered + // as "- mypkg.a (l.3)" / " & mypkg.b (l.4)" by multi-hop chain output. + private static readonly Regex ModuleWithLinesLine = new( + @"^[\s\-&]+(?[A-Za-z0-9_.]+)\s*\((?l\.[^()]*)\)\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // Bare module bullets — exhaustive layers' "not listed as layers" list. + private static readonly Regex ModuleBulletLine = new( + @"^\s*-\s+(?[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // acyclic_siblings renders relative module names (".foo -> .bar"); the + // owning package comes from its "No cycles are allowed in ." line. + private static readonly Regex CycleContextLine = new( + @"^No cycles are allowed in (?[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)\.$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex LineNumber = new( + @"l\.(?\d+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public IReadOnlyList Parse(ExternalToolParseInput input) + { + ArgumentNullException.ThrowIfNull(input); + if (string.IsNullOrWhiteSpace(input.Stdout)) + throw new ExternalToolParseException( + $"Tool '{input.ToolName}' produced no report on stdout — the check did not run."); + + var lines = input.Stdout.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + var brokenCount = FindSummary(lines, input.ToolName); + if (input.ExitCode != 0 && brokenCount == 0) + throw new ExternalToolParseException( + $"Tool '{input.ToolName}' exited {input.ExitCode} but its report shows no broken " + + "contracts — a contradictory state, so the run cannot be read as a verdict."); + + var findings = new List(); + var brokenStart = FindSectionStart(lines, "Broken contracts"); + var warningsStart = FindSectionStart(lines, "Warnings"); + + if (warningsStart >= 0) + ParseWarningsSection(lines, warningsStart, findings); + if (brokenStart >= 0) + ParseBrokenContractsSection(lines, brokenStart, findings); + + // A report that claims broken contracts but yields no broken-contract + // finding — missing section or an unrecognised shape — would pass the + // audit while the tool reported failure. Fail closed instead. + if (brokenCount > 0 && !findings.Any(f => f.SeverityLevel == "error")) + throw new ExternalToolParseException( + $"Tool '{input.ToolName}' reports {brokenCount} broken contract(s) but the " + + "'Broken contracts' details could not be recognised — the report shape is " + + "unrecognised, so the run cannot be read as a verdict."); + + return findings; + } + + /// + /// Locates the "Contracts: N kept, M broken." summary and returns the + /// broken count. Throws when the line is absent — every failure path + /// (missing config, invalid contract options, unknown --contract id, + /// crash) exits 1 without printing it, so "no summary" is "could not + /// run", never a clean report. + /// + private static int FindSummary(string[] lines, string toolName) + { + foreach (var raw in lines) + { + var match = SummaryLine.Match(raw.TrimEnd()); + if (match.Success) + return int.Parse(match.Groups["broken"].Value, System.Globalization.CultureInfo.InvariantCulture); + } + + throw new ExternalToolParseException( + $"Tool '{toolName}' produced no 'Contracts: N kept, N broken.' summary — the check did " + + "not run (missing or unreadable import-linter configuration, invalid contract options, " + + $"or a crash). Output tail: {Tail(lines)}"); + } + + /// + /// Finds a level-two section heading — a line sandwiched between two + /// all-dash lines — and returns the index of the line after it, or -1. + /// + private static int FindSectionStart(string[] lines, string name) + { + for (var i = 1; i + 1 < lines.Length; i++) + { + if (string.Equals(lines[i].Trim(), name, StringComparison.Ordinal) + && IsUnderline(lines[i - 1]) + && IsUnderline(lines[i + 1])) + return i + 1; + } + + return -1; + } + + /// + /// True when is a level-two heading (dash line + /// above and below) — the boundary that ends a section. + /// + private static bool IsSectionBoundary(string[] lines, int index) + => index > 0 + && index + 1 < lines.Length + && lines[index].Trim().Length > 0 + && IsUnderline(lines[index - 1]) + && IsUnderline(lines[index + 1]); + + /// + /// True when is a level-three heading — a text + /// line underlined by a dash line, with no dash line above (that would + /// make it a level-two section heading). + /// + private static bool IsContractHeading(string[] lines, int index) + { + var text = lines[index].Trim(); + return text.Length > 0 + && index + 1 < lines.Length + && IsUnderline(lines[index + 1]) + && (index == 0 || !IsUnderline(lines[index - 1])); + } + + private static bool IsUnderline(string line) + { + var trimmed = line.Trim(); + if (trimmed.Length < 2) + return false; + foreach (var c in trimmed) + { + if (c != '-') + return false; + } + + return true; + } + + private static void ParseBrokenContractsSection( + string[] lines, int sectionStart, List findings) + { + var end = lines.Length; + string? contractName = null; + string? context = null; + string? cyclePackage = null; + var contractProducedFindings = false; + var blockStart = -1; + + for (var i = sectionStart; i < end; i++) + { + if (findings.Count >= MaxResults) + return; + if (IsSectionBoundary(lines, i)) + break; + var trimmed = lines[i].Trim(); + if (trimmed.Length == 0 || IsUnderline(lines[i])) + continue; + + if (IsContractHeading(lines, i)) + { + EmitFallbackIfNeeded(findings, contractProducedFindings, contractName, lines, blockStart, i); + contractName = trimmed; + context = null; + cyclePackage = null; + contractProducedFindings = false; + blockStart = i; + continue; + } + + if (contractName is null) + continue; + + var link = ImportLinkLine.Match(lines[i]); + if (link.Success) + { + var importer = link.Groups["importer"].Value.TrimStart('-', '&').Trim(); + var imported = link.Groups["imported"].Value; + var detail = link.Groups["detail"].Value.Trim(); + var path = ModuleToPath( + importer.StartsWith(".", StringComparison.Ordinal) && cyclePackage is not null + ? cyclePackage + importer + : importer); + findings.Add(new ExternalToolFinding( + SeverityLevel: "error", + RuleId: contractName, + Message: Describe(context, $"{importer} -> {imported} ({detail})"), + Path: path, + Line: FirstLineNumber(detail))); + contractProducedFindings = true; + continue; + } + + var moduleWithLines = ModuleWithLinesLine.Match(lines[i]); + if (moduleWithLines.Success) + { + var module = moduleWithLines.Groups["module"].Value; + var detail = moduleWithLines.Groups["detail"].Value.Trim(); + findings.Add(new ExternalToolFinding( + SeverityLevel: "error", + RuleId: contractName, + Message: Describe(context, $"{module} ({detail})"), + Path: ModuleToPath(module), + Line: FirstLineNumber(detail))); + contractProducedFindings = true; + continue; + } + + var bullet = ModuleBulletLine.Match(lines[i]); + if (bullet.Success) + { + var module = bullet.Groups["module"].Value; + findings.Add(new ExternalToolFinding( + SeverityLevel: "error", + RuleId: contractName, + Message: Describe(context, module), + Path: ModuleToPath(module), + Line: null)); + contractProducedFindings = true; + continue; + } + + // Any other non-blank line inside a contract block — the + // "x is not allowed to import y:", "Illegal imports of protected + // package x:", "No cycles are allowed in x.", "It could be made + // acyclic by removing N dependencies:", "The following modules + // are not listed as layers:" context lines, plus custom-contract + // text — prefixes the next finding's message for context. + context = trimmed; + var cycleMatch = CycleContextLine.Match(trimmed); + if (cycleMatch.Success) + cyclePackage = cycleMatch.Groups["package"].Value; + } + + EmitFallbackIfNeeded(findings, contractProducedFindings, contractName, lines, blockStart, end); + } + + private static void ParseWarningsSection( + string[] lines, int sectionStart, List findings) + { + var end = lines.Length; + string? contractName = null; + + for (var i = sectionStart; i < end; i++) + { + if (findings.Count >= MaxResults) + return; + if (IsSectionBoundary(lines, i)) + break; + var trimmed = lines[i].Trim(); + if (trimmed.Length == 0 || IsUnderline(lines[i])) + continue; + + if (IsContractHeading(lines, i)) + { + contractName = trimmed; + continue; + } + + // Warnings are rendered as "- " bullets under level-three + // contract headings. + if (contractName is not null && trimmed.StartsWith('-')) + { + findings.Add(new ExternalToolFinding( + SeverityLevel: "warning", + RuleId: contractName, + Message: $"{contractName}: {trimmed.TrimStart('-').Trim()}", + Path: null, + Line: null)); + } + } + } + + /// + /// A broken contract whose rendered block produced no recognised + /// import/module lines (custom contracts render free text) still gets one + /// finding — a BROKEN verdict must never parse to zero findings. + /// + private static void EmitFallbackIfNeeded( + List findings, + bool contractProducedFindings, + string? contractName, + string[] lines, + int blockStart, + int blockEnd) + { + if (contractName is null || contractProducedFindings || findings.Count >= MaxResults) + return; + + var detail = new StringBuilder(); + for (var i = blockStart + 1; i < blockEnd && detail.Length < FallbackDetailMaxChars; i++) + { + var text = lines[i].Trim(); + if (text.Length == 0 || IsUnderline(lines[i])) + continue; + if (detail.Length > 0) + detail.Append(' '); + detail.Append(text); + } + + var suffix = detail.Length == 0 + ? "no per-import details rendered" + : ExternalToolJsonHelpers.Truncate(detail.ToString(), FallbackDetailMaxChars); + findings.Add(new ExternalToolFinding( + SeverityLevel: "error", + RuleId: contractName, + Message: $"{contractName} reported BROKEN: {suffix}", + Path: null, + Line: null)); + } + + private static string Describe(string? context, string detail) + => string.IsNullOrWhiteSpace(context) ? detail : $"{context} {detail}"; + + /// + /// Renders a Python module name in slash-separated form so the finding + /// path reads like its location and prefix exclusions can match: + /// mypkg.low → mypkg/low (the file is either + /// mypkg/low.py or mypkg/low/__init__.py — the tool does + /// not say which). + /// + private static string ModuleToPath(string module) + => module.Trim('.').Replace('.', '/'); + + private static int? FirstLineNumber(string detail) + { + var match = LineNumber.Match(detail); + return match.Success && int.TryParse(match.Groups["line"].Value, out var line) && line > 0 + ? line + : null; + } + + private static string Tail(string[] lines) + { + var nonBlank = new List(); + for (var i = lines.Length - 1; i >= 0 && nonBlank.Count < 5; i--) + { + var text = lines[i].Trim(); + if (text.Length > 0) + nonBlank.Add(text); + } + + nonBlank.Reverse(); + return ExternalToolJsonHelpers.Truncate( + ExternalToolJsonHelpers.SingleLine(string.Join(" ", nonBlank)), + FailureTailMaxChars); + } +} diff --git a/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/README.md b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/README.md new file mode 100644 index 00000000..7df4ab63 --- /dev/null +++ b/plugins/auditors-architecture/CodeyBox.ImportLinterAuditorPlugin/README.md @@ -0,0 +1,158 @@ +# CodeyBox: Import Linter Architecture Contracts + +Auditor plugin wrapping [Import Linter](https://import-linter.readthedocs.io/): +it checks the audited repository's declared Python import contracts with +`lint-imports --no-logo --no-cache` and reports each broken-contract detail +line as an audit finding with the contract name as the rule id and the +violating module + line as the location. Python import-boundary and +architecture checks only. + +## What it reports + +- One finding per rendered violation inside the `Broken contracts` report + section: direct import links (`importer -> imported (l.N)`), undeclared + layer modules, and chain links. `RuleId` is the contract name (the tool's + only identifier — contract types are not rendered in the report). +- One advisory finding per entry under the `Warnings` section (e.g. unmatched + `ignore_imports` entries with `unmatched_ignore_imports_alerting = warn`). +- One fallback finding per broken contract whose detail block renders in a + shape this parser does not recognise (e.g. custom contract types), so a + BROKEN verdict can never parse to zero findings. +- **Gate behaviour: blocking for broken contracts, advisory for warnings.** + Broken-contract entries are reported at tool level `error` → `Error` and + fail the audit; warnings map to `Warning` and never block on their own. +- **Locations are Python modules, not file paths.** lint-imports reports + dotted module names, so a finding's `Location` is the module rendered in + slash form plus the first reported line — `mypkg.high -> mypkg.low (l.1)` + lands at `mypkg/high:1`, where the file is `mypkg/high.py` or + `mypkg/high/__init__.py`. ExcludePaths prefixes match this slash form. + +## What it cannot see + +- **Repositories with no import-linter configuration.** Contracts come from + `pyproject.toml [tool.importlinter]`, `setup.cfg [importlinter]`, or + `.importlinter` in the audited repo — `lint-imports` exits 1 with "Could + not read any configuration." when none exists, which the auditor reports + as **infrastructure**, never as a pass. Enabling this plugin requires + declaring at least one contract (and `root_package`/`root_packages`) in + the repository, or pinning an operator-owned file via `ConfigPath`. +- **Non-Python files and non-import violations.** lint-imports checks the + static import graph; it sees only what the configured `root_packages` + cover. +- **Contract types.** The text report renders contract names but not their + type (`forbidden`, `layers`, `independence`, `protected`, + `acyclic_siblings`, custom); findings carry the contract name only. +- **Indentation conventions of the checked code.** It sees imports, not + formatting or lint — pair with a linting auditor for that. +- **More than `MaxFindings` violations.** Findings beyond `MaxFindings` + (default 1000) are dropped and the truncation is reported in the raw + output. + +## Exit codes and failure classification + +lint-imports' convention (verified against import-linter 2.15 — **not** the +common "0 clean / 1 findings / 2 error" table): there are only two exits. + +| Exit | Meaning | Classification | +|---|---|---| +| `0` | All contracts kept; report ends `Contracts: N kept, 0 broken.` | Verdict (pass) | +| `1` + `Contracts: … broken.` | Checks ran; broken contracts found | Verdict (`Passed = false`) | +| `1`, no summary line | Could not run: missing/unreadable config, invalid contract options (`Contract "x" is not configured correctly`), unknown `--contract` id, or a caught exception — all rendered as plain error text | Infrastructure (`AuditUnavailableException`) | +| `1`, summary says `0 broken` | Contradictory state — cannot be read as a verdict | Infrastructure | +| `1`, report claims broken but no recognised `Broken contracts` details | Unrecognised report shape | Infrastructure | +| `2` | Click usage error — bad flags | Infrastructure | +| `126` / `127` | Binary not executable or not found | Infrastructure | +| anything else | Unknown convention | Infrastructure (fails loud, never a pass) | + +A missing `lint-imports` is always an infrastructure failure naming the tool — +never a passing audit. + +## Version pinning + +The auditor is pinned to **import-linter `2.15.0`** (`ExpectedVersion` in +scoped config). Contract types and the rendered report change between +releases, so an unpinned tool would change findings under you: the auditor +probes `lint-imports --version` before every run and reports an +infrastructure failure on any other version. `lint-imports --version` prints +`import-linter 2.15` — a two-part PEP 440 release — which the auditor +zero-pads to `2.15.0` for comparison; always set `ExpectedVersion` in +three-part form. + +The tool requirement is declared **verify-only** — no `AptPackage`: +import-linter ships via pip/pipx and no distro package carries a version pin. +Provision the pinned release **only when this plugin is enabled**: + +```sh +# baseline bake step (needs Python 3 + pip/pipx on the image) +pip install 'import-linter==2.15.0' # resolves the 2.15 release +lint-imports --version # must print import-linter 2.15 +``` + +## Enabling + +The plugin is **disabled by default** — it loads only when named in both +gates, and baseline provisioning verifies `lint-imports` only in that state: + +```json +{ + "CodeyBox": { + "Plugins": { + "Allowlist": ["codeybox.import-linter"], + "Enabled": ["codeybox.import-linter"] + } + } +} +``` + +and per project under `Audit.Custom`: + +```json +{ "Kind": "plugin", "PluginId": "codeybox.import-linter" } +``` + +## Configuration + +Scoped under `CodeyBox:Plugins:codeybox.import-linter`, resolved per run +(hot-reloadable): + +| Key | Default | Meaning | +|---|---|---| +| `ExpectedVersion` | `2.15.0` | Pinned import-linter release (three-part form); a different installed version fails closed as infrastructure. Set this to the release you provisioned. | +| `ConfigPath` | `null` | Path passed to `--config` — an operator-pinned `.importlinter`/INI or TOML file outside the repository, or a repo file overriding the default discovery (`pyproject.toml`, `setup.cfg`, `.importlinter`). Ignored when `ExtraArguments` already supplies `--config`. | +| `MinimumSeverity` | `info` | Drop mapped findings below this severity (`info`, `warning`, `error`). Can only weaken the gate (e.g. `error` keeps warnings out entirely, and broken contracts still block since they are errors — there is no advisory mode for this auditor). | +| `IncludedRules` / `ExcludedRules` | — | Contract names (exact match) to keep/drop as findings — the tool's only rule identifier. | +| `ExcludePaths` | `vendor/`, `third_party/`, `node_modules/`, `dist/`, `build/`, `out/`, `coverage/`, `.venv/`, `venv/`, `.tox/` | Module-path prefixes dropped from findings — findings carry modules in slash form (`vendor/pkg`), so these match vendored/generated trees. Setting it replaces the default list. | +| `ExtraArguments` | — | Extra argv appended after the built-in args (never via a shell). Useful for `--contract ` to limit the check, `--cache-dir ` to opt back into caching, or `--verbose`. A repeated `--config`/`--no-cache`/`--no-logo` defers to the operator's own setting. | +| `TimeoutSeconds` | `300` | Per-run bound — graph building is the dominant cost on large trees. Exceeding it is infrastructure, not a pass. | +| `MaxOutputBytesPerStream` / `MaxFindings` | `1 MiB` / `1000` | Output/result caps; overruns are reported as truncation. | + +**The contract set is repo-authored.** The audited repository writes +`[tool.importlinter]`/`[importlinter]` — which contracts exist, their +`ignore_imports`, and `root_packages` — so the subject can weaken its own +gate (the same posture every config-file-driven auditor takes: the project's +own declared contracts are the meaningful check, and config changes are +visible in the audited diff). Operators who need an operator-owned contract +set pin a file outside the repository via `ConfigPath`. The auditor runs +under `AuditCapabilities.None` (no agent credentials, no network). + +## Default scope + +`lint-imports` checks every module in the configured `root_packages` — the +repository's own declaration of what its architecture covers. The scan passes +`--no-cache` so it never writes `.import_linter_cache` into the audited tree, +and `TERM=dumb` in the tool environment keeps a baseline `FORCE_COLOR` (or a +pty-allocating provider) from injecting Rich ANSI escapes and live-progress +control codes into stdout. On top of that, the finding-level `ExcludePaths` +backstop drops violations under vendored and generated module prefixes — +noise that would train operators to ignore the auditor. Narrow the check with +`--contract` in `ExtraArguments`, or widen it by extending `ExcludePaths` +overrides. + +### Known edge cases + +- `TERM=dumb` fixes the rendered width at 80 columns; a violation line longer + than that wraps mid-line in Rich's renderer and may not parse as a single + import link. The broken contract still produces a fallback finding (without + a precise location), so no verdict is ever silently lost. +- Contracts without an `id` cannot be limited via `--contract`; findings + still carry the contract `name` as the rule id. diff --git a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj index 0cbeaa29..8797f253 100644 --- a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj +++ b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/tests/CodeyBox.Tests/ImportLinterAuditorTests.cs b/tests/CodeyBox.Tests/ImportLinterAuditorTests.cs new file mode 100644 index 00000000..eb44cc43 --- /dev/null +++ b/tests/CodeyBox.Tests/ImportLinterAuditorTests.cs @@ -0,0 +1,863 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using CodeyBox.Core; +using CodeyBox.Orchestrator; +using CodeyBox.PluginSdk; +using CodeyBox.ImportLinterAuditorPlugin; +using CodeyBox.Sandbox.Process; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CodeyBox.Tests; + +/// +/// Covers the Import Linter auditor plugin: +/// - Missing or wrong-version binary is an infrastructure failure naming lint-imports (never a pass or finding). +/// - lint-imports has only exits 0 and 1: broken contracts AND every could-not-run path share +/// exit 1, so the report summary line — not the exit code — separates verdict from failure. +/// - "Broken contracts" detail lines map to findings with the contract name as rule id and +/// module:line locations; "Warnings" entries are advisory. +/// - Raw tool severities go through the declared mapping (error -> Error, warning -> Warning). +/// - Plugin is disabled by default, absent from baseline provisioning until enabled. +/// - Real binary execution tests under [Trait("requires_lint_imports", "true")]. +/// +public sealed class ImportLinterAuditorTests +{ + private static readonly string? InstalledImportLinterVersion = ProbeInstalledVersion(); + + // Shape mirrors real `lint-imports --no-logo --no-cache` output (2.15). + private const string ReportBrokenWithWarning = """ + + --------- + Contracts + --------- + + Analyzed 3 files, 2 dependencies. + --------------------------------- + + Layering KEPT + No low in high BROKEN (1 warning) + + Contracts: 1 kept, 1 broken. + + -------- + Warnings + -------- + + No low in high + -------------- + + - No matches for ignored import mypkg.nonexistent -> mypkg.low. + + + ---------------- + Broken contracts + ---------------- + + No low in high + -------------- + + mypkg.high is not allowed to import mypkg.low: + + - mypkg.high -> mypkg.low (l.1) + + + """; + + private const string ReportClean = """ + + --------- + Contracts + --------- + + Analyzed 3 files, 0 dependencies. + --------------------------------- + + Layering KEPT + No low in high KEPT + + Contracts: 2 kept, 0 broken. + """; + + // Clean verdict plus advisory warnings: exit 0, still passes. + private const string ReportCleanWithWarning = """ + + --------- + Contracts + --------- + + Analyzed 3 files, 0 dependencies. + --------------------------------- + + No low in high KEPT (1 warning) + + Contracts: 1 kept, 0 broken. + + -------- + Warnings + -------- + + No low in high + -------------- + + - No matches for ignored import mypkg.nonexistent -> mypkg.low. + + """; + + private const string ReportBrokenUnrecognisedDetails = """ + + --------- + Contracts + --------- + + Analyzed 3 files, 2 dependencies. + --------------------------------- + + Custom contract BROKEN + + Contracts: 0 kept, 1 broken. + + + ---------------- + Broken contracts + ---------------- + + Custom contract + --------------- + + Some free-form text a custom contract renderer produced. + + """; + + private const string ReportBrokenVendored = """ + + --------- + Contracts + --------- + + Analyzed 4 files, 3 dependencies. + --------------------------------- + + No vendored imports BROKEN + + Contracts: 0 kept, 1 broken. + + + ---------------- + Broken contracts + ---------------- + + No vendored imports + ------------------- + + mypkg.low is not allowed to import vendor.lib: + + - mypkg.low -> vendor.lib (l.2) + - vendor.lib.deep -> other.mod (l.7) + + + """; + + // Exit-1 failure shapes — no report summary on any of them. + private const string MissingConfigOutput = "\nCould not read any configuration.\n"; + + private const string CouldNotRunReport = """ + + Contract "No low in high" is not configured correctly: + source_modules: This is a required field. + + """; + + [Fact] + public async Task MissingBinary_IsInfrastructureFailure_NamingLintImports_NeverAPass() + { + var scanExecs = 0; + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec)) + return Task.FromResult(new SandboxExecResult(1, "", "")); + if (IsVersionProbe(exec)) + return Task.FromResult(new SandboxExecResult(127, "", "lint-imports: command not found")); + scanExecs++; + return Task.FromResult(new SandboxExecResult(0, ReportClean, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var ex = await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + + Assert.Contains("lint-imports", ex.Message, StringComparison.Ordinal); + Assert.Equal(0, scanExecs); + } + + [Fact] + public async Task WrongVersion_IsInfrastructureFailure() + { + var scanExecs = 0; + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec)) + return Task.FromResult(Ok(exec)); + if (IsVersionProbe(exec)) + return Task.FromResult(new SandboxExecResult(0, "import-linter 2.14\n", "")); + scanExecs++; + return Task.FromResult(new SandboxExecResult(0, ReportClean, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var ex = await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + + Assert.Contains("lint-imports", ex.Message, StringComparison.Ordinal); + Assert.Equal(0, scanExecs); + } + + [Fact] + public async Task Fixture_WithBrokenContract_YieldsFindings_WithRuleIdAndLocation() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, ReportBrokenWithWarning, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var result = await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.False(result.Passed); + Assert.Equal(2, result.Findings.Count); + + var broken = Assert.Single(result.Findings, f => f.Severity == AuditSeverity.Error); + Assert.Equal("No low in high", BrokenRuleId(broken)); + Assert.Equal("mypkg/high:1", broken.Location); + Assert.Contains("mypkg.high is not allowed to import mypkg.low", broken.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task CleanFixture_YieldsZeroFindings_AndPasses() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(0, ReportClean, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var result = await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.True(result.Passed); + Assert.Empty(result.Findings); + } + + [Fact] + public async Task ExitCode1_BrokenContracts_ReportsFindings_AndFails() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, ReportBrokenUnrecognisedDetails, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var result = await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + // The custom-contract block renders no import links: the per-contract + // fallback still surfaces a finding — a BROKEN verdict never parses + // to zero findings. + Assert.False(result.Passed); + var finding = Assert.Single(result.Findings); + Assert.Equal(AuditSeverity.Error, finding.Severity); + Assert.Equal("Custom contract", BrokenRuleId(finding)); + } + + [Fact] + public async Task ExitCode1_WithoutReport_IsInfrastructureFailure() + { + // "Could not read any configuration." — a could-not-run exit shares + // exit 1 with broken contracts; only the missing summary line + // separates the two. + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, MissingConfigOutput, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var ex = await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + + Assert.Contains("lint-imports", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExitCode1_InvalidContractOptions_IsInfrastructureFailure() + { + // The could-not-run report prints no summary line — the check did not + // run, so this is infrastructure rather than findings. + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, CouldNotRunReport, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + } + + [Fact] + public async Task ExitCode1_ContradictorySummary_IsInfrastructureFailure() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, ReportClean, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + } + + [Fact] + public async Task ExitCode2_IsInfrastructureFailure() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(2, "", "Error: No such option '--bogus'")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var ex = await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + + Assert.Contains("lint-imports", ex.Message, StringComparison.Ordinal); + Assert.Contains("2", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExitCode127_IsInfrastructureFailure() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(127, "", "lint-imports: command not found")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var ex = await Assert.ThrowsAsync( + () => auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + + Assert.Contains("lint-imports", ex.Message, StringComparison.Ordinal); + Assert.Contains("127", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SeverityMapping_Applied_BrokenBlocksWarningsAdvisory_NoRawPassThrough() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, ReportBrokenWithWarning, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var result = await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.False(result.Passed); // the error finding blocks + Assert.Contains(result.Findings, f => f.Severity == AuditSeverity.Error); + var warning = Assert.Single(result.Findings, f => f.Severity == AuditSeverity.Warning); + Assert.Contains("No matches for ignored import", warning.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task WarningsOnly_Report_Passes_WithAdvisoryFindings() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(0, ReportCleanWithWarning, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var result = await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.True(result.Passed); + var warning = Assert.Single(result.Findings); + Assert.Equal(AuditSeverity.Warning, warning.Severity); + } + + [Fact] + public async Task DefaultExcludePaths_DropVendoredModuleFindings() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, ReportBrokenVendored, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + var result = await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + // "vendor.lib.deep -> other.mod" lands under vendor/ and is dropped; + // "mypkg.low -> vendor.lib" keeps its location under mypkg/. + Assert.False(result.Passed); + var finding = Assert.Single(result.Findings); + Assert.Equal("mypkg/low:2", finding.Location); + } + + [Fact] + public async Task DefaultArguments_RunNoLogoNoCache_DumbTerminal() + { + SandboxExec? scanExec = null; + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + scanExec = exec; + return Task.FromResult(new SandboxExecResult(0, ReportClean, "")); + }); + + IAuditor auditor = new ImportLinterAuditor(); + await auditor.RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.NotNull(scanExec); + var argv = scanExec!.Argv; + Assert.Equal("lint-imports", argv[0]); + Assert.Contains("--no-logo", argv); + Assert.Contains("--no-cache", argv); + Assert.Equal("dumb", scanExec.ExtraEnvironment?["TERM"]); + } + + [Fact] + public async Task ScopedConfiguration_ConfigPath_AppendedToArguments() + { + SandboxExec? scanExec = null; + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + scanExec = exec; + return Task.FromResult(new SandboxExecResult(0, ReportClean, "")); + }); + + var auditor = new ImportLinterAuditor(); + await auditor.InitializeAsync( + BuildPluginContext(new Dictionary + { + ["Scoped:ConfigPath"] = "/opt/codeybox/importlinter.operator.ini", + }), + CancellationToken.None); + + await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.NotNull(scanExec); + var argv = scanExec!.Argv; + var configIndex = argv.ToList().IndexOf("--config"); + Assert.True(configIndex >= 0 && configIndex + 1 < argv.Count); + Assert.Equal("/opt/codeybox/importlinter.operator.ini", argv[configIndex + 1]); + } + + [Fact] + public async Task ScopedConfiguration_IncludedRules_FiltersOtherContracts() + { + var sandbox = new FakeSandbox((exec, _) => + { + if (IsPresenceProbe(exec) || IsVersionProbe(exec)) + return Task.FromResult(Ok(exec)); + return Task.FromResult(new SandboxExecResult(1, ReportBrokenWithWarning, "")); + }); + + var auditor = new ImportLinterAuditor(); + await auditor.InitializeAsync( + BuildPluginContext(new Dictionary + { + ["Scoped:IncludedRules"] = "Layering", + }), + CancellationToken.None); + + var result = await ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None); + + // "No low in high" is not in the include set — findings drop, and + // lint-imports' exit 1 + report still parses to an empty finding + // list: the tool ran and produced a verdict the operator scoped out. + Assert.Empty(result.Findings); + } + + [Fact] + public void DisabledPlugin_IsNotLoaded_AndToolAbsentFromBaselineProvisioning() + { + var assemblyPath = PluginAssemblyPath(); + var loader = new PluginLoader( + new PluginOptions + { + AssemblyPaths = [assemblyPath], + Allowlist = ["*"], + Enabled = [], + }, + new ConfigurationBuilder().Build(), + NullLogger.Instance); + + Assert.Empty(loader.DiscoverPlugins()); + var status = Assert.Single( + loader.GetDiscoveryStatuses(), + s => s.PluginId == ImportLinterAuditor.PluginId); + Assert.Equal(PluginSkipReason.Disabled, status.SkipReason); + + var tools = loader.GetEnabledPluginTools(); + Assert.Empty(tools); + + var contributions = PluginBaselineProvisioning.BuildContributions(tools); + var flattened = string.Join("\n", contributions.InstallCommands) + + "\n" + string.Join("\n", contributions.VerificationCommands.SelectMany(static v => v.Argv)); + Assert.DoesNotContain("lint-imports", flattened, StringComparison.Ordinal); + } + + [Fact] + public void EnabledPlugin_DeclaresLintImportsRequirement_VerifyOnly() + { + var assemblyPath = PluginAssemblyPath(); + var loader = new PluginLoader( + new PluginOptions + { + AssemblyPaths = [assemblyPath], + Allowlist = ["*"], + Enabled = [ImportLinterAuditor.PluginId], + }, + new ConfigurationBuilder().Build(), + NullLogger.Instance); + + var plugins = loader.DiscoverPlugins(); + Assert.Contains(plugins, p => p.PluginId == ImportLinterAuditor.PluginId); + + var tool = Assert.Single(loader.GetEnabledPluginTools()); + Assert.Equal("lint-imports", tool.Binary); + // Verify-only by design: import-linter ships via pip/pipx; no distro + // apt package carries a version pin. + Assert.Null(tool.AptPackage); + + var contributions = PluginBaselineProvisioning.BuildContributions(loader.GetEnabledPluginTools()); + var verification = Assert.Single(contributions.VerificationCommands); + Assert.Contains("lint-imports", string.Join(" ", verification.Argv), StringComparison.Ordinal); + Assert.Empty(contributions.InstallCommands); + } + + [Fact] + [Trait("requires_lint_imports", "true")] + public async Task RealLintImports_BrokenContract_YieldsFinding_WithRuleIdAndLocation() + { + var installed = InstalledImportLinterVersion; + var pythonPath = ImportLinterSitePackages; + if (installed is null || pythonPath is null) + return; + + var fixtureDir = await SeedImportLinterFixtureRepoAsync(broken: true); + + try + { + var provider = new ProcessSandboxProvider(NullLogger.Instance); + await using var sandbox = await provider.CreateAsync( + new SandboxSpec + { + ImageReference = "ignored", + Environment = new Dictionary { ["PYTHONPATH"] = pythonPath }, + WorkingDirectory = "/work", + Mounts = [new SandboxMount { SandboxPath = "/work", HostPath = fixtureDir }], + }, + CancellationToken.None); + + var auditor = new ImportLinterAuditor(); + await auditor.InitializeAsync( + BuildPluginContext(new Dictionary + { + ["Scoped:ExpectedVersion"] = installed, + }), + CancellationToken.None); + + var result = await ((IAuditor)auditor).RunAsync( + sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.False(result.Passed); + var finding = Assert.Single(result.Findings); + Assert.Equal(AuditSeverity.Error, finding.Severity); + Assert.Equal("No low in high", BrokenRuleId(finding)); + Assert.Equal("mypkg/high:1", finding.Location); + } + finally + { + TryDeleteDirectory(fixtureDir); + } + } + + [Fact] + [Trait("requires_lint_imports", "true")] + public async Task RealLintImports_CleanFixture_Passes_AndWritesNoCache() + { + var installed = InstalledImportLinterVersion; + var pythonPath = ImportLinterSitePackages; + if (installed is null || pythonPath is null) + return; + + var fixtureDir = await SeedImportLinterFixtureRepoAsync(broken: false); + + try + { + var provider = new ProcessSandboxProvider(NullLogger.Instance); + await using var sandbox = await provider.CreateAsync( + new SandboxSpec + { + ImageReference = "ignored", + Environment = new Dictionary { ["PYTHONPATH"] = pythonPath }, + WorkingDirectory = "/work", + Mounts = [new SandboxMount { SandboxPath = "/work", HostPath = fixtureDir }], + }, + CancellationToken.None); + + var auditor = new ImportLinterAuditor(); + await auditor.InitializeAsync( + BuildPluginContext(new Dictionary + { + ["Scoped:ExpectedVersion"] = installed, + }), + CancellationToken.None); + + var result = await ((IAuditor)auditor).RunAsync( + sandbox, "/work", FakeContext(), CancellationToken.None); + + Assert.True(result.Passed); + Assert.Empty(result.Findings); + Assert.False(Directory.Exists(Path.Combine(fixtureDir, ".import_linter_cache"))); + } + finally + { + TryDeleteDirectory(fixtureDir); + } + } + + [Fact] + [Trait("requires_lint_imports", "true")] + public async Task RealLintImports_MissingConfig_IsInfrastructureFailure() + { + var installed = InstalledImportLinterVersion; + var pythonPath = ImportLinterSitePackages; + if (installed is null || pythonPath is null) + return; + + var fixtureDir = Path.Combine( + Path.GetTempPath(), "codeybox-il-fixture-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(fixtureDir); + + try + { + var provider = new ProcessSandboxProvider(NullLogger.Instance); + await using var sandbox = await provider.CreateAsync( + new SandboxSpec + { + ImageReference = "ignored", + Environment = new Dictionary { ["PYTHONPATH"] = pythonPath }, + WorkingDirectory = "/work", + Mounts = [new SandboxMount { SandboxPath = "/work", HostPath = fixtureDir }], + }, + CancellationToken.None); + + var auditor = new ImportLinterAuditor(); + await auditor.InitializeAsync( + BuildPluginContext(new Dictionary + { + ["Scoped:ExpectedVersion"] = installed, + }), + CancellationToken.None); + + // No .importlinter/pyproject.toml/setup.cfg: exit 1 without a + // report — infrastructure, never a pass. + await Assert.ThrowsAsync( + () => ((IAuditor)auditor).RunAsync(sandbox, "/work", FakeContext(), CancellationToken.None)); + } + finally + { + TryDeleteDirectory(fixtureDir); + } + } + + private static string? BrokenRuleId(AuditFinding finding) + => finding.Description + .Split('\n') + .FirstOrDefault(l => l.StartsWith("Rule: ", StringComparison.Ordinal))?[6..]; + + private static string PluginAssemblyPath() + { + var path = Path.Combine(AppContext.BaseDirectory, "CodeyBox.ImportLinterAuditorPlugin.dll"); + Assert.True(File.Exists(path), $"Plugin assembly not found at '{path}'."); + return path; + } + + private static PluginContext BuildPluginContext(IReadOnlyDictionary scopedValues) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(scopedValues) + .Build(); + return new PluginContext( + HostApiVersion: "1.0", + PluginId: ImportLinterAuditor.PluginId, + PluginDisplayName: "CodeyBox: Import Linter Architecture Contracts", + Host: new TestPluginHost(config.GetSection("Scoped"))); + } + + private static SandboxExecResult Ok(SandboxExec exec) + => IsVersionProbe(exec) + ? new SandboxExecResult(0, "import-linter " + InstalledVersion2Part + "\n", "") + : new SandboxExecResult(0, "", ""); + + private const string InstalledVersion2Part = "2.15"; // probe output for the 2.15.0 pin + + private static bool IsPresenceProbe(SandboxExec exec) + => exec.Argv.Count >= 3 + && exec.Argv[0] == "sh" + && exec.Argv[1] == "-c" + && exec.Argv[2].Contains("command -v", StringComparison.Ordinal) + && exec.Argv.Contains("lint-imports", StringComparer.Ordinal); + + private static bool IsVersionProbe(SandboxExec exec) + => exec.Argv.Count == 2 && exec.Argv[0] == "lint-imports" && exec.Argv[1] == "--version"; + + private static async Task SeedImportLinterFixtureRepoAsync(bool broken) + { + var dir = Path.Combine( + Path.GetTempPath(), "codeybox-il-fixture-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(Path.Combine(dir, "mypkg", "high")); + Directory.CreateDirectory(Path.Combine(dir, "mypkg", "low")); + + await File.WriteAllTextAsync(Path.Combine(dir, "mypkg", "__init__.py"), ""); + await File.WriteAllTextAsync( + Path.Combine(dir, "mypkg", "high", "__init__.py"), + broken ? "import mypkg.low\n" : ""); + await File.WriteAllTextAsync(Path.Combine(dir, "mypkg", "low", "__init__.py"), ""); + await File.WriteAllTextAsync( + Path.Combine(dir, ".importlinter"), + """ + [importlinter] + root_package = mypkg + contracts = + forbidden + + [importlinter:contract:forbidden] + name = No low in high + type = forbidden + source_modules = + mypkg.high + forbidden_modules = + mypkg.low + """); + + return dir; + } + + private static readonly string? ImportLinterSitePackages = ProbeImportLinterSitePackages(); + + /// + /// Directory containing the importlinter package (its site-packages + /// root). A pip --user install lands under ~/.local, which the process + /// sandbox's remapped HOME hides — the real tests pass it as PYTHONPATH + /// so the console script resolves its package regardless of HOME. + /// + private static string? ProbeImportLinterSitePackages() + { + try + { + var psi = new ProcessStartInfo + { + FileName = "python3", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("-c"); + psi.ArgumentList.Add("import importlinter, os, sys; sys.stdout.write(os.path.dirname(os.path.dirname(importlinter.__file__)))"); + using var process = Process.Start(psi)!; + var stdout = process.StandardOutput.ReadToEnd(); + if (!process.WaitForExit(milliseconds: 10_000)) + { + try { process.Kill(); } catch { /* best-effort probe teardown */ } + return null; + } + return process.ExitCode == 0 && Directory.Exists(stdout) ? stdout : null; + } + catch + { + return null; + } + } + + private static string? ProbeInstalledVersion() + { + try + { + var psi = new ProcessStartInfo + { + FileName = "lint-imports", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("--version"); + using var process = Process.Start(psi)!; + var stdout = process.StandardOutput.ReadToEnd(); + if (!process.WaitForExit(milliseconds: 10_000)) + { + try { process.Kill(); } catch { /* best-effort probe teardown */ } + return null; + } + var match = Regex.Match(stdout, @"\d+\.\d+(?:\.\d+)*"); + if (process.ExitCode != 0 || !match.Success) + return null; + // Reported "2.15" normalises to the three-part "2.15.0" pin form. + return match.Value.Split('.').Length == 2 ? match.Value + ".0" : match.Value; + } + catch + { + return null; + } + } + + private static void TryDeleteDirectory(string path) + { + try { Directory.Delete(path, recursive: true); } + catch { /* best-effort fixture cleanup */ } + } + + private static AuditContext FakeContext() => + new(WorkItemId.New(), "feature", "main", 1, "do x"); + + private sealed class TestPluginHost(IConfigurationSection scoped) : IPluginHost + { + public Microsoft.Extensions.Logging.ILogger Logger { get; } = NullLogger.Instance; + public IConfigurationSection ScopedConfig { get; } = scoped; + } + + private sealed class FakeSandbox( + Func> onExec) : ISandbox + { + public string Id => "fake"; + + public async Task ExecAsync(SandboxExec exec, CancellationToken ct = default) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + return await onExec(exec, ct); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +}