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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver

## [Unreleased]

Fixes from running JevGate on nine widely used projects under daily development (rtk, headroom, paperclip, hermes-agent, cc-switch, freellmapi, herdr, multica, OmniRoute).

- Two crashes on text outside ASCII: a colon right after non-ASCII text in documentation (`已移除:` before a code span) and a Python test ending in a multi-byte character. Both stopped the whole run, on herdr, hermes-agent and OmniRoute.
- Staleness: a path with an anchor in a code span, such as `docs/en/env/01-variables.md#idempotency`, names its file; it was reported as missing although the file and its heading exist. So does a module path without its extension, such as dify's `web/test/i18n-mock` for `i18n-mock.ts`. A `make` or `just` target is checked only when the repository tracks a Makefile or justfile: openclaw documents `make routing-isolation` from a separate models repository. A package's own binaries (`bin` in `package.json`) count as scripts `pnpm` can run, such as n8n's `n8n-generate-translations`. No corpus finding changes.
- Documentation: the docs of one release, in a directory named like a version (`docs/versions/0.7.5`, `v1.2`) or under `versioned_docs`, are left out as frozen copies. herdr keeps its website docs per release, and 137 of its 147 documentation considers named a section of such a copy. So are one language's copy of the docs under `i18n`, `l10n`, `locales` or `translations` (`docs/i18n/ja/`, Docusaurus's `i18n/zh-Hans/`): translations whose stale links are the original's. OmniRoute keeps its docs in 30 languages, and 543 of its 547 staleness considers repeated an original's finding in a translation. No corpus finding changes.
- Duplication: two documents whose paths name different locales (`docs/en` and `docs/zh-cn`, `README.md` and `README_zh.md`) or whose prose is in different scripts are translations; their sections are asked only whether they disagree, as when the translation question says so. freellmapi, cc-switch and rtk had 12 translated pairs reported as repetition, the translation question answering 0.04 to 0.71 on them. No corpus finding pairs two languages.
- Security findings on the hot projects were mostly wrong in the same few ways, and each is now asked the one thing that decided it, only after the finding. On the corpus's 55 projects with labeled security findings, 15 findings labeled wrong and 1 debatable are notes, for 1 labeled right; nothing changed on the held-out projects. The run asked about $0.02 of new questions.
- Injection: an SQL, command or code finding is asked what its values can hold where they enter the text; fixed clauses a key selects (freellmapi's `ORDER BY` from a map of presets), parsed ids (multica's option UUIDs), the program's own names or a query the sender may run anyway make it a note. The 23 such corpus findings labeled right put at most 0.12 on those, one labeled wrong 0.82.
- Injection: a markup consider on the function's parameters is also asked what its values hold where they enter the markup, as markup reviews already were; text escaped before (a syntax highlighter's output), typed values or the program's own markup make it a note. 11 labeled wrong and 1 labeled right became notes; the other 44 labeled right put at most 0.47 there.
- Unsafe settings: a finding the escaping check raised is asked what the unescaped HTML holds; markup a library built from escaped text, or that ships with the program (freellmapi's highlighted code, cc-switch's bundled provider icons, multica's KaTeX output), makes it a note. The three corpus findings labeled right put at most 0.18 there.
- Injection: where a finding's URLs come from, asked before only while the URL check was undecided, is asked whenever it is not clear; a host written in the code or configuration, with only ids in the path or query, clears it at 0.80. paperclip's cloud route, a fixed path on its configured origin, is no longer a review; on the corpus no review or consider changed and 47 notes on URLs of fixed or configured hosts cleared.
- Sensitive data: a logging finding whose value is the output the person asked for, such as rtk's `env` command or a CLI printing a new token, is a note (two labeled wrong at 0.96 and 0.98, twelve labeled right at most 0.43). An error-detail finding is asked who reads the error text, with the opening of the root README: when, at 0.80, only the operator, the project's own services or the person running it on their own machine read it, it is a note. That cleared headroom's 24 local-proxy reviews and 6 of multica's daemon endpoints, while multica's handlers for its hosted service's users stay reviews; on the corpus it cleared 2 labeled wrong or debatable and none of the 51 labeled right, which put at most 0.46 there. The README opening is sent only with this question, bounded by the upload patterns.

## [0.24.1] - 2026-09-27

- File organization: in Rust, a function another file passes by path, such as `compose::unconfirmed_units` in `follow_ups(plan, files, compose::unconfirmed_units)`, counts as used by that file, so a file's outline names the files that use each member (`used_by`). JevGate's own `compose.rs`, before it was split, listed `follow_ups.rs` as the user of 3 of its 8 follow-up selectors, and the file stayed clear; with all 8 listed, its outline is a consider (0.80). Only outlines whose members are passed by path change: on the 26 Rust projects of the corpus, 37 outline requests were asked again (under $0.01), one wrong review (a SpacetimeDB conversion module, one job laid out in sections) is a consider, one right consider (zoxide's `util.rs`, a grab bag of helpers) is a review, and two units are undecided; nothing else changed. Other languages are unchanged.
Expand Down
1 change: 1 addition & 0 deletions site/src/privacy-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- **What is uploaded:** only the selected units of source, bounded by `upload_allow` and `upload_deny`. `--dry-run --show-requests` prints every initial request body without credentials or network access.
- **Instruction files:** uploaded only when a documentation rule is selected, and still bounded by the upload patterns.
- **README opening:** a sensitive-data finding about error details is asked who reads the error text, with the first 1,200 characters of the root README's prose (images, badges and HTML left out), unless `upload_deny` covers the README or `upload_allow` leaves it out.
- **Credentials:** a check reads `TYPESAFE_API_KEY` from the environment, then `--env-file` or the repository's `.env`, then the key saved by `jevgate auth login` (OS credential store, or an owner-only file). The key is never printed or written to reports.
- **Cost:** every run prints its input tokens and an estimated cost. Cached answers cost nothing.
- **Secrets:** out of scope on purpose, because judging secrets would mean uploading them. Use a local secret scanner.
6 changes: 6 additions & 0 deletions src/analysis/test_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,12 @@ mod tests {
.collect()
}

#[test]
fn a_test_ending_in_a_wide_character_is_located() {
let source = "import pytest\n\n\ndef test_price():\n café = 1\n assert café\n";
assert_eq!(located("tests/test_price.py", source), [(4, 6)]);
}

#[test]
fn phpunit_methods_and_pest_calls_are_test_cases() {
let phpunit = "<?php\nnamespace Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class TotalTest extends TestCase\n{\n private function rows(): array { return [1]; }\n\n public function testAdds(): void\n {\n $this->assertSame(3, total([1, 2]));\n }\n\n /** @test */\n public function it_is_empty(): void\n {\n $this->assertSame(0, (new Summer())->total([]));\n }\n\n #[Test]\n public function keeps_order(): void {}\n}\n\nclass Helper { public function testLike() {} }\n";
Expand Down
10 changes: 5 additions & 5 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,15 @@ pub fn rule_version(key: &str) -> &'static str {
SHARED_LOGIC => "22",
TEST_VALUE => "7",
TEST_REDUNDANCY => "4",
INJECTION => "12",
SENSITIVE_DATA => "8",
INJECTION => "13",
SENSITIVE_DATA => "9",
HARDCODED_VALUES => "8",
UNSAFE_SETTINGS => "6",
UNSAFE_SETTINGS => "7",
AGENT_CONTEXT => "3",
COMMENTS => "3",
LARGE_DOCS => "3",
LARGE_DOCS => "4",
ACCESS_CONTROL => "4",
DOC_STALENESS | DOC_DUPLICATION => "3",
DOC_STALENESS | DOC_DUPLICATION => "4",
WORKFLOWS => "2",
_ => "1",
}
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ impl ConfigContext {
args.include_tests |= self.config.include_tests;
args.model = args.model.take().or_else(|| self.config.model.clone());
args.cache_ttl_secs = args.cache_ttl_secs.or(self.config.cache_ttl_secs);
args.project = crate::docs::project_opening(
&self.root,
&crate::boundary::Boundary::new(&self.config)?,
);
self.configure_rules(args)?;
self.configure_gate(args)?;
self.configure_budgets(args)
Expand Down
54 changes: 52 additions & 2 deletions src/docs/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,16 @@ fn project_doc(path: &Path) -> bool {
RECORD_STEMS.contains(&d.as_str())
|| matches!(
d.as_str(),
"fixtures" | "__fixtures__" | "testdata" | "__snapshots__" | "archive" | "_build"
"fixtures"
| "__fixtures__"
| "testdata"
| "__snapshots__"
| "archive"
| "_build"
| "versioned_docs"
)
});
|| release_dir(d)
}) || translation_dir(&dirs);
let documentation = dirs.is_empty()
|| stem.starts_with("readme")
|| stem.starts_with("contributing")
Expand All @@ -125,6 +132,37 @@ fn project_doc(path: &Path) -> bool {
&& !RECORD_STEMS.contains(&stem.as_str())
}

