@kernlang/review 3.5.6-canary.202.1.31706b95 → 3.5.7
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/dist/config-files/env.d.ts +45 -0
- package/dist/config-files/env.js +235 -0
- package/dist/config-files/env.js.map +1 -0
- package/dist/config-files/json.d.ts +20 -0
- package/dist/config-files/json.js +220 -0
- package/dist/config-files/json.js.map +1 -0
- package/dist/config-files/markdown.d.ts +68 -0
- package/dist/config-files/markdown.js +262 -0
- package/dist/config-files/markdown.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +64 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env` analyzer — duplicate variables, malformed assignments, and a
|
|
3
|
+
* conservative committed-secret heuristic. Parallel to json.ts and
|
|
4
|
+
* markdown.ts: a non-ts-morph path that participates in the same
|
|
5
|
+
* ReviewFinding pipeline so kern-sight and kern-guard consume findings
|
|
6
|
+
* without API changes. No parser dep — the format is line-oriented and
|
|
7
|
+
* regex-tractable.
|
|
8
|
+
*
|
|
9
|
+
* Format conventions (dotenv-style, conservative subset):
|
|
10
|
+
*
|
|
11
|
+
* • One assignment per line: `KEY=VALUE` or `KEY="quoted value"`.
|
|
12
|
+
* • Comments start with `#`. Inline comments (`KEY=val # note`) are
|
|
13
|
+
* accepted; the comment is stripped before secret-likeness checks.
|
|
14
|
+
* • Blank lines and pure-comment lines are ignored.
|
|
15
|
+
* • `export KEY=VALUE` is accepted (bash-style).
|
|
16
|
+
* • Continuation across lines is NOT supported. Multi-line values
|
|
17
|
+
* inside quotes are NOT supported (rare in real env files; out of
|
|
18
|
+
* scope for a hygiene scanner).
|
|
19
|
+
*
|
|
20
|
+
* Rules:
|
|
21
|
+
*
|
|
22
|
+
* • `env/duplicate-key` — same KEY assigned more than once in the file.
|
|
23
|
+
* The second value wins on `process.env` parse. Fingerprint by key
|
|
24
|
+
* name (NOT line) so kern-guard dedup is stable; 3rd+ occurrences
|
|
25
|
+
* append `#N`.
|
|
26
|
+
* • `env/malformed` — a non-blank, non-comment line that does not
|
|
27
|
+
* match the assignment shape. Fingerprint by line content hash so
|
|
28
|
+
* formatting fixes upstream don't perturb downstream findings.
|
|
29
|
+
* • `env/possible-secret` — KEY matches `SECRET|TOKEN|API_KEY|PASSWORD|
|
|
30
|
+
* PRIVATE|ACCESS_KEY` AND value is non-empty AND is not an obvious
|
|
31
|
+
* placeholder (`changeme`, `example`, `<…>`, `${…}`, `your_…_here`,
|
|
32
|
+
* pure `*` masks, etc.). Warning severity — conservative on purpose;
|
|
33
|
+
* this should not fire on placeholders in `.env.example`.
|
|
34
|
+
*
|
|
35
|
+
* Files routed here are: `.env`, `.env.local`, `.env.development`,
|
|
36
|
+
* `.env.production`, `.env.test`, `.env.<anything>`. Files named
|
|
37
|
+
* `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are
|
|
38
|
+
* scanned but the secret-likeness rule is SUPPRESSED — those are by
|
|
39
|
+
* convention for committed placeholders.
|
|
40
|
+
*/
|
|
41
|
+
import type { ReviewFinding } from '../types.js';
|
|
42
|
+
/** Files this analyzer claims. Routed by basename, not extension: `.env`
|
|
43
|
+
* has no extension in the usual sense. */
|
|
44
|
+
export declare function isEnvFile(filePath: string): boolean;
|
|
45
|
+
export declare function reviewEnvFile(source: string, filePath: string): ReviewFinding[];
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env` analyzer — duplicate variables, malformed assignments, and a
|
|
3
|
+
* conservative committed-secret heuristic. Parallel to json.ts and
|
|
4
|
+
* markdown.ts: a non-ts-morph path that participates in the same
|
|
5
|
+
* ReviewFinding pipeline so kern-sight and kern-guard consume findings
|
|
6
|
+
* without API changes. No parser dep — the format is line-oriented and
|
|
7
|
+
* regex-tractable.
|
|
8
|
+
*
|
|
9
|
+
* Format conventions (dotenv-style, conservative subset):
|
|
10
|
+
*
|
|
11
|
+
* • One assignment per line: `KEY=VALUE` or `KEY="quoted value"`.
|
|
12
|
+
* • Comments start with `#`. Inline comments (`KEY=val # note`) are
|
|
13
|
+
* accepted; the comment is stripped before secret-likeness checks.
|
|
14
|
+
* • Blank lines and pure-comment lines are ignored.
|
|
15
|
+
* • `export KEY=VALUE` is accepted (bash-style).
|
|
16
|
+
* • Continuation across lines is NOT supported. Multi-line values
|
|
17
|
+
* inside quotes are NOT supported (rare in real env files; out of
|
|
18
|
+
* scope for a hygiene scanner).
|
|
19
|
+
*
|
|
20
|
+
* Rules:
|
|
21
|
+
*
|
|
22
|
+
* • `env/duplicate-key` — same KEY assigned more than once in the file.
|
|
23
|
+
* The second value wins on `process.env` parse. Fingerprint by key
|
|
24
|
+
* name (NOT line) so kern-guard dedup is stable; 3rd+ occurrences
|
|
25
|
+
* append `#N`.
|
|
26
|
+
* • `env/malformed` — a non-blank, non-comment line that does not
|
|
27
|
+
* match the assignment shape. Fingerprint by line content hash so
|
|
28
|
+
* formatting fixes upstream don't perturb downstream findings.
|
|
29
|
+
* • `env/possible-secret` — KEY matches `SECRET|TOKEN|API_KEY|PASSWORD|
|
|
30
|
+
* PRIVATE|ACCESS_KEY` AND value is non-empty AND is not an obvious
|
|
31
|
+
* placeholder (`changeme`, `example`, `<…>`, `${…}`, `your_…_here`,
|
|
32
|
+
* pure `*` masks, etc.). Warning severity — conservative on purpose;
|
|
33
|
+
* this should not fire on placeholders in `.env.example`.
|
|
34
|
+
*
|
|
35
|
+
* Files routed here are: `.env`, `.env.local`, `.env.development`,
|
|
36
|
+
* `.env.production`, `.env.test`, `.env.<anything>`. Files named
|
|
37
|
+
* `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are
|
|
38
|
+
* scanned but the secret-likeness rule is SUPPRESSED — those are by
|
|
39
|
+
* convention for committed placeholders.
|
|
40
|
+
*/
|
|
41
|
+
import { basename } from 'node:path';
|
|
42
|
+
// Matches `KEY=value`, `export KEY=value`, with optional spaces around `=`
|
|
43
|
+
// (dotenv accepts both `KEY=value` and `KEY = value` — discouraged but
|
|
44
|
+
// common). Captures:
|
|
45
|
+
// [1] = key
|
|
46
|
+
// [2] = value (everything after `=`, before any trailing comment)
|
|
47
|
+
const ASSIGNMENT_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
|
48
|
+
// Keys that suggest a secret. Case-insensitive. Conservative set.
|
|
49
|
+
const SECRET_KEY_RE = /(?:^|_)(?:SECRET|TOKEN|API[_]?KEY|PASSWORD|PRIVATE[_]?KEY|ACCESS[_]?KEY|AUTH[_]?KEY|CLIENT[_]?SECRET)(?:$|_)/i;
|
|
50
|
+
// Placeholder shapes — if the value is one of these, do NOT fire the
|
|
51
|
+
// secret-likeness rule. The list is intentionally generous because the
|
|
52
|
+
// cost of a false positive on .env.example is much higher than missing
|
|
53
|
+
// one real secret (kern-guard would post the warning every PR).
|
|
54
|
+
const PLACEHOLDER_PATTERNS = [
|
|
55
|
+
/^\s*$/,
|
|
56
|
+
/^(changeme|change-me|change_me|example|sample|placeholder|todo|tbd|xxx+|none|null|undefined|fill[_-]?me[_-]?in)\s*$/i,
|
|
57
|
+
/^your[_-]?.+[_-]?here$/i, // `your_api_key_here`
|
|
58
|
+
/^<[^>]+>$/, // `<your-token>`
|
|
59
|
+
/^\$\{[^}]+\}$/, // `${VAR}`
|
|
60
|
+
/^\*+$/, // pure asterisk mask
|
|
61
|
+
/^[*x]+(?:[-_][*x]+)*$/i, // `xxx-xxx-xxx`
|
|
62
|
+
];
|
|
63
|
+
/** Strip surrounding single/double quotes if balanced. */
|
|
64
|
+
function unquote(s) {
|
|
65
|
+
if (s.length >= 2) {
|
|
66
|
+
const first = s.charAt(0);
|
|
67
|
+
const last = s.charAt(s.length - 1);
|
|
68
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
69
|
+
return s.slice(1, -1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
/** Produce the "value for secret-likeness check" from a raw post-`=` slice.
|
|
75
|
+
* Order matters: a quoted value with a trailing inline comment looks like
|
|
76
|
+
* `"changeme" # production`. Stripping the inline comment FIRST without
|
|
77
|
+
* quote-awareness would either bail (old behavior — bug) or chop a `#`
|
|
78
|
+
* inside the quotes. Walking forward through the string, tracking the
|
|
79
|
+
* initial quote char if any, gives the right answer in one pass:
|
|
80
|
+
*
|
|
81
|
+
* 1. trim
|
|
82
|
+
* 2. if starts with `"` or `'`, find the matching closing quote and
|
|
83
|
+
* drop everything from the FIRST whitespace+`#` AFTER it
|
|
84
|
+
* 3. otherwise, drop everything from the first whitespace+`#`
|
|
85
|
+
* 4. unquote whatever remains
|
|
86
|
+
*
|
|
87
|
+
* Used only for the secret-likeness check; duplicate detection compares
|
|
88
|
+
* full raw values to keep error spans precise. */
|
|
89
|
+
function valueForSecretCheck(raw) {
|
|
90
|
+
const trimmed = raw.trim();
|
|
91
|
+
if (trimmed === '')
|
|
92
|
+
return '';
|
|
93
|
+
let body = trimmed;
|
|
94
|
+
const first = trimmed.charAt(0);
|
|
95
|
+
if (first === '"' || first === "'") {
|
|
96
|
+
// Find the matching unescaped closing quote.
|
|
97
|
+
let endIdx = -1;
|
|
98
|
+
for (let i = 1; i < trimmed.length; i++) {
|
|
99
|
+
const c = trimmed.charAt(i);
|
|
100
|
+
if (c === '\\') {
|
|
101
|
+
i++; // skip the escaped char
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c === first) {
|
|
105
|
+
endIdx = i;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (endIdx !== -1) {
|
|
110
|
+
// Everything past the closing quote, after optional whitespace, may
|
|
111
|
+
// be an inline `# comment`. Take only the quoted portion (inclusive
|
|
112
|
+
// of the quotes — `unquote` strips them next).
|
|
113
|
+
body = trimmed.slice(0, endIdx + 1);
|
|
114
|
+
}
|
|
115
|
+
// If no closing quote was found, fall through and let the # stripper
|
|
116
|
+
// handle the malformed input.
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// Unquoted value — strip a `# comment` preceded by whitespace.
|
|
120
|
+
const hashIdx = trimmed.indexOf(' #');
|
|
121
|
+
if (hashIdx !== -1)
|
|
122
|
+
body = trimmed.slice(0, hashIdx);
|
|
123
|
+
}
|
|
124
|
+
return unquote(body.trim());
|
|
125
|
+
}
|
|
126
|
+
function isPlaceholder(value) {
|
|
127
|
+
for (const re of PLACEHOLDER_PATTERNS) {
|
|
128
|
+
if (re.test(value))
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Treat files named `.env.example`, `.env.sample`, `.env.template`,
|
|
134
|
+
* `.env.defaults`, `.env.dist` as committed-placeholder files where the
|
|
135
|
+
* secret-likeness rule is suppressed. */
|
|
136
|
+
function isPlaceholderFile(filePath) {
|
|
137
|
+
const base = basename(filePath).toLowerCase();
|
|
138
|
+
return /^\.env\.(example|sample|template|defaults|dist)$/.test(base);
|
|
139
|
+
}
|
|
140
|
+
/** Files this analyzer claims. Routed by basename, not extension: `.env`
|
|
141
|
+
* has no extension in the usual sense. */
|
|
142
|
+
export function isEnvFile(filePath) {
|
|
143
|
+
const base = basename(filePath);
|
|
144
|
+
return base === '.env' || base.startsWith('.env.');
|
|
145
|
+
}
|
|
146
|
+
/** Fast non-cryptographic hash for malformed-line fingerprints. djb2. */
|
|
147
|
+
function hashShort(s) {
|
|
148
|
+
let h = 5381;
|
|
149
|
+
for (let i = 0; i < s.length; i++) {
|
|
150
|
+
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
151
|
+
}
|
|
152
|
+
return (h >>> 0).toString(36);
|
|
153
|
+
}
|
|
154
|
+
function makeSpan(filePath, line, startCol, endCol) {
|
|
155
|
+
return { file: filePath, startLine: line, startCol, endLine: line, endCol };
|
|
156
|
+
}
|
|
157
|
+
export function reviewEnvFile(source, filePath) {
|
|
158
|
+
const findings = [];
|
|
159
|
+
const suppressSecretRule = isPlaceholderFile(filePath);
|
|
160
|
+
// Track first-occurrence + dup count per key for fingerprint stability.
|
|
161
|
+
const seen = new Map();
|
|
162
|
+
const lines = source.split(/\r?\n/);
|
|
163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
164
|
+
const raw = lines[i];
|
|
165
|
+
const trimmed = raw.trim();
|
|
166
|
+
if (trimmed === '')
|
|
167
|
+
continue;
|
|
168
|
+
if (trimmed.startsWith('#'))
|
|
169
|
+
continue;
|
|
170
|
+
const m = raw.match(ASSIGNMENT_RE);
|
|
171
|
+
if (!m) {
|
|
172
|
+
// Malformed line — non-blank, non-comment, not parseable.
|
|
173
|
+
const ruleId = 'env/malformed';
|
|
174
|
+
findings.push({
|
|
175
|
+
source: 'kern',
|
|
176
|
+
ruleId,
|
|
177
|
+
severity: 'warning',
|
|
178
|
+
category: 'style',
|
|
179
|
+
message: 'Line does not look like a `KEY=VALUE` assignment. dotenv parsers may skip or misread this line.',
|
|
180
|
+
primarySpan: makeSpan(filePath, i + 1, 1, raw.length + 1),
|
|
181
|
+
confidence: 85,
|
|
182
|
+
// Fingerprint by content shape — same broken line on a different
|
|
183
|
+
// line number stays the same finding. Different broken lines get
|
|
184
|
+
// distinct fingerprints.
|
|
185
|
+
fingerprint: `${ruleId}:${hashShort(trimmed)}`,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const key = m[1];
|
|
190
|
+
const rawValue = m[2] ?? '';
|
|
191
|
+
// ── Duplicate key ──────────────────────────────────────────────────
|
|
192
|
+
const prior = seen.get(key);
|
|
193
|
+
if (prior) {
|
|
194
|
+
prior.dupCount += 1;
|
|
195
|
+
const ruleId = 'env/duplicate-key';
|
|
196
|
+
const suffix = prior.dupCount === 1 ? '' : `#${prior.dupCount}`;
|
|
197
|
+
findings.push({
|
|
198
|
+
source: 'kern',
|
|
199
|
+
ruleId,
|
|
200
|
+
severity: 'warning',
|
|
201
|
+
category: 'bug',
|
|
202
|
+
message: `Duplicate variable "${key}". The last assignment wins; earlier lines are silently overridden when dotenv loads the file.`,
|
|
203
|
+
primarySpan: makeSpan(filePath, i + 1, 1, raw.length + 1),
|
|
204
|
+
relatedSpans: [makeSpan(filePath, prior.firstLine, 1, 1)],
|
|
205
|
+
confidence: 100,
|
|
206
|
+
fingerprint: `${ruleId}:${key}${suffix}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
seen.set(key, { firstLine: i + 1, dupCount: 0 });
|
|
211
|
+
}
|
|
212
|
+
// ── Possible committed secret ──────────────────────────────────────
|
|
213
|
+
if (!suppressSecretRule && SECRET_KEY_RE.test(key)) {
|
|
214
|
+
const valueUnquoted = valueForSecretCheck(rawValue);
|
|
215
|
+
if (valueUnquoted.length > 0 && !isPlaceholder(valueUnquoted)) {
|
|
216
|
+
const ruleId = 'env/possible-secret';
|
|
217
|
+
findings.push({
|
|
218
|
+
source: 'kern',
|
|
219
|
+
ruleId,
|
|
220
|
+
severity: 'warning',
|
|
221
|
+
category: 'bug',
|
|
222
|
+
message: `"${key}" looks like a secret with a real-looking value committed. If this is a placeholder, rename the file to \`.env.example\` (or similar) or use \`<placeholder>\` / \`\${VAR}\` syntax.`,
|
|
223
|
+
primarySpan: makeSpan(filePath, i + 1, 1, raw.length + 1),
|
|
224
|
+
confidence: 70,
|
|
225
|
+
// Fingerprint by key only — same key flagged across edits stays
|
|
226
|
+
// the same finding. We do NOT include the value (changes are
|
|
227
|
+
// exactly the signal we want to NOT suppress).
|
|
228
|
+
fingerprint: `${ruleId}:${key}`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return findings;
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=env.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env.js","sourceRoot":"","sources":["../../src/config-files/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrC,2EAA2E;AAC3E,uEAAuE;AACvE,qBAAqB;AACrB,cAAc;AACd,oEAAoE;AACpE,MAAM,aAAa,GAAG,4DAA4D,CAAC;AAEnF,kEAAkE;AAClE,MAAM,aAAa,GACjB,+GAA+G,CAAC;AAElH,qEAAqE;AACrE,uEAAuE;AACvE,uEAAuE;AACvE,gEAAgE;AAChE,MAAM,oBAAoB,GAAG;IAC3B,OAAO;IACP,sHAAsH;IACtH,yBAAyB,EAAE,sBAAsB;IACjD,WAAW,EAAE,iBAAiB;IAC9B,eAAe,EAAE,WAAW;IAC5B,OAAO,EAAE,qBAAqB;IAC9B,wBAAwB,EAAE,gBAAgB;CAC3C,CAAC;AAEF,0DAA0D;AAC1D,SAAS,OAAO,CAAC,CAAS;IACxB,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QAClB,MAAM,KAAK,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC1B,MAAM,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACpC,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACvE,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACxB,CAAC;IACH,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC;AAED;;;;;;;;;;;;;;mDAcmD;AACnD,SAAS,mBAAmB,CAAC,GAAW;IACtC,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IAC3B,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC;IAE9B,IAAI,IAAI,GAAG,OAAO,CAAC;IACnB,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAChC,IAAI,KAAK,KAAK,GAAG,IAAI,KAAK,KAAK,GAAG,EAAE,CAAC;QACnC,6CAA6C;QAC7C,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC;QAChB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACxC,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC5B,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;gBACf,CAAC,EAAE,CAAC,CAAC,wBAAwB;gBAC7B,SAAS;YACX,CAAC;YACD,IAAI,CAAC,KAAK,KAAK,EAAE,CAAC;gBAChB,MAAM,GAAG,CAAC,CAAC;gBACX,MAAM;YACR,CAAC;QACH,CAAC;QACD,IAAI,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;YAClB,oEAAoE;YACpE,oEAAoE;YACpE,+CAA+C;YAC/C,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC;QACtC,CAAC;QACD,qEAAqE;QACrE,8BAA8B;IAChC,CAAC;SAAM,CAAC;QACN,+DAA+D;QAC/D,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACtC,IAAI,OAAO,KAAK,CAAC,CAAC;YAAE,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IACvD,CAAC;IAED,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,aAAa,CAAC,KAAa;IAClC,KAAK,MAAM,EAAE,IAAI,oBAAoB,EAAE,CAAC;QACtC,IAAI,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;IAClC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;0CAE0C;AAC1C,SAAS,iBAAiB,CAAC,QAAgB;IACzC,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9C,OAAO,kDAAkD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACvE,CAAC;AAED;2CAC2C;AAC3C,MAAM,UAAU,SAAS,CAAC,QAAgB;IACxC,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAChC,OAAO,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;AACrD,CAAC;AAED,yEAAyE;AACzE,SAAS,SAAS,CAAC,CAAS;IAC1B,IAAI,CAAC,GAAG,IAAI,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,QAAQ,CAAC,QAAgB,EAAE,IAAY,EAAE,QAAgB,EAAE,MAAc;IAChF,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,MAAc,EAAE,QAAgB;IAC5D,MAAM,QAAQ,GAAoB,EAAE,CAAC;IACrC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAEvD,wEAAwE;IACxE,MAAM,IAAI,GAAG,IAAI,GAAG,EAAmD,CAAC;IAExE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACpC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;QACtB,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,IAAI,OAAO,KAAK,EAAE;YAAE,SAAS;QAC7B,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAEtC,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QACnC,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,0DAA0D;YAC1D,MAAM,MAAM,GAAG,eAAe,CAAC;YAC/B,QAAQ,CAAC,IAAI,CAAC;gBACZ,MAAM,EAAE,MAAM;gBACd,MAAM;gBACN,QAAQ,EAAE,SAAS;gBACnB,QAAQ,EAAE,OAAO;gBACjB,OAAO,EAAE,iGAAiG;gBAC1G,WAAW,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;gBACzD,UAAU,EAAE,EAAE;gBACd,iEAAiE;gBACjE,iEAAiE;gBACjE,yBAAyB;gBACzB,WAAW,EAAE,GAAG,MAAM,IAAI,SAAS,CAAC,OAAO,CAAC,EAAE;aAC/C,CAAC,CAAC;YACH,SAAS;QACX,CAAC;QAED,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC;QAClB,MAAM,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAE5B,sEAAsE;QACtE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,KAAK,EAAE,CAAC;YACV,KAAK,CAAC,QAAQ,IAAI,CAAC,CAAC;YACpB,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACnC,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YAChE,QAAQ,CAAC,IAAI,CAAC;gBACZ,MAAM,EAAE,MAAM;gBACd,MAAM;gBACN,QAAQ,EAAE,SAAS;gBACnB,QAAQ,EAAE,KAAK;gBACf,OAAO,EAAE,uBAAuB,GAAG,gGAAgG;gBACnI,WAAW,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;gBACzD,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;gBACzD,UAAU,EAAE,GAAG;gBACf,WAAW,EAAE,GAAG,MAAM,IAAI,GAAG,GAAG,MAAM,EAAE;aACzC,CAAC,CAAC;QACL,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QACnD,CAAC;QAED,sEAAsE;QACtE,IAAI,CAAC,kBAAkB,IAAI,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACnD,MAAM,aAAa,GAAG,mBAAmB,CAAC,QAAQ,CAAC,CAAC;YACpD,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,aAAa,CAAC,aAAa,CAAC,EAAE,CAAC;gBAC9D,MAAM,MAAM,GAAG,qBAAqB,CAAC;gBACrC,QAAQ,CAAC,IAAI,CAAC;oBACZ,MAAM,EAAE,MAAM;oBACd,MAAM;oBACN,QAAQ,EAAE,SAAS;oBACnB,QAAQ,EAAE,KAAK;oBACf,OAAO,EAAE,IAAI,GAAG,sLAAsL;oBACtM,WAAW,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;oBACzD,UAAU,EAAE,EAAE;oBACd,gEAAgE;oBAChE,6DAA6D;oBAC7D,+CAA+C;oBAC/C,WAAW,EAAE,GAAG,MAAM,IAAI,GAAG,EAAE;iBAChC,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON / JSONC analyzer — emits ReviewFindings for parse errors, duplicate
|
|
3
|
+
* keys, and trailing commas in strict JSON. Parallel to python-fallback.ts:
|
|
4
|
+
* a non-ts-morph analysis path that participates in the same finding pipeline
|
|
5
|
+
* so kern-sight (editor diagnostics) and kern-guard (PR Check annotations)
|
|
6
|
+
* both consume it without changes.
|
|
7
|
+
*
|
|
8
|
+
* Dialect detection:
|
|
9
|
+
* .jsonc → JSONC (comments + trailing commas allowed)
|
|
10
|
+
* tsconfig.json / jsconfig.json / .vscode/*.json → JSONC (de facto)
|
|
11
|
+
* everything else .json → strict JSON
|
|
12
|
+
*
|
|
13
|
+
* Fingerprint policy: NOT line-based. Parse errors fingerprint by error code
|
|
14
|
+
* + dialect; duplicate keys fingerprint by key-path. Line numbers shift under
|
|
15
|
+
* unrelated edits — line-based fingerprints would make kern-guard re-post
|
|
16
|
+
* the same finding as "new" on every PR that touched whitespace above it.
|
|
17
|
+
*/
|
|
18
|
+
import type { ReviewFinding } from '../types.js';
|
|
19
|
+
/** Entry point for the engine dispatcher. */
|
|
20
|
+
export declare function reviewJsonFile(source: string, filePath: string): ReviewFinding[];
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON / JSONC analyzer — emits ReviewFindings for parse errors, duplicate
|
|
3
|
+
* keys, and trailing commas in strict JSON. Parallel to python-fallback.ts:
|
|
4
|
+
* a non-ts-morph analysis path that participates in the same finding pipeline
|
|
5
|
+
* so kern-sight (editor diagnostics) and kern-guard (PR Check annotations)
|
|
6
|
+
* both consume it without changes.
|
|
7
|
+
*
|
|
8
|
+
* Dialect detection:
|
|
9
|
+
* .jsonc → JSONC (comments + trailing commas allowed)
|
|
10
|
+
* tsconfig.json / jsconfig.json / .vscode/*.json → JSONC (de facto)
|
|
11
|
+
* everything else .json → strict JSON
|
|
12
|
+
*
|
|
13
|
+
* Fingerprint policy: NOT line-based. Parse errors fingerprint by error code
|
|
14
|
+
* + dialect; duplicate keys fingerprint by key-path. Line numbers shift under
|
|
15
|
+
* unrelated edits — line-based fingerprints would make kern-guard re-post
|
|
16
|
+
* the same finding as "new" on every PR that touched whitespace above it.
|
|
17
|
+
*/
|
|
18
|
+
import { basename } from 'node:path';
|
|
19
|
+
import { parseTree } from 'jsonc-parser';
|
|
20
|
+
function dialectForPath(filePath) {
|
|
21
|
+
if (filePath.endsWith('.jsonc'))
|
|
22
|
+
return 'jsonc';
|
|
23
|
+
// Files inside a `.vscode/` directory (settings.json, launch.json,
|
|
24
|
+
// tasks.json, extensions.json, …) accept JSONC per VS Code convention.
|
|
25
|
+
// Normalize separators so the check works on Windows paths too.
|
|
26
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
27
|
+
if (normalized.includes('/.vscode/'))
|
|
28
|
+
return 'jsonc';
|
|
29
|
+
// tsconfig + jsconfig allow comments per TS spec — matches `tsconfig.json`,
|
|
30
|
+
// `tsconfig.base.json`, etc. (startsWith already covers `tsconfig.json`.)
|
|
31
|
+
const base = basename(filePath).toLowerCase();
|
|
32
|
+
if (base.startsWith('tsconfig.') || base.startsWith('jsconfig.'))
|
|
33
|
+
return 'jsonc';
|
|
34
|
+
return 'json';
|
|
35
|
+
}
|
|
36
|
+
// jsonc-parser ParseErrorCode is a `const enum` — values are inlined at
|
|
37
|
+
// compile time and there is no runtime reverse-lookup table. Mirror the
|
|
38
|
+
// numeric enum to a stable kebab-case slug here. If jsonc-parser ever
|
|
39
|
+
// renumbers (it has been stable since 1.x) the analyzer will degrade to
|
|
40
|
+
// `unknown-<n>` rather than crash.
|
|
41
|
+
const ERROR_CODE_SLUGS = {
|
|
42
|
+
1: 'invalid-symbol',
|
|
43
|
+
2: 'invalid-number-format',
|
|
44
|
+
3: 'property-name-expected',
|
|
45
|
+
4: 'value-expected',
|
|
46
|
+
5: 'colon-expected',
|
|
47
|
+
6: 'comma-expected',
|
|
48
|
+
7: 'close-brace-expected',
|
|
49
|
+
8: 'close-bracket-expected',
|
|
50
|
+
9: 'end-of-file-expected',
|
|
51
|
+
10: 'invalid-comment-token',
|
|
52
|
+
11: 'unexpected-end-of-comment',
|
|
53
|
+
12: 'unexpected-end-of-string',
|
|
54
|
+
13: 'unexpected-end-of-number',
|
|
55
|
+
14: 'invalid-unicode',
|
|
56
|
+
15: 'invalid-escape-character',
|
|
57
|
+
16: 'invalid-character',
|
|
58
|
+
};
|
|
59
|
+
function errorCodeSlug(code) {
|
|
60
|
+
return ERROR_CODE_SLUGS[code] ?? `unknown-${code}`;
|
|
61
|
+
}
|
|
62
|
+
function humanize(err, dialect) {
|
|
63
|
+
const slug = errorCodeSlug(err.error);
|
|
64
|
+
if (slug === 'invalid-comment-token' && dialect === 'json') {
|
|
65
|
+
return 'Comments are not allowed in strict JSON. Rename to .jsonc or remove the comment.';
|
|
66
|
+
}
|
|
67
|
+
if (slug === 'value-expected')
|
|
68
|
+
return 'Expected a JSON value (string, number, object, array, boolean, or null).';
|
|
69
|
+
if (slug === 'colon-expected')
|
|
70
|
+
return "Expected ':' between property name and value.";
|
|
71
|
+
if (slug === 'comma-expected')
|
|
72
|
+
return "Expected ',' between elements.";
|
|
73
|
+
if (slug === 'close-brace-expected')
|
|
74
|
+
return "Expected '}' to close object.";
|
|
75
|
+
if (slug === 'close-bracket-expected')
|
|
76
|
+
return "Expected ']' to close array.";
|
|
77
|
+
if (slug === 'invalid-character')
|
|
78
|
+
return 'Invalid character in JSON.';
|
|
79
|
+
if (slug === 'invalid-escape-character')
|
|
80
|
+
return 'Invalid escape sequence in string.';
|
|
81
|
+
if (slug === 'invalid-unicode')
|
|
82
|
+
return 'Invalid Unicode escape sequence.';
|
|
83
|
+
if (slug === 'invalid-number-format')
|
|
84
|
+
return 'Invalid number format.';
|
|
85
|
+
if (slug === 'property-name-expected')
|
|
86
|
+
return 'Expected a property name (a quoted string).';
|
|
87
|
+
if (slug === 'end-of-file-expected')
|
|
88
|
+
return 'Trailing content after end of JSON value.';
|
|
89
|
+
if (slug === 'unexpected-end-of-comment')
|
|
90
|
+
return 'Unclosed comment — reached end of file before `*/`.';
|
|
91
|
+
if (slug === 'unexpected-end-of-string')
|
|
92
|
+
return 'Unclosed string — reached end of file before closing quote.';
|
|
93
|
+
if (slug === 'unexpected-end-of-number')
|
|
94
|
+
return 'Unterminated number literal.';
|
|
95
|
+
if (slug === 'invalid-symbol')
|
|
96
|
+
return 'Unexpected token in JSON.';
|
|
97
|
+
return `JSON parse error: ${slug}.`;
|
|
98
|
+
}
|
|
99
|
+
/** Convert a character offset into the source to a 1-based (line, col) pair. */
|
|
100
|
+
function offsetToLineCol(source, offset) {
|
|
101
|
+
const clamped = Math.max(0, Math.min(offset, source.length));
|
|
102
|
+
let line = 1;
|
|
103
|
+
let lineStart = 0;
|
|
104
|
+
for (let i = 0; i < clamped; i++) {
|
|
105
|
+
if (source.charCodeAt(i) === 0x0a /* \n */) {
|
|
106
|
+
line++;
|
|
107
|
+
lineStart = i + 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { line, col: clamped - lineStart + 1 };
|
|
111
|
+
}
|
|
112
|
+
function spanAt(source, filePath, offset, length) {
|
|
113
|
+
const start = offsetToLineCol(source, offset);
|
|
114
|
+
const end = offsetToLineCol(source, offset + Math.max(1, length));
|
|
115
|
+
return { file: filePath, startLine: start.line, startCol: start.col, endLine: end.line, endCol: end.col };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Walk the AST collecting duplicate property names within the same object.
|
|
119
|
+
* Builds a structural key-path (e.g. `compilerOptions.paths`) so the
|
|
120
|
+
* fingerprint stays stable across unrelated formatting changes.
|
|
121
|
+
*/
|
|
122
|
+
function detectDuplicateKeys(tree, source, filePath) {
|
|
123
|
+
const out = [];
|
|
124
|
+
function visit(node, path) {
|
|
125
|
+
if (node.type === 'object' && node.children) {
|
|
126
|
+
// Track first-occurrence node AND how many duplicates we've already
|
|
127
|
+
// emitted for that key. The count lets us disambiguate fingerprints
|
|
128
|
+
// when the same key appears 3+ times in one object — without a
|
|
129
|
+
// counter, dedup in kern-guard would collapse all but one into the
|
|
130
|
+
// baseline and a later edit could silently drop a real finding.
|
|
131
|
+
const seen = new Map();
|
|
132
|
+
for (const prop of node.children) {
|
|
133
|
+
// jsonc-parser 'property' node: children[0] = key, children[1] = value
|
|
134
|
+
if (prop.type !== 'property' || !prop.children || prop.children.length < 1)
|
|
135
|
+
continue;
|
|
136
|
+
const keyNode = prop.children[0];
|
|
137
|
+
if (!keyNode || keyNode.type !== 'string' || typeof keyNode.value !== 'string')
|
|
138
|
+
continue;
|
|
139
|
+
const key = keyNode.value;
|
|
140
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
141
|
+
const entry = seen.get(key);
|
|
142
|
+
if (entry) {
|
|
143
|
+
entry.dupCount += 1;
|
|
144
|
+
const ruleId = 'json/duplicate-key';
|
|
145
|
+
// Second duplicate keeps the path-only fingerprint (most common
|
|
146
|
+
// case, stable across formatting). Third+ append `#N` so each
|
|
147
|
+
// additional occurrence is independently dedup-able.
|
|
148
|
+
const suffix = entry.dupCount === 1 ? '' : `#${entry.dupCount}`;
|
|
149
|
+
out.push({
|
|
150
|
+
source: 'kern',
|
|
151
|
+
ruleId,
|
|
152
|
+
severity: 'error',
|
|
153
|
+
category: 'bug',
|
|
154
|
+
message: `Duplicate property "${key}" in object. The second value silently overrides the first in JSON.parse.`,
|
|
155
|
+
primarySpan: spanAt(source, filePath, keyNode.offset, keyNode.length),
|
|
156
|
+
relatedSpans: [spanAt(source, filePath, entry.firstNode.offset, entry.firstNode.length)],
|
|
157
|
+
confidence: 100,
|
|
158
|
+
fingerprint: `${ruleId}:${childPath}${suffix}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
seen.set(key, { firstNode: keyNode, dupCount: 0 });
|
|
163
|
+
}
|
|
164
|
+
const valueNode = prop.children[1];
|
|
165
|
+
if (valueNode)
|
|
166
|
+
visit(valueNode, childPath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (node.type === 'array' && node.children) {
|
|
170
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
171
|
+
const child = node.children[i];
|
|
172
|
+
if (child)
|
|
173
|
+
visit(child, `${path}[${i}]`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
visit(tree, '');
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/** Entry point for the engine dispatcher. */
|
|
181
|
+
export function reviewJsonFile(source, filePath) {
|
|
182
|
+
const dialect = dialectForPath(filePath);
|
|
183
|
+
const errors = [];
|
|
184
|
+
const tree = parseTree(source, errors, {
|
|
185
|
+
disallowComments: dialect === 'json',
|
|
186
|
+
allowTrailingComma: dialect === 'jsonc',
|
|
187
|
+
});
|
|
188
|
+
const findings = [];
|
|
189
|
+
// Track per-(ruleId) occurrence count so multiple parse errors of the
|
|
190
|
+
// same kind (e.g. two separate `invalid-comment-token` in a strict JSON)
|
|
191
|
+
// produce distinct fingerprints. Without this, kern-guard's baseline
|
|
192
|
+
// dedup collapses N independent errors into one and silently hides the
|
|
193
|
+
// others on the next PR.
|
|
194
|
+
const perRuleCount = new Map();
|
|
195
|
+
for (const err of errors) {
|
|
196
|
+
const slug = errorCodeSlug(err.error);
|
|
197
|
+
const ruleId = `json/parse/${slug}`;
|
|
198
|
+
const idx = perRuleCount.get(ruleId) ?? 0;
|
|
199
|
+
perRuleCount.set(ruleId, idx + 1);
|
|
200
|
+
// First occurrence keeps the cleanest fingerprint (most common case
|
|
201
|
+
// is exactly one parse error per file); 2nd+ append `#N` so they are
|
|
202
|
+
// independently dedup-able. Still line-independent.
|
|
203
|
+
const suffix = idx === 0 ? '' : `#${idx}`;
|
|
204
|
+
findings.push({
|
|
205
|
+
source: 'kern',
|
|
206
|
+
ruleId,
|
|
207
|
+
severity: 'error',
|
|
208
|
+
category: 'bug',
|
|
209
|
+
message: humanize(err, dialect),
|
|
210
|
+
primarySpan: spanAt(source, filePath, err.offset, err.length),
|
|
211
|
+
confidence: 100,
|
|
212
|
+
fingerprint: `${ruleId}:${dialect}${suffix}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (tree) {
|
|
216
|
+
findings.push(...detectDuplicateKeys(tree, source, filePath));
|
|
217
|
+
}
|
|
218
|
+
return findings;
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=json.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json.js","sourceRoot":"","sources":["../../src/config-files/json.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown analyzer + outline extractor — self-contained line scanner.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the previous `mdast-util-from-markdown` dependency (which pulled
|
|
5
|
+
* ~30 transitive `micromark-*` packages) with a focused state machine. The
|
|
6
|
+
* tradeoff is deliberate: this is NOT a CommonMark parser. It is a config /
|
|
7
|
+
* docs hygiene scanner that covers exactly what kern-sight and kern-guard
|
|
8
|
+
* need today —
|
|
9
|
+
*
|
|
10
|
+
* • ATX headings (`#` through `######`) outside fenced code
|
|
11
|
+
* • Image syntax `` on a non-code line
|
|
12
|
+
* • Fenced-code awareness (backtick and tilde fences, length-matched)
|
|
13
|
+
* • Outline tree built from heading levels + slugs
|
|
14
|
+
*
|
|
15
|
+
* What we deliberately do NOT handle, because feature surface stays small:
|
|
16
|
+
*
|
|
17
|
+
* • Setext headings (`===` / `---` underlines) — uncommon in this codebase
|
|
18
|
+
* • Inline HTML headings (`<h1>…</h1>`)
|
|
19
|
+
* • Reference-style images (`![alt][label]` + `[label]: url`)
|
|
20
|
+
* • Indented (4-space) code blocks
|
|
21
|
+
* • Tab handling beyond the obvious cases
|
|
22
|
+
* • Inline code spans (`` `` ``) — image syntax inside backtick
|
|
23
|
+
* spans on an otherwise non-fenced line can trip the image-alt rule.
|
|
24
|
+
* False positives here surface as `md/image-missing-alt` on the URL
|
|
25
|
+
* inside the span. Acceptable for a hygiene scanner; if it becomes
|
|
26
|
+
* a real noise source, suppress with `kern-ignore` directives.
|
|
27
|
+
*
|
|
28
|
+
* If a doc uses those forms, findings on it are best-effort. The point is
|
|
29
|
+
* predictable diagnostics for the common 95% case, not full CommonMark
|
|
30
|
+
* fidelity, and to keep `@kernlang/review` trending toward zero dependencies.
|
|
31
|
+
*
|
|
32
|
+
* Two outputs from a single pass:
|
|
33
|
+
*
|
|
34
|
+
* 1. ReviewFinding[] — structural issues (skipped heading levels, missing
|
|
35
|
+
* image alt text). Flow through the engine's standard pipeline so both
|
|
36
|
+
* kern-sight (editor diagnostics) and kern-guard (Check annotations)
|
|
37
|
+
* consume them without API changes.
|
|
38
|
+
*
|
|
39
|
+
* 2. MarkdownOutline — heading tree shaped for kern-sight's Current File
|
|
40
|
+
* webview. Exported separately because kern-guard has no use for it
|
|
41
|
+
* (only findings get posted to GitHub); keeping it off the engine's
|
|
42
|
+
* ReviewReport keeps the worker-side surface minimal.
|
|
43
|
+
*
|
|
44
|
+
* Fingerprint policy: structural keys (heading path, image alt-text URL),
|
|
45
|
+
* NEVER line numbers, so kern-guard's baseline dedup does not re-post on
|
|
46
|
+
* whitespace edits.
|
|
47
|
+
*/
|
|
48
|
+
import type { ReviewFinding } from '../types.js';
|
|
49
|
+
/** A single heading in the outline tree, with nesting. */
|
|
50
|
+
export interface MarkdownOutlineHeading {
|
|
51
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
52
|
+
text: string;
|
|
53
|
+
/** GitHub-style slug (lowercase, hyphenated, alphanumerics + hyphens). */
|
|
54
|
+
slug: string;
|
|
55
|
+
/** 1-based start line of the heading marker. */
|
|
56
|
+
line: number;
|
|
57
|
+
children: MarkdownOutlineHeading[];
|
|
58
|
+
}
|
|
59
|
+
export interface MarkdownOutline {
|
|
60
|
+
/** Flat list of headings in source order. */
|
|
61
|
+
flat: MarkdownOutlineHeading[];
|
|
62
|
+
/** Nested heading tree (each heading owns the higher-level headings under it). */
|
|
63
|
+
tree: MarkdownOutlineHeading[];
|
|
64
|
+
}
|
|
65
|
+
/** Entry point for the engine dispatcher — findings only. */
|
|
66
|
+
export declare function reviewMarkdownFile(source: string, filePath: string): ReviewFinding[];
|
|
67
|
+
/** Public outline extractor — kern-sight only. */
|
|
68
|
+
export declare function extractMarkdownOutline(source: string): MarkdownOutline;
|