@nerviq/cli 1.19.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -223,8 +223,8 @@ All successful operational responses are wrapped in a JSON envelope:
223
223
  {
224
224
  "data": {},
225
225
  "meta": {
226
- "version": "1.19.0",
227
- "timestamp": "2026-04-13T12:00:00.000Z"
226
+ "version": "1.20.1",
227
+ "timestamp": "2026-04-14T12:00:00.000Z"
228
228
  }
229
229
  }
230
230
  ```
package/bin/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // macOS pipe-flush guard: when stdout is a pipe, Node defaults to
2
3
  // non-blocking writes. `console.log(...) + process.exit(N)` then drops
3
4
  // the trailing write (empty stdout on macOS Node 18, truncation at the
@@ -2536,6 +2537,7 @@ async function main() {
2536
2537
  }
2537
2538
  } catch (err) {
2538
2539
  console.error(`\n Error: ${err.message}`);
2540
+ if (process.env.NERVIQ_DEBUG) console.error(err.stack);
2539
2541
  console.error(' Fix: Run `npx nerviq doctor` to diagnose common issues, or check https://github.com/nerviq/nerviq#troubleshooting');
2540
2542
  process.exit(2);
2541
2543
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.19.0",
3
+ "version": "1.20.1",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,441 checks (8 platforms × ~300 governance rules), 10 languages, 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -18,6 +18,8 @@
18
18
  "build": "npm pack --dry-run",
19
19
  "test": "node test/run.js",
20
20
  "verify:release-metadata": "node tools/validate-release-metadata.js",
21
+ "prepublish:check": "node tools/pre-publish.js",
22
+ "prepublishOnly": "node tools/pre-publish.js",
21
23
  "test:jest": "jest",
22
24
  "test:coverage": "jest --coverage",
23
25
  "test:all": "npm test && npx jest && node test/check-matrix.js && node test/codex-check-matrix.js && node test/gemini-check-matrix.js && node test/copilot-check-matrix.js && node test/cursor-check-matrix.js && node test/windsurf-check-matrix.js && node test/aider-check-matrix.js && node test/opencode-check-matrix.js && node test/golden-matrix.js && node test/codex-golden-matrix.js && node test/gemini-golden-matrix.js && node test/copilot-golden-matrix.js && node test/cursor-golden-matrix.js && node test/windsurf-golden-matrix.js && node test/aider-golden-matrix.js && node test/opencode-golden-matrix.js",
@@ -39,7 +39,8 @@ function detectAiderVersion() {
39
39
 
40
40
  class AiderProjectContext extends ProjectContext {
41
41
  configContent() {
42
- return this.fileContent('.aider.conf.yml');
42
+ // Aider accepts both .yml and .yaml extensions for the project config
43
+ return this.fileContent('.aider.conf.yml') || this.fileContent('.aider.conf.yaml');
43
44
  }
44
45
 
45
46
  modelSettingsContent() {
package/src/context.js CHANGED
@@ -83,14 +83,20 @@ class ProjectContext {
83
83
  if (!raw) return null;
84
84
 
85
85
  // If the file is very short and looks like a file reference, follow it.
86
- // Pattern: a single line that is just a filename (e.g., "AGENTS.md" or "docs/CODING.md")
86
+ // Recognised pointer shapes on each line:
87
+ // AGENTS.md
88
+ // docs/CODING.md
89
+ // @AGENTS.md (Claude Code @import syntax)
90
+ // @./docs/CODING.md (Claude Code @import with relative prefix)
87
91
  const trimmed = raw.trim();
88
- if (trimmed.length < 200 && /^[a-zA-Z0-9_./-]+\.(md|txt|rst)$/m.test(trimmed)) {
92
+ const pointerLine = /^@?\.?\/?[a-zA-Z0-9_./-]+\.(md|txt|rst)$/;
93
+ if (trimmed.length < 200 && pointerLine.test(trimmed.split(/\r?\n/)[0].trim())) {
89
94
  const lines = trimmed.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
90
95
  let combined = raw;
91
96
  for (const line of lines) {
92
- if (/^[a-zA-Z0-9_./-]+\.(md|txt|rst)$/.test(line)) {
93
- const referenced = this.fileContent(line);
97
+ if (pointerLine.test(line)) {
98
+ const ref = line.replace(/^@/, '').replace(/^\.\//, '');
99
+ const referenced = this.fileContent(ref);
94
100
  if (referenced) {
95
101
  combined += '\n' + referenced;
96
102
  }
@@ -54,18 +54,87 @@ class GeminiProjectContext extends ProjectContext {
54
54
 
55
55
  geminiMdContent() {
56
56
  const direct = this.fileContent('GEMINI.md');
57
- if (direct) return direct;
57
+ if (direct) return this._expandGeminiMdImports(direct);
58
58
 
59
- // Fallback: use context.fileName from settings if configured
59
+ // Fallback: use context.fileName from settings if configured.
60
+ // Per Gemini CLI spec, context.fileName may be a string or an array of strings.
60
61
  const contextFileName = this.configValue('context.fileName');
61
- if (contextFileName) {
62
- const content = this.fileContent(contextFileName);
63
- if (content) return content;
62
+ const candidates = Array.isArray(contextFileName)
63
+ ? contextFileName.filter(n => typeof n === 'string' && n.length > 0)
64
+ : (typeof contextFileName === 'string' && contextFileName ? [contextFileName] : []);
65
+ for (const name of candidates) {
66
+ const content = this.fileContent(name);
67
+ if (content) return this._expandGeminiMdImports(content);
68
+ }
69
+
70
+ // Further fallback: recognise common alternate instruction surfaces
71
+ // (AGENTS.md, CLAUDE.md) and Gemini Code Assist styleguides
72
+ // (.gemini/styleguide.md) even when not explicitly declared in settings,
73
+ // mirroring how real Gemini-using repos document guidance.
74
+ for (const alt of ['AGENTS.md', 'CLAUDE.md', '.gemini/styleguide.md']) {
75
+ const content = this.fileContent(alt);
76
+ if (content) return this._expandGeminiMdImports(content);
64
77
  }
65
78
 
66
79
  return null;
67
80
  }
68
81
 
82
+ /**
83
+ * Expand Gemini CLI-style imports inside an instructions file. Gemini CLI
84
+ * supports `@path/to/file.md` imports and treats GEMINI.md files that are
85
+ * a single pointer line as an alias for the referenced file. For audit
86
+ * purposes we concatenate the referenced bodies so substance/architecture/
87
+ * command checks see the effective instructions bundle.
88
+ */
89
+ _expandGeminiMdImports(content, depth = 0) {
90
+ if (!content || depth > 3) return content || '';
91
+ let out = content;
92
+ const importRe = /@([^\s@]+\.(?:md|markdown|MD))/g;
93
+ const seen = new Set();
94
+ let m;
95
+ while ((m = importRe.exec(content)) !== null) {
96
+ const ref = m[1].replace(/^\.\//, '');
97
+ if (seen.has(ref)) continue;
98
+ seen.add(ref);
99
+ const body = this.fileContent(ref);
100
+ if (body) out += '\n\n' + this._expandGeminiMdImports(body, depth + 1);
101
+ }
102
+ // "Pointer" GEMINI.md: the whole file is a single relative path to another
103
+ // markdown doc (no @ prefix). Observed in google/dotprompt.
104
+ const trimmed = content.trim();
105
+ if (/^[\w./-]+\.(md|markdown)$/.test(trimmed) && !trimmed.includes('\n')) {
106
+ const body = this.fileContent(trimmed);
107
+ if (body) out += '\n\n' + this._expandGeminiMdImports(body, depth + 1);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Returns true when the repo exposes any Gemini-recognisable instruction
114
+ * surface — GEMINI.md (directly or via context.fileName override), an
115
+ * imported pointer, AGENTS.md, or CLAUDE.md. Used to gate checks that
116
+ * would otherwise hard-fail on repos that use alternative conventions.
117
+ */
118
+ hasAnyInstructionsSurface() {
119
+ return Boolean(this.geminiMdContent());
120
+ }
121
+
122
+ /**
123
+ * Returns true when the repo exposes any evidence of Gemini CLI usage.
124
+ * This is deliberately narrower than `isGeminiRepo`: it also counts
125
+ * `.idx/airules.md` (Project IDX) and Gemini-specific settings keys.
126
+ */
127
+ hasGeminiCliSurface() {
128
+ if (this.fileContent('.gemini/settings.json')) return true;
129
+ if (this.fileContent('GEMINI.md')) return true;
130
+ const extDirs = this.extensionDirs ? this.extensionDirs() : [];
131
+ if (extDirs.length > 0) return true;
132
+ const cmdFiles = this.commandFiles ? this.commandFiles() : [];
133
+ if (cmdFiles.length > 0) return true;
134
+ if (this.fileContent('.idx/airules.md')) return true;
135
+ return false;
136
+ }
137
+
69
138
  globalGeminiMdContent() {
70
139
  const homeDir = os.homedir();
71
140
  const globalPath = path.join(homeDir, '.gemini', 'GEMINI.md');
@@ -93,10 +93,37 @@ function settingsData(ctx) {
93
93
  return result && result.ok ? result.data : null;
94
94
  }
95
95
 
96
+ /**
97
+ * True when .gemini/settings.json is effectively an MCP-only config — i.e.
98
+ * it configures external tool servers but does not attempt to tune CLI
99
+ * behaviour (model, sandbox, approval, theme, history, etc.). Checks that
100
+ * assert on CLI-behaviour keys should be N/A on such configs.
101
+ */
102
+ function isMcpOnlySettings(data) {
103
+ if (!data || typeof data !== 'object') return false;
104
+ const keys = Object.keys(data).filter(k => k !== '$schema' && k !== 'ide' && k !== 'context');
105
+ if (keys.length === 0) return true;
106
+ const behaviourKeys = new Set(['model', 'sandbox', 'safety', 'theme', 'approval', 'approvalMode', 'history', 'session', 'telemetry', 'hooks', 'tools', 'skills', 'commands', 'extensions', 'security']);
107
+ return keys.every(k => k === 'mcpServers' || !behaviourKeys.has(k));
108
+ }
109
+
96
110
  function docsBundle(ctx) {
97
111
  const gmd = geminiMd(ctx) || '';
98
112
  const readme = ctx.fileContent('README.md') || '';
99
- return `${gmd}\n${readme}`;
113
+ const agents = ctx.fileContent('AGENTS.md') || '';
114
+ const claudeMd = ctx.fileContent('CLAUDE.md') || '';
115
+ const contributing = ctx.fileContent('CONTRIBUTING.md') || '';
116
+ const architecture = ctx.fileContent('ARCHITECTURE.md') || '';
117
+ const development = ctx.fileContent('DEVELOPMENT.md') || ctx.fileContent('docs/development.md') || '';
118
+ return [gmd, readme, agents, claudeMd, contributing, architecture, development].join('\n');
119
+ }
120
+
121
+ // Broader bundle for stack-specific docs discovery (mirrors the Copilot
122
+ // PP-01 stackDocsBundle approach): consults common developer docs in
123
+ // addition to the instruction surfaces so stack checks don't hard-fail
124
+ // when conventions live in CONTRIBUTING/DEVELOPMENT instead of GEMINI.md.
125
+ function stackDocsBundle(ctx) {
126
+ return docsBundle(ctx);
100
127
  }
101
128
 
102
129
  function expectedVerificationCategories(ctx) {
@@ -262,11 +289,18 @@ const GEMINI_TECHNIQUES = {
262
289
 
263
290
  geminiMdArchitecture: {
264
291
  id: 'GM-A04',
265
- name: 'GEMINI.md has architecture section or Mermaid diagram',
292
+ name: 'Instructions have architecture section or Mermaid diagram',
266
293
  check: (ctx) => {
267
294
  const content = geminiMd(ctx);
268
295
  if (!content) return null;
269
- return hasArchitecture(content);
296
+ if (hasArchitecture(content)) return true;
297
+ // Credit an ARCHITECTURE.md at repo root, or architecture content
298
+ // surfaced in README.md — both are legitimate ways Gemini will pick
299
+ // up repo shape, especially when GEMINI.md is a pointer/import.
300
+ const arch = ctx.fileContent('ARCHITECTURE.md') || ctx.fileContent('docs/architecture.md');
301
+ if (arch) return true;
302
+ const readme = ctx.fileContent('README.md') || '';
303
+ return hasArchitecture(readme);
270
304
  },
271
305
  impact: 'medium',
272
306
  rating: 4,
@@ -352,7 +386,15 @@ const GEMINI_TECHNIQUES = {
352
386
  geminiSettingsExists: {
353
387
  id: 'GM-B01',
354
388
  name: '.gemini/settings.json exists',
355
- check: (ctx) => Boolean(ctx.fileContent('.gemini/settings.json')),
389
+ check: (ctx) => {
390
+ if (ctx.fileContent('.gemini/settings.json')) return true;
391
+ // N/A when the repo uses only the GEMINI.md-instruction convention
392
+ // without any .gemini/ configuration directory. settings.json is
393
+ // opt-in for CLI tuning; instruction-only repos should not fail.
394
+ const hasGeminiDir = ctx.hasDir && ctx.hasDir('.gemini');
395
+ if (!hasGeminiDir) return null;
396
+ return false;
397
+ },
356
398
  impact: 'high',
357
399
  rating: 5,
358
400
  category: 'config',
@@ -397,6 +439,7 @@ const GEMINI_TECHNIQUES = {
397
439
  check: (ctx) => {
398
440
  const data = settingsData(ctx);
399
441
  if (!data) return null;
442
+ if (isMcpOnlySettings(data)) return null;
400
443
  if (!data.model) return false;
401
444
  // v0.36.0: model field MUST be an object { name: "..." }, not a string
402
445
  // String format causes exit code 41: "Expected object, received string"
@@ -419,8 +462,9 @@ const GEMINI_TECHNIQUES = {
419
462
  check: (ctx) => {
420
463
  const data = settingsData(ctx);
421
464
  if (!data) return null;
422
- // At least sandbox or safety setting should be explicit
423
- return Boolean(data.sandbox || data.safety || data.theme);
465
+ if (isMcpOnlySettings(data)) return null;
466
+ // At least one CLI-behaviour setting should be explicit.
467
+ return Boolean(data.sandbox || data.safety || data.theme || data.approval || data.approvalMode);
424
468
  },
425
469
  impact: 'medium',
426
470
  rating: 4,
@@ -479,13 +523,17 @@ const GEMINI_TECHNIQUES = {
479
523
 
480
524
  geminiEnvApiKey: {
481
525
  id: 'GM-B07',
482
- name: '.env exists with required API keys (GEMINI_API_KEY or Google auth)',
526
+ name: 'API key / auth documented (env file, README, or GEMINI.md)',
483
527
  check: (ctx) => {
484
528
  const envContent = ctx.fileContent('.env') || '';
485
- const envExample = ctx.fileContent('.env.example') || ctx.fileContent('.env.template') || '';
486
- const combined = `${envContent}\n${envExample}`;
487
- // Check for Gemini API key or Google auth
488
- return /\bGEMINI_API_KEY\b|\bGOOGLE_API_KEY\b|\bGOOGLE_APPLICATION_CREDENTIALS\b|\bgcloud\b/i.test(combined) || Boolean(envContent);
529
+ const envExample = ctx.fileContent('.env.example') || ctx.fileContent('.env.template') || ctx.fileContent('.env.sample') || '';
530
+ const docs = docsBundle(ctx);
531
+ const combined = `${envContent}\n${envExample}\n${docs}`;
532
+ if (!combined.trim()) return null;
533
+ // Credit env files OR documentation that mentions any Gemini/Google auth mechanism,
534
+ // including `gemini auth`, ADC / application default credentials, Vertex AI, or a
535
+ // direct mention of the standard env var names.
536
+ return /\bGEMINI_API_KEY\b|\bGOOGLE_API_KEY\b|\bGOOGLE_APPLICATION_CREDENTIALS\b|\bgcloud\b|\bapplication[- ]default credentials?\b|\bADC\b|\bvertex[- ]?ai\b|\bgemini auth\b|\bservice account\b/i.test(combined);
489
537
  },
490
538
  impact: 'high',
491
539
  rating: 4,
@@ -542,6 +590,7 @@ const GEMINI_TECHNIQUES = {
542
590
  check: (ctx) => {
543
591
  const data = settingsData(ctx);
544
592
  if (!data) return null;
593
+ if (isMcpOnlySettings(data)) return null;
545
594
  return Boolean(data.sandbox && (data.sandbox.mode || typeof data.sandbox === 'string'));
546
595
  },
547
596
  impact: 'high',
@@ -1017,6 +1066,7 @@ const GEMINI_TECHNIQUES = {
1017
1066
  check: (ctx) => {
1018
1067
  const data = settingsData(ctx);
1019
1068
  if (!data) return null;
1069
+ if (isMcpOnlySettings(data)) return null;
1020
1070
  const sandbox = data.sandbox;
1021
1071
  if (!sandbox) return false;
1022
1072
  const mode = typeof sandbox === 'string' ? sandbox : sandbox.mode;
@@ -1633,8 +1683,9 @@ const GEMINI_TECHNIQUES = {
1633
1683
  id: 'GM-J01',
1634
1684
  name: 'Rate limit/quota awareness documented',
1635
1685
  check: (ctx) => {
1636
- const gmd = geminiMd(ctx) || '';
1637
- return /\brate limit\b|\bquota\b|\brequests? per\b|\bcost\b|\btoken\b.*\blimit\b/i.test(gmd);
1686
+ const docs = docsBundle(ctx);
1687
+ if (!docs.trim()) return null;
1688
+ return /\brate[- ]?limit\b|\bquota\b|\brequests? per\b|\bcost\b|\btoken\b.*\blimit\b|\bthrottl|\b429\b/i.test(docs);
1638
1689
  },
1639
1690
  impact: 'medium',
1640
1691
  rating: 3,
@@ -1677,6 +1728,7 @@ const GEMINI_TECHNIQUES = {
1677
1728
  check: (ctx) => {
1678
1729
  const data = settingsData(ctx);
1679
1730
  if (!data) return null;
1731
+ if (isMcpOnlySettings(data)) return null;
1680
1732
  // Check if session/history settings are explicit
1681
1733
  return data.history !== undefined || data.session !== undefined || data.telemetry !== undefined;
1682
1734
  },
@@ -1836,11 +1888,11 @@ const GEMINI_TECHNIQUES = {
1836
1888
 
1837
1889
  geminiTokenUsageAwareness: {
1838
1890
  id: 'GM-K05',
1839
- name: 'Token usage awareness in GEMINI.md',
1891
+ name: 'Token usage awareness documented',
1840
1892
  check: (ctx) => {
1841
- const gmd = geminiMd(ctx) || '';
1842
- if (!gmd) return null;
1843
- return /\btoken\b|\bcontext window\b|\bcontext length\b|\b1M\b|\btruncat/i.test(gmd);
1893
+ const docs = docsBundle(ctx);
1894
+ if (!docs.trim()) return null;
1895
+ return /\btoken\b|\bcontext window\b|\bcontext length\b|\b1M\b|\btruncat/i.test(docs);
1844
1896
  },
1845
1897
  impact: 'low',
1846
1898
  rating: 2,
@@ -1863,7 +1915,12 @@ const GEMINI_TECHNIQUES = {
1863
1915
  name: 'Custom commands exist in .gemini/commands/',
1864
1916
  check: (ctx) => {
1865
1917
  const commandFiles = ctx.commandFiles ? ctx.commandFiles() : [];
1866
- return commandFiles.length > 0;
1918
+ if (commandFiles.length > 0) return true;
1919
+ // Custom commands are opt-in — only fire when the repo already has
1920
+ // a .gemini/commands/ directory (implying the user intends to use
1921
+ // commands but hasn't populated it).
1922
+ const hasCommandsDir = ctx.hasDir && ctx.hasDir('.gemini/commands');
1923
+ return hasCommandsDir ? false : null;
1867
1924
  },
1868
1925
  impact: 'medium',
1869
1926
  rating: 3,
@@ -2109,7 +2166,22 @@ const GEMINI_TECHNIQUES = {
2109
2166
  },
2110
2167
  geminiPropagationCompleteness: {
2111
2168
  id: 'GM-P03', name: 'No dangling surface references',
2112
- check: (ctx) => { const g = ctx.geminiMdContent(); if (!g) return null; const issues = []; if (/\bhooks?\b/i.test(g)) { const s = ctx.settingsJson(); if (!s || (!s.hooks && !s.BeforeTool && !s.AfterTool)) issues.push('hooks'); } if (/\bskills?\b/i.test(g) && !(ctx.hasDir ? ctx.hasDir('.gemini/skills') : false)) issues.push('skills'); if (/\bextensions?\b/i.test(g) && !(ctx.hasDir ? ctx.hasDir('.gemini/extensions') : false)) issues.push('extensions'); return issues.length === 0; },
2169
+ check: (ctx) => {
2170
+ const g = ctx.geminiMdContent();
2171
+ if (!g) return null;
2172
+ const issues = [];
2173
+ // Require specific Gemini-CLI vocabulary before asserting a dangling
2174
+ // reference. "skills" is too generic a word (appears in unrelated
2175
+ // product copy); require `.gemini/skills` path or `gemini skills`
2176
+ // phrasing. Same for hooks/extensions.
2177
+ if (/\.gemini\/hooks\b|\bgemini hooks?\b|\bhooksConfig\b|\bBeforeTool\b|\bAfterTool\b/i.test(g)) {
2178
+ const s = ctx.settingsJson();
2179
+ if (!s || !s.ok || (!s.data || (!s.data.hooks && !s.data.BeforeTool && !s.data.AfterTool))) issues.push('hooks');
2180
+ }
2181
+ if (/\.gemini\/skills\b|\bgemini skills?\b/i.test(g) && !(ctx.hasDir ? ctx.hasDir('.gemini/skills') : false)) issues.push('skills');
2182
+ if (/\.gemini\/extensions\b|\bgemini extensions?\b/i.test(g) && !(ctx.hasDir ? ctx.hasDir('.gemini/extensions') : false)) issues.push('extensions');
2183
+ return issues.length === 0;
2184
+ },
2113
2185
  impact: 'high', rating: 4, category: 'release-freshness',
2114
2186
  fix: 'Ensure all surfaces mentioned in GEMINI.md have corresponding definition files.',
2115
2187
  template: 'gemini-md', file: () => 'GEMINI.md', line: () => 1,
@@ -36,26 +36,59 @@ class WindsurfProjectContext extends ProjectContext {
36
36
  * Windsurf uses Markdown + YAML frontmatter (NOT MDC like Cursor).
37
37
  * 4 activation modes: Always, Auto, Agent-Requested, Manual.
38
38
  * 10K char limit per rule file.
39
+ *
40
+ * PP-03: also recognises the `.windsurfrules/` *directory* convention
41
+ * (observed in rudrankriyam/Ichi) where the rule files sit in
42
+ * `.windsurfrules/*.md` or `*.mdc` instead of `.windsurf/rules/`.
39
43
  */
