From c334dd38fd6dcf06ac7d2d6374a2abaa94f89b31 Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:07:49 +0800 Subject: [PATCH] feat: model Cursor activation for declared file context --- CHANGELOG.md | 2 + docs/README.md | 1 + docs/cursor-scope.md | 147 ++++++++++++ docs/limitations.md | 17 ++ docs/simplified/limitations.md | 6 + docs/simplified/user-guide.md | 11 + docs/traditional/limitations.md | 6 + docs/traditional/user-guide.md | 11 + docs/translations.md | 9 + docs/user-guide.md | 21 ++ examples/README.md | 4 + .../cursor-scope/.cursor/rules/backend.mdc | 5 + .../cursor-scope/.cursor/rules/frontend.mdc | 5 + .../cursor-scope/.cursor/rules/review.mdc | 5 + examples/cursor-scope/README.md | 31 +++ scripts/verify_installed_package.py | 63 ++++- src/agent_config_score/cli.py | 39 ++- src/agent_config_score/entrypoint.py | 2 + src/agent_config_score/provenance.py | 2 +- src/agent_config_score/scanner.py | 77 ++++++ src/agent_config_score/scopes.py | 202 ++++++++++++++++ tests/test_cursor_scope.py | 226 ++++++++++++++++++ tests/test_scopes.py | 136 +++++++++++ 23 files changed, 1025 insertions(+), 3 deletions(-) create mode 100644 docs/cursor-scope.md create mode 100644 examples/cursor-scope/.cursor/rules/backend.mdc create mode 100644 examples/cursor-scope/.cursor/rules/frontend.mdc create mode 100644 examples/cursor-scope/.cursor/rules/review.mdc create mode 100644 examples/cursor-scope/README.md create mode 100644 src/agent_config_score/scopes.py create mode 100644 tests/test_cursor_scope.py create mode 100644 tests/test_scopes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 750c495..f855dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- An opt-in, unscored `scope` command predicting root Cursor project-rule activation for explicitly declared file context. A bounded scalar-metadata parser and iterative glob model retain unknowns, source-line evidence and multi-file opposing-directive signals even for disjoint patterns. Includes a runnable synthetic example, installed-wheel acceptance and aligned core guides. It does not observe Cursor, prove semantic conflicts, alter scan/gate scores or ship in the published v0.24.0 artifacts or rolling Action. + - An offline runnable design lab with five synthetic before/after scenarios and two passing controls, comparing baseline new-error gating, score-delta-only gating and explicit candidate-threshold caller overrides through the real CLI. Retains observed decisions, commands and source/scenario hashes without claiming independent accuracy, AI superiority or user adoption. Runtime rules, scores, package artifacts and Action refs are unchanged. - An explicitly manual, read-only GitHub Actions replay of the existing pinned real-repository smoke corpus. The same runner propagates acquisition/mismatch failures and retains logs/results for seven days; it does not execute external repository code, run on every PR or establish independent accuracy/adoption. diff --git a/docs/README.md b/docs/README.md index ed42f88..39a1955 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ read the result, and troubleshoot a failing gate. - [Visual tour](visual-tour.md) - [Instruction templates](../examples/README.md) - [Opt-in deletion safeguard review and completion](guardrail-suggestions.md) (v0.24.0+) +- [Cursor rule activation for declared file context](cursor-scope.md) (unreleased, unscored) - [Repository doctor](doctor.md) - [Optional PR comments](pr-comments.md) - [Score-history workflow](score-history.md) and [local history command](score-history-cli.md) diff --git a/docs/cursor-scope.md b/docs/cursor-scope.md new file mode 100644 index 0000000..4029f5e --- /dev/null +++ b/docs/cursor-scope.md @@ -0,0 +1,147 @@ +# Cursor rule activation from declared file context + +**Unreleased, opt-in and unscored.** `scope` is available in source builds after +v0.24.0, not the published v0.24.0 package or rolling Action. It predicts which +root `.cursor/rules/` project rules attach automatically given explicitly +declared file paths. It does not query Cursor or observe an agent run. + +```bash +agent-config-score scope . --target src/web/app.ts +agent-config-score scope . --target src/web/app.ts --target src/api/service.py --json +``` + +Repeat `--target` for every file assumed in context. Paths are normalized, +deduplicated, sorted and repository-relative. Windows separators are accepted; +absolute, drive-relative, parent-traversal and control-character paths are +rejected. Target existence and contents are not checked: the command can model +a proposed file that has not been created yet. Up to 64 inputs of 1,024 +characters each are accepted. + +## What the profile models + +The `cursor-file-context-v1` profile follows the activation modes described in +the [Cursor rules documentation](https://cursor.com/docs/rules), checked on +2026-11-03. This is a bounded interpretation of that documentation, not a claim +of version-independent equivalence to Cursor's implementation. + +| Recognized metadata | Model mode | Predicted activation | +|---|---|---| +| `alwaysApply: true` | `always` | Active for the declared context; valid scalar description/globs do not restrict it | +| `alwaysApply: false` and nonempty supported globs | `patterns` | Active if any declared file matches any pattern; otherwise inactive | +| `alwaysApply: false`, no globs, nonempty description | `agent_selected` | Unknown: file paths do not determine model selection | +| `alwaysApply: false`, no globs or description | `manual` | Unknown: manual mentions are not observed | +| Unsupported or ambiguous metadata | `unknown` | Unknown, including when no supported pattern matches | +| Plain `.md` under root `.cursor/rules/` | `ignored` | Inactive under the documented project-rule format | + +“Active” means **predicted automatic attachment under this profile**. “Inactive” +means **not triggered by these paths**, not disabled or observed absent. Manual +references and runtime settings can change actual context. User/team rules, +other instruction formats, nested project roots, precedence, and the agent's +interpretation of attached text are not modelled. Directory names or proximity +to a rule do not add an inferred condition beyond its declared globs. + +Globs trigger attachment when matching files enter context. They do not prove +an instruction can only affect those files. If a task includes both frontend +and backend files, both rule sets may attach even with disjoint globs. The +checker evaluates the complete declared file set instead of treating disjoint +patterns as a proof that contradictory text cannot co-activate. + +## Bounded metadata and glob grammar + +The first line may have a UTF-8 BOM and must be `---`. Closing `---` must occur +within the following 128 lines. Only unique, unindented `description`, `globs` +and `alwaysApply` keys, blank lines and comment lines are accepted. Boolean +flags must be unquoted lowercase `true` or `false`; missing flags stay unknown. + +Description/globs accept empty values, plain strings with optional inline +comments, JSON double-quoted strings, or single-quoted strings with doubled +apostrophes. Full YAML is not implemented: lists, blocks, anchors, aliases, +duplicate/unknown keys, multiline values, implicit non-string scalars and +ambiguous syntax stay unknown. Quote numeric-looking intended strings and +glob strings starting with `*`. For `alwaysApply: true`, scalar syntax still +has to be recognized, but the glob language is not evaluated. + +For conditional rules, globs are a comma-separated scalar with OR semantics, +at most 32 patterns of 256 characters each. Every pattern must be supported; +one unsupported pattern makes the rule unknown rather than falsely inactive. + +| Pattern | Meaning in this profile | +|---|---| +| `*.ts` | A root-level filename ending in `.ts` | +| `**/*.ts` | A `.ts` file at any directory depth, including root | +| `src/**` | Files below `src`, including nested directories | +| `src/**/*.tsx` | `.tsx` files at any depth below `src` | +| `tailwind.config.*` | A matching root-level filename | +| `src/**, tests/**` | Either pattern | + +Matching is case-sensitive, includes dot-prefixed components, and treats `/` +as a component boundary. `*` matches within one component; `**` is a whole +component matching zero or more directories. Terminal `**` requires a file +below the prefix, not a path equal to the prefix. Absolute paths, backslashes, +empty/dot/parent components, embedded `**`, `?`, braces, character classes, +negation and extglob syntax are unsupported. Different native matching behavior +or runtime settings may require a future profile revision. + +The engine uses iterative component wildcard dynamic programming and a finite +set of segment states with zero-directory transitions for `**`. It does not +use recursive glob backtracking, enumerate the target filesystem, or attempt +to prove intersection/disjointness of arbitrary pattern languages. + +## Opposing-directive review signals + +For non-inactive rules with an interpretable body, the scanner reuses its +bounded English directive recognizer: for example, `Always use tabs` versus +`Never use tabs`. Recognized directive text is normalized before pairing. +Explicit examples, fences, quotes and comments are excluded using the existing +bounded prose context. Open example sections and HTML-like body markup leave +the body uninterpreted. This is not full Markdown, natural-language reasoning, +or an extraction of every constraint in a document. + +Each pair retains both source paths, original one-based line numbers and +polarity, plus a SHA-256 of the normalized directive. Pair activation is active +only if both rules are predicted active; otherwise it is unknown. The report +includes the union of matched trigger paths and any shared target matches. +An empty shared set does not cancel multi-file co-activation. More than 10,000 +pairs fails with exit `2` instead of emitting a partial clean-looking report. + +These are textual review signals. Two identical opposing phrases could concern +different entities or contain qualifiers the grammar does not understand. +They are not proven semantic conflicts, exploitability, or a safety verdict. +No signals can mean no supported text, an inactive rule, or an uninterpreted +body; inspect the per-rule analysis and summary counts. + +## Report, privacy and failure contract + +JSON has `schema_version: 1`, `profile`, `status: "completed"`, `scored: false`, +the tool identity/source fingerprint, `targets`, `rules`, `opposing_directives` +and `summary`. `observed_agent_context` and `targets_existence_checked` are +always false. `activation_inputs` and `unmodelled_inputs` make the boundary +explicit. Each rule records mode, activation/reason, pattern count, metadata +line numbers, body start, matched targets, directive-analysis status and the +number of recognized directives considered for pairing. Inactive rules have +zero considered directives even when their body contains supported prose. + +Reports retain source paths and supplied target paths. Raw descriptions, glob +values and directive bodies are not exported; hashes are not anonymization or +proof of authenticity. The command does not write reports, modify rules, +execute their instructions, upload files or contact maintainers. + +Normal supported-source discovery and metadata/source preflight still run +across the repository, with existing ignores and skipped directories. An +unrelated unsafe/broken supported instruction link or ignore-file error can +therefore stop analysis. Only exact lowercase `.mdc` files under root +`.cursor/rules/` have their contents read here; other discovered instruction +formats are counted but not interpreted. Unknown extension casing remains +unknown. Existing containment checks and their non-atomic filesystem limits +apply; this is not an isolated selected-file traversal. + +Exit `0` means the advisory analysis completed, including signals and unknowns. +Invalid inputs, unreadable/invalid UTF-8 sources or resource-limit failures use +exit `2` without a successful JSON report. There is no exit `1` conflict gate, +score, policy threshold, suppression handling or pass/fail safety result. + +Normal scan, compare, diff and Action scoring are unchanged. In particular, +the old general contradiction check is not suppressed by this new command. +This is the first activation-model phase, not effective-policy inheritance, +configuration-change impact analysis, or execution-invariant verification. +Try the [runnable synthetic example](../examples/cursor-scope/README.md). diff --git a/docs/limitations.md b/docs/limitations.md index b9c193f..6dcc593 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -82,6 +82,23 @@ this guard does not claim to constrain every filesystem operation in the tool. Non-instruction link cycles are excluded from the path index, not judged safe or semantically correct. Keep repositories stable while scanning. +## Cursor file-context profile (unreleased) + +The opt-in `scope` command predicts automatic rule attachment from declared file +paths for root `.cursor/rules/`. It is unscored and does not observe Cursor, +validate target existence, resolve other formats/precedence or change normal +scan/regression findings. Inactive means not triggered under this profile; +manual/model selection, unsupported metadata and runtime settings remain +unobserved. Disjoint globs can co-activate in a multi-file task. + +Bounded English opposing-directive pairs are review signals with source lines, +not semantic-conflict proofs. Uninterpreted bodies and unknown activation stay +visible; no signals is not a clean bill of health. Completed advisory analysis +returns `0` even with signals; input/read/limit errors use `2`. Whole-repository +supported-source preflight still applies. See the [grammar, report and limits](cursor-scope.md). +This first activation-model phase is not in published v0.24.0 artifacts and +does not implement constraint-impact comparison or execution verification. + ## Command interpretation The `rm-rf`, `git-clean-force`, and `docker-system-prune` rules tokenize bounded diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index 37bf037..815820d 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -42,6 +42,12 @@ v0.24.0+ 提供[工具源码身份(英文)](../report-identity.md),用于 中文文档是可选阅读入口。**它不表示检测规则能理解任意简体或繁体中文语义。** 英文散文规则的跨语言表现尚未得到验证;中文的否定、例子、改写和矛盾仍需要人工审核。 +## Cursor 文件上下文模型(尚未发布) + +可选 `scope` 根据声明的文件路径,预测根目录 `.cursor/rules/` 规则的自动加载。它不计分,不观察 Cursor、不检查目标存在性、不解析其他格式/优先级,也不改变正常扫描和回归发现。inactive 仅表示这些路径未触发规则;手动引用、模型选择、不支持的元数据与运行设置仍未被观察。多个文件可触发不同 glob 的规则共同加载。 + +有限英文语法识别的相反指令对只提供带来源行号的审核信号,不是语义冲突证明。未知加载条件和未解释的正文会保留;没有信号不代表没有问题。分析完成返回 `0`,包括存在信号的情况;输入/读取/限制错误返回 `2`。仍会执行全仓库支持来源预检查。详见[语法、报告与限制(英文)](../cursor-scope.md)。这是加载条件模型的第一阶段,尚未包含在已发布 v0.24.0 中,也未实现约束变更影响比较或执行验证。 + ## 命令与禁止上下文 `rm-rf`、`git-clean-force`、`docker-system-prune` 解析有限 shell 片段的选项,支持部分重排、拆分、长选项、引号内的选项值和显式反斜杠续行。shell 分隔符与 `--` 会停止选项解释。Git 的预览/交互模式排除在危险匹配外;`rm` 后出现的交互选项会覆盖强制模式。排除/过滤选项的值不会当成执行标志。 diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index ebb74eb..bda8d6b 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -126,6 +126,17 @@ agent-config-score guardrails . --file AGENTS.md --template-language simplified 尚未发布的源码可直接选择 `--file`,不先遍历整仓,因此无关指令文件的坏链接不会阻止这次单文件审阅。相关忽略规则和所选路径的祖先目录检查仍生效;目录别名或大小写有歧义的目录须手动接入。其他文件不会因此被验证或修复,普通扫描/doctor 的失败仍须单独处理;追加成功不代表整仓扫描完整或安全。此范围修正不在 v0.24.0 包中。 +## 可选:Cursor 文件上下文分析(尚未发布) + +```bash +agent-config-score scope . --target src/web/app.ts +agent-config-score scope . --target src/web/app.ts --target src/api/service.py --json +``` + +v0.24.0 之后的源码构建可根据你声明在上下文中的逻辑文件路径,预测根目录 `.cursor/rules/` 项目规则的自动加载条件。不会检查目标文件是否存在。手动引用、模型选择和不支持的元数据保留为未知;inactive 仅表示这些路径未触发规则,不代表实际未加载。多个文件可以触发不同 glob 的规则共同加载。 + +这是带来源行号、不计分的审核建议,不是实际 Agent 轨迹或阻断门禁。分析完成返回 `0`,包括存在信号/未知的情况;输入或读取失败返回 `2`。原有扫描和回归评分不变。该命令仍使用全仓库支持来源预检查,无关的失效指令链接也可能导致失败。详见[模型与限制(英文)](../cursor-scope.md)及[可运行的合成示例(英文)](../../examples/cursor-scope/README.md)。已发布的 v0.24.0 包和滚动 Action 不包含此命令。 + ## 可选:面向维护者的具体建议(v0.24.0+) ```bash diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index f63dfd2..6567351 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -42,6 +42,12 @@ v0.24.0+ 提供[工具原始碼身分(英文)](../report-identity.md),用 中文文件是選用的閱讀入口。**它不表示偵測規則能理解任意簡體或繁體中文語意。** 英文敘述規則的跨語言表現尚未驗證;中文的否定、範例、改寫與矛盾仍須人工審查。 +## Cursor 檔案上下文模型(尚未發布) + +選用 `scope` 根據宣告的檔案路徑,預測根目錄 `.cursor/rules/` 規則的自動載入。它不計分,不觀察 Cursor、不檢查目標存在性、不解析其他格式/優先順序,也不改變正常掃描和回歸發現。inactive 僅表示這些路徑未觸發規則;手動引用、模型選擇、不支援的中繼資料與執行設定仍未被觀察。多個檔案可觸發不同 glob 的規則共同載入。 + +有限英文語法識別的相反指令對只提供帶來源行號的審閱訊號,不是語意衝突證明。未知載入條件和未解釋的正文會保留;沒有訊號不代表沒有問題。分析完成回傳 `0`,包括存在訊號的情況;輸入/讀取/限制錯誤回傳 `2`。仍會執行全儲存庫支援來源預先檢查。詳見[語法、報告與限制(英文)](../cursor-scope.md)。這是載入條件模型的第一階段,尚未包含在已發布 v0.24.0 中,也未實現約束變更影響比較或執行驗證。 + ## 命令與禁止上下文 `rm-rf`、`git-clean-force`、`docker-system-prune` 解析有限 shell 片段的選項,支援部分重新排序、拆分、長選項、引號內的選項值與明確反斜線續行。shell 分隔符號與 `--` 會停止解讀選項。Git 的預覽/互動模式排除於危險匹配之外;`rm` 後續的互動選項會覆蓋強制模式。排除/過濾選項的值不會被當作執行旗標。 diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index b850a3a..27f17cc 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -126,6 +126,17 @@ agent-config-score guardrails . --file AGENTS.md --template-language simplified 尚未發布的原始碼可直接選擇 `--file`,不先遍歷整個儲存庫,因此無關指令檔的失效連結不會阻止這次單檔審閱。相關排除規則及所選路徑的祖先目錄檢查仍生效;目錄別名或大小寫有歧義的目錄須手動整合。其他檔案不會因此被驗證或修復,一般掃描/doctor 的失敗仍須另行處理;追加成功不代表整個儲存庫已完整掃描或安全。此範圍修正不在 v0.24.0 套件中。 +## 選用:Cursor 檔案上下文分析(尚未發布) + +```bash +agent-config-score scope . --target src/web/app.ts +agent-config-score scope . --target src/web/app.ts --target src/api/service.py --json +``` + +v0.24.0 之後的原始碼建置可根據你宣告在上下文中的邏輯檔案路徑,預測根目錄 `.cursor/rules/` 專案規則的自動載入條件。不會檢查目標檔案是否存在。手動引用、模型選擇和不支援的中繼資料保留為未知;inactive 僅表示這些路徑未觸發規則,不代表實際未載入。多個檔案可以觸發不同 glob 的規則共同載入。 + +這是帶來源行號、不計分的審閱建議,不是實際 Agent 軌跡或阻斷門禁。分析完成回傳 `0`,包括存在訊號/未知的情況;輸入或讀取失敗回傳 `2`。原有掃描和回歸評分不變。此命令仍使用全儲存庫支援來源預先檢查,無關的失效指令連結也可能導致失敗。詳見[模型與限制(英文)](../cursor-scope.md)及[可執行的合成範例(英文)](../../examples/cursor-scope/README.md)。已發布的 v0.24.0 套件和滾動 Action 不包含此命令。 + ## 選用:面向維護者的具體建議(v0.24.0+) ```bash diff --git a/docs/translations.md b/docs/translations.md index 587a7cc..9391143 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -152,6 +152,15 @@ accuracy, real users or agent behavior. The source-checkout requirement and evidence boundary are synchronized; executable guide examples, runtime behavior, published artifacts, Action refs and frozen research are unchanged. +On 2026-11-03, built on `21105b1f6bacfa0c51f3bb0d0bcc04e920a26d48`, both core +guides and limitations were synchronized for the unreleased Cursor file-context +profile: declared logical paths, automatic attachment predictions, manual/model +unknowns, multi-file co-activation, source-line review signals, unchanged scores, +advisory exit codes and whole-repository source preflight. Executable examples +remain identical in all three guides. The detailed grammar and runnable +synthetic fixture are linked in English; this is not an observed agent run, +constraint-impact engine or published v0.24.0/Action update. + When an English source page changes, review both variants together and record the new source revision here. Preserve executable examples, rule IDs and JSON keys verbatim. Keep the score limitations and failure/exit semantics aligned; diff --git a/docs/user-guide.md b/docs/user-guide.md index cb8ad0a..70cf6a3 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -214,6 +214,27 @@ certified or repaired. Normal scan/doctor failures still need separate attention an append does not turn an incomplete whole-tree scan into a clean result. This scope refinement is not in the v0.24.0 package. +## Optional: Cursor file-context analysis (unreleased) + +```bash +agent-config-score scope . --target src/web/app.ts +agent-config-score scope . --target src/web/app.ts --target src/api/service.py --json +``` + +Source builds after v0.24.0 can predict automatic attachment of root +`.cursor/rules/` project rules for logical file paths you declare in context. +Target existence is not checked. Manual/model selection and unsupported +metadata remain unknown; inactive means not triggered by these paths, not +observed absent. Different globs can co-activate in multi-file tasks. + +This is unscored advice with source-line evidence, not an observed agent run or +blocking gate. Completion returns `0`, including signals/unknowns; input/read +failures return `2`. Existing scan and regression scores remain unchanged. +The command still uses whole-repository supported-source preflight, so unrelated +invalid instruction links can stop it. See the [model and limits](cursor-scope.md) +and [runnable synthetic example](../examples/cursor-scope/README.md). +This command is not in the published v0.24.0 package or rolling Action. + ## Optional: actionable maintainer results (v0.24.0+) ```bash diff --git a/examples/README.md b/examples/README.md index 3015e70..0f2e25e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,10 @@ template, see the [design lab](regression-lab/README.md). It compares five known synthetic before/after scenarios under three explicitly defined gate settings; it is not independent accuracy or adoption evidence. +The separate [Cursor file-context example](cursor-scope/README.md) demonstrates +single-file activation, multi-file co-activation and explicitly unknown model +selection. It needs an unreleased source build and does not change scoring. + These templates provide conservative repository workflow, verification, safety, and handoff guidance without assuming a programming language or package manager. Copy one into the matching path in your repository, then adapt it to your real diff --git a/examples/cursor-scope/.cursor/rules/backend.mdc b/examples/cursor-scope/.cursor/rules/backend.mdc new file mode 100644 index 0000000..1824fd5 --- /dev/null +++ b/examples/cursor-scope/.cursor/rules/backend.mdc @@ -0,0 +1,5 @@ +--- +globs: "src/api/**" +alwaysApply: false +--- +Never use tabs. diff --git a/examples/cursor-scope/.cursor/rules/frontend.mdc b/examples/cursor-scope/.cursor/rules/frontend.mdc new file mode 100644 index 0000000..11c7df5 --- /dev/null +++ b/examples/cursor-scope/.cursor/rules/frontend.mdc @@ -0,0 +1,5 @@ +--- +globs: "src/web/**" +alwaysApply: false +--- +Always use tabs. diff --git a/examples/cursor-scope/.cursor/rules/review.mdc b/examples/cursor-scope/.cursor/rules/review.mdc new file mode 100644 index 0000000..668f905 --- /dev/null +++ b/examples/cursor-scope/.cursor/rules/review.mdc @@ -0,0 +1,5 @@ +--- +description: Review changes that need extra attention. +alwaysApply: false +--- +Explain the reasoning behind the proposed change. diff --git a/examples/cursor-scope/README.md b/examples/cursor-scope/README.md new file mode 100644 index 0000000..bfbade7 --- /dev/null +++ b/examples/cursor-scope/README.md @@ -0,0 +1,31 @@ +# Cursor file-context example + +Requires an unreleased source installation; the published v0.24.0 package does +not include `scope`. These are synthetic, deliberately opposing formatting +instructions, not real user evidence or a recommended instruction template. + +From the repository root: + +```bash +agent-config-score scope examples/cursor-scope --target src/web/app.ts +agent-config-score scope examples/cursor-scope --target src/web/app.ts --target src/api/service.py --json +``` + +The targets are logical repository-relative file paths assumed to be in the +agent's context. They do not need to exist; their contents are never opened. + +The first command predicts frontend **active**, backend **inactive** (not +triggered by the declared path), and review **unknown** (model selection cannot +be inferred from file paths). It produces no opposing-directive signal. + +The second predicts both formatting rules active and reports one opposing pair +at `frontend.mdc:5` and `backend.mdc:5`. Their patterns share no target here, but +both rules can attach in a multi-file task. Disjoint globs are not evidence +that the instructions cannot co-activate. `review.mdc` remains unknown. + +Both commands return `0` for completed advisory analysis, even with signals or +unknowns. Input/read errors return `2`. There is no score or blocking CI gate. +The normal scan still reports its original cross-file contradiction; this +separate profile does not suppress findings or change existing score semantics. + +See the [model, metadata grammar and limitations](../../docs/cursor-scope.md). diff --git a/scripts/verify_installed_package.py b/scripts/verify_installed_package.py index 69adf52..dad398d 100644 --- a/scripts/verify_installed_package.py +++ b/scripts/verify_installed_package.py @@ -43,6 +43,64 @@ def run(command: list[str], cwd: Path, expected: int = 0) -> str: return result.stdout +def verify_cursor_scope(commands: list[list[str]], workspace: Path) -> None: + repo = workspace / "cursor file context" + rules = repo / ".cursor" / "rules" + rules.mkdir(parents=True) + for name, pattern, directive in ( + ("frontend", "src/web/**", "Always use tabs."), + ("backend", "src/api/**", "Never use tabs."), + ): + (rules / f"{name}.mdc").write_text( + f'---\nglobs: "{pattern}"\nalwaysApply: false\n---\n{directive}\n' + "Remember synthetic-private-body-value.\n", encoding="utf-8") + (rules / "manual.mdc").write_text( + "---\ndescription: synthetic-private-description\nalwaysApply: false\n---\nReview this change.\n", + encoding="utf-8") + original = {p.relative_to(repo): p.read_bytes() for p in repo.rglob("*") if p.is_file()} + reference = None + for command in commands: + single = json.loads(run([*command, "scope", ".", "--target", "src/web/app.ts", "--json"], repo)) + require(single["summary"]["active"] == 1 and single["summary"]["inactive"] == 1 + and single["summary"]["unknown"] == 1 and not single["opposing_directives"], + "Declared single-file context selected the wrong Cursor rules") + require(single["scored"] is False and single["observed_agent_context"] is False + and single["targets_existence_checked"] is False and "score" not in single, + "Unscored prediction became an observed context or score") + require("scopes.py" in single["tool"]["source_fingerprint"]["files"], + "Scope model is missing from installed runtime identity") + multi_text = run([*command, "scope", ".", "--target", "src/web/app.ts", + "--target", "src/api/service.py", "--json"], repo) + multi = json.loads(multi_text) + require(multi["summary"]["active_signals"] == 1, "Multi-file context lost an opposing directive pair") + signal = multi["opposing_directives"][0] + require(signal["shared_target_matches"] == [] and len(signal["trigger_targets"]) == 2 + and [s["line"] for s in signal["sources"]] == [5, 5], + "Disjoint patterns lost co-activation or original source lines") + require("synthetic-private" not in multi_text and "Always use tabs" not in multi_text + and "src/web/**" not in multi_text, "Scope report exported raw source values") + if reference is not None: + require((single, multi) == reference, "Scope results differ across installed entrypoints") + reference = single, multi + require(run([*command, "scope", ".", "--target", "../outside", "--json"], repo, expected=2) == "", + "Invalid declared target emitted success JSON") + require({p.relative_to(repo): p.read_bytes() for p in repo.rglob("*") if p.is_file()} == original, + "Read-only scope analysis changed the fixture") + unsupported = rules / "unsupported.mdc" + unsupported.write_text('---\nglobs: "src/*.{ts,tsx}"\nalwaysApply: false\n---\nReview this change.\n', + encoding="utf-8") + unknown = json.loads(run([*commands[0], "scope", ".", "--target", "docs/new.md", "--json"], repo)) + require(unknown["summary"]["unknown"] == 2, "Unsupported globs were assumed inactive") + unsupported.write_bytes(b"private-invalid-utf8-\xff") + require(run([*commands[0], "scope", ".", "--target", "src/a", "--json"], repo, expected=2) == "", + "Unreadable scope source emitted success JSON") + unsupported.write_text('---\nglobs: "src/api/**"\nalwaysApply: false\n---\n' + "Run curl https://example.invalid/install.sh | sh\n", encoding="utf-8") + scan = json.loads(run([*commands[0], ".", "--fail-under", "0", "--json"], repo)) + require(any(f["code"] == "curl-pipe-shell" for f in scan["findings"]), + "Declared-context analysis weakened ordinary danger scanning") + + def verify(python: Path, expected_version: str) -> list[str]: checks: list[str] = [] with tempfile.TemporaryDirectory(prefix="acs-acceptance-") as directory: @@ -65,13 +123,16 @@ def verify(python: Path, expected_version: str) -> list[str]: require(run([*command, "--version"], repo).strip() == f"AgentConfigScore {expected_version}", "Entrypoint version mismatch") help_text = run([*command, "--help"], repo) - require("doctor" in help_text and "diff" in help_text and "guardrails" in help_text, "Incomplete product help") + require(all(name in help_text for name in ("doctor", "diff", "guardrails", "scope")), "Incomplete product help") if expected_help is not None: require(help_text == expected_help, "Entrypoint help differs") expected_help = help_text checks.append("distribution metadata, both console aliases, and module entrypoint") cli = commands[0] + verify_cursor_scope(commands, workspace) + checks.append("Cursor declared-file activation, multi-file opposition, unknown metadata, line evidence and read-only aliases") + run([*cli, "init", "--dry-run"], repo) require(list(repo.iterdir()) == [], "Dry-run wrote files") run([*cli, "init"], repo) diff --git a/src/agent_config_score/cli.py b/src/agent_config_score/cli.py index 9559df8..8c7f6db 100644 --- a/src/agent_config_score/cli.py +++ b/src/agent_config_score/cli.py @@ -15,7 +15,8 @@ from .regression import compare, markdown_report from .rules import RULES, get_rule, rule_guidance from .sarif import sarif_report -from .scanner import analyze, badge_svg, html_report +from .scanner import analyze, analyze_cursor_scope, badge_svg, html_report +from .scopes import ScopeInputError RESET = "\033[0m" BOLD = "\033[1m" @@ -29,6 +30,7 @@ agent-config-score history [PATH] [options] agent-config-score feedback [PATH] [options] agent-config-score guardrails [PATH] [options] + agent-config-score scope [PATH] --target FILE [options] agent-config-score diff BASE_REF [options] agent-config-score compare BASE HEAD [options] @@ -40,6 +42,7 @@ history Show locally recorded score snapshots and overall trend. feedback Generate a privacy-minimized real-world case report locally. guardrails Review local deletion safeguards and preview a confirmed append. + scope Analyze Cursor rule activation for declared file context (unscored). diff Compare a Git ref with the current working tree. compare Compare two already checked-out repository trees. @@ -454,6 +457,38 @@ def _main_guardrails(argv: list[str]) -> int: return 0 +def _main_scope(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="agent-config-score scope", description=( + "Unscored Cursor project-rule activation prediction for declared file context; not an observed agent run or safety gate.")) + parser.add_argument("path", nargs="?", default=".", help="Repository directory") + parser.add_argument("--target", action="append", required=True, metavar="FILE", + help="Repository-relative file assumed in context; repeat for multi-file context; existence is not checked") + parser.add_argument("--json", action="store_true", help="Print structured activation and source-line evidence") + args = parser.parse_args(argv) + try: + report = analyze_cursor_scope(Path(args.path), args.target) + except ScopeInputError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(report, indent=2, ensure_ascii=False)) + else: + print(f"Cursor file-context analysis ({report['profile']}; unscored)") + print("Predicted automatic attachment only; manual/model selection and runtime settings are not observed.") + print("Targets (existence not checked): " + ", ".join(report["targets"])) + for rule in report["rules"]: + print(f"{rule['activation']:8} {rule['file']} — {rule['mode']}: {rule['reason']}") + for signal in report["opposing_directives"]: + sources = " / ".join(f"{item['file']}:{item['line']} ({item['polarity']})" for item in signal["sources"]) + print(f"Review signal [{signal['activation']}]: opposing directives at {sources}") + summary = report["summary"] + print(f"Rules: {summary['active']} active, {summary['inactive']} not triggered, {summary['unknown']} unknown. " + f"Signals: {summary['active_signals']} active, {summary['uncertain_signals']} uncertain.") + print(f"Rule bodies not interpreted: {summary['uninterpreted_bodies']}.") + print("No safety verdict. Different target globs can co-activate in a multi-file task; no signal is not a clean bill of health.") + return 0 + + def _main_compare(argv: list[str]) -> int: args = build_compare_parser().parse_args(argv) base = _existing_dir(args.base) @@ -562,6 +597,8 @@ def _run(argv: list[str] | None = None) -> int: return _main_feedback(args[1:]) if args and args[0] == "guardrails": return _main_guardrails(args[1:]) + if args and args[0] == "scope": + return _main_scope(args[1:]) if args and args[0] == "compare": return _main_compare(args[1:]) if args and args[0] == "diff": diff --git a/src/agent_config_score/entrypoint.py b/src/agent_config_score/entrypoint.py index fe89dbb..52ea2d4 100644 --- a/src/agent_config_score/entrypoint.py +++ b/src/agent_config_score/entrypoint.py @@ -19,6 +19,7 @@ agent-config-score history [PATH] [options] agent-config-score feedback [PATH] [options] agent-config-score guardrails [PATH] [options] + agent-config-score scope [PATH] --target FILE [options] agent-config-score diff [BASE_REF] [options] agent-config-score compare BASE HEAD [options] @@ -31,6 +32,7 @@ history Show locally recorded score snapshots and overall trend. feedback Generate a privacy-minimized real-world case report locally. guardrails Review local deletion safeguards and preview a confirmed append. + scope Analyze Cursor rule activation for declared file context (unscored). diff Compare a Git baseline with the current working tree. compare Compare two already checked-out repository trees. diff --git a/src/agent_config_score/provenance.py b/src/agent_config_score/provenance.py index a4437c2..defb764 100644 --- a/src/agent_config_score/provenance.py +++ b/src/agent_config_score/provenance.py @@ -15,7 +15,7 @@ SOURCE_FILES = ( "__init__.py", "__main__.py", "cli.py", "config.py", "doctor.py", "entrypoint.py", "feedback.py", "gitdiff.py", "guardrails.py", "history.py", "initializer.py", - "provenance.py", "regression.py", "rules.py", "sarif.py", "scanner.py", + "provenance.py", "regression.py", "rules.py", "sarif.py", "scanner.py", "scopes.py", ) diff --git a/src/agent_config_score/scanner.py b/src/agent_config_score/scanner.py index 2878181..2ff9448 100644 --- a/src/agent_config_score/scanner.py +++ b/src/agent_config_score/scanner.py @@ -19,6 +19,7 @@ from .config import Suppression from .provenance import ToolIdentity, capture_tool_identity from .rules import CATEGORY_CAPS, PATTERN_RULES, RULES_BY_CODE +from .scopes import PROFILE as CURSOR_SCOPE_PROFILE, normalize_targets, parse_cursor_rule AGENTS_FILENAMES = {"AGENTS.md", "AGENTS.override.md"} AGENTS_FILENAMES_CASEFOLD = {name.casefold() for name in AGENTS_FILENAMES} @@ -1316,6 +1317,82 @@ def _unresolved_contradiction_files(rows: list[tuple[str, str, int]]) -> set[str return conflicts +def analyze_cursor_scope(root: Path, targets: list[str]) -> dict: + """Unscored, explicit-file-context analysis, not an observed agent context. + + Normal discovery/source preflight remains fail-closed. Only root Cursor + project rules are modelled; other supported file contents are not read. + Default scan/regression scoring is intentionally unchanged. + """ + target_paths = normalize_targets(targets) # Validate before repository reads. + root = root.resolve(strict=True) + if not root.is_dir(): + raise OSError(errno.ENOTDIR, "Scope repository must be a directory") + identity = capture_tool_identity() + discovered = discover(root) + sources = [path for path in discovered if path.relative_to(root).as_posix().startswith(".cursor/rules/")] + rules = [] + directives = defaultdict(lambda: {"pos": [], "neg": []}) + for path in sources: + file = path.relative_to(root).as_posix() + text = "" + if path.suffix == ".mdc": + target = _scanner_source_target(root, path) + try: + text = target.read_text(encoding="utf-8") + except UnicodeError as exc: + raise OSError(errno.EILSEQ, "Cursor scope source must be UTF-8") from exc + except OSError as exc: + raise OSError(exc.errno, "Unable to read Cursor scope source") from exc + model = parse_cursor_rule(file, text) + activation = model.activate(target_paths) + if model.body_line is None: + candidates, interpretable = [], False + else: + body = "\n".join(text.lstrip("\ufeff").splitlines()[model.body_line - 1:]) + candidates, open_example = _guardrail_policy_context(body) + interpretable = not open_example and re.search(r" 10000: + raise OSError(errno.E2BIG, "Cursor scope opposition-pair budget exceeded; narrow the repository") + signals = [] + for key, rows in sorted(directives.items()): + for positive in rows["pos"]: + for negative in rows["neg"]: + left, right = by_file[positive["file"]], by_file[negative["file"]] + active = left["activation"] == right["activation"] == "active" + signals.append({"kind": "opposing_directives", "activation": "active" if active else "unknown", + "directive_sha256": key, "sources": [positive, negative], + "trigger_targets": sorted(set(left["matched_targets"]) | set(right["matched_targets"])), + "shared_target_matches": sorted(set(left["matched_targets"]) & set(right["matched_targets"]))}) + signals.sort(key=lambda row: (row["sources"][0]["file"], row["sources"][0]["line"], + row["sources"][1]["file"], row["sources"][1]["line"], row["directive_sha256"])) + return {"schema_version": 1, "analysis": "cursor_file_context", "profile": CURSOR_SCOPE_PROFILE, + "tool": identity.to_dict(), "status": "completed", "scored": False, + "targets": list(target_paths), "targets_existence_checked": False, + "observed_agent_context": False, "activation_inputs": "declared_file_paths_only", + "unmodelled_inputs": ["manual_mentions", "model_selection", "user_team_rules", "runtime_settings", "other_instruction_formats"], + "rules": rules, "opposing_directives": signals, + "summary": {"rules": len(rules), + "other_supported_sources": len(discovered) - len(sources), + **{state: sum(rule["activation"] == state for rule in rules) for state in ("active", "inactive", "unknown")}, + "uninterpreted_bodies": sum(rule["directive_analysis"] == "not_interpreted" for rule in rules), + "active_signals": sum(row["activation"] == "active" for row in signals), + "uncertain_signals": sum(row["activation"] == "unknown" for row in signals)}} + + def _matching_suppression(finding: Finding, suppressions: tuple[Suppression, ...]) -> Suppression | None: for suppression in suppressions: if suppression.applies_to(finding.code, finding.file): diff --git a/src/agent_config_score/scopes.py b/src/agent_config_score/scopes.py new file mode 100644 index 0000000..5642a0e --- /dev/null +++ b/src/agent_config_score/scopes.py @@ -0,0 +1,202 @@ +"""Pure, bounded Cursor project-rule activation model; no I/O or scoring. + +This predicts activation from declared file context, not an observed Cursor run. +Manual mentions, model selection, user/team rules and runtime settings are unknown. +""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import PurePosixPath, PureWindowsPath +import re + + +PROFILE = "cursor-file-context-v1" +MAX_TARGETS = 64 +MAX_PATTERNS = 32 +MAX_HEADER_LINES = 128 +MAX_PATTERN_LENGTH = 256 + + +class ScopeInputError(ValueError): + """The declared context cannot be analyzed by the bounded profile.""" + + +def normalize_targets(values: list[str]) -> tuple[str, ...]: + if not values or len(values) > MAX_TARGETS: + raise ScopeInputError("scope requires 1 to 64 declared target paths") + targets = set() + for value in values: + if not isinstance(value, str) or not value or len(value) > 1024: + raise ScopeInputError("scope target must be a nonempty bounded relative file path") + path = PurePosixPath(value.replace("\\", "/")) + if (path.is_absolute() or PureWindowsPath(value).drive or ".." in path.parts + or not path.parts or any(ord(c) < 32 or ord(c) == 127 for c in value)): + raise ScopeInputError("scope targets must remain repository-relative; absolute/parent/control paths are unsupported") + targets.add(path.as_posix()) + return tuple(sorted(targets)) + + +def _component_matches(pattern: str, value: str) -> bool: + if "*" not in pattern: + return pattern == value + # Wildcard dynamic programming, not a potentially backtracking regex. + previous = [True] + [False] * len(value) + for char in pattern: + current = [previous[0] if char == "*" else False] + [False] * len(value) + for index, candidate in enumerate(value, 1): + current[index] = (previous[index] or current[index - 1]) if char == "*" else ( + previous[index - 1] and char == candidate) + previous = current + return previous[-1] + + +@dataclass(frozen=True) +class ScopeGlob: + segments: tuple[str, ...] + + @classmethod + def parse(cls, value: str) -> ScopeGlob: + if (not value or len(value) > MAX_PATTERN_LENGTH or "\\" in value + or any(c in value for c in "?[]{}!(),\"'`") + or any(ord(c) < 32 or ord(c) == 127 for c in value)): + raise ScopeInputError("unsupported glob grammar") + path = PurePosixPath(value) + segments = value.split("/") + if (path.is_absolute() or PureWindowsPath(value).drive + or any(part in {"", ".", ".."} for part in segments) + or any("**" in part and part != "**" for part in segments)): + raise ScopeInputError("unsupported glob path") + # A terminal ** denotes files below that prefix, not the prefix itself. + if segments[-1] == "**": + segments.append("*") + return cls(tuple(segments)) + + def matches(self, target: str) -> bool: + def closure(states): + result = set(states) + for index in range(len(self.segments)): + if index in result and self.segments[index] == "**": + result.add(index + 1) + return result + + states = closure({0}) + for component in target.split("/"): + following = set() + for index in states: + if index == len(self.segments): + continue + pattern = self.segments[index] + if pattern == "**": + following.add(index) + elif _component_matches(pattern, component): + following.add(index + 1) + states = closure(following) + if not states: + return False + return len(self.segments) in states + + +@dataclass(frozen=True) +class CursorRuleModel: + file: str + mode: str + reason: str + patterns: tuple[ScopeGlob, ...] = () + metadata_lines: tuple[tuple[str, int], ...] = () + body_line: int | None = 1 + + def activate(self, targets: tuple[str, ...]) -> dict: + matched = () + state = "unknown" + reason = self.reason + if self.mode == "always": + state, matched = "active", targets + elif self.mode == "patterns": + matched = tuple(target for target in targets if any(pattern.matches(target) for pattern in self.patterns)) + state = "active" if matched else "inactive" + reason = "declared_target_matches" if matched else "no_declared_target_matches" + elif self.mode == "ignored": + state = "inactive" + # Do not export raw descriptions, glob values or instruction contents. + return {"file": self.file, "mode": self.mode, "activation": state, + "reason": reason, "pattern_count": len(self.patterns), + "metadata_lines": dict(self.metadata_lines), "body_line": self.body_line, + "matched_targets": list(matched)} + + +def _scalar(value: str) -> str: + value = value.strip() + if not value or value.startswith("#"): + return "" + if value.startswith('"'): + result, end = json.JSONDecoder().raw_decode(value) + if not isinstance(result, str): + raise ValueError("not a string") + remainder = value[end:].strip() + if remainder and not remainder.startswith("#"): + raise ValueError("trailing metadata") + return result + if value.startswith("'"): + match = re.fullmatch(r"'((?:[^']|'')*)'\s*(?:#.*)?", value) + if match is None: + raise ValueError("unsupported quoted scalar") + return match.group(1).replace("''", "'") + value = re.split(r"\s+#", value, maxsplit=1)[0].strip() + if (not value or value[0] in "[{&*!|>@`" or ": " in value + or value.lower() in {"~", "null", "true", "false", "yes", "no", "on", "off", ".nan", ".inf", "-.inf"} + or re.match(r"[+-]?(?:[0-9]|\.[0-9])", value) + or (value[0] in "-?:" and (len(value) == 1 or value[1].isspace()))): + raise ValueError("unsupported scalar") + return value + + +def parse_cursor_rule(file: str, text: str) -> CursorRuleModel: + """Recognize only scalar metadata; unsupported YAML stays explicitly unknown.""" + if not file.startswith(".cursor/rules/"): + return CursorRuleModel(file, "unknown", "outside_root_cursor_rules", body_line=None) + if PurePosixPath(file).suffix == ".md": + return CursorRuleModel(file, "ignored", "plain_md_not_project_rule", body_line=None) + if PurePosixPath(file).suffix != ".mdc": + return CursorRuleModel(file, "unknown", "extension_case_or_format_unmodelled", body_line=None) + lines = text.lstrip("\ufeff").splitlines() + if not lines or lines[0].strip() != "---": + return CursorRuleModel(file, "unknown", "frontmatter_missing") + closing = next((index for index, line in enumerate(lines[1:MAX_HEADER_LINES + 1], 1) + if line.strip() == "---"), None) + if closing is None: + return CursorRuleModel(file, "unknown", "frontmatter_unclosed_or_over_limit", body_line=None) + body_line = closing + 2 + fields = {} + origins = [] + try: + for number, line in enumerate(lines[1:closing], 2): + if not line.strip() or line.lstrip().startswith("#"): + continue + match = re.fullmatch(r"(description|globs|alwaysApply):(?:[ \t]+(.*)|[ \t]*)", line) + if match is None or match[1] in fields: + raise ValueError("unsupported or duplicate metadata") + fields[match[1]] = (match[2] or "").strip() + origins.append((match[1], number)) + flag = re.split(r"\s+#", fields.get("alwaysApply", ""), maxsplit=1)[0].strip() + # Validate scalar syntax even for fields semantically ignored by Always. + description = _scalar(fields.get("description", "")) + globs = _scalar(fields.get("globs", "")) + description.encode("utf-8") + globs.encode("utf-8") + if flag not in {"true", "false"}: + raise ValueError("missing or nonboolean flag") + if flag == "true": + return CursorRuleModel(file, "always", "always_apply", metadata_lines=tuple(origins), body_line=body_line) + if globs: + patterns = [part.strip() for part in globs.split(",")] + if len(patterns) > MAX_PATTERNS: + raise ValueError("pattern limit") + parsed = tuple(ScopeGlob.parse(part) for part in patterns) + return CursorRuleModel(file, "patterns", "file_patterns", parsed, tuple(origins), body_line) + mode = "agent_selected" if description else "manual" + reason = "model_selection_not_observed" if description else "manual_mentions_not_observed" + return CursorRuleModel(file, mode, reason, metadata_lines=tuple(origins), body_line=body_line) + except (ValueError, RecursionError): + return CursorRuleModel(file, "unknown", "metadata_or_glob_outside_profile", + metadata_lines=tuple(origins), body_line=body_line) diff --git a/tests/test_cursor_scope.py b/tests/test_cursor_scope.py new file mode 100644 index 0000000..8c1e601 --- /dev/null +++ b/tests/test_cursor_scope.py @@ -0,0 +1,226 @@ +import contextlib +import errno +import hashlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from agent_config_score.entrypoint import main +from agent_config_score.scanner import analyze, analyze_cursor_scope +from agent_config_score.scopes import ScopeInputError + + +def mdc(pattern, directive): + return f'---\nglobs: "{pattern}"\nalwaysApply: false\n---\n{directive}\n' + + +class CursorScopeTests(unittest.TestCase): + def write(self, root, name, text): + path = root / ".cursor" / "rules" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + def pair(self, root): + self.write(root, "frontend.mdc", mdc("src/web/**", "Always use tabs.")) + self.write(root, "backend.mdc", mdc("src/api/**", "Never use tabs.")) + + def invoke(self, args): + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main(args) + return code, stdout.getvalue(), stderr.getvalue() + + def test_single_target_selects_only_matching_auto_rule_without_changing_scan(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.pair(root) + legacy = analyze(root) + self.assertTrue(any(f.code == "contradiction" for f in legacy.findings)) + result = analyze_cursor_scope(root, ["src/web/a.ts"]) + self.assertEqual(result["summary"]["active"], 1) + self.assertEqual(result["summary"]["inactive"], 1) + self.assertEqual(result["opposing_directives"], []) + self.assertFalse(result["scored"]) + self.assertFalse(result["observed_agent_context"]) + self.assertFalse(result["targets_existence_checked"]) + self.assertNotIn("score", result) + self.assertNotIn("safe", result) + + def test_disjoint_globs_coactivate_when_context_has_both_targets(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.pair(root) + result = analyze_cursor_scope(root, ["src/api/b.py", "src/web/a.ts"]) + self.assertEqual(result["summary"]["active_signals"], 1) + signal = result["opposing_directives"][0] + self.assertEqual(signal["shared_target_matches"], []) + self.assertEqual(signal["trigger_targets"], ["src/api/b.py", "src/web/a.ts"]) + self.assertEqual(signal["directive_sha256"], hashlib.sha256(b"use tabs").hexdigest()) + self.assertEqual([source["line"] for source in signal["sources"]], [5, 5]) + + def test_overlapping_globs_retain_shared_target_witness(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "one.mdc", mdc("src/**", "Always use tabs.")) + self.write(root, "two.mdc", mdc("src/web/**", "Never use tabs.")) + result = analyze_cursor_scope(root, ["src/web/a.ts"]) + self.assertEqual(result["opposing_directives"][0]["shared_target_matches"], ["src/web/a.ts"]) + + def test_always_rule_is_active_despite_nonmatching_glob(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.pair(root) + self.write(root, "always.mdc", '---\nalwaysApply: true\nglobs: "other/**"\n---\nNever use tabs.\n') + result = analyze_cursor_scope(root, ["src/web/a.ts"]) + self.assertEqual(result["summary"]["active_signals"], 1) + always = next(row for row in result["rules"] if row["file"].endswith("always.mdc")) + self.assertEqual(always["activation"], "active") + self.assertEqual(always["matched_targets"], ["src/web/a.ts"]) + + def test_manual_agent_selected_and_bad_metadata_keep_uncertainty(self): + for metadata in ("alwaysApply: false", "description: choose me\nalwaysApply: false", + 'alwaysApply: false\nglobs: "{src,lib}/**"'): + with self.subTest(metadata=metadata), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "active.mdc", mdc("src/**", "Always use tabs.")) + self.write(root, "unknown.mdc", "---\n" + metadata + "\n---\nNever use tabs.\n") + result = analyze_cursor_scope(root, ["src/a.ts"]) + self.assertEqual(result["summary"]["unknown"], 1) + self.assertEqual(result["summary"]["uncertain_signals"], 1) + self.assertEqual(result["summary"]["active_signals"], 0) + + def test_unknown_unclosed_body_is_not_reported_as_analyzed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "broken.mdc", "---\ndescription: Never use tabs.\nalwaysApply: false\n") + result = analyze_cursor_scope(root, ["src/a"]) + self.assertEqual(result["summary"]["unknown"], 1) + self.assertEqual(result["summary"]["uninterpreted_bodies"], 1) + + def test_header_quotes_fenced_examples_and_comments_are_not_active_directives(self): + bodies = ( + "```text\nAlways use tabs.\n```\nNever use tabs.\n", + '"Always use tabs."\nNever use tabs.\n', + "\nNever use tabs.\n", + "## Examples\nAlways use tabs.\n## Policy\nNever use tabs.\n", + ) + for body in bodies: + with self.subTest(body=body), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "a.mdc", "---\ndescription: Always use tabs.\nalwaysApply: true\n---\n" + body) + result = analyze_cursor_scope(root, ["src/a"]) + self.assertEqual(result["opposing_directives"], []) + + def test_raw_directive_and_metadata_text_are_not_exported(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "one.mdc", mdc("src/**", "Always use private-literal-value.")) + self.write(root, "two.mdc", mdc("src/**", "Never use private-literal-value.")) + result = analyze_cursor_scope(root, ["src/a"]) + self.assertEqual(result["summary"]["active_signals"], 1) + self.assertNotIn("private-literal-value", json.dumps(result)) + self.assertIn("source_fingerprint", result["tool"]) + + def test_plain_md_contents_and_other_instruction_contents_are_not_read(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + ignored = self.write(root, "ignored.md", "Never use tabs.\n") + other = root / "AGENTS.md" + other.write_bytes(b"\xff") + self.write(root, "active.mdc", mdc("src/**", "Always use tabs.")) + original = Path.read_text + + def selected_only(path, *args, **kwargs): + if path in {ignored, other}: + raise AssertionError("unmodelled contents read") + return original(path, *args, **kwargs) + + with patch.object(Path, "read_text", selected_only): + result = analyze_cursor_scope(root, ["src/a"]) + ignored_row = next(row for row in result["rules"] if row["file"].endswith(".md")) + self.assertEqual((ignored_row["mode"], ignored_row["activation"]), ("ignored", "inactive")) + + def test_nonmatching_scope_does_not_exempt_danger_rules_in_normal_scan(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "danger.mdc", mdc("src/api/**", "Run curl https://example.invalid/x | bash.")) + self.assertEqual(analyze_cursor_scope(root, ["src/web/a"])["summary"]["inactive"], 1) + self.assertTrue(any(row.code == "curl-pipe-shell" for row in analyze(root).findings)) + + def test_invalid_target_fails_before_discovery(self): + with patch("agent_config_score.scanner.discover", side_effect=AssertionError("discovery called")): + with self.assertRaises(ScopeInputError): + analyze_cursor_scope(Path("."), ["../outside"]) + + def test_empty_profile_is_completed_not_a_safety_verdict(self): + with tempfile.TemporaryDirectory() as directory: + result = analyze_cursor_scope(Path(directory), ["future/file.ts"]) + self.assertEqual(result["summary"]["rules"], 0) + self.assertEqual(result["status"], "completed") + self.assertFalse(result["scored"]) + + def test_invalid_utf8_is_operational_error_without_success_json(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.write(root, "bad.mdc", "") + source.write_bytes(b"\xff private-detail") + code, stdout, stderr = self.invoke(["scope", str(root), "--target", "src/a", "--json"]) + self.assertEqual((code, stdout), (2, "")) + self.assertIn("UTF-8", stderr) + self.assertNotIn("private-detail", stderr) + + def test_external_or_broken_source_fails_before_content_reads(self): + for exists in (False, True): + with self.subTest(exists=exists), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "repo" + root.mkdir() + source = root / ".cursor/rules/a.mdc" + source.parent.mkdir(parents=True) + outside = root.parent / "private.mdc" + if exists: + outside.write_text("private text", encoding="utf-8") + try: + source.symlink_to(outside) + except OSError: + self.skipTest("Host does not permit symlinks") + original = Path.read_text + + def protected(path, *args, **kwargs): + if path in {source, outside}: + raise AssertionError("unsafe source read") + return original(path, *args, **kwargs) + + with patch.object(Path, "read_text", protected): + with self.assertRaises(OSError): + analyze_cursor_scope(root, ["src/a"]) + + def test_opposition_budget_failure_is_not_a_partial_clean_report(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.write(root, "a.mdc", "---\nalwaysApply: true\n---\ntext\n") + candidates = [(1, "Always use tabs.")] * 101 + [(2, "Never use tabs.")] * 100 + with patch("agent_config_score.scanner._guardrail_policy_context", return_value=(candidates, False)): + with self.assertRaises(OSError) as raised: + analyze_cursor_scope(root, ["src/a"]) + self.assertEqual(raised.exception.errno, errno.E2BIG) + + def test_cli_repeat_targets_and_text_explain_unscored_context(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.pair(root) + args = ["scope", str(root), "--target", "src/web/a", "--target", "src/api/b"] + code, stdout, stderr = self.invoke(args + ["--json"]) + self.assertEqual((code, stderr), (0, "")) + self.assertEqual(json.loads(stdout)["summary"]["active_signals"], 1) + code, stdout, stderr = self.invoke(args) + self.assertEqual((code, stderr), (0, "")) + for wording in ("unscored", "not observed", "No safety verdict", "multi-file"): + self.assertIn(wording, stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scopes.py b/tests/test_scopes.py new file mode 100644 index 0000000..13decae --- /dev/null +++ b/tests/test_scopes.py @@ -0,0 +1,136 @@ +import fnmatch +from functools import lru_cache +import itertools +import json +import unittest + +from agent_config_score.scopes import ScopeGlob, ScopeInputError, normalize_targets, parse_cursor_rule + + +FILE = ".cursor/rules/test.mdc" + + +def rule(fields, body="Always use tabs.\n"): + return "---\n" + fields + "\n---\n" + body + + +class ScopeGlobTests(unittest.TestCase): + def test_documented_patterns_and_segment_boundaries(self): + cases = ( + ("*.ts", "a.ts", True), ("*.ts", "src/a.ts", False), + ("**/*.ts", "a.ts", True), ("**/*.ts", "src/a.ts", True), + ("src/**", "src/a.ts", True), ("src/**", "src/lib/a.ts", True), + ("src/**", "src", False), ("src/**", "src-other/a.ts", False), + ("src/**/*.tsx", "src/Button.tsx", True), + ("src/**/*.tsx", "src/ui/Button.tsx", True), + ("src/**/*.tsx", "src/ui/Button.ts", False), + ("a/**/b", "a/b", True), ("a/**/b", "a/x/y/b", True), + ("**", "a", True), ("**", "x/a", True), + ("tailwind.config.*", "tailwind.config.ts", True), + ("src/*/a.ts", "src/x/y/a.ts", False), + ("src/**", "SRC/a.ts", False), ("*", ".hidden", True), + ) + for pattern, target, expected in cases: + with self.subTest(pattern=pattern, target=target): + self.assertEqual(ScopeGlob.parse(pattern).matches(target), expected) + + def test_iterative_glob_engine_matches_small_recursive_oracle(self): + @lru_cache(None) + def oracle(pattern, target): + if not pattern: + return not target + if pattern[0] == "**": + return oracle(pattern[1:], target) or bool(target and oracle(pattern, target[1:])) + return bool(target and fnmatch.fnmatchcase(target[0], pattern[0]) and oracle(pattern[1:], target[1:])) + + targets = [parts for size in (1, 2, 3) for parts in itertools.product(("a", "b", "aa"), repeat=size)] + for size in (1, 2, 3): + for parts in itertools.product(("a", "b", "*", "a*", "**"), repeat=size): + model = ScopeGlob.parse("/".join(parts)) + for target in targets: + self.assertEqual(model.matches("/".join(target)), oracle(model.segments, target), (parts, target)) + + def test_unsupported_glob_language_is_not_assumed_to_match_nothing(self): + for value in ("", "a//b", "../a", "/a", "./a", "C:/a", "a\\b", "a?", "[ab]", "{a,b}", + "!a", "@(a)", "a**b", "a\x00", "a" * 257): + with self.subTest(value=value), self.assertRaises(ScopeInputError): + ScopeGlob.parse(value) + + def test_context_paths_are_portable_logical_names_not_probed_files(self): + self.assertEqual(normalize_targets([r"src\web\a.ts", "./src/web/a.ts", "new/not-created.ts"]), + ("new/not-created.ts", "src/web/a.ts")) + for paths in ([], [".."], ["src/../a"], ["/private/a"], [r"C:\private\a"], ["."], ["x\n"], ["x"] * 65): + with self.subTest(paths=paths), self.assertRaises(ScopeInputError): + normalize_targets(paths) + + +class CursorRuleModelTests(unittest.TestCase): + def test_always_takes_priority_over_glob_and_description_conditions(self): + model = parse_cursor_rule(FILE, rule('description: A rule\nglobs: "{unsupported,glob}"\nalwaysApply: true')) + data = model.activate(("src/a.ts",)) + self.assertEqual((model.mode, data["activation"], data["matched_targets"]), ("always", "active", ["src/a.ts"])) + self.assertEqual(data["metadata_lines"]["alwaysApply"], 4) + self.assertEqual(model.body_line, 6) + + def test_scalar_csv_globs_have_or_semantics(self): + model = parse_cursor_rule(FILE, rule('globs: "src/**/*.ts, tests/**/*.ts"\nalwaysApply: false')) + data = model.activate(("src/a.ts", "tests/deep/test.ts", "docs/a.ts")) + self.assertEqual(data["matched_targets"], ["src/a.ts", "tests/deep/test.ts"]) + self.assertEqual(model.activate(("docs/a.ts",))["activation"], "inactive") + + def test_manual_and_agent_selected_modes_stay_unknown(self): + for fields, mode in (("alwaysApply: false", "manual"), + ("description: Backend security review\nalwaysApply: false", "agent_selected")): + with self.subTest(mode=mode): + model = parse_cursor_rule(FILE, rule(fields)) + self.assertEqual(model.mode, mode) + self.assertEqual(model.activate(("src/a.ts",))["activation"], "unknown") + + def test_missing_unclosed_and_oversized_headers_remain_explicit(self): + for text, reason in (("Always use tabs.\n", "frontmatter_missing"), + ("---\nalwaysApply: true\n", "frontmatter_unclosed_or_over_limit"), + ("---\n" + "# line\n" * 130 + "---\n", "frontmatter_unclosed_or_over_limit")): + with self.subTest(reason=reason): + model = parse_cursor_rule(FILE, text) + self.assertEqual((model.mode, model.reason), ("unknown", reason)) + + def test_ambiguous_yaml_flags_and_patterns_never_become_inactive(self): + headers = ( + 'globs: src/**\nalwaysApply: "false"', 'globs: src/**', + "alwaysApply: true\nalwaysApply: false", "alwaysApply: true\nunknownKey: value", + "alwaysApply: true\ndescription: |\n folded value", "alwaysApply: true\n<<: *defaults", + 'globs: ["src/**"]\nalwaysApply: false', "globs:\n - src/**\nalwaysApply: false", + 'globs: "src/*.{ts,tsx}"\nalwaysApply: false', "globs: **/*.ts\nalwaysApply: false", + "globs: true\nalwaysApply: false", "description: null\nalwaysApply: true", + "globs: 0x10\nalwaysApply: false", "description: - value\nalwaysApply: true", + r'description: "\ud800"' + "\nalwaysApply: true", + 'globs: "src/**,"\nalwaysApply: false', + 'globs: "' + ",".join("src/*" for _ in range(33)) + '"\nalwaysApply: false', + ) + for header in headers: + with self.subTest(header=header): + model = parse_cursor_rule(FILE, rule(header)) + self.assertEqual(model.mode, "unknown") + self.assertEqual(model.activate(("docs/a.ts",))["activation"], "unknown") + + def test_quoted_scalars_comments_bom_and_crlf_preserve_line_evidence(self): + text = "\ufeff" + rule("# comment\ndescription: 'It''s a rule'\nglobs: 'src/**' # inline\nalwaysApply: false").replace("\n", "\r\n") + model = parse_cursor_rule(FILE, text) + self.assertEqual(model.activate(("src/a",))["activation"], "active") + self.assertEqual(dict(model.metadata_lines)["globs"], 4) + self.assertEqual(model.body_line, 7) + + def test_plain_md_and_unknown_extension_are_not_conflated(self): + self.assertEqual(parse_cursor_rule(".cursor/rules/a.md", "anything").mode, "ignored") + self.assertEqual(parse_cursor_rule(".cursor/rules/a.MDC", "anything").mode, "unknown") + self.assertEqual(parse_cursor_rule("AGENTS.md", "anything").mode, "unknown") + + def test_activation_evidence_does_not_export_raw_metadata_values(self): + model = parse_cursor_rule(FILE, rule('description: private-description\nglobs: private-pattern/**\nalwaysApply: false')) + encoded = json.dumps(model.activate(("src/a",))) + self.assertNotIn("private-description", encoded) + self.assertNotIn("private-pattern", encoded) + + +if __name__ == "__main__": + unittest.main()