feat(core): add path-based context rule injection from .qwen/rules/ (#3339)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(core): add path-based context rule injection from .qwen/rules/

Support multiple rule files in `.qwen/rules/` directories with optional
YAML frontmatter for conditional loading based on glob patterns.

Rules with a `paths:` field only load when matching files exist in the
project. Rules without `paths:` always load as baseline rules.

Key behaviors:
- Global rules from ~/.qwen/rules/ always load
- Project rules from <root>/.qwen/rules/ require folder trust
- HTML comments stripped to save tokens
- Files sorted alphabetically for deterministic ordering
- Deduplication when project root equals home directory
- Uses globIterate for early termination on first match

* feat(core): align rules loading with Claude Code reference implementation

Closes three gaps with Claude Code's .claude/rules/ feature:

1. Recursive directory scanning — .qwen/rules/ now supports subdirectories
   like frontend/, backend/ for organized rule hierarchies.

2. Exclusion patterns — new `contextRuleExcludes` config parameter accepts
   glob patterns to skip specific rule files (useful in monorepos with
   other teams' rules).

3. Turn-level lazy loading — conditional rules (with `paths:` frontmatter)
   are no longer injected eagerly at session start. Instead, they are
   stored in a per-session ConditionalRulesRegistry and injected on-demand
   via <system-reminder> when the model reads/edits a matching file
   (read_file, edit, write_file). Each rule is injected at most once per
   session.

Internals:
- loadRules() now returns { content, ruleCount, conditionalRules } — only
  baseline rules flow into the system prompt; conditional rules are
  deferred.
- ConditionalRulesRegistry pre-compiles picomatch matchers for efficiency
  and tracks injected rules to avoid duplicate injection.
- coreToolScheduler.ts injects matched rules after PostToolUse hooks but
  before the tool response is sent to the model.
- Path matching defensively rejects files outside the project root.
- /memory refresh and /directory add keep the registry in sync via
  setConditionalRulesRegistry().

* fix(core): correct field placement in config.test.ts mocks after merge

Earlier replace_all inserted ruleCount/conditionalRules/projectRoot
into the wrong mock call (readAutoMemoryIndex instead of
loadServerHierarchicalMemory), breaking the build with syntax errors.
Move the fields back to the correct mocked return value.

* fix(core): normalize rule display paths to forward slashes for Windows

On Windows, path.relative() returns backslash-separated paths, causing
the "Rule from:" marker to differ from Linux/macOS and breaking the
formats-rules-with-source-markers test on Windows CI.

Normalize to forward slashes for cross-platform consistency, matching
the convention used in glob patterns (paths: field) so that the model
sees the same format regardless of the host OS.

* fix(core): harden rulesDiscovery path checks and sort determinism

Two small defensive improvements surfaced by the audit:

1. matchAndConsume now rejects the exact '..' relative path in addition
   to '../'-prefixed paths. path.relative returns '..' (no trailing
   slash) when the target equals the parent of projectRoot — rare in
   practice but worth guarding against.

2. loadRulesFromDir now uses Array.sort() default (UTF-16 code point
   comparison) instead of localeCompare. The previous sort was
   locale-dependent and could produce different rule loading order on
   machines with non-English locales (e.g. zh-CN). Rule filenames are
   typically ASCII so behaviour is unchanged in common cases, but
   deterministic ordering is preferable across environments.

Adds one test case for the '..' rejection path.

* fix(core): address CodeQL incomplete HTML comment sanitization

stripHtmlComments only matched complete <!-- ... --> pairs in a single
pass, so input like 'A<!-- one --><!-- two -->B<!--unclosed' would
leave a residual '<!--' marker — flagged by CodeQL as
incomplete-multi-character-sanitization.

Not a security issue in our context (the output goes to an LLM system
prompt, not an HTML renderer), but worth fixing to:
 - clear the CodeQL alert in CI
 - avoid token waste from dangling markers
 - produce deterministic output

Strategy: iteratively strip <!-- ... --> pairs until stable, then
remove any residual <!-- markers (leaving the following content
visible since the author probably intended it to appear in the rule).
This commit is contained in:
Shaojin Wen 2026-04-17 22:05:50 +08:00 committed by GitHub
parent 7e83c08062
commit 355ac5d54a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 984 additions and 44 deletions

View file

@ -10,7 +10,10 @@ import { MessageType } from '../types.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
import {
loadServerHierarchicalMemory,
ConditionalRulesRegistry,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
export function expandHomeDir(p: string): string {
@ -147,7 +150,7 @@ export const directoryCommand: SlashCommand = {
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
const { memoryContent, fileCount, conditionalRules, projectRoot } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[
@ -159,9 +162,13 @@ export const directoryCommand: SlashCommand = {
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getContextRuleExcludes(),
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
config.setConditionalRulesRegistry(
new ConditionalRulesRegistry(conditionalRules, projectRoot),
);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(