40
44
  windsurfRules() {
41
- const dir = path.join(this.dir, '.windsurf', 'rules');
42
- const files = listFiles(dir, f => f.endsWith('.md'));
43
- return files.map(f => {
45
+ const collected = [];
46
+
47
+ // Primary: .windsurf/rules/*.md
48
+ const primaryDir = path.join(this.dir, '.windsurf', 'rules');
49
+ const primaryFiles = listFiles(primaryDir, f => f.endsWith('.md'));
50
+ for (const f of primaryFiles) {
44
51
  const relPath = `.windsurf/rules/${f}`;
45
52
  const content = this.fileContent(relPath);
46
- if (!content) return null;
53
+ if (!content) continue;
47
54
  const parsed = parseWindsurfRule(content);
48
55
  const ruleType = detectRuleType(parsed.frontmatter);
49
- return {
56
+ collected.push({
50
57
  name: f.replace('.md', ''),
51
58
  path: relPath,
52
59
  frontmatter: parsed.frontmatter,
53
60
  body: parsed.body,
54
61
  ruleType,
55
- charCount: (content || '').length,
56
- overLimit: (content || '').length > 10000,
57
- };
58
- }).filter(Boolean);
62
+ charCount: content.length,
63
+ overLimit: content.length > 10000,
64
+ });
65
+ }
66
+
67
+ // PP-03: fallback — `.windsurfrules/` as a directory.
68
+ const altDir = path.join(this.dir, '.windsurfrules');
69
+ try {
70
+ if (fs.statSync(altDir).isDirectory()) {
71
+ const altFiles = listFiles(altDir, f => f.endsWith('.md') || f.endsWith('.mdc'));
72
+ for (const f of altFiles) {
73
+ const relPath = `.windsurfrules/${f}`;
74
+ const content = this.fileContent(relPath);
75
+ if (!content) continue;
76
+ const parsed = parseWindsurfRule(content);
77
+ const ruleType = detectRuleType(parsed.frontmatter);
78
+ collected.push({
79
+ name: f.replace(/\.(md|mdc)$/, ''),
80
+ path: relPath,
81
+ frontmatter: parsed.frontmatter,
82
+ body: parsed.body,
83
+ ruleType,
84
+ charCount: content.length,
85
+ overLimit: content.length > 10000,
86
+ });
87
+ }
88
+ }
89
+ } catch { /* not a directory */ }
90
+
91
+ return collected;
59
92
  }
