Skip to content
Open
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
30 changes: 25 additions & 5 deletions crates/cofferdam-cli/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,11 +748,31 @@ pub fn run_plugins_with_sources(
.collect();
(line_views, None)
}
// Rust, unrecognised: no whole-file parse for
// plugins today (matches the engine's catch-all —
// no built-in check declares Rust for the plugin
// surface yet).
Language::Rust => (Vec::new(), None),
// Rust: no plugin-facing AST wire builder yet (matches
// the engine's catch-all — no built-in check declares
// Rust for the plugin surface yet), but a Pattern-A
// line-scan check scoped to `.rs` needs real line
// text/spans the same way Astro does (CD-93) —
// `Lines::plain` gives unclassified (all flags false)
// line views rather than silently iterating zero lines.
Language::Rust => {
let line_views = cofferdam_core::Lines::plain(text)
.map(|lv| ManifestLineView {
line_no: lv.line_no,
text: lv.text.to_string(),
is_comment: false,
is_doc_comment: false,
is_string_literal: false,
string_literal_ranges: Vec::new(),
is_jsx_text: false,
is_pragma: false,
is_tag: false,
is_text: false,
line_start: lv.line_start,
})
.collect();
(line_views, None)
}
};
// Resolve layer membership for this file. `None` → JSON `null`.
let layer: Option<String> = layers_cfg
Expand Down
81 changes: 81 additions & 0 deletions crates/cofferdam-cli/tests/baseline_workspace_subdir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! Regression test for CD-105: `cofferdam check` reported every baselined
//! finding as new immediately after `baseline write`, with no code changes
//! in between.
//!
//! Root cause: `baseline::normalize_path` compared the freshly-discovered
//! `issue.file` against the project root using a plain `Path::strip_prefix`
//! on whatever raw form each side happened to be in. In an npm-workspaces
//! monorepo, `baseline write` is commonly run from the repo root (`roots =
//! ["."]`, so `issue.file` comes back relative, e.g. `pkg/src/a.ts`) while
//! `check` is run per-workspace from a package subdirectory (`roots =
//! ["."]` relative to that subdir, so `issue.file` comes back as `src/a.ts`)
//! with the shared root baseline auto-discovered by walking up from cwd.
//! The stored `pkg/src/a.ts` and the freshly computed `src/a.ts` never
//! string-match, so every finding looks new even though nothing changed.

use std::path::PathBuf;
use std::process::Command;

fn cofferdam_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_cofferdam"))
}

#[test]
fn check_from_workspace_subdir_matches_baseline_written_from_repo_root() {
let dir = tempfile::TempDir::new().expect("temp dir");
let root = dir.path();

std::fs::create_dir_all(root.join("pkg/src")).expect("mkdir pkg/src");
std::fs::write(
root.join("pkg/src/a.ts"),
"export function f() { if (x == 1) { return 1; } }\n",
)
.expect("write a.ts");

// Baseline is written once from the repo root, covering the whole tree.
let write_out = Command::new(cofferdam_bin())
.args(["baseline", "write"])
.current_dir(root)
.output()
.expect("spawn baseline write");
assert!(
write_out.status.success(),
"baseline write should succeed; stderr={}",
String::from_utf8_lossy(&write_out.stderr)
);

let baseline_path = root.join(".cofferdam/baseline.json");
assert!(baseline_path.is_file(), "baseline.json must be written");
let baseline_contents = std::fs::read_to_string(&baseline_path).expect("read baseline");
assert!(
baseline_contents.contains("Warning.TripleEquals"),
"sanity check: expected a TripleEquals finding in the baseline; got:\n{baseline_contents}"
);

// `check` runs from the package subdirectory (the npm-workspaces
// per-package script pattern), with no explicit --baseline flag — it
// must auto-discover the repo-root baseline by walking up.
let check_out = Command::new(cofferdam_bin())
.args(["check", "--format=json", "."])
.current_dir(root.join("pkg"))
.output()
.expect("spawn check");

let stdout = String::from_utf8_lossy(&check_out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"check stdout not valid JSON: {e}\nstdout={stdout}\nstderr={}",
String::from_utf8_lossy(&check_out.stderr)
)
});

let baselined_count = v["summary"]["baselined"].as_u64().unwrap_or(0);
let new_count = v["summary"]["new"].as_u64().unwrap_or(0);

