@handong66/evidoc-core 0.1.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.
@@ -0,0 +1,852 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import ts from "typescript";
4
+ import { pathExists } from "./repository.js";
5
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun", "npx"]);
6
+ const BUILTIN_PACKAGE_COMMANDS = new Set([
7
+ "add",
8
+ "ci",
9
+ "create",
10
+ "dlx",
11
+ "exec",
12
+ "i",
13
+ "init",
14
+ "install",
15
+ "link",
16
+ "pack",
17
+ "publish",
18
+ "remove",
19
+ "uninstall",
20
+ "view",
21
+ "whoami",
22
+ "x"
23
+ ]);
24
+ export async function detectDrift(snapshot) {
25
+ const findings = [];
26
+ findings.push(...(await detectAgentInstructionDrift(snapshot)));
27
+ for (const doc of snapshot.documents) {
28
+ findings.push(...detectDocumentedCommands(snapshot, doc.path, doc.text));
29
+ if (doc.kind !== "agent_instruction") {
30
+ findings.push(...(await detectMissingPaths(snapshot, doc.path, doc.text)));
31
+ }
32
+ findings.push(...detectApiDrift(snapshot, doc.path, doc.text));
33
+ findings.push(...(await detectSymbolDrift(snapshot, doc.path, doc.text)));
34
+ findings.push(...(await detectFrontmatterDrift(snapshot, doc.path, doc.text)));
35
+ findings.push(...detectPackageClaims(snapshot, doc.path, doc.text));
36
+ findings.push(...detectAdrDrift(snapshot, doc.path, doc.text));
37
+ }
38
+ return findings;
39
+ }
40
+ async function detectAgentInstructionDrift(snapshot) {
41
+ const findings = [];
42
+ const agentDocs = snapshot.documents.filter((doc) => doc.kind === "agent_instruction");
43
+ findings.push(...detectAgentInstructionPackageManagerDrift(snapshot, agentDocs));
44
+ findings.push(...detectAgentInstructionContradictions(agentDocs));
45
+ findings.push(...(await detectAgentInstructionMissingPaths(snapshot, agentDocs)));
46
+ return findings;
47
+ }
48
+ function detectAgentInstructionPackageManagerDrift(snapshot, agentDocs) {
49
+ const findings = [];
50
+ const packageManagers = new Map();
51
+ for (const doc of agentDocs) {
52
+ for (const [line, text] of lines(doc.text)) {
53
+ const match = text.match(/\bpackageManager\s*:\s*(npm|pnpm|yarn|bun)\b/i);
54
+ if (!match)
55
+ continue;
56
+ const manager = match[1].toLowerCase();
57
+ const entries = packageManagers.get(manager) ?? [];
58
+ entries.push({ path: doc.path, line });
59
+ packageManagers.set(manager, entries);
60
+ if (snapshot.expectedPackageManager && manager !== snapshot.expectedPackageManager) {
61
+ const rawSubject = match[0];
62
+ findings.push({
63
+ id: findingId(doc.path, line, "agent_instruction.package-manager-mismatch", "packageManager"),
64
+ ruleId: "agent_instruction.package-manager-mismatch",
65
+ severity: "medium",
66
+ status: "review_needed",
67
+ docPath: doc.path,
68
+ line,
69
+ message: `${doc.path}:${line} declares ${manager}, but this repository is configured for ${snapshot.expectedPackageManager}.`,
70
+ evidence: [
71
+ {
72
+ kind: "agent_instruction",
73
+ subject: rawSubject,
74
+ expected: snapshot.expectedPackageManager,
75
+ actual: manager,
76
+ detail: "Agent instruction package-manager field does not match package.json or lockfile evidence."
77
+ }
78
+ ],
79
+ suggestedAction: `Update the agent instruction packageManager field to ${snapshot.expectedPackageManager}.`
80
+ });
81
+ }
82
+ }
83
+ }
84
+ if (packageManagers.size <= 1)
85
+ return findings;
86
+ const actual = [...packageManagers.entries()]
87
+ .map(([manager, entries]) => `${manager} in ${entries.map((entry) => entry.path).join(", ")}`)
88
+ .join("; ");
89
+ const first = [...packageManagers.values()][0][0];
90
+ findings.push({
91
+ id: findingId(first.path, first.line, "agent_instruction.conflict", "packageManager"),
92
+ ruleId: "agent_instruction.conflict",
93
+ severity: "high",
94
+ status: "broken",
95
+ docPath: first.path,
96
+ line: first.line,
97
+ message: "Agent instruction files declare conflicting package managers.",
98
+ evidence: [
99
+ {
100
+ kind: "config",
101
+ subject: "packageManager",
102
+ expected: "one package manager across agent instruction files",
103
+ actual,
104
+ detail: "AGENTS.md and CLAUDE.md are agent-facing control surfaces and must not disagree."
105
+ }
106
+ ],
107
+ suggestedAction: "Align agent instruction package-manager guidance with the repository package manager."
108
+ });
109
+ return findings;
110
+ }
111
+ function detectAgentInstructionContradictions(agentDocs) {
112
+ const required = new Map();
113
+ const forbidden = new Map();
114
+ for (const doc of agentDocs) {
115
+ for (const [line, text] of lines(doc.text)) {
116
+ const must = text.match(/\b(?:always|must)\s+(?:use|run)\s+[`"']?([^`"'.\n]+)[`"']?/i);
117
+ if (must) {
118
+ const key = normalizeInstructionTarget(must[1]);
119
+ const entries = required.get(key) ?? [];
120
+ entries.push({ path: doc.path, line, text: text.trim() });
121
+ required.set(key, entries);
122
+ }
123
+ const never = text.match(/\b(?:never|do not|don't)\s+(?:use|run)\s+[`"']?([^`"'.\n]+)[`"']?/i);
124
+ if (never) {
125
+ const key = normalizeInstructionTarget(never[1]);
126
+ const entries = forbidden.get(key) ?? [];
127
+ entries.push({ path: doc.path, line, text: text.trim() });
128
+ forbidden.set(key, entries);
129
+ }
130
+ }
131
+ }
132
+ const findings = [];
133
+ for (const [target, requiredEntries] of required) {
134
+ const forbiddenEntries = forbidden.get(target);
135
+ if (!forbiddenEntries?.length)
136
+ continue;
137
+ const first = requiredEntries[0];
138
+ findings.push({
139
+ id: findingId(first.path, first.line, "agent_instruction.contradiction", target),
140
+ ruleId: "agent_instruction.contradiction",
141
+ severity: "high",
142
+ status: "broken",
143
+ docPath: first.path,
144
+ line: first.line,
145
+ message: `Agent instruction files both require and forbid "${target}".`,
146
+ evidence: [
147
+ {
148
+ kind: "agent_instruction",
149
+ subject: target,
150
+ expected: `consistent instruction for ${target}`,
151
+ actual: [
152
+ ...requiredEntries.map((entry) => `${entry.path}:${entry.line} ${entry.text}`),
153
+ ...forbiddenEntries.map((entry) => `${entry.path}:${entry.line} ${entry.text}`)
154
+ ].join("; "),
155
+ detail: "Agent-facing control files contain directly contradictory always/never guidance."
156
+ }
157
+ ],
158
+ suggestedAction: "Choose one current instruction and update AGENTS.md, CLAUDE.md, Cursor, and Copilot guidance to match."
159
+ });
160
+ }
161
+ return findings;
162
+ }
163
+ async function detectAgentInstructionMissingPaths(snapshot, agentDocs) {
164
+ const findings = [];
165
+ const seen = new Set();
166
+ for (const doc of agentDocs) {
167
+ for (const reference of extractPathReferences(doc.text)) {
168
+ const candidate = normalizeCandidatePath(reference.path);
169
+ if (!candidate || seen.has(`${doc.path}:${candidate}:${reference.line}`))
170
+ continue;
171
+ seen.add(`${doc.path}:${candidate}:${reference.line}`);
172
+ if (snapshot.files.has(candidate) || (await pathExists(snapshot.root, candidate)))
173
+ continue;
174
+ findings.push({
175
+ id: findingId(doc.path, reference.line, "agent_instruction.missing-path", candidate),
176
+ ruleId: "agent_instruction.missing-path",
177
+ severity: "high",
178
+ status: "broken",
179
+ docPath: doc.path,
180
+ line: reference.line,
181
+ message: `${doc.path}:${reference.line} points agents at missing path ${candidate}.`,
182
+ evidence: [
183
+ {
184
+ kind: "agent_instruction",
185
+ subject: candidate,
186
+ expected: "agent instruction file path exists",
187
+ actual: "missing",
188
+ detail: "Agent instruction files are executable guidance for coding agents; stale file pointers cause repeated wrong actions."
189
+ }
190
+ ],
191
+ suggestedAction: "Update the agent instruction path, restore the file, or remove the stale instruction."
192
+ });
193
+ }
194
+ }
195
+ return findings;
196
+ }
197
+ function normalizeInstructionTarget(value) {
198
+ return value.replace(/[.;:]+$/g, "").trim().replace(/\s+/g, " ").toLowerCase();
199
+ }
200
+ function detectDocumentedCommands(snapshot, docPath, text) {
201
+ const findings = [];
202
+ const seen = new Set();
203
+ const scripts = snapshot.packageManifest?.scripts ?? {};
204
+ for (const command of extractDocumentedCommands(text)) {
205
+ const key = `${command.manager}:${command.script}:${command.line}`;
206
+ if (seen.has(key))
207
+ continue;
208
+ seen.add(key);
209
+ if (snapshot.expectedPackageManager &&
210
+ shouldCheckPackageManagerMismatch(command) &&
211
+ !isPackageManagerCompatible(command.manager, snapshot.expectedPackageManager)) {
212
+ findings.push({
213
+ id: findingId(docPath, command.line, "command.package-manager-mismatch", command.raw),
214
+ ruleId: "command.package-manager-mismatch",
215
+ severity: "medium",
216
+ status: "review_needed",
217
+ docPath,
218
+ line: command.line,
219
+ message: `${docPath}:${command.line} uses ${command.manager}, but this repository is configured for ${snapshot.expectedPackageManager}.`,
220
+ evidence: [
221
+ {
222
+ kind: "command",
223
+ subject: command.raw,
224
+ expected: snapshot.expectedPackageManager,
225
+ actual: command.manager,
226
+ detail: "Documented package-manager command does not match package.json or lockfile evidence."
227
+ }
228
+ ],
229
+ suggestedAction: `Review the command and update it to ${snapshot.expectedPackageManager} if the repository package manager has changed.`
230
+ });
231
+ }
232
+ if (snapshot.packageManifest &&
233
+ command.manager !== "npx" &&
234
+ !BUILTIN_PACKAGE_COMMANDS.has(command.script) &&
235
+ !Object.hasOwn(scripts, command.script)) {
236
+ findings.push({
237
+ id: findingId(docPath, command.line, "command.unknown-script", command.raw),
238
+ ruleId: "command.unknown-script",
239
+ severity: "high",
240
+ status: "broken",
241
+ docPath,
242
+ line: command.line,
243
+ message: `${docPath}:${command.line} references script "${command.script}", but package.json does not define it.`,
244
+ evidence: [
245
+ {
246
+ kind: "command",
247
+ subject: command.raw,
248
+ expected: `package.json scripts.${command.script}`,
249
+ actual: "missing",
250
+ detail: "Documented command names a package script that is absent from the manifest."
251
+ }
252
+ ],
253
+ suggestedAction: "Update the documented command or add the missing package script if the command is still intended."
254
+ });
255
+ }
256
+ }
257
+ return findings;
258
+ }
259
+ async function detectMissingPaths(snapshot, docPath, text) {
260
+ const findings = [];
261
+ const seen = new Set();
262
+ for (const reference of extractPathReferences(text)) {
263
+ const candidate = normalizeCandidatePath(reference.path);
264
+ if (!candidate || seen.has(`${candidate}:${reference.line}`))
265
+ continue;
266
+ seen.add(`${candidate}:${reference.line}`);
267
+ if (snapshot.files.has(candidate)) {
268
+ continue;
269
+ }
270
+ if (!(await pathExists(snapshot.root, candidate))) {
271
+ findings.push({
272
+ id: findingId(docPath, reference.line, "path.missing-reference", candidate),
273
+ ruleId: "path.missing-reference",
274
+ severity: "high",
275
+ status: "broken",
276
+ docPath,
277
+ line: reference.line,
278
+ message: `${docPath}:${reference.line} references missing path ${candidate}.`,
279
+ evidence: [
280
+ {
281
+ kind: "path",
282
+ subject: candidate,
283
+ expected: "path exists in repository",
284
+ actual: "missing",
285
+ detail: "Markdown contains a local repository path that could not be resolved."
286
+ }
287
+ ],
288
+ suggestedAction: "Update the path reference, restore the file, or mark the example so Evidoc can ignore it."
289
+ });
290
+ }
291
+ }
292
+ return findings;
293
+ }
294
+ function detectApiDrift(snapshot, docPath, text) {
295
+ if (snapshot.apiOperations.length === 0)
296
+ return [];
297
+ const findings = [];
298
+ const known = new Set(snapshot.apiOperations.map((operation) => `${operation.method} ${operation.path}`));
299
+ const byOperation = new Map(snapshot.apiOperations.map((operation) => [`${operation.method} ${operation.path}`, operation]));
300
+ const seen = new Set();
301
+ for (const reference of extractApiReferences(text)) {
302
+ const key = `${reference.method} ${reference.path}`;
303
+ if (seen.has(`${key}:${reference.line}`))
304
+ continue;
305
+ seen.add(`${key}:${reference.line}`);
306
+ if (known.has(key))
307
+ continue;
308
+ findings.push({
309
+ id: findingId(docPath, reference.line, "api.missing-operation", key),
310
+ ruleId: "api.missing-operation",
311
+ severity: "high",
312
+ status: "broken",
313
+ docPath,
314
+ line: reference.line,
315
+ message: `${docPath}:${reference.line} references ${key}, but no matching OpenAPI operation exists.`,
316
+ evidence: [
317
+ {
318
+ kind: "api",
319
+ subject: key,
320
+ expected: "OpenAPI operation exists",
321
+ actual: "missing",
322
+ detail: "Documented HTTP operation could not be resolved against openapi.json/swagger.json."
323
+ }
324
+ ],
325
+ suggestedAction: "Update the documented API operation, add it to the OpenAPI spec, or mark the example as intentionally external."
326
+ });
327
+ }
328
+ for (const reference of extractApiSchemaReferences(text)) {
329
+ const key = `${reference.method} ${reference.path}`;
330
+ const operation = byOperation.get(key);
331
+ if (!operation)
332
+ continue;
333
+ const requestMatches = !reference.requestSchema || operation.requestSchemas.includes(reference.requestSchema);
334
+ const responseSchemas = Object.values(operation.responseSchemas).flat();
335
+ const responseMatches = !reference.responseSchema || responseSchemas.includes(reference.responseSchema);
336
+ if (requestMatches && responseMatches)
337
+ continue;
338
+ const subject = `${key} request:${reference.requestSchema ?? "*"} response:${reference.responseSchema ?? "*"}`;
339
+ findings.push({
340
+ id: findingId(docPath, reference.line, "api.schema-mismatch", subject),
341
+ ruleId: "api.schema-mismatch",
342
+ severity: "high",
343
+ status: "broken",
344
+ docPath,
345
+ line: reference.line,
346
+ message: `${docPath}:${reference.line} references ${key} ${requestMatches ? "response schema" : "request schema"} that does not match the OpenAPI operation.`,
347
+ evidence: [
348
+ {
349
+ kind: "api",
350
+ subject,
351
+ expected: `request schemas ${operation.requestSchemas.join(", ") || "none"}; response schemas ${responseSchemas.join(", ") || "none"}`,
352
+ actual: `request ${reference.requestSchema ?? "unspecified"}; response ${reference.responseSchema ?? "unspecified"}`,
353
+ detail: "Documented request/response schema claim could not be resolved against the OpenAPI operation."
354
+ }
355
+ ],
356
+ suggestedAction: "Update the documented schema names or the OpenAPI operation schema references."
357
+ });
358
+ }
359
+ return findings;
360
+ }
361
+ async function detectSymbolDrift(snapshot, docPath, text) {
362
+ const findings = [];
363
+ for (const reference of extractSymbolReferences(text)) {
364
+ if (!snapshot.files.has(reference.path))
365
+ continue;
366
+ if (await fileHasSymbol(snapshot.root, reference.path, reference.symbol))
367
+ continue;
368
+ findings.push({
369
+ id: findingId(docPath, reference.line, "symbol.missing-reference", `${reference.path}#${reference.symbol}`),
370
+ ruleId: "symbol.missing-reference",
371
+ severity: "high",
372
+ status: "broken",
373
+ docPath,
374
+ line: reference.line,
375
+ message: `${docPath}:${reference.line} references missing symbol ${reference.path}#${reference.symbol}.`,
376
+ evidence: [
377
+ {
378
+ kind: "symbol",
379
+ subject: `${reference.path}#${reference.symbol}`,
380
+ expected: "symbol exists in source file",
381
+ actual: "missing",
382
+ detail: "Documented source binding names a symbol that could not be found in the referenced file."
383
+ }
384
+ ],
385
+ suggestedAction: "Update the symbol binding, restore the symbol, or remove the source binding if it is no longer relevant."
386
+ });
387
+ }
388
+ return findings;
389
+ }
390
+ async function detectFrontmatterDrift(snapshot, docPath, text) {
391
+ const parsed = parseFrontmatter(text);
392
+ if (!parsed)
393
+ return [];
394
+ const findings = [];
395
+ for (const source of parsed.sources) {
396
+ if (snapshot.files.has(source.path) || (await pathExists(snapshot.root, source.path))) {
397
+ if (snapshot.config.requireTestsForSources && !hasTestCoverage(snapshot, source.path)) {
398
+ findings.push(missingSourceCoverageFinding(docPath, source.line, source.path));
399
+ }
400
+ continue;
401
+ }
402
+ findings.push({
403
+ id: findingId(docPath, source.line, "frontmatter.missing-source", source.path),
404
+ ruleId: "frontmatter.missing-source",
405
+ severity: "high",
406
+ status: "broken",
407
+ docPath,
408
+ line: source.line,
409
+ message: `${docPath}:${source.line} binds missing frontmatter source ${source.path}.`,
410
+ evidence: [
411
+ {
412
+ kind: "frontmatter",
413
+ subject: source.path,
414
+ expected: "frontmatter source path exists",
415
+ actual: "missing",
416
+ detail: "The document declares a source binding that cannot be resolved in the repository."
417
+ }
418
+ ],
419
+ suggestedAction: "Restore the source file, update the frontmatter source binding, or remove the binding after review."
420
+ });
421
+ if (snapshot.config.requireTestsForSources && !hasTestCoverage(snapshot, source.path)) {
422
+ findings.push(missingSourceCoverageFinding(docPath, source.line, source.path));
423
+ }
424
+ }
425
+ const ttlDays = parsed.ttlDays ?? snapshot.config.reviewTtlDays;
426
+ if (parsed.lastReviewed && ttlDays !== undefined) {
427
+ const reviewedAt = Date.parse(parsed.lastReviewed);
428
+ if (!Number.isNaN(reviewedAt)) {
429
+ const expiresAt = reviewedAt + ttlDays * 24 * 60 * 60 * 1000;
430
+ if (expiresAt < Date.now()) {
431
+ findings.push({
432
+ id: findingId(docPath, parsed.lastReviewedLine, "frontmatter.review-expired", parsed.lastReviewed),
433
+ ruleId: "frontmatter.review-expired",
434
+ severity: "medium",
435
+ status: "review_needed",
436
+ docPath,
437
+ line: parsed.lastReviewedLine,
438
+ message: `${docPath}:${parsed.lastReviewedLine} review window expired for lastReviewed ${parsed.lastReviewed}.`,
439
+ evidence: [
440
+ {
441
+ kind: "frontmatter",
442
+ subject: "lastReviewed",
443
+ expected: `review within ${ttlDays} days`,
444
+ actual: parsed.lastReviewed,
445
+ detail: "The document declares a review TTL that has elapsed."
446
+ }
447
+ ],
448
+ suggestedAction: "Re-review the document against its sources and update lastReviewed only after the evidence is checked."
449
+ });
450
+ }
451
+ }
452
+ }
453
+ return findings;
454
+ }
455
+ function missingSourceCoverageFinding(docPath, line, sourcePath) {
456
+ return {
457
+ id: findingId(docPath, line, "test.missing-source-coverage", sourcePath),
458
+ ruleId: "test.missing-source-coverage",
459
+ severity: "medium",
460
+ status: "review_needed",
461
+ docPath,
462
+ line,
463
+ message: `${docPath}:${line} binds ${sourcePath}, but no matching test file was found.`,
464
+ evidence: [
465
+ {
466
+ kind: "config",
467
+ subject: sourcePath,
468
+ expected: "matching test file for frontmatter source binding",
469
+ actual: "missing",
470
+ detail: "Configured source coverage checks require a nearby or test-directory test for bound source files."
471
+ }
472
+ ],
473
+ suggestedAction: "Add a source test, update owner mapping, or disable requireTestsForSources for this repository."
474
+ };
475
+ }
476
+ function detectPackageClaims(snapshot, docPath, text) {
477
+ const findings = [];
478
+ const manifest = snapshot.packageManifest;
479
+ if (!manifest)
480
+ return findings;
481
+ for (const claim of extractPackageClaims(text)) {
482
+ const actual = packageField(manifest, claim.field);
483
+ if (actual === undefined || actual === claim.value)
484
+ continue;
485
+ findings.push({
486
+ id: findingId(docPath, claim.line, "claim.package-field-mismatch", `${claim.field}=${claim.value}`),
487
+ ruleId: "claim.package-field-mismatch",
488
+ severity: "medium",
489
+ status: "review_needed",
490
+ docPath,
491
+ line: claim.line,
492
+ message: `${docPath}:${claim.line} claims package.${claim.field}=${claim.value}, but package.json has ${actual}.`,
493
+ evidence: [
494
+ {
495
+ kind: "config",
496
+ subject: `package.${claim.field}`,
497
+ expected: String(actual),
498
+ actual: claim.value,
499
+ detail: "Documented semantic package claim does not match package.json."
500
+ }
501
+ ],
502
+ suggestedAction: "Update the documented claim or package metadata after review."
503
+ });
504
+ }
505
+ return findings;
506
+ }
507
+ function detectAdrDrift(snapshot, docPath, text) {
508
+ const findings = [];
509
+ const docsByPath = new Map(snapshot.documents.map((doc) => [doc.path, doc]));
510
+ for (const reference of extractAdrReferences(text)) {
511
+ const adr = docsByPath.get(reference.path);
512
+ if (!adr)
513
+ continue;
514
+ const status = adr.text.match(/^Status:\s*(.+)$/im)?.[1]?.trim().toLowerCase();
515
+ if (status !== "superseded" && status !== "deprecated")
516
+ continue;
517
+ findings.push({
518
+ id: findingId(docPath, reference.line, "adr.superseded-reference", reference.path),
519
+ ruleId: "adr.superseded-reference",
520
+ severity: "medium",
521
+ status: "review_needed",
522
+ docPath,
523
+ line: reference.line,
524
+ message: `${docPath}:${reference.line} references ${status} ADR ${reference.path}.`,
525
+ evidence: [
526
+ {
527
+ kind: "path",
528
+ subject: reference.path,
529
+ expected: "active ADR",
530
+ actual: status,
531
+ detail: "Document references an ADR whose status indicates it should not be treated as current guidance."
532
+ }
533
+ ],
534
+ suggestedAction: "Update the document to the superseding ADR or record why the old ADR still applies."
535
+ });
536
+ }
537
+ return findings;
538
+ }
539
+ function extractDocumentedCommands(text) {
540
+ const results = [];
541
+ const commandRegex = /\b(npm|pnpm|yarn|bun|npx)\s+(?:run\s+)?((?:@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_.:@/-]+)\b/g;
542
+ for (const { line: index, text: snippet } of codeSnippets(text)) {
543
+ for (const match of snippet.matchAll(commandRegex)) {
544
+ const manager = match[1];
545
+ const script = match[2];
546
+ if (!PACKAGE_MANAGERS.has(manager))
547
+ continue;
548
+ results.push({
549
+ line: index,
550
+ manager,
551
+ script,
552
+ raw: match[0]
553
+ });
554
+ }
555
+ }
556
+ return results;
557
+ }
558
+ function shouldCheckPackageManagerMismatch(command) {
559
+ if (command.manager !== "npx")
560
+ return true;
561
+ if (command.script.startsWith("-"))
562
+ return false;
563
+ if (command.script.startsWith("@") || command.script.includes("/"))
564
+ return false;
565
+ return true;
566
+ }
567
+ function isPackageManagerCompatible(manager, expected) {
568
+ if (manager === expected)
569
+ return true;
570
+ return manager === "npx" && expected === "npm";
571
+ }
572
+ function extractPathReferences(text) {
573
+ const results = [];
574
+ const markdownLinkRegex = /\[[^\]]*]\((?!https?:|mailto:|#)([^)\s#]+)(?:#[^)]+)?\)/g;
575
+ const pathRegex = /(?:^|[\s`"'])((?:\.{1,2}\/)?[A-Za-z0-9_.@/-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|ya?ml|mdx?|py|go|rs|java|rb|php|cs|sh|toml|sql|proto|graphql|gql|lock))(?:$|[\s`"',;:)])/g;
576
+ for (const [index, line] of lines(text)) {
577
+ for (const match of line.matchAll(markdownLinkRegex)) {
578
+ results.push({ line: index, path: match[1] });
579
+ }
580
+ }
581
+ for (const snippet of codeSnippets(text)) {
582
+ for (const match of snippet.text.matchAll(pathRegex)) {
583
+ results.push({ line: snippet.line, path: match[1] });
584
+ }
585
+ }
586
+ return results;
587
+ }
588
+ function extractApiReferences(text) {
589
+ const results = [];
590
+ const apiRegex = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\s+((?:\/[A-Za-z0-9._~!$&'()*+,;=:@%-]+)+)\b/g;
591
+ for (const snippet of codeSnippets(text)) {
592
+ for (const match of snippet.text.matchAll(apiRegex)) {
593
+ results.push({
594
+ line: snippet.line,
595
+ method: match[1].toUpperCase(),
596
+ path: match[2]
597
+ });
598
+ }
599
+ }
600
+ return results;
601
+ }
602
+ function extractApiSchemaReferences(text) {
603
+ const results = [];
604
+ const apiSchemaRegex = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\s+((?:\/[A-Za-z0-9._~!$&'()*+,;=:@%-]+)+)\b([^\n`]*)/g;
605
+ for (const snippet of codeSnippets(text)) {
606
+ for (const match of snippet.text.matchAll(apiSchemaRegex)) {
607
+ const tail = match[3] ?? "";
608
+ const requestSchema = tail.match(/\brequest:\s*([A-Za-z_$][\w$]*)\b/)?.[1];
609
+ const responseSchema = tail.match(/\bresponse:\s*([A-Za-z_$][\w$]*)\b/)?.[1];
610
+ if (!requestSchema && !responseSchema)
611
+ continue;
612
+ results.push({
613
+ line: snippet.line,
614
+ method: match[1].toUpperCase(),
615
+ path: match[2],
616
+ requestSchema,
617
+ responseSchema
618
+ });
619
+ }
620
+ }
621
+ return results;
622
+ }
623
+ function extractSymbolReferences(text) {
624
+ const results = [];
625
+ const symbolRegex = /((?:\.{1,2}\/)?[A-Za-z0-9_.@/-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|cs))#([A-Za-z_$][\w$]*)/g;
626
+ for (const snippet of codeSnippets(text)) {
627
+ for (const match of snippet.text.matchAll(symbolRegex)) {
628
+ const path = normalizeCandidatePath(match[1]);
629
+ if (!path)
630
+ continue;
631
+ results.push({ line: snippet.line, path, symbol: match[2] });
632
+ }
633
+ }
634
+ const frontmatter = parseFrontmatter(text);
635
+ for (const source of frontmatter?.sources ?? []) {
636
+ for (const symbol of source.symbols) {
637
+ results.push({ line: source.line, path: source.path, symbol });
638
+ }
639
+ }
640
+ return results;
641
+ }
642
+ function extractPackageClaims(text) {
643
+ const results = [];
644
+ const claimRegex = /\bclaim:package\.([A-Za-z0-9_.-]+)=([^\s`]+)\b/g;
645
+ for (const snippet of codeSnippets(text)) {
646
+ for (const match of snippet.text.matchAll(claimRegex)) {
647
+ results.push({ line: snippet.line, field: match[1], value: match[2] });
648
+ }
649
+ }
650
+ return results;
651
+ }
652
+ function extractAdrReferences(text) {
653
+ const results = [];
654
+ const adrRegex = /\bADR:\s*((?:\.{1,2}\/)?[A-Za-z0-9_.@/-]+\.md)\b/g;
655
+ for (const snippet of codeSnippets(text)) {
656
+ for (const match of snippet.text.matchAll(adrRegex)) {
657
+ const path = normalizeCandidatePath(match[1]);
658
+ if (path)
659
+ results.push({ line: snippet.line, path });
660
+ }
661
+ }
662
+ return results;
663
+ }
664
+ function normalizeCandidatePath(raw) {
665
+ const withoutQuotes = raw.trim().replace(/^['"`]+|['"`.,;:)]+$/g, "");
666
+ if (!withoutQuotes)
667
+ return undefined;
668
+ if (withoutQuotes.includes("://"))
669
+ return undefined;
670
+ if (withoutQuotes.startsWith("#"))
671
+ return undefined;
672
+ if (withoutQuotes.startsWith("/"))
673
+ return undefined;
674
+ if (withoutQuotes.startsWith("node_modules/"))
675
+ return undefined;
676
+ if (withoutQuotes.includes("*"))
677
+ return undefined;
678
+ if (withoutQuotes.includes("$"))
679
+ return undefined;
680
+ return withoutQuotes.replace(/^\.\//, "");
681
+ }
682
+ async function fileHasSymbol(root, path, symbol) {
683
+ const text = await readFile(join(root, path), "utf8");
684
+ if (hasTypeScriptSymbol(text, path, symbol)) {
685
+ return true;
686
+ }
687
+ const escaped = escapeRegExp(symbol);
688
+ const patterns = [
689
+ new RegExp(`\\b(?:export\\s+)?(?:async\\s+)?function\\s+${escaped}\\b`),
690
+ new RegExp(`\\b(?:export\\s+)?(?:abstract\\s+)?class\\s+${escaped}\\b`),
691
+ new RegExp(`\\b(?:export\\s+)?interface\\s+${escaped}\\b`),
692
+ new RegExp(`\\b(?:export\\s+)?type\\s+${escaped}\\b`),
693
+ new RegExp(`\\b(?:export\\s+)?(?:const|let|var)\\s+${escaped}\\b`),
694
+ new RegExp(`\\b(?:export\\s+)?enum\\s+${escaped}\\b`),
695
+ new RegExp(`\\b${escaped}\\s*[:=]\\s*`)
696
+ ];
697
+ return patterns.some((pattern) => pattern.test(text));
698
+ }
699
+ function hasTypeScriptSymbol(text, path, symbol) {
700
+ const scriptKind = path.endsWith(".tsx")
701
+ ? ts.ScriptKind.TSX
702
+ : path.endsWith(".jsx")
703
+ ? ts.ScriptKind.JSX
704
+ : path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")
705
+ ? ts.ScriptKind.JS
706
+ : ts.ScriptKind.TS;
707
+ const sourceFile = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKind);
708
+ const exported = new Set();
709
+ function visit(node) {
710
+ if (hasExportModifier(node)) {
711
+ const name = declarationName(node);
712
+ if (name)
713
+ exported.add(name);
714
+ }
715
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
716
+ for (const element of node.exportClause.elements) {
717
+ exported.add(element.name.text);
718
+ }
719
+ }
720
+ ts.forEachChild(node, visit);
721
+ }
722
+ visit(sourceFile);
723
+ return exported.has(symbol);
724
+ }
725
+ function hasExportModifier(node) {
726
+ return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
727
+ }
728
+ function declarationName(node) {
729
+ if ((ts.isFunctionDeclaration(node) ||
730
+ ts.isClassDeclaration(node) ||
731
+ ts.isInterfaceDeclaration(node) ||
732
+ ts.isTypeAliasDeclaration(node) ||
733
+ ts.isEnumDeclaration(node) ||
734
+ ts.isVariableDeclaration(node)) &&
735
+ node.name &&
736
+ ts.isIdentifier(node.name)) {
737
+ return node.name.text;
738
+ }
739
+ if (ts.isVariableStatement(node)) {
740
+ for (const declaration of node.declarationList.declarations) {
741
+ if (ts.isIdentifier(declaration.name))
742
+ return declaration.name.text;
743
+ }
744
+ }
745
+ return undefined;
746
+ }
747
+ function parseFrontmatter(text) {
748
+ const allLines = text.split(/\r?\n/);
749
+ if (allLines[0]?.trim() !== "---")
750
+ return undefined;
751
+ const end = allLines.findIndex((line, index) => index > 0 && line.trim() === "---");
752
+ if (end < 0)
753
+ return undefined;
754
+ const parsed = {
755
+ lastReviewedLine: 1,
756
+ sources: []
757
+ };
758
+ let currentSource;
759
+ let inSymbols = false;
760
+ for (let index = 1; index < end; index += 1) {
761
+ const line = allLines[index] ?? "";
762
+ const lineNumber = index + 1;
763
+ const trimmed = line.trim();
764
+ if (trimmed.startsWith("- path:")) {
765
+ currentSource = {
766
+ path: trimmed.slice("- path:".length).trim(),
767
+ line: lineNumber,
768
+ symbols: []
769
+ };
770
+ parsed.sources.push(currentSource);
771
+ inSymbols = false;
772
+ continue;
773
+ }
774
+ if (trimmed === "symbols:") {
775
+ inSymbols = true;
776
+ continue;
777
+ }
778
+ if (inSymbols && trimmed.startsWith("- ") && currentSource) {
779
+ currentSource.symbols.push(trimmed.slice(2).trim());
780
+ continue;
781
+ }
782
+ if (trimmed.startsWith("lastReviewed:")) {
783
+ parsed.lastReviewed = trimmed.slice("lastReviewed:".length).trim();
784
+ parsed.lastReviewedLine = lineNumber;
785
+ inSymbols = false;
786
+ continue;
787
+ }
788
+ if (trimmed.startsWith("ttlDays:")) {
789
+ const value = Number.parseInt(trimmed.slice("ttlDays:".length).trim(), 10);
790
+ if (Number.isFinite(value))
791
+ parsed.ttlDays = value;
792
+ inSymbols = false;
793
+ continue;
794
+ }
795
+ if (trimmed && !line.startsWith(" ")) {
796
+ inSymbols = false;
797
+ }
798
+ }
799
+ return parsed;
800
+ }
801
+ function hasTestCoverage(snapshot, sourcePath) {
802
+ const withoutExtension = sourcePath.replace(/\.[^.]+$/, "");
803
+ const basename = withoutExtension.split("/").at(-1);
804
+ if (!basename)
805
+ return false;
806
+ const candidates = [
807
+ `${withoutExtension}.test.ts`,
808
+ `${withoutExtension}.spec.ts`,
809
+ `${withoutExtension}.test.tsx`,
810
+ `${withoutExtension}.spec.tsx`,
811
+ `test/${basename}.test.ts`,
812
+ `tests/${basename}.test.ts`,
813
+ `test/${basename}.spec.ts`,
814
+ `tests/${basename}.spec.ts`
815
+ ];
816
+ return candidates.some((candidate) => snapshot.files.has(candidate));
817
+ }
818
+ function packageField(manifest, field) {
819
+ if (field === "name")
820
+ return manifest.name;
821
+ if (field === "version")
822
+ return manifest.version;
823
+ return undefined;
824
+ }
825
+ function findingId(docPath, line, ruleId, subject) {
826
+ return `${docPath}:${line}:${ruleId}:${subject}`.replace(/\s+/g, " ");
827
+ }
828
+ function escapeRegExp(value) {
829
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
830
+ }
831
+ function codeSnippets(text) {
832
+ const snippets = [];
833
+ let inFence = false;
834
+ for (const [lineNumber, line] of lines(text)) {
835
+ if (line.trimStart().startsWith("```")) {
836
+ inFence = !inFence;
837
+ continue;
838
+ }
839
+ if (inFence) {
840
+ snippets.push({ line: lineNumber, text: line });
841
+ continue;
842
+ }
843
+ for (const match of line.matchAll(/`([^`]+)`/g)) {
844
+ snippets.push({ line: lineNumber, text: match[1] });
845
+ }
846
+ }
847
+ return snippets;
848
+ }
849
+ function lines(text) {
850
+ return text.split(/\r?\n/).map((line, index) => [index + 1, line]);
851
+ }
852
+ //# sourceMappingURL=detectors.js.map