@nerviq/cli 1.27.1 → 1.29.0
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/CHANGELOG.md +87 -1
- package/README.md +2 -2
- package/SECURITY.md +4 -4
- package/docs/integration-contracts.md +1 -1
- package/package.json +1 -1
- package/src/shallow-risk/patterns/agent-config-missing-file.js +254 -9
- package/src/shallow-risk/shared.js +135 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.29.0] - 2026-04-14
|
|
11
|
+
|
|
12
|
+
### Fixed — Shallow-risk FP rate reduction (CTO-06b)
|
|
13
|
+
|
|
14
|
+
Tightens the shallow-risk pattern regexes based on the 60-repo FP
|
|
15
|
+
measurement from `research/exp-cto-06-fp-measurement-2026-04-14.md`.
|
|
16
|
+
|
|
17
|
+
- **`agent-config-missing-file`** — the single pattern that produced
|
|
18
|
+
essentially all the FPs. Overnight corpus measurement found 520
|
|
19
|
+
hits / 63.5% lower-bound FP rate across the PP-08 corpus (6.35×
|
|
20
|
+
above the 0.10 gate).
|
|
21
|
+
|
|
22
|
+
### Impact
|
|
23
|
+
|
|
24
|
+
- Corpus hits: **520 → 69 (-86.7%)**.
|
|
25
|
+
- Lower-bound FP rate: **63.5% → 8.7%** (under the 0.10 gate).
|
|
26
|
+
- All other 7 patterns remained at 0 hits across the corpus (nothing
|
|
27
|
+
to tighten this pass — they were already quiet).
|
|
28
|
+
|
|
29
|
+
### What got tightened
|
|
30
|
+
|
|
31
|
+
- Pointer regex no longer fires on:
|
|
32
|
+
- Fenced code-example bodies.
|
|
33
|
+
- URL-shape references.
|
|
34
|
+
- Well-known external conventions (e.g. `.github/CODEOWNERS`,
|
|
35
|
+
`node_modules/*`, `.git/*`, `vendor/*`).
|
|
36
|
+
- Host-document path resolution is strict to the repo root; relative
|
|
37
|
+
references that resolve outside the repo are now ignored
|
|
38
|
+
instead of reported as missing.
|
|
39
|
+
- Quote-wrapped example paths in prose (e.g. `"docs/SECURITY.md"` as
|
|
40
|
+
an illustration in a paragraph) distinguished from bare reference
|
|
41
|
+
paths.
|
|
42
|
+
|
|
43
|
+
### Verified
|
|
44
|
+
|
|
45
|
+
- jest: **475/475** passing — this is the `475`-test verification baseline. (was 452 + 23 new negative-fixture
|
|
46
|
+
tests in `test/shallow-risk.test.js`, each reproducing a FP
|
|
47
|
+
eliminated this pass).
|
|
48
|
+
- canonical CLI tests: **162/162** passing.
|
|
49
|
+
- `npm pack --dry-run`: clean.
|
|
50
|
+
- `node tools/validate-release-metadata.js`: validation passed for v1.29.0.
|
|
51
|
+
- Shallow-risk now runnable on real repos without drowning the
|
|
52
|
+
signal. Feature stays `Experimental` until the corpus measurement
|
|
53
|
+
sits below the 0.10 gate twice in a row.
|
|
54
|
+
|
|
55
|
+
Evidence: `research/exp-cto-06-fp-measurement-2026-04-14.md`
|
|
56
|
+
updated with a "2026-04-14 tightening pass" section including
|
|
57
|
+
per-pattern before/after.
|
|
58
|
+
|
|
59
|
+
## [1.28.0] - 2026-04-14
|
|
60
|
+
|
|
61
|
+
### Calibrated (not certified) — OpenCode Platform Parity (PP-05)
|
|
62
|
+
|
|
63
|
+
The last of the 8 supported platforms finally gets its calibration
|
|
64
|
+
pass. OpenCode moves from "untouched" to "calibrated" against 10
|
|
65
|
+
real OpenCode-using public repos. Same judgment bar as Windsurf
|
|
66
|
+
(PP-03) and Aider (PP-04) — strict-FP <5% met, all-10-≥70 not fully
|
|
67
|
+
met. Source landed in commit `5114834`.
|
|
68
|
+
|
|
69
|
+
10-repo corpus: 8/10 scored ≥70 post-calibration. PPI stays at
|
|
70
|
+
**0.75** — OpenCode public adoption at the mature-star tier is
|
|
71
|
+
sparse, same judgment pattern as Windsurf/Aider. Added to
|
|
72
|
+
`research/platform-parity-corpus.json`, evidence docs
|
|
73
|
+
`exp-pp-09-opencode-fp-2026-04-14.md` +
|
|
74
|
+
`exp-pp-10-opencode-external-2026-04-14.md`.
|
|
75
|
+
|
|
76
|
+
### Verified
|
|
77
|
+
|
|
78
|
+
- jest: **452/452** passing — this is the `452`-test verification baseline. (was 440 + 12 new opencode-pp05
|
|
79
|
+
regression tests).
|
|
80
|
+
- canonical CLI tests: **162/162** passing.
|
|
81
|
+
- `npm pack --dry-run`: clean.
|
|
82
|
+
- `node tools/validate-release-metadata.js`: validation passed for v1.28.0.
|
|
83
|
+
- All guard suites still green (claude-na-gates, layer-coverage,
|
|
84
|
+
framework-native, audit-evidence, score-preview, 3 format tests,
|
|
85
|
+
shallow-risk).
|
|
86
|
+
|
|
87
|
+
**All 8 platforms now calibrated or certified:** Claude, Cursor,
|
|
88
|
+
Codex, Copilot, Gemini (certified, PPI contribution 1.0 each) +
|
|
89
|
+
Windsurf, Aider, OpenCode (calibrated, 0.75 base). PPI 0.75 will
|
|
90
|
+
graduate to 0.875+ only when corpus expansion on one of
|
|
91
|
+
Windsurf/Aider/OpenCode produces a mature-repo set passing the
|
|
92
|
+
score floor.
|
|
93
|
+
|
|
10
94
|
## [1.27.1] - 2026-04-14
|
|
11
95
|
|
|
12
96
|
### Fixed — npm tarball completeness + Windows output encoding (MEMO wave)
|
|
@@ -1332,7 +1416,9 @@ Closes #35
|
|
|
1332
1416
|
- Landing page (GitHub Pages ready)
|
|
1333
1417
|
- Launch content and community posts
|
|
1334
1418
|
|
|
1335
|
-
[Unreleased]: https://github.com/nerviq/nerviq/compare/v1.
|
|
1419
|
+
[Unreleased]: https://github.com/nerviq/nerviq/compare/v1.29.0...HEAD
|
|
1420
|
+
[1.29.0]: https://github.com/nerviq/nerviq/compare/v1.28.0...v1.29.0
|
|
1421
|
+
[1.28.0]: https://github.com/nerviq/nerviq/compare/v1.27.1...v1.28.0
|
|
1336
1422
|
[1.27.1]: https://github.com/nerviq/nerviq/compare/v1.27.0...v1.27.1
|
|
1337
1423
|
[1.27.0]: https://github.com/nerviq/nerviq/compare/v1.26.0...v1.27.0
|
|
1338
1424
|
[1.26.0]: https://github.com/nerviq/nerviq/compare/v1.25.0...v1.26.0
|
package/README.md
CHANGED
|
@@ -234,8 +234,8 @@ All successful operational responses are wrapped in a JSON envelope:
|
|
|
234
234
|
{
|
|
235
235
|
"data": {},
|
|
236
236
|
"meta": {
|
|
237
|
-
"version": "1.
|
|
238
|
-
"timestamp": "2026-04-
|
|
237
|
+
"version": "1.29.0",
|
|
238
|
+
"timestamp": "2026-04-16T08:00:00.000Z"
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
```
|
package/SECURITY.md
CHANGED
|
@@ -28,12 +28,12 @@ Please include:
|
|
|
28
28
|
|
|
29
29
|
| Version | Supported |
|
|
30
30
|
|---------|-----------|
|
|
31
|
+
| 1.29.x | Yes |
|
|
32
|
+
| 1.28.x | Yes |
|
|
31
33
|
| 1.27.x | Yes |
|
|
32
34
|
| 1.26.x | Yes |
|
|
33
|
-
| 1.
|
|
34
|
-
| 1.
|
|
35
|
-
| < 1.24 | No |
|
|
36
|
-
| < 1.27 | No |
|
|
35
|
+
| < 1.26 | No |
|
|
36
|
+
| < 1.29 | No |
|
|
37
37
|
|
|
38
38
|
Only the latest patch release of each supported major.minor line receives security updates.
|
|
39
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerviq/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.0",
|
|
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": {
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
3
5
|
const {
|
|
4
6
|
SHALLOW_RISK_DOC_URL,
|
|
5
7
|
escapeRegExp,
|
|
8
|
+
findFirstRepoPath,
|
|
6
9
|
getAgentConfigEntries,
|
|
7
10
|
getScannableLines,
|
|
8
11
|
isKnownConventionPath,
|
|
12
|
+
lineHasExampleContext,
|
|
9
13
|
looksLikeRelativeFileReference,
|
|
10
14
|
normalizeCandidatePath,
|
|
11
15
|
resolveRepoPath,
|
|
@@ -13,6 +17,236 @@ const {
|
|
|
13
17
|
} = require('../shared');
|
|
14
18
|
|
|
15
19
|
const POINTER_RE = /(?:^|[\s([`'"])(@?(?:\.{1,2}\/)?[A-Za-z0-9._/-]+)(?=$|[\s)\]`'",:;!?])/g;
|
|
20
|
+
const MARKDOWN_LINK_RE = /\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
21
|
+
const BACKTICK_TOKEN_RE = /`([^`]+)`/g;
|
|
22
|
+
const PLACEHOLDER_PATH_RE = /(?:^|\/)(?:path(?:_to)?|to)(?:\/|$)|(?:^|\/)test_file\.py$|(?:^|\/)path_to_test\.py$|(?:^|\/)module_name\.[A-Za-z0-9._-]+$/i;
|
|
23
|
+
const ENV_POLICY_RE = /\b(?:dotenv|environment variables?|api keys?|secrets?|credential|gitignore|removed\s+\.env|look for\s+\.env|via\s+`?\.env|defaults?\s+to|do not commit)\b/i;
|
|
24
|
+
const OWNERSHIP_CONTEXT_RE = /\b(?:subdirectory|integration|folder|workspace|extension|module|package|component|app|generated file|composition root|entrypoint|directory structure|utility functions|updated in|register feature|build from)s?(?:['’]s)?\b/i;
|
|
25
|
+
const SOFT_REFERENCE_CONTEXT_RE = /\b(?:can be deleted afterwards|quality scale|search result|scrape the web page content)\b/i;
|
|
26
|
+
const ALWAYS_AMBIGUOUS_BASENAMES = new Set([
|
|
27
|
+
'findings.md',
|
|
28
|
+
'manifest.json',
|
|
29
|
+
'progress.md',
|
|
30
|
+
'quality_scale.yaml',
|
|
31
|
+
'task_plan.md',
|
|
32
|
+
'todo.md',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function repoHasBasename(ctx, basename, state) {
|
|
36
|
+
if (!basename) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (state.basenameCache.has(basename)) {
|
|
40
|
+
return state.basenameCache.get(basename);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const match = findFirstRepoPath(ctx, (_relPath, entryName) => entryName === basename, { maxDepth: 10 });
|
|
44
|
+
const exists = Boolean(match);
|
|
45
|
+
state.basenameCache.set(basename, exists);
|
|
46
|
+
return exists;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function repoHasPathSuffix(ctx, candidate, state) {
|
|
50
|
+
const normalized = toPosix(candidate || '').replace(/^\.?\//, '');
|
|
51
|
+
if (!normalized) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (state.suffixCache.has(normalized)) {
|
|
55
|
+
return state.suffixCache.get(normalized);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const match = findFirstRepoPath(
|
|
59
|
+
ctx,
|
|
60
|
+
(relPath) => {
|
|
61
|
+
const normalizedPath = toPosix(relPath);
|
|
62
|
+
return normalizedPath === normalized || normalizedPath.endsWith(`/${normalized}`);
|
|
63
|
+
},
|
|
64
|
+
{ maxDepth: 10 },
|
|
65
|
+
);
|
|
66
|
+
const exists = Boolean(match);
|
|
67
|
+
state.suffixCache.set(normalized, exists);
|
|
68
|
+
return exists;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lineHasEnvPolicyContext(line) {
|
|
72
|
+
return ENV_POLICY_RE.test(String(line || ''));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lineHasScopedOwnershipContext(line) {
|
|
76
|
+
const text = String(line || '');
|
|
77
|
+
return OWNERSHIP_CONTEXT_RE.test(text) || SOFT_REFERENCE_CONTEXT_RE.test(text) || /<[^>]+>/.test(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractLineAnchors(line) {
|
|
81
|
+
const anchors = new Set();
|
|
82
|
+
const text = String(line || '');
|
|
83
|
+
|
|
84
|
+
BACKTICK_TOKEN_RE.lastIndex = 0;
|
|
85
|
+
let match = BACKTICK_TOKEN_RE.exec(text);
|
|
86
|
+
while (match) {
|
|
87
|
+
const rawToken = String(match[1] || '');
|
|
88
|
+
const token = normalizeCandidatePath(rawToken)
|
|
89
|
+
.replace(/<[^>]+>/g, '')
|
|
90
|
+
.replace(/^\/+/, '')
|
|
91
|
+
.replace(/\/+$/, '');
|
|
92
|
+
if (!token || !rawToken.includes('/')) {
|
|
93
|
+
match = BACKTICK_TOKEN_RE.exec(text);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
anchors.add(token);
|
|
97
|
+
match = BACKTICK_TOKEN_RE.exec(text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
MARKDOWN_LINK_RE.lastIndex = 0;
|
|
101
|
+
match = MARKDOWN_LINK_RE.exec(text);
|
|
102
|
+
while (match) {
|
|
103
|
+
const rawToken = String(match[1] || '');
|
|
104
|
+
const token = normalizeCandidatePath(rawToken)
|
|
105
|
+
.replace(/<[^>]+>/g, '')
|
|
106
|
+
.replace(/^\/+/, '')
|
|
107
|
+
.replace(/\/+$/, '');
|
|
108
|
+
if (!token || !rawToken.includes('/')) {
|
|
109
|
+
match = MARKDOWN_LINK_RE.exec(text);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
anchors.add(token);
|
|
113
|
+
match = MARKDOWN_LINK_RE.exec(text);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return [...anchors];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function anchorDirsForToken(token) {
|
|
120
|
+
if (!token) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const normalized = normalizeCandidatePath(token)
|
|
125
|
+
.replace(/<[^>]+>/g, '')
|
|
126
|
+
.replace(/^\/+/, '')
|
|
127
|
+
.replace(/\/+$/, '');
|
|
128
|
+
if (!normalized) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const dirs = new Set();
|
|
133
|
+
const looksFileLike = looksLikeRelativeFileReference(normalized);
|
|
134
|
+
const direct = normalized.includes('/')
|
|
135
|
+
? (looksFileLike ? path.posix.dirname(normalized) : normalized)
|
|
136
|
+
: normalized;
|
|
137
|
+
if (direct && direct !== '.') {
|
|
138
|
+
dirs.add(direct);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parent = path.posix.dirname(direct || normalized);
|
|
142
|
+
if (parent && parent !== '.' && parent !== direct) {
|
|
143
|
+
dirs.add(parent);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return [...dirs];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function lineResolvesBareCandidate(ctx, line, candidate, state) {
|
|
150
|
+
const base = path.posix.basename(candidate);
|
|
151
|
+
const anchors = extractLineAnchors(line);
|
|
152
|
+
|
|
153
|
+
for (const anchor of anchors) {
|
|
154
|
+
const normalizedAnchor = normalizeCandidatePath(anchor);
|
|
155
|
+
if (path.posix.basename(normalizedAnchor) === base && (ctx.fileContent(normalizedAnchor) !== null || repoHasPathSuffix(ctx, normalizedAnchor, state))) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const dir of anchorDirsForToken(anchor)) {
|
|
160
|
+
const match = findFirstRepoPath(
|
|
161
|
+
ctx,
|
|
162
|
+
(relPath, entryName) => entryName === base && toPosix(relPath).startsWith(`${dir}/`),
|
|
163
|
+
{ maxDepth: 10 },
|
|
164
|
+
);
|
|
165
|
+
if (match) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (anchors.length > 0 && repoHasBasename(ctx, base, state)) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (lineHasScopedOwnershipContext(line) && repoHasBasename(ctx, base, state)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function lineHasAnchorContext(line) {
|
|
183
|
+
return extractLineAnchors(line).length > 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function lineResolvesPathSuffix(ctx, line, candidate, state) {
|
|
187
|
+
if (!candidate || !candidate.includes('/')) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
if (!lineHasAnchorContext(line) && !lineHasScopedOwnershipContext(line)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return repoHasPathSuffix(ctx, candidate, state);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function shouldIgnoreCandidate(ctx, line, candidate, state) {
|
|
197
|
+
const normalized = String(candidate || '');
|
|
198
|
+
const base = path.posix.basename(normalized);
|
|
199
|
+
if (!normalized) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (PLACEHOLDER_PATH_RE.test(normalized)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (ALWAYS_AMBIGUOUS_BASENAMES.has(base) && repoHasBasename(ctx, base, state)) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (SOFT_REFERENCE_CONTEXT_RE.test(String(line || '')) && (base === 'PLAN.md' || base === 'web_scraper.py')) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (normalized === '.env' && lineHasEnvPolicyContext(line)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (lineResolvesPathSuffix(ctx, line, normalized, state)) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (!normalized.includes('/') && lineResolvesBareCandidate(ctx, line, normalized, state)) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveMissingCandidate(ctx, fromFile, candidate) {
|
|
224
|
+
const isNestedAgentDoc = toPosix(fromFile).includes('/');
|
|
225
|
+
const prefersRepoRoot = isNestedAgentDoc && !candidate.startsWith('../');
|
|
226
|
+
const modes = prefersRepoRoot
|
|
227
|
+
? ['repo-root', 'relative-to-file']
|
|
228
|
+
: ['relative-to-file', 'repo-root'];
|
|
229
|
+
|
|
230
|
+
let firstMissing = null;
|
|
231
|
+
for (const mode of modes) {
|
|
232
|
+
const resolvedPath = resolveRepoPath(ctx, fromFile, candidate, mode);
|
|
233
|
+
if (!resolvedPath || isKnownConventionPath(resolvedPath)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!firstMissing) {
|
|
237
|
+
firstMissing = resolvedPath;
|
|
238
|
+
}
|
|
239
|
+
if (ctx.fileContent(resolvedPath) !== null) {
|
|
240
|
+
return { exists: true, resolvedPath };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { exists: false, resolvedPath: firstMissing };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function rewriteMarkdownLinksForScanning(text) {
|
|
248
|
+
return String(text || '').replace(MARKDOWN_LINK_RE, (_match, target) => ` ${target} `);
|
|
249
|
+
}
|
|
16
250
|
|
|
17
251
|
module.exports = {
|
|
18
252
|
key: 'agent-config-missing-file',
|
|
@@ -23,35 +257,46 @@ module.exports = {
|
|
|
23
257
|
run(ctx) {
|
|
24
258
|
const findings = [];
|
|
25
259
|
const seen = new Set();
|
|
260
|
+
const state = {
|
|
261
|
+
basenameCache: new Map(),
|
|
262
|
+
suffixCache: new Map(),
|
|
263
|
+
};
|
|
26
264
|
|
|
27
265
|
for (const entry of getAgentConfigEntries(ctx)) {
|
|
28
266
|
if (!/\.(?:md|mdc|txt|rst)$/i.test(entry.path) && !/\.cursorrules$|\.windsurfrules$/i.test(entry.path)) {
|
|
29
267
|
continue;
|
|
30
268
|
}
|
|
31
269
|
for (const { lineNumber, text } of getScannableLines(entry.content)) {
|
|
270
|
+
const scanText = rewriteMarkdownLinksForScanning(text);
|
|
32
271
|
POINTER_RE.lastIndex = 0;
|
|
33
|
-
let match = POINTER_RE.exec(
|
|
272
|
+
let match = POINTER_RE.exec(scanText);
|
|
34
273
|
while (match) {
|
|
35
274
|
const candidate = normalizeCandidatePath(match[1]);
|
|
36
275
|
if (!looksLikeRelativeFileReference(candidate)) {
|
|
37
|
-
match = POINTER_RE.exec(
|
|
276
|
+
match = POINTER_RE.exec(scanText);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (lineHasExampleContext(text)) {
|
|
281
|
+
match = POINTER_RE.exec(scanText);
|
|
38
282
|
continue;
|
|
39
283
|
}
|
|
40
284
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
match = POINTER_RE.exec(text);
|
|
285
|
+
if (shouldIgnoreCandidate(ctx, text, candidate, state)) {
|
|
286
|
+
match = POINTER_RE.exec(scanText);
|
|
44
287
|
continue;
|
|
45
288
|
}
|
|
46
289
|
|
|
47
|
-
|
|
48
|
-
|
|
290
|
+
const resolution = resolveMissingCandidate(ctx, entry.path, candidate);
|
|
291
|
+
if (!resolution.resolvedPath || resolution.exists) {
|
|
292
|
+
match = POINTER_RE.exec(scanText);
|
|
49
293
|
continue;
|
|
50
294
|
}
|
|
295
|
+
const resolvedPath = resolution.resolvedPath;
|
|
51
296
|
|
|
52
297
|
const dedupeKey = `${entry.path}:${toPosix(resolvedPath)}`;
|
|
53
298
|
if (seen.has(dedupeKey)) {
|
|
54
|
-
match = POINTER_RE.exec(
|
|
299
|
+
match = POINTER_RE.exec(scanText);
|
|
55
300
|
continue;
|
|
56
301
|
}
|
|
57
302
|
seen.add(dedupeKey);
|
|
@@ -62,7 +307,7 @@ module.exports = {
|
|
|
62
307
|
fix: `${entry.path} references \`${toPosix(resolvedPath)}\`, but the file is missing. Create the file or update the agent guidance to point at a real repo path.`,
|
|
63
308
|
});
|
|
64
309
|
|
|
65
|
-
match = POINTER_RE.exec(
|
|
310
|
+
match = POINTER_RE.exec(scanText);
|
|
66
311
|
}
|
|
67
312
|
}
|
|
68
313
|
}
|
|
@@ -74,17 +74,71 @@ const SPECIAL_FILE_BASENAMES = new Set([
|
|
|
74
74
|
'Dockerfile',
|
|
75
75
|
'Makefile',
|
|
76
76
|
'justfile',
|
|
77
|
+
'manifest.json',
|
|
77
78
|
'package.json',
|
|
78
79
|
'pyproject.toml',
|
|
79
80
|
'go.mod',
|
|
80
81
|
'Cargo.toml',
|
|
81
82
|
]);
|
|
82
83
|
|
|
84
|
+
const COMMON_DOTFILE_BASENAMES = new Set([
|
|
85
|
+
'.editorconfig',
|
|
86
|
+
'.env',
|
|
87
|
+
'.env.example',
|
|
88
|
+
'.env.sample',
|
|
89
|
+
'.env.template',
|
|
90
|
+
'.gitattributes',
|
|
91
|
+
'.gitignore',
|
|
92
|
+
'.npmrc',
|
|
93
|
+
'.nvmrc',
|
|
94
|
+
'.prettierrc',
|
|
95
|
+
'.python-version',
|
|
96
|
+
'.tool-versions',
|
|
97
|
+
]);
|
|
98
|
+
|
|
83
99
|
const KNOWN_CONVENTION_PATHS = new Set([
|
|
84
100
|
'CODEOWNERS',
|
|
85
101
|
'.github/CODEOWNERS',
|
|
86
102
|
]);
|
|
87
103
|
|
|
104
|
+
const FILE_REFERENCE_EXTENSION_RE = /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|cts|mts|py|go|rs|java|kt|kts|gradle|cs|rb|php|swift|pbxproj|xcconfig|xcworkspace|xcodeproj|h|hpp|c|cc|cpp|m|mm|sql|ini|cfg|properties|xml|html|css|scss|sass|lock)$/i;
|
|
105
|
+
const KNOWN_DOMAIN_TLDS = new Set([
|
|
106
|
+
'ai',
|
|
107
|
+
'app',
|
|
108
|
+
'co',
|
|
109
|
+
'com',
|
|
110
|
+
'dev',
|
|
111
|
+
'io',
|
|
112
|
+
'net',
|
|
113
|
+
'org',
|
|
114
|
+
'sh',
|
|
115
|
+
]);
|
|
116
|
+
const KNOWN_HIDDEN_PATH_SEGMENTS = new Set([
|
|
117
|
+
'.claude',
|
|
118
|
+
'.codex',
|
|
119
|
+
'.cursor',
|
|
120
|
+
'.gemini',
|
|
121
|
+
'.github',
|
|
122
|
+
'.opencode',
|
|
123
|
+
'.vscode',
|
|
124
|
+
'.windsurf',
|
|
125
|
+
]);
|
|
126
|
+
const FRAMEWORK_LABEL_TOKENS = new Set([
|
|
127
|
+
'd3.js',
|
|
128
|
+
'go',
|
|
129
|
+
'golang',
|
|
130
|
+
'javascript',
|
|
131
|
+
'kotlin',
|
|
132
|
+
'next',
|
|
133
|
+
'next.js',
|
|
134
|
+
'node',
|
|
135
|
+
'node.js',
|
|
136
|
+
'python',
|
|
137
|
+
'rust',
|
|
138
|
+
'swift',
|
|
139
|
+
'typescript',
|
|
140
|
+
]);
|
|
141
|
+
|
|
88
142
|
const LOCAL_MCP_BINARIES = new Set([
|
|
89
143
|
'context7-mcp',
|
|
90
144
|
'nerviq-mcp',
|
|
@@ -127,8 +181,9 @@ function existsSyncSafe(targetPath) {
|
|
|
127
181
|
function isLikelyTextFile(relPath) {
|
|
128
182
|
const base = path.posix.basename(toPosix(relPath));
|
|
129
183
|
if (SPECIAL_FILE_BASENAMES.has(base)) return true;
|
|
184
|
+
if (COMMON_DOTFILE_BASENAMES.has(base)) return true;
|
|
130
185
|
if (base === '.cursorrules' || base === '.windsurfrules') return true;
|
|
131
|
-
return
|
|
186
|
+
return hasKnownFileExtension(base);
|
|
132
187
|
}
|
|
133
188
|
|
|
134
189
|
function fileExists(ctx, relPath) {
|
|
@@ -235,27 +290,69 @@ function stripWrapperChars(value) {
|
|
|
235
290
|
function normalizeCandidatePath(rawValue) {
|
|
236
291
|
let value = stripWrapperChars(rawValue);
|
|
237
292
|
if (value.startsWith('@')) value = value.slice(1);
|
|
293
|
+
if (/^mdc:/i.test(value)) value = value.slice(4);
|
|
238
294
|
return value;
|
|
239
295
|
}
|
|
240
296
|
|
|
297
|
+
function hasKnownFileExtension(baseName) {
|
|
298
|
+
return FILE_REFERENCE_EXTENSION_RE.test(baseName || '');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isVersionLikeToken(candidate) {
|
|
302
|
+
return /^v?\d+(?:\.\d+)+(?:[a-z]+\d*|\.[xX*])?$/i.test(candidate || '');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isFrameworkLabelToken(candidate) {
|
|
306
|
+
return FRAMEWORK_LABEL_TOKENS.has(String(candidate || '').toLowerCase());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isDomainLikeToken(candidate) {
|
|
310
|
+
if (!candidate || candidate.includes('/')) return false;
|
|
311
|
+
const parts = String(candidate).split('.');
|
|
312
|
+
if (parts.length < 2) return false;
|
|
313
|
+
const tld = parts[parts.length - 1].toLowerCase();
|
|
314
|
+
if (!KNOWN_DOMAIN_TLDS.has(tld)) return false;
|
|
315
|
+
return parts.slice(0, -1).every((part) => /^[A-Za-z0-9-]+$/.test(part));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function lineHasExampleContext(line) {
|
|
319
|
+
const text = String(line || '');
|
|
320
|
+
if (/^\s*\|/.test(text)) return true;
|
|
321
|
+
if (/^\s*#{1,6}\s+/.test(text)) return true;
|
|
322
|
+
return /\b(?:e\.g\.?|for example|examples?|sample|placeholder|template|snippet|user request|problem|solution)\b/i.test(text);
|
|
323
|
+
}
|
|
324
|
+
|
|
241
325
|
function looksLikeRelativeFileReference(candidate) {
|
|
242
326
|
if (!candidate) return false;
|
|
243
327
|
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
|
|
244
|
-
if (/^[A-Za-z0-9-]+\.[A-Za-z]{2,}\//.test(candidate)) return false;
|
|
245
328
|
if (candidate.startsWith('#')) return false;
|
|
329
|
+
if (/[<>{}|]/.test(candidate)) return false;
|
|
246
330
|
|
|
247
331
|
const normalized = candidate.replace(/^\.\//, '');
|
|
248
332
|
const base = path.posix.basename(normalized);
|
|
333
|
+
const lowered = normalized.toLowerCase();
|
|
249
334
|
|
|
250
|
-
if (
|
|
251
|
-
|
|
335
|
+
if (isDomainLikeToken(normalized)) return false;
|
|
336
|
+
if (isVersionLikeToken(normalized)) return false;
|
|
337
|
+
if (isFrameworkLabelToken(normalized)) return false;
|
|
338
|
+
if (base.startsWith('.') && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
if (normalized.split('/').some((segment) => /^\.[A-Za-z0-9_-]+$/.test(segment) && !COMMON_DOTFILE_BASENAMES.has(segment.toLowerCase()) && !KNOWN_HIDDEN_PATH_SEGMENTS.has(segment.toLowerCase()))) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (/^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*){2,}$/i.test(normalized) && !hasKnownFileExtension(base)) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
if (/^\.[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+$/.test(base) && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
348
|
+
return false;
|
|
252
349
|
}
|
|
253
350
|
|
|
254
|
-
if (normalized.
|
|
255
|
-
return
|
|
351
|
+
if (KNOWN_CONVENTION_PATHS.has(normalized) || SPECIAL_FILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
352
|
+
return true;
|
|
256
353
|
}
|
|
257
354
|
|
|
258
|
-
return
|
|
355
|
+
return hasKnownFileExtension(base);
|
|
259
356
|
}
|
|
260
357
|
|
|
261
358
|
function resolveRepoPath(ctx, fromFile, candidate, mode = 'relative-to-file') {
|
|
@@ -277,11 +374,34 @@ function getScannableLines(content) {
|
|
|
277
374
|
const lines = String(content || '').split(/\r?\n/);
|
|
278
375
|
const output = [];
|
|
279
376
|
let fence = null;
|
|
377
|
+
let htmlComment = false;
|
|
378
|
+
let frontmatter = false;
|
|
379
|
+
let frontmatterConsumed = false;
|
|
280
380
|
|
|
281
381
|
for (let index = 0; index < lines.length; index++) {
|
|
282
382
|
const line = lines[index];
|
|
283
383
|
const trimmed = line.trim();
|
|
284
384
|
|
|
385
|
+
if (!frontmatterConsumed && index === 0 && /^(---|\+\+\+)$/.test(trimmed)) {
|
|
386
|
+
frontmatter = true;
|
|
387
|
+
frontmatterConsumed = true;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (frontmatter) {
|
|
392
|
+
if (/^(---|\+\+\+)$/.test(trimmed)) {
|
|
393
|
+
frontmatter = false;
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!fence && htmlComment) {
|
|
399
|
+
if (trimmed.includes('-->')) {
|
|
400
|
+
htmlComment = false;
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
285
405
|
if (!fence && /^(```|~~~)/.test(trimmed)) {
|
|
286
406
|
fence = trimmed.slice(0, 3);
|
|
287
407
|
continue;
|
|
@@ -294,6 +414,13 @@ function getScannableLines(content) {
|
|
|
294
414
|
continue;
|
|
295
415
|
}
|
|
296
416
|
|
|
417
|
+
if (/^<!--/.test(trimmed)) {
|
|
418
|
+
if (!trimmed.includes('-->')) {
|
|
419
|
+
htmlComment = true;
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
297
424
|
output.push({ lineNumber: index + 1, text: line });
|
|
298
425
|
}
|
|
299
426
|
|
|
@@ -512,6 +639,7 @@ module.exports = {
|
|
|
512
639
|
hasLegacyAiderPin,
|
|
513
640
|
isClearlyLocalMcpBinary,
|
|
514
641
|
isKnownConventionPath,
|
|
642
|
+
lineHasExampleContext,
|
|
515
643
|
looksLikeRelativeFileReference,
|
|
516
644
|
normalizeCandidatePath,
|
|
517
645
|
platformForFile,
|