assert!(
baselined_count > 0 && new_count == 0,
"findings present at baseline-write time must be suppressed as baselined when \
`check` runs from a workspace subdirectory with the baseline auto-discovered \
from the repo root; got new={new_count} baselined={baselined_count}\nstdout={stdout}"
);
}
106 changes: 106 additions & 0 deletions crates/cofferdam-cli/tests/html_template_parse_error_severity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Regression test for CD-101: `Warning.ParseError` from the HTML adapter's
//! recovered-parse path (tree-sitter-html hit ERROR/MISSING nodes) fired at
//! `Critical` severity on legitimate ERB/EJS `<% %>` scriptlets and Jinja
//! nested-double-quote attribute idioms — template-source constructs that
//! are only invalid as raw, unrendered HTML. Downgraded to `Low` so it
//! surfaces for visibility without failing CI at the default
//! `--fail-on=medium`.

use std::path::PathBuf;
use std::process::Command;

fn cofferdam_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_cofferdam"))
}

fn parse_error_findings(root: &std::path::Path, filename: &str) -> serde_json::Value {
let out = Command::new(cofferdam_bin())
.args(["check", "--no-baseline", "--format=json", filename])
.current_dir(root)
.output()
.expect("spawn cofferdam");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"cofferdam stdout not valid JSON: {e}\nstdout={stdout}\nstderr={}",
String::from_utf8_lossy(&out.stderr)
)
});
v
}

fn assert_low_severity_parse_error(v: &serde_json::Value, label: &str) {
let findings = v["findings"].as_array().expect("findings array");
let parse_errors: Vec<&serde_json::Value> = findings
.iter()
.filter(|f| f["id"].as_str() == Some("Warning.ParseError"))
.collect();
assert!(
!parse_errors.is_empty(),
"{label}: expected a Warning.ParseError finding (tree-sitter-html has no grammar \
for this template idiom); findings={findings:?}"
);
for f in &parse_errors {
assert_eq!(
f["severity"].as_str(),
Some("low"),
"{label}: HTML adapter recovered-parse Warning.ParseError must be Low severity \
(template-source false alarm, not a real bug); got {f:?}"
);
}
}

/// Root cause 1 (CD-101): ERB/EJS `<% %>` scriptlet — the leading `<` in
/// `<%` is indistinguishable from a malformed tag-open to an HTML-only
/// grammar.
#[test]
fn erb_style_scriptlet_reports_low_severity_parse_error() {
let dir = tempfile::TempDir::new().expect("temp dir");
std::fs::write(
dir.path().join("index.html"),
"<html><body><% if (user) { %><p>Hi <%= user.name %></p><% } %></body></html>\n",
)
.expect("write html");

let v = parse_error_findings(dir.path(), "index.html");
assert_low_severity_parse_error(&v, "ERB/EJS scriptlet");
}

/// Root cause 2 (CD-101): Jinja nested-double-quote attribute idiom, e.g.
/// `action="{{ url_for("tasks.add") }}"` — genuinely invalid standalone
/// HTML (unescaped `"` inside a double-quoted attribute value), only valid
/// after Jinja renders it away.
#[test]
fn jinja_nested_quote_attribute_reports_low_severity_parse_error() {
let dir = tempfile::TempDir::new().expect("temp dir");
std::fs::write(
dir.path().join("index.html"),
"<html><body><form action=\"{{ url_for(\"tasks.add\") }}\"></form></body></html>\n",
)
.expect("write html");

let v = parse_error_findings(dir.path(), "index.html");
assert_low_severity_parse_error(&v, "Jinja nested-quote attribute");
}

/// A clean, fully-rendered HTML file must never trip `Warning.ParseError`
/// at all — sanity check that the adapter isn't just downgrading severity
/// blindly on every file.
#[test]
fn clean_html_reports_no_parse_error() {
let dir = tempfile::TempDir::new().expect("temp dir");
std::fs::write(
dir.path().join("index.html"),
"<html lang=\"en\"><body><p>Hello</p></body></html>\n",
)
.expect("write html");

let v = parse_error_findings(dir.path(), "index.html");
let findings = v["findings"].as_array().expect("findings array");
assert!(
!findings
.iter()
.any(|f| f["id"].as_str() == Some("Warning.ParseError")),
"clean HTML must not report a parse error; findings={findings:?}"
);
}
161 changes: 161 additions & 0 deletions crates/cofferdam-cli/tests/orphan_js_extension_resolution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Regression test for `Design.OrphanExport` false-positives on TypeScript's
//! NodeNext/ESM `.js`-extension import convention (CD-104).
//!
//! Under `"module": "NodeNext"` / `"moduleResolution": "NodeNext"`, Node's ESM
//! loader requires import specifiers to carry the *compiled output*
//! extension (`.js`) even though the source file on disk is `.ts` — e.g.
//! `import { x } from "./foo.js"` where only `foo.ts` exists. Without
//! resolving `.js` specifiers against sibling `.ts`/`.tsx` files, every such
//! import fails to resolve and the target's export is reported orphan.