60
93
 
61
94
  /**
@@ -81,15 +114,92 @@ class WindsurfProjectContext extends ProjectContext {
81
114
 
82
115
  /**
83
116
  * .windsurfrules content (deprecated).
117
+ *
118
+ * PP-03: handles three real-world shapes:
119
+ * 1. Classic file with rule text.
120
+ * 2. Pointer file — a single short line referencing another markdown
121
+ * file (e.g. `.ai/instructions.md`, `.llmrules`,
122
+ * `.ai/tech-stack.md`). Observed in ShareX/XerahS,
123
+ * Brawl345/Image-Reverse-Search-WebExtension, wepublish/wepublish.
124
+ * 3. Directory convention — `.windsurfrules/` is itself a directory
125
+ * of rule files. Observed in rudrankriyam/Ichi. In that case this
126
+ * method returns the concatenated body of all contained rule files
127
+ * so consumer checks (architecture / verification / etc.) see the
128
+ * effective instruction bundle.
84
129
  */
85
130
  legacyWindsurfrules() {
86
- return this.fileContent('.windsurfrules');
131
+ // Directory form first — `.windsurfrules/` as a directory.
132
+ const altDir = path.join(this.dir, '.windsurfrules');
133
+ try {
134
+ if (fs.statSync(altDir).isDirectory()) {
135
+ const files = listFiles(altDir, f => f.endsWith('.md') || f.endsWith('.mdc'));
136
+ const bodies = files
137
+ .map(f => this.fileContent(`.windsurfrules/${f}`) || '')
138
+ .filter(Boolean);
139
+ return bodies.length > 0 ? bodies.join('\n') : '';
140
+ }
141
+ } catch { /* not a directory */ }
142
+
143
+ const raw = this.fileContent('.windsurfrules');
144
+ if (!raw) return null;
145
+
146
+ // Pointer form — one short line that looks like a relative path.
147
+ const trimmed = raw.trim();
148
+ if (trimmed.length < 200) {
149
+ const lines = trimmed.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
150
+ if (lines.length <= 3 && lines.every(l => /^[a-zA-Z0-9_./-]+(\.(md|mdc|txt|rst))?$/.test(l))) {
151
+ let combined = raw;
152
+ for (const line of lines) {
153
+ const referenced = this.fileContent(line);
154
+ if (referenced) combined += '\n' + referenced;
155
+ }
156
+ return combined;
157
+ }
158
+ }
159
+ return raw;
87
160
  }
