@drkmttr/adhere 0.18.0 → 0.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1099 -1066
- package/package.json +15 -15
- package/presets/README.md +132 -0
- package/skills/adhere/SKILL.md +51 -33
- package/skills/adhere-setup/SKILL.md +77 -53
- package/src/cli.ts +133 -14
- package/src/config.ts +157 -138
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +147 -0
- package/src/create/look.ts +140 -0
- package/src/create/wizard.ts +432 -0
- package/src/index.ts +2 -0
- package/src/init.ts +24 -4
- package/src/install.ts +30 -24
- package/src/instructions.ts +76 -0
- package/src/markdown.ts +3 -9
- package/src/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +31 -18
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +26 -2
- package/src/services/Jev.ts +36 -14
- package/src/wording.ts +1 -1
- package/src/workflows/audit.ts +84 -16
- package/src/workflows/format.ts +97 -0
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
package/src/workflows/format.ts
CHANGED
|
@@ -89,6 +89,10 @@ const hanging = (label: Span, lines: ReadonlyArray<Line>): ReadonlyArray<Line> =
|
|
|
89
89
|
const highlighted = (code: string): ReadonlyArray<Line> =>
|
|
90
90
|
tokenize(code).map((tokens) => tokens.map(({ text, kind }) => span(text, kind)));
|
|
91
91
|
|
|
92
|
+
/** Code as lines of text, highlighted as a report highlights it when `color`. */
|
|
93
|
+
export const highlightedLines = (code: string, color: boolean): ReadonlyArray<string> =>
|
|
94
|
+
highlighted(code).map((line) => serialize(line, color));
|
|
95
|
+
|
|
92
96
|
/** Code as highlighted lines, without the blank lines at either end. */
|
|
93
97
|
const codeLines = (code: string): ReadonlyArray<Line> => {
|
|
94
98
|
const lines = highlighted(code);
|
|
@@ -398,6 +402,99 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
398
402
|
].map((line) => serialize(line, options.color === true));
|
|
399
403
|
};
|
|
400
404
|
|
|
405
|
+
/** The complete audit result, with the same rule ids and relative paths as the text report. */
|
|
406
|
+
export const renderJson = (result: AuditResult, options: RenderOptions = {}): string =>
|
|
407
|
+
JSON.stringify(
|
|
408
|
+
{
|
|
409
|
+
...result,
|
|
410
|
+
threshold: options.threshold ?? DEFAULT_THRESHOLD,
|
|
411
|
+
sufficiencyThreshold: options.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
|
|
412
|
+
...(options.provider === undefined ? {} : { provider: options.provider }),
|
|
413
|
+
findings: result.findings.map((finding) => ({
|
|
414
|
+
...finding,
|
|
415
|
+
rule: shownId({ id: finding.rule, preset: finding.preset }),
|
|
416
|
+
file: displayPath(finding.file, options.root),
|
|
417
|
+
})),
|
|
418
|
+
blocked: result.blocked.map((blocked) => ({
|
|
419
|
+
...blocked,
|
|
420
|
+
file: displayPath(blocked.file, options.root),
|
|
421
|
+
})),
|
|
422
|
+
},
|
|
423
|
+
null,
|
|
424
|
+
2,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const markdownText = (text: string): string => text.replace(/[\\`*_[\]<>#]/g, "\\$&");
|
|
428
|
+
|
|
429
|
+
/** A fence longer than any backtick run in the code, including template literals. */
|
|
430
|
+
const fenced = (code: string): string => {
|
|
431
|
+
const fence = "`".repeat(
|
|
432
|
+
Math.max(3, ...Array.from(code.matchAll(/`+/g), ([run]) => run.length + 1)),
|
|
433
|
+
);
|
|
434
|
+
return `${fence}typescript\n${code}\n${fence}`;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** All findings grouped by rule, followed by the run summary once. */
|
|
438
|
+
export const renderMarkdown = (result: AuditResult, options: RenderOptions = {}): string => {
|
|
439
|
+
const sufficiencyThreshold = options.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD;
|
|
440
|
+
const groups = Map.groupBy(result.findings, (finding) =>
|
|
441
|
+
shownId({ id: finding.rule, preset: finding.preset }),
|
|
442
|
+
);
|
|
443
|
+
return [
|
|
444
|
+
...Array.from(groups, ([id, findings]) =>
|
|
445
|
+
[
|
|
446
|
+
`# ${markdownText(id)}`,
|
|
447
|
+
counted(findings.length, "finding", "findings"),
|
|
448
|
+
...findings.flatMap((finding) => {
|
|
449
|
+
const lines = finding.lines ?? finding.section;
|
|
450
|
+
const location = `${displayPath(finding.file, options.root)}:${lines.first}${lines.last === lines.first ? "" : `–${lines.last}`}`;
|
|
451
|
+
return [
|
|
452
|
+
`## ${markdownText(location)}`,
|
|
453
|
+
finding.description,
|
|
454
|
+
`Level: **${finding.level}** · Confidence: ${finding.probability} · Threshold: ${finding.threshold}${finding.context === undefined ? "" : ` · Context: ${finding.context}`}`,
|
|
455
|
+
...(lacksContext(finding, sufficiencyThreshold)
|
|
456
|
+
? [
|
|
457
|
+
`> Warning: this file may not show enough to check this rule (context below ${sufficiencyThreshold}). Add missing context with an \`@adhere\` note.`,
|
|
458
|
+
]
|
|
459
|
+
: []),
|
|
460
|
+
`Excerpt starting at line ${finding.excerpt.start}:`,
|
|
461
|
+
fenced(finding.excerpt.lines.join("\n")),
|
|
462
|
+
...[finding.examples.good, finding.examples.bad].flatMap((example) =>
|
|
463
|
+
example === undefined ? [] : [`### ${example.word}`, fenced(example.code.trim())],
|
|
464
|
+
),
|
|
465
|
+
];
|
|
466
|
+
}),
|
|
467
|
+
].join("\n\n"),
|
|
468
|
+
),
|
|
469
|
+
"## Run summary",
|
|
470
|
+
...summary(result).map((line) => serialize(line, false)),
|
|
471
|
+
...blockedFiles(result, options.root)
|
|
472
|
+
.filter((line) => line.length > 0)
|
|
473
|
+
.map((line) => serialize(line, false)),
|
|
474
|
+
].join("\n\n");
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/** One file per violated rule in each requested format, with run counts retained as context. */
|
|
478
|
+
export const renderRuleReports = (
|
|
479
|
+
result: AuditResult,
|
|
480
|
+
formats: { readonly json: boolean; readonly md: boolean },
|
|
481
|
+
options: RenderOptions = {},
|
|
482
|
+
): ReadonlyMap<string, string> => {
|
|
483
|
+
const groups = Map.groupBy(result.findings, (finding) =>
|
|
484
|
+
shownId({ id: finding.rule, preset: finding.preset }),
|
|
485
|
+
);
|
|
486
|
+
return new Map(
|
|
487
|
+
Array.from(groups).flatMap(([id, findings]): Array<[string, string]> => {
|
|
488
|
+
const name = encodeURIComponent(id);
|
|
489
|
+
const report = { ...result, findings };
|
|
490
|
+
const files: Array<[string, string]> = [];
|
|
491
|
+
if (formats.json) files.push([`${name}.json`, `${renderJson(report, options)}\n`]);
|
|
492
|
+
if (formats.md) files.push([`${name}.md`, `${renderMarkdown(report, options)}\n`]);
|
|
493
|
+
return files;
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
};
|
|
497
|
+
|
|
401
498
|
/**
|
|
402
499
|
* What a run will do, before it asks: the files and rules found, the checks
|
|
403
500
|
* between them and how many the cache answers, then what judging the rest
|
package/src/workspaces.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
dependentFilesIn,
|
|
4
|
+
importSpecifiersIn,
|
|
5
|
+
packageOf,
|
|
6
|
+
workspacePackagesIn,
|
|
7
|
+
} from "./workspaces.ts";
|
|
3
8
|
|
|
4
9
|
const catalog = {
|
|
5
10
|
"@repo/foo": "packages/foo",
|
|
@@ -71,3 +76,34 @@ describe("file-local workspace packages", () => {
|
|
|
71
76
|
expect(workspacePackagesIn("import(`@repo/foo/${name}`)", catalog)).toEqual({});
|
|
72
77
|
});
|
|
73
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("dependent files", () => {
|
|
81
|
+
it("resolves relative specifiers to known files as written, by emitted extension, added extension, or index", () => {
|
|
82
|
+
const dependents = dependentFilesIn(
|
|
83
|
+
new Map([
|
|
84
|
+
["src/a.ts", importSpecifiersIn('import { b } from "./b.js"; export * from "./lib";')],
|
|
85
|
+
["src/b.ts", importSpecifiersIn('import "../util"; import x from "pkg/./b";')],
|
|
86
|
+
[
|
|
87
|
+
"src/lib/index.ts",
|
|
88
|
+
importSpecifiersIn('const self = require("./index.ts"); import("..");'),
|
|
89
|
+
],
|
|
90
|
+
["src/c.tsx", importSpecifiersIn('import { b } from "./b"; import "./missing";')],
|
|
91
|
+
["util.ts", []],
|
|
92
|
+
["src/index.ts", []],
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
expect(Object.fromEntries(dependents)).toEqual({
|
|
96
|
+
"src/b.ts": ["src/a.ts", "src/c.tsx"],
|
|
97
|
+
"src/lib/index.ts": ["src/a.ts"],
|
|
98
|
+
"util.ts": ["src/b.ts"],
|
|
99
|
+
"src/index.ts": ["src/lib/index.ts"],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("places a file in the deepest package directory that holds it", () => {
|
|
104
|
+
const catalog = { outer: "packages/outer", inner: "packages/outer/inner", other: "packages/o" };
|
|
105
|
+
expect(packageOf("packages/outer/inner/src/a.ts", catalog)).toBe("inner");
|
|
106
|
+
expect(packageOf("packages/outer/src/a.ts", catalog)).toBe("outer");
|
|
107
|
+
expect(packageOf("packages/outer-x/a.ts", catalog)).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/workspaces.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
+
import { posix } from "node:path";
|
|
2
3
|
import { scan, type Token } from "#highlight.ts";
|
|
3
4
|
|
|
4
5
|
/** A workspace's packages: each one's name, with its directory from the root. */
|
|
@@ -38,15 +39,12 @@ const specifierOf = (token: Token | undefined, templates = false): string | unde
|
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
* or import/require calls. Tokens keep comments,
|
|
43
|
-
* opaque; computed modules and imports inside
|
|
44
|
-
*
|
|
42
|
+
* Every module specifier named by this file's literal imports, type imports,
|
|
43
|
+
* re-exports, or import/require calls, once each, in order. Tokens keep comments,
|
|
44
|
+
* strings, regexes, and templates opaque; computed modules and imports inside
|
|
45
|
+
* template substitutions are not resolved.
|
|
45
46
|
*/
|
|
46
|
-
export const
|
|
47
|
-
code: string,
|
|
48
|
-
catalog: WorkspacePackages,
|
|
49
|
-
): WorkspacePackages => {
|
|
47
|
+
export const importSpecifiersIn = (code: string): ReadonlyArray<string> => {
|
|
50
48
|
const tokens = scan(code).flatMap((token) => {
|
|
51
49
|
if (token.kind === "comment" || token.text.trim() === "") return [];
|
|
52
50
|
// Highlighting groups punctuation; module clauses need individual delimiters.
|
|
@@ -54,11 +52,9 @@ export const workspacePackagesIn = (
|
|
|
54
52
|
? [...token.text].map((text): Token => ({ text }))
|
|
55
53
|
: [token];
|
|
56
54
|
});
|
|
57
|
-
const
|
|
55
|
+
const specifiers = new Set<string>();
|
|
58
56
|
const keep = (specifier: string | undefined) => {
|
|
59
|
-
if (specifier
|
|
60
|
-
const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
|
|
61
|
-
if (Object.hasOwn(catalog, name)) names.add(name);
|
|
57
|
+
if (specifier !== undefined) specifiers.add(specifier);
|
|
62
58
|
};
|
|
63
59
|
for (const [index, token] of tokens.entries()) {
|
|
64
60
|
if (tokens[index - 1]?.text === ".") continue;
|
|
@@ -91,6 +87,23 @@ export const workspacePackagesIn = (
|
|
|
91
87
|
if (part.kind === "keyword" && !["type", "as", "from"].includes(part.text)) break;
|
|
92
88
|
}
|
|
93
89
|
}
|
|
90
|
+
return [...specifiers];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Only packages this file imports, by `importSpecifiersIn`. Subpaths identify
|
|
95
|
+
* their canonical package, without walking the global catalog.
|
|
96
|
+
*/
|
|
97
|
+
export const workspacePackagesIn = (
|
|
98
|
+
code: string,
|
|
99
|
+
catalog: WorkspacePackages,
|
|
100
|
+
): WorkspacePackages => {
|
|
101
|
+
const names = new Set<string>();
|
|
102
|
+
for (const specifier of importSpecifiersIn(code)) {
|
|
103
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) continue;
|
|
104
|
+
const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
|
|
105
|
+
if (Object.hasOwn(catalog, name)) names.add(name);
|
|
106
|
+
}
|
|
94
107
|
return Object.fromEntries([...names].sort().map((name) => [name, catalog[name]!]));
|
|
95
108
|
};
|
|
96
109
|
|
|
@@ -230,3 +243,99 @@ export const workspacePackagesOf = Effect.fn("workspacePackagesOf")(function* (r
|
|
|
230
243
|
named.flat().sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
231
244
|
) as WorkspacePackages;
|
|
232
245
|
});
|
|
246
|
+
|
|
247
|
+
const DEPENDENCY_FIELDS = [
|
|
248
|
+
"dependencies",
|
|
249
|
+
"devDependencies",
|
|
250
|
+
"peerDependencies",
|
|
251
|
+
"optionalDependencies",
|
|
252
|
+
] as const;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* For each workspace package, the other workspace packages whose package.json
|
|
256
|
+
* depends on it, by name with directory, sorted. A package no other one names
|
|
257
|
+
* is left out.
|
|
258
|
+
*/
|
|
259
|
+
export const workspaceDependentsOf = Effect.fn("workspaceDependentsOf")(function* (
|
|
260
|
+
root: string,
|
|
261
|
+
catalog: WorkspacePackages,
|
|
262
|
+
) {
|
|
263
|
+
const fs = yield* FileSystem.FileSystem;
|
|
264
|
+
const path = yield* Path.Path;
|
|
265
|
+
const dependents = new Map<string, Map<string, string>>();
|
|
266
|
+
// Sequential over the sorted catalog, so each package's dependents are inserted sorted.
|
|
267
|
+
yield* Effect.forEach(Object.entries(catalog), ([name, directory]) =>
|
|
268
|
+
Effect.map(
|
|
269
|
+
fs
|
|
270
|
+
.readFileString(path.join(root, directory, "package.json"))
|
|
271
|
+
.pipe(Effect.orElseSucceed(() => "")),
|
|
272
|
+
(text) => {
|
|
273
|
+
const manifest = parsed(text) as Readonly<Record<string, unknown>> | undefined;
|
|
274
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
275
|
+
const named = manifest?.[field];
|
|
276
|
+
if (named === null || typeof named !== "object") continue;
|
|
277
|
+
for (const dependency of Object.keys(named)) {
|
|
278
|
+
if (dependency === name || !Object.hasOwn(catalog, dependency)) continue;
|
|
279
|
+
const found = dependents.get(dependency) ?? new Map<string, string>();
|
|
280
|
+
dependents.set(dependency, found.set(name, directory));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
return Object.fromEntries(
|
|
287
|
+
[...dependents].map(([name, found]) => [name, Object.fromEntries(found)]),
|
|
288
|
+
) as Readonly<Record<string, WorkspacePackages>>;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/** TypeScript source extensions an import resolves to, in the order tried. */
|
|
292
|
+
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
|
|
293
|
+
/** The source extensions an emitted extension in a specifier stands for. */
|
|
294
|
+
const EMITTED: Readonly<Record<string, ReadonlyArray<string>>> = {
|
|
295
|
+
".js": [".ts", ".tsx"],
|
|
296
|
+
".jsx": [".tsx"],
|
|
297
|
+
".mjs": [".mts"],
|
|
298
|
+
".cjs": [".cts"],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** The paths a relative specifier can name, most specific first. */
|
|
302
|
+
const candidatesOf = (target: string): ReadonlyArray<string> => {
|
|
303
|
+
const extension = posix.extname(target);
|
|
304
|
+
const emitted = EMITTED[extension] ?? [];
|
|
305
|
+
const stem = target.slice(0, target.length - extension.length);
|
|
306
|
+
return [
|
|
307
|
+
target,
|
|
308
|
+
...emitted.map((source) => `${stem}${source}`),
|
|
309
|
+
...SOURCE_EXTENSIONS.map((source) => `${target}${source}`),
|
|
310
|
+
...SOURCE_EXTENSIONS.map((source) => `${target}/index${source}`),
|
|
311
|
+
];
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* For each file, the files that import it by a relative specifier, sorted.
|
|
316
|
+
* Keys and values are `/`-separated paths from one root, as `imports` gives
|
|
317
|
+
* them; a specifier resolves only to a file `imports` has, by the first of its
|
|
318
|
+
* candidates found: as written, with `.js` as `.ts`, with a source extension
|
|
319
|
+
* added, then as a directory's index. Package and path-alias imports do not count.
|
|
320
|
+
*/
|
|
321
|
+
export const dependentFilesIn = (
|
|
322
|
+
imports: ReadonlyMap<string, ReadonlyArray<string>>,
|
|
323
|
+
): ReadonlyMap<string, ReadonlyArray<string>> => {
|
|
324
|
+
const dependents = new Map<string, Set<string>>();
|
|
325
|
+
for (const [importer, specifiers] of imports) {
|
|
326
|
+
for (const specifier of specifiers) {
|
|
327
|
+
if (specifier !== "." && specifier !== ".." && !/^\.\.?\//.test(specifier)) continue;
|
|
328
|
+
const target = posix.join(posix.dirname(importer), specifier);
|
|
329
|
+
const found = candidatesOf(target).find((candidate) => imports.has(candidate));
|
|
330
|
+
if (found === undefined || found === importer) continue;
|
|
331
|
+
dependents.set(found, (dependents.get(found) ?? new Set()).add(importer));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return new Map([...dependents].map(([file, found]) => [file, [...found].sort()]));
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** The workspace package a `/`-separated path from the root lies in: the one with the deepest directory holding it. */
|
|
338
|
+
export const packageOf = (file: string, catalog: WorkspacePackages): string | undefined =>
|
|
339
|
+
Object.entries(catalog)
|
|
340
|
+
.filter(([, directory]) => file.startsWith(`${directory}/`))
|
|
341
|
+
.sort(([, a], [, b]) => b.length - a.length)[0]?.[0];
|