use std::path::PathBuf;
use std::process::Command;

fn cofferdam_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_cofferdam"))
}

fn run_check(root: &std::path::Path) -> (serde_json::Value, String) {
let out = Command::new(cofferdam_bin())
.args(["check", "--no-baseline", "--format=json", "."])
.current_dir(root)
.output()
.expect("spawn cofferdam");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"cofferdam stdout not valid JSON: {e}\nstdout={stdout}\nstderr={}",
String::from_utf8_lossy(&out.stderr)
)
});
(v, stdout)
}

fn orphan_files(v: &serde_json::Value) -> Vec<String> {
v["findings"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|f| f["id"].as_str() == Some("Design.OrphanExport"))
.filter_map(|f| f["file"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}

fn write_genuine_orphan(root: &std::path::Path) {
std::fs::create_dir_all(root.join("lib")).ok();
std::fs::write(
root.join("lib").join("dead.ts"),
"export function dead() { return 0; }\n",
)
.expect("write dead");
}

/// The exact CD-104 repro: a same-directory relative import written with a
/// `.js` specifier against a `.ts` file on disk, under NodeNext resolution.
#[test]
fn orphan_export_not_flagged_for_js_extension_relative_import() {
let dir = tempfile::TempDir::new().expect("tempdir");
let root = dir.path();

std::fs::write(
root.join("package.json"),
r#"{"name": "repro", "type": "module"}"#,
)
.expect("write package.json");

std::fs::write(
root.join("tsconfig.json"),
r#"{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}"#,
)
.expect("write tsconfig");

std::fs::write(
root.join("difficulty.ts"),
"export function deriveDifficultyProfile() { return 1; }\n",
)
.expect("write difficulty.ts");

std::fs::write(
root.join("aiTactics.ts"),
"import { deriveDifficultyProfile } from \"./difficulty.js\";\nconsole.log(deriveDifficultyProfile());\n",
)
.expect("write aiTactics.ts");

write_genuine_orphan(root);
let (v, stdout) = run_check(root);
let orphans = orphan_files(&v);

assert!(
orphans.iter().any(|f| f.contains("dead")),
"expected lib/dead.ts to be orphan — confirms OrphanExport is running.\n\
orphans={orphans:?}"
);

assert!(
!orphans.iter().any(|f| f.contains("difficulty")),
"deriveDifficultyProfile is imported via \"./difficulty.js\" from aiTactics.ts \
(NodeNext .js-extension convention against a .ts file on disk) — must not be orphan.\n\
Got OrphanExport on: {orphans:?}\nstdout={stdout}"
);
}

/// Same convention one directory level down, and with a `.tsx` target, to
/// confirm the extension-alias fallback isn't limited to same-dir `.ts`.
#[test]
fn orphan_export_not_flagged_for_js_extension_nested_tsx_import() {
let dir = tempfile::TempDir::new().expect("tempdir");
let root = dir.path();

std::fs::write(
root.join("package.json"),
r#"{"name": "repro", "type": "module"}"#,
)
.expect("write package.json");

std::fs::write(
root.join("tsconfig.json"),
r#"{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx"
}
}"#,
)
.expect("write tsconfig");

std::fs::create_dir_all(root.join("components")).expect("mkdir components");
std::fs::write(
root.join("components").join("Widget.tsx"),
"export function Widget() { return null; }\n",
)
.expect("write Widget.tsx");

std::fs::write(
root.join("main.ts"),
"import { Widget } from \"./components/Widget.js\";\nconsole.log(Widget);\n",
)
.expect("write main.ts");

write_genuine_orphan(root);
let (v, stdout) = run_check(root);
let orphans = orphan_files(&v);

assert!(
orphans.iter().any(|f| f.contains("dead")),
"expected lib/dead.ts to be orphan — sanity check. orphans={orphans:?}"
);

assert!(
!orphans.iter().any(|f| f.contains("Widget")),
"Widget is imported via \"./components/Widget.js\" (NodeNext convention against \
a .tsx file on disk) — must not be orphan.\nGot OrphanExport on: {orphans:?}\nstdout={stdout}"
);
}
Loading
Loading