/// Whether a document sits in one language's copy of the docs, such as
/// `docs/i18n/am/` or Docusaurus's `i18n/zh-hans/`: a translation, whose
/// stale links and repetition are the original's. OmniRoute keeps its docs
/// in 30 languages, and 543 of its 547 staleness considers repeated an
/// original's finding in a translation.
fn translation_dir(dirs: &[String]) -> bool {
dirs.windows(2).any(|w| {
matches!(w[0].as_str(), "i18n" | "l10n" | "locales" | "translations") && locale_code(&w[1])
})
}

/// A language code, optionally with a script or region, such as `ja`,
/// `zh-hans` or `pt_br`.
fn locale_code(dir: &str) -> bool {
let (language, region) = dir.split_once(['-', '_']).unwrap_or((dir, ""));
(2..=3).contains(&language.len())
&& language.chars().all(|c| c.is_ascii_lowercase())
&& (region.is_empty()
|| (2..=4).contains(&region.len()) && region.chars().all(|c| c.is_ascii_alphanumeric()))
}

/// A directory holding the docs of one release, such as `docs/versions/0.7.5`
/// or `v1.2`: a frozen copy of the current docs, not a second source.
fn release_dir(dir: &str) -> bool {
let number = dir.strip_prefix('v').unwrap_or(dir);
number.contains('.')
&& number
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
}