88
161
 
89
162
  hasLegacyRules() {
90
163
  return Boolean(this.legacyWindsurfrules());
91
164
  }
92
165
 
166
+ /**
167
+ * PP-03: True only when `.windsurfrules` exists as a regular file
168
+ * containing legacy rule text (not a pointer and not a directory).
169
+ * Used by checks that warn about the deprecated single-file format.
170
+ */
171
+ hasRawLegacyWindsurfrules() {
172
+ const altDir = path.join(this.dir, '.windsurfrules');
173
+ try {
174
+ if (fs.statSync(altDir).isDirectory()) return false;
175
+ } catch { /* not a dir */ }
176
+ const raw = this.fileContent('.windsurfrules');
177
+ if (!raw) return false;
178
+ const trimmed = raw.trim();
179
+ if (trimmed.length < 200) {
180
+ const lines = trimmed.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
181
+ if (lines.length <= 3 && lines.every(l => /^[a-zA-Z0-9_./-]+(\.(md|mdc|txt|rst))?$/.test(l))) {
182
+ // It's a pointer — not a raw legacy file.
183
+ return false;
184
+ }
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * PP-03: surface detection helper — any instruction surface that
191
+ * Cascade/Windsurf can pick up.
192
+ */
193
+ hasAnyInstructionsSurface() {
194
+ return (
195
+ this.windsurfRules().length > 0 ||
196
+ Boolean(this.legacyWindsurfrules()) ||
197
+ Boolean(this.fileContent('AGENTS.md')) ||
198
+ Boolean(this.fileContent('CLAUDE.md')) ||
199
+ Boolean(this.fileContent('.ai/instructions.md'))
200
+ );
201
+ }
202
+
93
203
  // ─── MCP config (.windsurf/mcp.json) ──────────────────────────────────
94
204
 
95
205
  /**
@@ -160,10 +160,23 @@ function isValidWindsurfFrontmatter(frontmatter) {
160
160
  }
161
161
 
162
162
  function docsBundle(ctx) {
163
+ // PP-03: broadened to include the surfaces real Windsurf-using repos
164
+ // actually use for instructions (AGENTS.md, CLAUDE.md, CONTRIBUTING.md,
165
+ // ARCHITECTURE.md, DEVELOPMENT.md) plus the `.ai/` convention observed
166
+ // in ShareX/XerahS and wepublish/wepublish. This mirrors the Gemini
167
+ // PP-02 broadening and ensures docs-quality checks do not FP on repos
168
+ // that keep guidance outside `.windsurf/rules/` alone.
163
169
  const rules = allRulesContent(ctx) || '';
164
170
  const readme = ctx.fileContent('README.md') || '';
165
171
  const legacy = ctx.legacyWindsurfrules ? (ctx.legacyWindsurfrules() || '') : '';
166
- return `${rules}\n${readme}\n${legacy}`;
172
+ const agents = ctx.fileContent('AGENTS.md') || '';
173
+ const claudeMd = ctx.fileContent('CLAUDE.md') || ctx.fileContent('.claude/CLAUDE.md') || '';
174
+ const contributing = ctx.fileContent('CONTRIBUTING.md') || '';
175
+ const architecture = ctx.fileContent('ARCHITECTURE.md') || '';
176
+ const development = ctx.fileContent('DEVELOPMENT.md') || ctx.fileContent('DEVELOPING.md') || '';
177
+ const aiInstructions = ctx.fileContent('.ai/instructions.md') || ctx.fileContent('.ai/tech-stack.md') || '';
178
+ const windsurfMd = ctx.fileContent('WINDSURF.md') || ctx.fileContent('windsurf_rules.md') || '';
179
+ return `${rules}\n${readme}\n${legacy}\n${agents}\n${claudeMd}\n${contributing}\n${architecture}\n${development}\n${aiInstructions}\n${windsurfMd}`;
167
180
  }
168
181
 
169
182
  function expectedVerificationCategories(ctx) {
@@ -257,8 +270,14 @@ const WINDSURF_TECHNIQUES = {
257
270
  id: 'WS-A01',
258
271
  name: '.windsurf/rules/ directory exists with .md files',
259
272
  check: (ctx) => {
273
+ // PP-03: `windsurfRules()` now also enumerates the
274
+ // `.windsurfrules/` directory form. In addition, pointer-style
275
+ // `.windsurfrules` (one-liner referencing e.g. `.ai/instructions.md`)
276
+ // counts because it resolves to a real instruction body.
260
277
  const rules = ctx.windsurfRules ? ctx.windsurfRules() : [];
261
- return rules.length > 0;
278
+ if (rules.length > 0) return true;
279
+ const legacy = ctx.legacyWindsurfrules ? ctx.legacyWindsurfrules() : null;
280
+ return Boolean(legacy && legacy.trim().length > 0);
262
281
  },
263
282
  impact: 'critical',
264
283
  rating: 5,
@@ -273,8 +292,13 @@ const WINDSURF_TECHNIQUES = {
273
292
  id: 'WS-A02',
274
293
  name: 'No .windsurfrules without migration to .windsurf/rules/',
275
294
  check: (ctx) => {
276
- const hasLegacy = ctx.hasLegacyRules ? ctx.hasLegacyRules() : Boolean(ctx.fileContent('.windsurfrules'));
277
- return !hasLegacy;
295
+ // PP-03: only raw legacy single-file `.windsurfrules` (non-pointer,
296
+ // non-directory) counts as the deprecated form. Pointer files
297
+ // delegating to a modern instruction surface (e.g.
298
+ // `.ai/instructions.md`) and the `.windsurfrules/` directory
299
+ // convention are both acceptable modern patterns.
300
+ const raw = ctx.hasRawLegacyWindsurfrules ? ctx.hasRawLegacyWindsurfrules() : false;
301
+ return !raw;
278
302
  },
279
303
  impact: 'critical',
280
304
  rating: 5,
@@ -306,9 +330,12 @@ const WINDSURF_TECHNIQUES = {
306
330
  id: 'WS-A04',
307
331
  name: 'Rules have valid YAML frontmatter',
308
332
  check: (ctx) => {
333
+ // PP-03: absent frontmatter is acceptable — Windsurf defaults such
334
+ // rules to `always_on`. Only flag when frontmatter *is* present
335
+ // and malformed, or when the declared trigger/field is invalid.
309
336
  const rules = ctx.windsurfRules ? ctx.windsurfRules() : [];
310
337
  if (rules.length === 0) return null;
311
- return rules.every((rule) => isValidWindsurfFrontmatter(rule.frontmatter));
338
+ return rules.every((rule) => rule.frontmatter == null || isValidWindsurfFrontmatter(rule.frontmatter));
312
339
  },
313
340
  impact: 'high',
314
341
  rating: 4,
@@ -462,8 +489,13 @@ const WINDSURF_TECHNIQUES = {
462
489
  id: 'WS-B03',
463
490
  name: 'Workflow slash commands exist in .windsurf/workflows/',
464
491
  check: (ctx) => {
492
+ // PP-03: workflows are opt-in. N/A when the repo has no
493
+ // `.windsurf/workflows/` directory at all — firing a fail on every
494
+ // Windsurf repo without workflows produced systematic bias.
465
495
  const files = ctx.workflowFiles ? ctx.workflowFiles() : [];
466
- return files.length > 0;
496
+ if (files.length > 0) return true;
497
+ if (!ctx.hasDir || !ctx.hasDir('.windsurf/workflows')) return null;
498
+ return false;
467
499
  },
468
500
  impact: 'medium',
469
501
  rating: 3,
@@ -498,8 +530,15 @@ const WINDSURF_TECHNIQUES = {
498
530
  id: 'WS-B05',
499
531
  name: 'Memories configured for persistent context',
500
532
  check: (ctx) => {
533
+ // PP-03: memories are workspace-local and strictly opt-in. The
534
+ // technique docs themselves warn not to rely on them (see
535
+ // windsurfMemoryScopeDocumented). Firing a fail on every repo that
536
+ // doesn't ship a `.windsurf/memories/` directory produced a 10/10
537
+ // FP rate. N/A when the repo doesn't opt in.
501
538
  const memories = ctx.memoryFiles ? ctx.memoryFiles() : [];
502
- return memories.length > 0;
539
+ if (memories.length > 0) return true;
540
+ if (!ctx.hasDir || !ctx.hasDir('.windsurf/memories')) return null;
541
+ return false;
503
542
  },
504
543
  impact: 'medium',
505
544
  rating: 3,
@@ -740,10 +779,18 @@ const WINDSURF_TECHNIQUES = {
740
779
  id: 'WS-D01',
741
780
  name: 'Rules properly reach Cascade (not just .windsurfrules)',
742
781
  check: (ctx) => {
782
+ // PP-03: `windsurfRules()` now includes `.windsurfrules/`
783
+ // directory form. Pointer-style legacy `.windsurfrules` that
784
+ // points at a modern instruction file (`.ai/instructions.md`,
785
+ // AGENTS.md, etc.) is also acceptable since the referenced body
786
+ // is what Cascade actually receives.
743
787
  const rules = ctx.windsurfRules ? ctx.windsurfRules() : [];
744
788
  const hasLegacy = ctx.hasLegacyRules ? ctx.hasLegacyRules() : false;
745
789
  if (rules.length === 0 && !hasLegacy) return null;
746
- return rules.length > 0;
790
+ if (rules.length > 0) return true;
791
+ // Raw legacy single-file is a genuine miss; pointer/dir is fine.
792
+ const raw = ctx.hasRawLegacyWindsurfrules ? ctx.hasRawLegacyWindsurfrules() : false;
793
+ return !raw;
747
794
  },
748
795
  impact: 'critical',
749
796
  rating: 5,
@@ -758,9 +805,15 @@ const WINDSURF_TECHNIQUES = {
758
805
  id: 'WS-D02',
759
806
  name: 'Cascade multi-file editing awareness documented',
760
807
  check: (ctx) => {
808
+ // PP-03: this is a Cascade-specific awareness advisory. It should
809
+ // only fire when the repo has actual `.windsurf/rules/` content
810
+ // that could reasonably cover Cascade guidance. Pointer-only
811
+ // `.windsurfrules` repos and repos with just a README keep this
812
+ // check N/A — the README is not the right place for Cascade
813
+ // multi-file editing notes.
761
814
  const rules = allRulesContent(ctx);
762
815
  if (!rules.trim()) return null;
763
- return /multi.?file|cross.?file|cascade.*edit|multiple.*file/i.test(rules);
816
+ return /multi.?file|cross.?file|cascade.*edit|multiple.*file/i.test(docsBundle(ctx));
764
817
  },
765
818
  impact: 'medium',
766
819
  rating: 3,
@@ -775,9 +828,11 @@ const WINDSURF_TECHNIQUES = {
775
828
  id: 'WS-D03',
776
829
  name: 'Steps automation awareness documented',
777
830
  check: (ctx) => {
831
+ // PP-03: Cascade-specific advisory; N/A when no
832
+ // `.windsurf/rules/` content exists.
778
833
  const rules = allRulesContent(ctx);
779
834
  if (!rules.trim()) return null;
780
- return /steps|automation|step.?by.?step|cascade.*step/i.test(rules);
835
+ return /steps|automation|step.?by.?step|cascade.*step/i.test(docsBundle(ctx));
781
836
  },
782
837
  impact: 'medium',
783
838
  rating: 3,
@@ -792,9 +847,12 @@ const WINDSURF_TECHNIQUES = {
792
847
  id: 'WS-D04',
793
848
  name: 'Agent session length awareness',
794
849
  check: (ctx) => {
850
+ // PP-03: Cascade-specific advisory; N/A when no
851
+ // `.windsurf/rules/` content exists (advisory belongs in rules,
852
+ // not in README).
795
853
  const rules = allRulesContent(ctx);
796
854
  if (!rules.trim()) return null;
797
- return /session.*length|session.*limit|context.*drift|long.*session/i.test(rules);
855
+ return /session.*length|session.*limit|context.*drift|long.*session/i.test(docsBundle(ctx));
798
856
  },
799
857
  impact: 'low',
800
858
  rating: 2,
@@ -809,9 +867,13 @@ const WINDSURF_TECHNIQUES = {
809
867
  id: 'WS-D05',
810
868
  name: 'Cascade skills configured for project needs',
811
869
  check: (ctx) => {
870
+ // PP-03: the `.windsurf/skills/` directory is itself a valid
871
+ // signal (observed in snyk/snyk-intellij-plugin). Otherwise
872
+ // N/A unless the repo has `.windsurf/rules/` content.
873
+ if (ctx.hasDir && ctx.hasDir('.windsurf/skills')) return true;
812
874
  const rules = allRulesContent(ctx);
813
875
  if (!rules.trim()) return null;
814
- return /skill|capability|tool.*use|cascade.*skill/i.test(rules);
876
+ return /\bskill\b|\bcapability\b|tool.*use|cascade.*skill/i.test(docsBundle(ctx));
815
877
  },
816
878
  impact: 'medium',
817
879
  rating: 3,
@@ -925,11 +987,21 @@ const WINDSURF_TECHNIQUES = {
925
987
  id: 'WS-F01',
926
988
  name: 'Rules include build/test/lint commands',
927
989
  check: (ctx) => {
928
- const content = coreRulesContent(ctx) || allRulesContent(ctx);
929
- if (!content.trim()) return null;
990
+ // PP-03: verification commands often live in README / AGENTS /
991
+ // CONTRIBUTING. Fall back to the full docsBundle if the core
992
+ // rules don't mention them, so we don't FP on repos that keep
993
+ // commands in a standard README section.
994
+ const core = coreRulesContent(ctx) || allRulesContent(ctx);
930
995
  const expected = expectedVerificationCategories(ctx);
931
- if (expected.length === 0) return /\bverify\b|\btest\b|\blint\b|\bbuild\b/i.test(content);
932
- return expected.every(cat => hasCommandMention(content, cat));
996
+ if (expected.length === 0) {
997
+ const combined = core || docsBundle(ctx);
998
+ if (!combined.trim()) return null;
999
+ return /\bverify\b|\btest\b|\blint\b|\bbuild\b/i.test(combined);
1000
+ }
1001
+ if (expected.every(cat => hasCommandMention(core, cat))) return true;
1002
+ const docs = docsBundle(ctx);
1003
+ if (!docs.trim()) return null;
1004
+ return expected.every(cat => hasCommandMention(docs, cat));
933
1005
  },
934
1006
  impact: 'high',
935
1007
  rating: 5,
@@ -944,9 +1016,13 @@ const WINDSURF_TECHNIQUES = {
944
1016
  id: 'WS-F02',
945
1017
  name: 'Rules include architecture section or Mermaid diagram',
946
1018
  check: (ctx) => {
947
- const content = allRulesContent(ctx);
948
- if (!content.trim()) return null;
949
- return hasArchitecture(content);
1019
+ // PP-03: architecture content commonly lives in ARCHITECTURE.md
1020
+ // or a README section, not duplicated inside rules. Widen to
1021
+ // docsBundle. N/A only when the repo has no instruction surface
1022
+ // whatsoever (not even a README).
1023
+ const bundle = docsBundle(ctx);
1024
+ if (!bundle.trim()) return null;
1025
+ return hasArchitecture(bundle);
950
1026
  },
951
1027
  impact: 'medium',
952
1028
  rating: 4,
@@ -997,12 +1073,17 @@ const WINDSURF_TECHNIQUES = {
997
1073
  id: 'WS-F05',
998
1074
  name: 'Rules reference project-specific patterns (not generic)',
999
1075
  check: (ctx) => {
1000
- const content = allRulesContent(ctx);
1076
+ // PP-03: widen to docsBundle and add stack-agnostic project
1077
+ // directory markers (internal/, pkg/, cmd/, crates/, modules/,
1078
+ // packages/, tests/, docs/, examples/). The previous JS-heavy
1079
+ // regex produced FPs on Rust/Go/Java/Swift/Kotlin repos whose
1080
+ // project layouts never mention src/app/api etc.
1081
+ const content = docsBundle(ctx);
1001
1082
  if (!content.trim()) return null;
1002
1083
  const pkg = ctx.jsonFile ? ctx.jsonFile('package.json') : null;
1003
1084
  const projectName = (pkg && pkg.name) || path.basename(ctx.dir);
1004
1085
  const hasSpecific = content.includes(projectName) ||
1005
- /src\/|app\/|api\/|routes\/|services\/|components\/|lib\/|cmd\//i.test(content);
1086
+ /\b(src|app|api|routes|services|components|lib|cmd|internal|pkg|crates|modules|packages|tests?|docs|examples|scripts)\//i.test(content);
1006
1087
  return hasSpecific;
1007
1088
  },
1008
1089
  impact: 'medium',
@@ -1471,7 +1552,11 @@ const WINDSURF_TECHNIQUES = {
1471
1552
  id: 'WS-L01',
1472
1553
  name: 'Rules mention modern Windsurf features (Steps, Memories, Workflows)',
1473
1554
  check: (ctx) => {
1474
- const content = allRulesContent(ctx);
1555
+ // PP-03: widen to docsBundle; also credit `.windsurf/workflows` or
1556
+ // `.windsurf/skills` directories as structural evidence that the
1557
+ // repo has adopted modern Windsurf features.
1558
+ if (ctx.hasDir && (ctx.hasDir('.windsurf/workflows') || ctx.hasDir('.windsurf/skills'))) return true;
1559
+ const content = docsBundle(ctx);
1475
1560
  if (!content.trim()) return null;
1476
1561
  return /steps|memories|workflow|cascade|skill|slash command/i.test(content);
1477
1562
  },
@@ -1488,9 +1573,12 @@ const WINDSURF_TECHNIQUES = {
1488
1573
  id: 'WS-L02',
1489
1574
  name: 'No deprecated patterns (.windsurfrules for agent)',
1490
1575
  check: (ctx) => {
1491
- const legacy = ctx.legacyWindsurfrules ? ctx.legacyWindsurfrules() : null;
1492
- if (!legacy) return null;
1493
- return false; // Legacy exists = deprecated pattern
1576
+ // PP-03: only the raw single-file legacy form is deprecated.
1577
+ // Pointer-style `.windsurfrules` and the `.windsurfrules/`
1578
+ // directory convention are not.
1579
+ const raw = ctx.hasRawLegacyWindsurfrules ? ctx.hasRawLegacyWindsurfrules() : false;
1580
+ if (!raw) return null;
1581
+ return false;
1494
1582
  },
1495
1583
  impact: 'high',
1496
1584
  rating: 4,
@@ -1556,7 +1644,11 @@ const WINDSURF_TECHNIQUES = {
1556
1644
  id: 'WS-L06',
1557
1645
  name: 'Rules guide Cascade context usage (@-mentions, file refs)',
1558
1646
  check: (ctx) => {
1559
- const content = allRulesContent(ctx);
1647
+ // PP-03: Cascade-specific deep-quality advisory; N/A when no
1648
+ // `.windsurf/rules/` content exists.
1649
+ const rules = allRulesContent(ctx);
1650
+ if (!rules.trim()) return null;
1651
+ const content = docsBundle(ctx);
1560
1652
  if (!content.trim()) return null;
1561
1653
  return /@|file.*reference|context.*include|codebase|index/i.test(content);
1562
1654
  },
@@ -1573,9 +1665,11 @@ const WINDSURF_TECHNIQUES = {
1573
1665
  id: 'WS-L07',
1574
1666
  name: 'Session drift awareness documented',
1575
1667
  check: (ctx) => {
1576
- const content = allRulesContent(ctx);
1577
- if (!content.trim()) return null;
1578
- return /session.*drift|context.*window|long.*session|session.*length|refresh.*context/i.test(content);
1668
+ // PP-03: Cascade-specific deep-quality advisory; N/A when no
1669
+ // `.windsurf/rules/` content exists.
1670
+ const rules = allRulesContent(ctx);
1671
+ if (!rules.trim()) return null;
1672
+ return /session.*drift|context.*window|long.*session|session.*length|refresh.*context/i.test(docsBundle(ctx));
1579
1673
  },
1580
1674
  impact: 'low',
1581
1675
  rating: 2,
@@ -1650,9 +1744,13 @@ const WINDSURF_TECHNIQUES = {
1650
1744
  id: 'WS-M04',
1651
1745
  name: 'Windows/WSL usage includes a Windsurf stability caveat',
1652
1746
  check: (ctx) => {
1747
+ // PP-03: relevance was keyed off `os.platform()`, which is the
1748
+ // *host* running the audit (always Windows in our environment),
1749
+ // causing a systematic 10/10 fail on every target repo. This
1750
+ // check should only fire when the *target repo* itself documents
1751
+ // Windows/WSL use — otherwise the advisory is not applicable.
1653
1752
  const docs = docsBundle(ctx);
1654
- const relevant = os.platform() === 'win32' || /\bwsl\b|\bwindows\b/i.test(docs);
1655
- if (!relevant) return null;
1753
+ if (!/\bwsl\b|\bnative windows\b|\bwindows subsystem\b/i.test(docs)) return null;
1656
1754
  return /\bwsl\b.{0,40}\b(crash|unstable|avoid|native windows)\b|\bnative windows\b|\bavoid wsl\b/i.test(docs);
1657
1755
  },
1658
1756
  impact: 'medium',
@@ -1672,8 +1770,23 @@ const WINDSURF_TECHNIQUES = {
1672
1770
  id: 'WS-N01',
1673
1771
  name: 'Domain pack detection returns relevant results',
1674
1772
  check: (ctx) => {
1773
+ // PP-03: expand stack markers so we also recognise Kotlin/Java
1774
+ // (build.gradle, build.gradle.kts, pom.xml), Swift
1775
+ // (Package.swift, *.xcodeproj), .NET (*.csproj / *.sln), Ruby
1776
+ // (Gemfile), PHP (composer.json), requirements.txt and
1777
+ // Pipfile/poetry. Without these the check FP'd on every
1778
+ // non-JS/Go/Rust/Python repo.
1675
1779
  const pkg = ctx.jsonFile ? ctx.jsonFile('package.json') : null;
1676
- return Boolean(pkg || ctx.fileContent('go.mod') || ctx.fileContent('Cargo.toml') || ctx.fileContent('pyproject.toml'));
1780
+ if (pkg) return true;
1781
+ const simple = [
1782
+ 'go.mod', 'Cargo.toml', 'pyproject.toml', 'requirements.txt',
1783
+ 'Pipfile', 'poetry.lock', 'Gemfile', 'composer.json', 'pom.xml',
1784
+ 'build.gradle', 'build.gradle.kts', 'Package.swift', 'mix.exs',
1785
+ ];
1786
+ if (simple.some(f => ctx.fileContent(f))) return true;
1787
+ const files = ctx.files || [];
1788
+ if (files.some(f => /\.(csproj|sln|fsproj|vbproj|xcodeproj|xcworkspace)\/?$/i.test(f))) return true;
1789
+ return false;
1677
1790
  },
1678
1791
  impact: 'low',
1679
1792
  rating: 2,
@@ -1688,9 +1801,18 @@ const WINDSURF_TECHNIQUES = {
1688
1801
  id: 'WS-N02',
1689
1802
  name: 'MCP packs recommended based on project signals',
1690
1803
  check: (ctx) => {
1804
+ // PP-03: only relevant when the repo actually opts in to MCP
1805
+ // (either documents it or ships a project-local `.windsurf/mcp.json`).
1806
+ // Previously fired on every repo without global MCP config, which
1807
+ // is 10/10 FP against real Windsurf repos that don't use MCP.
1691
1808
  const mcp = mcpJsonData(ctx);
1692
1809
  const servers = mcp && mcp.mcpServers ? mcp.mcpServers : {};
1693
- return Object.keys(servers).length > 0;
1810
+ if (Object.keys(servers).length > 0) return true;
1811
+ const projectMcp = ctx.mcpConfig ? ctx.mcpConfig() : null;
1812
+ if (projectMcp && projectMcp.ok) return true;
1813
+ const docs = docsBundle(ctx);
1814
+ if (!/\bmcp\b/i.test(docs)) return null;
1815
+ return false;
1694
1816
  },
1695
1817
  impact: 'low',
1696
1818
  rating: 2,