/// Claude Code skills, commands and subagent definitions: Markdown under
/// `.claude/skills`, `.claude/commands` or `.claude/agents`. A session loads
/// only their descriptions and reads the rest when one is used, so they are
Expand Down Expand Up @@ -331,6 +369,18 @@ mod tests {
("tests/fixtures/readme.md", false),
("docs/changelog/v1.md", false),
("docs/archive/plan.md", false),
("docs/versions/0.7.5/website/agents.mdx", false),
("docs/v1.2/guide.md", false),
("website/versioned_docs/version-2/intro.md", false),
("docs/v2/guide.md", true),
("docs/next/README.md", true),
("docs/i18n/am/CONTRIBUTING.md", false),
("docs/i18n/uk-UA/docs/guide.md", false),
(
"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/intro.md",
false,
),
("docs/i18n/README.md", true),
] {
assert_eq!(project_doc(Path::new(path)), expected, "{path}");
}
Expand Down
45 changes: 45 additions & 0 deletions src/docs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,51 @@ const TEACHING: [&str; 8] = [
"all comments in this project",
];

/// Characters of a README's opening sent as what the project is.
const OPENING_CHARS: usize = 1_200;

/// The opening of the README at the repository root, when the upload
/// boundary permits it: its prose and headings without images, badges or
/// HTML, up to `OPENING_CHARS` characters. Only questions about who reads a
/// program's responses send it.
pub fn project_opening(root: &Path, boundary: &crate::boundary::Boundary) -> Option<String> {
let mut names: Vec<String> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.to_lowercase().starts_with("readme"))
.collect();
// README.md before README.rst or a translated README_zh.md.
names.sort_by_key(|n| (n.len(), !n.to_lowercase().ends_with(".md")));
let name = names.first()?;
if !boundary.permits(Path::new(name)) {
return None;
}
let text = crate::inventory::read_source(&root.join(name), TEACHING_READ_BYTES).ok()?;
let mut opening = String::new();
for line in text.lines() {
let line = line.trim();
let decoration = line.starts_with('<')
|| line.starts_with("![")
|| line.starts_with("[![")
|| line.starts_with("[!")
|| line.starts_with("---");
if line.is_empty() || decoration {
continue;
}
if !opening.is_empty() {
opening.push('\n');
}
opening.push_str(line);
if opening.chars().count() >= OPENING_CHARS {
break;
}
}
let opening: String = opening.chars().take(OPENING_CHARS).collect();
(!opening.is_empty()).then_some(opening)
}

/// Bytes of a README or CONTRIBUTING file read for the teaching phrases.
const TEACHING_READ_BYTES: u64 = 262_144;

Expand Down
119 changes: 118 additions & 1 deletion src/docs/overlap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
//! A section that pairs with two or more others heads a family: its members
//! are asked against it alone, not against each other, so a section repeated
//! in seven quickstarts is six questions, not twenty-one.
use std::collections::{BTreeMap, BTreeSet};
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};

/// A pair is a candidate when this share of the smaller section recurs.
pub const MIN_SHARE: f64 = 0.3;
Expand Down Expand Up @@ -117,6 +120,80 @@ fn sequences(text: &str) -> BTreeSet<String> {
words.windows(3).map(|w| w.join(" ")).collect()
}

/// Locale codes a documentation tree or file name carries, such as `zh-cn`
/// in `docs/zh-cn/` or `zh` in `README_zh.md`.
const LOCALES: &[&str] = &[
"ar", "bg", "bn", "cn", "cs", "da", "de", "el", "en", "en-gb", "en-us", "es", "fa", "fi", "fr",
"he", "hi", "hu", "id", "it", "ja", "jp", "ko", "kr", "ms", "nb", "nl", "no", "pl", "pt",
"pt-br", "pt_br", "ro", "ru", "sv", "th", "tr", "tw", "uk", "vi", "zh", "zh-cn", "zh-hans",
"zh-hant", "zh-tw", "zh_cn", "zh_tw",
];

/// Codes that are also common words at the end of a file name, as in
/// `user_id.md`; they name a locale only as a directory.
const WORD_LOCALES: &[&str] = &["id", "it", "no"];

/// The locale a document's path names, in lower case: a directory such as
/// `docs/ja/`, or the last part of its file name after a dot or underscore,
/// as in `README.zh-CN.md` or `README_zh.md`.
fn locale(path: &Path) -> Option<String> {
let lower = path
.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase();
let mut parts: Vec<&str> = lower.split('/').collect();
let name = parts.pop()?;
if let Some(dir) = parts.iter().find(|d| LOCALES.contains(d)) {
return Some((*dir).to_string());
}
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
['.', '_'].into_iter().find_map(|separator| {
let (_, suffix) = stem.rsplit_once(separator)?;
(LOCALES.contains(&suffix) && !WORD_LOCALES.contains(&suffix)).then(|| suffix.to_string())
})
}

/// Whether two documents are written for readers of different languages:
/// their paths name different locales, not both English (`docs/en/` and
/// `docs/zh-cn/`, `README.md` and `README_zh.md`, `ja/` and `zh/`), or
/// their prose is written in different scripts. A translation repeats its
/// original on purpose: freellmapi, cc-switch and rtk had 12 translated
/// pairs reported as repetition, the translation question, which reads the
/// two texts alone, answering from 0.04 to 0.71. No corpus finding pairs
/// documents of two languages.
pub fn other_language(a: (&Path, &str), b: (&Path, &str)) -> bool {
let english = |l: &Option<String>| {
l.as_deref()
.is_none_or(|l| l == "en" || l.starts_with("en-"))
};
let (x, y) = (locale(a.0), locale(b.0));
(x != y && !(english(&x) && english(&y))) || other_script(a.1, b.1)
}

/// Whether one text's prose is mostly in a script other than Latin, such as
/// Han, Kana, Hangul or Cyrillic, and the other's almost never.
fn other_script(a: &str, b: &str) -> bool {
let (x, y) = (non_latin_share(a), non_latin_share(b));
let (high, low) = if x > y { (x, y) } else { (y, x) };
high >= 0.3 && low < 0.05
}

/// The share of the letters outside program code that are not Latin.
fn non_latin_share(text: &str) -> f64 {
let letters: Vec<char> = outside_code(text)
.chars()
.filter(|c| c.is_alphabetic())
.collect();
if letters.is_empty() {
return 0.0;
}
let other = letters
.iter()
.filter(|c| !c.is_ascii() && !matches!(**c, '\u{00C0}'..='\u{024F}'))
.count();
other as f64 / letters.len() as f64
}

/// A candidate pair: two indexes into the texts and their share.
type Pair = (usize, usize, f64);

Expand Down Expand Up @@ -220,6 +297,46 @@ fn capped(texts: &[Text<'_>], found: Vec<Pair>) -> (Vec<Pair>, usize) {
mod tests {
use super::*;

#[test]
fn documents_for_readers_of_other_languages_are_translations() {
let pair = |a: &str, b: &str| {
other_language(
(Path::new(a), "Install the tool and run it."),
(Path::new(b), "Install the tool and run it."),
)
};
assert!(pair(
"docs/en/api/OVERVIEW.md",
"docs/zh-cn/api/OVERVIEW.md"
));
assert!(pair("README.md", "README_zh.md"));
assert!(pair("README.md", "README.zh-CN.md"));
assert!(pair(
"docs/user-manual/ja/intro.md",
"docs/user-manual/zh/setup.md"
));
assert!(
!pair("docs/en/guide.md", "docs/setup.md"),
"English either way"
);
assert!(!pair("README.zh-CN.md", "docs/next/README.zh-CN.md"));
assert!(
!pair("docs/user_id.md", "docs/guide.md"),
"`id` ends a file name as a word"
);
assert!(!pair("docs/guide.md", "docs/setup.md"));
assert!(other_language(
(
Path::new("docs/proxy.md"),
"## Scheme support\n\nHTTP and SOCKS5 proxies work."
),
(
Path::new("docs/proxy-notes.md"),
"## 协议支持\n\n支持 HTTP 和 SOCKS5 代理。"
),
));
}

#[test]
fn repeated_sections_across_files_pair_up() {
let stack = "The frontend uses React with Vite and Tailwind while the backend runs Hono on Node with Supabase for storage and auth";
Expand Down
Loading
Loading